v0.2.1 - 2026-11-10

  • Docs only. The README's guide links open the HexDocs pages again (on hex.pm they opened the raw Markdown), Precompiled.Host.run/1 documents its own report() type, and the docs build without warnings.
  • mix hex.publish runs in the docs env, where ex_doc lives.

v0.2.0 - 2026-11-10

  • Renamed to precompiled before the first Hex release: the app and package :precompiled, modules Precompiled.* (the host API, run/1 and the Mix.install/2 wrapper install/2, is now Precompiled.Host; Precompiled itself is the device API), config config :precompiled, environment variables PRECOMPILED_*, the default cache dir :filename.basedir(:user_cache, "precompiled"), the provenance file .mix/precompiled.json and the archive precompiled-<vsn>.ez. The Igniter installer is mix igniter.install precompiled (mix precompiled.install), and the deps.loadpaths step it adds is now mix precompiled.deps. A clean break: none of the interim elixir_precompiled names is read, and a cache under the old dir is simply not used. The repository keeps its name, elixir_precompiled.

  • Precompiled.install/1,2 installs signed precompiled builds into a running Nerves device from an IEx shell, with no Mix, Hex or compiler on the device: it selects the build for the device's system, OTP, Elixir, CPU and present apps (by name and exact version), verifies every byte, loads and starts it with its absent runtime dependencies, and rolls the whole call back on any failure. Nothing present is ever replaced. Precompiled.list/1, Precompiled.uninstall/2 and dry_run: true (with report: for a bundle) come with it. See the "On a device" guide.

  • On by default, with the official signed source (https://compatibility.nerves-project.org/api/precompiled) as the default source; it only acts when called. config :precompiled, :device sets enabled: false (no installs), allow: [...] (an allowlist), another source, dir, max_bytes and cacerts_path; enabled and allow cannot be overridden by a call.

  • Installs are kept as a cache on /data across reboots, never loaded at boot; installing again loads from the cache after re-verifying every file. The cache is bounded by max_bytes with least recently used eviction, and Precompiled.clean/0 empties it.

  • file:// sources: a bundle directory with the API's layout, read from local storage only, with no network and no network apps.

  • Offline bundles: a bundle's offline.json, signed by the root's offline role, has no expiry and needs no clock; a device honours it only from a file:// source, under a root with an offline role, and never below the highest offline sequence it has seen. A device adopts a newer root from a bundle when it chains. The shipped root has no offline role yet, so a bundle is valid for its batch's 24 hours.

  • mix precompiled.bundle PKG[@REQ]... --out DIR writes a bundle on a host with network, planned from the device's facts (flags, or a dry run's --device-report), with every byte checked before it is written.

  • mix igniter.install precompiled --device keeps the dependency at runtime so it ships in the firmware, and adds castore unless the project already depends on it.

  • :inets, :ssl and :public_key are optional applications; :crypto stays required. A firmware without them installs from bundles only (an online call returns {:error, {:missing_app, app}}).

  • cacerts_path: (an option of Precompiled.install/2 and a :device config key) names a PEM CA bundle for a device install. Without one (and without PRECOMPILED_CACERTS_PATH/HEX_CACERTS_PATH), an empty or missing OS certificate store now falls back to the castore package's bundle when that is loaded. With no certificates at all the error now reads no CA certificates (before: no CA certificate store on this system), and an empty store is that error too, never a TLS attempt with no CAs.

  • The host's check that an installed build is still intact is stricter about priv: a priv that is a copy rather than Mix's link to the dependency's sources (as on Windows, where links may be refused) no longer counts as intact, so such a build is installed once more.

  • enabled: false disables install/2 only: list/1, uninstall/2 and clean/1 keep working, so what is installed can still be removed.

