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.
This commit is contained in:
Yun Chan 2026-09-16 23:49:37 +09:00
parent c3ddd36c6f
commit 49a4c97923
2 changed files with 35 additions and 1 deletions

View file

@ -98,6 +98,11 @@ if (dryRun) {
process.exit(0); process.exit(0);
} }
// 0) 이미 게시된 버전은 재게시하지 않는다 (SemVer 동일 버전 재릴리스 금지).
// 버전별 경로에 같은 크기의 자산이 있으면 재시도/재실행으로 보고 통과시키고,
// 다른 바이트를 가진 자산이 있으면 fail-closed로 중단한다.
await assertVersionNotRepublished(sorted);
// 1) 버전별(immutable) 패키지 업로드 // 1) 버전별(immutable) 패키지 업로드
for (const file of sorted) { for (const file of sorted) {
await uploadToRegistry(file, `${packageVersionedUrl}/${encodeURIComponent(safeAssetName(file.name))}`, { await uploadToRegistry(file, `${packageVersionedUrl}/${encodeURIComponent(safeAssetName(file.name))}`, {
@ -188,6 +193,24 @@ async function sha256(path) {
return hash.digest("hex"); 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 } = {}) { async function uploadToRegistry(file, url, { replace = false, immutable = false } = {}) {
const fileStat = await stat(file.path).catch(() => null); const fileStat = await stat(file.path).catch(() => null);

View file

@ -196,6 +196,10 @@ function validate(surfaces) {
fail(surfaces.forgejoPublisher.includes('verifyPublicFile'), 'forgejo_publisher_public_verification_missing') 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('update-policy.json'), 'forgejo_publisher_policy_upload_missing')
fail(surfaces.forgejoPublisher.includes('CHANGELOG.md'), 'forgejo_publisher_changelog_gate_missing') fail(surfaces.forgejoPublisher.includes('CHANGELOG.md'), 'forgejo_publisher_changelog_gate_missing')
fail(
surfaces.forgejoPublisher.includes('assertVersionNotRepublished'),
'forgejo_publisher_rerelease_guard_missing',
)
fail( fail(
surfaces.forgejoPublisher.includes('/api/packages/') && surfaces.forgejoPublisher.includes('/api/packages/') &&
surfaces.forgejoPublisher.includes('generic'), surfaces.forgejoPublisher.includes('generic'),
@ -309,6 +313,13 @@ if (process.argv.includes('--self-test')) {
}, },
'forgejo_publisher_asset_first_order_missing', 'forgejo_publisher_asset_first_order_missing',
) )
expectRejected(
surfaces,
(candidate) => {
candidate.forgejoPublisher = candidate.forgejoPublisher.replaceAll('assertVersionNotRepublished', 'uploadAnyway')
},
'forgejo_publisher_rerelease_guard_missing',
)
expectRejected( expectRejected(
surfaces, surfaces,
(candidate) => { (candidate) => {
@ -373,7 +384,7 @@ if (process.argv.includes('--self-test')) {
if (!missingDesktopKeyRejected) { if (!missingDesktopKeyRejected) {
throw new Error('release_metadata_self_test_failed:desktop_license_public_key_missing') 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`) process.stdout.write(`${JSON.stringify(result, null, 2)}\n`)