Packages
mob_dev
0.6.13
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.republish.ex
defmodule Mix.Tasks.Mob.Republish do
use Mix.Task
@shortdoc "Bump build, rebuild, and upload — one shot (--ios | --android)"
@moduledoc """
Convenience wrapper around the per-release flow. Bumps the platform's
build number, rebuilds the release artifact, and uploads to the store.
mix mob.republish --ios # bump CFBundleVersion, mob.release, mob.publish --ios
mix mob.republish --ios --no-bump # skip the bump (Apple will reject same build #; mostly for testing)
mix mob.republish --android # bump versionCode, gradlew bundleRelease, mob.publish --android
mix mob.republish --android --track production # publish to a specific track
Platform flag is **required** — Mob is intentionally platform-agnostic
and refuses to default to either side.
## What --ios does (under the hood)
Three steps. Each is a standalone command if you'd rather run them
separately or need to troubleshoot one in isolation:
1. Bump `CFBundleVersion` in `ios/Info.plist` by 1:
CURRENT=$(/usr/libexec/PlistBuddy -c "Print :CFBundleVersion" ios/Info.plist)
/usr/libexec/PlistBuddy -c "Set :CFBundleVersion $((CURRENT + 1))" ios/Info.plist
Apple rejects re-uploads with the same `CFBundleVersion`. The bump
only touches `CFBundleVersion` (the integer build number); your
`CFBundleShortVersionString` (the public semver) stays put.
2. `mix mob.release` — builds `_build/mob_release/<App>.ipa`. See
`Mix.Tasks.Mob.Release` for what this produces. Bump-then-release
order matters: the build number is baked into the binary, not
inferred at upload time.
3. `mix mob.publish --ios` — uploads via `xcrun altool` with App
Store Connect API key auth. See `Mix.Tasks.Mob.Publish`.
## Failure handling
If the bump itself fails (e.g. `CFBundleVersion` isn't an integer —
someone wrote `"1.0"` which actually belongs in `CFBundleShortVersionString`),
you get a clear error before anything else runs.
If `mix mob.release` fails, `mix mob.publish` is not invoked. The
build-number bump is NOT rolled back — you'll see a gap in your
uploaded versions, which Apple is fine with (gaps are allowed; going
backward isn't). Re-run after fixing whatever broke the build.
If `mix mob.publish` itself fails (network, Apple API error, etc.),
you may need to bump the version *again* before retrying — Apple
considers the build number "consumed" once they see it, even on
upload failure. Use `--no-bump` is rarely the right call; usually
the right move is just `mix mob.republish --ios` again.
"""
@switches [
ios: :boolean,
android: :boolean,
no_bump: :boolean,
verbose: :boolean,
track: :string
]
@impl Mix.Task
def run(argv) do
{opts, _, _} = OptionParser.parse(argv, strict: @switches)
case pick_platform(opts) do
:ios -> republish_ios(opts)
:android -> republish_android(opts)
end
end
defp pick_platform(opts) do
case {opts[:ios], opts[:android]} do
{true, true} ->
Mix.raise(
"Pass exactly one of --ios or --android, not both. " <>
"Republish targets one store at a time."
)
{true, _} ->
:ios
{_, true} ->
:android
_ ->
Mix.raise("""
mix mob.republish requires --ios or --android.
Mob is platform-agnostic by design — neither side is the default.
mix mob.republish --ios
mix mob.republish --android
""")
end
end
defp republish_android(opts) do
gradle = Path.expand("android/app/build.gradle")
unless File.exists?(gradle) do
Mix.raise(
"android/app/build.gradle not found — run from the project root of a Mob Android app."
)
end
unless opts[:no_bump] do
{old, new} = bump_android_version_code!(gradle)
Mix.shell().info(
"#{cyan()}Bumped versionCode: #{old} → #{new}#{reset()} " <>
"(Play rejects re-uploads of the same versionCode)"
)
end
Mix.Task.run("mob.release", ["--android"])
Mix.Task.reenable("mob.release")
publish_argv = ["--android"] ++ if(opts[:track], do: ["--track", opts[:track]], else: [])
Mix.Task.run("mob.publish", publish_argv)
Mix.Task.reenable("mob.publish")
end
defp republish_ios(opts) do
plist = "ios/Info.plist"
unless File.exists?(plist) do
Mix.raise("ios/Info.plist not found — run from the project root of a Mob iOS app.")
end
unless opts[:no_bump] do
{old, new} = bump_ios_build_number!(plist)
Mix.shell().info(
"#{cyan()}Bumped CFBundleVersion: #{old} → #{new}#{reset()} " <>
"(Apple rejects re-uploads of the same build number)"
)
end
Mix.Task.run("mob.release")
Mix.Task.reenable("mob.release")
publish_argv = ["--ios"] ++ if(opts[:verbose], do: ["--verbose"], else: [])
Mix.Task.run("mob.publish", publish_argv)
Mix.Task.reenable("mob.publish")
end
@doc """
Read `versionCode` from the given `build.gradle`, integer-bump it, and
write back. Returns `{old, new}` strings.
Raises with a clear message if no `versionCode` line is found.
"""
@spec bump_android_version_code!(Path.t()) :: {String.t(), String.t()}
def bump_android_version_code!(gradle_path) do
content = File.read!(gradle_path)
# Regex.compile! used deliberately — ~r// bakes compiled patterns into the .beam,
# which breaks on OTP 28.0 (re.import/1 removed; fixed in 28.1).
find_re = Regex.compile!("\\bversionCode\\s+(\\d+)")
replace_re = Regex.compile!("\\bversionCode\\s+\\d+")
case Regex.run(find_re, content, capture: :all_but_first) do
[current_str] ->
current = String.to_integer(current_str)
new_str = Integer.to_string(current + 1)
updated =
String.replace(content, replace_re, "versionCode #{new_str}", global: false)
File.write!(gradle_path, updated)
{current_str, new_str}
nil ->
Mix.raise("""
No versionCode found in #{gradle_path}.
Expected a line like:
versionCode 1
inside the defaultConfig block.
""")
end
end
@doc """
Read `CFBundleVersion` from the given Info.plist, integer-bump it, and
write back. Returns `{old, new}` strings.
Raises with a clear message if the current value isn't a clean integer
— typically that means someone put a semver-style version
(`"1.0.0"`) where Apple expects an integer build counter; the semver
belongs in `CFBundleShortVersionString` instead.
"""
@spec bump_ios_build_number!(String.t()) :: {String.t(), String.t()}
def bump_ios_build_number!(plist) do
{raw, 0} =
System.cmd("/usr/libexec/PlistBuddy", ["-c", "Print :CFBundleVersion", plist])
current = String.trim(raw)
case Integer.parse(current) do
{n, ""} ->
new = Integer.to_string(n + 1)
{_, 0} =
System.cmd("/usr/libexec/PlistBuddy", [
"-c",
"Set :CFBundleVersion #{new}",
plist
])
{current, new}
_ ->
Mix.raise("""
CFBundleVersion in #{plist} is "#{current}" — expected a bare integer.
CFBundleVersion is the *build number* (an integer counter Apple
uses to distinguish uploads). The public semver "1.0.0" belongs
in CFBundleShortVersionString. Fix manually:
/usr/libexec/PlistBuddy -c "Set :CFBundleVersion 1" #{plist}
Then re-run `mix mob.republish --ios`.
""")
end
end
defp cyan, do: IO.ANSI.cyan()
defp reset, do: IO.ANSI.reset()
end