Skip to content

Commit b870282

Browse files
authored
Merge pull request #373 from knockout/plans/single-action-release
plan: collapse release to a single human action
2 parents 7840419 + 4d9be2a commit b870282

1 file changed

Lines changed: 393 additions & 0 deletions

File tree

plans/single-action-release.md

Lines changed: 393 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,393 @@
1+
# Plan: Single-Action Release
2+
3+
**Goal**: Reduce the TKO release flow from a multi-step manual dance (tag,
4+
wait, hand-open PR, merge, force-tag, wait) to a single human action — merge
5+
the auto-generated version PR. Everything else runs unattended.
6+
7+
This is a Dark Factory plan: the maintainer's job is direction, not
8+
choreography.
9+
10+
---
11+
12+
## Original State (May 2026)
13+
14+
`release.yml` triggers on `push: tags: v*`. The intended flow is:
15+
16+
1. Maintainer pushes `vX.Y.Z` tag.
17+
2. `prepare-release` runs `changesets/action@v1`. If there are pending
18+
changesets, it commits version bumps to a branch and opens a "chore:
19+
version packages" PR.
20+
3. Maintainer reviews and merges that PR.
21+
4. Maintainer force-moves the tag to the merged commit and re-pushes.
22+
5. `prepare-release` re-runs, sees no changesets, gates in the `publish`
23+
job. `changeset publish` runs to npm via OIDC.
24+
6. `github-release` creates the matching GitHub Release.
25+
26+
### What actually happened on the 4.1.0 cut
27+
28+
- Step 2 produced the bumped branch `changeset-release/refs/tags/v4.1.0`
29+
but **did not open a PR**. `changesets/action@v1` was designed for
30+
`push: branches` triggers; under tag-push it can produce the branch and
31+
silently skip PR creation. The branch name itself leaks the tag ref
32+
(`changeset-release/refs/tags/v4.1.0` instead of the usual
33+
`changeset-release/main`), which is the symptom.
34+
- A human had to hand-open the PR (#372) wrapping the bot-generated
35+
branch.
36+
- Step 4's force-push tag is a footgun — easy to forget to `git pull`
37+
first and tag the wrong commit.
38+
39+
## Pain points
40+
41+
| Current step | Time/risk cost |
42+
|---|---|
43+
| Push initial tag | Trivial, but obscures intent ("am I starting a release or testing CI?") |
44+
| Wait for action to maybe-open a PR | Action is flaky under tag triggers (see above) |
45+
| Hand-open the PR if action skipped | Pure admin |
46+
| Merge PR | Necessary (review + click) |
47+
| Force-move tag, re-push | Footgun: must be on merge commit |
48+
| Wait for second workflow run | Pure latency |
49+
50+
Four of six steps are admin or latency. The one with maintainer
51+
judgment (review + merge) is buried.
52+
53+
---
54+
55+
## Target State
56+
57+
**Trigger**: `push: branches: [main]`.
58+
59+
**Flow**:
60+
1. Feature PR with a changeset merges to `main`.
61+
2. `release.yml` runs:
62+
- Pending changesets exist → `changesets/action` opens or updates the
63+
"chore: version packages" PR. Done.
64+
3. Maintainer accumulates PRs over time. When ready to release, **merges
65+
the version PR**.
66+
4. `release.yml` re-runs:
67+
- No pending changesets → `publish-and-tag` job runs. `changeset publish`
68+
publishes per-package versions on npm via OIDC, then a post-publish
69+
step creates a single repo-wide `vX.Y.Z` tag and a matching GitHub
70+
Release.
71+
72+
**Single human action: merge the version PR.** Everything before and
73+
after is automation.
74+
75+
---
76+
77+
## Mechanism
78+
79+
### `release.yml` changes
80+
81+
Two-job design preserves least-privilege isolation: `prepare` only needs
82+
PR-write to open/update the version PR (no OIDC); `publish-and-tag` only
83+
needs OIDC and contents-write (no PR-write). They communicate via a
84+
single output (`should_publish`).
85+
86+
```yaml
87+
on:
88+
push:
89+
branches: [main]
90+
91+
# Serialize releases against themselves so two near-simultaneous main
92+
# pushes (e.g. version-PR merge + a doc PR merge) cannot race two
93+
# parallel publishes or tag creations.
94+
concurrency: ${{ github.workflow }}-${{ github.ref }}
95+
96+
jobs:
97+
prepare:
98+
name: Open or update version PR
99+
runs-on: ubuntu-latest
100+
permissions:
101+
contents: write
102+
pull-requests: write
103+
outputs:
104+
should_publish: ${{ steps.changesets.outputs.hasChangesets == 'false' }}
105+
steps:
106+
- uses: actions/checkout@v6
107+
with:
108+
# changesets/action commits version bumps via the GitHub API
109+
# (commitMode: github-api) so we deliberately disable persisted
110+
# credentials — there is no `git push` from this job.
111+
persist-credentials: false
112+
113+
- uses: oven-sh/setup-bun@v2
114+
with:
115+
bun-version-file: .tool-versions
116+
117+
- uses: actions/setup-node@v6
118+
with:
119+
node-version: 24.x
120+
registry-url: 'https://registry.npmjs.org'
121+
122+
- run: bun install --frozen-lockfile
123+
124+
- name: Open or update version PR
125+
id: changesets
126+
uses: changesets/action@v1
127+
with:
128+
version: npx changeset version
129+
title: 'chore: version packages'
130+
commit: 'chore: version packages'
131+
# Required when persist-credentials: false — without this the
132+
# action falls back to git-cli, which has no remote auth and
133+
# fails the push. github-api also produces a verified commit
134+
# authored by github-actions[bot].
135+
commitMode: github-api
136+
env:
137+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
138+
139+
publish-and-tag:
140+
name: Publish to npm + tag repo
141+
needs: prepare
142+
if: needs.prepare.outputs.should_publish == 'true'
143+
runs-on: ubuntu-latest
144+
permissions:
145+
# contents:write is required to push the repo-wide vX.Y.Z tag.
146+
# PRs are not modified from this job.
147+
contents: write
148+
id-token: write # npm OIDC trusted publishing
149+
steps:
150+
- uses: actions/checkout@v6
151+
# persist-credentials defaults to true, which is required for the
152+
# post-publish `git push origin "$tag"`.
153+
154+
- uses: oven-sh/setup-bun@v2
155+
with:
156+
bun-version-file: .tool-versions
157+
158+
- uses: actions/setup-node@v6
159+
with:
160+
# npm trusted publishing requires npm CLI 11.5.1+
161+
node-version: 24.x
162+
registry-url: 'https://registry.npmjs.org'
163+
164+
- run: bun install --frozen-lockfile
165+
166+
# Build is gated on the publish path so doc-only / plan-only main
167+
# pushes do not pay the cost.
168+
- run: bun run build
169+
170+
# Tests run before publish so a regression caught only in the
171+
# browser matrix cannot ship to npm. main-build.yml is parallel,
172+
# not a gate; this is the gate.
173+
- run: bun run test
174+
175+
- name: Determine release version
176+
id: version
177+
run: |
178+
version="$(node tools/release-version.cjs)"
179+
echo "version=$version" >> "$GITHUB_OUTPUT"
180+
181+
- name: Publish packages
182+
# changeset publish creates per-package git tags by default
183+
# (e.g. @tko/utils@4.1.0). With the .changeset/config.json
184+
# `fixed` group all 27 @tko/* packages share one version, so the
185+
# per-package tags carry no information beyond the repo-wide
186+
# vX.Y.Z. We suppress them via --no-git-tag and rely solely on
187+
# the post-publish step below for the single tag.
188+
run: npx changeset publish --no-git-tag
189+
190+
# Order: create GH release first via a single API call that also
191+
# creates the tag ref, then verify. This avoids the failure mode
192+
# where `git tag && git push` succeeds and the subsequent
193+
# `gh release create` fails, leaving an orphan tag. `gh release
194+
# create` errors if the tag already exists, which is itself a
195+
# guard against the "no-changeset push to main" re-publish case
196+
# (changeset publish would no-op against npm; this step refuses
197+
# to re-tag).
198+
- name: Create GitHub release
199+
env:
200+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
201+
VERSION: ${{ steps.version.outputs.version }}
202+
TARGET_SHA: ${{ github.sha }}
203+
run: |
204+
tag="v${VERSION}"
205+
# Tighten prerelease matching: only match canonical pre-release
206+
# suffixes anchored after the final hyphen, not substrings.
207+
prerelease_flag=""
208+
case "$VERSION" in
209+
*-alpha|*-alpha.*|*-beta|*-beta.*|*-rc|*-rc.*)
210+
prerelease_flag="--prerelease"
211+
;;
212+
esac
213+
gh release create "$tag" \
214+
--repo "$GITHUB_REPOSITORY" \
215+
--target "$TARGET_SHA" \
216+
--title "TKO ${VERSION}" \
217+
--generate-notes \
218+
$prerelease_flag
219+
```
220+
221+
Key shape changes from current `release.yml`:
222+
223+
- **Trigger** flips from `push: tags: v*` to `push: branches: [main]`.
224+
- **Two jobs** instead of three (`prepare-release` / `publish` /
225+
`github-release` → `prepare` / `publish-and-tag`).
226+
- **`prepare` keeps least-privilege** — only `contents:write` +
227+
`pull-requests:write`, never holds OIDC.
228+
- **`publish-and-tag` keeps OIDC** but never gets PR-write.
229+
- **No tag-driven entry point.** The repo-wide `vX.Y.Z` tag is created
230+
by a single `gh release create` call that creates both the release
231+
and the underlying tag ref, by reading the bumped version from
232+
`tools/release-version.cjs`.
233+
- **`changesets/action` runs only the `version` path.** Publish is
234+
invoked explicitly by the second job, so we have control over what
235+
runs between version-bump and publish (build, test, version
236+
read-back).
237+
238+
### Removed pieces
239+
240+
- Force-pushed tag dance — gone.
241+
- Tag-vs-version validation step — gone for *tag typos* (the tag is
242+
generated from the version). The same validator was also catching
243+
`tools/release-version.cjs` errors when public-package versions
244+
drift; that error path is preserved because `release-version.cjs`
245+
still runs in the `Determine release version` step and exits non-zero
246+
on drift.
247+
- Separate `prepare-release` / `publish` / `github-release` jobs —
248+
collapsed into two.
249+
250+
### Kept pieces
251+
252+
- npm trusted publishing via OIDC (`id-token: write` on
253+
`publish-and-tag`).
254+
- `github-release.yml` as a manual fallback to backfill a release that
255+
the post-publish step missed (rare, but worth keeping).
256+
- `publish-check.yml` on PRs (validates packages are publishable before
257+
they hit main). **Caveat**: PRs opened by `changesets/action` use
258+
`GITHUB_TOKEN`, and GitHub by default does not trigger
259+
`pull_request` workflows on PRs authored by `GITHUB_TOKEN`. The
260+
version PR will therefore not run `publish-check.yml` on its own. We
261+
accept this — the version PR's diff is mechanical (changeset bumps
262+
+ changelog appends) and the publish path itself runs the same
263+
validation. If we want triggers, switch the action's token to a
264+
scoped GitHub App / PAT.
265+
266+
### Failure modes
267+
268+
- **Partial publish.** `changeset publish` publishes packages
269+
serially. If one of 27 fails mid-loop, npm has a partial release and
270+
the workflow exits non-zero before the tag step runs — no tag, no GH
271+
release. Recovery: re-run the workflow (already-published packages
272+
are skipped by `changeset publish`); the tag step then runs once the
273+
full set succeeds. If the partial state is unrecoverable (e.g. a
274+
yanked-then-rebumped version is required), `github-release.yml`
275+
remains the manual backfill.
276+
- **Tag/release dual-failure.** `gh release create` creates the tag
277+
ref atomically with the release, so the previous "tag exists,
278+
release missing" failure mode is closed. If `gh release create`
279+
fails entirely, no tag exists either; rerun.
280+
- **Doc-only main pushes.** Every merge to `main` invokes `prepare`,
281+
including doc-only or plan-only PRs. `prepare` runs `bun install
282+
--frozen-lockfile` plus the changesets call (~1–3 min cold; faster
283+
with cache, not added in this plan). Only the `publish-and-tag` job
284+
— gated on `should_publish == 'true'` — pays the build/test cost.
285+
The concurrency block also queues a doc-only push behind any
286+
in-flight `publish-and-tag` for the same ref; doc-only latency
287+
during a release window is the price of serialization.
288+
- **No-changeset main push triggers `publish-and-tag`.** If a
289+
maintainer pushes directly to `main` without a changeset (or merges
290+
a no-changeset PR), `prepare` reports `hasChangesets == 'false'` and
291+
the publish job runs. `changeset publish` is a no-op against npm
292+
(already-published versions skip), and `gh release create` refuses
293+
to overwrite an existing tag, so the worst case is a noisy failed
294+
workflow run. No data loss; review the run, ignore.
295+
296+
---
297+
298+
## Tradeoffs
299+
300+
- **No more "I tag when I want to release."** The maintainer decides via
301+
PR merge, not via tag push. This is the canonical changesets pattern
302+
and arguably clearer — "I merge the version PR" is one action with
303+
visible review surface.
304+
- **Releases batch by version-PR cadence.** Multiple feature PRs land,
305+
each adding a changeset, all accumulate in the version PR. Maintainer
306+
merges when the batch feels release-worthy. Still gives full control
307+
over timing.
308+
- **The version PR auto-updates on every main push.** Each new merged
309+
changeset rebumps it. Maintainers can preview the next release at any
310+
time by reading the open PR.
311+
- **`prepare` job runs on every main push, including doc-only.**
312+
~1–3 min cold install + changesets call. Non-zero CI minutes plus
313+
queueing latency under concurrency. Accepted; see Failure modes
314+
above. A future PR can add `actions/cache` over `~/.bun` and
315+
`node_modules` to cut this further.
316+
- **Per-package npm tags retained, per-package git tags suppressed.**
317+
npm dist-tags (`@tko/utils@4.1.0`) are how consumers install; they
318+
stay. Per-package git tags add noise without information given the
319+
fixed group; `--no-git-tag` removes them.
320+
- **Bot version PR does not run `publish-check.yml`.** See *Kept
321+
pieces* caveat. Acceptable trade because the change is mechanical.
322+
Cheaper alternatives to a scoped App/PAT exist if we want PR-time
323+
validation: switch the workflow to `pull_request_target` (fires for
324+
bot-authored PRs, but runs against the *base* ref with elevated
325+
perms — only safe because the version PR is bot-generated, not a
326+
fork PR), or trigger `publish-check.yml` on `push` to the
327+
`changeset-release/main` branch.
328+
329+
---
330+
331+
## Phasing
332+
333+
The plan-only PR (#373) is the proposal and ships first so reviewers
334+
can argue with the design without YAML to wade through. The
335+
implementation PR ships the `release.yml` rewrite + AGENTS.md update
336+
**together** so the running workflow and the documented procedure
337+
never disagree.
338+
339+
Between merge of #373 and merge of the implementation PR, AGENTS.md
340+
§ Release Process still describes the tag-push flow — that's
341+
intentional: AGENTS.md describes the *current* workflow, not the
342+
planned one. Plans live in `plans/`.
343+
344+
1. **Land plan** (this PR, #373) — proposal only, no behavior change.
345+
2. **Implementation PR**:
346+
- Replace `.github/workflows/release.yml` with the two-job design
347+
above.
348+
- Update `AGENTS.md` § Release Process in the same commit: drop
349+
force-push-tag instructions, replace with "merge the version PR;
350+
the workflow handles publish + tag + GH release".
351+
- Update `AGENTS.md` workflows table row for `release.yml` (trigger
352+
column changes from "Tag push (`v*`)" to "Push to `main`").
353+
- Verify with a no-op patch release: one trivial changeset (e.g.
354+
typo fix), watch the version PR appear, merge, watch publish run,
355+
confirm tag + release. This exercises the happy path only;
356+
partial-publish recovery and unrecoverable-publish paths are
357+
covered by re-running the workflow on synthetic failures in a
358+
follow-up dry-run, not the initial cut.
359+
3. **Optional follow-up**: enable auto-merge on the version PR once
360+
it's green and approved, gated by a label like `release-ready`.
361+
That gets the flow to *zero* human actions for trivial releases
362+
(still one for anything needing review).
363+
364+
---
365+
366+
## Out of scope
367+
368+
- **Changelog reform** (top-level vs per-package narrative). Tracked
369+
separately — that's about the artifact shape, not the trigger
370+
pipeline.
371+
- **Pre-release / canary channels.** The tag-suffix branching for
372+
`-alpha`/`-beta`/`-rc` in the post-publish step preserves the
373+
existing prerelease conventions, but a full canary pipeline
374+
(auto-publishing every main commit to `next`) is a separate plan.
375+
- **Switching to a scoped GitHub App for the release token.** Would
376+
enable `publish-check.yml` on the bot version PR and is a strict
377+
improvement, but introduces a secret-management dependency. Track
378+
separately.
379+
380+
---
381+
382+
## Verification
383+
384+
- A test patch release runs end-to-end without human intervention beyond
385+
merging the version PR.
386+
- `gh release list` shows `vX.Y.Z` matching the npm-published version.
387+
- npm `dist-tag ls @tko/utils` shows `latest: X.Y.Z`.
388+
- The repo-wide `vX.Y.Z` tag in git points at the merge commit of the
389+
version PR.
390+
- No per-package git tags (`@tko/utils@X.Y.Z`) created (suppressed by
391+
`--no-git-tag`).
392+
- Doc-only main pushes invoke only `prepare`; `publish-and-tag` is
393+
skipped (visible in workflow run summary).

0 commit comments

Comments
 (0)