Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 1 addition & 5 deletions .github/workflows/codex-autofix.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: {}
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/llms-feed-cache-ops.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down
16 changes: 9 additions & 7 deletions .github/workflows/personality-agent-auto-runner.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 }}
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/public-performance-audit.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
185 changes: 185 additions & 0 deletions scripts/verify-github-action-refs.mjs
Original file line number Diff line number Diff line change
@@ -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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Resolve pinned SHAs instead of moving major tags

Because the CI contracts job runs pnpm test:contract, this --resolve path becomes a required check for every PR, but it validates mutable major tags (refs/tags/v4, v6, etc.) against the blessed SHA. When an upstream action advances a major tag to a new patch release, this will fail CI even though every workflow is still pinned to a valid immutable commit; the guard for the previous invalid-pin outage should instead verify the pinned SHA is reachable or lock exact release refs.

Useful? React with 👍 / 👎.


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 || "<missing>"} 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" : ""}).`
);
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
24 changes: 24 additions & 0 deletions tests/contracts/workflow-action-ref-integrity.contract.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
Loading