Repository navigation
propagate-hooks.yml propagates to zero repos: tokenless gh api misclassifies every repo as hookless #807
Description
Summary
.github/workflows/propagate-hooks.yml cannot propagate anything. Its
repository-discovery step calls gh api with no token in scope, so every
call fails, every repo is classified as having no .githooks, and the job then
crashes on the empty result. The hook-propagation path across the estate is
therefore dormant — and it fails in a way that looks like "nothing to do"
rather than like a break.
Exposed by the merge of #804, which is the first commit in a long time to touch
.githooks/** and so the first to trigger this workflow's path filter. The
defect is not caused by #804.
Evidence
Run 34952192815,
job Identify Repositories to Update, on 4c9225a1:
🔍 Filtering repos with .githooks directory...
✗ hyperpolymath/standards skipped (no .githooks)
##[error]Unable to process file command 'output' successfully.
##[error]Invalid format ' ""'
hyperpolymath/standards is the repo that owns .githooks/ — it has 14 of
them. A classifier that answers "no" for the canonical repo is answering a
different question than it appears to.
Root cause — two defects, one visible
-
No credential. The step runs
gh api /repos/$ORG/$REPO_NAME/contents/.githooks | jq -e '. | type == "array"'
with noenv: GH_TOKENand noenv: GITHUB_TOKEN. The only token in the
file is on line 155, an input to a different step. Tokenlessgh api
fails;2>/dev/nullhides the error;jq -eon the error object returns
false; the repo is silently classified as hookless. Every repo takes
this branch, soREPOS_WITH_HOOKSis always empty. -
Empty bash array through
printf. Line 106:REPOS_JSON=$(printf '%s\n' "${REPOS_WITH_HOOKS[@]}" | jq -R . | jq -s .)With an empty array this still prints one empty line, so
REPOS_JSON
becomes[""], not[]. Writing that multi-line value to$GITHUB_OUTPUT
without a heredoc delimiter producesInvalid format ' ""'.
Defect 2 is the only reason this was ever visible. Had the array been handled
correctly, the job would have gone green while propagating to zero repos —
a fake green, and this would still be undiagnosed.
Why it matters now
#804 added .githooks/validate-actions-lock.sh, the SHA-exact
uses ⊆ actions.lock validator. Estate repos will not receive it until this
workflow works. More generally: no .githooks change has propagated for as
long as this has been broken, so estate hook state has been drifting silently.
Acceptance criteria
- The discovery step has an explicit
env: GH_TOKEN: ${{ secrets.… }}with
a credential that can readcontentson target repos, and the token's
scope is stated in a comment. - A tokenless or failing
gh apicall causes the step to fail, not
to classify the repo as hookless. Distinguish "API said no" from "API did
not answer" — drop the blanket2>/dev/nulland check the exit status. -
hyperpolymath/standardsitself is classified as having.githooks.
Use it as a permanent positive control in the job: if the canonical repo
is not in the list, abort. - An empty result yields
[]and the job reports "0 repos" explicitly
rather than crashing or silently succeeding. Guard with
${arr[@]+"${arr[@]}"}or build the JSON withjq -n. - Multi-line
$GITHUB_OUTPUTwrites use the heredoc delimiter form. - A run on a real
.githookschange propagates
validate-actions-lock.sh, and the summary names a non-zero repo count.
Related
The same shape as the recurring "a guard asks a different question than its
consumer" trap: here the guard asks "did an unauthenticated API call return a
JSON array?" while the consumer needs "does this repo have .githooks?".
Activity
hyperpolymath commentedon Sep 17, 2026
Fixed by #824 (merged
cf7d262d, 2026-09-17 20:00Z).propagate-hooks.ymldiscovery now authenticates withsecrets.HYPATIA_SCAN_PAT— the estate's read-only cross-repo convention, matchinggovernance-reusable.ymlandallowlist-preflight-reusable.yml. The probe distinguishes OK / 404 / ERROR (DISCOVERY_ERRORSis fatal, so a silently-empty discovery can no longer masquerade as "no repos need hooks"), the canary assertshyperpolymath/standardsalways lands inREPOS_WITH_HOOKS, target repo is extracted via jq, and the array is committed as compact single-line JSON ([]for empty — the bash-empty-array trap is documented in the PR).Local sims green for
[], single-repo and multi-repo serializations;bash -n+ PyYAML parse clean. Close-out per protocol — closing completed, board → Done.