tinfoil
Release automation for Burrito-based Elixir CLIs.
Tinfoil reads a :tinfoil keyword in your mix.exs, generates a
GitHub Actions workflow, and provides mix tasks that workflow calls
at CI time to build each target, package the binary as a tar.gz with a
sha256 sidecar, create a GitHub Release, and upload the assets.
Status: pre-1.0. The mix tasks, workflow template, and Hex publish loop are all in place and dogfooded against a real Burrito project. Defaults and target strategies are still evolving — pin to an exact minor version if you need stability.
Scope
Burrito packages an Elixir application into a single binary. Tinfoil handles the steps around that: the CI matrix that runs the builds, the archive + checksum packaging, the GitHub Release creation, and (optionally) an installer script and a Homebrew formula template.
It does not replace Burrito, and it does not try to handle everything a release pipeline might ever want — anything beyond creating a GitHub Release and publishing archives (signing, notarization, custom distribution channels, etc.) is out of scope.
Installation
Add tinfoil to your dependencies alongside Burrito:
def deps do
[
{:burrito, "~> 1.0"},
{:tinfoil, "~> 0.2", runtime: false}
]
endDon't set
only: :dev. The generated CI workflow runsMIX_ENV=prod mix tinfoil.build, so tinfoil must be compiled in the prod environment too.runtime: falsekeeps it out of the started applications at runtime while still making the mix tasks available during builds.
Then add a :tinfoil key to project/0 in mix.exs:
def project do
[
app: :my_cli,
version: "0.1.0",
# ... standard project config ...
tinfoil: [
targets: [:darwin_arm64, :darwin_x86_64, :linux_x86_64, :linux_arm64],
homebrew: [
enabled: true,
tap: "owner/homebrew-tap"
],
installer: [
enabled: true
]
]
]
endThen run:
mix deps.get
mix tinfoil.init
That generates .github/workflows/release.yml and, if enabled, an
installer script, a Homebrew formula template, and a tap update
script. Commit the generated files and push a tag like v0.1.0 to
trigger the workflow.
Generated files
your-project/
├── .github/workflows/release.yml ← CI pipeline (always)
├── .tinfoil/formula.rb.eex ← if homebrew enabled
├── scripts/
│ ├── install.sh ← if installer enabled
│ └── update-homebrew.sh ← if homebrew enabled
└── mix.exs
The workflow runs a build job per configured target in parallel. Each
job calls mix tinfoil.build, which produces one .tar.gz with a
sha256 sidecar. A release job then collects the artifacts, calls
mix tinfoil.publish to create the GitHub Release, and (if homebrew
is enabled) runs the tap update script.
Tasks
| Task | Description |
|---|---|
mix tinfoil.init |
Print a suggested :tinfoil config snippet and, if one already exists, generate the workflow and supporting files. |
mix tinfoil.generate |
Regenerate the workflow and scripts from the current config. Run after editing :tinfoil in mix.exs or upgrading tinfoil. |
mix tinfoil.plan |
Print what would be built and released. Supports --format human (default), --format json, and --format matrix for GitHub Actions consumption. |
mix tinfoil.build |
Build a single target: run mix release with the right BURRITO_TARGET, package the binary into a tar.gz, and write a sha256 sidecar. Called by the generated workflow once per matrix entry. |
mix tinfoil.publish |
Create a GitHub Release from artifacts in artifacts/ and upload every archive plus a combined checksums-sha256.txt. Tags containing -rc, -beta, or -alpha are marked as prereleases. Pass --replace to delete and recreate if a release for the tag already exists. |
The generated workflow invokes mix tinfoil.build and
mix tinfoil.publish directly, so tinfoil version bumps usually take
effect the next time the workflow runs without needing to regenerate
the YAML.
Burrito target resolution
Tinfoil uses its own abstract target atoms (:darwin_arm64,
:linux_x86_64, …) independent of the names you choose in your
Burrito config. At load time, tinfoil reads your releases/0 block
and matches each tinfoil target to a Burrito target by [os:, cpu:]
pair.
For example, suppose your app declares custom Burrito target names:
releases: [
my_cli: [
steps: [:assemble, &Burrito.wrap/1],
burrito: [
targets: [
macos: [os: :darwin, cpu: :x86_64],
macos_m1: [os: :darwin, cpu: :aarch64],
linux: [os: :linux, cpu: :x86_64]
]
]
]
]
When tinfoil builds :darwin_arm64, it finds the matching Burrito
target (macos_m1), runs mix release with
BURRITO_TARGET=macos_m1, reads the output at
burrito_out/my_cli_macos_m1, and packages it as
my_cli-0.1.0-aarch64-apple-darwin.tar.gz. If a tinfoil target has no
matching Burrito target, Tinfoil.Config.load/1 returns an error at
plan time naming the expected [os:, cpu:] pair.
mix tinfoil.plan
Read-only preview of the release plan, including the resolved Burrito target names:
$ mix tinfoil.plan
tinfoil plan for my_cli 0.1.0
target burrito runner archive
───────────── ──────── ───────────── ───────────────────────────────────────────────
darwin_arm64 macos_m1 macos-latest my_cli-0.1.0-aarch64-apple-darwin.tar.gz
linux_x86_64 linux ubuntu-latest my_cli-0.1.0-x86_64-unknown-linux-musl.tar.gz
format: tar_gz (sha256)
github: owner/my_cli (draft: false)
homebrew: disabled
installer: ~/.local/bin
For CI consumption, --format matrix emits a compact GitHub Actions
matrix fragment:
- id: plan
run: echo "matrix=$(mix tinfoil.plan --format matrix)" >> "$GITHUB_OUTPUT"
build:
needs: plan
strategy:
matrix: ${{ fromJson(needs.plan.outputs.matrix) }}Supported targets
| Target | Triple | GitHub runner | Archive |
|---|---|---|---|
:darwin_arm64 | aarch64-apple-darwin | macos-latest | .tar.gz |
:darwin_x86_64 | x86_64-apple-darwin | macos-15-intel | .tar.gz |
:linux_x86_64 | x86_64-unknown-linux-musl | ubuntu-latest | .tar.gz |
:linux_arm64 | aarch64-unknown-linux-musl | ubuntu-latest | .tar.gz |
:windows_x86_64 | x86_64-pc-windows-msvc | ubuntu-latest | .zip |
Triples follow the standard Rust-style convention since that is what users expect to see in release asset names.
:darwin_x86_64usesmacos-15-intel, GitHub's last Intel runner label, available until August 2027. After that date, native x86_64 macOS runners will no longer exist on GitHub Actions. By then the Intel Mac install base will be small enough that dropping the target is likely the right call.
Windows and linux_arm64 builds cross-compile from ubuntu-latest
via Zig; no native windows-latest or ubuntu-24.04-arm runner is
required. The cross-compiled linux_arm64 default matters because
GitHub's ARM runner is only on paid plans -- free-tier users were
previously stuck on a queued job forever. Paid users who want a
native arm64 build can flip the runner back via :extra_targets.
The generated installer script targets Unix shells only; a PowerShell installer for Windows users is future work.
Collapsing the build matrix
By default every target is its own CI job. Set
single_runner_per_os: true in your :tinfoil config to collapse
each OS family onto one job that builds every target in that family
sequentially:
tinfoil: [
targets: [:darwin_arm64, :darwin_x86_64, :linux_x86_64, :linux_arm64],
single_runner_per_os: true
]
The runner used for each family is taken from the first target in
that family, so list the one you want to own the family first
(:darwin_arm64 before :darwin_x86_64 puts both on macos-latest
and cross-compiles the x86 slice via Zig). This trades wall-clock
parallelism for fewer runner-minutes; leave it off if your builds
don't contend for runners.
NIFs and cross-compilation
Burrito cross-compiles via Zig, which handles pure Erlang/Elixir deps
reliably but can struggle with NIFs (Rustler crates, elixir_make C
extensions, raw c_src/ sources). mix tinfoil.plan inspects your
resolved deps and prints a warning for anything that looks like a NIF
so you know where to double-check your built artifacts. The warning
is informational -- many NIFs do cross-compile cleanly, and
rustler_precompiled sidesteps the issue when prebuilts cover your
targets.
Configuration reference
tinfoil: [
# Required. Targets to build.
targets: [:darwin_arm64, :linux_x86_64],
# Archive naming template. Interpolations: {app}, {version}, {target}.
archive_name: "{app}-{version}-{target}",
archive_format: :tar_gz,
# GitHub Release configuration. repo is inferred from `git remote get-url
# origin` if omitted.
github: [
repo: "owner/my_cli",
draft: false
],
# Homebrew formula generation. Requires a HOMEBREW_TAP_TOKEN secret with
# repo access to the tap.
homebrew: [
enabled: true,
tap: "owner/homebrew-tap",
formula_name: "my_cli" # defaults to the app name
],
# Shell installer script.
installer: [
enabled: true,
install_dir: "~/.local/bin"
],
checksums: :sha256,
ci: [
provider: :github_actions,
# All three are auto-detected if not set: elixir_version from the
# project's :elixir requirement, otp_version from System.otp_release(),
# zig_version from Burrito.get_versions(). These are the fallbacks.
elixir_version: "1.19",
otp_version: "28",
zig_version: "0.15.2"
]
]
The only required key is :targets. Everything else has a sensible default.
Related projects
- Burrito — builds
self-contained Elixir binaries. Required peer dependency. Tinfoil
reads your Burrito target config and drives
mix releasevia the normal Burrito flow. - cargo-dist — the equivalent tool in the Rust/Cargo ecosystem. Tinfoil borrows the architectural pattern of a generated CI workflow that calls back into the tool via mix tasks, so upgrading the tool upgrades the pipeline.
License
MIT.