@entro314labs/release-kit 2.9.3 → 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.3",
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
 
@@ -1672,7 +1676,18 @@ function patternFor({ path, pattern, all = false }) {
1672
1676
  /** @returns {string | null} the version recorded in a source file */
1673
1677
  function readVersionFrom(entry) {
1674
1678
  const source = versionSource(entry)
1675
- 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) {
1676
1691
  const { kind, shape } = versionMode(source, text)
1677
1692
  if (kind === 'bare') return text.trim() || null
1678
1693
  if (kind === 'markers') {
@@ -1895,7 +1910,11 @@ if (flag('--help') || flag('-h')) {
1895
1910
 
1896
1911
  // --sync copies this file into other projects and exits; it touches no git state.
1897
1912
  if (flag('--sync')) {
1898
- 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)
1899
1918
  // Piped from stdin (`curl … | node -`) there is no file to copy: import.meta.url points
1900
1919
  // at a synthetic [eval] path. Say so instead of failing on a missing file.
1901
1920
  if (!existsSync(self)) {
@@ -2022,6 +2041,9 @@ const VALUE_OPTIONS = new Set([
2022
2041
  '--assistant-effort',
2023
2042
  ])
2024
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
+
2025
2047
  /** The version or bump target: the only argument that is neither a flag nor a flag's value. */
2026
2048
  const positionals = []
2027
2049
  for (let i = 0; i < argv.length; i += 1) {
@@ -2029,7 +2051,20 @@ for (let i = 0; i < argv.length; i += 1) {
2029
2051
  i += 1
2030
2052
  continue
2031
2053
  }
2032
- 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
+ }
2033
2068
  }
2034
2069
  const target = positionals[0]
2035
2070
  // A second positional is always a mistake, and silently ignoring it changes the release
@@ -2552,6 +2587,23 @@ let autoBump = null
2552
2587
  /** The tag of a previous release this run is finishing rather than starting. */
2553
2588
  let resuming = null
2554
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
+
2555
2607
  let version
2556
2608
  if (!target) {
2557
2609
  if (!currentVersion) {
@@ -2568,12 +2620,6 @@ if (!target) {
2568
2620
  'from.\n Pass the first version explicitly: release-kit 0.1.0',
2569
2621
  )
2570
2622
  }
2571
- // A release that died after the tag and before the publish is finished by re-running the
2572
- // same command — but only while nothing new has happened. A commit or a working tree that
2573
- // `--commit` is about to turn into one moves HEAD past the tag, and publishing then would
2574
- // ship a tree the tag does not describe; that work belongs in the next version, which is
2575
- // what the baseline below makes sure it is released as.
2576
- const wouldCommitMore = !!tryRead('git', ['status', '--porcelain']) && runs('commit')
2577
2623
  const pending = wouldCommitMore ? null : unfinishedRelease()
2578
2624
  if (pending) {
2579
2625
  ;({ name: resuming, version } = pending)
@@ -2611,6 +2657,10 @@ if (!target) {
2611
2657
  `a ${target} bump from a stable version needs --preid <${[...KNOWN_CHANNELS].sort().join('|')}>`,
2612
2658
  )
2613
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()
2614
2664
  version = incrementVersion(currentVersion, target, preid)
2615
2665
  } else if (parseVersion(target)) {
2616
2666
  version = target
@@ -2749,6 +2799,33 @@ if (resuming) {
2749
2799
  ok(`finishing ${resuming}: it was tagged and pushed, but never reached the registry`)
2750
2800
  }
2751
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
+
2752
2829
  // A previous release that never shipped is not history — its commits are still owed to
2753
2830
  // whoever installs this package, and they are in this release's range because of it. Say
2754
2831
  // so: the changelog keeps the section that was written for that version, and a section
@@ -2756,7 +2833,9 @@ if (resuming) {
2756
2833
  const absorbed = absorbedReleaseTags({ stable: !isPrerelease }).filter(
2757
2834
  (entry) => entry.version !== version,
2758
2835
  )
2759
- 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) {
2760
2839
  const names = absorbed.map((entry) => entry.name).join(', ')
2761
2840
  const many = absorbed.length > 1
2762
2841
  const existingChangelog =
@@ -2789,13 +2868,32 @@ if (bumping) {
2789
2868
  }
2790
2869
  if (source.optional) continue
2791
2870
  const text = readFileSync(source.path, 'utf8')
2792
- 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
2793
2881
  if (kind === 'pattern' && !shape.test(text)) {
2794
2882
  fail(
2795
2883
  `${source.path} has no version for release-kit to replace.\n` +
2796
2884
  ' Remove it from versionFiles, mark the line with x-release-kit-version, ' +
2797
2885
  'or give the entry a "pattern".',
2798
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
+ )
2799
2897
  }
2800
2898
  }
2801
2899
  }
@@ -2807,6 +2905,17 @@ if (bumping && currentVersion && compareVersions(version, currentVersion) <= 0)
2807
2905
  } else if (bumping) {
2808
2906
  // No manifest and no tag to read a version from, but files to write one into.
2809
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
+ )
2810
2919
  } else if (versionFile) {
2811
2920
  ok(`releasing the version already in ${versionFile.path} (${version})`)
2812
2921
  } else {
@@ -3010,9 +3119,17 @@ if (!runs('release')) {
3010
3119
  if (releaseExists) note(`a GitHub release for ${tag} already exists — will skip that step`)
3011
3120
  }
3012
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
+
3013
3130
  /** Whether one publish CLI can publish at all: OIDC, a token, or a live session. */
3014
3131
  function checkCredentials({ cli, registry, command }) {
3015
- if (isTrustedPublishing) {
3132
+ if (isTrustedPublishing && OIDC_CLIS.has(cli)) {
3016
3133
  ok(`${cli}: trusted publishing (OIDC) — no token needed`)
3017
3134
  // Provenance is the other half of what OIDC makes possible: a signed attestation
3018
3135
  // tying the published artefact to the workflow and commit that produced it. It is
@@ -3236,7 +3353,9 @@ for (const asset of config.assets) {
3236
3353
  const wouldCommit = [
3237
3354
  dirty && runs('commit') && 'the working tree',
3238
3355
  bumping && 'a version bump',
3239
- 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',
3240
3359
  ].filter(Boolean)
3241
3360
  if (taggedCommit && runs('tag') && wouldCommit.length) {
3242
3361
  fail(
@@ -3439,15 +3558,37 @@ for (const target of publishTargets) {
3439
3558
  // target published nothing, and telling downstream otherwise is a lie it may act on.
3440
3559
  if (publishedSomething) runHook('afterPublish')
3441
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
+
3442
3579
  if (runs('release') && !releaseExists) {
3443
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
+ }
3444
3585
  const args = [
3445
3586
  'release',
3446
3587
  'create',
3447
3588
  tag,
3448
3589
  '--title',
3449
3590
  expand(config.releaseTitle),
3450
- isPrerelease ? '--prerelease' : '--latest',
3591
+ isPrerelease ? '--prerelease' : `--latest=${latest}`,
3451
3592
  // Notes arrive on stdin, so there is no temp file and nothing to escape.
3452
3593
  ...(notes ? ['--notes-file', '-'] : ['--generate-notes']),
3453
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')