Skip to content

Commit e312f46

Browse files
feat(scripts): propagate-sha-bump.sh — actuation for hypatia#418 (#248) (#249)
## Summary Actuation-half of the three-system propagation architecture (#248): ``` hypatia (detection) → gitbot-fleet (THIS) → .git-private-farm (propagation) ``` - \`scripts/propagate-sha-bump.sh\` consumes a hypatia finding with rule \`reusable_workflow_sha_bump_needs_propagation\`, pre-filters the upstream PR title (HARD), enumerates consumers via \`gh search code\`, drops forks, and fires a \`propagate-sha-bump\` \`repository_dispatch\` event into \`.git-private-farm\`. - \`scripts/fix-script-registry.json\` wires the recipe → script; \`dispatch-runner.sh\` routes via the existing \`by_recipe\` map. - \`tests/propagate-sha-bump-smoke.sh\` exercises the refusal + DRY_RUN paths (10/10 passing locally). ## Safety properties Canonical title-keyword gate lives HERE (per \`feedback_pr_sweep_title_keyword_exclusion\` + \`feedback_no_automated_licence_edits\`). The receiver workflow re-checks belt-and-braces. - \`pr_title\` matching \`license|SPDX|PMPL|MPL|AGPL|GPL|Apache|copyright|attribution|relicens|secret|vulnerab|CVE-\` → exit 0 (REFUSED, routed to manual review — not an error path). - \`title_suffix\` synthesised from **safe** metadata (workflow slug + short SHA), NOT from \`pr_title\`. So even if a forbidden keyword somehow slipped past the pre-filter, the dispatched PR title would not carry it. - \`old_sha == new_sha\` rejected. - \`source_repo\` constrained to \`hyperpolymath/*\`. - \`source_workflow\` constrained to \`.github/workflows/*.{yml,yaml}\` or \`action.{yml,yaml}\`. - Forks dropped via \`gh repo view --json isFork\`. - \`DRY_RUN=true\` prints the payload instead of dispatching. ## Coupling with the other two PRs - **Depends on**: hyperpolymath/hypatia#419 — the finding shape this script consumes. - **Triggers**: hyperpolymath/.git-private-farm#68 — the receiver workflow + scripts (#66). Each can land independently; once all three land, the chain end-to-ends. ## Test plan - [x] Wrong rule name → exit 1 - [x] Malformed SHA → exit 1 - [x] \`old_sha == new_sha\` → exit 1 - [x] Non-estate \`source_repo\` → exit 1 - [x] License keyword in \`pr_title\` → exit 0 (REFUSED message printed) - [x] DRY_RUN with valid finding → exit 0, payload composed - [x] \`branch_name\` slug correct (\`ci/bump-<workflow-slug>-<short-sha>\`) - [x] \`title_suffix\` synthesised from safe metadata only - [ ] End-to-end (post-merge): hypatia emits → dispatch-runner picks up → this script dispatches → farm receiver runs → consumer PRs land - [ ] Smoke test as a CI job (follow-up — leaving as local script for first landing) ## Related - #248 — this PR closes - hyperpolymath/hypatia#419 — detection rule (PR open) - hyperpolymath/.git-private-farm#66 — propagation scripts (PR open) - hyperpolymath/.git-private-farm#68 — receiver workflow (PR open) Closes #248 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: hyperpolymath <hyperpolymath@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent a2c5584 commit e312f46

3 files changed

Lines changed: 406 additions & 1 deletion

File tree

scripts/fix-script-registry.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,8 @@
102102
"codeql-cron-monthly": "fix-codeql-cron-monthly.sh",
103103
"chapel-manpath-guard": "fix-chapel-manpath-guard.sh",
104104
"chapel-chpl-llvm-export": "fix-chapel-chpl-llvm-export.sh",
105-
"chapel-replace-chpl-about-with-version": "fix-chapel-replace-chpl-about.sh"
105+
"chapel-replace-chpl-about-with-version": "fix-chapel-replace-chpl-about.sh",
106+
"reusable_workflow_sha_bump_needs_propagation": "propagate-sha-bump.sh"
106107
}
107108
}
108109
}

