← Lab notebook

Every merge is a release

On 25 October, a change to our browser driving simulator, Seat Time, never reached production—the live game. We build it with artificial intelligence (AI) coding agents. Merging changes into main, our shared release branch, was supposed to release them. Instead, the previous build—the compiled game—stayed live. The incident record put it plainly: “Nothing told anyone; the build was just red.”

By then, Seat Time had lap records, ghosts and competition rounds. We were changing the physics every few days, so knowing which build was live mattered. We wanted a merge to finish the job without another round of feature switches. That October afternoon showed how much could still happen between merging and reaching the browser.

Previously: It outgrew its name.

We pulled out the release flags

On 18 October, we removed 23 release flags. These were switches that could leave merged features waiting for separate activation. Our rule became simple: a merge to main is the release. Money-related features kept their separate switches and stayed off by default.

That decision also made generated values part of the release. The build embeds an integrity digest: a fingerprint of the code used by the part-time runner. This server component replays recorded drives from the game’s part-time activities. The runner refuses to start if that fingerprint differs from the accepted one. Merging code therefore means checking that its generated companions agree with it.

Every lap got a physics version

We had already started separating laps by physics version on 12 October, beginning with version 2. Laps, ghosts and leaderboard entries received that stamp. Older entries remained available in their own group. They no longer competed against laps made under different physics.

Our rule was to bump the version whenever a simulation change moved default lap times. Otherwise, a faster lap could reflect a changed car rather than a better drive. The version stamp gave us a way to preserve old results without pretending the conditions were unchanged. It also made every physics release responsible for more than the newest code.

We needed a page that answered “is it live?”

We had the agents add /version to report the identity of the running build. If that identity was unavailable, it returned an unavailable response rather than inventing a fallback. A successful merge could no longer stand in for evidence about production. We could ask the running site itself.

We also added a smoke journey: a short automated trip through the game’s essential controls. An earlier change set had compiled while breaking three buttons. Settings would not open, the leaderboard stayed on “Loading…”, and Race ghost opened Settings. Patch fragments had landed in the wrong methods. That is why our release check presses controls instead of stopping at compilation.

An older build replaced a newer one

Builds could finish in the wrong order, and we had seen an older build replace a newer one. Having multiple routes to production made that harder to track. We needed the publisher to compare code order, not assume the last upload was newest. The eventual guard checks before promotion—making the candidate live—and again afterwards.

Our production smoke had its own misleading failure. The earlier check had never passed because the automated renderer advanced the simulation much slower than real time. We changed that check to judge simulation progress instead of elapsed wall time. It then passed twice against production; later waits also watched meaningful game progress rather than frame counts alone.

One publisher, with checks after promotion

We reduced publication to one Cloudflare Workers Builds publisher, building main. It stages a candidate without giving it traffic, then checks startup processor time. Our chosen ceiling is 400 ms. A slower candidate, or a missing measurement, stops before database changes.

The remaining sequence is deliberate: check ordering, migrate the database, confirm readiness, then promote the candidate. After promotion, the publisher checks live identity, web responses and browser journeys. Failure in those checks triggers rollback, restoring the previous build while that candidate is still live. If a newer build has taken over, the older publisher stands down instead of undoing it.

The release stages lead through a 400 ms startup CPU gate, migration, promotion and smoke checks; failures roll back and older builds stand down.
Merged, staged and live became separate facts, with checks between them.

Promotion still comes before the browser journey, so this is recovery machinery as well as a release gate. We do not treat “uploaded” as “accepted.” The build must reach the running game and exercise it. A release can be refused, rolled back or superseded without its code being lost from the next successful build.

The merge that never went live

On 25 October, the repository clone consumed about 8.5 minutes instead of the previous roughly 3 minutes. Staging finished at about 12.5 minutes. The publisher reported 392 seconds remaining against its deadline. Its admission rule required a 10-minute reserve, so it refused to start publication.

Our build had a 20-minute Workers Builds timeout. The publisher uses an earlier, 19-minute internal deadline to leave room before that cutoff. The recorded phase times are approximate; the reported 392 seconds belongs to that internal deadline. It is not the remainder obtained by subtracting rounded staging time from 20 minutes.

On 25 October, cloning took about 8.5 minutes and staging finished around 12.5 minutes. The logged 392 seconds remaining fell short of the ten-minute reserve.
The safety reserve stopped publication; we later reduced it to six minutes using measured journey times.

The refusal protected the release checks from being cut short, but it also left the previous build live. The failure appeared in the build result without bringing the missed release to our attention. When the new live check measured the gap, production was 30 minutes behind main. Our release rule had become an intention that needed separate evidence.

We made the gap visible, but recovery still had a limit

We reduced the admission reserve to 6 minutes using observed phase times. The guest journeys, including setup, had taken 1.9–3.1 minutes. We kept the browser check mandatory, rather than accepting a release when time ran out. A slow promotion could still force a rollback.

