From 5e2856d276c36f5706e97307b867993cdd73f703 Mon Sep 17 00:00:00 2001 From: James Tippett Date: Sat, 20 Jun 2026 12:03:02 +0700 Subject: [PATCH] Adopt release assistant + bump CI checkout to Node 24 - scripts/release.exs (interactive version bump/tag) + justfile (just release/test/fmt) - README Releasing section leads with the release assistant - actions/checkout@v5 -> v7 (cache/upload/download already current) --- .github/workflows/ci.yml | 2 +- .github/workflows/release.yml | 4 +- README.md | 41 ++++---- justfile | 14 +++ scripts/release.exs | 172 ++++++++++++++++++++++++++++++++++ 5 files changed, 211 insertions(+), 22 deletions(-) create mode 100644 justfile create mode 100644 scripts/release.exs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cbc91c1..52cfce9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,7 +27,7 @@ jobs: MIX_ENV: test EXBASHKIT_BUILD: "1" steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - name: Set up Elixir uses: erlef/setup-beam@v1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d35f554..8bc61bf 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -33,7 +33,7 @@ jobs: os: ubuntu-24.04-arm steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - name: Extract version id: version @@ -107,7 +107,7 @@ jobs: EXBASHKIT_BUILD: "1" HEX_API_KEY: ${{ secrets.HEX_API_KEY }} steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - name: Set up Elixir uses: erlef/setup-beam@v1 diff --git a/README.md b/README.md index fee4dac..8319a4b 100644 --- a/README.md +++ b/README.md @@ -433,25 +433,28 @@ generate one at [hex.pm/dashboard/keys](https://hex.pm/dashboard/keys) with the gh secret set HEX_API_KEY --env hex --repo jtippett/ex_bashkit ``` -**To cut a release:** - -1. Bump `@version` in `mix.exs`; move the `CHANGELOG.md` `[Unreleased]` section - under the new version number. -2. Open a PR and merge to `master` once CI is green. -3. Tag the merge commit and push the tag: - ```bash - git tag -a vX.Y.Z -m vX.Y.Z && git push origin vX.Y.Z - ``` -4. `release.yml` builds NIFs for all four targets, creates the GitHub release - with the `.tar.gz` artifacts, then **waits for approval**. -5. Review the release, then approve the **`hex`** deployment in the workflow run - (Actions → the run → *Review deployments* → approve). On approval it - regenerates `checksum-Elixir.ExBashkit.Native.exs` from the released artifacts - and runs `mix hex.publish`. - -Don't commit the checksum file or move a published tag by hand — the pipeline -owns both. See [`UPDATE_PROCEDURE.md`](UPDATE_PROCEDURE.md) for bumping the -pinned bashkit version. +**To cut a release**, run the release assistant from `master` and follow the +prompts: + +```bash +just release # or, without just: elixir scripts/release.exs +``` + +It shows the current and published versions, asks for a **patch / minor / major** +bump (you pick the level — no version numbers to type), rolls the +`CHANGELOG.md` `[Unreleased]` section into the new version, then commits, tags, +and pushes. That kicks off `release.yml`, which builds NIFs for all four targets +and creates the GitHub release. + +Then **approve the publish**: open the workflow run → *Review deployments* → +approve the **`hex`** environment. On approval it generates +`checksum-Elixir.ExBashkit.Native.exs` from the released artifacts and runs +`mix hex.publish`. + +Keep notes under `## [Unreleased]` in `CHANGELOG.md` as you work — the assistant +rolls them into each release. Don't commit the checksum file or move a published +tag by hand; the pipeline owns both. See +[`UPDATE_PROCEDURE.md`](UPDATE_PROCEDURE.md) for bumping the pinned bashkit version. ## License diff --git a/justfile b/justfile new file mode 100644 index 0000000..d6d1b86 --- /dev/null +++ b/justfile @@ -0,0 +1,14 @@ +# Project commands. Run `just --list` to see them all. + +# Interactive release: pick patch/minor/major, roll the CHANGELOG, tag & push. +release: + elixir scripts/release.exs + +# Run the test suite (builds the NIF locally). +test: + EXBASHKIT_BUILD=1 mix test + +# Format Elixir + Rust. +fmt: + mix format + cd native/ex_bashkit && cargo fmt diff --git a/scripts/release.exs b/scripts/release.exs new file mode 100644 index 0000000..afc7c84 --- /dev/null +++ b/scripts/release.exs @@ -0,0 +1,172 @@ +# Interactive release assistant. Run from the project root: +# +# just release # or: elixir scripts/release.exs +# +# Shows the current and published versions, asks for a patch/minor/major bump, +# rolls the CHANGELOG, then (with your confirmation) commits, tags, and pushes — +# which starts the release workflow. The Hex publish still waits for your +# approval on the `hex` GitHub environment. +# +# Standalone Elixir — no mix/NIF compilation, just file edits + git. + +defmodule Release do + def run do + {app, current} = read_mix() + branch = trimmed(git!(["rev-parse", "--abbrev-ref", "HEAD"])) + + info("package", app) + info("current (mix.exs)", current) + if v = published(app), do: info("latest on Hex", v) + info("branch", branch) + + ensure_clean_tree!() + confirm_branch!(branch) + + {maj, min, pat} = parse(current) + + choices = %{ + "1" => {"patch", "#{maj}.#{min}.#{pat + 1}", "bug fixes"}, + "2" => {"minor", "#{maj}.#{min + 1}.0", "new features, backwards-compatible"}, + "3" => {"major", "#{maj + 1}.0.0", "breaking changes"} + } + + IO.puts("\nselect the release type:") + + for k <- ["1", "2", "3"] do + {name, ver, note} = choices[k] + IO.puts(" #{k}) #{name} → #{ver}\t(#{note})") + end + + {_, new, _} = choices[prompt("choice [1-3]: ")] || abort() + unless yes?("bump #{current} → #{new} ?"), do: abort() + + bump_mix!(current, new) + roll_changelog!(new) + + IO.puts("\nchanges:") + IO.puts(git!(["--no-pager", "diff", "--", "mix.exs", "CHANGELOG.md"])) + + unless yes?("commit, tag v#{new}, and push? (this starts the release build)") do + IO.puts(""" + Edits left in place, uncommitted. + Run `git checkout -- mix.exs CHANGELOG.md` to discard them. + """) + + System.halt(0) + end + + git!(["add", "mix.exs", "CHANGELOG.md"]) + git!(["commit", "-m", "Release #{new}"]) + git!(["tag", "-a", "v#{new}", "-m", "v#{new}"]) + git!(["push", "origin", branch]) + git!(["push", "origin", "v#{new}"]) + + IO.puts(""" + + ✅ pushed v#{new} — the release workflow is building the NIFs. + Final step: approve the `hex` deployment to publish: + #{actions_url()} + """) + end + + # ── mix.exs ──────────────────────────────────────────────────────────────── + defp read_mix do + src = File.read!("mix.exs") + [_, version] = Regex.run(~r/@version "([^"]+)"/, src) || die("no @version in mix.exs") + [_, app] = Regex.run(~r/app:\s*:([a-z0-9_]+)/, src) || die("no `app:` in mix.exs") + {app, version} + end + + defp parse(version) do + [maj, min, pat] = + version + |> String.split("-") + |> hd() + |> String.split(".") + |> Enum.map(&String.to_integer/1) + + {maj, min, pat} + end + + defp bump_mix!(old, new) do + src = File.read!("mix.exs") + File.write!("mix.exs", String.replace(src, ~s(@version "#{old}"), ~s(@version "#{new}"))) + end + + # ── CHANGELOG ────────────────────────────────────────────────────────────── + defp roll_changelog!(new) do + path = "CHANGELOG.md" + + with true <- File.exists?(path), + src = File.read!(path), + true <- String.contains?(src, "## [Unreleased]") do + today = Date.to_iso8601(Date.utc_today()) + heading = "## [Unreleased]\n\n## #{new} - #{today}" + File.write!(path, String.replace(src, "## [Unreleased]", heading, global: false)) + else + _ -> :ok + end + end + + # ── Hex (best-effort) ────────────────────────────────────────────────────── + defp published(app) do + case System.cmd("mix", ["hex.info", app], stderr_to_stdout: true) do + {out, 0} -> Regex.run(~r/[0-9]+\.[0-9]+\.[0-9]+/, out) |> then(&(&1 && hd(&1))) + _ -> nil + end + rescue + _ -> nil + end + + # ── git ──────────────────────────────────────────────────────────────────── + defp ensure_clean_tree! do + case trimmed(git!(["status", "--porcelain"])) do + "" -> :ok + _ -> die("working tree is dirty — commit or stash first.") + end + end + + defp confirm_branch!(b) when b in ["master", "main"], do: :ok + + defp confirm_branch!(b) do + unless yes?("⚠ not on master/main (on '#{b}'). release from here anyway?"), do: abort() + end + + defp git!(args) do + case System.cmd("git", args, stderr_to_stdout: true) do + {out, 0} -> out + {out, code} -> die("git #{Enum.join(args, " ")} failed (#{code}):\n#{out}") + end + end + + defp actions_url do + case System.cmd("git", ["config", "--get", "remote.origin.url"]) do + {url, 0} -> + slug = + url + |> String.trim() + |> String.replace(~r/\.git$/, "") + |> String.replace(~r{^git@github\.com:}, "") + |> String.replace(~r{^https://github\.com/}, "") + + "https://github.com/#{slug}/actions" + + _ -> + "your repo's Actions tab" + end + end + + # ── IO ───────────────────────────────────────────────────────────────────── + defp info(label, value), do: IO.puts(String.pad_trailing("#{label}:", 19) <> value) + defp prompt(label), do: IO.gets(label) |> to_string() |> String.trim() + defp yes?(question), do: prompt("#{question} [y/N] ") =~ ~r/^[Yy]/ + defp trimmed(s), do: String.trim(s) + defp abort, do: die("aborted.") + + defp die(msg) do + IO.puts(:stderr, "✗ #{msg}") + System.halt(1) + end +end + +Release.run()