Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 116 additions & 14 deletions scripts/propagate-sha-bump.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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/repo<TAB>workflow_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

Expand Down Expand Up @@ -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
40 changes: 40 additions & 0 deletions tests/propagate-sha-bump-smoke.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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' '{}' ;;
Comment thread
hyperpolymath marked this conversation as resolved.
esac
EOF
chmod +x "$tmp/gh"
export GH_BIN="$tmp/gh"

pass=0
fail=0

Expand Down Expand Up @@ -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++))
Expand All @@ -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 ]]
Loading