ci(nightly-next): build the tip at once and install its tree's attested client when the tree publishes
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 landing | ends at |
|---|---|
the four build / build <target> legs | 13-15 min |
| publish to R2 (commit-addressed, attested manifest) | 21-22 min |
| publish tree-addressed cmux-tui | 26 min |
| tree-commit marker | 27-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-keyexit 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.
- If its tree is a clean 404 (
- 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 thatverify-keynames, 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.
- "Bundle acpmux" gets a stand-in input file. The phase reads only
- await-nightly-tree-marker (new job, Linux, read-only).
publish-nightlyneeds it whenever the build wastree_pending. The build commit's tree-commit marker must exist, name the build's key, and name the installed client's commit aspublishedBy. This is G5's check, moved into the run.prepare-nightly-variantchecks 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; theninstall-cmux-tui-client.sh,strip-release-bundle.sh,write-cmux-tui-version.shand 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 fortree/<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.app | A and B |
|---|---|
| Contents/Resources/bin/cmux | sha256 6cd30a2d…eb10, universal (x86_64 arm64), ad hoc, Identifier=cmux |
| bin/cmux-app-host, cmux-browser-host, cmux-cloud, cmux-holder, cmux-tui-host | same sha256 in both, universal, ad hoc |
| bin/acpmux, bin/cmux-tui | symlinks to cmux in both |
| bin/acpmux.version | 8f7cf0e0…9bdd in both (the stand-in input changes nothing) |
| bin/cmux-tui.version, daemon-capabilities.json, cmux-tui-ssh/manifest.json | same 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'sifand 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.pyand 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-markerpassed (OWN_TREE). A recovered build can no longer be selected while an unmarked own build is skipped. The job'sifis unchanged. await-nightly-tree-markeralso runs for an unsigned resume, against the commit the resumed app installed.- Recovery refuses a run whose
await-nightly-tree-markerfailed, 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.shreads 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
| metric | before | after | delta | harness or run link |
|---|---|---|---|---|
| tree-changing landing to its nightly run created | 27-28 min (marker) + window | about 1-5 min (tick + window) | about -23 min | cmux-tui artifacts runs 40913738554, 40918933039, 40921878495 (jobs API) |
| tree-changing landing to the client install starting | 27-28 min + about 10 min | about 19-21 min (manifest upload) | about -17 min | same runs; build-nightly-app median 10.2 min on the dedicated builder (13 runs, 10-29) |
| tree-changing landing to publish | 52-58 min | about 38-40 min, to be measured on the first landings after merge | about -15 min | same 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