From 5fef3115758cdd356934f97448a122b56673f121 Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Mon, 28 Sep 2026 00:53:45 +0900 Subject: [PATCH] fix(release): never roll the latest updater feed back to an older tag --- scripts/ci/lib/latest-feed-guard.mjs | 126 ++++++++++++++++++ scripts/ci/lib/latest-feed-guard.test.mjs | 152 ++++++++++++++++++++++ scripts/ci/publish-forgejo-release.mjs | 37 ++++-- 3 files changed, 303 insertions(+), 12 deletions(-) create mode 100644 scripts/ci/lib/latest-feed-guard.mjs create mode 100644 scripts/ci/lib/latest-feed-guard.test.mjs diff --git a/scripts/ci/lib/latest-feed-guard.mjs b/scripts/ci/lib/latest-feed-guard.mjs new file mode 100644 index 0000000..dabfe81 --- /dev/null +++ b/scripts/ci/lib/latest-feed-guard.mjs @@ -0,0 +1,126 @@ +// scripts/ci/lib/latest-feed-guard.mjs +// latest feed(electron-updater alias) 보호 정책. +// +// 버전별 경로(/)는 불변이라 어떤 순서로 게시돼도 안전하지만, 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 비교. ab → 양수. + * 같은 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, + * upload: (file: T) => Promise, + * }} 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 }} deps + * @returns {Promise} + */ +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()); +} diff --git a/scripts/ci/lib/latest-feed-guard.test.mjs b/scripts/ci/lib/latest-feed-guard.test.mjs new file mode 100644 index 0000000..8990500 --- /dev/null +++ b/scripts/ci/lib/latest-feed-guard.test.mjs @@ -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"); +}); diff --git a/scripts/ci/publish-forgejo-release.mjs b/scripts/ci/publish-forgejo-release.mjs index eb4b1fb..e6f9960 100644 --- a/scripts/ci/publish-forgejo-release.mjs +++ b/scripts/ci/publish-forgejo-release.mjs @@ -24,6 +24,7 @@ import { basename, join } from "node:path"; import process from "node:process"; import { fileURLToPath, URL } from "node:url"; import credentialHelpers from "../lib/credentials.cjs"; +import { publishLatestAlias, readPublishedFeedVersion } from "./lib/latest-feed-guard.mjs"; const { forgejoAuthorization } = credentialHelpers; @@ -110,28 +111,40 @@ 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 order = Number(isUpdateMetadata(a.name)) - Number(isUpdateMetadata(b.name)); return order || a.name.localeCompare(b.name); }); -for (const file of latestOrder) { - await uploadToRegistry(file, `${packageLatestUrl}/${encodeURIComponent(safeAssetName(file.name))}`, { - replace: true, - }); +const latestFeed = await publishLatestAlias({ + 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, + }), +}); +if (!latestFeed.update) { + process.stdout.write( + `WARNING: latest feed already serves ${latestFeed.publishedVersion} (newer than ${version}) — ` + + "kept latest.yml and update-policy.json unchanged.\n", + ); } -await uploadToRegistry( - { name: POLICY_FILENAME, path: policyPath }, - `${packageLatestUrl}/${POLICY_FILENAME}`, - { replace: true }, -); // 3) metadata 참조 검증 + 공개 URL 재검증 for (const file of latestOrder.filter((candidate) => isYamlUpdateMetadata(candidate.name))) { validateUpdateMetadataReferences(file, latestOrder); } -for (const name of ["latest.yml", POLICY_FILENAME]) { - await verifyPublicFile(`${packageLatestUrl}/${name}`); +if (latestFeed.update) { + for (const name of ["latest.yml", POLICY_FILENAME]) { + await verifyPublicFile(`${packageLatestUrl}/${name}`); + } +} else { + await verifyPublicFile(`${packageVersionedUrl}/latest.yml`); } // 4) Forgejo Release 생성/갱신 + 자산 첨부