Skip to content

elixir-vibe/release_kit

Repository files navigation

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

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:

MIX_ENV=prod mix release_kit.artifact --out-dir _build/prod/artifacts

For a web service, record health-check metadata:

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:

_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:

# 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:

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:

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.

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:

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:

config :release_kit, :artifact,
  telemetry: [summary: true]

Example output:

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:

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:

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:

config :release_kit, :artifact,
  assets: [volt: [tailwind: true]]

For umbrellas or multiple web apps, build multiple profiles:

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:

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:

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:

MIX_ENV=prod mix release_kit.artifact --out-dir _build/prod/artifacts

Manifest shape

The manifest is an ETF-encoded %ReleaseKit.Manifest{} struct:

%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

--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.

About

Build OTP release tarballs with deployment-neutral artifact manifests

Topics

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages