CI / verify (pull_request) Successful in 1m5s
CI / infrastructure-smoke (pull_request) Successful in 37s
CI / migration-smoke (pull_request) Successful in 1m52s
CI / package-release (pull_request) Successful in 34s
CI / image-build (pull_request) Successful in 24s
CI / deploy-smoke (same-origin) (pull_request) Successful in 50s
CI / deploy-smoke (split) (pull_request) Successful in 1m30s
CI / package-publication (pull_request) Successful in 49s
The first real 0.2.0 publish failed on its first package with ENOENT: "Publishing ekklesia-contracts-0.2.0.tgz failed: npm error code ENOENT". The publish workflow passes a relative release path, --release "$(find package-train …)", which is package-train/ekklesia-packages-0.2.0. The tool validated the artifacts relative to the job's working directory, but ran npm in its own temporary home (cwd /tmp/ekklesia-publish-XXXXXX) with the same relative path, package-train/ekklesia-packages-0.2.0/ekklesia-contracts-0.2.0.tgz, which there names nothing. The disposable-registry suite always passed an absolute path, so it never saw this. - loadReleaseTrain resolves the release directory once; every artifact path is absolute from validation on, and npm is given only that exact file's absolute path (anything else is refused at the npm boundary). - Just before each upload the file is checked against the validated bytes once more; the tool logs the absolute path it publishes, and a failure names it with npm's path/syscall lines (npm's temporary home masked, the token never shown). - Regression tests: unit tests with a recording npm (relative nested release from another cwd; npm's argv, cwd, and the file's existence; a changed artifact refused before npm runs), which fail on the previous tool; and a disposable-Gitea test that runs the CLI exactly as the workflow does, from the Actions artifact layout, round-tripping all nine packages byte for byte. Co-Authored-By: Claude Opus 5.5 <[email protected]>
360 lines
14 KiB
TypeScript
360 lines
14 KiB
TypeScript
import { execFile } from "node:child_process";
|
|
import { cp, mkdir, 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, layoutOwner] = setting("EKKLESIA_TEST_OWNERS").split(" ");
|
|
if (!origin || !releaseDirectory || !publishToken || !outsiderToken || !layoutOwner) {
|
|
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,
|
|
);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("the publish workflow's own invocation", () => {
|
|
it("publishes from the Actions artifact layout, given a relative release path from another directory", async () => {
|
|
// Exactly as .github/workflows/publish-packages.yml runs it: the artifact unpacked under
|
|
// `package-train/` in the job's workspace, and `--release "$(find package-train …)"`.
|
|
const workspace = await mkdtemp(path.join(tmpdir(), "ekklesia-publish-workspace-"));
|
|
scratch.push(workspace);
|
|
const releaseName = path.basename(releaseDirectory);
|
|
await mkdir(path.join(workspace, "package-train"), { recursive: true });
|
|
await cp(releaseDirectory, path.join(workspace, "package-train", releaseName), {
|
|
recursive: true,
|
|
});
|
|
const record = path.join(workspace, "publication-record.json");
|
|
const { stdout } = await execFileAsync(
|
|
process.execPath,
|
|
[
|
|
path.resolve("scripts/publish-package-train.ts"),
|
|
"--release",
|
|
`package-train/${releaseName}`,
|
|
"--registry",
|
|
registryOf(layoutOwner),
|
|
"--version",
|
|
train.manifest.version,
|
|
"--revision",
|
|
train.manifest.source.revision,
|
|
"--manifest-sha256",
|
|
train.manifestSha256,
|
|
"--record",
|
|
record,
|
|
"--require-public-read",
|
|
"--allow-http",
|
|
],
|
|
{
|
|
cwd: workspace,
|
|
env: { [PUBLISH_TOKEN_VARIABLE]: publishToken, PATH: process.env.PATH ?? "" },
|
|
},
|
|
);
|
|
expect(stdout).toContain(
|
|
`publishing ${path.join(workspace, "package-train", releaseName, local("@ekklesia/contracts").entry.file)}`,
|
|
);
|
|
expect(stdout).not.toContain(publishToken);
|
|
const published = JSON.parse(await readFile(record, "utf8")) as PublicationRecord;
|
|
expect(published.packages.map((entry) => entry.outcome)).toEqual(Array(9).fill("published"));
|
|
for (const name of order) {
|
|
const version = (await anonymousPackument(layoutOwner, name))?.versions[
|
|
train.manifest.version
|
|
];
|
|
expect(
|
|
(await anonymousBytes(version?.dist.tarball ?? "")).equals(local(name).data),
|
|
name,
|
|
).toBe(true);
|
|
}
|
|
});
|
|
});
|