element.version === currentVersion)) { versionNodes.unshift({ version: currentVersion, url: "#" }); } document.querySelector("#project-version").innerHTML = versionNodes.reduce( (acc, element) => { const status = currentVersion === element.version ? "selected disabled" : ""; return ` ${acc} `; }, `
version_bump · v0.1.2

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:

  1. resolve the current branch and build the shared context
  2. resolve each configured plugin against the registry
  3. verify_conditions
  4. find the last release from the git tags
  5. read & parse the commits since that release
  6. analyze_commits -> a release type (or stop: “no release”)
  7. compute the next version and build the next release
  8. verify_release
  9. generate_notes -> attach to the next release
  10. (dry-run) report and stop
  11. prepare, then create & push the git tag
  12. publish -> collect the produced releases
  13. success

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 hex plugin runs gleam publish
  • npm — only needed if you use the npm plugin instead of hex
  • A clean checkout on a configured release branch (main, master, next, beta, or alpha by 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 the hex plugin to gleam publish. Generate a key with publish (API write) permission at hex.pm → Dashboard → Keys (a read-only key authenticates but cannot publish). verify_conditions only checks the key is present, so the publish step verifies the package actually reached Hex and fails loudly otherwise.
  • NPM_TOKEN — required by the npm plugin’s verify_conditions.
  • GITHUB_TOKEN (or GH_TOKEN) — required by the github plugin’s verify_conditions. GITHUB_TOKEN takes precedence over GH_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.sh builds 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). See examples/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):

  1. .releaserc.json (JSON)
  2. .releaserc (JSON)
  3. release.config.json (JSON)
  4. .releaserc.toml (TOML)
  5. [tools.version_bump] in gleam.toml (TOML; also derives repository_url)
  6. the "release" key of package.json (JSON)

Any recognised keys override the defaults; unknown keys are ignored. Fields (gleam.toml snake_case key / .releaserc.* camelCase key):

gleam.toml / .releaserc.*TypeDefaultMeaning
repository_url / repositoryUrlstringderived / nonerepo URL; used by the github plugin
tag_format / tagFormatstringv${version}git tag template; ${version} is substituted
branches / branchesarraythe 5 defaultsrelease branches (see below)
plugins / pluginsarraythe 5 defaultsplugin pipeline (see below)
dry_run / dryRunboolfalseforce dry-run (--dry-run also turns it on)
ci / cibooltruewhether running in CI
initial_development / initialDevelopmentboolfalse0.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.0 instead of 1.0.0, and
  • while the major version is 0, a breaking change is a minor bump (0.3.10.4.0) rather than 1.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 publish guards releases below 1.0.0 behind a prompt that makes you type I am not using semantic versioning, which --yes does not auto-accept — so a naive non-interactive publish silently aborts. The hex plugin 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 git plugin commits the version bump and the engine pushes the branch + tag.
  • GitHub API — the github plugin 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/checkout with fetch-depth: 0 — full history + tags (a shallow clone makes every run look like a first release). Its default persist-credentials: true is what lets git push use GITHUB_TOKEN automatically.
  • Pass GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} to the run step for the github plugin, and HEXPM_API_KEY: ${{ secrets.HEX_API_KEY }} for hex (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_TOKEN pushes don’t trigger other workflows (loop prevention by design); the git plugin’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 the git plugin (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_bump assumes 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 highest ReleaseType across plugins wins (Patch < Minor < Major)
  • generate_notes: results are concatenated in plugin order
  • publish: Some(release) is published; None means “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 keyHook
verifyConditionsCmdverify_conditions
analyzeCommitsCmdanalyze_commits
verifyReleaseCmdverify_release
generateNotesCmdgenerate_notes
prepareCmdprepare
publishCmdpublish
successCmdsuccess
failCmdfail

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
Search Document