Skip to content

fix(ci): a skipped smoke-test must not satisfy the required context (#17126) - #17388

Merged
mrveiss merged 6 commits into
mainfrom
issue-17126-smoke-gate
Sep 24, 2026
Merged

mrveiss merged 6 commits into
mainfrom
issue-17126-smoke-gate

Conversation

@mrveiss

@mrveiss mrveiss commented Sep 24, 2026 •

Copy link
Copy Markdown
Owner

Thinking Path

Owner rule, 2026-09-19: nothing lands on main without passing the Docker smoke test. smoke-test
is a required status check published by two mutually exclusive jobs — the real build in
docker-smoke-test.yml, and the path-filtered shim in docker-smoke-required-context.yml for pull
requests that cannot reach an image.

Both jobs needs: changes. A changes job that failed or was cancelled therefore skipped both of
them
, and GitHub counts a skipped check run as satisfying a required status check. The
requirement was met with no smoke test run at all.

The part worth reading is that both files said the opposite. Each carried a comment asserting
fail-closed:

if changes errors, needs skips this job rather than reporting green, and the shim's complement
leaves the context unreported — which blocks the pull request, the safe direction.

Correct that the job is skipped. Wrong about what a skip means. And written in the one place that
cannot be falsified — the same shape as #17354's global-channel comment ("carries no per-tenant
payload of its own") and the floor prose in #17384: a claim parked where nothing can test it, which
then gets read as enforcement.

Why the fix touches the real workflow too. There are two changes jobs, one per workflow, and
each job can only see its own. Making the shim refuse covers the case where the shim's detector
fails. If the real workflow's detector fails while the shim's succeeds with docker == 'true',
the shim correctly stands down and only the real job's condition prevents an all-skipped context. So
the real build now also runs when its own changes did not succeed: when nothing can say whether an
image could have changed, the honest answer is to build it. That costs a full run on a rare
infrastructure failure, which is the right direction for a gate nothing is allowed to land without.

The three cases stay mutually exclusive, so exactly one check run named smoke-test ever carries a
conclusion:

detector docker paths who reports
success unchanged the shim, green — the legitimate case
success changed the real build (the shim skips)
failed / cancelled unknown the shim fails, and the real workflow builds on its own side

What Changed

file change
.github/workflows/docker-smoke-required-context.yml the shim runs on always() && (result != 'success' || docker != 'true') and its step refuses before reporting, with the detector result passed through env: rather than interpolated into the shell
.github/workflows/docker-smoke-test.yml the real build also runs when its own changes did not succeed; the stale fail-closed comment is replaced with what the platform actually does
repo_tests/docker_smoke_required_context_17126_test.py new, 14 tests — both if: expressions, the refusal's ORDER, the env-var seam, and that both jobs still publish the one context name

Verification

$ python3 -m pytest repo_tests/docker_smoke_required_context_17126_test.py -q
14 passed in 0.64s

The pre-push hook additionally ran every guard whose declared globs match the changed workflow —
21 repo_tests files — and reported [pre-push OK] pytest: all relevant tests pass.

Mutation proof, each fix reverted in turn, with the mutation confirmed applied (git diff --stat)
before the verdict was read:

mutation result
shim if: back to the pre-#17126 needs.changes.outputs.docker != 'true' RED — test_the_shim_runs_even_when_its_detector_failed, ..._runs_on_the_detector_failure_path, ..._refuses_rather_than_reporting_green
the refusal moved after the success echo RED — the order test, by name
real build's if: back to skipping on detector failure RED — test_the_real_build_runs_when_its_own_detection_failed
all three reverted 14 passed

The order assertion is the one I would not have thought to write before today: an exit 1 anywhere
in the script satisfies a naive check, and an exit 1 after the success echo reports the required
context green and then exits non-zero.

Stated boundary, in the test's own docstring: this reads workflow YAML. It cannot prove GitHub's
required-context resolution and does not try — the platform behaviour is the premise, not the
assertion. What it holds is that neither job can reach a state where a skip is the only thing
reported. ci-gate's MUST_RUN list independently refuses an all-skipped smoke-test at merge
time, and that belt stays: a guard on the workflow text and a guard at merge time fail for different
reasons.

Process note, since it cost real work: after the mutation proof I reverted with
git checkout -- .github/workflows/, which discarded my own uncommitted fixes along with the
mutations. Re-applied and re-verified before committing. The rule that would have prevented it is to
commit the fix before mutating it — reverting a mutation from git only works when what is
underneath it is committed.

AC2 has been amended on the issue, so Closes is honest — and I read the amended text before
switching back to it rather than taking the amendment on report.
The old criterion asked for
"neither job can produce a pass" on a detector failure; the amended one asks the shim to refuse and
the real build to run, "because a detector that cannot answer is not evidence that nothing
changed."
That is what this PR implements. AC1 and AC2 are ticked on the issue; AC3 is deliberately
left unticked there until this merges, since a criterion met on a branch is not met in the tree.

The residual, which is NOT in AC2's scope and is filed rather than hidden: the amended criterion
covers detector failure. Two detectors that both succeed and disagree produce either no
publisher (the context is never created — #17126's hole by another door) or two (a shim green with no
build behind it). The four-case table is now in both workflows' comments, and the walk-through with
proposed acceptance criteria is on #17391, the single-publisher problem. It is not closable inside
either condition: each is individually correct for the value its own detector computed.

I corrected my own comment twice getting to that. It first claimed the three cases were mutually
exclusive ("exactly one check run ever carries a conclusion" — false), then claimed the shim's green
"can never admit a PR no build covered" — also false, row 4. Both were overstatements in the one
place a reader would trust them, on a PR about a gate that reported green while checking nothing.

Closes #17126, not Closes — and the reason is this PR's own subject.**

AC2 reads, verbatim on the issue: "When path detection fails, neither job can produce a pass."
This implementation deliberately does the opposite — when detection fails the real build RUNS, and a
green then means a smoke test actually ran, which is the stronger guarantee. Two other sessions
independently reached the same conclusion: the code is right and the criterion should be amended.

The amendment has not happened, so Closes would tick a criterion the implementation contradicts —
on an issue about a gate that reported green while checking nothing. The proposed amendment is on the
issue with its reasoning; the owner's criterion stays theirs to change.

Refs #17126

Model Used

Claude Opus 5 (1M context)

…17126)

