Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

12. Cut releases with git tags and forge releases, not release-plz

Date: 2026-09-18

Status

Accepted. Supersedes the release automation set up in 0002: Monorepo structure with four crates and every subsequent .release-plz.toml fix. Closes issue #64.

Context

ci/woodpecker/push/release was red on every push to main. The failure was:

ERROR failed to determine next versions
Caused by:
    0: run cargo package
    1: cargo package failed: "   Packaging cerebro-core v0.2.2 (.../worktree/crates/cerebro-core)
         error: failed to verify manifest at `.../worktree/crates/cerebro-mcp/Cargo.toml`
         Caused by:
           all dependencies must have a version requirement specified when packaging.
           dependency `cerebro-core` does not specify a version"

Eight configuration variants were tried and discarded on a clean tree (issue #64 lists them): publish_no_verify, allow_dirty, semver_check = false, release = false, git_only = false, dropping the version key from [workspace.dependencies], aligning [workspace.package] version with the tags, deleting the newest tags. Two release-plz versions, 0.3.159 and 0.3.165, fail identically.

The reason none of them could work is that this is not a configuration problem. Every crate in the workspace is publish = false, and the binaries depend on their siblings by path:

$ cargo package -p cerebro-mcp
error: failed to prepare local package for uploading
Caused by:
  no matching package named `cerebro-core` found
  location searched: crates.io index

cerebro-core, cerebro-tui and cerebro-mcp return 404 on crates.io, and the name cerebro is taken there by an unrelated crate. Packaging strips the path from a dependency and resolves it in a registry, so cargo package cannot succeed for any crate in this workspace. It never could. release-plz shells out to cargo package even with git_only = true, which is why the pipeline is red and why no knob on it helps. Upstream tracks this as release-plz#2595 and #2651, with the actual fix in #2789, unmerged at the time of writing.

Nothing here is published. Installation is cargo install --path crates/cerebro (see docs/quick-start.md), and a release is a git tag plus a forge page someone can read. The tags were doing that job already:

$ git cat-file -p cerebro-v0.2.3
tag cerebro-v0.2.3
tagger Gitea <gitea@fake.local> 1779942905 +0200

The tagger is the forge, not a person. Creating a release through the API with a tag that does not exist yet makes Codeberg cut the tag itself. So the one release-plz step that worked did not need release-plz.

That leaves a tool whose remaining value here is: read conventional commits, propose a semver bump, write a changelog. git-cliff does all three, and does not need a registry.

Decision

Drop release-plz. A release is two halves with a human and a merge in between.

Local half, just release. scripts/release.sh prepare asks git-cliff for the next version from the commit types since the last cerebro-v* tag, inserts the rendered section into CHANGELOG.md, rewrites [workspace.package] version, refreshes Cargo.lock so the resolved versions move with the manifest, and commits all three on a release/cerebro-v<version> branch. It prints the push and fj pr create commands and touches nothing remote. It refuses a dirty tree and refuses to re-use a tagged version.

Forge half, CI. .woodpecker/release.yml has one release step, on push to main: scripts/release.sh publish. It reads the version from Cargo.toml. If a matching tag exists, the answer is “already released” and exit 0, which is the normal result of most pushes. Otherwise it takes that version’s CHANGELOG.md section as the body and posts one release to the forge API, which creates the tag pointing at the commit CI just built.

There is no crates.io step, and no publish on a tag event: the forge makes the tag, so a tag-triggered pipeline would be a loop waiting to happen.

cliff.toml owns the section format. [workspace.dependencies] loses the hand-maintained version = "0.2" ranges on the internal crates, since a path dependency in a workspace that is never packaged needs no range, and that key is what cargo printed the misleading “does not specify a version” line from.

scripts/release-test.sh drives the script against scratch git repositories with the forge call stubbed, covering: which line bump rewrites, next across patch/minor/major/nothing, whether notes finds one section and stops at the next, and the publish decisions (no-op, publish once, stand down after the tag exists, refuse without notes, refuse without a token, --dry-run sends nothing). It runs in CI’s test step and inside just test.

Consequences

Pros

  • The pipeline is green on pushes that are not releases, which is most of them.
  • A release is reviewed as a pull request before any tag exists. The old flow had the same shape by accident, but the notes came from a tool that could not verify the tree.
  • What CI publishes is the changelog section a human read in the release PR, not a second rendering from a different tool.
  • The version in Cargo.toml and the newest tag cannot drift silently: the tags cerebro-v0.2.2 and cerebro-v0.2.3 both point at the same commit while the manifest read 0.2.2, and no release page exists for either.
  • One tool less in the CI image. The release step needs git, curl, and bash.

Cons

  • Version bumps are no longer automatic on merge. Someone runs just release. That is a deliberate trade: this repo shipped three tags in five months, and the automation that was supposed to cover it has never once run cleanly end to end.
  • No crates.io presence, so cargo install cerebro from the registry stays unavailable. It was already unavailable, and the name is taken.
  • A release PR that is merged without its changelog section makes CI red rather than publishing something thin. Loud and self-explanatory, but still red.
  • git-cliff is now a required local tool for releasing, where before CI installed it on demand.

Migration

Nothing to do for users. For anyone with a release-plz habit:

just release-status   # current version vs. what the commits propose
just release          # branch + changelog + bump, one commit, nothing remote
just release-preview  # the tag and notes CI would publish, no forge traffic
bash scripts/release-test.sh

The 0.2.2 and 0.2.3 tags stay as they are. Both are older than main, and rewriting published tags to fix a mismatch is a worse trade than leaving a pair of releases unannounced. The next just release moves to 0.3.0, which is what the commits since cerebro-v0.2.3 ask for.

Notes

  • CEREBRO_RELEASE_TOKEN carries the forge token in CI, from the existing API Token secret. Locally release.sh also accepts CODEBERG_ACCESS_TOKEN, which fj already uses.
  • The first release this flow cut (0.3.0) left Cargo.lock resolving the workspace at 0.2.2, because nothing refreshed it after the manifest bump. prepare now runs cargo metadata --offline and commits the lock with the manifest and changelog. cargo would have rewritten it on the next build, which is why the disagreement is invisible until someone clones a tag.
  • Every path the script reads is overridable (CEREBRO_RELEASE_REPO_DIR, CEREBRO_RELEASE_CARGO_TOML, CEREBRO_RELEASE_CHANGELOG, CEREBRO_RELEASE_API, CEREBRO_RELEASE_REPO). The tests need that to point a scratch repo and a stub curl at the script.
  • git-cliff --prepend is not used. It re-emits its own header each run, which duplicates the preamble in CHANGELOG.md. prepend_changelog in the script splices the rendered section in above the first existing section, so the preamble belongs to the file.
  • git-cliff’s body template is written on one line with explicit newlines. With the indented multi-line form, git-cliff 2.14 emits a newline for every loop tag, which renders each bullet as its own one-item list. scripts/release-test.sh asserts against that with loose_bullets.
  • Group names in cliff.toml carry an HTML comment prefix (<!-- 0 -->Features) because group_by sorts keys as strings. striptags removes the prefix, and the prefix is what keeps Features above Bug Fixes.
  • build, ci, chore, style and test commits are skip = true. They are in the history, they are how the repo works, and they are not release notes.