Packages
mob_dev
0.5.14
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/mob_dev/release/tarball.ex
defmodule MobDev.Release.Tarball do
@moduledoc """
Replaces `scripts/release/tarball_*.sh` × 4 — the staging + `tar czf`
step that produces the `otp-<target>-<hash>.tar.gz` archive
`MobDev.OtpDownloader` later fetches.
iex> MobDev.Release.Tarball.build(:android_arm64,
...> exqlite_build: "/path/to/_build/dev/lib/exqlite")
{:ok, %{tarball: "/tmp/otp-android-abc12345.tar.gz", target: :android_arm64}}
## Per-target variation (the richest of any iter so far)
| Field | android_arm64 | android_arm32 | ios_sim | ios_device |
|--------------------------------|---------------------|----------------------------|-----------------|---------------------------|
| Tarball basename | `otp-android` | `otp-android-arm32` | `otp-ios-sim` | `otp-ios-device` |
| Borrow crypto/pk/ssl apps from | (built in-tree) | (built in-tree) | Android install | Android install |
| Bundle exqlite BEAMs | yes | yes | no | no |
| Bundle EPMD source | no | no | no | yes (for static-link) |
| Verify crypto.so present | yes | no | no | no |
| tar --exclude paths | (none) | (none) | test-app dirs | (none) |
Note the asymmetry in `tarball_basename`: the Android arm64 tarball is
`otp-android-<hash>.tar.gz` (NOT `otp-android-arm64-...`), preserved
for backward compat with `MobDev.OtpDownloader.@otp_hash` —
changing this would break every existing cache entry.
## Why iOS targets "borrow" crypto/public_key/ssl apps from Android
iOS OTP is cross-compiled `--without-ssl` (the iOS xcomp confs use
`--enable-static-nifs` and OTP's build system doesn't propagate
`--with-ssl` to beam.emu's link in that mode). The `crypto`,
`public_key`, and `ssl` Erlang apps therefore aren't produced for
iOS. BEAM bytecode is platform-neutral, so we copy these apps'
ebins from the Android arm64 install tree — they're the same
bytecode. The platform-specific NIF artifacts they ship in `priv/`
are obsolete on iOS anyway because we replace `crypto.so` with the
static `crypto.a` + `libcrypto.a` we just baked.
## What "build" does end-to-end
1. Stage — mktemp + `cp -r <otp_release>/.` (the cross-compiled
OTP install tree from `MobDev.Release.OTP`)
2. Borrow apps — iOS targets only. cp from
`<android_otp_release>/lib/{crypto,public_key,ssl}-*`.
3. Static libs — cp the four arch-specific `.a` files
(`libzstd.a`, `libepcre.a`, `libryu.a`, `asn1rt_nif.a`) into
`<stage>/erts-<vsn>/lib/`.
4. Crypto archives — cp `crypto.a` (from
`MobDev.Release.OpenSSL.CryptoNif`) + `libcrypto.a` (from
`MobDev.Release.OpenSSL`) into the same place.
5. ERTS headers — cp the 5 headers (`erl_nif.h`,
`erl_nif_api_funcs.h`, `erl_drv_nif.h`,
`erl_fixed_size_int_types.h`, arch-specific
`erl_int_sizes_config.h`) into `<stage>/erts-<vsn>/include/`.
6. Elixir stdlib — `MobDev.Release.Helpers.bundle_elixir_stdlib/2`
(elixir, logger, eex ebins).
7. exqlite BEAMs — Android targets only. Detect version from
mix.lock; cp ebins into `<stage>/lib/exqlite-<vsn>/`.
8. EPMD source — iOS device only. cp the 3 `.c` files + headers
+ the arch-specific config dir.
9. tar czf the stage into `<out_dir>/<basename>-<hash>.tar.gz`,
honouring per-target `--exclude` flags.
10. Verify — `tar tzf | grep <pattern>` for each per-target
required entry. Missing entries fail loudly rather than
ship a broken tarball.
## Why mix.lock parsing lives here
Android tarballs ship exqlite BEAMs that the user app loads as a NIF
on device. The version of those BEAMs has to match the user app's
`_build/dev/lib/exqlite/` ebins so dialyzer behaviours and protocol
consolidation line up. Detecting the exqlite version means parsing
mix.lock — a small regex on a known shape. Pure function, tested.
"""
alias MobDev.Release.{Errors, Shell, Helpers}
# ── Target spec ────────────────────────────────────────────────────────
defmodule Target do
@moduledoc "Per-target tarball staging description."
@enforce_keys [
:id,
:arch_dir,
:tarball_basename,
:default_otp_release,
:openssl_prefix_default,
:include_exqlite,
:include_epmd_source,
:borrow_crypto_apps,
:additional_verifies,
:tar_excludes
]
defstruct [
:id,
:arch_dir,
:tarball_basename,
:default_otp_release,
:openssl_prefix_default,
:include_exqlite,
:include_epmd_source,
:borrow_crypto_apps,
:additional_verifies,
:tar_excludes
]
@type t :: %__MODULE__{
id: :android_arm64 | :android_arm32 | :ios_sim | :ios_device,
arch_dir: String.t(),
tarball_basename: String.t(),
default_otp_release: Path.t(),
openssl_prefix_default: Path.t(),
include_exqlite: boolean(),
include_epmd_source: boolean(),
# Path of an Android install to copy crypto/public_key/ssl
# apps from, or nil (Android targets — they're built in-tree).
borrow_crypto_apps: Path.t() | nil,
# Additional `tar tzf | grep <pattern>` entries to verify
# beyond the universal ones.
additional_verifies: [String.t()],
# Paths (relative to the stage root) to exclude from the tarball.
tar_excludes: [String.t()]
}
end
@doc "All tarball targets, in canonical order."
@spec targets() :: [atom()]
def targets, do: [:android_arm64, :android_arm32, :ios_sim, :ios_device]
@doc """
Per-target spec. Public for testing — surface lock-down for tarball
naming (changing the basename breaks the downloader's cache), the
iOS borrow-from-Android decision, and the per-target verify list.
"""
@spec target_spec(atom()) :: Target.t()
def target_spec(:android_arm64) do
%Target{
id: :android_arm64,
arch_dir: "aarch64-unknown-linux-android",
# Asymmetric: this is `otp-android` not `otp-android-arm64`.
# MobDev.OtpDownloader's @otp_hash cache convention depends on
# this — DO NOT change without bumping every cached tarball.
tarball_basename: "otp-android",
default_otp_release: "/tmp/otp-android",
openssl_prefix_default: "/tmp/openssl-android-arm64",
include_exqlite: true,
include_epmd_source: false,
borrow_crypto_apps: nil,
# arm64 verifies the runtime crypto NIF + ssl/public_key ebins
# are present — proves --with-ssl was wired correctly in
# MobDev.Release.OTP. arm32 doesn't (the shell version didn't
# either; matches the historical asymmetry).
additional_verifies: [
"lib/crypto-.*/priv/lib/crypto.so",
"lib/public_key-.*/ebin/public_key.beam",
"lib/ssl-.*/ebin/ssl.beam"
],
tar_excludes: []
}
end
def target_spec(:android_arm32) do
%Target{
id: :android_arm32,
arch_dir: "arm-unknown-linux-androideabi",
tarball_basename: "otp-android-arm32",
default_otp_release: "/tmp/otp-android-arm32",
openssl_prefix_default: "/tmp/openssl-android-arm32",
include_exqlite: true,
include_epmd_source: false,
borrow_crypto_apps: nil,
additional_verifies: [],
tar_excludes: []
}
end
def target_spec(:ios_sim) do
%Target{
id: :ios_sim,
arch_dir: "aarch64-apple-iossimulator",
tarball_basename: "otp-ios-sim",
default_otp_release: "/tmp/otp-ios-sim",
openssl_prefix_default: "/tmp/openssl-ios-sim",
include_exqlite: false,
include_epmd_source: false,
# iOS borrows from the Android arm64 install (BEAM bytecode is
# arch-independent; iOS OTP was --without-ssl so doesn't produce
# these apps).
borrow_crypto_apps: "/tmp/otp-android",
additional_verifies: [],
# The iOS sim OTP_RELEASE often has stray test-app build dirs;
# excluding them keeps the tarball small + reproducible.
tar_excludes: ["beamhello", "test_app", "test_app0"]
}
end
def target_spec(:ios_device) do
%Target{
id: :ios_device,
arch_dir: "aarch64-apple-ios",
tarball_basename: "otp-ios-device",
default_otp_release: "/tmp/otp-ios-device",
openssl_prefix_default: "/tmp/openssl-ios-device",
include_exqlite: false,
include_epmd_source: true,
borrow_crypto_apps: "/tmp/otp-android",
# iOS device tarball ships EPMD source for static-link — the
# per-app build.zig compiles these into the .app's main native lib.
additional_verifies: [
"erts/epmd/src/epmd.c",
"erts/epmd/src/epmd_srv.c",
"erts/epmd/src/epmd_cli.c",
"erts/epmd/src/epmd.h",
"erts/epmd/src/epmd_int.h",
"erts/aarch64-apple-ios/config.h"
],
tar_excludes: []
}
end
# ── Tarball-name + path assembly (pure) ────────────────────────────────
@doc """
Final tarball path: `<out_dir>/<basename>-<hash>.tar.gz`. Public for
testing — basename drift breaks the downloader's cache.
"""
@spec tarball_path(Target.t(), Path.t(), String.t()) :: Path.t()
def tarball_path(%Target{} = target, out_dir, hash) when is_binary(hash) do
Path.join(out_dir, "#{target.tarball_basename}-#{hash}.tar.gz")
end
# ── exqlite version detection (pure) ───────────────────────────────────
@doc """
Parse the `exqlite` package version out of a mix.lock string.
Returns `{:ok, version}` or a tagged error.
Public for testing — silent parse failures here would silently bundle
no exqlite, then user apps die at runtime with `:undef`.
"""
@spec parse_exqlite_version_from_lock(binary()) :: {:ok, String.t()} | Errors.t()
def parse_exqlite_version_from_lock(content) when is_binary(content) do
# mix.lock entries look like:
# "exqlite": {:hex, :exqlite, "0.39.0", "...", ...}
# We want the version string. Match the package name explicitly to
# avoid false-matching another dep whose version field shares a
# prefix.
case Regex.run(~r/"exqlite":\s*\{:hex,\s*:exqlite,\s*"([^"]+)"/, content,
capture: :all_but_first
) do
[vsn] -> {:ok, vsn}
_ -> Errors.parse_failed(content, "exqlite version line in mix.lock")
end
end
@doc """
Parse the `vsn` field out of an `exqlite.app` file's content.
Fallback for when mix.lock isn't available (rare, but the shell
version handled it). Returns `{:ok, version}` or a tagged error.
"""
@spec parse_exqlite_version_from_app_file(binary()) :: {:ok, String.t()} | Errors.t()
def parse_exqlite_version_from_app_file(content) when is_binary(content) do
# OTP .app files look like:
# {application, exqlite, [{vsn, "0.39.0"}, ...]}
case Regex.run(~r/\{vsn,\s*"([^"]+)"\}/, content, capture: :all_but_first) do
[vsn] -> {:ok, vsn}
_ -> Errors.parse_failed(content, "{vsn, \"...\"} entry in exqlite.app")
end
end
@doc """
Detect the exqlite version by reading mix.lock (preferred) or
`<exqlite_build>/ebin/exqlite.app` (fallback). Returns `{:ok, vsn}`.
`exqlite_build` is the path to `_build/dev/lib/exqlite/` in any Mob
project that has run `mix deps.get && mix compile`.
Project root lookup: mix.lock lives 4 levels up from
`_build/dev/lib/exqlite/` — that's the project root convention every
Hex umbrella uses.
"""
@spec detect_exqlite_version(Path.t()) :: {:ok, String.t()} | Errors.t()
def detect_exqlite_version(exqlite_build) do
# Prefer mix.lock — it's the authoritative source.
project_root = exqlite_build |> Path.join(["..", "..", "..", ".."]) |> Path.expand()
mix_lock = Path.join(project_root, "mix.lock")
case File.read(mix_lock) do
{:ok, content} ->
case parse_exqlite_version_from_lock(content) do
{:ok, vsn} -> {:ok, vsn}
{:error, _} -> detect_from_app_file(exqlite_build)
end
{:error, _} ->
detect_from_app_file(exqlite_build)
end
end
defp detect_from_app_file(exqlite_build) do
app_file = Path.join([exqlite_build, "ebin", "exqlite.app"])
case File.read(app_file) do
{:ok, content} -> parse_exqlite_version_from_app_file(content)
{:error, reason} -> Errors.fs_failed(app_file, reason)
end
end
# ── Build entrypoint ───────────────────────────────────────────────────
@doc """
Stage + tar the per-target OTP runtime tarball. Returns `{:ok, info}`
naming the produced tarball + final size, or a tagged error.
Options:
* `:otp_src` — OTP source checkout (default: `$OTP_SRC` env or `~/code/otp`)
* `:otp_release` — install tree from `MobDev.Release.OTP.build/2`
(default: target's `default_otp_release`)
* `:openssl_prefix` — OpenSSL install dir (default per-target)
* `:exqlite_build` — `_build/dev/lib/exqlite` in any project (required
for Android targets; ignored for iOS)
* `:android_otp_release` — used by iOS targets to borrow crypto
apps (default: `/tmp/otp-android`)
* `:asn1rt_nif_arm32` — pre-built arm32 asn1rt_nif.a (Android arm32
only; default: `/tmp/asn1rt_nif_arm32.a`)
* `:out_dir` — tarball output (default: `/tmp`)
* `:hash` — release hash (default: detected from OTP source git)
"""
@spec build(atom(), keyword()) :: {:ok, map()} | Errors.t()
def build(target_id, opts \\ [])
when target_id in [:android_arm64, :android_arm32, :ios_sim, :ios_device] do
target = target_spec(target_id)
shell = Shell.impl()
otp_src = opts[:otp_src] || Helpers.default_otp_src()
# Explicit otp_src dir check FIRST. Without this, resolve_release_env's
# implicit File.read on erts/vsn.mk fires first and returns fs_failed
# with a confusing path-with-vsn.mk-suffix rather than the actionable
# "OTP_SRC missing" precondition_failed.
if not shell.dir?(otp_src) do
Errors.precondition("OTP_SRC missing at #{otp_src} — clone github.com/erlang/otp")
else
with {:ok, env} <-
Helpers.resolve_release_env(Keyword.take(opts, [:otp_src, :hash, :out_dir])),
otp_release = opts[:otp_release] || target.default_otp_release,
openssl_prefix = opts[:openssl_prefix] || target.openssl_prefix_default,
:ok <- precheck(target, shell, env, otp_release, openssl_prefix, opts) do
do_build(target, shell, env, otp_release, openssl_prefix, opts)
end
end
end
defp do_build(target, shell, env, otp_release, openssl_prefix, opts) do
stage = mktemp_stage(shell)
try do
with :ok <- stage_otp_release(shell, otp_release, stage),
:ok <- maybe_borrow_crypto_apps(target, shell, opts, stage),
:ok <- stage_static_libs(target, shell, env, otp_release, opts, openssl_prefix, stage),
:ok <- stage_erts_headers(target, shell, env, stage),
{:ok, _} <- Helpers.bundle_elixir_stdlib(stage, env.elixir_lib),
:ok <- maybe_stage_exqlite(target, shell, opts, stage),
:ok <- maybe_stage_epmd(target, shell, env, stage),
tarball = tarball_path(target, env.out_dir, env.hash),
:ok <- tar_stage(target, shell, stage, tarball),
:ok <- verify_tarball(target, shell, env, tarball) do
{:ok, %{target: target.id, tarball: tarball, hash: env.hash, erts_vsn: env.erts_vsn}}
end
after
_ = shell.cmd(["rm", "-rf", stage], [])
end
end
# ── Phases ─────────────────────────────────────────────────────────────
defp mktemp_stage(shell) do
{:ok, output} = shell.cmd(["mktemp", "-d"], [])
String.trim(output)
end
defp stage_otp_release(shell, otp_release, stage) do
# The trailing `/.` copies the directory CONTENTS, not the dir itself
# — same as the shell `cp -r $OTP_RELEASE/. $STAGE` idiom.
case shell.cmd(["cp", "-r", otp_release <> "/.", stage], []) do
{:ok, _} -> :ok
err -> err
end
end
defp maybe_borrow_crypto_apps(%Target{borrow_crypto_apps: nil}, _shell, _opts, _stage), do: :ok
defp maybe_borrow_crypto_apps(%Target{borrow_crypto_apps: default}, shell, opts, stage) do
src_root = opts[:android_otp_release] || default
if not shell.dir?(src_root) do
Errors.precondition(
"android_otp_release missing at #{src_root} — needed to borrow crypto/public_key/ssl beams. " <>
"Run MobDev.Release.OTP.build(:android_arm64) first or pass `android_otp_release:` explicitly."
)
else
Enum.reduce_while(~w(crypto public_key ssl), :ok, fn app, :ok ->
# The app directory is named like `<app>-<version>/`. Find the
# first match via `ls -d`. Failure here is a hard precondition —
# the Android install must have these apps.
case shell.cmd(
["bash", "-c", "ls -d #{src_root}/lib/#{app}-*/ 2>/dev/null | head -1"],
[]
) do
{:ok, output} ->
src = String.trim(output)
if src == "" do
{:halt, Errors.precondition("no #{app}-*/ in #{src_root}/lib")}
else
case shell.cmd(["cp", "-r", src, Path.join(stage, "lib") <> "/"], []) do
{:ok, _} -> {:cont, :ok}
err -> {:halt, err}
end
end
err ->
{:halt, err}
end
end)
end
end
defp stage_static_libs(target, shell, env, _otp_release, opts, openssl_prefix, stage) do
erts_lib_dst = Path.join(stage, "erts-#{env.erts_vsn}/lib") <> "/"
# Static libs from OTP source tree (arch-specific path).
base_libs = [
"erts/emulator/zstd/obj/#{target.arch_dir}/opt/libzstd.a",
"erts/emulator/pcre/obj/#{target.arch_dir}/opt/libepcre.a",
"erts/emulator/ryu/obj/#{target.arch_dir}/opt/libryu.a"
]
# asn1rt_nif.a — arm32 has a special pre-built location; others come
# from the OTP source tree.
asn1_src =
case target.id do
:android_arm32 ->
opts[:asn1rt_nif_arm32] || "/tmp/asn1rt_nif_arm32.a"
_ ->
Path.join(env.otp_src, "lib/asn1/priv/lib/#{target.arch_dir}/asn1rt_nif.a")
end
# crypto.a from CryptoNif's output.
crypto_a =
Path.join(env.otp_src, "lib/crypto/priv/lib/#{target.arch_dir}/crypto.a")
# libcrypto.a from OpenSSL build.
libcrypto_a = Path.join(openssl_prefix, "lib/libcrypto.a")
sources =
Enum.map(base_libs, &Path.join(env.otp_src, &1)) ++
[asn1_src, crypto_a, libcrypto_a]
Enum.reduce_while(sources, :ok, fn src, :ok ->
case shell.cmd(["cp", src, erts_lib_dst], []) do
{:ok, _} -> {:cont, :ok}
err -> {:halt, err}
end
end)
end
defp stage_erts_headers(target, shell, env, stage) do
erts_inc_dst = Path.join(stage, "erts-#{env.erts_vsn}/include") <> "/"
with :ok <- shell.mkdir_p(erts_inc_dst) do
headers = [
"erts/emulator/beam/erl_nif.h",
"erts/emulator/beam/erl_nif_api_funcs.h",
"erts/emulator/beam/erl_drv_nif.h",
"erts/include/erl_fixed_size_int_types.h",
# Arch-specific — the per-target size config.
"erts/include/#{target.arch_dir}/erl_int_sizes_config.h"
]
Enum.reduce_while(headers, :ok, fn header, :ok ->
src = Path.join(env.otp_src, header)
case shell.cmd(["cp", src, erts_inc_dst], []) do
{:ok, _} -> {:cont, :ok}
err -> {:halt, err}
end
end)
end
end
defp maybe_stage_exqlite(%Target{include_exqlite: false}, _shell, _opts, _stage), do: :ok
defp maybe_stage_exqlite(_target, shell, opts, stage) do
case opts[:exqlite_build] do
nil ->
Errors.precondition(
"exqlite_build required for Android targets — pass `exqlite_build: <path>` pointing at " <>
"any project's _build/dev/lib/exqlite (run `mix deps.get && mix compile` in one of your projects)"
)
exqlite_build ->
with true <-
shell.dir?(Path.join(exqlite_build, "ebin")) ||
{:fs, Path.join(exqlite_build, "ebin"), :enoent},
{:ok, vsn} <- detect_exqlite_version(exqlite_build),
dst = Path.join(stage, "lib/exqlite-#{vsn}"),
:ok <- shell.mkdir_p(Path.join(dst, "ebin")),
:ok <- shell.mkdir_p(Path.join(dst, "priv")),
# Copy ebin/* — bytecode + .app file
{:ok, _} <- shell.cmd(["bash", "-c", "cp #{exqlite_build}/ebin/* #{dst}/ebin/"], []) do
:ok
else
{:fs, path, reason} -> Errors.fs_failed(path, reason)
err -> err
end
end
end
defp maybe_stage_epmd(%Target{include_epmd_source: false}, _shell, _env, _stage), do: :ok
defp maybe_stage_epmd(target, shell, env, stage) do
epmd_dst = Path.join(stage, "erts/epmd/src")
arch_dst = Path.join(stage, "erts/#{target.arch_dir}")
include_dst = Path.join(stage, "erts/include")
include_internal_dst = Path.join(stage, "erts/include/internal")
with :ok <- shell.mkdir_p(epmd_dst),
# epmd .c sources
epmd_srcs = ~w(epmd.c epmd_srv.c epmd_cli.c),
:ok <- copy_each(shell, env.otp_src, "erts/epmd/src", epmd_srcs, epmd_dst <> "/"),
# all epmd .h files (cheap to include)
{:ok, _} <-
shell.cmd(
[
"bash",
"-c",
"cp #{env.otp_src}/erts/epmd/src/*.h #{epmd_dst}/"
],
[]
),
# arch-specific configure output
:ok <- shell.mkdir_p(arch_dst),
{:ok, _} <-
shell.cmd(
[
"bash",
"-c",
"cp -r #{env.otp_src}/erts/#{target.arch_dir}/* #{arch_dst}/"
],
[]
),
# erts/include + erts/include/internal mirrors
:ok <- shell.mkdir_p(include_dst),
:ok <- shell.mkdir_p(include_internal_dst),
{:ok, _} <-
shell.cmd(
[
"bash",
"-c",
"cp -r #{env.otp_src}/erts/include/* #{include_dst}/"
],
[]
),
{:ok, _} <-
shell.cmd(
[
"bash",
"-c",
"cp -r #{env.otp_src}/erts/include/internal/* #{include_internal_dst}/"
],
[]
) do
:ok
end
end
defp copy_each(shell, otp_src, rel_dir, files, dst) do
Enum.reduce_while(files, :ok, fn file, :ok ->
src = Path.join([otp_src, rel_dir, file])
case shell.cmd(["cp", src, dst], []) do
{:ok, _} -> {:cont, :ok}
err -> {:halt, err}
end
end)
end
defp tar_stage(target, shell, stage, tarball) do
base = Path.basename(stage)
parent = Path.dirname(stage)
# Build the tar argv with per-target --exclude flags.
exclude_args =
Enum.flat_map(target.tar_excludes, fn exclude ->
["--exclude=#{base}/#{exclude}"]
end)
argv = ["tar", "czf", tarball] ++ exclude_args ++ ["-C", parent, base]
case shell.cmd(argv, []) do
{:ok, _} -> :ok
err -> err
end
end
# ── Verification ───────────────────────────────────────────────────────
@doc """
Verify the produced tarball contains every required entry. Returns
`:ok` or a precondition_failed naming the missing entry.
Universal entries (every target):
* `erts-<vsn>/` directory entry
* `lib/elixir/ebin/elixir.app`
* `erts-<vsn>/lib/crypto.a`
* `erts-<vsn>/lib/libcrypto.a`
Per-target additions come from `target.additional_verifies`.
"""
@spec verify_tarball(Target.t(), MobDev.Release.Shell.t() | module(), map(), Path.t()) ::
:ok | Errors.t()
def verify_tarball(target, shell, env, tarball) do
with {:ok, listing} <- shell.cmd(["tar", "tzf", tarball], []) do
expected = required_entries(target, env)
check_entries(listing, expected, tarball)
end
end
@doc """
Per-target required entries. Public for testing — pinning the list
is the surface lock that prevents silent drops.
ERTS version interpolation happens against `env.erts_vsn`.
"""
@spec required_entries(Target.t(), map()) :: [String.t()]
def required_entries(target, %{erts_vsn: vsn}) do
universal = [
"erts-#{vsn}",
"lib/elixir/ebin/elixir.app",
"erts-#{vsn}/lib/crypto.a",
"erts-#{vsn}/lib/libcrypto.a"
]
universal ++ target.additional_verifies
end
@doc """
Scan a `tar tzf` listing and confirm every expected pattern matches
at least one entry. Patterns are treated as regex (matching the
shell's `grep -q` semantics). Public for tests.
"""
@spec check_entries(binary(), [String.t()], Path.t()) :: :ok | Errors.t()
def check_entries(listing, expected, tarball_path)
when is_binary(listing) and is_list(expected) do
missing =
Enum.reject(expected, fn pattern ->
# Compile as a regex — the shell version used grep -E, so .*
# etc. need to work. Anchor on neither end (grep doesn't either).
regex = Regex.compile!(pattern)
Regex.match?(regex, listing)
end)
case missing do
[] ->
:ok
[first | _] ->
Errors.precondition("verify failed — tarball #{tarball_path} missing #{inspect(first)}")
end
end
# ── build_all/1 ────────────────────────────────────────────────────────
@doc """
Build all four tarballs in sequence. Each target picks up its own
defaults. Returns `[{target_id, result}, ...]` in canonical order.
Doesn't short-circuit.
"""
@spec build_all(keyword()) :: [{atom(), {:ok, map()} | Errors.t()}]
def build_all(opts \\ []) do
for target_id <- targets() do
{target_id, build(target_id, opts)}
end
end
# ── Preconditions ──────────────────────────────────────────────────────
defp precheck(target, shell, env, otp_release, openssl_prefix, opts) do
cond do
not shell.dir?(env.otp_src) ->
Errors.precondition("OTP_SRC missing at #{env.otp_src}")
not shell.dir?(otp_release) ->
Errors.precondition(
"otp_release missing at #{otp_release} — run MobDev.Release.OTP.build(#{inspect(target.id)}) first"
)
not shell.dir?(openssl_prefix) ->
Errors.precondition(
"openssl_prefix missing at #{openssl_prefix} — run MobDev.Release.OpenSSL.build(#{inspect(target.id)}) first"
)
target.include_exqlite and is_nil(opts[:exqlite_build]) ->
Errors.precondition(
"exqlite_build required for #{target.id} — pass `exqlite_build: <path>` " <>
"pointing at any project's _build/dev/lib/exqlite"
)
target.include_exqlite and not shell.dir?(Path.join(opts[:exqlite_build], "ebin")) ->
Errors.precondition(
"exqlite_build/ebin not found — did you `mix deps.get && mix compile` in the project?"
)
true ->
:ok
end
end
end