tinfoil

CIHex.pmHexDocsLicense

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:

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}
  ]
end

Don't set only: :dev. The generated CI workflow runs MIX_ENV=prod mix tinfoil.build, so tinfoil must be compiled in the prod environment too. runtime: false keeps 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
      ]
    ]
  ]
end

Then 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_module config key is metadata only — Burrito boots the BEAM but never calls main/1 itself. 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
end

The running_standalone?/0 guard keeps mix test and iex -S mix from hijacking their own argv. See tinfoil_demo for 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_arm64aarch64-apple-darwinmacos-latest.tar.gz
:darwin_x86_64x86_64-apple-darwinmacos-15-intel.tar.gz
:linux_x86_64x86_64-unknown-linux-muslubuntu-latest.tar.gz
:linux_arm64aarch64-unknown-linux-muslubuntu-latest.tar.gz
:windows_x86_64x86_64-pc-windows-msvcubuntu-latest.zip

Triples follow the standard Rust-style convention since that is what users expect to see in release asset names.

:darwin_x86_64 uses macos-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:

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:

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&#39;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

License

MIT.