ci
A skipped matrix job never posts the checks you required
A one-file docs PR sat on "Expected — waiting for status to be reported" with no checks coming. Two GitHub mechanisms hid required checks: a paths filter that skipped the whole workflow, and a matrix job skipped at the job level — evaluated before the matrix expands, so its per-leg contexts never post. Why "skipped" is not "never ran."
By the X-GIS team 6 min read
A pull request that changed exactly one file — a Markdown blog post — could not
be merged. Its status read “Expected — waiting for status to be reported,” and
it stayed that way. gh pr checks <branch> was blunter:
no checks reported on the branchNothing was coming. Branch protection required eleven status checks before a
merge, and zero of them would ever post, because the workflow that posts them
never ran. A required check that never runs is not red and it is not pending in
any way that resolves — it is Expected, and Expected blocks the merge
forever.
This took two fixes to clear, and the second one is the interesting one: a matrix job skipped at the job level does not post the per-leg checks you made required, because the skip is decided before the matrix expands.
Layer one: a path filter skipped the whole workflow
The test workflow guarded its trigger with a path filter:
on: pull_request: paths-ignore: - '**/*.md' - 'docs/**' - 'LICENSE' - '.gitignore'The intent is ordinary and reasonable: a docs-only change can’t break the unit suite or the shader gate, so don’t burn CI minutes on it. And for a long time that was free, because nothing gated on the result. Then branch protection was switched on and made those checks required.
Now the filter is a deadlock. A PR that touches only **/*.md matches
paths-ignore, so GitHub skips the entire workflow — no jobs, and therefore
no check runs posted at all. Branch protection is still waiting for eleven
named contexts. They never arrive.
The workflow’s own comment had predicted this, months earlier:
If branch protection is ever added with a required test/render check, REMOVE this
paths-ignore— a never-running required check strands the PR.
Warning
paths / paths-ignore on a pull_request trigger + a required status check
= a PR that touches only ignored paths can never merge. The workflow skips, the
check never posts, and branch protection waits on it forever. A path-filtered
workflow and a required check produced by that workflow are mutually exclusive.
Before fixing it properly I tried to just force the merge — the equivalent of
gh pr merge --admin. That was refused by policy (the PR was authored by an
automated account, and admin-bypassing its own required checks is exactly what
the policy exists to stop). Which was the right outcome: forcing this one merge
would have hidden a bug that strands every future docs PR. So, delete the
paths-ignore. The workflow runs on the docs PR now.
It was still blocked.
Layer two: the matrix job skipped before it expanded
With the workflow running, the checks it posted were:
changes ✓test (${{ matrix.id }}) – skippedtypecheck – skippedsite ✓render-gate ✓Look at the second line. Branch protection did not require a check called
test (${{ matrix.id }}). It required eight expanded ones — test (unit-a),
test (unit-b), test (integration), and five more. None of those posted.
The literal, un-interpolated template string did.
The test job is a matrix, gated at the job level:
test: needs: changes if: needs.changes.outputs.code == 'true' # ← job-level skip strategy: matrix: include: - { id: unit-a, args: '...' } - { id: unit-b, args: '...' } # …eight legs… name: test (${{ matrix.id }}) steps: [checkout, setup, build, test]A docs PR sets code == false, so the job’s if is false and the job is
skipped. The trap is the evaluation order: a job-level if is evaluated
before the matrix is expanded. GitHub never instantiates the eight legs, so
the eight names are never created. It posts a single skipped check under the
raw template name test (${{ matrix.id }}) — the string, un-interpolated —
and the eight contexts your branch protection actually lists stay Expected.
The tell that this is specifically a matrix problem: on the same PR,
typecheck — an ordinary, non-matrix job with the identical if — skipped
cleanly and posted under its own name typecheck, which branch protection
accepts. A skipped single job reports the check you required. A skipped matrix
job reports a check nobody required.
Warning
skipped satisfies a required status check — but only if the check is
posted. A single skipped job posts its own name. A skipped matrix job posts
only the un-expanded name: template, so every per-leg context you require is
never created. Requiring test (leg-1)…test (leg-8) and skipping the test
job at the job level are incompatible.
The fix: make the matrix expand, then skip inside
The repair is to stop skipping the job and start skipping its steps — move the
if from the job down to each step, so the matrix always expands:
test: needs: changes # no job-level if — the matrix MUST expand so each per-leg check posts strategy: matrix: { include: [… eight legs …] } name: test (${{ matrix.id }}) steps: - if: needs.changes.outputs.code == 'true' uses: actions/checkout@v4 - if: needs.changes.outputs.code == 'true' run: ./build-and-testNow every leg is instantiated. On a docs PR each leg starts, finds every step
if-gated off, runs nothing, and posts green in a few seconds under its
real expanded name. On a code PR the steps run exactly as before. The eight
required contexts are satisfied either way, and the docs PR merges.
This is the same shape the workflow’s own render-gate job already used — a job
with no top-level if, every expensive step guarded individually, so it always
posts a check even when it does no work. The matrix legs just needed to be told
the same thing.
If you would rather not spin up eight near-empty jobs, the other fix is to stop
requiring the per-leg names at all and require a single aggregator job instead —
one needs: [test], if: always() job that fails iff any leg failed. That name
is immune to how many legs exist and to how they skip. Requiring the leaf
contexts of a matrix, and then skipping that matrix, is the combination to
avoid.
What actually generalizes
Strip the specifics and the shape is: a required check waits on a name that a skip never creates. Two ways a workflow silently fails to create a required name:
- A
paths/paths-ignorefilter skips the whole workflow, so nothing posts. - A matrix job skipped at the job level is decided before expansion, so its per-leg contexts are never instantiated.
Both look identical from the PR page — a check stuck on Expected, no red, no
log to read, nothing to rerun. The instinct is to hunt for the job that failed.
There is no job that failed; there is a job that never ran, and a required name
that was never born. When a check sits on Expected with no run behind it, stop
looking for a failure and start asking which skip ate the check.
Read next
ci
5 min read
The second ratchet: one invariant with two authorities, and a gate we could not run
An icon-padding feature grew three compiler files past their LOC ceiling. The PR raised the ratchet it knew about and went green — then CI failed on a SECOND ratchet tracking the same three files with the same numbers. A repo that preaches single-authority had let one invariant grow two authorities, and a local gate nobody knew existed is a gate nobody can run.
verification
5 min read
"Touches rendering" is not "needs a render to verify"
A headless session with no GPU had thirteen open issues and a hard rule against claiming a render is correct without a pixel diff. The split that worked sorted the backlog not by difficulty but by whether each fix's correctness reduces to something provable without a raster.
testing
8 min read
A gate you have never watched fail is decoration
We broke instanced batching on purpose to watch a brand-new CI gate go red — 100,000 draw calls — before trusting its green. Why new gates need a demonstrated failure, and why this one asserts dc(10k) === dc(100k) instead of pinning an exact count.