ci(nightly-next): build the tip at once and install its tree's attested client when the tree publishes

Closed
TE
7d ago
+616-91

Leo: "it is unacceptable to have nightlies >30min out of date."

What is slow

When a landing changes the cmux-tui tree key, its NEXT nightly could not start until that tree was compiled, published and marked. Measured on today's feat-cmux-next pushes (cmux-tui artifacts runs 40913738554, 40918933039 and 40921878495):

step after the landingends at
the four build / build <target> legs13-15 min
publish to R2 (commit-addressed, attested manifest)21-22 min
publish tree-addressed cmux-tui26 min
tree-commit marker27-28 min

The nightly itself then takes 24-40 min (median about 30; nightly.yml runs 40868602625 to 40920248555). So these landings published 52-58 min after they landed. About a quarter of landings change the key.

The app does not need the tree to compile. In a Release build, "Bundle cmux-tui" is in pin mode, and "Bundle acpmux" only makes bin/acpmux an alias of bin/cmux. The tree's client is installed after xcodebuild, in "Bundle the cmux-tui client". On the dedicated builder that compile takes a median 2.1 min.

What changes

Main's promote guard already accepts such a commit when its nightly.yml carries NIGHTLY_NEXT_TREE_AT_INSTALL: v1 (#27498, merged). This PR adds that line and the nightly that honours it. The client is fetched by tree, never rebuilt: the same attested manifest, through the same install-cmux-tui-client.sh command.

  • resolve-nightly-cmux-tui-client. A new step, scripts/ci/nightly-steps/resolve-tree-pending.sh, handles the nightly-next tip:
    • If its tree is a clean 404 (verify-key exit 1), the tip is built as it is: tree_pending=true, plus its same-key first-parent candidates, oldest first.
    • A published tree, or a read failure, leaves the decision to the existing resolver, which is unchanged.
  • build-nightly-app compiles at once:
    • "Bundle acpmux" gets a stand-in input file. The phase reads only $CMUX_NEXT_ACPMUX_BIN.ref, which is absent in both paths.
    • "Bundle the cmux-tui client" first runs scripts/cmux-next/await-tree-client.sh. Once the tree is published, it takes the commit that verify-key names, exactly as today. Before that, it takes the first candidate whose commit-addressed manifest has been uploaded (the manifest is written last, after the attestation).
    • It fails fast after two checks with no active cmux-tui-artifacts run for the key, and otherwise after 45 min.
    • The step outputs the installed commit.
  • await-nightly-tree-marker (new job, Linux, read-only). publish-nightly needs it whenever the build was tree_pending. The build commit's tree-commit marker must exist, name the build's key, and name the installed client's commit as publishedBy. This is G5's check, moved into the run. prepare-nightly-variant checks the bundled client against that installed commit.
  • request-nightly-next.sh requests such a tip only when three things hold:
    • it is the feat-cmux-next tip;
    • its nightly.yml has the line;
    • a cmux-tui-artifacts run for its key is active.
  • Unchanged:
    • the non-pending path, which runs every published tree exactly as today;
    • the unsigned resume, fast and build-only paths;
    • recovery;
    • signing, notarizing and publishing, and their environments.
  • To keep nightly.yml under its size limit, two step bodies moved to scripts/ci/nightly-steps/ and some comments in the touched jobs got shorter.

Byte-identity proof

Both paths ran on one fleet Mac (an AWS M4 Pro, macOS 27.0.1, Xcode 26.6) for one commit, 58286343a8d1. Its tree b00b2f91b8e0 was published by c230bb58a50c, the oldest of 14 same-key candidates. Each path starts from the same skeleton app and runs the real scripts:

  • A (today): the Release "Bundle cmux-tui" and "Bundle acpmux" phases with the fetched companion; then resolve-newest-published; then install-cmux-tui-client.sh, strip-release-bundle.sh, write-cmux-tui-version.sh and the capability check.
  • B (this PR): the same phases with the stand-in input; then await-tree-client.sh, with the tree hidden by a curl wrapper that answers 404 for tree/<key>/, as before publication; then the same install.

Results (full log kept with the run; key lines):

