@entro314labs/release-kit 2.9.4 → 2.10.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 (3) hide show
  1. package/README.md +189 -15
  2. package/package.json +1 -1
  3. package/release.mjs +494 -24
package/README.md CHANGED
@@ -97,7 +97,7 @@ release-kit minor
97
97
  No global state to drift, no install step, and the version is explicit in the command.
98
98
 
99
99
  ```sh
100
- npx @entro314labs/release-kit@2.3.0 minor --yes
100
+ npx @entro314labs/release-kit@2.9.4 minor --yes
101
101
  ```
102
102
 
103
103
  ### Vendored — no registry at release time
@@ -164,7 +164,8 @@ footer) is a major, a `feat:` is a minor, anything else is a patch. Below `1.0.0
164
164
  softened — a breaking change bumps the minor rather than jumping to `1.0.0`. A commit body
165
165
  containing `Release-As: 2.0.0` pins the version outright. It always prints what it inferred
166
166
  and why before doing anything. Set `"versioning"` to `always-patch`, `always-minor` or
167
- `always-major` to never infer.
167
+ `always-major` to never infer. A `Notes:` line in a body sets what the release notes say
168
+ about that commit — see [Release notes](#release-notes).
168
169
 
169
170
  | Target | From `1.2.3` | From `2.0.0-beta.1` |
170
171
  | ------------------------------------ | ------------------------------------------------ | ------------------- |
@@ -227,7 +228,7 @@ commit released is the **pull request title**, which no hook ever sees — check
227
228
  - name: Lint the pull request title
228
229
  env:
229
230
  TITLE: ${{ github.event.pull_request.title }}
230
- run: npx @entro314labs/release-kit@2.8.0 lint-commits --subject "$TITLE"
231
+ run: npx @entro314labs/release-kit@2.9.4 lint-commits --subject "$TITLE"
231
232
  ```
232
233
 
233
234
  Pass the title through `env`, never through `${{ }}` inside `run:` — a pull request title is
@@ -288,8 +289,11 @@ Notes resolve in this order:
288
289
  2. The `## [Unreleased]` section, if the version has no section of its own — this is the
289
290
  same content that step 2 above is about to promote.
290
291
  3. The commits grouped by Conventional Commit type — Features, Bug Fixes, Performance
291
- Improvements, Reverts, with breaking changes first and chores, CI and docs hidden. Each
292
- bullet links to its commit, and `closes #12` / `fixes #34` in a message becomes a link to
292
+ Improvements, Reverts, Dependencies, Documentation, Code Refactoring, Build System,
293
+ Continuous Integration, Tests, Styles, Miscellaneous Chores — with breaking changes
294
+ first. Every type is reported, since a changelog is a record; list the ones to leave out
295
+ in `hiddenTypes` — `["chore", "ci", "docs"]`, say, for a shorter list. A type outside
296
+ the table, such as `security:`, lands under Other Changes. Each bullet links to its commit, and `closes #12` / `fixes #34` in a message becomes a link to
293
297
  the issue. A `BREAKING CHANGE:` footer is used in place of the subject, since it explains
294
298
  the break. A commit reverted within the same release drops out along with its revert.
295
299
  A **New Contributors** section names anyone whose first commit to the repository is in
@@ -299,6 +303,20 @@ Notes resolve in this order:
299
303
  rather than something that requires an assistant.
300
304
  4. Otherwise GitHub generates them from the commits since the previous tag.
301
305
 
