Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,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 }}
full_suite: ${{ steps.suite.outputs.full_suite }}
compile_admitted: ${{ steps.admitted.outputs.compile_admitted }}
permissions:
Expand All @@ -65,6 +66,7 @@ jobs:
echo "macos=true"
echo "web=true"
echo "agent_session_web=true"
echo "release_build=true"
} >> "$GITHUB_OUTPUT"
}

Expand Down Expand Up @@ -127,6 +129,7 @@ jobs:
echo "macos=false"
echo "web=false"
echo "agent_session_web=false"
echo "release_build=false"
} >> "$GITHUB_OUTPUT"
exit 0
fi
Expand Down Expand Up @@ -2041,24 +2044,30 @@ jobs:
# "Select package tests" diffs against.
fetch-depth: 2

# 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" \
CMUX_CI_REQUIRED_MACOS_SDK_MAJOR=15 \
./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/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.cache/zig
key: zig-packages-${{ hashFiles('ghostty/build.zig.zon', 'ghostty/build.zig.zon.json') }}
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
Expand All @@ -2073,6 +2082,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
Expand All @@ -2083,7 +2093,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
Expand Down Expand Up @@ -2911,7 +2921,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' && needs.changes.outputs.full_suite == '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' && needs.changes.outputs.full_suite == '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.
Expand Down
18 changes: 17 additions & 1 deletion scripts/ci/detect_ci_change_areas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)}",
]


Expand Down Expand Up @@ -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/")
Comment thread
coderabbitai[bot] marked this conversation as resolved.


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))
Comment on lines +263 to +266

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Renames Can Skip Release

When a Release-relevant file is renamed into one of these test directories, git diff --name-only reports only the destination path. This classifier therefore treats the entire rename as test-only, even though removing or relocating the production source can break the Release configuration. The Release build is then skipped, allowing Release-only failures to reach main. Collect both sides of renames, such as with --no-renames, or otherwise classify the source path too.



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:
Expand All @@ -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
Expand All @@ -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,
)


Expand Down
70 changes: 61 additions & 9 deletions tests/test_ci_change_areas.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,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:
Expand Down Expand Up @@ -340,14 +385,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:
Expand Down Expand Up @@ -614,7 +659,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:
Expand Down Expand Up @@ -645,6 +690,7 @@ def test_workflow_diff_failure_runs_all_areas() -> None:
"macos=true",
"web=true",
"agent_session_web=true",
"release_build=true",
]


Expand Down Expand Up @@ -733,7 +779,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:
Expand All @@ -742,14 +788,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:
Expand Down Expand Up @@ -788,6 +834,7 @@ def test_ghosttykit_checksum_pr_uses_release_guard_only() -> None:
"macos=false",
"web=false",
"agent_session_web=false",
"release_build=false",
]


Expand All @@ -809,6 +856,7 @@ def test_ghosttykit_guard_wiring_pr_stays_on_release_guard() -> None:
"macos=false",
"web=false",
"agent_session_web=false",
"release_build=false",
]


Expand All @@ -821,6 +869,7 @@ def test_workflow_only_pr_keeps_fail_open_routing() -> None:
"macos=true",
"web=true",
"agent_session_web=true",
"release_build=true",
]


Expand Down Expand Up @@ -860,6 +909,7 @@ def test_cli_writes_github_outputs() -> None:
"macos=false",
"web=true",
"agent_session_web=false",
"release_build=false",
]


Expand Down Expand Up @@ -892,6 +942,7 @@ def test_cli_empty_diff_runs_all_areas() -> None:
"macos=true",
"web=true",
"agent_session_web=true",
"release_build=true",
]


Expand Down Expand Up @@ -1015,10 +1066,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'"
+ (
" && needs.changes.outputs.compile_admitted != 'true'"
if job_name == "macos-compile-admission"
Expand Down Expand Up @@ -1584,7 +1636,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)
Expand All @@ -1596,7 +1648,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)
Expand Down
Loading