Current section
Files
Jump to
Current section
Files
lib/rodar_release.ex
defmodule RodarRelease do
@moduledoc """
Version management and release utilities.
The version lives directly in `mix.exs` as the single source of truth.
At release time, `mix rodar_release.patch|minor|major` bumps it, updates
CHANGELOG.md (including comparison links), commits, and tags.
## Semantic Versioning
Versions follow [Semantic Versioning](https://semver.org) (`MAJOR.MINOR.PATCH`):
* `:patch` — backward-compatible bug fixes
* `:minor` — new functionality, backward-compatible
* `:major` — breaking changes
Pre-release suffixes (`-rc.1`, `-beta.1`, `-dev.1`) are automatically inferred
from the current git branch. `develop` → `-dev`, `release/*` → `-rc`, etc.
See `RodarRelease.Helpers.resolve_pre/2` for the full mapping.
## Releasing
mix rodar_release.patch # bug fix (on main)
mix rodar_release.minor # new feature (on main)
mix rodar_release.major # breaking change (on main)
mix rodar_release.minor # on develop: 1.1.0 -> 1.2.0-dev.1
mix rodar_release.patch # on develop: 1.2.0-dev.1 -> 1.2.0-dev.2
mix rodar_release.patch # on release/*: 1.2.0-dev.3 -> 1.2.0-rc.1
mix rodar_release.patch --dry-run # preview
If `CHANGELOG.md` has no entries under `[Unreleased]`, the release task will
offer to generate one using an AI CLI based on the git history since the
last tag. You are prompted to confirm before anything is written.
The AI CLI defaults to Claude Code (`{"claude", ["-p"]}`) and can be
configured via:
config :rodar_release, :ai_cmd, {"codex", ["e"]}
## Installation
If your project uses [Igniter](https://hex.pm/packages/igniter):
mix igniter.install rodar_release
Otherwise, create a `CHANGELOG.md` with the
[Keep a Changelog](https://keepachangelog.com) structure and optionally
configure `:ai_cmd` in `config/config.exs`:
config :rodar_release, :ai_cmd, {"gemini", ["-p"]}
Custom branch-to-suffix mappings can also be configured:
config :rodar_release, :branch_pre, %{
"staging" => "rc",
~r/^preview\\// => "beta"
}
See the [README](https://github.com/rodar-project/rodar_release#without-igniter)
for full manual setup instructions.
## Rollback & Amend
mix rodar_release.rollback # undo last release (soft reset)
mix rodar_release.rollback --hard # undo and discard changes
mix rodar_release.amend # fold changes into release commit
Run `mix help rodar_release` for the full list of commands.
"""
@version_pattern ~r/version:\s*"(\d+\.\d+\.\d+(?:-[a-zA-Z0-9]+\.\d+)?)"/
@doc """
Reads the current version from a mix.exs file.
## Options
* `:file` - path to mix.exs (default: `"mix.exs"`)
## Examples
iex> File.write!("test_mix.exs", ~s|version: "1.2.3"|)
iex> RodarRelease.read_version(file: "test_mix.exs")
"1.2.3"
iex> File.rm!("test_mix.exs")
"""
def read_version(opts \\ []) do
file = Keyword.get(opts, :file, "mix.exs")
content =
case File.read(file) do
{:ok, c} -> c
{:error, _} -> raise "Could not read #{file}"
end
case Regex.run(@version_pattern, content) do
[_, version] ->
version
nil ->
raise "Could not find version in #{file}. Expected `version: \"x.y.z\"` or `version: \"x.y.z-pre.n\"`."
end
end
@doc """
Writes a new version into a mix.exs file, replacing the existing version string.
## Options
* `:file` - path to mix.exs (default: `"mix.exs"`)
"""
def write_version(new_version, opts \\ []) do
file = Keyword.get(opts, :file, "mix.exs")
content =
case File.read(file) do
{:ok, c} -> c
{:error, _} -> raise "Could not read #{file}"
end
updated =
Regex.replace(@version_pattern, content, ~s|version: "#{new_version}"|, global: false)
File.write!(file, updated)
end
@doc """
Bumps a version string by the given segment, with optional pre-release label.
## Version transitions
* **Stable + no pre** — normal bump: `bump("1.0.8", :patch)` → `"1.0.9"`
* **Stable + `--pre`** — bump and start pre-release: `bump("1.1.0", :minor, "rc")` → `"1.2.0-rc.1"`
* **Pre-release + same label** — increment counter: `bump("1.2.0-rc.1", :patch, "rc")` → `"1.2.0-rc.2"`
* **Pre-release + different label** — switch label: `bump("1.2.0-dev.1", :patch, "rc")` → `"1.2.0-rc.1"`
* **Pre-release + no pre** — promote to stable: `bump("1.2.0-rc.2", :patch)` → `"1.2.0"`
## Examples
iex> RodarRelease.bump("1.0.8", :patch)
"1.0.9"
iex> RodarRelease.bump("1.0.8", :minor)
"1.1.0"
iex> RodarRelease.bump("1.0.8", :major)
"2.0.0"
iex> RodarRelease.bump("1.1.0", :minor, "rc")
"1.2.0-rc.1"
iex> RodarRelease.bump("1.2.0-rc.1", :patch, "rc")
"1.2.0-rc.2"
iex> RodarRelease.bump("1.2.0-rc.2", :patch)
"1.2.0"
"""
def bump(version, segment, pre \\ nil)
def bump(version, segment, nil) when segment in [:patch, :minor, :major] do
{base, _label, _counter} = parse_version(version)
[major, minor, patch] =
base
|> String.split(".")
|> Enum.map(&String.to_integer/1)
if has_pre?(version) do
# Promote to stable: strip suffix
base
else
case segment do
:patch -> "#{major}.#{minor}.#{patch + 1}"
:minor -> "#{major}.#{minor + 1}.0"
:major -> "#{major + 1}.0.0"
end
end
end
def bump(version, segment, pre) when segment in [:patch, :minor, :major] do
validate_pre!(pre)
{base, current_label, counter} = parse_version(version)
if current_label == pre do
# Same label: increment counter
"#{base}-#{pre}.#{(counter || 0) + 1}"
else
if has_pre?(version) do
# Different label: switch label, reset counter
"#{base}-#{pre}.1"
else
# Stable version: bump segment, start pre-release
bumped = bump(version, segment, nil)
"#{bumped}-#{pre}.1"
end
end
end
defp parse_version(version) do
case String.split(version, "-", parts: 2) do
[base] ->
{base, nil, nil}
[base, pre_release] ->
case String.split(pre_release, ".") do
[label, counter] -> {base, label, String.to_integer(counter)}
[label] -> {base, label, nil}
end
end
end
defp has_pre?(version), do: String.contains?(version, "-")
defp validate_pre!(pre) do
unless Regex.match?(~r/^[a-zA-Z][a-zA-Z0-9]*$/, pre) do
raise ArgumentError,
"Invalid pre-release label #{inspect(pre)}. Must be alphanumeric, starting with a letter."
end
end
end