Maintainer-facing runbook for tasks that don't fit in the consumer-facing
README. If you're just using Emily, start at README.md.
Emily has two distinct compile paths depending on whether it's being built from source (in this repo / CI) or consumed as a hex package:
-
In-repo / CI (has
c_src/).mix compileruns:emily_mlx → :elixir_make. Thecompile.emily_mlxalias callsscripts/build-mlx.sh, which cmake-builds libmlx.a + mlx.metallib from the:mlx_srcMix git dep (deps/mlx_src/) and installs into$EMILY_CACHE/mlx-<v>-<variant>(default$(getconf DARWIN_USER_CACHE_DIR)emily/mlx-<v>-<variant>on macOS,${XDG_CACHE_HOME:-~/.cache}/emily/mlx-<v>-<variant>on Linux).elixir_makethen compilesc_src/*.cppagainst that MLX install and linkspriv/libemily.{so,dylib}. -
Hex consumer (no
c_src/in the tarball).mix compileruns:emily_nif. Thecompile.emily_nifalias downloads the matchingemily-nif-<v>-<variant>-<target>.tar.gzfrom the emily GitHub release for the tag, verifies its SHA256 against the checksum pinned innative_checksums.txt(shipped inside the hex package, so it's covered by Hex's package hash in the consumer'smix.lock— a trust root independent of the mutable release), validates the archive entries against an allowlist (libemily.so/libemily.dylib+mlx.metallib; rejects symlinks, hardlinks,..traversal, absolute paths, and any unexpected entry), and extracts those files intopriv/via:erl_tar. No compilation; no MLX source tree on the consumer side. SeeEmily.NifArtifact.
The switch is driven by a File.dir?("c_src") check in mix.exs's
compilers/0 — the hex package[:files] list ships only lib/ and
the docs, so consumers land on the download path automatically.
EMILY_CACHE must point at a private, user-owned directory: the
source build statically links $EMILY_CACHE/mlx-<v>-<variant>/lib/libmlx.a
into the NIF, so the build refuses to reuse a cache dir owned by another
user (and keeps its own dirs 0700) to stop a shared cache from planting
native code. The per-user macOS/XDG defaults already satisfy this.
Variant selection is unified via the :variant app-config key:
in-repo builds read EMILY_MLX_VARIANT env var (aot|jit,
default aot) through config/config.exs and stash the atom as
Application.get_env(:emily, :variant); hex consumers set
config :emily, variant: :jit in their own config/config.exs.
Consumers verify each NIF tarball against the checksum pinned in
native_checksums.txt, which ships in the hex package. The publish step
itself is two commands (steps 4–5): mix emily.publish regenerates that
file from the built release artifacts, then mix hex.publish publishes —
so there is nothing to update or commit by hand (the file is git-ignored
and can't go stale).
Normal PR flow. The per-matrix CI lane (precommit job) is the
canonical "still works" signal.
mix publisho patch # or minor / majorBumps @version, rolls RELEASE.md into CHANGELOG.md under a
dated ## <v> heading, commits Version <v>, and creates an annotated
bare-semver tag (no v prefix). It does not push — that's the next
step.
git push # the `Version <v>` commit
git push origin <v> # the tag — this is what fires the release workflowThe tag push fires .github/workflows/release-nif.yml, which fans
out {variant × target}:
| Variant | Target | Runner |
|---|---|---|
| aot | macos-arm64 | macos-14 |
| jit | macos-arm64 | macos-26 |
Each cell clones :mlx_src, builds MLX + the NIF from source
(scripts/build-mlx.sh + elixir_make), tars
priv/libemily.* + priv/mlx.metallib as
emily-nif-<v>-<variant>-<target>.tar.gz, writes a .sha256
sidecar (informational — consumers verify against the pinned
native_checksums.txt, not the sidecar), and uploads both to a
draft GitHub release at
https://github.com/ausimian/emily/releases/tag/<v> — the URL the
consumer's compile.emily_nif step fetches from.
Once both cells finish, the workflow's publish-release job flips the
release out of draft automatically (gh release edit <v> --draft=false),
so its assets become public with no manual step. That job is gated to the
tag-push path, so a manual workflow_dispatch rebuild leaves the release
untouched — see Rebuilding without retagging.
Once the workflow has finished and the release is public:
mix emily.publish # alias for `mix emily.checksums`Downloads each tarball from the now-public release and records its SHA256
into native_checksums.txt. It does not trust the .sha256 sidecars —
it hashes the bytes itself. If the release isn't public yet, the download
404s and the task aborts before writing anything.
mix hex.publish # publishes the package (incl. native_checksums.txt) + docsPackages native_checksums.txt — so it's covered by Hex's package hash in
the consumer's mix.lock, a trust root independent of the mutable GitHub
release — and publishes the package + docs.
Steps 4 and 5 are two separate commands on purpose — they can't be folded
into one alias. Mix only loads the Hex archive for the task named on the
command line, so a hex.publish step chained inside an alias (whose CLI
name is emily.publish) fails with ** (Mix) The task "hex.publish" could not be found. Running mix hex.publish directly is what loads Hex.
hex.publish keeps its :docs preferred_env, so it publishes docs as
well as the package.
After publishing, in a throwaway project:
mix new /tmp/emily-verify && cd /tmp/emily-verify
# add {:emily, "~> <v>"} to deps
mix deps.get && mix compile
iex -S mix
# Nx.default_backend(Emily.Backend)
# Nx.tensor([1.0, 2.0]) |> Nx.add(3) |> Nx.to_flat_list()mix compile reads the pinned checksum from native_checksums.txt,
downloads the tarball, verifies, validates entries, extracts. A
variant-mismatched consumer (config :emily, variant: :jit) should
download the JIT tarball instead — worth spot-checking both lanes on
the first release of a bump. The checksum pin only exists in the
published package, so this must run against the published package,
i.e. after step 5.
If you need to reproduce a release's artefacts out-of-band (say, to
compare against an earlier build, or to iterate on
scripts/build-mlx.sh without bumping the version), trigger
release-nif.yml manually:
gh workflow run release-nif.yml --repo ausimian/emily --ref mainThe dispatch run resolves @version from mix.exs, builds the
same tarballs, and stashes them as workflow-run artefacts
(retention 90 days). The GitHub release is untouched, so consumers
on that version see no change.
Emily pins an MLX version in mix.exs (@mlx_version). The
:mlx_src git dep is cloned at v<@mlx_version> by mix deps.get,
so changing the attribute is the entire pin.
- Bump
@mlx_versionin mix.exs. mix deps.update mlx_src.- Force a local MLX rebuild to sanity-check:
rm -rf "$(getconf DARWIN_USER_CACHE_DIR)emily/"mlx-<new>-* # macOS default # or: rm -rf "${EMILY_CACHE:-${XDG_CACHE_HOME:-$HOME/.cache}/emily}/"mlx-<new>-* mix precommit
- Note the bump in
RELEASE.md. - Land the PR, then follow the release flow above. CI's NIF builds pick up the new MLX automatically.
brew install cppcheck # one-time
make cppcheckRuns cppcheck over c_src/ and exits non-zero on any finding. It needs
neither a built libmlx nor the BEAM toolchain, so it works on a bare
checkout in seconds — the same target CI runs (.github/workflows/ci.yml,
the cppcheck job). See the cppcheck target in the Makefile for the
enabled checks and suppressions; use inline // cppcheck-suppress <id>
for one-off false positives.
brew install llvm # one-time; clang-tidy isn't in stock Xcode
mix clang.tidyRuns clang-tidy (including the clang static analyzer, via its
clang-analyzer-* checks) over c_src/ and exits non-zero on any finding
— the same tool the clang-tidy CI job runs. Unlike cppcheck it compiles
each translation unit, so it needs the MLX/Fine/ERTS headers and build
flags; mix clang.tidy supplies that env (reusing the cached MLX) and
drives the clang-tidy Makefile target, so make clang-tidy on its own
will refuse to run. Enabled checks and the header filter live in the
repo-root .clang-tidy; use inline // NOLINT(<check>) for one-off false
positives. Point at a specific binary with CLANG_TIDY=/path/to/clang-tidy.
mix deps.get # populate deps/mlx_src
scripts/build-mlx.sh deps/mlx_src <v> 0 /tmp/mlx-install # 0 = AOT, 1 = JITmix hex.build # produces emily-<v>.tar
# unpack into a throwaway project as a path dep
# (see prior scripts/smoke-test-package.sh for the pattern)The consumer will hit the real compile.emily_nif step — if the
tarball and its .sha256 sidecar are present on the published
GitHub release for the tag, it downloads + verifies + extracts;
otherwise the sidecar fetch 404s with a clear NIF download failed (HTTP 404 Not Found) error pointing at the missing asset URL.
The JIT libmlx.a is built against the macOS 26.2+ SDK — MLX's NAX
kernel sources transitively include
<MetalPerformancePrimitives/MetalPerformancePrimitives.h>, which
only ships in that SDK, and they also end up referencing libSystem
symbols (e.g. __fmaxf16) that older macOS releases don't have. The
JIT NIF therefore requires macOS 26.2+ at runtime as well as build
time, which is why the JIT CI lane runs on macos-26 — the binary
won't dlopen on older hosts.
The AOT lane has no such constraint and is built on macos-14, so
the AOT NIF runs anywhere from macOS 14 upward.