From 3c3d4fccba7123bd327121cb01745c90de53d96b Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Sun, 18 Oct 2026 13:46:39 +0000 Subject: [PATCH 1/2] fix(ci): declare HYPATIA_SCAN_PAT (un-breaks hypatia-scan startup) + Codex #1283 follow-ups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit URGENT — main is startup-failing: #1283 narrowed hypatia-scan.yml to pass HYPATIA_SCAN_PAT explicitly, but the matching workflow_call declaration in hypatia-scan-reusable.yml did not land in that commit (several file edits silently failed to apply; the merge then carried the partial tree). Passing an undeclared secret is a validation error, so every hypatia-scan run on e16a2317 is `startup_failure`. This adds the declaration (required: false; built-in-token fallback unchanged). Also completes the parts of #1283 that did not land, and addresses all six Codex review findings on that PR: - secret-scanner-reusable.yml + mirror-reusable.yml: caller EXAMPLES no longer show `secrets: inherit` (examples are what generators copy); Radicle comment corrected. - Packaged scorecard templates (3): grant the callee's declared calling-job set (actions: read, id-token: write added) — at the re-pinned @8ff9ce65 the old grant would startup-fail. - Packaged mirror templates (2): narrowed to the 7 named secrets (verified declared ⊇ referenced at their @210f14e7 pin); their test exemptions deleted. The 2 packaged hypatia exemptions stay: their pin predates the declaration added here. - CICD-WORKFLOW-CATALOG.md: governance example now includes the callee's declared actions: read. - tests/test_secret_scanner_no_inherit.sh: asserts (a) caller-example comment blocks contain no inherit, (b) a narrowed caller passes only secrets its callee declares (the exact guard that would have caught the hypatia break pre-merge), (c) the checkout pin is keyed in the scanner's OWN actions.lock section, not merely anywhere in the file. Mutation-checked: removing the declaration or re-adding inherit to the example both fail the test. Ref: #1108 §6b, #1283 Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- .github/workflows/hypatia-scan-reusable.yml | 9 ++++ .github/workflows/mirror-reusable.yml | 11 ++++- .github/workflows/secret-scanner-reusable.yml | 3 +- .../a2ml/meta/.github/workflows/mirror.yml | 12 ++++- .../.github/workflows/scorecard-enforcer.yml | 5 ++ .../a2ml/meta/.github/workflows/scorecard.yml | 5 ++ .../.github/workflows/mirror.yml | 12 ++++- .../.github/workflows/scorecard.yml | 5 ++ CICD-WORKFLOW-CATALOG.md | 5 ++ tests/test_secret_scanner_no_inherit.sh | 48 +++++++++++++++++-- 10 files changed, 106 insertions(+), 9 deletions(-) diff --git a/.github/workflows/hypatia-scan-reusable.yml b/.github/workflows/hypatia-scan-reusable.yml index 06cf2db37..0f1c5ba55 100644 --- a/.github/workflows/hypatia-scan-reusable.yml +++ b/.github/workflows/hypatia-scan-reusable.yml @@ -21,6 +21,15 @@ on: type: string required: false default: 51ab6496bf47e30a0576d503df31fec30f3cfe56 + secrets: + HYPATIA_SCAN_PAT: + description: >- + Fine-grained read-only PAT used to read Dependabot, code-scanning + and secret-scanning alerts; the scan falls back to the built-in + GITHUB_TOKEN when it is not supplied. Declared explicitly so + callers pass ONLY this secret instead of `secrets: inherit`, + which forwarded every caller secret (standards#1108 §6b). + required: false permissions: actions: read diff --git a/.github/workflows/mirror-reusable.yml b/.github/workflows/mirror-reusable.yml index 7d171db00..3314e28ee 100644 --- a/.github/workflows/mirror-reusable.yml +++ b/.github/workflows/mirror-reusable.yml @@ -34,7 +34,14 @@ # jobs: # mirror: # uses: hyperpolymath/standards/.github/workflows/mirror-reusable.yml@ -# secrets: inherit +# secrets: +# GITLAB_SSH_KEY: ${{ secrets.GITLAB_SSH_KEY }} +# BITBUCKET_SSH_KEY: ${{ secrets.BITBUCKET_SSH_KEY }} +# CODEBERG_SSH_KEY: ${{ secrets.CODEBERG_SSH_KEY }} +# SOURCEHUT_SSH_KEY: ${{ secrets.SOURCEHUT_SSH_KEY }} +# DISROOT_SSH_KEY: ${{ secrets.DISROOT_SSH_KEY }} +# GITEA_SSH_KEY: ${{ secrets.GITEA_SSH_KEY }} +# RADICLE_KEY: ${{ secrets.RADICLE_KEY }} name: Mirror to Git Forges (reusable) @@ -399,7 +406,7 @@ jobs: with: fetch-depth: 0 # All Radicle steps gate on secrets.RADICLE_KEY being set on the - # caller repo (resolved via `secrets: inherit`). Without this gate + # caller repo (passed explicitly under that declared name). Without this gate # the workflow burned ~3 minutes of Rust+Radicle install on every # push to every RADICLE_MIRROR_ENABLED repo only to fail at # `~/.radicle/keys/radicle: No such file or directory` because the diff --git a/.github/workflows/secret-scanner-reusable.yml b/.github/workflows/secret-scanner-reusable.yml index dcb8a5bca..2eee815b0 100644 --- a/.github/workflows/secret-scanner-reusable.yml +++ b/.github/workflows/secret-scanner-reusable.yml @@ -91,7 +91,8 @@ # permissions: # contents: read # uses: hyperpolymath/standards/.github/workflows/secret-scanner-reusable.yml@ -# secrets: inherit +# # Deliberately NO `secrets:` line — the callee declares none and +# # needs none (see the Design note above). name: Secret Scanner (reusable) diff --git a/1-formats/a2ml/meta/.github/workflows/mirror.yml b/1-formats/a2ml/meta/.github/workflows/mirror.yml index d5201b962..b502c1cb6 100644 --- a/1-formats/a2ml/meta/.github/workflows/mirror.yml +++ b/1-formats/a2ml/meta/.github/workflows/mirror.yml @@ -15,4 +15,14 @@ jobs: actions: read contents: read uses: hyperpolymath/standards/.github/workflows/mirror-reusable.yml@210f14e753c80064ec1bcae72f1d654dd9b0e687 - secrets: inherit + # Exactly the seven optional secrets the callee declares at this pin + # (verified declared ⊇ referenced) — not `secrets: inherit` + # (standards#1108 §6b). + secrets: + GITLAB_SSH_KEY: ${{ secrets.GITLAB_SSH_KEY }} + BITBUCKET_SSH_KEY: ${{ secrets.BITBUCKET_SSH_KEY }} + CODEBERG_SSH_KEY: ${{ secrets.CODEBERG_SSH_KEY }} + SOURCEHUT_SSH_KEY: ${{ secrets.SOURCEHUT_SSH_KEY }} + DISROOT_SSH_KEY: ${{ secrets.DISROOT_SSH_KEY }} + GITEA_SSH_KEY: ${{ secrets.GITEA_SSH_KEY }} + RADICLE_KEY: ${{ secrets.RADICLE_KEY }} diff --git a/1-formats/a2ml/meta/.github/workflows/scorecard-enforcer.yml b/1-formats/a2ml/meta/.github/workflows/scorecard-enforcer.yml index 52887c357..cfab025b8 100644 --- a/1-formats/a2ml/meta/.github/workflows/scorecard-enforcer.yml +++ b/1-formats/a2ml/meta/.github/workflows/scorecard-enforcer.yml @@ -14,8 +14,13 @@ permissions: jobs: scorecard: + # Exactly the callee's declared calling-job contract (scorecard- + # reusable header): a smaller job-level grant startup-fails, because + # job-level permissions replace, never merge with, the workflow grant. permissions: + actions: read contents: read security-events: write + id-token: write uses: hyperpolymath/standards/.github/workflows/scorecard-reusable.yml@8ff9ce65f7e54f8d5c5aa94584e2ead0f2ba460f # No `secrets:` line: the reusable declares none (standards#1108 §6b). diff --git a/1-formats/a2ml/meta/.github/workflows/scorecard.yml b/1-formats/a2ml/meta/.github/workflows/scorecard.yml index d614a336f..22d2c8059 100644 --- a/1-formats/a2ml/meta/.github/workflows/scorecard.yml +++ b/1-formats/a2ml/meta/.github/workflows/scorecard.yml @@ -12,8 +12,13 @@ permissions: read-all jobs: analysis: + # Exactly the callee's declared calling-job contract (scorecard- + # reusable header): a smaller job-level grant startup-fails, because + # job-level permissions replace, never merge with, the workflow grant. permissions: + actions: read contents: read security-events: write + id-token: write uses: hyperpolymath/standards/.github/workflows/scorecard-reusable.yml@8ff9ce65f7e54f8d5c5aa94584e2ead0f2ba460f # No `secrets:` line: the reusable declares none (standards#1108 §6b). diff --git a/2-protocols/0-ai-gatekeeper/.github/workflows/mirror.yml b/2-protocols/0-ai-gatekeeper/.github/workflows/mirror.yml index d5201b962..b502c1cb6 100644 --- a/2-protocols/0-ai-gatekeeper/.github/workflows/mirror.yml +++ b/2-protocols/0-ai-gatekeeper/.github/workflows/mirror.yml @@ -15,4 +15,14 @@ jobs: actions: read contents: read uses: hyperpolymath/standards/.github/workflows/mirror-reusable.yml@210f14e753c80064ec1bcae72f1d654dd9b0e687 - secrets: inherit + # Exactly the seven optional secrets the callee declares at this pin + # (verified declared ⊇ referenced) — not `secrets: inherit` + # (standards#1108 §6b). + secrets: + GITLAB_SSH_KEY: ${{ secrets.GITLAB_SSH_KEY }} + BITBUCKET_SSH_KEY: ${{ secrets.BITBUCKET_SSH_KEY }} + CODEBERG_SSH_KEY: ${{ secrets.CODEBERG_SSH_KEY }} + SOURCEHUT_SSH_KEY: ${{ secrets.SOURCEHUT_SSH_KEY }} + DISROOT_SSH_KEY: ${{ secrets.DISROOT_SSH_KEY }} + GITEA_SSH_KEY: ${{ secrets.GITEA_SSH_KEY }} + RADICLE_KEY: ${{ secrets.RADICLE_KEY }} diff --git a/2-protocols/0-ai-gatekeeper/.github/workflows/scorecard.yml b/2-protocols/0-ai-gatekeeper/.github/workflows/scorecard.yml index ac1483460..7bab5966f 100644 --- a/2-protocols/0-ai-gatekeeper/.github/workflows/scorecard.yml +++ b/2-protocols/0-ai-gatekeeper/.github/workflows/scorecard.yml @@ -20,8 +20,13 @@ permissions: jobs: scorecard: + # Exactly the callee's declared calling-job contract (scorecard- + # reusable header): a smaller job-level grant startup-fails, because + # job-level permissions replace, never merge with, the workflow grant. permissions: + actions: read contents: read security-events: write + id-token: write uses: hyperpolymath/standards/.github/workflows/scorecard-reusable.yml@8ff9ce65f7e54f8d5c5aa94584e2ead0f2ba460f # No `secrets:` line: the reusable declares none (standards#1108 §6b). diff --git a/CICD-WORKFLOW-CATALOG.md b/CICD-WORKFLOW-CATALOG.md index f886b1150..c5d88db0e 100644 --- a/CICD-WORKFLOW-CATALOG.md +++ b/CICD-WORKFLOW-CATALOG.md @@ -333,7 +333,12 @@ These should **call** the reusable workflow instead of copying: ```yaml jobs: governance: + # Grant exactly what the callee declares (governance-reusable asks for + # actions: read + contents: read). A called workflow can never exceed + # its caller's grant, and a job-level block REPLACES the workflow-level + # one — under-granting here rejects the run at startup. permissions: + actions: read contents: read uses: hyperpolymath/standards/.github/workflows/governance-reusable.yml@ # Add a `secrets:` block ONLY when the callee declares named secrets diff --git a/tests/test_secret_scanner_no_inherit.sh b/tests/test_secret_scanner_no_inherit.sh index f10aa7e39..a0ce3c1fc 100755 --- a/tests/test_secret_scanner_no_inherit.sh +++ b/tests/test_secret_scanner_no_inherit.sh @@ -77,9 +77,7 @@ DEAD_PIN="a3308ea8d0cc513727c8ad149b1132317a886c98" # startup-fail (passing an undeclared secret is a validation error). # Each line: pathreason. Shrinks as the templates are re-pinned. SUPPORTED_INHERIT=( - "1-formats/a2ml/meta/.github/workflows/mirror.yml callee declares 7 SSH secrets; explicit form lands with the template re-pin (standards#943)" "1-formats/a2ml/meta/.github/workflows/hypatia-scan.yml callee predates the declared HYPATIA_SCAN_PAT contract at its pin (standards#1108 §6b)" - "2-protocols/0-ai-gatekeeper/.github/workflows/mirror.yml callee declares 7 SSH secrets; explicit form lands with the template re-pin (standards#943)" "2-protocols/0-ai-gatekeeper/.github/workflows/hypatia-scan.yml callee predates the declared HYPATIA_SCAN_PAT contract at its pin (standards#1108 §6b)" ) @@ -130,6 +128,42 @@ if grep -vE '^[[:space:]]*#' "$CALLER" | grep -nE '^[[:space:]]+secrets:' >/dev/ grep -vE '^[[:space:]]*#' "$CALLER" | grep -nE '^[[:space:]]+secrets:' | sed 's/^/ /' fi +# --- 2b. Documented caller EXAMPLES must not teach inherit ------------- +# The directive scan below reads non-comment lines (a directive is live +# YAML), but the original §6b defect propagated precisely through the +# commented examples generators copy. So the example blocks in the two +# reusables that document a caller are asserted separately: extract from +# the "Caller example" marker to the first non-comment line and forbid +# the inherit string there (in any role — an example has no business +# containing it at all). +for ex_file in "$REUSABLE" "$WF_DIR/mirror-reusable.yml"; do + [ -r "$ex_file" ] || { fail "example-bearing reusable unreadable: $ex_file"; continue; } + example="$(awk '/Caller example/{f=1} f && /^[^#]/{exit} f{print}' "$ex_file")" + [ -n "$example" ] || { fail "no caller example found in $ex_file — extraction is broken"; continue; } + if printf '%s +' "$example" | grep -q 'secrets:[[:space:]]*inherit'; then + fail "caller example in $(basename "$ex_file") still teaches \`secrets: inherit\` — examples are what generators copy" + fi +done + +# --- 2c. Narrowed callers may pass only secrets the callee declares ---- +# Passing a secret the callee does not declare is a startup validation +# error (this is not theoretical: it startup-failed hypatia-scan on main +# when the caller was narrowed one commit before the declaration +# landed). For each standards wrapper that passes named secrets to a +# LOCAL reusable, every passed name must be declared in that reusable's +# workflow_call block. +check_declared() { # caller reusable secret-name + local caller="$1" reusable="$2" name="$3" + [ -r "$caller" ] && [ -r "$reusable" ] || return 0 + grep -vE '^[[:space:]]*#' "$caller" | grep -qE "^[[:space:]]+${name}:" || return 0 + if ! awk '/workflow_call:/{f=1} f && /^permissions:/{exit} f{print}' "$reusable" | grep -qE "^[[:space:]]+${name}:"; then + fail "$(basename "$caller") passes secret $name but $(basename "$reusable") does not declare it in workflow_call.secrets — every run startup-fails" + fi +} +check_declared "$WF_DIR/hypatia-scan.yml" "$WF_DIR/hypatia-scan-reusable.yml" "HYPATIA_SCAN_PAT" +check_declared "$WF_DIR/governance.yml" "$WF_DIR/governance-reusable.yml" "HYPATIA_SCAN_PAT" + # --- 3. No live `secrets: inherit` directive outside the supported list - supported_path() { local p="$1" entry @@ -213,10 +247,16 @@ note "checked $wf_count workflow file(s) against actions.lock sections" checkout_pin="$(grep -vE '^[[:space:]]*#' "$REUSABLE" | grep -oE 'actions/checkout@[0-9a-f]{40}' | sort -u)" [ -n "$checkout_pin" ] || fail "could not find the reusable's actions/checkout pin — extraction is broken" +# Look the pin up inside the scanner reusable's OWN lock section, not the +# whole file: checkout is keyed under nearly every workflow, so a global +# search would pass even if the scanner's section lost its record. +lock_section="$(awk "/^ '\\.github\\/workflows\\/secret-scanner-reusable\\.yml':/{f=1;next} /^ '\\.github\\/workflows\\//{f=0} f{print}" "$LOCKFILE")" +[ -n "$lock_section" ] || fail "actions.lock has no section for secret-scanner-reusable.yml — regenerate with \`gh actions-lock\`" while IFS= read -r pin; do [ -n "$pin" ] || continue - grep -qF "'$pin'" "$LOCKFILE" \ - || fail "scanner reusable uses $pin but actions.lock does not key it — callers pinned to this commit will fail lock validation at startup" + printf '%s +' "$lock_section" | grep -qF "'$pin'" \ + || fail "scanner reusable uses $pin but its actions.lock section does not key it — callers pinned to this commit will fail lock validation at startup" done <<< "$checkout_pin" # --- Verdict --------------------------------------------------------------- From 4255d18f083ff43cafb838acc84b2be67a721bcc Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Sun, 18 Oct 2026 14:13:16 +0000 Subject: [PATCH 2/2] test(ci): Validate all passed secrets against workflow_call.secrets --- tests/test_secret_scanner_no_inherit.sh | 39 +++++++++++++++++++------ 1 file changed, 30 insertions(+), 9 deletions(-) diff --git a/tests/test_secret_scanner_no_inherit.sh b/tests/test_secret_scanner_no_inherit.sh index a0ce3c1fc..c87e58b41 100755 --- a/tests/test_secret_scanner_no_inherit.sh +++ b/tests/test_secret_scanner_no_inherit.sh @@ -152,17 +152,38 @@ done # when the caller was narrowed one commit before the declaration # landed). For each standards wrapper that passes named secrets to a # LOCAL reusable, every passed name must be declared in that reusable's -# workflow_call block. -check_declared() { # caller reusable secret-name - local caller="$1" reusable="$2" name="$3" +# workflow_call.secrets mapping. These workflows use two-space indentation; +# inspect only direct secret keys, not inputs or nested metadata. +check_declared() { # caller reusable + local caller="$1" reusable="$2" name declared [ -r "$caller" ] && [ -r "$reusable" ] || return 0 - grep -vE '^[[:space:]]*#' "$caller" | grep -qE "^[[:space:]]+${name}:" || return 0 - if ! awk '/workflow_call:/{f=1} f && /^permissions:/{exit} f{print}' "$reusable" | grep -qE "^[[:space:]]+${name}:"; then - fail "$(basename "$caller") passes secret $name but $(basename "$reusable") does not declare it in workflow_call.secrets — every run startup-fails" - fi + declared="$(awk ' + /^[[:space:]]*(#|$)/ { next } + /^ workflow_call:/ { call=1; next } + call && /^ {0,2}[^ ]/ { exit } + call && /^ secrets:[[:space:]]*(#.*)?$/ { secrets=1; next } + secrets && /^ [^ ]/ { exit } + secrets && /^ [A-Za-z_][A-Za-z0-9_]*:/ { + sub(/^ /, ""); sub(/:.*/, ""); print + } + ' "$reusable")" + while IFS= read -r name; do + if ! grep -qxF -- "$name" <<< "$declared"; then + fail "$(basename "$caller") passes secret $name but $(basename "$reusable") does not declare it in workflow_call.secrets — every run startup-fails" + fi + done < <(awk ' + /^[[:space:]]*(#|$)/ { next } + /^jobs:/ { jobs=1; next } + jobs && /^[^ ]/ { exit } + jobs && /^ secrets:[[:space:]]*(#.*)?$/ { secrets=1; next } + secrets && /^ {0,4}[^ ]/ { secrets=0 } + secrets && /^ [A-Za-z_][A-Za-z0-9_]*:/ { + sub(/^ /, ""); sub(/:.*/, ""); print + } + ' "$caller") } -check_declared "$WF_DIR/hypatia-scan.yml" "$WF_DIR/hypatia-scan-reusable.yml" "HYPATIA_SCAN_PAT" -check_declared "$WF_DIR/governance.yml" "$WF_DIR/governance-reusable.yml" "HYPATIA_SCAN_PAT" +check_declared "$WF_DIR/hypatia-scan.yml" "$WF_DIR/hypatia-scan-reusable.yml" +check_declared "$WF_DIR/governance.yml" "$WF_DIR/governance-reusable.yml" # --- 3. No live `secrets: inherit` directive outside the supported list - supported_path() {