From 3460796efe6877ce37b9063540d2258c6a90ba9f Mon Sep 17 00:00:00 2001 From: Leo Li Date: Sat, 19 Sep 2026 18:04:09 -0700 Subject: [PATCH 1/2] ci: skip the Release build when a pull request only changes tests release-build compiles the universal Release app from scratch on a macOS 26 runner, about 35 minutes per run. It builds only the cmux target, so a change limited to cmuxTests, cmuxUITests or a package's Tests directory cannot alter what it produces. The router gains a release_build output. It is true whenever a macOS-relevant path outside those test directories changed, and whenever every area is forced. release-build gates on it; the other macOS jobs still gate on macos. A new test file also edits project.pbxproj, so it still runs the Release build. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/ci.yml | 5 +- scripts/ci/detect_ci_change_areas.py | 18 ++++++- tests/test_ci_change_areas.py | 70 ++++++++++++++++++++++++---- 3 files changed, 82 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e37dba2be140..4ca8eb08e041 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,6 +24,7 @@ jobs: macos: ${{ steps.detect.outputs.macos }} web: ${{ steps.detect.outputs.web }} agent_session_web: ${{ steps.detect.outputs.agent_session_web }} + release_build: ${{ steps.detect.outputs.release_build }} steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -44,6 +45,7 @@ jobs: echo "macos=true" echo "web=true" echo "agent_session_web=true" + echo "release_build=true" } >> "$GITHUB_OUTPUT" } @@ -106,6 +108,7 @@ jobs: echo "macos=false" echo "web=false" echo "agent_session_web=false" + echo "release_build=false" } >> "$GITHUB_OUTPUT" exit 0 fi @@ -2634,7 +2637,7 @@ jobs: # See app-host-unit-tests: explicit direct-needs gate instead of the # implicit success() so skipped routed linux jobs upstream of # linux-preflight do not skip this job transitively. - if: ${{ !cancelled() && needs.changes.result == 'success' && needs.linux-preflight.result == 'success' && needs.swift-package-tests.result == 'success' && needs.macos-compile-admission.result == 'success' && needs.changes.outputs.macos == 'true' }} + if: ${{ !cancelled() && needs.changes.result == 'success' && needs.linux-preflight.result == 'success' && needs.swift-package-tests.result == 'success' && needs.macos-compile-admission.result == 'success' && needs.changes.outputs.release_build == 'true' }} # Compile the same unsigned universal Release app that nightly builds before # signing, notarization, and publishing. This catches DEBUG/Release boundary # mistakes before they reach main. diff --git a/scripts/ci/detect_ci_change_areas.py b/scripts/ci/detect_ci_change_areas.py index 464250219708..65d54a597e14 100644 --- a/scripts/ci/detect_ci_change_areas.py +++ b/scripts/ci/detect_ci_change_areas.py @@ -18,16 +18,18 @@ class ChangeAreas: macos: bool web: bool agent_session_web: bool + release_build: bool @classmethod def all(cls) -> ChangeAreas: - return cls(macos=True, web=True, agent_session_web=True) + return cls(macos=True, web=True, agent_session_web=True, release_build=True) def as_output_lines(self) -> list[str]: return [ f"macos={bool_output(self.macos)}", f"web={bool_output(self.web)}", f"agent_session_web={bool_output(self.agent_session_web)}", + f"release_build={bool_output(self.release_build)}", ] @@ -255,10 +257,20 @@ def is_macos_change(path: str) -> bool: return not is_macos_neutral(path) +_PACKAGE_TESTS_RE = re.compile(r"Packages/[^/]+/[^/]+/Tests/") + + +def is_test_only_source(path: str) -> bool: + # The Release app builds only the cmux target, so test sources cannot reach + # it. A new test file also edits project.pbxproj, which is not matched here. + return path.startswith(("cmuxTests/", "cmuxUITests/")) or bool(_PACKAGE_TESTS_RE.match(path)) + + def classify_files(paths: Iterable[str], *, ci_workflow_linux_only: bool = False) -> ChangeAreas: macos = False web = False agent_session_web = False + release_build = False test_references = load_macos_job_test_references() for raw_path in paths: @@ -271,6 +283,7 @@ def classify_files(paths: Iterable[str], *, ci_workflow_linux_only: bool = False macos = True web = True agent_session_web = True + release_build = True continue if is_other_workflow_config(path) or is_guard_only_test(path, test_references): continue @@ -280,11 +293,14 @@ def classify_files(paths: Iterable[str], *, ci_workflow_linux_only: bool = False agent_session_web = True if is_macos_change(path): macos = True + if not is_test_only_source(path): + release_build = True return ChangeAreas( macos=macos, web=web, agent_session_web=agent_session_web, + release_build=release_build, ) diff --git a/tests/test_ci_change_areas.py b/tests/test_ci_change_areas.py index 3d7b1f395413..a0ada29795a2 100644 --- a/tests/test_ci_change_areas.py +++ b/tests/test_ci_change_areas.py @@ -38,6 +38,51 @@ def assert_areas( assert actual.macos is macos, (paths, actual) assert actual.web is web, (paths, actual) assert actual.agent_session_web is agent_session_web, (paths, actual) + # The Release build is a macOS job, so it can never run without that area. + assert actual.macos or not actual.release_build, (paths, actual) + + +def test_test_only_changes_skip_the_release_build() -> None: + for paths in ( + ["cmuxTests/WorkspaceRemoteConnectionTests.swift"], + ["cmuxUITests/SidebarUITests.swift", "cmuxTests/GhosttyConfigTests.swift"], + ["Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/FakeTerminalEngine.swift", "docs/ci.md"], + ): + actual = module.classify_files(paths) + assert actual.macos is True, (paths, actual) + assert actual.release_build is False, (paths, actual) + + +def test_anything_the_app_can_build_from_runs_the_release_build() -> None: + for path in ( + "Sources/AppDelegate.swift", + "CLI/cmux.swift", + "cmux.xcodeproj/project.pbxproj", + "Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/TerminalEngine.swift", + "Packages/macOS/CmuxTerminal/Package.swift", + "Resources/Localizable.xcstrings", + "scripts/thin-app-bundle.sh", + "tests/test_thin_app_bundle.sh", + "package.json", + "some-new-top-level-dir/file.txt", + ): + paths = ["cmuxTests/GhosttyConfigTests.swift", path] + actual = module.classify_files(paths) + assert actual.macos is True, (paths, actual) + assert actual.release_build is True, (paths, actual) + + +def test_release_build_follows_the_other_areas_when_macos_is_skipped_or_forced() -> None: + assert module.classify_files(["docs/ci.md"]).release_build is False + assert module.classify_files([".github/workflows/ci.yml"]).release_build is True + assert module.ChangeAreas.all().release_build is True + + +def test_test_only_pull_request_routes_macos_without_the_release_build() -> None: + result, outputs = run_detect_step_for_paths(["cmuxTests/GhosttyConfigTests.swift"]) + + assert result.returncode == 0, result.stderr + assert outputs == ["macos=true", "web=false", "agent_session_web=false", "release_build=false"] def test_docs_only_skips_expensive_areas() -> None: @@ -333,14 +378,14 @@ def test_workflow_routes_linux_only_ci_workflow_edit_away_from_macos() -> None: _, outputs = run_detect_step_for_ci_workflow_edit( CI_DIFF_BASE, CI_DIFF_BASE.replace("- run: guard", "- run: guard\n - run: more") ) - assert outputs == ["macos=false", "web=false", "agent_session_web=false"] + assert outputs == ["macos=false", "web=false", "agent_session_web=false", "release_build=false"] def test_workflow_routes_macos_job_edit_to_every_area() -> None: _, outputs = run_detect_step_for_ci_workflow_edit( CI_DIFF_BASE, CI_DIFF_BASE.replace("- run: compile", "- run: compile --faster") ) - assert outputs == ["macos=true", "web=true", "agent_session_web=true"] + assert outputs == ["macos=true", "web=true", "agent_session_web=true", "release_build=true"] def test_macos_test_references_fail_open_without_ci_workflow() -> None: @@ -604,7 +649,7 @@ def test_workflow_self_change_guard_runs_before_detector_imports() -> None: result, outputs = run_detect_step_for_paths(["scripts/ci/subprocess.py"]) assert "CI router changed; running all CI areas." in result.stdout - assert outputs == ["macos=true", "web=true", "agent_session_web=true"] + assert outputs == ["macos=true", "web=true", "agent_session_web=true", "release_build=true"] def test_workflow_diff_failure_runs_all_areas() -> None: @@ -635,6 +680,7 @@ def test_workflow_diff_failure_runs_all_areas() -> None: "macos=true", "web=true", "agent_session_web=true", + "release_build=true", ] @@ -723,7 +769,7 @@ def test_workflow_routes_from_shallow_synthetic_merge() -> None: result, outputs = run_detect_step_on_shallow_synthetic_merge(stale_event_base=False) assert "Could not compute PR diff" not in result.stderr - assert outputs == ["macos=false", "web=true", "agent_session_web=false"] + assert outputs == ["macos=false", "web=true", "agent_session_web=false", "release_build=false"] def test_workflow_routes_when_main_moved_past_the_event_base() -> None: @@ -732,14 +778,14 @@ def test_workflow_routes_when_main_moved_past_the_event_base() -> None: assert "Could not compute PR diff" not in result.stderr # base-only.txt landed on main after the event base. It is not part of the # pull request and must not route macOS. - assert outputs == ["macos=false", "web=true", "agent_session_web=false"] + assert outputs == ["macos=false", "web=true", "agent_session_web=false", "release_build=false"] def test_workflow_empty_diff_runs_all_areas() -> None: result, outputs = run_detect_step_for_paths([]) assert "PR diff is empty; running all CI areas." in result.stdout - assert outputs == ["macos=true", "web=true", "agent_session_web=true"] + assert outputs == ["macos=true", "web=true", "agent_session_web=true", "release_build=true"] def test_router_changes_run_everything() -> None: @@ -778,6 +824,7 @@ def test_ghosttykit_checksum_pr_uses_release_guard_only() -> None: "macos=false", "web=false", "agent_session_web=false", + "release_build=false", ] @@ -799,6 +846,7 @@ def test_ghosttykit_guard_wiring_pr_stays_on_release_guard() -> None: "macos=false", "web=false", "agent_session_web=false", + "release_build=false", ] @@ -811,6 +859,7 @@ def test_workflow_only_pr_keeps_fail_open_routing() -> None: "macos=true", "web=true", "agent_session_web=true", + "release_build=true", ] @@ -850,6 +899,7 @@ def test_cli_writes_github_outputs() -> None: "macos=false", "web=true", "agent_session_web=false", + "release_build=false", ] @@ -882,6 +932,7 @@ def test_cli_empty_diff_runs_all_areas() -> None: "macos=true", "web=true", "agent_session_web=true", + "release_build=true", ] @@ -964,10 +1015,11 @@ def test_macos_jobs_wait_for_linux_preflight() -> None: expected_needs.append("swift-package-tests") if job_name in {"app-host-unit-tests", "tests-build-and-lag", "release-build"}: expected_needs.append("macos-compile-admission") + route = "release_build" if job_name == "release-build" else "macos" expected_if = ( "if: ${{ !cancelled() && " + " && ".join(f"needs.{need}.result == 'success'" for need in expected_needs) - + " && needs.changes.outputs.macos == 'true' }}" + + f" && needs.changes.outputs.{route} == 'true' }}}}" ) assert expected_if in block, f"{job_name} must gate on direct needs explicitly" @@ -1282,7 +1334,7 @@ def test_agent_session_web_resources_runs_only_for_agent_session_web_area() -> N def test_perf_activation_runs_for_its_own_workflow_and_not_for_others() -> None: _, outputs = run_detect_step_for_paths([".github/workflows/relay-tls.yml"], PERF_ACTIVATION_WORKFLOW) - assert outputs == ["macos=false", "web=false", "agent_session_web=false"] + assert outputs == ["macos=false", "web=false", "agent_session_web=false", "release_build=false"] for path in (".github/workflows/perf-activation.yml", "scripts/ci/subprocess.py"): result, outputs = run_detect_step_for_paths([path], PERF_ACTIVATION_WORKFLOW) @@ -1294,7 +1346,7 @@ def test_perf_activation_workflow_keeps_required_status_while_gating_benchmark() result, outputs = run_detect_step_for_paths(["docs/ci-runners.md"], PERF_ACTIVATION_WORKFLOW) assert "Resolved areas: macos=false web=false" in result.stdout - assert outputs == ["macos=false", "web=false", "agent_session_web=false"] + assert outputs == ["macos=false", "web=false", "agent_session_web=false", "release_build=false"] benchmark = workflow_job_block("activation-session-benchmark", PERF_ACTIVATION_WORKFLOW) sentinel = workflow_job_block("activation-session", PERF_ACTIVATION_WORKFLOW) From a14fa0df901b92e0544bebdf4f4c7b0f36cb7bcf Mon Sep 17 00:00:00 2001 From: Leo Li Date: Sun, 20 Sep 2026 01:05:17 -0700 Subject: [PATCH 2/2] ci: skip release helper work when the release build is skipped --- .github/workflows/ci.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2ac4023d684c..b566c07f6915 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1915,7 +1915,10 @@ jobs: with: submodules: recursive + # Only release-build consumes this artifact. Package/app-host tests use + # the prebuilt GhosttyKit framework and do not need the universal CLI. - name: Select helper Xcode + if: ${{ needs.changes.outputs.release_build == 'true' }} run: | set -euo pipefail CMUX_CI_XCODE_APP="$CMUX_CI_HELPER_XCODE_APP" \ @@ -1923,9 +1926,11 @@ jobs: ./scripts/select-ci-xcode.sh - name: Install zig + if: ${{ needs.changes.outputs.release_build == 'true' }} run: ./scripts/install-zig-ci.sh - name: Cache Zig packages + if: ${{ needs.changes.outputs.release_build == 'true' }} uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: ~/.cache/zig @@ -1933,6 +1938,7 @@ jobs: restore-keys: zig-packages- - name: Build universal Ghostty CLI helper + if: ${{ needs.changes.outputs.release_build == 'true' }} run: | set -euo pipefail mkdir -p ghostty-cli-helper @@ -1947,6 +1953,7 @@ jobs: done - name: Upload universal Ghostty CLI helper + if: ${{ needs.changes.outputs.release_build == 'true' }} id: upload-ghostty-cli-helper continue-on-error: true uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 @@ -1957,7 +1964,7 @@ jobs: retention-days: 1 - name: Retry universal Ghostty CLI helper upload - if: steps.upload-ghostty-cli-helper.outcome == 'failure' + if: ${{ needs.changes.outputs.release_build == 'true' && steps.upload-ghostty-cli-helper.outcome == 'failure' }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: cmux-ghostty-cli-helper