diff --git a/scripts/ci/product_input_identity.py b/scripts/ci/product_input_identity.py index 6cd8c5c70f36..a2828608bcb0 100644 --- a/scripts/ci/product_input_identity.py +++ b/scripts/ci/product_input_identity.py @@ -36,6 +36,28 @@ # Changing it changes product bytes, so it has to invalidate reuse. PRODUCT_WORKER_PREFIXES = ("workers/cmux-paste-text/",) +# Developer and maintenance tooling that neither the Xcode build nor any macOS +# CI lane reads: no build phase, compile helper, bundled-resource script, or +# ci-macos.yml / test-e2e.yml step names them, and no native test executes +# them. agent-chat/ is the standalone chat server a user starts with cmux-chat; +# the app only connects to it. Each keeps its own Linux guard. Keep this exact: +# scripts/ also holds the build phases' helpers, which must stay product inputs. +NON_PRODUCT_TOOLING_PREFIXES = ( + ".claude/", + "agent-chat/", + "scripts/git-hooks/", +) +NON_PRODUCT_TOOLING = frozenset({ + "scripts/benchmark-dev-fleet-warm-slots.py", + "scripts/check-pbxproj.sh", + "scripts/check-test-determinism.py", + "scripts/dev-fleet-warm-slot.py", + "scripts/install-git-hooks.sh", + "scripts/merge-xcstrings.py", + "scripts/normalize-pbxproj.py", + "scripts/prune_nightly_release_assets.py", +}) + REQUIRED_PRODUCT_JOB_ENV_KEYS = frozenset({ "CMUX_CI_XCODE_APP", "CMUX_CI_REQUIRED_MACOS_SDK_MAJOR", @@ -116,6 +138,8 @@ def reaches_product(path: str) -> bool: return True if path.startswith("scripts/ci/"): return False + if path in NON_PRODUCT_TOOLING or path.startswith(NON_PRODUCT_TOOLING_PREFIXES): + return False if path.startswith((".github/", "tests/", "tests_v2/", "docs/", "design/", "plans/", "ios/", "web/", "workers/", "config/iroh/", "cmux-tui/", "cmux-browser/", "daemon/remote/")): return False if path in {".vercelignore", "vercel.json"}: diff --git a/tests/test_reuse_app_host_products.py b/tests/test_reuse_app_host_products.py index cce5509b5ccc..02eae9bb8203 100644 --- a/tests/test_reuse_app_host_products.py +++ b/tests/test_reuse_app_host_products.py @@ -283,6 +283,56 @@ def mutate_admission(old: str, new: str) -> str: self.assertTrue(identity.reaches_product("scripts/ci/compile-app-host-test-product.sh")) self.assertTrue(identity.reaches_product("cmuxTests/WorkspaceTests.swift")) + def test_developer_tooling_outside_the_build_does_not_reach_product(self): + """Editing these must not force a compile: no build or macOS lane reads them.""" + identity = reuse.product_inputs + tooling = ( + ".claude/commands/review.md", + "agent-chat/server.ts", + "agent-chat/src/components/Chat.tsx", + "scripts/git-hooks/pre-commit", + "scripts/benchmark-dev-fleet-warm-slots.py", + "scripts/check-pbxproj.sh", + "scripts/check-test-determinism.py", + "scripts/dev-fleet-warm-slot.py", + "scripts/install-git-hooks.sh", + "scripts/merge-xcstrings.py", + "scripts/normalize-pbxproj.py", + "scripts/prune_nightly_release_assets.py", + ) + for path in tooling: + self.assertFalse(identity.reaches_product(path), path) + + # Neighbours that the build does read stay product inputs. + for path in ( + "scripts/build-app-bundled-resources.sh", + "scripts/build-plain-text-paste-worker.sh", + "scripts/setup.sh", + "skills/cmux-cua/SKILL.md", + ".gitattributes", + ): + self.assertTrue(identity.reaches_product(path), path) + + # Drift guard: if the Xcode project, the compile script, or either + # product workflow starts naming one of these, it is a build input again. + root = Path(__file__).resolve().parents[1] + readers = { + name: (root / name).read_text() + for name in ( + "cmux.xcodeproj/project.pbxproj", + "scripts/ci/compile-app-host-test-product.sh", + "scripts/build-app-bundled-resources.sh", + ".github/workflows/ci-macos.yml", + ".github/workflows/test-e2e.yml", + ) + } + # Check the module's own lists, not the samples above, so a reader + # naming any file under an excluded prefix fails here too. + needles = sorted(identity.NON_PRODUCT_TOOLING) + list(identity.NON_PRODUCT_TOOLING_PREFIXES) + for needle in needles: + for name, text in readers.items(): + self.assertNotIn(needle, text, f"{name} reads {needle}") + def test_product_identity_binds_the_e2e_build_recipe(self): identity = reuse.product_inputs root = Path(__file__).resolve().parents[1]