Packaging with Burrito

Copy Markdown View Source

Shipping a TUI built on ExRatatui.App normally means asking end users to install Erlang, Elixir, and (often) a Rust toolchain before they can run a single command. Burrito flips that around: it wraps an OTP release into one statically-linked native binary per OS/arch, distributed as a single file. The end user downloads it, runs it, and the BEAM plus the ex_ratatui NIF unpack into a per-user cache directory on first launch.

ex_ratatui itself stays a library — it does not depend on Burrito at runtime and does not produce its own binaries. Burrito is something a consumer project opts into.

Prerequisites

  • Erlang/OTP and Elixir matching ex_ratatui's mix.exs requirements.
  • zig exactly 0.16.0. Burrito 1.6 hard-pins this; any other version is rejected at mix release time. mise install zig@0.16.0 is the shortest path; the broader install matrix is on the Burrito README. (On macOS 26 "Tahoe" this pin is mandatory: Zig 0.15.2 cannot link native binaries against the SDK 26 toolchain — see ziglang/zig#31658 — which is why Burrito moved to 0.16.0 in 1.6.)
  • xz on PATH. Usually already present on Linux and macOS.

A .mise.toml in the consumer project keeps the toolchain reproducible:

[tools]
zig = "0.16.0"

Quick start

mix ex_ratatui.gen.burrito does the whole setup in one command:

mix ex_ratatui.gen.burrito --tui-module MyTui.TUI --ci github

It patches mix.exs, scaffolds the Application and CLI modules, and optionally drops the matrix CI workflow into .github/workflows/. The generator is opt-in: ex_ratatui declares igniter as optional: true, so projects that never run the task pay nothing for it.

That leaves nothing to do but build. The section below is the same wiring by hand — worth reading to understand what the generator wrote, or to retrofit a project that already has its own Application and release config.

Wiring it by hand

examples/burrito_demo/ in this repo is the finished version of everything below, and what the generator produces matches it.

Start from a normal OTP-shaped TUI project:

mix new my_tui --sup
cd my_tui

Add ex_ratatui and burrito to mix.exs:

defp deps do
  [
    {:ex_ratatui, "~> 0.12"},
    {:burrito, "~> 1.6"}
  ]
end

Add a releases/0 and reference it from project/0:

def project do
  [
    app: :my_tui,
    version: "0.1.0",
    elixir: "~> 1.17",
    deps: deps(),
    releases: releases()
  ]
end

defp releases do
  [
    my_tui: [
      steps: [:assemble, &ExRatatui.Burrito.verify_linux_nif/1, &Burrito.wrap/1],
      burrito: [
        targets: [
          linux: [os: :linux, cpu: :x86_64],
          macos: [os: :darwin, cpu: :x86_64],
          macos_silicon: [os: :darwin, cpu: :aarch64],
          windows: [os: :windows, cpu: :x86_64]
        ]
      ]
    ]
  ]
end

ExRatatui.Burrito.verify_linux_nif/2 is a guard against the most common packaging mistake: building the linux target without TARGET_ABI=musl (covered under Building) assembles a glibc NIF that cannot load inside burrito's musl wrapper — the step fails the build with the exact fix instead of letting a broken binary ship. It is wired as the arity-1 capture above; the second argument names the NIF to scan and defaults to the one this build loads.

Wire the Application as Burrito's entry point. Burrito expects a :mod in application/0; the CLI module below is a supervised Task that reads command-line arguments via Burrito.Util.Args.argv/0:

def application do
  [
    extra_applications: [:logger],
    mod: {MyTui.Application, []}
  ]
end
# lib/my_tui/application.ex
defmodule MyTui.Application do
  use Application

  @impl true
  def start(_type, _args) do
    children = [
      MyTui.CLI
    ]

    Supervisor.start_link(children, strategy: :one_for_one, name: MyTui.Supervisor)
  end
end
# lib/my_tui/cli.ex
defmodule MyTui.CLI do
  use Task

  @version Mix.Project.config()[:version]

  def start_link(_arg) do
    ExRatatui.Burrito.start_link(MyTui.TUI, Burrito.Util.Args.argv(),
      name: "my_tui",
      version: @version
    )
  end
end

MyTui.TUI is any module using ExRatatui.App. The entry-point protocol — boot the TUI, wait for it to exit, stop the VM with a matching exit code so the wrapper returns control to the shell — lives in ExRatatui.Burrito.start_link/3, so its fixes arrive with ex_ratatui upgrades instead of freezing in this module. Inside a wrapped binary it runs the TUI synchronously, blocking application startup for the TUI's lifetime: burrito boots the release with :elixir.start_cli, which halts the node the instant the boot's -s call returns, so a TUI spawned into an async task would be killed before it drew a frame — blocking the boot keeps the VM alive until the TUI exits. Outside a wrapped binary (mix test, iex -S mix, detected via burrito's __BURRITO env var) it is instead an async no-op, so it never takes over those sessions. --version anywhere in argv prints and exits 0 without a TTY, first forcing the NIF dlopen so a NIF/host mismatch fails loudly; and every failure path — the TUI crashing, failing to start, or the entry point raising — reports on stderr and exits 1. The use Task shape matters too: keying the child spec on the CLI module keeps the wiring idempotent and cannot collide with unrelated Task children in the tree.

Building

Build a single target at a time — Burrito tries to build every declared target by default, which fails on the first host that's missing a tool needed for some other target:

TARGET_ABI=musl BURRITO_TARGET=linux MIX_ENV=prod mix release --overwrite

TARGET_ABI=musl is required for the linux target. Burrito's linux wrapper uses a musl runtime, so the bundled NIF must be the musl variant — without the override, rustler_precompiled resolves the NIF from the build host (typically glibc), and a glibc .so cannot load inside the musl environment, so the wrapped binary crashes at NIF load. The musl artifacts are fully self-contained since ex_ratatui 0.12.0 (older ones additionally needed libgcc_s.so.1 on the target — see #83). The other targets do not need this override; pass TARGET_ABI=musl only when BURRITO_TARGET=linux.

TARGET_ABI only chooses between precompiled artifacts, so it is silently ignored whenever the crate is built from source — with EX_RATATUI_BUILD exported, or :force_build set in the rustler_precompiled config, the release gets a NIF compiled against the build host's glibc no matter what TARGET_ABI says. Unset both before releasing the linux target.

Output lands in burrito_out/:

burrito_out/
 my_tui_linux       # 15–20 MB, statically linked, ready to ship

Repeat with BURRITO_TARGET=macos, macos_silicon, windows to produce the other artifacts — on a host that matches the target OS. While zig can cross-compile burrito's wrapper from any host, rustler_precompiled resolves the bundled NIF from the build host's triple, so a macOS or Windows release built on Linux ends up with a Linux .so inside and fails to load. The per-target CI matrix below is the canonical way to produce all artifacts in one pipeline.

Testing the wrapped binary

The same checklist works on every target — it is what this repo's CI runs against the demo, plus the interactive checks CI cannot do.

Non-interactive smoke — proves the BEAM boots and the NIF loads without a TTY:

./burrito_out/my_tui_linux --version
echo $?        # 0, after printing "my_tui 0.1.0"

On Windows (PowerShell), the same pair is .\burrito_out\my_tui_windows.exe --version and $LASTEXITCODE.

Interactive — run the binary on a real terminal: the TUI takes over the alternate screen, input works, and quitting restores the shell. Then check the exit codes: echo $? reports 0 after a clean quit, and 1 when the TUI crashed or the binary ran without a usable terminal (piped stdin, for example — it must exit, not hang).

Dev-session gate — from the consumer project itself, mix test and iex -S mix must behave normally: the CLI task starts with the application but never boots the TUI outside the wrapped binary.

One caveat when iterating: the wrapper caches the extracted release by version, so a rebuild with an unchanged version: re-executes the cached copy. Clear it between test builds with the wrapper's built-in maintenance command:

./burrito_out/my_tui_linux maintenance uninstall

Per-target CI matrix

A clean CI shape gives each runner one target on its native OS, so no host needs the union of all tools. The burrito_demo.yml workflow in this repo is the reference layout — copy it into a consumer project and adapt the artifact upload destination from a 7-day artifact to a GitHub Release:

strategy:
  matrix:
    include:
      - { os: ubuntu-latest,  target: linux,         artifact: my_tui_linux }
      - { os: macos-15-intel,       target: macos,         artifact: my_tui_macos }
      - { os: macos-14,       target: macos_silicon, artifact: my_tui_macos_silicon }
      - { os: windows-latest, target: windows,       artifact: my_tui_windows.exe }

Each job runs:

env:
  BURRITO_TARGET: ${{ matrix.target }}
  MIX_ENV: prod
run: mix release --overwrite

For tagged releases, swap the actions/upload-artifact step for softprops/action-gh-release and the binaries land directly on the GitHub Release page — the end-user install pattern becomes:

curl -L https://github.com/<owner>/<repo>/releases/latest/download/my_tui_linux \
  -o my_tui && chmod +x my_tui && ./my_tui

The shape of a wrapped app

Everything above treats the binary as a black box. Opening it up explains most of the gotchas that follow — a wrapped app is a single file at rest and an ordinary OTP release once it runs, and the first launch turns one into the other:

   my_tui_linux (~21 MB)             ~/.local/share/.burrito/
           
   zig launcher           unpack   my_tui_erts-17.0.4_1.0.0/             
         bin/                release entry 
   compressed payload      (once)    erts-17.0.4/        the BEAM      
    = the whole release              lib//priv/native/  the NIFs      
              releases/1.0.0/                   
                                      _metadata.json                    
               re-exec 

The binary itself is a zig-compiled launcher wrapped around a compressed copy of the release. Every launch it derives the cache directory name, unpacks the payload there if the directory is missing, then execs that release's standard bin/<app> start. So the first run pays for the unpack and every later run goes straight to the re-exec.

That directory name is <app>_erts-<erts version>_<app version>, and both halves matter:

  • erts-17.0.4 is the version of the ERTS bundled at build time — the runtime the release was compiled against, not the OTP release number. ERTS 17.0.x ships with OTP 29, ERTS 16.x with OTP 28.
  • 1.0.0 is the app version from mix.exs.

Because the name pins both, a rebuilt binary never reuses a stale unpack: bumping the app version, or rebuilding on a newer OTP, unpacks into a fresh directory and leaves the old one alone. That is also why the cache accumulates directories over time — NIF cache location below has the per-OS paths for finding and clearing them.

The wrapped BEAM has full TTY, SIGWINCH, and Ctrl-C handling — nothing in the wrapper interferes with the terminal once the BEAM takes over.

Gotchas

Terminal handoff

Burrito's wrapper writes a few diagnostic lines to stderr during the first-run unpack, then execs into the cached release. After the exec, nothing in the wrapper is between the BEAM and the TTY — raw mode, alt screen entry, SIGWINCH resize events, and Ctrl-C all behave exactly like a mix run'd TUI. The first-run delay is the only thing the wrapper adds; subsequent runs skip the unpack entirely.

macOS Gatekeeper

An unsigned binary downloaded over the network gets the com.apple.quarantine extended attribute. Gatekeeper refuses to run it until the attribute is cleared:

xattr -d com.apple.quarantine my_tui_macos
./my_tui_macos

Proper signing + notarization removes the friction for end users, but it is consumer-side concern — neither ex_ratatui nor Burrito ship signing keys. The Apple developer docs on notarization cover the workflow.

Windows SmartScreen / antivirus

Unsigned binaries on Windows frequently trigger SmartScreen prompts and get flagged by aggressive AV products. Authenticode signing eliminates the SmartScreen warning. Until that's in place, the README of a release should mention "if Windows blocks the binary, click More info → Run anyway."

NIF cache location

The unpacked release lives in a per-user cache, one directory per <app>_erts-<erts version>_<app version> combination:

OSPath
Linux~/.local/share/.burrito/
macOS~/Library/Application Support/.burrito/
Windows%LOCALAPPDATA%\.burrito\

Inside one of those directories, the ex_ratatui NIF sits at lib/ex_ratatui-<v>/priv/native/libex_ratatui-*.so (or .dylib / .dll) — handy to know when debugging "the NIF won't load." Clearing a cache is safe at any time; the next launch just pays for the unpack again.

Per-target dependencies in the payload

OTP releases bundle the contents of every dependency's priv/ directory. If priv/native/ in the consumer's _build/<env>/lib/ex_ratatui/ happens to hold multiple precompiled NIF variants (a side effect of bumping exratatui versions across dev sessions), all of them ship inside the binary even though only one is used. Setting the [`TARGET*environment variables](https://hexdocs.pm/rustler_precompiled/RustlerPrecompiled.html#module-environment-variables) beforemix deps.compile(or wiping_build/<env>/lib/ex_ratatui/priv/native/` between rebuilds) keeps the payload to a single matching variant.

This is payload weight, not a correctness problem, and verify_linux_nif/2 scans only the variant the release actually loads — a leftover glibc .so sitting next to the musl one will not fail the build.

Where to next