# rlls
**rlls** is a Rust-first release tool that treats single repos as trivial and makes monorepos hard to mess up. It does one job: bump versions and create **correct, package-scoped Git tags** and **(optionally) GitHub Releases** with clean changelog entries derived from Git history. No “semantic commit” rituals, no plugin boilerplate, no registry publishing — just Git done right.
---
## Why rlls?
Tools like semantic-release optimize for ceremony. rlls optimizes for **getting out of your way**:
- **Exact commit windows.** In a single repo, release notes are strictly `last_tag..HEAD`. In a monorepo, each package uses **its own last tag** as the baseline. If there’s no previous tag, we use all relevant commits.
- **Package-scoped tags.** Monorepo tags look like `{{name}}@v{{version}}` by default (e.g., `@scope/core@v1.4.2`). Single repos keep `vX.Y.Z`.
- **Lock file safety.** Monorepos are backed by an auto-generated `rlls.lock` so you can survive tag deletions, migrations, or history rewrites and still rebuild correct baselines.
- **Minimal config.** A small `rlls.toml` governs bump defaults, messages, changelog path, and the _allowlist_ of packages you actually care about.
- **No registry publishing.** rlls does not publish to npm/PyPI/Crates. You own that pipeline. rlls produces tagged, reviewable artifacts and (optionally) GH releases.
---
## What rlls actually does
- Bumps the version field in your package manifest(s) (Node `package.json`, Python `pyproject.toml`/`setup.cfg`/`setup.py`, Rust `Cargo.toml`).
- Creates an annotated Git tag for the new version:
- Single repo: `vX.Y.Z`
- Monorepo: `{{name}}@vX.Y.Z` (template is configurable)
- Writes a changelog section assembled from `git log` between the correct baselines.
- Pushes the commit(s) and tag(s).
- **Single repo:** tags only by default.
- **Monorepo:** creates **one GH Release per package tag** with scoped notes (uses `gh` CLI if present).
That’s it.
---
## Installation
```bash
cargo install --locked rlls
```
Requirements:
- **git** (obvious)
- **gh** (GitHub CLI) _optional_: used to enrich PR titles in notes and to create GitHub Releases. Without `gh`, rlls still works; notes just won’t resolve PR titles.
---
## Supported projects
rlls knows how to read & write versions for:
- **Node:** `package.json` (+ refreshes lockfiles when present)
- **Python:** `pyproject.toml` (PEP 621 or Poetry), `setup.cfg`, `setup.py`
- **Rust:** `Cargo.toml` (single crate or workspace)
> You don’t need to tell rlls “what kind” your package is; it auto-detects. In a monorepo, you’ll **allowlist** the **names** you want rlls to manage.
---
## Quick start
### Single repo
1. Create a minimal config (optional — sensible defaults exist):
```toml
# rlls.toml
default_bump = "patch"
bump_commit_message = "chore(release): bump to {{version}}"
bump_tag_message = "release {{version}}"
[changelog]
enable = true
path = "CHANGELOG.md"
```
2. Bump & tag:
```bash
# patch | minor | major
rlls patch
```
rlls will:
- Verify your working tree is clean (unless `allow_dirty = true`)
- Compute `last_tag..HEAD` (or full history if no tags)
- Bump the version, write the changelog entry, create an **annotated** tag, push, and you’re done.
### Monorepo (interactive)
1. Create `rlls.toml` and **allowlist** your packages:
```toml
# rlls.toml
default_bump = "patch"
# commit message templates
bump_commit_message = "chore(release): bump {{name}} to {{version}}" # used for single repo bumps
monorepo_bump_commit_message = "chore(release): bump {{count}} packages\n\n{{list}}"
# tag text (annotated tag message)
bump_tag_message = "{{name}}@{{version}}"
# tag shape & version prefix
pkg_tag_template = "{{name}}@v{{version}}"
include_v_prefix = true
# changelog
[changelog]
enable = true
path = "CHANGELOG.md"
# only these package names are managed by rlls (everything else is ignored)
packages = ["@acme/core", "@acme/adapters"]
# optional migration hints per package
[migration."@acme/core"]
legacy_tag_patterns = ["core-v*", "core@*"]
```
2. **Bootstrap the lock file** (first time only, or after migration):
```bash
# detect a monorepo? -> build rlls.lock
rlls lock rebuild
# if any package has no discoverable baseline tag, provide one explicitly:
rlls lock rebuild --baseline @acme/core:v1.2.3 --baseline @acme/adapters:v0.8.0
```
3. Release:
```bash
# discovers changed packages (by per-package last tag),
# asks you which ones to bump and how
rlls --monorepo
```
rlls will:
- Use **per-package** last baselines (from `rlls.lock`, or from actual tags if lock says so)
- Show commit counts since each baseline
- Prompt you for **p/m/M** per package
- Create one commit, create one tag **per package**, push, update a **single** changelog with per-package sections, and publish **GitHub Releases per tag**.
> Need a subset?
> `rlls monorepo --packages "@acme/core,@acme/adapters"`
---
## Configuration (rlls.toml)
All keys are optional unless otherwise stated. Defaults aim to be unsurprising.
```toml
# ---------- Top-level ----------
# default semver bump when the CLI doesn’t specify one
default_bump = "patch" # "patch" | "minor" | "major"
# commit message when bumping a **single** package (single repo mode)
# vars: {{name}}, {{version}}
bump_commit_message = "chore(release): bump {{name}} to {{version}}"
# commit message when bumping **multiple** packages (monorepo)
# vars: {{count}}, {{list}}, {{name}}, {{version}}
monorepo_bump_commit_message = "chore(release): bump {{count}} packages\n\n{{list}}"
# tag annotation message (the tag **name** always contains the version; this is the tag body)
# vars: {{name}}, {{version}}
bump_tag_message = "release {{version}}"
# optional H1-ish header injected at the very top of the changelog if missing
changelog_header = "# Changelog"
# Package-scoped tag shape for monorepos:
# default -> "@scope/name@v1.2.3" or "name@v1.2.3"
pkg_tag_template = "{{name}}@v{{version}}"
# whether to prefix "v" on versions (affects tag name construction)
include_v_prefix = true
# if true, bypass clean-tree check (useful in CI emergencies; default false)
allow_dirty = false
# only these package **names** are considered (must match manifest names)
packages = ["@acme/core", "@acme/adapters"]
# ---------- Changelog ----------
[changelog]
enable = true
path = "CHANGELOG.md"
# ---------- Migration hints (optional) ----------
# If you’re migrating from another tool and old tags don’t match the default pattern,
# rlls can consider legacy patterns when rebuilding the lock:
[migration."@acme/core"]
legacy_tag_patterns = ["core-v*", "core@*"]
```
### Environment overrides
Every one of these is optional:
- `RLLS_DEFAULT_BUMP`
- `RLLS_BUMP_COMMIT_MESSAGE`
- `RLLS_MONOREPO_BUMP_COMMIT_MESSAGE`
- `RLLS_BUMP_TAG_MESSAGE`
- `RLLS_CHANGELOG_HEADER`
- `RLLS_CHANGELOG_ENABLE` (`true`/`false`)
- `RLLS_CHANGELOG_PATH`
- `RLLS_PKG_TAG_TEMPLATE`
- `RLLS_INCLUDE_V_PREFIX` (`true`/`false`)
---
## The lock file (rlls.lock)
**State is king in monorepos.** `rlls.lock` is a TOML document that rlls **creates automatically** the first time you run `rlls lock rebuild`. It records, per package:
- package name
- package path (relative)
- last known release (tag, version core, and **commit SHA**)
- last transaction metadata
### Why this matters
- If someone **deletes tags**, rlls can still reason about the correct baseline commits via the recorded SHA.
- If you **rename** or **move** packages, the lock tracks paths and allows you to rebuild safely.
- If you or another tool previously used a **different tag shape**, you can declare legacy patterns to discover the right baseline on rebuild.
### How we rebuild safely
`rlls lock rebuild` walks the repo to discover packages, filters to your `packages = [...]` allowlist, and then determines the baseline for each **in this order**:
1. **Explicit override** via `--baseline NAME:REF` (where `REF` can be `v1.2.3` or a raw commit).
2. **Newest reachable tag** matching:
- `pkg_tag_template` (`{{name}}@v*` by default), **plus**
- Any `migration.<name>.legacy_tag_patterns` you provided.
- We keep only **reachable** tags (`merge-base --is-ancestor`) to avoid detached/rewritten ghosts.
- We sort **semver-aware** and pick the highest. Collisions at the same version are flagged.
3. **No baseline** → rebuild fails with a clear message until you provide `--baseline` or migration hints.
> You can also synthesize tags from baselines:
> `rlls tag synthesize --baseline @acme/core:v1.2.3 --baseline @acme/adapters:deadbeef`
### “What if…?”
- **I deleted `rlls.lock`.**
Run `rlls lock rebuild` and provide `--baseline NAME:REF` for any package without discoverable tags. Commit the new lock.
- **I migrated from some other tool with weird tags.**
Add `[migration."<name>"] legacy_tag_patterns = ["weird-*"]` and rerun `rlls lock rebuild`.
- **Tags were force-pushed away.**
If you had created them with rlls, our annotated tags include **trailers** (`rlls:pkg=…`, `rlls:ver=…`, `rlls:sha=…`). Rebuilding can use those, and the lock remembers SHAs regardless.
- **We rewrote history.**
Rebuild will refuse baselines not reachable from `HEAD`. Provide new `--baseline` refs, then commit the updated lock.
---
## CLI reference
```
rlls [KIND] [FLAGS] # single repo
rlls --monorepo [FLAGS] # monorepo (interactive)
rlls monorepo [--packages CSV] # monorepo subset (interactive)
rlls prerelease [--id rc] [--bump KIND] # single repo prerelease tag
rlls finalize # single repo: convert last prerelease to stable tag
rlls rollback [--local] # remove tags at HEAD and revert last release commits
rlls selfupdate # install latest rlls via crates.io or GH releases
rlls lock rebuild [--force] [--baseline NAME:REF ...] [--from-file PATH] # create or rebuild rlls.lock
rlls lock verify # sanity-check rlls.lock
rlls tag synthesize --baseline NAME:REF [...] # create annotated tags at given refs
```
**KIND** is one of: `patch | minor | major`.
**Common flags**
- `--dry` : don’t push, just stage the actions that would occur
- `--no-changelog` : skip changelog writes for this run
- `--repo <owner/repo>` : override detected GitHub repo (for GH release URLs)
- `--monorepo` : force monorepo mode if detection is ambiguous
- `--packages "<a,b,c>"`: in `rlls monorepo …`, restrict to a subset
- `--ignore_package` : (single repo) bump _computed_ version but don’t rewrite the manifest (useful for tag-only scenarios)
> **Notes on monorepo + CI**
> The current monorepo flow is **interactive** (per-package bump selection). Non-interactive batch planning is on deck in the planner/executor refactor.
---
## Changelog behavior
rlls writes Markdown to `changelog.path` (default `CHANGELOG.md`), prepending the newest section.
- Top line: `## <tag> <YYYY-MM-DD>`
- Sections:
- **Changes since** `<base>`: `git log --no-merges --pretty=- %h %s`
- **Pull requests**: if commits include `(#123)`, rlls (optionally via `gh`) resolves titles
- **Authors**: from `git shortlog -sn`
- **Compare**: `https://github.com/<repo>/compare/<base>...<tag>`
**Single repo:** one section per tag.
**Monorepo:** one **batch** section labeled `batch-YYYYMMDDHHMMSS` with a **subsection per package** tagged in that run, each with its own compare window.
---
## Tagging details
- **Single repo tag name:** `v<semver>`
- **Monorepo tag name:** constructed from `pkg_tag_template` (default `{{name}}@v{{version}}`)
- **Tags are annotated**. rlls appends stable **trailers** to every tag message:
```
rlls:id=<sanitized-tag-id>
rlls:pkg=<package-name>
rlls:ver=<version-core>
rlls:sha=<target-commit-sha>
```
These help with forensic rebuilds and migrations.
---
## Under the hood (how rlls finds “the right commits”)
### Single repo
- Baseline = **last reachable tag** (or the repo’s first commit if none)
- Window = `baseline..HEAD`
- Notes & authors built directly from that window
### Monorepo
- Baseline per package:
1. `rlls.lock` last release (preferred)
2. Newest reachable tag matching `pkg_tag_template` ± `legacy_tag_patterns`
3. Otherwise fail and ask for `--baseline`
- Changed files are computed per package via path scopes (`git log range -- <package_dir>`)
- Version bump writes just that package’s manifest(s)
- One commit captures all the bumps
- One annotated tag per package
- Push commits, then push tags
- Create GH Releases per tag with **scoped** notes (commit filtering with `-- <package_dir>`)
Everything is **idempotent** within a run — attempts to double-tag or push get surfaced cleanly. `rollback` can pop tags at `HEAD` and revert the release commit(s) locally (and remotely with `--local` omitted).
---
## How rlls differs (at a glance)
| Uses **Git tags as truth** | ✅ | ⚠️ (plugins) | ✅ | ✅ |
| **Package-scoped** tags | ✅ | ⚠️ w/ plugins | ✅ | ❌ |
| **Monorepo** baseline is per-package | ✅ | ⚠️ (requires structure) | ✅ | ❌ |
| **No commit message rules** | ✅ | ❌ (conv. commits) | ✅ (optional) | ✅ |
| **Lock file** for recovery | ✅ | ❌ | ❌ | ❌ |
| **No registry publish** (BYO CI) | ✅ | ❌ (publishes) | ⚠️ (optional) | ✅ |
| **Zero plugins** | ✅ | ❌ | ⚠️ | ✅ |
---
## Examples
### Minimal single-repo config
```toml
# rlls.toml
default_bump = "patch"
bump_commit_message = "chore(release): bump to {{version}}"
bump_tag_message = "release {{version}}"
[changelog]
enable = true
path = "CHANGELOG.md"
```
### Monorepo config with two packages
```toml
default_bump = "patch"
bump_commit_message = "chore(release): bump {{name}} to {{version}}"
monorepo_bump_commit_message = "chore(release): bump {{count}} packages\n\n{{list}}"
bump_tag_message = "{{name}}@{{version}}"
pkg_tag_template = "{{name}}@v{{version}}"
include_v_prefix = true
[changelog]
enable = true
path = "CHANGELOG.md"
packages = ["@acme/core", "@acme/adapters"]
[migration."@acme/core"]
legacy_tag_patterns = ["core-v*"]
```
**Flow:**
```bash
rlls lock rebuild
rlls --monorepo
```
---
## Troubleshooting / FAQ
**rlls says my tree is dirty.**
Commit or stash your changes, or set `allow_dirty = true` temporarily.
**It can’t find a baseline for one package.**
Provide an explicit baseline:
`rlls lock rebuild --baseline @acme/core:v1.2.3`
…or add `[migration."<name>"].legacy_tag_patterns` and rerun.
**We renamed a package.**
Update the manifest’s `name` and your `packages = [...]` list, then run `rlls lock rebuild --baseline <newname>:<old-tag-or-commit>` once to anchor the new name.
**We deleted tags by mistake.**
Recreate them if you know the versions, or use `rlls tag synthesize --baseline NAME:REF` to mint missing tags at known commits. Then `rlls lock rebuild`.
**GitHub Releases?**
- Single repo: rlls **only tags** by default.
- Monorepo: rlls creates **one GitHub Release per package tag** using scoped notes. If `gh` is not installed, rlls will still tag; the release step will be skipped.
---
## Conventions & templates
You can customize messages using these variables:
- `{{name}}` — package name
- `{{version}}` — version (with or without `v` depending on `include_v_prefix`)
- `{{count}}` — number of packages in a monorepo batch
- `{{list}}` — newline-joined lines like `- <name>@<version> (N commits since tag)`
---
## Safety rails
- **Clean tree check** (unless `allow_dirty`)
- **Reachability** validation when rebuilding or verifying `rlls.lock`
- **Semver sorting** of tags to avoid lexicographic traps
- **Collision detection** across legacy patterns
- **Rollback** removes tags at `HEAD` and rewinds release commit(s)
---
## Roadmap (public)
- Non-interactive monorepo planning for CI (CLI flags for per-package bumps)
- Optional toggle to disable GH Release creation in monorepo mode
- Built-in “planner/executor” for resumable multi-step runs (JSON plan)
---
## Contributing
- Open PRs against `main`.
- Add tests for tag parsing, lock rebuilds, baseline selection, and changelog formatting.
- Keep the config small. If a feature adds confusion, it probably belongs in user CI, not rlls.
---
## Appendix: Command cheatsheet
```bash
# Single repo release (patch)
rlls patch
# Single repo prerelease
rlls prerelease --bump minor --id rc
# later:
rlls finalize
# Monorepo (interactive)
rlls lock rebuild
rlls --monorepo
# subset:
rlls monorepo --packages "@acme/core,@acme/adapters"
# Recover from missing tags by synthesizing them
rlls tag synthesize --baseline @acme/core:v1.2.3
# Verify the lock file
rlls lock verify
# Roll back the last release (remove tags at HEAD and revert release commits)
rlls rollback # local + remote (tags)
rlls rollback --local # local only
```
---
**rlls** keeps your releases boring (the good kind). Tag cleanly, ship confidently, and get on with your day.