Release branches in a monorepo Jump to heading
In a single-product repository, a release branch means “this is version 2.4”. In a monorepo, it is less clear. Is release/2.4 a release of the web app, the mobile backend, the billing service, or all of them? If it is all of them, every team is tied to the same release cadence, and a fix one team needs forces a release branch for everyone. If each product cuts its own, release branches multiply and shared libraries end up in many versions at once. The workable middle ground is per-product release branches, scoped by name and by path: each product cuts its own, backports touch only that product’s paths and the shared libraries it uses, and rules prevent a product’s release branch from becoming a place where unrelated code changes. This page sets that up, within monorepo branch topology.
When to use this approach Jump to heading
- Several independently released products live in one repository.
- Some products need release branches — for stabilisation or long-term support — and others release continuously.
- A shared release branch forces teams onto one cadence.
- Packages are versioned independently, as in tagging packages independently in a monorepo.
Step 1 — Name release branches by product Jump to heading
Include the product in the branch name, so the branch states what it releases and rules can target it.
git switch -c release/billing-api/2.4 origin/main
git push -u origin release/billing-api/2.4
git branch -r --list 'origin/release/*' | sed 's#origin/release/##'
# billing-api/2.4
# web/2026.10 Step 2 — Limit which paths a release branch may change Jump to heading
A product’s release branch should change only that product and the shared libraries it depends on. A required check compares the files changed in a pull request against an allow-list derived from the product’s dependency set.
#!/bin/sh
# ci/release-scope.sh <product> — fail if a PR to release/<product>/* touches other paths
set -eu
product=$1
allowed=$(python3 ci/deps_for.py "$product") # e.g. services/billing libs/money libs/auth-client
base=$(git merge-base "origin/$BASE_REF" HEAD)
outside=$(git diff --name-only "$base" HEAD | while read -r f; do
ok=no; for a in $allowed; do case "$f" in "$a"/*) ok=yes ;; esac; done
[ "$ok" = yes ] || echo "$f"
done)
[ -z "$outside" ] || { echo "Paths outside $product's scope:"; echo "$outside"; exit 1; } The dependency set comes from the same graph used in detecting affected projects from git diff.
Step 3 — Backport fixes with path awareness Jump to heading
Fixes land on main first and are cherry-picked to the product’s release branch. A fix commit on main sometimes touches several products at once; cherry-pick only what the release branch needs, or split the fix on main so each product’s part is its own commit.
# Cherry-pick only the billing part of a multi-product fix
git switch release/billing-api/2.4
git cherry-pick -x --no-commit abc1234
git restore --staged --worktree -- apps/ services/export/ # drop paths outside this product
git commit -C abc1234 --trailer "Backport-Scope: billing-api" Labelled backports work here too, with one label per product release branch, as in automating backport pull requests with labels.
Step 4 — Decide the rule for shared libraries Jump to heading
The hard question in a monorepo is what happens when a shared library needs a fix on a release branch. Two rules work; mixing them does not.
- Libraries follow the product. The release branch carries its own copy of the library’s state, and library fixes are backported per product. Simple, and each product’s release is self-contained.
- Libraries are released separately. Libraries have their own versions and tags, products pin a version, and release branches update the pin rather than the library code.
# Rule 1: library fix backported into one product's release branch
git cherry-pick -x <money-lib-fix>
# Rule 2: bump the pinned library version on the release branch instead
sed -i 's/"@acme\/money": "1.8.2"/"@acme\/money": "1.8.3"/' services/billing/package.json Step 5 — Protect release branches per product Jump to heading
Protection rules target the naming pattern, with owners per product. Each team controls its own release branches; nobody can push to another product’s.
# CODEOWNERS on release branches
/services/billing/ @acme/billing
/libs/money/ @acme/billing @acme/payments-platform # Ruleset: release/billing-api/* — PR required, billing team approval, no force-push
gh api "repos/$OWNER/$REPO/rulesets" --jq '.[] | select(.name|startswith("release-")) | .name' Step 6 — Retire release branches when products move on Jump to heading
Products that stop supporting a version lock its branch. In a monorepo, stale release branches are particularly costly, because tooling scanning all branches — affected detection, security scanners — keeps processing them.
Validation checklist Jump to heading
Frequently Asked Questions Jump to heading
Should the whole monorepo ever have one release branch? Jump to heading
When all products ship together as one release — an installable suite, for example — a single release branch is the honest model. The per-product approach is for repositories whose products have independent lifecycles.
How do tags work with per-product branches? Jump to heading
Tags include the product too, such as [email protected], so they are unambiguous across the repository; see the tagging guide for conventions.
What about CI on release branches? Jump to heading
Run only the product’s affected set, computed against the release branch’s own base, plus a full build of that product. Running the whole monorepo’s suite on every release branch wastes time on code that cannot have changed.
Related Jump to heading
- Monorepo Branch Topology — the parent topic.
- One Branching Strategy for Many Teams — the wider strategy around these branches.
- Supporting Multiple Maintained Versions — support windows per product.
- Path-Based CODEOWNERS in a Monorepo — the ownership that scopes approval.