Skip to content

ci: a skipped matrix job creates no per-leg contexts, so #351 made docs-only PRs unmergeable - #357

Merged
rainmanjam merged 3 commits into
mainfrom
ci/matrix-contexts-report
Aug 14, 2026
Merged

ci: a skipped matrix job creates no per-leg contexts, so #351 made docs-only PRs unmergeable#357
rainmanjam merged 3 commits into
mainfrom
ci/matrix-contexts-report

Conversation

@rainmanjam

Copy link
Copy Markdown
Owner

The bug I shipped

#351 gated the acceptance matrix with a job-level if:, on the reasoning that a skipped job still reports and a skip satisfies a required check.

That is true of an ordinary job. It is false of a matrix job: a skipped matrix never expands, so the per-leg contexts are not skipped — they are never created.

#349 is the proof, and it is unambiguous:

15 checks pass, 0 fail, mergeStateStatus = BLOCKED

checks list shows:  acceptance: ${{ matrix.suite }}    skipping
                    ^ the expression, unsubstituted, because the matrix never expanded

required contexts:  MISSING: acceptance: acceptance
                    MISSING: acceptance: acceptance-audio
                    ... 12 total

A documentation-only pull request — the exact case #351 was built for — could not be merged at all. #351's own PR body named this trap and then walked into it, because the mitigation it chose was the same bug in a different costume.

The fix

The matrix always expands, so all twelve contexts report. What is conditional is the work: each step carries if: needs.changes.outputs.code == 'true'.

Before #351 #351 (broken) This PR
Contexts reported on a docs PR 12 0 12
Docs PR mergeable yes never yes
Docs PR cost 12 full suites 0 12 runner allocations

A step cannot end a job early without failing it, so the gate is spelled out on each of the six working steps rather than checked once. Upload logs moves from always() to always() && …code == 'true' — no artifact exists on a docs run, only a warning about its absence.

Added a Documentation-only change, so this suite did not run step that emits a ::notice, so a reader of the checks list is never left guessing whether a green check ran the suite or no-opped.

Why not the container job's shape

container-suites solves this by emitting a suite list and using if: needs.container-suites.outputs.suites != '[]' — the same job-level skip. It gets away with it because its per-suite contexts are not required; only which container suites is. That option is unavailable here.

Verification

Closes the regression from #351.

https://claude.ai/code/session_01HeLrWaDmsNeeNSbHQfEofX

…cs PRs unmergeable

#351 gated the acceptance matrix with a job-level `if:` on the reasoning that a
skipped job still reports and a skip satisfies a required check. That is true of
an ordinary job and false of a matrix one: a skipped matrix never expands, so
the per-leg contexts are not skipped, they are never created.

#349 is the proof. Fifteen checks green, zero failures, one entry in the checks
list named literally `acceptance: ${{ matrix.suite }}`, and all twelve required
`acceptance: acceptance-*` contexts reported MISSING. A documentation-only pull
request -- the exact case #351 was built for -- could not be merged at all.

The #351 PR body named this trap ("if a required check is skipped by a path
filter, GitHub can leave a PR unmergeable forever") and then walked into it,
because the mitigation it chose was the same bug in a different costume.

The matrix now always expands and every leg reports. What is conditional is the
work: each step carries the gate, a documentation-only run costs a runner
allocation instead of a suite, and an explicit notice step says why a green
check did nothing rather than leaving a reader to guess.

Claude-Session: https://claude.ai/code/session_01HeLrWaDmsNeeNSbHQfEofX
Copilot AI lite review requested due to automatic review settings August 14, 2026 20:28

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes a GitHub Actions branch-protection regression where a job-level if: on a matrix job prevented matrix expansion on docs-only PRs, resulting in missing (not skipped) required per-leg check contexts and therefore unmergeable pull requests. The workflow is adjusted so the matrix always expands (ensuring all required contexts exist) while the actual suite work is gated at the step level based on the changes job output.

Changes:

  • Removes the job-level if: from the acceptance matrix job to ensure all matrix legs (and thus all required contexts) are created.
  • Adds step-level gating (if: needs.changes.outputs.code == 'true') to prevent suite work from running on documentation-only changes.
  • Adds an explicit ::notice step for docs-only runs and avoids log artifact upload when suites did not run.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

…d today it hung for 24 minutes

`go build, vet, test` on #357 sat 24 minutes in "internal/api coverage
measures the tests, not the preflight" against a step measured at 98-114s
across the last five runs on main, until the job's own timeout-minutes: 25
cancelled it and named nothing.

TWO GAPS, AND THE SECOND IS BROKEN ARITHMETIC WITH OR WITHOUT A HANG. The
probes carried no -timeout, so Go's default of TEN MINUTES PER PROBE applied
to three of them: 30 minutes of worst case behind a job ceiling of 25. The
guard could never have reported; it could only ever have been cancelled. This
is the error the acceptance job already documents about its watchdog, where
the arithmetic "quietly assumes the suite starts when the job does".

And the step was the one exception to a discipline this file states out loud
at :340 -- "THE FOUR SCRIPT STEPS IN THIS JOB CARRY STEP TIMEOUTS ... a job
timeout names nothing, a step timeout names the step". Four got one. The
fifth is the one that hung.

WHY GO'S TIMEOUT CANNOT BE THE BACKSTOP HERE, though it is still worth having.
The guard captures each probe with `out="$(go test ...)"`, and command
substitution blocks until the pipe has no writers left rather than until go
test exits. A test leaking a child that holds inherited stdout hangs the
capture after the binary is gone and after Go's timeout has already fired --
the mechanism the note at :360 already gives for the watchdog, one step below
the step this happened to.

So: -timeout 4m per probe (~3x the slowest real one, 12m worst case) and
timeout-minutes: 14 on the step, above that 12 so Go still wins the race and
prints the goroutine dump naming the test, and below the job's 25 either way.

Verified: passes in 103s locally, matching CI's 98-114s. Mutating the flag to
1ms makes the first probe fail with `panic: test timed out after 1ms` naming
TestLedgerPreflight, so the bound is wired to the probes rather than decorative.

Claude-Session: https://claude.ai/code/session_01HeLrWaDmsNeeNSbHQfEofX
@sonarqubecloud

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants