Building and Publishing OTP Release Tarballs

Copy Markdown View Source

MobDev.OtpDownloader pulls pre-built OTP runtimes from a GitHub release on GenericJam/mob. This file documents how to build and publish those tarballs when upgrading OTP.

TL;DR — runnable form: every step below is also implemented as a script under scripts/release/. See scripts/release/README.md for the typical full-release flow. The markdown carries the narrative; the scripts carry the imperative.


What's in each tarball

Both tarballs must extract (via --strip-components=1) into a flat directory that looks like an OTP release root:

erts-<vsn>/
  bin/          # ERTS executables (erl_child_setup, inet_gethost, epmd, ...)
  include/      # Headers (iOS only — see below)
  lib/          # Static libs + ERTS internal libs
    libbeam.a
    libzstd.a       (iOS only)
    libepcre.a      (iOS only)
    libryu.a        (iOS only)
    asn1rt_nif.a    (iOS only)
    crypto.a              # OTP's crypto NIF, built with -DSTATIC_ERLANG_NIF
    libcrypto.a           # OpenSSL 3.x, statically linked into crypto.a
    internal/
      liberts_internal_r.a
      libethread.a
lib/            # OTP stdlib (kernel, stdlib, elixir, logger, ...)
              # Includes lib/crypto-VSN, lib/public_key-VSN, lib/ssl-VSN
              # — real crypto, not a shim.
releases/
  29/
    start_clean.boot
    start_sasl.boot

Android arm64 (otp-android-<hash>.tar.gz)

Built from a full cross-compiled OTP release for aarch64-unknown-linux-android. Needs the same extra static libs and headers as iOS (see Step 2).

The ERTS helper binaries (erl_child_setup, inet_gethost, epmd) must be in erts-<vsn>/bin/; mob_dev copies them into the APK as lib*.so (required for SELinux execve permission on Android).

Android arm32 (otp-android-arm32-<hash>.tar.gz)

Built from a full cross-compiled OTP release for arm-unknown-linux-androideabi (armeabi-v7a). Same structure as arm64. Required for 32-bit-only devices (e.g. Motorola E 2020).

asn1rt_nif.a must be compiled separately — it is not emitted by the OTP build system for arm32. Build it with:

NDK=~/Library/Android/sdk/ndk/27.2.12479018/toolchains/llvm/prebuilt/darwin-x86_64/bin
OTP_SRC=~/code/otp

$NDK/armv7a-linux-androideabi21-clang \
  -march=armv7-a -mfloat-abi=softfp -mthumb \
  -fvisibility=hidden -fno-common -fno-strict-aliasing \
  -fstack-protector-strong -O2 \
  -I "$OTP_SRC/erts/arm-unknown-linux-androideabi" \
  -I "$OTP_SRC/erts/include/arm-unknown-linux-androideabi" \
  -I "$OTP_SRC/erts/emulator/beam" \
  -I "$OTP_SRC/erts/include" \
  -DHAVE_CONFIG_H \
  -DSTATIC_ERLANG_NIF_LIBNAME=asn1rt_nif \
  -c "$OTP_SRC/lib/asn1/c_src/asn1_erl_nif.c" \
  -o /tmp/asn1rt_nif_arm32.o

$NDK/llvm-ar rc /tmp/asn1rt_nif_arm32.a /tmp/asn1rt_nif_arm32.o
$NDK/llvm-ranlib /tmp/asn1rt_nif_arm32.a

Then include it in the tarball at erts-<vsn>/lib/asn1rt_nif.a (see Step 2b).

iOS device (otp-ios-device-<hash>.tar.gz)

Built from a cross-compiled OTP for aarch64-apple-ios. Same install-tree contents as the simulator tarball (libs, headers, ERTS bin), plus EPMD source files and iOS configure output that mob_dev's build_device.sh needs at native-build time to static-link EPMD into the app.

The extra files added on top of the install tree:

erts/
  epmd/src/{epmd,epmd_srv,epmd_cli}.c    # EPMD C sources
  aarch64-apple-ios/config.h             # generated by ./configure for iOS arm64
  include/                               # cross-platform ERTS headers
  include/internal/                      # ERTS-internal headers (ethread, etc.)

