// 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). // 교체는 패키지 삭제가 아니라 바뀐 파일만 파일 단위 DELETE→PUT, 인덱스가 마지막이다 // (./portable-alias-plan.mjs). 별칭에는 spec.aliasNames 파일만 둔다. // - 재실행 복구: 버전 경로가 이미 완성돼 있고 재빌드 바이트만 달라 abort 되는 경우(빌드가 // 재현 불가 — generatedAt 등), 중간에 끊긴 별칭을 원격 버전 경로의 인덱스로 복구한 뒤 중단한다. // // 구조 // 1) 순수 정책: planVersionedPackage / parsePublishedIndexVersion / decideAliasUpdate // + ./portable-alias-plan.mjs (selectAliasItems / planAliasFiles / planAliasRestore) // 2) 유스케이스: publishPortablePackages — registry 포트(IO)를 주입받는다. // IO 어댑터는 ./forgejo-generic-registry.mjs (+ ./forgejo-generic-file-delete.mjs) 에 있다. import { createHash } from "node:crypto"; import { decideLatestFeedUpdate, parseSemver } from "./latest-feed-guard.mjs"; import { AliasSelectionError, planAliasFiles, planAliasRestore, selectAliasItems, } from "./portable-alias-plan.mjs"; export { planAliasFiles, planAliasRestore, selectAliasItems } from "./portable-alias-plan.mjs"; /** * @typedef {{ name: string, bytes: Uint8Array, contentType: string }} Payload * @typedef {Payload & { sha256: string }} HashedPayload * @typedef {{ * listFileHashes: (versionPath: string) => Promise>, * uploadFile: (versionPath: string, file: HashedPayload) => Promise, * readTextFile: (versionPath: string, name: string) => Promise, * deleteFile?: (versionPath: string, name: string) => Promise, * deleteVersion?: (versionPath: string) => Promise, * }} PackageRegistry * deleteFile 이 있으면 별칭을 파일 단위로 교체한다(권장). 없으면 deleteVersion 으로 * 별칭 전체를 지우고 다시 올리는 예전 방식으로 동작한다(인덱스 404 구간이 길다). * @typedef {{ kind: string, indexName: string, payloads: readonly Payload[], aliasNames?: readonly string[] }} PackageSpec * aliasNames: *-latest 별칭에 둘 파일(인덱스 포함). 생략하면 모든 payload. */ 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: sha256Hex(payload.bytes), })); } /** @param {Uint8Array} bytes */ function sha256Hex(bytes) { return createHash("sha256").update(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 }; } /** * 별칭 교체 여부 요약(skip/replace). 유스케이스는 planAliasFiles 의 파일 단위 계획을 쓴다. * deleteFirst 는 원격 별칭에 파일이 있어 교체 시 삭제가 필요하다는 뜻이다. * * @deprecated planAliasFiles 를 쓴다. 기존 호출부 호환용 요약이다. * @param {{ items: readonly HashedPayload[], remoteHashes: ReadonlyMap }} input * @returns {{ action: "skip" | "replace", deleteFirst: boolean }} */ export function planAliasPackage({ items, remoteHashes }) { const { writes } = planAliasFiles({ items, remoteHashes }); if (writes.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); } /** * @typedef {HashedPayload[]} AliasItems * @typedef {{ * spec: PackageSpec & { items: HashedPayload[] }, * versionPath: string, * aliasPath: string, * aliasItems: AliasItems, * remoteHashes: Map, * plan: ReturnType, * }} PlannedPackage */ /** * 롤백 방지 판정. 교체해도 되면 true, 더 새로운 버전이 있으면 false, 읽을 수 없으면 예외. * @param {PackageRegistry} registry * @param {PlannedPackage} pkg * @param {string} version * @param {(message: string) => void} log * @param {Record} aliases */ async function aliasMayAdvance(registry, pkg, version, log, aliases) { const { aliasPath, spec } = pkg; 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; } return decision.update; } /** * 별칭 계획을 실행한다. deleteFile 포트가 있으면 파일 단위 교체(인덱스가 마지막, 정리는 그 뒤), * 없으면 예전 방식(별칭 전체 삭제 후 전부 업로드)으로 대체한다. * * @param {PackageRegistry} registry * @param {string} aliasPath * @param {AliasItems} aliasItems * @param {{ writes: Array<{ item: HashedPayload, replace: boolean }>, prunes: readonly string[] }} plan */ async function applyAliasPlan(registry, aliasPath, aliasItems, plan) { if (typeof registry.deleteFile === "function") { for (const { item, replace } of plan.writes) { if (replace) await registry.deleteFile(aliasPath, item.name); await registry.uploadFile(aliasPath, item); } for (const name of plan.prunes) await registry.deleteFile(aliasPath, name); return; } const needsDelete = plan.prunes.length > 0 || plan.writes.some((write) => write.replace); if (!needsDelete) { for (const { item } of plan.writes) await registry.uploadFile(aliasPath, item); return; } if (typeof registry.deleteVersion !== "function") { throw new PortablePublishError(`${aliasPath} 를 교체할 삭제 수단(deleteFile/deleteVersion)이 registry에 없습니다.`); } await registry.deleteVersion(aliasPath); for (const item of aliasItems) await registry.uploadFile(aliasPath, item); } /** * 로컬 빌드로 별칭을 갱신한다(버전 경로가 완성된 뒤에만 호출). * @param {PackageRegistry} registry * @param {PlannedPackage} pkg * @returns {Promise<"unchanged" | "replaced">} */ async function publishAlias(registry, pkg) { const plan = planAliasFiles({ items: pkg.aliasItems, remoteHashes: await registry.listFileHashes(pkg.aliasPath), }); if (plan.action === "skip") return "unchanged"; await applyAliasPlan(registry, pkg.aliasPath, pkg.aliasItems, plan); return "replaced"; } /** * 버전 경로가 이미 완성돼 있는데 로컬 재빌드가 달라 게시를 중단하는 경우, 별칭을 원격 버전 경로의 * 게시본(정본)으로 맞춘다 — 이전 실행이 별칭 교체 도중 끊겼다면 CI 재실행이 이를 복구한다. * 파일 단위 삭제 포트가 없거나 복구할 수 없으면 아무것도 쓰지 않는다. * * @param {PackageRegistry} registry * @param {PlannedPackage} pkg * @returns {Promise<"restored" | "unchanged" | "unrestorable">} */ async function restoreAliasFromPublished(registry, pkg) { if (typeof registry.deleteFile !== "function") return "unrestorable"; const aliasHashes = await registry.listFileHashes(pkg.aliasPath); const restore = planAliasRestore({ aliasItems: pkg.aliasItems, versionedHashes: pkg.remoteHashes, aliasHashes, }); if (!restore.restorable) return "unrestorable"; if (restore.reads.length === 0 && restore.prunes.length === 0) return "unchanged"; /** @type {Array<{ item: HashedPayload, replace: boolean }>} */ const writes = []; for (const read of restore.reads) { const text = await registry.readTextFile(pkg.versionPath, read.name); if (text === undefined) return "unrestorable"; const bytes = Buffer.from(text, "utf8"); // 원격 바이트와 정확히 같은지 확인한다(인코딩 손실이 있으면 옮기지 않는다). if (sha256Hex(bytes) !== read.sha256) return "unrestorable"; writes.push({ item: { name: read.name, bytes, contentType: read.contentType, sha256: read.sha256 }, replace: aliasHashes.has(read.name), }); } await applyAliasPlan(registry, pkg.aliasPath, [], { writes, prunes: restore.prunes }); return "restored"; } /** * @param {PlannedPackage[]} aborted * @param {Record} aliases */ function conflictError(aborted, aliases) { const lines = aborted.map( (pkg) => `${pkg.versionPath} 에 같은 이름의 다른 바이트가 이미 게시돼 있습니다: ` + pkg.plan.conflicts.map((item) => item.name).join(", "), ); const restored = Object.entries(aliases) .filter(([, status]) => status === "restored") .map(([kind]) => `${kind}-latest`); return new PortablePublishError( `${lines.join("\n")}\n` + " 버전 경로는 불변이라 지우거나 덮어쓰지 않습니다. 새 버전으로 게시하세요.\n" + (restored.length > 0 ? ` (중단된 별칭은 게시된 버전 경로의 인덱스로 복구했습니다: ${restored.join(", ")})\n` : "") + " (게시가 중간에 실패해 어떤 별칭도 이 버전을 가리키지 않는 것이 확실할 때만 " + "패키지 버전을 수동으로 삭제한 뒤 다시 실행하세요.)", ); } /** * 버전 경로를 모두 완성한 뒤 별칭을 갱신한다. * * @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) => { const items = hashPayloads(spec.payloads); let aliasItems; try { aliasItems = selectAliasItems({ items, indexName: spec.indexName, aliasNames: spec.aliasNames }); } catch (error) { if (error instanceof AliasSelectionError) throw new PortablePublishError(`${spec.kind}: ${error.message}`); throw error; } return { spec: { ...spec, items }, versionPath: `${spec.kind}-${version}`, aliasPath: `${spec.kind}-latest`, aliasItems, }; }); /** @type {Record} */ const versioned = {}; /** @type {Record} */ const aliases = {}; if (dryRun) { for (const pkg of active) { for (const item of pkg.spec.items) { log(`(check) PUT ${describeUrl(pkg.versionPath, item.name)} (${item.bytes.length} bytes)`); } for (const item of pkg.aliasItems) { log(`(check) PUT ${describeUrl(pkg.aliasPath, item.name)} (${item.bytes.length} bytes)`); } } return { versioned, aliases }; } // 1) 불변 버전 경로 계획 — 어느 하나라도 충돌하면 어떤 버전 경로에도 쓰지 않는다. /** @type {PlannedPackage[]} */ const planned = []; for (const pkg of active) { const remoteHashes = await registry.listFileHashes(pkg.versionPath); planned.push({ ...pkg, remoteHashes, plan: planVersionedPackage({ items: pkg.spec.items, remoteHashes }), }); } const aborted = planned.filter((pkg) => pkg.plan.action === "abort"); if (aborted.length > 0) { // 재실행 복구: 이미 완성된 버전 경로(게시본)로 끊긴 별칭을 맞춘 뒤 중단한다. for (const pkg of aborted) { if (!(await aliasMayAdvance(registry, pkg, version, log, aliases))) continue; aliases[pkg.spec.kind] = await restoreAliasFromPublished(registry, pkg); } throw conflictError(aborted, aliases); } // 2) 버전 경로 업로드 — 모두 완성되기 전에는 어떤 별칭도 건드리지 않는다. for (const pkg of planned) { if (pkg.plan.action === "skip") { log(`변경 없음(건너뜀): ${pkg.versionPath}`); versioned[pkg.spec.kind] = "unchanged"; continue; } for (const item of pkg.plan.uploads) await registry.uploadFile(pkg.versionPath, item); versioned[pkg.spec.kind] = pkg.plan.uploads.length === pkg.spec.items.length ? "uploaded" : "resumed"; } // 3) 공유 별칭 — 더 새로운 버전이 게시돼 있으면 되돌리지 않는다. 파일 단위, 인덱스가 마지막. for (const pkg of planned) { if (!(await aliasMayAdvance(registry, pkg, version, log, aliases))) continue; const status = await publishAlias(registry, pkg); if (status === "unchanged") log(`변경 없음(건너뜀): ${pkg.aliasPath}`); aliases[pkg.spec.kind] = status; } return { versioned, aliases }; }