@entro314labs/release-kit 2.9.2 → 2.9.4

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 CHANGED
@@ -185,20 +185,22 @@ Prerelease bumps need `--preid` unless the current version already carries one t
185
185
 
186
186
  ### Flags
187
187
 
188
- | Flag | Effect |
189
- | ---------------------------- | -------------------------------------------------------------------------- |
190
- | `--only <steps>` | Run only these steps, comma-separated. |
191
- | `--skip <steps>` | Run every step except these. |
192
- | `--commit` | Force the `commit` step on when a `steps` config removed it. |
193
- | `--dry-run` | Print every step, execute nothing. Preflight still runs and still reports. |
194
- | `--yes`, `-y` | Skip the confirmation prompt. |
195
- | `--preid <id>` | Prerelease identifier: `alpha`, `beta`, `rc`, `next`, `nightly`, `canary`. |
196
- | `--dist-tag <name>` | Override the npm dist-tag. Always wins over the derived one. |
197
- | `--assistant <name>` | Drafting CLI: `auto`, `none`, `claude`, `codex`. |
198
- | `--assistant-model <name>` | Model the assistant runs with. |
199
- | `--assistant-effort <level>` | Reasoning effort the assistant runs with. |
200
- | `--sync <dir>...` | Copy this script into other projects and exit. Touches no git state. |
201
- | `--help`, `-h` | Full flag list. |
188
+ | Flag | Effect |
189
+ | ---------------------------- | ----------------------------------------------------------------------------- |
190
+ | `--only <steps>` | Run only these steps, comma-separated. |
191
+ | `--skip <steps>` | Run every step except these. |
192
+ | `--commit` | Force the `commit` step on when a `steps` config removed it. |
193
+ | `--dry-run` | Print every step, execute nothing. Preflight still runs and still reports. |
194
+ | `--yes`, `-y` | Skip the confirmation prompt. |
195
+ | `--preid <id>` | Prerelease identifier: `alpha`, `beta`, `rc`, `next`, `nightly`, `canary`. |
196
+ | `--dist-tag <name>` | Override the npm dist-tag. Always wins over the derived one. |
197
+ | `--notes <source>` | Where notes come from: `auto`, `changelog`, `assistant`, `commits`, `github`. |
198
+ | `--notes-file <path>` | Write the resolved notes to a file for the next tool — see `notesFile`. |
199
+ | `--assistant <name>` | Drafting CLI: `auto`, `none`, `claude`, `codex`. |
200
+ | `--assistant-model <name>` | Model the assistant runs with. |
201
+ | `--assistant-effort <level>` | Reasoning effort the assistant runs with. |
202
+ | `--sync <dir>...` | Copy this script into other projects and exit. Touches no git state. |
203
+ | `--help`, `-h` | Full flag list. |
202
204
 
203
205
  ### Linting commits
204
206
 
@@ -350,6 +352,13 @@ rather than stopping at the first problem.
350
352
 
351
353
  - The target version is greater than the current one — and for `auto`, which bump the
352
354
  commits imply and why
355
+ - The version step will write the target, when the target differs from what the files say
356
+ — a tag naming one version over a manifest carrying another is refused, since
357
+ `npm publish` sends the manifest
358
+ - A relative bump is not counting from a version a dead run wrote and never committed
359
+ (the file says one thing on disk and another at `HEAD`), and not skipping past a release
360
+ tagged at `HEAD` that never reached the registry — both are refused with the command that
361
+ finishes the earlier release instead
353
362
  - Working tree is clean, or listed for commit when the `commit` step runs
354
363
  - On the configured branch, and not on a detached HEAD
355
364
  - The remote exists, is reachable, and the branch is not behind it
@@ -393,6 +402,13 @@ last tag, and after a failed publish there are none — the tag it would read fr
393
402
  the dead run made. Rather than aborting with "no releasable commits", it finishes that
394
403
  release: same version, same tag, the steps that remain.
395
404
 
405
+ A relative bump is the one target that cannot be re-run as-is, because it counts from the
406
+ version the dead run already wrote: `minor` after a dead `minor` would release `1.2.0`
407
+ from the commit `v1.1.0` already tags. Preflight refuses both shapes of that — the bump
408
+ written but never committed, and the tag at `HEAD` that never reached the registry — and
409
+ names the command that finishes the earlier release: `release-kit 1.1.0`, or no target, or
410
+ `auto`.
411
+
396
412
  ### A release that was never published
397
413
 
398
414
  A tag is not a release. The tag and the push happen before the publish, so a publish that
@@ -666,23 +682,27 @@ execution is not wired up yet.
666
682
  `release.config.json`, beside `package.json`. Every key is optional; unknown keys abort
667
683
  rather than being silently ignored.
668
684
 
