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
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]>
128 lines
4.6 KiB
TypeScript
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`]);
|
|
});
|
|
});
|