* ci: resolve attestation eligibility from repository API The publish guard in #103 used github.repository_owner_type, which is not part of the documented github context and evaluates empty on push. That made the OR condition true and the attest step always ran. Query repos/{owner}/{repo} for owner.type and private, skip attestation only for User-owned private repositories, and gate the attest action on the resolved step output. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * test: escape workflow placeholders for Biome noTemplateCurlyInString Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
77 lines
3.3 KiB
TypeScript
77 lines
3.3 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}"`);
|
|
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("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}$/);
|
|
}
|
|
});
|
|
});
|