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
|
|
@ -24,6 +24,12 @@ import { basename, join } from "node:path";
|
|||
import process from "node:process";
|
||||
import { fileURLToPath, URL } from "node:url";
|
||||
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";
|
||||
|
||||
const { forgejoAuthorization } = credentialHelpers;
|
||||
|
|
@ -44,6 +50,10 @@ if (!/^\d+\.\d+\.\d+$/.test(version)) {
|
|||
const { origin, owner, repo } = parseRepo(resolveRepoUrl());
|
||||
const packageVersionedUrl = `${origin}/api/packages/${owner}/generic/${PACKAGE_NAME}/${version}`;
|
||||
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 authorization = dryRun && !process.env.FORGEJO_TOKEN && !process.env.FORGEJO_USERNAME
|
||||
|
|
@ -100,15 +110,39 @@ if (dryRun) {
|
|||
}
|
||||
|
||||
// 0) 이미 게시된 버전은 재게시하지 않는다 (SemVer 동일 버전 재릴리스 금지).
|
||||
// 버전별 경로에 같은 크기의 자산이 있으면 재시도/재실행으로 보고 통과시키고,
|
||||
// 다른 바이트를 가진 자산이 있으면 fail-closed로 중단한다.
|
||||
await assertVersionNotRepublished(sorted);
|
||||
// 버전별 경로의 원격 sha256이 로컬과 같으면 재시도/재실행으로 보고 건너뛰고,
|
||||
// 다른 바이트(재빌드·재서명된 설치본 등)가 있으면 어떤 파일도 건드리기 전에 fail-closed로 중단한다.
|
||||
const readVersionedDigests = () =>
|
||||
readRemotePackageDigests({ filesApiUrl: packageVersionedFilesApiUrl, fetchImpl: forgejoFetch });
|
||||
const versionedAssets = sorted.map((file) => ({
|
||||
file,
|
||||
name: safeAssetName(file.name),
|
||||
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) 패키지 업로드
|
||||
for (const file of sorted) {
|
||||
await uploadToRegistry(file, `${packageVersionedUrl}/${encodeURIComponent(safeAssetName(file.name))}`, {
|
||||
immutable: true,
|
||||
// 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 마지막, 정책 파일 맨 마지막.
|
||||
|
|
@ -124,9 +158,7 @@ const latestFeed = await publishLatestAlias({
|
|||
readPublishedVersion: () =>
|
||||
readPublishedFeedVersion({ url: `${packageLatestUrl}/latest.yml`, fetchImpl: forgejoFetch }),
|
||||
upload: (file) =>
|
||||
uploadToRegistry(file, `${packageLatestUrl}/${encodeURIComponent(safeAssetName(file.name))}`, {
|
||||
replace: true,
|
||||
}),
|
||||
uploadToRegistry(file, `${packageLatestUrl}/${encodeURIComponent(safeAssetName(file.name))}`),
|
||||
});
|
||||
if (!latestFeed.update) {
|
||||
process.stdout.write(
|
||||
|
|
@ -206,46 +238,35 @@ 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;
|
||||
let latestDigestsPromise = null;
|
||||
|
||||
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.",
|
||||
);
|
||||
}
|
||||
/** latest alias에 이미 같은 바이트가 있는지 본다. 목록을 못 읽으면 "모름"으로 보고 교체한다. */
|
||||
async function latestAlreadyHolds(name, digest) {
|
||||
latestDigestsPromise ??= readRemotePackageDigests({
|
||||
filesApiUrl: packageLatestFilesApiUrl,
|
||||
fetchImpl: forgejoFetch,
|
||||
}).catch(() => new Map());
|
||||
const remote = (await latestDigestsPromise).get(name);
|
||||
return Boolean(remote) && remote === digest;
|
||||
}
|
||||
|
||||
async function uploadToRegistry(file, url, { replace = false, immutable = false } = {}) {
|
||||
const fileStat = await stat(file.path).catch(() => null);
|
||||
|
||||
if (immutable) {
|
||||
// 버전별 패키지: 이미 동일 크기의 파일이 업로드되어 있다면 중복 전송 방지
|
||||
const head = await forgejoFetch(url, { method: "HEAD" }).catch(() => null);
|
||||
if (head && head.ok) {
|
||||
const remoteLength = head.headers.get("content-length");
|
||||
if (fileStat && remoteLength && Number(remoteLength) === fileStat.size) {
|
||||
process.stdout.write(` verified existing immutable ${file.name} (${fileStat.size} bytes)\n`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (replace) {
|
||||
// replace 모드: 기존 파일이 있으면 먼저 삭제하여 409 Conflict 후 이중 전송으로 인한
|
||||
// Cloudflare 타임아웃(HTTP 524)을 원천 차단한다.
|
||||
await forgejoFetch(url, { method: "DELETE" }).catch(() => null);
|
||||
}
|
||||
|
||||
/**
|
||||
* latest(mutable) alias 파일 교체. 같은 바이트가 이미 있으면 건드리지 않아
|
||||
* 동일 버전 재실행 때 DELETE→PUT 사이 404 구간을 만들지 않는다.
|
||||
*/
|
||||
async function uploadToRegistry(file, url) {
|
||||
const name = safeAssetName(file.name);
|
||||
const body = await readFile(file.path);
|
||||
const digest = createHash("sha256").update(body).digest("hex");
|
||||
if (await latestAlreadyHolds(name, digest)) {
|
||||
process.stdout.write(` latest already holds identical ${file.name}\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 기존 파일이 있으면 먼저 삭제하여 409 Conflict 후 이중 전송으로 인한
|
||||
// Cloudflare 타임아웃(HTTP 524)을 원천 차단한다.
|
||||
await forgejoFetch(url, { method: "DELETE" }).catch(() => null);
|
||||
|
||||
const response = await forgejoFetch(url, {
|
||||
method: "PUT",
|
||||
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`);
|
||||
return;
|
||||
}
|
||||
if (response.status === 409 && (replace || immutable)) {
|
||||
// 재실행/재시도: 기존 파일을 지우고 다시 올린다.
|
||||
if (response.status === 409) {
|
||||
// alias는 가변 경로다: 기존 파일을 지우고 다시 올린다. (버전별 경로에는 절대 적용하지 않는다.)
|
||||
await forgejoFetch(url, { method: "DELETE" });
|
||||
const retry = await forgejoFetch(url, {
|
||||
method: "PUT",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue