fix(release): keep portable/runtime version packages immutable and guard latest aliases against rollback

This commit is contained in:
Yun Chan 2026-09-28 02:16:18 +09:00
parent 88f3dbcd69
commit 5f12ab4772
4 changed files with 658 additions and 88 deletions

View file

@ -0,0 +1,231 @@
// scripts/ci/lib/portable-publish-policy.mjs
// 휴대용 배포본(portable-*) · 로컬 AI 런타임(runtime-*) 게시 정책과 유스케이스.
//
// Forgejo generic registry 경로는 두 종류다.
// - 버전 경로 <kind>-<version> : 불변. 한 번 게시된 파일은 절대 지우거나 바꾸지 않는다.
// runtime-latest/runtime.json 과 커밋된 Scoop 매니페스트(bucket/d3ro-voice.json)가
// 이 경로의 파일과 sha256을 직접 가리키므로, 교체하면 이미 배포된 클라이언트가 깨진다.
// 재실행(예: Cloudflare 524 후)이 부분 업로드를 복구할 수 있도록, 원격에 없는 파일만
// 이어서 올리고 같은 이름에 다른 바이트가 있으면 중단한다(fail-closed).
// - 별칭 경로 <kind>-latest : 모든 태그가 공유. 버전 경로가 완성된 뒤에만, 그리고
// 이미 게시된 인덱스(runtime.json / portable.json)의 버전보다 오래된 버전이 아닐 때만
// 교체한다. 게시된 버전을 읽을 수 없으면 건드리지 않는다(fail-closed).
//
// 구조
// 1) 순수 정책: planVersionedPackage / planAliasPackage / parsePublishedIndexVersion /
// decideAliasUpdate
// 2) 유스케이스: publishPortablePackages — registry 포트(IO)를 주입받는다.
// IO 어댑터는 ./forgejo-generic-registry.mjs 에 있다.
import { createHash } from "node:crypto";
import { decideLatestFeedUpdate, parseSemver } from "./latest-feed-guard.mjs";
/**
* @typedef {{ name: string, bytes: Uint8Array, contentType: string }} Payload
* @typedef {Payload & { sha256: string }} HashedPayload
* @typedef {{
* listFileHashes: (versionPath: string) => Promise<Map<string, string>>,
* deleteVersion: (versionPath: string) => Promise<void>,
* uploadFile: (versionPath: string, file: HashedPayload) => Promise<void>,
* readTextFile: (versionPath: string, name: string) => Promise<string | undefined>,
* }} PackageRegistry
* @typedef {{ kind: string, indexName: string, payloads: readonly Payload[] }} PackageSpec
*/
export class PortablePublishError extends Error {
/** @param {string} message */
constructor(message) {
super(message);
this.name = "PortablePublishError";
}
}
/**
* @param {readonly Payload[]} payloads
* @returns {HashedPayload[]}
*/
export function hashPayloads(payloads) {
return payloads.map((payload) => ({
...payload,
sha256: createHash("sha256").update(payload.bytes).digest("hex"),
}));
}
/**
* 불변 버전 경로 게시 계획.
* - 같은 이름에 다른 sha256이 원격에 있음 → abort (절대 삭제/교체하지 않는다)
* - 로컬 파일이 모두 같은 바이트로 원격에 있음 → skip
* - 일부만 있음(부분 업로드 재실행) 또는 비어 있음 → 없는 파일만 upload (순서 유지: 인덱스가 마지막)
*
* @param {{ items: readonly HashedPayload[], remoteHashes: ReadonlyMap<string, string> }} input
* @returns {{ action: "abort" | "skip" | "upload", conflicts: HashedPayload[], uploads: HashedPayload[] }}
*/
export function planVersionedPackage({ items, remoteHashes }) {
const conflicts = items.filter(
(item) => remoteHashes.has(item.name) && remoteHashes.get(item.name) !== item.sha256,
);
if (conflicts.length > 0) return { action: "abort", conflicts, uploads: [] };
const uploads = items.filter((item) => !remoteHashes.has(item.name));
if (uploads.length === 0) return { action: "skip", conflicts: [], uploads: [] };
return { action: "upload", conflicts: [], uploads };
}
/**
* 별칭 경로 게시 계획. Forgejo는 파일 단위 덮어쓰기를 거부(409)하므로, 다른 파일이 하나라도
* 있으면 버전 전체를 지우고 모든 파일을 다시 올린다(낡은 바이트와 새 바이트가 섞이지 않게).
*
* @param {{ items: readonly HashedPayload[], remoteHashes: ReadonlyMap<string, string> }} input
* @returns {{ action: "skip" | "replace", deleteFirst: boolean }}
*/
export function planAliasPackage({ items, remoteHashes }) {
const differing = items.filter((item) => remoteHashes.get(item.name) !== item.sha256);
if (differing.length === 0) return { action: "skip", deleteFirst: false };
return { action: "replace", deleteFirst: remoteHashes.size > 0 };
}
/**
* 게시된 인덱스(runtime.json / portable.json)의 최상위 version 을 읽는다.
* @param {string} text
* @returns {string | null} 파싱할 수 없거나 semver가 아니면 null
*/
export function parsePublishedIndexVersion(text) {
let parsed;
try {
parsed = JSON.parse(String(text ?? ""));
} catch {
return null;
}
const value = parsed && typeof parsed === "object" ? parsed.version : undefined;
if (typeof value !== "string" || !parseSemver(value)) return null;
return value.trim().replace(/^v/, "");
}
/**
* 별칭 교체 여부. decideLatestFeedUpdate 를 재사용하되, 게시된 인덱스를 읽었는데 버전을
* 알 수 없으면(null) 교체하지 않고 중단한다 — 스키마가 바뀐 더 새로운 버전을 오래된
* 버전으로 덮어쓰는 롤백을 막기 위한 fail-closed.
*
* @param {{ publishingVersion: string, publishedVersion: string | null | undefined }} input
* @returns {{ update: boolean, abort: boolean, reason: string }}
*/
export function decideAliasUpdate({ publishingVersion, publishedVersion }) {
if (publishedVersion === null) {
return { update: false, abort: true, reason: "unreadable-feed" };
}
const decision = decideLatestFeedUpdate({ publishingVersion, publishedVersion });
return { ...decision, abort: false };
}
/**
* @param {PackageRegistry} registry
* @param {string} aliasPath
* @param {string} indexName
* @returns {Promise<string | null | undefined>} undefined=별칭 없음, null=버전 읽기 불가
*/
async function readAliasVersion(registry, aliasPath, indexName) {
const text = await registry.readTextFile(aliasPath, indexName);
if (text === undefined) return undefined;
return parsePublishedIndexVersion(text);
}
/**
* 버전 경로를 모두 완성한 뒤 별칭을 갱신한다.
*
* @param {{
* version: string,
* packages: readonly PackageSpec[],
* registry: PackageRegistry,
* log: (message: string) => void,
* dryRun?: boolean,
* describeUrl?: (versionPath: string, name: string) => string,
* }} input
* @returns {Promise<{ versioned: Record<string, string>, aliases: Record<string, string> }>}
*/
export async function publishPortablePackages({
version,
packages,
registry,
log,
dryRun = false,
describeUrl = (versionPath, name) => `${versionPath}/${name}`,
}) {
if (!parseSemver(version)) {
throw new PortablePublishError(`게시 버전이 semver가 아닙니다: ${version}`);
}
const active = packages
.filter((spec) => spec.payloads.length > 0)
.map((spec) => ({ ...spec, items: hashPayloads(spec.payloads) }));
/** @type {Record<string, string>} */
const versioned = {};
/** @type {Record<string, string>} */
const aliases = {};
if (dryRun) {
for (const spec of active) {
for (const path of [`${spec.kind}-${version}`, `${spec.kind}-latest`]) {
for (const item of spec.items) {
log(`(check) PUT ${describeUrl(path, item.name)} (${item.bytes.length} bytes)`);
}
}
}
return { versioned, aliases };
}
// 1) 불변 버전 경로 — 모두 완성되기 전에는 어떤 별칭도 건드리지 않는다.
for (const spec of active) {
const versionPath = `${spec.kind}-${version}`;
const plan = planVersionedPackage({
items: spec.items,
remoteHashes: await registry.listFileHashes(versionPath),
});
if (plan.action === "abort") {
throw new PortablePublishError(
`${versionPath} 에 같은 이름의 다른 바이트가 이미 게시돼 있습니다: ` +
`${plan.conflicts.map((item) => item.name).join(", ")}\n` +
" 버전 경로는 불변이라 지우거나 덮어쓰지 않습니다. 새 버전으로 게시하세요.\n" +
" (게시가 중간에 실패해 어떤 별칭도 이 버전을 가리키지 않는 것이 확실할 때만 " +
"패키지 버전을 수동으로 삭제한 뒤 다시 실행하세요.)",
);
}
if (plan.action === "skip") {
log(`변경 없음(건너뜀): ${versionPath}`);
versioned[spec.kind] = "unchanged";
continue;
}
for (const item of plan.uploads) await registry.uploadFile(versionPath, item);
versioned[spec.kind] = plan.uploads.length === spec.items.length ? "uploaded" : "resumed";
}
// 2) 공유 별칭 — 더 새로운 버전이 게시돼 있으면 되돌리지 않는다.
for (const spec of active) {
const aliasPath = `${spec.kind}-latest`;
const publishedVersion = await readAliasVersion(registry, aliasPath, spec.indexName);
const decision = decideAliasUpdate({ publishingVersion: version, publishedVersion });
if (decision.abort) {
throw new PortablePublishError(
`${aliasPath}/${spec.indexName} 의 게시 버전을 읽을 수 없습니다. ` +
"버전을 모른 채 별칭을 덮어쓰지 않습니다(롤백 방지).",
);
}
if (!decision.update) {
log(`${aliasPath} 건너뜀: 더 새로운 버전(${publishedVersion})이 이미 게시돼 있습니다 (이번 ${version})`);
aliases[spec.kind] = decision.reason;
continue;
}
const plan = planAliasPackage({
items: spec.items,
remoteHashes: await registry.listFileHashes(aliasPath),
});
if (plan.action === "skip") {
log(`변경 없음(건너뜀): ${aliasPath}`);
aliases[spec.kind] = "unchanged";
continue;
}
if (plan.deleteFirst) await registry.deleteVersion(aliasPath);
for (const item of spec.items) await registry.uploadFile(aliasPath, item);
aliases[spec.kind] = "replaced";
}
return { versioned, aliases };
}