ci(nightly-next): the feed keeps two builds, so a rollback always has one to serve

Merged
TE
10d ago
+190-13

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 in publish-nightly and in the delta republish) keeps the newest earlier build that Nightly apps are offered (untagged or cmux-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-builds below 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.py KeepEarlierBuildsTests:
    • 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 plan on 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 1 is 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