Owner rule, 2026-09-19: nothing lands on main without passing the Docker smoke
test. `smoke-test` is a required status check published by two mutually
exclusive jobs -- the real build in docker-smoke-test.yml and the path-filtered
shim in docker-smoke-required-context.yml.

Both jobs `needs: changes`, so a `changes` job that failed or was cancelled
SKIPPED both of them. GitHub counts a skipped check run as satisfying a required
status check, so the requirement was met with no smoke test run at all.

Both files carried a comment asserting the opposite: "if `changes` errors,
`needs` skips this job rather than reporting green ... which blocks the pull
request". Correct about the skip, wrong about what a skip means, and written in
the one place that cannot be falsified. Same shape as #17354's `global` channel
comment and #17384's floor prose -- a claim parked where nothing can test it.

The shim now runs on `always()` and REFUSES when its own detector did not
answer, before reporting anything. The real build now also runs when its own
`changes` did not succeed: two `changes` jobs exist, one per workflow, and the
shim can only speak for its own. When nothing can say whether an image could
have changed, the honest answer is to build it -- a full run on a rare
infrastructure failure is the correct direction for a gate nothing is allowed
to land without.

The three cases stay mutually exclusive, so exactly one check run named
`smoke-test` ever carries a conclusion:

  changes ok, docker != true   shim succeeds (legitimate green)
  changes ok, docker == true   shim skips, real build reports
  changes NOT ok               shim fails; the real workflow builds

`repo_tests/docker_smoke_required_context_17126_test.py` moves the claim
somewhere it can fail: 14 tests over both workflows' `if:` expressions, the
shim's refusal ORDER (an `exit 1` after the success echo would report green
first), and the env-var seam. With positive controls, because every assertion is
a substring check against a live file and those pass for two different reasons.

Mutation proof, each fix reverted in turn with the mutation confirmed applied:

  shim `if:` back to the pre-#17126 form   -> 3 tests red by name
  refusal moved after the success echo     -> the order test red
  real build's `if:` back                  -> 1 test red by name
  all reverted                             -> 14 passed

