@entro314labs/release-kit 2.3.3 → 2.5.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 +51 -0
  2. package/package.json +5 -3
  3. package/release.mjs +63 -9
package/README.md CHANGED
@@ -342,6 +342,11 @@ the version. Only the version itself is rewritten, so comments and formatting su
342
342
  because the TOML match is anchored to the start of a line, a dependency's
343
343
  `serde = { version = "1.0" }` is left alone.
344
344
 
345
+ **Lockfiles are scoped automatically.** A `Cargo.lock` records a version for every
346
+ dependency — hundreds of them — so matching the first `version = "…"` would rewrite an
347
+ unrelated crate. Listing one rewrites only the `[[package]]` block whose name matches the
348
+ crate in the sibling `Cargo.toml`; with no sibling to read, it refuses rather than guesses.
349
+
345
350
  For anything else, give a pattern with one capture group around the version. `versionFiles`
346
351
  takes the same entries, so several files stay in sync across formats:
347
352
 
@@ -352,6 +357,29 @@ takes the same entries, so several files stay in sync across formats:
352
357
  }
353
358
  ```
354
359
 
360
+ A desktop app usually carries the same version in a lot of places at once — a workspace
361
+ manifest, per-platform bundle configs, a crate manifest and its lockfile. They stay in step
362
+ in one release, across three formats, with no scripting:
363
+
364
+ ```json
365
+ {
366
+ "versionFiles": [
367
+ "apps/desktop/package.json",
368
+ "apps/desktop/src-tauri/tauri.conf.json",
369
+ "apps/desktop/src-tauri/tauri.macos.conf.json",
370
+ "apps/desktop/src-tauri/tauri.windows.conf.json",
371
+ "apps/desktop/src-tauri/tauri.linux.conf.json",
372
+ "apps/desktop/src-tauri/Cargo.toml",
373
+ "apps/desktop/src-tauri/Cargo.lock"
374
+ ],
375
+ "publish": null,
376
+ "steps": ["version", "changelog", "tag", "push"]
377
+ }
378
+ ```
379
+
380
+ Stopping at `push` because the tag is what triggers the build pipeline — see
381
+ [Libraries versus apps](#-libraries-versus-apps).
382
+
355
383
  The project name comes from the manifest when there is one (`name` in `package.json`,
356
384
  `Cargo.toml` or `pyproject.toml`), and falls back to the repository directory.
357
385
 
@@ -593,6 +621,18 @@ jobs:
593
621
  - run: goreleaser release --clean --release-notes dist-notes.md
594
622
  ```
595
623
 
624
+ A Go project has no version file at all — the tag is the version — so it is
625
+ `"versionFile": null` and the version is passed explicitly, or inferred with `auto`:
626
+
627
+ ```sh
628
+ release-kit auto # bump from the commits, tag, push; goreleaser takes it from there
629
+ ```
630
+
631
+ **Only one of the two should write release notes.** goreleaser groups conventional commits
632
+ itself; `--release-notes` makes it use yours instead and skip its own generation. Leaving
633
+ both on means the notes in the GitHub release and the notes in your `CHANGELOG.md` are
634
+ generated by different code from the same commits, and they drift.
635
+
596
636
  **Do not leave `release` in `steps` here.** goreleaser creates the GitHub release itself; if
597
637
  release-kit has already created one for that tag, goreleaser fails. Exactly one of them
598
638
  should own it, and it should be the one attaching the binaries.
@@ -642,6 +682,17 @@ with the reasoning — are in [ROADMAP.md](ROADMAP.md).
642
682
 
643
683
  ## 🤝 Contributing
644
684
 
685
+ ```sh
686
+ pnpm install
687
+ pnpm test # 63 tests, node --test, no framework
688
+ pnpm check # format + lint + tests, the same gate CI runs
689
+ ```
690
+
691
+ `test/` holds unit suites for the pure functions and an integration suite that builds real
692
+ throwaway repositories with a real bare remote and stubbed `gh`/`npm`. The integration tests
693
+ pin defects found in use, so a name like "refuses to reuse a tag while still producing a
694
+ commit" is describing something that actually happened.
695
+
645
696
  The tool releases itself, so a change ships the same way it would in any consuming project:
646
697
  add a `## [Unreleased]` entry to `CHANGELOG.md`, then run `pnpm release <bump>` from a clone.
647
698
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@entro314labs/release-kit",
3
- "version": "2.3.3",
3
+ "version": "2.5.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",
@@ -41,12 +41,14 @@
41
41
  "format:check": "oxfmt --check .",
42
42
  "lint": "oxlint .",
43
43
  "lint:ci": "oxlint --deny-warnings .",
44
- "check": "pnpm run format:check && pnpm run lint:ci",
44
+ "test": "node --test \"test/**/*.test.mjs\"",
45
+ "check": "pnpm run format:check && pnpm run lint:ci && pnpm run test",
45
46
  "release": "node release.mjs"
