Precompilation guide

Copy Markdown View Source

This guide walks through setting up precompiled Zig NIFs for your Elixir library.

Why precompile?

Zigler provides easy Zig NIFs, but every user needs the Zig compiler installed. Precompilation removes that requirement — users download a pre-built shared library that matches their platform.

Advantages over Rust precompilation

Zig's cross-compilation story is dramatically simpler than Rust's:

  • No cross tool or Docker — Zig can cross-compile to any target from any host
  • Single CI job — a matrix of -Dtarget= flags builds everything
  • No NIF version matrix — Zig compiles directly against erl_nif.h, so there's no separate NIF 2.15/2.16/2.17 dimension

Configure GitHub Actions

Enable read/write permissions for the repository:

  1. Settings → Actions → General
  2. Workflow permissions → Read and write permissions

Build workflow

name: Build precompiled NIFs

on:
  push:
    tags: ['v*']

permissions:
  contents: write

jobs:
  build:
    runs-on: ${{ matrix.os }}
    strategy:
      matrix:
        include:
          - { target: aarch64-linux-gnu,      os: ubuntu-latest }
          - { target: aarch64-linux-musl,     os: ubuntu-latest }
          - { target: aarch64-macos-none,     os: macos-latest }
          - { target: arm-linux-gnueabihf,    os: ubuntu-latest }
          - { target: arm-linux-musleabihf,   os: ubuntu-latest }
          - { target: riscv64-linux-gnu,      os: ubuntu-latest }
          - { target: x86_64-linux-gnu,       os: ubuntu-latest }
          - { target: x86_64-linux-musl,      os: ubuntu-latest }
          - { target: x86_64-macos-none,      os: macos-latest }
          - { target: x86_64-windows-gnu,     os: ubuntu-latest }
          - { target: x86-linux-gnu,          os: ubuntu-latest }
          - { target: x86-windows-gnu,        os: ubuntu-latest }

    steps:
      - uses: actions/checkout@v4

      - name: Install Zig
        uses: goto-bus-stop/setup-zig@v2
        with:
          version: 0.15.1

      - name: Install Erlang/Elixir
        uses: erlef/setup-beam@v1
        with:
          otp-version: '27'
          elixir-version: '1.17'

      - name: Build NIF
        run: |
          mix deps.get
          mix zig.get

          # Build for the target
          ZIGLER_PRECOMPILING=${{ matrix.target }} mix compile

      - name: Package artifact
        run: |
          VERSION=${GITHUB_REF#refs/tags/v}
          BASENAME="MyApp.Native"
          LIB_NAME="${BASENAME}-v${VERSION}-${{ matrix.target }}"

          # Find the built library
          if [[ "${{ matrix.target }}" == *windows* ]]; then
            EXT=".dll"
          else
            EXT=".so"
          fi

          mkdir -p artifacts
          cp priv/lib/*${EXT} "artifacts/"
          cd artifacts
          tar czf "${LIB_NAME}${EXT}.tar.gz" *${EXT}

      - name: Upload to release
        uses: softprops/action-gh-release@v1
        with:
          files: artifacts/*.tar.gz

Note: The exact build script depends on your project structure. The key point is that Zig handles all cross-compilation natively — no Docker or cross needed.

The NIF module

defmodule MyApp.Native do
  version = Mix.Project.config()[:version]

  use ZiglerPrecompiled,
    otp_app: :my_app,
    base_url: "https://github.com/me/my_project/releases/download/v#{version}",
    version: version,
    force_build: System.get_env("MY_APP_BUILD") in ["1", "true"],
    nifs: [
      add: 2,
      multiply: 2
    ]
end

The :nifs option is required. It declares every NIF function as a {name, arity} pair. ZiglerPrecompiled generates stub functions matching Zigler's marshalled-<name> convention — these stubs are replaced when the precompiled .so loads.

When force_build: true, the :nifs option is stripped and the remaining options are passed to use Zig, which uses the Zig compiler for full compilation with rich error tracing and type marshalling wrappers.

Real-world example: QuickBEAM

defmodule QuickBEAM.Native do
  version = Mix.Project.config()[:version]

  use ZiglerPrecompiled,
    otp_app: :quickbeam,
    base_url: "https://github.com/dannote/quickbeam/releases/download/v#{version}",
    version: version,
    nifs: [
      eval: 3, compile: 2, call_function: 4,
      load_module: 3, load_bytecode: 2,
      reset_runtime: 1, stop_runtime: 1, start_runtime: 2,
      resolve_call: 3, reject_call: 3,
      resolve_call_term: 3, reject_call_term: 3,
      send_message: 2, define_global: 3,
      memory_usage: 1,
      dom_find: 2, dom_find_all: 2, dom_text: 2,
      dom_attr: 3, dom_html: 1
    ]
end

Generating checksums

After CI uploads all artifacts:

mix zigler_precompiled.download MyApp.Native --all --print

This creates checksum-Elixir.MyApp.Native.exs. You must include it in your Hex package:

defp package do
  [
    files: [
      "lib",
      "native",
      "checksum-*.exs",
      "mix.exs"
    ]
  ]
end

Release flow

  1. Tag a new release
  2. Push: git push origin main --tags
  3. Wait for all CI builds to finish
  4. Run mix zigler_precompiled.download MyApp.Native --all
  5. Publish to Hex (ensure checksum-*.exs is in files:)

Forcing a local build

Set the env var or config to skip precompiled downloads:

MY_APP_BUILD=1 mix compile

Or in config:

config :zigler_precompiled, :force_build, my_app: true

This requires {:zigler, ">= 0.0.0", optional: true} in your deps.