Make Android native deploys update-only and target-frozen - #43
Open
dl-alexandre wants to merge 569 commits into
Open
Make Android native deploys update-only and target-frozen#43dl-alexandre wants to merge 569 commits into
dl-alexandre wants to merge 569 commits into
Conversation
Closes the armv7 follow-up from issue #19. `mix mob.add_nif --type c | rustler | zigler --demo` now produces working NIFs on both `arm64-v8a` and `armeabi-v7a` Android targets — no hand-editing. Before: the armv7 `.so` was missing the `<name>_nif_init` symbols the driver_tab declared as external, so `System.loadLibrary` failed at boot with `UnsatisfiedLinkError: cannot locate symbol …`. ## What changes ### Per-ABI mob_dev build atoms The build pipeline previously used a single `:android` platform atom for the cross-compile chain. Split into `:android_arm64` and `:android_arm32`: * `rust_target_for/1` → `aarch64-linux-android` vs `armv7-linux-androideabi`. * `zig_build_target_for/1` → `aarch64-linux-android` vs `arm-linux-androideabi` (Zig's armv7-android triple; the `androideabi` ABI marker matches Rust's for ELF-level compat when both archives land in the same `.so`). * `sdkroot_args_for/1` accepts both; the NDK sysroot is shared. * `project_nif_zig_args/1`'s arch filter now maps each per-ABI build atom to the matching `MobDev.StaticNifs` arch atom so entries declared with e.g. `archs: [:android_arm64]` only land in their intended ABI's `.so`. Entries with `archs: [:all]` or `[:android]` (the default) appear in both builds. ### Per-ABI Zigler outputs `cross_compile_zig_nif/4` now passes `--prefix zig-out-<target>` to `zig build` so each target's archive lives at a distinct path inside the Zigler staging dir. Without this the second cross-compile (armv7) clobbered the first's `zig-out/lib/lib<Module>.a`. ### Per-ABI nif_args fan-out `zig_build_android_objects/3` now computes `project_nif_zig_args/1` twice — once for `:android_arm64`, once for `:android_arm32` — and threads each ABI's resulting `nif_args` into its matching `run_zig_android_objects/7` call. Each ABI's `build.zig` invocation receives its own `-Dproject_rust_libs=` set pointing at the correct per-target archives. ### StaticNifs platform table `on_platform?/2` now accepts per-arch atoms (`:android_arm64`, `:android_arm32`, `:ios_device`, `:ios_sim`) in addition to the existing `:ios`/`:android` broadeners. Each is a singleton via a new `platform_archs/1` clause, so the existing intersection check still behaves identically for callers that pass the broad atom. Type updated: `platform :: :ios | :android | arch()`. ## Verified `mix mob.new android_nif_demo --android` + all three demos (`mix mob.add_nif greet_{c,rust,zig} --type {c,rustler,zigler} --demo`) deployed to three physical Motorola devices via `mix mob.deploy --native --device <serial>`: | Device | ABI | greet_c | greet_rust | greet_zig | |--------------------------------|-------------|---------|------------|-----------| | moto g power (2021) | arm64-v8a | ✓ | ✓ | ✓ | | moto g power 5G (2024) | arm64-v8a | ✓ | ✓ | ✓ | | moto e (Android 10) | armeabi-v7a | ✓ | ✓ | ✓ | Each NIF returns its expected string ("Hello from C!" / Rust / Zig) over Erlang distribution. `mix test` clean (1288 tests, 0 failures). ## Out of scope (orthogonal) * Pythonx on armeabi-v7a: Chaquopy's CPython distribution doesn't ship arm32, so projects depending on Pythonx still can't deploy to the moto e. Tracked separately in `mob/nif_future.md`. * `mix mob.doctor` doesn't yet warn when `rustup target add armv7-linux-androideabi` is missing — small follow-up; mirror the existing aarch64 check.
Matches the mob bump. Verified mob_dev compiles + 1288 tests pass under the new toolchain. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Project NIF cross-compile is now ABI-aware. Splits the :android build atom into :android_arm64 and :android_arm32, updates rust_target_for, zig_build_target_for, and sdkroot_args_for clauses for both, and routes project_nif_zig_args/1 twice (once per ABI) from zig_build_android_objects/3. Zigler gets a distinct --prefix zig-out-<target> per arch so the second cross-compile doesn't clobber the first's archive. MobDev.StaticNifs.on_platform?/2 now accepts the per-arch atoms. Verified by the other agent on three real devices: - moto g power (2021) arm64-v8a greet_c/rust/zig all ✓ - moto g power 5G (2024) arm64-v8a all ✓ - moto e (Android 10) armeabi-v7a all ✓ Issue #19's "armv7 follow-up" is now complete: all three demo NIF scaffolds work on arm64 and armv7 Android hardware via plain \`mix mob.deploy --native --device <serial>\` with zero hand-editing. Follow-up: rustup target add armv7-linux-androideabi must be on the dev box. Worth a mix mob.doctor line — captured as a TODO. 1298 tests pass under OTP 29.0 + Elixir 1.20.0-rc.5. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Per the armv7 merge note, project NIFs that use Rustler need both
\`aarch64-linux-android\` and \`armv7-linux-androideabi\` rustup
targets installed locally before \`mix mob.deploy --native\` will
cross-compile to either Android ABI. Without them, \`cargo build
--target=...\` fails with "toolchain '<x>' is not installed", and
the failure surfaces deep in the zig build pipeline rather than
upfront in doctor.
Detection is gated on \`native/*/Cargo.toml\` (project has a Rust NIF)
+ \`rustup\` on PATH. Doctor reports:
✓ rust android targets — aarch64 + armv7 ✓
✗ rust android targets — missing: armv7-linux-androideabi
Install:
rustup target add armv7-linux-androideabi
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
macOS doesn't ship GNU coreutils 'timeout' on the default PATH; the existing System.cmd("sh", ["-c", "timeout 8 ..."]) pattern fails with /bin/sh: timeout: command not found. Replaced with pure-Elixir Task.async/yield/shutdown(:brutal_kill) which works on any Unix without external dependencies.
Affects lib/mob_dev/discovery/android.ex and lib/mob_dev/tunnel.ex.
Published OTP runtime tarballs at https://github.com/GenericJam/mob/releases/tag/otp-550d7b78 Bundle contents (per priv/security/bundled_versions.exs): - erts: 17.0 - otp_release: 29 (was rc3 at hash 7721ab74) - elixir: 1.20.0-rc.5 (bundled stdlib) - openssl: 3.4.0 (unchanged) - exqlite_beam: 0.36.0 (unchanged; iOS doesn't ship) - platforms: android arm64, android arm32, ios_sim, ios_device Verified end-to-end on iPhone with nif_combo: GreetC, GreetRust, GreetZig, Pythonx all functional under the new runtime. Manifest housekeeping: dropped the stale "73ba6e0f" entry that never matched the actual published @otp_hash ("7721ab74") — the bundled_versions.exs file documents what's *currently active*, not all past releases. If we want a backlog later, add a separate CHANGELOG.md. iOS sim cross-compile fix that landed in this round: the xcomp/erl-xcomp-arm64-iossimulator.conf needs \`--disable-security-hardening-flags\` (which the iOS device conf already has) — without it, OTP 29.0 puts -fstack-protector-strong in DED_LDFLAGS and Apple ld rejects it for the runtime_tools dyntrace.so build. Currently a local OTP-source patch; should be promoted to scripts/release/patches/ as a follow-up. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The fake-tarball builder hard-coded \`erts-16.3\` even though the manifest now says \`17.0\` after the OTP 29.0 bump. Result: a spurious MOB-DRIFT-<plat>-erts finding fired alongside the Elixir-drift the test was deliberately creating, blowing the assertion's exact-match on the findings list (2 findings produced, 1 expected). Sourcing the path from \`BundledVersions.active().erts\` keeps the test in lock-step with manifest bumps so we don't have to remember to update it on each release. 1298 tests pass. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Without this, a user runs `mix mob.add_nif foo --type rustler`, ships to iOS fine, then hits the rustler-0.37 Bionic panic (`undefined symbol: enif_priv_data`) on first Android deploy and has to figure out the workaround themselves. Embed the `[patch.crates-io]` block pointing at GenericJam/rustler's genericjam-android-rtld-default branch directly in the scaffolded Cargo.toml. Comment says "DROP WHEN UPSTREAM RUSTLER MERGES" and points at mob#7, so future maintainers (and the test that pins this exact wording) know it's transient. Test asserts the block exists, points at the right git ref, and carries the drop-when cue.
A single canonical answer to "how does Mob handle my Rust crate / Zig
NIF / embedded Python" so a user or agent can read docs instead of
build code.
Per backend, the guide states:
1. How the upstream library works (with links: erl_nif man page,
Rustler crate + repo, Zigler hex + repo, Pythonx hex + repo,
BeeWare Python-Apple-support, Chaquopy, PyO3, uv).
2. What Mob changes for static-link on-device.
3. What stays standard (Cargo deps, multi-file crates, Rustler
type-mapping surface, etc.).
Python gets special attention since it's two-sourced: BeeWare's
Python-Apple-support on iOS (release b13, Python 3.13) and
Chaquopy on Android (3.13.9-0 from Maven Central) — because no
upstream ships a single Python 3.11+ distribution covering both
platforms. The guide explains why two sources, what's identical
(stdlib comes from python.org 3.13 on both), and where the
divergence is acceptable.
Three current workarounds (Rustler Android dlsym patch, Zigler fork,
Chaquopy as Android source) get a final-section "drop when…" table
so future maintainers know the exit criteria.
README.md: replace the partially-stale "static-link gotcha" warning
with a pointer at the new guide. The warning's claim that users
must "manually wire the archive into ios/build.zig + android/jni/"
hasn't been true since mob_dev started auto-wiring.
mob.add_nif.ex: tighten Rustler + Zigler stub moduledocs accordingly.
Drop the manual-wiring claim; keep the static-link mention (and the
"Static linking" substring tests rely on); point at the guide for
the rest. The Zig toolchain / macOS 26 / nif_init collision notes
collapse into one paragraph that explains why the fork is pinned.
…alias
Normalizes the CLI vocabulary across mob_dev's two NIF-adjacent commands.
Previously `mix mob.enable python` (generic name) was the odd one out:
`mob.add_nif --type rustler` and `--type zigler` are library-named, as is
the existing `mob.enable mlx` feature. Picking the library-named route
across the board is consistent and matches the technical-audience tone
of the rest of the docs — the user is going to see `:pythonx` in their
deps either way.
Changes:
* `@valid_features` adds `pythonx`. The list shown in usage/error
messages now leads with the canonical name; `python` is hidden from
the list but accepted via a separate `@deprecated_features` map.
* `dispatch/3` gets a `"pythonx"` clause that delegates to a new
`enable_pythonx/2` helper. The `"python"` clause keeps working but
emits a deprecation notice and routes to the same helper.
* Notice header is now "Next steps for pythonx:" — caught by a new
test so a half-revert (renaming the dispatch but forgetting the
header) fails loudly.
Tests:
* `describe "pythonx feature"` replaces the old `describe "python feature"`
with identical assertions plus a notice-header check.
* `describe "python (deprecated alias)"` covers (a) functional parity
(same dep + same generated module) and (b) the deprecation notice
naming both old and new spellings. The comment in that describe
notes when to delete the block.
Docs (this commit + companion mob_new commit): every `mix mob.enable python`
in user-facing prose is replaced with `mix mob.enable pythonx`. The
deprecation notice itself and the test that asserts on it still mention
both spellings, intentionally.
The mob.enable moduledoc explains the rename and points at the
consistency rationale, so users hitting `mix help mob.enable` see why.
Removes the @deprecated_features map, the python dispatch clause, the deprecation-notice paragraph, and the matching test block. `mob.enable python` now errors as "Unknown feature(s): python. Valid: ..." like any other typo. No external users yet so the deprecation cycle wasn't buying anything. Leaves `pythonx` as the canonical, only-accepted name.
Covers the 4-step manual hookup for users who already have Rust code written outside mob.add_nif scaffolding (one crate or a whole multi-crate project authored against vanilla Rustler): 1. Drop crate(s) into native/<name>/. Cargo handles Rust internals. 2. Add staticlib to each crate-type. 3. Add the [patch.crates-io] block (Android dlsym workaround). 4. Register each crate in mob.exs :static_nifs; run mob.regen_driver_tab. Plus the cwd note (umbrella users: run from the child app dir), the one-time rustup target list, and a caveat about external C deps in transitive crates. Goal: a user or agent reading this doc never has to read mob_dev's source to figure out how to attach an existing Rust project. They can be as informed as we are by following docs alone. Also: register guides/nifs.md in mix.exs so ExDoc picks it up under the Guides group.
NxEigen is an Eigen-backed CPU Nx backend (header-only C++). Mob already
ships a working pipeline for Rust/Zig/Pythonx NIFs but had nothing for
"normal" C++ NIFs that should statically link into the app binary.
This adds that path:
* `MobDev.NxEigenNif` — per-target cross-compile module mirroring
`MobDev.Release.OpenSSL.CryptoNif` (clang++/xcrun toolchains, BN-folded
Eigen sources + our Eigen-FFT bridge in priv/cpp_nif, archive +
ranlib + nm symbol verification).
* `MobDev.NativeBuild` integration: `maybe_build_nxeigen/1` runs per
target during `mix mob.deploy --native` when `:nx_eigen` is a project
dep; `nxeigen_zig_args_{ios,android}/1` thread `-D` flags into the
Zig build; `install_nx_eigen_otp_lib/1` stages an empty
`<otp_root>/lib/{nx_eigen,fine}-VSN/priv/` so `:code.priv_dir/1`
resolves and the static-NIF lookup fires (mirrors the EMLX trick).
* Default `:nx_eigen` static_nifs entry guarded by
`MOB_STATIC_NX_EIGEN_NIF`.
* `mix mob.enable nxeigen` adds `:nx + :nx_eigen` deps and generates
`<App>.NxEigenInit` (parallel to the MLX init module).
* `priv/cpp_nif/nx_eigen_fft_eigen.cpp` — Eigen-kissfft bridge so
`Nx.fft` works on-device without bundling FFTW.
Empirically verified: libnx_eigen.a cross-compiles for arm64-ios +
arm64-android, statically links into the app binary, and
`Nx.tensor([1,2,3,4], backend: NxEigen.Backend) |> Nx.sum() |>
Nx.to_number()` returns 10 on physical iPhone + moto g power.
Tests:
* nx_eigen_nif_test.exs (27 tests) — sources, target_spec, cxxflags,
check_symbol_present, full build/2 Mox sequences.
* native_build_test.exs (8 new tests) — nxeigen_zig_args asymmetry,
install_nx_eigen_otp_lib tmpfs integration, idempotency.
* mob_enable_test.exs (4 new tests) — deps, init module, notice,
cross-platform messaging; valid-features list extended.
* static_nifs_test.exs — Android Zig output now includes nx_eigen
comptime guard.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds the v2 MLX iOS tarball variant flagged in the existing
mob.enable mlx notice: a Metal-enabled `libmlx.a` + precompiled
`mlx.metallib`, shipped into the iOS .app bundle alongside the
binary so MLX's `load_colocated_library("mlx")` finds it at runtime
and `device: :gpu` in EMLX.Backend works.
* `scripts/release/mlx/ios_device_metal.sh` — sibling to the CPU
build script; configures with `-DMLX_BUILD_METAL=ON`, runs the
full kernel compile, installs both artifacts to PREFIX/lib.
Checks for the Xcode Metal Toolchain (optional ~700 MB component:
`xcodebuild -downloadComponent MetalToolchain`) and fails early
with that command in the error if absent.
* `scripts/release/mlx/patches/0001-ios-metal-build.patch` — MLX
0.25.1 hardcodes `xcrun -sdk macosx` and `CMAKE_SYSTEM_NAME=Darwin`
for the Metal path. The patch switches both based on
CMAKE_SYSTEM_NAME so iOS picks iphoneos SDK + `-mios-version-min`.
Applied idempotently via a sentinel-grep in `_lib.sh`.
* `MobDev.MLXDownloader.metallib_path/1` — returns the metallib
path when the cached bundle ships one, `nil` otherwise. Used by
`bundle_ios_device_app` to decide whether to copy.
* `MobDev.NativeBuild.maybe_bundle_mlx_metallib/1` — copies the
metallib into the .app at deploy time. No-op for CPU bundles.
Wired into `bundle_ios_device_app/4`.
Empirically: YOLOv8n forward pass on Kevin's iPhone 17 Pro
(arm64) goes from 188s (NxEigen scalar CPU) → 7-8ms
(EMLX/Apple Accelerate SIMD) → 7-8ms (EMLX/Metal GPU). Same 5
detections every level. Stable across 10 consecutive runs.
Metal isn't dramatically faster than Accelerate for YOLOv8n (small
model, per-kernel dispatch overhead dominates) but the path now
works end-to-end and unlocks larger models / batched inference.
Tests:
* mlx_downloader_test.exs — 3 new tests for metallib_path/1.
* native_build_test.exs — 4 new tests: maybe_bundle_mlx_metallib
with tmpfs (copies when present, no-op when absent) + script +
patch file presence pins.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
C++ NIF support: NxEigen cross-compile + EMLX Metal GPU on iOS
Replaces an in-progress draft (committed locally pre-NxEigen-landing)
with a section that matches what `mix mob.enable {nxeigen,mlx}`
actually does today:
* NxEigen is the cross-platform CPU choice — works on iOS device +
sim and Android arm64/arm32, single `mix mob.enable nxeigen`
invocation. Builds as a C++ static-NIF entry; no per-target
prebuilt downloads.
* EMLX defaults to Apple Accelerate (CPU); Metal GPU on iOS device
is opt-in via `device: :gpu` and ships with the deploy pipeline
bundling `mlx.metallib`.
* EXLA section preserved — the three-step "why not" (macOS-only
NIF build, no static-nif glue, no mobile libxla.so) is evergreen
and saves explanation cycles.
* `Nx.BinaryBackend` row stays as the zero-setup fallback.
Both `mob.enable` paths print detailed next-steps, so the doc just
points there rather than duplicating the per-step instructions.
Restructures the "multiple Rust NIFs per app" section to honour filmor's stated preference (one Rustler crate per app, multiple #[rustler::nif] functions inside it, single rustler::init! call). Multi-crate static linking still works and is documented as an escape hatch — `nif_combo` remains the proof — but it's no longer the lead recommendation. The escape-hatch section calls out the specific tradeoffs (duplicated deps without a workspace, reliance on the 0.37 symbol mangling, less upstream support) so a user choosing it does so deliberately. Reference: rusterlium/rustler#686
…rce links * `.github/workflows/test.yml` — runs `mix test`, format check, and `mix credo --strict` on every push to master and every PR. Caches deps + _build keyed on mix.lock. Test job uses `--max-cases 4` to dodge the known high-parallelism flake in test/mob_dev/google_play/setup_wizard_test.exs (environmental, not a real failure). * `.github/workflows/test.yml` also runs `mix mob.security_scan` after the test job — this is mob_dev's own task, so no extra dependency wiring is needed. Installs osv-scanner from the upstream release binary; missing tools degrade to warnings per the scanner's contract. Currently `continue-on-error: true` while we establish a baseline; flip to `--strict` once findings are triaged. * `.github/workflows/release.yml` — on tag push, creates a GitHub Release whose body is the matching `## [<tag>]` section from CHANGELOG.md (falls back to auto-generated commit notes). * `CHANGELOG.md` — Keep-a-Changelog format, points at hexdocs for full module reference. Backfilled entries for 0.5.2 and 0.5.3; Unreleased section captures doc-link fix + CI workflows. Docs polish in the same commit since CHANGELOG.md is wired in: * `source_url_pattern` corrected — was `/blob/main/...` but the default branch is `master`, so every `</>` in the rendered docs 404'd. * CHANGELOG.md added to the `extras:` list so it lands in the HexDocs sidebar.
Same shape as mob's hex_publish job — runs after github_release, publishes via mix hex.publish --yes when the HEX_API_KEY repo secret is set, emits a notice and skips cleanly otherwise.
Two functions (nxeigen_zig_args_ios/1 and _android/1) had:
@doc \"\"\"
Returns the iOS-side zig -D flags...
Public for testing.
\"\"\"
@doc false
@SPEC ...
def ...
`@doc false` immediately overwrites the previous `@doc`, so Elixir
emits a "redefining @doc attribute previously set" warning on each.
mix compile --warnings-as-errors (which the new CI workflow uses)
turned that into a hard failure.
Intent was clearly "documented for code readers, hidden from ex_doc"
— the docstrings even said "Public for testing." Converting the
descriptive blocks to plain `#` comments above the function
preserves the reader-facing documentation while letting `@doc false`
do the ex_doc-hiding job it was meant for.
mix format --check-formatted gate in the new CI workflow flagged test/mob_dev/native_build_test.exs:1010 where the assert message ran past the 98-char line limit. Trivial wrap.
Five real fixes, two false-positive silences:
* lib/mix/tasks/mob.battery_bench_ios.ex:1079 — comment started
"Lets `test_nif_ios` accept ..." which is narrator voice. Rewrote
to "`test_nif_ios` therefore accepts ..." — same content, no
"Lets us" lead-in.
* test/mob_dev/nx_eigen_nif_test.exs:360 — `unless/else` flipped to
`if/else` with branches swapped. The original ordering put the
early-exit (`:skipped`) inside the `unless` body, which is hard
to read. The `if X.installed?` form reads forward.
* test/mob_dev/nx_eigen_nif_test.exs:185-186 — `assert march_idx`
and `assert branch_idx` were truthiness-only assertions on
`Enum.find_index/2` results. Replaced with `assert is_integer(...)`
plus a failure message that surfaces the actual flags list, so a
missing flag produces a useful diagnostic instead of `nil is not
truthy`.
* test/mix/tasks/mob_enable_test.exs:262 — `assert notice` replaced
with `assert is_binary(notice)` + diagnostic message listing
`igniter.notices` on failure.
* test/mob_dev/native_build_test.exs:1015 — the "iOS-Metal CMake
patch file exists" test only touches File + String stdlib, so
Jump.CredoChecks.VacuousTest flags it as not exercising app code.
Strictly true, but the assertion guards a build asset the deploy
pipeline consumes — losing it silently would break iOS Metal
builds. Silenced inline with a comment explaining why.
* lib/mix/tasks/mob.publish.ex:265 — `Path.expand("~/.appstoreconnect/private_keys")`
triggered ExSlop's PathExpandPriv on the "priv" substring inside
"private_keys". That's Apple's altool key directory, not Mob's
own priv/ — Application.app_dir/2 doesn't apply. Silenced inline.
mix test 1338/1338, credo --strict clean.
Run 25956021520's flake was the same one we'd been carrying all
session — Kernel.ParallelCompiler.require_file/2 racing with a
concurrent test's process-wide cwd change. The test wrapper around
`NativeBuild.{install_nx_eigen_otp_lib,stage_empty_priv_otp_lib}`
was `File.cd!(project, fn -> ... end)` because those functions
read `_build/dev/lib/<app>/ebin` as a relative path. While the cwd
was /tmp/mobdev_proj_N/, an async test's parallel compiler tried
to load `test/mix/tasks/mob_release_tarball_test.exs` and ended up
looking at /tmp/mobdev_proj_N/test/mix/tasks/... — :enoent.
Refactor: add an optional `project_root \\ File.cwd!()` to both
functions. Production callers (mix mob.deploy --native) keep the
old behaviour via the default. The test passes the path explicitly
and drops File.cd!. No more cwd race; root cause gone.
Three full back-to-back `mix test --max-cases 24` runs locally —
1338/1338 every time. Removed the `--max-cases 4` workaround from
the CI workflow at the same time; the bug it was masking is now
actually fixed.
Run 25956175245 surfaced two environment-specific failures: 1. MobDev.HotPushTest (3 tests). The HotPush helper inspects _build/dev/lib/*/ebin/*.beam to figure out which BEAMs `mix mob.push` should ship to a device. CI runs MIX_ENV=test, so _build/dev/ never exists and the tests find zero beams → assertions like `pushed > 0` fail. Fix: extra workflow step that runs `MIX_ENV=dev mix compile` before the test job, populating _build/dev/ alongside _build/test/. ~10s of cold-cache time; deps are cached anyway. 2. Mix.Tasks.Mob.RepublishTest (whole module, 13 tests). The CFBundleVersion bump shells out to /usr/libexec/PlistBuddy which is macOS-only — ErlangError :enoent on Linux. Fix: `@moduletag :macos_only` on the module, plus `mix test --exclude macos_only` in the workflow. Local macOS dev runs still execute them by default; CI explicitly skips. Local: mix test --exclude macos_only → 1325/1325 mix test --only macos_only → 13/13 (republish suite) The pattern (env-dependent tests + platform-bound tests) is worth remembering — both will surface again when we add the "deploy mandelbrot_demo to emulator" Layer-2 CI from PLAN.md.
…2-20260803 Require authoritative app-scoped iOS restart success
…osed-v2-20260803 Serialize authoritative native deploys across exact targets
…-v2-20260803 Fix native Zigler staging isolation
…t-20260803 Fail early for unmatched explicit device platform
…-20260803 fix(android): report partial native updates
…ata-cap fix(android): bound Elixir metadata verification
Recover interrupted Android native-ready deploys safely
…-codes fix(android): classify recovery proof refusals safely
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Verification