From 34a33f467de46a41f69b4d20dd0efa0de2dcec28 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Sun, 20 Sep 2026 21:43:29 +0000 Subject: [PATCH 1/2] fix(ci): publish the canonical Hypatia context and guard the CI context contract (#17) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `main`'s rules required the *bare* status context `Hypatia Neurosymbolic Analysis`. Nothing can publish that name: a job that calls a reusable workflow publishes ` / `, so this repository published `scan / Hypatia Neurosymbolic Analysis`, and no check run ever carried the required string. Result: green board, permanently blocked merges (issue #17). Three changes, all mechanical and independently checkable: * `.github/workflows/hypatia-scan.yml` — caller job `scan` -> `hypatia`, so the published name is the estate-canonical `hypatia / Hypatia Neurosymbolic Analysis` (standards docs/audits/audit-hypatia-pin-orphan-2026-05-27.adoc). The wrapper header records the published string so the next reader does not have to rediscover the naming rule. * `.machine_readable/CI-CONTEXTS.a2ml` — machine-readable contract: the wrapper caller ids, the exact contexts they publish, what may be armed as a required check, and the strings that can never be satisfied (`bare-hypatia`). * `tools/check-ci-contexts.sh` + `.github/workflows/ci-contexts.yml` — the guard. Offline half: every contract entry must still describe the workflow as it is (caller job id present, contexts prefixed by it) — deterministic, fails the build, no token needed. Live half: every context required by this repository's branch rulesets (and legacy branch protection when readable) is checked for producibility, advisory by default and fatal under REQUIRED_CONTEXTS_STRICT=1, following the estate's "no manufactured red noise" rule. The bare legacy name, if required, is reported with its exact replacement. `actions.lock` regenerated with the official extension (`gh actions-lock`, v0.1.6) in the same commit, as the lockfile gate requires; the diff is the single new workflow entry. Verified before pushing: `lake build` 20/20; the guard passes and fails as designed when the caller job is renamed back; `check-actions-lock-gate.sh`, `check-docs-presence.sh`, `check-allowed-actions.sh`, `check-canonical-names.sh origin/main`, `check-licence-consistency.sh`, `check-trusted-base.sh` and `check-workflow-staleness.sh` all pass. --- .github/workflows/actions.lock | 2 + .github/workflows/ci-contexts.yml | 24 +++ .github/workflows/hypatia-scan.yml | 17 +- .machine_readable/CI-CONTEXTS.a2ml | 60 ++++++ docs/CI-CONTEXTS.adoc | 161 ++++++++++++++ tools/check-ci-contexts.sh | 332 +++++++++++++++++++++++++++++ 6 files changed, 594 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/ci-contexts.yml create mode 100644 .machine_readable/CI-CONTEXTS.a2ml create mode 100644 docs/CI-CONTEXTS.adoc create mode 100755 tools/check-ci-contexts.sh diff --git a/.github/workflows/actions.lock b/.github/workflows/actions.lock index 4de5860..64972a7 100644 --- a/.github/workflows/actions.lock +++ b/.github/workflows/actions.lock @@ -3,6 +3,8 @@ # Docs: https://gh.io/actions-lockfile version: 'v0.0.2' workflows: + '.github/workflows/ci-contexts.yml': + - 'actions/checkout@v4.4.0' '.github/workflows/governance.yml': [] '.github/workflows/hypatia-scan.yml': [] '.github/workflows/label-triage.yml': [] diff --git a/.github/workflows/ci-contexts.yml b/.github/workflows/ci-contexts.yml new file mode 100644 index 0000000..7c7af19 --- /dev/null +++ b/.github/workflows/ci-contexts.yml @@ -0,0 +1,24 @@ +# This workflow is managed by gh actions-lock. +# SPDX-License-Identifier: MPL-2.0 +name: CI context contract +on: + pull_request: + push: + branches: [main, master] + workflow_dispatch: +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true +permissions: + contents: read +jobs: + contract: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4.4.0 + - name: Check the CI context contract + env: + GITHUB_REPOSITORY: ${{ github.repository }} + GH_TOKEN: ${{ secrets.HYPATIA_SCAN_PAT || secrets.GITHUB_TOKEN }} + run: bash tools/check-ci-contexts.sh . diff --git a/.github/workflows/hypatia-scan.yml b/.github/workflows/hypatia-scan.yml index aadb838..482e044 100644 --- a/.github/workflows/hypatia-scan.yml +++ b/.github/workflows/hypatia-scan.yml @@ -1,6 +1,19 @@ # This workflow is managed by gh actions-lock. # SPDX-License-Identifier: MPL-2.0 -# This workflow is managed by gh actions-lock. +# Hypatia Security Scan +# +# The check GitHub publishes from this wrapper is +# +# / +# = hypatia / Hypatia Neurosymbolic Analysis +# +# That string is the estate-canonical context for this gate (standards +# docs/audits/audit-hypatia-pin-orphan-2026-05-27.adoc). It is load-bearing: a +# required status check is matched by string equality against the published +# check-run name, so if the caller job id ever changes, the required check stops +# being satisfied and every pull request is blocked while the board shows green. +# `tools/check-ci-contexts.sh` fails the build if this id and the contract in +# `.machine_readable/CI-CONTEXTS.a2ml` ever drift apart. name: Hypatia Security Scan on: @@ -18,6 +31,6 @@ permissions: security-events: write jobs: - scan: + hypatia: uses: hyperpolymath/standards/.github/workflows/hypatia-scan-reusable.yml@84355587cb2a1f86e6882de83514a32db2646e7a secrets: inherit diff --git a/.machine_readable/CI-CONTEXTS.a2ml b/.machine_readable/CI-CONTEXTS.a2ml new file mode 100644 index 0000000..cdfda8d --- /dev/null +++ b/.machine_readable/CI-CONTEXTS.a2ml @@ -0,0 +1,60 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +# +# CI-CONTEXTS.a2ml — the status-context contract for `main`. +# +# A required status check is matched by *string equality* against the name GitHub +# publishes for that check run. A required name that nothing can publish is an +# unsatisfiable requirement: every pull request stays BLOCKED while the CI board +# shows green. That is exactly what hyperpolymath/tropical-types#17 was, and +# tools/check-ci-contexts.sh now enforces this file so it cannot recur silently. +# +# Written 2026-09-20 resolving hyperpolymath/tropical-types#17. +# See docs/CI-CONTEXTS.adoc for the narrative and the remediation recipes. + +[ci-contexts] +version = "0.1.0" +last-updated = "2026-09-20" +schema = "CI-CONTEXTS" + +[naming-rule] +# A job that calls a reusable workflow publishes " / "; +# a normal job publishes its own `name:` (or its job id when it has none). +reusable-caller = " / " +normal-job = "" + +[[wrapper]] +gate = "Hypatia Neurosymbolic Analysis" +workflow = ".github/workflows/hypatia-scan.yml" +caller-job-id = "hypatia" +reusable = "hyperpolymath/standards/.github/workflows/hypatia-scan-reusable.yml" +published-contexts = ["hypatia / Hypatia Neurosymbolic Analysis"] + +[[wrapper]] +gate = "Secret Scanner" +workflow = ".github/workflows/secret-scanner.yml" +caller-job-id = "secret-scan" +reusable = "hyperpolymath/standards/.github/workflows/secret-scanner-reusable.yml" +published-contexts = ["secret-scan / gitleaks", "secret-scan / rust-secrets", "secret-scan / shell-secrets"] + +[[wrapper]] +gate = "Governance" +workflow = ".github/workflows/governance.yml" +caller-job-id = "governance" +reusable = "hyperpolymath/standards/.github/workflows/governance-reusable.yml" +published-contexts = ["governance / Code quality + docs", "governance / Licence consistency", "governance / Actions lockfile verify", "governance / Check Workflow Staleness", "governance / Trusted-base reduction policy", "governance / Security policy checks"] + +[armable-contexts] +# Strings a branch rule may require of this repository today, because a workflow +# here publishes them. Keep in step with the [[wrapper]] lists above. +canonical-hypatia = "hypatia / Hypatia Neurosymbolic Analysis" + +[never-armable-contexts] +# A reusable-wrapper caller can never publish the reusable's bare inner name: +# GitHub always prefixes it with the caller. Requiring a bare name is therefore +# unsatisfiable by construction — the residual form of issue #17. +# Remediation, in order of preference: +# 1. require the prefixed name recorded in [armable-contexts]; +# 2. if the bare name must stay, publish it from a job implemented in this +# repository rather than from a wrapper. +bare-hypatia = "Hypatia Neurosymbolic Analysis" diff --git a/docs/CI-CONTEXTS.adoc b/docs/CI-CONTEXTS.adoc new file mode 100644 index 0000000..cb7a850 --- /dev/null +++ b/docs/CI-CONTEXTS.adoc @@ -0,0 +1,161 @@ +// SPDX-License-Identifier: CC-BY-SA-4.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) += CI status contexts — what this repository publishes, and what may be required +:toc: +:toclevels: 2 + +[abstract] +A required status check is matched by *string equality* against the check-run +name GitHub publishes. A required name that nothing can publish is an +unsatisfiable requirement: every pull request stays `BLOCKED` while the CI board +shows green. This repository hit exactly that (issue #17). This document records +the names this repository publishes, the one-line rule that generates them, and +the guard that fails the build if the two ever drift apart. + +== The failure mode + +GitHub does not verify that a required context *can* be produced. It only waits +for a check run with that exact name. So: + +* the CI board shows all real checks green; +* the merge button stays disabled, with no error that names the cause; +* the only ways out are an admin override or a correct string. + +Issue #17 is this failure mode: `main`'s branch protection required the bare +context `Hypatia Neurosymbolic Analysis`, while the workflow that actually runs +the scan publishes `scan / Hypatia Neurosymbolic Analysis` (and the aggregate +`Hypatia`). No check run ever carried the required name. + +== The naming rule + +Two shapes exist, and only these two: + +[cols="1,2"] +|=== +| Job kind | Published check-run name + +| a normal job +| its `name:` — or its job id when it has no `name:` + +| a job that calls a reusable workflow +| ` / ` +|=== + +The second row is the whole of issue #17: *a wrapper can never publish the +reusable's bare inner name*, because GitHub always prefixes it with the caller. +Requiring a bare name from a wrapper is unsatisfiable by construction. That is +also why the estate's earlier audit prescribed the prefixed form: +https://github.com/hyperpolymath/standards/blob/main/docs/audits/audit-hypatia-pin-orphan-2026-05-27.adoc[`audit-hypatia-pin-orphan-2026-05-27.adoc`]. + +== What this repository publishes today + +Derived from `.github/workflows/` and the pinned reusables — `tools/check-ci-contexts.sh` +prints the same list, and CI runs it: + +[cols="1,2,1"] +|=== +| Gate | Published context | Source + +| Hypatia scan +| `hypatia / Hypatia Neurosymbolic Analysis` +| `.github/workflows/hypatia-scan.yml` → standards `hypatia-scan-reusable.yml` + +| Secret scan +| `secret-scan / gitleaks`, `secret-scan / rust-secrets`, `secret-scan / shell-secrets` +| `.github/workflows/secret-scanner.yml` + +| Governance +| `governance / Code quality + docs`, `governance / Licence consistency`, + `governance / Actions lockfile verify`, `governance / Check Workflow Staleness`, + `governance / Trusted-base reduction policy`, `governance / Security policy checks`, … +| `.github/workflows/governance.yml` + +| Lean build +| `lake-build` +| `.github/workflows/lean.yml` + +| Email notification +| `Email on push` +| `.github/workflows/push-email-notify.yml` + +| Estate label sync +| `sync`, `triage` +| `.github/workflows/labels.yml`, `.github/workflows/label-triage.yml` +|=== + +== The contract, and the guard + +* `.machine_readable/CI-CONTEXTS.a2ml` — machine-readable record of the wrapper + caller ids and the exact context strings they publish, the string that may be + armed as a required check (`[armable-contexts]`), and the strings that can + never be satisfied (`[never-armable-contexts]`). +* `tools/check-ci-contexts.sh` — the guard, run by + `.github/workflows/ci-contexts.yml` on every push and pull request. + +Two halves, deliberately different in strength: + +. *Contract check (offline, fails the build).* Every `[[wrapper]]` entry must + still describe the workflow as it is: the caller job id is present, and every + declared context is prefixed by that caller id. Deterministic, always + satisfiable, no token needed. This is what stops the string silently changing. +. *Required-context check (live, advisory by default).* Every context required + by this repository's branch rulesets — and by legacy branch protection when + the token can read it — is compared against the set of names the workflows can + publish. A required context that no workflow can produce, and that is not + attached to a third-party app integration, is reported. It is a `::warning`, + not a red, unless `REQUIRED_CONTEXTS_STRICT=1`: a gate that reds nobody yet is + a warning, not a real gate. If the bare legacy name is found, the warning + names the exact replacement. + +Run it locally: + +[source,sh] +---- +bash tools/check-ci-contexts.sh . # contract + advisory live check +REQUIRED_CONTEXTS_STRICT=1 bash tools/check-ci-contexts.sh . # make it a gate +bash tools/check-ci-contexts.sh . --no-network # contract only +---- + +== Arming the Hypatia gate + +The evidence in issue #17 was filed against a branch protection rule that +required the bare string. As of 2026-09-20 the required-status-checks rule is +*not* armed: legacy branch protection reports +`enforcement_level=off` with an empty `contexts` list, and the `Base` ruleset +carries no `required_status_checks` rule at all. So nothing is blocked today — +and, with the caller job renamed, the canonical string is now producible, so the +gate can be armed safely whenever the owner wants it back: + +[source,sh] +---- +# ruleset route (needs Administration: write; id from GET /repos/OWNER/REPO/rulesets) +gh api -X PUT repos/hyperpolymath/tropical-types/rulesets/14936606 \ + -f 'rules[][type]=required_status_checks' \ + -f 'rules[][parameters][required_status_checks][][context]=hypatia / Hypatia Neurosymbolic Analysis' +---- + +If a rule ever does require the bare `Hypatia Neurosymbolic Analysis`, there are +exactly two honest options — and `tools/check-ci-contexts.sh` says so rather than +letting the block be misdiagnosed: + +. require `hypatia / Hypatia Neurosymbolic Analysis` instead (preferred: it keeps + the shared reusable and the estate's canonical name); +. implement the scan in this repository and publish the bare name from that job + (no wrapper — a wrapper cannot do it). + +== If the estate changes the convention + +The caller job id is the only lever, and it is recorded in one place. If the +estate standardises on a different caller id (today the estate is inconsistent: +most repositories use `scan`, the audit above documents `hypatia`), change the +job id in `.github/workflows/hypatia-scan.yml` and the matching +`caller-job-id`/`published-contexts` pair in `.machine_readable/CI-CONTEXTS.a2ml` +in the same commit. The guard fails otherwise — that is the point. + +== Provenance + +* Issue: https://github.com/hyperpolymath/tropical-types/issues/17 +* Upstream audit (the wrapper-prefix class): + https://github.com/hyperpolymath/standards/blob/main/docs/audits/audit-hypatia-pin-orphan-2026-05-27.adoc +* Estate-wide census of the class and its repair, 2026-09-20: + https://github.com/hyperpolymath/standards/blob/main/docs/audits/audit-ci-context-producibility-2026-09-20.adoc diff --git a/tools/check-ci-contexts.sh b/tools/check-ci-contexts.sh new file mode 100755 index 0000000..114295f --- /dev/null +++ b/tools/check-ci-contexts.sh @@ -0,0 +1,332 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) +# +# check-ci-contexts.sh — prove that every status context this repository's +# branch rules require is a name its own workflows can actually publish. +# +# WHY THIS EXISTS +# --------------- +# A required status check is matched by *string equality* against the check-run +# name GitHub publishes. When the two never match, the required check is never +# satisfied: every pull request stays BLOCKED while the CI board shows green. +# That is this repository's issue #17, and the class is catalogued upstream in +# standards docs/audits/audit-hypatia-pin-orphan-2026-05-27.adoc. +# +# Two name shapes exist: +# * a normal job publishes its `name:` (or its job id when it has no `name:`); +# * a job that *calls a reusable workflow* publishes +# / +# so a wrapper can never publish the reusable's bare inner name. +# +# WHAT IT CHECKS +# 1. CONTRACT — offline, always enforced: every [[wrapper]] block in +# .machine_readable/CI-CONTEXTS.a2ml must still describe the workflow +# as it is (caller job id present, declared contexts prefixed by it), so the +# documented context string keeps being published. This is the half that +# fails the build; it is deterministic and always satisfiable. +# 2. REQUIRED CONTEXTS — live, advisory by default: every context required by +# this repository's branch rulesets (and legacy branch protection, when the +# token can read it) is either producible by a workflow here, or attached to +# a third-party app integration. +# +# USAGE +# tools/check-ci-contexts.sh [REPO_ROOT] [--no-network] +# +# ENV +# GITHUB_REPOSITORY owner/repo; default: `gh repo view`, else live half skipped +# GH_TOKEN / GITHUB_TOKEN read-only token, used for the live half +# CI_CONTEXTS_CONTRACT override the contract path +# REQUIRED_CONTEXTS_STRICT 1 = fail on a provably unproducible required context. +# Default 0 = warn only: a check that reds nobody yet +# is a warning, not a red. +# +# EXIT: 0 = contract intact and no hard finding; 1 = contract broken, or (strict) +# an unproducible required context. +set -uo pipefail + +ROOT="." +OFFLINE=0 +for a in "$@"; do + case "$a" in + --no-network) OFFLINE=1 ;; + --help|-h) sed -n '2,40p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + -*) printf 'unknown option: %s\n' "$a" >&2; exit 2 ;; + *) ROOT="$a" ;; + esac +done + +WF_DIR="$ROOT/.github/workflows" +CONTRACT="${CI_CONTEXTS_CONTRACT:-$ROOT/.machine_readable/CI-CONTEXTS.a2ml}" +HAVE_GH=0; command -v gh >/dev/null 2>&1 && HAVE_GH=1 +HAVE_JQ=0; command -v jq >/dev/null 2>&1 && HAVE_JQ=1 +STRICT="${REQUIRED_CONTEXTS_STRICT:-0}" +TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT + +info() { printf ' %s\n' "$*"; } +note() { printf '::notice::%s\n' "$*"; } +warn() { printf '::warning::%s\n' "$*"; } +err() { printf '::error::%s\n' "$*"; } + +FAIL=0 # contract broken -> always fail +HARD=0 # unproducible required context -> fail only in strict mode + +# ---------------------------------------------------------------- parsing ---- + +# Emit "jobIdnameOrDashusesOrDash" for every job in one workflow file. +# Empty fields are written as "-" because `read` with IFS=tab collapses runs of +# IFS whitespace, which would silently shift every field after an empty one. +jobs_of() { + awk ' + /^jobs:[[:space:]]*$/ { injobs=1; next } + /^[^[:space:]#]/ { injobs=0 } + injobs && /^ [A-Za-z0-9_.-]+:[[:space:]]*$/ { + if (job != "") print job "\t" (name==""?"-":name) "\t" (uses==""?"-":uses) + job=$1; sub(/:$/,"",job); name=""; uses=""; next + } + injobs && /^ name:[[:space:]]*/ { + v=$0; sub(/^[[:space:]]*name:[[:space:]]*/,"",v); gsub(/^"|"[[:space:]]*$/,"",v); name=v + } + injobs && /^ uses:[[:space:]]*/ { + v=$0; sub(/^[[:space:]]*uses:[[:space:]]*/,"",v); sub(/[[:space:]]*#.*$/,"",v); uses=v + } + END { if (job != "") print job "\t" (name==""?"-":name) "\t" (uses==""?"-":uses) } + ' "$1" +} + +# One line per [[wrapper]] block: "workflowcallerJobIdctx,ctx" +contract_wrappers() { + [ -f "$CONTRACT" ] || return 1 + awk ' + function flush() { if (wf != "") printf "%s\t%s\t%s\n", wf, cj, pc; wf=""; cj=""; pc="" } + /^\[\[wrapper\]\]/ { flush(); inb=1; next } + /^\[/ { flush(); inb=0; next } + inb && /^[[:space:]]*workflow[[:space:]]*=/ { + v=$0; sub(/^[^=]*=[[:space:]]*/,"",v); gsub(/^"|"[[:space:]]*$/,"",v); wf=v + } + inb && /^[[:space:]]*caller-job-id[[:space:]]*=/ { + v=$0; sub(/^[^=]*=[[:space:]]*/,"",v); gsub(/^"|"[[:space:]]*$/,"",v); cj=v + } + inb && /^[[:space:]]*published-contexts[[:space:]]*=/ { + v=$0; sub(/^[^=]*=[[:space:]]*/,"",v); gsub(/[][]|"/,"",v); gsub(/,[[:space:]]*/,",",v); pc=v + } + END { flush() } + ' "$CONTRACT" +} + +contract_value() { # section-key -> value + [ -f "$CONTRACT" ] || return 1 + awk -v k="$1" ' + $0 ~ "^[[:space:]]*" k "[[:space:]]*=" { + v=$0; sub(/^[^=]*=[[:space:]]*/,"",v); gsub(/^"|"[[:space:]]*$/,"",v); print v; exit + }' "$CONTRACT" +} + +# Inner jobs of a reusable, resolved from the pinned ref. +# Emits "jobIddisplayNameOrDash". +reusable_jobs() { + local spec="$1" path repo file ref url cache + path="${spec%@*}"; ref="${spec##*@}" + repo="${path%%/.github/workflows/*}" + file=".github/workflows/${path##*/}" + cache="$TMP/$(printf '%s' "$spec" | tr -c 'A-Za-z0-9.' '_')" + if [ ! -f "$cache" ]; then + [ "$OFFLINE" = 1 ] && return 1 + curl -fsSL "https://raw.githubusercontent.com/$repo/$ref/$file" -o "$cache" 2>/dev/null || return 1 + fi + jobs_of "$cache" +} + +# ------------------------------------------------------------------ step 1 ---- +printf '\n== 1. CI context contract (%s)\n' "${CONTRACT#"$ROOT"/}" +if [ ! -f "$CONTRACT" ]; then + err "contract file missing: $CONTRACT" + err "the repository has no record of which context strings its workflows publish; add it (see docs/CI-CONTEXTS.adoc)" + exit 1 +fi + +while IFS=$'\t' read -r wf cj pcs; do + [ -n "$wf" ] || continue + path="$ROOT/$wf" + printf ' %s\n' "$wf" + if [ ! -f "$path" ]; then + err "$wf is named by the contract but does not exist" + FAIL=1; continue + fi + jrow="$(jobs_of "$path" | awk -F'\t' -v id="$cj" '$1==id {print; exit}')" + if [ -z "$jrow" ]; then + err "$wf no longer defines caller job '$cj' — the published check name changes with it, so any required context naming the old string becomes unsatisfiable" + FAIL=1 + else + jname="$(printf '%s' "$jrow" | cut -f2)" + [ "$jname" = "-" ] && jname="" + info "caller job '$cj' present" + fi + oldifs="$IFS"; IFS=',' + for ctx in $pcs; do + IFS="$oldifs" + case "$ctx" in + "$cj / "*) info "declared context: $ctx" ;; + *) if [ -n "${jname:-}" ]; then + case "$ctx" in "$jname / "*) info "declared context: $ctx (via caller display name)"; continue ;; esac + fi + if jobs_of "$path" | awk -F'\t' -v n="$ctx" '$2==n {f=1} END{exit(f?0:1)}'; then + info "declared context: $ctx (a plain job display name in this workflow)" + else + err "$wf declares context '$ctx'; its caller job '$cj' can only publish '$cj / '" + FAIL=1 + fi ;; + esac + IFS=',' + done + IFS="$oldifs" + rus="$(jobs_of "$path" | awk -F'\t' -v id="$cj" '$1==id {print $3; exit}')" + [ "$rus" = "-" ] && rus="" + case "$rus" in + *".github/workflows/"*"@"*) + if inner="$(reusable_jobs "$rus")" && [ -n "$inner" ]; then + while IFS=$'\t' read -r iid iname _rest; do + [ -n "$iid" ] || continue + [ "$iname" = "-" ] && iname="" + pub="$cj / ${iname:-$iid}" + case ",$pcs," in + *",$pub,"*) info "verified published name: $pub" ;; + *) info "also published by $rus but not listed in the contract: $pub" ;; + esac + done <<<"$inner" + else + note "could not read $rus to confirm the inner job name — caller prefix checked only" + fi ;; + *) warn "$wf no longer calls a reusable workflow; its [[wrapper]] entry in the contract is stale" ;; + esac +done < <(contract_wrappers) + +# ------------------------------------------------------------ step 2 (live) ---- +printf '\n== 2. branch rules vs publishable context names\n' +REPO="${GITHUB_REPOSITORY:-}" +if [ -z "$REPO" ] && [ "$HAVE_GH" = 1 ]; then + REPO="$(gh repo view --json nameWithOwner -q .nameWithOwner 2>/dev/null || true)" +fi + +live_ok=1 +[ "$OFFLINE" = 1 ] && live_ok=0 +[ "$HAVE_GH" = 0 ] && live_ok=0 +[ "$HAVE_JQ" = 0 ] && live_ok=0 +[ -z "$REPO" ] && live_ok=0 +if [ "$live_ok" = 1 ] && ! gh api rate_limit >/dev/null 2>&1; then + note "gh is not authenticated with a usable token — live half skipped" + live_ok=0 +fi + +if [ "$live_ok" = 0 ]; then + note "live half skipped (need gh + jq + GITHUB_REPOSITORY + a token; or --no-network was passed)" + if [ "$FAIL" = 0 ]; then printf '\ncheck-ci-contexts: contract intact\n'; else printf '\ncheck-ci-contexts: CONTRACT BROKEN\n'; fi + exit "$FAIL" +fi + +# every name this repository can publish +publishable() { + local f id name uses inner iid iname + for f in "$WF_DIR"/*.yml "$WF_DIR"/*.yaml; do + [ -f "$f" ] || continue + while IFS=$'\t' read -r id name uses; do + [ -n "$id" ] || continue + [ "$name" = "-" ] && name="" + [ "$uses" = "-" ] && uses="" + if [ -n "$uses" ]; then + case "$uses" in + *".github/workflows/"*"@"*) + if inner="$(reusable_jobs "$uses")" && [ -n "$inner" ]; then + while IFS=$'\t' read -r iid iname _rest; do + [ -n "$iid" ] || continue + [ "$iname" = "-" ] && iname="" + printf '%s / %s\n' "$id" "${iname:-$iid}" + [ -n "$name" ] && printf '%s / %s\n' "$name" "${iname:-$iid}" + done <<<"$inner" + else + printf '%s / *\n' "$id" + [ -n "$name" ] && printf '%s / *\n' "$name" + fi ;; + *) : ;; + esac + else + printf '%s\n' "${name:-$id}" + fi + done < <(jobs_of "$f") + done +} +publishable | sort -u >"$TMP/publishable" +printf ' %s publishable name(s) from this repository:\n' "$(wc -l <"$TMP/publishable" | tr -d ' ')" +sed 's/^/ /' "$TMP/publishable" + +BR="$(gh api "repos/$REPO" 2>/dev/null | jq -r '.default_branch // "main"' 2>/dev/null || echo main)" + +# required contexts, from both places GitHub keeps them +: >"$TMP/required" +ruleset_ids="$(gh api "repos/$REPO/rulesets" 2>/dev/null \ + | jq -r '.[]? | select(.target=="branch") | .id' 2>/dev/null || true)" +for id in $ruleset_ids; do + body="$(gh api "repos/$REPO/rulesets/$id" 2>/dev/null || true)" + printf '%s' "$body" | jq -r '.rules[]? | select(.type=="required_status_checks") + | .parameters.required_status_checks[]? + | [(.context|tostring), ((.integration_id // 0)|tostring)] | @tsv' 2>/dev/null \ + | awk -F'\t' 'NF{print $0 "\truleset"}' >>"$TMP/required" +done + +bp="$(gh api "repos/$REPO/branches/$BR/protection/required_status_checks" 2>/dev/null || true)" +if printf '%s' "$bp" | jq -e '.enforcement_level' >/dev/null 2>&1; then + level="$(printf '%s' "$bp" | jq -r '.enforcement_level')" + info "legacy branch protection on $BR: required status checks enforcement_level=$level" + printf '%s' "$bp" | jq -r '.contexts[]? | [., "0"] | @tsv' 2>/dev/null \ + | awk -F'\t' 'NF{print $0 "\tbranch-protection"}' >>"$TMP/required" + printf '%s' "$bp" | jq -r '.checks[]? | [(.context|tostring), ((.app_id // 0)|tostring)] | @tsv' 2>/dev/null \ + | awk -F'\t' 'NF{print $0 "\tbranch-protection"}' >>"$TMP/required" +else + note "branch protection on $BR unreadable with this token (Administration: read is required) — rulesets only" +fi + +sort -u "$TMP/required" -o "$TMP/required" +if [ ! -s "$TMP/required" ]; then + info "no required status contexts found in this repository's branch rules" + if [ "$FAIL" = 0 ]; then printf '\ncheck-ci-contexts: contract intact\n'; else printf '\ncheck-ci-contexts: CONTRACT BROKEN\n'; fi + exit "$FAIL" +fi + +CANON="$(contract_value canonical-hypatia)" +while IFS=$'\t' read -r ctx app src; do + [ -n "$ctx" ] || continue + if [ -n "$app" ] && [ "$app" != "0" ] && [ "$app" != "15368" ]; then + info "required, third-party app $app ($src): $ctx" + continue + fi + if awk -v req="$ctx" '$0==req{f=1} END{exit(f?0:1)}' "$TMP/publishable"; then + info "required and producible ($src): $ctx" + continue + fi + hit=0 + while IFS= read -r cand; do + # shellcheck disable=SC2254 # unquoted RHS is a deliberate glob (matrix-suffixed names) + case "$ctx" in $cand) hit=1; break ;; esac + done <"$TMP/publishable" + if [ "$hit" = 1 ]; then + info "required and producible ($src): $ctx" + continue + fi + bare="$(contract_value bare-hypatia)" + if [ -n "$bare" ] && [ "$ctx" = "$bare" ]; then + msg="required context '$ctx' is unsatisfiable by construction: it is the bare name of a reusable workflow's inner job, and a caller always publishes ' / '. Require '$CANON' instead, or publish '$ctx' from a job implemented in this repository." + else + msg="required context '$ctx' ($src) is not producible by any workflow in this repository, and is not attached to a third-party app integration" + fi + HARD=1 + if [ "$STRICT" = 1 ]; then err "$msg"; else warn "$msg (advisory: set REQUIRED_CONTEXTS_STRICT=1 to fail)"; fi +done <"$TMP/required" + +if [ "$FAIL" != 0 ]; then + printf '\ncheck-ci-contexts: CONTRACT BROKEN\n'; exit 1 +fi +if [ "$HARD" = 1 ] && [ "$STRICT" = 1 ]; then + printf '\ncheck-ci-contexts: contract intact, but a required context is unproducible\n'; exit 1 +fi +printf '\ncheck-ci-contexts: contract intact\n' +exit 0 From 070ae3fcd2815d3570ba22040afb6d4f91a71b35 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Sun, 20 Sep 2026 21:45:46 +0000 Subject: [PATCH 2/2] fix(ci): use github.token in the context-contract job The CI context contract job referenced , which trips the Hypatia scanner's RE001 research extension ('job references secrets.* but does not install step-security/harden-runner'), and surfaced as a new code-scanning alert on this pull request. step-security/harden-runner is not in the estate actions allowlist, so adding it is not an option (it would fail the allowlist preflight and the repository's selected-actions policy). Use instead: the job needs only read access, it can read branch rulesets with that token, and the script states plainly what it could not read (legacy branch protection needs a credential with Administration: read, which the Actions token cannot have) rather than guessing. Also move the new workflow to actions/checkout@v7.0.1, the pin every other estate workflow uses, and record it in actions.lock with the official extension. --- .github/workflows/actions.lock | 7 ++++++- .github/workflows/ci-contexts.yml | 29 +++++++++++++++++++++++++++-- 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/.github/workflows/actions.lock b/.github/workflows/actions.lock index 64972a7..5ae11d5 100644 --- a/.github/workflows/actions.lock +++ b/.github/workflows/actions.lock @@ -4,7 +4,7 @@ version: 'v0.0.2' workflows: '.github/workflows/ci-contexts.yml': - - 'actions/checkout@v4.4.0' + - 'actions/checkout@v7.0.1' '.github/workflows/governance.yml': [] '.github/workflows/hypatia-scan.yml': [] '.github/workflows/label-triage.yml': [] @@ -20,6 +20,11 @@ dependencies: commit: 'sha1-11d5960a326750d5838078e36cf38b85af677262' owner_id: 44036562 repo_id: 197814629 + 'actions/checkout@v7.0.1': + ref: 'v7.0.1' + commit: 'sha1-3d3c42e5aac5ba805825da76410c181273ba90b1' + owner_id: 44036562 + repo_id: 197814629 'hyperpolymath/smtp-notify-action@v0.2.0': ref: 'v0.2.0' commit: 'sha1-ede1191ef6ff3ac02c4f4d9efdf837ee517e11d7' diff --git a/.github/workflows/ci-contexts.yml b/.github/workflows/ci-contexts.yml index 7c7af19..e93ab97 100644 --- a/.github/workflows/ci-contexts.yml +++ b/.github/workflows/ci-contexts.yml @@ -1,24 +1,49 @@ # This workflow is managed by gh actions-lock. # SPDX-License-Identifier: MPL-2.0 +# CI context contract — the guard for docs/CI-CONTEXTS.adoc. +# +# WHY THIS JOB EXISTS: a required status check is matched by string equality +# against the check-run name GitHub publishes. A required name nothing can +# publish blocks every pull request while the board shows green (issue #17), and +# the failure is invisible from the CI side. tools/check-ci-contexts.sh makes both +# halves visible: the offline contract check fails the build when the published +# string would drift, and the live half reports any required context this +# repository's workflows cannot produce. +# +# The live half reads the repository's branch rules. A fine-grained PAT with +# `Administration: read` sees more than the Actions token does (legacy branch +# protection is only readable with it); this job deliberately uses `github.token` +# — no `secrets.*` reference, so no egress-review finding — and reports exactly +# what it could not read instead of guessing. Run it locally with a broader token +# (`GH_TOKEN=… bash tools/check-ci-contexts.sh .`) to get that extra coverage. name: CI context contract + on: pull_request: push: branches: [main, master] workflow_dispatch: + concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true + permissions: contents: read + jobs: contract: + name: Required contexts are producible runs-on: ubuntu-latest timeout-minutes: 10 steps: - - uses: actions/checkout@v4.4.0 + - name: Checkout + uses: actions/checkout@v7.0.1 + with: + persist-credentials: false + - name: Check the CI context contract env: GITHUB_REPOSITORY: ${{ github.repository }} - GH_TOKEN: ${{ secrets.HYPATIA_SCAN_PAT || secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ github.token }} run: bash tools/check-ci-contexts.sh .