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
95 changes: 88 additions & 7 deletions scripts/check-actions-lock-gate.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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@<sha>",` failed its `@<sha>([[: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
Expand Down Expand Up @@ -63,14 +78,80 @@
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() {

Check warning on line 87 in scripts/check-actions-lock-gate.sh

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Add an explicit return statement at the end of the function.

See more on https://sonarcloud.io/project/issues?id=hyperpolymath_standards&issues=AaEgkW7b9vGxHdotmlvn&open=AaEgkW7b9vGxHdotmlvn&pullRequest=1217
"$YQ_BIN" -r "$USES_EXPR" "$1"

Check warning on line 88 in scripts/check-actions-lock-gate.sh

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Assign this positional parameter to a local variable.

See more on https://sonarcloud.io/project/issues?id=hyperpolymath_standards&issues=AaEgkW7b9vGxHdotmlvm&open=AaEgkW7b9vGxHdotmlvm&pullRequest=1217
}

# 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)."

Check warning on line 102 in scripts/check-actions-lock-gate.sh

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Redirect this error message to stderr (>&2).

See more on https://sonarcloud.io/project/issues?id=hyperpolymath_standards&issues=AaEgkW7b9vGxHdotmlvo&open=AaEgkW7b9vGxHdotmlvo&pullRequest=1217
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

Check failure on line 113 in scripts/check-actions-lock-gate.sh

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use '[[' instead of '[' for conditional tests. The '[[' construct is safer and more feature-rich.

See more on https://sonarcloud.io/project/issues?id=hyperpolymath_standards&issues=AaEgkW7b9vGxHdotmlvp&open=AaEgkW7b9vGxHdotmlvp&pullRequest=1217

Check failure on line 113 in scripts/check-actions-lock-gate.sh

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use '[[' instead of '[' for conditional tests. The '[[' construct is safer and more feature-rich.

See more on https://sonarcloud.io/project/issues?id=hyperpolymath_standards&issues=AaEgkW7b9vGxHdotmlvq&open=AaEgkW7b9vGxHdotmlvq&pullRequest=1217
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."

Check warning on line 114 in scripts/check-actions-lock-gate.sh

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Redirect this error message to stderr (>&2).

See more on https://sonarcloud.io/project/issues?id=hyperpolymath_standards&issues=AaEgkW7b9vGxHdotmlvr&open=AaEgkW7b9vGxHdotmlvr&pullRequest=1217
exit 2
fi

unpinned=""
unexamined=""
checked=0
for wf in "$WF_DIR"/*.yml "$WF_DIR"/*.yaml; do
[ -f "$wf" ] || continue

Check failure on line 122 in scripts/check-actions-lock-gate.sh

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use '[[' instead of '[' for conditional tests. The '[[' construct is safer and more feature-rich.

See more on https://sonarcloud.io/project/issues?id=hyperpolymath_standards&issues=AaEgkW7b9vGxHdotmlvs&open=AaEgkW7b9vGxHdotmlvs&pullRequest=1217
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

Check failure on line 131 in scripts/check-actions-lock-gate.sh

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use '[[' instead of '[' for conditional tests. The '[[' construct is safer and more feature-rich.

See more on https://sonarcloud.io/project/issues?id=hyperpolymath_standards&issues=AaEgkW7b9vGxHdotmlvt&open=AaEgkW7b9vGxHdotmlvt&pullRequest=1217
unexamined+=" $wf: parses, but has no jobs: map"$'\n'
continue
fi
while IFS= read -r ref; do
[ -n "$ref" ] || continue

Check failure on line 136 in scripts/check-actions-lock-gate.sh

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use '[[' instead of '[' for conditional tests. The '[[' construct is safer and more feature-rich.

See more on https://sonarcloud.io/project/issues?id=hyperpolymath_standards&issues=AaEgkW7b9vGxHdotmlvu&open=AaEgkW7b9vGxHdotmlvu&pullRequest=1217
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

Check failure on line 144 in scripts/check-actions-lock-gate.sh

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use '[[' instead of '[' for conditional tests. The '[[' construct is safer and more feature-rich.

See more on https://sonarcloud.io/project/issues?id=hyperpolymath_standards&issues=AaEgkW7b9vGxHdotmlvv&open=AaEgkW7b9vGxHdotmlvv&pullRequest=1217
echo "::error::actions-lock gate: these workflows could not be read, so their refs are UNEXAMINED (never counted as pinned):"

Check warning on line 145 in scripts/check-actions-lock-gate.sh

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Redirect this error message to stderr (>&2).

See more on https://sonarcloud.io/project/issues?id=hyperpolymath_standards&issues=AaEgkW7b9vGxHdotmlvw&open=AaEgkW7b9vGxHdotmlvw&pullRequest=1217
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

Check failure on line 154 in scripts/check-actions-lock-gate.sh

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use '[[' instead of '[' for conditional tests. The '[[' construct is safer and more feature-rich.

See more on https://sonarcloud.io/project/issues?id=hyperpolymath_standards&issues=AaEgkW7b9vGxHdotmlvy&open=AaEgkW7b9vGxHdotmlvy&pullRequest=1217

Check failure on line 154 in scripts/check-actions-lock-gate.sh

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use '[[' instead of '[' for conditional tests. The '[[' construct is safer and more feature-rich.

See more on https://sonarcloud.io/project/issues?id=hyperpolymath_standards&issues=AaEgkW7b9vGxHdotmlvx&open=AaEgkW7b9vGxHdotmlvx&pullRequest=1217
exit 1
fi

Expand Down
40 changes: 40 additions & 0 deletions scripts/tests/check-actions-lock-gate-test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -66,5 +66,45 @@
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

Check failure on line 75 in scripts/tests/check-actions-lock-gate-test.sh

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use '[[' instead of '[' for conditional tests. The '[[' construct is safer and more feature-rich.

See more on https://sonarcloud.io/project/issues?id=hyperpolymath_standards&issues=AaEgkWrX9vGxHdotmlvl&open=AaEgkWrX9vGxHdotmlvl&pullRequest=1217
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 <<EOF\n uses: actions/checkout@v4\n EOF\n' >> "$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 ]
Loading