// node --test scripts/ci/lib/portable-publish-alias.test.mjs // *-latest 별칭 교체 회귀: 패키지째 지우고 부품을 다시 올리는 동안 인덱스가 404가 되던 문제, // 중간 실패 후 CI 재실행(재빌드 바이트 → 버전 경로 abort)으로 별칭이 복구되지 않던 문제. import assert from "node:assert/strict"; import { createHash } from "node:crypto"; import { readFileSync } from "node:fs"; import { test } from "node:test"; import { withFileDeletion } from "./forgejo-generic-file-delete.mjs"; import { hashPayloads, planAliasFiles, planAliasRestore, PortablePublishError, publishPortablePackages, selectAliasItems, } 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 runtimeJson = (version, generatedAt = "2026-09-20T00:00:00.000Z") => JSON.stringify({ schemaVersion: 1, version, generatedAt }); const portableJson = (version) => JSON.stringify({ version }); const runtimeIndex = (version, generatedAt) => payload("runtime.json", runtimeJson(version, generatedAt), "application/json"); const portableIndex = (version) => payload("portable.json", portableJson(version), "application/json"); const installer = (text = "irm") => payload("install-d3ro-voice.ps1", text, "text/plain"); /** * Forgejo 동작을 흉내 내는 메모리 registry: 같은 이름 PUT은 409, 파일/버전 단위 삭제 지원. * 모든 쓰기 직후 watch 경로의 존재 여부를 timeline 에 기록한다. */ function forgejoLikeRegistry(initial = {}, { watch = [], failUpload } = {}) { const packages = new Map( Object.entries(initial).map(([path, files]) => [path, new Map(Object.entries(files))]), ); const calls = []; const timeline = []; const snapshot = (op) => timeline.push({ op, present: Object.fromEntries( watch.map((key) => { const [path, name] = key.split("/"); return [key, packages.get(path)?.has(name) ?? false]; }), ), }); return { packages, calls, timeline, 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(["deleteVersion", versionPath]); packages.delete(versionPath); snapshot(`deleteVersion ${versionPath}`); }, async deleteFile(versionPath, name) { calls.push(["deleteFile", versionPath, name]); const files = packages.get(versionPath); files?.delete(name); if (files && files.size === 0) packages.delete(versionPath); snapshot(`deleteFile ${versionPath}/${name}`); }, async uploadFile(versionPath, file) { calls.push(["upload", versionPath, file.name]); if (failUpload?.(versionPath, file.name)) throw new Error(`HTTP 524 ${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); snapshot(`upload ${versionPath}/${file.name}`); }, async readTextFile(versionPath, name) { calls.push(["read", versionPath, name]); return packages.get(versionPath)?.get(name); }, }; } const RUNTIME_PARTS = (tag) => [ payload("d3ro-runtime-sidecar.tar.gz.001", `sidecar-1-${tag}`), payload("d3ro-runtime-sidecar.tar.gz.002", `sidecar-2-${tag}`), payload("d3ro-runtime-ffmpeg.tar.gz.001", `ffmpeg-${tag}`), ]; const publishRuntime = (registry, version, runtimePayloads) => publishPortablePackages({ version, registry, log: () => {}, packages: [ { kind: "runtime", indexName: "runtime.json", payloads: runtimePayloads, aliasNames: ["runtime.json"] }, ], }); // ── 순수 정책 ───────────────────────────────────────────────────────────── test("selectAliasItems keeps only alias files and moves the index to the end", () => { const items = hashPayloads([portableIndex("1.9.1"), payload("a.zip.001", "z"), payload("a.7z.001", "v"), installer()]); const selected = selectAliasItems({ items, indexName: "portable.json", aliasNames: ["portable.json", "a.zip.001", "install-d3ro-voice.ps1"], }); assert.deepEqual( selected.map((item) => item.name), ["a.zip.001", "install-d3ro-voice.ps1", "portable.json"], ); assert.throws( () => selectAliasItems({ items, indexName: "portable.json", aliasNames: ["a.zip.001"] }), /portable\.json/, ); assert.throws( () => selectAliasItems({ items, indexName: "portable.json", aliasNames: ["portable.json", "nope"] }), /nope/, ); }); test("planAliasFiles swaps only changed files and prunes stale ones", () => { const items = hashPayloads([payload("new.zip.001", "n"), installer("same"), portableIndex("1.9.1")]); const plan = planAliasFiles({ items, remoteHashes: new Map([ ["old.zip.001", sha("o")], ["install-d3ro-voice.ps1", sha("same")], ["portable.json", sha(portableJson("1.9.0"))], ]), }); assert.equal(plan.action, "update"); assert.deepEqual( plan.writes.map(({ item, replace }) => [item.name, replace]), [["new.zip.001", false], ["portable.json", true]], ); assert.deepEqual(plan.prunes, ["old.zip.001"]); assert.equal(planAliasFiles({ items, remoteHashes: new Map(items.map((i) => [i.name, i.sha256])) }).action, "skip"); }); test("planAliasRestore only restores from a complete versioned package and never copies binaries", () => { const aliasItems = hashPayloads([payload("a.zip.001", "rebuilt-zip"), runtimeIndex("1.9.1", "B")]); const versionedHashes = new Map([ ["a.zip.001", sha("zip")], ["runtime.json", sha(runtimeJson("1.9.1", "A"))], ]); // 별칭에 같은 zip이 이미 있으면 인덱스만 옮기면 된다. const ok = planAliasRestore({ aliasItems, versionedHashes, aliasHashes: new Map([["a.zip.001", sha("zip")], ["stale", sha("s")]]), }); assert.equal(ok.restorable, true); assert.deepEqual(ok.reads.map((read) => [read.name, read.sha256]), [["runtime.json", sha(runtimeJson("1.9.1", "A"))]]); assert.deepEqual(ok.prunes, ["stale"]); // 이진 부품이 어긋나 있으면 복구하지 않는다. assert.equal(planAliasRestore({ aliasItems, versionedHashes, aliasHashes: new Map() }).restorable, false); // 버전 경로 미완성(인덱스 없음)이면 복구하지 않는다. assert.equal( planAliasRestore({ aliasItems, versionedHashes: new Map([["a.zip.001", sha("zip")]]), aliasHashes: new Map([["a.zip.001", sha("zip")]]), }).restorable, false, ); }); // ── 유스케이스: 404 구간 ────────────────────────────────────────────────── test("a tag bump never deletes runtime-latest nor re-uploads parts; runtime.json is missing only for its own swap", async () => { const registry = forgejoLikeRegistry( { "runtime-latest": { "d3ro-runtime-sidecar.tar.gz.001": "legacy-alias-part", "runtime.json": runtimeJson("1.9.0"), }, }, { watch: ["runtime-latest/runtime.json"] }, ); const result = await publishRuntime(registry, "1.9.1", [...RUNTIME_PARTS("191"), runtimeIndex("1.9.1", "2026-09-28T00:00:00.000Z")]); assert.deepEqual(result, { versioned: { runtime: "uploaded" }, aliases: { runtime: "replaced" } }); assert.equal(registry.calls.some(([op]) => op === "deleteVersion"), false); assert.equal( registry.calls.some(([op, path, name]) => op === "upload" && path === "runtime-latest" && name !== "runtime.json"), false, "parts must not be re-uploaded to the alias — runtime.json points at runtime-", ); const gap = registry.timeline.filter((entry) => !entry.present["runtime-latest/runtime.json"]); assert.deepEqual( gap.map((entry) => entry.op), ["deleteFile runtime-latest/runtime.json"], "the only write while the index is missing is the index swap itself", ); assert.deepEqual([...registry.packages.get("runtime-latest").keys()], ["runtime.json"]); assert.equal( registry.packages.get("runtime-latest").get("runtime.json"), runtimeJson("1.9.1", "2026-09-28T00:00:00.000Z"), ); }); test("portable-latest keeps the old index and installer live while new zip parts upload, prunes after the swap", async () => { const registry = forgejoLikeRegistry( { "portable-latest": { "D3RO-Voice-1.9.0-x64-portable.7z.001": "legacy-volume", "D3RO-Voice-1.9.0-x64-portable.zip.001": "zip190", "install-d3ro-voice.ps1": "irm-old", "portable.json": portableJson("1.9.0"), }, }, { watch: ["portable-latest/portable.json", "portable-latest/install-d3ro-voice.ps1"] }, ); await publishPortablePackages({ version: "1.9.1", registry, log: () => {}, packages: [ { kind: "portable", indexName: "portable.json", payloads: [ payload("D3RO-Voice-1.9.1-x64-portable.7z.001", "vol191"), payload("D3RO-Voice-1.9.1-x64-portable.zip.001", "zip191"), installer("irm-new"), portableIndex("1.9.1"), ], aliasNames: ["D3RO-Voice-1.9.1-x64-portable.zip.001", "install-d3ro-voice.ps1", "portable.json"], }, ], }); const aliasOps = registry.timeline.map((entry) => entry.op).filter((op) => op.includes("portable-latest")); assert.deepEqual(aliasOps, [ "upload portable-latest/D3RO-Voice-1.9.1-x64-portable.zip.001", "deleteFile portable-latest/install-d3ro-voice.ps1", "upload portable-latest/install-d3ro-voice.ps1", "deleteFile portable-latest/portable.json", "upload portable-latest/portable.json", "deleteFile portable-latest/D3RO-Voice-1.9.0-x64-portable.7z.001", "deleteFile portable-latest/D3RO-Voice-1.9.0-x64-portable.zip.001", ]); const aliasFiles = Object.fromEntries(registry.packages.get("portable-latest")); assert.deepEqual(aliasFiles, { "D3RO-Voice-1.9.1-x64-portable.zip.001": "zip191", "install-d3ro-voice.ps1": "irm-new", "portable.json": portableJson("1.9.1"), }); }); test("an upload failure mid-alias leaves the previous index serving", async () => { const registry = forgejoLikeRegistry( { "portable-latest": { "D3RO-Voice-1.9.0-x64-portable.zip.001": "zip190", "install-d3ro-voice.ps1": "irm", "portable.json": portableJson("1.9.0"), }, }, { failUpload: (path, name) => path === "portable-latest" && name.endsWith(".zip.001") }, ); await assert.rejects( publishPortablePackages({ version: "1.9.1", registry, log: () => {}, packages: [ { kind: "portable", indexName: "portable.json", payloads: [payload("D3RO-Voice-1.9.1-x64-portable.zip.001", "zip191"), installer("irm"), portableIndex("1.9.1")], aliasNames: ["D3RO-Voice-1.9.1-x64-portable.zip.001", "install-d3ro-voice.ps1", "portable.json"], }, ], }), /HTTP 524/, ); assert.equal(registry.packages.get("portable-latest").get("portable.json"), portableJson("1.9.0")); assert.equal(registry.packages.get("portable-latest").get("D3RO-Voice-1.9.0-x64-portable.zip.001"), "zip190"); }); // ── 유스케이스: 재실행 복구 ──────────────────────────────────────────────── test("a CI re-run with rebuilt bytes restores an alias left without its index, then still fails closed", async () => { const publishedIndex = runtimeJson("1.9.1", "2026-09-28T00:00:00.000Z"); const registry = forgejoLikeRegistry({ "runtime-1.9.1": { "d3ro-runtime-sidecar.tar.gz.001": "sidecar-original", "runtime.json": publishedIndex, }, // 이전 실행이 runtime.json DELETE 뒤 PUT에서 끊겼다. "runtime-latest": { "d3ro-runtime-sidecar.tar.gz.001": "legacy-alias-part" }, }); await assert.rejects( publishRuntime(registry, "1.9.1", [ payload("d3ro-runtime-sidecar.tar.gz.001", "sidecar-rebuilt"), runtimeIndex("1.9.1", "2026-09-28T01:00:00.000Z"), ]), (error) => error instanceof PortablePublishError && /runtime-1\.9\.1/.test(error.message) && /runtime-latest/.test(error.message), ); assert.equal(registry.packages.get("runtime-latest").get("runtime.json"), publishedIndex); assert.equal(registry.packages.get("runtime-latest").has("d3ro-runtime-sidecar.tar.gz.001"), false); assert.equal( registry.calls.some(([op, path]) => op !== "list" && op !== "read" && path === "runtime-1.9.1"), false, "the immutable versioned package is never written", ); assert.equal(registry.packages.get("runtime-1.9.1").get("d3ro-runtime-sidecar.tar.gz.001"), "sidecar-original"); }); test("re-run recovery never rolls back a newer alias nor restores from an incomplete versioned package", async () => { const newer = runtimeJson("1.9.2"); const rolledForward = forgejoLikeRegistry({ "runtime-1.9.1": { "d3ro-runtime-sidecar.tar.gz.001": "p", "runtime.json": runtimeJson("1.9.1", "A") }, "runtime-latest": { "runtime.json": newer }, }); await assert.rejects( publishRuntime(rolledForward, "1.9.1", [payload("d3ro-runtime-sidecar.tar.gz.001", "rebuilt"), runtimeIndex("1.9.1", "B")]), PortablePublishError, ); assert.equal(rolledForward.packages.get("runtime-latest").get("runtime.json"), newer); assert.equal(rolledForward.calls.some(([op]) => op === "upload" || op.startsWith("delete")), false); const incomplete = forgejoLikeRegistry({ "runtime-1.9.1": { "d3ro-runtime-sidecar.tar.gz.001": "p" }, "runtime-latest": { "runtime.json": runtimeJson("1.9.0") }, }); await assert.rejects( publishRuntime(incomplete, "1.9.1", [payload("d3ro-runtime-sidecar.tar.gz.001", "rebuilt"), runtimeIndex("1.9.1", "B")]), PortablePublishError, ); assert.equal(incomplete.packages.get("runtime-latest").get("runtime.json"), runtimeJson("1.9.0")); assert.equal(incomplete.calls.some(([op]) => op === "upload" || op.startsWith("delete")), false); }); test("a conflict in one package blocks every versioned upload (no partial new version)", async () => { const registry = forgejoLikeRegistry({ "portable-1.9.1": { "D3RO-Voice-1.9.1-x64-portable.7z.001": "scoop-pinned" }, }); await assert.rejects( publishPortablePackages({ version: "1.9.1", registry, log: () => {}, packages: [ { kind: "runtime", indexName: "runtime.json", payloads: [payload("p", "p"), runtimeIndex("1.9.1")], aliasNames: ["runtime.json"] }, { kind: "portable", indexName: "portable.json", payloads: [payload("D3RO-Voice-1.9.1-x64-portable.7z.001", "rebuilt"), portableIndex("1.9.1")] }, ], }), /portable-1\.9\.1/, ); assert.equal(registry.calls.some(([op]) => op === "upload"), false); }); test("an alias set without the index is rejected before any IO", async () => { const registry = forgejoLikeRegistry(); await assert.rejects( publishPortablePackages({ version: "1.9.1", registry, log: () => {}, packages: [{ kind: "runtime", indexName: "runtime.json", payloads: [payload("p", "p"), runtimeIndex("1.9.1")], aliasNames: ["p"] }], }), (error) => error instanceof PortablePublishError && /runtime\.json/.test(error.message), ); assert.equal(registry.calls.length, 0); }); test("dry run lists only alias files for the alias path", async () => { const lines = []; await publishPortablePackages({ version: "1.9.1", registry: forgejoLikeRegistry(), dryRun: true, log: (line) => lines.push(line), packages: [ { kind: "runtime", indexName: "runtime.json", payloads: [payload("p", "p"), runtimeIndex("1.9.1")], aliasNames: ["runtime.json"] }, ], }); assert.deepEqual(lines.map((line) => line.split(" ")[2]), ["runtime-1.9.1/p", "runtime-1.9.1/runtime.json", "runtime-latest/runtime.json"]); }); // ── IO 어댑터 / 조립 ────────────────────────────────────────────────────── test("withFileDeletion deletes a single file and fails closed on non-404 errors", async () => { const seen = []; const statuses = { "a.json": 204, "gone.json": 404, "boom.json": 500 }; const fetchImpl = async (url, init = {}) => { seen.push(`${init.method} ${url}`); return new Response(null, { status: statuses[url.split("/").pop()] }); }; const base = { fileUrl: (path, name) => `https://feed.test/${path}/${name}`, marker: 1 }; const registry = withFileDeletion(base, { fetchImpl }); assert.equal(registry.marker, 1); await registry.deleteFile("runtime-latest", "a.json"); await registry.deleteFile("runtime-latest", "gone.json"); await assert.rejects(registry.deleteFile("runtime-latest", "boom.json"), /HTTP 500/); assert.deepEqual(seen, [ "DELETE https://feed.test/runtime-latest/a.json", "DELETE https://feed.test/runtime-latest/gone.json", "DELETE https://feed.test/runtime-latest/boom.json", ]); }); test("the portable publisher swaps alias files individually and keeps parts off runtime-latest", () => { const source = readFileSync(new URL("../publish-portable-release.mjs", import.meta.url), "utf8"); assert.match(source, /withFileDeletion\(/); assert.match(source, /aliasNames:\s*\[\s*'runtime\.json'\s*\]/); assert.match(source, /aliasNames:\s*portableAliasNames/); // 인덱스가 버전 경로에서도 마지막(설치 스크립트 뒤)이어야 재실행 복구가 완성 여부를 판단할 수 있다. assert.ok(source.indexOf("name: 'install-d3ro-voice.ps1'") < source.indexOf("name: 'portable.json'")); assert.doesNotMatch(source, /method:\s*['"](?:PUT|DELETE)['"]/); });