// scripts/ci/lib/portable-publish-policy.mjs // 휴대용 배포본(portable-*) · 로컬 AI 런타임(runtime-*) 게시 정책과 유스케이스. // // Forgejo generic registry 경로는 두 종류다. // - 버전 경로 - : 불변. 한 번 게시된 파일은 절대 지우거나 바꾸지 않는다. // runtime-latest/runtime.json 과 커밋된 Scoop 매니페스트(bucket/d3ro-voice.json)가 // 이 경로의 파일과 sha256을 직접 가리키므로, 교체하면 이미 배포된 클라이언트가 깨진다. // 재실행(예: Cloudflare 524 후)이 부분 업로드를 복구할 수 있도록, 원격에 없는 파일만 // 이어서 올리고 같은 이름에 다른 바이트가 있으면 중단한다(fail-closed). // - 별칭 경로 -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>, * deleteVersion: (versionPath: string) => Promise, * uploadFile: (versionPath: string, file: HashedPayload) => Promise, * readTextFile: (versionPath: string, name: string) => Promise, * }} 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 }} 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 }} 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} 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, aliases: Record }>} */ 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} */ const versioned = {}; /** @type {Record} */ 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 }; }