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
15 changes: 4 additions & 11 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,7 @@ you'll lose the ability to evolve the parsers safely.
## Destructive-task conventions

Apply consistently to every Mix task that mutates device state
(`mix mob.uninstall` today; `mix mob.deploy --all-devices`,
(`mix mob.uninstall`, `mix mob.deploy`,
`mix mob.connect`, future ones).

**Emulator vs physical safety pattern (from `mix mob.uninstall`):**
Expand All @@ -237,19 +237,12 @@ Apply consistently to every Mix task that mutates device state
flags → error with a hint pointing at `--all-physical` or
`--device`.

The predicate to route on is `MobDev.Device.physical?/1`. The
selection logic lives in `MobDev.Uninstaller.select_devices/3`
(public for testing); same shape should appear in any new task
needing the same fan-out behavior. Pin the headline guarantee in
The predicate to route on is `MobDev.Device.physical?/1`. Shared
selection logic lives in `MobDev.TaskTargets`; task-specific planning
and error messages wrap it. Pin the headline guarantee in
each task's tests — "personal iPhone + dev emulators + `--all-devices`
must leave the iPhone alone."

**TODO:** apply this pattern to `mix mob.deploy` (today's `--all-devices`
deploy can push BEAMs to a personal phone). When that fan-out exists
or grows, factor `select_devices/3` plus the flag plumbing into a
shared `MobDev.TaskTargets` (or similar) module so the rules don't
drift between tasks.

## Naming gotcha: `mix mob.install` vs `mix mob.uninstall`

These look like inverses but aren't. Future agents touching either
Expand Down
30 changes: 30 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,33 @@
## [Unreleased]

### Changed

- **`mix mob.deploy` now freezes an explicit target set before doing work**
(MOB-169). A bare deploy automatically targets exactly one emulator or
simulator and never a physical phone. `--device`, `--all-devices`, and
`--all-physical` provide explicit single, development-device, and physical
scopes; `ANDROID_SERIAL` acts as the Android single-device selector on
Android-only runs.

Compatibility checks, native installs, and the final BEAM push all consume
the same snapshot, so a device appearing during the build cannot join the
operation. On the iOS path, the diagnostic that annotates a mid-copy
failure is now extracted (`Deployer.finalize_ios_override_result/2`) so
a `:skipped` result stays `:skipped` and only real `:error` finalizations
can inherit the incomplete-override wording.

**Behaviour change** — a bare `mix mob.deploy` with **two or more emulators
or simulators running for parallel testing** used to fan out to both, and
now refuses with an ambiguity error demanding an explicit selection
(`--device <id>` or `--all-devices`). This is the loudest new failure mode:
scripts and shell aliases that relied on the fan-out will need one of the
new flags. Physical devices also no longer receive an implicit deploy —
name one with `--device` or pass `--all-physical` to include them.

See `decisions/2026-09-11-deploy-freezes-explicit-targets.md`.

---

## [0.7.1] - 2026-09-11

### Added
Expand Down
21 changes: 13 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ end
| `mix mob.new APP_NAME` | Generate a new Mob project (see `mob_new` archive) |
| `mix mob.adopt` | Install Mob into an **existing** Phoenix project (Igniter-based; composes `mob.adopt.{deps,bridge,screen,mob_app,mob_exs,native,finalize}`). The install-into-existing counterpart to `mix mob.new` |
| `mix mob.install` | First-run setup: download OTP runtime, generate icons, write `mob.exs` |
| `mix mob.deploy` | Compile and push BEAMs to all connected devices |
| `mix mob.deploy` | Compile and push BEAMs to one selected emulator/simulator |
| `mix mob.deploy --native` | Also build and install the native APK/iOS app |
| `mix mob.deploy --slim` | Same, but with the App Store strip pass applied (slow, lets you verify a slim build before TestFlight — see [`guides/slim_release.md`](guides/slim_release.md)) |
| `mix mob.release` | Build a signed `.ipa` / `.aab` for App Store / TestFlight / Play Store (slim by default) |
Expand Down Expand Up @@ -83,6 +83,14 @@ Watch events broadcast on `"watch"` PubSub topic:

## Hot-push transport (`mix mob.deploy`)

The task resolves its target set once before compiling. With no target flag it
automatically selects exactly one emulator or simulator and never selects a
physical device. Use `--device <id>` for one explicit target,
`--all-devices` for every emulator/simulator, or `--all-physical` for attached
phones. Combining the two broad flags selects every connected device.
`ANDROID_SERIAL` has the same single-target effect as `--device` for Android;
an explicit CLI scope takes precedence.

When Erlang distribution is reachable, `mix mob.deploy` hot-pushes changed BEAMs in-place via RPC — no `adb push`, no app restart. The running modules are replaced exactly like `nl/1` in IEx.

```
Expand Down Expand Up @@ -134,18 +142,15 @@ unloaded at any moment.
`mix mob.deploy` exits non-zero when:

- any device **failed**, including a partial success where others deployed;
- every device of a platform you **named** was skipped — `mix mob.deploy --ios`
where every iOS device lacked the app. A skip stays non-fatal when it is
incidental, so a plain `mix mob.deploy` with an unrelated phone attached
still exits 0, and one simulator deploying while a stale one is skipped is a
success;
- every selected device of a platform you **named** was skipped —
`mix mob.deploy --ios --device <id>` where the target lacked the app;
- you named `--device X` and nothing was deployed to it, or no device matched;
- `--native` built nothing for a platform you **named** — a missing `sdk.dir`
in `android/local.properties` under `--android --native`, say. A plain
`mix mob.deploy --native` that skips a platform nobody asked for still
exits 0;
- you **named** a platform and no device of it was connected at all, which is
also what `--ios` on Linux does.
- you selected a broad scope and no device matched it. Naming a platform alone
still permits a native artifact-only build with no attached device.

A `--native` run that built the artifact and found no device to push it to
still exits 0: "build the APK now, attach the phone after" is a legitimate
Expand Down
55 changes: 55 additions & 0 deletions decisions/2026-09-11-deploy-freezes-explicit-targets.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# mob.deploy freezes explicit targets before doing work

Date: 2026-09-11
Status: accepted
Ticket: MOB-169

## Context

`mix mob.deploy --native` discovered devices independently during compatibility
checks, native installation, and the final BEAM push. A bare run therefore
installed on every Android device returned by `adb devices` and tried every
connected iPhone. It also ignored `ANDROID_SERIAL`. The set could change during
a long native build, so even checking the initial output did not define which
devices the command would later modify.

Physical phones may contain personal data and are often attached for unrelated
work. A command that changes them needs a deliberate target choice. Emulators
and simulators are safer development defaults, but selecting several of them
implicitly is still ambiguous.

## Decision

The deploy task discovers once and resolves one immutable list before compile,
build, install, or push begins. Every later stage consumes that list.

The shared `MobDev.TaskTargets` policy is:

- `--device <id>` selects one named device, including a physical device.
- `--all-devices` selects every emulator and simulator.
- `--all-physical` selects every physical device.
- Combining the broad flags selects every connected device.
- With no flag, exactly one emulator or simulator is selected automatically.
Physical devices are never implicit, and multiple development devices are an
error.

For Android, a non-empty `ANDROID_SERIAL` is a named target when no CLI target
scope was supplied. Explicit CLI selection takes precedence.

Native artifact-only builds remain valid with no connected device. An empty
frozen list means install and push nothing; it never means rediscover and fan
out.

## Consequences

- Users with several emulators or simulators must choose one or pass
`--all-devices`.
- Users deploying to a phone must name it or pass `--all-physical`.
- Android install and OTP delivery intersect later `adb devices` output with
the frozen serials. Newly connected devices cannot join, and a selected
device disappearing stops the native delivery instead of widening scope.
- An iOS simulator build is shared across selected simulators and installed
only on their frozen UDIDs. Physical iOS builds remain per-device because
signing and installation are device-specific.
- `MobDev.Uninstaller` delegates to the same selector so the two device-changing
tasks cannot drift independently.
Loading
Loading