From 408ac601ffc2f030e74489a3d9222706819d9887 Mon Sep 17 00:00:00 2001 From: Jaro-c <75870284+Jaro-c@users.noreply.github.com> Date: Mon, 24 Aug 2026 16:16:36 -0500 Subject: [PATCH 1/2] feat(rust-ci): add gate jobs whose names cannot move MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A required status check is matched by its name string, and two jobs here build their name by interpolating a value: `Test (${{ matrix.os }})` and `MSRV (${{ inputs.msrv }})`. When a consumer changes that value the job emits a different name, the consumer's ruleset still requires the old one, nothing emits it, and the pull request waits forever — and it is the pull request carrying the change, so the fix cannot be merged from inside it. `MSRV` is the worse of the two: not a matrix, but a single job interpolating an input into its own name. Renaming the existing jobs would be the obvious fix and is the wrong one: it would break every ruleset that requires the current names, with a failure that is harder to diagnose than a renamed input, because the input is in a file and the check name is in a settings page nobody opens. So this is additive. Two new jobs carry names that cannot move, `Extra platforms` and `MSRV`, and consumers migrate their rulesets to those at their own pace. The value-carrying names keep emitting; nothing is removed here. Both gates use `if: always()` and inspect `needs..result` rather than relying on `needs:` alone, because `needs:` alone SKIPS the dependent job when its dependency fails, and a skipped required check does not block a merge — the exact failure these jobs exist to prevent. `skipped` counts as a pass, since both upstream jobs are conditional on caller inputs. Refs Glyndor/podup#1552 Signed-off-by: Jaro-c <75870284+Jaro-c@users.noreply.github.com> --- .github/workflows/rust-ci.yml | 48 +++++++++++++++++++++++++++++++++++ docs/reusables/README.md | 2 +- docs/reusables/rust-ci.md | 4 ++- 3 files changed, 52 insertions(+), 2 deletions(-) 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..6c40601 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 | 8 | 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..29e526d 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, +8 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. From b5ab596a51b256d6799ec98c36b7d69b3781264b Mon Sep 17 00:00:00 2001 From: Jaro-c <75870284+Jaro-c@users.noreply.github.com> Date: Mon, 24 Aug 2026 16:17:35 -0500 Subject: [PATCH 2/2] fix(docs): stop reading `if: always()` as a condition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The generated page tells a consumer which checks are safe to require, since a required check whose name nothing emits blocks every pull request. Its rule for "conditional" was `job.if is not None`, so the two gate jobs added in the previous commit — which carry `if: always()` precisely so they keep emitting when the job they report on failed or was skipped — were documented as conditional, with `always()` printed in the "Emitted when" column. That inverts the meaning of the table on exactly the rows a ruleset should be pointed at: it warned readers away from the only two checks in this workflow that cannot become phantoms. `rust-ci` now reads 6 of 10 conditional rather than 8 of 10, and both gates read `always`. No other reusable page changes: none of them spells out `always()`. Signed-off-by: Jaro-c <75870284+Jaro-c@users.noreply.github.com> --- docs/reusables/README.md | 2 +- docs/reusables/rust-ci.md | 6 +++--- scripts/render-reusable-docs.py | 12 +++++++++++- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/docs/reusables/README.md b/docs/reusables/README.md index 6c40601..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) | 10 | 8 | 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 29e526d..528ad21 100644 --- a/docs/reusables/rust-ci.md +++ b/docs/reusables/rust-ci.md @@ -27,15 +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 / Extra platforms` | always | | `example / Coverage` | `inputs.coverage-threshold > 0` | | `example / MSRV (${{ inputs.msrv }})` | `inputs.msrv != ''` | -| `example / MSRV` | `always()` | +| `example / MSRV` | always | | `example / Package check` | `inputs.package-check` | | `example / Semver checks` | `inputs.semver-check` | | `example / Doc warnings` | `inputs.doc-warnings` | -8 of these 10 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()}`"