From 49a4c97923e80e0cfc20d05c5238fd3d3e938977 Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Wed, 16 Sep 2026 23:49:37 +0900 Subject: [PATCH] fix(release): refuse to re-publish a version that already shipped The feed publisher overwrote whatever version-specific assets it found, so a re-run of an old release tag could quietly replace the installer that customers already downloaded under that version number. Publication now compares the bytes already in the version-specific registry path and stops when they differ, while still allowing an identical re-run to finish. The metadata verifier gained a negative case so the guard cannot be removed unnoticed. --- scripts/ci/publish-forgejo-release.mjs | 23 +++++++++++++++++++++++ scripts/ci/verify-release-metadata.mjs | 13 ++++++++++++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/scripts/ci/publish-forgejo-release.mjs b/scripts/ci/publish-forgejo-release.mjs index ea06e57..eb4b1fb 100644 --- a/scripts/ci/publish-forgejo-release.mjs +++ b/scripts/ci/publish-forgejo-release.mjs @@ -98,6 +98,11 @@ if (dryRun) { process.exit(0); } +// 0) 이미 게시된 버전은 재게시하지 않는다 (SemVer 동일 버전 재릴리스 금지). +// 버전별 경로에 같은 크기의 자산이 있으면 재시도/재실행으로 보고 통과시키고, +// 다른 바이트를 가진 자산이 있으면 fail-closed로 중단한다. +await assertVersionNotRepublished(sorted); + // 1) 버전별(immutable) 패키지 업로드 for (const file of sorted) { await uploadToRegistry(file, `${packageVersionedUrl}/${encodeURIComponent(safeAssetName(file.name))}`, { @@ -188,6 +193,24 @@ 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; + + 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.", + ); + } +} + async function uploadToRegistry(file, url, { replace = false, immutable = false } = {}) { const fileStat = await stat(file.path).catch(() => null); diff --git a/scripts/ci/verify-release-metadata.mjs b/scripts/ci/verify-release-metadata.mjs index a2a5337..7e0438d 100644 --- a/scripts/ci/verify-release-metadata.mjs +++ b/scripts/ci/verify-release-metadata.mjs @@ -196,6 +196,10 @@ function validate(surfaces) { fail(surfaces.forgejoPublisher.includes('verifyPublicFile'), 'forgejo_publisher_public_verification_missing') fail(surfaces.forgejoPublisher.includes('update-policy.json'), 'forgejo_publisher_policy_upload_missing') fail(surfaces.forgejoPublisher.includes('CHANGELOG.md'), 'forgejo_publisher_changelog_gate_missing') + fail( + surfaces.forgejoPublisher.includes('assertVersionNotRepublished'), + 'forgejo_publisher_rerelease_guard_missing', + ) fail( surfaces.forgejoPublisher.includes('/api/packages/') && surfaces.forgejoPublisher.includes('generic'), @@ -309,6 +313,13 @@ if (process.argv.includes('--self-test')) { }, 'forgejo_publisher_asset_first_order_missing', ) + expectRejected( + surfaces, + (candidate) => { + candidate.forgejoPublisher = candidate.forgejoPublisher.replaceAll('assertVersionNotRepublished', 'uploadAnyway') + }, + 'forgejo_publisher_rerelease_guard_missing', + ) expectRejected( surfaces, (candidate) => { @@ -373,7 +384,7 @@ if (process.argv.includes('--self-test')) { if (!missingDesktopKeyRejected) { throw new Error('release_metadata_self_test_failed:desktop_license_public_key_missing') } - result.negativeCases = 13 + result.negativeCases = 14 } process.stdout.write(`${JSON.stringify(result, null, 2)}\n`)