From 0d50cbe52d01a00eea404ea07dff31e6e8e789c6 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Fri, 9 Oct 2026 13:00:53 +0100 Subject: [PATCH] fix(actions-lock-gate): read uses: with yq so KYAML is not RED The lockless branch of check-actions-lock-gate.sh found `uses:` with an anchored line grep. A KYAML step value is quoted and ends in a comma (`uses: "a/b@",`), so it failed the `@([[:space:]]|$)` test and a quoted `"./local"` missed its exemption: a fully pinned KYAML workflow went RED (jaffascript#72, 2026-10-09). The grep also read a `run:` body line that starts with `uses:` as a step ref. The gate now reads each top-level *.yml/*.yaml with yq (YAML-POLICY Y-1): every string `uses:` value, step and job level, block and flow style alike. The four exemptions and the 40-hex pin test are unchanged. Fail-closed cases: - a file yq cannot parse, or one with no `jobs:` map (an unclosed quote can swallow a file and still parse), is UNEXAMINED: exit 1, never 0 or 3. The old gate returned 3 (ledgerable debt) for an unparseable file. - yq missing, or a yq that cannot read the ref from a known block and KYAML input, is exit 2. mikefarah yq v4 is already required by R5. Tests: 12 new cases. Run against origin/main's gate, 8 of them fail (KYAML pinned x2, KYAML mutant, run: decoy, unparseable, quote-swallow, yq missing, wrong yq); against this gate all 23 pass. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Wo32J8Ym7XpPr9EYdBCgVB --- scripts/check-actions-lock-gate.sh | 95 +++++++++++++++++-- scripts/tests/check-actions-lock-gate-test.sh | 40 ++++++++ 2 files changed, 128 insertions(+), 7 deletions(-) diff --git a/scripts/check-actions-lock-gate.sh b/scripts/check-actions-lock-gate.sh index 6f799f2e3..64240b38b 100755 --- a/scripts/check-actions-lock-gate.sh +++ b/scripts/check-actions-lock-gate.sh @@ -10,7 +10,8 @@ # --verify-local` via scripts/update-actions-lock.sh) and # propagate its exit status. A corrupted lock goes RED. # lockfile absent, → RED: an unpinned `uses:` is a violation today, lock or -# unpinned refs no lock. +# unpinned refs no lock. A workflow the gate cannot read is RED too +# (UNEXAMINED), never counted as pinned. # lockfile absent, → grace window: `::warning` + "NOT YET ENFORCED" and exit # all SHA-pinned 0 until ENFORCE_ACTIONS_LOCK_FROM; `::error` + exit 3 # from that date. The sweep (spec §10 step 5) lands the @@ -19,17 +20,31 @@ # # Exit contract (consumed by governance-reusable.yml's ledger exemption): # 0 = pass (verified lock, or lockless+pinned inside the grace window) -# 1 = LIVE VIOLATION (unpinned refs, or verifier-rejected lock) — never exempt -# 2 = infrastructure failure (no workflows dir, no verifier) — never exempt +# 1 = LIVE VIOLATION (unpinned refs, an UNEXAMINED workflow, or a +# verifier-rejected lock) — never exempt +# 2 = infrastructure failure (no workflows dir, no verifier, no usable yq) +# — never exempt # 3 = missing-lock debt ONLY (lockless, every ref pinned, grace window # closed) — the single state the shrink-only ledger may excuse. # Collapsing 3 into 1 would let the ledger wave unpinned refs and corrupt # locks through with the debt it was built to excuse. # +# Reading `uses:` (lockless branch): the YAML parser, never a line grep +# (YAML-POLICY Y-1). The anchored grep this replaces could not read a KYAML +# value: `uses: "a/b@",` failed its `@([[:space:]]|$)` test and a +# quoted `"./local"` missed its exemption, so a fully pinned KYAML workflow went +# RED (jaffascript#72, 2026-10-09). It also matched `uses:` text inside a `run:` +# body. Only top-level `*.yml`/`*.yaml` files are read: those are the files +# GitHub runs, and the verifier snapshots the same set. Requires mikefarah yq +# v4, which ships on GitHub-hosted ubuntu runners and which the R5 job of +# governance-reusable.yml already requires. A yq that cannot run the extraction +# on a known input is exit 2, so a wrong yq never reads as "every file broken". +# # Test seams (used by scripts/tests/check-actions-lock-gate-test.sh): # LOCK_TODAY override today's date (YYYY-MM-DD) # ENFORCE_ACTIONS_LOCK_FROM override the cutoff (default 2026-10-01) # ACTIONS_LOCK_VERIFIER path to update-actions-lock.sh (default: sibling) +# YQ_BIN yq to run (default: yq on PATH) # # Usage: check-actions-lock-gate.sh [WORKFLOWS_DIR] (default .github/workflows) set -uo pipefail @@ -63,14 +78,80 @@ if [ -f "$WF_DIR/actions.lock" ]; then fi # No lockfile. Unpinned refs are a violation regardless of the grace window. -unpinned=$(grep -rnE --include='*.yml' --include='*.yaml' "^[[:space:]]+-?[[:space:]]*uses:" "$WF_DIR" \ - | grep -vE "@[a-f0-9]{40}([[:space:]]|$)" \ - | grep -vE "uses:[[:space:]]+(\./|docker://|actions/github-script|hyperpolymath/standards/)" || true) +YQ_BIN="${YQ_BIN:-yq}" +USES_EXPR='.. | select(tag == "!!map") | select(has("uses")) | .uses | select(tag == "!!str")' + +# Prints every string `uses:` value in the workflow file $1, one per line, at +# step and job level alike, in block YAML and KYAML alike. Comments and `run:` +# bodies are never read. Exits non-zero when the file does not parse. +uses_refs() { + "$YQ_BIN" -r "$USES_EXPR" "$1" +} + +# Succeeds when $1 needs no inline SHA: a local action, a container image, or +# one of the two exemptions the grep this replaced carried +# (actions/github-script, and standards' own reusables). +exempt_ref() { + case "$1" in + ./* | docker://* | actions/github-script* | hyperpolymath/standards/*) return 0 ;; + *) return 1 ;; + esac +} + +if ! command -v "$YQ_BIN" >/dev/null 2>&1; then + echo "::error::actions-lock gate: yq not found ($YQ_BIN). It is required to read workflows (YAML-POLICY Y-1)." + exit 2 +fi +# Positive control: the extraction must find the one ref in a known input, in +# both spellings, before any verdict below is believed. +scratch="$(mktemp)" +trap 'rm -f "$scratch"' EXIT +printf 'jobs:\n a:\n steps:\n - uses: o/r@v1\n' > "$scratch" +probe_block="$(uses_refs "$scratch" 2>&1)" +printf '{ jobs: { a: { steps: [ { uses: "o/r@v1", }, ], }, }, }\n' > "$scratch" +probe_kyaml="$(uses_refs "$scratch" 2>&1)" +if [ "$probe_block" != "o/r@v1" ] || [ "$probe_kyaml" != "o/r@v1" ]; then + echo "::error::actions-lock gate: $YQ_BIN cannot read uses: refs from a known workflow (got '$probe_block' / '$probe_kyaml'). mikefarah yq v4 is required." + exit 2 +fi + +unpinned="" +unexamined="" +checked=0 +for wf in "$WF_DIR"/*.yml "$WF_DIR"/*.yaml; do + [ -f "$wf" ] || continue + checked=$((checked + 1)) + if ! refs="$(uses_refs "$wf" 2>"$scratch")"; then + unexamined+=" $wf: not parseable as YAML: $(head -1 "$scratch")"$'\n' + continue + fi + # An unclosed quote can swallow the rest of a file into one scalar and still + # parse, leaving no jobs and no refs. GitHub cannot run that file, so an + # empty ref list from it is not "pinned". + if [ "$("$YQ_BIN" -r '.jobs | tag' "$wf" 2>/dev/null)" != '!!map' ]; then + unexamined+=" $wf: parses, but has no jobs: map"$'\n' + continue + fi + while IFS= read -r ref; do + [ -n "$ref" ] || continue + exempt_ref "$ref" && continue + [[ "$ref" =~ @[0-9a-f]{40}$ ]] && continue + unpinned+=" $wf: uses: $ref"$'\n' + done <<< "$refs" +done +echo "Read $checked workflow file(s) in $WF_DIR with $YQ_BIN." + +if [ -n "$unexamined" ]; then + echo "::error::actions-lock gate: these workflows could not be read, so their refs are UNEXAMINED (never counted as pinned):" + printf '%s' "$unexamined" +fi if [ -n "$unpinned" ]; then echo "::error::actions-lock gate: no $WF_DIR/actions.lock AND these refs are not SHA-pinned:" - echo "$unpinned" + printf '%s' "$unpinned" echo " Prefer \`gh actions-lock\` (scripts/update-actions-lock.sh): it also locks the" echo " transitive dependencies of composite actions, which an inline SHA cannot express." +fi +if [ -n "$unexamined" ] || [ -n "$unpinned" ]; then exit 1 fi diff --git a/scripts/tests/check-actions-lock-gate-test.sh b/scripts/tests/check-actions-lock-gate-test.sh index 4aa48ddeb..70a745fe4 100755 --- a/scripts/tests/check-actions-lock-gate-test.sh +++ b/scripts/tests/check-actions-lock-gate-test.sh @@ -66,5 +66,45 @@ assert "no lock, unpinned, before cutoff → 1 (no grace for unpinned)" 1 "not S assert "no lock, unpinned, after cutoff → 1" 1 "not SHA-pinned" env LOCK_TODAY="$AFTER" bash "$GATE" "$d" assert "missing workflows dir → 2" 2 "not found" bash "$GATE" "$WORK/does-not-exist/.github/workflows" +echo "=== no lockfile, KYAML and parser cases (YAML-POLICY Y-1) ===" +# The KYAML control is generated from the block fixture by yq itself, and must +# carry the same data, so a fail here is the gate's reading, not the fixture's. +d=$(mkwf k pinned) +yq -p yaml -o kyaml "$d/ci.yml" > "$d/ci.kyaml.tmp" && mv "$d/ci.kyaml.tmp" "$d/ci.yml" +kyaml_same=no +[ "$(yq -o json -I 0 "$d/ci.yml")" = "$(yq -o json -I 0 "$(mkwf k0 pinned)/ci.yml")" ] && kyaml_same=yes +assert "KYAML control: same data as the block fixture" 0 "yes" echo "$kyaml_same" +assert "KYAML control: really is flow style" 0 '"./local"' cat "$d/ci.yml" +assert "no lock, KYAML pinned, before cutoff → 0" 0 "NOT YET ENFORCED" env LOCK_TODAY="$BEFORE" bash "$GATE" "$d" +assert "no lock, KYAML pinned, after cutoff → 3" 3 "MISSING-LOCK DEBT" env LOCK_TODAY="$AFTER" bash "$GATE" "$d" +d=$(mkwf ku unpinned) +yq -p yaml -o kyaml "$d/ci.yml" > "$d/ci.kyaml.tmp" && mv "$d/ci.kyaml.tmp" "$d/ci.yml" +assert "no lock, KYAML unpinned (mutant) → 1" 1 "uses: actions/checkout@v4" env LOCK_TODAY="$BEFORE" bash "$GATE" "$d" + +d=$(mkwf rb pinned) +# The heredoc line starts with `uses:`, so a line grep reads it as a step ref. +printf ' b:\n steps:\n - run: |\n cat <> "$d/ci.yml" +assert "a run: body line 'uses: x@v4' is not a ref → 0" 0 "NOT YET ENFORCED" env LOCK_TODAY="$BEFORE" bash "$GATE" "$d" +d=$(mkwf jl pinned) +printf ' call:\n uses: o/r/.github/workflows/w.yml@v1\n' >> "$d/ci.yml" +assert "a job-level reusable call by tag → 1" 1 "uses: o/r/.github/workflows/w.yml@v1" env LOCK_TODAY="$BEFORE" bash "$GATE" "$d" +d=$(mkwf sh pinned) +printf ' - uses: actions/checkout@3d3c42e\n' >> "$d/ci.yml" +assert "a short SHA is not a pin → 1" 1 "uses: actions/checkout@3d3c42e" env LOCK_TODAY="$BEFORE" bash "$GATE" "$d" + +d=$(mkwf up pinned) +printf 'jobs:\n a: [\n' > "$d/broken.yml" +assert "an unparseable workflow → 1 UNEXAMINED, never pinned" 1 "UNEXAMINED" env LOCK_TODAY="$AFTER" bash "$GATE" "$d" +d=$(mkwf qs pinned) +# An unclosed quote that swallows the file still parses: name becomes one long +# string and there is no jobs map, so the unpinned ref inside is never read. +printf 'name: "X\non: push\njobs:\n a:\n steps:\n - uses: actions/checkout@v4\n"\n' > "$d/swallowed.yml" +assert "a quote-swallowed workflow → 1, not a pass" 1 "has no jobs: map" env LOCK_TODAY="$BEFORE" bash "$GATE" "$d" + +d=$(mkwf ny pinned) +assert "yq missing → 2" 2 "yq not found" env YQ_BIN="$WORK/no-such-yq" bash "$GATE" "$d" +WRONG_YQ="$WORK/wrong-yq.sh"; printf '#!/usr/bin/env bash\nexit 0\n' > "$WRONG_YQ"; chmod +x "$WRONG_YQ" +assert "a yq that reads nothing → 2" 2 "cannot read uses: refs" env YQ_BIN="$WRONG_YQ" bash "$GATE" "$d" + echo; echo "passed=$pass failed=$fail" [ "$fail" -eq 0 ]