306
+ **A `Notes:` line in a commit body is the author's wording for the entry.** Commit-derived
307
+ notes use it in place of the subject (and in place of a `BREAKING CHANGE:` footer), and an
308
+ [assistant](#-assistant-optional) is handed it with the commit and told to keep it.
309
+ `Notes: no-notes` leaves the commit out of the notes entirely — both kinds — for work that
310
+ has to be a `fix:` or `feat:` for the version bump but is not news to anyone upgrading. The
311
+ bump itself is unaffected: the trailer decides what is said about a change, not whether it
312
+ counts.
313
+
314
+ ```text
315
+ fix(ui): raise the badge z-index above the progress bar
316
+
317
+ Notes: The pull request badge stays visible while a push is in progress
318
+ ```
319
+
302
320
  **Which tag the history is read from** is the highest version tag carrying the configured
303
321
  prefix that is reachable from `HEAD` — not the nearest tag. A repository carrying tags that
304
322
  are not releases (a rolling `latest-beta` marker, a `nightly`) is unaffected by them, and a
@@ -308,7 +326,11 @@ patch tagged on top of a later minor does not drag the baseline backwards.
308
326
  `2.0.0-rc.1` and `-rc.2` reads history from the last _stable_ tag, so the notes describe
309
327
  everything the release ships rather than the gap between the last two candidates — which
310
328
  is usually just the release commit. Releasing a candidate is unchanged: each one's notes
311
- say what changed in that candidate.
329
+ say what changed in that candidate. The candidates' own changelog sections are not a source
330
+ for the stable release: its notes are generated from those commits, so wording edited into
331
+ a `[2.0.0-rc.1]` section does not carry over. The sections stay in the file, preflight warns
332
+ when it finds them, and writing the `2.0.0` notes into `[Unreleased]` before releasing is
333
+ how hand-written wording reaches the stable release.
312
334
 
313
335
  `--notes <source>` forces one instead of walking that list: `changelog`, `assistant`,
314
336
  `commits`, or `github`. A named source that produces nothing is an error rather than a
@@ -366,9 +388,13 @@ rather than stopping at the first problem.
366
388
  - `gh` is installed and authenticated
367
389
  - Commit and tag signing can actually sign, and the key is one GitHub will accept
368
390
  - The publishing CLI is authenticated, and the version is not already published
391
+ - A crate that `cargo publish` will upload packages cleanly (`cargo package`) and fits
392
+ crates.io's 10 MiB upload limit — see [below](#crates-are-packaged-in-preflight)
369
393
  - The previous release actually reached the registry — one that did not is either finished
370
394
  by this run or absorbed into it _(warning)_
371
395
  - Configured release assets exist
396
+ - With `"requireGreen": true`, HEAD is on the remote and GitHub reports it green — see
397
+ [below](#requiring-a-green-head)
372
398
  - The configured `verify` command passes — the project's own gate (tests, build) runs
373
399
  before anything mutates, instead of a `prepublishOnly` hook failing after the commit,
374
400
  tag and push
@@ -378,10 +404,60 @@ rather than stopping at the first problem.
378
404
  previous tag reachable it passes, without one it fails `auto` (the bump would be inferred
379
405
  from partial history) and warns otherwise
380
406
  - A changelog section for the version exists _(warning — it falls back to generated notes)_
407
+ - Notes drafted by an [assistant](#-assistant-optional) carry no entry it marked `[???]`
408
+ as unsure — refused under `--yes` or without a terminal, listed for review at the prompt
409
+ otherwise
381
410
 
382
411
  Under `--dry-run` the failures are reported and then the remaining steps are shown anyway,
383
412
  so you can see the whole plan without fixing the blockers first.
384
413
 
414
+ ### Crates are packaged in preflight
415
+
416
+ `cargo publish` packages and verifies the crate as its first act, so a file your
417
+ `include`/`exclude` left out, metadata crates.io refuses, a crate that does not build from
418
+ its own archive, or an archive over crates.io's 10 MiB limit used to surface only after the
419
+ release was tagged and pushed. When a `cargo publish` command is going to run, preflight
420
+ runs `cargo package` with the same arguments first — `-p`, `--workspace`,
421
+ `--manifest-path`, `--features` and `--no-verify` carry over, so a workspace packages each
422
+ crate it will publish — and refuses on a failure or an oversized `.crate`.
423
+
424
+ - It packages the tree before the version bump. That is enough because the bump writes
425
+ the version into `Cargo.toml` _and_ `Cargo.lock` together, so the tree `cargo publish`
426
+ sees differs only in the version number, which changes neither the file list nor the
427
+ size. A `Cargo.lock` left on the old version would dirty the tree after the push instead:
428
+ it is detected beside a `Cargo.toml` version source, and a configured `versionFiles` that
429
+ writes `Cargo.toml` without it is refused while a `cargo publish` is going to run.
430
+ - `--locked` is added when the repository has a `Cargo.lock`; `--allow-dirty` only when the
431
+ tree is dirty (which preflight reports on its own, or the `commit` step is about to fix).
432
+ - Like `cargo publish`, it compiles the crate, so preflight takes a build longer. Put
433
+ `--no-verify` on the publish command if you want neither to compile.
434
+ - A publish command that is not a plain `cargo publish …` — a pipeline, quoting, an
435
+ environment prefix — is left alone, since what it uploads cannot be read off it.
436
+ - The 10 MiB is crates.io's default; it can raise the limit for a single crate on request.
437
+
438
+ ### Requiring a green HEAD
439
+
440
+ `verify` runs your gate locally. `"requireGreen": true` asks GitHub instead, before anything
441
+ is bumped: the release is refused unless the commit being released is on the remote (a
442
+ local commit has no checks, so HEAD must not be ahead of `origin/<branch>`) and its checks
443
+ passed. A dirty tree the `commit` step would fold in is refused too — CI never saw it.
444
+
445
+ - **Failed** — any check run concluding `failure`, `cancelled`, `timed_out`,
446
+ `action_required` or `stale`, or a commit status of `failure` or `error`. The failing
447
+ checks are named.
448
+ - **Not finished** — any check run still queued or in progress, or a status still
449
+ `pending`: wait for it and re-run.
450
+ - **Nothing checked** — no check run or status reporting `success`. Zero checks, or only
451
+ skipped ones, means nothing verified the commit, which is not the same as green.
452
+
453
+ Both of GitHub's systems are read — check runs (`commits/{sha}/check-runs`, what Actions
454
+ and GitHub Apps report) and commit statuses (`commits/{sha}/status`, what external CI such
455
+ as Buildkite or Jenkins still posts) — because either one alone can call a commit green
456
+ while the other has it red. It needs `gh`, authenticated, even with the `release` step off.
457
+ Run inside GitHub Actions, the check runs of the current workflow run are ignored: the job
458
+ doing the release is itself an unfinished check on that commit. Ordering against other jobs
459
+ in the same workflow is what `needs:` is for.
460
+
385
461
  ## ♻️ Recovering from a failed run
386
462
 
387
463
  Re-run the same command. Every step is idempotent:
@@ -579,6 +655,12 @@ too, and are refreshed by the tool that owns them rather than rewritten by patte
579
655
  version sits in more than one place and the formats change shape between tool versions.
580
656
  Each is scoped to its manifest, so a `uv.lock` for a component this release is not
581
657
  versioning stays out of the release commit, and a missing tool warns rather than aborting.
658
+
659
+ A `Cargo.lock` beside a detected `Cargo.toml` is kept in step without being listed, whether
660
+ `Cargo.toml` is the version source (a plain crate) or a companion manifest. A configured
661
+ `versionFiles` is never extended, so list the lockfile there yourself; when a `cargo publish`
662
+ is going to run and a written `Cargo.toml` would leave its `Cargo.lock` on the old version,
663
+ preflight refuses rather than letting `cargo publish` fail on the dirty tree after the push.
582
664
  `pnpm-lock.yaml` records no root version, so it never goes stale.
583
665
 
584
666
  ### Marking the line instead of writing a pattern
@@ -655,7 +737,9 @@ later — and the overlays that carry no `version` of their own are skipped rath
655
737
  failing the release.
656
738
 
657
739
  Stopping at `push` because the tag is what triggers the build pipeline — see
658
- [Libraries versus apps](#-libraries-versus-apps).
740
+ [Libraries versus apps](#-libraries-versus-apps). When release-kit itself runs in CI, the
741
+ push must not use `GITHUB_TOKEN`, or the tag triggers nothing — see
742
+ [Continuous integration](#continuous-integration).
659
743
 
660
744
  The project name comes from the manifest when there is one (`name` in `package.json`,
661
745
  `Cargo.toml` or `pyproject.toml`), and falls back to the repository directory.
@@ -694,6 +778,7 @@ rather than being silently ignored.
694
778
  | `publish` | detected | Publish command, or an array of them; `null` publishes nothing |
695
779
  | `versioning` | `"conventional"` | How `auto` infers; or `always-patch` / `-minor` / `-major` |
696
780
  | `verify` | `null` | Command run during preflight; non-zero aborts before anything mutates |
781
+ | `requireGreen` | `false` | Refuse a HEAD that is not pushed and green on GitHub |
697
782
  | `hooks` | `{}` | Commands run between the steps — see [Hooks](#-hooks) |
698
783
  | `assistant` | `null` | Drafting CLI: a name, `"auto"`, or `{ tool, model, effort }` |
699
784
  | `commitMessage` | `"chore(release): %t"` | Release commit subject |
@@ -715,13 +800,19 @@ Non-interactive by default: the confirmation prompt is skipped when stdin is not
715
800
  `gh` picks up `GITHUB_TOKEN` on its own. Pass `--yes` to be explicit.
716
801
 
717
802
  ```yaml
718
- - uses: actions/checkout@v5
803
+ - uses: actions/create-github-app-token@v3
804
+ id: app-token
805
+ with:
806
+ client-id: ${{ vars.RELEASE_APP_CLIENT_ID }}
807
+ private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
808
+ - uses: actions/checkout@v7
719
809
  with:
720
810
  fetch-depth: 0 # release notes and the last-tag lookup need real history
811
+ token: ${{ steps.app-token.outputs.token }} # the push below is made with this token
721
812
  - id: release
722
- run: npx @entro314labs/release-kit@2.3.0 minor --yes
813
+ run: npx @entro314labs/release-kit@2.9.4 minor --yes
723
814
  env:
724
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
815
+ GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
725
816
  - run: echo "shipped ${{ steps.release.outputs.tag }} ${{ steps.release.outputs.release-url }}"
726
817
  ```
727
818
 
@@ -730,11 +821,71 @@ re-deriving it: `version`, `tag`, `name`, `dist-tag`, `steps`, `published`, `rel
730
821
  Nothing is written on a dry run, and an unwritable `$GITHUB_OUTPUT` never fails a release
731
822
  that already completed.
732
823
 
824
+ **A tag pushed with `GITHUB_TOKEN` triggers nothing.** GitHub deliberately does not start
825
+ workflows from events caused by the job's own `GITHUB_TOKEN`, so an `on: push: tags` build
826
+ workflow never runs for a tag release-kit pushed with it — the release is tagged and the
827
+ build that was supposed to follow it silently does not happen. The push uses whatever token
828
+ `actions/checkout` persisted, which is why the example gives checkout a
829
+ [GitHub App](https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/making-authenticated-api-requests-with-a-github-app-in-a-github-actions-workflow)
830
+ installation token (`actions/create-github-app-token`) — a fine-grained personal access
831
+ token with `contents: write` works the same way. Leave `persist-credentials` on for this
832
+ job: the push has nothing to authenticate with otherwise. The alternative that needs no
833
+ second identity is not to rely on the tag event at all: run the build in the same workflow
834
+ after the release job (`needs: release`), or call it as a reusable workflow
835
+ (`on: workflow_call`) with the tag from `steps.release.outputs.tag`. With `GITHUB_TOKEN`
836
+ alone, the example above still releases correctly; only the tag-triggered follow-up is lost.
837
+
733
838
  Two upstream habits make commit-derived notes trustworthy, and neither is release-kit's job:
734
839
 
735
840
  - **Gate the release on CI, and guard against forks.** Trigger on `workflow_run` after your
736
- check workflow succeeds, with `if: github.repository_owner == 'your-org'` so a fork never
737
- tries to release.
841
+ check workflow completes — but that event fires for _every_ completed run of it, including
842
+ pull request runs and failed ones, so the condition has to say which run may release.
843
+ `workflow_run` also checks out the default branch's latest commit, not the commit that
844
+ was checked, so check out `head_sha` explicitly:
845
+
846
+ ```yaml
847
+ on:
848
+ workflow_run:
849
+ workflows: [check]
850
+ types: [completed]
851
+ branches: [main]
852
+
853
+ jobs:
854
+ release:
855
+ # A push to main that passed, in this repository — not a fork's copy of the workflow.
856
+ if: >-
857
+ github.repository_owner == 'your-org' &&
858
+ github.event.workflow_run.event == 'push' &&
859
+ github.event.workflow_run.head_branch == 'main' &&
860
+ github.event.workflow_run.conclusion == 'success'
861
+ runs-on: ubuntu-latest
862
+ steps:
863
+ - uses: actions/create-github-app-token@v3
864
+ id: app-token
865
+ with:
866
+ client-id: ${{ vars.RELEASE_APP_CLIENT_ID }}
867
+ private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
868
+ - uses: actions/checkout@v7
869
+ with:
870
+ ref: ${{ github.event.workflow_run.head_sha }}
871
+ fetch-depth: 0
872
+ token: ${{ steps.app-token.outputs.token }} # so the tag push triggers workflows
873
+ # Checking out a SHA leaves a detached HEAD, which release-kit refuses — there is no
874
+ # branch to push. Put main back at the commit CI checked; if main has moved on
875
+ # since, preflight refuses as "behind origin/main" instead of releasing it unchecked.
876
+ - run: git switch -C main
877
+ - run: npx @entro314labs/release-kit@2.9.4 auto --yes
878
+ env:
879
+ GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
880
+ ```
881
+
882
+ [`requireGreen`](#requiring-a-green-head) is the same gate inside release-kit, for a
883
+ release run by hand or from a workflow that does not know which checks passed.
884
+
885
+ `branches: [main]` narrows the trigger to runs on `main`, but a pull request from a fork
886
+ whose branch is named `main` matches it too; `event == 'push'` is what rules pull request
887
+ runs out. The owner check stops a fork's own copy of the workflow from trying to release.
888
+
738
889
  - **Validate pull request titles.** A squash-merge takes its subject from the PR title, so
739
890
  that title becomes the commit the notes are built from. Check it with
740
891
  [`lint-commits`](#linting-commits), which uses this tool's own parser rather than a second
@@ -795,7 +946,10 @@ Two npm behaviours are handled automatically:
795
946
  `id-token: write`, or GitLab CI/CircleCI with `NPM_ID_TOKEN`, `whoami` fails while
796
947
  `publish` succeeds. That environment is detected and the auth check is skipped, so a
797
948
  valid CI release is not aborted over a missing token it does not need. That covers the
798
- CLIs that exchange the OIDC token themselves — npm, pnpm, bun and `uv publish`. cargo is
949
+ CLIs that exchange the OIDC token themselves — npm, pnpm, bun and `uv publish`. npm
950
+ only learned to in 11.5.1, and an older one fails the publish after the tag and push, so
951
+ under OIDC an older `npm` is a preflight failure (`npm install -g npm@latest` fixes it).
952
+ pnpm and bun document no minimum and are not checked. cargo is
799
953
  not one of them: crates.io's trusted publishing goes through an action that turns the
800
954
  token into `CARGO_REGISTRY_TOKEN`, so the cargo credential check still applies in CI.
801
955
 
@@ -910,6 +1064,24 @@ downgrade, so a configured pipeline fails loudly; `"auto"` degrades quietly by d
910
1064
  annotation, and posted as the GitHub release body — the same "written once, lands in three
911
1065
  places" path a hand-written section takes.
912
1066
 
1067
+ The prompt asks for notes written for someone upgrading: internal chores (CI, formatting,
1068
+ version and dependency bumps) are left out, except a dependency that ships inside the
1069
+ product — a bundled runtime, a sidecar binary, an embedded engine — since users run that
1070
+ code; a fix is described as what works now rather than what was broken; and a commit's
1071
+ [`Notes:` line](#release-notes) is kept in the author's words.
1072
+
1073
+ **An entry the model is unsure of is flagged, not guessed.** The prompt lets it start a
1074
+ bullet with `[???]` when it cannot tell whether a change is user-facing or what it means for
1075
+ someone upgrading. Flagged entries are listed at the confirmation prompt, and answering `y`
1076
+ releases them as shown with the marker removed — your answer is the review. With `--yes`,
1077
+ or with no terminal to ask on (CI), there is nobody to review them, so preflight refuses
1078
+ before anything mutates: re-run in a terminal, or write the notes into `[Unreleased]` with
1079
+ the entries resolved, since a hand-written section wins over a draft. When a dirty tree
1080
+ defers drafting past the prompt, the check runs right after the working-tree commit instead
1081
+ and asks again (or, non-interactively, stops there with only that commit made). This applies
1082
+ to drafted notes only — commit-derived and hand-written notes are never checked for the
1083
+ marker.
1084
+
913
1085
  With `--commit`, notes are drafted _after_ that commit lands, so they describe the change it
914
1086
  just made. Merge, release, `WIP` and `fixup!`/`squash!` commits are excluded from the prompt.
915
1087
 
@@ -960,7 +1132,9 @@ at the pushed tag:
960
1132
  ```
961
1133
 
962
1134
  Nothing after `push` — no `publish`, no `release`. The tag push is the handoff, and it is
963
- what triggers the build workflow. The notes travel in the annotated tag, which is the one
1135
+ what triggers the build workflow — unless it was pushed with `GITHUB_TOKEN` from another
1136
+ workflow, which triggers nothing ([Continuous integration](#continuous-integration) has the
1137
+ fix). The notes travel in the annotated tag, which is the one
964
1138
  thing that reaches a fresh checkout on another machine; the signature block a signed tag
965
1139
  appends is stripped before the file is handed over:
966
1140
 
@@ -972,7 +1146,7 @@ on:
972
1146
  jobs:
973
1147
  build:
974
1148
  steps:
975
- - uses: actions/checkout@v5
1149
+ - uses: actions/checkout@v7
976
1150
  with: { fetch-depth: 0 }
977
1151
  - name: Read the release notes off the tag
978
1152
  run: |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@entro314labs/release-kit",
3
- "version": "2.9.4",
3
+ "version": "2.10.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",
package/release.mjs CHANGED
@@ -42,6 +42,7 @@ import {
42
42
  mkdtempSync,
43
43
  readdirSync,
44
44
  readFileSync,
45
+ statSync,
45
46
  writeFileSync,
46
47
  } from 'node:fs'
47
48
  import { homedir, tmpdir } from 'node:os'
@@ -92,6 +93,9 @@ import { fileURLToPath } from 'node:url'
92
93
  * verify string command run during preflight — a project's own gate (tests,
93
94
  * build). Non-zero aborts before anything mutates, instead of a
94
95
  * prepublishOnly hook failing after the commit, tag and push
96
+ * requireGreen boolean refuse to release a HEAD that GitHub does not report green:
97
+ * pushed, every check run and commit status finished, none of
98
+ * them failed. See `checkHeadIsGreen`
95
99
  * assistant string|object drafting CLI for commit messages and notes. A key of
96
100
  * ASSISTANTS, "auto" for the first available, or null. The
97
101
  * object form { tool, model, effort } also pins which model and
@@ -147,6 +151,7 @@ const DEFAULTS = {
147
151
  '^(fixup|squash)!',
148
152
  ],
149
153
  verify: null,
154
+ requireGreen: false,
150
155
  hooks: {},
151
156
  }
152
157
 
@@ -657,11 +662,29 @@ const CHANGELOG_SECTIONS = [
657
662
  { type: 'chore', section: 'Miscellaneous Chores' },
658
663
  ]
659
664
 
665
+ /**
666
+ * The `Notes:` trailer in a commit body: the author's own wording for the release-notes
667
+ * entry, which beats anything derived from the subject. `Notes: no-notes` keeps the commit
668
+ * out of the notes altogether — a refactor that has to be a `fix:` for the version bump but
669
+ * that no reader upgrading needs to hear about. GitHub Desktop runs its notes this way.
670
+ *
671
+ * The version bump is not affected either way: the trailer decides what is said about a
672
+ * change, not whether it happened.
673
+ *
674
+ * @returns {{text: string|null, excluded: boolean}}
675
+ */
676
+ function notesTrailer(body = '') {
677
+ const text = /^Notes:[ \t]*(\S.*)$/im.exec(body)?.[1]?.trim() ?? null
678
+ const excluded = !!text && /^no-notes$/i.test(text)
679
+ return { text: excluded ? null : text, excluded }
680
+ }
681
+
660
682
  /**
661
683
  * Parse a commit into the parts a release cares about.
662
684
  *
663
685
  * @returns {{type: string, scope: string|null, breaking: boolean, subject: string,
664
- * releaseAs: string|null} | null} null when the subject is not Conventional Commits
686
+ * releaseAs: string|null, notes: string|null, noNotes: boolean} | null} null when the
687
+ * subject is not Conventional Commits
665
688
  */
666
689
  function parseCommit(subject, body = '', hash = '') {
667
690
  // Conventional Commits does not restrict the type to letters — `i18n:` and `a11y:` are
@@ -680,6 +703,7 @@ function parseCommit(subject, body = '', hash = '') {
680
703
  // A BREAKING CHANGE footer usually explains the break far better than the subject does.
681
704
  const breakingNote =
682
705
  /^BREAKING[ -]CHANGE:\s*([\s\S]+?)(?=\n\n|$)/m.exec(body)?.[1]?.trim() ?? null
706
+ const trailer = notesTrailer(body)
683
707
  return {
684
708
  type: type.toLowerCase(),
685
709
  scope: scope ?? null,
@@ -689,6 +713,8 @@ function parseCommit(subject, body = '', hash = '') {
689
713
  releaseAs,
690
714
  closes: [...new Set(closes)],
691
715
  breakingNote,
716
+ notes: trailer.text,
717
+ noNotes: trailer.excluded,
692
718
  }
693
719
  }
694
720
 
@@ -727,7 +753,9 @@ function inferBump(commits, currentVersion, strategy = 'conventional') {
727
753
  * @returns {string | null} markdown body, or null when nothing visible changed
728
754
  */
729
755
  function changelogFromCommits(commits, links = null, hidden = [], contributors = []) {
730
- const parsed = commits.map((c) => parseCommit(c.subject, c.body, c.hash)).filter(Boolean)
756
+ const parsed = commits
757
+ .map((c) => parseCommit(c.subject, c.body, c.hash))
758
+ .filter((c) => c && !c.noNotes)
731
759
  const lines = []
732
760
 
733
761
  /** One bullet: scope, text, a link to the commit, and any issues it closes. */
@@ -745,8 +773,9 @@ function changelogFromCommits(commits, links = null, hidden = [], contributors =
745
773
  const breaking = parsed.filter((c) => c.breaking)
746
774
  if (breaking.length) {
747
775
  lines.push('### ⚠ BREAKING CHANGES', '')
748
- // The footer explains the break; the subject only says what changed.
749
- for (const c of breaking) lines.push(bullet(c, c.breakingNote ?? c.subject))
776
+ // The footer explains the break; the subject only says what changed. A `Notes:`
777
+ // trailer beats both: it is the author's wording written for exactly this list.
778
+ for (const c of breaking) lines.push(bullet(c, c.notes ?? c.breakingNote ?? c.subject))
750
779
  lines.push('')
751
780
  }
752
781
 
@@ -768,7 +797,7 @@ function changelogFromCommits(commits, links = null, hidden = [], contributors =
768
797
  )
769
798
  if (!inSection.length) continue
770
799
  lines.push(`### ${section}`, '')
771
- for (const c of inSection) lines.push(bullet(c, c.subject))
800
+ for (const c of inSection) lines.push(bullet(c, c.notes ?? c.subject))
772
801
  lines.push('')
773
802
  }
774
803
 
@@ -780,7 +809,7 @@ function changelogFromCommits(commits, links = null, hidden = [], contributors =
780
809
  )
781
810
  if (other.length) {
782
811
  lines.push('### Other Changes', '')
783
- for (const c of other) lines.push(bullet(c, c.subject))
812
+ for (const c of other) lines.push(bullet(c, c.notes ?? c.subject))
784
813
  lines.push('')
785
814
  }
786
815
 
@@ -1066,13 +1095,41 @@ function linkCitedCommits(notes, commits, links) {
1066
1095
  })
1067
1096
  }
1068
1097
 
1098
+ /**
1099
+ * The marker a drafting model puts on an entry it is not sure of — whether the change is
1100
+ * user-facing, or what it means for someone upgrading. The alternative is a confident guess
1101
+ * published as fact; a flagged entry is a question for the human cutting the release.
1102
+ */
1103
+ const UNSURE = '[???]'
1104
+
1105
+ /**
1106
+ * Drafted entries the model flagged as unsure, as the lines it wrote them on. Drafts are
1107
+ * validated, not trusted: like an invented commit hash, a flagged entry must not reach a
1108
+ * tag, a changelog or a release page without someone having looked at it.
1109
+ *
1110
+ * @returns {string[]}
1111
+ */
1112
+ function uncertainEntries(notes) {
1113
+ return (notes ?? '')
1114
+ .split('\n')
1115
+ .map((line) => line.trim())
1116
+ .filter((line) => line.includes(UNSURE))
1117
+ }
1118
+
1119
+ /** The notes with the marker removed, once a human has reviewed the flagged entries. */
1120
+ const withoutUnsureMarkers = (notes) => notes.replaceAll(`${UNSURE} `, '').replaceAll(UNSURE, '')
1121
+
1069
1122
  /**
1070
1123
  * Draft release notes from the commit log.
1071
1124
  *
1072
1125
  * @returns {string | null} markdown body (no version heading), or null
1073
1126
  */
1074
1127
  function draftReleaseNotes(version, commits, lastTag, links) {
1075
- if (!commits.length) return null
1128
+ // `Notes: no-notes` is the author saying this commit is not news; the model never sees it.
1129
+ const listed = commits
1130
+ .map((c) => ({ ...c, trailer: notesTrailer(c.body) }))
1131
+ .filter((c) => !c.trailer.excluded)
1132
+ if (!listed.length) return null
1076
1133
 
1077
1134
  const prompt = [
1078
1135
  `Write release notes for version ${version}.`,
@@ -1084,20 +1141,31 @@ function draftReleaseNotes(version, commits, lastTag, links) {
1084
1141
  '- End every bullet with the short hashes it covers, in parentheses: `(abc1234)` or',
1085
1142
  ' `(abc1234, def5678)` when merged. Copy them exactly from the list below and invent',
1086
1143
  ' nothing — a hash that is not in the list will be removed.',
1087
- '- Omit internal chores: CI, linting, formatting, dependency bumps, version bumps.',
1144
+ '- Omit internal chores: CI, linting, formatting, version bumps and dependency bumps —',
1145
+ ' except a component that ships inside the product (a bundled runtime, a sidecar',
1146
+ ' binary, an embedded engine or database). Users run that code, so its update is a',
1147
+ ' user-visible change: name the component and the version it moved to.',
1148
+ '- For bug fixes, describe what works now, not what was broken.',
1149
+ '- If you cannot tell whether a change is user-facing, or what it means for someone',
1150
+ ` upgrading, start that bullet with ${UNSURE} — a person will resolve it. Do not guess.`,
1151
+ "- A commit with a `Notes:` line carries its author's wording for the entry: use that",
1152
+ ' text as written, changing it only to fit the heading or to merge it with related work.',
1088
1153
  '- Write for someone upgrading: say what changed for them, not which files moved.',
1089
1154
  '- Plain, factual language. No hype, no emoji, no concluding summary.',
1090
1155
  '- Output only the markdown body: no version heading, no code fences, no attribution.',
1091
1156
  '- Do NOT explain your reasoning or add any commentary before or after the notes.',
1092
1157
  '',
1093
1158
  `Commits since ${lastTag ?? 'the start of the project'}:`,
1094
- ...commits.map((c) => `- ${(c.hash ?? '').slice(0, 7)} ${c.subject}`),
1159
+ ...listed.flatMap((c) => [
1160
+ `- ${(c.hash ?? '').slice(0, 7)} ${c.subject}`,
1161
+ ...(c.trailer.text ? [` Notes: ${c.trailer.text}`] : []),
1162
+ ]),
1095
1163
  ].join('\n')
1096
1164
 
1097
1165
  const drafted = runAssistant(prompt)
1098
1166
  if (!drafted) return null
1099
1167
  const cleaned = cleanNotes(drafted)
1100
- return cleaned ? linkCitedCommits(cleaned, commits, links) : null
1168
+ return cleaned ? linkCitedCommits(cleaned, listed, links) : null
1101
1169
  }
1102
1170
 
1103
1171
  /** One-line rendering of an argv, so a multi-line arg (release notes) stays readable. */
@@ -1396,6 +1464,32 @@ function changelogOutOfOrder(text) {
1396
1464
  return versions.filter((v, i) => i > 0 && compareVersions(versions[i - 1], v) < 0)
1397
1465
  }
1398
1466
 
1467
+ /**
1468
+ * The release candidates of a stable version that the changelog has sections for: for
1469
+ * `2.0.0`, the `2.0.0-rc.1` and `2.0.0-beta.3` headings.
1470
+ *
1471
+ * A stable release reads history from the last stable tag and generates its notes from
1472
+ * those commits, so wording someone edited into a candidate's section does not carry over.
1473
+ * The sections stay in the file; this is how the release notices they exist.
1474
+ *
1475
+ * @returns {string[]} the candidate versions, in file order
1476
+ */
1477
+ function candidateSections(text, version) {
1478
+ const base = parseVersion(version)
1479
+ if (!base || base.pre.length) return []
1480
+ return [...text.matchAll(/^## \[?v?(\d+\.\d+\.\d+-[\w.]+)\]?/gm)]
1481
+ .map((m) => m[1])
1482
+ .filter((v) => {
1483
+ const parsed = parseVersion(v)
1484
+ return (
1485
+ parsed &&
1486
+ parsed.major === base.major &&
1487
+ parsed.minor === base.minor &&
1488
+ parsed.patch === base.patch
1489
+ )
1490
+ })
1491
+ }
1492
+
1399
1493
  /** The `## ` heading offsets in a changelog, in file order. */
1400
1494
  const sectionOffsets = (text) => [...text.matchAll(/^## .*$/gm)].map((m) => m.index)
1401
1495
 
@@ -1425,9 +1519,13 @@ function insertChangelogSection(text, version, date, body) {
1425
1519
  }
1426
1520
  // Falling out of the loop means every version section is newer, so the release belongs
1427
1521
  // at the foot. With nothing to compare against it belongs at the head instead: appending
1428
- // to a date-headed changelog would file the release below its oldest entry.
1429
- if (!comparable && offsets.length) {
1430
- return `${text.slice(0, offsets[0])}${entry}\n${text.slice(offsets[0])}`
1522
+ // to a date-headed changelog would file the release below its oldest entry. The head is
1523
+ // below [Unreleased], though, which stays the first section — a first release drafted into
1524
+ // a changelog holding only an empty [Unreleased] used to land above it.
1525
+ if (!comparable) {
1526
+ const unreleased = /^## \[?Unreleased\]?/i
1527
+ const first = offsets.find((offset) => !unreleased.test(text.slice(offset)))
1528
+ if (first !== undefined) return `${text.slice(0, first)}${entry}\n${text.slice(first)}`
1431
1529
  }
1432
1530
  const trimmed = text.trimEnd()
1433
1531
  return `${trimmed}\n\n${entry}`
@@ -1611,6 +1709,53 @@ function expandPaths(pattern) {
1611
1709
  return current
1612
1710
  }
1613
1711
 
1712
+ /**
1713
+ * crates.io's default ceiling on an uploaded `.crate`, in bytes: `10 * 1024 * 1024` in
1714
+ * crates.io's src/config/publish_limits.rs. The registry can raise it for one crate on
1715
+ * request, but a crate over it is otherwise refused at upload — after the tag and the push.
1716
+ */
1717
+ const CRATES_IO_MAX_BYTES = 10 * 1024 * 1024
1718
+
1719
+ /**
1720
+ * The `cargo package` argv that builds exactly what a `cargo publish` command would upload,
1721
+ * so preflight can find a missing file or an oversized archive before anything is tagged.
1722
+ *
1723
+ * Deriving it from the publish command rather than guessing keeps the selection the same:
1724
+ * `-p`, `--workspace`, `--manifest-path`, `--features` and `--no-verify` mean the same thing
1725
+ * to both subcommands, so a workspace packages each crate it will publish. Only the flags
1726
+ * `cargo package` does not take are dropped (`--dry-run`, `--token`).
1727
+ *
1728
+ * `--locked` is added when the repository has a `Cargo.lock`: publishing verifies against
1729
+ * it, and a stale one is a failure better found now. Without a lockfile it would refuse
1730
+ * outright, and plenty of libraries do not commit one. `--allow-dirty` is added only when
1731
+ * the tree is dirty — preflight reports that on its own, or the commit step is about to
1732
+ * make it clean — so the package check still says something useful.
1733
+ *
1734
+ * A command that is not a plain `cargo publish …` (a pipeline, quoting, an env prefix) is
1735
+ * not taken apart: there is no reliable way to know what it uploads.
1736
+ *
1737
+ * @returns {string[] | null} args for `cargo`, or null when it cannot be derived
1738
+ */
1739
+ function cargoPackageArgs(command, { dirty = false, lockfile = false } = {}) {
1740
+ if (!/^cargo\s+publish(?:\s|$)/.test(command.trim()) || /[;&|<>`$()'"\\]/.test(command)) {
1741
+ return null
1742
+ }
1743
+ const words = command.trim().split(/\s+/).slice(2)
1744
+ const args = ['package']
1745
+ for (let i = 0; i < words.length; i += 1) {
1746
+ const word = words[i]
1747
+ if (word === '--dry-run' || word === '-n' || word.startsWith('--token=')) continue
1748
+ if (word === '--token') {
1749
+ i += 1
1750
+ continue
1751
+ }
1752
+ args.push(word)
1753
+ }
1754
+ if (lockfile && !args.includes('--locked')) args.push('--locked')
1755
+ if (dirty && !args.includes('--allow-dirty')) args.push('--allow-dirty')
1756
+ return args
1757
+ }
1758
+
1614
1759
  /**
1615
1760
  * The crates in a Cargo workspace whose version this bump owns: the members that inherit
1616
1761
  * it with `version.workspace = true`, which is how a workspace keeps its crates in step.
@@ -2166,6 +2311,10 @@ const ECOSYSTEM_MANIFESTS = ['package.json', 'pyproject.toml', 'Cargo.toml']
2166
2311
  */
2167
2312
  function detectCompanionFiles(primaryPath, primaryVersion) {
2168
2313
  const companions = []
2314
+ // The lockfile pins the crate's own version, so a bump leaves it stale — whether
2315
+ // Cargo.toml is the version source or a manifest kept in step with one. A plain crate was
2316
+ // the case missed: `cargo publish` then refused the dirty lockfile, after the push.
2317
+ if (primaryPath === 'Cargo.toml' && existsSync('Cargo.lock')) companions.push('Cargo.lock')
2169
2318
  for (const candidate of ECOSYSTEM_MANIFESTS) {
2170
2319
  if (candidate === primaryPath || !existsSync(candidate)) continue
2171
2320
  const found = readVersionFrom({ path: candidate })
@@ -2898,6 +3047,31 @@ if (bumping) {
2898
3047
  }
2899
3048
  }
2900
3049
 
3050
+ // A configured list is never extended, so a Cargo.toml written without the Cargo.lock beside
3051
+ // it leaves the lockfile on the old version. Publishing is where that breaks: `cargo publish`
3052
+ // refuses a dirty tree, after the tag and the push. Say so before either.
3053
+ if (bumping && publishTargets.some((target) => target.cli === 'cargo')) {
3054
+ const written = new Set(versionTargets.map((source) => source.path))
3055
+ for (const source of versionTargets) {
3056
+ if (basename(source.path) !== 'Cargo.toml') continue
3057
+ const lockPath = join(dirname(source.path), 'Cargo.lock')
3058
+ if (written.has(lockPath) || !existsSync(lockPath)) continue
3059
+ let recorded = null
3060
+ try {
3061
+ recorded = readVersionFrom({ path: lockPath })
3062
+ } catch {
3063
+ // A lockfile with nothing beside it to scope by: not this check's question.
3064
+ }
3065
+ if (recorded && recorded !== version) {
3066
+ fail(
3067
+ `${lockPath} records ${readNameFrom(source) ?? 'the crate'} ${recorded}, and nothing ` +
3068
+ `will bump it — \`cargo publish\` would refuse the stale lockfile after the push.\n` +
3069
+ ` Add "${lockPath}" to versionFiles in release.config.json.`,
3070
+ )
3071
+ }
3072
+ }
3073
+ }
3074
+
2901
3075
  if (bumping && currentVersion && compareVersions(version, currentVersion) <= 0) {
2902
3076
  fail(`${version} is not greater than the current version ${currentVersion}`)
2903
3077
  } else if (bumping && currentVersion) {
@@ -3008,6 +3182,123 @@ if (!succeeds('git', ['remote', 'get-url', config.remote])) {
3008
3182
  }
3009
3183
  }
3010
3184
 
3185
+ /**
3186
+ * Check run conclusions that mean the commit is not fit to release. `stale` is GitHub giving
3187
+ * up on a run that never finished, which is not a pass either.
3188
+ */
3189
+ const RED_CONCLUSIONS = new Set(['failure', 'cancelled', 'timed_out', 'action_required', 'stale'])
3190
+
3191
+ /**
3192
+ * Whether GitHub reports the commit being released as green — the gate `requireGreen` opts
3193
+ * into, so a release cannot be cut from a commit CI failed on or has not finished with.
3194
+ *
3195
+ * Both of GitHub's check systems are read. Check runs are what Actions and GitHub Apps
3196
+ * report; commit statuses are the older API that external CI (Buildkite, Jenkins, older
3197
+ * integrations) still posts to. Reading only one would call a commit green while the other
3198
+ * system has it red. The combined status's own `state` is not used: it says `pending` for a
3199
+ * commit with no statuses at all, so the individual statuses are what count.
3200
+ *
3201
+ * Only `success` counts as a pass. `neutral` and `skipped` are not failures, but a commit
3202
+ * whose every check was skipped has not been verified by anything, and zero checks is the
3203
+ * same answer — both are refused rather than read as green.
3204
+ *
3205
+ * When this runs inside GitHub Actions, the job running it is itself an unfinished check run
3206
+ * on the same commit — the `workflow_run` recipe in the README releases exactly the commit
3207
+ * it runs on — and waiting for it would wait forever. Check runs belonging to the current
3208
+ * workflow run are therefore skipped; jobs in the same workflow are what `needs:` orders.
3209
+ *
3210
+ * `{owner}/{repo}` is filled in by gh from the checkout, the same way `gh release create`
3211
+ * resolves the repository the release step publishes to.
3212
+ */
3213
+ function checkHeadIsGreen(sha) {
3214
+ const api = (path, jq) => tryRead('gh', ['api', '--paginate', path, '--jq', jq])
3215
+ const checkRuns = api(
3216
+ `repos/{owner}/{repo}/commits/${sha}/check-runs?per_page=100`,
3217
+ '.check_runs[] | [.name, .status, (.conclusion // ""), (.details_url // "")] | @tsv',
3218
+ )
3219
+ const statuses = api(
3220
+ `repos/{owner}/{repo}/commits/${sha}/status?per_page=100`,
3221
+ '.statuses[] | [.context, .state] | @tsv',
3222
+ )
3223
+ if (checkRuns === null || statuses === null) {
3224
+ fail(`requireGreen: could not read the checks on ${sha.slice(0, 8)} from GitHub (gh api)`)
3225
+ return
3226
+ }
3227
+ const ownRun =
3228
+ process.env.GITHUB_ACTIONS === 'true' && process.env.GITHUB_RUN_ID
3229
+ ? `/actions/runs/${process.env.GITHUB_RUN_ID}/`
3230
+ : null
3231
+ const rows = (text) =>
3232
+ text
3233
+ .split('\n')
3234
+ .filter((line) => line.trim())
3235
+ .map((line) => line.split('\t'))
3236
+
3237
+ const red = []
3238
+ const waiting = []
3239
+ let passed = 0
3240
+ for (const [name, status, conclusion, url = ''] of rows(checkRuns)) {
3241
+ if (ownRun && url.includes(ownRun)) continue
3242
+ if (status !== 'completed') waiting.push(`${name} (${status})`)
3243
+ else if (RED_CONCLUSIONS.has(conclusion)) red.push(`${name} (${conclusion})`)
3244
+ else if (conclusion === 'success') passed += 1
3245
+ }
3246
+ for (const [context, state] of rows(statuses)) {
3247
+ if (state === 'failure' || state === 'error') red.push(`${context} (${state})`)
3248
+ else if (state === 'pending') waiting.push(`${context} (pending)`)
3249
+ else if (state === 'success') passed += 1
3250
+ }
3251
+
3252
+ const at = `HEAD (${sha.slice(0, 8)})`
3253
+ if (red.length) {
3254
+ fail(`requireGreen: ${at} is not green — ${red.join(', ')}`)
3255
+ } else if (waiting.length) {
3256
+ fail(
3257
+ `requireGreen: checks on ${at} have not finished — ${waiting.join(', ')}.\n` +
3258
+ ' Wait for them to complete, then re-run.',
3259
+ )
3260
+ } else if (!passed) {
3261
+ fail(
3262
+ `requireGreen: no check has passed on ${at} — nothing has verified it.\n` +
3263
+ ' Push it and wait for CI, or turn requireGreen off for a repository without CI.',
3264
+ )
3265
+ } else {
3266
+ ok(`${at} is green (${passed} check${passed === 1 ? '' : 's'} passed)`)
3267
+ }
3268
+ }
3269
+
3270
+ // Opt-in: the commit being released has to be one CI has seen and passed. That means it is
3271
+ // on the remote — a local commit has no checks to read — and that what the commit step is
3272
+ // about to add is not part of the release, since CI never saw that either.
3273
+ if (config.requireGreen) {
3274
+ const upstream = branch && !detached ? `${config.remote}/${branch}` : null
3275
+ const sha = tryRead('git', ['rev-parse', 'HEAD'])
3276
+ const onRemote =
3277
+ upstream && succeeds('git', ['rev-parse', '--verify', '--quiet', `refs/remotes/${upstream}`])
3278
+ const ahead = onRemote ? tryRead('git', ['rev-list', '--count', `${upstream}..HEAD`]) : null
3279
+ if (dirty && runs('commit')) {
3280
+ fail(
3281
+ 'requireGreen: the working tree would be committed and released without CI having ' +
3282
+ 'run on it.\n Commit and push it, wait for the checks, then release.',
3283
+ )
3284
+ }
3285
+ if (!succeeds('gh', ['--version']) || !succeeds('gh', ['auth', 'status'])) {
3286
+ fail('requireGreen reads the checks with `gh`, which is not installed or not authenticated')
3287
+ } else if (!onRemote) {
3288
+ fail(
3289
+ `requireGreen: ${upstream ?? 'this branch'} does not exist on ${config.remote}, so no ` +
3290
+ 'check has run on HEAD. Push it and wait for CI.',
3291
+ )
3292
+ } else if (ahead !== '0') {
3293
+ fail(
3294
+ `requireGreen: HEAD is ${ahead ?? 'an unknown number of'} commit(s) ahead of ` +
3295
+ `${upstream}, so CI has not seen it. Push it and wait for the checks.`,
3296
+ )
3297
+ } else if (sha) {
3298
+ checkHeadIsGreen(sha)
3299
+ }
3300
+ }
3301
+
3011
3302
  // If the previous release tag is reachable from HEAD, a shallow clone hides nothing the
3012
3303
  // release reads — notes and `auto` see the whole span. No reachable tag means the history
3013
3304
  // is provably truncated: `auto` would infer the bump from a fraction of the commits, so
@@ -3127,10 +3418,32 @@ if (!runs('release')) {
3127
3418
  */
3128
3419
  const OIDC_CLIS = new Set([...NPM_CLIS, 'uv'])
3129
3420
 
3421
+ /**
3422
+ * The oldest npm CLI that publishes over OIDC. An older one finds no token and fails the
3423
+ * publish — after the tag and the push, since nothing before that step asks it anything.
3424
+ * https://docs.npmjs.com/trusted-publishers states the minimum; pnpm and bun implement the
3425
+ * exchange themselves and document no equivalent, so only npm is checked.
3426
+ */
3427
+ const NPM_OIDC_MIN = '11.5.1'
3428
+
3130
3429
  /** Whether one publish CLI can publish at all: OIDC, a token, or a live session. */
3131
3430
  function checkCredentials({ cli, registry, command }) {
3132
3431
  if (isTrustedPublishing && OIDC_CLIS.has(cli)) {
3133
3432
  ok(`${cli}: trusted publishing (OIDC) — no token needed`)
3433
+ if (cli === 'npm') {
3434
+ const npmVersion = tryRead('npm', ['--version'])
3435
+ if (!npmVersion || !parseVersion(npmVersion)) {
3436
+ warn(
3437
+ `npm: could not read its version — trusted publishing needs npm ${NPM_OIDC_MIN} or later`,
3438
+ )
3439
+ } else if (compareVersions(npmVersion, NPM_OIDC_MIN) < 0) {
3440
+ fail(
3441
+ `npm ${npmVersion} cannot publish with trusted publishing, which needs npm ` +
3442
+ `${NPM_OIDC_MIN} or later — the publish would fail after the tag and push.\n` +
3443
+ ' Update it before releasing: npm install -g npm@latest',
3444
+ )
3445
+ }
3446
+ }
3134
3447
  // Provenance is the other half of what OIDC makes possible: a signed attestation
3135
3448
  // tying the published artefact to the workflow and commit that produced it. It is
3136
3449
  // not added to the command here — npm generates it for a trusted publish on its own,
@@ -3169,6 +3482,75 @@ function checkCredentials({ cli, registry, command }) {
3169
3482
  }
3170
3483
  }
3171
3484
 
3485
+ /**
3486
+ * Package what `cargo publish` will upload, before the tag and the push rather than after.
3487
+ *
3488
+ * `cargo publish` packages and verifies as its first act, so a file left out by `include`
3489
+ * or `exclude`, a manifest crates.io rejects, a crate that does not build from its own
3490
+ * archive, or one over the upload limit is discovered only once the release is tagged and
3491
+ * pushed. This runs the same packaging now. It builds the crate, as publishing does, so it
3492
+ * costs a compile.
3493
+ *
3494
+ * It packages the tree as it is — the version is not bumped yet. The version number is the
3495
+ * one thing that differs from what will be uploaded, and it changes neither the file list,
3496
+ * the metadata, nor the size.
3497
+ */
3498
+ function checkCratePackage(target) {
3499
+ const args = cargoPackageArgs(target.command, {
3500
+ dirty: !!dirty,
3501
+ lockfile: existsSync('Cargo.lock'),
3502
+ })
3503
+ if (!args) {
3504
+ note(`cargo: \`${target.command}\` is not a plain cargo publish — not packaging it first`)
3505
+ return
3506
+ }
3507
+ const started = Date.now()
3508
+ try {
3509
+ // A verify build prints every crate it compiles; Node's 1 MiB default would overflow.
3510
+ execFileSync('cargo', args, { stdio: 'pipe', encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 })
3511
+ } catch (err) {
3512
+ const tail = `${err.stdout ?? ''}${err.stderr ?? ''}`.trim().split('\n').slice(-12).join('\n')
3513
+ fail(`\`cargo ${args.join(' ')}\` failed — \`${target.command}\` would too:\n${indent(tail)}`)
3514
+ return
3515
+ }
3516
+ const metadata = tryRead('cargo', ['metadata', '--no-deps', '--format-version', '1'])
3517
+ let targetDir = null
3518
+ try {
3519
+ targetDir = metadata ? JSON.parse(metadata).target_directory : null
3520
+ } catch {
3521
+ targetDir = null
3522
+ }
3523
+ const packageDir = targetDir ? join(targetDir, 'package') : null
3524
+ // Only archives this run wrote: target/package keeps every earlier version too.
3525
+ const crates =
3526
+ packageDir && existsSync(packageDir)
3527
+ ? readdirSync(packageDir)
3528
+ .filter((name) => name.endsWith('.crate'))
3529
+ .map((name) => {
3530
+ const { size, mtimeMs } = statSync(join(packageDir, name))
3531
+ return { name, size, mtimeMs }
3532
+ })
3533
+ .filter(({ mtimeMs }) => mtimeMs >= started - 1000)
3534
+ : []
3535
+ if (!crates.length) {
3536
+ warn('cargo package passed, but no .crate it wrote was found to check against the size limit')
3537
+ return
3538
+ }
3539
+ const limitMb = (CRATES_IO_MAX_BYTES / 1024 / 1024).toFixed(0)
3540
+ for (const { name, size } of crates) {
3541
+ const mb = (size / 1024 / 1024).toFixed(1)
3542
+ if (size > CRATES_IO_MAX_BYTES) {
3543
+ fail(
3544
+ `${name} is ${mb} MiB, over crates.io's ${limitMb} MiB upload limit.\n` +
3545
+ ' Trim it with `include`/`exclude` in Cargo.toml (`cargo package --list` shows ' +
3546
+ 'what goes in), or ask crates.io to raise the limit for this crate.',
3547
+ )
3548
+ } else {
3549
+ ok(`cargo package: ${name} (${mb} MiB)`)
3550
+ }
3551
+ }
3552
+ }
3553
+
3172
3554
  /** Commands whose version is already on the registry, so the publish step skips them. */
3173
3555
  const alreadyPublished = new Set()
3174
3556
  if (!publishTargets.length) {
@@ -3192,6 +3574,7 @@ if (!publishTargets.length) {
3192
3574
  alreadyPublished.add(target.command)
3193
3575
  note(`${target.name}@${version} is already published — will skip \`${target.command}\``)
3194
3576
  }
3577
+ if (target.cli === 'cargo' && !alreadyPublished.has(target.command)) checkCratePackage(target)
3195
3578
  }
3196
3579
  }
3197
3580
 
@@ -3244,6 +3627,18 @@ const notesDeferred = !!(dirty && runs('commit'))
3244
3627
  */
3245
3628
  let notesPending = false
3246
3629
 
3630
+ /**
3631
+ * Entries the assistant flagged as unsure in the notes it drafted — see `uncertainEntries`.
3632
+ * Only a draft can carry them: hand-written and commit-derived notes are someone's words.
3633
+ */
3634
+ let unsure = []
3635
+
3636
+ /**
3637
+ * Whether a person answers the confirmation prompt. Under --yes, or with no terminal to ask
3638
+ * on, nobody reviews anything before it is tagged and published.
3639
+ */
3640
+ const confirming = !assumeYes && !dryRun && !!process.stdin.isTTY
3641
+
3247
3642
  /**
3248
3643
  * Notes for a version, in descending order of how much they can be trusted:
3249
3644
  * an assistant's prose when one is configured, otherwise the commits grouped by
@@ -3284,11 +3679,30 @@ function draftNotesFor(v) {
3284
3679
  )
3285
3680
  }
3286
3681
  note(`drafting notes from ${subjects.length} commit(s) with ${assistantName}...`)
3682
+ const drafted = draftReleaseNotes(v, commits, lastTag, remoteLinks(config.remote))
3683
+ unsure = uncertainEntries(drafted)
3287
3684
  return (
3288
- draftReleaseNotes(v, commits, lastTag, remoteLinks(config.remote)) ??
3685
+ drafted ??
3289
3686
  changelogFromCommits(commits, remoteLinks(config.remote), config.hiddenTypes, contributors)
3290
3687
  )
3291
3688
  }
3689
+
3690
+ /**
3691
+ * The flagged entries, listed for whoever has to resolve them. Non-interactively nobody can,
3692
+ * so preflight refuses; at the prompt the person answering is the review.
3693
+ */
3694
+ const listUnsure = () => {
3695
+ const one = unsure.length === 1
3696
+ const what = `${unsure.length} drafted entr${one ? 'y' : 'ies'} ${UNSURE}`
3697
+ return `${assistantName} marked ${what} — it could not tell what ${one ? 'it means' : 'they mean'} for users:\n${indent(unsure.join('\n'))}`
3698
+ }
3699
+
3700
+ /** What to do about flagged entries when nobody is at the prompt to review them. */
3701
+ const handWritten = config.changelog
3702
+ ? `, or write the notes into [Unreleased] in ${config.changelog} with them resolved — a hand-written section wins over a draft`
3703
+ : ''
3704
+ const UNSURE_FIX = ` Re-run without --yes in a terminal to review them at the prompt${handWritten}.`
3705
+
3292
3706
  const changelogText =
3293
3707
  config.changelog && existsSync(config.changelog) ? readFileSync(config.changelog, 'utf8') : null
3294
3708
  if (changelogText) reportChangelogOrder(changelogText)
@@ -3318,18 +3732,37 @@ if (notesSource === 'github') {
3318
3732
 
3319
3733
  // Generate, either because nothing was written or because a source was named.
3320
3734
  if (!notes) {
3735
+ // Candidates' sections are not a source for the stable release: its notes come from all
3736
+ // the commits since the last stable tag. Say so while there is still time to write the
3737
+ // wording that should survive into [Unreleased] — nothing else would.
3738
+ const candidates =
3739
+ notesSource === 'auto' && changelogText ? candidateSections(changelogText, version) : []
3740
+ if (candidates.length) {
3741
+ warn(
3742
+ `${config.changelog} has sections for ${candidates.join(', ')}, but ${version} ` +
3743
+ 'has none of its own, so its notes are generated from every commit since the last ' +
3744
+ 'stable release.\n Anything edited into those sections is not carried over. ' +
3745
+ `To release with it, write the ${version} notes into [Unreleased] and re-run.`,
3746
+ )
3747
+ }
3321
3748
  if (notesDeferred) {
3322
3749
  notesPending = true
3323
3750
  ok(`release notes will be ${assistant ? 'drafted' : 'generated'} after the commit`)
3324
3751
  } else {
3325
3752
  draftedNotes = draftNotesFor(version)
3753
+ if (unsure.length) {
3754
+ if (confirming) warn(`${listUnsure()}\n They are shown again at the prompt.`)
3755
+ else fail(`${listUnsure()}\n${UNSURE_FIX}`)
3756
+ }
3326
3757
  if (draftedNotes) {
3327
3758
  notes = draftedNotes
3328
- ok(
3329
- notesSource === 'commits' || !assistant
3330
- ? 'release notes built from the commit log'
3331
- : `release notes drafted by ${assistantName}`,
3332
- )
3759
+ // An "ok" under the failure above would contradict it: these notes are refused.
3760
+ if (!unsure.length || confirming)
3761
+ ok(
3762
+ notesSource === 'commits' || !assistant
3763
+ ? 'release notes built from the commit log'
3764
+ : `release notes drafted by ${assistantName}`,
3765
+ )
3333
3766
  } else if (notesSource === 'auto') {
3334
3767
  warn(
3335
3768
  `no ${config.changelog ?? 'changelog'} section and nothing to draft from — GitHub will generate the notes`,
@@ -3371,7 +3804,8 @@ if (taggedCommit && runs('tag') && wouldCommit.length) {
3371
3804
  if (config.verify) {
3372
3805
  note(`running verify: ${config.verify}`)
3373
3806
  try {
3374
- execSync(config.verify, { stdio: 'pipe', encoding: 'utf8' })
3807
+ // A test suite or a build can print far more than Node's 1 MiB default capture.
3808
+ execSync(config.verify, { stdio: 'pipe', encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 })
3375
3809
  ok(`verify passed: ${config.verify}`)
3376
3810
  } catch (err) {
3377
3811
  const tail = `${err.stdout ?? ''}${err.stderr ?? ''}`.trim().split('\n').slice(-12).join('\n')
@@ -3417,19 +3851,43 @@ if (dirty && runs('commit') && !dryRun) {
3417
3851
  console.log(indent(commitMessage))
3418
3852
  }
3419
3853
 
3420
- if (!assumeYes && !dryRun && process.stdin.isTTY) {
3421
- if (notes) console.log(`\n${bold('Release notes')}\n${indent(notes)}`)
3854
+ /** Ask a yes/no question on the terminal; anything but yes is no. */
3855
+ async function ask(question) {
3422
3856
  const rl = createInterface({ input: process.stdin, output: process.stdout })
3423
3857
  let answer = ''
3424
3858
  try {
3425
- answer = await rl.question(`\nRelease ${bold(tag)} of ${projectName}? [y/N] `)
3859
+ answer = await rl.question(question)
3426
3860
  } catch {
3427
3861
  // Ctrl+C or Ctrl+D at the prompt rejects the question. That is a decline, not a
3428
3862
  // crash — without this it exits on an unhandled AbortError and a stack trace.
3429
3863
  } finally {
3430
3864
  rl.close()
3431
3865
  }
3432
- if (!/^y(es)?$/i.test(answer.trim())) {
3866
+ return /^y(es)?$/i.test(answer.trim())
3867
+ }
3868
+
3869
+ /**
3870
+ * Show the notes, with the entries the assistant was unsure of called out, and ask. A yes is
3871
+ * the review those entries were waiting for, so the marker comes off before anything is
3872
+ * written: `[???]` in a tag or on a release page helps nobody.
3873
+ */
3874
+ async function confirmRelease(question) {
3875
+ if (notes) console.log(`\n${bold('Release notes')}\n${indent(notes)}`)
3876
+ if (unsure.length) {
3877
+ console.log(`\n${yellow(bold('Review these'))} — ${listUnsure()}`)
3878
+ console.log(dim(' Answering y releases them as shown, without the marker.'))
3879
+ }
3880
+ const yes = await ask(question)
3881
+ if (yes && unsure.length) {
3882
+ notes = withoutUnsureMarkers(notes)
3883
+ if (draftedNotes) draftedNotes = withoutUnsureMarkers(draftedNotes)
3884
+ unsure = []
3885
+ }
3886
+ return yes
3887
+ }
3888
+
3889
+ if (confirming) {
3890
+ if (!(await confirmRelease(`\nRelease ${bold(tag)} of ${projectName}? [y/N] `))) {
3433
3891
  // Leave the index exactly as it was found.
3434
3892
  if (didStage) mutate('git', ['reset', '--quiet'])
3435
3893
  abort('cancelled')
@@ -3451,6 +3909,18 @@ if (dirty && runs('commit')) {
3451
3909
  if (notesPending && !dryRun) {
3452
3910
  draftedNotes = draftNotesFor(version)
3453
3911
  if (draftedNotes) notes = draftedNotes
3912
+ // These notes were drafted after the prompt, so nobody has seen them. Flagged entries
3913
+ // get the review preflight would have given them; the commit just made stays, and a
3914
+ // re-run drafts from a clean tree — before the prompt, where they can be reviewed.
3915
+ if (unsure.length) {
3916
+ const committed =
3917
+ ' The working tree is committed; nothing else has changed. Re-running drafts the ' +
3918
+ 'notes during preflight, before anything else happens.'
3919
+ if (!confirming) abort(`${listUnsure()}\n${UNSURE_FIX}\n${committed}`)
3920
+ if (!(await confirmRelease(`\nRelease ${bold(tag)} with these notes? [y/N] `))) {
3921
+ abort(`cancelled\n${committed}`)
3922
+ }
3923
+ }
3454
3924
  }
3455
3925
  }
3456
3926