@entro314labs/release-kit 2.7.0 → 2.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (5) hide show
  1. package/README.md +322 -70
  2. package/TRAIN.md +13 -0
  3. package/package.json +1 -1
  4. package/release.mjs +1264 -136
  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
 
@@ -189,26 +200,58 @@ Prerelease bumps need `--preid` unless the current version already carries one t
189
200
  | `--sync <dir>...` | Copy this script into other projects and exit. Touches no git state. |
190
201
  | `--help`, `-h` | Full flag list. |
191
202
 
203
+ ### Linting commits
204
+
205
+ A subject that is not Conventional Commits is invisible: it contributes nothing to the
206
+ inferred bump and never reaches the changelog. `lint-commits` checks subjects against the
207
+ same parser the release uses, so the gate and the release can never disagree about what
208
+ counts.
209
+
210
+ ```bash
211
+ release-kit lint-commits # since the last tag
212
+ release-kit lint-commits main..HEAD # an explicit range
213
+ release-kit lint-commits --subject "feat: x" # one subject — a pull request title
214
+ ```
215
+
216
+ It exits non-zero on a subject the release cannot read. A type outside the changelog table
217
+ — `security:`, `i18n:` — is a warning, not a failure: those still get printed, under _Other
218
+ Changes_. Release commits, merges and `fixup!`/`squash!` markers are skipped, per the same
219
+ `ignoreCommits` config the release notes use.
220
+
221
+ Local commit-msg hooks cannot cover the case that matters most. If you squash-merge, the
222
+ commit released is the **pull request title**, which no hook ever sees — check it in CI:
223
+
224
+ ```yaml
225
+ - name: Lint the pull request title
226
+ env:
227
+ TITLE: ${{ github.event.pull_request.title }}
228
+ run: npx @entro314labs/release-kit@2.8.0 lint-commits --subject "$TITLE"
229
+ ```
230
+
231
+ Pass the title through `env`, never through `${{ }}` inside `run:` — a pull request title is
232
+ attacker-controlled text and interpolating it into a shell command is a script injection.
233
+
192
234
  ## 🧩 Steps
193
235
 
194
236
  A release is seven named steps. They always run in this order — `steps` selects which of
195
237
  them execute, it never reorders them.
196
238
 
