From 97dd367b753b687dad0aa04cf09ea57483edd76b Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Thu, 17 Sep 2026 19:58:31 +0000 Subject: [PATCH] =?UTF-8?q?fix(ci):=20propagate-hooks=20discovery=20?= =?UTF-8?q?=E2=80=94=20credential,=20error=20honesty,=20canary,=20compact?= =?UTF-8?q?=20JSON=20(closes=20#807)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The discovery step failed in a way that looked like 'nothing to do': 1. Root cause 1: the .githooks contents probe ran 'gh api' with NO token in scope (the file's only token sat on a different step). Tokenless calls failed, 2>/dev/null hid the error, and jq -e read the error object as 'not an array' — so every repo classified hookless and REPOS_WITH_HOOKS was permanently empty. → Step now carries env: GH_TOKEN: secrets.HYPATIA_SCAN_PAT, the estate's read-only cross-repo PAT (same credential governance and allowlist-preflight reusables use for live repo reads), with the scope stated in a comment per the issue's acceptance criterion. 2. Root cause 2: printf '%s\n' on an EMPTY bash array prints one empty line, so REPOS_JSON became [""], and writing that multiline value to $GITHUB_OUTPUT crashed the job ('Invalid format'). An empty list now serializes explicitly to [] and all JSON is compact single-line, including the target-repo branch (jq -cn). 3. Error-honesty + canary: probe errors are now a third outcome — reported and fatal-on-any — and a canary asserts hyperpolymath/standards (which owns 14 hooks) always classifies as having hooks. Zero-propagation can never fake-green again. --- .github/workflows/propagate-hooks.yml | 70 +++++++++++++++++++++------ 1 file changed, 56 insertions(+), 14 deletions(-) diff --git a/.github/workflows/propagate-hooks.yml b/.github/workflows/propagate-hooks.yml index ad3614996..f2c7662cd 100644 --- a/.github/workflows/propagate-hooks.yml +++ b/.github/workflows/propagate-hooks.yml @@ -66,6 +66,14 @@ jobs: - name: Identify repositories with .githooks id: identify + env: + # CREDENTIAL (issue #807, root cause 1): the probe below calls the + # contents API on target repos. HYPATIA_SCAN_PAT is the estate's + # read-only PAT (contents:read across repos) — the same credential + # governance-reusable.yml and allowlist-preflight-reusable.yml use + # for live repo reads. Without a token every probe fails silently + # and every repo is misclassified as hookless. + GH_TOKEN: ${{ secrets.HYPATIA_SCAN_PAT }} run: | echo "🔍 Identifying repositories that need hook updates..." @@ -75,7 +83,9 @@ jobs: # Check if specific repo was requested if [ -n "${{ github.event.inputs.target-repo }}" ]; then TARGET="${{ github.event.inputs.target-repo }}" - REPOS_JSON=$(jq -n --arg repo "$TARGET" '[$repo]') + # -c: compact, single-line JSON — a multiline value breaks + # $GITHUB_OUTPUT below (same defect class as root cause 2, #807). + REPOS_JSON=$(jq -cn --arg repo "$TARGET" '[$repo]') else # Use a sample list for now - TODO: Query all repos dynamically # For production, this should query the GitHub API for all repos @@ -84,26 +94,58 @@ jobs: "hyperpolymath/standards" ) - # Filter to only repos that have .githooks directory + # Filter to only repos that have .githooks directory. + # Three outcomes per repo, not two (issue #807): has-hooks, no-hooks, + # and PROBE ERROR. The old code folded error into no-hooks (2>/dev/null + # + jq -e on the error object), so a credential failure read as + # "nothing to do" — a fake green. echo "🔍 Filtering repos with .githooks directory..." REPOS_WITH_HOOKS=() + DISCOVERY_ERRORS=() for repo in "${ALL_REPOS[@]}"; do - ORG=$(echo "$repo" | cut -d'/' -f1) - REPO_NAME=$(echo "$repo" | cut -d'/' -f2) - - # Check if .githooks exists in the repo - if gh api /repos/$ORG/$REPO_NAME/contents/.githooks \ + ERR_FILE=$(mktemp) + if OUT=$(gh api "/repos/$repo/contents/.githooks" \ -H "Accept: application/vnd.github+json" \ - -H "X-GitHub-Api-Version: 2022-11-28" 2>/dev/null | \ - jq -e '. | type == "array"' 2>/dev/null; then - REPOS_WITH_HOOKS+=("$repo") - echo " ✓ $repo has .githooks" - else + -H "X-GitHub-Api-Version: 2022-11-28" 2>"$ERR_FILE"); then + if jq -e 'type == "array"' >/dev/null <<<"$OUT"; then + REPOS_WITH_HOOKS+=("$repo") + echo " ✓ $repo has .githooks" + else + echo " ✗ $repo skipped (.githooks exists but is not a directory)" + fi + elif grep -q "HTTP 404" "$ERR_FILE"; then echo " ✗ $repo skipped (no .githooks)" + else + DISCOVERY_ERRORS+=("$repo") + echo " ⚠ $repo: probe errored — NOT counted as hookless: $(head -1 "$ERR_FILE")" fi + rm -f "$ERR_FILE" done - - REPOS_JSON=$(printf '%s\n' "${REPOS_WITH_HOOKS[@]}" | jq -R . | jq -s .) + + # An erroring probe must fail the job, never propagate blind. + if ((${#DISCOVERY_ERRORS[@]})); then + echo "::error::Discovery errored for ${#DISCOVERY_ERRORS[@]} repo(s): ${DISCOVERY_ERRORS[*]} — refusing to propagate blind (check the credential on this step)" + exit 1 + fi + + # CANARY (issue #807): hyperpolymath/standards OWNS .githooks — 14 + # hooks. If the canonical repo classifies as hookless, the classifier + # is broken, not the repo. Fail loudly instead of propagating nowhere. + CANARY="hyperpolymath/standards" + if [[ " ${ALL_REPOS[*]} " == *" $CANARY "* && ! " ${REPOS_WITH_HOOKS[*]} " == *" $CANARY "* ]]; then + echo "::error::Discovery canary failed: $CANARY has .githooks yet classified hookless — discovery is broken, refusing to propagate" + exit 1 + fi + + # (issue #807, root cause 2): printf on an EMPTY bash array still + # prints one empty line, yielding [""] — and a multiline value then + # breaks $GITHUB_OUTPUT ('Invalid format'). Serialize [] explicitly, + # and always compact single-line JSON. + if ((${#REPOS_WITH_HOOKS[@]})); then + REPOS_JSON=$(printf '%s\n' "${REPOS_WITH_HOOKS[@]}" | jq -R . | jq -cs .) + else + REPOS_JSON='[]' + fi fi echo "repos=$REPOS_JSON" >> $GITHUB_OUTPUT