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
31 changes: 26 additions & 5 deletions .githooks/validate-actions-lock.sh
Original file line number Diff line number Diff line change
Expand Up @@ -113,13 +113,34 @@ CHECKED=0
declare -a SEEN_ABSENT=()

# Collect every SHA-pinned uses: ref across all workflow files.
#
# Read with yq, never grep (YAML-POLICY Y-1). A line grep for `uses:` only
# sees block style: on a KYAML file (`uses: "owner/repo@sha", # v1`) it
# captured the quote and comma into the ref and reported a pinned, locked
# action as missing. The parser returns the scalar VALUE whatever the style,
# and never matches a `uses:` that is text inside a `run:` body.
# Measured 2026-10-01: same 29-ref set as the old grep on the block tree.
if ! command -v yq >/dev/null 2>&1; then
echo -e "${RED}[validate-actions-lock] ERROR: yq not found -- it reads the workflows (YAML-POLICY Y-1)${NC}" >&2
exit 1
fi
declare -a UNPARSED=()
mapfile -t RAW < <(
grep -rhoE '^[[:space:]]*(-[[:space:]]+)?uses:[[:space:]]*[^[:space:]#]+@[0-9a-fA-F]{40}' \
"$WORKFLOW_DIR"/*.yml "$WORKFLOW_DIR"/*.yaml \
"$ACTIONS_DIR"/*/action.yml "$ACTIONS_DIR"/*/action.yaml 2>/dev/null \
| sed -E 's/^[[:space:]]*(-[[:space:]]+)?uses:[[:space:]]*//' \
| sort -u
for f in "$WORKFLOW_DIR"/*.yml "$WORKFLOW_DIR"/*.yaml \
"$ACTIONS_DIR"/*/action.yml "$ACTIONS_DIR"/*/action.yaml; do
[ -f "$f" ] || continue
yq -r '.. | select(tag == "!!map") | select(has("uses")) | .uses | select(tag == "!!str")' "$f" \
|| printf '\001UNPARSED\001%s\n' "$f"
done | grep -E $'@[0-9a-fA-F]{40}$|^\001UNPARSED\001' | sort -u
)
# A file yq cannot parse is a file whose refs went unchecked: fail closed.
for ref in "${RAW[@]}"; do
case "$ref" in $'\001UNPARSED\001'*) UNPARSED+=("${ref#$'\001UNPARSED\001'}") ;; esac
done
if [ "${#UNPARSED[@]}" -gt 0 ]; then
echo -e "${RED}[validate-actions-lock] ERROR: yq could not parse: ${UNPARSED[*]}${NC}" >&2
exit 1
fi

# A zero-input pass is the classic fake green: if ref extraction ever breaks,
# this script would report success having checked nothing. If the lockfile
Expand Down
26 changes: 22 additions & 4 deletions .githooks/validate-permissions.sh
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,29 @@ SCAN_PATH="${INPUT_PATH:-.}"
STAGED_FILES="${INPUT_STAGED_FILES:-}"
ERRORS=0

# Ask the YAML parser, not a line grep (YAML-POLICY Y-1): `^permissions:` never
# matches a KYAML workflow, where every key sits inside `{ ... }`. Without yq the
# grep is kept -- it can only false-FAIL a KYAML file, never false-pass one.
HAVE_YQ=1
command -v yq >/dev/null 2>&1 || {
HAVE_YQ=0
echo "[validate-permissions] WARNING: yq not found -- line grep used; a KYAML workflow will be misreported" >&2
}

# Records an error unless <file> declares a top-level `permissions:` key; a
# file that does not parse is an error too, never a pass.
validate_file() {
local file="$1"

# Check for permissions block
if ! grep -qE '^permissions:' "$file"; then
local file="$1" verdict

if [ "$HAVE_YQ" -eq 1 ]; then
if ! verdict="$(yq 'has("permissions")' "$file" 2>&1)"; then
echo "[validate-permissions] ERROR: $file is not parseable as YAML: $verdict" >&2
ERRORS=$((ERRORS + 1))
elif [ "$verdict" != "true" ]; then
echo "[validate-permissions] ERROR: $file missing permissions block" >&2
ERRORS=$((ERRORS + 1))
fi
elif ! grep -qE '^permissions:' "$file"; then
echo "[validate-permissions] ERROR: $file missing permissions block" >&2
ERRORS=$((ERRORS + 1))
fi
Expand Down
18 changes: 17 additions & 1 deletion .github/workflows/governance-reusable.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1298,6 +1298,16 @@ jobs:
- name: Check SPDX headers + permissions
run: |
failed=0
# The permissions verdict is read with yq (YAML-POLICY Y-1): the old
# `grep -q "^permissions:"` FALSELY FAILED every KYAML workflow,
# whose keys sit indented inside `{ … }`. yq ships on GitHub-hosted
# Ubuntu; a runner without it (inputs.runs-on) keeps the old line
# test, which is still right for block-style files, and says so.
have_yq=1
command -v yq >/dev/null 2>&1 || {
have_yq=0
echo "::warning::yq not on this runner -- permissions read by line grep; a KYAML workflow will be misreported"
}
for file in .github/workflows/*.yml .github/workflows/*.yaml; do
[ -f "$file" ] || continue
# ⚠ SCAN THE HEADER BLOCK, NOT LINE 1. REUSE places the identifier
Expand All @@ -1317,7 +1327,13 @@ jobs:
| grep -q "^# SPDX-License-Identifier:"; then
echo "ERROR: $file has no SPDX-License-Identifier in its header comment block"; failed=1
fi
if ! grep -q "^permissions:" "$file"; then
if [ "$have_yq" -eq 1 ]; then
if ! verdict="$(yq 'has("permissions")' "$file" 2>&1)"; then
echo "ERROR: $file is not parseable as YAML: $verdict"; failed=1
elif [ "$verdict" != "true" ]; then
echo "ERROR: $file missing top-level 'permissions:' declaration"; failed=1
fi
elif ! grep -q "^permissions:" "$file"; then
echo "ERROR: $file missing top-level 'permissions:' declaration"; failed=1
fi
done
Expand Down
3 changes: 2 additions & 1 deletion .machine_readable/contractiles/must/Mustfile.a2ml
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,8 @@ requirements — CI and pre-commit hooks fail if any check fails.

### actions-sha-pinned
- description: every GitHub Action is pinned to a 40-char commit SHA
- run: ! grep -rEn 'uses:[[:space:]]+[^@]+@(v?[0-9.]+|main|master)([[:space:]]|$)' .github/workflows/ 2>/dev/null
- run: ! grep -rEn 'uses:[[:space:]]+"?[^@"]+@(v?[0-9.]+|main|master)"?,?([[:space:]]|$)' .github/workflows/ 2>/dev/null
- notes: The optional quote and trailing comma match KYAML (YAML-POLICY Y-3), whose values are always quoted; without them a tag pin in a KYAML workflow passed unseen.
- severity: critical

### jobs-have-timeout
Expand Down
28 changes: 25 additions & 3 deletions scripts/check-action-pins-resolve.sh
Original file line number Diff line number Diff line change
Expand Up @@ -95,10 +95,32 @@ fi
# Skips local (`./`) and docker:// refs, which have no upstream commit.
# kind = R: the ref points at a reusable workflow file (.github/workflows/*.yml
# in the repo) — those get the ancestry probe (see header); kind = A otherwise.
#
# Values come from the YAML parser, not a line grep (YAML-POLICY Y-1): a grep
# for `uses:` extracted NOTHING from a KYAML workflow (the value is quoted),
# so its pins went unchecked while the script reported success. The parser
# also stops matching `# uses: …` usage examples in header comments, which
# never execute. Measured 2026-10-01 on main: 28 refs, the grep's 30 minus
# exactly those two comment examples.
if ! command -v yq >/dev/null 2>&1; then
echo "::error::yq not found -- it reads the workflows (YAML-POLICY Y-1)" >&2
exit 1
fi
unparsed=""
for wf in "$WORKFLOW_DIR"/*.yml "$WORKFLOW_DIR"/*.yaml; do
[ -f "$wf" ] || continue
yq '.' "$wf" >/dev/null 2>&1 || unparsed="$unparsed $wf"
done
if [ -n "$unparsed" ]; then
echo "::error::yq could not parse:$unparsed -- their pins would go unchecked" >&2
exit 1
fi
pairs="$(
grep -rhoE '\buses:[[:space:]]*[A-Za-z0-9_.-]+/[A-Za-z0-9_./-]+@[0-9a-f]{40}' \
"$WORKFLOW_DIR" 2>/dev/null \
| sed -E 's/.*uses:[[:space:]]*//' \
for wf in "$WORKFLOW_DIR"/*.yml "$WORKFLOW_DIR"/*.yaml; do
[ -f "$wf" ] || continue
yq -r '.. | select(tag == "!!map") | select(has("uses")) | .uses | select(tag == "!!str")' "$wf"
done \
| grep -E '^[A-Za-z0-9_.-]+/[A-Za-z0-9_./-]+@[0-9a-f]{40}$' \
| awk -F'@' '{ split($1, p, "/"); k = ($1 ~ /\.github\/workflows\/[^\/]+\.ya?ml$/) ? "R" : "A"; print p[1] "/" p[2] "\t" $2 "\t" k }' \
| sort -u
)"
Expand Down
37 changes: 35 additions & 2 deletions scripts/check-workflow-duplicate-keys.sh
Original file line number Diff line number Diff line change
Expand Up @@ -111,10 +111,43 @@ for t in "${targets[@]}"; do
fi
done

