Skip to content

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.Z tags. go install <module>@v1.2.3 requires this.
  • Sub-modules in subdirectories take <path>/vX.Y.Z tags. go get <module>/transports/foo@v1.2.3 requires this.

The off-the-shelf options each have a sharp edge for this layout.

At a glance

Capabilityrelease-pleasechangesetsKnopemonorel
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 tagsn/a (JS layout)
Cleans go.mod for proxy publishn/a (JS)
Tidies sub-module go.sum at releasen/a (JS)
Source of truthConventional Commits in git history.changeset/*.mdconfigurable (commits or files).changeset/*.md
Native toTypeScriptTypeScriptRustGo
Multi-providerGitHubGitHub (bot); CLI host-agnosticGitHub / GitLab / GiteaGitHub + 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: or BREAKING 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-package bootstrap-sha, but it's opt-in and easy to forget. We hit this on loglayer-go: an old Release-As: 1.1.0 on an unrelated change set the initial version of a new sub-module to v1.1.0.
  • exclude-paths doesn'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>.md with YAML frontmatter mapping package names to bump levels and a markdown body that becomes the changelog entry. No commit-message parsing, no path attribution, no Release-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 bare vX.Y.Z; tag_prefix = "transports/foo" for a sub-module yields transports/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 release on the merge commit, pushes tags, and publishes per-tag releases.
  • Pre-release support. monorel pre enter rc switches the repo to release-candidate mode; subsequent releases append -rc.N to the next stable version and increment a per-package counter. pre exit returns 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.mod at release time. Sub-modules carry dev replace directives and placeholder require versions for local cross-module work; monorel strips and pins them in the release commit so downstream consumers' go mod tidy resolves the published versions.
  • Tidy sub-module go.sum at release time. Pinning sibling requires shifts the go.sum drift problem onto consumers. monorel runs go mod tidy (offline, against a seeded local cache) in every released sub-module that requires an in-plan sibling, so the release commit's go.sum is canonically clean. No proxy roundtrip; main is go 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.toml schema 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, and doctor, plus pre-release mode, multi-module go.mod rewrites, 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_tidy runs monorel apply inside a fresh golang:1.26-alpine container so GOMODCACHE-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.

Released under the MIT License.