fix(release): stop overwriting immutable Forgejo version assets on re-run

This commit is contained in:
Yun Chan 2026-09-28 02:16:18 +09:00
parent 5f12ab4772
commit e015f43de1
3 changed files with 387 additions and 48 deletions

View file

@ -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<string, string>} RemoteDigests 원격 파일명 → sha256(hex, 소문자)
* @typedef {(url: string, init?: RequestInit) => Promise<Response>} 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<LocalAssetDigest & { remoteSha256: string }> }}
*/
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<LocalAssetDigest & { remoteSha256: string }> }} 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<Map<string, string>>}
*/
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<string | undefined>,
* }} 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()}`);
}