Releaser

Monorepo versioning, changelog, and Hex publishing for Elixir poncho/umbrella projects.

The only Hex package that handles versioning + publishing for Elixir monorepos with internal dependencies. Think Rush (Node.js) but for Elixir.

Two workflows, both first-class

Releaser supports two equally valid ways to drive releases:

Pick whichever fits your team — cascade, pre-releases, publishing and changelogs work the same in both.

Features

FeatureReleaserVersioceGitHub Tag Bump
SemVer bump (patch/minor/major)YesYesYes
Pre-release tags (dev, beta, rc)YesPartialPartial
Same tag increments (dev.1 → dev.2)YesNoNo
Tag change keeps base (dev → beta)YesNoNo
Release (strip tag)YesNoNo
Cascade bumps to dependentsYesNoNo
Dependency graph (visual)YesNoNo
Topological Hex publishingYesNoNo
Release status (local vs Hex)YesNoNo
Changelog from git commitsYesYesNo
Git hooks (commit + tag)YesYesNo
Multi-file version syncYesYesNo
Build metadataYesYesNo
Explicit version setYesYesYes
Monorepo / poncho supportYesNoNo

Installation

Add to your rootmix.exs:

defp deps do
[
{:releaser, "~> 0.1", only: :dev, runtime: false}
]
end

Quick start

# List all apps and versions
mix releaser.bump --list
# See dependency graph
mix releaser.graph
# Bump a package
mix releaser.bump my_app patch
# Check what needs publishing
mix releaser.status
# Publish everything to Hex
mix releaser.publish --dry-run

Versioning

Basic bump

mix releaser.bump my_app patch # 4.0.17 → 4.0.18
mix releaser.bump my_app minor # 4.0.17 → 4.1.0
mix releaser.bump my_app major # 4.0.17 → 5.0.0

Explicit version

mix releaser.bump my_app 2.0.0 # set to exact version

Build metadata

mix releaser.bump my_app patch --build 20260420 # 4.0.18+20260420

Bump all apps

mix releaser.bump --all patch # bump every app

Pre-release tags

Full lifecycle support for pre-release versions following SemVer 2.0.

Lifecycle

┌─────────────────────────────────────────────────────────────────┐
Version lifecycle
├─────────────────────────────────────────────────────────────────┤
4.0.17 (current stable)
├── releaser.bump my_app patch --tag dev
4.0.18-dev.1 bump base + add tag
├── releaser.bump my_app patch --tag dev
4.0.18-dev.2 same tag = increment only
├── releaser.bump my_app patch --tag dev
4.0.18-dev.3 another dev fix
├── releaser.bump my_app patch --tag beta
4.0.18-beta.1 tag change = keeps base
├── releaser.bump my_app patch --tag beta
4.0.18-beta.2 beta fix
├── releaser.bump my_app patch --tag rc
4.0.18-rc.1 release candidate
├── releaser.bump my_app release
4.0.18 strip tag = stable release
4.0.18 (new stable)
└─────────────────────────────────────────────────────────────────┘

Tag rules

SituationCommandResult
Clean 4.0.17patch --tag dev4.0.18-dev.1 (bump + tag)
Same tag -dev.2patch --tag dev4.0.18-dev.3 (increment)
Different tag -dev.3patch --tag beta4.0.18-beta.1 (keep base)
Any tag -beta.2release4.0.18 (strip tag)

Available tags

TagUsage
devActive development, may break things
alphaFirst internal test version
betaFeature-complete, may have bugs
rcRelease candidate, production-ready except bugs

SemVer ordering: alpha < beta < dev < rc < stable

Cascade bumps

When you bump a package, all packages that depend on it automatically receive a patch bump:

mix releaser.bump clir_openssl minor --dry-run
Version changes:
clir_openssl 0.0.17 0.1.0 (direct)
cfdi_csd 4.0.16 4.0.17 (cascade)
sat_auth 1.0.1 1.0.2 (cascade)
cfdi_xml 4.0.18 4.0.19 (cascade)
cfdi_cancelacion 0.0.1 0.0.2 (cascade)
cfdi_descarga 0.0.1 0.0.2 (cascade)

Disable with --no-cascade.

Dependency graph

mix releaser.graph
╔══════════════════════════════════════════════════╗
Dependency Graph
╚══════════════════════════════════════════════════╝
┌── Level 0 (no internal deps) ──┐
cfdi_catalogos v4.0.16
clir_openssl v0.0.17
saxon_he v12.5.2
... (28 apps)
┌── Level 1 ──┐
cfdi_csd v4.0.16
└─ depends on: clir_openssl
┌── Level 2 ──┐
cfdi_xml v4.0.18
└─ depends on: cfdi_csd[1][1][0], cfdi_transform[1][1][0], cfdi_complementos
sat_auth v1.0.1
└─ depends on: cfdi_csd[1][1][0]
┌── Level 3 ──┐
cfdi_cancelacion v0.0.1
└─ depends on: sat_auth[2][1][1]
└── end ──┘

Reading the dep annotations

Each project-internal dep is rendered as <name>[level][count][deep]:

BracketMeaning
[level]Topological level of that dep (0 = leaf, no project deps). Colored per level — cycle: 0→cyan, 1→green, 2→yellow, 3→magenta, 4→red, 5→blue, then rem(level, 6) repeats.
[count]Number of direct project-internal deps that the dep itself has.
[deep]Of those [count] deps, how many themselves have at least one project-internal dep. Shallow (one level of look-ahead), not recursive.

Clean mode: when all three values are zero (a true leaf with no project deps), the dep prints as a bare name with no brackets — see cfdi_complementos in the example above.

