fix(release): keep portable/runtime version packages immutable and guard latest aliases against rollback
This commit is contained in:
parent
88f3dbcd69
commit
5f12ab4772
4 changed files with 658 additions and 88 deletions
76
scripts/ci/lib/forgejo-generic-registry.mjs
Normal file
76
scripts/ci/lib/forgejo-generic-registry.mjs
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
// scripts/ci/lib/forgejo-generic-registry.mjs
|
||||
// Forgejo generic package registry IO 어댑터 (portable-publish-policy.mjs 의 PackageRegistry 포트).
|
||||
//
|
||||
// - Forgejo generic registry는 HEAD를 405로 거부하고 파일 해시를 헤더로 주지 않는다.
|
||||
// 패키지 버전의 파일 목록 API는 sha256을 주므로, 큰 볼륨을 내려받지 않고도 원격 바이트가
|
||||
// 로컬과 같은지 정확히 판단할 수 있다. (Range GET의 크기만 비교하면 90MiB 볼륨에서 크기가
|
||||
// 우연히 같을 때 다른 바이트를 "동일"로 오판한다 — 실측 사고.)
|
||||
// - 404 외의 오류는 모두 예외로 올린다(fail-closed). 원격 상태를 모르면 게시 판단을 할 수 없다.
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* feedUrl: string,
|
||||
* packageApiUrl: string,
|
||||
* fetchImpl: (url: string, init?: RequestInit) => Promise<Response>,
|
||||
* onUploaded?: (url: string) => void,
|
||||
* }} deps
|
||||
* @returns {import("./portable-publish-policy.mjs").PackageRegistry & { fileUrl: (versionPath: string, name: string) => string }}
|
||||
*/
|
||||
export function createForgejoGenericRegistry({ feedUrl, packageApiUrl, fetchImpl, onUploaded }) {
|
||||
const versionApi = (versionPath) => `${packageApiUrl}/${encodeURIComponent(versionPath)}`;
|
||||
const fileUrl = (versionPath, name) =>
|
||||
`${feedUrl}/${encodeURIComponent(versionPath)}/${encodeURIComponent(name)}`;
|
||||
|
||||
return {
|
||||
fileUrl,
|
||||
|
||||
async listFileHashes(versionPath) {
|
||||
const response = await fetchImpl(`${versionApi(versionPath)}/files`);
|
||||
if (response.status === 404) return new Map();
|
||||
if (!response.ok) {
|
||||
throw new Error(`파일 목록 조회 실패 (HTTP ${response.status}): ${versionPath}`);
|
||||
}
|
||||
const files = await response.json();
|
||||
if (!Array.isArray(files)) {
|
||||
throw new Error(`파일 목록 응답 형식이 올바르지 않습니다: ${versionPath}`);
|
||||
}
|
||||
return new Map(files.map((file) => [String(file.name), String(file.sha256)]));
|
||||
},
|
||||
|
||||
async deleteVersion(versionPath) {
|
||||
const response = await fetchImpl(versionApi(versionPath), { method: "DELETE" });
|
||||
if (!response.ok && response.status !== 404) {
|
||||
throw new Error(`버전 삭제 실패 (HTTP ${response.status}): ${versionPath}`);
|
||||
}
|
||||
},
|
||||
|
||||
async uploadFile(versionPath, file) {
|
||||
const url = fileUrl(versionPath, file.name);
|
||||
const response = await fetchImpl(url, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": file.contentType },
|
||||
body: file.bytes,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`업로드 실패 (HTTP ${response.status}): ${url}\n` +
|
||||
" HTTP 413이면 Cloudflare 본문 한도(100MiB) 초과입니다. 볼륨 크기를 줄이세요.",
|
||||
);
|
||||
}
|
||||
onUploaded?.(url);
|
||||
},
|
||||
|
||||
async readTextFile(versionPath, name) {
|
||||
const response = await fetchImpl(`${fileUrl(versionPath, name)}?ts=${Date.now()}`, {
|
||||
cache: "no-store",
|
||||
});
|
||||
if (response.status === 404) return undefined;
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`게시된 파일을 읽을 수 없습니다 (HTTP ${response.status}): ${versionPath}/${name}`,
|
||||
);
|
||||
}
|
||||
return response.text();
|
||||
},
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue