@entro314labs/release-kit 2.8.0 → 2.9.1

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 +292 -50
  2. package/TRAIN.md +13 -0
  3. package/package.json +1 -1
  4. package/release.mjs +1257 -159
  5. package/train.mjs +18 -5
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%2018-339933?logo=node.js&logoColor=white)](#-requirements)
13
+ [![node](https://img.shields.io/badge/node-%E2%89%A5%2022-339933?logo=node.js&logoColor=white)](#-requirements)
14
14
  [![license](https://img.shields.io/badge/license-MIT-blue)](LICENSE)
15
15
 
16
16
  </div>
@@ -56,6 +56,7 @@ Released v2.5.0
56
56
  | [⚡ Usage](#-usage) | targets, bumps, flags |
57
57
  | [🧩 Steps](#-steps) | the seven steps and how to select them |
58
58
  | [📚 Libraries versus apps](#-libraries-versus-apps) | which steps you want, and why |
59
+ | [🪝 Hooks](#-hooks) | commands between the steps |
59
60
  | [🤖 Assistant](#-assistant-optional) | optional AI drafting |
60
61
  | [🌍 Any language](#-any-language) | Rust, Python, tag-only, anything |
61
62
  | [🚂 Release trains](#-release-trains) | monorepos and multi-repo workspaces |
@@ -126,7 +127,7 @@ Pin the URL to a tag, never `main`: piping an unpinned remote script into an int
126
127
  means whatever is at that URL runs against your repository and your credentials. `--sync` is
127
128
  the one thing that does not work this way — copying itself needs a file on disk.
128
129
 
129
- > **All five paths run the same file and need Node 18+.** That includes the Rust, Python and
130
+ > **All five paths run the same file and need Node 22+.** That includes the Rust, Python and
130
131
  > Go projects: `release-kit` is a Node program regardless of what it is releasing.
131
132
 
132
133
  Zero-config works on the conventions below; add a [`release.config.json`](#️-configuration)
@@ -143,6 +144,16 @@ pnpm release -- --dry-run # print every step, execute nothing
143
144
  pnpm release -- --help
144
145
  ```
145
146
 
147
+ `next` answers "what version would this release?" and stops. Only the version reaches
148
+ stdout, so it substitutes into a command; everything it would otherwise narrate goes to
149
+ stderr, where a human still reads it.
150
+
151
+ ```sh
152
+ release-kit next # the version already in the manifest
153
+ release-kit next minor # what a minor bump would release
154
+ release-kit next auto # what the commits imply — the same inference the release uses
155
+ ```
156
+
146
157
  The target is optional. With no target it releases whatever version `package.json`
147
158
  already says — which is the mode to use when a version bump landed in an earlier commit.
148
159
 
@@ -231,7 +242,7 @@ them execute, it never reorders them.
231
242
  | `version` | on | Write the version into `package.json` and `versionFiles` |
232
243
  | `changelog` | on | Roll `[Unreleased]` into the version, or add drafted notes |
233
244
  | `tag` | on | Annotated git tag carrying the release notes |
234
- | `push` | on | Push the branch and tag together (`--follow-tags`) |
245
+ | `push` | on | Push the branch and tag as one transaction (`--follow-tags --atomic`) |
235
246
  | `publish` | on | Run the configured `publish` command |
236
247
  | `release` | on | Create the GitHub release |
237
248
 
@@ -254,10 +265,14 @@ release-kit minor --skip commit # never touch uncommitted work
254
265
  Or fix it per project, and just run `release-kit minor`:
255
266
 
256
267
  ```json
257
- { "steps": ["version", "changelog", "tag", "push", "release"] }
268
+ { "steps": ["commit", "version", "changelog", "tag", "push", "release"] }
258
269
  ```
259
270
 
260
- `steps` decides **what** runs. Every other key describes **how** a step behaves — `publish`
271
+ `steps` decides **what** runs — and an explicit list is complete: a step not named does
272
+ not run, including `commit`. A `steps` list written before `commit` became a default step
273
+ therefore opts out of it without having chosen to; add `"commit"` to the list (as the
274
+ examples here do), or pass `--commit` for one run. Every other key describes **how** a
275
+ step behaves — `publish`
261
276
  is the command, `changelog` is the file. A step whose configuration is `null` runs as a
262
277
  no-op and says so, rather than silently meaning "skip".
263
278
 
@@ -275,10 +290,24 @@ Notes resolve in this order:
275
290
  bullet links to its commit, and `closes #12` / `fixes #34` in a message becomes a link to
276
291
  the issue. A `BREAKING CHANGE:` footer is used in place of the subject, since it explains
277
292
  the break. A commit reverted within the same release drops out along with its revert.
278
- All deterministic and needing nothing installed, so decent notes are the default rather
279
- than something that requires an assistant.
293
+ A **New Contributors** section names anyone whose first commit to the repository is in
294
+ this release — derived from the git history rather than a forge API, so it needs no
295
+ token and works offline, and it is skipped on a first release where everyone would be
296
+ new. All deterministic and needing nothing installed, so decent notes are the default
297
+ rather than something that requires an assistant.
280
298
  4. Otherwise GitHub generates them from the commits since the previous tag.
281
299
 
300
+ **Which tag the history is read from** is the highest version tag carrying the configured
301
+ prefix that is reachable from `HEAD` — not the nearest tag. A repository carrying tags that
302
+ are not releases (a rolling `latest-beta` marker, a `nightly`) is unaffected by them, and a
303
+ patch tagged on top of a later minor does not drag the baseline backwards.
304
+
305
+ **A stable release absorbs the candidates that led to it.** Releasing `2.0.0` after
306
+ `2.0.0-rc.1` and `-rc.2` reads history from the last _stable_ tag, so the notes describe
307
+ everything the release ships rather than the gap between the last two candidates — which
308
+ is usually just the release commit. Releasing a candidate is unchanged: each one's notes
309
+ say what changed in that candidate.
310
+
282
311
  `--notes <source>` forces one instead of walking that list: `changelog`, `assistant`,
283
312
  `commits`, or `github`. A named source that produces nothing is an error rather than a
284
313
  quiet fall-through — asking for one thing and being given another is worse than being told
@@ -290,6 +319,14 @@ does not make it preferred, because a hand-written changelog entry should still
290
319
  The same text becomes the tag annotation, the GitHub release body, and (when rolled) the
291
320
  changelog entry. It is written once and lands in three places.
292
321
 
322
+ **Keep a Changelog link definitions are maintained.** `## [1.2.3]` is a markdown link
323
+ _reference_, and renders as literal bracketed text without a matching definition at the
324
+ foot of the file. Every bracketed heading in the document gets one — not just the version
325
+ being released — so a changelog that never had them is repaired in one release: each
326
+ version compares against the version below it, the oldest links to its own tag, and
327
+ `[Unreleased]` compares the newest version against `HEAD`. Definitions for labels that are
328
+ not headings are left alone, since those are yours.
329
+
293
330
  ### npm dist-tags
294
331
 
295
332
  The [dist-tag](https://docs.npmjs.com/cli/commands/npm-dist-tag) is derived from the
@@ -320,6 +357,8 @@ rather than stopping at the first problem.
320
357
  - `gh` is installed and authenticated
321
358
  - Commit and tag signing can actually sign, and the key is one GitHub will accept
322
359
  - The publishing CLI is authenticated, and the version is not already published
360
+ - The previous release actually reached the registry — one that did not is either finished
361
+ by this run or absorbed into it _(warning)_
323
362
  - Configured release assets exist
324
363
  - The configured `verify` command passes — the project's own gate (tests, build) runs
325
364
  before anything mutates, instead of a `prepublishOnly` hook failing after the commit,
@@ -349,6 +388,31 @@ Re-run the same command. Every step is idempotent:
349
388
  So a run that dies at the publish step (2FA timeout, flaky network) picks up exactly where
350
389
  it stopped. There is no cleanup step, no `--resume`, and nothing to remember.
351
390
 
391
+ `auto` is included in that. It normally resolves the version from the commits since the
392
+ last tag, and after a failed publish there are none — the tag it would read from is the one
393
+ the dead run made. Rather than aborting with "no releasable commits", it finishes that
394
+ release: same version, same tag, the steps that remain.
395
+
396
+ ### A release that was never published
397
+
398
+ A tag is not a release. The tag and the push happen before the publish, so a publish that
399
+ fails leaves the version tagged, pushed and written into the changelog while no registry
400
+ carries it — and everything that reads "the last release" from tags then reads it wrong.
401
+
402
+ Once history has moved past that tag, finishing it is no longer possible: publishing sends
403
+ what is on disk, and that is no longer what the tag describes. The next release absorbs it
404
+ instead. History is read from the last tag whose version actually reached the registry, so
405
+ the unpublished release's commits are in range for both the notes and the bump `auto`
406
+ infers — a feature that never shipped still makes the next release a minor. Preflight says
407
+ which tags were absorbed, and points at the changelog sections that now document versions
408
+ no registry carries.
409
+
410
+ This costs one registry lookup per release, and the registry is the only thing asked: a
411
+ project configured with `"publish": null` has nothing that can answer, so it reads history
412
+ from tags as it always did. When the registry does not answer at all — offline, a proxy, an
413
+ expired session, a private package with no credentials — nothing is concluded from the
414
+ silence, and history is again read exactly as it was before.
415
+
352
416
  The one case that is not recoverable by re-running is a tag that exists at a _different_
353
417
  commit than `HEAD`. That is a genuine conflict, and it aborts rather than guessing.
354
418
 
@@ -358,24 +422,26 @@ Only one step is Node-specific: `publish`. Committing, changelog rolling, taggin
358
422
  and GitHub releases are the same everywhere, so `versionFile` points at wherever a project
359
423
  keeps its version and the rest works unchanged.
360
424
 
361
- | Project | Config |
362
- | ------------------------------ | ------------------------------------------------------------------------------- |
363
- | Node (npm) | nothing — `package.json` and `npm publish` are the defaults |
364
- | Node (pnpm / bun) | `{"publish": "pnpm publish --tag %d"}` or `{"publish": "bun publish --tag %d"}` |
365
- | Rust | `{"versionFile": "Cargo.toml", "publish": "cargo publish"}` |
366
- | Python | `{"versionFile": "pyproject.toml", "publish": "uv publish"}` |
367
- | Go | `{"versionFile": null, "publish": "go list -m %n@%t"}` — the tag is the release |
368
- | Anything with a `VERSION` file | `{"versionFile": "VERSION", "publish": null}` |
369
- | Versioned only by tag | `{"versionFile": null}`, then `release-kit 1.2.3` |
425
+ | Project | Config |
426
+ | ------------------------------ | -------------------------------------------------------------------------------------- |
427
+ | Node (npm) | nothing — `package.json` and `npm publish` are the defaults |
428
+ | Node (pnpm / bun) | `{"publish": "pnpm publish --tag %d"}` or `{"publish": "bun publish --tag %d"}` |
429
+ | Rust | nothing — `Cargo.toml` and `cargo publish` are detected |
430
+ | Rust + npm (a Tauri plugin) | nothing — both manifests bump, both registries publish |
431
+ | Python | `{"versionFile": "pyproject.toml", "publish": "uv publish"}` |
432
+ | Go | `{"publish": "go list -m %n@%t"}` — the tag is the release; a `version.go` is detected |
433
+ | Anything with a `VERSION` file | `{"versionFile": "VERSION", "publish": null}` |
434
+ | Versioned only by tag | `{"versionFile": null}`, then `release-kit 1.2.3` |
370
435
 
371
436
  The publish step also gets a preflight when the command is one it recognises:
372
437
 
373
- | Publish command | Authentication | Already published? |
374
- | --------------- | ------------------------------------- | ----------------------------------- |
375
- | `npm` / `pnpm` | `whoami` | `view <name>@<version>` |
376
- | `bun` | `bun pm whoami` | `bun pm view <name>@<version>` |
377
- | `uv` | `UV_PUBLISH_TOKEN` in the environment | none — `uv` skips duplicates itself |
378
- | `go` | none needed | `go list -m <module>@<tag>` |
438
+ | Publish command | Authentication | Already published? |
439
+ | --------------- | ----------------------------------------------------------- | ----------------------------------- |
440
+ | `npm` / `pnpm` | `whoami` | `view <name>@<version>` |
441
+ | `bun` | `bun pm whoami` | `bun pm view <name>@<version>` |
442
+ | `uv` | `UV_PUBLISH_TOKEN` in the environment | none — `uv` skips duplicates itself |
443
+ | `cargo` | `CARGO_REGISTRY_TOKEN`, or `cargo login`'s credentials file | `cargo info <crate>@<version>` |
444
+ | `go` | none needed | `go list -m <module>@<tag>` |
379
445
 
380
446
  Anything else runs as written with no preflight. The project name comes from the manifest —
381
447
  `name` in `package.json`, `Cargo.toml` or `pyproject.toml`, `module` in `go.mod` — falling
@@ -384,7 +450,95 @@ back to the repository directory.
384
450
  `publish` is detected too, but only where one ecosystem obviously owns it: `package.json`
385
451
  gets `npm publish`, `Cargo.toml` gets `cargo publish`. Python has several publishers (uv,
386
452
  twine, poetry, flit) and Go has none, so those get nothing rather than a guess — publishing
387
- to the wrong registry is a far worse failure than being asked to configure it.
453
+ to the wrong registry is a far worse failure than being asked to configure it. A manifest
454
+ that forbids publishing is honoured: a `"private": true` package.json, a crate with
455
+ `publish = false`, and a crate built only as a `cdylib` — a native extension module rather
456
+ than a library — get no command at all.
457
+
458
+ ### One repository, two registries
459
+
460
+ A Tauri plugin is a crate and its npm bindings built from one source tree; a maturin
461
+ project is a crate and a wheel. Both manifests sit at the repository root and carry the
462
+ same version, and both publish on the same release. That needs no config:
463
+
464
+ ```
465
+ tauri-plugin-demo/
466
+ package.json @tauri-apps/plugin-demo 1.4.0
467
+ Cargo.toml tauri-plugin-demo 1.4.0
468
+ Cargo.lock
469
+ ```
470
+
471
+ ```sh
472
+ release-kit minor --yes
473
+ # also versioned in Cargo.toml, Cargo.lock (detected)
474
+ # publish: npm publish --tag latest
475
+ # publish: cargo publish
476
+ ```
477
+
478
+ The second manifest is detected only when it **already carries the same version**. Two
479
+ manifests on different numbers are two independent release lines, and dragging one to the
480
+ other's number is a silent, wrong release — that case says so and leaves the file alone,
481
+ pointing at `versionFiles` for a project that really does want them joined. A project that
482
+ wrote its own `versionFile` or `versionFiles` is never extended behind its back.
483
+
484
+ `publish` therefore takes an array as well as a string, for any project releasing to more
485
+ than one registry:
486
+
487
+ ```json
488
+ { "publish": ["npm publish --tag %d", "cargo publish"] }
489
+ ```
490
+
491
+ They run in order, each with its own authentication check and its own already-published
492
+ check, so a re-run after a half-finished publish completes the other half instead of
493
+ failing. The detected order puts npm before cargo deliberately: npm allows an unpublish for
494
+ 72 hours and crates.io never does, so a publish that fails part-way through has not already
495
+ made the permanent half.
496
+
497
+ Names are resolved per registry rather than once: the crate is looked up under the `name`
498
+ in `Cargo.toml`, not the npm package name, since `@tauri-apps/plugin-demo` and
499
+ `tauri-plugin-demo` are the same release under two names.
500
+
501
+ ### Languages that have no version of their own
502
+
503
+ A Go module has only `go.mod`, and `go.mod` carries no version — `go get` resolves a tag.
504
+ The same is true of a Swift package or a plain C library. There is nothing to bump, so the
505
+ tag is the version and `versionFile` resolves to `null` on its own.
506
+
507
+ Those projects still usually keep a number in source, so `mytool --version` prints
508
+ something:
509
+
510
+ ```go
511
+ package main
512
+
513
+ const Version = "1.2.0"
514
+ ```
515
+
516
+ `version.go`, `internal/version/version.go` and `pkg/version/version.go` are detected and
517
+ rewritten on release, with no config — `const Version`, `var Version` and
518
+ `var Version string` are all read. The tag stays the source of truth; the constant is a
519
+ mirror kept in step with it.
520
+
521
+ Detection adopts a file only when it **already carries the current version**. That rules
522
+ out the placeholder a build replaces at link time:
523
+
524
+ ```go
525
+ var Version = "dev" // -ldflags "-X main.Version=$(git describe)" — left alone
526
+ ```
527
+
528
+ A mismatch here is skipped silently rather than warned about, because a placeholder is a
529
+ normal thing to find rather than a mistake — unlike two manifests on different versions.
530
+
531
+ For a constant somewhere else, or in a language whose convention is not in that list, name
532
+ it with a `pattern`:
533
+
534
+ ```json
535
+ {
536
+ "versionFiles": [{ "path": "src/version.h", "pattern": "^#define VERSION \"(.+)\"" }]
537
+ }
538
+ ```
539
+
540
+ `versionFiles` is written whether or not the project has a `versionFile`, so this works for
541
+ a repository that versions by tag alone.
388
542
 
389
543
  With no `versionFile` configured it is detected from the repository — `package.json`,
390
544
  `pyproject.toml`, `Cargo.toml`, then `VERSION` — so most projects need no config for it at
@@ -398,8 +552,60 @@ because the TOML match is anchored to the start of a line, a dependency's
398
552
 
399
553
  **Lockfiles are scoped automatically.** A `Cargo.lock` records a version for every
400
554
  dependency — hundreds of them — so matching the first `version = "…"` would rewrite an
401
- unrelated crate. Listing one rewrites only the `[[package]]` block whose name matches the
402
- crate in the sibling `Cargo.toml`; with no sibling to read, it refuses rather than guesses.
555
+ unrelated crate. Listing one rewrites only the `[[package]]` blocks this project owns: the
556
+ crate named in the sibling `Cargo.toml`, or — for a workspace root, which has no
557
+ `[package]` of its own — every member that inherits the version with
558
+ `version.workspace = true`. A member pinned to its own number is versioned separately and
559
+ is left alone. With nothing beside it to read, it refuses rather than guesses.
560
+
561
+ `package-lock.json`, `npm-shrinkwrap.json` and `uv.lock` record the project's own version
562
+ too, and are refreshed by the tool that owns them rather than rewritten by pattern — the
563
+ version sits in more than one place and the formats change shape between tool versions.
564
+ Each is scoped to its manifest, so a `uv.lock` for a component this release is not
565
+ versioning stays out of the release commit, and a missing tool warns rather than aborting.
566
+ `pnpm-lock.yaml` records no root version, so it never goes stale.
567
+
568
+ ### Marking the line instead of writing a pattern
569
+
570
+ The files that most want keeping in step — a README install line, a badge URL, a
571
+ Dockerfile tag — are the ones where a regex is fiddliest to get right. A comment on the
572
+ line says which of a file's numbers is the version, so nothing outside the file has to
573
+ describe where it sits:
574
+
575
+ ````md
576
+ Install with `npm i acme@1.2.3` <!-- x-release-kit-version -->
577
+
578
+ ```dockerfile
579
+ FROM acme:1.2 # x-release-kit-minor
580
+ ```
581
+ ````
582
+
583
+ `-major`, `-minor`, `-patch`, `-date` and `-version-date` write a piece of the release
584
+ instead of the whole version — `-version-date` covers both on one line, which is the shape
585
+ of an AppStream `<release version="…" date="…"/>` tag — and `x-release-kit-start-<scope>` … `x-release-kit-end` covers a run of lines
586
+ rather than commenting each one. Just list the file:
587
+
588
+ ```json
589
+ { "versionFiles": ["README.md", "Dockerfile"] }
590
+ ```
591
+
592
+ A file listed with no marker, no `pattern` and no recognised extension is treated as
593
+ containing nothing but the version — right for a `VERSION` file, and refused for anything
594
+ else rather than replacing its contents with the number.
595
+
596
+ **Paths may contain `*`**, matching within one path segment, so the per-platform configs a
597
+ desktop app carries do not have to be written out one by one:
598
+
599
+ ```json
600
+ { "versionFiles": ["src-tauri/tauri.*.conf.json"] }
601
+ ```
602
+
603
+ A pattern matching no files aborts: it was written to keep files in step, and silently
604
+ keeping none of them in step is the failure it was meant to prevent. A file the glob
605
+ matched that carries no version is skipped, though — a glob says "every file of this
606
+ shape", and some of them legitimately have none, as a Tauri per-OS overlay does. A file
607
+ you named on purpose is different: being unable to write it fails preflight, before
608
+ anything mutates.
403
609
 
404
610
  For anything else, give a pattern with one capture group around the version. `versionFiles`
405
611
  takes the same entries, so several files stay in sync across formats:
@@ -419,18 +625,19 @@ in one release, across three formats, with no scripting:
419
625
  {
420
626
  "versionFiles": [
421
627
  "apps/desktop/package.json",
422
- "apps/desktop/src-tauri/tauri.conf.json",
423
- "apps/desktop/src-tauri/tauri.macos.conf.json",
424
- "apps/desktop/src-tauri/tauri.windows.conf.json",
425
- "apps/desktop/src-tauri/tauri.linux.conf.json",
628
+ "apps/desktop/src-tauri/tauri.*conf.json",
426
629
  "apps/desktop/src-tauri/Cargo.toml",
427
630
  "apps/desktop/src-tauri/Cargo.lock"
428
631
  ],
429
632
  "publish": null,
430
- "steps": ["version", "changelog", "tag", "push"]
633
+ "steps": ["commit", "version", "changelog", "tag", "push"]
431
634
  }
432
635
  ```
433
636
 
637
+ The glob covers `tauri.conf.json` and every per-OS overlay beside it, including ones added
638
+ later — and the overlays that carry no `version` of their own are skipped rather than
639
+ failing the release.
640
+
434
641
  Stopping at `push` because the tag is what triggers the build pipeline — see
435
642
  [Libraries versus apps](#-libraries-versus-apps).
436
643
 
@@ -459,22 +666,23 @@ execution is not wired up yet.
459
666
  `release.config.json`, beside `package.json`. Every key is optional; unknown keys abort
460
667
  rather than being silently ignored.
461
668
 
462
- | Key | Default | Meaning |
463
- | --------------- | ------------------------ | --------------------------------------------------------------------- |
464
- | `steps` | all but `commit` | Which steps run; the order is fixed |
465
- | `tagPrefix` | `"v"` | Prepended to the version to form the tag |
466
- | `branch` | `"main"` | The only branch a release may run from; `null` allows any |
467
- | `remote` | `"origin"` | Git remote to push to |
468
- | `changelog` | `"CHANGELOG.md"` | Changelog path; `null` for a project without one |
469
- | `versionFile` | detected | Where the version lives; `null` versions by tag alone |
470
- | `versionFiles` | `[]` | Further files kept in sync; a path or `{ path, pattern }` |
471
- | `publish` | `"npm publish --tag %d"` | Publish command; `null` means none is configured |
472
- | `versioning` | `"conventional"` | How `auto` infers; or `always-patch` / `-minor` / `-major` |
473
- | `verify` | `null` | Command run during preflight; non-zero aborts before anything mutates |
474
- | `assistant` | `null` | Drafting CLI: a name, `"auto"`, or `{ tool, model, effort }` |
475
- | `commitMessage` | `"chore(release): %t"` | Release commit subject |
476
- | `releaseTitle` | `"%t"` | GitHub release title |
477
- | `assets` | `[]` | Files attached to the GitHub release |
669
+ | Key | Default | Meaning |
670
+ | --------------- | ---------------------- | --------------------------------------------------------------------- |
671
+ | `steps` | all but `commit` | Which steps run; the order is fixed |
672
+ | `tagPrefix` | `"v"` | Prepended to the version to form the tag |
673
+ | `branch` | `"main"` | The only branch a release may run from; `null` allows any |
674
+ | `remote` | `"origin"` | Git remote to push to |
675
+ | `changelog` | `"CHANGELOG.md"` | Changelog path; `null` for a project without one |
676
+ | `versionFile` | detected | Where the version lives; `null` versions by tag alone |
677
+ | `versionFiles` | detected | Further files kept in sync; a path or `{ path, pattern }` |
678
+ | `publish` | detected | Publish command, or an array of them; `null` publishes nothing |
679
+ | `versioning` | `"conventional"` | How `auto` infers; or `always-patch` / `-minor` / `-major` |
680
+ | `verify` | `null` | Command run during preflight; non-zero aborts before anything mutates |
681
+ | `hooks` | `{}` | Commands run between the steps — see [Hooks](#-hooks) |
682
+ | `assistant` | `null` | Drafting CLI: a name, `"auto"`, or `{ tool, model, effort }` |
683
+ | `commitMessage` | `"chore(release): %t"` | Release commit subject |
684
+ | `releaseTitle` | `"%t"` | GitHub release title |
685
+ | `assets` | `[]` | Files attached to the GitHub release |
478
686
 
479
687
  Command and message strings expand four tokens: `%v` version, `%t` tag, `%n` package
480
688
  name, `%d` npm dist-tag. In the `publish` command line the substituted values are
@@ -599,6 +807,40 @@ A project releasing off a non-default branch with a different tag scheme:
599
807
  }
600
808
  ```
601
809
 
810
+ ## 🪝 Hooks
811
+
812
+ `verify` covers the gate that matters most — the project's own tests, run during preflight
813
+ before anything mutates. What it cannot express is work that has to happen _between_ the
814
+ release's own steps.
815
+
816
+ ```json
817
+ {
818
+ "hooks": {
819
+ "afterVersion": "cargo build --release",
820
+ "beforePublish": "pnpm build",
821
+ "afterRelease": "curl -X POST -d 'shipped %t' https://hooks.example/deploy"
822
+ }
823
+ }
824
+ ```
825
+
826
+ | Hook | Runs |
827
+ | --------------- | ------------------------------------------------------- |
828
+ | `beforeVersion` | After preflight, before any file is written |
829
+ | `afterVersion` | After the version is on disk, before the release commit |
830
+ | `beforePublish` | After the tag is pushed, before anything is published |
831
+ | `afterPublish` | After a publish actually ran |
832
+ | `afterRelease` | After the GitHub release is created |
833
+
834
+ Each takes a command line or an array of them, expanding the same `%v` `%t` `%n` `%d`
835
+ tokens `publish` does, shell-quoted on substitution. A non-zero exit aborts the release
836
+ where it happened. An unknown hook name aborts rather than silently never running.
837
+
838
+ `afterVersion` sits where it does for a reason: **anything it changes is staged for the
839
+ release commit**, so a file regenerated from the version rides in that commit instead of
840
+ being left behind in the working tree. `afterPublish` runs only when a publish actually
841
+ happened, so a re-run that skipped an already-published target does not tell something
842
+ downstream a lie it may act on.
843
+
602
844
  ## 🤖 Assistant (optional)
603
845
 
604
846
  An assistant is an AI CLI already installed on your machine. When one is configured,
@@ -691,7 +933,7 @@ release themselves, with the artifacts attached. That is their job. release-kit'
691
933
  at the pushed tag:
692
934
 
693
935
  ```json
694
- { "steps": ["version", "changelog", "tag", "push"], "notesFile": "dist-notes.md" }
936
+ { "steps": ["commit", "version", "changelog", "tag", "push"], "notesFile": "dist-notes.md" }
695
937
  ```
696
938
 
697
939
  Nothing after `push` — no `publish`, no `release`. The tag push is the handoff, and it is
@@ -737,7 +979,7 @@ A project can be both — a Rust crate that also ships binaries, say. Publish th
737
979
  release-kit and let the build tool handle the binaries and the release:
738
980
 
739
981
  ```json
740
- { "publish": "cargo publish", "steps": ["version", "changelog", "tag", "push", "publish"] }
982
+ { "publish": "cargo publish", "steps": ["commit", "version", "changelog", "tag", "push", "publish"] }
741
983
  ```
742
984
 
743
985
  ## 🔄 Keeping vendored copies in sync
@@ -757,7 +999,7 @@ including a directory that is not a repository.
757
999
 
758
1000
  ## 📋 Requirements
759
1001
 
760
- - **Node 18+ — including for Rust, Python and Go projects.** `release-kit` is a Node
1002
+ - **Node 22+ — including for Rust, Python and Go projects.** `release-kit` is a Node
761
1003
  program whatever it releases; there is no standalone binary.
762
1004
  - `git`
763
1005
  - `gh`, authenticated — only when creating GitHub releases
@@ -773,7 +1015,7 @@ with the reasoning — are in [ROADMAP.md](ROADMAP.md).
773
1015
 
774
1016
  ```sh
775
1017
  pnpm install
776
- pnpm test # 63 tests, node --test, no framework
1018
+ pnpm test # 188 tests, node --test, no framework
777
1019
  pnpm check # format + lint + tests, the same gate CI runs
778
1020
  ```
779
1021
 
package/TRAIN.md CHANGED
@@ -269,6 +269,19 @@ Seeding refuses, per member and without touching it, when:
269
269
  The orchestrator's graph is built from npm-style manifests today. Non-npm members still
270
270
  participate:
271
271
 
272
+ - **Rust**: `Cargo.toml` versions and `cargo publish`, both already handled per package.
273
+ What the orchestrator would additionally need is the range rewrite: a crate depending on
274
+ a sibling by `path` also carries a `version` for it, and crates.io rejects a publish
275
+ whose path dependency has no version — so the dependent's manifest must move to the
276
+ dependency's new number before it publishes. release-please's `cargo-toml.ts` is the
277
+ worked reference: it rewrites `version` under `dependencies`, `dev-dependencies`,
278
+ `build-dependencies` and every `target.<cfg>` table, skipping entries with no `path`
279
+ (a real crates.io dependency, not a sibling) and no `version` (a path-only dependency,
280
+ which needs nothing). Workspace inheritance moves the problem rather than removing it:
281
+ `version.workspace = true` in a member points at `[workspace.package]`, which is one
282
+ place to rewrite instead of many. Not built in `release.mjs`, where a single package has
283
+ no internal ranges to rewrite and the code would have no caller.
284
+
272
285
  - **Go**: no manifest version; the tag is the release. release-kit already handles it
273
286
  (`versionFile: null`). It has no npm-visible dependents, so no registry wait.
274
287
  - **Python / PHP**: `pyproject.toml` / `composer.json` versions, publish via the package's
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@entro314labs/release-kit",
3
- "version": "2.8.0",
3
+ "version": "2.9.1",
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",