From bf2a196713bbe71871f86bed5c3f429d3ad38324 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 22:09:52 +0000 Subject: [PATCH 1/2] fix(ci): the pinning check accepts refs that actions.lock resolves "Check SHA-Pinned Actions" required an inline 40-hex SHA on every `uses:` line. This repository pins with tag refs that actions.lock resolves, so the step rejected all of them and Workflow Security Linter was red on main whatever else it found. The step now runs scripts/check-action-pinning.js, copied unchanged from rsr-template-repo (ec33724). A ref passes if it is an inline SHA or if the lock resolves it. The workflow gains the template's "Set up Bun" step, and actions.lock gains the oven-sh/setup-bun@v2.2.0 record with the template's commit, owner and repo ids. One change from the template: the example in the error text labels the checkout SHA v7.0.1, which is what this repository's lock records; the template says v6.0.1. Checked: the script passes on main's workflows (15 refs resolve through the lock) and fails on a planted unlocked ref; standards' validate-actions-lock.sh passes on the new lock. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Y6FD63UrriRBHqmhhEca9t --- .github/workflows/actions.lock | 6 ++ .github/workflows/workflow-linter.yml | 26 +++++-- scripts/check-action-pinning.js | 105 ++++++++++++++++++++++++++ 3 files changed, 132 insertions(+), 5 deletions(-) create mode 100644 scripts/check-action-pinning.js diff --git a/.github/workflows/actions.lock b/.github/workflows/actions.lock index 60c630b..946fbf2 100644 --- a/.github/workflows/actions.lock +++ b/.github/workflows/actions.lock @@ -50,6 +50,7 @@ workflows: - 'actions/checkout@v7.0.1' '.github/workflows/workflow-linter.yml': - 'actions/checkout@v7.0.1' + - 'oven-sh/setup-bun@v2.2.0' dependencies: 'actions/checkout@v7.0.1': ref: 'v7.0.1' @@ -108,6 +109,11 @@ dependencies: commit: 'sha1-fa7117db83c52d787ae5a89f350999701505d78e' owner_id: 67707773 repo_id: 421101922 + 'oven-sh/setup-bun@v2.2.0': + ref: 'v2.2.0' + commit: 'sha1-0c5077e51419868618aeaa5fe8019c62421857d6' + owner_id: 108928776 + repo_id: 512644635 'peter-evans/repository-dispatch@v4.0.1': ref: 'v4.0.1' commit: 'sha1-28959ce8df70de7be546dd1250a005dd32156697' diff --git a/.github/workflows/workflow-linter.yml b/.github/workflows/workflow-linter.yml index b0e79ec..4d62c18 100644 --- a/.github/workflows/workflow-linter.yml +++ b/.github/workflows/workflow-linter.yml @@ -60,21 +60,37 @@ jobs: fi echo "All workflows have permissions declared" + # Bun is the estate's first-choice runtime (LANGUAGE-POLICY.adoc §1: + # Bun > Deno > pnpm > npm). Tag form + # matches every other ref in this repo and is resolved by actions.lock. + - name: Set up Bun + uses: oven-sh/setup-bun@v2.2.0 + with: + bun-version: latest + - name: Check SHA-Pinned Actions run: | echo "=== Checking Action Pinning ===" # Find any uses: lines that don't have @SHA format # Pattern: uses: owner/repo@<40-char-hex> - unpinned=$(grep -rnE "^[[:space:]]+uses:" .github/workflows/ | \ - grep -v "@[a-f0-9]\{40\}" | \ - grep -v "uses: \./\|uses: docker://\|uses: actions/github-script" || true) + # Delegated to scripts/check-action-pinning.js. The rule it enforces is + # unchanged in spirit — every action ref must resolve to an immutable + # commit — but "pinned" now includes refs the workflow lockfile + # resolves, which is how this repo pins them. Kept as a script rather + # than inline because the inline form needs a heredoc inside a YAML + # block scalar, and that is a well-known way to ship a gate that + # silently does nothing. + if ! bun scripts/check-action-pinning.js; then + unpinned="see above" + else + unpinned="" + fi if [ -n "$unpinned" ]; then echo "ERROR: Found unpinned actions:" - echo "$unpinned" echo "" echo "Replace version tags with SHA pins, e.g.:" - echo " uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4.1.1" + echo " uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1" exit 1 fi echo "All actions are SHA-pinned" diff --git a/scripts/check-action-pinning.js b/scripts/check-action-pinning.js new file mode 100644 index 0000000..80533f6 --- /dev/null +++ b/scripts/check-action-pinning.js @@ -0,0 +1,105 @@ +#!/usr/bin/env bun +// SPDX-License-Identifier: MPL-2.0 +// +// Assert every workflow action ref is pinned — inline SHA, or via actions.lock. +// +// What counts as "pinned" changed when GitHub introduced workflow lockfiles. A +// ref is pinned if EITHER it is an inline 40-hex SHA, OR +// .github/workflows/actions.lock resolves it to one. The previous inline-only +// rule rejected all 40 of this repo's own refs, so `Workflow Security Linter` +// was red on main permanently — and every repo minted from this template +// inherited both the tag-form workflows and the gate that rejects them. +// +// Runtime: Bun — the estate's first-choice runtime per LANGUAGE-POLICY.adoc §1 +// (Bun > Deno > pnpm > npm). Plain JavaScript, not TypeScript: TypeScript is +// banned estate-wide (owner ruling; CLAUDE.md banned-languages table). Note +// LANGUAGE-POLICY.adoc §1.2 currently claims TS is "permitted under Bun" — that +// line is wrong and should be corrected; the ban stands. +// +// Python was the first draft of this script and is banned with no exceptions. +// +// Three details are load-bearing, each of them a defect found by testing: +// +// `-?` in the pattern. `- uses:` is the list-item form and by far the most +// common; a `\s+uses:` pattern silently misses every one of them. Estate +// memory records this exact failure — "green linter != full SHA-pinning". A +// gate that cannot see the common case reads as coverage and enforces nothing. +// +// Case-insensitive comparison. Workflows write `SonarSource/...`; the lockfile +// records `sonarsource/...`. Action refs are case-insensitive in practice, so +// a case-sensitive match reports a false positive on a correctly-pinned action. +// +// Only *.yml / *.yaml, depth 1. A bare recursive grep also reads actions.lock +// (whose `uses:` keys are lockfile entries, not refs) and *.yml.template +// (whose tag ref is deliberate and resolved at mint time). Both were reported +// as unpinned, which made the gate unsatisfiable the moment a lockfile existed. +// +// Exit 0 if every ref is pinned, 1 otherwise, listing file:line: ref. + +import { readdirSync, readFileSync, existsSync, statSync } from "node:fs"; +import { join } from "node:path"; + +const WF = ".github/workflows"; +const LOCK = join(WF, "actions.lock"); +// `-?` matches the list-item form; see the header. +const USES = /^\s*-?\s*uses:\s*([^\s#]+)/; +const SHA = /@[a-f0-9]{40}$/; +const EXEMPT_PREFIX = ["./", "$", "docker://"]; + +function workflowFiles() { + if (!existsSync(WF) || !statSync(WF).isDirectory()) return []; + return readdirSync(WF) + .filter((f) => f.endsWith(".yml") || f.endsWith(".yaml")) + .sort() + .map((f) => join(WF, f)); +} + +function main() { + if (!existsSync(WF)) { + console.log("no .github/workflows — nothing to check"); + return 0; + } + + let known = new Set(); + if (existsSync(LOCK)) { + const lock = readFileSync(LOCK, "utf8"); + known = new Set( + [...lock.matchAll(/'([^']+@[^']+)'/g)].map((m) => m[1].toLowerCase()), + ); + console.log(`lockfile present: ${known.size} ref(s) resolvable through it`); + } else { + console.log("no lockfile — every ref must be an inline 40-character SHA"); + } + + const bad = []; + for (const wf of workflowFiles()) { + const lines = readFileSync(wf, "utf8").split("\n"); + lines.forEach((line, i) => { + const m = USES.exec(line); + if (!m) return; + const ref = m[1]; + if (EXEMPT_PREFIX.some((p) => ref.startsWith(p))) return; + if (ref.includes("actions/github-script")) return; + if (SHA.test(ref)) return; + const at = ref.lastIndexOf("@"); + const name = at === -1 ? ref : ref.slice(0, at); + const tag = at === -1 ? "" : ref.slice(at + 1); + // subpath actions key by repo root: github/codeql-action/init -> github/codeql-action + const root = name.split("/").slice(0, 2).join("/"); + if (known.has(ref.toLowerCase()) || known.has(`${root}@${tag}`.toLowerCase())) return; + bad.push(`${wf}:${i + 1}: ${ref}`); + }); + } + + if (bad.length) { + console.log("\nERROR: these action refs are neither SHA-pinned nor covered by the lockfile:"); + for (const b of bad) console.log(` ${b}`); + console.log("\nEither pin to a full commit SHA, or run `gh actions-lock` so the"); + console.log("lockfile resolves the ref."); + return 1; + } + console.log("all action refs are pinned"); + return 0; +} + +process.exit(main()); From 9d13eba473f0584057534591dfa49dfc8fcc5139 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 22:55:21 +0000 Subject: [PATCH 2/2] ci(workflow-linter): install Bun 1.4.2 verified, not via setup-bun Port of rsr-template-repo#249. SonarCloud failed this PR's quality gate ("C Security Rating") on oven-sh/setup-bun@v2.2.0, a third-party action pinned by tag, and bun-version: latest left the runtime unpinned. The linter now downloads the Bun 1.4.2 release archive and checks its sha256. The setup-bun lock edge and record this PR added go with it, so actions.lock is unchanged from main. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Y6FD63UrriRBHqmhhEca9t --- .github/workflows/actions.lock | 6 ------ .github/workflows/workflow-linter.yml | 17 +++++++++++------ 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/.github/workflows/actions.lock b/.github/workflows/actions.lock index 946fbf2..60c630b 100644 --- a/.github/workflows/actions.lock +++ b/.github/workflows/actions.lock @@ -50,7 +50,6 @@ workflows: - 'actions/checkout@v7.0.1' '.github/workflows/workflow-linter.yml': - 'actions/checkout@v7.0.1' - - 'oven-sh/setup-bun@v2.2.0' dependencies: 'actions/checkout@v7.0.1': ref: 'v7.0.1' @@ -109,11 +108,6 @@ dependencies: commit: 'sha1-fa7117db83c52d787ae5a89f350999701505d78e' owner_id: 67707773 repo_id: 421101922 - 'oven-sh/setup-bun@v2.2.0': - ref: 'v2.2.0' - commit: 'sha1-0c5077e51419868618aeaa5fe8019c62421857d6' - owner_id: 108928776 - repo_id: 512644635 'peter-evans/repository-dispatch@v4.0.1': ref: 'v4.0.1' commit: 'sha1-28959ce8df70de7be546dd1250a005dd32156697' diff --git a/.github/workflows/workflow-linter.yml b/.github/workflows/workflow-linter.yml index 4d62c18..ff29be5 100644 --- a/.github/workflows/workflow-linter.yml +++ b/.github/workflows/workflow-linter.yml @@ -61,12 +61,17 @@ jobs: echo "All workflows have permissions declared" # Bun is the estate's first-choice runtime (LANGUAGE-POLICY.adoc §1: - # Bun > Deno > pnpm > npm). Tag form - # matches every other ref in this repo and is resolved by actions.lock. - - name: Set up Bun - uses: oven-sh/setup-bun@v2.2.0 - with: - bun-version: latest + # Bun > Deno > pnpm > npm). It is the estate pin 1.4.2, downloaded and + # sha256-checked as rsr-template-repo#249 installs it, so this gate runs + # no third-party action and no unpinned runtime. + - name: Install verified Bun 1.4.2 + run: | + curl --proto '=https' --proto-redir '=https' --fail --silent --show-error --location \ + https://github.com/oven-sh/bun/releases/download/bun-v1.4.2/bun-linux-x64.zip \ + --output "$RUNNER_TEMP/bun.zip" + echo "36368faef7527875d5ffa52e53cd48021741f2a83eb6208a8dd64068d422a913 $RUNNER_TEMP/bun.zip" | sha256sum --check --strict + unzip -q "$RUNNER_TEMP/bun.zip" -d "$RUNNER_TEMP" + echo "$RUNNER_TEMP/bun-linux-x64" >> "$GITHUB_PATH" - name: Check SHA-Pinned Actions run: |