46
47
  },
47
48
  "devDependencies": {
48
49
  "oxfmt": "^0.63.0",
49
- "oxlint": "^1.78.0"
50
+ "oxlint": "^1.78.0",
51
+ "semver": "^7.8.5"
50
52
  },
51
53
  "engines": {
52
54
  "node": ">=22"
package/release.mjs CHANGED
@@ -41,7 +41,7 @@ import {
41
41
  writeFileSync,
42
42
  } from 'node:fs'
43
43
  import { homedir, tmpdir } from 'node:os'
44
- import { basename, join, relative, resolve, sep } from 'node:path'
44
+ import { basename, dirname, join, relative, resolve, sep } from 'node:path'
45
45
  import { createInterface } from 'node:readline/promises'
46
46
 
47
47
  // ─────────────────────────────────────────────────────────────────────────────
@@ -419,6 +419,7 @@ const CHANGELOG_SECTIONS = [
419
419
  { type: 'fix', section: 'Bug Fixes' },
420
420
  { type: 'perf', section: 'Performance Improvements' },
421
421
  { type: 'revert', section: 'Reverts' },
422
+ { type: 'deps', section: 'Dependencies' },
422
423
  { type: 'docs', section: 'Documentation', hidden: true },
423
424
  { type: 'style', section: 'Styles', hidden: true },
424
425
  { type: 'refactor', section: 'Code Refactoring', hidden: true },
@@ -435,7 +436,9 @@ const CHANGELOG_SECTIONS = [
435
436
  * releaseAs: string|null} | null} null when the subject is not Conventional Commits
436
437
  */
437
438
  function parseCommit(subject, body = '', hash = '') {
438
- const match = /^([a-z]+)(?:\(([^)]+)\))?(!)?: (.+)$/i.exec(subject)
439
+ // Conventional Commits does not restrict the type to letters — `i18n:` and `a11y:` are
440
+ // types people really use, and `[a-z]+` silently failed to parse them at all.
441
+ const match = /^([a-z][a-z0-9-]*)(?:\(([^)]+)\))?(!)?: (.+)$/i.exec(subject)
439
442
  if (!match) return null
440
443
  const [, type, scope, bang, text] = match
441
444
  const shortHash = hash.slice(0, 7)
@@ -519,6 +522,7 @@ function changelogFromCommits(commits, links = null) {
519
522
  lines.push('')
520
523
  }
521
524
 
