Files
Ekklesia/tests/module-package-release.test.ts
ekklesiaandClaude Opus 5.5 27d85e0162
CI / verify (pull_request) Successful in 58s
CI / infrastructure-smoke (pull_request) Successful in 36s
CI / migration-smoke (pull_request) Successful in 1m51s
CI / image-build (pull_request) Successful in 22s
CI / deploy-smoke (same-origin) (pull_request) Successful in 42s
CI / deploy-smoke (split) (pull_request) Successful in 1m31s
fix(smoke): hand the verifier the package's archive and digest as one readable release pair
The split deploy smoke failed in CI with "The SHA-256 file could not be read."
BuildKit's local exporter creates its output directory (`--output
type=local,dest=$work/module-package`) with mode 0700, owned by whoever ran the
build. That directory was mounted into the verifier, which runs as the image's
unprivileged `node` user (uid 1000). In CI the build runs as root, so the
directory was root:root 0700 and the verifier could read neither file; the
digest file is simply read first. On a workstation the build runs as uid 1000,
the same uid as `node`, so it was readable by coincidence.

scripts/lib/module-package-release.sh turns one build's output into the release
pair: it requires exactly one archive and its .sha256 and nothing else, checks
that the digest line names that archive, then creates a fresh directory that
must not exist yet (0755) and copies both files into it (0644). The smoke stages
and mounts only that directory; BuildKit's output directory is never mounted.
Archive-digest verification stays mandatory: the verifier still reads the
.sha256 file. Nothing in the verifier changed.

Tests: the helper is driven in bash against a 0700 build output, and refuses a
missing, malformed, or mismatched digest, stray output, a second archive, and
an existing release directory, creating nothing. A static test pins the smoke
to the release pair and the verifier's single mount with --sha256-file.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 13:10:58 -05:00

128 lines
4.6 KiB
TypeScript

import { execFile } from "node:child_process";
import { chmod, mkdir, mkdtemp, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { afterEach, describe, expect, it } from "vitest";
/**
* The deploy smoke test's release pair (ADR 0049): a package archive and its `.sha256` are one
* logical release and reach the verifier together, from a directory every user can traverse.
*
* The regression this exists for: BuildKit's local exporter creates its output directory with mode
* 0700, owned by whoever ran the build. On a workstation that is uid 1000, which is also the image's
* unprivileged `node` user, so the verifier could read it; in CI the build runs as root, and the
* verifier could read neither file ("The SHA-256 file could not be read").
*/
const execFileAsync = promisify(execFile);
const helper = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"..",
"scripts",
"lib",
"module-package-release.sh",
);
const archive = "example-0.1.0-any.tar.gz";
const digest = "91e5dfee80880027bd1c2e1796513f07023c03847e9ca3870b51f8272c3accf7";
const temporaryDirectories: string[] = [];
afterEach(async () => {
await Promise.all(
temporaryDirectories
.splice(0)
.map((directory) => rm(directory, { force: true, recursive: true })),
);
});
/** A build output exactly as BuildKit leaves it: mode 0700, holding the archive and its digest. */
async function buildOutput(
files: Readonly<Record<string, string>> = {
[archive]: "archive bytes",
[`${archive}.sha256`]: `${digest} ${archive}\n`,
},
): Promise<{ readonly root: string; readonly output: string }> {
const root = await mkdtemp(path.join(tmpdir(), "ekklesia-release-pair-"));
temporaryDirectories.push(root);
const output = path.join(root, "module-package");
await mkdir(output);
for (const [name, contents] of Object.entries(files)) {
await writeFile(path.join(output, name), contents, { mode: 0o600 });
}
await chmod(output, 0o700);
return { output, root };
}
async function release(output: string, target: string) {
return execFileAsync("bash", [
"-c",
'source "$1"; module_package_release "$2" "$3"',
"release",
helper,
output,
target,
]);
}
describe("the smoke test's package release pair", () => {
it("copies the archive and its digest, together, into a fresh world-traversable directory", async () => {
const { output, root } = await buildOutput();
const target = path.join(root, "package");
const { stdout } = await release(output, target);
expect(stdout.trim()).toBe(archive);
expect((await readdir(target)).sort()).toEqual([archive, `${archive}.sha256`]);
expect((await stat(target)).mode & 0o777).toBe(0o755);
for (const name of [archive, `${archive}.sha256`]) {
expect((await stat(path.join(target, name))).mode & 0o777, name).toBe(0o644);
expect(await readFile(path.join(target, name))).toEqual(
await readFile(path.join(output, name)),
);
}
});
it.each([
["the digest is missing", { [archive]: "archive bytes" }],
[
"the digest names another file",
{ [archive]: "a", [`${archive}.sha256`]: `${digest} other.tar.gz\n` },
],
["the digest is malformed", { [archive]: "a", [`${archive}.sha256`]: "not-a-digest x\n" }],
[
"the build left something else beside the pair",
{
[archive]: "a",
[`${archive}.sha256`]: `${digest} ${archive}\n`,
"stale.tar.gz.sha256": "x",
},
],
[
"there is more than one archive",
{
[archive]: "a",
[`${archive}.sha256`]: `${digest} ${archive}\n`,
"example-0.0.9-any.tar.gz": "old",
},
],
["there is no archive at all", {}],
])("refuses when %s", async (_label, files) => {
const { output, root } = await buildOutput(files);
const target = path.join(root, "package");
await expect(release(output, target)).rejects.toThrow();
await expect(stat(target)).rejects.toMatchObject({ code: "ENOENT" });
});
it("never reuses a release directory that already exists", async () => {
const { output, root } = await buildOutput();
const target = path.join(root, "package");
await mkdir(target);
await writeFile(path.join(target, `${archive}.sha256`), `${"0".repeat(64)} ${archive}\n`);
await expect(release(output, target)).rejects.toThrow();
// The stale digest is left exactly as it was; nothing was mixed into it.
expect(await readdir(target)).toEqual([`${archive}.sha256`]);
});
});