Use per-job DOCKER_CONFIG under RUNNER_TEMP so login-action logout on shared self-hosted runners cannot strip another job's credentials. Drop broken buildx imagetools rm; untag ci-* handoff labels via the registry distribution API after publish. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
101 lines
4.7 KiB
TypeScript
101 lines
4.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;
|
|
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 Docker registry auth per job and deletes handoff tags without imagetools rm", () => {
|
|
expect(workflowText).toContain("DOCKER_CONFIG: ${{ runner.temp }}/docker-config");
|
|
expect(workflowText).toContain("Prepare isolated Docker config directory");
|
|
expect(workflowText).not.toContain("imagetools rm");
|
|
expect(workflowText).toContain("Delete CI handoff tags");
|
|
expect(workflowText).toContain("/v2/${repository}/manifests/${tag}");
|
|
});
|
|
|
|
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' }}");
|
|
expect(workflowText).toContain("publish-server-image-build-${{ github.ref }}");
|
|
expect(workflowText).toContain("publish-server-image-publish-${{ github.ref == 'refs/heads/main' && 'main' || github.ref }}");
|
|
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}$/);
|
|
}
|
|
});
|
|
});
|