669
- | Key | Default | Meaning |
670
- | --------------- | ---------------------- | --------------------------------------------------------------------- |
671
- | `steps` | all but `commit` | Which steps run; the order is fixed |
672
- | `tagPrefix` | `"v"` | Prepended to the version to form the tag |
673
- | `branch` | `"main"` | The only branch a release may run from; `null` allows any |
674
- | `remote` | `"origin"` | Git remote to push to |
675
- | `changelog` | `"CHANGELOG.md"` | Changelog path; `null` for a project without one |
676
- | `versionFile` | detected | Where the version lives; `null` versions by tag alone |
677
- | `versionFiles` | detected | Further files kept in sync; a path or `{ path, pattern }` |
678
- | `publish` | detected | Publish command, or an array of them; `null` publishes nothing |
679
- | `versioning` | `"conventional"` | How `auto` infers; or `always-patch` / `-minor` / `-major` |
680
- | `verify` | `null` | Command run during preflight; non-zero aborts before anything mutates |
681
- | `hooks` | `{}` | Commands run between the steps — see [Hooks](#-hooks) |
682
- | `assistant` | `null` | Drafting CLI: a name, `"auto"`, or `{ tool, model, effort }` |
683
- | `commitMessage` | `"chore(release): %t"` | Release commit subject |
684
- | `releaseTitle` | `"%t"` | GitHub release title |
685
- | `assets` | `[]` | Files attached to the GitHub release |
685
+ | Key | Default | Meaning |
686
+ | --------------- | ---------------------- | ----------------------------------------------------------------------- |
687
+ | `steps` | all seven | Which steps run; the order is fixed. `commit` no-ops on a clean tree |
688
+ | `tagPrefix` | `"v"` | Prepended to the version to form the tag |
689
+ | `branch` | `"main"` | The only branch a release may run from; `null` allows any |
690
+ | `remote` | `"origin"` | Git remote to push to |
691
+ | `changelog` | `"CHANGELOG.md"` | Changelog path; `null` for a project without one |
692
+ | `versionFile` | detected | Where the version lives; `null` versions by tag alone |
693
+ | `versionFiles` | detected | Further files kept in sync; a path or `{ path, pattern }` |
694
+ | `publish` | detected | Publish command, or an array of them; `null` publishes nothing |
695
+ | `versioning` | `"conventional"` | How `auto` infers; or `always-patch` / `-minor` / `-major` |
696
+ | `verify` | `null` | Command run during preflight; non-zero aborts before anything mutates |
697
+ | `hooks` | `{}` | Commands run between the steps — see [Hooks](#-hooks) |
698
+ | `assistant` | `null` | Drafting CLI: a name, `"auto"`, or `{ tool, model, effort }` |
699
+ | `commitMessage` | `"chore(release): %t"` | Release commit subject |
700
+ | `releaseTitle` | `"%t"` | GitHub release title |
701
+ | `assets` | `[]` | Files attached to the GitHub release |
702
+ | `notes` | `"auto"` | Notes source; or force `changelog` / `assistant` / `commits` / `github` |
703
+ | `notesFile` | `null` | Write the resolved notes here for the tool that runs next |
704
+ | `hiddenTypes` | `[]` | Commit types left out of commit-derived notes |
705
+ | `ignoreCommits` | release, merge, wip… | Regexes for commits that are bookkeeping rather than change |
686
706
 
687
707
  Command and message strings expand four tokens: `%v` version, `%t` tag, `%n` package
688
708
  name, `%d` npm dist-tag. In the `publish` command line the substituted values are
@@ -774,7 +794,10 @@ Two npm behaviours are handled automatically:
774
794
  at all.** In GitHub Actions with
775
795
  `id-token: write`, or GitLab CI/CircleCI with `NPM_ID_TOKEN`, `whoami` fails while
776
796
  `publish` succeeds. That environment is detected and the auth check is skipped, so a
777
- valid CI release is not aborted over a missing token it does not need.
797
+ valid CI release is not aborted over a missing token it does not need. That covers the
798
+ CLIs that exchange the OIDC token themselves — npm, pnpm, bun and `uv publish`. cargo is
799
+ not one of them: crates.io's trusted publishing goes through an action that turns the
800
+ token into `CARGO_REGISTRY_TOKEN`, so the cargo credential check still applies in CI.
778
801
 
779
802
  ### Examples
780
803
 
@@ -933,11 +956,13 @@ release themselves, with the artifacts attached. That is their job. release-kit'
933
956
  at the pushed tag:
934
957
 
935
958
  ```json
936
- { "steps": ["commit", "version", "changelog", "tag", "push"], "notesFile": "dist-notes.md" }
959
+ { "steps": ["commit", "version", "changelog", "tag", "push"] }
937
960
  ```
938
961
 
939
962
  Nothing after `push` — no `publish`, no `release`. The tag push is the handoff, and it is
940
- what triggers the build workflow:
963
+ what triggers the build workflow. The notes travel in the annotated tag, which is the one
964
+ thing that reaches a fresh checkout on another machine; the signature block a signed tag
965
+ appends is stripped before the file is handed over:
941
966
 
942
967
  ```yaml
943
968
  on:
@@ -949,6 +974,10 @@ jobs:
949
974
  steps:
950
975
  - uses: actions/checkout@v5
951
976
  with: { fetch-depth: 0 }
977
+ - name: Read the release notes off the tag
978
+ run: |
979
+ git tag -l --format='%(contents)' "$GITHUB_REF_NAME" \
980
+ | sed '/^-----BEGIN [A-Z ]*SIGNATURE-----$/,$d' > dist-notes.md
952
981
  - run: goreleaser release --clean --release-notes dist-notes.md
953
982
  ```
954
983
 
@@ -968,10 +997,11 @@ generated by different code from the same commits, and they drift.
968
997
  release-kit has already created one for that tag, goreleaser fails. Exactly one of them
969
998
  should own it, and it should be the one attaching the binaries.
970
999
 
971
- `notesFile` exists for this handoff: goreleaser's `--release-notes` takes a file and skips
972
- its own changelog generation. The notes are also in the annotated tag, but reading them back
973
- with `git tag --format='%(contents)'` embeds the signature when tags are signed, which then
974
- appears in your published release notes. `notesFile` writes the text itself.
1000
+ `notesFile` is the same handoff for a build tool that runs next **on the same machine**:
1001
+ `release-kit auto && goreleaser release --release-notes dist-notes.md`. It writes the text
1002
+ itself, with no signature to strip. It is written after the release commit and is not part
1003
+ of it, so a workflow on another machine never sees it — that is what the tag read above is
1004
+ for.
975
1005
 
976
1006
  ### Both at once
977
1007
 
package/TRAIN.md CHANGED
@@ -12,7 +12,11 @@ readable, vendorable, zero dependencies.
12
12
  **Status: prototype.** Discovery, graph derivation, registry-aware change detection,
13
13
  cascade, planning, whole-train preflight, `seed-tags`, and the train summary work.
14
14
  Execution (running release-kit per package) is not implemented yet — `train` without
15
- `--dry-run` says so and exits.
15
+ `--dry-run` says so and exits. Three things the design below describes are not built
16
+ either, and the plan does not claim them: taking `packages` from `pnpm-workspace.yaml` /
17
+ `workspaces` when the config omits it (the config must list them today), and the two
18
+ per-package authentication checks in the preflight table (publish CLI and `gh`), which
19
+ each package's own release-kit run performs when execution lands.
16
20
 
17
21
  ```sh
18
22
  release-train graph # print the derived dependency graph and topo order
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@entro314labs/release-kit",
3
- "version": "2.9.2",
3
+ "version": "2.9.4",
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,11 +49,11 @@
49
49
  "release": "node release.mjs"
50
50
  },
51
51
  "devDependencies": {
52
- "oxfmt": "^0.65.0",
53
- "oxlint": "^1.80.0",
52
+ "oxfmt": "^0.67.0",
53
+ "oxlint": "^1.82.0",
54
54
  "semver": "^7.8.5"
55
55
  },
56
56
  "engines": {
57
- "node": ">=22"
57
+ "node": ">=24"
58
58
  }
59
59
  }
package/release.mjs CHANGED
@@ -47,6 +47,7 @@ import {
47
47
  import { homedir, tmpdir } from 'node:os'
48
48
  import { basename, dirname, join, relative, resolve, sep } from 'node:path'
49
49
  import { createInterface } from 'node:readline/promises'
50
+ import { fileURLToPath } from 'node:url'
50
51
 
51
52
  // ─────────────────────────────────────────────────────────────────────────────
52
53
  // CONFIG
@@ -115,9 +116,10 @@ import { createInterface } from 'node:readline/promises'
115
116
  const STEPS = ['commit', 'version', 'changelog', 'tag', 'push', 'publish', 'release']
116
117
 
117
118
  /**
118
- * All seven. `commit` is a conditional default: it no-ops on a clean tree, and on a dirty
119
- * tree it proceeds only when a drafting assistant is configured — otherwise preflight
120
- * still refuses the unclean tree. Opt out with `--skip commit` or a `steps` config.
119
+ * All seven. `commit` no-ops on a clean tree; on a dirty tree it stages everything and
120
+ * commits it, with a drafted message when an assistant is configured and a generated
121
+ * `chore:` message naming the files otherwise. Opt out with `--skip commit` or a `steps`
122
+ * config, which restores the refusal on a dirty tree.
121
123
  */
122
124
  const DEFAULT_STEPS = [...STEPS]
123
125
 
@@ -185,11 +187,13 @@ release-kit — tag, publish, and release a JS/TS/Node project.
185
187
 
186
188
  Target (optional; defaults to the version already in package.json):
187
189
  <x.y.z> release this exact version
190
+ auto infer the bump from the commits since the last tag
188
191
  patch minor major bump from the current version
189
192
  prepatch preminor premajor prerelease
190
193
  prerelease bump; needs --preid unless it can be inferred
191
194
 
192
- Steps, in the fixed order they run. All but "commit" run by default:
195
+ Steps, in the fixed order they run. All seven run by default; "commit" no-ops on a
196
+ clean tree:
193
197
  ${STEPS.join(' ')}
194
198
 
195
199
  Subcommands (they check, print or copy, and never start a release):
@@ -1352,7 +1356,7 @@ function distTagFor(version, explicitTag) {
1352
1356
  throw new Error(
1353
1357
  `prerelease identifier "${label}" maps to no known dist-tag ` +
1354
1358
  `(${[...KNOWN_CHANNELS].sort().join(', ')}). Publishing it as "latest" would ` +
1355
- `clobber the stable line — pass --tag <dist-tag> to choose one explicitly.`,
1359
+ `clobber the stable line — pass --dist-tag <name> to choose one explicitly.`,
1356
1360
  )
1357
1361
  }
1358
1362
 
@@ -1405,16 +1409,26 @@ const sectionOffsets = (text) => [...text.matchAll(/^## .*$/gm)].map((m) => m.in
1405
1409
  */
1406
1410
  function insertChangelogSection(text, version, date, body) {
1407
1411
  const entry = `## [${version}] - ${date}\n\n${body}\n`
1408
- for (const offset of sectionOffsets(text)) {
1412
+ const offsets = sectionOffsets(text)
1413
+ let comparable = false
1414
+ for (const offset of offsets) {
1409
1415
  const heading = /^## \[?v?([\d.]+(?:-[\w.]+)?)\]?/m.exec(
1410
1416
  text.slice(offset, text.indexOf('\n', offset)),
1411
1417
  )
1412
- // An [Unreleased] heading has no version and always stays above the releases.
1413
- if (!heading) continue
1418
+ // Headings that carry no version — [Unreleased], and the date headings a changelog
1419
+ // that never adopted semver is made of — are not positions this can order against.
1420
+ if (!heading || !parseVersion(heading[1])) continue
1421
+ comparable = true
1414
1422
  if (compareVersions(version, heading[1]) > 0) {
1415
1423
  return `${text.slice(0, offset)}${entry}\n${text.slice(offset)}`
1416
1424
  }
1417
1425
  }
1426
+ // Falling out of the loop means every version section is newer, so the release belongs
1427
+ // at the foot. With nothing to compare against it belongs at the head instead: appending
1428
+ // to a date-headed changelog would file the release below its oldest entry.
1429
+ if (!comparable && offsets.length) {
1430
+ return `${text.slice(0, offsets[0])}${entry}\n${text.slice(offsets[0])}`
1431
+ }
1418
1432
  const trimmed = text.trimEnd()
1419
1433
  return `${trimmed}\n\n${entry}`
1420
1434
  }
@@ -1662,7 +1676,18 @@ function patternFor({ path, pattern, all = false }) {
1662
1676
  /** @returns {string | null} the version recorded in a source file */
1663
1677
  function readVersionFrom(entry) {
1664
1678
  const source = versionSource(entry)
1665
- const text = readFileSync(source.path, 'utf8')
1679
+ return versionInText(source, readFileSync(source.path, 'utf8'))
1680
+ }
1681
+
1682
+ /**
1683
+ * The version a source file's text carries, resolved the same way it will be written.
1684
+ *
1685
+ * Split from `readVersionFrom` so the text can come from somewhere other than the working
1686
+ * tree — `git show HEAD:<path>` — and still be read by the resolver the write uses.
1687
+ *
1688
+ * @returns {string | null}
1689
+ */
1690
+ function versionInText(source, text) {
1666
1691
  const { kind, shape } = versionMode(source, text)
1667
1692
  if (kind === 'bare') return text.trim() || null
1668
1693
  if (kind === 'markers') {
@@ -1885,7 +1910,11 @@ if (flag('--help') || flag('-h')) {
1885
1910
 
1886
1911
  // --sync copies this file into other projects and exits; it touches no git state.
1887
1912
  if (flag('--sync')) {
1888
- const self = new URL(import.meta.url).pathname
1913
+ // A URL's pathname is percent-encoded and keeps the leading slash before a Windows
1914
+ // drive letter, so a script installed under a directory with a space in its name — or
1915
+ // anywhere on Windows — was reported as "piped from stdin". fileURLToPath is the
1916
+ // inverse of what Node did to build import.meta.url.
1917
+ const self = fileURLToPath(import.meta.url)
1889
1918
  // Piped from stdin (`curl … | node -`) there is no file to copy: import.meta.url points
1890
1919
  // at a synthetic [eval] path. Say so instead of failing on a missing file.
1891
1920
  if (!existsSync(self)) {
@@ -2012,6 +2041,9 @@ const VALUE_OPTIONS = new Set([
2012
2041
  '--assistant-effort',
2013
2042
  ])
2014
2043
 
2044
+ /** Flags that take no value. With VALUE_OPTIONS, the whole vocabulary this file accepts. */
2045
+ const BOOLEAN_FLAGS = new Set(['--dry-run', '--yes', '-y', '--commit', '--help', '-h', '--sync'])
2046
+
2015
2047
  /** The version or bump target: the only argument that is neither a flag nor a flag's value. */
2016
2048
  const positionals = []
2017
2049
  for (let i = 0; i < argv.length; i += 1) {
@@ -2019,7 +2051,20 @@ for (let i = 0; i < argv.length; i += 1) {
2019
2051
  i += 1
2020
2052
  continue
2021
2053
  }
2022
- if (!argv[i].startsWith('-')) positionals.push(argv[i])
2054
+ if (!argv[i].startsWith('-')) {
2055
+ positionals.push(argv[i])
2056
+ continue
2057
+ }
2058
+ // An unknown flag used to be dropped without a word, and `--auto` is the one people
2059
+ // reach for: it released whatever version package.json already carried, which is a
2060
+ // different release from the `auto` they asked for.
2061
+ if (!BOOLEAN_FLAGS.has(argv[i])) {
2062
+ const bare = argv[i].replace(/^-+/, '')
2063
+ const hint = BUMPS.has(bare)
2064
+ ? `\n The target is positional: ${INVOCATION} ${bare}, not ${argv[i]}`
2065
+ : ''
2066
+ abort(`unknown flag: ${argv[i]}${hint}`)
2067
+ }
2023
2068
  }
2024
2069
  const target = positionals[0]
2025
2070
  // A second positional is always a mistake, and silently ignoring it changes the release
@@ -2542,6 +2587,23 @@ let autoBump = null
2542
2587
  /** The tag of a previous release this run is finishing rather than starting. */
2543
2588
  let resuming = null
2544
2589
 
2590
+ /**
2591
+ * A release tagged at HEAD that never reached the registry, found while resolving a
2592
+ * relative bump. `auto` finishes such a release; a `patch`/`minor`/`major` cannot — it
2593
+ * would bump past it, tag a second version on the same commit, and leave the first one
2594
+ * unpublished for good. Preflight refuses it and names the command that finishes it.
2595
+ */
2596
+ let unfinishedAtHead = null
2597
+
2598
+ /**
2599
+ * A release that died after the tag and before the publish is finished by re-running the
2600
+ * same command — but only while nothing new has happened. A commit or a working tree that
2601
+ * `--commit` is about to turn into one moves HEAD past the tag, and publishing then would
2602
+ * ship a tree the tag does not describe; that work belongs in the next version, which is
2603
+ * what the shipped-tag baseline makes sure it is released as.
2604
+ */
2605
+ const wouldCommitMore = !!tryRead('git', ['status', '--porcelain']) && runs('commit')
2606
+
2545
2607
  let version
2546
2608
  if (!target) {
2547
2609
  if (!currentVersion) {
@@ -2558,12 +2620,6 @@ if (!target) {
2558
2620
  'from.\n Pass the first version explicitly: release-kit 0.1.0',
2559
2621
  )
2560
2622
  }
2561
- // A release that died after the tag and before the publish is finished by re-running the
2562
- // same command — but only while nothing new has happened. A commit or a working tree that
2563
- // `--commit` is about to turn into one moves HEAD past the tag, and publishing then would
2564
- // ship a tree the tag does not describe; that work belongs in the next version, which is
2565
- // what the baseline below makes sure it is released as.
2566
- const wouldCommitMore = !!tryRead('git', ['status', '--porcelain']) && runs('commit')
2567
2623
  const pending = wouldCommitMore ? null : unfinishedRelease()
2568
2624
  if (pending) {
2569
2625
  ;({ name: resuming, version } = pending)
@@ -2601,6 +2657,10 @@ if (!target) {
2601
2657
  `a ${target} bump from a stable version needs --preid <${[...KNOWN_CHANNELS].sort().join('|')}>`,
2602
2658
  )
2603
2659
  }
2660
+ // The same question `auto` asks, with the opposite answer: `auto` finishes the release
2661
+ // it finds at HEAD, a named bump would skip past it. Recorded here, refused in preflight
2662
+ // with the rest.
2663
+ unfinishedAtHead = wouldCommitMore ? null : unfinishedRelease()
2604
2664
  version = incrementVersion(currentVersion, target, preid)
2605
2665
  } else if (parseVersion(target)) {
2606
2666
  version = target
@@ -2739,6 +2799,33 @@ if (resuming) {
2739
2799
  ok(`finishing ${resuming}: it was tagged and pushed, but never reached the registry`)
2740
2800
  }
2741
2801
 
2802
+ if (unfinishedAtHead) {
2803
+ fail(
2804
+ `${unfinishedAtHead.name} is tagged at HEAD but never reached the registry, and a ` +
2805
+ `${target} bump would release ${version} from the same commit and leave it that way.\n` +
2806
+ ` Finish it instead: re-run with no target, or with auto.`,
2807
+ )
2808
+ }
2809
+
2810
+ // A run that wrote the version and died before its release commit — a failing lockfile
2811
+ // refresh, an afterVersion hook, a commit hook — leaves the bump on disk and nowhere
2812
+ // else. The current version is then read from that file, and a relative bump counts from
2813
+ // it: `minor` after a dead `minor` released 1.2.0 with 1.1.0 tagged nowhere, its changelog
2814
+ // section documenting a version that never existed. Only a bump is affected; an explicit
2815
+ // version, or none, releases what is on disk and the commit step carries it.
2816
+ if (target && BUMPS.has(target) && versionFile && currentVersion) {
2817
+ const committed = tryRead('git', ['show', `HEAD:${versionFile.path}`])
2818
+ const headVersion = committed === null ? null : versionInText(versionFile, committed)
2819
+ if (headVersion && headVersion !== currentVersion) {
2820
+ fail(
2821
+ `${versionFile.path} says ${currentVersion} on disk but ${headVersion} at HEAD — an ` +
2822
+ `uncommitted version bump, which a ${target} bump would count from and skip past.\n` +
2823
+ ` Finish it with \`${INVOCATION} ${currentVersion}\`, or restore the file: ` +
2824
+ `git restore ${versionFile.path}`,
2825
+ )
2826
+ }
2827
+ }
2828
+
2742
2829
  // A previous release that never shipped is not history — its commits are still owed to
2743
2830
  // whoever installs this package, and they are in this release's range because of it. Say
2744
2831
  // so: the changelog keeps the section that was written for that version, and a section
@@ -2746,7 +2833,9 @@ if (resuming) {
2746
2833
  const absorbed = absorbedReleaseTags({ stable: !isPrerelease }).filter(
2747
2834
  (entry) => entry.version !== version,
2748
2835
  )
2749
- if (absorbed.length) {
2836
+ // Not when the run is being refused for exactly this: saying the commits ship in a
2837
+ // version that will not be released contradicts the failure above it.
2838
+ if (absorbed.length && !unfinishedAtHead) {
2750
2839
  const names = absorbed.map((entry) => entry.name).join(', ')
2751
2840
  const many = absorbed.length > 1
2752
2841
  const existingChangelog =
@@ -2779,13 +2868,32 @@ if (bumping) {
2779
2868
  }
2780
2869
  if (source.optional) continue
2781
2870
  const text = readFileSync(source.path, 'utf8')
2782
- const { kind, shape } = versionMode(source, text)
2871
+ // Resolving the mode can itself refuse — a Cargo.lock with nothing beside it to say
2872
+ // which crate is yours — and the write step must not be where that is discovered.
2873
+ let mode
2874
+ try {
2875
+ mode = versionMode(source, text)
2876
+ } catch (err) {
2877
+ fail(err.message)
2878
+ continue
2879
+ }
2880
+ const { kind, shape } = mode
2783
2881
  if (kind === 'pattern' && !shape.test(text)) {
2784
2882
  fail(
2785
2883
  `${source.path} has no version for release-kit to replace.\n` +
2786
2884
  ' Remove it from versionFiles, mark the line with x-release-kit-version, ' +
2787
2885
  'or give the entry a "pattern".',
2788
2886
  )
2887
+ } else if (kind === 'bare' && text.trim() && !parseVersion(text.trim())) {
2888
+ // The whole-file mode is right for a VERSION file and catastrophic for anything
2889
+ // else. `writeVersionInto` refuses it too, but by then the files before it in the
2890
+ // list have already changed.
2891
+ fail(
2892
+ `${source.path} is not a file containing only a version, and carries no ` +
2893
+ 'x-release-kit-version marker.\n Writing the version into it would replace ' +
2894
+ 'everything else in it. Mark the line that holds the version, or give the entry ' +
2895
+ 'a "pattern".',
2896
+ )
2789
2897
  }
2790
2898
  }
2791
2899
  }
@@ -2797,6 +2905,17 @@ if (bumping && currentVersion && compareVersions(version, currentVersion) <= 0)
2797
2905
  } else if (bumping) {
2798
2906
  // No manifest and no tag to read a version from, but files to write one into.
2799
2907
  ok(`writing ${version} into ${versionTargets.map((source) => source.path).join(', ')}`)
2908
+ } else if (versionTargets.length && version !== currentVersion) {
2909
+ // The version step is off and the target is not what the files say. The tag would name
2910
+ // one version and the manifest — which is what `npm publish` sends and what the build
2911
+ // compiles in — another. There is no release in which those two are allowed to differ.
2912
+ const files = versionTargets.map((source) => source.path).join(', ')
2913
+ fail(
2914
+ `the version step is not selected, but ${version} is not the version in ${files}` +
2915
+ `${currentVersion ? ` (${currentVersion})` : ''}.\n` +
2916
+ ' The tag would say one version and the files another. Add "version" to the ' +
2917
+ 'steps, or release the version already there.',
2918
+ )
2800
2919
  } else if (versionFile) {
2801
2920
  ok(`releasing the version already in ${versionFile.path} (${version})`)
2802
2921
  } else {
@@ -3000,9 +3119,17 @@ if (!runs('release')) {
3000
3119
  if (releaseExists) note(`a GitHub release for ${tag} already exists — will skip that step`)
3001
3120
  }
3002
3121
 
3122
+ /**
3123
+ * CLIs that publish over OIDC with no token of their own once the CI job can mint one:
3124
+ * npm, pnpm and bun exchange it with the registry themselves, and so does `uv publish`.
3125
+ * cargo does not — crates.io's trusted publishing goes through an action that turns the
3126
+ * OIDC token into a `CARGO_REGISTRY_TOKEN`, so for cargo the token check still stands.
3127
+ */
3128
+ const OIDC_CLIS = new Set([...NPM_CLIS, 'uv'])
3129
+
3003
3130
  /** Whether one publish CLI can publish at all: OIDC, a token, or a live session. */
3004
3131
  function checkCredentials({ cli, registry, command }) {
3005
- if (isTrustedPublishing) {
3132
+ if (isTrustedPublishing && OIDC_CLIS.has(cli)) {
3006
3133
  ok(`${cli}: trusted publishing (OIDC) — no token needed`)
3007
3134
  // Provenance is the other half of what OIDC makes possible: a signed attestation
3008
3135
  // tying the published artefact to the workflow and commit that produced it. It is
@@ -3226,7 +3353,9 @@ for (const asset of config.assets) {
3226
3353
  const wouldCommit = [
3227
3354
  dirty && runs('commit') && 'the working tree',
3228
3355
  bumping && 'a version bump',
3229
- rolledChangelog && 'a changelog entry',
3356
+ // The roll is computed whenever [Unreleased] is populated, because the notes come from
3357
+ // it either way; it is only written — and only becomes a commit — when the step runs.
3358
+ rolledChangelog && runs('changelog') && 'a changelog entry',
3230
3359
  ].filter(Boolean)
3231
3360
  if (taggedCommit && runs('tag') && wouldCommit.length) {
3232
3361
  fail(
@@ -3429,15 +3558,37 @@ for (const target of publishTargets) {
3429
3558
  // target published nothing, and telling downstream otherwise is a lie it may act on.
3430
3559
  if (publishedSomething) runHook('afterPublish')
3431
3560
 
3561
+ /**
3562
+ * Whether a stable release above this version already exists, anywhere in the
3563
+ * repository — not only in this branch's history, since a patch on an older line is
3564
+ * cut from a branch the newer minor was never merged into.
3565
+ *
3566
+ * GitHub's "Latest" badge is what `releases/latest` resolves to, and `--latest`
3567
+ * forces it. A backport patch that took it would point every "download the latest
3568
+ * release" link at the older line.
3569
+ */
3570
+ function supersededByExistingRelease() {
3571
+ const listed = tryRead('git', ['tag', '--list', `${config.tagPrefix}*`]) ?? ''
3572
+ return listed
3573
+ .split('\n')
3574
+ .map((name) => name.trim().slice(config.tagPrefix.length))
3575
+ .filter((v) => parseVersion(v) && !parseVersion(v).pre.length)
3576
+ .some((v) => compareVersions(v, version) > 0)
3577
+ }
3578
+
3432
3579
  if (runs('release') && !releaseExists) {
3433
3580
  step(`GitHub release ${tag}`)
3581
+ const latest = !isPrerelease && !supersededByExistingRelease()
3582
+ if (!isPrerelease && !latest) {
3583
+ note(`a newer stable release is already tagged — ${tag} will not be marked Latest`)
3584
+ }
3434
3585
  const args = [
3435
3586
  'release',
3436
3587
  'create',
3437
3588
  tag,
3438
3589
  '--title',
3439
3590
  expand(config.releaseTitle),
3440
- isPrerelease ? '--prerelease' : '--latest',
3591
+ isPrerelease ? '--prerelease' : `--latest=${latest}`,
3441
3592
  // Notes arrive on stdin, so there is no temp file and nothing to escape.
3442
3593
  ...(notes ? ['--notes-file', '-'] : ['--generate-notes']),
3443
3594
  ...config.assets,
package/train.mjs CHANGED
@@ -78,6 +78,11 @@ export function parseSemver(version) {
78
78
  return { major: +match[1], minor: +match[2], patch: +match[3], prerelease: match[4] ?? null }
79
79
  }
80
80
 
81
+ /**
82
+ * Precedence per semver §11, the same ordering release.mjs `compareVersions` applies. It
83
+ * has to be, because this decides which tag is a package's last release: ranking every
84
+ * prerelease equal left `rc.9` and `rc.10` in `git tag` order, which is alphabetical.
85
+ */
81
86
  export function compareSemver(a, b) {
82
87
  const pa = parseSemver(a)
83
88
  const pb = parseSemver(b)
@@ -85,18 +90,45 @@ export function compareSemver(a, b) {
85
90
  for (const key of ['major', 'minor', 'patch']) {
86
91
  if (pa[key] !== pb[key]) return pa[key] - pb[key]
87
92
  }
88
- if (pa.prerelease && !pb.prerelease) return -1
89
- if (!pa.prerelease && pb.prerelease) return 1
93
+ if (!pa.prerelease && !pb.prerelease) return 0
94
+ if (!pa.prerelease) return 1
95
+ if (!pb.prerelease) return -1
96
+ const x = pa.prerelease.split('.')
97
+ const y = pb.prerelease.split('.')
98
+ for (let i = 0; i < Math.max(x.length, y.length); i += 1) {
99
+ const left = x[i]
100
+ const right = y[i]
101
+ if (left === undefined) return -1
102
+ if (right === undefined) return 1
103
+ if (left === right) continue
104
+ const leftNumeric = /^\d+$/.test(left)
105
+ const rightNumeric = /^\d+$/.test(right)
106
+ if (leftNumeric && rightNumeric) return Number(left) - Number(right)
107
+ // Numeric identifiers always have lower precedence than alphanumeric ones.
108
+ if (leftNumeric !== rightNumeric) return leftNumeric ? -1 : 1
109
+ return left < right ? -1 : 1
110
+ }
90
111
  return 0
91
112
  }
92
113
 
114
+ /**
115
+ * The bumps a train plans, with release.mjs `incrementVersion`'s arithmetic for them: a
116
+ * major/minor/patch off a prerelease releases that prerelease's base when the base already
117
+ * satisfies the bump, so `2.0.0-beta.1` + `patch` is `2.0.0` — not the `2.0.1` a naive
118
+ * increment gives, which release-kit would then never produce.
119
+ */
93
120
  export function bumpSemver(version, bump) {
94
121
  const v = parseSemver(version)
95
122
  if (!v) return null
96
123
  if (bump === 'as-is') return version
97
- if (bump === 'major') return `${v.major + 1}.0.0`
98
- if (bump === 'minor') return `${v.major}.${v.minor + 1}.0`
99
- return `${v.major}.${v.minor}.${v.patch + 1}`
124
+ const pre = v.prerelease !== null
125
+ if (bump === 'major') {
126
+ return pre && v.minor === 0 && v.patch === 0 ? `${v.major}.0.0` : `${v.major + 1}.0.0`
127
+ }
128
+ if (bump === 'minor') {
129
+ return pre && v.patch === 0 ? `${v.major}.${v.minor}.0` : `${v.major}.${v.minor + 1}.0`
130
+ }
131
+ return pre ? `${v.major}.${v.minor}.${v.patch}` : `${v.major}.${v.minor}.${v.patch + 1}`
100
132
  }
101
133
 
102
134
  /**
@@ -339,6 +371,8 @@ export function discover(rootDir, config) {
339
371
  repoRelPath: repoDir ? relative(repoDir, dir) || '.' : null,
340
372
  publish: entry.publish !== false,
341
373
  branch: releaseConfig.branch === undefined ? 'main' : releaseConfig.branch,
374
+ // The prefix release-kit will tag with, and so the one its history is read under.
375
+ tagPrefix: releaseConfig.tagPrefix ?? 'v',
342
376
  ...manifest,
343
377
  })
344
378
  }
@@ -416,19 +450,24 @@ export function topoSort(orderEdges) {
416
450
  // ─────────────────────────────────────────────────────────────────────────────
417
451
 
418
452
  /**
419
- * Tag scheme: `v<version>` when the repo owns exactly one member, `<name>@<version>` when
420
- * it owns several — which keeps single-package repos identical to standalone release-kit.
453
+ * Tag scheme: the package's own `tagPrefix` (`v` unless its release.config.json says
454
+ * otherwise) when the repo owns exactly one member, `<name>@<version>` when it owns
455
+ * several — which keeps single-package repos identical to standalone release-kit.
421
456
  */
422
457
  export function tagPatternFor(member, repoMemberCount) {
423
- return repoMemberCount > 1
424
- ? { prefix: `${member.name}@`, glob: `${member.name}@*` }
425
- : { prefix: 'v', glob: 'v*' }
458
+ if (repoMemberCount > 1) return { prefix: `${member.name}@`, glob: `${member.name}@*` }
459
+ const prefix = member.tagPrefix ?? 'v'
460
+ return { prefix, glob: `${prefix}*` }
426
461
  }
427
462
 
428
463
  function lastReleaseTag(member, repoMemberCount) {
429
464
  if (!member.repoDir) return null
430
465
  const { prefix, glob } = tagPatternFor(member, repoMemberCount)
431
- const tags = git(member.repoDir, ['tag', '--list', glob], { allowFailure: true })
466
+ // `--merged HEAD`, as release.mjs reads it: a tag made on another branch is not this
467
+ // branch's last release, and the commits it would hide are still unreleased here.
468
+ const tags = git(member.repoDir, ['tag', '--list', glob, '--merged', 'HEAD'], {
469
+ allowFailure: true,
470
+ })
432
471
  if (!tags) return null
433
472
  const versions = tags
434
473
  .split('\n')