@entro314labs/release-kit 2.9.4 → 2.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (5) hide show
  1. package/README.md +221 -42
  2. package/TRAIN.md +78 -41
  3. package/package.json +3 -3
  4. package/release.mjs +671 -47
  5. package/train.mjs +328 -33
package/README.md CHANGED
@@ -10,7 +10,7 @@
10
10
  [![downloads](https://img.shields.io/npm/dm/@entro314labs/release-kit?color=cb3837)](https://www.npmjs.com/package/@entro314labs/release-kit)
11
11
  [![unpacked size](https://img.shields.io/npm/unpacked-size/@entro314labs/release-kit?color=blueviolet)](https://www.npmjs.com/package/@entro314labs/release-kit?activeTab=code)
12
12
  [![dependencies](https://img.shields.io/badge/dependencies-0-brightgreen)](#-requirements)
13
- [![node](https://img.shields.io/badge/node-%E2%89%A5%2022-339933?logo=node.js&logoColor=white)](#-requirements)
13
+ [![node](https://img.shields.io/badge/node-%E2%89%A5%2024-339933?logo=node.js&logoColor=white)](#-requirements)
14
14
  [![license](https://img.shields.io/badge/license-MIT-blue)](LICENSE)
15
15
 
16
16
  </div>
@@ -97,7 +97,7 @@ release-kit minor
97
97
  No global state to drift, no install step, and the version is explicit in the command.
98
98
 
99
99
  ```sh
100
- npx @entro314labs/release-kit@2.3.0 minor --yes
100
+ npx @entro314labs/release-kit@2.9.4 minor --yes
101
101
  ```
102
102
 
103
103
  ### Vendored — no registry at release time
@@ -127,7 +127,7 @@ Pin the URL to a tag, never `main`: piping an unpinned remote script into an int
127
127
  means whatever is at that URL runs against your repository and your credentials. `--sync` is
128
128
  the one thing that does not work this way — copying itself needs a file on disk.
129
129
 
130
- > **All five paths run the same file and need Node 22+.** That includes the Rust, Python and
130
+ > **All five paths run the same file and need Node 24+.** That includes the Rust, Python and
131
131
  > Go projects: `release-kit` is a Node program regardless of what it is releasing.
132
132
 
133
133
  Zero-config works on the conventions below; add a [`release.config.json`](#️-configuration)
@@ -164,7 +164,8 @@ footer) is a major, a `feat:` is a minor, anything else is a patch. Below `1.0.0
164
164
  softened — a breaking change bumps the minor rather than jumping to `1.0.0`. A commit body
165
165
  containing `Release-As: 2.0.0` pins the version outright. It always prints what it inferred
166
166
  and why before doing anything. Set `"versioning"` to `always-patch`, `always-minor` or
167
- `always-major` to never infer.
167
+ `always-major` to never infer. A `Notes:` line in a body sets what the release notes say
168
+ about that commit — see [Release notes](#release-notes).
168
169
 
169
170
  | Target | From `1.2.3` | From `2.0.0-beta.1` |
170
171
  | ------------------------------------ | ------------------------------------------------ | ------------------- |
@@ -185,22 +186,23 @@ Prerelease bumps need `--preid` unless the current version already carries one t
185
186
 
186
187
  ### Flags
187
188
 
188
- | Flag | Effect |
189
- | ---------------------------- | ----------------------------------------------------------------------------- |
190
- | `--only <steps>` | Run only these steps, comma-separated. |
191
- | `--skip <steps>` | Run every step except these. |
192
- | `--commit` | Force the `commit` step on when a `steps` config removed it. |
193
- | `--dry-run` | Print every step, execute nothing. Preflight still runs and still reports. |
194
- | `--yes`, `-y` | Skip the confirmation prompt. |
195
- | `--preid <id>` | Prerelease identifier: `alpha`, `beta`, `rc`, `next`, `nightly`, `canary`. |
196
- | `--dist-tag <name>` | Override the npm dist-tag. Always wins over the derived one. |
197
- | `--notes <source>` | Where notes come from: `auto`, `changelog`, `assistant`, `commits`, `github`. |
198
- | `--notes-file <path>` | Write the resolved notes to a file for the next tool — see `notesFile`. |
199
- | `--assistant <name>` | Drafting CLI: `auto`, `none`, `claude`, `codex`. |
200
- | `--assistant-model <name>` | Model the assistant runs with. |
201
- | `--assistant-effort <level>` | Reasoning effort the assistant runs with. |
202
- | `--sync <dir>...` | Copy this script into other projects and exit. Touches no git state. |
203
- | `--help`, `-h` | Full flag list. |
189
+ | Flag | Effect |
190
+ | ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
191
+ | `--only <steps>` | Run only these steps, comma-separated. |
192
+ | `--skip <steps>` | Run every step except these. |
193
+ | `--commit` | Force the `commit` step on when a `steps` config removed it. |
194
+ | `--package` | Release the package in this directory, one of several in the repository: its `release.config.json`, manifest, changelog and publish; commits and working tree limited to the directory; tagged `<name>@<version>` unless `tagPrefix` is set. release-train passes it. Without it, a nested package is refused. |
195
+ | `--dry-run` | Print every step, execute nothing. Preflight still runs and still reports. |
196
+ | `--yes`, `-y` | Skip the confirmation prompt. |
197
+ | `--preid <id>` | Prerelease identifier for a `pre*` bump: `alpha`, `beta`, `rc`, `next`, `nightly`, `canary`. |
198
+ | `--dist-tag <name>` | Override the npm dist-tag. Always wins over the derived one. |
199
+ | `--notes <source>` | Where notes come from: `auto`, `changelog`, `assistant`, `commits`, `github`. |
200
+ | `--notes-file <path>` | Write the resolved notes to a file for the next tool — see `notesFile`. |
201
+ | `--assistant <name>` | Drafting CLI: `auto`, `none`, `claude`, `codex`. |
202
+ | `--assistant-model <name>` | Model the assistant runs with. |
203
+ | `--assistant-effort <level>` | Reasoning effort the assistant runs with. |
204
+ | `--sync <dir>...` | Copy this script into other projects and exit. Touches no git state. |
205
+ | `--help`, `-h` | Full flag list. |
204
206
 
205
207
  ### Linting commits
206
208
 
@@ -227,7 +229,7 @@ commit released is the **pull request title**, which no hook ever sees — check
227
229
  - name: Lint the pull request title
228
230
  env:
229
231
  TITLE: ${{ github.event.pull_request.title }}
230
- run: npx @entro314labs/release-kit@2.8.0 lint-commits --subject "$TITLE"
232
+ run: npx @entro314labs/release-kit@2.9.4 lint-commits --subject "$TITLE"
231
233
  ```
232
234
 
233
235
  Pass the title through `env`, never through `${{ }}` inside `run:` — a pull request title is
@@ -288,8 +290,11 @@ Notes resolve in this order:
288
290
  2. The `## [Unreleased]` section, if the version has no section of its own — this is the
289
291
  same content that step 2 above is about to promote.
290
292
  3. The commits grouped by Conventional Commit type — Features, Bug Fixes, Performance
291
- Improvements, Reverts, with breaking changes first and chores, CI and docs hidden. Each
292
- bullet links to its commit, and `closes #12` / `fixes #34` in a message becomes a link to
293
+ Improvements, Reverts, Dependencies, Documentation, Code Refactoring, Build System,
294
+ Continuous Integration, Tests, Styles, Miscellaneous Chores — with breaking changes
295
+ first. Every type is reported, since a changelog is a record; list the ones to leave out
296
+ in `hiddenTypes` — `["chore", "ci", "docs"]`, say, for a shorter list. A type outside
297
+ the table, such as `security:`, lands under Other Changes. Each bullet links to its commit, and `closes #12` / `fixes #34` in a message becomes a link to
293
298
  the issue. A `BREAKING CHANGE:` footer is used in place of the subject, since it explains
294
299
  the break. A commit reverted within the same release drops out along with its revert.
295
300
  A **New Contributors** section names anyone whose first commit to the repository is in
@@ -299,6 +304,20 @@ Notes resolve in this order:
299
304
  rather than something that requires an assistant.
300
305
  4. Otherwise GitHub generates them from the commits since the previous tag.
301
306
 
307
+ **A `Notes:` line in a commit body is the author's wording for the entry.** Commit-derived
308
+ notes use it in place of the subject (and in place of a `BREAKING CHANGE:` footer), and an
309
+ [assistant](#-assistant-optional) is handed it with the commit and told to keep it.
310
+ `Notes: no-notes` leaves the commit out of the notes entirely — both kinds — for work that
311
+ has to be a `fix:` or `feat:` for the version bump but is not news to anyone upgrading. The
312
+ bump itself is unaffected: the trailer decides what is said about a change, not whether it
313
+ counts.
314
+
315
+ ```text
316
+ fix(ui): raise the badge z-index above the progress bar
317
+
318
+ Notes: The pull request badge stays visible while a push is in progress
319
+ ```
320
+
302
321
  **Which tag the history is read from** is the highest version tag carrying the configured
303
322
  prefix that is reachable from `HEAD` — not the nearest tag. A repository carrying tags that
304
323
  are not releases (a rolling `latest-beta` marker, a `nightly`) is unaffected by them, and a
@@ -308,7 +327,11 @@ patch tagged on top of a later minor does not drag the baseline backwards.
308
327
  `2.0.0-rc.1` and `-rc.2` reads history from the last _stable_ tag, so the notes describe
309
328
  everything the release ships rather than the gap between the last two candidates — which
310
329
  is usually just the release commit. Releasing a candidate is unchanged: each one's notes
311
- say what changed in that candidate.
330
+ say what changed in that candidate. The candidates' own changelog sections are not a source
331
+ for the stable release: its notes are generated from those commits, so wording edited into
332
+ a `[2.0.0-rc.1]` section does not carry over. The sections stay in the file, preflight warns
333
+ when it finds them, and writing the `2.0.0` notes into `[Unreleased]` before releasing is
334
+ how hand-written wording reaches the stable release.
312
335
 
313
336
  `--notes <source>` forces one instead of walking that list: `changelog`, `assistant`,
314
337
  `commits`, or `github`. A named source that produces nothing is an error rather than a
@@ -366,9 +389,13 @@ rather than stopping at the first problem.
366
389
  - `gh` is installed and authenticated
367
390
  - Commit and tag signing can actually sign, and the key is one GitHub will accept
368
391
  - The publishing CLI is authenticated, and the version is not already published
392
+ - A crate that `cargo publish` will upload packages cleanly (`cargo package`) and fits
393
+ crates.io's 10 MiB upload limit — see [below](#crates-are-packaged-in-preflight)
369
394
  - The previous release actually reached the registry — one that did not is either finished
370
395
  by this run or absorbed into it _(warning)_
371
396
  - Configured release assets exist
397
+ - With `"requireGreen": true`, HEAD is on the remote and GitHub reports it green — see
398
+ [below](#requiring-a-green-head)
372
399
  - The configured `verify` command passes — the project's own gate (tests, build) runs
373
400
  before anything mutates, instead of a `prepublishOnly` hook failing after the commit,
374
401
  tag and push
@@ -378,10 +405,60 @@ rather than stopping at the first problem.
378
405
  previous tag reachable it passes, without one it fails `auto` (the bump would be inferred
379
406
  from partial history) and warns otherwise
380
407
  - A changelog section for the version exists _(warning — it falls back to generated notes)_
408
+ - Notes drafted by an [assistant](#-assistant-optional) carry no entry it marked `[???]`
409
+ as unsure — refused under `--yes` or without a terminal, listed for review at the prompt
410
+ otherwise
381
411
 
382
412
  Under `--dry-run` the failures are reported and then the remaining steps are shown anyway,
383
413
  so you can see the whole plan without fixing the blockers first.
384
414
 
415
+ ### Crates are packaged in preflight
416
+
417
+ `cargo publish` packages and verifies the crate as its first act, so a file your
418
+ `include`/`exclude` left out, metadata crates.io refuses, a crate that does not build from
419
+ its own archive, or an archive over crates.io's 10 MiB limit used to surface only after the
420
+ release was tagged and pushed. When a `cargo publish` command is going to run, preflight
421
+ runs `cargo package` with the same arguments first — `-p`, `--workspace`,
422
+ `--manifest-path`, `--features` and `--no-verify` carry over, so a workspace packages each
423
+ crate it will publish — and refuses on a failure or an oversized `.crate`.
424
+
425
+ - It packages the tree before the version bump. That is enough because the bump writes
426
+ the version into `Cargo.toml` _and_ `Cargo.lock` together, so the tree `cargo publish`
427
+ sees differs only in the version number, which changes neither the file list nor the
428
+ size. A `Cargo.lock` left on the old version would dirty the tree after the push instead:
429
+ it is detected beside a `Cargo.toml` version source, and a configured `versionFiles` that
430
+ writes `Cargo.toml` without it is refused while a `cargo publish` is going to run.
431
+ - `--locked` is added when the repository has a `Cargo.lock`; `--allow-dirty` only when the
432
+ tree is dirty (which preflight reports on its own, or the `commit` step is about to fix).
433
+ - Like `cargo publish`, it compiles the crate, so preflight takes a build longer. Put
434
+ `--no-verify` on the publish command if you want neither to compile.
435
+ - A publish command that is not a plain `cargo publish …` — a pipeline, quoting, an
436
+ environment prefix — is left alone, since what it uploads cannot be read off it.
437
+ - The 10 MiB is crates.io's default; it can raise the limit for a single crate on request.
438
+
439
+ ### Requiring a green HEAD
440
+
441
+ `verify` runs your gate locally. `"requireGreen": true` asks GitHub instead, before anything
442
+ is bumped: the release is refused unless the commit being released is on the remote (a
443
+ local commit has no checks, so HEAD must not be ahead of `origin/<branch>`) and its checks
444
+ passed. A dirty tree the `commit` step would fold in is refused too — CI never saw it.
445
+
446
+ - **Failed** — any check run concluding `failure`, `cancelled`, `timed_out`,
447
+ `action_required` or `stale`, or a commit status of `failure` or `error`. The failing
448
+ checks are named.
449
+ - **Not finished** — any check run still queued or in progress, or a status still
450
+ `pending`: wait for it and re-run.
451
+ - **Nothing checked** — no check run or status reporting `success`. Zero checks, or only
452
+ skipped ones, means nothing verified the commit, which is not the same as green.
453
+
454
+ Both of GitHub's systems are read — check runs (`commits/{sha}/check-runs`, what Actions
455
+ and GitHub Apps report) and commit statuses (`commits/{sha}/status`, what external CI such
456
+ as Buildkite or Jenkins still posts) — because either one alone can call a commit green
457
+ while the other has it red. It needs `gh`, authenticated, even with the `release` step off.
458
+ Run inside GitHub Actions, the check runs of the current workflow run are ignored: the job
459
+ doing the release is itself an unfinished check on that commit. Ordering against other jobs
460
+ in the same workflow is what `needs:` is for.
461
+
385
462
  ## ♻️ Recovering from a failed run
386
463
 
387
464
  Re-run the same command. Every step is idempotent:
@@ -407,7 +484,9 @@ version the dead run already wrote: `minor` after a dead `minor` would release `
407
484
  from the commit `v1.1.0` already tags. Preflight refuses both shapes of that — the bump
408
485
  written but never committed, and the tag at `HEAD` that never reached the registry — and
409
486
  names the command that finishes the earlier release: `release-kit 1.1.0`, or no target, or
410
- `auto`.
487
+ `auto`. With `"publish": null` there is no registry to ask, so a tag at `HEAD` that the
488
+ remote does not have marks the unfinished release instead: a push that was refused is
489
+ finished by `auto` too, and a relative bump over it is refused the same way.
411
490
 
412
491
  ### A release that was never published
413
492
 
@@ -579,6 +658,12 @@ too, and are refreshed by the tool that owns them rather than rewritten by patte
579
658
  version sits in more than one place and the formats change shape between tool versions.
580
659
  Each is scoped to its manifest, so a `uv.lock` for a component this release is not
581
660
  versioning stays out of the release commit, and a missing tool warns rather than aborting.
661
+
662
+ A `Cargo.lock` beside a detected `Cargo.toml` is kept in step without being listed, whether
663
+ `Cargo.toml` is the version source (a plain crate) or a companion manifest. A configured
664
+ `versionFiles` is never extended, so list the lockfile there yourself; when a `cargo publish`
665
+ is going to run and a written `Cargo.toml` would leave its `Cargo.lock` on the old version,
666
+ preflight refuses rather than letting `cargo publish` fail on the dirty tree after the push.
582
667
  `pnpm-lock.yaml` records no root version, so it never goes stale.
583
668
 
584
669
  ### Marking the line instead of writing a pattern
@@ -655,7 +740,9 @@ later — and the overlays that carry no `version` of their own are skipped rath
655
740
  failing the release.
656
741
 
657
742
  Stopping at `push` because the tag is what triggers the build pipeline — see
658
- [Libraries versus apps](#-libraries-versus-apps).
743
+ [Libraries versus apps](#-libraries-versus-apps). When release-kit itself runs in CI, the
744
+ push must not use `GITHUB_TOKEN`, or the tag triggers nothing — see
745
+ [Continuous integration](#continuous-integration).
659
746
 
660
747
  The project name comes from the manifest when there is one (`name` in `package.json`,
661
748
  `Cargo.toml` or `pyproject.toml`), and falls back to the repository directory.
@@ -667,6 +754,7 @@ release with the second bin in this package:
667
754
 
668
755
  ```sh
669
756
  release-train --dry-run # plan + whole-train preflight, execute nothing
757
+ release-train # the same, then confirm and release (--yes without a terminal)
670
758
  ```
671
759
 
672
760
  `train.mjs` derives the dependency graph and publish order from the package manifests
@@ -674,13 +762,14 @@ release-train --dry-run # plan + whole-train preflight, execute nothing
674
762
  the per-package worker, rewrites internal ranges, and refuses the whole train before
675
763
  anything mutates if any package would fail. A `train.config.json` declares only which
676
764
  directories are members. Design, configuration and the full pipeline are in
677
- [TRAIN.md](TRAIN.md). Prototype status: planning, preflight and `seed-tags` work;
678
- execution is not wired up yet.
765
+ [TRAIN.md](TRAIN.md). A member that shares its git repository with other members is
766
+ released with `--package`, so each keeps its own `<name>@<version>` tags and changelog.
679
767
 
680
768
  ## ⚙️ Configuration
681
769
 
682
- `release.config.json`, beside `package.json`. Every key is optional; unknown keys abort
683
- rather than being silently ignored.
770
+ `release.config.json`, beside `package.json`. Every key is optional; unknown keys, and known
771
+ keys holding the wrong type (a string where the table says array), abort rather than being
772
+ silently ignored.
684
773
 
685
774
  | Key | Default | Meaning |
686
775
  | --------------- | ---------------------- | ----------------------------------------------------------------------- |
@@ -694,6 +783,7 @@ rather than being silently ignored.
694
783
  | `publish` | detected | Publish command, or an array of them; `null` publishes nothing |
695
784
  | `versioning` | `"conventional"` | How `auto` infers; or `always-patch` / `-minor` / `-major` |
696
785
  | `verify` | `null` | Command run during preflight; non-zero aborts before anything mutates |
786
+ | `requireGreen` | `false` | Refuse a HEAD that is not pushed and green on GitHub |
697
787
  | `hooks` | `{}` | Commands run between the steps — see [Hooks](#-hooks) |
698
788
  | `assistant` | `null` | Drafting CLI: a name, `"auto"`, or `{ tool, model, effort }` |
699
789
  | `commitMessage` | `"chore(release): %t"` | Release commit subject |
@@ -715,13 +805,19 @@ Non-interactive by default: the confirmation prompt is skipped when stdin is not
715
805
  `gh` picks up `GITHUB_TOKEN` on its own. Pass `--yes` to be explicit.
716
806
 
717
807
  ```yaml
718
- - uses: actions/checkout@v5
808
+ - uses: actions/create-github-app-token@v3
809
+ id: app-token
810
+ with:
811
+ client-id: ${{ vars.RELEASE_APP_CLIENT_ID }}
812
+ private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
813
+ - uses: actions/checkout@v7
719
814
  with:
720
815
  fetch-depth: 0 # release notes and the last-tag lookup need real history
816
+ token: ${{ steps.app-token.outputs.token }} # the push below is made with this token
721
817
  - id: release
722
- run: npx @entro314labs/release-kit@2.3.0 minor --yes
818
+ run: npx @entro314labs/release-kit@2.9.4 minor --yes
723
819
  env:
724
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
820
+ GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
725
821
  - run: echo "shipped ${{ steps.release.outputs.tag }} ${{ steps.release.outputs.release-url }}"
726
822
  ```
727
823
 
@@ -730,11 +826,71 @@ re-deriving it: `version`, `tag`, `name`, `dist-tag`, `steps`, `published`, `rel
730
826
  Nothing is written on a dry run, and an unwritable `$GITHUB_OUTPUT` never fails a release
731
827
  that already completed.
732
828
 
829
+ **A tag pushed with `GITHUB_TOKEN` triggers nothing.** GitHub deliberately does not start
830
+ workflows from events caused by the job's own `GITHUB_TOKEN`, so an `on: push: tags` build
831
+ workflow never runs for a tag release-kit pushed with it — the release is tagged and the
832
+ build that was supposed to follow it silently does not happen. The push uses whatever token
833
+ `actions/checkout` persisted, which is why the example gives checkout a
834
+ [GitHub App](https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/making-authenticated-api-requests-with-a-github-app-in-a-github-actions-workflow)
835
+ installation token (`actions/create-github-app-token`) — a fine-grained personal access
836
+ token with `contents: write` works the same way. Leave `persist-credentials` on for this
837
+ job: the push has nothing to authenticate with otherwise. The alternative that needs no
838
+ second identity is not to rely on the tag event at all: run the build in the same workflow
839
+ after the release job (`needs: release`), or call it as a reusable workflow
840
+ (`on: workflow_call`) with the tag from `steps.release.outputs.tag`. With `GITHUB_TOKEN`
841
+ alone, the example above still releases correctly; only the tag-triggered follow-up is lost.
842
+
733
843
  Two upstream habits make commit-derived notes trustworthy, and neither is release-kit's job:
734
844
 
735
845
  - **Gate the release on CI, and guard against forks.** Trigger on `workflow_run` after your
736
- check workflow succeeds, with `if: github.repository_owner == 'your-org'` so a fork never
737
- tries to release.
846
+ check workflow completes — but that event fires for _every_ completed run of it, including
847
+ pull request runs and failed ones, so the condition has to say which run may release.
848
+ `workflow_run` also checks out the default branch's latest commit, not the commit that
849
+ was checked, so check out `head_sha` explicitly:
850
+
851
+ ```yaml
852
+ on:
853
+ workflow_run:
854
+ workflows: [check]
855
+ types: [completed]
856
+ branches: [main]
857
+
858
+ jobs:
859
+ release:
860
+ # A push to main that passed, in this repository — not a fork's copy of the workflow.
861
+ if: >-
862
+ github.repository_owner == 'your-org' &&
863
+ github.event.workflow_run.event == 'push' &&
864
+ github.event.workflow_run.head_branch == 'main' &&
865
+ github.event.workflow_run.conclusion == 'success'
866
+ runs-on: ubuntu-latest
867
+ steps:
868
+ - uses: actions/create-github-app-token@v3
869
+ id: app-token
870
+ with:
871
+ client-id: ${{ vars.RELEASE_APP_CLIENT_ID }}
872
+ private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
873
+ - uses: actions/checkout@v7
874
+ with:
875
+ ref: ${{ github.event.workflow_run.head_sha }}
876
+ fetch-depth: 0
877
+ token: ${{ steps.app-token.outputs.token }} # so the tag push triggers workflows
878
+ # Checking out a SHA leaves a detached HEAD, which release-kit refuses — there is no
879
+ # branch to push. Put main back at the commit CI checked; if main has moved on
880
+ # since, preflight refuses as "behind origin/main" instead of releasing it unchecked.
881
+ - run: git switch -C main
882
+ - run: npx @entro314labs/release-kit@2.9.4 auto --yes
883
+ env:
884
+ GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
885
+ ```
886
+
887
+ [`requireGreen`](#requiring-a-green-head) is the same gate inside release-kit, for a
888
+ release run by hand or from a workflow that does not know which checks passed.
889
+
890
+ `branches: [main]` narrows the trigger to runs on `main`, but a pull request from a fork
891
+ whose branch is named `main` matches it too; `event == 'push'` is what rules pull request
892
+ runs out. The owner check stops a fork's own copy of the workflow from trying to release.
893
+
738
894
  - **Validate pull request titles.** A squash-merge takes its subject from the PR title, so
739
895
  that title becomes the commit the notes are built from. Check it with
740
896
  [`lint-commits`](#linting-commits), which uses this tool's own parser rather than a second
@@ -795,7 +951,10 @@ Two npm behaviours are handled automatically:
795
951
  `id-token: write`, or GitLab CI/CircleCI with `NPM_ID_TOKEN`, `whoami` fails while
796
952
  `publish` succeeds. That environment is detected and the auth check is skipped, so a
797
953
  valid CI release is not aborted over a missing token it does not need. That covers the
798
- CLIs that exchange the OIDC token themselves — npm, pnpm, bun and `uv publish`. cargo is
954
+ CLIs that exchange the OIDC token themselves — npm, pnpm, bun and `uv publish`. npm
955
+ only learned to in 11.5.1, and an older one fails the publish after the tag and push, so
956
+ under OIDC an older `npm` is a preflight failure (`npm install -g npm@latest` fixes it).
957
+ pnpm and bun document no minimum and are not checked. cargo is
799
958
  not one of them: crates.io's trusted publishing goes through an action that turns the
800
959
  token into `CARGO_REGISTRY_TOKEN`, so the cargo credential check still applies in CI.
801
960
 
@@ -910,6 +1069,24 @@ downgrade, so a configured pipeline fails loudly; `"auto"` degrades quietly by d
910
1069
  annotation, and posted as the GitHub release body — the same "written once, lands in three
911
1070
  places" path a hand-written section takes.
912
1071
 
1072
+ The prompt asks for notes written for someone upgrading: internal chores (CI, formatting,
1073
+ version and dependency bumps) are left out, except a dependency that ships inside the
1074
+ product — a bundled runtime, a sidecar binary, an embedded engine — since users run that
1075
+ code; a fix is described as what works now rather than what was broken; and a commit's
1076
+ [`Notes:` line](#release-notes) is kept in the author's words.
1077
+
1078
+ **An entry the model is unsure of is flagged, not guessed.** The prompt lets it start a
1079
+ bullet with `[???]` when it cannot tell whether a change is user-facing or what it means for
1080
+ someone upgrading. Flagged entries are listed at the confirmation prompt, and answering `y`
1081
+ releases them as shown with the marker removed — your answer is the review. With `--yes`,
1082
+ or with no terminal to ask on (CI), there is nobody to review them, so preflight refuses
1083
+ before anything mutates: re-run in a terminal, or write the notes into `[Unreleased]` with
1084
+ the entries resolved, since a hand-written section wins over a draft. When a dirty tree
1085
+ defers drafting past the prompt, the check runs right after the working-tree commit instead
1086
+ and asks again (or, non-interactively, stops there with only that commit made). This applies
1087
+ to drafted notes only — commit-derived and hand-written notes are never checked for the
1088
+ marker.
1089
+
913
1090
  With `--commit`, notes are drafted _after_ that commit lands, so they describe the change it
914
1091
  just made. Merge, release, `WIP` and `fixup!`/`squash!` commits are excluded from the prompt.
915
1092
 
@@ -960,7 +1137,9 @@ at the pushed tag:
960
1137
  ```
961
1138
 
962
1139
  Nothing after `push` — no `publish`, no `release`. The tag push is the handoff, and it is
963
- what triggers the build workflow. The notes travel in the annotated tag, which is the one
1140
+ what triggers the build workflow — unless it was pushed with `GITHUB_TOKEN` from another
1141
+ workflow, which triggers nothing ([Continuous integration](#continuous-integration) has the
1142
+ fix). The notes travel in the annotated tag, which is the one
964
1143
  thing that reaches a fresh checkout on another machine; the signature block a signed tag
965
1144
  appends is stripped before the file is handed over:
966
1145
 
@@ -972,7 +1151,7 @@ on:
972
1151
  jobs:
973
1152
  build:
974
1153
  steps:
975
- - uses: actions/checkout@v5
1154
+ - uses: actions/checkout@v7
976
1155
  with: { fetch-depth: 0 }
977
1156
  - name: Read the release notes off the tag
978
1157
  run: |
@@ -1023,13 +1202,13 @@ npx @entro314labs/release-kit --sync ../project-a ../project-b
1023
1202
  ```
1024
1203
 
1025
1204
  It reports `installed`, `updated`, or `already up to date` per target, creates `scripts/`
1026
- if missing, skips directories with no `package.json`, and warns when a target lacks the
1027
- `release` npm script. It runs before any git resolution, so it works from anywhere,
1205
+ if missing, skips a directory that does not exist, and warns when a target with a
1206
+ `package.json` lacks the `release` npm script. It runs before any git resolution, so it works from anywhere,
1028
1207
  including a directory that is not a repository.
1029
1208
 
1030
1209
  ## 📋 Requirements
1031
1210
 
1032
- - **Node 22+ — including for Rust, Python and Go projects.** `release-kit` is a Node
1211
+ - **Node 24+ — including for Rust, Python and Go projects.** `release-kit` is a Node
1033
1212
  program whatever it releases; there is no standalone binary.
1034
1213
  - `git`
1035
1214
  - `gh`, authenticated — only when creating GitHub releases
@@ -1045,7 +1224,7 @@ with the reasoning — are in [ROADMAP.md](ROADMAP.md).
1045
1224
 
1046
1225
  ```sh
1047
1226
  pnpm install
1048
- pnpm test # 188 tests, node --test, no framework
1227
+ pnpm test # node --test, no framework
1049
1228
  pnpm check # format + lint + tests, the same gate CI runs
1050
1229
  ```
1051
1230
 
package/TRAIN.md CHANGED
@@ -9,24 +9,27 @@ Ships in this package as `train.mjs` — a second self-contained, `node:*`-only
9
9
  `release.mjs`, installed as the `release-train` bin. Same design contract as release-kit:
10
10
  readable, vendorable, zero dependencies.
11
11
 
12
- **Status: prototype.** Discovery, graph derivation, registry-aware change detection,
13
- cascade, planning, whole-train preflight, `seed-tags`, and the train summary work.
14
- Execution (running release-kit per package) is not implemented yet — `train` without
15
- `--dry-run` says so and exits. Three things the design below describes are not built
16
- either, and the plan does not claim them: taking `packages` from `pnpm-workspace.yaml` /
17
- `workspaces` when the config omits it (the config must list them today), and the two
18
- per-package authentication checks in the preflight table (publish CLI and `gh`), which
19
- each package's own release-kit run performs when execution lands.
12
+ **Status.** Discovery, graph derivation, registry-aware change detection, cascade,
13
+ planning, whole-train preflight, execution, `seed-tags` and the train summary work, for
14
+ both topologies: a member alone in its repository releases exactly as standalone
15
+ release-kit would, and a member sharing its repository with others releases with
16
+ release-kit's `--package`. Not built yet, and not claimed:
17
+
18
+ - Taking `packages` from `pnpm-workspace.yaml` / `workspaces` when the config omits it (the
19
+ config must list them today).
20
+ - The two per-package authentication checks in the preflight table (publish CLI and `gh`)
21
+ run in each package's own release-kit preflight, at that package's turn — not up front.
20
22
 
21
23
  ```sh
24
+ release-train # plan + preflight, confirm, release (--yes: no prompt)
22
25
  release-train graph # print the derived dependency graph and topo order
23
26
  release-train --dry-run # full plan + whole-train preflight, execute nothing
24
- release-train --dry-run --all # plan every member, not just changed ones
25
- release-train --dry-run <id>... # plan these packages and their dependents
26
- release-train seed-tags # baseline tags at each HEAD (--dry-run to preview)
27
- release-train --summary <path> # write the train summary; --assistant drafts on top
28
- release-train --offline # no network: registry checks skipped, tags not pushed
29
- release-train --config <path> # config elsewhere than ./train.config.json
27
+ release-train --all # every member, not just changed ones
28
+ release-train <id>... # these packages and their dependents
29
+ release-train seed-tags # baseline tags at each HEAD (--dry-run to preview)
30
+ release-train --summary <path> # write the train summary; --assistant drafts on top
31
+ release-train --offline --dry-run # no network: registry checks skipped, tags not pushed
32
+ release-train --config <path> # config elsewhere than ./train.config.json
30
33
  ```
31
34
 
32
35
  ## Problem
@@ -212,35 +215,67 @@ One failure anywhere aborts the entire train before any package releases. This i
212
215
  whole safety story for the meta-workspace, where no transaction exists — so it is
213
216
  deliberately strict: one dirty repo out of thirty blocks all thirty.
214
217
 
215
- **Execute.** In topo order, per package:
218
+ **Execute.** After a confirmation prompt (`--yes` skips it, and is required without a
219
+ terminal), in topo order, per package:
216
220
 
217
221
  1. Rewrite internal dependency ranges in this package's manifest to the versions its
218
- dependencies just released, per `rangePolicy`. Skipped entirely for `workspace:`
222
+ dependencies just released, per `rangePolicy`, and commit that in the package's
223
+ repository as `chore(deps): move <name> <range>, …`. Skipped entirely for `workspace:`
219
224
  ranges — the package manager rewrites those at publish time, which is the preferred
220
- setup inside a workspace.
221
- 2. Run release-kit in the package directory: bump, changelog, release commit (the range
222
- rewrite rides in it), tag, push, publish, GitHub release — whatever that package's
223
- `steps` say.
225
+ setup inside a workspace. The lockfile governing the package (in its directory, or up
226
+ to its repository root for a workspace) records those ranges, so it is refreshed by the
227
+ tool that owns it — `pnpm install --lockfile-only`, `npm install --package-lock-only`,
228
+ both with `--ignore-scripts` — and rides in the same commit; a stale one would fail the
229
+ repository's next frozen install. A `yarn.lock` or `bun.lock` the train would have to
230
+ rewrite is refused in preflight instead of being committed stale. It is a commit of its
231
+ own, not part of release-kit's release commit: release-kit refuses a dirty tree unless
232
+ its `commit` step runs, and that step would stage everything and draft a message. The
233
+ separate commit is deterministic, and it gives a cascade-only release — a package whose
234
+ only change is its dependency — a commit for its notes to describe.
235
+ 2. Run release-kit — the `release.mjs` shipped beside `train.mjs`, one version for the
236
+ whole train — in the package directory with the planned version passed explicitly, so
237
+ release-kit releases exactly what the plan printed: bump, changelog, release commit,
238
+ tag, push, publish, GitHub release — whatever that package's `steps` say. A member
239
+ with `publish: false` runs with `--skip publish`; a Go member, which has no manifest
240
+ version, runs with `auto`; a member that shares its repository runs with `--package`
241
+ (below). `--assistant none` on the train is forwarded to every run.
224
242
  3. If any member still to come depends on this package: poll the registry
225
- (`npm view name@version` or the ecosystem equivalent) until the new version is
226
- visible or `registryWait.timeout` elapses. Registries have replication lag; a
227
- dependent that publishes or installs too early fails spuriously.
228
-
229
- In a monorepo, step 2's commits per package would produce commit noise; there the
230
- orchestrator batches: all version/changelog/range writes land in one release commit, then
231
- tags, one push, then publishes in topo order with the same waits.
243
+ (`npm view name@version version`) every `registryWait.interval` seconds until the new
244
+ version is visible or `registryWait.timeout` elapses. Registries have replication lag;
245
+ a dependent that publishes or installs too early fails spuriously.
246
+
247
+ The first failure stops the train and prints what was released, where it stopped, and
248
+ what never started.
249
+
250
+ **Several members in one repository.** release-kit on its own releases a whole
251
+ repository and refuses a nested package. `--package` releases the directory it runs in
252
+ instead: that directory's `release.config.json`, manifest, changelog, `verify` and publish;
253
+ `git log`, `git status` and the commit step limited to it (`-- .`); and tags
254
+ `<name>@<version>` unless the package's config sets `tagPrefix`. Each member gets its own
255
+ release commit, tag and push, one after another in topological order — release-kit's steps
256
+ unchanged, rather than batched into one commit, which would mean reordering them. A
257
+ member at the repository root that shares it with nested members reads every commit in the
258
+ repository, nested ones included. Inside a pnpm/npm workspace, internal ranges should be
259
+ `workspace:` and the member's publish command `pnpm publish` (or its equivalent), which is
260
+ what rewrites them into real ranges; a bare `npm publish` ships `workspace:` verbatim.
232
261
 
233
262
  **Report.** What released at which version, what was skipped and why, and — on failure —
234
263
  exactly which packages completed, so the resume story ("run it again") is verifiable.
235
264
 
236
265
  ## Failure and resume
237
266
 
238
- | Died at | State | Re-run does |
239
- | ---------------------------------- | ----------------------------------------- | ----------------------------------------------------- |
240
- | Preflight | Nothing mutated anywhere | Everything, after you fix the reported list |
241
- | Mid-package (e.g. publish timeout) | That package partially released | release-kit's own idempotency finishes it |
242
- | Between packages | Earlier packages fully released | Skips them (tag exists, version published), continues |
243
- | Registry wait timeout | Dependency published, dependent untouched | Wait resumes; registry has had more time |
267
+ | Died at | State | Re-run does |
268
+ | ----------------------------------------------------------------------------- | ----------------------------------------- | ----------------------------------------------------------------------------------- |
269
+ | Preflight | Nothing mutated anywhere | Everything, after you fix the reported list |
270
+ | Mid-package (e.g. publish timeout) | That package partially released | release-kit's own idempotency finishes it |
271
+ | Between packages | Earlier packages fully released | Skips them (tag exists, version published), continues |
272
+ | Registry wait timeout | Dependency published, dependent untouched | Wait resumes; registry has had more time |
273
+ | Between the version write and its commit (a lockfile refresh or hook failing) | That package's tree is dirty | Preflight refuses the dirty tree; finish that package with release-kit, then re-run |
274
+
275
+ A member whose `release.config.json` sets `requireGreen` is refused in preflight when the
276
+ train commits to its repository before its turn (its own `chore(deps)` commit, or an
277
+ earlier member's release in the same repository): CI cannot have passed that commit yet,
278
+ so release-kit would stop the train there, after earlier members had released.
244
279
 
245
280
  The invariant throughout: at no point does a published package depend on an unpublished
246
281
  version, because dependencies always complete first.
@@ -315,12 +350,14 @@ Flag rules, mirroring release-kit's posture:
315
350
  - `--dry-run` composes with everything: it is always "show me, touch nothing".
316
351
  - `--offline` degrades honestly: the plan says which checks were skipped, and seed-tags
317
352
  skips npm members it cannot verify rather than guessing.
353
+ - `--offline` plans only: a release publishes and waits on the registry, so `--offline`
354
+ without `--dry-run` is refused.
355
+ - `--yes` skips the confirmation, as in release-kit; without a terminal it is required.
318
356
  - Deliberately absent: a `--bump <type>` override (forcing one bump across packages is
319
357
  lockstep by the back door; release one package explicitly instead and let derivation do
320
- the rest) and a declared-order override (see Considered and declined). `--yes` arrives
321
- with execution, matching release-kit. A `--json` plan output for CI is the one addition
322
- under consideration — release-kit writes `$GITHUB_OUTPUT`, and the train's equivalent is
323
- a machine-readable plan.
358
+ the rest) and a declared-order override (see Considered and declined). A `--json` plan
359
+ output for CI is the one addition under consideration — release-kit writes
360
+ `$GITHUB_OUTPUT`, and the train's equivalent is a machine-readable plan.
324
361
 
325
362
  ## Considered and declined
326
363
 
@@ -335,10 +372,10 @@ Flag rules, mirroring release-kit's posture:
335
372
 
336
373
  ## Open questions
337
374
 
338
- - **Monorepo commit batching** — the batched single-commit path shares release-kit's steps
339
- but reorders when the commit happens; whether that is a release-kit flag
340
- (`--no-commit`, commit handled by caller) or orchestrator-side sequencing needs a
341
- decision before implementation.
375
+ - **Batching a monorepo's releases into one commit** — each member releases with its own
376
+ commit today (see Execute). One commit for all of them is less history noise, but needs
377
+ release-kit to stop before its commit and resume after; worth it only if the per-member
378
+ commits turn out to be a problem.
342
379
  - **GitHub releases in a multi-package repo** — `gh release create` per tag works; whether
343
380
  the nested-package changelog path (`packages/x/CHANGELOG.md`) needs anything from
344
381
  release-kit beyond cwd-relative resolution needs verification.