medlab / case

medlab

what got hurt, and what now fails loudly

↑ sublayer

2026-11-02 · fixed: yes

A commit's CI result is assumed to be reachable through a pull request

Human ComprehensionInvariantsContract TestsExample-Based Tests

now fails loudly `forge checks` answers for a pull request number, a revision, or the branch's head, and refuses an argument it cannot read as either rather than guessing.

What broke

Wortling's deployment is gated on continuous integration: before an image is built, someone asks whether the commit about to be deployed passed. The command-line tool that answers questions about the forge had one way in, a pull request number, and everything else was reached through it.

That is enough while a change is open. It stops working the moment the change merges: the merge commit is on the main branch and is no pull request's head at all, and the tool had no way to name it. Measured on the request this case is about: head acf9075a, the last commit on the branch, while the merge commit 46d2df84 is what main points at and nothing's head. The deploy had two options, both bad: skip the check, or push a no-op branch to get a pull request whose head was the commit it cared about.

The belief was that a commit's CI result belongs to a pull request. In fact the forge records runs against commits, and a pull request is only the most common way to name one.

How it showed

The workaround is recorded by the deploy gate's own author rather than in the repository's history: skip the check, or push a no-op branch to get one. Neither leaves a trace in the branch list, which is why the shape survived as long as it did.

Building the fix exposed three more ways the same tool answered about a different thing than it was asked:

  • a revision beginning with a digit was read as a pull request number, because

the parser accepted a decimal prefix: 7b5df16 became request 7, 86d43cd request 86, and a full sha beginning 3bf0adb request 3, each answered with exit 0 and a green summary for a commit nobody had asked about;

  • a pull request list that could not be read was treated as "no request is

open", so the tool answered for the branch head instead of failing;

  • the branchless fallback was unreachable in a detached checkout, which is the

deploy's own state. That one was caught by the pull request's first test run: checkout is detached reported as a usage error, reproduced on the branch by checking out the same commit detached and passing again with a branch checked out.

What was watching

The tool's tests covered the path it had: a pull request number, an open request for the branch, and the refusal for an argument that is neither. None of them asked about a commit that no request names, which is the case a deploy is in.

The forge's own behaviour was measured rather than assumed during the repair, and the three coupled defects above were found by writing the tests for the new path rather than by any monitor.

The detector was a person holding a deploy gate open with a workaround.

Where it sits

This is a comprehension failure over an invariants gap.

The family is comprehension because the tool carried a model of the forge in which a commit is reached through a request. It is invariants because the deploy's rule, "no image from a commit whose CI result is unknown", could not be stated with the tool's vocabulary.

Example-based tests were present for every path the tool had and absent for the path the deploy needed; contract tests would have separated "a commit is identifiable by its sha" from "a pull request names a commit", which is the confusion at the centre of this.

What we did

forge checks now takes a pull request number, a revision, or nothing meaning this branch's head, and when the branch is detached it answers for HEAD instead of refusing. A digit-leading revision takes the revision path, an unreadable list fails rather than answering for something else, and a forge read failure no longer shares an exit code with a bad argument.

What now fails loudly

Ten tests, and the ones that matter name the confusion directly: TestChecksTakesARevisionWithoutAskingTheForge, TestChecksAnswersForADetachedHead, TestChecksTakesADigitLeadingRevisionAsARevision, TestPrNumberTakesOnlyAWholeNumber, and TestChecksDoesNotMistakeAnUnreadableListForNoRequest.

TestChecksAnswersForADetachedHead is the one that failed first in continuous integration, which is the right place for it: a developer's checkout has a branch, so only the runner's detached checkout could show that the fallback was unreachable.