@entro314labs/release-kit 2.0.0 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +114 -21
- package/package.json +2 -2
- package/release.mjs +212 -48
package/README.md
CHANGED
|
@@ -56,6 +56,7 @@ Released v2.5.0
|
|
|
56
56
|
| [⚡ Usage](#-usage) | targets, bumps, flags |
|
|
57
57
|
| [🧩 Steps](#-steps) | the seven steps and how to select them |
|
|
58
58
|
| [🤖 Assistant](#-assistant-optional) | optional AI drafting |
|
|
59
|
+
| [🌍 Any language](#-any-language) | Rust, Python, tag-only, anything |
|
|
59
60
|
| [✅ Preflight](#-preflight) | what is checked before anything mutates |
|
|
60
61
|
| [♻️ Recovering from a failed run](#️-recovering-from-a-failed-run) | why re-running is safe |
|
|
61
62
|
| [⚙️ Configuration](#️-configuration) | `release.config.json`, publishing, auth |
|
|
@@ -64,45 +65,70 @@ Released v2.5.0
|
|
|
64
65
|
|
|
65
66
|
## 📦 Install
|
|
66
67
|
|
|
67
|
-
|
|
68
|
+
Pick by what the project is, not by preference.
|
|
69
|
+
|
|
70
|
+
### Node projects — devDependency
|
|
71
|
+
|
|
72
|
+
Pins the version, so every machine and CI run behave identically.
|
|
68
73
|
|
|
69
74
|
```sh
|
|
70
75
|
pnpm add -D @entro314labs/release-kit
|
|
71
76
|
```
|
|
72
77
|
|
|
73
78
|
```json
|
|
74
|
-
{
|
|
75
|
-
"scripts": {
|
|
76
|
-
"release": "release-kit"
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
+
{ "scripts": { "release": "release-kit" } }
|
|
79
80
|
```
|
|
80
81
|
|
|
81
|
-
|
|
82
|
-
|
|
82
|
+
### Non-Node projects — global install
|
|
83
|
+
|
|
84
|
+
A Rust, Python or Go repository has no manifest to hang a devDependency on, so install it
|
|
85
|
+
once and use it everywhere.
|
|
83
86
|
|
|
84
87
|
```sh
|
|
85
|
-
|
|
88
|
+
npm i -g @entro314labs/release-kit
|
|
89
|
+
release-kit minor
|
|
86
90
|
```
|
|
87
91
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
92
|
+
### CI, any language — pinned npx
|
|
93
|
+
|
|
94
|
+
No global state to drift, no install step, and the version is explicit in the command.
|
|
95
|
+
|
|
96
|
+
```sh
|
|
97
|
+
npx @entro314labs/release-kit@2.1.0 minor --yes
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### Vendored — no registry at release time
|
|
101
|
+
|
|
102
|
+
For a project that should not depend on the registry it is about to publish to, or that
|
|
103
|
+
needs releases to work offline. The file is self-contained, so a copy is a complete install.
|
|
91
104
|
|
|
92
105
|
```sh
|
|
93
|
-
npx @entro314labs/release-kit --sync .
|
|
106
|
+
npx @entro314labs/release-kit --sync . # writes scripts/release.mjs
|
|
94
107
|
```
|
|
95
108
|
|
|
96
109
|
```json
|
|
97
|
-
{
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
110
|
+
{ "scripts": { "release": "node scripts/release.mjs" } }
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
### Piped — nothing installed at all
|
|
114
|
+
|
|
115
|
+
`release.mjs` runs straight from stdin, arguments and all. Useful for a one-off release on a
|
|
116
|
+
machine you do not want to install anything on.
|
|
117
|
+
|
|
118
|
+
```sh
|
|
119
|
+
curl -fsSL https://raw.githubusercontent.com/entro314-labs/release-kit/v2.1.0/release.mjs \
|
|
120
|
+
| node - minor --yes
|
|
102
121
|
```
|
|
103
122
|
|
|
104
|
-
|
|
105
|
-
|
|
123
|
+
Pin the URL to a tag, never `main`: piping an unpinned remote script into an interpreter
|
|
124
|
+
means whatever is at that URL runs against your repository and your credentials. `--sync` is
|
|
125
|
+
the one thing that does not work this way — copying itself needs a file on disk.
|
|
126
|
+
|
|
127
|
+
> **All five paths run the same file and need Node 18+.** That includes the Rust, Python and
|
|
128
|
+
> Go projects: `release-kit` is a Node program regardless of what it is releasing.
|
|
129
|
+
|
|
130
|
+
Zero-config works on the conventions below; add a [`release.config.json`](#️-configuration)
|
|
131
|
+
only for what differs.
|
|
106
132
|
|
|
107
133
|
## ⚡ Usage
|
|
108
134
|
|
|
@@ -223,6 +249,7 @@ rather than stopping at the first problem.
|
|
|
223
249
|
- The remote exists, is reachable, and the branch is not behind it
|
|
224
250
|
- The tag is free — or already exists at `HEAD`, in which case it is reused
|
|
225
251
|
- `gh` is installed and authenticated
|
|
252
|
+
- Commit and tag signing can actually sign, when `commit.gpgsign` or `tag.gpgsign` is on
|
|
226
253
|
- The publishing CLI is authenticated, and the version is not already on the registry
|
|
227
254
|
- Configured release assets exist
|
|
228
255
|
- A changelog section for the version exists _(a warning, not a failure — it falls back
|
|
@@ -249,6 +276,54 @@ it stopped. There is no cleanup step, no `--resume`, and nothing to remember.
|
|
|
249
276
|
The one case that is not recoverable by re-running is a tag that exists at a _different_
|
|
250
277
|
commit than `HEAD`. That is a genuine conflict, and it aborts rather than guessing.
|
|
251
278
|
|
|
279
|
+
## 🌍 Any language
|
|
280
|
+
|
|
281
|
+
Only one step is Node-specific: `publish`. Committing, changelog rolling, tagging, pushing
|
|
282
|
+
and GitHub releases are the same everywhere, so `versionFile` points at wherever a project
|
|
283
|
+
keeps its version and the rest works unchanged.
|
|
284
|
+
|
|
285
|
+
| Project | Config |
|
|
286
|
+
| ------------------------------ | ------------------------------------------------------------------------------- |
|
|
287
|
+
| Node (npm) | nothing — `package.json` and `npm publish` are the defaults |
|
|
288
|
+
| Node (pnpm / bun) | `{"publish": "pnpm publish --tag %d"}` or `{"publish": "bun publish --tag %d"}` |
|
|
289
|
+
| Rust | `{"versionFile": "Cargo.toml", "publish": "cargo publish"}` |
|
|
290
|
+
| Python | `{"versionFile": "pyproject.toml", "publish": "uv publish"}` |
|
|
291
|
+
| Go | `{"versionFile": null, "publish": "go list -m %n@%t"}` — the tag is the release |
|
|
292
|
+
| Anything with a `VERSION` file | `{"versionFile": "VERSION", "publish": null}` |
|
|
293
|
+
| Versioned only by tag | `{"versionFile": null}`, then `release-kit 1.2.3` |
|
|
294
|
+
|
|
295
|
+
The publish step also gets a preflight when the command is one it recognises:
|
|
296
|
+
|
|
297
|
+
| Publish command | Authentication | Already published? |
|
|
298
|
+
| --------------- | ------------------------------------- | ----------------------------------- |
|
|
299
|
+
| `npm` / `pnpm` | `whoami` | `view <name>@<version>` |
|
|
300
|
+
| `bun` | `bun pm whoami` | `bun pm view <name>@<version>` |
|
|
301
|
+
| `uv` | `UV_PUBLISH_TOKEN` in the environment | none — `uv` skips duplicates itself |
|
|
302
|
+
| `go` | none needed | `go list -m <module>@<tag>` |
|
|
303
|
+
|
|
304
|
+
Anything else runs as written with no preflight. The project name comes from the manifest —
|
|
305
|
+
`name` in `package.json`, `Cargo.toml` or `pyproject.toml`, `module` in `go.mod` — falling
|
|
306
|
+
back to the repository directory.
|
|
307
|
+
|
|
308
|
+
The format is inferred from the file name: `.json` reads the `"version"` field, `.toml`
|
|
309
|
+
reads the first `version = "x.y.z"` line, and any other file is treated as containing just
|
|
310
|
+
the version. Only the version itself is rewritten, so comments and formatting survive — and
|
|
311
|
+
because the TOML match is anchored to the start of a line, a dependency's
|
|
312
|
+
`serde = { version = "1.0" }` is left alone.
|
|
313
|
+
|
|
314
|
+
For anything else, give a pattern with one capture group around the version. `versionFiles`
|
|
315
|
+
takes the same entries, so several files stay in sync across formats:
|
|
316
|
+
|
|
317
|
+
```json
|
|
318
|
+
{
|
|
319
|
+
"versionFile": { "path": "version.go", "pattern": "^const Version = \"(.+)\"" },
|
|
320
|
+
"versionFiles": [{ "path": "Chart.yaml", "pattern": "^version: (.+)$" }]
|
|
321
|
+
}
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
The project name comes from the manifest when there is one (`name` in `package.json`,
|
|
325
|
+
`Cargo.toml` or `pyproject.toml`), and falls back to the repository directory.
|
|
326
|
+
|
|
252
327
|
## ⚙️ Configuration
|
|
253
328
|
|
|
254
329
|
`release.config.json`, beside `package.json`. Every key is optional; unknown keys abort
|
|
@@ -271,6 +346,23 @@ name, `%d` npm dist-tag. In the `publish` command line the substituted values ar
|
|
|
271
346
|
shell-quoted, so a version carrying shell metacharacters is passed through as one literal
|
|
272
347
|
argument.
|
|
273
348
|
|
|
349
|
+
### Signing
|
|
350
|
+
|
|
351
|
+
Signing is git's, not this tool's: commits and tags are made with plain `git commit` and
|
|
352
|
+
`git tag`, so they are signed exactly when `commit.gpgsign` and `tag.gpgsign` say to, with
|
|
353
|
+
whatever key `user.signingkey` resolves to. There is no key handling here to get wrong.
|
|
354
|
+
|
|
355
|
+
What it does add is a preflight check, because an unusable key otherwise fails at the commit
|
|
356
|
+
step with the version already written. For CI, where a signing key usually is not present,
|
|
357
|
+
disable signing for that run rather than configuring keys:
|
|
358
|
+
|
|
359
|
+
```sh
|
|
360
|
+
git -c commit.gpgsign=false -c tag.gpgsign=false release-kit minor --yes
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
For commits to show as **Verified** on GitHub, the SSH key must be registered as a _signing_
|
|
364
|
+
key in your account, which is a separate list from authentication keys.
|
|
365
|
+
|
|
274
366
|
### Publishing and authentication
|
|
275
367
|
|
|
276
368
|
The registry preflight (`whoami`, the already-published lookup) runs with whichever CLI the
|
|
@@ -396,7 +488,8 @@ including a directory that is not a repository.
|
|
|
396
488
|
|
|
397
489
|
## 📋 Requirements
|
|
398
490
|
|
|
399
|
-
- Node 18+
|
|
491
|
+
- **Node 18+ — including for Rust, Python and Go projects.** `release-kit` is a Node
|
|
492
|
+
program whatever it releases; there is no standalone binary.
|
|
400
493
|
- `git`
|
|
401
494
|
- `gh`, authenticated — only when creating GitHub releases
|
|
402
495
|
- Whatever the `publish` command needs — for the default, a live `npm login` session
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@entro314labs/release-kit",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.2.0",
|
|
4
4
|
"description": "Single-file, zero-dependency release mechanism for JS/TS/Node projects: version bump, changelog roll, commit, annotated tag, push, publish, GitHub release",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"changelog",
|
|
@@ -49,6 +49,6 @@
|
|
|
49
49
|
"oxlint": "^1.78.0"
|
|
50
50
|
},
|
|
51
51
|
"engines": {
|
|
52
|
-
"node": ">=
|
|
52
|
+
"node": ">=22"
|
|
53
53
|
}
|
|
54
54
|
}
|
package/release.mjs
CHANGED
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
|
|
34
34
|
import { execFileSync, execSync } from 'node:child_process'
|
|
35
35
|
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
|
|
36
|
-
import { tmpdir } from 'node:os'
|
|
36
|
+
import { homedir, tmpdir } from 'node:os'
|
|
37
37
|
import { basename, join, relative, resolve, sep } from 'node:path'
|
|
38
38
|
import { createInterface } from 'node:readline/promises'
|
|
39
39
|
|
|
@@ -51,7 +51,10 @@ import { createInterface } from 'node:readline/promises'
|
|
|
51
51
|
* branch string the only branch a release may run from; null to allow any
|
|
52
52
|
* remote string git remote to push to
|
|
53
53
|
* changelog string changelog path; null to disable changelog handling
|
|
54
|
-
*
|
|
54
|
+
* versionFile string|object|null where the project's version lives; null when the
|
|
55
|
+
* repository versions by git tag alone
|
|
56
|
+
* versionFiles array further files whose version is kept in sync; each is a path
|
|
57
|
+
* or { path, pattern }
|
|
55
58
|
* publish string publish command; null to skip publishing entirely
|
|
56
59
|
* commitMessage string release commit subject
|
|
57
60
|
* releaseTitle string GitHub release title
|
|
@@ -88,6 +91,7 @@ const DEFAULTS = {
|
|
|
88
91
|
branch: 'main',
|
|
89
92
|
remote: 'origin',
|
|
90
93
|
changelog: 'CHANGELOG.md',
|
|
94
|
+
versionFile: 'package.json',
|
|
91
95
|
versionFiles: [],
|
|
92
96
|
publish: 'npm publish --tag %d',
|
|
93
97
|
commitMessage: 'chore(release): %t',
|
|
@@ -645,18 +649,84 @@ function insertChangelogSection(text, version, date, body) {
|
|
|
645
649
|
const readJson = (path) => JSON.parse(readFileSync(path, 'utf8'))
|
|
646
650
|
|
|
647
651
|
/**
|
|
648
|
-
*
|
|
649
|
-
*
|
|
652
|
+
* Where a project keeps its version. The format is inferred from the file name, so the
|
|
653
|
+
* common cases need nothing but a path:
|
|
654
|
+
*
|
|
655
|
+
* *.json the JSON `"version": "x.y.z"` field
|
|
656
|
+
* *.toml the first `version = "x.y.z"` line — the `[package]` / `[project]`
|
|
657
|
+
* table comes first in Cargo.toml and pyproject.toml
|
|
658
|
+
* anything else the whole file is the version (a plain VERSION file)
|
|
659
|
+
*
|
|
660
|
+
* An explicit `pattern` overrides inference for formats not listed. Whatever the source,
|
|
661
|
+
* it must capture the version in exactly one group, which is what gets replaced on write.
|
|
662
|
+
*/
|
|
663
|
+
const VERSION_PATTERNS = {
|
|
664
|
+
json: /^\s*"version"\s*:\s*"([^"]*)"/m,
|
|
665
|
+
toml: /^version\s*=\s*"([^"]*)"/m,
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/** The project name in the same files, used for display and the registry lookup. */
|
|
669
|
+
const NAME_PATTERNS = {
|
|
670
|
+
json: /^\s*"name"\s*:\s*"([^"]*)"/m,
|
|
671
|
+
toml: /^name\s*=\s*"([^"]*)"/m,
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/** @returns {string | null} the project name recorded beside the version */
|
|
675
|
+
function readNameFrom(entry) {
|
|
676
|
+
const source = versionSource(entry)
|
|
677
|
+
const kind = source.path.endsWith('.json')
|
|
678
|
+
? 'json'
|
|
679
|
+
: source.path.endsWith('.toml')
|
|
680
|
+
? 'toml'
|
|
681
|
+
: null
|
|
682
|
+
if (!kind || !existsSync(source.path)) return null
|
|
683
|
+
return NAME_PATTERNS[kind].exec(readFileSync(source.path, 'utf8'))?.[1] ?? null
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/** Normalise a versionFile / versionFiles entry to { path, pattern }. */
|
|
687
|
+
const versionSource = (entry) => (typeof entry === 'string' ? { path: entry } : entry)
|
|
688
|
+
|
|
689
|
+
/** The regex for a source, or null when the whole file is the version. */
|
|
690
|
+
function patternFor({ path, pattern }) {
|
|
691
|
+
if (pattern) return new RegExp(pattern, 'm')
|
|
692
|
+
if (path.endsWith('.json')) return VERSION_PATTERNS.json
|
|
693
|
+
if (path.endsWith('.toml')) return VERSION_PATTERNS.toml
|
|
694
|
+
return null
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/** @returns {string | null} the version recorded in a source file */
|
|
698
|
+
function readVersionFrom(entry) {
|
|
699
|
+
const source = versionSource(entry)
|
|
700
|
+
const text = readFileSync(source.path, 'utf8')
|
|
701
|
+
const pattern = patternFor(source)
|
|
702
|
+
if (!pattern) return text.trim() || null
|
|
703
|
+
const match = pattern.exec(text)
|
|
704
|
+
return match ? match[1] : null
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/**
|
|
708
|
+
* Replace the version in a source file, touching nothing else: only the captured range is
|
|
709
|
+
* rewritten, so formatting, key order and comments all survive.
|
|
650
710
|
*
|
|
651
711
|
* @returns {boolean} whether the file needed changing
|
|
652
712
|
*/
|
|
653
|
-
function writeVersionInto(
|
|
654
|
-
const
|
|
655
|
-
const
|
|
656
|
-
|
|
657
|
-
|
|
713
|
+
function writeVersionInto(entry, version) {
|
|
714
|
+
const source = versionSource(entry)
|
|
715
|
+
const text = readFileSync(source.path, 'utf8')
|
|
716
|
+
const pattern = patternFor(source)
|
|
717
|
+
|
|
718
|
+
let updated
|
|
719
|
+
if (pattern) {
|
|
720
|
+
const match = pattern.exec(text)
|
|
721
|
+
if (!match) throw new Error(`${source.path} has no version matching ${pattern}`)
|
|
722
|
+
const start = match.index + match[0].indexOf(match[1])
|
|
723
|
+
updated = text.slice(0, start) + version + text.slice(start + match[1].length)
|
|
724
|
+
} else {
|
|
725
|
+
updated = `${version}\n`
|
|
726
|
+
}
|
|
727
|
+
|
|
658
728
|
if (updated === text) return false
|
|
659
|
-
if (!dryRun) writeFileSync(path, updated)
|
|
729
|
+
if (!dryRun) writeFileSync(source.path, updated)
|
|
660
730
|
return true
|
|
661
731
|
}
|
|
662
732
|
|
|
@@ -692,6 +762,15 @@ if (flag('--help') || flag('-h')) {
|
|
|
692
762
|
// --sync copies this file into other projects and exits; it touches no git state.
|
|
693
763
|
if (flag('--sync')) {
|
|
694
764
|
const self = new URL(import.meta.url).pathname
|
|
765
|
+
// Piped from stdin (`curl … | node -`) there is no file to copy: import.meta.url points
|
|
766
|
+
// at a synthetic [eval] path. Say so instead of failing on a missing file.
|
|
767
|
+
if (!existsSync(self)) {
|
|
768
|
+
abort(
|
|
769
|
+
'--sync copies this script from disk, and it was piped from stdin so there is no ' +
|
|
770
|
+
'file to copy.\n Run it from an installed copy instead: ' +
|
|
771
|
+
'npx @entro314labs/release-kit --sync <dir>',
|
|
772
|
+
)
|
|
773
|
+
}
|
|
695
774
|
const targets = argv.slice(argv.indexOf('--sync') + 1).filter((a) => !a.startsWith('-'))
|
|
696
775
|
if (!targets.length) abort('--sync needs at least one project directory')
|
|
697
776
|
|
|
@@ -771,11 +850,6 @@ if (existsSync(localManifest) && localManifest !== rootManifest) {
|
|
|
771
850
|
}
|
|
772
851
|
process.chdir(root)
|
|
773
852
|
|
|
774
|
-
if (!existsSync('package.json')) abort(`no package.json at ${root}`)
|
|
775
|
-
const pkg = readJson('package.json')
|
|
776
|
-
if (!pkg.version) abort('package.json has no "version" field')
|
|
777
|
-
if (!parseVersion(pkg.version)) abort(`package.json version "${pkg.version}" is not semver`)
|
|
778
|
-
|
|
779
853
|
const config = {
|
|
780
854
|
...DEFAULTS,
|
|
781
855
|
...(existsSync('release.config.json') ? readJson('release.config.json') : {}),
|
|
@@ -793,6 +867,34 @@ const parseStepList = (value) =>
|
|
|
793
867
|
.map((name) => name.trim())
|
|
794
868
|
.filter(Boolean)
|
|
795
869
|
|
|
870
|
+
/**
|
|
871
|
+
* The project's current version and name. Both normally come from package.json, but the
|
|
872
|
+
* only Node-specific thing about a release is publishing: `versionFile` points at whatever
|
|
873
|
+
* file this project keeps its version in, and `null` means the repository versions by git
|
|
874
|
+
* tag alone and the version has to be passed explicitly.
|
|
875
|
+
*/
|
|
876
|
+
const manifest = existsSync('package.json') ? readJson('package.json') : null
|
|
877
|
+
const versionFile = config.versionFile ? versionSource(config.versionFile) : null
|
|
878
|
+
|
|
879
|
+
if (versionFile && !existsSync(versionFile.path)) {
|
|
880
|
+
abort(`versionFile ${versionFile.path} does not exist`)
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
const currentVersion = versionFile ? readVersionFrom(versionFile) : null
|
|
884
|
+
if (versionFile && !currentVersion) {
|
|
885
|
+
abort(`could not read a version from ${versionFile.path}`)
|
|
886
|
+
}
|
|
887
|
+
if (currentVersion && !parseVersion(currentVersion)) {
|
|
888
|
+
abort(`${versionFile.path} version "${currentVersion}" is not semver`)
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
/** Used for display, the registry lookup, and the %n token. */
|
|
892
|
+
const goModule = existsSync('go.mod')
|
|
893
|
+
? (/^module\s+(\S+)/m.exec(readFileSync('go.mod', 'utf8'))?.[1] ?? null)
|
|
894
|
+
: null
|
|
895
|
+
const projectName =
|
|
896
|
+
manifest?.name ?? (versionFile ? readNameFrom(versionFile) : null) ?? goModule ?? basename(root)
|
|
897
|
+
|
|
796
898
|
// Validate every name that was asked for, not just the ones that survive: a typo in
|
|
797
899
|
// --skip would otherwise delete nothing and silently run the step you meant to drop.
|
|
798
900
|
const requestedStepNames = [
|
|
@@ -850,20 +952,30 @@ const assistant = assistantName ? ASSISTANTS[assistantName] : null
|
|
|
850
952
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
851
953
|
|
|
852
954
|
console.log(
|
|
853
|
-
bold(`${
|
|
955
|
+
bold(`${projectName} release`) +
|
|
956
|
+
(dryRun ? ` ${yellow('(dry run — nothing will execute)')}` : ''),
|
|
854
957
|
)
|
|
855
958
|
|
|
856
959
|
let version
|
|
857
960
|
if (!target) {
|
|
858
|
-
|
|
961
|
+
if (!currentVersion) {
|
|
962
|
+
abort(
|
|
963
|
+
'this repository has no versionFile, so there is no version to default to.\n' +
|
|
964
|
+
' Pass one explicitly: release-kit 1.2.3',
|
|
965
|
+
)
|
|
966
|
+
}
|
|
967
|
+
version = currentVersion
|
|
859
968
|
} else if (BUMPS.has(target)) {
|
|
860
|
-
|
|
969
|
+
if (!currentVersion) {
|
|
970
|
+
abort(`a ${target} bump needs a versionFile to bump from. Pass a version explicitly instead.`)
|
|
971
|
+
}
|
|
972
|
+
const preid = requestedPreid ?? preidOf(currentVersion)
|
|
861
973
|
if (target.startsWith('pre') && !preid) {
|
|
862
974
|
abort(
|
|
863
975
|
`a ${target} bump from a stable version needs --preid <${[...KNOWN_CHANNELS].sort().join('|')}>`,
|
|
864
976
|
)
|
|
865
977
|
}
|
|
866
|
-
version = incrementVersion(
|
|
978
|
+
version = incrementVersion(currentVersion, target, preid)
|
|
867
979
|
} else if (parseVersion(target)) {
|
|
868
980
|
version = target
|
|
869
981
|
} else {
|
|
@@ -872,7 +984,7 @@ if (!target) {
|
|
|
872
984
|
|
|
873
985
|
const tag = `${config.tagPrefix}${version}`
|
|
874
986
|
const isPrerelease = parseVersion(version).pre.length > 0
|
|
875
|
-
const bumping = version !==
|
|
987
|
+
const bumping = !!versionFile && version !== currentVersion && runs('version')
|
|
876
988
|
|
|
877
989
|
let distTag
|
|
878
990
|
try {
|
|
@@ -885,7 +997,7 @@ const expandWith = (template, transform) =>
|
|
|
885
997
|
template
|
|
886
998
|
.replaceAll('%v', transform(version))
|
|
887
999
|
.replaceAll('%t', transform(tag))
|
|
888
|
-
.replaceAll('%n', transform(
|
|
1000
|
+
.replaceAll('%n', transform(projectName))
|
|
889
1001
|
.replaceAll('%d', transform(distTag))
|
|
890
1002
|
|
|
891
1003
|
/** Expand tokens for a message or title, which never reaches a shell. */
|
|
@@ -902,14 +1014,29 @@ const expandShell = (template) => expandWith(template, shellQuote)
|
|
|
902
1014
|
const publishCommand = runs('publish') && config.publish ? expandShell(config.publish) : null
|
|
903
1015
|
|
|
904
1016
|
/**
|
|
905
|
-
*
|
|
906
|
-
*
|
|
907
|
-
*
|
|
908
|
-
*
|
|
1017
|
+
* Registries whose preflight can be run, keyed by the first word of the publish command.
|
|
1018
|
+
* Each declares how that CLI answers "who am I" and "does this version already exist";
|
|
1019
|
+
* either may be null when the tool has no such notion. A publish command outside this
|
|
1020
|
+
* table (vsce, a shell pipeline) is run as written with no preflight — it cannot be
|
|
1021
|
+
* introspected, and guessing would invent failures.
|
|
909
1022
|
*/
|
|
910
|
-
const
|
|
1023
|
+
const REGISTRIES = {
|
|
1024
|
+
npm: { whoami: ['whoami'], published: (name, v) => ['view', `${name}@${v}`, 'version'] },
|
|
1025
|
+
pnpm: { whoami: ['whoami'], published: (name, v) => ['view', `${name}@${v}`, 'version'] },
|
|
1026
|
+
bun: {
|
|
1027
|
+
whoami: ['pm', 'whoami'],
|
|
1028
|
+
published: (name, v) => ['pm', 'view', `${name}@${v}`, 'version'],
|
|
1029
|
+
},
|
|
1030
|
+
// uv authenticates with a token from the environment rather than a logged-in session,
|
|
1031
|
+
// and skips duplicate uploads itself via --check-url, so there is no version lookup.
|
|
1032
|
+
uv: { env: ['UV_PUBLISH_TOKEN', 'UV_PUBLISH_PASSWORD'], login: 'set UV_PUBLISH_TOKEN' },
|
|
1033
|
+
// For Go the tag is the release; `go list` warms the module proxy and doubles as the
|
|
1034
|
+
// check for whether this version is already resolvable.
|
|
1035
|
+
go: { published: (name, v) => ['list', '-m', `${name}@${v}`] },
|
|
1036
|
+
}
|
|
1037
|
+
|
|
911
1038
|
const publishCli = publishCommand?.trim().split(/\s+/)[0]
|
|
912
|
-
const
|
|
1039
|
+
const registry = publishCli ? REGISTRIES[publishCli] : null
|
|
913
1040
|
|
|
914
1041
|
/**
|
|
915
1042
|
* CI publishing over OIDC ("trusted publishing") carries no token at all: `whoami` fails
|
|
@@ -923,7 +1050,9 @@ const isTrustedPublishing =
|
|
|
923
1050
|
!!process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN) ||
|
|
924
1051
|
!!process.env.NPM_ID_TOKEN
|
|
925
1052
|
|
|
926
|
-
console.log(
|
|
1053
|
+
console.log(
|
|
1054
|
+
` ${dim(`${currentVersion ?? '(no version file)'} → ${version} tag ${tag} dist-tag ${distTag}`)}`,
|
|
1055
|
+
)
|
|
927
1056
|
console.log(` ${dim(`steps: ${STEPS.filter(runs).join(' → ')}`)}`)
|
|
928
1057
|
|
|
929
1058
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -938,12 +1067,14 @@ const fail = (message) => {
|
|
|
938
1067
|
problems.push(message)
|
|
939
1068
|
}
|
|
940
1069
|
|
|
941
|
-
if (bumping && compareVersions(version,
|
|
942
|
-
fail(`${version} is not greater than the current version ${
|
|
1070
|
+
if (bumping && compareVersions(version, currentVersion) <= 0) {
|
|
1071
|
+
fail(`${version} is not greater than the current version ${currentVersion}`)
|
|
943
1072
|
} else if (bumping) {
|
|
944
|
-
ok(`version ${
|
|
1073
|
+
ok(`version ${currentVersion} → ${version}`)
|
|
1074
|
+
} else if (versionFile) {
|
|
1075
|
+
ok(`releasing the version already in ${versionFile.path} (${version})`)
|
|
945
1076
|
} else {
|
|
946
|
-
ok(`releasing
|
|
1077
|
+
ok(`releasing ${version} (no version file; the tag is the version)`)
|
|
947
1078
|
}
|
|
948
1079
|
|
|
949
1080
|
const dirty = tryRead('git', ['status', '--porcelain'])
|
|
@@ -1000,6 +1131,30 @@ if (!succeeds('git', ['remote', 'get-url', config.remote])) {
|
|
|
1000
1131
|
}
|
|
1001
1132
|
}
|
|
1002
1133
|
|
|
1134
|
+
// Signing is configured per repository and inherited, never managed here — git already
|
|
1135
|
+
// owns that. But a signing setup that cannot produce a signature fails at the commit step,
|
|
1136
|
+
// after the version has been written, so it is worth catching before anything mutates.
|
|
1137
|
+
const signsSomething = ['commit', 'version', 'changelog', 'tag'].some(runs)
|
|
1138
|
+
const signingKeys = ['commit.gpgsign', 'tag.gpgsign'].filter(
|
|
1139
|
+
(key) => tryRead('git', ['config', '--get', key]) === 'true',
|
|
1140
|
+
)
|
|
1141
|
+
if (signsSomething && signingKeys.length) {
|
|
1142
|
+
const format = tryRead('git', ['config', '--get', 'gpg.format']) || 'openpgp'
|
|
1143
|
+
const signingKey = tryRead('git', ['config', '--get', 'user.signingkey'])
|
|
1144
|
+
const keyPath = signingKey?.replace(/^~/, homedir())
|
|
1145
|
+
if (!signingKey) {
|
|
1146
|
+
fail(`${signingKeys.join(' and ')} enabled but user.signingkey is not set`)
|
|
1147
|
+
} else if (format === 'ssh' && /^[~/.]/.test(signingKey) && !existsSync(keyPath)) {
|
|
1148
|
+
fail(
|
|
1149
|
+
`signing key ${signingKey} does not exist.\n` +
|
|
1150
|
+
' Point user.signingkey at a key that is present, or disable signing for this ' +
|
|
1151
|
+
'run with `git -c commit.gpgsign=false -c tag.gpgsign=false`.',
|
|
1152
|
+
)
|
|
1153
|
+
} else {
|
|
1154
|
+
ok(`signing commits and tags (${format})`)
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1003
1158
|
const head = tryRead('git', ['rev-parse', 'HEAD'])
|
|
1004
1159
|
const taggedCommit = tryRead('git', ['rev-list', '-n', '1', tag])
|
|
1005
1160
|
if (!runs('tag')) {
|
|
@@ -1030,27 +1185,35 @@ if (!runs('release')) {
|
|
|
1030
1185
|
let alreadyPublished = false
|
|
1031
1186
|
if (!publishCommand) {
|
|
1032
1187
|
note(runs('publish') ? 'no publish command configured' : 'publish step not selected')
|
|
1033
|
-
} else if (
|
|
1188
|
+
} else if (manifest?.private) {
|
|
1034
1189
|
fail('package.json is private but a publish command is configured')
|
|
1035
|
-
} else if (!
|
|
1190
|
+
} else if (!registry) {
|
|
1036
1191
|
ok(`publish: ${publishCommand}`)
|
|
1037
1192
|
} else {
|
|
1038
1193
|
if (isTrustedPublishing) {
|
|
1039
1194
|
ok('trusted publishing (OIDC) — no token needed')
|
|
1040
|
-
} else {
|
|
1041
|
-
|
|
1195
|
+
} else if (registry.env) {
|
|
1196
|
+
// Token-in-the-environment auth: there is no session to interrogate, only credentials.
|
|
1197
|
+
const found = registry.env.find((name) => process.env[name])
|
|
1198
|
+
if (found) ok(`${publishCli} credentials found (${found})`)
|
|
1199
|
+
else fail(`${publishCli} has no publish credentials — ${registry.login}`)
|
|
1200
|
+
} else if (registry.whoami) {
|
|
1201
|
+
const user = tryRead(publishCli, registry.whoami)
|
|
1042
1202
|
if (user === null) {
|
|
1043
1203
|
// npm replaced long-lived tokens with two-hour sessions in December 2025, so the
|
|
1044
1204
|
// usual cause is an expired session rather than a missing login.
|
|
1045
1205
|
fail(
|
|
1046
|
-
`${
|
|
1206
|
+
`${publishCli} is not authenticated — run \`${publishCli} login\`. ` +
|
|
1047
1207
|
'npm logins are two-hour sessions, so an earlier one may have expired.',
|
|
1048
1208
|
)
|
|
1049
|
-
} else ok(`${
|
|
1209
|
+
} else ok(`${publishCli} authenticated (${user || 'unknown user'})`)
|
|
1050
1210
|
}
|
|
1051
|
-
|
|
1052
|
-
if (
|
|
1053
|
-
|
|
1211
|
+
|
|
1212
|
+
if (registry.published) {
|
|
1213
|
+
alreadyPublished = succeeds(publishCli, registry.published(projectName, version))
|
|
1214
|
+
if (alreadyPublished) {
|
|
1215
|
+
note(`${projectName}@${version} is already published — will skip the publish step`)
|
|
1216
|
+
}
|
|
1054
1217
|
}
|
|
1055
1218
|
}
|
|
1056
1219
|
|
|
@@ -1128,7 +1291,7 @@ if (!assumeYes && !dryRun && process.stdin.isTTY) {
|
|
|
1128
1291
|
const rl = createInterface({ input: process.stdin, output: process.stdout })
|
|
1129
1292
|
let answer = ''
|
|
1130
1293
|
try {
|
|
1131
|
-
answer = await rl.question(`\nRelease ${bold(tag)} of ${
|
|
1294
|
+
answer = await rl.question(`\nRelease ${bold(tag)} of ${projectName}? [y/N] `)
|
|
1132
1295
|
} catch {
|
|
1133
1296
|
// Ctrl+C or Ctrl+D at the prompt rejects the question. That is a decline, not a
|
|
1134
1297
|
// crash — without this it exits on an unhandled AbortError and a stack trace.
|
|
@@ -1168,11 +1331,12 @@ if (dirty && runs('commit')) {
|
|
|
1168
1331
|
|
|
1169
1332
|
if (bumping) {
|
|
1170
1333
|
step(`Write version ${version}`)
|
|
1171
|
-
for (const
|
|
1172
|
-
|
|
1173
|
-
if (
|
|
1174
|
-
|
|
1175
|
-
|
|
1334
|
+
for (const entry of [versionFile, ...config.versionFiles]) {
|
|
1335
|
+
const source = versionSource(entry)
|
|
1336
|
+
if (!existsSync(source.path)) abort(`versionFiles entry ${source.path} does not exist`)
|
|
1337
|
+
if (writeVersionInto(source, version)) {
|
|
1338
|
+
staged.push(source.path)
|
|
1339
|
+
console.log(` ${dryRun ? yellow('would write') : dim('wrote')} ${source.path}`)
|
|
1176
1340
|
}
|
|
1177
1341
|
}
|
|
1178
1342
|
// A package-lock.json embeds the root version twice, so it goes stale on a bump.
|
|
@@ -1222,7 +1386,7 @@ if (runs('tag') && !taggedCommit) {
|
|
|
1222
1386
|
tag,
|
|
1223
1387
|
'--cleanup=verbatim',
|
|
1224
1388
|
'-m',
|
|
1225
|
-
`${notes ?? `${
|
|
1389
|
+
`${notes ?? `${projectName} ${tag}`}\n`,
|
|
1226
1390
|
])
|
|
1227
1391
|
}
|
|
1228
1392
|
|