diff --git a/scripts/apply-workflow-pins-remote.sh b/scripts/apply-workflow-pins-remote.sh index a56a37bb3..c7dade0ac 100755 --- a/scripts/apply-workflow-pins-remote.sh +++ b/scripts/apply-workflow-pins-remote.sh @@ -63,6 +63,10 @@ set -uo pipefail +# The shared ancestry assertion (D5c): every pin bumper calls the same one. +# shellcheck source-path=SCRIPTDIR source=lib/pin-ancestry.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib/pin-ancestry.sh" + STANDARDS_REPO="hyperpolymath/standards" TARGET_SHA="${STANDARDS_TARGET_SHA:-}" DO_FIX=0 @@ -278,19 +282,7 @@ resolve_target() { # The comparison is done SERVER-side so a shallow/partial clone cannot produce # a false negative. validate_target() { - local sha="$1" status - case "$sha" in - *[!0-9a-fA-F]*|"") log "ERROR: target '$sha' is not hex."; return 1 ;; - esac - if [ "${#sha}" -ne 40 ]; then - log "ERROR: target must be a full 40-hex SHA, got ${#sha} chars."; return 1 - fi - status=$(gh api "repos/${STANDARDS_REPO}/compare/${sha}...main" --jq '.status' 2>/dev/null) - case "$status" in - identical|ahead) return 0 ;; - "") log "ERROR: could not prove ${sha} is reachable from ${STANDARDS_REPO} main; refusing."; return 1 ;; - *) log "ERROR: ${sha} is NOT reachable from main (compare status: ${status}); refusing."; return 1 ;; - esac + assert_pin_ancestry "$STANDARDS_REPO" "$1" main } # sha_is_commit — is this a real commit in standards? diff --git a/scripts/lib/pin-ancestry.sh b/scripts/lib/pin-ancestry.sh new file mode 100644 index 000000000..2091438de --- /dev/null +++ b/scripts/lib/pin-ancestry.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) +# +# pin-ancestry.sh — the ONE ancestry assertion every pin bumper calls +# (owner ruling D5c, standards#787; mandatory under D19a). +# +# Source it; do not execute it: +# +# . "$(dirname "${BASH_SOURCE[0]}")/lib/pin-ancestry.sh" +# assert_pin_ancestry hyperpolymath/standards "$sha" || exit 1 +# +# WHY: a pin can be a real commit that GitHub still refuses to run. A PR head +# that is later squash-merged, or a commit on a deleted unmerged branch, is an +# ancestor of nothing; `commits/` still answers 200, but a cross-repo +# reusable-workflow ref at that SHA dies at graph resolution (standards#782: +# 61 dead rows, 0 alive). Squash is the estate's only merge method (D19a), so +# every bumper that may be handed a PR head must prove ancestry before writing. +# +# PREDICATE: compare/... on the server. +# identical | ahead → is an ancestor of → rc 0 +# behind | diverged → NOT an ancestor; never pin it → rc 1 +# not hex / not 40 → refuse; abbreviated SHAs are prefix +# queries, not existence tests → rc 1 +# no/unknown answer → indeterminate; callers must refuse too → rc 2 +# The comparison is server-side on purpose: `git merge-base --is-ancestor` in a +# shallow or partial clone reports false negatives (standards#782). A caller +# may try a full local clone first, but only this function may say "no" from +# incomplete local evidence. +# +# ENVIRONMENT +# STANDARDS_REACHABILITY_API_BASE API root (default https://api.github.com); +# tests point it at an unreachable port. +# GITHUB_TOKEN / GH_TOKEN bearer token; else `gh auth token` if gh +# is logged in; else unauthenticated. + +# pin_ancestry_log — write one diagnostic line to stderr. +pin_ancestry_log() { printf '%s\n' "$*" >&2; } + +# pin_ancestry_token — print the bearer token to use, or nothing. +pin_ancestry_token() { + if [ -n "${GITHUB_TOKEN:-}" ]; then printf '%s' "$GITHUB_TOKEN" + elif [ -n "${GH_TOKEN:-}" ]; then printf '%s' "$GH_TOKEN" + elif command -v gh >/dev/null 2>&1; then gh auth token 2>/dev/null || true + fi +} + +# assert_pin_ancestry [branch] +# Succeed only if is a full 40-hex commit that is an ancestor of +# (default: main) in . Returns 0 ancestor, 1 not an +# ancestor or malformed, 2 indeterminate. Logs the reason on any refusal. +assert_pin_ancestry() { + local repo="$1" sha="$2" branch="${3:-main}" + if ! [[ "$sha" =~ ^[0-9a-fA-F]{40}$ ]]; then + pin_ancestry_log "ERROR: pin must be a full 40-character hex commit SHA, got '${sha}'; refusing." + return 1 + fi + + local api="${STANDARDS_REACHABILITY_API_BASE:-https://api.github.com}" + local token body status + local -a auth=() + token="$(pin_ancestry_token)" + [ -n "$token" ] && auth=(-H "Authorization: Bearer ${token}") + + if ! body=$(curl -fsS --max-time 20 \ + -H 'Accept: application/vnd.github+json' \ + "${auth[@]}" \ + "$api/repos/${repo}/compare/${sha}...${branch}" 2>/dev/null); then + pin_ancestry_log "ERROR: could not prove ${sha} is an ancestor of ${repo}@${branch} (compare request failed); refusing." + return 2 + fi + + # The top-level "status" precedes the commits/files arrays, whose entries + # carry their own "status" ("modified", …). GitHub pretty-prints this body + # today, but compact JSON puts them all on one line, where a greedy + # `sed 's/.*"status"…/'` takes the LAST one. bash =~ takes the leftmost in + # either layout, and needs no pipe that could SIGPIPE on a large body. + status="" + local re='"status"[[:space:]]*:[[:space:]]*"([a-z]+)"' + [[ "$body" =~ $re ]] && status="${BASH_REMATCH[1]}" + case "$status" in + identical|ahead) return 0 ;; + behind|diverged) + pin_ancestry_log "ERROR: ${sha} is NOT an ancestor of ${repo}@${branch} (compare status: ${status}). Repin to the merge commit, never a PR head; refusing." + return 1 ;; + *) + pin_ancestry_log "ERROR: could not prove ${sha} is an ancestor of ${repo}@${branch} (compare status: ${status:-none}); refusing." + return 2 ;; + esac +} diff --git a/scripts/propagate-workflow-pins.sh b/scripts/propagate-workflow-pins.sh index c27f72794..e9d558298 100755 --- a/scripts/propagate-workflow-pins.sh +++ b/scripts/propagate-workflow-pins.sh @@ -45,6 +45,10 @@ set -euo pipefail +# The shared ancestry assertion (D5c): every pin bumper calls the same one. +# shellcheck source-path=SCRIPTDIR source=lib/pin-ancestry.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib/pin-ancestry.sh" + # --------------------------------------------------------------------------- # Configuration / argument parsing # --------------------------------------------------------------------------- @@ -140,26 +144,10 @@ validate_target() { # Neither usable local main ref proved reachability. Local refs can be stale, # so only the authoritative server comparison may return a negative result. - - local api="${STANDARDS_REACHABILITY_API_BASE:-https://api.github.com}" - local -a auth=() - [ -n "${GITHUB_TOKEN:-}" ] && auth=(-H "Authorization: Bearer ${GITHUB_TOKEN}") - local body status - body=$(curl -fsS --max-time 20 \ - -H 'Accept: application/vnd.github+json' \ - "${auth[@]}" \ - "$api/repos/hyperpolymath/standards/compare/${TARGET_SHA}...main") || { - log "ERROR: could not prove target ${TARGET_SHA} is reachable from standards main; refusing propagation." - return 1 - } - status=$(printf '%s' "$body" | sed -n 's/.*"status"[[:space:]]*:[[:space:]]*"\([a-z]*\)".*/\1/p' | head -n1) - case "$status" in - identical|ahead) return 0 ;; - *) - log "ERROR: target ${TARGET_SHA} is not reachable from standards main (compare status: ${status:-unknown}); refusing propagation." - return 1 - ;; - esac + assert_pin_ancestry hyperpolymath/standards "$TARGET_SHA" main || { + log "ERROR: refusing propagation of ${TARGET_SHA}." + return 1 + } } validate_target || exit 2 diff --git a/scripts/tests/pin-ancestry-test.sh b/scripts/tests/pin-ancestry-test.sh new file mode 100644 index 000000000..fd62b910c --- /dev/null +++ b/scripts/tests/pin-ancestry-test.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) +set -euo pipefail + +# Unit test for scripts/lib/pin-ancestry.sh — the shared ancestry assertion +# (D5c). The compare API is served from file:// fixtures, so every verdict, +# including each refusal, is a planted positive control rather than an absence. + +TEST_DIR=$(mktemp -d) +trap 'rm -rf "$TEST_DIR"' EXIT + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source-path=SCRIPTDIR source=../lib/pin-ancestry.sh +. "$SCRIPT_DIR/../lib/pin-ancestry.sh" + +export STANDARDS_REACHABILITY_API_BASE="file://$TEST_DIR" +export GITHUB_TOKEN=test-token +REPO=hyperpolymath/standards +mkdir -p "$TEST_DIR/repos/$REPO/compare" + +pass=0 fail=0 + +# plant — serve as compare/...main. +plant() { printf '%s' "$2" > "$TEST_DIR/repos/$REPO/compare/$1...main"; } + +# expect