Closes #17126
@coderabbitai

coderabbitai Bot commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

Next included review available in 45 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Repository: mrveiss/AutoBot-AI/.coderabbit.yaml

Review profile: ASSERTIVE

Plan: Advanced

Run ID: 52e504b8-a9a3-4b81-9622-973d4161ae0f

📥 Commits

Reviewing files that changed from the base of the PR and between 6f5d119 and 4a1e111.

📒 Files selected for processing (3)
  • .github/workflows/docker-smoke-required-context.yml
  • .github/workflows/docker-smoke-test.yml
  • repo_tests/docker_smoke_required_context_17126_test.py

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown
Contributor

✅ SSOT Configuration Compliance: Passing

🎉 No new hardcoded values of either class — ssot and other both block.

Known backlog in pipeline-scripts/hardcoded_values_baseline.txt is suppressed and tracked in #14371.

@mrveiss

mrveiss commented Sep 24, 2026

Copy link
Copy Markdown
Owner Author

Review pass (requested by coordination, not the author)

Verdict: FIX-FIRST — nothing broken, but one decision needs an explicit
answer before Closes #17126 is accurate, and two cheap gaps are worth closing
while the file is open.

Verified clean, by re-deriving rather than trusting the PR's own claims: the
refusal ordering is mutually exclusive if/fi and not a report-then-exit race;
the real build is gated on its own changes job rather than the shim's;
no new "required context never reports" path on any trigger including
merge_group; and each of the three mutations the PR's table claims to catch
does redden the named test. Neither workflow carries an on:-level paths:
filter, so the #17386 trigger-domain class does not apply here.


1. AC2 as written is not what is implemented, and I think the code is right

#17126 AC2 says, verbatim:

When path detection fails, neither job can produce a pass.

On needs.changes.result != 'success':

behaviour
shim (docker-smoke-required-context.yml:83-87) runs, refuses
real build (docker-smoke-test.yml:74-79) runs, and passes if the build succeeds

So a pass is producible on detector failure. The workflow comment argues the
case well — "when nothing can say whether an image could have changed, the
honest answer is to build it"
— and I agree that is the better rule than the
one the issue wrote down. Building on an unknown is the safe direction for a
gate the owner rule says nothing lands without.

But the issue's checkbox still says the opposite, and nothing records the
substitution.
Merging with Closes #17126 would tick an AC the code does not
implement. Two honest ways out, and this is the author's and the owner's call,
not mine:

  • (a) amend AC2 to the implemented rule and say why, then Closes is accurate;
  • (b) keep AC2 literal, make the real build skip on detector failure, and
    accept that a Docker change can then land unverified when the detector breaks.

I would take (a). Flagging rather than deciding.

2. The AC2 deviation and the two-publisher case are the same scenario

These were two separate findings in my pass and they are one. On detector
failure the shim reports failure and the real build may report success —
the same smoke-test context, two runs, conflicting conclusions, one SHA.

I could not verify how GitHub's branch-protection resolution treats that. It
matters in both directions: if the later conclusion wins, a real build finishing
after the shim turns a deliberate refusal green; if the failure sticks, the
"build it when unsure" design can never actually satisfy the gate it was written
for. ~/.claude/scripts/ci-gate picks the newest non-skipped conclusion, but
that is a local convenience tool, not the merge gate.

Stated as unverified rather than guessed. Worth either a one-line note in the
workflow recording which resolution the design assumes, or a follow-up issue —
whichever, the assumption should be written down, because option (a) above makes
this path reachable by design rather than by accident.

3. No test stops continue-on-error: true reopening the hole

The 14 tests pin the if: expressions, the refusal, and its ordering. None
assert the absence of continue-on-error: true (or an unconditional exit 0)
on the shim's step — either would let the job report SUCCESS with the refusal
logic fully intact and untouched, which is a vacuous pass through a different
door than the one this PR closes. One assertion closes it.

4. Dead variable

DOCKER_CHANGED is declared at docker-smoke-required-context.yml:48 and never
referenced in the script body. Cosmetic, but in a file whose whole subject is
"the gate reads the signal it claims to read", an unread signal is the wrong
thing to leave lying around.