scripts/propagate-sha-bump.sh

Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
#!/usr/bin/env bash
2+
# SPDX-License-Identifier: MPL-2.0
3+
#
4+
# propagate-sha-bump.sh — actuation half of the three-system propagation arch.
5+
#
6+
# hypatia (detection) → gitbot-fleet (THIS) → .git-private-farm (propagation)
7+
#
8+
# Consumes a hypatia finding with
9+
# rule = reusable_workflow_sha_bump_needs_propagation
10+
# (see hyperpolymath/hypatia#418), pre-filters by title keyword (HARD —
11+
# per feedback_pr_sweep_title_keyword_exclusion + feedback_no_automated_licence_edits),
12+
# enumerates estate consumers pinning the old SHA, and fires a
13+
# repository_dispatch event of type `propagate-sha-bump` into
14+
# hyperpolymath/.git-private-farm where the receiver workflow runs
15+
# `scripts/sha-bump-propagate.sh`.
16+
#
17+
# Usage (called by dispatch-runner.sh):
18+
# propagate-sha-bump.sh <repo_path_ignored> <finding.json>
19+
#
20+
# `repo_path` is ignored — this script operates on the finding alone, not
21+
# on the upstream repo's working tree.
22+
#
23+
# Required env:
24+
# GH_TOKEN gh CLI auth, repo + workflow scopes
25+
#
26+
# Optional env:
27+
# FARM_REPO default "hyperpolymath/.git-private-farm"
28+
# DRY_RUN "true" prints the payload without dispatching
29+
set -euo pipefail
30+
31+
usage() {
32+
echo "Usage: $0 <repo_path_ignored> <finding.json>" >&2
33+
exit 64
34+
}
35+
36+
[[ $# -ge 2 ]] || usage
37+
38+
FINDING_FILE="$2"
39+
[[ -f "$FINDING_FILE" ]] || { echo "ERROR: finding file not found: $FINDING_FILE" >&2; exit 1; }
40+
41+
FARM_REPO="${FARM_REPO:-hyperpolymath/.git-private-farm}"
42+
DRY_RUN="${DRY_RUN:-false}"
43+
44+
# Title-keyword exclusion regex. Keep in sync with:
45+
# feedback_pr_sweep_title_keyword_exclusion
46+
# farm receiver workflow .github/workflows/sha-bump-propagate.yml
47+
# Case-insensitive — grep -iE.
48+
FORBIDDEN_KEYWORDS='license|SPDX|PMPL|MPL|AGPL|GPL|Apache|copyright|attribution|relicens|secret|vulnerab|CVE-'
49+
50+
# --- 1. Parse finding ----------------------------------------------------------
51+
52+
rule=$(jq -r '.rule // ""' "$FINDING_FILE")
53+
source_repo=$(jq -r '.source_repo // ""' "$FINDING_FILE")
54+
source_workflow=$(jq -r '.source_workflow // ""' "$FINDING_FILE")
55+
old_sha=$(jq -r '.old_sha // ""' "$FINDING_FILE")
56+
new_sha=$(jq -r '.new_sha // ""' "$FINDING_FILE")
57+
pr_title=$(jq -r '.pr_title // ""' "$FINDING_FILE")
58+
pr_number=$(jq -r '.pr_number // ""' "$FINDING_FILE")
59+
60+
# Hard rule-name gate — refuse to operate on findings of any other shape.
61+
if [[ "$rule" != "reusable_workflow_sha_bump_needs_propagation" ]]; then
62+
echo "ERROR: finding rule mismatch: got '$rule', expected 'reusable_workflow_sha_bump_needs_propagation'" >&2
63+
exit 1
64+
fi
65+
66+
# --- 2. SHA + path validation -------------------------------------------------
67+
68+
for v in old_sha new_sha; do
69+
val="${!v}"
70+
if ! printf '%s' "$val" | grep -qE '^[0-9a-f]{40}$'; then
71+
echo "ERROR: $v is not a 40-char hex SHA: $val" >&2
72+
exit 1
73+
fi
74+
done
75+
76+
if [[ "$old_sha" == "$new_sha" ]]; then
77+
echo "ERROR: old_sha equals new_sha — nothing to propagate" >&2
78+
exit 1
79+
fi
80+
81+
case "$source_repo" in
82+
hyperpolymath/*) ;;
83+
*) echo "ERROR: source_repo not in hyperpolymath/* : '$source_repo'" >&2; exit 1 ;;
84+
esac
85+
86+
case "$source_workflow" in
87+
.github/workflows/*.yml|.github/workflows/*.yaml|action.yml|action.yaml) ;;
88+
*) echo "ERROR: source_workflow not in expected shape: '$source_workflow'" >&2; exit 1 ;;
89+
esac
90+
91+
# --- 3. Title-keyword pre-filter (HARD) ---------------------------------------
92+
93+
# Per feedback_no_automated_licence_edits: licence/SPDX changes are MANUAL,
94+
# even if policy-correct. The receiver workflow re-checks this (belt-and-braces),
95+
# but the canonical gate lives HERE.
96+
if printf '%s' "$pr_title" | grep -iqE "$FORBIDDEN_KEYWORDS"; then
97+
echo "REFUSED: pr_title matched forbidden keyword pattern — routing to manual review." >&2
98+
echo " source_repo=$source_repo source_workflow=$source_workflow" >&2
99+
echo " pr_title=$pr_title" >&2
100+
echo " Owner must approve and apply this bump manually, per-consumer." >&2
101+
exit 0 # NOT an error — this is the expected, correct refusal path.
102+
fi
103+
104+
# --- 4. Build consumer TSV via code search ------------------------------------
105+
106+
# Construct the search pattern the codebases use to pin this workflow.
107+
# Example: `uses: hyperpolymath/standards/.github/workflows/governance-reusable.yml@<OLD_SHA>`
108+
# (with `@<OLD_SHA>` truncated — gh code-search is whitespace-tolerant).
109+
# We search for the path + SHA combination; the TSV emits `<owner>/<repo>\t<workflow_path>`.
110+
111+
# Strip the `.github/workflows/` prefix for the search needle, since the full
112+
# `uses: …` line includes the source repo path.
113+
needle="${source_repo}/${source_workflow}@${old_sha}"
114+
115+
TMPDIR_RUN=$(mktemp -d -t propagate-sha-bump.XXXXXX)
116+
trap 'rm -rf "$TMPDIR_RUN"' EXIT
117+
118+
CONSUMERS_TSV="$TMPDIR_RUN/consumers.tsv"
119+
120+
echo "Enumerating consumers pinning: $needle" >&2
121+
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.
125+
if [[ -n "${CONSUMERS_TSV_OVERRIDE:-}" && -f "$CONSUMERS_TSV_OVERRIDE" ]]; then
126+
cp "$CONSUMERS_TSV_OVERRIDE" "$CONSUMERS_TSV"
127+
echo "Using override consumers TSV: $CONSUMERS_TSV_OVERRIDE" >&2
128+
else
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
133+
fi
134+
135+
# Drop fork repos — per estate license policy, third-party / forked stuff is
136+
# off-limits. (gh search code does not filter forks; we look up each owner-repo
137+
# pair and skip forks.) For large sweeps this round-trips N times — cache as
138+
# needed.
139+
filter_forks() {
140+
local tsv="$1"
141+
local out="${tsv}.no-forks"
142+
: > "$out"
143+
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
147+
printf '%s\t%s\n' "$repo" "$path" >> "$out"
148+
else
149+
echo "SKIP (fork): $repo" >&2
150+
fi
151+
done < "$tsv"
152+
mv "$out" "$tsv"
153+
}
154+
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
157+
filter_forks "$CONSUMERS_TSV"
158+
fi
159+
160+
n_consumers=$(wc -l < "$CONSUMERS_TSV")
161+
echo "Consumers identified: $n_consumers" >&2
162+
163+
if [[ "$n_consumers" -eq 0 ]]; then
164+
echo "No estate consumers found for $needle — nothing to propagate." >&2
165+
exit 0
166+
fi
167+
168+
# --- 5. Compose payload + fire repository_dispatch ----------------------------
169+
170+
# Slug the workflow basename for branch name.
171+
workflow_slug=$(basename "$source_workflow" .yml | tr '/.' '--')
172+
short_new_sha="${new_sha:0:7}"
173+
174+
branch_name="ci/bump-${workflow_slug}-${short_new_sha}"
175+
176+
# title_suffix re-checked against forbidden keywords; we synthesise it from
177+
# safe metadata only (workflow slug + short SHA), NOT from pr_title.
178+
title_suffix="bump ${source_workflow}@${short_new_sha}"
179+
180+
body_blurb=$(cat <<EOF
181+
Upstream SHA bump propagation.
182+
183+
- Reusable: \`${source_repo}/${source_workflow}\`
184+
- Old: \`${old_sha}\`
185+
- New: \`${new_sha}\`
186+
- Upstream PR: ${source_repo}#${pr_number}
187+
- Driven by: hypatia rule \`reusable_workflow_sha_bump_needs_propagation\` (gitbot-fleet propagate-sha-bump.sh).
188+
EOF
189+
)
190+
191+
# Build client_payload as JSON.
192+
consumers_blob=$(cat "$CONSUMERS_TSV")
193+
194+
payload=$(jq -n \
195+
--arg reusable_path "${source_repo}/${source_workflow}" \
196+
--arg old_sha "$old_sha" \
197+
--arg new_sha "$new_sha" \
198+
--arg branch_name "$branch_name" \
199+
--arg title_suffix "$title_suffix" \
200+
--arg body_blurb "$body_blurb" \
201+
--arg consumers "$consumers_blob" \
202+
'{
203+
event_type: "propagate-sha-bump",
204+
client_payload: {
205+
reusable_path: $reusable_path,
206+
old_sha: $old_sha,
207+
new_sha: $new_sha,
208+
branch_name: $branch_name,
209+
title_suffix: $title_suffix,
210+
body_blurb: $body_blurb,
211+
consumers: $consumers
212+
}
213+
}')
214+
215+
if [[ "$DRY_RUN" == "true" ]]; then
216+
echo "DRY-RUN — would dispatch to $FARM_REPO:" >&2
217+
printf '%s\n' "$payload"
218+
exit 0
219+
fi
220+
221+
echo "Firing repository_dispatch propagate-sha-bump → $FARM_REPO ($n_consumers consumers)" >&2
222+
223+
printf '%s' "$payload" \
224+
| gh api -X POST "repos/${FARM_REPO}/dispatches" --input -
225+
226+
echo "OK: dispatch fired. Receiver workflow will run async on $FARM_REPO." >&2

0 commit comments

Comments
 (0)