diff --git a/scripts/propagate-hypatia-caller-id.sh b/scripts/propagate-hypatia-caller-id.sh new file mode 100755 index 000000000..486943d5d --- /dev/null +++ b/scripts/propagate-hypatia-caller-id.sh @@ -0,0 +1,196 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) +# +# propagate-hypatia-caller-id.sh — deliberate, audit-first standardisation of the +# Hypatia wrapper's caller job id across consumer repositories. +# +# WHY THIS EXISTS +# --------------- +# The check a reusable-caller job publishes is ` / `. The estate's canonical required context is +# `hypatia / Hypatia Neurosymbolic Analysis` (docs/audits/audit-hypatia-pin-orphan-2026-05-27.adoc), +# which requires the caller job to be named `hypatia`. In practice the caller is +# named `scan` in the large majority of consumer repositories, so they publish +# `scan / Hypatia Neurosymbolic Analysis` instead. Two names for one gate: any +# required context written against one of them is unsatisfiable in a repository +# that publishes the other — the defect class of hyperpolymath/tropical-types#17. +# +# This is the standards-side action that settles it: rename the caller job id to +# the canonical one, per repository, deliberately. +# +# Mode: READ-ONLY (audit) by default. Pass --fix to stage the rewrite in the +# consumer checkout. The script NEVER commits, NEVER pushes, and NEVER edits a +# required-status-check — that is the human's / bot's job (estate guardrail: no +# unattended mutations; mirrors scripts/propagate-workflow-pins.sh). +# +# Principles (do not violate): +# * Non-destructive: rewrites one job key, nothing else. The body of the job, +# its pin, its inputs and its secrets are untouched byte-for-byte. +# * Idempotent: a caller already at the canonical id is left untouched. +# * Requirement-aware: a rename silently breaks any repository whose rules name +# the old prefixed string. Those are reported BLOCKED and refused under --fix +# until the requirement is updated in the same change. +# * Honest about blindness: legacy branch protection cannot be read with an +# ordinary token. If requirements cannot be read, the verdict is UNVERIFIED +# and --fix refuses. Never rename on an unread requirement set. +# * Shell-only: no Python, no Ruby. +# +# Usage: +# bash scripts/propagate-hypatia-caller-id.sh [PATH] +# bash scripts/propagate-hypatia-caller-id.sh --fix [PATH] +# bash scripts/propagate-hypatia-caller-id.sh --caller-id [PATH] +# bash scripts/propagate-hypatia-caller-id.sh --no-network [PATH] +# +# PATH may be a single consumer repo (has .github/workflows) or a parent +# directory of many repos. Defaults to the current directory. +# +# --caller-id canonical caller job id (default: hypatia) +# --no-network never probe the API; requirements count as UNVERIFIED +# --fix rewrite the caller job id in place (no commit, no push) +# +# Environment seams (testing / CI): +# HYPATIA_REQUIRED_JSON file containing a JSON array of required context +# strings for the repositories under PATH. When set, it +# replaces the API probe entirely (hermetic). +# GITHUB_REPOSITORY used to probe one repository's rulesets when the path +# argument is a single repo and the API is reachable. +# +# Output: tab-separated audit lines +# \t\t\t +# verdict ∈ canonical | stage | BLOCKED | UNVERIFIED | unchanged-shape | absent +set -uo pipefail + +CANONICAL="hypatia" +MODE_FIX=0 +OFFLINE=0 +TARGET="." + +while [ $# -gt 0 ]; do + case "$1" in + --fix) MODE_FIX=1; shift ;; + --no-network) OFFLINE=1; shift ;; + --caller-id) CANONICAL="${2:-}"; shift 2 ;; + -h|--help) sed -n '2,60p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + -*) printf 'unknown option: %s\n' "$1" >&2; exit 2 ;; + *) TARGET="$1"; shift ;; + esac +done + +[ -n "$CANONICAL" ] || { echo "canonical caller id must not be empty" >&2; exit 2; } + +say() { printf '%s\n' "$*"; } +note() { printf '::notice::%s\n' "$*" >&2; } + +# ---- requirement discovery ------------------------------------------------- +# The rename is only safe when nothing requires the old prefixed string. +# Returns "read" / "unreadable" and prints one required context per line. +requirements() { + if [ -n "${HYPATIA_REQUIRED_JSON:-}" ]; then + [ -f "$HYPATIA_REQUIRED_JSON" ] || { note "HYPATIA_REQUIRED_JSON not found"; echo unreadable; return 0; } + if ! command -v jq >/dev/null 2>&1; then echo unreadable; return 0; fi + jq -r '.[]' "$HYPATIA_REQUIRED_JSON" 2>/dev/null || { echo unreadable; return 0; } + echo "__read__" + return 0 + fi + [ "$OFFLINE" = 1 ] && { echo unreadable; return 0; } + command -v gh >/dev/null 2>&1 || { echo unreadable; return 0; } + command -v jq >/dev/null 2>&1 || { echo unreadable; return 0; } + [ -n "${GITHUB_REPOSITORY:-}" ] || { echo unreadable; return 0; } + local list body + list="$(gh api "repos/$GITHUB_REPOSITORY/rulesets" 2>/dev/null || true)" + printf '%s' "$list" | jq -e 'type == "array"' >/dev/null 2>&1 || { echo unreadable; return 0; } + for id in $(printf '%s' "$list" | jq -r '.[]? | select(.target=="branch") | .id'); do + body="$(gh api "repos/$GITHUB_REPOSITORY/rulesets/$id" 2>/dev/null || true)" + printf '%s' "$body" | jq -r ' + if type == "object" and .enforcement != "disabled" then + .rules[]? | select(.type=="required_status_checks") | .parameters.required_status_checks[]? + | (.context|tostring) + else empty end' 2>/dev/null + done + echo "__read__" +} + +# Read the requirement set once for the whole run: a parent directory of repos +# cannot be probed per-repo with a single GITHUB_REPOSITORY, so the seam is +# per-run by design and the API path is documented as single-repo only. +REQ_RAW="$(requirements)" +REQ_READABLE=0 +REQ_LIST="" +if printf '%s\n' "$REQ_RAW" | grep -qx '__read__'; then + REQ_READABLE=1 + REQ_LIST="$(printf '%s\n' "$REQ_RAW" | grep -vx '__read__')" +fi + +requires_old_name() { # $1 = old caller id + [ "$REQ_READABLE" = 1 ] || return 2 # 2 = unknown + printf '%s\n' "$REQ_LIST" | grep -Fxq "$1 / Hypatia Neurosymbolic Analysis" +} + +process_repo() { # $1 = repository directory + local repo="$1" wf caller verdict + repo="$(cd "$repo" && pwd)" + wf="$repo/.github/workflows/hypatia-scan.yml" + if [ ! -f "$wf" ]; then + printf '%s\t%s\t-\tabsent\n' "$(basename "$repo")" ".github/workflows/hypatia-scan.yml" + return 0 + fi + + # The caller job is the one whose `uses:` names the estate's scan reusable. + caller="$(awk ' + /^[[:space:]]*[A-Za-z0-9_.-]+:[[:space:]]*$/ { key=$1; sub(/:$/, "", key) } + /^[[:space:]]+uses:.*hypatia-scan-reusable\.ya?ml@/ { print key; exit } + ' "$wf")" + + if [ -z "$caller" ]; then + printf '%s\t%s\t-\tunchanged-shape\n' "$(basename "$repo")" ".github/workflows/hypatia-scan.yml" + return 0 + fi + if [ "$caller" = "$CANONICAL" ]; then + printf '%s\t%s\t%s\tcanonical\n' "$(basename "$repo")" ".github/workflows/hypatia-scan.yml" "$caller" + return 0 + fi + + # A rename changes the published check name. Refuse when a rule names the old one. + if requires_old_name "$caller"; then + verdict="BLOCKED" + elif [ "$REQ_READABLE" = 1 ]; then + verdict="stage" + else + verdict="UNVERIFIED" + fi + + if [ "$verdict" = "stage" ] && [ "$MODE_FIX" = 1 ]; then + # Rewrite only the job key line: same indentation, same position. + local tmp; tmp="$(mktemp)" + awk -v from="$caller" -v to="$CANONICAL" ' + BEGIN { done = 0 } + !done && $0 ~ "^[[:space:]]*" from ":[[:space:]]*$" { sub(from ":", to ":"); done = 1 } + { print } + ' "$wf" > "$tmp" && mv "$tmp" "$wf" + verdict="staged" + fi + + printf '%s\t%s\t%s\t%s\n' "$(basename "$repo")" ".github/workflows/hypatia-scan.yml" "$caller" "$verdict" + return 0 +} + +if [ -f "$TARGET/.github/workflows/hypatia-scan.yml" ]; then + process_repo "$TARGET" +else + found=0 + for d in "$TARGET"/*/; do + [ -d "$d/.github/workflows" ] || continue + process_repo "$d" + found=1 + done + [ "$found" = 1 ] || { note "no consumer repositories found under $TARGET"; exit 1; } +fi + +if [ "$REQ_READABLE" != 1 ]; then + note "requirements unreadable (no API access, offline, or a multi-repo path): every differing caller id is UNVERIFIED, and --fix refuses. Read the requirements first — a rename is only safe when nothing requires the old name." +fi +if [ "$MODE_FIX" = 1 ]; then + note "--fix only stages edits. Commit, push and open the PR yourself or via the fleet bot (estate guardrail: no unattended mutations)." +fi +exit 0 diff --git a/scripts/tests/propagate-hypatia-caller-id-test.sh b/scripts/tests/propagate-hypatia-caller-id-test.sh new file mode 100755 index 000000000..f22067d70 --- /dev/null +++ b/scripts/tests/propagate-hypatia-caller-id-test.sh @@ -0,0 +1,115 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) +# +# propagate-hypatia-caller-id-test.sh — fixture suite for +# scripts/propagate-hypatia-caller-id.sh. +# +# Builds synthetic consumer repositories in a temp directory and drives the +# audit / --fix / refused paths. No network, no gh, no token: the requirement +# probe is supplied through the HYPATIA_REQUIRED_JSON seam. +# +# Branches driven: +# * caller already canonical -> canonical, --fix is a no-op +# * caller `scan`, nothing requires the old name -> stage, --fix rewrites the +# job key and NOTHING else in the file +# * caller `scan`, a rule requires `scan / …` -> BLOCKED, --fix refuses and the +# file is byte-identical afterwards +# * workflow with no reusable call -> unchanged-shape +# * repository without the wrapper -> absent +# * requirements unreadable (offline) -> UNVERIFIED, --fix refuses +# +# Run: bash scripts/tests/propagate-hypatia-caller-id-test.sh +set -uo pipefail +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +SCRIPT="$SCRIPT_DIR/../propagate-hypatia-caller-id.sh" +[ -f "$SCRIPT" ] || { echo "FAIL: script not found at $SCRIPT"; exit 1; } + +fail=0 +check() { # name expected actual + if [ "$2" = "$3" ]; then printf 'PASS %s\n' "$1"; else printf 'FAIL %s\n expected: %s\n actual: %s\n' "$1" "$2" "$3"; fail=$((fail+1)); fi +} + +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT +mkdir -p "$work/fleet" + +wrapper() { # $1 dir, $2 caller id + mkdir -p "$1/.github/workflows" + cat > "$1/.github/workflows/hypatia-scan.yml" < "$work/fleet/eee-inline/.github/workflows/hypatia-scan.yml" <<'EOF' +name: Hypatia Scan +on: [push] +jobs: + scan: + name: Hypatia Neurosymbolic Analysis + runs-on: ubuntu-latest + steps: + - run: echo inline +EOF + +# Nothing requires the old prefixed name anywhere in the safe fixture. +printf '["hypatia / Hypatia Neurosymbolic Analysis","CodeRabbit"]' > "$work/safe.json" +# One rule requires it -> the rename must be refused for that repository. +printf '["scan / Hypatia Neurosymbolic Analysis"]' > "$work/blocking.json" + +out="$(HYPATIA_REQUIRED_JSON="$work/safe.json" bash "$SCRIPT" "$work/fleet" 2>/dev/null)" +check "canonical-caller-is-reported-canonical" "canonical" "$(printf '%s\n' "$out" | awk -F'\t' '$1=="aaa-canonical"{print $4}')" +check "differing-caller-is-stage" "stage" "$(printf '%s\n' "$out" | awk -F'\t' '$1=="bbb-needs-rename"{print $4}')" +check "no-wrapper-is-absent" "absent" "$(printf '%s\n' "$out" | awk -F'\t' '$1=="ddd-absent"{print $4}')" +check "non-reusable-is-unchanged-shape" "unchanged-shape" "$(printf '%s\n' "$out" | awk -F'\t' '$1=="eee-inline"{print $4}')" + +out="$(HYPATIA_REQUIRED_JSON="$work/blocking.json" bash "$SCRIPT" "$work/fleet" 2>/dev/null)" +check "required-old-name-blocks-the-rename" "BLOCKED" "$(printf '%s\n' "$out" | awk -F'\t' '$1=="ccc-blocked"{print $4}')" + +# --fix must refuse the blocked repo and stage the safe one. +before_blocked="$(cat "$work/fleet/ccc-blocked/.github/workflows/hypatia-scan.yml")" +HYPATIA_REQUIRED_JSON="$work/blocking.json" bash "$SCRIPT" --fix "$work/fleet" >/dev/null 2>&1 +after_blocked="$(cat "$work/fleet/ccc-blocked/.github/workflows/hypatia-scan.yml")" +check "fix-leaves-blocked-file-untouched" "$before_blocked" "$after_blocked" +check "fix-refuses-blocked-repo" "scan" "$(awk '/^[[:space:]]*[A-Za-z0-9_.-]+:[[:space:]]*$/{k=$1; sub(/:$/,"",k)} /uses:.*hypatia-scan-reusable/{print k; exit}' "$work/fleet/ccc-blocked/.github/workflows/hypatia-scan.yml")" + +before_safe="$(cat "$work/fleet/bbb-needs-rename/.github/workflows/hypatia-scan.yml")" +out="$(HYPATIA_REQUIRED_JSON="$work/safe.json" bash "$SCRIPT" --fix "$work/fleet" 2>/dev/null)" +check "fix-reports-staged" "staged" "$(printf '%s\n' "$out" | awk -F'\t' '$1=="bbb-needs-rename"{print $4}')" +after_safe="$(cat "$work/fleet/bbb-needs-rename/.github/workflows/hypatia-scan.yml")" +check "fix-renames-the-caller-key" "hypatia" "$(awk '/^[[:space:]]*[A-Za-z0-9_.-]+:[[:space:]]*$/{k=$1; sub(/:$/,"",k)} /uses:.*hypatia-scan-reusable/{print k; exit}' "$work/fleet/bbb-needs-rename/.github/workflows/hypatia-scan.yml")" +# Only the key line may differ: the pin, the secrets line and the comments stay. +check "fix-touches-only-the-job-key" "2" "$(diff <(printf '%s\n' "$before_safe") <(printf '%s\n' "$after_safe") | grep -c '^[<>]')" +check "fix-keeps-the-pin" "1" "$(printf '%s\n' "$after_safe" | grep -c '84355587cb2a1f86e6882de83514a32db2646e7a')" +check "fix-keeps-secrets-inherit" "1" "$(printf '%s\n' "$after_safe" | grep -c '^[[:space:]]*secrets: inherit$')" +check "fix-is-idempotent" "canonical" "$(HYPATIA_REQUIRED_JSON="$work/safe.json" bash "$SCRIPT" "$work/fleet" 2>/dev/null | awk -F'\t' '$1=="bbb-needs-rename"{print $4}')" + +# Unreadable requirements: never rename on an unknown requirement set. +wrapper "$work/fleet/fff-unverified" scan +out="$(bash "$SCRIPT" --no-network "$work/fleet" 2>/dev/null)" +check "unreadable-requirements-are-unverified" "UNVERIFIED" "$(printf '%s\n' "$out" | awk -F'\t' '$1=="fff-unverified"{print $4}')" +before="$(cat "$work/fleet/fff-unverified/.github/workflows/hypatia-scan.yml")" +bash "$SCRIPT" --no-network --fix "$work/fleet" >/dev/null 2>&1 +check "fix-refuses-when-requirements-unreadable" "$before" "$(cat "$work/fleet/fff-unverified/.github/workflows/hypatia-scan.yml")" + +if [ "$fail" -gt 0 ]; then + echo "::error file=$SCRIPT::$fail fixture(s) failed" + exit 1 +fi +echo "PASS $0"