Packages
mob_dev
0.5.2
0.6.23
0.6.22
0.6.21
0.6.20
0.6.19
0.6.18
0.6.17
0.6.16
0.6.15
0.6.14
0.6.13
0.6.12
0.6.11
0.6.10
0.6.9
0.6.8
0.6.7
0.6.6
0.6.5
0.6.4
0.6.3
0.6.2
0.6.1
0.6.0
0.5.17
0.5.16
0.5.15
0.5.14
0.5.13
0.5.12
0.5.11
0.5.10
0.5.9
0.5.8
0.5.7
0.5.6
0.5.5
0.5.4
0.5.3
0.5.2
0.5.1
0.5.0
0.4.0
0.3.37
0.3.35
0.3.34
0.3.33
0.3.28
0.3.26
0.3.23
0.3.21
0.3.19
0.3.18
0.3.17
0.3.16
0.3.15
0.3.14
0.3.13
0.3.12
0.3.11
0.3.10
0.3.9
0.3.8
0.3.7
0.3.6
0.3.5
0.3.4
0.3.3
0.3.2
0.3.1
0.3.0
0.2.18
0.2.17
0.2.15
0.2.14
0.2.13
0.2.12
0.2.11
0.2.10
0.2.9
0.2.8
0.2.7
0.2.6
0.2.5
0.2.4
0.2.3
0.2.2
0.2.1
0.2.0
0.1.0
Development tooling for the Mob mobile framework
Current section
Files
Jump to
Current section
Files
lib/mix/tasks/mob.publish.ex
defmodule Mix.Tasks.Mob.Publish do
use Mix.Task
@shortdoc "Upload a release artifact to a platform store (--ios | --android)"
@moduledoc """
Uploads a release-signed artifact to the platform's app store.
mix mob.publish --ios # uploads _build/mob_release/<App>.ipa
mix mob.publish --ios path/to/Foo.ipa # uploads a specific .ipa
mix mob.publish --android # uploads android/app/build/outputs/bundle/release/app-release.aab
mix mob.publish --android path/to/app.aab # uploads a specific .aab
mix mob.publish --android --track production # override track from mob.exs
Platform flag is **required** — Mob is intentionally platform-agnostic
and refuses to default to either side. Pick `--ios` or `--android`
explicitly so it's obvious from the command which store you're hitting.
## --ios prerequisites
1. App Store Connect API key (.p8 file). Create one at
https://appstoreconnect.apple.com/access/api with App Manager role.
2. The app record exists in App Store Connect (the bundle ID is
registered there as an app, not just as an App ID in the developer
portal).
3. `mob.exs` configured:
config :mob_dev,
app_store_connect: [
key_id: "ABC123XYZ4",
issuer_id: "69a6de76-aaaa-bbbb-cccc-1234567890ab",
key_path: "~/.appstoreconnect/AuthKey_ABC123XYZ4.p8"
]
## What --ios does
Runs `xcrun altool --upload-app` with API-key auth. altool validates the
IPA, uploads it, and returns when Apple has accepted the build for
processing. Apple then takes 5-15 minutes to process the build before it
appears in TestFlight.
"""
@switches [verbose: :boolean, ios: :boolean, android: :boolean, track: :string]
@impl Mix.Task
def run(argv) do
{opts, args, _} = OptionParser.parse(argv, strict: @switches)
case pick_platform(opts) do
:ios -> publish_ios(opts, args)
:android -> publish_android(opts, args)
end
end
# Platform selection — explicit-only. `mix mob.publish` with no flag
# is always wrong; `mix mob.publish --ios --android` is contradictory.
defp pick_platform(opts) do
case {opts[:ios], opts[:android]} do
{true, true} ->
Mix.raise(
"Pass exactly one of --ios or --android, not both. Each store has " <>
"a separate validator and credential set; one publish at a time."
)
{true, _} ->
:ios
{_, true} ->
:android
_ ->
Mix.raise("""
mix mob.publish requires --ios or --android.
Mob is platform-agnostic by design — neither side is the default.
Pick the store you want to publish to:
mix mob.publish --ios
mix mob.publish --android
Or use `mix mob.republish --ios` to bump the build number,
rebuild, and upload in one shot.
""")
end
end
defp publish_android(opts, args) do
aab_path = resolve_aab_path(args)
gp = load_google_play_config!()
track = opts[:track] || gp[:track] || "internal"
Mix.shell().info("")
Mix.shell().info("#{cyan()}=== Uploading to Google Play ===#{reset()}")
Mix.shell().info(" AAB: #{aab_path}")
Mix.shell().info(" Package: #{gp[:package_name]}")
Mix.shell().info(" Track: #{track}")
Mix.shell().info(" Service acct: #{gp[:service_account_json]}")
Mix.shell().info("")
case MobDev.GooglePlay.upload(aab_path, Keyword.put(gp, :track, track)) do
{:ok, version_code} ->
Mix.shell().info("")
Mix.shell().info("#{green()}✓ Upload accepted by Google Play#{reset()}")
Mix.shell().info(" versionCode #{version_code} is on the #{track} track.")
Mix.shell().info("")
Mix.shell().info("View it at:")
Mix.shell().info(" #{cyan()}https://play.google.com/console#{reset()}")
{:error, reason} ->
Mix.raise(reason)
end
end
# ── --ios path (existing behavior) ──────────────────────────────────────────
defp publish_ios(opts, args) do
case :os.type() do
{:unix, :darwin} ->
:ok
_ ->
Mix.raise("mix mob.publish --ios is only supported on macOS (xcrun altool is required).")
end
unless System.find_executable("xcrun") do
Mix.raise("xcrun not found — install Xcode and run `xcode-select --install`.")
end
ipa_path = resolve_ipa_path(args)
asc = load_asc_config!()
Mix.shell().info("")
Mix.shell().info("#{cyan()}=== Uploading to App Store Connect ===#{reset()}")
Mix.shell().info(" IPA: #{ipa_path}")
Mix.shell().info(" Key ID: #{asc[:key_id]}")
Mix.shell().info(" Issuer ID: #{asc[:issuer_id]}")
Mix.shell().info(" Key path: #{asc[:key_path]}")
Mix.shell().info("")
Mix.shell().info("(altool may take a few minutes — IPA is uploaded then validated by Apple.)")
Mix.shell().info("")
install_p8_for_altool!(asc[:key_path], asc[:key_id])
altool_args = [
"altool",
"--upload-app",
"--type",
"ios",
"--file",
ipa_path,
"--apiKey",
asc[:key_id],
"--apiIssuer",
asc[:issuer_id]
]
altool_args = if opts[:verbose], do: altool_args ++ ["--verbose"], else: altool_args
case System.cmd("xcrun", altool_args, stderr_to_stdout: true, into: IO.stream()) do
{_, 0} ->
Mix.shell().info("")
Mix.shell().info("#{green()}✓ Upload accepted by App Store Connect#{reset()}")
Mix.shell().info("")
Mix.shell().info("Apple is processing the build now (~5-15 minutes).")
Mix.shell().info("Once processed, the build appears in TestFlight at:")
Mix.shell().info(" #{cyan()}https://appstoreconnect.apple.com/apps#{reset()}")
{_, rc} ->
Mix.raise("altool exited #{rc} — see output above.")
end
end
# ── IPA resolution ──────────────────────────────────────────────────────────
defp resolve_ipa_path([path]) when is_binary(path) do
abs = Path.expand(path)
unless File.exists?(abs) do
Mix.raise("IPA not found at #{abs}")
end
abs
end
defp resolve_ipa_path([]) do
output_dir = Path.expand("_build/mob_release")
case Path.wildcard(Path.join(output_dir, "*.ipa")) do
[] ->
Mix.raise("""
No .ipa found in #{output_dir}.
Run `mix mob.release` first, or pass an explicit path:
mix mob.publish path/to/App.ipa
""")
[single] ->
single
many ->
Mix.raise("""
Multiple .ipas found in #{output_dir}; pass one explicitly:
#{Enum.map_join(many, "\n", &" #{&1}")}
mix mob.publish #{List.first(many)}
""")
end
end
defp resolve_ipa_path(_) do
Mix.raise("Usage: mix mob.publish [path/to/App.ipa]")
end
# ── App Store Connect config ────────────────────────────────────────────────
defp load_asc_config! do
config_file = Path.join(File.cwd!(), "mob.exs")
unless File.exists?(config_file) do
Mix.raise("mob.exs not found in #{File.cwd!()} — run from the project root.")
end
cfg = Config.Reader.read!(config_file) |> Keyword.get(:mob_dev, [])
asc = cfg[:app_store_connect]
unless is_list(asc) do
Mix.raise("""
Missing :app_store_connect in mob.exs. Add:
config :mob_dev,
app_store_connect: [
key_id: "ABC123XYZ4",
issuer_id: "69a6de76-aaaa-bbbb-cccc-1234567890ab",
key_path: "~/.appstoreconnect/AuthKey_ABC123XYZ4.p8"
]
Get an API key at https://appstoreconnect.apple.com/access/api
""")
end
Enum.each([:key_id, :issuer_id, :key_path], fn key ->
unless is_binary(asc[key]) do
Mix.raise("app_store_connect[:#{key}] missing or not a string in mob.exs")
end
end)
Keyword.update!(asc, :key_path, &Path.expand/1)
end
# altool's --apiKey flag looks up the .p8 file in fixed locations:
# ./private_keys/AuthKey_<KEY_ID>.p8
# ~/private_keys/AuthKey_<KEY_ID>.p8
# ~/.private_keys/AuthKey_<KEY_ID>.p8
# ~/.appstoreconnect/private_keys/AuthKey_<KEY_ID>.p8
# Copy/symlink the user's key into the last of those so altool finds it
# without us needing to clutter their home dir's ~/private_keys.
defp install_p8_for_altool!(key_path, key_id) do
unless File.exists?(key_path) do
Mix.raise("App Store Connect API key not found at #{key_path}")
end
target_dir = Path.expand("~/.appstoreconnect/private_keys")
File.mkdir_p!(target_dir)
target = Path.join(target_dir, "AuthKey_#{key_id}.p8")
if not File.exists?(target) or File.read!(target) != File.read!(key_path) do
File.cp!(key_path, target)
end
:ok
end
# ── AAB resolution ───────────────────────────────────────────────────────────
defp resolve_aab_path([path]) when is_binary(path) do
abs = Path.expand(path)
unless File.exists?(abs), do: Mix.raise("AAB not found at #{abs}")
abs
end
defp resolve_aab_path([]) do
output_dir = Path.expand("android/app/build/outputs/bundle/release")
case Path.wildcard(Path.join(output_dir, "*.aab")) do
[] ->
Mix.raise("""
No .aab found in #{output_dir}.
Run `cd android && ./gradlew bundleRelease` first, or pass an explicit path:
mix mob.publish --android path/to/app-release.aab
""")
[single] ->
single
many ->
Mix.raise("""
Multiple .aab files found; pass one explicitly:
#{Enum.map_join(many, "\n", &" #{&1}")}
""")
end
end
defp resolve_aab_path(_), do: Mix.raise("Usage: mix mob.publish --android [path/to/app.aab]")
# ── Google Play config ───────────────────────────────────────────────────────
defp load_google_play_config! do
config_file = Path.join(File.cwd!(), "mob.exs")
unless File.exists?(config_file) do
Mix.raise("mob.exs not found in #{File.cwd!()} — run from the project root.")
end
cfg = Config.Reader.read!(config_file) |> Keyword.get(:mob_dev, [])
gp = cfg[:google_play]
unless is_list(gp) do
Mix.raise("""
Missing :google_play in mob.exs. Add:
config :mob_dev,
google_play: [
package_name: "com.example.myapp",
service_account_json: "~/.google_play/my-service-account.json",
track: "internal"
]
Service account setup:
1. Play Console → Setup → API access → link a Google Cloud project
2. Google Cloud → IAM → Service Accounts → create account → download JSON key
3. Play Console → Setup → API access → grant the account "Release manager"
""")
end
Enum.each([:package_name, :service_account_json], fn key ->
unless is_binary(gp[key]) do
Mix.raise("google_play[:#{key}] missing or not a string in mob.exs")
end
end)
Keyword.update!(gp, :service_account_json, &Path.expand/1)
end
defp green, do: IO.ANSI.green()
defp cyan, do: IO.ANSI.cyan()
defp reset, do: IO.ANSI.reset()
end