The path layout — erts/... next to erts-<vsn>/... — looks unusual but intentional: erts-<vsn>/ is the install tree (compiled artifacts), erts/ is a stripped-down source tree fragment carrying just what build_device.sh needs to compile EPMD against the iOS arm64 SDK.

MobDev.OtpDownloader validates these files are present after extraction; older tarballs without them are treated as invalid and re-downloaded.

iOS simulator (otp-ios-sim-<hash>.tar.gz)

Built from a cross-compiled OTP for aarch64-apple-iossimulator. Needs:

  1. Headers at erts-<vsn>/include/ (same set for both platforms, arch-specific file differs):

    • erl_nif.h — from erts/emulator/beam/erl_nif.h
    • erl_nif_api_funcs.h — from erts/emulator/beam/erl_nif_api_funcs.h
    • erl_drv_nif.h — from erts/emulator/beam/erl_drv_nif.h
    • erl_int_sizes_config.h — iOS: erts/include/aarch64-apple-iossimulator/erl_int_sizes_config.h / Android: erts/include/aarch64-unknown-linux-android/erl_int_sizes_config.h
    • erl_fixed_size_int_types.h — from erts/include/erl_fixed_size_int_types.h
  2. Extra static libs at erts-<vsn>/lib/:

    • libzstd.aerts/emulator/zstd/obj/aarch64-apple-iossimulator/opt/libzstd.a
    • libepcre.aerts/emulator/pcre/obj/aarch64-apple-iossimulator/opt/libepcre.a
    • libryu.aerts/emulator/ryu/obj/aarch64-apple-iossimulator/opt/libryu.a
    • asn1rt_nif.alib/asn1/priv/lib/aarch64-apple-iossimulator/asn1rt_nif.a

Prerequisites

  • A cross-compiled OTP build. The OTP source tree at ~/code/otp (commit 7721ab74) has iOS simulator and Android targets already compiled.
  • gh CLI authenticated to the GenericJam GitHub account.

Step 1 — Locate the OTP commit hash

cd ~/code/otp
git rev-parse --short HEAD   # e.g. 7721ab74

Use this hash everywhere below as <hash>.


Step 2 — Build the Android tarballs (arm64 + arm32)

The Android OTP release lives at the cross-compiled install dir (typically /tmp/otp-android for arm64, /tmp/otp-android-arm32 for arm32 — check where the previous build left it).

Before tarballing, make sure you have a project with exqlite compiled (_build/dev/lib/exqlite/ebin/ must exist — run mix deps.get && mix compile from any app that uses ecto_sqlite3). The exqlite BEAMs are platform-independent bytecode and can be bundled directly; the native .so is already in the APK and is symlinked at runtime by mob_beam.c.

arm64

OTP_SRC=~/code/otp
OTP_RELEASE=/tmp/otp-android   # adjust if different
EXQLITE_BUILD=~/code/mob_test_liveview/_build/dev/lib/exqlite  # any project with exqlite
HASH=<hash>
STAGE=$(mktemp -d)

cp -r "$OTP_RELEASE/." "$STAGE"

# Add extra static libs
ERTS_LIB="$STAGE/erts-16.3/lib"   # update version as needed
cp "$OTP_SRC/erts/emulator/zstd/obj/aarch64-unknown-linux-android/opt/libzstd.a" "$ERTS_LIB/"
cp "$OTP_SRC/erts/emulator/pcre/obj/aarch64-unknown-linux-android/opt/libepcre.a" "$ERTS_LIB/"
cp "$OTP_SRC/erts/emulator/ryu/obj/aarch64-unknown-linux-android/opt/libryu.a"   "$ERTS_LIB/"
cp "$OTP_SRC/lib/asn1/priv/lib/aarch64-unknown-linux-android/asn1rt_nif.a"       "$ERTS_LIB/"

