Packages

phoenix_kit

1.7.151
1.7.209 1.7.208 1.7.207 1.7.206 1.7.205 1.7.204 1.7.203 1.7.202 1.7.201 1.7.200 1.7.199 1.7.198 1.7.197 1.7.196 1.7.194 1.7.193 1.7.192 1.7.191 1.7.190 1.7.189 1.7.187 1.7.186 1.7.185 1.7.184 1.7.183 1.7.182 1.7.181 1.7.180 1.7.179 1.7.178 1.7.177 1.7.176 1.7.175 1.7.174 1.7.173 1.7.172 1.7.171 1.7.170 1.7.169 1.7.168 1.7.167 1.7.166 1.7.165 1.7.164 1.7.162 1.7.161 1.7.160 1.7.159 1.7.157 1.7.156 1.7.155 1.7.154 1.7.153 1.7.152 1.7.151 1.7.150 1.7.149 1.7.146 1.7.145 1.7.144 1.7.143 1.7.138 1.7.133 1.7.132 1.7.131 1.7.130 1.7.128 1.7.126 1.7.125 1.7.121 1.7.120 1.7.119 1.7.118 1.7.117 1.7.116 1.7.115 1.7.114 1.7.113 1.7.112 1.7.111 1.7.110 1.7.109 1.7.108 1.7.107 1.7.106 1.7.105 1.7.104 1.7.103 1.7.102 1.7.101 1.7.100 1.7.99 1.7.98 1.7.97 1.7.96 1.7.95 1.7.94 1.7.93 1.7.92 1.7.91 1.7.90 1.7.89 1.7.88 1.7.87 1.7.86 1.7.85 1.7.84 1.7.83 1.7.82 1.7.81 1.7.80 1.7.79 1.7.78 1.7.77 1.7.76 1.7.75 1.7.74 1.7.71 1.7.70 1.7.69 1.7.66 1.7.65 1.7.64 1.7.63 1.7.62 1.7.61 1.7.59 1.7.58 1.7.57 1.7.56 1.7.55 1.7.54 1.7.53 1.7.52 1.7.51 1.7.49 1.7.44 1.7.43 1.7.42 1.7.41 1.7.39 1.7.38 1.7.37 1.7.36 1.7.34 1.7.33 1.7.31 1.7.30 1.7.29 1.7.28 1.7.27 1.7.26 1.7.25 1.7.24 1.7.23 1.7.22 1.7.21 1.7.20 1.7.19 1.7.18 1.7.17 1.7.16 1.7.15 1.7.14 1.7.13 1.7.12 1.7.11 1.7.10 1.7.9 1.7.8 1.7.7 1.7.6 1.7.5 1.7.4 1.7.3 1.7.2 1.7.1 1.7.0 1.6.20 1.6.19 1.6.18 1.6.17 1.6.16 1.6.15 1.6.14 1.6.13 1.6.12 1.6.11 1.6.10 1.6.9 1.6.8 1.6.7 1.6.6 1.6.5 1.6.4 1.6.3 1.5.2 1.5.1 1.5.0 1.4.9 1.4.8 1.4.7 1.4.6 1.4.5 1.4.4 1.4.3 1.4.2 1.4.1 1.4.0 1.3.2 1.3.1 1.3.0 1.2.10 1.2.9 1.2.8 1.2.7 1.2.5 1.2.4 1.2.2 1.2.1 1.2.0 1.1.0 1.0.0

A foundation for building Elixir Phoenix apps — SaaS, social networks, ERP systems, marketplaces, and more

Current section

Files

Jump to
phoenix_kit lib mix tasks phoenix_kit.release_check.ex
Raw

lib/mix/tasks/phoenix_kit.release_check.ex

