diff --git a/.github/workflows/ci-guards.yml b/.github/workflows/ci-guards.yml index f27aafe6cff4..fc36f18bef77 100644 --- a/.github/workflows/ci-guards.yml +++ b/.github/workflows/ci-guards.yml @@ -6,6 +6,9 @@ on: linux_guard_tests: required: true type: string + linux_guard_test_groups: + required: true + type: string linux_guard_history: required: true type: string @@ -26,7 +29,7 @@ jobs: strategy: fail-fast: false matrix: - group: [preflight, ci, app-host-execution, app-host-process, app-host-cache, release-ios, release-notary, release-tooling, quality-sharding, quality-runtime, quality-determinism] + group: ${{ fromJSON(inputs.linux_guard_test_groups) }} runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} # This job executes scripts from the pull request. It needs only read # access for checkout, never workflow, issue, or repository write access. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 45f29579fb33..b2378b5da830 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,6 +49,7 @@ jobs: agent_session_web: ${{ steps.detect.outputs.agent_session_web }} release_build: ${{ steps.detect.outputs.release_build }} linux_guard_tests: ${{ steps.linux_guards.outputs.linux_guard_tests }} + linux_guard_test_groups: ${{ steps.linux_guards.outputs.linux_guard_test_groups }} linux_guard_history: ${{ steps.linux_guards.outputs.linux_guard_history }} linux_guard_cli: ${{ steps.linux_guards.outputs.linux_guard_cli }} linux_guard_source: ${{ steps.linux_guards.outputs.linux_guard_source }} @@ -254,10 +255,11 @@ jobs: if [ -f /tmp/cmux-ci-changed-files.txt ]; then while IFS= read -r path; do case "$path" in - .github/workflows/ci.yml|scripts/ci/detect_ci_change_areas.py|scripts/ci/detect_linux_guard_changes.py|tests/test_ci_change_areas.py|tests/test_ci_linux_guard_routing.py) + .github/workflows/ci.yml|.github/workflows/ci-guards.yml|scripts/ci/detect_ci_change_areas.py|scripts/ci/detect_linux_guard_changes.py|scripts/ci/workflow_guard_groups.py|tests/test_ci_change_areas.py|tests/test_ci_linux_guard_routing.py|tests/test_ci_guard_workflow_structure.py|tests/test_ci_app_host_guard_structure.py|tests/test_ci_quality_guard_structure.py|tests/test_ci_release_guard_structure.py) for route in linux_guard_tests linux_guard_history linux_guard_cli linux_guard_source ghosttykit_release; do echo "$route=true" >> "$GITHUB_OUTPUT" done + echo 'linux_guard_test_groups=["preflight","ci","app-host-execution","app-host-process","app-host-cache","release-ios","release-notary","release-tooling","quality-sharding","quality-runtime","quality-determinism"]' >> "$GITHUB_OUTPUT" exit 0 ;; esac @@ -404,6 +406,7 @@ jobs: uses: ./.github/workflows/ci-guards.yml with: linux_guard_tests: ${{ needs.changes.outputs.linux_guard_tests }} + linux_guard_test_groups: ${{ needs.changes.outputs.linux_guard_test_groups }} linux_guard_history: ${{ needs.changes.outputs.linux_guard_history }} linux_guard_cli: ${{ needs.changes.outputs.linux_guard_cli }} linux_guard_source: ${{ needs.changes.outputs.linux_guard_source }} diff --git a/scripts/ci/detect_linux_guard_changes.py b/scripts/ci/detect_linux_guard_changes.py index 77c1566cf844..54361f537b77 100644 --- a/scripts/ci/detect_linux_guard_changes.py +++ b/scripts/ci/detect_linux_guard_changes.py @@ -4,8 +4,11 @@ from __future__ import annotations import argparse +import json from pathlib import Path +from workflow_guard_groups import GROUPS, groups_for_path + ROUTES = ( "linux_guard_tests", "linux_guard_history", "linux_guard_cli", @@ -98,6 +101,30 @@ def classify(paths: list[str], *, event: str, macos: str) -> dict[str, bool]: return routes +def classify_test_groups(paths: list[str], *, event: str, macos: str) -> tuple[str, ...]: + """Select only workflow-guard-tests groups that can observe this diff.""" + if event != "pull_request" or macos not in {"true", "false"} or not paths: + return GROUPS + + selected: set[str] = set() + for path in paths: + if not path or path.startswith("/") or ".." in path.split("/"): + return GROUPS + if plain_documentation(path): + continue + owners = groups_for_path(path) + if owners is None: + return GROUPS + selected.update(owners) + + # linux_guard_tests skips documentation-only diffs. Keep a valid non-empty + # matrix value available anyway so malformed callers cannot create an empty + # matrix-expansion failure. + if not selected: + return GROUPS + return tuple(group for group in GROUPS if group in selected) + + def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--event-name", required=True) @@ -108,8 +135,11 @@ def main() -> None: paths = args.files_from.read_text(encoding="utf-8").splitlines() except (OSError, UnicodeError): paths = [] - for name, enabled in classify(paths, event=args.event_name, macos=args.macos).items(): + routes = classify(paths, event=args.event_name, macos=args.macos) + for name, enabled in routes.items(): print(f"{name}={'true' if enabled else 'false'}") + groups = classify_test_groups(paths, event=args.event_name, macos=args.macos) + print(f"linux_guard_test_groups={json.dumps(groups, separators=(',', ':'))}") if __name__ == "__main__": diff --git a/scripts/ci/workflow_guard_groups.py b/scripts/ci/workflow_guard_groups.py new file mode 100644 index 000000000000..f622ace656a2 --- /dev/null +++ b/scripts/ci/workflow_guard_groups.py @@ -0,0 +1,331 @@ +#!/usr/bin/env python3 +"""Declarative ownership for the workflow-guard-tests Linux matrix. + +Unknown paths deliberately return None so the caller can fail open to every +group. Keep this file data-oriented: tests compare STEP_OWNERS with ci-guards.yml +and verify directly executed paths route back to their owning group. +""" + +from __future__ import annotations + + +GROUPS = ( + "preflight", + "ci", + "app-host-execution", + "app-host-process", + "app-host-cache", + "release-ios", + "release-notary", + "release-tooling", + "quality-sharding", + "quality-runtime", + "quality-determinism", +) + +# Every step in workflow-guard-tests with an exact matrix.group condition. +# A step move or rename must update this table in the same change. +STEP_OWNERS = { + "Initialize Ghostty for Zig version guard": "release-tooling", + "Run canonical CMUX CI guard profile": "ci", + "Test XCStrings catalog lint": "preflight", + "Test macOS localization catalog tooling": "preflight", + "Test review fabric contracts": "preflight", + "Validate Blacksmith Testbox broker trust boundary": "ci", + "Validate CI Xcode selection fast path": "release-notary", + "Validate CI change area filter": "ci", + "Validate CMUX INTERNAL main-push path filter": "release-ios", + "Validate CMUX workload profile contract": "ci", + "Validate Claude launch environment policy behavior": "preflight", + "Validate Claude launch environment policy generation": "preflight", + "Validate Computer Use helper notarization behavior": "release-notary", + "Validate Ghostty Zig version synchronization": "release-tooling", + "Validate Ghostty helper cache failure handling": "release-tooling", + "Validate GhosttyKit checksum verification": "release-tooling", + "Validate GhosttyKit release check behavior": "release-tooling", + "Validate Localizable.xcstrings catalog structure": "preflight", + "Validate Pro TestFlight distribution workflow": "release-ios", + "Validate Python R2 appcast upload guard": "release-tooling", + "Validate Python test harness syntax": "preflight", + "Validate Release check architectures": "release-tooling", + "Validate Sparkle appcast generation without previous archives": "release-notary", + "Validate Sparkle delta finalization": "release-notary", + "Validate Sparkle monotonic guard modes": "release-notary", + "Validate Swift Testing suite timeout guard": "app-host-execution", + "Validate Swift incremental diagnostics tooling": "preflight", + "Validate Swift warning budget guard": "release-notary", + "Validate TUI npm package artifact transfer": "preflight", + "Validate TUI package artifact contract": "preflight", + "Validate TestFlight notes generator": "release-ios", + "Validate Xcode SourcePackages cache sanitizer": "app-host-cache", + "Validate Xcode build metrics receipt": "ci", + "Validate Xcode compilation cache pruning": "app-host-cache", + "Validate Zig download resume and mirror isolation": "release-tooling", + "Validate Zig install without sudo": "release-tooling", + "Validate activation benchmark scrollback sizing": "ci", + "Validate app bundle license compliance": "release-notary", + "Validate app-host failure census": "app-host-process", + "Validate app-host guard structure": "ci", + "Validate app-host identity and cleanup confirmation": "app-host-process", + "Validate app-host process receipts": "app-host-process", + "Validate app-host test failure classification": "app-host-process", + "Validate app-host user configuration isolation": "app-host-process", + "Validate app-host xcodebuild attempt budget": "app-host-execution", + "Validate app-host xcodebuild retry guard": "app-host-execution", + "Validate auxiliary window close shortcut lint": "quality-determinism", + "Validate bash prompt bootstrap composes with user PROMPT_COMMAND (starship)": "quality-determinism", + "Validate bash shell integration job control": "quality-determinism", + "Validate bundled-resource incremental outputs": "quality-runtime", + "Validate checkout failure network diagnostics": "ci", + "Validate cmux scheme test configuration": "app-host-cache", + "Validate cmux-cloud-vm skill covers the vm CLI": "preflight", + "Validate cmux-tui client installation": "release-tooling", + "Validate cmuxTests sharding": "quality-sharding", + "Validate control-plane generated types": "preflight", + "Validate create-dmg version pinning": "release-notary", + "Validate current GhosttyKit checksum pin": "release-tooling", + "Validate dev-fleet warm-slot lifecycle": "preflight", + "Validate docs deployment authentication guard": "preflight", + "Validate embedded cmux.json schema generation": "preflight", + "Validate external TestFlight group assignment helper": "release-ios", + "Validate focused Dock shortcut routing guard": "quality-determinism", + "Validate focused test launcher": "app-host-execution", + "Validate iOS App Store lane identity": "release-ios", + "Validate isolated app-host home cleanup": "app-host-process", + "Validate local build cache preflight": "app-host-cache", + "Validate local hook trust boundary": "ci", + "Validate macOS runner guards": "preflight", + "Validate manual iOS workflow ref resolution": "release-ios", + "Validate markdown viewer asset compression": "release-notary", + "Validate nightly Xcode selection": "release-notary", + "Validate nightly notarization behavior": "release-notary", + "Validate nightly prune Python compatibility": "release-tooling", + "Validate nightly tag push auth": "release-notary", + "Validate notification semantics gates": "ci", + "Validate package test selection": "ci", + "Validate persistent Mac compile admission contract": "preflight", + "Validate pipe-safe CI capture": "app-host-execution", + "Validate previous nightly build fetch": "release-notary", + "Validate pull request caches are read-only": "ci", + "Validate quality guard structure": "ci", + "Validate release SDK build lane": "preflight", + "Validate release asset guard": "release-notary", + "Validate release bundle stripping": "release-notary", + "Validate release does not gate on iOS screenshot capture": "release-ios", + "Validate release guard structure": "ci", + "Validate release tunnel extension identifiers": "release-ios", + "Validate release-build timeout guard": "release-notary", + "Validate reload shared DerivedData default": "preflight", + "Validate required checks report for the merge queue": "ci", + "Validate resumable GitHub release publication": "release-notary", + "Validate reusable guard workflow structure": "ci", + "Validate reusable workflow permission grants": "ci", + "Validate selected iOS test execution guard": "app-host-cache", + "Validate shallow complexity comparison fetching": "ci", + "Validate sidebar extension point generation": "preflight", + "Validate stale run cleanup eligibility": "ci", + "Validate stalled GhosttyKit downloads resume": "release-tooling", + "Validate tagged iOS device entitlement fallback": "release-ios", + "Validate tagged reload works without the shared dev backend": "preflight", + "Validate test compilation cache seeding": "quality-sharding", + "Validate test determinism gate": "quality-determinism", + "Validate the R2 cache store script": "ci", + "Validate the trusted web complexity workflow": "ci", + "Validate unit-test SwiftPM retry guard": "app-host-execution", + "Validate universal nightly workflow": "release-notary", + "Validate virtual display lock": "quality-runtime", + "Validate xcodebuild failure diagnostics": "app-host-execution", + "Validate xcodebuild noninteractive crash prompt guard": "app-host-execution", +} + +# Direct executables plus known indirect inputs whose guard ownership is stable. +# Broad scanners below add their groups on top of these exact owners. +PATH_OWNERS = { + ".github/review-fabric-policy.json": frozenset(("preflight",)), + ".github/review-fabric.md": frozenset(("preflight",)), + ".github/scripts/review_fabric.py": frozenset(("preflight",)), + ".github/workflows/ios-testflight.yml": frozenset(("preflight", "ci", "release-ios")), + "agent-chat/test/claude-environment.test.ts": frozenset(("preflight",)), + "ghostty": frozenset(("release-tooling",)), + "ios/scripts/upload-testflight.sh": frozenset(("release-ios",)), + "ios/tests/tagged-device-entitlements.test.mjs": frozenset(("release-ios",)), + "scripts/check-control-plane-types.sh": frozenset(("preflight",)), + "scripts/check-test-determinism.py": frozenset(("quality-determinism",)), + "scripts/ci/app_host_test_products.py": frozenset(("preflight",)), + "scripts/ci/build_input_fingerprint.py": frozenset(("preflight",)), + "scripts/ci/cmux_unit_test_shard.py": frozenset(("quality-sharding",)), + "scripts/ci/cmux_workload_profile.py": frozenset(("ci",)), + "scripts/ci/compile-app-host-test-product.sh": frozenset(("preflight",)), + "scripts/ci/find_admitted_build.py": frozenset(("preflight",)), + "scripts/ci/persistent_mac_route.py": frozenset(("preflight",)), + "scripts/ci/product_input_identity.py": frozenset(("preflight",)), + "scripts/ci/restore-app-host-test-product.sh": frozenset(("preflight",)), + "scripts/ci/reuse_app_host_products.py": frozenset(("preflight",)), + "scripts/ci/sanitize-xcode-source-packages-cache.py": frozenset(("preflight",)), + "scripts/ci/swift_incremental_diagnostics.py": frozenset(("preflight",)), + "scripts/generate-claude-launch-environment-policy.py": frozenset(("preflight",)), + "scripts/generate-cmux-config-schema.py": frozenset(("preflight",)), + "scripts/release_asset_guard.test.js": frozenset(("release-notary",)), + "skills/cmux-cloud-vm/SKILL.md": frozenset(("preflight",)), + "skills/cmux-cloud-vm/references/agent-workflows.md": frozenset(("preflight",)), + "skills/cmux-cloud-vm/references/commands.md": frozenset(("preflight",)), + "skills/cmux-cloud-vm/references/guest.md": frozenset(("preflight",)), + "tests/test_app_bundle_license_compliance.sh": frozenset(("release-notary",)), + "tests/test_app_host_test_products.py": frozenset(("ci",)), + "tests/test_bash_integration_no_done_notifications.py": frozenset(("quality-determinism",)), + "tests/test_benchmark_dev_fleet_warm_slots.py": frozenset(("preflight",)), + "tests/test_build_app_bundled_resources.sh": frozenset(("quality-runtime",)), + "tests/test_build_metrics.py": frozenset(("ci",)), + "tests/test_ci_app_host_failure_census.py": frozenset(("app-host-process",)), + "tests/test_ci_app_host_guard_structure.py": frozenset(("ci",)), + "tests/test_ci_app_host_home_cleanup.sh": frozenset(("app-host-process",)), + "tests/test_ci_app_host_home_isolation.py": frozenset(("app-host-process",)), + "tests/test_ci_app_host_identity.sh": frozenset(("app-host-process",)), + "tests/test_ci_app_host_pipe_capture.py": frozenset(("app-host-execution",)), + "tests/test_ci_app_host_processes.sh": frozenset(("app-host-process",)), + "tests/test_ci_app_host_test_output.py": frozenset(("app-host-process",)), + "tests/test_ci_app_host_xcodebuild_attempts.sh": frozenset(("app-host-execution",)), + "tests/test_ci_app_host_xcodebuild_retry.sh": frozenset(("app-host-execution",)), + "tests/test_ci_auxiliary_window_close_shortcuts.sh": frozenset(("quality-determinism",)), + "tests/test_ci_change_areas.py": frozenset(("ci",)), + "tests/test_ci_checkout_network_diagnostics.py": frozenset(("ci",)), + "tests/test_ci_cmux_unit_test_shard.py": frozenset(("quality-sharding",)), + "tests/test_ci_create_dmg_pinned.sh": frozenset(("release-notary",)), + "tests/test_ci_fetch_complexity_base.py": frozenset(("ci",)), + "tests/test_ci_ghosttykit_checksum_present.sh": frozenset(("release-tooling",)), + "tests/test_ci_ghosttykit_checksum_verification.sh": frozenset(("release-tooling",)), + "tests/test_ci_ghosttykit_download_stall.sh": frozenset(("release-tooling",)), + "tests/test_ci_ghosttykit_release_check.sh": frozenset(("release-tooling",)), + "tests/test_ci_guard_workflow_structure.py": frozenset(("ci",)), + "tests/test_ci_linux_guard_routing.py": frozenset(("ci",)), + "tests/test_ci_merge_queue_required_checks.py": frozenset(("ci",)), + "tests/test_ci_nightly_prune_python_compat.sh": frozenset(("release-tooling",)), + "tests/test_ci_nightly_tag_push_auth.sh": frozenset(("release-notary",)), + "tests/test_ci_nightly_xcode_selection.sh": frozenset(("release-notary",)), + "tests/test_ci_notification_semantics.py": frozenset(("ci",)), + "tests/test_ci_persistent_mac_compile.py": frozenset(("preflight",)), + "tests/test_ci_product_publication.py": frozenset(("ci",)), + "tests/test_ci_prune_xcode_compilation_cache.py": frozenset(("app-host-cache",)), + "tests/test_ci_pull_request_caches_are_read_only.py": frozenset(("ci",)), + "tests/test_ci_quality_guard_structure.py": frozenset(("ci",)), + "tests/test_ci_r2_cache.sh": frozenset(("ci",)), + "tests/test_ci_r2_upload_python.sh": frozenset(("release-tooling",)), + "tests/test_ci_release_build_archs.sh": frozenset(("release-tooling",)), + "tests/test_ci_release_build_timeout.sh": frozenset(("release-notary",)), + "tests/test_ci_release_guard_structure.py": frozenset(("ci",)), + "tests/test_ci_release_ios_screenshots_decoupled.sh": frozenset(("release-ios",)), + "tests/test_ci_release_sdk_lane.sh": frozenset(("preflight",)), + "tests/test_ci_release_tunnel_identifiers.sh": frozenset(("release-ios",)), + "tests/test_ci_reusable_workflow_permissions.py": frozenset(("ci",)), + "tests/test_ci_run_and_capture.sh": frozenset(("app-host-execution",)), + "tests/test_ci_sanitize_xcode_source_packages_cache.py": frozenset(("app-host-cache",)), + "tests/test_ci_scheme_testaction_debug.sh": frozenset(("app-host-cache",)), + "tests/test_ci_select_package_tests.py": frozenset(("ci",)), + "tests/test_ci_self_hosted_guard.sh": frozenset(("preflight",)), + "tests/test_ci_swift_warning_budget.sh": frozenset(("release-notary",)), + "tests/test_ci_test_compilation_cache_seed.sh": frozenset(("quality-sharding",)), + "tests/test_ci_testbox_broker_guard.py": frozenset(("ci",)), + "tests/test_ci_unit_test_spm_retry.sh": frozenset(("app-host-execution",)), + "tests/test_ci_virtual_display_lock.sh": frozenset(("quality-runtime",)), + "tests/test_ci_workload_profiles.py": frozenset(("ci",)), + "tests/test_ci_xcode_selection_fast_path.sh": frozenset(("release-notary",)), + "tests/test_ci_xcodebuild_noninteractive_helper.py": frozenset(("app-host-execution",)), + "tests/test_ci_xcodebuild_with_diagnostics.sh": frozenset(("app-host-execution",)), + "tests/test_cleanup_stale_runs.py": frozenset(("ci",)), + "tests/test_cloud_vm_skill_coverage.py": frozenset(("preflight",)), + "tests/test_compress_markdown_viewer_assets.sh": frozenset(("release-notary",)), + "tests/test_dev_fleet_warm_slot.py": frozenset(("preflight",)), + "tests/test_dock_shortcut_routing_guard.py": frozenset(("quality-determinism",)), + "tests/test_docs_deploy_auth_guard.py": frozenset(("preflight",)), + "tests/test_fetch_previous_nightly_dmgs.sh": frozenset(("release-notary",)), + "tests/test_finalize_sparkle_deltas.sh": frozenset(("release-notary",)), + "tests/test_ghostty_cli_helper_cache.sh": frozenset(("release-tooling",)), + "tests/test_ghostty_zig_version_sync.sh": frozenset(("release-tooling",)), + "tests/test_github_release_publication.py": frozenset(("release-notary",)), + "tests/test_install_cmux_tui_client.sh": frozenset(("release-tooling",)), + "tests/test_install_zig_ci_download_resume.sh": frozenset(("release-tooling",)), + "tests/test_install_zig_ci_no_sudo.sh": frozenset(("release-tooling",)), + "tests/test_ios_appstore_lane_identity.py": frozenset(("release-ios",)), + "tests/test_ios_selected_test_execution.py": frozenset(("app-host-cache",)), + "tests/test_ios_testflight_external_distribution.py": frozenset(("release-ios",)), + "tests/test_ios_testflight_main_push_filter.py": frozenset(("release-ios",)), + "tests/test_ios_testflight_notes.py": frozenset(("release-ios",)), + "tests/test_ios_testflight_pro_distribution.py": frozenset(("release-ios",)), + "tests/test_ios_workflow_dispatch_ref.py": frozenset(("release-ios",)), + "tests/test_issue_5164_starship_prompt_composition.py": frozenset(("quality-determinism",)), + "tests/test_lint_xcstrings.py": frozenset(("preflight",)), + "tests/test_local_build_cache_policy.py": frozenset(("app-host-cache",)), + "tests/test_local_build_cache_preflight.py": frozenset(("app-host-cache",)), + "tests/test_localizable_xcstrings_structure.py": frozenset(("preflight",)), + "tests/test_localization_catalog.py": frozenset(("preflight",)), + "tests/test_localize_changes.py": frozenset(("preflight",)), + "tests/test_nightly_universal_build.sh": frozenset(("release-notary",)), + "tests/test_notarize_computer_use_helper.sh": frozenset(("release-notary",)), + "tests/test_notarize_nightly_dmg.sh": frozenset(("release-notary",)), + "tests/test_perf_activation_scrollback_sizing.py": frozenset(("ci",)), + "tests/test_preflight_trust.py": frozenset(("ci",)), + "tests/test_reload_local_backend_mode.sh": frozenset(("preflight",)), + "tests/test_reload_shared_derived_data.sh": frozenset(("preflight",)), + "tests/test_reuse_app_host_products.py": frozenset(("ci",)), + "tests/test_review_fabric.py": frozenset(("preflight",)), + "tests/test_run_e2e.py": frozenset(("app-host-execution",)), + "tests/test_sparkle_build_monotonic_modes.sh": frozenset(("release-notary",)), + "tests/test_sparkle_generate_appcast_no_deltas.sh": frozenset(("release-notary",)), + "tests/test_strip_release_bundle.sh": frozenset(("release-notary",)), + "tests/test_swift_incremental_diagnostics.py": frozenset(("preflight",)), + "tests/test_swift_testing_suite_timeout.py": frozenset(("app-host-execution",)), + "tests/test_tui_npm_package_artifact.py": frozenset(("preflight",)), + "tests/test_tui_package_contract.py": frozenset(("preflight",)), + "tests/test_web_complexity_trusted_workflow.py": frozenset(("ci",)), + "tests/test_write_sidebar_extension_point.py": frozenset(("preflight",)), +} + +# Changes here can alter which required work runs. They always exercise every +# group, including when the candidate implementation would route more narrowly. +ROUTING_POLICY_PATHS = frozenset({ + ".github/workflows/ci.yml", + ".github/workflows/ci-guards.yml", + "scripts/ci/detect_ci_change_areas.py", + "scripts/ci/detect_linux_guard_changes.py", + "scripts/ci/workflow_guard_groups.py", + "tests/test_ci_change_areas.py", + "tests/test_ci_linux_guard_routing.py", + "tests/test_ci_guard_workflow_structure.py", + "tests/test_ci_app_host_guard_structure.py", + "tests/test_ci_quality_guard_structure.py", + "tests/test_ci_release_guard_structure.py", +}) + +DETERMINISM_SUFFIXES = (".swift", ".py", ".sh", ".ts", ".tsx", ".js", ".mjs") + + +def _python_syntax_scan(path: str) -> bool: + return path.endswith(".py") and path.startswith(("tests/", "tests_v2/", "scripts/")) + + +def _determinism_scan(path: str) -> bool: + if not path.endswith(DETERMINISM_SUFFIXES): + return False + if path.startswith(("cmuxTests/", "cmuxUITests/", "ios/cmuxUITests/", + "tests/", "tests_v2/", "web/tests/", "webviews/test/")): + return True + return path.startswith("Packages/") and "/Tests/" in path + + +def groups_for_path(path: str) -> tuple[str, ...] | None: + """Return current observers for path, or None when ownership is unknown.""" + if path in ROUTING_POLICY_PATHS: + return GROUPS + + owners = set(PATH_OWNERS.get(path, ())) + if _python_syntax_scan(path): + owners.add("preflight") + if _determinism_scan(path): + owners.add("quality-determinism") + + if path == ".github/workflows/ios-testflight.yml": + owners.update(("preflight", "ci")) + + if not owners: + return None + return tuple(group for group in GROUPS if group in owners) diff --git a/tests/test_ci_app_host_guard_structure.py b/tests/test_ci_app_host_guard_structure.py index 35c476b59679..ea62f7ab192d 100644 --- a/tests/test_ci_app_host_guard_structure.py +++ b/tests/test_ci_app_host_guard_structure.py @@ -8,19 +8,7 @@ ROOT = Path(__file__).resolve().parents[1] GUARD_WORKFLOW = ROOT / ".github" / "workflows" / "ci-guards.yml" -EXPECTED_GROUPS = [ - "preflight", - "ci", - "app-host-execution", - "app-host-process", - "app-host-cache", - "release-ios", - "release-notary", - "release-tooling", - "quality-sharding", - "quality-runtime", - "quality-determinism", -] +MATRIX_GROUPS = "${{ fromJSON(inputs.linux_guard_test_groups) }}" def workflow_guard_job() -> dict: @@ -30,7 +18,7 @@ def workflow_guard_job() -> dict: def test_app_host_groups_are_parallel_and_owned() -> None: job = workflow_guard_job() - assert job["strategy"]["matrix"]["group"] == EXPECTED_GROUPS + assert job["strategy"]["matrix"]["group"] == MATRIX_GROUPS expected = { "Validate unit-test SwiftPM retry guard": "app-host-execution", diff --git a/tests/test_ci_change_areas.py b/tests/test_ci_change_areas.py index 14e0a9941996..301b5c6fc465 100644 --- a/tests/test_ci_change_areas.py +++ b/tests/test_ci_change_areas.py @@ -1939,6 +1939,10 @@ def test_guard_workflow_call_preserves_routes_and_static_gate() -> None: for route in GUARD_ROUTE_JOBS: assert f" {route}: ${{{{ needs.changes.outputs.{route} }}}}" in block assert f"needs.changes.outputs.{route} != 'false'" in block + assert ( + " linux_guard_test_groups: " + "${{ needs.changes.outputs.linux_guard_test_groups }}" + ) in block def test_app_host_failures_preserve_attempt_and_crash_diagnostics() -> None: diff --git a/tests/test_ci_linux_guard_routing.py b/tests/test_ci_linux_guard_routing.py index 55680ef191ab..cd81cd6ff22a 100644 --- a/tests/test_ci_linux_guard_routing.py +++ b/tests/test_ci_linux_guard_routing.py @@ -1,13 +1,17 @@ #!/usr/bin/env python3 """Exercise the Linux route CLI and the real required-status gate.""" +import json import os +import re import subprocess import sys import tempfile import unittest from pathlib import Path +import yaml + from test_ci_change_areas import ( linux_preflight_needs, run_guard_status, @@ -21,6 +25,8 @@ ROOT = Path(__file__).resolve().parents[1] HELPER = ROOT / "scripts/ci/detect_linux_guard_changes.py" +sys.path.insert(0, str(ROOT / "scripts" / "ci")) +from workflow_guard_groups import GROUPS, PATH_OWNERS, STEP_OWNERS JOBS = { "linux_guard_tests": "workflow-guard-tests", "linux_guard_history": "workflow-guard-history", @@ -33,7 +39,7 @@ } -def route(paths, event="pull_request", macos="false"): +def route_decision(paths, event="pull_request", macos="false"): with tempfile.TemporaryDirectory(prefix="cmux-linux-routes-") as temp: path = Path(temp) / "changed.txt" if paths is not None: @@ -43,13 +49,24 @@ def route(paths, event="pull_request", macos="false"): "--macos", macos, "--files-from", str(path)], capture_output=True, text=True, check=True, ) - return dict(line.split("=", 1) for line in result.stdout.splitlines()) + outputs = dict(line.split("=", 1) for line in result.stdout.splitlines()) + groups = tuple(json.loads(outputs.pop("linux_guard_test_groups"))) + return outputs, groups + + +def route(paths, event="pull_request", macos="false"): + return route_decision(paths, event=event, macos=macos)[0] class LinuxGuardRoutingTests(unittest.TestCase): def test_candidate_router_cannot_disable_its_own_guards(self): script = workflow_job_step_script("changes", "Route Linux guard suites") - for changed in ("scripts/ci/detect_linux_guard_changes.py", ".github/workflows/ci.yml"): + for changed in ( + "scripts/ci/detect_linux_guard_changes.py", + "scripts/ci/workflow_guard_groups.py", + ".github/workflows/ci.yml", + ".github/workflows/ci-guards.yml", + ): with self.subTest(changed=changed), tempfile.TemporaryDirectory() as temp: root = Path(temp) changed_file = root / "changed.txt" @@ -65,8 +82,73 @@ def test_candidate_router_cannot_disable_its_own_guards(self): "EVENT_NAME": "pull_request", "MACOS": "false"}, ) self.assertEqual(result.returncode, 0, result.stderr) - self.assertEqual(dict(line.split("=", 1) for line in output.read_text().splitlines()), - dict.fromkeys(JOBS, "true")) + routed = dict(line.split("=", 1) for line in output.read_text().splitlines()) + groups = tuple(json.loads(routed.pop("linux_guard_test_groups"))) + self.assertEqual(routed, dict.fromkeys(JOBS, "true")) + self.assertEqual(groups, GROUPS) + + def test_testflight_change_routes_only_observing_test_groups(self): + outputs, groups = route_decision([ + ".github/workflows/ios-testflight.yml", + "ios/scripts/upload-testflight.sh", + "tests/test_ios_appstore_lane_identity.py", + ]) + # Preserve the outer fail-open routes for workflow edits while trimming + # only the expensive workflow-guard-tests matrix. + self.assertEqual(outputs, dict.fromkeys(JOBS, "true")) + self.assertEqual( + groups, + ("preflight", "ci", "release-ios", "quality-determinism"), + ) + + def test_unknown_and_policy_inputs_fail_open_to_every_test_group(self): + for changed in ( + "new-area/input", + ".github/workflows/ci-guards.yml", + "scripts/ci/workflow_guard_groups.py", + "tests/test_ci_release_guard_structure.py", + ): + with self.subTest(changed=changed): + _, groups = route_decision([changed]) + self.assertEqual(groups, GROUPS) + + def test_guard_step_ownership_manifest_matches_workflow(self): + workflow = yaml.safe_load( + (ROOT / ".github/workflows/ci-guards.yml").read_text(encoding="utf-8") + ) + job = workflow["jobs"]["workflow-guard-tests"] + self.assertEqual( + job["strategy"]["matrix"]["group"], + "${{ fromJSON(inputs.linux_guard_test_groups) }}", + ) + + actual = {} + direct_path = re.compile( + r"(?:\./)?((?:tests(?:_v2)?|scripts|ios/tests)/[A-Za-z0-9_./-]+)" + ) + for step in job["steps"]: + condition = step.get("if", "") + match = re.fullmatch(r"\$\{\{ matrix\.group == '([^']+)' \}\}", condition) + if match is None: + continue + name = step["name"] + group = match.group(1) + self.assertNotIn(name, actual) + actual[name] = group + + paths = set(direct_path.findall(step.get("run", ""))) + if ( + step.get("working-directory") == "agent-chat" + and "test/claude-environment.test.ts" in step.get("run", "") + ): + paths.add("agent-chat/test/claude-environment.test.ts") + if name == "Initialize Ghostty for Zig version guard": + paths.add("ghostty") + for path in paths: + self.assertIn(path, PATH_OWNERS, (name, path)) + self.assertIn(group, PATH_OWNERS[path], (name, path, group)) + + self.assertEqual(actual, STEP_OWNERS) def test_linux_preflight_skips_when_macos_route_is_false(self): block = workflow_job_block("linux-preflight") diff --git a/tests/test_ci_quality_guard_structure.py b/tests/test_ci_quality_guard_structure.py index 3c020390bd13..427452642413 100644 --- a/tests/test_ci_quality_guard_structure.py +++ b/tests/test_ci_quality_guard_structure.py @@ -8,19 +8,7 @@ ROOT = Path(__file__).resolve().parents[1] GUARD_WORKFLOW = ROOT / ".github" / "workflows" / "ci-guards.yml" -EXPECTED_GROUPS = [ - "preflight", - "ci", - "app-host-execution", - "app-host-process", - "app-host-cache", - "release-ios", - "release-notary", - "release-tooling", - "quality-sharding", - "quality-runtime", - "quality-determinism", -] +MATRIX_GROUPS = "${{ fromJSON(inputs.linux_guard_test_groups) }}" def workflow_guard_job() -> dict: @@ -30,7 +18,7 @@ def workflow_guard_job() -> dict: def test_quality_groups_are_parallel_and_owned() -> None: job = workflow_guard_job() - assert job["strategy"]["matrix"]["group"] == EXPECTED_GROUPS + assert job["strategy"]["matrix"]["group"] == MATRIX_GROUPS expected = { "Validate cmuxTests sharding": "quality-sharding", diff --git a/tests/test_ci_release_guard_structure.py b/tests/test_ci_release_guard_structure.py index 1a866989af33..bdf0ae3138e6 100644 --- a/tests/test_ci_release_guard_structure.py +++ b/tests/test_ci_release_guard_structure.py @@ -30,11 +30,7 @@ def workflow_job_block(job_name: str) -> str: def test_release_groups_are_parallel_and_owned() -> None: block = workflow_job_block("workflow-guard-tests") - assert ( - "group: [preflight, ci, app-host-execution, app-host-process, " - "app-host-cache, release-ios, release-notary, release-tooling, " - "quality-sharding, quality-runtime, quality-determinism]" - ) in block + assert "group: ${{ fromJSON(inputs.linux_guard_test_groups) }}" in block expected = { "Validate TestFlight notes generator": "release-ios",