# Add required headers
ERTS_INC="$STAGE/erts-16.3/include"
mkdir -p "$ERTS_INC"
cp "$OTP_SRC/erts/emulator/beam/erl_nif.h"                                        "$ERTS_INC/"
cp "$OTP_SRC/erts/emulator/beam/erl_nif_api_funcs.h"                              "$ERTS_INC/"
cp "$OTP_SRC/erts/emulator/beam/erl_drv_nif.h"                                    "$ERTS_INC/"
cp "$OTP_SRC/erts/include/aarch64-unknown-linux-android/erl_int_sizes_config.h"   "$ERTS_INC/"
cp "$OTP_SRC/erts/include/erl_fixed_size_int_types.h"                             "$ERTS_INC/"

# Add host Elixir stdlib (elixir, logger, eex) so the device starts with the
# correct version. Without this, Elixir version drift causes Regex.safe_run
# crashes when the host is upgraded between native deploys.
ELIXIR_LIB=$(elixir -e "IO.puts(:code.lib_dir(:elixir))" | xargs dirname)
for app in elixir logger eex; do
    mkdir -p "$STAGE/lib/$app/ebin"
    cp "$ELIXIR_LIB/$app/ebin/"* "$STAGE/lib/$app/ebin/"
done
echo "Bundled Elixir $(elixir --version | grep Elixir | awk '{print $2}')"

# Add exqlite BEAMs. The .so NIF is in the APK (symlinked at runtime by mob_beam.c);
# only the ebin/ bytecode goes in the tarball.
EXQLITE_VSN=$(grep '"exqlite"' "$EXQLITE_BUILD/../../../mix.lock" | grep -o '"[0-9][^"]*"' | head -1 | tr -d '"')
EXQLITE_LIB="$STAGE/lib/exqlite-$EXQLITE_VSN"
mkdir -p "$EXQLITE_LIB/ebin" "$EXQLITE_LIB/priv"
cp "$EXQLITE_BUILD/ebin/"* "$EXQLITE_LIB/ebin/"
echo "Bundled exqlite $EXQLITE_VSN"

BASE=$(basename $STAGE)
tar czf "/tmp/otp-android-$HASH.tar.gz" -C "$(dirname $STAGE)" "$BASE"

# Verify
tar tzf "/tmp/otp-android-$HASH.tar.gz" | grep "\.a$\|\.h$"
tar tzf "/tmp/otp-android-$HASH.tar.gz" | grep "lib/elixir/ebin/elixir.app"
tar tzf "/tmp/otp-android-$HASH.tar.gz" | grep "lib/exqlite"

arm32

Repeat the same steps with the arm32 OTP release and the arm32 asn1rt_nif.a (see prerequisites above for how to build it). The Elixir and exqlite BEAMs are the same — bytecode is architecture-independent.

OTP_RELEASE_ARM32=/tmp/otp-android-arm32   # adjust if different
STAGE32=$(mktemp -d)

cp -r "$OTP_RELEASE_ARM32/." "$STAGE32"

ERTS_LIB32="$STAGE32/erts-16.3/lib"
cp "$OTP_SRC/erts/emulator/zstd/obj/arm-unknown-linux-androideabi/opt/libzstd.a"   "$ERTS_LIB32/"
cp "$OTP_SRC/erts/emulator/pcre/obj/arm-unknown-linux-androideabi/opt/libepcre.a"  "$ERTS_LIB32/"
cp "$OTP_SRC/erts/emulator/ryu/obj/arm-unknown-linux-androideabi/opt/libryu.a"    "$ERTS_LIB32/"
cp /tmp/asn1rt_nif_arm32.a "$ERTS_LIB32/asn1rt_nif.a"

ERTS_INC32="$STAGE32/erts-16.3/include"
mkdir -p "$ERTS_INC32"
cp "$OTP_SRC/erts/emulator/beam/erl_nif.h"                                          "$ERTS_INC32/"
cp "$OTP_SRC/erts/emulator/beam/erl_nif_api_funcs.h"                                "$ERTS_INC32/"
cp "$OTP_SRC/erts/emulator/beam/erl_drv_nif.h"                                      "$ERTS_INC32/"
cp "$OTP_SRC/erts/include/arm-unknown-linux-androideabi/erl_int_sizes_config.h"     "$ERTS_INC32/"
cp "$OTP_SRC/erts/include/erl_fixed_size_int_types.h"                               "$ERTS_INC32/"

