tinfoil
Release automation for Burrito-based Elixir CLIs. Tag a version, tinfoil ships the binaries.
What you get
One git tag v1.0.0 && git push --tags produces:
- Cross-compiled binaries for darwin (arm64, x86_64), linux (arm64, x86_64 musl), and windows (x86_64) — built on GitHub Actions, cross-compiled via Zig (no native ARM or Windows runner required).
- A GitHub Release with every archive attached, a combined
checksums-sha256.txt, and release notes auto-generated from your commits. - A
curl | shinstaller (optional) atscripts/install.shand a PowerShell equivalent atscripts/install.ps1. Both pick the right asset for the user's OS/arch and verify the sha256 before installing. - An updated Homebrew formula (optional) pushed to your tap so
brew install you/tap/yourclijust works on macOS and Linux (via Linuxbrew, no extra config) — tinfoil rendersFormula/yourcli.rbwith real URLs + SHAs and commits it, under either a PAT or an SSH deploy key. - A Scoop manifest (optional, Windows) pushed to a bucket repo
so
scoop install you/bucket/yourclijust works. Same auth model as Homebrew. - Optional extras bundled with every binary.
extra_artifacts:config ships your LICENSE, man pages, shell completions, or whatever else alongside the binary at configurable destinations inside each archive. - A regeneratable workflow.
mix tinfoil.generaterewrites.github/workflows/release.ymlfrom yourmix.exsconfig, so upgrading tinfoil upgrades the pipeline.
All configured via one :tinfoil keyword in mix.exs; no hand-edited
YAML. See tinfoil_demo
for a full working project.
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.
How it works
Tinfoil reads the :tinfoil keyword in mix.exs, resolves it
against your Burrito :releases config, and provides mix tasks
the generated workflow calls at CI time. The workflow runs one
build job per target (or one per OS family if you opt into
single_runner_per_os), uploads artifacts, and a release job
stitches them into a GitHub Release plus (optionally) a Homebrew
tap push.
Scope
Burrito packages an Elixir application into a single binary. Tinfoil handles the steps around that: the CI matrix, archive + checksum packaging, the GitHub Release, and the installer / Homebrew surfaces.
It does not replace Burrito, and anything beyond publishing archives (signing, notarization, non-GitHub distribution, 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
Or skip the manual edits entirely on a fresh mix new project:
mix tinfoil.init --install # splices dep + config into mix.exs
mix tinfoil.init # generates the workflow
That generates .github/workflows/release.yml and, if enabled, an
installer script, and a Homebrew formula template. Commit the
generated files and push a tag like v0.1.0 to trigger the workflow.
Heads-up: your CLI needs an Application callback. Burrito's
main_moduleconfig key is metadata only — Burrito boots the BEAM but never callsmain/1itself. Without an OTP application callback that reads argv and runs your CLI, the binary launches and just sits there until you SIGTERM it. The minimal pattern:# mix.exs def application do [extra_applications: [:logger], mod: {MyCli.Application, []}] end # lib/my_cli/application.ex defmodule MyCli.Application do use Application def start(_type, _args) do if Burrito.Util.running_standalone?() do spawn(fn -> MyCli.run(Burrito.Util.Args.argv()) System.halt(0) end) end Supervisor.start_link([], strategy: :one_for_one, name: MyCli.Supervisor) end endThe
running_standalone?/0guard keepsmix testandiex -S mixfrom hijacking their own argv. Seetinfoil_demofor a full working example.
Generated files
your-project/
├── .github/workflows/release.yml ← CI pipeline (always)
├── .tinfoil/formula.rb.eex ← if homebrew enabled
├── scripts/
│ ├── install.sh ← if installer enabled (Unix)
│ └── install.ps1 ← if installer enabled (Windows)
└── 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) calls mix tinfoil.homebrew to render the formula and
push it to the configured tap.
Tasks
| Task | Description |
|---|---|
mix tinfoil.init |
Print a suggested :tinfoil config snippet and, if one already exists, generate the workflow and supporting files. Pass --install to splice the tinfoil dep + a starter config into mix.exs and run mix deps.get. |
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. |
mix tinfoil.homebrew |
Render the Homebrew formula from artifacts/ and push it to the configured tap. Honors homebrew.auth for choosing between a PAT (HOMEBREW_TAP_TOKEN) and an SSH deploy key. |
mix tinfoil.scoop |
Render the Scoop manifest from artifacts/ and push it to the configured bucket. Honors scoop.auth for choosing between a PAT (SCOOP_BUCKET_TOKEN) and an SSH deploy key. Requires :windows_x86_64 in :targets. |
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.
Two installer scripts ship when installer.enabled: true:
scripts/install.sh for Unix (curl | sh) and scripts/install.ps1
for Windows (iex (irm ...)). Both resolve the latest release tag
from the GitHub API, download the right asset for the detected
OS/arch, verify against the combined checksums-sha256.txt, and
install to a sensible default directory (configurable via flags).
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.
Homebrew auth
The homebrew: job needs push access to the tap repo. Two auth
modes are supported:
auth: :token (default). The workflow expects a
HOMEBREW_TAP_TOKEN repo secret -- a Personal Access Token (classic
or fine-grained) with contents: write on the tap repo. The mix
task clones over HTTPS with the token baked into the URL.
auth: :deploy_key. Generate an SSH key pair, add the public
key to the tap repo's deploy keys (with write access), and set the
private key as the HOMEBREW_TAP_DEPLOY_KEY secret on the CLI
repo. The generated workflow installs
webfactory/ssh-agent
before running mix tinfoil.homebrew, which clones over SSH. Deploy
keys are scoped to a single repo and never expire, which is the main
reason to prefer them over PATs.
If your secret is named differently, override the name with
homebrew: [token_secret: "YOUR_NAME"] or
homebrew: [deploy_key_secret: "YOUR_NAME"]. The env var the mix
task reads is fixed; only the secret reference in the workflow is
configurable.
Linuxbrew
The generated formula's on_linux block makes it work under
Linuxbrew too -- no
separate config needed. Linux users can run
brew install owner/tap/myapp the same way macOS users do, and
they'll pull the matching linux_x86_64 or linux_arm64 tarball.
Scoop (Windows)
Symmetric counterpart to Homebrew for Windows users. When
scoop: [enabled: true] and you have :windows_x86_64 in
:targets, every release pushes a Scoop manifest to the
configured bucket repo:
scoop: [
enabled: true,
bucket: "owner/scoop-bucket",
auth: :token # or :deploy_key
]
Create the bucket repo on GitHub (any name works, but the
convention is scoop-<something>), grant push access via a PAT
named SCOOP_BUCKET_TOKEN or an SSH deploy key named
SCOOP_BUCKET_DEPLOY_KEY, and downstream users install with:
scoop bucket add owner https://github.com/owner/scoop-bucket
scoop install owner/my_cli
The rendered manifest includes a checkver + autoupdate block
so Scoop bucket maintainers (or automated bots) can pick up new
versions without tinfoil re-pushing. If you don't want that, edit
the manifest in the bucket after push.
If your bucket-auth secret is named differently, override the name
with scoop: [token_secret: "YOUR_NAME"] or
scoop: [deploy_key_secret: "YOUR_NAME"] — same pattern as the
Homebrew section above. The env var the mix task reads is fixed;
only the secret reference in the workflow is configurable.
Release channels and prerelease handling
prerelease_pattern controls two things:
- GitHub Release creation (
mix tinfoil.publish) — the release is marked as a prerelease when the tag matches the pattern - Homebrew / Scoop push-skip — the generated workflow jobs skip the publish step when the tag looks like a prerelease, so tagged prereleases don't overwrite the stable formula/manifest
The workflow's skip condition is hardcoded to match the default
pattern (-rc, -beta, -alpha). If you override
prerelease_pattern to use different tokens (-dev, -nightly,
-snapshot, ...), mix tinfoil.publish will respect your pattern
for the release flag, but the Homebrew and Scoop jobs will still
only skip the default tokens. Workarounds:
-
Keep a superset pattern in
prerelease_patternthat always includes the default tokens, OR -
Add a
homebrew: [enabled: false]/scoop: [enabled: false]environment-gated override (the release still gets published; the package managers just won't auto-update), OR -
Hand-edit
.github/workflows/release.ymlaftermix tinfoil.generateto extend theif:expression
Unifying this into a single configurable skip list is tracked; open a PR if you need it before we get there.
Runtime output from the wrapped binary
A Burrito-wrapped binary prints a handful of diagnostic lines to stderr on every invocation before your CLI output:
debug: Unpacked 977 files
debug: Going to clean up older versions of this application...
debug: Launching erlang...
[l] Uninstalled older version (v0.5.0)
These are emitted by Burrito's Zig wrapper (the debug: lines) and
its maintenance pass (the [l] line when an older cached version is
cleaned up). They are not tinfoil's output and tinfoil cannot
silence them from the outside -- the wrapper runs before any Elixir
code loads. Passing debug: false inside your burrito: config block
has no effect on these lines as of Burrito 1.5.
The noise is safe to redirect (your_cli 2>/dev/null) if it bothers
end users. Upstream tracking lives with Burrito; follow
https://github.com/burrito-elixir/burrito if a quieter mode lands.
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],
# Optional: user-defined targets merged on top of the built-in matrix.
# Each entry needs the full spec shape.
extra_targets: %{},
# Optional: collapse every target in an OS family onto one CI runner
# that builds them sequentially. Defaults to false — one job per target.
single_runner_per_os: false,
# Regex matched against the git tag to auto-mark a release as
# prerelease. Default covers -rc / -beta / -alpha; override if you use
# different conventions. See the caveat about Homebrew / Scoop skip
# logic in the Release channels section below.
prerelease_pattern: ~r/-(rc|beta|alpha)(\.|$)/,
# 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 auth material for the tap
# repo — either HOMEBREW_TAP_TOKEN (PAT) or an SSH deploy key.
homebrew: [
enabled: true,
tap: "owner/homebrew-tap",
formula_name: "my_cli", # defaults to the app name
auth: :token, # or :deploy_key (default :token)
token_secret: "HOMEBREW_TAP_TOKEN", # GitHub secret name for the PAT
deploy_key_secret: "HOMEBREW_TAP_DEPLOY_KEY" # GitHub secret name for the SSH key
],
# Scoop manifest generation (Windows). Same auth model as Homebrew.
# Requires :windows_x86_64 in :targets.
scoop: [
enabled: true,
bucket: "owner/scoop-bucket",
manifest_name: "my_cli", # defaults to the app name
auth: :token, # or :deploy_key (default :token)
token_secret: "SCOOP_BUCKET_TOKEN", # GitHub secret name for the PAT
deploy_key_secret: "SCOOP_BUCKET_DEPLOY_KEY" # GitHub secret name for the SSH key
],
# Shell installer script.
installer: [
enabled: true,
install_dir: "~/.local/bin"
],
checksums: :sha256,
# GitHub build provenance attestations on every uploaded artifact.
# Defaults to true; set false to opt out (which also drops the
# `id-token: write` and `attestations: write` permissions from the
# generated workflow).
attestations: true,
# Extra files to bundle alongside the binary in every archive. Bare
# strings use the same relative path inside the archive; a source/dest
# map places the file at a custom location.
extra_artifacts: [
"LICENSE",
%{source: "man/myapp.1", dest: "share/man/man1/myapp.1"}
],
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.