diff --git a/README.md b/README.md index 9a3f9ad..d82ee74 100644 --- a/README.md +++ b/README.md @@ -496,9 +496,106 @@ See [#83][pr-83] for more context. ### OSX: sysroot For non-trivial programs (and for all darwin/arm64 cgo programs) MacOS SDK may -be necessary. Read [Jakub's comment][sysroot] about it. Support for OSX sysroot -is currently not implemented, but patches implementing it will be accepted, as -long as the OSX sysroot must come through an `http_archive`. +be necessary. Read [Jakub's comment][sysroot] about it. + +The `toolchains` module extension accepts an optional `sysroot` tag, +configuring an external sysroot (e.g. a macOS SDK) for the zig cc toolchains +targeting a given OS. It is entirely optional and backward-compatible: with no +`sysroot` tag, toolchains behave exactly as before. As discussed in [#10][pr-10], +the sysroot itself must come from an `http_archive` (or another repo rule +producing an equivalent filegroup) — `hermetic_cc_toolchain` does not fetch or +redistribute Apple's SDK itself. + +```starlark +sysroot( + os, # "linux" | "windows" | "macos" + path = "", # exec-root-relative path to the sysroot + include_dirs = [], # extra cxx_builtin_include_directories (e.g. /usr/include) + copts = [], # extra compiler flags, e.g. "-F /System/Library/Frameworks" + linkopts = [], # extra linker flags + files = "", # label (as a string) of a filegroup staging the sysroot's files +) +``` + +`path`/`include_dirs` are registered as `builtin_sysroot`/ +`cxx_builtin_include_directories` so Bazel's header-inclusion validation +accepts headers found there without forcing every SDK header onto the +compiler's `-I` search path ahead of libc++'s own headers (`include_dirs` are +searched with `-idirafter`, the lowest priority, after the toolchain's own +headers). `files` is required for the sysroot to work on actions that don't +otherwise declare it as a dependency (Bazel does not create the external +repo's symlink in the sandbox otherwise, and the `-I`/`-isysroot` paths would +dangle). + +Two ways to get the SDK into a `files` filegroup: + +**1. A prebuilt redistributable mirror**, e.g. +[`joseluisq/macosx-sdks`](https://github.com/joseluisq/macosx-sdks) (a widely +used mirror of the Xcode Command Line Tools SDK): + +```starlark +http_archive = use_repo_rule("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive") + +http_archive( + name = "macos_sdk", + build_file_content = """\ +filegroup( + name = "sysroot", + srcs = glob(["usr/include/**", "usr/lib/**", "System/Library/Frameworks/**", "SDKSettings.*"]), + visibility = ["//visibility:public"], +) +""", + sha256 = "6e146275d19f027faa2e8354da5e0267513abf013b8f16ad65a231653a2b1c5d", + strip_prefix = "MacOSX14.5.sdk", + urls = ["https://github.com/joseluisq/macosx-sdks/releases/download/14.5/MacOSX14.5.sdk.tar.xz"], +) + +zig_toolchains = use_extension("@hermetic_cc_toolchain//toolchain:ext.bzl", "toolchains") +zig_toolchains.sysroot( + os = "macos", + path = "external/+http_archive+macos_sdk", + include_dirs = ["external/+http_archive+macos_sdk/usr/include"], + copts = ["-iframework", "external/+http_archive+macos_sdk/System/Library/Frameworks"], + files = "@@+http_archive+macos_sdk//:sysroot", +) +``` + +**2. Apple's own CDN**, downloading the Command Line Tools `.pkg` directly +(the approach [openai/codex](https://github.com/openai/codex) takes for its +own, unrelated `llvm`-based toolchain — `http_archive`'s `type = "pkg"` +support extracts a `.pkg` payload the same way it extracts a `.tar.gz`): + +```starlark +http_archive( + name = "macos_sdk", + build_file_content = """\ +filegroup( + name = "sysroot", + srcs = glob(["usr/include/**", "usr/lib/**", "System/Library/Frameworks/**"]), + visibility = ["//visibility:public"], +) +""", + sha256 = "5f044578cd78a3a9b9c965a42d56bad609ee5d252e1d4e6aa7c42fc3f35fee7b", + strip_prefix = "Payload/Library/Developer/CommandLineTools/SDKs/MacOSX26.5.sdk", + type = "pkg", + urls = ["https://swcdn.apple.com/content/downloads/09/08/047-91568-A_Y1CFZWQCD4/4xekpyz43i26dbp4enxfro8eb1q7wiujh5/CLTools_macOSNMOS_SDK.pkg"], +) + +zig_toolchains.sysroot( + os = "macos", + path = "external/+http_archive+macos_sdk", + include_dirs = ["external/+http_archive+macos_sdk/usr/include"], + files = "@@+http_archive+macos_sdk//:sysroot", +) +``` + +Known limitation: pure zig-cc `-framework` linking of a cross target +(`aarch64-macos-none` from a non-macOS host) is still limited — zig resolves +the top-level `.tbd` but its absolute `/usr/lib` reexports aren't re-rooted at +the sysroot (see [ziglang/zig#10299][sysroot]). Consumers with a raw +non-zig-cc linker in the loop (e.g. `rules_rust`'s `rust-lld`, or a +`rules_foreign_cc`/cmake build) aren't affected, since they never go through +zig cc's own linker driver for the final link. In essence, OSX target support is not well tested with `hermetic_cc_toolchain`. Also see [#10][pr-10]. diff --git a/toolchain/BUILD.sdk.bazel b/toolchain/BUILD.sdk.bazel index 686b565..610a3cd 100644 --- a/toolchain/BUILD.sdk.bazel +++ b/toolchain/BUILD.sdk.bazel @@ -11,10 +11,12 @@ package( declare_files( os = {os}, + sysroots_json = {sysroots_json}, ) declare_cc_toolchains( os = {os}, + sysroots_json = {sysroots_json}, zig_sdk_path = {zig_sdk_path}, ) diff --git a/toolchain/defs.bzl b/toolchain/defs.bzl index 602a42d..1c50862 100644 --- a/toolchain/defs.bzl +++ b/toolchain/defs.bzl @@ -68,14 +68,25 @@ def toolchains( exec_platforms = {}, extra_exec_compatible_with = [], extra_target_compatible_with = [], - extra_target_settings = []): + extra_target_settings = [], + sysroots = {}): """ Download zig toolchain and declare bazel toolchains. The platforms are not registered automatically, that should be done by the user with register_toolchains() in the WORKSPACE file. See README for possible choices. + + sysroots: optional dict keyed by target os ("macos"/"linux"/"windows") + with values {path, include_dirs, copts, linkopts, files}. When set, the + zig cc toolchains for that os use the external sysroot (e.g. a macOS + SDK). See the `sysroot` tag in toolchain/ext.bzl. Backward-compatible: + empty by default. """ + # Repo rule attrs can't carry nested dicts, so serialize to JSON and + # decode inside the repository / the generated BUILD. + sysroots_json = json.encode(sysroots) + if not url_formats: if "dev" in version: mirror_format = URL_FORMAT_NIGHTLY.replace("https://ziglang.org/", "https://mirror.bazel.build/ziglang.org/") @@ -98,6 +109,7 @@ def toolchains( url_formats = url_formats, host_platform_sha256 = host_platform_sha256, host_platform_ext = host_platform_ext, + sysroots_json = sysroots_json, ) private_repos = ["zig_config"] @@ -112,6 +124,7 @@ def toolchains( host_platform_ext = host_platform_ext, exec_os = os, exec_arch = arch, + sysroots_json = sysroots_json, ) private_repos.append("zig_config-{}-{}".format(os, arch)) @@ -175,6 +188,7 @@ def _zig_repository_impl(repository_ctx): "{os}": quote(exec_os), "{exec_os}": exec_os, "{exec_cpu}": exec_arch, + "{sysroots_json}": quote(repository_ctx.attr.sysroots_json), }, ) @@ -289,6 +303,7 @@ zig_repository = repository_rule( "host_platform_ext": attr.string_dict(), "exec_os": attr.string(default = "HOST"), "exec_arch": attr.string(default = "HOST"), + "sysroots_json": attr.string(default = "{}"), }, environ = ["HERMETIC_CC_TOOLCHAIN_CACHE_PREFIX"], implementation = _zig_repository_impl, @@ -298,9 +313,27 @@ def filegroup(name, **kwargs): native.filegroup(name = name, **kwargs) return ":" + name -def declare_files(os): +def declare_files(os, sysroots_json = "{}"): exe = ".exe" if os == "windows" else "" + # Per-target-OS sysroot file labels to STAGE into compile/link actions, so + # the sysroot's -I/-isysroot paths resolve in the exec root (see the + # `files` attr in toolchain/ext.bzl). Keyed by the "--" triple token. + sysroots = json.decode(sysroots_json) if sysroots_json else {} + _sysroot_token_files = {} + for _os_name, _cfg in sysroots.items(): + _files = _cfg.get("files", "") + if _files: + _token = {"linux": "-linux-", "macos": "-macos-", "windows": "-windows-"}.get(_os_name) + if _token: + _sysroot_token_files[_token] = _files + + def _sysroot_files_for(zigtarget): + for _token, _lbl in _sysroot_token_files.items(): + if _token in zigtarget: + return [_lbl] + return [] + native.exports_files(["zig{}".format(exe)], visibility = ["//visibility:public"]) if os == "windows": native.alias(name = "zig", actual = ":zig.exe") @@ -325,13 +358,15 @@ def declare_files(os): srcs = _flatten(all_includes), ) + sysroot_files = _sysroot_files_for(target_config.zigtarget) + filegroup( name = "{}_compiler_files".format(target_config.zigtarget), srcs = [ ":zig", ":{}_includes".format(target_config.zigtarget), cxx_tool_label, - ], + ] + sysroot_files, ) filegroup( @@ -355,7 +390,7 @@ def declare_files(os): "lib/c/**", "lib/*.zig", "lib/*.h", - ]), + ]) + sysroot_files, ) filegroup( diff --git a/toolchain/ext.bzl b/toolchain/ext.bzl index 9c8d82e..bfcef00 100644 --- a/toolchain/ext.bzl +++ b/toolchain/ext.bzl @@ -36,6 +36,48 @@ _extra_exec_compatible_with = tag_class( doc = "Extra constraints added to every toolchain's `exec_compatible_with`", ) +_sysroot = tag_class( + attrs = { + "os": attr.string( + values = ["linux", "windows", "macos"], + mandatory = True, + doc = "Target OS whose zig cc toolchains should use this sysroot.", + ), + "path": attr.string( + doc = "Exec-root-relative path to the sysroot. Passed as " + + "`--sysroot ` (and `-isysroot ` on macos). " + + "May be empty if only include_dirs/copts/linkopts are needed.", + ), + "include_dirs": attr.string_list( + doc = "Extra directories registered as the toolchain's " + + "cxx_builtin_include_directories (so Bazel's header " + + "validation accepts headers found there). Typically " + + "`/usr/include` and the macOS framework roots.", + ), + "copts": attr.string_list( + doc = "Extra compiler flags for this OS's toolchains, e.g. " + + "`-F /System/Library/Frameworks`.", + ), + "linkopts": attr.string_list( + doc = "Extra linker flags for this OS's toolchains, e.g. " + + "`-F /System/Library/Frameworks` and `-L /usr/lib`.", + ), + "files": attr.string( + doc = "Optional label (as a string, e.g. \"@macos_sdk//:sysroot\") " + + "of a filegroup of the sysroot's files. It is added to the " + + "toolchains' compiler_files/linker_files so every compile/" + + "link action STAGES the sysroot into the exec root (Bazel " + + "does not create the external symlink otherwise, and the " + + "-I/-isysroot paths would dangle). Required for the sysroot " + + "to work on actions that don't otherwise depend on it (e.g. " + + "the @m4 BCR compile).", + ), + }, + doc = "Configure an external sysroot (e.g. a macOS SDK) for the zig cc " + + "toolchains targeting a given OS. Optional and backward-compatible: " + + "with no sysroot tag the toolchains behave exactly as before.", +) + def _toolchains_impl(mctx): exec_platforms = {} root_direct_deps = [] @@ -44,6 +86,7 @@ def _toolchains_impl(mctx): extra_exec_compatible_with = [] extra_target_compatible_with = [] extra_target_settings = [] + sysroots = {} for mod in mctx.modules: if mod.is_root: @@ -62,11 +105,21 @@ def _toolchains_impl(mctx): for tag in mod.tags.extra_target_compatible_with: extra_target_compatible_with += tag.constraints + for tag in mod.tags.sysroot: + sysroots[tag.os] = { + "path": tag.path, + "include_dirs": tag.include_dirs, + "copts": tag.copts, + "linkopts": tag.linkopts, + "files": tag.files, + } + repos = zig_toolchains( exec_platforms = exec_platforms, extra_exec_compatible_with = extra_exec_compatible_with, extra_target_compatible_with = extra_target_compatible_with, extra_target_settings = extra_target_settings, + sysroots = sysroots, ) root_direct_deps = list(repos.public) if is_non_dev_dependency else [] @@ -89,5 +142,6 @@ toolchains = module_extension( "extra_exec_compatible_with": _extra_exec_compatible_with, "extra_target_compatible_with": _extra_target_compatible_with, "extra_target_settings": _extra_target_settings, + "sysroot": _sysroot, }, ) diff --git a/toolchain/private/cc_toolchains.bzl b/toolchain/private/cc_toolchains.bzl index efadf18..9d2436d 100644 --- a/toolchain/private/cc_toolchains.bzl +++ b/toolchain/private/cc_toolchains.bzl @@ -2,13 +2,31 @@ load("@hermetic_cc_toolchain//toolchain:zig_cc_toolchain.bzl", "zig_cc_toolchain load("@rules_cc//cc/toolchains:cc_toolchain.bzl", "cc_toolchain") load(":defs.bzl", "target_structs", "zig_tool_path") -def declare_cc_toolchains(os, zig_sdk_path): +# Maps the target OS token used by the `sysroot` config (linux/macos/windows) +# to the substring present in every zig target triple for that OS. +_OS_TRIPLE_TOKEN = { + "linux": "-linux-", + "macos": "-macos-", + "windows": "-windows-", +} + +def _sysroot_for(zigtarget, sysroots): + """Return the sysroot config dict for zigtarget's OS, or None.""" + for os_name, cfg in sysroots.items(): + token = _OS_TRIPLE_TOKEN.get(os_name) + if token and token in zigtarget: + return cfg + return None + +def declare_cc_toolchains(os, zig_sdk_path, sysroots_json = "{}"): exe = ".exe" if os == "windows" else "" + sysroots = json.decode(sysroots_json) if sysroots_json else {} for target_config in target_structs(): gotarget = target_config.gotarget zigtarget = target_config.zigtarget cxx_builtin_include_directories = [] + builtin_sysroot = "" tool_paths = {} @@ -32,6 +50,84 @@ def declare_cc_toolchains(os, zig_sdk_path): copts = target_config.copts linkopts = target_config.linkopts + # Optional external sysroot (e.g. a macOS SDK) for this target OS. The + # sysroot's include dirs are registered as cxx_builtin_include_directories + # so Bazel's header-inclusion validation accepts headers found there + # (a plain -idirafter copt would trip "undeclared inclusion"). The + # --sysroot / -isysroot flags point zig cc at the SDK's libc + frameworks. + sysroot_cfg = _sysroot_for(zigtarget, sysroots) + if sysroot_cfg: + path = sysroot_cfg.get("path", "") + is_macos = "-macos-" in zigtarget + + # Setting builtin_sysroot makes Bazel (a) resolve the sysroot symlink + # when validating header inclusions, so headers found under it are + # exempt from "undeclared inclusion" errors WITHOUT staging the whole + # SDK as inputs, and (b) substitute %sysroot% in the include dirs + # below. It also makes Bazel pass `--sysroot=` automatically to + # compile AND link. On macOS that `--sysroot` reaches zig cc which, + # being a clang driver, forwards it to ld64 as `-syslibroot` — fine. + builtin_sysroot = path + + # We suppress Bazel's implicit `--sysroot` flag (see + # suppress_builtin_sysroot_flag in zig_cc_toolchain.bzl) so it never + # reaches raw linkers like rust-lld. Instead pass the sysroot to the + # COMPILER explicitly here, on compile actions only. + sysroot_copts = [] + sysroot_linkopts = [] + if path: + sysroot_copts += ["--sysroot", path] + if is_macos: + # zig/clang find the SDK headers & frameworks via -isysroot + # on macOS. Also emit it on link: ld64 (through zig's clang + # driver on a cc_binary/cc_library CppLink) derives the + # framework search root / .tbd reexport chain from -isysroot. + # NB: pure zig-cc `-framework` linking of a cross target + # (aarch64-macos-none) is still limited — zig resolves the + # top .tbd but its absolute /usr/lib reexports aren't + # re-rooted at the sysroot (see ziglang/zig#10299). The real + # consumers avoid this: rust links use rules_rust's own + # rust-lld, and swipl's framework link happens inside cmake. + sysroot_copts += ["-isysroot", path] + sysroot_linkopts += ["-isysroot", path] + + # Search the SDK include dirs with -idirafter (LOWEST priority, AFTER + # the compiler's own libc++/libc headers) rather than -I. zig + # cross-compiling to *-macos-none does NOT derive usr/include from + # --sysroot/-isysroot, so the dirs must be named explicitly (else + # `'libc.h' file not found`); but -I would place the SDK's + # etc. BEFORE libc++'s wrappers and break C++ (` tried + # including but didn't find libc++'s`). -idirafter keeps + # the SDK as a fallback for Apple-only headers (libc.h, mach/*, + # frameworks) without shadowing the toolchain's own headers. + for d in sysroot_cfg.get("include_dirs", []): + sysroot_copts += ["-idirafter", d] + sysroot_copts += list(sysroot_cfg.get("copts", [])) + sysroot_linkopts += list(sysroot_cfg.get("linkopts", [])) + copts = copts + sysroot_copts + linkopts = linkopts + sysroot_linkopts + + # Register the SDK include dirs as cxx_builtin_include_directories + # for VALIDATION only (exempt SDK headers from "undeclared + # inclusion"). They are registered in the %sysroot% form ONLY — + # zig_cc_toolchain.bzl strips %-prefixed entries from the emitted -I + # flags, so these never shadow libc++ (the search is the -idirafter + # above). builtin_sysroot resolves the external symlink so the + # %sysroot% dir matches the .d-recorded header paths + # (external/.../usr/include/...). Any dir outside `path` is + # registered literally (it can't be %sysroot%-relative); such a dir + # WOULD become a -I, so callers should keep include_dirs under path. + for d in sysroot_cfg.get("include_dirs", []): + if path and (d == path or d.startswith(path + "/")): + cxx_builtin_include_directories = ( + cxx_builtin_include_directories + + ["%sysroot%" + d[len(path):]] + ) + else: + cxx_builtin_include_directories = ( + cxx_builtin_include_directories + [d] + ) + # We can't pass a list of structs to a rule, so we use json encoding. artifact_name_patterns = getattr(target_config, "artifact_name_patterns", []) artifact_name_pattern_strings = [json.encode(p) for p in artifact_name_patterns] @@ -41,6 +137,7 @@ def declare_cc_toolchains(os, zig_sdk_path): target = zigtarget, tool_paths = tool_paths, cxx_builtin_include_directories = cxx_builtin_include_directories, + builtin_sysroot = builtin_sysroot, copts = copts, linkopts = linkopts, dynamic_library_linkopts = dynamic_library_linkopts, diff --git a/toolchain/zig_cc_toolchain.bzl b/toolchain/zig_cc_toolchain.bzl index 25610bd..6ab61ae 100644 --- a/toolchain/zig_cc_toolchain.bzl +++ b/toolchain/zig_cc_toolchain.bzl @@ -100,8 +100,13 @@ def _compilation_mode_features(ctx): def _zig_cc_toolchain_config_impl(ctx): compiler_flags = [ + # Special Bazel tokens (%sysroot%, %workspace%, %package(...)%) are + # resolved by Bazel for the undeclared-inclusion exemption but must NOT + # be emitted as literal -I flags (the compiler can't understand them). + # For a %sysroot% dir the search path already comes from -isysroot. "-I" + d for d in ctx.attr.cxx_builtin_include_directories + if not d.startswith("%") ] + [ "-no-canonical-prefixes", "-Wno-builtin-macro-redefined", @@ -191,6 +196,20 @@ def _zig_cc_toolchain_config_impl(ctx): soname_feature, ] + _compilation_mode_features(ctx) + # When builtin_sysroot is set purely to satisfy Bazel's header-inclusion + # validation (headers found under the sysroot are exempt from "undeclared + # inclusion"), Bazel's implicit `sysroot` feature would ALSO append + # `--sysroot=` to every compile AND link. That breaks consumers that + # invoke a raw linker not going through `zig cc` (e.g. rules_rust calls + # rust-lld directly, which rejects `--sysroot=` in its darwin/ld64 flavor). + # Defining an (empty, enabled) feature named "sysroot" overrides the + # built-in one, so no automatic `--sysroot` is emitted. The caller supplies + # the sysroot to the *compiler* explicitly via copts (`--sysroot` / + # `-isysroot`) and the SDK link search paths via linkopts (`-F` / `-L`), + # which are the forms zig cc / ld64 understand. + if ctx.attr.builtin_sysroot and ctx.attr.suppress_builtin_sysroot_flag: + features.append(feature(name = "sysroot", enabled = True)) + artifact_name_patterns = [ artifact_name_pattern(**json.decode(p)) for p in ctx.attr.artifact_name_patterns @@ -212,6 +231,7 @@ def _zig_cc_toolchain_config_impl(ctx): for name, path in ctx.attr.tool_paths.items() ], cxx_builtin_include_directories = ctx.attr.cxx_builtin_include_directories, + builtin_sysroot = ctx.attr.builtin_sysroot or None, artifact_name_patterns = artifact_name_patterns, ) @@ -219,6 +239,8 @@ zig_cc_toolchain_config = rule( implementation = _zig_cc_toolchain_config_impl, attrs = { "cxx_builtin_include_directories": attr.string_list(), + "builtin_sysroot": attr.string(), + "suppress_builtin_sysroot_flag": attr.bool(default = True), "linkopts": attr.string_list(), "dynamic_library_linkopts": attr.string_list(), "supports_dynamic_linker": attr.bool(),