# Same Elixir and exqlite BEAMs (bytecode is arch-independent)
for app in elixir logger eex; do
    mkdir -p "$STAGE32/lib/$app/ebin"
    cp "$ELIXIR_LIB/$app/ebin/"* "$STAGE32/lib/$app/ebin/"
done
mkdir -p "$STAGE32/lib/exqlite-$EXQLITE_VSN/ebin" "$STAGE32/lib/exqlite-$EXQLITE_VSN/priv"
cp "$EXQLITE_BUILD/ebin/"* "$STAGE32/lib/exqlite-$EXQLITE_VSN/ebin/"

BASE32=$(basename $STAGE32)
tar czf "/tmp/otp-android-arm32-$HASH.tar.gz" -C "$(dirname $STAGE32)" "$BASE32"
tar tzf "/tmp/otp-android-arm32-$HASH.tar.gz" | grep "lib/elixir/ebin/elixir.app"
tar tzf "/tmp/otp-android-arm32-$HASH.tar.gz" | grep "lib/exqlite"

Step 3 — Build the iOS simulator tarball

The iOS OTP runtime typically lives at /tmp/otp-ios-sim. App-specific BEAM directories (created by ios/build.sh runs) must be excluded.

OTP_SRC=~/code/otp
OTP_ROOT=/tmp/otp-ios-sim
HASH=<hash>
STAGE=$(mktemp -d)

# Copy the OTP runtime
cp -r "$OTP_ROOT/." "$STAGE"

# Add extra static libs
ERTS_LIB="$STAGE/erts-16.3/lib"   # update version as needed
cp "$OTP_SRC/erts/emulator/zstd/obj/aarch64-apple-iossimulator/opt/libzstd.a" "$ERTS_LIB/"
cp "$OTP_SRC/erts/emulator/pcre/obj/aarch64-apple-iossimulator/opt/libepcre.a" "$ERTS_LIB/"
cp "$OTP_SRC/erts/emulator/ryu/obj/aarch64-apple-iossimulator/opt/libryu.a"   "$ERTS_LIB/"
cp "$OTP_SRC/lib/asn1/priv/lib/aarch64-apple-iossimulator/asn1rt_nif.a"       "$ERTS_LIB/"

# Add required headers
ERTS_INC="$STAGE/erts-16.3/include"
cp "$OTP_SRC/erts/emulator/beam/erl_nif.h"                                     "$ERTS_INC/"
cp "$OTP_SRC/erts/emulator/beam/erl_nif_api_funcs.h"                           "$ERTS_INC/"
cp "$OTP_SRC/erts/emulator/beam/erl_drv_nif.h"                                 "$ERTS_INC/"
cp "$OTP_SRC/erts/include/aarch64-apple-iossimulator/erl_int_sizes_config.h"   "$ERTS_INC/"
cp "$OTP_SRC/erts/include/erl_fixed_size_int_types.h"                          "$ERTS_INC/"

# Add host Elixir stdlib (same reasoning as Android — bake in so fresh installs
# start with the correct version and don't hit Regex.safe_run crashes).
ELIXIR_LIB=$(elixir -e "IO.puts(:code.lib_dir(:elixir))" | xargs dirname)
for app in elixir logger eex; do
    mkdir -p "$STAGE/lib/$app/ebin"
    cp "$ELIXIR_LIB/$app/ebin/"* "$STAGE/lib/$app/ebin/"
done
echo "Bundled Elixir $(elixir --version | grep Elixir | awk '{print $2}')"

# List any app-specific BEAM dirs to exclude (ls $OTP_ROOT | grep -vE "^(erts|lib|releases|misc|usr)$")
BASE=$(basename $STAGE)
tar czf "/tmp/otp-ios-sim-$HASH.tar.gz" \
    --exclude="$BASE/beamhello" \
    --exclude="$BASE/test_app" \
    --exclude="$BASE/test_app0" \
    -C "$(dirname $STAGE)" "$BASE"

