@entro314labs/release-kit 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +49 -0
  2. package/package.json +2 -2
  3. package/release.mjs +178 -47
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 |
@@ -249,6 +250,54 @@ it stopped. There is no cleanup step, no `--resume`, and nothing to remember.
249
250
  The one case that is not recoverable by re-running is a tag that exists at a _different_
250
251
  commit than `HEAD`. That is a genuine conflict, and it aborts rather than guessing.
251
252
 
253
+ ## 🌍 Any language
254
+
255
+ Only one step is Node-specific: `publish`. Committing, changelog rolling, tagging, pushing
256
+ and GitHub releases are the same everywhere, so `versionFile` points at wherever a project
257
+ keeps its version and the rest works unchanged.
258
+
259
+ | Project | Config |
260
+ | ------------------------------ | ------------------------------------------------------------------------------- |
261
+ | Node (npm) | nothing — `package.json` and `npm publish` are the defaults |
262
+ | Node (pnpm / bun) | `{"publish": "pnpm publish --tag %d"}` or `{"publish": "bun publish --tag %d"}` |
263
+ | Rust | `{"versionFile": "Cargo.toml", "publish": "cargo publish"}` |
264
+ | Python | `{"versionFile": "pyproject.toml", "publish": "uv publish"}` |
265
+ | Go | `{"versionFile": null, "publish": "go list -m %n@%t"}` — the tag is the release |
266
+ | Anything with a `VERSION` file | `{"versionFile": "VERSION", "publish": null}` |
267
+ | Versioned only by tag | `{"versionFile": null}`, then `release-kit 1.2.3` |
268
+
269
+ The publish step also gets a preflight when the command is one it recognises:
270
+
271
+ | Publish command | Authentication | Already published? |
272
+ | --------------- | ------------------------------------- | ----------------------------------- |
273
+ | `npm` / `pnpm` | `whoami` | `view <name>@<version>` |
274
+ | `bun` | `bun pm whoami` | `bun pm view <name>@<version>` |
275
+ | `uv` | `UV_PUBLISH_TOKEN` in the environment | none — `uv` skips duplicates itself |
276
+ | `go` | none needed | `go list -m <module>@<tag>` |
277
+
278
+ Anything else runs as written with no preflight. The project name comes from the manifest —
279
+ `name` in `package.json`, `Cargo.toml` or `pyproject.toml`, `module` in `go.mod` — falling
280
+ back to the repository directory.
281
+
282
+ The format is inferred from the file name: `.json` reads the `"version"` field, `.toml`
283
+ reads the first `version = "x.y.z"` line, and any other file is treated as containing just
284
+ the version. Only the version itself is rewritten, so comments and formatting survive — and
285
+ because the TOML match is anchored to the start of a line, a dependency's
286
+ `serde = { version = "1.0" }` is left alone.
287
+
288
+ For anything else, give a pattern with one capture group around the version. `versionFiles`
289
+ takes the same entries, so several files stay in sync across formats:
290
+
291
+ ```json
292
+ {
293
+ "versionFile": { "path": "version.go", "pattern": "^const Version = \"(.+)\"" },
294
+ "versionFiles": [{ "path": "Chart.yaml", "pattern": "^version: (.+)$" }]
295
+ }
296
+ ```
297
+
298
+ The project name comes from the manifest when there is one (`name` in `package.json`,
299
+ `Cargo.toml` or `pyproject.toml`), and falls back to the repository directory.
300
+
252
301
  ## ⚙️ Configuration
253
302
 
254
303
  `release.config.json`, beside `package.json`. Every key is optional; unknown keys abort
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@entro314labs/release-kit",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
4
4
  "description": "Single-file, zero-dependency release mechanism for JS/TS/Node projects: version bump, changelog roll, commit, annotated tag, push, publish, GitHub release",
5
5
  "keywords": [
6
6
  "changelog",
@@ -49,6 +49,6 @@
49
49
  "oxlint": "^1.78.0"
50
50
  },
51
51
  "engines": {
52
- "node": ">=18"
52
+ "node": ">=22"
53
53
  }
54
54
  }
package/release.mjs CHANGED
@@ -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
 
@@ -771,11 +841,6 @@ if (existsSync(localManifest) && localManifest !== rootManifest) {
771
841
  }
