Current section

Files

Jump to
mob_dev lib mix tasks mob.republish.ex
Raw

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