2626# Optional env:
2727# FARM_REPO default "hyperpolymath/.git-private-farm"
2828# DRY_RUN "true" prints the payload without dispatching
29+ # GH_BIN GitHub CLI command (test seam; default "gh")
2930set -euo pipefail
3031
3132usage () {
@@ -40,6 +41,7 @@ FINDING_FILE="$2"
4041
4142FARM_REPO=" ${FARM_REPO:- hyperpolymath/ .git-private-farm} "
4243DRY_RUN=" ${DRY_RUN:- false} "
44+ GH_BIN=" ${GH_BIN:- gh} "
4345
4446# Title-keyword exclusion regex. Keep in sync with:
4547# feedback_pr_sweep_title_keyword_exclusion
@@ -88,6 +90,45 @@ case "$source_workflow" in
8890 * ) echo " ERROR: source_workflow not in expected shape: '$source_workflow '" >&2 ; exit 1 ;;
8991esac
9092
93+ # A SHA can exist in GitHub's object database yet be unusable as a cross-repo
94+ # reusable workflow. Feature-branch commits that are later squash-merged have
95+ # exactly that shape: Contents/Commits APIs resolve them, but Actions rejects
96+ # `uses: ...@sha` at startup with `workflow was not found`. Prove the proposed
97+ # target is on the source repository's default-branch history before searching
98+ # consumers or dispatching a mutation.
99+ default_branch=$( $GH_BIN api " repos/${source_repo} " --jq ' .default_branch' ) || {
100+ echo " ERROR: could not resolve ${source_repo} 's default branch" >&2
101+ exit 1
102+ }
103+
104+ compare_status=$( $GH_BIN api \
105+ " repos/${source_repo} /compare/${new_sha} ...${default_branch} " \
106+ --jq ' .status' ) || {
107+ echo " ERROR: could not verify ${new_sha} against ${source_repo} :${default_branch} " >&2
108+ exit 1
109+ }
110+
111+ case " $compare_status " in
112+ identical|ahead) ;;
113+ * )
114+ echo " ERROR: new_sha ${new_sha} exists but is not reachable from ${source_repo} :${default_branch} (compare status: ${compare_status:- unknown} )" >&2
115+ exit 1
116+ ;;
117+ esac
118+
119+ if [[ " $source_workflow " == .github/workflows/* ]]; then
120+ source_type=$( $GH_BIN api \
121+ " repos/${source_repo} /contents/${source_workflow} ?ref=${new_sha} " \
122+ --jq ' .type' ) || {
123+ echo " ERROR: ${source_workflow} does not resolve at ${source_repo} @${new_sha} " >&2
124+ exit 1
125+ }
126+ [[ " $source_type " == " file" ]] || {
127+ echo " ERROR: ${source_workflow} at ${source_repo} @${new_sha} is not a file" >&2
128+ exit 1
129+ }
130+ fi
131+
91132# --- 3. Title-keyword pre-filter (HARD) ---------------------------------------
92133
93134# Per feedback_no_automated_licence_edits: licence/SPDX changes are MANUAL,
@@ -119,41 +160,102 @@ CONSUMERS_TSV="$TMPDIR_RUN/consumers.tsv"
119160
120161echo " Enumerating consumers pinning: $needle " >&2
121162
122- # gh code-search has a 100-result cap per query. For larger sweeps the
123- # operator should pre-build a TSV manually and supply it via a CONSUMERS_TSV
124- # env override. Tracked here for posterity.
163+ # Search both named estates, page to the authoritative `total_count`, and fail
164+ # rather than silently truncate if GitHub's 1,000-result search horizon is ever
165+ # reached. The old `gh search code --limit 100 --owner hyperpolymath` path both
166+ # capped its answer and omitted metadatastician entirely.
125167if [[ -n " ${CONSUMERS_TSV_OVERRIDE:- } " && -f " $CONSUMERS_TSV_OVERRIDE " ]]; then
126168 cp " $CONSUMERS_TSV_OVERRIDE " " $CONSUMERS_TSV "
127169 echo " Using override consumers TSV: $CONSUMERS_TSV_OVERRIDE " >&2
128170else
129- gh search code " $needle " --owner hyperpolymath --limit 100 \
130- --json repository,path \
131- --jq ' .[] | select(.path | startswith(".github/workflows/")) | "\(.repository.nameWithOwner)\t\(.path)"' \
132- > " $CONSUMERS_TSV " || true
171+ : > " $CONSUMERS_TSV "
172+
173+ # Paginate GitHub code-search for a given scope (user:… or org:…), validate
174+ # result integrity, and append workflow YAML paths to CONSUMERS_TSV. Fails
175+ # if total_count exceeds GitHub's 1,000-result horizon or if the API returns
176+ # incomplete_results=true.
177+ #
178+ # Args:
179+ # $1 scope qualifier (e.g. "user:hyperpolymath" or "org:metadatastician")
180+ # Env:
181+ # needle search pattern (reusable path + old SHA)
182+ # GH_BIN GitHub CLI command
183+ # CONSUMERS_TSV output file (appended)
184+ # Returns:
185+ # 0 on success, 1 on search failure or integrity violation
186+ search_scope () {
187+ local scope=" $1 " page=1 body total incomplete count
188+ while : ; do
189+ body=$( $GH_BIN api -X GET search/code \
190+ -f " q=${needle} ${scope} " \
191+ -F per_page=100 \
192+ -F " page=${page} " ) || {
193+ echo " ERROR: code search failed for scope '${scope} ' page ${page} " >&2
194+ return 1
195+ }
196+
197+ total=$( jq -r ' .total_count' <<< " $body" )
198+ incomplete=$( jq -r ' .incomplete_results' <<< " $body" )
199+ [[ " $total " =~ ^[0-9]+$ && " $incomplete " == false ]] || {
200+ echo " ERROR: invalid/incomplete code-search result for '${scope} '" >&2
201+ return 1
202+ }
203+ (( total <= 1000 )) || {
204+ echo " ERROR: '${scope} ' has ${total} matches, beyond GitHub's 1,000-result search horizon; split the query before propagating" >&2
205+ return 1
206+ }
207+
208+ jq -r ' .items[]
209+ | select(.path | startswith(".github/workflows/"))
210+ | select(.path | test("\\.ya?ml$"))
211+ | "\(.repository.full_name)\t\(.path)"' <<< " $body" >> " $CONSUMERS_TSV "
212+
213+ count=$( jq ' .items | length' <<< " $body" )
214+ (( count == 100 && page * 100 < total )) || break
215+ page=$(( page + 1 ))
216+ done
217+ }
218+
219+ search_scope " user:hyperpolymath"
220+ search_scope " org:metadatastician"
221+ sort -u -o " $CONSUMERS_TSV " " $CONSUMERS_TSV "
133222fi
134223
135224# Drop fork repos — per estate license policy, third-party / forked stuff is
136225# off-limits. (gh search code does not filter forks; we look up each owner-repo
137226# pair and skip forks.) For large sweeps this round-trips N times — cache as
138227# needed.
228+ #
229+ # Filters a TSV of repo + workflow path pairs, removing forks, archived repos,
230+ # and inaccessible repositories. Overwrites the input file in-place with the
231+ # filtered result.
232+ #
233+ # Args:
234+ # $1 path to TSV file (format: "owner/repo<TAB>workflow_path")
235+ # Env:
236+ # GH_BIN GitHub CLI command
237+ # Side effects:
238+ # Overwrites the input TSV with filtered content (non-fork, non-archived,
239+ # accessible repos only). Logs skipped repos to stderr.
139240filter_forks () {
140241 local tsv=" $1 "
141242 local out=" ${tsv} .no-forks"
142243 : > " $out "
143244 while IFS=$' \t ' read -r repo path; do
144- local is_fork
145- is_fork =$( gh repo view " $ repo" --json isFork -- jq ' .isFork ' 2> /dev/null || echo " true" )
146- if [[ " $is_fork " == " false" ]]; then
245+ local repo_state
246+ repo_state =$( $GH_BIN api " repos/ ${ repo} " --jq ' [.fork, .archived] | @tsv ' 2> /dev/null || printf ' true\ttrue\n ' )
247+ if [[ " $repo_state " == $' false\t false ' ]]; then
147248 printf ' %s\t%s\n' " $repo " " $path " >> " $out "
148249 else
149- echo " SKIP (fork): $repo " >&2
250+ echo " SKIP (fork, archived, or inaccessible ): $repo " >&2
150251 fi
151252 done < " $tsv "
152253 mv " $out " " $tsv "
153254}
154255
155- # Skip fork-filter if the operator supplied an override TSV — they've already vetted it.
156- if [[ -s " $CONSUMERS_TSV " && -z " ${CONSUMERS_TSV_OVERRIDE:- } " ]]; then
256+ # Apply the same active-root eligibility check to searched and overridden
257+ # consumers so the test seam cannot bypass the production safety boundary.
258+ if [[ -s " $CONSUMERS_TSV " ]]; then
157259 filter_forks " $CONSUMERS_TSV "
158260fi
159261
221323echo " Firing repository_dispatch propagate-sha-bump → $FARM_REPO ($n_consumers consumers)" >&2
222324
223325printf ' %s' " $payload " \
224- | gh api -X POST " repos/${FARM_REPO} /dispatches" --input -
326+ | " $GH_BIN " api -X POST " repos/${FARM_REPO} /dispatches" --input -
225327
226328echo " OK: dispatch fired. Receiver workflow will run async on $FARM_REPO ." >&2
0 commit comments