fix(release): never roll the latest updater feed back to an older tag
This commit is contained in:
parent
524d390bc2
commit
5fef311575
3 changed files with 303 additions and 12 deletions
126
scripts/ci/lib/latest-feed-guard.mjs
Normal file
126
scripts/ci/lib/latest-feed-guard.mjs
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
// scripts/ci/lib/latest-feed-guard.mjs
|
||||||
|
// latest feed(electron-updater alias) 보호 정책.
|
||||||
|
//
|
||||||
|
// 버전별 경로(<version>/)는 불변이라 어떤 순서로 게시돼도 안전하지만, latest/ 경로는
|
||||||
|
// 모든 태그가 공유한다. 오래된 태그의 job이 늦게 끝나거나(재실행·GitLab mirror 재시도)
|
||||||
|
// 하면 latest.yml과 update-policy.json이 이전 버전으로 되돌아간다. 이 모듈은
|
||||||
|
// 1) 순수 정책 (버전 파싱·비교·게시 여부 결정)
|
||||||
|
// 2) 이미 게시된 latest.yml을 읽는 IO 어댑터 (fetch 주입)
|
||||||
|
// 를 분리해 publisher가 alias를 덮어쓰기 전에 판단할 수 있게 한다.
|
||||||
|
|
||||||
|
const SEMVER_PATTERN = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} value
|
||||||
|
* @returns {{ major: number, minor: number, patch: number, prerelease: string | null } | null}
|
||||||
|
*/
|
||||||
|
export function parseSemver(value) {
|
||||||
|
const match = SEMVER_PATTERN.exec(String(value ?? "").trim().replace(/^v/, ""));
|
||||||
|
if (!match) return null;
|
||||||
|
return {
|
||||||
|
major: Number(match[1]),
|
||||||
|
minor: Number(match[2]),
|
||||||
|
patch: Number(match[3]),
|
||||||
|
prerelease: match[4] ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SemVer 비교. a<b → 음수, a=b → 0, a>b → 양수.
|
||||||
|
* 같은 core면 정식 버전이 prerelease보다 크다. 파싱할 수 없으면 예외.
|
||||||
|
* @param {string} a
|
||||||
|
* @param {string} b
|
||||||
|
*/
|
||||||
|
export function compareSemver(a, b) {
|
||||||
|
const left = parseSemver(a);
|
||||||
|
const right = parseSemver(b);
|
||||||
|
if (!left || !right) throw new Error(`Cannot compare non-semver versions: ${a} vs ${b}`);
|
||||||
|
for (const key of /** @type {const} */ (["major", "minor", "patch"])) {
|
||||||
|
if (left[key] !== right[key]) return left[key] - right[key];
|
||||||
|
}
|
||||||
|
if (left.prerelease === right.prerelease) return 0;
|
||||||
|
if (left.prerelease === null) return 1;
|
||||||
|
if (right.prerelease === null) return -1;
|
||||||
|
return left.prerelease < right.prerelease ? -1 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* electron-builder update metadata(latest.yml)의 최상위 `version:` 값을 읽는다.
|
||||||
|
* @param {string} yamlText
|
||||||
|
* @returns {string | null}
|
||||||
|
*/
|
||||||
|
export function parseUpdateMetadataVersion(yamlText) {
|
||||||
|
const match = /^version:\s*["']?([^"'\s#]+)["']?\s*(?:#.*)?$/m.exec(String(yamlText ?? ""));
|
||||||
|
if (!match) return null;
|
||||||
|
return parseSemver(match[1]) ? match[1].replace(/^v/, "") : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* latest alias(latest.yml + update-policy.json)를 갱신할지 결정하는 순수 정책.
|
||||||
|
*
|
||||||
|
* - 게시된 feed가 없음(publishedVersion === undefined) → 갱신 (첫 게시)
|
||||||
|
* - 게시된 latest.yml을 파싱할 수 없음(null) → 갱신 (깨진 feed는 복구 대상)
|
||||||
|
* - 게시된 버전 > 이번 버전 → 건너뜀 (이전 태그의 재실행/지연 완료가 되돌리는 것 방지)
|
||||||
|
* - 게시된 버전 = 이번 버전 → 갱신 (같은 태그 재실행: 부분 실패한 alias/policy 복구, 바이트 동일)
|
||||||
|
* - 게시된 버전 < 이번 버전 → 갱신
|
||||||
|
*
|
||||||
|
* @param {{ publishingVersion: string, publishedVersion: string | null | undefined }} input
|
||||||
|
* @returns {{ update: boolean, reason: "no-feed" | "unreadable-feed" | "newer-published" | "same-version" | "older-published" }}
|
||||||
|
*/
|
||||||
|
export function decideLatestFeedUpdate({ publishingVersion, publishedVersion }) {
|
||||||
|
if (!parseSemver(publishingVersion)) {
|
||||||
|
throw new Error(`Publishing version is not semver: ${publishingVersion}`);
|
||||||
|
}
|
||||||
|
if (publishedVersion === undefined) return { update: true, reason: "no-feed" };
|
||||||
|
if (publishedVersion === null || !parseSemver(publishedVersion)) {
|
||||||
|
return { update: true, reason: "unreadable-feed" };
|
||||||
|
}
|
||||||
|
const order = compareSemver(publishedVersion, publishingVersion);
|
||||||
|
if (order > 0) return { update: false, reason: "newer-published" };
|
||||||
|
if (order === 0) return { update: true, reason: "same-version" };
|
||||||
|
return { update: true, reason: "older-published" };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* latest alias 갱신 유스케이스. IO는 포트로 주입받는다.
|
||||||
|
* 판단 결과가 "건너뜀"이면 어떤 파일도 업로드하지 않는다.
|
||||||
|
*
|
||||||
|
* @template T
|
||||||
|
* @param {{
|
||||||
|
* publishingVersion: string,
|
||||||
|
* files: readonly T[],
|
||||||
|
* readPublishedVersion: () => Promise<string | null | undefined>,
|
||||||
|
* upload: (file: T) => Promise<void>,
|
||||||
|
* }} input files는 게시 순서대로 (설치 자산 → metadata → policy)
|
||||||
|
* @returns {Promise<{ update: boolean, reason: string, publishedVersion: string | null | undefined }>}
|
||||||
|
*/
|
||||||
|
export async function publishLatestAlias({ publishingVersion, files, readPublishedVersion, upload }) {
|
||||||
|
const publishedVersion = await readPublishedVersion();
|
||||||
|
const decision = decideLatestFeedUpdate({ publishingVersion, publishedVersion });
|
||||||
|
if (decision.update) {
|
||||||
|
for (const file of files) await upload(file);
|
||||||
|
}
|
||||||
|
return { ...decision, publishedVersion };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이미 게시된 latest.yml의 버전을 읽는 IO 어댑터.
|
||||||
|
* - 404 → undefined (feed 없음)
|
||||||
|
* - 200 → 파싱된 버전 또는 null (파싱 불가)
|
||||||
|
* - 그 외 HTTP/네트워크 오류 → 예외 (fail-closed: 판단할 수 없으면 alias를 건드리지 않는다)
|
||||||
|
*
|
||||||
|
* @param {{ url: string, fetchImpl: (url: string, init?: RequestInit) => Promise<Response> }} deps
|
||||||
|
* @returns {Promise<string | null | undefined>}
|
||||||
|
*/
|
||||||
|
export async function readPublishedFeedVersion({ url, fetchImpl }) {
|
||||||
|
const separator = url.includes("?") ? "&" : "?";
|
||||||
|
const response = await fetchImpl(`${url}${separator}ts=${Date.now()}`, { cache: "no-store" });
|
||||||
|
if (response.status === 404) return undefined;
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`Cannot read the published latest feed (${url}): HTTP ${response.status}. ` +
|
||||||
|
"Refusing to overwrite the latest alias without knowing its version.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return parseUpdateMetadataVersion(await response.text());
|
||||||
|
}
|
||||||
152
scripts/ci/lib/latest-feed-guard.test.mjs
Normal file
152
scripts/ci/lib/latest-feed-guard.test.mjs
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
// node --test scripts/ci/lib/latest-feed-guard.test.mjs
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { test } from "node:test";
|
||||||
|
import { fileURLToPath, URL } from "node:url";
|
||||||
|
import {
|
||||||
|
compareSemver,
|
||||||
|
decideLatestFeedUpdate,
|
||||||
|
parseUpdateMetadataVersion,
|
||||||
|
publishLatestAlias,
|
||||||
|
readPublishedFeedVersion,
|
||||||
|
} from "./latest-feed-guard.mjs";
|
||||||
|
|
||||||
|
const LATEST_YML_191 = [
|
||||||
|
"version: 1.9.1",
|
||||||
|
"files:",
|
||||||
|
" - url: D3RO-Voice-Setup-1.9.1.exe",
|
||||||
|
" sha512: abc",
|
||||||
|
" size: 123",
|
||||||
|
"path: D3RO-Voice-Setup-1.9.1.exe",
|
||||||
|
"sha512: abc",
|
||||||
|
"releaseDate: '2026-09-20T00:00:00.000Z'",
|
||||||
|
"",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
test("parseUpdateMetadataVersion reads the top-level version of electron-builder metadata", () => {
|
||||||
|
assert.equal(parseUpdateMetadataVersion(LATEST_YML_191), "1.9.1");
|
||||||
|
assert.equal(parseUpdateMetadataVersion("version: '1.10.0'\n"), "1.10.0");
|
||||||
|
assert.equal(parseUpdateMetadataVersion('version: "2.0.0-beta.1"\r\n'), "2.0.0-beta.1");
|
||||||
|
assert.equal(parseUpdateMetadataVersion("files: []\n"), null);
|
||||||
|
assert.equal(parseUpdateMetadataVersion("version: garbage\n"), null);
|
||||||
|
assert.equal(parseUpdateMetadataVersion(""), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("compareSemver orders numerically, not lexically, and ranks stable above prerelease", () => {
|
||||||
|
assert.ok(compareSemver("1.10.0", "1.9.1") > 0);
|
||||||
|
assert.ok(compareSemver("1.9.0", "1.9.1") < 0);
|
||||||
|
assert.equal(compareSemver("v1.9.1", "1.9.1"), 0);
|
||||||
|
assert.ok(compareSemver("2.0.0", "2.0.0-beta.1") > 0);
|
||||||
|
assert.ok(compareSemver("2.0.0-alpha.1", "2.0.0-beta.1") < 0);
|
||||||
|
assert.throws(() => compareSemver("x", "1.0.0"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("decideLatestFeedUpdate refuses to roll the latest alias back to an older tag", () => {
|
||||||
|
assert.deepEqual(decideLatestFeedUpdate({ publishingVersion: "1.9.0", publishedVersion: "1.9.1" }), {
|
||||||
|
update: false,
|
||||||
|
reason: "newer-published",
|
||||||
|
});
|
||||||
|
assert.deepEqual(decideLatestFeedUpdate({ publishingVersion: "1.9.1", publishedVersion: "1.9.0" }), {
|
||||||
|
update: true,
|
||||||
|
reason: "older-published",
|
||||||
|
});
|
||||||
|
assert.deepEqual(decideLatestFeedUpdate({ publishingVersion: "1.9.1", publishedVersion: "1.9.1" }), {
|
||||||
|
update: true,
|
||||||
|
reason: "same-version",
|
||||||
|
});
|
||||||
|
assert.deepEqual(decideLatestFeedUpdate({ publishingVersion: "1.9.1", publishedVersion: undefined }), {
|
||||||
|
update: true,
|
||||||
|
reason: "no-feed",
|
||||||
|
});
|
||||||
|
assert.deepEqual(decideLatestFeedUpdate({ publishingVersion: "1.9.1", publishedVersion: null }), {
|
||||||
|
update: true,
|
||||||
|
reason: "unreadable-feed",
|
||||||
|
});
|
||||||
|
assert.throws(() => decideLatestFeedUpdate({ publishingVersion: "nope", publishedVersion: "1.0.0" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
function fakeFetch(status, body = "") {
|
||||||
|
const calls = [];
|
||||||
|
const impl = async (url, init) => {
|
||||||
|
calls.push({ url, init });
|
||||||
|
return new Response(status === 404 ? "not found" : body, { status });
|
||||||
|
};
|
||||||
|
return { impl, calls };
|
||||||
|
}
|
||||||
|
|
||||||
|
test("readPublishedFeedVersion maps 404 to no feed, 200 to the parsed version, and fails closed otherwise", async () => {
|
||||||
|
const missing = fakeFetch(404);
|
||||||
|
assert.equal(await readPublishedFeedVersion({ url: "https://x/latest/latest.yml", fetchImpl: missing.impl }), undefined);
|
||||||
|
assert.match(missing.calls[0].url, /^https:\/\/x\/latest\/latest\.yml\?ts=\d+$/);
|
||||||
|
assert.equal(missing.calls[0].init.cache, "no-store");
|
||||||
|
|
||||||
|
const present = fakeFetch(200, LATEST_YML_191);
|
||||||
|
assert.equal(await readPublishedFeedVersion({ url: "https://x/latest/latest.yml", fetchImpl: present.impl }), "1.9.1");
|
||||||
|
|
||||||
|
const broken = fakeFetch(200, "not yaml");
|
||||||
|
assert.equal(await readPublishedFeedVersion({ url: "https://x/latest/latest.yml", fetchImpl: broken.impl }), null);
|
||||||
|
|
||||||
|
const failing = fakeFetch(502, "bad gateway");
|
||||||
|
await assert.rejects(
|
||||||
|
readPublishedFeedVersion({ url: "https://x/latest/latest.yml", fetchImpl: failing.impl }),
|
||||||
|
/HTTP 502/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("regression: retrying an older tag's job does not overwrite latest.yml or update-policy.json", async () => {
|
||||||
|
const uploaded = [];
|
||||||
|
const result = await publishLatestAlias({
|
||||||
|
publishingVersion: "1.9.0",
|
||||||
|
files: [{ name: "D3RO-Voice-Setup-1.9.0.exe" }, { name: "latest.yml" }, { name: "update-policy.json" }],
|
||||||
|
readPublishedVersion: async () => "1.9.1",
|
||||||
|
upload: async (file) => {
|
||||||
|
uploaded.push(file.name);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(result.update, false);
|
||||||
|
assert.equal(result.publishedVersion, "1.9.1");
|
||||||
|
assert.deepEqual(uploaded, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("publishLatestAlias uploads every file in the given order for a newer tag", async () => {
|
||||||
|
const uploaded = [];
|
||||||
|
const result = await publishLatestAlias({
|
||||||
|
publishingVersion: "1.9.1",
|
||||||
|
files: [{ name: "setup.exe" }, { name: "latest.yml" }, { name: "update-policy.json" }],
|
||||||
|
readPublishedVersion: async () => "1.9.0",
|
||||||
|
upload: async (file) => {
|
||||||
|
uploaded.push(file.name);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(result.update, true);
|
||||||
|
assert.deepEqual(uploaded, ["setup.exe", "latest.yml", "update-policy.json"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("publishLatestAlias uploads nothing when the published version cannot be read", async () => {
|
||||||
|
const uploaded = [];
|
||||||
|
await assert.rejects(
|
||||||
|
publishLatestAlias({
|
||||||
|
publishingVersion: "1.9.1",
|
||||||
|
files: [{ name: "latest.yml" }],
|
||||||
|
readPublishedVersion: async () => {
|
||||||
|
throw new Error("HTTP 500");
|
||||||
|
},
|
||||||
|
upload: async (file) => {
|
||||||
|
uploaded.push(file.name);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
/HTTP 500/,
|
||||||
|
);
|
||||||
|
assert.deepEqual(uploaded, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the Forgejo publisher routes every latest-alias upload through the guard", () => {
|
||||||
|
const source = readFileSync(
|
||||||
|
fileURLToPath(new URL("../publish-forgejo-release.mjs", import.meta.url)),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
assert.match(source, /publishLatestAlias\(/);
|
||||||
|
// latest/ 경로에 직접 업로드하는 호출이 guard 밖에 남아 있으면 안 된다.
|
||||||
|
const directLatestUploads = [...source.matchAll(/uploadToRegistry\([^;]*?packageLatestUrl/gs)].length;
|
||||||
|
assert.equal(directLatestUploads, 1, "only the guarded upload callback may target packageLatestUrl");
|
||||||
|
});
|
||||||
|
|
@ -24,6 +24,7 @@ import { basename, join } from "node:path";
|
||||||
import process from "node:process";
|
import process from "node:process";
|
||||||
import { fileURLToPath, URL } from "node:url";
|
import { fileURLToPath, URL } from "node:url";
|
||||||
import credentialHelpers from "../lib/credentials.cjs";
|
import credentialHelpers from "../lib/credentials.cjs";
|
||||||
|
import { publishLatestAlias, readPublishedFeedVersion } from "./lib/latest-feed-guard.mjs";
|
||||||
|
|
||||||
const { forgejoAuthorization } = credentialHelpers;
|
const { forgejoAuthorization } = credentialHelpers;
|
||||||
|
|
||||||
|
|
@ -110,29 +111,41 @@ for (const file of sorted) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2) latest feed 갱신 — 설치 자산 먼저, metadata 마지막
|
// 2) latest feed 갱신 — 설치 자산 먼저, metadata 마지막, 정책 파일 맨 마지막.
|
||||||
|
// 이미 더 새 버전이 latest에 게시돼 있으면(이전 태그 재실행·mirror 지연 완료)
|
||||||
|
// alias와 update-policy.json은 건드리지 않는다. 버전별 경로와 Release는 계속 게시한다.
|
||||||
const latestOrder = [...sorted].sort((a, b) => {
|
const latestOrder = [...sorted].sort((a, b) => {
|
||||||
const order = Number(isUpdateMetadata(a.name)) - Number(isUpdateMetadata(b.name));
|
const order = Number(isUpdateMetadata(a.name)) - Number(isUpdateMetadata(b.name));
|
||||||
return order || a.name.localeCompare(b.name);
|
return order || a.name.localeCompare(b.name);
|
||||||
});
|
});
|
||||||
for (const file of latestOrder) {
|
const latestFeed = await publishLatestAlias({
|
||||||
await uploadToRegistry(file, `${packageLatestUrl}/${encodeURIComponent(safeAssetName(file.name))}`, {
|
publishingVersion: version,
|
||||||
|
files: [...latestOrder, { name: POLICY_FILENAME, path: policyPath }],
|
||||||
|
readPublishedVersion: () =>
|
||||||
|
readPublishedFeedVersion({ url: `${packageLatestUrl}/latest.yml`, fetchImpl: forgejoFetch }),
|
||||||
|
upload: (file) =>
|
||||||
|
uploadToRegistry(file, `${packageLatestUrl}/${encodeURIComponent(safeAssetName(file.name))}`, {
|
||||||
replace: true,
|
replace: true,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
}
|
if (!latestFeed.update) {
|
||||||
await uploadToRegistry(
|
process.stdout.write(
|
||||||
{ name: POLICY_FILENAME, path: policyPath },
|
`WARNING: latest feed already serves ${latestFeed.publishedVersion} (newer than ${version}) — ` +
|
||||||
`${packageLatestUrl}/${POLICY_FILENAME}`,
|
"kept latest.yml and update-policy.json unchanged.\n",
|
||||||
{ replace: true },
|
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// 3) metadata 참조 검증 + 공개 URL 재검증
|
// 3) metadata 참조 검증 + 공개 URL 재검증
|
||||||
for (const file of latestOrder.filter((candidate) => isYamlUpdateMetadata(candidate.name))) {
|
for (const file of latestOrder.filter((candidate) => isYamlUpdateMetadata(candidate.name))) {
|
||||||
validateUpdateMetadataReferences(file, latestOrder);
|
validateUpdateMetadataReferences(file, latestOrder);
|
||||||
}
|
}
|
||||||
|
if (latestFeed.update) {
|
||||||
for (const name of ["latest.yml", POLICY_FILENAME]) {
|
for (const name of ["latest.yml", POLICY_FILENAME]) {
|
||||||
await verifyPublicFile(`${packageLatestUrl}/${name}`);
|
await verifyPublicFile(`${packageLatestUrl}/${name}`);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
await verifyPublicFile(`${packageVersionedUrl}/latest.yml`);
|
||||||
|
}
|
||||||
|
|
||||||
// 4) Forgejo Release 생성/갱신 + 자산 첨부
|
// 4) Forgejo Release 생성/갱신 + 자산 첨부
|
||||||
const description = await buildReleaseDescription();
|
const description = await buildReleaseDescription();
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue