diff --git a/scripts/apply-workflow-pins-remote.sh b/scripts/apply-workflow-pins-remote.sh index 0c9ce9bbc..457faf70c 100755 --- a/scripts/apply-workflow-pins-remote.sh +++ b/scripts/apply-workflow-pins-remote.sh @@ -49,6 +49,11 @@ # (6 of 9 campaign heads unsigned). An unsigned applier opens PRs that can # never merge, which is worse than opening none. # * Shell-only: no Python, no Ruby. +# * THE LOCK FOLLOWS THE PINS. A repo's actions.lock is fetched with its +# workflows and re-keyed by scripts/relock-standards-pin.sh in the same +# commit; a lock that cannot follow (fail-closed refusal) means the repo is +# reported LOCK-REFUSED and not landed. Pins without their lock stop the +# workflows at startup, which is worse than leaving them behind. # # Usage: # bash scripts/apply-workflow-pins-remote.sh [options] @@ -76,6 +81,10 @@ ONLY_REPO="" OUT_FILE="" LIMIT=0 BRANCH_NAME="chore/re-point-standards-workflow-pins" +# The standards checkout this script runs from: the relock reads reusables and +# standards' own actions.lock AT THE TARGET from it. +STANDARDS_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +RELOCK="$(dirname "${BASH_SOURCE[0]}")/relock-standards-pin.sh" log() { printf '%s\n' "$*" >&2; } @@ -260,6 +269,16 @@ self_test() { echo " FAIL illegal repair did not yield a legal pin: $(cat "$d/illegal.yml")" >&2; rc=1 fi + # The relock is what keeps a fix-mode PR from desyncing actions.lock; a + # missing relock must stop the run, not silently land pins without locks. + # Its own known-answer suite is scripts/tests/relock-standards-pin-test.sh. + echo "control: the lock relock is present" + if [ -f "$RELOCK" ] && bash -n "$RELOCK"; then + echo " PASS $(basename "$RELOCK") present and parses" + else + echo " FAIL $RELOCK missing or unparseable" >&2; rc=1 + fi + rm -rf "$d" return $rc } @@ -345,14 +364,19 @@ list_repos() { rm -f "$all" "$part" } -# fetch_workflows — download .github/workflows/*.y*ml. +# fetch_workflows — download .github/workflows/*.yml, +# *.yaml and actions.lock, when present, into destdir. # The REMOTE content is the only evidence: a local checkout can be arbitrarily # stale, and reading one is what produced a false "285 callers track main" # census on 2026-09-15. -# Returns non-zero when the answer is UNKNOWN (rate limit, 5xx, a file that -# would not download). Only a 404 is "no workflows". Returning 0 on failure -# made a rate-limited repo vanish from the census while `walked N` still -# counted it (measured 2026-10-02, shared 5,000/h token exhausted mid-run). +# Returns 0 for a successful GraphQL response, including an empty tree. Blobs +# without text are skipped; decoding failures remove the destination file +# without changing the success status. In the REST fallback, returns 0 for +# a missing directory (HTTP 404), and 1 for other listing or download failures; +# files already downloaded remain in destdir on failure. Only a 404 is "no +# workflows": returning 0 on failure made a rate-limited repo vanish from the +# census while `walked N` still counted it (measured 2026-10-02, shared +# 5,000/h token exhausted mid-run). fetch_workflows() { local repo="$1" dest="$2" owner="${1%%/*}" name="${1##*/}" resp mkdir -p "$dest" @@ -379,7 +403,7 @@ fetch_workflows() { done < <(jq -r ' (.data.repository.object.entries // [])[] | select(.type == "blob") - | select(.name | test("\\.ya?ml$")) + | select((.name | test("\\.ya?ml$")) or .name == "actions.lock") | select(.object.text != null) | "\(.name)\t\(.object.text | @base64)"' "$resp" 2>/dev/null) rm -f "$resp" @@ -399,7 +423,7 @@ fetch_workflows() { case "$listing" in *"HTTP 404"*) return 0 ;; esac return 1 fi - for fname in $(printf '%s\n' "$listing" | grep -E '\.ya?ml$'); do + for fname in $(printf '%s\n' "$listing" | grep -E '\.ya?ml$|^actions\.lock$'); do gh api "repos/${repo}/contents/.github/workflows/${fname}" \ -H 'Accept: application/vnd.github.raw' > "${dest}/${fname}" 2>/dev/null \ || { rm -f "${dest}/${fname}"; return 1; } @@ -475,6 +499,14 @@ commit_payload() { # --------------------------------------------------------------------------- # MAIN # --------------------------------------------------------------------------- +# main [options] — audit remote standards pins and write a TSV census to +# stdout, or to --out. Under --fix, fetch the target commit locally if needed +# and create remote branches, commits and PRs for changed workflows and locks. +# Fetch, relock and PR failures become census statuses and processing continues; +# a relock failure prevents that repository's changes from being submitted. +# Recorded repository failures do not cause a non-zero exit status. +# Exits 1 on failed controls, authentication, target validation/fetch or repository +# enumeration, and 2 on an unknown option; --self-test exits with its test status. main() { while [ $# -gt 0 ]; do case "$1" in @@ -505,6 +537,12 @@ main() { TARGET_SHA=$(resolve_target) [ -n "$TARGET_SHA" ] || { log "FATAL: could not resolve a target SHA."; exit 1; } validate_target "$TARGET_SHA" || exit 1 + # The relock reads the target's reusables and lock from this checkout; a + # shallow CI checkout may hold a different commit, so fetch it if needed. + if [ "$DO_FIX" -eq 1 ] && ! git -C "$STANDARDS_ROOT" cat-file -e "${TARGET_SHA}^{commit}" 2>/dev/null; then + git -C "$STANDARDS_ROOT" fetch -q --depth=1 origin "$TARGET_SHA" 2>/dev/null \ + || { log "FATAL: ${TARGET_SHA} is not in ${STANDARDS_ROOT} and could not be fetched; the lock relock needs it."; exit 1; } + fi log "== target: ${TARGET_SHA} (reachable from ${STANDARDS_REPO} main) ==" [ "$DO_FIX" -eq 1 ] && log "== MODE: --fix (will open PRs) ==" || log "== MODE: audit (writes nothing) ==" @@ -557,6 +595,23 @@ main() { shopt -u nullglob [ "$found" -eq 0 ] && continue + # The lock must follow the pins. Re-pointing `uses:` alone leaves + # actions.lock keyed to the old standards commit: check-lock-sync.sh + # rejects it and GitHub refuses the run at startup (jobs=0). Measured + # 2026-10-14: 200 of 368 governance callers carry a lock, and this applier + # used to fetch and commit only *.yml, so every fix-mode PR to one of them + # would have stopped its reusable-calling workflows. relock is fail-closed; + # a repo whose lock cannot follow is reported and NOT landed. + if [ "$DO_FIX" -eq 1 ] && [ "${#changed[@]}" -gt 0 ] && [ -f "$rdir/actions.lock" ]; then + local lock_before; lock_before=$(cat "$rdir/actions.lock") + if ! bash "$RELOCK" --to "$TARGET_SHA" --standards "$STANDARDS_ROOT" "$rdir" >&2; then + log " LOCK-REFUSED ${repo}: actions.lock cannot follow the new pins; NOT landed" + printf '%s\t-\tLOCK-REFUSED\t-\n' "$repo" >> "$tsv" + continue + fi + [ "$lock_before" != "$(cat "$rdir/actions.lock")" ] && changed+=("actions.lock") + fi + if [ "$DO_FIX" -eq 1 ] && [ "${#changed[@]}" -gt 0 ]; then local url if url=$(land_pr "$repo" "$rdir" "${changed[@]}"); then diff --git a/scripts/propagate-workflow-pins.sh b/scripts/propagate-workflow-pins.sh index e9d558298..4e4eef8ba 100755 --- a/scripts/propagate-workflow-pins.sh +++ b/scripts/propagate-workflow-pins.sh @@ -54,6 +54,7 @@ set -euo pipefail # --------------------------------------------------------------------------- FIX_MODE=false +LOCK_REFUSED=0 TARGET_SHA="${STANDARDS_TARGET_SHA:-}" BRANCH="chore/bump-standards-pins" TARGET_PATH="." @@ -174,6 +175,16 @@ rewrite_file() { # Per-repo processing # --------------------------------------------------------------------------- +# process_repo REPO — print an audit of matching standards SHA pins in the +# local checkout's workflows; do nothing if its workflows directory is absent. +# Under --fix, rewrite pins to TARGET_SHA and relock an existing actions.lock +# when workflows change. A relock failure increments LOCK_REFUSED for the +# script's final exit status, restores each rewritten workflow to its content +# before this run (uncommitted edits included), and skips branch selection and +# staging. Otherwise, selects BRANCH and stages the entire workflows directory +# when pins change in a Git checkout; branch/staging failures are ignored. +# Returns 0 after processing without committing or pushing. Temporary directory +# creation, workflow backup, rewrite or restoration errors terminate the script. process_repo() { local repo="$1" name name="$(basename "$repo")" @@ -181,7 +192,9 @@ process_repo() { [ -d "$wfdir" ] || return 0 local total_behind=0 files_touched=0 - local f base behind + local f base behind orig="" + # The originals of the files rewritten below, restored if the lock refuses. + if $FIX_MODE; then orig="$(mktemp -d)"; fi for f in "$wfdir"/*.yml "$wfdir"/*.yaml; do [ -f "$f" ] || continue grep -qE "$PIN_RE" "$f" || continue @@ -192,6 +205,7 @@ process_repo() { total_behind=$((total_behind + behind)) files_touched=$((files_touched + 1)) if $FIX_MODE; then + cp -p "$f" "$orig/$base" rewrite_file "$f" fi else @@ -199,6 +213,27 @@ process_repo() { fi done + # The lock must follow the pins. Rewriting `uses:` alone leaves actions.lock + # keyed to the old standards commit, which check-lock-sync.sh rejects and + # GitHub refuses at startup (jobs=0). relock-standards-pin.sh is fail-closed: + # it leaves the lock untouched and exits 1 when it cannot relock correctly. + if $FIX_MODE && [ "$files_touched" -gt 0 ] && [ -f "$wfdir/actions.lock" ]; then + if bash "$(dirname "${BASH_SOURCE[0]}")/relock-standards-pin.sh" --to "$TARGET_SHA" "$wfdir" >&2; then + printf '%-30s\t%-28s\t%s\t-> %s\n' "$name" "actions.lock" "RELOCKED" "${TARGET_SHA:0:12}" + else + printf '%-30s\t%-28s\t%s\t-> %s\n' "$name" "actions.lock" "LOCK-REFUSED" "${TARGET_SHA:0:12}" + LOCK_REFUSED=$((LOCK_REFUSED + 1)) + # New pins over the old lock is the state the relock exists to prevent: + # put this repo's workflows back as they were, and leave its index and + # branch untouched. + cp -p "$orig"/* "$wfdir"/ + rm -rf "$orig" + log " [not staged] $name — lock refused; workflows restored" + return 0 + fi + fi + [ -z "$orig" ] || rm -rf "$orig" + if $FIX_MODE && [ "$files_touched" -gt 0 ] && git -C "$repo" rev-parse --git-dir >/dev/null 2>&1; then local current_branch current_branch=$(git -C "$repo" branch --show-current 2>/dev/null || echo "") @@ -243,3 +278,8 @@ if $FIX_MODE; then else echo "# Re-run with --fix to stage the bump on a branch (no commit/push)." fi +if [ "$LOCK_REFUSED" -gt 0 ]; then + echo "# LOCK-REFUSED in $LOCK_REFUSED repo(s): actions.lock could not follow the pins; workflows restored, staging skipped." + echo "# See relock-standards-pin.sh's message above." + exit 1 +fi diff --git a/scripts/relock-standards-pin.sh b/scripts/relock-standards-pin.sh new file mode 100755 index 000000000..e8f1d6156 --- /dev/null +++ b/scripts/relock-standards-pin.sh @@ -0,0 +1,183 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) +# +# relock-standards-pin.sh — move a consumer's actions.lock to the standards +# commit its reusable-workflow pins were just re-pointed at. +# +# WHY: both pin bumpers (propagate-workflow-pins.sh and the scheduled +# apply-workflow-pins-remote.sh) rewrite `uses: hyperpolymath/standards/...@SHA` +# in workflow YAML and nothing else. In a repo with an actions.lock that leaves +# the lock keyed to the OLD standards commit: per-workflow entries name a ref no +# `uses:` carries any more, and the standards dependency record still lists the +# actions the OLD reusables used. check-lock-sync.sh fails, and GitHub refuses +# such runs at startup (jobs=0; mirror.yml, standards 2026-09-22). Measured +# 2026-10-14: 200 of 368 governance callers carry an actions.lock, so a fix-mode +# applier run would have broken their reusable-calling workflows. +# `gh actions-lock` cannot repair it: v0.1.6 is blind to job-level `uses:`. +# +# WHAT IT WRITES, and nothing else: +# * per-workflow entries hyperpolymath/standards@OLD -> @TARGET; +# * the hyperpolymath/standards@TARGET dependency record, whose nested `uses:` +# is every action the called reusables use AT TARGET (owner/repo@ref); +# * records those actions need and the lock lacks, copied verbatim from +# standards' own actions.lock at TARGET; +# * removal of the OLD standards records, and of records that only they +# kept alive (nothing else is pruned); +# * dependency records in C-locale key order, as gh actions-lock writes them. +# +# A short pin that prefixes the target (as the applier leaves a FRESH one) is +# current: its own short-keyed record is left alone. +# +# FAIL-CLOSED: it works on a copy and leaves the lock untouched (exit 1) when a +# workflow still pins a standards reusable at another SHA, when the called +# reusable is not in standards at TARGET, when the OLD record is missing, +# when a needed record is in neither lock, or when a workflow's standards entries +# would not name exactly the refs it pins. A lock with a dangling edge is +# worse than a stale one: the stale one is at least reported by the gate. +# +# Usage: relock-standards-pin.sh --to [--standards ] +# holds the workflow files and actions.lock: a repo's +# .github/workflows, or the applier's flat fetch directory. +# --standards a standards git checkout that contains ; default +# $STANDARDS_DIR, else the checkout this script lives in. +# Exit: 0 relocked or nothing to do; 1 refused (lock untouched); 2 usage. +set -euo pipefail + +TARGET="" SROOT="${STANDARDS_DIR:-}" WF="" +while [ $# -gt 0 ]; do + case "$1" in + --to) TARGET="${2:-}"; shift 2 ;; + --to=*) TARGET="${1#*=}"; shift ;; + --standards) SROOT="${2:-}"; shift 2 ;; + -h|--help) sed -n '2,44p' "$0"; exit 0 ;; + -*) echo "relock-standards-pin: unknown option $1" >&2; exit 2 ;; + *) WF="$1"; shift ;; + esac +done +[ -n "$SROOT" ] || SROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +[[ "$TARGET" =~ ^[0-9a-f]{40}$ ]] || { echo "relock-standards-pin: --to must be a full 40-hex SHA" >&2; exit 2; } +[ -n "$WF" ] && [ -d "$WF" ] || { echo "relock-standards-pin: workflows directory required" >&2; exit 2; } +git -C "$SROOT" cat-file -e "${TARGET}^{commit}" 2>/dev/null \ + || { echo "relock-standards-pin: standards commit $TARGET not in $SROOT" >&2; exit 2; } + +L="$WF/actions.lock" +[ -f "$L" ] || { echo "relock-standards-pin: no actions.lock in $WF; nothing to relock"; exit 0; } +# refuse REASON... — report why the relock is refused and exit 1, lock untouched. +refuse() { echo "relock-standards-pin: REFUSED ($*); $L left untouched" >&2; exit 1; } + +PIN_RE='hyperpolymath/standards/\.github/workflows/[A-Za-z0-9._-]+\.ya?ml@[0-9a-fA-F]+' +shopt -s nullglob +wfs=("$WF"/*.yml "$WF"/*.yaml) +shopt -u nullglob +pins=$(grep -hoE "$PIN_RE" "${wfs[@]}" 2>/dev/null | sort -u || true) +# A short pin (7+ hex) that prefixes TARGET names TARGET: the applier counts it +# FRESH and leaves it as written, and the lock keys it by that short ref in a +# record of its own, which is left as it is. Only full pins feed TARGET's record. +full="" stale="" +for p in $pins; do + r=${p##*@} + if [ "$r" = "$TARGET" ]; then full="$full $p" + elif [ "${#r}" -lt 7 ] || [ "${TARGET:0:${#r}}" != "$r" ]; then stale="$stale $p" + fi +done +[ -z "$stale" ] || refuse "workflows still pin other standards commits:$stale" + +olds=$(grep -oE "'hyperpolymath/standards@[0-9a-f]{40}'" "$L" | tr -d "'" | sed 's/.*@//' | sort -u | grep -vx "$TARGET" || true) +if [ -z "$olds" ]; then echo "relock-standards-pin: lock already keyed to standards@${TARGET:0:12}"; exit 0; fi +[ -n "$full" ] || refuse "lock names standards commits but no workflow pins standards@${TARGET:0:12} in full" + +tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT +q="'" + +# rec_body KEY FILE — print the dependency record KEY (header and body) from FILE. +# KEY is the unquoted owner/repo@ref. A missing record produces no output and +# succeeds; a file read error propagates awk's non-zero status. +rec_body() { awk -v k=" ${q}$1${q}:" '$0==k{f=1;print;next} f&&/^ [^ ]/{exit} f' "$2"; } + +# Actions the called reusables use at TARGET, normalised to owner/repo@ref. +: > "$tmp/uses" +for p in $full; do + path=${p#hyperpolymath/standards/}; path=${path%@*} + git -C "$SROOT" show "${TARGET}:${path}" > "$tmp/reusable" 2>/dev/null || refuse "$path is not in standards at ${TARGET:0:12}" + sed 's/[[:space:]]#.*$//' "$tmp/reusable" \ + | grep -oE "^[[:space:]]*-?[[:space:]]*uses:[[:space:]]*['\"]?[A-Za-z0-9_.-]+/[A-Za-z0-9_./-]+@[^[:space:]'\"]+" \ + | sed -E "s/^[[:space:]]*-?[[:space:]]*uses:[[:space:]]*['\"]?//; s#^([^/]+/[^/@]+)[^@]*@#\1@#" >> "$tmp/uses" || true +done +grep -v '^hyperpolymath/standards@' "$tmp/uses" | LC_ALL=C sort -u > "$tmp/uses.s" || true + +# owner_id / repo_id come from an OLD record; without one the lock was already dangling. +ids="" +for o in $olds; do ids=$(rec_body "hyperpolymath/standards@$o" "$L" | grep -E '^ (owner_id|repo_id):' || true); [ -n "$ids" ] && break; done +[ -n "$ids" ] || refuse "no hyperpolymath/standards@OLD dependency record to take owner_id/repo_id from" + +# Records the OLD standards records kept alive: the pruning candidates. +: > "$tmp/cand" +for o in $olds; do rec_body "hyperpolymath/standards@$o" "$L" | grep -oE "^ - '[^']+'" | sed -E "s/^ +- '|'$//g" >> "$tmp/cand" || true; done + +cp "$L" "$tmp/lock" +for o in $olds; do + sed -i "s|^ - 'hyperpolymath/standards@$o'\$| - 'hyperpolymath/standards@$TARGET'|" "$tmp/lock" + awk -v k=" ${q}hyperpolymath/standards@$o${q}:" '$0==k{s=1;next} s&&/^ [^ ]/{s=0} !s' "$tmp/lock" > "$tmp/l" && mv "$tmp/l" "$tmp/lock" +done +awk -v k=" ${q}hyperpolymath/standards@$TARGET${q}:" '$0==k{s=1;next} s&&/^ [^ ]/{s=0} !s' "$tmp/lock" > "$tmp/l" && mv "$tmp/l" "$tmp/lock" +{ printf " 'hyperpolymath/standards@%s':\n ref: '%s'\n commit: 'sha1-%s'\n" "$TARGET" "$TARGET" "$TARGET" + printf '%s\n' "$ids" + if [ -s "$tmp/uses.s" ]; then echo " uses:"; sed "s|.*| - '&'|" "$tmp/uses.s"; fi +} >> "$tmp/lock" + +# Records the new pin needs: from this lock, else verbatim from standards' own. +git -C "$SROOT" show "${TARGET}:.github/workflows/actions.lock" > "$tmp/slock" 2>/dev/null || : > "$tmp/slock" +while read -r u; do + [ -n "$u" ] || continue + grep -qF " '$u':" "$tmp/lock" && continue + r=$(rec_body "$u" "$tmp/slock") + [ -n "$r" ] || refuse "no record for $u in this lock or in standards' own lock at ${TARGET:0:12}" + printf '%s\n' "$r" >> "$tmp/lock" +done < "$tmp/uses.s" + +# Prune candidates nothing references any more (to a fixed point). +# deps_line FILE — print the one-based line number of each exact dependencies: +# header. Returns non-zero if no header is found or the file cannot be read. +deps_line() { grep -n '^dependencies:$' "$1" | cut -d: -f1; } +while :; do + n=$(deps_line "$tmp/lock") + grep -oE "^ {8,12}- '[^']+'" "$tmp/lock" | sed -E "s/^ +- '|'$//g" | LC_ALL=C sort -u > "$tmp/edges" + pruned=0 + while read -r c; do + [ -n "$c" ] || continue + grep -qxF "$c" "$tmp/edges" && continue + tail -n +"$((n+1))" "$tmp/lock" | grep -qxF " '$c':" || continue + rec_body "$c" "$tmp/lock" | grep -oE "^ - '[^']+'" | sed -E "s/^ +- '|'$//g" >> "$tmp/cand" || true + awk -v k=" ${q}$c${q}:" '$0==k{s=1;next} s&&/^ [^ ]/{s=0} !s' "$tmp/lock" > "$tmp/l" && mv "$tmp/l" "$tmp/lock" + pruned=1 + done < <(LC_ALL=C sort -u "$tmp/cand") + [ "$pruned" = 1 ] || break +done + +# Dependency records in C-locale key order (portable awk: tag lines, sort, strip). +n=$(deps_line "$tmp/lock") +head -n "$n" "$tmp/lock" > "$tmp/out" +tail -n +"$((n+1))" "$tmp/lock" \ + | awk -v q="$q" 'index($0, " " q) == 1 { key = $0 } { printf "%s\t%08d\t%s\n", key, NR, $0 }' \ + | LC_ALL=C sort -t "$(printf '\t')" -k1,1 -k2,2n | cut -f3- >> "$tmp/out" + +# Closure: every ref the lock names must have a record of its own. +n=$(deps_line "$tmp/out") +grep -oE "^ {8,12}- '[^']+'" "$tmp/out" | sed -E "s/^ +- '|'$//g" | LC_ALL=C sort -u > "$tmp/edges" +tail -n +"$((n+1))" "$tmp/out" | grep -oE "^ '[^']+':" | sed -E "s/^ '|':$//g" | LC_ALL=C sort -u > "$tmp/recs" +dangling=$(LC_ALL=C comm -23 "$tmp/edges" "$tmp/recs") +[ -z "$dangling" ] || refuse "result would have dangling edges: ${dangling//$'\n'/ }" + +# Each workflow's standards entries must name exactly the refs its pins carry. +# The gates match an entry to a `uses:` by its ref as written, so an entry +# re-keyed to TARGET for a workflow that pins a short ref is a desync they reject. +for w in "${wfs[@]}"; do + want=$(grep -oE "$PIN_RE" "$w" | sed 's#^hyperpolymath/standards/.*@#hyperpolymath/standards@#' | LC_ALL=C sort -u || true) + have=$(awk -v k=" '.github/workflows/${w##*/}':" '$0==k{f=1;next} f&&!/^ - /{exit} f' "$tmp/out" \ + | grep -oE "'hyperpolymath/standards@[0-9a-fA-F]+'" | tr -d "'" | LC_ALL=C sort -u || true) + [ "$want" = "$have" ] || refuse "${w##*/}: lock entries [${have//$'\n'/ }] would not match its pins [${want//$'\n'/ }]" +done + +cat "$tmp/out" > "$L" +echo "relock-standards-pin: $L keyed to standards@${TARGET:0:12} (from $(echo $olds | sed 's/\([0-9a-f]\{12\}\)[0-9a-f]*/\1/g'))" diff --git a/scripts/tests/propagate-workflow-pins-test.sh b/scripts/tests/propagate-workflow-pins-test.sh index 977bf00c5..c79e2eefd 100755 --- a/scripts/tests/propagate-workflow-pins-test.sh +++ b/scripts/tests/propagate-workflow-pins-test.sh @@ -158,6 +158,53 @@ set -e try "linked shallow worktree defers to server reachability" test "$RC" -eq 0 try "linked shallow worktree does not report unreachable" not_contains "not reachable" "$OUT" +# ── 11. A refused relock restores the workflows and stages nothing ────────── +# The reusables are not in standards at the target, so the relock is refused. +# New pins over the old lock must be left neither in the index nor in the tree. +ROOT="$TEST_DIR/refused-many" +R="$ROOT/refused"; mk_consumer "$R" +cat > "$R/.github/workflows/actions.lock" <> "$R/.github/workflows/governance.yml" +git -C "$R" add .github/workflows/governance.yml +printf '\n# unstaged edit\n' >> "$R/.github/workflows/governance.yml" +git -C "$R" diff --cached --binary > "$TEST_DIR/index-before" +cp "$R/.github/workflows/actions.lock" "$TEST_DIR/lock-before" +cp "$R/.github/workflows/governance.yml" "$TEST_DIR/governance-before" +cp "$R/.github/workflows/scorecard.yml" "$TEST_DIR/scorecard-before" +mk_consumer "$ROOT/success" +set +e +OUT=$(bash "$PROP" --fix --to "$TARGET" "$ROOT" 2>&1) +RC=$? +set -e +git -C "$R" diff --cached --binary > "$TEST_DIR/index-after" +try "refused relock exits nonzero" test "$RC" -eq 1 +try "refused relock is reported" contains LOCK-REFUSED "$OUT" +try "refused relock leaves lock unchanged" cmp -s "$TEST_DIR/lock-before" "$R/.github/workflows/actions.lock" +try "refused relock preserves index without staging pins" cmp -s "$TEST_DIR/index-before" "$TEST_DIR/index-after" +try "refused relock leaves branch unchanged" test "$(git -C "$R" branch --show-current)" = main +try "refused relock retains staged workflow edit" file_has "$R/.github/workflows/governance.yml" '# staged edit' +try "refused relock retains unstaged workflow edit" file_has "$R/.github/workflows/governance.yml" '# unstaged edit' +try "refused relock restores an edited workflow byte for byte" cmp -s "$TEST_DIR/governance-before" "$R/.github/workflows/governance.yml" +try "refused relock restores an unedited workflow byte for byte" cmp -s "$TEST_DIR/scorecard-before" "$R/.github/workflows/scorecard.yml" +try "refused relock reports skipped staging" contains 'staging skipped' "$OUT" +try "refused relock does not prevent other repo fixes" file_has "$ROOT/success/.github/workflows/governance.yml" "governance-reusable.yml@${TARGET}" + echo "----------------------------------------" echo "$PASS/$TOTAL test cases passed." [ "$PASS" -eq "$TOTAL" ] || { echo "Some propagation tests FAILED."; exit 1; } diff --git a/scripts/tests/relock-standards-pin-test.sh b/scripts/tests/relock-standards-pin-test.sh new file mode 100755 index 000000000..5b6412e3e --- /dev/null +++ b/scripts/tests/relock-standards-pin-test.sh @@ -0,0 +1,230 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) +set -euo pipefail + +# Regression test for relock-standards-pin.sh. Offline: a synthetic standards +# repository and a synthetic consumer lock, with known answers. + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +RELOCK="$SCRIPT_DIR/../relock-standards-pin.sh" +SYNC="$SCRIPT_DIR/../check-lock-sync.sh" +T=$(mktemp -d); trap 'rm -rf "$T"' EXIT +# fail MESSAGE — report a failed assertion and stop the test. +fail() { echo "FAIL: $*" >&2; exit 1; } + +# Synthetic standards: OLD's reusable uses checkout + old/dep; NEW's uses +# checkout + acme/tool, and NEW's own lock records acme/tool. +S="$T/standards"; git init -q -b main "$S"; git -C "$S" config user.email t@t; git -C "$S" config user.name t +mkdir -p "$S/.github/workflows" +cat > "$S/.github/workflows/x-reusable.yml" <<'Y' +on: { workflow_call: {} } +jobs: + a: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@1111111111111111111111111111111111111111 # v1 + - uses: old/dep/sub@2222222222222222222222222222222222222222 +Y +git -C "$S" add -A; git -C "$S" commit -q -m old; OLD=$(git -C "$S" rev-parse HEAD) +cat > "$S/.github/workflows/x-reusable.yml" <<'Y' +on: { workflow_call: {} } +jobs: + a: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@1111111111111111111111111111111111111111 # v1 + - uses: acme/tool/sub@3333333333333333333333333333333333333333 +Y +# A second reusable, called in the short-pin case only. +cat > "$S/.github/workflows/y-reusable.yml" <<'Y' +on: { workflow_call: {} } +jobs: + a: + runs-on: ubuntu-latest + steps: + - uses: zeta/only@7777777777777777777777777777777777777777 +Y +cat > "$S/.github/workflows/actions.lock" <<'Y' +version: 'v0.0.3' +workflows: + '.github/workflows/x-reusable.yml': + - 'acme/tool@3333333333333333333333333333333333333333' + - 'actions/checkout@1111111111111111111111111111111111111111' +dependencies: + 'acme/tool@3333333333333333333333333333333333333333': + ref: 'v3' + commit: 'sha1-3333333333333333333333333333333333333333' + owner_id: 30 + repo_id: 31 + 'actions/checkout@1111111111111111111111111111111111111111': + ref: 'v1' + commit: 'sha1-1111111111111111111111111111111111111111' + owner_id: 10 + repo_id: 11 +Y +git -C "$S" add -A; git -C "$S" commit -q -m new; NEW=$(git -C "$S" rev-parse HEAD) + +# A consumer whose caller was re-pointed to NEW but whose lock is still on OLD. +consumer() { + local d="$T/$1/.github/workflows"; rm -rf "${T:?}/${1:?}"; mkdir -p "$d" + printf 'on: push\njobs:\n call:\n uses: hyperpolymath/standards/.github/workflows/x-reusable.yml@%s\n' "$NEW" > "$d/caller.yml" + printf 'on: push\njobs:\n b:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@1111111111111111111111111111111111111111\n' > "$d/own.yml" + cat > "$d/actions.lock" </dev/null +L="$D/actions.lock" +grep -q "^ - 'hyperpolymath/standards@$NEW'\$" "$L" || fail "workflow entry not re-keyed" +grep -q "$OLD" "$L" && fail "OLD standards commit still in the lock" +grep -q "^ 'hyperpolymath/standards@$NEW':\$" "$L" || fail "no record for the new pin" +grep -q "^ 'acme/tool@3333333333333333333333333333333333333333':\$" "$L" || fail "needed record not copied from standards' lock" +grep -q "old/dep@" "$L" && fail "record only the OLD reusable used was not pruned" +grep -q "old/sub@" "$L" && fail "record kept alive only by a pruned record was not pruned" +grep -q "unrelated/orphan@" "$L" || fail "an orphan the OLD record never named was pruned" +grep -q "owner_id: 6759885" "$L" || fail "owner_id not carried over" +[ "$(awk '/^dependencies:$/{f=1;next} f && /^ \x27/' "$L" | LC_ALL=C sort -c 2>&1; echo $?)" = 0 ] || fail "dependency records not in C-locale key order" +if command -v gawk >/dev/null 2>&1; then + sed -i '/unrelated\/orphan/,/repo_id: 41/d' "$L" # the gate checks closure, not unrelated orphans + bash "$SYNC" "$D" >/dev/null 2>&1 || fail "check-lock-sync.sh rejects the relocked lock" +fi +echo " PASS known answer (re-key, record rebuilt, needed record copied, OLD-only records pruned transitively, unrelated orphan kept)" + +# 2. Idempotent. +D=$(consumer idem); bash "$RELOCK" --to "$NEW" --standards "$S" "$D" >/dev/null +cp "$D/actions.lock" "$T/once"; bash "$RELOCK" --to "$NEW" --standards "$S" "$D" >/dev/null +cmp -s "$T/once" "$D/actions.lock" || fail "second run changed the lock" +echo " PASS idempotent" + +# 3. Refusals leave the lock byte-identical. +refuse_case() { # name mutate-command + local d; d=$(consumer "$1"); eval "$2"; cp "$d/actions.lock" "$T/before" + if bash "$RELOCK" --to "$NEW" --standards "$S" "$d" >/dev/null 2>&1; then fail "$1: relock succeeded"; fi + cmp -s "$T/before" "$d/actions.lock" || fail "$1: lock modified on refusal" + echo " PASS refused: $1" +} +refuse_case still-on-old 'sed -i "s/@$NEW/@$OLD/" "$d/caller.yml"' +BAD_PREFIX="${NEW:0:11}" +# Appends a hex digit (a string, not arithmetic), so the 12th char differs from NEW's. +if [ "${NEW:11:1}" = 0 ]; then BAD_PREFIX+="1"; else BAD_PREFIX+="0"; fi +refuse_case different-prefix 'sed -i "s/@$NEW/@$BAD_PREFIX/" "$d/caller.yml"' +refuse_case mixed-commits 'cp "$d/caller.yml" "$d/other.yml"; sed -i "s/@$NEW/@$BAD_PREFIX/" "$d/other.yml"' +refuse_case overlong-pin 'sed -i "s/@$NEW/@${NEW}a/" "$d/caller.yml"' +# A pin whose reusable needs a record that neither lock has: a commit NEW2 +# whose reusable uses acme/tool at a ref standards' own lock does not record. +sed -i "s#acme/tool/sub@3333333333333333333333333333333333333333#acme/tool/sub@5555555555555555555555555555555555555555#" "$S/.github/workflows/x-reusable.yml" +git -C "$S" commit -qam new2; NEW2=$(git -C "$S" rev-parse HEAD) +refuse_case record-nowhere 'sed -i "s/@$NEW/@$NEW2/" "$d/caller.yml"; NEW_SAVE=$NEW; NEW=$NEW2' +NEW=$NEW_SAVE +refuse_case no-old-record 'sed -i "/^ .hyperpolymath\/standards@$OLD.:/,/^ - .old\/dep/d" "$d/actions.lock"' +refuse_case short-pin-elsewhere 'sed -i "s/@$NEW/@${OLD:0:7}/" "$d/caller.yml"' +# Under 7 hex is no pin the applier writes or reads, even when it prefixes NEW +# and the lock carries a matching entry and record for it. +# tiny DIR — add tiny.yml pinning a 6-hex prefix of NEW, with its lock entry. +tiny() { + sed "s/@$NEW/@${NEW:0:6}/" "$1/caller.yml" > "$1/tiny.yml" + sed -i "/^dependencies:\$/i\\ '.github/workflows/tiny.yml':\\n - 'hyperpolymath/standards@${NEW:0:6}'" "$1/actions.lock" + printf " 'hyperpolymath/standards@%s':\n ref: '%s'\n commit: 'sha1-%s'\n owner_id: 6759885\n repo_id: 1116521501\n" \ + "${NEW:0:6}" "${NEW:0:6}" "$NEW" >> "$1/actions.lock" +} +refuse_case short-pin-too-short 'tiny "$d"' +# desync DIR — caller.yml now pins a short NEW ref but its lock entry still +# names OLD; full.yml pins NEW in full. Re-keying caller.yml's entry to the full +# NEW would not match its pin, which the gates reject. +desync() { + cp "$1/caller.yml" "$1/full.yml" + sed -i "s/@$NEW/@${NEW:0:12}/" "$1/caller.yml" + sed -i "/^dependencies:\$/i\\ '.github/workflows/full.yml':\\n - 'hyperpolymath/standards@$OLD'" "$1/actions.lock" +} +refuse_case short-pin-entry-desync 'desync "$d"' + +# 4. No lock: nothing to do. +D=$(consumer nolock); rm "$D/actions.lock" +bash "$RELOCK" --to "$NEW" --standards "$S" "$D" >/dev/null || fail "no-lock repo was refused" +[ ! -e "$D/actions.lock" ] || fail "a lock was created" +echo " PASS no actions.lock: nothing to do" + +# 5. A short pin that prefixes NEW is current (the applier leaves it FRESH), at +# the lengths the applier reads (7..39 hex). Its workflow and its own record +# stay byte-identical, and only full pins feed NEW's record: zeta/only, used by +# the short-pinned reusable alone, must not join it. +for length in 7 12 39; do + D=$(consumer "short-$length"); SHORT=${NEW:0:length} + printf 'on: push\njobs:\n call:\n uses: hyperpolymath/standards/.github/workflows/y-reusable.yml@%s\n' "$SHORT" > "$D/short.yml" + sed -i "/^dependencies:\$/i\\ '.github/workflows/short.yml':\\n - 'hyperpolymath/standards@$SHORT'" "$D/actions.lock" + cat >> "$D/actions.lock" < "$T/short-before" + cp "$D/short.yml" "$T/short-wf-before" + bash "$RELOCK" --to "$NEW" --standards "$S" "$D" >/dev/null || fail "$length-hex pin prefixing the target was refused" + L="$D/actions.lock" + cmp -s "$T/short-wf-before" "$D/short.yml" || fail "$length-hex pin: the workflow was rewritten" + short_rec "$L" | cmp -s - "$T/short-before" || fail "$length-hex pin: its own record was changed" + grep -q "^ - 'hyperpolymath/standards@$SHORT'\$" "$L" || fail "$length-hex pin: its workflow entry was re-keyed" + grep -q "$OLD" "$L" && fail "$length-hex pin: OLD left in the lock" + awk -v k=" 'hyperpolymath/standards@$NEW':" '$0==k{f=1;next} f&&/^ [^ ]/{exit} f' "$L" | grep -q zeta/only \ + && fail "$length-hex pin: a reusable called only by the short pin fed the full record" + grep -q "^ 'zeta/only@7777777777777777777777777777777777777777':\$" "$L" || fail "$length-hex pin: its record's dependency was pruned" + if command -v gawk >/dev/null 2>&1; then + sed -i '/unrelated\/orphan/,/repo_id: 41/d' "$L" + bash "$SYNC" "$D" >/dev/null 2>&1 || fail "$length-hex pin: check-lock-sync.sh rejects the relocked lock" + fi + echo " PASS $length-hex pin prefixing the target: left as it is, full record from full pins only" +done + +echo "PASS: relock-standards-pin.sh"