From b55236a2579774ca0066c158bf42d59dd08af4a8 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Thu, 8 Oct 2026 11:47:31 +0100 Subject: [PATCH] ci(ci-pipeline): drop duplicate semgrep SAST job (app covers PRs) The semgrep-code-hyperpolymath GitHub App (Semgrep Managed Scans) reports semgrep-cloud-platform/scan on every pull request, so the `sast` job scanned the same code a second time. Removed: the job, the `enable_sast` and `sast_blocking` inputs, its `needs` edge and env in `report`, and its row in the skip ledger. No caller passes either input (census in the PR body), and every caller pins this reusable by SHA, so none changes until it bumps. The file is rewritten as KYAML with scripts/kyaml-format.sh in the same change (owner ruling 2026-10-08: a workflow a PR touches is converted in that PR). Parsed data is identical to the hand-edited block form; every comment survives as a YAML comment or inside its run: string. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013aSu89DNALjTYHBvA6FcoM --- .github/workflows/ci-pipeline.yml | 1630 +++++++++-------------------- 1 file changed, 467 insertions(+), 1163 deletions(-) diff --git a/.github/workflows/ci-pipeline.yml b/.github/workflows/ci-pipeline.yml index c25c5db87..32f9f3d11 100644 --- a/.github/workflows/ci-pipeline.yml +++ b/.github/workflows/ci-pipeline.yml @@ -1,7 +1,7 @@ # This workflow is managed by gh actions-lock. # SPDX-License-Identifier: MPL-2.0 # -# CI Pipeline (reusable) — secrets, SAST, per-ecosystem lint/format, coverage. +# CI Pipeline (reusable) — secrets, per-ecosystem lint/format, coverage. # # Consumed by both @hyperpolymath and @metadatastician repos through a thin # caller. Everything here is READ-ONLY verification: no `--fix`, no bare `fmt`, @@ -25,13 +25,12 @@ # fully qualified (never `./`) because `./` resolution inside a nested # reusable called from another repo is not worth a startup failure. # -# 3. SAST FINDINGS ARE ADVISORY; SAST VACUITY IS FATAL. -# Owner standing ruling: a NEW scanner finding does not block a merge, it -# becomes an issue with acceptance criteria. So semgrep findings are -# reported, not thrown — unless `sast_blocking: true`. What IS fatal, and -# unconditionally so, is semgrep scanning ZERO files: `p/default` has thin -# Rust coverage, and a 0-file scan would otherwise report a cheerful green -# having examined nothing at all. +# 3. NO SAST JOB HERE. Semgrep runs as the semgrep-code-hyperpolymath +# GitHub App (Semgrep Managed Scans), which reports +# `semgrep-cloud-platform/scan` on every pull request. A second, +# in-pipeline semgrep scanned the same code twice, so it was removed +# (owner, 2026-10-08). The `enable_sast` and `sast_blocking` inputs went +# with it; no caller passed either. # # Language-policy note: ReScript, V-lang and Deno are BANNED for new code # (.claude/CLAUDE.md, owner rulings 2026-04-30 / 2026-04-10 / 2026-08-26). @@ -44,1158 +43,463 @@ # 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') - # Haskell is counted ONLY where its consuming job can run: the `haskell` - # job runs `cabal build all` at the REPOSITORY ROOT, which needs a root - # `cabal.project` or a root `*.cabal`. `:(glob)` stops `*` crossing `/`. - # The old marker set (`*.cabal`, `*.hs`, `stack.yaml` at any depth) - # went true on a nested satellite (standards: sanctify-php, - # palimpsest-license/TOOLS/validation/haskell) and then failed with - # Cabal-7136 "There is no .cabal package file or cabal.project" — - # a guard counting a different set than its consumer checks - # (standards#1161). Nested Haskell is reported below as unsupported. - N_HASKELL=$(count 'cabal.project' ':(glob)*.cabal') - # ⚠ 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' - - # Haskell the `haskell` job cannot build (no root cabal.project or - # `*.cabal`): nested projects, stack-only projects, loose `*.hs`. - # Probed only when N_HASKELL is 0 — under a root project, nested - # sources belong to it and ARE built. - if [ "$N_HASKELL" -eq 0 ]; then - probe 'Haskell (no root cabal.project/*.cabal)' '*.cabal' 'cabal.project' '*/cabal.project' 'stack.yaml' '*/stack.yaml' '*.hs' - fi - - 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 | root \`cabal.project\` or root \`*.cabal\` | ${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. - # - # BUILD WHAT `detect` COUNTED. Since #1184 the probe counts Haskell only - # from a ROOT `cabal.project` or a ROOT `*.cabal` (`:(glob)` stops `*` - # crossing `/`); a repo with nested packages and neither root file is - # reported by detect as UNSUPPORTED and this job does not run (owner - # ruling 2026-10-06, standards#1161). A root `cabal.project` is the - # repo's own word and is used as-is, even when every package it lists - # is nested. Without one, detect fired on a root `*.cabal`, and one is - # written (untracked) listing the root package and - # every tracked nested package directory, so a satellite package that - # sits beside the root one is built too, not silently skipped. - if [ -f cabal.project ]; then - echo "Using the repository's own cabal.project." - source_note="root cabal.project" - else - mapfile -t pkg_dirs < <(git ls-files -- '*.cabal' | xargs -r -n1 dirname | sort -u) - if [ "${#pkg_dirs[@]}" -eq 0 ]; then - echo "::error::Haskell was detected from a root *.cabal, but git ls-files lists no tracked *.cabal, so there is nothing cabal can build. Commit the .cabal file (hpack users: commit the generated one) or add a root cabal.project." - exit 1 - fi - { echo "packages:"; printf ' %s/\n' "${pkg_dirs[@]}"; } > cabal.project - echo "No root cabal.project; generated one for ${#pkg_dirs[@]} package dir(s):" - cat cabal.project - source_note="generated cabal.project, ${#pkg_dirs[@]} package dir(s)" - fi - cabal update - cabal build all --ghc-options='-Wall -Werror' - { - echo "### Haskell" - echo "" - echo "- \`cabal build all --ghc-options='-Wall -Werror'\` ($source_note): 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 root cabal.project/*.cabal 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_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')\n# Haskell is counted ONLY where its consuming job can run: the `haskell`\n# job runs `cabal build all` at the REPOSITORY ROOT, which needs a root\n# `cabal.project` or a root `*.cabal`. `:(glob)` stops `*` crossing `/`.\n# The old marker set (`*.cabal`, `*.hs`, `stack.yaml` at any depth)\n# went true on a nested satellite (standards: sanctify-php,\n# palimpsest-license/TOOLS/validation/haskell) and then failed with\n# Cabal-7136 \"There is no .cabal package file or cabal.project\" —\n# a guard counting a different set than its consumer checks\n# (standards#1161). Nested Haskell is reported below as unsupported.\nN_HASKELL=$(count 'cabal.project' ':(glob)*.cabal')\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\n# Haskell the `haskell` job cannot build (no root cabal.project or\n# `*.cabal`): nested projects, stack-only projects, loose `*.hs`.\n# Probed only when N_HASKELL is 0 — under a root project, nested\n# sources belong to it and ARE built.\nif [ \"$N_HASKELL\" -eq 0 ]; then\n probe 'Haskell (no root cabal.project/*.cabal)' '*.cabal' 'cabal.project' '*/cabal.project' 'stack.yaml' '*/stack.yaml' '*.hs'\nfi\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 | root \\`cabal.project\\` or root \\`*.cabal\\` | ${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 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.\n#\n# BUILD WHAT `detect` COUNTED. Since #1184 the probe counts Haskell only\n# from a ROOT `cabal.project` or a ROOT `*.cabal` (`:(glob)` stops `*`\n# crossing `/`); a repo with nested packages and neither root file is\n# reported by detect as UNSUPPORTED and this job does not run (owner\n# ruling 2026-10-06, standards#1161). A root `cabal.project` is the\n# repo's own word and is used as-is, even when every package it lists\n# is nested. Without one, detect fired on a root `*.cabal`, and one is\n# written (untracked) listing the root package and\n# every tracked nested package directory, so a satellite package that\n# sits beside the root one is built too, not silently skipped.\nif [ -f cabal.project ]; then\n echo \"Using the repository's own cabal.project.\"\n source_note=\"root cabal.project\"\nelse\n mapfile -t pkg_dirs < <(git ls-files -- '*.cabal' | xargs -r -n1 dirname | sort -u)\n if [ \"${#pkg_dirs[@]}\" -eq 0 ]; then\n echo \"::error::Haskell was detected from a root *.cabal, but git ls-files lists no tracked *.cabal, so there is nothing cabal can build. Commit the .cabal file (hpack users: commit the generated one) or add a root cabal.project.\"\n exit 1\n fi\n { echo \"packages:\"; printf ' %s/\\n' \"${pkg_dirs[@]}\"; } > cabal.project\n echo \"No root cabal.project; generated one for ${#pkg_dirs[@]} package dir(s):\"\n cat cabal.project\n source_note=\"generated cabal.project, ${#pkg_dirs[@]} package dir(s)\"\nfi\ncabal update\ncabal build all --ghc-options='-Wall -Werror'\n{\n echo \"### Haskell\"\n echo \"\"\n echo \"- \\`cabal build all --ghc-options='-Wall -Werror'\\` ($source_note): 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", + "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_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 \"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 root cabal.project/*.cabal 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` carries no `needs: detect`, so it runs 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` has no\n# `needs: detect`, so it runs 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", + }, + ], + }, + }, +}