diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index f7063e7..c18455b 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -174,6 +174,33 @@ jobs: - name: Tests run: cargo test --locked --all-features --workspace + extra-platforms-gate: + name: Extra platforms + # A required status check is matched by name, so a name carrying a value makes + # the pull request that changes the value block itself: the job emits a new + # name, the ruleset still requires the old one, and nothing emits it. This job + # carries a name that cannot move, so a ruleset gates on "the extra-platform + # tests passed" without naming which platforms those are. Glyndor/podup#1552. + needs: test-extra + # `needs` on its own SKIPS this job when the dependency fails, and a skipped + # required check does not block a merge - which is the exact failure this job + # exists to prevent. `if: always()` plus an explicit result check is what makes + # it a gate rather than a decoration. + if: always() + runs-on: ubuntu-latest + steps: + - name: Reflect the extra-platform test result + env: + RESULT: ${{ needs.test-extra.result }} + run: | + set -euo pipefail + # `skipped` is a pass: test-extra is gated on `extra-test-os != '[]'`, so a + # caller that asks for no extra platforms has nothing to fail. + case "$RESULT" in + success | skipped) echo "extra-platform tests: $RESULT" ;; + *) echo "::error::extra-platform tests did not pass (result: $RESULT)"; exit 1 ;; + esac + coverage: name: Coverage if: inputs.coverage-threshold > 0 @@ -268,6 +295,27 @@ jobs: - name: Build on the declared MSRV run: cargo build --locked --all-features + msrv-gate: + name: MSRV + # Same reason as extra-platforms-gate: `MSRV (1.85)` moves the day the MSRV + # moves, and takes the gate with it. MSRV is the worse of the two because it is + # not a matrix - a single job interpolating an input into its own name. The + # number stays visible in the job log and in the `MSRV (x.y)` job itself. + needs: msrv + if: always() + runs-on: ubuntu-latest + steps: + - name: Reflect the MSRV result + env: + RESULT: ${{ needs.msrv.result }} + run: | + set -euo pipefail + # `skipped` is a pass: msrv is gated on `inputs.msrv != ''`. + case "$RESULT" in + success | skipped) echo "MSRV: $RESULT" ;; + *) echo "::error::MSRV job did not pass (result: $RESULT)"; exit 1 ;; + esac + package: name: Package check if: inputs.package-check diff --git a/docs/reusables/README.md b/docs/reusables/README.md index 2eb71de..0be0751 100644 --- a/docs/reusables/README.md +++ b/docs/reusables/README.md @@ -23,7 +23,7 @@ which blocks every pull request until someone works out why. | [`python-ci`](python-ci.md) | 1 | 0 | 5 | | [`release-verify`](release-verify.md) | 1 | 0 | 6 | | [`rust-audit`](rust-audit.md) | 2 | 0 | 4 | -| [`rust-ci`](rust-ci.md) | 8 | 6 | 12 | +| [`rust-ci`](rust-ci.md) | 10 | 6 | 12 | | [`rust-debian`](rust-debian.md) | 2 | 1 | 11 | | [`rust-fuzz`](rust-fuzz.md) | 1 | 0 | 7 | | [`rust-supply-chain`](rust-supply-chain.md) | 1 | 0 | 5 | diff --git a/docs/reusables/rust-ci.md b/docs/reusables/rust-ci.md index 35194d6..528ad21 100644 --- a/docs/reusables/rust-ci.md +++ b/docs/reusables/rust-ci.md @@ -27,13 +27,15 @@ a required check whose name nothing emits blocks every pull request. | `example / Format & lint` | always | | `example / Test` | always | | `example / Test (${{ matrix.os }})` (one per matrix entry) | `inputs.extra-test-os != '[]'` | +| `example / Extra platforms` | always | | `example / Coverage` | `inputs.coverage-threshold > 0` | | `example / MSRV (${{ inputs.msrv }})` | `inputs.msrv != ''` | +| `example / MSRV` | always | | `example / Package check` | `inputs.package-check` | | `example / Semver checks` | `inputs.semver-check` | | `example / Doc warnings` | `inputs.doc-warnings` | -6 of these 8 are conditional: they do not run, +6 of these 10 are conditional: they do not run, and therefore emit no check, unless the input in the right-hand column says so. Requiring one in a ruleset without setting its input is how a phantom check is created. diff --git a/scripts/render-reusable-docs.py b/scripts/render-reusable-docs.py index b3ce2b5..e1983e3 100755 --- a/scripts/render-reusable-docs.py +++ b/scripts/render-reusable-docs.py @@ -67,6 +67,12 @@ def header_comment(path): return " ".join(x for x in out if x).strip() +# Spellings of "this job always runs". A job with no `if:` runs unconditionally; +# so does one that spells that out, which a gate job must do to survive a failed +# dependency. +ALWAYS = {"always()", "${{ always() }}"} + + def emitted_checks(spec): """One row per job: the name a caller will see, and the condition that decides whether it appears at all.""" @@ -74,7 +80,11 @@ def emitted_checks(spec): for job_id, job in (spec.get("jobs") or {}).items(): name = job.get("name", job_id) cond = job.get("if") - if cond is None: + # `if: always()` is the opposite of conditional: it is how a gate job + # keeps emitting its check even when the job it reports on failed or was + # skipped. Reading it as a condition would document exactly the jobs a + # ruleset CAN safely require as the ones that might not appear. + if cond is None or str(cond).strip() in ALWAYS: when = "always" else: when = f"`{str(cond).strip()}`"