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..eb93c2a 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,10 @@ """ -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: + 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}"],' @@ -50,6 +54,7 @@ def consumer_module(module_root: Path, tools: Path, prefix: Path | None) -> str: host_cache_key( name = "host_cache_key", + {developer} {homebrew} {search_path} ) @@ -79,7 +84,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 +100,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 +199,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 +212,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 +280,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)