Skip to main content

README.md

# version_bump

A port of [semantic-release](https://github.com/semantic-release/semantic-release)
to [Gleam](https://gleam.run), running on the Erlang/BEAM. It automates the
release workflow: it reads the conventional commits since the last release,
decides the next [semantic version](https://semver.org), 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:

```sh
git clone <this-repo>
cd version_bump
gleam deps download
```

## Usage

Run the full release pipeline against the current working directory:

```sh
gleam run
```

Compute and preview the next release without tagging or publishing:

```sh
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):

```sh
gleam run -- --cwd ../packages/api --dry-run
```

Other commands:

```sh
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:

```sh
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:

```toml
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.*`        | 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.0`** instead of `1.0.0`, and
- while the major version is `0`, a **breaking change is a minor bump**
  (`0.3.1` → `0.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:

```json
"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).

```json
"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](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`](.github/workflows/release.yml.example) —
copy it to `.github/workflows/release.yml` in your package. (`version_bump`'s own
[`.github/workflows/release.yml`](.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:

```yaml
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](docs/github-actions-release.md)).

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.

```gleam
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:

```gleam
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.

```json
"plugins": [
  "commit-analyzer",
  "release-notes-generator",
  ["exec", { "publishCmd": "./scripts/deploy.sh ${nextRelease.version}" }]
]
```

See [`.releaserc.example.json`](./.releaserc.example.json) for a complete
example combining branches, the four default plugins, and an `exec` step.

## Development

```sh
gleam run    # Run the release pipeline
gleam test   # Run the tests
```