ci(nightly-next): the feed keeps two builds, so a rollback always has one to serve
A NEXT nightly shipped a launch crash. Part of the follow-up: the feed keeps the last 2 good builds, so a rollback always has a build to serve. Whatever prunes feed entries or artifacts keeps at least the 2 newest builds that passed the gate.
Before
With the Dev ring off, each publication replaced the cmux NEXT feed with the new build alone. The live feed holds one item right now (appcast-arm64.xml: build 4062224162001 only). nightly-next-rollback.yml refuses to pull that build because "pulling ... would leave the feed empty", so the documented rollback could not run when it was needed. The GitHub release asset prune's cap rule could also delete builds down to the newest one.
After
publish-nightly-next-r2.py write-feed(the feed write inpublish-nightlyand in the delta republish) keeps the newest earlier build that Nightly apps are offered (untagged orcmux-next), counted over the feed it writes and the live feed, and adds the live feed's item for it when the feed lacks it. A feed combined from an older read still takes a build that landed meanwhile. A Dev-tagged item is never the kept one, since a Nightly app rejects it. Items are copied byte for byte (signature, deltas, ring tag). Every build on the live feed passed the publish gate, and a pulled build is no longer on it, so the kept build is a good one. The merge runs inside the existing compare-and-swap loop, so a lost race merges from the feed it reads next.prune_nightly_release_assets.py: no rule deletes the 2 newest builds (ROLLBACK_FLOOR). The cap now refuses instead of deleting them, and--keep-buildsbelow 2 is refused. Every caller passes 100.- The R2 DMGs are write-once and nothing prunes them.
- Rollback is the existing single command, now documented with this guarantee in
docs/ci/nightly-next-canary.md:
gh workflow run nightly-next-rollback.yml -R manaflow-ai/cmux --ref nightly-next -f reason="..."
Tests
tests/test_publish_nightly_next_r2.pyKeepEarlierBuildsTests:- the earlier build is kept byte for byte;
- a feed that already has 2 builds is written unchanged;
- after a lost race, the merge uses the feed read last;
- a build that landed meanwhile is kept even when the feed already had 2 builds;
- a Dev-tagged build is never the kept one;
- with no live feed, only the new build is written;
- the delta republish keeps the earlier build too;
nightly_next_feed_rollback.py planon the written feed pulls the new build and serves the kept one.
tests/test_prune_nightly_release_assets.py: the cap never takes the 2 newest builds, and--keep-builds 1is refused.- These pass locally:
test_publish_nightly_next_r2,test_prune_nightly_release_assets,test_nightly_next_feed_rollback,test_nightly_next_ring,test_nightly_next_live_feed_check,test_nightly_next_guard,test_nightly_next_recovery,test_ci_nightly_prune_python_compat.sh.
Risk: the feed gets one more item. Sparkle offers the newest applicable item, so update behavior is unchanged. The Dev ring path (publish-next-feed-keep-nightly.sh) already writes more than one item, and this adds nothing there once the feed has 2 builds.
Agent session: agent session
š¤ Generated with Claude Code