diff --git a/scripts/fix-stale-reusable-pin.sh b/scripts/fix-stale-reusable-pin.sh index d2c09247..c86abc19 100755 --- a/scripts/fix-stale-reusable-pin.sh +++ b/scripts/fix-stale-reusable-pin.sh @@ -42,11 +42,22 @@ # Table: scripts/lib/stale-reusable-pins.tsv — reusable_pathstale_sha # replacement_shareason. reusable_path may end in `/*` to cover every # reusable of that repository. +# +# Moved repositories: scripts/lib/moved-repositories.tsv (MOVED_TABLE) — +# formercurrentrepo_idreason. A table row and a caller are +# matched under the repository's current name, so a row written with either +# name covers callers written with either. A reusable `uses:` does not follow +# GitHub's redirect, so a caller still written with the former name is +# rewritten to the current name along with its SHA; its lock entry carries +# the former owner_id and is left for `gh actions-lock` (exit 3). A +# former-name caller at a SHA the table does not name is not this script's: +# no row says where to move it (hypatia WF028 reports it). set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PINS_TABLE="${PINS_TABLE:-$SCRIPT_DIR/lib/stale-reusable-pins.tsv}" +MOVED_TABLE="${MOVED_TABLE:-$SCRIPT_DIR/lib/moved-repositories.tsv}" REPO_PATH="${1:?Usage: fix-stale-reusable-pin.sh }" [ -r "$PINS_TABLE" ] || { echo "fix-stale-reusable-pin: table not readable: $PINS_TABLE" >&2; exit 2; } @@ -57,11 +68,55 @@ REPO_PATH="${1:?Usage: fix-stale-reusable-pin.sh }" # candidate scan is built from them, so a private table for another owner # (`octo/example/.github/workflows/*`) finds its callers. TABLE_OWNERS_RE="" +# Moved repositories (lib/moved-repositories.tsv): former `owner/repo`, in +# lower case, to the current name as the table spells it. GitHub's redirect +# serves an API read of the former name but not a reusable `uses:` — a caller +# of the former name fails at startup whatever its SHA — so a table row and a +# caller are compared under the CURRENT name, and a caller written with the +# former name is rewritten to the current one along with its SHA. +declare -A MOVED_TO=() +validate_moved() { + local n=0 m_former m_current m_id bad=0 + [ -r "$MOVED_TABLE" ] || { echo "fix-stale-reusable-pin: moved-repository table not readable: $MOVED_TABLE" >&2; exit 2; } + while IFS=$'\t' read -r m_former m_current m_id _ || [ -n "$m_former" ]; do + n=$((n + 1)) + case "$m_former" in ''|'#'*) continue ;; esac + [[ "$m_former" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]] || { echo "fix-stale-reusable-pin: moved row $n: bad former name '$m_former'" >&2; bad=1; } + [[ "$m_current" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]] || { echo "fix-stale-reusable-pin: moved row $n: bad current name '$m_current'" >&2; bad=1; } + [[ "$m_id" =~ ^[0-9]+$ ]] || { echo "fix-stale-reusable-pin: moved row $n: bad repo_id '$m_id'" >&2; bad=1; } + [ "${m_former,,}" != "${m_current,,}" ] || { echo "fix-stale-reusable-pin: moved row $n: '$m_former' moves to itself" >&2; bad=1; } + # One name, one destination; and a current name is never itself moved + # on (a chain would need a second lookup the rewrite does not make). + if [ -n "${MOVED_TO[${m_former,,}]:-}" ] && [ "${MOVED_TO[${m_former,,}],,}" != "${m_current,,}" ]; then + echo "fix-stale-reusable-pin: moved row $n: '$m_former' already moves to '${MOVED_TO[${m_former,,}]}'" >&2; bad=1 + fi + MOVED_TO[${m_former,,}]="$m_current" + done < "$MOVED_TABLE" + local f + for f in "${!MOVED_TO[@]}"; do + [ -z "${MOVED_TO[${MOVED_TO[$f],,}]:-}" ] || { echo "fix-stale-reusable-pin: moved table: '$f' moves to '${MOVED_TO[$f]}', which moves again; record the final name" >&2; bad=1; } + done + [ "$bad" -eq 0 ] || { echo "fix-stale-reusable-pin: table $MOVED_TABLE is invalid; nothing was changed" >&2; exit 2; } +} +# : the path in lower case, its +# repository under its current name. No subshell: lookup_replacement runs +# this once per candidate. +canon_path_into() { + local p="${2,,}" pre + pre="${p%%/.github/workflows/*}" + [ -n "${MOVED_TO[$pre]:-}" ] && p="${MOVED_TO[$pre],,}${p#"$pre"}" + printf -v "$1" '%s' "$p" +} +validate_moved +# The table, read once: each row's path canonical (lower case, current name), +# in file order — lookup_replacement keeps first-match-wins. +T_PATH=(); T_STALE=(); T_NEW=(); T_REASON=() validate_table() { - local n=0 v_path v_stale v_new bad=0 owners="" + local n=0 v_path v_stale v_new v_reason bad=0 owners="" canon pre f + local -A table_repos=() # `|| [ -n ... ]`: a final row the file ends on without a newline is a # row too — `read` returns 1 on it with the fields filled in. - while IFS=$'\t' read -r v_path v_stale v_new _ || [ -n "$v_path" ]; do + while IFS=$'\t' read -r v_path v_stale v_new v_reason || [ -n "$v_path" ]; do n=$((n + 1)) case "$v_path" in ''|'#'*) continue ;; esac [[ "$v_stale" =~ ^[0-9a-f]{40}$ ]] || { echo "fix-stale-reusable-pin: table row $n: bad stale SHA '$v_stale'" >&2; bad=1; } @@ -73,8 +128,18 @@ validate_table() { # a `*` anywhere else would be matched literally, never as a glob. [[ "$v_path" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+/\.github/workflows/([A-Za-z0-9_.-]+|\*)$ ]] || { echo "fix-stale-reusable-pin: table row $n: bad reusable path '$v_path' (the only wildcard is a terminal /*)" >&2; bad=1; } owners="$owners${v_path%%/*}"$'\n' + canon_path_into canon "$v_path" + T_PATH+=("$canon"); T_STALE+=("$v_stale"); T_NEW+=("$v_new"); T_REASON+=("$v_reason") + table_repos[${canon%%/.github/workflows/*}]=1 done < "$PINS_TABLE" [ "$bad" -eq 0 ] || { echo "fix-stale-reusable-pin: table $PINS_TABLE is invalid; nothing was changed" >&2; exit 2; } + # A caller may write a table repository under either of its names: the + # scan covers the owners of both. + for f in "${!MOVED_TO[@]}"; do + pre="${MOVED_TO[$f],,}" + [ -n "${table_repos[$pre]:-}" ] || continue + owners="$owners${f%%/*}"$'\n'"${pre%%/*}"$'\n' + done TABLE_OWNERS_RE=$(printf '%s' "$owners" | sed -E 's/[.]/\\./g' | sort -u | paste -sd'|' -) [ -n "$TABLE_OWNERS_RE" ] || TABLE_OWNERS_RE="hyperpolymath" } @@ -113,7 +178,7 @@ anchor_value() { # pin_fixer_lock "$REPO_PATH" LOCK="$REPO_PATH/.github/workflows/actions.lock" -rewrites=0; lock_touched=0 +rewrites=0; lock_touched=0; lock_moved=0 # Two callers pinned at two different dead SHAs move to the SAME replacement, # so after substitution the lock can hold the same dependency key twice. A @@ -303,18 +368,18 @@ merge_duplicate_records() { # wildcard paths. Set replacement/reason and return 0 on a match; otherwise # clear both variables and return 1. lookup_replacement() { # - local t_path t_stale t_new t_reason t_path_lc + local i caller replacement=""; reason="" - while IFS=$'\t' read -r t_path t_stale t_new t_reason || [ -n "$t_path" ]; do - [[ -z "$t_path" || "$t_path" == \#* ]] && continue - t_path_lc="${t_path,,}" - [[ "$t_stale" == "$2" ]] || continue - # GitHub resolves an owner or repository name in either case: the - # table and the caller are compared case-folded. - if [[ "${t_path,,}" == "${1,,}" ]] || { [[ "$t_path" == */\* ]] && [[ "${1,,}" == "${t_path_lc%\*}"* ]]; }; then - replacement="$t_new"; reason="$t_reason"; return 0 + # GitHub resolves an owner or repository name in either case, and the + # table may name a moved repository by either of its names: both sides + # are compared case-folded under the current name (canon_path_into). + canon_path_into caller "$1" + for i in "${!T_PATH[@]}"; do + [[ "${T_STALE[$i]}" == "$2" ]] || continue + if [[ "${T_PATH[$i]}" == "$caller" ]] || { [[ "${T_PATH[$i]}" == */\* ]] && [[ "$caller" == "${T_PATH[$i]%\*}"* ]]; }; then + replacement="${T_NEW[$i]}"; reason="${T_REASON[$i]}"; return 0 fi - done < "$PINS_TABLE" + done return 1 } @@ -506,7 +571,7 @@ while IFS= read -r -d '' wf; do [ -n "$ref" ] || continue stale="${ref##*@}"; stale="${stale,,}" lookup_replacement "${ref%@*}" "$stale" || continue - key="${ref%%/.github/workflows/*}"; key="${key,,}@$stale" + canon_path_into key "${ref%@*}"; key="${key%%/.github/workflows/*}@$stale" if [ -n "${move_to[$key]:-}" ] && [ "${move_to[$key]}" != "$replacement" ]; then echo "CONFLICT ${wf#"$REPO_PATH"/}:$lineno: $key would move to ${move_to[$key]:0:7} here and to ${replacement:0:7} elsewhere; one lock edge and one record cannot carry both. Fix the table or the callers by hand; nothing was changed." >&2 conflict=1 @@ -634,12 +699,27 @@ while IFS= read -r -d '' wf; do # ERE: a `$` inside a BRE group is literal to sed, so the boundary # alternation needs -E (and a delimiter the pattern does not use). # The path as the caller spells it, matched literally (a `.` in it - # is a dot). + # is a dot). A caller of a moved repository's former name is written + # with the current name: the reusable does not run under the former + # one, whatever its SHA. path_re=$(printf '%s' "$path" | sed -E 's/[][\.^$*+?(){}|~\\]/\\&/g') - sed -i -E "${lineno}s~^([[:space:]]*(-[[:space:]]+)?[\"']?uses[\"']?[[:space:]]*:[[:space:]]*[\"']?$path_re)@$stale_raw([\"'[:space:]]|\$)~\1@$replacement\3~" "$wf" - echo "FIXED ${wf#"$REPO_PATH"/}:$lineno $path ${stale:0:7} -> ${replacement:0:7} ($reason)" + repo_prefix="${path%%/.github/workflows/*}"; moved_to="${MOVED_TO[${repo_prefix,,}]:-}" + new_path="$path"; [ -z "$moved_to" ] || new_path="$moved_to${path#"$repo_prefix"}" + sed -i -E "${lineno}s~^([[:space:]]*(-[[:space:]]+)?[\"']?uses[\"']?[[:space:]]*:[[:space:]]*[\"']?)$path_re@$stale_raw([\"'[:space:]]|\$)~\1$new_path@$replacement\3~" "$wf" + if [ -n "$moved_to" ]; then + echo "FIXED ${wf#"$REPO_PATH"/}:$lineno $path ${stale:0:7} -> ${replacement:0:7} ($reason; $repo_prefix moved to $moved_to)" + else + echo "FIXED ${wf#"$REPO_PATH"/}:$lineno $path ${stale:0:7} -> ${replacement:0:7} ($reason)" + fi rewrites=$((rewrites + 1)) - pending+=("$stale"$'\t'"$replacement"$'\t'"${path%%/.github/workflows/*}") + # A lock edge and record carry the repository's owner_id, which a + # transfer changes: the renamed caller's lock entry is left for + # `gh actions-lock` to write, and the run ends 3. + if [ -n "$moved_to" ]; then + [ -f "$LOCK" ] && lock_moved=1 + continue + fi + pending+=("$stale"$'\t'"$replacement"$'\t'"$repo_prefix") done # A lock edge is per repository@SHA, shared by every reusable of that # repository the workflow calls. When a job of this workflow still calls @@ -887,6 +967,10 @@ if [ "$merge_refused" -ne 0 ]; then echo "fix-stale-reusable-pin: actions.lock still lists a dependency twice (UNSUPPORTED above); not a clean success." >&2 exit 2 fi +if [ "$lock_moved" -eq 1 ]; then + echo "fix-stale-reusable-pin: a caller was moved to its repository's current name; actions.lock still names the former one (and its owner_id) and was not edited for it — run \`gh actions-lock\` before pushing. Exit 3: not a clean success." >&2 + exit 3 +fi if [ "$lock_stale" -eq 1 ]; then echo "fix-stale-reusable-pin: a rewritten caller had no matching lock edge (WARN above); the lock is out of sync. Exit 3: not a clean success." >&2 exit 3 diff --git a/scripts/lib/moved-repositories.tsv b/scripts/lib/moved-repositories.tsv new file mode 100644 index 00000000..6f438be8 --- /dev/null +++ b/scripts/lib/moved-repositories.tsv @@ -0,0 +1,9 @@ +# SPDX-License-Identifier: MPL-2.0 +# moved-repositories.tsv — estate repositories that were transferred or renamed, and the name a caller must use now. +# Columns (tab-separated): formercurrentrepo_idreason +# former and current are `owner/repo`; repo_id is GitHub's numeric id, which a transfer keeps (it is how the two names are known to be one repository). +# Consumed by scripts/fix-stale-reusable-pin.sh: a table row for either name matches a caller written with either name, and a caller still +# written with the former name is rewritten to the current one. A reusable `uses:` does NOT follow GitHub's redirect — a caller of the former +# name dies at startup_failure whatever its SHA — so moving only the SHA would leave it dead. +# Mirrored by hypatia (Hypatia.Rules.MovedRepositories, rule WF028) so detection and repair carry the same facts. +hyperpolymath/standards metadatastician/standards 1116521501 transferred 2026-10-25; a reusable call of the former name fails at startup (hypatia WF028) diff --git a/scripts/lib/stale-reusable-pins.tsv b/scripts/lib/stale-reusable-pins.tsv index f8be4f6c..49be768a 100644 --- a/scripts/lib/stale-reusable-pins.tsv +++ b/scripts/lib/stale-reusable-pins.tsv @@ -1,7 +1,7 @@ # SPDX-License-Identifier: MPL-2.0 # stale-reusable-pins.tsv — pins of estate reusables that are KNOWN DEAD, and the pin to move to. # Columns (tab-separated): reusable_pathstale_shareplacement_shareason -# reusable_path is `hyperpolymath//.github/workflows/` or `hyperpolymath//.github/workflows/*` (every reusable of that repo). +# reusable_path is `hyperpolymath//.github/workflows/` or `hyperpolymath//.github/workflows/*` (every reusable of that repo). A moved repository (lib/moved-repositories.tsv) may be named by either name: rows and callers are matched under its current name. # Consumed by scripts/fix-stale-reusable-pin.sh; mirrored by hypatia (pin denylist PIN-003) so detection and repair carry the same facts. # # Window A — standards main 2026-06-01..2026-06-24 (d73c93e0..49ed4715): rust-ci-reusable.yml pins dtolnay/rust-toolchain by SHA and passes diff --git a/scripts/tests/fix-workflow-pins.sh b/scripts/tests/fix-workflow-pins.sh index d1aebd73..525ab222 100755 --- a/scripts/tests/fix-workflow-pins.sh +++ b/scripts/tests/fix-workflow-pins.sh @@ -23,6 +23,13 @@ bad() { fail=$((fail + 1)); echo " FAIL $1" >&2; } check(){ if eval "$2"; then ok "$1"; else bad "$1"; fi; } # Capture both channels and the exit status, including expected failures. run_fix() { rc=0; out=$("$@" 2>&1) || rc=$?; } +# The stale-pin fixtures spell standards by its former name +# (hyperpolymath/standards): they test the SHA and lock mechanics, which do +# not depend on the name, so they run with no known moves. The moved-repository +# contracts (sp-moved*, at the end) pass the shipped table explicitly. +: > "$fx/no-moves.tsv" +export MOVED_TABLE="$fx/no-moves.tsv" +shipped_moves="$fleet/scripts/lib/moved-repositories.tsv" # ── 1. fix-rust-toolchain-input.sh ─────────────────────────────────────────── mkdir -p "$fx/tc/.github/workflows" @@ -3807,6 +3814,125 @@ dq_out=$(printf '%s\n' 'uses: "\x64tolnay/x@1" # "\x41"' "name: 'a\\x41' \"b\\\" check "yaml_decode_dq decodes double-quoted escapes only, an escaped quote to a byte no reference holds" \ "[ \"\$dq_out\" = \"\$(printf 'uses: \"dtolnay/x@1\" # \"\\\\x41\"|name: '\"'\"'a\\\\x41'\"'\"' \"b\\001c\"|')\" ]" +# sp-moved: standards moved to metadatastician/standards (lib/moved-repositories.tsv). +# A reusable `uses:` does not follow the redirect, so a caller of the former +# name is dead whatever its SHA: a table pin is rewritten to the CURRENT name +# with its replacement SHA, and the lock — whose owner_id the move changed — +# is left for `gh actions-lock` (exit 3). A caller already on the current +# name is matched by the table's former-name rows and gets the ordinary +# SHA move and lock surgery. +mv_new=metadatastician/standards +mkdir -p "$fx/sp-moved/.github/workflows" +printf 'jobs:\n rust-ci:\n uses: hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml@%s\n' "$STALE_A" > "$fx/sp-moved/.github/workflows/rust-ci.yml" +cat > "$fx/sp-moved/.github/workflows/actions.lock" < "$fx/sp-moved-current/.github/workflows/governance.yml" +cat > "$fx/sp-moved-current/.github/workflows/actions.lock" < "$fx/sp-moved-plain/.github/workflows/ci.yml" < "$fx/sp-moved-plain-expected.yml" < "$fx/sp-moved-alias/.github/workflows/ci.yml" +cp "$fx/sp-moved-alias/.github/workflows/ci.yml" "$fx/sp-moved-alias-before.yml" +MOVED_TABLE="$shipped_moves" run_fix bash "$fleet/scripts/fix-stale-reusable-pin.sh" "$fx/sp-moved-alias" +check "sp-moved-alias a current-name dead pin behind an alias is UNSUPPORTED (exit 2), file untouched" \ + "[ \$rc -eq 2 ] && grep -q 'UNSUPPORTED.*uses: \\*call' <<<\"\$out\" && cmp -s '$fx/sp-moved-alias-before.yml' '$fx/sp-moved-alias/.github/workflows/ci.yml'" + +# sp-moved-invalid: a moved table that is unreadable, malformed, or chains a +# move is refused before any caller changes. +printf 'a/b\tc/d\t1\tx\nc/d\te/f\t2\tx\n' > "$fx/moves-chain.tsv" +printf 'a/b\tc/d\tnot-an-id\tx\n' > "$fx/moves-badid.tsv" +printf 'a/b\tA/B\t1\tx\n' > "$fx/moves-self.tsv" +cp "$fx/sp-moved/.github/workflows/rust-ci.yml" "$fx/sp-moved-invalid-before.yml" +for t in chain badid self missing; do + MOVED_TABLE="$fx/moves-$t.tsv" run_fix bash "$fleet/scripts/fix-stale-reusable-pin.sh" "$fx/sp-moved" + check "sp-moved-invalid a $t moved table exits 2 and changes nothing" \ + "[ \$rc -eq 2 ] && grep -q 'moved' <<<\"\$out\" && cmp -s '$fx/sp-moved-invalid-before.yml' '$fx/sp-moved/.github/workflows/rust-ci.yml'" +done +check "the shipped moved table records standards' move under its stable repo_id" \ + "grep -qxP 'hyperpolymath/standards\\tmetadatastician/standards\\t1116521501\\t.*' '$shipped_moves'" + bash "$fleet/scripts/tests/fix-workflow-resources.sh" echo "fix-workflow-pins: $pass passed, $fail failed"