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);
}
// 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);