Why this matters: at a glance you know if editing a dep cascades. cfdi_csd[1][1][0] means level 1, has 1 project dep, and that dep is a leaf — safe to edit in isolation. sat_auth[2][1][1] means level 2, has 1 project dep, and that dep itself has more project deps — editing reaches deeper into the graph.

Only the levels view is annotated. The dependents-tree form (mix releaser.graph <app>) is unchanged.

Show dependents of a specific app:

mix releaser.graph cfdi_csd
Dependents of cfdi_csd:
└─ sat_auth
└─ cfdi_cancelacion
└─ cfdi_descarga
└─ cfdi_xml

Publishing to Hex

Publishes all packages in topological order (dependencies first). Automatically replaces path: deps with Hex versions and restores after publishing.

# See the publish plan
mix releaser.publish --dry-run
# Publish everything
mix releaser.publish
# Bump + publish
mix releaser.publish --bump patch
# Only specific apps (+ their deps automatically)
mix releaser.publish --only cfdi_xml
# Publish to a Hex organization
mix releaser.publish --org myorg

What happens internally

For each package (in dependency order):

  1. Backup mix.exs
  2. Bump version (if --bump)
  3. Replace {:dep, path: "..."} or {:dep, in_umbrella: true}{:dep, "~> X.Y"}
  4. Inject package/0 if missing
  5. mix hex.publish --yes
  6. Restore original mix.exs (always, even on failure)

Release status

Compare local versions against what's published on Hex:

mix releaser.status
Package Local Hex Status
cfdi_xml 4.0.19 4.0.18 ahead
cfdi_csd 4.0.16 4.0.16 published
cfdi_complementos 4.0.18-dev.1 4.0.17 pre-release
my_new_app 0.1.0 unpublished
2 package(s) need publishing.

Changelog

Generate changelogs from git commits using conventional commit prefixes:

# Generate for all apps
mix releaser.changelog
# Generate for one app
mix releaser.changelog cfdi_xml
# Preview without writing
mix releaser.changelog --dry-run
# From a specific ref
mix releaser.changelog --from v4.0.17

Commits should follow conventional commits:

feat: add CartaPorte 3.1 support
fix: correct XML encoding for special characters
refactor: extract version parsing to struct
breaking: remove deprecated cer/key modules

Output follows Keep a Changelog format.

Hooks

Pre and post-bump hooks for custom automation.

Built-in hooks

HookTypeWhat it does
Releaser.Hooks.GitTagpostgit add + git commit + git tag
Releaser.Hooks.ChangelogHookpostGenerate/update CHANGELOG.md

Custom hooks

defmodule MyProject.NotifySlack do
@behaviour Releaser.Hooks.PostHook
@impl true
def run(%{app: app, new_version: version, changes: changes}) do
# Send Slack notification...
:ok
end
end

Disable hooks

mix releaser.bump my_app patch --no-hooks

Configuration

All config lives in your root mix.exs under the :releaser key:

def project do
[
app: :my_project,
version: "0.1.0",
deps: deps(),
releaser: [
# Root directory containing apps (default: "apps")
apps_root: "apps",
# Additional files to sync version in
version_files: [
{"README.md", ~r/@version (\S+)/},
{"Dockerfile", ~r/ARG VERSION=(\S+)/}
],
# Changelog configuration
changelog: [
path: "CHANGELOG.md",
anchors: %{
"feat" => "Added",
"fix" => "Fixed",
"refactor" => "Changed",
"docs" => "Documentation",
"perf" => "Performance",
"breaking" => "Breaking Changes"
}
],
# Pre/post hooks
hooks: [
pre: [],
post: [Releaser.Hooks.GitTag, Releaser.Hooks.ChangelogHook]
],
# Hex publishing defaults
publisher: [
org: nil,
package_defaults: [
licenses: ["MIT"],
links: %{"GitHub" => "https://github.com/me/project"},
files: ~w(lib mix.exs README.md LICENSE)
]
]
]
]
end

All commands

# Versioning
mix releaser.bump <app> <major|minor|patch> # bump with cascade
mix releaser.bump <app> <major|minor|patch> --tag dev
mix releaser.bump <app> release # strip pre-release
mix releaser.bump <app> 2.0.0 # explicit version
mix releaser.bump --list # list versions
mix releaser.bump --all patch # bump all apps
# Graph
mix releaser.graph # full dependency graph
mix releaser.graph <app> # dependents of app
# Publishing
mix releaser.publish # publish all to Hex
mix releaser.publish --dry-run # show plan
mix releaser.publish --only app1,app2 # only these + deps
mix releaser.publish --bump patch # bump before publish
mix releaser.publish --org myorg # Hex organization
# Status
mix releaser.status # local vs Hex comparison
# Changelog
mix releaser.changelog # generate for all
mix releaser.changelog <app> # generate for one
mix releaser.changelog --from v1.0.0 # from specific ref
# Global options (all commands)
--dry-run # preview without changes
--no-hooks # skip pre/post hooks
# 1. Start development
mix releaser.bump my_app patch --tag dev
# 2. Iterate
mix releaser.bump my_app patch --tag dev # dev.1 → dev.2
# 3. Promote to beta
mix releaser.bump my_app patch --tag beta # dev.3 → beta.1
# 4. Release candidate
mix releaser.bump my_app patch --tag rc
# 5. Final release
mix releaser.bump my_app release # rc.1 → stable
# 6. Check what needs publishing
mix releaser.status
# 7. Publish
mix releaser.publish

License

MIT