ci(nightly): headroom under the size budget (14 more run blocks, step notes into scripts, expressions through env)

Merged
LA
5d ago
+619-449

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 through env: (table below).
  • tests/nightly_workflow_text.py still 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_sha step 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 :: stepExpressionEnv nameScriptReadsWritesOther scriptsUses in nightly.yml
build-nightly-app :: Start Release DerivedData empty${{ needs.decide.outputs.track }}DECIDE_TRACKapp-start-release-derived-data-empty.sh1001
build-nightly-app :: Start Release DerivedData empty${{ needs.decide.outputs.channel }}DECIDE_CHANNELapp-start-release-derived-data-empty.sh2001
build-nightly-app :: Compute Xcode compilation cache key${{ needs.decide.outputs.track }}CACHE_KEY_TRACKapp-compilation-cache-key.sh1001
build-nightly-app :: Build nightly app (Release)`${{ needs.decide.outputs.cold_cache == 'true' && '1''' }}`DECIDE_COLD_CACHE_DISABLEapp-build-release.sh10
build-nightly-app :: Assemble the Computer Use helper`${{ contains(needs.decide.outputs.variants, 'x86_64') && 'arm64 x86_64''arm64' }}`HELPER_ARCHSapp-assemble-computer-use-helper.sh10
build-nightly-app :: Resolve immutable SSH daemon metadata${{ needs.resolve-nightly-cmux-tui-client.outputs.daemon_build }}RESOLVED_DAEMON_BUILDapp-resolve-daemon-metadata.sh1001
build-nightly-app :: Resolve immutable SSH daemon metadata${{ needs.decide.outputs.track }}DAEMON_DECIDE_TRACKapp-resolve-daemon-metadata.sh1001
build-nightly-app :: Resolve immutable SSH daemon metadata${{ needs.decide.outputs.should_publish }}DECIDE_SHOULD_PUBLISHapp-resolve-daemon-metadata.sh1001
publish-nightly :: Move channel release tag to built commit${{ steps.source.outputs.build_sha }}SOURCE_BUILD_SHApublish-move-release-tag.sh1001
publish-nightly :: Move channel release tag to built commit`${{ needs.decide.outputs.release_tag != 'nightly' && '--allow-non-descendant''' }}`ALLOW_NON_DESCENDANT_FLAGpublish-move-release-tag.sh20
verify-nightly-identity-legacy :: Hand the tested app's Info.plist to publish${{ steps.verify.outputs.tested_sha256 }}VERIFIED_DMG_SHA256legacy-identity-verify-hand-info-plist.sh1001

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_helper and test_nightly_sparkle_key.sh.
  • test_nightly_trusted_routing checks env.RESOLVED_DAEMON_BUILD equals the resolver output, and that the script reads ${RESOLVED_DAEMON_BUILD}.
  • test_ci_fleet_compilation_cache checks env.DECIDE_COLD_CACHE_DISABLE equals 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_gate and test_seed_derived_data.
  • 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