diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b887ddb9..539b978f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -74,8 +74,8 @@ jobs: MOB_NEW_DIR: ${{ github.workspace }}/mob_new # `:macos_only` — tests that shell out to /usr/libexec/PlistBuddy # and similar macOS-bound tools. - # `:requires_zig` — the StaticNifs zig-ast-check round-trip; we - # don't install Zig in CI yet (it's a 30 MB download for one test). + # `:requires_zig` — run by the macOS `zig` job below, which has the + # pinned Zig and reads Mach-O objects with `nm`. # Both run by default on a maintainer's laptop; CI explicitly skips. run: mix test --exclude macos_only --exclude requires_zig @@ -135,6 +135,51 @@ jobs: - run: mix deps.get - run: mix test --only xcode_live test/mob_dev/xcode_live_test.exs + # `:requires_zig` tests compile generated driver tables and plugin NIFs with + # the pinned Zig (read from .tool-versions so CI can't drift from it). macOS, + # because the iOS objects are Mach-O and the test reads them with `nm`. + zig: + name: Zig (requires_zig) + runs-on: macos-26 + env: + MIX_ENV: test + HOMEBREW_NO_AUTO_UPDATE: '1' + steps: + - uses: actions/checkout@v4 + + - name: Install Elixir + run: | + brew install elixir + mix local.hex --force + mix local.rebar --force + + - name: Zig version from .tool-versions + id: zig + run: echo "version=$(awk '$1 == "zig" {print $2}' .tool-versions)" >> "$GITHUB_OUTPUT" + + - uses: mlugg/setup-zig@v2 + with: + version: ${{ steps.zig.outputs.version }} + # A cached .zig-cache holds configure results that name the zig + # binary by its absolute path, which changes every run. + use-cache: false + + # The Xcode 26.6 lane's cache (same runner, same MIX_ENV). On a cold + # cache vix's precompiled downloads failed on this runner (NIF :enoent, + # then header_too_long fetching libvips), and the source fallback needs + # glib. This job depends on that lane keeping the cache warm. + - name: Cache deps + _build + uses: actions/cache/restore@v4 + with: + path: | + deps + _build + key: mix-macos-26-${{ hashFiles('**/mix.lock') }} + restore-keys: mix-macos-26- + + - run: mix deps.get + - run: mix test --only requires_zig + security_scan: name: Security scan needs: test diff --git a/.tool-versions b/.tool-versions index b3d9d31b..e6da9a5c 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,3 +1,3 @@ elixir 1.20.0-otp-29 erlang 29.1.2 -zig 0.17.0-dev.269+ebff43698 +zig 0.17.0 diff --git a/CHANGELOG.md b/CHANGELOG.md index 4029c948..803e1b3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,21 @@ ## [Unreleased] +### Changed + +- **Zig pin is 0.17.0.** Replaces `0.17.0-dev.269+ebff43698` now that 0.17.0 + is the stable with the macOS 26 SDK fix the nightly was taken for. + `mix mob.doctor` / `mix mob.deploy --native` still require an exact + `zig version` match. Companion to mob and mob_new. + ### Fixed +- **`mix mob.doctor` checks the iOS Swift glob for Zig 0.17.0.** It fails + `ios/build.zig` / `ios/build_device.zig` that still use `b.build_root`, + which 0.17.0 removed (configure panics with `no field named + 'build_root'`). It warns when the glob opens `mob/ios` without + `b.dependOnDirectoryContents`: 0.17.0 caches the configure phase, so a + Swift file mob adds is missed until the zig cache is cleared. Comments and + strings are ignored. The fix names mob_new's template block. - **The Hex package ships `guides/` (MOB-501).** It shipped only the default files, so an app had no `deps/mob_dev/guides/` and the publishing and security-scan guides were only on hexdocs. @@ -10,6 +24,17 @@ one. The task docs and `guides/publishing_to_google_play.md` now list the file's four keys and name `mix mob.setup.google_play`, which writes it. +### Upgrading + +- **Zig 0.17.0.** In each app, set `zig 0.17.0` in `.tool-versions` + (`mise use zig@0.17.0` installs it and edits the file; `mise install + zig@0.17.0` alone leaves the app on the nightly, and the native build + rejects it). Existing iOS apps must port the Swift glob in + `ios/build.zig` and `ios/build_device.zig`: `std.Io.Dir.openDirAbsolute` + instead of `b.build_root`, with `b.dependOnDirectoryContents` on the + directory first (doctor says how). Android `build.zig` from current + templates already configures on 0.17.0. + ## [0.7.30] - 2026-10-23 ### Security diff --git a/lib/mix/tasks/mob.doctor.ex b/lib/mix/tasks/mob.doctor.ex index 3aa23729..41bf0b0d 100644 --- a/lib/mix/tasks/mob.doctor.ex +++ b/lib/mix/tasks/mob.doctor.ex @@ -739,6 +739,7 @@ defmodule Mix.Tasks.Mob.Doctor do check_compiled(), check_driver_tab(), check_plugin_build_options(), + check_ios_zig_build_root(), check_inactive_nif_plugins(), check_component_event_jni(), check_sheet_dismiss_wire_shape(), @@ -1043,6 +1044,93 @@ defmodule Mix.Tasks.Mob.Doctor do Enum.reject(required, &String.contains?(content, "\"#{&1}\"")) end + # Zig 0.17.0 removed `Build.build_root`. iOS templates generated against + # 0.17.0-dev.269 still call `b.build_root.handle.openDir` to glob mob Swift + # sources; configure then exits with `no field named 'build_root'`. 0.17.0 + # also caches the configure phase, so a glob without + # `b.dependOnDirectoryContents` misses a Swift file mob adds until the zig + # cache is cleared. + @swift_glob_fix "Before the Swift glob, add `b.dependOnDirectoryContents(.{ " <> + ".cwd_relative = b.fmt(\"{s}/ios\", .{mob_dir}) });`, and open " <> + "the directory with `std.Io.Dir.openDirAbsolute(...)`, not " <> + "`b.build_root.handle.openDir(...)`. Or copy the block from a " <> + "freshly generated app (mob_new's ios/build.zig.eex)." + + defp check_ios_zig_build_root do + for path <- ["ios/build.zig", "ios/build_device.zig"], + {:ok, content} <- [File.read(path)], + status = __ios_swift_glob_status__(content), + status != :ok do + case status do + :build_root -> + {:fail, "zig 0.17.0 (#{path})", "uses b.build_root, which Zig 0.17.0 removed", + @swift_glob_fix} + + :untracked -> + {:warn, "zig 0.17.0 (#{path})", + "the mob/ios Swift glob isn't a configure dependency, so a Swift file " <> + "mob adds is missed until the zig cache is cleared", @swift_glob_fix} + end + end + end + + @doc false + # :build_root when Zig code (not a comment or string) reads `build_root` off + # `b`, whitespace and line breaks allowed around the dot; :untracked for a + # glob opened with openDirAbsolute without the generated template's + # `b.dependOnDirectoryContents` on `"{s}/ios"` (a call on some other + # directory doesn't count); else :ok. + @spec __ios_swift_glob_status__(String.t()) :: :ok | :build_root | :untracked + def __ios_swift_glob_status__(content) do + code = zig_strip(content, false, []) + + tracked = + Regex.compile!( + "\\bb\\s*\\.\\s*dependOnDirectoryContents\\s*\\(\\s*\\.\\{\\s*\\.cwd_relative\\s*=" <> + "\\s*b\\s*\\.\\s*fmt\\s*\\(\\s*\"\\{s\\}/ios\"" + ) + + cond do + Regex.match?(Regex.compile!("\\bb\\s*\\.\\s*build_root\\b"), code) -> + :build_root + + String.contains?(code, "openDirAbsolute") and + not Regex.match?(tracked, zig_strip(content, true, [])) -> + :untracked + + true -> + :ok + end + end + + # Blanks out `//` comments and `\\` multiline-string lines, keeping + # newlines. Quoted string/char literals are kept when `keep_strings`, else + # blanked. + defp zig_strip(<<>>, _keep, acc), do: acc |> Enum.reverse() |> IO.iodata_to_binary() + defp zig_strip(<<"//", rest::binary>>, keep, acc), do: zig_strip(skip_line(rest), keep, acc) + defp zig_strip(<<"\\\\", rest::binary>>, keep, acc), do: zig_strip(skip_line(rest), keep, acc) + + defp zig_strip(<>, keep, acc) when q in [?", ?'] do + {literal, rest} = take_literal(rest, q, [q]) + zig_strip(rest, keep, [if(keep, do: literal, else: " ") | acc]) + end + + defp zig_strip(<>, keep, acc), do: zig_strip(rest, keep, [c | acc]) + + defp skip_line(rest) do + case :binary.split(rest, "\n") do + [_, after_newline] -> "\n" <> after_newline + [_] -> "" + end + end + + # Consumes a literal after its opening quote; returns its text and the rest. + defp take_literal(<<"\\", e, rest::binary>>, q, acc), do: take_literal(rest, q, [e, ?\\ | acc]) + defp take_literal(<>, q, acc), do: {Enum.reverse([q | acc]), rest} + defp take_literal(<<"\n", _::binary>> = rest, _q, acc), do: {Enum.reverse(acc), rest} + defp take_literal(<>, q, acc), do: take_literal(rest, q, [c | acc]) + defp take_literal(<<>>, _q, acc), do: {Enum.reverse(acc), ""} + # ── Installed-but-inactive NIF plugins (MOB-281) ───────────────────────────── # # Same check `mix mob.deploy --native` prints: a dep shipping a NIF that isn't diff --git a/lib/mob_dev/toolchain.ex b/lib/mob_dev/toolchain.ex index edb353ec..65dfd0e0 100644 --- a/lib/mob_dev/toolchain.ex +++ b/lib/mob_dev/toolchain.ex @@ -3,7 +3,7 @@ defmodule MobDev.Toolchain do # This value must live in packaged source; the root toolchain file is not in # Hex archives. The source test keeps both release authorities in lockstep. - @required_zig_version "0.17.0-dev.269+ebff43698" + @required_zig_version "0.17.0" # iPhone Duo needs the iOS 27.1 SDK, which ships with Xcode 27.1 (MOB-201). @duo_min_version {27, 1, 0} diff --git a/test/mix/tasks/mob_doctor_test.exs b/test/mix/tasks/mob_doctor_test.exs index 04b1c971..e04e8963 100644 --- a/test/mix/tasks/mob_doctor_test.exs +++ b/test/mix/tasks/mob_doctor_test.exs @@ -106,6 +106,64 @@ defmodule Mix.Tasks.Mob.DoctorTest do end end + describe "__ios_swift_glob_status__/1" do + @tracked """ + b.dependOnDirectoryContents(.{ .cwd_relative = b.fmt("{s}/ios", .{mob_dir}) }); + var mob_ios = std.Io.Dir.openDirAbsolute(glob_io, b.fmt("{s}/ios", .{mob_dir}), .{ .iterate = true }) catch @panic("mob ios dir not found"); + """ + + defp status(content), do: Mix.Tasks.Mob.Doctor.__ios_swift_glob_status__(content) + + test "the 0.17-dev glob that calls b.build_root fails" do + assert status(""" + var mob_ios = b.build_root.handle.openDir(glob_io, b.fmt("{s}/ios", .{mob_dir}), .{ .iterate = true }) catch @panic("mob ios dir not found"); + """) == :build_root + end + + test "the 0.17.0 glob passes only when it declares the directory a configure dependency" do + assert status(@tracked) == :ok + + assert status(""" + var mob_ios = std.Io.Dir.openDirAbsolute(glob_io, b.fmt("{s}/ios", .{mob_dir}), .{ .iterate = true }) catch @panic("mob ios dir not found"); + """) == :untracked + + assert status(""" + // b.dependOnDirectoryContents(.{ .cwd_relative = mob_ios_dir }); + var mob_ios = std.Io.Dir.openDirAbsolute(glob_io, mob_ios_dir, .{ .iterate = true }) catch @panic("x"); + """) == :untracked + + assert status(""" + b.dependOnDirectoryContents(.{ .cwd_relative = "some/other/directory" }); + var mob_ios = std.Io.Dir.openDirAbsolute(glob_io, b.fmt("{s}/ios", .{mob_dir}), .{ .iterate = true }) catch @panic("x"); + """) == :untracked + end + + test "a migration comment that names the old field passes" do + assert status("// Replaced b.build_root.handle.openDir with openDirAbsolute.\n" <> @tracked) == + :ok + end + + test "a use after a string holding //, and one split across lines, fail" do + assert status(""" + const url = "https://example"; _ = b.build_root; + """) == :build_root + + assert status(""" + var dir = b + .build_root.handle; + """) == :build_root + end + + test "the name inside a string or a multiline string passes" do + assert status(""" + const note = "b.build_root was removed"; + const text = + \\\\see b.build_root + ; + """) == :ok + end + end + describe "__component_event_jni_mismatched__/1 (MOB-98 JNI owner check)" do test "flags the pre-fix declaration (bare external fun, no @JvmStatic)" do pre_fix = """ diff --git a/test/mob_dev/static_nifs_test.exs b/test/mob_dev/static_nifs_test.exs index 5c64788e..8dfb3185 100644 --- a/test/mob_dev/static_nifs_test.exs +++ b/test/mob_dev/static_nifs_test.exs @@ -359,9 +359,8 @@ defmodule MobDev.StaticNifsTest do assert out =~ "build_options" end - # The `zig` binary isn't installed in CI (we don't compile Zig in the - # Elixir test suite). Skip there via `mix test --exclude requires_zig`; - # local dev runs with Zig on PATH still execute this. + # Needs the pinned `zig` on PATH. CI's Linux job excludes :requires_zig; + # its macOS `zig` job runs these. @tag :requires_zig test "produces parseable Zig source (round-trip via zig ast-check)" do for platform <- [:ios, :android] do diff --git a/test/mob_dev/toolchain_test.exs b/test/mob_dev/toolchain_test.exs index 3ebed9c7..f01fa23a 100644 --- a/test/mob_dev/toolchain_test.exs +++ b/test/mob_dev/toolchain_test.exs @@ -34,6 +34,11 @@ defmodule MobDev.ToolchainTest do {:version_mismatch, "0.17.0-dev.270+different"} end + test "rejects the previous 0.17.0-dev.269 nightly" do + assert Toolchain.zig_status_from_result({"0.17.0-dev.269+ebff43698\n", 0}) == + {:version_mismatch, "0.17.0-dev.269+ebff43698"} + end + test "rejects a failed version command" do assert Toolchain.zig_status_from_result({"dyld failure\n", 127}) == {:version_command_failed, "dyld failure", 127} diff --git a/test/mob_dev/zig_build_test.exs b/test/mob_dev/zig_build_test.exs index 7a400dfa..a2e63e5e 100644 --- a/test/mob_dev/zig_build_test.exs +++ b/test/mob_dev/zig_build_test.exs @@ -82,6 +82,8 @@ defmodule MobDev.ZigBuildTest do describe "run/4 against a real zig build" do @describetag :requires_zig + # A cold zig cache (CI) compiles the target's libc and compiler_rt first. + @describetag timeout: :timer.minutes(5) @describetag :tmp_dir setup %{tmp_dir: dir} do