fix(release): stop overwriting immutable Forgejo version assets on re-run
This commit is contained in:
parent
5f12ab4772
commit
e015f43de1
3 changed files with 387 additions and 48 deletions
134
scripts/ci/lib/immutable-package-guard.mjs
Normal file
134
scripts/ci/lib/immutable-package-guard.mjs
Normal 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()}`);
|
||||||
|
}
|
||||||
184
scripts/ci/lib/immutable-package-guard.test.mjs
Normal file
184
scripts/ci/lib/immutable-package-guard.test.mjs
Normal file
|
|
@ -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/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
@ -24,6 +24,12 @@ import { basename, join } from "node:path";
|
||||||
import process from "node:process";
|
import process from "node:process";
|
||||||
import { fileURLToPath, URL } from "node:url";
|
import { fileURLToPath, URL } from "node:url";
|
||||||
import credentialHelpers from "../lib/credentials.cjs";
|
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";
|
import { publishLatestAlias, readPublishedFeedVersion } from "./lib/latest-feed-guard.mjs";
|
||||||
|
|
||||||
const { forgejoAuthorization } = credentialHelpers;
|
const { forgejoAuthorization } = credentialHelpers;
|
||||||
|
|
@ -44,6 +50,10 @@ if (!/^\d+\.\d+\.\d+$/.test(version)) {
|
||||||
const { origin, owner, repo } = parseRepo(resolveRepoUrl());
|
const { origin, owner, repo } = parseRepo(resolveRepoUrl());
|
||||||
const packageVersionedUrl = `${origin}/api/packages/${owner}/generic/${PACKAGE_NAME}/${version}`;
|
const packageVersionedUrl = `${origin}/api/packages/${owner}/generic/${PACKAGE_NAME}/${version}`;
|
||||||
const packageLatestUrl = `${origin}/api/packages/${owner}/generic/${PACKAGE_NAME}/latest`;
|
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 releasesApiUrl = `${origin}/api/v1/repos/${owner}/${repo}/releases`;
|
||||||
|
|
||||||
const authorization = dryRun && !process.env.FORGEJO_TOKEN && !process.env.FORGEJO_USERNAME
|
const authorization = dryRun && !process.env.FORGEJO_TOKEN && !process.env.FORGEJO_USERNAME
|
||||||
|
|
@ -100,15 +110,39 @@ if (dryRun) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 0) 이미 게시된 버전은 재게시하지 않는다 (SemVer 동일 버전 재릴리스 금지).
|
// 0) 이미 게시된 버전은 재게시하지 않는다 (SemVer 동일 버전 재릴리스 금지).
|
||||||
// 버전별 경로에 같은 크기의 자산이 있으면 재시도/재실행으로 보고 통과시키고,
|
// 버전별 경로의 원격 sha256이 로컬과 같으면 재시도/재실행으로 보고 건너뛰고,
|
||||||
// 다른 바이트를 가진 자산이 있으면 fail-closed로 중단한다.
|
// 다른 바이트(재빌드·재서명된 설치본 등)가 있으면 어떤 파일도 건드리기 전에 fail-closed로 중단한다.
|
||||||
await assertVersionNotRepublished(sorted);
|
const readVersionedDigests = () =>
|
||||||
|
readRemotePackageDigests({ filesApiUrl: packageVersionedFilesApiUrl, fetchImpl: forgejoFetch });
|
||||||
// 1) 버전별(immutable) 패키지 업로드
|
const versionedAssets = sorted.map((file) => ({
|
||||||
for (const file of sorted) {
|
file,
|
||||||
await uploadToRegistry(file, `${packageVersionedUrl}/${encodeURIComponent(safeAssetName(file.name))}`, {
|
name: safeAssetName(file.name),
|
||||||
immutable: true,
|
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) 패키지 업로드 — 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 마지막, 정책 파일 맨 마지막.
|
// 2) latest feed 갱신 — 설치 자산 먼저, metadata 마지막, 정책 파일 맨 마지막.
|
||||||
|
|
@ -124,9 +158,7 @@ const latestFeed = await publishLatestAlias({
|
||||||
readPublishedVersion: () =>
|
readPublishedVersion: () =>
|
||||||
readPublishedFeedVersion({ url: `${packageLatestUrl}/latest.yml`, fetchImpl: forgejoFetch }),
|
readPublishedFeedVersion({ url: `${packageLatestUrl}/latest.yml`, fetchImpl: forgejoFetch }),
|
||||||
upload: (file) =>
|
upload: (file) =>
|
||||||
uploadToRegistry(file, `${packageLatestUrl}/${encodeURIComponent(safeAssetName(file.name))}`, {
|
uploadToRegistry(file, `${packageLatestUrl}/${encodeURIComponent(safeAssetName(file.name))}`),
|
||||||
replace: true,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
if (!latestFeed.update) {
|
if (!latestFeed.update) {
|
||||||
process.stdout.write(
|
process.stdout.write(
|
||||||
|
|
@ -206,46 +238,35 @@ async function sha256(path) {
|
||||||
return hash.digest("hex");
|
return hash.digest("hex");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function assertVersionNotRepublished(localFiles) {
|
let latestDigestsPromise = null;
|
||||||
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"));
|
/** latest alias에 이미 같은 바이트가 있는지 본다. 목록을 못 읽으면 "모름"으로 보고 교체한다. */
|
||||||
const localLength = (await stat(file.path)).size;
|
async function latestAlreadyHolds(name, digest) {
|
||||||
if (!Number.isFinite(remoteLength) || remoteLength === localLength) continue;
|
latestDigestsPromise ??= readRemotePackageDigests({
|
||||||
|
filesApiUrl: packageLatestFilesApiUrl,
|
||||||
throw new Error(
|
fetchImpl: forgejoFetch,
|
||||||
`${tag} is already published with different bytes (${safeAssetName(file.name)}: ` +
|
}).catch(() => new Map());
|
||||||
`remote ${remoteLength} bytes, local ${localLength} bytes). Releases are immutable — ` +
|
const remote = (await latestDigestsPromise).get(name);
|
||||||
"lift the version and publish a new tag instead of re-publishing this one.",
|
return Boolean(remote) && remote === digest;
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function uploadToRegistry(file, url, { replace = false, immutable = false } = {}) {
|
/**
|
||||||
const fileStat = await stat(file.path).catch(() => null);
|
* latest(mutable) alias 파일 교체. 같은 바이트가 이미 있으면 건드리지 않아
|
||||||
|
* 동일 버전 재실행 때 DELETE→PUT 사이 404 구간을 만들지 않는다.
|
||||||
if (immutable) {
|
*/
|
||||||
// 버전별 패키지: 이미 동일 크기의 파일이 업로드되어 있다면 중복 전송 방지
|
async function uploadToRegistry(file, url) {
|
||||||
const head = await forgejoFetch(url, { method: "HEAD" }).catch(() => null);
|
const name = safeAssetName(file.name);
|
||||||
if (head && head.ok) {
|
const body = await readFile(file.path);
|
||||||
const remoteLength = head.headers.get("content-length");
|
const digest = createHash("sha256").update(body).digest("hex");
|
||||||
if (fileStat && remoteLength && Number(remoteLength) === fileStat.size) {
|
if (await latestAlreadyHolds(name, digest)) {
|
||||||
process.stdout.write(` verified existing immutable ${file.name} (${fileStat.size} bytes)\n`);
|
process.stdout.write(` latest already holds identical ${file.name}\n`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (replace) {
|
// 기존 파일이 있으면 먼저 삭제하여 409 Conflict 후 이중 전송으로 인한
|
||||||
// replace 모드: 기존 파일이 있으면 먼저 삭제하여 409 Conflict 후 이중 전송으로 인한
|
|
||||||
// Cloudflare 타임아웃(HTTP 524)을 원천 차단한다.
|
// Cloudflare 타임아웃(HTTP 524)을 원천 차단한다.
|
||||||
await forgejoFetch(url, { method: "DELETE" }).catch(() => null);
|
await forgejoFetch(url, { method: "DELETE" }).catch(() => null);
|
||||||
}
|
|
||||||
|
|
||||||
const body = await readFile(file.path);
|
|
||||||
const response = await forgejoFetch(url, {
|
const response = await forgejoFetch(url, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "Content-Type": "application/octet-stream" },
|
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`);
|
process.stdout.write(` uploaded ${file.name}\n`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (response.status === 409 && (replace || immutable)) {
|
if (response.status === 409) {
|
||||||
// 재실행/재시도: 기존 파일을 지우고 다시 올린다.
|
// alias는 가변 경로다: 기존 파일을 지우고 다시 올린다. (버전별 경로에는 절대 적용하지 않는다.)
|
||||||
await forgejoFetch(url, { method: "DELETE" });
|
await forgejoFetch(url, { method: "DELETE" });
|
||||||
const retry = await forgejoFetch(url, {
|
const retry = await forgejoFetch(url, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue