Files
Ekklesia/tests/package-publication.integration.ts
T
ekklesiaandClaude Opus 5.5 d3c62e63bf
CI / verify (pull_request) Successful in 1m1s
CI / infrastructure-smoke (pull_request) Successful in 35s
CI / migration-smoke (pull_request) Successful in 1m53s
CI / package-release (pull_request) Failing after 43s
CI / package-publication (pull_request) Skipped
CI / image-build (pull_request) Successful in 29s
CI / deploy-smoke (split) (pull_request) Canceled after 0s
CI / deploy-smoke (same-origin) (pull_request) Canceled after 38s
feat: publish the exact @ekklesia package train to the npm registry (ADR 0053)
Publishes one package release train to the Gitea npm registry under
River_of_Glory_Church, whose packages are publicly readable on purpose:
exactly the .tgz files the package-release gate proved, and nothing else.
Nothing is published by this change.

- scripts/publish-package-train.ts validates the whole local train before
  any request (gate manifest hash, version, revision, unmodified source,
  the nine packages, every artifact's size, SHA-256, integrity,
  SHA256SUMS line, and inner package.json), then publishes in dependency
  order with `npm publish <file.tgz>` under a staging tag. Each package
  is reconciled first (absent: publish; same bytes: accept; other bytes:
  hard stop, version burned), then read back: metadata (name, version,
  integrity, dependency maps, normalized bin, tarball on the registry)
  and the tarball itself, byte for byte, also anonymously. `latest` moves
  only after all nine verify; reruns converge and never delete anything.
  It writes a publication record. The token comes from
  EKKLESIA_PACKAGES_PUBLISH_TOKEN only, through a temporary npm config
  that refers to the variable.
- scripts/test-package-publication.sh proves it against a disposable
  Gitea 1.27.2 with public owners, a publisher team and an outsider:
  anonymous reads, refused unauthorized publishing, byte-identical round
  trips, the registry's own 409, partial-train recovery, the burned
  version hard stop, and dist-tag retry convergence.
- CI: package-release keeps its proven release and hands it on as an
  artifact with its manifest hash; the new package-publication job
  publishes exactly that artifact to the disposable Gitea. Artifact
  actions v3 (node20): v4 refuses any server other than github.com.
- .github/workflows/publish-packages.yml publishes to the real registry
  only by manual dispatch on main, after its own gate run, with the
  secret in the publish step alone.
- docs: the public-read model, the workflow, the record, credentials,
  immutability policy, provider moves, and the first-publication steps.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 09:49:27 -05:00

306 lines
12 KiB
TypeScript

