version_bump
A port of semantic-release to Gleam, running on the Erlang/BEAM. It automates the release workflow: it reads the conventional commits since the last release, decides the next semantic version, generates release notes, commits and tags the bump, and publishes to Hex (or npm) and GitHub.
The lifecycle mirrors upstream semantic-release:
- resolve the current branch and build the shared context
- resolve each configured plugin against the registry
verify_conditions- find the last release from the git tags
- read & parse the commits since that release
analyze_commits-> a release type (or stop: "no release")- compute the next version and build the next release
verify_releasegenerate_notes-> attach to the next release- (dry-run) report and stop
prepare, then create & push the git tagpublish-> collect the produced releasessuccess
Any error after verify_conditions runs every plugin's fail hook before the
error is returned.
Prerequisites
- Gleam (developed against 1.17)
- Erlang/OTP — Gleam compiles to the BEAM, so an Erlang runtime is required
- git — the pipeline shells out to git to read branches, tags, and commits, and to commit & push the release
- gleam — the default
hexplugin runsgleam publish - npm — only needed if you use the
npmplugin instead ofhex - A clean checkout on a configured release branch (
main,master,next,beta, oralphaby default)
Environment variables
Tokens are read from the process environment (plugins also accept them through
the context env). None are needed for --dry-run:
HEXPM_API_KEY— required by thehexplugin togleam publish. Generate a key with publish (API write) permission at hex.pm → Dashboard → Keys (a read-only key authenticates but cannot publish).verify_conditionsonly checks the key is present, so thepublishstep verifies the package actually reached Hex and fails loudly otherwise.NPM_TOKEN— required by thenpmplugin'sverify_conditions.GITHUB_TOKEN(orGH_TOKEN) — required by thegithubplugin'sverify_conditions.GITHUB_TOKENtakes precedence overGH_TOKEN.
Install / build
Clone the repository and fetch dependencies:
git clone <this-repo>
cd version_bump
gleam deps download
Usage
Run the full release pipeline against the current working directory:
gleam run
Compute and preview the next release without tagging or publishing:
gleam run -- --dry-run
In a dry run the pipeline stops after generating notes: it logs the computed
version and release notes but skips prepare, tagging, publish, and
success.
See it working:
examples/run-demo.shbuilds a throwaway Gleam-package repo and runs the tool across four scenarios (first release → patch → minor → major), printing the computed version and notes each time. Runs on both targets (TARGET=javascript examples/run-demo.sh). Seeexamples/README.md.
Run against a project in another directory (e.g. a monorepo package) with
--cwd (the --cwd=<path> form also works):
gleam run -- --cwd ../packages/api --dry-run
Other commands:
gleam run -- --version # print the tool version and exit
gleam run -- --help # print usage and exit
Unknown flags are rejected with a non-zero exit so mistakes are visible rather than silently ignored.
A typical CI invocation provides the tokens inline:
NPM_TOKEN=... GITHUB_TOKEN=... gleam run
Configuration
Configuration is optional. With no config at all the tool uses Gleam-first
defaults — the plugins commit-analyzer, release-notes-generator, hex,
git, and github, over the conventional branches (main, master, next,
beta, alpha). (git commits the version bump back; see the note below.)
Recommended: gleam.toml
For a Gleam package, put config under [tools.version_bump] in gleam.toml
— the conventions-blessed location for tool config:
name = "my_package"
version = "1.4.2" # the tool bumps this on release
description = "..."
licences = ["Apache-2.0"]
repository = { type = "github", user = "my-org", repo = "my-package" }
[tools.version_bump]
tag_format = "v${version}"
branches = ["main", { name = "beta", prerelease = "beta" }]
plugins = ["commit-analyzer", "release-notes-generator", "hex", "git", "github"]
# per-plugin options go in sub-tables:
[tools.version_bump.plugin_options.exec]
publishCmd = "./scripts/extra.sh ${nextRelease.version}"
repository_url is derived from the standard [repository] field, and
name/version are reused from gleam.toml, so a typical Gleam package needs
little or no [tools.version_bump] config.
Lookup order
Config is loaded from the project root; the first source that exists and parses wins (values merge over the defaults):
.releaserc.json(JSON).releaserc(JSON)release.config.json(JSON).releaserc.toml(TOML)[tools.version_bump]ingleam.toml(TOML; also derivesrepository_url)- the
"release"key ofpackage.json(JSON)
Any recognised keys override the defaults; unknown keys are ignored. Fields
(gleam.toml snake_case key / .releaserc.* camelCase key):
gleam.toml / .releaserc.* | Type | Default | Meaning |
|---|---|---|---|
repository_url / repositoryUrl | string | derived / none | repo URL; used by the github plugin |
tag_format / tagFormat | string | v${version} | git tag template; ${version} is substituted |
branches / branches | array | the 5 defaults | release branches (see below) |
plugins / plugins | array | the 5 defaults | plugin pipeline (see below) |
dry_run / dryRun | bool | false | force dry-run (--dry-run also turns it on) |
ci / ci | bool | true | whether running in CI |
initial_development / initialDevelopment | bool | false | 0.x mode (see below) |
Note: --dry-run is only ever an override that turns dry-run on; it cannot
force a real release when the config disables it.
Initial development (0.x)
By default the first release is 1.0.0 and a breaking change is a major bump —
so a breaking change in 0.x would jump straight to 1.0.0. Setting
initial_development = true enables SemVer's "initial development" semantics
(spec clause 4 — the 0.y.z phase where the public API isn't yet stable):
- the first release starts at
0.1.0instead of1.0.0, and - while the major version is
0, a breaking change is a minor bump (0.3.1→0.4.0) rather than1.0.0. Features and fixes are unchanged (feat→ minor,fix→ patch).
This keeps the package in 0.x until you're ready to commit to a stable API —
release 1.0.0 yourself (set version in gleam.toml and tag it), after which
the flag has no further effect.
Publishing a 0.x package to Hex:
gleam publishguards releases below1.0.0behind a prompt that makes you typeI am not using semantic versioning, which--yesdoes not auto-accept — so a naive non-interactive publish silently aborts. Thehexplugin supplies that phrase for you, so 0.x releases publish unattended in CI.
Branches
A branch entry is either a bare string (just the name) or an object:
"branches": [
"main",
{ "name": "next", "channel": "next" },
{ "name": "beta", "prerelease": "beta" },
{ "name": "alpha", "prerelease": true }
]
prerelease may be a string (the prerelease identifier) or true (use the
branch name). channel and range are optional.
Plugins
A plugin entry is either a bare string (the plugin name, no options) or a
two-element [name, options] array. Options are kept as a flat dictionary of
stringified scalar values; nested objects/arrays in options are skipped (each
plugin reparses what it needs).
"plugins": [
"commit-analyzer",
"release-notes-generator",
["npm", { "npmPublish": true }],
"github"
]
The built-in plugin names are: commit-analyzer, release-notes-generator,
hex, npm, git, github, and exec. An unknown plugin name is a
configuration error. In gleam.toml, plugin options live in
[tools.version_bump.plugin_options.<name>] sub-tables (shown above); the
JSON sources use the [name, { options }] array form shown here.
The git plugin (committing the version bump)
git (in the defaults, listed after hex) commits the files the release
changed — by default the bumped gleam.toml — in its prepare hook. The engine
then pushes the branch alongside the tag, so the release tag points at the
commit containing the new version and the working tree is left clean. Options:
assets (comma-separated, default gleam.toml), message (default
chore(release): ${version} [skip ci]), committerName, committerEmail.
This means a real release pushes a commit to your release branch, so the CI
token needs branch-push permission. If you prefer the tag-only model (leave the
committed gleam.toml version as a placeholder and treat the tag + Hex as the
source of truth), simply drop git from plugins.
Releasing in CI (GitHub Actions)
Full step-by-step guide:docs/github-actions-release.md — Hex key creation, the secret, the workflow, permissions, the first release, the gotchas, and a command reference. The summary below is the short version.
A ready-to-copy workflow lives at
.github/workflows/release.yml.example —
copy it to .github/workflows/release.yml in your package. (version_bump's own
.github/workflows/release.yml dogfoods this:
it's the same setup but runs gleam run since the tool releases itself.)
On GitHub, two things need authorization, and both are covered by the
built-in GITHUB_TOKEN:
- git push — the
gitplugin commits the version bump and the engine pushes the branch + tag. - GitHub API — the
githubplugin creates the Release.
The one thing you must provision is write access:
permissions:
contents: write
Without it, both the push and the release creation return 403 (many repos
default GITHUB_TOKEN to read-only). The rest:
actions/checkoutwithfetch-depth: 0— full history + tags (a shallow clone makes every run look like a first release). Its defaultpersist-credentials: trueis what letsgit pushuseGITHUB_TOKENautomatically.- Pass
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}to the run step for thegithubplugin, andHEXPM_API_KEY: ${{ secrets.HEX_API_KEY }}forhex(create a key with publish/API-write permission at hex.pm → Dashboard → Keys, then add it as a repo secret — see the full guide).
So GITHUB_TOKEN itself is automatic — the only setup is the contents: write
permission and the HEX_API_KEY secret.
Caveats
GITHUB_TOKENpushes don't trigger other workflows (loop prevention by design); thegitplugin's default[skip ci]message is extra insurance.- Branch protection on the release branch can reject a direct push from
GITHUB_TOKEN. Allow a bypass actor, release from an unprotected branch, or drop thegitplugin (tag-only model). - If you need the release commit to trigger downstream workflows, or to
bypass branch protection, the built-in token can't — use a fine-grained
PAT (Contents: read+write) or a GitHub App token, and pass it to both
actions/checkout(token:) and the run step (GITHUB_TOKEN:). See the commented block in the example workflow.
Distribution note:
gleam run -m version_bumpassumes the tool is available to your project (e.g. as a dev dependency once it's published to Hex). Until then, adjust the invocation to how you run it.
The plugin model
Upstream semantic-release plugins are JS modules that duck-type which lifecycle
hooks they implement. Gleam has no dynamic dispatch, so a plugin is instead a
record of optional hook functions (version_bump/plugin.Plugin). A
plugin implements a hook by setting that field to Some(fn); the engine skips
None fields.
pub type Plugin {
Plugin(
name: String,
verify_conditions: Option(VerifyConditions),
analyze_commits: Option(AnalyzeCommits),
verify_release: Option(VerifyRelease),
generate_notes: Option(GenerateNotes),
add_channel: Option(AddChannel),
prepare: Option(Prepare),
publish: Option(Publish),
success: Option(Success),
fail: Option(Fail),
)
}
Every hook has the shape fn(PluginSpec, Context) -> Result(..., ReleaseError),
where PluginSpec carries the plugin's configured options and Context is the
immutable state threaded through the pipeline. The engine enforces the
per-hook return semantics:
analyze_commits: the highestReleaseTypeacross plugins wins (Patch < Minor < Major)generate_notes: results are concatenated in plugin orderpublish:Some(release)is published;Nonemeans "not handled"- all others: run for effect; a failure aborts the pipeline
Build a concrete plugin by starting from plugin.new(name) (all hooks None)
and overriding the fields you implement:
import gleam/option.{Some}
import version_bump/plugin
pub fn my_plugin() -> plugin.Plugin {
plugin.Plugin(..plugin.new("my-plugin"), publish: Some(do_publish))
}
fn do_publish(spec, ctx) {
// ... create the release for ctx.next_release ...
Ok(option.None)
}
The exec escape hatch
You don't have to write Gleam to add behavior. The built-in exec plugin lets
you wire a shell command to any lifecycle step through its options. Each option
key maps to one hook; the command runs through sh -c in the project's working
directory:
| Option key | Hook |
|---|---|
verifyConditionsCmd | verify_conditions |
analyzeCommitsCmd | analyze_commits |
verifyReleaseCmd | verify_release |
generateNotesCmd | generate_notes |
prepareCmd | prepare |
publishCmd | publish |
successCmd | success |
failCmd | fail |
For analyzeCommitsCmd, the trimmed stdout (major/minor/patch,
case-insensitive) is parsed into the release type; anything else means "no
release". For generateNotesCmd, the trimmed stdout becomes the notes. For the
effect-only hooks, a non-zero exit aborts the pipeline.
"plugins": [
"commit-analyzer",
"release-notes-generator",
["exec", { "publishCmd": "./scripts/deploy.sh ${nextRelease.version}" }]
]
See .releaserc.example.json for a complete
example combining branches, the four default plugins, and an exec step.
Development
gleam run # Run the release pipeline
gleam test # Run the tests