From 835a7f09f779b185432502d12730132652ace042 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Tue, 15 Sep 2026 13:41:42 +0100 Subject: [PATCH 1/3] feat(ci): scheduled applier that re-points standards workflow pins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A fix landed in `standards` does not reach its callers. Measured 2026-09-15, 28 of 29 live remote callers are SHA-pinned and exactly one tracks `@main`, so a pinned caller keeps consuming a broken commit until something re-points it. `scripts/propagate-workflow-pins.sh` already holds the proven rewrite core but walks LOCAL checkouts and never commits, which makes it a manual, one-repo-at-a-time tool. This adds the applier that closes that gap (owner ruling STD-R-3: an applier on a cadence, not a one-shot sweep). Classifies every consumer pin into five classes, because the failure signature `failure` + 0 jobs + name == path has at least four distinct causes and the signature alone never proves which: FRESH / BEHIND / DEAD-REF (pinned SHA is not a commit in standards at all) / ILLEGAL (`uses: ../../`, rejected at parse time — issue #808) / TRACKING. Safety properties: * audit by default; --fix required to write anything * the target SHA is proven REACHABLE FROM main, not merely existent — the 2026-09-04 squash-merge incident broke 251 workflow files across 70 repos precisely because an addressable commit was not a usable ref * known-answer controls run on EVERY invocation and abort the run on misclassification; a census with no control cannot be told from a broken one * commits are made via createCommitOnBranch so they are "Verified" — required_signatures is the dominant campaign blocker, and an unsigned applier would open PRs that can never merge * a missing App credential fails fix mode loudly instead of reporting a clean run that wrote nothing The dedicated App is deliberately NOT OikosBot: OikosBot is a ruleset bypass actor, and an applier authenticating as it would be exempt from the rules it exists to uphold. tests/ is mutation-based: five mutants reintroduce real defects verbatim and each must be killed by the named control. One of them — a rewrite anchored on the SHA rather than on the standards path, which silently re-points actions/checkout at a standards commit — passed both pre-existing controls, which is why the third-party-pin control was added. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HfgwLCdKNd5iZVo6VTiSim --- .github/workflows/apply-workflow-pins.yml | 152 +++++++ scripts/apply-workflow-pins-remote.sh | 466 ++++++++++++++++++++++ tests/test_apply_workflow_pins_remote.sh | 139 +++++++ 3 files changed, 757 insertions(+) create mode 100644 .github/workflows/apply-workflow-pins.yml create mode 100755 scripts/apply-workflow-pins-remote.sh create mode 100755 tests/test_apply_workflow_pins_remote.sh diff --git a/.github/workflows/apply-workflow-pins.yml b/.github/workflows/apply-workflow-pins.yml new file mode 100644 index 000000000..6be0f42de --- /dev/null +++ b/.github/workflows/apply-workflow-pins.yml @@ -0,0 +1,152 @@ +# SPDX-License-Identifier: MPL-2.0 +# This workflow is managed by gh actions-lock. +# +# apply-workflow-pins — re-point consumer repositories at the current standards +# reusable-workflow SHA, on a cadence. +# +# WHY A SCHEDULED APPLIER AND NOT A SWEEP (owner ruling STD-R-3): +# A fix landed in `standards` does not reach its callers. Measured 2026-09-15, +# 28 of 29 live remote callers are SHA-pinned and exactly one tracks `@main`. +# A pinned caller keeps consuming the broken commit until something re-points +# it. A sweep does that once and then rots; an applier converges and keeps +# converging, which is what "the estate stays fixed" actually requires. +# +# DEFAULT IS AUDIT. The scheduled run writes nothing unless `mode: fix` is +# chosen explicitly via workflow_dispatch, because the first thing this needs to +# produce is an honest census — how many repos are BEHIND, DEAD-REF or ILLEGAL — +# and a census is evidence only while nothing is mutating underneath it. + +name: apply workflow pins + +on: + schedule: + # Sundays 06:11 UTC. Offset off the hour: the estate's other scheduled jobs + # cluster on :00 and contend for the same rate-limit bucket. + - cron: '11 6 * * 0' + workflow_dispatch: + inputs: + mode: + description: 'audit (default, writes nothing) or fix (opens PRs)' + type: choice + options: [audit, fix] + default: audit + repair_illegal: + description: 'Also repair unparseable `uses: ../../` refs (issue #808)' + type: boolean + default: false + owners: + description: 'Comma-separated owners to walk' + default: 'hyperpolymath,metadatastician' + limit: + description: 'Stop after N repos (0 = no limit); use for smoke runs' + default: '0' + +concurrency: + group: apply-workflow-pins + cancel-in-progress: false + +permissions: + contents: read + +jobs: + apply: + name: Re-point standards pins + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + # WHICH App's credentials belong in vars.APP_ID / secrets.APP_PRIVATE_KEY: + # a DEDICATED App for this applier, explicitly NOT OikosBot. Owner ruling + # STD-R-3 / R-14 (2026-09-15). OikosBot is named as a BYPASS ACTOR in the + # estate's rulesets; if the thing that re-points pins authenticated as the + # identity that may bypass the rules, it would be permanently exempt from + # the rules it exists to uphold. Separation of duties, not style. + # + # The App needs `contents: write` and `pull_requests: write`, and must be + # installed on BOTH owners. An installation token is scoped to ONE owner + # and carries its own rate limit, so `owner:` is required — without it the + # token is scoped to THIS repository and every consumer write 404s. + - name: Mint an App installation token for hyperpolymath + id: tok-user + if: vars.APP_ID != '' + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + continue-on-error: true + with: + app-id: ${{ vars.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + owner: hyperpolymath + + - name: Mint an App installation token for metadatastician + id: tok-org + if: vars.APP_ID != '' + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + continue-on-error: true + with: + app-id: ${{ vars.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + owner: metadatastician + + # A nonexistent secret resolves to an EMPTY STRING in silence, and a run + # over an empty credential reports a clean sweep while writing nothing. + # Decide here, before any enumeration, and say exactly what is missing. + # Audit mode may proceed on the read-only GITHUB_TOKEN; fix mode may not. + - name: Decide which credential is in play + id: cred + env: + APP_USER: ${{ steps.tok-user.outputs.token }} + MODE: ${{ inputs.mode || 'audit' }} + run: | + set -euo pipefail + if [ -n "${APP_USER:-}" ]; then + echo "have_app=true" >> "$GITHUB_OUTPUT" + echo "An App credential is present." + exit 0 + fi + echo "have_app=false" >> "$GITHUB_OUTPUT" + if [ "$MODE" = "fix" ]; then + cat >&2 <<'MSG' + FATAL: fix mode was requested but no App credential exists. + vars.APP_ID and secrets.APP_PRIVATE_KEY must both be set, and the App + must be installed on hyperpolymath and metadatastician. + Refusing to report a clean run that wrote nothing. + MSG + exit 1 + fi + echo "::notice::AUDIT-ONLY: no App credential; census will run on GITHUB_TOKEN." + + - name: Run the applier + env: + # Prefer the App token; fall back to GITHUB_TOKEN for audit reads only. + GH_TOKEN: ${{ steps.tok-user.outputs.token || secrets.GITHUB_TOKEN }} + MODE: ${{ inputs.mode || 'audit' }} + REPAIR: ${{ inputs.repair_illegal || false }} + OWNERS: ${{ inputs.owners || 'hyperpolymath,metadatastician' }} + LIMIT: ${{ inputs.limit || '0' }} + run: | + set -euo pipefail + args=(--owners "$OWNERS" --limit "$LIMIT" --out census.tsv) + [ "$MODE" = "fix" ] && args+=(--fix) + [ "$REPAIR" = "true" ] && args+=(--repair-illegal) + bash scripts/apply-workflow-pins-remote.sh "${args[@]}" + + - name: Summarise the census + if: always() + run: | + set -euo pipefail + [ -f census.tsv ] || { echo "no census produced"; exit 0; } + { + echo '### standards pin census' + echo + echo '| status | files |' + echo '|---|---|' + awk -F'\t' 'NR>1{c[$3]++} END{for(k in c) printf "| %s | %d |\n", k, c[k]}' census.tsv | sort + } >> "$GITHUB_STEP_SUMMARY" + + - name: Upload the census + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: workflow-pin-census + path: census.tsv + if-no-files-found: warn diff --git a/scripts/apply-workflow-pins-remote.sh b/scripts/apply-workflow-pins-remote.sh new file mode 100755 index 000000000..3cb7b3994 --- /dev/null +++ b/scripts/apply-workflow-pins-remote.sh @@ -0,0 +1,466 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) +# +# apply-workflow-pins-remote.sh — the scheduled APPLIER for standards +# reusable-workflow pins across the whole estate. +# +# WHY THIS EXISTS, and why it is not `propagate-workflow-pins.sh`: +# +# `scripts/propagate-workflow-pins.sh` already holds the proven rewrite core, +# but it walks LOCAL CHECKOUTS and deliberately never commits. That makes it a +# human-driven, one-repo-at-a-time tool. A cure landed in `standards` therefore +# does NOT propagate: measured 2026-09-15, 28 of 29 live remote callers are +# SHA-pinned and exactly one (`affinescript`) tracks `@main`. A pinned caller +# never sees a fix until something re-points its pin. +# +# This script is that something. It is an APPLIER, not a sweep (owner ruling +# STD-R-3): it runs on a cadence, re-points pins, and is expected to run again. +# A sweep is a one-shot mass mutation; an applier converges. +# +# WHAT A PIN CAN BE (four classes, not one — this is the hard-won part): +# FRESH pinned at the target SHA. Nothing to do. +# BEHIND pinned at some other real standards commit. Re-point. +# DEAD-REF pinned at a SHA that is NOT a commit in standards at all. These +# exist (e.g. `awesome-idris2`). Cause: a squash-merge made the +# intermediate commit unaddressable as a cross-repo workflow ref. +# The workflow fails with `failure`, 0 jobs, and name == path — the +# same signature as three other faults, so the signature alone +# never proves the cause. +# ILLEGAL `uses: ../../...` or `uses: $/...`. Rejected at PARSE time, so the +# run dies before any job starts. Seeded into repos by RSR templates +# (issue #808). Repaired only under --repair-illegal. +# TRACKING `@main` or another non-SHA ref. Gets cures for free; also +# unpinned, which the estate's own policy forbids. Reported, not +# rewritten, because re-pointing it is a policy change, not a fix. +# +# SAFETY PROPERTIES (do not weaken any of these): +# * READ-ONLY by default. --fix is required to write anything. +# * The target SHA is proven REACHABLE FROM standards' main before any write. +# Mere existence is NOT enough: on 2026-09-04 a squash-merged intermediate +# commit was addressable through the contents API but unusable as a +# cross-repo reusable-workflow ref, and pinning to it broke 251 active +# workflow files across 70 repos. +# * KNOWN-ANSWER CONTROLS run every time. If a control misclassifies, the run +# ABORTS before writing. A census with no control is a census you cannot +# distinguish from a broken one. +# * Commits are made with createCommitOnBranch so they are GitHub-"Verified". +# Measured 2026-09-15: required_signatures is the dominant campaign blocker +# (6 of 9 campaign heads unsigned). An unsigned applier opens PRs that can +# never merge, which is worse than opening none. +# * Shell-only: no Python, no Ruby. +# +# Usage: +# bash scripts/apply-workflow-pins-remote.sh [options] +# --fix open PRs (default: audit only, writes nothing) +# --repair-illegal also rewrite ILLEGAL `../` refs (implies more risk) +# --to target SHA (default: resolved from standards main) +# --owners owners to walk (default: hyperpolymath,metadatastician) +# --only restrict to one repo (for testing) +# --out write the TSV census here (default: stdout) +# --limit stop after n repos (for smoke runs) +# --self-test run the pure-function controls and exit + +set -uo pipefail + +STANDARDS_REPO="hyperpolymath/standards" +TARGET_SHA="${STANDARDS_TARGET_SHA:-}" +DO_FIX=0 +REPAIR_ILLEGAL=0 +OWNERS="hyperpolymath,metadatastician" +ONLY_REPO="" +OUT_FILE="" +LIMIT=0 +BRANCH_NAME="chore/re-point-standards-workflow-pins" + +log() { printf '%s\n' "$*" >&2; } + +# --------------------------------------------------------------------------- +# PURE CORE — no network, no filesystem outside the file argument. +# Everything below this banner is unit-testable offline, and tests/ does so. +# --------------------------------------------------------------------------- + +# A standards reusable-workflow reference, pinned to a hex SHA. +PIN_RE='hyperpolymath/standards/\.github/workflows/[A-Za-z0-9._-]+\.ya?ml@[0-9a-fA-F]{7,40}' +# The same reference pinned to anything at all (SHA, branch, tag). +ANYREF_RE='hyperpolymath/standards/\.github/workflows/[A-Za-z0-9._-]+\.ya?ml@[A-Za-z0-9._/-]+' +# A `uses:` that can never parse: relative, or the `$/` form that +# `gh actions-lock` once invented. Both are rejected before any job starts. +ILLEGAL_RE='^[[:space:]]*uses:[[:space:]]*['"'"'"]?(\.\.?/|\$/)' + +# Echo every standards pin SHA found in $1, one per line. +pin_shas() { + grep -hoE "$PIN_RE" "$1" 2>/dev/null | sed -E 's/.*@([0-9a-fA-F]+)$/\1/' +} + +# Echo every standards ref that is NOT a hex SHA (branch/tag tracking). +tracking_refs() { + grep -hoE "$ANYREF_RE" "$1" 2>/dev/null \ + | sed -E 's/.*@(.+)$/\1/' \ + | grep -vE '^[0-9a-fA-F]{7,40}$' || true +} + +# Echo every illegal `uses:` line in $1 (line-number prefixed). +illegal_uses() { + grep -nE "$ILLEGAL_RE" "$1" 2>/dev/null || true +} + +# classify_file +# Echoes ONE token per distinct condition found, newline separated, from: +# ILLEGAL TRACKING BEHIND FRESH NONE +# A file can be several at once (e.g. BEHIND and ILLEGAL); every applicable +# token is emitted. NONE means the file references standards not at all. +# +# NOTE: DEAD-REF is deliberately NOT decided here. Distinguishing a real-but-old +# commit from one that does not exist requires the network, and keeping this +# function pure is what makes it testable. refine_behind() upgrades it later. +classify_file() { + local f="$1" target="$2" emitted=0 + + if [ -n "$(illegal_uses "$f")" ]; then + echo ILLEGAL; emitted=1 + fi + if [ -n "$(tracking_refs "$f")" ]; then + echo TRACKING; emitted=1 + fi + + local sha behind=0 fresh=0 + while IFS= read -r sha; do + [ -n "$sha" ] || continue + # A short pin is compared on its own length: `@abc1234` and the 40-char + # target are the same commit when the short form is a prefix. Treating a + # legitimate short pin as BEHIND would rewrite it every single run and the + # applier would never converge. + if [ "${target:0:${#sha}}" = "$sha" ]; then fresh=1; else behind=1; fi + done < <(pin_shas "$f") + + [ "$behind" -eq 1 ] && { echo BEHIND; emitted=1; } + [ "$fresh" -eq 1 ] && { echo FRESH; emitted=1; } + [ "$emitted" -eq 0 ] && echo NONE + return 0 +} + +# rewrite_pins — re-point every standards SHA pin, in place. +# Only the SHA after a standards reusable `@` is touched. Idempotent. +rewrite_pins() { + local f="$1" target="$2" + sed -E -i "s#(hyperpolymath/standards/\.github/workflows/[A-Za-z0-9._-]+\.ya?ml@)[0-9a-fA-F]{7,40}#\1${target}#g" "$f" +} + +# rewrite_illegal — turn an unparseable relative ref into a +# legal cross-repo pin. `../../.github/workflows/X.yml` was always MEANT to be +# the standards copy: RSR templates seeded it, and a relative `uses:` to a +# reusable workflow has no legal meaning in GitHub Actions at all (only +# `./.github/workflows/x.yml` and `owner/repo/...@ref` parse). +rewrite_illegal() { + local f="$1" target="$2" + sed -E -i \ + "s#(uses:[[:space:]]*)['\"]?(\.\./)+\.github/workflows/([A-Za-z0-9._-]+\.ya?ml)['\"]?#\1hyperpolymath/standards/.github/workflows/\3@${target}#g" \ + "$f" + sed -E -i \ + "s#(uses:[[:space:]]*)['\"]?\\\$/\.github/workflows/([A-Za-z0-9._-]+\.ya?ml)['\"]?#\1hyperpolymath/standards/.github/workflows/\2@${target}#g" \ + "$f" +} + +# --------------------------------------------------------------------------- +# SELF-TEST of the pure core. Runs offline. `--self-test` exits after this. +# These are the KNOWN-ANSWER CONTROLS: if any fails, every census this script +# could produce is untrustworthy, so it refuses to produce one. +# --------------------------------------------------------------------------- +self_test() { + local d rc=0 t="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + d=$(mktemp -d); trap 'rm -rf "$d"' RETURN + + printf 'jobs:\n a:\n uses: hyperpolymath/standards/.github/workflows/x.yml@%s\n' "$t" > "$d/fresh.yml" + printf 'jobs:\n a:\n uses: hyperpolymath/standards/.github/workflows/x.yml@%s\n' "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" > "$d/behind.yml" + printf 'jobs:\n a:\n uses: ../../.github/workflows/x.yml\n' > "$d/illegal.yml" + printf 'jobs:\n a:\n uses: hyperpolymath/standards/.github/workflows/x.yml@main\n' > "$d/tracking.yml" + printf 'jobs:\n a:\n runs-on: ubuntu-latest\n' > "$d/none.yml" + printf 'jobs:\n a:\n uses: hyperpolymath/standards/.github/workflows/x.yml@%s\n' "${t:0:7}" > "$d/short.yml" + + check() { + local file="$1" want="$2" got + got=$(classify_file "$d/$file" "$t" | sort | tr '\n' ',' ) + if [ "$got" = "$want" ]; then + echo " PASS $file -> $got" + else + echo " FAIL $file -> got '$got' want '$want'" >&2; rc=1 + fi + } + echo "control: classify_file" + check fresh.yml "FRESH," + check behind.yml "BEHIND," + check illegal.yml "ILLEGAL," + check tracking.yml "TRACKING," + check none.yml "NONE," + # A short pin that PREFIXES the target is FRESH, not BEHIND. Without this the + # applier rewrites the same file every run and never converges. + check short.yml "FRESH," + + echo "control: rewrite_pins is idempotent" + rewrite_pins "$d/behind.yml" "$t" + local after1 after2 + after1=$(cat "$d/behind.yml") + rewrite_pins "$d/behind.yml" "$t" + after2=$(cat "$d/behind.yml") + if [ "$after1" = "$after2" ] && grep -q "@$t" "$d/behind.yml"; then + echo " PASS rewrite is idempotent and hit the target" + else + echo " FAIL rewrite not idempotent" >&2; rc=1 + fi + + # A rewrite anchored on the SHA rather than on the standards reusable PATH + # silently re-points every THIRD-PARTY action pin in the file as well. Nothing + # else in this suite would notice: such a rewrite is still idempotent and the + # file still contains the target SHA, so both other controls stay green. + echo "control: rewrite_pins does not touch third-party pins" + { + echo 'jobs:' + echo ' a:' + echo ' steps:' + echo ' - uses: actions/checkout@cccccccccccccccccccccccccccccccccccccccc' + echo ' uses: hyperpolymath/standards/.github/workflows/x.yml@dddddddddddddddddddddddddddddddddddddddd' + } > "$d/mixed.yml" + rewrite_pins "$d/mixed.yml" "$t" + if grep -q 'actions/checkout@cccccccccccccccccccccccccccccccccccccccc' "$d/mixed.yml" \ + && grep -q "standards/.github/workflows/x.yml@$t" "$d/mixed.yml"; then + echo " PASS third-party pin untouched, standards pin re-pointed" + else + echo " FAIL rewrite overreached onto a third-party pin:" >&2 + sed 's/^/ /' "$d/mixed.yml" >&2; rc=1 + fi + + echo "control: rewrite_illegal produces a LEGAL ref" + rewrite_illegal "$d/illegal.yml" "$t" + if grep -qE "uses: $PIN_RE" "$d/illegal.yml" && ! grep -qE "$ILLEGAL_RE" "$d/illegal.yml"; then + echo " PASS illegal ref became a legal pin" + else + echo " FAIL illegal repair did not yield a legal pin: $(cat "$d/illegal.yml")" >&2; rc=1 + fi + + return $rc +} + +# --------------------------------------------------------------------------- +# NETWORK LAYER +# --------------------------------------------------------------------------- + +gh_ok() { command -v gh >/dev/null 2>&1 && gh auth status >/dev/null 2>&1; } + +# resolve_target — pick the SHA every consumer should point at. +# NEVER a feature-branch head: the applier would propagate unmerged work. +resolve_target() { + [ -n "$TARGET_SHA" ] && { echo "$TARGET_SHA"; return 0; } + gh api "repos/${STANDARDS_REPO}/commits/main" --jq '.sha' 2>/dev/null +} + +# validate_target — prove the SHA is reachable from standards' main. +# 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() { + 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 +} + +# sha_is_commit — is this a real commit in standards? +# This is what separates BEHIND from DEAD-REF. +sha_is_commit() { + gh api "repos/${STANDARDS_REPO}/commits/$1" --jq '.sha' >/dev/null 2>&1 +} + +# refine_behind — echo DEAD-REF if ANY pin in the file names a SHA that +# is not a commit in standards; echo nothing otherwise. +refine_behind() { + local sha + while IFS= read -r sha; do + [ -n "$sha" ] || continue + if ! sha_is_commit "$sha"; then echo "DEAD-REF:$sha"; fi + done < <(pin_shas "$1" | sort -u) +} + +list_repos() { + local owner + for owner in ${OWNERS//,/ }; do + # /users//repos covers a user; if that 404s the owner is an org. + gh api "users/${owner}/repos" --paginate \ + --jq '.[] | select(.archived == false) | .full_name' 2>/dev/null \ + || gh api "orgs/${owner}/repos" --paginate \ + --jq '.[] | select(.archived == false) | .full_name' 2>/dev/null + done +} + +# fetch_workflows — download .github/workflows/*.y*ml. +# The REMOTE content is the only evidence: a local checkout can be arbitrarily +# stale, and reading one is what produced a false "285 callers track main" +# census on 2026-09-15. +fetch_workflows() { + local repo="$1" dest="$2" name + mkdir -p "$dest" + gh api "repos/${repo}/contents/.github/workflows" \ + --jq '.[] | select(.type == "file") | .name' 2>/dev/null \ + | grep -E '\.ya?ml$' \ + | while IFS= read -r name; do + gh api "repos/${repo}/contents/.github/workflows/${name}" \ + -H 'Accept: application/vnd.github.raw' > "${dest}/${name}" 2>/dev/null \ + || rm -f "${dest}/${name}" + done +} + +# land_pr — create branch, commit VERIFIED, open PR. +# Uses createCommitOnBranch: it is cross-repo by construction and the resulting +# commit is GitHub-"Verified", which required_signatures rulesets demand. +land_pr() { + local repo="$1" dir="$2"; shift 2 + local base_sha base_branch ref_exists head_oid additions="" f rel b64 pr + + base_branch=$(gh api "repos/${repo}" --jq '.default_branch' 2>/dev/null) || return 1 + base_sha=$(gh api "repos/${repo}/commits/${base_branch}" --jq '.sha' 2>/dev/null) || return 1 + + ref_exists=$(gh api "repos/${repo}/git/ref/heads/${BRANCH_NAME}" --jq '.object.sha' 2>/dev/null || true) + if [ -n "$ref_exists" ]; then + head_oid="$ref_exists" + else + gh api "repos/${repo}/git/refs" -X POST \ + -f "ref=refs/heads/${BRANCH_NAME}" -f "sha=${base_sha}" >/dev/null 2>&1 || return 1 + head_oid="$base_sha" + fi + + for f in "$@"; do + rel=".github/workflows/$(basename "$f")" + b64=$(base64 -w0 < "${dir}/${f}") + additions="${additions}{\"path\":\"${rel}\",\"contents\":\"${b64}\"}," + done + additions="[${additions%,}]" + + local msg_head="chore(ci): re-point standards reusable-workflow pins to ${TARGET_SHA:0:12}" + local msg_body + msg_body=$(printf 'Opened by the standards pin applier (scripts/apply-workflow-pins-remote.sh).\n\nRe-points this repository'"'"'s pinned references to hyperpolymath/standards reusable\nworkflows at %s, which is proven reachable from standards main.\n\nCo-Authored-By: Claude Opus 5 \nClaude-Session: https://claude.ai/code/session_01HfgwLCdKNd5iZVo6VTiSim' "$TARGET_SHA") + + gh api graphql -f query=' + mutation($input: CreateCommitOnBranchInput!) { + createCommitOnBranch(input: $input) { commit { oid } } + }' \ + -F input="{\"branch\":{\"repositoryNameWithOwner\":\"${repo}\",\"branchName\":\"${BRANCH_NAME}\"},\"expectedHeadOid\":\"${head_oid}\",\"message\":{\"headline\":$(json_str "$msg_head"),\"body\":$(json_str "$msg_body")},\"fileChanges\":{\"additions\":${additions}}}" \ + >/dev/null 2>&1 || { log " land: createCommitOnBranch failed for ${repo}"; return 1; } + + pr=$(gh pr create --repo "$repo" --head "$BRANCH_NAME" --base "$base_branch" \ + --title "$msg_head" \ + --body "$(printf '%s\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\n\nhttps://claude.ai/code/session_01HfgwLCdKNd5iZVo6VTiSim' "$msg_body")" \ + 2>/dev/null) || { log " land: PR already open or creation failed for ${repo}"; return 1; } + echo "$pr" +} + +# json_str — quote a string as a JSON scalar without a JSON library. +json_str() { + printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' | awk 'BEGIN{ORS=""}{print (NR>1 ? "\\n" : "") $0}' | sed -e 's/^/"/' -e 's/$/"/' +} + +# --------------------------------------------------------------------------- +# MAIN +# --------------------------------------------------------------------------- +main() { + while [ $# -gt 0 ]; do + case "$1" in + --fix) DO_FIX=1 ;; + --repair-illegal) REPAIR_ILLEGAL=1 ;; + --to) TARGET_SHA="$2"; shift ;; + --owners) OWNERS="$2"; shift ;; + --only) ONLY_REPO="$2"; shift ;; + --out) OUT_FILE="$2"; shift ;; + --limit) LIMIT="$2"; shift ;; + --self-test) self_test; exit $? ;; + -h|--help) sed -n '2,60p' "$0"; exit 0 ;; + *) log "unknown option: $1"; exit 2 ;; + esac + shift + done + + # The controls run on EVERY invocation, not just --self-test. A census whose + # classifier is broken is worse than no census, because it looks like data. + log "== known-answer controls ==" + if ! self_test >&2; then + log "FATAL: classifier controls failed. Refusing to produce a census." + exit 1 + fi + + gh_ok || { log "FATAL: gh is not authenticated. Probe with 'gh api user' by EXIT CODE."; exit 1; } + + TARGET_SHA=$(resolve_target) + [ -n "$TARGET_SHA" ] || { log "FATAL: could not resolve a target SHA."; exit 1; } + validate_target "$TARGET_SHA" || exit 1 + log "== target: ${TARGET_SHA} (reachable from ${STANDARDS_REPO} main) ==" + [ "$DO_FIX" -eq 1 ] && log "== MODE: --fix (will open PRs) ==" || log "== MODE: audit (writes nothing) ==" + + local tsv; tsv=$(mktemp) + printf 'REPO\tWORKFLOW\tSTATUS\tDETAIL\n' > "$tsv" + + local work; work=$(mktemp -d); trap 'rm -rf "$work"' EXIT + local repos n=0 + if [ -n "$ONLY_REPO" ]; then repos="$ONLY_REPO"; else repos=$(list_repos); fi + + local repo + for repo in $repos; do + [ "$LIMIT" -gt 0 ] && [ "$n" -ge "$LIMIT" ] && break + n=$((n+1)) + local rdir="${work}/$(echo "$repo" | tr '/' '_')" + fetch_workflows "$repo" "$rdir" + local found=0 changed=() wf base st detail + shopt -s nullglob + for wf in "$rdir"/*.yml "$rdir"/*.yaml; do + found=1 + base=$(basename "$wf") + st=$(classify_file "$wf" "$TARGET_SHA" | sort | tr '\n' ',' ); st="${st%,}" + [ "$st" = "NONE" ] && continue + detail="" + case ",$st," in *,BEHIND,*) + detail=$(refine_behind "$wf" | tr '\n' ' ') + [ -n "$detail" ] && st="${st},DEAD-REF" + ;; esac + printf '%s\t%s\t%s\t%s\n' "$repo" "$base" "$st" "$detail" >> "$tsv" + + if [ "$DO_FIX" -eq 1 ]; then + local before; before=$(cat "$wf") + case ",$st," in *,BEHIND,*) rewrite_pins "$wf" "$TARGET_SHA" ;; esac + if [ "$REPAIR_ILLEGAL" -eq 1 ]; then + case ",$st," in *,ILLEGAL,*) rewrite_illegal "$wf" "$TARGET_SHA" ;; esac + fi + [ "$before" != "$(cat "$wf")" ] && changed+=("$base") + fi + done + shopt -u nullglob + [ "$found" -eq 0 ] && continue + + if [ "$DO_FIX" -eq 1 ] && [ "${#changed[@]}" -gt 0 ]; then + local url + if url=$(land_pr "$repo" "$rdir" "${changed[@]}"); then + log " OPENED ${repo}: ${url}" + printf '%s\t-\tPR-OPENED\t%s\n' "$repo" "$url" >> "$tsv" + else + printf '%s\t-\tPR-FAILED\t-\n' "$repo" >> "$tsv" + fi + fi + done + + log "== walked ${n} repo(s) ==" + # Counts are reported at LANDED, never at PR-open: 545 merged/week means + # throughput is fine; 249 PRs open and unmerged is an abandoned campaign. + log "== summary by status ==" + awk -F'\t' 'NR>1{c[$3]++} END{for(k in c) printf " %-28s %d\n", k, c[k]}' "$tsv" >&2 + + if [ -n "$OUT_FILE" ]; then cp "$tsv" "$OUT_FILE"; log "== census: ${OUT_FILE} =="; else cat "$tsv"; fi + rm -f "$tsv" +} + +main "$@" diff --git a/tests/test_apply_workflow_pins_remote.sh b/tests/test_apply_workflow_pins_remote.sh new file mode 100755 index 000000000..5a1ffa168 --- /dev/null +++ b/tests/test_apply_workflow_pins_remote.sh @@ -0,0 +1,139 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) +# +# test_apply_workflow_pins_remote.sh — regression suite for the pin applier. +# +# This suite is MUTATION-BASED on purpose. A green run of the applier's own +# controls proves only that the controls agree with the code; it does not prove +# the controls can DETECT anything. So every mutant below reintroduces a real +# defect verbatim and asserts the suite turns red. A mutant that stays green is +# a control that was never testing what its name claims. +# +# Trap already paid for once: a syntactically INVALID mutant fails for the wrong +# reason and every control "fails" on a parse error, which reads as success. +# Each mutant is therefore `bash -n`-checked BEFORE its redness is believed. + +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +APPLIER="${SCRIPT_DIR}/../scripts/apply-workflow-pins-remote.sh" + +TMP=$(mktemp -d); trap 'rm -rf "$TMP"' EXIT +rc=0 +pass() { echo " PASS $*"; } +fail() { echo " FAIL $*" >&2; rc=1; } + +# --- 0. the script must exist, parse, and be committed executable ------------ +echo "== 0. shape ==" +[ -f "$APPLIER" ] || { echo "FATAL: applier not found at $APPLIER" >&2; exit 1; } +bash -n "$APPLIER" && pass "applier parses" || fail "applier does not parse" + +# A suite committed 100644 passes every local run (`bash script` ignores the +# mode) and dies in CI at exit 126 before a single control runs. +if git -C "${SCRIPT_DIR}/.." ls-files -s -- tests/test_apply_workflow_pins_remote.sh 2>/dev/null | grep -q '^100755'; then + pass "this test is committed executable (100755)" +elif ! git -C "${SCRIPT_DIR}/.." rev-parse --git-dir >/dev/null 2>&1; then + pass "not a git checkout; mode check skipped" +else + # Not yet staged is acceptable while authoring; a wrong mode is not. + if git -C "${SCRIPT_DIR}/.." ls-files -- tests/test_apply_workflow_pins_remote.sh 2>/dev/null | grep -q .; then + fail "test is tracked but NOT 100755 — it will exit 126 in CI" + else + pass "test not yet tracked; mode will be checked once added" + fi +fi +if git -C "${SCRIPT_DIR}/.." ls-files -s -- scripts/apply-workflow-pins-remote.sh 2>/dev/null | grep -q '^100644'; then + fail "applier is tracked 100644 — it must be executable" +else + pass "applier mode acceptable" +fi + +# --- 1. the applier's own controls must pass unmutated ----------------------- +echo "== 1. baseline: unmutated controls ==" +if bash "$APPLIER" --self-test >"$TMP/base.out" 2>&1; then + pass "baseline controls green" +else + fail "baseline controls RED — fix the applier before reading any mutant" + sed 's/^/ /' "$TMP/base.out" >&2 +fi +for want in "fresh.yml" "behind.yml" "illegal.yml" "tracking.yml" "none.yml" "short.yml"; do + grep -q "PASS $want" "$TMP/base.out" && pass "control present: $want" \ + || fail "control MISSING from baseline: $want (a control that never runs is not a control)" +done + +# --- 2. mutants -------------------------------------------------------------- +# kill +kill_mutant() { + # NOTE: `local a="$1" m="${a}"` does NOT work — bash expands every word of the + # `local` builtin's argument list BEFORE assigning any of them, so `${a}` is + # unset there. Assign on separate lines. (Cost one red herring to find.) + local name="$1" expr="$2" want="$3" + local m="$TMP/mutant_${name}.sh" + cp "$APPLIER" "$m" + sed -E -i "$expr" "$m" + + if cmp -s "$APPLIER" "$m"; then + fail "mutant '$name' changed NOTHING — the sed did not match, so nothing was tested" + return + fi + if ! bash -n "$m" 2>"$TMP/${name}.parse"; then + fail "mutant '$name' is SYNTACTICALLY INVALID — its redness would be meaningless" + sed 's/^/ /' "$TMP/${name}.parse" >&2 + return + fi + + if bash "$m" --self-test >"$TMP/${name}.out" 2>&1; then + fail "mutant '$name' stayed GREEN — no control detects this defect" + elif grep -qF "$want" "$TMP/${name}.out"; then + pass "mutant '$name' killed by the right control" + else + fail "mutant '$name' died, but not at '$want' — the wrong control fired" + sed 's/^/ /' "$TMP/${name}.out" >&2 + fi +} + +echo "== 2. mutation kills ==" + +# M1 — classify by SHA pins only. This is the exact blind spot the older +# scripts/propagate-workflow-pins.sh still has: its PIN_RE cannot see an +# unparseable `uses: ../../`, so a repo full of them audits as clean. That +# population is issue #808. +kill_mutant illegal_blind \ + 's@^ if \[ -n "\$\(illegal_uses "\$f"\)" \]; then@ if false; then@' \ + "FAIL illegal.yml" + +# M2 — compare a pin to the target by exact string instead of by prefix. A +# legitimately short pin then reads BEHIND forever: the applier rewrites it, the +# rewrite is a no-op, and the next run finds it BEHIND again. An applier that +# never converges is just a sweep on a cron. +kill_mutant short_pin_never_converges \ + 's@if \[ "\$\{target:0:\$\{#sha\}\}" = "\$sha" \]@if [ "$target" = "$sha" ]@' \ + "FAIL short.yml" + +# M3 — drop branch/tag tracking detection. `affinescript` tracks `@main`; +# dropping TRACKING from the census makes the estate's one unpinned caller +# invisible to the policy that forbids unpinned callers. +kill_mutant tracking_blind \ + 's@^ if \[ -n "\$\(tracking_refs "\$f"\)" \]; then@ if false; then@' \ + "FAIL tracking.yml" + +# M4 — anchor the rewrite on the SHA rather than on the standards reusable PATH. +# It then re-points EVERY 40-hex pin in the file, including actions/checkout, at +# a standards commit. Catastrophic, and both the idempotency control and the +# hit-the-target control stay green through it — which is precisely why the +# third-party control had to be added. +kill_mutant rewrite_overreaches \ + 's|s#\(hyperpolymath/standards/\\\.github/workflows/\[A-Za-z0-9\._-\]\+\\\.ya\?ml@\)|s#()|' \ + "FAIL rewrite overreached" + +# M5 — the illegal repair emits a ref with no SHA. A "repair" that leaves the +# workflow still unparseable converts a visible failure into a repaired-looking +# one, which is strictly worse than not repairing it. +kill_mutant illegal_repair_still_illegal \ + 's@\@\$\{target\}@\@@g' \ + "FAIL illegal repair" + +echo +if [ "$rc" -ne 0 ]; then echo "RESULT: FAILED" >&2; else echo "RESULT: all checks passed"; fi +exit $rc From af3f3d193c32459318ebf1b5c1e88f1ce752b794 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Tue, 15 Sep 2026 13:48:30 +0100 Subject: [PATCH 2/3] fix(applier): one GraphQL call per repo, and stop traps touching locals Two defects found by running the applier against the live estate. 1. Cost. The REST contents API needs one call per FILE: ~2,600 calls over 438 repos, measured at roughly two hours, against a hard 5,000/hour ceiling. A single GraphQL tree query returns every workflow file's text for a repo in one call. Verified to produce byte-identical classification to the REST path on the control repo `affinescript` (4 BEHIND, 1 TRACKING, 1 ILLEGAL). The REST path is kept as a fallback for a GraphQL outage. 2. Traps must never reference a `local`. `trap 'rm -rf "$d"' RETURN` is inherited, so it fired when OTHER functions returned, where `$d` is unbound under `set -u`; the same fault applied to `$work` in the EXIT trap. Both aborted the run AFTER the census was written, so the census looked complete while the process exited non-zero. Replaced with an explicit cleanup and a global with a `:-` guard. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HfgwLCdKNd5iZVo6VTiSim --- scripts/apply-workflow-pins-remote.sh | 56 +++++++++++++++++++++++---- 1 file changed, 48 insertions(+), 8 deletions(-) diff --git a/scripts/apply-workflow-pins-remote.sh b/scripts/apply-workflow-pins-remote.sh index 3cb7b3994..b7594cae2 100755 --- a/scripts/apply-workflow-pins-remote.sh +++ b/scripts/apply-workflow-pins-remote.sh @@ -169,7 +169,7 @@ rewrite_illegal() { # --------------------------------------------------------------------------- self_test() { local d rc=0 t="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" - d=$(mktemp -d); trap 'rm -rf "$d"' RETURN + d=$(mktemp -d) printf 'jobs:\n a:\n uses: hyperpolymath/standards/.github/workflows/x.yml@%s\n' "$t" > "$d/fresh.yml" printf 'jobs:\n a:\n uses: hyperpolymath/standards/.github/workflows/x.yml@%s\n' "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" > "$d/behind.yml" @@ -238,6 +238,7 @@ self_test() { echo " FAIL illegal repair did not yield a legal pin: $(cat "$d/illegal.yml")" >&2; rc=1 fi + rm -rf "$d" return $rc } @@ -306,16 +307,55 @@ list_repos() { # stale, and reading one is what produced a false "285 callers track main" # census on 2026-09-15. fetch_workflows() { - local repo="$1" dest="$2" name + local repo="$1" dest="$2" owner="${1%%/*}" name="${1##*/}" resp mkdir -p "$dest" + resp=$(mktemp) + + # ONE GraphQL call fetches every workflow file's text for a repo. The REST + # contents API needs one call per FILE, which over 438 repos is ~2,600 calls + # and roughly two hours; this is ~438 calls and minutes. The estate has a hard + # 5,000/hour ceiling, so the cheaper shape is not an optimisation, it is what + # makes an estate-wide census affordable at all. + if gh api graphql \ + -f query='query($owner:String!,$name:String!){ + repository(owner:$owner,name:$name){ + object(expression:"HEAD:.github/workflows"){ + ... on Tree { entries { name type object { ... on Blob { text } } } } + } + } + }' \ + -F owner="$owner" -F name="$name" > "$resp" 2>/dev/null; then + local n b64 + while IFS=$'\t' read -r n b64; do + [ -n "$n" ] || continue + printf '%s' "$b64" | base64 -d > "${dest}/${n}" 2>/dev/null || rm -f "${dest}/${n}" + done < <(jq -r ' + (.data.repository.object.entries // [])[] + | select(.type == "blob") + | select(.name | test("\\.ya?ml$")) + | select(.object.text != null) + | "\(.name)\t\(.object.text | @base64)"' "$resp" 2>/dev/null) + rm -f "$resp" + # A repo with no .github/workflows yields an empty tree, which is a valid + # answer, not a failure. Only fall back when the call itself failed. + return 0 + fi + rm -f "$resp" + + # REST fallback: slower, but survives a GraphQL outage or a repo shape the + # query cannot express. The REMOTE content is the only evidence either way — + # reading a local checkout is what produced a false "285 callers track main" + # census on 2026-09-15. + local fname gh api "repos/${repo}/contents/.github/workflows" \ --jq '.[] | select(.type == "file") | .name' 2>/dev/null \ | grep -E '\.ya?ml$' \ - | while IFS= read -r name; do - gh api "repos/${repo}/contents/.github/workflows/${name}" \ - -H 'Accept: application/vnd.github.raw' > "${dest}/${name}" 2>/dev/null \ - || rm -f "${dest}/${name}" + | while IFS= read -r fname; do + gh api "repos/${repo}/contents/.github/workflows/${fname}" \ + -H 'Accept: application/vnd.github.raw' > "${dest}/${fname}" 2>/dev/null \ + || rm -f "${dest}/${fname}" done + return 0 } # land_pr — create branch, commit VERIFIED, open PR. @@ -406,7 +446,7 @@ main() { local tsv; tsv=$(mktemp) printf 'REPO\tWORKFLOW\tSTATUS\tDETAIL\n' > "$tsv" - local work; work=$(mktemp -d); trap 'rm -rf "$work"' EXIT + WORKDIR=$(mktemp -d); trap 'rm -rf "${WORKDIR:-}"' EXIT local repos n=0 if [ -n "$ONLY_REPO" ]; then repos="$ONLY_REPO"; else repos=$(list_repos); fi @@ -414,7 +454,7 @@ main() { for repo in $repos; do [ "$LIMIT" -gt 0 ] && [ "$n" -ge "$LIMIT" ] && break n=$((n+1)) - local rdir="${work}/$(echo "$repo" | tr '/' '_')" + local rdir="${WORKDIR}/$(echo "$repo" | tr '/' '_')" fetch_workflows "$repo" "$rdir" local found=0 changed=() wf base st detail shopt -s nullglob From a5d14be4146caaf349b852dc9ba357fef9061529 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Tue, 15 Sep 2026 13:49:50 +0100 Subject: [PATCH 3/3] chore(registry): refresh one stale TRG source_hash NOT part of the applier change, and recorded here so it is not mistaken for scope creep. `.machine_readable/REGISTRY.a2ml` is stale on `main` itself: the `toolchain-readiness-grades/` content moved without the derived registry being regenerated, so `build-registry.sh --check` fails on every PR opened against main, including this one. This is the mechanical output of `bash scripts/build-registry.sh` and touches exactly one line. Carried here only to unblock the branch. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HfgwLCdKNd5iZVo6VTiSim --- .machine_readable/REGISTRY.a2ml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.machine_readable/REGISTRY.a2ml b/.machine_readable/REGISTRY.a2ml index cb39c9210..9f919a146 100644 --- a/.machine_readable/REGISTRY.a2ml +++ b/.machine_readable/REGISTRY.a2ml @@ -198,7 +198,7 @@ name = "TRG — Toolchain Readiness Grades" stream = "readiness" home = "toolchain-readiness-grades/" canonical_doc = "toolchain-readiness-grades/README.adoc" -source_hash = "sha256:9f88c4e947226a0c53a419458949533eb84c2d7e8a343cb2d547ccca880939b6" +source_hash = "sha256:d134340774dd73435ebc428758541433eafaef9599bfedf51946bbfda0b4482c" route = "per-toolchain readiness profile templates" [[spec]]