From 0937ddb2670b89632e78048eebef93332c33d04b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Jul 2026 22:56:18 +0000 Subject: [PATCH] Add unit tests for hermetic_erlang_repository's OS/arch mapping This file's _ARCH mapping table has the exact same shape as gleam_host's (arm64/amd64 vs. aarch64/x86_64 spellings), and it's where the original macOS hermetic-Erlang arch bug was found earlier -- purely via a real CI failure on a real macOS runner, since no test existed for this file at all. Extracted the OS/arch/platform-constraint resolution out of _hermetic_erlang_repository_impl into a pure resolve_host(repository_ctx) function (same technique used for gleam_host's host_platform), and added test/unit/hermetic_erlang_repository covering every OS x arch combination (including both raw and erlef-normalized arch spellings) plus the two failure cases, so a repeat of that bug class fails fast locally instead of needing a real CI run to notice. No behavior change: resolve_host reproduces the exact same URL/checksum-key/constraint values the inlined per-branch logic did before. --- erlang/private/hermetic_erlang_repository.bzl | 82 ++++++--- .../hermetic_erlang_repository/BUILD.bazel | 3 + .../hermetic_erlang_repository_test.bzl | 167 ++++++++++++++++++ 3 files changed, 227 insertions(+), 25 deletions(-) create mode 100644 test/unit/hermetic_erlang_repository/BUILD.bazel create mode 100644 test/unit/hermetic_erlang_repository/hermetic_erlang_repository_test.bzl diff --git a/erlang/private/hermetic_erlang_repository.bzl b/erlang/private/hermetic_erlang_repository.bzl index db48d40..9f829c5 100644 --- a/erlang/private/hermetic_erlang_repository.bzl +++ b/erlang/private/hermetic_erlang_repository.bzl @@ -30,6 +30,52 @@ _ARCH = { def _sha256_key(os_key, arch): return "{}_{}".format(os_key, arch) +def resolve_host(repository_ctx): + """Determine the OS/arch/platform-constraint quadruple for a repository_ctx's host. + + Pulled out of _hermetic_erlang_repository_impl so it can be unit-tested (test/unit/ + hermetic_erlang_repository) against plain fake structs, without needing a real + repository_ctx or a network download -- the same trick host_repo.bzl's host_platform uses. + + Args: + repository_ctx: a repository_ctx, or any struct exposing the same `.os.name`/`.os.arch` + fields (e.g. a fake used by tests). + + Returns: + A struct with fields: + os_key: "linux" or "macos" (used to key the sha256 dict and pick a download URL shape) + arch: "arm64" or "amd64" (erlef's origin-naming convention, not necessarily + repository_ctx.os.arch's raw spelling) + os_constraint: a "@platforms//os:..." label for the toolchain() definition + cpu_constraint: a "@platforms//cpu:..." label for the toolchain() definition + """ + os_name = repository_ctx.os.name.lower() + arch = repository_ctx.os.arch + + if "linux" in os_name: + os_key = "linux" + os_label = "Linux" + os_constraint = "@platforms//os:linux" + elif "mac os" in os_name: + os_key = "macos" + os_label = "macOS" + os_constraint = "@platforms//os:osx" + else: + fail("Hermetic Erlang/OTP toolchain does not support OS: {}".format(repository_ctx.os.name)) + + normalized_arch = _ARCH.get(arch) + if not normalized_arch: + fail("Unsupported CPU architecture for hermetic Erlang/OTP on {}: {}".format(os_label, arch)) + + cpu_constraint = "@platforms//cpu:arm64" if normalized_arch == "arm64" else "@platforms//cpu:x86_64" + + return struct( + os_key = os_key, + arch = normalized_arch, + os_constraint = os_constraint, + cpu_constraint = cpu_constraint, + ) + def _find_executable(repository_ctx, root, name): result = repository_ctx.execute(["find", str(root), "-type", "f", "-name", name, "-path", "*/bin/" + name]) lines = [line for line in result.stdout.strip().split("\n") if line] @@ -46,23 +92,19 @@ def _hermetic_erlang_repository_impl(repository_ctx): otp_tag = "OTP-" + otp_version sha256_map = repository_ctx.attr.sha256 - os_name = repository_ctx.os.name.lower() - arch = repository_ctx.os.arch - - if "linux" in os_name: - os_key = "linux" - linux_arch = _ARCH.get(arch) - if not linux_arch: - fail("Unsupported CPU architecture for hermetic Erlang/OTP on Linux: {}".format(arch)) - os_constraint = "@platforms//os:linux" - cpu_constraint = "@platforms//cpu:arm64" if linux_arch == "arm64" else "@platforms//cpu:x86_64" + host = resolve_host(repository_ctx) + os_key = host.os_key + arch = host.arch + os_constraint = host.os_constraint + cpu_constraint = host.cpu_constraint + if os_key == "linux": url = "https://builds.hex.pm/builds/otp/{arch}/{os_version}/{tag}.tar.gz".format( - arch = linux_arch, + arch = arch, os_version = repository_ctx.attr.os_version, tag = otp_tag, ) - checksum_key = _sha256_key(os_key, linux_arch) + checksum_key = _sha256_key(os_key, arch) expected_sha256 = sha256_map.get(checksum_key, "") download = repository_ctx.download( @@ -97,19 +139,12 @@ def _hermetic_erlang_repository_impl(repository_ctx): if install_result.return_code != 0: fail("Erlang/OTP 'Install -minimal' script failed: " + install_result.stderr) - elif "mac os" in os_name: - os_key = "macos" - macos_arch = _ARCH.get(arch) - if not macos_arch: - fail("Unsupported CPU architecture for hermetic Erlang/OTP on macOS: {}".format(arch)) - os_constraint = "@platforms//os:osx" - cpu_constraint = "@platforms//cpu:arm64" if macos_arch == "arm64" else "@platforms//cpu:x86_64" - + else: # os_key == "macos" url = "https://github.com/erlef/otp_builds/releases/download/{tag}/{tag}-macos-{arch}.tar.gz".format( tag = otp_tag, - arch = macos_arch, + arch = arch, ) - checksum_key = _sha256_key(os_key, macos_arch) + checksum_key = _sha256_key(os_key, arch) expected_sha256 = sha256_map.get(checksum_key, "") download = repository_ctx.download( @@ -133,9 +168,6 @@ def _hermetic_erlang_repository_impl(repository_ctx): otp_root = repository_ctx.path("otp") - else: - fail("Hermetic Erlang/OTP toolchain does not support OS: {}".format(repository_ctx.os.name)) - erl_path = _find_executable(repository_ctx, otp_root, "erl") erlc_path = _find_executable(repository_ctx, otp_root, "erlc") escript_path = _find_executable(repository_ctx, otp_root, "escript") diff --git a/test/unit/hermetic_erlang_repository/BUILD.bazel b/test/unit/hermetic_erlang_repository/BUILD.bazel new file mode 100644 index 0000000..e3602d3 --- /dev/null +++ b/test/unit/hermetic_erlang_repository/BUILD.bazel @@ -0,0 +1,3 @@ +load(":hermetic_erlang_repository_test.bzl", "hermetic_erlang_repository_test_suite") + +hermetic_erlang_repository_test_suite(name = "hermetic_erlang_repository_test_suite") diff --git a/test/unit/hermetic_erlang_repository/hermetic_erlang_repository_test.bzl b/test/unit/hermetic_erlang_repository/hermetic_erlang_repository_test.bzl new file mode 100644 index 0000000..1e00530 --- /dev/null +++ b/test/unit/hermetic_erlang_repository/hermetic_erlang_repository_test.bzl @@ -0,0 +1,167 @@ +"""Unit tests for erlang/private/hermetic_erlang_repository.bzl's resolve_host. + +resolve_host only ever reads `.os.name`/`.os.arch` off whatever it's given, so a plain struct +stands in for a real repository_ctx without needing any Bazel machinery or a network download -- +same trick test/unit/host_repo/host_repo_test.bzl uses for gleam_host's own, separate +os/arch-mapping table. + +This table is exactly where the original macOS hermetic-Erlang arch-naming bug lived (a real +production bug found via a real CI failure, not caught by any test, since none existed for this +file) -- these tests exist so a repeat of that class of mistake fails fast and locally instead. +""" + +load("@bazel_skylib//lib:unittest.bzl", "analysistest", "asserts", "unittest") +load("//erlang/private:hermetic_erlang_repository.bzl", "resolve_host") # buildifier: disable=bzl-visibility + +def _fake_repository_ctx(os_name, os_arch): + return struct(os = struct(name = os_name, arch = os_arch)) + +def _resolves_linux_x86_64_test_impl(ctx): + env = unittest.begin(ctx) + host = resolve_host(_fake_repository_ctx("linux", "x86_64")) + asserts.equals(env, "linux", host.os_key) + asserts.equals(env, "amd64", host.arch) + asserts.equals(env, "@platforms//os:linux", host.os_constraint) + asserts.equals(env, "@platforms//cpu:x86_64", host.cpu_constraint) + return unittest.end(env) + +resolves_linux_x86_64_test = unittest.make(_resolves_linux_x86_64_test_impl) + +def _resolves_linux_amd64_spelling_test_impl(ctx): + env = unittest.begin(ctx) + + # "amd64" is the same value os.arch reports as "x86_64" is normalized to -- confirm the + # already-normalized spelling is also accepted (idempotent), not just the raw one. + host = resolve_host(_fake_repository_ctx("linux", "amd64")) + asserts.equals(env, "amd64", host.arch) + asserts.equals(env, "@platforms//cpu:x86_64", host.cpu_constraint) + return unittest.end(env) + +resolves_linux_amd64_spelling_test = unittest.make(_resolves_linux_amd64_spelling_test_impl) + +def _resolves_linux_aarch64_test_impl(ctx): + env = unittest.begin(ctx) + host = resolve_host(_fake_repository_ctx("linux", "aarch64")) + asserts.equals(env, "linux", host.os_key) + asserts.equals(env, "arm64", host.arch) + asserts.equals(env, "@platforms//cpu:arm64", host.cpu_constraint) + return unittest.end(env) + +resolves_linux_aarch64_test = unittest.make(_resolves_linux_aarch64_test_impl) + +def _resolves_linux_arm64_spelling_test_impl(ctx): + env = unittest.begin(ctx) + + # Real Bazel/OS combinations have been observed reporting "arm64" (not "aarch64") for + # repository_ctx.os.arch on Linux too -- confirm both spellings normalize the same way. + host = resolve_host(_fake_repository_ctx("linux", "arm64")) + asserts.equals(env, "arm64", host.arch) + asserts.equals(env, "@platforms//cpu:arm64", host.cpu_constraint) + return unittest.end(env) + +resolves_linux_arm64_spelling_test = unittest.make(_resolves_linux_arm64_spelling_test_impl) + +def _resolves_macos_x86_64_test_impl(ctx): + env = unittest.begin(ctx) + host = resolve_host(_fake_repository_ctx("mac os x", "x86_64")) + asserts.equals(env, "macos", host.os_key) + asserts.equals(env, "amd64", host.arch) + asserts.equals(env, "@platforms//os:osx", host.os_constraint) + asserts.equals(env, "@platforms//cpu:x86_64", host.cpu_constraint) + return unittest.end(env) + +resolves_macos_x86_64_test = unittest.make(_resolves_macos_x86_64_test_impl) + +def _resolves_macos_amd64_spelling_test_impl(ctx): + env = unittest.begin(ctx) + host = resolve_host(_fake_repository_ctx("mac os x", "amd64")) + asserts.equals(env, "amd64", host.arch) + asserts.equals(env, "@platforms//cpu:x86_64", host.cpu_constraint) + return unittest.end(env) + +resolves_macos_amd64_spelling_test = unittest.make(_resolves_macos_amd64_spelling_test_impl) + +def _resolves_macos_aarch64_test_impl(ctx): + env = unittest.begin(ctx) + host = resolve_host(_fake_repository_ctx("mac os x", "aarch64")) + asserts.equals(env, "macos", host.os_key) + asserts.equals(env, "arm64", host.arch) + asserts.equals(env, "@platforms//cpu:arm64", host.cpu_constraint) + return unittest.end(env) + +resolves_macos_aarch64_test = unittest.make(_resolves_macos_aarch64_test_impl) + +def _resolves_macos_arm64_spelling_test_impl(ctx): + env = unittest.begin(ctx) + + # This is the exact spelling real macOS (Apple Silicon) Bazel runners report for + # repository_ctx.os.arch -- the original production bug this whole test file exists to + # prevent a repeat of was this normalization being missing/wrong for exactly this case. + host = resolve_host(_fake_repository_ctx("mac os x", "arm64")) + asserts.equals(env, "arm64", host.arch) + asserts.equals(env, "@platforms//cpu:arm64", host.cpu_constraint) + return unittest.end(env) + +resolves_macos_arm64_spelling_test = unittest.make(_resolves_macos_arm64_spelling_test_impl) + +def _fails_on_unsupported_arch_test_impl(ctx): + env = analysistest.begin(ctx) + asserts.expect_failure(env, "Unsupported CPU architecture") + return analysistest.end(env) + +def _unsupported_arch_lookup_impl(_ctx): + resolve_host(_fake_repository_ctx("linux", "riscv64")) + return [DefaultInfo()] + +_unsupported_arch_lookup_rule = rule(implementation = _unsupported_arch_lookup_impl) + +_fails_on_unsupported_arch_test = analysistest.make(_fails_on_unsupported_arch_test_impl, expect_failure = True) + +def _fails_on_unsupported_os_test_impl(ctx): + env = analysistest.begin(ctx) + asserts.expect_failure(env, "does not support OS") + return analysistest.end(env) + +def _unsupported_os_lookup_impl(_ctx): + resolve_host(_fake_repository_ctx("plan9", "x86_64")) + return [DefaultInfo()] + +_unsupported_os_lookup_rule = rule(implementation = _unsupported_os_lookup_impl) + +_fails_on_unsupported_os_test = analysistest.make(_fails_on_unsupported_os_test_impl, expect_failure = True) + +def hermetic_erlang_repository_test_suite(name): + """Registers all hermetic_erlang_repository unit tests as a single test_suite target. + + Args: + name: name of the generated test_suite. + """ + _unsupported_arch_lookup_rule(name = "unsupported_arch_lookup", tags = ["manual"]) + _fails_on_unsupported_arch_test( + name = "fails_on_unsupported_arch_test", + target_under_test = ":unsupported_arch_lookup", + ) + _unsupported_os_lookup_rule(name = "unsupported_os_lookup", tags = ["manual"]) + _fails_on_unsupported_os_test( + name = "fails_on_unsupported_os_test", + target_under_test = ":unsupported_os_lookup", + ) + unittest.suite( + "hermetic_erlang_repository_pure_tests", + resolves_linux_x86_64_test, + resolves_linux_amd64_spelling_test, + resolves_linux_aarch64_test, + resolves_linux_arm64_spelling_test, + resolves_macos_x86_64_test, + resolves_macos_amd64_spelling_test, + resolves_macos_aarch64_test, + resolves_macos_arm64_spelling_test, + ) + native.test_suite( + name = name, + tests = [ + ":hermetic_erlang_repository_pure_tests", + ":fails_on_unsupported_arch_test", + ":fails_on_unsupported_os_test", + ], + )