197
- | Step | Default | What it does |
198
- | ----------- | ------- | ----------------------------------------------------------------------------------------------- |
199
- | `commit` | on\* | Commit a dirty working tree with a drafted message ([assistant](#-assistant-optional) required) |
200
- | `version` | on | Write the version into `package.json` and `versionFiles` |
201
- | `changelog` | on | Roll `[Unreleased]` into the version, or add drafted notes |
202
- | `tag` | on | Annotated git tag carrying the release notes |
203
- | `push` | on | Push the branch and tag together (`--follow-tags`) |
204
- | `publish` | on | Run the configured `publish` command |
205
- | `release` | on | Create the GitHub release |
206
-
207
- \* `commit` is a conditional default: it no-ops on a clean tree, and on a dirty tree it
208
- proceeds only when a drafting [assistant](#-assistant-optional) is configured — without
209
- one, preflight still refuses the unclean tree (with a hint), exactly as before. So
210
- `release-kit auto --assistant auto` releases a dirty tree end to end: stage, drafted
211
- commit, then the rest of the pipeline. Opt out with `--skip commit` or a `steps` config.
239
+ | Step | Default | What it does |
240
+ | ----------- | ------- | -------------------------------------------------------------------------------------------------- |
241
+ | `commit` | on\* | Commit a dirty working tree — drafted with an [assistant](#-assistant-optional), generated without |
242
+ | `version` | on | Write the version into `package.json` and `versionFiles` |
243
+ | `changelog` | on | Roll `[Unreleased]` into the version, or add drafted notes |
244
+ | `tag` | on | Annotated git tag carrying the release notes |
245
+ | `push` | on | Push the branch and tag as one transaction (`--follow-tags --atomic`) |
246
+ | `publish` | on | Run the configured `publish` command |
247
+ | `release` | on | Create the GitHub release |
248
+
249
+ \* `commit` no-ops on a clean tree. On a dirty tree it stages everything and commits:
250
+ with an [assistant](#-assistant-optional) configured the message is drafted from the
251
+ diff; without one it degrades gracefully to a generated `chore:` message naming the
252
+ changed files — a release is never blocked because a text generator was unavailable.
253
+ Opt out with `--skip commit` or a `steps` config, which restores the refusal on a dirty
254
+ tree.
212
255
 
213
256
  `version` and `changelog` write files; those writes are persisted by a release commit made
214
257
  automatically when either step runs.
@@ -243,10 +286,24 @@ Notes resolve in this order:
243
286
  bullet links to its commit, and `closes #12` / `fixes #34` in a message becomes a link to
244
287
  the issue. A `BREAKING CHANGE:` footer is used in place of the subject, since it explains
245
288
  the break. A commit reverted within the same release drops out along with its revert.
246
- All deterministic and needing nothing installed, so decent notes are the default rather
247
- than something that requires an assistant.
289
+ A **New Contributors** section names anyone whose first commit to the repository is in
290
+ this release — derived from the git history rather than a forge API, so it needs no
291
+ token and works offline, and it is skipped on a first release where everyone would be
292
+ new. All deterministic and needing nothing installed, so decent notes are the default
293
+ rather than something that requires an assistant.
248
294
  4. Otherwise GitHub generates them from the commits since the previous tag.
249
295
 
296
+ **Which tag the history is read from** is the highest version tag carrying the configured
297
+ prefix that is reachable from `HEAD` — not the nearest tag. A repository carrying tags that
298
+ are not releases (a rolling `latest-beta` marker, a `nightly`) is unaffected by them, and a
299
+ patch tagged on top of a later minor does not drag the baseline backwards.
300
+
301
+ **A stable release absorbs the candidates that led to it.** Releasing `2.0.0` after
302
+ `2.0.0-rc.1` and `-rc.2` reads history from the last _stable_ tag, so the notes describe
303
+ everything the release ships rather than the gap between the last two candidates — which
304
+ is usually just the release commit. Releasing a candidate is unchanged: each one's notes
305
+ say what changed in that candidate.
306
+
250
307
  `--notes <source>` forces one instead of walking that list: `changelog`, `assistant`,
251
308
  `commits`, or `github`. A named source that produces nothing is an error rather than a
252
309
  quiet fall-through — asking for one thing and being given another is worse than being told
@@ -258,6 +315,14 @@ does not make it preferred, because a hand-written changelog entry should still
258
315
  The same text becomes the tag annotation, the GitHub release body, and (when rolled) the
259
316
  changelog entry. It is written once and lands in three places.
260
317
 
318
+ **Keep a Changelog link definitions are maintained.** `## [1.2.3]` is a markdown link
319
+ _reference_, and renders as literal bracketed text without a matching definition at the
320
+ foot of the file. Every bracketed heading in the document gets one — not just the version
321
+ being released — so a changelog that never had them is repaired in one release: each
322
+ version compares against the version below it, the oldest links to its own tag, and
323
+ `[Unreleased]` compares the newest version against `HEAD`. Definitions for labels that are
324
+ not headings are left alone, since those are yours.
325
+
261
326
  ### npm dist-tags
262
327
 
263
328
  The [dist-tag](https://docs.npmjs.com/cli/commands/npm-dist-tag) is derived from the
@@ -289,7 +354,14 @@ rather than stopping at the first problem.
289
354
  - Commit and tag signing can actually sign, and the key is one GitHub will accept
290
355
  - The publishing CLI is authenticated, and the version is not already published
291
356
  - Configured release assets exist
292
- - A shallow clone is reported, since it truncates the history notes come from _(warning)_
357
+ - The configured `verify` command passes — the project's own gate (tests, build) runs
358
+ before anything mutates, instead of a `prepublishOnly` hook failing after the commit,
359
+ tag and push
360
+ - `package.json`'s `repository` matches the git remote, so the registry's "Repository"
361
+ link is not broken _(warning)_
362
+ - A shallow clone only matters when it truncates the history the release reads: with the
363
+ previous tag reachable it passes, without one it fails `auto` (the bump would be inferred
364
+ from partial history) and warns otherwise
293
365
  - A changelog section for the version exists _(warning — it falls back to generated notes)_
294
366
 
295
367
  Under `--dry-run` the failures are reported and then the remaining steps are shown anyway,
@@ -319,24 +391,26 @@ Only one step is Node-specific: `publish`. Committing, changelog rolling, taggin
319
391
  and GitHub releases are the same everywhere, so `versionFile` points at wherever a project
320
392
  keeps its version and the rest works unchanged.
321
393
 
322
- | Project | Config |
323
- | ------------------------------ | ------------------------------------------------------------------------------- |
324
- | Node (npm) | nothing — `package.json` and `npm publish` are the defaults |
325
- | Node (pnpm / bun) | `{"publish": "pnpm publish --tag %d"}` or `{"publish": "bun publish --tag %d"}` |
326
- | Rust | `{"versionFile": "Cargo.toml", "publish": "cargo publish"}` |
327
- | Python | `{"versionFile": "pyproject.toml", "publish": "uv publish"}` |
328
- | Go | `{"versionFile": null, "publish": "go list -m %n@%t"}` — the tag is the release |
329
- | Anything with a `VERSION` file | `{"versionFile": "VERSION", "publish": null}` |
330
- | Versioned only by tag | `{"versionFile": null}`, then `release-kit 1.2.3` |
394
+ | Project | Config |
395
+ | ------------------------------ | -------------------------------------------------------------------------------------- |
396
+ | Node (npm) | nothing — `package.json` and `npm publish` are the defaults |
397
+ | Node (pnpm / bun) | `{"publish": "pnpm publish --tag %d"}` or `{"publish": "bun publish --tag %d"}` |
398
+ | Rust | nothing — `Cargo.toml` and `cargo publish` are detected |
399
+ | Rust + npm (a Tauri plugin) | nothing — both manifests bump, both registries publish |
400
+ | Python | `{"versionFile": "pyproject.toml", "publish": "uv publish"}` |
401
+ | Go | `{"publish": "go list -m %n@%t"}` — the tag is the release; a `version.go` is detected |
402
+ | Anything with a `VERSION` file | `{"versionFile": "VERSION", "publish": null}` |
403
+ | Versioned only by tag | `{"versionFile": null}`, then `release-kit 1.2.3` |
331
404
 
332
405
  The publish step also gets a preflight when the command is one it recognises:
333
406
 
334
- | Publish command | Authentication | Already published? |
335
- | --------------- | ------------------------------------- | ----------------------------------- |
336
- | `npm` / `pnpm` | `whoami` | `view <name>@<version>` |
337
- | `bun` | `bun pm whoami` | `bun pm view <name>@<version>` |
338
- | `uv` | `UV_PUBLISH_TOKEN` in the environment | none — `uv` skips duplicates itself |
339
- | `go` | none needed | `go list -m <module>@<tag>` |
407
+ | Publish command | Authentication | Already published? |
408
+ | --------------- | ----------------------------------------------------------- | ----------------------------------- |
409
+ | `npm` / `pnpm` | `whoami` | `view <name>@<version>` |
410
+ | `bun` | `bun pm whoami` | `bun pm view <name>@<version>` |
411
+ | `uv` | `UV_PUBLISH_TOKEN` in the environment | none — `uv` skips duplicates itself |
412
+ | `cargo` | `CARGO_REGISTRY_TOKEN`, or `cargo login`'s credentials file | `cargo info <crate>@<version>` |
413
+ | `go` | none needed | `go list -m <module>@<tag>` |
340
414
 
341
415
  Anything else runs as written with no preflight. The project name comes from the manifest —
342
416
  `name` in `package.json`, `Cargo.toml` or `pyproject.toml`, `module` in `go.mod` — falling
@@ -345,7 +419,95 @@ back to the repository directory.
345
419
  `publish` is detected too, but only where one ecosystem obviously owns it: `package.json`
346
420
  gets `npm publish`, `Cargo.toml` gets `cargo publish`. Python has several publishers (uv,
347
421
  twine, poetry, flit) and Go has none, so those get nothing rather than a guess — publishing
348
- to the wrong registry is a far worse failure than being asked to configure it.
422
+ to the wrong registry is a far worse failure than being asked to configure it. A manifest
423
+ that forbids publishing is honoured: a `"private": true` package.json, a crate with
424
+ `publish = false`, and a crate built only as a `cdylib` — a native extension module rather
425
+ than a library — get no command at all.
426
+
427
+ ### One repository, two registries
428
+
429
+ A Tauri plugin is a crate and its npm bindings built from one source tree; a maturin
430
+ project is a crate and a wheel. Both manifests sit at the repository root and carry the
431
+ same version, and both publish on the same release. That needs no config:
432
+
433
+ ```
434
+ tauri-plugin-demo/
435
+ package.json @tauri-apps/plugin-demo 1.4.0
436
+ Cargo.toml tauri-plugin-demo 1.4.0
437
+ Cargo.lock
438
+ ```
439
+
440
+ ```sh
441
+ release-kit minor --yes
442
+ # also versioned in Cargo.toml, Cargo.lock (detected)
443
+ # publish: npm publish --tag latest
444
+ # publish: cargo publish
445
+ ```
446
+
447
+ The second manifest is detected only when it **already carries the same version**. Two
448
+ manifests on different numbers are two independent release lines, and dragging one to the
449
+ other's number is a silent, wrong release — that case says so and leaves the file alone,
450
+ pointing at `versionFiles` for a project that really does want them joined. A project that
451
+ wrote its own `versionFile` or `versionFiles` is never extended behind its back.
452
+
453
+ `publish` therefore takes an array as well as a string, for any project releasing to more
454
+ than one registry:
455
+
456
+ ```json
457
+ { "publish": ["npm publish --tag %d", "cargo publish"] }
458
+ ```
459
+
460
+ They run in order, each with its own authentication check and its own already-published
461
+ check, so a re-run after a half-finished publish completes the other half instead of
462
+ failing. The detected order puts npm before cargo deliberately: npm allows an unpublish for
463
+ 72 hours and crates.io never does, so a publish that fails part-way through has not already
464
+ made the permanent half.
465
+
466
+ Names are resolved per registry rather than once: the crate is looked up under the `name`
467
+ in `Cargo.toml`, not the npm package name, since `@tauri-apps/plugin-demo` and
468
+ `tauri-plugin-demo` are the same release under two names.
469
+
470
+ ### Languages that have no version of their own
471
+
472
+ A Go module has only `go.mod`, and `go.mod` carries no version — `go get` resolves a tag.
473
+ The same is true of a Swift package or a plain C library. There is nothing to bump, so the
474
+ tag is the version and `versionFile` resolves to `null` on its own.
475
+
476
+ Those projects still usually keep a number in source, so `mytool --version` prints
477
+ something:
478
+
479
+ ```go
480
+ package main
481
+
482
+ const Version = "1.2.0"
483
+ ```
484
+
485
+ `version.go`, `internal/version/version.go` and `pkg/version/version.go` are detected and
486
+ rewritten on release, with no config — `const Version`, `var Version` and
487
+ `var Version string` are all read. The tag stays the source of truth; the constant is a
488
+ mirror kept in step with it.
489
+
490
+ Detection adopts a file only when it **already carries the current version**. That rules
491
+ out the placeholder a build replaces at link time:
492
+
493
+ ```go
494
+ var Version = "dev" // -ldflags "-X main.Version=$(git describe)" — left alone
495
+ ```
496
+
497
+ A mismatch here is skipped silently rather than warned about, because a placeholder is a
498
+ normal thing to find rather than a mistake — unlike two manifests on different versions.
499
+
500
+ For a constant somewhere else, or in a language whose convention is not in that list, name
501
+ it with a `pattern`:
502
+
503
+ ```json
504
+ {
505
+ "versionFiles": [{ "path": "src/version.h", "pattern": "^#define VERSION \"(.+)\"" }]
506
+ }
507
+ ```
508
+
509
+ `versionFiles` is written whether or not the project has a `versionFile`, so this works for
510
+ a repository that versions by tag alone.
349
511
 
350
512
  With no `versionFile` configured it is detected from the repository — `package.json`,
351
513
  `pyproject.toml`, `Cargo.toml`, then `VERSION` — so most projects need no config for it at
@@ -359,8 +521,60 @@ because the TOML match is anchored to the start of a line, a dependency's
359
521
 
360
522
  **Lockfiles are scoped automatically.** A `Cargo.lock` records a version for every
361
523
  dependency — hundreds of them — so matching the first `version = "…"` would rewrite an
362
- unrelated crate. Listing one rewrites only the `[[package]]` block whose name matches the
363
- crate in the sibling `Cargo.toml`; with no sibling to read, it refuses rather than guesses.
524
+ unrelated crate. Listing one rewrites only the `[[package]]` blocks this project owns: the
525
+ crate named in the sibling `Cargo.toml`, or — for a workspace root, which has no
526
+ `[package]` of its own — every member that inherits the version with
527
+ `version.workspace = true`. A member pinned to its own number is versioned separately and
528
+ is left alone. With nothing beside it to read, it refuses rather than guesses.
529
+
530
+ `package-lock.json`, `npm-shrinkwrap.json` and `uv.lock` record the project's own version
531
+ too, and are refreshed by the tool that owns them rather than rewritten by pattern — the
532
+ version sits in more than one place and the formats change shape between tool versions.
533
+ Each is scoped to its manifest, so a `uv.lock` for a component this release is not
534
+ versioning stays out of the release commit, and a missing tool warns rather than aborting.
535
+ `pnpm-lock.yaml` records no root version, so it never goes stale.
536
+
537
+ ### Marking the line instead of writing a pattern
538
+
539
+ The files that most want keeping in step — a README install line, a badge URL, a
540
+ Dockerfile tag — are the ones where a regex is fiddliest to get right. A comment on the
541
+ line says which of a file's numbers is the version, so nothing outside the file has to
542
+ describe where it sits:
543
+
544
+ ````md
545
+ Install with `npm i acme@1.2.3` <!-- x-release-kit-version -->
546
+
547
+ ```dockerfile
548
+ FROM acme:1.2 # x-release-kit-minor
549
+ ```
550
+ ````
551
+
552
+ `-major`, `-minor`, `-patch`, `-date` and `-version-date` write a piece of the release
553
+ instead of the whole version — `-version-date` covers both on one line, which is the shape
554
+ of an AppStream `<release version="…" date="…"/>` tag — and `x-release-kit-start-<scope>` … `x-release-kit-end` covers a run of lines
555
+ rather than commenting each one. Just list the file:
556
+
557
+ ```json
558
+ { "versionFiles": ["README.md", "Dockerfile"] }
559
+ ```
560
+
561
+ A file listed with no marker, no `pattern` and no recognised extension is treated as
562
+ containing nothing but the version — right for a `VERSION` file, and refused for anything
563
+ else rather than replacing its contents with the number.
564
+
565
+ **Paths may contain `*`**, matching within one path segment, so the per-platform configs a
566
+ desktop app carries do not have to be written out one by one:
567
+
568
+ ```json
569
+ { "versionFiles": ["src-tauri/tauri.*.conf.json"] }
570
+ ```
571
+
572
+ A pattern matching no files aborts: it was written to keep files in step, and silently
573
+ keeping none of them in step is the failure it was meant to prevent. A file the glob
574
+ matched that carries no version is skipped, though — a glob says "every file of this
575
+ shape", and some of them legitimately have none, as a Tauri per-OS overlay does. A file
576
+ you named on purpose is different: being unable to write it fails preflight, before
577
+ anything mutates.
364
578
 
365
579
  For anything else, give a pattern with one capture group around the version. `versionFiles`
366
580
  takes the same entries, so several files stay in sync across formats:
@@ -380,10 +594,7 @@ in one release, across three formats, with no scripting:
380
594
  {
381
595
  "versionFiles": [
382
596
  "apps/desktop/package.json",
383
- "apps/desktop/src-tauri/tauri.conf.json",
384
- "apps/desktop/src-tauri/tauri.macos.conf.json",
385
- "apps/desktop/src-tauri/tauri.windows.conf.json",
386
- "apps/desktop/src-tauri/tauri.linux.conf.json",
597
+ "apps/desktop/src-tauri/tauri.*conf.json",
387
598
  "apps/desktop/src-tauri/Cargo.toml",
388
599
  "apps/desktop/src-tauri/Cargo.lock"
389
600
  ],
@@ -392,6 +603,10 @@ in one release, across three formats, with no scripting:
392
603
  }
393
604
  ```
394
605
 
606
+ The glob covers `tauri.conf.json` and every per-OS overlay beside it, including ones added
607
+ later — and the overlays that carry no `version` of their own are skipped rather than
608
+ failing the release.
609
+
395
610
  Stopping at `push` because the tag is what triggers the build pipeline — see
396
611
  [Libraries versus apps](#-libraries-versus-apps).
397
612
 
@@ -420,21 +635,23 @@ execution is not wired up yet.
420
635
  `release.config.json`, beside `package.json`. Every key is optional; unknown keys abort
421
636
  rather than being silently ignored.
422
637
 
423
- | Key | Default | Meaning |
424
- | --------------- | ------------------------ | ------------------------------------------------------------ |
425
- | `steps` | all but `commit` | Which steps run; the order is fixed |
426
- | `tagPrefix` | `"v"` | Prepended to the version to form the tag |
427
- | `branch` | `"main"` | The only branch a release may run from; `null` allows any |
428
- | `remote` | `"origin"` | Git remote to push to |
429
- | `changelog` | `"CHANGELOG.md"` | Changelog path; `null` for a project without one |
430
- | `versionFile` | detected | Where the version lives; `null` versions by tag alone |
431
- | `versionFiles` | `[]` | Further files kept in sync; a path or `{ path, pattern }` |
432
- | `publish` | `"npm publish --tag %d"` | Publish command; `null` means none is configured |
433
- | `versioning` | `"conventional"` | How `auto` infers; or `always-patch` / `-minor` / `-major` |
434
- | `assistant` | `null` | Drafting CLI: a name, `"auto"`, or `{ tool, model, effort }` |
435
- | `commitMessage` | `"chore(release): %t"` | Release commit subject |
436
- | `releaseTitle` | `"%t"` | GitHub release title |
437
- | `assets` | `[]` | Files attached to the GitHub release |
638
+ | Key | Default | Meaning |
639
+ | --------------- | ---------------------- | --------------------------------------------------------------------- |
640
+ | `steps` | all but `commit` | Which steps run; the order is fixed |
641
+ | `tagPrefix` | `"v"` | Prepended to the version to form the tag |
642
+ | `branch` | `"main"` | The only branch a release may run from; `null` allows any |
643
+ | `remote` | `"origin"` | Git remote to push to |
644
+ | `changelog` | `"CHANGELOG.md"` | Changelog path; `null` for a project without one |
645
+ | `versionFile` | detected | Where the version lives; `null` versions by tag alone |
646
+ | `versionFiles` | detected | Further files kept in sync; a path or `{ path, pattern }` |
647
+ | `publish` | detected | Publish command, or an array of them; `null` publishes nothing |
648
+ | `versioning` | `"conventional"` | How `auto` infers; or `always-patch` / `-minor` / `-major` |
649
+ | `verify` | `null` | Command run during preflight; non-zero aborts before anything mutates |
650
+ | `hooks` | `{}` | Commands run between the steps — see [Hooks](#-hooks) |
651
+ | `assistant` | `null` | Drafting CLI: a name, `"auto"`, or `{ tool, model, effort }` |
652
+ | `commitMessage` | `"chore(release): %t"` | Release commit subject |
653
+ | `releaseTitle` | `"%t"` | GitHub release title |
654
+ | `assets` | `[]` | Files attached to the GitHub release |
438
655
 
439
656
  Command and message strings expand four tokens: `%v` version, `%t` tag, `%n` package
440
657
  name, `%d` npm dist-tag. In the `publish` command line the substituted values are
@@ -468,11 +685,11 @@ Two upstream habits make commit-derived notes trustworthy, and neither is releas
468
685
  check workflow succeeds, with `if: github.repository_owner == 'your-org'` so a fork never
469
686
  tries to release.
470
687
  - **Validate pull request titles.** A squash-merge takes its subject from the PR title, so
471
- that title becomes the commit the notes are built from.
472
- [`amannn/action-semantic-pull-request`](https://github.com/amannn/action-semantic-pull-request)
473
- enforces it. Without something like it, work silently goes missing from release notes —
474
- release-kit says how many commits are not Conventional Commits, but it cannot fix them
475
- after the fact.
688
+ that title becomes the commit the notes are built from. Check it with
689
+ [`lint-commits`](#linting-commits), which uses this tool's own parser rather than a second
690
+ opinion about the grammar. Without something like it, work silently goes missing from
691
+ release notes — release-kit says how many commits are not Conventional Commits, but it
692
+ cannot fix them after the fact.
476
693
 
477
694
  Three things CI does that are worth knowing about:
478
695
 
@@ -559,6 +776,40 @@ A project releasing off a non-default branch with a different tag scheme:
559
776
  }
560
777
  ```
561
778
 
779
+ ## 🪝 Hooks
780
+
781
+ `verify` covers the gate that matters most — the project's own tests, run during preflight
782
+ before anything mutates. What it cannot express is work that has to happen _between_ the
783
+ release's own steps.
784
+
785
+ ```json
786
+ {
787
+ "hooks": {
788
+ "afterVersion": "cargo build --release",
789
+ "beforePublish": "pnpm build",
790
+ "afterRelease": "curl -X POST -d 'shipped %t' https://hooks.example/deploy"
791
+ }
792
+ }
793
+ ```
794
+
795
+ | Hook | Runs |
796
+ | --------------- | ------------------------------------------------------- |
797
+ | `beforeVersion` | After preflight, before any file is written |
798
+ | `afterVersion` | After the version is on disk, before the release commit |
799
+ | `beforePublish` | After the tag is pushed, before anything is published |
800
+ | `afterPublish` | After a publish actually ran |
801
+ | `afterRelease` | After the GitHub release is created |
802
+
803
+ Each takes a command line or an array of them, expanding the same `%v` `%t` `%n` `%d`
804
+ tokens `publish` does, shell-quoted on substitution. A non-zero exit aborts the release
805
+ where it happened. An unknown hook name aborts rather than silently never running.
806
+
807
+ `afterVersion` sits where it does for a reason: **anything it changes is staged for the
808
+ release commit**, so a file regenerated from the version rides in that commit instead of
809
+ being left behind in the working tree. `afterPublish` runs only when a publish actually
810
+ happened, so a re-run that skipped an already-published target does not tell something
811
+ downstream a lie it may act on.
812
+
562
813
  ## 🤖 Assistant (optional)
563
814
 
564
815
  An assistant is an AI CLI already installed on your machine. When one is configured,
@@ -590,12 +841,13 @@ downgrade, so a configured pipeline fails loudly; `"auto"` degrades quietly by d
590
841
 
591
842
  ### What it does
592
843
 
593
- - **A dirty working tree is committed instead of refusing to release.** The tree is
594
- staged, a Conventional Commits message is drafted for the staged diff, and the commit is
595
- made — by default, whenever an assistant is configured (`--skip commit` opts out). The subject is validated
596
- against the Conventional Commits grammar; an answer that does not parse is rejected rather
597
- than committed. Attribution lines (`Co-Authored-By`, `Generated with`) are stripped, so
598
- the tool never signs your commits.
844
+ - **Commit messages for a dirty working tree are drafted from the staged diff**, instead
845
+ of the generated `chore:` message used when no assistant is available. The draft is
846
+ validated, not trusted: a subject that is not Conventional Commits, or a message naming
847
+ a version the staged changes never touch (a model narrating an unchanged `"version"`
848
+ context line), falls back to the generated message rather than being committed.
849
+ Attribution lines (`Co-Authored-By`, `Generated with`) are stripped, so the tool never
850
+ signs your commits.
599
851
  - **Release notes** are drafted from the commits since the last tag when `CHANGELOG.md` has
600
852
  no section for the version. Each bullet ends with a link to the commits it covers: the
601
853
  assistant is given the short hashes and asked to cite them, and every citation is checked
@@ -716,7 +968,7 @@ including a directory that is not a repository.
716
968
 
717
969
  ## 📋 Requirements
718
970
 
719
- - **Node 18+ — including for Rust, Python and Go projects.** `release-kit` is a Node
971
+ - **Node 22+ — including for Rust, Python and Go projects.** `release-kit` is a Node
720
972
  program whatever it releases; there is no standalone binary.
721
973
  - `git`
722
974
  - `gh`, authenticated — only when creating GitHub releases
@@ -732,7 +984,7 @@ with the reasoning — are in [ROADMAP.md](ROADMAP.md).
732
984
 
733
985
  ```sh
734
986
  pnpm install
735
- pnpm test # 63 tests, node --test, no framework
987
+ pnpm test # 188 tests, node --test, no framework
736
988
  pnpm check # format + lint + tests, the same gate CI runs
737
989
  ```
738
990
 
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.7.0",
3
+ "version": "2.9.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",