From 520772866c01bc740e9e659e2f99589326297bdb Mon Sep 17 00:00:00 2001 From: Denbeigh Stevens Date: Thu, 2 Apr 2026 14:56:08 +0000 Subject: [PATCH 1/7] update toolchains with compatible_withs --- bzlmod/extensions.bzl | 20 ++++++++++++++++++++ repositories/BUILD_internal.tpl | 4 ++-- repositories/elixir_config.bzl | 26 ++++++++++++++++++++++++++ 3 files changed, 48 insertions(+), 2 deletions(-) diff --git a/bzlmod/extensions.bzl b/bzlmod/extensions.bzl index 2ab286b..8b817fb 100644 --- a/bzlmod/extensions.bzl +++ b/bzlmod/extensions.bzl @@ -23,12 +23,17 @@ def _elixir_config(ctx): strip_prefixs = {} sha256s = {} elixir_homes = {} + target_compatible_withs = {} + exec_compatible_withs = {} for mod in ctx.modules: for elixir in mod.tags.external_elixir_from_path: types[elixir.name] = INSTALLATION_TYPE_EXTERNAL versions[elixir.name] = elixir.version elixir_homes[elixir.name] = elixir.elixir_home + target_compatible_withs[elixir.name] = [str(l) for l in elixir.target_compatible_with] + exec_compatible_withs[elixir.name] = [str(l) for l in elixir.exec_compatible_with] + for elixir in mod.tags.internal_elixir_from_http_archive: types[elixir.name] = INSTALLATION_TYPE_INTERNAL @@ -36,6 +41,8 @@ def _elixir_config(ctx): urls[elixir.name] = elixir.url strip_prefixs[elixir.name] = elixir.strip_prefix sha256s[elixir.name] = elixir.sha256 + target_compatible_withs[elixir.name] = [str(l) for l in elixir.target_compatible_with] + exec_compatible_withs[elixir.name] = [str(l) for l in elixir.exec_compatible_with] # Create repository for downloading and building Elixir source http_archive( @@ -65,6 +72,8 @@ elixir_build( urls[elixir.name] = url strip_prefixs[elixir.name] = strip_prefix sha256s[elixir.name] = elixir.sha256 + target_compatible_withs[elixir.name] = [str(l) for l in elixir.target_compatible_with] + exec_compatible_withs[elixir.name] = [str(l) for l in elixir.exec_compatible_with] # Create repository for downloading and building Elixir source http_archive( @@ -91,12 +100,18 @@ elixir_build( strip_prefixs = strip_prefixs, sha256s = sha256s, elixir_homes = elixir_homes, + exec_compatible_withs = exec_compatible_withs, + target_compatible_withs = target_compatible_withs, ) external_elixir_from_path = tag_class(attrs = { "name": attr.string(), "version": attr.string(), "elixir_home": attr.string(), + # It doesn't...really make sense to have an exec_compatible_with for an + # extenral toolchain that's already on the host? + "exec_compatible_with": attr.label_list(default = []), + "target_compatible_with": attr.label_list(default = []), }) internal_elixir_from_http_archive = tag_class(attrs = { @@ -105,6 +120,8 @@ internal_elixir_from_http_archive = tag_class(attrs = { "url": attr.string(), "strip_prefix": attr.string(), "sha256": attr.string(), + "exec_compatible_with": attr.label_list(default = []), + "target_compatible_with": attr.label_list(default = []), }) internal_elixir_from_github_release = tag_class(attrs = { @@ -117,6 +134,9 @@ internal_elixir_from_github_release = tag_class(attrs = { "sha256": attr.string( default = DEFAULT_ELIXIR_SHA256, ), + # NOTE: these should default to the host platform + "exec_compatible_with": attr.label_list(default = []), + "target_compatible_with": attr.label_list(default = []), }) elixir_config = module_extension( diff --git a/repositories/BUILD_internal.tpl b/repositories/BUILD_internal.tpl index e25a05e..efe0526 100644 --- a/repositories/BUILD_internal.tpl +++ b/repositories/BUILD_internal.tpl @@ -22,10 +22,10 @@ toolchain( name = "toolchain", exec_compatible_with = [ "//:elixir_internal", - ], +%{EXTRA_EXEC_CONSTRAINTS} ], target_compatible_with = [ "//:elixir_%{ELIXIR_VERSION_ID}", - ], +%{EXTRA_TARGET_CONSTRAINTS} ], toolchain = ":elixir", toolchain_type = "@rules_elixir//:toolchain_type", visibility = ["//visibility:public"], diff --git a/repositories/elixir_config.bzl b/repositories/elixir_config.bzl index 8e249a3..fe71916 100644 --- a/repositories/elixir_config.bzl +++ b/repositories/elixir_config.bzl @@ -12,6 +12,15 @@ _ELIXIR_VERSION_UNKNOWN = "UNKNOWN" INSTALLATION_TYPE_EXTERNAL = "external" INSTALLATION_TYPE_INTERNAL = "internal" +def _format_constraints(constraints): + if not constraints: + return "" + else: + return " " + ",\n ".join([ + ('"%s"' % constraint) + for constraint in constraints + ]) + def _version_identifier(version_string): parts = version_string.split(".", 2) if len(parts) > 1: @@ -36,6 +45,8 @@ def _impl(repository_ctx): strip_prefix = repository_ctx.attr.strip_prefixs.get(name, None), sha256 = repository_ctx.attr.sha256s.get(name, None), elixir_home = repository_ctx.attr.elixir_homes.get(name, None), + target_compatible_with = repository_ctx.attr.target_compatible_withs.get(name, None), + exec_compatible_with = repository_ctx.attr.exec_compatible_withs.get(name, None), ) for (name, props) in elixir_installations.items(): @@ -46,6 +57,9 @@ def _impl(repository_ctx): { "%{ELIXIR_HOME}": props.elixir_home, "%{ELIXIR_VERSION_ID}": props.identifier, + # again, i don't...really think it makes sense sense to do + # exec_compatible_with for stuff that's on the host system? + "%{EXTRA_TARGET_CONSTRAINTS}": _format_constraints(props.target_compatible_with), }, False, ) @@ -59,6 +73,8 @@ def _impl(repository_ctx): "%{SHA_256}": props.sha256 or "", "%{ELIXIR_VERSION_ID}": props.identifier, "%{ELIXIR_NAME}": name, + "%{EXTRA_EXEC_CONSTRAINTS}": _format_constraints(props.exec_compatible_with), + "%{EXTRA_TARGET_CONSTRAINTS}": _format_constraints(props.target_compatible_with), }, False, ) @@ -98,6 +114,8 @@ elixir_config = repository_rule( "strip_prefixs": attr.string_dict(), "sha256s": attr.string_dict(), "elixir_homes": attr.string_dict(), + "exec_compatible_withs": attr.string_list_dict(), + "target_compatible_withs": attr.string_list_dict(), }, environ = [ ELIXIR_HOME_ENV_VAR, @@ -160,6 +178,10 @@ def _default_elixir_dict(repository_ctx): version = version, identifier = identifier, elixir_home = elixir_home, + # XXX: this is probably not what we want to commit, but will be + # fine during iteration. + target_compatible_with = None, + exec_compatible_with = None, ), } else: @@ -169,6 +191,10 @@ def _default_elixir_dict(repository_ctx): version = _ELIXIR_VERSION_UNKNOWN, identifier = _ELIXIR_VERSION_UNKNOWN.lower(), elixir_home = elixir_home, + # XXX: this is probably not what we want to commit, but will be + # fine during iteration. + target_compatible_with = None, + exec_compatible_with = None, ), } From 86a11362e1d59fb92d3aa46f50c6f1bc051efe16 Mon Sep 17 00:00:00 2001 From: Denbeigh Stevens Date: Thu, 2 Apr 2026 14:56:58 +0000 Subject: [PATCH 2/7] WIP: architecture transitions for mix_* code --- defs.bzl | 10 +- private/beam_transitions.bzl | 16 +++ private/mix_library.bzl | 207 ++++++++++++++++++++++++++--------- 3 files changed, 177 insertions(+), 56 deletions(-) create mode 100644 private/beam_transitions.bzl diff --git a/defs.bzl b/defs.bzl index b889a2c..7b11e86 100644 --- a/defs.bzl +++ b/defs.bzl @@ -33,7 +33,7 @@ load( load("//private:elixir_sys_config.bzl", _elixir_sys_config = "elixir_sys_config") # mix-backed targets. Generally fairly reliable, and set-and-forget. -def mix_library(*args, **kwargs): +def mix_library(name, app_name, deps = [], **kwargs): """Compiles an Elixir library using Mix. Automatically adds dependencies for hex unless the app_name is "hex", @@ -41,11 +41,9 @@ def mix_library(*args, **kwargs): dependency is relevant in the current compilation context (even though we invoke in offline mode) """ - deps = kwargs.pop("deps", []) - if kwargs.get("app_name") != "hex": - deps.append(Label("@hex_pm//:lib")) - kwargs['deps'] = deps - _mix_library(*args, **kwargs) + if app_name != "hex": + deps = deps + [Label("@hex_pm//:lib")] + _mix_library(name = name, app_name = app_name, deps = deps, **kwargs) def mix_release(*args, **kwargs): _mix_release(*args, **kwargs) diff --git a/private/beam_transitions.bzl b/private/beam_transitions.bzl new file mode 100644 index 0000000..dab989f --- /dev/null +++ b/private/beam_transitions.bzl @@ -0,0 +1,16 @@ +"""Configuration transitions for platform-independent BEAM compilation. + +BEAM bytecode (.beam) and .app files are platform-independent artifacts. +This transition normalizes platform-related settings so that compilation +actions get the same configuration hash regardless of target platform, +enabling remote cache hits across platforms. +""" + +def _platform_independent_impl(settings, attr): + return {"//command_line_option:platforms": []} + +platform_independent_transition = transition( + implementation = _platform_independent_impl, + inputs = ["//command_line_option:platforms"], + outputs = ["//command_line_option:platforms"], +) diff --git a/private/mix_library.bzl b/private/mix_library.bzl index 33f0efb..f375138 100644 --- a/private/mix_library.bzl +++ b/private/mix_library.bzl @@ -9,6 +9,7 @@ load( "maybe_install_erlang", ) load("//private:mix_info.bzl", "MixProjectInfo") +load("//private:beam_transitions.bzl", "platform_independent_transition") def _priv_file_dest_relative_path(label, f): """Calculate the relative path for a priv file, preserving directory structure. @@ -39,24 +40,14 @@ def _elixir_erl_libs_args(paths): lib_dirs = " ".join(paths) return "-pa " + lib_dirs -def _mix_library_impl(ctx): +# --- _mix_compile: platform-independent compilation --- + +def _mix_compile_impl(ctx): # TODO: i don't _think_ we need to explicitly pass the output dir in, and # should instead return a Provider that can provide erlang...library info? # TBD # This also needs a better name - ebin = ctx.actions.declare_directory(ctx.label.name + "_ebin") - - # Create individual priv file symlinks rather than a directory artifact. - # A directory artifact named "priv" causes path doubling (priv/priv/...) - # in any consumer that places priv files under a priv/ directory, because - # additional_file_dest_relative_path returns "priv" which doesn't match - # the startswith("priv/") check. - priv_files = [] - for priv_file in ctx.files.priv: - rel_path = _priv_file_dest_relative_path(ctx.label, priv_file) - out = ctx.actions.declare_file(path_join("priv", rel_path)) - ctx.actions.symlink(output = out, target_file = priv_file) - priv_files.append(out) + ebin = ctx.actions.declare_directory("ebin") erl_libs_dir = ctx.label.name + "_deps" @@ -96,8 +87,9 @@ def _mix_library_impl(ctx): (erlang_home, _, erlang_runfiles) = erlang_dirs(ctx) (elixir_home, elixir_runfiles) = elixir_dirs(ctx) - all_deps = flat_deps(ctx.attr.deps) - + # Priv files are copied into the build dir as inputs (needed during + # compilation for NIFs, etc.) but NOT declared as outputs — priv output + # handling is done by _mix_priv in normal platform config. priv_copy_to_build_dir = "" if ctx.files.priv: build_dir_cmds = [] @@ -210,15 +202,90 @@ cp _output/{mix_env}/lib/{app_name}/ebin/*.beam _output/{mix_env}/lib/{app_name} mnemonic = "MIXCOMPILE", ) - dep_runfiles = [dep[DefaultInfo].default_runfiles for dep in ctx.attr.deps if DefaultInfo in dep] - lib_runfiles = ctx.runfiles(files = [ebin] + priv_files + ctx.files.data) - for dr in dep_runfiles: - lib_runfiles = lib_runfiles.merge(dr) + return [DefaultInfo(files = depset([ebin]))] + +_mix_compile = rule( + implementation = _mix_compile_impl, + cfg = platform_independent_transition, + attrs = { + "app_name": attr.string(mandatory = True), + "mix_env": attr.string( + default = "prod", + values = ["prod", "test", "dev"], + doc = "The MIX_ENV to use when compiling (default: prod)", + ), + "env": attr.string_dict(), + "mix_config": attr.label( + allow_single_file = [".exs"], + default = ":mix.exs", + ), + "srcs": attr.label_list( + # TODO: is there a more comprehensive place I can find all + # supported extensions than just adding them as I find them? There + # are a lot of weird extensions... + allow_files = [".ex", ".erl", ".xrl", ".hrl", ".app.src"], + ), + "data": attr.label_list( + allow_files = True, + ), + "deps": attr.label_list( + # TODO: need to confirm the provider we create also outputs this + providers = [ErlangAppInfo], + ), + "priv": attr.label_list( + allow_files = True, + ), + "include": attr.label_list( + allow_files = [".hrl"], + ), + # TODO: we should probably set a default for this? + "ez_deps": attr.label_list( + allow_files = [".ez"], + ), + }, + # TODO: confirm(??) (????) + toolchains = ["//:toolchain_type"], +) + +# --- _mix_priv: platform-dependent priv file handling --- + +def _mix_priv_impl(ctx): + # Create individual priv file symlinks rather than a directory artifact. + # A directory artifact named "priv" causes path doubling (priv/priv/...) + # in any consumer that places priv files under a priv/ directory, because + # additional_file_dest_relative_path returns "priv" which doesn't match + # the startswith("priv/") check. + priv_files = [] + for priv_file in ctx.files.priv: + rel_path = _priv_file_dest_relative_path(ctx.label, priv_file) + out = ctx.actions.declare_file(path_join("priv", rel_path)) + ctx.actions.symlink(output = out, target_file = priv_file) + priv_files.append(out) + + return [DefaultInfo(files = depset(priv_files))] + +_mix_priv = rule( + implementation = _mix_priv_impl, + attrs = { + "priv": attr.label_list( + allow_files = True, + ), + }, +) + +# --- _mix_library_info: aggregator providing ErlangAppInfo + MixProjectInfo --- + +def _mix_library_info_impl(ctx): + compile_files = ctx.attr.compile[DefaultInfo].files.to_list() + priv_files = [] + if ctx.attr.priv_target: + priv_files = ctx.attr.priv_target[DefaultInfo].files.to_list() + + all_deps = flat_deps(ctx.attr.deps) return [ DefaultInfo( - files = depset([ebin] + priv_files), - runfiles = lib_runfiles, + files = depset(compile_files + priv_files), ), MixProjectInfo( # app_name = ctx.attr.app_name, @@ -250,7 +317,7 @@ cp _output/{mix_env}/lib/{app_name}/ebin/*.beam _output/{mix_env}/lib/{app_name} deps = all_deps, srcs = ctx.attr.srcs, # TODO: beam? - beam = [ebin], + beam = compile_files, # Priv files with preserved directory structure priv = priv_files, # TODO: extra erlang libs to include? @@ -261,44 +328,84 @@ cp _output/{mix_env}/lib/{app_name}/ebin/*.beam _output/{mix_env}/lib/{app_name} ), ] -mix_library = rule( - implementation = _mix_library_impl, +_mix_library_info = rule( + implementation = _mix_library_info_impl, attrs = { - "app_name": attr.string(), - "mix_env": attr.string( - default = "prod", - values = ["prod", "test", "dev"], - doc = "The MIX_ENV to use when compiling (default: prod)", - ), - "env": attr.string_dict(), + "app_name": attr.string(mandatory = True), + "mix_env": attr.string(default = "prod"), "mix_config": attr.label( allow_single_file = [".exs"], default = ":mix.exs", ), - "srcs": attr.label_list( - # TODO: is there a more comprehensive place I can find all - # supported extensions than just adding them as I find them? There - # are a lot of weird extensions... - allow_files = [".ex", ".erl", ".xrl", ".hrl", ".app.src"], - ), - "data": attr.label_list( - allow_files = True, - ), + "compile": attr.label(mandatory = True), + "priv_target": attr.label(), "deps": attr.label_list( - # TODO: need to confirm the provider we create also outputs this providers = [ErlangAppInfo], ), - "priv": attr.label_list( - allow_files = True, + "srcs": attr.label_list( + allow_files = [".ex", ".erl", ".xrl", ".hrl", ".app.src"], ), "include": attr.label_list( allow_files = [".hrl"], ), - # TODO: we should probably set a default for this? - "ez_deps": attr.label_list( - allow_files = [".ez"], - ), }, - # TODO: confirm(??) (????) - toolchains = ["//:toolchain_type"], ) + +# --- mix_library: composing macro --- + +def mix_library(name, app_name, priv = [], visibility = None, **kwargs): + """Compiles an Elixir library using Mix. + + Splits compilation into platform-independent (.beam/.app) and + platform-dependent (priv/) artifacts for cross-platform cache reuse. + """ + + # Attrs that go to _mix_compile + compile_kwargs = {k: v for k, v in kwargs.items() if k in ( + "mix_env", + "env", + "mix_config", + "srcs", + "data", + "deps", + "include", + "ez_deps", + )} + + # Attrs that go to _mix_library_info + info_kwargs = {k: v for k, v in kwargs.items() if k in ( + "mix_env", + "mix_config", + "deps", + "srcs", + "include", + )} + + # Platform-independent: mix compile → ebin/ + _mix_compile( + name = name + "_compile", + app_name = app_name, + priv = priv, + visibility = ["//visibility:private"], + **compile_kwargs + ) + + # Platform-dependent: priv file symlinks + priv_target = None + if priv: + _mix_priv( + name = name + "_priv", + priv = priv, + visibility = ["//visibility:private"], + ) + priv_target = ":" + name + "_priv" + + # Aggregator: provides ErlangAppInfo + MixProjectInfo + _mix_library_info( + name = name, + app_name = app_name, + compile = ":" + name + "_compile", + priv_target = priv_target, + visibility = visibility, + **info_kwargs + ) From d9abf941dee816a05dc559a255aad6c185464e99 Mon Sep 17 00:00:00 2001 From: Denbeigh Stevens Date: Thu, 2 Apr 2026 18:16:35 +0000 Subject: [PATCH 3/7] do not register external toolchain by default --- MODULE.bazel | 3 --- repositories/elixir_config.bzl | 13 +++++++++++-- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/MODULE.bazel b/MODULE.bazel index d93cd65..50e5ee4 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -27,9 +27,6 @@ use_repo( "elixir_config", ) -register_toolchains( - "@elixir_config//external:toolchain", -) # Use elixir_packages extension to get hex_pm elixir_packages = use_extension( diff --git a/repositories/elixir_config.bzl b/repositories/elixir_config.bzl index fe71916..689fe35 100644 --- a/repositories/elixir_config.bzl +++ b/repositories/elixir_config.bzl @@ -29,7 +29,13 @@ def _version_identifier(version_string): return parts[0] def _impl(repository_ctx): - elixir_installations = _default_elixir_dict(repository_ctx) + # Skip host Elixir detection when explicit installations are provided. + # The host probe produces arch-dependent results, making the lockfile + # non-deterministic across architectures. + if len(repository_ctx.attr.types) > 0: + elixir_installations = {} + else: + elixir_installations = _default_elixir_dict(repository_ctx) for name in repository_ctx.attr.types.keys(): if name == _DEFAULT_EXTERNAL_ELIXIR_PACKAGE_NAME: fail("'{}' is reserved as an elixir name".format( @@ -221,7 +227,10 @@ constraint_value( """ - default_installation = elixir_installations[_DEFAULT_EXTERNAL_ELIXIR_PACKAGE_NAME] + if _DEFAULT_EXTERNAL_ELIXIR_PACKAGE_NAME in elixir_installations: + default_installation = elixir_installations[_DEFAULT_EXTERNAL_ELIXIR_PACKAGE_NAME] + else: + default_installation = elixir_installations.values()[0] build_file_content += """\ constraint_setting( From ba8849f88aa7583250d76d8b3d35006ac4821ef6 Mon Sep 17 00:00:00 2001 From: Denbeigh Stevens Date: Thu, 2 Apr 2026 19:26:33 +0000 Subject: [PATCH 4/7] pass OTP to elixir calls explicitly, not implicitly through OtpInfo --- MODULE.bazel | 11 ++++++ bzlmod/extensions.bzl | 34 +++++++++++++++--- private/elixir_build.bzl | 62 +++++++++++++++++++++++++-------- private/elixir_source_build.bzl | 30 +++++++++++----- 4 files changed, 110 insertions(+), 27 deletions(-) diff --git a/MODULE.bazel b/MODULE.bazel index 50e5ee4..95beb73 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -18,6 +18,17 @@ archive_override( url = "https://github.com/discord/rules_erlang/archive/b4b01911b99944fa0c9efb66bf3d9030ee784fa9.tar.gz", ) +# erlang_config is needed so @erlang_config is visible in repos created +# by the elixir_config extension (for wiring the OTP build dependency). +erlang_config = use_extension( + "@rules_erlang//bzlmod:extensions.bzl", + "erlang_config", +) +use_repo( + erlang_config, + "erlang_config", +) + elixir_config = use_extension( "@rules_elixir//bzlmod:extensions.bzl", "elixir_config", diff --git a/bzlmod/extensions.bzl b/bzlmod/extensions.bzl index 8b817fb..ab8f709 100644 --- a/bzlmod/extensions.bzl +++ b/bzlmod/extensions.bzl @@ -17,6 +17,12 @@ DEFAULT_ELIXIR_VERSION = "1.15.0" DEFAULT_ELIXIR_SHA256 = "0f4df7574a5f300b5c66f54906222cd46dac0df7233ded165bc8e80fd9ffeb7a" def _elixir_config(ctx): + # Resolve the canonical repo name for @erlang_config so we can reference + # it from generated http_archive repos (which don't have it in their + # repo mapping). We use Label().repo_name per Bazel docs rather than + # hardcoding the canonical name format. + erlang_config_repo = Label("@erlang_config").repo_name + types = {} versions = {} urls = {} @@ -44,7 +50,9 @@ def _elixir_config(ctx): target_compatible_withs[elixir.name] = [str(l) for l in elixir.target_compatible_with] exec_compatible_withs[elixir.name] = [str(l) for l in elixir.exec_compatible_with] - # Create repository for downloading and building Elixir source + # Create repository for downloading and building Elixir source. + # The http_archive repo doesn't have @erlang_config in its repo + # mapping, so we use the canonical name resolved above. http_archive( name = "elixir_source_{}".format(elixir.name), url = elixir.url, @@ -56,9 +64,13 @@ load("@rules_elixir//private:elixir_build.bzl", "elixir_build") elixir_build( name = "elixir_build", srcs = glob(["**/*"]), + otp = "@@{erlang_config_repo}//{otp}:otp-{otp}", visibility = ["//visibility:public"], ) -""", +""".format( + erlang_config_repo = erlang_config_repo, + otp = elixir.otp, + ), ) for elixir in mod.tags.internal_elixir_from_github_release: @@ -75,7 +87,9 @@ elixir_build( target_compatible_withs[elixir.name] = [str(l) for l in elixir.target_compatible_with] exec_compatible_withs[elixir.name] = [str(l) for l in elixir.exec_compatible_with] - # Create repository for downloading and building Elixir source + # Create repository for downloading and building Elixir source. + # The http_archive repo doesn't have @erlang_config in its repo + # mapping, so we use the canonical name resolved above. http_archive( name = "elixir_source_{}".format(elixir.name), url = url, @@ -87,9 +101,13 @@ load("@rules_elixir//private:elixir_build.bzl", "elixir_build") elixir_build( name = "elixir_build", srcs = glob(["**/*"]), + otp = "@@{erlang_config_repo}//{otp}:otp-{otp}", visibility = ["//visibility:public"], ) -""", +""".format( + erlang_config_repo = erlang_config_repo, + otp = elixir.otp, + ), ) _elixir_config_rule( @@ -120,6 +138,10 @@ internal_elixir_from_http_archive = tag_class(attrs = { "url": attr.string(), "strip_prefix": attr.string(), "sha256": attr.string(), + "otp": attr.string( + mandatory = True, + doc = "Name of an erlang_config installation to use for building Elixir (e.g. '25_bootstrap').", + ), "exec_compatible_with": attr.label_list(default = []), "target_compatible_with": attr.label_list(default = []), }) @@ -134,6 +156,10 @@ internal_elixir_from_github_release = tag_class(attrs = { "sha256": attr.string( default = DEFAULT_ELIXIR_SHA256, ), + "otp": attr.string( + mandatory = True, + doc = "Name of an erlang_config installation to use for building Elixir (e.g. '25_bootstrap').", + ), # NOTE: these should default to the host platform "exec_compatible_with": attr.label_list(default = []), "target_compatible_with": attr.label_list(default = []), diff --git a/private/elixir_build.bzl b/private/elixir_build.bzl index cea8dcd..8f06152 100644 --- a/private/elixir_build.bzl +++ b/private/elixir_build.bzl @@ -7,9 +7,8 @@ load( "http_archive", ) load( - "@rules_erlang//tools:erlang_toolchain.bzl", - "erlang_dirs", - "maybe_install_erlang", + "@rules_erlang//private:erlang_build.bzl", + "OtpInfo", ) ElixirInfo = provider( @@ -21,11 +20,34 @@ ElixirInfo = provider( ], ) +def otp_install_snippet(otp_info): + """Generate shell snippet to extract OTP release tar, like maybe_install_erlang.""" + if otp_info.release_dir_tar == None: + return "" + return """\ +mkdir -p $(dirname "{install_path}") +if mkdir "{install_path}"; then + tar --extract --no-same-owner \\ + --directory "{install_path}" \\ + --file {release_tar} +fi""".format( + release_tar = otp_info.release_dir_tar.path, + install_path = otp_info.install_path, + ) + +def otp_runfiles(ctx, otp_info): + """Build runfiles depset for OTP, like erlang_dirs.""" + if otp_info.release_dir_tar != None: + return ctx.runfiles([otp_info.release_dir_tar, otp_info.version_file]) + else: + return ctx.runfiles([otp_info.version_file]) + def _elixir_build_impl(ctx): + otp_info = ctx.attr.otp[OtpInfo] release_dir = ctx.actions.declare_directory("elixir_release") version_file = ctx.actions.declare_file("elixir_version") - (erlang_home, _, runfiles) = erlang_dirs(ctx) + runfiles = otp_runfiles(ctx, otp_info) ctx.actions.run_shell( inputs = depset( @@ -63,8 +85,8 @@ make cp -r bin $ABS_RELEASE_DIR/ cp -r lib $ABS_RELEASE_DIR/ """.format( - maybe_install_erlang = maybe_install_erlang(ctx), - erlang_home = erlang_home, + maybe_install_erlang = otp_install_snippet(otp_info), + erlang_home = otp_info.erlang_home, release_path = release_dir.path, source_files = " ".join([f.path for f in ctx.files.srcs]), first_source_file = ctx.files.srcs[0].path if ctx.files.srcs else "", @@ -88,8 +110,8 @@ export PATH="{erlang_home}"/bin:${{PATH}} "{elixir_home}"/bin/iex --version > {version_file} """.format( - maybe_install_erlang = maybe_install_erlang(ctx), - erlang_home = erlang_home, + maybe_install_erlang = otp_install_snippet(otp_info), + erlang_home = otp_info.erlang_home, elixir_home = release_dir.path, version_file = version_file.path, ), @@ -99,7 +121,7 @@ export PATH="{erlang_home}"/bin:${{PATH}} return [ DefaultInfo(files = depset([release_dir, version_file])), - ctx.toolchains["@rules_erlang//tools:toolchain_type"].otpinfo, + otp_info, ElixirInfo( release_dir = release_dir, elixir_home = None, @@ -111,18 +133,24 @@ elixir_build = rule( implementation = _elixir_build_impl, attrs = { "srcs": attr.label_list(allow_files = True), + "otp": attr.label( + mandatory = True, + providers = [OtpInfo], + doc = "An erlang_build target to use for compiling Elixir.", + ), }, - toolchains = ["@rules_erlang//tools:toolchain_type"], ) def _elixir_external_impl(ctx): + otp_info = ctx.attr.otp[OtpInfo] + elixir_home = ctx.attr.elixir_home if elixir_home == "": elixir_home = ctx.attr._elixir_home[BuildSettingInfo].value version_file = ctx.actions.declare_file(ctx.label.name + "_version") - (erlang_home, _, runfiles) = erlang_dirs(ctx) + runfiles = otp_runfiles(ctx, otp_info) ctx.actions.run_shell( inputs = runfiles.files, @@ -135,8 +163,8 @@ export PATH="{erlang_home}"/bin:${{PATH}} "{elixir_home}"/bin/iex --version > {version_file} """.format( - maybe_install_erlang = maybe_install_erlang(ctx), - erlang_home = erlang_home, + maybe_install_erlang = otp_install_snippet(otp_info), + erlang_home = otp_info.erlang_home, elixir_home = elixir_home, version_file = version_file.path, ), @@ -148,7 +176,7 @@ export PATH="{erlang_home}"/bin:${{PATH}} DefaultInfo( files = depset([version_file]), ), - ctx.toolchains["@rules_erlang//tools:toolchain_type"].otpinfo, + otp_info, ElixirInfo( release_dir = None, elixir_home = elixir_home, @@ -161,6 +189,10 @@ elixir_external = rule( attrs = { "_elixir_home": attr.label(default = Label("//:elixir_home")), "elixir_home": attr.string(), + "otp": attr.label( + mandatory = True, + providers = [OtpInfo], + doc = "An erlang_build target providing the OTP installation.", + ), }, - toolchains = ["@rules_erlang//tools:toolchain_type"], ) diff --git a/private/elixir_source_build.bzl b/private/elixir_source_build.bzl index 6014baf..6756cab 100644 --- a/private/elixir_source_build.bzl +++ b/private/elixir_source_build.bzl @@ -1,10 +1,20 @@ -load("@rules_erlang//tools:erlang_toolchain.bzl", "erlang_dirs", "maybe_install_erlang") +load( + "@rules_erlang//private:erlang_build.bzl", + "OtpInfo", +) +load( + ":elixir_build.bzl", + "ElixirInfo", + "otp_install_snippet", + "otp_runfiles", +) def _elixir_source_build_impl(ctx): + otp_info = ctx.attr.otp[OtpInfo] release_dir = ctx.actions.declare_directory("elixir_release") version_file = ctx.actions.declare_file("elixir_version") - (erlang_home, _, runfiles) = erlang_dirs(ctx) + runfiles = otp_runfiles(ctx, otp_info) ctx.actions.run_shell( inputs = depset( @@ -42,8 +52,8 @@ make cp -r bin $ABS_RELEASE_DIR/ cp -r lib $ABS_RELEASE_DIR/ """.format( - maybe_install_erlang = maybe_install_erlang(ctx), - erlang_home = erlang_home, + maybe_install_erlang = otp_install_snippet(otp_info), + erlang_home = otp_info.erlang_home, release_path = release_dir.path, source_files = " ".join([f.path for f in ctx.files.srcs]), first_source_file = ctx.files.srcs[0].path if ctx.files.srcs else "", @@ -67,8 +77,8 @@ export PATH="{erlang_home}"/bin:${{PATH}} "{elixir_home}"/bin/iex --version > {version_file} """.format( - maybe_install_erlang = maybe_install_erlang(ctx), - erlang_home = erlang_home, + maybe_install_erlang = otp_install_snippet(otp_info), + erlang_home = otp_info.erlang_home, elixir_home = release_dir.path, version_file = version_file.path, ), @@ -78,7 +88,7 @@ export PATH="{erlang_home}"/bin:${{PATH}} return [ DefaultInfo(files = depset([release_dir, version_file])), - ctx.toolchains["@rules_erlang//tools:toolchain_type"].otpinfo, + otp_info, ElixirInfo( release_dir = release_dir, elixir_home = None, @@ -90,6 +100,10 @@ elixir_source_build = rule( implementation = _elixir_source_build_impl, attrs = { "srcs": attr.label_list(allow_files = True), + "otp": attr.label( + mandatory = True, + providers = [OtpInfo], + doc = "An erlang_build target to use for compiling Elixir.", + ), }, - toolchains = ["@rules_erlang//tools:toolchain_type"], ) From d1ca4a283e0e638092022f825acf508513b61148 Mon Sep 17 00:00:00 2001 From: Denbeigh Stevens Date: Fri, 3 Apr 2026 20:11:48 +0000 Subject: [PATCH 5/7] WIP: introduce elixir/erlang version settings --- BUILD.bazel | 6 ++++++ bzlmod/extensions.bzl | 14 ++++++++++++++ private/beam_transitions.bzl | 28 +++++++++++++++++++++++++++- repositories/elixir_config.bzl | 34 +++++++++++++++++++++++++++++----- 4 files changed, 76 insertions(+), 6 deletions(-) diff --git a/BUILD.bazel b/BUILD.bazel index 725ce79..ea865eb 100644 --- a/BUILD.bazel +++ b/BUILD.bazel @@ -9,6 +9,12 @@ string_flag( visibility = ["//visibility:public"], ) +string_flag( + name = "elixir_platform", + build_setting_default = "", + visibility = ["//visibility:public"], +) + toolchain_type( name = "toolchain_type", visibility = ["//visibility:public"], diff --git a/bzlmod/extensions.bzl b/bzlmod/extensions.bzl index ab8f709..ab8ecb5 100644 --- a/bzlmod/extensions.bzl +++ b/bzlmod/extensions.bzl @@ -31,6 +31,7 @@ def _elixir_config(ctx): elixir_homes = {} target_compatible_withs = {} exec_compatible_withs = {} + erlang_versions = {} for mod in ctx.modules: for elixir in mod.tags.external_elixir_from_path: @@ -49,6 +50,8 @@ def _elixir_config(ctx): sha256s[elixir.name] = elixir.sha256 target_compatible_withs[elixir.name] = [str(l) for l in elixir.target_compatible_with] exec_compatible_withs[elixir.name] = [str(l) for l in elixir.exec_compatible_with] + if elixir.erlang_version: + erlang_versions[elixir.name] = elixir.erlang_version # Create repository for downloading and building Elixir source. # The http_archive repo doesn't have @erlang_config in its repo @@ -86,6 +89,8 @@ elixir_build( sha256s[elixir.name] = elixir.sha256 target_compatible_withs[elixir.name] = [str(l) for l in elixir.target_compatible_with] exec_compatible_withs[elixir.name] = [str(l) for l in elixir.exec_compatible_with] + if elixir.erlang_version: + erlang_versions[elixir.name] = elixir.erlang_version # Create repository for downloading and building Elixir source. # The http_archive repo doesn't have @erlang_config in its repo @@ -120,6 +125,7 @@ elixir_build( elixir_homes = elixir_homes, exec_compatible_withs = exec_compatible_withs, target_compatible_withs = target_compatible_withs, + erlang_versions = erlang_versions, ) external_elixir_from_path = tag_class(attrs = { @@ -142,6 +148,10 @@ internal_elixir_from_http_archive = tag_class(attrs = { mandatory = True, doc = "Name of an erlang_config installation to use for building Elixir (e.g. '25_bootstrap').", ), + "erlang_version": attr.string( + doc = "OTP version string (e.g. '26.3') for generating combined platform constraints. " + + "Must match the version of the OTP installation named by the 'otp' attr.", + ), "exec_compatible_with": attr.label_list(default = []), "target_compatible_with": attr.label_list(default = []), }) @@ -160,6 +170,10 @@ internal_elixir_from_github_release = tag_class(attrs = { mandatory = True, doc = "Name of an erlang_config installation to use for building Elixir (e.g. '25_bootstrap').", ), + "erlang_version": attr.string( + doc = "OTP version string (e.g. '26.3') for generating combined platform constraints. " + + "Must match the version of the OTP installation named by the 'otp' attr.", + ), # NOTE: these should default to the host platform "exec_compatible_with": attr.label_list(default = []), "target_compatible_with": attr.label_list(default = []), diff --git a/private/beam_transitions.bzl b/private/beam_transitions.bzl index dab989f..9e554a0 100644 --- a/private/beam_transitions.bzl +++ b/private/beam_transitions.bzl @@ -4,13 +4,39 @@ BEAM bytecode (.beam) and .app files are platform-independent artifacts. This transition normalizes platform-related settings so that compilation actions get the same configuration hash regardless of target platform, enabling remote cache hits across platforms. + +When //:elixir_platform is set, the transition uses that platform (which +should carry both elixir_version and erlang_version constraints) for +correct toolchain resolution with multiple versions. + +Falls back to @rules_erlang//:erlang_platform if only the Erlang flag +is set (covers transitive Erlang rule usage). + +When both flags are empty (default), --platforms is cleared to [] — +identical to the previous behavior, safe for single-version setups. """ +# Cross-repo reference needs Label() for canonicalization. The str() result +# is used as both the inputs entry and the settings dict key. +_ERLANG_PLATFORM_LABEL = str(Label("@rules_erlang//:erlang_platform")) + def _platform_independent_impl(settings, attr): + elixir_platform = settings["//:elixir_platform"] + if elixir_platform: + return {"//command_line_option:platforms": [elixir_platform]} + + erlang_platform = settings[_ERLANG_PLATFORM_LABEL] + if erlang_platform: + return {"//command_line_option:platforms": [erlang_platform]} + return {"//command_line_option:platforms": []} platform_independent_transition = transition( implementation = _platform_independent_impl, - inputs = ["//command_line_option:platforms"], + inputs = [ + "//command_line_option:platforms", + "//:elixir_platform", + _ERLANG_PLATFORM_LABEL, + ], outputs = ["//command_line_option:platforms"], ) diff --git a/repositories/elixir_config.bzl b/repositories/elixir_config.bzl index 689fe35..c89828e 100644 --- a/repositories/elixir_config.bzl +++ b/repositories/elixir_config.bzl @@ -53,6 +53,7 @@ def _impl(repository_ctx): elixir_home = repository_ctx.attr.elixir_homes.get(name, None), target_compatible_with = repository_ctx.attr.target_compatible_withs.get(name, None), exec_compatible_with = repository_ctx.attr.exec_compatible_withs.get(name, None), + erlang_version = repository_ctx.attr.erlang_versions.get(name, None), ) for (name, props) in elixir_installations.items(): @@ -122,6 +123,7 @@ elixir_config = repository_rule( "elixir_homes": attr.string_dict(), "exec_compatible_withs": attr.string_list_dict(), "target_compatible_withs": attr.string_list_dict(), + "erlang_versions": attr.string_dict(), }, environ = [ ELIXIR_HOME_ENV_VAR, @@ -240,18 +242,40 @@ constraint_setting( """.format(default_installation.identifier) - unique_identifiers = { - props.identifier: name - for (name, props) in elixir_installations.items() - }.keys() + # Collect unique identifiers and their paired erlang versions. + # When multiple installations share an identifier, the last one wins + # (same as the old behavior for the identifier set). + identifier_erlang_versions = {} + for (name, props) in elixir_installations.items(): + identifier_erlang_versions[props.identifier] = getattr(props, "erlang_version", None) - for identifier in unique_identifiers: + for (identifier, erlang_version) in identifier_erlang_versions.items(): build_file_content += """\ constraint_value( name = "elixir_{identifier}", constraint_setting = ":elixir_version", ) +""".format(identifier = identifier) + + # Generate a platform with both elixir_version and (if known) + # erlang_version constraints. The erlang constraint enables correct + # toolchain resolution when platform_independent_transition replaces + # --platforms with this synthetic platform. + if erlang_version: + erlang_version_id = _version_identifier(erlang_version) + build_file_content += """\ +platform( + name = "elixir_{identifier}_platform", + constraint_values = [ + ":elixir_{identifier}", + "@erlang_config//:erlang_{erlang_version_id}", + ], +) + +""".format(identifier = identifier, erlang_version_id = erlang_version_id) + else: + build_file_content += """\ platform( name = "elixir_{identifier}_platform", constraint_values = [ From b483c3e04086b5dbbf56eaa70bc820d0b6a5f869 Mon Sep 17 00:00:00 2001 From: Denbeigh Stevens Date: Fri, 3 Apr 2026 20:41:23 +0000 Subject: [PATCH 6/7] sacrifice elixir bytecode reuse to have sane platform guards unfortunately means we cannot re-use bytecode between architectures, but i'm personally fine with the safety <> speed/redundancy trade-off until i'm willing to dive back into this quagmire. --- private/mix_library.bzl | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/private/mix_library.bzl b/private/mix_library.bzl index f375138..eb8105b 100644 --- a/private/mix_library.bzl +++ b/private/mix_library.bzl @@ -206,7 +206,6 @@ cp _output/{mix_env}/lib/{app_name}/ebin/*.beam _output/{mix_env}/lib/{app_name} _mix_compile = rule( implementation = _mix_compile_impl, - cfg = platform_independent_transition, attrs = { "app_name": attr.string(mandatory = True), "mix_env": attr.string( @@ -220,16 +219,12 @@ _mix_compile = rule( default = ":mix.exs", ), "srcs": attr.label_list( - # TODO: is there a more comprehensive place I can find all - # supported extensions than just adding them as I find them? There - # are a lot of weird extensions... allow_files = [".ex", ".erl", ".xrl", ".hrl", ".app.src"], ), "data": attr.label_list( allow_files = True, ), "deps": attr.label_list( - # TODO: need to confirm the provider we create also outputs this providers = [ErlangAppInfo], ), "priv": attr.label_list( @@ -238,12 +233,10 @@ _mix_compile = rule( "include": attr.label_list( allow_files = [".hrl"], ), - # TODO: we should probably set a default for this? "ez_deps": attr.label_list( allow_files = [".ez"], ), }, - # TODO: confirm(??) (????) toolchains = ["//:toolchain_type"], ) @@ -381,7 +374,10 @@ def mix_library(name, app_name, priv = [], visibility = None, **kwargs): "include", )} - # Platform-independent: mix compile → ebin/ + # mix compile runs without a platform transition so that native deps + # resolve with full CPU/OS constraints. Unfortunately that means we + # compile elixir BEAM twice in multi-arch cases, but the redundancy is + # acceptable to also ensure we don't mix and match OTP versions. _mix_compile( name = name + "_compile", app_name = app_name, From c69d22a79f45abc09ce6bf0afc4701617fe141c7 Mon Sep 17 00:00:00 2001 From: Denbeigh Stevens Date: Fri, 3 Apr 2026 21:17:06 +0000 Subject: [PATCH 7/7] incorporate changes from #24 --- private/mix_library.bzl | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/private/mix_library.bzl b/private/mix_library.bzl index eb8105b..4cd4be0 100644 --- a/private/mix_library.bzl +++ b/private/mix_library.bzl @@ -47,7 +47,7 @@ def _mix_compile_impl(ctx): # should instead return a Provider that can provide erlang...library info? # TBD # This also needs a better name - ebin = ctx.actions.declare_directory("ebin") + ebin = ctx.actions.declare_directory(ctx.label.name + "_ebin") erl_libs_dir = ctx.label.name + "_deps" @@ -255,7 +255,10 @@ def _mix_priv_impl(ctx): ctx.actions.symlink(output = out, target_file = priv_file) priv_files.append(out) - return [DefaultInfo(files = depset(priv_files))] + return [DefaultInfo( + files = depset(priv_files), + runfiles = ctx.runfiles(files = priv_files), + )] _mix_priv = rule( implementation = _mix_priv_impl, @@ -276,9 +279,20 @@ def _mix_library_info_impl(ctx): all_deps = flat_deps(ctx.attr.deps) + dep_runfiles = [dep[DefaultInfo].default_runfiles for dep in ctx.attr.deps if DefaultInfo in dep] + lib_runfiles = ctx.runfiles(files = compile_files + priv_files + ctx.files.data) + compile_runfiles = ctx.attr.compile[DefaultInfo].default_runfiles + if compile_runfiles: + lib_runfiles = lib_runfiles.merge(compile_runfiles) + if ctx.attr.priv_target and ctx.attr.priv_target[DefaultInfo].default_runfiles: + lib_runfiles = lib_runfiles.merge(ctx.attr.priv_target[DefaultInfo].default_runfiles) + for dr in dep_runfiles: + lib_runfiles = lib_runfiles.merge(dr) + return [ DefaultInfo( files = depset(compile_files + priv_files), + runfiles = lib_runfiles, ), MixProjectInfo( # app_name = ctx.attr.app_name, @@ -332,6 +346,9 @@ _mix_library_info = rule( ), "compile": attr.label(mandatory = True), "priv_target": attr.label(), + "data": attr.label_list( + allow_files = True, + ), "deps": attr.label_list( providers = [ErlangAppInfo], ), @@ -369,6 +386,7 @@ def mix_library(name, app_name, priv = [], visibility = None, **kwargs): info_kwargs = {k: v for k, v in kwargs.items() if k in ( "mix_env", "mix_config", + "data", "deps", "srcs", "include",