Files
rea/tests/boundary/filesystem/workflowActionPinning.test.ts
T
Ysopwnable 3406dc4d85 ci: pin Cutter actions and guard workflow-only changes (#4199)
Pin the remaining Cutter upload/download actions and enforce immutable GitHub/Docker references at actual action call sites. Run the same exact guard for workflow-only changes without preparing runtime artifacts.

Co-authored-by: Govindu Sasanka <31107592+GovinduSasanka@users.noreply.github.com>
Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com>
2026-10-15 18:38:19 +00:00

149 lines
4.5 KiB
TypeScript

import { readdir, readFile } from "node:fs/promises";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import { parse } from "yaml";
import { z } from "zod";
const github = fileURLToPath(new URL("../../../.github/", import.meta.url));
const steps = z.array(z.object({ uses: z.string().optional() })).optional();
const actionReferences = z.object({
jobs: z
.record(z.string(), z.object({ uses: z.string().optional(), steps }))
.optional(),
runs: z.object({ steps }).optional(),
});
// Inspect action/workflow call sites, not arbitrary inputs named `uses` or
// shell source. YAML parsing also covers flow syntax and quoted references.
const unpinnedReferences = (source: string): readonly string[] => {
const document = actionReferences.parse(parse(source));
const references = [
...Object.values(document.jobs ?? {}).flatMap((job) => [
job.uses,
...(job.steps ?? []).map((step) => step.uses),
]),
...(document.runs?.steps ?? []).map((step) => step.uses),
];
return references.filter(
(reference): reference is string =>
reference !== undefined &&
!reference.startsWith("./") &&
(reference.startsWith("docker://")
? !/@sha256:[0-9a-f]{64}$/iu.test(reference)
: !/@[0-9a-f]{40}$/iu.test(reference)),
);
};
describe("external GitHub action pinning", () => {
const sha = "11d5960a326750d5838078e36cf38b85af677262";
it("finds mutable GitHub and Docker references in all action call sites", () => {
expect(
unpinnedReferences(`
jobs:
call:
uses: owner/repo/.github/workflows/build.yml@main
build:
steps:
- uses: actions/checkout@v4
- { uses: actions/setup-node@v4 }
- uses: docker://alpine:3.20
- uses: docker://alpine@sha256:abcd
runs:
using: composite
steps:
- uses: "actions/cache@v4"
`),
).toEqual([
"owner/repo/.github/workflows/build.yml@main",
"actions/checkout@v4",
"actions/setup-node@v4",
"docker://alpine:3.20",
"docker://alpine@sha256:abcd",
"actions/cache@v4",
]);
});
it("accepts full GitHub SHAs, local calls and Docker SHA256 digests", () => {
expect(
unpinnedReferences(`
jobs:
local:
uses: ./.github/workflows/build.yml
remote:
uses: owner/repo/.github/workflows/build.yml@${sha}
build:
steps:
- uses: "actions/checkout@${sha}" # version comment
- uses: actions/cache/restore@${sha.toUpperCase()}
- uses: './.github/actions/npm-cache'
- uses: docker://alpine@sha256:${"a".repeat(64)}
`),
).toEqual([]);
});
it("ignores inputs, environment and shell source named uses", () => {
expect(
unpinnedReferences(`
env: { uses: ordinary-value }
jobs:
build:
steps:
- uses: actions/checkout@${sha}
with: { uses: ordinary-input }
- run: 'echo "uses: actions/example@main"'
`),
).toEqual([]);
});
it("also runs the guard for workflow-only changes without preparing runtime artifacts", async () => {
const workflow = z
.object({
jobs: z.object({
static: z.object({
steps: z.array(
z.object({
if: z.string().optional(),
run: z.string().optional(),
}),
),
}),
}),
})
.parse(parse(await readFile(join(github, "workflows/ci.yml"), "utf8")));
const guard = workflow.jobs.static.steps.find((step) =>
step.run?.includes("workflowActionPinning.test.ts"),
);
expect(guard?.if).toBe(
"needs.changes.outputs.workflows == 'true' && needs.changes.outputs.code != 'true'",
);
expect(guard?.run).toBe(
"node scripts/run-exact-tests.mjs run tests/boundary/filesystem/workflowActionPinning.test.ts",
);
});
it("pins external actions in all repository workflows and composite actions", async () => {
const workflows = join(github, "workflows");
const actions = join(github, "actions");
const files = [
...(await readdir(workflows))
.filter((name) => /\.ya?ml$/u.test(name))
.map((name) => join(workflows, name)),
...(await readdir(actions, { recursive: true }))
.filter((name) => /(?:^|[/\\])action\.ya?ml$/u.test(name))
.map((name) => join(actions, name)),
].sort();
const unpinned: string[] = [];
for (const file of files) {
for (const reference of unpinnedReferences(
await readFile(file, "utf8"),
)) {
unpinned.push(`${file}: ${reference}`);
}
}
expect(unpinned).toEqual([]);
});
});