From e015f43de17090eb8ad0fdb971554788c525d5ff Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Mon, 28 Sep 2026 02:16:18 +0900 Subject: [PATCH] fix(release): stop overwriting immutable Forgejo version assets on re-run --- scripts/ci/lib/immutable-package-guard.mjs | 134 +++++++++++++ .../ci/lib/immutable-package-guard.test.mjs | 184 ++++++++++++++++++ scripts/ci/publish-forgejo-release.mjs | 117 ++++++----- 3 files changed, 387 insertions(+), 48 deletions(-) create mode 100644 scripts/ci/lib/immutable-package-guard.mjs create mode 100644 scripts/ci/lib/immutable-package-guard.test.mjs diff --git a/scripts/ci/lib/immutable-package-guard.mjs b/scripts/ci/lib/immutable-package-guard.mjs new file mode 100644 index 0000000..05f7238 --- /dev/null +++ b/scripts/ci/lib/immutable-package-guard.mjs @@ -0,0 +1,134 @@ +// scripts/ci/lib/immutable-package-guard.mjs +// 버전별(immutable) generic package 경로 보호 정책. +// +// Forgejo generic registry는 HEAD를 405로 거부하고 파일 해시도 헤더로 주지 않는다 +// (docs/deployment/unsigned-distribution.md §1). HEAD로 존재 여부를 보던 예전 가드는 +// 항상 "없음"으로 판정해 죽어 있었고, 업로드가 409를 받으면 DELETE 후 재PUT으로 +// 이미 게시된 버전의 바이트를 조용히 바꿨다. +// +// 이 모듈은 +// 1) 순수 정책: 로컬 sha256과 원격 sha256을 비교해 업로드/건너뜀/충돌을 분류 +// 2) IO 어댑터: 패키지 파일 목록 API(sha256 제공)를 읽는 함수 (fetch 주입) +// 3) 유스케이스: 불변 경로에 한 파일을 올리는 절차 (DELETE를 절대 하지 않는다) +// 를 분리해 publisher가 같은 버전 재실행을 안전하게 처리하게 한다. + +/** + * @typedef {{ name: string, sha256: string }} LocalAssetDigest + * @typedef {ReadonlyMap} RemoteDigests 원격 파일명 → sha256(hex, 소문자) + * @typedef {(url: string, init?: RequestInit) => Promise} FetchLike + */ + +/** + * @param {string | null | undefined} value + * @returns {string} + */ +function normalizeDigest(value) { + return String(value ?? "").trim().toLowerCase(); +} + +/** + * 순수 정책: 버전별 경로에 올릴 파일을 분류한다. + * - 원격에 없음 → upload + * - 원격 sha256 == 로컬 sha256 → identical (재실행/재시도: 건너뜀) + * - 원격 sha256 != 로컬 sha256 → conflicting (불변 위반: 중단해야 함) + * + * @param {{ localFiles: readonly LocalAssetDigest[], remoteDigests: RemoteDigests }} input + * @returns {{ upload: LocalAssetDigest[], identical: LocalAssetDigest[], conflicting: Array }} + */ +export function classifyImmutableAssets({ localFiles, remoteDigests }) { + const upload = []; + const identical = []; + const conflicting = []; + for (const file of localFiles) { + if (!remoteDigests.has(file.name)) { + upload.push(file); + continue; + } + const remoteSha256 = normalizeDigest(remoteDigests.get(file.name)); + if (remoteSha256 === normalizeDigest(file.sha256)) identical.push(file); + else conflicting.push({ ...file, remoteSha256 }); + } + return { upload, identical, conflicting }; +} + +/** + * 불변 위반이 있으면 fail-closed 예외를 던진다. + * @param {{ tag: string, conflicting: ReadonlyArray }} input + */ +export function assertNoImmutableConflicts({ tag, conflicting }) { + if (conflicting.length === 0) return; + const details = conflicting + .map((file) => `${file.name}: remote sha256 ${file.remoteSha256 || "(unknown)"}, local sha256 ${file.sha256}`) + .join("; "); + throw new Error( + `${tag} is already published with different bytes (${details}). Releases are immutable — ` + + "lift the version and publish a new tag instead of re-publishing this one.", + ); +} + +/** + * IO 어댑터: 패키지 버전의 파일 목록 API에서 파일별 sha256을 읽는다. + * GET {origin}/api/v1/packages/{owner}/generic/{package}/{version}/files + * - 404 → 빈 Map (아직 게시되지 않은 버전) + * - 200 → 파일명 → sha256 + * - 그 외 HTTP/네트워크 오류 → 예외 (fail-closed: 판단할 수 없으면 게시하지 않는다) + * + * @param {{ filesApiUrl: string, fetchImpl: FetchLike }} deps + * @returns {Promise>} + */ +export async function readRemotePackageDigests({ filesApiUrl, fetchImpl }) { + const response = await fetchImpl(filesApiUrl, { cache: "no-store" }); + if (response.status === 404) return new Map(); + if (!response.ok) { + throw new Error( + `Cannot list published package files (${filesApiUrl}): HTTP ${response.status}. ` + + "Refusing to publish without knowing what the immutable version path already holds.", + ); + } + const payload = await response.json(); + if (!Array.isArray(payload)) { + throw new Error(`Unexpected package files payload from ${filesApiUrl}: expected an array.`); + } + const digests = new Map(); + for (const entry of payload) { + if (entry && typeof entry.name === "string" && typeof entry.sha256 === "string") { + digests.set(entry.name, normalizeDigest(entry.sha256)); + } + } + return digests; +} + +/** + * 유스케이스: 불변(버전별) 경로에 파일 하나를 올린다. + * - PUT 성공 → "uploaded" + * - 409(동시 실행 등으로 그 사이 누가 올림) → 원격 sha256을 다시 읽어 같으면 "verified-existing", + * 다르거나 확인할 수 없으면 예외. **DELETE는 절대 하지 않는다.** + * - 그 외 실패 → 예외 + * + * @param {{ + * url: string, + * name: string, + * sha256: string, + * body: BodyInit, + * fetchImpl: FetchLike, + * readRemoteDigest: () => Promise, + * }} input + * @returns {Promise<"uploaded" | "verified-existing">} + */ +export async function uploadImmutableAsset({ url, name, sha256, body, fetchImpl, readRemoteDigest }) { + const response = await fetchImpl(url, { + method: "PUT", + headers: { "Content-Type": "application/octet-stream" }, + body, + }); + if (response.ok) return "uploaded"; + if (response.status === 409) { + const remote = normalizeDigest(await readRemoteDigest()); + if (remote && remote === normalizeDigest(sha256)) return "verified-existing"; + throw new Error( + `registry upload refused for immutable ${name}: HTTP 409 and remote sha256 ` + + `${remote || "(unknown)"} != local ${sha256}. Published versions are never overwritten.`, + ); + } + throw new Error(`registry upload failed for ${name}: HTTP ${response.status} ${await response.text()}`); +} diff --git a/scripts/ci/lib/immutable-package-guard.test.mjs b/scripts/ci/lib/immutable-package-guard.test.mjs new file mode 100644 index 0000000..788570f --- /dev/null +++ b/scripts/ci/lib/immutable-package-guard.test.mjs @@ -0,0 +1,184 @@ +// node --test scripts/ci/lib/immutable-package-guard.test.mjs +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + assertNoImmutableConflicts, + classifyImmutableAssets, + readRemotePackageDigests, + uploadImmutableAsset, +} from "./immutable-package-guard.mjs"; + +const SHA_A = "a".repeat(64); +const SHA_B = "b".repeat(64); +const FILES_API = "https://git.example.test/api/v1/packages/o/generic/d3ro-voice/1.9.1/files"; +const ASSET_URL = "https://git.example.test/api/packages/o/generic/d3ro-voice/1.9.1/D3RO-Voice-Setup-1.9.1.exe"; + +/** + * Forgejo generic registry의 실측 동작을 흉내 낸다: + * HEAD → 405, 이미 있는 경로에 PUT → 409, 파일 목록 API → sha256. + */ +function fakeRegistry(existing) { + const calls = []; + const store = new Map(Object.entries(existing)); + const fetchImpl = async (url, init = {}) => { + const method = init.method ?? "GET"; + calls.push({ method, url }); + if (method === "HEAD") return new Response(null, { status: 405 }); + if (url.endsWith("/files")) { + if (store.size === 0) return new Response("not found", { status: 404 }); + const body = [...store.entries()].map(([name, sha256]) => ({ name, sha256 })); + return new Response(JSON.stringify(body), { status: 200 }); + } + const name = decodeURIComponent(url.split("/").pop()); + if (method === "PUT") { + if (store.has(name)) return new Response("conflict", { status: 409 }); + store.set(name, SHA_A); + return new Response(null, { status: 201 }); + } + if (method === "DELETE") { + store.delete(name); + return new Response(null, { status: 204 }); + } + return new Response("unexpected", { status: 500 }); + }; + return { fetchImpl, calls, store }; +} + +test("classifyImmutableAssets splits new, identical and conflicting assets by sha256", () => { + const plan = classifyImmutableAssets({ + localFiles: [ + { name: "new.exe", sha256: SHA_A }, + { name: "same.exe", sha256: SHA_A.toUpperCase() }, + { name: "resigned.exe", sha256: SHA_A }, + ], + remoteDigests: new Map([ + ["same.exe", SHA_A], + ["resigned.exe", SHA_B], + ]), + }); + assert.deepEqual(plan.upload.map((file) => file.name), ["new.exe"]); + assert.deepEqual(plan.identical.map((file) => file.name), ["same.exe"]); + assert.deepEqual(plan.conflicting, [{ name: "resigned.exe", sha256: SHA_A, remoteSha256: SHA_B }]); +}); + +test("same-size re-signed installer is a conflict, not 'verified existing'", () => { + // 예전 가드는 content-length만 비교했다: 같은 크기의 재서명 바이너리를 통과시켰다. + const plan = classifyImmutableAssets({ + localFiles: [{ name: "D3RO-Voice-Setup-1.9.1.exe", sha256: SHA_A }], + remoteDigests: new Map([["D3RO-Voice-Setup-1.9.1.exe", SHA_B]]), + }); + assert.equal(plan.conflicting.length, 1); + assert.throws( + () => assertNoImmutableConflicts({ tag: "v1.9.1", conflicting: plan.conflicting }), + /v1\.9\.1 is already published with different bytes.*Releases are immutable/, + ); +}); + +test("assertNoImmutableConflicts is a no-op when nothing conflicts", () => { + assert.doesNotThrow(() => assertNoImmutableConflicts({ tag: "v1.9.1", conflicting: [] })); +}); + +test("readRemotePackageDigests detects published assets even though the registry rejects HEAD", async () => { + const registry = fakeRegistry({ "D3RO-Voice-Setup-1.9.1.exe": SHA_B.toUpperCase() }); + const digests = await readRemotePackageDigests({ filesApiUrl: FILES_API, fetchImpl: registry.fetchImpl }); + assert.equal(digests.get("D3RO-Voice-Setup-1.9.1.exe"), SHA_B); + assert.ok(registry.calls.every((call) => call.method !== "HEAD")); +}); + +test("readRemotePackageDigests treats 404 as an unpublished version", async () => { + const registry = fakeRegistry({}); + const digests = await readRemotePackageDigests({ filesApiUrl: FILES_API, fetchImpl: registry.fetchImpl }); + assert.equal(digests.size, 0); +}); + +test("readRemotePackageDigests fails closed on other HTTP errors and bad payloads", async () => { + await assert.rejects( + readRemotePackageDigests({ + filesApiUrl: FILES_API, + fetchImpl: async () => new Response("boom", { status: 502 }), + }), + /HTTP 502/, + ); + await assert.rejects( + readRemotePackageDigests({ + filesApiUrl: FILES_API, + fetchImpl: async () => new Response(JSON.stringify({ oops: true }), { status: 200 }), + }), + /expected an array/, + ); +}); + +test("uploadImmutableAsset uploads a new asset with a single PUT", async () => { + const registry = fakeRegistry({}); + const outcome = await uploadImmutableAsset({ + url: ASSET_URL, + name: "D3RO-Voice-Setup-1.9.1.exe", + sha256: SHA_A, + body: "bytes", + fetchImpl: registry.fetchImpl, + readRemoteDigest: async () => undefined, + }); + assert.equal(outcome, "uploaded"); + assert.deepEqual(registry.calls.map((call) => call.method), ["PUT"]); +}); + +test("uploadImmutableAsset never DELETEs an existing immutable asset on 409 (re-run with re-signed bytes)", async () => { + const registry = fakeRegistry({ "D3RO-Voice-Setup-1.9.1.exe": SHA_B }); + await assert.rejects( + uploadImmutableAsset({ + url: ASSET_URL, + name: "D3RO-Voice-Setup-1.9.1.exe", + sha256: SHA_A, + body: "resigned-bytes", + fetchImpl: registry.fetchImpl, + readRemoteDigest: async () => SHA_B, + }), + /HTTP 409.*never overwritten/, + ); + assert.ok(registry.calls.every((call) => call.method !== "DELETE")); + assert.equal(registry.store.get("D3RO-Voice-Setup-1.9.1.exe"), SHA_B); +}); + +test("uploadImmutableAsset accepts a 409 when the remote bytes are identical (concurrent retry)", async () => { + const registry = fakeRegistry({ "D3RO-Voice-Setup-1.9.1.exe": SHA_A }); + const outcome = await uploadImmutableAsset({ + url: ASSET_URL, + name: "D3RO-Voice-Setup-1.9.1.exe", + sha256: SHA_A, + body: "bytes", + fetchImpl: registry.fetchImpl, + readRemoteDigest: async () => SHA_A.toUpperCase(), + }); + assert.equal(outcome, "verified-existing"); + assert.ok(registry.calls.every((call) => call.method !== "DELETE")); +}); + +test("uploadImmutableAsset fails closed on 409 when the remote digest cannot be read", async () => { + const registry = fakeRegistry({ "D3RO-Voice-Setup-1.9.1.exe": SHA_A }); + await assert.rejects( + uploadImmutableAsset({ + url: ASSET_URL, + name: "D3RO-Voice-Setup-1.9.1.exe", + sha256: SHA_A, + body: "bytes", + fetchImpl: registry.fetchImpl, + readRemoteDigest: async () => undefined, + }), + /\(unknown\)/, + ); + assert.ok(registry.calls.every((call) => call.method !== "DELETE")); +}); + +test("uploadImmutableAsset surfaces non-409 failures", async () => { + await assert.rejects( + uploadImmutableAsset({ + url: ASSET_URL, + name: "x.exe", + sha256: SHA_A, + body: "bytes", + fetchImpl: async () => new Response("too large", { status: 413 }), + readRemoteDigest: async () => undefined, + }), + /HTTP 413 too large/, + ); +}); diff --git a/scripts/ci/publish-forgejo-release.mjs b/scripts/ci/publish-forgejo-release.mjs index e6f9960..47e7add 100644 --- a/scripts/ci/publish-forgejo-release.mjs +++ b/scripts/ci/publish-forgejo-release.mjs @@ -24,6 +24,12 @@ import { basename, join } from "node:path"; import process from "node:process"; import { fileURLToPath, URL } from "node:url"; import credentialHelpers from "../lib/credentials.cjs"; +import { + assertNoImmutableConflicts, + classifyImmutableAssets, + readRemotePackageDigests, + uploadImmutableAsset, +} from "./lib/immutable-package-guard.mjs"; import { publishLatestAlias, readPublishedFeedVersion } from "./lib/latest-feed-guard.mjs"; const { forgejoAuthorization } = credentialHelpers; @@ -44,6 +50,10 @@ if (!/^\d+\.\d+\.\d+$/.test(version)) { const { origin, owner, repo } = parseRepo(resolveRepoUrl()); const packageVersionedUrl = `${origin}/api/packages/${owner}/generic/${PACKAGE_NAME}/${version}`; const packageLatestUrl = `${origin}/api/packages/${owner}/generic/${PACKAGE_NAME}/latest`; +// 패키지 파일 목록 API는 파일별 sha256을 준다 (generic registry는 HEAD 405, 해시 헤더 없음). +const packageFilesApiBase = `${origin}/api/v1/packages/${owner}/generic/${PACKAGE_NAME}`; +const packageVersionedFilesApiUrl = `${packageFilesApiBase}/${encodeURIComponent(version)}/files`; +const packageLatestFilesApiUrl = `${packageFilesApiBase}/latest/files`; const releasesApiUrl = `${origin}/api/v1/repos/${owner}/${repo}/releases`; const authorization = dryRun && !process.env.FORGEJO_TOKEN && !process.env.FORGEJO_USERNAME @@ -100,15 +110,39 @@ if (dryRun) { } // 0) 이미 게시된 버전은 재게시하지 않는다 (SemVer 동일 버전 재릴리스 금지). -// 버전별 경로에 같은 크기의 자산이 있으면 재시도/재실행으로 보고 통과시키고, -// 다른 바이트를 가진 자산이 있으면 fail-closed로 중단한다. -await assertVersionNotRepublished(sorted); +// 버전별 경로의 원격 sha256이 로컬과 같으면 재시도/재실행으로 보고 건너뛰고, +// 다른 바이트(재빌드·재서명된 설치본 등)가 있으면 어떤 파일도 건드리기 전에 fail-closed로 중단한다. +const readVersionedDigests = () => + readRemotePackageDigests({ filesApiUrl: packageVersionedFilesApiUrl, fetchImpl: forgejoFetch }); +const versionedAssets = sorted.map((file) => ({ + file, + name: safeAssetName(file.name), + sha256: sha256ByFile.get(file.name), +})); +const versionedPlan = classifyImmutableAssets({ + localFiles: versionedAssets, + remoteDigests: await readVersionedDigests(), +}); +assertNoImmutableConflicts({ tag, conflicting: versionedPlan.conflicting }); +for (const asset of versionedPlan.identical) { + process.stdout.write(` verified existing immutable ${asset.name} (sha256 ${asset.sha256})\n`); +} -// 1) 버전별(immutable) 패키지 업로드 -for (const file of sorted) { - await uploadToRegistry(file, `${packageVersionedUrl}/${encodeURIComponent(safeAssetName(file.name))}`, { - immutable: true, +// 1) 버전별(immutable) 패키지 업로드 — 409여도 DELETE/덮어쓰기는 하지 않는다. +for (const asset of versionedPlan.upload) { + const outcome = await uploadImmutableAsset({ + url: `${packageVersionedUrl}/${encodeURIComponent(asset.name)}`, + name: asset.name, + sha256: asset.sha256, + body: await readFile(asset.file.path), + fetchImpl: forgejoFetch, + readRemoteDigest: async () => (await readVersionedDigests()).get(asset.name), }); + process.stdout.write( + outcome === "uploaded" + ? ` uploaded ${asset.name}\n` + : ` verified existing immutable ${asset.name} (sha256 ${asset.sha256})\n`, + ); } // 2) latest feed 갱신 — 설치 자산 먼저, metadata 마지막, 정책 파일 맨 마지막. @@ -124,9 +158,7 @@ const latestFeed = await publishLatestAlias({ readPublishedVersion: () => readPublishedFeedVersion({ url: `${packageLatestUrl}/latest.yml`, fetchImpl: forgejoFetch }), upload: (file) => - uploadToRegistry(file, `${packageLatestUrl}/${encodeURIComponent(safeAssetName(file.name))}`, { - replace: true, - }), + uploadToRegistry(file, `${packageLatestUrl}/${encodeURIComponent(safeAssetName(file.name))}`), }); if (!latestFeed.update) { process.stdout.write( @@ -206,46 +238,35 @@ async function sha256(path) { return hash.digest("hex"); } -async function assertVersionNotRepublished(localFiles) { - for (const file of localFiles) { - const url = `${packageVersionedUrl}/${encodeURIComponent(safeAssetName(file.name))}`; - const head = await forgejoFetch(url, { method: "HEAD" }).catch(() => null); - if (!head || !head.ok) continue; +let latestDigestsPromise = null; - const remoteLength = Number(head.headers.get("content-length")); - const localLength = (await stat(file.path)).size; - if (!Number.isFinite(remoteLength) || remoteLength === localLength) continue; - - throw new Error( - `${tag} is already published with different bytes (${safeAssetName(file.name)}: ` + - `remote ${remoteLength} bytes, local ${localLength} bytes). Releases are immutable — ` + - "lift the version and publish a new tag instead of re-publishing this one.", - ); - } +/** latest alias에 이미 같은 바이트가 있는지 본다. 목록을 못 읽으면 "모름"으로 보고 교체한다. */ +async function latestAlreadyHolds(name, digest) { + latestDigestsPromise ??= readRemotePackageDigests({ + filesApiUrl: packageLatestFilesApiUrl, + fetchImpl: forgejoFetch, + }).catch(() => new Map()); + const remote = (await latestDigestsPromise).get(name); + return Boolean(remote) && remote === digest; } -async function uploadToRegistry(file, url, { replace = false, immutable = false } = {}) { - const fileStat = await stat(file.path).catch(() => null); - - if (immutable) { - // 버전별 패키지: 이미 동일 크기의 파일이 업로드되어 있다면 중복 전송 방지 - const head = await forgejoFetch(url, { method: "HEAD" }).catch(() => null); - if (head && head.ok) { - const remoteLength = head.headers.get("content-length"); - if (fileStat && remoteLength && Number(remoteLength) === fileStat.size) { - process.stdout.write(` verified existing immutable ${file.name} (${fileStat.size} bytes)\n`); - return; - } - } - } - - if (replace) { - // replace 모드: 기존 파일이 있으면 먼저 삭제하여 409 Conflict 후 이중 전송으로 인한 - // Cloudflare 타임아웃(HTTP 524)을 원천 차단한다. - await forgejoFetch(url, { method: "DELETE" }).catch(() => null); - } - +/** + * latest(mutable) alias 파일 교체. 같은 바이트가 이미 있으면 건드리지 않아 + * 동일 버전 재실행 때 DELETE→PUT 사이 404 구간을 만들지 않는다. + */ +async function uploadToRegistry(file, url) { + const name = safeAssetName(file.name); const body = await readFile(file.path); + const digest = createHash("sha256").update(body).digest("hex"); + if (await latestAlreadyHolds(name, digest)) { + process.stdout.write(` latest already holds identical ${file.name}\n`); + return; + } + + // 기존 파일이 있으면 먼저 삭제하여 409 Conflict 후 이중 전송으로 인한 + // Cloudflare 타임아웃(HTTP 524)을 원천 차단한다. + await forgejoFetch(url, { method: "DELETE" }).catch(() => null); + const response = await forgejoFetch(url, { method: "PUT", headers: { "Content-Type": "application/octet-stream" }, @@ -256,8 +277,8 @@ async function uploadToRegistry(file, url, { replace = false, immutable = false process.stdout.write(` uploaded ${file.name}\n`); return; } - if (response.status === 409 && (replace || immutable)) { - // 재실행/재시도: 기존 파일을 지우고 다시 올린다. + if (response.status === 409) { + // alias는 가변 경로다: 기존 파일을 지우고 다시 올린다. (버전별 경로에는 절대 적용하지 않는다.) await forgejoFetch(url, { method: "DELETE" }); const retry = await forgejoFetch(url, { method: "PUT",