@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.
- package/LICENSE +21 -0
- package/README.md +288 -0
- package/package.json +54 -0
- 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.')
|