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. This skill does that without changing how you develop: it only changes the
release trigger. 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.
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.
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.ref_name == github.event.repository.default_branch; keep it.
- 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. This skill does that **without changing how you develop**: it only changes the11release **trigger**. It builds directly on12[`semantic-release-automation`](../semantic-release-automation/SKILL.md) — same13plugin pipeline, same Conventional Commits; read that first.1415## The key idea: decouple *merge* from *release*1617Trunk-based development still wants frequent small merges to `main` — that's18unchanged, so **dev velocity and PR flow don't change**. The only thing you move is19**when a release is published**:2021| | Trigger | Result |22| --- | --- | --- |23| **Per-merge (default)** | `on: push: [main]` | a release every qualifying merge — many small ones |24| **Pooled (this skill)** | `workflow_dispatch` and/or `schedule` | semantic-release batches **all** commits since the last tag into **one** release |2526semantic-release already does the batching — it always releases "everything since the27last tag." Pooling just means you tag **less often**.2829A pooled run produces the **same outputs** as a per-merge one (just batched), and30those outputs are independent — CHANGELOG, GitHub Release, and npm publish are each31opt-in. A pooled **app** usually wants CHANGELOG + GitHub Release but not npm; see32[`semantic-release-automation` → Outputs are à la carte](../semantic-release-automation/SKILL.md#outputs-are-à-la-carte--pick-any-subset).33The GitHub Release is also what a deploy gate keys on (below).3435> **For a visual, human-readable walkthrough** — trigger diagrams, a merges-to-releases36> timeline, and the "why each config delta" table — see37> [`reference/pooled-vs-unpooled.md`](./reference/pooled-vs-unpooled.md).3839## Pick a model4041Use [`templates/release-on-demand.yml`](./templates/release-on-demand.yml) for the42first two; it ships with `workflow_dispatch` on and `schedule` commented.43441. **Manual button — `workflow_dispatch`** *(recommended default).* Cut a release45 when you decide it's worth one (Actions tab → Run workflow). Simplest; no surprise46 releases; no release branch.472. **Cadence — `schedule`.** Uncomment the `cron` for an automatic train (e.g. weekly).48 Good when you want predictable, regular cuts. (Combine with the button if you like.)493. **Prerelease channels — `next` (and/or `beta`).** A *related* pattern, not strictly50 pooling: push work to a `next` branch for **continuous** `x.y.z-next.N` prereleases,51 then cut a batched **stable** release by promoting `next` → `main` **when you52 choose** (that promotion is the "pooling"). Notes:53 - This model is **push-triggered on the prerelease branch**, so it pairs with the54 parent skill's push-triggered `release.yml` (`on: push: [main, next]`) — **not**55 the on-demand workflow above (which would defeat the "continuous" part). The56 `branches: [main, next]` setting itself lives in the **semantic-release config**,57 not the workflow — see58 [`templates/releaserc.prerelease-channels.json`](./templates/releaserc.prerelease-channels.json).59 - Channels are **independent** — a `next` and a `beta` channel don't flow into each60 other (no auto-promotion between them), so **pick one** unless you genuinely need61 two parallel pre-release lines. The template ships just `next`; add a62 `{ "name": "beta", "prerelease": true }` entry only if you do.63 - Promote with a normal **merge or fast-forward** of `next` into `main` — semantic-64 release cuts the stable release once the commits are reachable from `main`, either way.6566## Release branches — usually don't6768A long-lived `release/x.y` branch is real trunk-based practice but only pays off when69you must **stabilise/QA a release while trunk keeps moving**, or **support multiple70live versions** at once. It costs you hotfix cherry-picking and snapshot maintenance.71For a single-version app/CLI, **skip it** — pooling via trigger (above) gives you the72batched releases without the branch overhead.7374## What changes vs the per-merge setup7576Start from `semantic-release-automation`'s `release.yml` and:7778- **Replace the trigger** — drop `on: push: [main]`; add `workflow_dispatch` (and/or79 `schedule`).80- **Drop the no-loop guard** — with no `on: push`, the `chore(release): …` commit81 semantic-release pushes can't re-trigger the workflow, so the82 `if: !startsWith(... 'chore(release):')` guard is unnecessary.83- **Everything else is identical** — plugin pipeline, `fetch-depth: 0`,84 `$GITHUB_SERVER_URL` repo URL, tokens, npm publish.8586## Gotchas8788- **`schedule` only runs once the workflow file is on the default branch** — merge it89 to `main` before expecting cron to fire; `cron` is UTC.90- **`fetch-depth: 0` still required** (full history + tags for the batched analysis).91- **`concurrency: { group: release }`** so a manual run and a scheduled run can't92 collide on the tag push.93- **`workflow_dispatch` lets the runner pick any branch** — the Actions UI branch94 dropdown (or `gh workflow run --ref <branch>`) means a manual run could analyze a95 feature/prerelease branch instead of `main`. The template guards this with a96 job-level `if: github.ref_name == github.event.repository.default_branch`; keep it.97- **Big gaps → big changelogs.** That's the point, but communicate the cadence so98 contributors know when their merged work actually ships.99- **Pooling does NOT gate production deploys — wire that separately.** If a platform100 (Vercel/Netlify) auto-deploys `main` on every push, it keeps shipping prod on every101 *merge*, defeating the point of batching. Gate it with102 [`production-release-gating`](../production-release-gating/SKILL.md) so prod ships on103 the **train**, not on merges. The most portable gate is its **Promotion Branch**104 model: `main` → staging on every merge, promote `production` to the released commit105 to ship prod — no platform-specific script.106- **Don't add `on: push` to the on-demand workflow without re-adding the loop guard.**107 With only `workflow_dispatch`/`schedule`, the `chore(release): …` commit can't108 re-trigger it (no guard needed). If you later add `on: push` (e.g. for hotfixes),109 re-add `if: !startsWith(github.event.head_commit.message, 'chore(release):')` or it110 will loop.111112## Verify113114- `npx semantic-release --dry-run` on `main` prints the **batched** next version and115 the full accumulated release notes — without publishing.116- Manual: Actions tab → the workflow → **Run workflow**. Scheduled: confirm the cron117 in the default-branch copy of the file.118119## See also120121- [`reference/pooled-vs-unpooled.md`](./reference/pooled-vs-unpooled.md) — visual122 walkthrough (diagrams, timeline, config-delta table) for humans skimming the design.123- [`semantic-release-automation`](../semantic-release-automation/SKILL.md) — the124 pipeline this reuses; pooling only swaps the trigger.125- [`conventional-commits`](../conventional-commits/SKILL.md) — what the batched126 changelog is built from.127- [`production-release-gating`](../production-release-gating/SKILL.md) — deploy on the128 pooled release rather than on every merge.129130## Sources131132- semantic-release branches/channels config (prerelease `next`/`beta`):133 <https://semantic-release.gitbook.io/semantic-release/usage/configuration#branches>134- Trunk-based development & release strategies: <https://trunkbaseddevelopment.com/>,135 Atlassian's TBD guide, and the semantic-release TBD discussion (#2041). The136 decouple-merge-from-release framing and "release branches only when you must137 stabilise/QA or support multiple live versions" come from those.