The agents added one retry attempt for a build started by a push, which sends changes to the repository. It must not retry itself repeatedly, duplicate an existing build, or retry while newer code is staged. There was a practical catch on 28 October: the automatic retry was rejected for insufficient permission. The recorded recovery used the dashboard; the retry design was not proof of unattended recovery.

The new check-live command compares main with the running build and warns after more than 25 minutes behind. That is our chosen alert threshold, not a measured guarantee of delivery. As checked on 6 November, we found instructions to schedule it but no committed schedule. It still needs a confirmed schedule before we can call it automatic monitoring.

We now report merged, staged and live separately. Local checks must finish before a merge, and merges should not be batched without checking publication. “Is it live?” has a direct answer from the running build. It no longer gets answered with “the change was merged.”

A week later, the release reached into the test harness

In the following week, through 2 November, physics versions advanced from v8 to v17. Each bump brought a trail of fingerprints, reference drives, medal targets and generated records to refresh. Some releases waited for competition periods to finish. On 1 November, we chose a rolling release: keep shipping changes as they became ready.

Those changes could move recorded results. With v16, the automated smoke lap changed from 133.425 to 132.469 seconds. Jump Line’s recorded score went from 621, silver on v15, to 594, bronze. No medal target moved with v17. Neither release record contained a pre-merge G923 or DualSense drive. Those checks cannot tell us how either build felt on hardware.

The test harness—the tools that run our tests—also needed repair. We found that local continuous-integration checks had omitted 575 of 710 Seat Time test files. These checks test changes as they are brought together. The files were absent from that local runner, not necessarily never run. We added a catch-all group and a guard requiring each test file to belong to exactly one group.

Then a space in our project folder name broke browser mocks—test stand-ins—in Vitest 4.1.10. The upstream fix for directories with spaces explains the mismatch: encoded paths kept %20 where the lookup needed a space. We patched our pinned dependency, the tool version we had fixed in place. Checked on 6 November, the fix was merged upstream. But the v4.1.12 stack parser, which reads error traces, still lacked that address-decoding change.

Generated values caused another round of release trouble when they were rebuilt against the wrong code state. Our resulting rule is to regenerate them on the final merged code. A release also gets one name matching the physics version it actually ships. Both rules help keep the code, its evidence and the name we report together.

Next, the numbers inside the build

We can now distinguish a merge from a live release, but we still need that monitoring schedule confirmed. The retry permission gap also prevents us from claiming automatic recovery is complete. Our interpretation of this stretch is simple: each safeguard has an incident behind it. If every lap needs a physics version, every number inside that physics needs a source.

Drive Seat Time.

Next episode: Every number gets a source.

Notes and sources

  • Sourced fact — our release records: 23 flags removed on 18 October; money switches remained default-off. Build fingerprints, /version, the three broken controls, older-build replacement and the single publisher describe our implementation. The simulation-time smoke passed twice; these are recorded checks, not a reliability rate.
  • Chosen design value — our release rules: physics stamping began at version 2 on 12 October. Older laps, ghosts and boards stay separate. The startup ceiling is 400 ms; the internal deadline is 19 minutes. The reserve changed from 10 to 6 minutes, with one conditional retry and an alert after more than 25 minutes.
  • Measured — 25 October incident: clone approximately 3 → 8.5 minutes; staging approximately 12.5 minutes; 392 seconds reported remaining; production 30 minutes behind when checked. Guest journeys took 1.9–3.1 minutes. These historical measurements are not fresh benchmarks.
  • Sourced fact — platform limit: Cloudflare, Workers Builds limits and pricing, lists a 20-minute build timeout. Read on 6 November 2026. Other thresholds above are ours.
  • Sourced fact — remaining operational limits: our 28 October record says the retry lacked permission and recovery used the dashboard. Our 6 November review found no committed check-live schedule. That does not establish what might run outside the repository.
  • Sourced fact / chosen design value — look-ahead: our release records cover v8–v17 and competition holds. They record the 1 November rolling-release decision, one release name and regeneration on final merged code.
  • Measured / not yet driven on hardware: the v16 smoke lap changed from 133.425 to 132.469 seconds. Jump Line’s recorded score went from 621 to 594, silver to bronze. v17 moved no medal target. Those release records contain no pre-merge G923 or DualSense drive.
  • Measured / sourced fact — harness: local checks omitted 575 of 710 specs. The recorded browser-mock failure used Vitest 4.1.10. Vitest’s directory-space fix merged on 9 October 2026. On 6 November, we read the v4.1.11 notes, v4.1.12 notes and v4.1.12 parser source. That parser still lacked the decoding fix; we do not claim our local patch can be removed.
  • Our interpretation: these failures made release identity and evidence part of shipping. AI coding agents helped implement the changes; we made release decisions. This article used AI assistance for drafting and translation. Product and tool names are descriptive, not endorsements.

Keep reading