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,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<versionPath, Map<name, text>> */
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("<html>"), 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-<version> 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-<version>", 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/);
});