Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 47 additions & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion .tool-versions
Original file line number Diff line number Diff line change
@@ -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
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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
Expand Down
88 changes: 88 additions & 0 deletions lib/mix/tasks/mob.doctor.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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(<<q, rest::binary>>, 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(<<c, rest::binary>>, 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, rest::binary>>, q, acc), do: {Enum.reverse([q | acc]), rest}
defp take_literal(<<"\n", _::binary>> = rest, _q, acc), do: {Enum.reverse(acc), rest}
defp take_literal(<<c, rest::binary>>, 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
Expand Down
2 changes: 1 addition & 1 deletion lib/mob_dev/toolchain.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
58 changes: 58 additions & 0 deletions test/mix/tasks/mob_doctor_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = """
Expand Down
5 changes: 2 additions & 3 deletions test/mob_dev/static_nifs_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions test/mob_dev/toolchain_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
2 changes: 2 additions & 0 deletions test/mob_dev/zig_build_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading