@entro314labs/release-kit 2.3.3 → 2.4.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 +39 -0
  2. package/package.json +5 -3
  3. package/release.mjs +24 -3
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
 
@@ -642,6 +670,17 @@ with the reasoning — are in [ROADMAP.md](ROADMAP.md).
642
670
 
643
671
  ## 🤝 Contributing
644
672
 
673
+ ```sh
674
+ pnpm install
675
+ pnpm test # 63 tests, node --test, no framework
676
+ pnpm check # format + lint + tests, the same gate CI runs
677
+ ```
678
+
679
+ `test/` holds unit suites for the pure functions and an integration suite that builds real
680
+ throwaway repositories with a real bare remote and stubbed `gh`/`npm`. The integration tests
681
+ pin defects found in use, so a name like "refuses to reuse a tag while still producing a
682
+ commit" is describing something that actually happened.
683
+
645
684
  The tool releases itself, so a change ships the same way it would in any consuming project:
646
685
  add a `## [Unreleased]` entry to `CHANGELOG.md`, then run `pnpm release <bump>` from a clone.
647
686
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@entro314labs/release-kit",
3
- "version": "2.3.3",
3
+ "version": "2.4.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
  // ─────────────────────────────────────────────────────────────────────────────
@@ -919,9 +919,29 @@ function readNameFrom(entry) {
919
919
  /** Normalise a versionFile / versionFiles entry to { path, pattern }. */
920
920
  const versionSource = (entry) => (typeof entry === 'string' ? { path: entry } : entry)
921
921
 
922
+ /**
923
+ * A lockfile records a version for every dependency — hundreds of them — so the first
924
+ * `version = "…"` in the file belongs to whichever crate sorts first, not to this project.
925
+ * Rewriting it corrupts an unrelated dependency, silently. Scope to the named package block.
926
+ */
927
+ function cargoLockPattern(lockPath) {
928
+ const sibling = join(dirname(lockPath), 'Cargo.toml')
929
+ const crate = existsSync(sibling) ? readNameFrom({ path: sibling }) : null
930
+ if (!crate) {
931
+ throw new Error(
932
+ `${lockPath} lists every dependency's version, so it needs to know which package is ` +
933
+ `yours.\n No Cargo.toml beside it to read the name from — give an explicit ` +
934
+ `pattern:\n { "path": "${lockPath}", "pattern": "name = \\"<crate>\\"\\nversion = ` +
935
+ `\\"(.+)\\"" }`,
936
+ )
937
+ }
938
+ return new RegExp(`\\[\\[package\\]\\]\\nname = "${escapeRe(crate)}"\\nversion = "([^"]*)"`)
939
+ }
940
+
922
941
  /** The regex for a source, or null when the whole file is the version. */
923
942
  function patternFor({ path, pattern }) {
924
943
  if (pattern) return new RegExp(pattern, 'm')
944
+ if (basename(path) === 'Cargo.lock') return cargoLockPattern(path)
925
945
  if (path.endsWith('.json')) return VERSION_PATTERNS.json
926
946
  if (path.endsWith('.toml')) return VERSION_PATTERNS.toml
927
947
  return null
@@ -941,9 +961,10 @@ function readVersionFrom(entry) {
941
961
  * Replace the version in a source file, touching nothing else: only the captured range is
942
962
  * rewritten, so formatting, key order and comments all survive.
943
963
  *
964
+ * @param {{dryRun?: boolean}} [options] report the change without making it
944
965
  * @returns {boolean} whether the file needed changing
945
966
  */
946
- function writeVersionInto(entry, version) {
967
+ function writeVersionInto(entry, version, { dryRun = false } = {}) {
947
968
  const source = versionSource(entry)
948
969
  const text = readFileSync(source.path, 'utf8')
949
970
  const pattern = patternFor(source)
@@ -1754,7 +1775,7 @@ if (bumping) {
1754
1775
  for (const entry of [versionFile, ...config.versionFiles]) {
1755
1776
  const source = versionSource(entry)
1756
1777
  if (!existsSync(source.path)) abort(`versionFiles entry ${source.path} does not exist`)
1757
- if (writeVersionInto(source, version)) {
1778
+ if (writeVersionInto(source, version, { dryRun })) {
1758
1779
  staged.push(source.path)
1759
1780
  console.log(` ${dryRun ? yellow('would write') : dim('wrote')} ${source.path}`)
1760
1781
  }