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/9] 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/9] 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() { From d941b64274be84e4ca35a588eb1effa01dce3057 Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Sun, 18 Oct 2026 14:21:53 +0000 Subject: [PATCH 3/9] fix(lock): resolve remaining leaf self-SHA refs to tags and declare HYPATIA_SCAN_PAT - .github/workflows/actions.lock: replace self-SHA ref: values on the four remaining leaf action entries with their upstream release tags: * github/codeql-action@b96794f015dfd88f77b49b1c93e0fa7110f94c63 -> ref: 'v4.38.0' * goto-bus-stop/setup-zig@abea47f85e598557f500fa1fd2ab7464fcb39406 -> ref: 'v2.2.1' * swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 -> ref: 'v2.9.2' * webfactory/ssh-agent@e83874834305fe9a4a2997156cb26c5de65a8555 -> ref: 'v0.10.0' - .github/workflows/hypatia-scan-reusable.yml: declare optional workflow_call secret HYPATIA_SCAN_PAT (required: false) to match hypatia-scan.yml (#1283) and prevent startup_failure (0 jobs). - scripts/relock-sha-keys.sh: prefer symbolic (tag/branch) ref: over self-SHA ref: when deduplicating merged SHA-keyed records in Step 4, and avoid gawk-only {40} interval syntax. - scripts/close-lock.sh: resolve upstream release tags when synthesizing leaf action records for 40-hex SHA pins instead of emitting self-SHA ref: values. - scripts/lock-selfcheck.sh, .githooks/validate-actions-lock.sh, tests/test_secret_scanner_no_inherit.sh: reject self-SHA ref: values on leaf action entries and verify HYPATIA_SCAN_PAT declaration. Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- .githooks/validate-actions-lock.sh | 36 ++++++++++++++ .github/workflows/actions.lock | 8 +-- .github/workflows/hypatia-scan-reusable.yml | 4 ++ scripts/close-lock.sh | 8 ++- scripts/lock-selfcheck.sh | 54 +++++++++++++++++---- scripts/relock-sha-keys.sh | 44 ++++++++++++----- scripts/tests/close-lock-test.sh | 9 ++++ scripts/tests/lock-selfcheck-test.sh | 31 ++++++++++++ scripts/tests/relock-sha-keys-test.sh | 3 ++ scripts/tests/validate-actions-lock-test.sh | 20 ++++++++ tests/test_secret_scanner_no_inherit.sh | 31 ++++++++++++ 11 files changed, 223 insertions(+), 25 deletions(-) diff --git a/.githooks/validate-actions-lock.sh b/.githooks/validate-actions-lock.sh index 734dec811..430b79f62 100755 --- a/.githooks/validate-actions-lock.sh +++ b/.githooks/validate-actions-lock.sh @@ -108,6 +108,38 @@ lock_has() { return 1 } +# Leaf action entries in `dependencies:` whose `ref:` equals their own 40-hex +# SHA pin fail GitHub runner lockfile verification during `Set up job` +# ("Unable to resolve action ...: invalid lockfile pin", standards#1271). +mapfile -t SELF_SHA_KEYS < <( + awk ' + /^dependencies:/ { in_dep = 1; next } + /^[a-z_]+:/ { in_dep = 0; cur = ""; next } + !in_dep { next } + /^ \x27[^\x27]+\x27:$/ { + cur = $0; sub(/^ \x27/, "", cur); sub(/\x27:$/, "", cur) + next + } + cur != "" && /^ ref: \x27[^\x27]+\x27$/ { + r = $0; sub(/^ ref: \x27/, "", r); sub(/\x27$/, "", r) + n = index(cur, "@") + pin = (n > 0) ? substr(cur, n + 1) : "" + if (length(r) == 40 && r ~ /^[0-9a-fA-F]+$/ && tolower(r) == tolower(pin)) { + print tolower(cur) + } + cur = "" + } + ' "$LOCKFILE" | sort -u +) + +lock_has_self_sha() { + local needle="$1" k + for k in ${SELF_SHA_KEYS[@]+"${SELF_SHA_KEYS[@]}"}; do + [ "$k" = "$needle" ] && return 0 + done + return 1 +} + ERRORS=0 CHECKED=0 declare -a SEEN_ABSENT=() @@ -189,6 +221,10 @@ for ref in "${RAW[@]}"; do echo -e "${RED}[validate-actions-lock] ERROR: not in actions.lock: ${ref}${NC}" >&2 echo " normalised to: $norm" >&2 ERRORS=$((ERRORS + 1)) + elif lock_has_self_sha "$norm"; then + echo -e "${RED}[validate-actions-lock] ERROR: self-SHA ref in actions.lock dependencies: ${norm}${NC}" >&2 + echo " leaf action entries must carry a tag/branch ref, not their own commit SHA (standards#1271)" >&2 + ERRORS=$((ERRORS + 1)) fi done diff --git a/.github/workflows/actions.lock b/.github/workflows/actions.lock index 24a50d8ee..427f05d0b 100644 --- a/.github/workflows/actions.lock +++ b/.github/workflows/actions.lock @@ -250,12 +250,12 @@ dependencies: owner_id: 47606891 repo_id: 331103973 'github/codeql-action@b96794f015dfd88f77b49b1c93e0fa7110f94c63': - ref: 'b96794f015dfd88f77b49b1c93e0fa7110f94c63' + ref: 'v4.38.0' commit: 'sha1-b96794f015dfd88f77b49b1c93e0fa7110f94c63' owner_id: 9919 repo_id: 259445878 'goto-bus-stop/setup-zig@abea47f85e598557f500fa1fd2ab7464fcb39406': - ref: 'abea47f85e598557f500fa1fd2ab7464fcb39406' + ref: 'v2.2.1' commit: 'sha1-abea47f85e598557f500fa1fd2ab7464fcb39406' owner_id: 1006268 repo_id: 212984112 @@ -304,7 +304,7 @@ dependencies: owner_id: 88700172 repo_id: 422287306 'swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6': - ref: '6323deb102c322ba6fcbdcafc7e3dddab59af2b6' + ref: 'v2.9.2' commit: 'sha1-6323deb102c322ba6fcbdcafc7e3dddab59af2b6' owner_id: 580492 repo_id: 298565987 @@ -314,7 +314,7 @@ dependencies: owner_id: 48932923 repo_id: 360548653 'webfactory/ssh-agent@e83874834305fe9a4a2997156cb26c5de65a8555': - ref: 'e83874834305fe9a4a2997156cb26c5de65a8555' + ref: 'v0.10.0' commit: 'sha1-e83874834305fe9a4a2997156cb26c5de65a8555' owner_id: 135788 repo_id: 208510314 diff --git a/.github/workflows/hypatia-scan-reusable.yml b/.github/workflows/hypatia-scan-reusable.yml index 06cf2db37..d6ebb10ac 100644 --- a/.github/workflows/hypatia-scan-reusable.yml +++ b/.github/workflows/hypatia-scan-reusable.yml @@ -21,6 +21,10 @@ on: type: string required: false default: 51ab6496bf47e30a0576d503df31fec30f3cfe56 + secrets: + HYPATIA_SCAN_PAT: + description: Fine-grained token with Dependabot, code-scanning and secret-scanning read access + required: false permissions: actions: read diff --git a/scripts/close-lock.sh b/scripts/close-lock.sh index cc2f173d6..f2db6dc08 100755 --- a/scripts/close-lock.sh +++ b/scripts/close-lock.sh @@ -178,8 +178,14 @@ for pass in 1 2 3 4 5 6; do if ! printf '%s' "$sha" | grep -qxE '[0-9a-f]{40}'; then echo " SKIP $k (cannot resolve commit)"; skipped=$((skipped + 1)); continue fi + rec_ref="$ref" + if printf '%s' "$ref" | grep -qxE '[0-9a-f]{40}' && ! grep -qxF "$k$TAB" "$SC/owed"; then + tag_ref=$("$GH_BIN" api "repos/$owner/$name/tags?per_page=100" --jq '.[] | select(.commit.sha == "'"$sha"'") | .name' 2>/dev/null \ + | awk '/^[A-Za-z0-9._\/-]+$/ && !(length($0) == 40 && $0 ~ /^[0-9a-f]+$/) { if (!first) first = $0; if ($0 ~ /^v?[0-9]+\.[0-9]+\.[0-9]+/) { print $0; found = 1; exit } } END { if (!found && first) print first }') || tag_ref="" + [ -n "$tag_ref" ] && rec_ref="$tag_ref" + fi { printf " '%s':\n ref: '%s'\n commit: 'sha1-%s'\n owner_id: %s\n repo_id: %s\n" \ - "$k" "$ref" "$sha" "${ids%% *}" "${ids##* }" + "$k" "$rec_ref" "$sha" "${ids%% *}" "${ids##* }" uses_block "$k"; } >> "$SC/new" echo " + $k" done < "$SC/miss" diff --git a/scripts/lock-selfcheck.sh b/scripts/lock-selfcheck.sh index aa8d3562b..b6f9b1a8d 100755 --- a/scripts/lock-selfcheck.sh +++ b/scripts/lock-selfcheck.sh @@ -110,6 +110,7 @@ for SHA in "$@"; do # Parse the lock into "\t" pairs. awk ' /^workflows:/ { in_wf = 1; next } + /^[a-z_]+:/ { in_wf = 0; next } !in_wf { next } # A workflow key line: \x27.github/workflows/foo.yml\x27: /^[[:space:]]+\x27[^\x27]+\x27:/ { @@ -128,6 +129,27 @@ for SHA in "$@"; do } ' "$TMP/lock" | sort -u > "$TMP/locked" + # Parse dependencies: entries whose `ref:` equals their own 40-hex SHA pin + # (self-SHA ref; rejected by the GitHub runner during Set up job, standards#1271). + awk ' + /^dependencies:/ { in_dep = 1; next } + /^[a-z_]+:/ { in_dep = 0; cur = ""; next } + !in_dep { next } + /^ \x27[^\x27]+\x27:$/ { + cur = $0; sub(/^ \x27/, "", cur); sub(/\x27:$/, "", cur) + next + } + cur != "" && /^ ref: \x27[^\x27]+\x27$/ { + r = $0; sub(/^ ref: \x27/, "", r); sub(/\x27$/, "", r) + n = index(cur, "@") + pin = (n > 0) ? substr(cur, n + 1) : "" + if (length(r) == 40 && r ~ /^[0-9a-fA-F]+$/ && tolower(r) == tolower(pin)) { + print tolower(cur) + } + cur = "" + } + ' "$TMP/lock" | sort -u > "$TMP/self_sha_deps" + locked_pairs=$(wc -l < "$TMP/locked") locked_wfs=$(cut -f1 "$TMP/locked" | sort -u | wc -l) @@ -144,6 +166,7 @@ for SHA in "$@"; do tr 'A-Z' 'a-z' < "$TMP/locked" > "$TMP/locked.lc" : > "$TMP/missing" + : > "$TMP/self_sha_used" : > "$TMP/unkeyed_wf" : > "$TMP/scanned" : > "$TMP/unparsed" @@ -189,6 +212,8 @@ for SHA in "$@"; do printf '%s\t%s\n' "$wf" "$key" >> "$TMP/unkeyed_wf" elif ! /usr/bin/grep -qF "$(printf '%s\t%s' "$wf_lc" "$key_lc")" "$TMP/locked.lc"; then printf '%s\t%s\n' "$wf" "$key" >> "$TMP/missing" + elif /usr/bin/grep -qxF "$key_lc" "$TMP/self_sha_deps"; then + printf '%s\t%s\n' "$wf" "$key" >> "$TMP/self_sha_used" fi done done < "$TMP/wfs" @@ -196,6 +221,7 @@ for SHA in "$@"; do n_actions=$(/usr/bin/grep -c 'ACTION$' "$TMP/scanned" 2>/dev/null || echo 0) n_reusable=$(/usr/bin/grep -c 'REUSABLE-CALL$' "$TMP/scanned" 2>/dev/null || echo 0) n_missing=$(sort -u "$TMP/missing" 2>/dev/null | wc -l) + n_self_sha=$(sort -u "$TMP/self_sha_used" 2>/dev/null | wc -l) n_unkeyed=$(sort -u "$TMP/unkeyed_wf" 2>/dev/null | wc -l) n_unkeyed_wf=$(cut -f1 "$TMP/unkeyed_wf" 2>/dev/null | sort -u | wc -l) @@ -211,18 +237,28 @@ for SHA in "$@"; do echo " VERDICT: UNEXAMINED — yq could not parse $(sort -u "$TMP/unparsed" | wc -l) workflow(s); their refs were not checked:" sort -u "$TMP/unparsed" | sed 's/^/ /' overall_rc=1 - elif [ "$n_missing" -eq 0 ]; then + elif [ "$n_missing" -eq 0 ] && [ "$n_self_sha" -eq 0 ]; then echo " VERDICT: SELF-CONSISTENT — every action ref used is keyed in this SHA's own lock." echo " (Reachability at the remote is NOT proven by this check.)" else - echo " VERDICT: POISON — ${n_missing} action ref(s) used but NOT keyed in this SHA's own lock." - echo " Any caller pinned here dies at startup with 0 jobs and no stated reason." - sort -u "$TMP/missing" | while IFS=$'\t' read -r wf key; do - echo " ${wf}" - echo " uses ${key}" - locked_for=$(/usr/bin/grep -F "$(printf '%s\t' "$wf")" "$TMP/locked" | cut -f2 | sort -u | tr '\n' ' ') - echo " lock keys for this workflow: ${locked_for:-}" - done + if [ "$n_missing" -gt 0 ]; then + echo " VERDICT: POISON — ${n_missing} action ref(s) used but NOT keyed in this SHA's own lock." + echo " Any caller pinned here dies at startup with 0 jobs and no stated reason." + sort -u "$TMP/missing" | while IFS=$'\t' read -r wf key; do + echo " ${wf}" + echo " uses ${key}" + locked_for=$(/usr/bin/grep -F "$(printf '%s\t' "$wf")" "$TMP/locked" | cut -f2 | sort -u | tr '\n' ' ') + echo " lock keys for this workflow: ${locked_for:-}" + done + fi + if [ "$n_self_sha" -gt 0 ]; then + echo " VERDICT: POISON — ${n_self_sha} action ref(s) carry a self-SHA ref: in dependencies: (standards#1271)." + echo " GitHub runner verification fails at Set up job ('invalid lockfile pin')." + sort -u "$TMP/self_sha_used" | while IFS=$'\t' read -r wf key; do + echo " ${wf}" + echo " self-SHA ref ${key}" + done + fi overall_rc=1 fi done diff --git a/scripts/relock-sha-keys.sh b/scripts/relock-sha-keys.sh index 28ca4ecd9..35fafb0da 100755 --- a/scripts/relock-sha-keys.sh +++ b/scripts/relock-sha-keys.sh @@ -39,11 +39,12 @@ MERGED="" trap 'rm -f "$INLINE" "$TAGGED" "$MAP" "$TMPLOCK" "$MERGED"' EXIT find "$WF_DIR" -maxdepth 1 -type f \( -name '*.yml' -o -name '*.yaml' \) -print0 \ | xargs -0 -r awk ' - match($0, /uses:[ \t]*[A-Za-z0-9_.-]+\/[A-Za-z0-9_.\/-]+@[0-9a-f]{40}/) { + match($0, /uses:[ \t]*[A-Za-z0-9_.-]+\/[A-Za-z0-9_.\/-]+@[0-9a-f]+/) { s = substr($0, RSTART, RLENGTH) sub(/^uses:[ \t]*/, "", s) n = split(s, parts, "@") path = parts[1]; sha = parts[2] + if (length(sha) != 40) next split(path, seg, "/") print tolower(seg[1] "/" seg[2]) "\t" sha }' | sort -u > "$INLINE" @@ -63,7 +64,7 @@ find "$WF_DIR" -maxdepth 1 -type f \( -name '*.yml' -o -name '*.yaml' \) -print0 sub(/^uses:[ \t]*/, "", s) n = index(s, "@") path = substr(s, 1, n - 1); ref = substr(s, n + 1) - if (ref ~ /^[0-9a-f]{40}$/) next + if (length(ref) == 40 && ref ~ /^[0-9a-f]+$/) next split(path, seg, "/") print tolower(seg[1] "/" seg[2]) "\t" ref }' | sort -u > "$TAGGED" @@ -82,16 +83,16 @@ awk -v inline="$INLINE" -v tagged="$TAGGED" ' n = index(key, "@") action = substr(key, 1, n - 1); ref = substr(key, n + 1) # already SHA-keyed: nothing to do - if (ref ~ /^[0-9a-f]{40}$/) { cur = ""; next } + if (length(ref) == 40 && ref ~ /^[0-9a-f]+$/) { cur = ""; next } # still written as this tag somewhere: keep the tag key (see 1b) if ((tolower(action) SUBSEP ref) in intag) { cur = ""; next } cur = key; curaction = action; next } # its body carries commit: '\''sha1-'\'' - cur != "" && match($0, /commit:[ \t]*'\''sha1-[0-9a-f]{40}'\''/) { + cur != "" && match($0, /commit:[ \t]*'\''sha1-[0-9a-f]+'\''/) { c = substr($0, RSTART, RLENGTH) sub(/.*sha1-/, "", c); sub(/'\''.*/, "", c) - if ((tolower(curaction) SUBSEP c) in seen) print cur "\t" curaction "@" c + if (length(c) == 40 && ((tolower(curaction) SUBSEP c) in seen)) print cur "\t" curaction "@" c cur = "" } ' "$LOCK" > "$MAP" @@ -127,18 +128,39 @@ rm -f "$MAP.count" # already holds a SHA-keyed record for a commit and the tool added a # tag-keyed record for the same commit, step 3 leaves TWO records under one # key, and the lockfile reader rejects the file ("already defined"). Both -# describe the same commit, so the first is kept and the later one dropped -# with its body. A list (a workflow entry, a record's uses:) that now names -# the same ref twice keeps it once. +# describe the same commit, so prefer the record whose `ref:` is symbolic +# (tag/branch) over a self-SHA `ref:` (which fails GitHub runner lockfile +# verification; standards#1271) and drop the duplicate with its body. A list +# (a workflow entry, a record's uses:) that now names the same ref twice +# keeps it once. MERGED="$(mktemp)" awk ' + NR == FNR { + if ($0 ~ /^[a-z_]+:/) { p1_sec = $0; p1_k = ""; next } + if (p1_sec ~ /^dependencies:/ && $0 ~ /^ \047[^\047]+\047:/) { + p1_k = $0; sub(/:.*$/, "", p1_k) + occ[p1_k]++ + p1_i = occ[p1_k] + if (!(p1_k in chosen)) chosen[p1_k] = p1_i + next + } + if (p1_sec ~ /^dependencies:/ && p1_k != "" && $0 ~ /^ ref: \047[^\047]+\047$/) { + r = $0; sub(/^ ref: \047/, "", r); sub(/\047$/, "", r) + is_sym = !(length(r) == 40 && r ~ /^[0-9a-f]+$/) + if (is_sym && !chosen_sym[p1_k]) { + chosen[p1_k] = p1_i + chosen_sym[p1_k] = 1 + } + } + next + } /^[a-z_]+:/ { sec = $0; skip = 0; delete items; print; next } /^ \047[^\047]+\047:/ { delete items if (sec ~ /^dependencies:/) { k = $0; sub(/:.*$/, "", k) - if (k in seen) { skip = 1; merged++; next } - seen[k] = 1 + seen[k]++ + if (seen[k] != chosen[k]) { skip = 1; merged++; next } } skip = 0; print; next } @@ -151,7 +173,7 @@ awk ' } { delete items; print } END { print merged + 0 > "/dev/stderr" } -' "$TMPLOCK" > "$MERGED" 2>"$MAP.merged" +' "$TMPLOCK" "$TMPLOCK" > "$MERGED" 2>"$MAP.merged" merged="$(cat "$MAP.merged" 2>/dev/null || echo 0)" rm -f "$MAP.merged" "$TMPLOCK" mv "$MERGED" "$LOCK" diff --git a/scripts/tests/close-lock-test.sh b/scripts/tests/close-lock-test.sh index 2fd715a9a..7b50d8a1f 100755 --- a/scripts/tests/close-lock-test.sh +++ b/scripts/tests/close-lock-test.sh @@ -39,6 +39,7 @@ case "\$2" in if [ -f "\$f" ]; then cat "\$f"; else echo '{"message":"Not Found","status":"404"}'; exit 1; fi ;; repos/o/missing*) echo '{"message":"Not Found","status":"404"}'; exit 1 ;; repos/o/garbled*) echo '{"messa'; exit 0 ;; + repos/o/leaf/tags*) printf 'v4\nv4.38.0\n' ;; repos/o/*/commits/v1) echo $C ;; repos/o/*/commits/*) echo '{"message":"No commit found"}'; exit 1 ;; repos/o/*) echo '7 9' ;; @@ -71,6 +72,14 @@ run "$d" >/dev/null grep -qxF " ref: 'v1'" "$d/actions.lock" && grep -qxF " commit: 'sha1-$C'" "$d/actions.lock" \ && ok "tag ref recorded with its resolved commit" || bad "tag ref recorded with its resolved commit" +echo "=== resolves a leaf SHA ref to its upstream semver tag (standards#1271) ===" +d=$(mklock t2b "o/leaf@$A" /dev/null +grep -qxF " 'o/leaf@$A':" "$d/actions.lock" && grep -qxF " ref: 'v4.38.0'" "$d/actions.lock" \ + && grep -qxF " commit: 'sha1-$A'" "$d/actions.lock" \ + && ok "leaf SHA ref records upstream semver tag in ref: ('v4.38.0')" \ + || bad "leaf SHA ref records upstream semver tag in ref:" + echo "=== closes a nested edge of an existing record ===" d=$(mklock t3 "o/a@$A" < "$WF/actions.lock" <&1); st=$? && ok "a record's uses: list holds the merged ref once" || bad "a record's uses: list holds the merged ref once" [ "$(grep -c "^ commit: 'sha1-$A'" "$L")" = 1 ] \ && ok "the merged record's body appears once" || bad "the merged record's body appears once" +grep -qxF " ref: 'v7'" "$L" && ! grep -qxF " ref: '$A'" "$L" \ + && ok "merged record keeps the symbolic tag ref ('v7'), not the self-SHA ref" \ + || bad "merged record keeps the symbolic tag ref ('v7'), not the self-SHA ref" grep -qxF " 'actions/cache@$C':" "$L" && ! grep -qF "actions/cache@v4" "$L" \ && ok "plain re-key (no SHA record yet) still happens" || bad "plain re-key (no SHA record yet) still happens" grep -qxF " 'o/tagged@v2':" "$L" && grep -qxF " - 'o/tagged@v2'" "$L" \ diff --git a/scripts/tests/validate-actions-lock-test.sh b/scripts/tests/validate-actions-lock-test.sh index 2f20b6bc2..c62c1971b 100755 --- a/scripts/tests/validate-actions-lock-test.sh +++ b/scripts/tests/validate-actions-lock-test.sh @@ -116,6 +116,26 @@ reset_repo printf '{\n jobs: {\n' > "$WORK/repo/.github/workflows/ci.yml" expect "an unparseable workflow fails closed" 1 "ci.yml" +reset_repo +cat >> "$WORK/repo/.github/workflows/actions.lock" < "$WORK/repo/.github/workflows/ci.yml" < 0) ? substr(cur, 1, n - 1) : cur + pin = (n > 0) ? substr(cur, n + 1) : "" + if (tolower(nwo) != "hyperpolymath/standards" && length(r) == 40 && r ~ /^[0-9a-fA-F]+$/ && tolower(r) == tolower(pin)) { + print cur + } + cur = "" + } +' "$LOCKFILE")" +if [ -n "$self_sha_leaves" ]; then + fail "actions.lock contains leaf action entries with ref equal to their own SHA pin (standards#1271):" + printf '%s\n' "$self_sha_leaves" | sed 's/^/ /' +fi + # --- Verdict --------------------------------------------------------------- if [ "$fails" -gt 0 ]; then echo "::error::no-inherit: $fails check(s) failed" From fd3daa936a8116f7def0f9b6bbf63ab0fdd22797 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Sun, 18 Oct 2026 21:35:01 +0100 Subject: [PATCH 4/9] fix(ci): repin nested pipeline and mirror consumers to repaired workflows Advance the three live Standards calls to the repaired foundation commit. Regenerate their dependency records with relock-standards-pin.sh, preserving full closure and verifying an unchanged second pass. --- .github/workflows/actions.lock | 24 ++++++++---------------- .github/workflows/ci-pipeline.yml | 4 ++-- .github/workflows/mirror.yml | 2 +- 3 files changed, 11 insertions(+), 19 deletions(-) diff --git a/.github/workflows/actions.lock b/.github/workflows/actions.lock index 427f05d0b..de16c0924 100644 --- a/.github/workflows/actions.lock +++ b/.github/workflows/actions.lock @@ -35,7 +35,7 @@ workflows: '.github/workflows/ci-pipeline.yml': - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' - 'haskell-actions/setup@0f8e8c99d88aeb3fbfd523f1ef2c6f762d10d64d' - - 'hyperpolymath/standards@571cc734cd69fb846032ec77a662aa8ee4fc32cd' + - 'hyperpolymath/standards@427eb35c40552d912d8c757518e8326f0d20f597' - 'oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6' '.github/workflows/codeql-reusable.yml': - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' @@ -109,7 +109,7 @@ workflows: - 'dtolnay/rust-toolchain@e2a55d2ffb04f378e9626c28d38b36d230d1e12f' - 'webfactory/ssh-agent@e83874834305fe9a4a2997156cb26c5de65a8555' '.github/workflows/mirror.yml': - - 'hyperpolymath/standards@b935e63937b1f1b5cbaeea7e375947d7b924038f' + - 'hyperpolymath/standards@427eb35c40552d912d8c757518e8326f0d20f597' '.github/workflows/no-js-scan.yml': - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' '.github/workflows/pages-archive.yml': @@ -229,11 +229,6 @@ dependencies: commit: 'sha1-22d081ff2d3a40755e97629de92e3bcbfa7cf2ed' owner_id: 42048915 repo_id: 356423100 - 'dtolnay/rust-toolchain@7e38f4b43b4db5c8dd498af069a4f6196df1d067': - ref: 'v1' - commit: 'sha1-7e38f4b43b4db5c8dd498af069a4f6196df1d067' - owner_id: 1940490 - repo_id: 260749683 'dtolnay/rust-toolchain@e2a55d2ffb04f378e9626c28d38b36d230d1e12f': ref: 'v1' commit: 'sha1-e2a55d2ffb04f378e9626c28d38b36d230d1e12f' @@ -264,19 +259,16 @@ dependencies: commit: 'sha1-0f8e8c99d88aeb3fbfd523f1ef2c6f762d10d64d' owner_id: 75048950 repo_id: 623796603 - 'hyperpolymath/standards@571cc734cd69fb846032ec77a662aa8ee4fc32cd': - ref: '571cc734cd69fb846032ec77a662aa8ee4fc32cd' - commit: 'sha1-571cc734cd69fb846032ec77a662aa8ee4fc32cd' - owner_id: 6759885 - repo_id: 1116521501 - 'hyperpolymath/standards@b935e63937b1f1b5cbaeea7e375947d7b924038f': - ref: 'b935e63937b1f1b5cbaeea7e375947d7b924038f' - commit: 'sha1-b935e63937b1f1b5cbaeea7e375947d7b924038f' + 'hyperpolymath/standards@427eb35c40552d912d8c757518e8326f0d20f597': + ref: '427eb35c40552d912d8c757518e8326f0d20f597' + commit: 'sha1-427eb35c40552d912d8c757518e8326f0d20f597' owner_id: 6759885 repo_id: 1116521501 uses: - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' - - 'dtolnay/rust-toolchain@7e38f4b43b4db5c8dd498af069a4f6196df1d067' + - 'dtolnay/rust-toolchain@e2a55d2ffb04f378e9626c28d38b36d230d1e12f' + - 'goto-bus-stop/setup-zig@abea47f85e598557f500fa1fd2ab7464fcb39406' + - 'swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6' - 'webfactory/ssh-agent@e83874834305fe9a4a2997156cb26c5de65a8555' 'ocaml/setup-ocaml@93303b622b2522e4411e295f9e77411a24912ac7': ref: 'v3.9.0' diff --git a/.github/workflows/ci-pipeline.yml b/.github/workflows/ci-pipeline.yml index 9d2ec1478..6b69f765e 100644 --- a/.github/workflows/ci-pipeline.yml +++ b/.github/workflows/ci-pipeline.yml @@ -319,7 +319,7 @@ jobs: name: Secret scanning # Deliberately NOT gated on `detect`: a repo with no recognised ecosystem # can still be leaking credentials. - uses: hyperpolymath/standards/.github/workflows/secret-scanner-reusable.yml@571cc734cd69fb846032ec77a662aa8ee4fc32cd + uses: hyperpolymath/standards/.github/workflows/secret-scanner-reusable.yml@427eb35c40552d912d8c757518e8326f0d20f597 with: runs-on: ${{ inputs.runs-on }} @@ -330,7 +330,7 @@ jobs: name: Rust needs: detect if: ${{ needs.detect.outputs.has_rust == 'true' }} - uses: hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml@571cc734cd69fb846032ec77a662aa8ee4fc32cd + uses: hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml@427eb35c40552d912d8c757518e8326f0d20f597 with: runs-on: ${{ inputs.runs-on }} enable_coverage: ${{ inputs.enable_coverage }} diff --git a/.github/workflows/mirror.yml b/.github/workflows/mirror.yml index 03ca9e537..9298823f9 100644 --- a/.github/workflows/mirror.yml +++ b/.github/workflows/mirror.yml @@ -14,7 +14,7 @@ permissions: jobs: mirror: - uses: hyperpolymath/standards/.github/workflows/mirror-reusable.yml@b935e63937b1f1b5cbaeea7e375947d7b924038f + uses: hyperpolymath/standards/.github/workflows/mirror-reusable.yml@427eb35c40552d912d8c757518e8326f0d20f597 # Exactly the seven optional secrets the callee declares at the # pinned SHA (verified declared ⊇ referenced). Was `secrets: # inherit`, which forwarded every repo/org secret (standards#1108 From 8458d478a8d22d12aa27654ddb6a029bcefaff07 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Sun, 18 Oct 2026 21:47:34 +0100 Subject: [PATCH 5/9] Update tests/test_secret_scanner_no_inherit.sh Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Signed-off-by: Jonathan D.A. Jewell <6759885+hyperpolymath@users.noreply.github.com> --- tests/test_secret_scanner_no_inherit.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_secret_scanner_no_inherit.sh b/tests/test_secret_scanner_no_inherit.sh index a5e487d2d..7fcd1c3b7 100755 --- a/tests/test_secret_scanner_no_inherit.sh +++ b/tests/test_secret_scanner_no_inherit.sh @@ -271,7 +271,7 @@ checkout_pin="$(grep -vE '^[[:space:]]*#' "$REUSABLE" | grep -oE 'actions/checko # 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")" +lock_section="$(awk "/^ '\\.github\\/workflows\\/secret-scanner-reusable\\.yml':/{f=1;next} /^ '\\.github\\/workflows\\//{f=0} /^dependencies:/{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 From 187af3bc296690a868867d9b386d20ac4382dbad Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Sun, 18 Oct 2026 20:54:42 +0000 Subject: [PATCH 6/9] docs: Clarify action lock and secret check helpers --- .githooks/validate-actions-lock.sh | 2 ++ tests/test_secret_scanner_no_inherit.sh | 5 ++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/.githooks/validate-actions-lock.sh b/.githooks/validate-actions-lock.sh index 430b79f62..d93ec8ef7 100755 --- a/.githooks/validate-actions-lock.sh +++ b/.githooks/validate-actions-lock.sh @@ -132,6 +132,8 @@ mapfile -t SELF_SHA_KEYS < <( ' "$LOCKFILE" | sort -u ) +# Check a normalized owner/repo@sha key against the parsed self-SHA entries. +# Return 0 if its dependency ref equals its own SHA pin, or 1 if absent. lock_has_self_sha() { local needle="$1" k for k in ${SELF_SHA_KEYS[@]+"${SELF_SHA_KEYS[@]}"}; do diff --git a/tests/test_secret_scanner_no_inherit.sh b/tests/test_secret_scanner_no_inherit.sh index 7fcd1c3b7..d39d408d9 100755 --- a/tests/test_secret_scanner_no_inherit.sh +++ b/tests/test_secret_scanner_no_inherit.sh @@ -154,7 +154,10 @@ done # LOCAL reusable, every passed name must be declared in that reusable's # workflow_call.secrets mapping. These workflows use two-space indentation; # inspect only direct secret keys, not inputs or nested metadata. -check_declared() { # caller reusable +# check_declared CALLER REUSABLE: report each named caller secret missing from +# the reusable's workflow_call.secrets via fail, incrementing the failure count. +# Skip the comparison if either file is unreadable. +check_declared() { local caller="$1" reusable="$2" name declared [ -r "$caller" ] && [ -r "$reusable" ] || return 0 declared="$(awk ' From 0ac164096dcff3a4adc7836a88a0dd88320c0273 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Mon, 19 Oct 2026 09:05:22 +0100 Subject: [PATCH 7/9] fix(lock): accept build metadata in symbolic version tags Add a fixture for v1.2.3+build.4. It fails before the fix and passes afterward; all 25 close-lock assertions and the no-inherit regression check pass. --- scripts/close-lock.sh | 2 +- scripts/tests/close-lock-test.sh | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/scripts/close-lock.sh b/scripts/close-lock.sh index d74ff9c28..a3b2babe0 100755 --- a/scripts/close-lock.sh +++ b/scripts/close-lock.sh @@ -185,7 +185,7 @@ for pass in 1 2 3 4 5 6; do tag_ref="" if "$GH_BIN" api "repos/$owner/$name/tags?per_page=100" --paginate \ --jq '.[] | select(.commit.sha == "'"$sha"'") | .name' > "$SC/tags" 2>/dev/null; then - tag_ref=$(awk '/^[A-Za-z0-9._\/-]+$/ && !(length($0) == 40 && $0 ~ /^[0-9a-f]+$/) { if (!first) first = $0; if (!preferred && $0 ~ /^v?[0-9]+\.[0-9]+\.[0-9]+/) preferred = $0 } END { if (preferred) print preferred; else if (first) print first }' "$SC/tags") + tag_ref=$(awk '/^[A-Za-z0-9._+\/-]+$/ && !(length($0) == 40 && $0 ~ /^[0-9a-f]+$/) { if (!first) first = $0; if (!preferred && $0 ~ /^v?[0-9]+\.[0-9]+\.[0-9]+/) preferred = $0 } END { if (preferred) print preferred; else if (first) print first }' "$SC/tags") fi if [ -z "$tag_ref" ]; then echo " SKIP $k (cannot resolve a symbolic tag for leaf SHA)" diff --git a/scripts/tests/close-lock-test.sh b/scripts/tests/close-lock-test.sh index fca38724d..24c8217d2 100755 --- a/scripts/tests/close-lock-test.sh +++ b/scripts/tests/close-lock-test.sh @@ -45,6 +45,7 @@ case "\$2" in repos/o/laterpage/tags*) case " \$* " in *' --paginate '*) echo 'v2.3.4' ;; *) exit 0 ;; esac ;; repos/o/a/tags*|repos/o/b/tags*|repos/o/c/tags*) echo 'v1.0.0' ;; + repos/o/buildmeta/tags*) echo 'v1.2.3+build.4' ;; repos/o/leaf/tags*) printf 'v4\nv4.38.0\n' ;; repos/o/*/commits/v1) echo $C ;; repos/o/*/commits/*) echo '{"message":"No commit found"}'; exit 1 ;; @@ -86,6 +87,12 @@ grep -qxF " 'o/leaf@$A':" "$d/actions.lock" && grep -qxF " ref: 'v4.38 && ok "leaf SHA ref records upstream semver tag in ref: ('v4.38.0')" \ || bad "leaf SHA ref records upstream semver tag in ref:" +echo "=== accepts a version tag with build metadata ===" +d=$(mklock buildmeta "o/buildmeta@$A" Date: Mon, 19 Oct 2026 09:08:00 +0100 Subject: [PATCH 8/9] fix(lock): repair copied self-SHA leaf records when repinning consumers Refresh poisoned ref fields from the target Standards lock only when commit and repository identities match. Refuse mismatches without changing the consumer lock. Regression test reproduces the stale-copy defect before the fix. --- scripts/relock-standards-pin.sh | 25 +++++++++++++++++++++- scripts/tests/relock-standards-pin-test.sh | 16 ++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/scripts/relock-standards-pin.sh b/scripts/relock-standards-pin.sh index e8f1d6156..ae89fa526 100755 --- a/scripts/relock-standards-pin.sh +++ b/scripts/relock-standards-pin.sh @@ -130,7 +130,30 @@ awk -v k=" ${q}hyperpolymath/standards@$TARGET${q}:" '$0==k{s=1;next} s&&/^ git -C "$SROOT" show "${TARGET}:.github/workflows/actions.lock" > "$tmp/slock" 2>/dev/null || : > "$tmp/slock" while read -r u; do [ -n "$u" ] || continue - grep -qF " '$u':" "$tmp/lock" && continue + existing=$(rec_body "$u" "$tmp/lock") + if [ -n "$existing" ]; then + pin=${u##*@} + if [[ "$pin" =~ ^[0-9a-f]{40}$ ]] && printf '%s\n' "$existing" | grep -qxF " ref: '$pin'"; then + # A consumer can retain a poisoned copy after Standards repairs its own + # record. Refresh only ref:, and only with identical commit and IDs. + repaired=$(rec_body "$u" "$tmp/slock") + symbolic=$(printf '%s\n' "$repaired" | sed -n "s/^ ref: '\([^']*\)'$/\1/p") + [ -n "$symbolic" ] && [[ ! "$symbolic" =~ ^[0-9a-fA-F]{40}$ ]] \ + || refuse "no symbolic repair for $u in target Standards lock" + local_identity=$(printf '%s\n' "$existing" | grep -E '^ (commit|owner_id|repo_id):') + source_identity=$(printf '%s\n' "$repaired" | grep -E '^ (commit|owner_id|repo_id):') + [ "$local_identity" = "$source_identity" ] && [ "$(printf '%s\n' "$source_identity" | wc -l)" -eq 3 ] \ + || refuse "identity mismatch while repairing $u" + awk -v k=" '$u':" -v replacement=" ref: '$symbolic'" ' + $0 == k { selected = 1; print; next } + /^ [^ ]/ { selected = 0 } + selected && /^ ref:/ { print replacement; next } + { print } + ' "$tmp/lock" > "$tmp/repaired.lock" + mv "$tmp/repaired.lock" "$tmp/lock" + fi + continue + fi r=$(rec_body "$u" "$tmp/slock") [ -n "$r" ] || refuse "no record for $u in this lock or in standards' own lock at ${TARGET:0:12}" printf '%s\n' "$r" >> "$tmp/lock" diff --git a/scripts/tests/relock-standards-pin-test.sh b/scripts/tests/relock-standards-pin-test.sh index 5b6412e3e..db3e1cb1b 100755 --- a/scripts/tests/relock-standards-pin-test.sh +++ b/scripts/tests/relock-standards-pin-test.sh @@ -131,6 +131,22 @@ if command -v gawk >/dev/null 2>&1; then fi echo " PASS known answer (re-key, record rebuilt, needed record copied, OLD-only records pruned transitively, unrelated orphan kept)" +# Copied self-SHA leaf refs must not survive a repin to repaired Standards. +D=$(consumer stale-leaf) +sed -i "s/ref: 'v1'/ref: '1111111111111111111111111111111111111111'/" "$D/actions.lock" +bash "$RELOCK" --to "$NEW" --standards "$S" "$D" >/dev/null +grep -q "ref: '1111111111111111111111111111111111111111'" "$D/actions.lock" && fail "copied self-SHA leaf survived repin" +grep -q "ref: 'v1'" "$D/actions.lock" || fail "corrected symbolic ref missing" +echo " PASS copied self-SHA leaf refreshed from defining lock" + +D=$(consumer stale-identity) +sed -i "s/ref: 'v1'/ref: '1111111111111111111111111111111111111111'/; s/repo_id: 11$/repo_id: 999/" "$D/actions.lock" +cp "$D/actions.lock" "$T/identity-before" +if bash "$RELOCK" --to "$NEW" --standards "$S" "$D" >"$T/identity-log" 2>&1; then fail "identity mismatch accepted"; fi +cmp -s "$D/actions.lock" "$T/identity-before" || fail "refusal changed consumer lock" +grep -q 'identity mismatch' "$T/identity-log" || fail "wrong refusal reason" +echo " PASS identity mismatch refused without changing lock" + # 2. Idempotent. D=$(consumer idem); bash "$RELOCK" --to "$NEW" --standards "$S" "$D" >/dev/null cp "$D/actions.lock" "$T/once"; bash "$RELOCK" --to "$NEW" --standards "$S" "$D" >/dev/null From 44aae87b78367af087300f34685ccc64b7fa7b1c Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Mon, 19 Oct 2026 09:23:05 +0100 Subject: [PATCH 9/9] Update scripts/relock-standards-pin.sh Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Signed-off-by: Jonathan D.A. Jewell <6759885+hyperpolymath@users.noreply.github.com> --- scripts/relock-standards-pin.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/relock-standards-pin.sh b/scripts/relock-standards-pin.sh index ae89fa526..912e5a5e4 100755 --- a/scripts/relock-standards-pin.sh +++ b/scripts/relock-standards-pin.sh @@ -140,8 +140,8 @@ while read -r u; do symbolic=$(printf '%s\n' "$repaired" | sed -n "s/^ ref: '\([^']*\)'$/\1/p") [ -n "$symbolic" ] && [[ ! "$symbolic" =~ ^[0-9a-fA-F]{40}$ ]] \ || refuse "no symbolic repair for $u in target Standards lock" - local_identity=$(printf '%s\n' "$existing" | grep -E '^ (commit|owner_id|repo_id):') - source_identity=$(printf '%s\n' "$repaired" | grep -E '^ (commit|owner_id|repo_id):') + local_identity=$(printf '%s\n' "$existing" | grep -E '^ (commit|owner_id|repo_id):' || true) + source_identity=$(printf '%s\n' "$repaired" | grep -E '^ (commit|owner_id|repo_id):' || true) [ "$local_identity" = "$source_identity" ] && [ "$(printf '%s\n' "$source_identity" | wc -l)" -eq 3 ] \ || refuse "identity mismatch while repairing $u" awk -v k=" '$u':" -v replacement=" ref: '$symbolic'" '