Replacing release-please: why we built tallyrelease after three years of release PRs

After three years of release-please across 150+ repositories, we built tallyrelease: one dependency graph for builds and releases, build-only units, and prereleases without committed state.

André LorethAndré Loreth··10 min read
Share
Three white tiles with grey outlines on the left, each joined by an orange line to one large orange tile on the right.

For about three years, release-please has cut the releases of most of our 150+ repositories, and of the monorepos among them. It never failed us in a way anybody remembers. There was no incident, no bad release that cost a weekend. What we have instead is a long list of small things we worked around, and at some point the workarounds outweighed the tool. This post is about what we replaced it with, why, and who should not follow us.

Three years of release-please

Release-please does the central thing well. It reads Conventional Commits, keeps a release PR up to date as commits land, and cuts a tag and a GitHub release when somebody merges it. The release PR is a review point we have come to rely on: a human sees the next version and the changelog before anything ships. The changelog format is good enough that we never wanted to change it.

The friction came in four areas, and none of them was large on its own.

Monorepos. One of our monorepos has five web apps and a handful of shared packages. Every app wanted its own release PR, so we ended up with separate pull requests and a manifest that changed in ways nobody wanted to read. The more packages a repository had, the noisier the release PRs got, and the harder it was to tell which of them mattered.

Config we could not bend. Versioning rules, changelog layout and tag naming each had a setting, until they did not. We kept release-please-config.json and .release-please-manifest.json side by side in every repository and learned which questions the config could answer and which it could not.

Upstream. Fixes arrived slowly, issues stayed open, and the forks we carried for the cases we could not live without drifted. Maintaining a fork of a release tool is a poor use of a small team.

Workflow fit. Our pipeline is CI, GitOps and OCI publishing. We needed a plan we could read before it ran, a list of releases to hand to publishers, and a way to build only what a change touched. Release-please does not set out to be that, and the gaps showed up as glue code in our shared workflow. Our monorepos carried a prerelease-channel: beta input there, which existed only so the workflow and release-please agreed on a prerelease label.

Why not semantic-release or Changesets

We looked at both and ruled them out for the same reason: they replace the workflow we like. Changesets asks contributors to write an intent file per change, which means a second artifact next to the commit and something to enforce in review. semantic-release releases straight from the pipeline on every merge, so the PR where a human approves the version disappears.

Our flow is PR-based and driven by Conventional Commits, and we are not willing to give up either half. The commit message already says what the change is. The release PR is already where we check what it will do. Any replacement had to keep both. None of the tools we looked at did, so we wrote our own.

What we kept

tallyrelease keeps the parts that worked. It reads Conventional Commits, honors Release-As footers, opens release PRs and writes changelogs in the release-please format. Its default tags are compatible with release-please’s ({id}-v{version}, and v{version} for a root unit), so a repository can switch without retagging. Some of our monorepos tag apps as app-{id}-v{version}, so we set tagFormat to match and kept every existing tag.

We did not want a flag day where every existing tag changed shape, or a changelog that suddenly looked different on Monday.

The graph is the hard part

The hardest thing to get right in a release tool for monorepos is the dependency and version graph. Which packages does a change reach, and which of them get a new version because of it? Release-please matches commits to packages by path and, with its workspace plugins, patches the internal dependents. That works until you have internal packages that nobody ever releases.

In tallyrelease, builds and releases come from one graph. Plugins discover the units of the repository and the edges between them (node, php and kustomize are built in, and anything else can be declared as a config-only unit that versions through git tags). The same graph answers “what do we build for this change” and “what do we release”. We did not want two models of the repository that could disagree.

The second decision is that units are build-only unless you opt them in with release: true. That is a deliberate break from release-please. Take a shared packages/ui in a monorepo of web apps. It is an internal library that no registry ever sees, and nobody wants to read a changelog for it. In the old setup it was either versioned for no reason or invisible to the release logic. Now it is a build-only unit. A fix: in it is absorbed into every released app that depends on it, as a patch by default, and the changelog of the app lists the change with the unit it came through.

A change in a build-only package is absorbed into the released apps that depend on it

flowchart LR
  ui["packages/ui (build-only)"]
  tsconfig["packages/tsconfig (build-only)"]
  a["apps/web (released)"]
  b["apps/docs (released)"]
  c["apps/admin (released)"]
  ui -->|"fix: absorbed as patch"| a
  ui -->|"fix: absorbed as patch"| b
  ui -->|"fix: absorbed as patch"| c
  tsconfig -.->|"chore: no bump"| a

