chore: import upstream snapshot with attribution
Deploy (to testing) and Test Playground Preview Worker / Deploy Playground Preview Worker (testing) (push) Has been skipped
Deploy Workers Shared Staging / Deploy Workers Shared Staging (push) Failing after 0s
Prerelease / build (push) Has been skipped
Handle Changesets / Handle Changesets (push) Has been cancelled
Semgrep OSS scan / semgrep-oss (push) Has been cancelled
Deploy (to testing) and Test Playground Preview Worker / Deploy Playground Preview Worker (testing) (push) Has been skipped
Deploy Workers Shared Staging / Deploy Workers Shared Staging (push) Failing after 0s
Prerelease / build (push) Has been skipped
Handle Changesets / Handle Changesets (push) Has been cancelled
Semgrep OSS scan / semgrep-oss (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,246 @@
|
||||
import {
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
realpathSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import dedent from "ts-dedent";
|
||||
import { afterEach, beforeEach, describe, it } from "vitest";
|
||||
import { validateActionPinning } from "../validate-action-pinning";
|
||||
|
||||
describe("validateActionPinning()", () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = realpathSync(mkdtempSync(join(tmpdir(), "action-pinning-")));
|
||||
mkdirSync(join(tmpDir, ".github", "workflows"), { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// eslint-disable-next-line workers-sdk/no-direct-recursive-rm -- test cleanup
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function writeWorkflow(filename: string, content: string) {
|
||||
writeFileSync(
|
||||
join(tmpDir, ".github", "workflows", filename),
|
||||
content,
|
||||
"utf-8"
|
||||
);
|
||||
}
|
||||
|
||||
function writeCompositeAction(actionDir: string, content: string) {
|
||||
const dir = join(tmpDir, ".github", "actions", actionDir);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(join(dir, "action.yml"), content, "utf-8");
|
||||
}
|
||||
|
||||
it("should pass for pinned third-party actions", ({ expect }) => {
|
||||
writeWorkflow(
|
||||
"test.yml",
|
||||
dedent`
|
||||
jobs:
|
||||
build:
|
||||
steps:
|
||||
- uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36
|
||||
`
|
||||
);
|
||||
expect(validateActionPinning(tmpDir)).toEqual([]);
|
||||
});
|
||||
|
||||
it("should pass for pinned actions with a version comment", ({ expect }) => {
|
||||
writeWorkflow(
|
||||
"test.yml",
|
||||
dedent`
|
||||
jobs:
|
||||
build:
|
||||
steps:
|
||||
- uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3
|
||||
`
|
||||
);
|
||||
expect(validateActionPinning(tmpDir)).toEqual([]);
|
||||
});
|
||||
|
||||
it("should fail for third-party actions pinned to a tag", ({ expect }) => {
|
||||
writeWorkflow(
|
||||
"test.yml",
|
||||
dedent`
|
||||
jobs:
|
||||
build:
|
||||
steps:
|
||||
- uses: dorny/paths-filter@v3
|
||||
`
|
||||
);
|
||||
const errors = validateActionPinning(tmpDir);
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0]).toContain("dorny/paths-filter@v3");
|
||||
expect(errors[0]).toContain("not pinned to a commit SHA");
|
||||
});
|
||||
|
||||
it("should fail for third-party actions pinned to a semver tag", ({
|
||||
expect,
|
||||
}) => {
|
||||
writeWorkflow(
|
||||
"test.yml",
|
||||
dedent`
|
||||
jobs:
|
||||
build:
|
||||
steps:
|
||||
- uses: Ana06/get-changed-files@v2.3.0
|
||||
`
|
||||
);
|
||||
const errors = validateActionPinning(tmpDir);
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0]).toContain("Ana06/get-changed-files@v2.3.0");
|
||||
});
|
||||
|
||||
it("should fail for third-party actions pinned to a branch", ({ expect }) => {
|
||||
writeWorkflow(
|
||||
"test.yml",
|
||||
dedent`
|
||||
jobs:
|
||||
build:
|
||||
steps:
|
||||
- uses: changesets/action@main
|
||||
`
|
||||
);
|
||||
const errors = validateActionPinning(tmpDir);
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0]).toContain("changesets/action@main");
|
||||
});
|
||||
|
||||
it("should skip first-party actions/* regardless of pinning", ({
|
||||
expect,
|
||||
}) => {
|
||||
writeWorkflow(
|
||||
"test.yml",
|
||||
dedent`
|
||||
jobs:
|
||||
build:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/upload-artifact@v4
|
||||
`
|
||||
);
|
||||
expect(validateActionPinning(tmpDir)).toEqual([]);
|
||||
});
|
||||
|
||||
it("should skip local actions", ({ expect }) => {
|
||||
writeWorkflow(
|
||||
"test.yml",
|
||||
dedent`
|
||||
jobs:
|
||||
build:
|
||||
steps:
|
||||
- uses: ./.github/actions/install-dependencies
|
||||
`
|
||||
);
|
||||
expect(validateActionPinning(tmpDir)).toEqual([]);
|
||||
});
|
||||
|
||||
it("should report multiple errors across multiple files", ({ expect }) => {
|
||||
writeWorkflow(
|
||||
"a.yml",
|
||||
dedent`
|
||||
jobs:
|
||||
build:
|
||||
steps:
|
||||
- uses: foo/bar@v1
|
||||
`
|
||||
);
|
||||
writeWorkflow(
|
||||
"b.yml",
|
||||
dedent`
|
||||
jobs:
|
||||
build:
|
||||
steps:
|
||||
- uses: baz/qux@v2
|
||||
- uses: quux/corge@latest
|
||||
`
|
||||
);
|
||||
const errors = validateActionPinning(tmpDir);
|
||||
expect(errors).toHaveLength(3);
|
||||
const joined = errors.join("\n");
|
||||
expect(joined).toContain("foo/bar@v1");
|
||||
expect(joined).toContain("baz/qux@v2");
|
||||
expect(joined).toContain("quux/corge@latest");
|
||||
});
|
||||
|
||||
it("should validate composite actions in .github/actions/", ({ expect }) => {
|
||||
writeCompositeAction(
|
||||
"my-action",
|
||||
dedent`
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- uses: unpinned/action@v1
|
||||
`
|
||||
);
|
||||
const errors = validateActionPinning(tmpDir);
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0]).toContain("unpinned/action@v1");
|
||||
expect(errors[0]).toContain(".github/actions/my-action/action.yml");
|
||||
});
|
||||
|
||||
it("should include the correct line number in errors", ({ expect }) => {
|
||||
writeWorkflow(
|
||||
"test.yml",
|
||||
dedent`
|
||||
name: Test
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: unpinned/action@v3
|
||||
`
|
||||
);
|
||||
const errors = validateActionPinning(tmpDir);
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0]).toContain(":8");
|
||||
});
|
||||
|
||||
it("should handle uses without a hyphen prefix", ({ expect }) => {
|
||||
writeWorkflow(
|
||||
"test.yml",
|
||||
dedent`
|
||||
jobs:
|
||||
build:
|
||||
steps:
|
||||
- name: Do something
|
||||
uses: unpinned/action@v1
|
||||
`
|
||||
);
|
||||
const errors = validateActionPinning(tmpDir);
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0]).toContain("unpinned/action@v1");
|
||||
});
|
||||
|
||||
it("should fail for third-party actions with no version reference at all", ({
|
||||
expect,
|
||||
}) => {
|
||||
writeWorkflow(
|
||||
"test.yml",
|
||||
dedent`
|
||||
jobs:
|
||||
build:
|
||||
steps:
|
||||
- uses: some-org/some-action
|
||||
`
|
||||
);
|
||||
const errors = validateActionPinning(tmpDir);
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0]).toContain("some-org/some-action");
|
||||
expect(errors[0]).toContain("has no version reference at all");
|
||||
});
|
||||
|
||||
it("should pass when there are no workflow files", ({ expect }) => {
|
||||
// tmpDir has the .github/workflows/ directory but no files
|
||||
expect(validateActionPinning(tmpDir)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { globSync } from "tinyglobby";
|
||||
|
||||
/**
|
||||
* Matches `uses:` directives in GitHub Actions workflow YAML files.
|
||||
* Captures the action reference (everything after `uses:`), trimmed.
|
||||
*
|
||||
* Examples that match:
|
||||
* - uses: actions/checkout@v4
|
||||
* uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3
|
||||
*/
|
||||
const USES_RE = /^\s*-?\s*uses:\s*(.+)$/;
|
||||
|
||||
/**
|
||||
* A valid SHA pin is exactly 40 lowercase hex characters.
|
||||
*/
|
||||
const SHA_RE = /^[0-9a-f]{40}$/;
|
||||
|
||||
/**
|
||||
* First-party GitHub action prefixes that are trusted and do not
|
||||
* need to be pinned to a commit SHA.
|
||||
*/
|
||||
const TRUSTED_PREFIXES = ["actions/"];
|
||||
|
||||
/**
|
||||
* Checks that all third-party GitHub Actions in workflow and composite
|
||||
* action files are pinned to a full commit SHA rather than a mutable
|
||||
* tag or branch reference.
|
||||
*
|
||||
* @param repoRoot - Absolute path to the repository root directory.
|
||||
* @returns An array of human-readable error strings (empty = all good).
|
||||
*/
|
||||
export function validateActionPinning(repoRoot: string): string[] {
|
||||
const patterns = [
|
||||
".github/workflows/*.yml",
|
||||
".github/workflows/*.yaml",
|
||||
".github/actions/*/action.yml",
|
||||
".github/actions/*/action.yaml",
|
||||
];
|
||||
|
||||
const files = patterns.flatMap((pattern) =>
|
||||
globSync(pattern, { cwd: repoRoot })
|
||||
);
|
||||
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const relPath of files) {
|
||||
const absPath = resolve(repoRoot, relPath);
|
||||
const content = readFileSync(absPath, "utf-8");
|
||||
const lines = content.split("\n");
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const match = lines[i].match(USES_RE);
|
||||
if (!match) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const raw = match[1].trim();
|
||||
|
||||
// Local actions (e.g. ./.github/actions/foo) — skip
|
||||
if (raw.startsWith("./") || raw.startsWith("../")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Trusted first-party actions — skip
|
||||
if (TRUSTED_PREFIXES.some((prefix) => raw.startsWith(prefix))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// At this point it's a third-party action.
|
||||
// Expected format: owner/repo@<ref> (possibly with a trailing comment)
|
||||
const atIndex = raw.indexOf("@");
|
||||
if (atIndex === -1) {
|
||||
errors.push(
|
||||
`${relPath}:${i + 1} — ${raw.split(/\s/)[0]} has no version reference at all`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// The ref is everything after @ up to the first space (comments follow a space)
|
||||
const afterAt = raw.slice(atIndex + 1).split(/\s/)[0];
|
||||
|
||||
if (!SHA_RE.test(afterAt)) {
|
||||
errors.push(
|
||||
`${relPath}:${i + 1} — ${raw.split(/\s/)[0]} is not pinned to a commit SHA`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
console.log("::group::Checking GitHub Action pinning");
|
||||
const repoRoot = resolve(__dirname, "../..");
|
||||
const errors = validateActionPinning(repoRoot);
|
||||
if (errors.length > 0) {
|
||||
console.error(
|
||||
"::error::Action pinning checks:" + errors.map((e) => `\n- ${e}`).join("")
|
||||
);
|
||||
}
|
||||
console.log("::endgroup::");
|
||||
process.exit(errors.length > 0 ? 1 : 0);
|
||||
}
|
||||
Reference in New Issue
Block a user