@entro314labs/release-kit 2.10.0 → 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 +32 -27
  2. package/TRAIN.md +78 -41
  3. package/package.json +3 -3
  4. package/release.mjs +177 -23
  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>
@@ -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)
@@ -186,22 +186,23 @@ Prerelease bumps need `--preid` unless the current version already carries one t
186
186
 
187
187
  ### Flags
188
188
 
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
- | `--dry-run` | Print every step, execute nothing. Preflight still runs and still reports. |
195
- | `--yes`, `-y` | Skip the confirmation prompt. |
196
- | `--preid <id>` | Prerelease identifier: `alpha`, `beta`, `rc`, `next`, `nightly`, `canary`. |
197
- | `--dist-tag <name>` | Override the npm dist-tag. Always wins over the derived one. |
198
- | `--notes <source>` | Where notes come from: `auto`, `changelog`, `assistant`, `commits`, `github`. |
199
- | `--notes-file <path>` | Write the resolved notes to a file for the next tool — see `notesFile`. |
200
- | `--assistant <name>` | Drafting CLI: `auto`, `none`, `claude`, `codex`. |
201
- | `--assistant-model <name>` | Model the assistant runs with. |
202
- | `--assistant-effort <level>` | Reasoning effort the assistant runs with. |
203
- | `--sync <dir>...` | Copy this script into other projects and exit. Touches no git state. |
204
- | `--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. |
205
206
 
206
207
  ### Linting commits
207
208
 
@@ -483,7 +484,9 @@ version the dead run already wrote: `minor` after a dead `minor` would release `
483
484
  from the commit `v1.1.0` already tags. Preflight refuses both shapes of that — the bump
484
485
  written but never committed, and the tag at `HEAD` that never reached the registry — and
485
486
  names the command that finishes the earlier release: `release-kit 1.1.0`, or no target, or
486
- `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.
487
490
 
488
491
  ### A release that was never published
489
492
 
@@ -751,6 +754,7 @@ release with the second bin in this package:
751
754
 
752
755
  ```sh
753
756
  release-train --dry-run # plan + whole-train preflight, execute nothing
757
+ release-train # the same, then confirm and release (--yes without a terminal)
754
758
  ```
755
759
 
756
760
  `train.mjs` derives the dependency graph and publish order from the package manifests
@@ -758,13 +762,14 @@ release-train --dry-run # plan + whole-train preflight, execute nothing
758
762
  the per-package worker, rewrites internal ranges, and refuses the whole train before
759
763
  anything mutates if any package would fail. A `train.config.json` declares only which
760
764
  directories are members. Design, configuration and the full pipeline are in
761
- [TRAIN.md](TRAIN.md). Prototype status: planning, preflight and `seed-tags` work;
762
- 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.
763
767
 
764
768
  ## ⚙️ Configuration
765
769
 
766
- `release.config.json`, beside `package.json`. Every key is optional; unknown keys abort
767
- 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.
768
773
 
769
774
  | Key | Default | Meaning |
770
775
  | --------------- | ---------------------- | ----------------------------------------------------------------------- |
@@ -1197,13 +1202,13 @@ npx @entro314labs/release-kit --sync ../project-a ../project-b
1197
1202
  ```
1198
1203
 
1199
1204
  It reports `installed`, `updated`, or `already up to date` per target, creates `scripts/`
1200
- if missing, skips directories with no `package.json`, and warns when a target lacks the
1201
- `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,
1202
1207
  including a directory that is not a repository.
1203
1208
 
1204
1209
  ## 📋 Requirements
1205
1210
 
1206
- - **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
1207
1212
  program whatever it releases; there is no standalone binary.
1208
1213
  - `git`
1209
1214
  - `gh`, authenticated — only when creating GitHub releases
@@ -1219,7 +1224,7 @@ with the reasoning — are in [ROADMAP.md](ROADMAP.md).
1219
1224
 
1220
1225
  ```sh
1221
1226
  pnpm install
1222
- pnpm test # 188 tests, node --test, no framework
1227
+ pnpm test # node --test, no framework
1223
1228
  pnpm check # format + lint + tests, the same gate CI runs
1224
1229
  ```
1225
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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@entro314labs/release-kit",
3
- "version": "2.10.0",
3
+ "version": "2.11.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",
@@ -49,8 +49,8 @@
49
49
  "release": "node release.mjs"
50
50
  },
51
51
  "devDependencies": {
52
- "oxfmt": "^0.67.0",
53
- "oxlint": "^1.82.0",
52
+ "oxfmt": "^0.70.0",
53
+ "oxlint": "^1.85.0",
54
54
  "semver": "^7.8.5"
55
55
  },
56
56
  "engines": {
package/release.mjs CHANGED
@@ -220,6 +220,8 @@ Flags:
220
220
  drafted Conventional Commits message instead of refusing to release
221
221
  --preid <id> prerelease identifier (alpha, beta, rc, next, nightly, canary)
222
222
  --dist-tag <name> override the npm dist-tag (default: derived from the version)
223
+ --package release the package in this directory, one of several in the
224
+ repository: its own tag (<name>@<version>), commits and publish
223
225
  --dry-run print every step and execute nothing
224
226
  --yes, -y skip the confirmation prompt
225
227
  --notes-file <path> write the resolved release notes to a file for the next tool
@@ -286,7 +288,9 @@ const formatStatus = (porcelain) =>
286
288
  .join('\n')
287
289
 
288
290
  function abort(message, title = 'RELEASE ABORTED') {
289
- console.log(`\n${red(bold(title))} — ${message}\n`)
291
+ // Through `say`: under `next`, stdout carries the version alone, and an abort captured by
292
+ // `$(...)` would otherwise become the "version".
293
+ say(`\n${red(bold(title))} — ${message}\n`)
290
294
  process.exit(1)
291
295
  }
292
296
 
@@ -297,9 +301,15 @@ function abort(message, title = 'RELEASE ABORTED') {
297
301
  * the real cause under a Node stack trace.
298
302
  */
299
303
  function abortMidRelease(commandLine) {
304
+ // A relative bump re-run as-is would count from the version this run already wrote, and
305
+ // preflight refuses it; what finishes the release is the version itself, or no target.
306
+ const rerun =
307
+ target && target !== 'auto' && BUMPS.has(target)
308
+ ? 're-run with no target (or `auto`)'
309
+ : 're-run the same command'
300
310
  abort(
301
311
  `\`${commandLine}\` failed — see its output above.\n\n` +
302
- ' The release stopped partway through. Fix the cause and re-run the same command:\n' +
312
+ ` The release stopped partway through. Fix the cause and ${rerun}:\n` +
303
313
  ' the steps that already completed are detected and skipped.',
304
314
  )
305
315
  }