525
+ const known = new Set(CHANGELOG_SECTIONS.map((s) => s.type))
522
526
  for (const { type, section, hidden } of CHANGELOG_SECTIONS) {
523
527
  if (hidden) continue
524
528
  const inSection = parsed.filter(
@@ -530,6 +534,16 @@ function changelogFromCommits(commits, links = null) {
530
534
  lines.push('')
531
535
  }
532
536
 
537
+ // A conventional type nobody anticipated — `security:`, `i18n:` — is still a change
538
+ // someone made deliberately. Dropping it silently is how a security fix goes unmentioned.
539
+ // The hidden types are excluded because hiding them is the point.
540
+ const other = parsed.filter((c) => !known.has(c.type) && c.type !== 'feature')
541
+ if (other.length) {
542
+ lines.push('### Other Changes', '')
543
+ for (const c of other) lines.push(bullet(c, c.subject))
544
+ lines.push('')
545
+ }
546
+
533
547
  return lines.length ? lines.join('\n').trim() : null
534
548
  }
535
549
 
@@ -919,9 +933,29 @@ function readNameFrom(entry) {
919
933
  /** Normalise a versionFile / versionFiles entry to { path, pattern }. */
920
934
  const versionSource = (entry) => (typeof entry === 'string' ? { path: entry } : entry)
921
935
 
936
+ /**
937
+ * A lockfile records a version for every dependency — hundreds of them — so the first
938
+ * `version = "…"` in the file belongs to whichever crate sorts first, not to this project.
939
+ * Rewriting it corrupts an unrelated dependency, silently. Scope to the named package block.
940
+ */
941
+ function cargoLockPattern(lockPath) {
942
+ const sibling = join(dirname(lockPath), 'Cargo.toml')
943
+ const crate = existsSync(sibling) ? readNameFrom({ path: sibling }) : null
944
+ if (!crate) {
945
+ throw new Error(
946
+ `${lockPath} lists every dependency's version, so it needs to know which package is ` +
947
+ `yours.\n No Cargo.toml beside it to read the name from — give an explicit ` +
948
+ `pattern:\n { "path": "${lockPath}", "pattern": "name = \\"<crate>\\"\\nversion = ` +
949
+ `\\"(.+)\\"" }`,
950
+ )
951
+ }
952
+ return new RegExp(`\\[\\[package\\]\\]\\nname = "${escapeRe(crate)}"\\nversion = "([^"]*)"`)
953
+ }
954
+
922
955
  /** The regex for a source, or null when the whole file is the version. */
923
956
  function patternFor({ path, pattern }) {
924
957
  if (pattern) return new RegExp(pattern, 'm')
958
+ if (basename(path) === 'Cargo.lock') return cargoLockPattern(path)
925
959
  if (path.endsWith('.json')) return VERSION_PATTERNS.json
926
960
  if (path.endsWith('.toml')) return VERSION_PATTERNS.toml
927
961
  return null
@@ -941,9 +975,10 @@ function readVersionFrom(entry) {
941
975
  * Replace the version in a source file, touching nothing else: only the captured range is
942
976
  * rewritten, so formatting, key order and comments all survive.
943
977
  *
978
+ * @param {{dryRun?: boolean}} [options] report the change without making it
944
979
  * @returns {boolean} whether the file needed changing
945
980
  */
946
- function writeVersionInto(entry, version) {
981
+ function writeVersionInto(entry, version, { dryRun = false } = {}) {
947
982
  const source = versionSource(entry)
948
983
  const text = readFileSync(source.path, 'utf8')
949
984
  const pattern = patternFor(source)
@@ -1167,7 +1202,20 @@ if (versionFile && !existsSync(versionFile.path)) {
1167
1202
  abort(`versionFile ${versionFile.path} does not exist`)
1168
1203
  }
1169
1204
 
1170
- const currentVersion = versionFile ? readVersionFrom(versionFile) : null
1205
+ /**
1206
+ * Where a repository versions by tag alone — a Go module, a docs site — the latest tag is
1207
+ * the current version. Without this, `auto` and every bump have nothing to work from and
1208
+ * the version has to be typed out in full every time.
1209
+ */
1210
+ function versionFromLastTag() {
1211
+ const tag = tryRead('git', ['describe', '--tags', '--abbrev=0'])
1212
+ if (!tag) return null
1213
+ const bare =
1214
+ config.tagPrefix && tag.startsWith(config.tagPrefix) ? tag.slice(config.tagPrefix.length) : tag
1215
+ return parseVersion(bare) ? bare : null
1216
+ }
1217
+
1218
+ const currentVersion = versionFile ? readVersionFrom(versionFile) : versionFromLastTag()
1171
1219
  if (versionFile && !currentVersion) {
1172
1220
  abort(`could not read a version from ${versionFile.path}`)
1173
1221
  }
@@ -1259,14 +1307,17 @@ let version
1259
1307
  if (!target) {
1260
1308
  if (!currentVersion) {
1261
1309
  abort(
1262
- 'this repository has no versionFile, so there is no version to default to.\n' +
1263
- ' Pass one explicitly: release-kit 1.2.3',
1310
+ 'nothing to default to: this repository has no versionFile and no tag to read a ' +
1311
+ 'version from.\n Pass one explicitly: release-kit 1.2.3',
1264
1312
  )
1265
1313
  }
1266
1314
  version = currentVersion
1267
1315
  } else if (target === 'auto') {
1268
1316
  if (!currentVersion) {
1269
- abort('auto needs a versionFile to bump from. Pass a version explicitly instead.')
1317
+ abort(
1318
+ 'auto has nothing to bump from: there is no versionFile and no tag to read a version ' +
1319
+ 'from.\n Pass the first version explicitly: release-kit 0.1.0',
1320
+ )
1270
1321
  }
1271
1322
  const { commits, lastTag } = commitsSinceLastTag()
1272
1323
  if (!commits.length) {
@@ -1289,7 +1340,10 @@ if (!target) {
1289
1340
  }
1290
1341
  } else if (BUMPS.has(target)) {
1291
1342
  if (!currentVersion) {
1292
- abort(`a ${target} bump needs a versionFile to bump from. Pass a version explicitly instead.`)
1343
+ abort(
1344
+ `a ${target} bump has nothing to bump from: no versionFile, and no tag to read a ` +
1345
+ 'version from.\n Pass the version explicitly instead.',
1346
+ )
1293
1347
  }
1294
1348
  const preid = requestedPreid ?? preidOf(currentVersion)
1295
1349
  if (target.startsWith('pre') && !preid) {
@@ -1754,7 +1808,7 @@ if (bumping) {
1754
1808
  for (const entry of [versionFile, ...config.versionFiles]) {
1755
1809
  const source = versionSource(entry)
1756
1810
  if (!existsSync(source.path)) abort(`versionFiles entry ${source.path} does not exist`)
1757
- if (writeVersionInto(source, version)) {
1811
+ if (writeVersionInto(source, version, { dryRun })) {
1758
1812
  staged.push(source.path)
1759
1813
  console.log(` ${dryRun ? yellow('would write') : dim('wrote')} ${source.path}`)
1760
1814
  }