v0.1.0 - unreleased

  • Any Mix project can use it: mix precompiled.deps, reached through a deps.loadpaths alias (mix igniter.install precompiled adds it), installs builds before Mix compiles dependencies in plain projects, umbrellas and Phoenix apps, and for Nerves with released nerves too, where only pure_elixir builds match: system (target) builds need the Nerves fork's Nerves.build_plan/0 to name the system. Precompiled.Host.run/1 for the nerves hook is unchanged. The Igniter installer sets the dependency runtime: false, :source in config/config.exs and the alias, prepended to an existing one; Igniter is an optional dependency, declared only on Elixir 1.18+.
  • Never fails a build: any internal error is one warning and a source compile; no HOME or an unwritable cache moves the cache under _build.
  • Warm runs make one conditional batch request (If-None-Match) and download nothing once the source answers If-None-Match with 304 (until then the batch itself is downloaded again; manifests and files still come from the cache); the root is checked at most once an hour; manifests are cached by SHA-256; files of a dependency download in parallel. PRECOMPILED_OFFLINE=1 works from the cache; a run has a network deadline (:deadline_seconds, 300; root and batch 15 s). When the source cannot be reached, a cached batch that still passes every check is used; a refused response never falls back. A source that failed to connect is then not asked again in that run, so uncached manifests and files miss at once; a content origin that fails to connect is skipped for the run (the source still serves).
  • Content origins: the signed batch (or :content) may name CDNs for files and manifests, with fallback to the source; every byte is still checked.
  • An installed build is kept while the batch still offers its exact files, so a new portal run of the same inputs no longer recompiles the project.
  • Install refuses colliding paths (duplicate, case or Unicode variants, path collision: ...), paths that are not plain relative paths inside the dependency's own build dir (unsafe path or mode (...)), ebin/ entries other than beams, <dep>.app and <dep>.appup, such as another app's .app (foreign path: ...), and beams the .app does not list; linking happens before the install is renamed into place. A cache entry that is a symlink is never followed: that dependency compiles, and the line names the path to remove.
  • Dependencies that read Mix.target() or environment variables at compile time are matched on them (README, rule 8).
  • nerves' own dependencies are recognised from Mix's dependency data, so a git or path nerves keeps them out.
  • PRECOMPILED=0, or no source configured, now removes installed builds (Mix compiles them in that run). Only the _build of the run's own env and target is touched: under Nerves a run on host removes the host installs, never a device target's.
  • mix precompiled.status and mix precompiled.clean [--cache]; the cache is bounded (:cache_max_mb, 4096).
  • A batch the source did not deliver is reported as batch unavailable: <why> (before: the bare reason); a delivered batch that fails a check is still reported by the check alone (batch stale, batch signature invalid, ...).
  • Lower-case proxy variables, NO_PROXY, and a CA bundle (PRECOMPILED_CACERTS_PATH or HEX_CACERTS_PATH). The bundle replaces the OS store, as Hex's does: verification stays on and only its CAs are trusted.
  • PRECOMPILED_SOURCE opts in without a config change; it wins over app config, an explicit source: option wins over it. An empty value counts as unset.
  • Precompiled.Host.install/2 on Elixir 1.17 and 1.18 installs into the dir Mix.install/2 uses there (elixir-<vsn>-erts-<vsn>); before, its installs landed beside it and Mix compiled everything.
  • CI runs Elixir 1.17 to 1.20.
  • A batch issued more than 24 hours ago, more than 5 minutes in the future, valid for more than 30 days, or read against a local clock earlier than this library's build time, is refused; the run falls back to compiling, same as an expired or rolled-back batch.
  • Every run, Precompiled.Host.install/2 included, rechecks dependencies an earlier run installed (in a Mix project's _build or the Mix.install/2 project) against the current batch: one the batch no longer names, or one newly listed in :except, is removed and compiled from source; one whose manifest changed is matched again. When no batch is available, installed builds are left as they are and one warning line says how many were kept, not rechecked (report.json still lists each as fail, (installed build kept)). Builds installed by an earlier version carry no provenance file and are rechecked only after mix deps.clean --build or a clean _build.
  • Root rotation follows TUF: an intermediate root that expired after a newer one was adopted no longer ends the chain; only the root in use must be unexpired. Before, v1 -> v2 -> v3 fell back to v1 once v2 expired, which with the version floor failed every run.
  • The adopted root version is kept and never goes back (the error names the root_version file to delete to accept an older root); a trust-state file that exists but will not parse is warned about and treated as 0.
  • On a device, the Nerves system is read from /usr/lib/os-release; the loaded-application lookup never matched because systems are runtime: false, so devices only got pure-Elixir hits.
  • Extracted from nerves PR #1220: Precompiled.Host.run/1 is the build-time step nerves calls; Config, Match, Trust and Fetch moved unchanged apart from their HTTP client and output.
  • New: Precompiled.Host.install/2, Mix.install/2 with the precompiled step in front of it, for use on a device.
  • Config moved to config :precompiled, source: ...; the config :nerves, :precompiled form still works.
  • On a device, a dependency already compiled in the Mix.install/2 project is no candidate again: a second boot with a cached install re-places nothing and works offline. Builds this library installed there are rechecked against the batch, so such a boot fetches the root and batch.
  • Every HTTP request has an overall deadline (300 s by default) on top of the inactivity timer, so a server that keeps dripping data cannot hold a build or a boot open.
  • precompiled never installs a portal build of itself.
  • Renamed to precompiled; old config keys and NERVES_PRECOMPILED* variables still work for one release.
  • Reads manifest format 2: a version lists its builds, each with a target (pure_elixir or a Nerves system) and the toolchain it was made with. Format 1 manifests still work.
  • Matching rules: the running Elixir must have the build's minor and at least its patch (pre-releases compare as older), the OTP major must be equal, and the dependency's compile env (dep_env, prod unless the project overrides it with env:) must equal the build's. Among passing builds the closest Elixir patch wins, then pure_elixir, then the newest run.
  • report.json carries "format": 1.
  • Ships as a Mix archive (mix archive.install github TomHoenderdos/elixir_precompiled) so Livebook and scripts can call Precompiled.Host.install/2 in place of Mix.install/2; CI builds it.
  • The file cache moved from ~/.nerves/precompiled to the user cache dir (:filename.basedir(:user_cache, "precompiled"), ~/Library/Caches/precompiled on macOS, ~/.cache/precompiled on Linux). The old directory is not migrated, so the first run after upgrading downloads everything again; set PRECOMPILED_HOME=~/.nerves/precompiled to keep using the old one.