@entro314labs/release-kit 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +288 -0
  3. package/package.json +54 -0
  4. package/release.mjs +815 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Entro314 Labs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,288 @@
1
+ # @entro314labs/release-kit
2
+
3
+ A single-file release mechanism for any JS/TS/Node project: version bump → changelog roll
4
+ → commit → annotated tag → push → registry publish → GitHub release.
5
+
6
+ `release.mjs` imports nothing but `node:*`. No dependencies, no build step, no config
7
+ required — the file _is_ the tool. That is why it can be installed as a package, run
8
+ straight from the registry, or vendored into a project as a plain file, with no difference
9
+ in behaviour between them.
10
+
11
+ ## Install
12
+
13
+ **As a devDependency** — the normal choice. Updates arrive through your package manager.
14
+
15
+ ```sh
16
+ pnpm add -D @entro314labs/release-kit
17
+ ```
18
+
19
+ ```json
20
+ {
21
+ "scripts": {
22
+ "release": "release-kit"
23
+ }
24
+ }
25
+ ```
26
+
27
+ **Without installing** — for a one-off release, or a project you do not want to add a
28
+ dependency to:
29
+
30
+ ```sh
31
+ npx @entro314labs/release-kit --dry-run
32
+ ```
33
+
34
+ **Vendored** — for a project that should not depend on the registry it is about to publish
35
+ to, or one that needs releases to work offline. `--sync` copies the file into
36
+ `scripts/release.mjs`:
37
+
38
+ ```sh
39
+ npx @entro314labs/release-kit --sync .
40
+ ```
41
+
42
+ ```json
43
+ {
44
+ "scripts": {
45
+ "release": "node scripts/release.mjs"
46
+ }
47
+ }
48
+ ```
49
+
50
+ All three run the same file. Zero-config works on the conventions below; add a
51
+ [`release.config.json`](#configuration) only for what differs.
52
+
53
+ ## Usage
54
+
55
+ ```sh
56
+ pnpm release # release the version already in package.json
57
+ pnpm release 2.3.0 # release an explicit version
58
+ pnpm release minor # bump from the current version
59
+ pnpm release prerelease --preid beta
60
+ pnpm release -- --dry-run # print every step, execute nothing
61
+ pnpm release -- --help
62
+ ```
63
+
64
+ The target is optional. With no target it releases whatever version `package.json`
65
+ already says — which is the mode to use when a version bump landed in an earlier commit.
66
+
67
+ | Target | From `1.2.3` | From `2.0.0-beta.1` |
68
+ | ------------------------------------ | ------------------------------------------------ | ------------------- |
69
+ | _(none)_ | `1.2.3` | `2.0.0-beta.1` |
70
+ | `patch` | `1.2.4` | `2.0.0` |
71
+ | `minor` | `1.3.0` | `2.0.0` |
72
+ | `major` | `2.0.0` | `2.0.0` |
73
+ | `prerelease` | `1.2.4-beta.0` | `2.0.0-beta.2` |
74
+ | `prepatch` / `preminor` / `premajor` | `1.2.4-beta.0` / `1.3.0-beta.0` / `2.0.0-beta.0` | same |
75
+ | `2.5.0` | `2.5.0` | `2.5.0` |
76
+
77
+ A `major`/`minor`/`patch` bump off a prerelease releases that prerelease's base version
78
+ when the base already satisfies the bump, so promoting a release candidate is a plain
79
+ `patch`. The arithmetic matches `semver.inc` exactly.
80
+
81
+ Prerelease bumps need `--preid` unless the current version already carries one to infer.
82
+
83
+ ### Flags
84
+
85
+ | Flag | Effect |
86
+ | ------------------ | -------------------------------------------------------------------------- |
87
+ | `--dry-run` | Print every step, execute nothing. Preflight still runs and still reports. |
88
+ | `--yes`, `-y` | Skip the confirmation prompt. |
89
+ | `--preid <id>` | Prerelease identifier: `alpha`, `beta`, `rc`, `next`, `nightly`, `canary`. |
90
+ | `--tag <dist-tag>` | Override the npm dist-tag. Always wins over the derived one. |
91
+ | `--skip-publish` | Do not publish to the registry. |
92
+ | `--skip-release` | Do not create the GitHub release. |
93
+ | `--sync <dir>...` | Copy this script into other projects and exit. Touches no git state. |
94
+ | `--help`, `-h` | Full flag list. |
95
+
96
+ ## What a release does
97
+
98
+ Steps that do not apply are skipped silently — a project with no changelog, or with
99
+ `publish` disabled, simply has fewer steps.
100
+
101
+ 1. **Write the version** into `package.json` and any configured `versionFiles`. The value
102
+ is replaced in place, so key order, indentation, and trailing newline all survive. A
103
+ `package-lock.json` is resynced, because it embeds the root version twice.
104
+ 2. **Roll the changelog**: `## [Unreleased]` becomes `## [x.y.z] - YYYY-MM-DD`, with a
105
+ fresh empty `## [Unreleased]` reopened above it for the next cycle.
106
+ 3. **Commit** the files that actually changed.
107
+ 4. **Tag**, annotated, with the release notes as the annotation — so a CI workflow can
108
+ read the notes straight off the tag instead of re-deriving them.
109
+ 5. **Push** with `git push --follow-tags`, which sends the commit and the tag in one
110
+ call. Pushing them separately is how a tag ends up on the remote without its commit.
111
+ 6. **Publish** to the registry.
112
+ 7. **Create the GitHub release**, marked `--latest` or `--prerelease`.
113
+
114
+ ### Release notes
115
+
116
+ Notes resolve in this order:
117
+
118
+ 1. The `CHANGELOG.md` section for the version being released. Every common heading shape
119
+ is recognised: `## [1.2.3] - 2026-08-17`, `## v1.2.3`, `## 1.2.3 (2026-08-17)`. The
120
+ section ends at the next `##` heading or `---` rule.
121
+ 2. The `## [Unreleased]` section, if the version has no section of its own — this is the
122
+ same content that step 2 above is about to promote.
123
+ 3. Otherwise GitHub generates them from the commits since the previous tag.
124
+
125
+ The same text becomes the tag annotation, the GitHub release body, and (when rolled) the
126
+ changelog entry. It is written once and lands in three places.
127
+
128
+ ### npm dist-tags
129
+
130
+ The dist-tag is derived from the version, never guessed:
131
+
132
+ | Version | dist-tag |
133
+ | ---------------------- | ------------------------------------------------------------- |
134
+ | `1.2.3` | `latest` |
135
+ | `1.2.3-beta.4` | `beta` (any of `alpha` `beta` `canary` `next` `nightly` `rc`) |
136
+ | `3.0.0-1751023456789` | `canary` (an all-numeric prerelease is a timestamp) |
137
+ | `1.2.3-experimental.0` | **refuses to release** |
138
+
139
+ The refusal is deliberate: an unrecognised prerelease identifier has no safe channel, and
140
+ falling through to `latest` would put a prerelease on the stable line where every
141
+ `npm install` picks it up. Pass `--tag <dist-tag>` to choose a channel explicitly.
142
+
143
+ ## Preflight
144
+
145
+ Every check runs and every failure is reported before it aborts once with the whole list,
146
+ rather than stopping at the first problem.
147
+
148
+ - The target version is greater than the current one
149
+ - Working tree is clean
150
+ - On the configured branch
151
+ - The remote exists, is reachable, and the branch is not behind it
152
+ - The tag is free — or already exists at `HEAD`, in which case it is reused
153
+ - `gh` is installed and authenticated
154
+ - The publishing CLI is authenticated, and the version is not already on the registry
155
+ - Configured release assets exist
156
+ - A changelog section for the version exists _(a warning, not a failure — it falls back
157
+ to generated notes)_
158
+
159
+ Under `--dry-run` the failures are reported and then the remaining steps are shown anyway,
160
+ so you can see the whole plan without fixing the blockers first.
161
+
162
+ ## Recovering from a failed run
163
+
164
+ Re-run the same command. Every step is idempotent:
165
+
166
+ | Already done | What happens |
167
+ | ----------------------- | ------------------------------ |
168
+ | Version written | No diff to stage, so no commit |
169
+ | Tag exists at `HEAD` | Reused, not recreated |
170
+ | Commit and tag pushed | Push is a no-op |
171
+ | Version on the registry | Publish skipped |
172
+ | GitHub release exists | Release skipped |
173
+
174
+ So a run that dies at the publish step (2FA timeout, flaky network) picks up exactly where
175
+ it stopped. There is no cleanup step, no `--resume`, and nothing to remember.
176
+
177
+ The one case that is not recoverable by re-running is a tag that exists at a _different_
178
+ commit than `HEAD`. That is a genuine conflict, and it aborts rather than guessing.
179
+
180
+ ## Configuration
181
+
182
+ `release.config.json`, beside `package.json`. Every key is optional; unknown keys abort
183
+ rather than being silently ignored.
184
+
185
+ | Key | Default | Meaning |
186
+ | --------------- | ------------------------ | ------------------------------------------------------------ |
187
+ | `tagPrefix` | `"v"` | Prepended to the version to form the tag |
188
+ | `branch` | `"main"` | The only branch a release may run from; `null` allows any |
189
+ | `remote` | `"origin"` | Git remote to push to |
190
+ | `changelog` | `"CHANGELOG.md"` | Changelog path; `null` disables changelog handling |
191
+ | `versionFiles` | `[]` | Extra JSON files whose top-level `"version"` is kept in sync |
192
+ | `publish` | `"npm publish --tag %d"` | Publish command; `null` skips publishing |
193
+ | `commitMessage` | `"chore(release): %t"` | Release commit subject |
194
+ | `releaseTitle` | `"%t"` | GitHub release title |
195
+ | `assets` | `[]` | Files attached to the GitHub release |
196
+
197
+ Command and message strings expand four tokens: `%v` version, `%t` tag, `%n` package
198
+ name, `%d` npm dist-tag. In the `publish` command line the substituted values are
199
+ shell-quoted, so a version carrying shell metacharacters is passed through as one literal
200
+ argument.
201
+
202
+ ### Publishing and authentication
203
+
204
+ The registry preflight (`whoami`, the already-published lookup) runs with whichever CLI the
205
+ `publish` command names, so a pnpm project is checked with pnpm:
206
+
207
+ ```json
208
+ {
209
+ "publish": "pnpm publish --tag %d"
210
+ }
211
+ ```
212
+
213
+ `npm` and `pnpm` are both understood. Any other publish command — `vsce publish`, a shell
214
+ pipeline — is run as written with no registry preflight, because there is nothing reliable
215
+ to introspect.
216
+
217
+ Two npm behaviours are handled automatically:
218
+
219
+ - **`npm login` issues a two-hour session**, not a durable token. Classic tokens were
220
+ permanently revoked in December 2025. A login from earlier in the day has expired, and
221
+ the preflight failure says so rather than implying you never logged in.
222
+ - **Trusted publishing (OIDC) carries no token at all.** In GitHub Actions with
223
+ `id-token: write`, or GitLab CI/CircleCI with `NPM_ID_TOKEN`, `whoami` fails while
224
+ `publish` succeeds. That environment is detected and the auth check is skipped, so a
225
+ valid CI release is not aborted over a missing token it does not need.
226
+
227
+ ### Examples
228
+
229
+ A VS Code extension, published to the marketplace rather than npm:
230
+
231
+ ```json
232
+ {
233
+ "publish": "vsce publish"
234
+ }
235
+ ```
236
+
237
+ A browser extension with a separate manifest and a built artifact:
238
+
239
+ ```json
240
+ {
241
+ "versionFiles": ["src/manifest.json"],
242
+ "publish": null,
243
+ "assets": ["build.zip"],
244
+ "changelog": null
245
+ }
246
+ ```
247
+
248
+ A project releasing off a non-default branch with a different tag scheme:
249
+
250
+ ```json
251
+ {
252
+ "branch": "release",
253
+ "tagPrefix": "release-",
254
+ "commitMessage": "release: %n %v"
255
+ }
256
+ ```
257
+
258
+ ## Keeping vendored copies in sync
259
+
260
+ Installed as a dependency, updates come from your package manager and there is nothing to
261
+ sync. For projects using the vendored file, `--sync` pushes the current version out — to
262
+ one project or to many at once:
263
+
264
+ ```sh
265
+ npx @entro314labs/release-kit --sync ../project-a ../project-b
266
+ ```
267
+
268
+ It reports `installed`, `updated`, or `already up to date` per target, creates `scripts/`
269
+ if missing, skips directories with no `package.json`, and warns when a target lacks the
270
+ `release` npm script. It runs before any git resolution, so it works from anywhere,
271
+ including a directory that is not a repository.
272
+
273
+ ## Requirements
274
+
275
+ - Node 18+ (uses `node:readline/promises` and `Array.prototype.at`)
276
+ - `git`
277
+ - `gh`, authenticated — only when creating GitHub releases
278
+ - Whatever the `publish` command needs — for the default, a live `npm login` session
279
+ (two hours) or an OIDC trusted-publishing environment
280
+
281
+ ## Contributing
282
+
283
+ The tool releases itself, so a change ships the same way it would in any consuming project:
284
+ add a `## [Unreleased]` entry to `CHANGELOG.md`, then run `pnpm release <bump>` from a clone.
285
+
286
+ ## License
287
+
288
+ MIT
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "@entro314labs/release-kit",
3
+ "version": "1.0.1",
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
+ "keywords": [
6
+ "changelog",
7
+ "cli",
8
+ "github-release",
9
+ "npm-publish",
10
+ "release",
11
+ "release-automation",
12
+ "semver",
13
+ "tag",
14
+ "versioning",
15
+ "zero-dependency"
16
+ ],
17
+ "homepage": "https://github.com/entro314-labs/release-kit#readme",
18
+ "bugs": {
19
+ "url": "https://github.com/entro314-labs/release-kit/issues"
20
+ },
21
+ "license": "MIT",
22
+ "author": "Dominikos Pritis <idominikos@outlook.com>",
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "git+https://github.com/entro314-labs/release-kit.git"
26
+ },
27
+ "bin": {
28
+ "release-kit": "release.mjs"
29
+ },
30
+ "files": [
31
+ "release.mjs",
32
+ "README.md",
33
+ "LICENSE"
34
+ ],
35
+ "type": "module",
36
+ "publishConfig": {
37
+ "access": "public"
38
+ },
39
+ "scripts": {
40
+ "format": "oxfmt --write .",
41
+ "format:check": "oxfmt --check .",
42
+ "lint": "oxlint .",
43
+ "lint:ci": "oxlint --deny-warnings .",
44
+ "check": "pnpm run format:check && pnpm run lint:ci",
45
+ "release": "node release.mjs"
46
+ },
47
+ "devDependencies": {
48
+ "oxfmt": "^0.63.0",
49
+ "oxlint": "^1.78.0"
50
+ },
51
+ "engines": {
52
+ "node": ">=18"
53
+ }
54
+ }
package/release.mjs ADDED
@@ -0,0 +1,815 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Drop-in atomic release for any JS/TS/Node project.
4
+ *
5
+ * Version bump → changelog roll → commit → annotated tag → push → registry publish →
6
+ * GitHub release. It imports nothing but `node:*`, which is why the same file works
7
+ * installed as a package, run through `npx`, or vendored into a project's `scripts/`.
8
+ *
9
+ * pnpm add -D @entro314labs/release-kit then "release": "release-kit"
10
+ * npx @entro314labs/release-kit no install
11
+ * npx @entro314labs/release-kit --sync . vendor it as scripts/release.mjs
12
+ *
13
+ * release-kit release the version already in package.json
14
+ * release-kit 2.3.0 release an explicit version
15
+ * release-kit minor bump from the current version
16
+ * release-kit prerelease --preid beta
17
+ * release-kit --dry-run print every step, execute nothing
18
+ * release-kit --help full flag list
19
+ *
20
+ * Two properties shape the design:
21
+ *
22
+ * - Preflight accumulates. Every check runs and every failure is reported before it
23
+ * aborts once with the whole list, rather than stopping at the first problem.
24
+ * - Every step is idempotent. A run interrupted partway through (a publish timeout, a
25
+ * network failure) can be re-run: an already-written version, an existing tag at HEAD,
26
+ * an already-published version and an existing release are each detected and skipped.
27
+ * There is no cleanup step and no --resume flag.
28
+ *
29
+ * Configuration is optional. Defaults are the conventions (package.json version,
30
+ * CHANGELOG.md, main branch, `v` tag prefix, npm publish); a release.config.json beside
31
+ * package.json overrides only what differs. See CONFIG below.
32
+ */
33
+
34
+ import { execFileSync, execSync } from 'node:child_process'
35
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
36
+ import { basename, join, relative, resolve, sep } from 'node:path'
37
+ import { createInterface } from 'node:readline/promises'
38
+
39
+ // ─────────────────────────────────────────────────────────────────────────────
40
+ // CONFIG
41
+ // ─────────────────────────────────────────────────────────────────────────────
42
+
43
+ /**
44
+ * Defaults, overridden per-key by release.config.json.
45
+ *
46
+ * Command and message strings expand four tokens: %v version, %t tag, %n package name,
47
+ * %d npm dist-tag.
48
+ *
49
+ * tagPrefix string prepended to the version to form the tag
50
+ * branch string the only branch a release may run from; null to allow any
51
+ * remote string git remote to push to
52
+ * changelog string changelog path; null to disable changelog handling
53
+ * versionFiles string[] extra JSON files whose top-level "version" is kept in sync
54
+ * publish string publish command; null to skip publishing entirely
55
+ * commitMessage string release commit subject
56
+ * releaseTitle string GitHub release title
57
+ * assets string[] files attached to the GitHub release
58
+ */
59
+ const DEFAULTS = {
60
+ tagPrefix: 'v',
61
+ branch: 'main',
62
+ remote: 'origin',
63
+ changelog: 'CHANGELOG.md',
64
+ versionFiles: [],
65
+ publish: 'npm publish --tag %d',
66
+ commitMessage: 'chore(release): %t',
67
+ releaseTitle: '%t',
68
+ assets: [],
69
+ }
70
+
71
+ /**
72
+ * Prerelease identifiers that map to their own npm dist-tag. An identifier outside this
73
+ * set has no safe home, so `distTagFor` refuses rather than letting a prerelease fall
74
+ * through to `latest` and clobber the stable line.
75
+ */
76
+ const KNOWN_CHANNELS = new Set(['alpha', 'beta', 'canary', 'next', 'nightly', 'rc'])
77
+
78
+ /**
79
+ * How this script was invoked, so --help prints a command that actually works: the bin
80
+ * name when it is installed as a package, `node <path>` when it is vendored as a file.
81
+ */
82
+ const INVOCATION = process.argv[1]?.includes(`${sep}node_modules${sep}`)
83
+ ? 'release-kit'
84
+ : `node ${relative(process.cwd(), process.argv[1] ?? 'release.mjs') || 'release.mjs'}`
85
+
86
+ const USAGE = `
87
+ release-kit — tag, publish, and release a JS/TS/Node project.
88
+
89
+ ${INVOCATION} [<version>|<bump>] [flags]
90
+
91
+ Target (optional; defaults to the version already in package.json):
92
+ <x.y.z> release this exact version
93
+ patch minor major bump from the current version
94
+ prepatch preminor premajor prerelease
95
+ prerelease bump; needs --preid unless it can be inferred
96
+
97
+ Flags:
98
+ --preid <id> prerelease identifier (alpha, beta, rc, next, nightly, canary)
99
+ --tag <dist-tag> override the npm dist-tag (default: derived from the version)
100
+ --dry-run print every step and execute nothing
101
+ --yes, -y skip the confirmation prompt
102
+ --skip-publish do not publish to the registry
103
+ --skip-release do not create the GitHub release
104
+ --sync <dir>... copy this script into other projects' scripts/ and exit
105
+ --help, -h show this
106
+
107
+ Config: release.config.json beside package.json overrides any of
108
+ ${Object.keys(DEFAULTS).join(', ')}
109
+ `
110
+
111
+ // ─────────────────────────────────────────────────────────────────────────────
112
+ // OUTPUT
113
+ // ─────────────────────────────────────────────────────────────────────────────
114
+
115
+ const TTY = !!process.stdout.isTTY
116
+ const paint = (code, s) => (TTY ? `\u001B[${code}m${s}\u001B[0m` : s)
117
+ const bold = (s) => paint('1', s)
118
+ const dim = (s) => paint('2', s)
119
+ const green = (s) => paint('32', s)
120
+ const red = (s) => paint('31', s)
121
+ const yellow = (s) => paint('33', s)
122
+
123
+ let stepNumber = 0
124
+ const step = (title) => console.log(`\n${bold(`[${++stepNumber}] ${title}`)}`)
125
+ const ok = (message) => console.log(` ${green('ok')} ${message}`)
126
+ const warn = (message) => console.log(` ${yellow('warn')} ${message}`)
127
+ const note = (message) => console.log(` ${dim(message)}`)
128
+ const indent = (text) =>
129
+ text
130
+ .split('\n')
131
+ .map((line) => ` ${line}`)
132
+ .join('\n')
133
+
134
+ /**
135
+ * Re-pad `git status --porcelain` entries so the two-column status code lines up. The
136
+ * raw output is trimmed on capture, which strips the leading space off the first entry
137
+ * only — ` M file` becomes `M file` while the rest keep theirs, misaligning the column.
138
+ */
139
+ const formatStatus = (porcelain) =>
140
+ porcelain
141
+ .split('\n')
142
+ .map((line) => {
143
+ const entry = line.trim()
144
+ const gap = entry.indexOf(' ')
145
+ return gap === -1 ? entry : `${entry.slice(0, gap).padEnd(2)} ${entry.slice(gap + 1)}`
146
+ })
147
+ .join('\n')
148
+
149
+ function abort(message) {
150
+ console.log(`\n${red(bold('RELEASE ABORTED'))} — ${message}\n`)
151
+ process.exit(1)
152
+ }
153
+
154
+ /**
155
+ * A command failed after the release started mutating. The command has already printed its
156
+ * own error to stderr, so say only what that does not: where it stopped, and that this is
157
+ * resumable. Without this the process dies on an unhandled child-process error and buries
158
+ * the real cause under a Node stack trace.
159
+ */
160
+ function abortMidRelease(commandLine) {
161
+ abort(
162
+ `\`${commandLine}\` failed — see its output above.\n\n` +
163
+ ' The release stopped partway through. Fix the cause and re-run the same command:\n' +
164
+ ' the steps that already completed are detected and skipped.',
165
+ )
166
+ }
167
+
168
+ // ─────────────────────────────────────────────────────────────────────────────
169
+ // COMMANDS
170
+ // ─────────────────────────────────────────────────────────────────────────────
171
+
172
+ /** Read-only command → trimmed stdout. Throws on a non-zero exit. Always executes. */
173
+ function read(command, args) {
174
+ return execFileSync(command, args, {
175
+ encoding: 'utf8',
176
+ stdio: ['ignore', 'pipe', 'pipe'],
177
+ }).trim()
178
+ }
179
+
180
+ /** Read-only command → trimmed stdout, or null when it exits non-zero. */
181
+ function tryRead(command, args) {
182
+ try {
183
+ return read(command, args)
184
+ } catch {
185
+ return null
186
+ }
187
+ }
188
+
189
+ /**
190
+ * Whether a read-only command exits zero. Separate from `tryRead` because a command can
191
+ * succeed while printing nothing, and "no output" must not read as "failed".
192
+ */
193
+ const succeeds = (command, args) => tryRead(command, args) !== null
194
+
195
+ /** One-line rendering of an argv, so a multi-line arg (release notes) stays readable. */
196
+ const formatCommand = (command, args) =>
197
+ [
198
+ command,
199
+ ...args.map((arg) => {
200
+ const flat = String(arg).replace(/\s+/g, ' ').trim()
201
+ return flat.length > 60 ? `${flat.slice(0, 57)}...` : flat
202
+ }),
203
+ ].join(' ')
204
+
205
+ /** Mutating command. Printed instead of executed under --dry-run. */
206
+ function mutate(command, args, options = {}) {
207
+ const line = formatCommand(command, args)
208
+ if (dryRun) {
209
+ console.log(` ${yellow('would run:')} ${line}`)
210
+ return
211
+ }
212
+ console.log(` ${dim(`$ ${line}`)}`)
213
+ try {
214
+ execFileSync(command, args, { stdio: ['pipe', 'inherit', 'inherit'], ...options })
215
+ } catch {
216
+ abortMidRelease(line)
217
+ }
218
+ }
219
+
220
+ /**
221
+ * Mutating shell command, for configured strings like `publish` that are written as a
222
+ * whole command line rather than an argv. Shell metacharacters are the author's to own.
223
+ */
224
+ function mutateShell(commandLine) {
225
+ if (dryRun) {
226
+ console.log(` ${yellow('would run:')} ${commandLine}`)
227
+ return
228
+ }
229
+ console.log(` ${dim(`$ ${commandLine}`)}`)
230
+ try {
231
+ execSync(commandLine, { stdio: 'inherit' })
232
+ } catch {
233
+ abortMidRelease(commandLine)
234
+ }
235
+ }
236
+
237
+ // ─────────────────────────────────────────────────────────────────────────────
238
+ // SEMVER (the subset a release needs: parse, compare, increment)
239
+ // ─────────────────────────────────────────────────────────────────────────────
240
+
241
+ const SEMVER_RE = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9a-z.-]+))?(?:\+[0-9a-z.-]+)?$/i
242
+
243
+ /** @returns {{major: number, minor: number, patch: number, pre: string[]} | null} */
244
+ function parseVersion(version) {
245
+ const match = SEMVER_RE.exec(version)
246
+ if (!match) return null
247
+ return {
248
+ major: Number(match[1]),
249
+ minor: Number(match[2]),
250
+ patch: Number(match[3]),
251
+ pre: match[4] ? match[4].split('.') : [],
252
+ }
253
+ }
254
+
255
+ /** Precedence comparison per semver §11. @returns negative, 0, or positive. */
256
+ function compareVersions(a, b) {
257
+ const x = parseVersion(a)
258
+ const y = parseVersion(b)
259
+ for (const part of ['major', 'minor', 'patch']) {
260
+ if (x[part] !== y[part]) return x[part] - y[part]
261
+ }
262
+ // A version with a prerelease has lower precedence than one without.
263
+ if (x.pre.length === 0 && y.pre.length === 0) return 0
264
+ if (x.pre.length === 0) return 1
265
+ if (y.pre.length === 0) return -1
266
+
267
+ for (let i = 0; i < Math.max(x.pre.length, y.pre.length); i += 1) {
268
+ const left = x.pre[i]
269
+ const right = y.pre[i]
270
+ if (left === undefined) return -1
271
+ if (right === undefined) return 1
272
+ if (left === right) continue
273
+ const leftNumeric = /^\d+$/.test(left)
274
+ const rightNumeric = /^\d+$/.test(right)
275
+ if (leftNumeric && rightNumeric) return Number(left) - Number(right)
276
+ // Numeric identifiers always have lower precedence than alphanumeric ones.
277
+ if (leftNumeric !== rightNumeric) return leftNumeric ? -1 : 1
278
+ return left < right ? -1 : 1
279
+ }
280
+ return 0
281
+ }
282
+
283
+ /**
284
+ * Increment a version, matching `semver.inc` for the bumps a release uses.
285
+ *
286
+ * A major/minor/patch bump off a prerelease releases that prerelease's base version when
287
+ * the base already satisfies the bump (1.2.3-beta.1 + patch → 1.2.3), which is what makes
288
+ * "promote the release candidate" a plain `patch`.
289
+ *
290
+ * @param {string} version
291
+ * @param {'major'|'minor'|'patch'|'premajor'|'preminor'|'prepatch'|'prerelease'} bump
292
+ * @param {string | null | undefined} preid
293
+ */
294
+ function incrementVersion(version, bump, preid) {
295
+ const { major, minor, patch, pre } = parseVersion(version)
296
+ const base = (m, n, p) => `${m}.${n}.${p}`
297
+
298
+ switch (bump) {
299
+ case 'major':
300
+ if (pre.length && minor === 0 && patch === 0) return base(major, 0, 0)
301
+ return base(major + 1, 0, 0)
302
+ case 'minor':
303
+ if (pre.length && patch === 0) return base(major, minor, 0)
304
+ return base(major, minor + 1, 0)
305
+ case 'patch':
306
+ if (pre.length) return base(major, minor, patch)
307
+ return base(major, minor, patch + 1)
308
+ case 'premajor':
309
+ return `${base(major + 1, 0, 0)}-${preid}.0`
310
+ case 'preminor':
311
+ return `${base(major, minor + 1, 0)}-${preid}.0`
312
+ case 'prepatch':
313
+ return `${base(major, minor, patch + 1)}-${preid}.0`
314
+ case 'prerelease': {
315
+ if (pre.length && pre[0] === preid && /^\d+$/.test(pre.at(-1))) {
316
+ const next = [...pre]
317
+ next[next.length - 1] = String(Number(next.at(-1)) + 1)
318
+ return `${base(major, minor, patch)}-${next.join('.')}`
319
+ }
320
+ // Switching channel, or coming from a stable version: start the channel at .0.
321
+ if (pre.length) return `${base(major, minor, patch)}-${preid}.0`
322
+ return `${base(major, minor, patch + 1)}-${preid}.0`
323
+ }
324
+ default:
325
+ throw new Error(`unknown bump: ${bump}`)
326
+ }
327
+ }
328
+
329
+ /** The prerelease identifier of a version, or null when it is stable. */
330
+ function preidOf(version) {
331
+ const { pre } = parseVersion(version)
332
+ if (!pre.length) return null
333
+ return /^\d+$/.test(pre[0]) ? null : pre[0].toLowerCase()
334
+ }
335
+
336
+ /**
337
+ * The npm dist-tag a version publishes under.
338
+ *
339
+ * 1.2.3 → latest
340
+ * 1.2.3-beta.4 → beta (any identifier in KNOWN_CHANNELS)
341
+ * 1.2.3-17512… → canary (an all-numeric prerelease is a timestamp)
342
+ * 1.2.3-lol.0 → throws (never silently falls through to latest)
343
+ */
344
+ function distTagFor(version, explicitTag) {
345
+ if (explicitTag) return explicitTag
346
+ const { pre } = parseVersion(version)
347
+ if (!pre.length) return 'latest'
348
+ const label = String(pre[0]).toLowerCase()
349
+ if (/^\d+$/.test(label)) return 'canary'
350
+ if (KNOWN_CHANNELS.has(label)) return label
351
+ throw new Error(
352
+ `prerelease identifier "${label}" maps to no known dist-tag ` +
353
+ `(${[...KNOWN_CHANNELS].sort().join(', ')}). Publishing it as "latest" would ` +
354
+ `clobber the stable line — pass --tag <dist-tag> to choose one explicitly.`,
355
+ )
356
+ }
357
+
358
+ // ─────────────────────────────────────────────────────────────────────────────
359
+ // CHANGELOG
360
+ // ─────────────────────────────────────────────────────────────────────────────
361
+
362
+ const escapeRe = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
363
+
364
+ /**
365
+ * The body of a changelog's section for one version, up to the next `##` heading or `---`
366
+ * rule. Matches every common heading shape: `## [1.2.3] - 2026-08-17`, `## v1.2.3`,
367
+ * `## 1.2.3 (2026-08-17)`.
368
+ *
369
+ * @returns {string | null} the section body, or null when there is no such section
370
+ */
371
+ function changelogSection(text, version) {
372
+ const heading = new RegExp(`^##\\s+\\[?v?${escapeRe(version)}\\]?(?![\\w.-])[^\\n]*$`, 'm')
373
+ const match = heading.exec(text)
374
+ if (!match) return null
375
+ const rest = text.slice(match.index + match[0].length)
376
+ const end = /^(?:## |---\s*$)/m.exec(rest)
377
+ const body = (end ? rest.slice(0, end.index) : rest).trim()
378
+ return body || null
379
+ }
380
+
381
+ /**
382
+ * Rewrite a `## [Unreleased]` heading as the released version, and open a fresh
383
+ * `## [Unreleased]` above it for the next cycle.
384
+ *
385
+ * @returns {string | null} the updated document, or null when there is nothing to roll
386
+ */
387
+ function rollUnreleased(text, version, date) {
388
+ const heading = /^##\s+\[?Unreleased\]?[^\n]*$/im
389
+ const match = heading.exec(text)
390
+ if (!match) return null
391
+ const released = `## [Unreleased]\n\n## [${version}] - ${date}`
392
+ return text.slice(0, match.index) + released + text.slice(match.index + match[0].length)
393
+ }
394
+
395
+ // ─────────────────────────────────────────────────────────────────────────────
396
+ // JSON FILES
397
+ // ─────────────────────────────────────────────────────────────────────────────
398
+
399
+ const readJson = (path) => JSON.parse(readFileSync(path, 'utf8'))
400
+
401
+ /**
402
+ * Write a top-level "version" into a JSON file without reformatting the rest of it: the
403
+ * value is replaced in place, so key order, indentation and trailing newline all survive.
404
+ *
405
+ * @returns {boolean} whether the file needed changing
406
+ */
407
+ function writeVersionInto(path, version) {
408
+ const text = readFileSync(path, 'utf8')
409
+ const field = /^(\s*"version"\s*:\s*)"[^"]*"/m
410
+ if (!field.test(text)) throw new Error(`${path} has no top-level "version" field`)
411
+ const updated = text.replace(field, `$1"${version}"`)
412
+ if (updated === text) return false
413
+ if (!dryRun) writeFileSync(path, updated)
414
+ return true
415
+ }
416
+
417
+ // ─────────────────────────────────────────────────────────────────────────────
418
+ // ARGUMENTS
419
+ // ─────────────────────────────────────────────────────────────────────────────
420
+
421
+ const argv = process.argv.slice(2)
422
+ const BUMPS = new Set(['major', 'minor', 'patch', 'premajor', 'preminor', 'prepatch', 'prerelease'])
423
+
424
+ const flag = (name) => argv.includes(name)
425
+ const option = (name) => {
426
+ const index = argv.indexOf(name)
427
+ return index === -1 ? undefined : argv[index + 1]
428
+ }
429
+
430
+ const dryRun = flag('--dry-run')
431
+ const assumeYes = flag('--yes') || flag('-y')
432
+ const skipPublish = flag('--skip-publish')
433
+ const skipRelease = flag('--skip-release')
434
+ const explicitDistTag = option('--tag')
435
+ const requestedPreid = option('--preid')
436
+
437
+ if (flag('--help') || flag('-h')) {
438
+ console.log(USAGE)
439
+ process.exit(0)
440
+ }
441
+
442
+ // --sync copies this file into other projects and exits; it touches no git state.
443
+ if (flag('--sync')) {
444
+ const self = new URL(import.meta.url).pathname
445
+ const targets = argv.slice(argv.indexOf('--sync') + 1).filter((a) => !a.startsWith('-'))
446
+ if (!targets.length) abort('--sync needs at least one project directory')
447
+
448
+ const source = readFileSync(self, 'utf8')
449
+ for (const target of targets) {
450
+ const projectRoot = resolve(target)
451
+ const destination = join(projectRoot, 'scripts', basename(self))
452
+ if (destination === self) continue
453
+ if (!existsSync(join(projectRoot, 'package.json'))) {
454
+ warn(`${target}: no package.json — skipped`)
455
+ continue
456
+ }
457
+ const current = existsSync(destination) ? readFileSync(destination, 'utf8') : null
458
+ if (current === source) {
459
+ note(`${target}: already up to date`)
460
+ continue
461
+ }
462
+ if (!dryRun) {
463
+ mkdirSync(join(projectRoot, 'scripts'), { recursive: true })
464
+ writeFileSync(destination, source)
465
+ }
466
+ ok(`${target}: ${current === null ? 'installed' : 'updated'}${dryRun ? ' (dry run)' : ''}`)
467
+ const scripts = readJson(join(projectRoot, 'package.json')).scripts ?? {}
468
+ if (!scripts.release) {
469
+ warn(`${target}: add "release": "node scripts/${basename(self)}" to package.json`)
470
+ }
471
+ }
472
+ process.exit(0)
473
+ }
474
+
475
+ const target = argv.find((a) => !a.startsWith('-') && a !== explicitDistTag && a !== requestedPreid)
476
+
477
+ // ─────────────────────────────────────────────────────────────────────────────
478
+ // SETUP
479
+ // ─────────────────────────────────────────────────────────────────────────────
480
+
481
+ const root = tryRead('git', ['rev-parse', '--show-toplevel'])
482
+ if (!root) abort('not inside a git repository')
483
+ process.chdir(root)
484
+
485
+ if (!existsSync('package.json')) abort(`no package.json at ${root}`)
486
+ const pkg = readJson('package.json')
487
+ if (!pkg.version) abort('package.json has no "version" field')
488
+ if (!parseVersion(pkg.version)) abort(`package.json version "${pkg.version}" is not semver`)
489
+
490
+ const config = {
491
+ ...DEFAULTS,
492
+ ...(existsSync('release.config.json') ? readJson('release.config.json') : {}),
493
+ }
494
+ const unknownKeys = Object.keys(config).filter((key) => !(key in DEFAULTS))
495
+ if (unknownKeys.length) abort(`release.config.json has unknown keys: ${unknownKeys.join(', ')}`)
496
+
497
+ // ─────────────────────────────────────────────────────────────────────────────
498
+ // RESOLVE THE TARGET VERSION
499
+ // ─────────────────────────────────────────────────────────────────────────────
500
+
501
+ console.log(
502
+ bold(`${pkg.name} release`) + (dryRun ? ` ${yellow('(dry run — nothing will execute)')}` : ''),
503
+ )
504
+
505
+ let version
506
+ if (!target) {
507
+ ;({ version } = pkg)
508
+ } else if (BUMPS.has(target)) {
509
+ const preid = requestedPreid ?? preidOf(pkg.version)
510
+ if (target.startsWith('pre') && !preid) {
511
+ abort(
512
+ `a ${target} bump from a stable version needs --preid <${[...KNOWN_CHANNELS].sort().join('|')}>`,
513
+ )
514
+ }
515
+ version = incrementVersion(pkg.version, target, preid)
516
+ } else if (parseVersion(target)) {
517
+ version = target
518
+ } else {
519
+ abort(`"${target}" is neither a semver version nor a bump (${[...BUMPS].join(', ')})`)
520
+ }
521
+
522
+ const tag = `${config.tagPrefix}${version}`
523
+ const isPrerelease = parseVersion(version).pre.length > 0
524
+ const bumping = version !== pkg.version
525
+
526
+ let distTag
527
+ try {
528
+ distTag = distTagFor(version, explicitDistTag)
529
+ } catch (err) {
530
+ abort(err.message)
531
+ }
532
+
533
+ const expandWith = (template, transform) =>
534
+ template
535
+ .replaceAll('%v', transform(version))
536
+ .replaceAll('%t', transform(tag))
537
+ .replaceAll('%n', transform(pkg.name))
538
+ .replaceAll('%d', transform(distTag))
539
+
540
+ /** Expand tokens for a message or title, which never reaches a shell. */
541
+ const expand = (template) => expandWith(template, (value) => value)
542
+
543
+ /**
544
+ * Expand tokens for the `publish` command line, which does reach a shell. The values are
545
+ * single-quoted so a version or dist-tag carrying shell metacharacters (a crafted
546
+ * package.json, a hand-typed `--tag`) is passed through as one literal argument.
547
+ */
548
+ const shellQuote = (value) => `'${value.replaceAll("'", `'\\''`)}'`
549
+ const expandShell = (template) => expandWith(template, shellQuote)
550
+
551
+ const publishCommand = config.publish && !skipPublish ? expandShell(config.publish) : null
552
+
553
+ /**
554
+ * npm and pnpm answer `whoami` and `view` identically and share `~/.npmrc`, so whichever
555
+ * one publishes can also run the registry preflight. Checking with the wrong one mislabels
556
+ * the result. A publish command driving anything else (vsce, a shell pipeline) is left
557
+ * alone — it cannot be introspected, and guessing would invent failures.
558
+ */
559
+ const REGISTRY_CLIS = new Set(['npm', 'pnpm'])
560
+ const publishCli = publishCommand?.trim().split(/\s+/)[0]
561
+ const registryCli = REGISTRY_CLIS.has(publishCli) ? publishCli : null
562
+
563
+ /**
564
+ * CI publishing over OIDC ("trusted publishing") carries no token at all: `whoami` fails
565
+ * while `publish` succeeds. Demanding a login there would abort a perfectly valid release.
566
+ * GitHub Actions exposes the OIDC request variables; GitLab CI and CircleCI set
567
+ * NPM_ID_TOKEN. See https://docs.npmjs.com/trusted-publishers
568
+ */
569
+ const isTrustedPublishing =
570
+ (process.env.GITHUB_ACTIONS === 'true' &&
571
+ !!process.env.ACTIONS_ID_TOKEN_REQUEST_URL &&
572
+ !!process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN) ||
573
+ !!process.env.NPM_ID_TOKEN
574
+
575
+ console.log(` ${dim(`${pkg.version} → ${version} tag ${tag} dist-tag ${distTag}`)}`)
576
+
577
+ // ─────────────────────────────────────────────────────────────────────────────
578
+ // PREFLIGHT — every check runs, then it aborts once with all of the failures
579
+ // ─────────────────────────────────────────────────────────────────────────────
580
+
581
+ step('Preflight')
582
+
583
+ const problems = []
584
+ const fail = (message) => {
585
+ console.log(` ${red('fail')} ${message}`)
586
+ problems.push(message)
587
+ }
588
+
589
+ if (bumping && compareVersions(version, pkg.version) <= 0) {
590
+ fail(`${version} is not greater than the current version ${pkg.version}`)
591
+ } else if (bumping) {
592
+ ok(`version ${pkg.version} → ${version}`)
593
+ } else {
594
+ ok(`releasing the version already in package.json (${version})`)
595
+ }
596
+
597
+ const dirty = tryRead('git', ['status', '--porcelain'])
598
+ if (dirty === null) fail('could not read git status')
599
+ else if (dirty) fail(`working tree is not clean:\n${indent(formatStatus(dirty))}`)
600
+ else ok('working tree clean')
601
+
602
+ const branch = tryRead('git', ['rev-parse', '--abbrev-ref', 'HEAD'])
603
+ if (!branch) fail('could not read the current branch')
604
+ else if (config.branch && branch !== config.branch) {
605
+ fail(`on '${branch}', expected '${config.branch}'`)
606
+ } else ok(`on ${branch}`)
607
+
608
+ if (!succeeds('git', ['remote', 'get-url', config.remote])) {
609
+ fail(`no '${config.remote}' remote configured`)
610
+ } else {
611
+ ok(`remote ${config.remote}`)
612
+ // Fetch so the tag and behind-remote checks below see the real remote state.
613
+ if (!succeeds('git', ['fetch', '--quiet', '--tags', config.remote])) {
614
+ fail(`could not fetch from ${config.remote}`)
615
+ } else if (branch) {
616
+ const upstream = `${config.remote}/${branch}`
617
+ if (!succeeds('git', ['rev-parse', '--verify', '--quiet', `refs/remotes/${upstream}`])) {
618
+ note(`${upstream} does not exist yet — the push will create it`)
619
+ } else {
620
+ const behind = tryRead('git', ['rev-list', '--count', `HEAD..${upstream}`])
621
+ if (behind === null) fail(`could not compare HEAD with ${upstream}`)
622
+ else if (behind !== '0') fail(`${behind} commit(s) behind ${upstream} — pull first`)
623
+ else ok(`up to date with ${upstream}`)
624
+ }
625
+ }
626
+ }
627
+
628
+ const head = tryRead('git', ['rev-parse', 'HEAD'])
629
+ const taggedCommit = tryRead('git', ['rev-list', '-n', '1', tag])
630
+ if (taggedCommit && bumping) {
631
+ fail(`tag ${tag} already exists — release a different version`)
632
+ } else if (taggedCommit && taggedCommit !== head) {
633
+ fail(`tag ${tag} already exists at ${taggedCommit.slice(0, 8)}, not at HEAD`)
634
+ } else if (taggedCommit) {
635
+ ok(`tag ${tag} already exists at HEAD — will reuse it`)
636
+ } else {
637
+ ok(`tag ${tag} is free`)
638
+ }
639
+
640
+ let releaseExists = false
641
+ if (skipRelease) {
642
+ note('GitHub release skipped (--skip-release)')
643
+ } else if (!succeeds('gh', ['--version'])) {
644
+ fail('the GitHub CLI (`gh`) is not installed — https://cli.github.com')
645
+ } else if (!succeeds('gh', ['auth', 'status'])) {
646
+ fail('`gh` is not authenticated — run `gh auth login`')
647
+ } else {
648
+ ok(`gh authenticated (${tryRead('gh', ['api', 'user', '--jq', '.login']) || 'unknown user'})`)
649
+ releaseExists = succeeds('gh', ['release', 'view', tag])
650
+ if (releaseExists) note(`a GitHub release for ${tag} already exists — will skip that step`)
651
+ }
652
+
653
+ let alreadyPublished = false
654
+ if (!publishCommand) {
655
+ note(skipPublish ? 'publish skipped (--skip-publish)' : 'publish disabled in config')
656
+ } else if (pkg.private) {
657
+ fail('package.json is private but a publish command is configured')
658
+ } else if (!registryCli) {
659
+ ok(`publish: ${publishCommand}`)
660
+ } else {
661
+ if (isTrustedPublishing) {
662
+ ok('trusted publishing (OIDC) — no token needed')
663
+ } else {
664
+ const user = tryRead(registryCli, ['whoami'])
665
+ if (user === null) {
666
+ // npm replaced long-lived tokens with two-hour sessions in December 2025, so the
667
+ // usual cause is an expired session rather than a missing login.
668
+ fail(
669
+ `${registryCli} is not authenticated — run \`${registryCli} login\`. ` +
670
+ 'npm logins are two-hour sessions, so an earlier one may have expired.',
671
+ )
672
+ } else ok(`${registryCli} authenticated (${user || 'unknown user'})`)
673
+ }
674
+ alreadyPublished = succeeds(registryCli, ['view', `${pkg.name}@${version}`, 'version'])
675
+ if (alreadyPublished) {
676
+ note(`${pkg.name}@${version} is already on the registry — will skip publishing`)
677
+ }
678
+ }
679
+
680
+ // Notes: the changelog section for this version, else GitHub generates them from commits.
681
+ let notes = null
682
+ let rolledChangelog = null
683
+ if (config.changelog && existsSync(config.changelog)) {
684
+ const text = readFileSync(config.changelog, 'utf8')
685
+ notes = changelogSection(text, version)
686
+ if (notes) {
687
+ ok(`${config.changelog} has a ${version} section`)
688
+ } else {
689
+ rolledChangelog = rollUnreleased(text, version, new Date().toISOString().slice(0, 10))
690
+ if (rolledChangelog) {
691
+ notes = changelogSection(rolledChangelog, version)
692
+ ok(`${config.changelog}: [Unreleased] will become [${version}]`)
693
+ } else {
694
+ warn(
695
+ `${config.changelog} has no ${version} or [Unreleased] section — GitHub will generate the notes`,
696
+ )
697
+ }
698
+ }
699
+ } else if (config.changelog) {
700
+ note(`no ${config.changelog} — GitHub will generate the notes`)
701
+ }
702
+
703
+ for (const asset of config.assets) {
704
+ if (existsSync(asset)) ok(`asset ${asset}`)
705
+ else fail(`asset ${asset} does not exist`)
706
+ }
707
+
708
+ if (problems.length) {
709
+ const summary = `${problems.length} preflight check(s) failed:\n - ${problems.join('\n - ')}`
710
+ if (!dryRun) abort(summary)
711
+ console.log(
712
+ `\n ${yellow('dry run: the above would abort here — showing the remaining steps anyway')}`,
713
+ )
714
+ }
715
+
716
+ // ─────────────────────────────────────────────────────────────────────────────
717
+ // CONFIRM
718
+ // ─────────────────────────────────────────────────────────────────────────────
719
+
720
+ if (!assumeYes && !dryRun && process.stdin.isTTY) {
721
+ if (notes) console.log(`\n${bold('Release notes')}\n${indent(notes)}`)
722
+ const rl = createInterface({ input: process.stdin, output: process.stdout })
723
+ let answer = ''
724
+ try {
725
+ answer = await rl.question(`\nRelease ${bold(tag)} of ${pkg.name}? [y/N] `)
726
+ } catch {
727
+ // Ctrl+C or Ctrl+D at the prompt rejects the question. That is a decline, not a
728
+ // crash — without this it exits on an unhandled AbortError and a stack trace.
729
+ } finally {
730
+ rl.close()
731
+ }
732
+ if (!/^y(es)?$/i.test(answer.trim())) abort('cancelled')
733
+ }
734
+
735
+ // ─────────────────────────────────────────────────────────────────────────────
736
+ // RELEASE
737
+ // ─────────────────────────────────────────────────────────────────────────────
738
+
739
+ const staged = []
740
+
741
+ if (bumping) {
742
+ step(`Write version ${version}`)
743
+ for (const file of ['package.json', ...config.versionFiles]) {
744
+ if (!existsSync(file)) abort(`versionFiles entry ${file} does not exist`)
745
+ if (writeVersionInto(file, version)) {
746
+ staged.push(file)
747
+ console.log(` ${dryRun ? yellow('would write') : dim('wrote')} ${file}`)
748
+ }
749
+ }
750
+ // A package-lock.json embeds the root version twice, so it goes stale on a bump.
751
+ if (existsSync('package-lock.json')) {
752
+ mutate('npm', ['install', '--package-lock-only', '--ignore-scripts', '--silent'])
753
+ staged.push('package-lock.json')
754
+ }
755
+ }
756
+
757
+ if (rolledChangelog) {
758
+ step(`Roll ${config.changelog} to ${version}`)
759
+ if (dryRun) console.log(` ${yellow('would write')} ${config.changelog}`)
760
+ else writeFileSync(config.changelog, rolledChangelog)
761
+ staged.push(config.changelog)
762
+ }
763
+
764
+ if (staged.length) {
765
+ step('Commit')
766
+ mutate('git', ['add', '--', ...staged])
767
+ mutate('git', ['commit', '-m', expand(config.commitMessage)])
768
+ }
769
+
770
+ if (!taggedCommit) {
771
+ step(`Annotated tag ${tag}`)
772
+ // The notes become the tag annotation too, so a CI release workflow can read them
773
+ // straight off the tag instead of re-deriving them. --cleanup=verbatim is required:
774
+ // git's default strips every line starting with '#', which would silently eat the
775
+ // markdown headings out of the notes.
776
+ mutate('git', [
777
+ 'tag',
778
+ '-a',
779
+ tag,
780
+ '--cleanup=verbatim',
781
+ '-m',
782
+ `${notes ?? `${pkg.name} ${tag}`}\n`,
783
+ ])
784
+ }
785
+
786
+ step(`Push branch and tag to ${config.remote}`)
787
+ // --follow-tags sends the commit and the tag in one call; pushing them separately is how
788
+ // a tag ends up on the remote without its commit, or a release without its tag.
789
+ mutate('git', ['push', '--follow-tags', config.remote, branch ?? 'HEAD'])
790
+
791
+ if (publishCommand && !alreadyPublished) {
792
+ step(`Publish to the registry (dist-tag ${distTag})`)
793
+ mutateShell(publishCommand)
794
+ }
795
+
796
+ if (!skipRelease && !releaseExists) {
797
+ step(`GitHub release ${tag}`)
798
+ const args = [
799
+ 'release',
800
+ 'create',
801
+ tag,
802
+ '--title',
803
+ expand(config.releaseTitle),
804
+ isPrerelease ? '--prerelease' : '--latest',
805
+ // Notes arrive on stdin, so there is no temp file and nothing to escape.
806
+ ...(notes ? ['--notes-file', '-'] : ['--generate-notes']),
807
+ ...config.assets,
808
+ ]
809
+ mutate('gh', args, notes ? { input: `${notes}\n` } : {})
810
+ }
811
+
812
+ console.log(
813
+ `\n${green(bold(dryRun ? 'Dry run complete — nothing was changed.' : `Released ${tag}`))}`,
814
+ )
815
+ if (dryRun) note('Run the same command without --dry-run to execute.')