# Succeed when the file is a flow-style (KYAML) document: its first line that is
# not blank, a comment or a `---` marker opens a `{` mapping or `[` sequence.
is_flow_document() {
awk '
{ line = $0; sub(/\r$/, "", line); sub(/^[ \t]+/, "", line) }
line == "" || substr(line, 1, 1) == "#" || line ~ /^---[ \t]*$/ { next }
{ exit (substr(line, 1, 1) == "{" || substr(line, 1, 1) == "[") ? 0 : 1 }
END { if (NR == 0) exit 1 }
' "$1"
}

# WHY FLOW DOCUMENTS ARE NORMALISED FIRST. scan_one walks BLOCK structure by
# indentation; a KYAML file (YAML-POLICY Y-3) puts every sibling on its own
# line inside `{ … }`, so the walker sees each step's keys as repeats of the
# previous step's and reports phantom duplicates (14 on the provisioning pilot,
# measured 2026-10-01). `yq -P` rewrites flow as block while KEEPING duplicate
# keys (it works on the node tree, not a map), so the unchanged scanner then
# answers the same question it answers for block files. Reported line numbers
# refer to that normalised form, and the message says so.
failed=0
norm="$(mktemp)"
trap 'rm -f "$norm"' EXIT
for f in "${files[@]}"; do
out="$(scan_one "$f")" || {
detail="$(printf '%s' "$out" | awk -F'|' '{printf "%s\x27%s\x27 (line %s)", sep, $1, $2; sep=", "}')"
src="$f" where=""
if is_flow_document "$f"; then
if ! yq -P '.' "$f" > "$norm" 2> "$norm.err"; then
echo "::error file=${f}::not parseable as YAML: $(head -c 300 "$norm.err")"
echo "FAIL ${f}: not parseable as YAML (yq -P): $(head -c 300 "$norm.err")"
rm -f "$norm.err"
failed=$((failed + 1))
continue
fi
rm -f "$norm.err"
src="$norm" where=" of the block-normalised form (yq -P)"
fi
out="$(scan_one "$src")" || {
detail="$(printf '%s' "$out" | awk -F'|' -v w="$where" '{printf "%s\x27%s\x27 (line %s%s)", sep, $1, $2, w; sep=", "}')"
echo "::error file=${f}::duplicate key(s): ${detail}"
echo "FAIL ${f}: duplicate key(s): ${detail}"
failed=$((failed + 1))
Expand Down
25 changes: 20 additions & 5 deletions scripts/lock-selfcheck.sh
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: MPL-2.0
# lock-selfcheck.sh — is a given `standards` commit SAFE TO PIN A CALLER TO?
#
# WHY THIS EXISTS
Expand Down Expand Up @@ -38,7 +39,6 @@
# (a garbage-collected commit is the general case). Reachability is a
# SEPARATE probe and must be made against the remote.
#
# SPDX-License-Identifier: MPL-2.0

set -uo pipefail

Expand Down Expand Up @@ -74,6 +74,12 @@ normalise_ref() {

overall_rc=0

# The workflows are read with yq (YAML-POLICY Y-1); without it nothing is examined.
if ! command -v yq >/dev/null 2>&1; then
echo "lock-selfcheck: yq not found -- it reads the workflows (YAML-POLICY Y-1)" >&2
exit 2
fi

for SHA in "$@"; do
echo "=============================================================="
if ! git -C "$STANDARDS_DIR" cat-file -e "${SHA}^{commit}" 2>/dev/null; then
Expand Down Expand Up @@ -140,13 +146,18 @@ for SHA in "$@"; do
: > "$TMP/missing"
: > "$TMP/unkeyed_wf"
: > "$TMP/scanned"
: > "$TMP/unparsed"

while IFS= read -r wf; do
[ -n "$wf" ] || continue
git -C "$STANDARDS_DIR" show "${SHA}:${wf}" 2>/dev/null > "$TMP/wfbody" || continue
# Extract every `uses:` value, strip inline comments and quotes.
/usr/bin/grep -hoE '^[[:space:]]*(-[[:space:]]*)?uses:[[:space:]]*[^[:space:]#]+' "$TMP/wfbody" \
| sed -E 's/.*uses:[[:space:]]*//; s/^["\x27]//; s/["\x27]$//' \
# Extract every `uses:` VALUE with the YAML parser (YAML-POLICY Y-1). A
# line grep only sees block style: on a KYAML workflow it captured
# `…@sha",` and reported a keyed ref as POISON. A file yq cannot parse is
# recorded and fails the SHA below -- its refs went unexamined.
# Measured 2026-10-01 on main: same 111 (workflow, ref) pairs as the grep.
{ yq -r '.. | select(tag == "!!map") | select(has("uses")) | .uses | select(tag == "!!str")' "$TMP/wfbody" \
|| printf '%s\n' "$wf" >> "$TMP/unparsed"; } \
| while IFS= read -r ref; do
[ -n "$ref" ] || continue
case "$ref" in
Expand Down Expand Up @@ -196,7 +207,11 @@ for SHA in "$@"; do
cut -f1 "$TMP/unkeyed_wf" | sort -u | sed 's/^/ /'
fi

if [ "$n_missing" -eq 0 ]; then
if [ -s "$TMP/unparsed" ]; then
echo " VERDICT: UNEXAMINED — yq could not parse $(sort -u "$TMP/unparsed" | wc -l) workflow(s); their refs were not checked:"
sort -u "$TMP/unparsed" | sed 's/^/ /'
overall_rc=1
elif [ "$n_missing" -eq 0 ]; then
echo " VERDICT: SELF-CONSISTENT — every action ref used is keyed in this SHA's own lock."
echo " (Reachability at the remote is NOT proven by this check.)"
else
Expand Down
61 changes: 58 additions & 3 deletions scripts/tests/check-action-pins-resolve-test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -176,10 +176,10 @@ echo "== orphan reusable pins — the four #782 witness SHAs =="
#
# The old predicate passed the first three, which is exactly the class this
# gate now exists to fail on.
SHA_W1=7fdc27050000000000000000000000000000000000
SHA_W2=892497fe0000000000000000000000000000000000
SHA_W1=7fdc270500000000000000000000000000000000
SHA_W2=892497fe00000000000000000000000000000000
SHA_W3=4696052100000000000000000000000000000000
SHA_W4=5b1d00220000000000000000000000000000000000
SHA_W4=5b1d002200000000000000000000000000000000
SHA_OK=81dbf2dd00000000000000000000000000000000

mk_reusable "$TMP/w1" "$SHA_W1"
Expand Down Expand Up @@ -290,6 +290,61 @@ YAML
STUB_COMMITS=200 \
expect "a subpath pin is resolved at the repository level" 0 "Checking 2 unique action pin(s)" "$TMP/dedup"

echo
echo "== YAML syntax independence (YAML-POLICY Y-1 / Y-3) =="

# A KYAML (flow-style) workflow quotes its values. The old line grep extracted
# NOTHING from it and reported "nothing to check" -- a dead pin sailed through.
rm -rf "$TMP/kyaml"; mkdir -p "$TMP/kyaml/.github/workflows"
cat > "$TMP/kyaml/.github/workflows/k.yml" <<YAML
{
jobs: {
j: {
steps: [{
uses: "actions/checkout@${SHA_A}", # v6
}],
},
},
}
YAML
STUB_COMMITS=200 \
expect "a KYAML pin is extracted and checked" 0 "Checking 1 unique action pin(s)" "$TMP/kyaml"
STUB_COMMITS=404 STUB_REPO=200 \
expect "a dead KYAML pin fails (mutant: unresolvable SHA)" 1 "SHA-NOT-FOUND" "$TMP/kyaml"

# Text that merely mentions `uses:` -- a header usage example, a run: body --
# is not a ref GitHub resolves, and must not be probed.
rm -rf "$TMP/textual"; mkdir -p "$TMP/textual/.github/workflows"
cat > "$TMP/textual/.github/workflows/t.yml" <<YAML
# Usage:
# uses: actions/checkout@${SHA_B}
jobs:
j:
steps:
- run: |
echo "uses: actions/checkout@${SHA_B}"
- uses: actions/checkout@${SHA_A}
YAML
STUB_COMMITS=200 \
expect "uses: in a comment or run: body is not a pin" 0 "Checking 1 unique action pin(s)" "$TMP/textual"

# A ref longer than 40 hex is not a commit SHA. The old unanchored grep
# truncated it to 40 and probed that instead -- which is how this suite's own
# witness fixtures carried 44-hex SHAs unnoticed until 2026-10-01.
rm -rf "$TMP/long"; mkdir -p "$TMP/long/.github/workflows"
cat > "$TMP/long/.github/workflows/l.yml" <<YAML
jobs:
j:
steps:
- uses: actions/checkout@${SHA_A}abcd
YAML
expect "a 44-hex ref is not taken as a 40-hex pin" 0 "nothing to check" "$TMP/long"

# A file the parser rejects must fail closed: its pins would go unchecked.
rm -rf "$TMP/broken"; mkdir -p "$TMP/broken/.github/workflows"
printf 'jobs: {\n j: [\n' > "$TMP/broken/.github/workflows/b.yml"
expect "an unparseable workflow fails closed" 1 "yq could not parse" "$TMP/broken"

echo
echo "check-action-pins-resolve regression: $pass passed, $fail failed"
[ "$fail" -eq 0 ]
51 changes: 51 additions & 0 deletions scripts/tests/check-workflow-duplicate-keys-test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,57 @@ jobs:
runs-on: ubuntu-latest
YAML

echo
echo "== KYAML / flow-style workflows (YAML-POLICY Y-3) =="

# A KYAML file nests every key inside `{ … }`, so the indentation scanner sees
# one scope. These cases prove the flow path normalises it first: it must not
# go blind (miss a real duplicate) and must not cry wolf on sibling scopes.

run_case "a clean KYAML workflow is clean" 0 "clean" <<'YAML'
# SPDX-License-Identifier: MPL-2.0
{
name: "demo",
jobs: {
a: { runs-on: "ubuntu-latest", steps: [{ run: "true" }] },
b: { runs-on: "ubuntu-latest", steps: [{ run: "true" }] },
},
}
YAML

run_case "a nested duplicate in a KYAML workflow is rejected" 1 "'runs-on'" <<'YAML'
{
name: "demo",
jobs: {
build: {
runs-on: "ubuntu-latest",
runs-on: "ubuntu-24.04",
},
},
}
YAML

run_case "a duplicate inside a one-line flow mapping is rejected" 1 "'runs-on'" <<'YAML'
{
name: "demo",
jobs: { build: { runs-on: "ubuntu-latest", runs-on: "ubuntu-24.04" } },
}
YAML

run_case "a top-level duplicate in a KYAML workflow is rejected" 1 "duplicate key(s)" <<'YAML'
{
name: "demo",
jobs: {},
name: "demo again",
}
YAML

run_case "an unparseable flow workflow fails closed" 1 "" <<'YAML'
{
name: "demo",
jobs: {
YAML

echo
echo "== directory scanning =="

Expand Down
Loading
Loading