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

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:
ekklesia
2026-10-08 17:51:22 -05:00
co-authored by Claude Opus 5.5
parent debb8df3ce
commit 1caffc5f26
5 changed files with 207 additions and 17 deletions
+31 -9
View File
@@ -109,6 +109,7 @@ export function tarballPackageJson(tarball: Uint8Array): Record<string, unknown>
export interface LocalPackage {
readonly entry: TrainPackage;
/** The artifact's absolute path: the exact file validated against the release manifest. */
readonly path: string;
readonly data: Buffer;
readonly packageJson: Readonly<Record<string, unknown>>;
@@ -129,9 +130,12 @@ export async function loadReleaseTrain(
releaseDirectory: string,
expected: { readonly version: string; readonly revision?: string },
): Promise<LocalTrain> {
// Resolved once, here: every artifact path from now on is absolute, so the file npm is handed
// is the file validated below, whatever directory npm itself runs in.
const directory = path.resolve(releaseDirectory);
const files = new Map<string, Buffer>();
for (const file of await readdir(releaseDirectory)) {
files.set(file, await readFile(path.join(releaseDirectory, file)));
for (const file of await readdir(directory)) {
files.set(file, await readFile(path.join(directory, file)));
}
const manifestBytes = files.get(PACKAGE_TRAIN_MANIFEST);
if (manifestBytes === undefined) {
@@ -156,7 +160,7 @@ export async function loadReleaseTrain(
data,
entry,
packageJson: tarballPackageJson(data),
path: path.join(releaseDirectory, entry.file),
path: path.join(directory, entry.file),
};
}),
};
@@ -525,9 +529,9 @@ async function npmSession(registry: string, token: string, npm: string): Promise
const stderr = String((error as { stderr?: unknown }).stderr ?? "");
const summary = stderr
.split("\n")
.filter((line) => /^npm error (?:code|\d{3}|[A-Z])/u.test(line))
.filter((line) => /^npm error (?:code|path|syscall|errno|enoent|\d{3}|[A-Z])/u.test(line))
.filter((line) => !line.includes("complete log"))
.slice(0, 3)
.slice(0, 5)
.join(" / ")
.replaceAll(token, "***")
.replaceAll(home, "<npm home>");
@@ -541,13 +545,22 @@ async function npmSession(registry: string, token: string, npm: string): Promise
addTag: (name, version, tag) =>
run(["dist-tag", "add", `${name}@${version}`, tag], `Moving ${tag} to ${name}@${version}`),
dispose: () => rm(home, { force: true, recursive: true }),
publish: (file, tag) =>
run(
publish: async (file, tag) => {
// npm runs in its own temporary home, so a relative path would name a file there, not the
// release artifact. Only the artifact's absolute path is ever passed.
if (!path.isAbsolute(file)) {
throw new PublicationError(
"PUBLISH_FAILED",
`Refusing to publish ${file}: npm is only ever given an artifact's absolute path.`,
);
}
await run(
// A file spec runs no lifecycle script and sends the file's own bytes; --ignore-scripts
// says so explicitly. --access public is what a scoped package needs on a public registry.
["publish", file, "--tag", tag, "--access", "public", "--ignore-scripts"],
`Publishing ${path.basename(file)}`,
),
`Publishing ${path.basename(file)} from ${file}`,
);
},
};
}
@@ -677,6 +690,15 @@ export async function publishPackageTrain(options: PublishOptions): Promise<Publ
let existing = versionOf(await reader.packument(name), version);
if (existing === undefined) {
await options.hooks?.beforePublish?.(name, index);
// The file npm reads must still be the validated artifact, byte for byte.
const current = await readFile(local.path).catch(() => undefined);
if (current === undefined || !current.equals(local.data)) {
throw new PublicationError(
"INVALID_RELEASE",
`${local.path} is missing or no longer the artifact validated against the release manifest.`,
);
}
log(`${name}@${version}: publishing ${local.path} (sha256 ${local.entry.sha256})`);
try {
await npm.publish(local.path, stagingTag);
outcome = "published";
+1 -1
View File
@@ -110,7 +110,7 @@ for user in publisher outsider; do
password_variable="${user}_password"
api POST /admin/users "{\"username\":\"$user\",\"password\":\"${!password_variable}\",\"email\":\"$user@example.invalid\",\"must_change_password\":false}" >/dev/null
done
owners=(River_of_Glory_Church ekklesia-burned-version ekklesia-tag-retry)
owners=(River_of_Glory_Church ekklesia-burned-version ekklesia-tag-retry ekklesia-artifact-layout)
for owner in "${owners[@]}"; do
api POST /orgs "{\"username\":\"$owner\",\"visibility\":\"public\"}" >/dev/null
team="$(api POST "/orgs/$owner/teams" '{"name":"package-publishers","includes_all_repositories":false,"can_create_org_repo":false,"units":["repo.packages"],"units_map":{"repo.packages":"write"}}' |