772
842
  process.chdir(root)
773
843
 
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
844
  const config = {
780
845
  ...DEFAULTS,
781
846
  ...(existsSync('release.config.json') ? readJson('release.config.json') : {}),
@@ -793,6 +858,34 @@ const parseStepList = (value) =>
793
858
  .map((name) => name.trim())
794
859
  .filter(Boolean)
795
860
 
861
+ /**
862
+ * The project's current version and name. Both normally come from package.json, but the
863
+ * only Node-specific thing about a release is publishing: `versionFile` points at whatever
864
+ * file this project keeps its version in, and `null` means the repository versions by git
865
+ * tag alone and the version has to be passed explicitly.
866
+ */
867
+ const manifest = existsSync('package.json') ? readJson('package.json') : null
868
+ const versionFile = config.versionFile ? versionSource(config.versionFile) : null
869
+
870
+ if (versionFile && !existsSync(versionFile.path)) {
871
+ abort(`versionFile ${versionFile.path} does not exist`)
872
+ }
873
+
874
+ const currentVersion = versionFile ? readVersionFrom(versionFile) : null
875
+ if (versionFile && !currentVersion) {
876
+ abort(`could not read a version from ${versionFile.path}`)
877
+ }
878
+ if (currentVersion && !parseVersion(currentVersion)) {
879
+ abort(`${versionFile.path} version "${currentVersion}" is not semver`)
880
+ }
881
+
882
+ /** Used for display, the registry lookup, and the %n token. */
883
+ const goModule = existsSync('go.mod')
884
+ ? (/^module\s+(\S+)/m.exec(readFileSync('go.mod', 'utf8'))?.[1] ?? null)
885
+ : null
886
+ const projectName =
887
+ manifest?.name ?? (versionFile ? readNameFrom(versionFile) : null) ?? goModule ?? basename(root)
888
+
796
889
  // Validate every name that was asked for, not just the ones that survive: a typo in
797
890
  // --skip would otherwise delete nothing and silently run the step you meant to drop.
798
891
  const requestedStepNames = [
@@ -850,20 +943,30 @@ const assistant = assistantName ? ASSISTANTS[assistantName] : null
850
943
  // ─────────────────────────────────────────────────────────────────────────────
851
944
 
852
945
  console.log(
853
- bold(`${pkg.name} release`) + (dryRun ? ` ${yellow('(dry run — nothing will execute)')}` : ''),
946
+ bold(`${projectName} release`) +
947
+ (dryRun ? ` ${yellow('(dry run — nothing will execute)')}` : ''),
854
948
  )
855
949
 
856
950
  let version
857
951
  if (!target) {
858
- ;({ version } = pkg)
952
+ if (!currentVersion) {
953
+ abort(
954
+ 'this repository has no versionFile, so there is no version to default to.\n' +
955
+ ' Pass one explicitly: release-kit 1.2.3',
956
+ )
957
+ }
958
+ version = currentVersion
859
959
  } else if (BUMPS.has(target)) {
860
- const preid = requestedPreid ?? preidOf(pkg.version)
960
+ if (!currentVersion) {
961
+ abort(`a ${target} bump needs a versionFile to bump from. Pass a version explicitly instead.`)
962
+ }
963
+ const preid = requestedPreid ?? preidOf(currentVersion)
861
964
  if (target.startsWith('pre') && !preid) {
862
965
  abort(
863
966
  `a ${target} bump from a stable version needs --preid <${[...KNOWN_CHANNELS].sort().join('|')}>`,
864
967
  )
865
968
  }
866
- version = incrementVersion(pkg.version, target, preid)
969
+ version = incrementVersion(currentVersion, target, preid)
867
970
  } else if (parseVersion(target)) {
868
971
  version = target
869
972
  } else {
@@ -872,7 +975,7 @@ if (!target) {
872
975
 
873
976
  const tag = `${config.tagPrefix}${version}`
874
977
  const isPrerelease = parseVersion(version).pre.length > 0
875
- const bumping = version !== pkg.version && runs('version')
978
+ const bumping = !!versionFile && version !== currentVersion && runs('version')
876
979
 
877
980
  let distTag
878
981
  try {
@@ -885,7 +988,7 @@ const expandWith = (template, transform) =>
885
988
  template
886
989
  .replaceAll('%v', transform(version))
887
990
  .replaceAll('%t', transform(tag))
888
- .replaceAll('%n', transform(pkg.name))
991
+ .replaceAll('%n', transform(projectName))
889
992
  .replaceAll('%d', transform(distTag))
890
993
 
891
994
  /** Expand tokens for a message or title, which never reaches a shell. */
@@ -902,14 +1005,29 @@ const expandShell = (template) => expandWith(template, shellQuote)
902
1005
  const publishCommand = runs('publish') && config.publish ? expandShell(config.publish) : null
903
1006
 
904
1007
  /**
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.
1008
+ * Registries whose preflight can be run, keyed by the first word of the publish command.
1009
+ * Each declares how that CLI answers "who am I" and "does this version already exist";
1010
+ * either may be null when the tool has no such notion. A publish command outside this
1011
+ * table (vsce, a shell pipeline) is run as written with no preflight — it cannot be
1012
+ * introspected, and guessing would invent failures.
909
1013
  */
910
- const REGISTRY_CLIS = new Set(['npm', 'pnpm'])
1014
+ const REGISTRIES = {
1015
+ npm: { whoami: ['whoami'], published: (name, v) => ['view', `${name}@${v}`, 'version'] },
1016
+ pnpm: { whoami: ['whoami'], published: (name, v) => ['view', `${name}@${v}`, 'version'] },
1017
+ bun: {
1018
+ whoami: ['pm', 'whoami'],
1019
+ published: (name, v) => ['pm', 'view', `${name}@${v}`, 'version'],
1020
+ },
1021
+ // uv authenticates with a token from the environment rather than a logged-in session,
1022
+ // and skips duplicate uploads itself via --check-url, so there is no version lookup.
1023
+ uv: { env: ['UV_PUBLISH_TOKEN', 'UV_PUBLISH_PASSWORD'], login: 'set UV_PUBLISH_TOKEN' },
1024
+ // For Go the tag is the release; `go list` warms the module proxy and doubles as the
1025
+ // check for whether this version is already resolvable.
1026
+ go: { published: (name, v) => ['list', '-m', `${name}@${v}`] },
1027
+ }
1028
+
911
1029
  const publishCli = publishCommand?.trim().split(/\s+/)[0]
912
- const registryCli = REGISTRY_CLIS.has(publishCli) ? publishCli : null
1030
+ const registry = publishCli ? REGISTRIES[publishCli] : null
913
1031
 
914
1032
  /**
915
1033
  * CI publishing over OIDC ("trusted publishing") carries no token at all: `whoami` fails
@@ -923,7 +1041,9 @@ const isTrustedPublishing =
923
1041
  !!process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN) ||
924
1042
  !!process.env.NPM_ID_TOKEN
925
1043
 
926
- console.log(` ${dim(`${pkg.version} → ${version} tag ${tag} dist-tag ${distTag}`)}`)
1044
+ console.log(
1045
+ ` ${dim(`${currentVersion ?? '(no version file)'} → ${version} tag ${tag} dist-tag ${distTag}`)}`,
1046
+ )
927
1047
  console.log(` ${dim(`steps: ${STEPS.filter(runs).join(' → ')}`)}`)
928
1048
 
929
1049
  // ─────────────────────────────────────────────────────────────────────────────
@@ -938,12 +1058,14 @@ const fail = (message) => {
938
1058
  problems.push(message)
939
1059
  }
940
1060
 
941
- if (bumping && compareVersions(version, pkg.version) <= 0) {
942
- fail(`${version} is not greater than the current version ${pkg.version}`)
1061
+ if (bumping && compareVersions(version, currentVersion) <= 0) {
1062
+ fail(`${version} is not greater than the current version ${currentVersion}`)
943
1063
  } else if (bumping) {
944
- ok(`version ${pkg.version} → ${version}`)
1064
+ ok(`version ${currentVersion} → ${version}`)
1065
+ } else if (versionFile) {
1066
+ ok(`releasing the version already in ${versionFile.path} (${version})`)
945
1067
  } else {
946
- ok(`releasing the version already in package.json (${version})`)
1068
+ ok(`releasing ${version} (no version file; the tag is the version)`)
947
1069
  }
948
1070
 
949
1071
  const dirty = tryRead('git', ['status', '--porcelain'])
@@ -1030,27 +1152,35 @@ if (!runs('release')) {
1030
1152
  let alreadyPublished = false
1031
1153
  if (!publishCommand) {
1032
1154
  note(runs('publish') ? 'no publish command configured' : 'publish step not selected')
1033
- } else if (pkg.private) {
1155
+ } else if (manifest?.private) {
1034
1156
  fail('package.json is private but a publish command is configured')
1035
- } else if (!registryCli) {
1157
+ } else if (!registry) {
1036
1158
  ok(`publish: ${publishCommand}`)
1037
1159
  } else {
1038
1160
  if (isTrustedPublishing) {
1039
1161
  ok('trusted publishing (OIDC) — no token needed')
1040
- } else {
1041
- const user = tryRead(registryCli, ['whoami'])
1162
+ } else if (registry.env) {
1163
+ // Token-in-the-environment auth: there is no session to interrogate, only credentials.
1164
+ const found = registry.env.find((name) => process.env[name])
1165
+ if (found) ok(`${publishCli} credentials found (${found})`)
1166
+ else fail(`${publishCli} has no publish credentials — ${registry.login}`)
1167
+ } else if (registry.whoami) {
1168
+ const user = tryRead(publishCli, registry.whoami)
1042
1169
  if (user === null) {
1043
1170
  // npm replaced long-lived tokens with two-hour sessions in December 2025, so the
1044
1171
  // usual cause is an expired session rather than a missing login.
1045
1172
  fail(
1046
- `${registryCli} is not authenticated — run \`${registryCli} login\`. ` +
1173
+ `${publishCli} is not authenticated — run \`${publishCli} login\`. ` +
1047
1174
  'npm logins are two-hour sessions, so an earlier one may have expired.',
1048
1175
  )
1049
- } else ok(`${registryCli} authenticated (${user || 'unknown user'})`)
1176
+ } else ok(`${publishCli} authenticated (${user || 'unknown user'})`)
1050
1177
  }
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`)
1178
+
1179
+ if (registry.published) {
1180
+ alreadyPublished = succeeds(publishCli, registry.published(projectName, version))
1181
+ if (alreadyPublished) {
1182
+ note(`${projectName}@${version} is already published — will skip the publish step`)
1183
+ }
1054
1184
  }
1055
1185
  }
1056
1186
 
@@ -1128,7 +1258,7 @@ if (!assumeYes && !dryRun && process.stdin.isTTY) {
1128
1258
  const rl = createInterface({ input: process.stdin, output: process.stdout })
1129
1259
  let answer = ''
1130
1260
  try {
1131
- answer = await rl.question(`\nRelease ${bold(tag)} of ${pkg.name}? [y/N] `)
1261
+ answer = await rl.question(`\nRelease ${bold(tag)} of ${projectName}? [y/N] `)
1132
1262
  } catch {
1133
1263
  // Ctrl+C or Ctrl+D at the prompt rejects the question. That is a decline, not a
1134
1264
  // crash — without this it exits on an unhandled AbortError and a stack trace.
@@ -1168,11 +1298,12 @@ if (dirty && runs('commit')) {
1168
1298
 
1169
1299
  if (bumping) {
1170
1300
  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}`)
1301
+ for (const entry of [versionFile, ...config.versionFiles]) {
1302
+ const source = versionSource(entry)
1303
+ if (!existsSync(source.path)) abort(`versionFiles entry ${source.path} does not exist`)
1304
+ if (writeVersionInto(source, version)) {
1305
+ staged.push(source.path)
1306
+ console.log(` ${dryRun ? yellow('would write') : dim('wrote')} ${source.path}`)
1176
1307
  }
1177
1308
  }
1178
1309
  // A package-lock.json embeds the root version twice, so it goes stale on a bump.
@@ -1222,7 +1353,7 @@ if (runs('tag') && !taggedCommit) {
1222
1353
  tag,
1223
1354
  '--cleanup=verbatim',
1224
1355
  '-m',
1225
- `${notes ?? `${pkg.name} ${tag}`}\n`,
1356
+ `${notes ?? `${projectName} ${tag}`}\n`,
1226
1357
  ])
1227
1358
  }
1228
1359