defmodule Mix.Tasks.PhoenixKit.ReleaseCheck do
@moduledoc """
Asserts release-metadata consistency before publishing to Hex.
Catches the class of mistakes that `precommit`/`quality.ci` cannot see —
version/CHANGELOG/migration drift and unsafe git state — and exits non-zero
on any failure so it can gate `mix hex.publish`. It is the semantic core of
the `mix prerelease` alias and runs without a database.
## Usage
$ mix phoenix_kit.release_check
$ mix phoenix_kit.release_check --allow-dirty --allow-branch
## Options
* `--allow-dirty` — downgrade the "working tree clean" check to a warning
* `--allow-branch` — downgrade the "on main branch" check to a warning
## Checks Performed
1. **CHANGELOG Heading** — top `## X.Y.Z` entry matches `mix.exs` `@version`
(and is a real version, not "Unreleased").
2. **CHANGELOG Body** — that entry has at least one content line.
3. **Migration Version Sync** — `Migrations.Postgres.current_version/0`
equals the highest `vNNN.ex` migration file on disk.
4. **Git Tree Clean** — no uncommitted changes (`--allow-dirty` to warn).
5. **Git Branch** — on `main` (`--allow-branch` to warn).
6. **Tag Collision** — tag `v<version>` does not already exist (publish-
before-tag means a pre-existing tag signals a double release).
"""
use Mix.Task
@shortdoc "Asserts version/CHANGELOG/migration/git consistency before a Hex release"
@switches [allow_dirty: :boolean, allow_branch: :boolean]
@impl Mix.Task
def run(argv) do
{opts, _argv, _errors} = OptionParser.parse(argv, switches: @switches)
version = Mix.Project.config()[:version]
header("PhoenixKit Release Check (v#{version})")
results = [
run_check("CHANGELOG Heading", fn -> check_changelog_heading(version) end),
run_check("CHANGELOG Body", fn -> check_changelog_body(version) end),
run_check("Migration Version Sync", fn -> check_migration_sync() end),
run_check("Git Tree Clean", fn -> check_git_clean(opts) end),
run_check("Git Branch", fn -> check_git_branch(opts) end),
run_check("Tag Collision", fn -> check_tag_collision(version) end)
]
IO.puts("")
summary(results)
end
# ── Check implementations (return {:pass | :warn | :fail, detail}) ──
defp check_changelog_heading(version) do
case top_changelog_version() do
{:ok, ^version} ->
{:pass, "Top CHANGELOG entry is #{version}"}
{:ok, other} ->
{:fail,
"mix.exs is #{version} but top CHANGELOG entry is #{other}. " <>
"Add a `## #{version} - <date>` section before publishing."}
:unreleased ->
{:fail, "Top CHANGELOG entry is still \"Unreleased\" — stamp it with #{version}."}
:error ->
{:fail, "Could not find a `## X.Y.Z` heading in CHANGELOG.md"}
end
end
defp check_changelog_body(version) do
case changelog_body(version) do
{:ok, body} ->
if String.trim(body) == "" do
{:fail, "The #{version} CHANGELOG section has no content."}
else
lines = body |> String.split("\n") |> Enum.count(&(String.trim(&1) != ""))
{:pass, "#{lines} non-blank line(s)"}
end
:error ->
{:fail, "No CHANGELOG section found for #{version}."}
end
end
defp check_migration_sync do
module = PhoenixKit.Migrations.Postgres
if Code.ensure_loaded?(module) and function_exported?(module, :current_version, 0) do
code_version = module.current_version()
case highest_migration_file() do
{:ok, file_version} when file_version == code_version ->
{:pass, "current_version/0 == v#{file_version}.ex"}
{:ok, file_version} ->
{:fail,
"Migrations.Postgres.current_version/0 is #{code_version} but the highest " <>
"migration file is v#{file_version}.ex — register the new version (or " <>
"remove the stray file)."}
:error ->
{:fail, "No vNNN.ex migration files found."}
end
else
{:warn, "PhoenixKit.Migrations.Postgres.current_version/0 not available — skipped."}
end
end
defp check_git_clean(opts) do
case git(["status", "--porcelain"]) do
{:ok, ""} ->
{:pass, "No uncommitted changes"}
{:ok, out} ->
count = out |> String.split("\n", trim: true) |> length()
detail = "#{count} uncommitted change(s). Commit or stash before publishing."
if opts[:allow_dirty], do: {:warn, detail}, else: {:fail, detail}
:error ->
{:warn, "git unavailable — skipped."}
end
end
defp check_git_branch(opts) do
case git(["rev-parse", "--abbrev-ref", "HEAD"]) do
{:ok, "main"} ->
{:pass, "On main"}
{:ok, branch} ->
detail = "On #{branch}, not main. Releases are cut from main."
if opts[:allow_branch], do: {:warn, detail}, else: {:fail, detail}
:error ->
{:warn, "git unavailable — skipped."}
end
end
defp check_tag_collision(version) do
tag = "v#{version}"
case git(["tag", "-l", tag]) do
{:ok, ""} ->
{:pass, "#{tag} does not exist yet"}
{:ok, _} ->
{:fail, "Tag #{tag} already exists — this version looks already released."}
:error ->
{:warn, "git unavailable — skipped."}
end
end
# ── CHANGELOG parsing ──────────────────────────────────────────────
# Matches "## 1.7.138 - 2026-06-09", "## [1.7.138]", "## v1.7.138", etc.
@heading_re ~r/^##\s+\[?v?(?<ver>\d+\.\d+\.\d+)/
defp top_changelog_version do
case File.read(changelog_path()) do
{:ok, contents} ->
heading = first_heading(contents) || ""
cond do
Regex.match?(~r/^##\s+\[?unreleased/im, heading) ->
:unreleased
match = Regex.named_captures(@heading_re, heading) ->
{:ok, match["ver"]}
true ->
:error
end
_ ->
:error
end
end
# The first line starting with "## " — the latest entry.
defp first_heading(contents) do
contents
|> String.split("\n")
|> Enum.find(&String.starts_with?(&1, "## "))
end
defp changelog_body(version) do
case File.read(changelog_path()) do
{:ok, contents} ->
lines = String.split(contents, "\n")
target = ~r/^##\s+\[?v?#{Regex.escape(version)}\b/
case Enum.find_index(lines, &Regex.match?(target, &1)) do
nil ->
:error
idx ->
body =
lines
|> Enum.drop(idx + 1)
|> Enum.take_while(&(not String.starts_with?(&1, "## ")))
|> Enum.join("\n")
{:ok, body}
end
_ ->
:error
end
end
defp changelog_path, do: Path.join(File.cwd!(), "CHANGELOG.md")
# ── Migration file discovery ───────────────────────────────────────
defp highest_migration_file do
"lib/phoenix_kit/migrations/postgres/v*.ex"
|> Path.wildcard()
|> Enum.map(&extract_migration_number/1)
|> Enum.reject(&is_nil/1)
|> case do
[] -> :error
nums -> {:ok, Enum.max(nums)}
end
end
defp extract_migration_number(path) do
case Regex.run(~r/v(\d+)\.ex$/, Path.basename(path)) do
[_, num] -> String.to_integer(num)
_ -> nil
end
end
# ── Git ────────────────────────────────────────────────────────────
defp git(args) do
case System.cmd("git", args, stderr_to_stdout: true) do
{out, 0} -> {:ok, String.trim(out)}
_ -> :error
end
rescue
_ -> :error
end
# ── Output (mirrors mix phoenix_kit.doctor) ────────────────────────
defp header(title) do
IO.puts("\n#{IO.ANSI.bright()}#{IO.ANSI.cyan()}#{title}#{IO.ANSI.reset()}")
IO.puts(String.duplicate("─", 60))
end
defp run_check(name, fun) do
result =
try do
fun.()
rescue
e -> {:fail, "Exception: #{Exception.message(e)}"}
end
display_check(name, result)
{name, result}
end
defp display_check(name, {:pass, detail}) do
IO.puts(" #{IO.ANSI.green()}PASS#{IO.ANSI.reset()} #{name}")
if detail, do: IO.puts(" #{IO.ANSI.faint()}#{detail}#{IO.ANSI.reset()}")
end
defp display_check(name, {:warn, detail}) do
IO.puts(" #{IO.ANSI.yellow()}WARN#{IO.ANSI.reset()} #{name}")
if detail, do: IO.puts(" #{IO.ANSI.yellow()}#{detail}#{IO.ANSI.reset()}")
end
defp display_check(name, {:fail, detail}) do
IO.puts(" #{IO.ANSI.red()}FAIL#{IO.ANSI.reset()} #{name}")
if detail, do: IO.puts(" #{IO.ANSI.red()}#{detail}#{IO.ANSI.reset()}")
end
defp summary(results) do
pass = Enum.count(results, fn {_, {status, _}} -> status == :pass end)
warn = Enum.count(results, fn {_, {status, _}} -> status == :warn end)
fail = Enum.count(results, fn {_, {status, _}} -> status == :fail end)
total = length(results)
IO.puts(
"#{IO.ANSI.bright()}Summary#{IO.ANSI.reset()}: #{pass}/#{total} passed, #{warn} warnings, #{fail} failures"
)
if fail > 0 do
IO.puts(
"#{IO.ANSI.red()}Release blocked — fix the FAIL items above before publishing.#{IO.ANSI.reset()}"
)
exit({:shutdown, 1})
end
end
end