diff --git a/.github/workflows/actions-lock-gate.yml b/.github/workflows/actions-lock-gate.yml index ea4c64986..ad0172aa2 100644 --- a/.github/workflows/actions-lock-gate.yml +++ b/.github/workflows/actions-lock-gate.yml @@ -6,30 +6,40 @@ # never runs pre-commit -- which is exactly how a codeql-action bump landed # in three reusable workflows without regenerating the lockfile, poisoning # every repo that calls them. A pre-commit hook cannot gate a bot; this can. -name: Actions Lockfile Gate - -# NO `paths:` FILTER, deliberately. This check is meant to be REQUIRED, and a -# required check whose workflow is skipped by path filtering never reports: -# the PR sits on "Expected -- waiting for status" forever. Every PR that does -# not touch .github/** would be permanently unmergeable. The job is a -# checkout plus a few seconds of bash, so it runs on everything. -on: - pull_request: - push: - branches: [main] - workflow_dispatch: - -permissions: - contents: read - -jobs: - lockfile-coverage: - name: uses ⊆ actions.lock - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - name: "Every SHA-pinned uses: must be in the lockfile" - run: bash .githooks/validate-actions-lock.sh +{ + name: "Actions Lockfile Gate", + # NO `paths:` FILTER, deliberately. This check is meant to be REQUIRED, and a + # required check whose workflow is skipped by path filtering never reports: + # the PR sits on "Expected -- waiting for status" forever. Every PR that does + # not touch .github/** would be permanently unmergeable. The job is a + # checkout plus a few seconds of bash, so it runs on everything. + on: { + pull_request: null, + push: { + branches: [ + "main", + ], + }, + workflow_dispatch: null, + }, + permissions: { + contents: "read", + }, + jobs: { + lockfile-coverage: { + name: "uses ⊆ actions.lock", + runs-on: "ubuntu-latest", + timeout-minutes: 5, + steps: [ + { + name: "Checkout", + uses: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", # v7.0.1 + }, + { + name: "Every SHA-pinned uses: must be in the lockfile", + run: "bash .githooks/validate-actions-lock.sh", + }, + ], + }, + }, +} diff --git a/.github/workflows/affinescript-verify.yml b/.github/workflows/affinescript-verify.yml index 14bf63735..b14709f33 100644 --- a/.github/workflows/affinescript-verify.yml +++ b/.github/workflows/affinescript-verify.yml @@ -1,196 +1,123 @@ # This workflow is managed by gh actions-lock. # SPDX-License-Identifier: MPL-2.0 # This workflow is managed by gh actions-lock. -name: AffineScript Verify -# Direct pushes only on integration branches. Feature-branch validation -# is fully covered by pull_request — running a full AffineScript verify -# on every WIP push to every branch in every consumer repo was a dominant -# estate-wide drain on the shared Actions concurrency pool. No coverage -# is lost: PRs and post-merge main/master still verify. -on: - push: - branches: [main, master] - pull_request: - -# Estate guardrail: cancel superseded runs so re-pushes / rebased PR -# updates do not pile up queued runs against the shared account-wide -# Actions concurrency pool. Applied only to read-only check workflows -# (no publish/mutation), so cancelling a superseded run is always safe. -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -permissions: - actions: read - contents: read - -# Compile-verifies changed `.affine` files with the canonical AffineScript -# compiler (hyperpolymath/affinescript). The compiler is pinned to a commit -# SHA for reproducibility; bump COMPILER_REF deliberately. -# -# SPLIT GATE (Wave 8, standards#446 honest-gating): the initial -# ReScript->AffineScript port (PR #62) predates the compiler, so some legacy -# ports still fail `affinescript check`. Blindly flipping the whole job to -# blocking would red every PR that touches a legacy port — manufactured noise. -# Instead the gate distinguishes: -# * ADDED .affine files (diff-filter=A) -> BLOCKING. New code has no excuse -# not to compile; a failing added file fails this job. -# * MODIFIED legacy files (diff-filter=CMR) -> advisory warnings until the -# port backlog clears (flip LEGACY_BLOCKING=true then). -# * Toolchain failures (compiler checkout/opam/build) -> advisory: the step -# reports "toolchain unavailable" and the verify step SKIPS (loudly) rather -# than blocking unrelated changes on OCaml infra flake. -env: - LEGACY_BLOCKING: "false" - COMPILER_REPO: hyperpolymath/affinescript - COMPILER_REF: d2875a552f1d389b4a60c4adfdc02ae53e36aca3 - -jobs: - verify: - timeout-minutes: 20 - name: AffineScript Verify - runs-on: ubuntu-latest - permissions: - contents: read - steps: - - name: Checkout standards - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - fetch-depth: 0 - - - name: Determine changed .affine files - id: changed - run: | - if [ "${{ github.event_name }}" = "pull_request" ]; then - BASE="${{ github.event.pull_request.base.sha }}" - else - BASE="${{ github.event.before }}" - fi - if [ -z "$BASE" ] || ! git cat-file -e "$BASE^{commit}" 2>/dev/null \ - || printf '%s' "$BASE" | grep -qE '^0+$'; then - BASE="$(git rev-parse HEAD^ 2>/dev/null || git rev-parse HEAD)" - fi - # Split-gate inputs: ADDED files gate hard; modified legacy files are - # advisory until the port backlog clears (see header). - ADDED="$(git diff --name-only --diff-filter=A "$BASE" HEAD -- '*.affine' || true)" - MODIFIED="$(git diff --name-only --diff-filter=CMR "$BASE" HEAD -- '*.affine' || true)" - FILES="$(printf '%s\n%s\n' "$ADDED" "$MODIFIED" | sed '/^$/d')" - if [ -z "$FILES" ]; then - echo "any=false" >> "$GITHUB_OUTPUT" - echo "No changed .affine files — nothing to verify." - else - echo "any=true" >> "$GITHUB_OUTPUT" - echo "Changed .affine files (added gate hard; modified advisory):" - echo "$FILES" - { echo 'files<> "$GITHUB_OUTPUT" - { echo 'added<> "$GITHUB_OUTPUT" - fi - - - name: Checkout AffineScript compiler - if: steps.changed.outputs.any == 'true' - # advisory: compiler checkout is report-only until the port backlog - # is cleared and BLOCKING flips to true. "Verify changed .affine - # files" below checks steps.build.outputs.ok and the checkout dir, - # and on toolchain-unavailable emits an explicit ::warning:: + - # step-summary "SKIPPED — not a pass" instead of a silent green claim. - # hypatia: allow research_extensions/RE005 -- advisory-by-design, fails OPEN not closed; see comment above. - continue-on-error: true - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: ${{ env.COMPILER_REPO }} - ref: ${{ env.COMPILER_REF }} - path: .affinescript-compiler - - - name: Set up OCaml - if: steps.changed.outputs.any == 'true' - # advisory: setup failures should surface as signal without blocking - # unrelated standards changes while AffineScript verification matures. - continue-on-error: true - uses: ocaml/setup-ocaml@93303b622b2522e4411e295f9e77411a24912ac7 # v3 - with: - ocaml-compiler: "5.1" - - - name: Build compiler - id: build - if: steps.changed.outputs.any == 'true' - # advisory: toolchain failures are infra flake, not code failures — - # they must not block unrelated changes. The verify step below skips - # LOUDLY (never a silent green claim) when the compiler is unavailable. - continue-on-error: true - working-directory: .affinescript-compiler - run: | - opam install . --deps-only - opam exec -- dune build - echo "ok=true" >> "$GITHUB_OUTPUT" - - - name: Verify changed .affine files (added files gate; legacy advisory) - if: steps.changed.outputs.any == 'true' - # No working-directory: if the (advisory) compiler checkout failed, the - # dir may not exist — the guard below must run BEFORE any cd. - run: | - set -u - if [ "${{ steps.build.outputs.ok }}" != "true" ] || [ ! -d .affinescript-compiler ]; then - echo "::warning::AffineScript toolchain unavailable (compiler checkout/opam/build failed) — verification SKIPPED, not passed." - { - echo "## AffineScript Verify" - echo "⚠️ **SKIPPED — toolchain unavailable.** The compiler did not build;" - echo "no \`.affine\` file was verified. This is an infra flake, not a pass." - } >> "$GITHUB_STEP_SUMMARY" - exit 0 - fi - cd .affinescript-compiler - # is_added : is this file in the added set? (added files gate hard) - is_added() { - case $'\n'"${ADDED_SET}"$'\n' in *$'\n'"$1"$'\n'*) return 0 ;; *) return 1 ;; esac - } - ADDED_SET="$(cat <<'EOF' - ${{ steps.changed.outputs.added }} - EOF - )" - ADDED_SET="$(printf '%s\n' "$ADDED_SET" | sed 's/^[[:space:]]*//' | sed '/^$/d')" - added_fail="" - legacy_fail="" - while IFS= read -r f; do - [ -z "$f" ] && continue - abs="$GITHUB_WORKSPACE/$f" - echo "::group::check $f" - if opam exec -- dune exec affinescript -- check "$abs" 2>&1; then - echo "✅ $f" - elif is_added "$f"; then - echo "::error file=$f::AffineScript check failed (ADDED file — blocking)" - echo "❌ $f failed (added file — blocking)" - added_fail="$added_fail$f"$'\n' - else - echo "::warning file=$f::AffineScript check failed (legacy port — advisory until backlog clears)" - echo "⚠️ $f failed (legacy port — advisory)" - legacy_fail="$legacy_fail$f"$'\n' - fi - echo "::endgroup::" - done <<'EOF' - ${{ steps.changed.outputs.files }} - EOF - - { - echo "## AffineScript Verify (split gate)" - if [ -z "$added_fail" ] && [ -z "$legacy_fail" ]; then - echo "All changed \`.affine\` files passed \`affinescript check\`." - fi - if [ -n "$added_fail" ]; then - echo "### ❌ ADDED files failing (BLOCKING — new code must compile)" - echo "$added_fail" | sed '/^$/d' | sed 's/^/- /' - fi - if [ -n "$legacy_fail" ]; then - echo "### ⚠️ Legacy ports failing (advisory until the port backlog clears)" - echo "$legacy_fail" | sed '/^$/d' | sed 's/^/- /' - fi - } >> "$GITHUB_STEP_SUMMARY" - - if [ -n "$added_fail" ]; then - echo "AffineScript verification failed for ADDED files (blocking)." - exit 1 - fi - if [ -n "$legacy_fail" ] && [ "$LEGACY_BLOCKING" = "true" ]; then - echo "Legacy port failures are blocking (LEGACY_BLOCKING=true)." - exit 1 - fi - echo "Verification complete (added files clean)." +{ + name: "AffineScript Verify", + # Direct pushes only on integration branches. Feature-branch validation + # is fully covered by pull_request — running a full AffineScript verify + # on every WIP push to every branch in every consumer repo was a dominant + # estate-wide drain on the shared Actions concurrency pool. No coverage + # is lost: PRs and post-merge main/master still verify. + on: { + push: { + branches: [ + "main", + "master", + ], + }, + pull_request: null, + }, + # Estate guardrail: cancel superseded runs so re-pushes / rebased PR + # updates do not pile up queued runs against the shared account-wide + # Actions concurrency pool. Applied only to read-only check workflows + # (no publish/mutation), so cancelling a superseded run is always safe. + concurrency: { + group: "${{ github.workflow }}-${{ github.ref }}", + cancel-in-progress: true, + }, + permissions: { + actions: "read", + contents: "read", + }, + # Compile-verifies changed `.affine` files with the canonical AffineScript + # compiler (hyperpolymath/affinescript). The compiler is pinned to a commit + # SHA for reproducibility; bump COMPILER_REF deliberately. + # + # SPLIT GATE (Wave 8, standards#446 honest-gating): the initial + # ReScript->AffineScript port (PR #62) predates the compiler, so some legacy + # ports still fail `affinescript check`. Blindly flipping the whole job to + # blocking would red every PR that touches a legacy port — manufactured noise. + # Instead the gate distinguishes: + # * ADDED .affine files (diff-filter=A) -> BLOCKING. New code has no excuse + # not to compile; a failing added file fails this job. + # * MODIFIED legacy files (diff-filter=CMR) -> advisory warnings until the + # port backlog clears (flip LEGACY_BLOCKING=true then). + # * Toolchain failures (compiler checkout/opam/build) -> advisory: the step + # reports "toolchain unavailable" and the verify step SKIPS (loudly) rather + # than blocking unrelated changes on OCaml infra flake. + env: { + LEGACY_BLOCKING: "false", + COMPILER_REPO: "hyperpolymath/affinescript", + COMPILER_REF: "d2875a552f1d389b4a60c4adfdc02ae53e36aca3", + }, + jobs: { + verify: { + timeout-minutes: 20, + name: "AffineScript Verify", + runs-on: "ubuntu-latest", + permissions: { + contents: "read", + }, + steps: [ + { + name: "Checkout standards", + uses: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", # v7.0.1 + with: { + fetch-depth: 0, + }, + }, + { + name: "Determine changed .affine files", + id: "changed", + run: "if [ \"${{ github.event_name }}\" = \"pull_request\" ]; then\n BASE=\"${{ github.event.pull_request.base.sha }}\"\nelse\n BASE=\"${{ github.event.before }}\"\nfi\nif [ -z \"$BASE\" ] || ! git cat-file -e \"$BASE^{commit}\" 2>/dev/null \\\n || printf '%s' \"$BASE\" | grep -qE '^0+$'; then\n BASE=\"$(git rev-parse HEAD^ 2>/dev/null || git rev-parse HEAD)\"\nfi\n# Split-gate inputs: ADDED files gate hard; modified legacy files are\n# advisory until the port backlog clears (see header).\nADDED=\"$(git diff --name-only --diff-filter=A \"$BASE\" HEAD -- '*.affine' || true)\"\nMODIFIED=\"$(git diff --name-only --diff-filter=CMR \"$BASE\" HEAD -- '*.affine' || true)\"\nFILES=\"$(printf '%s\\n%s\\n' \"$ADDED\" \"$MODIFIED\" | sed '/^$/d')\"\nif [ -z \"$FILES\" ]; then\n echo \"any=false\" >> \"$GITHUB_OUTPUT\"\n echo \"No changed .affine files — nothing to verify.\"\nelse\n echo \"any=true\" >> \"$GITHUB_OUTPUT\"\n echo \"Changed .affine files (added gate hard; modified advisory):\"\n echo \"$FILES\"\n { echo 'files<> \"$GITHUB_OUTPUT\"\n { echo 'added<> \"$GITHUB_OUTPUT\"\nfi\n", + }, + { + name: "Checkout AffineScript compiler", + if: "steps.changed.outputs.any == 'true'", + # advisory: compiler checkout is report-only until the port backlog + # is cleared and BLOCKING flips to true. "Verify changed .affine + # files" below checks steps.build.outputs.ok and the checkout dir, + # and on toolchain-unavailable emits an explicit ::warning:: + + # step-summary "SKIPPED — not a pass" instead of a silent green claim. + # hypatia: allow research_extensions/RE005 -- advisory-by-design, fails OPEN not closed; see comment above. + continue-on-error: true, + uses: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", # v7.0.1 + with: { + repository: "${{ env.COMPILER_REPO }}", + ref: "${{ env.COMPILER_REF }}", + path: ".affinescript-compiler", + }, + }, + { + name: "Set up OCaml", + if: "steps.changed.outputs.any == 'true'", + # advisory: setup failures should surface as signal without blocking + # unrelated standards changes while AffineScript verification matures. + continue-on-error: true, + uses: "ocaml/setup-ocaml@93303b622b2522e4411e295f9e77411a24912ac7", # v3 + with: { + ocaml-compiler: "5.1", + }, + }, + { + name: "Build compiler", + id: "build", + if: "steps.changed.outputs.any == 'true'", + # advisory: toolchain failures are infra flake, not code failures — + # they must not block unrelated changes. The verify step below skips + # LOUDLY (never a silent green claim) when the compiler is unavailable. + continue-on-error: true, + working-directory: ".affinescript-compiler", + run: "opam install . --deps-only\nopam exec -- dune build\necho \"ok=true\" >> \"$GITHUB_OUTPUT\"\n", + }, + { + name: "Verify changed .affine files (added files gate; legacy advisory)", + if: "steps.changed.outputs.any == 'true'", + # No working-directory: if the (advisory) compiler checkout failed, the + # dir may not exist — the guard below must run BEFORE any cd. + run: "set -u\nif [ \"${{ steps.build.outputs.ok }}\" != \"true\" ] || [ ! -d .affinescript-compiler ]; then\n echo \"::warning::AffineScript toolchain unavailable (compiler checkout/opam/build failed) — verification SKIPPED, not passed.\"\n {\n echo \"## AffineScript Verify\"\n echo \"⚠️ **SKIPPED — toolchain unavailable.** The compiler did not build;\"\n echo \"no \\`.affine\\` file was verified. This is an infra flake, not a pass.\"\n } >> \"$GITHUB_STEP_SUMMARY\"\n exit 0\nfi\ncd .affinescript-compiler\n# is_added : is this file in the added set? (added files gate hard)\nis_added() {\n case $'\\n'\"${ADDED_SET}\"$'\\n' in *$'\\n'\"$1\"$'\\n'*) return 0 ;; *) return 1 ;; esac\n}\nADDED_SET=\"$(cat <<'EOF'\n${{ steps.changed.outputs.added }}\nEOF\n)\"\nADDED_SET=\"$(printf '%s\\n' \"$ADDED_SET\" | sed 's/^[[:space:]]*//' | sed '/^$/d')\"\nadded_fail=\"\"\nlegacy_fail=\"\"\nwhile IFS= read -r f; do\n [ -z \"$f\" ] && continue\n abs=\"$GITHUB_WORKSPACE/$f\"\n echo \"::group::check $f\"\n if opam exec -- dune exec affinescript -- check \"$abs\" 2>&1; then\n echo \"✅ $f\"\n elif is_added \"$f\"; then\n echo \"::error file=$f::AffineScript check failed (ADDED file — blocking)\"\n echo \"❌ $f failed (added file — blocking)\"\n added_fail=\"$added_fail$f\"$'\\n'\n else\n echo \"::warning file=$f::AffineScript check failed (legacy port — advisory until backlog clears)\"\n echo \"⚠️ $f failed (legacy port — advisory)\"\n legacy_fail=\"$legacy_fail$f\"$'\\n'\n fi\n echo \"::endgroup::\"\ndone <<'EOF'\n${{ steps.changed.outputs.files }}\nEOF\n\n{\n echo \"## AffineScript Verify (split gate)\"\n if [ -z \"$added_fail\" ] && [ -z \"$legacy_fail\" ]; then\n echo \"All changed \\`.affine\\` files passed \\`affinescript check\\`.\"\n fi\n if [ -n \"$added_fail\" ]; then\n echo \"### ❌ ADDED files failing (BLOCKING — new code must compile)\"\n echo \"$added_fail\" | sed '/^$/d' | sed 's/^/- /'\n fi\n if [ -n \"$legacy_fail\" ]; then\n echo \"### ⚠️ Legacy ports failing (advisory until the port backlog clears)\"\n echo \"$legacy_fail\" | sed '/^$/d' | sed 's/^/- /'\n fi\n} >> \"$GITHUB_STEP_SUMMARY\"\n\nif [ -n \"$added_fail\" ]; then\n echo \"AffineScript verification failed for ADDED files (blocking).\"\n exit 1\nfi\nif [ -n \"$legacy_fail\" ] && [ \"$LEGACY_BLOCKING\" = \"true\" ]; then\n echo \"Legacy port failures are blocking (LEGACY_BLOCKING=true).\"\n exit 1\nfi\necho \"Verification complete (added files clean).\"\n", + }, + ], + }, + }, +} diff --git a/.github/workflows/allowlist-preflight-reusable.yml b/.github/workflows/allowlist-preflight-reusable.yml index b31e5235f..73d8e9946 100644 --- a/.github/workflows/allowlist-preflight-reusable.yml +++ b/.github/workflows/allowlist-preflight-reusable.yml @@ -1,111 +1,89 @@ # This workflow is managed by gh actions-lock. # SPDX-License-Identifier: MPL-2.0 # Reusable fail-closed Actions policy preflight (standards#486). -name: Actions Policy Preflight Reusable - -on: - workflow_call: - secrets: - HYPATIA_SCAN_PAT: - description: Fine-grained token with repository Administration read access - required: false - -permissions: - contents: read - -jobs: - preflight: - name: Actions policy (tree) - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - - name: Checkout caller repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: ${{ github.repository }} - ref: ${{ github.sha }} - - - name: Checkout policy source - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: hyperpolymath/standards - ref: ${{ job.workflow_sha }} - path: .standards-checkout - sparse-checkout: | - rhodium-standard-repositories/actions-allowlist - scripts/check-actions-policy.sh - scripts/check-allowed-actions.sh - sparse-checkout-cone-mode: false - - - name: Stage policy tools outside the scanned tree - run: | - if [ "$GITHUB_REPOSITORY" = hyperpolymath/standards ]; then - cp scripts/check-actions-policy.sh scripts/check-allowed-actions.sh "$RUNNER_TEMP/" - cp rhodium-standard-repositories/actions-allowlist/allowed-actions.json \ - "$RUNNER_TEMP/allowed-actions.json" - else - cp .standards-checkout/scripts/check-actions-policy.sh \ - .standards-checkout/scripts/check-allowed-actions.sh "$RUNNER_TEMP/" - cp .standards-checkout/rhodium-standard-repositories/actions-allowlist/allowed-actions.json \ - "$RUNNER_TEMP/allowed-actions.json" - fi - rm -rf .standards-checkout - - - name: Check workflow references against canonical allowlist - run: | - bash "$RUNNER_TEMP/check-allowed-actions.sh" \ - "$RUNNER_TEMP/allowed-actions.json" .github/workflows - - live-policy: - name: Live Actions policy (credentialed advisory) - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - - name: Checkout policy source - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: hyperpolymath/standards - ref: ${{ job.workflow_sha }} - path: .standards-checkout - sparse-checkout: | - rhodium-standard-repositories/actions-allowlist - scripts/check-actions-policy.sh - sparse-checkout-cone-mode: false - - - name: Report unavailable live policy credential - env: - GH_TOKEN: ${{ secrets.HYPATIA_SCAN_PAT }} - if: ${{ env.GH_TOKEN == '' }} - run: | - echo "::notice::Live Actions policy was not evaluated: HYPATIA_SCAN_PAT was not supplied by the caller. The separate tree allowlist gate still ran." - # shellcheck disable=SC2016 - printf '%s\n' \ - '### Live Actions policy not evaluated' \ - '' \ - 'The caller did not supply `HYPATIA_SCAN_PAT`. This advisory job' \ - 'does not claim that the repository Administration setting passed.' \ - 'The independent tree allowlist preflight remains enforced.' \ - >> "$GITHUB_STEP_SUMMARY" - - - name: Check live repository policy - env: - GH_TOKEN: ${{ secrets.HYPATIA_SCAN_PAT }} - if: ${{ env.GH_TOKEN != '' }} - run: | - set +e - bash .standards-checkout/scripts/check-actions-policy.sh \ - "$GITHUB_REPOSITORY" \ - .standards-checkout/rhodium-standard-repositories/actions-allowlist/allowed-actions.json - rc=$? - set -e - if [ "$rc" -eq 3 ]; then - echo "::notice::Live Actions policy was not evaluated: the supplied credential could not read the Administration endpoint." - printf '%s\n' \ - '### Live Actions policy not evaluated' \ - '' \ - 'A credential was supplied, but the Administration API was unavailable.' \ - 'No live-policy pass is claimed; rotate or re-authorise the credential.' \ - >> "$GITHUB_STEP_SUMMARY" - exit 0 - fi - exit "$rc" +{ + name: "Actions Policy Preflight Reusable", + on: { + workflow_call: { + secrets: { + HYPATIA_SCAN_PAT: { + description: "Fine-grained token with repository Administration read access", + required: false, + }, + }, + }, + }, + permissions: { + contents: "read", + }, + jobs: { + preflight: { + name: "Actions policy (tree)", + runs-on: "ubuntu-latest", + timeout-minutes: 5, + steps: [ + { + name: "Checkout caller repository", + uses: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", # v7.0.1 + with: { + repository: "${{ github.repository }}", + ref: "${{ github.sha }}", + }, + }, + { + name: "Checkout policy source", + uses: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", # v7.0.1 + with: { + repository: "hyperpolymath/standards", + ref: "${{ job.workflow_sha }}", + path: ".standards-checkout", + sparse-checkout: "rhodium-standard-repositories/actions-allowlist\nscripts/check-actions-policy.sh\nscripts/check-allowed-actions.sh\n", + sparse-checkout-cone-mode: false, + }, + }, + { + name: "Stage policy tools outside the scanned tree", + run: "if [ \"$GITHUB_REPOSITORY\" = hyperpolymath/standards ]; then\n cp scripts/check-actions-policy.sh scripts/check-allowed-actions.sh \"$RUNNER_TEMP/\"\n cp rhodium-standard-repositories/actions-allowlist/allowed-actions.json \\\n \"$RUNNER_TEMP/allowed-actions.json\"\nelse\n cp .standards-checkout/scripts/check-actions-policy.sh \\\n .standards-checkout/scripts/check-allowed-actions.sh \"$RUNNER_TEMP/\"\n cp .standards-checkout/rhodium-standard-repositories/actions-allowlist/allowed-actions.json \\\n \"$RUNNER_TEMP/allowed-actions.json\"\nfi\nrm -rf .standards-checkout\n", + }, + { + name: "Check workflow references against canonical allowlist", + run: "bash \"$RUNNER_TEMP/check-allowed-actions.sh\" \\\n \"$RUNNER_TEMP/allowed-actions.json\" .github/workflows\n", + }, + ], + }, + live-policy: { + name: "Live Actions policy (credentialed advisory)", + runs-on: "ubuntu-latest", + timeout-minutes: 5, + steps: [ + { + name: "Checkout policy source", + uses: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", # v7.0.1 + with: { + repository: "hyperpolymath/standards", + ref: "${{ job.workflow_sha }}", + path: ".standards-checkout", + sparse-checkout: "rhodium-standard-repositories/actions-allowlist\nscripts/check-actions-policy.sh\n", + sparse-checkout-cone-mode: false, + }, + }, + { + name: "Report unavailable live policy credential", + env: { + GH_TOKEN: "${{ secrets.HYPATIA_SCAN_PAT }}", + }, + if: "${{ env.GH_TOKEN == '' }}", + run: "echo \"::notice::Live Actions policy was not evaluated: HYPATIA_SCAN_PAT was not supplied by the caller. The separate tree allowlist gate still ran.\"\n# shellcheck disable=SC2016\nprintf '%s\\n' \\\n '### Live Actions policy not evaluated' \\\n '' \\\n 'The caller did not supply `HYPATIA_SCAN_PAT`. This advisory job' \\\n 'does not claim that the repository Administration setting passed.' \\\n 'The independent tree allowlist preflight remains enforced.' \\\n >> \"$GITHUB_STEP_SUMMARY\"\n", + }, + { + name: "Check live repository policy", + env: { + GH_TOKEN: "${{ secrets.HYPATIA_SCAN_PAT }}", + }, + if: "${{ env.GH_TOKEN != '' }}", + run: "set +e\nbash .standards-checkout/scripts/check-actions-policy.sh \\\n \"$GITHUB_REPOSITORY\" \\\n .standards-checkout/rhodium-standard-repositories/actions-allowlist/allowed-actions.json\nrc=$?\nset -e\nif [ \"$rc\" -eq 3 ]; then\n echo \"::notice::Live Actions policy was not evaluated: the supplied credential could not read the Administration endpoint.\"\n printf '%s\\n' \\\n '### Live Actions policy not evaluated' \\\n '' \\\n 'A credential was supplied, but the Administration API was unavailable.' \\\n 'No live-policy pass is claimed; rotate or re-authorise the credential.' \\\n >> \"$GITHUB_STEP_SUMMARY\"\n exit 0\nfi\nexit \"$rc\"\n", + }, + ], + }, + }, +} diff --git a/.github/workflows/apply-workflow-pins.yml b/.github/workflows/apply-workflow-pins.yml index 88406d79b..6415f22a3 100644 --- a/.github/workflows/apply-workflow-pins.yml +++ b/.github/workflows/apply-workflow-pins.yml @@ -17,141 +17,142 @@ # produce is an honest census — how many repos are BEHIND, DEAD-REF or ILLEGAL — # and a census is evidence only while nothing is mutating underneath it. -name: apply workflow pins - -on: - schedule: - # Sundays 06:11 UTC. Offset off the hour: the estate's other scheduled jobs - # cluster on :00 and contend for the same rate-limit bucket. - - cron: '11 6 * * 0' - workflow_dispatch: - inputs: - mode: - description: 'audit (default, writes nothing) or fix (opens PRs)' - type: choice - options: [audit, fix] - default: audit - repair_illegal: - description: 'Also repair unparseable `uses: ../../` refs (issue #808)' - type: boolean - default: false - owners: - description: 'Comma-separated owners to walk' - default: 'hyperpolymath,metadatastician' - limit: - description: 'Stop after N repos (0 = no limit); use for smoke runs' - default: '0' - -concurrency: - group: apply-workflow-pins - cancel-in-progress: false - -permissions: - contents: read - -jobs: - apply: - name: Re-point standards pins - runs-on: ubuntu-latest - timeout-minutes: 60 - steps: - - name: Harden runner (egress audit) - uses: step-security/harden-runner@e14015d583714f6e62063499dc959a02595150a1 # v2.21.1 - with: - egress-policy: audit - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - # WHICH App's credentials belong in vars.APP_ID / secrets.APP_PRIVATE_KEY: - # a DEDICATED App for this applier, explicitly NOT OikosBot. Owner ruling - # STD-R-3 / R-14 (2026-09-15). OikosBot is named as a BYPASS ACTOR in the - # estate's rulesets; if the thing that re-points pins authenticated as the - # identity that may bypass the rules, it would be permanently exempt from - # the rules it exists to uphold. Separation of duties, not style. - # - # The App needs `contents: write` and `pull_requests: write`, and must be - # installed on BOTH owners. An installation token is scoped to ONE owner - # and carries its own rate limit, so `owner:` is required — without it the - # token is scoped to THIS repository and every consumer write 404s. - - name: Mint an App installation token for hyperpolymath - id: tok-user - if: vars.APP_ID != '' - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 - continue-on-error: true - with: - app-id: ${{ vars.APP_ID }} - private-key: ${{ secrets.APP_PRIVATE_KEY }} - owner: hyperpolymath - - - name: Mint an App installation token for metadatastician - id: tok-org - if: vars.APP_ID != '' - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 - continue-on-error: true - with: - app-id: ${{ vars.APP_ID }} - private-key: ${{ secrets.APP_PRIVATE_KEY }} - owner: metadatastician - - # A nonexistent secret resolves to an EMPTY STRING in silence, and a run - # over an empty credential reports a clean sweep while writing nothing. - # Decide here, before any enumeration, and say exactly what is missing. - # Audit mode may proceed on the read-only GITHUB_TOKEN; fix mode may not. - - name: Decide which credential is in play - id: cred - env: - APP_USER: ${{ steps.tok-user.outputs.token }} - MODE: ${{ inputs.mode || 'audit' }} - run: | - set -euo pipefail - if [ -n "${APP_USER:-}" ]; then - echo "have_app=true" >> "$GITHUB_OUTPUT" - echo "An App credential is present." - exit 0 - fi - echo "have_app=false" >> "$GITHUB_OUTPUT" - if [ "$MODE" = "fix" ]; then - cat >&2 <<'MSG' - FATAL: fix mode was requested but no App credential exists. - vars.APP_ID and secrets.APP_PRIVATE_KEY must both be set, and the App - must be installed on hyperpolymath and metadatastician. - Refusing to report a clean run that wrote nothing. - MSG - exit 1 - fi - echo "::notice::AUDIT-ONLY: no App credential; census will run on GITHUB_TOKEN." - - - name: Run the applier - env: - # Prefer the App token; fall back to GITHUB_TOKEN for audit reads only. - GH_TOKEN: ${{ steps.tok-user.outputs.token || secrets.GITHUB_TOKEN }} - MODE: ${{ inputs.mode || 'audit' }} - REPAIR: ${{ inputs.repair_illegal || false }} - OWNERS: ${{ inputs.owners || 'hyperpolymath,metadatastician' }} - LIMIT: ${{ inputs.limit || '0' }} - run: | - set -euo pipefail - args=(--owners "$OWNERS" --limit "$LIMIT" --out census.tsv) - [ "$MODE" = "fix" ] && args+=(--fix) - [ "$REPAIR" = "true" ] && args+=(--repair-illegal) - bash scripts/apply-workflow-pins-remote.sh "${args[@]}" - - - name: Summarise the census - if: always() - run: | - set -euo pipefail - [ -f census.tsv ] || { echo "no census produced"; exit 0; } - { - echo '### standards pin census' - echo - echo '| status | files |' - echo '|---|---|' - awk -F'\t' 'NR>1{c[$3]++} END{for(k in c) printf "| %s | %d |\n", k, c[k]}' census.tsv | sort - } >> "$GITHUB_STEP_SUMMARY" - - - name: Upload the census - if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: workflow-pin-census - path: census.tsv - if-no-files-found: warn +{ + name: "apply workflow pins", + on: { + schedule: [ + # Sundays 06:11 UTC. Offset off the hour: the estate's other scheduled jobs + # cluster on :00 and contend for the same rate-limit bucket. + { + cron: "11 6 * * 0", + }, + ], + workflow_dispatch: { + inputs: { + mode: { + description: "audit (default, writes nothing) or fix (opens PRs)", + type: "choice", + options: [ + "audit", + "fix", + ], + default: "audit", + }, + repair_illegal: { + description: "Also repair unparseable `uses: ../../` refs (issue #808)", + type: "boolean", + default: false, + }, + owners: { + description: "Comma-separated owners to walk", + default: "hyperpolymath,metadatastician", + }, + limit: { + description: "Stop after N repos (0 = no limit); use for smoke runs", + default: "0", + }, + }, + }, + }, + concurrency: { + group: "apply-workflow-pins", + cancel-in-progress: false, + }, + permissions: { + contents: "read", + }, + jobs: { + apply: { + name: "Re-point standards pins", + runs-on: "ubuntu-latest", + timeout-minutes: 60, + steps: [ + { + name: "Harden runner (egress audit)", + uses: "step-security/harden-runner@e14015d583714f6e62063499dc959a02595150a1", # v2.21.1 + with: { + egress-policy: "audit", + }, + }, + { + uses: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", # v7.0.1 + }, + # WHICH App's credentials belong in vars.APP_ID / secrets.APP_PRIVATE_KEY: + # a DEDICATED App for this applier, explicitly NOT OikosBot. Owner ruling + # STD-R-3 / R-14 (2026-09-15). OikosBot is named as a BYPASS ACTOR in the + # estate's rulesets; if the thing that re-points pins authenticated as the + # identity that may bypass the rules, it would be permanently exempt from + # the rules it exists to uphold. Separation of duties, not style. + # + # The App needs `contents: write` and `pull_requests: write`, and must be + # installed on BOTH owners. An installation token is scoped to ONE owner + # and carries its own rate limit, so `owner:` is required — without it the + # token is scoped to THIS repository and every consumer write 404s. + { + name: "Mint an App installation token for hyperpolymath", + id: "tok-user", + if: "vars.APP_ID != ''", + uses: "actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1", # v3.2.0 + continue-on-error: true, + with: { + app-id: "${{ vars.APP_ID }}", + private-key: "${{ secrets.APP_PRIVATE_KEY }}", + owner: "hyperpolymath", + }, + }, + { + name: "Mint an App installation token for metadatastician", + id: "tok-org", + if: "vars.APP_ID != ''", + uses: "actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1", # v3.2.0 + continue-on-error: true, + with: { + app-id: "${{ vars.APP_ID }}", + private-key: "${{ secrets.APP_PRIVATE_KEY }}", + owner: "metadatastician", + }, + }, + # A nonexistent secret resolves to an EMPTY STRING in silence, and a run + # over an empty credential reports a clean sweep while writing nothing. + # Decide here, before any enumeration, and say exactly what is missing. + # Audit mode may proceed on the read-only GITHUB_TOKEN; fix mode may not. + { + name: "Decide which credential is in play", + id: "cred", + env: { + APP_USER: "${{ steps.tok-user.outputs.token }}", + MODE: "${{ inputs.mode || 'audit' }}", + }, + run: "set -euo pipefail\nif [ -n \"${APP_USER:-}\" ]; then\n echo \"have_app=true\" >> \"$GITHUB_OUTPUT\"\n echo \"An App credential is present.\"\n exit 0\nfi\necho \"have_app=false\" >> \"$GITHUB_OUTPUT\"\nif [ \"$MODE\" = \"fix\" ]; then\n cat >&2 <<'MSG'\nFATAL: fix mode was requested but no App credential exists.\n vars.APP_ID and secrets.APP_PRIVATE_KEY must both be set, and the App\n must be installed on hyperpolymath and metadatastician.\n Refusing to report a clean run that wrote nothing.\nMSG\n exit 1\nfi\necho \"::notice::AUDIT-ONLY: no App credential; census will run on GITHUB_TOKEN.\"\n", + }, + { + name: "Run the applier", + env: { + # Prefer the App token; fall back to GITHUB_TOKEN for audit reads only. + GH_TOKEN: "${{ steps.tok-user.outputs.token || secrets.GITHUB_TOKEN }}", + MODE: "${{ inputs.mode || 'audit' }}", + REPAIR: "${{ inputs.repair_illegal || false }}", + OWNERS: "${{ inputs.owners || 'hyperpolymath,metadatastician' }}", + LIMIT: "${{ inputs.limit || '0' }}", + }, + run: "set -euo pipefail\nargs=(--owners \"$OWNERS\" --limit \"$LIMIT\" --out census.tsv)\n[ \"$MODE\" = \"fix\" ] && args+=(--fix)\n[ \"$REPAIR\" = \"true\" ] && args+=(--repair-illegal)\nbash scripts/apply-workflow-pins-remote.sh \"${args[@]}\"\n", + }, + { + name: "Summarise the census", + if: "always()", + run: "set -euo pipefail\n[ -f census.tsv ] || { echo \"no census produced\"; exit 0; }\n{\n echo '### standards pin census'\n echo\n echo '| status | files |'\n echo '|---|---|'\n awk -F'\\t' 'NR>1{c[$3]++} END{for(k in c) printf \"| %s | %d |\\n\", k, c[k]}' census.tsv | sort\n} >> \"$GITHUB_STEP_SUMMARY\"\n", + }, + { + name: "Upload the census", + if: "always()", + uses: "actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a", # v7.0.1 + with: { + name: "workflow-pin-census", + path: "census.tsv", + if-no-files-found: "warn", + }, + }, + ], + }, + }, +} diff --git a/.github/workflows/boj-build.yml b/.github/workflows/boj-build.yml index 529d95e0a..1fa354f9c 100644 --- a/.github/workflows/boj-build.yml +++ b/.github/workflows/boj-build.yml @@ -7,59 +7,54 @@ # It is a no-op if BOJ_SERVER_URL is not set or the server is unreachable. # To enable: set BOJ_SERVER_URL as a repository secret or variable. # To disable: delete this file or leave BOJ_SERVER_URL unset. -name: BoJ Server Build Trigger - -on: - push: - branches: [main, master] - workflow_dispatch: - -# Estate guardrail: cancel superseded runs so re-pushes / rebased PR -# updates do not pile up queued runs against the shared account-wide -# Actions concurrency pool. Applied only to read-only check workflows -# (no publish/mutation), so cancelling a superseded run is always safe. -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -permissions: - actions: read - contents: read - -jobs: - trigger-boj: - name: Trigger BoJ server - runs-on: ubuntu-latest - timeout-minutes: 10 - # No job-level gate: the `secrets` context is not available in `if:` - # (referencing it is an "Unrecognized named-value: 'secrets'" startup - # failure). The Trigger step below already self-gates — it exits 0 when - # BOJ_URL (secret-or-var) is empty — so the whole job is a cheap no-op - # when unconfigured. - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - name: Trigger BoJ Server (Casket/ssg-mcp) - env: - BOJ_URL: ${{ secrets.BOJ_SERVER_URL || vars.BOJ_SERVER_URL }} - REPO_NAME: ${{ github.repository }} - BRANCH_NAME: ${{ github.ref_name }} - run: | - set -euo pipefail - - if [ -z "$BOJ_URL" ]; then - echo "BOJ_SERVER_URL not configured - skipping" - exit 0 - fi - - payload="$(jq -cn \ - --arg repo "$REPO_NAME" \ - --arg branch "$BRANCH_NAME" \ - --arg engine "casket" \ - '{repo:$repo, branch:$branch, engine:$engine}')" - - curl -sf -X POST "${BOJ_URL}/cartridges/ssg-mcp/invoke" \ - -H "Content-Type: application/json" \ - --data "$payload" \ - || echo "BoJ server unreachable - skipping (non-fatal)" +{ + name: "BoJ Server Build Trigger", + on: { + push: { + branches: [ + "main", + "master", + ], + }, + workflow_dispatch: null, + }, + # Estate guardrail: cancel superseded runs so re-pushes / rebased PR + # updates do not pile up queued runs against the shared account-wide + # Actions concurrency pool. Applied only to read-only check workflows + # (no publish/mutation), so cancelling a superseded run is always safe. + concurrency: { + group: "${{ github.workflow }}-${{ github.ref }}", + cancel-in-progress: true, + }, + permissions: { + actions: "read", + contents: "read", + }, + jobs: { + trigger-boj: { + name: "Trigger BoJ server", + runs-on: "ubuntu-latest", + timeout-minutes: 10, + # No job-level gate: the `secrets` context is not available in `if:` + # (referencing it is an "Unrecognized named-value: 'secrets'" startup + # failure). The Trigger step below already self-gates — it exits 0 when + # BOJ_URL (secret-or-var) is empty — so the whole job is a cheap no-op + # when unconfigured. + steps: [ + { + name: "Checkout", + uses: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", # v7.0.1 + }, + { + name: "Trigger BoJ Server (Casket/ssg-mcp)", + env: { + BOJ_URL: "${{ secrets.BOJ_SERVER_URL || vars.BOJ_SERVER_URL }}", + REPO_NAME: "${{ github.repository }}", + BRANCH_NAME: "${{ github.ref_name }}", + }, + run: "set -euo pipefail\n\nif [ -z \"$BOJ_URL\" ]; then\n echo \"BOJ_SERVER_URL not configured - skipping\"\n exit 0\nfi\n\npayload=\"$(jq -cn \\\n --arg repo \"$REPO_NAME\" \\\n --arg branch \"$BRANCH_NAME\" \\\n --arg engine \"casket\" \\\n '{repo:$repo, branch:$branch, engine:$engine}')\"\n\ncurl -sf -X POST \"${BOJ_URL}/cartridges/ssg-mcp/invoke\" \\\n -H \"Content-Type: application/json\" \\\n --data \"$payload\" \\\n || echo \"BoJ server unreachable - skipping (non-fatal)\"\n", + }, + ], + }, + }, +} diff --git a/.github/workflows/canon-spine-lockstep.yml b/.github/workflows/canon-spine-lockstep.yml index 0dc702a06..f1e4cbacb 100644 --- a/.github/workflows/canon-spine-lockstep.yml +++ b/.github/workflows/canon-spine-lockstep.yml @@ -19,178 +19,158 @@ # YOU MAY NOT TIGHTEN THE CRITERIA UNTIL THE REFERENCE IMPLEMENTATION # PASSES THEM. # -name: Canon / Spine Lockstep - -on: - push: - branches: [ main, master ] - paths: - - 'canon.lock' - - 'standards-map.toml' - - '0-canon/rsr/rsr-criteria-v2.a2ml' - - '.machine_readable/template-capability-gates.toml' - - '0-canon/TEMPLATE-APPLICABILITY-POLICY.adoc' - - '0-canon/rsr/SCAFFOLD-LIFECYCLE.adoc' - - '0-canon/constitution/**' - - 'scripts/check-canon-lockstep.sh' - - 'scripts/check-standards-map.sh' - - '.github/workflows/canon-spine-lockstep.yml' - pull_request: - branches: [ main, master ] - paths: - - 'canon.lock' - - 'standards-map.toml' - - '0-canon/rsr/**' - - '.machine_readable/**' - - '0-canon/TEMPLATE-APPLICABILITY-POLICY.adoc' - - '0-canon/constitution/**' - - 'scripts/check-canon-lockstep.sh' - - 'scripts/check-standards-map.sh' - workflow_dispatch: - inputs: - strict: - description: 'Promote lockstep assertions 3/4/5 from SKIP to FAIL' - type: boolean - default: false - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -# Least privilege: all three jobs only check out and run local scripts — -# `actions: read` was dead (nothing calls the Actions API). -permissions: - contents: read - -jobs: - # --------------------------------------------------------------------------- - # Gate A — the canon and the spine are on the same law. - # --------------------------------------------------------------------------- - lockstep: - name: Canon / spine lockstep - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - name: Checkout canon - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - path: canon - # Assertion 2 diffs the canon against its base ref, and assertion 1 - # hashes directories with `git ls-files -s`, so history must be real. - fetch-depth: 0 - - - name: Checkout spine - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: hyperpolymath/rsr-template-repo - path: spine - fetch-depth: 1 - persist-credentials: false - - - name: Determine base ref for the version-bump assertion - id: base - run: | - if [ "${{ github.event_name }}" = "pull_request" ]; then - echo "ref=origin/${{ github.base_ref }}" >> "$GITHUB_OUTPUT" - else - echo "ref=HEAD~1" >> "$GITHUB_OUTPUT" - fi - - - name: Run Gate A - id: gate - env: - # Assertion 4 reads the spine's last dogfood-gate conclusion. Without - # a token that call is unauthenticated and rate-limited; the script - # degrades to SKIP rather than passing silently. - GH_TOKEN: ${{ github.token }} - run: | - set +e - args=( --canon canon --spine spine --base "${{ steps.base.outputs.ref }}" ) - if [ "${{ inputs.strict }}" = "true" ]; then args+=( --strict ); fi - bash canon/scripts/check-canon-lockstep.sh "${args[@]}" | tee "$RUNNER_TEMP/gate-a.txt" - rc=${PIPESTATUS[0]} - echo "rc=$rc" >> "$GITHUB_OUTPUT" - exit "$rc" - - - name: Summarise - if: always() - run: | - { - echo "## Gate A — Canon / Spine Lockstep" - echo "" - echo '```' - cat "$RUNNER_TEMP/gate-a.txt" 2>/dev/null || echo "(no output)" - echo '```' - if [ "${{ steps.gate.outputs.rc }}" = "0" ]; then - echo ":white_check_mark: The canon and the spine are on the same law." - else - echo ":x: **Gate A failed.**" - echo "" - echo "This is not necessarily a bad change. It is very often a change" - echo "made in the **wrong order**: \`canon.lock [canon.lockstep].order\`" - echo "is \`spine-adopts-then-canon-releases\`. Land the spine's" - echo "adoption first, then the canon change becomes a one-line bump." - fi - } >> "$GITHUB_STEP_SUMMARY" - - # --------------------------------------------------------------------------- - # Gate D — the map of this repository is complete and honest, in BOTH - # directions. Bidirectional deliberately: the template's root-allow.txt - # learned that "a one-directional allowlist only ever ratchets open" after a - # root cleanup left stale permissions behind and the allowlist "quietly - # became a licence for the very drift it was written to prevent". - # --------------------------------------------------------------------------- - map: - name: Standards map integrity - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - name: Checkout repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - fetch-depth: 0 - - - name: Run Gate D - run: | - if ! bash scripts/check-standards-map.sh --repo .; then - { - echo "### Standards map drift" - echo "" - echo "Every top-level entry must have an \`[[entry]]\` in" - echo "\`standards-map.toml\`, and every \`[[entry]]\` must point at a" - echo "path that exists. Add or remove the record — do not exempt it." - } >> "$GITHUB_STEP_SUMMARY" - exit 1 - fi - - # --------------------------------------------------------------------------- - # The canon's own conformance. Before this change the repository that SHIPS - # scripts/check-rsr-profile.sh exited 2 on itself: "no profile at - # ./.machine_readable/rsr-profile.a2ml". The law was not subject to the law. - # --------------------------------------------------------------------------- - self-conformance: - name: Canon self-conformance - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - name: Checkout repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - name: Canon is subject to its own gate table - run: | - if ! bash scripts/check-rsr-profile.sh .; then - { - echo "### Canon self-conformance failed" - echo "" - echo "This repository declares \`role = \"canon\"\` and an honest" - echo "capability set. Either the scaffold has drifted from the" - echo "declaration, or the declaration is wrong." - echo "" - echo "Do NOT add a vestigial path to \`[canon]\` in the gate table to" - echo "silence this — that section exists for law artefacts the" - echo "canon carries BY NATURE (the criteria, this gate table, the" - echo "reusable gates other repos call, proof artefacts of the" - echo "estate), not as an escape hatch." - } >> "$GITHUB_STEP_SUMMARY" - exit 1 - fi +{ + name: "Canon / Spine Lockstep", + on: { + push: { + branches: [ + "main", + "master", + ], + paths: [ + "canon.lock", + "standards-map.toml", + "0-canon/rsr/rsr-criteria-v2.a2ml", + ".machine_readable/template-capability-gates.toml", + "0-canon/TEMPLATE-APPLICABILITY-POLICY.adoc", + "0-canon/rsr/SCAFFOLD-LIFECYCLE.adoc", + "0-canon/constitution/**", + "scripts/check-canon-lockstep.sh", + "scripts/check-standards-map.sh", + ".github/workflows/canon-spine-lockstep.yml", + ], + }, + pull_request: { + branches: [ + "main", + "master", + ], + paths: [ + "canon.lock", + "standards-map.toml", + "0-canon/rsr/**", + ".machine_readable/**", + "0-canon/TEMPLATE-APPLICABILITY-POLICY.adoc", + "0-canon/constitution/**", + "scripts/check-canon-lockstep.sh", + "scripts/check-standards-map.sh", + ], + }, + workflow_dispatch: { + inputs: { + strict: { + description: "Promote lockstep assertions 3/4/5 from SKIP to FAIL", + type: "boolean", + default: false, + }, + }, + }, + }, + concurrency: { + group: "${{ github.workflow }}-${{ github.ref }}", + cancel-in-progress: true, + }, + # Least privilege: all three jobs only check out and run local scripts — + # `actions: read` was dead (nothing calls the Actions API). + permissions: { + contents: "read", + }, + jobs: { + # --------------------------------------------------------------------------- + # Gate A — the canon and the spine are on the same law. + # --------------------------------------------------------------------------- + lockstep: { + name: "Canon / spine lockstep", + runs-on: "ubuntu-latest", + timeout-minutes: 10, + steps: [ + { + name: "Checkout canon", + uses: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", # v7.0.1 + with: { + path: "canon", + # Assertion 2 diffs the canon against its base ref, and assertion 1 + # hashes directories with `git ls-files -s`, so history must be real. + fetch-depth: 0, + }, + }, + { + name: "Checkout spine", + uses: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", # v7.0.1 + with: { + repository: "hyperpolymath/rsr-template-repo", + path: "spine", + fetch-depth: 1, + persist-credentials: false, + }, + }, + { + name: "Determine base ref for the version-bump assertion", + id: "base", + run: "if [ \"${{ github.event_name }}\" = \"pull_request\" ]; then\n echo \"ref=origin/${{ github.base_ref }}\" >> \"$GITHUB_OUTPUT\"\nelse\n echo \"ref=HEAD~1\" >> \"$GITHUB_OUTPUT\"\nfi\n", + }, + { + name: "Run Gate A", + id: "gate", + env: { + # Assertion 4 reads the spine's last dogfood-gate conclusion. Without + # a token that call is unauthenticated and rate-limited; the script + # degrades to SKIP rather than passing silently. + GH_TOKEN: "${{ github.token }}", + }, + run: "set +e\nargs=( --canon canon --spine spine --base \"${{ steps.base.outputs.ref }}\" )\nif [ \"${{ inputs.strict }}\" = \"true\" ]; then args+=( --strict ); fi\nbash canon/scripts/check-canon-lockstep.sh \"${args[@]}\" | tee \"$RUNNER_TEMP/gate-a.txt\"\nrc=${PIPESTATUS[0]}\necho \"rc=$rc\" >> \"$GITHUB_OUTPUT\"\nexit \"$rc\"\n", + }, + { + name: "Summarise", + if: "always()", + run: "{\n echo \"## Gate A — Canon / Spine Lockstep\"\n echo \"\"\n echo '```'\n cat \"$RUNNER_TEMP/gate-a.txt\" 2>/dev/null || echo \"(no output)\"\n echo '```'\n if [ \"${{ steps.gate.outputs.rc }}\" = \"0\" ]; then\n echo \":white_check_mark: The canon and the spine are on the same law.\"\n else\n echo \":x: **Gate A failed.**\"\n echo \"\"\n echo \"This is not necessarily a bad change. It is very often a change\"\n echo \"made in the **wrong order**: \\`canon.lock [canon.lockstep].order\\`\"\n echo \"is \\`spine-adopts-then-canon-releases\\`. Land the spine's\"\n echo \"adoption first, then the canon change becomes a one-line bump.\"\n fi\n} >> \"$GITHUB_STEP_SUMMARY\"\n", + }, + ], + }, + # --------------------------------------------------------------------------- + # Gate D — the map of this repository is complete and honest, in BOTH + # directions. Bidirectional deliberately: the template's root-allow.txt + # learned that "a one-directional allowlist only ever ratchets open" after a + # root cleanup left stale permissions behind and the allowlist "quietly + # became a licence for the very drift it was written to prevent". + # --------------------------------------------------------------------------- + map: { + name: "Standards map integrity", + runs-on: "ubuntu-latest", + timeout-minutes: 10, + steps: [ + { + name: "Checkout repository", + uses: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", # v7.0.1 + with: { + fetch-depth: 0, + }, + }, + { + name: "Run Gate D", + run: "if ! bash scripts/check-standards-map.sh --repo .; then\n {\n echo \"### Standards map drift\"\n echo \"\"\n echo \"Every top-level entry must have an \\`[[entry]]\\` in\"\n echo \"\\`standards-map.toml\\`, and every \\`[[entry]]\\` must point at a\"\n echo \"path that exists. Add or remove the record — do not exempt it.\"\n } >> \"$GITHUB_STEP_SUMMARY\"\n exit 1\nfi\n", + }, + ], + }, + # --------------------------------------------------------------------------- + # The canon's own conformance. Before this change the repository that SHIPS + # scripts/check-rsr-profile.sh exited 2 on itself: "no profile at + # ./.machine_readable/rsr-profile.a2ml". The law was not subject to the law. + # --------------------------------------------------------------------------- + self-conformance: { + name: "Canon self-conformance", + runs-on: "ubuntu-latest", + timeout-minutes: 10, + steps: [ + { + name: "Checkout repository", + uses: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", # v7.0.1 + }, + { + name: "Canon is subject to its own gate table", + run: "if ! bash scripts/check-rsr-profile.sh .; then\n {\n echo \"### Canon self-conformance failed\"\n echo \"\"\n echo \"This repository declares \\`role = \\\"canon\\\"\\` and an honest\"\n echo \"capability set. Either the scaffold has drifted from the\"\n echo \"declaration, or the declaration is wrong.\"\n echo \"\"\n echo \"Do NOT add a vestigial path to \\`[canon]\\` in the gate table to\"\n echo \"silence this — that section exists for law artefacts the\"\n echo \"canon carries BY NATURE (the criteria, this gate table, the\"\n echo \"reusable gates other repos call, proof artefacts of the\"\n echo \"estate), not as an escape hatch.\"\n } >> \"$GITHUB_STEP_SUMMARY\"\n exit 1\nfi\n", + }, + ], + }, + }, +} diff --git a/.github/workflows/casket-pages.yml b/.github/workflows/casket-pages.yml index 6e244a23c..410738de5 100644 --- a/.github/workflows/casket-pages.yml +++ b/.github/workflows/casket-pages.yml @@ -1,103 +1,96 @@ # This workflow is managed by gh actions-lock. # SPDX-License-Identifier: MPL-2.0 # This workflow is managed by gh actions-lock. -name: GitHub Pages - -on: - push: - branches: [main] - workflow_dispatch: - -permissions: - actions: read - contents: read - pages: write - id-token: write - -concurrency: - group: "pages" - cancel-in-progress: false - -jobs: - build: - timeout-minutes: 20 - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4 - - - name: Checkout casket-ssg - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4 - with: - repository: hyperpolymath/casket-ssg - path: .casket-ssg - - - name: Setup GHCup - uses: haskell-actions/setup@0f8e8c99d88aeb3fbfd523f1ef2c6f762d10d64d # v2.12.1 - with: - ghc-version: '9.8.2' - cabal-version: '3.10' - - - name: Cache Cabal - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v4 - with: - path: | - ~/.cabal/packages - ~/.cabal/store - .casket-ssg/dist-newstyle - key: ${{ runner.os }}-casket-${{ hashFiles('.casket-ssg/casket-ssg.cabal') }} - - - name: Build casket-ssg - working-directory: .casket-ssg - run: cabal build - - - name: Build site - run: | - mkdir -p site _site - # Generate index.md from README if site/index.md doesn't exist - if [ ! -f site/index.md ]; then - if [ -f README.adoc ]; then - # Convert AsciiDoc to Markdown (basic conversion) - echo "---" > site/index.md - echo "title: $(basename $PWD)" >> site/index.md - echo "date: $(date +%Y-%m-%d)" >> site/index.md - echo "---" >> site/index.md - cat README.adoc >> site/index.md - elif [ -f README.md ]; then - echo "---" > site/index.md - echo "title: $(basename $PWD)" >> site/index.md - echo "date: $(date +%Y-%m-%d)" >> site/index.md - echo "---" >> site/index.md - cat README.md >> site/index.md - else - echo "---" > site/index.md - echo "title: $(basename $PWD)" >> site/index.md - echo "date: $(date +%Y-%m-%d)" >> site/index.md - echo "---" >> site/index.md - echo "" >> site/index.md - echo "# $(basename $PWD)" >> site/index.md - echo "" >> site/index.md - echo "Documentation coming soon." >> site/index.md - fi - fi - cd .casket-ssg && cabal run casket-ssg -- build ../site ../_site - - - name: Setup Pages - uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0 - - - name: Upload artifact - uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v3 - with: - path: '_site' - - deploy: - timeout-minutes: 20 - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - runs-on: ubuntu-latest - needs: build - steps: - - name: Deploy to GitHub Pages - id: deployment - uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1 +{ + name: "GitHub Pages", + on: { + push: { + branches: [ + "main", + ], + }, + workflow_dispatch: null, + }, + permissions: { + actions: "read", + contents: "read", + pages: "write", + id-token: "write", + }, + concurrency: { + group: "pages", + cancel-in-progress: false, + }, + jobs: { + build: { + timeout-minutes: 20, + runs-on: "ubuntu-latest", + steps: [ + { + name: "Checkout", + uses: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", # v4 + }, + { + name: "Checkout casket-ssg", + uses: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", # v4 + with: { + repository: "hyperpolymath/casket-ssg", + path: ".casket-ssg", + }, + }, + { + name: "Setup GHCup", + uses: "haskell-actions/setup@0f8e8c99d88aeb3fbfd523f1ef2c6f762d10d64d", # v2.12.1 + with: { + ghc-version: "9.8.2", + cabal-version: "3.10", + }, + }, + { + name: "Cache Cabal", + uses: "actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9", # v4 + with: { + path: "~/.cabal/packages\n~/.cabal/store\n.casket-ssg/dist-newstyle\n", + key: "${{ runner.os }}-casket-${{ hashFiles('.casket-ssg/casket-ssg.cabal') }}", + }, + }, + { + name: "Build casket-ssg", + working-directory: ".casket-ssg", + run: "cabal build", + }, + { + name: "Build site", + run: "mkdir -p site _site\n# Generate index.md from README if site/index.md doesn't exist\nif [ ! -f site/index.md ]; then\n if [ -f README.adoc ]; then\n # Convert AsciiDoc to Markdown (basic conversion)\n echo \"---\" > site/index.md\n echo \"title: $(basename $PWD)\" >> site/index.md\n echo \"date: $(date +%Y-%m-%d)\" >> site/index.md\n echo \"---\" >> site/index.md\n cat README.adoc >> site/index.md\n elif [ -f README.md ]; then\n echo \"---\" > site/index.md\n echo \"title: $(basename $PWD)\" >> site/index.md\n echo \"date: $(date +%Y-%m-%d)\" >> site/index.md\n echo \"---\" >> site/index.md\n cat README.md >> site/index.md\n else\n echo \"---\" > site/index.md\n echo \"title: $(basename $PWD)\" >> site/index.md\n echo \"date: $(date +%Y-%m-%d)\" >> site/index.md\n echo \"---\" >> site/index.md\n echo \"\" >> site/index.md\n echo \"# $(basename $PWD)\" >> site/index.md\n echo \"\" >> site/index.md\n echo \"Documentation coming soon.\" >> site/index.md\n fi\nfi\ncd .casket-ssg && cabal run casket-ssg -- build ../site ../_site\n", + }, + { + name: "Setup Pages", + uses: "actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d", # v6.0.0 + }, + { + name: "Upload artifact", + uses: "actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9", # v3 + with: { + path: "_site", + }, + }, + ], + }, + deploy: { + timeout-minutes: 20, + environment: { + name: "github-pages", + url: "${{ steps.deployment.outputs.page_url }}", + }, + runs-on: "ubuntu-latest", + needs: "build", + steps: [ + { + name: "Deploy to GitHub Pages", + id: "deployment", + uses: "actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346", # v5.0.1 + }, + ], + }, + }, +} diff --git a/.github/workflows/changelog-reusable.yml b/.github/workflows/changelog-reusable.yml index c3b238e1c..9e704427b 100644 --- a/.github/workflows/changelog-reusable.yml +++ b/.github/workflows/changelog-reusable.yml @@ -43,229 +43,136 @@ # `commit-back` mode callers (where the auto commit lands directly on # main without a squash step) and provides defense-in-depth for pr-back. -on: - workflow_call: - inputs: - mode: - description: 'commit-back | pr-back | release-only | check-only' - required: false - type: string - default: 'commit-back' - runs-on: - description: 'Runner label' - required: false - type: string - default: 'ubuntu-latest' - git-cliff-version: - description: 'Version of git-cliff to install (semver, no leading v)' - required: false - type: string - default: '2.6.1' - -permissions: - actions: read - contents: read - -jobs: - guard: - timeout-minutes: 10 - name: Check for self-trigger loop - runs-on: ${{ inputs.runs-on }} - outputs: - skip: ${{ steps.loopguard.outputs.skip }} - steps: - - name: Checkout caller (HEAD only is enough for the guard) - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: ${{ github.repository }} - ref: ${{ github.ref }} - fetch-depth: 1 - path: caller - - - name: Loop guard - id: loopguard - working-directory: caller - run: | - set -euo pipefail - # If the push that triggered us is itself a changelog regeneration - # (commit-back's direct commit, or a squash-merged pr-back PR whose - # body still contains the [skip changelog] marker placed below), - # skip the whole generate job. cliff.toml's subject-matcher is the - # primary fix; this guard handles cases where the marker is on the - # body (e.g. commit-back mode, rebase-merges, mergers who preserve - # the PR body). - if git log -1 --pretty=%B | grep -q '\[skip changelog\]'; then - echo "HEAD commit carries [skip changelog]; skipping generate job to break the regenerate loop." - echo "skip=true" >> "$GITHUB_OUTPUT" - else - echo "skip=false" >> "$GITHUB_OUTPUT" - fi - - generate: - needs: guard - if: ${{ needs.guard.outputs.skip != 'true' }} - timeout-minutes: 10 - name: Generate CHANGELOG.md - runs-on: ${{ inputs.runs-on }} - permissions: - contents: write - pull-requests: write - steps: - - name: Checkout caller repository (full history) - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: ${{ github.repository }} - ref: ${{ github.ref }} - fetch-depth: 0 - path: caller - - - name: Checkout standards (for canonical cliff.toml) - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: hyperpolymath/standards - ref: main - path: standards - - - name: Install git-cliff - run: | - set -euo pipefail - version="${{ inputs.git-cliff-version }}" - asset="git-cliff-${version}-x86_64-unknown-linux-gnu.tar.gz" - url="https://github.com/orhun/git-cliff/releases/download/v${version}/${asset}" - tmp="$(mktemp -d)" - curl -fsSL "$url" -o "$tmp/${asset}" - tar -C "$tmp" -xzf "$tmp/${asset}" - # Move binary onto PATH (the tarball extracts to a versioned dir). - install -m 0755 "$tmp"/git-cliff-*/git-cliff /usr/local/bin/git-cliff - git-cliff --version - - - name: Pick the cliff.toml to use - id: cfg - run: | - set -euo pipefail - if [ -f caller/cliff.toml ]; then - echo "Using caller's own cliff.toml" - echo "path=caller/cliff.toml" >> "$GITHUB_OUTPUT" - else - echo "Using canonical cliff.toml from standards" - echo "path=standards/templates/cliff.toml" >> "$GITHUB_OUTPUT" - fi - - - name: Generate CHANGELOG.md - working-directory: caller - run: | - set -euo pipefail - git-cliff \ - --config "../${{ steps.cfg.outputs.path }}" \ - --output CHANGELOG.md.new - wc -l CHANGELOG.md.new - echo "::group::CHANGELOG preview (first 40 lines)" - head -40 CHANGELOG.md.new - echo "::endgroup::" - - - name: Mode = check-only — verify no drift - if: ${{ inputs.mode == 'check-only' }} - working-directory: caller - run: | - set -euo pipefail - if [ ! -f CHANGELOG.md ]; then - echo "ERROR: caller has no CHANGELOG.md; check-only mode requires one to exist." - echo "Adopt mode=commit-back to seed it, then switch to check-only." - exit 1 - fi - if ! diff -q CHANGELOG.md CHANGELOG.md.new >/dev/null; then - echo "ERROR: CHANGELOG.md is out of date relative to commit history." - echo "Run git-cliff locally or switch to mode=commit-back." - diff -u CHANGELOG.md CHANGELOG.md.new | head -60 - exit 1 - fi - echo "CHANGELOG.md is up to date." - - - name: Mode = commit-back — commit regenerated CHANGELOG.md - if: ${{ inputs.mode == 'commit-back' && github.event_name == 'push' }} - working-directory: caller - run: | - set -euo pipefail - if [ -f CHANGELOG.md ] && diff -q CHANGELOG.md CHANGELOG.md.new >/dev/null; then - echo "No CHANGELOG changes; skipping commit." - exit 0 - fi - mv CHANGELOG.md.new CHANGELOG.md - git config user.email "github-actions[bot]@users.noreply.github.com" - git config user.name "github-actions[bot]" - git add CHANGELOG.md - if git diff --cached --quiet; then - echo "Nothing to commit after move (race condition)." - exit 0 - fi - git commit -m "chore(changelog): regenerate from conventional commits - - [skip changelog] - - Auto-generated by hyperpolymath/standards changelog-reusable.yml. - See standards/templates/cliff.toml for the canonical config. - - Closes part of the 2026-05-26 CHANGELOG gap (standards#197 audit)." - git push origin HEAD:${{ github.ref_name }} - - - name: Mode = pr-back — open PR with regenerated CHANGELOG.md - if: ${{ inputs.mode == 'pr-back' && github.event_name == 'push' }} - working-directory: caller - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - if [ -f CHANGELOG.md ] && diff -q CHANGELOG.md CHANGELOG.md.new >/dev/null; then - echo "No CHANGELOG changes; skipping PR." - exit 0 - fi - mv CHANGELOG.md.new CHANGELOG.md - branch="bot/changelog-$(date +%Y%m%d-%H%M%S)" - git config user.email "github-actions[bot]@users.noreply.github.com" - git config user.name "github-actions[bot]" - git checkout -b "$branch" - git add CHANGELOG.md - git commit -m "chore(changelog): regenerate from conventional commits - - [skip changelog]" - git push -u origin "$branch" - # Before opening a new PR, close any older open bot/changelog-* PRs - # against the same base so we don't accumulate duplicate PRs if a - # maintainer hasn't merged the previous one before another push lands. - for pr in $(gh pr list --repo "${{ github.repository }}" \ - --base "${{ github.ref_name }}" \ - --head 'bot/changelog-' \ - --state open \ - --json number,headRefName \ - --jq '.[] | select(.headRefName != "'"$branch"'") | .number'); do - echo "Closing superseded changelog PR #$pr" - gh pr close "$pr" --repo "${{ github.repository }}" \ - --comment "Superseded by a newer changelog regeneration PR." - done - gh pr create \ - --repo "${{ github.repository }}" \ - --title "chore(changelog): regenerate from conventional commits" \ - --body "Auto-generated by hyperpolymath/standards changelog-reusable.yml. - - [skip changelog] - - This PR body carries the [skip changelog] marker so that a - squash-merge of this PR will not re-trigger the changelog - workflow (loop-prevention added in response to maa-framework#219). - Please do not remove that marker when editing the description. - - Closes part of the 2026-05-26 CHANGELOG gap (standards#197 audit)." \ - --base "${{ github.ref_name }}" \ - --head "$branch" - - - name: Mode = release-only — attach to release - if: ${{ inputs.mode == 'release-only' && github.event_name == 'release' }} - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - tag="${{ github.event.release.tag_name }}" - gh release upload "$tag" caller/CHANGELOG.md.new \ - --clobber \ - --repo "${{ github.repository }}" - echo "Attached CHANGELOG.md to release $tag" +{ + on: { + workflow_call: { + inputs: { + mode: { + description: "commit-back | pr-back | release-only | check-only", + required: false, + type: "string", + default: "commit-back", + }, + runs-on: { + description: "Runner label", + required: false, + type: "string", + default: "ubuntu-latest", + }, + git-cliff-version: { + description: "Version of git-cliff to install (semver, no leading v)", + required: false, + type: "string", + default: "2.6.1", + }, + }, + }, + }, + permissions: { + actions: "read", + contents: "read", + }, + jobs: { + guard: { + timeout-minutes: 10, + name: "Check for self-trigger loop", + runs-on: "${{ inputs.runs-on }}", + outputs: { + skip: "${{ steps.loopguard.outputs.skip }}", + }, + steps: [ + { + name: "Checkout caller (HEAD only is enough for the guard)", + uses: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", # v7.0.1 + with: { + repository: "${{ github.repository }}", + ref: "${{ github.ref }}", + fetch-depth: 1, + path: "caller", + }, + }, + { + name: "Loop guard", + id: "loopguard", + working-directory: "caller", + run: "set -euo pipefail\n# If the push that triggered us is itself a changelog regeneration\n# (commit-back's direct commit, or a squash-merged pr-back PR whose\n# body still contains the [skip changelog] marker placed below),\n# skip the whole generate job. cliff.toml's subject-matcher is the\n# primary fix; this guard handles cases where the marker is on the\n# body (e.g. commit-back mode, rebase-merges, mergers who preserve\n# the PR body).\nif git log -1 --pretty=%B | grep -q '\\[skip changelog\\]'; then\n echo \"HEAD commit carries [skip changelog]; skipping generate job to break the regenerate loop.\"\n echo \"skip=true\" >> \"$GITHUB_OUTPUT\"\nelse\n echo \"skip=false\" >> \"$GITHUB_OUTPUT\"\nfi\n", + }, + ], + }, + generate: { + needs: "guard", + if: "${{ needs.guard.outputs.skip != 'true' }}", + timeout-minutes: 10, + name: "Generate CHANGELOG.md", + runs-on: "${{ inputs.runs-on }}", + permissions: { + contents: "write", + pull-requests: "write", + }, + steps: [ + { + name: "Checkout caller repository (full history)", + uses: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", # v7.0.1 + with: { + repository: "${{ github.repository }}", + ref: "${{ github.ref }}", + fetch-depth: 0, + path: "caller", + }, + }, + { + name: "Checkout standards (for canonical cliff.toml)", + uses: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", # v7.0.1 + with: { + repository: "hyperpolymath/standards", + ref: "main", + path: "standards", + }, + }, + { + name: "Install git-cliff", + run: "set -euo pipefail\nversion=\"${{ inputs.git-cliff-version }}\"\nasset=\"git-cliff-${version}-x86_64-unknown-linux-gnu.tar.gz\"\nurl=\"https://github.com/orhun/git-cliff/releases/download/v${version}/${asset}\"\ntmp=\"$(mktemp -d)\"\ncurl -fsSL \"$url\" -o \"$tmp/${asset}\"\ntar -C \"$tmp\" -xzf \"$tmp/${asset}\"\n# Move binary onto PATH (the tarball extracts to a versioned dir).\ninstall -m 0755 \"$tmp\"/git-cliff-*/git-cliff /usr/local/bin/git-cliff\ngit-cliff --version\n", + }, + { + name: "Pick the cliff.toml to use", + id: "cfg", + run: "set -euo pipefail\nif [ -f caller/cliff.toml ]; then\n echo \"Using caller's own cliff.toml\"\n echo \"path=caller/cliff.toml\" >> \"$GITHUB_OUTPUT\"\nelse\n echo \"Using canonical cliff.toml from standards\"\n echo \"path=standards/templates/cliff.toml\" >> \"$GITHUB_OUTPUT\"\nfi\n", + }, + { + name: "Generate CHANGELOG.md", + working-directory: "caller", + run: "set -euo pipefail\ngit-cliff \\\n --config \"../${{ steps.cfg.outputs.path }}\" \\\n --output CHANGELOG.md.new\nwc -l CHANGELOG.md.new\necho \"::group::CHANGELOG preview (first 40 lines)\"\nhead -40 CHANGELOG.md.new\necho \"::endgroup::\"\n", + }, + { + name: "Mode = check-only — verify no drift", + if: "${{ inputs.mode == 'check-only' }}", + working-directory: "caller", + run: "set -euo pipefail\nif [ ! -f CHANGELOG.md ]; then\n echo \"ERROR: caller has no CHANGELOG.md; check-only mode requires one to exist.\"\n echo \"Adopt mode=commit-back to seed it, then switch to check-only.\"\n exit 1\nfi\nif ! diff -q CHANGELOG.md CHANGELOG.md.new >/dev/null; then\n echo \"ERROR: CHANGELOG.md is out of date relative to commit history.\"\n echo \"Run git-cliff locally or switch to mode=commit-back.\"\n diff -u CHANGELOG.md CHANGELOG.md.new | head -60\n exit 1\nfi\necho \"CHANGELOG.md is up to date.\"\n", + }, + { + name: "Mode = commit-back — commit regenerated CHANGELOG.md", + if: "${{ inputs.mode == 'commit-back' && github.event_name == 'push' }}", + working-directory: "caller", + run: "set -euo pipefail\nif [ -f CHANGELOG.md ] && diff -q CHANGELOG.md CHANGELOG.md.new >/dev/null; then\n echo \"No CHANGELOG changes; skipping commit.\"\n exit 0\nfi\nmv CHANGELOG.md.new CHANGELOG.md\ngit config user.email \"github-actions[bot]@users.noreply.github.com\"\ngit config user.name \"github-actions[bot]\"\ngit add CHANGELOG.md\nif git diff --cached --quiet; then\n echo \"Nothing to commit after move (race condition).\"\n exit 0\nfi\ngit commit -m \"chore(changelog): regenerate from conventional commits\n\n[skip changelog]\n\nAuto-generated by hyperpolymath/standards changelog-reusable.yml.\nSee standards/templates/cliff.toml for the canonical config.\n\nCloses part of the 2026-05-26 CHANGELOG gap (standards#197 audit).\"\ngit push origin HEAD:${{ github.ref_name }}\n", + }, + { + name: "Mode = pr-back — open PR with regenerated CHANGELOG.md", + if: "${{ inputs.mode == 'pr-back' && github.event_name == 'push' }}", + working-directory: "caller", + env: { + GH_TOKEN: "${{ github.token }}", + }, + run: "set -euo pipefail\nif [ -f CHANGELOG.md ] && diff -q CHANGELOG.md CHANGELOG.md.new >/dev/null; then\n echo \"No CHANGELOG changes; skipping PR.\"\n exit 0\nfi\nmv CHANGELOG.md.new CHANGELOG.md\nbranch=\"bot/changelog-$(date +%Y%m%d-%H%M%S)\"\ngit config user.email \"github-actions[bot]@users.noreply.github.com\"\ngit config user.name \"github-actions[bot]\"\ngit checkout -b \"$branch\"\ngit add CHANGELOG.md\ngit commit -m \"chore(changelog): regenerate from conventional commits\n\n[skip changelog]\"\ngit push -u origin \"$branch\"\n# Before opening a new PR, close any older open bot/changelog-* PRs\n# against the same base so we don't accumulate duplicate PRs if a\n# maintainer hasn't merged the previous one before another push lands.\nfor pr in $(gh pr list --repo \"${{ github.repository }}\" \\\n --base \"${{ github.ref_name }}\" \\\n --head 'bot/changelog-' \\\n --state open \\\n --json number,headRefName \\\n --jq '.[] | select(.headRefName != \"'\"$branch\"'\") | .number'); do\n echo \"Closing superseded changelog PR #$pr\"\n gh pr close \"$pr\" --repo \"${{ github.repository }}\" \\\n --comment \"Superseded by a newer changelog regeneration PR.\"\ndone\ngh pr create \\\n --repo \"${{ github.repository }}\" \\\n --title \"chore(changelog): regenerate from conventional commits\" \\\n --body \"Auto-generated by hyperpolymath/standards changelog-reusable.yml.\n\n [skip changelog]\n\n This PR body carries the [skip changelog] marker so that a\n squash-merge of this PR will not re-trigger the changelog\n workflow (loop-prevention added in response to maa-framework#219).\n Please do not remove that marker when editing the description.\n\n Closes part of the 2026-05-26 CHANGELOG gap (standards#197 audit).\" \\\n --base \"${{ github.ref_name }}\" \\\n --head \"$branch\"\n", + }, + { + name: "Mode = release-only — attach to release", + if: "${{ inputs.mode == 'release-only' && github.event_name == 'release' }}", + env: { + GH_TOKEN: "${{ github.token }}", + }, + run: "set -euo pipefail\ntag=\"${{ github.event.release.tag_name }}\"\ngh release upload \"$tag\" caller/CHANGELOG.md.new \\\n --clobber \\\n --repo \"${{ github.repository }}\"\necho \"Attached CHANGELOG.md to release $tag\"\n", + }, + ], + }, + }, +} diff --git a/.github/workflows/changelog.yml b/.github/workflows/changelog.yml index 9fad6904f..54269a728 100644 --- a/.github/workflows/changelog.yml +++ b/.github/workflows/changelog.yml @@ -1,23 +1,31 @@ # This workflow is managed by gh actions-lock. # SPDX-License-Identifier: MPL-2.0 # This workflow is managed by gh actions-lock. -name: Changelog - -on: - push: - branches: [main, master] - paths: - - 'CHANGELOG.md' - -permissions: - # Required: the reusable this calls requests `actions: read`. A caller that - # declares `permissions:` at all gets `none` for every key it omits, so - # leaving this out makes GitHub reject the run at startup_failure with zero - # jobs and no log — "requesting 'actions: read', but is only allowed 'actions: none'". - actions: read - contents: read - pull-requests: write - -jobs: - generate: - uses: ./.github/workflows/changelog-reusable.yml +{ + name: "Changelog", + on: { + push: { + branches: [ + "main", + "master", + ], + paths: [ + "CHANGELOG.md", + ], + }, + }, + permissions: { + # Required: the reusable this calls requests `actions: read`. A caller that + # declares `permissions:` at all gets `none` for every key it omits, so + # leaving this out makes GitHub reject the run at startup_failure with zero + # jobs and no log — "requesting 'actions: read', but is only allowed 'actions: none'". + actions: "read", + contents: "read", + pull-requests: "write", + }, + jobs: { + generate: { + uses: "./.github/workflows/changelog-reusable.yml", + }, + }, +} diff --git a/.github/workflows/check-suite-monitor.yml b/.github/workflows/check-suite-monitor.yml index 6d14f927e..276e582f2 100644 --- a/.github/workflows/check-suite-monitor.yml +++ b/.github/workflows/check-suite-monitor.yml @@ -5,262 +5,114 @@ # # Monitors the status of all check suites in the repository to ensure # CI health and catch misconfigurations early. -name: "🟡 CHECK: Check Suite Monitor" - -on: - check_suite: - types: [completed, requested, rerequested] - workflow_run: - workflows: ["🔴 GATE: *", "🟡 CHECK: *"] - types: [completed] - -# Least privilege: both monitor jobs only read the event payload and log it -# (the github-script step makes no API calls — see its "would create an -# issue" comment). Event payloads need no grants; `checks/pull-requests/ -# actions: read` were dead. -permissions: - contents: read - -jobs: - monitor-check-suite: - name: Monitor Check Suite Completion - runs-on: ubuntu-latest - timeout-minutes: 10 - - steps: - - name: Checkout repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - name: Get Check Suite Details - id: check-suite - run: | - echo "🔍 Retrieving check suite information..." - - # Get the check suite that triggered this workflow - CHECK_SUITE_ID="${{ github.event.check_suite.id }}" - REPO="${{ github.repository }}" - - if [ -n "$CHECK_SUITE_ID" ]; then - echo "check_suite_id=$CHECK_SUITE_ID" >> $GITHUB_OUTPUT - - # Get check suite details via GitHub API - # Note: This would require a token with appropriate permissions - # For now, we'll use the context information - - echo "repository=$REPO" >> $GITHUB_OUTPUT - echo "event_type=${{ github.event.action }}" >> $GITHUB_OUTPUT - - # Extract branch information - if [ "${{ github.event.check_suite.pull_requests[0].id }}" != "" ]; then - echo "is_pr=true" >> $GITHUB_OUTPUT - echo "pr_number=${{ github.event.check_suite.pull_requests[0].number }}" >> $GITHUB_OUTPUT - else - echo "is_pr=false" >> $GITHUB_OUTPUT - fi - - echo "head_sha=${{ github.event.check_suite.head_sha }}" >> $GITHUB_OUTPUT - echo "conclusion=${{ github.event.check_suite.conclusion }}" >> $GITHUB_OUTPUT - else - echo "check_suite_id=unknown" >> $GITHUB_OUTPUT - fi - - - name: Analyze Check Suite Results - id: analyze - run: | - echo "🔍 Analyzing check suite results..." - - CONCLUSION="${{ steps.check-suite.outputs.conclusion }}" - IS_PR="${{ steps.check-suite.outputs.is_pr }}" - REPO="${{ steps.check-suite.outputs.repository }}" - - case "$CONCLUSION" in - "success") - echo "status=✅ PASSED" >> $GITHUB_OUTPUT - echo "severity=none" >> $GITHUB_OUTPUT - ;; - "failure") - echo "status=❌ FAILED" >> $GITHUB_OUTPUT - echo "severity=high" >> $GITHUB_OUTPUT - ;; - "neutral") - echo "status=⚪ NEUTRAL" >> $GITHUB_OUTPUT - echo "severity=low" >> $GITHUB_OUTPUT - ;; - "cancelled"|"timed_out") - echo "status=⏹️ ${CONCLUSION^^}" >> $GITHUB_OUTPUT - echo "severity=medium" >> $GITHUB_OUTPUT - ;; - *) - echo "status=❓ $CONCLUSION" >> $GITHUB_OUTPUT - echo "severity=unknown" >> $GITHUB_OUTPUT - ;; - esac - - - name: Check Required GATE Workflows - if: steps.check-suite.outputs.is_pr == 'true' - id: gate-check - run: | - echo "🔍 Checking required 🔴 GATE workflows..." - - # List of required GATE workflows - REQUIRED_GATES=( - "🔴 GATE: Governance" - "🔴 GATE: CodeQL" - "🔴 GATE: Scorecard" - "🔴 GATE: Hypatia Scan" - "🔴 GATE: Secret Scanner" - "🔴 GATE: Main Estate Audit" - ) - - # Get all check runs for this check suite - # Note: This is a simplified version - in practice you'd need the GitHub API - - # For now, we'll just document what should be checked - echo "required_gates=${#REQUIRED_GATES[@]}" >> $GITHUB_OUTPUT - echo "gate_list=${REQUIRED_GATES[*]}" >> $GITHUB_OUTPUT - - # In a real implementation, this would query the GitHub API - # to get all check runs and verify the required ones passed - echo "::notice::This check verifies that all required 🔴 GATE workflows complete successfully" - - - name: Alert on Critical Failures - if: steps.analyze.outputs.severity == 'high' - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - with: - script: | - const checkSuiteId = '${{ steps.check-suite.outputs.check_suite_id }}'; - const conclusion = '${{ steps.check-suite.outputs.conclusion }}'; - const repo = context.repo; - - console.log(`Check suite ${checkSuiteId} ${conclusion.toUpperCase()}`); - - // This would create an issue or send a notification - // For now, we'll just log it - core.notice(`Check suite failed: ${checkSuiteId}`); - - - name: Log Check Suite Information - run: | - echo "" >> $GITHUB_STEP_SUMMARY - echo "## Check Suite Monitor Report" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "**Repository:** ${{ steps.check-suite.outputs.repository }}" >> $GITHUB_STEP_SUMMARY - echo "**Check Suite ID:** ${{ steps.check-suite.outputs.check_suite_id }}" >> $GITHUB_STEP_SUMMARY - echo "**Event Type:** ${{ steps.check-suite.outputs.event_type }}" >> $GITHUB_STEP_SUMMARY - echo "**Head SHA:** ${{ steps.check-suite.outputs.head_sha }}" >> $GITHUB_STEP_SUMMARY - echo "**Conclusion:** ${{ steps.check-suite.outputs.conclusion }}" >> $GITHUB_STEP_SUMMARY - echo "**Status:** ${{ steps.analyze.outputs.status }}" >> $GITHUB_STEP_SUMMARY - echo "**Severity:** ${{ steps.analyze.outputs.severity }}" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - - if [ "${{ steps.check-suite.outputs.is_pr }}" = "true" ]; then - echo "**Pull Request:** #${{ steps.check-suite.outputs.pr_number }}" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "**Required GATE Workflows:**" >> $GITHUB_STEP_SUMMARY - echo "${{ steps.gate-check.outputs.gate_list }}" >> $GITHUB_STEP_SUMMARY - fi - - workflow-run-monitor: - name: Monitor Workflow Runs - runs-on: ubuntu-latest - timeout-minutes: 10 - if: github.event_name == 'workflow_run' - - steps: - - name: Checkout repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - name: Get Workflow Run Information - id: workflow-info - run: | - echo "workflow_name=${{ github.event.workflow }}" >> $GITHUB_OUTPUT - echo "workflow_id=${{ github.event.workflow_run.id }}" >> $GITHUB_OUTPUT - echo "conclusion=${{ github.event.workflow_run.conclusion }}" >> $GITHUB_OUTPUT - echo "run_number=${{ github.event.workflow_run.run_number }}" >> $GITHUB_OUTPUT - echo "head_sha=${{ github.event.workflow_run.head_sha }}" >> $GITHUB_OUTPUT - - - name: Analyze GATE Workflow Results - if: contains(github.event.workflow, '🔴 GATE:') - run: | - CONCLUSION="${{ steps.workflow-info.outputs.conclusion }}" - WORKFLOW="${{ steps.workflow-info.outputs.workflow_name }}" - - echo "🔍 Analyzing $WORKFLOW result: $CONCLUSION" - - case "$CONCLUSION" in - "success") - echo "::notice::✅ GATE workflow PASSED: $WORKFLOW" - ;; - "failure") - echo "::error::❌ GATE workflow FAILED: $WORKFLOW" - echo "This is a blocking failure - the PR cannot be merged" - ;; - "cancelled"|"timed_out") - echo "::warning::⏹️ GATE workflow $CONCLUSION: $WORKFLOW" - echo "This needs attention" - ;; - *) - echo "::notice::❓ GATE workflow $CONCLUSION: $WORKFLOW" - ;; - esac - - - name: Track CI Health Metrics - run: | - echo "📊 Tracking CI health metrics..." - - # This would collect and store metrics about CI performance - # For now, we'll just document the pattern - - echo "## CI Health Metrics" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "- **Workflow:** ${{ steps.workflow-info.outputs.workflow_name }}" >> $GITHUB_STEP_SUMMARY - echo "- **Run Number:** ${{ steps.workflow-info.outputs.run_number }}" >> $GITHUB_STEP_SUMMARY - echo "- **Conclusion:** ${{ steps.workflow-info.outputs.conclusion }}" >> $GITHUB_STEP_SUMMARY - echo "- **Head SHA:** ${{ steps.workflow-info.outputs.head_sha }}" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "In a full implementation, this would track:" >> $GITHUB_STEP_SUMMARY - echo "- Average workflow duration" >> $GITHUB_STEP_SUMMARY - echo "- Failure rates by workflow" >> $GITHUB_STEP_SUMMARY - echo "- Flaky workflow detection" >> $GITHUB_STEP_SUMMARY - - summary: - name: Check Suite Monitor Summary - runs-on: ubuntu-latest - timeout-minutes: 10 - needs: [monitor-check-suite, workflow-run-monitor] - if: always() - - steps: - - name: Generate Summary - run: | - echo "## Check Suite Monitor - Final Summary" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - - echo "### Check Suite Monitoring" >> $GITHUB_STEP_SUMMARY - if [ "${{ needs.monitor-check-suite.result }}" = "success" ]; then - echo "✅ Check suite monitoring: **PASSED**" >> $GITHUB_STEP_SUMMARY - elif [ "${{ needs.monitor-check-suite.result }}" = "failure" ]; then - echo "❌ Check suite monitoring: **FAILED**" >> $GITHUB_STEP_SUMMARY - else - echo "⚪ Check suite monitoring: **SKIPPED**" >> $GITHUB_STEP_SUMMARY - fi - - echo "" >> $GITHUB_STEP_SUMMARY - echo "### Workflow Run Monitoring" >> $GITHUB_STEP_SUMMARY - if [ "${{ needs.workflow-run-monitor.result }}" = "success" ]; then - echo "✅ Workflow run monitoring: **PASSED**" >> $GITHUB_STEP_SUMMARY - elif [ "${{ needs.workflow-run-monitor.result }}" = "failure" ]; then - echo "❌ Workflow run monitoring: **FAILED**" >> $GITHUB_STEP_SUMMARY - else - echo "⚪ Workflow run monitoring: **SKIPPED**" >> $GITHUB_STEP_SUMMARY - fi - - echo "" >> $GITHUB_STEP_SUMMARY - echo "### About This Workflow" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "This workflow monitors the health of your CI/CD pipeline by:" >> $GITHUB_STEP_SUMMARY - echo "- Tracking check suite completion status" >> $GITHUB_STEP_SUMMARY - echo "- Alerting on critical failures" >> $GITHUB_STEP_SUMMARY - echo "- Verifying required GATE workflows" >> $GITHUB_STEP_SUMMARY - echo "- Collecting CI health metrics" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "This helps catch CI misconfigurations and flaky workflows early." >> $GITHUB_STEP_SUMMARY +{ + name: "🟡 CHECK: Check Suite Monitor", + on: { + check_suite: { + types: [ + "completed", + "requested", + "rerequested", + ], + }, + workflow_run: { + workflows: [ + "🔴 GATE: *", + "🟡 CHECK: *", + ], + types: [ + "completed", + ], + }, + }, + # Least privilege: both monitor jobs only read the event payload and log it + # (the github-script step makes no API calls — see its "would create an + # issue" comment). Event payloads need no grants; `checks/pull-requests/ + # actions: read` were dead. + permissions: { + contents: "read", + }, + jobs: { + monitor-check-suite: { + name: "Monitor Check Suite Completion", + runs-on: "ubuntu-latest", + timeout-minutes: 10, + steps: [ + { + name: "Checkout repository", + uses: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", # v7.0.1 + }, + { + name: "Get Check Suite Details", + id: "check-suite", + run: "echo \"🔍 Retrieving check suite information...\"\n\n# Get the check suite that triggered this workflow\nCHECK_SUITE_ID=\"${{ github.event.check_suite.id }}\"\nREPO=\"${{ github.repository }}\"\n\nif [ -n \"$CHECK_SUITE_ID\" ]; then\n echo \"check_suite_id=$CHECK_SUITE_ID\" >> $GITHUB_OUTPUT\n \n # Get check suite details via GitHub API\n # Note: This would require a token with appropriate permissions\n # For now, we'll use the context information\n \n echo \"repository=$REPO\" >> $GITHUB_OUTPUT\n echo \"event_type=${{ github.event.action }}\" >> $GITHUB_OUTPUT\n \n # Extract branch information\n if [ \"${{ github.event.check_suite.pull_requests[0].id }}\" != \"\" ]; then\n echo \"is_pr=true\" >> $GITHUB_OUTPUT\n echo \"pr_number=${{ github.event.check_suite.pull_requests[0].number }}\" >> $GITHUB_OUTPUT\n else\n echo \"is_pr=false\" >> $GITHUB_OUTPUT\n fi\n \n echo \"head_sha=${{ github.event.check_suite.head_sha }}\" >> $GITHUB_OUTPUT\n echo \"conclusion=${{ github.event.check_suite.conclusion }}\" >> $GITHUB_OUTPUT\nelse\n echo \"check_suite_id=unknown\" >> $GITHUB_OUTPUT\nfi\n", + }, + { + name: "Analyze Check Suite Results", + id: "analyze", + run: "echo \"🔍 Analyzing check suite results...\"\n\nCONCLUSION=\"${{ steps.check-suite.outputs.conclusion }}\"\nIS_PR=\"${{ steps.check-suite.outputs.is_pr }}\"\nREPO=\"${{ steps.check-suite.outputs.repository }}\"\n\ncase \"$CONCLUSION\" in\n \"success\")\n echo \"status=✅ PASSED\" >> $GITHUB_OUTPUT\n echo \"severity=none\" >> $GITHUB_OUTPUT\n ;;\n \"failure\")\n echo \"status=❌ FAILED\" >> $GITHUB_OUTPUT\n echo \"severity=high\" >> $GITHUB_OUTPUT\n ;;\n \"neutral\")\n echo \"status=⚪ NEUTRAL\" >> $GITHUB_OUTPUT\n echo \"severity=low\" >> $GITHUB_OUTPUT\n ;;\n \"cancelled\"|\"timed_out\")\n echo \"status=⏹️ ${CONCLUSION^^}\" >> $GITHUB_OUTPUT\n echo \"severity=medium\" >> $GITHUB_OUTPUT\n ;;\n *)\n echo \"status=❓ $CONCLUSION\" >> $GITHUB_OUTPUT\n echo \"severity=unknown\" >> $GITHUB_OUTPUT\n ;;\nesac\n", + }, + { + name: "Check Required GATE Workflows", + if: "steps.check-suite.outputs.is_pr == 'true'", + id: "gate-check", + run: "echo \"🔍 Checking required 🔴 GATE workflows...\"\n\n# List of required GATE workflows\nREQUIRED_GATES=(\n \"🔴 GATE: Governance\"\n \"🔴 GATE: CodeQL\"\n \"🔴 GATE: Scorecard\"\n \"🔴 GATE: Hypatia Scan\"\n \"🔴 GATE: Secret Scanner\"\n \"🔴 GATE: Main Estate Audit\"\n)\n\n# Get all check runs for this check suite\n# Note: This is a simplified version - in practice you'd need the GitHub API\n\n# For now, we'll just document what should be checked\necho \"required_gates=${#REQUIRED_GATES[@]}\" >> $GITHUB_OUTPUT\necho \"gate_list=${REQUIRED_GATES[*]}\" >> $GITHUB_OUTPUT\n\n# In a real implementation, this would query the GitHub API\n# to get all check runs and verify the required ones passed\necho \"::notice::This check verifies that all required 🔴 GATE workflows complete successfully\"\n", + }, + { + name: "Alert on Critical Failures", + if: "steps.analyze.outputs.severity == 'high'", + uses: "actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3", # v9.0.0 + with: { + script: "const checkSuiteId = '${{ steps.check-suite.outputs.check_suite_id }}';\nconst conclusion = '${{ steps.check-suite.outputs.conclusion }}';\nconst repo = context.repo;\n\nconsole.log(`Check suite ${checkSuiteId} ${conclusion.toUpperCase()}`);\n\n// This would create an issue or send a notification\n// For now, we'll just log it\ncore.notice(`Check suite failed: ${checkSuiteId}`);\n", + }, + }, + { + name: "Log Check Suite Information", + run: "echo \"\" >> $GITHUB_STEP_SUMMARY\necho \"## Check Suite Monitor Report\" >> $GITHUB_STEP_SUMMARY\necho \"\" >> $GITHUB_STEP_SUMMARY\necho \"**Repository:** ${{ steps.check-suite.outputs.repository }}\" >> $GITHUB_STEP_SUMMARY\necho \"**Check Suite ID:** ${{ steps.check-suite.outputs.check_suite_id }}\" >> $GITHUB_STEP_SUMMARY\necho \"**Event Type:** ${{ steps.check-suite.outputs.event_type }}\" >> $GITHUB_STEP_SUMMARY\necho \"**Head SHA:** ${{ steps.check-suite.outputs.head_sha }}\" >> $GITHUB_STEP_SUMMARY\necho \"**Conclusion:** ${{ steps.check-suite.outputs.conclusion }}\" >> $GITHUB_STEP_SUMMARY\necho \"**Status:** ${{ steps.analyze.outputs.status }}\" >> $GITHUB_STEP_SUMMARY\necho \"**Severity:** ${{ steps.analyze.outputs.severity }}\" >> $GITHUB_STEP_SUMMARY\necho \"\" >> $GITHUB_STEP_SUMMARY\n\nif [ \"${{ steps.check-suite.outputs.is_pr }}\" = \"true\" ]; then\n echo \"**Pull Request:** #${{ steps.check-suite.outputs.pr_number }}\" >> $GITHUB_STEP_SUMMARY\n echo \"\" >> $GITHUB_STEP_SUMMARY\n echo \"**Required GATE Workflows:**\" >> $GITHUB_STEP_SUMMARY\n echo \"${{ steps.gate-check.outputs.gate_list }}\" >> $GITHUB_STEP_SUMMARY\nfi\n", + }, + ], + }, + workflow-run-monitor: { + name: "Monitor Workflow Runs", + runs-on: "ubuntu-latest", + timeout-minutes: 10, + if: "github.event_name == 'workflow_run'", + steps: [ + { + name: "Checkout repository", + uses: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", # v7.0.1 + }, + { + name: "Get Workflow Run Information", + id: "workflow-info", + run: "echo \"workflow_name=${{ github.event.workflow }}\" >> $GITHUB_OUTPUT\necho \"workflow_id=${{ github.event.workflow_run.id }}\" >> $GITHUB_OUTPUT\necho \"conclusion=${{ github.event.workflow_run.conclusion }}\" >> $GITHUB_OUTPUT\necho \"run_number=${{ github.event.workflow_run.run_number }}\" >> $GITHUB_OUTPUT\necho \"head_sha=${{ github.event.workflow_run.head_sha }}\" >> $GITHUB_OUTPUT\n", + }, + { + name: "Analyze GATE Workflow Results", + if: "contains(github.event.workflow, '🔴 GATE:')", + run: "CONCLUSION=\"${{ steps.workflow-info.outputs.conclusion }}\"\nWORKFLOW=\"${{ steps.workflow-info.outputs.workflow_name }}\"\n\necho \"🔍 Analyzing $WORKFLOW result: $CONCLUSION\"\n\ncase \"$CONCLUSION\" in\n \"success\")\n echo \"::notice::✅ GATE workflow PASSED: $WORKFLOW\"\n ;;\n \"failure\")\n echo \"::error::❌ GATE workflow FAILED: $WORKFLOW\"\n echo \"This is a blocking failure - the PR cannot be merged\"\n ;;\n \"cancelled\"|\"timed_out\")\n echo \"::warning::⏹️ GATE workflow $CONCLUSION: $WORKFLOW\"\n echo \"This needs attention\"\n ;;\n *)\n echo \"::notice::❓ GATE workflow $CONCLUSION: $WORKFLOW\"\n ;;\nesac\n", + }, + { + name: "Track CI Health Metrics", + run: "echo \"📊 Tracking CI health metrics...\"\n\n# This would collect and store metrics about CI performance\n# For now, we'll just document the pattern\n\necho \"## CI Health Metrics\" >> $GITHUB_STEP_SUMMARY\necho \"\" >> $GITHUB_STEP_SUMMARY\necho \"- **Workflow:** ${{ steps.workflow-info.outputs.workflow_name }}\" >> $GITHUB_STEP_SUMMARY\necho \"- **Run Number:** ${{ steps.workflow-info.outputs.run_number }}\" >> $GITHUB_STEP_SUMMARY\necho \"- **Conclusion:** ${{ steps.workflow-info.outputs.conclusion }}\" >> $GITHUB_STEP_SUMMARY\necho \"- **Head SHA:** ${{ steps.workflow-info.outputs.head_sha }}\" >> $GITHUB_STEP_SUMMARY\necho \"\" >> $GITHUB_STEP_SUMMARY\necho \"In a full implementation, this would track:\" >> $GITHUB_STEP_SUMMARY\necho \"- Average workflow duration\" >> $GITHUB_STEP_SUMMARY\necho \"- Failure rates by workflow\" >> $GITHUB_STEP_SUMMARY\necho \"- Flaky workflow detection\" >> $GITHUB_STEP_SUMMARY\n", + }, + ], + }, + summary: { + name: "Check Suite Monitor Summary", + runs-on: "ubuntu-latest", + timeout-minutes: 10, + needs: [ + "monitor-check-suite", + "workflow-run-monitor", + ], + if: "always()", + steps: [ + { + name: "Generate Summary", + run: "echo \"## Check Suite Monitor - Final Summary\" >> $GITHUB_STEP_SUMMARY\necho \"\" >> $GITHUB_STEP_SUMMARY\n\necho \"### Check Suite Monitoring\" >> $GITHUB_STEP_SUMMARY\nif [ \"${{ needs.monitor-check-suite.result }}\" = \"success\" ]; then\n echo \"✅ Check suite monitoring: **PASSED**\" >> $GITHUB_STEP_SUMMARY\nelif [ \"${{ needs.monitor-check-suite.result }}\" = \"failure\" ]; then\n echo \"❌ Check suite monitoring: **FAILED**\" >> $GITHUB_STEP_SUMMARY\nelse\n echo \"⚪ Check suite monitoring: **SKIPPED**\" >> $GITHUB_STEP_SUMMARY\nfi\n\necho \"\" >> $GITHUB_STEP_SUMMARY\necho \"### Workflow Run Monitoring\" >> $GITHUB_STEP_SUMMARY\nif [ \"${{ needs.workflow-run-monitor.result }}\" = \"success\" ]; then\n echo \"✅ Workflow run monitoring: **PASSED**\" >> $GITHUB_STEP_SUMMARY\nelif [ \"${{ needs.workflow-run-monitor.result }}\" = \"failure\" ]; then\n echo \"❌ Workflow run monitoring: **FAILED**\" >> $GITHUB_STEP_SUMMARY\nelse\n echo \"⚪ Workflow run monitoring: **SKIPPED**\" >> $GITHUB_STEP_SUMMARY\nfi\n\necho \"\" >> $GITHUB_STEP_SUMMARY\necho \"### About This Workflow\" >> $GITHUB_STEP_SUMMARY\necho \"\" >> $GITHUB_STEP_SUMMARY\necho \"This workflow monitors the health of your CI/CD pipeline by:\" >> $GITHUB_STEP_SUMMARY\necho \"- Tracking check suite completion status\" >> $GITHUB_STEP_SUMMARY\necho \"- Alerting on critical failures\" >> $GITHUB_STEP_SUMMARY\necho \"- Verifying required GATE workflows\" >> $GITHUB_STEP_SUMMARY\necho \"- Collecting CI health metrics\" >> $GITHUB_STEP_SUMMARY\necho \"\" >> $GITHUB_STEP_SUMMARY\necho \"This helps catch CI misconfigurations and flaky workflows early.\" >> $GITHUB_STEP_SUMMARY\n", + }, + ], + }, + }, +} diff --git a/.github/workflows/ci-pipeline.yml b/.github/workflows/ci-pipeline.yml index 237988754..d68df2b7c 100644 --- a/.github/workflows/ci-pipeline.yml +++ b/.github/workflows/ci-pipeline.yml @@ -44,1116 +44,504 @@ # a job, so the old shape lint-checked — and thereby blessed — the very thing # it called banned. -name: CI Pipeline (reusable) - -on: - workflow_call: - inputs: - runs-on: - description: Runner label for every job in this pipeline. - type: string - default: ubuntu-latest - enable_sast: - description: Run the semgrep SAST job. - type: boolean - default: true - sast_blocking: - description: >- - Fail the build on semgrep findings. Default false per the owner's - standing ruling that a new scanner finding is an issue, not a - blocker. The zero-files-scanned check is fatal regardless. - type: boolean - default: false - enable_coverage: - description: Pass through to rust-ci-reusable.yml. - type: boolean - default: true - coverage_floor: - description: >- - Minimum coverage percentage. Ratchet upward, never lower. - type: string - default: '0' - fail_on_no_ecosystem: - description: >- - Fail `detect` when no known ecosystem is found. Leave true: this is - the guard against a pipeline that passes by matching nothing. - type: boolean - default: true - -permissions: - contents: read - -jobs: - # ─────────────────────────────────────────────────────────────────────── - # detect — the denominator - # ─────────────────────────────────────────────────────────────────────── - detect: - name: Detect ecosystems - runs-on: ${{ inputs.runs-on }} - timeout-minutes: 5 - permissions: - contents: read - outputs: - has_rust: ${{ steps.scan.outputs.has_rust }} - has_nickel: ${{ steps.scan.outputs.has_nickel }} - has_rescript: ${{ steps.scan.outputs.has_rescript }} - has_v: ${{ steps.scan.outputs.has_v }} - has_haskell: ${{ steps.scan.outputs.has_haskell }} - has_deno: ${{ steps.scan.outputs.has_deno }} - # Ecosystems this pipeline can DETECT but has no job to CHECK. Reported - # separately from `total` on purpose — see the scan step's comment. - n_unsupported: ${{ steps.scan.outputs.n_unsupported }} - unsupported: ${{ steps.scan.outputs.unsupported }} - # The Deno gate REFUSES and names what it found, so it needs the - # count itself, not merely the boolean. - n_deno: ${{ steps.scan.outputs.n_deno }} - total: ${{ steps.scan.outputs.total }} - # The refusal VERDICT, carried as data rather than as an exit code. - # `detect` measures; `report` is the single judge that can fail the run. - # See the refusal branch at the end of the scan step for why. - refused: ${{ steps.scan.outputs.refused }} - refusal_reason: ${{ steps.scan.outputs.refusal_reason }} - steps: - - name: Checkout repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - name: Scan tracked files for ecosystem markers - id: scan - shell: bash - run: | - set -euo pipefail - - # `git ls-files` lists TRACKED files only. `find` would descend into - # vendored trees (node_modules/, deps/, target/) and report an - # ecosystem this repo does not own. - count() { - git ls-files -z -- "$@" | tr -cd '\0' | wc -c - } - - # ── SUPPORTED ecosystems ──────────────────────────────────────── - # Every probe here has a CONSUMING job later in this file. That is - # the entry requirement for this list, not a coincidence. - N_RUST=$(count 'Cargo.toml' '*/Cargo.toml') - # A suffix is not a format. `*.k9.ncl` files open with a `K9!` sentinel on - # line 1 and are k9 contracts, not Nickel source — `nickel typecheck` dies - # at 1:3 on the `!`. They are excluded here so `has_nickel` cannot go true - # on a repo holding no Nickel at all, and excluded by the SAME pathspec in - # the `nickel` job: a guard that counts a different set than its consumer - # checks is how a gate comes to fail over files the repo cannot fix. - N_NICKEL=$(count '*.ncl' ':!*.k9.ncl') - N_K9=$(count '*.k9.ncl') - N_RESCRIPT=$(count 'rescript.json' '*/rescript.json' 'bsconfig.json' '*/bsconfig.json' '*.res') - # V is gated on `v.mod` ALONE. The `.v` extension is shared with Coq - # proof scripts and Verilog sources, so globbing `*.v` would install - # a V toolchain to lint a Coq development. - N_V=$(count 'v.mod' '*/v.mod') - N_HASKELL=$(count '*.cabal' 'stack.yaml' '*/stack.yaml' '*.hs') - # ⚠ DO NOT REMOVE THE DENO PROBE. It does not exist to lint Deno — - # it is the trigger for the REFUSAL job `deno` below, i.e. the teeth - # of the estate's standing no-Deno ruling, with slugs still live in - # `.machine_readable/deno-allow.txt`. Deleting the probe would not - # "drop support"; it would silently un-enforce a ban, because the - # refusal simply stops firing and the repo goes green. - N_DENO=$(count 'deno.json' '*/deno.json' 'deno.jsonc' '*/deno.jsonc') - - # TOTAL counts ONLY ecosystems some job in this file actually - # examines. Deno counts: a refusal IS a check. - TOTAL=$(( N_RUST + N_NICKEL + N_RESCRIPT + N_V + N_HASKELL + N_DENO )) - - # ── DETECTED BUT UNSUPPORTED ──────────────────────────────────── - # These have NO lint or format job in this file. They are counted - # SEPARATELY and deliberately DO NOT enter TOTAL. - # - # ⚠ THAT SEPARATION IS THE ENTIRE POINT OF THIS SECTION. Folding - # them into TOTAL would lift it above zero and SILENCE the - # zero-denominator refusal below while nothing whatsoever had been - # checked. That is a vacuous gate, and a vacuous gate is worse than - # an honest blind spot: the blind spot reports nothing, the vacuous - # gate reports success. A probe moves from this list to the one - # above ONLY in the same commit that adds its consuming job. - # - # Keyed on MANIFESTS, not extensions, for the reason `v.mod` already - # is: an extension is shared between languages, a manifest is not. - UNSUPPORTED="" - N_UNSUPPORTED=0 - probe() { - local name="$1"; shift - local n - n=$(count "$@") - if [ "$n" -gt 0 ]; then - UNSUPPORTED="${UNSUPPORTED:+${UNSUPPORTED}, }${name} (${n})" - N_UNSUPPORTED=$(( N_UNSUPPORTED + n )) - fi - } - - probe 'Julia' 'Project.toml' '*/Project.toml' - probe 'Zig' 'build.zig' '*/build.zig' 'build.zig.zon' '*/build.zig.zon' - # `*.idr` as well as the manifest, because Idris2 sources routinely - # ship without an `.ipkg` (ddraig-ssg tracks 4 `.idr` files and no - # package file). Extension-keyed probes are the exception here, not - # the rule — `.v` is deliberately NOT one, because it is also Coq and - # Verilog — but `.idr` is unambiguous, exactly like the `*.hs` and - # `*.res` probes above. This moves such a repository from detector - # BLINDNESS to honest measured debt; it does not make it green. - probe 'Idris2' '*.ipkg' '*.idr' - probe 'Elixir' 'mix.exs' '*/mix.exs' - probe 'Lean' 'lakefile.toml' '*/lakefile.toml' 'lakefile.lean' '*/lakefile.lean' 'lean-toolchain' '*/lean-toolchain' - probe 'Agda' '*.agda-lib' - probe 'Ada' 'alire.toml' '*/alire.toml' '*.gpr' - probe 'Ruby' 'Gemfile' '*/Gemfile' '*.gemspec' - probe 'PHP' 'composer.json' '*/composer.json' - probe 'Gleam' 'gleam.toml' '*/gleam.toml' - # ⚠ `ROOT` is an unusually generic filename and this probe is the - # most likely of the set to produce a FALSE POSITIVE. It is tolerable - # only because a false positive here cannot bless anything: it can - # only move a repo from "nothing detected" to "detected, unchecked", - # and both refuse. It must NOT be promoted to the supported list on - # this evidence alone. - probe 'Isabelle' 'ROOT' '*/ROOT' - # Bun. `bun.lock*` and `bunfig.toml` are positive identification. - # ⚠ `package.json` ALONE is an ASSUMPTION, stated here rather than - # hidden: the estate's standing ruling is bun-only, with Node and - # Deno banned, so a bare package.json in this estate is presumed to - # be a Bun manifest. That assumption is safe while Bun sits in THIS - # list — it yields a refusal, never a pass. Re-examine it on the day - # a Bun lint job is added, because then it would start selecting - # which repositories get linted. - probe 'Bun' 'bun.lock' '*/bun.lock' 'bun.lockb' '*/bun.lockb' 'bunfig.toml' '*/bunfig.toml' 'package.json' '*/package.json' - - bool() { [ "$1" -gt 0 ] && echo true || echo false; } - - { - echo "has_rust=$(bool "$N_RUST")" - echo "has_nickel=$(bool "$N_NICKEL")" - echo "has_rescript=$(bool "$N_RESCRIPT")" - echo "has_v=$(bool "$N_V")" - echo "has_haskell=$(bool "$N_HASKELL")" - echo "has_deno=$(bool "$N_DENO")" - echo "n_deno=$N_DENO" - echo "total=$TOTAL" - echo "n_unsupported=$N_UNSUPPORTED" - echo "unsupported=$UNSUPPORTED" - } >> "$GITHUB_OUTPUT" - - # Print the denominator. A gate that never shows what it measured - # cannot be distinguished from a gate that measured nothing. - { - echo "### Ecosystem detection" - echo "" - echo "| Ecosystem | Marker | Tracked files matched |" - echo "|---|---|---|" - echo "| Rust | \`Cargo.toml\` | ${N_RUST} |" - echo "| Nickel | \`*.ncl\` excluding \`*.k9.ncl\` | ${N_NICKEL} |" - echo "| ReScript | \`rescript.json\`, \`bsconfig.json\`, \`*.res\` | ${N_RESCRIPT} |" - echo "| V | \`v.mod\` only | ${N_V} |" - echo "| Haskell | \`*.cabal\`, \`stack.yaml\`, \`*.hs\` | ${N_HASKELL} |" - echo "| Deno | \`deno.json(c)\` | ${N_DENO} |" - echo "| **Total checkable** | | **${TOTAL}** |" - echo "" - if [ "$N_K9" -gt 0 ]; then - echo "> **${N_K9} \`*.k9.ncl\` excluded.** These are k9 contracts (a \`K9!\` sentinel on line 1), not Nickel source. They are neither typechecked nor counted above, and they cannot raise the Total." - echo "" - fi - if [ "$N_UNSUPPORTED" -gt 0 ]; then - echo "#### Detected, but this pipeline has no gate for it" - echo "" - echo "${UNSUPPORTED}" - echo "" - echo "_These files were found and **not checked**. They are excluded from the" - echo "total above on purpose: counting them would hide the fact that nothing" - echo "examined them. Tracked as hyperpolymath/standards#967._" - echo "" - fi - } >> "$GITHUB_STEP_SUMMARY" - - # A refusal is a VERDICT, not an exit code. - # - # This block used to `exit 1` here, which quietly made `detect` a - # SECOND judge. The exemption ledger in the `report` job could spare - # only `report`'s own `exit 1`, so a ledgered repository still went - # red on this job and could never actually go green. That is the - # vacuous gate's mirror image: a ledger that can never grant the - # thing it promises. - # - # So the verdict travels to `report` as an output and this job stays - # green. Nothing is weakened. `report` runs `if: always()`, reads this - # output in its own `if:`, and fails closed when the repository is not - # on the ledger — an unreadable or empty ledger still blocks. - REFUSED=false - MSG="" - if [ "$TOTAL" -eq 0 ]; then - # Same switch, two different truths. Telling them apart is what - # makes the adoption ledger measure real debt instead of detector - # blindness: "we have no gate for Julia" and "we could not - # recognise this repository at all" need different cures. - if [ "$N_UNSUPPORTED" -gt 0 ]; then - MSG="Detected ${UNSUPPORTED}, but this pipeline has no lint or format job for any of them, so NOTHING was checked. This is measured debt, not an unrecognised repository — see hyperpolymath/standards#967." - else - MSG="No known ecosystem detected (denominator = 0). Every lint and format gate would be vacuous, so this pipeline refuses to report success. Add the ecosystem's marker file, or call this workflow with fail_on_no_ecosystem: false and say why." - fi - if [ "${{ inputs.fail_on_no_ecosystem }}" = "true" ]; then - echo "::error::${MSG}" - echo "**REFUSED: nothing could be checked.** ${MSG}" >> "$GITHUB_STEP_SUMMARY" - REFUSED=true - else - # ⚠ This escape hatch is warning-only, and a `::warning::` cannot - # fail a job. It is therefore NOT a route to green for estate - # adoption: it is unratcheted, invisible to the exemption - # denominator, and indistinguishable from a pass. Repositories - # with no gate belong on the ledger, where the debt is counted. - echo "::warning::${MSG} fail_on_no_ecosystem is false, so continuing with nothing checked." - fi - fi - - # Written on BOTH paths, so a consumer can never confuse "did not - # refuse" with "the output was never set". - { - echo "refused=${REFUSED}" - echo "refusal_reason=${MSG}" - } >> "$GITHUB_OUTPUT" - - # ─────────────────────────────────────────────────────────────────────── - # Job 1a — secret scanning (delegated: pinned, sha256-verified gitleaks) - # ─────────────────────────────────────────────────────────────────────── - secret-scan: - 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 - with: - runs-on: ${{ inputs.runs-on }} - - # ─────────────────────────────────────────────────────────────────────── - # Job 1b — SAST (semgrep OSS rulesets, no account, no telemetry) - # ─────────────────────────────────────────────────────────────────────── - sast: - name: SAST (semgrep) - if: ${{ inputs.enable_sast }} - runs-on: ${{ inputs.runs-on }} - timeout-minutes: 20 - permissions: - contents: read - steps: - - name: Checkout repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - name: Run semgrep (p/default + p/security-audit) - id: semgrep - shell: bash - env: - # Digest resolved from the Docker Hub registry, not copied from a - # tag. A tag is mutable; this is the image or nothing. - SEMGREP_IMAGE: semgrep/semgrep@sha256:10301f060aacf84078f9704fb1ba3a321df4ac46b009fd29c1c66880d1db8e77 - run: | - set -uo pipefail - - # Run through `docker run` rather than a job-level `container:` so - # that checkout and the jq post-processing happen on the runner, - # where jq is preinstalled. The semgrep image's own tooling is not - # relied upon for anything but semgrep itself. - # - # --metrics off: OSS rulesets, no account, nothing phoned home. - # A config that fails to resolve makes semgrep exit non-zero, so - # "the ruleset did not download" cannot masquerade as "clean". - set +e - docker run --rm \ - -v "${PWD}:/src" -w /src \ - "$SEMGREP_IMAGE" \ - semgrep scan \ - --config p/default \ - --config p/security-audit \ - --metrics off \ - --disable-version-check \ - --json --output /src/semgrep.json \ - 2> "$RUNNER_TEMP/semgrep.err" - SEMGREP_RC=$? - set -e - - echo "semgrep exit code: $SEMGREP_RC" - echo "--- semgrep stderr (rule loading and scan totals) ---" - cat "$RUNNER_TEMP/semgrep.err" || true - echo "-----------------------------------------------------" - - if [ ! -s semgrep.json ]; then - echo "::error::semgrep produced no JSON report (exit $SEMGREP_RC). Treating as a scan failure, not as a clean result." - echo "### SAST (semgrep): FAILED — no report produced" >> "$GITHUB_STEP_SUMMARY" - exit 1 - fi - - # A report can exist and still be PARTIAL: semgrep exits non-zero on - # rule-loading and scan errors while still writing what it managed to - # collect. Reading only the JSON would report that truncated scan as a - # clean tree. The exit code is the only signal that separates them. - if [ "$SEMGREP_RC" -ne 0 ]; then - echo "::error::semgrep exited $SEMGREP_RC. A partial report is not a clean result." - echo "### SAST (semgrep): FAILED — scanner exited $SEMGREP_RC" >> "$GITHUB_STEP_SUMMARY" - exit 1 - fi - - SCANNED=$(jq '(.paths.scanned // []) | length' semgrep.json) - FINDINGS=$(jq '(.results // []) | length' semgrep.json) - RULES_FIRED=$(jq '[(.results // [])[].check_id] | unique | length' semgrep.json) - ERRORS=$(jq '(.errors // []) | length' semgrep.json) - - { - echo "### SAST (semgrep)" - echo "" - echo "| Measure | Value |" - echo "|---|---|" - echo "| Files scanned | ${SCANNED} |" - echo "| Findings | ${FINDINGS} |" - echo "| Distinct rules fired | ${RULES_FIRED} |" - echo "| Scan errors | ${ERRORS} |" - echo "| Rulesets | \`p/default\`, \`p/security-audit\` |" - echo "" - } >> "$GITHUB_STEP_SUMMARY" - - # THE LOAD-BEARING ASSERTION. Zero files scanned means every rule in - # both rulesets was applied to nothing, which produces exactly the - # same green tick as a genuinely clean tree. - if [ "$SCANNED" -eq 0 ]; then - echo "::error::semgrep scanned 0 files. Zero findings over zero files is not a clean result — it is no result. Failing rather than reporting a vacuous pass." - echo "**REFUSED: 0 files scanned — a vacuous pass, not a clean tree.**" >> "$GITHUB_STEP_SUMMARY" - exit 1 - fi - - if [ "$FINDINGS" -gt 0 ]; then - { - echo "| Severity | Rule | File | Line |" - echo "|---|---|---|---|" - jq -r '(.results // [])[:100][] - | "| \(.extra.severity // "INFO") | `\(.check_id)` | `\(.path)` | \(.start.line) |"' \ - semgrep.json - echo "" - } >> "$GITHUB_STEP_SUMMARY" - - if [ "${{ inputs.sast_blocking }}" = "true" ]; then - echo "::error::semgrep reported ${FINDINGS} finding(s) and sast_blocking is true." - exit 1 - fi - # Advisory by owner ruling: a new scanner finding is an issue with - # acceptance criteria, not a merge blocker. The findings are in the - # step summary above, so this is visible rather than swallowed. - echo "::warning::semgrep reported ${FINDINGS} finding(s). Advisory by standing ruling — raise an issue with acceptance criteria. Set sast_blocking: true to gate on these." - fi - - # ─────────────────────────────────────────────────────────────────────── - # Job 2/3 — Rust: lint, format, build, test, coverage (delegated) - # ─────────────────────────────────────────────────────────────────────── - rust: - name: Rust - needs: detect - if: ${{ needs.detect.outputs.has_rust == 'true' }} - uses: hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml@571cc734cd69fb846032ec77a662aa8ee4fc32cd - with: - runs-on: ${{ inputs.runs-on }} - enable_coverage: ${{ inputs.enable_coverage }} - coverage_floor: ${{ inputs.coverage_floor }} - # Read-only. `-D warnings` promotes every warning to an error, which is - # the "fail on warnings" the spec asked for. - clippy_args: '--all-targets -- -D warnings' - - # ─────────────────────────────────────────────────────────────────────── - # Job 2 — Nickel - # ─────────────────────────────────────────────────────────────────────── - nickel: - name: Nickel - needs: detect - if: ${{ needs.detect.outputs.has_nickel == 'true' }} - runs-on: ${{ inputs.runs-on }} - timeout-minutes: 15 - permissions: - contents: read - steps: - - name: Checkout repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - name: Install Nickel (pinned, sha256-verified) - shell: bash - env: - NICKEL_VERSION: '1.18.0' - # Computed locally from the release asset, not transcribed from a - # web page. Same discipline as secret-scanner-reusable.yml's gitleaks - # pin: a download whose bytes are not checked is not a pin. - NICKEL_SHA256: '9cba4dd65ae9915ec61f73033aafcff307a377665a83fd8f530df086763318cb' - run: | - set -euo pipefail - URL="https://github.com/tweag/nickel/releases/download/${NICKEL_VERSION}/nickel-x86_64-linux" - curl -sSfL --proto '=https' --proto-redir '=https' --tlsv1.2 --retry 3 -o "$RUNNER_TEMP/nickel" "$URL" - echo "${NICKEL_SHA256} ${RUNNER_TEMP}/nickel" | sha256sum -c - - chmod +x "$RUNNER_TEMP/nickel" - echo "$RUNNER_TEMP" >> "$GITHUB_PATH" - - - name: Nickel format check + typecheck - shell: bash - run: | - set -euo pipefail - - # Same pathspec as `detect`. k9 contracts are a DIFFERENT format that - # merely shares the suffix; typechecking them reports a parse error the - # repo has no way to fix. - mapfile -d '' FILES < <(git ls-files -z -- '*.ncl' ':!*.k9.ncl') - N_K9=$(git ls-files -z -- '*.k9.ncl' | tr -cd '\0' | wc -c) - - echo "Nickel files: ${#FILES[@]} (k9 contracts excluded: ${N_K9})" - if [ "${#FILES[@]}" -eq 0 ]; then - echo "::error::detect said Nickel was present but zero Nickel files are tracked here (${N_K9} *.k9.ncl were excluded as k9 contracts). Refusing to report a pass over an empty set." - exit 1 - fi - - FAILED=0 - TYPECHECKED=0 - SKIPPED=() - - for f in "${FILES[@]}"; do - # --check is read-only: it reports, it does not rewrite the file. - # Format is checked over EVERY genuine Nickel file, with no exemption. - nickel format --check "$f" || { echo "::error file=$f::not formatted (nickel format --check)"; FAILED=1; } - - # A file importing a build artefact cannot be typechecked standalone: - # the input is generated immediately before use and is correctly - # uncommitted. Detect that by asking git whether the import target is - # tracked, and DISCLOSE every skip by name. A skip is not a pass. - MISSING="" - while IFS= read -r imp; do - [ -n "$imp" ] || continue - cand="$(dirname "$f")/$imp" - cand="$(realpath -m --relative-to=. "$cand" 2>/dev/null || echo "$cand")" - git ls-files --error-unmatch -- "$cand" >/dev/null 2>&1 \ - || MISSING="${MISSING}${MISSING:+, }${imp}" - # Comments are stripped first: `_base.ncl` documents its own usage - # in a `#` comment containing a literal `import "../_base.ncl"`, and - # matching that would skip a file that typechecks perfectly well. - # hypatia: allow research_extensions/RE005 -- `|| true` absorbs grep's exit 1 on a file with no imports; an empty import list is the correct answer there, and the typecheck below still gates. - done < <(sed 's/#.*//' "$f" | grep -oE 'import[[:space:]]+"[^"]+"' | sed -E 's/.*"([^"]+)".*/\1/' || true) - - if [ -n "$MISSING" ]; then - echo "::notice file=$f::nickel typecheck skipped - import target not tracked in git: ${MISSING}" - SKIPPED+=("$f (${MISSING})") - continue - fi - - nickel typecheck "$f" || { echo "::error file=$f::failed nickel typecheck"; FAILED=1; } - TYPECHECKED=$((TYPECHECKED + 1)) - done - - # Narrowing a file set must never be able to manufacture a pass. - if [ "$TYPECHECKED" -eq 0 ]; then - echo "::error::all ${#FILES[@]} Nickel files were skipped for untracked imports, so nothing was typechecked. Refusing to report a pass over an empty set." - FAILED=1 - fi - - { - echo "### Nickel" - echo "" - echo "- format checked: **${#FILES[@]}**" - echo "- typechecked: **${TYPECHECKED}**" - echo "- typecheck skipped (untracked import): **${#SKIPPED[@]}**" - echo "- k9 contracts excluded (\`*.k9.ncl\`, not Nickel): **${N_K9}**" - if [ "${#SKIPPED[@]}" -gt 0 ]; then - echo "" - echo "Skipped, and why:" - echo "" - for s in "${SKIPPED[@]}"; do echo "- \`${s}\`"; done - fi - echo "" - echo "- result: $( [ "$FAILED" -eq 0 ] && echo 'pass' || echo '**FAIL**' )" - echo "" - } >> "$GITHUB_STEP_SUMMARY" - - exit "$FAILED" - exit "$FAILED" - - # ─────────────────────────────────────────────────────────────────────── - # Job 2 — Deno REFUSAL gate (BANNED; shrink-only ledger exemption) - # ─────────────────────────────────────────────────────────────────────── - # - # Owner ruling: "deno is over, we're prioritising bun, and using bunx." - # This job used to install a pinned Deno binary and run `deno lint` / - # `deno fmt --check`, announcing the ban with a `::warning::`. A - # `::warning::` CANNOT fail a job, so that job asserted the opposite of what - # it said: it lint-checked, and thereby blessed, the very thing it called - # banned. It now REFUSES, and the only way past it is the central - # shrink-only ledger `.machine_readable/deno-allow.txt` in `standards`. - # - # Both outcomes print their denominator. A gate that only ever says yes - # proves nothing, and an exemption that reports nothing is a silent pass. - deno: - name: Deno (banned) - needs: detect - if: ${{ needs.detect.outputs.has_deno == 'true' }} - runs-on: ${{ inputs.runs-on }} - timeout-minutes: 10 - permissions: - contents: read - steps: - - name: Checkout repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - # The ledger is CENTRAL: one file in `standards`, not a per-repo opt-out, - # so the estate's Deno debt is countable from one place and the ratchet - # can forbid its growth. Pinned to an immutable commit for the same - # reason as the governance helpers — `github.workflow_sha` resolves to - # the CALLER's commit, and following `main` would let an edit in - # `standards` change the verdict of every already-pinned caller with no - # review in their repositories. - - name: Checkout the pinned Standards Deno ledger - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: hyperpolymath/standards - # 938264d8 is the commit that introduced the ledger. ⚠ BUMP THIS - # whenever .machine_readable/deno-allow.txt changes, or the gate goes - # on judging callers against a stale list. A stale list is SAFE in - # one direction only: a repository that migrated off Deno stops - # tracking deno.json, so the job does not run at all and its lingering - # exemption never fires. A repository that is ADDED to the ledger - # stays red here until this pin moves — which is the correct default, - # because the addition needs a Ratchet-exception trailer anyway. - ref: 938264d8b0bbc3ca079e3d02efff5c1d423d0329 - path: .standards-deno-ledger - sparse-checkout: | - .machine_readable/deno-allow.txt - sparse-checkout-cone-mode: false - # Not fatal here: the next step names precisely what was missing and - # then FAILS CLOSED. A fetch failure must never read as an exemption. - # hypatia: allow research_extensions/RE005 -- deliberate fail-closed: "Refuse Deno unless this repository is ledgered" below checks for the ledger file and exits 1 with an explicit ::error:: if missing, regardless of why. - continue-on-error: true - - - name: Refuse Deno unless this repository is ledgered - shell: bash - env: - LEDGER: .standards-deno-ledger/.machine_readable/deno-allow.txt - SLUG: ${{ github.repository }} - N_DENO: ${{ needs.detect.outputs.n_deno }} - run: | - # LEDGER, SLUG and N_DENO come from the step `env:` map above. - set -euo pipefail - - # FAIL CLOSED. "Could not read the ledger" is not "is exempt". - if [ ! -f "$LEDGER" ]; then - echo "::error::Deno gate: the central ledger could not be read ($LEDGER). This is NOT an exemption — the gate refuses rather than guess. Check the pinned ref of the 'Checkout the pinned Standards Deno ledger' step." - { - echo "### Deno (banned)" - echo "" - echo "**REFUSED: the exemption ledger could not be read.** A gate that cannot" - echo "check its exemption list must not pass; this is not an exemption." - echo "" - } >> "$GITHUB_STEP_SUMMARY" - exit 1 - fi - - # Strip comments and blanks; this is also the ledger's own denominator. - ALLOWED="$(grep -vE '^[[:space:]]*(#|$)' "$LEDGER" || true)" - N_ALLOWED="$(printf '%s' "$ALLOWED" | grep -c . || true)" - - # What this repository actually tracks, named rather than counted, so - # the migration has a list to work from. - CONFIGS="$(git ls-files -- 'deno.json' '*/deno.json' 'deno.jsonc' '*/deno.jsonc')" - - echo "Deno debt: ${N_ALLOWED} ledgered repositories estate-wide." - echo "This repository (${SLUG}) tracks ${N_DENO} Deno config file(s):" - printf '%s\n' "$CONFIGS" | sed 's/^/ - /' - - if printf '%s\n' "$ALLOWED" | grep -Fxq "$SLUG"; then - echo "::notice::${SLUG} is on the Deno exemption ledger (${N_ALLOWED} repositories). Deno remains BANNED: this is grandfathered debt, not a licence. Port the deno.json 'tasks' map to package.json scripts run by 'bun run', delete the config, then delete this repository's line from .machine_readable/deno-allow.txt." - { - echo "### Deno (banned) — ledgered debt" - echo "" - echo "| | |" - echo "|---|---|" - echo "| Repository | \`${SLUG}\` |" - echo "| Deno configs tracked here | **${N_DENO}** |" - echo "| Ledgered repositories estate-wide | **${N_ALLOWED}** |" - echo "| Verdict | exempt (shrink-only ledger) |" - echo "" - echo "Tracked configs:" - echo "" - # Backticks below are literal markdown, not command substitution. - # shellcheck disable=SC2016 - printf '%s\n' "$CONFIGS" | sed 's/^/- `/; s/$/`/' - echo "" - echo "_Exempt is not approved._ Deno is banned estate-wide. The ledger is" - echo "SHRINK-ONLY: \`scripts/check-exemption-ratchet.sh\` fails any commit that" - echo "adds a line to it without a \`Ratchet-exception\` trailer naming the file." - echo "" - } >> "$GITHUB_STEP_SUMMARY" - exit 0 - fi - - echo "::error::Deno is BANNED (owner ruling: 'deno is over, we're prioritising bun, and using bunx'). ${SLUG} tracks ${N_DENO} Deno config file(s) and is NOT on the exemption ledger (${N_ALLOWED} repositories listed). Migrate to Bun: port the deno.json 'tasks' map to package.json scripts run by 'bun run' — 'bun run' does not read a deno.json tasks map — and delete the config." - { - echo "### Deno (banned) — REFUSED" - echo "" - echo "| | |" - echo "|---|---|" - echo "| Repository | \`${SLUG}\` |" - echo "| Deno configs tracked here | **${N_DENO}** |" - echo "| Ledgered repositories estate-wide | **${N_ALLOWED}** |" - echo "| Verdict | **REFUSED — not ledgered** |" - echo "" - echo "Tracked configs:" - echo "" - # Backticks below are literal markdown, not command substitution. - # shellcheck disable=SC2016 - printf '%s\n' "$CONFIGS" | sed 's/^/- `/; s/$/`/' - echo "" - echo "Deno is banned estate-wide. Migrate to Bun, or — if this is genuinely" - echo "grandfathered debt — add \`${SLUG}\` to" - echo "\`.machine_readable/deno-allow.txt\` in \`hyperpolymath/standards\` with a" - echo "\`Ratchet-exception: .machine_readable/deno-allow.txt — \` commit" - echo "trailer, and bump this workflow's ledger pin." - echo "" - } >> "$GITHUB_STEP_SUMMARY" - exit 1 - # ─────────────────────────────────────────────────────────────────────── - # Job 2 — ReScript (grandfathered; banned for new code) - # ─────────────────────────────────────────────────────────────────────── - rescript: - name: ReScript - needs: detect - if: ${{ needs.detect.outputs.has_rescript == 'true' }} - runs-on: ${{ inputs.runs-on }} - timeout-minutes: 20 - permissions: - contents: read - steps: - - name: Checkout repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - name: Policy notice - run: | - echo "::warning::ReScript is BANNED for new code (owner ruling 2026-04-30). Existing .res migrates to .affine directly — AffineScript, not TypeScript. This job serves a grandfathered tree mid-migration." - - - name: Install Bun - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 - with: - bun-version: latest - - - name: Install dependencies - shell: bash - run: | - set -euo pipefail - # An npm lifecycle script is arbitrary code execution at CI - # privilege, so scripts stay off. MEASURED 2026-09-22 against the - # registry: rescript 11.1.4 declares postinstall - # 'node scripts/rescript_postinstall.js', but 12.3.1 declares NO - # postinstall at all -- it ships the compiler as per-platform - # optionalDependencies. So the flag only costs anything on the - # superseded major, and ReScript is a banned language here in any - # case: this job exists to lint grandfathered sources, not to make - # legacy installs convenient. - # - # --frozen-lockfile is the other half: without it bun may resolve a - # floating range, and CI would then be testing a compiler the repo - # does not pin. - bun install --frozen-lockfile --ignore-scripts - - # A missing binary after that install must be an ERROR, not a - # silent fall-through to a build step that cannot run. A skip is - # not a pass. - if ! bunx --no-install rescript -h >/dev/null 2>&1; then - echo "::error::rescript is not present after a --frozen-lockfile --ignore-scripts install. Upgrade to rescript >= 12, which needs no lifecycle script, or add the compiler to the lockfile. Lifecycle scripts are deliberately not enabled in this workflow." - exit 1 - fi - - - name: ReScript build (warnings are errors) + format check - shell: bash - run: | - set -euo pipefail - FAILED=0 - - # A compile IS the lint for ReScript; -warn-error +a promotes every - # warning. This writes to lib/, which is compiler output and not a - # tracked source file. - bunx --no-install rescript build -with-deps -warn-error +a || FAILED=1 - - # `rescript format` gained -check at different points in different - # majors. Probe for the capability rather than assume it: a gate that - # fails because the flag does not exist tells you nothing about the - # code, and one that silently passes tells you less. - if bunx --no-install rescript format --help 2>&1 | grep -q -- '-check'; then - bunx --no-install rescript format -all -check || { echo "::error::ReScript sources are not formatted"; FAILED=1; } - FMT='checked' - else - echo "::warning::The installed rescript CLI has no 'format -check'; the format gate did NOT run. This is a skip, not a pass." - FMT='**SKIPPED — CLI lacks -check**' - fi - - { - echo "### ReScript" - echo "" - echo "- \`rescript build -warn-error +a\`: $( [ "$FAILED" -eq 0 ] && echo 'pass' || echo '**FAIL**' )" - echo "- format check: ${FMT}" - echo "- policy: **banned for new code**, migrates to AffineScript" - echo "" - } >> "$GITHUB_STEP_SUMMARY" - exit "$FAILED" - - # ─────────────────────────────────────────────────────────────────────── - # Job 2 — V (grandfathered; banned, migration to Zig completed 2026-05-28) - # ─────────────────────────────────────────────────────────────────────── - v-lang: - name: V - needs: detect - if: ${{ needs.detect.outputs.has_v == 'true' }} - runs-on: ${{ inputs.runs-on }} - timeout-minutes: 20 - permissions: - contents: read - steps: - - name: Checkout repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - name: Policy notice - run: | - echo "::warning::V-lang is BANNED (owner ruling 2026-04-10; estate migration to Zig COMPLETED 2026-05-28 across 16 PRs). A v.mod here means either a carve-out (v-cartridge / v-adapter / v-bindings / v-client, asdf-vlang, archived polystack/) or a regression. Confirm which." - - - name: Install V (pinned, sha256-verified) - shell: bash - env: - V_VERSION: '0.5.2' - # Computed locally from the release archive. - V_SHA256: '86caf9e70c3342d48ef19eb4f6c47b709f18c90ae86255520d5c29df6b482e23' - run: | - set -euo pipefail - URL="https://github.com/vlang/v/releases/download/${V_VERSION}/v_linux.zip" - curl -sSfL --proto '=https' --proto-redir '=https' --tlsv1.2 --retry 3 -o "$RUNNER_TEMP/v_linux.zip" "$URL" - echo "${V_SHA256} ${RUNNER_TEMP}/v_linux.zip" | sha256sum -c - - unzip -q "$RUNNER_TEMP/v_linux.zip" -d "$RUNNER_TEMP" - echo "$RUNNER_TEMP/v" >> "$GITHUB_PATH" - - - name: V vet + format verify - shell: bash - run: | - set -euo pipefail - FAILED=0 - # ⚠ MEASURED on v 0.5.2: `v fmt -verify ` prints - # " is not vfmt'ed / Encountered a total of: 1 formatting - # errors." and then EXITS 0 — a fake green. Only the DIRECTORY form - # exits 1. Do not "simplify" this into a per-file loop: it would go - # on printing the same errors and stop failing on them. - # `v fmt -w` is the destructive form and must never appear in CI. - v fmt -verify . || { echo "::error::V sources are not formatted (v fmt -verify)"; FAILED=1; } - v vet . || { echo "::error::v vet reported problems"; FAILED=1; } - { - echo "### V" - echo "" - echo "- \`v fmt -verify\` + \`v vet\`: $( [ "$FAILED" -eq 0 ] && echo 'pass' || echo '**FAIL**' )" - echo "- policy: **banned**; migration to Zig completed 2026-05-28" - echo "" - } >> "$GITHUB_STEP_SUMMARY" - exit "$FAILED" - - # ─────────────────────────────────────────────────────────────────────── - # Job 2 — Haskell - # ─────────────────────────────────────────────────────────────────────── - haskell: - name: Haskell - needs: detect - if: ${{ needs.detect.outputs.has_haskell == 'true' }} - runs-on: ${{ inputs.runs-on }} - timeout-minutes: 30 - permissions: - contents: read - steps: - - name: Checkout repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - name: Install GHC and Cabal - uses: haskell-actions/setup@0f8e8c99d88aeb3fbfd523f1ef2c6f762d10d64d # v2.12.1 - with: - enable-stack: false - - - name: Build with warnings as errors - shell: bash - run: | - set -euo pipefail - # Haskell's lint IS the compiler here. hlint/fourmolu would each need - # a `cabal install` costing several minutes, and neither is pinned - # anywhere in the estate — an unpinned toolchain install is a worse - # trade than a narrower gate. -Wall -Werror is the spec's "fail on - # warnings" and is read-only with respect to tracked files. - cabal update - cabal build all --ghc-options='-Wall -Werror' - { - echo "### Haskell" - echo "" - echo "- \`cabal build all --ghc-options='-Wall -Werror'\`: pass" - echo "- note: format gate not run — no pinned fourmolu/hlint in the estate" - echo "" - } >> "$GITHUB_STEP_SUMMARY" - - # ─────────────────────────────────────────────────────────────────────── - # report — make every skip legible - # ─────────────────────────────────────────────────────────────────────── - report: - name: Pipeline report - needs: [detect, secret-scan, sast, rust, nickel, deno, rescript, v-lang, haskell] - if: ${{ always() }} - runs-on: ${{ inputs.runs-on }} - timeout-minutes: 5 - permissions: - contents: read - steps: - - name: Write skip ledger - shell: bash - env: - R_DETECT: ${{ needs.detect.result }} - # `detect` no longer exits 1 on a refusal, so R_DETECT is `success` - # even when the scan found nothing to check. Without this the Detect - # row below would print "pass", which is the precise opposite of what - # happened. The verdict travels as data; so must its rendering. - REFUSED: ${{ needs.detect.outputs.refused }} - REASON: ${{ needs.detect.outputs.refusal_reason }} - R_SECRET: ${{ needs.secret-scan.result }} - R_SAST: ${{ needs.sast.result }} - R_RUST: ${{ needs.rust.result }} - R_NICKEL: ${{ needs.nickel.result }} - R_DENO: ${{ needs.deno.result }} - R_RESCRIPT: ${{ needs.rescript.result }} - R_V: ${{ needs.v-lang.result }} - R_HASKELL: ${{ needs.haskell.result }} - H_RUST: ${{ needs.detect.outputs.has_rust }} - H_NICKEL: ${{ needs.detect.outputs.has_nickel }} - H_DENO: ${{ needs.detect.outputs.has_deno }} - H_RESCRIPT: ${{ needs.detect.outputs.has_rescript }} - H_V: ${{ needs.detect.outputs.has_v }} - H_HASKELL: ${{ needs.detect.outputs.has_haskell }} - N_UNSUP: ${{ needs.detect.outputs.n_unsupported }} - UNSUP: ${{ needs.detect.outputs.unsupported }} - run: | - set -euo pipefail - - # A skipped job renders as a grey tick that reads, at a glance, like - # a pass. Spell out which gates did NOT run and why, so nobody reads - # an absent check as a satisfied one. - row() { - local name="$1" result="$2" reason="$3" - case "$result" in - skipped) echo "| ${name} | skipped | ${reason} |" ;; - success) echo "| ${name} | pass | ran |" ;; - failure) echo "| ${name} | **FAIL** | ran |" ;; - *) echo "| ${name} | ${result} | ran |" ;; - esac - } - - { - echo "### Pipeline report" - echo "" - echo "| Gate | Result | Note |" - echo "|---|---|---|" - # ⚠ NOT a plain row(). `detect` succeeds as a JOB even when it - # refuses, because the refusal is an output rather than an exit - # code (see the refusal branch in that job). row() would map that - # `success` to the word "pass" and assert that the repository was - # examined. It was not. - if [ "${REFUSED:-false}" = "true" ]; then - echo "| Detect | **REFUSED** | ran, and found nothing it could check — ${REASON} |" - else - row "Detect" "$R_DETECT" "n/a" - fi - row "Secret scanning" "$R_SECRET" "n/a" - row "SAST (semgrep)" "$R_SAST" "disabled via enable_sast: false" - row "Rust" "$R_RUST" "no Cargo.toml tracked (has_rust=${H_RUST})" - row "Nickel" "$R_NICKEL" "no Nickel tracked, *.k9.ncl excluded (has_nickel=${H_NICKEL})" - row "Deno" "$R_DENO" "BANNED — refusal gate; skipped means no deno.json(c) tracked (has_deno=${H_DENO})" - row "ReScript" "$R_RESCRIPT" "no rescript.json/bsconfig.json/*.res tracked (has_rescript=${H_RESCRIPT})" - row "V" "$R_V" "no v.mod tracked (has_v=${H_V})" - row "Haskell" "$R_HASKELL" "no *.cabal/stack.yaml/*.hs tracked (has_haskell=${H_HASKELL})" - echo "" - echo "_A skipped gate examined nothing. It is not a pass._" - echo "" - # ⚠ NOT a row(). These ecosystems have no job, so they have no - # `needs.*.result` to report — and row()'s catch-all branch prints - # the word "ran" for an unset result, which would assert the exact - # opposite of the truth here. Say it in prose instead. - if [ "${N_UNSUP:-0}" -gt 0 ] 2>/dev/null; then - echo "> **Detected with no gate at all:** ${UNSUP}" - echo ">" - echo "> No job in this pipeline examines these. They are absent from the table" - echo "> above because there is no result to report, not because they passed." - echo "> Tracked as hyperpolymath/standards#967." - fi - echo "" - } >> "$GITHUB_STEP_SUMMARY" - - # ── The pipeline adoption ratchet ──────────────────────────────── - # - # Both steps below run ONLY when a gate has already failed, OR when - # `detect` refused because this pipeline has no gate for anything this - # repository is written in. A green repository never reads the ledger, - # never pays for the checkout, and cannot be affected by the ledger being - # stale, empty or unreachable. - # - # That is what makes this fail closed for free: on the failure path an - # unreadable ledger and an empty ledger give the SAME verdict — blocked. - # "Could not read the ledger" can never be mistaken for "is exempt". - - name: Checkout the pinned Standards pipeline ledger - if: ${{ contains(needs.*.result, 'failure') || needs.detect.outputs.refused == 'true' }} - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: hyperpolymath/standards - # Read the ledger from THIS workflow file's own commit. - # - # `job.workflow_sha` is "the commit SHA of the workflow file that - # defines the current job". This job is defined in THIS file, so when - # a caller invokes this reusable workflow the value is precisely the - # SHA that caller pinned in its `uses:`. - # - # ⚠ It is NOT `github.workflow_sha`, which inside a called reusable - # workflow is the CALLER's workflow file; that would look for the - # ledger in the caller's repository and find nothing. - # - # ⚠ Nor is it `github.job_workflow_sha`, which DOES NOT EXIST — the - # plausible-looking name is in the OIDC token claim set, not in the - # `github` context. actionlint rejects it, and github/docs confirms - # the `github` context has no such property. The workflow identity - # properties live on the `job` context: `workflow_sha`, - # `workflow_ref`, `workflow_repository`, `workflow_file_path`. The - # `job` context is available in `steps.*.with` and `steps.*.env`, - # which is all this file uses it for. - # - # ⚠ These four are unavailable on GitHub Enterprise Server. This - # estate is github.com; a GHES port would need a different key. - # - # This preserves in full the property the hand-written pin existed - # for: an edit to `main` in `standards` still cannot change the - # verdict of an already-pinned caller, because that caller goes on - # executing this commit. What it removes is the manual bump — and it - # makes the old comment's promise, "the pin and the first entry move - # in the SAME commit", literally true rather than aspirational, since - # the ledger and this line are now necessarily the same commit. - # - # It also repairs a real defect. This read `ref: a2ff696a`, an - # ABBREVIATED object id, and the git wire protocol cannot fetch one: - # `git fetch origin a2ff696a` fails with "couldn't find remote ref" - # where the full 40-hex succeeds. So the checkout ALWAYS failed. The - # `continue-on-error` below meant the verdict stayed correct - # (unreadable == empty == blocked) and nothing looked broken — but - # every exemption ever added would have been silently vacuous. - # ⚠ `git cat-file -t a2ff696a` answers `commit` locally, so a local - # probe CANNOT falsify a claim about remote fetchability. - ref: ${{ job.workflow_sha }} - path: .standards-pipeline-ledger - sparse-checkout: | - .machine_readable/pipeline-allow.txt - sparse-checkout-cone-mode: false - # Not fatal: the next step names what was missing and then FAILS CLOSED. - # hypatia: allow research_extensions/RE005 -- deliberate fail-closed: the Verdict step below treats an unreadable ledger exactly like an empty one, i.e. blocked. - continue-on-error: true - - - name: Verdict — block unless this repository is ledgered - if: ${{ contains(needs.*.result, 'failure') || needs.detect.outputs.refused == 'true' }} - shell: bash - env: - LEDGER: .standards-pipeline-ledger/.machine_readable/pipeline-allow.txt - SLUG: ${{ github.repository }} - # The ledger is read at this commit; printed below so that an empty - # context resolves VISIBLY rather than as a silent closed failure. - LEDGER_SHA: ${{ job.workflow_sha }} - REFUSED: ${{ needs.detect.outputs.refused }} - REASON: ${{ needs.detect.outputs.refusal_reason }} - # `secret-scan` and `sast` carry no `needs: detect`, so they run and - # can fail on a repository that ALSO refused. Both debts are real at - # once and the message below must be able to say so. - ANY_FAILED: ${{ contains(needs.*.result, 'failure') }} - run: | - # Every name above comes from the step `env:` map. - set -euo pipefail - - # Strip comments and blanks; this is also the ledger's own - # denominator. A missing file yields an empty list and N=0, which is - # the fail-closed path: nothing can match an empty list. - if [ -f "$LEDGER" ]; then - ALLOWED="$(grep -vE '^[[:space:]]*(#|$)' "$LEDGER" || true)" - else - ALLOWED="" - echo "::warning::Pipeline ledger not readable ($LEDGER). Treating as EMPTY, which blocks. This is not an exemption." - fi - N_ALLOWED="$(printf '%s' "$ALLOWED" | grep -c . || true)" - - # ALWAYS print the denominator, on both paths. A gate that only ever - # says yes proves nothing, and a `::warning::` cannot fail a job. - # Naming the commit matters: if `job_workflow_sha` were ever empty - # the checkout would fail and this would print an empty SHA, making - # the cause legible instead of leaving a bare fail-closed verdict. - echo "pipeline debt: ${N_ALLOWED} repos (ledger read at '${LEDGER_SHA}')" - - # A fail-closed verdict with no explanation is exactly how the - # abbreviated-SHA defect stayed invisible: the answer was right, so - # nobody asked why. If this is empty the checkout above cannot have - # succeeded, and the block below would be an artefact of the harness - # rather than a statement about this repository. Say so loudly. - if [ -z "${LEDGER_SHA:-}" ]; then - echo "::error::job.workflow_sha resolved EMPTY, so the exemption ledger could not be fetched and NOTHING can be exempt here. This is a pipeline defect, not a verdict about ${SLUG}. Note the job workflow-identity properties are unavailable on GitHub Enterprise Server." - fi - - # Two DIFFERENT debts arrive here and calling them the same thing - # would make the notice below assert something false. A gate that ran - # and failed is fixable in this repository; a refusal means no gate - # exists for what this repository is written in, which nothing done - # here can cure. - # - # ⚠ These are not mutually exclusive. `secret-scan` and `sast` have no - # `needs: detect`, so they run on every repository including one that - # refused. Treating this as an either/or would print "NOTHING WAS - # CHECKED" over a secret-scan that ran and failed — understating a - # real finding to describe a missing gate. Say both when both hold. - REF="${REFUSED:-false}" - FAILED="${ANY_FAILED:-false}" - if [ "$REF" = "true" ] && [ "$FAILED" = "true" ]; then - DEBT="TWO debts at once: no ecosystem gate could run (${REASON}), AND at least one gate that does not depend on detection REALLY FAILED" - FIX="Fix what the report table names, and add a gate for the ecosystems named above (see hyperpolymath/standards#967)." - elif [ "$REF" = "true" ]; then - DEBT="NOTHING WAS CHECKED — ${REASON}" - FIX="Add a gate for the ecosystems named above (see hyperpolymath/standards#967). Until one exists this repository cannot be made green by fixing its own code." - else - DEBT="at least one gate above REALLY FAILED — this is grandfathered debt, not a pass" - FIX="Fix what the report table names." - fi - - if printf '%s\n' "$ALLOWED" | grep -Fxq "$SLUG"; then - echo "::notice::${SLUG} is on the pipeline exemption ledger (pipeline debt: ${N_ALLOWED} repos). ${DEBT}. ${FIX} Then delete this repository's line from .machine_readable/pipeline-allow.txt." - { - echo "### Pipeline verdict — ledgered debt" - echo "" - echo "| | |" - echo "|---|---|" - echo "| Repository | \`${SLUG}\` |" - echo "| Verdict | **exempt** (shrink-only ledger) |" - echo "| pipeline debt | **${N_ALLOWED} repos** |" - echo "" - echo "| Debt | ${DEBT} |" - echo "" - echo "**An exemption is not a pass;** it is debt that someone agreed to" - echo "carry. ${FIX}" - echo "" - echo "Then DELETE this repository's line from" - echo "\`.machine_readable/pipeline-allow.txt\` in \`hyperpolymath/standards\` —" - echo "deletion needs no \`Ratchet-exception\` trailer, because shrinking is" - echo "the point." - echo "" - } >> "$GITHUB_STEP_SUMMARY" - exit 0 - fi - - echo "::error::${DEBT}, and ${SLUG} is not on the exemption ledger (pipeline debt: ${N_ALLOWED} repos). See the report table in the step summary." - { - echo "### Pipeline verdict — BLOCKED" - echo "" - echo "| | |" - echo "|---|---|" - echo "| Repository | \`${SLUG}\` |" - echo "| Verdict | **blocked** (not ledgered) |" - echo "| Debt | ${DEBT} |" - echo "| pipeline debt | **${N_ALLOWED} repos** |" - echo "" - echo "The report table above names every gate that failed. Fix them, or — if" - echo "this is debt the estate has agreed to carry — add \`${SLUG}\` to" - echo "\`.machine_readable/pipeline-allow.txt\` in \`hyperpolymath/standards\`," - echo "bumping the ledger pin in the same commit, with a" - echo "\`Ratchet-exception: .machine_readable/pipeline-allow.txt — \`" - echo "trailer in column 0." - echo "" - } >> "$GITHUB_STEP_SUMMARY" - exit 1 +{ + name: "CI Pipeline (reusable)", + on: { + workflow_call: { + inputs: { + runs-on: { + description: "Runner label for every job in this pipeline.", + type: "string", + default: "ubuntu-latest", + }, + enable_sast: { + description: "Run the semgrep SAST job.", + type: "boolean", + default: true, + }, + sast_blocking: { + description: "Fail the build on semgrep findings. Default false per the owner's standing ruling that a new scanner finding is an issue, not a blocker. The zero-files-scanned check is fatal regardless.", + type: "boolean", + default: false, + }, + enable_coverage: { + description: "Pass through to rust-ci-reusable.yml.", + type: "boolean", + default: true, + }, + coverage_floor: { + description: "Minimum coverage percentage. Ratchet upward, never lower.", + type: "string", + default: "0", + }, + fail_on_no_ecosystem: { + description: "Fail `detect` when no known ecosystem is found. Leave true: this is the guard against a pipeline that passes by matching nothing.", + type: "boolean", + default: true, + }, + }, + }, + }, + permissions: { + contents: "read", + }, + jobs: { + # ─────────────────────────────────────────────────────────────────────── + # detect — the denominator + # ─────────────────────────────────────────────────────────────────────── + detect: { + name: "Detect ecosystems", + runs-on: "${{ inputs.runs-on }}", + timeout-minutes: 5, + permissions: { + contents: "read", + }, + outputs: { + has_rust: "${{ steps.scan.outputs.has_rust }}", + has_nickel: "${{ steps.scan.outputs.has_nickel }}", + has_rescript: "${{ steps.scan.outputs.has_rescript }}", + has_v: "${{ steps.scan.outputs.has_v }}", + has_haskell: "${{ steps.scan.outputs.has_haskell }}", + has_deno: "${{ steps.scan.outputs.has_deno }}", + # Ecosystems this pipeline can DETECT but has no job to CHECK. Reported + # separately from `total` on purpose — see the scan step's comment. + n_unsupported: "${{ steps.scan.outputs.n_unsupported }}", + unsupported: "${{ steps.scan.outputs.unsupported }}", + # The Deno gate REFUSES and names what it found, so it needs the + # count itself, not merely the boolean. + n_deno: "${{ steps.scan.outputs.n_deno }}", + total: "${{ steps.scan.outputs.total }}", + # The refusal VERDICT, carried as data rather than as an exit code. + # `detect` measures; `report` is the single judge that can fail the run. + # See the refusal branch at the end of the scan step for why. + refused: "${{ steps.scan.outputs.refused }}", + refusal_reason: "${{ steps.scan.outputs.refusal_reason }}", + }, + steps: [ + { + name: "Checkout repository", + uses: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", # v7.0.1 + }, + { + name: "Scan tracked files for ecosystem markers", + id: "scan", + shell: "bash", + run: "set -euo pipefail\n\n# `git ls-files` lists TRACKED files only. `find` would descend into\n# vendored trees (node_modules/, deps/, target/) and report an\n# ecosystem this repo does not own.\ncount() {\n git ls-files -z -- \"$@\" | tr -cd '\\0' | wc -c\n}\n\n# ── SUPPORTED ecosystems ────────────────────────────────────────\n# Every probe here has a CONSUMING job later in this file. That is\n# the entry requirement for this list, not a coincidence.\nN_RUST=$(count 'Cargo.toml' '*/Cargo.toml')\n# A suffix is not a format. `*.k9.ncl` files open with a `K9!` sentinel on\n# line 1 and are k9 contracts, not Nickel source — `nickel typecheck` dies\n# at 1:3 on the `!`. They are excluded here so `has_nickel` cannot go true\n# on a repo holding no Nickel at all, and excluded by the SAME pathspec in\n# the `nickel` job: a guard that counts a different set than its consumer\n# checks is how a gate comes to fail over files the repo cannot fix.\nN_NICKEL=$(count '*.ncl' ':!*.k9.ncl')\nN_K9=$(count '*.k9.ncl')\nN_RESCRIPT=$(count 'rescript.json' '*/rescript.json' 'bsconfig.json' '*/bsconfig.json' '*.res')\n# V is gated on `v.mod` ALONE. The `.v` extension is shared with Coq\n# proof scripts and Verilog sources, so globbing `*.v` would install\n# a V toolchain to lint a Coq development.\nN_V=$(count 'v.mod' '*/v.mod')\nN_HASKELL=$(count '*.cabal' 'stack.yaml' '*/stack.yaml' '*.hs')\n# ⚠ DO NOT REMOVE THE DENO PROBE. It does not exist to lint Deno —\n# it is the trigger for the REFUSAL job `deno` below, i.e. the teeth\n# of the estate's standing no-Deno ruling, with slugs still live in\n# `.machine_readable/deno-allow.txt`. Deleting the probe would not\n# \"drop support\"; it would silently un-enforce a ban, because the\n# refusal simply stops firing and the repo goes green.\nN_DENO=$(count 'deno.json' '*/deno.json' 'deno.jsonc' '*/deno.jsonc')\n\n# TOTAL counts ONLY ecosystems some job in this file actually\n# examines. Deno counts: a refusal IS a check.\nTOTAL=$(( N_RUST + N_NICKEL + N_RESCRIPT + N_V + N_HASKELL + N_DENO ))\n\n# ── DETECTED BUT UNSUPPORTED ────────────────────────────────────\n# These have NO lint or format job in this file. They are counted\n# SEPARATELY and deliberately DO NOT enter TOTAL.\n#\n# ⚠ THAT SEPARATION IS THE ENTIRE POINT OF THIS SECTION. Folding\n# them into TOTAL would lift it above zero and SILENCE the\n# zero-denominator refusal below while nothing whatsoever had been\n# checked. That is a vacuous gate, and a vacuous gate is worse than\n# an honest blind spot: the blind spot reports nothing, the vacuous\n# gate reports success. A probe moves from this list to the one\n# above ONLY in the same commit that adds its consuming job.\n#\n# Keyed on MANIFESTS, not extensions, for the reason `v.mod` already\n# is: an extension is shared between languages, a manifest is not.\nUNSUPPORTED=\"\"\nN_UNSUPPORTED=0\nprobe() {\n local name=\"$1\"; shift\n local n\n n=$(count \"$@\")\n if [ \"$n\" -gt 0 ]; then\n UNSUPPORTED=\"${UNSUPPORTED:+${UNSUPPORTED}, }${name} (${n})\"\n N_UNSUPPORTED=$(( N_UNSUPPORTED + n ))\n fi\n}\n\nprobe 'Julia' 'Project.toml' '*/Project.toml'\nprobe 'Zig' 'build.zig' '*/build.zig' 'build.zig.zon' '*/build.zig.zon'\n# `*.idr` as well as the manifest, because Idris2 sources routinely\n# ship without an `.ipkg` (ddraig-ssg tracks 4 `.idr` files and no\n# package file). Extension-keyed probes are the exception here, not\n# the rule — `.v` is deliberately NOT one, because it is also Coq and\n# Verilog — but `.idr` is unambiguous, exactly like the `*.hs` and\n# `*.res` probes above. This moves such a repository from detector\n# BLINDNESS to honest measured debt; it does not make it green.\nprobe 'Idris2' '*.ipkg' '*.idr'\nprobe 'Elixir' 'mix.exs' '*/mix.exs'\nprobe 'Lean' 'lakefile.toml' '*/lakefile.toml' 'lakefile.lean' '*/lakefile.lean' 'lean-toolchain' '*/lean-toolchain'\nprobe 'Agda' '*.agda-lib'\nprobe 'Ada' 'alire.toml' '*/alire.toml' '*.gpr'\nprobe 'Ruby' 'Gemfile' '*/Gemfile' '*.gemspec'\nprobe 'PHP' 'composer.json' '*/composer.json'\nprobe 'Gleam' 'gleam.toml' '*/gleam.toml'\n# ⚠ `ROOT` is an unusually generic filename and this probe is the\n# most likely of the set to produce a FALSE POSITIVE. It is tolerable\n# only because a false positive here cannot bless anything: it can\n# only move a repo from \"nothing detected\" to \"detected, unchecked\",\n# and both refuse. It must NOT be promoted to the supported list on\n# this evidence alone.\nprobe 'Isabelle' 'ROOT' '*/ROOT'\n# Bun. `bun.lock*` and `bunfig.toml` are positive identification.\n# ⚠ `package.json` ALONE is an ASSUMPTION, stated here rather than\n# hidden: the estate's standing ruling is bun-only, with Node and\n# Deno banned, so a bare package.json in this estate is presumed to\n# be a Bun manifest. That assumption is safe while Bun sits in THIS\n# list — it yields a refusal, never a pass. Re-examine it on the day\n# a Bun lint job is added, because then it would start selecting\n# which repositories get linted.\nprobe 'Bun' 'bun.lock' '*/bun.lock' 'bun.lockb' '*/bun.lockb' 'bunfig.toml' '*/bunfig.toml' 'package.json' '*/package.json'\n\nbool() { [ \"$1\" -gt 0 ] && echo true || echo false; }\n\n{\n echo \"has_rust=$(bool \"$N_RUST\")\"\n echo \"has_nickel=$(bool \"$N_NICKEL\")\"\n echo \"has_rescript=$(bool \"$N_RESCRIPT\")\"\n echo \"has_v=$(bool \"$N_V\")\"\n echo \"has_haskell=$(bool \"$N_HASKELL\")\"\n echo \"has_deno=$(bool \"$N_DENO\")\"\n echo \"n_deno=$N_DENO\"\n echo \"total=$TOTAL\"\n echo \"n_unsupported=$N_UNSUPPORTED\"\n echo \"unsupported=$UNSUPPORTED\"\n} >> \"$GITHUB_OUTPUT\"\n\n# Print the denominator. A gate that never shows what it measured\n# cannot be distinguished from a gate that measured nothing.\n{\n echo \"### Ecosystem detection\"\n echo \"\"\n echo \"| Ecosystem | Marker | Tracked files matched |\"\n echo \"|---|---|---|\"\n echo \"| Rust | \\`Cargo.toml\\` | ${N_RUST} |\"\n echo \"| Nickel | \\`*.ncl\\` excluding \\`*.k9.ncl\\` | ${N_NICKEL} |\"\n echo \"| ReScript | \\`rescript.json\\`, \\`bsconfig.json\\`, \\`*.res\\` | ${N_RESCRIPT} |\"\n echo \"| V | \\`v.mod\\` only | ${N_V} |\"\n echo \"| Haskell | \\`*.cabal\\`, \\`stack.yaml\\`, \\`*.hs\\` | ${N_HASKELL} |\"\n echo \"| Deno | \\`deno.json(c)\\` | ${N_DENO} |\"\n echo \"| **Total checkable** | | **${TOTAL}** |\"\n echo \"\"\n if [ \"$N_K9\" -gt 0 ]; then\n echo \"> **${N_K9} \\`*.k9.ncl\\` excluded.** These are k9 contracts (a \\`K9!\\` sentinel on line 1), not Nickel source. They are neither typechecked nor counted above, and they cannot raise the Total.\"\n echo \"\"\n fi\n if [ \"$N_UNSUPPORTED\" -gt 0 ]; then\n echo \"#### Detected, but this pipeline has no gate for it\"\n echo \"\"\n echo \"${UNSUPPORTED}\"\n echo \"\"\n echo \"_These files were found and **not checked**. They are excluded from the\"\n echo \"total above on purpose: counting them would hide the fact that nothing\"\n echo \"examined them. Tracked as hyperpolymath/standards#967._\"\n echo \"\"\n fi\n} >> \"$GITHUB_STEP_SUMMARY\"\n\n# A refusal is a VERDICT, not an exit code.\n#\n# This block used to `exit 1` here, which quietly made `detect` a\n# SECOND judge. The exemption ledger in the `report` job could spare\n# only `report`'s own `exit 1`, so a ledgered repository still went\n# red on this job and could never actually go green. That is the\n# vacuous gate's mirror image: a ledger that can never grant the\n# thing it promises.\n#\n# So the verdict travels to `report` as an output and this job stays\n# green. Nothing is weakened. `report` runs `if: always()`, reads this\n# output in its own `if:`, and fails closed when the repository is not\n# on the ledger — an unreadable or empty ledger still blocks.\nREFUSED=false\nMSG=\"\"\nif [ \"$TOTAL\" -eq 0 ]; then\n # Same switch, two different truths. Telling them apart is what\n # makes the adoption ledger measure real debt instead of detector\n # blindness: \"we have no gate for Julia\" and \"we could not\n # recognise this repository at all\" need different cures.\n if [ \"$N_UNSUPPORTED\" -gt 0 ]; then\n MSG=\"Detected ${UNSUPPORTED}, but this pipeline has no lint or format job for any of them, so NOTHING was checked. This is measured debt, not an unrecognised repository — see hyperpolymath/standards#967.\"\n else\n MSG=\"No known ecosystem detected (denominator = 0). Every lint and format gate would be vacuous, so this pipeline refuses to report success. Add the ecosystem's marker file, or call this workflow with fail_on_no_ecosystem: false and say why.\"\n fi\n if [ \"${{ inputs.fail_on_no_ecosystem }}\" = \"true\" ]; then\n echo \"::error::${MSG}\"\n echo \"**REFUSED: nothing could be checked.** ${MSG}\" >> \"$GITHUB_STEP_SUMMARY\"\n REFUSED=true\n else\n # ⚠ This escape hatch is warning-only, and a `::warning::` cannot\n # fail a job. It is therefore NOT a route to green for estate\n # adoption: it is unratcheted, invisible to the exemption\n # denominator, and indistinguishable from a pass. Repositories\n # with no gate belong on the ledger, where the debt is counted.\n echo \"::warning::${MSG} fail_on_no_ecosystem is false, so continuing with nothing checked.\"\n fi\nfi\n\n# Written on BOTH paths, so a consumer can never confuse \"did not\n# refuse\" with \"the output was never set\".\n{\n echo \"refused=${REFUSED}\"\n echo \"refusal_reason=${MSG}\"\n} >> \"$GITHUB_OUTPUT\"\n", + }, + ], + }, + # ─────────────────────────────────────────────────────────────────────── + # Job 1a — secret scanning (delegated: pinned, sha256-verified gitleaks) + # ─────────────────────────────────────────────────────────────────────── + secret-scan: { + 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", + with: { + runs-on: "${{ inputs.runs-on }}", + }, + }, + # ─────────────────────────────────────────────────────────────────────── + # Job 1b — SAST (semgrep OSS rulesets, no account, no telemetry) + # ─────────────────────────────────────────────────────────────────────── + sast: { + name: "SAST (semgrep)", + if: "${{ inputs.enable_sast }}", + runs-on: "${{ inputs.runs-on }}", + timeout-minutes: 20, + permissions: { + contents: "read", + }, + steps: [ + { + name: "Checkout repository", + uses: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", # v7.0.1 + }, + { + name: "Run semgrep (p/default + p/security-audit)", + id: "semgrep", + shell: "bash", + env: { + # Digest resolved from the Docker Hub registry, not copied from a + # tag. A tag is mutable; this is the image or nothing. + SEMGREP_IMAGE: "semgrep/semgrep@sha256:10301f060aacf84078f9704fb1ba3a321df4ac46b009fd29c1c66880d1db8e77", + }, + run: "set -uo pipefail\n\n# Run through `docker run` rather than a job-level `container:` so\n# that checkout and the jq post-processing happen on the runner,\n# where jq is preinstalled. The semgrep image's own tooling is not\n# relied upon for anything but semgrep itself.\n#\n# --metrics off: OSS rulesets, no account, nothing phoned home.\n# A config that fails to resolve makes semgrep exit non-zero, so\n# \"the ruleset did not download\" cannot masquerade as \"clean\".\nset +e\ndocker run --rm \\\n -v \"${PWD}:/src\" -w /src \\\n \"$SEMGREP_IMAGE\" \\\n semgrep scan \\\n --config p/default \\\n --config p/security-audit \\\n --metrics off \\\n --disable-version-check \\\n --json --output /src/semgrep.json \\\n 2> \"$RUNNER_TEMP/semgrep.err\"\nSEMGREP_RC=$?\nset -e\n\necho \"semgrep exit code: $SEMGREP_RC\"\necho \"--- semgrep stderr (rule loading and scan totals) ---\"\ncat \"$RUNNER_TEMP/semgrep.err\" || true\necho \"-----------------------------------------------------\"\n\nif [ ! -s semgrep.json ]; then\n echo \"::error::semgrep produced no JSON report (exit $SEMGREP_RC). Treating as a scan failure, not as a clean result.\"\n echo \"### SAST (semgrep): FAILED — no report produced\" >> \"$GITHUB_STEP_SUMMARY\"\n exit 1\nfi\n\n# A report can exist and still be PARTIAL: semgrep exits non-zero on\n# rule-loading and scan errors while still writing what it managed to\n# collect. Reading only the JSON would report that truncated scan as a\n# clean tree. The exit code is the only signal that separates them.\nif [ \"$SEMGREP_RC\" -ne 0 ]; then\n echo \"::error::semgrep exited $SEMGREP_RC. A partial report is not a clean result.\"\n echo \"### SAST (semgrep): FAILED — scanner exited $SEMGREP_RC\" >> \"$GITHUB_STEP_SUMMARY\"\n exit 1\nfi\n\nSCANNED=$(jq '(.paths.scanned // []) | length' semgrep.json)\nFINDINGS=$(jq '(.results // []) | length' semgrep.json)\nRULES_FIRED=$(jq '[(.results // [])[].check_id] | unique | length' semgrep.json)\nERRORS=$(jq '(.errors // []) | length' semgrep.json)\n\n{\n echo \"### SAST (semgrep)\"\n echo \"\"\n echo \"| Measure | Value |\"\n echo \"|---|---|\"\n echo \"| Files scanned | ${SCANNED} |\"\n echo \"| Findings | ${FINDINGS} |\"\n echo \"| Distinct rules fired | ${RULES_FIRED} |\"\n echo \"| Scan errors | ${ERRORS} |\"\n echo \"| Rulesets | \\`p/default\\`, \\`p/security-audit\\` |\"\n echo \"\"\n} >> \"$GITHUB_STEP_SUMMARY\"\n\n# THE LOAD-BEARING ASSERTION. Zero files scanned means every rule in\n# both rulesets was applied to nothing, which produces exactly the\n# same green tick as a genuinely clean tree.\nif [ \"$SCANNED\" -eq 0 ]; then\n echo \"::error::semgrep scanned 0 files. Zero findings over zero files is not a clean result — it is no result. Failing rather than reporting a vacuous pass.\"\n echo \"**REFUSED: 0 files scanned — a vacuous pass, not a clean tree.**\" >> \"$GITHUB_STEP_SUMMARY\"\n exit 1\nfi\n\nif [ \"$FINDINGS\" -gt 0 ]; then\n {\n echo \"| Severity | Rule | File | Line |\"\n echo \"|---|---|---|---|\"\n jq -r '(.results // [])[:100][]\n | \"| \\(.extra.severity // \"INFO\") | `\\(.check_id)` | `\\(.path)` | \\(.start.line) |\"' \\\n semgrep.json\n echo \"\"\n } >> \"$GITHUB_STEP_SUMMARY\"\n\n if [ \"${{ inputs.sast_blocking }}\" = \"true\" ]; then\n echo \"::error::semgrep reported ${FINDINGS} finding(s) and sast_blocking is true.\"\n exit 1\n fi\n # Advisory by owner ruling: a new scanner finding is an issue with\n # acceptance criteria, not a merge blocker. The findings are in the\n # step summary above, so this is visible rather than swallowed.\n echo \"::warning::semgrep reported ${FINDINGS} finding(s). Advisory by standing ruling — raise an issue with acceptance criteria. Set sast_blocking: true to gate on these.\"\nfi\n", + }, + ], + }, + # ─────────────────────────────────────────────────────────────────────── + # Job 2/3 — Rust: lint, format, build, test, coverage (delegated) + # ─────────────────────────────────────────────────────────────────────── + rust: { + name: "Rust", + needs: "detect", + if: "${{ needs.detect.outputs.has_rust == 'true' }}", + uses: "hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml@571cc734cd69fb846032ec77a662aa8ee4fc32cd", + with: { + runs-on: "${{ inputs.runs-on }}", + enable_coverage: "${{ inputs.enable_coverage }}", + coverage_floor: "${{ inputs.coverage_floor }}", + # Read-only. `-D warnings` promotes every warning to an error, which is + # the "fail on warnings" the spec asked for. + clippy_args: "--all-targets -- -D warnings", + }, + }, + # ─────────────────────────────────────────────────────────────────────── + # Job 2 — Nickel + # ─────────────────────────────────────────────────────────────────────── + nickel: { + name: "Nickel", + needs: "detect", + if: "${{ needs.detect.outputs.has_nickel == 'true' }}", + runs-on: "${{ inputs.runs-on }}", + timeout-minutes: 15, + permissions: { + contents: "read", + }, + steps: [ + { + name: "Checkout repository", + uses: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", # v7.0.1 + }, + { + name: "Install Nickel (pinned, sha256-verified)", + shell: "bash", + env: { + NICKEL_VERSION: "1.18.0", + # Computed locally from the release asset, not transcribed from a + # web page. Same discipline as secret-scanner-reusable.yml's gitleaks + # pin: a download whose bytes are not checked is not a pin. + NICKEL_SHA256: "9cba4dd65ae9915ec61f73033aafcff307a377665a83fd8f530df086763318cb", + }, + run: "set -euo pipefail\nURL=\"https://github.com/tweag/nickel/releases/download/${NICKEL_VERSION}/nickel-x86_64-linux\"\ncurl -sSfL --proto '=https' --proto-redir '=https' --tlsv1.2 --retry 3 -o \"$RUNNER_TEMP/nickel\" \"$URL\"\necho \"${NICKEL_SHA256} ${RUNNER_TEMP}/nickel\" | sha256sum -c -\nchmod +x \"$RUNNER_TEMP/nickel\"\necho \"$RUNNER_TEMP\" >> \"$GITHUB_PATH\"\n", + }, + { + name: "Nickel format check + typecheck", + shell: "bash", + run: "set -euo pipefail\n\n# Same pathspec as `detect`. k9 contracts are a DIFFERENT format that\n# merely shares the suffix; typechecking them reports a parse error the\n# repo has no way to fix.\nmapfile -d '' FILES < <(git ls-files -z -- '*.ncl' ':!*.k9.ncl')\nN_K9=$(git ls-files -z -- '*.k9.ncl' | tr -cd '\\0' | wc -c)\n\necho \"Nickel files: ${#FILES[@]} (k9 contracts excluded: ${N_K9})\"\nif [ \"${#FILES[@]}\" -eq 0 ]; then\n echo \"::error::detect said Nickel was present but zero Nickel files are tracked here (${N_K9} *.k9.ncl were excluded as k9 contracts). Refusing to report a pass over an empty set.\"\n exit 1\nfi\n\nFAILED=0\nTYPECHECKED=0\nSKIPPED=()\n\nfor f in \"${FILES[@]}\"; do\n # --check is read-only: it reports, it does not rewrite the file.\n # Format is checked over EVERY genuine Nickel file, with no exemption.\n nickel format --check \"$f\" || { echo \"::error file=$f::not formatted (nickel format --check)\"; FAILED=1; }\n\n # A file importing a build artefact cannot be typechecked standalone:\n # the input is generated immediately before use and is correctly\n # uncommitted. Detect that by asking git whether the import target is\n # tracked, and DISCLOSE every skip by name. A skip is not a pass.\n MISSING=\"\"\n while IFS= read -r imp; do\n [ -n \"$imp\" ] || continue\n cand=\"$(dirname \"$f\")/$imp\"\n cand=\"$(realpath -m --relative-to=. \"$cand\" 2>/dev/null || echo \"$cand\")\"\n git ls-files --error-unmatch -- \"$cand\" >/dev/null 2>&1 \\\n || MISSING=\"${MISSING}${MISSING:+, }${imp}\"\n # Comments are stripped first: `_base.ncl` documents its own usage\n # in a `#` comment containing a literal `import \"../_base.ncl\"`, and\n # matching that would skip a file that typechecks perfectly well.\n # hypatia: allow research_extensions/RE005 -- `|| true` absorbs grep's exit 1 on a file with no imports; an empty import list is the correct answer there, and the typecheck below still gates.\n done < <(sed 's/#.*//' \"$f\" | grep -oE 'import[[:space:]]+\"[^\"]+\"' | sed -E 's/.*\"([^\"]+)\".*/\\1/' || true)\n\n if [ -n \"$MISSING\" ]; then\n echo \"::notice file=$f::nickel typecheck skipped - import target not tracked in git: ${MISSING}\"\n SKIPPED+=(\"$f (${MISSING})\")\n continue\n fi\n\n nickel typecheck \"$f\" || { echo \"::error file=$f::failed nickel typecheck\"; FAILED=1; }\n TYPECHECKED=$((TYPECHECKED + 1))\ndone\n\n# Narrowing a file set must never be able to manufacture a pass.\nif [ \"$TYPECHECKED\" -eq 0 ]; then\n echo \"::error::all ${#FILES[@]} Nickel files were skipped for untracked imports, so nothing was typechecked. Refusing to report a pass over an empty set.\"\n FAILED=1\nfi\n\n{\n echo \"### Nickel\"\n echo \"\"\n echo \"- format checked: **${#FILES[@]}**\"\n echo \"- typechecked: **${TYPECHECKED}**\"\n echo \"- typecheck skipped (untracked import): **${#SKIPPED[@]}**\"\n echo \"- k9 contracts excluded (\\`*.k9.ncl\\`, not Nickel): **${N_K9}**\"\n if [ \"${#SKIPPED[@]}\" -gt 0 ]; then\n echo \"\"\n echo \"Skipped, and why:\"\n echo \"\"\n for s in \"${SKIPPED[@]}\"; do echo \"- \\`${s}\\`\"; done\n fi\n echo \"\"\n echo \"- result: $( [ \"$FAILED\" -eq 0 ] && echo 'pass' || echo '**FAIL**' )\"\n echo \"\"\n} >> \"$GITHUB_STEP_SUMMARY\"\n\nexit \"$FAILED\"\nexit \"$FAILED\"\n", + }, + ], + }, + # ─────────────────────────────────────────────────────────────────────── + # Job 2 — Deno REFUSAL gate (BANNED; shrink-only ledger exemption) + # ─────────────────────────────────────────────────────────────────────── + # + # Owner ruling: "deno is over, we're prioritising bun, and using bunx." + # This job used to install a pinned Deno binary and run `deno lint` / + # `deno fmt --check`, announcing the ban with a `::warning::`. A + # `::warning::` CANNOT fail a job, so that job asserted the opposite of what + # it said: it lint-checked, and thereby blessed, the very thing it called + # banned. It now REFUSES, and the only way past it is the central + # shrink-only ledger `.machine_readable/deno-allow.txt` in `standards`. + # + # Both outcomes print their denominator. A gate that only ever says yes + # proves nothing, and an exemption that reports nothing is a silent pass. + deno: { + name: "Deno (banned)", + needs: "detect", + if: "${{ needs.detect.outputs.has_deno == 'true' }}", + runs-on: "${{ inputs.runs-on }}", + timeout-minutes: 10, + permissions: { + contents: "read", + }, + steps: [ + { + name: "Checkout repository", + uses: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", # v7.0.1 + }, + # The ledger is CENTRAL: one file in `standards`, not a per-repo opt-out, + # so the estate's Deno debt is countable from one place and the ratchet + # can forbid its growth. Pinned to an immutable commit for the same + # reason as the governance helpers — `github.workflow_sha` resolves to + # the CALLER's commit, and following `main` would let an edit in + # `standards` change the verdict of every already-pinned caller with no + # review in their repositories. + { + name: "Checkout the pinned Standards Deno ledger", + uses: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", # v7.0.1 + with: { + repository: "hyperpolymath/standards", + # 938264d8 is the commit that introduced the ledger. ⚠ BUMP THIS + # whenever .machine_readable/deno-allow.txt changes, or the gate goes + # on judging callers against a stale list. A stale list is SAFE in + # one direction only: a repository that migrated off Deno stops + # tracking deno.json, so the job does not run at all and its lingering + # exemption never fires. A repository that is ADDED to the ledger + # stays red here until this pin moves — which is the correct default, + # because the addition needs a Ratchet-exception trailer anyway. + ref: "938264d8b0bbc3ca079e3d02efff5c1d423d0329", + path: ".standards-deno-ledger", + sparse-checkout: ".machine_readable/deno-allow.txt\n", + sparse-checkout-cone-mode: false, + }, + # Not fatal here: the next step names precisely what was missing and + # then FAILS CLOSED. A fetch failure must never read as an exemption. + # hypatia: allow research_extensions/RE005 -- deliberate fail-closed: "Refuse Deno unless this repository is ledgered" below checks for the ledger file and exits 1 with an explicit ::error:: if missing, regardless of why. + continue-on-error: true, + }, + { + name: "Refuse Deno unless this repository is ledgered", + shell: "bash", + env: { + LEDGER: ".standards-deno-ledger/.machine_readable/deno-allow.txt", + SLUG: "${{ github.repository }}", + N_DENO: "${{ needs.detect.outputs.n_deno }}", + }, + run: "# LEDGER, SLUG and N_DENO come from the step `env:` map above.\nset -euo pipefail\n\n# FAIL CLOSED. \"Could not read the ledger\" is not \"is exempt\".\nif [ ! -f \"$LEDGER\" ]; then\n echo \"::error::Deno gate: the central ledger could not be read ($LEDGER). This is NOT an exemption — the gate refuses rather than guess. Check the pinned ref of the 'Checkout the pinned Standards Deno ledger' step.\"\n {\n echo \"### Deno (banned)\"\n echo \"\"\n echo \"**REFUSED: the exemption ledger could not be read.** A gate that cannot\"\n echo \"check its exemption list must not pass; this is not an exemption.\"\n echo \"\"\n } >> \"$GITHUB_STEP_SUMMARY\"\n exit 1\nfi\n\n# Strip comments and blanks; this is also the ledger's own denominator.\nALLOWED=\"$(grep -vE '^[[:space:]]*(#|$)' \"$LEDGER\" || true)\"\nN_ALLOWED=\"$(printf '%s' \"$ALLOWED\" | grep -c . || true)\"\n\n# What this repository actually tracks, named rather than counted, so\n# the migration has a list to work from.\nCONFIGS=\"$(git ls-files -- 'deno.json' '*/deno.json' 'deno.jsonc' '*/deno.jsonc')\"\n\necho \"Deno debt: ${N_ALLOWED} ledgered repositories estate-wide.\"\necho \"This repository (${SLUG}) tracks ${N_DENO} Deno config file(s):\"\nprintf '%s\\n' \"$CONFIGS\" | sed 's/^/ - /'\n\nif printf '%s\\n' \"$ALLOWED\" | grep -Fxq \"$SLUG\"; then\n echo \"::notice::${SLUG} is on the Deno exemption ledger (${N_ALLOWED} repositories). Deno remains BANNED: this is grandfathered debt, not a licence. Port the deno.json 'tasks' map to package.json scripts run by 'bun run', delete the config, then delete this repository's line from .machine_readable/deno-allow.txt.\"\n {\n echo \"### Deno (banned) — ledgered debt\"\n echo \"\"\n echo \"| | |\"\n echo \"|---|---|\"\n echo \"| Repository | \\`${SLUG}\\` |\"\n echo \"| Deno configs tracked here | **${N_DENO}** |\"\n echo \"| Ledgered repositories estate-wide | **${N_ALLOWED}** |\"\n echo \"| Verdict | exempt (shrink-only ledger) |\"\n echo \"\"\n echo \"Tracked configs:\"\n echo \"\"\n # Backticks below are literal markdown, not command substitution.\n # shellcheck disable=SC2016\n printf '%s\\n' \"$CONFIGS\" | sed 's/^/- `/; s/$/`/'\n echo \"\"\n echo \"_Exempt is not approved._ Deno is banned estate-wide. The ledger is\"\n echo \"SHRINK-ONLY: \\`scripts/check-exemption-ratchet.sh\\` fails any commit that\"\n echo \"adds a line to it without a \\`Ratchet-exception\\` trailer naming the file.\"\n echo \"\"\n } >> \"$GITHUB_STEP_SUMMARY\"\n exit 0\nfi\n\necho \"::error::Deno is BANNED (owner ruling: 'deno is over, we're prioritising bun, and using bunx'). ${SLUG} tracks ${N_DENO} Deno config file(s) and is NOT on the exemption ledger (${N_ALLOWED} repositories listed). Migrate to Bun: port the deno.json 'tasks' map to package.json scripts run by 'bun run' — 'bun run' does not read a deno.json tasks map — and delete the config.\"\n{\n echo \"### Deno (banned) — REFUSED\"\n echo \"\"\n echo \"| | |\"\n echo \"|---|---|\"\n echo \"| Repository | \\`${SLUG}\\` |\"\n echo \"| Deno configs tracked here | **${N_DENO}** |\"\n echo \"| Ledgered repositories estate-wide | **${N_ALLOWED}** |\"\n echo \"| Verdict | **REFUSED — not ledgered** |\"\n echo \"\"\n echo \"Tracked configs:\"\n echo \"\"\n # Backticks below are literal markdown, not command substitution.\n # shellcheck disable=SC2016\n printf '%s\\n' \"$CONFIGS\" | sed 's/^/- `/; s/$/`/'\n echo \"\"\n echo \"Deno is banned estate-wide. Migrate to Bun, or — if this is genuinely\"\n echo \"grandfathered debt — add \\`${SLUG}\\` to\"\n echo \"\\`.machine_readable/deno-allow.txt\\` in \\`hyperpolymath/standards\\` with a\"\n echo \"\\`Ratchet-exception: .machine_readable/deno-allow.txt — \\` commit\"\n echo \"trailer, and bump this workflow's ledger pin.\"\n echo \"\"\n} >> \"$GITHUB_STEP_SUMMARY\"\nexit 1\n", + }, + ], + }, + # ─────────────────────────────────────────────────────────────────────── + # Job 2 — ReScript (grandfathered; banned for new code) + # ─────────────────────────────────────────────────────────────────────── + rescript: { + name: "ReScript", + needs: "detect", + if: "${{ needs.detect.outputs.has_rescript == 'true' }}", + runs-on: "${{ inputs.runs-on }}", + timeout-minutes: 20, + permissions: { + contents: "read", + }, + steps: [ + { + name: "Checkout repository", + uses: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", # v7.0.1 + }, + { + name: "Policy notice", + run: "echo \"::warning::ReScript is BANNED for new code (owner ruling 2026-04-30). Existing .res migrates to .affine directly — AffineScript, not TypeScript. This job serves a grandfathered tree mid-migration.\"\n", + }, + { + name: "Install Bun", + uses: "oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6", # v2.2.0 + with: { + bun-version: "latest", + }, + }, + { + name: "Install dependencies", + shell: "bash", + run: "set -euo pipefail\n# An npm lifecycle script is arbitrary code execution at CI\n# privilege, so scripts stay off. MEASURED 2026-09-22 against the\n# registry: rescript 11.1.4 declares postinstall\n# 'node scripts/rescript_postinstall.js', but 12.3.1 declares NO\n# postinstall at all -- it ships the compiler as per-platform\n# optionalDependencies. So the flag only costs anything on the\n# superseded major, and ReScript is a banned language here in any\n# case: this job exists to lint grandfathered sources, not to make\n# legacy installs convenient.\n#\n# --frozen-lockfile is the other half: without it bun may resolve a\n# floating range, and CI would then be testing a compiler the repo\n# does not pin.\nbun install --frozen-lockfile --ignore-scripts\n\n# A missing binary after that install must be an ERROR, not a\n# silent fall-through to a build step that cannot run. A skip is\n# not a pass.\nif ! bunx --no-install rescript -h >/dev/null 2>&1; then\n echo \"::error::rescript is not present after a --frozen-lockfile --ignore-scripts install. Upgrade to rescript >= 12, which needs no lifecycle script, or add the compiler to the lockfile. Lifecycle scripts are deliberately not enabled in this workflow.\"\n exit 1\nfi\n", + }, + { + name: "ReScript build (warnings are errors) + format check", + shell: "bash", + run: "set -euo pipefail\nFAILED=0\n\n# A compile IS the lint for ReScript; -warn-error +a promotes every\n# warning. This writes to lib/, which is compiler output and not a\n# tracked source file.\nbunx --no-install rescript build -with-deps -warn-error +a || FAILED=1\n\n# `rescript format` gained -check at different points in different\n# majors. Probe for the capability rather than assume it: a gate that\n# fails because the flag does not exist tells you nothing about the\n# code, and one that silently passes tells you less.\nif bunx --no-install rescript format --help 2>&1 | grep -q -- '-check'; then\n bunx --no-install rescript format -all -check || { echo \"::error::ReScript sources are not formatted\"; FAILED=1; }\n FMT='checked'\nelse\n echo \"::warning::The installed rescript CLI has no 'format -check'; the format gate did NOT run. This is a skip, not a pass.\"\n FMT='**SKIPPED — CLI lacks -check**'\nfi\n\n{\n echo \"### ReScript\"\n echo \"\"\n echo \"- \\`rescript build -warn-error +a\\`: $( [ \"$FAILED\" -eq 0 ] && echo 'pass' || echo '**FAIL**' )\"\n echo \"- format check: ${FMT}\"\n echo \"- policy: **banned for new code**, migrates to AffineScript\"\n echo \"\"\n} >> \"$GITHUB_STEP_SUMMARY\"\nexit \"$FAILED\"\n", + }, + ], + }, + # ─────────────────────────────────────────────────────────────────────── + # Job 2 — V (grandfathered; banned, migration to Zig completed 2026-05-28) + # ─────────────────────────────────────────────────────────────────────── + v-lang: { + name: "V", + needs: "detect", + if: "${{ needs.detect.outputs.has_v == 'true' }}", + runs-on: "${{ inputs.runs-on }}", + timeout-minutes: 20, + permissions: { + contents: "read", + }, + steps: [ + { + name: "Checkout repository", + uses: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", # v7.0.1 + }, + { + name: "Policy notice", + run: "echo \"::warning::V-lang is BANNED (owner ruling 2026-04-10; estate migration to Zig COMPLETED 2026-05-28 across 16 PRs). A v.mod here means either a carve-out (v-cartridge / v-adapter / v-bindings / v-client, asdf-vlang, archived polystack/) or a regression. Confirm which.\"\n", + }, + { + name: "Install V (pinned, sha256-verified)", + shell: "bash", + env: { + V_VERSION: "0.5.2", + # Computed locally from the release archive. + V_SHA256: "86caf9e70c3342d48ef19eb4f6c47b709f18c90ae86255520d5c29df6b482e23", + }, + run: "set -euo pipefail\nURL=\"https://github.com/vlang/v/releases/download/${V_VERSION}/v_linux.zip\"\ncurl -sSfL --proto '=https' --proto-redir '=https' --tlsv1.2 --retry 3 -o \"$RUNNER_TEMP/v_linux.zip\" \"$URL\"\necho \"${V_SHA256} ${RUNNER_TEMP}/v_linux.zip\" | sha256sum -c -\nunzip -q \"$RUNNER_TEMP/v_linux.zip\" -d \"$RUNNER_TEMP\"\necho \"$RUNNER_TEMP/v\" >> \"$GITHUB_PATH\"\n", + }, + { + name: "V vet + format verify", + shell: "bash", + run: "set -euo pipefail\nFAILED=0\n# ⚠ MEASURED on v 0.5.2: `v fmt -verify ` prints\n# \" is not vfmt'ed / Encountered a total of: 1 formatting\n# errors.\" and then EXITS 0 — a fake green. Only the DIRECTORY form\n# exits 1. Do not \"simplify\" this into a per-file loop: it would go\n# on printing the same errors and stop failing on them.\n# `v fmt -w` is the destructive form and must never appear in CI.\nv fmt -verify . || { echo \"::error::V sources are not formatted (v fmt -verify)\"; FAILED=1; }\nv vet . || { echo \"::error::v vet reported problems\"; FAILED=1; }\n{\n echo \"### V\"\n echo \"\"\n echo \"- \\`v fmt -verify\\` + \\`v vet\\`: $( [ \"$FAILED\" -eq 0 ] && echo 'pass' || echo '**FAIL**' )\"\n echo \"- policy: **banned**; migration to Zig completed 2026-05-28\"\n echo \"\"\n} >> \"$GITHUB_STEP_SUMMARY\"\nexit \"$FAILED\"\n", + }, + ], + }, + # ─────────────────────────────────────────────────────────────────────── + # Job 2 — Haskell + # ─────────────────────────────────────────────────────────────────────── + haskell: { + name: "Haskell", + needs: "detect", + if: "${{ needs.detect.outputs.has_haskell == 'true' }}", + runs-on: "${{ inputs.runs-on }}", + timeout-minutes: 30, + permissions: { + contents: "read", + }, + steps: [ + { + name: "Checkout repository", + uses: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", # v7.0.1 + }, + { + name: "Install GHC and Cabal", + uses: "haskell-actions/setup@0f8e8c99d88aeb3fbfd523f1ef2c6f762d10d64d", # v2.12.1 + with: { + enable-stack: false, + }, + }, + { + name: "Build with warnings as errors", + shell: "bash", + run: "set -euo pipefail\n# Haskell's lint IS the compiler here. hlint/fourmolu would each need\n# a `cabal install` costing several minutes, and neither is pinned\n# anywhere in the estate — an unpinned toolchain install is a worse\n# trade than a narrower gate. -Wall -Werror is the spec's \"fail on\n# warnings\" and is read-only with respect to tracked files.\ncabal update\ncabal build all --ghc-options='-Wall -Werror'\n{\n echo \"### Haskell\"\n echo \"\"\n echo \"- \\`cabal build all --ghc-options='-Wall -Werror'\\`: pass\"\n echo \"- note: format gate not run — no pinned fourmolu/hlint in the estate\"\n echo \"\"\n} >> \"$GITHUB_STEP_SUMMARY\"\n", + }, + ], + }, + # ─────────────────────────────────────────────────────────────────────── + # report — make every skip legible + # ─────────────────────────────────────────────────────────────────────── + report: { + name: "Pipeline report", + needs: [ + "detect", + "secret-scan", + "sast", + "rust", + "nickel", + "deno", + "rescript", + "v-lang", + "haskell", + ], + if: "${{ always() }}", + runs-on: "${{ inputs.runs-on }}", + timeout-minutes: 5, + permissions: { + contents: "read", + }, + steps: [ + { + name: "Write skip ledger", + shell: "bash", + env: { + R_DETECT: "${{ needs.detect.result }}", + # `detect` no longer exits 1 on a refusal, so R_DETECT is `success` + # even when the scan found nothing to check. Without this the Detect + # row below would print "pass", which is the precise opposite of what + # happened. The verdict travels as data; so must its rendering. + REFUSED: "${{ needs.detect.outputs.refused }}", + REASON: "${{ needs.detect.outputs.refusal_reason }}", + R_SECRET: "${{ needs.secret-scan.result }}", + R_SAST: "${{ needs.sast.result }}", + R_RUST: "${{ needs.rust.result }}", + R_NICKEL: "${{ needs.nickel.result }}", + R_DENO: "${{ needs.deno.result }}", + R_RESCRIPT: "${{ needs.rescript.result }}", + R_V: "${{ needs.v-lang.result }}", + R_HASKELL: "${{ needs.haskell.result }}", + H_RUST: "${{ needs.detect.outputs.has_rust }}", + H_NICKEL: "${{ needs.detect.outputs.has_nickel }}", + H_DENO: "${{ needs.detect.outputs.has_deno }}", + H_RESCRIPT: "${{ needs.detect.outputs.has_rescript }}", + H_V: "${{ needs.detect.outputs.has_v }}", + H_HASKELL: "${{ needs.detect.outputs.has_haskell }}", + N_UNSUP: "${{ needs.detect.outputs.n_unsupported }}", + UNSUP: "${{ needs.detect.outputs.unsupported }}", + }, + run: "set -euo pipefail\n\n# A skipped job renders as a grey tick that reads, at a glance, like\n# a pass. Spell out which gates did NOT run and why, so nobody reads\n# an absent check as a satisfied one.\nrow() {\n local name=\"$1\" result=\"$2\" reason=\"$3\"\n case \"$result\" in\n skipped) echo \"| ${name} | skipped | ${reason} |\" ;;\n success) echo \"| ${name} | pass | ran |\" ;;\n failure) echo \"| ${name} | **FAIL** | ran |\" ;;\n *) echo \"| ${name} | ${result} | ran |\" ;;\n esac\n}\n\n{\n echo \"### Pipeline report\"\n echo \"\"\n echo \"| Gate | Result | Note |\"\n echo \"|---|---|---|\"\n # ⚠ NOT a plain row(). `detect` succeeds as a JOB even when it\n # refuses, because the refusal is an output rather than an exit\n # code (see the refusal branch in that job). row() would map that\n # `success` to the word \"pass\" and assert that the repository was\n # examined. It was not.\n if [ \"${REFUSED:-false}\" = \"true\" ]; then\n echo \"| Detect | **REFUSED** | ran, and found nothing it could check — ${REASON} |\"\n else\n row \"Detect\" \"$R_DETECT\" \"n/a\"\n fi\n row \"Secret scanning\" \"$R_SECRET\" \"n/a\"\n row \"SAST (semgrep)\" \"$R_SAST\" \"disabled via enable_sast: false\"\n row \"Rust\" \"$R_RUST\" \"no Cargo.toml tracked (has_rust=${H_RUST})\"\n row \"Nickel\" \"$R_NICKEL\" \"no Nickel tracked, *.k9.ncl excluded (has_nickel=${H_NICKEL})\"\n row \"Deno\" \"$R_DENO\" \"BANNED — refusal gate; skipped means no deno.json(c) tracked (has_deno=${H_DENO})\"\n row \"ReScript\" \"$R_RESCRIPT\" \"no rescript.json/bsconfig.json/*.res tracked (has_rescript=${H_RESCRIPT})\"\n row \"V\" \"$R_V\" \"no v.mod tracked (has_v=${H_V})\"\n row \"Haskell\" \"$R_HASKELL\" \"no *.cabal/stack.yaml/*.hs tracked (has_haskell=${H_HASKELL})\"\n echo \"\"\n echo \"_A skipped gate examined nothing. It is not a pass._\"\n echo \"\"\n # ⚠ NOT a row(). These ecosystems have no job, so they have no\n # `needs.*.result` to report — and row()'s catch-all branch prints\n # the word \"ran\" for an unset result, which would assert the exact\n # opposite of the truth here. Say it in prose instead.\n if [ \"${N_UNSUP:-0}\" -gt 0 ] 2>/dev/null; then\n echo \"> **Detected with no gate at all:** ${UNSUP}\"\n echo \">\"\n echo \"> No job in this pipeline examines these. They are absent from the table\"\n echo \"> above because there is no result to report, not because they passed.\"\n echo \"> Tracked as hyperpolymath/standards#967.\"\n fi\n echo \"\"\n} >> \"$GITHUB_STEP_SUMMARY\"\n", + }, + # ── The pipeline adoption ratchet ──────────────────────────────── + # + # Both steps below run ONLY when a gate has already failed, OR when + # `detect` refused because this pipeline has no gate for anything this + # repository is written in. A green repository never reads the ledger, + # never pays for the checkout, and cannot be affected by the ledger being + # stale, empty or unreachable. + # + # That is what makes this fail closed for free: on the failure path an + # unreadable ledger and an empty ledger give the SAME verdict — blocked. + # "Could not read the ledger" can never be mistaken for "is exempt". + { + name: "Checkout the pinned Standards pipeline ledger", + if: "${{ contains(needs.*.result, 'failure') || needs.detect.outputs.refused == 'true' }}", + uses: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", # v7.0.1 + with: { + repository: "hyperpolymath/standards", + # Read the ledger from THIS workflow file's own commit. + # + # `job.workflow_sha` is "the commit SHA of the workflow file that + # defines the current job". This job is defined in THIS file, so when + # a caller invokes this reusable workflow the value is precisely the + # SHA that caller pinned in its `uses:`. + # + # ⚠ It is NOT `github.workflow_sha`, which inside a called reusable + # workflow is the CALLER's workflow file; that would look for the + # ledger in the caller's repository and find nothing. + # + # ⚠ Nor is it `github.job_workflow_sha`, which DOES NOT EXIST — the + # plausible-looking name is in the OIDC token claim set, not in the + # `github` context. actionlint rejects it, and github/docs confirms + # the `github` context has no such property. The workflow identity + # properties live on the `job` context: `workflow_sha`, + # `workflow_ref`, `workflow_repository`, `workflow_file_path`. The + # `job` context is available in `steps.*.with` and `steps.*.env`, + # which is all this file uses it for. + # + # ⚠ These four are unavailable on GitHub Enterprise Server. This + # estate is github.com; a GHES port would need a different key. + # + # This preserves in full the property the hand-written pin existed + # for: an edit to `main` in `standards` still cannot change the + # verdict of an already-pinned caller, because that caller goes on + # executing this commit. What it removes is the manual bump — and it + # makes the old comment's promise, "the pin and the first entry move + # in the SAME commit", literally true rather than aspirational, since + # the ledger and this line are now necessarily the same commit. + # + # It also repairs a real defect. This read `ref: a2ff696a`, an + # ABBREVIATED object id, and the git wire protocol cannot fetch one: + # `git fetch origin a2ff696a` fails with "couldn't find remote ref" + # where the full 40-hex succeeds. So the checkout ALWAYS failed. The + # `continue-on-error` below meant the verdict stayed correct + # (unreadable == empty == blocked) and nothing looked broken — but + # every exemption ever added would have been silently vacuous. + # ⚠ `git cat-file -t a2ff696a` answers `commit` locally, so a local + # probe CANNOT falsify a claim about remote fetchability. + ref: "${{ job.workflow_sha }}", + path: ".standards-pipeline-ledger", + sparse-checkout: ".machine_readable/pipeline-allow.txt\n", + sparse-checkout-cone-mode: false, + }, + # Not fatal: the next step names what was missing and then FAILS CLOSED. + # hypatia: allow research_extensions/RE005 -- deliberate fail-closed: the Verdict step below treats an unreadable ledger exactly like an empty one, i.e. blocked. + continue-on-error: true, + }, + { + name: "Verdict — block unless this repository is ledgered", + if: "${{ contains(needs.*.result, 'failure') || needs.detect.outputs.refused == 'true' }}", + shell: "bash", + env: { + LEDGER: ".standards-pipeline-ledger/.machine_readable/pipeline-allow.txt", + SLUG: "${{ github.repository }}", + # The ledger is read at this commit; printed below so that an empty + # context resolves VISIBLY rather than as a silent closed failure. + LEDGER_SHA: "${{ job.workflow_sha }}", + REFUSED: "${{ needs.detect.outputs.refused }}", + REASON: "${{ needs.detect.outputs.refusal_reason }}", + # `secret-scan` and `sast` carry no `needs: detect`, so they run and + # can fail on a repository that ALSO refused. Both debts are real at + # once and the message below must be able to say so. + ANY_FAILED: "${{ contains(needs.*.result, 'failure') }}", + }, + run: "# Every name above comes from the step `env:` map.\nset -euo pipefail\n\n# Strip comments and blanks; this is also the ledger's own\n# denominator. A missing file yields an empty list and N=0, which is\n# the fail-closed path: nothing can match an empty list.\nif [ -f \"$LEDGER\" ]; then\n ALLOWED=\"$(grep -vE '^[[:space:]]*(#|$)' \"$LEDGER\" || true)\"\nelse\n ALLOWED=\"\"\n echo \"::warning::Pipeline ledger not readable ($LEDGER). Treating as EMPTY, which blocks. This is not an exemption.\"\nfi\nN_ALLOWED=\"$(printf '%s' \"$ALLOWED\" | grep -c . || true)\"\n\n# ALWAYS print the denominator, on both paths. A gate that only ever\n# says yes proves nothing, and a `::warning::` cannot fail a job.\n# Naming the commit matters: if `job_workflow_sha` were ever empty\n# the checkout would fail and this would print an empty SHA, making\n# the cause legible instead of leaving a bare fail-closed verdict.\necho \"pipeline debt: ${N_ALLOWED} repos (ledger read at '${LEDGER_SHA}')\"\n\n# A fail-closed verdict with no explanation is exactly how the\n# abbreviated-SHA defect stayed invisible: the answer was right, so\n# nobody asked why. If this is empty the checkout above cannot have\n# succeeded, and the block below would be an artefact of the harness\n# rather than a statement about this repository. Say so loudly.\nif [ -z \"${LEDGER_SHA:-}\" ]; then\n echo \"::error::job.workflow_sha resolved EMPTY, so the exemption ledger could not be fetched and NOTHING can be exempt here. This is a pipeline defect, not a verdict about ${SLUG}. Note the job workflow-identity properties are unavailable on GitHub Enterprise Server.\"\nfi\n\n# Two DIFFERENT debts arrive here and calling them the same thing\n# would make the notice below assert something false. A gate that ran\n# and failed is fixable in this repository; a refusal means no gate\n# exists for what this repository is written in, which nothing done\n# here can cure.\n#\n# ⚠ These are not mutually exclusive. `secret-scan` and `sast` have no\n# `needs: detect`, so they run on every repository including one that\n# refused. Treating this as an either/or would print \"NOTHING WAS\n# CHECKED\" over a secret-scan that ran and failed — understating a\n# real finding to describe a missing gate. Say both when both hold.\nREF=\"${REFUSED:-false}\"\nFAILED=\"${ANY_FAILED:-false}\"\nif [ \"$REF\" = \"true\" ] && [ \"$FAILED\" = \"true\" ]; then\n DEBT=\"TWO debts at once: no ecosystem gate could run (${REASON}), AND at least one gate that does not depend on detection REALLY FAILED\"\n FIX=\"Fix what the report table names, and add a gate for the ecosystems named above (see hyperpolymath/standards#967).\"\nelif [ \"$REF\" = \"true\" ]; then\n DEBT=\"NOTHING WAS CHECKED — ${REASON}\"\n FIX=\"Add a gate for the ecosystems named above (see hyperpolymath/standards#967). Until one exists this repository cannot be made green by fixing its own code.\"\nelse\n DEBT=\"at least one gate above REALLY FAILED — this is grandfathered debt, not a pass\"\n FIX=\"Fix what the report table names.\"\nfi\n\nif printf '%s\\n' \"$ALLOWED\" | grep -Fxq \"$SLUG\"; then\n echo \"::notice::${SLUG} is on the pipeline exemption ledger (pipeline debt: ${N_ALLOWED} repos). ${DEBT}. ${FIX} Then delete this repository's line from .machine_readable/pipeline-allow.txt.\"\n {\n echo \"### Pipeline verdict — ledgered debt\"\n echo \"\"\n echo \"| | |\"\n echo \"|---|---|\"\n echo \"| Repository | \\`${SLUG}\\` |\"\n echo \"| Verdict | **exempt** (shrink-only ledger) |\"\n echo \"| pipeline debt | **${N_ALLOWED} repos** |\"\n echo \"\"\n echo \"| Debt | ${DEBT} |\"\n echo \"\"\n echo \"**An exemption is not a pass;** it is debt that someone agreed to\"\n echo \"carry. ${FIX}\"\n echo \"\"\n echo \"Then DELETE this repository's line from\"\n echo \"\\`.machine_readable/pipeline-allow.txt\\` in \\`hyperpolymath/standards\\` —\"\n echo \"deletion needs no \\`Ratchet-exception\\` trailer, because shrinking is\"\n echo \"the point.\"\n echo \"\"\n } >> \"$GITHUB_STEP_SUMMARY\"\n exit 0\nfi\n\necho \"::error::${DEBT}, and ${SLUG} is not on the exemption ledger (pipeline debt: ${N_ALLOWED} repos). See the report table in the step summary.\"\n{\n echo \"### Pipeline verdict — BLOCKED\"\n echo \"\"\n echo \"| | |\"\n echo \"|---|---|\"\n echo \"| Repository | \\`${SLUG}\\` |\"\n echo \"| Verdict | **blocked** (not ledgered) |\"\n echo \"| Debt | ${DEBT} |\"\n echo \"| pipeline debt | **${N_ALLOWED} repos** |\"\n echo \"\"\n echo \"The report table above names every gate that failed. Fix them, or — if\"\n echo \"this is debt the estate has agreed to carry — add \\`${SLUG}\\` to\"\n echo \"\\`.machine_readable/pipeline-allow.txt\\` in \\`hyperpolymath/standards\\`,\"\n echo \"bumping the ledger pin in the same commit, with a\"\n echo \"\\`Ratchet-exception: .machine_readable/pipeline-allow.txt — \\`\"\n echo \"trailer in column 0.\"\n echo \"\"\n} >> \"$GITHUB_STEP_SUMMARY\"\nexit 1\n", + }, + ], + }, + }, +} diff --git a/.github/workflows/codeql-reusable.yml b/.github/workflows/codeql-reusable.yml index 4e13fa00d..f13e1a57a 100644 --- a/.github/workflows/codeql-reusable.yml +++ b/.github/workflows/codeql-reusable.yml @@ -51,55 +51,71 @@ # with: # language: rust -name: CodeQL Security Analysis (reusable) - -on: - workflow_call: - inputs: - language: - description: 'CodeQL language identifier (e.g. javascript-typescript, rust, actions). Single language per call; multi-language wrappers invoke the reusable once per language.' - type: string - required: false - default: javascript-typescript - build-mode: - description: 'CodeQL build mode (none|autobuild|manual). 100% of estate currently uses "none"; override only for compiled languages that require explicit build.' - type: string - required: false - default: none - runs-on: - description: 'Runner label for the analyze job' - type: string - required: false - default: ubuntu-latest - -permissions: - actions: read - contents: read - -jobs: - analyze: - timeout-minutes: 20 - runs-on: ${{ inputs.runs-on }} - # Job-level permissions inherit from workflow-level and ADD security-events: write. - # Do NOT narrow here or CodeQL action loses `actions: read` which it needs. - permissions: - actions: read - contents: read - security-events: write - - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - - name: Initialize CodeQL - uses: github/codeql-action/init@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0 (4.38.1 blocked estate-wide; nexia-list#100) - with: - languages: ${{ inputs.language }} - build-mode: ${{ inputs.build-mode }} - - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0 (4.38.1 blocked estate-wide; nexia-list#100) - with: - category: "/language:${{ inputs.language }}" +{ + name: "CodeQL Security Analysis (reusable)", + on: { + workflow_call: { + inputs: { + language: { + description: "CodeQL language identifier (e.g. javascript-typescript, rust, actions). Single language per call; multi-language wrappers invoke the reusable once per language.", + type: "string", + required: false, + default: "javascript-typescript", + }, + build-mode: { + description: "CodeQL build mode (none|autobuild|manual). 100% of estate currently uses \"none\"; override only for compiled languages that require explicit build.", + type: "string", + required: false, + default: "none", + }, + runs-on: { + description: "Runner label for the analyze job", + type: "string", + required: false, + default: "ubuntu-latest", + }, + }, + }, + }, + permissions: { + actions: "read", + contents: "read", + }, + jobs: { + analyze: { + timeout-minutes: 20, + runs-on: "${{ inputs.runs-on }}", + # Job-level permissions inherit from workflow-level and ADD security-events: write. + # Do NOT narrow here or CodeQL action loses `actions: read` which it needs. + permissions: { + actions: "read", + contents: "read", + security-events: "write", + }, + steps: [ + { + name: "Checkout", + uses: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", # v7.0.1 + with: { + persist-credentials: false, + }, + }, + { + name: "Initialize CodeQL", + uses: "github/codeql-action/init@b96794f015dfd88f77b49b1c93e0fa7110f94c63", # v4.38.0 (4.38.1 blocked estate-wide; nexia-list#100) + with: { + languages: "${{ inputs.language }}", + build-mode: "${{ inputs.build-mode }}", + }, + }, + { + name: "Perform CodeQL Analysis", + uses: "github/codeql-action/analyze@b96794f015dfd88f77b49b1c93e0fa7110f94c63", # v4.38.0 (4.38.1 blocked estate-wide; nexia-list#100) + with: { + category: "/language:${{ inputs.language }}", + }, + }, + ], + }, + }, +} diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 3829618eb..cee803a3a 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -1,51 +1,67 @@ # This workflow is managed by gh actions-lock. # SPDX-License-Identifier: MPL-2.0 # This workflow is managed by gh actions-lock. -name: CodeQL Security Analysis - -on: - push: - branches: [main, master] - pull_request: - branches: [main, master] - schedule: - # Estate cut 3 (standards#233): weekly → monthly. 1st of each month - # at 06:00 UTC. Owner-decision Option B (2026-05-30): ~85% savings - # (~46k Actions-min/yr) with bounded 30-day CVE-detection floor. - # PR-trigger runs (push + pull_request) are kept regardless — this - # cut only changes the scheduled-baseline cadence. - - cron: '0 6 1 * *' - -# Estate guardrail: serialise superseded runs so re-pushes / rebased PR -# updates do not pile up against the shared account-wide Actions pool. -# -# cancel-in-progress is DELIBERATELY false: CodeQL is a REQUIRED -# code-scanning tool in the estate branch rulesets (a result must upload -# for the head commit before a PR can merge). A cancelled run uploads -# nothing, leaving branch protection stuck "expecting 1 result from CodeQL -# for " indefinitely — the ghost check that blocks PRs for hours/days. -# Queue rather than cancel so the latest commit's scan always completes and -# uploads. (Non-required read-only workflows may still cancel-in-progress.) -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: false - -# Must cover everything codeql-reusable's `analyze` job requests: a reusable -# cannot widen its caller's grant, and asking for more aborts the run as -# `startup_failure` with zero jobs. security-events: write is what uploads -# the SARIF results that branch protection requires. -permissions: - actions: read - contents: read - security-events: write - -jobs: - analyze-js: - uses: ./.github/workflows/codeql-reusable.yml - with: - language: javascript-typescript - - analyze-actions: - uses: ./.github/workflows/codeql-reusable.yml - with: - language: actions +{ + name: "CodeQL Security Analysis", + on: { + push: { + branches: [ + "main", + "master", + ], + }, + pull_request: { + branches: [ + "main", + "master", + ], + }, + schedule: [ + # Estate cut 3 (standards#233): weekly → monthly. 1st of each month + # at 06:00 UTC. Owner-decision Option B (2026-05-30): ~85% savings + # (~46k Actions-min/yr) with bounded 30-day CVE-detection floor. + # PR-trigger runs (push + pull_request) are kept regardless — this + # cut only changes the scheduled-baseline cadence. + { + cron: "0 6 1 * *", + }, + ], + }, + # Estate guardrail: serialise superseded runs so re-pushes / rebased PR + # updates do not pile up against the shared account-wide Actions pool. + # + # cancel-in-progress is DELIBERATELY false: CodeQL is a REQUIRED + # code-scanning tool in the estate branch rulesets (a result must upload + # for the head commit before a PR can merge). A cancelled run uploads + # nothing, leaving branch protection stuck "expecting 1 result from CodeQL + # for " indefinitely — the ghost check that blocks PRs for hours/days. + # Queue rather than cancel so the latest commit's scan always completes and + # uploads. (Non-required read-only workflows may still cancel-in-progress.) + concurrency: { + group: "${{ github.workflow }}-${{ github.ref }}", + cancel-in-progress: false, + }, + # Must cover everything codeql-reusable's `analyze` job requests: a reusable + # cannot widen its caller's grant, and asking for more aborts the run as + # `startup_failure` with zero jobs. security-events: write is what uploads + # the SARIF results that branch protection requires. + permissions: { + actions: "read", + contents: "read", + security-events: "write", + }, + jobs: { + analyze-js: { + uses: "./.github/workflows/codeql-reusable.yml", + with: { + language: "javascript-typescript", + }, + }, + analyze-actions: { + uses: "./.github/workflows/codeql-reusable.yml", + with: { + language: "actions", + }, + }, + }, +} diff --git a/.github/workflows/debt-measure.yml b/.github/workflows/debt-measure.yml index 175f2bb91..42f6c7600 100644 --- a/.github/workflows/debt-measure.yml +++ b/.github/workflows/debt-measure.yml @@ -1,109 +1,73 @@ # This workflow is managed by gh actions-lock. # SPDX-License-Identifier: MPL-2.0 -name: "📅 Debt measure" - -# Re-measure every Debtfile probe and record the result as ONE pull request. -# -# This workflow exists to replace a specific bad habit. In `.git-private-farm`, -# a bot minted the same measurement as a NEW ISSUE on every run: -# -# #104 rustsec: 138 open advisories (34 high/critical) -# #105 rustsec: 136 open advisories (34 high/critical) -# #107 rustsec: 139 open advisories (34 high/critical) -# #117 rustsec: 156 open advisories (54 high/critical) -# #119 rustsec: 156 open advisories (54 high/critical) -# #120 rustsec: 155 open advisories (53 high/critical) -# -# Six issues carrying one number. This workflow opens at most one PR and -# NEVER opens an issue. That is the whole point; do not add issue creation. -# -# 📅 PERIODIC per docs/CICD-SIGNAL-DISCIPLINE.adoc — MUST NOT be a required -# status check. - -on: - schedule: - # Mondays 06:17 UTC. Off the hour on purpose: the estate's crons cluster on - # :00 and saturate the Actions queue. - - cron: '17 6 * * 1' - workflow_dispatch: - -permissions: - contents: read - -concurrency: - group: debt-measure-${{ github.ref }} - cancel-in-progress: false - -jobs: - measure: - name: Measure and record - runs-on: ubuntu-latest - timeout-minutes: 15 - permissions: - contents: write - pull-requests: write - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - name: Re-measure the Debtfile - id: measure - run: | - set -uo pipefail - if [ ! -f .machine_readable/Debtfile.a2ml ]; then - echo "No Debtfile — nothing to measure." - echo "changed=no" >> "$GITHUB_OUTPUT" - exit 0 - fi - - # --write updates `count` and lowers any `ceiling` that has been paid - # down. It NEVER raises one. If debt grew past its ceiling the run - # exits 1 and the resulting file has count > ceiling — which the - # structure check rejects, so the PR opens RED. That is deliberate: - # growth is a human decision (pay it down, or raise the ceiling with a - # `Debt-exception:` trailer), never something a cron decides. - rc=0 - bash scripts/run-debtfile.sh --write | tee "$RUNNER_TEMP/report.txt" || rc=$? - echo "rc=$rc" >> "$GITHUB_OUTPUT" - - if git diff --quiet -- .machine_readable/Debtfile.a2ml; then - echo "No change in measured debt." - echo "changed=no" >> "$GITHUB_OUTPUT" - else - echo "changed=yes" >> "$GITHUB_OUTPUT" - fi - - - name: Open (or update) the single recording PR - if: steps.measure.outputs.changed == 'yes' - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - BRANCH="chore/debt-measure" - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - - git checkout -B "$BRANCH" - git add .machine_readable/Debtfile.a2ml - # One -m per paragraph. A multi-line quoted string here would put its - # continuation lines at column 1, which ends the YAML block scalar and - # leaves the workflow unparseable — the estate already has a large - # population of workflows broken exactly this way. - git commit -m "chore(debt): re-measure Debtfile probes" \ - -m "Machine-written by .github/workflows/debt-measure.yml. Counts follow the probes; ceilings only ever fall. A ceiling that needs to RISE is not done here — raise it in a human PR with a Debt-exception: trailer." - git push -f origin "$BRANCH" - - BODY="$(printf 'Automated re-measurement of `.machine_readable/Debtfile.a2ml`.\n\n```\n%s\n```\n\nCeilings only fall here. If an entry breached its ceiling this PR is red on\npurpose — pay the debt down, or raise the ceiling in a human commit carrying\na `Debt-exception:` trailer naming the entry.\n\nSee `docs/DEBTFILE-SPEC.adoc`.\n' "$(cat "$RUNNER_TEMP/report.txt")")" - - if gh pr view "$BRANCH" --json number >/dev/null 2>&1; then - gh pr edit "$BRANCH" --body "$BODY" - else - gh pr create --head "$BRANCH" --base "${{ github.ref_name }}" \ - --title "chore(debt): re-measure Debtfile probes" --body "$BODY" - fi - - - name: Surface a breach without minting an issue - if: steps.measure.outputs.rc != '0' - run: | - echo "::error::Debtfile run reported a breach, a broken probe, or an expired acceptance." - echo "See the PR opened by this run. No issue has been created, by design." - exit 1 +{ + name: "📅 Debt measure", + # Re-measure every Debtfile probe and record the result as ONE pull request. + # + # This workflow exists to replace a specific bad habit. In `.git-private-farm`, + # a bot minted the same measurement as a NEW ISSUE on every run: + # + # #104 rustsec: 138 open advisories (34 high/critical) + # #105 rustsec: 136 open advisories (34 high/critical) + # #107 rustsec: 139 open advisories (34 high/critical) + # #117 rustsec: 156 open advisories (54 high/critical) + # #119 rustsec: 156 open advisories (54 high/critical) + # #120 rustsec: 155 open advisories (53 high/critical) + # + # Six issues carrying one number. This workflow opens at most one PR and + # NEVER opens an issue. That is the whole point; do not add issue creation. + # + # 📅 PERIODIC per docs/CICD-SIGNAL-DISCIPLINE.adoc — MUST NOT be a required + # status check. + on: { + schedule: [ + # Mondays 06:17 UTC. Off the hour on purpose: the estate's crons cluster on + # :00 and saturate the Actions queue. + { + cron: "17 6 * * 1", + }, + ], + workflow_dispatch: null, + }, + permissions: { + contents: "read", + }, + concurrency: { + group: "debt-measure-${{ github.ref }}", + cancel-in-progress: false, + }, + jobs: { + measure: { + name: "Measure and record", + runs-on: "ubuntu-latest", + timeout-minutes: 15, + permissions: { + contents: "write", + pull-requests: "write", + }, + steps: [ + { + uses: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", # v7.0.1 + }, + { + name: "Re-measure the Debtfile", + id: "measure", + run: "set -uo pipefail\nif [ ! -f .machine_readable/Debtfile.a2ml ]; then\n echo \"No Debtfile — nothing to measure.\"\n echo \"changed=no\" >> \"$GITHUB_OUTPUT\"\n exit 0\nfi\n\n# --write updates `count` and lowers any `ceiling` that has been paid\n# down. It NEVER raises one. If debt grew past its ceiling the run\n# exits 1 and the resulting file has count > ceiling — which the\n# structure check rejects, so the PR opens RED. That is deliberate:\n# growth is a human decision (pay it down, or raise the ceiling with a\n# `Debt-exception:` trailer), never something a cron decides.\nrc=0\nbash scripts/run-debtfile.sh --write | tee \"$RUNNER_TEMP/report.txt\" || rc=$?\necho \"rc=$rc\" >> \"$GITHUB_OUTPUT\"\n\nif git diff --quiet -- .machine_readable/Debtfile.a2ml; then\n echo \"No change in measured debt.\"\n echo \"changed=no\" >> \"$GITHUB_OUTPUT\"\nelse\n echo \"changed=yes\" >> \"$GITHUB_OUTPUT\"\nfi\n", + }, + { + name: "Open (or update) the single recording PR", + if: "steps.measure.outputs.changed == 'yes'", + env: { + GH_TOKEN: "${{ github.token }}", + }, + run: "set -euo pipefail\nBRANCH=\"chore/debt-measure\"\ngit config user.name \"github-actions[bot]\"\ngit config user.email \"41898282+github-actions[bot]@users.noreply.github.com\"\n\ngit checkout -B \"$BRANCH\"\ngit add .machine_readable/Debtfile.a2ml\n# One -m per paragraph. A multi-line quoted string here would put its\n# continuation lines at column 1, which ends the YAML block scalar and\n# leaves the workflow unparseable — the estate already has a large\n# population of workflows broken exactly this way.\ngit commit -m \"chore(debt): re-measure Debtfile probes\" \\\n -m \"Machine-written by .github/workflows/debt-measure.yml. Counts follow the probes; ceilings only ever fall. A ceiling that needs to RISE is not done here — raise it in a human PR with a Debt-exception: trailer.\"\ngit push -f origin \"$BRANCH\"\n\nBODY=\"$(printf 'Automated re-measurement of `.machine_readable/Debtfile.a2ml`.\\n\\n```\\n%s\\n```\\n\\nCeilings only fall here. If an entry breached its ceiling this PR is red on\\npurpose — pay the debt down, or raise the ceiling in a human commit carrying\\na `Debt-exception:` trailer naming the entry.\\n\\nSee `docs/DEBTFILE-SPEC.adoc`.\\n' \"$(cat \"$RUNNER_TEMP/report.txt\")\")\"\n\nif gh pr view \"$BRANCH\" --json number >/dev/null 2>&1; then\n gh pr edit \"$BRANCH\" --body \"$BODY\"\nelse\n gh pr create --head \"$BRANCH\" --base \"${{ github.ref_name }}\" \\\n --title \"chore(debt): re-measure Debtfile probes\" --body \"$BODY\"\nfi\n", + }, + { + name: "Surface a breach without minting an issue", + if: "steps.measure.outputs.rc != '0'", + run: "echo \"::error::Debtfile run reported a breach, a broken probe, or an expired acceptance.\"\necho \"See the PR opened by this run. No issue has been created, by design.\"\nexit 1\n", + }, + ], + }, + }, +} diff --git a/.github/workflows/deed-conformance.yml b/.github/workflows/deed-conformance.yml index 54b3db873..1c05fe4c3 100644 --- a/.github/workflows/deed-conformance.yml +++ b/.github/workflows/deed-conformance.yml @@ -1,42 +1,50 @@ # This workflow is managed by gh actions-lock. # SPDX-License-Identifier: MPL-2.0 -name: deed-conformance - -# Conformance lane for the DEED grammar (standards#837). Three gates: -# validator self-test → fixture corpus → every committed *.deed file. - -on: - push: - paths: - - '**.deed' - - '1-formats/deed/**' - - '.github/workflows/deed-conformance.yml' - pull_request: - paths: - - '**.deed' - - '1-formats/deed/**' - - '.github/workflows/deed-conformance.yml' - -permissions: {} - -jobs: - lint: - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - name: validator self-test - run: python3 1-formats/deed/tools/deed_lint.py --self-test - - name: fixture corpus (valid must pass, invalid must fail) - run: python3 1-formats/deed/tools/deed_lint.py --fixtures 1-formats/deed/tools/fixtures - - name: lint every committed deed - run: | - set -e - FILES=$(find . -name '*.deed' -not -path './1-formats/deed/tools/fixtures/invalid/*' -print) - if [ -z "$FILES" ]; then - echo "no committed .deed files yet — fixtures carry the gate" - exit 0 - fi - echo "$FILES" | xargs python3 1-formats/deed/tools/deed_lint.py +{ + name: "deed-conformance", + # Conformance lane for the DEED grammar (standards#837). Three gates: + # validator self-test → fixture corpus → every committed *.deed file. + on: { + push: { + paths: [ + "**.deed", + "1-formats/deed/**", + ".github/workflows/deed-conformance.yml", + ], + }, + pull_request: { + paths: [ + "**.deed", + "1-formats/deed/**", + ".github/workflows/deed-conformance.yml", + ], + }, + }, + permissions: {}, + jobs: { + lint: { + runs-on: "ubuntu-latest", + timeout-minutes: 10, + steps: [ + { + uses: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", # v7.0.1 + with: { + persist-credentials: false, + }, + }, + { + name: "validator self-test", + run: "python3 1-formats/deed/tools/deed_lint.py --self-test", + }, + { + name: "fixture corpus (valid must pass, invalid must fail)", + run: "python3 1-formats/deed/tools/deed_lint.py --fixtures 1-formats/deed/tools/fixtures", + }, + { + name: "lint every committed deed", + run: "set -e\nFILES=$(find . -name '*.deed' -not -path './1-formats/deed/tools/fixtures/invalid/*' -print)\nif [ -z \"$FILES\" ]; then\n echo \"no committed .deed files yet — fixtures carry the gate\"\n exit 0\nfi\necho \"$FILES\" | xargs python3 1-formats/deed/tools/deed_lint.py\n", + }, + ], + }, + }, +} diff --git a/.github/workflows/dependabot-lock-regen.yml b/.github/workflows/dependabot-lock-regen.yml index 1d2a31ba4..06c741435 100644 --- a/.github/workflows/dependabot-lock-regen.yml +++ b/.github/workflows/dependabot-lock-regen.yml @@ -12,83 +12,96 @@ # signed-push read (owner ruling STD-R-3: NOT OikosBot, which is a ruleset # bypass actor). Needs Contents R/W, Workflows R/W, Pull requests R, and must # be installed on BOTH owners. Until it exists the run warns and exits 0. -name: Dependabot lock regeneration - -on: - schedule: - - cron: '23,53 * * * *' - workflow_dispatch: - inputs: - repo: - description: 'Only this owner/repo (empty = every installed repo)' - required: false - default: '' - dry-run: - description: 'Regenerate and report, but commit nothing' - type: boolean - default: false - -concurrency: - group: ${{ github.workflow }} - cancel-in-progress: false - -permissions: - contents: read - -jobs: - regen: - runs-on: ubuntu-latest - timeout-minutes: 60 - steps: - - name: Harden runner (egress audit) - uses: step-security/harden-runner@e14015d583714f6e62063499dc959a02595150a1 # v2.21.1 - with: - egress-policy: audit - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - # An installation token is scoped to ONE owner, so mint one per owner. - - name: Mint an App installation token for hyperpolymath - id: tok-user - if: vars.APP_ID != '' - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 - continue-on-error: true - with: - app-id: ${{ vars.APP_ID }} - private-key: ${{ secrets.APP_PRIVATE_KEY }} - owner: hyperpolymath - - name: Mint an App installation token for metadatastician - id: tok-org - if: vars.APP_ID != '' - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 - continue-on-error: true - with: - app-id: ${{ vars.APP_ID }} - private-key: ${{ secrets.APP_PRIVATE_KEY }} - owner: metadatastician - - - name: Install the lock tooling (same pin as the governance gate) - env: - GH_TOKEN: ${{ github.token }} - run: | - sudo apt-get install -y -qq gawk >/dev/null - gh extension install github/gh-actions-lock --pin v0.1.6 - - - name: Regenerate Dependabot PR lockfiles - env: - REGEN_TOKENS: >- - hyperpolymath=${{ steps.tok-user.outputs.token }} - metadatastician=${{ steps.tok-org.outputs.token }} - DRY_RUN: ${{ inputs.dry-run && '1' || '0' }} - ONLY_REPO: ${{ inputs.repo }} - run: | - # A token is an empty string when the App is absent or not installed - # on that owner; the script names each owner it did NOT examine. - bash scripts/regen-dependabot-locks.sh ${ONLY_REPO:+"$ONLY_REPO"} | tee "$RUNNER_TEMP/regen.txt" - { - echo '### Dependabot lock regeneration' - echo '```' - cat "$RUNNER_TEMP/regen.txt" - echo '```' - } >> "$GITHUB_STEP_SUMMARY" +{ + name: "Dependabot lock regeneration", + on: { + schedule: [ + { + cron: "23,53 * * * *", + }, + ], + workflow_dispatch: { + inputs: { + repo: { + description: "Only this owner/repo (empty = every installed repo)", + required: false, + default: "", + }, + dry-run: { + description: "Regenerate and report, but commit nothing", + type: "boolean", + default: false, + }, + }, + }, + }, + concurrency: { + group: "${{ github.workflow }}", + cancel-in-progress: false, + }, + permissions: { + contents: "read", + }, + jobs: { + regen: { + runs-on: "ubuntu-latest", + timeout-minutes: 60, + steps: [ + { + name: "Harden runner (egress audit)", + uses: "step-security/harden-runner@e14015d583714f6e62063499dc959a02595150a1", # v2.21.1 + with: { + egress-policy: "audit", + }, + }, + { + uses: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", # v7.0.1 + with: { + persist-credentials: false, + }, + }, + # An installation token is scoped to ONE owner, so mint one per owner. + { + name: "Mint an App installation token for hyperpolymath", + id: "tok-user", + if: "vars.APP_ID != ''", + uses: "actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1", # v3.2.0 + continue-on-error: true, + with: { + app-id: "${{ vars.APP_ID }}", + private-key: "${{ secrets.APP_PRIVATE_KEY }}", + owner: "hyperpolymath", + }, + }, + { + name: "Mint an App installation token for metadatastician", + id: "tok-org", + if: "vars.APP_ID != ''", + uses: "actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1", # v3.2.0 + continue-on-error: true, + with: { + app-id: "${{ vars.APP_ID }}", + private-key: "${{ secrets.APP_PRIVATE_KEY }}", + owner: "metadatastician", + }, + }, + { + name: "Install the lock tooling (same pin as the governance gate)", + env: { + GH_TOKEN: "${{ github.token }}", + }, + run: "sudo apt-get install -y -qq gawk >/dev/null\ngh extension install github/gh-actions-lock --pin v0.1.6\n", + }, + { + name: "Regenerate Dependabot PR lockfiles", + env: { + REGEN_TOKENS: "hyperpolymath=${{ steps.tok-user.outputs.token }} metadatastician=${{ steps.tok-org.outputs.token }}", + DRY_RUN: "${{ inputs.dry-run && '1' || '0' }}", + ONLY_REPO: "${{ inputs.repo }}", + }, + run: "# A token is an empty string when the App is absent or not installed\n# on that owner; the script names each owner it did NOT examine.\nbash scripts/regen-dependabot-locks.sh ${ONLY_REPO:+\"$ONLY_REPO\"} | tee \"$RUNNER_TEMP/regen.txt\"\n{\n echo '### Dependabot lock regeneration'\n echo '```'\n cat \"$RUNNER_TEMP/regen.txt\"\n echo '```'\n} >> \"$GITHUB_STEP_SUMMARY\"\n", + }, + ], + }, + }, +} diff --git a/.github/workflows/doc-format.yml b/.github/workflows/doc-format.yml index 892363c66..503c1c265 100644 --- a/.github/workflows/doc-format.yml +++ b/.github/workflows/doc-format.yml @@ -1,109 +1,57 @@ # This workflow is managed by gh actions-lock. # SPDX-License-Identifier: MPL-2.0 # This workflow is managed by gh actions-lock. -name: Documentation Format Enforcement - -on: - push: - branches: [main, master] - pull_request: - branches: [main, master] - -# Estate guardrail: cancel superseded runs so re-pushes / rebased PR -# updates do not pile up queued runs against the shared account-wide -# Actions concurrency pool. Applied only to read-only check workflows -# (no publish/mutation), so cancelling a superseded run is always safe. -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -permissions: - actions: read - contents: read - -jobs: - check-doc-format: - timeout-minutes: 10 - name: Check Documentation Format - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4 - - - name: Check for duplicate documentation formats - run: | - DUPLICATES=0 - - # .adoc-primary docs: keep only .adoc. README is NOT here — README is a - # GitHub-required Markdown file (see the README rule below). - for doc in ARCHITECTURE ROADMAP PHILOSOPHY INSTALL; do - if [ -f "${doc}.md" ] && [ -f "${doc}.adoc" ]; then - echo "::error::Duplicate documentation: ${doc}.md and ${doc}.adoc both exist. Keep only .adoc" - DUPLICATES=$((DUPLICATES + 1)) - fi - done - - # README: AsciiDoc is canonical estate-wide (ADR-004). A README.md - # alongside README.adoc is legitimate ONLY when it is a *derived* - # artefact — it must carry the GENERATED banner and the repo must - # declare a [publishing.readme] consumer in descriptiles. A hand- - # maintained README.md next to README.adoc is the duplicate to remove - # (this is the case the June 2026 runaway inverted). - if [ -f "README.md" ] && [ -f "README.adoc" ]; then - if grep -q "GENERATED from README.adoc" README.md 2>/dev/null; then - echo "✓ README.md is a derived artefact (GENERATED banner present)" - else - echo "::error::README.md alongside README.adoc is not a derived artefact (no GENERATED banner). Under ADR-004, README.adoc is canonical; either derive README.md via readme-derive-reusable.yml (with a [publishing.readme] declaration) or remove the hand-maintained README.md." - DUPLICATES=$((DUPLICATES + 1)) - fi - fi - - # CONTRIBUTING can have both but .md should just be a redirect - if [ -f "CONTRIBUTING.md" ] && [ -f "3-practice/CONTRIBUTING.adoc" ]; then - if ! grep -q "See.*3-practice/CONTRIBUTING.adoc" CONTRIBUTING.md 2>/dev/null; then - echo "::warning::CONTRIBUTING.md exists alongside 3-practice/CONTRIBUTING.adoc but is not a redirect" - fi - fi - - if [ $DUPLICATES -gt 0 ]; then - echo "::error::Found $DUPLICATES duplicate documentation files" - exit 1 - fi - - echo "✓ No duplicate documentation formats found" - - - name: Check documentation format - run: | - # ADR-004: README.adoc is canonical estate-wide. A lone README.adoc - # is correct and needs no action — do NOT nudge it to Markdown. A - # README.md is only produced by deriving it (readme-derive-reusable.yml) - # for repos that declare a [publishing.readme] consumer. GitHub renders - # .adoc natively; only external Markdown-only consumers (Glama, the - # profile repo) need the derived .md. - - # Community-health files that GitHub special-cases by exact .md name: - # 3-practice/SECURITY.md, CONTRIBUTING.md (can redirect), CODE_OF_CONDUCT.md, CHANGELOG.md - - # Check other docs are .adoc - for doc in ARCHITECTURE ROADMAP PHILOSOPHY INSTALL; do - if [ -f "${doc}.md" ]; then - echo "::warning::${doc}.md should be ${doc}.adoc" - fi - done - - echo "✓ Documentation format check complete" - - - name: Verify GitHub-required files exist - run: | - # These files are required/preferred by GitHub in .md format - MISSING=0 - - if [ ! -f "3-practice/SECURITY.md" ]; then - echo "::warning::3-practice/SECURITY.md not found (required for GitHub security tab)" - fi - - if [ ! -f "LICENSE.txt" ] && [ ! -f "LICENSE" ]; then - echo "::warning::LICENSE file not found" - fi - - echo "✓ GitHub-required files check complete" +{ + name: "Documentation Format Enforcement", + on: { + push: { + branches: [ + "main", + "master", + ], + }, + pull_request: { + branches: [ + "main", + "master", + ], + }, + }, + # Estate guardrail: cancel superseded runs so re-pushes / rebased PR + # updates do not pile up queued runs against the shared account-wide + # Actions concurrency pool. Applied only to read-only check workflows + # (no publish/mutation), so cancelling a superseded run is always safe. + concurrency: { + group: "${{ github.workflow }}-${{ github.ref }}", + cancel-in-progress: true, + }, + permissions: { + actions: "read", + contents: "read", + }, + jobs: { + check-doc-format: { + timeout-minutes: 10, + name: "Check Documentation Format", + runs-on: "ubuntu-latest", + steps: [ + { + name: "Checkout", + uses: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", # v4 + }, + { + name: "Check for duplicate documentation formats", + run: "DUPLICATES=0\n\n# .adoc-primary docs: keep only .adoc. README is NOT here — README is a\n# GitHub-required Markdown file (see the README rule below).\nfor doc in ARCHITECTURE ROADMAP PHILOSOPHY INSTALL; do\n if [ -f \"${doc}.md\" ] && [ -f \"${doc}.adoc\" ]; then\n echo \"::error::Duplicate documentation: ${doc}.md and ${doc}.adoc both exist. Keep only .adoc\"\n DUPLICATES=$((DUPLICATES + 1))\n fi\ndone\n\n# README: AsciiDoc is canonical estate-wide (ADR-004). A README.md\n# alongside README.adoc is legitimate ONLY when it is a *derived*\n# artefact — it must carry the GENERATED banner and the repo must\n# declare a [publishing.readme] consumer in descriptiles. A hand-\n# maintained README.md next to README.adoc is the duplicate to remove\n# (this is the case the June 2026 runaway inverted).\nif [ -f \"README.md\" ] && [ -f \"README.adoc\" ]; then\n if grep -q \"GENERATED from README.adoc\" README.md 2>/dev/null; then\n echo \"✓ README.md is a derived artefact (GENERATED banner present)\"\n else\n echo \"::error::README.md alongside README.adoc is not a derived artefact (no GENERATED banner). Under ADR-004, README.adoc is canonical; either derive README.md via readme-derive-reusable.yml (with a [publishing.readme] declaration) or remove the hand-maintained README.md.\"\n DUPLICATES=$((DUPLICATES + 1))\n fi\nfi\n\n# CONTRIBUTING can have both but .md should just be a redirect\nif [ -f \"CONTRIBUTING.md\" ] && [ -f \"3-practice/CONTRIBUTING.adoc\" ]; then\n if ! grep -q \"See.*3-practice/CONTRIBUTING.adoc\" CONTRIBUTING.md 2>/dev/null; then\n echo \"::warning::CONTRIBUTING.md exists alongside 3-practice/CONTRIBUTING.adoc but is not a redirect\"\n fi\nfi\n\nif [ $DUPLICATES -gt 0 ]; then\n echo \"::error::Found $DUPLICATES duplicate documentation files\"\n exit 1\nfi\n\necho \"✓ No duplicate documentation formats found\"\n", + }, + { + name: "Check documentation format", + run: "# ADR-004: README.adoc is canonical estate-wide. A lone README.adoc\n# is correct and needs no action — do NOT nudge it to Markdown. A\n# README.md is only produced by deriving it (readme-derive-reusable.yml)\n# for repos that declare a [publishing.readme] consumer. GitHub renders\n# .adoc natively; only external Markdown-only consumers (Glama, the\n# profile repo) need the derived .md.\n\n# Community-health files that GitHub special-cases by exact .md name:\n# 3-practice/SECURITY.md, CONTRIBUTING.md (can redirect), CODE_OF_CONDUCT.md, CHANGELOG.md\n\n# Check other docs are .adoc\nfor doc in ARCHITECTURE ROADMAP PHILOSOPHY INSTALL; do\n if [ -f \"${doc}.md\" ]; then\n echo \"::warning::${doc}.md should be ${doc}.adoc\"\n fi\ndone\n\necho \"✓ Documentation format check complete\"\n", + }, + { + name: "Verify GitHub-required files exist", + run: "# These files are required/preferred by GitHub in .md format\nMISSING=0\n\nif [ ! -f \"3-practice/SECURITY.md\" ]; then\n echo \"::warning::3-practice/SECURITY.md not found (required for GitHub security tab)\"\nfi\n\nif [ ! -f \"LICENSE.txt\" ] && [ ! -f \"LICENSE\" ]; then\n echo \"::warning::LICENSE file not found\"\nfi\n\necho \"✓ GitHub-required files check complete\"\n", + }, + ], + }, + }, +} diff --git a/.github/workflows/dyadt-verify.yml b/.github/workflows/dyadt-verify.yml index 8c9f83235..32c4c61b1 100644 --- a/.github/workflows/dyadt-verify.yml +++ b/.github/workflows/dyadt-verify.yml @@ -4,52 +4,60 @@ # dyadt-verify — DYADT dogfood. Verify this repo's CLAIMS.a2ml against primary # evidence, and run the DYADT conformance vector suite. If a claim this change # makes is false, the verifier REFUTES it and this job fails loudly. -name: DYADT Verify - -on: - push: - branches: [ main, master ] - pull_request: - branches: [ main, master ] - workflow_dispatch: - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -permissions: - actions: read - contents: read - -jobs: - verify-claims: - name: Verify CLAIMS.a2ml + conformance - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - name: Checkout repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - fetch-depth: 0 # need origin/main as the DYADT diff base - - - name: DYADT conformance vectors - run: | - bash 1-formats/sub-specs/did-you-actually-do-that/spec/conformance/run-conformance.sh - - - name: FFP conformance vectors (form-fill provenance) - run: | - # Same shape as DYADT: the spec's own vectors are the gate. A detector - # change that stops reproducing a vector's expected line fails here. - # Reference probe only (bash + awk); a product detector runs the same - # runner via FFP_DETECTOR=. - bash 1-formats/sub-specs/form-fill-provenance/spec/conformance/run-conformance.sh - - - name: Verify this change's own claims (dogfood) - env: - DYADT_BASE: origin/${{ github.base_ref || 'main' }} - run: | - if [ -f CLAIMS.a2ml ]; then - bash scripts/verify-claims.sh CLAIMS.a2ml - else - echo "No CLAIMS.a2ml at root — nothing to verify." - fi +{ + name: "DYADT Verify", + on: { + push: { + branches: [ + "main", + "master", + ], + }, + pull_request: { + branches: [ + "main", + "master", + ], + }, + workflow_dispatch: null, + }, + concurrency: { + group: "${{ github.workflow }}-${{ github.ref }}", + cancel-in-progress: true, + }, + permissions: { + actions: "read", + contents: "read", + }, + jobs: { + verify-claims: { + name: "Verify CLAIMS.a2ml + conformance", + runs-on: "ubuntu-latest", + timeout-minutes: 10, + steps: [ + { + name: "Checkout repository", + uses: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", # v7.0.1 + with: { + fetch-depth: 0, # need origin/main as the DYADT diff base + }, + }, + { + name: "DYADT conformance vectors", + run: "bash 1-formats/sub-specs/did-you-actually-do-that/spec/conformance/run-conformance.sh\n", + }, + { + name: "FFP conformance vectors (form-fill provenance)", + run: "# Same shape as DYADT: the spec's own vectors are the gate. A detector\n# change that stops reproducing a vector's expected line fails here.\n# Reference probe only (bash + awk); a product detector runs the same\n# runner via FFP_DETECTOR=.\nbash 1-formats/sub-specs/form-fill-provenance/spec/conformance/run-conformance.sh\n", + }, + { + name: "Verify this change's own claims (dogfood)", + env: { + DYADT_BASE: "origin/${{ github.base_ref || 'main' }}", + }, + run: "if [ -f CLAIMS.a2ml ]; then\n bash scripts/verify-claims.sh CLAIMS.a2ml\nelse\n echo \"No CLAIMS.a2ml at root — nothing to verify.\"\nfi\n", + }, + ], + }, + }, +} diff --git a/.github/workflows/echidna-verify.yml b/.github/workflows/echidna-verify.yml index c4f8594d6..1f9a53148 100644 --- a/.github/workflows/echidna-verify.yml +++ b/.github/workflows/echidna-verify.yml @@ -22,235 +22,178 @@ # Dogfooding: standards repo defines ECHIDNA trust pipeline; this workflow # applies that pipeline to the repo's own proofs. -name: ECHIDNA Proof Verification - -on: - push: - branches: [main] - paths: - - 'lol/proofs/**' - - '2-protocols/avow/avow-lib/src/abi/*.idr' - - '.github/workflows/echidna-verify.yml' - # NO path filter on pull_request, deliberately. It once existed because - # `Idris2 - a2ml proofs` was recorded as a REQUIRED context; that is STALE — - # verified 2026-09-17 against the live org ruleset: no ruleset pins any - # proof context, and the a2ml job itself is now evicted (retired project). - # The always-on shape is kept even so: the surviving jobs are second-level - # presence Guards, and this workflow doubles as the repo's proof-hygiene - # canary — letting proof-irrelevant PRs skip it would silently un-exercise - # the Guards. - pull_request: - schedule: - # Weekly re-verification to catch stale-proof drift - - cron: '0 6 * * 1' - workflow_dispatch: - -# Estate guardrail: cancel superseded runs so re-pushes / rebased PR -# updates do not pile up queued runs against the shared account-wide -# Actions concurrency pool. Applied only to read-only check workflows -# (no publish/mutation), so cancelling a superseded run is always safe. -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -permissions: - actions: read - contents: read - -jobs: - detect-proof-changes: - timeout-minutes: 5 - name: Detect proof changes - runs-on: ubuntu-latest - outputs: - proofs: ${{ steps.check.outputs.proofs }} - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4 - with: - fetch-depth: 0 - - name: Did this change touch any proof source? - id: check - shell: bash - run: | - # Non-PR events (push, schedule, dispatch) always verify. - if [ "${{ github.event_name }}" != "pull_request" ]; then - echo "proofs=true" >> "$GITHUB_OUTPUT"; exit 0 - fi - base="${{ github.event.pull_request.base.sha }}" - head="${{ github.event.pull_request.head.sha }}" - changed=$(git diff --name-only "$base" "$head" 2>/dev/null || true) - if printf '%s\n' "$changed" | grep -qE '^(lol/proofs/|2-protocols/avow/avow-lib/src/abi/.*\.idr$|\.github/workflows/echidna-verify\.yml$)'; then - echo "proofs=true" >> "$GITHUB_OUTPUT" - else - echo "proofs=false" >> "$GITHUB_OUTPUT" - fi - - agda-lol: - name: Agda — lol/proofs - needs: detect-proof-changes - if: needs.detect-proof-changes.outputs.proofs == 'true' - runs-on: ubuntu-latest - timeout-minutes: 20 - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - submodules: recursive - - - name: Guard — proofs are evicted from this repo - id: guard - run: | - # Same honesty guard idris2-avow got (see its Type-check step), - # placed BEFORE the toolchain install so the weekly cron stops - # spending minutes bootstrapping for a directory that does not - # exist. lol/proofs moved to the lol repository; if the corpus ever - # returns here, this job verifies again, unchanged (issue #748). - if [ ! -d lol/proofs ]; then - echo "::notice title=Agda proofs::lol/proofs is absent here — proofs were evicted to the lol repository. Nothing to type-check; remaining steps skipped." - echo "present=false" >> "$GITHUB_OUTPUT" - else - echo "present=true" >> "$GITHUB_OUTPUT" - fi - - - name: Install Agda - if: steps.guard.outputs.present == 'true' - run: | - sudo apt-get update - sudo apt-get install -y agda agda-stdlib - agda --version - - - name: Type-check proofs - id: typecheck - if: steps.guard.outputs.present == 'true' - run: | - set -eo pipefail - echo "=== Agda proofs under lol/proofs ===" - find lol/proofs/theories -name '*.agda' -print - cd lol/proofs - failed="" - for f in $(find theories -name '*.agda'); do - echo "--- checking $f ---" - if ! agda --safe "$f" 2>&1 | tee -a ../../agda-verify.log; then - failed="$failed$f"$'\n' - fi - done - if [ -n "$failed" ]; then - echo "::warning::agda --safe FAILED to type-check the following proof(s) (advisory until the evicted lol/proofs corpus's re-entry criteria are set; see issue #748):" - printf '%s' "$failed" | sed 's/^/ - /' - else - echo "All Agda proofs type-checked cleanly." - fi - - - name: Postulate audit - if: steps.guard.outputs.present == 'true' - run: | - echo "=== Postulate usage in lol/proofs ===" - grep -rn "postulate" lol/proofs/theories/ || echo "No postulates found" - - - name: Upload log - if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: agda-verify-log - path: agda-verify.log - if-no-files-found: ignore - - idris2-avow: - name: Idris2 — AVOW consent proofs - needs: detect-proof-changes - if: needs.detect-proof-changes.outputs.proofs == 'true' - runs-on: ubuntu-latest - timeout-minutes: 20 - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - submodules: recursive - - - name: Cache pack - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v4 - with: - path: | - ~/.pack - ~/.idris2 - key: ${{ runner.os }}-idris2-pack-${{ hashFiles('**/pack.toml', '**/*.ipkg') }} - restore-keys: | - ${{ runner.os }}-idris2-pack- - - - name: Install Idris2 via pack - run: | - set -e - if command -v idris2 >/dev/null 2>&1; then - echo "idris2 already present: $(idris2 --version)" - exit 0 - fi - sudo apt-get update - sudo apt-get install -y build-essential libgmp-dev chezscheme - git clone --depth 1 https://github.com/stefan-hoeck/idris2-pack.git "$HOME/idris2-pack" - cd "$HOME/idris2-pack" - # pack's install.bash prompts for the scheme binary; feeding newlines - # makes the prompt take its detected default (chezscheme) instead of - # hitting EOF (which aborts the bootstrap and leaves pack uninstalled). - yes '' | bash install.bash || echo "::warning::pack install had warnings" - # pack installs its launcher into ~/.local/bin (newer layout) and/or - # ~/.pack/bin (older); add both so `pack` resolves on PATH. - echo "$HOME/.local/bin" >> "$GITHUB_PATH" - echo "$HOME/.pack/bin" >> "$GITHUB_PATH" - export PATH="$HOME/.local/bin:$HOME/.pack/bin:$PATH" - # `install-api` is not a pack subcommand; `install-app idris2` - # installs the idris2 executable into the pack bin dir so the - # subsequent `idris2 --check` resolves. The base library (Prelude, - # base, contrib) ships with idris2, so no .ipkg is needed for a - # standalone --check. - pack install-app idris2 - idris2 --version - - - name: Type-check AVOW proofs - run: | - export PATH="$HOME/.pack/bin:$PATH" - # The per-file `if [ -f ]` guard below was written to tolerate absent - # proofs, but the `cd` ahead of it was NOT guarded, so a missing - # directory killed the step before the guard could ever run. In this - # repo 2-protocols/avow/ holds only BINDING.adoc - the proofs live in the - # avow-protocol repository - so the cd always failed and this job was - # permanently red. It only became visible once the workflow began - # running on pull requests. - if [ ! -d 2-protocols/avow/avow-lib ]; then - echo "::notice title=AVOW proofs::2-protocols/avow/avow-lib is not present here (this repo carries only the BINDING). Nothing to type-check." - exit 0 - fi - cd 2-protocols/avow/avow-lib - for f in src/abi/Consent.idr src/abi/Unsubscribe.idr src/abi/Types.idr; do - if [ -f "$f" ]; then - echo "--- checking $f ---" - idris2 --check "$f" 2>&1 | tee -a ../../idris2-avow.log - fi - done - - - name: Upload log - if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: idris2-avow-log - path: idris2-avow.log - if-no-files-found: ignore - - trust-summary: - timeout-minutes: 10 - name: Trust pipeline summary - needs: [detect-proof-changes, agda-lol, idris2-avow] - runs-on: ubuntu-latest - if: always() - steps: - - name: Summarise - run: | - echo "## ECHIDNA Trust Summary" >> "$GITHUB_STEP_SUMMARY" - echo "" >> "$GITHUB_STEP_SUMMARY" - echo "| Target | Result |" >> "$GITHUB_STEP_SUMMARY" - echo "|---|---|" >> "$GITHUB_STEP_SUMMARY" - echo "| Agda lol/proofs | ${{ needs.agda-lol.result }} |" >> "$GITHUB_STEP_SUMMARY" - echo "| Idris2 a2ml | _evicted — a2ml project officially retired 2026-09-17 |" >> "$GITHUB_STEP_SUMMARY" - echo "| Idris2 avow | ${{ needs.idris2-avow.result }} |" >> "$GITHUB_STEP_SUMMARY" - echo "" >> "$GITHUB_STEP_SUMMARY" - echo "See artefacts for verification logs." >> "$GITHUB_STEP_SUMMARY" +{ + name: "ECHIDNA Proof Verification", + on: { + push: { + branches: [ + "main", + ], + paths: [ + "lol/proofs/**", + "2-protocols/avow/avow-lib/src/abi/*.idr", + ".github/workflows/echidna-verify.yml", + ], + }, + # NO path filter on pull_request, deliberately. It once existed because + # `Idris2 - a2ml proofs` was recorded as a REQUIRED context; that is STALE — + # verified 2026-09-17 against the live org ruleset: no ruleset pins any + # proof context, and the a2ml job itself is now evicted (retired project). + # The always-on shape is kept even so: the surviving jobs are second-level + # presence Guards, and this workflow doubles as the repo's proof-hygiene + # canary — letting proof-irrelevant PRs skip it would silently un-exercise + # the Guards. + pull_request: null, + schedule: [ + # Weekly re-verification to catch stale-proof drift + { + cron: "0 6 * * 1", + }, + ], + workflow_dispatch: null, + }, + # Estate guardrail: cancel superseded runs so re-pushes / rebased PR + # updates do not pile up queued runs against the shared account-wide + # Actions concurrency pool. Applied only to read-only check workflows + # (no publish/mutation), so cancelling a superseded run is always safe. + concurrency: { + group: "${{ github.workflow }}-${{ github.ref }}", + cancel-in-progress: true, + }, + permissions: { + actions: "read", + contents: "read", + }, + jobs: { + detect-proof-changes: { + timeout-minutes: 5, + name: "Detect proof changes", + runs-on: "ubuntu-latest", + outputs: { + proofs: "${{ steps.check.outputs.proofs }}", + }, + steps: [ + { + name: "Checkout", + uses: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", # v4 + with: { + fetch-depth: 0, + }, + }, + { + name: "Did this change touch any proof source?", + id: "check", + shell: "bash", + run: "# Non-PR events (push, schedule, dispatch) always verify.\nif [ \"${{ github.event_name }}\" != \"pull_request\" ]; then\n echo \"proofs=true\" >> \"$GITHUB_OUTPUT\"; exit 0\nfi\nbase=\"${{ github.event.pull_request.base.sha }}\"\nhead=\"${{ github.event.pull_request.head.sha }}\"\nchanged=$(git diff --name-only \"$base\" \"$head\" 2>/dev/null || true)\nif printf '%s\\n' \"$changed\" | grep -qE '^(lol/proofs/|2-protocols/avow/avow-lib/src/abi/.*\\.idr$|\\.github/workflows/echidna-verify\\.yml$)'; then\n echo \"proofs=true\" >> \"$GITHUB_OUTPUT\"\nelse\n echo \"proofs=false\" >> \"$GITHUB_OUTPUT\"\nfi\n", + }, + ], + }, + agda-lol: { + name: "Agda — lol/proofs", + needs: "detect-proof-changes", + if: "needs.detect-proof-changes.outputs.proofs == 'true'", + runs-on: "ubuntu-latest", + timeout-minutes: 20, + steps: [ + { + name: "Checkout", + uses: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", # v7.0.1 + with: { + submodules: "recursive", + }, + }, + { + name: "Guard — proofs are evicted from this repo", + id: "guard", + run: "# Same honesty guard idris2-avow got (see its Type-check step),\n# placed BEFORE the toolchain install so the weekly cron stops\n# spending minutes bootstrapping for a directory that does not\n# exist. lol/proofs moved to the lol repository; if the corpus ever\n# returns here, this job verifies again, unchanged (issue #748).\nif [ ! -d lol/proofs ]; then\n echo \"::notice title=Agda proofs::lol/proofs is absent here — proofs were evicted to the lol repository. Nothing to type-check; remaining steps skipped.\"\n echo \"present=false\" >> \"$GITHUB_OUTPUT\"\nelse\n echo \"present=true\" >> \"$GITHUB_OUTPUT\"\nfi\n", + }, + { + name: "Install Agda", + if: "steps.guard.outputs.present == 'true'", + run: "sudo apt-get update\nsudo apt-get install -y agda agda-stdlib\nagda --version\n", + }, + { + name: "Type-check proofs", + id: "typecheck", + if: "steps.guard.outputs.present == 'true'", + run: "set -eo pipefail\necho \"=== Agda proofs under lol/proofs ===\"\nfind lol/proofs/theories -name '*.agda' -print\ncd lol/proofs\nfailed=\"\"\nfor f in $(find theories -name '*.agda'); do\n echo \"--- checking $f ---\"\n if ! agda --safe \"$f\" 2>&1 | tee -a ../../agda-verify.log; then\n failed=\"$failed$f\"$'\\n'\n fi\ndone\nif [ -n \"$failed\" ]; then\n echo \"::warning::agda --safe FAILED to type-check the following proof(s) (advisory until the evicted lol/proofs corpus's re-entry criteria are set; see issue #748):\"\n printf '%s' \"$failed\" | sed 's/^/ - /'\nelse\n echo \"All Agda proofs type-checked cleanly.\"\nfi\n", + }, + { + name: "Postulate audit", + if: "steps.guard.outputs.present == 'true'", + run: "echo \"=== Postulate usage in lol/proofs ===\"\ngrep -rn \"postulate\" lol/proofs/theories/ || echo \"No postulates found\"\n", + }, + { + name: "Upload log", + if: "always()", + uses: "actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a", # v7.0.1 + with: { + name: "agda-verify-log", + path: "agda-verify.log", + if-no-files-found: "ignore", + }, + }, + ], + }, + idris2-avow: { + name: "Idris2 — AVOW consent proofs", + needs: "detect-proof-changes", + if: "needs.detect-proof-changes.outputs.proofs == 'true'", + runs-on: "ubuntu-latest", + timeout-minutes: 20, + steps: [ + { + name: "Checkout", + uses: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", # v7.0.1 + with: { + submodules: "recursive", + }, + }, + { + name: "Cache pack", + uses: "actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9", # v4 + with: { + path: "~/.pack\n~/.idris2\n", + key: "${{ runner.os }}-idris2-pack-${{ hashFiles('**/pack.toml', '**/*.ipkg') }}", + restore-keys: "${{ runner.os }}-idris2-pack-\n", + }, + }, + { + name: "Install Idris2 via pack", + run: "set -e\nif command -v idris2 >/dev/null 2>&1; then\n echo \"idris2 already present: $(idris2 --version)\"\n exit 0\nfi\nsudo apt-get update\nsudo apt-get install -y build-essential libgmp-dev chezscheme\ngit clone --depth 1 https://github.com/stefan-hoeck/idris2-pack.git \"$HOME/idris2-pack\"\ncd \"$HOME/idris2-pack\"\n# pack's install.bash prompts for the scheme binary; feeding newlines\n# makes the prompt take its detected default (chezscheme) instead of\n# hitting EOF (which aborts the bootstrap and leaves pack uninstalled).\nyes '' | bash install.bash || echo \"::warning::pack install had warnings\"\n# pack installs its launcher into ~/.local/bin (newer layout) and/or\n# ~/.pack/bin (older); add both so `pack` resolves on PATH.\necho \"$HOME/.local/bin\" >> \"$GITHUB_PATH\"\necho \"$HOME/.pack/bin\" >> \"$GITHUB_PATH\"\nexport PATH=\"$HOME/.local/bin:$HOME/.pack/bin:$PATH\"\n# `install-api` is not a pack subcommand; `install-app idris2`\n# installs the idris2 executable into the pack bin dir so the\n# subsequent `idris2 --check` resolves. The base library (Prelude,\n# base, contrib) ships with idris2, so no .ipkg is needed for a\n# standalone --check.\npack install-app idris2\nidris2 --version\n", + }, + { + name: "Type-check AVOW proofs", + run: "export PATH=\"$HOME/.pack/bin:$PATH\"\n# The per-file `if [ -f ]` guard below was written to tolerate absent\n# proofs, but the `cd` ahead of it was NOT guarded, so a missing\n# directory killed the step before the guard could ever run. In this\n# repo 2-protocols/avow/ holds only BINDING.adoc - the proofs live in the\n# avow-protocol repository - so the cd always failed and this job was\n# permanently red. It only became visible once the workflow began\n# running on pull requests.\nif [ ! -d 2-protocols/avow/avow-lib ]; then\n echo \"::notice title=AVOW proofs::2-protocols/avow/avow-lib is not present here (this repo carries only the BINDING). Nothing to type-check.\"\n exit 0\nfi\ncd 2-protocols/avow/avow-lib\nfor f in src/abi/Consent.idr src/abi/Unsubscribe.idr src/abi/Types.idr; do\n if [ -f \"$f\" ]; then\n echo \"--- checking $f ---\"\n idris2 --check \"$f\" 2>&1 | tee -a ../../idris2-avow.log\n fi\ndone\n", + }, + { + name: "Upload log", + if: "always()", + uses: "actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a", # v7.0.1 + with: { + name: "idris2-avow-log", + path: "idris2-avow.log", + if-no-files-found: "ignore", + }, + }, + ], + }, + trust-summary: { + timeout-minutes: 10, + name: "Trust pipeline summary", + needs: [ + "detect-proof-changes", + "agda-lol", + "idris2-avow", + ], + runs-on: "ubuntu-latest", + if: "always()", + steps: [ + { + name: "Summarise", + run: "echo \"## ECHIDNA Trust Summary\" >> \"$GITHUB_STEP_SUMMARY\"\necho \"\" >> \"$GITHUB_STEP_SUMMARY\"\necho \"| Target | Result |\" >> \"$GITHUB_STEP_SUMMARY\"\necho \"|---|---|\" >> \"$GITHUB_STEP_SUMMARY\"\necho \"| Agda lol/proofs | ${{ needs.agda-lol.result }} |\" >> \"$GITHUB_STEP_SUMMARY\"\necho \"| Idris2 a2ml | _evicted — a2ml project officially retired 2026-09-17 |\" >> \"$GITHUB_STEP_SUMMARY\"\necho \"| Idris2 avow | ${{ needs.idris2-avow.result }} |\" >> \"$GITHUB_STEP_SUMMARY\"\necho \"\" >> \"$GITHUB_STEP_SUMMARY\"\necho \"See artefacts for verification logs.\" >> \"$GITHUB_STEP_SUMMARY\"\n", + }, + ], + }, + }, +} diff --git a/.github/workflows/elixir-ci-reusable.yml b/.github/workflows/elixir-ci-reusable.yml index 320e7d5ab..50c204350 100644 --- a/.github/workflows/elixir-ci-reusable.yml +++ b/.github/workflows/elixir-ci-reusable.yml @@ -55,172 +55,192 @@ # with: # working_directory: server -name: Elixir CI (reusable) - -on: - workflow_call: - inputs: - runs-on: - description: Runner label for the Elixir CI job - type: string - required: false - default: ubuntu-latest - otp-version: - description: OTP version selector for erlef/setup-beam - type: string - required: false - default: "26" - elixir-version: - description: Elixir version selector for erlef/setup-beam - type: string - required: false - default: "1.17" - rebar3-version: - description: >- - rebar3 version for erlef/setup-beam. ONLY set this when a dependency is - rebar-based; `mix` installs the rebar it needs on demand, so most Elixir - repos should leave this empty. Setting it installs rebar3 from GitHub - releases, resolved through api.github.com — pass `github-token` as well - so those lookups are authenticated. NOTE: this does NOT replace the - `mix local.rebar` call setup-beam makes when `install-rebar` is true - (the default); that runs first and is gated only by that input. - type: string - required: false - default: "" - github-token: - description: >- - Optional token forwarded to erlef/setup-beam, used solely to - authenticate its api.github.com lookups. Pass the caller's - `secrets.GITHUB_TOKEN`. Required in practice when - `rebar3-version` is set; harmless when it is empty. - type: string - required: false - default: "" - enable_dialyzer: - description: Run `mix dialyzer` (slow cold-cache; off by default) - type: boolean - required: false - default: false - enable_credo: - description: Run `mix credo --strict` - type: boolean - required: false - default: true - working_directory: - description: | - Directory containing `mix.exs` (relative to the repo root). All - mix invocations and cache lookups consult this directory. - Default `.` keeps single-app repos unchanged. Set to e.g. - `server` for a sub-app layout (burble, feedback-o-tron, - verisimdb at audit time). - type: string - required: false - default: "." - -# Only `contents: read` is requested. A reusable workflow may narrow the -# caller's permissions but never widen them: requesting a permission the -# caller has not granted aborts the run as `startup_failure` with ZERO -# jobs — no logs, no red step, just an empty run. The previous -# `actions: read` here was used by no job in this file, so every caller -# granting only `contents: read` (the estate default) failed to start. -permissions: - contents: read - -jobs: - # Guard on mix.exs so the wrapper is safe to add unconditionally. This must - # be a checked-out step, NOT a job-level `if: hashFiles(...)`: job `if:` is - # evaluated server-side before any checkout, so hashFiles() sees an empty - # workspace and always returns '' — which silently skipped the test job on - # EVERY repo, Elixir or not. The detect job checks out and exposes a real - # boolean output the test job gates on. - detect: - timeout-minutes: 5 - name: Detect mix.exs - runs-on: ${{ inputs.runs-on }} - permissions: - contents: read - outputs: - has_mix: ${{ steps.detect.outputs.has_mix }} - steps: - - name: Checkout repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: ${{ github.repository }} - ref: ${{ github.ref }} - - name: Detect mix.exs - id: detect - run: | - if [ -f "${{ inputs.working_directory }}/mix.exs" ]; then - echo "has_mix=true" >> "$GITHUB_OUTPUT" - else - echo "has_mix=false" >> "$GITHUB_OUTPUT" - fi - - test: - timeout-minutes: 20 - name: Compile + test - runs-on: ${{ inputs.runs-on }} - needs: detect - if: ${{ needs.detect.outputs.has_mix == 'true' }} - permissions: - contents: read - env: - MIX_ENV: test - defaults: - run: - working-directory: ${{ inputs.working_directory }} - steps: - - name: Checkout repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: ${{ github.repository }} - ref: ${{ github.ref }} - - - name: Guard — rebar3 pinning needs an authenticated lookup - if: ${{ inputs.rebar3-version != '' && inputs.github-token == '' }} - run: | - echo "::warning title=rebar3-version set without github-token::setup-beam resolves rebar3-version through unauthenticated api.github.com calls, which are rate-limited on the shared GitHub-hosted runner ranges and can fail this step within seconds. Pass the github-token input (secrets.GITHUB_TOKEN) or drop rebar3-version unless a dependency is rebar-based." - - - name: Set up BEAM (OTP + Elixir) - uses: erlef/setup-beam@54075bcc5e249e4758d363f27d099f55d843f124 # v1.24.1 - with: - otp-version: ${{ inputs.otp-version }} - elixir-version: ${{ inputs.elixir-version }} - rebar3-version: ${{ inputs.rebar3-version }} - github-token: ${{ inputs.github-token }} - - - name: Cache deps - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: ${{ inputs.working_directory }}/deps - key: deps-${{ inputs.elixir-version }}-${{ hashFiles(format('{0}/mix.lock', inputs.working_directory)) }} - - - name: Cache _build - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: ${{ inputs.working_directory }}/_build - key: build-${{ inputs.elixir-version }}-${{ hashFiles(format('{0}/mix.lock', inputs.working_directory)) }} - - - name: Install deps - run: mix deps.get - - # Compile deps WITHOUT --warnings-as-errors so upstream warnings - # (rustler's :json.decode needing Elixir 1.18, deprecated - # `use Bitwise`, etc.) don't fail the build. Strict mode then - # applies only to the project's own modules in the next step. - - name: Compile dependencies - run: mix deps.compile - - - name: Compile project (strict) - run: mix compile --warnings-as-errors - - - name: Credo lint - if: ${{ inputs.enable_credo }} - run: mix credo --strict - - - name: Dialyzer - if: ${{ inputs.enable_dialyzer }} - run: mix dialyzer - - - name: Run tests - run: mix test --cover +{ + name: "Elixir CI (reusable)", + on: { + workflow_call: { + inputs: { + runs-on: { + description: "Runner label for the Elixir CI job", + type: "string", + required: false, + default: "ubuntu-latest", + }, + otp-version: { + description: "OTP version selector for erlef/setup-beam", + type: "string", + required: false, + default: "26", + }, + elixir-version: { + description: "Elixir version selector for erlef/setup-beam", + type: "string", + required: false, + default: "1.17", + }, + rebar3-version: { + description: "rebar3 version for erlef/setup-beam. ONLY set this when a dependency is rebar-based; `mix` installs the rebar it needs on demand, so most Elixir repos should leave this empty. Setting it installs rebar3 from GitHub releases, resolved through api.github.com — pass `github-token` as well so those lookups are authenticated. NOTE: this does NOT replace the `mix local.rebar` call setup-beam makes when `install-rebar` is true (the default); that runs first and is gated only by that input.", + type: "string", + required: false, + default: "", + }, + github-token: { + description: "Optional token forwarded to erlef/setup-beam, used solely to authenticate its api.github.com lookups. Pass the caller's `secrets.GITHUB_TOKEN`. Required in practice when `rebar3-version` is set; harmless when it is empty.", + type: "string", + required: false, + default: "", + }, + enable_dialyzer: { + description: "Run `mix dialyzer` (slow cold-cache; off by default)", + type: "boolean", + required: false, + default: false, + }, + enable_credo: { + description: "Run `mix credo --strict`", + type: "boolean", + required: false, + default: true, + }, + working_directory: { + description: "Directory containing `mix.exs` (relative to the repo root). All\nmix invocations and cache lookups consult this directory.\nDefault `.` keeps single-app repos unchanged. Set to e.g.\n`server` for a sub-app layout (burble, feedback-o-tron,\nverisimdb at audit time).\n", + type: "string", + required: false, + default: ".", + }, + }, + }, + }, + # Only `contents: read` is requested. A reusable workflow may narrow the + # caller's permissions but never widen them: requesting a permission the + # caller has not granted aborts the run as `startup_failure` with ZERO + # jobs — no logs, no red step, just an empty run. The previous + # `actions: read` here was used by no job in this file, so every caller + # granting only `contents: read` (the estate default) failed to start. + permissions: { + contents: "read", + }, + jobs: { + # Guard on mix.exs so the wrapper is safe to add unconditionally. This must + # be a checked-out step, NOT a job-level `if: hashFiles(...)`: job `if:` is + # evaluated server-side before any checkout, so hashFiles() sees an empty + # workspace and always returns '' — which silently skipped the test job on + # EVERY repo, Elixir or not. The detect job checks out and exposes a real + # boolean output the test job gates on. + detect: { + timeout-minutes: 5, + name: "Detect mix.exs", + runs-on: "${{ inputs.runs-on }}", + permissions: { + contents: "read", + }, + outputs: { + has_mix: "${{ steps.detect.outputs.has_mix }}", + }, + steps: [ + { + name: "Checkout repository", + uses: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", # v7.0.1 + with: { + repository: "${{ github.repository }}", + ref: "${{ github.ref }}", + }, + }, + { + name: "Detect mix.exs", + id: "detect", + run: "if [ -f \"${{ inputs.working_directory }}/mix.exs\" ]; then\n echo \"has_mix=true\" >> \"$GITHUB_OUTPUT\"\nelse\n echo \"has_mix=false\" >> \"$GITHUB_OUTPUT\"\nfi\n", + }, + ], + }, + test: { + timeout-minutes: 20, + name: "Compile + test", + runs-on: "${{ inputs.runs-on }}", + needs: "detect", + if: "${{ needs.detect.outputs.has_mix == 'true' }}", + permissions: { + contents: "read", + }, + env: { + MIX_ENV: "test", + }, + defaults: { + run: { + working-directory: "${{ inputs.working_directory }}", + }, + }, + steps: [ + { + name: "Checkout repository", + uses: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", # v7.0.1 + with: { + repository: "${{ github.repository }}", + ref: "${{ github.ref }}", + }, + }, + { + name: "Guard — rebar3 pinning needs an authenticated lookup", + if: "${{ inputs.rebar3-version != '' && inputs.github-token == '' }}", + run: "echo \"::warning title=rebar3-version set without github-token::setup-beam resolves rebar3-version through unauthenticated api.github.com calls, which are rate-limited on the shared GitHub-hosted runner ranges and can fail this step within seconds. Pass the github-token input (secrets.GITHUB_TOKEN) or drop rebar3-version unless a dependency is rebar-based.\"\n", + }, + { + name: "Set up BEAM (OTP + Elixir)", + uses: "erlef/setup-beam@54075bcc5e249e4758d363f27d099f55d843f124", # v1.24.1 + with: { + otp-version: "${{ inputs.otp-version }}", + elixir-version: "${{ inputs.elixir-version }}", + rebar3-version: "${{ inputs.rebar3-version }}", + github-token: "${{ inputs.github-token }}", + }, + }, + { + name: "Cache deps", + uses: "actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9", # v6.1.0 + with: { + path: "${{ inputs.working_directory }}/deps", + key: "deps-${{ inputs.elixir-version }}-${{ hashFiles(format('{0}/mix.lock', inputs.working_directory)) }}", + }, + }, + { + name: "Cache _build", + uses: "actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9", # v6.1.0 + with: { + path: "${{ inputs.working_directory }}/_build", + key: "build-${{ inputs.elixir-version }}-${{ hashFiles(format('{0}/mix.lock', inputs.working_directory)) }}", + }, + }, + { + name: "Install deps", + run: "mix deps.get", + }, + # Compile deps WITHOUT --warnings-as-errors so upstream warnings + # (rustler's :json.decode needing Elixir 1.18, deprecated + # `use Bitwise`, etc.) don't fail the build. Strict mode then + # applies only to the project's own modules in the next step. + { + name: "Compile dependencies", + run: "mix deps.compile", + }, + { + name: "Compile project (strict)", + run: "mix compile --warnings-as-errors", + }, + { + name: "Credo lint", + if: "${{ inputs.enable_credo }}", + run: "mix credo --strict", + }, + { + name: "Dialyzer", + if: "${{ inputs.enable_dialyzer }}", + run: "mix dialyzer", + }, + { + name: "Run tests", + run: "mix test --cover", + }, + ], + }, + }, +} diff --git a/.github/workflows/elixir-ci.yml b/.github/workflows/elixir-ci.yml index c80decacd..e004f7634 100644 --- a/.github/workflows/elixir-ci.yml +++ b/.github/workflows/elixir-ci.yml @@ -1,21 +1,32 @@ # This workflow is managed by gh actions-lock. # SPDX-License-Identifier: MPL-2.0 # This workflow is managed by gh actions-lock. -name: Elixir CI - -on: - push: - branches: [main, master] - pull_request: - branches: [main, master] - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -permissions: - contents: read - -jobs: - ci: - uses: ./.github/workflows/elixir-ci-reusable.yml +{ + name: "Elixir CI", + on: { + push: { + branches: [ + "main", + "master", + ], + }, + pull_request: { + branches: [ + "main", + "master", + ], + }, + }, + concurrency: { + group: "${{ github.workflow }}-${{ github.ref }}", + cancel-in-progress: true, + }, + permissions: { + contents: "read", + }, + jobs: { + ci: { + uses: "./.github/workflows/elixir-ci-reusable.yml", + }, + }, +} diff --git a/.github/workflows/github-backup-mirror-reusable.yml b/.github/workflows/github-backup-mirror-reusable.yml index 1885423f2..a94729345 100644 --- a/.github/workflows/github-backup-mirror-reusable.yml +++ b/.github/workflows/github-backup-mirror-reusable.yml @@ -55,270 +55,102 @@ # only `repo` fails at push time with a message that does not obviously say # "scope", so this is worth getting right before the first run. -name: GitHub Backup Mirror (reusable) - -on: - workflow_call: - inputs: - runs-on: - description: Runner label for the mirror job - type: string - required: false - default: ubuntu-latest - destination-branch: - description: Branch name to write on the destination - type: string - required: false - default: main - secrets: - BACKUP_MIRROR_TOKEN: - required: false - -permissions: - contents: read - -jobs: - mirror-github: - name: Mirror to GitHub backup - timeout-minutes: 20 - runs-on: ${{ inputs.runs-on }} - if: vars.BACKUP_MIRROR_ENABLED == 'true' - # Advisory mirror: a backup failure must not redden an otherwise good build. - # The safety checks below still fail closed — they refuse to push. - continue-on-error: true - # Map the secret to env so step `if:`s can gate on its presence: the - # `secrets` context is NOT available in `if:` (using it is an - # "Unrecognized named-value: 'secrets'" startup failure that kills the whole - # workflow before any job runs — `instant-sync.yml` in this very repo still - # carries that bug). `env` IS available in step `if:`, and secrets are valid - # in job-level `env`. - env: - MIRROR_TOKEN: ${{ secrets.BACKUP_MIRROR_TOKEN }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - # persist-credentials: false is LOAD-BEARING, not hygiene. - # actions/checkout writes the job GITHUB_TOKEN into the local git - # config as `http.https://github.com/.extraheader`. That header - # applies to EVERY github.com remote, including the backup - # destination — where that token has no write access. An - # extraheader beats credentials in a remote URL, so leaving it in - # place makes the push below fail with a 403 that reads like a - # problem with the PAT. Do not re-enable without unsetting it. - persist-credentials: false - # Full history: a shallow clone cannot answer "is the destination an - # ancestor of what I am about to push?", which is the whole safety - # argument below. - fetch-depth: 0 - - - name: Skipped (BACKUP_MIRROR_TOKEN not configured) - if: ${{ env.MIRROR_TOKEN == '' }} - run: | - set -euo pipefail - echo "::notice::BACKUP_MIRROR_ENABLED=true but secrets.BACKUP_MIRROR_TOKEN is empty. Skipping GitHub backup mirror." - { - echo "### GitHub backup mirror: skipped" - echo "" - echo "\`BACKUP_MIRROR_ENABLED\` is \`true\` but \`BACKUP_MIRROR_TOKEN\` is empty." - echo "A skip is not a pass: nothing was mirrored." - } >> "$GITHUB_STEP_SUMMARY" - - - name: Resolve destination and prove it is safe - id: resolve - if: ${{ env.MIRROR_TOKEN != '' }} - env: - MIRROR_ORG: ${{ vars.BACKUP_MIRROR_ORG || vars.MIRROR_ORG }} - REPO_NAME: ${{ github.event.repository.name }} - SRC_OWNER: ${{ github.repository_owner }} - AUTOCREATE: ${{ vars.BACKUP_MIRROR_AUTOCREATE }} - DEST_BRANCH: ${{ inputs.destination-branch }} - GH_TOKEN: ${{ secrets.BACKUP_MIRROR_TOKEN }} - run: | - set -euo pipefail - - refuse() { - # A refusal under continue-on-error is invisible unless it is - # written in both places. Say it twice, deliberately. - echo "::error::$1" - { - echo "### GitHub backup mirror: REFUSED" - echo "" - echo "$1" - } >> "$GITHUB_STEP_SUMMARY" - exit 1 - } - - if [ -z "$MIRROR_ORG" ]; then - refuse "No destination owner. Set vars.BACKUP_MIRROR_ORG (or vars.MIRROR_ORG)." - fi - - # ── Hard denylist ──────────────────────────────────────────────── - # `.github` is the community-health repo, and the two estate copies - # are UNRELATED repositories that merely share a name: - # hyperpolymath/.github and metadatastician/.github differ in size, - # content and creation date, with no common history. Mirroring by - # name would overwrite one with the other and destroy real content. - # This is not configurable, because no configuration makes it safe. - case "$REPO_NAME" in - .github) - refuse "Refusing to mirror '.github': the estate copies are unrelated repositories sharing a name. Mirroring by name would destroy the destination." - ;; - esac - - if [ "$MIRROR_ORG" = "$SRC_OWNER" ]; then - refuse "Destination owner equals source owner ($SRC_OWNER); that would mirror a repository onto itself." - fi - - DEST_SLUG="${MIRROR_ORG}/${REPO_NAME}" - echo "Destination: $DEST_SLUG (branch $DEST_BRANCH)" - - # ── Does the destination exist? ────────────────────────────────── - # Discriminate on the HTTP STATUS, not on whether the command - # failed. GitHub deliberately returns 404 for a private repository - # you are not allowed to see, so "absent" and "exists but forbidden" - # are the SAME failure from `gh repo view`. Reading a 403 as - # "absent" would send us down the create path against a repository - # that already exists. `gh api -i` prints the status line, which is - # the only thing that tells them apart. - gh api -i "repos/${DEST_SLUG}" > "$RUNNER_TEMP/dest.http" 2>&1 || true - CODE="$(awk 'NR==1 {print $2; exit}' "$RUNNER_TEMP/dest.http")" - - if [[ ! "$CODE" =~ ^[0-9]{3}$ ]]; then - refuse "Could not parse an HTTP status from the destination probe for $DEST_SLUG. Refusing to guess whether it exists." - fi - - case "$CODE" in - 200) DEST_EXISTS=true ;; - 404) DEST_EXISTS=false ;; - 401|403) - refuse "Destination probe for $DEST_SLUG returned HTTP $CODE. The token is missing, expired, or lacks scope on the destination owner. A 403 is NOT an absent repository: not creating, not pushing." - ;; - *) - refuse "Destination probe for $DEST_SLUG returned HTTP $CODE. Unexpected; refusing to act on an unclassified response." - ;; - esac - echo "destination $DEST_SLUG probe: HTTP $CODE" - - if [ "$DEST_EXISTS" = false ]; then - if [ "$AUTOCREATE" != "true" ]; then - # Creating repositories is a side effect nobody asked for. Across - # this estate 46 of 49 source repos have no same-named - # destination, so a default-on autocreate would silently spawn 46 - # repositories on the first run. Opt in deliberately or not at all. - refuse "Destination $DEST_SLUG does not exist and vars.BACKUP_MIRROR_AUTOCREATE is not 'true'. Create it manually, or set BACKUP_MIRROR_AUTOCREATE=true to allow this workflow to create it private." - fi - echo "::notice::Creating private destination $DEST_SLUG" - gh repo create "$DEST_SLUG" --private \ - --description "Backup mirror of ${SRC_OWNER}/${REPO_NAME}. Managed by github-backup-mirror-reusable.yml." - # A freshly created repo is empty. Empty is SAFE, not divergent: - # there is nothing there to lose. - echo "expected_dest_head=" >> "$GITHUB_OUTPUT" - echo "safe_reason=destination created empty" >> "$GITHUB_OUTPUT" - echo "dest_slug=$DEST_SLUG" >> "$GITHUB_OUTPUT" - exit 0 - fi - - # ── Destination exists: prove we are not about to clobber it ───── - # Embed the token in the fetch URL. `secrets.BACKUP_MIRROR_TOKEN` - # arrives through the secrets context, so Actions masks it in logs; - # a base64 extraheader would NOT be masked, which is why the plain - # form is the safer one here. - DEST_URL="https://x-access-token:${MIRROR_TOKEN}@github.com/${DEST_SLUG}.git" - git remote add backup "$DEST_URL" 2>/dev/null || git remote set-url backup "$DEST_URL" - - # `git fetch` returns non-zero for BOTH "no such branch" and "the - # fetch failed" (transport, auth, server). Reading that one code as - # absence lets any outage bypass the ancestry proof below and fall - # straight through to a force push. `ls-remote` separates them: - # rc != 0 is a real failure, empty output is a genuinely absent branch. - if ! LS_OUT="$(git ls-remote --heads backup "refs/heads/${DEST_BRANCH}" 2>&1)"; then - refuse "Cannot reach $DEST_SLUG to check for '$DEST_BRANCH'. A failed lookup is NOT proof the branch is absent, so this run refuses rather than force-pushing over a destination it could not read." - fi - - if [ -z "$LS_OUT" ]; then - echo "::notice::Destination $DEST_SLUG has no '$DEST_BRANCH' branch yet; nothing to overwrite." - echo "expected_dest_head=" >> "$GITHUB_OUTPUT" - echo "safe_reason=destination has no $DEST_BRANCH branch" >> "$GITHUB_OUTPUT" - echo "dest_slug=$DEST_SLUG" >> "$GITHUB_OUTPUT" - exit 0 - fi - - # The branch DOES exist, so a fetch failure here is a real failure. - if ! git fetch --no-tags backup "refs/heads/${DEST_BRANCH}:refs/remotes/backup/${DEST_BRANCH}"; then - refuse "Destination branch '$DEST_BRANCH' exists on $DEST_SLUG but could not be fetched. Refusing to push over a destination whose state could not be read." - fi - - # An ancestry proof over a TRUNCATED history is vacuous. With a - # shallow clone `git merge-base --is-ancestor` can answer "no" - # purely because the common ancestor was never fetched, and it can - # answer "yes" only within the fetched window. checkout above asks - # for fetch-depth: 0, but a caller override or a grafted repo would - # silently defeat that — so assert it rather than assume it. - if [ "$(git rev-parse --is-shallow-repository)" != "false" ]; then - refuse "Repository is shallow; an ancestry proof over a truncated history proves nothing. Set fetch-depth: 0 on the checkout. Not pushing." - fi - - DEST_HEAD="$(git rev-parse "refs/remotes/backup/${DEST_BRANCH}")" - SRC_HEAD="$(git rev-parse HEAD)" - echo "Destination HEAD: $DEST_HEAD" - echo "Source HEAD: $SRC_HEAD" - - if [ "$DEST_HEAD" = "$SRC_HEAD" ]; then - echo "expected_dest_head=$DEST_HEAD" >> "$GITHUB_OUTPUT" - echo "safe_reason=already up to date" >> "$GITHUB_OUTPUT" - echo "dest_slug=$DEST_SLUG" >> "$GITHUB_OUTPUT" - exit 0 - fi - - # The ONLY other safe case: everything the destination has is already - # contained in what we are about to push. Anything else means the - # destination holds commits that a force-push would destroy. - if ! git merge-base --is-ancestor "$DEST_HEAD" "$SRC_HEAD"; then - AHEAD="$(git rev-list --count "^${SRC_HEAD}" "${DEST_HEAD}" 2>/dev/null || echo '?')" - refuse "Destination $DEST_SLUG has DIVERGED: $DEST_HEAD is not an ancestor of $SRC_HEAD ($AHEAD commit(s) exist only on the destination). Refusing to force-push; reconcile the histories by hand. This is the case the naive 'git push --mirror || true' pattern silently destroys." - fi - - echo "expected_dest_head=$DEST_HEAD" >> "$GITHUB_OUTPUT" - echo "safe_reason=destination is a strict ancestor (fast-forward)" >> "$GITHUB_OUTPUT" - echo "dest_slug=$DEST_SLUG" >> "$GITHUB_OUTPUT" - - - name: Push to backup mirror - if: ${{ env.MIRROR_TOKEN != '' && steps.resolve.outputs.dest_slug != '' }} - env: - DEST_SLUG: ${{ steps.resolve.outputs.dest_slug }} - SAFE_REASON: ${{ steps.resolve.outputs.safe_reason }} - EXPECTED_DEST_HEAD: ${{ steps.resolve.outputs.expected_dest_head }} - DEST_BRANCH: ${{ inputs.destination-branch }} - run: | - set -euo pipefail - DEST_URL="https://x-access-token:${MIRROR_TOKEN}@github.com/${DEST_SLUG}.git" - git remote add backup "$DEST_URL" 2>/dev/null || git remote set-url backup "$DEST_URL" - - # HEAD:refs/heads/ rather than a bare branch name: the local - # branch is not guaranteed to be checked out under every trigger, and - # this form pushes exactly the commit that was verified above. - # The ancestry proof was taken in an EARLIER step. The destination can - # advance in the window between that step and this one, and a bare - # `--force` would destroy whatever arrived in it. The lease binds this - # push to the exact SHA that was verified: if the destination moved, - # the push is rejected instead of overwriting an unseen commit. - if [ -z "${EXPECTED_DEST_HEAD:-}" ]; then - # Nothing existed to verify, so creating the branch needs no force. - # If it appeared in the meantime this push fails, which is correct. - git push backup "HEAD:refs/heads/${DEST_BRANCH}" - else - git push \ - --force-with-lease="refs/heads/${DEST_BRANCH}:${EXPECTED_DEST_HEAD}" \ - backup "HEAD:refs/heads/${DEST_BRANCH}" - fi - - { - echo "### GitHub backup mirror: pushed" - echo "" - echo "| Field | Value |" - echo "|---|---|" - echo "| Destination | \`${DEST_SLUG}\` |" - echo "| Branch | \`${DEST_BRANCH}\` |" - echo "| Commit | \`${GITHUB_SHA}\` |" - echo "| Safety | ${SAFE_REASON} |" - } >> "$GITHUB_STEP_SUMMARY" +{ + name: "GitHub Backup Mirror (reusable)", + on: { + workflow_call: { + inputs: { + runs-on: { + description: "Runner label for the mirror job", + type: "string", + required: false, + default: "ubuntu-latest", + }, + destination-branch: { + description: "Branch name to write on the destination", + type: "string", + required: false, + default: "main", + }, + }, + secrets: { + BACKUP_MIRROR_TOKEN: { + required: false, + }, + }, + }, + }, + permissions: { + contents: "read", + }, + jobs: { + mirror-github: { + name: "Mirror to GitHub backup", + timeout-minutes: 20, + runs-on: "${{ inputs.runs-on }}", + if: "vars.BACKUP_MIRROR_ENABLED == 'true'", + # Advisory mirror: a backup failure must not redden an otherwise good build. + # The safety checks below still fail closed — they refuse to push. + continue-on-error: true, + # Map the secret to env so step `if:`s can gate on its presence: the + # `secrets` context is NOT available in `if:` (using it is an + # "Unrecognized named-value: 'secrets'" startup failure that kills the whole + # workflow before any job runs — `instant-sync.yml` in this very repo still + # carries that bug). `env` IS available in step `if:`, and secrets are valid + # in job-level `env`. + env: { + MIRROR_TOKEN: "${{ secrets.BACKUP_MIRROR_TOKEN }}", + }, + steps: [ + { + uses: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", # v7.0.1 + with: { + # persist-credentials: false is LOAD-BEARING, not hygiene. + # actions/checkout writes the job GITHUB_TOKEN into the local git + # config as `http.https://github.com/.extraheader`. That header + # applies to EVERY github.com remote, including the backup + # destination — where that token has no write access. An + # extraheader beats credentials in a remote URL, so leaving it in + # place makes the push below fail with a 403 that reads like a + # problem with the PAT. Do not re-enable without unsetting it. + persist-credentials: false, + # Full history: a shallow clone cannot answer "is the destination an + # ancestor of what I am about to push?", which is the whole safety + # argument below. + fetch-depth: 0, + }, + }, + { + name: "Skipped (BACKUP_MIRROR_TOKEN not configured)", + if: "${{ env.MIRROR_TOKEN == '' }}", + run: "set -euo pipefail\necho \"::notice::BACKUP_MIRROR_ENABLED=true but secrets.BACKUP_MIRROR_TOKEN is empty. Skipping GitHub backup mirror.\"\n{\n echo \"### GitHub backup mirror: skipped\"\n echo \"\"\n echo \"\\`BACKUP_MIRROR_ENABLED\\` is \\`true\\` but \\`BACKUP_MIRROR_TOKEN\\` is empty.\"\n echo \"A skip is not a pass: nothing was mirrored.\"\n} >> \"$GITHUB_STEP_SUMMARY\"\n", + }, + { + name: "Resolve destination and prove it is safe", + id: "resolve", + if: "${{ env.MIRROR_TOKEN != '' }}", + env: { + MIRROR_ORG: "${{ vars.BACKUP_MIRROR_ORG || vars.MIRROR_ORG }}", + REPO_NAME: "${{ github.event.repository.name }}", + SRC_OWNER: "${{ github.repository_owner }}", + AUTOCREATE: "${{ vars.BACKUP_MIRROR_AUTOCREATE }}", + DEST_BRANCH: "${{ inputs.destination-branch }}", + GH_TOKEN: "${{ secrets.BACKUP_MIRROR_TOKEN }}", + }, + run: "set -euo pipefail\n\nrefuse() {\n # A refusal under continue-on-error is invisible unless it is\n # written in both places. Say it twice, deliberately.\n echo \"::error::$1\"\n {\n echo \"### GitHub backup mirror: REFUSED\"\n echo \"\"\n echo \"$1\"\n } >> \"$GITHUB_STEP_SUMMARY\"\n exit 1\n}\n\nif [ -z \"$MIRROR_ORG\" ]; then\n refuse \"No destination owner. Set vars.BACKUP_MIRROR_ORG (or vars.MIRROR_ORG).\"\nfi\n\n# ── Hard denylist ────────────────────────────────────────────────\n# `.github` is the community-health repo, and the two estate copies\n# are UNRELATED repositories that merely share a name:\n# hyperpolymath/.github and metadatastician/.github differ in size,\n# content and creation date, with no common history. Mirroring by\n# name would overwrite one with the other and destroy real content.\n# This is not configurable, because no configuration makes it safe.\ncase \"$REPO_NAME\" in\n .github)\n refuse \"Refusing to mirror '.github': the estate copies are unrelated repositories sharing a name. Mirroring by name would destroy the destination.\"\n ;;\nesac\n\nif [ \"$MIRROR_ORG\" = \"$SRC_OWNER\" ]; then\n refuse \"Destination owner equals source owner ($SRC_OWNER); that would mirror a repository onto itself.\"\nfi\n\nDEST_SLUG=\"${MIRROR_ORG}/${REPO_NAME}\"\necho \"Destination: $DEST_SLUG (branch $DEST_BRANCH)\"\n\n# ── Does the destination exist? ──────────────────────────────────\n# Discriminate on the HTTP STATUS, not on whether the command\n# failed. GitHub deliberately returns 404 for a private repository\n# you are not allowed to see, so \"absent\" and \"exists but forbidden\"\n# are the SAME failure from `gh repo view`. Reading a 403 as\n# \"absent\" would send us down the create path against a repository\n# that already exists. `gh api -i` prints the status line, which is\n# the only thing that tells them apart.\ngh api -i \"repos/${DEST_SLUG}\" > \"$RUNNER_TEMP/dest.http\" 2>&1 || true\nCODE=\"$(awk 'NR==1 {print $2; exit}' \"$RUNNER_TEMP/dest.http\")\"\n\nif [[ ! \"$CODE\" =~ ^[0-9]{3}$ ]]; then\n refuse \"Could not parse an HTTP status from the destination probe for $DEST_SLUG. Refusing to guess whether it exists.\"\nfi\n\ncase \"$CODE\" in\n 200) DEST_EXISTS=true ;;\n 404) DEST_EXISTS=false ;;\n 401|403)\n refuse \"Destination probe for $DEST_SLUG returned HTTP $CODE. The token is missing, expired, or lacks scope on the destination owner. A 403 is NOT an absent repository: not creating, not pushing.\"\n ;;\n *)\n refuse \"Destination probe for $DEST_SLUG returned HTTP $CODE. Unexpected; refusing to act on an unclassified response.\"\n ;;\nesac\necho \"destination $DEST_SLUG probe: HTTP $CODE\"\n\nif [ \"$DEST_EXISTS\" = false ]; then\n if [ \"$AUTOCREATE\" != \"true\" ]; then\n # Creating repositories is a side effect nobody asked for. Across\n # this estate 46 of 49 source repos have no same-named\n # destination, so a default-on autocreate would silently spawn 46\n # repositories on the first run. Opt in deliberately or not at all.\n refuse \"Destination $DEST_SLUG does not exist and vars.BACKUP_MIRROR_AUTOCREATE is not 'true'. Create it manually, or set BACKUP_MIRROR_AUTOCREATE=true to allow this workflow to create it private.\"\n fi\n echo \"::notice::Creating private destination $DEST_SLUG\"\n gh repo create \"$DEST_SLUG\" --private \\\n --description \"Backup mirror of ${SRC_OWNER}/${REPO_NAME}. Managed by github-backup-mirror-reusable.yml.\"\n # A freshly created repo is empty. Empty is SAFE, not divergent:\n # there is nothing there to lose.\n echo \"expected_dest_head=\" >> \"$GITHUB_OUTPUT\"\n echo \"safe_reason=destination created empty\" >> \"$GITHUB_OUTPUT\"\n echo \"dest_slug=$DEST_SLUG\" >> \"$GITHUB_OUTPUT\"\n exit 0\nfi\n\n# ── Destination exists: prove we are not about to clobber it ─────\n# Embed the token in the fetch URL. `secrets.BACKUP_MIRROR_TOKEN`\n# arrives through the secrets context, so Actions masks it in logs;\n# a base64 extraheader would NOT be masked, which is why the plain\n# form is the safer one here.\nDEST_URL=\"https://x-access-token:${MIRROR_TOKEN}@github.com/${DEST_SLUG}.git\"\ngit remote add backup \"$DEST_URL\" 2>/dev/null || git remote set-url backup \"$DEST_URL\"\n\n# `git fetch` returns non-zero for BOTH \"no such branch\" and \"the\n# fetch failed\" (transport, auth, server). Reading that one code as\n# absence lets any outage bypass the ancestry proof below and fall\n# straight through to a force push. `ls-remote` separates them:\n# rc != 0 is a real failure, empty output is a genuinely absent branch.\nif ! LS_OUT=\"$(git ls-remote --heads backup \"refs/heads/${DEST_BRANCH}\" 2>&1)\"; then\n refuse \"Cannot reach $DEST_SLUG to check for '$DEST_BRANCH'. A failed lookup is NOT proof the branch is absent, so this run refuses rather than force-pushing over a destination it could not read.\"\nfi\n\nif [ -z \"$LS_OUT\" ]; then\n echo \"::notice::Destination $DEST_SLUG has no '$DEST_BRANCH' branch yet; nothing to overwrite.\"\n echo \"expected_dest_head=\" >> \"$GITHUB_OUTPUT\"\n echo \"safe_reason=destination has no $DEST_BRANCH branch\" >> \"$GITHUB_OUTPUT\"\n echo \"dest_slug=$DEST_SLUG\" >> \"$GITHUB_OUTPUT\"\n exit 0\nfi\n\n# The branch DOES exist, so a fetch failure here is a real failure.\nif ! git fetch --no-tags backup \"refs/heads/${DEST_BRANCH}:refs/remotes/backup/${DEST_BRANCH}\"; then\n refuse \"Destination branch '$DEST_BRANCH' exists on $DEST_SLUG but could not be fetched. Refusing to push over a destination whose state could not be read.\"\nfi\n\n# An ancestry proof over a TRUNCATED history is vacuous. With a\n# shallow clone `git merge-base --is-ancestor` can answer \"no\"\n# purely because the common ancestor was never fetched, and it can\n# answer \"yes\" only within the fetched window. checkout above asks\n# for fetch-depth: 0, but a caller override or a grafted repo would\n# silently defeat that — so assert it rather than assume it.\nif [ \"$(git rev-parse --is-shallow-repository)\" != \"false\" ]; then\n refuse \"Repository is shallow; an ancestry proof over a truncated history proves nothing. Set fetch-depth: 0 on the checkout. Not pushing.\"\nfi\n\nDEST_HEAD=\"$(git rev-parse \"refs/remotes/backup/${DEST_BRANCH}\")\"\nSRC_HEAD=\"$(git rev-parse HEAD)\"\necho \"Destination HEAD: $DEST_HEAD\"\necho \"Source HEAD: $SRC_HEAD\"\n\nif [ \"$DEST_HEAD\" = \"$SRC_HEAD\" ]; then\n echo \"expected_dest_head=$DEST_HEAD\" >> \"$GITHUB_OUTPUT\"\n echo \"safe_reason=already up to date\" >> \"$GITHUB_OUTPUT\"\n echo \"dest_slug=$DEST_SLUG\" >> \"$GITHUB_OUTPUT\"\n exit 0\nfi\n\n# The ONLY other safe case: everything the destination has is already\n# contained in what we are about to push. Anything else means the\n# destination holds commits that a force-push would destroy.\nif ! git merge-base --is-ancestor \"$DEST_HEAD\" \"$SRC_HEAD\"; then\n AHEAD=\"$(git rev-list --count \"^${SRC_HEAD}\" \"${DEST_HEAD}\" 2>/dev/null || echo '?')\"\n refuse \"Destination $DEST_SLUG has DIVERGED: $DEST_HEAD is not an ancestor of $SRC_HEAD ($AHEAD commit(s) exist only on the destination). Refusing to force-push; reconcile the histories by hand. This is the case the naive 'git push --mirror || true' pattern silently destroys.\"\nfi\n\necho \"expected_dest_head=$DEST_HEAD\" >> \"$GITHUB_OUTPUT\"\necho \"safe_reason=destination is a strict ancestor (fast-forward)\" >> \"$GITHUB_OUTPUT\"\necho \"dest_slug=$DEST_SLUG\" >> \"$GITHUB_OUTPUT\"\n", + }, + { + name: "Push to backup mirror", + if: "${{ env.MIRROR_TOKEN != '' && steps.resolve.outputs.dest_slug != '' }}", + env: { + DEST_SLUG: "${{ steps.resolve.outputs.dest_slug }}", + SAFE_REASON: "${{ steps.resolve.outputs.safe_reason }}", + EXPECTED_DEST_HEAD: "${{ steps.resolve.outputs.expected_dest_head }}", + DEST_BRANCH: "${{ inputs.destination-branch }}", + }, + run: "set -euo pipefail\nDEST_URL=\"https://x-access-token:${MIRROR_TOKEN}@github.com/${DEST_SLUG}.git\"\ngit remote add backup \"$DEST_URL\" 2>/dev/null || git remote set-url backup \"$DEST_URL\"\n\n# HEAD:refs/heads/ rather than a bare branch name: the local\n# branch is not guaranteed to be checked out under every trigger, and\n# this form pushes exactly the commit that was verified above.\n# The ancestry proof was taken in an EARLIER step. The destination can\n# advance in the window between that step and this one, and a bare\n# `--force` would destroy whatever arrived in it. The lease binds this\n# push to the exact SHA that was verified: if the destination moved,\n# the push is rejected instead of overwriting an unseen commit.\nif [ -z \"${EXPECTED_DEST_HEAD:-}\" ]; then\n # Nothing existed to verify, so creating the branch needs no force.\n # If it appeared in the meantime this push fails, which is correct.\n git push backup \"HEAD:refs/heads/${DEST_BRANCH}\"\nelse\n git push \\\n --force-with-lease=\"refs/heads/${DEST_BRANCH}:${EXPECTED_DEST_HEAD}\" \\\n backup \"HEAD:refs/heads/${DEST_BRANCH}\"\nfi\n\n{\n echo \"### GitHub backup mirror: pushed\"\n echo \"\"\n echo \"| Field | Value |\"\n echo \"|---|---|\"\n echo \"| Destination | \\`${DEST_SLUG}\\` |\"\n echo \"| Branch | \\`${DEST_BRANCH}\\` |\"\n echo \"| Commit | \\`${GITHUB_SHA}\\` |\"\n echo \"| Safety | ${SAFE_REASON} |\"\n} >> \"$GITHUB_STEP_SUMMARY\"\n", + }, + ], + }, + }, +} diff --git a/.github/workflows/governance-reusable.yml b/.github/workflows/governance-reusable.yml index 6150c2b2a..cc2541b88 100644 --- a/.github/workflows/governance-reusable.yml +++ b/.github/workflows/governance-reusable.yml @@ -2,1697 +2,980 @@ # SPDX-License-Identifier: MPL-2.0 # This workflow is managed by gh actions-lock. # Governance checks for hyperpolymath repositories — Reusable Workflow -name: Governance Reusable Workflow - -on: - workflow_call: - inputs: - hypatia-ref: - description: >- - Hypatia commit the baseline job builds, as a 40-hex SHA, or "HEAD" - for a canary run. Kept in step with hypatia-scan-reusable.yml's - default; a floating HEAD here broke every baseline caller when - hypatia#895 changed the emitted severity of `warn` findings. - type: string - default: 51ab6496bf47e30a0576d503df31fec30f3cfe56 - runs-on: - description: Runner label for all governance jobs - type: string - required: false - default: ubuntu-latest - secrets: - HYPATIA_SCAN_PAT: - description: Fine-grained token with repository Administration read access - required: false - -permissions: - actions: read - contents: read - -jobs: - workflow-staleness: - name: Check Workflow Staleness - runs-on: ${{ inputs.runs-on }} - timeout-minutes: 10 - outputs: - has_baseline: ${{ steps.check.outputs.has_baseline }} - steps: - - name: Checkout caller repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - name: Check baseline existence - id: check - run: | - if [ -f ".hypatia-baseline.json" ]; then - echo "has_baseline=true" >> $GITHUB_OUTPUT - else - echo "has_baseline=false" >> $GITHUB_OUTPUT - fi - - - name: Checkout pinned standards history - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: hyperpolymath/standards - # The gate needs the full graph for ancestry and age checks, but its - # script must come from the exact reusable commit rather than moving - # main. This also lets a Standards PR test its own validator change. - ref: ${{ job.workflow_sha }} - path: .standards-history - fetch-depth: 0 - filter: tree:0 - persist-credentials: false - - - name: Run staleness check - env: - # The gate confirms a pin server-side before ever accusing it of being - # forged, and before accepting a deny-list negative from a clone it - # cannot trust. Unauthenticated that is 60 requests/hour per runner IP; - # exhausting it makes the gate degrade to ::warning (loudly — it never - # silently passes, see check-workflow-staleness.sh). A token removes - # the cliff entirely. Note this only reaches a consumer once it re-pins - # past this commit: the script comes from standards HEAD, but the job - # definition comes from whatever SHA the consumer pins. - GITHUB_TOKEN: ${{ github.token }} - STALENESS_STANDARDS_DIR: ${{ github.workspace }}/.standards-history - run: | - bash .standards-history/scripts/check-workflow-staleness.sh . - - uuid-v7-conformance: - name: UUID v7 conformance - runs-on: ${{ inputs.runs-on }} - timeout-minutes: 10 - steps: - - name: Checkout caller repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - fetch-depth: 1 - persist-credentials: false - - - name: Checkout UUID v7 validator - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: hyperpolymath/standards - ref: ${{ job.workflow_sha }} - path: .standards-uuid - sparse-checkout: scripts/check-uuid-v7.sh - sparse-checkout-cone-mode: false - persist-credentials: false - - - name: Reject non-v7 UUID literals - run: | - chmod +x .standards-uuid/scripts/check-uuid-v7.sh - .standards-uuid/scripts/check-uuid-v7.sh . - - allowlist-preflight: - name: Allowlist Preflight - runs-on: ${{ inputs.runs-on }} - timeout-minutes: 5 - permissions: - contents: read - steps: - - name: Checkout caller repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: ${{ github.repository }} - ref: ${{ github.sha }} - - - name: Checkout standards for the allowlist gate - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: hyperpolymath/standards - # Exact commit of this reusable workflow. This preserves consumer - # pin semantics and lets a standards PR test its own scripts. - ref: ${{ job.workflow_sha }} - path: .standards-checkout - sparse-checkout: | - rhodium-standard-repositories/actions-allowlist - scripts - sparse-checkout-cone-mode: false - - - name: Run tree allowlist preflight check - run: | - # Bootstrap rule: standards must test the scripts from its PR tree; - # consumers use the canonical copies checked out from standards. - if [ "$GITHUB_REPOSITORY" = hyperpolymath/standards ]; then - cp scripts/check-actions-policy.sh scripts/check-allowed-actions.sh "$RUNNER_TEMP/" - cp rhodium-standard-repositories/actions-allowlist/allowed-actions.json \ - "$RUNNER_TEMP/allowed-actions.json" - else - cp .standards-checkout/scripts/check-actions-policy.sh \ - .standards-checkout/scripts/check-allowed-actions.sh "$RUNNER_TEMP/" - cp .standards-checkout/rhodium-standard-repositories/actions-allowlist/allowed-actions.json \ - "$RUNNER_TEMP/allowed-actions.json" - fi - bash "$RUNNER_TEMP/check-allowed-actions.sh" \ - "$RUNNER_TEMP/allowed-actions.json" .github/workflows - rm -rf .standards-checkout - - actions-policy-live: - name: Live Actions policy (credentialed advisory) - runs-on: ${{ inputs.runs-on }} - timeout-minutes: 5 - permissions: - contents: read - steps: - - name: Checkout standards for the live policy gate - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: hyperpolymath/standards - ref: ${{ job.workflow_sha }} - path: .standards-checkout - sparse-checkout: | - rhodium-standard-repositories/actions-allowlist - scripts/check-actions-policy.sh - sparse-checkout-cone-mode: false - - - name: Report unavailable live policy credential - env: - GH_TOKEN: ${{ secrets.HYPATIA_SCAN_PAT }} - if: ${{ env.GH_TOKEN == '' }} - run: | - echo "::notice::Live Actions policy was not evaluated: HYPATIA_SCAN_PAT was not supplied by the caller. The separate tree allowlist gate still ran." - # shellcheck disable=SC2016 - printf '%s\n' \ - '### Live Actions policy not evaluated' \ - '' \ - 'The caller did not supply `HYPATIA_SCAN_PAT`. This advisory job' \ - 'does not claim that the repository Administration setting passed.' \ - 'The independent tree allowlist preflight remains enforced.' \ - >> "$GITHUB_STEP_SUMMARY" - - - name: Check live Actions policy - env: - # The ordinary job token cannot read the repository Administration - # endpoint. When a caller deliberately supplies this credential, an - # API/authentication failure or real policy violation remains red. - GH_TOKEN: ${{ secrets.HYPATIA_SCAN_PAT }} - if: ${{ env.GH_TOKEN != '' }} - run: | - set +e - bash .standards-checkout/scripts/check-actions-policy.sh \ - "$GITHUB_REPOSITORY" \ - .standards-checkout/rhodium-standard-repositories/actions-allowlist/allowed-actions.json - rc=$? - set -e - if [ "$rc" -eq 3 ]; then - echo "::notice::Live Actions policy was not evaluated: the supplied credential could not read the Administration endpoint." - printf '%s\n' \ - '### Live Actions policy not evaluated' \ - '' \ - 'A credential was supplied, but the Administration API was unavailable.' \ - 'No live-policy pass is claimed; rotate or re-authorise the credential.' \ - >> "$GITHUB_STEP_SUMMARY" - exit 0 - fi - exit "$rc" - - validate-hypatia-baseline: - name: Validate Hypatia Baseline - needs: workflow-staleness - # NOTE: deliberately NO job-level `if:`. This context is REQUIRED by branch - # protection, and a skipped job never satisfies a required context — so a - # repo without a baseline blocked forever, by construction. The job now - # always runs; the EXPENSIVE steps are guarded individually, so it still - # costs nothing when there is no baseline to validate. - runs-on: ${{ inputs.runs-on }} - timeout-minutes: 15 - steps: - - name: Checkout caller repository - if: needs.workflow-staleness.outputs.has_baseline == 'true' - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - fetch-depth: 0 - - - name: Setup Elixir for Hypatia scanner - if: needs.workflow-staleness.outputs.has_baseline == 'true' - uses: erlef/setup-beam@54075bcc5e249e4758d363f27d099f55d843f124 # v1.24.1 - with: - elixir-version: '1.19.4' - otp-version: '28.3' - - - name: Resolve Hypatia scanner commit - if: needs.workflow-staleness.outputs.has_baseline == 'true' - id: hypatia-rev - env: - HYPATIA_REF: ${{ inputs.hypatia-ref }} - run: | - # Resolved before the cache step because cache restore happens before - # the clone, so the key must hash the commit, not a cloned tree. - if [ "$HYPATIA_REF" = "HEAD" ]; then - sha=$(git ls-remote https://github.com/hyperpolymath/hypatia.git HEAD | cut -f1) - else - sha="$HYPATIA_REF" - fi - if [[ ! "$sha" =~ ^[0-9a-f]{40}$ ]]; then - echo "ERROR: hypatia-ref must be a 40-hex commit SHA or HEAD (got '$HYPATIA_REF')" >&2 - exit 1 - fi - echo "sha=$sha" >> "$GITHUB_OUTPUT" - echo "Resolved hypatia scanner commit: $sha" - - - name: Cache Hex/Mix and Scanner Build - if: needs.workflow-staleness.outputs.has_baseline == 'true' - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: | - ~/.mix - ~/.hex - ~/hypatia - # Source-pinned key: when Hypatia main advances, the SHA changes, the - # cache misses, and the clone + escript build below re-run instead of - # restoring a stale binary. The old keyless `...-build` key meant the - # first scanner ever cached was reused forever, so baseline validation - # ran against a stale ruleset. No restore-keys on purpose — a partial - # restore would repopulate ~/hypatia and the guards below would skip - # the rebuild, reintroducing the staleness. - key: hypatia-scanner-v4-${{ runner.os }}-${{ steps.hypatia-rev.outputs.sha }} - - - name: Check out resolved Hypatia commit - if: needs.workflow-staleness.outputs.has_baseline == 'true' - env: - HYPATIA_SHA: ${{ steps.hypatia-rev.outputs.sha }} - run: | - set -euo pipefail - if [ ! -d "$HOME/hypatia" ]; then - git init "$HOME/hypatia" - git -C "$HOME/hypatia" remote add origin https://github.com/hyperpolymath/hypatia.git - git -C "$HOME/hypatia" fetch --depth 1 origin "$HYPATIA_SHA" - git -C "$HOME/hypatia" checkout --detach FETCH_HEAD - fi - ACTUAL_SHA=$(git -C "$HOME/hypatia" rev-parse HEAD) - if [ "$ACTUAL_SHA" != "$HYPATIA_SHA" ]; then - echo "::error::Hypatia cached source does not match the resolved commit" - exit 1 - fi - - - name: Build Hypatia scanner - if: needs.workflow-staleness.outputs.has_baseline == 'true' - run: | - cd "$HOME/hypatia" - if [ ! -x hypatia ]; then - if ! (mix deps.get && mix escript.build); then - echo "::error::Hypatia scanner build failed at commit $(git rev-parse HEAD) — see upstream hyperpolymath/hypatia" - exit 1 - fi - fi - if [ ! -x hypatia ]; then - echo "::error::Hypatia scanner binary is missing after build" - exit 1 - fi - - # A reusable workflow only auto-checks-out its own YAML, not sibling - # scripts. Sparse-check-out standards' scripts/ to get apply-baseline.sh, - # mirroring the language-policy job below. Pinned to job.workflow_sha (this - # reusable's own standards commit), NOT main: a script fetched from main - # changed every pinned caller's verdict on each standards push. (Unlike - # github.workflow_sha, which is the caller's SHA, job.workflow_sha is ours.) - - name: Check out standards for the baseline filter - if: needs.workflow-staleness.outputs.has_baseline == 'true' - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: hyperpolymath/standards - ref: ${{ job.workflow_sha }} - path: .standards-checkout - sparse-checkout: | - scripts - sparse-checkout-cone-mode: false - - - name: Run Hypatia scan (Baseline validation) - if: needs.workflow-staleness.outputs.has_baseline == 'true' - env: - # Preserve the historical strictness: fail on ANY finding not - # acknowledged by .hypatia-baseline.json (not just >= high). Set to - # 'high' to only block on high/critical. - BLOCKING_THRESHOLD: info - run: | - echo "Scanning repository: ${{ github.repository }} (checking baseline)" - # Move the baseline filter OUT of the scanned tree, then delete the - # standards checkout, so `hypatia scan .` only ever sees the CALLER's - # own files. Without this, `.standards-checkout/` (the tooling we - # checked out to get apply-baseline.sh) is itself scanned, and - # standards' own files get reported as the caller's findings (a banned - # `.ts`, `shell_download` bootstrap.sh scripts, etc.). - # Prefer the CALLER's own scripts/apply-baseline.sh when present - # (self-lint: standards validating itself must run the tree under - # test, not main's copy — a new baseline severity the main-pinned - # script doesn't know would fail closed here while passing - # everywhere else). Consumers without the script keep the - # main-pinned fallback. - if [ -f scripts/apply-baseline.sh ]; then - cp scripts/apply-baseline.sh "$RUNNER_TEMP/apply-baseline.sh" - else - cp .standards-checkout/scripts/apply-baseline.sh "$RUNNER_TEMP/apply-baseline.sh" - fi - rm -rf .standards-checkout - # hypatia's `scan` exits non-zero whenever it finds anything — that is - # by design, and under `bash -e` it would abort this step at this line, - # before the baseline filter (the real gate) ever runs. Tolerate the - # scan's own exit code… - HYPATIA_FORMAT=json "$HOME/hypatia/hypatia-cli.sh" scan . > hypatia-findings.raw.json || true - # …but never swallow a genuine scanner crash into a false pass: require a - # valid JSON array before trusting the output as "the findings". - if ! jq -e 'type == "array"' hypatia-findings.raw.json >/dev/null 2>&1; then - echo "::error::hypatia scan did not produce a valid JSON findings array (scanner error, not a baseline result)" - exit 1 - fi - - # Relativize finding paths before matching. Hypatia's honest_completion - # and code_safety modules emit ABSOLUTE host paths in `.file` - # (Path.expand(repo)), while .hypatia-baseline.json uses repo-relative - # paths per the schema + docs/HYPATIA-BASELINE-FORMAT.adoc. Strip the - # workspace prefix so baseline file/file_pattern entries can match, and - # map the repo root itself to "." so root-scoped findings (no_tests) - # match. Upstream fix tracked in hypatia#566; this is the stopgap. - jq --arg root "$PWD" ' - map(if (.file? // "") == $root then .file = "." - elif (.file? // "") | startswith($root + "/") - then .file = (.file | ltrimstr($root + "/")) - else . end) - ' hypatia-findings.raw.json > hypatia-findings.json - - # Filter against the per-repo baseline. apply-baseline.sh KEEPS every - # finding not matched by an active (non-expired) baseline entry — so a - # genuinely new finding still fails the gate (no silent-green) — and - # suppresses only exact matches. Exits non-zero iff a kept finding is at - # or above BLOCKING_THRESHOLD. (Validated against synthetic fixtures; - # see the PR description.) - bash "$RUNNER_TEMP/apply-baseline.sh" \ - hypatia-findings.json .hypatia-baseline.json blocking - - - name: No baseline to validate - if: needs.workflow-staleness.outputs.has_baseline != 'true' - run: | - echo "No .hypatia-baseline.json in this repo — nothing to validate." - echo "The job still reports success so the required context is satisfied." - language-policy: - name: Language / package anti-pattern policy - runs-on: ${{ inputs.runs-on }} - timeout-minutes: 10 - permissions: - contents: read - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: ${{ github.repository }} - # Pin to the concrete event SHA (the PR merge commit on - # pull_request, the pushed commit on push). Do NOT use - # `ref: ${{ github.ref }}`: in a reusable workflow called from a - # pull_request, github.ref is the named merge ref - # `refs/pull//merge`, which actions/checkout cannot fetch - # ("couldn't find remote ref refs/pull//merge") — it broke 8/10 - # governance jobs on every PR estate-wide. github.sha resolves to the - # same merge commit but is always fetchable. - ref: ${{ github.sha }} - - # Estate language policy bans Python with no exceptions (CLAUDE.md - # Language Policy; SaltStack exception removed 2026-01-03). The - # previous in-line `python3 << PYEOF` heredoc made this very gate a - # self-referential violation — same structural class as the CSA001 - # self-loop fixed in hypatia#328. Eradicated by porting the logic to - # a script that lives in this standards repo. - # - # Implementation note: a reusable workflow only auto-checks-out its - # YAML, not sibling files in its repo. So we explicitly check out - # this repo into `.standards-checkout/`, then run the script from - # there. - # - # The checkout is pinned to an immutable standards commit, not `main`. - # `github.workflow_sha` cannot serve: it resolves to the caller repo's - # commit SHA (not standards'), so the fetch fails with exit 128 - # ("No commit found for SHA"). Hence a literal SHA, bumped in the same - # PR as any helper change. scripts/tests/governance-reusable-contract- - # test.sh asserts exactly this with `ref: [0-9a-f]{40}`: following - # moving `main` would let one standards push silently change the - # behaviour of every already-pinned caller, with no review in their - # repos. - - name: Checkout the pinned Standards policy helpers - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: hyperpolymath/standards - ref: 317101e03b8fe642589498f4bdb84541ab466062 - path: .standards-checkout - # Sparse-checkout only the scripts dir to keep this fast. - sparse-checkout: | - scripts - sparse-checkout-cone-mode: false - - - name: Check for TypeScript - # Read-only execution; never writes outside the runner workspace. +{ + name: "Governance Reusable Workflow", + on: { + workflow_call: { + inputs: { + hypatia-ref: { + description: "Hypatia commit the baseline job builds, as a 40-hex SHA, or \"HEAD\" for a canary run. Kept in step with hypatia-scan-reusable.yml's default; a floating HEAD here broke every baseline caller when hypatia#895 changed the emitted severity of `warn` findings.", + type: "string", + default: "51ab6496bf47e30a0576d503df31fec30f3cfe56", + }, + runs-on: { + description: "Runner label for all governance jobs", + type: "string", + required: false, + default: "ubuntu-latest", + }, + }, + secrets: { + HYPATIA_SCAN_PAT: { + description: "Fine-grained token with repository Administration read access", + required: false, + }, + }, + }, + }, + permissions: { + actions: "read", + contents: "read", + }, + jobs: { + workflow-staleness: { + name: "Check Workflow Staleness", + runs-on: "${{ inputs.runs-on }}", + timeout-minutes: 10, + outputs: { + has_baseline: "${{ steps.check.outputs.has_baseline }}", + }, + steps: [ + { + name: "Checkout caller repository", + uses: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", # v7.0.1 + }, + { + name: "Check baseline existence", + id: "check", + run: "if [ -f \".hypatia-baseline.json\" ]; then\n echo \"has_baseline=true\" >> $GITHUB_OUTPUT\nelse\n echo \"has_baseline=false\" >> $GITHUB_OUTPUT\nfi\n", + }, + { + name: "Checkout pinned standards history", + uses: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", # v7.0.1 + with: { + repository: "hyperpolymath/standards", + # The gate needs the full graph for ancestry and age checks, but its + # script must come from the exact reusable commit rather than moving + # main. This also lets a Standards PR test its own validator change. + ref: "${{ job.workflow_sha }}", + path: ".standards-history", + fetch-depth: 0, + filter: "tree:0", + persist-credentials: false, + }, + }, + { + name: "Run staleness check", + env: { + # The gate confirms a pin server-side before ever accusing it of being + # forged, and before accepting a deny-list negative from a clone it + # cannot trust. Unauthenticated that is 60 requests/hour per runner IP; + # exhausting it makes the gate degrade to ::warning (loudly — it never + # silently passes, see check-workflow-staleness.sh). A token removes + # the cliff entirely. Note this only reaches a consumer once it re-pins + # past this commit: the script comes from standards HEAD, but the job + # definition comes from whatever SHA the consumer pins. + GITHUB_TOKEN: "${{ github.token }}", + STALENESS_STANDARDS_DIR: "${{ github.workspace }}/.standards-history", + }, + run: "bash .standards-history/scripts/check-workflow-staleness.sh .\n", + }, + ], + }, + uuid-v7-conformance: { + name: "UUID v7 conformance", + runs-on: "${{ inputs.runs-on }}", + timeout-minutes: 10, + steps: [ + { + name: "Checkout caller repository", + uses: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", # v7.0.1 + with: { + fetch-depth: 1, + persist-credentials: false, + }, + }, + { + name: "Checkout UUID v7 validator", + uses: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", # v7.0.1 + with: { + repository: "hyperpolymath/standards", + ref: "${{ job.workflow_sha }}", + path: ".standards-uuid", + sparse-checkout: "scripts/check-uuid-v7.sh", + sparse-checkout-cone-mode: false, + persist-credentials: false, + }, + }, + { + name: "Reject non-v7 UUID literals", + run: "chmod +x .standards-uuid/scripts/check-uuid-v7.sh\n.standards-uuid/scripts/check-uuid-v7.sh .\n", + }, + ], + }, + allowlist-preflight: { + name: "Allowlist Preflight", + runs-on: "${{ inputs.runs-on }}", + timeout-minutes: 5, + permissions: { + contents: "read", + }, + steps: [ + { + name: "Checkout caller repository", + uses: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", # v7.0.1 + with: { + repository: "${{ github.repository }}", + ref: "${{ github.sha }}", + }, + }, + { + name: "Checkout standards for the allowlist gate", + uses: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", # v7.0.1 + with: { + repository: "hyperpolymath/standards", + # Exact commit of this reusable workflow. This preserves consumer + # pin semantics and lets a standards PR test its own scripts. + ref: "${{ job.workflow_sha }}", + path: ".standards-checkout", + sparse-checkout: "rhodium-standard-repositories/actions-allowlist\nscripts\n", + sparse-checkout-cone-mode: false, + }, + }, + { + name: "Run tree allowlist preflight check", + run: "# Bootstrap rule: standards must test the scripts from its PR tree;\n# consumers use the canonical copies checked out from standards.\nif [ \"$GITHUB_REPOSITORY\" = hyperpolymath/standards ]; then\n cp scripts/check-actions-policy.sh scripts/check-allowed-actions.sh \"$RUNNER_TEMP/\"\n cp rhodium-standard-repositories/actions-allowlist/allowed-actions.json \\\n \"$RUNNER_TEMP/allowed-actions.json\"\nelse\n cp .standards-checkout/scripts/check-actions-policy.sh \\\n .standards-checkout/scripts/check-allowed-actions.sh \"$RUNNER_TEMP/\"\n cp .standards-checkout/rhodium-standard-repositories/actions-allowlist/allowed-actions.json \\\n \"$RUNNER_TEMP/allowed-actions.json\"\nfi\nbash \"$RUNNER_TEMP/check-allowed-actions.sh\" \\\n \"$RUNNER_TEMP/allowed-actions.json\" .github/workflows\nrm -rf .standards-checkout\n", + }, + ], + }, + actions-policy-live: { + name: "Live Actions policy (credentialed advisory)", + runs-on: "${{ inputs.runs-on }}", + timeout-minutes: 5, + permissions: { + contents: "read", + }, + steps: [ + { + name: "Checkout standards for the live policy gate", + uses: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", # v7.0.1 + with: { + repository: "hyperpolymath/standards", + ref: "${{ job.workflow_sha }}", + path: ".standards-checkout", + sparse-checkout: "rhodium-standard-repositories/actions-allowlist\nscripts/check-actions-policy.sh\n", + sparse-checkout-cone-mode: false, + }, + }, + { + name: "Report unavailable live policy credential", + env: { + GH_TOKEN: "${{ secrets.HYPATIA_SCAN_PAT }}", + }, + if: "${{ env.GH_TOKEN == '' }}", + run: "echo \"::notice::Live Actions policy was not evaluated: HYPATIA_SCAN_PAT was not supplied by the caller. The separate tree allowlist gate still ran.\"\n# shellcheck disable=SC2016\nprintf '%s\\n' \\\n '### Live Actions policy not evaluated' \\\n '' \\\n 'The caller did not supply `HYPATIA_SCAN_PAT`. This advisory job' \\\n 'does not claim that the repository Administration setting passed.' \\\n 'The independent tree allowlist preflight remains enforced.' \\\n >> \"$GITHUB_STEP_SUMMARY\"\n", + }, + { + name: "Check live Actions policy", + env: { + # The ordinary job token cannot read the repository Administration + # endpoint. When a caller deliberately supplies this credential, an + # API/authentication failure or real policy violation remains red. + GH_TOKEN: "${{ secrets.HYPATIA_SCAN_PAT }}", + }, + if: "${{ env.GH_TOKEN != '' }}", + run: "set +e\nbash .standards-checkout/scripts/check-actions-policy.sh \\\n \"$GITHUB_REPOSITORY\" \\\n .standards-checkout/rhodium-standard-repositories/actions-allowlist/allowed-actions.json\nrc=$?\nset -e\nif [ \"$rc\" -eq 3 ]; then\n echo \"::notice::Live Actions policy was not evaluated: the supplied credential could not read the Administration endpoint.\"\n printf '%s\\n' \\\n '### Live Actions policy not evaluated' \\\n '' \\\n 'A credential was supplied, but the Administration API was unavailable.' \\\n 'No live-policy pass is claimed; rotate or re-authorise the credential.' \\\n >> \"$GITHUB_STEP_SUMMARY\"\n exit 0\nfi\nexit \"$rc\"\n", + }, + ], + }, + validate-hypatia-baseline: { + name: "Validate Hypatia Baseline", + needs: "workflow-staleness", + # NOTE: deliberately NO job-level `if:`. This context is REQUIRED by branch + # protection, and a skipped job never satisfies a required context — so a + # repo without a baseline blocked forever, by construction. The job now + # always runs; the EXPENSIVE steps are guarded individually, so it still + # costs nothing when there is no baseline to validate. + runs-on: "${{ inputs.runs-on }}", + timeout-minutes: 15, + steps: [ + { + name: "Checkout caller repository", + if: "needs.workflow-staleness.outputs.has_baseline == 'true'", + uses: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", # v7.0.1 + with: { + fetch-depth: 0, + }, + }, + { + name: "Setup Elixir for Hypatia scanner", + if: "needs.workflow-staleness.outputs.has_baseline == 'true'", + uses: "erlef/setup-beam@54075bcc5e249e4758d363f27d099f55d843f124", # v1.24.1 + with: { + elixir-version: "1.19.4", + otp-version: "28.3", + }, + }, + { + name: "Resolve Hypatia scanner commit", + if: "needs.workflow-staleness.outputs.has_baseline == 'true'", + id: "hypatia-rev", + env: { + HYPATIA_REF: "${{ inputs.hypatia-ref }}", + }, + run: "# Resolved before the cache step because cache restore happens before\n# the clone, so the key must hash the commit, not a cloned tree.\nif [ \"$HYPATIA_REF\" = \"HEAD\" ]; then\n sha=$(git ls-remote https://github.com/hyperpolymath/hypatia.git HEAD | cut -f1)\nelse\n sha=\"$HYPATIA_REF\"\nfi\nif [[ ! \"$sha\" =~ ^[0-9a-f]{40}$ ]]; then\n echo \"ERROR: hypatia-ref must be a 40-hex commit SHA or HEAD (got '$HYPATIA_REF')\" >&2\n exit 1\nfi\necho \"sha=$sha\" >> \"$GITHUB_OUTPUT\"\necho \"Resolved hypatia scanner commit: $sha\"\n", + }, + { + name: "Cache Hex/Mix and Scanner Build", + if: "needs.workflow-staleness.outputs.has_baseline == 'true'", + uses: "actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9", # v6.1.0 + with: { + path: "~/.mix\n~/.hex\n~/hypatia\n", + # Source-pinned key: when Hypatia main advances, the SHA changes, the + # cache misses, and the clone + escript build below re-run instead of + # restoring a stale binary. The old keyless `...-build` key meant the + # first scanner ever cached was reused forever, so baseline validation + # ran against a stale ruleset. No restore-keys on purpose — a partial + # restore would repopulate ~/hypatia and the guards below would skip + # the rebuild, reintroducing the staleness. + key: "hypatia-scanner-v4-${{ runner.os }}-${{ steps.hypatia-rev.outputs.sha }}", + }, + }, + { + name: "Check out resolved Hypatia commit", + if: "needs.workflow-staleness.outputs.has_baseline == 'true'", + env: { + HYPATIA_SHA: "${{ steps.hypatia-rev.outputs.sha }}", + }, + run: "set -euo pipefail\nif [ ! -d \"$HOME/hypatia\" ]; then\n git init \"$HOME/hypatia\"\n git -C \"$HOME/hypatia\" remote add origin https://github.com/hyperpolymath/hypatia.git\n git -C \"$HOME/hypatia\" fetch --depth 1 origin \"$HYPATIA_SHA\"\n git -C \"$HOME/hypatia\" checkout --detach FETCH_HEAD\nfi\nACTUAL_SHA=$(git -C \"$HOME/hypatia\" rev-parse HEAD)\nif [ \"$ACTUAL_SHA\" != \"$HYPATIA_SHA\" ]; then\n echo \"::error::Hypatia cached source does not match the resolved commit\"\n exit 1\nfi\n", + }, + { + name: "Build Hypatia scanner", + if: "needs.workflow-staleness.outputs.has_baseline == 'true'", + run: "cd \"$HOME/hypatia\"\nif [ ! -x hypatia ]; then\n if ! (mix deps.get && mix escript.build); then\n echo \"::error::Hypatia scanner build failed at commit $(git rev-parse HEAD) — see upstream hyperpolymath/hypatia\"\n exit 1\n fi\nfi\nif [ ! -x hypatia ]; then\n echo \"::error::Hypatia scanner binary is missing after build\"\n exit 1\nfi\n", + }, + # A reusable workflow only auto-checks-out its own YAML, not sibling + # scripts. Sparse-check-out standards' scripts/ to get apply-baseline.sh, + # mirroring the language-policy job below. Pinned to job.workflow_sha (this + # reusable's own standards commit), NOT main: a script fetched from main + # changed every pinned caller's verdict on each standards push. (Unlike + # github.workflow_sha, which is the caller's SHA, job.workflow_sha is ours.) + { + name: "Check out standards for the baseline filter", + if: "needs.workflow-staleness.outputs.has_baseline == 'true'", + uses: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", # v7.0.1 + with: { + repository: "hyperpolymath/standards", + ref: "${{ job.workflow_sha }}", + path: ".standards-checkout", + sparse-checkout: "scripts\n", + sparse-checkout-cone-mode: false, + }, + }, + { + name: "Run Hypatia scan (Baseline validation)", + if: "needs.workflow-staleness.outputs.has_baseline == 'true'", + env: { + # Preserve the historical strictness: fail on ANY finding not + # acknowledged by .hypatia-baseline.json (not just >= high). Set to + # 'high' to only block on high/critical. + BLOCKING_THRESHOLD: "info", + }, + run: "echo \"Scanning repository: ${{ github.repository }} (checking baseline)\"\n# Move the baseline filter OUT of the scanned tree, then delete the\n# standards checkout, so `hypatia scan .` only ever sees the CALLER's\n# own files. Without this, `.standards-checkout/` (the tooling we\n# checked out to get apply-baseline.sh) is itself scanned, and\n# standards' own files get reported as the caller's findings (a banned\n# `.ts`, `shell_download` bootstrap.sh scripts, etc.).\n# Prefer the CALLER's own scripts/apply-baseline.sh when present\n# (self-lint: standards validating itself must run the tree under\n# test, not main's copy — a new baseline severity the main-pinned\n# script doesn't know would fail closed here while passing\n# everywhere else). Consumers without the script keep the\n# main-pinned fallback.\nif [ -f scripts/apply-baseline.sh ]; then\n cp scripts/apply-baseline.sh \"$RUNNER_TEMP/apply-baseline.sh\"\nelse\n cp .standards-checkout/scripts/apply-baseline.sh \"$RUNNER_TEMP/apply-baseline.sh\"\nfi\nrm -rf .standards-checkout\n# hypatia's `scan` exits non-zero whenever it finds anything — that is\n# by design, and under `bash -e` it would abort this step at this line,\n# before the baseline filter (the real gate) ever runs. Tolerate the\n# scan's own exit code…\nHYPATIA_FORMAT=json \"$HOME/hypatia/hypatia-cli.sh\" scan . > hypatia-findings.raw.json || true\n# …but never swallow a genuine scanner crash into a false pass: require a\n# valid JSON array before trusting the output as \"the findings\".\nif ! jq -e 'type == \"array\"' hypatia-findings.raw.json >/dev/null 2>&1; then\n echo \"::error::hypatia scan did not produce a valid JSON findings array (scanner error, not a baseline result)\"\n exit 1\nfi\n\n# Relativize finding paths before matching. Hypatia's honest_completion\n# and code_safety modules emit ABSOLUTE host paths in `.file`\n# (Path.expand(repo)), while .hypatia-baseline.json uses repo-relative\n# paths per the schema + docs/HYPATIA-BASELINE-FORMAT.adoc. Strip the\n# workspace prefix so baseline file/file_pattern entries can match, and\n# map the repo root itself to \".\" so root-scoped findings (no_tests)\n# match. Upstream fix tracked in hypatia#566; this is the stopgap.\njq --arg root \"$PWD\" '\n map(if (.file? // \"\") == $root then .file = \".\"\n elif (.file? // \"\") | startswith($root + \"/\")\n then .file = (.file | ltrimstr($root + \"/\"))\n else . end)\n' hypatia-findings.raw.json > hypatia-findings.json\n\n# Filter against the per-repo baseline. apply-baseline.sh KEEPS every\n# finding not matched by an active (non-expired) baseline entry — so a\n# genuinely new finding still fails the gate (no silent-green) — and\n# suppresses only exact matches. Exits non-zero iff a kept finding is at\n# or above BLOCKING_THRESHOLD. (Validated against synthetic fixtures;\n# see the PR description.)\nbash \"$RUNNER_TEMP/apply-baseline.sh\" \\\n hypatia-findings.json .hypatia-baseline.json blocking\n", + }, + { + name: "No baseline to validate", + if: "needs.workflow-staleness.outputs.has_baseline != 'true'", + run: "echo \"No .hypatia-baseline.json in this repo — nothing to validate.\"\necho \"The job still reports success so the required context is satisfied.\"\n", + }, + ], + }, + language-policy: { + name: "Language / package anti-pattern policy", + runs-on: "${{ inputs.runs-on }}", + timeout-minutes: 10, + permissions: { + contents: "read", + }, + steps: [ + { + uses: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", # v7.0.1 + with: { + repository: "${{ github.repository }}", + # Pin to the concrete event SHA (the PR merge commit on + # pull_request, the pushed commit on push). Do NOT use + # `ref: ${{ github.ref }}`: in a reusable workflow called from a + # pull_request, github.ref is the named merge ref + # `refs/pull//merge`, which actions/checkout cannot fetch + # ("couldn't find remote ref refs/pull//merge") — it broke 8/10 + # governance jobs on every PR estate-wide. github.sha resolves to the + # same merge commit but is always fetchable. + ref: "${{ github.sha }}", + }, + }, + # Estate language policy bans Python with no exceptions (CLAUDE.md + # Language Policy; SaltStack exception removed 2026-01-03). The + # previous in-line `python3 << PYEOF` heredoc made this very gate a + # self-referential violation — same structural class as the CSA001 + # self-loop fixed in hypatia#328. Eradicated by porting the logic to + # a script that lives in this standards repo. # - # Runs `scripts/check-ts-allowlist.sh`, replacing the - # AffineScript-compiled `check-ts-allowlist.deno.js`. That artefact - # existed only to be fed to `deno run`, which forced a - # `denoland/setup-deno` install onto a REQUIRED context in every - # estate repo. The shell implementation is behaviourally equivalent - # (scripts/tests/check-ts-allowlist-test.sh, 18/18) and needs no - # runtime installed at all. Estate policy retires Deno in favour of - # bun — see LANGUAGE-POLICY.adoc §1. + # Implementation note: a reusable workflow only auto-checks-out its + # YAML, not sibling files in its repo. So we explicitly check out + # this repo into `.standards-checkout/`, then run the script from + # there. # - # The source/compile drift check that used to sit here was removed - # with the .deno.js it compared against: `affinescript compile` is - # unavailable on the runner, so the step could only ever emit - # `::warning::`, which cannot fail a job — a vacuous gate. - run: bash .standards-checkout/scripts/check-ts-allowlist.sh - - # Shared escape hatch for the banned-language-file checks below. - # Honours three exemption mechanisms (see - # standards/docs/EXEMPTION-MECHANISMS.adoc): - # 1. `.hypatia-baseline.json` — array of acknowledged findings, - # shape mirrors the Hypatia findings themselves. Schema is - # `.machine_readable/hypatia-baseline.schema.json` in this - # repo, enforced structurally by apply-baseline.sh itself - # (jq mirror; a malformed baseline exits 2 instead of - # silently matching nothing). Added 2026-05-25 as part of - # the convergence on a single exemption mechanism. - # 2. `.hypatia-ignore` flat-file — legacy single-rule-per-line - # format (`cicd_rules/banned_language_file:`). - # Will be retired in a follow-up PR once .hypatia-baseline.json - # is in active use across the estate. - # 3. Inline `# hypatia:ignore ...` pragma in the file's first - # 8 lines — the same escape the Hypatia scanner itself - # honours. - - name: Check banned-language files (ReScript / Go / Python / Java / Kotlin / Groovy / Swift / Dart / V-lang / Makefile) - run: | - rule_module="cicd_rules" - rule_type="banned_language_file" - rule="${rule_module}/${rule_type}" - - # Baseline lookup: returns 0 (exempt) if the file appears in - # .hypatia-baseline.json with a matching rule_module + type. - # Honours both `file` (exact) and `file_pattern` (glob) entries. - # The glob → regex translation mirrors apply-baseline.sh exactly: - # `**` matches any depth (incl. `/`); `*` matches one segment. - # Pattern support unblocks language-demo repos (absolute-zero - # carries ~30 banned-language example files under `examples/`) - # and any repo that vendors such subtrees, replacing per-file - # `.hypatia-ignore` enumeration with one `file_pattern` entry. - in_baseline() { - local target="$1" - [ -f .hypatia-baseline.json ] || return 1 - command -v jq >/dev/null 2>&1 || return 1 - # Note: the `as $pat` capture is essential — inside `test(...)` - # the dot rebinds to test's input ($f, a string), so - # `.file_pattern` would error with "Cannot index string". We - # capture file_pattern in $pat first, then reference it inside - # the test() argument. - jq -e \ - --arg rm "$rule_module" \ - --arg rt "$rule_type" \ - --arg f "$target" \ - 'any(.[]; - .rule_module == $rm and .type == $rt - and ( - (.file? // null) == $f - or ( - (.file_pattern? // null) as $pat - | $pat != null - and ($f | test( - $pat - | gsub("\\*\\*"; "DOUBLESTAR") - | gsub("\\*"; "[^/]*") - | gsub("DOUBLESTAR"; ".*") - | "^" + . + "$" - )) - ) - ))' \ - .hypatia-baseline.json >/dev/null 2>&1 - } - - is_exempt() { - f="${1#./}" - if in_baseline "$f"; then - echo "⏭️ exempt (baseline): $f" - return 0 - fi - if [ -f .hypatia-ignore ] && grep -qxF "${rule}:${f}" .hypatia-ignore; then - return 0 - fi - if head -n 8 "$1" 2>/dev/null | grep -q "hypatia:ignore.*${rule}"; then - return 0 - fi - return 1 - } - - # $1 = human label, $2 = remediation hint, $3 = newline-separated - # candidate paths (possibly empty). Reads via here-string so this - # runs in the current shell — no subshell, exit propagates. - enforce() { - label="$1"; hint="$2"; candidates="$3"; violations="" - while IFS= read -r f; do - [ -z "$f" ] && continue - if is_exempt "$f"; then - echo "⏭️ exempt (${rule}): $f" - continue - fi - violations="${violations}${f} - " - done <<< "$candidates" - if [ -n "$(printf '%s' "$violations" | tr -d '[:space:]')" ]; then - echo "❌ ${label} detected - ${hint}" - printf '%s' "$violations" - echo " (declare an exemption via .hypatia-ignore or an inline" - echo " '# hypatia:ignore ${rule}' pragma if intentional)" - exit 1 - fi - echo "✅ No non-exempt ${label}" - } - - # Use `git ls-files` instead of `find` so only TRACKED files are - # inspected. This respects .gitignore by definition — vendored - # deps in deps/, node_modules/, _build/ etc. are never tracked - # and cannot produce false-positives. Root cause for the switch: - # developer-ecosystem@baab1534 had false-positive governance - # failures because `find` crawled gitignored vendor directories. - # `git ls-files` works correctly on fresh PR checkouts because - # actions/checkout populates the index before running workflows. - RES_FILES=$(git ls-files '*.res') - GO_FILES=$(git ls-files '*.go') - PY_FILES=$(git ls-files '*.py' \ - | grep -v venv | grep -v __pycache__ || [ $? -eq 1 ]) - MAKE_FILES=$(git ls-files 'Makefile' 'Makefile.*' '*.mk' \ - | grep -v '\.github/' || [ $? -eq 1 ]) - # Platform-required JVM shims carve-out 2026-06-02: - # Java is permitted only in Android source trees - # (android/**/src/**/*.java) because Android instantiates - # Service/BroadcastReceiver/AppWidgetProvider/Activity classes by - # name at platform boundaries — Rust/Zig cannot provide JVM - # bytecode for these. Each Android Java shim must be a minimal - # delegating wrapper (typically <10 LoC) that JNIs into Rust/Zig - # immediately. + # The checkout is pinned to an immutable standards commit, not `main`. + # `github.workflow_sha` cannot serve: it resolves to the caller repo's + # commit SHA (not standards'), so the fetch fails with exit 128 + # ("No commit found for SHA"). Hence a literal SHA, bumped in the same + # PR as any helper change. scripts/tests/governance-reusable-contract- + # test.sh asserts exactly this with `ref: [0-9a-f]{40}`: following + # moving `main` would let one standards push silently change the + # behaviour of every already-pinned caller, with no review in their + # repos. + { + name: "Checkout the pinned Standards policy helpers", + uses: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", # v7.0.1 + with: { + repository: "hyperpolymath/standards", + ref: "317101e03b8fe642589498f4bdb84541ab466062", + path: ".standards-checkout", + # Sparse-checkout only the scripts dir to keep this fast. + sparse-checkout: "scripts\n", + sparse-checkout-cone-mode: false, + }, + }, + { + name: "Check for TypeScript", + # Read-only execution; never writes outside the runner workspace. # - # Kotlin/Groovy: NO JVM-interop exemption (reverted 2026-06-24). - # Kotlin is goodbye. There is NO carve-out for hand-written JVM - # bindings: FFI/interop to other ecosystems MUST be authored as - # Idris2-ABI → Zig FFI/API (Idris2 → C only as an absolute last - # resort if Zig genuinely cannot reach the boundary), never as - # Kotlin/Java/Groovy `bindings/*`. Mobile uses Tauri/Dioxus. The - # only remaining Java carve-out is the minimal Android platform - # shim above. `.groovy` is detected so it is banned everywhere. - JAVA_FILES=$(git ls-files '*.java' '*.kt' '*.kts' '*.groovy' \ - | grep -vE '(^|/)android/.*/src/.*\.java$' || [ $? -eq 1 ]) - SWIFT_FILES=$(git ls-files '*.swift') - DART_FILES=$(git ls-files '*.dart' 'pubspec.yaml') - # V-lang detected by manifest (v.mod / vpkg.json); the .v extension - # collides with Verilog so we never key on it. - VMOD_FILES=$(git ls-files 'v.mod' 'vpkg.json') - - enforce "ReScript files" "use AffineScript instead" "$RES_FILES" - enforce "Go files" "use Rust/WASM instead" "$GO_FILES" - enforce "Python files" "Python is fully banned — use AffineScript/Rust/SPARK/Julia (SaltStack carveout removed 2026-01-03)" "$PY_FILES" - enforce "Makefiles" "use Mustfile/justfile instead" "$MAKE_FILES" - enforce "Java/Kotlin/Groovy files" "Kotlin is goodbye — apps use Tauri/Dioxus; FFI/interop must be Idris2-ABI -> Zig FFI (or Idris2 -> C as a last resort), NOT hand-written JVM bindings" "$JAVA_FILES" - enforce "Swift files" "use Tauri/Dioxus instead" "$SWIFT_FILES" - enforce "Flutter/Dart files" "use Tauri/Dioxus instead (Google lock-in)" "$DART_FILES" - enforce "V-lang manifests (v.mod / vpkg.json)" "V-lang is banned since 2026-04-10 — migrate to Zig" "$VMOD_FILES" - - - name: Check for npm/yarn artifacts - # standards#67 — npm-avoidant: package-lock.json must never be tracked - # estate-wide. Check recursively (not just root) to catch monorepo - # sub-packages. See 3-practice/LANGUAGE-POLICY.adoc §1 and docs/JS-RUNTIME-POLICY.adoc. - # - # 2026-08-07: this step previously failed any repository carrying - # bun.lockb ("Use Deno instead") and any package.json declaring runtime - # dependencies. 3-practice/LANGUAGE-POLICY.adoc §1 has made Bun the tier-1 runtime - # since 2026-07-29, and Bun's model IS an npm-compatible package.json - # plus bun.lock — so this gate made adopting the estate's first-choice - # runtime impossible, not merely awkward. It blocked what the policy - # mandates. Bun artifacts are now accepted; npm and yarn are unchanged. - run: | - LOCK_FILES=$(git ls-files 'package-lock.json' '**/package-lock.json' 2>/dev/null) - YARN_FILES=$(find . -name "yarn.lock" -not -path "./.git/*" 2>/dev/null) - NPMRC_FILES=$(find . -name ".npmrc" -not -path "./.git/*" 2>/dev/null) - BUN_LOCK=$(find . \( -name "bun.lock" -o -name "bun.lockb" \) -not -path "./.git/*" 2>/dev/null) - FAILED="" - if [ -n "$LOCK_FILES" ]; then - echo "❌ Tracked package-lock.json detected (standards#67 — npm-avoidant)." - echo " npm is tier 4: permitted, never preferred. Prefer Bun (tier 1)." - printf '%s\n' "$LOCK_FILES" - FAILED=1 - fi - if [ -n "$YARN_FILES" ]; then - echo "❌ yarn.lock detected. yarn is not in the estate tier list." - echo " Use Bun (tier 1), or Deno / pnpm / npm in that order." - printf '%s\n' "$YARN_FILES" - FAILED=1 - fi - if [ -n "$NPMRC_FILES" ]; then - echo "❌ .npmrc detected. Configure the runtime you actually use —" - echo " bunfig.toml for Bun, deno.json for Deno." - printf '%s\n' "$NPMRC_FILES" - FAILED=1 - fi - if [ -n "$BUN_LOCK" ]; then - echo "✅ Bun lockfile present — tier 1 (3-practice/LANGUAGE-POLICY.adoc §1)." - fi - # Root package.json with runtime "dependencies". Bun consumes - # package.json by design, so this is only an anti-pattern when the - # repository is NOT using Bun; otherwise it is the tier-1 runtime - # working exactly as intended. - if [ -f package.json ] && grep -q '"dependencies"[[:space:]]*:[[:space:]]*{[^}]' package.json; then - if [ -n "$BUN_LOCK" ]; then - echo "✅ package.json runtime dependencies accompanied by a Bun lockfile — tier 1, fine." - else - echo "❌ package.json with runtime \"dependencies\" and no Bun lockfile." - echo " Adopt Bun (tier 1) or declare imports in deno.json (tier 2)." - FAILED=1 - fi - fi - if [ -n "$FAILED" ]; then - echo "See hyperpolymath/standards docs/JS-RUNTIME-POLICY.adoc for remediation." - exit 1 - fi - echo "✅ No npm/bun violations" - - - name: Check for tsconfig / rescript config - # Honours the same `.hypatia-ignore` exemption mechanism as the - # banned-language-files step (standards#72, Explicit-Escape - # Principle). A config file is exempt if either - # * `.hypatia-ignore` contains the exact line - # `cicd_rules/banned_config_file:`, OR - # * the file carries an inline `# hypatia:ignore … banned_config_file` - # pragma in its first 8 lines (works for JSONC-style configs; - # not for strict JSON variants). - # Required so repos that legitimately retain ReScript while in - # mid-migration (per their `.claude/CLAUDE.md`, e.g. verisimdb) - # can declare the exemption explicitly instead of being globally - # blocked. Same pattern as the .res file step above. - run: | - rule="cicd_rules/banned_config_file" - - is_exempt() { - f="$1" - if [ -f .hypatia-ignore ] && grep -qxF "${rule}:${f}" .hypatia-ignore; then - return 0 - fi - if head -n 8 "$f" 2>/dev/null | grep -q "hypatia:ignore.*${rule}"; then - return 0 - fi - return 1 - } - - check_config() { - cfg="$1"; hint="$2" - if [ -f "$cfg" ]; then - if is_exempt "$cfg"; then - echo "⏭️ exempt (${rule}): $cfg" - return 0 - fi - echo "❌ ${cfg} detected - ${hint}" - echo " (declare an exemption via .hypatia-ignore line" - echo " '${rule}:${cfg}' if intentional)" - exit 1 - fi - } - - check_config "tsconfig.json" "use AffineScript instead" - check_config "rescript.json" "use AffineScript config instead" - check_config "bsconfig.json" "use AffineScript config instead" - echo "✅ No non-exempt tsconfig.json / rescript config" - - - name: Summary - run: | - echo "RSR language/package policy passed — allowed: AffineScript, Deno," - echo "WASM, Rust, OCaml, Haskell, Guile/Scheme." - - package-policy: - name: Guix packaging policy (Nix retired) - runs-on: ${{ inputs.runs-on }} - timeout-minutes: 10 - permissions: - contents: read - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: ${{ github.repository }} - # Pin to the concrete event SHA (the PR merge commit on - # pull_request, the pushed commit on push). Do NOT use - # `ref: ${{ github.ref }}`: in a reusable workflow called from a - # pull_request, github.ref is the named merge ref - # `refs/pull//merge`, which actions/checkout cannot fetch - # ("couldn't find remote ref refs/pull//merge") — it broke 8/10 - # governance jobs on every PR estate-wide. github.sha resolves to the - # same merge commit but is always fetchable. - ref: ${{ github.sha }} - - # A reusable workflow only auto-checks-out its own YAML, not sibling - # scripts. Sparse-check-out standards' scripts/ to get - # check-package-policy.sh, mirroring the baseline-filter job above. - - name: Check out standards for the package-policy gate - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: hyperpolymath/standards - ref: ${{ job.workflow_sha }} - path: .standards-checkout - sparse-checkout: | - scripts - .machine_readable/template-capability-gates.toml - sparse-checkout-cone-mode: false - - - name: Enforce Guix-only packaging policy - run: | - # Move the checker OUT of the scanned tree and delete the standards - # checkout before scanning: the gate walks the whole caller tree, so - # a packaging file shipped inside .standards-checkout/ would satisfy - # the policy on the caller's behalf (same trap as the baseline job). - # The capability resolver (check-rsr-profile.sh) and its gate table travel - # with the checker, in the same relative layout, so applicability is read - # from the same standards commit as the policy itself. - pkg="$RUNNER_TEMP/pkg-policy" - mkdir -p "$pkg/scripts" "$pkg/.machine_readable" - cp .standards-checkout/scripts/check-package-policy.sh .standards-checkout/scripts/check-rsr-profile.sh "$pkg/scripts/" - cp .standards-checkout/.machine_readable/template-capability-gates.toml "$pkg/.machine_readable/" - rm -rf .standards-checkout - bash "$pkg/scripts/check-package-policy.sh" . - - security-policy: - name: Security policy checks - runs-on: ${{ inputs.runs-on }} - timeout-minutes: 10 - permissions: - contents: read - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: ${{ github.repository }} - # Pin to the concrete event SHA (the PR merge commit on - # pull_request, the pushed commit on push). Do NOT use - # `ref: ${{ github.ref }}`: in a reusable workflow called from a - # pull_request, github.ref is the named merge ref - # `refs/pull//merge`, which actions/checkout cannot fetch - # ("couldn't find remote ref refs/pull//merge") — it broke 8/10 - # governance jobs on every PR estate-wide. github.sha resolves to the - # same merge commit but is always fetchable. - ref: ${{ github.sha }} - - name: Security checks - run: | - FAILED=false - WEAK_CRYPTO=$(grep -rE 'md5\(|sha1\(' --include="*.py" --include="*.rb" --include="*.js" --include="*.ts" --include="*.go" --include="*.rs" . 2>/dev/null | grep -v 'checksum\|cache\|test\|spec' | head -5) - if [ -n "$WEAK_CRYPTO" ]; then - echo "⚠️ Weak crypto (MD5/SHA1) detected. Use SHA256+ for security:" - echo "$WEAK_CRYPTO" - fi - HTTP_URLS=$(grep -rE 'http://[^l][^o][^c]' --include="*.py" --include="*.js" --include="*.ts" --include="*.go" --include="*.rs" --include="*.yaml" --include="*.yml" . 2>/dev/null | grep -v 'localhost\|127.0.0.1\|example\|test\|spec' | head -5) - if [ -n "$HTTP_URLS" ]; then - echo "⚠️ HTTP URLs found. Use HTTPS:" - echo "$HTTP_URLS" - fi - SECRETS=$(grep -rEi '(api_key|apikey|secret_key|password)\s*[=:]\s*["\x27][A-Za-z0-9+/=]{20,}' --include="*.py" --include="*.js" --include="*.ts" --include="*.go" --include="*.rs" --include="*.env" . 2>/dev/null | grep -v 'example\|sample\|test\|mock\|placeholder' | head -3) - if [ -n "$SECRETS" ]; then - echo "❌ Potential hardcoded secrets detected!" - FAILED=true - fi - if [ "$FAILED" = true ]; then - exit 1 - fi - echo "✅ Security policy check passed" - - name: SSH-remote policy (token-in-URL detection) - # Estate Remote-URL policy (standards#69 / 3-practice/REMOTE-URL-POLICY.adoc). - # Scans every .git/config present in the checkout tree for token-in-URL - # remotes. Fails hard so a compromised credential cannot silently reach - # a PR or main-branch push. - run: | - found=0 - while IFS= read -r cfg; do - if grep -qE "url[[:space:]]*=[[:space:]]*https://[^[:space:]]*(x-access-token:|:gho_|:ghp_|:ghs_|:github_pat_)" "$cfg" 2>/dev/null; then - echo "❌ token-in-URL remote detected in: $cfg" - grep -E "url[[:space:]]*=" "$cfg" \ - | sed 's/\(x-access-token:\|gho_\|ghp_\|ghs_\|github_pat_\)[^@]*/\1****/g' - found=$((found + 1)) - fi - done < <(find . -name "config" -path "*/.git/config" 2>/dev/null) - if [ "$found" -gt 0 ]; then - echo "" - echo "Remediation: git remote set-url git@github.com:/.git" - echo "Policy: 3-practice/REMOTE-URL-POLICY.adoc — SSH-only remotes; no PAT/token in URL ever." - exit 1 - fi - echo "✅ SSH-remote policy: no token-in-URL remotes detected" - - - name: Tooling version integrity - # Estate Tooling Version Integrity policy (root cause: burble#39). - # Inline + dependency-free so it runs in any caller repo. - # R0 just>=1.19.0 floor (blocking when just present) and R1 - # unversioned family-tool install (blocking) are hard; R4 - # unexplained continue-on-error is advisory-first per the - # documented "advisory now, --strict later" gating doctrine. - run: | - set -uo pipefail - FAMILY='just|must|trust|adjust|bust|dust|intend' - if command -v just >/dev/null 2>&1; then - jv=$(just --version 2>/dev/null | cut -d' ' -f2) - maj=${jv%%.*}; rest=${jv#*.}; min=${rest%%.*} - if [ -z "$jv" ] || ! { [ "${maj:-0}" -gt 1 ] || { [ "${maj:-0}" -eq 1 ] && [ "${min:-0}" -ge 19 ]; }; }; then - echo "❌ [R0] just ${jv:-?} < 1.19.0 — import? unsupported"; exit 1 - fi - echo "✅ [R0] just $jv >= 1.19.0" - else - echo "ℹ️ [R0] just not on PATH — skipped" - fi - R1=0 - if [ -d .github/workflows ]; then - while IFS= read -r hit; do - [ -n "$hit" ] || continue - echo "❌ [R1] unversioned family-tool install: $hit" - R1=$((R1+1)) - done < <(grep -rnE "^[[:space:]]*tool:[[:space:]]*(${FAMILY})[[:space:]]*$" .github/workflows 2>/dev/null || true) - fi - [ "$R1" -gt 0 ] && { echo "❌ [R1] $R1 unversioned family-tool install(s) — pin tool: @"; exit 1; } - echo "✅ Tooling version integrity passed (R1 clean; R4 advisory via standards/tasks/tooling-integrity-lint.sh)" - - - name: Documentation version-string drift (R5b) - # Estate canonical-reference drift policy. Forbids pinned - # `Version: x.y.z` strings in load-bearing top-level docs - # because they reliably go stale and disagree with each other - # (echidna had v1.5.0 in README.adoc, v2.3.0 in CLAUDE.md, - # v2.1.0 in Cargo.toml simultaneously — cleared by echidna#169 - # / #170 / #171). CHANGELOG.md is the canonical release-history - # surface; Cargo.toml's [package].version is the canonical - # semver pin; the git log carries dates. Duplicating either - # in human prose invites drift. - # - # Scope: repo-root *.md + *.adoc only. Sub-tree historical - # docs (docs/handover/, docs/decisions/, docs/releases/, - # audit reports) are owner-managed snapshots and not in - # scope. CHANGELOG.{md,adoc} explicitly skipped. - # - # Companion rule R5a (bare prover counts) is repo-local where - # the count semantics are domain-specific — see - # `echidna/.github/workflows/governance-doc-drift.yml`. - run: | - set -uo pipefail - PATTERN='^[[:space:]]*[*_]{0,2}Version[*_]{0,2}[[:space:]]*[:=][[:space:]]*v?[0-9]+\.[0-9]+\.[0-9]+' - R5B=0 - shopt -s nullglob - for doc in *.md *.adoc; do - [ -f "$doc" ] || continue - case "$doc" in CHANGELOG.md|CHANGELOG.adoc) continue ;; esac - while IFS= read -r hit; do - [ -n "$hit" ] || continue - echo "❌ [R5b] pinned version string: $doc:$hit" - R5B=$((R5B+1)) - done < <(grep -nE "$PATTERN" "$doc" 2>/dev/null || true) - done - if [ "$R5B" -gt 0 ]; then - echo "" - echo "❌ [R5b] $R5B pinned version-string line(s) in load-bearing docs." - echo "Fix: drop the embedded version; defer to CHANGELOG.md (release" - echo "history) and Cargo.toml's [package].version (semver pin) or the" - echo "equivalent package manifest. Git log carries dates." - exit 1 - fi - echo "✅ [R5b] Documentation version-string drift: clean." - - - name: Canonical-reference drift (R5 generic) - # Repo-local canonical-reference rules. Reads - # `.github/canonical-references/*.{yml,yaml}` in the calling - # repo; each file declares one drift pattern with its - # canonical pointer. Skipped silently when the directory is - # absent — repos opt in by creating it. - # - # Generalises echidna's R5a (bare prover counts → docs/PROVER_COUNT.md) - # so sibling repos with their own canonical-reference cohorts - # (lemma counts for proven, stdlib fn counts for affinescript, - # proof-obligation counts for typed-wasm, rule counts for - # ephapax, …) can declare their drift patterns without - # carrying a per-repo workflow. - # - # Rule file shape: - # id: short-slug - # description: one-line summary - # patterns: [POSIX-ERE strings] - # canonical_pointer: path/to/single-source-of-truth.md - # scope: - # include: [list, of, files, to, scan] - # - # Patterns are POSIX-ERE (run through `grep -E`). Each - # listed include path is scanned line-by-line; a match emits - # a `::error::` annotation and the job fails non-zero. - # CHANGELOG.{md,adoc} and the rule files themselves are - # excluded automatically; the rule's own `canonical_pointer` - # is also excluded so the canonical doc can name what it - # is the canonical answer for. - run: | - set -uo pipefail - DIR=.github/canonical-references - if [ ! -d "$DIR" ]; then - echo "ℹ️ [R5] no $DIR/ — skipped (repo has not opted in)" - exit 0 - fi - # Rule files are read with yq, never a hand parser (YAML-POLICY Y-1); this - # gate previously parsed them with Python, which the estate bans. - if ! command -v yq >/dev/null 2>&1 || ! command -v jq >/dev/null 2>&1; then - echo "❌ [R5] yq and jq are required on the runner for YAML rule parsing" - exit 2 - fi - mapfile -t files < <(find "$DIR" -maxdepth 1 -type f \( -name '*.yml' -o -name '*.yaml' \) ! -name '.*' | LC_ALL=C sort) - if [ "${#files[@]}" -eq 0 ]; then - echo "ℹ️ [R5] $DIR/ has no .yml/.yaml rules — skipped" - exit 0 - fi - - err=$(mktemp) - trap 'rm -f "$err"' EXIT - total=0 - for rf in "${files[@]}"; do - if ! cfg=$(yq -o json '.' "$rf" 2>&1); then - echo "❌ [R5] $rf: not parseable YAML: $cfg"; total=$((total + 1)); continue - fi - if [ "$(jq -r 'type' <<<"$cfg")" != object ]; then - echo "❌ [R5] $rf: top-level must be a mapping"; total=$((total + 1)); continue - fi - rid=$(jq -r --arg b "$(basename "$rf")" 'if has("id") then .id | tostring else $b end' <<<"$cfg") - desc=$(jq -r '.description // ""' <<<"$cfg") - canon=$(jq -r '.canonical_pointer // ""' <<<"$cfg") - mapfile -t pats < <(jq -r '(.patterns // [])[]' <<<"$cfg") - mapfile -t includes < <(jq -r '((.scope // {}).include // [])[]' <<<"$cfg") - if [ "${#pats[@]}" -eq 0 ] || [ "${#includes[@]}" -eq 0 ]; then - echo "❌ [R5:$rid] missing patterns or scope.include in $rf" - total=$((total + 1)); continue - fi - rule_hits=0 - for f_ in "${includes[@]}"; do - # Self-references are excluded: the changelogs, the rule file, and the - # rule's own canonical pointer. - case "$f_" in CHANGELOG.md|CHANGELOG.adoc|"$rf") continue ;; esac - if [ -n "$canon" ] && [ "$f_" = "$canon" ]; then continue; fi - [ -f "$f_" ] || continue - for pat in "${pats[@]}"; do - out=$(grep -nE -e "$pat" -- "$f_" 2>"$err"); rc=$? - if [ "$rc" -eq 0 ]; then - while IFS= read -r line; do - echo "::error file=$f_,line=${line%%:*}::[R5:$rid] ${line#*:} → route to ${canon:-drop or move to a canonical source}" - rule_hits=$((rule_hits + 1)) - done <<<"$out" - elif [ "$rc" -gt 1 ]; then - echo "❌ [R5:$rid] grep error on $f_: $(<"$err")" - rule_hits=$((rule_hits + 1)) - fi - done - done - if [ "$rule_hits" -gt 0 ]; then - echo "❌ [R5:$rid] $rule_hits hit(s). $desc" - fi - total=$((total + rule_hits)) - done - - if [ "$total" -gt 0 ]; then - echo - echo "❌ [R5] $total canonical-reference drift hit(s) across ${#files[@]} rule(s)." - exit 1 - fi - echo "✅ [R5] canonical-reference drift: clean across ${#files[@]} rule(s)." - - quality: - name: Code quality + docs - runs-on: ${{ inputs.runs-on }} - timeout-minutes: 10 - permissions: - contents: read - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: ${{ github.repository }} - # Pin to the concrete event SHA (the PR merge commit on - # pull_request, the pushed commit on push). Do NOT use - # `ref: ${{ github.ref }}`: in a reusable workflow called from a - # pull_request, github.ref is the named merge ref - # `refs/pull//merge`, which actions/checkout cannot fetch - # ("couldn't find remote ref refs/pull//merge") — it broke 8/10 - # governance jobs on every PR estate-wide. github.sha resolves to the - # same merge commit but is always fetchable. - ref: ${{ github.sha }} - - name: Check file permissions - # advisory: informational only; repos can opt into blocking locally. - # No continue-on-error needed: the step below always exits 0 itself; - # it now surfaces its finding as an explicit ::warning:: instead of a - # silent printout. - run: | - FOUND=$(find . -type f -perm /111 -name "*.sh" | head -20) - if [ -n "$FOUND" ]; then - echo "::warning::Executable .sh files found (informational only):" - echo "$FOUND" - else - echo "No executable .sh files found." - fi - - name: Check TODO/FIXME - # advisory: informational only; repos can opt into blocking locally. - run: | - FOUND=$(grep -rn "TODO\|FIXME\|HACK\|XXX" --include="*.rs" --include="*.res" --include="*.py" --include="*.ex" . | head -20) - if [ -n "$FOUND" ]; then - echo "::warning::TODO/FIXME/HACK/XXX markers found (informational only):" - echo "$FOUND" - else - echo "No TODO/FIXME/HACK/XXX markers found." - fi - - name: Check for large files - # advisory: informational only; repos can opt into blocking locally. - run: | - FOUND=$(find . -type f -size +1M -not -path "./.git/*" | head -20) - if [ -n "$FOUND" ]; then - echo "::warning::Files larger than 1M found (informational only):" - echo "$FOUND" - else - echo "No files larger than 1M found." - fi - - name: EditorConfig check - id: editorconfig_check - uses: editorconfig-checker/action-editorconfig-checker@51f63319f592f97930c73d9c46184d20bd206393 # v3.0.0 - # advisory-by-design, fails OPEN not closed: formatting hygiene is - # reported from the reusable estate bundle; the follow-up step below - # emits ::warning:: explicitly when this fails instead of silently - # swallowing it. Repos opt into blocking formatter checks locally. - # hypatia: allow research_extensions/RE005 -- advisory-by-design, fails OPEN; see comment above. - continue-on-error: true - - name: EditorConfig check - surface result - if: steps.editorconfig_check.outcome == 'failure' - run: echo "::warning::EditorConfig check found formatting issues (advisory only — see the 'EditorConfig check' step's own log above; repos opt into blocking locally when ready)." - # Sparse-check-out standards' scripts/ for the docs gate (a reusable - # workflow only auto-checks-out its own YAML, not sibling scripts). - - name: Check out standards for the docs gate - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: hyperpolymath/standards - ref: ${{ job.workflow_sha }} - path: .standards-checkout - sparse-checkout: | - scripts - sparse-checkout-cone-mode: false - - - name: Check documentation - run: | - # Split gate (standards#505): README + LICENSE block immediately — - # measured 0/412 callers missing either, so arming them reds nobody. - # CONTRIBUTING (54/412 missing) warns until the cutoff baked into the - # script, then blocks. See scripts/check-docs-presence.sh. - cp .standards-checkout/scripts/check-docs-presence.sh "$RUNNER_TEMP/" - # The launcher-standard currency gate (standards#960 AC4) is copied - # out here too, because this step deletes the checkout. Prefer the - # CALLER's own copy when present (self-lint: standards validating - # itself must run the tree under test, not main's copy -- a gate fix - # in flight would otherwise be judged by the version it replaces). - # Callers without the script keep the main-pinned fallback. - if [ -f scripts/check-launcher-standard-currency.sh ]; then - cp scripts/check-launcher-standard-currency.sh "$RUNNER_TEMP/" - else - cp .standards-checkout/scripts/check-launcher-standard-currency.sh "$RUNNER_TEMP/" - fi - rm -rf .standards-checkout - bash "$RUNNER_TEMP/check-docs-presence.sh" . - - - name: Check launcher-standard currency - run: | - set -eo pipefail - # Arming policy, and the evidence it rests on: standards#991. - # - # retired-filename -> BLOCKS. A STABLE predicate: - # the retired `.a2ml` spelling of the launcher standard was - # deleted upstream on 2026-09-22 - # (standards#952) and stays deleted, so a caller that is clean - # today cannot become defective without editing the citation - # itself. Measured 2026-09-22 over EVERY clone in the estate -- - # 595 scanned, 553 carrying an origin/main. 432 reference this - # reusable workflow, but only 12 do so at a MUTABLE ref (@main), - # and a new step reaches ONLY those 12: a caller pinned at a SHA - # freezes this whole file, this step included, so it can never - # receive the step at all. The real gate was run against all 12: - # 12/12 rc=0, retired=0. Five slugs do carry the retired literal - # (tma-mark2, canonical-ums, the-nash-equilibrium, - # launch-scaffolder, trigger) and their overlap with the armed 12 - # is ZERO -- so arming this tier reds ZERO live callers. A - # known-answer positive control fired (rc=1) on three of those - # defective repos through the identical harness, so the twelve - # zeros are a real measurement and not a broken probe. + # Runs `scripts/check-ts-allowlist.sh`, replacing the + # AffineScript-compiled `check-ts-allowlist.deno.js`. That artefact + # existed only to be fed to `deno run`, which forced a + # `denoland/setup-deno` install onto a REQUIRED context in every + # estate repo. The shell implementation is behaviourally equivalent + # (scripts/tests/check-ts-allowlist-test.sh, 18/18) and needs no + # runtime installed at all. Estate policy retires Deno in favour of + # bun — see LANGUAGE-POLICY.adoc §1. # - # stale-version -> WARNS, and does not block. A TIME-DEPENDENT - # predicate: the gate compares against its own CURRENT_VERSION, so - # every correctly-citing caller flips to defect the moment the - # standard bumps, having done nothing. A baked-in cutoff DATE does - # not cure that -- the #505 split above can use one because its - # missing-CONTRIBUTING population is static, while this population - # is regenerated at every bump. Each CURRENT_VERSION bump is a - # measure-then-arm event, not a date. + # The source/compile drift check that used to sit here was removed + # with the .deno.js it compared against: `affinescript compile` is + # unavailable on the runner, so the step could only ever emit + # `::warning::`, which cannot fail a job — a vacuous gate. + run: "bash .standards-checkout/scripts/check-ts-allowlist.sh", + }, + # Shared escape hatch for the banned-language-file checks below. + # Honours three exemption mechanisms (see + # standards/docs/EXEMPTION-MECHANISMS.adoc): + # 1. `.hypatia-baseline.json` — array of acknowledged findings, + # shape mirrors the Hypatia findings themselves. Schema is + # `.machine_readable/hypatia-baseline.schema.json` in this + # repo, enforced structurally by apply-baseline.sh itself + # (jq mirror; a malformed baseline exits 2 instead of + # silently matching nothing). Added 2026-05-25 as part of + # the convergence on a single exemption mechanism. + # 2. `.hypatia-ignore` flat-file — legacy single-rule-per-line + # format (`cicd_rules/banned_language_file:`). + # Will be retired in a follow-up PR once .hypatia-baseline.json + # is in active use across the estate. + # 3. Inline `# hypatia:ignore ...` pragma in the file's first + # 8 lines — the same escape the Hypatia scanner itself + # honours. + { + name: "Check banned-language files (ReScript / Go / Python / Java / Kotlin / Groovy / Swift / Dart / V-lang / Makefile)", + run: "rule_module=\"cicd_rules\"\nrule_type=\"banned_language_file\"\nrule=\"${rule_module}/${rule_type}\"\n\n# Baseline lookup: returns 0 (exempt) if the file appears in\n# .hypatia-baseline.json with a matching rule_module + type.\n# Honours both `file` (exact) and `file_pattern` (glob) entries.\n# The glob → regex translation mirrors apply-baseline.sh exactly:\n# `**` matches any depth (incl. `/`); `*` matches one segment.\n# Pattern support unblocks language-demo repos (absolute-zero\n# carries ~30 banned-language example files under `examples/`)\n# and any repo that vendors such subtrees, replacing per-file\n# `.hypatia-ignore` enumeration with one `file_pattern` entry.\nin_baseline() {\n local target=\"$1\"\n [ -f .hypatia-baseline.json ] || return 1\n command -v jq >/dev/null 2>&1 || return 1\n # Note: the `as $pat` capture is essential — inside `test(...)`\n # the dot rebinds to test's input ($f, a string), so\n # `.file_pattern` would error with \"Cannot index string\". We\n # capture file_pattern in $pat first, then reference it inside\n # the test() argument.\n jq -e \\\n --arg rm \"$rule_module\" \\\n --arg rt \"$rule_type\" \\\n --arg f \"$target\" \\\n 'any(.[];\n .rule_module == $rm and .type == $rt\n and (\n (.file? // null) == $f\n or (\n (.file_pattern? // null) as $pat\n | $pat != null\n and ($f | test(\n $pat\n | gsub(\"\\\\*\\\\*\"; \"DOUBLESTAR\")\n | gsub(\"\\\\*\"; \"[^/]*\")\n | gsub(\"DOUBLESTAR\"; \".*\")\n | \"^\" + . + \"$\"\n ))\n )\n ))' \\\n .hypatia-baseline.json >/dev/null 2>&1\n}\n\nis_exempt() {\n f=\"${1#./}\"\n if in_baseline \"$f\"; then\n echo \"⏭️ exempt (baseline): $f\"\n return 0\n fi\n if [ -f .hypatia-ignore ] && grep -qxF \"${rule}:${f}\" .hypatia-ignore; then\n return 0\n fi\n if head -n 8 \"$1\" 2>/dev/null | grep -q \"hypatia:ignore.*${rule}\"; then\n return 0\n fi\n return 1\n}\n\n# $1 = human label, $2 = remediation hint, $3 = newline-separated\n# candidate paths (possibly empty). Reads via here-string so this\n# runs in the current shell — no subshell, exit propagates.\nenforce() {\n label=\"$1\"; hint=\"$2\"; candidates=\"$3\"; violations=\"\"\n while IFS= read -r f; do\n [ -z \"$f\" ] && continue\n if is_exempt \"$f\"; then\n echo \"⏭️ exempt (${rule}): $f\"\n continue\n fi\n violations=\"${violations}${f}\n\"\n done <<< \"$candidates\"\n if [ -n \"$(printf '%s' \"$violations\" | tr -d '[:space:]')\" ]; then\n echo \"❌ ${label} detected - ${hint}\"\n printf '%s' \"$violations\"\n echo \" (declare an exemption via .hypatia-ignore or an inline\"\n echo \" '# hypatia:ignore ${rule}' pragma if intentional)\"\n exit 1\n fi\n echo \"✅ No non-exempt ${label}\"\n}\n\n# Use `git ls-files` instead of `find` so only TRACKED files are\n# inspected. This respects .gitignore by definition — vendored\n# deps in deps/, node_modules/, _build/ etc. are never tracked\n# and cannot produce false-positives. Root cause for the switch:\n# developer-ecosystem@baab1534 had false-positive governance\n# failures because `find` crawled gitignored vendor directories.\n# `git ls-files` works correctly on fresh PR checkouts because\n# actions/checkout populates the index before running workflows.\nRES_FILES=$(git ls-files '*.res')\nGO_FILES=$(git ls-files '*.go')\nPY_FILES=$(git ls-files '*.py' \\\n | grep -v venv | grep -v __pycache__ || [ $? -eq 1 ])\nMAKE_FILES=$(git ls-files 'Makefile' 'Makefile.*' '*.mk' \\\n | grep -v '\\.github/' || [ $? -eq 1 ])\n# Platform-required JVM shims carve-out 2026-06-02:\n# Java is permitted only in Android source trees\n# (android/**/src/**/*.java) because Android instantiates\n# Service/BroadcastReceiver/AppWidgetProvider/Activity classes by\n# name at platform boundaries — Rust/Zig cannot provide JVM\n# bytecode for these. Each Android Java shim must be a minimal\n# delegating wrapper (typically <10 LoC) that JNIs into Rust/Zig\n# immediately.\n#\n# Kotlin/Groovy: NO JVM-interop exemption (reverted 2026-06-24).\n# Kotlin is goodbye. There is NO carve-out for hand-written JVM\n# bindings: FFI/interop to other ecosystems MUST be authored as\n# Idris2-ABI → Zig FFI/API (Idris2 → C only as an absolute last\n# resort if Zig genuinely cannot reach the boundary), never as\n# Kotlin/Java/Groovy `bindings/*`. Mobile uses Tauri/Dioxus. The\n# only remaining Java carve-out is the minimal Android platform\n# shim above. `.groovy` is detected so it is banned everywhere.\nJAVA_FILES=$(git ls-files '*.java' '*.kt' '*.kts' '*.groovy' \\\n | grep -vE '(^|/)android/.*/src/.*\\.java$' || [ $? -eq 1 ])\nSWIFT_FILES=$(git ls-files '*.swift')\nDART_FILES=$(git ls-files '*.dart' 'pubspec.yaml')\n# V-lang detected by manifest (v.mod / vpkg.json); the .v extension\n# collides with Verilog so we never key on it.\nVMOD_FILES=$(git ls-files 'v.mod' 'vpkg.json')\n\nenforce \"ReScript files\" \"use AffineScript instead\" \"$RES_FILES\"\nenforce \"Go files\" \"use Rust/WASM instead\" \"$GO_FILES\"\nenforce \"Python files\" \"Python is fully banned — use AffineScript/Rust/SPARK/Julia (SaltStack carveout removed 2026-01-03)\" \"$PY_FILES\"\nenforce \"Makefiles\" \"use Mustfile/justfile instead\" \"$MAKE_FILES\"\nenforce \"Java/Kotlin/Groovy files\" \"Kotlin is goodbye — apps use Tauri/Dioxus; FFI/interop must be Idris2-ABI -> Zig FFI (or Idris2 -> C as a last resort), NOT hand-written JVM bindings\" \"$JAVA_FILES\"\nenforce \"Swift files\" \"use Tauri/Dioxus instead\" \"$SWIFT_FILES\"\nenforce \"Flutter/Dart files\" \"use Tauri/Dioxus instead (Google lock-in)\" \"$DART_FILES\"\nenforce \"V-lang manifests (v.mod / vpkg.json)\" \"V-lang is banned since 2026-04-10 — migrate to Zig\" \"$VMOD_FILES\"\n", + }, + { + name: "Check for npm/yarn artifacts", + # standards#67 — npm-avoidant: package-lock.json must never be tracked + # estate-wide. Check recursively (not just root) to catch monorepo + # sub-packages. See 3-practice/LANGUAGE-POLICY.adoc §1 and docs/JS-RUNTIME-POLICY.adoc. # - # Deliberately no --standard / --expect-version: the gate arrives - # from the standard's own tree, so its CURRENT_VERSION is current by - # construction. Pointing it at a caller's vendored copy of the deed - # would red that caller for citing an older canon. - rc=0 - bash "$RUNNER_TEMP/check-launcher-standard-currency.sh" --root . \ - >"$RUNNER_TEMP/launcher-currency.out" 2>&1 || rc=$? - cat "$RUNNER_TEMP/launcher-currency.out" - - # rc=2 is a usage error OR a seeded self-test mutant that survived. A broken - # invocation must never read as a clean pass, so it fails before the tier split. - if [ "$rc" -eq 2 ]; then - echo "::error::check-launcher-standard-currency exited 2: usage error, or a seeded self-test mutant survived. A broken invocation is a failure, not a pass." - exit 1 - fi - - # The tiers are split on the gate's own typed stdout lines, not on its exit - # code -- rc=1 means "some defect", and the two classes are armed differently. - retired=0 - if grep -q '^DEFECT retired-filename' "$RUNNER_TEMP/launcher-currency.out"; then retired=1; fi - stale=0 - if grep -q '^DEFECT stale-version' "$RUNNER_TEMP/launcher-currency.out"; then stale=1; fi - - if [ "$retired" -eq 1 ]; then - # ⚠ Do NOT name the retired filename literally here. This workflow - # is part of the caller's scanned tree, so the gate reads its own - # error message and reports it as a defect -- measured: two - # defects on this file, from a comment and from this very echo. - # Nothing is lost by staying general: the gate has already printed - # both the offending file:line and its own cure text above. - echo "::error::This repository names the retired .a2ml spelling of the launcher standard, deleted upstream on 2026-09-22 (standards#952). The offending file:line and the cure are printed above." - exit 1 - fi - - if [ "$stale" -eq 1 ]; then - echo "::warning::This repository cites a launcher-standard version that is no longer current. NOT blocking, by policy (standards#991): the expected version moves with the standard, so a correct citation goes stale with no action of yours. Refresh it when convenient." - fi - - # Any other non-zero rc is the gate failing in a way this wrapper does not - # classify; fail rather than guess. - if [ "$rc" -ne 0 ] && [ "$retired" -eq 0 ] && [ "$stale" -eq 0 ]; then - echo "::error::check-launcher-standard-currency exited $rc but emitted no recognised DEFECT line. Failing closed." - exit 1 - fi - - wellknown: - name: Well-Known (RFC 9116 + RSR) - runs-on: ${{ inputs.runs-on }} - timeout-minutes: 10 - permissions: - contents: read - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: ${{ github.repository }} - # Pin to the concrete event SHA (the PR merge commit on - # pull_request, the pushed commit on push). Do NOT use - # `ref: ${{ github.ref }}`: in a reusable workflow called from a - # pull_request, github.ref is the named merge ref - # `refs/pull//merge`, which actions/checkout cannot fetch - # ("couldn't find remote ref refs/pull//merge") — it broke 8/10 - # governance jobs on every PR estate-wide. github.sha resolves to the - # same merge commit but is always fetchable. - ref: ${{ github.sha }} - - name: RFC 9116 security.txt validation - run: | - SECTXT="" - [ -f ".well-known/security.txt" ] && SECTXT=".well-known/security.txt" - [ -f "security.txt" ] && SECTXT="security.txt" - if [ -z "$SECTXT" ]; then - echo "::warning::No security.txt found." - exit 0 - fi - grep -q "^Contact:" "$SECTXT" || { echo "::error::Missing Contact field"; exit 1; } - if ! grep -q "^Expires:" "$SECTXT"; then - echo "::error::Missing Expires field" - exit 1 - fi - EXPIRES=$(grep "^Expires:" "$SECTXT" | cut -d: -f2- | tr -d ' ' | head -2) - if date -d "$EXPIRES" > /dev/null 2>&1; then - DAYS=$(( ($(date -d "$EXPIRES" +%s) - $(date +%s)) / 86400 )) - if [ $DAYS -lt 0 ]; then - echo "::error::security.txt EXPIRED" - exit 1 - elif [ $DAYS -lt 30 ]; then - echo "::warning::security.txt expires in $DAYS days" - else - echo "✅ security.txt valid ($DAYS days)" - fi - fi - - name: RSR well-known compliance - run: | - MISSING="" - [ ! -f ".well-known/security.txt" ] && [ ! -f "security.txt" ] && MISSING="$MISSING security.txt" - [ ! -f ".well-known/ai.txt" ] && MISSING="$MISSING ai.txt" - [ ! -f ".well-known/humans.txt" ] && MISSING="$MISSING humans.txt" - if [ -n "$MISSING" ]; then - echo "::warning::Missing RSR recommended files:$MISSING" - else - echo "✅ RSR well-known compliant" - fi - - name: Mixed content check - run: | - MIXED=$(grep -rE 'src="http://|href="http://' --include="*.html" --include="*.htm" . 2>/dev/null | grep -vE 'localhost|127\.0\.0\.1|example\.com|lol/|node_modules/|third-party/|vendor/' | head -5) - if [ -n "$MIXED" ]; then - echo "::error::Mixed content (HTTP in HTML)" - echo "$MIXED" - exit 1 - fi - echo "✅ No mixed content" - - workflow-lint: - name: Workflow security linter - runs-on: ${{ inputs.runs-on }} - timeout-minutes: 10 - permissions: - contents: read - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: ${{ github.repository }} - # Pin to the concrete event SHA (the PR merge commit on - # pull_request, the pushed commit on push). Do NOT use - # `ref: ${{ github.ref }}`: in a reusable workflow called from a - # pull_request, github.ref is the named merge ref - # `refs/pull//merge`, which actions/checkout cannot fetch - # ("couldn't find remote ref refs/pull//merge") — it broke 8/10 - # governance jobs on every PR estate-wide. github.sha resolves to the - # same merge commit but is always fetchable. - ref: ${{ github.sha }} - - name: Checkout the pinned Standards policy helpers - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: hyperpolymath/standards - ref: 317101e03b8fe642589498f4bdb84541ab466062 - path: .standards-dupkey - sparse-checkout: | - scripts/check-workflow-duplicate-keys.sh - sparse-checkout-cone-mode: false - # ⚠ Not fatal if a file is absent, and the next step names precisely - # which one was missing. Formerly this followed `main`, so a rename of - # any helper failed the linter on the one pull request that could not - # possibly be at fault — the new name does not exist on main until that - # very PR merges. An immutable pin removes the race entirely: the names - # at this SHA are fixed. The self-lint fallback in the next step still - # covers standards' own tree, where a rename lands before the bump. - # "Duplicate YAML keys in workflows" below refuses to run (::error:: + - # exit 1) when neither the fetched nor the self-hosted copy of the - # checker script is present, regardless of why the fetch failed. - # hypatia: allow research_extensions/RE005 -- deliberate fail-closed: see comment above. - continue-on-error: true - - - name: Duplicate YAML keys in workflows - run: | - # GitHub Actions REJECTS a workflow with duplicate keys: the run is - # `failure` with no jobs, no log and no check run. Nothing else here - # can see it, because yaml.safe_load silently keeps the LAST - # duplicate and reports success — so the file "parses" and every - # other lint passes. Measured 2026-08-05: nine workflows in hypatia - # were dead this way, including a CodeQL workflow with zero - # successful runs in its entire lifetime. - set -euo pipefail - SCRIPT=".standards-dupkey/scripts/check-workflow-duplicate-keys.sh" - # Self-hosting fallback: when THIS repository is standards, its own - # working tree already holds the script, and during a rename that copy - # is the only correct one — the pinned main checkout still has the old - # name. Preferring the fetched copy keeps every other caller on the - # canonical version. - if [ ! -f "$SCRIPT" ] && [ -f scripts/check-workflow-duplicate-keys.sh ]; then - SCRIPT="scripts/check-workflow-duplicate-keys.sh" - echo "Using this repository's own copy (standards self-lint)." - fi - if [ ! -f "$SCRIPT" ]; then - echo "::error::duplicate-key checker not found — neither fetched from" \ - "standards@main nor present locally." - exit 1 - fi - cp "$SCRIPT" "$RUNNER_TEMP/dupkeys.sh" - # NOTE: the actions-lock gate used to be staged here too. It is its - # own job now (`actions-lock-verify`, its own required context per - # the 2026-09-02 regularisation spec §6.2) — see that job below. - rm -rf .standards-dupkey - bash "$RUNNER_TEMP/dupkeys.sh" .github/workflows - - - name: Check SPDX headers + permissions - run: | - failed=0 - # The permissions verdict is read with yq (YAML-POLICY Y-1): the old - # `grep -q "^permissions:"` FALSELY FAILED every KYAML workflow, - # whose keys sit indented inside `{ … }`. yq ships on GitHub-hosted - # Ubuntu; a runner without it (inputs.runs-on) keeps the old line - # test, which is still right for block-style files, and says so. - have_yq=1 - command -v yq >/dev/null 2>&1 || { - have_yq=0 - echo "::warning::yq not on this runner -- permissions read by line grep; a KYAML workflow will be misreported" - } - for file in .github/workflows/*.yml .github/workflows/*.yaml; do - [ -f "$file" ] || continue - # ⚠ SCAN THE HEADER BLOCK, NOT LINE 1. REUSE places the identifier - # anywhere in a file's leading comment block, and `gh actions-lock` - # INSERTS `# This workflow is managed by gh actions-lock.` at line 1 - # whenever it mints a lockfile — so a line-1 test fights the estate's - # own tool and re-fails every time a lockfile is refreshed. - # - # Measured 2026-08-07: it reported 27 hypatia workflows and 13 more - # elsewhere as missing a header they all had, and "fixing" that by - # prepending a default MIS-LICENSED three files (PMPL-1.0-or-later - # shadowed by MPL-2.0) before it was caught. - # - # The leading run of comment lines is read, tolerating a YAML - # document marker. A licence declared there is declared. - if ! awk '/^---[[:space:]]*$/ { next } /^#/ { print; next } { exit }' "$file" \ - | grep -q "^# SPDX-License-Identifier:"; then - echo "ERROR: $file has no SPDX-License-Identifier in its header comment block"; failed=1 - fi - if [ "$have_yq" -eq 1 ]; then - if ! verdict="$(yq 'has("permissions")' "$file" 2>&1)"; then - echo "ERROR: $file is not parseable as YAML: $verdict"; failed=1 - elif [ "$verdict" != "true" ]; then - echo "ERROR: $file missing top-level 'permissions:' declaration"; failed=1 - fi - elif ! grep -q "^permissions:" "$file"; then - echo "ERROR: $file missing top-level 'permissions:' declaration"; failed=1 - fi - done - [ $failed -eq 1 ] && { echo "Add SPDX header + permissions:"; exit 1; } - echo "All workflows have SPDX headers + permissions" - # The actions-lock-verify job proves a pin has the right SHAPE. It cannot - # prove the SHA EXISTS — a fabricated 40-hex string passes it. - # Measured 2026-07-28: - # 112 of 613 unique estate pins (18%) do not resolve, in 876 committed - # files. An unresolvable `uses:` yields NO check run, so those repos look - # green while the job never ran. This step closes that gap. - - name: Checkout standards for the pin-existence gate - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: hyperpolymath/standards - # Immutable pin, for the same reason as the policy-helper checkout - # above: following `main` lets one standards push change the - # behaviour of every already-pinned caller with no review. - ref: 317101e03b8fe642589498f4bdb84541ab466062 - path: .standards-checkout - persist-credentials: false - sparse-checkout: | - scripts - sparse-checkout-cone-mode: false - - name: Check action pins resolve upstream - env: - GH_TOKEN: ${{ github.token }} - run: | - # Copy out and delete the checkout before scanning, so the standards - # tree is never part of the caller's workspace (same idiom as the - # allowlist preflight above). - cp .standards-checkout/scripts/check-action-pins-resolve.sh "$RUNNER_TEMP/" - rm -rf .standards-checkout - bash "$RUNNER_TEMP/check-action-pins-resolve.sh" . - - name: Check for duplicate workflows - run: | - if [ -f .github/workflows/codeql.yml ] && [ -f .github/workflows/codeql-analysis.yml ]; then - echo "ERROR: Duplicate CodeQL workflows found"; exit 1 - fi - echo "No critical duplicates found" - - actions-lock-verify: - name: Actions lockfile verify - runs-on: ${{ inputs.runs-on }} - timeout-minutes: 10 - permissions: - contents: read - steps: - - name: Checkout caller repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: ${{ github.repository }} - ref: ${{ github.sha }} - - name: Checkout standards for the lock gate - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: hyperpolymath/standards - # The reusable's OWN commit — the one the caller's `uses:` ref - # resolved to — so the lock tooling is exactly what the caller - # pinned, never older and never following `main`. This replaced a - # hardcoded third pin that was one change behind by construction and - # went stale twice (standards#946, then #1119); - # scripts/check-lock-gate-pin-freshness.sh accepts only this - # expression or a fresh 40-hex SHA. - ref: ${{ job.workflow_sha }} - path: .standards-lock - persist-credentials: false - sparse-checkout: | - scripts/check-actions-lock-gate.sh - scripts/update-actions-lock.sh - .machine_readable/lock-allow.txt - sparse-checkout-cone-mode: false - - name: Check locked or SHA-pinned actions - env: - GH_TOKEN: ${{ github.token }} - run: | - set -uo pipefail - # Stage the gate, the verifier and the exemption ledger. When THIS - # repository is standards, its own working tree already holds all - # three (self-lint); every other caller uses the pinned checkout. - if [ "$GITHUB_REPOSITORY" = hyperpolymath/standards ]; then - SRC=scripts - LEDGERSRC=.machine_readable - echo "Using this repository's own gate + verifier + ledger (standards self-lint)." - else - SRC=.standards-lock/scripts - LEDGERSRC=.standards-lock/.machine_readable - fi - for f in check-actions-lock-gate.sh update-actions-lock.sh; do - if [ ! -f "$SRC/$f" ]; then - echo "::error::actions-lock gate: $f not found in $SRC (pinned standards checkout failed?)" - exit 1 - fi - cp "$SRC/$f" "$RUNNER_TEMP/$f" - done - # A MISSING ledger must not silently exempt everyone, nor silently - # exempt no one. Stage an empty file and let the gate below print - # the denominator it actually read — a skip is not a pass. - if [ -f "$LEDGERSRC/lock-allow.txt" ]; then - cp "$LEDGERSRC/lock-allow.txt" "$RUNNER_TEMP/lock-allow.txt" - else - echo "::warning::actions-lock exemption ledger not found; treating it as EMPTY (no repo is exempt)." - : > "$RUNNER_TEMP/lock-allow.txt" - fi - rm -rf .standards-lock - - # ONE implementation of the lock/pin predicate, not two. + # 2026-08-07: this step previously failed any repository carrying + # bun.lockb ("Use Deno instead") and any package.json declaring runtime + # dependencies. 3-practice/LANGUAGE-POLICY.adoc §1 has made Bun the tier-1 runtime + # since 2026-07-29, and Bun's model IS an npm-compatible package.json + # plus bun.lock — so this gate made adopting the estate's first-choice + # runtime impossible, not merely awkward. It blocked what the policy + # mandates. Bun artifacts are now accepted; npm and yarn are unchanged. + run: "LOCK_FILES=$(git ls-files 'package-lock.json' '**/package-lock.json' 2>/dev/null)\nYARN_FILES=$(find . -name \"yarn.lock\" -not -path \"./.git/*\" 2>/dev/null)\nNPMRC_FILES=$(find . -name \".npmrc\" -not -path \"./.git/*\" 2>/dev/null)\nBUN_LOCK=$(find . \\( -name \"bun.lock\" -o -name \"bun.lockb\" \\) -not -path \"./.git/*\" 2>/dev/null)\nFAILED=\"\"\nif [ -n \"$LOCK_FILES\" ]; then\n echo \"❌ Tracked package-lock.json detected (standards#67 — npm-avoidant).\"\n echo \" npm is tier 4: permitted, never preferred. Prefer Bun (tier 1).\"\n printf '%s\\n' \"$LOCK_FILES\"\n FAILED=1\nfi\nif [ -n \"$YARN_FILES\" ]; then\n echo \"❌ yarn.lock detected. yarn is not in the estate tier list.\"\n echo \" Use Bun (tier 1), or Deno / pnpm / npm in that order.\"\n printf '%s\\n' \"$YARN_FILES\"\n FAILED=1\nfi\nif [ -n \"$NPMRC_FILES\" ]; then\n echo \"❌ .npmrc detected. Configure the runtime you actually use —\"\n echo \" bunfig.toml for Bun, deno.json for Deno.\"\n printf '%s\\n' \"$NPMRC_FILES\"\n FAILED=1\nfi\nif [ -n \"$BUN_LOCK\" ]; then\n echo \"✅ Bun lockfile present — tier 1 (3-practice/LANGUAGE-POLICY.adoc §1).\"\nfi\n# Root package.json with runtime \"dependencies\". Bun consumes\n# package.json by design, so this is only an anti-pattern when the\n# repository is NOT using Bun; otherwise it is the tier-1 runtime\n# working exactly as intended.\nif [ -f package.json ] && grep -q '\"dependencies\"[[:space:]]*:[[:space:]]*{[^}]' package.json; then\n if [ -n \"$BUN_LOCK\" ]; then\n echo \"✅ package.json runtime dependencies accompanied by a Bun lockfile — tier 1, fine.\"\n else\n echo \"❌ package.json with runtime \\\"dependencies\\\" and no Bun lockfile.\"\n echo \" Adopt Bun (tier 1) or declare imports in deno.json (tier 2).\"\n FAILED=1\n fi\nfi\nif [ -n \"$FAILED\" ]; then\n echo \"See hyperpolymath/standards docs/JS-RUNTIME-POLICY.adoc for remediation.\"\n exit 1\nfi\necho \"✅ No npm/bun violations\"\n", + }, + { + name: "Check for tsconfig / rescript config", + # Honours the same `.hypatia-ignore` exemption mechanism as the + # banned-language-files step (standards#72, Explicit-Escape + # Principle). A config file is exempt if either + # * `.hypatia-ignore` contains the exact line + # `cicd_rules/banned_config_file:`, OR + # * the file carries an inline `# hypatia:ignore … banned_config_file` + # pragma in its first 8 lines (works for JSONC-style configs; + # not for strict JSON variants). + # Required so repos that legitimately retain ReScript while in + # mid-migration (per their `.claude/CLAUDE.md`, e.g. verisimdb) + # can declare the exemption explicitly instead of being globally + # blocked. Same pattern as the .res file step above. + run: "rule=\"cicd_rules/banned_config_file\"\n\nis_exempt() {\n f=\"$1\"\n if [ -f .hypatia-ignore ] && grep -qxF \"${rule}:${f}\" .hypatia-ignore; then\n return 0\n fi\n if head -n 8 \"$f\" 2>/dev/null | grep -q \"hypatia:ignore.*${rule}\"; then\n return 0\n fi\n return 1\n}\n\ncheck_config() {\n cfg=\"$1\"; hint=\"$2\"\n if [ -f \"$cfg\" ]; then\n if is_exempt \"$cfg\"; then\n echo \"⏭️ exempt (${rule}): $cfg\"\n return 0\n fi\n echo \"❌ ${cfg} detected - ${hint}\"\n echo \" (declare an exemption via .hypatia-ignore line\"\n echo \" '${rule}:${cfg}' if intentional)\"\n exit 1\n fi\n}\n\ncheck_config \"tsconfig.json\" \"use AffineScript instead\"\ncheck_config \"rescript.json\" \"use AffineScript config instead\"\ncheck_config \"bsconfig.json\" \"use AffineScript config instead\"\necho \"✅ No non-exempt tsconfig.json / rescript config\"\n", + }, + { + name: "Summary", + run: "echo \"RSR language/package policy passed — allowed: AffineScript, Deno,\"\necho \"WASM, Rust, OCaml, Haskell, Guile/Scheme.\"\n", + }, + ], + }, + package-policy: { + name: "Guix packaging policy (Nix retired)", + runs-on: "${{ inputs.runs-on }}", + timeout-minutes: 10, + permissions: { + contents: "read", + }, + steps: [ + { + uses: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", # v7.0.1 + with: { + repository: "${{ github.repository }}", + # Pin to the concrete event SHA (the PR merge commit on + # pull_request, the pushed commit on push). Do NOT use + # `ref: ${{ github.ref }}`: in a reusable workflow called from a + # pull_request, github.ref is the named merge ref + # `refs/pull//merge`, which actions/checkout cannot fetch + # ("couldn't find remote ref refs/pull//merge") — it broke 8/10 + # governance jobs on every PR estate-wide. github.sha resolves to the + # same merge commit but is always fetchable. + ref: "${{ github.sha }}", + }, + }, + # A reusable workflow only auto-checks-out its own YAML, not sibling + # scripts. Sparse-check-out standards' scripts/ to get + # check-package-policy.sh, mirroring the baseline-filter job above. + { + name: "Check out standards for the package-policy gate", + uses: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", # v7.0.1 + with: { + repository: "hyperpolymath/standards", + ref: "${{ job.workflow_sha }}", + path: ".standards-checkout", + sparse-checkout: "scripts\n.machine_readable/template-capability-gates.toml\n", + sparse-checkout-cone-mode: false, + }, + }, + { + name: "Enforce Guix-only packaging policy", + run: "# Move the checker OUT of the scanned tree and delete the standards\n# checkout before scanning: the gate walks the whole caller tree, so\n# a packaging file shipped inside .standards-checkout/ would satisfy\n# the policy on the caller's behalf (same trap as the baseline job).\n# The capability resolver (check-rsr-profile.sh) and its gate table travel\n# with the checker, in the same relative layout, so applicability is read\n# from the same standards commit as the policy itself.\npkg=\"$RUNNER_TEMP/pkg-policy\"\nmkdir -p \"$pkg/scripts\" \"$pkg/.machine_readable\"\ncp .standards-checkout/scripts/check-package-policy.sh .standards-checkout/scripts/check-rsr-profile.sh \"$pkg/scripts/\"\ncp .standards-checkout/.machine_readable/template-capability-gates.toml \"$pkg/.machine_readable/\"\nrm -rf .standards-checkout\nbash \"$pkg/scripts/check-package-policy.sh\" .\n", + }, + ], + }, + security-policy: { + name: "Security policy checks", + runs-on: "${{ inputs.runs-on }}", + timeout-minutes: 10, + permissions: { + contents: "read", + }, + steps: [ + { + uses: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", # v7.0.1 + with: { + repository: "${{ github.repository }}", + # Pin to the concrete event SHA (the PR merge commit on + # pull_request, the pushed commit on push). Do NOT use + # `ref: ${{ github.ref }}`: in a reusable workflow called from a + # pull_request, github.ref is the named merge ref + # `refs/pull//merge`, which actions/checkout cannot fetch + # ("couldn't find remote ref refs/pull//merge") — it broke 8/10 + # governance jobs on every PR estate-wide. github.sha resolves to the + # same merge commit but is always fetchable. + ref: "${{ github.sha }}", + }, + }, + { + name: "Security checks", + run: "FAILED=false\nWEAK_CRYPTO=$(grep -rE 'md5\\(|sha1\\(' --include=\"*.py\" --include=\"*.rb\" --include=\"*.js\" --include=\"*.ts\" --include=\"*.go\" --include=\"*.rs\" . 2>/dev/null | grep -v 'checksum\\|cache\\|test\\|spec' | head -5)\nif [ -n \"$WEAK_CRYPTO\" ]; then\n echo \"⚠️ Weak crypto (MD5/SHA1) detected. Use SHA256+ for security:\"\n echo \"$WEAK_CRYPTO\"\nfi\nHTTP_URLS=$(grep -rE 'http://[^l][^o][^c]' --include=\"*.py\" --include=\"*.js\" --include=\"*.ts\" --include=\"*.go\" --include=\"*.rs\" --include=\"*.yaml\" --include=\"*.yml\" . 2>/dev/null | grep -v 'localhost\\|127.0.0.1\\|example\\|test\\|spec' | head -5)\nif [ -n \"$HTTP_URLS\" ]; then\n echo \"⚠️ HTTP URLs found. Use HTTPS:\"\n echo \"$HTTP_URLS\"\nfi\nSECRETS=$(grep -rEi '(api_key|apikey|secret_key|password)\\s*[=:]\\s*[\"\\x27][A-Za-z0-9+/=]{20,}' --include=\"*.py\" --include=\"*.js\" --include=\"*.ts\" --include=\"*.go\" --include=\"*.rs\" --include=\"*.env\" . 2>/dev/null | grep -v 'example\\|sample\\|test\\|mock\\|placeholder' | head -3)\nif [ -n \"$SECRETS\" ]; then\n echo \"❌ Potential hardcoded secrets detected!\"\n FAILED=true\nfi\nif [ \"$FAILED\" = true ]; then\n exit 1\nfi\necho \"✅ Security policy check passed\"\n", + }, + { + name: "SSH-remote policy (token-in-URL detection)", + # Estate Remote-URL policy (standards#69 / 3-practice/REMOTE-URL-POLICY.adoc). + # Scans every .git/config present in the checkout tree for token-in-URL + # remotes. Fails hard so a compromised credential cannot silently reach + # a PR or main-branch push. + run: "found=0\nwhile IFS= read -r cfg; do\n if grep -qE \"url[[:space:]]*=[[:space:]]*https://[^[:space:]]*(x-access-token:|:gho_|:ghp_|:ghs_|:github_pat_)\" \"$cfg\" 2>/dev/null; then\n echo \"❌ token-in-URL remote detected in: $cfg\"\n grep -E \"url[[:space:]]*=\" \"$cfg\" \\\n | sed 's/\\(x-access-token:\\|gho_\\|ghp_\\|ghs_\\|github_pat_\\)[^@]*/\\1****/g'\n found=$((found + 1))\n fi\ndone < <(find . -name \"config\" -path \"*/.git/config\" 2>/dev/null)\nif [ \"$found\" -gt 0 ]; then\n echo \"\"\n echo \"Remediation: git remote set-url git@github.com:/.git\"\n echo \"Policy: 3-practice/REMOTE-URL-POLICY.adoc — SSH-only remotes; no PAT/token in URL ever.\"\n exit 1\nfi\necho \"✅ SSH-remote policy: no token-in-URL remotes detected\"\n", + }, + { + name: "Tooling version integrity", + # Estate Tooling Version Integrity policy (root cause: burble#39). + # Inline + dependency-free so it runs in any caller repo. + # R0 just>=1.19.0 floor (blocking when just present) and R1 + # unversioned family-tool install (blocking) are hard; R4 + # unexplained continue-on-error is advisory-first per the + # documented "advisory now, --strict later" gating doctrine. + run: "set -uo pipefail\nFAMILY='just|must|trust|adjust|bust|dust|intend'\nif command -v just >/dev/null 2>&1; then\n jv=$(just --version 2>/dev/null | cut -d' ' -f2)\n maj=${jv%%.*}; rest=${jv#*.}; min=${rest%%.*}\n if [ -z \"$jv\" ] || ! { [ \"${maj:-0}\" -gt 1 ] || { [ \"${maj:-0}\" -eq 1 ] && [ \"${min:-0}\" -ge 19 ]; }; }; then\n echo \"❌ [R0] just ${jv:-?} < 1.19.0 — import? unsupported\"; exit 1\n fi\n echo \"✅ [R0] just $jv >= 1.19.0\"\nelse\n echo \"ℹ️ [R0] just not on PATH — skipped\"\nfi\nR1=0\nif [ -d .github/workflows ]; then\n while IFS= read -r hit; do\n [ -n \"$hit\" ] || continue\n echo \"❌ [R1] unversioned family-tool install: $hit\"\n R1=$((R1+1))\n done < <(grep -rnE \"^[[:space:]]*tool:[[:space:]]*(${FAMILY})[[:space:]]*$\" .github/workflows 2>/dev/null || true)\nfi\n[ \"$R1\" -gt 0 ] && { echo \"❌ [R1] $R1 unversioned family-tool install(s) — pin tool: @\"; exit 1; }\necho \"✅ Tooling version integrity passed (R1 clean; R4 advisory via standards/tasks/tooling-integrity-lint.sh)\"\n", + }, + { + name: "Documentation version-string drift (R5b)", + # Estate canonical-reference drift policy. Forbids pinned + # `Version: x.y.z` strings in load-bearing top-level docs + # because they reliably go stale and disagree with each other + # (echidna had v1.5.0 in README.adoc, v2.3.0 in CLAUDE.md, + # v2.1.0 in Cargo.toml simultaneously — cleared by echidna#169 + # / #170 / #171). CHANGELOG.md is the canonical release-history + # surface; Cargo.toml's [package].version is the canonical + # semver pin; the git log carries dates. Duplicating either + # in human prose invites drift. # - # The gate used to live inside workflow-lint with its own inline copy - # of the logic. That copy matched `^[[:space:]]+uses:` — with no - # `-?` — so it never saw `- uses: foo@v1`, the commonest step form, - # and its green was therefore partly vacuous. - # scripts/check-actions-lock-gate.sh uses - # `^[[:space:]]+-?[[:space:]]*uses:` and is the tested implementation - # (scripts/tests/check-actions-lock-gate-test.sh, 11/11). + # Scope: repo-root *.md + *.adoc only. Sub-tree historical + # docs (docs/handover/, docs/decisions/, docs/releases/, + # audit reports) are owner-managed snapshots and not in + # scope. CHANGELOG.{md,adoc} explicitly skipped. # - # actions.lock is the authoritative immutable resolution for both - # direct actions and their transitive dependencies. Do not also - # rewrite direct refs to raw SHAs: gh actions-lock omits refs that - # no tag or branch contains, and GitHub then rejects the workflow - # at startup. Measured in oikosbot PR #78 on 2026-08-29: five - # previously executable workflows became startup_failure after the - # redundant direct-SHA conversion; restoring their locked version - # refs made GitHub's native resolver accept them again. - if [ -f .github/workflows/actions.lock ]; then - # Pinned: an unpinned install let a new verifier release change every - # caller's verdict overnight. Bump deliberately, with the gate tests. - gh extension install github/gh-actions-lock --pin v0.1.6 - fi - - # The gate delegates lockfile verification to the authoritative - # verifier, staged into RUNNER_TEMP above. - export ACTIONS_LOCK_VERIFIER="$RUNNER_TEMP/update-actions-lock.sh" - bash "$RUNNER_TEMP/check-actions-lock-gate.sh" - rc=$? - - # Shrink-only exemption ledger (.machine_readable/lock-allow.txt in - # standards, guarded by scripts/check-exemption-ratchet.sh so it can - # never grow without a `Ratchet-exception` trailer). + # Companion rule R5a (bare prover counts) is repo-local where + # the count semantics are domain-specific — see + # `echidna/.github/workflows/governance-doc-drift.yml`. + run: "set -uo pipefail\nPATTERN='^[[:space:]]*[*_]{0,2}Version[*_]{0,2}[[:space:]]*[:=][[:space:]]*v?[0-9]+\\.[0-9]+\\.[0-9]+'\nR5B=0\nshopt -s nullglob\nfor doc in *.md *.adoc; do\n [ -f \"$doc\" ] || continue\n case \"$doc\" in CHANGELOG.md|CHANGELOG.adoc) continue ;; esac\n while IFS= read -r hit; do\n [ -n \"$hit\" ] || continue\n echo \"❌ [R5b] pinned version string: $doc:$hit\"\n R5B=$((R5B+1))\n done < <(grep -nE \"$PATTERN\" \"$doc\" 2>/dev/null || true)\ndone\nif [ \"$R5B\" -gt 0 ]; then\n echo \"\"\n echo \"❌ [R5b] $R5B pinned version-string line(s) in load-bearing docs.\"\n echo \"Fix: drop the embedded version; defer to CHANGELOG.md (release\"\n echo \"history) and Cargo.toml's [package].version (semver pin) or the\"\n echo \"equivalent package manifest. Git log carries dates.\"\n exit 1\nfi\necho \"✅ [R5b] Documentation version-string drift: clean.\"\n", + }, + { + name: "Canonical-reference drift (R5 generic)", + # Repo-local canonical-reference rules. Reads + # `.github/canonical-references/*.{yml,yaml}` in the calling + # repo; each file declares one drift pattern with its + # canonical pointer. Skipped silently when the directory is + # absent — repos opt in by creating it. # - # Why a ledger and not a later date: the gate compares `date -u +%F` - # against ENFORCE_ACTIONS_LOCK_FROM at RUNTIME, inside an artefact - # that callers have already pinned by SHA. A date therefore fires - # simultaneously across every bumped caller with no human action and - # cannot be moved without re-bumping every pin. Measured 2026-09-22 - # (complete n=368 census of governance callers, `gh api` on default - # branches): 200 carry actions.lock, 163 do not, 5 have no workflows - # directory. The ledger makes that 163 an explicit, shrinking debt - # instead of a cliff. - total=$(grep -cve '^[[:space:]]*$' -e '^[[:space:]]*#' "$RUNNER_TEMP/lock-allow.txt" || [ $? -eq 1 ]) - : "${total:=0}" - # The ledger excuses missing-lock debt ONLY (gate exit 3: lockless, - # every ref pinned, grace window closed). Exit 1 is a LIVE - # VIOLATION — unpinned refs or a verifier-rejected lock — and exit - # 2 is infrastructure failure; neither is ledgerable, so `-ne 0` - # here would wave real findings through with the debt. - if [ "$rc" -eq 3 ] && grep -qxF "$GITHUB_REPOSITORY" "$RUNNER_TEMP/lock-allow.txt"; then - echo "::notice::actions-lock debt is LEDGERED for $GITHUB_REPOSITORY (missing-lock only, gate exit 3)." - echo "lock debt: $GITHUB_REPOSITORY is 1 of $total ledgered repositories." - echo "This exemption is shrink-only. Run scripts/update-actions-lock.sh, commit" - echo "the lockfile, and delete this repository's line from" - echo ".machine_readable/lock-allow.txt in hyperpolymath/standards." - exit 0 - fi - # Never a silent pass: state the denominator even when clean. - echo "lock debt: $total ledgered repositories; $GITHUB_REPOSITORY is NOT among them." - exit "$rc" - - trusted-base: - name: Trusted-base reduction policy - runs-on: ${{ inputs.runs-on }} - timeout-minutes: 10 - permissions: - contents: read - steps: - - name: Checkout caller repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: ${{ github.repository }} - # Pin to the concrete event SHA (the PR merge commit on - # pull_request, the pushed commit on push). Do NOT use - # `ref: ${{ github.ref }}`: in a reusable workflow called from a - # pull_request, github.ref is the named merge ref - # `refs/pull//merge`, which actions/checkout cannot fetch - # ("couldn't find remote ref refs/pull//merge") — it broke 8/10 - # governance jobs on every PR estate-wide. github.sha resolves to the - # same merge commit but is always fetchable. - ref: ${{ github.sha }} - path: caller - - name: Checkout standards (for the check script) - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: hyperpolymath/standards - ref: ${{ job.workflow_sha }} - path: standards - - name: Run trusted-base check on caller repo - run: | - bash standards/scripts/check-trusted-base.sh caller - - licence-consistency: - timeout-minutes: 10 - name: Licence consistency - runs-on: ${{ inputs.runs-on }} - permissions: - contents: read - steps: - - name: Checkout caller repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: ${{ github.repository }} - # Pin to the concrete event SHA (the PR merge commit on - # pull_request, the pushed commit on push). Do NOT use - # `ref: ${{ github.ref }}`: in a reusable workflow called from a - # pull_request, github.ref is the named merge ref - # `refs/pull//merge`, which actions/checkout cannot fetch - # ("couldn't find remote ref refs/pull//merge") — it broke 8/10 - # governance jobs on every PR estate-wide. github.sha resolves to the - # same merge commit but is always fetchable. - ref: ${{ github.sha }} - path: caller - - name: Checkout standards (for the check script) - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: hyperpolymath/standards - ref: ${{ job.workflow_sha }} - path: standards - - name: Run licence-consistency check on caller repo - run: | - bash standards/scripts/check-licence-consistency.sh caller - - exemption-ratchet: - name: Exemption ratchet - runs-on: ${{ inputs.runs-on }} - timeout-minutes: 5 - permissions: - contents: read - # Only meaningful on a pull request: the check compares the ledgers against - # a BASE. On push there is nothing to compare to, and a push-time run would - # either be vacuous or compare against an arbitrary predecessor. - if: ${{ github.event_name == 'pull_request' }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: ${{ github.repository }} - ref: ${{ github.sha }} - # Full history: the ratchet reads the ledgers at the base ref and - # scans commit messages between base and HEAD for a declared - # exception. A shallow clone has neither. - fetch-depth: 0 - - - name: Check out standards repo for shared scripts - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: hyperpolymath/standards - ref: ${{ job.workflow_sha }} - path: .standards-checkout - sparse-checkout: | - scripts - sparse-checkout-cone-mode: false - - - name: Exemption ratchet - run: | - set -euo pipefail - # Stage the script OUT of the scanned tree and delete the checkout, - # so the ratchet only ever reads the CALLER's ledgers — standards has - # ledgers of its own and they are not this repository's. - # Both files: the ratchet calls count-ledger-entries.sh as a sibling, - # and it has no fallback if the counter is missing — deliberately. - cp .standards-checkout/scripts/check-exemption-ratchet.sh \ - .standards-checkout/scripts/count-ledger-entries.sh "$RUNNER_TEMP/" - rm -rf .standards-checkout - bash "$RUNNER_TEMP/check-exemption-ratchet.sh" \ - "${{ github.event.pull_request.base.sha }}" - - debt-ratchet: - name: Debt ratchet - runs-on: ${{ inputs.runs-on }} - timeout-minutes: 5 - permissions: - contents: read - # Sibling to exemption-ratchet, and pull-request-only for the same reason: - # the check compares ceilings against a BASE. The two govern different - # populations — the exemption ratchet watches debt you have EXCUSED, this - # one watches debt you have MEASURED. - if: ${{ github.event_name == 'pull_request' }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: ${{ github.repository }} - ref: ${{ github.sha }} - # Full history: the ratchet reads the Debtfile at the base ref and - # scans commit messages between base and HEAD for a declared - # exception. A shallow clone has neither. - fetch-depth: 0 - - - name: Check out standards repo for shared scripts - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: hyperpolymath/standards - ref: ${{ job.workflow_sha }} - path: .standards-checkout - sparse-checkout: | - scripts - sparse-checkout-cone-mode: false - - - name: Debt ratchet - run: | - set -euo pipefail - # Which copy of the scripts do we run? + # Generalises echidna's R5a (bare prover counts → docs/PROVER_COUNT.md) + # so sibling repos with their own canonical-reference cohorts + # (lemma counts for proven, stdlib fn counts for affinescript, + # proof-obligation counts for typed-wasm, rule counts for + # ephapax, …) can declare their drift patterns without + # carrying a per-repo workflow. # - # ⚠ BOOTSTRAP TRAP. A gate that fetches its own script from `main` - # cannot run on the pull request that INTRODUCES that script — the - # sparse checkout of main has no such file and the `cp` fails. This - # job failed exactly that way on the PR that added it, and the same - # shape has bitten hypatia's self-gating before. + # Rule file shape: + # id: short-slug + # description: one-line summary + # patterns: [POSIX-ERE strings] + # canonical_pointer: path/to/single-source-of-truth.md + # scope: + # include: [list, of, files, to, scan] # - # So when the repository under test IS standards, run the scripts - # from the PR's own tree. The guard is on the repository name and - # not on file existence: a consumer repo that happened to contain a - # file at scripts/check-debt-ratchet.sh must NOT be able to - # substitute its own gate. - if [ "${{ github.repository }}" = "hyperpolymath/standards" ]; then - cp scripts/check-debt-ratchet.sh \ - scripts/check-debtfile-structure.sh "$RUNNER_TEMP/" - else - cp .standards-checkout/scripts/check-debt-ratchet.sh \ - .standards-checkout/scripts/check-debtfile-structure.sh "$RUNNER_TEMP/" - fi - # Stage the scripts OUT of the scanned tree and delete the checkout, - # so the ratchet only ever reads the CALLER's Debtfile — standards - # has one of its own and it is not this repository's. - rm -rf .standards-checkout - - # A repo with no Debtfile is not in violation — adoption is opt-in. - # But a repo that HAS one must have a well-formed one, or the ratchet - # would be comparing ceilings it could not parse. - if [ -f .machine_readable/Debtfile.a2ml ]; then - bash "$RUNNER_TEMP/check-debtfile-structure.sh" - fi - bash "$RUNNER_TEMP/check-debt-ratchet.sh" \ - "${{ github.event.pull_request.base.sha }}" + # Patterns are POSIX-ERE (run through `grep -E`). Each + # listed include path is scanned line-by-line; a match emits + # a `::error::` annotation and the job fails non-zero. + # CHANGELOG.{md,adoc} and the rule files themselves are + # excluded automatically; the rule's own `canonical_pointer` + # is also excluded so the canonical doc can name what it + # is the canonical answer for. + run: "set -uo pipefail\nDIR=.github/canonical-references\nif [ ! -d \"$DIR\" ]; then\n echo \"ℹ️ [R5] no $DIR/ — skipped (repo has not opted in)\"\n exit 0\nfi\n# Rule files are read with yq, never a hand parser (YAML-POLICY Y-1); this\n# gate previously parsed them with Python, which the estate bans.\nif ! command -v yq >/dev/null 2>&1 || ! command -v jq >/dev/null 2>&1; then\n echo \"❌ [R5] yq and jq are required on the runner for YAML rule parsing\"\n exit 2\nfi\nmapfile -t files < <(find \"$DIR\" -maxdepth 1 -type f \\( -name '*.yml' -o -name '*.yaml' \\) ! -name '.*' | LC_ALL=C sort)\nif [ \"${#files[@]}\" -eq 0 ]; then\n echo \"ℹ️ [R5] $DIR/ has no .yml/.yaml rules — skipped\"\n exit 0\nfi\n\nerr=$(mktemp)\ntrap 'rm -f \"$err\"' EXIT\ntotal=0\nfor rf in \"${files[@]}\"; do\n if ! cfg=$(yq -o json '.' \"$rf\" 2>&1); then\n echo \"❌ [R5] $rf: not parseable YAML: $cfg\"; total=$((total + 1)); continue\n fi\n if [ \"$(jq -r 'type' <<<\"$cfg\")\" != object ]; then\n echo \"❌ [R5] $rf: top-level must be a mapping\"; total=$((total + 1)); continue\n fi\n rid=$(jq -r --arg b \"$(basename \"$rf\")\" 'if has(\"id\") then .id | tostring else $b end' <<<\"$cfg\")\n desc=$(jq -r '.description // \"\"' <<<\"$cfg\")\n canon=$(jq -r '.canonical_pointer // \"\"' <<<\"$cfg\")\n mapfile -t pats < <(jq -r '(.patterns // [])[]' <<<\"$cfg\")\n mapfile -t includes < <(jq -r '((.scope // {}).include // [])[]' <<<\"$cfg\")\n if [ \"${#pats[@]}\" -eq 0 ] || [ \"${#includes[@]}\" -eq 0 ]; then\n echo \"❌ [R5:$rid] missing patterns or scope.include in $rf\"\n total=$((total + 1)); continue\n fi\n rule_hits=0\n for f_ in \"${includes[@]}\"; do\n # Self-references are excluded: the changelogs, the rule file, and the\n # rule's own canonical pointer.\n case \"$f_\" in CHANGELOG.md|CHANGELOG.adoc|\"$rf\") continue ;; esac\n if [ -n \"$canon\" ] && [ \"$f_\" = \"$canon\" ]; then continue; fi\n [ -f \"$f_\" ] || continue\n for pat in \"${pats[@]}\"; do\n out=$(grep -nE -e \"$pat\" -- \"$f_\" 2>\"$err\"); rc=$?\n if [ \"$rc\" -eq 0 ]; then\n while IFS= read -r line; do\n echo \"::error file=$f_,line=${line%%:*}::[R5:$rid] ${line#*:} → route to ${canon:-drop or move to a canonical source}\"\n rule_hits=$((rule_hits + 1))\n done <<<\"$out\"\n elif [ \"$rc\" -gt 1 ]; then\n echo \"❌ [R5:$rid] grep error on $f_: $(<\"$err\")\"\n rule_hits=$((rule_hits + 1))\n fi\n done\n done\n if [ \"$rule_hits\" -gt 0 ]; then\n echo \"❌ [R5:$rid] $rule_hits hit(s). $desc\"\n fi\n total=$((total + rule_hits))\ndone\n\nif [ \"$total\" -gt 0 ]; then\n echo\n echo \"❌ [R5] $total canonical-reference drift hit(s) across ${#files[@]} rule(s).\"\n exit 1\nfi\necho \"✅ [R5] canonical-reference drift: clean across ${#files[@]} rule(s).\"\n", + }, + ], + }, + quality: { + name: "Code quality + docs", + runs-on: "${{ inputs.runs-on }}", + timeout-minutes: 10, + permissions: { + contents: "read", + }, + steps: [ + { + uses: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", # v7.0.1 + with: { + repository: "${{ github.repository }}", + # Pin to the concrete event SHA (the PR merge commit on + # pull_request, the pushed commit on push). Do NOT use + # `ref: ${{ github.ref }}`: in a reusable workflow called from a + # pull_request, github.ref is the named merge ref + # `refs/pull//merge`, which actions/checkout cannot fetch + # ("couldn't find remote ref refs/pull//merge") — it broke 8/10 + # governance jobs on every PR estate-wide. github.sha resolves to the + # same merge commit but is always fetchable. + ref: "${{ github.sha }}", + }, + }, + { + name: "Check file permissions", + # advisory: informational only; repos can opt into blocking locally. + # No continue-on-error needed: the step below always exits 0 itself; + # it now surfaces its finding as an explicit ::warning:: instead of a + # silent printout. + run: "FOUND=$(find . -type f -perm /111 -name \"*.sh\" | head -20)\nif [ -n \"$FOUND\" ]; then\n echo \"::warning::Executable .sh files found (informational only):\"\n echo \"$FOUND\"\nelse\n echo \"No executable .sh files found.\"\nfi\n", + }, + { + name: "Check TODO/FIXME", + # advisory: informational only; repos can opt into blocking locally. + run: "FOUND=$(grep -rn \"TODO\\|FIXME\\|HACK\\|XXX\" --include=\"*.rs\" --include=\"*.res\" --include=\"*.py\" --include=\"*.ex\" . | head -20)\nif [ -n \"$FOUND\" ]; then\n echo \"::warning::TODO/FIXME/HACK/XXX markers found (informational only):\"\n echo \"$FOUND\"\nelse\n echo \"No TODO/FIXME/HACK/XXX markers found.\"\nfi\n", + }, + { + name: "Check for large files", + # advisory: informational only; repos can opt into blocking locally. + run: "FOUND=$(find . -type f -size +1M -not -path \"./.git/*\" | head -20)\nif [ -n \"$FOUND\" ]; then\n echo \"::warning::Files larger than 1M found (informational only):\"\n echo \"$FOUND\"\nelse\n echo \"No files larger than 1M found.\"\nfi\n", + }, + { + name: "EditorConfig check", + id: "editorconfig_check", + uses: "editorconfig-checker/action-editorconfig-checker@51f63319f592f97930c73d9c46184d20bd206393", # v3.0.0 + # advisory-by-design, fails OPEN not closed: formatting hygiene is + # reported from the reusable estate bundle; the follow-up step below + # emits ::warning:: explicitly when this fails instead of silently + # swallowing it. Repos opt into blocking formatter checks locally. + # hypatia: allow research_extensions/RE005 -- advisory-by-design, fails OPEN; see comment above. + continue-on-error: true, + }, + { + name: "EditorConfig check - surface result", + if: "steps.editorconfig_check.outcome == 'failure'", + run: "echo \"::warning::EditorConfig check found formatting issues (advisory only — see the 'EditorConfig check' step's own log above; repos opt into blocking locally when ready).\"", + }, + # Sparse-check-out standards' scripts/ for the docs gate (a reusable + # workflow only auto-checks-out its own YAML, not sibling scripts). + { + name: "Check out standards for the docs gate", + uses: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", # v7.0.1 + with: { + repository: "hyperpolymath/standards", + ref: "${{ job.workflow_sha }}", + path: ".standards-checkout", + sparse-checkout: "scripts\n", + sparse-checkout-cone-mode: false, + }, + }, + { + name: "Check documentation", + run: "# Split gate (standards#505): README + LICENSE block immediately —\n# measured 0/412 callers missing either, so arming them reds nobody.\n# CONTRIBUTING (54/412 missing) warns until the cutoff baked into the\n# script, then blocks. See scripts/check-docs-presence.sh.\ncp .standards-checkout/scripts/check-docs-presence.sh \"$RUNNER_TEMP/\"\n# The launcher-standard currency gate (standards#960 AC4) is copied\n# out here too, because this step deletes the checkout. Prefer the\n# CALLER's own copy when present (self-lint: standards validating\n# itself must run the tree under test, not main's copy -- a gate fix\n# in flight would otherwise be judged by the version it replaces).\n# Callers without the script keep the main-pinned fallback.\nif [ -f scripts/check-launcher-standard-currency.sh ]; then\n cp scripts/check-launcher-standard-currency.sh \"$RUNNER_TEMP/\"\nelse\n cp .standards-checkout/scripts/check-launcher-standard-currency.sh \"$RUNNER_TEMP/\"\nfi\nrm -rf .standards-checkout\nbash \"$RUNNER_TEMP/check-docs-presence.sh\" .\n", + }, + { + name: "Check launcher-standard currency", + run: "set -eo pipefail\n# Arming policy, and the evidence it rests on: standards#991.\n#\n# retired-filename -> BLOCKS. A STABLE predicate:\n# the retired `.a2ml` spelling of the launcher standard was\n# deleted upstream on 2026-09-22\n# (standards#952) and stays deleted, so a caller that is clean\n# today cannot become defective without editing the citation\n# itself. Measured 2026-09-22 over EVERY clone in the estate --\n# 595 scanned, 553 carrying an origin/main. 432 reference this\n# reusable workflow, but only 12 do so at a MUTABLE ref (@main),\n# and a new step reaches ONLY those 12: a caller pinned at a SHA\n# freezes this whole file, this step included, so it can never\n# receive the step at all. The real gate was run against all 12:\n# 12/12 rc=0, retired=0. Five slugs do carry the retired literal\n# (tma-mark2, canonical-ums, the-nash-equilibrium,\n# launch-scaffolder, trigger) and their overlap with the armed 12\n# is ZERO -- so arming this tier reds ZERO live callers. A\n# known-answer positive control fired (rc=1) on three of those\n# defective repos through the identical harness, so the twelve\n# zeros are a real measurement and not a broken probe.\n#\n# stale-version -> WARNS, and does not block. A TIME-DEPENDENT\n# predicate: the gate compares against its own CURRENT_VERSION, so\n# every correctly-citing caller flips to defect the moment the\n# standard bumps, having done nothing. A baked-in cutoff DATE does\n# not cure that -- the #505 split above can use one because its\n# missing-CONTRIBUTING population is static, while this population\n# is regenerated at every bump. Each CURRENT_VERSION bump is a\n# measure-then-arm event, not a date.\n#\n# Deliberately no --standard / --expect-version: the gate arrives\n# from the standard's own tree, so its CURRENT_VERSION is current by\n# construction. Pointing it at a caller's vendored copy of the deed\n# would red that caller for citing an older canon.\nrc=0\nbash \"$RUNNER_TEMP/check-launcher-standard-currency.sh\" --root . \\\n >\"$RUNNER_TEMP/launcher-currency.out\" 2>&1 || rc=$?\ncat \"$RUNNER_TEMP/launcher-currency.out\"\n\n# rc=2 is a usage error OR a seeded self-test mutant that survived. A broken\n# invocation must never read as a clean pass, so it fails before the tier split.\nif [ \"$rc\" -eq 2 ]; then\n echo \"::error::check-launcher-standard-currency exited 2: usage error, or a seeded self-test mutant survived. A broken invocation is a failure, not a pass.\"\n exit 1\nfi\n\n# The tiers are split on the gate's own typed stdout lines, not on its exit\n# code -- rc=1 means \"some defect\", and the two classes are armed differently.\nretired=0\nif grep -q '^DEFECT retired-filename' \"$RUNNER_TEMP/launcher-currency.out\"; then retired=1; fi\nstale=0\nif grep -q '^DEFECT stale-version' \"$RUNNER_TEMP/launcher-currency.out\"; then stale=1; fi\n\nif [ \"$retired\" -eq 1 ]; then\n # ⚠ Do NOT name the retired filename literally here. This workflow\n # is part of the caller's scanned tree, so the gate reads its own\n # error message and reports it as a defect -- measured: two\n # defects on this file, from a comment and from this very echo.\n # Nothing is lost by staying general: the gate has already printed\n # both the offending file:line and its own cure text above.\n echo \"::error::This repository names the retired .a2ml spelling of the launcher standard, deleted upstream on 2026-09-22 (standards#952). The offending file:line and the cure are printed above.\"\n exit 1\nfi\n\nif [ \"$stale\" -eq 1 ]; then\n echo \"::warning::This repository cites a launcher-standard version that is no longer current. NOT blocking, by policy (standards#991): the expected version moves with the standard, so a correct citation goes stale with no action of yours. Refresh it when convenient.\"\nfi\n\n# Any other non-zero rc is the gate failing in a way this wrapper does not\n# classify; fail rather than guess.\nif [ \"$rc\" -ne 0 ] && [ \"$retired\" -eq 0 ] && [ \"$stale\" -eq 0 ]; then\n echo \"::error::check-launcher-standard-currency exited $rc but emitted no recognised DEFECT line. Failing closed.\"\n exit 1\nfi\n", + }, + ], + }, + wellknown: { + name: "Well-Known (RFC 9116 + RSR)", + runs-on: "${{ inputs.runs-on }}", + timeout-minutes: 10, + permissions: { + contents: "read", + }, + steps: [ + { + uses: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", # v7.0.1 + with: { + repository: "${{ github.repository }}", + # Pin to the concrete event SHA (the PR merge commit on + # pull_request, the pushed commit on push). Do NOT use + # `ref: ${{ github.ref }}`: in a reusable workflow called from a + # pull_request, github.ref is the named merge ref + # `refs/pull//merge`, which actions/checkout cannot fetch + # ("couldn't find remote ref refs/pull//merge") — it broke 8/10 + # governance jobs on every PR estate-wide. github.sha resolves to the + # same merge commit but is always fetchable. + ref: "${{ github.sha }}", + }, + }, + { + name: "RFC 9116 security.txt validation", + run: "SECTXT=\"\"\n[ -f \".well-known/security.txt\" ] && SECTXT=\".well-known/security.txt\"\n[ -f \"security.txt\" ] && SECTXT=\"security.txt\"\nif [ -z \"$SECTXT\" ]; then\n echo \"::warning::No security.txt found.\"\n exit 0\nfi\ngrep -q \"^Contact:\" \"$SECTXT\" || { echo \"::error::Missing Contact field\"; exit 1; }\nif ! grep -q \"^Expires:\" \"$SECTXT\"; then\n echo \"::error::Missing Expires field\"\n exit 1\nfi\nEXPIRES=$(grep \"^Expires:\" \"$SECTXT\" | cut -d: -f2- | tr -d ' ' | head -2)\nif date -d \"$EXPIRES\" > /dev/null 2>&1; then\n DAYS=$(( ($(date -d \"$EXPIRES\" +%s) - $(date +%s)) / 86400 ))\n if [ $DAYS -lt 0 ]; then\n echo \"::error::security.txt EXPIRED\"\n exit 1\n elif [ $DAYS -lt 30 ]; then\n echo \"::warning::security.txt expires in $DAYS days\"\n else\n echo \"✅ security.txt valid ($DAYS days)\"\n fi\nfi\n", + }, + { + name: "RSR well-known compliance", + run: "MISSING=\"\"\n[ ! -f \".well-known/security.txt\" ] && [ ! -f \"security.txt\" ] && MISSING=\"$MISSING security.txt\"\n[ ! -f \".well-known/ai.txt\" ] && MISSING=\"$MISSING ai.txt\"\n[ ! -f \".well-known/humans.txt\" ] && MISSING=\"$MISSING humans.txt\"\nif [ -n \"$MISSING\" ]; then\n echo \"::warning::Missing RSR recommended files:$MISSING\"\nelse\n echo \"✅ RSR well-known compliant\"\nfi\n", + }, + { + name: "Mixed content check", + run: "MIXED=$(grep -rE 'src=\"http://|href=\"http://' --include=\"*.html\" --include=\"*.htm\" . 2>/dev/null | grep -vE 'localhost|127\\.0\\.0\\.1|example\\.com|lol/|node_modules/|third-party/|vendor/' | head -5)\nif [ -n \"$MIXED\" ]; then\n echo \"::error::Mixed content (HTTP in HTML)\"\n echo \"$MIXED\"\n exit 1\nfi\necho \"✅ No mixed content\"\n", + }, + ], + }, + workflow-lint: { + name: "Workflow security linter", + runs-on: "${{ inputs.runs-on }}", + timeout-minutes: 10, + permissions: { + contents: "read", + }, + steps: [ + { + uses: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", # v7.0.1 + with: { + repository: "${{ github.repository }}", + # Pin to the concrete event SHA (the PR merge commit on + # pull_request, the pushed commit on push). Do NOT use + # `ref: ${{ github.ref }}`: in a reusable workflow called from a + # pull_request, github.ref is the named merge ref + # `refs/pull//merge`, which actions/checkout cannot fetch + # ("couldn't find remote ref refs/pull//merge") — it broke 8/10 + # governance jobs on every PR estate-wide. github.sha resolves to the + # same merge commit but is always fetchable. + ref: "${{ github.sha }}", + }, + }, + { + name: "Checkout the pinned Standards policy helpers", + uses: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", # v7.0.1 + with: { + repository: "hyperpolymath/standards", + ref: "317101e03b8fe642589498f4bdb84541ab466062", + path: ".standards-dupkey", + sparse-checkout: "scripts/check-workflow-duplicate-keys.sh\n", + sparse-checkout-cone-mode: false, + }, + # ⚠ Not fatal if a file is absent, and the next step names precisely + # which one was missing. Formerly this followed `main`, so a rename of + # any helper failed the linter on the one pull request that could not + # possibly be at fault — the new name does not exist on main until that + # very PR merges. An immutable pin removes the race entirely: the names + # at this SHA are fixed. The self-lint fallback in the next step still + # covers standards' own tree, where a rename lands before the bump. + # "Duplicate YAML keys in workflows" below refuses to run (::error:: + + # exit 1) when neither the fetched nor the self-hosted copy of the + # checker script is present, regardless of why the fetch failed. + # hypatia: allow research_extensions/RE005 -- deliberate fail-closed: see comment above. + continue-on-error: true, + }, + { + name: "Duplicate YAML keys in workflows", + run: "# GitHub Actions REJECTS a workflow with duplicate keys: the run is\n# `failure` with no jobs, no log and no check run. Nothing else here\n# can see it, because yaml.safe_load silently keeps the LAST\n# duplicate and reports success — so the file \"parses\" and every\n# other lint passes. Measured 2026-08-05: nine workflows in hypatia\n# were dead this way, including a CodeQL workflow with zero\n# successful runs in its entire lifetime.\nset -euo pipefail\nSCRIPT=\".standards-dupkey/scripts/check-workflow-duplicate-keys.sh\"\n# Self-hosting fallback: when THIS repository is standards, its own\n# working tree already holds the script, and during a rename that copy\n# is the only correct one — the pinned main checkout still has the old\n# name. Preferring the fetched copy keeps every other caller on the\n# canonical version.\nif [ ! -f \"$SCRIPT\" ] && [ -f scripts/check-workflow-duplicate-keys.sh ]; then\n SCRIPT=\"scripts/check-workflow-duplicate-keys.sh\"\n echo \"Using this repository's own copy (standards self-lint).\"\nfi\nif [ ! -f \"$SCRIPT\" ]; then\n echo \"::error::duplicate-key checker not found — neither fetched from\" \\\n \"standards@main nor present locally.\"\n exit 1\nfi\ncp \"$SCRIPT\" \"$RUNNER_TEMP/dupkeys.sh\"\n# NOTE: the actions-lock gate used to be staged here too. It is its\n# own job now (`actions-lock-verify`, its own required context per\n# the 2026-09-02 regularisation spec §6.2) — see that job below.\nrm -rf .standards-dupkey\nbash \"$RUNNER_TEMP/dupkeys.sh\" .github/workflows\n", + }, + { + name: "Check SPDX headers + permissions", + run: "failed=0\n# The permissions verdict is read with yq (YAML-POLICY Y-1): the old\n# `grep -q \"^permissions:\"` FALSELY FAILED every KYAML workflow,\n# whose keys sit indented inside `{ … }`. yq ships on GitHub-hosted\n# Ubuntu; a runner without it (inputs.runs-on) keeps the old line\n# test, which is still right for block-style files, and says so.\nhave_yq=1\ncommand -v yq >/dev/null 2>&1 || {\n have_yq=0\n echo \"::warning::yq not on this runner -- permissions read by line grep; a KYAML workflow will be misreported\"\n}\nfor file in .github/workflows/*.yml .github/workflows/*.yaml; do\n [ -f \"$file\" ] || continue\n # ⚠ SCAN THE HEADER BLOCK, NOT LINE 1. REUSE places the identifier\n # anywhere in a file's leading comment block, and `gh actions-lock`\n # INSERTS `# This workflow is managed by gh actions-lock.` at line 1\n # whenever it mints a lockfile — so a line-1 test fights the estate's\n # own tool and re-fails every time a lockfile is refreshed.\n #\n # Measured 2026-08-07: it reported 27 hypatia workflows and 13 more\n # elsewhere as missing a header they all had, and \"fixing\" that by\n # prepending a default MIS-LICENSED three files (PMPL-1.0-or-later\n # shadowed by MPL-2.0) before it was caught.\n #\n # The leading run of comment lines is read, tolerating a YAML\n # document marker. A licence declared there is declared.\n if ! awk '/^---[[:space:]]*$/ { next } /^#/ { print; next } { exit }' \"$file\" \\\n | grep -q \"^# SPDX-License-Identifier:\"; then\n echo \"ERROR: $file has no SPDX-License-Identifier in its header comment block\"; failed=1\n fi\n if [ \"$have_yq\" -eq 1 ]; then\n if ! verdict=\"$(yq 'has(\"permissions\")' \"$file\" 2>&1)\"; then\n echo \"ERROR: $file is not parseable as YAML: $verdict\"; failed=1\n elif [ \"$verdict\" != \"true\" ]; then\n echo \"ERROR: $file missing top-level 'permissions:' declaration\"; failed=1\n fi\n elif ! grep -q \"^permissions:\" \"$file\"; then\n echo \"ERROR: $file missing top-level 'permissions:' declaration\"; failed=1\n fi\ndone\n[ $failed -eq 1 ] && { echo \"Add SPDX header + permissions:\"; exit 1; }\necho \"All workflows have SPDX headers + permissions\"\n", + }, + # The actions-lock-verify job proves a pin has the right SHAPE. It cannot + # prove the SHA EXISTS — a fabricated 40-hex string passes it. + # Measured 2026-07-28: + # 112 of 613 unique estate pins (18%) do not resolve, in 876 committed + # files. An unresolvable `uses:` yields NO check run, so those repos look + # green while the job never ran. This step closes that gap. + { + name: "Checkout standards for the pin-existence gate", + uses: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", # v7.0.1 + with: { + repository: "hyperpolymath/standards", + # Immutable pin, for the same reason as the policy-helper checkout + # above: following `main` lets one standards push change the + # behaviour of every already-pinned caller with no review. + ref: "317101e03b8fe642589498f4bdb84541ab466062", + path: ".standards-checkout", + persist-credentials: false, + sparse-checkout: "scripts\n", + sparse-checkout-cone-mode: false, + }, + }, + { + name: "Check action pins resolve upstream", + env: { + GH_TOKEN: "${{ github.token }}", + }, + run: "# Copy out and delete the checkout before scanning, so the standards\n# tree is never part of the caller's workspace (same idiom as the\n# allowlist preflight above).\ncp .standards-checkout/scripts/check-action-pins-resolve.sh \"$RUNNER_TEMP/\"\nrm -rf .standards-checkout\nbash \"$RUNNER_TEMP/check-action-pins-resolve.sh\" .\n", + }, + { + name: "Check for duplicate workflows", + run: "if [ -f .github/workflows/codeql.yml ] && [ -f .github/workflows/codeql-analysis.yml ]; then\n echo \"ERROR: Duplicate CodeQL workflows found\"; exit 1\nfi\necho \"No critical duplicates found\"\n", + }, + ], + }, + actions-lock-verify: { + name: "Actions lockfile verify", + runs-on: "${{ inputs.runs-on }}", + timeout-minutes: 10, + permissions: { + contents: "read", + }, + steps: [ + { + name: "Checkout caller repository", + uses: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", # v7.0.1 + with: { + repository: "${{ github.repository }}", + ref: "${{ github.sha }}", + }, + }, + { + name: "Checkout standards for the lock gate", + uses: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", # v7.0.1 + with: { + repository: "hyperpolymath/standards", + # The reusable's OWN commit — the one the caller's `uses:` ref + # resolved to — so the lock tooling is exactly what the caller + # pinned, never older and never following `main`. This replaced a + # hardcoded third pin that was one change behind by construction and + # went stale twice (standards#946, then #1119); + # scripts/check-lock-gate-pin-freshness.sh accepts only this + # expression or a fresh 40-hex SHA. + ref: "${{ job.workflow_sha }}", + path: ".standards-lock", + persist-credentials: false, + sparse-checkout: "scripts/check-actions-lock-gate.sh\nscripts/update-actions-lock.sh\n.machine_readable/lock-allow.txt\n", + sparse-checkout-cone-mode: false, + }, + }, + { + name: "Check locked or SHA-pinned actions", + env: { + GH_TOKEN: "${{ github.token }}", + }, + run: "set -uo pipefail\n# Stage the gate, the verifier and the exemption ledger. When THIS\n# repository is standards, its own working tree already holds all\n# three (self-lint); every other caller uses the pinned checkout.\nif [ \"$GITHUB_REPOSITORY\" = hyperpolymath/standards ]; then\n SRC=scripts\n LEDGERSRC=.machine_readable\n echo \"Using this repository's own gate + verifier + ledger (standards self-lint).\"\nelse\n SRC=.standards-lock/scripts\n LEDGERSRC=.standards-lock/.machine_readable\nfi\nfor f in check-actions-lock-gate.sh update-actions-lock.sh; do\n if [ ! -f \"$SRC/$f\" ]; then\n echo \"::error::actions-lock gate: $f not found in $SRC (pinned standards checkout failed?)\"\n exit 1\n fi\n cp \"$SRC/$f\" \"$RUNNER_TEMP/$f\"\ndone\n# A MISSING ledger must not silently exempt everyone, nor silently\n# exempt no one. Stage an empty file and let the gate below print\n# the denominator it actually read — a skip is not a pass.\nif [ -f \"$LEDGERSRC/lock-allow.txt\" ]; then\n cp \"$LEDGERSRC/lock-allow.txt\" \"$RUNNER_TEMP/lock-allow.txt\"\nelse\n echo \"::warning::actions-lock exemption ledger not found; treating it as EMPTY (no repo is exempt).\"\n : > \"$RUNNER_TEMP/lock-allow.txt\"\nfi\nrm -rf .standards-lock\n\n# ONE implementation of the lock/pin predicate, not two.\n#\n# The gate used to live inside workflow-lint with its own inline copy\n# of the logic. That copy matched `^[[:space:]]+uses:` — with no\n# `-?` — so it never saw `- uses: foo@v1`, the commonest step form,\n# and its green was therefore partly vacuous.\n# scripts/check-actions-lock-gate.sh uses\n# `^[[:space:]]+-?[[:space:]]*uses:` and is the tested implementation\n# (scripts/tests/check-actions-lock-gate-test.sh, 11/11).\n#\n# actions.lock is the authoritative immutable resolution for both\n# direct actions and their transitive dependencies. Do not also\n# rewrite direct refs to raw SHAs: gh actions-lock omits refs that\n# no tag or branch contains, and GitHub then rejects the workflow\n# at startup. Measured in oikosbot PR #78 on 2026-08-29: five\n# previously executable workflows became startup_failure after the\n# redundant direct-SHA conversion; restoring their locked version\n# refs made GitHub's native resolver accept them again.\nif [ -f .github/workflows/actions.lock ]; then\n # Pinned: an unpinned install let a new verifier release change every\n # caller's verdict overnight. Bump deliberately, with the gate tests.\n gh extension install github/gh-actions-lock --pin v0.1.6\nfi\n\n# The gate delegates lockfile verification to the authoritative\n# verifier, staged into RUNNER_TEMP above.\nexport ACTIONS_LOCK_VERIFIER=\"$RUNNER_TEMP/update-actions-lock.sh\"\nbash \"$RUNNER_TEMP/check-actions-lock-gate.sh\"\nrc=$?\n\n# Shrink-only exemption ledger (.machine_readable/lock-allow.txt in\n# standards, guarded by scripts/check-exemption-ratchet.sh so it can\n# never grow without a `Ratchet-exception` trailer).\n#\n# Why a ledger and not a later date: the gate compares `date -u +%F`\n# against ENFORCE_ACTIONS_LOCK_FROM at RUNTIME, inside an artefact\n# that callers have already pinned by SHA. A date therefore fires\n# simultaneously across every bumped caller with no human action and\n# cannot be moved without re-bumping every pin. Measured 2026-09-22\n# (complete n=368 census of governance callers, `gh api` on default\n# branches): 200 carry actions.lock, 163 do not, 5 have no workflows\n# directory. The ledger makes that 163 an explicit, shrinking debt\n# instead of a cliff.\ntotal=$(grep -cve '^[[:space:]]*$' -e '^[[:space:]]*#' \"$RUNNER_TEMP/lock-allow.txt\" || [ $? -eq 1 ])\n: \"${total:=0}\"\n# The ledger excuses missing-lock debt ONLY (gate exit 3: lockless,\n# every ref pinned, grace window closed). Exit 1 is a LIVE\n# VIOLATION — unpinned refs or a verifier-rejected lock — and exit\n# 2 is infrastructure failure; neither is ledgerable, so `-ne 0`\n# here would wave real findings through with the debt.\nif [ \"$rc\" -eq 3 ] && grep -qxF \"$GITHUB_REPOSITORY\" \"$RUNNER_TEMP/lock-allow.txt\"; then\n echo \"::notice::actions-lock debt is LEDGERED for $GITHUB_REPOSITORY (missing-lock only, gate exit 3).\"\n echo \"lock debt: $GITHUB_REPOSITORY is 1 of $total ledgered repositories.\"\n echo \"This exemption is shrink-only. Run scripts/update-actions-lock.sh, commit\"\n echo \"the lockfile, and delete this repository's line from\"\n echo \".machine_readable/lock-allow.txt in hyperpolymath/standards.\"\n exit 0\nfi\n# Never a silent pass: state the denominator even when clean.\necho \"lock debt: $total ledgered repositories; $GITHUB_REPOSITORY is NOT among them.\"\nexit \"$rc\"\n", + }, + ], + }, + trusted-base: { + name: "Trusted-base reduction policy", + runs-on: "${{ inputs.runs-on }}", + timeout-minutes: 10, + permissions: { + contents: "read", + }, + steps: [ + { + name: "Checkout caller repository", + uses: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", # v7.0.1 + with: { + repository: "${{ github.repository }}", + # Pin to the concrete event SHA (the PR merge commit on + # pull_request, the pushed commit on push). Do NOT use + # `ref: ${{ github.ref }}`: in a reusable workflow called from a + # pull_request, github.ref is the named merge ref + # `refs/pull//merge`, which actions/checkout cannot fetch + # ("couldn't find remote ref refs/pull//merge") — it broke 8/10 + # governance jobs on every PR estate-wide. github.sha resolves to the + # same merge commit but is always fetchable. + ref: "${{ github.sha }}", + path: "caller", + }, + }, + { + name: "Checkout standards (for the check script)", + uses: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", # v7.0.1 + with: { + repository: "hyperpolymath/standards", + ref: "${{ job.workflow_sha }}", + path: "standards", + }, + }, + { + name: "Run trusted-base check on caller repo", + run: "bash standards/scripts/check-trusted-base.sh caller\n", + }, + ], + }, + licence-consistency: { + timeout-minutes: 10, + name: "Licence consistency", + runs-on: "${{ inputs.runs-on }}", + permissions: { + contents: "read", + }, + steps: [ + { + name: "Checkout caller repository", + uses: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", # v7.0.1 + with: { + repository: "${{ github.repository }}", + # Pin to the concrete event SHA (the PR merge commit on + # pull_request, the pushed commit on push). Do NOT use + # `ref: ${{ github.ref }}`: in a reusable workflow called from a + # pull_request, github.ref is the named merge ref + # `refs/pull//merge`, which actions/checkout cannot fetch + # ("couldn't find remote ref refs/pull//merge") — it broke 8/10 + # governance jobs on every PR estate-wide. github.sha resolves to the + # same merge commit but is always fetchable. + ref: "${{ github.sha }}", + path: "caller", + }, + }, + { + name: "Checkout standards (for the check script)", + uses: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", # v7.0.1 + with: { + repository: "hyperpolymath/standards", + ref: "${{ job.workflow_sha }}", + path: "standards", + }, + }, + { + name: "Run licence-consistency check on caller repo", + run: "bash standards/scripts/check-licence-consistency.sh caller\n", + }, + ], + }, + exemption-ratchet: { + name: "Exemption ratchet", + runs-on: "${{ inputs.runs-on }}", + timeout-minutes: 5, + permissions: { + contents: "read", + }, + # Only meaningful on a pull request: the check compares the ledgers against + # a BASE. On push there is nothing to compare to, and a push-time run would + # either be vacuous or compare against an arbitrary predecessor. + if: "${{ github.event_name == 'pull_request' }}", + steps: [ + { + uses: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", # v7.0.1 + with: { + repository: "${{ github.repository }}", + ref: "${{ github.sha }}", + # Full history: the ratchet reads the ledgers at the base ref and + # scans commit messages between base and HEAD for a declared + # exception. A shallow clone has neither. + fetch-depth: 0, + }, + }, + { + name: "Check out standards repo for shared scripts", + uses: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", # v7.0.1 + with: { + repository: "hyperpolymath/standards", + ref: "${{ job.workflow_sha }}", + path: ".standards-checkout", + sparse-checkout: "scripts\n", + sparse-checkout-cone-mode: false, + }, + }, + { + name: "Exemption ratchet", + run: "set -euo pipefail\n# Stage the script OUT of the scanned tree and delete the checkout,\n# so the ratchet only ever reads the CALLER's ledgers — standards has\n# ledgers of its own and they are not this repository's.\n# Both files: the ratchet calls count-ledger-entries.sh as a sibling,\n# and it has no fallback if the counter is missing — deliberately.\ncp .standards-checkout/scripts/check-exemption-ratchet.sh \\\n .standards-checkout/scripts/count-ledger-entries.sh \"$RUNNER_TEMP/\"\nrm -rf .standards-checkout\nbash \"$RUNNER_TEMP/check-exemption-ratchet.sh\" \\\n \"${{ github.event.pull_request.base.sha }}\"\n", + }, + ], + }, + debt-ratchet: { + name: "Debt ratchet", + runs-on: "${{ inputs.runs-on }}", + timeout-minutes: 5, + permissions: { + contents: "read", + }, + # Sibling to exemption-ratchet, and pull-request-only for the same reason: + # the check compares ceilings against a BASE. The two govern different + # populations — the exemption ratchet watches debt you have EXCUSED, this + # one watches debt you have MEASURED. + if: "${{ github.event_name == 'pull_request' }}", + steps: [ + { + uses: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", # v7.0.1 + with: { + repository: "${{ github.repository }}", + ref: "${{ github.sha }}", + # Full history: the ratchet reads the Debtfile at the base ref and + # scans commit messages between base and HEAD for a declared + # exception. A shallow clone has neither. + fetch-depth: 0, + }, + }, + { + name: "Check out standards repo for shared scripts", + uses: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", # v7.0.1 + with: { + repository: "hyperpolymath/standards", + ref: "${{ job.workflow_sha }}", + path: ".standards-checkout", + sparse-checkout: "scripts\n", + sparse-checkout-cone-mode: false, + }, + }, + { + name: "Debt ratchet", + run: "set -euo pipefail\n# Which copy of the scripts do we run?\n#\n# ⚠ BOOTSTRAP TRAP. A gate that fetches its own script from `main`\n# cannot run on the pull request that INTRODUCES that script — the\n# sparse checkout of main has no such file and the `cp` fails. This\n# job failed exactly that way on the PR that added it, and the same\n# shape has bitten hypatia's self-gating before.\n#\n# So when the repository under test IS standards, run the scripts\n# from the PR's own tree. The guard is on the repository name and\n# not on file existence: a consumer repo that happened to contain a\n# file at scripts/check-debt-ratchet.sh must NOT be able to\n# substitute its own gate.\nif [ \"${{ github.repository }}\" = \"hyperpolymath/standards\" ]; then\n cp scripts/check-debt-ratchet.sh \\\n scripts/check-debtfile-structure.sh \"$RUNNER_TEMP/\"\nelse\n cp .standards-checkout/scripts/check-debt-ratchet.sh \\\n .standards-checkout/scripts/check-debtfile-structure.sh \"$RUNNER_TEMP/\"\nfi\n# Stage the scripts OUT of the scanned tree and delete the checkout,\n# so the ratchet only ever reads the CALLER's Debtfile — standards\n# has one of its own and it is not this repository's.\nrm -rf .standards-checkout\n\n# A repo with no Debtfile is not in violation — adoption is opt-in.\n# But a repo that HAS one must have a well-formed one, or the ratchet\n# would be comparing ceilings it could not parse.\nif [ -f .machine_readable/Debtfile.a2ml ]; then\n bash \"$RUNNER_TEMP/check-debtfile-structure.sh\"\nfi\nbash \"$RUNNER_TEMP/check-debt-ratchet.sh\" \\\n \"${{ github.event.pull_request.base.sha }}\"\n", + }, + ], + }, + }, +} diff --git a/.github/workflows/governance.yml b/.github/workflows/governance.yml index c2945c45e..e13da24f8 100644 --- a/.github/workflows/governance.yml +++ b/.github/workflows/governance.yml @@ -1,20 +1,31 @@ # This workflow is managed by gh actions-lock. # SPDX-License-Identifier: MPL-2.0 # This workflow is managed by gh actions-lock. -name: Governance - -on: - push: - branches: [main, master] - pull_request: - branches: [main, master] - workflow_dispatch: - -permissions: - actions: read - contents: read - -jobs: - governance: - uses: ./.github/workflows/governance-reusable.yml - secrets: inherit +{ + name: "Governance", + on: { + push: { + branches: [ + "main", + "master", + ], + }, + pull_request: { + branches: [ + "main", + "master", + ], + }, + workflow_dispatch: null, + }, + permissions: { + actions: "read", + contents: "read", + }, + jobs: { + governance: { + uses: "./.github/workflows/governance-reusable.yml", + secrets: "inherit", + }, + }, +} diff --git a/.github/workflows/hypatia-scan-reusable.yml b/.github/workflows/hypatia-scan-reusable.yml index a3e8ebd1a..69203e7e1 100644 --- a/.github/workflows/hypatia-scan-reusable.yml +++ b/.github/workflows/hypatia-scan-reusable.yml @@ -2,380 +2,194 @@ # SPDX-License-Identifier: MPL-2.0 # This workflow is managed by gh actions-lock. # Hypatia Neurosymbolic CI/CD Security Scan — Reusable Workflow -name: Hypatia Reusable Scan - -on: - workflow_call: - inputs: - block-on-high: - description: Refuse current high or critical findings after validated baseline filtering; no baseline is required - type: boolean - required: false - default: false - hypatia-ref: - description: >- - Hypatia commit to build the scanner from. Defaults to a vetted SHA so a - bad hypatia main cannot turn every caller red at once (09-12..14 and - 09-26..30 outages). Canary callers pass "HEAD" to trial the tip before - the default is moved here. - type: string - required: false - default: 51ab6496bf47e30a0576d503df31fec30f3cfe56 - -permissions: - actions: read - contents: read - security-events: write - -jobs: - scan: - name: Hypatia Neurosymbolic Analysis - runs-on: ubuntu-latest - timeout-minutes: 30 - steps: - - name: Checkout repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - fetch-depth: 0 - persist-credentials: false - - - name: Setup Elixir for Hypatia scanner - uses: erlef/setup-beam@54075bcc5e249e4758d363f27d099f55d843f124 # v1.24.1 - with: - elixir-version: '1.19.4' - otp-version: '28.3' - - - name: Resolve Hypatia scanner commit - id: hypatia-rev - env: - HYPATIA_REF: ${{ inputs.hypatia-ref }} - run: | - # The scanner is PINNED (input hypatia-ref, default a vetted SHA). Only - # an explicit "HEAD" floats, for canary callers. Resolved before the - # cache step because the cache key must hash the commit. - if [ "$HYPATIA_REF" = "HEAD" ]; then - sha=$(git ls-remote https://github.com/hyperpolymath/hypatia.git HEAD | cut -f1) - else - sha="$HYPATIA_REF" - fi - if [[ ! "$sha" =~ ^[0-9a-f]{40}$ ]]; then - echo "ERROR: hypatia-ref must be a 40-hex commit SHA or HEAD (got: $HYPATIA_REF)" >&2 - exit 1 - fi - echo "sha=$sha" >> "$GITHUB_OUTPUT" - echo "Resolved hypatia HEAD: $sha" - - - name: Cache Hex/Mix and Scanner Build - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: | - ~/.mix - ~/.hex - ~/hypatia - # Source-pinned key: when Hypatia main advances, the SHA changes, the - # cache misses, and the clone + escript build below re-run instead of - # restoring a stale binary. The old keyless `...-build` key meant the - # first scanner ever cached was reused forever, so scanner fixes (e.g. - # SD022, hypatia#545) never reached CI. No restore-keys on purpose — - # a partial restore would repopulate ~/hypatia and the guards below - # would then skip the rebuild, reintroducing the staleness. - key: hypatia-scanner-v4-${{ runner.os }}-${{ steps.hypatia-rev.outputs.sha }} - - - name: Check out resolved Hypatia commit - env: - HYPATIA_SHA: ${{ steps.hypatia-rev.outputs.sha }} - run: | - set -euo pipefail - if [ ! -d "$HOME/hypatia" ]; then - git init "$HOME/hypatia" - git -C "$HOME/hypatia" remote add origin https://github.com/hyperpolymath/hypatia.git - git -C "$HOME/hypatia" fetch --depth 1 origin "$HYPATIA_SHA" - git -C "$HOME/hypatia" checkout --detach FETCH_HEAD - fi - # A cache is usable only when its source matches the key, including - # on cache hits. v4 invalidates caches populated by the moving clone. - ACTUAL_SHA=$(git -C "$HOME/hypatia" rev-parse HEAD) - if [ "$ACTUAL_SHA" != "$HYPATIA_SHA" ]; then - echo "::error::Hypatia cached source does not match the resolved commit" - exit 1 - fi - - - name: Build Hypatia scanner - run: | - cd "$HOME/hypatia" - if [ ! -x hypatia ]; then - if ! (mix deps.get && mix escript.build); then - echo "::error::Hypatia scanner build failed at commit $(git rev-parse HEAD) — see upstream hyperpolymath/hypatia" - exit 1 - fi - fi - if [ ! -x hypatia ]; then - echo "::error::Hypatia scanner binary is missing after build" - exit 1 - fi - - - name: Run Hypatia scan - env: - # The scanner reads a single env var, GITHUB_TOKEN, to pull Dependabot, - # code-scanning and secret-scanning alerts (lib/rules/{dependabot, - # code_scanning,secret_scanning}_alerts.ex). Without it those three - # checks are skipped with "GITHUB_TOKEN not set" warnings. - # - # Prefer a fine-grained, read-only PAT (HYPATIA_SCAN_PAT, inherited as - # an org secret via the caller's `secrets: inherit`): the built-in - # Actions token CANNOT read Dependabot alerts (no `dependabot` - # permission exists for it). Fall back to the built-in token so - # code-scanning still works in repos where the PAT isn't present yet. - GITHUB_TOKEN: ${{ secrets.HYPATIA_SCAN_PAT || secrets.GITHUB_TOKEN }} - run: | - set -euo pipefail - echo "Scanning repository: ${GITHUB_REPOSITORY:-}" - # --exit-zero: hypatia-cli exits 1 when findings exist; under the default - # `bash -eo pipefail` that aborts this step before the counts/outputs/summary - # run AND skips the upload, so the gate fails opaquely. Gate on the severity - # counts below, not on the scanner's exit code. - HYPATIA_FORMAT=json "$HOME/hypatia/hypatia-cli.sh" scan . --exit-zero > hypatia-findings.json - - # Emit SARIF for the GitHub Security tab. The code_scanning_alerts - # meta-rules are filtered at SARIF render time (lib/hypatia/sarif.ex), - # so an upload can never self-echo. Same tool name ("Hypatia") and - # category ("hypatia") as the historical upload, so GitHub RECONCILES: - # findings fixed in code since the last scan auto-close instead of - # orphaning as stale open alerts. - HYPATIA_FORMAT=sarif "$HOME/hypatia/hypatia-cli.sh" scan . --exit-zero > hypatia.sarif - if [ ! -s hypatia.sarif ]; then - echo "::error::Hypatia did not produce hypatia.sarif" - exit 2 - fi - - - name: Validate findings and count severities - id: scan - run: | - set -euo pipefail - # Exactly one JSON array of findings, each with a recognised severity. - # Missing/truncated/multi-document output is a scanner error and fails - # closed; a single well-formed `[]` is a valid clean scan when the - # scanner step succeeded (standards#1054). - if [ ! -s hypatia-findings.json ] || ! jq -e -s ' - length == 1 and (.[0] | type == "array" and all(.[]; - type == "object" and (.severity as $s | - ["critical", "high", "medium", "warn", "low", "info", "informational"] | index($s) != null))) - ' hypatia-findings.json >/dev/null; then - echo "::error::Hypatia did not produce one valid findings array" - exit 2 - fi - - FINDING_COUNT=$(jq '. | length' hypatia-findings.json) - CRITICAL=$(jq '[.[] | select(.severity == "critical")] | length' hypatia-findings.json) - HIGH=$(jq '[.[] | select(.severity == "high")] | length' hypatia-findings.json) - # Hypatia's research rules use "warn" at the same rank as "medium". - MEDIUM=$(jq '[.[] | select(.severity == "medium" or .severity == "warn")] | length' hypatia-findings.json) - - { - echo "findings_count=$FINDING_COUNT" - echo "critical=$CRITICAL" - echo "high=$HIGH" - echo "medium=$MEDIUM" - } >> "$GITHUB_OUTPUT" - - { - echo "## Hypatia Scan Results" - echo "" - echo "| Severity | Count |" - echo "|----------|-------|" - echo "| Critical | $CRITICAL |" - echo "| High | $HIGH |" - echo "| Medium | $MEDIUM |" - echo "| **Total**| $FINDING_COUNT |" - } >> "$GITHUB_STEP_SUMMARY" - - - name: Relativize finding paths - run: | - # Hoisted out of the gate step so the SARIF filter and the gate share - # ONE definition of a finding's path. code_safety / honest_completion - # emit ABSOLUTE host paths in .file (Path.expand(repo)) while the - # baseline uses repo-relative paths; without this they never match - # (hypatia#566 stopgap). - if [ -f hypatia-findings.json ]; then - jq --arg root "$PWD" ' - map(if (.file? // "") == $root then .file = "." - elif (.file? // "") | startswith($root + "/") - then .file = (.file | ltrimstr($root + "/")) - else . end) - ' hypatia-findings.json > hypatia-findings.relativized.json - fi - - - name: Check out standards for the SARIF baseline filter - id: fetch_filter - # ⚠ continue-on-error is on the CHECKOUT, never on the check. If the - # fetch fails the next step still runs and says so; putting the flag on - # the filter itself would produce exactly the fake gate this estate - # keeps finding. Pinned to main because github.workflow_sha resolves to - # the CALLER's SHA, which would 404 here. - # advisory-by-design, not fail-closed: this checkout fails OPEN. - # "Filter SARIF through the baseline before upload" below falls back - # to uploading the SARIF UNFILTERED when the fetched filter script is - # unavailable — an unfiltered upload can only show more alerts, never - # hide real ones. - # hypatia: allow research_extensions/RE005 -- advisory-by-design, fails OPEN; see comment above. - continue-on-error: true - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: hyperpolymath/standards - persist-credentials: false - # Includes the filter's completion output and literal-glob matching. - # Keep the helper immutable and compatible with the strict gate. - ref: 6b0bfce648cf9b306693cea0af0bd5f6fc9fea0f - path: .standards-checkout - sparse-checkout: | - scripts - sparse-checkout-cone-mode: false - - - name: Filter SARIF through the baseline before upload - id: filter_baseline - run: | - # ⚠ WITHOUT THIS, ACKNOWLEDGING A FINDING DOES NOT UNBLOCK ANYTHING. - # A finding travelled two paths that never met: the gate filtered - # findings.json through the baseline, while the SARIF went to code - # scanning UNFILTERED. The `code_scanning` ruleset rule blocks on - # those alerts and has no knowledge of the baseline, so a pull - # request stayed BLOCKED with every required check green and nothing - # to point at. - # - # Measured 2026-08-07: 147 open pull requests across 91 repositories - # were held this way by 1,777 alerts, ZERO of which were introduced - # by the pull request they blocked. - # - # The filter FAILS OPEN in every error path — an unfiltered upload is - # a safe failure, a silently over-filtered one hides real alerts. - if [ -f .standards-checkout/scripts/filter-sarif-by-baseline.sh ]; then - FILTER=.standards-checkout/scripts/filter-sarif-by-baseline.sh - elif [ -f scripts/filter-sarif-by-baseline.sh ]; then - FILTER=scripts/filter-sarif-by-baseline.sh - else - echo " filter script unavailable — SARIF uploaded unfiltered"; exit 0 - fi - # Use the CALLER's apply-baseline.sh — the same copy the gate runs, - # so the SARIF and the gate cannot disagree about what is acknowledged. - APPLY_BASELINE=scripts/apply-baseline.sh \ - bash "$FILTER" hypatia.sarif hypatia-findings.relativized.json .hypatia-baseline.json - - - name: Upload SARIF to code scanning - # NOTE: this does NOT make under-permissioned callers "skip gracefully". - # This workflow declares `security-events: write` at the top, so a - # caller granting only `read` is rejected at startup and NO step of - # this job ever executes — `continue-on-error` cannot rescue a job - # that was never created. Callers MUST grant `security-events: write`. - # This flag only tolerates a genuine upload failure (e.g. Advanced - # Security disabled on a private repo) once the job is actually running. - continue-on-error: true - uses: github/codeql-action/upload-sarif@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0 (4.38.1 blocked estate-wide; nexia-list#100) - with: - sarif_file: hypatia.sarif - category: hypatia - - - name: Run panic-attack assail - run: | - if command -v panic-attack >/dev/null 2>&1; then - panic-attack assail . > panic-attack-findings.json 2>&1 || true - echo "panic-attack scan complete" - else - echo "panic-attack not available in CI — skipping" - echo "[]" > panic-attack-findings.json - fi - - - name: Upload findings artifacts - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: hypatia-scan-findings - path: | - hypatia-findings.json - panic-attack-findings.json - retention-days: 90 - - - name: Gate on baseline (blocking when a baseline is committed) - if: '!inputs.block-on-high && steps.scan.outputs.findings_count > 0' - run: | - # Wave-8 gate promotion: when the calling repo commits a - # .hypatia-baseline.json (schema: .machine_readable/hypatia-baseline. - # schema.json) AND ships scripts/apply-baseline.sh, findings are - # filtered against the baseline and any UNBASELINED finding at or - # above the blocking threshold FAILS this job. Repos without a - # baseline keep the honest ADVISORY behaviour below — the gate arms - # itself the moment a baseline lands (standards#399/#437/#446). - if [ -f scripts/apply-baseline.sh ] && [ -f .hypatia-baseline.json ]; then - echo "Baseline present — running BLOCKING gate (threshold: high)." - # Paths were relativized in an earlier step so the SARIF filter and - # this gate agree on what a finding's path is. Two definitions of - # "the same finding" is how the baseline came to mean one thing to - # the gate and nothing to the security tab. - bash scripts/apply-baseline.sh hypatia-findings.relativized.json .hypatia-baseline.json blocking - else - echo "No committed baseline — gate stays advisory (see next step)." - fi - - - name: Block high and critical findings when requested - id: blocking-findings - if: inputs.block-on-high - env: - HYPATIA_BASELINE_FILTERED: ${{ steps.filter_baseline.outputs.filtered }} - run: | - set -euo pipefail - # Use the scanner's authoritative SARIF projection, after the same - # baseline filter used for the Security tab. Raw JSON also contains - # code_scanning_alerts: historical GitHub alert summaries, including - # alerts fixed by this PR that cannot close until it reaches main. - # Hypatia already excludes those meta-rules in its SARIF renderer. - # Counting them here creates a circular merge dependency. Raw JSON - # remains in the artifact for the fleet's historical-debt review. - # Validate a present baseline even when there are no current errors. - # Advisory mode validates without gating historical meta-findings; - # the final filtered SARIF below supplies the blocking decision. - if [ -f .hypatia-baseline.json ]; then - if [ ! -f scripts/apply-baseline.sh ]; then - echo "::error::A committed baseline requires scripts/apply-baseline.sh" - exit 2 - fi - bash scripts/apply-baseline.sh hypatia-findings.relativized.json .hypatia-baseline.json advisory >/dev/null - if [ "${HYPATIA_BASELINE_FILTERED:-}" != "true" ]; then - echo "::error::Baseline validation passed but SARIF filtering did not complete" - exit 2 - fi - fi - # Missing/truncated output must never become a clean security gate. - if ! jq -se ' - length == 1 and (.[0] | - .version == "2.1.0" and (.runs | type == "array" and length > 0) and - all(.runs[]; - .tool.driver.name == "Hypatia" and - (.results | type == "array") and - all(.results[]; - type == "object" and (.ruleId | type == "string" and length > 0) and - (.level as $level | ["error", "warning", "note", "none"] | index($level) != null)))) - ' hypatia.sarif >/dev/null; then - echo "::error::Hypatia did not produce one valid SARIF findings document" - exit 2 - fi - count=$(jq '[.runs[].results[] | select(.level == "error")] | length' hypatia.sarif) - if [ "$count" -gt 0 ]; then - echo "::error::Hypatia found $count current high or critical finding(s); see the SARIF artifact" - exit 1 - fi - echo "Hypatia blocking gate: no current high or critical findings after baseline filtering" - - - name: Check for critical issues (ADVISORY — does not gate) - if: '!inputs.block-on-high && steps.scan.outputs.critical > 0' - run: | - # This scan is ADVISORY / fix-forward WHEN NO BASELINE IS COMMITTED - # (the step above becomes the blocking gate once .hypatia-baseline.json - # lands). The label and summary state that explicitly so a green check - # carrying critical findings is not mistaken for "no critical findings". - echo "::warning title=Hypatia (ADVISORY, non-blocking)::${{ steps.scan.outputs.critical }} critical finding(s). This scan does not gate — review hypatia-findings.json and fix forward." - { - echo "### Hypatia scan — ADVISORY (does not gate)" - echo "" - echo "**${{ steps.scan.outputs.critical }} critical finding(s)** reported." - echo "This scan is fix-forward and **never fails the build**; a green" - echo "check here does not mean zero critical findings. See the" - echo "\`hypatia-scan-findings\` artifact. Blocking promotion: standards#399/#437." - } >> "$GITHUB_STEP_SUMMARY" +{ + name: "Hypatia Reusable Scan", + on: { + workflow_call: { + inputs: { + block-on-high: { + description: "Refuse current high or critical findings after validated baseline filtering; no baseline is required", + type: "boolean", + required: false, + default: false, + }, + hypatia-ref: { + description: "Hypatia commit to build the scanner from. Defaults to a vetted SHA so a bad hypatia main cannot turn every caller red at once (09-12..14 and 09-26..30 outages). Canary callers pass \"HEAD\" to trial the tip before the default is moved here.", + type: "string", + required: false, + default: "51ab6496bf47e30a0576d503df31fec30f3cfe56", + }, + }, + }, + }, + permissions: { + actions: "read", + contents: "read", + security-events: "write", + }, + jobs: { + scan: { + name: "Hypatia Neurosymbolic Analysis", + runs-on: "ubuntu-latest", + timeout-minutes: 30, + steps: [ + { + name: "Checkout repository", + uses: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", # v7.0.1 + with: { + fetch-depth: 0, + persist-credentials: false, + }, + }, + { + name: "Setup Elixir for Hypatia scanner", + uses: "erlef/setup-beam@54075bcc5e249e4758d363f27d099f55d843f124", # v1.24.1 + with: { + elixir-version: "1.19.4", + otp-version: "28.3", + }, + }, + { + name: "Resolve Hypatia scanner commit", + id: "hypatia-rev", + env: { + HYPATIA_REF: "${{ inputs.hypatia-ref }}", + }, + run: "# The scanner is PINNED (input hypatia-ref, default a vetted SHA). Only\n# an explicit \"HEAD\" floats, for canary callers. Resolved before the\n# cache step because the cache key must hash the commit.\nif [ \"$HYPATIA_REF\" = \"HEAD\" ]; then\n sha=$(git ls-remote https://github.com/hyperpolymath/hypatia.git HEAD | cut -f1)\nelse\n sha=\"$HYPATIA_REF\"\nfi\nif [[ ! \"$sha\" =~ ^[0-9a-f]{40}$ ]]; then\n echo \"ERROR: hypatia-ref must be a 40-hex commit SHA or HEAD (got: $HYPATIA_REF)\" >&2\n exit 1\nfi\necho \"sha=$sha\" >> \"$GITHUB_OUTPUT\"\necho \"Resolved hypatia HEAD: $sha\"\n", + }, + { + name: "Cache Hex/Mix and Scanner Build", + uses: "actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9", # v6.1.0 + with: { + path: "~/.mix\n~/.hex\n~/hypatia\n", + # Source-pinned key: when Hypatia main advances, the SHA changes, the + # cache misses, and the clone + escript build below re-run instead of + # restoring a stale binary. The old keyless `...-build` key meant the + # first scanner ever cached was reused forever, so scanner fixes (e.g. + # SD022, hypatia#545) never reached CI. No restore-keys on purpose — + # a partial restore would repopulate ~/hypatia and the guards below + # would then skip the rebuild, reintroducing the staleness. + key: "hypatia-scanner-v4-${{ runner.os }}-${{ steps.hypatia-rev.outputs.sha }}", + }, + }, + { + name: "Check out resolved Hypatia commit", + env: { + HYPATIA_SHA: "${{ steps.hypatia-rev.outputs.sha }}", + }, + run: "set -euo pipefail\nif [ ! -d \"$HOME/hypatia\" ]; then\n git init \"$HOME/hypatia\"\n git -C \"$HOME/hypatia\" remote add origin https://github.com/hyperpolymath/hypatia.git\n git -C \"$HOME/hypatia\" fetch --depth 1 origin \"$HYPATIA_SHA\"\n git -C \"$HOME/hypatia\" checkout --detach FETCH_HEAD\nfi\n# A cache is usable only when its source matches the key, including\n# on cache hits. v4 invalidates caches populated by the moving clone.\nACTUAL_SHA=$(git -C \"$HOME/hypatia\" rev-parse HEAD)\nif [ \"$ACTUAL_SHA\" != \"$HYPATIA_SHA\" ]; then\n echo \"::error::Hypatia cached source does not match the resolved commit\"\n exit 1\nfi\n", + }, + { + name: "Build Hypatia scanner", + run: "cd \"$HOME/hypatia\"\nif [ ! -x hypatia ]; then\n if ! (mix deps.get && mix escript.build); then\n echo \"::error::Hypatia scanner build failed at commit $(git rev-parse HEAD) — see upstream hyperpolymath/hypatia\"\n exit 1\n fi\nfi\nif [ ! -x hypatia ]; then\n echo \"::error::Hypatia scanner binary is missing after build\"\n exit 1\nfi\n", + }, + { + name: "Run Hypatia scan", + env: { + # The scanner reads a single env var, GITHUB_TOKEN, to pull Dependabot, + # code-scanning and secret-scanning alerts (lib/rules/{dependabot, + # code_scanning,secret_scanning}_alerts.ex). Without it those three + # checks are skipped with "GITHUB_TOKEN not set" warnings. + # + # Prefer a fine-grained, read-only PAT (HYPATIA_SCAN_PAT, inherited as + # an org secret via the caller's `secrets: inherit`): the built-in + # Actions token CANNOT read Dependabot alerts (no `dependabot` + # permission exists for it). Fall back to the built-in token so + # code-scanning still works in repos where the PAT isn't present yet. + GITHUB_TOKEN: "${{ secrets.HYPATIA_SCAN_PAT || secrets.GITHUB_TOKEN }}", + }, + run: "set -euo pipefail\necho \"Scanning repository: ${GITHUB_REPOSITORY:-}\"\n# --exit-zero: hypatia-cli exits 1 when findings exist; under the default\n# `bash -eo pipefail` that aborts this step before the counts/outputs/summary\n# run AND skips the upload, so the gate fails opaquely. Gate on the severity\n# counts below, not on the scanner's exit code.\nHYPATIA_FORMAT=json \"$HOME/hypatia/hypatia-cli.sh\" scan . --exit-zero > hypatia-findings.json\n\n# Emit SARIF for the GitHub Security tab. The code_scanning_alerts\n# meta-rules are filtered at SARIF render time (lib/hypatia/sarif.ex),\n# so an upload can never self-echo. Same tool name (\"Hypatia\") and\n# category (\"hypatia\") as the historical upload, so GitHub RECONCILES:\n# findings fixed in code since the last scan auto-close instead of\n# orphaning as stale open alerts.\nHYPATIA_FORMAT=sarif \"$HOME/hypatia/hypatia-cli.sh\" scan . --exit-zero > hypatia.sarif\nif [ ! -s hypatia.sarif ]; then\n echo \"::error::Hypatia did not produce hypatia.sarif\"\n exit 2\nfi\n", + }, + { + name: "Validate findings and count severities", + id: "scan", + run: "set -euo pipefail\n# Exactly one JSON array of findings, each with a recognised severity.\n# Missing/truncated/multi-document output is a scanner error and fails\n# closed; a single well-formed `[]` is a valid clean scan when the\n# scanner step succeeded (standards#1054).\nif [ ! -s hypatia-findings.json ] || ! jq -e -s '\n length == 1 and (.[0] | type == \"array\" and all(.[];\n type == \"object\" and (.severity as $s |\n [\"critical\", \"high\", \"medium\", \"warn\", \"low\", \"info\", \"informational\"] | index($s) != null)))\n' hypatia-findings.json >/dev/null; then\n echo \"::error::Hypatia did not produce one valid findings array\"\n exit 2\nfi\n\nFINDING_COUNT=$(jq '. | length' hypatia-findings.json)\nCRITICAL=$(jq '[.[] | select(.severity == \"critical\")] | length' hypatia-findings.json)\nHIGH=$(jq '[.[] | select(.severity == \"high\")] | length' hypatia-findings.json)\n# Hypatia's research rules use \"warn\" at the same rank as \"medium\".\nMEDIUM=$(jq '[.[] | select(.severity == \"medium\" or .severity == \"warn\")] | length' hypatia-findings.json)\n\n{\n echo \"findings_count=$FINDING_COUNT\"\n echo \"critical=$CRITICAL\"\n echo \"high=$HIGH\"\n echo \"medium=$MEDIUM\"\n} >> \"$GITHUB_OUTPUT\"\n\n{\n echo \"## Hypatia Scan Results\"\n echo \"\"\n echo \"| Severity | Count |\"\n echo \"|----------|-------|\"\n echo \"| Critical | $CRITICAL |\"\n echo \"| High | $HIGH |\"\n echo \"| Medium | $MEDIUM |\"\n echo \"| **Total**| $FINDING_COUNT |\"\n} >> \"$GITHUB_STEP_SUMMARY\"\n", + }, + { + name: "Relativize finding paths", + run: "# Hoisted out of the gate step so the SARIF filter and the gate share\n# ONE definition of a finding's path. code_safety / honest_completion\n# emit ABSOLUTE host paths in .file (Path.expand(repo)) while the\n# baseline uses repo-relative paths; without this they never match\n# (hypatia#566 stopgap).\nif [ -f hypatia-findings.json ]; then\n jq --arg root \"$PWD\" '\n map(if (.file? // \"\") == $root then .file = \".\"\n elif (.file? // \"\") | startswith($root + \"/\")\n then .file = (.file | ltrimstr($root + \"/\"))\n else . end)\n ' hypatia-findings.json > hypatia-findings.relativized.json\nfi\n", + }, + { + name: "Check out standards for the SARIF baseline filter", + id: "fetch_filter", + # ⚠ continue-on-error is on the CHECKOUT, never on the check. If the + # fetch fails the next step still runs and says so; putting the flag on + # the filter itself would produce exactly the fake gate this estate + # keeps finding. Pinned to main because github.workflow_sha resolves to + # the CALLER's SHA, which would 404 here. + # advisory-by-design, not fail-closed: this checkout fails OPEN. + # "Filter SARIF through the baseline before upload" below falls back + # to uploading the SARIF UNFILTERED when the fetched filter script is + # unavailable — an unfiltered upload can only show more alerts, never + # hide real ones. + # hypatia: allow research_extensions/RE005 -- advisory-by-design, fails OPEN; see comment above. + continue-on-error: true, + uses: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", # v7.0.1 + with: { + repository: "hyperpolymath/standards", + persist-credentials: false, + # Includes the filter's completion output and literal-glob matching. + # Keep the helper immutable and compatible with the strict gate. + ref: "6b0bfce648cf9b306693cea0af0bd5f6fc9fea0f", + path: ".standards-checkout", + sparse-checkout: "scripts\n", + sparse-checkout-cone-mode: false, + }, + }, + { + name: "Filter SARIF through the baseline before upload", + id: "filter_baseline", + run: "# ⚠ WITHOUT THIS, ACKNOWLEDGING A FINDING DOES NOT UNBLOCK ANYTHING.\n# A finding travelled two paths that never met: the gate filtered\n# findings.json through the baseline, while the SARIF went to code\n# scanning UNFILTERED. The `code_scanning` ruleset rule blocks on\n# those alerts and has no knowledge of the baseline, so a pull\n# request stayed BLOCKED with every required check green and nothing\n# to point at.\n#\n# Measured 2026-08-07: 147 open pull requests across 91 repositories\n# were held this way by 1,777 alerts, ZERO of which were introduced\n# by the pull request they blocked.\n#\n# The filter FAILS OPEN in every error path — an unfiltered upload is\n# a safe failure, a silently over-filtered one hides real alerts.\nif [ -f .standards-checkout/scripts/filter-sarif-by-baseline.sh ]; then\n FILTER=.standards-checkout/scripts/filter-sarif-by-baseline.sh\nelif [ -f scripts/filter-sarif-by-baseline.sh ]; then\n FILTER=scripts/filter-sarif-by-baseline.sh\nelse\n echo \" filter script unavailable — SARIF uploaded unfiltered\"; exit 0\nfi\n# Use the CALLER's apply-baseline.sh — the same copy the gate runs,\n# so the SARIF and the gate cannot disagree about what is acknowledged.\nAPPLY_BASELINE=scripts/apply-baseline.sh \\\n bash \"$FILTER\" hypatia.sarif hypatia-findings.relativized.json .hypatia-baseline.json\n", + }, + { + name: "Upload SARIF to code scanning", + # NOTE: this does NOT make under-permissioned callers "skip gracefully". + # This workflow declares `security-events: write` at the top, so a + # caller granting only `read` is rejected at startup and NO step of + # this job ever executes — `continue-on-error` cannot rescue a job + # that was never created. Callers MUST grant `security-events: write`. + # This flag only tolerates a genuine upload failure (e.g. Advanced + # Security disabled on a private repo) once the job is actually running. + continue-on-error: true, + uses: "github/codeql-action/upload-sarif@b96794f015dfd88f77b49b1c93e0fa7110f94c63", # v4.38.0 (4.38.1 blocked estate-wide; nexia-list#100) + with: { + sarif_file: "hypatia.sarif", + category: "hypatia", + }, + }, + { + name: "Run panic-attack assail", + run: "if command -v panic-attack >/dev/null 2>&1; then\n panic-attack assail . > panic-attack-findings.json 2>&1 || true\n echo \"panic-attack scan complete\"\nelse\n echo \"panic-attack not available in CI — skipping\"\n echo \"[]\" > panic-attack-findings.json\nfi\n", + }, + { + name: "Upload findings artifacts", + uses: "actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a", # v7.0.1 + with: { + name: "hypatia-scan-findings", + path: "hypatia-findings.json\npanic-attack-findings.json\n", + retention-days: 90, + }, + }, + { + name: "Gate on baseline (blocking when a baseline is committed)", + if: "!inputs.block-on-high && steps.scan.outputs.findings_count > 0", + run: "# Wave-8 gate promotion: when the calling repo commits a\n# .hypatia-baseline.json (schema: .machine_readable/hypatia-baseline.\n# schema.json) AND ships scripts/apply-baseline.sh, findings are\n# filtered against the baseline and any UNBASELINED finding at or\n# above the blocking threshold FAILS this job. Repos without a\n# baseline keep the honest ADVISORY behaviour below — the gate arms\n# itself the moment a baseline lands (standards#399/#437/#446).\nif [ -f scripts/apply-baseline.sh ] && [ -f .hypatia-baseline.json ]; then\n echo \"Baseline present — running BLOCKING gate (threshold: high).\"\n # Paths were relativized in an earlier step so the SARIF filter and\n # this gate agree on what a finding's path is. Two definitions of\n # \"the same finding\" is how the baseline came to mean one thing to\n # the gate and nothing to the security tab.\n bash scripts/apply-baseline.sh hypatia-findings.relativized.json .hypatia-baseline.json blocking\nelse\n echo \"No committed baseline — gate stays advisory (see next step).\"\nfi\n", + }, + { + name: "Block high and critical findings when requested", + id: "blocking-findings", + if: "inputs.block-on-high", + env: { + HYPATIA_BASELINE_FILTERED: "${{ steps.filter_baseline.outputs.filtered }}", + }, + run: "set -euo pipefail\n# Use the scanner's authoritative SARIF projection, after the same\n# baseline filter used for the Security tab. Raw JSON also contains\n# code_scanning_alerts: historical GitHub alert summaries, including\n# alerts fixed by this PR that cannot close until it reaches main.\n# Hypatia already excludes those meta-rules in its SARIF renderer.\n# Counting them here creates a circular merge dependency. Raw JSON\n# remains in the artifact for the fleet's historical-debt review.\n# Validate a present baseline even when there are no current errors.\n# Advisory mode validates without gating historical meta-findings;\n# the final filtered SARIF below supplies the blocking decision.\nif [ -f .hypatia-baseline.json ]; then\n if [ ! -f scripts/apply-baseline.sh ]; then\n echo \"::error::A committed baseline requires scripts/apply-baseline.sh\"\n exit 2\n fi\n bash scripts/apply-baseline.sh hypatia-findings.relativized.json .hypatia-baseline.json advisory >/dev/null\n if [ \"${HYPATIA_BASELINE_FILTERED:-}\" != \"true\" ]; then\n echo \"::error::Baseline validation passed but SARIF filtering did not complete\"\n exit 2\n fi\nfi\n# Missing/truncated output must never become a clean security gate.\nif ! jq -se '\n length == 1 and (.[0] |\n .version == \"2.1.0\" and (.runs | type == \"array\" and length > 0) and\n all(.runs[];\n .tool.driver.name == \"Hypatia\" and\n (.results | type == \"array\") and\n all(.results[];\n type == \"object\" and (.ruleId | type == \"string\" and length > 0) and\n (.level as $level | [\"error\", \"warning\", \"note\", \"none\"] | index($level) != null))))\n' hypatia.sarif >/dev/null; then\n echo \"::error::Hypatia did not produce one valid SARIF findings document\"\n exit 2\nfi\ncount=$(jq '[.runs[].results[] | select(.level == \"error\")] | length' hypatia.sarif)\nif [ \"$count\" -gt 0 ]; then\n echo \"::error::Hypatia found $count current high or critical finding(s); see the SARIF artifact\"\n exit 1\nfi\necho \"Hypatia blocking gate: no current high or critical findings after baseline filtering\"\n", + }, + { + name: "Check for critical issues (ADVISORY — does not gate)", + if: "!inputs.block-on-high && steps.scan.outputs.critical > 0", + run: "# This scan is ADVISORY / fix-forward WHEN NO BASELINE IS COMMITTED\n# (the step above becomes the blocking gate once .hypatia-baseline.json\n# lands). The label and summary state that explicitly so a green check\n# carrying critical findings is not mistaken for \"no critical findings\".\necho \"::warning title=Hypatia (ADVISORY, non-blocking)::${{ steps.scan.outputs.critical }} critical finding(s). This scan does not gate — review hypatia-findings.json and fix forward.\"\n{\n echo \"### Hypatia scan — ADVISORY (does not gate)\"\n echo \"\"\n echo \"**${{ steps.scan.outputs.critical }} critical finding(s)** reported.\"\n echo \"This scan is fix-forward and **never fails the build**; a green\"\n echo \"check here does not mean zero critical findings. See the\"\n echo \"\\`hypatia-scan-findings\\` artifact. Blocking promotion: standards#399/#437.\"\n} >> \"$GITHUB_STEP_SUMMARY\"\n", + }, + ], + }, + }, +} diff --git a/.github/workflows/hypatia-scan.yml b/.github/workflows/hypatia-scan.yml index 8686b8c29..2b1c9fa6b 100644 --- a/.github/workflows/hypatia-scan.yml +++ b/.github/workflows/hypatia-scan.yml @@ -1,30 +1,45 @@ # This workflow is managed by gh actions-lock. # SPDX-License-Identifier: MPL-2.0 # This workflow is managed by gh actions-lock. -name: Hypatia Security Scan - -on: - push: - branches: [main, master, develop] - pull_request: - branches: [main, master] - schedule: - - cron: "0 0 * * 0" - workflow_dispatch: - -permissions: - actions: read - contents: read - # MUST be `write`, not `read`. hypatia-scan-reusable.yml declares - # `security-events: write` so it can upload SARIF. A called workflow may - # never request more than its caller grants: if it does, GitHub rejects the - # run at *startup*, before any job is created — `startup_failure`, zero jobs, - # and `gh run view --log-failed` returns "log not found". Granting `read` - # here did not degrade the scan, it silently prevented it from ever running. - # See standards#451. - security-events: write - -jobs: - scan: - uses: ./.github/workflows/hypatia-scan-reusable.yml - secrets: inherit +{ + name: "Hypatia Security Scan", + on: { + push: { + branches: [ + "main", + "master", + "develop", + ], + }, + pull_request: { + branches: [ + "main", + "master", + ], + }, + schedule: [ + { + cron: "0 0 * * 0", + }, + ], + workflow_dispatch: null, + }, + permissions: { + actions: "read", + contents: "read", + # MUST be `write`, not `read`. hypatia-scan-reusable.yml declares + # `security-events: write` so it can upload SARIF. A called workflow may + # never request more than its caller grants: if it does, GitHub rejects the + # run at *startup*, before any job is created — `startup_failure`, zero jobs, + # and `gh run view --log-failed` returns "log not found". Granting `read` + # here did not degrade the scan, it silently prevented it from ever running. + # See standards#451. + security-events: "write", + }, + jobs: { + scan: { + uses: "./.github/workflows/hypatia-scan-reusable.yml", + secrets: "inherit", + }, + }, +} diff --git a/.github/workflows/instant-sync.yml b/.github/workflows/instant-sync.yml index d886ec84f..5cd381fad 100644 --- a/.github/workflows/instant-sync.yml +++ b/.github/workflows/instant-sync.yml @@ -2,67 +2,79 @@ # SPDX-License-Identifier: MPL-2.0 # This workflow is managed by gh actions-lock. # Instant Forge Sync - Triggers propagation to all forges on push/release -name: Instant Sync - -on: - push: - branches: [main, master] - release: - types: [published] - -# Estate guardrail: cancel superseded runs so re-pushes / rebased PR -# updates do not pile up queued runs against the shared account-wide -# Actions concurrency pool. Applied only to read-only check workflows -# (no publish/mutation), so cancelling a superseded run is always safe. -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -permissions: - actions: read - contents: read - -jobs: - dispatch: - timeout-minutes: 10 - runs-on: ubuntu-latest - # Map the secret to env so step `if:`s can gate on its presence: the - # `secrets` context is NOT available in `if:` (using it is an - # "Unrecognized named-value: 'secrets'" startup failure). `env` IS - # available in step `if:`, and secrets are valid in job-level `env`. - 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 - # falls back to GITHUB_TOKEN — which cannot dispatch cross-repo and - # returns HTTP 401 "Bad credentials", failing the job. Caught 39 - # estate repos on the 2026-05-30 audit. With this gate the workflow - # gracefully skips on repos where the secret has not been - # propagated, instead of red-ing main on every push. - - name: Trigger Propagation - if: ${{ env.FARM_DISPATCH_TOKEN != '' }} - uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v3 - with: - token: ${{ secrets.FARM_DISPATCH_TOKEN }} - repository: hyperpolymath/.git-private-farm - event-type: propagate - client-payload: |- - { - "repo": "${{ github.event.repository.name }}", - "ref": "${{ github.ref }}", - "sha": "${{ github.sha }}", - "forges": "" - } - - - name: Skipped (FARM_DISPATCH_TOKEN not configured) - if: ${{ env.FARM_DISPATCH_TOKEN == '' }} - env: - REPO_NAME: ${{ github.event.repository.name }} - run: | - echo "::notice::FARM_DISPATCH_TOKEN secret not configured on ${REPO_NAME}; skipping cross-repo dispatch. Configure the org-level FARM_DISPATCH_TOKEN PAT (repo scope) to enable instant forge propagation." - - - name: Confirm - env: - REPO_NAME: ${{ github.event.repository.name }} - run: echo "::notice::Propagation triggered for ${REPO_NAME}" +{ + name: "Instant Sync", + on: { + push: { + branches: [ + "main", + "master", + ], + }, + release: { + types: [ + "published", + ], + }, + }, + # Estate guardrail: cancel superseded runs so re-pushes / rebased PR + # updates do not pile up queued runs against the shared account-wide + # Actions concurrency pool. Applied only to read-only check workflows + # (no publish/mutation), so cancelling a superseded run is always safe. + concurrency: { + group: "${{ github.workflow }}-${{ github.ref }}", + cancel-in-progress: true, + }, + permissions: { + actions: "read", + contents: "read", + }, + jobs: { + dispatch: { + timeout-minutes: 10, + runs-on: "ubuntu-latest", + # Map the secret to env so step `if:`s can gate on its presence: the + # `secrets` context is NOT available in `if:` (using it is an + # "Unrecognized named-value: 'secrets'" startup failure). `env` IS + # available in step `if:`, and secrets are valid in job-level `env`. + 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 + # falls back to GITHUB_TOKEN — which cannot dispatch cross-repo and + # returns HTTP 401 "Bad credentials", failing the job. Caught 39 + # estate repos on the 2026-05-30 audit. With this gate the workflow + # gracefully skips on repos where the secret has not been + # propagated, instead of red-ing main on every push. + { + name: "Trigger Propagation", + if: "${{ env.FARM_DISPATCH_TOKEN != '' }}", + uses: "peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697", # v3 + with: { + token: "${{ secrets.FARM_DISPATCH_TOKEN }}", + repository: "hyperpolymath/.git-private-farm", + event-type: "propagate", + client-payload: "{\n \"repo\": \"${{ github.event.repository.name }}\",\n \"ref\": \"${{ github.ref }}\",\n \"sha\": \"${{ github.sha }}\",\n \"forges\": \"\"\n}", + }, + }, + { + name: "Skipped (FARM_DISPATCH_TOKEN not configured)", + if: "${{ env.FARM_DISPATCH_TOKEN == '' }}", + env: { + REPO_NAME: "${{ github.event.repository.name }}", + }, + run: "echo \"::notice::FARM_DISPATCH_TOKEN secret not configured on ${REPO_NAME}; skipping cross-repo dispatch. Configure the org-level FARM_DISPATCH_TOKEN PAT (repo scope) to enable instant forge propagation.\"\n", + }, + { + name: "Confirm", + env: { + REPO_NAME: "${{ github.event.repository.name }}", + }, + run: "echo \"::notice::Propagation triggered for ${REPO_NAME}\"", + }, + ], + }, + }, +} diff --git a/.github/workflows/k9-contractile.yml b/.github/workflows/k9-contractile.yml index 7abffb152..911e89538 100644 --- a/.github/workflows/k9-contractile.yml +++ b/.github/workflows/k9-contractile.yml @@ -16,250 +16,121 @@ # The job name below is VERBATIM from the required-context list. A required # context is matched against the emitted job name, so renaming it silently # un-gates the branch. -name: K9-SVC Contractile Validation - -on: - push: - branches: [main, master] - pull_request: - workflow_dispatch: - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -permissions: - contents: read - -jobs: - validate-contractiles: - name: K9-SVC contractile validation - timeout-minutes: 10 - runs-on: ubuntu-latest - # Needed only by the final step, which posts the validator's verdict to - # the pull request. Log blobs are not reachable from every machine that - # needs the result, so the result is published where it can be read. - permissions: - contents: read - pull-requests: write - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - name: K9-SVC Validation - run: | - set -euo pipefail - echo "Running K9-SVC contractile validation..." - # Real structural validation (no placeholder): every Mustfile check - # must carry a severity and a means of discharge (run or verification). - # A hollow check fails loudly. See scripts/check-mustfile-structure.sh - # (unit-tested by scripts/tests/wave0-false-green-test.sh). - bash scripts/check-mustfile-structure.sh - - - name: Mustfile Enforcement (execute the checks) - run: | - set -euo pipefail - # Execute the Mustfile's `- run:` invariants — the mandatory checks - # that were previously declared but never run by any CI job. A failing - # critical/high check fails the build (fail loudly); warning-severity - # failures are reported but non-blocking. See scripts/run-mustfile.sh. - bash scripts/run-mustfile.sh - - - name: Contractile Check - run: | - set -euo pipefail - echo "Checking contractile completeness..." - index=".machine_readable/contractiles/INDEX.a2ml" - if [ ! -f "$index" ]; then - echo "❌ Contractile registry not found: $index" - exit 1 - fi - # Read the canonical verb set from the registry rather than hard-coding - # it (INDEX.a2ml §Registry: consumers SHOULD discover verbs from here). - # Each verb's trident lists "/file.a2ml" as its first entry. - mapfile -t files < <(grep -oE '"[a-z]+/[A-Z][a-z]+file\.a2ml"' "$index" | tr -d '"' | sort -u) - if [ "${#files[@]}" -eq 0 ]; then - echo "❌ No contractile files discovered in $index" - exit 1 - fi - missing=0 - for rel in "${files[@]}"; do - if [ -f ".machine_readable/contractiles/$rel" ]; then - echo "✅ $rel" - else - echo "❌ Missing: $rel" - missing=$((missing + 1)) - fi - done - if [ "$missing" -gt 0 ]; then - echo "❌ $missing contractile(s) missing" - exit 1 - fi - echo "✅ All ${#files[@]} contractiles present" - # ── K9 CONTRACT CONFORMANCE (standards#1058, ruling D173) ────────── - # - # Everything above this line checks that contractile FILES EXIST. None of - # it checked that a `.k9.ncl` file is a valid K9 component: the local hook - # grepped for a line beginning `contract`, which no file in this repo has, - # and ci-pipeline.yml excludes `*.k9.ncl` from Nickel entirely. So the - # required check named "K9-SVC contractile validation" validated the - # registry and never once read a pedigree. - # - # Nickel is installed here, pinned and sha256-verified with the SAME pin - # as ci-pipeline.yml's `nickel` job — one version estate-wide, so a file - # cannot typecheck in one lane and not in another. - - name: Install Nickel (pinned, sha256-verified) - env: - NICKEL_VERSION: '1.18.0' - NICKEL_SHA256: '9cba4dd65ae9915ec61f73033aafcff307a377665a83fd8f530df086763318cb' - run: | - set -euo pipefail - URL="https://github.com/tweag/nickel/releases/download/${NICKEL_VERSION}/nickel-x86_64-linux" - curl -sSfL --proto '=https' --proto-redir '=https' --tlsv1.2 --retry 3 -o "$RUNNER_TEMP/nickel" "$URL" - echo "${NICKEL_SHA256} ${RUNNER_TEMP}/nickel" | sha256sum -c - - chmod +x "$RUNNER_TEMP/nickel" - echo "$RUNNER_TEMP" >> "$GITHUB_PATH" - echo "K9_REPORT=$RUNNER_TEMP/k9-report.md" >> "$GITHUB_ENV" - - # Each step mirrors its output into $GITHUB_STEP_SUMMARY as well as - # stdout. The PR then shows the verdict without anyone opening a raw log, - # and `gh api repos/{o}/{r}/check-runs/{job_id} --jq .output.summary` - # reads it back — which matters on a machine that cannot reach the log - # blob host. `set -uo pipefail` without `-e` so a failing validator still - # reaches the tee and its own status is what the step exits with. - - name: K9 normative contract typecheck - run: | - set -uo pipefail - # Its own step, ahead of the fixtures. `nickel typecheck` stops at the - # first error, and when the contract is broken every fixture verdict - # downstream is void — reported as five "non-conforming" positive - # controls rather than one broken contract. Twice now that cost a run - # to diagnose (`Record`, then `Any`: neither is a Nickel type). - set +e - out="$(nickel typecheck 1-formats/k9/spec/contract/k9_contract.ncl 2>&1)"; rc=$? - set -e - { - echo '## K9 normative contract typecheck' - echo '```' - [ $rc -eq 0 ] && echo 'k9_contract.ncl typechecks' || printf '%s\n' "$out" - echo '```' - } | tee -a "$GITHUB_STEP_SUMMARY" "$K9_REPORT" - if [ $rc -ne 0 ]; then - printf '%s\n' "$out" | head -20 | while IFS= read -r line; do - esc="$(printf '%s' "$line" | sed 's/%/%25/g' | tr -d '\r\n')" - [ -n "$esc" ] && printf '::error title=k9_contract.ncl does not typecheck::%s\n' "$esc" - done - fi - exit $rc - - - name: K9 contract self-test - run: | - set -uo pipefail - # Asserts the validator's bash mirrors still agree with the normative - # contract, the capability arithmetic, the structural extractor, and - # the envelope strip. Runs before the fixtures so a broken validator - # is reported as a broken validator rather than as a broken corpus. - { - echo '## K9 contract self-test' - echo '```' - bash 1-formats/k9/tools/k9-validate.sh --self-test 2>&1 - echo '```' - } | tee -a "$GITHUB_STEP_SUMMARY" "$K9_REPORT" - exit "${PIPESTATUS[0]}" - - - name: K9 conformance fixtures (positive AND negative controls) - run: | - set -uo pipefail - # --strict: with Nickel on PATH every format layer (L0-L2) must - # actually run, so a missing toolchain cannot report a pass. The 21 - # negative controls are the load-bearing half — a validator that - # accepts everything satisfies the positive half trivially. - set +e - out="$(bash 1-formats/k9/tools/k9-validate.sh --strict \ - --fixtures 1-formats/k9/tools/fixtures 2>&1)"; rc=$? - set -e - { - echo '## K9 conformance fixtures' - echo '```' - printf '%s\n' "$out" - echo '```' - } | tee -a "$GITHUB_STEP_SUMMARY" "$K9_REPORT" - # A failing gate also raises a check-run annotation. Annotations are - # readable through `gh api repos/{o}/{r}/check-runs/{job}/annotations` - # from machines that cannot reach the log blob host, which is where - # the first two runs of this workflow had to be diagnosed from. - # Workflow-command escaping: % first, then CR and LF. - if [ $rc -ne 0 ]; then - # One annotation per failure, not one multi-kilobyte annotation: a - # whole-report message never reached the check-run API, and a - # per-failure annotation is what a reader wants anyway. - printf '%s\n' "$out" | grep -E '^(FAIL|ERROR)' | head -20 | while IFS= read -r line; do - esc="$(printf '%s' "$line" | sed 's/%/%25/g' | tr -d '\r\n')" - printf '::error title=K9 conformance fixture::%s\n' "$esc" - done - fi - exit $rc - - - name: K9 corpus conformance (ratcheted, L2) - env: - # MIGRATION-1058 M7's first step. The hook's own default stays L1: a - # pre-commit hook that fails because a laptop lacks a toolchain - # teaches people to pass --no-verify, which is how a gate gets - # deleted. CI has the pinned binary, so here the corpus is checked at - # L2 as well. The six contractiles and the two axel config files that - # M1/M4 fixed had never been evaluated against K9.Component, and the - # closed record contracts in k9_contract.ncl rejected exactly the - # extension fields §5.5 permits. Ledger entries stay advisory, so the - # four svc templates and the rsr checklist are reported, not failed, - # until their own items land. - K9_VALIDATE_LAYER: all - run: | - set -uo pipefail - # The whole tracked corpus, through the same gate the pre-commit hook - # uses, so CI and the hook cannot disagree about what a K9 file is. - # .machine_readable/k9-contract-debt.txt grandfathers the files - # that predate the contract; it is shrink-only (a conforming file - # left in it fails), and it does not protect a file this PR touches. - set +e - out="$(bash .githooks/validate-k9.sh 2>&1)"; rc=$? - set -e - { - echo '## K9 corpus conformance (L2)' - echo '```' - printf '%s\n' "$out" - echo '```' - } | tee -a "$GITHUB_STEP_SUMMARY" "$K9_REPORT" - if [ $rc -ne 0 ]; then - printf '%s\n' "$out" | grep -E '^(FAIL|ERROR)' | head -20 | while IFS= read -r line; do - esc="$(printf '%s' "$line" | sed 's/%/%25/g' | tr -d '\r\n')" - printf '::error title=K9 corpus conformance::%s\n' "$esc" - done - fi - exit $rc - - - name: Publish the K9 verdict to the pull request - # always(): a failing gate is exactly when the detail is needed, and a - # skipped step would publish nothing. - if: ${{ always() && github.event_name == 'pull_request' }} - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - PR_NUMBER: ${{ github.event.pull_request.number }} - run: | - set -uo pipefail - [ -s "$K9_REPORT" ] || { echo "no K9 report produced"; exit 0; } - { - echo '' - echo '# K9 contract conformance' - echo - echo "_run_ $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" - cat "$K9_REPORT" - } > "$RUNNER_TEMP/body.md" - # Always post; never PATCH. Creating a comment is the one path this - # step has been observed to complete, and two runs were lost - # diagnosing an update that could not be read back. Stacked reports - # cost a little noise on the PR and buy a result that actually - # arrives, which is the right trade while the log host is - # unreachable. Newest comment wins; each one names its run. - gh pr comment "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" \ - --body-file "$RUNNER_TEMP/body.md" >/dev/null - echo "published the K9 verdict to #$PR_NUMBER" +{ + name: "K9-SVC Contractile Validation", + on: { + push: { + branches: [ + "main", + "master", + ], + }, + pull_request: null, + workflow_dispatch: null, + }, + concurrency: { + group: "${{ github.workflow }}-${{ github.ref }}", + cancel-in-progress: true, + }, + permissions: { + contents: "read", + }, + jobs: { + validate-contractiles: { + name: "K9-SVC contractile validation", + timeout-minutes: 10, + runs-on: "ubuntu-latest", + # Needed only by the final step, which posts the validator's verdict to + # the pull request. Log blobs are not reachable from every machine that + # needs the result, so the result is published where it can be read. + permissions: { + contents: "read", + pull-requests: "write", + }, + steps: [ + { + name: "Checkout", + uses: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", # v7.0.1 + }, + { + name: "K9-SVC Validation", + run: "set -euo pipefail\necho \"Running K9-SVC contractile validation...\"\n# Real structural validation (no placeholder): every Mustfile check\n# must carry a severity and a means of discharge (run or verification).\n# A hollow check fails loudly. See scripts/check-mustfile-structure.sh\n# (unit-tested by scripts/tests/wave0-false-green-test.sh).\nbash scripts/check-mustfile-structure.sh\n", + }, + { + name: "Mustfile Enforcement (execute the checks)", + run: "set -euo pipefail\n# Execute the Mustfile's `- run:` invariants — the mandatory checks\n# that were previously declared but never run by any CI job. A failing\n# critical/high check fails the build (fail loudly); warning-severity\n# failures are reported but non-blocking. See scripts/run-mustfile.sh.\nbash scripts/run-mustfile.sh\n", + }, + { + name: "Contractile Check", + run: "set -euo pipefail\necho \"Checking contractile completeness...\"\nindex=\".machine_readable/contractiles/INDEX.a2ml\"\nif [ ! -f \"$index\" ]; then\n echo \"❌ Contractile registry not found: $index\"\n exit 1\nfi\n# Read the canonical verb set from the registry rather than hard-coding\n# it (INDEX.a2ml §Registry: consumers SHOULD discover verbs from here).\n# Each verb's trident lists \"/file.a2ml\" as its first entry.\nmapfile -t files < <(grep -oE '\"[a-z]+/[A-Z][a-z]+file\\.a2ml\"' \"$index\" | tr -d '\"' | sort -u)\nif [ \"${#files[@]}\" -eq 0 ]; then\n echo \"❌ No contractile files discovered in $index\"\n exit 1\nfi\nmissing=0\nfor rel in \"${files[@]}\"; do\n if [ -f \".machine_readable/contractiles/$rel\" ]; then\n echo \"✅ $rel\"\n else\n echo \"❌ Missing: $rel\"\n missing=$((missing + 1))\n fi\ndone\nif [ \"$missing\" -gt 0 ]; then\n echo \"❌ $missing contractile(s) missing\"\n exit 1\nfi\necho \"✅ All ${#files[@]} contractiles present\"\n", + }, + # ── K9 CONTRACT CONFORMANCE (standards#1058, ruling D173) ────────── + # + # Everything above this line checks that contractile FILES EXIST. None of + # it checked that a `.k9.ncl` file is a valid K9 component: the local hook + # grepped for a line beginning `contract`, which no file in this repo has, + # and ci-pipeline.yml excludes `*.k9.ncl` from Nickel entirely. So the + # required check named "K9-SVC contractile validation" validated the + # registry and never once read a pedigree. + # + # Nickel is installed here, pinned and sha256-verified with the SAME pin + # as ci-pipeline.yml's `nickel` job — one version estate-wide, so a file + # cannot typecheck in one lane and not in another. + { + name: "Install Nickel (pinned, sha256-verified)", + env: { + NICKEL_VERSION: "1.18.0", + NICKEL_SHA256: "9cba4dd65ae9915ec61f73033aafcff307a377665a83fd8f530df086763318cb", + }, + run: "set -euo pipefail\nURL=\"https://github.com/tweag/nickel/releases/download/${NICKEL_VERSION}/nickel-x86_64-linux\"\ncurl -sSfL --proto '=https' --proto-redir '=https' --tlsv1.2 --retry 3 -o \"$RUNNER_TEMP/nickel\" \"$URL\"\necho \"${NICKEL_SHA256} ${RUNNER_TEMP}/nickel\" | sha256sum -c -\nchmod +x \"$RUNNER_TEMP/nickel\"\necho \"$RUNNER_TEMP\" >> \"$GITHUB_PATH\"\necho \"K9_REPORT=$RUNNER_TEMP/k9-report.md\" >> \"$GITHUB_ENV\"\n", + }, + # Each step mirrors its output into $GITHUB_STEP_SUMMARY as well as + # stdout. The PR then shows the verdict without anyone opening a raw log, + # and `gh api repos/{o}/{r}/check-runs/{job_id} --jq .output.summary` + # reads it back — which matters on a machine that cannot reach the log + # blob host. `set -uo pipefail` without `-e` so a failing validator still + # reaches the tee and its own status is what the step exits with. + { + name: "K9 normative contract typecheck", + run: "set -uo pipefail\n# Its own step, ahead of the fixtures. `nickel typecheck` stops at the\n# first error, and when the contract is broken every fixture verdict\n# downstream is void — reported as five \"non-conforming\" positive\n# controls rather than one broken contract. Twice now that cost a run\n# to diagnose (`Record`, then `Any`: neither is a Nickel type).\nset +e\nout=\"$(nickel typecheck 1-formats/k9/spec/contract/k9_contract.ncl 2>&1)\"; rc=$?\nset -e\n{\n echo '## K9 normative contract typecheck'\n echo '```'\n [ $rc -eq 0 ] && echo 'k9_contract.ncl typechecks' || printf '%s\\n' \"$out\"\n echo '```'\n} | tee -a \"$GITHUB_STEP_SUMMARY\" \"$K9_REPORT\"\nif [ $rc -ne 0 ]; then\n printf '%s\\n' \"$out\" | head -20 | while IFS= read -r line; do\n esc=\"$(printf '%s' \"$line\" | sed 's/%/%25/g' | tr -d '\\r\\n')\"\n [ -n \"$esc\" ] && printf '::error title=k9_contract.ncl does not typecheck::%s\\n' \"$esc\"\n done\nfi\nexit $rc\n", + }, + { + name: "K9 contract self-test", + run: "set -uo pipefail\n# Asserts the validator's bash mirrors still agree with the normative\n# contract, the capability arithmetic, the structural extractor, and\n# the envelope strip. Runs before the fixtures so a broken validator\n# is reported as a broken validator rather than as a broken corpus.\n{\n echo '## K9 contract self-test'\n echo '```'\n bash 1-formats/k9/tools/k9-validate.sh --self-test 2>&1\n echo '```'\n} | tee -a \"$GITHUB_STEP_SUMMARY\" \"$K9_REPORT\"\nexit \"${PIPESTATUS[0]}\"\n", + }, + { + name: "K9 conformance fixtures (positive AND negative controls)", + run: "set -uo pipefail\n# --strict: with Nickel on PATH every format layer (L0-L2) must\n# actually run, so a missing toolchain cannot report a pass. The 21\n# negative controls are the load-bearing half — a validator that\n# accepts everything satisfies the positive half trivially.\nset +e\nout=\"$(bash 1-formats/k9/tools/k9-validate.sh --strict \\\n --fixtures 1-formats/k9/tools/fixtures 2>&1)\"; rc=$?\nset -e\n{\n echo '## K9 conformance fixtures'\n echo '```'\n printf '%s\\n' \"$out\"\n echo '```'\n} | tee -a \"$GITHUB_STEP_SUMMARY\" \"$K9_REPORT\"\n# A failing gate also raises a check-run annotation. Annotations are\n# readable through `gh api repos/{o}/{r}/check-runs/{job}/annotations`\n# from machines that cannot reach the log blob host, which is where\n# the first two runs of this workflow had to be diagnosed from.\n# Workflow-command escaping: % first, then CR and LF.\nif [ $rc -ne 0 ]; then\n # One annotation per failure, not one multi-kilobyte annotation: a\n # whole-report message never reached the check-run API, and a\n # per-failure annotation is what a reader wants anyway.\n printf '%s\\n' \"$out\" | grep -E '^(FAIL|ERROR)' | head -20 | while IFS= read -r line; do\n esc=\"$(printf '%s' \"$line\" | sed 's/%/%25/g' | tr -d '\\r\\n')\"\n printf '::error title=K9 conformance fixture::%s\\n' \"$esc\"\n done\nfi\nexit $rc\n", + }, + { + name: "K9 corpus conformance (ratcheted, L2)", + env: { + # MIGRATION-1058 M7's first step. The hook's own default stays L1: a + # pre-commit hook that fails because a laptop lacks a toolchain + # teaches people to pass --no-verify, which is how a gate gets + # deleted. CI has the pinned binary, so here the corpus is checked at + # L2 as well. The six contractiles and the two axel config files that + # M1/M4 fixed had never been evaluated against K9.Component, and the + # closed record contracts in k9_contract.ncl rejected exactly the + # extension fields §5.5 permits. Ledger entries stay advisory, so the + # four svc templates and the rsr checklist are reported, not failed, + # until their own items land. + K9_VALIDATE_LAYER: "all", + }, + run: "set -uo pipefail\n# The whole tracked corpus, through the same gate the pre-commit hook\n# uses, so CI and the hook cannot disagree about what a K9 file is.\n# .machine_readable/k9-contract-debt.txt grandfathers the files\n# that predate the contract; it is shrink-only (a conforming file\n# left in it fails), and it does not protect a file this PR touches.\nset +e\nout=\"$(bash .githooks/validate-k9.sh 2>&1)\"; rc=$?\nset -e\n{\n echo '## K9 corpus conformance (L2)'\n echo '```'\n printf '%s\\n' \"$out\"\n echo '```'\n} | tee -a \"$GITHUB_STEP_SUMMARY\" \"$K9_REPORT\"\nif [ $rc -ne 0 ]; then\n printf '%s\\n' \"$out\" | grep -E '^(FAIL|ERROR)' | head -20 | while IFS= read -r line; do\n esc=\"$(printf '%s' \"$line\" | sed 's/%/%25/g' | tr -d '\\r\\n')\"\n printf '::error title=K9 corpus conformance::%s\\n' \"$esc\"\n done\nfi\nexit $rc\n", + }, + { + name: "Publish the K9 verdict to the pull request", + # always(): a failing gate is exactly when the detail is needed, and a + # skipped step would publish nothing. + if: "${{ always() && github.event_name == 'pull_request' }}", + env: { + GH_TOKEN: "${{ secrets.GITHUB_TOKEN }}", + PR_NUMBER: "${{ github.event.pull_request.number }}", + }, + run: "set -uo pipefail\n[ -s \"$K9_REPORT\" ] || { echo \"no K9 report produced\"; exit 0; }\n{\n echo ''\n echo '# K9 contract conformance'\n echo\n echo \"_run_ $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID\"\n cat \"$K9_REPORT\"\n} > \"$RUNNER_TEMP/body.md\"\n# Always post; never PATCH. Creating a comment is the one path this\n# step has been observed to complete, and two runs were lost\n# diagnosing an update that could not be read back. Stacked reports\n# cost a little noise on the PR and buy a result that actually\n# arrives, which is the right trade while the log host is\n# unreachable. Newest comment wins; each one names its run.\ngh pr comment \"$PR_NUMBER\" --repo \"$GITHUB_REPOSITORY\" \\\n --body-file \"$RUNNER_TEMP/body.md\" >/dev/null\necho \"published the K9 verdict to #$PR_NUMBER\"\n", + }, + ], + }, + }, +} diff --git a/.github/workflows/label-triage.yml b/.github/workflows/label-triage.yml index 3e7474854..4db8a69fd 100644 --- a/.github/workflows/label-triage.yml +++ b/.github/workflows/label-triage.yml @@ -1,118 +1,69 @@ # This workflow is managed by gh actions-lock. # SPDX-License-Identifier: MPL-2.0 -name: Label Triage - -# Classify newly-filed issues against the estate label taxonomy. -# -# The sweep that established the taxonomy is a one-off; this is what stops it -# decaying. Without it every new issue arrives unlabelled and the 55%-unlabelled -# state rebuilds itself. -# -# ⚠ NO `uses:` ANYWHERE, DELIBERATELY. The estate enforces -# .github/workflows/actions.lock, which is keyed BY WORKFLOW PATH: a workflow -# the lock does not list is rejected before any step runs (startup_failure, and -# therefore no check run at all). A dispatched workflow lands in repos whose -# lock has not been regenerated, so it must not depend on any action. -# -# ⚠ THE CLASSIFIER IS jq, NOT PYTHON. Python is fully banned estate-wide -- the -# `governance / Language / package anti-pattern policy` gate runs -# `git ls-files '*.py'` and fails the PR. Shipping a .py into 416 repos would -# mean shipping an exemption into 416 repos. jq is preinstalled on every GitHub -# runner, is not banned, and needs no action. -# -# Deliberately conservative: -# - ADDITIVE ONLY. It never removes a label and never overrides a human's -# classification: anything already on the issue is passed in via `have` and -# is never re-suggested, and the classifier stays out of any max-1 tier the -# issue already carries a label in. -# - SILENT WHEN UNSURE. Nothing is printed unless a prefix, bracket or type -# rule actually fired. Roughly 70% of the historical corpus classified this -# way; the rest is meant to reach a human. -# - NEVER FAILS THE ISSUE. Every step is best-effort; a missing payload or an -# API hiccup exits 0 rather than leaving a red mark on someone's bug report. - -on: - issues: - types: [opened, reopened] - workflow_dispatch: - inputs: - issue: - description: "Issue number to (re)classify" - required: true - -permissions: - issues: write - contents: read - -jobs: - triage: - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - - name: Classify and label - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - NUM: ${{ github.event.issue.number || inputs.issue }} - run: | - set -uo pipefail - work=$(mktemp -d); RULES=$work/rules.json; SCRIPT=$work/classify.jq - - # fetch instead of checking out -- no action means no lock entry to drift - gh api "repos/$GITHUB_REPOSITORY/contents/.github/label-classifier.json?ref=$GITHUB_SHA" \ - --jq '.content' 2>/dev/null | base64 -d > "$RULES" || true - gh api "repos/$GITHUB_REPOSITORY/contents/.github/scripts/classify-issue.jq?ref=$GITHUB_SHA" \ - --jq '.content' 2>/dev/null | base64 -d > "$SCRIPT" || true - if [[ ! -s "$RULES" || ! -s "$SCRIPT" ]]; then - echo "no classifier payload in this repo - nothing to do" - exit 0 - fi - - TITLE=$(gh issue view "$NUM" -R "$GITHUB_REPOSITORY" --json title --jq .title) || exit 0 - echo "issue #$NUM: $TITLE" - - # Labels this repo actually defines. --limit 1000 is GitHub's real - # per-repo ceiling; the default of 30 would silently hide most of the - # taxonomy. Fetched BEFORE the label read below so that read stays as - # close to the write as possible. - mapfile -t DEFINED < <(gh label list -R "$GITHUB_REPOSITORY" --limit 1000 \ - --json name --jq '.[].name' 2>/dev/null) - - # Labels already present; a human's work is never overridden. Read - # HERE rather than earlier: every API call between this read and the - # edit below widens a window in which someone could add a type label - # and get a second one back from us. Only the local jq call is inside it. - HAVE=$(gh issue view "$NUM" -R "$GITHUB_REPOSITORY" \ - --json labels --jq '[.labels[].name]' 2>/dev/null) || HAVE='[]' - [[ -n "$HAVE" ]] || HAVE='[]' - echo "already has: $HAVE" - - mapfile -t ADD < <(jq -r --arg title "$TITLE" --argjson have "$HAVE" \ - -f "$SCRIPT" "$RULES" 2>/dev/null) - if [[ ${#ADD[@]} -eq 0 || -z "${ADD[0]:-}" ]]; then - echo "no confident classification - leaving for a human" - exit 0 - fi - - apply=() - for want in "${ADD[@]}"; do - for def in "${DEFINED[@]}"; do - if [[ "$want" == "$def" ]]; then apply+=("$want"); break; fi - done - done - if [[ ${#apply[@]} -eq 0 ]]; then - echo "classified as ${ADD[*]} but this repo defines none of them - run the label sync" - exit 0 - fi - - printf 'applying: %s\n' "${apply[*]}" - # Build the arguments as an ARRAY. The previous form was an unquoted - # command substitution, so the shell re-split its output on spaces and - # a label name containing whitespace would arrive as several broken - # arguments. No canonical label contains a space today, which is - # exactly why this would have failed quietly the first time one did. - # (Also clears actionlint SC2046.) - edit_args=() - for lab in "${apply[@]}"; do edit_args+=(--add-label "$lab"); done - gh issue edit "$NUM" -R "$GITHUB_REPOSITORY" "${edit_args[@]}" \ - || echo "label apply failed - not failing the run" - exit 0 +{ + name: "Label Triage", + # Classify newly-filed issues against the estate label taxonomy. + # + # The sweep that established the taxonomy is a one-off; this is what stops it + # decaying. Without it every new issue arrives unlabelled and the 55%-unlabelled + # state rebuilds itself. + # + # ⚠ NO `uses:` ANYWHERE, DELIBERATELY. The estate enforces + # .github/workflows/actions.lock, which is keyed BY WORKFLOW PATH: a workflow + # the lock does not list is rejected before any step runs (startup_failure, and + # therefore no check run at all). A dispatched workflow lands in repos whose + # lock has not been regenerated, so it must not depend on any action. + # + # ⚠ THE CLASSIFIER IS jq, NOT PYTHON. Python is fully banned estate-wide -- the + # `governance / Language / package anti-pattern policy` gate runs + # `git ls-files '*.py'` and fails the PR. Shipping a .py into 416 repos would + # mean shipping an exemption into 416 repos. jq is preinstalled on every GitHub + # runner, is not banned, and needs no action. + # + # Deliberately conservative: + # - ADDITIVE ONLY. It never removes a label and never overrides a human's + # classification: anything already on the issue is passed in via `have` and + # is never re-suggested, and the classifier stays out of any max-1 tier the + # issue already carries a label in. + # - SILENT WHEN UNSURE. Nothing is printed unless a prefix, bracket or type + # rule actually fired. Roughly 70% of the historical corpus classified this + # way; the rest is meant to reach a human. + # - NEVER FAILS THE ISSUE. Every step is best-effort; a missing payload or an + # API hiccup exits 0 rather than leaving a red mark on someone's bug report. + on: { + issues: { + types: [ + "opened", + "reopened", + ], + }, + workflow_dispatch: { + inputs: { + issue: { + description: "Issue number to (re)classify", + required: true, + }, + }, + }, + }, + permissions: { + issues: "write", + contents: "read", + }, + jobs: { + triage: { + runs-on: "ubuntu-latest", + timeout-minutes: 5, + steps: [ + { + name: "Classify and label", + env: { + GH_TOKEN: "${{ secrets.GITHUB_TOKEN }}", + NUM: "${{ github.event.issue.number || inputs.issue }}", + }, + run: "set -uo pipefail\nwork=$(mktemp -d); RULES=$work/rules.json; SCRIPT=$work/classify.jq\n\n# fetch instead of checking out -- no action means no lock entry to drift\ngh api \"repos/$GITHUB_REPOSITORY/contents/.github/label-classifier.json?ref=$GITHUB_SHA\" \\\n --jq '.content' 2>/dev/null | base64 -d > \"$RULES\" || true\ngh api \"repos/$GITHUB_REPOSITORY/contents/.github/scripts/classify-issue.jq?ref=$GITHUB_SHA\" \\\n --jq '.content' 2>/dev/null | base64 -d > \"$SCRIPT\" || true\nif [[ ! -s \"$RULES\" || ! -s \"$SCRIPT\" ]]; then\n echo \"no classifier payload in this repo - nothing to do\"\n exit 0\nfi\n\nTITLE=$(gh issue view \"$NUM\" -R \"$GITHUB_REPOSITORY\" --json title --jq .title) || exit 0\necho \"issue #$NUM: $TITLE\"\n\n# Labels this repo actually defines. --limit 1000 is GitHub's real\n# per-repo ceiling; the default of 30 would silently hide most of the\n# taxonomy. Fetched BEFORE the label read below so that read stays as\n# close to the write as possible.\nmapfile -t DEFINED < <(gh label list -R \"$GITHUB_REPOSITORY\" --limit 1000 \\\n --json name --jq '.[].name' 2>/dev/null)\n\n# Labels already present; a human's work is never overridden. Read\n# HERE rather than earlier: every API call between this read and the\n# edit below widens a window in which someone could add a type label\n# and get a second one back from us. Only the local jq call is inside it.\nHAVE=$(gh issue view \"$NUM\" -R \"$GITHUB_REPOSITORY\" \\\n --json labels --jq '[.labels[].name]' 2>/dev/null) || HAVE='[]'\n[[ -n \"$HAVE\" ]] || HAVE='[]'\necho \"already has: $HAVE\"\n\nmapfile -t ADD < <(jq -r --arg title \"$TITLE\" --argjson have \"$HAVE\" \\\n -f \"$SCRIPT\" \"$RULES\" 2>/dev/null)\nif [[ ${#ADD[@]} -eq 0 || -z \"${ADD[0]:-}\" ]]; then\n echo \"no confident classification - leaving for a human\"\n exit 0\nfi\n\napply=()\nfor want in \"${ADD[@]}\"; do\n for def in \"${DEFINED[@]}\"; do\n if [[ \"$want\" == \"$def\" ]]; then apply+=(\"$want\"); break; fi\n done\ndone\nif [[ ${#apply[@]} -eq 0 ]]; then\n echo \"classified as ${ADD[*]} but this repo defines none of them - run the label sync\"\n exit 0\nfi\n\nprintf 'applying: %s\\n' \"${apply[*]}\"\n# Build the arguments as an ARRAY. The previous form was an unquoted\n# command substitution, so the shell re-split its output on spaces and\n# a label name containing whitespace would arrive as several broken\n# arguments. No canonical label contains a space today, which is\n# exactly why this would have failed quietly the first time one did.\n# (Also clears actionlint SC2046.)\nedit_args=()\nfor lab in \"${apply[@]}\"; do edit_args+=(--add-label \"$lab\"); done\ngh issue edit \"$NUM\" -R \"$GITHUB_REPOSITORY\" \"${edit_args[@]}\" \\\n || echo \"label apply failed - not failing the run\"\nexit 0\n", + }, + ], + }, + }, +} diff --git a/.github/workflows/labels.yml b/.github/workflows/labels.yml index a4208d7f1..cbfe87f80 100644 --- a/.github/workflows/labels.yml +++ b/.github/workflows/labels.yml @@ -1,107 +1,59 @@ # This workflow is managed by gh actions-lock. # SPDX-License-Identifier: MPL-2.0 -name: Labels - -# Applies the canonical estate label set from .github/labels.json. -# -# Additive and idempotent by design: it CREATES missing labels and UPDATES -# colour/description drift. It never deletes, and it never touches a label in -# the `frozen` list -- those are applied by Dependabot / PR automation, or are -# wired into triage.yml's exempt-issue-labels, and renaming them breaks things. -# -# jq is preinstalled on GitHub runners; PyYAML is not, which is why the payload -# is JSON rather than YAML. -# -# ⚠ NO `uses:` ANYWHERE, DELIBERATELY. The estate enforces -# .github/workflows/actions.lock, which is keyed BY WORKFLOW PATH: a workflow -# the lock does not list is rejected before any step runs (startup_failure, and -# therefore no check run at all). A dispatched workflow lands in repos whose -# lock has not been regenerated, so it must not depend on any action. - -on: - workflow_dispatch: - push: - paths: - - '.github/labels.json' - schedule: - - cron: "23 4 1 * *" # monthly drift repair - -permissions: - issues: write - contents: read - -jobs: - sync: - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - name: Apply canonical labels - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # ⚠ LOAD-BEARING. This workflow deliberately does not check the repo - # out (no `uses:`, so no actions.lock entry can drift), which means - # `gh label create` / `gh label edit` have no git remote to infer a - # target from. Without GH_REPO every mutation fails, and because the - # errors used to be discarded the step still exited 0 reporting - # "created=0 updated=0" -- a silent, estate-wide no-op. - GH_REPO: ${{ github.repository }} - run: | - set -uo pipefail - work=$(mktemp -d); PAYLOAD=$work/labels.json - - # fetch instead of checking out -- no action means no lock entry to drift - gh api "repos/$GITHUB_REPOSITORY/contents/.github/labels.json?ref=$GITHUB_SHA" \ - --jq '.content' 2>/dev/null | base64 -d > "$PAYLOAD" || true - [ -s "$PAYLOAD" ] || { echo "no .github/labels.json - nothing to do"; exit 0; } - - mapfile -t FROZEN < <(jq -r '.frozen[]' "$PAYLOAD") - created=0; updated=0; skipped=0; failed=0 - - existing=$(gh api "repos/$GITHUB_REPOSITORY/labels" --paginate \ - --jq '.[] | [.name, .color, (.description // "")] | @tsv') - - while IFS=$'\t' read -r name color desc; do - [ -z "$name" ] && continue - frozen=0 - for f in "${FROZEN[@]}"; do [ "$f" = "$name" ] && frozen=1 && break; done - - cur=$(printf '%s\n' "$existing" | awk -F'\t' -v n="$name" '$1==n{print;exit}') - if [ -z "$cur" ]; then - # A MISSING label is created even when frozen. "Frozen" protects a - # label's DEFINITION from being renamed or recoloured -- it was - # never meant to stop the label existing. Skipping creation broke - # `security`, the one canonical label that is also frozen: it was - # absent from 10 of 12 sampled repos, and label-triage drops any - # label the repo does not define, so every `security` finding was - # silently discarded estate-wide. - if err=$(gh label create "$name" --color "$color" \ - --description "$desc" 2>&1 >/dev/null); then - created=$((created+1)); sleep 0.4 - else - echo " create failed: $name -- ${err:-unknown}"; failed=$((failed+1)) - fi - else - # Present AND frozen: leave it exactly as it is. - if [ "$frozen" -eq 1 ]; then skipped=$((skipped+1)); continue; fi - ccol=$(cut -f2 <<<"$cur"); cdesc=$(cut -f3- <<<"$cur") - if [ "${ccol,,}" != "${color,,}" ] || [ "$cdesc" != "$desc" ]; then - if err=$(gh label edit "$name" --color "$color" \ - --description "$desc" 2>&1 >/dev/null); then - updated=$((updated+1)); sleep 0.4 - else - echo " edit failed: $name -- ${err:-unknown}"; failed=$((failed+1)) - fi - fi - fi - done < <(jq -r '.labels[] | [.name, .color, .description] | @tsv' "$PAYLOAD") - - echo "created=$created updated=$updated frozen-skipped=$skipped failed=$failed" - - # Fail ONLY on the misconfiguration shape: work was attempted, every - # attempt failed. That is the silent-no-op signature. A single flaky - # label must not turn the whole estate's CI red. - if [ "$failed" -gt 0 ] && [ "$((created + updated))" -eq 0 ]; then - echo "every label mutation failed - the sync did nothing. Check GH_REPO and token scope." - exit 1 - fi - exit 0 +{ + name: "Labels", + # Applies the canonical estate label set from .github/labels.json. + # + # Additive and idempotent by design: it CREATES missing labels and UPDATES + # colour/description drift. It never deletes, and it never touches a label in + # the `frozen` list -- those are applied by Dependabot / PR automation, or are + # wired into triage.yml's exempt-issue-labels, and renaming them breaks things. + # + # jq is preinstalled on GitHub runners; PyYAML is not, which is why the payload + # is JSON rather than YAML. + # + # ⚠ NO `uses:` ANYWHERE, DELIBERATELY. The estate enforces + # .github/workflows/actions.lock, which is keyed BY WORKFLOW PATH: a workflow + # the lock does not list is rejected before any step runs (startup_failure, and + # therefore no check run at all). A dispatched workflow lands in repos whose + # lock has not been regenerated, so it must not depend on any action. + on: { + workflow_dispatch: null, + push: { + paths: [ + ".github/labels.json", + ], + }, + schedule: [ + { + cron: "23 4 1 * *", # monthly drift repair + }, + ], + }, + permissions: { + issues: "write", + contents: "read", + }, + jobs: { + sync: { + runs-on: "ubuntu-latest", + timeout-minutes: 10, + steps: [ + { + name: "Apply canonical labels", + env: { + GH_TOKEN: "${{ secrets.GITHUB_TOKEN }}", + # ⚠ LOAD-BEARING. This workflow deliberately does not check the repo + # out (no `uses:`, so no actions.lock entry can drift), which means + # `gh label create` / `gh label edit` have no git remote to infer a + # target from. Without GH_REPO every mutation fails, and because the + # errors used to be discarded the step still exited 0 reporting + # "created=0 updated=0" -- a silent, estate-wide no-op. + GH_REPO: "${{ github.repository }}", + }, + run: "set -uo pipefail\nwork=$(mktemp -d); PAYLOAD=$work/labels.json\n\n# fetch instead of checking out -- no action means no lock entry to drift\ngh api \"repos/$GITHUB_REPOSITORY/contents/.github/labels.json?ref=$GITHUB_SHA\" \\\n --jq '.content' 2>/dev/null | base64 -d > \"$PAYLOAD\" || true\n[ -s \"$PAYLOAD\" ] || { echo \"no .github/labels.json - nothing to do\"; exit 0; }\n\nmapfile -t FROZEN < <(jq -r '.frozen[]' \"$PAYLOAD\")\ncreated=0; updated=0; skipped=0; failed=0\n\nexisting=$(gh api \"repos/$GITHUB_REPOSITORY/labels\" --paginate \\\n --jq '.[] | [.name, .color, (.description // \"\")] | @tsv')\n\nwhile IFS=$'\\t' read -r name color desc; do\n [ -z \"$name\" ] && continue\n frozen=0\n for f in \"${FROZEN[@]}\"; do [ \"$f\" = \"$name\" ] && frozen=1 && break; done\n\n cur=$(printf '%s\\n' \"$existing\" | awk -F'\\t' -v n=\"$name\" '$1==n{print;exit}')\n if [ -z \"$cur\" ]; then\n # A MISSING label is created even when frozen. \"Frozen\" protects a\n # label's DEFINITION from being renamed or recoloured -- it was\n # never meant to stop the label existing. Skipping creation broke\n # `security`, the one canonical label that is also frozen: it was\n # absent from 10 of 12 sampled repos, and label-triage drops any\n # label the repo does not define, so every `security` finding was\n # silently discarded estate-wide.\n if err=$(gh label create \"$name\" --color \"$color\" \\\n --description \"$desc\" 2>&1 >/dev/null); then\n created=$((created+1)); sleep 0.4\n else\n echo \" create failed: $name -- ${err:-unknown}\"; failed=$((failed+1))\n fi\n else\n # Present AND frozen: leave it exactly as it is.\n if [ \"$frozen\" -eq 1 ]; then skipped=$((skipped+1)); continue; fi\n ccol=$(cut -f2 <<<\"$cur\"); cdesc=$(cut -f3- <<<\"$cur\")\n if [ \"${ccol,,}\" != \"${color,,}\" ] || [ \"$cdesc\" != \"$desc\" ]; then\n if err=$(gh label edit \"$name\" --color \"$color\" \\\n --description \"$desc\" 2>&1 >/dev/null); then\n updated=$((updated+1)); sleep 0.4\n else\n echo \" edit failed: $name -- ${err:-unknown}\"; failed=$((failed+1))\n fi\n fi\n fi\ndone < <(jq -r '.labels[] | [.name, .color, .description] | @tsv' \"$PAYLOAD\")\n\necho \"created=$created updated=$updated frozen-skipped=$skipped failed=$failed\"\n\n# Fail ONLY on the misconfiguration shape: work was attempted, every\n# attempt failed. That is the silent-no-op signature. A single flaky\n# label must not turn the whole estate's CI red.\nif [ \"$failed\" -gt 0 ] && [ \"$((created + updated))\" -eq 0 ]; then\n echo \"every label mutation failed - the sync did nothing. Check GH_REPO and token scope.\"\n exit 1\nfi\nexit 0\n", + }, + ], + }, + }, +} diff --git a/.github/workflows/launcher-standard-lockstep.yml b/.github/workflows/launcher-standard-lockstep.yml index 66a5fe999..37017c4d2 100644 --- a/.github/workflows/launcher-standard-lockstep.yml +++ b/.github/workflows/launcher-standard-lockstep.yml @@ -1,102 +1,55 @@ # This workflow is managed by gh actions-lock. # SPDX-License-Identifier: MPL-2.0 # This workflow is managed by gh actions-lock. -name: launcher-standard lock-step - -# Enforces the sync requirement declared in launcher/README.adoc §Sync -# requirement: the machine-readable form (launcher-standard_praxis.deed) and -# the prose form (docs/UX-standards/launcher-standard.adoc) MUST be edited -# in the same PR. Drift between the two is a standards-compliance bug. -# -# How it works: the workflow triggers only when one of the two files in -# the lock-step group is touched. Inside the job we then verify that the -# other file is *also* in the PR diff. If not, fail with an actionable -# message naming the missing file. - -on: - pull_request: - paths: - - 'launcher/launcher-standard_praxis.deed' - - 'docs/UX-standards/launcher-standard.adoc' - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -permissions: - actions: read - contents: read - -jobs: - lockstep: - timeout-minutes: 10 - name: Verify launcher-standard lock-step - runs-on: ubuntu-latest - steps: - - name: Checkout (full history for base/head diff) - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4 - with: - fetch-depth: 0 - - - name: Verify both files changed - env: - BASE_SHA: ${{ github.event.pull_request.base.sha }} - HEAD_SHA: ${{ github.event.pull_request.head.sha }} - run: | - set -euo pipefail - - # The lock-step group. Editing any of these MUST be accompanied - # by edits to the others in the same PR. - declare -a GROUP=( - "launcher/launcher-standard_praxis.deed" - "docs/UX-standards/launcher-standard.adoc" - ) - - mapfile -t CHANGED < <(git diff --name-only "${BASE_SHA}" "${HEAD_SHA}") - - declare -a TOUCHED=() - declare -a MISSING=() - for f in "${GROUP[@]}"; do - found=0 - for c in "${CHANGED[@]}"; do - if [[ "$c" == "$f" ]]; then - found=1 - break - fi - done - if [[ $found -eq 1 ]]; then - TOUCHED+=("$f") - else - MISSING+=("$f") - fi - done - - # If the workflow ran, paths: filter matched at least one group - # member — so TOUCHED is non-empty. The check is whether MISSING - # is also non-empty (partial-touch = drift). - if [[ ${#MISSING[@]} -gt 0 ]]; then - echo "::error::launcher-standard lock-step violation" - echo "" - echo "Per launcher/README.adoc §Sync requirement, the machine-readable" - echo "form (deed) and the prose form (adoc) MUST be edited in the" - echo "same PR. Drift between them is a standards-compliance bug." - echo "" - echo "Touched in this PR:" - for f in "${TOUCHED[@]}"; do - echo " + ${f}" - done - echo "" - echo "Required but NOT touched:" - for f in "${MISSING[@]}"; do - echo " - ${f}" - done - echo "" - echo "Fix: add the matching edit to the file(s) above. If your" - echo "change is genuinely one-sided (e.g. a prose-only typo fix" - echo "with no deed equivalent), add a no-op whitespace touch to" - echo "the other file with a commit message explaining why." - exit 1 - fi - - echo "::notice::launcher-standard lock-step: OK — both files touched" - printf ' + %s\n' "${TOUCHED[@]}" +{ + name: "launcher-standard lock-step", + # Enforces the sync requirement declared in launcher/README.adoc §Sync + # requirement: the machine-readable form (launcher-standard_praxis.deed) and + # the prose form (docs/UX-standards/launcher-standard.adoc) MUST be edited + # in the same PR. Drift between the two is a standards-compliance bug. + # + # How it works: the workflow triggers only when one of the two files in + # the lock-step group is touched. Inside the job we then verify that the + # other file is *also* in the PR diff. If not, fail with an actionable + # message naming the missing file. + on: { + pull_request: { + paths: [ + "launcher/launcher-standard_praxis.deed", + "docs/UX-standards/launcher-standard.adoc", + ], + }, + }, + concurrency: { + group: "${{ github.workflow }}-${{ github.ref }}", + cancel-in-progress: true, + }, + permissions: { + actions: "read", + contents: "read", + }, + jobs: { + lockstep: { + timeout-minutes: 10, + name: "Verify launcher-standard lock-step", + runs-on: "ubuntu-latest", + steps: [ + { + name: "Checkout (full history for base/head diff)", + uses: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", # v4 + with: { + fetch-depth: 0, + }, + }, + { + name: "Verify both files changed", + env: { + BASE_SHA: "${{ github.event.pull_request.base.sha }}", + HEAD_SHA: "${{ github.event.pull_request.head.sha }}", + }, + run: "set -euo pipefail\n\n# The lock-step group. Editing any of these MUST be accompanied\n# by edits to the others in the same PR.\ndeclare -a GROUP=(\n \"launcher/launcher-standard_praxis.deed\"\n \"docs/UX-standards/launcher-standard.adoc\"\n)\n\nmapfile -t CHANGED < <(git diff --name-only \"${BASE_SHA}\" \"${HEAD_SHA}\")\n\ndeclare -a TOUCHED=()\ndeclare -a MISSING=()\nfor f in \"${GROUP[@]}\"; do\n found=0\n for c in \"${CHANGED[@]}\"; do\n if [[ \"$c\" == \"$f\" ]]; then\n found=1\n break\n fi\n done\n if [[ $found -eq 1 ]]; then\n TOUCHED+=(\"$f\")\n else\n MISSING+=(\"$f\")\n fi\ndone\n\n# If the workflow ran, paths: filter matched at least one group\n# member — so TOUCHED is non-empty. The check is whether MISSING\n# is also non-empty (partial-touch = drift).\nif [[ ${#MISSING[@]} -gt 0 ]]; then\n echo \"::error::launcher-standard lock-step violation\"\n echo \"\"\n echo \"Per launcher/README.adoc §Sync requirement, the machine-readable\"\n echo \"form (deed) and the prose form (adoc) MUST be edited in the\"\n echo \"same PR. Drift between them is a standards-compliance bug.\"\n echo \"\"\n echo \"Touched in this PR:\"\n for f in \"${TOUCHED[@]}\"; do\n echo \" + ${f}\"\n done\n echo \"\"\n echo \"Required but NOT touched:\"\n for f in \"${MISSING[@]}\"; do\n echo \" - ${f}\"\n done\n echo \"\"\n echo \"Fix: add the matching edit to the file(s) above. If your\"\n echo \"change is genuinely one-sided (e.g. a prose-only typo fix\"\n echo \"with no deed equivalent), add a no-op whitespace touch to\"\n echo \"the other file with a commit message explaining why.\"\n exit 1\nfi\n\necho \"::notice::launcher-standard lock-step: OK — both files touched\"\nprintf ' + %s\\n' \"${TOUCHED[@]}\"\n", + }, + ], + }, + }, +} diff --git a/.github/workflows/lock-selfcheck.yml b/.github/workflows/lock-selfcheck.yml index f7729c8d8..9449dd040 100644 --- a/.github/workflows/lock-selfcheck.yml +++ b/.github/workflows/lock-selfcheck.yml @@ -23,39 +23,39 @@ # # SCOPE: this proves INTERNAL CONSISTENCY of the commit under test. It does # NOT prove the commit is reachable at the remote — that is a separate probe. -name: Lock Self-Check - -on: - pull_request: - push: - branches: [main] - workflow_dispatch: - -# Deliberately NO `paths:` filter. This check is intended to be REQUIRED, and -# a required check that filters itself off does not report as passing — it -# does not report at all, which leaves the PR blocked on a context that can -# never arrive. - -permissions: - contents: read - -jobs: - selfcheck: - name: Lockfile self-consistency - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - - name: Checkout the commit under test - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - name: Check this commit validates against its own actions.lock - run: | - set -euo pipefail - sha="$(git rev-parse HEAD)" - # State the population. A gate that does not say what it examined - # cannot report that it examined the wrong thing. - echo "Commit under test: $sha" - echo "Event: ${{ github.event_name }}" - # On `pull_request` this is the MERGE PREVIEW, i.e. the content that - # would land on main, which is exactly the content callers will pin. - bash scripts/lock-selfcheck.sh "$sha" +{ + name: "Lock Self-Check", + on: { + pull_request: null, + push: { + branches: [ + "main", + ], + }, + workflow_dispatch: null, + }, + # Deliberately NO `paths:` filter. This check is intended to be REQUIRED, and + # a required check that filters itself off does not report as passing — it + # does not report at all, which leaves the PR blocked on a context that can + # never arrive. + permissions: { + contents: "read", + }, + jobs: { + selfcheck: { + name: "Lockfile self-consistency", + runs-on: "ubuntu-latest", + timeout-minutes: 5, + steps: [ + { + name: "Checkout the commit under test", + uses: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", # v7.0.1 + }, + { + name: "Check this commit validates against its own actions.lock", + run: "set -euo pipefail\nsha=\"$(git rev-parse HEAD)\"\n# State the population. A gate that does not say what it examined\n# cannot report that it examined the wrong thing.\necho \"Commit under test: $sha\"\necho \"Event: ${{ github.event_name }}\"\n# On `pull_request` this is the MERGE PREVIEW, i.e. the content that\n# would land on main, which is exactly the content callers will pin.\nbash scripts/lock-selfcheck.sh \"$sha\"\n", + }, + ], + }, + }, +} diff --git a/.github/workflows/lock-sync-gate.yml b/.github/workflows/lock-sync-gate.yml index b5c0a959c..358a02174 100644 --- a/.github/workflows/lock-sync-gate.yml +++ b/.github/workflows/lock-sync-gate.yml @@ -1,64 +1,64 @@ # SPDX-License-Identifier: MPL-2.0 -name: Lock Sync Gate - -# Estate-wide template from hyperpolymath/standards for issue #968. -# Adapted from metadatastician/burble#224. -# -# Fails any pull request whose .github/workflows/actions.lock has drifted from -# the workflow YAML. That drift is not cosmetic: GitHub refuses such a run at -# startup, creating ZERO jobs, and reports only "This run likely failed because -# of a workflow file issue." -# -# This workflow deliberately carries NO `uses:` of its own. It checks out by -# calling git in a `run:` step instead of using actions/checkout, so it has no -# lockfile entry to go stale and is structurally immune to the very failure it -# detects. Do not add a `uses:` to this file. -# -# There is also no `paths:` filter, on purpose: a filtered workflow never -# reports on pull requests that miss the filter, which deadlocks any branch -# ruleset that requires this check. -# -# For the 39 repos with step-level desync (hyperpolymath/standards#968): -# - 37 have latest run conclusion = startup_failure -# - 2 are stale (will die on next run) -# The cure: adopt this gate, then regenerate the lockfile with: -# gh actions-lock --no-migrate-local-actions --no-narrow -# And verify with: -# gh actions-lock --verify-local - -on: - pull_request: - push: - branches: [main] - -permissions: - contents: read - -concurrency: - group: lock-sync-gate-${{ github.ref }} - cancel-in-progress: true - -jobs: - lock-sync: - name: actions.lock is in sync with the workflow YAML - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - - name: Check out without actions/checkout - env: - REPO: ${{ github.repository }} - SHA: ${{ github.event.pull_request.head.sha || github.sha }} - run: | - set -euo pipefail - git init -q . - git remote add origin "https://github.com/${REPO}.git" - git fetch -q --depth 1 origin "${SHA}" - git checkout -q FETCH_HEAD - echo "checked out ${SHA}" - - - name: Verify lockfile synchronisation - run: | - set -euo pipefail - test -x scripts/check-lock-sync.sh \ - || { echo "::error::scripts/check-lock-sync.sh missing or not executable"; exit 1; } - ./scripts/check-lock-sync.sh +{ + name: "Lock Sync Gate", + # Estate-wide template from hyperpolymath/standards for issue #968. + # Adapted from metadatastician/burble#224. + # + # Fails any pull request whose .github/workflows/actions.lock has drifted from + # the workflow YAML. That drift is not cosmetic: GitHub refuses such a run at + # startup, creating ZERO jobs, and reports only "This run likely failed because + # of a workflow file issue." + # + # This workflow deliberately carries NO `uses:` of its own. It checks out by + # calling git in a `run:` step instead of using actions/checkout, so it has no + # lockfile entry to go stale and is structurally immune to the very failure it + # detects. Do not add a `uses:` to this file. + # + # There is also no `paths:` filter, on purpose: a filtered workflow never + # reports on pull requests that miss the filter, which deadlocks any branch + # ruleset that requires this check. + # + # For the 39 repos with step-level desync (hyperpolymath/standards#968): + # - 37 have latest run conclusion = startup_failure + # - 2 are stale (will die on next run) + # The cure: adopt this gate, then regenerate the lockfile with: + # gh actions-lock --no-migrate-local-actions --no-narrow + # And verify with: + # gh actions-lock --verify-local + on: { + pull_request: null, + push: { + branches: [ + "main", + ], + }, + }, + permissions: { + contents: "read", + }, + concurrency: { + group: "lock-sync-gate-${{ github.ref }}", + cancel-in-progress: true, + }, + jobs: { + lock-sync: { + name: "actions.lock is in sync with the workflow YAML", + runs-on: "ubuntu-latest", + timeout-minutes: 5, + steps: [ + { + name: "Check out without actions/checkout", + env: { + REPO: "${{ github.repository }}", + SHA: "${{ github.event.pull_request.head.sha || github.sha }}", + }, + run: "set -euo pipefail\ngit init -q .\ngit remote add origin \"https://github.com/${REPO}.git\"\ngit fetch -q --depth 1 origin \"${SHA}\"\ngit checkout -q FETCH_HEAD\necho \"checked out ${SHA}\"\n", + }, + { + name: "Verify lockfile synchronisation", + run: "set -euo pipefail\ntest -x scripts/check-lock-sync.sh \\\n || { echo \"::error::scripts/check-lock-sync.sh missing or not executable\"; exit 1; }\n./scripts/check-lock-sync.sh\n", + }, + ], + }, + }, +} diff --git a/.github/workflows/lockfile-drift-detect.yml b/.github/workflows/lockfile-drift-detect.yml index 438fb0840..abd9bbb0a 100644 --- a/.github/workflows/lockfile-drift-detect.yml +++ b/.github/workflows/lockfile-drift-detect.yml @@ -21,180 +21,84 @@ # This workflow REPORTS ONLY. It opens/updates one tracking issue and never # mutates another repository — consistent with the estate guardrail that # unattended cross-repo mutation is a human decision. -name: Lockfile Drift Detect - -on: - schedule: - # Tuesday 07:20 UTC — after Dependabot's usual weekly window, so a bump - # and its resulting drift are caught in the same week rather than the next. - - cron: '20 7 * * 2' - workflow_dispatch: - inputs: - limit: - description: 'Max repos to scan (0 = all)' - type: string - default: '0' - -concurrency: - group: ${{ github.workflow }} - cancel-in-progress: false - -permissions: - contents: read - issues: write - -jobs: - sweep: - name: Sweep the estate for lockfile drift - runs-on: ubuntu-latest - timeout-minutes: 45 - steps: - - name: Harden runner (egress audit) - uses: step-security/harden-runner@e14015d583714f6e62063499dc959a02595150a1 # v2.21.1 - with: - egress-policy: audit - - name: Checkout standards (for the checker script) - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - - - name: Sweep - id: sweep - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - LIMIT: ${{ inputs.limit || '0' }} - run: | - set -euo pipefail - : > drift-report.tsv - printf 'repo\tworkflow\trequested\tlocked\n' >> drift-report.tsv - - # Enumerate, then filter. `gh repo list` has a denominator we can - # state; `gh search` does not. A count landing on a round number is a - # page size, not a measurement. - gh repo list hyperpolymath --limit 1000 --no-archived \ - --json name --jq '.[].name' > repos.txt - TOTAL=$(wc -l < repos.txt) - echo "enumerated $TOTAL non-archived repos" - # NOT `[ test ] && cmd` — under `set -e` a false test makes the whole - # compound non-zero and kills the run, and 0 is the DEFAULT. - if [ "$LIMIT" != "0" ]; then - head -n "$LIMIT" repos.txt > r.tmp - mv r.tmp repos.txt - fi - - scanned=0; withlock=0; drifted=0; errors=0 - while read -r r; do - scanned=$((scanned+1)) - # Cheap probe first: skip repos with no lockfile (that is failure - # mode 1, not drift, and a lockfile-free repo may be legitimately - # outside the enforcement cohort). - gh api "repos/hyperpolymath/$r/contents/.github/workflows/actions.lock" \ - --jq .size >/dev/null 2>&1 || continue - withlock=$((withlock+1)) - - rm -rf _w - if ! git clone -q --depth 1 \ - "https://x-access-token:${GH_TOKEN}@github.com/hyperpolymath/$r.git" _w 2>/dev/null; then - continue - fi - # Exit-code honesty (issue #708): 1 = drift found, 2 = usage/env - # error. The old `if !` folded both into 'drifted', so a broken - # clone counted as a drifted repo. The slug arg gives column 1 a - # real identity; without it every row said `_w`. - rc=0 - bash scripts/check-lockfile-drift.sh _w "$r" >> drift-report.tsv 2>> drift-errors.log || rc=$? - if [ "$rc" -eq 1 ]; then - drifted=$((drifted+1)) - elif [ "$rc" -ne 0 ]; then - errors=$((errors+1)) - echo "::warning::$r: drift check errored (rc=$rc) — see drift-errors.log" - fi - rm -rf _w - done < repos.txt - - # Count data rows only: lines bearing a TAB, minus the header. - # (wc -l − 1 counted banner lines as drift entries when banners - # still leaked into stdout — 67 of 418 'entries' in run 2026-09-15 - # were `[drift] clean` lines, issue #708.) - rows=$(( $(grep -c $'\t' drift-report.tsv || true) - 1 )) - { - echo "total=$TOTAL" - echo "scanned=$scanned" - echo "withlock=$withlock" - echo "drifted=$drifted" - echo "errors=$errors" - echo "rows=$rows" - } >> "$GITHUB_OUTPUT" - echo "scanned=$scanned withlock=$withlock drifted=$drifted errors=$errors rows=$rows" - - - name: Upload report - if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a - with: - name: lockfile-drift-report - path: drift-report.tsv - retention-days: 90 - - - name: Step summary - if: always() - run: | - { - echo "## Lockfile Drift Detect" - echo - echo "| | |" - echo "|---|---:|" - echo "| non-archived repos | ${{ steps.sweep.outputs.total }} |" - echo "| scanned | ${{ steps.sweep.outputs.scanned }} |" - echo "| carrying a lockfile | ${{ steps.sweep.outputs.withlock }} |" - echo "| **with drift** | **${{ steps.sweep.outputs.drifted }}** |" - echo "| check errors (rc≠0,1) | ${{ steps.sweep.outputs.errors }} |" - echo "| drifted entries | ${{ steps.sweep.outputs.rows }} |" - echo - echo '```' - head -40 drift-report.tsv - echo '```' - } >> "$GITHUB_STEP_SUMMARY" - - - name: Open / update the tracking issue - if: steps.sweep.outputs.drifted != '0' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - set -euo pipefail - title="lockfile drift: ${{ steps.sweep.outputs.drifted }} repo(s) as of $(date -u +%Y-%m-%d)" - - # NOTE: deliberately NOT a heredoc. A heredoc body sits at column 0, - # which terminates the enclosing `run: |` block scalar and makes the - # workflow unparseable — the exact defect that took - # .git-private-farm/inbox-steward-propagate.yml out of service and - # blocked `gh actions-lock` from running at all. Build the body with - # indented appends instead. - { - echo "Automated sweep for **lockfile version drift** — the estate's recurring CI killer." - echo - echo "**The fault.** Dependabot bumps an action version in a workflow; \`actions.lock\` is not regenerated; the workflow then requests a version the lockfile does not record, and GitHub rejects the run at \`startup_failure\` with **zero jobs** — no log, no annotation. It is self-reinflicting, so affected repos recur." - echo - echo "**To fix a listed repo:** run \`gh actions-lock\`, then restore the SPDX header to line 1 (the tool restamps its banner above it and the workflow-security linter greps \`head -1\` only)." - echo - echo "**Calibration:** a listed row is a genuine inconsistency but is *not* proof that workflow is dead — some drift is survivable. The reliable direction is the converse: workflows that were dead had drifted entries." - echo - echo "Full list in the run's \`lockfile-drift-report.tsv\` artifact." - echo - echo "| | |" - echo "|---|---:|" - echo "| scanned | ${{ steps.sweep.outputs.scanned }} |" - echo "| carrying a lockfile | ${{ steps.sweep.outputs.withlock }} |" - echo "| **with drift** | **${{ steps.sweep.outputs.drifted }}** |" - echo "| check errors (rc≠0,1) | ${{ steps.sweep.outputs.errors }} |" - echo "| drifted entries | ${{ steps.sweep.outputs.rows }} |" - } > issue-body.md - body="$(cat issue-body.md)" - existing=$(gh issue list --repo "$GITHUB_REPOSITORY" --label lockfile-drift \ - --state open --limit 1 --json number --jq '.[0].number // empty') - if [ -n "$existing" ]; then - gh issue edit "$existing" --repo "$GITHUB_REPOSITORY" --title "$title" --body "$body" - gh issue comment "$existing" --repo "$GITHUB_REPOSITORY" \ - --body "Re-swept $(date -u +%Y-%m-%d): **${{ steps.sweep.outputs.drifted }}** repo(s) drifted." - else - gh issue create --repo "$GITHUB_REPOSITORY" --title "$title" --body "$body" \ - --label lockfile-drift || \ - gh issue create --repo "$GITHUB_REPOSITORY" --title "$title" --body "$body" - fi +{ + name: "Lockfile Drift Detect", + on: { + schedule: [ + # Tuesday 07:20 UTC — after Dependabot's usual weekly window, so a bump + # and its resulting drift are caught in the same week rather than the next. + { + cron: "20 7 * * 2", + }, + ], + workflow_dispatch: { + inputs: { + limit: { + description: "Max repos to scan (0 = all)", + type: "string", + default: "0", + }, + }, + }, + }, + concurrency: { + group: "${{ github.workflow }}", + cancel-in-progress: false, + }, + permissions: { + contents: "read", + issues: "write", + }, + jobs: { + sweep: { + name: "Sweep the estate for lockfile drift", + runs-on: "ubuntu-latest", + timeout-minutes: 45, + steps: [ + { + name: "Harden runner (egress audit)", + uses: "step-security/harden-runner@e14015d583714f6e62063499dc959a02595150a1", # v2.21.1 + with: { + egress-policy: "audit", + }, + }, + { + name: "Checkout standards (for the checker script)", + uses: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", + }, + { + name: "Sweep", + id: "sweep", + env: { + GH_TOKEN: "${{ secrets.GITHUB_TOKEN }}", + LIMIT: "${{ inputs.limit || '0' }}", + }, + run: "set -euo pipefail\n: > drift-report.tsv\nprintf 'repo\\tworkflow\\trequested\\tlocked\\n' >> drift-report.tsv\n\n# Enumerate, then filter. `gh repo list` has a denominator we can\n# state; `gh search` does not. A count landing on a round number is a\n# page size, not a measurement.\ngh repo list hyperpolymath --limit 1000 --no-archived \\\n --json name --jq '.[].name' > repos.txt\nTOTAL=$(wc -l < repos.txt)\necho \"enumerated $TOTAL non-archived repos\"\n# NOT `[ test ] && cmd` — under `set -e` a false test makes the whole\n# compound non-zero and kills the run, and 0 is the DEFAULT.\nif [ \"$LIMIT\" != \"0\" ]; then\n head -n \"$LIMIT\" repos.txt > r.tmp\n mv r.tmp repos.txt\nfi\n\nscanned=0; withlock=0; drifted=0; errors=0\nwhile read -r r; do\n scanned=$((scanned+1))\n # Cheap probe first: skip repos with no lockfile (that is failure\n # mode 1, not drift, and a lockfile-free repo may be legitimately\n # outside the enforcement cohort).\n gh api \"repos/hyperpolymath/$r/contents/.github/workflows/actions.lock\" \\\n --jq .size >/dev/null 2>&1 || continue\n withlock=$((withlock+1))\n\n rm -rf _w\n if ! git clone -q --depth 1 \\\n \"https://x-access-token:${GH_TOKEN}@github.com/hyperpolymath/$r.git\" _w 2>/dev/null; then\n continue\n fi\n # Exit-code honesty (issue #708): 1 = drift found, 2 = usage/env\n # error. The old `if !` folded both into 'drifted', so a broken\n # clone counted as a drifted repo. The slug arg gives column 1 a\n # real identity; without it every row said `_w`.\n rc=0\n bash scripts/check-lockfile-drift.sh _w \"$r\" >> drift-report.tsv 2>> drift-errors.log || rc=$?\n if [ \"$rc\" -eq 1 ]; then\n drifted=$((drifted+1))\n elif [ \"$rc\" -ne 0 ]; then\n errors=$((errors+1))\n echo \"::warning::$r: drift check errored (rc=$rc) — see drift-errors.log\"\n fi\n rm -rf _w\ndone < repos.txt\n\n# Count data rows only: lines bearing a TAB, minus the header.\n# (wc -l − 1 counted banner lines as drift entries when banners\n# still leaked into stdout — 67 of 418 'entries' in run 2026-09-15\n# were `[drift] clean` lines, issue #708.)\nrows=$(( $(grep -c $'\\t' drift-report.tsv || true) - 1 ))\n{\n echo \"total=$TOTAL\"\n echo \"scanned=$scanned\"\n echo \"withlock=$withlock\"\n echo \"drifted=$drifted\"\n echo \"errors=$errors\"\n echo \"rows=$rows\"\n} >> \"$GITHUB_OUTPUT\"\necho \"scanned=$scanned withlock=$withlock drifted=$drifted errors=$errors rows=$rows\"\n", + }, + { + name: "Upload report", + if: "always()", + uses: "actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a", + with: { + name: "lockfile-drift-report", + path: "drift-report.tsv", + retention-days: 90, + }, + }, + { + name: "Step summary", + if: "always()", + run: "{\n echo \"## Lockfile Drift Detect\"\n echo\n echo \"| | |\"\n echo \"|---|---:|\"\n echo \"| non-archived repos | ${{ steps.sweep.outputs.total }} |\"\n echo \"| scanned | ${{ steps.sweep.outputs.scanned }} |\"\n echo \"| carrying a lockfile | ${{ steps.sweep.outputs.withlock }} |\"\n echo \"| **with drift** | **${{ steps.sweep.outputs.drifted }}** |\"\n echo \"| check errors (rc≠0,1) | ${{ steps.sweep.outputs.errors }} |\"\n echo \"| drifted entries | ${{ steps.sweep.outputs.rows }} |\"\n echo\n echo '```'\n head -40 drift-report.tsv\n echo '```'\n} >> \"$GITHUB_STEP_SUMMARY\"\n", + }, + { + name: "Open / update the tracking issue", + if: "steps.sweep.outputs.drifted != '0'", + env: { + GH_TOKEN: "${{ secrets.GITHUB_TOKEN }}", + }, + run: "set -euo pipefail\ntitle=\"lockfile drift: ${{ steps.sweep.outputs.drifted }} repo(s) as of $(date -u +%Y-%m-%d)\"\n\n# NOTE: deliberately NOT a heredoc. A heredoc body sits at column 0,\n# which terminates the enclosing `run: |` block scalar and makes the\n# workflow unparseable — the exact defect that took\n# .git-private-farm/inbox-steward-propagate.yml out of service and\n# blocked `gh actions-lock` from running at all. Build the body with\n# indented appends instead.\n{\n echo \"Automated sweep for **lockfile version drift** — the estate's recurring CI killer.\"\n echo\n echo \"**The fault.** Dependabot bumps an action version in a workflow; \\`actions.lock\\` is not regenerated; the workflow then requests a version the lockfile does not record, and GitHub rejects the run at \\`startup_failure\\` with **zero jobs** — no log, no annotation. It is self-reinflicting, so affected repos recur.\"\n echo\n echo \"**To fix a listed repo:** run \\`gh actions-lock\\`, then restore the SPDX header to line 1 (the tool restamps its banner above it and the workflow-security linter greps \\`head -1\\` only).\"\n echo\n echo \"**Calibration:** a listed row is a genuine inconsistency but is *not* proof that workflow is dead — some drift is survivable. The reliable direction is the converse: workflows that were dead had drifted entries.\"\n echo\n echo \"Full list in the run's \\`lockfile-drift-report.tsv\\` artifact.\"\n echo\n echo \"| | |\"\n echo \"|---|---:|\"\n echo \"| scanned | ${{ steps.sweep.outputs.scanned }} |\"\n echo \"| carrying a lockfile | ${{ steps.sweep.outputs.withlock }} |\"\n echo \"| **with drift** | **${{ steps.sweep.outputs.drifted }}** |\"\n echo \"| check errors (rc≠0,1) | ${{ steps.sweep.outputs.errors }} |\"\n echo \"| drifted entries | ${{ steps.sweep.outputs.rows }} |\"\n} > issue-body.md\nbody=\"$(cat issue-body.md)\"\nexisting=$(gh issue list --repo \"$GITHUB_REPOSITORY\" --label lockfile-drift \\\n --state open --limit 1 --json number --jq '.[0].number // empty')\nif [ -n \"$existing\" ]; then\n gh issue edit \"$existing\" --repo \"$GITHUB_REPOSITORY\" --title \"$title\" --body \"$body\"\n gh issue comment \"$existing\" --repo \"$GITHUB_REPOSITORY\" \\\n --body \"Re-swept $(date -u +%Y-%m-%d): **${{ steps.sweep.outputs.drifted }}** repo(s) drifted.\"\nelse\n gh issue create --repo \"$GITHUB_REPOSITORY\" --title \"$title\" --body \"$body\" \\\n --label lockfile-drift || \\\n gh issue create --repo \"$GITHUB_REPOSITORY\" --title \"$title\" --body \"$body\"\nfi\n", + }, + ], + }, + }, +} diff --git a/.github/workflows/makefile-blocker.yml b/.github/workflows/makefile-blocker.yml index 11b1a1558..3eda9b13b 100644 --- a/.github/workflows/makefile-blocker.yml +++ b/.github/workflows/makefile-blocker.yml @@ -1,52 +1,51 @@ # This workflow is managed by gh actions-lock. # SPDX-License-Identifier: MPL-2.0 # This workflow is managed by gh actions-lock. -name: Makefile Blocker - -on: - push: - paths: - - '**/Makefile' - - '**/Makefile.*' - - '**/*.mk' - pull_request: - paths: - - '**/Makefile' - - '**/Makefile.*' - - '**/*.mk' - -# Estate guardrail: cancel superseded runs so re-pushes / rebased PR -# updates do not pile up queued runs against the shared account-wide -# Actions concurrency pool. Applied only to read-only check workflows -# (no publish/mutation), so cancelling a superseded run is always safe. -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -permissions: - actions: read - contents: read - -jobs: - block-makefiles: - timeout-minutes: 10 - name: Block Makefile Changes - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4 - - - name: Fail on Makefile presence - run: | - echo "::error::Makefiles are not permitted in Hyperpolymath repositories." - echo "" - echo "Use the Mustfile/justfile system instead:" - echo " - Mustfile: Mandatory checks (see hyperpolymath/mustfile)" - echo " - justfile: Build recipes (https://just.systems)" - echo " - mustfile.toml: Configuration" - echo "" - echo "Found Makefiles:" - find . -name "Makefile" -o -name "Makefile.*" -o -name "*.mk" | grep -v ".github" - echo "" - echo "To migrate, see: https://github.com/hyperpolymath/mustfile" - exit 1 +{ + name: "Makefile Blocker", + on: { + push: { + paths: [ + "**/Makefile", + "**/Makefile.*", + "**/*.mk", + ], + }, + pull_request: { + paths: [ + "**/Makefile", + "**/Makefile.*", + "**/*.mk", + ], + }, + }, + # Estate guardrail: cancel superseded runs so re-pushes / rebased PR + # updates do not pile up queued runs against the shared account-wide + # Actions concurrency pool. Applied only to read-only check workflows + # (no publish/mutation), so cancelling a superseded run is always safe. + concurrency: { + group: "${{ github.workflow }}-${{ github.ref }}", + cancel-in-progress: true, + }, + permissions: { + actions: "read", + contents: "read", + }, + jobs: { + block-makefiles: { + timeout-minutes: 10, + name: "Block Makefile Changes", + runs-on: "ubuntu-latest", + steps: [ + { + name: "Checkout", + uses: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", # v4 + }, + { + name: "Fail on Makefile presence", + run: "echo \"::error::Makefiles are not permitted in Hyperpolymath repositories.\"\necho \"\"\necho \"Use the Mustfile/justfile system instead:\"\necho \" - Mustfile: Mandatory checks (see hyperpolymath/mustfile)\"\necho \" - justfile: Build recipes (https://just.systems)\"\necho \" - mustfile.toml: Configuration\"\necho \"\"\necho \"Found Makefiles:\"\nfind . -name \"Makefile\" -o -name \"Makefile.*\" -o -name \"*.mk\" | grep -v \".github\"\necho \"\"\necho \"To migrate, see: https://github.com/hyperpolymath/mustfile\"\nexit 1\n", + }, + ], + }, + }, +} diff --git a/.github/workflows/mirror-reusable.yml b/.github/workflows/mirror-reusable.yml index 7338c4266..861fabb4b 100644 --- a/.github/workflows/mirror-reusable.yml +++ b/.github/workflows/mirror-reusable.yml @@ -34,396 +34,404 @@ # uses: hyperpolymath/standards/.github/workflows/mirror-reusable.yml@ # secrets: inherit -name: Mirror to Git Forges (reusable) - -on: - workflow_call: - inputs: - runs-on: - description: Runner label for all mirror jobs - type: string - required: false - default: ubuntu-latest - - secrets: - GITLAB_SSH_KEY: - required: false - BITBUCKET_SSH_KEY: - required: false - CODEBERG_SSH_KEY: - required: false - SOURCEHUT_SSH_KEY: - required: false - DISROOT_SSH_KEY: - required: false - GITEA_SSH_KEY: - required: false - RADICLE_KEY: - required: false - -permissions: - actions: read - contents: read - -jobs: - mirror-gitlab: - # Advisory mirror: verification still fails closed and skips the push. - continue-on-error: true - timeout-minutes: 20 - runs-on: ${{ inputs.runs-on }} - if: vars.GITLAB_MIRROR_ENABLED == 'true' - # Map the secret to env so step `if:`s can gate on its presence: the - # `secrets` context is NOT available in `if:` (using it is an - # "Unrecognized named-value: 'secrets'" startup failure). `env` IS - # available in step `if:`, and secrets are valid in job-level `env`. - env: - GITLAB_KEY: ${{ secrets.GITLAB_SSH_KEY }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - fetch-depth: 0 - - uses: webfactory/ssh-agent@e83874834305fe9a4a2997156cb26c5de65a8555 # v0.10.0 - if: ${{ env.GITLAB_KEY != '' }} - with: - ssh-private-key: ${{ secrets.GITLAB_SSH_KEY }} - - name: Verify gitlab SSH host - if: ${{ env.GITLAB_KEY != '' }} - env: - MIRROR_HOST: gitlab.com - APPROVED_FINGERPRINT: ${{ vars.GITLAB_SSH_FINGERPRINT || 'SHA256:eUXGGm1YGsMAS7vkcx6JOJdOGHPem5gQp4taiCfCLB8' }} - run: | - set -euo pipefail - # Approved independently of ssh-keyscan; source: https://docs.gitlab.com/user/gitlab_com/#ssh-host-keys-fingerprints - if [[ ! "$MIRROR_HOST" =~ ^[a-zA-Z0-9][a-zA-Z0-9.-]*$ ]] || - [[ ! "$APPROVED_FINGERPRINT" =~ ^SHA256:[A-Za-z0-9+/]{43}$ ]]; then - echo "::error::Configure a valid GITLAB_SSH_FINGERPRINT and mirror hostname" - exit 1 - fi - CANDIDATE="$RUNNER_TEMP/mirror-gitlab-known-hosts" - ssh-keyscan -T 10 -t ed25519 "$MIRROR_HOST" > "$CANDIDATE" - ACTUAL=$(ssh-keygen -lf "$CANDIDATE" -E sha256 | awk '{print $2}' | sort -u) - if [ "$ACTUAL" != "$APPROVED_FINGERPRINT" ]; then - echo "::error::Mirror SSH host fingerprint mismatch; refusing push" - exit 1 - fi - printf 'GIT_SSH_COMMAND=ssh -o StrictHostKeyChecking=yes -o GlobalKnownHostsFile=/dev/null -o UserKnownHostsFile=%s\n' "$CANDIDATE" >> "$GITHUB_ENV" - - name: Mirror to GitLab - # continue-on-error: GitLab branch protection on the mirror repo may block - # force-push even for a deploy key. Owner action required: in GitLab go to - # Settings → Repository → Protected branches → main and either allow force-push - # for Maintainers/Developers or remove the protection on the mirror repo. - # Until then this step is advisory-only; failures do not red main. - continue-on-error: true - if: ${{ env.GITLAB_KEY != '' }} - env: - MIRROR_ORG: ${{ vars.GITLAB_ORG || vars.MIRROR_ORG || github.repository_owner }} - REPO_NAME: ${{ github.event.repository.name }} - MIRROR_HOST: gitlab.com - run: | - git remote add gitlab "git@gitlab.com:${MIRROR_ORG}/${REPO_NAME}.git" || true - git push --force gitlab main - - name: Skipped (GITLAB_SSH_KEY not configured) - if: ${{ env.GITLAB_KEY == '' }} - run: | - echo "::notice::GITLAB_MIRROR_ENABLED=true but secrets.GITLAB_SSH_KEY is empty. Skipping GitLab mirror. Configure the GITLAB_SSH_KEY org/repo secret to enable." - - mirror-bitbucket: - timeout-minutes: 20 - runs-on: ${{ inputs.runs-on }} - if: vars.BITBUCKET_MIRROR_ENABLED == 'true' - # See mirror-gitlab: the `secrets` context is not valid in step `if:`; - # hoist to job-level `env` and gate steps on `env.BITBUCKET_KEY`. - env: - BITBUCKET_KEY: ${{ secrets.BITBUCKET_SSH_KEY }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - fetch-depth: 0 - - uses: webfactory/ssh-agent@e83874834305fe9a4a2997156cb26c5de65a8555 # v0.10.0 - if: ${{ env.BITBUCKET_KEY != '' }} - with: - ssh-private-key: ${{ secrets.BITBUCKET_SSH_KEY }} - - name: Verify bitbucket SSH host - if: ${{ env.BITBUCKET_KEY != '' }} - env: - MIRROR_HOST: bitbucket.org - APPROVED_FINGERPRINT: ${{ vars.BITBUCKET_SSH_FINGERPRINT || 'SHA256:ybgmFkzwOSotHTHLJgHO0QN8L0xErw6vd0VhFA9m3SM' }} - run: | - set -euo pipefail - # Approved independently of ssh-keyscan; source: https://support.atlassian.com/bitbucket-cloud/docs/configure-ssh-and-two-step-verification/ - if [[ ! "$MIRROR_HOST" =~ ^[a-zA-Z0-9][a-zA-Z0-9.-]*$ ]] || - [[ ! "$APPROVED_FINGERPRINT" =~ ^SHA256:[A-Za-z0-9+/]{43}$ ]]; then - echo "::error::Configure a valid BITBUCKET_SSH_FINGERPRINT and mirror hostname" - exit 1 - fi - CANDIDATE="$RUNNER_TEMP/mirror-bitbucket-known-hosts" - ssh-keyscan -T 10 -t ed25519 "$MIRROR_HOST" > "$CANDIDATE" - ACTUAL=$(ssh-keygen -lf "$CANDIDATE" -E sha256 | awk '{print $2}' | sort -u) - if [ "$ACTUAL" != "$APPROVED_FINGERPRINT" ]; then - echo "::error::Mirror SSH host fingerprint mismatch; refusing push" - exit 1 - fi - printf 'GIT_SSH_COMMAND=ssh -o StrictHostKeyChecking=yes -o GlobalKnownHostsFile=/dev/null -o UserKnownHostsFile=%s\n' "$CANDIDATE" >> "$GITHUB_ENV" - - name: Mirror to Bitbucket - if: ${{ env.BITBUCKET_KEY != '' }} - env: - MIRROR_ORG: ${{ vars.BITBUCKET_ORG || vars.MIRROR_ORG || github.repository_owner }} - REPO_NAME: ${{ github.event.repository.name }} - MIRROR_HOST: bitbucket.org - run: | - git remote add bitbucket "git@bitbucket.org:${MIRROR_ORG}/${REPO_NAME}.git" || true - git push --force bitbucket main - - name: Skipped (BITBUCKET_SSH_KEY not configured) - if: ${{ env.BITBUCKET_KEY == '' }} - run: | - echo "::notice::BITBUCKET_MIRROR_ENABLED=true but secrets.BITBUCKET_SSH_KEY is empty. Skipping Bitbucket mirror. Configure the BITBUCKET_SSH_KEY org/repo secret to enable." - - mirror-codeberg: - timeout-minutes: 20 - runs-on: ${{ inputs.runs-on }} - if: vars.CODEBERG_MIRROR_ENABLED == 'true' - # See mirror-gitlab: the `secrets` context is not valid in step `if:`; - # hoist to job-level `env` and gate steps on `env.CODEBERG_KEY`. - env: - CODEBERG_KEY: ${{ secrets.CODEBERG_SSH_KEY }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - fetch-depth: 0 - - uses: webfactory/ssh-agent@e83874834305fe9a4a2997156cb26c5de65a8555 # v0.10.0 - if: ${{ env.CODEBERG_KEY != '' }} - with: - ssh-private-key: ${{ secrets.CODEBERG_SSH_KEY }} - - name: Verify codeberg SSH host - if: ${{ env.CODEBERG_KEY != '' }} - env: - MIRROR_HOST: codeberg.org - APPROVED_FINGERPRINT: ${{ vars.CODEBERG_SSH_FINGERPRINT || 'SHA256:mIlxA9k46MmM6qdJOdMnAQpzGxF4WIVVL+fj+wZbw0g' }} - run: | - set -euo pipefail - # Approved independently of ssh-keyscan; source: https://docs.codeberg.org/security/ssh-fingerprint/ - if [[ ! "$MIRROR_HOST" =~ ^[a-zA-Z0-9][a-zA-Z0-9.-]*$ ]] || - [[ ! "$APPROVED_FINGERPRINT" =~ ^SHA256:[A-Za-z0-9+/]{43}$ ]]; then - echo "::error::Configure a valid CODEBERG_SSH_FINGERPRINT and mirror hostname" - exit 1 - fi - CANDIDATE="$RUNNER_TEMP/mirror-codeberg-known-hosts" - ssh-keyscan -T 10 -t ed25519 "$MIRROR_HOST" > "$CANDIDATE" - ACTUAL=$(ssh-keygen -lf "$CANDIDATE" -E sha256 | awk '{print $2}' | sort -u) - if [ "$ACTUAL" != "$APPROVED_FINGERPRINT" ]; then - echo "::error::Mirror SSH host fingerprint mismatch; refusing push" - exit 1 - fi - printf 'GIT_SSH_COMMAND=ssh -o StrictHostKeyChecking=yes -o GlobalKnownHostsFile=/dev/null -o UserKnownHostsFile=%s\n' "$CANDIDATE" >> "$GITHUB_ENV" - - name: Mirror to Codeberg - if: ${{ env.CODEBERG_KEY != '' }} - env: - MIRROR_ORG: ${{ vars.CODEBERG_ORG || vars.MIRROR_ORG || github.repository_owner }} - REPO_NAME: ${{ github.event.repository.name }} - MIRROR_HOST: codeberg.org - run: | - git remote add codeberg "git@codeberg.org:${MIRROR_ORG}/${REPO_NAME}.git" || true - git push --force codeberg main - - name: Skipped (CODEBERG_SSH_KEY not configured) - if: ${{ env.CODEBERG_KEY == '' }} - run: | - echo "::notice::CODEBERG_MIRROR_ENABLED=true but secrets.CODEBERG_SSH_KEY is empty. Skipping Codeberg mirror. Configure the CODEBERG_SSH_KEY org/repo secret to enable." - - mirror-sourcehut: - timeout-minutes: 20 - runs-on: ${{ inputs.runs-on }} - if: vars.SOURCEHUT_MIRROR_ENABLED == 'true' - # See mirror-gitlab: the `secrets` context is not valid in step `if:`; - # hoist to job-level `env` and gate steps on `env.SOURCEHUT_KEY`. - env: - SOURCEHUT_KEY: ${{ secrets.SOURCEHUT_SSH_KEY }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - fetch-depth: 0 - - uses: webfactory/ssh-agent@e83874834305fe9a4a2997156cb26c5de65a8555 # v0.10.0 - if: ${{ env.SOURCEHUT_KEY != '' }} - with: - ssh-private-key: ${{ secrets.SOURCEHUT_SSH_KEY }} - - name: Verify sourcehut SSH host - if: ${{ env.SOURCEHUT_KEY != '' }} - env: - MIRROR_HOST: git.sr.ht - APPROVED_FINGERPRINT: ${{ vars.SOURCEHUT_SSH_FINGERPRINT }} - run: | - set -euo pipefail - # Approved independently of ssh-keyscan; source: https://man.sr.ht/ - if [[ ! "$MIRROR_HOST" =~ ^[a-zA-Z0-9][a-zA-Z0-9.-]*$ ]] || - [[ ! "$APPROVED_FINGERPRINT" =~ ^SHA256:[A-Za-z0-9+/]{43}$ ]]; then - echo "::error::Configure a valid SOURCEHUT_SSH_FINGERPRINT and mirror hostname" - exit 1 - fi - CANDIDATE="$RUNNER_TEMP/mirror-sourcehut-known-hosts" - ssh-keyscan -T 10 -t ed25519 "$MIRROR_HOST" > "$CANDIDATE" - ACTUAL=$(ssh-keygen -lf "$CANDIDATE" -E sha256 | awk '{print $2}' | sort -u) - if [ "$ACTUAL" != "$APPROVED_FINGERPRINT" ]; then - echo "::error::Mirror SSH host fingerprint mismatch; refusing push" - exit 1 - fi - printf 'GIT_SSH_COMMAND=ssh -o StrictHostKeyChecking=yes -o GlobalKnownHostsFile=/dev/null -o UserKnownHostsFile=%s\n' "$CANDIDATE" >> "$GITHUB_ENV" - - name: Mirror to SourceHut - if: ${{ env.SOURCEHUT_KEY != '' }} - env: - MIRROR_ORG: ${{ vars.SOURCEHUT_ORG || vars.MIRROR_ORG || github.repository_owner }} - REPO_NAME: ${{ github.event.repository.name }} - MIRROR_HOST: git.sr.ht - run: | - git remote add sourcehut "git@git.sr.ht:~${MIRROR_ORG}/${REPO_NAME}" || true - git push --force sourcehut main - - name: Skipped (SOURCEHUT_SSH_KEY not configured) - if: ${{ env.SOURCEHUT_KEY == '' }} - run: | - echo "::notice::SOURCEHUT_MIRROR_ENABLED=true but secrets.SOURCEHUT_SSH_KEY is empty. Skipping SourceHut mirror. Configure the SOURCEHUT_SSH_KEY org/repo secret to enable." - - mirror-disroot: - timeout-minutes: 20 - runs-on: ${{ inputs.runs-on }} - if: vars.DISROOT_MIRROR_ENABLED == 'true' - # See mirror-gitlab: the `secrets` context is not valid in step `if:`; - # hoist to job-level `env` and gate steps on `env.DISROOT_KEY`. - env: - DISROOT_KEY: ${{ secrets.DISROOT_SSH_KEY }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - fetch-depth: 0 - - uses: webfactory/ssh-agent@e83874834305fe9a4a2997156cb26c5de65a8555 # v0.10.0 - if: ${{ env.DISROOT_KEY != '' }} - with: - ssh-private-key: ${{ secrets.DISROOT_SSH_KEY }} - - name: Verify disroot SSH host - if: ${{ env.DISROOT_KEY != '' }} - env: - MIRROR_HOST: git.disroot.org - APPROVED_FINGERPRINT: ${{ vars.DISROOT_SSH_FINGERPRINT }} - run: | - set -euo pipefail - # Caller must approve this independently of the ssh-keyscan result. - if [[ ! "$MIRROR_HOST" =~ ^[a-zA-Z0-9][a-zA-Z0-9.-]*$ ]] || - [[ ! "$APPROVED_FINGERPRINT" =~ ^SHA256:[A-Za-z0-9+/]{43}$ ]]; then - echo "::error::Configure a valid DISROOT_SSH_FINGERPRINT and mirror hostname" - exit 1 - fi - CANDIDATE="$RUNNER_TEMP/mirror-disroot-known-hosts" - ssh-keyscan -T 10 -t ed25519 "$MIRROR_HOST" > "$CANDIDATE" - ACTUAL=$(ssh-keygen -lf "$CANDIDATE" -E sha256 | awk '{print $2}' | sort -u) - if [ "$ACTUAL" != "$APPROVED_FINGERPRINT" ]; then - echo "::error::Mirror SSH host fingerprint mismatch; refusing push" - exit 1 - fi - printf 'GIT_SSH_COMMAND=ssh -o StrictHostKeyChecking=yes -o GlobalKnownHostsFile=/dev/null -o UserKnownHostsFile=%s\n' "$CANDIDATE" >> "$GITHUB_ENV" - - name: Mirror to Disroot - if: ${{ env.DISROOT_KEY != '' }} - env: - MIRROR_ORG: ${{ vars.DISROOT_ORG || vars.MIRROR_ORG || github.repository_owner }} - REPO_NAME: ${{ github.event.repository.name }} - MIRROR_HOST: git.disroot.org - run: | - git remote add disroot "git@git.disroot.org:${MIRROR_ORG}/${REPO_NAME}.git" || true - git push --force disroot main - - name: Skipped (DISROOT_SSH_KEY not configured) - if: ${{ env.DISROOT_KEY == '' }} - run: | - echo "::notice::DISROOT_MIRROR_ENABLED=true but secrets.DISROOT_SSH_KEY is empty. Skipping Disroot mirror. Configure the DISROOT_SSH_KEY org/repo secret to enable." - - mirror-gitea: - timeout-minutes: 20 - runs-on: ${{ inputs.runs-on }} - if: vars.GITEA_MIRROR_ENABLED == 'true' - # See mirror-gitlab: the `secrets` context is not valid in step `if:`; - # hoist to job-level `env` and gate steps on `env.GITEA_KEY`. - env: - GITEA_KEY: ${{ secrets.GITEA_SSH_KEY }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - fetch-depth: 0 - - uses: webfactory/ssh-agent@e83874834305fe9a4a2997156cb26c5de65a8555 # v0.10.0 - if: ${{ env.GITEA_KEY != '' }} - with: - ssh-private-key: ${{ secrets.GITEA_SSH_KEY }} - - name: Verify gitea SSH host - if: ${{ env.GITEA_KEY != '' }} - env: - MIRROR_HOST: ${{ vars.GITEA_HOST }} - APPROVED_FINGERPRINT: ${{ vars.GITEA_SSH_FINGERPRINT }} - run: | - set -euo pipefail - # Approved independently of ssh-keyscan; source: caller-controlled instance - if [[ ! "$MIRROR_HOST" =~ ^[a-zA-Z0-9][a-zA-Z0-9.-]*$ ]] || - [[ ! "$APPROVED_FINGERPRINT" =~ ^SHA256:[A-Za-z0-9+/]{43}$ ]]; then - echo "::error::Configure a valid GITEA_SSH_FINGERPRINT and mirror hostname" - exit 1 - fi - CANDIDATE="$RUNNER_TEMP/mirror-gitea-known-hosts" - ssh-keyscan -T 10 -t ed25519 "$MIRROR_HOST" > "$CANDIDATE" - ACTUAL=$(ssh-keygen -lf "$CANDIDATE" -E sha256 | awk '{print $2}' | sort -u) - if [ "$ACTUAL" != "$APPROVED_FINGERPRINT" ]; then - echo "::error::Mirror SSH host fingerprint mismatch; refusing push" - exit 1 - fi - printf 'GIT_SSH_COMMAND=ssh -o StrictHostKeyChecking=yes -o GlobalKnownHostsFile=/dev/null -o UserKnownHostsFile=%s\n' "$CANDIDATE" >> "$GITHUB_ENV" - - name: Mirror to Gitea - if: ${{ env.GITEA_KEY != '' }} - env: - MIRROR_ORG: ${{ vars.GITEA_ORG || vars.MIRROR_ORG || github.repository_owner }} - REPO_NAME: ${{ github.event.repository.name }} - MIRROR_HOST: ${{ vars.GITEA_HOST }} - run: | - git remote add gitea "git@${MIRROR_HOST}:${MIRROR_ORG}/${REPO_NAME}.git" || true - git push --force gitea main - - name: Skipped (GITEA_SSH_KEY not configured) - if: ${{ env.GITEA_KEY == '' }} - run: | - echo "::notice::GITEA_MIRROR_ENABLED=true but secrets.GITEA_SSH_KEY is empty. Skipping Gitea mirror. Configure the GITEA_SSH_KEY org/repo secret to enable." - - mirror-radicle: - timeout-minutes: 20 - runs-on: ${{ inputs.runs-on }} - if: vars.RADICLE_MIRROR_ENABLED == 'true' - # Map the secret to env so step `if:`s can gate on its presence: the - # `secrets` context is NOT available in `if:` (using it is an - # "Unrecognized named-value: 'secrets'" startup failure). `env` IS - # available in step `if:`, and secrets are valid in job-level `env`. - env: - RADICLE_KEY: ${{ secrets.RADICLE_KEY }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - 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 - # 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 - # `echo "" > ...` write into a non-existent dir errors out — and - # even if the dir existed, the empty-key write would never sync. - # Caught 26 estate repos on the 2026-05-30 audit. The vars gate - # answers "is Radicle mirror desired here?"; the secret gate - # answers "are we configured to actually do it?". - - name: Setup Rust - if: ${{ env.RADICLE_KEY != '' }} - uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de # stable - with: - toolchain: stable - - name: Install Radicle - if: ${{ env.RADICLE_KEY != '' }} - run: | - cargo install radicle-cli --locked - echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" - - name: Mirror to Radicle - if: ${{ env.RADICLE_KEY != '' }} - run: | - mkdir -p ~/.radicle/keys - printf "%s\n" "$RADICLE_KEY" > ~/.radicle/keys/radicle - chmod 600 ~/.radicle/keys/radicle - rad sync --announce || echo "Radicle sync attempted" - - name: Skipped (RADICLE_KEY not configured) - 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." +{ + name: "Mirror to Git Forges (reusable)", + on: { + workflow_call: { + inputs: { + runs-on: { + description: "Runner label for all mirror jobs", + type: "string", + required: false, + default: "ubuntu-latest", + }, + }, + secrets: { + GITLAB_SSH_KEY: { + required: false, + }, + BITBUCKET_SSH_KEY: { + required: false, + }, + CODEBERG_SSH_KEY: { + required: false, + }, + SOURCEHUT_SSH_KEY: { + required: false, + }, + DISROOT_SSH_KEY: { + required: false, + }, + GITEA_SSH_KEY: { + required: false, + }, + RADICLE_KEY: { + required: false, + }, + }, + }, + }, + permissions: { + actions: "read", + contents: "read", + }, + jobs: { + mirror-gitlab: { + # Advisory mirror: verification still fails closed and skips the push. + continue-on-error: true, + timeout-minutes: 20, + runs-on: "${{ inputs.runs-on }}", + if: "vars.GITLAB_MIRROR_ENABLED == 'true'", + # Map the secret to env so step `if:`s can gate on its presence: the + # `secrets` context is NOT available in `if:` (using it is an + # "Unrecognized named-value: 'secrets'" startup failure). `env` IS + # available in step `if:`, and secrets are valid in job-level `env`. + env: { + GITLAB_KEY: "${{ secrets.GITLAB_SSH_KEY }}", + }, + steps: [ + { + uses: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", # v7.0.1 + with: { + fetch-depth: 0, + }, + }, + { + uses: "webfactory/ssh-agent@e83874834305fe9a4a2997156cb26c5de65a8555", # v0.10.0 + if: "${{ env.GITLAB_KEY != '' }}", + with: { + ssh-private-key: "${{ secrets.GITLAB_SSH_KEY }}", + }, + }, + { + name: "Verify gitlab SSH host", + if: "${{ env.GITLAB_KEY != '' }}", + env: { + MIRROR_HOST: "gitlab.com", + APPROVED_FINGERPRINT: "${{ vars.GITLAB_SSH_FINGERPRINT || 'SHA256:eUXGGm1YGsMAS7vkcx6JOJdOGHPem5gQp4taiCfCLB8' }}", + }, + run: "set -euo pipefail\n# Approved independently of ssh-keyscan; source: https://docs.gitlab.com/user/gitlab_com/#ssh-host-keys-fingerprints\nif [[ ! \"$MIRROR_HOST\" =~ ^[a-zA-Z0-9][a-zA-Z0-9.-]*$ ]] ||\n [[ ! \"$APPROVED_FINGERPRINT\" =~ ^SHA256:[A-Za-z0-9+/]{43}$ ]]; then\n echo \"::error::Configure a valid GITLAB_SSH_FINGERPRINT and mirror hostname\"\n exit 1\nfi\nCANDIDATE=\"$RUNNER_TEMP/mirror-gitlab-known-hosts\"\nssh-keyscan -T 10 -t ed25519 \"$MIRROR_HOST\" > \"$CANDIDATE\"\nACTUAL=$(ssh-keygen -lf \"$CANDIDATE\" -E sha256 | awk '{print $2}' | sort -u)\nif [ \"$ACTUAL\" != \"$APPROVED_FINGERPRINT\" ]; then\n echo \"::error::Mirror SSH host fingerprint mismatch; refusing push\"\n exit 1\nfi\nprintf 'GIT_SSH_COMMAND=ssh -o StrictHostKeyChecking=yes -o GlobalKnownHostsFile=/dev/null -o UserKnownHostsFile=%s\\n' \"$CANDIDATE\" >> \"$GITHUB_ENV\"\n", + }, + { + name: "Mirror to GitLab", + # continue-on-error: GitLab branch protection on the mirror repo may block + # force-push even for a deploy key. Owner action required: in GitLab go to + # Settings → Repository → Protected branches → main and either allow force-push + # for Maintainers/Developers or remove the protection on the mirror repo. + # Until then this step is advisory-only; failures do not red main. + continue-on-error: true, + if: "${{ env.GITLAB_KEY != '' }}", + env: { + MIRROR_ORG: "${{ vars.GITLAB_ORG || vars.MIRROR_ORG || github.repository_owner }}", + REPO_NAME: "${{ github.event.repository.name }}", + MIRROR_HOST: "gitlab.com", + }, + run: "git remote add gitlab \"git@gitlab.com:${MIRROR_ORG}/${REPO_NAME}.git\" || true\ngit push --force gitlab main\n", + }, + { + name: "Skipped (GITLAB_SSH_KEY not configured)", + if: "${{ env.GITLAB_KEY == '' }}", + run: "echo \"::notice::GITLAB_MIRROR_ENABLED=true but secrets.GITLAB_SSH_KEY is empty. Skipping GitLab mirror. Configure the GITLAB_SSH_KEY org/repo secret to enable.\"\n", + }, + ], + }, + mirror-bitbucket: { + timeout-minutes: 20, + runs-on: "${{ inputs.runs-on }}", + if: "vars.BITBUCKET_MIRROR_ENABLED == 'true'", + # See mirror-gitlab: the `secrets` context is not valid in step `if:`; + # hoist to job-level `env` and gate steps on `env.BITBUCKET_KEY`. + env: { + BITBUCKET_KEY: "${{ secrets.BITBUCKET_SSH_KEY }}", + }, + steps: [ + { + uses: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", # v7.0.1 + with: { + fetch-depth: 0, + }, + }, + { + uses: "webfactory/ssh-agent@e83874834305fe9a4a2997156cb26c5de65a8555", # v0.10.0 + if: "${{ env.BITBUCKET_KEY != '' }}", + with: { + ssh-private-key: "${{ secrets.BITBUCKET_SSH_KEY }}", + }, + }, + { + name: "Verify bitbucket SSH host", + if: "${{ env.BITBUCKET_KEY != '' }}", + env: { + MIRROR_HOST: "bitbucket.org", + APPROVED_FINGERPRINT: "${{ vars.BITBUCKET_SSH_FINGERPRINT || 'SHA256:ybgmFkzwOSotHTHLJgHO0QN8L0xErw6vd0VhFA9m3SM' }}", + }, + run: "set -euo pipefail\n# Approved independently of ssh-keyscan; source: https://support.atlassian.com/bitbucket-cloud/docs/configure-ssh-and-two-step-verification/\nif [[ ! \"$MIRROR_HOST\" =~ ^[a-zA-Z0-9][a-zA-Z0-9.-]*$ ]] ||\n [[ ! \"$APPROVED_FINGERPRINT\" =~ ^SHA256:[A-Za-z0-9+/]{43}$ ]]; then\n echo \"::error::Configure a valid BITBUCKET_SSH_FINGERPRINT and mirror hostname\"\n exit 1\nfi\nCANDIDATE=\"$RUNNER_TEMP/mirror-bitbucket-known-hosts\"\nssh-keyscan -T 10 -t ed25519 \"$MIRROR_HOST\" > \"$CANDIDATE\"\nACTUAL=$(ssh-keygen -lf \"$CANDIDATE\" -E sha256 | awk '{print $2}' | sort -u)\nif [ \"$ACTUAL\" != \"$APPROVED_FINGERPRINT\" ]; then\n echo \"::error::Mirror SSH host fingerprint mismatch; refusing push\"\n exit 1\nfi\nprintf 'GIT_SSH_COMMAND=ssh -o StrictHostKeyChecking=yes -o GlobalKnownHostsFile=/dev/null -o UserKnownHostsFile=%s\\n' \"$CANDIDATE\" >> \"$GITHUB_ENV\"\n", + }, + { + name: "Mirror to Bitbucket", + if: "${{ env.BITBUCKET_KEY != '' }}", + env: { + MIRROR_ORG: "${{ vars.BITBUCKET_ORG || vars.MIRROR_ORG || github.repository_owner }}", + REPO_NAME: "${{ github.event.repository.name }}", + MIRROR_HOST: "bitbucket.org", + }, + run: "git remote add bitbucket \"git@bitbucket.org:${MIRROR_ORG}/${REPO_NAME}.git\" || true\ngit push --force bitbucket main\n", + }, + { + name: "Skipped (BITBUCKET_SSH_KEY not configured)", + if: "${{ env.BITBUCKET_KEY == '' }}", + run: "echo \"::notice::BITBUCKET_MIRROR_ENABLED=true but secrets.BITBUCKET_SSH_KEY is empty. Skipping Bitbucket mirror. Configure the BITBUCKET_SSH_KEY org/repo secret to enable.\"\n", + }, + ], + }, + mirror-codeberg: { + timeout-minutes: 20, + runs-on: "${{ inputs.runs-on }}", + if: "vars.CODEBERG_MIRROR_ENABLED == 'true'", + # See mirror-gitlab: the `secrets` context is not valid in step `if:`; + # hoist to job-level `env` and gate steps on `env.CODEBERG_KEY`. + env: { + CODEBERG_KEY: "${{ secrets.CODEBERG_SSH_KEY }}", + }, + steps: [ + { + uses: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", # v7.0.1 + with: { + fetch-depth: 0, + }, + }, + { + uses: "webfactory/ssh-agent@e83874834305fe9a4a2997156cb26c5de65a8555", # v0.10.0 + if: "${{ env.CODEBERG_KEY != '' }}", + with: { + ssh-private-key: "${{ secrets.CODEBERG_SSH_KEY }}", + }, + }, + { + name: "Verify codeberg SSH host", + if: "${{ env.CODEBERG_KEY != '' }}", + env: { + MIRROR_HOST: "codeberg.org", + APPROVED_FINGERPRINT: "${{ vars.CODEBERG_SSH_FINGERPRINT || 'SHA256:mIlxA9k46MmM6qdJOdMnAQpzGxF4WIVVL+fj+wZbw0g' }}", + }, + run: "set -euo pipefail\n# Approved independently of ssh-keyscan; source: https://docs.codeberg.org/security/ssh-fingerprint/\nif [[ ! \"$MIRROR_HOST\" =~ ^[a-zA-Z0-9][a-zA-Z0-9.-]*$ ]] ||\n [[ ! \"$APPROVED_FINGERPRINT\" =~ ^SHA256:[A-Za-z0-9+/]{43}$ ]]; then\n echo \"::error::Configure a valid CODEBERG_SSH_FINGERPRINT and mirror hostname\"\n exit 1\nfi\nCANDIDATE=\"$RUNNER_TEMP/mirror-codeberg-known-hosts\"\nssh-keyscan -T 10 -t ed25519 \"$MIRROR_HOST\" > \"$CANDIDATE\"\nACTUAL=$(ssh-keygen -lf \"$CANDIDATE\" -E sha256 | awk '{print $2}' | sort -u)\nif [ \"$ACTUAL\" != \"$APPROVED_FINGERPRINT\" ]; then\n echo \"::error::Mirror SSH host fingerprint mismatch; refusing push\"\n exit 1\nfi\nprintf 'GIT_SSH_COMMAND=ssh -o StrictHostKeyChecking=yes -o GlobalKnownHostsFile=/dev/null -o UserKnownHostsFile=%s\\n' \"$CANDIDATE\" >> \"$GITHUB_ENV\"\n", + }, + { + name: "Mirror to Codeberg", + if: "${{ env.CODEBERG_KEY != '' }}", + env: { + MIRROR_ORG: "${{ vars.CODEBERG_ORG || vars.MIRROR_ORG || github.repository_owner }}", + REPO_NAME: "${{ github.event.repository.name }}", + MIRROR_HOST: "codeberg.org", + }, + run: "git remote add codeberg \"git@codeberg.org:${MIRROR_ORG}/${REPO_NAME}.git\" || true\ngit push --force codeberg main\n", + }, + { + name: "Skipped (CODEBERG_SSH_KEY not configured)", + if: "${{ env.CODEBERG_KEY == '' }}", + run: "echo \"::notice::CODEBERG_MIRROR_ENABLED=true but secrets.CODEBERG_SSH_KEY is empty. Skipping Codeberg mirror. Configure the CODEBERG_SSH_KEY org/repo secret to enable.\"\n", + }, + ], + }, + mirror-sourcehut: { + timeout-minutes: 20, + runs-on: "${{ inputs.runs-on }}", + if: "vars.SOURCEHUT_MIRROR_ENABLED == 'true'", + # See mirror-gitlab: the `secrets` context is not valid in step `if:`; + # hoist to job-level `env` and gate steps on `env.SOURCEHUT_KEY`. + env: { + SOURCEHUT_KEY: "${{ secrets.SOURCEHUT_SSH_KEY }}", + }, + steps: [ + { + uses: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", # v7.0.1 + with: { + fetch-depth: 0, + }, + }, + { + uses: "webfactory/ssh-agent@e83874834305fe9a4a2997156cb26c5de65a8555", # v0.10.0 + if: "${{ env.SOURCEHUT_KEY != '' }}", + with: { + ssh-private-key: "${{ secrets.SOURCEHUT_SSH_KEY }}", + }, + }, + { + name: "Verify sourcehut SSH host", + if: "${{ env.SOURCEHUT_KEY != '' }}", + env: { + MIRROR_HOST: "git.sr.ht", + APPROVED_FINGERPRINT: "${{ vars.SOURCEHUT_SSH_FINGERPRINT }}", + }, + run: "set -euo pipefail\n# Approved independently of ssh-keyscan; source: https://man.sr.ht/\nif [[ ! \"$MIRROR_HOST\" =~ ^[a-zA-Z0-9][a-zA-Z0-9.-]*$ ]] ||\n [[ ! \"$APPROVED_FINGERPRINT\" =~ ^SHA256:[A-Za-z0-9+/]{43}$ ]]; then\n echo \"::error::Configure a valid SOURCEHUT_SSH_FINGERPRINT and mirror hostname\"\n exit 1\nfi\nCANDIDATE=\"$RUNNER_TEMP/mirror-sourcehut-known-hosts\"\nssh-keyscan -T 10 -t ed25519 \"$MIRROR_HOST\" > \"$CANDIDATE\"\nACTUAL=$(ssh-keygen -lf \"$CANDIDATE\" -E sha256 | awk '{print $2}' | sort -u)\nif [ \"$ACTUAL\" != \"$APPROVED_FINGERPRINT\" ]; then\n echo \"::error::Mirror SSH host fingerprint mismatch; refusing push\"\n exit 1\nfi\nprintf 'GIT_SSH_COMMAND=ssh -o StrictHostKeyChecking=yes -o GlobalKnownHostsFile=/dev/null -o UserKnownHostsFile=%s\\n' \"$CANDIDATE\" >> \"$GITHUB_ENV\"\n", + }, + { + name: "Mirror to SourceHut", + if: "${{ env.SOURCEHUT_KEY != '' }}", + env: { + MIRROR_ORG: "${{ vars.SOURCEHUT_ORG || vars.MIRROR_ORG || github.repository_owner }}", + REPO_NAME: "${{ github.event.repository.name }}", + MIRROR_HOST: "git.sr.ht", + }, + run: "git remote add sourcehut \"git@git.sr.ht:~${MIRROR_ORG}/${REPO_NAME}\" || true\ngit push --force sourcehut main\n", + }, + { + name: "Skipped (SOURCEHUT_SSH_KEY not configured)", + if: "${{ env.SOURCEHUT_KEY == '' }}", + run: "echo \"::notice::SOURCEHUT_MIRROR_ENABLED=true but secrets.SOURCEHUT_SSH_KEY is empty. Skipping SourceHut mirror. Configure the SOURCEHUT_SSH_KEY org/repo secret to enable.\"\n", + }, + ], + }, + mirror-disroot: { + timeout-minutes: 20, + runs-on: "${{ inputs.runs-on }}", + if: "vars.DISROOT_MIRROR_ENABLED == 'true'", + # See mirror-gitlab: the `secrets` context is not valid in step `if:`; + # hoist to job-level `env` and gate steps on `env.DISROOT_KEY`. + env: { + DISROOT_KEY: "${{ secrets.DISROOT_SSH_KEY }}", + }, + steps: [ + { + uses: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", # v7.0.1 + with: { + fetch-depth: 0, + }, + }, + { + uses: "webfactory/ssh-agent@e83874834305fe9a4a2997156cb26c5de65a8555", # v0.10.0 + if: "${{ env.DISROOT_KEY != '' }}", + with: { + ssh-private-key: "${{ secrets.DISROOT_SSH_KEY }}", + }, + }, + { + name: "Verify disroot SSH host", + if: "${{ env.DISROOT_KEY != '' }}", + env: { + MIRROR_HOST: "git.disroot.org", + APPROVED_FINGERPRINT: "${{ vars.DISROOT_SSH_FINGERPRINT }}", + }, + run: "set -euo pipefail\n# Caller must approve this independently of the ssh-keyscan result.\nif [[ ! \"$MIRROR_HOST\" =~ ^[a-zA-Z0-9][a-zA-Z0-9.-]*$ ]] ||\n [[ ! \"$APPROVED_FINGERPRINT\" =~ ^SHA256:[A-Za-z0-9+/]{43}$ ]]; then\n echo \"::error::Configure a valid DISROOT_SSH_FINGERPRINT and mirror hostname\"\n exit 1\nfi\nCANDIDATE=\"$RUNNER_TEMP/mirror-disroot-known-hosts\"\nssh-keyscan -T 10 -t ed25519 \"$MIRROR_HOST\" > \"$CANDIDATE\"\nACTUAL=$(ssh-keygen -lf \"$CANDIDATE\" -E sha256 | awk '{print $2}' | sort -u)\nif [ \"$ACTUAL\" != \"$APPROVED_FINGERPRINT\" ]; then\n echo \"::error::Mirror SSH host fingerprint mismatch; refusing push\"\n exit 1\nfi\nprintf 'GIT_SSH_COMMAND=ssh -o StrictHostKeyChecking=yes -o GlobalKnownHostsFile=/dev/null -o UserKnownHostsFile=%s\\n' \"$CANDIDATE\" >> \"$GITHUB_ENV\"\n", + }, + { + name: "Mirror to Disroot", + if: "${{ env.DISROOT_KEY != '' }}", + env: { + MIRROR_ORG: "${{ vars.DISROOT_ORG || vars.MIRROR_ORG || github.repository_owner }}", + REPO_NAME: "${{ github.event.repository.name }}", + MIRROR_HOST: "git.disroot.org", + }, + run: "git remote add disroot \"git@git.disroot.org:${MIRROR_ORG}/${REPO_NAME}.git\" || true\ngit push --force disroot main\n", + }, + { + name: "Skipped (DISROOT_SSH_KEY not configured)", + if: "${{ env.DISROOT_KEY == '' }}", + run: "echo \"::notice::DISROOT_MIRROR_ENABLED=true but secrets.DISROOT_SSH_KEY is empty. Skipping Disroot mirror. Configure the DISROOT_SSH_KEY org/repo secret to enable.\"\n", + }, + ], + }, + mirror-gitea: { + timeout-minutes: 20, + runs-on: "${{ inputs.runs-on }}", + if: "vars.GITEA_MIRROR_ENABLED == 'true'", + # See mirror-gitlab: the `secrets` context is not valid in step `if:`; + # hoist to job-level `env` and gate steps on `env.GITEA_KEY`. + env: { + GITEA_KEY: "${{ secrets.GITEA_SSH_KEY }}", + }, + steps: [ + { + uses: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", # v7.0.1 + with: { + fetch-depth: 0, + }, + }, + { + uses: "webfactory/ssh-agent@e83874834305fe9a4a2997156cb26c5de65a8555", # v0.10.0 + if: "${{ env.GITEA_KEY != '' }}", + with: { + ssh-private-key: "${{ secrets.GITEA_SSH_KEY }}", + }, + }, + { + name: "Verify gitea SSH host", + if: "${{ env.GITEA_KEY != '' }}", + env: { + MIRROR_HOST: "${{ vars.GITEA_HOST }}", + APPROVED_FINGERPRINT: "${{ vars.GITEA_SSH_FINGERPRINT }}", + }, + run: "set -euo pipefail\n# Approved independently of ssh-keyscan; source: caller-controlled instance\nif [[ ! \"$MIRROR_HOST\" =~ ^[a-zA-Z0-9][a-zA-Z0-9.-]*$ ]] ||\n [[ ! \"$APPROVED_FINGERPRINT\" =~ ^SHA256:[A-Za-z0-9+/]{43}$ ]]; then\n echo \"::error::Configure a valid GITEA_SSH_FINGERPRINT and mirror hostname\"\n exit 1\nfi\nCANDIDATE=\"$RUNNER_TEMP/mirror-gitea-known-hosts\"\nssh-keyscan -T 10 -t ed25519 \"$MIRROR_HOST\" > \"$CANDIDATE\"\nACTUAL=$(ssh-keygen -lf \"$CANDIDATE\" -E sha256 | awk '{print $2}' | sort -u)\nif [ \"$ACTUAL\" != \"$APPROVED_FINGERPRINT\" ]; then\n echo \"::error::Mirror SSH host fingerprint mismatch; refusing push\"\n exit 1\nfi\nprintf 'GIT_SSH_COMMAND=ssh -o StrictHostKeyChecking=yes -o GlobalKnownHostsFile=/dev/null -o UserKnownHostsFile=%s\\n' \"$CANDIDATE\" >> \"$GITHUB_ENV\"\n", + }, + { + name: "Mirror to Gitea", + if: "${{ env.GITEA_KEY != '' }}", + env: { + MIRROR_ORG: "${{ vars.GITEA_ORG || vars.MIRROR_ORG || github.repository_owner }}", + REPO_NAME: "${{ github.event.repository.name }}", + MIRROR_HOST: "${{ vars.GITEA_HOST }}", + }, + run: "git remote add gitea \"git@${MIRROR_HOST}:${MIRROR_ORG}/${REPO_NAME}.git\" || true\ngit push --force gitea main\n", + }, + { + name: "Skipped (GITEA_SSH_KEY not configured)", + if: "${{ env.GITEA_KEY == '' }}", + run: "echo \"::notice::GITEA_MIRROR_ENABLED=true but secrets.GITEA_SSH_KEY is empty. Skipping Gitea mirror. Configure the GITEA_SSH_KEY org/repo secret to enable.\"\n", + }, + ], + }, + mirror-radicle: { + timeout-minutes: 20, + runs-on: "${{ inputs.runs-on }}", + if: "vars.RADICLE_MIRROR_ENABLED == 'true'", + # Map the secret to env so step `if:`s can gate on its presence: the + # `secrets` context is NOT available in `if:` (using it is an + # "Unrecognized named-value: 'secrets'" startup failure). `env` IS + # available in step `if:`, and secrets are valid in job-level `env`. + env: { + RADICLE_KEY: "${{ secrets.RADICLE_KEY }}", + }, + steps: [ + { + uses: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", # v7.0.1 + 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 + # 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 + # `echo "" > ...` write into a non-existent dir errors out — and + # even if the dir existed, the empty-key write would never sync. + # Caught 26 estate repos on the 2026-05-30 audit. The vars gate + # answers "is Radicle mirror desired here?"; the secret gate + # answers "are we configured to actually do it?". + { + name: "Setup Rust", + if: "${{ env.RADICLE_KEY != '' }}", + uses: "dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de", # stable + with: { + toolchain: "stable", + }, + }, + { + name: "Install Radicle", + if: "${{ env.RADICLE_KEY != '' }}", + run: "cargo install radicle-cli --locked\necho \"$HOME/.cargo/bin\" >> \"$GITHUB_PATH\"\n", + }, + { + name: "Mirror to Radicle", + if: "${{ env.RADICLE_KEY != '' }}", + run: "mkdir -p ~/.radicle/keys\nprintf \"%s\\n\" \"$RADICLE_KEY\" > ~/.radicle/keys/radicle\nchmod 600 ~/.radicle/keys/radicle\nrad sync --announce || echo \"Radicle sync attempted\"\n", + }, + { + name: "Skipped (RADICLE_KEY not configured)", + 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.\"\n", + }, + ], + }, + }, +} diff --git a/.github/workflows/mirror.yml b/.github/workflows/mirror.yml index 5440e4683..6a9b53301 100644 --- a/.github/workflows/mirror.yml +++ b/.github/workflows/mirror.yml @@ -1,18 +1,24 @@ # This workflow is managed by gh actions-lock. # SPDX-License-Identifier: MPL-2.0 # This workflow is managed by gh actions-lock. -name: Mirror to Git Forges - -on: - push: - branches: [main] - workflow_dispatch: - -permissions: - actions: read - contents: read - -jobs: - mirror: - uses: hyperpolymath/standards/.github/workflows/mirror-reusable.yml@571cc734cd69fb846032ec77a662aa8ee4fc32cd - secrets: inherit +{ + name: "Mirror to Git Forges", + on: { + push: { + branches: [ + "main", + ], + }, + workflow_dispatch: null, + }, + permissions: { + actions: "read", + contents: "read", + }, + jobs: { + mirror: { + uses: "hyperpolymath/standards/.github/workflows/mirror-reusable.yml@571cc734cd69fb846032ec77a662aa8ee4fc32cd", + secrets: "inherit", + }, + }, +} diff --git a/.github/workflows/no-js-scan.yml b/.github/workflows/no-js-scan.yml index 18ccf0ec2..303f60d94 100644 --- a/.github/workflows/no-js-scan.yml +++ b/.github/workflows/no-js-scan.yml @@ -1,108 +1,50 @@ # This workflow is managed by gh actions-lock. # SPDX-License-Identifier: MPL-2.0 # This workflow is managed by gh actions-lock. -name: No-JS Scan (warn-first) - -# Estate policy: no hand-authored JavaScript/TypeScript source -# (see standards docs/NO-JAVASCRIPT-SOURCE-POLICY.adoc). This workflow is -# WARN-FIRST: it reports the authored-JS surface for migration but never fails -# the build. The authoritative hard-block for NEW JS in non-carve-out paths is -# hypatia (cicd_rules/javascript_detected); this is an additive companion. - -on: - # NO path filter, deliberately. The previous filter matched only - # **/*.{js,jsx,mjs,cjs,ts,tsx} - precisely the files estate policy bans - so - # the better the estate complied, the more reliably this required check never - # ran and deadlocked every PR. The scan is a cheap grep; always run it. - push: - branches: [main] - pull_request: - -# Estate guardrail: cancel superseded runs (read-only check, no mutation). -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -permissions: - actions: read - contents: read - -jobs: - scan-authored-js: - timeout-minutes: 10 - name: Scan for hand-authored JavaScript/TypeScript - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4 - - - name: Report authored JS/TS (warn-first, non-blocking) - shell: bash - run: | - # Exclude only things that are NOT hand-authored source: vendored - # (node_modules, deps, vendor, .git), generated/compiled (*.res.js, - # *.res.mjs, lib/{js,es6,bs}, out, dist, .deno, generated/, *.min.js), - # and declaration headers (*.d.ts). Everything else is reported. - mapfile -t hits < <( - find . \ - \( -path './.git' -o -name node_modules -o -path '*/deps/*' \ - -o -path '*/vendor/*' -o -path '*/lib/js/*' -o -path '*/lib/es6/*' \ - -o -path '*/lib/bs/*' -o -path '*/out/*' -o -path '*/dist/*' \ - -o -path '*/.deno/*' -o -path '*/generated/*' \) -prune -o \ - -type f \ - \( -name '*.js' -o -name '*.jsx' -o -name '*.mjs' -o -name '*.cjs' \ - -o -name '*.ts' -o -name '*.tsx' \) \ - ! -name '*.res.js' ! -name '*.res.mjs' ! -name '*.min.js' ! -name '*.d.ts' \ - -print 2>/dev/null | sort || true - ) - - count=${#hits[@]} - - { - echo "# No-JS scan (warn-first)" - echo - echo "Estate policy: **no hand-authored JavaScript/TypeScript source.**" - echo "Destination is AffineScript -> typed-wasm, or Rust + Zig -> wasm." - echo "This check is **non-blocking** — it reports the migration surface only." - echo "Authoritative hard-block for new files: hypatia \`cicd_rules/javascript_detected\`." - echo "Policy: standards \`docs/NO-JAVASCRIPT-SOURCE-POLICY.adoc\`." - echo - echo "**Hand-authored JS/TS files found: ${count}**" - if [ "${count}" -gt 0 ]; then - echo - echo '| # | File |' - echo '|---|------|' - i=0 - for f in "${hits[@]}"; do - i=$((i + 1)) - echo "| ${i} | \`${f#./}\` |" - done - else - echo - echo "No hand-authored JavaScript/TypeScript found. :white_check_mark:" - fi - } >> "${GITHUB_STEP_SUMMARY}" - - # Ratchet. The existing files are debt, not a blocker; what is - # forbidden is ADDING to them. Reading the baseline from a committed - # file makes every reduction a visible, reviewable commit. - baseline_file=".github/no-js-baseline.txt" - baseline=$(tr -cd '0-9' < "${baseline_file}" 2>/dev/null || echo "") - if [ -z "${baseline}" ]; then - echo "::error title=No-JS ratchet::${baseline_file} is missing or not a number." - echo "A ratchet with no baseline cannot fail, which is the defect this replaced." - exit 1 - fi - - echo "count=${count} baseline=${baseline}" - - if [ "${count}" -gt "${baseline}" ]; then - echo "::error title=No-JS ratchet::${count} hand-authored JS/TS files, baseline is ${baseline}. Adding hand-authored JS/TS is not permitted; estate target is AffineScript->typed-wasm or Rust+Zig->wasm. See docs/NO-JAVASCRIPT-SOURCE-POLICY.adoc." - exit 1 - fi - - if [ "${count}" -lt "${baseline}" ]; then - echo "::notice title=No-JS ratchet::${count} < baseline ${baseline}. Lower ${baseline_file} to ${count} to lock the improvement in." - fi - - exit 0 +{ + name: "No-JS Scan (warn-first)", + # Estate policy: no hand-authored JavaScript/TypeScript source + # (see standards docs/NO-JAVASCRIPT-SOURCE-POLICY.adoc). This workflow is + # WARN-FIRST: it reports the authored-JS surface for migration but never fails + # the build. The authoritative hard-block for NEW JS in non-carve-out paths is + # hypatia (cicd_rules/javascript_detected); this is an additive companion. + on: { + # NO path filter, deliberately. The previous filter matched only + # **/*.{js,jsx,mjs,cjs,ts,tsx} - precisely the files estate policy bans - so + # the better the estate complied, the more reliably this required check never + # ran and deadlocked every PR. The scan is a cheap grep; always run it. + push: { + branches: [ + "main", + ], + }, + pull_request: null, + }, + # Estate guardrail: cancel superseded runs (read-only check, no mutation). + concurrency: { + group: "${{ github.workflow }}-${{ github.ref }}", + cancel-in-progress: true, + }, + permissions: { + actions: "read", + contents: "read", + }, + jobs: { + scan-authored-js: { + timeout-minutes: 10, + name: "Scan for hand-authored JavaScript/TypeScript", + runs-on: "ubuntu-latest", + steps: [ + { + name: "Checkout", + uses: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", # v4 + }, + { + name: "Report authored JS/TS (warn-first, non-blocking)", + shell: "bash", + run: "# Exclude only things that are NOT hand-authored source: vendored\n# (node_modules, deps, vendor, .git), generated/compiled (*.res.js,\n# *.res.mjs, lib/{js,es6,bs}, out, dist, .deno, generated/, *.min.js),\n# and declaration headers (*.d.ts). Everything else is reported.\nmapfile -t hits < <(\n find . \\\n \\( -path './.git' -o -name node_modules -o -path '*/deps/*' \\\n -o -path '*/vendor/*' -o -path '*/lib/js/*' -o -path '*/lib/es6/*' \\\n -o -path '*/lib/bs/*' -o -path '*/out/*' -o -path '*/dist/*' \\\n -o -path '*/.deno/*' -o -path '*/generated/*' \\) -prune -o \\\n -type f \\\n \\( -name '*.js' -o -name '*.jsx' -o -name '*.mjs' -o -name '*.cjs' \\\n -o -name '*.ts' -o -name '*.tsx' \\) \\\n ! -name '*.res.js' ! -name '*.res.mjs' ! -name '*.min.js' ! -name '*.d.ts' \\\n -print 2>/dev/null | sort || true\n)\n\ncount=${#hits[@]}\n\n{\n echo \"# No-JS scan (warn-first)\"\n echo\n echo \"Estate policy: **no hand-authored JavaScript/TypeScript source.**\"\n echo \"Destination is AffineScript -> typed-wasm, or Rust + Zig -> wasm.\"\n echo \"This check is **non-blocking** — it reports the migration surface only.\"\n echo \"Authoritative hard-block for new files: hypatia \\`cicd_rules/javascript_detected\\`.\"\n echo \"Policy: standards \\`docs/NO-JAVASCRIPT-SOURCE-POLICY.adoc\\`.\"\n echo\n echo \"**Hand-authored JS/TS files found: ${count}**\"\n if [ \"${count}\" -gt 0 ]; then\n echo\n echo '| # | File |'\n echo '|---|------|'\n i=0\n for f in \"${hits[@]}\"; do\n i=$((i + 1))\n echo \"| ${i} | \\`${f#./}\\` |\"\n done\n else\n echo\n echo \"No hand-authored JavaScript/TypeScript found. :white_check_mark:\"\n fi\n} >> \"${GITHUB_STEP_SUMMARY}\"\n\n# Ratchet. The existing files are debt, not a blocker; what is\n# forbidden is ADDING to them. Reading the baseline from a committed\n# file makes every reduction a visible, reviewable commit.\nbaseline_file=\".github/no-js-baseline.txt\"\nbaseline=$(tr -cd '0-9' < \"${baseline_file}\" 2>/dev/null || echo \"\")\nif [ -z \"${baseline}\" ]; then\n echo \"::error title=No-JS ratchet::${baseline_file} is missing or not a number.\"\n echo \"A ratchet with no baseline cannot fail, which is the defect this replaced.\"\n exit 1\nfi\n\necho \"count=${count} baseline=${baseline}\"\n\nif [ \"${count}\" -gt \"${baseline}\" ]; then\n echo \"::error title=No-JS ratchet::${count} hand-authored JS/TS files, baseline is ${baseline}. Adding hand-authored JS/TS is not permitted; estate target is AffineScript->typed-wasm or Rust+Zig->wasm. See docs/NO-JAVASCRIPT-SOURCE-POLICY.adoc.\"\n exit 1\nfi\n\nif [ \"${count}\" -lt \"${baseline}\" ]; then\n echo \"::notice title=No-JS ratchet::${count} < baseline ${baseline}. Lower ${baseline_file} to ${count} to lock the improvement in.\"\nfi\n\nexit 0\n", + }, + ], + }, + }, +} diff --git a/.github/workflows/pages-archive.yml b/.github/workflows/pages-archive.yml index 404d50670..8019bda1b 100644 --- a/.github/workflows/pages-archive.yml +++ b/.github/workflows/pages-archive.yml @@ -1,109 +1,104 @@ # This workflow is managed by gh actions-lock. # SPDX-License-Identifier: MPL-2.0 # This workflow is managed by gh actions-lock. -name: GitHub Pages - -on: - push: - branches: [main] - workflow_dispatch: - -# Least privilege: build reads, deploy publishes. Job-level permissions -# REPLACE (not union) the top level, so each job block below is self-sufficient. -permissions: - contents: read - -concurrency: - group: "pages" - cancel-in-progress: false - -jobs: - build: - timeout-minutes: 20 - runs-on: ubuntu-latest - permissions: - # Job level replaces the top level — restate the checkout read grant. - contents: read - actions: read - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - name: Checkout casket-ssg - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: hyperpolymath/casket-ssg - path: .casket-ssg - - - name: Setup GHCup - uses: haskell-actions/setup@0f8e8c99d88aeb3fbfd523f1ef2c6f762d10d64d # v2.12.1 - with: - ghc-version: '9.8.2' - cabal-version: '3.10' - - - name: Cache Cabal - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: | - ~/.cabal/packages - ~/.cabal/store - .casket-ssg/dist-newstyle - key: ${{ runner.os }}-casket-${{ hashFiles('.casket-ssg/casket-ssg.cabal') }} - - - name: Build casket-ssg - working-directory: .casket-ssg - run: cabal build - - - name: Build site - run: | - mkdir -p site _site - # Generate index.md from README if site/index.md doesn't exist - if [ ! -f site/index.md ]; then - if [ -f README.adoc ]; then - # Convert AsciiDoc to Markdown (basic conversion) - echo "---" > site/index.md - echo "title: $(basename $PWD)" >> site/index.md - echo "date: $(date +%Y-%m-%d)" >> site/index.md - echo "---" >> site/index.md - cat README.adoc >> site/index.md - elif [ -f README.md ]; then - echo "---" > site/index.md - echo "title: $(basename $PWD)" >> site/index.md - echo "date: $(date +%Y-%m-%d)" >> site/index.md - echo "---" >> site/index.md - cat README.md >> site/index.md - else - echo "---" > site/index.md - echo "title: $(basename $PWD)" >> site/index.md - echo "date: $(date +%Y-%m-%d)" >> site/index.md - echo "---" >> site/index.md - echo "" >> site/index.md - echo "# $(basename $PWD)" >> site/index.md - echo "" >> site/index.md - echo "Documentation coming soon." >> site/index.md - fi - fi - cd .casket-ssg && cabal run casket-ssg -- build ../site ../_site - - - name: Setup Pages - uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0 - - - name: Upload artifact - uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 - with: - path: '_site' - - deploy: - timeout-minutes: 20 - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - runs-on: ubuntu-latest - needs: build - permissions: - pages: write - id-token: write - steps: - - name: Deploy to GitHub Pages - id: deployment - uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1 +{ + name: "GitHub Pages", + on: { + push: { + branches: [ + "main", + ], + }, + workflow_dispatch: null, + }, + # Least privilege: build reads, deploy publishes. Job-level permissions + # REPLACE (not union) the top level, so each job block below is self-sufficient. + permissions: { + contents: "read", + }, + concurrency: { + group: "pages", + cancel-in-progress: false, + }, + jobs: { + build: { + timeout-minutes: 20, + runs-on: "ubuntu-latest", + permissions: { + # Job level replaces the top level — restate the checkout read grant. + contents: "read", + actions: "read", + }, + steps: [ + { + name: "Checkout", + uses: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", # v7.0.1 + }, + { + name: "Checkout casket-ssg", + uses: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", # v7.0.1 + with: { + repository: "hyperpolymath/casket-ssg", + path: ".casket-ssg", + }, + }, + { + name: "Setup GHCup", + uses: "haskell-actions/setup@0f8e8c99d88aeb3fbfd523f1ef2c6f762d10d64d", # v2.12.1 + with: { + ghc-version: "9.8.2", + cabal-version: "3.10", + }, + }, + { + name: "Cache Cabal", + uses: "actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9", # v6.1.0 + with: { + path: "~/.cabal/packages\n~/.cabal/store\n.casket-ssg/dist-newstyle\n", + key: "${{ runner.os }}-casket-${{ hashFiles('.casket-ssg/casket-ssg.cabal') }}", + }, + }, + { + name: "Build casket-ssg", + working-directory: ".casket-ssg", + run: "cabal build", + }, + { + name: "Build site", + run: "mkdir -p site _site\n# Generate index.md from README if site/index.md doesn't exist\nif [ ! -f site/index.md ]; then\n if [ -f README.adoc ]; then\n # Convert AsciiDoc to Markdown (basic conversion)\n echo \"---\" > site/index.md\n echo \"title: $(basename $PWD)\" >> site/index.md\n echo \"date: $(date +%Y-%m-%d)\" >> site/index.md\n echo \"---\" >> site/index.md\n cat README.adoc >> site/index.md\n elif [ -f README.md ]; then\n echo \"---\" > site/index.md\n echo \"title: $(basename $PWD)\" >> site/index.md\n echo \"date: $(date +%Y-%m-%d)\" >> site/index.md\n echo \"---\" >> site/index.md\n cat README.md >> site/index.md\n else\n echo \"---\" > site/index.md\n echo \"title: $(basename $PWD)\" >> site/index.md\n echo \"date: $(date +%Y-%m-%d)\" >> site/index.md\n echo \"---\" >> site/index.md\n echo \"\" >> site/index.md\n echo \"# $(basename $PWD)\" >> site/index.md\n echo \"\" >> site/index.md\n echo \"Documentation coming soon.\" >> site/index.md\n fi\nfi\ncd .casket-ssg && cabal run casket-ssg -- build ../site ../_site\n", + }, + { + name: "Setup Pages", + uses: "actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d", # v6.0.0 + }, + { + name: "Upload artifact", + uses: "actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9", # v5.0.0 + with: { + path: "_site", + }, + }, + ], + }, + deploy: { + timeout-minutes: 20, + environment: { + name: "github-pages", + url: "${{ steps.deployment.outputs.page_url }}", + }, + runs-on: "ubuntu-latest", + needs: "build", + permissions: { + pages: "write", + id-token: "write", + }, + steps: [ + { + name: "Deploy to GitHub Pages", + id: "deployment", + uses: "actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346", # v5.0.1 + }, + ], + }, + }, +} diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 0ab5e0431..12c2020ec 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -1,57 +1,80 @@ # This workflow is managed by gh actions-lock. # SPDX-License-Identifier: MPL-2.0 # This workflow is managed by gh actions-lock. -name: GitHub Pages (Ddraig SSG) -on: - push: - branches: [main, master] - workflow_dispatch: -permissions: - actions: read - contents: read - pages: write - id-token: write -concurrency: - group: "pages" - cancel-in-progress: false -jobs: - build: - runs-on: ubuntu-latest - timeout-minutes: 15 - container: - image: ghcr.io/stefan-hoeck/idris2-pack@sha256:f0758996a931fb35d9ecb1de273c4d59dabe2a09b433afc7e357f65a08b7e1ff - steps: - - name: Checkout Site - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Checkout Ddraig SSG - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: hyperpolymath/ddraig-ssg - path: .ddraig-ssg - - name: Compile Ddraig - working-directory: .ddraig-ssg - run: idris2 Ddraig.idr -o ddraig - - name: Build site - run: | - mkdir -p src - if [ ! -f src/index.md ] && [ -f README.md ]; then - cp README.md src/index.md - elif [ ! -f src/index.md ]; then - echo "# ${GITHUB_REPOSITORY}" > src/index.md - fi - ./.ddraig-ssg/build/exec/ddraig build src _site https://hyperpolymath.github.io/${GITHUB_REPOSITORY#*/} - - name: Upload artifact - uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 - with: - path: '_site' - deploy: - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - runs-on: ubuntu-latest - timeout-minutes: 15 - needs: build - steps: - - name: Deploy to GitHub Pages - id: deployment - uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1 +{ + name: "GitHub Pages (Ddraig SSG)", + on: { + push: { + branches: [ + "main", + "master", + ], + }, + workflow_dispatch: null, + }, + permissions: { + actions: "read", + contents: "read", + pages: "write", + id-token: "write", + }, + concurrency: { + group: "pages", + cancel-in-progress: false, + }, + jobs: { + build: { + runs-on: "ubuntu-latest", + timeout-minutes: 15, + container: { + image: "ghcr.io/stefan-hoeck/idris2-pack@sha256:f0758996a931fb35d9ecb1de273c4d59dabe2a09b433afc7e357f65a08b7e1ff", + }, + steps: [ + { + name: "Checkout Site", + uses: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", # v7.0.1 + }, + { + name: "Checkout Ddraig SSG", + uses: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", # v7.0.1 + with: { + repository: "hyperpolymath/ddraig-ssg", + path: ".ddraig-ssg", + }, + }, + { + name: "Compile Ddraig", + working-directory: ".ddraig-ssg", + run: "idris2 Ddraig.idr -o ddraig", + }, + { + name: "Build site", + run: "mkdir -p src\nif [ ! -f src/index.md ] && [ -f README.md ]; then\n cp README.md src/index.md\nelif [ ! -f src/index.md ]; then\n echo \"# ${GITHUB_REPOSITORY}\" > src/index.md\nfi\n./.ddraig-ssg/build/exec/ddraig build src _site https://hyperpolymath.github.io/${GITHUB_REPOSITORY#*/}\n", + }, + { + name: "Upload artifact", + uses: "actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9", # v5.0.0 + with: { + path: "_site", + }, + }, + ], + }, + deploy: { + environment: { + name: "github-pages", + url: "${{ steps.deployment.outputs.page_url }}", + }, + runs-on: "ubuntu-latest", + timeout-minutes: 15, + needs: "build", + steps: [ + { + name: "Deploy to GitHub Pages", + id: "deployment", + uses: "actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346", # v5.0.1 + }, + ], + }, + }, +} diff --git a/.github/workflows/propagate-hooks.yml b/.github/workflows/propagate-hooks.yml index 82362b62a..ab9afbefd 100644 --- a/.github/workflows/propagate-hooks.yml +++ b/.github/workflows/propagate-hooks.yml @@ -9,380 +9,193 @@ # Triggered when: # - .githooks/ directory is updated in standards repo # - Manual trigger via workflow_dispatch -name: "⚙️ AUTO: Propagate Hook Updates" - -on: - push: - branches: [main, master] - paths: - - '.githooks/**' - - '.github/workflows/propagate-hooks.yml' - workflow_dispatch: - inputs: - target-repo: - description: 'Specific repo to update (optional)' - required: false - dry-run: - description: 'Dry run - do not actually update' - required: false - default: 'false' - repository_dispatch: - types: [refresh-githooks] - -# Least privilege (WH002/S8233): only the `propagate` job pushes, so only it -# carries `contents: write` (job level). `pull-requests: write` and -# `actions: read` were dead — no step opens a PR or calls the Actions API. -permissions: - contents: read - -# Only run one instance at a time -concurrency: - group: propagate-hooks - cancel-in-progress: false - -jobs: - identify-repos: - name: Identify Repositories to Update - runs-on: ubuntu-latest - timeout-minutes: 30 - outputs: - repos: ${{ steps.identify.outputs.repos }} - dry_run: ${{ steps.config.outputs.dry_run }} - - steps: - - name: Harden runner (egress block) - uses: step-security/harden-runner@e14015d583714f6e62063499dc959a02595150a1 # v2.21.1 - with: - # Runs on a protected-branch push (RE002): block, not audit. This - # job only talks to GitHub (checkout, the REST API, git transport). - egress-policy: block - allowed-endpoints: > - github.com:443 - api.github.com:443 - codeload.github.com:443 - objects.githubusercontent.com:443 - - name: Checkout standards repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - fetch-depth: 1 - - - name: Configure - id: config - run: | - # Check if this is a dry run - if [ "${{ github.event.inputs.dry-run }}" = "true" ] || [ "${{ github.event.inputs.dry_run }}" = "true" ]; then - echo "dry_run=true" >> $GITHUB_OUTPUT - echo "::notice::Running in DRY RUN mode - no changes will be made" - else - echo "dry_run=false" >> $GITHUB_OUTPUT - fi - - - name: Identify repositories with .githooks - id: identify - env: - # CREDENTIAL (issue #807, root cause 1): the probe below calls the - # contents API on target repos. HYPATIA_SCAN_PAT is the estate's - # read-only PAT (contents:read across repos) — the same credential - # governance-reusable.yml and allowlist-preflight-reusable.yml use - # for live repo reads. Without a token every probe fails silently - # and every repo is misclassified as hookless. - GH_TOKEN: ${{ secrets.HYPATIA_SCAN_PAT }} - run: | - echo "🔍 Identifying repositories that need hook updates..." - - # Get the SHA of the current commit (triggering commit) - TRIGGER_SHA="${{ github.sha }}" - - # Check if specific repo was requested - if [ -n "${{ github.event.inputs.target-repo }}" ]; then - TARGET="${{ github.event.inputs.target-repo }}" - # -c: compact, single-line JSON — a multiline value breaks - # $GITHUB_OUTPUT below (same defect class as root cause 2, #807). - REPOS_JSON=$(jq -cn --arg repo "$TARGET" '[$repo]') - else - # Use a sample list for now - TODO: Query all repos dynamically - # For production, this should query the GitHub API for all repos - # in hyperpolymath and metadatastician orgs and filter by .githooks - ALL_REPOS=( - "hyperpolymath/standards" - ) - - # Filter to only repos that have .githooks directory. - # Three outcomes per repo, not two (issue #807): has-hooks, no-hooks, - # and PROBE ERROR. The old code folded error into no-hooks (2>/dev/null - # + jq -e on the error object), so a credential failure read as - # "nothing to do" — a fake green. - echo "🔍 Filtering repos with .githooks directory..." - REPOS_WITH_HOOKS=() - DISCOVERY_ERRORS=() - for repo in "${ALL_REPOS[@]}"; do - ERR_FILE=$(mktemp) - if OUT=$(gh api "/repos/$repo/contents/.githooks" \ - -H "Accept: application/vnd.github+json" \ - -H "X-GitHub-Api-Version: 2022-11-28" 2>"$ERR_FILE"); then - if jq -e 'type == "array"' >/dev/null <<<"$OUT"; then - REPOS_WITH_HOOKS+=("$repo") - echo " ✓ $repo has .githooks" - else - echo " ✗ $repo skipped (.githooks exists but is not a directory)" - fi - elif grep -q "HTTP 404" "$ERR_FILE"; then - echo " ✗ $repo skipped (no .githooks)" - else - DISCOVERY_ERRORS+=("$repo") - echo " ⚠ $repo: probe errored — NOT counted as hookless: $(head -1 "$ERR_FILE")" - fi - rm -f "$ERR_FILE" - done - - # An erroring probe must fail the job, never propagate blind. - if ((${#DISCOVERY_ERRORS[@]})); then - echo "::error::Discovery errored for ${#DISCOVERY_ERRORS[@]} repo(s): ${DISCOVERY_ERRORS[*]} — refusing to propagate blind (check the credential on this step)" - exit 1 - fi - - # CANARY (issue #807): hyperpolymath/standards OWNS .githooks — 14 - # hooks. If the canonical repo classifies as hookless, the classifier - # is broken, not the repo. Fail loudly instead of propagating nowhere. - CANARY="hyperpolymath/standards" - if [[ " ${ALL_REPOS[*]} " == *" $CANARY "* && ! " ${REPOS_WITH_HOOKS[*]} " == *" $CANARY "* ]]; then - echo "::error::Discovery canary failed: $CANARY has .githooks yet classified hookless — discovery is broken, refusing to propagate" - exit 1 - fi - - # (issue #807, root cause 2): printf on an EMPTY bash array still - # prints one empty line, yielding [""] — and a multiline value then - # breaks $GITHUB_OUTPUT ('Invalid format'). Serialize [] explicitly, - # and always compact single-line JSON. - if ((${#REPOS_WITH_HOOKS[@]})); then - REPOS_JSON=$(printf '%s\n' "${REPOS_WITH_HOOKS[@]}" | jq -R . | jq -cs .) - else - REPOS_JSON='[]' - fi - fi - - echo "repos=$REPOS_JSON" >> $GITHUB_OUTPUT - echo "repo_count=$(echo "$REPOS_JSON" | jq 'length')" >> $GITHUB_OUTPUT - - - name: Display repositories to update - run: | - REPO_COUNT="${{ steps.identify.outputs.repo_count }}" - echo "📊 Found $REPO_COUNT repository(ies) to check for hook updates" - - # Pretty print the repos list - echo "${{ steps.identify.outputs.repos }}" | jq '.' - - propagate: - name: Propagate Hook Updates - runs-on: ubuntu-latest - needs: identify-repos - timeout-minutes: 60 - permissions: - # Required to push hook updates to target repos. Job level replaces - # the top level; `contents: write` implies read, so this is sufficient. - contents: write - strategy: - matrix: - repo: ${{ fromJson(needs.identify-repos.outputs.repos) }} - max-parallel: 10 # Limit concurrent updates - fail-fast: false # Don't stop on individual failures - - steps: - - name: Harden runner (egress block) - uses: step-security/harden-runner@e14015d583714f6e62063499dc959a02595150a1 # v2.21.1 - with: - # Runs on a protected-branch push (RE002): block, not audit. This - # job only talks to GitHub (checkout, the REST API, git transport). - egress-policy: block - allowed-endpoints: > - github.com:443 - api.github.com:443 - codeload.github.com:443 - objects.githubusercontent.com:443 - - name: Checkout standards repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - name: Determine source and target - id: config - run: | - SOURCE_REPO="${{ github.repository }}" - TARGET_REPO="${{ matrix.repo }}" - - echo "source_repo=$SOURCE_REPO" >> $GITHUB_OUTPUT - echo "target_repo=$TARGET_REPO" >> $GITHUB_OUTPUT - - # Extract org and repo name - TARGET_ORG=$(echo "$TARGET_REPO" | cut -d'/' -f1) - TARGET_NAME=$(echo "$TARGET_REPO" | cut -d'/' -f2) - - echo "target_org=$TARGET_ORG" >> $GITHUB_OUTPUT - echo "target_name=$TARGET_NAME" >> $GITHUB_OUTPUT - - - name: Checkout target repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: ${{ matrix.repo }} - token: ${{ secrets.GITHUB_TOKEN }} - path: target-repo - fetch-depth: 1 - - - name: Check if target has .githooks directory - id: check-githooks - run: | - if [ -d "target-repo/.githooks" ]; then - echo "has_githooks=true" >> $GITHUB_OUTPUT - echo "::notice::✅ $TARGET_REPO has .githooks directory" - else - echo "has_githooks=false" >> $GITHUB_OUTPUT - echo "::warning::⚠️ $TARGET_REPO does not have .githooks directory - creating" - fi - - - name: Create .githooks directory if missing - if: steps.check-githooks.outputs.has_githooks != 'true' - run: | - mkdir -p target-repo/.githooks - echo "::notice::Created .githooks directory in $TARGET_REPO" - - - name: Copy updated hooks - run: | - SOURCE="./.githooks" - DEST="target-repo/.githooks" - - echo "📝 Copying hooks from $SOURCE to $DEST..." - - # Copy all hook files - safe because SOURCE is controlled - cp -v $SOURCE/* $DEST/ 2>&1 || true - - # Make all hooks executable - required for git hooks to function - chmod +x $DEST/* - - # List what was copied - echo "" >> $GITHUB_STEP_SUMMARY - echo "### Files Copied to $TARGET_REPO" >> $GITHUB_STEP_SUMMARY - ls -la $DEST/ >> $GITHUB_STEP_SUMMARY || true - - - name: Create or update install.sh - run: | - DEST="target-repo/.githooks/install.sh" - - # Create an install.sh specific to this repo - cat > "$DEST" << 'EOF' - #!/usr/bin/env bash - # SPDX-License-Identifier: MPL-2.0 - # Git Hooks Installer for this repository - # Copied from: hyperpolymath/standards - - set -euo pipefail - - REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" - - echo "Installing git hooks for $REPO_ROOT..." - git config core.hooksPath .githooks - chmod +x .githooks/* - echo "✅ Hooks installed. Use: git config core.hooksPath .githooks" - EOF - - chmod +x "$DEST" - - - name: Commit and push hook updates - if: needs.identify-repos.outputs.dry_run != 'true' - run: | - cd target-repo - - # Check if there are changes - if git status --porcelain | grep -q '.githooks'; then - echo "📝 Changes detected in .githooks - committing..." - - # Configure git - git config user.name "hyperpolymath-hooks-bot" - git config user.email "hooks-bot@hyperpolymath.dev" - - # Add all changes - git add .githooks/ - - # Commit - COMMIT_MSG="chore(.githooks): update hooks from standards (${{ github.sha }})" - git commit -m "$COMMIT_MSG" - - # Push - using force-with-lease for safety - # Note: This requires write permissions on the target repo - if git push origin HEAD --force-with-lease 2>&1; then - echo "::notice::✅ Successfully updated hooks in $TARGET_REPO" - else - echo "::error::❌ Failed to push hook updates to $TARGET_REPO" - echo "This may be due to insufficient permissions" - exit 1 - fi - else - echo "::notice::⚪ No changes needed for $TARGET_REPO" - fi - - - name: Dry run - show what would be updated - if: needs.identify-repos.outputs.dry_run == 'true' - run: | - cd target-repo - - echo "🔧 DRY RUN MODE - No changes will be made" - echo "" - echo "Would copy the following files to $TARGET_REPO/.githooks/:" - ls -la ./.githooks/ || true - echo "" - - # Show diff - if [ -d "target-repo/.githooks" ]; then - echo "Differences:" - diff -r ./.githooks target-repo/.githooks/ || echo " (no differences)" - else - echo " Would create .githooks directory" - fi - - summary: - name: Propagation Summary - runs-on: ubuntu-latest - timeout-minutes: 10 - needs: [identify-repos, propagate] - if: always() - - steps: - - name: Generate Summary - run: | - echo "## Hook Propagation Summary" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - - REPO_COUNT="${{ needs.identify-repos.outputs.repo_count }}" - echo "**Repositories processed:** $REPO_COUNT" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - - # Count successes and failures - SUCCESS_COUNT=$(echo "${{ needs.propagate.result }}" | grep -c 'success' || echo "0") - FAILURE_COUNT=$(echo "${{ needs.propagate.result }}" | grep -c 'failure' || echo "0") - - echo "### Results" >> $GITHUB_STEP_SUMMARY - echo "- ✅ Successful: $SUCCESS_COUNT" >> $GITHUB_STEP_SUMMARY - echo "- ❌ Failed: $FAILURE_COUNT" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - - if [ "${{ needs.identify-repos.outputs.dry_run }}" = "true" ]; then - echo "**Mode:** DRY RUN (no changes made)" >> $GITHUB_STEP_SUMMARY - else - echo "**Mode:** LIVE (changes applied)" >> $GITHUB_STEP_SUMMARY - fi - - echo "" >> $GITHUB_STEP_SUMMARY - echo "### What This Workflow Does" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "1. **Identifies** all repositories in hyperpolymath and metadatastician orgs" >> $GITHUB_STEP_SUMMARY - echo "2. **Checks** if each repo has a .githooks directory" >> $GITHUB_STEP_SUMMARY - echo "3. **Copies** updated hook files from standards repo" >> $GITHUB_STEP_SUMMARY - echo "4. **Commits and pushes** the updates (with force-with-lease)" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "### Triggering This Workflow" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "- **Automatic:** Pushes to .githooks/ in standards repo" >> $GITHUB_STEP_SUMMARY - echo "- **Manual:** `gh workflow run propagate-hooks.yml`" >> $GITHUB_STEP_SUMMARY - echo "- **Repository dispatch:** Send `refresh-githooks` event" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "### Notes" >> $GITHUB_STEP_SUMMARY - echo "- Requires write permissions on target repositories" >> $GITHUB_STEP_SUMMARY - echo "- Uses --force-with-lease for safe updates" >> $GITHUB_STEP_SUMMARY - echo "- Can be run in dry-run mode for testing" >> $GITHUB_STEP_SUMMARY +{ + name: "⚙️ AUTO: Propagate Hook Updates", + on: { + push: { + branches: [ + "main", + "master", + ], + paths: [ + ".githooks/**", + ".github/workflows/propagate-hooks.yml", + ], + }, + workflow_dispatch: { + inputs: { + target-repo: { + description: "Specific repo to update (optional)", + required: false, + }, + dry-run: { + description: "Dry run - do not actually update", + required: false, + default: "false", + }, + }, + }, + repository_dispatch: { + types: [ + "refresh-githooks", + ], + }, + }, + # Least privilege (WH002/S8233): only the `propagate` job pushes, so only it + # carries `contents: write` (job level). `pull-requests: write` and + # `actions: read` were dead — no step opens a PR or calls the Actions API. + permissions: { + contents: "read", + }, + # Only run one instance at a time + concurrency: { + group: "propagate-hooks", + cancel-in-progress: false, + }, + jobs: { + identify-repos: { + name: "Identify Repositories to Update", + runs-on: "ubuntu-latest", + timeout-minutes: 30, + outputs: { + repos: "${{ steps.identify.outputs.repos }}", + dry_run: "${{ steps.config.outputs.dry_run }}", + }, + steps: [ + { + name: "Harden runner (egress block)", + uses: "step-security/harden-runner@e14015d583714f6e62063499dc959a02595150a1", # v2.21.1 + with: { + # Runs on a protected-branch push (RE002): block, not audit. This + # job only talks to GitHub (checkout, the REST API, git transport). + egress-policy: "block", + allowed-endpoints: "github.com:443 api.github.com:443 codeload.github.com:443 objects.githubusercontent.com:443\n", + }, + }, + { + name: "Checkout standards repository", + uses: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", # v7.0.1 + with: { + fetch-depth: 1, + }, + }, + { + name: "Configure", + id: "config", + run: "# Check if this is a dry run\nif [ \"${{ github.event.inputs.dry-run }}\" = \"true\" ] || [ \"${{ github.event.inputs.dry_run }}\" = \"true\" ]; then\n echo \"dry_run=true\" >> $GITHUB_OUTPUT\n echo \"::notice::Running in DRY RUN mode - no changes will be made\"\nelse\n echo \"dry_run=false\" >> $GITHUB_OUTPUT\nfi\n", + }, + { + name: "Identify repositories with .githooks", + id: "identify", + env: { + # CREDENTIAL (issue #807, root cause 1): the probe below calls the + # contents API on target repos. HYPATIA_SCAN_PAT is the estate's + # read-only PAT (contents:read across repos) — the same credential + # governance-reusable.yml and allowlist-preflight-reusable.yml use + # for live repo reads. Without a token every probe fails silently + # and every repo is misclassified as hookless. + GH_TOKEN: "${{ secrets.HYPATIA_SCAN_PAT }}", + }, + run: "echo \"🔍 Identifying repositories that need hook updates...\"\n\n# Get the SHA of the current commit (triggering commit)\nTRIGGER_SHA=\"${{ github.sha }}\"\n\n# Check if specific repo was requested\nif [ -n \"${{ github.event.inputs.target-repo }}\" ]; then\n TARGET=\"${{ github.event.inputs.target-repo }}\"\n # -c: compact, single-line JSON — a multiline value breaks\n # $GITHUB_OUTPUT below (same defect class as root cause 2, #807).\n REPOS_JSON=$(jq -cn --arg repo \"$TARGET\" '[$repo]')\nelse\n # Use a sample list for now - TODO: Query all repos dynamically\n # For production, this should query the GitHub API for all repos\n # in hyperpolymath and metadatastician orgs and filter by .githooks\n ALL_REPOS=(\n \"hyperpolymath/standards\"\n )\n \n # Filter to only repos that have .githooks directory.\n # Three outcomes per repo, not two (issue #807): has-hooks, no-hooks,\n # and PROBE ERROR. The old code folded error into no-hooks (2>/dev/null\n # + jq -e on the error object), so a credential failure read as\n # \"nothing to do\" — a fake green.\n echo \"🔍 Filtering repos with .githooks directory...\"\n REPOS_WITH_HOOKS=()\n DISCOVERY_ERRORS=()\n for repo in \"${ALL_REPOS[@]}\"; do\n ERR_FILE=$(mktemp)\n if OUT=$(gh api \"/repos/$repo/contents/.githooks\" \\\n -H \"Accept: application/vnd.github+json\" \\\n -H \"X-GitHub-Api-Version: 2022-11-28\" 2>\"$ERR_FILE\"); then\n if jq -e 'type == \"array\"' >/dev/null <<<\"$OUT\"; then\n REPOS_WITH_HOOKS+=(\"$repo\")\n echo \" ✓ $repo has .githooks\"\n else\n echo \" ✗ $repo skipped (.githooks exists but is not a directory)\"\n fi\n elif grep -q \"HTTP 404\" \"$ERR_FILE\"; then\n echo \" ✗ $repo skipped (no .githooks)\"\n else\n DISCOVERY_ERRORS+=(\"$repo\")\n echo \" ⚠ $repo: probe errored — NOT counted as hookless: $(head -1 \"$ERR_FILE\")\"\n fi\n rm -f \"$ERR_FILE\"\n done\n\n # An erroring probe must fail the job, never propagate blind.\n if ((${#DISCOVERY_ERRORS[@]})); then\n echo \"::error::Discovery errored for ${#DISCOVERY_ERRORS[@]} repo(s): ${DISCOVERY_ERRORS[*]} — refusing to propagate blind (check the credential on this step)\"\n exit 1\n fi\n\n # CANARY (issue #807): hyperpolymath/standards OWNS .githooks — 14\n # hooks. If the canonical repo classifies as hookless, the classifier\n # is broken, not the repo. Fail loudly instead of propagating nowhere.\n CANARY=\"hyperpolymath/standards\"\n if [[ \" ${ALL_REPOS[*]} \" == *\" $CANARY \"* && ! \" ${REPOS_WITH_HOOKS[*]} \" == *\" $CANARY \"* ]]; then\n echo \"::error::Discovery canary failed: $CANARY has .githooks yet classified hookless — discovery is broken, refusing to propagate\"\n exit 1\n fi\n\n # (issue #807, root cause 2): printf on an EMPTY bash array still\n # prints one empty line, yielding [\"\"] — and a multiline value then\n # breaks $GITHUB_OUTPUT ('Invalid format'). Serialize [] explicitly,\n # and always compact single-line JSON.\n if ((${#REPOS_WITH_HOOKS[@]})); then\n REPOS_JSON=$(printf '%s\\n' \"${REPOS_WITH_HOOKS[@]}\" | jq -R . | jq -cs .)\n else\n REPOS_JSON='[]'\n fi\nfi\n\necho \"repos=$REPOS_JSON\" >> $GITHUB_OUTPUT\necho \"repo_count=$(echo \"$REPOS_JSON\" | jq 'length')\" >> $GITHUB_OUTPUT\n", + }, + { + name: "Display repositories to update", + run: "REPO_COUNT=\"${{ steps.identify.outputs.repo_count }}\"\necho \"📊 Found $REPO_COUNT repository(ies) to check for hook updates\"\n\n# Pretty print the repos list\necho \"${{ steps.identify.outputs.repos }}\" | jq '.'\n", + }, + ], + }, + propagate: { + name: "Propagate Hook Updates", + runs-on: "ubuntu-latest", + needs: "identify-repos", + timeout-minutes: 60, + permissions: { + # Required to push hook updates to target repos. Job level replaces + # the top level; `contents: write` implies read, so this is sufficient. + contents: "write", + }, + strategy: { + matrix: { + repo: "${{ fromJson(needs.identify-repos.outputs.repos) }}", + }, + max-parallel: 10, # Limit concurrent updates + fail-fast: false, # Don't stop on individual failures + }, + steps: [ + { + name: "Harden runner (egress block)", + uses: "step-security/harden-runner@e14015d583714f6e62063499dc959a02595150a1", # v2.21.1 + with: { + # Runs on a protected-branch push (RE002): block, not audit. This + # job only talks to GitHub (checkout, the REST API, git transport). + egress-policy: "block", + allowed-endpoints: "github.com:443 api.github.com:443 codeload.github.com:443 objects.githubusercontent.com:443\n", + }, + }, + { + name: "Checkout standards repository", + uses: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", # v7.0.1 + }, + { + name: "Determine source and target", + id: "config", + run: "SOURCE_REPO=\"${{ github.repository }}\"\nTARGET_REPO=\"${{ matrix.repo }}\"\n\necho \"source_repo=$SOURCE_REPO\" >> $GITHUB_OUTPUT\necho \"target_repo=$TARGET_REPO\" >> $GITHUB_OUTPUT\n\n# Extract org and repo name\nTARGET_ORG=$(echo \"$TARGET_REPO\" | cut -d'/' -f1)\nTARGET_NAME=$(echo \"$TARGET_REPO\" | cut -d'/' -f2)\n\necho \"target_org=$TARGET_ORG\" >> $GITHUB_OUTPUT\necho \"target_name=$TARGET_NAME\" >> $GITHUB_OUTPUT\n", + }, + { + name: "Checkout target repository", + uses: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", # v7.0.1 + with: { + repository: "${{ matrix.repo }}", + token: "${{ secrets.GITHUB_TOKEN }}", + path: "target-repo", + fetch-depth: 1, + }, + }, + { + name: "Check if target has .githooks directory", + id: "check-githooks", + run: "if [ -d \"target-repo/.githooks\" ]; then\n echo \"has_githooks=true\" >> $GITHUB_OUTPUT\n echo \"::notice::✅ $TARGET_REPO has .githooks directory\"\nelse\n echo \"has_githooks=false\" >> $GITHUB_OUTPUT\n echo \"::warning::⚠️ $TARGET_REPO does not have .githooks directory - creating\"\nfi\n", + }, + { + name: "Create .githooks directory if missing", + if: "steps.check-githooks.outputs.has_githooks != 'true'", + run: "mkdir -p target-repo/.githooks\necho \"::notice::Created .githooks directory in $TARGET_REPO\"\n", + }, + { + name: "Copy updated hooks", + run: "SOURCE=\"./.githooks\"\nDEST=\"target-repo/.githooks\"\n\necho \"📝 Copying hooks from $SOURCE to $DEST...\"\n\n# Copy all hook files - safe because SOURCE is controlled\ncp -v $SOURCE/* $DEST/ 2>&1 || true\n\n# Make all hooks executable - required for git hooks to function\nchmod +x $DEST/*\n\n# List what was copied\necho \"\" >> $GITHUB_STEP_SUMMARY\necho \"### Files Copied to $TARGET_REPO\" >> $GITHUB_STEP_SUMMARY\nls -la $DEST/ >> $GITHUB_STEP_SUMMARY || true\n", + }, + { + name: "Create or update install.sh", + run: "DEST=\"target-repo/.githooks/install.sh\"\n\n# Create an install.sh specific to this repo\ncat > \"$DEST\" << 'EOF'\n#!/usr/bin/env bash\n# SPDX-License-Identifier: MPL-2.0\n# Git Hooks Installer for this repository\n# Copied from: hyperpolymath/standards\n\nset -euo pipefail\n\nREPO_ROOT=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")/..\" && pwd)\"\n\necho \"Installing git hooks for $REPO_ROOT...\"\ngit config core.hooksPath .githooks\nchmod +x .githooks/*\necho \"✅ Hooks installed. Use: git config core.hooksPath .githooks\"\nEOF\n\nchmod +x \"$DEST\"\n", + }, + { + name: "Commit and push hook updates", + if: "needs.identify-repos.outputs.dry_run != 'true'", + run: "cd target-repo\n\n# Check if there are changes\nif git status --porcelain | grep -q '.githooks'; then\n echo \"📝 Changes detected in .githooks - committing...\"\n \n # Configure git\n git config user.name \"hyperpolymath-hooks-bot\"\n git config user.email \"hooks-bot@hyperpolymath.dev\"\n \n # Add all changes\n git add .githooks/\n \n # Commit\n COMMIT_MSG=\"chore(.githooks): update hooks from standards (${{ github.sha }})\"\n git commit -m \"$COMMIT_MSG\"\n \n # Push - using force-with-lease for safety\n # Note: This requires write permissions on the target repo\n if git push origin HEAD --force-with-lease 2>&1; then\n echo \"::notice::✅ Successfully updated hooks in $TARGET_REPO\"\n else\n echo \"::error::❌ Failed to push hook updates to $TARGET_REPO\"\n echo \"This may be due to insufficient permissions\"\n exit 1\n fi\nelse\n echo \"::notice::⚪ No changes needed for $TARGET_REPO\"\nfi\n", + }, + { + name: "Dry run - show what would be updated", + if: "needs.identify-repos.outputs.dry_run == 'true'", + run: "cd target-repo\n\necho \"🔧 DRY RUN MODE - No changes will be made\"\necho \"\"\necho \"Would copy the following files to $TARGET_REPO/.githooks/:\"\nls -la ./.githooks/ || true\necho \"\"\n\n# Show diff\nif [ -d \"target-repo/.githooks\" ]; then\n echo \"Differences:\"\n diff -r ./.githooks target-repo/.githooks/ || echo \" (no differences)\"\nelse\n echo \" Would create .githooks directory\"\nfi\n", + }, + ], + }, + summary: { + name: "Propagation Summary", + runs-on: "ubuntu-latest", + timeout-minutes: 10, + needs: [ + "identify-repos", + "propagate", + ], + if: "always()", + steps: [ + { + name: "Generate Summary", + run: "echo \"## Hook Propagation Summary\" >> $GITHUB_STEP_SUMMARY\necho \"\" >> $GITHUB_STEP_SUMMARY\n\nREPO_COUNT=\"${{ needs.identify-repos.outputs.repo_count }}\"\necho \"**Repositories processed:** $REPO_COUNT\" >> $GITHUB_STEP_SUMMARY\necho \"\" >> $GITHUB_STEP_SUMMARY\n\n# Count successes and failures\nSUCCESS_COUNT=$(echo \"${{ needs.propagate.result }}\" | grep -c 'success' || echo \"0\")\nFAILURE_COUNT=$(echo \"${{ needs.propagate.result }}\" | grep -c 'failure' || echo \"0\")\n\necho \"### Results\" >> $GITHUB_STEP_SUMMARY\necho \"- ✅ Successful: $SUCCESS_COUNT\" >> $GITHUB_STEP_SUMMARY\necho \"- ❌ Failed: $FAILURE_COUNT\" >> $GITHUB_STEP_SUMMARY\necho \"\" >> $GITHUB_STEP_SUMMARY\n\nif [ \"${{ needs.identify-repos.outputs.dry_run }}\" = \"true\" ]; then\n echo \"**Mode:** DRY RUN (no changes made)\" >> $GITHUB_STEP_SUMMARY\nelse\n echo \"**Mode:** LIVE (changes applied)\" >> $GITHUB_STEP_SUMMARY\nfi\n\necho \"\" >> $GITHUB_STEP_SUMMARY\necho \"### What This Workflow Does\" >> $GITHUB_STEP_SUMMARY\necho \"\" >> $GITHUB_STEP_SUMMARY\necho \"1. **Identifies** all repositories in hyperpolymath and metadatastician orgs\" >> $GITHUB_STEP_SUMMARY\necho \"2. **Checks** if each repo has a .githooks directory\" >> $GITHUB_STEP_SUMMARY\necho \"3. **Copies** updated hook files from standards repo\" >> $GITHUB_STEP_SUMMARY\necho \"4. **Commits and pushes** the updates (with force-with-lease)\" >> $GITHUB_STEP_SUMMARY\necho \"\" >> $GITHUB_STEP_SUMMARY\necho \"### Triggering This Workflow\" >> $GITHUB_STEP_SUMMARY\necho \"\" >> $GITHUB_STEP_SUMMARY\necho \"- **Automatic:** Pushes to .githooks/ in standards repo\" >> $GITHUB_STEP_SUMMARY\necho \"- **Manual:** `gh workflow run propagate-hooks.yml`\" >> $GITHUB_STEP_SUMMARY\necho \"- **Repository dispatch:** Send `refresh-githooks` event\" >> $GITHUB_STEP_SUMMARY\necho \"\" >> $GITHUB_STEP_SUMMARY\necho \"### Notes\" >> $GITHUB_STEP_SUMMARY\necho \"- Requires write permissions on target repositories\" >> $GITHUB_STEP_SUMMARY\necho \"- Uses --force-with-lease for safe updates\" >> $GITHUB_STEP_SUMMARY\necho \"- Can be run in dry-run mode for testing\" >> $GITHUB_STEP_SUMMARY\n", + }, + ], + }, + }, +} diff --git a/.github/workflows/provisioning-check-reusable.yml b/.github/workflows/provisioning-check-reusable.yml index 2f06a5574..807dbe4af 100644 --- a/.github/workflows/provisioning-check-reusable.yml +++ b/.github/workflows/provisioning-check-reusable.yml @@ -21,92 +21,69 @@ # jobs: # provisioning: # uses: hyperpolymath/standards/.github/workflows/provisioning-check-reusable.yml@ -name: "Provisioning Check Reusable" - -on: - workflow_call: null - -permissions: - contents: read - -jobs: - provisioning: - name: "Provisioning set conforms" - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - name: "Checkout caller repository" - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: "${{ github.repository }}" - ref: "${{ github.sha }}" +{ + name: "Provisioning Check Reusable", + on: { + workflow_call: null, + }, + permissions: { + contents: "read", + }, + jobs: { + provisioning: { + name: "Provisioning set conforms", + runs-on: "ubuntu-latest", + timeout-minutes: 10, + steps: [ + { + name: "Checkout caller repository", + uses: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", # v7.0.1 + with: { + repository: "${{ github.repository }}", + ref: "${{ github.sha }}", # launcher.sh (caller code) runs below: never leave the token in .git/config. - persist-credentials: false - - name: "Checkout the provisioning canon" - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: hyperpolymath/standards - ref: ${{ job.workflow_sha }} - path: .standards-checkout - persist-credentials: false - sparse-checkout: "3-practice/provisioning/templates/build/just\n" - sparse-checkout-cone-mode: false - - name: "Stage the canon engine outside the checked tree" - shell: bash - run: "mkdir -p \"$RUNNER_TEMP/canon\"\ncp .standards-checkout/3-practice/provisioning/templates/build/just/* \"$RUNNER_TEMP/canon/\"\nrm -rf .standards-checkout\nls \"$RUNNER_TEMP/canon\"\n" - - name: "Install just (the engine's runner; >= 1.42 is required)" - shell: bash - env: - JUST_VERSION: "1.56.0" - JUST_SHA256: "fa2a8ec1015d9df5330941ade12437488fc40d33f9c9f8cd4eb70a26de11b639" - run: | - set +e - tgz="$RUNNER_TEMP/just.tar.gz" - curl -fsSL -o "$tgz" \ - "https://github.com/casey/just/releases/download/${JUST_VERSION}/just-${JUST_VERSION}-x86_64-unknown-linux-musl.tar.gz" \ - && echo "${JUST_SHA256} $tgz" | sha256sum -c - \ - && mkdir -p "$RUNNER_TEMP/bin" \ - && tar -xzf "$tgz" -C "$RUNNER_TEMP/bin" just - STATUS=$? - if [ "$STATUS" -ne 0 ]; then - echo "::error title=just install::could not fetch or verify just ${JUST_VERSION}" - exit "$STATUS" - fi - echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH" - "$RUNNER_TEMP/bin/just" --version - - name: "Engine files match the canon" - if: "${{ !cancelled() }}" - shell: bash - run: | - set +e - drift=0 - for f in provision.just provision-lib.sh provision-modes.sh provision-check.sh; do - if [ ! -f "build/just/$f" ]; then - echo "::error file=build/just/$f,title=engine missing::build/just/$f is missing (run: launch-scaffolder provision-set realign)" - drift=1 - elif ! cmp -s "build/just/$f" "$RUNNER_TEMP/canon/$f"; then - echo "::error file=build/just/$f,title=engine drift::build/just/$f differs from the canon at standards@${{ job.workflow_sha }} (run: launch-scaffolder provision-set realign)" - drift=1 - else - echo "ok build/just/$f" - fi - done - exit "$drift" - - name: "Provisioning set conforms (provision-check.sh)" - if: "${{ !cancelled() }}" - shell: bash - run: | - set +e - bash "$RUNNER_TEMP/canon/provision-check.sh" . | tee "$RUNNER_TEMP/check.log" - STATUS="${PIPESTATUS[0]}" - grep '^ FAIL' "$RUNNER_TEMP/check.log" | sed 's/^ FAIL //' | while IFS= read -r line; do - echo "::error title=provisioning::$line" - done - { - echo "## Provisioning check" - echo "" - echo '```' - cat "$RUNNER_TEMP/check.log" - echo '```' - } >> "$GITHUB_STEP_SUMMARY" - exit "$STATUS" + persist-credentials: false, + }, + }, + { + name: "Checkout the provisioning canon", + uses: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", # v7.0.1 + with: { + repository: "hyperpolymath/standards", + ref: "${{ job.workflow_sha }}", + path: ".standards-checkout", + persist-credentials: false, + sparse-checkout: "3-practice/provisioning/templates/build/just\n", + sparse-checkout-cone-mode: false, + }, + }, + { + name: "Stage the canon engine outside the checked tree", + shell: "bash", + run: "mkdir -p \"$RUNNER_TEMP/canon\"\ncp .standards-checkout/3-practice/provisioning/templates/build/just/* \"$RUNNER_TEMP/canon/\"\nrm -rf .standards-checkout\nls \"$RUNNER_TEMP/canon\"\n", + }, + { + name: "Install just (the engine's runner; >= 1.42 is required)", + shell: "bash", + env: { + JUST_VERSION: "1.56.0", + JUST_SHA256: "fa2a8ec1015d9df5330941ade12437488fc40d33f9c9f8cd4eb70a26de11b639", + }, + run: "set +e\ntgz=\"$RUNNER_TEMP/just.tar.gz\"\ncurl -fsSL -o \"$tgz\" \\\n \"https://github.com/casey/just/releases/download/${JUST_VERSION}/just-${JUST_VERSION}-x86_64-unknown-linux-musl.tar.gz\" \\\n && echo \"${JUST_SHA256} $tgz\" | sha256sum -c - \\\n && mkdir -p \"$RUNNER_TEMP/bin\" \\\n && tar -xzf \"$tgz\" -C \"$RUNNER_TEMP/bin\" just\nSTATUS=$?\nif [ \"$STATUS\" -ne 0 ]; then\n echo \"::error title=just install::could not fetch or verify just ${JUST_VERSION}\"\n exit \"$STATUS\"\nfi\necho \"$RUNNER_TEMP/bin\" >> \"$GITHUB_PATH\"\n\"$RUNNER_TEMP/bin/just\" --version\n", + }, + { + name: "Engine files match the canon", + if: "${{ !cancelled() }}", + shell: "bash", + run: "set +e\ndrift=0\nfor f in provision.just provision-lib.sh provision-modes.sh provision-check.sh; do\n if [ ! -f \"build/just/$f\" ]; then\n echo \"::error file=build/just/$f,title=engine missing::build/just/$f is missing (run: launch-scaffolder provision-set realign)\"\n drift=1\n elif ! cmp -s \"build/just/$f\" \"$RUNNER_TEMP/canon/$f\"; then\n echo \"::error file=build/just/$f,title=engine drift::build/just/$f differs from the canon at standards@${{ job.workflow_sha }} (run: launch-scaffolder provision-set realign)\"\n drift=1\n else\n echo \"ok build/just/$f\"\n fi\ndone\nexit \"$drift\"\n", + }, + { + name: "Provisioning set conforms (provision-check.sh)", + if: "${{ !cancelled() }}", + shell: "bash", + run: "set +e\nbash \"$RUNNER_TEMP/canon/provision-check.sh\" . | tee \"$RUNNER_TEMP/check.log\"\nSTATUS=\"${PIPESTATUS[0]}\"\ngrep '^ FAIL' \"$RUNNER_TEMP/check.log\" | sed 's/^ FAIL //' | while IFS= read -r line; do\n echo \"::error title=provisioning::$line\"\ndone\n{\n echo \"## Provisioning check\"\n echo \"\"\n echo '```'\n cat \"$RUNNER_TEMP/check.log\"\n echo '```'\n} >> \"$GITHUB_STEP_SUMMARY\"\nexit \"$STATUS\"\n", + }, + ], + }, + }, +} diff --git a/.github/workflows/readme-derive-reusable.yml b/.github/workflows/readme-derive-reusable.yml index f3899bc0b..2ad2740c7 100644 --- a/.github/workflows/readme-derive-reusable.yml +++ b/.github/workflows/readme-derive-reusable.yml @@ -10,253 +10,109 @@ # README.md is stale — it never self-commits and never converts an undeclared # repo. Absence of a declaration is a machine-readable "do not convert": the # anti-runaway guard that the June 2026 estate-wide .adoc->.md sweep lacked. -name: README Derive Reusable Workflow - -on: - workflow_call: - inputs: - runs-on: - description: Runner label - type: string - required: false - default: ubuntu-latest - threshold: - description: Vocabulary-preservation floor (percent). ADR-004 default 98. - type: number - required: false - default: 98 - standards-ref: - description: Ref of hyperpolymath/standards to source the vocab-gate script from. - type: string - required: false - default: main - -permissions: - actions: read - contents: read - -# Read-only check workflow — safe to cancel superseded runs. -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - derive-readme: - name: Derive & verify README.md - runs-on: ${{ inputs.runs-on }} - timeout-minutes: 30 - steps: - - name: Checkout caller repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4 - - - name: Read publishing declaration - id: decl - run: | - # Prefer descriptiles; fall back to the deprecated 6a2 dir during the - # estate migration. No file / no [publishing.readme] table / derive - # not true => this repo has opted out and we do nothing. - ECO="" - for c in .machine_readable/descriptiles/ECOSYSTEM.a2ml \ - .machine_readable/6a2/ECOSYSTEM.a2ml; do - if [ -f "$c" ]; then ECO="$c"; break; fi - done - - if [ -z "$ECO" ]; then - echo "No ECOSYSTEM.a2ml found — repo not opted in. Nothing to derive." - echo "derive=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - echo "Reading declaration from: $ECO" - - # Extract the flat [publishing.readme] table into KEY=VALUE lines. - eval "$( - awk ' - /^[[:space:]]*\[/ { inblk = ($0 ~ /^[[:space:]]*\[publishing\.readme\][[:space:]]*$/) ? 1 : 0; next } - inblk && /=/ { - key = $0; sub(/=.*/, "", key); gsub(/[[:space:]]/, "", key) - val = $0; sub(/^[^=]*=[[:space:]]*/, "", val) - sub(/[[:space:]]*(#.*)?$/, "", val) # strip trailing comment/space - gsub(/^"|"$/, "", val) # strip surrounding quotes - if (key ~ /^(canonical|derive|format|path|consumer)$/) - printf "DECL_%s=%s\n", toupper(key), val - } - ' "$ECO" - )" - - if [ "${DECL_DERIVE:-false}" != "true" ]; then - echo "[publishing.readme] absent or derive != true — repo not opted in." - echo "derive=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - - CANONICAL="${DECL_CANONICAL:-README.adoc}" - DERIVED_PATH="${DECL_PATH:-README.md}" - FORMAT="${DECL_FORMAT:-gfm}" - CONSUMER="${DECL_CONSUMER:-unspecified}" - - if [ ! -f "$CANONICAL" ]; then - echo "::error::Declared canonical '$CANONICAL' does not exist." - exit 1 - fi - if [ "$FORMAT" != "gfm" ]; then - echo "::error::Unsupported derived format '$FORMAT' (only 'gfm' is implemented)." - exit 1 - fi - - echo "Opted in: consumer=$CONSUMER $CANONICAL -> $DERIVED_PATH ($FORMAT)" - { - echo "derive=true" - echo "canonical=$CANONICAL" - echo "derived_path=$DERIVED_PATH" - echo "consumer=$CONSUMER" - } >> "$GITHUB_OUTPUT" - - - name: Install toolchain - if: steps.decl.outputs.derive == 'true' - env: - # Pinned so derivation is REPRODUCIBLE: the same versions used to - # generate the committed README.md must run in CI, or the freshness - # check would flag cosmetic toolchain-drift diffs as staleness. Bump - # deliberately (and regenerate every derived README) — never float. - PANDOC_VERSION: "3.10" - ASCIIDOCTOR_VERSION: "2.0.26" - ASCIIDOCTOR_REDUCER_VERSION: "1.1.1" - run: | - set -euo pipefail - curl -fsSL -o /tmp/pandoc.deb \ - "https://github.com/jgm/pandoc/releases/download/${PANDOC_VERSION}/pandoc-${PANDOC_VERSION}-1-amd64.deb" - sudo dpkg -i /tmp/pandoc.deb - sudo gem install --no-document \ - "asciidoctor:${ASCIIDOCTOR_VERSION}" \ - "asciidoctor-reducer:${ASCIIDOCTOR_REDUCER_VERSION}" - pandoc --version | head -1 - asciidoctor --version | head -1 - - - name: Fetch vocab-gate script from standards - if: steps.decl.outputs.derive == 'true' - run: | - # Version-locked to the same standards ref that owns this workflow. - git clone --depth 1 --branch "${{ inputs.standards-ref }}" \ - https://github.com/hyperpolymath/standards.git "$RUNNER_TEMP/standards" - chmod +x "$RUNNER_TEMP/standards/.github/scripts/readme-vocab-gate.sh" - - - name: Anchor-ID convention guard - if: steps.decl.outputs.derive == 'true' - env: - CANONICAL: ${{ steps.decl.outputs.canonical }} - run: | - set -euo pipefail - # A canonical .adoc with intra-document anchor links (link:#foo[] or - # <> written as #foo) only renders a working TOC on GitHub and - # re-derives to matching Markdown anchors when it opts out of - # Asciidoctor's default '_prefixed_underscored' section IDs. So if the - # source uses #anchors, it MUST set :idprefix: (empty) and - # :idseparator: - — otherwise every TOC link silently dead-links - # (a failure the vocabulary gate cannot see, since it checks words not - # link targets). See ADR-004 "Bootstrapping a canonical .adoc". - if grep -qE 'link:#[a-z0-9-]+\[' "$CANONICAL"; then - if ! grep -qE '^:idprefix:[[:space:]]*$' "$CANONICAL" \ - || ! grep -qE '^:idseparator:[[:space:]]+-[[:space:]]*$' "$CANONICAL"; then - echo "::error::$CANONICAL has intra-doc anchor links (link:#…) but does not set ':idprefix:' (empty) and ':idseparator: -' in its header. Without them Asciidoctor generates '_underscored' section IDs and every #anchor TOC link dead-links on GitHub and in the derived README.md. Add both attributes to the document header (see ADR-004)." - exit 1 - fi - echo "✓ anchor-ID convention satisfied (idprefix/idseparator set)" - else - echo "✓ no intra-doc anchors — convention not required" - fi - - - name: Derive Markdown from AsciiDoc - if: steps.decl.outputs.derive == 'true' - env: - CANONICAL: ${{ steps.decl.outputs.canonical }} - run: | - set -euo pipefail - # This step COMMITS what it generates, so a diagnostic that does not - # stop it is a wrong README baked into a consumer repo. Neither tool - # below reports cleanliness through its exit code by default, and - # each needs a different remedy: - # - # asciidoctor's --failure-level defaults to FATAL, so even - # "ERROR: dropping cells from incomplete row" exits 0 — one stray - # '|' in a table cell shifts every later cell and the derived - # Markdown silently loses them. The flag below makes the exit code - # mean what `set -e` already assumes it means. - # - # asciidoctor-reducer has no --failure-level at all (only - # --log-level, which changes what is printed, not what is - # returned), so its exit code cannot be made to carry its verdict. - # Its stderr is captured and treated as the verdict instead. - # - # 1) flatten includes so the source is self-contained - asciidoctor-reducer "$CANONICAL" -o "$RUNNER_TEMP/reduced.adoc" \ - 2>"$RUNNER_TEMP/reducer.err" - if [ -s "$RUNNER_TEMP/reducer.err" ]; then - echo "::error::asciidoctor-reducer reported a problem flattening $CANONICAL, so the derived README would be generated from a source it could not read cleanly. Its exit code is 0 either way, which is why this is checked on stderr." - cat "$RUNNER_TEMP/reducer.err" >&2 - exit 1 - fi - # 2) reliable route: asciidoc -> docbook -> gfm (direct converters lose structure) - asciidoctor --failure-level=WARN -b docbook5 -a attribute-missing=drop \ - "$RUNNER_TEMP/reduced.adoc" -o "$RUNNER_TEMP/reduced.xml" - pandoc -f docbook -t gfm --wrap=preserve \ - "$RUNNER_TEMP/reduced.xml" -o "$RUNNER_TEMP/body.md" - - # 3) stamp: carry the canonical SPDX line (rewritten as an HTML - # comment) + a GENERATED banner so no one hand-edits the artefact. - SPDX="$(grep -m1 -oE 'SPDX-License-Identifier:[[:space:]]*[^[:space:]]+' "$CANONICAL" || true)" - { - [ -n "$SPDX" ] && echo "" - echo "" - echo "" - cat "$RUNNER_TEMP/body.md" - } > "$RUNNER_TEMP/derived.md" - - - name: Vocabulary-preservation gate - if: steps.decl.outputs.derive == 'true' - env: - CANONICAL: ${{ steps.decl.outputs.canonical }} - run: | - "$RUNNER_TEMP/standards/.github/scripts/readme-vocab-gate.sh" \ - "$CANONICAL" "$RUNNER_TEMP/derived.md" "${{ inputs.threshold }}" - - - name: Freshness check (fail-and-tell) - if: steps.decl.outputs.derive == 'true' - env: - DERIVED_PATH: ${{ steps.decl.outputs.derived_path }} - CANONICAL: ${{ steps.decl.outputs.canonical }} - run: | - set -euo pipefail - # Normalise before comparing: drop the banner comment lines, trim - # trailing whitespace, and squeeze blank-line runs. The toolchain is - # version-pinned so output is reproducible; this normalisation is a - # belt-and-braces layer that keeps a harmless whitespace nudge from - # being reported as staleness, while any real prose/structure/anchor - # change still shows. - norm() { grep -vE '^\"\n echo \"\"\n echo \"\"\n cat \"$RUNNER_TEMP/body.md\"\n} > \"$RUNNER_TEMP/derived.md\"\n", + }, + { + name: "Vocabulary-preservation gate", + if: "steps.decl.outputs.derive == 'true'", + env: { + CANONICAL: "${{ steps.decl.outputs.canonical }}", + }, + run: "\"$RUNNER_TEMP/standards/.github/scripts/readme-vocab-gate.sh\" \\\n \"$CANONICAL\" \"$RUNNER_TEMP/derived.md\" \"${{ inputs.threshold }}\"\n", + }, + { + name: "Freshness check (fail-and-tell)", + if: "steps.decl.outputs.derive == 'true'", + env: { + DERIVED_PATH: "${{ steps.decl.outputs.derived_path }}", + CANONICAL: "${{ steps.decl.outputs.canonical }}", + }, + run: "set -euo pipefail\n# Normalise before comparing: drop the banner comment lines, trim\n# trailing whitespace, and squeeze blank-line runs. The toolchain is\n# version-pinned so output is reproducible; this normalisation is a\n# belt-and-braces layer that keeps a harmless whitespace nudge from\n# being reported as staleness, while any real prose/structure/anchor\n# change still shows.\nnorm() { grep -vE '^