(motir-core) The release lane's digest-table commit is SKIPPED by an unawaited `$GITHUB_OUTPUT` write — `cli-v0.4.0` was the first release to exercise MOTIR-2699's automation and it silently recorded nothing
Type · code · Repo · motir-core · Found while running MOTIR-3850, cutting cli-v0.4.0 — whose criterion 3 this defect blocks.
The observation
Release sandbox images run 33314220333 is green. Its Record the published digests in the sandbox README job (99266773289) is green. Its log says the work happened:
all 9 published tags resolved anonymously, each matching its immutable twin
every digest differs from its cli-v0.3.0 row (9 of 9)
every digest matches what the push jobs recorded (9 of 9)
packages/cli/sandbox/README.md: inserted the cli-v0.4.0 section, demoting cli-v0.3.0 to history
And nothing was committed. origin/main carries no digest commit, and git show origin/main:packages/cli/sandbox/README.md | grep -c 'cli-v0.4.0' → 0; its newest section is still ### Release cli-v0.3.0.
The job's step list says why: step 5, Commit it on main — skipped.
Root cause — an unawaited write racing process.exit
.github/workflows/release-sandbox.yml:187 gates the commit on steps.render.outputs.changed == 'true'. The render script sets that output like this (packages/cli/sandbox/smoke/render-digest-table.mjs):
const { readdir, readFile, writeFile, appendFile } = await import('node:fs/promises'); // :674
…
setOutput: (name, value) => {
if (process.env.GITHUB_OUTPUT) {
void appendFile(process.env.GITHUB_OUTPUT, `${name}=${value}\n`, 'utf8'); // :689
}
},
…
main(process.argv.slice(2), io).then((code) => process.exit(code), …); // :694
appendFile is the promise-based one, it is invoked with void — deliberately unawaited — and the very next thing that happens is process.exit(0). process.exit does not flush pending asynchronous I/O, so whether changed=true reaches $GITHUB_OUTPUT is a race the script usually loses. The gate then reads an empty string, and the commit step is skipped with no error anywhere.
Every other signal is green because every other signal is true: the digests WERE resolved, the invariants WERE asserted, the file WAS written — in the runner's working copy, which is then discarded.
Why it has never been caught
cli-v0.4.0 is the FIRST release to exercise this path. MOTIR-2699 automated the table in cb3b2552b; the previous release's rows were written by hand in 35a393183 — "record the cli-v0.3.0 digests — the release lane's manual step 4" — before that automation existed. So the lane has been merged, green and never actually asked to commit until today.
Fix direction
awaitthe output write — makesetOutputasync and await it, or use the synchronousappendFileSync, which is what a fire-and-forget-then-exit script wants. The synchronous call is the smaller change and cannot race by construction.- Do not let
process.exittruncate I/O: prefer settingprocess.exitCodeand returning, so Node drains before exiting. - A unit test that would have caught it: run the script's
mainwith aGITHUB_OUTPUTpointed at a temp file, exercised through the REAL entry point (theprocess.exitpath), and assert the file containschanged=true.packages/cli/test/sandboxDigestTable.test.tsalready covers the rendering and asserted every invariant in that log — it testsmain's return value and never the output side effect, which is exactly the seam that broke. - The lane owes a louder failure than a skip. A
Commit it on mainthat silently does not run leaves a green lane and an unrecorded release. Consider asserting after the render that the README on disk differs from HEAD, and failing when the gate says otherwise.
Acceptance criteria
render-digest-table.mjswriteschangedto$GITHUB_OUTPUTby a means that cannot race process exit, and the change is exercised by a test that runs the script's real entry point and reads the file back.- The negative direction is exercised: a test that fails against the current unawaited implementation, quoted in the pull request. A fix for a race that ships without a reproduction is indistinguishable from a fix that happened to win the race.
cli-v0.4.0's nine digests reachpackages/cli/sandbox/README.mdonmain— either by a repair run of the lane, or by the same commit the fix lands in, with the digests read anonymously from GHCR rather than retyped from this card.- The lane fails, rather than passing, when the table was rendered and not committed.
Context refs
.github/workflows/release-sandbox.yml:186-200— theCommit it on mainstep and itsif:gate.packages/cli/sandbox/smoke/render-digest-table.mjs:674,689,694— the import, the unawaited append, theprocess.exit.packages/cli/test/sandboxDigestTable.test.ts— the existing coverage, and the seam it does not reach.- Run 33314220333, job
99266773289— green, with step 5 skipped. - MOTIR-2699 — the card that built this. MOTIR-3850 — the release this blocks.