Why monorel?
monorel handles both single-module and multi-module Go repos, but it's designed for the multi-module case. That's where the Go ecosystem currently has a gap: no battle-tested release tool handles the canonical Go monorepo layout cleanly. The layout in question:
my-repo/ ← main module (root)
├── go.mod module example.com/widget
├── widget.go
├── transports/
│ └── foo/ ← sub-module
│ ├── go.mod module example.com/widget/transports/foo
│ └── foo.go
└── plugins/
└── bar/ ← sub-module
├── go.mod module example.com/widget/plugins/bar
└── bar.go
tags: v1.2.3 ← root: BARE tag (required by go install)
transports/foo/v0.5.1 ← sub-module: PATH-PREFIXED tag (required by go get)
plugins/bar/v2.0.0- Main module at the repo root takes bare
vX.Y.Ztags.go install <module>@v1.2.3requires this. - Sub-modules in subdirectories take
<path>/vX.Y.Ztags.go get <module>/transports/foo@v1.2.3requires this.
The off-the-shelf options each have a sharp edge for this layout.
At a glance
| Capability | release-please | changesets | Knope | monorel |
|---|---|---|---|---|
| Per-package versioning | ✅ | ✅ | ✅ | ✅ |
| Auto-generated CHANGELOG | ✅ | ✅ | ✅ | ✅ |
| Pre-release / RC support | ⚠️ via Release-As: footers | ✅ | ✅ | ✅ |
| Always-open release PR | ✅ | ✅ via changesets-bot | ⚠️ via custom workflow | ✅ |
| Local CLI (works off-CI) | ⚠️ via npm package | ✅ | ✅ | ✅ |
Bare-tag root (vX.Y.Z) | ✅ | n/a (JS layout) | ❌ prefixes mandatory | ✅ |
| Path-prefixed sub-module tags | ✅ | n/a (JS layout) | ✅ | ✅ |
Cleans go.mod for proxy publish | ❌ | n/a (JS) | ❌ | ✅ |
Tidies sub-module go.sum at release | ❌ | n/a (JS) | ❌ | ✅ |
| Source of truth | Conventional Commits in git history | .changeset/*.md | configurable (commits or files) | .changeset/*.md |
| Native to | TypeScript | TypeScript | Rust | Go |
| Multi-provider | GitHub | GitHub (bot); CLI host-agnostic | GitHub / GitLab / Gitea | GitHub + Gitea / Forgejo + GitLab |
| Polyglot / non-language-specific | ✅ | ⚠️ JS-shaped (package.json per package) | ✅ | ❌ Go-only by design |
- Common ground (first three rows): every tool in this category manages independent per-package versions, writes a per-package CHANGELOG, and supports pre-release windows.
- Friction (rows below): how releases are triggered (commit messages vs explicit files), what tag shapes are supported, and which language ecosystem the tool is native to.
The per-tool sections below dive into each tool's specific friction point for the Go-monorepo layout.
release-please
Works, with friction. The friction lives in three sharp edges:
- Squash-merge strips Conventional Commits footers. GitHub's squash collapses the branch's commits into one; per-commit
Release-As:orBREAKING CHANGE:footers either get demoted into bullet-list text in the squash body or vanish, depending on the repo's squash-message defaults. Workarounds (put the footer in the PR title or body) are easy to forget. - Full-history scans on new packages leak footers. When a new package is registered, release-please scans the entire git history for Conventional Commits affecting that path. A stray
Release-As:footer anywhere in repo history can apply to it. The documented escape hatch is per-packagebootstrap-sha, but it's opt-in and easy to forget. We hit this onloglayer-go: an oldRelease-As: 1.1.0on an unrelated change set the initial version of a new sub-module tov1.1.0. exclude-pathsdoesn't catch path-attribution leaks for everything. A docs-only PR can still bump the main module if the path-attribution rules don't cover the directory. The list grows over time and is easy to forget.
These are all recoverable, but recovery means manual release-as cleanup, manual tag deletes, manual manifest fixes. The tool's mental model is "infer intent from commit history"; the failures are all variations of "the inference got confused."
Knope
Doesn't fit the bare-tag root convention. Knope per-package tag prefixes are mandatory; you can't get bare vX.Y.Z for the root module. That's a Go-specific requirement Knope wasn't built for.
changesets
Works conceptually but is JS-native. Adapting it to Go requires a synthetic package.json per Go module, a manual bridge between npm version and git tag, and a JS toolchain in the release path.
What monorel does
monorel takes the changesets idea (per-PR intent files, named affected packages, bump levels) and ships it as a Go-native CLI:
- Changeset files are the source of truth. Each release-affecting PR includes
.changeset/<name>.mdwith YAML frontmatter mapping package names to bump levels and a markdown body that becomes the changelog entry. No commit-message parsing, no path attribution, noRelease-As:footers. The class of "release tool got confused" failures becomes structurally impossible. - Tag format is per-package.
tag_prefix = ""for the main module yields barevX.Y.Z;tag_prefix = "transports/foo"for a sub-module yieldstransports/foo/vX.Y.Z. Both work in the same repo. - Always-open release PR. The bot orchestrator force-pushes a speculative-version branch and upserts a PR. Merging the PR runs
monorel releaseon the merge commit, pushes tags, and publishes per-tag releases. - Pre-release support.
monorel pre enter rcswitches the repo to release-candidate mode; subsequent releases append-rc.Nto the next stable version and increment a per-package counter.pre exitreturns to stable. - Provider-neutral. GitHub, Gitea / Forgejo, and GitLab are all wired up. The orchestrator never sees provider-specific types; add a subpackage to support a host that isn't covered.
- Clean
go.modat release time. Sub-modules carry devreplacedirectives and placeholderrequireversions for local cross-module work; monorel strips and pins them in the release commit so downstream consumers'go mod tidyresolves the published versions. - Tidy sub-module
go.sumat release time. Pinning sibling requires shifts thego.sumdrift problem onto consumers. monorel runsgo mod tidy(offline, against a seeded local cache) in every released sub-module that requires an in-plan sibling, so the release commit'sgo.sumis canonically clean. No proxy roundtrip; main isgo mod tidy-clean for every consumer on the next pull.
Where monorel sits
monorel is a CLI plus a thin GitHub Action wrapper. There is no GitHub App, no hosted service, no per-repo install beyond a workflow file. The same binary runs in CI and on your laptop.
The next page walks through wiring it up: Getting Started.
How monorel is tested
Three layers, all running under go test ./...:
- Unit tests, pure-function shape. Around 320 tests across 17 packages, table-driven where the logic is finite: the planner's version-math matrix, semver bumps, changeset frontmatter parsing,
monorel.tomlschema validation, and changelog merging. The release planner (the load-bearing part of the codebase) has 20 dedicated cases covering single-package, multi-package, max-bump, initial-release, pre-release, unknown-package, and non-semver-tag-ignore matrices. - End-to-end lifecycle scenarios against a live host. 27 scenarios under
tests/e2e/boot a Forgejo container via testcontainers-go and exercise the full pipeline against a real provider API:apply,preview,tag,publish,auto, anddoctor, plus pre-release mode, multi-modulego.modrewrites, manually-closed release-PR recovery, stale-branch overwrites, concurrent-contributor races, every error path, and the squash / rebase / merge-commit matrix. - Clean-cache regression test. Build tag
e2e_tidyrunsmonorel applyinside a freshgolang:1.26-alpinecontainer soGOMODCACHE-class bugs that the developer's local cache happens to mask surface deterministically on every machine.
CI runs the unit suite and the Forgejo suite on every PR. The clean-cache test is opt-in via its build tag and runs locally in seconds.
