From 11d5a5b55c27f9f46df36069f85b94650633c25c Mon Sep 17 00:00:00 2001 From: Jeffrey Wall <265409180+jeffwall-curlewlabs@users.noreply.github.com> Date: Sun, 11 Oct 2026 17:26:20 -0700 Subject: [PATCH 1/2] Keep job-local Xcode selection out of default host identity Strict action environments use the global developer selection. Make discovery explicit and offer a tracked rule attribute for actions selecting another host developer directory, retaining its path and tool identity. The real Bazel platform check covers inherited overrides in running and fresh servers and explicitly selecting another installed Xcode. Unit tests, pinned Ruff, strict mypy and buildifier passed. --- README.md | 8 +++++ defs.bzl | 8 ++++- tests/check_platform.py | 68 ++++++++++++++++++++++++++++++++++++++--- 3 files changed, 79 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 73d6d28..551ffb0 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,13 @@ Not covered: - Linux distributions without dpkg, and Windows. Both fail with an error instead of producing a key that covers less than it claims. +On macOS the default key follows the global `xcode-select` selection, even when +the process starting Bazel exports `DEVELOPER_DIR`. Strict action environments +do not inherit that job-local override. If host-dependent actions explicitly +select a different developer directory, set the rule's `developer_dir` to match. +Actions that acquire Xcode declare that bundle as their own inputs instead. +The selected directory remains in the key because host tools can record its path. + ## When the key changes The rule asks Bazel to watch everything it read. When any of it changes, Bazel @@ -93,6 +100,7 @@ All attributes of `host_cache_key` are optional: | Attribute | Default | Use | | --- | --- | --- | +| `developer_dir` | empty | Global Xcode selection, or the directory explicitly selected by host-dependent actions. | | `parent` | `@platforms//host` | Your own host platform, when your build defines one. | | `search_path` | `/bin`, `/usr/bin`, `/usr/local/bin` | Add directories your actions find tools in. | | `homebrew_prefixes` | the three standard prefixes | Other Homebrew installations. | diff --git a/defs.bzl b/defs.bzl index a2fb8d7..572a626 100644 --- a/defs.bzl +++ b/defs.bzl @@ -23,7 +23,7 @@ def _host_cache_key_impl(rctx): command.extend(["--search-path", directory]) for prefix in rctx.attr.homebrew_prefixes: command.extend(["--homebrew-prefix", prefix]) - result = rctx.execute(command, environment = {"LC_ALL": "C"}, quiet = True) + result = rctx.execute(command, environment = {"DEVELOPER_DIR": rctx.attr.developer_dir, "LC_ALL": "C"}, quiet = True) if result.return_code != 0: fail("host_cache_key: computing the host key failed:\n" + result.stderr) report = json.decode(result.stdout) @@ -62,6 +62,12 @@ Hosts with identical tools share results. `:key.json` records the fields the key digests, for comparing two hosts. """, attrs = { + "developer_dir": attr.string( + default = "", + doc = "Developer directory used by macOS actions, or empty for the " + + "global xcode-select selection. Match an explicit action " + + "DEVELOPER_DIR here; job-local acquisition paths are not host tools.", + ), "exec_property": attr.string( default = "host-cache-key", doc = "Name of the execution property that carries the key.", diff --git a/tests/check_platform.py b/tests/check_platform.py index 59f53bb..1de9d47 100755 --- a/tests/check_platform.py +++ b/tests/check_platform.py @@ -12,6 +12,7 @@ import argparse import json +import os import platform import re import shutil @@ -33,7 +34,9 @@ """ -def consumer_module(module_root: Path, tools: Path, prefix: Path | None) -> str: +def consumer_module( + module_root: Path, tools: Path, prefix: Path | None, developer_dir: str = "" +) -> str: homebrew = f'homebrew_prefixes = ["{prefix}"],' if prefix is not None else "" search_path = ( f'search_path = ["/bin", "/usr/bin", "/usr/local/bin", "{tools}"],' @@ -50,6 +53,7 @@ def consumer_module(module_root: Path, tools: Path, prefix: Path | None) -> str: host_cache_key( name = "host_cache_key", + developer_dir = {json.dumps(developer_dir)}, {homebrew} {search_path} ) @@ -79,7 +83,9 @@ def __init__(self, bazel: str, workspace: Path, disk_cache: Path) -> None: self.disk_cache = disk_cache self.output_bases: list[Path] = [] - def build(self, output_base: Path) -> str: + def build( + self, output_base: Path, environment: dict[str, str] | None = None + ) -> str: if output_base not in self.output_bases: self.output_bases.append(output_base) completed = subprocess.run( @@ -93,6 +99,7 @@ def build(self, output_base: Path) -> str: f"--disk_cache={self.disk_cache}", ], cwd=self.workspace, + env=environment, capture_output=True, text=True, check=False, @@ -191,7 +198,9 @@ def check_watched_changes(bazel: str, module_root: Path, scratch: Path) -> None: consumer.shutdown() -def check_this_host(bazel: str, module_root: Path, scratch: Path) -> None: +def check_this_host( + bazel: str, module_root: Path, scratch: Path, alternate_developer_dir: str +) -> None: workspace = scratch / "defaults" workspace.mkdir() shutil.copy(module_root / ".bazelversion", workspace / ".bazelversion") @@ -202,6 +211,56 @@ def check_this_host(bazel: str, module_root: Path, scratch: Path) -> None: try: expect("default attributes", consumer.build(output_base), "executed") report = consumer.key_report(output_base) + if platform.system() == "Darwin": + # A workflow may export an acquired Xcode's job-local path; the + # default platform still describes the globally selected host tools. + overridden = {**os.environ, "DEVELOPER_DIR": str(scratch / "unselected")} + expect( + "inherited developer override, running server", + consumer.build(output_base, overridden), + "reused", + ) + fresh = scratch / "override-output-base" + expect( + "inherited developer override, fresh base", + consumer.build(fresh, overridden), + "disk cache hit", + ) + if consumer.key_report(fresh) != report: + raise SystemExit( + "check_platform: inherited Xcode selection changed the host key" + ) + if alternate_developer_dir: + (workspace / "MODULE.bazel").write_text( + consumer_module(module_root, Path(), None, alternate_developer_dir) + ) + expect( + "explicit developer selection, running server", + consumer.build(output_base), + "executed", + ) + changed = consumer.key_report(output_base) + changed_fields = changed["fields"] + assert isinstance(changed_fields, dict) + fields = report["fields"] + assert isinstance(fields, dict) + if changed_fields["developer_tools"] == fields["developer_tools"]: + raise SystemExit( + "check_platform: alternate Xcode must have different tool identity" + ) + expect( + "explicit developer selection, fresh base", + consumer.build(fresh), + "disk cache hit", + ) + (workspace / "MODULE.bazel").write_text( + consumer_module(module_root, Path(), None) + ) + expect( + "global developer selection restored", + consumer.build(output_base), + "disk cache hit", + ) finally: consumer.shutdown() fields = report["fields"] @@ -220,12 +279,13 @@ def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--bazel", default="bazel") parser.add_argument("--module-root", type=Path, default=ROOT) + parser.add_argument("--alternate-developer-dir", default="") args = parser.parse_args() module_root = args.module_root.resolve() with tempfile.TemporaryDirectory() as directory: scratch = Path(directory).resolve() check_watched_changes(args.bazel, module_root, scratch) - check_this_host(args.bazel, module_root, scratch) + check_this_host(args.bazel, module_root, scratch, args.alternate_developer_dir) print("check_platform: host changes reach action keys", flush=True) From 5518b87492f0f4e9ca8cca6f7444011418275353 Mon Sep 17 00:00:00 2001 From: Jeffrey Wall <265409180+jeffwall-curlewlabs@users.noreply.github.com> Date: Sun, 11 Oct 2026 17:38:54 -0700 Subject: [PATCH 2/2] Demonstrate the inherited-selection regression against an older consumer Omit the optional developer attribute when using its default so the real platform check can also run against the previous rule. That run executes instead of hitting the disk cache with an inherited override; the fixed rule passes the same check. --- tests/check_platform.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/check_platform.py b/tests/check_platform.py index 1de9d47..eb93c2a 100755 --- a/tests/check_platform.py +++ b/tests/check_platform.py @@ -37,6 +37,7 @@ def consumer_module( module_root: Path, tools: Path, prefix: Path | None, developer_dir: str = "" ) -> str: + developer = f"developer_dir = {json.dumps(developer_dir)}," if developer_dir else "" homebrew = f'homebrew_prefixes = ["{prefix}"],' if prefix is not None else "" search_path = ( f'search_path = ["/bin", "/usr/bin", "/usr/local/bin", "{tools}"],' @@ -53,7 +54,7 @@ def consumer_module( host_cache_key( name = "host_cache_key", - developer_dir = {json.dumps(developer_dir)}, + {developer} {homebrew} {search_path} )