fix(release): keep portable/runtime version packages immutable and guard latest aliases against rollback
This commit is contained in:
parent
88f3dbcd69
commit
5f12ab4772
4 changed files with 658 additions and 88 deletions
322
scripts/ci/lib/portable-publish-policy.test.mjs
Normal file
322
scripts/ci/lib/portable-publish-policy.test.mjs
Normal 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/);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue