# ReleaseKit
ReleaseKit builds deployment-neutral artifacts from Mix OTP releases.
It is for Elixir applications that want a repeatable build product without tying
that build product to a deploy tool. ReleaseKit produces ordinary files:
- an OTP release tarball;
- an ETF manifest describing the tarball, runtime command, runtime environment
hints, and optional HTTP health check.
Deployment tools such as HostKit can consume the manifest, but ReleaseKit does
not know about systemd, users, Caddy, hosts, or filesystem layouts.
## Why use ReleaseKit?
A deploy pipeline usually needs two separate responsibilities:
1. **Build an application artifact** from source.
2. **Install and supervise that artifact** on a host.
ReleaseKit handles only the first responsibility. It gives downstream deploy
systems a small, stable manifest instead of asking every application to invent a
custom release tarball format or wrapper Mix task.
## Installation
```elixir
def deps do
[
{:release_kit, "~> 0.3.0", only: [:dev, :prod], runtime: false}
]
end
```
## Quick start
Build a production artifact for the default Mix release:
```sh
MIX_ENV=prod mix release_kit.artifact --out-dir _build/prod/artifacts
```
For a web service, record health-check metadata:
```sh
MIX_ENV=prod mix release_kit.artifact \
--out-dir _build/prod/artifacts \
--port 4000 \
--health-path /
```
The output names are stable for deployment tooling and include a checksum sidecar:
```text
_build/prod/artifacts/my_app-20260620-abcdef0.tar.gz
_build/prod/artifacts/my_app-20260620-abcdef0.tar.gz.sha256
_build/prod/artifacts/my_app.etf
```
## Configuration
You can put artifact defaults in application config and keep the command short:
```elixir
# config/config.exs
config :release_kit, :artifact,
port: 4000,
health_path: "/",
env_clear: %{
"MY_APP_WEB" => "true",
"MY_APP_PORT" => "4000",
"RELEASE_DISTRIBUTION" => "none"
}
```
Then build with:
```sh
MIX_ENV=prod mix release_kit.artifact --out-dir _build/prod/artifacts
```
CLI flags override config values when provided.
### Compile policy
ReleaseKit owns the compile phase before assembling the Mix release. Mix task
flags stay behind configuration so artifact builds can use a stable incremental
policy:
```elixir
config :release_kit, :artifact,
compile: [
# default: true; set false only when an earlier pipeline step already ran
# MIX_ENV=prod mix compile for the same checkout and build path
enabled: true,
# default: false; avoids full recompilation when an otherwise reusable
# build directory is reached through a different absolute path
check_cwd: false,
# optional diagnostics for investigating slow compiles
profile: nil
]
```
Other compile policy options default to Mix's safe behavior and can be disabled
when a pipeline has already performed those checks: `:optional_deps`,
`:deps_check`, `:archives_check`, `:elixir_version_check`,
`:protocol_consolidation`, `:validate_compile_env`, and `:listeners`.
### Package cache
ReleaseKit caches package fingerprints by default. If the release directory,
manifest-relevant options, build metadata, and asset metadata are unchanged, a
subsequent artifact build reuses the existing tarball, checksum sidecar, and
manifest instead of compressing the release again.
```elixir
config :release_kit, :artifact,
package: [cache: true, compression: :gzip]
```
Set `cache: false` to force the tarball and manifest to be rewritten on every
artifact build. When release content is unchanged but the artifact version or
name changes, ReleaseKit reuses package bytes by copying the previous tarball by
default. Use `reuse: false` to disable this or `reuse: :hardlink` to prefer a
hardlink with copy fallback.
ReleaseKit's package backend stays BEAM-native. For local or internal pipelines
where package size matters less than packaging speed, use an uncompressed
tarball:
```elixir
config :release_kit, :artifact,
package: [compression: :none]
```
This writes `.tar` artifacts instead of `.tar.gz`.
### Build telemetry
ReleaseKit emits `:telemetry` spans for artifact builds. Each phase emits
`[:release_kit, :artifact, phase, :start]` and `:stop` or `:exception` events,
where `phase` is one of `:build`, `:compile`, `:before_release`, `:assets`,
`:release`, `:after_release`, and `:package`. OpenTelemetry users can bridge
these telemetry events into spans or metrics in their build environment.
For a local timing summary, enable:
```elixir
config :release_kit, :artifact,
telemetry: [summary: true]
```
Example output:
```text
ReleaseKit timings:
build: 5572ms
compile: 84ms
assets: 56ms
release: 404ms
package: 5032ms cache_hit?=false
```
## Phoenix and Volt assets
Phoenix applications that use Volt can configure a first-class asset preset.
ReleaseKit installs locked npm packages, runs `mix volt.build`, records asset
metadata in the manifest, and then packages the OTP release:
```elixir
config :release_kit, :artifact,
port: 4000,
health_path: "/",
assets: [
volt: [profile: :my_app_web, tailwind: true, root: "assets", production: true, frozen: true]
]
```
For faster local artifact rebuilds, enable Volt incremental policy. It keeps
Volt's output directory and skips npm install by default, while still allowing
explicit overrides:
```elixir
config :release_kit, :artifact,
assets: [
volt: [profile: :my_app_web, incremental: true]
]
```
Use `install: true` or `clean: true` when a build needs those phases even under
incremental policy.
For a flat Volt config, omit the profile:
```elixir
config :release_kit, :artifact,
assets: [volt: [tailwind: true]]
```
For umbrellas or multiple web apps, build multiple profiles:
```elixir
config :release_kit, :artifact,
assets: [
volt: [
profiles: [
[profile: :public_web, root: "apps/public_web/assets", tailwind: true],
[profile: :admin_web, root: "apps/admin_web/assets", tailwind: true]
]
]
]
```
## Lifecycle steps
Some applications need generated files inside the OTP release, such as frontend
assets or generated config. Configure lifecycle steps and still use the same
ReleaseKit task:
```elixir
config :release_kit, :artifact,
steps: [
before_compile: [],
before_release: [{MyApp.ReleaseStep, mode: :prod}],
after_release: [],
before_package: [],
after_package: []
]
```
A lifecycle step is any module that implements the `ReleaseKit.Step` behaviour:
```elixir
defmodule MyApp.ReleaseStep do
@behaviour ReleaseKit.Step
@impl true
def run(opts) do
# build generated files before mix release runs
:ok
end
end
```
`ReleaseKit.Step.Volt` is compiled only when both optional dependencies `:volt`
and `:npm` are available in the consuming project. It installs locked npm
packages from the configured asset root, removes Volt's output directory, and
runs `mix volt.build` before the OTP release is assembled. It supports Volt
named profiles, `--tailwind`, source map mode, public directory, asset URL
prefix, and the production build toggles exposed by Volt's Mix task.
See `examples/vanilla` for a minimal Phoenix/Volt app that builds with plain:
```sh
MIX_ENV=prod mix release_kit.artifact --out-dir _build/prod/artifacts
```
## Manifest shape
The manifest is an ETF-encoded `%ReleaseKit.Manifest{}` struct:
```elixir
%ReleaseKit.Manifest{
tool: "release_kit",
format: :beam_release_artifact,
format_version: 2,
app: "my_app",
release: "my_app",
version: "20260620-abcdef0",
mix_env: "prod",
tarball: "/absolute/path/to/my_app-20260620-abcdef0.tar.gz",
artifact: %ReleaseKit.ArtifactInfo{
path: "/absolute/path/to/my_app-20260620-abcdef0.tar.gz",
size: 1_234_567,
compression: :gzip,
checksum: %{
algorithm: :sha256,
value: "...",
sidecar: "/absolute/path/to/my_app-20260620-abcdef0.tar.gz.sha256"
}
},
target: %ReleaseKit.Target{os: "linux", arch: "x86_64", libc: "glibc"},
build: %ReleaseKit.BuildInfo{
release_kit_version: "0.3.0",
elixir_version: "1.20.0",
otp_version: "28",
git_revision: "abcdef0",
git_dirty?: false
},
assets: [
%ReleaseKit.AssetInfo{
tool: :volt,
profile: :my_app_web,
outdir: "priv/static/assets",
manifest: "priv/static/assets/manifest.json",
asset_url_prefix: "/assets",
tailwind?: true
}
],
runtime: %{command: ["bin/my_app", "start"]},
env: %{
clear: %{"MY_APP_WEB" => "true"},
secret: []
},
health_check: %{
path: "/",
port: 4000,
url: "http://127.0.0.1:4000/"
}
}
```
## Task options
```text
--out-dir PATH Directory for the tarball and manifest
--release NAME Mix release name; defaults to the app name
--version VERSION Artifact version; defaults to YYYYMMDD-gitsha
--port PORT HTTP port recorded in health-check metadata
--health-path PATH HTTP path recorded in health-check metadata
--skip-release Package an existing _build/.../rel release directory
--target-os OS Override the manifest target OS
--target-arch ARCH Override the manifest target architecture
--target-libc LIBC Override the manifest target libc
--target-suffix Include the target in artifact and manifest filenames
```
## Deployment tool contract
ReleaseKit intentionally stops after producing files. A deployment system should:
1. read the ETF manifest with `:erlang.binary_to_term/1`;
2. verify `artifact.checksum` against the tarball or `.sha256` sidecar;
3. unpack the tarball into the deployment-specific release directory;
4. materialize `env.clear` and resolve secret names from `env.secret`;
5. start `runtime.command` from the unpacked release root;
6. supervise the release with the host's process manager;
7. poll `health_check.url` when present before promoting traffic.
Manifest fields are additive. Consumers should ignore unknown keys whose
`format_version` they support.