@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.
Files changed (3) hide show
  1. package/README.md +114 -21
  2. package/package.json +2 -2
  3. 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
- **As a devDependency** — the normal choice. Updates arrive through your package manager.
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
- **Without installing** — for a one-off release, or a project you do not want to add a
82
- dependency to:
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
- npx @entro314labs/release-kit --dry-run
88
+ npm i -g @entro314labs/release-kit
89
+ release-kit minor
86
90
  ```
87
91
 
88
- **Vendored** — for a project that should not depend on the registry it is about to publish
89
- to, or one that needs releases to work offline. `--sync` copies the file into
90
- `scripts/release.mjs`:
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
- "scripts": {
99
- "release": "node scripts/release.mjs"
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
- All three run the same file. Zero-config works on the conventions below; add a
105
- [`release.config.json`](#️-configuration) only for what differs.
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+ (uses `node:readline/promises` and `Array.prototype.at`)
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.0.0",
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": ">=18"
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
- * versionFiles string[] extra JSON files whose top-level "version" is kept in sync
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
- * Write a top-level "version" into a JSON file without reformatting the rest of it: the
649
- * value is replaced in place, so key order, indentation and trailing newline all survive.
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(path, version) {
654
- const text = readFileSync(path, 'utf8')
655
- const field = /^(\s*"version"\s*:\s*)"[^"]*"/m
656
- if (!field.test(text)) throw new Error(`${path} has no top-level "version" field`)
657
- const updated = text.replace(field, `$1"${version}"`)
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(`${pkg.name} release`) + (dryRun ? ` ${yellow('(dry run — nothing will execute)')}` : ''),
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
- ;({ version } = pkg)
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
- const preid = requestedPreid ?? preidOf(pkg.version)
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(pkg.version, target, preid)
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 !== pkg.version && runs('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(pkg.name))
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
- * npm and pnpm answer `whoami` and `view` identically and share `~/.npmrc`, so whichever
906
- * one publishes can also run the registry preflight. Checking with the wrong one mislabels
907
- * the result. A publish command driving anything else (vsce, a shell pipeline) is left
908
- * alone — it cannot be introspected, and guessing would invent failures.
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 REGISTRY_CLIS = new Set(['npm', 'pnpm'])
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 registryCli = REGISTRY_CLIS.has(publishCli) ? publishCli : null
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(` ${dim(`${pkg.version} → ${version} tag ${tag} dist-tag ${distTag}`)}`)
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, pkg.version) <= 0) {
942
- fail(`${version} is not greater than the current version ${pkg.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 ${pkg.version} → ${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 the version already in package.json (${version})`)
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 (pkg.private) {
1188
+ } else if (manifest?.private) {
1034
1189
  fail('package.json is private but a publish command is configured')
1035
- } else if (!registryCli) {
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
- const user = tryRead(registryCli, ['whoami'])
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
- `${registryCli} is not authenticated — run \`${registryCli} login\`. ` +
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(`${registryCli} authenticated (${user || 'unknown user'})`)
1209
+ } else ok(`${publishCli} authenticated (${user || 'unknown user'})`)
1050
1210
  }
1051
- alreadyPublished = succeeds(registryCli, ['view', `${pkg.name}@${version}`, 'version'])
1052
- if (alreadyPublished) {
1053
- note(`${pkg.name}@${version} is already on the registry — will skip publishing`)
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 ${pkg.name}? [y/N] `)
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 file of ['package.json', ...config.versionFiles]) {
1172
- if (!existsSync(file)) abort(`versionFiles entry ${file} does not exist`)
1173
- if (writeVersionInto(file, version)) {
1174
- staged.push(file)
1175
- console.log(` ${dryRun ? yellow('would write') : dim('wrote')} ${file}`)
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 ?? `${pkg.name} ${tag}`}\n`,
1389
+ `${notes ?? `${projectName} ${tag}`}\n`,
1226
1390
  ])
1227
1391
  }
1228
1392