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: * `main` / `master` — stable (no suffix) * `develop` — `-dev` * `release/*` — `-rc` * `hotfix/*` — `-rc` * custom mappings via `config :rodar_release, :branch_pre, %{...}` ## 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 ## Post-merge promotion After merging a development branch into `main`, the version will have a pre-release suffix (e.g. `1.5.1-dev.3`). Use `mix rodar_release.merge` to promote it to a stable release: mix rodar_release.merge # 1.5.1-dev.3 -> 1.5.1 mix rodar_release.merge minor # 1.5.1-dev.3 -> 1.6.0 mix rodar_release.merge major # 1.5.1-dev.3 -> 2.0.0 The regular `patch/minor/major` tasks will detect a leftover pre-release suffix on a stable branch and point you to `merge` instead. 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 @doc """ Promotes a pre-release version to stable. Without a segment, strips the suffix and returns the base version. With a segment, bumps the base version by that segment — useful when the merge represents a higher-level change than the original pre-release. ## Examples iex> RodarRelease.promote("1.5.1-dev.3") "1.5.1" iex> RodarRelease.promote("1.5.1-dev.3", :minor) "1.6.0" iex> RodarRelease.promote("1.5.1-dev.3", :major) "2.0.0" """ def promote(version, segment \\ nil) def promote(version, nil) do unless has_pre?(version) do raise ArgumentError, "promote/2 expects a pre-release version, got #{inspect(version)}" end {base, _label, _counter} = parse_version(version) base end def promote(version, segment) when segment in [:patch, :minor, :major] do unless has_pre?(version) do raise ArgumentError, "promote/2 expects a pre-release version, got #{inspect(version)}" end {base, _label, _counter} = parse_version(version) bump(base, segment) 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 @doc """ Returns whether a version string contains a pre-release suffix. ## Examples iex> RodarRelease.has_pre?("1.0.0-rc.1") true iex> RodarRelease.has_pre?("1.0.0") false """ def 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