@entro314labs/release-kit 2.6.0 → 2.8.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 +112 -38
  2. package/TRAIN.md +332 -0
  3. package/package.json +5 -2
  4. package/release.mjs +406 -87
  5. package/train.mjs +1241 -0
package/README.md CHANGED
@@ -58,6 +58,7 @@ Released v2.5.0
58
58
  | [📚 Libraries versus apps](#-libraries-versus-apps) | which steps you want, and why |
59
59
  | [🤖 Assistant](#-assistant-optional) | optional AI drafting |
60
60
  | [🌍 Any language](#-any-language) | Rust, Python, tag-only, anything |
61
+ | [🚂 Release trains](#-release-trains) | monorepos and multi-repo workspaces |
61
62
  | [✅ Preflight](#-preflight) | what is checked before anything mutates |
62
63
  | [♻️ Recovering from a failed run](#️-recovering-from-a-failed-run) | why re-running is safe |
63
64
  | [⚙️ Configuration](#️-configuration) | `release.config.json`, publishing, auth |
@@ -177,7 +178,7 @@ Prerelease bumps need `--preid` unless the current version already carries one t
177
178
  | ---------------------------- | -------------------------------------------------------------------------- |
178
179
  | `--only <steps>` | Run only these steps, comma-separated. |
179
180
  | `--skip <steps>` | Run every step except these. |
180
- | `--commit` | Add the opt-in `commit` step: commit a dirty tree with a drafted message. |
181
+ | `--commit` | Force the `commit` step on when a `steps` config removed it. |
181
182
  | `--dry-run` | Print every step, execute nothing. Preflight still runs and still reports. |
182
183
  | `--yes`, `-y` | Skip the confirmation prompt. |
183
184
  | `--preid <id>` | Prerelease identifier: `alpha`, `beta`, `rc`, `next`, `nightly`, `canary`. |
@@ -188,20 +189,58 @@ Prerelease bumps need `--preid` unless the current version already carries one t
188
189
  | `--sync <dir>...` | Copy this script into other projects and exit. Touches no git state. |
189
190
  | `--help`, `-h` | Full flag list. |
190
191
 
192
+ ### Linting commits
193
+
194
+ A subject that is not Conventional Commits is invisible: it contributes nothing to the
195
+ inferred bump and never reaches the changelog. `lint-commits` checks subjects against the
196
+ same parser the release uses, so the gate and the release can never disagree about what
197
+ counts.
198
+
199
+ ```bash
200
+ release-kit lint-commits # since the last tag
201
+ release-kit lint-commits main..HEAD # an explicit range
202
+ release-kit lint-commits --subject "feat: x" # one subject — a pull request title
203
+ ```
204
+
205
+ It exits non-zero on a subject the release cannot read. A type outside the changelog table
206
+ — `security:`, `i18n:` — is a warning, not a failure: those still get printed, under _Other
207
+ Changes_. Release commits, merges and `fixup!`/`squash!` markers are skipped, per the same
208
+ `ignoreCommits` config the release notes use.
209
+
210
+ Local commit-msg hooks cannot cover the case that matters most. If you squash-merge, the
211
+ commit released is the **pull request title**, which no hook ever sees — check it in CI:
212
+
213
+ ```yaml
214
+ - name: Lint the pull request title
215
+ env:
216
+ TITLE: ${{ github.event.pull_request.title }}
217
+ run: npx @entro314labs/release-kit@2.8.0 lint-commits --subject "$TITLE"
218
+ ```
219
+
220
+ Pass the title through `env`, never through `${{ }}` inside `run:` — a pull request title is
221
+ attacker-controlled text and interpolating it into a shell command is a script injection.
222
+
191
223
  ## 🧩 Steps
192
224
 
193
225
  A release is seven named steps. They always run in this order — `steps` selects which of
194
226
  them execute, it never reorders them.
195
227
 
196
- | Step | Default | What it does |
197
- | ----------- | ------- | ----------------------------------------------------------------------------------------------- |
198
- | `commit` | off | Commit a dirty working tree with a drafted message ([assistant](#-assistant-optional) required) |
199
- | `version` | on | Write the version into `package.json` and `versionFiles` |
200
- | `changelog` | on | Roll `[Unreleased]` into the version, or add drafted notes |
201
- | `tag` | on | Annotated git tag carrying the release notes |
202
- | `push` | on | Push the branch and tag together (`--follow-tags`) |
203
- | `publish` | on | Run the configured `publish` command |
204
- | `release` | on | Create the GitHub release |
228
+ | Step | Default | What it does |
229
+ | ----------- | ------- | -------------------------------------------------------------------------------------------------- |
230
+ | `commit` | on\* | Commit a dirty working tree — drafted with an [assistant](#-assistant-optional), generated without |
231
+ | `version` | on | Write the version into `package.json` and `versionFiles` |
232
+ | `changelog` | on | Roll `[Unreleased]` into the version, or add drafted notes |
233
+ | `tag` | on | Annotated git tag carrying the release notes |
234
+ | `push` | on | Push the branch and tag together (`--follow-tags`) |
235
+ | `publish` | on | Run the configured `publish` command |
236
+ | `release` | on | Create the GitHub release |
237
+
238
+ \* `commit` no-ops on a clean tree. On a dirty tree it stages everything and commits:
239
+ with an [assistant](#-assistant-optional) configured the message is drafted from the
240
+ diff; without one it degrades gracefully to a generated `chore:` message naming the
241
+ changed files — a release is never blocked because a text generator was unavailable.
242
+ Opt out with `--skip commit` or a `steps` config, which restores the refusal on a dirty
243
+ tree.
205
244
 
206
245
  `version` and `changelog` write files; those writes are persisted by a release commit made
207
246
  automatically when either step runs.
@@ -209,13 +248,13 @@ automatically when either step runs.
209
248
  ```sh
210
249
  release-kit minor --skip publish # everything but publish
211
250
  release-kit --only tag,push,release # a version already committed elsewhere
212
- release-kit minor --commit # add the opt-in commit step
251
+ release-kit minor --skip commit # never touch uncommitted work
213
252
  ```
214
253
 
215
254
  Or fix it per project, and just run `release-kit minor`:
216
255
 
217
256
  ```json
218
- { "steps": ["commit", "version", "changelog", "tag", "push", "release"] }
257
+ { "steps": ["version", "changelog", "tag", "push", "release"] }
219
258
  ```
220
259
 
221
260
  `steps` decides **what** runs. Every other key describes **how** a step behaves — `publish`
@@ -240,6 +279,14 @@ Notes resolve in this order:
240
279
  than something that requires an assistant.
241
280
  4. Otherwise GitHub generates them from the commits since the previous tag.
242
281
 
282
+ `--notes <source>` forces one instead of walking that list: `changelog`, `assistant`,
283
+ `commits`, or `github`. A named source that produces nothing is an error rather than a
284
+ quiet fall-through — asking for one thing and being given another is worse than being told
285
+ it is unavailable.
286
+
287
+ `--assistant` names the _tool_; `--notes` names the _source_. Making an assistant available
288
+ does not make it preferred, because a hand-written changelog entry should still win.
289
+
243
290
  The same text becomes the tag annotation, the GitHub release body, and (when rolled) the
244
291
  changelog entry. It is written once and lands in three places.
245
292
 
@@ -274,7 +321,14 @@ rather than stopping at the first problem.
274
321
  - Commit and tag signing can actually sign, and the key is one GitHub will accept
275
322
  - The publishing CLI is authenticated, and the version is not already published
276
323
  - Configured release assets exist
277
- - A shallow clone is reported, since it truncates the history notes come from _(warning)_
324
+ - The configured `verify` command passes — the project's own gate (tests, build) runs
325
+ before anything mutates, instead of a `prepublishOnly` hook failing after the commit,
326
+ tag and push
327
+ - `package.json`'s `repository` matches the git remote, so the registry's "Repository"
328
+ link is not broken _(warning)_
329
+ - A shallow clone only matters when it truncates the history the release reads: with the
330
+ previous tag reachable it passes, without one it fails `auto` (the bump would be inferred
331
+ from partial history) and warns otherwise
278
332
  - A changelog section for the version exists _(warning — it falls back to generated notes)_
279
333
 
280
334
  Under `--dry-run` the failures are reported and then the remaining steps are shown anyway,
@@ -383,26 +437,44 @@ Stopping at `push` because the tag is what triggers the build pipeline — see
383
437
  The project name comes from the manifest when there is one (`name` in `package.json`,
384
438
  `Cargo.toml` or `pyproject.toml`), and falls back to the repository directory.
385
439
 
440
+ ## 🚂 Release trains
441
+
442
+ Interdependent packages — a monorepo, or a plain folder of sibling git repositories —
443
+ release with the second bin in this package:
444
+
445
+ ```sh
446
+ release-train --dry-run # plan + whole-train preflight, execute nothing
447
+ ```
448
+
449
+ `train.mjs` derives the dependency graph and publish order from the package manifests
450
+ (never from declared config), releases dependencies before dependents with release-kit as
451
+ the per-package worker, rewrites internal ranges, and refuses the whole train before
452
+ anything mutates if any package would fail. A `train.config.json` declares only which
453
+ directories are members. Design, configuration and the full pipeline are in
454
+ [TRAIN.md](TRAIN.md). Prototype status: planning, preflight and `seed-tags` work;
455
+ execution is not wired up yet.
456
+
386
457
  ## ⚙️ Configuration
387
458
 
388
459
  `release.config.json`, beside `package.json`. Every key is optional; unknown keys abort
389
460
  rather than being silently ignored.
390
461
 
391
- | Key | Default | Meaning |
392
- | --------------- | ------------------------ | ------------------------------------------------------------ |
393
- | `steps` | all but `commit` | Which steps run; the order is fixed |
394
- | `tagPrefix` | `"v"` | Prepended to the version to form the tag |
395
- | `branch` | `"main"` | The only branch a release may run from; `null` allows any |
396
- | `remote` | `"origin"` | Git remote to push to |
397
- | `changelog` | `"CHANGELOG.md"` | Changelog path; `null` for a project without one |
398
- | `versionFile` | detected | Where the version lives; `null` versions by tag alone |
399
- | `versionFiles` | `[]` | Further files kept in sync; a path or `{ path, pattern }` |
400
- | `publish` | `"npm publish --tag %d"` | Publish command; `null` means none is configured |
401
- | `versioning` | `"conventional"` | How `auto` infers; or `always-patch` / `-minor` / `-major` |
402
- | `assistant` | `null` | Drafting CLI: a name, `"auto"`, or `{ tool, model, effort }` |
403
- | `commitMessage` | `"chore(release): %t"` | Release commit subject |
404
- | `releaseTitle` | `"%t"` | GitHub release title |
405
- | `assets` | `[]` | Files attached to the GitHub release |
462
+ | Key | Default | Meaning |
463
+ | --------------- | ------------------------ | --------------------------------------------------------------------- |
464
+ | `steps` | all but `commit` | Which steps run; the order is fixed |
465
+ | `tagPrefix` | `"v"` | Prepended to the version to form the tag |
466
+ | `branch` | `"main"` | The only branch a release may run from; `null` allows any |
467
+ | `remote` | `"origin"` | Git remote to push to |
468
+ | `changelog` | `"CHANGELOG.md"` | Changelog path; `null` for a project without one |
469
+ | `versionFile` | detected | Where the version lives; `null` versions by tag alone |
470
+ | `versionFiles` | `[]` | Further files kept in sync; a path or `{ path, pattern }` |
471
+ | `publish` | `"npm publish --tag %d"` | Publish command; `null` means none is configured |
472
+ | `versioning` | `"conventional"` | How `auto` infers; or `always-patch` / `-minor` / `-major` |
473
+ | `verify` | `null` | Command run during preflight; non-zero aborts before anything mutates |
474
+ | `assistant` | `null` | Drafting CLI: a name, `"auto"`, or `{ tool, model, effort }` |
475
+ | `commitMessage` | `"chore(release): %t"` | Release commit subject |
476
+ | `releaseTitle` | `"%t"` | GitHub release title |
477
+ | `assets` | `[]` | Files attached to the GitHub release |
406
478
 
407
479
  Command and message strings expand four tokens: `%v` version, `%t` tag, `%n` package
408
480
  name, `%d` npm dist-tag. In the `publish` command line the substituted values are
@@ -436,11 +508,11 @@ Two upstream habits make commit-derived notes trustworthy, and neither is releas
436
508
  check workflow succeeds, with `if: github.repository_owner == 'your-org'` so a fork never
437
509
  tries to release.
438
510
  - **Validate pull request titles.** A squash-merge takes its subject from the PR title, so
439
- that title becomes the commit the notes are built from.
440
- [`amannn/action-semantic-pull-request`](https://github.com/amannn/action-semantic-pull-request)
441
- enforces it. Without something like it, work silently goes missing from release notes —
442
- release-kit says how many commits are not Conventional Commits, but it cannot fix them
443
- after the fact.
511
+ that title becomes the commit the notes are built from. Check it with
512
+ [`lint-commits`](#linting-commits), which uses this tool's own parser rather than a second
513
+ opinion about the grammar. Without something like it, work silently goes missing from
514
+ release notes — release-kit says how many commits are not Conventional Commits, but it
515
+ cannot fix them after the fact.
444
516
 
445
517
  Three things CI does that are worth knowing about:
446
518
 
@@ -558,11 +630,13 @@ downgrade, so a configured pipeline fails loudly; `"auto"` degrades quietly by d
558
630
 
559
631
  ### What it does
560
632
 
561
- - **`--commit`** stages the working tree, drafts a Conventional Commits message for the
562
- staged diff, and commits — instead of refusing to release. The subject is validated
563
- against the Conventional Commits grammar; an answer that does not parse is rejected rather
564
- than committed. Attribution lines (`Co-Authored-By`, `Generated with`) are stripped, so
565
- the tool never signs your commits.
633
+ - **Commit messages for a dirty working tree are drafted from the staged diff**, instead
634
+ of the generated `chore:` message used when no assistant is available. The draft is
635
+ validated, not trusted: a subject that is not Conventional Commits, or a message naming
636
+ a version the staged changes never touch (a model narrating an unchanged `"version"`
637
+ context line), falls back to the generated message rather than being committed.
638
+ Attribution lines (`Co-Authored-By`, `Generated with`) are stripped, so the tool never
639
+ signs your commits.
566
640
  - **Release notes** are drafted from the commits since the last tag when `CHANGELOG.md` has
567
641
  no section for the version. Each bullet ends with a link to the commits it covers: the
568
642
  assistant is given the short hashes and asked to cite them, and every citation is checked
package/TRAIN.md ADDED
@@ -0,0 +1,332 @@
1
+ # release-train
2
+
3
+ Orchestrated releases for a set of interdependent packages, in dependency order, using
4
+ release-kit as the per-package worker. One command releases a "train": every package that
5
+ changed, plus every package that depends on one that did, each with its own version bump,
6
+ changelog, tag, push, publish and GitHub release.
7
+
8
+ Ships in this package as `train.mjs` — a second self-contained, `node:*`-only file beside
9
+ `release.mjs`, installed as the `release-train` bin. Same design contract as release-kit:
10
+ readable, vendorable, zero dependencies.
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.
16
+
17
+ ```sh
18
+ release-train graph # print the derived dependency graph and topo order
19
+ release-train --dry-run # full plan + whole-train preflight, execute nothing
20
+ release-train --dry-run --all # plan every member, not just changed ones
21
+ release-train --dry-run <id>... # plan these packages and their dependents
22
+ release-train seed-tags # baseline tags at each HEAD (--dry-run to preview)
23
+ release-train --summary <path> # write the train summary; --assistant drafts on top
24
+ release-train --offline # no network: registry checks skipped, tags not pushed
25
+ release-train --config <path> # config elsewhere than ./train.config.json
26
+ ```
27
+
28
+ ## Problem
29
+
30
+ A single package release is solved (release-kit). What is not solved is the shape where
31
+ package B depends on package A, so releasing A implies:
32
+
33
+ 1. publish A first,
34
+ 2. move B onto A's new version,
35
+ 3. release B — and repeat transitively for anything that depends on B.
36
+
37
+ Doing this by hand means remembering the order, editing dependency ranges, and hoping the
38
+ registry has A before B publishes. Existing tools (changesets, Lerna, Nx Release) solve it
39
+ only for a single git repository. Nothing mainstream solves it for a workspace of sibling
40
+ git repositories, which is the second topology this design covers.
41
+
42
+ ## Relationship to release-kit
43
+
44
+ release-train is an **orchestrator, not a release tool**. It never bumps a version, writes
45
+ a changelog, tags, pushes or publishes itself — it decides _which_ packages release, _in
46
+ what order_, rewrites internal dependency ranges, and then runs release-kit once per
47
+ package with the right working directory. Everything release-kit already guarantees
48
+ (accumulate-then-abort preflight, idempotent steps, notes resolution, per-ecosystem
49
+ publish) is inherited per package, unchanged.
50
+
51
+ This split is deliberate:
52
+
53
+ - release-kit keeps its hard refusal to release a nested package when invoked directly —
54
+ the refusal exists because it once silently released the parent. The orchestrator is the
55
+ one caller that knows which nested directory it means, and says so explicitly.
56
+ - Each package keeps its own `release.config.json`. The orchestrator does not know what
57
+ npm, cargo or uv are; the package's own config does.
58
+ - The orchestrator stays small enough to hold to the same standard as release-kit: one
59
+ readable file, `node:*` imports only.
60
+
61
+ ## Principles
62
+
63
+ 1. **Derive, never declare.** Publish order is a projection of the dependency graph, and
64
+ the graph already exists in the package manifests. A declared order duplicates that
65
+ truth and drifts; a derived order cannot. The same goes for versions: the manifest is
66
+ the truth, no config file stores a copy of it. (Validated empirically: the entrolytics
67
+ ecosystem's `versions.json` stored versions and a declared-dependencies field — every
68
+ sampled version had drifted from its manifest, and the dependencies field was never
69
+ populated at all.)
70
+ 2. **Config declares membership, nothing else that changes per release.** Which
71
+ directories are part of the train is a fact discovery cannot always get right (stale
72
+ folders, docs repos, private apps). Order, versions and the graph are always derived.
73
+ 3. **Whole-train preflight before anything mutates.** There is no cross-package
74
+ transaction on npm or across git repositories. The compensation is release-kit's
75
+ accumulate-then-abort preflight, widened to every package in the plan: all failures
76
+ from all packages are reported in one list, and nothing is touched until the list is
77
+ empty.
78
+ 4. **Idempotency is the resume mechanism.** Because dependencies publish before
79
+ dependents, an interrupted train never leaves a published package depending on an
80
+ unpublished version. Re-running the same command skips completed packages (release-kit
81
+ already skips written versions, existing tags, published versions, existing releases)
82
+ and continues from the first incomplete one. No state file, no `--resume`.
83
+ 5. **Independent versions.** Each package bumps by its own history. Lockstep ("global
84
+ version") is not offered: in practice it decays into drift the moment one package needs
85
+ a patch the others do not (observed in the wild), and it forces empty releases.
86
+
87
+ ## Topologies
88
+
89
+ Two topologies, one model.
90
+
91
+ **Single-repo workspace (monorepo).** One git repository, packages under globs
92
+ (`packages/*`, `src/app/*`), usually a pnpm/npm/bun workspace. One release commit for the
93
+ whole train, one tag per released package.
94
+
95
+ **Meta-workspace (multi-repo).** A plain folder — not itself a git repository — containing
96
+ sibling git repositories, each with its own remote, branch, history and (possibly) its own
97
+ nested packages. Each package releases as a full release-kit run in its owning repo: its
98
+ own commit, tag, push, publish, GitHub release.
99
+
100
+ **The unit model that covers both:** a _package_ (the publish unit, a directory with a
101
+ manifest) belongs to an _owning repo_ (the git unit, found by walking up to the nearest
102
+ `.git`). A repo may own many packages; in a monorepo, one repo owns all of them; in a
103
+ meta-workspace most repos own exactly one, but a nested monorepo inside a meta-workspace
104
+ is just a repo that owns several. Nothing in the pipeline branches on topology — only on
105
+ "which repo owns this package", which decides where the commit and tag land.
106
+
107
+ ## Configuration
108
+
109
+ `train.config.json` at the workspace root. Membership and policy only — no versions, no
110
+ dependencies, no order.
111
+
112
+ ```json
113
+ {
114
+ "packages": [
115
+ "packages/*",
116
+ { "path": "sdks/react", "id": "react-sdk" },
117
+ { "path": "apps/dashboard", "publish": false }
118
+ ],
119
+ "rangePolicy": "caret",
120
+ "registryWait": { "timeout": 300, "interval": 5 }
121
+ }
122
+ ```
123
+
124
+ | Key | Default | Meaning |
125
+ | -------------- | --------- | --------------------------------------------------------------------------------------------- |
126
+ | `packages` | required | Paths or globs. An entry is a string or `{ path, id?, publish? }`. |
127
+ | `rangePolicy` | `"caret"` | How rewritten internal ranges are written: `caret`, `tilde`, `exact`, or `preserve`. |
128
+ | `registryWait` | as shown | How long to poll the registry for a just-published dependency before releasing its dependent. |
129
+
130
+ - `id` names the package in output and on the command line; defaults to the manifest name.
131
+ - `publish: false` keeps a package in the graph (its changes still cascade to dependents,
132
+ its version still bumps) but skips registry publishing — for apps.
133
+ - In a workspace with `pnpm-workspace.yaml` / `workspaces` globs, `packages` may be
134
+ omitted and is taken from there.
135
+ - Everything per-package — publish command, changelog path, version files, assistant —
136
+ lives in that package's own `release.config.json`, exactly as standalone release-kit
137
+ reads it. The orchestrator adds nothing to it and overrides nothing in it.
138
+ - Unknown keys abort, as in release-kit.
139
+
140
+ ## Pipeline
141
+
142
+ ```
143
+ discover → graph → detect changes → cascade → plan → preflight (whole train) → execute → report
144
+ ```
145
+
146
+ **Discover.** Resolve `packages` globs to directories, read each manifest (name, version,
147
+ dependencies), find each package's owning repo. A membership entry whose path does not
148
+ exist is a preflight failure, not a silent skip.
149
+
150
+ **Graph.** An internal dependency is a manifest dependency (`dependencies`,
151
+ `peerDependencies`, `optionalDependencies`) whose name matches another member package.
152
+ `devDependencies` do not create publish-order edges — they never appear in the published
153
+ artifact — but a devDependency change still marks the dependent as changed. Cycles through
154
+ publish-order edges abort with the cycle printed; there is no order that releases a cycle.
155
+
156
+ **Detect changes.** Two sources, and the registry outranks the commits.
157
+
158
+ _The registry._ One lookup per npm package (`npm view <name> versions`, cached) classifies
159
+ the manifest version:
160
+
161
+ | State | Meaning | Consequence |
162
+ | --------- | -------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
163
+ | `current` | manifest version is the registry's latest | normal — bump from commits |
164
+ | `pending` | manifest version is not published (a committed-but-unreleased bump, or a package never published at all) | released **as-is**: the pending version is the release, it joins the set even with zero new commits, and dependents' rewrites target it. Nothing is skipped over. |
165
+ | `behind` | the registry has a newer version than the manifest | preflight **failure** — the tree is behind what shipped; sync it, never guess |
166
+ | `unknown` | lookup failed, or `--offline` | warning — pending/collision checks not performed |
167
+
168
+ `pending` is common in the wild (both reference workspaces had them: versions bumped and
169
+ committed, publish never ran or failed) and is precisely the case a "bump then publish"
170
+ tool gets wrong by bumping past the version that never shipped.
171
+
172
+ _The commits._ Per package: full commit messages (subjects **and** bodies, so
173
+ `BREAKING CHANGE:` footers count) in the owning repo touching the package's path since the
174
+ package's last release tag. Tag scheme is `<name>@<version>` per package (the
175
+ changesets/release-please convention) when a repo owns more than one package, and plain
176
+ `v<version>` when it owns exactly one — which keeps single-package repos identical to
177
+ standalone release-kit. A package with no release tag yet is _cold_ (see Cold start).
178
+
179
+ **Cascade.** Any member that depends on a package in the release set joins the set with at
180
+ least a patch bump, transitively. This is what makes the new dependency version actually
181
+ reach consumers. Each package's own bump is derived by release-kit's `auto` from its
182
+ path-scoped commits; the cascade only raises "no release" to "patch", it never lowers.
183
+
184
+ **Plan.** Topological sort of the release set. The plan is printed before anything runs:
185
+ each package, its current → next version, its bump reason (commits, cascade, or explicit),
186
+ which internal ranges will be rewritten, and the order. `--dry-run` stops here — after
187
+ preflight, so the whole plan and every blocker are visible together, matching release-kit.
188
+
189
+ **Preflight (whole train).** For every package in the plan, before anything mutates
190
+ anywhere:
191
+
192
+ | Check | Scope |
193
+ | -------------------------------------------------------------------------------------- | ----------- |
194
+ | Working tree clean | per repo |
195
+ | On the configured branch, not detached | per repo |
196
+ | Remote reachable, branch not behind it | per repo |
197
+ | Release tag free (or already at `HEAD`, reusable) | per package |
198
+ | Planned version not already on the registry | per package |
199
+ | Manifest not _behind_ the registry (a newer version was published than the tree knows) | per package |
200
+ | Publish CLI authenticated | per package |
201
+ | `gh` authenticated | once |
202
+ | Every internal range will, after rewriting, match the version being published | per edge |
203
+ | A `workspace:` range's target is a member of the train | per edge |
204
+ | Membership paths exist and carry a manifest | per entry |
205
+ | No publish-order cycles | once |
206
+
207
+ One failure anywhere aborts the entire train before any package releases. This is the
208
+ whole safety story for the meta-workspace, where no transaction exists — so it is
209
+ deliberately strict: one dirty repo out of thirty blocks all thirty.
210
+
211
+ **Execute.** In topo order, per package:
212
+
213
+ 1. Rewrite internal dependency ranges in this package's manifest to the versions its
214
+ dependencies just released, per `rangePolicy`. Skipped entirely for `workspace:`
215
+ ranges — the package manager rewrites those at publish time, which is the preferred
216
+ setup inside a workspace.
217
+ 2. Run release-kit in the package directory: bump, changelog, release commit (the range
218
+ rewrite rides in it), tag, push, publish, GitHub release — whatever that package's
219
+ `steps` say.
220
+ 3. If any member still to come depends on this package: poll the registry
221
+ (`npm view name@version` or the ecosystem equivalent) until the new version is
222
+ visible or `registryWait.timeout` elapses. Registries have replication lag; a
223
+ dependent that publishes or installs too early fails spuriously.
224
+
225
+ In a monorepo, step 2's commits per package would produce commit noise; there the
226
+ orchestrator batches: all version/changelog/range writes land in one release commit, then
227
+ tags, one push, then publishes in topo order with the same waits.
228
+
229
+ **Report.** What released at which version, what was skipped and why, and — on failure —
230
+ exactly which packages completed, so the resume story ("run it again") is verifiable.
231
+
232
+ ## Failure and resume
233
+
234
+ | Died at | State | Re-run does |
235
+ | ---------------------------------- | ----------------------------------------- | ----------------------------------------------------- |
236
+ | Preflight | Nothing mutated anywhere | Everything, after you fix the reported list |
237
+ | Mid-package (e.g. publish timeout) | That package partially released | release-kit's own idempotency finishes it |
238
+ | Between packages | Earlier packages fully released | Skips them (tag exists, version published), continues |
239
+ | Registry wait timeout | Dependency published, dependent untouched | Wait resumes; registry has had more time |
240
+
241
+ The invariant throughout: at no point does a published package depend on an unpublished
242
+ version, because dependencies always complete first.
243
+
244
+ ## Cold start
245
+
246
+ A package with no release tag cannot compute "commits since last tag". Options, chosen per
247
+ run, not configured:
248
+
249
+ - `train seed-tags` — create `<name>@<manifest version>` (or `v<version>`) at each repo's
250
+ current `HEAD`, push tags, release nothing. The next train has a baseline. This is the
251
+ right move for an existing ecosystem whose versions are already published.
252
+ - `--all` — treat every member as changed and release everything once.
253
+
254
+ Seeding refuses, per member and without touching it, when:
255
+
256
+ - the manifest version is not on the registry — tagging a commit as "released 2.4.2" when
257
+ 2.4.2 never shipped would make the baseline a lie. A pending version is _released_ by
258
+ the train (as-is), then it has a real tag;
259
+ - the manifest is behind the registry — sync the tree first;
260
+ - the manifest file has uncommitted changes — the version on disk may not be the version
261
+ at `HEAD`, so the tag would point at the wrong commit;
262
+ - there is no manifest version (go, tag-only projects) or the registry cannot be reached —
263
+ nothing can vouch for the baseline; seed those by hand.
264
+
265
+ `seed-tags --dry-run` previews every action; `--offline` creates tags without pushing.
266
+
267
+ ## Cross-ecosystem notes
268
+
269
+ The orchestrator's graph is built from npm-style manifests today. Non-npm members still
270
+ participate:
271
+
272
+ - **Go**: no manifest version; the tag is the release. release-kit already handles it
273
+ (`versionFile: null`). It has no npm-visible dependents, so no registry wait.
274
+ - **Python / PHP**: `pyproject.toml` / `composer.json` versions, publish via the package's
275
+ own configured command; Packagist releases _are_ the pushed tag.
276
+ - Cross-ecosystem dependency edges (a Python package tracking an npm package's version)
277
+ are out of scope for ordering; they version independently.
278
+
279
+ ## CLI
280
+
281
+ Two commands and four flags; modes are commands, modifiers are flags.
282
+
283
+ ```sh
284
+ train # plan + preflight + release everything that changed (or is pending)
285
+ train --dry-run # plan + preflight, execute nothing
286
+ train react-sdk # release this package and its dependents only
287
+ train --all # every member, cold start or forced full train
288
+ train seed-tags # establish baseline tags, release nothing (--dry-run to preview)
289
+ train graph # print the derived graph and topo order
290
+ train --offline # no network: registry checks skipped, tags not pushed
291
+ train --config <path> # config file elsewhere than ./train.config.json
292
+ ```
293
+
294
+ Flag rules, mirroring release-kit's posture:
295
+
296
+ - Unknown flags abort — a typo must never silently change behaviour.
297
+ - `--all` and explicit package ids conflict and abort; `graph` takes neither.
298
+ - `--dry-run` composes with everything: it is always "show me, touch nothing".
299
+ - `--offline` degrades honestly: the plan says which checks were skipped, and seed-tags
300
+ skips npm members it cannot verify rather than guessing.
301
+ - Deliberately absent: a `--bump <type>` override (forcing one bump across packages is
302
+ lockstep by the back door; release one package explicitly instead and let derivation do
303
+ the rest) and a declared-order override (see Considered and declined). `--yes` arrives
304
+ with execution, matching release-kit. A `--json` plan output for CI is the one addition
305
+ under consideration — release-kit writes `$GITHUB_OUTPUT`, and the train's equivalent is
306
+ a machine-readable plan.
307
+
308
+ ## Considered and declined
309
+
310
+ | Idea | Why not |
311
+ | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
312
+ | Declared release order in config | Duplicates the graph, drifts, cannot express independence or verify itself. The graph is already in the manifests. |
313
+ | Versions stored in a central file | A copy of the manifest that goes stale. Observed failing in a real ecosystem within weeks. The manifest is the version. |
314
+ | Lockstep / global version | Decays into drift the first time one package needs a solo patch; forces empty releases of unchanged packages. |
315
+ | Changeset intent files | A second workflow to learn and enforce. Conventional Commits already carry the intent, and release-kit's `auto` already reads them per path. |
316
+ | Parallel publishing of independent packages | Real wall-clock win, real interleaved-output and rate-limit cost. Sequential is comprehensible; revisit only if train duration becomes a problem. |
317
+ | Baking orchestration into release.mjs | Would grow the single file past readability and reopen the nested-package refusal for every standalone user. Two small files beat one large one. |
318
+
319
+ ## Open questions
320
+
321
+ - **Monorepo commit batching** — the batched single-commit path shares release-kit's steps
322
+ but reorders when the commit happens; whether that is a release-kit flag
323
+ (`--no-commit`, commit handled by caller) or orchestrator-side sequencing needs a
324
+ decision before implementation.
325
+ - **GitHub releases in a multi-package repo** — `gh release create` per tag works; whether
326
+ the nested-package changelog path (`packages/x/CHANGELOG.md`) needs anything from
327
+ release-kit beyond cwd-relative resolution needs verification.
328
+ - **Partial trains and humans** — `train react-sdk` releases a package and its dependents,
329
+ but _not_ its dependencies. If a dependency has unreleased changes, is that a warning or
330
+ a refusal?
331
+ - **Private registries / scoped auth per package** — preflight currently assumes one
332
+ registry identity per publish CLI; per-package `.npmrc` scoping needs a pass.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@entro314labs/release-kit",
3
- "version": "2.6.0",
3
+ "version": "2.8.0",
4
4
  "description": "Single-file, zero-dependency release mechanism for JS/TS/Node projects: version bump, changelog roll, commit, annotated tag, push, publish, GitHub release",
5
5
  "keywords": [
6
6
  "changelog",
@@ -25,11 +25,14 @@
25
25
  "url": "git+https://github.com/entro314-labs/release-kit.git"
26
26
  },
27
27
  "bin": {
28
- "release-kit": "release.mjs"
28
+ "release-kit": "release.mjs",
29
+ "release-train": "train.mjs"
29
30
  },
30
31
  "files": [
31
32
  "release.mjs",
33
+ "train.mjs",
32
34
  "README.md",
35
+ "TRAIN.md",
33
36
  "LICENSE"
34
37
  ],
35
38
  "type": "module",