@entro314labs/release-kit 2.5.0 → 2.7.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 +55 -7
  2. package/TRAIN.md +332 -0
  3. package/package.json +5 -2
  4. package/release.mjs +255 -91
  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`. |
@@ -195,7 +196,7 @@ them execute, it never reorders them.
195
196
 
196
197
  | Step | Default | What it does |
197
198
  | ----------- | ------- | ----------------------------------------------------------------------------------------------- |
198
- | `commit` | off | Commit a dirty working tree with a drafted message ([assistant](#-assistant-optional) required) |
199
+ | `commit` | on\* | Commit a dirty working tree with a drafted message ([assistant](#-assistant-optional) required) |
199
200
  | `version` | on | Write the version into `package.json` and `versionFiles` |
200
201
  | `changelog` | on | Roll `[Unreleased]` into the version, or add drafted notes |
201
202
  | `tag` | on | Annotated git tag carrying the release notes |
@@ -203,19 +204,25 @@ them execute, it never reorders them.
203
204
  | `publish` | on | Run the configured `publish` command |
204
205
  | `release` | on | Create the GitHub release |
205
206
 
207
+ \* `commit` is a conditional default: it no-ops on a clean tree, and on a dirty tree it
208
+ proceeds only when a drafting [assistant](#-assistant-optional) is configured — without
209
+ one, preflight still refuses the unclean tree (with a hint), exactly as before. So
210
+ `release-kit auto --assistant auto` releases a dirty tree end to end: stage, drafted
211
+ commit, then the rest of the pipeline. Opt out with `--skip commit` or a `steps` config.
212
+
206
213
  `version` and `changelog` write files; those writes are persisted by a release commit made
207
214
  automatically when either step runs.
208
215
 
209
216
  ```sh
210
217
  release-kit minor --skip publish # everything but publish
211
218
  release-kit --only tag,push,release # a version already committed elsewhere
212
- release-kit minor --commit # add the opt-in commit step
219
+ release-kit minor --skip commit # never touch uncommitted work
213
220
  ```
214
221
 
215
222
  Or fix it per project, and just run `release-kit minor`:
216
223
 
217
224
  ```json
218
- { "steps": ["commit", "version", "changelog", "tag", "push", "release"] }
225
+ { "steps": ["version", "changelog", "tag", "push", "release"] }
219
226
  ```
220
227
 
221
228
  `steps` decides **what** runs. Every other key describes **how** a step behaves — `publish`
@@ -240,6 +247,14 @@ Notes resolve in this order:
240
247
  than something that requires an assistant.
241
248
  4. Otherwise GitHub generates them from the commits since the previous tag.
242
249
 
250
+ `--notes <source>` forces one instead of walking that list: `changelog`, `assistant`,
251
+ `commits`, or `github`. A named source that produces nothing is an error rather than a
252
+ quiet fall-through — asking for one thing and being given another is worse than being told
253
+ it is unavailable.
254
+
255
+ `--assistant` names the _tool_; `--notes` names the _source_. Making an assistant available
256
+ does not make it preferred, because a hand-written changelog entry should still win.
257
+
243
258
  The same text becomes the tag annotation, the GitHub release body, and (when rolled) the
244
259
  changelog entry. It is written once and lands in three places.
245
260
 
@@ -383,6 +398,23 @@ Stopping at `push` because the tag is what triggers the build pipeline — see
383
398
  The project name comes from the manifest when there is one (`name` in `package.json`,
384
399
  `Cargo.toml` or `pyproject.toml`), and falls back to the repository directory.
385
400
 
401
+ ## 🚂 Release trains
402
+
403
+ Interdependent packages — a monorepo, or a plain folder of sibling git repositories —
404
+ release with the second bin in this package:
405
+
406
+ ```sh
407
+ release-train --dry-run # plan + whole-train preflight, execute nothing
408
+ ```
409
+
410
+ `train.mjs` derives the dependency graph and publish order from the package manifests
411
+ (never from declared config), releases dependencies before dependents with release-kit as
412
+ the per-package worker, rewrites internal ranges, and refuses the whole train before
413
+ anything mutates if any package would fail. A `train.config.json` declares only which
414
+ directories are members. Design, configuration and the full pipeline are in
415
+ [TRAIN.md](TRAIN.md). Prototype status: planning, preflight and `seed-tags` work;
416
+ execution is not wired up yet.
417
+
386
418
  ## ⚙️ Configuration
387
419
 
388
420
  `release.config.json`, beside `package.json`. Every key is optional; unknown keys abort
@@ -430,6 +462,18 @@ re-deriving it: `version`, `tag`, `name`, `dist-tag`, `steps`, `published`, `rel
430
462
  Nothing is written on a dry run, and an unwritable `$GITHUB_OUTPUT` never fails a release
431
463
  that already completed.
432
464
 
465
+ Two upstream habits make commit-derived notes trustworthy, and neither is release-kit's job:
466
+
467
+ - **Gate the release on CI, and guard against forks.** Trigger on `workflow_run` after your
468
+ check workflow succeeds, with `if: github.repository_owner == 'your-org'` so a fork never
469
+ tries to release.
470
+ - **Validate pull request titles.** A squash-merge takes its subject from the PR title, so
471
+ that title becomes the commit the notes are built from.
472
+ [`amannn/action-semantic-pull-request`](https://github.com/amannn/action-semantic-pull-request)
473
+ enforces it. Without something like it, work silently goes missing from release notes —
474
+ release-kit says how many commits are not Conventional Commits, but it cannot fix them
475
+ after the fact.
476
+
433
477
  Three things CI does that are worth knowing about:
434
478
 
435
479
  - **`fetch-depth: 0`.** The default checkout is a shallow clone, which hides the history
@@ -546,13 +590,17 @@ downgrade, so a configured pipeline fails loudly; `"auto"` degrades quietly by d
546
590
 
547
591
  ### What it does
548
592
 
549
- - **`--commit`** stages the working tree, drafts a Conventional Commits message for the
550
- staged diff, and commits — instead of refusing to release. The subject is validated
593
+ - **A dirty working tree is committed instead of refusing to release.** The tree is
594
+ staged, a Conventional Commits message is drafted for the staged diff, and the commit is
595
+ made — by default, whenever an assistant is configured (`--skip commit` opts out). The subject is validated
551
596
  against the Conventional Commits grammar; an answer that does not parse is rejected rather
552
597
  than committed. Attribution lines (`Co-Authored-By`, `Generated with`) are stripped, so
553
598
  the tool never signs your commits.
554
599
  - **Release notes** are drafted from the commits since the last tag when `CHANGELOG.md` has
555
- no section for the version. They are written into the changelog, used as the tag
600
+ no section for the version. Each bullet ends with a link to the commits it covers: the
601
+ assistant is given the short hashes and asked to cite them, and every citation is checked
602
+ against the commits that actually exist. Models invent plausible-looking hashes, so an
603
+ unrecognised one is removed rather than published as a link to nothing. They are written into the changelog, used as the tag
556
604
  annotation, and posted as the GitHub release body — the same "written once, lands in three
557
605
  places" path a hand-written section takes.
558
606
 
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.5.0",
3
+ "version": "2.7.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",