Pooled releases (release trains)
Release on every merge is great for a fast-moving lib, but sometimes you want
fewer, fatter releases — one readable changelog per cut, not a version bump per
PR. For the manual and scheduled models it does that without changing how you
develop — it only changes the release trigger (the prerelease-channel model in
Model 3 does shift where you target work; see there). It builds directly on
semantic-release-automation — same
plugin pipeline, same Conventional Commits; read that first.
The key idea: decouple merge from release
Trunk-based development still wants frequent small merges to main — that's
unchanged, so dev velocity and PR flow don't change. The only thing you move is
when a release is published:
|
Trigger |
Result |
| Per-merge (default) |
on: push: [main] |
a release every qualifying merge — many small ones |
| Pooled (this skill) |
workflow_dispatch and/or schedule |
semantic-release batches all commits since the last tag into one release |
semantic-release already does the batching — it always releases "everything since the
last tag." Pooling just means you tag less often.
A pooled run produces the same outputs as a per-merge one (just batched), and
those outputs are independent — CHANGELOG, GitHub Release, and npm publish are each
opt-in. A pooled app usually wants CHANGELOG + GitHub Release but not npm; see
semantic-release-automation → Outputs are à la carte.
The GitHub Release is also what a deploy gate keys on (below).
For a visual, human-readable walkthrough — trigger diagrams, a merges-to-releases
timeline, and the "why each config delta" table — see
reference/pooled-vs-unpooled.md.
Pick a model
Use templates/release-on-demand.yml for the
first two; it ships with workflow_dispatch on and schedule commented.
- Manual button —
workflow_dispatch (recommended default). Cut a release
when you decide it's worth one (Actions tab → Run workflow). Simplest; no surprise
releases; no release branch.
- Cadence —
schedule. Uncomment the cron for an automatic train (e.g. weekly).
Good when you want predictable, regular cuts. (Combine with the button if you like.)
- Prerelease channels —
next (and/or beta). A related pattern, not strictly
pooling: push work to a next branch for continuous x.y.z-next.N prereleases,
then cut a batched stable release by promoting next → main when you
choose (that promotion is the "pooling"). Notes:
- This model is push-triggered on the prerelease branch, so it pairs with the
parent skill's push-triggered
release.yml (on: push: [main, next]) — not
the on-demand workflow above (which would defeat the "continuous" part). The
branches: [main, next] setting itself lives in the semantic-release config,
not the workflow — see
templates/releaserc.prerelease-channels.json.
- Channels are independent — a
next and a beta channel don't flow into each
other (no auto-promotion between them), so pick one unless you genuinely need
two parallel pre-release lines. The template ships just next; add a
{ "name": "beta", "prerelease": true } entry only if you do.
- Promote with a normal merge or fast-forward of
next into main — semantic-
release cuts the stable release once the commits are reachable from main, either way.
- Known quirk: the template runs
@semantic-release/changelog + git on the
next branch too, so each x.y.z-next.N prerelease commits its own CHANGELOG
entries; the stable cut then regenerates notes for the same commits. If the
duplicated prerelease sections bother you, scope the changelog/git plugins to
stable only (e.g. via a per-branch config) or accept the noise on next.
Release branches — usually don't
A long-lived release/x.y branch is real trunk-based practice but only pays off when
you must stabilise/QA a release while trunk keeps moving, or support multiple
live versions at once. It costs you hotfix cherry-picking and snapshot maintenance.
For a single-version app/CLI, skip it — pooling via trigger (above) gives you the
batched releases without the branch overhead.
What changes vs the per-merge setup
Start from semantic-release-automation's release.yml and:
- Replace the trigger — drop
on: push: [main]; add workflow_dispatch (and/or
schedule).
- Drop the no-loop guard — with no
on: push, the chore(release): … commit
semantic-release pushes can't re-trigger the workflow, so the
if: !startsWith(... 'chore(release):') guard is unnecessary.
- Everything else is identical — plugin pipeline,
fetch-depth: 0,
$GITHUB_SERVER_URL repo URL, tokens, npm publish.
Gotchas
schedule only runs once the workflow file is on the default branch — merge it
to main before expecting cron to fire; cron is UTC.
- The branch guard must special-case
schedule. On scheduled runs
github.event.repository is empty, so a bare
if: github.ref_name == github.event.repository.default_branch evaluates to
main == '' → false and silently skips every cron run. The template's guard
short-circuits with github.event_name == 'schedule' || (safe — schedule only
fires on the default branch); keep that clause if you adapt the guard.
fetch-depth: 0 still required (full history + tags for the batched analysis).
concurrency: { group: release } so a manual run and a scheduled run can't
collide on the tag push.
workflow_dispatch lets the runner pick any branch — the Actions UI branch
dropdown (or gh workflow run --ref <branch>) means a manual run could analyze a
feature/prerelease branch instead of main. The template guards this with a
job-level if: github.event_name == 'schedule' || github.ref_name == github.event.repository.default_branch;
keep it. (semantic-release-automation's push-triggered release.yml carries the
same guard on its workflow_dispatch path, so the two templates stay consistent —
a manual dispatch on either can only cut a release from the default branch.)
- Big gaps → big changelogs. That's the point, but communicate the cadence so
contributors know when their merged work actually ships.
- Pooling does NOT gate production deploys — wire that separately. If a platform
(Vercel/Netlify) auto-deploys
main on every push, it keeps shipping prod on every
merge, defeating the point of batching. Gate it with
production-release-gating so prod ships on
the train, not on merges. The most portable gate is its Promotion Branch
model: main → staging on every merge, promote production to the released commit
to ship prod — no platform-specific script.
- Don't add
on: push to the on-demand workflow without re-adding the loop guard.
With only workflow_dispatch/schedule, the chore(release): … commit can't
re-trigger it (no guard needed). If you later add on: push (e.g. for hotfixes),
re-add if: !startsWith(github.event.head_commit.message, 'chore(release):') or it
will loop.
Verify
npx semantic-release --dry-run on main prints the batched next version and
the full accumulated release notes — without publishing.
- Manual: Actions tab → the workflow → Run workflow. Scheduled: confirm the cron
in the default-branch copy of the file.
See also
Sources
1---2name: pooled-release3description: Cut fewer, batched releases (a 'release train') instead of one per merge — by triggering semantic-release on demand or on a cadence rather than on every push to main. Use when releasing on every merge is too noisy, when you want one larger readable changelog per release, to add a manual 'cut a release' button (workflow_dispatch) or a scheduled/weekly release, to set up prerelease channels (next/beta → promote to stable), or when deciding whether you need a release branch.4---56# Pooled releases (release trains)78Release on every merge is great for a fast-moving lib, but sometimes you want9**fewer, fatter releases** — one readable changelog per cut, not a version bump per10PR. For the manual and scheduled models it does that **without changing how you11develop** — it only changes the release **trigger** (the prerelease-channel model in12Model 3 does shift where you target work; see there). It builds directly on13[`semantic-release-automation`](../semantic-release-automation/SKILL.md) — same14plugin pipeline, same Conventional Commits; read that first.1516## The key idea: decouple *merge* from *release*1718Trunk-based development still wants frequent small merges to `main` — that's19unchanged, so **dev velocity and PR flow don't change**. The only thing you move is20**when a release is published**:2122| | Trigger | Result |23| --- | --- | --- |24| **Per-merge (default)** | `on: push: [main]` | a release every qualifying merge — many small ones |25| **Pooled (this skill)** | `workflow_dispatch` and/or `schedule` | semantic-release batches **all** commits since the last tag into **one** release |2627semantic-release already does the batching — it always releases "everything since the28last tag." Pooling just means you tag **less often**.2930A pooled run produces the **same outputs** as a per-merge one (just batched), and31those outputs are independent — CHANGELOG, GitHub Release, and npm publish are each32opt-in. A pooled **app** usually wants CHANGELOG + GitHub Release but not npm; see33[`semantic-release-automation` → Outputs are à la carte](../semantic-release-automation/SKILL.md#outputs-are-à-la-carte--pick-any-subset).34The GitHub Release is also what a deploy gate keys on (below).3536> **For a visual, human-readable walkthrough** — trigger diagrams, a merges-to-releases37> timeline, and the "why each config delta" table — see38> [`reference/pooled-vs-unpooled.md`](./reference/pooled-vs-unpooled.md).3940## Pick a model4142Use [`templates/release-on-demand.yml`](./templates/release-on-demand.yml) for the43first two; it ships with `workflow_dispatch` on and `schedule` commented.44451. **Manual button — `workflow_dispatch`** *(recommended default).* Cut a release46 when you decide it's worth one (Actions tab → Run workflow). Simplest; no surprise47 releases; no release branch.482. **Cadence — `schedule`.** Uncomment the `cron` for an automatic train (e.g. weekly).49 Good when you want predictable, regular cuts. (Combine with the button if you like.)503. **Prerelease channels — `next` (and/or `beta`).** A *related* pattern, not strictly51 pooling: push work to a `next` branch for **continuous** `x.y.z-next.N` prereleases,52 then cut a batched **stable** release by promoting `next` → `main` **when you53 choose** (that promotion is the "pooling"). Notes:54 - This model is **push-triggered on the prerelease branch**, so it pairs with the55 parent skill's push-triggered `release.yml` (`on: push: [main, next]`) — **not**56 the on-demand workflow above (which would defeat the "continuous" part). The57 `branches: [main, next]` setting itself lives in the **semantic-release config**,58 not the workflow — see59 [`templates/releaserc.prerelease-channels.json`](./templates/releaserc.prerelease-channels.json).60 - Channels are **independent** — a `next` and a `beta` channel don't flow into each61 other (no auto-promotion between them), so **pick one** unless you genuinely need62 two parallel pre-release lines. The template ships just `next`; add a63 `{ "name": "beta", "prerelease": true }` entry only if you do.64 - Promote with a normal **merge or fast-forward** of `next` into `main` — semantic-65 release cuts the stable release once the commits are reachable from `main`, either way.66 - **Known quirk:** the template runs `@semantic-release/changelog` + `git` on the67 `next` branch too, so each `x.y.z-next.N` prerelease commits its own CHANGELOG68 entries; the stable cut then regenerates notes for the same commits. If the69 duplicated prerelease sections bother you, scope the `changelog`/`git` plugins to70 stable only (e.g. via a per-branch config) or accept the noise on `next`.7172## Release branches — usually don't7374A long-lived `release/x.y` branch is real trunk-based practice but only pays off when75you must **stabilise/QA a release while trunk keeps moving**, or **support multiple76live versions** at once. It costs you hotfix cherry-picking and snapshot maintenance.77For a single-version app/CLI, **skip it** — pooling via trigger (above) gives you the78batched releases without the branch overhead.7980## What changes vs the per-merge setup8182Start from `semantic-release-automation`'s `release.yml` and:8384- **Replace the trigger** — drop `on: push: [main]`; add `workflow_dispatch` (and/or85 `schedule`).86- **Drop the no-loop guard** — with no `on: push`, the `chore(release): …` commit87 semantic-release pushes can't re-trigger the workflow, so the88 `if: !startsWith(... 'chore(release):')` guard is unnecessary.89- **Everything else is identical** — plugin pipeline, `fetch-depth: 0`,90 `$GITHUB_SERVER_URL` repo URL, tokens, npm publish.9192## Gotchas9394- **`schedule` only runs once the workflow file is on the default branch** — merge it95 to `main` before expecting cron to fire; `cron` is UTC.96- **The branch guard must special-case `schedule`.** On scheduled runs97 `github.event.repository` is empty, so a bare98 `if: github.ref_name == github.event.repository.default_branch` evaluates to99 `main == ''` → false and **silently skips every cron run**. The template's guard100 short-circuits with `github.event_name == 'schedule' ||` (safe — `schedule` only101 fires on the default branch); keep that clause if you adapt the guard.102- **`fetch-depth: 0` still required** (full history + tags for the batched analysis).103- **`concurrency: { group: release }`** so a manual run and a scheduled run can't104 collide on the tag push.105- **`workflow_dispatch` lets the runner pick any branch** — the Actions UI branch106 dropdown (or `gh workflow run --ref <branch>`) means a manual run could analyze a107 feature/prerelease branch instead of `main`. The template guards this with a108 job-level `if: github.event_name == 'schedule' || github.ref_name == github.event.repository.default_branch`;109 keep it. (`semantic-release-automation`'s push-triggered `release.yml` carries the110 same guard on its `workflow_dispatch` path, so the two templates stay consistent —111 a manual dispatch on either can only cut a release from the default branch.)112- **Big gaps → big changelogs.** That's the point, but communicate the cadence so113 contributors know when their merged work actually ships.114- **Pooling does NOT gate production deploys — wire that separately.** If a platform115 (Vercel/Netlify) auto-deploys `main` on every push, it keeps shipping prod on every116 *merge*, defeating the point of batching. Gate it with117 [`production-release-gating`](../production-release-gating/SKILL.md) so prod ships on118 the **train**, not on merges. The most portable gate is its **Promotion Branch**119 model: `main` → staging on every merge, promote `production` to the released commit120 to ship prod — no platform-specific script.121- **Don't add `on: push` to the on-demand workflow without re-adding the loop guard.**122 With only `workflow_dispatch`/`schedule`, the `chore(release): …` commit can't123 re-trigger it (no guard needed). If you later add `on: push` (e.g. for hotfixes),124 re-add `if: !startsWith(github.event.head_commit.message, 'chore(release):')` or it125 will loop.126127## Verify128129- `npx semantic-release --dry-run` on `main` prints the **batched** next version and130 the full accumulated release notes — without publishing.131- Manual: Actions tab → the workflow → **Run workflow**. Scheduled: confirm the cron132 in the default-branch copy of the file.133134## See also135136- [`reference/pooled-vs-unpooled.md`](./reference/pooled-vs-unpooled.md) — visual137 walkthrough (diagrams, timeline, config-delta table) for humans skimming the design.138- [`semantic-release-automation`](../semantic-release-automation/SKILL.md) — the139 pipeline this reuses; pooling only swaps the trigger.140- [`conventional-commits`](../conventional-commits/SKILL.md) — what the batched141 changelog is built from.142- [`production-release-gating`](../production-release-gating/SKILL.md) — deploy on the143 pooled release rather than on every merge.144145## Sources146147- semantic-release branches/channels config (prerelease `next`/`beta`):148 <https://semantic-release.gitbook.io/semantic-release/usage/configuration#branches>149- Trunk-based development & release strategies: <https://trunkbaseddevelopment.com/>,150 Atlassian's TBD guide, and the semantic-release TBD discussion (#2041). The151 decouple-merge-from-release framing and "release branches only when you must152 stabilise/QA or support multiple live versions" come from those.