Skip to content

Commit df96e43

Browse files
hyperpolymathcoderabbitai[bot]arena-agent
authored
CI/CD foundation + PR #241 consolidation (coverage, PHP, estate conformance) (#242)
WIP — consolidating #241 and rebuilding CI/CD on estate canon (SHA pinning, allowlist-safe reusable refs, proven-tests harness, squabbler, recon). --------- Signed-off-by: Jonathan D.A. Jewell <6759885+hyperpolymath@users.noreply.github.com> Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>
1 parent b553083 commit df96e43

78 files changed

Lines changed: 6430 additions & 445 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.githooks/validate-actions-lock.sh‎

Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
#!/usr/bin/env bash
2+
# SPDX-License-Identifier: MPL-2.0
3+
# Actions lockfile coverage: every SHA-pinned `uses:` ref in .github/workflows/
4+
# AND in .github/actions/*/action.yml must have a matching entry in
5+
# .github/workflows/actions.lock.
6+
#
7+
# WHY THIS EXISTS
8+
#
9+
# GitHub validates a reusable workflow against the CALLEE repo's own
10+
# actions.lock. A lockfile that has drifted from its workflows therefore
11+
# does not break this repo -- it breaks every repo that calls into it, with
12+
# `failure` (not `startup_failure`), 0 jobs, and no reason in either the
13+
# REST or the GraphQL payload; only the run page says why. actionlint
14+
# passes either way. Dependabot bumps a `uses:` SHA without regenerating
15+
# the lock, and Dependabot never runs pre-commit -- so CI is the only place
16+
# this is caught before it reaches the callers.
17+
#
18+
# WHY NOT `gh actions-lock --verify-local`, measured 2026-09-15 on v0.1.6:
19+
#
20+
# * It WRITES. It rewrote `uses: ./.github/actions/signed-push` to the
21+
# invalid `uses: $/.github/actions/signed-push` -- a ref that kills the
22+
# workflow at startup -- while running in a mode whose own help text
23+
# calls it read-only and "ideal for pre-commit hooks".
24+
# --no-migrate-local-actions suppresses that, but then the tool stops
25+
# descending into local composite actions and misreports their
26+
# dependencies as stale. Safety and accuracy are in conflict there.
27+
# * Its coverage is REPO-scoped, not SHA-exact. Bumping ONE of a workflow's
28+
# two refs to the same action leaves the old lock key still referenced by
29+
# the other: nothing reported stale, nothing reported missing, check
30+
# green, workflow broken. That mutant survives it and dies here.
31+
#
32+
# SCOPE -- stated, not implied:
33+
# * Only 40-hex SHA-pinned refs are checked. Tag refs (@v2, @main) are a
34+
# different question, gated by validate-sha-pins.sh.
35+
# * Reusable WORKFLOW refs (owner/repo/.github/workflows/x.yml@sha) are
36+
# skipped: the lockfile models actions and keys no entry for them.
37+
# * Local refs (./...) are skipped; they carry no SHA.
38+
# * Sub-path actions normalise to their repo -- codeql-action/init@X is
39+
# keyed once as codeql-action@X, never once per sub-path.
40+
# * Comparison is case-insensitive: workflows say Swatinem/rust-cache while
41+
# the lockfile stores swatinem/rust-cache.
42+
# * Local COMPOSITE actions (.github/actions/*/action.yml) are scanned too.
43+
# Their deps are keyed in the lock under the workflow that uses them, and
44+
# Dependabot bumps them like any other ref; scanning only the workflow
45+
# directory leaves that drift invisible.
46+
# * Membership is GLOBAL, not per-workflow-section. A ref present in some
47+
# other workflow's lock section satisfies this check. A workflow with
48+
# SHA-pinned refs but no lock section of its own is therefore NOT
49+
# detected here -- see the PR notes.
50+
51+
set -euo pipefail
52+
53+
REPO_ROOT="${INPUT_PATH:-.}"
54+
LOCKFILE="$REPO_ROOT/.github/workflows/actions.lock"
55+
WORKFLOW_DIR="$REPO_ROOT/.github/workflows"
56+
ACTIONS_DIR="$REPO_ROOT/.github/actions"
57+
58+
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m'
59+
60+
# Refs deliberately absent from the lockfile. Each entry is an exact
61+
# normalised owner/repo@sha plus the reason it is absent. This is an explicit
62+
# list, never a pattern: anything not named here fails, so the list cannot
63+
# quietly widen to cover an accident. An entry that stops being used is
64+
# reported as stale, so it cannot rot either.
65+
EXPECTED_ABSENT=(
66+
# Empty: the denoland/setup-deno exception was removed 2026-09-22 after
67+
# the last consumer went away (the validator itself reported it stale).
68+
# Estate doctrine is still bun-only; if a deno ref ever returns, the
69+
# cure is to remove the consumer, not to re-add an exception.
70+
)
71+
72+
if [ ! -f "$LOCKFILE" ]; then
73+
echo -e "${RED}[validate-actions-lock] ERROR: $LOCKFILE not found${NC}" >&2
74+
exit 1
75+
fi
76+
77+
# An unreadable lockfile must abort, never silently pass.
78+
if [ ! -r "$LOCKFILE" ]; then
79+
echo -e "${RED}[validate-actions-lock] ERROR: $LOCKFILE unreadable${NC}" >&2
80+
exit 1
81+
fi
82+
83+
# Parse the lockfile's keys into an explicit set ONCE, rather than substring
84+
# -matching against the whole file. A substring test over a blob fails open in
85+
# ways that are hard to see: if the blob is empty or truncated, every ref is
86+
# reported "missing" and the operator is told to regenerate a lockfile that
87+
# was actually fine. An explicit set can be counted, and is counted below.
88+
mapfile -t LOCK_KEYS < <(
89+
grep -oE "'[A-Za-z0-9._-]+/[A-Za-z0-9._-]+@[0-9a-fA-F]{40}'" "$LOCKFILE" \
90+
| tr -d "'" | tr '[:upper:]' '[:lower:]' | sort -u
91+
)
92+
93+
# Positive control: the lockfile is known to key at least one action. If the
94+
# parse yields nothing, the FILE FORMAT changed or the read failed -- say so,
95+
# rather than reporting every ref in the repo as missing.
96+
if [ "${#LOCK_KEYS[@]}" -eq 0 ]; then
97+
echo -e "${RED}[validate-actions-lock] ERROR: parsed 0 keys from $LOCKFILE${NC}" >&2
98+
echo " The lockfile exists but no 'owner/repo@sha' keys were found." >&2
99+
echo " This is a parser/format failure, NOT a coverage failure." >&2
100+
exit 1
101+
fi
102+
103+
lock_has() {
104+
local needle="$1" k
105+
for k in "${LOCK_KEYS[@]}"; do
106+
[ "$k" = "$needle" ] && return 0
107+
done
108+
return 1
109+
}
110+
111+
# Leaf action entries in `dependencies:` whose `ref:` equals their own 40-hex
112+
# SHA pin fail GitHub runner lockfile verification during `Set up job`
113+
# ("Unable to resolve action ...: invalid lockfile pin", standards#1271).
114+
mapfile -t SELF_SHA_KEYS < <(
115+
awk '
116+
/^dependencies:/ { in_dep = 1; next }
117+
/^[a-z_]+:/ { in_dep = 0; cur = ""; next }
118+
!in_dep { next }
119+
/^ \x27[^\x27]+\x27:$/ {
120+
cur = $0; sub(/^ \x27/, "", cur); sub(/\x27:$/, "", cur)
121+
next
122+
}
123+
cur != "" && /^ ref: \x27[^\x27]+\x27$/ {
124+
r = $0; sub(/^ ref: \x27/, "", r); sub(/\x27$/, "", r)
125+
n = index(cur, "@")
126+
pin = (n > 0) ? substr(cur, n + 1) : ""
127+
if (length(r) == 40 && r ~ /^[0-9a-fA-F]+$/ && tolower(r) == tolower(pin)) {
128+
print tolower(cur)
129+
}
130+
cur = ""
131+
}
132+
' "$LOCKFILE" | sort -u
133+
)
134+
135+
# Check a normalized owner/repo@sha key against the parsed self-SHA entries.
136+
# Return 0 if its dependency ref equals its own SHA pin, or 1 if absent.
137+
lock_has_self_sha() {
138+
local needle="$1" k
139+
for k in ${SELF_SHA_KEYS[@]+"${SELF_SHA_KEYS[@]}"}; do
140+
[ "$k" = "$needle" ] && return 0
141+
done
142+
return 1
143+
}
144+
145+
ERRORS=0
146+
CHECKED=0
147+
declare -a SEEN_ABSENT=()
148+
149+
# Collect every SHA-pinned uses: ref across all workflow files.
150+
#
151+
# Read with yq, never grep (YAML-POLICY Y-1). A line grep for `uses:` only
152+
# sees block style: on a KYAML file (`uses: "owner/repo@sha", # v1`) it
153+
# captured the quote and comma into the ref and reported a pinned, locked
154+
# action as missing. The parser returns the scalar VALUE whatever the style,
155+
# and never matches a `uses:` that is text inside a `run:` body.
156+
# Measured 2026-10-01: same 29-ref set as the old grep on the block tree.
157+
if ! command -v yq >/dev/null 2>&1; then
158+
echo -e "${RED}[validate-actions-lock] ERROR: yq not found -- it reads the workflows (YAML-POLICY Y-1)${NC}" >&2
159+
exit 1
160+
fi
161+
declare -a UNPARSED=()
162+
mapfile -t RAW < <(
163+
for f in "$WORKFLOW_DIR"/*.yml "$WORKFLOW_DIR"/*.yaml \
164+
"$ACTIONS_DIR"/*/action.yml "$ACTIONS_DIR"/*/action.yaml; do
165+
[ -f "$f" ] || continue
166+
yq -r '.. | select(tag == "!!map") | select(has("uses")) | .uses | select(tag == "!!str")' "$f" \
167+
|| printf '\001UNPARSED\001%s\n' "$f"
168+
done | grep -E $'@[0-9a-fA-F]{40}$|^\001UNPARSED\001' | sort -u
169+
)
170+
# A file yq cannot parse is a file whose refs went unchecked: fail closed.
171+
for ref in "${RAW[@]}"; do
172+
case "$ref" in $'\001UNPARSED\001'*) UNPARSED+=("${ref#$'\001UNPARSED\001'}") ;; esac
173+
done
174+
if [ "${#UNPARSED[@]}" -gt 0 ]; then
175+
echo -e "${RED}[validate-actions-lock] ERROR: yq could not parse: ${UNPARSED[*]}${NC}" >&2
176+
exit 1
177+
fi
178+
179+
# A zero-input pass is the classic fake green: if ref extraction ever breaks,
180+
# this script would report success having checked nothing. If the lockfile
181+
# already names workflows, finding no refs is a contradiction, not an empty
182+
# repo -- fail instead of passing vacuously. The success line below also
183+
# prints the number checked, so a silent collapse to near-zero is visible.
184+
if [ "${#RAW[@]}" -eq 0 ]; then
185+
if grep -qE "^ '\\.github/workflows/" "$LOCKFILE"; then
186+
echo -e "${RED}[validate-actions-lock] ERROR: no SHA-pinned refs extracted, yet the lockfile names workflows -- extraction is broken${NC}" >&2
187+
exit 1
188+
fi
189+
echo -e "${YELLOW}[validate-actions-lock] no SHA-pinned refs found -- nothing to check${NC}"
190+
exit 0
191+
fi
192+
193+
for ref in "${RAW[@]}"; do
194+
case "$ref" in
195+
./*|.\\*) continue ;; # local action, carries no SHA
196+
*/.github/workflows/*) continue ;; # reusable workflow, not keyed
197+
esac
198+
case "$ref" in
199+
*.yml@*|*.yaml@*) continue ;; # reusable workflow, any path
200+
esac
201+
202+
sha="${ref##*@}"
203+
path="${ref%@*}"
204+
owner="${path%%/*}"
205+
rest="${path#*/}"
206+
repo="${rest%%/*}"
207+
[ -n "$owner" ] && [ -n "$repo" ] || continue
208+
norm="$(printf '%s/%s@%s' "$owner" "$repo" "$sha" | tr '[:upper:]' '[:lower:]')"
209+
210+
skip=0
211+
for ex in ${EXPECTED_ABSENT[@]+"${EXPECTED_ABSENT[@]}"}; do
212+
ex_lc="$(printf '%s' "$ex" | tr '[:upper:]' '[:lower:]')"
213+
if [ "$norm" = "$ex_lc" ]; then
214+
SEEN_ABSENT+=("$ex_lc")
215+
skip=1
216+
break
217+
fi
218+
done
219+
[ "$skip" -eq 1 ] && continue
220+
221+
CHECKED=$((CHECKED + 1))
222+
if ! lock_has "$norm"; then
223+
echo -e "${RED}[validate-actions-lock] ERROR: not in actions.lock: ${ref}${NC}" >&2
224+
echo " normalised to: $norm" >&2
225+
ERRORS=$((ERRORS + 1))
226+
elif lock_has_self_sha "$norm"; then
227+
echo -e "${RED}[validate-actions-lock] ERROR: self-SHA ref in actions.lock dependencies: ${norm}${NC}" >&2
228+
echo " leaf action entries must carry a tag/branch ref, not their own commit SHA (standards#1271)" >&2
229+
ERRORS=$((ERRORS + 1))
230+
fi
231+
done
232+
233+
# A doctrine exception that is no longer used must be removed, or the list
234+
# becomes a place where real coverage gaps can hide.
235+
for ex in ${EXPECTED_ABSENT[@]+"${EXPECTED_ABSENT[@]}"}; do
236+
ex_lc="$(printf '%s' "$ex" | tr '[:upper:]' '[:lower:]')"
237+
found=0
238+
for s in ${SEEN_ABSENT[@]+"${SEEN_ABSENT[@]}"}; do
239+
[ "$s" = "$ex_lc" ] && found=1 && break
240+
done
241+
if [ "$found" -eq 0 ]; then
242+
echo -e "${YELLOW}[validate-actions-lock] WARNING: stale exception, no workflow uses ${ex} -- remove it from EXPECTED_ABSENT${NC}" >&2
243+
fi
244+
done
245+
246+
if [ "$ERRORS" -gt 0 ]; then
247+
echo -e "${RED}[validate-actions-lock] ${ERRORS} ref(s) missing from the lockfile${NC}" >&2
248+
echo " Regenerate with the LOCKFILE ONLY, and verify the *.yml diff is empty:" >&2
249+
echo " gh actions-lock <workflow paths> --no-migrate-local-actions --no-narrow" >&2
250+
echo " git diff --stat -- '.github/workflows/*.yml' # MUST be empty" >&2
251+
exit 1
252+
fi
253+
254+
echo -e "${GREEN}[validate-actions-lock] ✅ ${CHECKED} SHA-pinned ref(s) found among ${#LOCK_KEYS[@]} lockfile keys, ${#EXPECTED_ABSENT[@]} doctrine exception(s)${NC}"
255+
exit 0

0 commit comments

Comments
 (0)