@entro314labs/release-kit 1.0.3 → 2.1.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 +146 -31
- package/package.json +2 -2
- package/release.mjs +636 -75
package/README.md
CHANGED
|
@@ -54,7 +54,9 @@ Released v2.5.0
|
|
|
54
54
|
| ----------------------------------------------------------------------- | --------------------------------------- |
|
|
55
55
|
| [📦 Install](#-install) | package, `npx`, or vendored file |
|
|
56
56
|
| [⚡ Usage](#-usage) | targets, bumps, flags |
|
|
57
|
-
| [
|
|
57
|
+
| [🧩 Steps](#-steps) | the seven steps and how to select them |
|
|
58
|
+
| [🤖 Assistant](#-assistant-optional) | optional AI drafting |
|
|
59
|
+
| [🌍 Any language](#-any-language) | Rust, Python, tag-only, anything |
|
|
58
60
|
| [✅ Preflight](#-preflight) | what is checked before anything mutates |
|
|
59
61
|
| [♻️ Recovering from a failed run](#️-recovering-from-a-failed-run) | why re-running is safe |
|
|
60
62
|
| [⚙️ Configuration](#️-configuration) | `release.config.json`, publishing, auth |
|
|
@@ -136,35 +138,50 @@ Prerelease bumps need `--preid` unless the current version already carries one t
|
|
|
136
138
|
|
|
137
139
|
### Flags
|
|
138
140
|
|
|
139
|
-
| Flag
|
|
140
|
-
|
|
|
141
|
-
| `--dry-run`
|
|
142
|
-
| `--yes`, `-y`
|
|
143
|
-
| `--preid <id>`
|
|
144
|
-
| `--tag <
|
|
145
|
-
| `--
|
|
146
|
-
| `--skip
|
|
147
|
-
| `--sync <dir>...`
|
|
148
|
-
| `--help`, `-h`
|
|
149
|
-
|
|
150
|
-
##
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
141
|
+
| Flag | Effect |
|
|
142
|
+
| ------------------- | -------------------------------------------------------------------------- |
|
|
143
|
+
| `--dry-run` | Print every step, execute nothing. Preflight still runs and still reports. |
|
|
144
|
+
| `--yes`, `-y` | Skip the confirmation prompt. |
|
|
145
|
+
| `--preid <id>` | Prerelease identifier: `alpha`, `beta`, `rc`, `next`, `nightly`, `canary`. |
|
|
146
|
+
| `--dist-tag <name>` | Override the npm dist-tag. Always wins over the derived one. |
|
|
147
|
+
| `--only <steps>` | Run only these steps, comma-separated. |
|
|
148
|
+
| `--skip <steps>` | Run every step except these. |
|
|
149
|
+
| `--sync <dir>...` | Copy this script into other projects and exit. Touches no git state. |
|
|
150
|
+
| `--help`, `-h` | Full flag list. |
|
|
151
|
+
|
|
152
|
+
## 🧩 Steps
|
|
153
|
+
|
|
154
|
+
A release is seven named steps. They always run in this order — `steps` selects which of
|
|
155
|
+
them execute, it never reorders them.
|
|
156
|
+
|
|
157
|
+
| Step | Default | What it does |
|
|
158
|
+
| ----------- | ------- | ----------------------------------------------------------------------------------------------- |
|
|
159
|
+
| `commit` | off | Commit a dirty working tree with a drafted message ([assistant](#-assistant-optional) required) |
|
|
160
|
+
| `version` | on | Write the version into `package.json` and `versionFiles` |
|
|
161
|
+
| `changelog` | on | Roll `[Unreleased]` into the version, or add drafted notes |
|
|
162
|
+
| `tag` | on | Annotated git tag carrying the release notes |
|
|
163
|
+
| `push` | on | Push the branch and tag together (`--follow-tags`) |
|
|
164
|
+
| `publish` | on | Run the configured `publish` command |
|
|
165
|
+
| `release` | on | Create the GitHub release |
|
|
166
|
+
|
|
167
|
+
`version` and `changelog` write files; those writes are persisted by a release commit made
|
|
168
|
+
automatically when either step runs.
|
|
169
|
+
|
|
170
|
+
```sh
|
|
171
|
+
release-kit minor --skip publish # everything but publish
|
|
172
|
+
release-kit --only tag,push,release # a version already committed elsewhere
|
|
173
|
+
release-kit minor --commit # add the opt-in commit step
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Or fix it per project, and just run `release-kit minor`:
|
|
177
|
+
|
|
178
|
+
```json
|
|
179
|
+
{ "steps": ["commit", "version", "changelog", "tag", "push", "release"] }
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
`steps` decides **what** runs. Every other key describes **how** a step behaves — `publish`
|
|
183
|
+
is the command, `changelog` is the file. A step whose configuration is `null` runs as a
|
|
184
|
+
no-op and says so, rather than silently meaning "skip".
|
|
168
185
|
|
|
169
186
|
### Release notes
|
|
170
187
|
|
|
@@ -194,7 +211,7 @@ version, never guessed:
|
|
|
194
211
|
|
|
195
212
|
The refusal is deliberate: an unrecognised prerelease identifier has no safe channel, and
|
|
196
213
|
falling through to `latest` would put a prerelease on the stable line where every
|
|
197
|
-
`npm install` picks it up. Pass `--tag <
|
|
214
|
+
`npm install` picks it up. Pass `--dist-tag <name>` to choose a channel explicitly.
|
|
198
215
|
|
|
199
216
|
## ✅ Preflight
|
|
200
217
|
|
|
@@ -233,6 +250,54 @@ it stopped. There is no cleanup step, no `--resume`, and nothing to remember.
|
|
|
233
250
|
The one case that is not recoverable by re-running is a tag that exists at a _different_
|
|
234
251
|
commit than `HEAD`. That is a genuine conflict, and it aborts rather than guessing.
|
|
235
252
|
|
|
253
|
+
## 🌍 Any language
|
|
254
|
+
|
|
255
|
+
Only one step is Node-specific: `publish`. Committing, changelog rolling, tagging, pushing
|
|
256
|
+
and GitHub releases are the same everywhere, so `versionFile` points at wherever a project
|
|
257
|
+
keeps its version and the rest works unchanged.
|
|
258
|
+
|
|
259
|
+
| Project | Config |
|
|
260
|
+
| ------------------------------ | ------------------------------------------------------------------------------- |
|
|
261
|
+
| Node (npm) | nothing — `package.json` and `npm publish` are the defaults |
|
|
262
|
+
| Node (pnpm / bun) | `{"publish": "pnpm publish --tag %d"}` or `{"publish": "bun publish --tag %d"}` |
|
|
263
|
+
| Rust | `{"versionFile": "Cargo.toml", "publish": "cargo publish"}` |
|
|
264
|
+
| Python | `{"versionFile": "pyproject.toml", "publish": "uv publish"}` |
|
|
265
|
+
| Go | `{"versionFile": null, "publish": "go list -m %n@%t"}` — the tag is the release |
|
|
266
|
+
| Anything with a `VERSION` file | `{"versionFile": "VERSION", "publish": null}` |
|
|
267
|
+
| Versioned only by tag | `{"versionFile": null}`, then `release-kit 1.2.3` |
|
|
268
|
+
|
|
269
|
+
The publish step also gets a preflight when the command is one it recognises:
|
|
270
|
+
|
|
271
|
+
| Publish command | Authentication | Already published? |
|
|
272
|
+
| --------------- | ------------------------------------- | ----------------------------------- |
|
|
273
|
+
| `npm` / `pnpm` | `whoami` | `view <name>@<version>` |
|
|
274
|
+
| `bun` | `bun pm whoami` | `bun pm view <name>@<version>` |
|
|
275
|
+
| `uv` | `UV_PUBLISH_TOKEN` in the environment | none — `uv` skips duplicates itself |
|
|
276
|
+
| `go` | none needed | `go list -m <module>@<tag>` |
|
|
277
|
+
|
|
278
|
+
Anything else runs as written with no preflight. The project name comes from the manifest —
|
|
279
|
+
`name` in `package.json`, `Cargo.toml` or `pyproject.toml`, `module` in `go.mod` — falling
|
|
280
|
+
back to the repository directory.
|
|
281
|
+
|
|
282
|
+
The format is inferred from the file name: `.json` reads the `"version"` field, `.toml`
|
|
283
|
+
reads the first `version = "x.y.z"` line, and any other file is treated as containing just
|
|
284
|
+
the version. Only the version itself is rewritten, so comments and formatting survive — and
|
|
285
|
+
because the TOML match is anchored to the start of a line, a dependency's
|
|
286
|
+
`serde = { version = "1.0" }` is left alone.
|
|
287
|
+
|
|
288
|
+
For anything else, give a pattern with one capture group around the version. `versionFiles`
|
|
289
|
+
takes the same entries, so several files stay in sync across formats:
|
|
290
|
+
|
|
291
|
+
```json
|
|
292
|
+
{
|
|
293
|
+
"versionFile": { "path": "version.go", "pattern": "^const Version = \"(.+)\"" },
|
|
294
|
+
"versionFiles": [{ "path": "Chart.yaml", "pattern": "^version: (.+)$" }]
|
|
295
|
+
}
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
The project name comes from the manifest when there is one (`name` in `package.json`,
|
|
299
|
+
`Cargo.toml` or `pyproject.toml`), and falls back to the repository directory.
|
|
300
|
+
|
|
236
301
|
## ⚙️ Configuration
|
|
237
302
|
|
|
238
303
|
`release.config.json`, beside `package.json`. Every key is optional; unknown keys abort
|
|
@@ -313,6 +378,56 @@ A project releasing off a non-default branch with a different tag scheme:
|
|
|
313
378
|
}
|
|
314
379
|
```
|
|
315
380
|
|
|
381
|
+
## 🤖 Assistant (optional)
|
|
382
|
+
|
|
383
|
+
An assistant is an AI CLI already installed on your machine. When one is configured,
|
|
384
|
+
release-kit can write the Conventional Commits message for a dirty working tree and draft
|
|
385
|
+
release notes from the commit log. It is **off by default**, and every failure — not
|
|
386
|
+
installed, not authenticated, timed out, unusable answer — falls back to the behaviour you
|
|
387
|
+
already have. A release is never blocked because a text generator was unavailable.
|
|
388
|
+
|
|
389
|
+
```sh
|
|
390
|
+
pnpm release minor --commit --assistant claude --assistant-model sonnet --assistant-effort low
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
| Tool | Invocation | Model | Effort |
|
|
394
|
+
| -------- | ------------ | ------------------- | -------------------------------- |
|
|
395
|
+
| `claude` | `claude -p` | `--assistant-model` | `--assistant-effort` (low … max) |
|
|
396
|
+
| `codex` | `codex exec` | `-m` | `-c model_reasoning_effort=` |
|
|
397
|
+
|
|
398
|
+
Configure it once instead:
|
|
399
|
+
|
|
400
|
+
```json
|
|
401
|
+
{
|
|
402
|
+
"assistant": { "tool": "claude", "model": "sonnet", "effort": "low" }
|
|
403
|
+
}
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
`"assistant": "auto"` picks the first tool found on PATH; `"claude"` is shorthand for
|
|
407
|
+
`{ "tool": "claude" }`. Naming a tool that is not installed is an error rather than a silent
|
|
408
|
+
downgrade, so a configured pipeline fails loudly; `"auto"` degrades quietly by design.
|
|
409
|
+
|
|
410
|
+
### What it does
|
|
411
|
+
|
|
412
|
+
- **`--commit`** stages the working tree, drafts a Conventional Commits message for the
|
|
413
|
+
staged diff, and commits — instead of refusing to release. The subject is validated
|
|
414
|
+
against the Conventional Commits grammar; an answer that does not parse is rejected rather
|
|
415
|
+
than committed. Attribution lines (`Co-Authored-By`, `Generated with`) are stripped, so
|
|
416
|
+
the tool never signs your commits.
|
|
417
|
+
- **Release notes** are drafted from the commits since the last tag when `CHANGELOG.md` has
|
|
418
|
+
no section for the version. They are written into the changelog, used as the tag
|
|
419
|
+
annotation, and posted as the GitHub release body — the same "written once, lands in three
|
|
420
|
+
places" path a hand-written section takes.
|
|
421
|
+
|
|
422
|
+
With `--commit`, notes are drafted _after_ that commit lands, so they describe the change it
|
|
423
|
+
just made. Merge, release, `WIP` and `fixup!`/`squash!` commits are excluded from the prompt.
|
|
424
|
+
|
|
425
|
+
### Adding another tool
|
|
426
|
+
|
|
427
|
+
One row in `ASSISTANTS` in `release.mjs`: the command, the args that make it read a prompt on
|
|
428
|
+
stdin, and how it spells model and effort. Tools whose stdout carries session scaffolding
|
|
429
|
+
declare `outputFile` and the answer is read from there instead.
|
|
430
|
+
|
|
316
431
|
## 🔄 Keeping vendored copies in sync
|
|
317
432
|
|
|
318
433
|
Installed as a dependency, updates come from your package manager and there is nothing to
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@entro314labs/release-kit",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "2.1.0",
|
|
4
4
|
"description": "Single-file, zero-dependency release mechanism for JS/TS/Node projects: version bump, changelog roll, commit, annotated tag, push, publish, GitHub release",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"changelog",
|
|
@@ -49,6 +49,6 @@
|
|
|
49
49
|
"oxlint": "^1.78.0"
|
|
50
50
|
},
|
|
51
51
|
"engines": {
|
|
52
|
-
"node": ">=
|
|
52
|
+
"node": ">=22"
|
|
53
53
|
}
|
|
54
54
|
}
|
package/release.mjs
CHANGED
|
@@ -32,7 +32,8 @@
|
|
|
32
32
|
*/
|
|
33
33
|
|
|
34
34
|
import { execFileSync, execSync } from 'node:child_process'
|
|
35
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
35
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
|
|
36
|
+
import { tmpdir } from 'node:os'
|
|
36
37
|
import { basename, join, relative, resolve, sep } from 'node:path'
|
|
37
38
|
import { createInterface } from 'node:readline/promises'
|
|
38
39
|
|
|
@@ -50,22 +51,53 @@ import { createInterface } from 'node:readline/promises'
|
|
|
50
51
|
* branch string the only branch a release may run from; null to allow any
|
|
51
52
|
* remote string git remote to push to
|
|
52
53
|
* changelog string changelog path; null to disable changelog handling
|
|
53
|
-
*
|
|
54
|
+
* versionFile string|object|null where the project's version lives; null when the
|
|
55
|
+
* repository versions by git tag alone
|
|
56
|
+
* versionFiles array further files whose version is kept in sync; each is a path
|
|
57
|
+
* or { path, pattern }
|
|
54
58
|
* publish string publish command; null to skip publishing entirely
|
|
55
59
|
* commitMessage string release commit subject
|
|
56
60
|
* releaseTitle string GitHub release title
|
|
57
61
|
* assets string[] files attached to the GitHub release
|
|
62
|
+
* assistant string|object drafting CLI for commit messages and notes. A key of
|
|
63
|
+
* ASSISTANTS, "auto" for the first available, or null. The
|
|
64
|
+
* object form { tool, model, effort } also pins which model and
|
|
65
|
+
* reasoning effort that CLI is invoked with.
|
|
58
66
|
*/
|
|
67
|
+
/**
|
|
68
|
+
* The release pipeline, in the only order that is safe to run it. `steps` selects which of
|
|
69
|
+
* these execute; it never reorders them — publishing before tagging, or pushing before
|
|
70
|
+
* committing, is a mistake the tool should not let you express.
|
|
71
|
+
*
|
|
72
|
+
* commit commit a dirty working tree (opt-in; touches work that predates the release)
|
|
73
|
+
* version write the version into package.json and versionFiles
|
|
74
|
+
* changelog roll [Unreleased] into the version, or add drafted notes
|
|
75
|
+
* tag annotated git tag carrying the release notes
|
|
76
|
+
* push push the branch and the tag together
|
|
77
|
+
* publish run the configured publish command
|
|
78
|
+
* release create the GitHub release
|
|
79
|
+
*
|
|
80
|
+
* `version` and `changelog` write files; those writes are persisted by a release commit
|
|
81
|
+
* made automatically when either step runs.
|
|
82
|
+
*/
|
|
83
|
+
const STEPS = ['commit', 'version', 'changelog', 'tag', 'push', 'publish', 'release']
|
|
84
|
+
|
|
85
|
+
/** Everything but `commit`, which is opt-in because it commits work you did not stage. */
|
|
86
|
+
const DEFAULT_STEPS = STEPS.filter((name) => name !== 'commit')
|
|
87
|
+
|
|
59
88
|
const DEFAULTS = {
|
|
89
|
+
steps: DEFAULT_STEPS,
|
|
60
90
|
tagPrefix: 'v',
|
|
61
91
|
branch: 'main',
|
|
62
92
|
remote: 'origin',
|
|
63
93
|
changelog: 'CHANGELOG.md',
|
|
94
|
+
versionFile: 'package.json',
|
|
64
95
|
versionFiles: [],
|
|
65
96
|
publish: 'npm publish --tag %d',
|
|
66
97
|
commitMessage: 'chore(release): %t',
|
|
67
98
|
releaseTitle: '%t',
|
|
68
99
|
assets: [],
|
|
100
|
+
assistant: null,
|
|
69
101
|
}
|
|
70
102
|
|
|
71
103
|
/**
|
|
@@ -94,13 +126,23 @@ Target (optional; defaults to the version already in package.json):
|
|
|
94
126
|
prepatch preminor premajor prerelease
|
|
95
127
|
prerelease bump; needs --preid unless it can be inferred
|
|
96
128
|
|
|
129
|
+
Steps, in the fixed order they run. All but "commit" run by default:
|
|
130
|
+
${STEPS.join(' ')}
|
|
131
|
+
|
|
97
132
|
Flags:
|
|
133
|
+
--only <steps> run only these steps, comma-separated
|
|
134
|
+
--skip <steps> run every step except these
|
|
135
|
+
--commit add the opt-in commit step: commit a dirty working tree with a
|
|
136
|
+
drafted Conventional Commits message instead of refusing to release
|
|
98
137
|
--preid <id> prerelease identifier (alpha, beta, rc, next, nightly, canary)
|
|
99
|
-
--tag <
|
|
138
|
+
--dist-tag <name> override the npm dist-tag (default: derived from the version)
|
|
100
139
|
--dry-run print every step and execute nothing
|
|
101
140
|
--yes, -y skip the confirmation prompt
|
|
102
|
-
--
|
|
103
|
-
--
|
|
141
|
+
--assistant <name> drafting CLI to use: auto, none, claude, codex
|
|
142
|
+
--assistant-model <name>
|
|
143
|
+
model the assistant runs with (e.g. sonnet, opus)
|
|
144
|
+
--assistant-effort <level>
|
|
145
|
+
reasoning effort the assistant runs with (low … max)
|
|
104
146
|
--sync <dir>... copy this script into other projects' scripts/ and exit
|
|
105
147
|
--help, -h show this
|
|
106
148
|
|
|
@@ -192,6 +234,203 @@ function tryRead(command, args) {
|
|
|
192
234
|
*/
|
|
193
235
|
const succeeds = (command, args) => tryRead(command, args) !== null
|
|
194
236
|
|
|
237
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
238
|
+
// ASSISTANTS — optional CLIs that draft prose (commit messages, release notes)
|
|
239
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Drafting tools, tried in this order when `assistant` is "auto". Each is a CLI that
|
|
243
|
+
* reads a prompt on stdin and writes plain text to stdout. Supporting another harness is
|
|
244
|
+
* one row here; nothing else in the file names a specific tool.
|
|
245
|
+
*/
|
|
246
|
+
const ASSISTANTS = {
|
|
247
|
+
claude: {
|
|
248
|
+
command: 'claude',
|
|
249
|
+
args: ['-p'],
|
|
250
|
+
probe: ['--version'],
|
|
251
|
+
model: (m) => ['--model', m],
|
|
252
|
+
effort: (e) => ['--effort', e],
|
|
253
|
+
},
|
|
254
|
+
codex: {
|
|
255
|
+
command: 'codex',
|
|
256
|
+
args: ['exec', '--skip-git-repo-check', '--sandbox', 'read-only'],
|
|
257
|
+
probe: ['--version'],
|
|
258
|
+
model: (m) => ['-m', m],
|
|
259
|
+
effort: (e) => ['-c', `model_reasoning_effort="${e}"`],
|
|
260
|
+
// `codex exec` streams session scaffolding (MCP notices, hook logs) to stdout, so the
|
|
261
|
+
// answer is only clean when written to a file with --output-last-message.
|
|
262
|
+
outputFile: (path) => ['--output-last-message', path],
|
|
263
|
+
},
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** How long a draft may take before the release gives up and falls back. */
|
|
267
|
+
const DRAFT_TIMEOUT_MS = 180_000
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Lines a drafting tool may append that must never reach a commit message or release
|
|
271
|
+
* notes: attribution for the tool itself, and the markdown fences models wrap output in.
|
|
272
|
+
*/
|
|
273
|
+
function cleanDraft(text) {
|
|
274
|
+
return text
|
|
275
|
+
.replace(/^\s*```[a-z]*\s*\n?/i, '')
|
|
276
|
+
.replace(/\n?```\s*$/, '')
|
|
277
|
+
.split('\n')
|
|
278
|
+
.filter(
|
|
279
|
+
(line) =>
|
|
280
|
+
!/^\s*co-authored-by:/i.test(line) &&
|
|
281
|
+
!/^\s*(🤖\s*)?generated with/i.test(line) &&
|
|
282
|
+
!/^\s*signed-off-by:\s*claude/i.test(line),
|
|
283
|
+
)
|
|
284
|
+
.join('\n')
|
|
285
|
+
.trim()
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Release notes carry two artefacts a commit message does not: stray code fences around
|
|
290
|
+
* part of the output, and a trailing paragraph explaining the model's reasoning. Keep the
|
|
291
|
+
* document only up to its last heading or list item; prose after that is commentary.
|
|
292
|
+
*/
|
|
293
|
+
function cleanNotes(text) {
|
|
294
|
+
const lines = cleanDraft(text)
|
|
295
|
+
.split('\n')
|
|
296
|
+
.filter((line) => !/^\s*```/.test(line))
|
|
297
|
+
let end = lines.length
|
|
298
|
+
while (end > 0) {
|
|
299
|
+
const line = lines[end - 1]
|
|
300
|
+
if (!line.trim()) {
|
|
301
|
+
end -= 1
|
|
302
|
+
continue
|
|
303
|
+
}
|
|
304
|
+
// A heading, a bullet, or an indented continuation of a bullet: the document proper.
|
|
305
|
+
if (/^\s*[-*+] /.test(line) || /^#{1,6} /.test(line) || /^\s+\S/.test(line)) break
|
|
306
|
+
end -= 1
|
|
307
|
+
}
|
|
308
|
+
return lines.slice(0, end).join('\n').trim() || null
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Run the selected assistant against a prompt.
|
|
313
|
+
*
|
|
314
|
+
* Every failure mode — not installed, not authenticated, timed out, empty answer — returns
|
|
315
|
+
* null rather than throwing. Drafting is a convenience; a release must never be blocked
|
|
316
|
+
* because a text generator was unavailable.
|
|
317
|
+
*
|
|
318
|
+
* @returns {string | null} the cleaned draft, or null when unavailable
|
|
319
|
+
*/
|
|
320
|
+
function runAssistant(prompt) {
|
|
321
|
+
if (!assistant) return null
|
|
322
|
+
|
|
323
|
+
const args = [...assistant.args]
|
|
324
|
+
if (assistantModel && assistant.model) args.push(...assistant.model(assistantModel))
|
|
325
|
+
if (assistantEffort && assistant.effort) args.push(...assistant.effort(assistantEffort))
|
|
326
|
+
|
|
327
|
+
// Tools whose stdout carries session scaffolding hand back the answer through a file.
|
|
328
|
+
const answerFile = assistant.outputFile
|
|
329
|
+
? join(mkdtempSync(join(tmpdir(), 'release-kit-')), 'answer.md')
|
|
330
|
+
: null
|
|
331
|
+
if (answerFile) args.push(...assistant.outputFile(answerFile))
|
|
332
|
+
|
|
333
|
+
try {
|
|
334
|
+
const stdout = execFileSync(assistant.command, args, {
|
|
335
|
+
input: prompt,
|
|
336
|
+
encoding: 'utf8',
|
|
337
|
+
timeout: DRAFT_TIMEOUT_MS,
|
|
338
|
+
stdio: ['pipe', 'pipe', 'ignore'],
|
|
339
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
340
|
+
})
|
|
341
|
+
const answer = answerFile
|
|
342
|
+
? existsSync(answerFile)
|
|
343
|
+
? readFileSync(answerFile, 'utf8')
|
|
344
|
+
: ''
|
|
345
|
+
: stdout
|
|
346
|
+
return cleanDraft(answer) || null
|
|
347
|
+
} catch {
|
|
348
|
+
return null
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** Commit subjects since the last tag, with release and merge commits filtered out. */
|
|
353
|
+
function commitsSinceLastTag() {
|
|
354
|
+
const lastTag = tryRead('git', ['describe', '--tags', '--abbrev=0'])
|
|
355
|
+
const range = lastTag ? `${lastTag}..HEAD` : 'HEAD'
|
|
356
|
+
const log = tryRead('git', ['log', '--format=%s', range]) ?? ''
|
|
357
|
+
return {
|
|
358
|
+
lastTag,
|
|
359
|
+
subjects: log
|
|
360
|
+
.split('\n')
|
|
361
|
+
.filter(
|
|
362
|
+
(s) =>
|
|
363
|
+
s && !/^chore\(release\)/i.test(s) && !/^Merge (branch|pull request|remote)/i.test(s),
|
|
364
|
+
),
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const CONVENTIONAL_TYPES = 'build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test'
|
|
369
|
+
const CONVENTIONAL_RE = new RegExp(`^(${CONVENTIONAL_TYPES})(\\([^)]+\\))?!?: .+`)
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Draft a Conventional Commits message for the staged changes.
|
|
373
|
+
*
|
|
374
|
+
* @returns {string | null} a message whose subject is valid Conventional Commits, or null
|
|
375
|
+
*/
|
|
376
|
+
function draftCommitMessage() {
|
|
377
|
+
const stat = tryRead('git', ['diff', '--cached', '--stat'])
|
|
378
|
+
// Cap the diff: a large one wastes the context window and rarely improves the subject.
|
|
379
|
+
const diff = tryRead('git', ['diff', '--cached', '--unified=1'])?.slice(0, 12_000)
|
|
380
|
+
if (!stat) return null
|
|
381
|
+
|
|
382
|
+
const prompt = [
|
|
383
|
+
'Write a Conventional Commits message for these staged changes.',
|
|
384
|
+
'',
|
|
385
|
+
'Rules:',
|
|
386
|
+
`- Subject: "<type>(<optional scope>): <description>" where type is one of ${CONVENTIONAL_TYPES.split('|').join(', ')}.`,
|
|
387
|
+
'- Subject in the imperative mood, no trailing period, under 72 characters.',
|
|
388
|
+
'- Add a body only if the change needs explanation; separate it with a blank line.',
|
|
389
|
+
'- Output the raw commit message and nothing else: no markdown fences, no preamble.',
|
|
390
|
+
'- Do NOT add Co-Authored-By, Signed-off-by, or any attribution or tool credit.',
|
|
391
|
+
'',
|
|
392
|
+
'Files changed:',
|
|
393
|
+
stat,
|
|
394
|
+
'',
|
|
395
|
+
'Diff:',
|
|
396
|
+
diff ?? '(unavailable)',
|
|
397
|
+
].join('\n')
|
|
398
|
+
|
|
399
|
+
const message = runAssistant(prompt)
|
|
400
|
+
if (!message) return null
|
|
401
|
+
const [subject] = message.split('\n')
|
|
402
|
+
return CONVENTIONAL_RE.test(subject) ? message : null
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Draft release notes from the commits since the last tag, in the changelog's own style.
|
|
407
|
+
*
|
|
408
|
+
* @returns {string | null} markdown body (no version heading), or null
|
|
409
|
+
*/
|
|
410
|
+
function draftReleaseNotes(version, subjects, lastTag) {
|
|
411
|
+
if (!subjects.length) return null
|
|
412
|
+
|
|
413
|
+
const prompt = [
|
|
414
|
+
`Write release notes for version ${version}.`,
|
|
415
|
+
'',
|
|
416
|
+
'Rules:',
|
|
417
|
+
'- Group the changes under Keep a Changelog headings (`### Added`, `### Changed`,',
|
|
418
|
+
' `### Fixed`, `### Removed`), including only the headings that apply.',
|
|
419
|
+
'- One bullet per user-visible change. Merge related commits into a single bullet.',
|
|
420
|
+
'- Omit internal chores: CI, linting, formatting, dependency bumps, version bumps.',
|
|
421
|
+
'- Write for someone upgrading: say what changed for them, not which files moved.',
|
|
422
|
+
'- Plain, factual language. No hype, no emoji, no concluding summary.',
|
|
423
|
+
'- Output only the markdown body: no version heading, no code fences, no attribution.',
|
|
424
|
+
'- Do NOT explain your reasoning or add any commentary before or after the notes.',
|
|
425
|
+
'',
|
|
426
|
+
`Commit subjects since ${lastTag ?? 'the start of the project'}:`,
|
|
427
|
+
...subjects.map((s) => `- ${s}`),
|
|
428
|
+
].join('\n')
|
|
429
|
+
|
|
430
|
+
const drafted = runAssistant(prompt)
|
|
431
|
+
return drafted ? cleanNotes(drafted) : null
|
|
432
|
+
}
|
|
433
|
+
|
|
195
434
|
/** One-line rendering of an argv, so a multi-line arg (release notes) stays readable. */
|
|
196
435
|
const formatCommand = (command, args) =>
|
|
197
436
|
[
|
|
@@ -392,6 +631,17 @@ function rollUnreleased(text, version, date) {
|
|
|
392
631
|
return text.slice(0, match.index) + released + text.slice(match.index + match[0].length)
|
|
393
632
|
}
|
|
394
633
|
|
|
634
|
+
/**
|
|
635
|
+
* Insert a section for a version above the newest existing one, so drafted notes are kept
|
|
636
|
+
* in the changelog rather than only reaching the tag and the GitHub release.
|
|
637
|
+
*/
|
|
638
|
+
function insertChangelogSection(text, version, date, body) {
|
|
639
|
+
const entry = `## [${version}] - ${date}\n\n${body}\n`
|
|
640
|
+
const firstSection = /^## /m.exec(text)
|
|
641
|
+
if (!firstSection) return `${text.trimEnd()}\n\n${entry}`
|
|
642
|
+
return `${text.slice(0, firstSection.index)}${entry}\n${text.slice(firstSection.index)}`
|
|
643
|
+
}
|
|
644
|
+
|
|
395
645
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
396
646
|
// JSON FILES
|
|
397
647
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -399,18 +649,84 @@ function rollUnreleased(text, version, date) {
|
|
|
399
649
|
const readJson = (path) => JSON.parse(readFileSync(path, 'utf8'))
|
|
400
650
|
|
|
401
651
|
/**
|
|
402
|
-
*
|
|
403
|
-
*
|
|
652
|
+
* Where a project keeps its version. The format is inferred from the file name, so the
|
|
653
|
+
* common cases need nothing but a path:
|
|
654
|
+
*
|
|
655
|
+
* *.json the JSON `"version": "x.y.z"` field
|
|
656
|
+
* *.toml the first `version = "x.y.z"` line — the `[package]` / `[project]`
|
|
657
|
+
* table comes first in Cargo.toml and pyproject.toml
|
|
658
|
+
* anything else the whole file is the version (a plain VERSION file)
|
|
659
|
+
*
|
|
660
|
+
* An explicit `pattern` overrides inference for formats not listed. Whatever the source,
|
|
661
|
+
* it must capture the version in exactly one group, which is what gets replaced on write.
|
|
662
|
+
*/
|
|
663
|
+
const VERSION_PATTERNS = {
|
|
664
|
+
json: /^\s*"version"\s*:\s*"([^"]*)"/m,
|
|
665
|
+
toml: /^version\s*=\s*"([^"]*)"/m,
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/** The project name in the same files, used for display and the registry lookup. */
|
|
669
|
+
const NAME_PATTERNS = {
|
|
670
|
+
json: /^\s*"name"\s*:\s*"([^"]*)"/m,
|
|
671
|
+
toml: /^name\s*=\s*"([^"]*)"/m,
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/** @returns {string | null} the project name recorded beside the version */
|
|
675
|
+
function readNameFrom(entry) {
|
|
676
|
+
const source = versionSource(entry)
|
|
677
|
+
const kind = source.path.endsWith('.json')
|
|
678
|
+
? 'json'
|
|
679
|
+
: source.path.endsWith('.toml')
|
|
680
|
+
? 'toml'
|
|
681
|
+
: null
|
|
682
|
+
if (!kind || !existsSync(source.path)) return null
|
|
683
|
+
return NAME_PATTERNS[kind].exec(readFileSync(source.path, 'utf8'))?.[1] ?? null
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/** Normalise a versionFile / versionFiles entry to { path, pattern }. */
|
|
687
|
+
const versionSource = (entry) => (typeof entry === 'string' ? { path: entry } : entry)
|
|
688
|
+
|
|
689
|
+
/** The regex for a source, or null when the whole file is the version. */
|
|
690
|
+
function patternFor({ path, pattern }) {
|
|
691
|
+
if (pattern) return new RegExp(pattern, 'm')
|
|
692
|
+
if (path.endsWith('.json')) return VERSION_PATTERNS.json
|
|
693
|
+
if (path.endsWith('.toml')) return VERSION_PATTERNS.toml
|
|
694
|
+
return null
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/** @returns {string | null} the version recorded in a source file */
|
|
698
|
+
function readVersionFrom(entry) {
|
|
699
|
+
const source = versionSource(entry)
|
|
700
|
+
const text = readFileSync(source.path, 'utf8')
|
|
701
|
+
const pattern = patternFor(source)
|
|
702
|
+
if (!pattern) return text.trim() || null
|
|
703
|
+
const match = pattern.exec(text)
|
|
704
|
+
return match ? match[1] : null
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/**
|
|
708
|
+
* Replace the version in a source file, touching nothing else: only the captured range is
|
|
709
|
+
* rewritten, so formatting, key order and comments all survive.
|
|
404
710
|
*
|
|
405
711
|
* @returns {boolean} whether the file needed changing
|
|
406
712
|
*/
|
|
407
|
-
function writeVersionInto(
|
|
408
|
-
const
|
|
409
|
-
const
|
|
410
|
-
|
|
411
|
-
|
|
713
|
+
function writeVersionInto(entry, version) {
|
|
714
|
+
const source = versionSource(entry)
|
|
715
|
+
const text = readFileSync(source.path, 'utf8')
|
|
716
|
+
const pattern = patternFor(source)
|
|
717
|
+
|
|
718
|
+
let updated
|
|
719
|
+
if (pattern) {
|
|
720
|
+
const match = pattern.exec(text)
|
|
721
|
+
if (!match) throw new Error(`${source.path} has no version matching ${pattern}`)
|
|
722
|
+
const start = match.index + match[0].indexOf(match[1])
|
|
723
|
+
updated = text.slice(0, start) + version + text.slice(start + match[1].length)
|
|
724
|
+
} else {
|
|
725
|
+
updated = `${version}\n`
|
|
726
|
+
}
|
|
727
|
+
|
|
412
728
|
if (updated === text) return false
|
|
413
|
-
if (!dryRun) writeFileSync(path, updated)
|
|
729
|
+
if (!dryRun) writeFileSync(source.path, updated)
|
|
414
730
|
return true
|
|
415
731
|
}
|
|
416
732
|
|
|
@@ -429,10 +745,14 @@ const option = (name) => {
|
|
|
429
745
|
|
|
430
746
|
const dryRun = flag('--dry-run')
|
|
431
747
|
const assumeYes = flag('--yes') || flag('-y')
|
|
432
|
-
const
|
|
433
|
-
const
|
|
434
|
-
const explicitDistTag = option('--tag')
|
|
748
|
+
const onlySteps = option('--only')
|
|
749
|
+
const skippedSteps = option('--skip')
|
|
750
|
+
const explicitDistTag = option('--dist-tag')
|
|
435
751
|
const requestedPreid = option('--preid')
|
|
752
|
+
const autoCommit = flag('--commit')
|
|
753
|
+
const requestedAssistant = option('--assistant')
|
|
754
|
+
const requestedModel = option('--assistant-model')
|
|
755
|
+
const requestedEffort = option('--assistant-effort')
|
|
436
756
|
|
|
437
757
|
if (flag('--help') || flag('-h')) {
|
|
438
758
|
console.log(USAGE)
|
|
@@ -472,7 +792,31 @@ if (flag('--sync')) {
|
|
|
472
792
|
process.exit(0)
|
|
473
793
|
}
|
|
474
794
|
|
|
475
|
-
|
|
795
|
+
/**
|
|
796
|
+
* Options that consume the argument after them. Without this list a positional target is
|
|
797
|
+
* found by guessing, and `--only tag,push` gets read as the version to release.
|
|
798
|
+
*/
|
|
799
|
+
const VALUE_OPTIONS = new Set([
|
|
800
|
+
'--only',
|
|
801
|
+
'--skip',
|
|
802
|
+
'--preid',
|
|
803
|
+
'--dist-tag',
|
|
804
|
+
'--assistant',
|
|
805
|
+
'--assistant-model',
|
|
806
|
+
'--assistant-effort',
|
|
807
|
+
])
|
|
808
|
+
|
|
809
|
+
/** The version or bump target: the first argument that is neither a flag nor a flag's value. */
|
|
810
|
+
const target = (() => {
|
|
811
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
812
|
+
if (VALUE_OPTIONS.has(argv[i])) {
|
|
813
|
+
i += 1
|
|
814
|
+
continue
|
|
815
|
+
}
|
|
816
|
+
if (!argv[i].startsWith('-')) return argv[i]
|
|
817
|
+
}
|
|
818
|
+
return undefined
|
|
819
|
+
})()
|
|
476
820
|
|
|
477
821
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
478
822
|
// SETUP
|
|
@@ -497,11 +841,6 @@ if (existsSync(localManifest) && localManifest !== rootManifest) {
|
|
|
497
841
|
}
|
|
498
842
|
process.chdir(root)
|
|
499
843
|
|
|
500
|
-
if (!existsSync('package.json')) abort(`no package.json at ${root}`)
|
|
501
|
-
const pkg = readJson('package.json')
|
|
502
|
-
if (!pkg.version) abort('package.json has no "version" field')
|
|
503
|
-
if (!parseVersion(pkg.version)) abort(`package.json version "${pkg.version}" is not semver`)
|
|
504
|
-
|
|
505
844
|
const config = {
|
|
506
845
|
...DEFAULTS,
|
|
507
846
|
...(existsSync('release.config.json') ? readJson('release.config.json') : {}),
|
|
@@ -509,25 +848,125 @@ const config = {
|
|
|
509
848
|
const unknownKeys = Object.keys(config).filter((key) => !(key in DEFAULTS))
|
|
510
849
|
if (unknownKeys.length) abort(`release.config.json has unknown keys: ${unknownKeys.join(', ')}`)
|
|
511
850
|
|
|
851
|
+
/**
|
|
852
|
+
* Which steps run: config provides the baseline, --only replaces it, --skip subtracts, and
|
|
853
|
+
* --commit adds the opt-in step. Unknown names are an error rather than a silent no-op.
|
|
854
|
+
*/
|
|
855
|
+
const parseStepList = (value) =>
|
|
856
|
+
value
|
|
857
|
+
.split(',')
|
|
858
|
+
.map((name) => name.trim())
|
|
859
|
+
.filter(Boolean)
|
|
860
|
+
|
|
861
|
+
/**
|
|
862
|
+
* The project's current version and name. Both normally come from package.json, but the
|
|
863
|
+
* only Node-specific thing about a release is publishing: `versionFile` points at whatever
|
|
864
|
+
* file this project keeps its version in, and `null` means the repository versions by git
|
|
865
|
+
* tag alone and the version has to be passed explicitly.
|
|
866
|
+
*/
|
|
867
|
+
const manifest = existsSync('package.json') ? readJson('package.json') : null
|
|
868
|
+
const versionFile = config.versionFile ? versionSource(config.versionFile) : null
|
|
869
|
+
|
|
870
|
+
if (versionFile && !existsSync(versionFile.path)) {
|
|
871
|
+
abort(`versionFile ${versionFile.path} does not exist`)
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
const currentVersion = versionFile ? readVersionFrom(versionFile) : null
|
|
875
|
+
if (versionFile && !currentVersion) {
|
|
876
|
+
abort(`could not read a version from ${versionFile.path}`)
|
|
877
|
+
}
|
|
878
|
+
if (currentVersion && !parseVersion(currentVersion)) {
|
|
879
|
+
abort(`${versionFile.path} version "${currentVersion}" is not semver`)
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
/** Used for display, the registry lookup, and the %n token. */
|
|
883
|
+
const goModule = existsSync('go.mod')
|
|
884
|
+
? (/^module\s+(\S+)/m.exec(readFileSync('go.mod', 'utf8'))?.[1] ?? null)
|
|
885
|
+
: null
|
|
886
|
+
const projectName =
|
|
887
|
+
manifest?.name ?? (versionFile ? readNameFrom(versionFile) : null) ?? goModule ?? basename(root)
|
|
888
|
+
|
|
889
|
+
// Validate every name that was asked for, not just the ones that survive: a typo in
|
|
890
|
+
// --skip would otherwise delete nothing and silently run the step you meant to drop.
|
|
891
|
+
const requestedStepNames = [
|
|
892
|
+
...(onlySteps ? parseStepList(onlySteps) : []),
|
|
893
|
+
...(skippedSteps ? parseStepList(skippedSteps) : []),
|
|
894
|
+
...(Array.isArray(config.steps) ? config.steps : []),
|
|
895
|
+
]
|
|
896
|
+
const unknownSteps = [...new Set(requestedStepNames)].filter((name) => !STEPS.includes(name))
|
|
897
|
+
if (unknownSteps.length) {
|
|
898
|
+
abort(`unknown step(s): ${unknownSteps.join(', ')}\n Known steps: ${STEPS.join(', ')}`)
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
const steps = new Set(onlySteps ? parseStepList(onlySteps) : (config.steps ?? DEFAULT_STEPS))
|
|
902
|
+
if (skippedSteps) for (const name of parseStepList(skippedSteps)) steps.delete(name)
|
|
903
|
+
if (autoCommit) steps.add('commit')
|
|
904
|
+
const runs = (name) => steps.has(name)
|
|
905
|
+
|
|
906
|
+
/**
|
|
907
|
+
* The drafting tool, resolved from --assistant then config. "auto" picks the first one
|
|
908
|
+
* present on PATH; a named tool must be known and installed, otherwise it is an error
|
|
909
|
+
* rather than a silent downgrade to no drafting.
|
|
910
|
+
*/
|
|
911
|
+
const assistantConfig =
|
|
912
|
+
config.assistant && typeof config.assistant === 'object' ? config.assistant : {}
|
|
913
|
+
const assistantChoice =
|
|
914
|
+
requestedAssistant ??
|
|
915
|
+
(typeof config.assistant === 'string' ? config.assistant : assistantConfig.tool) ??
|
|
916
|
+
'none'
|
|
917
|
+
const assistantModel = requestedModel ?? assistantConfig.model ?? null
|
|
918
|
+
const assistantEffort = requestedEffort ?? assistantConfig.effort ?? null
|
|
919
|
+
let assistantName = null
|
|
920
|
+
if (assistantChoice !== 'none' && assistantChoice !== null) {
|
|
921
|
+
const candidates =
|
|
922
|
+
assistantChoice === 'auto'
|
|
923
|
+
? Object.keys(ASSISTANTS)
|
|
924
|
+
: [assistantChoice].filter((name) => {
|
|
925
|
+
if (name in ASSISTANTS) return true
|
|
926
|
+
abort(
|
|
927
|
+
`unknown assistant "${name}" (known: ${Object.keys(ASSISTANTS).join(', ')}, auto, none)`,
|
|
928
|
+
)
|
|
929
|
+
return false
|
|
930
|
+
})
|
|
931
|
+
assistantName =
|
|
932
|
+
candidates.find((name) => succeeds(ASSISTANTS[name].command, ASSISTANTS[name].probe)) ?? null
|
|
933
|
+
if (!assistantName && assistantChoice !== 'auto') {
|
|
934
|
+
abort(
|
|
935
|
+
`assistant "${assistantChoice}" is configured but \`${ASSISTANTS[assistantChoice].command}\` is not on PATH`,
|
|
936
|
+
)
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
const assistant = assistantName ? ASSISTANTS[assistantName] : null
|
|
940
|
+
|
|
512
941
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
513
942
|
// RESOLVE THE TARGET VERSION
|
|
514
943
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
515
944
|
|
|
516
945
|
console.log(
|
|
517
|
-
bold(`${
|
|
946
|
+
bold(`${projectName} release`) +
|
|
947
|
+
(dryRun ? ` ${yellow('(dry run — nothing will execute)')}` : ''),
|
|
518
948
|
)
|
|
519
949
|
|
|
520
950
|
let version
|
|
521
951
|
if (!target) {
|
|
522
|
-
|
|
952
|
+
if (!currentVersion) {
|
|
953
|
+
abort(
|
|
954
|
+
'this repository has no versionFile, so there is no version to default to.\n' +
|
|
955
|
+
' Pass one explicitly: release-kit 1.2.3',
|
|
956
|
+
)
|
|
957
|
+
}
|
|
958
|
+
version = currentVersion
|
|
523
959
|
} else if (BUMPS.has(target)) {
|
|
524
|
-
|
|
960
|
+
if (!currentVersion) {
|
|
961
|
+
abort(`a ${target} bump needs a versionFile to bump from. Pass a version explicitly instead.`)
|
|
962
|
+
}
|
|
963
|
+
const preid = requestedPreid ?? preidOf(currentVersion)
|
|
525
964
|
if (target.startsWith('pre') && !preid) {
|
|
526
965
|
abort(
|
|
527
966
|
`a ${target} bump from a stable version needs --preid <${[...KNOWN_CHANNELS].sort().join('|')}>`,
|
|
528
967
|
)
|
|
529
968
|
}
|
|
530
|
-
version = incrementVersion(
|
|
969
|
+
version = incrementVersion(currentVersion, target, preid)
|
|
531
970
|
} else if (parseVersion(target)) {
|
|
532
971
|
version = target
|
|
533
972
|
} else {
|
|
@@ -536,7 +975,7 @@ if (!target) {
|
|
|
536
975
|
|
|
537
976
|
const tag = `${config.tagPrefix}${version}`
|
|
538
977
|
const isPrerelease = parseVersion(version).pre.length > 0
|
|
539
|
-
const bumping = version !==
|
|
978
|
+
const bumping = !!versionFile && version !== currentVersion && runs('version')
|
|
540
979
|
|
|
541
980
|
let distTag
|
|
542
981
|
try {
|
|
@@ -549,7 +988,7 @@ const expandWith = (template, transform) =>
|
|
|
549
988
|
template
|
|
550
989
|
.replaceAll('%v', transform(version))
|
|
551
990
|
.replaceAll('%t', transform(tag))
|
|
552
|
-
.replaceAll('%n', transform(
|
|
991
|
+
.replaceAll('%n', transform(projectName))
|
|
553
992
|
.replaceAll('%d', transform(distTag))
|
|
554
993
|
|
|
555
994
|
/** Expand tokens for a message or title, which never reaches a shell. */
|
|
@@ -563,17 +1002,32 @@ const expand = (template) => expandWith(template, (value) => value)
|
|
|
563
1002
|
const shellQuote = (value) => `'${value.replaceAll("'", `'\\''`)}'`
|
|
564
1003
|
const expandShell = (template) => expandWith(template, shellQuote)
|
|
565
1004
|
|
|
566
|
-
const publishCommand =
|
|
1005
|
+
const publishCommand = runs('publish') && config.publish ? expandShell(config.publish) : null
|
|
567
1006
|
|
|
568
1007
|
/**
|
|
569
|
-
*
|
|
570
|
-
*
|
|
571
|
-
*
|
|
572
|
-
*
|
|
1008
|
+
* Registries whose preflight can be run, keyed by the first word of the publish command.
|
|
1009
|
+
* Each declares how that CLI answers "who am I" and "does this version already exist";
|
|
1010
|
+
* either may be null when the tool has no such notion. A publish command outside this
|
|
1011
|
+
* table (vsce, a shell pipeline) is run as written with no preflight — it cannot be
|
|
1012
|
+
* introspected, and guessing would invent failures.
|
|
573
1013
|
*/
|
|
574
|
-
const
|
|
1014
|
+
const REGISTRIES = {
|
|
1015
|
+
npm: { whoami: ['whoami'], published: (name, v) => ['view', `${name}@${v}`, 'version'] },
|
|
1016
|
+
pnpm: { whoami: ['whoami'], published: (name, v) => ['view', `${name}@${v}`, 'version'] },
|
|
1017
|
+
bun: {
|
|
1018
|
+
whoami: ['pm', 'whoami'],
|
|
1019
|
+
published: (name, v) => ['pm', 'view', `${name}@${v}`, 'version'],
|
|
1020
|
+
},
|
|
1021
|
+
// uv authenticates with a token from the environment rather than a logged-in session,
|
|
1022
|
+
// and skips duplicate uploads itself via --check-url, so there is no version lookup.
|
|
1023
|
+
uv: { env: ['UV_PUBLISH_TOKEN', 'UV_PUBLISH_PASSWORD'], login: 'set UV_PUBLISH_TOKEN' },
|
|
1024
|
+
// For Go the tag is the release; `go list` warms the module proxy and doubles as the
|
|
1025
|
+
// check for whether this version is already resolvable.
|
|
1026
|
+
go: { published: (name, v) => ['list', '-m', `${name}@${v}`] },
|
|
1027
|
+
}
|
|
1028
|
+
|
|
575
1029
|
const publishCli = publishCommand?.trim().split(/\s+/)[0]
|
|
576
|
-
const
|
|
1030
|
+
const registry = publishCli ? REGISTRIES[publishCli] : null
|
|
577
1031
|
|
|
578
1032
|
/**
|
|
579
1033
|
* CI publishing over OIDC ("trusted publishing") carries no token at all: `whoami` fails
|
|
@@ -587,7 +1041,10 @@ const isTrustedPublishing =
|
|
|
587
1041
|
!!process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN) ||
|
|
588
1042
|
!!process.env.NPM_ID_TOKEN
|
|
589
1043
|
|
|
590
|
-
console.log(
|
|
1044
|
+
console.log(
|
|
1045
|
+
` ${dim(`${currentVersion ?? '(no version file)'} → ${version} tag ${tag} dist-tag ${distTag}`)}`,
|
|
1046
|
+
)
|
|
1047
|
+
console.log(` ${dim(`steps: ${STEPS.filter(runs).join(' → ')}`)}`)
|
|
591
1048
|
|
|
592
1049
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
593
1050
|
// PREFLIGHT — every check runs, then it aborts once with all of the failures
|
|
@@ -601,18 +1058,43 @@ const fail = (message) => {
|
|
|
601
1058
|
problems.push(message)
|
|
602
1059
|
}
|
|
603
1060
|
|
|
604
|
-
if (bumping && compareVersions(version,
|
|
605
|
-
fail(`${version} is not greater than the current version ${
|
|
1061
|
+
if (bumping && compareVersions(version, currentVersion) <= 0) {
|
|
1062
|
+
fail(`${version} is not greater than the current version ${currentVersion}`)
|
|
606
1063
|
} else if (bumping) {
|
|
607
|
-
ok(`version ${
|
|
1064
|
+
ok(`version ${currentVersion} → ${version}`)
|
|
1065
|
+
} else if (versionFile) {
|
|
1066
|
+
ok(`releasing the version already in ${versionFile.path} (${version})`)
|
|
608
1067
|
} else {
|
|
609
|
-
ok(`releasing
|
|
1068
|
+
ok(`releasing ${version} (no version file; the tag is the version)`)
|
|
610
1069
|
}
|
|
611
1070
|
|
|
612
1071
|
const dirty = tryRead('git', ['status', '--porcelain'])
|
|
613
1072
|
if (dirty === null) fail('could not read git status')
|
|
614
|
-
else if (dirty
|
|
615
|
-
|
|
1073
|
+
else if (dirty && runs('commit')) {
|
|
1074
|
+
ok(`working tree has ${dirty.split('\n').length} change(s) — will be committed first`)
|
|
1075
|
+
console.log(dim(indent(formatStatus(dirty))))
|
|
1076
|
+
} else if (dirty) {
|
|
1077
|
+
fail(`working tree is not clean:\n${indent(formatStatus(dirty))}`)
|
|
1078
|
+
} else ok('working tree clean')
|
|
1079
|
+
|
|
1080
|
+
if (runs('commit') && !assistant) {
|
|
1081
|
+
fail(
|
|
1082
|
+
'the commit step needs a drafting assistant to write the message. Configure one with ' +
|
|
1083
|
+
'`--assistant auto`, or set "assistant" in release.config.json.',
|
|
1084
|
+
)
|
|
1085
|
+
} else if (assistant) {
|
|
1086
|
+
const detail = [
|
|
1087
|
+
assistantModel && `model ${assistantModel}`,
|
|
1088
|
+
assistantEffort && `effort ${assistantEffort}`,
|
|
1089
|
+
]
|
|
1090
|
+
.filter(Boolean)
|
|
1091
|
+
.join(', ')
|
|
1092
|
+
ok(`assistant: ${assistantName}${detail ? ` (${detail})` : ''}`)
|
|
1093
|
+
if (assistantModel && !assistant.model)
|
|
1094
|
+
warn(`${assistantName} takes no model flag — --model ignored`)
|
|
1095
|
+
if (assistantEffort && !assistant.effort)
|
|
1096
|
+
warn(`${assistantName} takes no effort flag — --effort ignored`)
|
|
1097
|
+
}
|
|
616
1098
|
|
|
617
1099
|
const branch = tryRead('git', ['rev-parse', '--abbrev-ref', 'HEAD'])
|
|
618
1100
|
if (!branch) fail('could not read the current branch')
|
|
@@ -642,7 +1124,9 @@ if (!succeeds('git', ['remote', 'get-url', config.remote])) {
|
|
|
642
1124
|
|
|
643
1125
|
const head = tryRead('git', ['rev-parse', 'HEAD'])
|
|
644
1126
|
const taggedCommit = tryRead('git', ['rev-list', '-n', '1', tag])
|
|
645
|
-
if (
|
|
1127
|
+
if (!runs('tag')) {
|
|
1128
|
+
note('tag step not selected')
|
|
1129
|
+
} else if (taggedCommit && bumping) {
|
|
646
1130
|
fail(`tag ${tag} already exists — release a different version`)
|
|
647
1131
|
} else if (taggedCommit && taggedCommit !== head) {
|
|
648
1132
|
fail(`tag ${tag} already exists at ${taggedCommit.slice(0, 8)}, not at HEAD`)
|
|
@@ -653,8 +1137,8 @@ if (taggedCommit && bumping) {
|
|
|
653
1137
|
}
|
|
654
1138
|
|
|
655
1139
|
let releaseExists = false
|
|
656
|
-
if (
|
|
657
|
-
note('
|
|
1140
|
+
if (!runs('release')) {
|
|
1141
|
+
note('release step not selected')
|
|
658
1142
|
} else if (!succeeds('gh', ['--version'])) {
|
|
659
1143
|
fail('the GitHub CLI (`gh`) is not installed — https://cli.github.com')
|
|
660
1144
|
} else if (!succeeds('gh', ['auth', 'status'])) {
|
|
@@ -667,34 +1151,58 @@ if (skipRelease) {
|
|
|
667
1151
|
|
|
668
1152
|
let alreadyPublished = false
|
|
669
1153
|
if (!publishCommand) {
|
|
670
|
-
note(
|
|
671
|
-
} else if (
|
|
1154
|
+
note(runs('publish') ? 'no publish command configured' : 'publish step not selected')
|
|
1155
|
+
} else if (manifest?.private) {
|
|
672
1156
|
fail('package.json is private but a publish command is configured')
|
|
673
|
-
} else if (!
|
|
1157
|
+
} else if (!registry) {
|
|
674
1158
|
ok(`publish: ${publishCommand}`)
|
|
675
1159
|
} else {
|
|
676
1160
|
if (isTrustedPublishing) {
|
|
677
1161
|
ok('trusted publishing (OIDC) — no token needed')
|
|
678
|
-
} else {
|
|
679
|
-
|
|
1162
|
+
} else if (registry.env) {
|
|
1163
|
+
// Token-in-the-environment auth: there is no session to interrogate, only credentials.
|
|
1164
|
+
const found = registry.env.find((name) => process.env[name])
|
|
1165
|
+
if (found) ok(`${publishCli} credentials found (${found})`)
|
|
1166
|
+
else fail(`${publishCli} has no publish credentials — ${registry.login}`)
|
|
1167
|
+
} else if (registry.whoami) {
|
|
1168
|
+
const user = tryRead(publishCli, registry.whoami)
|
|
680
1169
|
if (user === null) {
|
|
681
1170
|
// npm replaced long-lived tokens with two-hour sessions in December 2025, so the
|
|
682
1171
|
// usual cause is an expired session rather than a missing login.
|
|
683
1172
|
fail(
|
|
684
|
-
`${
|
|
1173
|
+
`${publishCli} is not authenticated — run \`${publishCli} login\`. ` +
|
|
685
1174
|
'npm logins are two-hour sessions, so an earlier one may have expired.',
|
|
686
1175
|
)
|
|
687
|
-
} else ok(`${
|
|
1176
|
+
} else ok(`${publishCli} authenticated (${user || 'unknown user'})`)
|
|
688
1177
|
}
|
|
689
|
-
|
|
690
|
-
if (
|
|
691
|
-
|
|
1178
|
+
|
|
1179
|
+
if (registry.published) {
|
|
1180
|
+
alreadyPublished = succeeds(publishCli, registry.published(projectName, version))
|
|
1181
|
+
if (alreadyPublished) {
|
|
1182
|
+
note(`${projectName}@${version} is already published — will skip the publish step`)
|
|
1183
|
+
}
|
|
692
1184
|
}
|
|
693
1185
|
}
|
|
694
1186
|
|
|
695
|
-
// Notes: the changelog section for this version, else GitHub generates them
|
|
1187
|
+
// Notes: the changelog section for this version, else a draft, else GitHub generates them.
|
|
696
1188
|
let notes = null
|
|
697
1189
|
let rolledChangelog = null
|
|
1190
|
+
let draftedNotes = null
|
|
1191
|
+
|
|
1192
|
+
/**
|
|
1193
|
+
* True when --commit still has to create a commit. Notes drafted before that commit would
|
|
1194
|
+
* describe an incomplete release, so drafting waits until the working tree is committed.
|
|
1195
|
+
*/
|
|
1196
|
+
const notesDeferred = !!(dirty && runs('commit') && assistant)
|
|
1197
|
+
|
|
1198
|
+
/** Draft notes from the commit log, reporting what it is doing since it takes a moment. */
|
|
1199
|
+
function draftNotesFor(v) {
|
|
1200
|
+
if (!assistant) return null
|
|
1201
|
+
const { lastTag, subjects } = commitsSinceLastTag()
|
|
1202
|
+
if (!subjects.length) return null
|
|
1203
|
+
note(`drafting notes from ${subjects.length} commit(s) with ${assistantName}...`)
|
|
1204
|
+
return draftReleaseNotes(v, subjects, lastTag)
|
|
1205
|
+
}
|
|
698
1206
|
if (config.changelog && existsSync(config.changelog)) {
|
|
699
1207
|
const text = readFileSync(config.changelog, 'utf8')
|
|
700
1208
|
notes = changelogSection(text, version)
|
|
@@ -706,13 +1214,26 @@ if (config.changelog && existsSync(config.changelog)) {
|
|
|
706
1214
|
notes = changelogSection(rolledChangelog, version)
|
|
707
1215
|
ok(`${config.changelog}: [Unreleased] will become [${version}]`)
|
|
708
1216
|
} else {
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
1217
|
+
draftedNotes = notesDeferred ? null : draftNotesFor(version)
|
|
1218
|
+
if (notesDeferred) {
|
|
1219
|
+
ok(`${config.changelog}: a ${version} section will be drafted after the commit`)
|
|
1220
|
+
} else if (draftedNotes) {
|
|
1221
|
+
notes = draftedNotes
|
|
1222
|
+
ok(`${config.changelog}: a ${version} section will be drafted by ${assistantName}`)
|
|
1223
|
+
} else {
|
|
1224
|
+
warn(
|
|
1225
|
+
`${config.changelog} has no ${version} or [Unreleased] section — GitHub will generate the notes`,
|
|
1226
|
+
)
|
|
1227
|
+
}
|
|
712
1228
|
}
|
|
713
1229
|
}
|
|
714
|
-
} else
|
|
715
|
-
|
|
1230
|
+
} else {
|
|
1231
|
+
// No changelog file at all: there is nothing to roll, but notes can still be drafted for
|
|
1232
|
+
// the tag annotation and the GitHub release.
|
|
1233
|
+
notes = notesDeferred ? null : draftNotesFor(version)
|
|
1234
|
+
if (notesDeferred) ok('release notes will be drafted after the commit')
|
|
1235
|
+
else if (notes) ok(`release notes drafted by ${assistantName}`)
|
|
1236
|
+
else if (config.changelog) note(`no ${config.changelog} — GitHub will generate the notes`)
|
|
716
1237
|
}
|
|
717
1238
|
|
|
718
1239
|
for (const asset of config.assets) {
|
|
@@ -737,7 +1258,7 @@ if (!assumeYes && !dryRun && process.stdin.isTTY) {
|
|
|
737
1258
|
const rl = createInterface({ input: process.stdin, output: process.stdout })
|
|
738
1259
|
let answer = ''
|
|
739
1260
|
try {
|
|
740
|
-
answer = await rl.question(`\nRelease ${bold(tag)} of ${
|
|
1261
|
+
answer = await rl.question(`\nRelease ${bold(tag)} of ${projectName}? [y/N] `)
|
|
741
1262
|
} catch {
|
|
742
1263
|
// Ctrl+C or Ctrl+D at the prompt rejects the question. That is a decline, not a
|
|
743
1264
|
// crash — without this it exits on an unhandled AbortError and a stack trace.
|
|
@@ -753,13 +1274,36 @@ if (!assumeYes && !dryRun && process.stdin.isTTY) {
|
|
|
753
1274
|
|
|
754
1275
|
const staged = []
|
|
755
1276
|
|
|
1277
|
+
if (dirty && runs('commit')) {
|
|
1278
|
+
step('Commit the working tree')
|
|
1279
|
+
mutate('git', ['add', '--all'])
|
|
1280
|
+
// Draft after staging: the message describes what is staged, not what happens to be
|
|
1281
|
+
// in the tree. Under --dry-run nothing was staged, so there is nothing to describe.
|
|
1282
|
+
const message = dryRun ? null : draftCommitMessage()
|
|
1283
|
+
if (!message && !dryRun) {
|
|
1284
|
+
abort(
|
|
1285
|
+
`${assistantName} could not draft a Conventional Commits message for the staged changes.\n\n` +
|
|
1286
|
+
' Commit them yourself and re-run, or run without --commit.',
|
|
1287
|
+
)
|
|
1288
|
+
}
|
|
1289
|
+
console.log(indent(message ?? '<drafted at run time>'))
|
|
1290
|
+
mutate('git', ['commit', '-m', message ?? 'chore: working tree'])
|
|
1291
|
+
|
|
1292
|
+
// Now that the commit exists it is part of the release, so the notes can describe it.
|
|
1293
|
+
if (notesDeferred && !dryRun) {
|
|
1294
|
+
draftedNotes = draftNotesFor(version)
|
|
1295
|
+
if (draftedNotes) notes = draftedNotes
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
|
|
756
1299
|
if (bumping) {
|
|
757
1300
|
step(`Write version ${version}`)
|
|
758
|
-
for (const
|
|
759
|
-
|
|
760
|
-
if (
|
|
761
|
-
|
|
762
|
-
|
|
1301
|
+
for (const entry of [versionFile, ...config.versionFiles]) {
|
|
1302
|
+
const source = versionSource(entry)
|
|
1303
|
+
if (!existsSync(source.path)) abort(`versionFiles entry ${source.path} does not exist`)
|
|
1304
|
+
if (writeVersionInto(source, version)) {
|
|
1305
|
+
staged.push(source.path)
|
|
1306
|
+
console.log(` ${dryRun ? yellow('would write') : dim('wrote')} ${source.path}`)
|
|
763
1307
|
}
|
|
764
1308
|
}
|
|
765
1309
|
// A package-lock.json embeds the root version twice, so it goes stale on a bump.
|
|
@@ -769,11 +1313,26 @@ if (bumping) {
|
|
|
769
1313
|
}
|
|
770
1314
|
}
|
|
771
1315
|
|
|
772
|
-
if (rolledChangelog) {
|
|
1316
|
+
if (rolledChangelog && runs('changelog')) {
|
|
773
1317
|
step(`Roll ${config.changelog} to ${version}`)
|
|
774
1318
|
if (dryRun) console.log(` ${yellow('would write')} ${config.changelog}`)
|
|
775
1319
|
else writeFileSync(config.changelog, rolledChangelog)
|
|
776
1320
|
staged.push(config.changelog)
|
|
1321
|
+
} else if (runs('changelog') && draftedNotes && config.changelog && existsSync(config.changelog)) {
|
|
1322
|
+
step(`Add the drafted ${version} section to ${config.changelog}`)
|
|
1323
|
+
if (dryRun) console.log(` ${yellow('would write')} ${config.changelog}`)
|
|
1324
|
+
else {
|
|
1325
|
+
writeFileSync(
|
|
1326
|
+
config.changelog,
|
|
1327
|
+
insertChangelogSection(
|
|
1328
|
+
readFileSync(config.changelog, 'utf8'),
|
|
1329
|
+
version,
|
|
1330
|
+
new Date().toISOString().slice(0, 10),
|
|
1331
|
+
draftedNotes,
|
|
1332
|
+
),
|
|
1333
|
+
)
|
|
1334
|
+
}
|
|
1335
|
+
staged.push(config.changelog)
|
|
777
1336
|
}
|
|
778
1337
|
|
|
779
1338
|
if (staged.length) {
|
|
@@ -782,7 +1341,7 @@ if (staged.length) {
|
|
|
782
1341
|
mutate('git', ['commit', '-m', expand(config.commitMessage)])
|
|
783
1342
|
}
|
|
784
1343
|
|
|
785
|
-
if (!taggedCommit) {
|
|
1344
|
+
if (runs('tag') && !taggedCommit) {
|
|
786
1345
|
step(`Annotated tag ${tag}`)
|
|
787
1346
|
// The notes become the tag annotation too, so a CI release workflow can read them
|
|
788
1347
|
// straight off the tag instead of re-deriving them. --cleanup=verbatim is required:
|
|
@@ -794,21 +1353,23 @@ if (!taggedCommit) {
|
|
|
794
1353
|
tag,
|
|
795
1354
|
'--cleanup=verbatim',
|
|
796
1355
|
'-m',
|
|
797
|
-
`${notes ?? `${
|
|
1356
|
+
`${notes ?? `${projectName} ${tag}`}\n`,
|
|
798
1357
|
])
|
|
799
1358
|
}
|
|
800
1359
|
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
//
|
|
804
|
-
|
|
1360
|
+
if (runs('push')) {
|
|
1361
|
+
step(`Push branch and tag to ${config.remote}`)
|
|
1362
|
+
// --follow-tags sends the commit and the tag in one call; pushing them separately is how
|
|
1363
|
+
// a tag ends up on the remote without its commit, or a release without its tag.
|
|
1364
|
+
mutate('git', ['push', '--follow-tags', config.remote, branch ?? 'HEAD'])
|
|
1365
|
+
}
|
|
805
1366
|
|
|
806
1367
|
if (publishCommand && !alreadyPublished) {
|
|
807
1368
|
step(`Publish to the registry (dist-tag ${distTag})`)
|
|
808
1369
|
mutateShell(publishCommand)
|
|
809
1370
|
}
|
|
810
1371
|
|
|
811
|
-
if (
|
|
1372
|
+
if (runs('release') && !releaseExists) {
|
|
812
1373
|
step(`GitHub release ${tag}`)
|
|
813
1374
|
const args = [
|
|
814
1375
|
'release',
|