Run CLI Docker validation on native architectures and publish tested images

This commit is contained in:
vorotamoroz
2026-10-14 11:22:49 +00:00
parent 4a51253299
commit 8854eb9a15
11 changed files with 821 additions and 68 deletions
+178 -15
View File
@@ -1,4 +1,4 @@
import { readFileSync, mkdtempSync, rmSync } from "node:fs";
import { readFileSync, mkdtempSync, rmSync, mkdirSync, writeFileSync, existsSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { spawnSync } from "node:child_process";
@@ -7,12 +7,20 @@ import { describe, expect, it } from "vitest";
import { parse } from "yaml";
const root = new URL("../", import.meta.url);
type Step = { id?: string; run?: string; uses?: string; with?: Record<string, unknown> };
type Step = { id?: string; run?: string; uses?: string; if?: string; with?: Record<string, unknown> };
type Workflow = {
on: Record<string, { paths?: string[]; [key: string]: unknown }>;
jobs: Record<
string,
{ steps?: Step[]; needs?: string | string[]; uses?: string; if?: string; permissions?: Record<string, string> }
{
steps?: Step[];
needs?: string | string[];
uses?: string;
if?: string;
permissions?: Record<string, string>;
"runs-on"?: string;
strategy?: { matrix?: { include?: Array<{ arch: string; runner: string }> } };
}
>;
};
function workflow(name: string): Workflow {
@@ -130,20 +138,175 @@ describe("CLI CI selection", () => {
);
expect(publication.jobs.validate.uses).toBe("./.github/workflows/cli-docker-validation.yml");
expect(publication.jobs.validate.permissions).toEqual({ contents: "read" });
expect(publication.jobs["build-and-push"].needs).toBe("validate");
expect(publication.jobs["build-and-push"].if).toBe(
const publishJob = publication.jobs["publish-images"];
expect(publishJob.needs).toEqual(["metadata", "validate"]);
expect(publishJob.if).toBe(
"${{ needs.validate.outputs.e2e_passed == 'true' || (github.event_name == 'workflow_dispatch' && inputs.force) }}"
);
const publishBuild = publication.jobs["build-and-push"].steps?.find((step) =>
step.uses?.startsWith("docker/build-push-action@")
expect(publishJob.strategy?.matrix?.include).toEqual(validationJob.strategy?.matrix?.include);
expect(publishJob["runs-on"]).toBe("${{ matrix.runner }}");
const publishBuild = publishJob.steps?.find((step) => step.uses?.startsWith("docker/build-push-action@"));
expect(publishBuild?.with?.file).toBe("src/apps/cli/Dockerfile.publish");
expect(publishBuild?.with?.["build-contexts"]).toBe(
"validated-image=oci-layout://${{ runner.temp }}/cli-image@${{ steps.image-context.outputs.digest }}"
);
expect(publishBuild?.with?.["cache-from"]).toBe(build?.with?.["cache-from"]);
const validationScope = String(build?.with?.["cache-to"]).match(/scope=([^,\s]+)/)?.[1];
const publicationScope = String(publishBuild?.with?.["cache-to"]).match(/scope=([^,\s]+)/)?.[1];
expect(validationScope).toBeDefined();
expect(publicationScope).toBeDefined();
expect(publicationScope).not.toBe(validationScope);
expect(publishBuild?.with?.["cache-from"]).toContain(`scope=${validationScope}`);
expect(publishBuild?.with?.["cache-from"]).toContain(`scope=${publicationScope}`);
const imageUpload = validationJob.steps?.find(
(step) => step.with?.name === "cli-docker-image-${{ matrix.arch }}"
);
const imageDownload = publishJob.steps?.find(
(step) => step.with?.name === "cli-docker-image-${{ matrix.arch }}"
);
expect(imageUpload?.with?.path).toBe("${{ runner.temp }}/cli-image.tar");
expect(build?.with?.outputs).toBe("type=oci,dest=${{ runner.temp }}/cli-image.tar");
expect(imageDownload?.with?.name).toBe(imageUpload?.with?.name);
const publishingInstructions = readFileSync(new URL("src/apps/cli/Dockerfile.publish", root), "utf8")
.split("\n")
.filter((line) => line && !line.startsWith("#"));
expect(publishingInstructions).toEqual(["FROM validated-image"]);
expect(publishBuild?.with?.outputs).toBe(
"type=image,name=${{ needs.metadata.outputs.image }},push-by-digest=true,name-canonical=true,push=${{ needs.metadata.outputs.push }}"
);
expect(publishJob.steps?.some((step) => step.uses?.startsWith("docker/setup-qemu-action@"))).toBe(false);
expect(publishJob.steps?.find((step) => step.uses?.startsWith("docker/login-action@"))?.if).toBe(
"needs.metadata.outputs.push == 'true'"
);
const manifestJob = publication.jobs.manifest;
expect(manifestJob.needs).toEqual(["metadata", "publish-images"]);
expect(manifestJob.steps?.find((step) => step.run?.includes("imagetools create"))?.run).toContain(
'if [[ "$PUSH" == "true" ]]; then'
);
});
it("builds and tests Docker images on both native architectures", () => {
const job = workflow("cli-docker-validation.yml").jobs.validate;
expect(job.strategy?.matrix?.include).toEqual([
{ arch: "amd64", runner: "ubuntu-24.04" },
{ arch: "arm64", runner: "ubuntu-24.04-arm" },
]);
expect(job["runs-on"]).toBe("${{ matrix.runner }}");
expect(job.steps?.some((step) => step.uses?.startsWith("docker/setup-qemu-action@"))).toBe(false);
});
it("builds the Docker JavaScript bundle once and passes it to the native image builds", () => {
const jobs = workflow("cli-docker-validation.yml").jobs;
expect(jobs.bundle?.steps?.filter((step) => step.run === "npm run build") ?? []).toHaveLength(1);
expect(jobs.validate.needs).toBe("bundle");
const upload = jobs.bundle?.steps?.find((step) => step.uses?.startsWith("actions/upload-artifact@"));
const download = jobs.validate.steps?.find((step) => step.uses?.startsWith("actions/download-artifact@"));
expect(download?.with?.name).toBe(upload?.with?.name);
expect(download?.with?.path).toBe("_cli-bundle/");
const build = jobs.validate.steps?.find((step) => step.uses?.startsWith("docker/build-push-action@"));
expect(build?.with?.["build-args"]).toBe("CLI_BUNDLE_STAGE=prebuilt");
});
it.each([false, true])("assembles both image digests with publication=%s", (push) => {
const step = workflow("cli-docker.yml").jobs.manifest.steps?.find((step) =>
step.run?.includes("imagetools create")
);
expect(step?.run).toBeDefined();
const directory = mkdtempSync(join(tmpdir(), "livesync-cli-manifest-"));
try {
const digests = join(directory, "cli-digests");
const bin = join(directory, "bin");
mkdirSync(digests);
mkdirSync(bin);
for (const [arch, char] of [
["amd64", "a"],
["arm64", "b"],
]) {
writeFileSync(
join(digests, `${arch}.json`),
JSON.stringify({ arch, digest: `sha256:${char.repeat(64)}` })
);
}
const capture = join(directory, "docker-args");
writeFileSync(join(bin, "docker"), '#!/bin/sh\nprintf \'%s\\n\' "$@" > "$CLI_TEST_CAPTURE"\n', {
mode: 0o755,
});
const image = "ghcr.io/test-owner/livesync-cli";
const tags = [`${image}:1.0.36-cli`, `${image}:latest`];
const result = spawnSync("bash", ["-c", step!.run!], {
cwd: root,
env: {
...process.env,
RUNNER_TEMP: directory,
IMAGE: image,
IMAGE_TAGS: tags.join(","),
PUSH: String(push),
PATH: `${bin}:${process.env.PATH}`,
CLI_TEST_CAPTURE: capture,
},
encoding: "utf8",
});
expect(result.status, result.stderr).toBe(0);
if (push) {
expect(readFileSync(capture, "utf8").trim().split("\n")).toEqual([
"buildx",
"imagetools",
"create",
"--tag",
tags[0],
"--tag",
tags[1],
`${image}@sha256:${"a".repeat(64)}`,
`${image}@sha256:${"b".repeat(64)}`,
]);
} else {
expect(existsSync(capture)).toBe(false);
expect(result.stdout).toContain("publication skipped");
}
} finally {
rmSync(directory, { recursive: true, force: true });
}
});
it.each([
[
"1.0.36",
"refs/tags/1.0.36-cli",
"push",
"false",
true,
["1.0.36-cli", "1.0-cli", "latest", "1.0.36-sha-deadbee-cli"],
],
[
"1.0.36-rc.1",
"refs/tags/1.0.36-rc.1-cli",
"push",
"false",
true,
["1.0.36-rc.1-cli", "1.0.36-rc.1-sha-deadbee-cli"],
],
["1.0.36", "refs/heads/main", "push", "false", true, ["edge"]],
["1.0.36", "refs/heads/test", "workflow_dispatch", "true", false, ["1.0.36-dev-sha-deadbee-cli"]],
] as const)("preserves tag selection for %s on %s", (version, ref, event, dryRun, push, tags) => {
const metadata = workflow("cli-docker.yml").jobs.metadata.steps?.find((step) => step.id === "meta");
expect(metadata?.run).toBeDefined();
const directory = mkdtempSync(join(tmpdir(), "livesync-cli-image-metadata-"));
try {
const bin = join(directory, "bin");
const output = join(directory, "output");
mkdirSync(bin);
writeFileSync(join(directory, "manifest.json"), JSON.stringify({ version }));
writeFileSync(join(bin, "git"), "#!/bin/sh\nprintf 'deadbee\\n'\n", { mode: 0o755 });
const script = metadata!.run!.replace("${{ github.repository_owner }}", "test-owner");
const result = spawnSync("bash", ["-e", "-c", script], {
cwd: directory,
env: {
...process.env,
GITHUB_OUTPUT: output,
REF: ref,
EVENT_NAME: event,
DRY_RUN: dryRun,
PATH: `${bin}:${process.env.PATH}`,
},
encoding: "utf8",
});
expect(result.status, result.stderr).toBe(0);
const image = "ghcr.io/test-owner/livesync-cli";
expect(readFileSync(output, "utf8").trim().split("\n")).toEqual([
`image=${image}`,
`tags=${tags.map((tag) => `${image}:${tag}`).join(",")}`,
`push=${push}`,
]);
} finally {
rmSync(directory, { recursive: true, force: true });
}
});
});
+157
View File
@@ -0,0 +1,157 @@
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import { afterEach, describe, expect, it } from "vitest";
import { imageReferences, summariseResults } from "./cli-docker-artifacts.mjs";
const helper = fileURLToPath(new URL("./cli-docker-artifacts.mjs", import.meta.url));
const temporaryDirectories: string[] = [];
const amd64Digest = `sha256:${"a".repeat(64)}`;
const arm64Digest = `sha256:${"b".repeat(64)}`;
afterEach(() => {
for (const directory of temporaryDirectories.splice(0)) {
rmSync(directory, { recursive: true, force: true });
}
});
function makeTemporaryDirectory(): string {
const directory = mkdtempSync(join(tmpdir(), "livesync-cli-docker-artifacts-"));
temporaryDirectories.push(directory);
return directory;
}
function runCli(args: string[]) {
return spawnSync(process.execPath, [helper, ...args], { encoding: "utf8" });
}
function writeJson(directory: string, name: string, value: unknown): void {
writeFileSync(join(directory, name), `${JSON.stringify(value)}\n`, "utf8");
}
describe("CLI Docker artifact aggregation", () => {
it.each([
[
[
{ arch: "amd64", e2e_passed: false },
{ arch: "arm64", e2e_passed: true },
],
false,
],
[
[
{ arch: "arm64", e2e_passed: true },
{ arch: "amd64", e2e_passed: false },
],
false,
],
])("summarises mixed results independent of record order", (records, expected) => {
expect(summariseResults(records)).toBe(expected);
});
it("reports success only when both architecture results pass", () => {
expect(
summariseResults([
{ arch: "amd64", e2e_passed: true },
{ arch: "arm64", e2e_passed: true },
])
).toBe(true);
});
it("rejects malformed, missing, and duplicate result records", () => {
expect(() =>
summariseResults([
{ arch: "amd64", e2e_passed: "true" },
{ arch: "arm64", e2e_passed: true },
])
).toThrow(/boolean/);
expect(() => summariseResults([{ arch: "amd64", e2e_passed: true }])).toThrow(/Exactly one/);
expect(() =>
summariseResults([
{ arch: "amd64", e2e_passed: true },
{ arch: "amd64", e2e_passed: false },
])
).toThrow(/Duplicate/);
});
it("builds deterministic architecture-ordered references and rejects invalid or duplicate digests", () => {
const records = [
{ arch: "arm64", digest: arm64Digest },
{ arch: "amd64", digest: amd64Digest },
];
expect(imageReferences("ghcr.io/example/cli", records)).toEqual([
`ghcr.io/example/cli@${amd64Digest}`,
`ghcr.io/example/cli@${arm64Digest}`,
]);
expect(() =>
imageReferences("ghcr.io/example/cli", [
{ arch: "amd64", digest: "sha256:BAD" },
{ arch: "arm64", digest: arm64Digest },
])
).toThrow(/Digest/);
expect(() =>
imageReferences("ghcr.io/example/cli", [
{ arch: "amd64", digest: amd64Digest },
{ arch: "arm64", digest: amd64Digest },
])
).toThrow(/distinct/);
expect(() => imageReferences("../../escape", records)).toThrow(/canonical image/);
});
it("rejects extra JSON files and filenames whose records name another architecture", () => {
const directory = makeTemporaryDirectory();
writeJson(directory, "amd64.json", { arch: "amd64", e2e_passed: true });
writeJson(directory, "arm64.json", { arch: "arm64", e2e_passed: true });
writeJson(directory, "extra.json", { arch: "arm64", e2e_passed: false });
expect(runCli(["summary", directory]).status).toBe(1);
rmSync(join(directory, "extra.json"));
writeJson(directory, "amd64.json", { arch: "arm64", e2e_passed: true });
expect(runCli(["summary", directory]).status).toBe(1);
});
it("prints a false summary successfully and fails when either result file is missing", () => {
const directory = makeTemporaryDirectory();
const results = join(directory, "results");
expect(runCli(["record-result", "amd64", "failure", results]).status).toBe(0);
expect(runCli(["record-result", "arm64", "success", results]).status).toBe(0);
const summary = runCli(["summary", results]);
expect(summary.status, summary.stderr).toBe(0);
expect(summary.stdout).toBe("e2e_passed=false\n");
const missing = join(directory, "missing");
mkdirSync(missing);
expect(runCli(["summary", missing]).status).toBe(1);
});
it("records results and digests without overwriting and prints references in canonical order", () => {
const directory = makeTemporaryDirectory();
const results = join(directory, "results");
const digests = join(directory, "digests");
expect(runCli(["record-result", "arm64", "success", results]).status).toBe(0);
expect(runCli(["record-result", "amd64", "skipped", results]).status).toBe(0);
expect(runCli(["record-result", "amd64", "success", results]).status).toBe(1);
expect(JSON.parse(readFileSync(join(results, "amd64.json"), "utf8"))).toEqual({
arch: "amd64",
e2e_passed: false,
});
expect(runCli(["record-digest", "arm64", arm64Digest, digests]).status).toBe(0);
expect(runCli(["record-digest", "amd64", amd64Digest, digests]).status).toBe(0);
const invalid = join(directory, "invalid");
expect(runCli(["record-digest", "amd64", "sha256:BAD", invalid]).status).toBe(1);
expect(existsSync(invalid)).toBe(false);
const references = runCli(["references", "ghcr.io/example/cli", digests]);
expect(references.status, references.stderr).toBe(0);
expect(references.stdout).toBe(`ghcr.io/example/cli@${amd64Digest}\nghcr.io/example/cli@${arm64Digest}\n`);
const traversal = runCli(["record-result", "../escape", "success", join(directory, "nested")]);
expect(traversal.status).toBe(1);
expect(readdirSync(directory)).not.toContain("escape.json");
});
});
+177
View File
@@ -0,0 +1,177 @@
import { readdir, mkdir, readFile, writeFile } from "node:fs/promises";
import { resolve } from "node:path";
import { pathToFileURL } from "node:url";
const ARCHITECTURES = ["amd64", "arm64"];
const OUTCOMES = ["success", "failure", "cancelled", "skipped"];
const DIGEST_PATTERN = /^sha256:[0-9a-f]{64}$/;
const IMAGE_COMPONENT = "[a-z0-9]+(?:(?:[._]|__|[-]+)[a-z0-9]+)*";
const IMAGE_PATTERN = new RegExp(
`^(?:[a-z0-9]+(?:[.-][a-z0-9]+)*(?::[0-9]+)?/)?${IMAGE_COMPONENT}(?:/${IMAGE_COMPONENT})*(?::[A-Za-z0-9_][A-Za-z0-9_.-]{0,127})?$`
);
function assertArchitecture(arch) {
if (!ARCHITECTURES.includes(arch)) {
throw new Error(`Unsupported architecture: ${String(arch)}`);
}
}
function assertExactKeys(record, expectedKeys) {
if (record === null || typeof record !== "object" || Array.isArray(record)) {
throw new Error("Artifact record must be a JSON object");
}
const keys = Object.keys(record).sort();
const expected = [...expectedKeys].sort();
if (keys.length !== expected.length || keys.some((key, index) => key !== expected[index])) {
throw new Error(`Artifact record must contain exactly: ${expected.join(", ")}`);
}
}
function orderedPair(records, expectedKeys) {
if (!Array.isArray(records) || records.length !== ARCHITECTURES.length) {
throw new Error("Exactly one artifact record is required for each architecture");
}
const recordsByArchitecture = new Map();
for (const record of records) {
assertExactKeys(record, expectedKeys);
assertArchitecture(record.arch);
if (recordsByArchitecture.has(record.arch)) {
throw new Error(`Duplicate artifact record for ${record.arch}`);
}
recordsByArchitecture.set(record.arch, record);
}
return ARCHITECTURES.map((arch) => {
const record = recordsByArchitecture.get(arch);
if (!record) {
throw new Error(`Missing artifact record for ${arch}`);
}
return record;
});
}
function validateImage(image) {
if (typeof image !== "string" || !image || /\s|@/.test(image) || !IMAGE_PATTERN.test(image)) {
throw new Error("Image must be a canonical image name without whitespace or a digest");
}
}
function validateDigest(digest) {
if (typeof digest !== "string" || !DIGEST_PATTERN.test(digest)) {
throw new Error("Digest must match sha256:<64 lowercase hexadecimal characters>");
}
}
export function summariseResults(records) {
const orderedRecords = orderedPair(records, ["arch", "e2e_passed"]);
for (const { e2e_passed } of orderedRecords) {
if (typeof e2e_passed !== "boolean") {
throw new Error("e2e_passed must be a boolean");
}
}
return orderedRecords.every(({ e2e_passed }) => e2e_passed);
}
export function imageReferences(image, records) {
validateImage(image);
const orderedRecords = orderedPair(records, ["arch", "digest"]);
const digests = orderedRecords.map(({ digest }) => {
validateDigest(digest);
return digest;
});
if (new Set(digests).size !== digests.length) {
throw new Error("Each architecture must have a distinct image digest");
}
return digests.map((digest) => `${image}@${digest}`);
}
async function writeRecord(arch, directory, record) {
assertArchitecture(arch);
await mkdir(directory, { recursive: true });
await writeFile(resolve(directory, `${arch}.json`), `${JSON.stringify(record)}\n`, { flag: "wx" });
}
async function readRecords(directory) {
const entries = await readdir(directory, { withFileTypes: true });
const jsonEntries = entries.filter((entry) => entry.name.toLowerCase().endsWith(".json"));
const expectedNames = ARCHITECTURES.map((arch) => `${arch}.json`);
const actualNames = jsonEntries.map((entry) => entry.name).sort();
if (actualNames.length !== expectedNames.length || expectedNames.some((name) => !actualNames.includes(name))) {
throw new Error(`Directory must contain exactly ${expectedNames.join(" and ")}`);
}
return Promise.all(
ARCHITECTURES.map(async (arch) => {
const name = `${arch}.json`;
const entry = jsonEntries.find((candidate) => candidate.name === name);
if (!entry?.isFile()) {
throw new Error(`${name} must be a regular file`);
}
let record;
try {
record = JSON.parse(await readFile(resolve(directory, name), "utf8"));
} catch (error) {
throw new Error(`Could not read ${name}: ${error instanceof Error ? error.message : String(error)}`);
}
if (record?.arch !== arch) {
throw new Error(`${name} must contain an ${arch} record`);
}
return record;
})
);
}
async function main(args) {
const [command, ...values] = args;
switch (command) {
case "record-result": {
if (values.length !== 3) {
throw new Error("Usage: record-result ARCH OUTCOME DIRECTORY");
}
const [arch, outcome, directory] = values;
assertArchitecture(arch);
if (!OUTCOMES.includes(outcome)) {
throw new Error(`Unsupported outcome: ${outcome}`);
}
await writeRecord(arch, directory, { arch, e2e_passed: outcome === "success" });
return;
}
case "summary": {
if (values.length !== 1) {
throw new Error("Usage: summary DIRECTORY");
}
const records = await readRecords(values[0]);
process.stdout.write(`e2e_passed=${summariseResults(records)}\n`);
return;
}
case "record-digest": {
if (values.length !== 3) {
throw new Error("Usage: record-digest ARCH DIGEST DIRECTORY");
}
const [arch, digest, directory] = values;
assertArchitecture(arch);
validateDigest(digest);
await writeRecord(arch, directory, { arch, digest });
return;
}
case "references": {
if (values.length !== 2) {
throw new Error("Usage: references IMAGE DIRECTORY");
}
const [image, directory] = values;
const records = await readRecords(directory);
process.stdout.write(`${imageReferences(image, records).join("\n")}\n`);
return;
}
default:
throw new Error("Usage: cli-docker-artifacts.mjs <record-result|summary|record-digest|references> ...");
}
}
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
main(process.argv.slice(2)).catch((error) => {
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
process.exitCode = 1;
});
}
+37
View File
@@ -0,0 +1,37 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const root = new URL("../", import.meta.url);
const dockerfile = readFileSync(new URL("src/apps/cli/Dockerfile", root), "utf8");
const dockerfileLines = dockerfile.split(/\r?\n/);
const gitignoreLines = readFileSync(new URL(".gitignore", root), "utf8").split(/\r?\n/);
function stageLine(stage: string): string | undefined {
return dockerfileLines.find((line) => /^FROM\b/i.test(line) && new RegExp(`\\bAS\\s+${stage}$`, "i").test(line));
}
describe("CLI Dockerfile bundle stages", () => {
it("keeps the default build self-contained from source", () => {
expect(dockerfileLines).toContain("ARG CLI_BUNDLE_STAGE=builder");
expect(stageLine("builder")).toBe("FROM --platform=$BUILDPLATFORM node:22-slim AS builder");
expect(dockerfileLines).toContain("COPY . .");
expect(dockerfileLines).toContain("RUN cd src/apps/cli && npm run build");
expect(dockerfileLines).toContain("FROM ${CLI_BUNDLE_STAGE} AS cli-bundle");
expect(dockerfileLines).toContain("COPY --from=cli-bundle /build/src/apps/cli/dist ./dist");
});
it("provides an explicit stage for a prebuilt bundle", () => {
expect(stageLine("prebuilt")).toBe("FROM scratch AS prebuilt");
expect(dockerfileLines).toContain("COPY _cli-bundle/ /build/src/apps/cli/dist/");
expect(dockerfileLines).toContain("FROM ${CLI_BUNDLE_STAGE} AS cli-bundle");
});
it("builds source on the host platform and native dependencies in the target stages", () => {
expect(stageLine("runtime-deps")).toBe("FROM node:22-slim AS runtime-deps");
expect(dockerfileLines).toContain("FROM node:22-slim");
});
it("ignores the temporary prebuilt bundle directory", () => {
expect(gitignoreLines).toContain("_cli-bundle/");
});
});