|
| 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