From 232c42644051af1916e3f437486bbe23d95bd729 Mon Sep 17 00:00:00 2001 From: GenericJam Date: Sun, 30 Aug 2026 00:37:09 -0600 Subject: [PATCH 1/2] fix(tooling): correct Zig install guidance Bead: clarity-840 --- AGENTS.md | 1 + lib/mix/tasks/mob.doctor.ex | 11 ++++++++++- lib/mob_dev/native_build.ex | 5 +++-- test/mix/tasks/mob_doctor_test.exs | 9 +++++++++ test/mob_dev/native_build_test.exs | 5 +++-- 5 files changed, 26 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 04bea7f..ecd3c3c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -138,6 +138,7 @@ narrowing functions). Don't make them private: - `NativeBuild.narrow_platforms_for_device/2`, `ios_toolchain_available?/0`, `read_sdk_dir/1`, `fallback_entitlements_plist/3` - `NativeBuild.pythonx_in_project?/1`, `python_apple_support_env/2` - `NativeBuild.__prune_plugin_artifacts__/2` (the plugin-removal prune; ledger-tracked per merge concern) +- `Mix.Tasks.Mob.Doctor.__zig_install_fix__/0` - `Enable.inject_pythonx_dep/1`, `inject_pythonx_uv_init_gate/2`, `python_paths_module_template/1` - `Emulators.parse_simctl_json/1`, `find_emulator_binary/1` - `Provision.diagnose_xcodebuild_failure/1` diff --git a/lib/mix/tasks/mob.doctor.ex b/lib/mix/tasks/mob.doctor.ex index 590a69f..9135e52 100644 --- a/lib/mix/tasks/mob.doctor.ex +++ b/lib/mix/tasks/mob.doctor.ex @@ -231,7 +231,7 @@ defmodule Mix.Tasks.Mob.Doctor do nil -> {:warn, "zig", "not on PATH — required as the C cross-compile driver from Phase 1 of the build-system migration", - "Install zig 0.15.x:\n macOS: brew install zig\n Linux/asdf: asdf plugin add zig && asdf install zig 0.15.2\n manual: https://ziglang.org/download/"} + __zig_install_fix__()} _ -> case System.cmd("zig", ["version"], stderr_to_stdout: true) do @@ -245,6 +245,15 @@ defmodule Mix.Tasks.Mob.Doctor do end end + @doc false + @spec __zig_install_fix__() :: String.t() + def __zig_install_fix__ do + "Install zig 0.17.0-dev.269+ebff43698:\n" <> + " mise: mise use zig@0.17.0-dev.269+ebff43698\n" <> + " asdf: asdf plugin add zig && asdf install zig 0.17.0-dev.269+ebff43698 && asdf local zig 0.17.0-dev.269+ebff43698\n" <> + " manual: https://ziglang.org/download/" + end + defp check_xcrun do if macos?() do case System.find_executable("xcrun") do diff --git a/lib/mob_dev/native_build.ex b/lib/mob_dev/native_build.ex index 2bf8611..a9328a6 100644 --- a/lib/mob_dev/native_build.ex +++ b/lib/mob_dev/native_build.ex @@ -332,8 +332,9 @@ defmodule MobDev.NativeBuild do fallback would reference C sources (deps/mob/android/jni/mob_nif.c) that no longer ship with mob, so the build cannot succeed without zig. - Install zig 0.15.x, then re-run `mix mob.deploy --native --android`: - asdf: asdf plugin add zig && asdf install zig 0.15.2 && asdf global zig 0.15.2 + Install zig 0.17.0-dev.269+ebff43698, then re-run `mix mob.deploy --native --android`: + mise: mise use zig@0.17.0-dev.269+ebff43698 + asdf: asdf plugin add zig && asdf install zig 0.17.0-dev.269+ebff43698 && asdf local zig 0.17.0-dev.269+ebff43698 manual: https://ziglang.org/download/ (then put `zig` on your PATH) Verify your toolchain any time with `mix mob.doctor`.\ diff --git a/test/mix/tasks/mob_doctor_test.exs b/test/mix/tasks/mob_doctor_test.exs index 2594b31..3b3c2c8 100644 --- a/test/mix/tasks/mob_doctor_test.exs +++ b/test/mix/tasks/mob_doctor_test.exs @@ -1,6 +1,15 @@ defmodule Mix.Tasks.Mob.DoctorTest do use ExUnit.Case, async: true + describe "__zig_install_fix__/0" do + test "pins the exact application-build Zig version" do + fix = Mix.Tasks.Mob.Doctor.__zig_install_fix__() + + assert fix =~ "zig 0.17.0-dev.269+ebff43698" + refute fix =~ "zig 0.15" + end + end + describe "__missing_plugin_options__/2 (pre-plugin build.zig detection)" do # The real declaration shape every template uses. @declared """ diff --git a/test/mob_dev/native_build_test.exs b/test/mob_dev/native_build_test.exs index f8b0c2a..7fd066f 100644 --- a/test/mob_dev/native_build_test.exs +++ b/test/mob_dev/native_build_test.exs @@ -1944,8 +1944,9 @@ defmodule MobDev.NativeBuildTest do # the cause: zig missing + the vanished C fallback source assert msg =~ "zig is not on your PATH" assert msg =~ "mob_nif.c" - # the fix: the version mob.doctor pins, plus how to verify - assert msg =~ "zig 0.15" + # the fix: the exact application-build version, plus how to verify + assert msg =~ "zig 0.17.0-dev.269+ebff43698" + refute msg =~ "zig 0.15" assert msg =~ "mix mob.doctor" end From 320104ca957cfb0fd6f6c1bb98c66a41a7544f7e Mon Sep 17 00:00:00 2001 From: GenericJam Date: Sun, 30 Aug 2026 01:00:32 -0600 Subject: [PATCH 2/2] fix(tooling): reject unsupported Zig versions Bead: clarity-840 --- AGENTS.md | 8 ++++ lib/mix/tasks/mob.doctor.ex | 45 ++++++++++++----------- lib/mob_dev/native_build.ex | 59 ++++++++++++++++++------------ lib/mob_dev/toolchain.ex | 57 +++++++++++++++++++++++++++++ test/mix/tasks/mob_doctor_test.exs | 39 +++++++++++++++++++- test/mob_dev/native_build_test.exs | 45 ++++++++++++++++++----- test/mob_dev/toolchain_test.exs | 31 ++++++++++++++++ 7 files changed, 230 insertions(+), 54 deletions(-) create mode 100644 lib/mob_dev/toolchain.ex create mode 100644 test/mob_dev/toolchain_test.exs diff --git a/AGENTS.md b/AGENTS.md index ecd3c3c..9314478 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -67,6 +67,13 @@ mix test --exclude integration # skip the device-dependent ones - **The release scripts assume `~/code/otp` exists** with the right cross-compile output. The patches in `scripts/release/patches/` are applied automatically by `xcompile_ios_device.sh`, idempotently — re-running is safe. +- **The application-build Zig version is exact.** `MobDev.Toolchain` embeds the + root `.tool-versions` pin and both native build preflight and `mob.doctor` + reject any other version. `mob.adopt` deliberately does not rewrite an + existing Phoenix project's toolchain file: it installs `build.zig`-bearing + native trees, then the preflight reports a missing or conflicting pin with + the exact mise command. Changing an adopted app's existing language/runtime + pins without its owner's consent would be destructive. - **`mob.add_nif` is the entry point for new NIFs.** Don't add `:static_nifs` entries by hand to `mob.exs` — the task already does the AST-aware append, generates the Elixir stub via Igniter, and re-runs `mob.regen_driver_tab` @@ -139,6 +146,7 @@ narrowing functions). Don't make them private: - `NativeBuild.pythonx_in_project?/1`, `python_apple_support_env/2` - `NativeBuild.__prune_plugin_artifacts__/2` (the plugin-removal prune; ledger-tracked per merge concern) - `Mix.Tasks.Mob.Doctor.__zig_install_fix__/0` +- `Mix.Tasks.Mob.Doctor.__zig_check_result__/1` - `Enable.inject_pythonx_dep/1`, `inject_pythonx_uv_init_gate/2`, `python_paths_module_template/1` - `Emulators.parse_simctl_json/1`, `find_emulator_binary/1` - `Provision.diagnose_xcodebuild_failure/1` diff --git a/lib/mix/tasks/mob.doctor.ex b/lib/mix/tasks/mob.doctor.ex index 9135e52..53351a7 100644 --- a/lib/mix/tasks/mob.doctor.ex +++ b/lib/mix/tasks/mob.doctor.ex @@ -2,6 +2,7 @@ defmodule Mix.Tasks.Mob.Doctor do use Mix.Task alias MobDev.NdkVersion + alias MobDev.Toolchain @shortdoc "Check your environment for common Mob setup issues" @@ -227,31 +228,33 @@ defmodule Mix.Tasks.Mob.Doctor do end defp check_zig do - case System.find_executable("zig") do - nil -> - {:warn, "zig", - "not on PATH — required as the C cross-compile driver from Phase 1 of the build-system migration", - __zig_install_fix__()} - - _ -> - case System.cmd("zig", ["version"], stderr_to_stdout: true) do - {out, 0} -> - version = String.trim(out) - {:ok, "zig", version, nil} - - _ -> - {:warn, "zig", "found but `zig version` failed", nil} - end - end + Toolchain.zig_status() + |> __zig_check_result__() end @doc false @spec __zig_install_fix__() :: String.t() - def __zig_install_fix__ do - "Install zig 0.17.0-dev.269+ebff43698:\n" <> - " mise: mise use zig@0.17.0-dev.269+ebff43698\n" <> - " asdf: asdf plugin add zig && asdf install zig 0.17.0-dev.269+ebff43698 && asdf local zig 0.17.0-dev.269+ebff43698\n" <> - " manual: https://ziglang.org/download/" + def __zig_install_fix__, do: Toolchain.zig_install_instructions() + + @doc false + @spec __zig_check_result__(Toolchain.zig_status()) :: + {:ok | :warn | :fail, String.t(), String.t(), String.t() | nil} + def __zig_check_result__(:missing) do + {:warn, "zig", + "not on PATH — required as the C cross-compile driver from Phase 1 of the build-system migration", + __zig_install_fix__()} + end + + def __zig_check_result__({:ok, version}), do: {:ok, "zig", version, nil} + + def __zig_check_result__({:version_mismatch, actual}) do + {:fail, "zig", + "version #{actual} found; Mob requires exactly #{Toolchain.required_zig_version()}", + __zig_install_fix__()} + end + + def __zig_check_result__({:version_command_failed, output, exit_status}) do + {:fail, "zig", "`zig version` exited #{exit_status}: #{output}", __zig_install_fix__()} end defp check_xcrun do diff --git a/lib/mob_dev/native_build.ex b/lib/mob_dev/native_build.ex index a9328a6..cdb4808 100644 --- a/lib/mob_dev/native_build.ex +++ b/lib/mob_dev/native_build.ex @@ -1,5 +1,6 @@ defmodule MobDev.NativeBuild do alias MobDev.Release + alias MobDev.Toolchain @moduledoc """ Builds native binaries (APK for Android, .app bundle for iOS simulator) @@ -196,7 +197,7 @@ defmodule MobDev.NativeBuild do # so on a current mob the fallback is a dead end; see zig_build_plan/3. legacy_c = Path.join(mob_dir, "android/jni/mob_nif.c") - case zig_build_plan(File.exists?(build_zig), zig_available?(), File.exists?(legacy_c)) do + case zig_build_plan(File.exists?(build_zig), Toolchain.zig_status(), File.exists?(legacy_c)) do :skip_no_build_zig -> :ok @@ -207,8 +208,8 @@ defmodule MobDev.NativeBuild do :ok - :zig_required -> - {:error, zig_required_message()} + {:zig_required, zig_status} -> + {:error, zig_required_message(zig_status)} :run_zig -> driver_tab = resolve_driver_tab_android(mob_dir) @@ -298,49 +299,63 @@ defmodule MobDev.NativeBuild do # a native build can succeed at all: # # build_zig? does the project ship jni/build.zig? - # zig? is `zig` on PATH (so build.zig can actually run)? + # zig_status does `zig version` match Mob's exact build toolchain? # legacy_c? does the mob dep still ship the C JNI source the CMake # fallback would compile (android/jni/mob_nif.c)? # # Outcomes: # :skip_no_build_zig no build.zig, nothing for this step to do. - # :run_zig zig present, drive the real build.zig path. + # :run_zig exact Zig present, drive the real build.zig path. # :legacy_cmake no zig, but the mob dep still has the C sources, # so CMake can compile them directly (old mob). - # :zig_required no zig AND no C sources (mob 0.7+): the build - # cannot succeed, so fail fast with a clear cause - # instead of limping into a cryptic CMake error. + # {:zig_required, status} + # zig is absent with no C fallback, mismatched, or + # broken; fail fast instead of invoking the build. @doc false - @spec zig_build_plan(boolean(), boolean(), boolean()) :: - :skip_no_build_zig | :run_zig | :legacy_cmake | :zig_required - def zig_build_plan(build_zig?, zig?, legacy_c?) - def zig_build_plan(false, _zig?, _legacy_c?), do: :skip_no_build_zig - def zig_build_plan(true, true, _legacy_c?), do: :run_zig - def zig_build_plan(true, false, true), do: :legacy_cmake - def zig_build_plan(true, false, false), do: :zig_required + @spec zig_build_plan(boolean(), Toolchain.zig_status(), boolean()) :: + :skip_no_build_zig | :run_zig | :legacy_cmake | {:zig_required, Toolchain.zig_status()} + def zig_build_plan(build_zig?, zig_status, legacy_c?) + def zig_build_plan(false, _zig_status, _legacy_c?), do: :skip_no_build_zig + def zig_build_plan(true, {:ok, _version}, _legacy_c?), do: :run_zig + def zig_build_plan(true, :missing, true), do: :legacy_cmake + def zig_build_plan(true, zig_status, _legacy_c?), do: {:zig_required, zig_status} # The actionable error shown when an Android native build needs `zig` but # it is not on PATH and the mob dep no longer ships the C fallback sources. # Public so the test suite can pin the guidance without driving a build. @doc false @spec zig_required_message() :: String.t() - def zig_required_message do + def zig_required_message, do: zig_required_message(:missing) + + @doc false + @spec zig_required_message(Toolchain.zig_status()) :: String.t() + def zig_required_message(zig_status) do """ - zig is not on your PATH, and this project's Android native build needs it. + #{zig_status_message(zig_status)} mob 0.7+ compiles the Android JNI layer with build.zig. The legacy CMake fallback would reference C sources (deps/mob/android/jni/mob_nif.c) that no longer ship with mob, so the build cannot succeed without zig. - Install zig 0.17.0-dev.269+ebff43698, then re-run `mix mob.deploy --native --android`: - mise: mise use zig@0.17.0-dev.269+ebff43698 - asdf: asdf plugin add zig && asdf install zig 0.17.0-dev.269+ebff43698 && asdf local zig 0.17.0-dev.269+ebff43698 - manual: https://ziglang.org/download/ (then put `zig` on your PATH) + #{Toolchain.zig_install_instructions()} + Then re-run `mix mob.deploy --native --android`. Verify your toolchain any time with `mix mob.doctor`.\ """ end + defp zig_status_message(:missing) do + "zig is not on your PATH, and this project's Android native build needs it." + end + + defp zig_status_message({:version_mismatch, actual}) do + "zig version mismatch: found #{actual}, but Mob requires #{Toolchain.required_zig_version()}." + end + + defp zig_status_message({:version_command_failed, output, exit_status}) do + "`zig version` exited #{exit_status}: #{output}" + end + # True if the app's build.zig handles `abi`. mob_dev builds all of # arm64-v8a/armeabi-v7a/x86_64 by default, but an app's app-owned build.zig # (copied at `mix mob.new` time) may predate x86_64 support (mob_new < 0.4.5) @@ -543,8 +558,6 @@ defmodule MobDev.NativeBuild do end end - defp zig_available?, do: not is_nil(System.find_executable("zig")) - # Downloads Chaquopy's CPython distribution iff Pythonx is a dep. # Returns `{:ok, nil}` for projects without Pythonx so the rest of the # Android pipeline runs unchanged. diff --git a/lib/mob_dev/toolchain.ex b/lib/mob_dev/toolchain.ex new file mode 100644 index 0000000..adb5f20 --- /dev/null +++ b/lib/mob_dev/toolchain.ex @@ -0,0 +1,57 @@ +defmodule MobDev.Toolchain do + @moduledoc false + + @tool_versions_path Path.expand("../../.tool-versions", __DIR__) + @external_resource @tool_versions_path + @required_zig_version @tool_versions_path + |> File.read!() + |> String.split("\n") + |> Enum.find_value(fn line -> + case String.split(line) do + ["zig", version] -> version + _ -> nil + end + end) || raise("zig is not pinned in #{@tool_versions_path}") + + @type zig_status :: + :missing + | {:ok, String.t()} + | {:version_mismatch, String.t()} + | {:version_command_failed, String.t(), non_neg_integer()} + + @spec required_zig_version() :: String.t() + def required_zig_version, do: @required_zig_version + + @spec zig_status() :: zig_status() + def zig_status do + case System.find_executable("zig") do + nil -> + :missing + + executable -> + zig_status_from_result(System.cmd(executable, ["version"], stderr_to_stdout: true)) + end + end + + @spec zig_status_from_result({String.t(), non_neg_integer()}) :: zig_status() + def zig_status_from_result({output, 0}) do + version = String.trim(output) + + if version == @required_zig_version do + {:ok, version} + else + {:version_mismatch, version} + end + end + + def zig_status_from_result({output, exit_status}) do + {:version_command_failed, String.trim(output), exit_status} + end + + @spec zig_install_instructions() :: String.t() + def zig_install_instructions do + "Install the exact Zig build toolchain with mise:\n" <> + " mise install zig@#{@required_zig_version}\n" <> + " mise use zig@#{@required_zig_version}" + end +end diff --git a/test/mix/tasks/mob_doctor_test.exs b/test/mix/tasks/mob_doctor_test.exs index 3b3c2c8..7a5698c 100644 --- a/test/mix/tasks/mob_doctor_test.exs +++ b/test/mix/tasks/mob_doctor_test.exs @@ -5,8 +5,45 @@ defmodule Mix.Tasks.Mob.DoctorTest do test "pins the exact application-build Zig version" do fix = Mix.Tasks.Mob.Doctor.__zig_install_fix__() - assert fix =~ "zig 0.17.0-dev.269+ebff43698" + assert fix =~ MobDev.Toolchain.required_zig_version() refute fix =~ "zig 0.15" + refute fix =~ "ziglang.org/download" + end + end + + describe "__zig_check_result__/1" do + test "accepts only the exact version" do + version = MobDev.Toolchain.required_zig_version() + + assert {:ok, "zig", ^version, nil} = + Mix.Tasks.Mob.Doctor.__zig_check_result__({:ok, version}) + end + + test "fails an installed 0.15.x version" do + assert {:fail, "zig", detail, fix} = + Mix.Tasks.Mob.Doctor.__zig_check_result__({:version_mismatch, "0.15.2"}) + + assert detail =~ "0.15.2" + assert fix =~ MobDev.Toolchain.required_zig_version() + end + + test "fails another nightly" do + assert {:fail, "zig", detail, _fix} = + Mix.Tasks.Mob.Doctor.__zig_check_result__( + {:version_mismatch, "0.17.0-dev.270+different"} + ) + + assert detail =~ "0.17.0-dev.270+different" + end + + test "fails when `zig version` fails" do + assert {:fail, "zig", detail, _fix} = + Mix.Tasks.Mob.Doctor.__zig_check_result__( + {:version_command_failed, "dyld failure", 127} + ) + + assert detail =~ "exited 127" + assert detail =~ "dyld failure" end end diff --git a/test/mob_dev/native_build_test.exs b/test/mob_dev/native_build_test.exs index 7fd066f..8ed08cf 100644 --- a/test/mob_dev/native_build_test.exs +++ b/test/mob_dev/native_build_test.exs @@ -1918,22 +1918,34 @@ defmodule MobDev.NativeBuildTest do describe "zig_build_plan/3 (fail fast when the JNI build can't succeed)" do test "no build.zig: nothing to do, regardless of zig or C sources" do - assert NativeBuild.zig_build_plan(false, false, false) == :skip_no_build_zig - assert NativeBuild.zig_build_plan(false, true, true) == :skip_no_build_zig - assert NativeBuild.zig_build_plan(false, false, true) == :skip_no_build_zig + assert NativeBuild.zig_build_plan(false, :missing, false) == :skip_no_build_zig + + exact = {:ok, MobDev.Toolchain.required_zig_version()} + assert NativeBuild.zig_build_plan(false, exact, true) == :skip_no_build_zig + + assert NativeBuild.zig_build_plan(false, {:version_mismatch, "old"}, true) == + :skip_no_build_zig end - test "zig present: drive the real build.zig path (C-source presence irrelevant)" do - assert NativeBuild.zig_build_plan(true, true, false) == :run_zig - assert NativeBuild.zig_build_plan(true, true, true) == :run_zig + test "exact Zig present: drive the real build.zig path" do + exact = {:ok, MobDev.Toolchain.required_zig_version()} + assert NativeBuild.zig_build_plan(true, exact, false) == :run_zig end test "no zig but the mob dep still ships C sources: CMake fallback can compile them" do - assert NativeBuild.zig_build_plan(true, false, true) == :legacy_cmake + assert NativeBuild.zig_build_plan(true, :missing, true) == :legacy_cmake end test "no zig AND no C sources (mob 0.7+): obvious failure, so signal :zig_required" do - assert NativeBuild.zig_build_plan(true, false, false) == :zig_required + assert NativeBuild.zig_build_plan(true, :missing, false) == {:zig_required, :missing} + end + + test "wrong or broken Zig fails even when legacy C sources exist" do + mismatch = {:version_mismatch, "0.15.2"} + failed = {:version_command_failed, "dyld failure", 127} + + assert NativeBuild.zig_build_plan(true, mismatch, true) == {:zig_required, mismatch} + assert NativeBuild.zig_build_plan(true, failed, true) == {:zig_required, failed} end end @@ -1945,11 +1957,26 @@ defmodule MobDev.NativeBuildTest do assert msg =~ "zig is not on your PATH" assert msg =~ "mob_nif.c" # the fix: the exact application-build version, plus how to verify - assert msg =~ "zig 0.17.0-dev.269+ebff43698" + assert msg =~ MobDev.Toolchain.required_zig_version() refute msg =~ "zig 0.15" + refute msg =~ "ziglang.org/download" assert msg =~ "mix mob.doctor" end + test "reports an installed version mismatch" do + msg = NativeBuild.zig_required_message({:version_mismatch, "0.15.2"}) + + assert msg =~ "found 0.15.2" + assert msg =~ MobDev.Toolchain.required_zig_version() + end + + test "reports a failed version command" do + msg = NativeBuild.zig_required_message({:version_command_failed, "dyld failure", 127}) + + assert msg =~ "exited 127" + assert msg =~ "dyld failure" + end + test "stays in plain prose (no em dashes leaking into user-facing output)" do refute NativeBuild.zig_required_message() =~ "—" end diff --git a/test/mob_dev/toolchain_test.exs b/test/mob_dev/toolchain_test.exs new file mode 100644 index 0000000..a61f2e3 --- /dev/null +++ b/test/mob_dev/toolchain_test.exs @@ -0,0 +1,31 @@ +defmodule MobDev.ToolchainTest do + use ExUnit.Case, async: true + + alias MobDev.Toolchain + + test "required Zig version stays in lockstep with .tool-versions" do + tool_versions = File.read!(Path.expand("../../.tool-versions", __DIR__)) + + assert tool_versions =~ "zig #{Toolchain.required_zig_version()}" + end + + test "accepts the exact required Zig version" do + version = Toolchain.required_zig_version() + assert Toolchain.zig_status_from_result({version <> "\n", 0}) == {:ok, version} + end + + test "rejects Zig 0.15.x" do + assert Toolchain.zig_status_from_result({"0.15.2\n", 0}) == + {:version_mismatch, "0.15.2"} + end + + test "rejects another nightly" do + assert Toolchain.zig_status_from_result({"0.17.0-dev.270+different\n", 0}) == + {:version_mismatch, "0.17.0-dev.270+different"} + end + + test "rejects a failed version command" do + assert Toolchain.zig_status_from_result({"dyld failure\n", 127}) == + {:version_command_failed, "dyld failure", 127} + end +end