# Verify
tar tzf "/tmp/otp-ios-sim-$HASH.tar.gz" | grep "\.a$"
tar tzf "/tmp/otp-ios-sim-$HASH.tar.gz" | grep "\.h$"
tar tzf "/tmp/otp-ios-sim-$HASH.tar.gz" | grep "lib/elixir/ebin/elixir.app"

Step 3b — Build the iOS device tarball

The iOS device runtime is the cross-compiled install dir for aarch64-apple-ios. On top of the runtime, this tarball must ship EPMD source files and the iOS-arm64 configure output so mix mob.deploy --native can static-link EPMD into the iOS app at build time.

3b.0 — Cross-compile OTP for iOS arm64 (only needed once per OTP version)

One required source patch: the iOS device sandbox blocks fork(), which the BEAM unconditionally calls at startup via forker_start. The simulator runs as a Mac process and allows fork, which is why iOS-sim builds work without modification. The patch lives at scripts/release/patches/0001-ios-device-skip-forker-fork.patch; xcompile_ios_device.sh applies it automatically (idempotent — detects if it's already in the source). Without it, the device app dies at launch with Sandbox: <App>(<pid>) deny(1) process-fork in the device log and tears down before any UI shows.

This mirrors the iOS-sim cross-compile (which produced erts/aarch64-apple-iossimulator/) but uses the device xcomp config instead. OTP ships both configs out of the box — see xcomp/erl-xcomp-arm64-ios.conf (device) vs erl-xcomp-arm64-iossimulator.conf (simulator). The device conf differs only in the host triple (arm64-apple-ios) and SDK (iphoneos); flow is identical to the sim one. OTP's own walkthrough is at HOWTO/INSTALL-IOS.md.

cd ~/code/otp

# Sanity check: the iPhoneOS SDK must be installed.
xcrun --sdk iphoneos --show-sdk-path

# iOS doesn't allow shared libraries; this env var tells the build to emit
# libbeam.a (static) instead of libbeam.so. Same flag as for the sim build.
export RELEASE_LIBBEAM=yes

# Configure for the iOS arm64 device target.
#
# `--with-ssl=$OPENSSL_PREFIX` points at a previously cross-compiled
# OpenSSL 3.x install (see scripts/release/openssl/ios_device.sh).
# `--disable-dynamic-ssl-lib` keeps OpenSSL static-linked into the
# crypto NIF; no separate libcrypto.so/.dylib ends up in the app.
# `--enable-static-nifs` registers crypto (and asn1rt_nif) in the
# BEAM's static_nif_tab[] so `erlang:load_nif("crypto", ...)` resolves
# `crypto_nif_init` via dlsym(RTLD_DEFAULT) — no dlopen of crypto.so.
# The latter is required: Android's RTLD_LOCAL default makes a dlopen'd
# crypto.so unable to see libpigeon.so's enif_* symbols.
./otp_build configure \
    --xcomp-conf=./xcomp/erl-xcomp-arm64-ios.conf \
    --with-ssl="$OPENSSL_PREFIX" \
    --disable-dynamic-ssl-lib \
    --enable-static-nifs

# Build everything (ERTS, stdlib, and all OTP apps for the target).
./otp_build boot

# Assemble the install tree at /tmp/otp-ios-device — this is the dir that
# the staging step below copies from.
make release RELEASE_ROOT=/tmp/otp-ios-device

After this completes you should have:

  • erts/aarch64-apple-ios/config.h (the configure output the staging step bundles)
  • erts/emulator/{zstd,pcre,ryu}/obj/aarch64-apple-ios/opt/lib*.a
  • lib/asn1/priv/lib/aarch64-apple-ios/asn1rt_nif.a
  • /tmp/otp-ios-device/{bin,erts-<vsn>,lib,releases,...} (the install tree)
OTP_SRC=~/code/otp
OTP_RELEASE=/tmp/otp-ios-device   # wherever the iOS-arm64 install lives
HASH=<hash>
STAGE=$(mktemp -d)

# Copy the OTP runtime (install tree)
cp -r "$OTP_RELEASE/." "$STAGE"

# Add extra static libs (same set as the sim tarball, but iOS-device arch)
ERTS_LIB="$STAGE/erts-16.3/lib"   # update version as needed
cp "$OTP_SRC/erts/emulator/zstd/obj/aarch64-apple-ios/opt/libzstd.a"   "$ERTS_LIB/"
cp "$OTP_SRC/erts/emulator/pcre/obj/aarch64-apple-ios/opt/libepcre.a"  "$ERTS_LIB/"
cp "$OTP_SRC/erts/emulator/ryu/obj/aarch64-apple-ios/opt/libryu.a"     "$ERTS_LIB/"
cp "$OTP_SRC/lib/asn1/priv/lib/aarch64-apple-ios/asn1rt_nif.a"         "$ERTS_LIB/"

# Add required headers
ERTS_INC="$STAGE/erts-16.3/include"
mkdir -p "$ERTS_INC"
cp "$OTP_SRC/erts/emulator/beam/erl_nif.h"                              "$ERTS_INC/"
cp "$OTP_SRC/erts/emulator/beam/erl_nif_api_funcs.h"                    "$ERTS_INC/"
cp "$OTP_SRC/erts/emulator/beam/erl_drv_nif.h"                          "$ERTS_INC/"
cp "$OTP_SRC/erts/include/aarch64-apple-ios/erl_int_sizes_config.h"     "$ERTS_INC/"
cp "$OTP_SRC/erts/include/erl_fixed_size_int_types.h"                   "$ERTS_INC/"

# Add Elixir stdlib (same as sim — bake in for version stability)
ELIXIR_LIB=$(elixir -e "IO.puts(:code.lib_dir(:elixir))" | xargs dirname)
for app in elixir logger eex; do
    mkdir -p "$STAGE/lib/$app/ebin"
    cp "$ELIXIR_LIB/$app/ebin/"* "$STAGE/lib/$app/ebin/"
done
echo "Bundled Elixir $(elixir --version | grep Elixir | awk '{print $2}')"

# ── EPMD source + iOS-arm64 configure output ─────────────────────────────────
# build_device.sh static-links EPMD into the iOS app. The .c sources and the
# arch-specific config.h must be present alongside the install tree. The
# downloader's `valid_otp_dir?/2` checks for these and re-downloads if absent.
mkdir -p "$STAGE/erts/epmd/src"
cp "$OTP_SRC/erts/epmd/src/epmd.c"     "$STAGE/erts/epmd/src/"
cp "$OTP_SRC/erts/epmd/src/epmd_srv.c" "$STAGE/erts/epmd/src/"
cp "$OTP_SRC/erts/epmd/src/epmd_cli.c" "$STAGE/erts/epmd/src/"
# epmd.c → epmd.h, epmd_int.h, both in erts/epmd/src/.
cp "$OTP_SRC/erts/epmd/src/"*.h        "$STAGE/erts/epmd/src/"

mkdir -p "$STAGE/erts/aarch64-apple-ios"
cp -r "$OTP_SRC/erts/aarch64-apple-ios/"* "$STAGE/erts/aarch64-apple-ios/"

mkdir -p "$STAGE/erts/include" "$STAGE/erts/include/internal"
cp -r "$OTP_SRC/erts/include/"*          "$STAGE/erts/include/"
cp -r "$OTP_SRC/erts/include/internal/"* "$STAGE/erts/include/internal/"

# Tar it up — exclude any stray app build dirs left in OTP_RELEASE
BASE=$(basename $STAGE)
tar czf "/tmp/otp-ios-device-$HASH.tar.gz" -C "$(dirname $STAGE)" "$BASE"

# Verify all four schema requirements: erts-*/ install, EPMD source files,
# iOS-arm64 config.h, and Elixir stdlib.
tar tzf "/tmp/otp-ios-device-$HASH.tar.gz" | grep "erts-16"           | head -1
tar tzf "/tmp/otp-ios-device-$HASH.tar.gz" | grep "erts/epmd/src/epmd.c"
tar tzf "/tmp/otp-ios-device-$HASH.tar.gz" | grep "erts/epmd/src/epmd_srv.c"
tar tzf "/tmp/otp-ios-device-$HASH.tar.gz" | grep "erts/epmd/src/epmd_cli.c"
tar tzf "/tmp/otp-ios-device-$HASH.tar.gz" | grep "erts/aarch64-apple-ios/config.h"
tar tzf "/tmp/otp-ios-device-$HASH.tar.gz" | grep "lib/elixir/ebin/elixir.app"

Step 4 — Publish the GitHub release

Tag format: otp-<hash> (e.g. otp-7721ab74).

HASH=<hash>

# Create the release (or use an existing one)
gh release create "otp-$HASH" \
    --repo GenericJam/mob \
    --title "OTP pre-built runtime $HASH" \
    --notes "Pre-built OTP for Android (aarch64 + arm32), iOS simulator (aarch64-apple-iossimulator), and iOS device (aarch64-apple-ios). OTP source commit: $HASH."

# Upload tarballs (all four)
gh release upload "otp-$HASH" \
    "/tmp/otp-android-$HASH.tar.gz" \
    "/tmp/otp-android-arm32-$HASH.tar.gz" \
    "/tmp/otp-ios-sim-$HASH.tar.gz" \
    "/tmp/otp-ios-device-$HASH.tar.gz" \
    --repo GenericJam/mob

# Verify
gh release view "otp-$HASH" --repo GenericJam/mob --json assets \
    -q '.assets[] | "\(.name) \(.size)"'

To replace a bad asset:

gh release delete-asset "otp-$HASH" otp-ios-sim-$HASH.tar.gz --repo GenericJam/mob --yes
gh release upload "otp-$HASH" /tmp/otp-ios-sim-$HASH.tar.gz --repo GenericJam/mob

Re-uploading without bumping the hash (schema-bump pattern)

When the tarball contents change but the underlying OTP commit hasn't (e.g. adding EPMD source to the iOS device tarball), the canonical move is to re-upload at the same hash. MobDev.OtpDownloader.valid_otp_dir?/2 is the gate that decides whether a cached extracted dir is still acceptable — if you add a new schema requirement there, existing users' caches fail validation and the next mix mob.deploy --native re-downloads the asset automatically. No hash bump, no user action needed.


Step 5 — Update OtpDownloader

Edit lib/mob_dev/otp_downloader.ex — update the hash and ERTS version:

@otp_hash    "7721ab74"    # ← new hash

If the ERTS version changed (e.g. from 16.3 to 16.4), update build_release.md to match.

Step 6 — Update the bundled-versions manifest

Edit priv/security/bundled_versions.exs — update :active_hash and add (or modify) the :bundles entry for the new hash with the versions baked into the new tarballs:

%{
  active_hash: "<new-hash>",
  bundles: %{
    "<new-hash>" => %{
      erts: "16.3",            # erts-* directory in the tarball
      otp_release: "28",
      elixir: "1.19.5",
      openssl: "3.4.0",        # `strings libcrypto.a | grep '^OpenSSL'`
      exqlite_beam: "0.36.0",
      openssl_release_date: "2024-10-22"
    }
  }
}

This file is the source of truth that mix mob.security_scan checks against. The bundled-runtime scan layer fingerprints the cached tarball and raises if the binary disagrees with what's declared here — drift between what we say shipped and what actually shipped is exactly what this manifest is designed to catch. Update it in the same PR as the OTP hash bump; otherwise the scan will report a :high "manifest mismatch" finding for every Mob app.


Troubleshooting

"No erts-* directory found in $OTP_ROOT" — tarball extracted incorrectly. Check that --strip-components=1 produces erts-<vsn>/ at the top level:

tar tzf otp-ios-sim-<hash>.tar.gz | head -5
tar tzf otp-ios-sim-<hash>.tar.gz | sed 's|[^/]*/||' | head -5   # after strip

iOS build fails with missing header — confirm erl_nif.h is in the tarball:

tar tzf otp-ios-sim-<hash>.tar.gz | grep "\.h$"

Android BEAM crash {undef, app:start} — OTP runtime not on device. Check that ensure_android/0 succeeded and push_otp_release_android ran.

Wrong tarball uploaded — the iOS tarball once accidentally contained a compiled .app bundle instead of OTP. Always verify with:

tar tzf otp-ios-sim-<hash>.tar.gz | grep "erts-"