From 247894b98442585d59317be7604ceded7f72689e Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Fri, 4 Sep 2026 16:47:37 +0100 Subject: [PATCH 1/3] fix(fleet): validate and fully enumerate SHA fan-out --- scripts/propagate-sha-bump.sh | 100 ++++++++++++++++++++++++++---- tests/propagate-sha-bump-smoke.sh | 31 +++++++++ 2 files changed, 119 insertions(+), 12 deletions(-) diff --git a/scripts/propagate-sha-bump.sh b/scripts/propagate-sha-bump.sh index af923e89..ad22bf43 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,17 +160,52 @@ 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" + + 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 @@ -141,12 +217,12 @@ filter_forks() { 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" @@ -221,6 +297,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..1d50cd19 100755 --- a/tests/propagate-sha-bump-smoke.sh +++ b/tests/propagate-sha-bump-smoke.sh @@ -15,6 +15,22 @@ 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 ;; + *) printf '%s\n' '{}' ;; +esac +EOF +chmod +x "$tmp/gh" +export GH_BIN="$tmp/gh" + pass=0 fail=0 @@ -173,6 +189,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 ]] From f7d742d9f286f9b148541a27a003e2ec838364e2 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Fri, 4 Sep 2026 17:25:21 +0000 Subject: [PATCH 2/3] fix: apply CodeRabbit auto-fixes Fixed 1 file(s) based on 1 failed pre-merge check. Co-authored-by: CodeRabbit --- scripts/propagate-sha-bump.sh | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/scripts/propagate-sha-bump.sh b/scripts/propagate-sha-bump.sh index ad22bf43..402fecce 100755 --- a/scripts/propagate-sha-bump.sh +++ b/scripts/propagate-sha-bump.sh @@ -170,6 +170,19 @@ if [[ -n "${CONSUMERS_TSV_OVERRIDE:-}" && -f "$CONSUMERS_TSV_OVERRIDE" ]]; then else : > "$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 @@ -212,6 +225,18 @@ fi # 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" From 075ba324c0aa2cc42199e6c4aed2278515b1da89 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Fri, 4 Sep 2026 18:37:19 +0100 Subject: [PATCH 3/3] test(fleet): exercise active consumer filtering --- scripts/propagate-sha-bump.sh | 5 +++-- tests/propagate-sha-bump-smoke.sh | 9 +++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/scripts/propagate-sha-bump.sh b/scripts/propagate-sha-bump.sh index 402fecce..33e1f816 100755 --- a/scripts/propagate-sha-bump.sh +++ b/scripts/propagate-sha-bump.sh @@ -253,8 +253,9 @@ filter_forks() { 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 diff --git a/tests/propagate-sha-bump-smoke.sh b/tests/propagate-sha-bump-smoke.sh index 1d50cd19..027522a8 100755 --- a/tests/propagate-sha-bump-smoke.sh +++ b/tests/propagate-sha-bump-smoke.sh @@ -25,6 +25,7 @@ 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 @@ -163,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++))