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.

The shape of a wrapped app

                                  one-time on first run
                          
                                                          
        
    my_tui_linux        ~/.local/share/.burrito/     
    (~17 MB ELF)           my_tui_erts-28_1.0.0/       
           erts-28/                
                              lib/ex_ratatui-0.9.0/   
                                priv/native/*.so       
                              releases/1.0.0/         
                              bin/                    
                           
                                                          
            re-exec into the cached release 

Build time, the burrito wrapper is a zig-compiled launcher embedding a compressed payload (the full OTP release). At runtime, the wrapper checks whether the cache already holds an extracted copy of this exact version, extracts the payload if not, and execs the standard release/bin/<app> start entry point. The wrapped BEAM has full TTY, SIGWINCH, and Ctrl-C handling — nothing in the wrapper interferes with the terminal once the BEAM takes over.

Prerequisites

  • Erlang/OTP and Elixir matching ex_ratatui's mix.exs requirements.
  • zig exactly 0.15.2. Burrito 1.5 hard-pins this; any other version is rejected at mix release time. mise install zig@0.15.2 is the shortest path; the broader install matrix is on the Burrito README.
  • xz on PATH. Usually already present on Linux and macOS.
  • 7z only when cross-building Windows targets from a non-Windows host.

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

[tools]
zig = "0.15.2"

Quick start

The fastest path is reading examples/burrito_demo/ in this repo — it's a complete working project. The walkthrough below mirrors that example and is the same shape mix ex_ratatui.gen.burrito produces.

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.9"},
    {:burrito, "~> 1.5"}
  ]
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, &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

Wire the Application as Burrito's entry point. Burrito expects a :mod in application/0 and 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 = [
      {Task, fn -> MyTui.CLI.main(Burrito.Util.Args.argv()) end}
    ]

    Supervisor.start_link(children, strategy: :one_for_one, name: MyTui.Supervisor)
  end
end
# lib/my_tui/cli.ex
defmodule MyTui.CLI do
  def main(_argv) do
    {:ok, pid} = MyTui.TUI.start_link([])
    ref = Process.monitor(pid)

    receive do
      {:DOWN, ^ref, :process, ^pid, _reason} -> :ok
    end

    System.stop(0)
  end
end

MyTui.TUI is any module using ExRatatui.App. The CLI's job is to boot that GenServer, wait for it to exit, then stop the VM so the wrapper returns control to the shell.

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:

BURRITO_TARGET=linux MIX_ENV=prod mix release --overwrite

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. Cross-builds work from any host (zig handles the cross-compile), provided the host has xz and — for Windows targets — 7z.

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-13,       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

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:

OSPath
Linux~/.local/share/.burrito/<app>_erts-<v>_<version>/
macOS~/Library/Application Support/.burrito/<app>_erts-<v>_<version>/
Windows%LOCALAPPDATA%\.burrito\<app>_erts-<v>_<version>\

The directory name encodes the ERTS and release version, so multiple versions of the same app can coexist. The ex_ratatui NIF lives at lib/ex_ratatui-<v>/priv/native/libex_ratatui-*.so (or .dylib / .dll) inside that directory — handy to know when debugging "the NIF won't load."

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.

Generator shortcut

Once mix ex_ratatui.gen.burrito lands, the entire setup above collapses into:

mix ex_ratatui.gen.burrito --app my_tui --ci github

The task patches mix.exs, scaffolds the Application + 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.

Where to next