A installs the client of c230bb58a50cf8b35ee7de9f1130bff840c8e459
B (tree hidden) installs the client of c230bb58a50cf8b35ee7de9f1130bff840c8e459
B (tree published) picks c230bb58a50cf8b35ee7de9f1130bff840c8e459
--- files: 19 in A, 19 in B
IDENTICAL: diff -r
IDENTICAL: paths, modes, sha256, link targets
IDENTICAL: codesign -dvvv and lipo -archs of every Mach-O
SAME COMMIT: c230bb58a50cf8b35ee7de9f1130bff840c8e459
path in cmux.appA and B
Contents/Resources/bin/cmuxsha256 6cd30a2d…eb10, universal (x86_64 arm64), ad hoc, Identifier=cmux
bin/cmux-app-host, cmux-browser-host, cmux-cloud, cmux-holder, cmux-tui-hostsame sha256 in both, universal, ad hoc
bin/acpmux, bin/cmux-tuisymlinks to cmux in both
bin/acpmux.version8f7cf0e0…9bdd in both (the stand-in input changes nothing)
bin/cmux-tui.version, daemon-capabilities.json, cmux-tui-ssh/manifest.jsonsame sha256 in both

The compiled app is not in this diff because xcodebuild is unchanged. Its only new input is the stand-in path above, which no phase copies. The fleet Mac has no gh, so both paths installed with --allow-unattested. CI's install still verifies the manifest's build-provenance attestation, and that code is unchanged.

Tests

  • scripts/cmux-next/tests/nightly-tree-at-install.test.sh (new, 14 cases, run in cmux-next.yml's script tests) covers:
    • await-tree-client: published tree; a manifest uploaded mid-wait; oldest-first candidates; fail fast with no publisher; the limit; bad usage;
    • the marker gate: match; another publisher; another tree; a late marker; no marker;
    • the pending step: pending; published; an unverifiable tree.
  • scripts/cmux-next/tests/request-nightly-next.test.sh, new cases for a building tip:
    • without the line, it is not requested;
    • with no active run, it waits and says why;
    • with both, it is requested;
    • a commit behind the tip never is.
  • tests/test_nightly_next_recovery.py: publish-nightly's if and needs.
  • .github/nightly-step-coverage.json: the three new steps.
  • Passing locally: tests/test_nightly_step_coverage.py, tests/test_workflow_file_size.py (nightly.yml 489,822 bytes, limit 490,000), tests/test_nightly_cmux_tui_resolve.py, tests/test_nightly_stop_if_publish_doomed.py and 63 other nightly/CI workflow tests. Six others fail the same way on base here, because of this host's sparse checkout or missing macOS tools: test_ci_nightly_push_throttle, test_nightly_next_arm64_only, test_nightly_next_guard, test_nightly_next_identity, test_nightly_next_live_feed_check, test_nightly_universal_build.sh (same failing line).

Review fixes (second head)

  • publish-nightly's "Select the build to publish" takes this run's own build only when it never waited for its tree, or when await-nightly-tree-marker passed (OWN_TREE). A recovered build can no longer be selected while an unmarked own build is skipped. The job's if is unchanged.
  • await-nightly-tree-marker also runs for an unsigned resume, against the commit the resumed app installed.
  • Recovery refuses a run whose await-nightly-tree-marker failed, was cancelled, has not finished or cannot be read (scripts/ci/nightly-steps/recover-tree-marker-ok.sh).
  • Before the newest-50 listing, await-tree-client.sh reads each candidate's own artifacts runs (pin-cmux-tui.sh runs-state), so a crowded listing never reads as "no publisher".
  • New tests:
    • a candidate's own active run counts as a publisher;
    • recovery's marker check for ok, skipped, none, failed, running and unreadable;
    • an unmarked own build is never the source, and a recovered one is chosen instead.

Performance

metricbeforeafterdeltaharness or run link
tree-changing landing to its nightly run created27-28 min (marker) + windowabout 1-5 min (tick + window)about -23 mincmux-tui artifacts runs 40913738554, 40918933039, 40921878495 (jobs API)
tree-changing landing to the client install starting27-28 min + about 10 minabout 19-21 min (manifest upload)about -17 minsame runs; build-nightly-app median 10.2 min on the dedicated builder (13 runs, 10-29)
tree-changing landing to publish52-58 minabout 38-40 min, to be measured on the first landings after mergeabout -15 minsame harness as #27179's table; filled in after merge

The remaining critical path is the manifest upload (about 20 min) plus the post-build chain (about 18 min); both have their own PRs.

Timers: await-tree-client.sh polls the CDN every 20 s for at most 45 min, and ends early when the client is ready or when two run checks find no publisher. await-tree-marker.sh polls every 20 s for at most 50 min, inside a 60-min job. Both are bounded and end with the step.

Risk:

  • If the tree build fails after promotion, its nightly fails at install and publishes nothing, and the feed keeps its last build.
  • If two same-key builds race and the other one wins the tree, the marker names a different publisher. That build publishes nothing, and the next build takes the published tree.

🤖 Generated with Claude Code

Agent session: agent session