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
18 changes: 5 additions & 13 deletions scripts/apply-workflow-pins-remote.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -277,20 +281,8 @@
# Existence is not enough (see the 2026-09-04 squash-merge incident above).
# The comparison is done SERVER-side so a shallow/partial clone cannot produce
# a false negative.
validate_target() {

Check warning on line 284 in scripts/apply-workflow-pins-remote.sh

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Add an explicit return statement at the end of the function.

See more on https://sonarcloud.io/project/issues?id=hyperpolymath_standards&issues=AaEQXbU35Fxz_DXx2GDH&open=AaEQXbU35Fxz_DXx2GDH&pullRequest=1159
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

Check warning on line 285 in scripts/apply-workflow-pins-remote.sh

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Assign this positional parameter to a local variable.

See more on https://sonarcloud.io/project/issues?id=hyperpolymath_standards&issues=AaEQXbU35Fxz_DXx2GDG&open=AaEQXbU35Fxz_DXx2GDG&pullRequest=1159
}

# sha_is_commit <sha> — is this a real commit in standards?
Expand Down
90 changes: 90 additions & 0 deletions scripts/lib/pin-ancestry.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: MPL-2.0
# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>
#
# 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/<sha>` 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/<sha>...<branch> on the server.
# identical | ahead → <sha> is an ancestor of <branch> → 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 <msg> — write one diagnostic line to stderr.
pin_ancestry_log() { printf '%s\n' "$*" >&2; }

Check warning on line 38 in scripts/lib/pin-ancestry.sh

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Add an explicit return statement at the end of the function.

See more on https://sonarcloud.io/project/issues?id=hyperpolymath_standards&issues=AaEQXbVL5Fxz_DXx2GDI&open=AaEQXbVL5Fxz_DXx2GDI&pullRequest=1159

# pin_ancestry_token — print the bearer token to use, or nothing.
pin_ancestry_token() {

Check warning on line 41 in scripts/lib/pin-ancestry.sh

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Add an explicit return statement at the end of the function.

See more on https://sonarcloud.io/project/issues?id=hyperpolymath_standards&issues=AaEQXbVL5Fxz_DXx2GDL&open=AaEQXbVL5Fxz_DXx2GDL&pullRequest=1159
if [ -n "${GITHUB_TOKEN:-}" ]; then printf '%s' "$GITHUB_TOKEN"

Check failure on line 42 in scripts/lib/pin-ancestry.sh

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use '[[' instead of '[' for conditional tests. The '[[' construct is safer and more feature-rich.

See more on https://sonarcloud.io/project/issues?id=hyperpolymath_standards&issues=AaEQXbVL5Fxz_DXx2GDJ&open=AaEQXbVL5Fxz_DXx2GDJ&pullRequest=1159
elif [ -n "${GH_TOKEN:-}" ]; then printf '%s' "$GH_TOKEN"

Check failure on line 43 in scripts/lib/pin-ancestry.sh

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use '[[' instead of '[' for conditional tests. The '[[' construct is safer and more feature-rich.

See more on https://sonarcloud.io/project/issues?id=hyperpolymath_standards&issues=AaEQXbVL5Fxz_DXx2GDK&open=AaEQXbVL5Fxz_DXx2GDK&pullRequest=1159
elif command -v gh >/dev/null 2>&1; then gh auth token 2>/dev/null || true
fi
}

# assert_pin_ancestry <owner/repo> <sha> [branch]
# Succeed only if <sha> is a full 40-hex commit that is an ancestor of
# <branch> (default: main) in <owner/repo>. 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}")

Check failure on line 63 in scripts/lib/pin-ancestry.sh

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use '[[' instead of '[' for conditional tests. The '[[' construct is safer and more feature-rich.

