diff --git a/scripts/propagate-sha-bump.sh b/scripts/propagate-sha-bump.sh index af923e89..33e1f816 100755 --- a/scripts/propagate-sha-bump.sh +++ b/scripts/propagate-sha-bump.sh @@ -26,6 +26,7 @@ # Optional env: # FARM_REPO default "hyperpolymath/.git-private-farm" # DRY_RUN "true" prints the payload without dispatching +# GH_BIN GitHub CLI command (test seam; default "gh") set -euo pipefail usage() { @@ -40,6 +41,7 @@ FINDING_FILE="$2" FARM_REPO="${FARM_REPO:-hyperpolymath/.git-private-farm}" DRY_RUN="${DRY_RUN:-false}" +GH_BIN="${GH_BIN:-gh}" # Title-keyword exclusion regex. Keep in sync with: # feedback_pr_sweep_title_keyword_exclusion @@ -88,6 +90,45 @@ case "$source_workflow" in *) echo "ERROR: source_workflow not in expected shape: '$source_workflow'" >&2; exit 1 ;; esac +# A SHA can exist in GitHub's object database yet be unusable as a cross-repo +# reusable workflow. Feature-branch commits that are later squash-merged have +# exactly that shape: Contents/Commits APIs resolve them, but Actions rejects +# `uses: ...@sha` at startup with `workflow was not found`. Prove the proposed +# target is on the source repository's default-branch history before searching +# consumers or dispatching a mutation. +default_branch=$($GH_BIN api "repos/${source_repo}" --jq '.default_branch') || { + echo "ERROR: could not resolve ${source_repo}'s default branch" >&2 + exit 1 +} + +compare_status=$($GH_BIN api \ + "repos/${source_repo}/compare/${new_sha}...${default_branch}" \ + --jq '.status') || { + echo "ERROR: could not verify ${new_sha} against ${source_repo}:${default_branch}" >&2 + exit 1 +} + +case "$compare_status" in + identical|ahead) ;; + *) + echo "ERROR: new_sha ${new_sha} exists but is not reachable from ${source_repo}:${default_branch} (compare status: ${compare_status:-unknown})" >&2 + exit 1 + ;; +esac + +if [[ "$source_workflow" == .github/workflows/* ]]; then + source_type=$($GH_BIN api \ + "repos/${source_repo}/contents/${source_workflow}?ref=${new_sha}" \ + --jq '.type') || { + echo "ERROR: ${source_workflow} does not resolve at ${source_repo}@${new_sha}" >&2 + exit 1 + } + [[ "$source_type" == "file" ]] || { + echo "ERROR: ${source_workflow} at ${source_repo}@${new_sha} is not a file" >&2 + exit 1 + } +fi + # --- 3. Title-keyword pre-filter (HARD) --------------------------------------- # Per feedback_no_automated_licence_edits: licence/SPDX changes are MANUAL, @@ -119,41 +160,102 @@ CONSUMERS_TSV="$TMPDIR_RUN/consumers.tsv" echo "Enumerating consumers pinning: $needle" >&2 -# gh code-search has a 100-result cap per query. For larger sweeps the -# operator should pre-build a TSV manually and supply it via a CONSUMERS_TSV -# env override. Tracked here for posterity. +# Search both named estates, page to the authoritative `total_count`, and fail +# rather than silently truncate if GitHub's 1,000-result search horizon is ever +# reached. The old `gh search code --limit 100 --owner hyperpolymath` path both +# capped its answer and omitted metadatastician entirely. if [[ -n "${CONSUMERS_TSV_OVERRIDE:-}" && -f "$CONSUMERS_TSV_OVERRIDE" ]]; then cp "$CONSUMERS_TSV_OVERRIDE" "$CONSUMERS_TSV" echo "Using override consumers TSV: $CONSUMERS_TSV_OVERRIDE" >&2 else - gh search code "$needle" --owner hyperpolymath --limit 100 \ - --json repository,path \ - --jq '.[] | select(.path | startswith(".github/workflows/")) | "\(.repository.nameWithOwner)\t\(.path)"' \ - > "$CONSUMERS_TSV" || true + : > "$CONSUMERS_TSV" + + # Paginate GitHub code-search for a given scope (user:… or org:…), validate + # result integrity, and append workflow YAML paths to CONSUMERS_TSV. Fails + # if total_count exceeds GitHub's 1,000-result horizon or if the API returns + # incomplete_results=true. + # + # Args: + # $1 scope qualifier (e.g. "user:hyperpolymath" or "org:metadatastician") + # Env: + # needle search pattern (reusable path + old SHA) + # GH_BIN GitHub CLI command + # CONSUMERS_TSV output file (appended) + # Returns: + # 0 on success, 1 on search failure or integrity violation + search_scope() { + local scope="$1" page=1 body total incomplete count + while :; do + body=$($GH_BIN api -X GET search/code \ + -f "q=${needle} ${scope}" \ + -F per_page=100 \ + -F "page=${page}") || { + echo "ERROR: code search failed for scope '${scope}' page ${page}" >&2 + return 1 + } + + total=$(jq -r '.total_count' <<<"$body") + incomplete=$(jq -r '.incomplete_results' <<<"$body") + [[ "$total" =~ ^[0-9]+$ && "$incomplete" == false ]] || { + echo "ERROR: invalid/incomplete code-search result for '${scope}'" >&2 + return 1 + } + (( total <= 1000 )) || { + echo "ERROR: '${scope}' has ${total} matches, beyond GitHub's 1,000-result search horizon; split the query before propagating" >&2 + return 1 + } + + jq -r '.items[] + | select(.path | startswith(".github/workflows/")) + | select(.path | test("\\.ya?ml$")) + | "\(.repository.full_name)\t\(.path)"' <<<"$body" >> "$CONSUMERS_TSV" + + count=$(jq '.items | length' <<<"$body") + (( count == 100 && page * 100 < total )) || break + page=$((page + 1)) + done + } + + search_scope "user:hyperpolymath" + search_scope "org:metadatastician" + sort -u -o "$CONSUMERS_TSV" "$CONSUMERS_TSV" fi # Drop fork repos — per estate license policy, third-party / forked stuff is # off-limits. (gh search code does not filter forks; we look up each owner-repo # pair and skip forks.) For large sweeps this round-trips N times — cache as # needed. +# +# Filters a TSV of repo + workflow path pairs, removing forks, archived repos, +# and inaccessible repositories. Overwrites the input file in-place with the +# filtered result. +# +# Args: +# $1 path to TSV file (format: "owner/repoworkflow_path") +# Env: +# GH_BIN GitHub CLI command +# Side effects: +# Overwrites the input TSV with filtered content (non-fork, non-archived, +# accessible repos only). Logs skipped repos to stderr. filter_forks() { local tsv="$1" local out="${tsv}.no-forks" : > "$out" while IFS=$'\t' read -r repo path; do - local is_fork - is_fork=$(gh repo view "$repo" --json isFork --jq '.isFork' 2>/dev/null || echo "true") - if [[ "$is_fork" == "false" ]]; then + local repo_state + repo_state=$($GH_BIN api "repos/${repo}" --jq '[.fork, .archived] | @tsv' 2>/dev/null || printf 'true\ttrue\n') + if [[ "$repo_state" == $'false\tfalse' ]]; then printf '%s\t%s\n' "$repo" "$path" >> "$out" else - echo "SKIP (fork): $repo" >&2 + echo "SKIP (fork, archived, or inaccessible): $repo" >&2 fi done < "$tsv" mv "$out" "$tsv" } -# Skip fork-filter if the operator supplied an override TSV — they've already vetted it. -if [[ -s "$CONSUMERS_TSV" && -z "${CONSUMERS_TSV_OVERRIDE:-}" ]]; then +# Apply the same active-root eligibility check to searched and overridden +# consumers so the test seam cannot bypass the production safety boundary. +if [[ -s "$CONSUMERS_TSV" ]]; then filter_forks "$CONSUMERS_TSV" fi @@ -221,6 +323,6 @@ fi echo "Firing repository_dispatch propagate-sha-bump → $FARM_REPO ($n_consumers consumers)" >&2 printf '%s' "$payload" \ - | gh api -X POST "repos/${FARM_REPO}/dispatches" --input - + | "$GH_BIN" api -X POST "repos/${FARM_REPO}/dispatches" --input - echo "OK: dispatch fired. Receiver workflow will run async on $FARM_REPO." >&2 diff --git a/tests/propagate-sha-bump-smoke.sh b/tests/propagate-sha-bump-smoke.sh index 1ad1b825..027522a8 100755 --- a/tests/propagate-sha-bump-smoke.sh +++ b/tests/propagate-sha-bump-smoke.sh @@ -15,6 +15,23 @@ SCRIPT="scripts/propagate-sha-bump.sh" tmp=$(mktemp -d) trap 'rm -rf "$tmp"' EXIT +# Hermetic GitHub seam. The actuator must prove the target is reachable and +# that the reusable exists before it considers consumers; these responses model +# a mainline target without touching the network. +cat > "$tmp/gh" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +case "$*" in + *'/compare/'*) printf '%s\n' "${MOCK_COMPARE_STATUS:-ahead}" ;; + *'/contents/'*) printf '%s\n' file ;; + *'repos/hyperpolymath/standards --jq .default_branch'*) printf '%s\n' main ;; + *'--jq [.fork, .archived] | @tsv'*) printf 'false\tfalse\n' ;; + *) printf '%s\n' '{}' ;; +esac +EOF +chmod +x "$tmp/gh" +export GH_BIN="$tmp/gh" + pass=0 fail=0 @@ -147,6 +164,14 @@ rc=$? set -e assert_exit "DRY_RUN with valid finding → exit 0" 0 "$rc" +if printf '%s' "$out" | grep -q 'hyperpolymath/repo-a'; then + echo "ok active override consumer remains eligible" + ((pass++)) +else + echo "FAIL active override consumers were filtered out" + ((fail++)) +fi + if printf '%s' "$out" | grep -q '"event_type": "propagate-sha-bump"'; then echo "ok payload contains event_type" ((pass++)) @@ -173,6 +198,21 @@ else ((fail++)) fi +# 7. Existing but non-mainline new_sha → hard refusal before fan-out. +set +e +out=$(MOCK_COMPARE_STATUS=diverged CONSUMERS_TSV_OVERRIDE="$tmp/consumers.tsv" \ + DRY_RUN=true "$SCRIPT" /ignored "$tmp/good.json" 2>&1) +rc=$? +set -e +assert_exit "diverged reusable target → exit 1" 1 "$rc" +if printf '%s' "$out" | grep -q 'not reachable'; then + echo "ok non-mainline refusal explains reachability" + ((pass++)) +else + echo "FAIL non-mainline refusal message missing" + ((fail++)) +fi + echo "" echo "passed: $pass failed: $fail" [[ "$fail" -eq 0 ]]