@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.
- package/README.md +32 -27
- package/TRAIN.md +78 -41
- package/package.json +3 -3
- package/release.mjs +177 -23
- package/train.mjs +328 -33
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
[](https://www.npmjs.com/package/@entro314labs/release-kit)
|
|
11
11
|
[](https://www.npmjs.com/package/@entro314labs/release-kit?activeTab=code)
|
|
12
12
|
[](#-requirements)
|
|
13
|
-
[](#-requirements)
|
|
14
14
|
[](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
|
|
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
|
-
| `--
|
|
195
|
-
| `--
|
|
196
|
-
| `--
|
|
197
|
-
| `--
|
|
198
|
-
| `--
|
|
199
|
-
| `--notes
|
|
200
|
-
| `--
|
|
201
|
-
| `--assistant
|
|
202
|
-
| `--assistant-
|
|
203
|
-
| `--
|
|
204
|
-
| `--
|
|
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).
|
|
762
|
-
|
|
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
|
|
767
|
-
rather than being
|
|
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
|
|
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
|
|
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 #
|
|
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
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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 --
|
|
25
|
-
release-train
|
|
26
|
-
release-train seed-tags
|
|
27
|
-
release-train --summary <path>
|
|
28
|
-
release-train --offline
|
|
29
|
-
release-train --config <path>
|
|
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.**
|
|
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
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
`
|
|
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`
|
|
226
|
-
visible or `registryWait.timeout` elapses. Registries have replication lag;
|
|
227
|
-
dependent that publishes or installs too early fails spuriously.
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
|
239
|
-
|
|
|
240
|
-
| Preflight
|
|
241
|
-
| Mid-package (e.g. publish timeout)
|
|
242
|
-
| Between packages
|
|
243
|
-
| Registry wait timeout
|
|
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). `--
|
|
321
|
-
|
|
322
|
-
|
|
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
|
-
- **
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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.
|
|
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.
|
|
53
|
-
"oxlint": "^1.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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([
|
|
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
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
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
|
|
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 ??
|
|
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
|
|
2831
|
+
if (!newest) return null
|
|
2720
2832
|
const at = tryRead('git', ['rev-list', '-n', '1', newest.name])
|
|
2721
|
-
|
|
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
|
|
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
|
|
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 &&
|
|
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
|
|
3
|
+
* release-train — orchestrated releases for interdependent packages.
|
|
4
4
|
*
|
|
5
|
-
* Implements
|
|
6
|
-
*
|
|
7
|
-
* Execution
|
|
8
|
-
*
|
|
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 --
|
|
13
|
-
* train
|
|
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 {
|
|
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
|
-
|
|
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`
|
|
454
|
-
* otherwise
|
|
455
|
-
*
|
|
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
|
-
|
|
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`
|
|
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
|
|
1443
|
+
const HELP = `release-train — release interdependent packages in dependency order
|
|
1169
1444
|
|
|
1170
|
-
train
|
|
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 --
|
|
1173
|
-
train
|
|
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
|
|
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
|
-
|
|
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()
|