From 248eacedddf06079f4db29cce1c5a454c7d0a119 Mon Sep 17 00:00:00 2001 From: fermatmind <17551046983@163.com> Date: Sun, 12 Jul 2026 23:08:37 +0800 Subject: [PATCH] fix(workflows): verify pinned action references --- .github/workflows/codex-autofix.yml | 6 +- .github/workflows/llms-feed-cache-ops.yml | 2 +- .../personality-agent-auto-runner.yml | 16 +- .../workflows/public-performance-audit.yml | 6 +- scripts/verify-github-action-refs.mjs | 185 ++++++++++++++++++ ...s-feed-cache-ops-workflow.contract.test.ts | 2 +- ...flow-action-ref-integrity.contract.test.ts | 24 +++ 7 files changed, 224 insertions(+), 17 deletions(-) create mode 100644 scripts/verify-github-action-refs.mjs create mode 100644 tests/contracts/workflow-action-ref-integrity.contract.test.ts diff --git a/.github/workflows/codex-autofix.yml b/.github/workflows/codex-autofix.yml index bcbfff0f7..b98a4c773 100644 --- a/.github/workflows/codex-autofix.yml +++ b/.github/workflows/codex-autofix.yml @@ -18,11 +18,7 @@ concurrency: jobs: safe-autofix-boundary: - if: > - ${{ - github.event.workflow_run.conclusion == 'failure' && - github.event.workflow_run.event == 'pull_request' - }} + if: github.event.workflow_run.conclusion == 'failure' && github.event.workflow_run.event == 'pull_request' runs-on: ubuntu-latest timeout-minutes: 5 permissions: {} diff --git a/.github/workflows/llms-feed-cache-ops.yml b/.github/workflows/llms-feed-cache-ops.yml index b49aaa6da..eb3695ca9 100644 --- a/.github/workflows/llms-feed-cache-ops.yml +++ b/.github/workflows/llms-feed-cache-ops.yml @@ -356,7 +356,7 @@ jobs: NODE - name: Upload cache ops summary - uses: actions/upload-artifact@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: llms-feed-cache-ops-summary path: | diff --git a/.github/workflows/personality-agent-auto-runner.yml b/.github/workflows/personality-agent-auto-runner.yml index 597fac1dd..5e496e463 100644 --- a/.github/workflows/personality-agent-auto-runner.yml +++ b/.github/workflows/personality-agent-auto-runner.yml @@ -84,12 +84,14 @@ jobs: mkdir -p "$ARTIFACT_DIR" - echo "run_date=$RUN_DATE" >> "$GITHUB_OUTPUT" - echo "framework=$FRAMEWORK" >> "$GITHUB_OUTPUT" - echo "artifact_dir=$ARTIFACT_DIR" >> "$GITHUB_OUTPUT" - echo "output_json=$ARTIFACT_DIR/personality-agent-auto-runner-${RUN_DATE}.json" >> "$GITHUB_OUTPUT" - echo "output_md=$ARTIFACT_DIR/personality-agent-auto-runner-${RUN_DATE}.md" >> "$GITHUB_OUTPUT" - echo "output_csv=$ARTIFACT_DIR/personality-agent-auto-runner-${RUN_DATE}.csv" >> "$GITHUB_OUTPUT" + { + echo "run_date=$RUN_DATE" + echo "framework=$FRAMEWORK" + echo "artifact_dir=$ARTIFACT_DIR" + echo "output_json=$ARTIFACT_DIR/personality-agent-auto-runner-${RUN_DATE}.json" + echo "output_md=$ARTIFACT_DIR/personality-agent-auto-runner-${RUN_DATE}.md" + echo "output_csv=$ARTIFACT_DIR/personality-agent-auto-runner-${RUN_DATE}.csv" + } >> "$GITHUB_OUTPUT" - name: Run personality agent auto runner shell: bash @@ -122,7 +124,7 @@ jobs: jq empty "${{ steps.paths.outputs.output_json }}" - name: Upload personality agent artifact - uses: actions/upload-artifact@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: personality-agent-auto-runner-${{ steps.paths.outputs.run_date }} path: ${{ steps.paths.outputs.artifact_dir }} diff --git a/.github/workflows/public-performance-audit.yml b/.github/workflows/public-performance-audit.yml index 44c14b88a..b50b17d26 100644 --- a/.github/workflows/public-performance-audit.yml +++ b/.github/workflows/public-performance-audit.yml @@ -23,15 +23,15 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 15 steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: node-version: 24 - name: Run read-only public performance scan run: node scripts/performance/audit-public-performance.mjs ${{ inputs.enforce_budgets && '--enforce' || '' }} - name: Upload audit report if: always() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: public-performance-audit path: generated/performance/public-performance-audit.json diff --git a/scripts/verify-github-action-refs.mjs b/scripts/verify-github-action-refs.mjs new file mode 100644 index 000000000..56ad057af --- /dev/null +++ b/scripts/verify-github-action-refs.mjs @@ -0,0 +1,185 @@ +#!/usr/bin/env node + +import { execFileSync } from "node:child_process"; +import { readdirSync, readFileSync } from "node:fs"; +import path from "node:path"; + +const ROOT = process.cwd(); +const WORKFLOWS_DIR = path.join(ROOT, ".github/workflows"); + +const BLESSED_ACTIONS = { + "actions/checkout": { + repo: "actions/checkout", + sha: "df4cb1c069e1874edd31b4311f1884172cec0e10", + tag: "v6", + }, + "actions/setup-node": { + repo: "actions/setup-node", + sha: "48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e", + tag: "v6", + }, + "actions/upload-artifact": { + repo: "actions/upload-artifact", + sha: "ea165f8d65b6e75b540449e92b4886f43607fa02", + tag: "v4", + }, + "actions/github-script": { + repo: "actions/github-script", + sha: "ed597411d8f924073f98dfc5c65a23a2325f34cd", + tag: "v8", + }, + "github/codeql-action/init": { + repo: "github/codeql-action", + sha: "1ad29ea4a422cce9a242a9fae469541dcd08addc", + tag: "v4", + }, + "github/codeql-action/analyze": { + repo: "github/codeql-action", + sha: "1ad29ea4a422cce9a242a9fae469541dcd08addc", + tag: "v4", + }, + "webfactory/ssh-agent": { + repo: "webfactory/ssh-agent", + sha: "e83874834305fe9a4a2997156cb26c5de65a8555", + tag: "v0.10.0", + }, +}; + +const USES_PATTERN = /^\s*(?:-\s*)?uses:\s+([^#\s]+)(?:\s+#\s*(\S+).*)?$/; +const SHA_REF_PATTERN = /^[0-9a-f]{40}$/; + +function parseActionRef(ref) { + const atIndex = ref.lastIndexOf("@"); + if (atIndex === -1) { + return null; + } + + const action = ref.slice(0, atIndex); + const version = ref.slice(atIndex + 1); + const parts = action.split("/"); + + if (parts.length < 2 || action.startsWith("./") || action.startsWith("../")) { + return null; + } + + return { action, version }; +} + +function workflowFiles() { + return readdirSync(WORKFLOWS_DIR) + .filter((file) => file.endsWith(".yml") || file.endsWith(".yaml")) + .sort() + .map((file) => path.join(WORKFLOWS_DIR, file)); +} + +function extractUses() { + const uses = []; + + for (const filePath of workflowFiles()) { + const relPath = path.relative(ROOT, filePath); + const lines = readFileSync(filePath, "utf8").split("\n"); + + lines.forEach((line, index) => { + const match = line.match(USES_PATTERN); + if (!match) { + return; + } + + const parsed = parseActionRef(match[1]); + if (!parsed) { + return; + } + + uses.push({ + ...parsed, + comment: match[2] || "", + relPath, + line: index + 1, + raw: line.trim(), + }); + }); + } + + return uses; +} + +function verifyStaticPolicy() { + const violations = []; + + for (const item of extractUses()) { + const blessed = BLESSED_ACTIONS[item.action]; + const location = `${item.relPath}:${item.line}`; + + if (!blessed) { + violations.push(`${location}: action is not in blessed action lock: ${item.action}`); + continue; + } + + if (!SHA_REF_PATTERN.test(item.version)) { + violations.push(`${location}: action must use a 40-character lowercase SHA: ${item.raw}`); + continue; + } + + if (item.version !== blessed.sha) { + violations.push(`${location}: action SHA does not match blessed lock for ${item.action}`); + } + + if (item.comment && blessed.tag && item.comment !== blessed.tag) { + violations.push(`${location}: action comment ${item.comment} does not match blessed tag ${blessed.tag}`); + } + } + + return violations; +} + +function verifyRemoteRefs() { + const violations = []; + const checked = new Set(); + + for (const [action, blessed] of Object.entries(BLESSED_ACTIONS)) { + const key = `${blessed.repo}@${blessed.tag || blessed.sha}`; + if (checked.has(key)) { + continue; + } + checked.add(key); + + const repoUrl = `https://github.com/${blessed.repo}.git`; + const ref = blessed.tag ? `refs/tags/${blessed.tag}` : blessed.sha; + + let output = ""; + try { + output = execFileSync("git", ["ls-remote", repoUrl, ref], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + timeout: 20000, + }); + } catch (error) { + violations.push(`${action}: failed to resolve ${repoUrl} ${ref}: ${error.message}`); + continue; + } + + const resolvedSha = output.trim().split(/\s+/)[0] || ""; + if (resolvedSha !== blessed.sha) { + violations.push(`${action}: ${ref} resolves to ${resolvedSha || ""} instead of ${blessed.sha}`); + } + } + + return violations; +} + +const shouldResolve = process.argv.includes("--resolve"); +const violations = [...verifyStaticPolicy(), ...(shouldResolve ? verifyRemoteRefs() : [])]; + +if (violations.length > 0) { + console.error("GitHub workflow action reference integrity check failed:"); + for (const violation of violations) { + console.error(`- ${violation}`); + } + process.exit(1); +} + +console.log( + `GitHub workflow action reference integrity check passed (${extractUses().length} workflow uses, ${ + Object.keys(BLESSED_ACTIONS).length + } blessed actions${shouldResolve ? ", remote refs resolved" : ""}).` +); diff --git a/tests/contracts/llms-feed-cache-ops-workflow.contract.test.ts b/tests/contracts/llms-feed-cache-ops-workflow.contract.test.ts index be4003461..58c61165e 100644 --- a/tests/contracts/llms-feed-cache-ops-workflow.contract.test.ts +++ b/tests/contracts/llms-feed-cache-ops-workflow.contract.test.ts @@ -104,7 +104,7 @@ describe("llms feed cache-only ops workflow", () => { }); it("uploads JSON and Markdown audit summaries", () => { - expect(workflow).toContain("actions/upload-artifact@99df26d4f13ea111d4ec1a7dddef6063f76b97e9"); + expect(workflow).toContain("actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4"); expect(workflow).toContain("llms-feed-cache-ops-summary.json"); expect(workflow).toContain("llms-feed-cache-ops-summary.md"); expect(workflow).toContain("if-no-files-found: error"); diff --git a/tests/contracts/workflow-action-ref-integrity.contract.test.ts b/tests/contracts/workflow-action-ref-integrity.contract.test.ts new file mode 100644 index 000000000..2d22fe982 --- /dev/null +++ b/tests/contracts/workflow-action-ref-integrity.contract.test.ts @@ -0,0 +1,24 @@ +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +describe("workflow action reference integrity", () => { + it("keeps every workflow action on the blessed lock and resolves pinned refs", () => { + const output = execFileSync(process.execPath, ["scripts/verify-github-action-refs.mjs", "--resolve"], { + encoding: "utf8", + timeout: 90000, + }); + + expect(output).toContain("remote refs resolved"); + }); + + it("locks artifact upload to the current resolvable v4 SHA instead of the invalid historical SHA", () => { + const workflow = readFileSync(".github/workflows/llms-feed-cache-ops.yml", "utf8"); + const runnerWorkflow = readFileSync(".github/workflows/personality-agent-auto-runner.yml", "utf8"); + + expect(workflow).toContain("actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4"); + expect(runnerWorkflow).toContain("actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4"); + expect(workflow).not.toContain("actions/upload-artifact@99df26d4f13ea111d4ec1a7dddef6063f76b97e9"); + expect(runnerWorkflow).not.toContain("actions/upload-artifact@99df26d4f13ea111d4ec1a7dddef6063f76b97e9"); + }); +});