ci(nightly): headroom under the size budget (14 more run blocks, step notes into scripts, expressions through env)
After #30912, nightly.yml had only 3,986 bytes left under the 490,000-byte cap of tests/test_workflow_file_size.py (ci-guards preflight). The next nightly.yml change would turn preflight red on every PR again. Lead decision (13:2xZ): this PR does three things, (a), (b) and (c) below. nightly.yml goes from 486,014 to 458,313 bytes, which leaves 31,687 bytes of room.
Nightly owners (#30284, #30249, #30471, #30278, #30113):
- 13 more steps now call
scripts/ci/nightly-steps/<name>.sh. - The notes that described a script-backed step now sit in that script's header, under "The step's notes, moved from nightly.yml".
- Seven steps now pass their
${{ }}values to the script throughenv:(table below). tests/nightly_workflow_text.pystill shows the workflow with every script inline.
(a) The remaining safe blocks (6)
These move word for word, by #30912's rules:
- no
${{ }}; - no
shell:and no working directory; - the script exists in the tree the step runs on;
- each
build_shastep is skipped by an unsigned resume.
Four of them do not start with set -euo pipefail: "Fetch the Chromium engine", "Upload dSYMs to Sentry", "Finish the dSYM upload to Sentry" and "Report a missing daemon attestation". They ran under GitHub's default bash -e {0}, so they are now called with bash -e scripts/..., and the expansion helper accepts that call form.
"Generate deltas and revised appcast" (generate-nightly-deltas) stays inline. The first review found that its .publish-tooling checkout is a non-cone sparse checkout that lists single files, so a moved script would not be there. The step would fail on every run, and continue-on-error would hide it. My own selection matched the directory name as a substring and missed this. That job is now byte-identical to the feat tip.
Every step script must exist in the checkout its step runs from. I checked this for all 140 step-script calls in nightly.yml: for each call, the last checkout before it that provides the path (root, or path: .publish-tooling) must contain the file, either with no sparse set, a cone-mode directory or a non-cone pattern. The head has no gap (the feat tip has none in 127 calls either). Before the fix, the same check reported exactly this one step.
(c) Blocks with ${{ }}: each expression is passed through env: (7 blocks, 11 expressions)
GitHub recommends this pattern against script injection. Each expression moves to a new env: entry of the same step, and the script reads that variable in the same place.
| Job :: step | Expression | Env name | Script | Reads | Writes | Other scripts | Uses in nightly.yml |
|---|---|---|---|---|---|---|---|
| build-nightly-app :: Start Release DerivedData empty | ${{ needs.decide.outputs.track }} | DECIDE_TRACK | app-start-release-derived-data-empty.sh | 1 | 0 | 0 | 1 |
| build-nightly-app :: Start Release DerivedData empty | ${{ needs.decide.outputs.channel }} | DECIDE_CHANNEL | app-start-release-derived-data-empty.sh | 2 | 0 | 0 | 1 |
| build-nightly-app :: Compute Xcode compilation cache key | ${{ needs.decide.outputs.track }} | CACHE_KEY_TRACK | app-compilation-cache-key.sh | 1 | 0 | 0 | 1 |
| build-nightly-app :: Build nightly app (Release) | `${{ needs.decide.outputs.cold_cache == 'true' && '1' | '' }}` | DECIDE_COLD_CACHE_DISABLE | app-build-release.sh | 1 | 0 | |
| build-nightly-app :: Assemble the Computer Use helper | `${{ contains(needs.decide.outputs.variants, 'x86_64') && 'arm64 x86_64' | 'arm64' }}` | HELPER_ARCHS | app-assemble-computer-use-helper.sh | 1 | 0 | |
| build-nightly-app :: Resolve immutable SSH daemon metadata | ${{ needs.resolve-nightly-cmux-tui-client.outputs.daemon_build }} | RESOLVED_DAEMON_BUILD | app-resolve-daemon-metadata.sh | 1 | 0 | 0 | 1 |
| build-nightly-app :: Resolve immutable SSH daemon metadata | ${{ needs.decide.outputs.track }} | DAEMON_DECIDE_TRACK | app-resolve-daemon-metadata.sh | 1 | 0 | 0 | 1 |
| build-nightly-app :: Resolve immutable SSH daemon metadata | ${{ needs.decide.outputs.should_publish }} | DECIDE_SHOULD_PUBLISH | app-resolve-daemon-metadata.sh | 1 | 0 | 0 | 1 |
| publish-nightly :: Move channel release tag to built commit | ${{ steps.source.outputs.build_sha }} | SOURCE_BUILD_SHA | publish-move-release-tag.sh | 1 | 0 | 0 | 1 |
| publish-nightly :: Move channel release tag to built commit | `${{ needs.decide.outputs.release_tag != 'nightly' && '--allow-non-descendant' | '' }}` | ALLOW_NON_DESCENDANT_FLAG | publish-move-release-tag.sh | 2 | 0 | |
| verify-nightly-identity-legacy :: Hand the tested app's Info.plist to publish | ${{ steps.verify.outputs.tested_sha256 }} | VERIFIED_DMG_SHA256 | legacy-identity-verify-hand-info-plist.sh | 1 | 0 | 0 | 1 |
What the table proves:
- Reads: the script reads the variable that often. That is the same number of places as the old block had
${{ }}. - Writes = 0: the script never assigns the variable.
- Other scripts = 0: no other step script uses the name.
- Uses in nightly.yml = 1: the name appears only on its
env:line, so no job or workflow env can shadow it.
For every one of the 13 changed steps, the script body equals the old run block with each ${{ expr }} replaced by ${NAME}. I checked this programmatically against the feat tip. Two cases need a note:
--allow-non-descendant: the old text spliced it in unquoted when set and nothing when empty. It is now${ALLOW_NON_DESCENDANT_FLAG:+"$ALLOW_NON_DESCENDANT_FLAG"}, which gives the same one word or no word.DECIDE_COLD_CACHE_DISABLE: it was already inside double quotes, so the replacement keeps the same quoting.
"Republish deltas and revised appcasts" stays inline. test_nightly_republish_delta_guard.py runs its exact text, and its job checks out build_sha.
(b) Step notes move into their script's header (46 steps, 13.6 KB)
Only for steps whose script is called once. Comments of steps that keep inline code stay in nightly.yml. Moving the notes does not change yaml.safe_load of nightly.yml, and the non-comment lines of the 46 scripts are unchanged.
Why the 7 resume jobs are left out, and when they move
These 7 jobs run during an unsigned resume (nightly_next_unsigned_run) over the source run's build_sha: build-sign-notarize-nightly, prepare-nightly-variant, prepare-nightly-variant-primary, verify-signed-nightly, sign-release-notes, published-in and publish-nightly-reel.
resume-unsigned-nightly.py only requires that build_sha is an ancestor of the dispatch commit. So a resume of a source compiled before the scripts exist would not find them. Unsigned apps are kept for 1 day, and resumed copies for 7 days. These blocks therefore move 8 days after #30912 merged (2026-11-01 13:56Z), on or after 2026-11-09 14:00Z. That task is recorded in the lead's STATUS.
Tests
- 7 tests now read the expanded view, or copy the moved script into their scratch tree:
test_nightly_background_steps,test_upload_sentry_dsyms.sh,test_nightly_next_computer_use_helperandtest_nightly_sparkle_key.sh. test_nightly_trusted_routingchecksenv.RESOLVED_DAEMON_BUILDequals the resolver output, and that the script reads${RESOLVED_DAEMON_BUILD}.test_ci_fleet_compilation_cachechecksenv.DECIDE_COLD_CACHE_DISABLEequals the old expression, and that the command reads it.- The helper's regex accepts
bash -e. - What each test checks is unchanged.
No new CI job, step or gate. No limit changes, and the "Hold the whole Mac" gate is untouched. Risk tier: high.
Evidence
python3 tests/test_workflow_file_size.py: green at base (486,014, with 3,986 bytes of room) and green at head (458,313, with 31,687 bytes of room). This PR adds headroom; #30912 fixed the red.- All 124 tests that read nightly.yml: the same results at this head as at base.
- At #30912's head (
17bf1554652): all green on both sides. - At the current feat tip (
49aa2917ea9): the same 9 tests are red on both sides. These are base reds from later merges, and this PR changes none of them:test_check_ghostty_zig_workflows,test_ci_change_areas,test_ci_cloud_probe_rescue,test_ci_macos_xcode_selection,test_ci_self_hosted_guard.sh,test_ci_signing_keychain_is_deleted_before_bundle_code,test_ci_workflow_guards_are_wired,test_cmux_next_visual_gateandtest_seed_derived_data.
- At #30912's head (
shellcheck -S warning scripts/ci/nightly-steps/*.sh: clean.- actionlint 1.7.12-manaflow.1: the same 5 findings on nightly.yml as at base, all unused anchors that were already there.
Observed / Not run
- Observed: the above, plus
prepush-gate.py. - Not run: no nightly run (that needs the nightly-next push path). The fleet compile on this PR is the compile gate.
Changelog
none