feat: publish the exact @ekklesia package train to the npm registry (ADR 0053)
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
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
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]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
57df2e15f2
commit
d3c62e63bf
@@ -97,12 +97,18 @@ describe("CI scripts on a shared Docker daemon", () => {
|
||||
>;
|
||||
};
|
||||
const startsStacks =
|
||||
/smoke-infrastructure\.sh|test:migrations:integration|smoke-deploy\.sh|check-image\.sh|docker build/u;
|
||||
/smoke-infrastructure\.sh|test:migrations:integration|smoke-deploy\.sh|check-image\.sh|test-package-publication\.sh|docker build/u;
|
||||
const jobs = Object.entries(workflow.jobs).filter(([, job]) =>
|
||||
job.steps.some((step) => startsStacks.test(step.run ?? "")),
|
||||
);
|
||||
expect(jobs.map(([id]) => id).sort()).toEqual(
|
||||
["deploy-smoke", "image-build", "infrastructure-smoke", "migration-smoke"].sort(),
|
||||
[
|
||||
"deploy-smoke",
|
||||
"image-build",
|
||||
"infrastructure-smoke",
|
||||
"migration-smoke",
|
||||
"package-publication",
|
||||
].sort(),
|
||||
);
|
||||
for (const [id, job] of jobs) {
|
||||
expect(job.env?.EKKLESIA_CI_JOB_KEY, id).toBeTruthy();
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
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,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,542 @@
|
||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { gzipSync } from "node:zlib";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { parse } from "yaml";
|
||||
import {
|
||||
artifactDigests,
|
||||
PACKAGE_TRAIN_MANIFEST,
|
||||
type PackageTrainManifest,
|
||||
PUBLIC_PACKAGES,
|
||||
serializeManifest,
|
||||
tarballName,
|
||||
} from "../scripts/package-release.js";
|
||||
import {
|
||||
compareSemver,
|
||||
isUnderRegistry,
|
||||
loadReleaseTrain,
|
||||
normalizeBin,
|
||||
normalizeRegistry,
|
||||
npmConfiguration,
|
||||
PUBLISH_TOKEN_VARIABLE,
|
||||
PublicationError,
|
||||
packumentUrl,
|
||||
parsePublishArguments,
|
||||
publicationOrder,
|
||||
publishPackageTrain,
|
||||
registryCopyProblems,
|
||||
registryReader,
|
||||
tarballPackageJson,
|
||||
trainProblems,
|
||||
} from "../scripts/publish-package-train.js";
|
||||
|
||||
/**
|
||||
* The package train publication tool's own rules (ADR 0053), without a registry: what it accepts
|
||||
* as a publishable train, the order it publishes in, how it compares a registry copy with the
|
||||
* release, and what it refuses before it ever reaches the network. The registry behaviour itself is
|
||||
* proven against a disposable Gitea by `scripts/test-package-publication.sh`.
|
||||
*/
|
||||
|
||||
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const VERSION = "0.1.2";
|
||||
const REVISION = "86ae907f59458508e3277666cdd214f5bd7c1078";
|
||||
const REGISTRY = "https://registry.example/api/packages/owner/npm/";
|
||||
const scratch: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(scratch.splice(0).map((dir) => rm(dir, { force: true, recursive: true })));
|
||||
});
|
||||
|
||||
/** A minimal npm tarball: gzip of a ustar archive holding the given files under `package/`. */
|
||||
function tarball(files: Record<string, string>): Buffer {
|
||||
const blocks: Buffer[] = [];
|
||||
for (const [name, contents] of Object.entries(files)) {
|
||||
const body = Buffer.from(contents);
|
||||
const header = Buffer.alloc(512);
|
||||
header.write(`package/${name}`, 0, "utf8");
|
||||
header.write("0000644\0", 100);
|
||||
header.write("0000000\0", 108);
|
||||
header.write("0000000\0", 116);
|
||||
header.write(`${body.length.toString(8).padStart(11, "0")}\0`, 124);
|
||||
header.write("00000000000\0", 136);
|
||||
header.write(" ", 148);
|
||||
header.write("0", 156);
|
||||
header.write("ustar\0", 257);
|
||||
header.write("00", 263);
|
||||
let checksum = 0;
|
||||
for (const byte of header) checksum += byte;
|
||||
header.write(`${checksum.toString(8).padStart(6, "0")}\0 `, 148);
|
||||
blocks.push(header, body, Buffer.alloc((512 - (body.length % 512)) % 512));
|
||||
}
|
||||
blocks.push(Buffer.alloc(1024));
|
||||
return gzipSync(Buffer.concat(blocks));
|
||||
}
|
||||
|
||||
const TRAIN_DEPENDENCIES: Record<string, string[]> = {
|
||||
"@ekklesia/contracts": [],
|
||||
"@ekklesia/design-tokens": [],
|
||||
"@ekklesia/module-database": ["@ekklesia/contracts"],
|
||||
"@ekklesia/module-pack": ["@ekklesia/contracts", "@ekklesia/module-ui-build"],
|
||||
"@ekklesia/module-sdk": ["@ekklesia/contracts"],
|
||||
"@ekklesia/module-ui-build": ["@ekklesia/contracts"],
|
||||
"@ekklesia/module-ui-sdk": [],
|
||||
"@ekklesia/typescript-config": [],
|
||||
"@ekklesia/ui": ["@ekklesia/design-tokens"],
|
||||
};
|
||||
|
||||
function packageJsonFor(name: string): Record<string, unknown> {
|
||||
return {
|
||||
dependencies: Object.fromEntries((TRAIN_DEPENDENCIES[name] ?? []).map((dep) => [dep, VERSION])),
|
||||
name,
|
||||
version: VERSION,
|
||||
...(name === "@ekklesia/module-pack"
|
||||
? { bin: { "ekklesia-pack-module": "./bin/ekklesia-pack-module.mjs" } }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
async function releaseDirectory(
|
||||
mutate?: (files: Map<string, Buffer>, manifest: PackageTrainManifest) => PackageTrainManifest,
|
||||
): Promise<string> {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "ekklesia-publication-test-"));
|
||||
scratch.push(directory);
|
||||
const files = new Map<string, Buffer>();
|
||||
for (const entry of PUBLIC_PACKAGES) {
|
||||
files.set(
|
||||
tarballName(entry.name, VERSION),
|
||||
tarball({ "package.json": JSON.stringify(packageJsonFor(entry.name)), LICENSE: "AGPL\n" }),
|
||||
);
|
||||
}
|
||||
let manifest: PackageTrainManifest = {
|
||||
packageTrain: 1,
|
||||
packages: PUBLIC_PACKAGES.map((entry) => {
|
||||
const file = tarballName(entry.name, VERSION);
|
||||
const data = files.get(file) as Buffer;
|
||||
return {
|
||||
file,
|
||||
name: entry.name,
|
||||
role: entry.role,
|
||||
size: data.length,
|
||||
version: VERSION,
|
||||
...artifactDigests(data),
|
||||
};
|
||||
}),
|
||||
source: { modified: false, revision: REVISION },
|
||||
version: VERSION,
|
||||
};
|
||||
files.set(
|
||||
"SHA256SUMS",
|
||||
Buffer.from(
|
||||
[...manifest.packages]
|
||||
.sort((left, right) => (left.file < right.file ? -1 : 1))
|
||||
.map((entry) => `${entry.sha256} ${entry.file}\n`)
|
||||
.join(""),
|
||||
),
|
||||
);
|
||||
manifest = mutate ? mutate(files, manifest) : manifest;
|
||||
files.set(PACKAGE_TRAIN_MANIFEST, Buffer.from(serializeManifest(manifest)));
|
||||
for (const [file, data] of files) await writeFile(path.join(directory, file), data);
|
||||
return directory;
|
||||
}
|
||||
|
||||
describe("the local train", () => {
|
||||
it("reads package.json from inside an npm tarball", () => {
|
||||
const data = tarball({
|
||||
LICENSE: "x",
|
||||
"package.json": '{"name":"@ekklesia/x","version":"1.0.0"}',
|
||||
});
|
||||
expect(tarballPackageJson(data)).toEqual({ name: "@ekklesia/x", version: "1.0.0" });
|
||||
expect(() => tarballPackageJson(tarball({ LICENSE: "x" }))).toThrow();
|
||||
});
|
||||
|
||||
it("accepts a whole, unchanged train and records its manifest's own hash", async () => {
|
||||
const directory = await releaseDirectory();
|
||||
const train = await loadReleaseTrain(directory, { revision: REVISION, version: VERSION });
|
||||
expect(train.packages).toHaveLength(9);
|
||||
expect(train.manifestSha256).toBe(
|
||||
artifactDigests(await readFile(path.join(directory, PACKAGE_TRAIN_MANIFEST))).sha256,
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a modified build, another version or revision, and any changed, missing, or extra file", async () => {
|
||||
const cases: Record<string, Parameters<typeof releaseDirectory>[0]> = {
|
||||
altered: (files, manifest) => {
|
||||
files.set(tarballName("@ekklesia/ui", VERSION), tarball({ "package.json": "{}" }));
|
||||
return manifest;
|
||||
},
|
||||
extra: (files, manifest) => {
|
||||
files.set("ekklesia-core-0.1.2.tgz", Buffer.from("x"));
|
||||
return manifest;
|
||||
},
|
||||
missing: (files, manifest) => {
|
||||
files.delete(tarballName("@ekklesia/module-sdk", VERSION));
|
||||
return manifest;
|
||||
},
|
||||
modified: (_files, manifest) => ({
|
||||
...manifest,
|
||||
source: { ...manifest.source, modified: true },
|
||||
}),
|
||||
sums: (files, manifest) => {
|
||||
files.set("SHA256SUMS", Buffer.from("0 x\n"));
|
||||
return manifest;
|
||||
},
|
||||
wrongInside: (files, manifest) => {
|
||||
// Bytes that match the manifest, but a package.json that names another package.
|
||||
const file = tarballName("@ekklesia/ui", VERSION);
|
||||
const data = tarball({
|
||||
"package.json": JSON.stringify({ name: "@ekklesia/other", version: VERSION }),
|
||||
});
|
||||
files.set(file, data);
|
||||
return {
|
||||
...manifest,
|
||||
packages: manifest.packages.map((entry) =>
|
||||
entry.file === file ? { ...entry, size: data.length, ...artifactDigests(data) } : entry,
|
||||
),
|
||||
};
|
||||
},
|
||||
};
|
||||
for (const [name, mutate] of Object.entries(cases)) {
|
||||
const directory = await releaseDirectory(mutate);
|
||||
await expect(
|
||||
loadReleaseTrain(directory, { revision: REVISION, version: VERSION }),
|
||||
name,
|
||||
).rejects.toMatchObject({ code: "INVALID_RELEASE" });
|
||||
}
|
||||
const valid = await releaseDirectory();
|
||||
await expect(loadReleaseTrain(valid, { version: "0.1.3" })).rejects.toMatchObject({
|
||||
code: "INVALID_RELEASE",
|
||||
});
|
||||
await expect(
|
||||
loadReleaseTrain(valid, { revision: "f".repeat(40), version: VERSION }),
|
||||
).rejects.toMatchObject({ code: "INVALID_RELEASE" });
|
||||
});
|
||||
|
||||
it("explains every problem at once", () => {
|
||||
const problems = trainProblems(
|
||||
{
|
||||
packageTrain: 1,
|
||||
packages: [],
|
||||
source: { modified: true, revision: "abc" },
|
||||
version: "9.9.9",
|
||||
},
|
||||
new Map(),
|
||||
{ version: VERSION },
|
||||
);
|
||||
expect(problems.join(" ")).toContain("modified source tree");
|
||||
expect(problems.join(" ")).toContain("not the requested 0.1.2");
|
||||
expect(problems.join(" ")).toContain("no full source revision");
|
||||
expect(problems.join(" ")).toContain("is missing");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the publication order", () => {
|
||||
const entries = (names: readonly string[]) =>
|
||||
names.map((name) => ({ entry: { name }, packageJson: packageJsonFor(name) }));
|
||||
|
||||
it("puts every package after the train packages it depends on, and is otherwise alphabetical", () => {
|
||||
const order = publicationOrder(entries(Object.keys(TRAIN_DEPENDENCIES))).map(
|
||||
(e) => e.entry.name,
|
||||
);
|
||||
expect(order).toEqual([
|
||||
"@ekklesia/contracts",
|
||||
"@ekklesia/design-tokens",
|
||||
"@ekklesia/module-database",
|
||||
"@ekklesia/module-sdk",
|
||||
"@ekklesia/module-ui-build",
|
||||
"@ekklesia/module-pack",
|
||||
"@ekklesia/module-ui-sdk",
|
||||
"@ekklesia/typescript-config",
|
||||
"@ekklesia/ui",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not depend on the order the files are listed in", () => {
|
||||
const names = Object.keys(TRAIN_DEPENDENCIES);
|
||||
const forward = publicationOrder(entries(names)).map((e) => e.entry.name);
|
||||
const backward = publicationOrder(entries([...names].reverse())).map((e) => e.entry.name);
|
||||
expect(backward).toEqual(forward);
|
||||
});
|
||||
|
||||
it("follows the real packages' own dependencies", async () => {
|
||||
const real = await Promise.all(
|
||||
PUBLIC_PACKAGES.map(async (entry) => ({
|
||||
entry: { name: entry.name },
|
||||
packageJson: JSON.parse(
|
||||
await readFile(path.join(repositoryRoot, entry.directory, "package.json"), "utf8"),
|
||||
) as Record<string, unknown>,
|
||||
})),
|
||||
);
|
||||
const order = publicationOrder(real).map((e) => e.entry.name);
|
||||
for (const { entry, packageJson } of real) {
|
||||
for (const dependency of Object.keys((packageJson.dependencies as object) ?? {})) {
|
||||
if (!dependency.startsWith("@ekklesia/")) continue;
|
||||
expect(order.indexOf(dependency), `${dependency} before ${entry.name}`).toBeLessThan(
|
||||
order.indexOf(entry.name),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses a dependency cycle", () => {
|
||||
expect(() =>
|
||||
publicationOrder([
|
||||
{ entry: { name: "a" }, packageJson: { dependencies: { b: "1" } } },
|
||||
{ entry: { name: "b" }, packageJson: { dependencies: { a: "1" } } },
|
||||
]),
|
||||
).toThrow(PublicationError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("comparing a registry copy with the release", () => {
|
||||
const data = tarball({ "package.json": JSON.stringify(packageJsonFor("@ekklesia/module-pack")) });
|
||||
const digests = artifactDigests(data);
|
||||
const local = {
|
||||
data,
|
||||
entry: {
|
||||
file: "ekklesia-module-pack-0.1.2.tgz",
|
||||
name: "@ekklesia/module-pack",
|
||||
role: "tooling" as const,
|
||||
size: data.length,
|
||||
version: VERSION,
|
||||
...digests,
|
||||
},
|
||||
packageJson: packageJsonFor("@ekklesia/module-pack"),
|
||||
};
|
||||
const registryVersion = {
|
||||
// npm normalizes bin paths in the metadata it uploads; the tarball keeps the author's form.
|
||||
bin: { "ekklesia-pack-module": "bin/ekklesia-pack-module.mjs" },
|
||||
dependencies: { "@ekklesia/module-ui-build": VERSION, "@ekklesia/contracts": VERSION },
|
||||
dist: {
|
||||
integrity: digests.integrity,
|
||||
tarball: `${REGISTRY}%40ekklesia%2Fmodule-pack/-/0.1.2/module-pack-0.1.2.tgz`,
|
||||
},
|
||||
name: "@ekklesia/module-pack",
|
||||
version: VERSION,
|
||||
};
|
||||
|
||||
it("accepts the same bytes and metadata, however npm normalized bin", () => {
|
||||
expect(registryCopyProblems(local, registryVersion, data, REGISTRY)).toEqual([]);
|
||||
expect(normalizeBin("./cli.js", "@ekklesia/tool")).toEqual({ tool: "cli.js" });
|
||||
});
|
||||
|
||||
it("refuses other bytes, integrity, dependencies, bin, or a tarball elsewhere", () => {
|
||||
const other = tarball({ "package.json": "{}" });
|
||||
expect(registryCopyProblems(local, registryVersion, other, REGISTRY).join(" ")).toContain(
|
||||
"different bytes",
|
||||
);
|
||||
const cases = [
|
||||
{ ...registryVersion, dist: { ...registryVersion.dist, integrity: "sha512-AAAA" } },
|
||||
{ ...registryVersion, dependencies: { "@ekklesia/contracts": "0.1.1" } },
|
||||
{ ...registryVersion, bin: { other: "bin/other.mjs" } },
|
||||
{
|
||||
...registryVersion,
|
||||
dist: { ...registryVersion.dist, tarball: "https://elsewhere.example/x.tgz" },
|
||||
},
|
||||
{ ...registryVersion, version: "0.1.3" },
|
||||
];
|
||||
for (const version of cases) {
|
||||
expect(
|
||||
registryCopyProblems(local, version, data, REGISTRY),
|
||||
JSON.stringify(version),
|
||||
).not.toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it("orders versions by SemVer precedence", () => {
|
||||
expect(compareSemver("0.1.10", "0.1.9")).toBe(1);
|
||||
expect(compareSemver("0.2.0", "0.10.0")).toBe(-1);
|
||||
expect(compareSemver("1.0.0-rc.1", "1.0.0")).toBe(-1);
|
||||
expect(compareSemver("1.0.0-rc.2", "1.0.0-rc.10")).toBe(-1);
|
||||
expect(compareSemver("1.0.0-alpha", "1.0.0-1")).toBe(1);
|
||||
expect(compareSemver("0.1.2", "0.1.2+build")).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the registry and its credentials", () => {
|
||||
it("accepts an HTTPS registry, adds the trailing slash, and refuses anything else", () => {
|
||||
expect(normalizeRegistry("https://registry.example/api/packages/o/npm")).toBe(
|
||||
"https://registry.example/api/packages/o/npm/",
|
||||
);
|
||||
expect(() => normalizeRegistry("http://registry.example/npm/")).toThrow(PublicationError);
|
||||
expect(normalizeRegistry("http://gitea:3000/npm/", true)).toBe("http://gitea:3000/npm/");
|
||||
expect(() => normalizeRegistry("https://user:[email protected]/npm/")).toThrow(
|
||||
PublicationError,
|
||||
);
|
||||
});
|
||||
|
||||
it("refers to the token by its variable in npm's configuration, never by value", () => {
|
||||
const configuration = npmConfiguration(REGISTRY);
|
||||
expect(configuration).toContain(
|
||||
`//registry.example/api/packages/owner/npm/:_authToken=\${${PUBLISH_TOKEN_VARIABLE}}`,
|
||||
);
|
||||
expect(configuration).toContain(`@ekklesia:registry=${REGISTRY}`);
|
||||
expect(packumentUrl(REGISTRY, "@ekklesia/module-sdk")).toBe(
|
||||
`${REGISTRY}@ekklesia%2fmodule-sdk`,
|
||||
);
|
||||
});
|
||||
|
||||
it("sends the token only to the registry itself", async () => {
|
||||
const seen: Record<string, string | undefined> = {};
|
||||
const fake = (async (url: string, init?: RequestInit) => {
|
||||
seen[url] = ((init?.headers ?? {}) as Record<string, string>).authorization;
|
||||
return new Response(new Uint8Array([1]), { status: 200 });
|
||||
}) as typeof fetch;
|
||||
const reader = registryReader(REGISTRY, "token-value", fake);
|
||||
await reader.download(`${REGISTRY}x.tgz`);
|
||||
await reader.download("https://elsewhere.example/x.tgz");
|
||||
await reader.download(`${REGISTRY}y.tgz`, true);
|
||||
expect(seen[`${REGISTRY}x.tgz`]).toBe("Bearer token-value");
|
||||
expect(seen["https://elsewhere.example/x.tgz"]).toBeUndefined();
|
||||
expect(seen[`${REGISTRY}y.tgz`]).toBeUndefined();
|
||||
expect(isUnderRegistry("https://registry.example/api/packages/other/npm/x", REGISTRY)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses to start without a token, or with an invalid train, before any request", async () => {
|
||||
let requests = 0;
|
||||
const counting = (async () => {
|
||||
requests += 1;
|
||||
return new Response("{}");
|
||||
}) as typeof fetch;
|
||||
const directory = await releaseDirectory();
|
||||
await expect(
|
||||
publishPackageTrain({
|
||||
fetch: counting,
|
||||
registry: REGISTRY,
|
||||
releaseDirectory: directory,
|
||||
token: "",
|
||||
version: VERSION,
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "INVALID_OPTIONS" });
|
||||
const invalid = await releaseDirectory((_files, manifest) => ({
|
||||
...manifest,
|
||||
source: { ...manifest.source, modified: true },
|
||||
}));
|
||||
await expect(
|
||||
publishPackageTrain({
|
||||
fetch: counting,
|
||||
npm: "/nonexistent/npm",
|
||||
registry: REGISTRY,
|
||||
releaseDirectory: invalid,
|
||||
token: "publish-token-value",
|
||||
version: VERSION,
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "INVALID_RELEASE" });
|
||||
const valid = await releaseDirectory();
|
||||
await expect(
|
||||
publishPackageTrain({
|
||||
fetch: counting,
|
||||
manifestSha256: "0".repeat(64),
|
||||
npm: "/nonexistent/npm",
|
||||
registry: REGISTRY,
|
||||
releaseDirectory: valid,
|
||||
token: "publish-token-value",
|
||||
version: VERSION,
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "INVALID_RELEASE" });
|
||||
expect(requests).toBe(0);
|
||||
});
|
||||
|
||||
it("parses its command line, and takes no token from it", () => {
|
||||
expect(
|
||||
parsePublishArguments([
|
||||
"--release",
|
||||
"r",
|
||||
"--registry",
|
||||
REGISTRY,
|
||||
"--version",
|
||||
VERSION,
|
||||
"--require-public-read",
|
||||
]),
|
||||
).toEqual({
|
||||
flags: { requirePublicRead: true },
|
||||
values: { registry: REGISTRY, release: "r", version: VERSION },
|
||||
});
|
||||
expect(() => parsePublishArguments(["--release", "r", "--token", "x"])).toThrow(
|
||||
PublicationError,
|
||||
);
|
||||
expect(() => parsePublishArguments(["--release", "r"])).toThrow(PublicationError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("what the publisher may do to a registry", () => {
|
||||
it("never deletes, unpublishes, or overwrites: it only publishes new versions and moves a tag", async () => {
|
||||
// Code only: the tool's comments may say what it never does.
|
||||
const source = (
|
||||
await readFile(path.join(repositoryRoot, "scripts/publish-package-train.ts"), "utf8")
|
||||
)
|
||||
.replace(/\/\*[\s\S]*?\*\//gu, "")
|
||||
.replace(/^\s*\/\/.*$/gmu, "");
|
||||
expect(source).not.toMatch(
|
||||
/unpublish|deprecate|"DELETE"|method:\s*"(?:PUT|DELETE|POST)"|-rev\/|--force/u,
|
||||
);
|
||||
const npmCommands = [...source.matchAll(/\["(publish|dist-tag)"/gu)].map((match) => match[1]);
|
||||
expect([...new Set(npmCommands)].sort()).toEqual(["dist-tag", "publish"]);
|
||||
expect(source).toMatch(/\["dist-tag", "add",/u);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when the real registry can be written to", () => {
|
||||
type Workflow = {
|
||||
on: Record<string, unknown>;
|
||||
jobs: Record<
|
||||
string,
|
||||
{
|
||||
if?: string;
|
||||
needs?: string | string[];
|
||||
steps: { run?: string; env?: Record<string, string>; uses?: string }[];
|
||||
}
|
||||
>;
|
||||
};
|
||||
const workflow = async (file: string) =>
|
||||
parse(await readFile(path.join(repositoryRoot, ".github/workflows", file), "utf8")) as Workflow;
|
||||
|
||||
it("never from CI: no pull request or push job holds the publish secret or publishes", async () => {
|
||||
const ci = await readFile(path.join(repositoryRoot, ".github/workflows/ci.yml"), "utf8");
|
||||
expect(ci).not.toContain("EKKLESIA_PACKAGES_PUBLISH_TOKEN");
|
||||
expect(ci).not.toContain("secrets.");
|
||||
expect(ci).not.toContain("publish-package-train.ts");
|
||||
// CI publishes only to the disposable registry its own job starts.
|
||||
expect(ci).toContain("bash scripts/test-package-publication.sh --release");
|
||||
});
|
||||
|
||||
it("only by hand, on main, after this run's own gate, with the secret in the publish step alone", async () => {
|
||||
const publish = await workflow("publish-packages.yml");
|
||||
expect(Object.keys(publish.on)).toEqual(["workflow_dispatch"]);
|
||||
for (const [name, job] of Object.entries(publish.jobs)) {
|
||||
expect(job.if, name).toBe("github.ref == 'refs/heads/main'");
|
||||
}
|
||||
expect(publish.jobs.publish?.needs).toBe("package-release");
|
||||
const steps = Object.entries(publish.jobs).flatMap(([job, { steps }]) =>
|
||||
steps.map((step) => ({ job, step })),
|
||||
);
|
||||
const withSecret = steps.filter(({ step }) => JSON.stringify(step).includes("secrets."));
|
||||
expect(withSecret).toHaveLength(1);
|
||||
expect(withSecret[0]?.job).toBe("publish");
|
||||
expect(withSecret[0]?.step.env?.EKKLESIA_PACKAGES_PUBLISH_TOKEN).toBe(
|
||||
"${{ secrets.EKKLESIA_PACKAGES_PUBLISH_TOKEN }}",
|
||||
);
|
||||
const command = withSecret[0]?.step.run ?? "";
|
||||
for (const option of ["--revision", "--manifest-sha256", "--require-public-read", "--record"]) {
|
||||
expect(command).toContain(option);
|
||||
}
|
||||
expect(command).not.toContain("--allow-http");
|
||||
// The publish job never builds or packs: it installs nothing and runs no package script.
|
||||
const publishRuns = (publish.jobs.publish?.steps ?? [])
|
||||
.map((step) => step.run ?? "")
|
||||
.join("\n");
|
||||
expect(publishRuns).not.toMatch(/pnpm|npm (?:install|ci|run|pack)|package-release\.ts/u);
|
||||
});
|
||||
|
||||
it("uses the artifact actions that work on this Gitea, pinned by commit", async () => {
|
||||
for (const file of ["ci.yml", "publish-packages.yml"]) {
|
||||
const text = await readFile(path.join(repositoryRoot, ".github/workflows", file), "utf8");
|
||||
for (const use of text.matchAll(/uses: (actions\/(?:up|down)load-artifact)@(\S+)/gu)) {
|
||||
expect(use[2], `${file} ${use[1]}`).toMatch(/^[0-9a-f]{40}$/u);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user