fix: hand npm the release artifact's absolute path, so publishing finds it
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
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]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
debb8df3ce
commit
1caffc5f26
@@ -1,4 +1,4 @@
|
||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { chmod, cp, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
@@ -462,6 +462,119 @@ describe("the registry and its credentials", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("the artifact npm is given", () => {
|
||||
/**
|
||||
* The release where the Actions artifact puts it, `<workspace>/package-train/<release>`, named
|
||||
* relatively from the current directory, which is not the release directory: exactly how the
|
||||
* publish workflow called the tool when npm was handed a path relative to its own temporary home.
|
||||
*/
|
||||
async function nestedRelease(): Promise<{ relative: string; absolute: string }> {
|
||||
const workspace = await mkdtemp(path.join(tmpdir(), "ekklesia-workspace-"));
|
||||
scratch.push(workspace);
|
||||
const absolute = path.join(workspace, "package-train", `ekklesia-packages-${VERSION}`);
|
||||
await mkdir(path.dirname(absolute), { recursive: true });
|
||||
await cp(await releaseDirectory(), absolute, { recursive: true });
|
||||
const relative = path.relative(process.cwd(), absolute);
|
||||
expect(path.isAbsolute(relative)).toBe(false);
|
||||
expect(process.cwd()).not.toBe(absolute);
|
||||
return { absolute, relative };
|
||||
}
|
||||
|
||||
/** An npm that records what it was given, and where it ran, then fails as a registry would. */
|
||||
async function recordingNpm(): Promise<{
|
||||
npm: string;
|
||||
calls: () => Promise<{ argv: string[]; cwd: string; exists: boolean }[]>;
|
||||
}> {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "ekklesia-fake-npm-"));
|
||||
scratch.push(directory);
|
||||
const log = path.join(directory, "calls.jsonl");
|
||||
const npm = path.join(directory, "npm");
|
||||
await writeFile(
|
||||
npm,
|
||||
`#!/usr/bin/env node
|
||||
const { appendFileSync, existsSync } = require("node:fs");
|
||||
const argv = process.argv.slice(2);
|
||||
appendFileSync(${JSON.stringify(log)}, JSON.stringify({ argv, cwd: process.cwd(), exists: existsSync(argv[1] ?? "") }) + "\\n");
|
||||
process.stderr.write("npm error code E503\\nnpm error 503 Service Unavailable\\n");
|
||||
process.exit(1);
|
||||
`,
|
||||
);
|
||||
await chmod(npm, 0o755);
|
||||
return {
|
||||
calls: async () =>
|
||||
(await readFile(log, "utf8").catch(() => ""))
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((line) => JSON.parse(line)),
|
||||
npm,
|
||||
};
|
||||
}
|
||||
|
||||
const notFound = (async () => new Response("{}", { status: 404 })) as typeof fetch;
|
||||
|
||||
it("resolves a relative, nested release to absolute artifact paths when it loads the train", async () => {
|
||||
const { absolute, relative } = await nestedRelease();
|
||||
const train = await loadReleaseTrain(relative, { revision: REVISION, version: VERSION });
|
||||
for (const local of train.packages) {
|
||||
expect(local.path).toBe(path.join(absolute, local.entry.file));
|
||||
expect(path.isAbsolute(local.path)).toBe(true);
|
||||
expect((await readFile(local.path)).equals(local.data)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("hands npm the exact validated tarball by its absolute path, from npm's own directory, and says which", async () => {
|
||||
const { absolute, relative } = await nestedRelease();
|
||||
const { calls, npm } = await recordingNpm();
|
||||
const error = await publishPackageTrain({
|
||||
fetch: notFound,
|
||||
npm,
|
||||
registry: REGISTRY,
|
||||
releaseDirectory: relative,
|
||||
revision: REVISION,
|
||||
token: "publish-token-value",
|
||||
version: VERSION,
|
||||
}).catch((caught: unknown) => caught);
|
||||
expect(error).toMatchObject({ code: "PUBLISH_FAILED" });
|
||||
const first = tarballName("@ekklesia/contracts", VERSION);
|
||||
const expected = path.join(absolute, first);
|
||||
// The failure names the exact file npm was given, and never the token.
|
||||
expect((error as Error).message).toContain(expected);
|
||||
expect((error as Error).message).toContain("E503");
|
||||
expect((error as Error).message).not.toContain("publish-token-value");
|
||||
|
||||
const [call] = await calls();
|
||||
expect(call?.argv.slice(0, 2)).toEqual(["publish", expected]);
|
||||
expect(call?.exists).toBe(true);
|
||||
// npm ran in a directory of its own, where a relative path would have named nothing.
|
||||
expect(call?.cwd).not.toBe(absolute);
|
||||
expect(path.isAbsolute(call?.cwd ?? "")).toBe(true);
|
||||
expect(JSON.stringify(call)).not.toContain("publish-token-value");
|
||||
});
|
||||
|
||||
it("refuses to publish an artifact that changed after it was validated, before npm runs", async () => {
|
||||
const { absolute, relative } = await nestedRelease();
|
||||
const { calls, npm } = await recordingNpm();
|
||||
const file = path.join(absolute, tarballName("@ekklesia/contracts", VERSION));
|
||||
await expect(
|
||||
publishPackageTrain({
|
||||
fetch: notFound,
|
||||
hooks: {
|
||||
beforePublish: async (_name, index) => {
|
||||
if (index === 0) await writeFile(file, "changed after validation");
|
||||
},
|
||||
},
|
||||
npm,
|
||||
registry: REGISTRY,
|
||||
releaseDirectory: relative,
|
||||
revision: REVISION,
|
||||
token: "publish-token-value",
|
||||
version: VERSION,
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "INVALID_RELEASE" });
|
||||
expect(await calls()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user