evOo-local/infra/updater/src/publish-workflow.test.ts
cynarAI 634b3f823b
ci: stop image publish legs from cancelling each other (#191)
Every main push since the concurrency change cancelled two of the three
image builds: the build job's concurrency group named only the ref, so
the app, updater and computer legs of one run shared it and cancelled
each other, and the publish job never ran. Main has not published an
edge image since. The build and publish groups now name the matrix leg.

The Playwright report publisher also read the jobs of a run's latest
attempt, so a re-run already in progress made it fail on an empty
conclusion. It now reads the attempt that finished.


Claude-Session: https://claude.ai/code/session_01R8L4XgqjAemFR2remB5hVY

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-10 06:50:07 +02:00

124 lines
5.7 KiB
TypeScript

import { readFileSync } from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { parse } from "yaml";
const workflowText = readFileSync(
path.resolve(import.meta.dirname, "../../../.github/workflows/publish-server-image.yml"),
"utf8",
);
interface WorkflowJob {
if?: string;
concurrency?: { group?: string };
needs?: string;
"runs-on"?: string;
strategy?: { matrix?: { arch?: string[]; include?: Array<Record<string, string>> } };
}
const workflow = parse(workflowText) as {
jobs: { validate: WorkflowJob; build: WorkflowJob; publish: WorkflowJob };
};
describe("server image publish workflow", () => {
it("builds amd64 natively on the Linux runner and keeps arm64 paused, not emulated", () => {
expect(workflowText).not.toContain("setup-qemu-action");
expect(workflowText).toContain("arm64 is paused");
const build = workflow.jobs.build;
expect(build["runs-on"]).toContain("matrix.runner");
expect(build.strategy?.matrix?.arch).toEqual(["amd64"]);
const runners = Object.fromEntries(
(build.strategy?.matrix?.include ?? [])
.filter((entry) => entry.arch !== undefined)
.map((entry) => [entry.arch, entry.runner]),
);
expect(runners).toEqual({ amd64: ["self-hosted", "evoo-ci", "Linux"] });
});
it("publishes one verified manifest per image after the build", () => {
const publish = workflow.jobs.publish;
expect(publish.needs).toEqual(["changes", "build"]);
expect(workflowText).toContain("push-by-digest=true");
expect(workflowText).toContain("docker buildx imagetools create");
expect(workflowText).toContain(`if [[ "\${#sources[@]}" -ne 1 ]]`);
expect(workflowText).toContain("for want in linux/amd64; do");
expect(workflowText).toContain("actions/attest-build-provenance@");
expect(workflowText).not.toContain("github.repository_owner_type");
expect(workflowText).toContain(`gh api "repos/\${GITHUB_REPOSITORY}"`);
// `gh api --jq` already prints raw strings; `--jq -r` takes `-r` as the filter and fails.
expect(workflowText).not.toContain("--jq -r");
expect(workflowText).toContain(".owner.type");
expect(workflowText).toContain("if: steps.attestation.outputs.enabled == 'true'");
expect(workflowText).toContain('[[ "$owner_type" == "User" && "$private" == "true" ]]');
});
it("stores digest markers under RUNNER_TEMP so self-hosted runners do not leak stale files", () => {
expect(workflowText).not.toContain("/tmp/digests");
expect(workflowText).toContain("RUNNER_TEMP");
});
it("passes build digests to publish through GHCR handoff tags, not Actions artifact storage", () => {
expect(workflowText).not.toContain("actions/upload-artifact@");
expect(workflowText).not.toContain("actions/download-artifact@");
expect(workflowText).toContain("Tag digest for publish job handoff");
expect(workflowText).toContain("ci-${GITHUB_RUN_ID}-");
});
it("isolates buildx per matrix job on shared self-hosted Docker hosts", () => {
expect(workflowText).toContain('echo "DOCKER_CONFIG=${cfg}" >> "$GITHUB_ENV"');
expect(workflowText).toMatch(/builder:\s+\$\{\{\s*steps\.buildx\.outputs\.name\s*\}\}/);
expect(workflowText).toMatch(
/name: ci-\$\{\{ github\.run_id \}\}-\$\{\{ github\.run_attempt \}\}-\$\{\{ matrix\.name \}\}-\$\{\{ matrix\.arch \}\}/,
);
expect(workflowText).toMatch(
/name: ci-pr-\$\{\{ github\.event\.pull_request\.number \}\}-\$\{\{ matrix\.name \}\}/,
);
expect(workflowText).toMatch(
/name: ci-\$\{\{ github\.run_id \}\}-\$\{\{ github\.run_attempt \}\}-publish-\$\{\{ matrix\.name \}\}/,
);
});
it("isolates Docker registry auth per job and does not delete handoff tags via the registry API", () => {
expect(workflowText).toContain('echo "DOCKER_CONFIG=${cfg}" >> "$GITHUB_ENV"');
expect(workflowText).toContain("Prepare isolated Docker config directory");
expect(workflowText).not.toContain("imagetools rm");
expect(workflowText).not.toContain("Delete CI handoff tags");
expect(workflowText).toContain("safe tag-only delete API");
});
it("names every image under the lowercase repository namespace", () => {
expect(workflowText).not.toContain(`ghcr.io/\${{ github.repository }}`);
const references =
workflowText.match(/ghcr\.io\/\$\{\{ steps\.ns\.outputs\.repo \}\}\//g) ?? [];
expect(references.length).toBeGreaterThanOrEqual(6);
expect(workflowText).toContain(`repo=\${GITHUB_REPOSITORY,,}`);
});
it("keeps pull requests read-only and every action pinned to a commit", () => {
const validate = workflow.jobs.validate;
const build = workflow.jobs.build;
const publish = workflow.jobs.publish;
expect(validate.if).toBe("github.event_name == 'pull_request'");
expect(build.if).toBe(
"github.event_name != 'pull_request' && needs.changes.outputs.publish == 'true'",
);
expect(publish.if).toBe(
"github.event_name != 'pull_request' && needs.changes.outputs.publish == 'true'",
);
expect(workflowText).toContain(
"cancel-in-progress: ${{ github.event_name == 'pull_request' }}",
);
// Matrix legs share a job-level concurrency group unless it names the leg, and a shared
// group cancels sibling legs, so no image is published.
expect(build.concurrency?.group).toBe(
"publish-server-image-build-${{ github.ref }}-${{ matrix.name }}-${{ matrix.arch }}",
);
expect(publish.concurrency?.group).toBe(
"publish-server-image-publish-${{ github.ref == 'refs/heads/main' && 'main' || github.ref }}-${{ matrix.name }}",
);
expect(workflowText).toContain("push: false");
for (const match of workflowText.matchAll(/uses:\s+([^\s#]+)/g)) {
expect(match[1], match[1]).toMatch(/@[0-9a-f]{40}$/);
}
});
});