From add3343a1657da9a4d757a87b39ab93883263dae Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 3 Jun 2026 22:34:53 +0000 Subject: [PATCH 1/5] fix(ci): repair boj-build.yml startup_failure (missed by #360) #360 added timeout-minutes estate-wide and fixed changelog-reusable.yml, but could not touch boj-build.yml because it does not parse: two trailing steps (K9-SVC Validation, Contractile Check) had dropped to 2-space indentation, out of the 'steps:' list, so GitHub fails to compile it on every push (startup_failure, 0 jobs). Re-indent those steps to 6 spaces and add the job-level timeout-minutes the estate convention expects. The job stays gated on 'if: vars.BOJ_SERVER_URL != ...', so it skips cleanly when unconfigured. Validated with PyYAML. Addresses the repair side of standards#331. No SPDX header or licence content touched. https://claude.ai/code/session_011xv3VLrqeXkpjXxUojKz82 --- .github/workflows/boj-build.yml | 57 +++++++++++++++++---------------- 1 file changed, 29 insertions(+), 28 deletions(-) diff --git a/.github/workflows/boj-build.yml b/.github/workflows/boj-build.yml index ad2b8376..4e8a7c45 100644 --- a/.github/workflows/boj-build.yml +++ b/.github/workflows/boj-build.yml @@ -18,6 +18,7 @@ permissions: jobs: trigger-boj: runs-on: ubuntu-latest + timeout-minutes: 10 if: ${{ vars.BOJ_SERVER_URL != '' || secrets.BOJ_SERVER_URL != '' }} steps: - name: Checkout @@ -47,31 +48,31 @@ jobs: --data "$payload" \ || echo "BoJ server unreachable - skipping (non-fatal)" - - name: K9-SVC Validation - run: | - echo "Running K9-SVC contractile validation..." - if [ -f .machine_readable/contractiles/must/Mustfile.a2ml ]; then - echo "✅ Mustfile found - running validation" - # Placeholder for actual K9 validation - echo "K9 validation would run here" - else - echo "❌ Mustfile not found" - exit 1 - fi - - - name: Contractile Check - run: | - echo "Checking contractile completeness..." - contractiles=("must" "trust" "dust" "lust" "adjust" "intend") - missing=0 - for c in "${contractiles[@]}"; do - if [ ! -f ".machine_readable/contractiles/$c/${c^}file.a2ml" ]; then - echo "❌ Missing: $c" - missing=$((missing + 1)) - fi - done - if [ $missing -gt 0 ]; then - echo "❌ $missing contractiles missing" - exit 1 - fi - echo "✅ All contractiles present" + - name: K9-SVC Validation + run: | + echo "Running K9-SVC contractile validation..." + if [ -f .machine_readable/contractiles/must/Mustfile.a2ml ]; then + echo "✅ Mustfile found - running validation" + # Placeholder for actual K9 validation + echo "K9 validation would run here" + else + echo "❌ Mustfile not found" + exit 1 + fi + + - name: Contractile Check + run: | + echo "Checking contractile completeness..." + contractiles=("must" "trust" "dust" "lust" "adjust" "intend") + missing=0 + for c in "${contractiles[@]}"; do + if [ ! -f ".machine_readable/contractiles/$c/${c^}file.a2ml" ]; then + echo "❌ Missing: $c" + missing=$((missing + 1)) + fi + done + if [ $missing -gt 0 ]; then + echo "❌ $missing contractiles missing" + exit 1 + fi + echo "✅ All contractiles present" From 68d88344e8ae4cccf925d3e119037bf05ac8617f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 3 Jun 2026 22:42:52 +0000 Subject: [PATCH 2/5] fix(ci): stop using the secrets context in if: conditions (startup_failure) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GitHub does not expose the `secrets` context to `if:` expressions (only vars/env/inputs/github/needs). Any workflow with `if: ${{ secrets.* }}` fails to compile and startup_failures on every push — which is why these ran red on main with 0 jobs: - boj-build.yml — job `if: ... secrets.BOJ_SERVER_URL ...` → gate on the `vars` form only (the step still reads secret-or-var for the URL value). Job now skips cleanly when BOJ is unconfigured. Completes the boj-build repair (#331). - instant-sync.yml — 2 step `if: secrets.FARM_DISPATCH_TOKEN ...` → map the token to a job-level `env` and gate on `env.*`. - mirror-reusable.yml — 4 step `if: secrets.RADICLE_KEY ...` in mirror-radicle → same env-mapping. This also fixes the `mirror.yml` caller, which startup_failed because its callee did. env IS available in if:, so the skip-clean behaviour the comments describe now actually works. Validated with PyYAML; no remaining `secrets.` in any if:. No SPDX header or licence content touched. https://claude.ai/code/session_011xv3VLrqeXkpjXxUojKz82 --- .github/workflows/boj-build.yml | 6 +++++- .github/workflows/instant-sync.yml | 10 ++++++++-- .github/workflows/mirror-reusable.yml | 14 ++++++++++---- 3 files changed, 23 insertions(+), 7 deletions(-) diff --git a/.github/workflows/boj-build.yml b/.github/workflows/boj-build.yml index 4e8a7c45..af608eb9 100644 --- a/.github/workflows/boj-build.yml +++ b/.github/workflows/boj-build.yml @@ -19,7 +19,11 @@ jobs: trigger-boj: runs-on: ubuntu-latest timeout-minutes: 10 - if: ${{ vars.BOJ_SERVER_URL != '' || secrets.BOJ_SERVER_URL != '' }} + # NB: the `secrets` context is NOT available in `if:` (only vars/env/ + # inputs/github/needs) — referencing it here startup-fails the whole + # workflow. Gate on the variable form only; the step below still reads + # the secret-or-var for the actual URL value. + if: ${{ vars.BOJ_SERVER_URL != '' }} steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 diff --git a/.github/workflows/instant-sync.yml b/.github/workflows/instant-sync.yml index a61cb82d..d4822b61 100644 --- a/.github/workflows/instant-sync.yml +++ b/.github/workflows/instant-sync.yml @@ -15,6 +15,12 @@ jobs: dispatch: timeout-minutes: 10 runs-on: ubuntu-latest + # The `secrets` context cannot be used in `if:` conditions (only vars/ + # env/inputs/github/needs are available there). Map the token into a + # job-level env var so the gates below are valid; referencing + # `secrets.*` in `if:` startup-fails the whole workflow on every push. + env: + FARM_DISPATCH_TOKEN: ${{ secrets.FARM_DISPATCH_TOKEN }} steps: # Gate the cross-repo repository_dispatch on FARM_DISPATCH_TOKEN # being configured. Without the PAT, peter-evans/repository-dispatch @@ -24,7 +30,7 @@ jobs: # gracefully skips on repos where the secret has not been # propagated, instead of red-ing main on every push. - name: Trigger Propagation - if: ${{ secrets.FARM_DISPATCH_TOKEN != '' }} + if: ${{ env.FARM_DISPATCH_TOKEN != '' }} uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v3 with: token: ${{ secrets.FARM_DISPATCH_TOKEN }} @@ -39,7 +45,7 @@ jobs: } - name: Skipped (FARM_DISPATCH_TOKEN not configured) - if: ${{ secrets.FARM_DISPATCH_TOKEN == '' }} + if: ${{ env.FARM_DISPATCH_TOKEN == '' }} env: REPO_NAME: ${{ github.event.repository.name }} run: | diff --git a/.github/workflows/mirror-reusable.yml b/.github/workflows/mirror-reusable.yml index 02370c9b..5cbb53de 100644 --- a/.github/workflows/mirror-reusable.yml +++ b/.github/workflows/mirror-reusable.yml @@ -159,6 +159,12 @@ jobs: timeout-minutes: 20 runs-on: ${{ inputs.runs-on }} if: vars.RADICLE_MIRROR_ENABLED == 'true' + # The `secrets` context cannot be used in `if:` conditions (only vars/ + # env/inputs/github/needs). Map the key into a job-level env var so the + # per-step gates below are valid; `if: ${{ secrets.* }}` startup-fails the + # reusable (and therefore its caller) on every push. + env: + RADICLE_KEY: ${{ secrets.RADICLE_KEY }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: @@ -174,23 +180,23 @@ jobs: # answers "is Radicle mirror desired here?"; the secret gate # answers "are we configured to actually do it?". - name: Setup Rust - if: ${{ secrets.RADICLE_KEY != '' }} + if: ${{ env.RADICLE_KEY != '' }} uses: dtolnay/rust-toolchain@efa25f7f19611383d5b0ccf2d1c8914531636bf9 # stable with: toolchain: stable - name: Install Radicle - if: ${{ secrets.RADICLE_KEY != '' }} + if: ${{ env.RADICLE_KEY != '' }} run: | cargo install radicle-cli --locked echo "$HOME/.cargo/bin" >> $GITHUB_PATH - name: Mirror to Radicle - if: ${{ secrets.RADICLE_KEY != '' }} + if: ${{ env.RADICLE_KEY != '' }} run: | mkdir -p ~/.radicle/keys echo "${{ secrets.RADICLE_KEY }}" > ~/.radicle/keys/radicle chmod 600 ~/.radicle/keys/radicle rad sync --announce || echo "Radicle sync attempted" - name: Skipped (RADICLE_KEY not configured) - if: ${{ secrets.RADICLE_KEY == '' }} + if: ${{ env.RADICLE_KEY == '' }} run: | echo "::notice::RADICLE_MIRROR_ENABLED=true but secrets.RADICLE_KEY is empty. Skipping Radicle mirror. Configure the RADICLE_KEY org/repo secret to enable." From a85135de7975732da4a3e822f9921a4c580da7b3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 3 Jun 2026 22:45:26 +0000 Subject: [PATCH 3/5] fix(ci): gate rust/elixir reusables via a detect job, not inputs in job-if MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit rust-ci-reusable.yml and elixir-ci-reusable.yml put `hashFiles(format('{0}/', inputs.working_directory)) != ''` directly in each job-level `if:`. That startup-failed the reusable on every push (run with 0 jobs): the `inputs` context does not exist when GitHub compiles a workflow for a non-workflow_call event, so the expression is invalid. (It was also latently broken when *called* — `hashFiles` at job level runs before any checkout, so it reads an empty workspace.) Replace with the canonical pattern: a small `detect` job checks out first, resolves `has_cargo`/`has_mix` (and the enable_audit/enable_coverage toggles for Rust) into step outputs, and every downstream job gates on `needs.detect.outputs.*` — valid in every event context and actually sees the files. No existing job names change, so consumer status-check names are unaffected. This is an estate-consumed template change (separate commit for independent review). Validated with PyYAML. No SPDX header or licence content touched. https://claude.ai/code/session_011xv3VLrqeXkpjXxUojKz82 --- .github/workflows/elixir-ci-reusable.yml | 29 ++++++++++++- .github/workflows/rust-ci-reusable.yml | 52 ++++++++++++++++++++---- 2 files changed, 72 insertions(+), 9 deletions(-) diff --git a/.github/workflows/elixir-ci-reusable.yml b/.github/workflows/elixir-ci-reusable.yml index eb5ff6a8..f6f87566 100644 --- a/.github/workflows/elixir-ci-reusable.yml +++ b/.github/workflows/elixir-ci-reusable.yml @@ -98,12 +98,39 @@ permissions: contents: read jobs: + # Detect mix.exs AFTER checkout and gate the test job on this job's output. + # Putting `hashFiles(... inputs.working_directory)` directly in the job-level + # `if:` startup-failed the reusable on every push: the `inputs` context does + # not exist when GitHub compiles a workflow for a non-`workflow_call` event, + # and `hashFiles` at job level (pre-checkout) reads an empty workspace. + # Gating on `needs` is the canonical, event-context-safe pattern. + detect: + timeout-minutes: 5 + name: Detect Mix project + runs-on: ${{ inputs.runs-on }} + permissions: + contents: read + outputs: + has_mix: ${{ steps.d.outputs.has_mix }} + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + repository: ${{ github.repository }} + ref: ${{ github.ref }} + - id: d + working-directory: ${{ inputs.working_directory }} + run: | + has=false; [ -f mix.exs ] && has=true + echo "has_mix=$has" >> "$GITHUB_OUTPUT" + test: timeout-minutes: 20 name: Compile + test runs-on: ${{ inputs.runs-on }} + needs: detect # Guard on mix.exs so the wrapper is safe to add unconditionally. - if: ${{ hashFiles(format('{0}/mix.exs', inputs.working_directory)) != '' }} + if: ${{ needs.detect.outputs.has_mix == 'true' }} permissions: contents: read env: diff --git a/.github/workflows/rust-ci-reusable.yml b/.github/workflows/rust-ci-reusable.yml index 91e255cc..b529f1ba 100644 --- a/.github/workflows/rust-ci-reusable.yml +++ b/.github/workflows/rust-ci-reusable.yml @@ -100,14 +100,48 @@ permissions: contents: read jobs: - # Skip the whole reusable when the repo has no Cargo.toml — lets - # consumers add the wrapper unconditionally without worrying about - # repos that don't ship Rust code at the moment. + # Detect whether the repo ships a Cargo.toml (and resolve the opt-in + # toggles) AFTER checkout, then gate every downstream job on this job's + # outputs. The previous design put `hashFiles(... inputs.working_directory)` + # directly in each job-level `if:`. That startup-failed the whole reusable + # on every push: the `inputs` context does not exist when GitHub compiles a + # workflow for a non-`workflow_call` event, and `hashFiles` at job level + # (pre-checkout) reads an empty workspace anyway. Gating on `needs` instead + # is the canonical pattern — valid in every event context, and it actually + # sees the files because detect checks out first. + detect: + timeout-minutes: 5 + name: Detect Cargo project + runs-on: ${{ inputs.runs-on }} + permissions: + contents: read + outputs: + has_cargo: ${{ steps.d.outputs.has_cargo }} + run_audit: ${{ steps.d.outputs.run_audit }} + run_coverage: ${{ steps.d.outputs.run_coverage }} + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + repository: ${{ github.repository }} + ref: ${{ github.ref }} + - id: d + working-directory: ${{ inputs.working_directory }} + env: + ENABLE_AUDIT: ${{ inputs.enable_audit }} + ENABLE_COVERAGE: ${{ inputs.enable_coverage }} + run: | + has=false; [ -f Cargo.toml ] && has=true + echo "has_cargo=$has" >> "$GITHUB_OUTPUT" + { [ "$has" = true ] && [ "$ENABLE_AUDIT" = "true" ]; } && echo "run_audit=true" >> "$GITHUB_OUTPUT" || echo "run_audit=false" >> "$GITHUB_OUTPUT" + { [ "$has" = true ] && [ "$ENABLE_COVERAGE" = "true" ]; } && echo "run_coverage=true" >> "$GITHUB_OUTPUT" || echo "run_coverage=false" >> "$GITHUB_OUTPUT" + check: timeout-minutes: 20 name: Cargo check + clippy + fmt runs-on: ${{ inputs.runs-on }} - if: ${{ hashFiles(format('{0}/Cargo.toml', inputs.working_directory)) != '' }} + needs: detect + if: ${{ needs.detect.outputs.has_cargo == 'true' }} permissions: contents: read defaults: @@ -147,8 +181,8 @@ jobs: timeout-minutes: 20 name: Cargo test runs-on: ${{ inputs.runs-on }} - needs: check - if: ${{ hashFiles(format('{0}/Cargo.toml', inputs.working_directory)) != '' }} + needs: [detect, check] + if: ${{ needs.detect.outputs.has_cargo == 'true' }} permissions: contents: read defaults: @@ -187,7 +221,8 @@ jobs: timeout-minutes: 20 name: Cargo audit (security) runs-on: ${{ inputs.runs-on }} - if: ${{ inputs.enable_audit && hashFiles(format('{0}/Cargo.toml', inputs.working_directory)) != '' }} + needs: detect + if: ${{ needs.detect.outputs.run_audit == 'true' }} permissions: contents: read defaults: @@ -216,7 +251,8 @@ jobs: timeout-minutes: 20 name: Coverage (tarpaulin + codecov) runs-on: ${{ inputs.runs-on }} - if: ${{ inputs.enable_coverage && hashFiles(format('{0}/Cargo.toml', inputs.working_directory)) != '' }} + needs: detect + if: ${{ needs.detect.outputs.run_coverage == 'true' }} permissions: contents: read defaults: From 7dac9fc7abc568b20ae556c219bb3661be1abae1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 3 Jun 2026 22:52:31 +0000 Subject: [PATCH 4/5] fix(ci): scorecard-enforcer reads the REAL score (JSON), not absent SARIF field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The check-score gate read `jq '.runs[0].tool.driver.properties.score'` from the SARIF artifact. The OSSF Scorecard SARIF does not carry the aggregate score at that path, so jq's `// 0` fallback made the gate read 0 on EVERY push and fail `MIN_SCORE=5` unconditionally — it never measured real posture. Logs from the 2026-06-03 main run confirm: the `scorecard` job + all its steps succeed; only `check-score` fails with 'Scorecard Score: 0'. The aggregate score lives in scorecard's JSON output. Fix check-score to run scorecard-action with `results_format: json` + `publish_results: false` (so the job needs no OIDC/id-token) and read `.score`. The `scorecard` job is unchanged and stays uses-only (OSSF publish contract); its now-unused SARIF artifact persist step is removed. MIN_SCORE=5 is unchanged — posture policy untouched. NOTE: this makes the gate ACCURATE, not necessarily green — if the repo's real score is < 5 the gate will (correctly) fail, which is then a genuine posture finding for the owner to act on, not a workflow bug. Separate commit so this security-gate change can be reviewed/dropped independently. No SPDX/licence edit. https://claude.ai/code/session_011xv3VLrqeXkpjXxUojKz82 --- .github/workflows/scorecard-enforcer.yml | 29 ++++++++++++++---------- 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/.github/workflows/scorecard-enforcer.yml b/.github/workflows/scorecard-enforcer.yml index 8dc3f120..db64bbe4 100644 --- a/.github/workflows/scorecard-enforcer.yml +++ b/.github/workflows/scorecard-enforcer.yml @@ -54,28 +54,33 @@ jobs: with: sarif_file: results.sarif - - name: Persist SARIF for downstream score-gate job - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 - with: - name: scorecard-results - path: results.sarif - retention-days: 1 - + # Gate on the aggregate score. The score is NOT present in the SARIF output + # (the previous `jq '.runs[0].tool.driver.properties.score'` always returned + # null → 0 → this gate failed on every push regardless of the real posture). + # The aggregate score only exists in scorecard's JSON output, so run the + # action here with `results_format: json` (and `publish_results: false`, so + # this job needs no OIDC/id-token) and read `.score`. check-score: - timeout-minutes: 10 + timeout-minutes: 20 needs: scorecard runs-on: ubuntu-latest permissions: contents: read steps: - - name: Download SARIF from scorecard job - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v5.0.0 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Compute Scorecard score (JSON) + uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3 with: - name: scorecard-results + results_file: results.json + results_format: json + publish_results: false - name: Check minimum score run: | - SCORE=$(jq -r '.runs[0].tool.driver.properties.score // 0' results.sarif 2>/dev/null || echo "0") + SCORE=$(jq -r '.score // 0' results.json 2>/dev/null || echo "0") echo "OpenSSF Scorecard Score: $SCORE" From dcbd1d82f58ca17c47797a211d035ae9b7c9d5cd Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 4 Jun 2026 14:15:36 +0000 Subject: [PATCH 5/5] docs(ci): fix stale scorecard.yml reference in reusable comment The standards repo's thin scorecard.yml caller was retired in #372 (redundant second scorecard run; superseded by scorecard-enforcer.yml). The scorecard-reusable.yml header comment still pointed at that now- deleted file as the canonical weekly caller. Update the note to reflect that standards runs Scorecard via scorecard-enforcer.yml, and clarify that the reusable itself is unchanged so downstream thin-caller wrappers (the canonical pattern) are unaffected. https://claude.ai/code/session_011xv3VLrqeXkpjXxUojKz82 --- .github/workflows/scorecard-reusable.yml | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/.github/workflows/scorecard-reusable.yml b/.github/workflows/scorecard-reusable.yml index 306339a9..5d4f3824 100644 --- a/.github/workflows/scorecard-reusable.yml +++ b/.github/workflows/scorecard-reusable.yml @@ -53,9 +53,15 @@ # CANONICAL SCHEDULE — WEEKLY, NOT DAILY (2026-05-28). # Estate audit found 180 repos running daily at 04:00 UTC ('0 4 * * *') # vs 29 on canonical weekly ('23 4 * * 1') — drift driven by an older -# version of the example above. The actual canonical caller in -# `hyperpolymath/standards/.github/workflows/scorecard.yml` has always -# been weekly. The example now matches. +# version of the example above. Downstream thin-caller wrappers should +# keep the weekly cadence shown above. +# +# NOTE (2026-06-04): the standards repo itself no longer ships a thin +# `scorecard.yml` caller — it was retired in #372 as a redundant second +# scorecard run. Standards runs OSSF Scorecard directly via +# `scorecard-enforcer.yml` (weekly, Monday 06:00 UTC; publishes + gates +# on MIN_SCORE). This reusable is UNCHANGED and downstream callers are +# unaffected — they remain the canonical thin-caller pattern. # # GH Actions budget impact of the drift: 180 daily × (365 − 52) ≈ 56k # extra runs/year × ~1.5 min/run ≈ ~84k Actions-minutes/year. Fan-out