Pre-existing and explicitly not this PR's problem, recorded so it is not
mistaken for new: if both detectors succeed but disagree on the boolean, both
jobs skip and skip reads as pass. Identical before and after this diff, already
noted in .github/filters/docker-paths.yml:16-22.

Also noted: docker-paths.yml itself has no automated coverage guard. I
hand-enumerated the COPY sources of the three Dockerfiles and the three
compose build contexts against its globs and found them all covered — but that
is a manual check with a shelf life, and the general case is #17386 AC3.

@mrveiss

mrveiss commented Sep 24, 2026

Copy link
Copy Markdown
Owner Author

Update to finding 2 — it is no longer "unverified"

I said I could not verify how branch protection resolves two conclusions on one
context, and would not guess. There is an answer, and it changes the finding
from a platform unknown into a repo invariant that is unguarded for this
context
. Credit to @autobot-ai-f8 for chasing it; I verified it independently
below.

The repo's position is that the conflict cannot arise.
.github/filters/python-paths.yml states it outright — the CI job and its shim
"between them publish exactly one conclusion for the python-suite status
context... The two if: conditions are exact complements."

So the question "what does GitHub do with two conclusions?" is one this repo has
decided never to ask. That is a sound position. It just has to be true.

It is enforced for one of five. repo_tests/required_context_complements_test.py
opens with "Guards for every required-context complement split" and is then
hardcoded to a single pair:

CI               = .github/workflows/ci.yml
SHIM             = .github/workflows/python-required-context.yml
REQUIRED_CONTEXT = "python-suite"

test_the_two_conditions_are_exact_complements tests that pair and no other.
The broader REQUIRED_CONTEXTS frozenset further down feeds a different test
(about pull_request.paths), not complementarity.

There are five shims — backend, code-quality, docker-smoke, frontend,
python. Four required contexts carry the double-publisher structure with
nothing asserting the two sides are complements.
smoke-test is one of them.

Why that matters specifically here

It means the scenario in my finding 2 is reachable not because GitHub's
behaviour is unknowable, but because the invariant that would make it
impossible is not checked for this context
. And this PR's AC2 decision
determines whether that is an accident or a design:

  • take AC2 literally → keep the sides complementary → the invariant holds, and
    the missing guard is latent rather than live;
  • take the implemented reading (build when the detector fails, which I agree is
    the better rule) → the two sides are deliberately not complements on that
    path → the conflicting-conclusion case becomes reachable by design.

I still recommend the implemented reading — it verifies where AC2-as-written
merely blocks. But it should then say so where the next reader will be: a
comment in the workflow naming what the pair assumes about conclusion
resolution, precisely because it is stepping outside the invariant the rest of
the repo relies on.

A live instance already happened today

On PR #17379, one SHA carried two entries for code-quality — SUCCESS from the
real job at 07:17:07 and SKIPPED from the shim at 07:17:50. It was benign only
because one side was SKIPPED rather than FAILURE. It was read at the time as a
tooling artefact in a jq dedupe, which it also was; nobody asked why two
workflows had both concluded.

The general gap is filed as #17391. Nothing here blocks this PR beyond the
AC2 decision already raised — but the workflow comment is worth adding in the
same change, since this PR is what makes the path reachable.

…17126)

A pre-merge review found the comment asserting something the code does not do,
in the one place a future reader would trust it -- on a PR whose entire subject
is a gate that reported green while checking nothing.

The claim was "the three cases are mutually exclusive, so exactly one check run
named `smoke-test` ever carries a conclusion". The first two are exclusive. The
third is not a case: there are TWO `changes` jobs, one per workflow, and each
job can only see its own. When both detectors fail, the shim reports failure AND
the real build runs and reports whatever the build does -- two check runs, one
context, possibly opposite, resolved by whichever finished last.

The narrower invariant is the one #17126 needs and it does hold: THE SHIM
CANNOT REPORT GREEN UNLESS ITS OWN DETECTOR SAID NO DOCKER PATHS CHANGED. So a
disagreement between the two detectors can block a PR whose image build passed
-- conservative, self-resolving on a re-run -- and can never admit one no build
covered. Both comments now say that, and two tests assert it: the refusal
precedes any success message, and it is conditioned on the shim's own result.