import { execFile } from "node:child_process";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { promisify } from "node:util";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
type LocalTrain,
loadReleaseTrain,
normalizeRegistry,
type PublicationRecord,
PUBLISH_TOKEN_VARIABLE,
packumentUrl,
publicationOrder,
publishPackageTrain,
} from "../scripts/publish-package-train.js";
/**
* The publication tool against a disposable Gitea 1.27.2 (scripts/test-package-publication.sh),
* configured as the real registry is meant to be: public package owners anyone may read from, and a
* publisher team that alone may publish. Each scenario uses its own owner, so they never interfere.
*/
const execFileAsync = promisify(execFile);
/** Set by scripts/test-package-publication.sh for the disposable registry it started. */
const setting = (name: string): string => process.env[name] ?? "";
const origin = setting("EKKLESIA_TEST_REGISTRY_ORIGIN");
const releaseDirectory = setting("EKKLESIA_TEST_RELEASE");
const publishToken = setting("EKKLESIA_TEST_PUBLISH_TOKEN");
const outsiderToken = setting("EKKLESIA_TEST_OUTSIDER_TOKEN");
const [mainOwner, burnedOwner, tagOwner] = setting("EKKLESIA_TEST_OWNERS").split(" ");
if (!origin || !releaseDirectory || !publishToken || !outsiderToken || !tagOwner) {
throw new Error("Run this suite with scripts/test-package-publication.sh.");
}
const registryOf = (owner: string | undefined) =>
normalizeRegistry(`${origin}/api/packages/${owner}/npm/`, true);
let train: LocalTrain;
let order: string[];
const scratch: string[] = [];
beforeAll(async () => {
const manifest = JSON.parse(
await readFile(path.join(releaseDirectory, "ekklesia-package-train.json"), "utf8"),
) as { version: string; source: { revision: string } };
train = await loadReleaseTrain(releaseDirectory, {
revision: manifest.source.revision,
version: manifest.version,
});
order = publicationOrder(train.packages).map((entry) => entry.entry.name);
});
afterAll(async () => {
await Promise.all(scratch.splice(0).map((dir) => rm(dir, { force: true, recursive: true })));
});
function publish(
owner: string | undefined,
hooks?: Parameters<typeof publishPackageTrain>[0]["hooks"],
) {
return publishPackageTrain({
allowHttp: true,
registry: registryOf(owner),
releaseDirectory,
requirePublicRead: true,
revision: train.manifest.source.revision,
token: publishToken,
version: train.manifest.version,
...(hooks === undefined ? {} : { hooks }),
});
}
/** What anyone, signed in or not, gets for a package: its packument, or undefined on 404. */
async function anonymousPackument(owner: string | undefined, name: string) {
const response = await fetch(packumentUrl(registryOf(owner), name));
if (response.status === 404) return undefined;
expect(response.status).toBe(200);
return (await response.json()) as {
versions: Record<string, { dist: { tarball: string; integrity: string } }>;
"dist-tags": Record<string, string>;
};
}
async function anonymousBytes(url: string): Promise<Buffer> {
const response = await fetch(url);
expect(response.status).toBe(200);
return Buffer.from(await response.arrayBuffer());
}
function local(name: string) {
const found = train.packages.find((entry) => entry.entry.name === name);
if (!found) throw new Error(name);
return found;
}
/** `npm publish <file>` outside the tool, as an operator or an attacker might run it. */
async function npmPublish(owner: string | undefined, file: string, token: string): Promise<string> {
const home = await mkdtemp(path.join(tmpdir(), "ekklesia-npm-"));
scratch.push(home);
const registry = registryOf(owner);
const url = new URL(registry);
await writeFile(
path.join(home, "npmrc"),
`registry=${registry}\n//${url.host}${url.pathname}:_authToken=\${${PUBLISH_TOKEN_VARIABLE}}\n`,
);
try {
await execFileAsync(
"npm",
["publish", file, "--tag", "manual", "--access", "public", "--ignore-scripts"],
{
cwd: home,
env: {
[PUBLISH_TOKEN_VARIABLE]: token,
HOME: home,
PATH: process.env.PATH ?? "",
npm_config_globalconfig: path.join(home, "none"),
npm_config_userconfig: path.join(home, "npmrc"),
},
},
);
return "published";
} catch (error) {
return String((error as { stderr?: unknown }).stderr ?? "failed");
}
}
describe("the registry's access model", () => {
it("lets anyone search and read package metadata, with no account at all", async () => {
const search = await fetch(`${registryOf(mainOwner)}-/v1/search?text=ekklesia`);
expect(search.status).toBe(200);
// An absent package is 404 to an anonymous reader, not 401: reading is open.
expect(await anonymousPackument(mainOwner, "@ekklesia/contracts")).toBeUndefined();
});
it("refuses publishing without credentials, and from a user outside the publisher team", async () => {
const contracts = local("@ekklesia/contracts").path;
expect(await npmPublish(mainOwner, contracts, "")).toMatch(/ENEEDAUTH|E401|401/u);
expect(await npmPublish(mainOwner, contracts, outsiderToken)).toMatch(/E401|E403|40[13]/u);
expect(await anonymousPackument(mainOwner, "@ekklesia/contracts")).toBeUndefined();
});
});
describe("publishing a train, interrupted and resumed", () => {
let record: PublicationRecord;
it("run 1: stops before the sixth package, with five published and no final tag", async () => {
await expect(
publish(mainOwner, {
beforePublish: (_name, index) => {
if (index === 5) throw new Error("injected failure before the sixth package");
},
}),
).rejects.toThrow("injected failure");
for (const [index, name] of order.entries()) {
const packument = await anonymousPackument(mainOwner, name);
if (index < 5) {
const version = packument?.versions[train.manifest.version];
expect(version, name).toBeDefined();
expect(packument?.["dist-tags"].latest, name).toBeUndefined();
expect(packument?.["dist-tags"]["ekklesia-staging"], name).toBe(train.manifest.version);
expect((await anonymousBytes(version?.dist.tarball ?? "")).equals(local(name).data)).toBe(
true,
);
} else {
expect(packument, name).toBeUndefined();
}
}
});
it("run 2: verifies the five as they are, publishes the other four, and only then moves latest", async () => {
record = await publish(mainOwner);
expect(record.packages.map((entry) => entry.name)).toEqual(order);
expect(record.packages.map((entry) => entry.outcome)).toEqual([
...Array(5).fill("already-present"),
...Array(4).fill("published"),
]);
expect(record.packages.every((entry) => entry.finalTag === "moved")).toBe(true);
expect(record.packageTrain).toEqual({
manifestSha256: train.manifestSha256,
revision: train.manifest.source.revision,
version: train.manifest.version,
});
expect(JSON.stringify(record)).not.toContain(publishToken);
});
it("serves every package anonymously, byte for byte the release artifact", async () => {
for (const name of order) {
const packument = await anonymousPackument(mainOwner, name);
const version = packument?.versions[train.manifest.version];
expect(packument?.["dist-tags"].latest, name).toBe(train.manifest.version);
expect(version?.dist.integrity, name).toBe(local(name).entry.integrity);
expect(
(await anonymousBytes(version?.dist.tarball ?? "")).equals(local(name).data),
name,
).toBe(true);
const entry = record.packages.find((candidate) => candidate.name === name);
expect(entry?.registry.sha256).toBe(local(name).entry.sha256);
expect(entry?.registry.publicRead).toBe(true);
}
});
it("run 3: a rerun of a published train changes nothing", async () => {
const again = await publish(mainOwner);
expect(again.packages.every((entry) => entry.outcome === "already-present")).toBe(true);
expect(again.packages.every((entry) => entry.finalTag === "already-current")).toBe(true);
});
it("the registry itself refuses a second upload of a published version", async () => {
const name = "@ekklesia/module-sdk";
const packument = await anonymousPackument(mainOwner, name);
const tarballUrl = packument?.versions[train.manifest.version]?.dist.tarball ?? "";
// A raw upload, past npm's own client-side check, with the publisher's own token.
const { data, entry, packageJson } = local(name);
const response = await fetch(packumentUrl(registryOf(mainOwner), name), {
body: JSON.stringify({
_attachments: {
[path.basename(new URL(tarballUrl).pathname)]: {
content_type: "application/octet-stream",
data: data.toString("base64"),
length: data.length,
},
},
_id: name,
"dist-tags": { manual: entry.version },
name,
versions: { [entry.version]: { ...packageJson, dist: { integrity: entry.integrity } } },
}),
headers: { authorization: `Bearer ${publishToken}`, "content-type": "application/json" },
method: "PUT",
});
expect(response.status).toBe(409);
expect((await anonymousBytes(tarballUrl)).equals(data)).toBe(true);
});
});
describe("a version already published with other bytes", () => {
it("stops the train for good at that package, deleting and overwriting nothing", async () => {
const poisonedName = "@ekklesia/module-sdk";
const index = order.indexOf(poisonedName);
expect(index).toBeGreaterThan(0);
expect(index).toBeLessThan(order.length - 1);
// Same name and version, other bytes: the release tarball with one extra file.
const work = await mkdtemp(path.join(tmpdir(), "ekklesia-burned-"));
scratch.push(work);
await execFileAsync("tar", ["-xzf", local(poisonedName).path, "-C", work]);
await writeFile(path.join(work, "package", "UNEXPECTED"), "not the release\n");
const poisoned = path.join(work, "poisoned.tgz");
await execFileAsync("tar", ["-czf", poisoned, "-C", work, "package"]);
expect(await npmPublish(burnedOwner, poisoned, publishToken)).toBe("published");
const poisonedBytes = await readFile(poisoned);
await expect(publish(burnedOwner)).rejects.toMatchObject({ code: "BURNED_VERSION" });
for (const [position, name] of order.entries()) {
const packument = await anonymousPackument(burnedOwner, name);
if (position < index) {
expect(packument?.versions[train.manifest.version], name).toBeDefined();
} else if (position === index) {
const url = packument?.versions[train.manifest.version]?.dist.tarball ?? "";
expect((await anonymousBytes(url)).equals(poisonedBytes)).toBe(true);
} else {
expect(packument, `${name} must not be published after the stop`).toBeUndefined();
}
expect(packument?.["dist-tags"].latest, name).toBeUndefined();
}
// A retry stops in exactly the same place, and still changes nothing.
await expect(publish(burnedOwner)).rejects.toMatchObject({ code: "BURNED_VERSION" });
expect(await anonymousPackument(burnedOwner, order[index + 1] ?? "")).toBeUndefined();
});
});
describe("moving the final tag, interrupted and resumed", () => {
it("leaves the train unfinished when moving latest fails part way", async () => {
await expect(
publish(tagOwner, {
beforeTag: (_name, index) => {
if (index === 4) throw new Error("injected failure before the fifth tag");
},
}),
).rejects.toThrow("injected failure");
for (const [index, name] of order.entries()) {
const packument = await anonymousPackument(tagOwner, name);
expect(packument?.versions[train.manifest.version], name).toBeDefined();
expect(packument?.["dist-tags"].latest, name).toBe(
index < 4 ? train.manifest.version : undefined,
);
}
});
it("a retry republishes nothing and moves only the tags still missing", async () => {
const record = await publish(tagOwner);
expect(record.packages.every((entry) => entry.outcome === "already-present")).toBe(true);
expect(record.packages.map((entry) => entry.finalTag)).toEqual([
...Array(4).fill("already-current"),
...Array(5).fill("moved"),
]);
for (const name of order) {
expect((await anonymousPackument(tagOwner, name))?.["dist-tags"].latest).toBe(
train.manifest.version,
);
}
});
});