evOo-local/infra/updater/src/publish-workflow.test.ts
cynarAI 6fc734b0ae
ci: pass publish-server-image digests via GHCR handoff tags (#169)
Build jobs already push each architecture by digest. Tag those manifests
with ephemeral ci-${run_id}-${image}-${arch} refs instead of uploading
digest marker files to Actions artifact storage, which is blocked by the
account-wide quota.

Publish resolves handoff tags back into RUNNER_TEMP digest markers, then
assembles semver/edge tags and attestation as before. Handoff tags are
removed after publish with continue-on-error.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
2026-10-06 05:06:23 +02:00

86 lines
3.8 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;
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).toBe("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("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'");
expect(publish.if).toBe("github.event_name != 'pull_request'");
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}$/);
}
});
});