diff --git a/scripts/propagate-workflow-pins.sh b/scripts/propagate-workflow-pins.sh index 8455a0f19..c27f72794 100755 --- a/scripts/propagate-workflow-pins.sh +++ b/scripts/propagate-workflow-pins.sh @@ -37,8 +37,9 @@ # PATH may be a single consumer repo (has .github/workflows) or a parent # directory of many repos. Defaults to the current directory. # --to target standards SHA to pin to. If omitted, resolved from -# (in order) $STANDARDS_TARGET_SHA, a local standards checkout -# ($STANDARDS_DIR or ~/standards), then `git ls-remote` HEAD. +# (in order) $STANDARDS_TARGET_SHA, the main ref of a local +# standards checkout ($STANDARDS_DIR or ~/standards), then the +# remote main ref. A feature-worktree HEAD is never selected. # # Output: tab-separated audit lines suitable for review. @@ -77,13 +78,22 @@ resolve_target() { if [ -n "$TARGET_SHA" ]; then printf '%s' "$TARGET_SHA"; return 0; fi local sd="${STANDARDS_DIR:-$HOME/standards}" - if git -C "$sd" rev-parse HEAD >/dev/null 2>&1; then - git -C "$sd" rev-parse HEAD; return 0 - fi + # The old implementation used HEAD. When invoked from a standards feature + # worktree, that propagated a PR-intermediate commit before it had landed on + # main. GitHub's contents API could read that commit, but cross-repository + # reusable workflows rejected it as `workflow was not found` (251 active + # workflow files / 70 repos in the 2026-09-04 incident). + local ref + for ref in refs/remotes/origin/main refs/heads/main; do + if git -C "$sd" rev-parse --verify "${ref}^{commit}" >/dev/null 2>&1; then + git -C "$sd" rev-parse "${ref}^{commit}" + return 0 + fi + done # Network fallback — the live standards HEAD. local remote - remote=$(git ls-remote https://github.com/hyperpolymath/standards.git HEAD 2>/dev/null | awk '{print $1}') + remote=$(git ls-remote https://github.com/hyperpolymath/standards.git refs/heads/main 2>/dev/null | awk '{print $1}') if [ -n "$remote" ]; then printf '%s' "$remote"; return 0; fi return 1 @@ -94,6 +104,66 @@ if ! TARGET_SHA="$(resolve_target)"; then exit 2 fi +# Prove that the proposed pin is a full commit SHA reachable from standards' +# default branch. Merely proving that `/commits/` exists is insufficient: +# a squash-merged PR leaves its intermediate commits addressable through the +# API but unusable as cross-repository reusable-workflow refs. +validate_target() { + [[ "$TARGET_SHA" =~ ^[0-9a-fA-F]{40}$ ]] || { + log "ERROR: target must be a full 40-character hexadecimal commit SHA: $TARGET_SHA" + return 1 + } + + local sd="${STANDARDS_DIR:-$HOME/standards}" ref head shallow + local local_main_seen=false + if git -C "$sd" cat-file -e "${TARGET_SHA}^{commit}" >/dev/null 2>&1; then + for ref in refs/remotes/origin/main refs/heads/main; do + if head=$(git -C "$sd" rev-parse --verify "${ref}^{commit}" 2>/dev/null); then + local_main_seen=true + if git -C "$sd" merge-base --is-ancestor "$TARGET_SHA" "$head"; then + return 0 + fi + fi + done + + # Only reject after every usable local main ref has been checked. A + # shallow or partial graph is not conclusive, so defer that case to the + # authoritative server comparison below. + shallow=$(git -C "$sd" rev-parse --is-shallow-repository 2>/dev/null || printf 'true') + if [[ "$local_main_seen" == true && "$shallow" != true ]] && + [[ "$(git -C "$sd" config --get remote.origin.promisor 2>/dev/null || true)" != true ]] && + [[ -z "$(git -C "$sd" config --get remote.origin.partialclonefilter 2>/dev/null || true)" ]]; then + log "ERROR: target ${TARGET_SHA} exists but is not reachable from any available local standards main ref." + return 1 + fi + fi + + # Neither usable local main ref proved reachability. Local refs can be stale, + # so only the authoritative server comparison may return a negative result. + + local api="${STANDARDS_REACHABILITY_API_BASE:-https://api.github.com}" + local -a auth=() + [ -n "${GITHUB_TOKEN:-}" ] && auth=(-H "Authorization: Bearer ${GITHUB_TOKEN}") + local body status + body=$(curl -fsS --max-time 20 \ + -H 'Accept: application/vnd.github+json' \ + "${auth[@]}" \ + "$api/repos/hyperpolymath/standards/compare/${TARGET_SHA}...main") || { + log "ERROR: could not prove target ${TARGET_SHA} is reachable from standards main; refusing propagation." + return 1 + } + status=$(printf '%s' "$body" | sed -n 's/.*"status"[[:space:]]*:[[:space:]]*"\([a-z]*\)".*/\1/p' | head -n1) + case "$status" in + identical|ahead) return 0 ;; + *) + log "ERROR: target ${TARGET_SHA} is not reachable from standards main (compare status: ${status:-unknown}); refusing propagation." + return 1 + ;; + esac +} + +validate_target || exit 2 + # --------------------------------------------------------------------------- # Per-file rewrite (the unit-testable core) # --------------------------------------------------------------------------- diff --git a/scripts/tests/propagate-workflow-pins-test.sh b/scripts/tests/propagate-workflow-pins-test.sh index 497d0a8f9..977bf00c5 100755 --- a/scripts/tests/propagate-workflow-pins-test.sh +++ b/scripts/tests/propagate-workflow-pins-test.sh @@ -12,7 +12,25 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROP="$SCRIPT_DIR/../propagate-workflow-pins.sh" OLD="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" -TARGET="bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + +# A real local commit graph makes reachability part of every test. The +# propagation primitive must reject a SHA that merely exists on a feature +# branch: GitHub cannot resolve such a SHA as a cross-repo reusable workflow. +UPSTREAM="$TEST_DIR/standards" +git init -q -b main "$UPSTREAM" +git -C "$UPSTREAM" config user.email t@t +git -C "$UPSTREAM" config user.name t +touch "$UPSTREAM/reusable.yml" +git -C "$UPSTREAM" add reusable.yml +git -C "$UPSTREAM" commit -q -m main +TARGET=$(git -C "$UPSTREAM" rev-parse HEAD) +git -C "$UPSTREAM" checkout -q -b feature +touch "$UPSTREAM/feature-only" +git -C "$UPSTREAM" add feature-only +git -C "$UPSTREAM" commit -q -m feature +ORPHAN=$(git -C "$UPSTREAM" rev-parse HEAD) +git -C "$UPSTREAM" checkout -q main +export STANDARDS_DIR="$UPSTREAM" PASS=0; TOTAL=0 @@ -90,6 +108,56 @@ OUT=$(bash "$PROP" --to "$TARGET" "$ROOT") try "parent-dir mode sees repoA" contains repoA "$OUT" try "parent-dir mode sees repoB" contains repoB "$OUT" +# ── 8. Existing but non-mainline target is refused before any rewrite ─────── +R="$TEST_DIR/orphan"; mk_consumer "$R" +set +e +OUT=$(bash "$PROP" --fix --to "$ORPHAN" "$R" 2>&1) +RC=$? +set -e +try "feature-only target is rejected" test "$RC" -eq 2 +try "rejected target leaves consumer unchanged" file_has "$R/.github/workflows/governance.yml" "governance-reusable.yml@${OLD}" +try "rejection explains default-branch reachability" contains "not reachable" "$OUT" + +# ── 9. A stale origin/main must not hide a newer local main ────────────────── +git -C "$UPSTREAM" update-ref refs/remotes/origin/main "$TARGET" +touch "$UPSTREAM/mainline-newer" +git -C "$UPSTREAM" add mainline-newer +git -C "$UPSTREAM" commit -q -m mainline-newer +NEWER_TARGET=$(git -C "$UPSTREAM" rev-parse HEAD) +R="$TEST_DIR/stale-origin"; mk_consumer "$R" +set +e +OUT=$(bash "$PROP" --to "$NEWER_TARGET" "$R" 2>&1) +RC=$? +set -e +try "newer local main target survives stale origin/main" test "$RC" -eq 0 +try "stale origin/main does not report unreachable" not_contains "not reachable" "$OUT" + +# ── 10. A linked worktree retains the shared clone's shallow status ──────────── +SHALLOW_REMOTE="$TEST_DIR/standards-remote.git" +SHALLOW_CLONE="$TEST_DIR/standards-shallow" +SHALLOW_WORKTREE="$TEST_DIR/standards-shallow-worktree" +FAKE_BIN="$TEST_DIR/fake-bin" +git init -q --bare "$SHALLOW_REMOTE" +git -C "$UPSTREAM" push -q "$SHALLOW_REMOTE" main +git -C "$SHALLOW_REMOTE" symbolic-ref HEAD refs/heads/main +git clone -q --depth 1 "file://$SHALLOW_REMOTE" "$SHALLOW_CLONE" +git -C "$SHALLOW_CLONE" fetch -q origin "$TARGET" +git -C "$SHALLOW_CLONE" worktree add -q -b reachability-test "$SHALLOW_WORKTREE" HEAD +mkdir -p "$FAKE_BIN" +cat > "$FAKE_BIN/curl" <<'EOF' +#!/usr/bin/env sh +printf '%s\n' '{"status":"ahead"}' +EOF +chmod +x "$FAKE_BIN/curl" +R="$TEST_DIR/shallow-consumer"; mk_consumer "$R" +set +e +OUT=$(STANDARDS_DIR="$SHALLOW_WORKTREE" PATH="$FAKE_BIN:$PATH" \ + bash "$PROP" --to "$TARGET" "$R" 2>&1) +RC=$? +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" + echo "----------------------------------------" echo "$PASS/$TOTAL test cases passed." [ "$PASS" -eq "$TOTAL" ] || { echo "Some propagation tests FAILED."; exit 1; }