Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ jobs:
os: ubuntu-24.04-arm

steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v7

- name: Extract version
id: version
Expand Down Expand Up @@ -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
Expand Down
41 changes: 22 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
14 changes: 14 additions & 0 deletions justfile
Original file line number Diff line number Diff line change
@@ -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
172 changes: 172 additions & 0 deletions scripts/release.exs
Original file line number Diff line number Diff line change
@@ -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()