The complement assumption the two detectors rest on is #17391's subject, named
rather than left implicit.

Refs #17126
…read the file (#17126)

A pre-merge review found two weaknesses in this PR's own guard, both of the
class the guard exists to catch.

THE CONNECTIVE WAS NEVER ASSERTED. Every check was `"clause" in condition`, so
swapping `||` for `&&` between the disjuncts would make each job's `if` false in
exactly the case it must run -- reopening #17126 -- while every assertion kept
passing, because each token is still present verbatim. The condition is now
pinned WHOLE for both workflows.

Mutation proof, applied to the real file and confirmed by `git diff --stat`
before the verdict was read:

    `||` -> `&&` in the shim's if:
        FAILED test_the_condition_is_pinned_whole_not_clause_by_clause[...]
        1 failed, 18 passed        <- the substring tests all still passed
    reverted: 19 passed

That the older assertions stayed green under the mutation is the finding, not a
side note.

THE always() CONTROL COULD NOT FAIL. It asserted against two string literals
defined in the test file and never read the workflow, so it reported coverage of
the exact bug class #17126 fixed while exercising nothing. It now also checks
the live condition.

Also documented: the `always()` widening reaches every trigger, so a `changes`
failure on `merge_group` now runs the full build where it previously skipped --
a real cost at the last gate before main, and still the right direction. The
shim's trigger is pull_request only, so nothing double-reports there.

Refs #17126
…oes not carry (#17126)

I corrected this comment once already -- it claimed the three cases were
mutually exclusive, which is false -- and the correction then claimed the shim's
green 'can never admit one where no build ran', which is ALSO false. Both were
overstatements in the one place a reader would trust them, on a PR about a gate
that reported green while checking nothing.

Derived from the two live conditions, on a pull request with both detectors
succeeding (S = shim's detector, R = the real build's):

    S.docker  R.docker   publishers
    true      true       real only          correct
    false     false      shim only          correct
    true      false      NEITHER            no context is created at all
    false     true       BOTH               shim green + the build's result

Row 3 is #17126's hole by another door: a required check satisfies the gate by
never existing, with nothing failed and nothing cancelled. Row 4 is worse than
conservative -- the shim's green does not mean a build ran, and completion order
decides the context.

Neither row is a defect in either condition; each is correct for the value its
own detector computed. One question, two detectors, an assumed agreement. Both
files now carry the table, and the walk-through with proposed criteria is on
#17391 -- the single-publisher problem, not closable in either condition alone.

Refs #17126, #17391
…ard (#17126)

Two review findings on #17388, both fixed here.

`test_swapping_the_connective_would_be_caught` compared two of this module's
own literals -- `_EXPECTED_CONDITIONS[_SHIM].replace("||", "&&")` against
`_EXPECTED_CONDITIONS[_SHIM]` -- which is a tautology the moment the pin
contains an `||`, and it never opened a workflow file. It also sat ABOVE the
positive-controls banner, so its name and its position both read as a live
guard while it was asserting nothing about the live condition.

Renamed to say what it does, moved down with the other controls, and pointed
at `_condition(_job(_SHIM))`. Reading the live file is what makes it
non-vacuous: if the shim's condition ever stopped joining its disjuncts with
`||`, the swap becomes a no-op and the inequality fails. That is the property
being claimed -- that the whole-condition pin can see a connective where a
substring check cannot.

This is the same class as the finding the previous round fixed in
`test_the_always_check_rejects_the_shape_that_caused_17126`: a control that
never reads its subject reads as coverage and is not. Finding it twice in one
file says the first fix was applied to the instance rather than to the pattern.

`DOCKER_CHANGED` was declared in the step's `env:` and never read -- wired into
both log paths instead of dropped, so a refusal now names the filter's answer
alongside the detector's result, which is what a reader debugging a red shim
needs and had to go to the job summary for.

Closes #17126
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.

ci(docker-smoke): smoke-test passes with no Docker run when path detection fails — skipped satisfies the required check

1 participant