@@ -578,7 +588,7 @@ function commitsSinceLastTag(options) {
578
588
  // separator keeps multi-line messages parseable when splitting the log back apart.
579
589
  // %h first, then the author, then the message: the hash is what links each bullet back
580
590
  // to its commit, and the author is what says who is new here.
581
- const raw = tryRead('git', ['log', `--format=%h%x1f%an%x1f%ae%x1f%B%x1e`, range]) ?? ''
591
+ const raw = tryRead('git', ['log', `--format=%h%x1f%an%x1f%ae%x1f%B%x1e`, range, ...SCOPE]) ?? ''
582
592
  const commits = raw
583
593
  .split('\u001E')
584
594
  .map((entry) => entry.trim())
@@ -1281,7 +1291,9 @@ function pushBranchAndTag(branchRef) {
1281
1291
  } catch (err) {
1282
1292
  const stderr = `${err.stderr ?? ''}`
1283
1293
  process.stderr.write(stderr)
1284
- if (!/atomic/i.test(stderr)) abortMidRelease(line)
1294
+ // Only the capability refusal falls back. git's rejection of a ref also names atomic
1295
+ // ("atomic push failed"), and that one must stop here.
1296
+ if (!/does not support --atomic/i.test(stderr)) abortMidRelease(line)
1285
1297
  }
1286
1298
  warn(
1287
1299
  `${config.remote} does not support atomic pushes — sending the branch and tag in one ` +
@@ -1493,6 +1505,10 @@ function candidateSections(text, version) {
1493
1505
  /** The `## ` heading offsets in a changelog, in file order. */
1494
1506
  const sectionOffsets = (text) => [...text.matchAll(/^## .*$/gm)].map((m) => m.index)
1495
1507
 
1508
+ /** True when the changelog already has a `## [version]` heading, empty body or not. */
1509
+ const hasVersionHeading = (text, version) =>
1510
+ new RegExp(`^##\\s+\\[?v?${escapeRe(version)}\\]?(?![\\w.-])`, 'm').test(text)
1511
+
1496
1512
  /**
1497
1513
  * Place a version's section where it belongs: above the first section whose version is
1498
1514
  * lower, rather than wherever the file happens to start.
@@ -1601,7 +1617,7 @@ function withChangelogLinks(text, links, tagPrefix = '') {
1601
1617
  function rollUnreleased(text, version, date) {
1602
1618
  // A heading for this version already exists — possibly with an empty body, which
1603
1619
  // `changelogSection` reports as absent. Rolling again would duplicate the heading.
1604
- if (new RegExp(`^##\\s+\\[?v?${escapeRe(version)}\\]?(?![\\w.-])`, 'm').test(text)) return null
1620
+ if (hasVersionHeading(text, version)) return null
1605
1621
 
1606
1622
  const heading = /^##\s+\[?Unreleased\]?[^\n]*$/im
1607
1623
  const match = heading.exec(text)
@@ -2187,7 +2203,16 @@ const VALUE_OPTIONS = new Set([
2187
2203
  ])
2188
2204
 
2189
2205
  /** Flags that take no value. With VALUE_OPTIONS, the whole vocabulary this file accepts. */
2190
- const BOOLEAN_FLAGS = new Set(['--dry-run', '--yes', '-y', '--commit', '--help', '-h', '--sync'])
2206
+ const BOOLEAN_FLAGS = new Set([
2207
+ '--dry-run',
2208
+ '--yes',
2209
+ '-y',
2210
+ '--commit',
2211
+ '--package',
2212
+ '--help',
2213
+ '-h',
2214
+ '--sync',
2215
+ ])
2191
2216
 
2192
2217
  /** The version or bump target: the only argument that is neither a flag nor a flag's value. */
2193
2218
  const positionals = []
@@ -2222,6 +2247,14 @@ if (positionals.length > 1) {
2222
2247
  : ''
2223
2248
  abort(`unexpected argument${extras.length > 1 ? 's' : ''}: ${extras.join(' ')}${hint}`)
2224
2249
  }
2250
+ // Only a pre* bump reads --preid. Anywhere else it was accepted and dropped, so
2251
+ // `auto --preid beta` released a stable version to the `latest` dist-tag.
2252
+ if (requestedPreid !== undefined && !target?.startsWith('pre')) {
2253
+ abort(
2254
+ `--preid only applies to prepatch, preminor, premajor and prerelease, not ${target ? `"${target}"` : 'a release with no target'}.\n` +
2255
+ ` For a ${requestedPreid} prerelease: release-kit prerelease --preid ${requestedPreid}`,
2256
+ )
2257
+ }
2225
2258
 
2226
2259
  // ─────────────────────────────────────────────────────────────────────────────
2227
2260
  // SETUP
@@ -2230,26 +2263,89 @@ if (positionals.length > 1) {
2230
2263
  const root = tryRead('git', ['rev-parse', '--show-toplevel'])
2231
2264
  if (!root) abort('not inside a git repository')
2232
2265
 
2233
- // A release is scoped to the repository: the version, the tag and the push all belong to
2234
- // one git history, so the package released is the one at the git root. Refuse when invoked
2235
- // from a nested package instead — silently releasing the parent is the worse outcome.
2266
+ /**
2267
+ * A release is scoped to the repository: the version, the tag and the push all belong to
2268
+ * one git history, so the package released is the one at the git root. `--package` scopes
2269
+ * it to the working directory instead — one package among several in the repository, as
2270
+ * release-train runs it. Its config, manifest, changelog, verify and publish are that
2271
+ * directory's; the commits it reads and the working tree it checks are limited to the
2272
+ * directory; and it tags `<name>@<version>` unless its config names a `tagPrefix`, so
2273
+ * packages sharing a history keep separate tags.
2274
+ */
2275
+ const packageMode = flag('--package')
2276
+
2277
+ // Without --package, refuse a nested package rather than silently releasing the parent.
2236
2278
  const localManifest = resolve('package.json')
2237
2279
  const rootManifest = join(root, 'package.json')
2238
- if (existsSync(localManifest) && localManifest !== rootManifest) {
2280
+ if (!packageMode && existsSync(localManifest) && localManifest !== rootManifest) {
2239
2281
  abort(
2240
2282
  `${relative(root, localManifest)} is a nested package, but a release covers the whole ` +
2241
2283
  `repository.\n\n Running here would release ${
2242
2284
  existsSync(rootManifest) ? readJson(rootManifest).name : 'the repository root'
2243
2285
  } instead.\n` +
2244
- ' release-kit handles one package per repository; it does not release workspace members.',
2286
+ ' To release this package on its own — its own tag, commits, changelog and publish —\n' +
2287
+ ' run with --package (release-train does, for a repository holding several).',
2245
2288
  )
2246
2289
  }
2247
- process.chdir(root)
2290
+ if (!packageMode) process.chdir(root)
2291
+
2292
+ /** The pathspec a package release limits `git log`, `git status` and `git add` to. */
2293
+ const SCOPE = packageMode ? ['--', '.'] : []
2248
2294
 
2249
2295
  const userConfig = readUserConfig()
2250
2296
  const config = { ...DEFAULTS, ...userConfig }
2251
2297
  const unknownKeys = Object.keys(config).filter((key) => !(key in DEFAULTS))
2252
2298
  if (unknownKeys.length) abort(`release.config.json has unknown keys: ${unknownKeys.join(', ')}`)
2299
+
2300
+ /**
2301
+ * What each key has to hold. A known key with the wrong shape was accepted and then read as
2302
+ * if it were right: `"steps": "tag,push"` ran no step at all and still reported a release,
2303
+ * `"tagPrefix": null` tagged `null1.1.0`, and a string where an array belongs crashed with a
2304
+ * TypeError after the tag was pushed.
2305
+ */
2306
+ const isString = (value) => typeof value === 'string'
2307
+ const isStringOrNull = (value) => value === null || isString(value)
2308
+ const isStringArray = (value) => Array.isArray(value) && value.every(isString)
2309
+ const isObject = (value) => !!value && typeof value === 'object' && !Array.isArray(value)
2310
+ const CONFIG_SHAPES = {
2311
+ steps: [isStringArray, 'an array of step names'],
2312
+ tagPrefix: [isString, 'a string'],
2313
+ branch: [isStringOrNull, 'a string, or null'],
2314
+ remote: [isString, 'a string'],
2315
+ changelog: [isStringOrNull, 'a string, or null'],
2316
+ versionFile: [
2317
+ (v) => v === undefined || isStringOrNull(v) || isObject(v),
2318
+ 'a path, an object, or null',
2319
+ ],
2320
+ versionFiles: [
2321
+ (v) => Array.isArray(v) && v.every((f) => isString(f) || (isObject(f) && isString(f.path))),
2322
+ 'an array of paths or { path, pattern } objects',
2323
+ ],
2324
+ publish: [
2325
+ (v) => v === undefined || isStringOrNull(v) || isStringArray(v),
2326
+ 'a command, an array of commands, or null',
2327
+ ],
2328
+ commitMessage: [isString, 'a string'],
2329
+ releaseTitle: [isString, 'a string'],
2330
+ assets: [isStringArray, 'an array of paths'],
2331
+ assistant: [(v) => isStringOrNull(v) || isObject(v), 'a name, an object, or null'],
2332
+ notesFile: [isStringOrNull, 'a path, or null'],
2333
+ versioning: [
2334
+ (v) => ['conventional', 'always-patch', 'always-minor', 'always-major'].includes(v),
2335
+ 'one of conventional, always-patch, always-minor, always-major',
2336
+ ],
2337
+ notes: [isString, 'a string'],
2338
+ hiddenTypes: [isStringArray, 'an array of commit types'],
2339
+ ignoreCommits: [isStringArray, 'an array of regexes'],
2340
+ verify: [isStringOrNull, 'a command, or null'],
2341
+ requireGreen: [(v) => typeof v === 'boolean', 'true or false'],
2342
+ hooks: [(v) => isObject(v) && Object.values(v).every(isString), 'an object of command strings'],
2343
+ }
2344
+ const misshapen = Object.entries(CONFIG_SHAPES)
2345
+ .filter(([key, [valid]]) => !valid(config[key]))
2346
+ .map(([key, [, expected]]) => ` ${key} must be ${expected}, not ${JSON.stringify(config[key])}`)
2347
+ if (misshapen.length)
2348
+ abort(`release.config.json has keys of the wrong type:\n${misshapen.join('\n')}`)
2253
2349
  // A misspelled hook name is a hook that silently never runs, which is the failure mode
2254
2350
  // this file refuses everywhere else it takes a name.
2255
2351
  const unknownHooks = Object.keys(config.hooks ?? {}).filter((key) => !HOOKS.includes(key))
@@ -2430,6 +2526,19 @@ function versionFromLastTag() {
2430
2526
  return releaseTags()[0]?.version ?? null
2431
2527
  }
2432
2528
 
2529
+ // A package's tags are `<name>@<version>`, the scheme release-train reads, unless its
2530
+ // config names a prefix. Set before anything reads a tag.
2531
+ if (packageMode && !Object.hasOwn(userConfig, 'tagPrefix')) {
2532
+ const name = manifest?.name ?? (versionFile ? readNameFrom(versionFile) : null)
2533
+ if (!name) {
2534
+ abort(
2535
+ '--package tags a release <name>@<version>, and no package name was found here.\n' +
2536
+ ' Set "tagPrefix" in this package\'s release.config.json.',
2537
+ )
2538
+ }
2539
+ config.tagPrefix = `${name}@`
2540
+ }
2541
+
2433
2542
  const currentVersion = versionFile ? readVersionFrom(versionFile) : versionFromLastTag()
2434
2543
  if (versionFile && !currentVersion) {
2435
2544
  abort(`could not read a version from ${versionFile.path}`)
@@ -2443,7 +2552,10 @@ const goModule = existsSync('go.mod')
2443
2552
  ? (/^module\s+(\S+)/m.exec(readFileSync('go.mod', 'utf8'))?.[1] ?? null)
2444
2553
  : null
2445
2554
  const projectName =
2446
- manifest?.name ?? (versionFile ? readNameFrom(versionFile) : null) ?? goModule ?? basename(root)
2555
+ manifest?.name ??
2556
+ (versionFile ? readNameFrom(versionFile) : null) ??
2557
+ goModule ??
2558
+ basename(process.cwd())
2447
2559
 
2448
2560
  /**
2449
2561
  * Manifests found beside the primary one that carry the same version. Detected only when
@@ -2716,9 +2828,35 @@ function versionShipped(v) {
2716
2828
  */
2717
2829
  function unfinishedRelease() {
2718
2830
  const [newest] = releaseTags()
2719
- if (!newest || versionShipped(newest.version) !== false) return null
2831
+ if (!newest) return null
2720
2832
  const at = tryRead('git', ['rev-list', '-n', '1', newest.name])
2721
- return at && at === tryRead('git', ['rev-parse', 'HEAD']) ? newest : null
2833
+ if (!at || at !== tryRead('git', ['rev-parse', 'HEAD'])) return null
2834
+ const shipped = versionShipped(newest.version)
2835
+ if (shipped === false) return { ...newest, missing: 'the registry' }
2836
+ // With no registry to ask — `publish: null`, or one that could not answer — the push is
2837
+ // the last step that leaves a mark to check. A tag at HEAD the remote does not have is a
2838
+ // release that died before or during the push.
2839
+ if (shipped === null && runs('push') && tagOnRemote(newest.name) === false) {
2840
+ return { ...newest, missing: config.remote }
2841
+ }
2842
+ return null
2843
+ }
2844
+
2845
+ /**
2846
+ * Whether the remote has this tag: false only when it answered without it, null when it
2847
+ * could not be asked.
2848
+ *
2849
+ * @param {string} name
2850
+ * @returns {boolean | null}
2851
+ */
2852
+ function tagOnRemote(name) {
2853
+ try {
2854
+ read('git', ['ls-remote', '--exit-code', '--tags', config.remote, `refs/tags/${name}`])
2855
+ return true
2856
+ } catch (err) {
2857
+ // --exit-code exits 2 for "no matching refs"; anything else is the remote not answering.
2858
+ return err.status === 2 ? false : null
2859
+ }
2722
2860
  }
2723
2861
 
2724
2862
  // ─────────────────────────────────────────────────────────────────────────────
@@ -2735,6 +2873,8 @@ let autoBump = null
2735
2873
 
2736
2874
  /** The tag of a previous release this run is finishing rather than starting. */
2737
2875
  let resuming = null
2876
+ /** What that release never reached: the registry, or the remote. */
2877
+ let resumingMissing = null
2738
2878
 
2739
2879
  /**
2740
2880
  * A release tagged at HEAD that never reached the registry, found while resolving a
@@ -2751,7 +2891,7 @@ let unfinishedAtHead = null
2751
2891
  * ship a tree the tag does not describe; that work belongs in the next version, which is
2752
2892
  * what the shipped-tag baseline makes sure it is released as.
2753
2893
  */
2754
- const wouldCommitMore = !!tryRead('git', ['status', '--porcelain']) && runs('commit')
2894
+ const wouldCommitMore = !!tryRead('git', ['status', '--porcelain', ...SCOPE]) && runs('commit')
2755
2895
 
2756
2896
  let version
2757
2897
  if (!target) {
@@ -2771,7 +2911,7 @@ if (!target) {
2771
2911
  }
2772
2912
  const pending = wouldCommitMore ? null : unfinishedRelease()
2773
2913
  if (pending) {
2774
- ;({ name: resuming, version } = pending)
2914
+ ;({ name: resuming, version, missing: resumingMissing } = pending)
2775
2915
  } else {
2776
2916
  const { commits, lastTag } = commitsSinceLastTag({ shipped: true })
2777
2917
  if (!commits.length) {
@@ -2880,12 +3020,15 @@ function runHook(name) {
2880
3020
  */
2881
3021
  function dirtyPaths() {
2882
3022
  return (
2883
- (tryRead('git', ['status', '--porcelain']) ?? '')
3023
+ (tryRead('git', ['status', '--porcelain', ...SCOPE]) ?? '')
2884
3024
  .split('\n')
2885
3025
  .map((line) => /^\s*\S{1,2}\s+(.+)$/.exec(line)?.[1]?.trim())
2886
3026
  .filter(Boolean)
2887
3027
  // A rename reads as "old -> new"; the new path is the one to stage.
2888
3028
  .map((path) => path.split(' -> ').at(-1))
3029
+ // Porcelain paths are relative to the repository root; everything they are compared
3030
+ // with and staged as is relative to the working directory, which --package moves.
3031
+ .map((path) => relative(process.cwd(), join(root, path)))
2889
3032
  )
2890
3033
  }
2891
3034
 
@@ -2945,12 +3088,12 @@ if (autoBump) {
2945
3088
  }
2946
3089
 
2947
3090
  if (resuming) {
2948
- ok(`finishing ${resuming}: it was tagged and pushed, but never reached the registry`)
3091
+ ok(`finishing ${resuming}: it was tagged, but never reached ${resumingMissing}`)
2949
3092
  }
2950
3093
 
2951
3094
  if (unfinishedAtHead) {
2952
3095
  fail(
2953
- `${unfinishedAtHead.name} is tagged at HEAD but never reached the registry, and a ` +
3096
+ `${unfinishedAtHead.name} is tagged at HEAD but never reached ${unfinishedAtHead.missing}, and a ` +
2954
3097
  `${target} bump would release ${version} from the same commit and leave it that way.\n` +
2955
3098
  ` Finish it instead: re-run with no target, or with auto.`,
2956
3099
  )
@@ -3096,7 +3239,7 @@ if (bumping && currentVersion && compareVersions(version, currentVersion) <= 0)
3096
3239
  ok(`releasing ${version} (no version file; the tag is the version)`)
3097
3240
  }
3098
3241
 
3099
- const dirty = tryRead('git', ['status', '--porcelain'])
3242
+ const dirty = tryRead('git', ['status', '--porcelain', ...SCOPE])
3100
3243
  if (dirty === null) fail('could not read git status')
3101
3244
  else if (dirty && runs('commit')) {
3102
3245
  const entries = dirty.split('\n')
@@ -3779,6 +3922,16 @@ for (const asset of config.assets) {
3779
3922
  else fail(`asset ${asset} does not exist`)
3780
3923
  }
3781
3924
 
3925
+ /**
3926
+ * Whether drafted notes still have to be filed in the changelog. A named source (`commits`,
3927
+ * `assistant`) drafts even when the section exists — a resume, or one written by hand — and
3928
+ * filing it again would duplicate the heading and commit past a reused tag.
3929
+ */
3930
+ const draftedSectionMissing = () =>
3931
+ !!config.changelog &&
3932
+ existsSync(config.changelog) &&
3933
+ !hasVersionHeading(readFileSync(config.changelog, 'utf8'), version)
3934
+
3782
3935
  // Reusing a tag is the resume path, and a resume writes nothing. If this run would still
3783
3936
  // produce a commit, that commit moves HEAD past the tag and the release ends up tagged at
3784
3937
  // the wrong revision — which is silent until someone checks out the tag, and worse for the
@@ -3789,6 +3942,7 @@ const wouldCommit = [
3789
3942
  // The roll is computed whenever [Unreleased] is populated, because the notes come from
3790
3943
  // it either way; it is only written — and only becomes a commit — when the step runs.
3791
3944
  rolledChangelog && runs('changelog') && 'a changelog entry',
3945
+ draftedNotes && runs('changelog') && draftedSectionMissing() && 'a drafted changelog section',
3792
3946
  ].filter(Boolean)
3793
3947
  if (taggedCommit && runs('tag') && wouldCommit.length) {
3794
3948
  fail(
@@ -3835,7 +3989,7 @@ let commitMessage = null
3835
3989
  let didStage = false
3836
3990
  if (dirty && runs('commit') && !dryRun) {
3837
3991
  step('Stage the working tree')
3838
- mutate('git', ['add', '--all'])
3992
+ mutate('git', ['add', '--all', ...SCOPE])
3839
3993
  didStage = true
3840
3994
  commitMessage = assistant ? draftCommitMessage() : null
3841
3995
  if (!commitMessage) {
@@ -3951,7 +4105,7 @@ if (rolledChangelog && runs('changelog')) {
3951
4105
  if (dryRun) console.log(` ${yellow('would write')} ${config.changelog}`)
3952
4106
  else writeFileSync(config.changelog, linked(rolledChangelog))
3953
4107
  staged.push(config.changelog)
3954
- } else if (runs('changelog') && draftedNotes && config.changelog && existsSync(config.changelog)) {
4108
+ } else if (runs('changelog') && draftedNotes && draftedSectionMissing()) {
3955
4109
  step(`Add the drafted ${version} section to ${config.changelog}`)
3956
4110
  if (dryRun) console.log(` ${yellow('would write')} ${config.changelog}`)
3957
4111
  else {
package/train.mjs CHANGED
@@ -1,18 +1,19 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * release-train — orchestrated releases for interdependent packages, prototype.
3
+ * release-train — orchestrated releases for interdependent packages.
4
4
  *
5
- * Implements the read-only phases of TRAIN.md — discover → graph → detect changes →
6
- * cascade → plan → preflight — plus `seed-tags`, which establishes baseline release tags.
7
- * Execution (releasing via release-kit) is not implemented yet; `train` without
8
- * --dry-run says so and exits.
5
+ * Implements TRAIN.md's pipeline — discover → graph → detect changes → cascade → plan →
6
+ * preflight → execute → report — plus `seed-tags`, which establishes baseline release tags.
7
+ * Execution runs the release-kit beside this file once per package, in dependency order; a
8
+ * package that shares its git repository with other members runs with `--package`.
9
9
  *
10
+ * train plan + preflight, confirm, release (--yes skips the prompt)
10
11
  * train graph print the derived dependency graph and topo order
11
12
  * train --dry-run full plan + whole-train preflight, execute nothing
12
- * train --dry-run --all plan every member, not just changed ones
13
- * train --dry-run <id>... plan these packages and their dependents
13
+ * train --all every member, not just changed ones
14
+ * train <id>... these packages and their dependents
14
15
  * train seed-tags create baseline tags at each repo's HEAD (--dry-run to preview)
15
- * train --offline skip network work (registry lookups, tag pushes)
16
+ * train --offline skip network work (registry lookups, tag pushes); dry runs only
16
17
  * train --config <path> config elsewhere than ./train.config.json
17
18
  *
18
19
  * Reads train.config.json in the working directory. Config declares membership and
@@ -29,7 +30,7 @@
29
30
  * 3. The cascade: a dependent of a releasing package joins with at least a patch.
30
31
  */
31
32
 
32
- import { execFileSync } from 'node:child_process'
33
+ import { execFileSync, spawnSync } from 'node:child_process'
33
34
  import {
34
35
  existsSync,
35
36
  mkdtempSync,
@@ -40,7 +41,8 @@ import {
40
41
  } from 'node:fs'
41
42
  import { tmpdir } from 'node:os'
42
43
  import { basename, dirname, join, relative, resolve } from 'node:path'
43
- import { pathToFileURL } from 'node:url'
44
+ import { createInterface } from 'node:readline/promises'
45
+ import { fileURLToPath, pathToFileURL } from 'node:url'
44
46
 
45
47
  // ─────────────────────────────────────────────────────────────────────────────
46
48
  // Small utilities
@@ -206,6 +208,12 @@ export function loadConfig(configPath) {
206
208
  if (!RANGE_POLICIES.has(rangePolicy))
207
209
  fail(`rangePolicy must be one of: ${[...RANGE_POLICIES].join(', ')}`)
208
210
  const registryWait = { timeout: 300, interval: 5, ...config.registryWait }
211
+ for (const key of ['timeout', 'interval']) {
212
+ if (typeof registryWait[key] !== 'number' || !(registryWait[key] > 0))
213
+ fail(`registryWait.${key} must be a positive number of seconds`)
214
+ }
215
+ const extraWaitKeys = Object.keys(registryWait).filter((k) => k !== 'timeout' && k !== 'interval')
216
+ if (extraWaitKeys.length) fail(`unknown registryWait key: ${extraWaitKeys.join(', ')}`)
209
217
  const assistant = normalizeAssistant(config.assistant ?? null)
210
218
  if (assistant?.error) fail(assistant.error)
211
219
  const summaryFile = config.summaryFile ?? null
@@ -216,6 +224,8 @@ export function loadConfig(configPath) {
216
224
 
217
225
  const KNOWN_FLAGS = new Set([
218
226
  '--dry-run',
227
+ '--yes',
228
+ '-y',
219
229
  '--all',
220
230
  '--offline',
221
231
  '--config',
@@ -231,6 +241,7 @@ function parseArgs(argv) {
231
241
  command: null,
232
242
  ids: [],
233
243
  dryRun: false,
244
+ yes: false,
234
245
  all: false,
235
246
  offline: false,
236
247
  configPath: null,
@@ -251,6 +262,7 @@ function parseArgs(argv) {
251
262
  const normalized = normalizeAssistant(name)
252
263
  args.assistant = normalized?.error ? fail(normalized.error) : normalized
253
264
  } else if (arg === '--dry-run') args.dryRun = true
265
+ else if (arg === '--yes' || arg === '-y') args.yes = true
254
266
  else if (arg === '--all') args.all = true
255
267
  else if (arg === '--offline') args.offline = true
256
268
  else if (arg === '--help' || arg === '-h') args.help = true
@@ -263,6 +275,10 @@ function parseArgs(argv) {
263
275
  fail('--all and explicit package ids conflict — pass one or the other')
264
276
  if (args.command === 'graph' && (args.all || args.ids.length))
265
277
  fail('graph takes no package ids or --all')
278
+ // A release publishes and waits on the registry; neither is possible offline, and a
279
+ // train that skipped them would release dependents onto versions nobody can install.
280
+ if (args.offline && !args.dryRun && args.command === null)
281
+ fail('--offline cannot release — it skips the registry a train publishes to. Add --dry-run')
266
282
  return args
267
283
  }
268
284
 
@@ -371,8 +387,10 @@ export function discover(rootDir, config) {
371
387
  repoRelPath: repoDir ? relative(repoDir, dir) || '.' : null,
372
388
  publish: entry.publish !== false,
373
389
  branch: releaseConfig.branch === undefined ? 'main' : releaseConfig.branch,
374
- // The prefix release-kit will tag with, and so the one its history is read under.
375
- tagPrefix: releaseConfig.tagPrefix ?? 'v',
390
+ // The prefix release-kit will tag with, and so the one its history is read under;
391
+ // null when the package's config leaves it to the default (see tagPatternFor).
392
+ tagPrefix: releaseConfig.tagPrefix ?? null,
393
+ requireGreen: releaseConfig.requireGreen === true,
376
394
  ...manifest,
377
395
  })
378
396
  }
@@ -450,13 +468,12 @@ export function topoSort(orderEdges) {
450
468
  // ─────────────────────────────────────────────────────────────────────────────
451
469
 
452
470
  /**
453
- * Tag scheme: the package's own `tagPrefix` (`v` unless its release.config.json says
454
- * otherwise) when the repo owns exactly one member, `<name>@<version>` when it owns
455
- * several — which keeps single-package repos identical to standalone release-kit.
471
+ * Tag scheme: the package's own `tagPrefix` when its release.config.json sets one;
472
+ * otherwise `v` when the repo owns exactly one member, and `<name>@` when it owns several
473
+ * — the defaults release-kit itself tags with, standalone and under `--package`.
456
474
  */
457
475
  export function tagPatternFor(member, repoMemberCount) {
458
- if (repoMemberCount > 1) return { prefix: `${member.name}@`, glob: `${member.name}@*` }
459
- const prefix = member.tagPrefix ?? 'v'
476
+ const prefix = member.tagPrefix ?? (repoMemberCount > 1 ? `${member.name}@` : 'v')
460
477
  return { prefix, glob: `${prefix}*` }
461
478
  }
462
479
 
@@ -759,6 +776,38 @@ function preflight({
759
776
 
760
777
  for (const item of plan) {
761
778
  const { member, next } = item
779
+ // A member sharing its repository releases under --package, tagged `<name>@`; with no
780
+ // name and no configured prefix release-kit would stop at this package's turn.
781
+ if ((repoMemberCounts.get(member.repoDir) ?? 1) > 1 && !member.name && !member.tagPrefix) {
782
+ failures.push(
783
+ `${item.id}: shares ${relative(rootDir, member.repoDir) || '.'} with other members but has no package name to tag with — set "tagPrefix" in its release.config.json`,
784
+ )
785
+ }
786
+ // requireGreen refuses a HEAD CI has not passed. The train moves this member's HEAD
787
+ // before its turn — its own deps commit, or an earlier member's release commit in the
788
+ // same repository — so the refusal would come mid-train, after others released.
789
+ const earlierInRepo = plan
790
+ .slice(0, plan.indexOf(item))
791
+ .some((other) => other.member.repoDir === member.repoDir)
792
+ if (member.requireGreen && (item.rewrites.some((r) => r.to) || earlierInRepo)) {
793
+ failures.push(
794
+ `${item.id}: its release.config.json sets requireGreen, and the train commits to its repository before its turn — CI cannot have passed that commit; turn requireGreen off for train releases`,
795
+ )
796
+ }
797
+ // Moving a range makes the lockfile that records it stale, and a frozen install in
798
+ // that repository's CI then fails. The train refreshes it — with the tool that owns it.
799
+ if (item.rewrites.some((r) => r.to)) {
800
+ const lock = findLockfile(member)
801
+ if (lock && !lock.tool) {
802
+ failures.push(
803
+ `${item.id}: ${lock.name} records the ranges the train rewrites, and the train cannot refresh it — use workspace: ranges or an npm/pnpm lockfile`,
804
+ )
805
+ } else if (lock && !toolAvailable(lock.tool)) {
806
+ failures.push(
807
+ `${item.id}: ${lock.name} has to be refreshed after its ranges move, and ${lock.tool} is not installed`,
808
+ )
809
+ }
810
+ }
762
811
  if (!member.version) {
763
812
  warnings.push(
764
813
  `${item.id}: no manifest version (${member.ecosystem}); current version must come from its last release tag`,
@@ -944,7 +993,7 @@ function seedTags({ members, repoMemberCounts, registry, dryRun, offline }) {
944
993
  * Markdown report of the whole train: every package with its version movement and why,
945
994
  * plus the dependency ripple — which changes pulled which dependents in. Deterministic
946
995
  * and buildable from the plan alone; per-package release notes stay per-package
947
- * (release-kit owns those). `mode` is 'planned' until execution exists.
996
+ * (release-kit owns those). `mode` says whether the rows were planned or released.
948
997
  */
949
998
  export function buildSummary(plan, { workspace, date, mode = 'planned' }) {
950
999
  const lines = [
@@ -1101,6 +1150,232 @@ function draftAnnouncement(assistant, summaryMarkdown) {
1101
1150
  }
1102
1151
  }
1103
1152
 
1153
+ // ─────────────────────────────────────────────────────────────────────────────
1154
+ // Execute — release-kit once per package, dependencies first
1155
+ // ─────────────────────────────────────────────────────────────────────────────
1156
+
1157
+ /**
1158
+ * The release-kit shipped beside this file. Not the one on PATH and not a copy vendored in
1159
+ * each package: a train is one release, and one release-kit version runs all of it.
1160
+ */
1161
+ const RELEASE_KIT = join(dirname(fileURLToPath(import.meta.url)), 'release.mjs')
1162
+
1163
+ const DEPENDENCY_MAPS = ['dependencies', 'peerDependencies', 'optionalDependencies']
1164
+
1165
+ /**
1166
+ * Lockfiles that record dependency ranges, nearest first, with the command that brings one
1167
+ * back into step with its manifest. `tool: null` is a lockfile the train will not guess a
1168
+ * refresh for; preflight refuses it rather than committing it stale.
1169
+ */
1170
+ const LOCKFILES = [
1171
+ {
1172
+ name: 'pnpm-lock.yaml',
1173
+ tool: 'pnpm',
1174
+ args: ['install', '--lockfile-only', '--ignore-scripts'],
1175
+ },
1176
+ {
1177
+ name: 'package-lock.json',
1178
+ tool: 'npm',
1179
+ args: ['install', '--package-lock-only', '--ignore-scripts'],
1180
+ },
1181
+ {
1182
+ name: 'npm-shrinkwrap.json',
1183
+ tool: 'npm',
1184
+ args: ['install', '--package-lock-only', '--ignore-scripts'],
1185
+ },
1186
+ { name: 'yarn.lock', tool: null },
1187
+ { name: 'bun.lock', tool: null },
1188
+ { name: 'bun.lockb', tool: null },
1189
+ ]
1190
+
1191
+ /** The lockfile governing a member: in its directory, or up to its repository root (a workspace). */
1192
+ function findLockfile(member) {
1193
+ let { dir } = member
1194
+ for (;;) {
1195
+ const lock = LOCKFILES.find(({ name }) => existsSync(join(dir, name)))
1196
+ if (lock) return { ...lock, dir, path: join(dir, lock.name) }
1197
+ if (!member.repoDir || dir === member.repoDir || dir === dirname(dir)) return null
1198
+ dir = dirname(dir)
1199
+ }
1200
+ }
1201
+
1202
+ function toolAvailable(tool) {
1203
+ return spawnSync(tool, ['--version'], { stdio: 'ignore', timeout: 20_000 }).status === 0
1204
+ }
1205
+
1206
+ /**
1207
+ * A package.json with its internal ranges moved to the versions the train is releasing.
1208
+ * Every map that names the dependency is rewritten from its own current range, and
1209
+ * `workspace:` ranges are left to the package manager. Returns null when nothing changes —
1210
+ * the rewrite of a resumed train is already committed.
1211
+ *
1212
+ * @param {string} text the manifest as read
1213
+ * @param {Map<string, string>} targets dependency name → the version it releases
1214
+ * @param {string} policy rangePolicy
1215
+ * @returns {{ text: string, changed: string[] } | null}
1216
+ */
1217
+ export function rewriteManifest(text, targets, policy) {
1218
+ const manifest = JSON.parse(text)
1219
+ const changed = new Set()
1220
+ for (const key of DEPENDENCY_MAPS) {
1221
+ for (const [name, version] of targets) {
1222
+ const current = manifest[key]?.[name]
1223
+ if (current === undefined) continue
1224
+ const next = rewriteRange(policy, current, version)
1225
+ if (next === null || next === current) continue
1226
+ manifest[key][name] = next
1227
+ changed.add(`${name} ${next}`)
1228
+ }
1229
+ }
1230
+ if (!changed.size) return null
1231
+ const indent = /^\{\r?\n([ \t]+)"/.exec(text)?.[1] ?? ' '
1232
+ return { text: `${JSON.stringify(manifest, null, indent)}\n`, changed: [...changed] }
1233
+ }
1234
+
1235
+ /**
1236
+ * The release-kit command line for one plan item. The version is always passed explicitly
1237
+ * — release-kit then releases exactly what the plan printed — except for a package with no
1238
+ * manifest version (Go), whose version only release-kit can read, from its tags.
1239
+ */
1240
+ export function releaseArgs(item, { noAssistant = false } = {}) {
1241
+ const args = [item.next ?? 'auto', '--yes']
1242
+ // One member of several in its repository: release the directory, not the repository.
1243
+ if (item.member.sharesRepo) args.push('--package')
1244
+ // `publish: false` in the train config: versioned, tagged and released, never published.
1245
+ if (!item.member.publish) args.push('--skip', 'publish')
1246
+ // `--assistant none` is the one assistant value forwarded: a whole-train kill switch.
1247
+ // Forcing a drafting tool onto packages that did not opt in stays impossible.
1248
+ if (noAssistant) args.push('--assistant', 'none')
1249
+ return args
1250
+ }
1251
+
1252
+ const sleep = (seconds) =>
1253
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, seconds * 1000)
1254
+
1255
+ /**
1256
+ * Poll until `name@version` is on the registry. A dependent released before its dependency
1257
+ * is visible fails its own install or publish, and registries replicate with a lag.
1258
+ */
1259
+ function waitForRegistry(name, version, { timeout, interval }) {
1260
+ const deadline = Date.now() + timeout * 1000
1261
+ for (;;) {
1262
+ const found = spawnSync('npm', ['view', `${name}@${version}`, 'version'], {
1263
+ encoding: 'utf8',
1264
+ stdio: ['ignore', 'pipe', 'pipe'],
1265
+ timeout: 20_000,
1266
+ })
1267
+ if (found.status === 0) return true
1268
+ if (Date.now() + interval * 1000 > deadline) return false
1269
+ sleep(interval)
1270
+ }
1271
+ }
1272
+
1273
+ /**
1274
+ * Release the plan, in order. Each package: move its internal ranges onto the versions
1275
+ * just released and commit that, run release-kit, then — when a later package depends on
1276
+ * it — wait for the registry to show it. The first failure stops the train; everything
1277
+ * before it is fully released, and a re-run resumes (see TRAIN.md, Failure and resume).
1278
+ *
1279
+ * @returns {{ released: string[], failed: string | null, reason: string | null }}
1280
+ */
1281
+ function executePlan(plan, { rangePolicy, registryWait, noAssistant, env = process.env }) {
1282
+ const released = []
1283
+ const nextById = new Map(plan.map((item) => [item.id, item.next]))
1284
+ const stop = (item, reason) => ({ released, failed: item.id, reason })
1285
+
1286
+ for (const [index, item] of plan.entries()) {
1287
+ const { member } = item
1288
+ console.log(`\n━━ ${index + 1}/${plan.length} ${item.id} ${item.next ?? '(version from tag)'}`)
1289
+
1290
+ const targets = new Map(
1291
+ item.rewrites
1292
+ .filter((r) => r.to)
1293
+ .map((r) => [plan.find((p) => p.id === r.dep).member.name, nextById.get(r.dep)]),
1294
+ )
1295
+ if (targets.size) {
1296
+ const manifestPath = join(member.dir, member.manifestFile)
1297
+ const rewritten = rewriteManifest(readFileSync(manifestPath, 'utf8'), targets, rangePolicy)
1298
+ if (rewritten) {
1299
+ writeFileSync(manifestPath, rewritten.text)
1300
+ const paths = [member.manifestFile]
1301
+ const lock = findLockfile(member)
1302
+ if (lock?.tool) {
1303
+ // pnpm looks for its workspace by walking up past the repository, and a meta-
1304
+ // workspace root may carry a pnpm-workspace.yaml of its own. A lockfile with no
1305
+ // workspace file beside it is standalone: refresh that one, not a parent's.
1306
+ const args =
1307
+ lock.tool === 'pnpm' && !existsSync(join(lock.dir, 'pnpm-workspace.yaml'))
1308
+ ? [...lock.args, '--ignore-workspace']
1309
+ : lock.args
1310
+ const refresh = spawnSync(lock.tool, args, {
1311
+ cwd: lock.dir,
1312
+ env,
1313
+ encoding: 'utf8',
1314
+ stdio: ['ignore', 'pipe', 'pipe'],
1315
+ })
1316
+ if (refresh.status !== 0) {
1317
+ process.stderr.write(`${refresh.stdout}${refresh.stderr}`)
1318
+ git(member.dir, ['checkout', '--', member.manifestFile], { allowFailure: true })
1319
+ return stop(item, `\`${lock.tool} ${args.join(' ')}\` failed refreshing ${lock.name}`)
1320
+ }
1321
+ paths.push(relative(member.dir, lock.path))
1322
+ }
1323
+ const subject = `chore(deps): move ${rewritten.changed.join(', ')}`
1324
+ const committed =
1325
+ git(member.dir, ['add', '--', ...paths], { allowFailure: true }) !== null &&
1326
+ git(member.dir, ['commit', '-m', subject, '--', ...paths], {
1327
+ allowFailure: true,
1328
+ }) !== null
1329
+ if (!committed)
1330
+ return stop(item, `could not commit the range rewrite in ${member.manifestFile}`)
1331
+ console.log(` ${subject}`)
1332
+ }
1333
+ }
1334
+
1335
+ const run = spawnSync(process.execPath, [RELEASE_KIT, ...releaseArgs(item, { noAssistant })], {
1336
+ cwd: member.dir,
1337
+ env,
1338
+ stdio: 'inherit',
1339
+ })
1340
+ if (run.status !== 0) return stop(item, `release-kit exited with ${run.status ?? run.signal}`)
1341
+ released.push(item.id)
1342
+
1343
+ const awaited = plan
1344
+ .slice(index + 1)
1345
+ .some((later) => later.rewrites.some((r) => r.dep === item.id))
1346
+ if (awaited && member.ecosystem === 'npm' && member.publish && member.name && item.next) {
1347
+ console.log(` waiting for ${member.name}@${item.next} on the registry…`)
1348
+ if (!waitForRegistry(member.name, item.next, registryWait)) {
1349
+ return stop(
1350
+ item,
1351
+ `${member.name}@${item.next} was released but is not on the registry after ${registryWait.timeout}s`,
1352
+ )
1353
+ }
1354
+ }
1355
+ }
1356
+ return { released, failed: null, reason: null }
1357
+ }
1358
+
1359
+ function printExecution(plan, { released, failed, reason }) {
1360
+ if (!failed) {
1361
+ console.log(
1362
+ `\nTrain released — ${released.length} package${released.length === 1 ? '' : 's'}: ${released.join(', ')}`,
1363
+ )
1364
+ return
1365
+ }
1366
+ const notStarted = plan
1367
+ .map((item) => item.id)
1368
+ .filter((id) => id !== failed && !released.includes(id))
1369
+ console.log(`\nTRAIN STOPPED at ${failed}: ${reason}`)
1370
+ if (released.length) console.log(` released: ${released.join(', ')}`)
1371
+ console.log(` stopped: ${failed}`)
1372
+ if (notStarted.length) console.log(` not started: ${notStarted.join(', ')}`)
1373
+ console.log(
1374
+ '\n Fix the cause and run the same command again: released packages are skipped, and the\n' +
1375
+ ` one that stopped is finished by release-kit from wherever it got to.`,
1376
+ )
1377
+ }
1378
+
1104
1379
  // ─────────────────────────────────────────────────────────────────────────────
1105
1380
  // Output
1106
1381
  // ─────────────────────────────────────────────────────────────────────────────
@@ -1165,16 +1440,18 @@ function printSeedResults(results, dryRun) {
1165
1440
  console.log(`\n${refusedOrErrored} member(s) refused or failed — see above.`)
1166
1441
  }
1167
1442
 
1168
- const HELP = `release-train (prototype — read-only phases, seed-tags, and the train summary)
1443
+ const HELP = `release-train — release interdependent packages in dependency order
1169
1444
 
1170
- train graph print the derived dependency graph and topo order
1445
+ train plan, preflight, confirm, then release everything that changed
1446
+ train --yes the same without the confirmation prompt (required without a terminal)
1171
1447
  train --dry-run plan + whole-train preflight, execute nothing
1172
- train --dry-run --all plan every member
1173
- train --dry-run <id>... plan these packages and their dependents
1448
+ train --all every member, not just the changed ones
1449
+ train <id>... these packages and their dependents
1450
+ train graph print the derived dependency graph and topo order
1174
1451
  train seed-tags create baseline tags (add --dry-run to preview)
1175
1452
  train --summary <path> write the train summary (markdown) here; overrides summaryFile
1176
1453
  train --assistant <name> none (whole-train kill switch), auto, claude, codex
1177
- train --offline skip network work (registry lookups, tag pushes)
1454
+ train --offline skip network work (registry lookups, tag pushes); dry runs only
1178
1455
  train --config <path> config file (default ./train.config.json)
1179
1456
  `
1180
1457
 
@@ -1182,7 +1459,7 @@ const HELP = `release-train (prototype — read-only phases, seed-tags, and the
1182
1459
  // Main
1183
1460
  // ─────────────────────────────────────────────────────────────────────────────
1184
1461
 
1185
- function main() {
1462
+ async function main() {
1186
1463
  const args = parseArgs(process.argv.slice(2))
1187
1464
  if (args.help) {
1188
1465
  console.log(HELP)
@@ -1209,6 +1486,7 @@ function main() {
1209
1486
  if (member.repoDir)
1210
1487
  repoMemberCounts.set(member.repoDir, (repoMemberCounts.get(member.repoDir) ?? 0) + 1)
1211
1488
  }
1489
+ for (const member of members) member.sharesRepo = (repoMemberCounts.get(member.repoDir) ?? 0) > 1
1212
1490
  const registry = gatherRegistry(members, args.offline)
1213
1491
 
1214
1492
  if (args.command === 'seed-tags') {
@@ -1224,8 +1502,6 @@ function main() {
1224
1502
  return
1225
1503
  }
1226
1504
 
1227
- if (!args.dryRun) fail('execution is not implemented yet — run with --dry-run')
1228
-
1229
1505
  const changes = new Map()
1230
1506
  for (const member of members) {
1231
1507
  if (!member.repoDir) continue
@@ -1259,18 +1535,37 @@ function main() {
1259
1535
  })
1260
1536
  printPlan(plan, result)
1261
1537
 
1538
+ let execution = null
1539
+ if (!args.dryRun && !result.failures.length && plan.length) {
1540
+ if (!args.yes) {
1541
+ if (!process.stdin.isTTY)
1542
+ fail('no terminal to confirm on — pass --yes to release the plan above')
1543
+ const readline = createInterface({ input: process.stdin, output: process.stdout })
1544
+ const answer = await readline.question(
1545
+ `\nRelease ${plan.length} package${plan.length === 1 ? '' : 's'} in this order? [y/N] `,
1546
+ )
1547
+ readline.close()
1548
+ if (!/^y(es)?$/i.test(answer.trim())) fail('aborted — nothing was released')
1549
+ }
1550
+ execution = executePlan(plan, {
1551
+ rangePolicy: config.rangePolicy,
1552
+ registryWait: config.registryWait,
1553
+ noAssistant: args.assistant === null,
1554
+ })
1555
+ printExecution(plan, execution)
1556
+ }
1557
+
1262
1558
  // Train summary: deterministic always; announcement drafted only when an assistant is
1263
1559
  // configured (or forced via --assistant) and never blocking. --assistant none is the
1264
- // whole-train kill switch — when execution lands it is also forwarded to every
1265
- // release-kit run, and it is the ONLY assistant value that is forwarded: forcing a
1266
- // drafting tool onto packages that did not opt in stays impossible by design.
1560
+ // whole-train kill switch, and executePlan forwards it to every release-kit run.
1267
1561
  const summaryPath = args.summaryPath ?? config.summaryFile
1268
1562
  if (summaryPath) {
1269
1563
  const date = new Date().toISOString().slice(0, 10)
1270
- let summary = buildSummary(plan, {
1564
+ const shipped = execution ? plan.filter((item) => execution.released.includes(item.id)) : plan
1565
+ let summary = buildSummary(shipped, {
1271
1566
  workspace: basename(rootDir),
1272
1567
  date,
1273
- mode: 'planned (dry run)',
1568
+ mode: execution ? 'released' : args.dryRun ? 'planned (dry run)' : 'planned, not released',
1274
1569
  })
1275
1570
  const assistant = resolveAssistant(
1276
1571
  args.assistant === undefined ? config.assistant : args.assistant,
@@ -1287,7 +1582,7 @@ function main() {
1287
1582
  console.log(`\nTrain summary written to ${summaryPath}`)
1288
1583
  }
1289
1584
 
1290
- process.exitCode = result.failures.length ? 1 : 0
1585
+ process.exitCode = result.failures.length || execution?.failed ? 1 : 0
1291
1586
  }
1292
1587
 
1293
- if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main()
1588
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) await main()