|
| 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