@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.
- package/README.md +189 -15
- package/package.json +1 -1
- 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.
|
|
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.
|
|
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,
|
|
292
|
-
|
|
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/
|
|
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.
|
|
813
|
+
run: npx @entro314labs/release-kit@2.9.4 minor --yes
|
|
723
814
|
env:
|
|
724
|
-
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
|
|
737
|
-
|
|
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`.
|
|
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
|
|
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@
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
...
|
|
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,
|
|
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
|
-
|
|
1430
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3330
|
-
|
|
3331
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3421
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|