From 8d38af9dae0d5a983ef5d274bb9a6c72f0ea87d3 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Thu, 1 Oct 2026 12:14:07 +0100 Subject: [PATCH 1/2] feat(scripts): land actions.lock generators + mutation-checked tests Three repair steps the round-3 lock campaign (standards#968) ran from a job scratch dir, now reproducible by anyone. Repair order, recorded in each header: gh actions-lock -> relock-sha-keys.sh (existing) -> complete-job-refs.sh -> close-lock.sh -> prune-stale.sh. - complete-job-refs.sh: add job-level reusable refs (owner/repo/.github/ workflows/x.yml@ref) that gh actions-lock v0.1.6 does not write; creates a workflow entry when the lock has none (was silently dropped). - close-lock.sh: build top-level dependencies: records for dangling edges (a dangling edge = startup_failure, jobs=0). Every API capture is shape-validated (ids, 40-hex sha); a failed or malformed lookup SKIPs, writes nothing and exits 3 - an error body is never recorded as a value. - prune-stale.sh: drop per-workflow refs no uses: references. Two oracles (parser + literal grep -F) must agree; disagreement keeps the ref. respell-literal.sh is NOT landed: relock-sha-keys.sh on main supersedes it. Tests (picked up by self-test.yml): close-lock 9, complete-job-refs 11, prune-stale 11. Each guard mutation-checked: 4 valid mutants, 4 killed, each by its intended control. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01X3hgXxWm6umMgZkjYyHnnm --- scripts/close-lock.sh | 78 +++++++++++ scripts/complete-job-refs.sh | 90 ++++++++++++ scripts/prune-stale.sh | 173 ++++++++++++++++++++++++ scripts/tests/close-lock-test.sh | 97 +++++++++++++ scripts/tests/complete-job-refs-test.sh | 90 ++++++++++++ scripts/tests/prune-stale-test.sh | 80 +++++++++++ 6 files changed, 608 insertions(+) create mode 100755 scripts/close-lock.sh create mode 100755 scripts/complete-job-refs.sh create mode 100755 scripts/prune-stale.sh create mode 100755 scripts/tests/close-lock-test.sh create mode 100755 scripts/tests/complete-job-refs-test.sh create mode 100755 scripts/tests/prune-stale-test.sh diff --git a/scripts/close-lock.sh b/scripts/close-lock.sh new file mode 100755 index 000000000..e05815b1a --- /dev/null +++ b/scripts/close-lock.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) +# +# close-lock.sh — make actions.lock TRANSITIVELY CLOSED. +# +# MEASURED on hyperpolymath/cicd-squabbler#101: GitHub rejects a run at startup +# (jobs=0) when the lockfile contains a DANGLING edge — a ref named under +# `workflows:` or inside another record's nested `uses:` that has no top-level +# record of its own. Three workflows that ran with jobs on `pull_request` on +# 2026-09-21 went startup_failure once a job-level ref was listed without +# closure, and adding the record but not its nested refs' records did not cure +# it. metadatastician/burble's working lockfile has zero dangling edges. +# +# Leaf records are written without a nested `uses:` list, so one pass suffices. +# +# Lock repair order: gh actions-lock → relock-sha-keys.sh → complete-job-refs.sh +# → close-lock.sh → prune-stale.sh (see complete-job-refs.sh). +# +# Every value taken from the API is SHAPE-checked before it is written: a 40-hex +# commit and two integer ids. On an HTTP error `gh api` can put the error body +# on stdout, and a record built from `{"message"…` would be a present-but- +# unresolvable entry, which is fatal where an absent one is merely dangling. +# A ref that cannot be resolved is SKIPPED, reported, and makes the exit 3. +# GH_BIN overrides the `gh` binary (the test suite uses an offline stub). +set -euo pipefail +WF="${1:-.github/workflows}" +L="$WF/actions.lock" +GH_BIN="${GH_BIN:-gh}" +[ -f "$L" ] || { echo "close-lock: no $L" >&2; exit 1; } +SC="$(mktemp -d)"; trap 'rm -rf "$SC"' EXIT +keys() { awk '/^dependencies:/{f=1;next} f&&match($0,/^ '\''[^'\'']+'\'':$/){k=$0;sub(/^ '\''/,"",k);sub(/'\'':$/,"",k);print k}' "$L" | LC_ALL=C sort -u; } +nested() { awk '/^dependencies:/{f=1} f&&match($0,/^ - '\''[^'\'']+'\''/){k=$0;sub(/^ - '\''/,"",k);sub(/'\''.*$/,"",k);print k}' "$L" | LC_ALL=C sort -u; } +wfrefs() { awk '/^workflows:/{f=1;next} f&&/^[a-z_]+:/{f=0} f&&match($0,/^ - '\''[^'\'']+'\''/){k=$0;sub(/^ - '\''/,"",k);sub(/'\''.*$/,"",k);print k}' "$L" | LC_ALL=C sort -u; } + +skipped=0 +for pass in 1 2 3 4 5; do + keys > "$SC/have" + { nested; wfrefs; } | LC_ALL=C sort -u > "$SC/want" + LC_ALL=C comm -23 "$SC/want" "$SC/have" > "$SC/miss" + n=$(wc -l < "$SC/miss") + echo "close-lock: pass $pass — dangling refs: $n" + [ "$n" -eq 0 ] && break + : > "$SC/new" + while read -r k; do + [ -n "$k" ] || continue + owner="${k%%/*}"; rest="${k#*/}"; name="${rest%@*}"; ref="${k##*@}" + ids=$("$GH_BIN" api "repos/$owner/$name" --jq '"\(.owner.id) \(.id)"') || ids="" + if ! printf '%s' "$ids" | grep -qxE '[0-9]+ [0-9]+'; then + echo " SKIP $k (repo lookup failed or malformed)"; skipped=$((skipped + 1)); continue + fi + sha="" + if printf '%s' "$ref" | grep -qxE '[0-9a-f]{40}'; then sha="$ref" + else sha=$("$GH_BIN" api "repos/$owner/$name/commits/$ref" --jq .sha) || sha=""; fi + if ! printf '%s' "$sha" | grep -qxE '[0-9a-f]{40}'; then + echo " SKIP $k (cannot resolve commit)"; skipped=$((skipped + 1)); continue + fi + printf " '%s':\n ref: '%s'\n commit: 'sha1-%s'\n owner_id: %s\n repo_id: %s\n" \ + "$k" "$ref" "$sha" "${ids%% *}" "${ids##* }" >> "$SC/new" + echo " + $k" + done < "$SC/miss" + [ -s "$SC/new" ] || break + awk -v newf="$SC/new" ' + BEGIN { nn=0; while ((getline l < newf) > 0) { + if (l ~ /^ '\''[^'\'']+'\'':$/) { nn++; k=l; sub(/^ '\''/,"",k); sub(/'\'':$/,"",k); NK[nn]=k; NB[nn]=l } + else NB[nn] = NB[nn] "\n" l } } + function emit_lt(key, i) { for (i=1;i<=nn;i++) if (!used[i] && NK[i] < key) { print NB[i]; used[i]=1 } } + /^dependencies:[[:space:]]*$/ { print; indep=1; next } + indep && /^[a-z_]+:/ { for (i=1;i<=nn;i++) if (!used[i]) { print NB[i]; used[i]=1 } indep=0; print; next } + indep && /^ '\''[^'\'']+'\'':$/ { k=$0; sub(/^ '\''/,"",k); sub(/'\'':$/,"",k); emit_lt(k); print; next } + { print } + END { if (indep) for (i=1;i<=nn;i++) if (!used[i]) print NB[i] }' "$L" > "$SC/new.lock" && cat "$SC/new.lock" > "$L" +done +if [ "$skipped" -gt 0 ]; then + echo "close-lock: $skipped dangling ref(s) could not be resolved; lock is NOT closed" >&2 + exit 3 +fi +echo "close-lock: done" diff --git a/scripts/complete-job-refs.sh b/scripts/complete-job-refs.sh new file mode 100755 index 000000000..e101cf5a8 --- /dev/null +++ b/scripts/complete-job-refs.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) +# +# complete-job-refs.sh — add job-level reusable-workflow refs to actions.lock. +# `gh actions-lock` v0.1.6 is blind to job-level `uses:` (upstream #129), so the +# entries it cannot write are written here. Idempotent; run from a repo root. +# +# Lock repair order (each step idempotent, run from a repo root): +# gh actions-lock → scripts/relock-sha-keys.sh → scripts/complete-job-refs.sh +# → scripts/close-lock.sh → scripts/prune-stale.sh +# relock-sha-keys re-keys tag-spelled entries to the inline SHA the YAML pins +# (GitHub compares lock and YAML as LITERAL strings); complete-job-refs adds the +# job-level reusable refs the tool cannot see; close-lock adds a record for every +# dangling edge; prune-stale drops per-workflow refs nothing uses. +set -euo pipefail +WF="${1:-.github/workflows}" +L="$WF/actions.lock" +[ -f "$L" ] || { echo "complete-job-refs: no $L" >&2; exit 1; } +TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT +WANT="$TMP/want"; NEW="$TMP/new.lock" +# 1. enumerate job-level refs actually present in the YAML, as "pathowner/repo@ref" +: > "$WANT" +for f in "$WF"/*.yml "$WF"/*.yaml; do + [ -e "$f" ] || continue + b=".github/workflows/$(basename "$f")" + set +e + sed 's/[[:space:]]\+#.*$//' "$f" \ + | grep -E '^[[:space:]]*(-[[:space:]]+)?uses:[[:space:]]' \ + | sed -E 's/^[[:space:]]*(-[[:space:]]+)?uses:[[:space:]]+//' \ + | tr -d '"'"'"'' | awk '{print $1}' \ + | grep -E '/\.github/workflows/' \ + | while read -r u; do + case "$u" in ./*|\$/*|docker://*|'') continue;; esac + case "$u" in *@*) : ;; *) continue;; esac + p="${u%@*}"; r="${u##*@}" + o=$(printf '%s' "$p" | cut -d/ -f1); n=$(printf '%s' "$p" | cut -d/ -f2) + printf '%s\t%s/%s@%s\n' "$b" "$o" "$n" "$r" + done + set -e +done | LC_ALL=C sort -u >> "$WANT" + +# 2. rewrite the workflows: section, merging wanted job refs into each path entry +LC_ALL=C awk -v want="$WANT" ' +BEGIN { while ((getline l < want) > 0) { split(l, w, "\t"); WANT[w[1]] = WANT[w[1]] SUBSEP w[2] } } +function flush( i, n, arr, j, k, seen, out, c) { + if (cur == "") return + SEEN[cur] = 1 + n = split(items, arr, SUBSEP) + c = 0; delete seen; delete out + for (i = 1; i <= n; i++) if (arr[i] != "" && !(arr[i] in seen)) { seen[arr[i]] = 1; out[++c] = arr[i] } + if (cur in WANT) { n = split(WANT[cur], arr, SUBSEP) + for (i = 1; i <= n; i++) if (arr[i] != "" && !(arr[i] in seen)) { seen[arr[i]] = 1; out[++c] = arr[i] } } + if (c == 0) { printf " '\''%s'\'': []\n", cur; cur = ""; items = ""; return } + for (i = 1; i < c; i++) for (j = i + 1; j <= c; j++) if (out[j] < out[i]) { k = out[i]; out[i] = out[j]; out[j] = k } + printf " '\''%s'\'':\n", cur + for (i = 1; i <= c; i++) printf " - '\''%s'\''\n", out[i] + cur = ""; items = "" +} +function rest( k, ks, c, i, j, t) { # wanted paths the lock has no entry for yet + c = 0; for (k in WANT) if (!(k in SEEN)) ks[++c] = k + for (i = 1; i < c; i++) for (j = i + 1; j <= c; j++) if (ks[j] < ks[i]) { t = ks[i]; ks[i] = ks[j]; ks[j] = t } + for (i = 1; i <= c; i++) { cur = ks[i]; items = ""; flush() } +} +/^workflows:[[:space:]]*$/ { print; inwf = 1; sawwf = 1; next } +inwf && /^[a-z_]+:/ { flush(); rest(); inwf = 0; print; next } +inwf { + if (match($0, /^ '\''[^'\'']+'\'':/)) { + flush() + cur = $0; sub(/^ '\''/, "", cur); sub(/'\''.*$/, "", cur) + items = "" + next + } + if (match($0, /^ - '\''[^'\'']+'\''[[:space:]]*$/)) { + it = $0; sub(/^ - '\''/, "", it); sub(/'\''[[:space:]]*$/, "", it) + items = items SUBSEP it; next + } + next +} +{ print } +END { + if (inwf) { flush(); rest() } + if (!sawwf) for (k in WANT) { print "complete-job-refs: lock has no workflows: section" > "/dev/stderr"; exit 2 } +} +' "$L" > "$NEW" + +added=$(LC_ALL=C comm -13 <(grep -oE "^ - '[^']+'" "$L" | sed "s/.*- '//;s/'$//" | LC_ALL=C sort -u) \ + <(grep -oE "^ - '[^']+'" "$NEW" | sed "s/.*- '//;s/'$//" | LC_ALL=C sort -u) | wc -l) +cat "$NEW" > "$L" +echo "complete-job-refs: distinct refs newly present in workflows section: $added" diff --git a/scripts/prune-stale.sh b/scripts/prune-stale.sh new file mode 100755 index 000000000..f4bc8f679 --- /dev/null +++ b/scripts/prune-stale.sh @@ -0,0 +1,173 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) +# +# prune-stale.sh — remove workflow-entry refs from .github/workflows/actions.lock +# that no `uses:` in that workflow references. +# +# ── Why pruning, and not demoting the check ───────────────────────────────── +# The per-repo lock-sync gate (check-lock-sync.sh) clause 2 hard-FAILs on +# "stale lockfile entries, no uses: references them". Measured 2026-09-22 on +# hyperpolymath/my-lang, that condition is NOT fatal to GitHub: six workflows there share +# the identical stale entry 'hyperpolymath/standards@08586a12…' and produce +# THREE different outcomes — +# secret-scanner + spark-theatre-gate success x3, governance + hypatia-scan +# failure x3 (jobs>0), mirror + scorecard startup_failure x3. One property, +# three outcomes, so the property is not the cause. +# +# So the gate reds on a harmless condition. There are two cures; this is the +# one that repairs rather than silences: make the lock true, and leave the gate +# strict. A `::warning::` or a demotion to advisory would be a vacuous gate. +# +# ── Why pruning is safe ───────────────────────────────────────────────────── +# A workflow entry is a per-path list of the refs GitHub validates for THAT +# workflow. Transitive dependencies live in the top-level `dependencies:` map, +# never in a per-workflow list, so removing a per-workflow ref cannot open a +# dangling edge — and check-lock-sync clause 3 re-verifies closure afterwards +# regardless. An entry whose ref is referenced by no `uses:` in its own file +# cannot be satisfying anything there. +# +# ── Two independent oracles must agree before a ref is dropped ────────────── +# 1. the PARSER: no `uses:` in that workflow normalises to this owner/repo@ref +# (same norm()/ck() semantics as check-lock-sync.sh); +# 2. the LITERAL GREP: the exact ref string does not occur anywhere in the +# workflow file, comments included (`grep -F`). +# Disagreement means KEEP. A parser blind spot therefore cannot delete a needed +# entry (scripts/tests/prune-stale-test.sh plants exactly that blind spot). +# +# Lock repair order: gh actions-lock → relock-sha-keys.sh → complete-job-refs.sh +# → close-lock.sh → prune-stale.sh (see complete-job-refs.sh). +# +# Exit 0 whether or not anything was pruned; exit 1 only on a structural error. + +set -euo pipefail + +WF_DIR="${1:-.github/workflows}" +LOCK="$WF_DIR/actions.lock" + +AWK="" +for cand in gawk awk; do + if command -v "$cand" >/dev/null 2>&1 \ + && echo x | "$cand" '{ if (match($0, /(x)/, m) && m[1] == "x") exit 0; exit 1 }' 2>/dev/null; then + AWK="$cand"; break + fi +done +[ -n "$AWK" ] || { echo "prune-stale: FATAL: need gawk (3-arg match)" >&2; exit 1; } +[ -f "$LOCK" ] || { echo "prune-stale: FATAL: no lockfile at $LOCK" >&2; exit 1; } + +shopt -s nullglob +mapfile -t WORKFLOWS < <(printf '%s\n' "$WF_DIR"/*.yml "$WF_DIR"/*.yaml | sort -u) +[ "${#WORKFLOWS[@]}" -gt 0 ] || { echo "prune-stale: FATAL: no workflows under $WF_DIR" >&2; exit 1; } + +read -r -d '' CAND <<'AWK' || true +function norm(r, at, path, ref, n, parts) { + at = 0 + for (n = length(r); n > 0; n--) { if (substr(r, n, 1) == "@") { at = n; break } } + if (at == 0) return "" + path = substr(r, 1, at - 1); ref = substr(r, at + 1) + if (path == "" || ref == "") return "" + if (substr(path, 1, 2) == "./" || substr(path, 1, 2) == "$/") return "" + if (split(path, parts, "/") < 2) return "" + return parts[1] "/" parts[2] "@" ref +} +function ck(r, at, s) { + at = 0 + for (s = length(r); s > 0; s--) { if (substr(r, s, 1) == "@") { at = s; break } } + if (at == 0) return tolower(r) + return tolower(substr(r, 1, at - 1)) substr(r, at) +} +FILENAME == lockfile { + if ($0 ~ /^workflows:[[:space:]]*$/) { inwf = 1; next } + if ($0 ~ /^[a-z_]+:/) { inwf = 0; next } + if (!inwf) next + if (match($0, /^ '([^']+)':/, m)) { cur = m[1]; next } + if (match($0, /^ - '([^']+)'[[:space:]]*$/, m) && cur != "") { + lockref[cur, ck(m[1])] = m[1] + } + next +} +FNR == 1 { wf = FILENAME; key = wf; sub(/.*\//, "", key); key = ".github/workflows/" key; pathof[wf] = key } +{ + line = $0 + sub(/[[:space:]]+#.*$/, "", line) + if (match(line, /^[[:space:]]*-?[[:space:]]*uses:[[:space:]]*(.+)$/, m)) { + raw = m[1]; gsub(/^["']|["']$/, "", raw); gsub(/[[:space:]]+$/, "", raw) + n = norm(raw) + if (n != "") used[pathof[wf], ck(n)] = 1 + } +} +END { + for (k in lockref) { + split(k, kp, SUBSEP) + if (!(k in used)) printf "%s\t%s\n", kp[1], lockref[k] + } +} +AWK + +TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT +"$AWK" -v lockfile="$LOCK" "$CAND" "$LOCK" "${WORKFLOWS[@]}" | LC_ALL=C sort -u > "$TMP/cand.tsv" + +if [ ! -s "$TMP/cand.tsv" ]; then + echo "prune-stale: no stale workflow-entry refs; lockfile unchanged" + exit 0 +fi + +# ── oracle 2: the literal grep. Disagreement => KEEP. ────────────────────── +: > "$TMP/drop.tsv" +kept_by_oracle=0 +while IFS=$'\t' read -r path ref; do + wf="$WF_DIR/${path##*/}" + if [ -f "$wf" ] && command grep -Fq -- "$ref" "$wf"; then + printf ' ! oracle disagrees, KEEPING %s in %s (literal string present)\n' "$ref" "$path" + kept_by_oracle=$((kept_by_oracle + 1)) + continue + fi + printf '%s\t%s\n' "$path" "$ref" >> "$TMP/drop.tsv" +done < "$TMP/cand.tsv" + +if [ ! -s "$TMP/drop.tsv" ]; then + echo "prune-stale: every candidate was vetoed by the literal-grep oracle; lockfile unchanged" + exit 0 +fi + +read -r -d '' APPLY <<'AWK' || true +NR == FNR { drop[$1 SUBSEP $2] = 1; next } +{ + if ($0 ~ /^workflows:[[:space:]]*$/) { inwf = 1; print; next } + if ($0 ~ /^[a-z_]+:/) { inwf = 0; print; next } + if (!inwf) { print; next } + if (match($0, /^ '([^']+)':/, m)) { cur = m[1]; print; next } + if (match($0, /^ - '([^']+)'[[:space:]]*$/, m) && cur != "") { + if ((cur SUBSEP m[1]) in drop) { pruned++; next } + } + print +} +END { printf "prune-stale: pruned %d stale workflow-entry ref(s)\n", pruned + 0 > "/dev/stderr" } +AWK + +"$AWK" -F'\t' "$APPLY" "$TMP/drop.tsv" "$LOCK" > "$TMP/new.lock" + +# An entry left with no items must become an explicit empty list, or the YAML +# key would take a null value and the schema would reject it. +"$AWK" ' + # Scoped strictly to the workflows: section. A dependencies: record uses the + # same " '\''key'\'':" shape but its children are " ref:" style, so an + # unscoped pass would rewrite every dependency record to ": []" and orphan its + # children. That bug was caught by the gitbot-fleet test, not by review. + { lines[NR] = $0 } + END { + inwf = 0 + for (i = 1; i <= NR; i++) { + if (lines[i] ~ /^workflows:[[:space:]]*$/) { inwf = 1; print lines[i]; continue } + else if (lines[i] ~ /^[a-z_]+:/) { inwf = 0; print lines[i]; continue } + if (inwf && match(lines[i], /^ '\''[^'\'']+'\'':[[:space:]]*$/) \ + && (i == NR || lines[i+1] !~ /^ - /)) { + sub(/:[[:space:]]*$/, ": []", lines[i]) + } + print lines[i] + } + }' "$TMP/new.lock" > "$TMP/new2.lock" + +if command -v diff >/dev/null 2>&1; then diff -u "$LOCK" "$TMP/new2.lock" | sed 's/^/ | /' || true; fi +cat "$TMP/new2.lock" > "$LOCK" +echo "prune-stale: done (oracle vetoes: $kept_by_oracle)" diff --git a/scripts/tests/close-lock-test.sh b/scripts/tests/close-lock-test.sh new file mode 100755 index 000000000..13aaa4145 --- /dev/null +++ b/scripts/tests/close-lock-test.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) +# +# close-lock-test.sh — fixture suite for scripts/close-lock.sh. Offline: a stub +# `gh` (GH_BIN) answers the two API calls the script makes. The planted +# positives are the two ways a lookup goes wrong — an HTTP error and a body of +# the wrong shape — and both must SKIP with exit 3 and write NO record. +# +# Run: bash scripts/tests/close-lock-test.sh +# TARGET= runs the suite against another copy (used for mutant checks). +set -uo pipefail +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +TARGET="${TARGET:-$SCRIPT_DIR/../close-lock.sh}" +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT +pass=0; fail=0 +A=1111111111111111111111111111111111111111 +B=2222222222222222222222222222222222222222 +C=3333333333333333333333333333333333333333 + +ok() { echo "PASS: $1"; pass=$((pass + 1)); } +bad() { echo "FAIL: $1"; fail=$((fail + 1)); } + +# Stub: repos// → "7 9"; commits/v1 → $C; anything under o/missing → an +# HTTP error body on stdout, exit 1; anything under o/garbled → a truncated body +# on stdout, exit 0 (the {"messa shape AGENTS.md §5 records). +cat > "$WORK/gh" < ; dependencies come from stdin +mklock() { + local d="$WORK/$1/.github/workflows"; shift; mkdir -p "$d" + { echo 'workflows:'; echo " '.github/workflows/ci.yml':" + for r in "$@"; do echo " - '$r'"; done + echo 'dependencies:'; cat; } > "$d/actions.lock" + printf '%s' "$d" +} +run() { (cd "$1/../.." && GH_BIN="$WORK/gh" bash "$TARGET") 2>&1; } + +echo "=== closes a dangling workflows: edge ===" +d=$(mklock t1 "o/a@$A" /dev/null +grep -qxF " ref: 'v1'" "$d/actions.lock" && grep -qxF " commit: 'sha1-$C'" "$d/actions.lock" \ + && ok "tag ref recorded with its resolved commit" || bad "tag ref recorded with its resolved commit" + +echo "=== closes a nested edge of an existing record ===" +d=$(mklock t3 "o/a@$A" </dev/null +grep -qxF " 'o/b@$B':" "$d/actions.lock" && ok "nested ref gets a record" || bad "nested ref gets a record" +[ "$(grep -oE "^ 'o/[^']+'" "$d/actions.lock" | tr -d " '" | tr '\n' ,)" = "o/a@$A,o/b@$B," ] \ + && ok "records stay in key order" || bad "records stay in key order" + +echo "=== planted positives: a failed or malformed lookup writes nothing ===" +d=$(mklock t4 "o/missing@$A" /dev/null 2>&1; st=$? +[ "$st" = 1 ] && ok "no lockfile → exit 1" || bad "no lockfile → exit 1 (got $st)" + +echo "close-lock-test: $pass passed, $fail failed" +[ "$fail" -eq 0 ] diff --git a/scripts/tests/complete-job-refs-test.sh b/scripts/tests/complete-job-refs-test.sh new file mode 100755 index 000000000..ff5825ff1 --- /dev/null +++ b/scripts/tests/complete-job-refs-test.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) +# +# complete-job-refs-test.sh — fixture suite for scripts/complete-job-refs.sh. +# The planted positive is a job-level reusable ref that `gh actions-lock` +# v0.1.6 does not write; the negatives are refs that must NOT be added (a +# comment, a local `./` reusable, a step-level action). +# +# Run: bash scripts/tests/complete-job-refs-test.sh +# TARGET= runs the suite against another copy (used for mutant checks). +set -uo pipefail +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +TARGET="${TARGET:-$SCRIPT_DIR/../complete-job-refs.sh}" +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT +pass=0; fail=0 +A=1111111111111111111111111111111111111111 +S=5555555555555555555555555555555555555555 + +ok() { echo "PASS: $1"; pass=$((pass + 1)); } +bad() { echo "FAIL: $1"; fail=$((fail + 1)); } +run() { (cd "$WORK/$1" && bash "$TARGET") 2>&1; } + +mkrepo() { + local d="$WORK/$1/.github/workflows"; mkdir -p "$d" + cat > "$d/ci.yml" < "$d/other.yml" +} + +echo "=== adds the job-level refs, and nothing else ===" +mkrepo t1 +cat > "$WORK/t1/.github/workflows/actions.lock" < "$WORK/t2/.github/workflows/actions.lock" </dev/null +L="$WORK/t2/.github/workflows/actions.lock" +grep -qxF " '.github/workflows/other.yml':" "$L" && grep -qxF " - 'o/r@v2'" "$L" \ + && ok "missing workflow entry is created" || bad "missing workflow entry is created" +[ "$(sed -n '/^dependencies:/=' "$L")" -gt "$(sed -n "/other.yml/=" "$L")" ] \ + && ok "created entry stays inside workflows:" || bad "created entry stays inside workflows:" + +echo "=== structural ===" +mkrepo t3; printf 'dependencies:\n' > "$WORK/t3/.github/workflows/actions.lock" +run t3 >/dev/null; st=$? +[ "$st" != 0 ] && grep -qxF 'dependencies:' "$WORK/t3/.github/workflows/actions.lock" \ + && ok "no workflows: section → non-zero, lock unchanged" || bad "no workflows: section → non-zero, lock unchanged (st=$st)" +mkdir -p "$WORK/t4/.github/workflows"; run t4 >/dev/null; st=$? +[ "$st" = 1 ] && ok "no lockfile → exit 1" || bad "no lockfile → exit 1 (got $st)" + +echo "complete-job-refs-test: $pass passed, $fail failed" +[ "$fail" -eq 0 ] diff --git a/scripts/tests/prune-stale-test.sh b/scripts/tests/prune-stale-test.sh new file mode 100755 index 000000000..1a23065f8 --- /dev/null +++ b/scripts/tests/prune-stale-test.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) +# +# prune-stale-test.sh — fixture suite for scripts/prune-stale.sh. The planted +# positive is a stale per-workflow ref that must be dropped; the planted parser +# blind spot is a ref whose exact string survives only in a comment, which the +# parser cannot see and the literal-grep oracle must veto. +# +# Run: bash scripts/tests/prune-stale-test.sh +# TARGET= runs the suite against another copy (used for mutant checks). +set -uo pipefail +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +TARGET="${TARGET:-$SCRIPT_DIR/../prune-stale.sh}" +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT +pass=0; fail=0 +A=1111111111111111111111111111111111111111 +B=2222222222222222222222222222222222222222 +S=5555555555555555555555555555555555555555 + +ok() { echo "PASS: $1"; pass=$((pass + 1)); } +bad() { echo "FAIL: $1"; fail=$((fail + 1)); } + +d="$WORK/r/.github/workflows"; mkdir -p "$d" +cat > "$d/ci.yml" < "$d/old.yml" <<'EOF' +jobs: + a: + steps: + - run: true # previously o/kept@v1 +EOF +cat > "$d/actions.lock" < "$d/empty.yml" +L="$d/actions.lock" + +out=$(cd "$WORK/r" && bash "$TARGET" 2>&1); st=$? +[ "$st" = 0 ] && ok "exit 0" || bad "exit 0 (got $st) — $out" +! grep -qF " - 'actions/setup-node@$A'" "$L" && ok "stale step ref pruned (planted positive)" || bad "stale step ref pruned (planted positive)" +grep -qxF " - 'actions/checkout@$A'" "$L" && ok "used step ref kept" || bad "used step ref kept" +grep -qxF " - 'github/codeql-action@$B'" "$L" && ok "subpath ref (codeql-action/init) kept" || bad "subpath ref (codeql-action/init) kept" +grep -qxF " - 'hyperpolymath/standards@$S'" "$L" && ok "job-level reusable ref kept" || bad "job-level reusable ref kept" +grep -qxF " - 'o/kept@v1'" "$L" && grep -qF 'oracle disagrees, KEEPING o/kept@v1' <<<"$out" \ + && ok "comment-only ref vetoed by the literal-grep oracle" || bad "comment-only ref vetoed by the literal-grep oracle" +! grep -qF "o/gone@v1" "$L" && ok "stale refs dropped from both workflows" || bad "stale refs dropped from both workflows" +grep -qxF " '.github/workflows/empty.yml': []" "$L" && ok "emptied entry becomes []" || bad "emptied entry becomes []" +grep -qxF " 'actions/setup-node@$A':" "$L" && grep -qxF " commit: 'sha1-$A'" "$L" \ + && ok "dependencies: records untouched" || bad "dependencies: records untouched" +cp "$L" "$WORK/before"; out=$(cd "$WORK/r" && bash "$TARGET" 2>&1) +cmp -s "$L" "$WORK/before" && ok "idempotent: second run changes nothing" || bad "idempotent: second run changes nothing — $out" + +mkdir -p "$WORK/n/.github/workflows"; (cd "$WORK/n" && bash "$TARGET") >/dev/null 2>&1; st=$? +[ "$st" = 1 ] && ok "no lockfile → exit 1" || bad "no lockfile → exit 1 (got $st)" + +echo "prune-stale-test: $pass passed, $fail failed" +[ "$fail" -eq 0 ] From 67458731a998f409ab78af942b81a72f4333fb6d Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Thu, 1 Oct 2026 23:16:26 +0100 Subject: [PATCH 2/2] docs(scripts): docstring every function in the lock generators MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AGENTS.md §5d: each shell and awk function added by the generator commit now carries a one-line comment block stating what it does. No behaviour change; all three fixture suites still pass. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01X3hgXxWm6umMgZkjYyHnnm --- scripts/close-lock.sh | 4 ++++ scripts/complete-job-refs.sh | 7 ++++--- scripts/tests/close-lock-test.sh | 3 +++ scripts/tests/complete-job-refs-test.sh | 4 ++++ scripts/tests/prune-stale-test.sh | 2 ++ 5 files changed, 17 insertions(+), 3 deletions(-) diff --git a/scripts/close-lock.sh b/scripts/close-lock.sh index e05815b1a..2dbaa4339 100755 --- a/scripts/close-lock.sh +++ b/scripts/close-lock.sh @@ -29,8 +29,11 @@ L="$WF/actions.lock" GH_BIN="${GH_BIN:-gh}" [ -f "$L" ] || { echo "close-lock: no $L" >&2; exit 1; } SC="$(mktemp -d)"; trap 'rm -rf "$SC"' EXIT +# keys: list the dependency record keys already present in the lock. keys() { awk '/^dependencies:/{f=1;next} f&&match($0,/^ '\''[^'\'']+'\'':$/){k=$0;sub(/^ '\''/,"",k);sub(/'\'':$/,"",k);print k}' "$L" | LC_ALL=C sort -u; } +# nested: list the refs that dependency records themselves depend on. nested() { awk '/^dependencies:/{f=1} f&&match($0,/^ - '\''[^'\'']+'\''/){k=$0;sub(/^ - '\''/,"",k);sub(/'\''.*$/,"",k);print k}' "$L" | LC_ALL=C sort -u; } +# wfrefs: list every ref named under the workflows: section. wfrefs() { awk '/^workflows:/{f=1;next} f&&/^[a-z_]+:/{f=0} f&&match($0,/^ - '\''[^'\'']+'\''/){k=$0;sub(/^ - '\''/,"",k);sub(/'\''.*$/,"",k);print k}' "$L" | LC_ALL=C sort -u; } skipped=0 @@ -64,6 +67,7 @@ for pass in 1 2 3 4 5; do BEGIN { nn=0; while ((getline l < newf) > 0) { if (l ~ /^ '\''[^'\'']+'\'':$/) { nn++; k=l; sub(/^ '\''/,"",k); sub(/'\'':$/,"",k); NK[nn]=k; NB[nn]=l } else NB[nn] = NB[nn] "\n" l } } + # emit_lt: print each pending new record whose key sorts before key. function emit_lt(key, i) { for (i=1;i<=nn;i++) if (!used[i] && NK[i] < key) { print NB[i]; used[i]=1 } } /^dependencies:[[:space:]]*$/ { print; indep=1; next } indep && /^[a-z_]+:/ { for (i=1;i<=nn;i++) if (!used[i]) { print NB[i]; used[i]=1 } indep=0; print; next } diff --git a/scripts/complete-job-refs.sh b/scripts/complete-job-refs.sh index e101cf5a8..40b93a34b 100755 --- a/scripts/complete-job-refs.sh +++ b/scripts/complete-job-refs.sh @@ -43,6 +43,7 @@ done | LC_ALL=C sort -u >> "$WANT" # 2. rewrite the workflows: section, merging wanted job refs into each path entry LC_ALL=C awk -v want="$WANT" ' BEGIN { while ((getline l < want) > 0) { split(l, w, "\t"); WANT[w[1]] = WANT[w[1]] SUBSEP w[2] } } +# flush: write the current workflow entry, merged with its wanted refs, sorted and deduplicated. function flush( i, n, arr, j, k, seen, out, c) { if (cur == "") return SEEN[cur] = 1 @@ -57,7 +58,8 @@ function flush( i, n, arr, j, k, seen, out, c) { for (i = 1; i <= c; i++) printf " - '\''%s'\''\n", out[i] cur = ""; items = "" } -function rest( k, ks, c, i, j, t) { # wanted paths the lock has no entry for yet +# rest: create entries for wanted workflow paths the lock has no entry for yet. +function rest( k, ks, c, i, j, t) { c = 0; for (k in WANT) if (!(k in SEEN)) ks[++c] = k for (i = 1; i < c; i++) for (j = i + 1; j <= c; j++) if (ks[j] < ks[i]) { t = ks[i]; ks[i] = ks[j]; ks[j] = t } for (i = 1; i <= c; i++) { cur = ks[i]; items = ""; flush() } @@ -65,8 +67,7 @@ function rest( k, ks, c, i, j, t) { # wanted paths the lock has no entry for /^workflows:[[:space:]]*$/ { print; inwf = 1; sawwf = 1; next } inwf && /^[a-z_]+:/ { flush(); rest(); inwf = 0; print; next } inwf { - if (match($0, /^ '\''[^'\'']+'\'':/)) { - flush() + if (match($0, /^ '\''[^'\'']+'\'':/)) { flush() cur = $0; sub(/^ '\''/, "", cur); sub(/'\''.*$/, "", cur) items = "" next diff --git a/scripts/tests/close-lock-test.sh b/scripts/tests/close-lock-test.sh index 13aaa4145..21700f35e 100755 --- a/scripts/tests/close-lock-test.sh +++ b/scripts/tests/close-lock-test.sh @@ -19,7 +19,9 @@ A=1111111111111111111111111111111111111111 B=2222222222222222222222222222222222222222 C=3333333333333333333333333333333333333333 +# ok: record and print a passing assertion. ok() { echo "PASS: $1"; pass=$((pass + 1)); } +# bad: record and print a failing assertion. bad() { echo "FAIL: $1"; fail=$((fail + 1)); } # Stub: repos// → "7 9"; commits/v1 → $C; anything under o/missing → an @@ -46,6 +48,7 @@ mklock() { echo 'dependencies:'; cat; } > "$d/actions.lock" printf '%s' "$d" } +# run: execute the target from the fixture repo root with the stub gh binary. run() { (cd "$1/../.." && GH_BIN="$WORK/gh" bash "$TARGET") 2>&1; } echo "=== closes a dangling workflows: edge ===" diff --git a/scripts/tests/complete-job-refs-test.sh b/scripts/tests/complete-job-refs-test.sh index ff5825ff1..2b8a73c75 100755 --- a/scripts/tests/complete-job-refs-test.sh +++ b/scripts/tests/complete-job-refs-test.sh @@ -18,10 +18,14 @@ pass=0; fail=0 A=1111111111111111111111111111111111111111 S=5555555555555555555555555555555555555555 +# ok: record and print a passing assertion. ok() { echo "PASS: $1"; pass=$((pass + 1)); } +# bad: record and print a failing assertion. bad() { echo "FAIL: $1"; fail=$((fail + 1)); } +# run: execute the target inside fixture repo $1, capturing all output. run() { (cd "$WORK/$1" && bash "$TARGET") 2>&1; } +# mkrepo: create fixture repo $1 with job-level, local, step-level and commented-out refs. mkrepo() { local d="$WORK/$1/.github/workflows"; mkdir -p "$d" cat > "$d/ci.yml" <