From 5f12ab4772143ae1a024e87d2d95ca55c75170a6 Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Mon, 28 Sep 2026 02:16:18 +0900 Subject: [PATCH] fix(release): keep portable/runtime version packages immutable and guard latest aliases against rollback --- scripts/ci/lib/forgejo-generic-registry.mjs | 76 +++++ scripts/ci/lib/portable-publish-policy.mjs | 231 +++++++++++++ .../ci/lib/portable-publish-policy.test.mjs | 322 ++++++++++++++++++ scripts/ci/publish-portable-release.mjs | 117 ++----- 4 files changed, 658 insertions(+), 88 deletions(-) create mode 100644 scripts/ci/lib/forgejo-generic-registry.mjs create mode 100644 scripts/ci/lib/portable-publish-policy.mjs create mode 100644 scripts/ci/lib/portable-publish-policy.test.mjs diff --git a/scripts/ci/lib/forgejo-generic-registry.mjs b/scripts/ci/lib/forgejo-generic-registry.mjs new file mode 100644 index 0000000..cd81eee --- /dev/null +++ b/scripts/ci/lib/forgejo-generic-registry.mjs @@ -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, + * 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(); + }, + }; +} diff --git a/scripts/ci/lib/portable-publish-policy.mjs b/scripts/ci/lib/portable-publish-policy.mjs new file mode 100644 index 0000000..090db34 --- /dev/null +++ b/scripts/ci/lib/portable-publish-policy.mjs @@ -0,0 +1,231 @@ +// 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 }; +} diff --git a/scripts/ci/lib/portable-publish-policy.test.mjs b/scripts/ci/lib/portable-publish-policy.test.mjs new file mode 100644 index 0000000..70eca51 --- /dev/null +++ b/scripts/ci/lib/portable-publish-policy.test.mjs @@ -0,0 +1,322 @@ +// node --test scripts/ci/lib/portable-publish-policy.test.mjs +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { test } from "node:test"; +import { createForgejoGenericRegistry } from "./forgejo-generic-registry.mjs"; +import { + decideAliasUpdate, + hashPayloads, + parsePublishedIndexVersion, + planAliasPackage, + planVersionedPackage, + publishPortablePackages, +} from "./portable-publish-policy.mjs"; + +const sha = (text) => createHash("sha256").update(Buffer.from(text, "utf8")).digest("hex"); +const payload = (name, text, contentType = "application/octet-stream") => ({ + name, + bytes: Buffer.from(text, "utf8"), + contentType, +}); +const runtimeIndex = (version, generatedAt = "2026-09-20T00:00:00.000Z") => + payload( + "runtime.json", + JSON.stringify({ schemaVersion: 1, version, generatedAt }), + "application/json", + ); +const portableIndex = (version) => + payload("portable.json", JSON.stringify({ version }), "application/json"); + +/** 메모리 registry. packages: Map> */ +function fakeRegistry(initial = {}) { + const packages = new Map( + Object.entries(initial).map(([path, files]) => [path, new Map(Object.entries(files))]), + ); + const calls = []; + return { + packages, + calls, + async listFileHashes(versionPath) { + calls.push(["list", versionPath]); + const files = packages.get(versionPath); + return new Map([...(files ?? new Map())].map(([name, text]) => [name, sha(text)])); + }, + async deleteVersion(versionPath) { + calls.push(["delete", versionPath]); + packages.delete(versionPath); + }, + async uploadFile(versionPath, file) { + calls.push(["upload", versionPath, file.name]); + const files = packages.get(versionPath) ?? new Map(); + if (files.has(file.name)) throw new Error(`409 conflict ${versionPath}/${file.name}`); + files.set(file.name, Buffer.from(file.bytes).toString("utf8")); + packages.set(versionPath, files); + }, + async readTextFile(versionPath, name) { + calls.push(["read", versionPath, name]); + return packages.get(versionPath)?.get(name); + }, + }; +} + +const publish = (registry, version, runtime, portable) => + publishPortablePackages({ + version, + registry, + log: () => {}, + packages: [ + { kind: "runtime", indexName: "runtime.json", payloads: runtime }, + { kind: "portable", indexName: "portable.json", payloads: portable }, + ], + }); + +// ── 순수 정책 ───────────────────────────────────────────────────────────── + +test("planVersionedPackage aborts when a published file has different bytes", () => { + const items = hashPayloads([payload("d3ro-runtime-sidecar.tar.gz.001", "rebuilt")]); + const plan = planVersionedPackage({ + items, + remoteHashes: new Map([["d3ro-runtime-sidecar.tar.gz.001", sha("original")]]), + }); + assert.equal(plan.action, "abort"); + assert.deepEqual( + plan.conflicts.map((item) => item.name), + ["d3ro-runtime-sidecar.tar.gz.001"], + ); +}); + +test("planVersionedPackage resumes a partial upload with only the missing files, in order", () => { + const items = hashPayloads([ + payload("a.7z.001", "one"), + payload("a.7z.002", "two"), + portableIndex("1.9.0"), + ]); + const plan = planVersionedPackage({ items, remoteHashes: new Map([["a.7z.001", sha("one")]]) }); + assert.equal(plan.action, "upload"); + assert.deepEqual( + plan.uploads.map((item) => item.name), + ["a.7z.002", "portable.json"], + ); +}); + +test("planVersionedPackage skips when every file already matches", () => { + const items = hashPayloads([payload("a.7z.001", "one")]); + assert.equal( + planVersionedPackage({ items, remoteHashes: new Map([["a.7z.001", sha("one")]]) }).action, + "skip", + ); +}); + +test("planAliasPackage replaces the whole alias when any file differs", () => { + const items = hashPayloads([payload("x", "new")]); + assert.deepEqual(planAliasPackage({ items, remoteHashes: new Map([["x", sha("old")]]) }), { + action: "replace", + deleteFirst: true, + }); + assert.deepEqual(planAliasPackage({ items, remoteHashes: new Map() }), { + action: "replace", + deleteFirst: false, + }); + assert.equal(planAliasPackage({ items, remoteHashes: new Map([["x", sha("new")]]) }).action, "skip"); +}); + +test("parsePublishedIndexVersion reads a semver version or returns null", () => { + assert.equal(parsePublishedIndexVersion('{"version":"1.9.1"}'), "1.9.1"); + assert.equal(parsePublishedIndexVersion('{"version":"v2.0.0-beta.1"}'), "2.0.0-beta.1"); + assert.equal(parsePublishedIndexVersion('{"schemaVersion":2}'), null); + assert.equal(parsePublishedIndexVersion(""), null); + assert.equal(parsePublishedIndexVersion("null"), null); +}); + +test("decideAliasUpdate skips older versions and fails closed on an unreadable version", () => { + assert.deepEqual(decideAliasUpdate({ publishingVersion: "1.9.0", publishedVersion: "1.9.1" }), { + update: false, + reason: "newer-published", + abort: false, + }); + assert.equal(decideAliasUpdate({ publishingVersion: "1.9.1", publishedVersion: "1.9.0" }).update, true); + assert.equal(decideAliasUpdate({ publishingVersion: "1.9.0", publishedVersion: undefined }).update, true); + assert.deepEqual(decideAliasUpdate({ publishingVersion: "1.9.0", publishedVersion: null }), { + update: false, + abort: true, + reason: "unreadable-feed", + }); +}); + +// ── 유스케이스: 레드팀 회귀 ──────────────────────────────────────────────── + +test("re-run with rebuilt runtime bytes never deletes runtime- nor touches runtime-latest", async () => { + const published = { + "runtime-1.9.0": { + "d3ro-runtime-sidecar.tar.gz.001": "sidecar-original", + "runtime.json": runtimeIndex("1.9.0").bytes.toString("utf8"), + }, + "runtime-latest": { + "d3ro-runtime-sidecar.tar.gz.001": "sidecar-original", + "runtime.json": runtimeIndex("1.9.0").bytes.toString("utf8"), + }, + }; + const registry = fakeRegistry(published); + await assert.rejects( + publish( + registry, + "1.9.0", + [payload("d3ro-runtime-sidecar.tar.gz.001", "sidecar-rebuilt"), runtimeIndex("1.9.0", "2026-09-21T00:00:00.000Z")], + [payload("D3RO-Voice-1.9.0.7z.001", "vol"), portableIndex("1.9.0")], + ), + /runtime-1\.9\.0/, + ); + assert.equal(registry.calls.some(([op]) => op === "delete" || op === "upload"), false); + assert.equal( + registry.packages.get("runtime-1.9.0").get("d3ro-runtime-sidecar.tar.gz.001"), + "sidecar-original", + ); +}); + +test("re-run with rebuilt portable volumes never replaces portable-", async () => { + const registry = fakeRegistry({ + "portable-1.9.0": { "D3RO-Voice-1.9.0.7z.001": "scoop-pinned" }, + }); + await assert.rejects( + publish(registry, "1.9.0", [], [payload("D3RO-Voice-1.9.0.7z.001", "rebuilt"), portableIndex("1.9.0")]), + /portable-1\.9\.0/, + ); + assert.equal(registry.calls.some(([op]) => op === "delete" || op === "upload"), false); +}); + +test("re-run after a partial upload resumes the versioned package, then publishes the aliases", async () => { + const registry = fakeRegistry({ + "runtime-1.9.0": { "d3ro-runtime-sidecar.tar.gz.001": "p1" }, + }); + const result = await publish( + registry, + "1.9.0", + [payload("d3ro-runtime-sidecar.tar.gz.001", "p1"), payload("d3ro-runtime-sidecar.tar.gz.002", "p2"), runtimeIndex("1.9.0")], + [payload("D3RO-Voice-1.9.0.7z.001", "vol"), portableIndex("1.9.0")], + ); + assert.deepEqual(result.versioned, { runtime: "resumed", portable: "uploaded" }); + assert.deepEqual(result.aliases, { runtime: "replaced", portable: "replaced" }); + assert.equal(registry.calls.some(([op, path]) => op === "delete" && path.endsWith("-1.9.0")), false); + assert.equal(registry.packages.get("runtime-latest").get("runtime.json"), runtimeIndex("1.9.0").bytes.toString("utf8")); +}); + +test("versioned packages are all complete before any alias is touched", async () => { + const registry = fakeRegistry(); + await publish( + registry, + "1.9.1", + [payload("d3ro-runtime-sidecar.tar.gz.001", "p"), runtimeIndex("1.9.1")], + [payload("D3RO-Voice-1.9.1.7z.001", "v"), portableIndex("1.9.1")], + ); + const firstAliasCall = registry.calls.findIndex(([, path]) => path.endsWith("-latest")); + const lastVersionedUpload = registry.calls.findLastIndex( + ([op, path]) => op === "upload" && path.endsWith("-1.9.1"), + ); + assert.ok(firstAliasCall > lastVersionedUpload); +}); + +test("an older tag re-run does not roll runtime-latest / portable-latest back", async () => { + const newerRuntime = runtimeIndex("1.9.1").bytes.toString("utf8"); + const newerPortable = portableIndex("1.9.1").bytes.toString("utf8"); + const registry = fakeRegistry({ + "runtime-latest": { "d3ro-runtime-sidecar.tar.gz.001": "p191", "runtime.json": newerRuntime }, + "portable-latest": { "D3RO-Voice-1.9.1.7z.001": "v191", "portable.json": newerPortable }, + }); + const result = await publish( + registry, + "1.9.0", + [payload("d3ro-runtime-sidecar.tar.gz.001", "p190"), runtimeIndex("1.9.0")], + [payload("D3RO-Voice-1.9.0.7z.001", "v190"), portableIndex("1.9.0")], + ); + assert.deepEqual(result.aliases, { runtime: "newer-published", portable: "newer-published" }); + assert.equal(registry.calls.some(([op, path]) => op !== "read" && op !== "list" && path.endsWith("-latest")), false); + assert.equal(registry.packages.get("runtime-latest").get("runtime.json"), newerRuntime); + assert.equal(registry.packages.get("portable-latest").get("portable.json"), newerPortable); +}); + +test("an alias whose published version cannot be read is left untouched (fail-closed)", async () => { + const registry = fakeRegistry({ + "runtime-latest": { "runtime.json": '{"schemaVersion":2,"release":"2.0.0"}' }, + }); + await assert.rejects( + publish(registry, "1.9.0", [payload("d3ro-runtime-sidecar.tar.gz.001", "p"), runtimeIndex("1.9.0")], []), + /runtime-latest\/runtime\.json/, + ); + assert.equal(registry.calls.some(([op, path]) => op === "delete" && path === "runtime-latest"), false); +}); + +test("a newer version replaces the alias atomically (delete then full upload)", async () => { + const registry = fakeRegistry({ + "runtime-latest": { "d3ro-runtime-sidecar.tar.gz.001": "p190", "runtime.json": runtimeIndex("1.9.0").bytes.toString("utf8") }, + }); + const result = await publish( + registry, + "1.9.1", + [payload("d3ro-runtime-sidecar.tar.gz.001", "p191"), runtimeIndex("1.9.1")], + [], + ); + assert.equal(result.aliases.runtime, "replaced"); + assert.equal(registry.packages.get("runtime-latest").get("d3ro-runtime-sidecar.tar.gz.001"), "p191"); +}); + +test("dry run performs no registry IO", async () => { + const registry = fakeRegistry(); + const lines = []; + await publishPortablePackages({ + version: "1.9.0", + registry, + dryRun: true, + log: (line) => lines.push(line), + packages: [{ kind: "portable", indexName: "portable.json", payloads: [portableIndex("1.9.0")] }], + }); + assert.equal(registry.calls.length, 0); + assert.equal(lines.length, 2); +}); + +// ── IO 어댑터 ───────────────────────────────────────────────────────────── + +function fakeFetch(routes) { + const seen = []; + const fetchImpl = async (url, init = {}) => { + seen.push({ url, method: init.method ?? "GET" }); + const key = `${init.method ?? "GET"} ${url.replace(/\?ts=\d+$/, "")}`; + const route = routes[key]; + if (!route) return new Response("not found", { status: 404 }); + return new Response(route.body ?? "", { status: route.status ?? 200 }); + }; + return { fetchImpl, seen }; +} + +const FEED = "https://feed.test/generic/d3ro-voice"; +const API = "https://feed.test/api/v1/packages/d3ro-voice"; + +test("registry adapter maps 404 to empty/undefined and fails closed on other errors", async () => { + const { fetchImpl } = fakeFetch({ + [`GET ${API}/runtime-1.9.0/files`]: { body: JSON.stringify([{ name: "a", sha256: "h" }]) }, + [`GET ${API}/runtime-latest/files`]: { status: 500 }, + [`GET ${FEED}/runtime-latest/runtime.json`]: { status: 524 }, + }); + const registry = createForgejoGenericRegistry({ feedUrl: FEED, packageApiUrl: API, fetchImpl }); + assert.deepEqual([...(await registry.listFileHashes("runtime-1.9.0"))], [["a", "h"]]); + assert.equal((await registry.listFileHashes("portable-1.9.0")).size, 0); + await assert.rejects(registry.listFileHashes("runtime-latest"), /HTTP 500/); + assert.equal(await registry.readTextFile("portable-latest", "portable.json"), undefined); + await assert.rejects(registry.readTextFile("runtime-latest", "runtime.json"), /HTTP 524/); +}); + +test("registry adapter throws on a failed upload instead of exiting", async () => { + const { fetchImpl } = fakeFetch({ [`PUT ${FEED}/portable-1.9.0/a.7z.001`]: { status: 413 } }); + const registry = createForgejoGenericRegistry({ feedUrl: FEED, packageApiUrl: API, fetchImpl }); + await assert.rejects( + registry.uploadFile("portable-1.9.0", { ...payload("a.7z.001", "x"), sha256: sha("x") }), + /HTTP 413/, + ); +}); + +test("the portable publisher routes every registry write through the policy use case", async () => { + const { readFileSync } = await import("node:fs"); + const source = readFileSync(new URL("../publish-portable-release.mjs", import.meta.url), "utf8"); + assert.match(source, /publishPortablePackages\(/); + assert.doesNotMatch(source, /method:\s*['"](?:PUT|DELETE)['"]/); + assert.doesNotMatch(source, /async function publishBase/); +}); diff --git a/scripts/ci/publish-portable-release.mjs b/scripts/ci/publish-portable-release.mjs index 13c37dd..6889de3 100644 --- a/scripts/ci/publish-portable-release.mjs +++ b/scripts/ci/publish-portable-release.mjs @@ -10,6 +10,11 @@ // .../generic/d3ro-voice/portable-/portable.json // .../generic/d3ro-voice/portable-/install-d3ro-voice.ps1 // .../generic/d3ro-voice/portable-latest/... (동일 파일 alias) +// .../generic/d3ro-voice/runtime-/... (로컬 AI 런타임 부품 + runtime.json) +// .../generic/d3ro-voice/runtime-latest/... (동일 파일 alias) +// +// 버전 경로는 불변(다른 바이트면 중단, 부분 업로드는 이어 올림), *-latest 별칭은 버전 경로가 +// 모두 완성된 뒤 더 오래된 버전으로 되돌리지 않을 때만 교체한다 — lib/portable-publish-policy.mjs. // // 사용: // node scripts/ci/build-portable.mjs @@ -21,6 +26,8 @@ import { existsSync, readFileSync } from 'node:fs' import { readFile } from 'node:fs/promises' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' +import { createForgejoGenericRegistry } from './lib/forgejo-generic-registry.mjs' +import { publishPortablePackages } from './lib/portable-publish-policy.mjs' const { forgejoAuthorization } = credentialHelpers @@ -134,98 +141,32 @@ async function forgejoFetch(url, init = {}) { const PACKAGE_API = 'https://git.chanpaca.net/api/v1/packages/yunchan/generic/d3ro-voice' -/** - * Forgejo generic registry는 HEAD를 405로 거부하고 파일 해시도 헤더로 주지 않는다. - * 패키지 버전의 파일 목록 API는 sha256을 주므로, 큰 볼륨을 내려받지 않고도 원격 - * 바이트가 로컬과 같은지 정확히 판단할 수 있다. - * (Range GET의 크기만 비교하면 90MiB 볼륨에서 크기가 우연히 같을 때 다른 바이트를 - * "동일"로 오판해 별칭이 일부만 새 바이트로 갱신된다 — 실측 사고.) - */ -async function remoteFileHashes(versionPath) { - const response = await forgejoFetch(`${PACKAGE_API}/${encodeURIComponent(versionPath)}/files`) - if (!response.ok) return new Map() - const files = await response.json() - return new Map(files.map((file) => [file.name, file.sha256])) -} +const registry = createForgejoGenericRegistry({ + feedUrl: FEED, + packageApiUrl: PACKAGE_API, + fetchImpl: forgejoFetch, + onUploaded: (url) => console.log(`[portable] uploaded ${url}`), +}) -async function deletePackageVersion(versionPath) { - const response = await forgejoFetch(`${PACKAGE_API}/${encodeURIComponent(versionPath)}`, { - method: 'DELETE', +// 정책(불변 버전 경로 · 별칭 롤백 방지)은 lib/portable-publish-policy.mjs 에 있다. +// 버전 경로(runtime-, portable-)를 모두 완성한 뒤에만 *-latest 별칭을 갱신한다. +try { + await publishPortablePackages({ + version, + packages: [ + { kind: 'runtime', indexName: 'runtime.json', payloads: runtimePayloads }, + { kind: 'portable', indexName: 'portable.json', payloads }, + ], + registry, + dryRun: check, + describeUrl: registry.fileUrl, + log: (message) => console.log(`[portable] ${message}`), }) - if (!response.ok && response.status !== 404) { - console.error(`[portable] 버전 삭제 실패 (HTTP ${response.status}): ${versionPath}`) - process.exit(1) - } +} catch (error) { + console.error(`[portable] ${error instanceof Error ? error.message : String(error)}`) + process.exit(1) } -async function put(url, body, contentType) { - const response = await forgejoFetch(url, { - method: 'PUT', - headers: { 'Content-Type': contentType }, - body, - }) - if (!response.ok) { - console.error( - `[portable] 업로드 실패 (HTTP ${response.status}): ${url}\n` + - ' HTTP 413이면 Cloudflare 본문 한도(100MiB) 초과입니다. 볼륨 크기를 줄이세요.', - ) - process.exit(1) - } - console.log(`[portable] uploaded ${url}`) -} - -/** - * 한 버전 경로를 원자적으로 게시한다. - * Forgejo generic registry는 파일 단위 덮어쓰기를 거부(409)하므로, 내용이 다른 파일이 - * 하나라도 있으면 버전 전체를 지우고 모든 파일을 다시 올린다. 이렇게 해야 - * `runtime-latest`/`portable-latest` 같은 별칭이 낡은 바이트와 새 바이트가 섞이지 않는다. - */ -async function publishBase(base, basePayloads) { - const versionPath = base.split('/').pop() - const items = basePayloads.map((payload) => ({ - ...payload, - sha256: createHash('sha256').update(payload.bytes).digest('hex'), - })) - - if (check) { - for (const item of items) { - console.log(`[portable] (check) PUT ${base}/${item.name} (${item.bytes.length} bytes)`) - } - return - } - - const remoteHashes = await remoteFileHashes(versionPath) - const differing = items.filter((item) => remoteHashes.get(item.name) !== item.sha256) - if (differing.length === 0) { - console.log(`[portable] 변경 없음(건너): ${base}`) - return - } - - // 볼륨/부품은 불변 자산이다 — 같은 버전 경로에 다른 바이트가 있으면 덮어쓰지 않고 중단한다. - const isImmutableAsset = - base.includes(`/portable-${version}/`) && - differing.some((item) => item.name.includes('.7z.') || item.name.includes('.zip.')) - if (isImmutableAsset) { - console.error( - `[portable] ${version} 자산에 다른 바이트가 이미 있습니다: ${base}\n` + - ' 이미 게시된 버전은 덮어쓰지 않습니다(불변). 새 버전으로 게시하세요.', - ) - process.exit(1) - } - - if (remoteHashes.size > 0) { - await deletePackageVersion(versionPath) - } - for (const item of items) { - await put(`${base}/${encodeURIComponent(item.name)}`, item.bytes, item.contentType) - } -} - -await publishBase(`${FEED}/runtime-${version}`, runtimePayloads) -await publishBase(`${FEED}/runtime-latest`, runtimePayloads) -await publishBase(`${FEED}/portable-${version}`, payloads) -await publishBase(`${FEED}/portable-latest`, payloads) - console.log( [ '',