Encoding the release, part 3 of 8.
The release command's job description is a testing nightmare: branch and push, open a PR, watch CI until it concludes, merge on green, cut a GitHub Release, approve a deployment gate, poll npm until the new version appears. How do you test watch CI until green several hundred times a day without a CI run in sight?
Our answer is James Shore's Nullables
pattern — no mocks, no spies, anywhere in the suite. Every piece of infrastructure is a
thin wrapper with two factories: create() talks to the real thing, createNull()
answers from configured state and touches nothing. The crucial property is that real and
null share every line of code above the bottom layer. The null for our gh wrapper does
not stub out methods — it stubs the gh CLI itself, answering the same shapes the
real binary produces: a PR URL on create, JSON rows for checks. All the parsing,
mapping and error handling that runs in production runs in every unit test.
Two idioms carry most of the weight.
Output trackers, not spies. When a test needs to know what the tool did to the world, it asks the wrapper for a tracker and asserts on its data:
const merges = gh.trackMerges();
// ... run the policy ...
expect(merges.data).toEqual([154]);
That reads like a spy but is a different thing philosophically: it is the wrapper's own record of its outputs, part of its real interface, not a test framework's interception of its internals. The most important assertions in the suite are about what a tracker does not contain — part 4 hangs on an approvals tracker being empty.
Configured rounds, for time. A release is full of waiting: checks pending, then concluded; a publish run absent, then waiting; npm serving the old version, then the new. The nulls model time as a list of successive answers, where each call takes the next round and the last round repeats:
const gh = GhWrapper.createNull({
checkRounds: [
[{ name: 'build', concluded: false, passed: false }],
[{ name: 'build', concluded: true, passed: true }],
],
});
With that, "poll CI until it concludes, then merge" is an ordinary synchronous test that
walks a PR from pending to green in microseconds. The same idiom gave us
waitingRunRounds for the deployment gate and version rounds for the registry — the
npm null answers 0.4.0 during preflight and 0.5.0 after the publish, because that is
what the real registry does.
One boundary we drew deliberately: the null never pretends to be GitHub's behaviour, only its shapes. We refused to write an elaborate fake GitHub, because a fake you wrote yourself only ever proves you agree with yourself. The wrapper's comment states the doctrine: the real side is proven by a real release rather than a faked GitHub. That is a genuine trade — it means the first real release is a scheduled collision with reality. Part 7 is about the day that bill came due, and why we would sign the same deal again.
Next: The Gate Always Asks.