See more on https://sonarcloud.io/project/issues?id=hyperpolymath_standards&issues=AaEQXbVL5Fxz_DXx2GDM&open=AaEQXbVL5Fxz_DXx2GDM&pullRequest=1159

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
}
28 changes: 8 additions & 20 deletions scripts/propagate-workflow-pins.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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
Expand Down
66 changes: 66 additions & 0 deletions scripts/tests/pin-ancestry-test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: MPL-2.0
# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>
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 <sha> <body> — serve <body> as compare/<sha>...main.
plant() { printf '%s' "$2" > "$TEST_DIR/repos/$REPO/compare/$1...main"; }

Check warning on line 25 in scripts/tests/pin-ancestry-test.sh

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Assign this positional parameter to a local variable.

See more on https://sonarcloud.io/project/issues?id=hyperpolymath_standards&issues=AaEQXbEI5Fxz_DXx2GDD&open=AaEQXbEI5Fxz_DXx2GDD&pullRequest=1159

# expect <rc> <label> <sha> — assert assert_pin_ancestry returns <rc>.
expect() {
local want="$1" label="$2" sha="$3" got=0
assert_pin_ancestry "$REPO" "$sha" main 2>/dev/null || got=$?
if [ "$got" -eq "$want" ]; then

Check failure on line 31 in scripts/tests/pin-ancestry-test.sh

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use '[[' instead of '[' for conditional tests. The '[[' construct is safer and more feature-rich.

See more on https://sonarcloud.io/project/issues?id=hyperpolymath_standards&issues=AaEQXbEI5Fxz_DXx2GDE&open=AaEQXbEI5Fxz_DXx2GDE&pullRequest=1159
echo "PASS: $label (rc=$got)"; pass=$((pass + 1))
else
echo "FAIL: $label (want rc=$want, got rc=$got)"; fail=$((fail + 1))
fi
}

# sha <n> — print <n> zero-padded to a 40-character fake commit SHA.
sha() { printf '%040d' "$1"; }

plant "$(sha 1)" '{"url":"x","status":"identical","ahead_by":0,"behind_by":0}'
plant "$(sha 2)" '{"url":"x","status":"ahead","ahead_by":3,"behind_by":0,"commits":[{"status":"diverged"}]}'
plant "$(sha 3)" '{"url":"x","status":"behind","ahead_by":0,"behind_by":2}'
plant "$(sha 4)" '{"url":"x","status":"diverged","ahead_by":4,"behind_by":1}'
plant "$(sha 5)" '{"message":"Not Found"}'

expect 0 "identical is an ancestor" "$(sha 1)"
expect 0 "ahead is an ancestor (first status wins)" "$(sha 2)"
expect 1 "behind is NOT an ancestor (PR head)" "$(sha 3)"
expect 1 "diverged is NOT an ancestor (orphan)" "$(sha 4)"
expect 2 "body without status is indeterminate" "$(sha 5)"
expect 2 "failed request is indeterminate" "$(sha 6)"
expect 1 "abbreviated SHA is refused before probing" "5b1d0022"
expect 1 "non-hex SHA is refused" "$(sha 1 | tr 0 g)"

# The refusal must name the remedy, or the operator repins a PR head again.
msg=$(assert_pin_ancestry "$REPO" "$(sha 4)" main 2>&1 || true)
if grep -q 'never a PR head' <<<"$msg"; then
echo "PASS: non-ancestor refusal names the remedy"; pass=$((pass + 1))
else
echo "FAIL: non-ancestor refusal lacks the remedy: $msg"; fail=$((fail + 1))
fi

echo "----------------------------------------"
echo "$pass/$((pass + fail)) test cases passed."
[ "$fail" -eq 0 ]

Check failure on line 66 in scripts/tests/pin-ancestry-test.sh

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use '[[' instead of '[' for conditional tests. The '[[' construct is safer and more feature-rich.

See more on https://sonarcloud.io/project/issues?id=hyperpolymath_standards&issues=AaEQXbEI5Fxz_DXx2GDF&open=AaEQXbEI5Fxz_DXx2GDF&pullRequest=1159
Loading