From 3406dc4d85bf29414319b33b98bd1421d573f663 Mon Sep 17 00:00:00 2001 From: Ysopwnable <161784452+N0zoM1z0@users.noreply.github.com> Date: Fri, 16 Oct 2026 02:38:19 +0800 Subject: [PATCH] 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> --- .github/workflows/ci.yml | 5 + .github/workflows/real-cutter.yml | 6 +- .../filesystem/workflowActionPinning.test.ts | 148 ++++++++++++++++++ 3 files changed, 156 insertions(+), 3 deletions(-) create mode 100644 tests/boundary/filesystem/workflowActionPinning.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 95dbdbed19..3bc5e26af4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -140,6 +140,11 @@ jobs: - name: Validate workflow syntax and expressions if: needs.changes.outputs.workflows == 'true' run: go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.7 -shellcheck= -pyflakes= + # Code tiers run this source-only policy test in their existing shards. + # Workflow-only changes need the guard, without preparing runtime artifacts. + - name: Check action pins for workflow-only changes + if: needs.changes.outputs.workflows == 'true' && needs.changes.outputs.code != 'true' + run: node scripts/run-exact-tests.mjs run tests/boundary/filesystem/workflowActionPinning.test.ts - name: Record static task source and execution identity continue-on-error: true if: needs.changes.outputs.code == 'true' diff --git a/.github/workflows/real-cutter.yml b/.github/workflows/real-cutter.yml index 91121f2073..da76469594 100644 --- a/.github/workflows/real-cutter.yml +++ b/.github/workflows/real-cutter.yml @@ -22,7 +22,7 @@ jobs: sudo apt-get update sudo apt-get install --no-install-recommends -y g++-mingw-w64-x86-64 - run: npm run build:windows-native - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: cutter-windows-native path: native/windows/build/ @@ -53,7 +53,7 @@ jobs: uses: ./.github/actions/npm-cache - run: npm ci - if: matrix.platform == 'Windows' - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: cutter-windows-native path: native/windows/build/ @@ -67,7 +67,7 @@ jobs: env: EXPECTED_PLATFORM: ${{ matrix.platform }} run: npm run verify:cutter - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 if: always() with: name: cutter-upstream-${{ matrix.platform }} diff --git a/tests/boundary/filesystem/workflowActionPinning.test.ts b/tests/boundary/filesystem/workflowActionPinning.test.ts new file mode 100644 index 0000000000..6be3e71a81 --- /dev/null +++ b/tests/boundary/filesystem/workflowActionPinning.test.ts @@ -0,0 +1,148 @@ +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([]); + }); +});