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>
This commit is contained in:
3 files changed
+156
-3
No files matched your search
@@ -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'
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user