The rules around that are small and worth knowing. bumps.absorbed sets whether absorbed changes count as patch (the default) or minor. A chore: gives nothing, so a tweak to a shared tsconfig reaches the apps and bumps none of them. An absorbed change never bumps a unit by major, because it breaks in-repo callers, not the consumers of the released unit. A real break of an app is a commit attributed to that app. defaults.propagate (auto, patch or rebuild) decides what happens between released units that depend on each other.

The configuration for all of this is one file. This is a trimmed version of the tallyrelease.yaml from one of our monorepos, with the app names changed:

tallyrelease.yaml
plugins:
node: {}
defaults:
tagFormat: app-{id}-v{version}
releasePr: separate
branches:
- name: main
line: default
channel: beta
units:
apps/web: { release: true }
apps/docs: { release: true }
apps/admin: { release: true }
globalInputs:
- .tool-versions

The packages are not listed because the node plugin finds them from the workspace and leaves them build-only. releasePr: separate gives each app its own release PR, which is what we had before. globalInputs is for files that rebuild every unit when they change.

Prereleases and maintenance lines without committed state

The other thing we wanted to lose was state in the repository. Release-please keeps its truth in a manifest that is committed, and prerelease handling is configuration layered on top of it. tallyrelease computes a prerelease version from git history: {next}-{channel}.{N}, where N is the number of first-parent commits since the last stable release on that line. next is the version the plan would release, so a repository whose last stable tag is 1.4.0 and whose next release is a minor bump produces 1.5.0-beta.4 on the fourth commit. Nothing is written to the repository and no registry is queried.

The channel comes from a branch rule, which is why the prerelease-channel input is gone from our shared workflow. The rule for main in the file above is the only place the word beta appears.

Maintenance lines work the same way. A rule such as release/{major}.x caps bumps at minor, and release/{major}.{minor}.x caps them at patch. If a commit on such a branch would break the cap, the plan fails and names the offending commits, instead of quietly producing a version the line should never carry.

Two lines of releases. On main, a 1.4.0 tag is followed by four beta prereleases numbered one to four in orange, then a 1.5.0 tag. A branch leaves 1.4.0 and carries 1.4.1 and 1.4.2.
The beta counter and the maintenance patches are both read from the same git history.

Because the version is a function of history, you can ask for it before it exists. tallyrelease plan shows the versions, bumps and the commits behind them, tallyrelease explain <unit> says why a unit is affected, and --dry-run previews effects without performing them. The planning core does no I/O and is deterministic for a given repository state, so the answer you see locally is the answer CI will compute.

Switching on evidence: shadow mode

A release tool is not something we wanted to swap on faith. tallyrelease has a shadow command for exactly this: release-please keeps cutting your releases as before, and on every release tallyrelease reports what it would have done instead. Every disagreement shows up as a row, either labelled with a known cause or flagged for a human to look at.

We ran our own repositories in shadow before switching any of them. The switch itself was a small change: it deleted release-please-config.json and .release-please-manifest.json and one workflow input. If you migrate, do the same. Run both tools side by side, read the reports, and switch when they stop surprising you.

When you should still use release-please

Release-please is a good tool and there are real reasons to stay on it. Choose it if you need one of its many release types (Java, Python, Rust, Go, Helm, Terraform and more), because tallyrelease ships plugins for node, php and kustomize and everything else is a config-only unit versioned through tags. Choose it if you want a mature tool with a long track record. And choose it if you need linked versions, which tallyrelease does not support yet.

Also keep in mind that tallyrelease is in beta. Until 1.0, any 0.x minor release may contain breaking changes to config, CLI flags and outputs, so pin an exact version and read the changelog before upgrading. Release effects target GitHub only, it requires Node 24 or later and full git history, and it does not publish to registries: it hands a list of releases to whatever publishes for you. If you publish npm packages and want that built in, Changesets is the better match.

Try it

tallyrelease

Affected builds and release plans for monorepos, from one dependency graph. Conventional Commits, release PRs, tags and GitHub releases.

TypeScript00 forksApache-2.0

The smallest useful configuration is a plugin and one released unit:

tallyrelease.yaml
plugins:
node: {}
units:
packages/api:
release: true

Install @tallyrelease/cli, run tallyrelease check to validate the config and the graph, and then tallyrelease plan to see what the current branch would release. If you are coming from release-please, start with the shadow comparison so you can see where the two disagree on your history before anything changes. The source is Apache-2.0 on GitHub.

We built tallyrelease with help from AI coding assistants, and the maintainers review the design, code and tests.

For how this fits into the rest of our setup, see 150+ repositories, one small team for the fleet it runs on and Standards? Standards! for the conventions that made a Conventional Commits requirement realistic in the first place.

This article was written with the support of artificial intelligence. Given what we do here, it would have been strange not to.

Share