@entro314labs/release-kit 2.9.0 → 2.9.2

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 +36 -5
  2. package/package.json +3 -3
  3. package/release.mjs +342 -94
package/README.md CHANGED
@@ -265,10 +265,14 @@ release-kit minor --skip commit # never touch uncommitted work
265
265
  Or fix it per project, and just run `release-kit minor`:
266
266
 
267
267
  ```json
268
- { "steps": ["version", "changelog", "tag", "push", "release"] }
268
+ { "steps": ["commit", "version", "changelog", "tag", "push", "release"] }
269
269
  ```
270
270
 
271
- `steps` decides **what** runs. Every other key describes **how** a step behaves — `publish`
271
+ `steps` decides **what** runs — and an explicit list is complete: a step not named does
272
+ not run, including `commit`. A `steps` list written before `commit` became a default step
273
+ therefore opts out of it without having chosen to; add `"commit"` to the list (as the
274
+ examples here do), or pass `--commit` for one run. Every other key describes **how** a
275
+ step behaves — `publish`
272
276
  is the command, `changelog` is the file. A step whose configuration is `null` runs as a
273
277
  no-op and says so, rather than silently meaning "skip".
274
278
 
@@ -353,6 +357,8 @@ rather than stopping at the first problem.
353
357
  - `gh` is installed and authenticated
354
358
  - Commit and tag signing can actually sign, and the key is one GitHub will accept
355
359
  - The publishing CLI is authenticated, and the version is not already published
360
+ - The previous release actually reached the registry — one that did not is either finished
361
+ by this run or absorbed into it _(warning)_
356
362
  - Configured release assets exist
357
363
  - The configured `verify` command passes — the project's own gate (tests, build) runs
358
364
  before anything mutates, instead of a `prepublishOnly` hook failing after the commit,
@@ -382,6 +388,31 @@ Re-run the same command. Every step is idempotent:
382
388
  So a run that dies at the publish step (2FA timeout, flaky network) picks up exactly where
383
389
  it stopped. There is no cleanup step, no `--resume`, and nothing to remember.
384
390
 
391
+ `auto` is included in that. It normally resolves the version from the commits since the
392
+ last tag, and after a failed publish there are none — the tag it would read from is the one
393
+ the dead run made. Rather than aborting with "no releasable commits", it finishes that
394
+ release: same version, same tag, the steps that remain.
395
+
396
+ ### A release that was never published
397
+
398
+ A tag is not a release. The tag and the push happen before the publish, so a publish that
399
+ fails leaves the version tagged, pushed and written into the changelog while no registry
400
+ carries it — and everything that reads "the last release" from tags then reads it wrong.
401
+
402
+ Once history has moved past that tag, finishing it is no longer possible: publishing sends
403
+ what is on disk, and that is no longer what the tag describes. The next release absorbs it
404
+ instead. History is read from the last tag whose version actually reached the registry, so
405
+ the unpublished release's commits are in range for both the notes and the bump `auto`
406
+ infers — a feature that never shipped still makes the next release a minor. Preflight says
407
+ which tags were absorbed, and points at the changelog sections that now document versions
408
+ no registry carries.
409
+
410
+ This costs one registry lookup per release, and the registry is the only thing asked: a
411
+ project configured with `"publish": null` has nothing that can answer, so it reads history
412
+ from tags as it always did. When the registry does not answer at all — offline, a proxy, an
413
+ expired session, a private package with no credentials — nothing is concluded from the
414
+ silence, and history is again read exactly as it was before.
415
+
385
416
  The one case that is not recoverable by re-running is a tag that exists at a _different_
386
417
  commit than `HEAD`. That is a genuine conflict, and it aborts rather than guessing.
387
418
 
@@ -599,7 +630,7 @@ in one release, across three formats, with no scripting:
599
630
  "apps/desktop/src-tauri/Cargo.lock"
600
631
  ],
601
632
  "publish": null,
602
- "steps": ["version", "changelog", "tag", "push"]
633
+ "steps": ["commit", "version", "changelog", "tag", "push"]
603
634
  }
604
635
  ```
605
636
 
@@ -902,7 +933,7 @@ release themselves, with the artifacts attached. That is their job. release-kit'
902
933
  at the pushed tag:
903
934
 
904
935
  ```json
905
- { "steps": ["version", "changelog", "tag", "push"], "notesFile": "dist-notes.md" }
936
+ { "steps": ["commit", "version", "changelog", "tag", "push"], "notesFile": "dist-notes.md" }
906
937
  ```
907
938
 
908
939
  Nothing after `push` — no `publish`, no `release`. The tag push is the handoff, and it is
@@ -948,7 +979,7 @@ A project can be both — a Rust crate that also ships binaries, say. Publish th
948
979
  release-kit and let the build tool handle the binaries and the release:
949
980
 
950
981
  ```json
951
- { "publish": "cargo publish", "steps": ["version", "changelog", "tag", "push", "publish"] }
982
+ { "publish": "cargo publish", "steps": ["commit", "version", "changelog", "tag", "push", "publish"] }
952
983
  ```
953
984
 
954
985
  ## 🔄 Keeping vendored copies in sync
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@entro314labs/release-kit",
3
- "version": "2.9.0",
3
+ "version": "2.9.2",
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,8 +49,8 @@
49
49
  "release": "node release.mjs"
50
50
  },
51
51
  "devDependencies": {
52
- "oxfmt": "^0.63.0",
53
- "oxlint": "^1.78.0",
52
+ "oxfmt": "^0.65.0",
53
+ "oxlint": "^1.80.0",
54
54
  "semver": "^7.8.5"
55
55
  },
56
56
  "engines": {
package/release.mjs CHANGED
@@ -24,7 +24,10 @@
24
24
  * - Every step is idempotent. A run interrupted partway through (a publish timeout, a
25
25
  * network failure) can be re-run: an already-written version, an existing tag at HEAD,
26
26
  * an already-published version and an existing release are each detected and skipped.
27
- * There is no cleanup step and no --resume flag.
27
+ * There is no cleanup step and no --resume flag. `auto` re-run that way finishes the
28
+ * unpublished release rather than reporting nothing to do, and once history has moved
29
+ * on past it, the version that does ship carries its commits — a tag is not a release,
30
+ * and work that never reached a registry is still unreleased.
28
31
  *
29
32
  * Configuration is optional. Defaults are the conventions (package.json version,
30
33
  * CHANGELOG.md, main branch, `v` tag prefix, npm publish); a release.config.json beside
@@ -354,7 +357,28 @@ const ASSISTANTS = {
354
357
  },
355
358
  codex: {
356
359
  command: 'codex',
357
- args: ['exec', '--skip-git-repo-check', '--sandbox', 'read-only'],
360
+ // A draft needs a bare model, but `codex exec` boots the user's whole session by
361
+ // default — plugins (with their MCP servers, hooks and skills), memories, apps and a
362
+ // notify program — several thousand tokens of context and seconds of startup that a
363
+ // one-shot prose prompt never uses. All four features are stable flags; an unknown
364
+ // flag on some future codex makes the draft fail closed into the deterministic
365
+ // fallback, which is this tool's contract for every assistant failure.
366
+ args: [
367
+ 'exec',
368
+ '--skip-git-repo-check',
369
+ '--sandbox',
370
+ 'read-only',
371
+ '--disable',
372
+ 'plugins',
373
+ '--disable',
374
+ 'hooks',
375
+ '--disable',
376
+ 'memories',
377
+ '--disable',
378
+ 'apps',
379
+ '-c',
380
+ 'notify=[]',
381
+ ],
358
382
  probe: ['--version'],
359
383
  model: (m) => ['-m', m],
360
384
  effort: (e) => ['-c', `model_reasoning_effort="${e}"`],
@@ -481,21 +505,61 @@ function releaseTags(prefix = config.tagPrefix ?? '') {
481
505
  /**
482
506
  * The tag a release reads its history from.
483
507
  *
484
- * @param {{stable?: boolean}} [options] `stable` when the version being released has no
485
- * prerelease identifier, which rolls the release candidates leading to it up into it:
486
- * their work is what is shipping now, and reading from the last candidate describes only
487
- * the gap between the last two candidates. Promoting `2.0.0-rc.2` to `2.0.0` that way
488
- * produced empty notes, because the one commit in range was the release chore.
489
- * Releasing a candidate keeps the full ordering, so each candidate's notes say what
508
+ * @param {{stable?: boolean, shipped?: boolean}} [options] `stable` when the version being
509
+ * released has no prerelease identifier, which rolls the release candidates leading to it
510
+ * up into it: their work is what is shipping now, and reading from the last candidate
511
+ * describes only the gap between the last two candidates. Promoting `2.0.0-rc.2` to
512
+ * `2.0.0` that way produced empty notes, because the one commit in range was the release
513
+ * chore. Releasing a candidate keeps the full ordering, so each candidate's notes say what
490
514
  * changed in that candidate rather than repeating the whole cycle.
515
+ *
516
+ * `shipped` skips tags whose version never reached the registry — see
517
+ * `absorbedReleaseTags` for why, and `versionShipped` for how that is established.
491
518
  * @returns {string | null}
492
519
  */
493
- function lastReleaseTag({ stable = false, prefix } = {}) {
520
+ function lastReleaseTag({ stable = false, prefix, shipped = false } = {}) {
494
521
  const tags = releaseTags(prefix)
495
522
  const eligible = stable ? tags.filter(({ version }) => !parseVersion(version).pre.length) : tags
523
+ if (!shipped) return eligible[0]?.name ?? null
524
+ for (const tag of eligible) {
525
+ const state = versionShipped(tag.version)
526
+ if (state === true) return tag.name
527
+ // Nothing could answer. Walking further asks the same unanswerable question about older
528
+ // versions, and treating silence as "never published" would reach back to the first
529
+ // commit in the repository — so this reads history exactly as it did before.
530
+ if (state === null) break
531
+ }
532
+ // Either the registry went quiet, or no tag this project ever made is on it — a project
533
+ // that tags and publishes by hand looks exactly like that. Neither is evidence that the
534
+ // last release failed, so the newest tag stays the baseline.
496
535
  return eligible[0]?.name ?? null
497
536
  }
498
537
 
538
+ /**
539
+ * The tags this release is about to absorb: versions that were tagged, pushed and written
540
+ * into the changelog, and then never published.
541
+ *
542
+ * Their commits are still unreleased work — the tag says otherwise, and that is what made
543
+ * them disappear. `2.0.1` failed to publish, `2.0.2` read its history from the `v2.0.1` tag
544
+ * and shipped notes covering one commit, and the ten commits `2.0.1` was made of are named
545
+ * in no release anyone can install. Reading from the last *shipped* tag puts them back in
546
+ * range, both for the notes and for the bump `auto` infers from them.
547
+ *
548
+ * @returns {{name: string, version: string}[]} newest first, empty in the ordinary case
549
+ */
550
+ function absorbedReleaseTags({ stable = false } = {}) {
551
+ const tags = releaseTags()
552
+ const eligible = stable ? tags.filter(({ version }) => !parseVersion(version).pre.length) : tags
553
+ const baseline = lastReleaseTag({ stable, shipped: true })
554
+ const absorbed = []
555
+ for (const tag of eligible) {
556
+ if (tag.name === baseline) break
557
+ if (versionShipped(tag.version) !== false) break
558
+ absorbed.push(tag)
559
+ }
560
+ return absorbed
561
+ }
562
+
499
563
  /** Commit subjects since the last release tag, with release and merge commits filtered out. */
500
564
  function commitsSinceLastTag(options) {
501
565
  const ignored = (config.ignoreCommits ?? []).map((pattern) => new RegExp(pattern, 'i'))
@@ -684,10 +748,19 @@ function changelogFromCommits(commits, links = null, hidden = [], contributors =
684
748
 
685
749
  const known = new Set(CHANGELOG_SECTIONS.map((s) => s.type))
686
750
  const hiddenTypes = new Set(hidden)
751
+ // A dependency bump is conventionally written `chore(deps):` or `build(deps):` — the scope
752
+ // carries the meaning, and it is what dependabot, renovate and this tool's own drafter
753
+ // emit, because `deps:` is not in @commitlint/config-conventional's type-enum. Without
754
+ // this those bumps sit in Miscellaneous Chores and Build System while the Dependencies
755
+ // section stays empty. `fix(deps):` is left where it is: a fix is still a bug fix.
756
+ const isDependency = (c) =>
757
+ c.type === 'deps' || (c.scope === 'deps' && (c.type === 'chore' || c.type === 'build'))
687
758
  for (const { type, section } of CHANGELOG_SECTIONS) {
688
759
  if (hiddenTypes.has(type)) continue
689
- const inSection = parsed.filter(
690
- (c) => c.type === type || (type === 'feat' && c.type === 'feature'),
760
+ const inSection = parsed.filter((c) =>
761
+ type === 'deps'
762
+ ? isDependency(c)
763
+ : !isDependency(c) && (c.type === type || (type === 'feat' && c.type === 'feature')),
691
764
  )
692
765
  if (!inSection.length) continue
693
766
  lines.push(`### ${section}`, '')
@@ -849,7 +922,24 @@ const CHANGELOG_TYPES = CHANGELOG_SECTIONS.map((s) => s.type)
849
922
 
850
923
  /** The drafter's types plus `feature`, the alias `changelogFromCommits` folds into feat. */
851
924
  const KNOWN_TYPES = new Set([...CHANGELOG_TYPES, 'feature'])
852
- const CONVENTIONAL_RE = new RegExp(`^(${[...KNOWN_TYPES].join('|')})(\\([^)]+\\))?!?: .+`)
925
+
926
+ /**
927
+ * The types this tool will write into somebody else's repository.
928
+ *
929
+ * Reading history is permissive on purpose — KNOWN_TYPES files whatever people wrote — but
930
+ * drafting is not, because a drafted subject has to survive the repository's own commit-msg
931
+ * hook. The near-universal gate is @commitlint/config-conventional, whose type-enum is
932
+ * exactly CHANGELOG_TYPES minus `deps`, and a drafted `deps: update dev tooling` aborted a
933
+ * release mid-commit with "type must be one of [build, chore, ci, ...]".
934
+ *
935
+ * Dependency work is still drafted and still reaches the Dependencies section: as
936
+ * `chore(deps):`, the scope dependabot and renovate emit, which `changelogFromCommits`
937
+ * routes there. Only the spelling the enum refuses is off the table.
938
+ */
939
+ const WRITE_TYPES = CHANGELOG_TYPES.filter((type) => type !== 'deps')
940
+
941
+ /** The gate on a drafted subject — WRITE_TYPES, not KNOWN_TYPES. See WRITE_TYPES. */
942
+ const CONVENTIONAL_RE = new RegExp(`^(${WRITE_TYPES.join('|')})(\\([^)]+\\))?!?: .+`)
853
943
 
854
944
  /**
855
945
  * Check commit subjects against the grammar the rest of this file reads.
@@ -907,7 +997,9 @@ function draftCommitMessage() {
907
997
  'Write a Conventional Commits message for these staged changes.',
908
998
  '',
909
999
  'Rules:',
910
- `- Subject: "<type>(<optional scope>): <description>" where type is one of ${CHANGELOG_TYPES.join(', ')}.`,
1000
+ `- Subject: "<type>(<optional scope>): <description>" where type is one of ${WRITE_TYPES.join(', ')}.`,
1001
+ '- For a dependency update use the `deps` scope — `chore(deps): ...` — which the',
1002
+ ' changelog files under Dependencies.',
911
1003
  '- Subject in the imperative mood, no trailing period, under 72 characters.',
912
1004
  '- Add a body only if the change needs explanation; separate it with a blank line.',
913
1005
  '- Output the raw commit message and nothing else: no markdown fences, no preamble.',
@@ -2245,6 +2337,20 @@ if (skippedSteps) for (const name of parseStepList(skippedSteps)) steps.delete(n
2245
2337
  if (autoCommit) steps.add('commit')
2246
2338
  const runs = (name) => steps.has(name)
2247
2339
 
2340
+ /**
2341
+ * True when the commit step is off because a config `steps` list omits it — as opposed to
2342
+ * being switched off for this run with --skip or --only. Configs written before `commit`
2343
+ * became a default step omit it without ever having chosen to, so a dirty-tree refusal
2344
+ * caused by one deserves a hint that the flag-driven refusal does not: the flag user just
2345
+ * asked for exactly this.
2346
+ */
2347
+ const commitExcludedByConfig =
2348
+ !runs('commit') &&
2349
+ !onlySteps &&
2350
+ !(skippedSteps && parseStepList(skippedSteps).includes('commit')) &&
2351
+ Array.isArray(config.steps) &&
2352
+ !config.steps.includes('commit')
2353
+
2248
2354
  /**
2249
2355
  * The drafting tool, resolved from --assistant then config. "auto" picks the first one
2250
2356
  * present on PATH; a named tool must be known and installed, otherwise it is an error
@@ -2280,6 +2386,147 @@ if (assistantChoice !== 'none' && assistantChoice !== null) {
2280
2386
  }
2281
2387
  const assistant = assistantName ? ASSISTANTS[assistantName] : null
2282
2388
 
2389
+ /**
2390
+ * Registries whose preflight can be run, keyed by the first word of the publish command.
2391
+ * Each declares how that CLI answers "who am I", "does this version already exist" and
2392
+ * "is this package there at all"; any may be null when the tool has no such notion. A
2393
+ * publish command outside this table (vsce, a shell pipeline) is run as written with no
2394
+ * preflight — it cannot be introspected, and guessing would invent failures.
2395
+ *
2396
+ * `exists` is what separates "that version was never published" from "the registry did not
2397
+ * answer". Both make the version lookup exit non-zero, and only the first one means the
2398
+ * release is unfinished — see `versionShipped`.
2399
+ */
2400
+ const REGISTRIES = {
2401
+ npm: {
2402
+ whoami: ['whoami'],
2403
+ published: (name, v) => ['view', `${name}@${v}`, 'version'],
2404
+ exists: (name) => ['view', name, 'version'],
2405
+ },
2406
+ pnpm: {
2407
+ whoami: ['whoami'],
2408
+ published: (name, v) => ['view', `${name}@${v}`, 'version'],
2409
+ exists: (name) => ['view', name, 'version'],
2410
+ },
2411
+ bun: {
2412
+ whoami: ['pm', 'whoami'],
2413
+ published: (name, v) => ['pm', 'view', `${name}@${v}`, 'version'],
2414
+ exists: (name) => ['pm', 'view', name, 'version'],
2415
+ },
2416
+ // uv authenticates with a token from the environment rather than a logged-in session,
2417
+ // and skips duplicate uploads itself via --check-url, so there is no version lookup.
2418
+ uv: { env: ['UV_PUBLISH_TOKEN', 'UV_PUBLISH_PASSWORD'], login: 'set UV_PUBLISH_TOKEN' },
2419
+ // cargo has no "who am I": crates.io auth is a token, either in the environment or in
2420
+ // the credentials file `cargo login` writes. `cargo info` is the version lookup, and
2421
+ // exits non-zero for a version the index does not carry (cargo 1.82+).
2422
+ cargo: {
2423
+ env: ['CARGO_REGISTRY_TOKEN', 'CARGO_REGISTRIES_CRATES_IO_TOKEN'],
2424
+ credentials: [
2425
+ join(homedir(), '.cargo', 'credentials.toml'),
2426
+ join(homedir(), '.cargo', 'credentials'),
2427
+ ],
2428
+ login: 'run `cargo login`, or set CARGO_REGISTRY_TOKEN',
2429
+ published: (name, v) => ['info', `${name}@${v}`],
2430
+ exists: (name) => ['info', name],
2431
+ },
2432
+ // For Go the tag is the release; `go list` warms the module proxy and doubles as the
2433
+ // check for whether this version is already resolvable.
2434
+ go: {
2435
+ published: (name, v) => ['list', '-m', `${name}@${v}`],
2436
+ exists: (name) => ['list', '-m', `${name}@latest`],
2437
+ },
2438
+ }
2439
+
2440
+ /**
2441
+ * Which manifest records the name a registry knows this project by, when it is not the one
2442
+ * `projectName` came from. They are not always the same string: a Tauri plugin publishes as
2443
+ * `@tauri-apps/plugin-x` on npm and `tauri-plugin-x` on crates.io, so looking the crate up
2444
+ * under its npm name would report every version as unpublished.
2445
+ */
2446
+ const NAME_MANIFEST_BY_CLI = { cargo: 'Cargo.toml' }
2447
+
2448
+ function registryName(cli) {
2449
+ const manifest = NAME_MANIFEST_BY_CLI[cli]
2450
+ if (!manifest) return projectName
2451
+ const source = versionTargets.find((entry) => basename(entry.path) === manifest)
2452
+ return (source && readNameFrom(source)) ?? projectName
2453
+ }
2454
+
2455
+ /**
2456
+ * `publish` is one command or several, because one source tree can own a package in more
2457
+ * than one ecosystem. They run in the configured order.
2458
+ */
2459
+ const publishList = config.publish == null ? [] : [config.publish].flat()
2460
+ if (publishList.some((entry) => typeof entry !== 'string')) {
2461
+ abort('publish must be a command string, an array of command strings, or null')
2462
+ }
2463
+
2464
+ /**
2465
+ * One answer per version, per run: the lookups are network calls, and the same version is
2466
+ * asked about by the baseline walk and again by preflight.
2467
+ */
2468
+ const shippedCache = new Map()
2469
+
2470
+ /**
2471
+ * Whether a version actually reached every registry this project publishes to.
2472
+ *
2473
+ * A tag is not a release. The tag and the push happen before the publish, so a publish that
2474
+ * fails — a failing prepublish gate, an expired npm session, a network drop — leaves the
2475
+ * version tagged, pushed and changelogged but absent from the registry. Nothing downstream
2476
+ * has it, and until this could be asked, nothing upstream knew.
2477
+ *
2478
+ * "Not there" and "could not ask" are the same exit code from every one of these CLIs, and
2479
+ * conflating them is dangerous in one direction only: reading an unreachable registry as
2480
+ * "nothing was ever published" would drag the notes baseline back through the whole
2481
+ * history. The bare-name lookup separates them — a package whose own name resolves is a
2482
+ * registry that answered.
2483
+ *
2484
+ * @param {string} v
2485
+ * @returns {boolean | null} null when nothing here can answer
2486
+ */
2487
+ function versionShipped(v) {
2488
+ if (shippedCache.has(v)) return shippedCache.get(v)
2489
+ let answer = null
2490
+ for (const template of publishList) {
2491
+ const cli = template.trim().split(/\s+/)[0]
2492
+ const registry = REGISTRIES[cli]
2493
+ if (!registry?.published || !registry.exists) continue
2494
+ const name = registryName(cli)
2495
+ if (succeeds(cli, registry.published(name, v))) {
2496
+ answer ??= true
2497
+ continue
2498
+ }
2499
+ // One registry missing the version is enough: the release did not finish everywhere,
2500
+ // and the half that is missing is the half still owed to its consumers.
2501
+ if (succeeds(cli, registry.exists(name))) {
2502
+ answer = false
2503
+ break
2504
+ }
2505
+ answer = null
2506
+ break
2507
+ }
2508
+ shippedCache.set(v, answer)
2509
+ return answer
2510
+ }
2511
+
2512
+ /**
2513
+ * The release that was started and never finished: the newest tag, sitting at HEAD, whose
2514
+ * version never reached the registry.
2515
+ *
2516
+ * Re-running the same command is the documented way to recover from a release that died
2517
+ * partway through, and `auto` was the one target that could not: it resolves a version from
2518
+ * the commits since the last tag, finds none, and aborts with "nothing to release" — while
2519
+ * the thing left to do is the publish the previous run never got to.
2520
+ *
2521
+ * @returns {{name: string, version: string} | null}
2522
+ */
2523
+ function unfinishedRelease() {
2524
+ const [newest] = releaseTags()
2525
+ if (!newest || versionShipped(newest.version) !== false) return null
2526
+ const at = tryRead('git', ['rev-list', '-n', '1', newest.name])
2527
+ return at && at === tryRead('git', ['rev-parse', 'HEAD']) ? newest : null
2528
+ }
2529
+
2283
2530
  // ─────────────────────────────────────────────────────────────────────────────
2284
2531
  // RESOLVE THE TARGET VERSION
2285
2532
  // ─────────────────────────────────────────────────────────────────────────────
@@ -2292,6 +2539,9 @@ say(
2292
2539
  /** What `auto` inferred, kept so preflight can show the reasoning. */
2293
2540
  let autoBump = null
2294
2541
 
2542
+ /** The tag of a previous release this run is finishing rather than starting. */
2543
+ let resuming = null
2544
+
2295
2545
  let version
2296
2546
  if (!target) {
2297
2547
  if (!currentVersion) {
@@ -2308,24 +2558,35 @@ if (!target) {
2308
2558
  'from.\n Pass the first version explicitly: release-kit 0.1.0',
2309
2559
  )
2310
2560
  }
2311
- const { commits, lastTag } = commitsSinceLastTag()
2312
- if (!commits.length) {
2313
- abort(
2314
- `no releasable commits since ${lastTag ?? 'the start of the project'} — nothing to release`,
2315
- )
2316
- }
2317
- autoBump = inferBump(commits, currentVersion, config.versioning)
2318
- if (autoBump.releaseAs) {
2319
- if (!parseVersion(autoBump.releaseAs)) {
2320
- abort(`Release-As: ${autoBump.releaseAs} in a commit is not a semver version`)
2321
- }
2322
- version = autoBump.releaseAs
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
+ const pending = wouldCommitMore ? null : unfinishedRelease()
2568
+ if (pending) {
2569
+ ;({ name: resuming, version } = pending)
2323
2570
  } else {
2324
- version = incrementVersion(
2325
- currentVersion,
2326
- autoBump.bump,
2327
- requestedPreid ?? preidOf(currentVersion),
2328
- )
2571
+ const { commits, lastTag } = commitsSinceLastTag({ shipped: true })
2572
+ if (!commits.length) {
2573
+ abort(
2574
+ `no releasable commits since ${lastTag ?? 'the start of the project'} — nothing to release`,
2575
+ )
2576
+ }
2577
+ autoBump = inferBump(commits, currentVersion, config.versioning)
2578
+ if (autoBump.releaseAs) {
2579
+ if (!parseVersion(autoBump.releaseAs)) {
2580
+ abort(`Release-As: ${autoBump.releaseAs} in a commit is not a semver version`)
2581
+ }
2582
+ version = autoBump.releaseAs
2583
+ } else {
2584
+ version = incrementVersion(
2585
+ currentVersion,
2586
+ autoBump.bump,
2587
+ requestedPreid ?? preidOf(currentVersion),
2588
+ )
2589
+ }
2329
2590
  }
2330
2591
  } else if (BUMPS.has(target)) {
2331
2592
  if (!currentVersion) {
@@ -2419,64 +2680,6 @@ function dirtyPaths() {
2419
2680
  )
2420
2681
  }
2421
2682
 
2422
- /**
2423
- * Registries whose preflight can be run, keyed by the first word of the publish command.
2424
- * Each declares how that CLI answers "who am I" and "does this version already exist";
2425
- * either may be null when the tool has no such notion. A publish command outside this
2426
- * table (vsce, a shell pipeline) is run as written with no preflight — it cannot be
2427
- * introspected, and guessing would invent failures.
2428
- */
2429
- const REGISTRIES = {
2430
- npm: { whoami: ['whoami'], published: (name, v) => ['view', `${name}@${v}`, 'version'] },
2431
- pnpm: { whoami: ['whoami'], published: (name, v) => ['view', `${name}@${v}`, 'version'] },
2432
- bun: {
2433
- whoami: ['pm', 'whoami'],
2434
- published: (name, v) => ['pm', 'view', `${name}@${v}`, 'version'],
2435
- },
2436
- // uv authenticates with a token from the environment rather than a logged-in session,
2437
- // and skips duplicate uploads itself via --check-url, so there is no version lookup.
2438
- uv: { env: ['UV_PUBLISH_TOKEN', 'UV_PUBLISH_PASSWORD'], login: 'set UV_PUBLISH_TOKEN' },
2439
- // cargo has no "who am I": crates.io auth is a token, either in the environment or in
2440
- // the credentials file `cargo login` writes. `cargo info` is the version lookup, and
2441
- // exits non-zero for a version the index does not carry (cargo 1.82+).
2442
- cargo: {
2443
- env: ['CARGO_REGISTRY_TOKEN', 'CARGO_REGISTRIES_CRATES_IO_TOKEN'],
2444
- credentials: [
2445
- join(homedir(), '.cargo', 'credentials.toml'),
2446
- join(homedir(), '.cargo', 'credentials'),
2447
- ],
2448
- login: 'run `cargo login`, or set CARGO_REGISTRY_TOKEN',
2449
- published: (name, v) => ['info', `${name}@${v}`],
2450
- },
2451
- // For Go the tag is the release; `go list` warms the module proxy and doubles as the
2452
- // check for whether this version is already resolvable.
2453
- go: { published: (name, v) => ['list', '-m', `${name}@${v}`] },
2454
- }
2455
-
2456
- /**
2457
- * Which manifest records the name a registry knows this project by, when it is not the one
2458
- * `projectName` came from. They are not always the same string: a Tauri plugin publishes as
2459
- * `@tauri-apps/plugin-x` on npm and `tauri-plugin-x` on crates.io, so looking the crate up
2460
- * under its npm name would report every version as unpublished.
2461
- */
2462
- const NAME_MANIFEST_BY_CLI = { cargo: 'Cargo.toml' }
2463
-
2464
- function registryName(cli) {
2465
- const manifest = NAME_MANIFEST_BY_CLI[cli]
2466
- if (!manifest) return projectName
2467
- const source = versionTargets.find((entry) => basename(entry.path) === manifest)
2468
- return (source && readNameFrom(source)) ?? projectName
2469
- }
2470
-
2471
- /**
2472
- * `publish` is one command or several, because one source tree can own a package in more
2473
- * than one ecosystem. They run in the configured order.
2474
- */
2475
- const publishList = config.publish == null ? [] : [config.publish].flat()
2476
- if (publishList.some((entry) => typeof entry !== 'string')) {
2477
- abort('publish must be a command string, an array of command strings, or null')
2478
- }
2479
-
2480
2683
  /** Each publish command with the CLI it drives, that CLI's preflight row, and its name. */
2481
2684
  const publishTargets = runs('publish')
2482
2685
  ? publishList.map((template) => {
@@ -2532,6 +2735,39 @@ if (autoBump) {
2532
2735
  }
2533
2736
  }
2534
2737
 
2738
+ if (resuming) {
2739
+ ok(`finishing ${resuming}: it was tagged and pushed, but never reached the registry`)
2740
+ }
2741
+
2742
+ // A previous release that never shipped is not history — its commits are still owed to
2743
+ // whoever installs this package, and they are in this release's range because of it. Say
2744
+ // so: the changelog keeps the section that was written for that version, and a section
2745
+ // naming a version no registry carries is worth a human deciding about.
2746
+ const absorbed = absorbedReleaseTags({ stable: !isPrerelease }).filter(
2747
+ (entry) => entry.version !== version,
2748
+ )
2749
+ if (absorbed.length) {
2750
+ const names = absorbed.map((entry) => entry.name).join(', ')
2751
+ const many = absorbed.length > 1
2752
+ const existingChangelog =
2753
+ config.changelog && existsSync(config.changelog) ? readFileSync(config.changelog, 'utf8') : null
2754
+ const documented = absorbed
2755
+ .filter((entry) => existingChangelog && changelogSection(existingChangelog, entry.version))
2756
+ .map((entry) => entry.version)
2757
+ const stale = documented.length
2758
+ ? `\n ${config.changelog} still documents ${documented.join(', ')} — ${
2759
+ documented.length > 1 ? 'versions' : 'a version'
2760
+ } no registry carries. Fold ${
2761
+ documented.length > 1 ? 'those sections' : 'that section'
2762
+ } into ${version} by hand.`
2763
+ : ''
2764
+ warn(
2765
+ `${names} ${many ? 'were' : 'was'} tagged but never published, so ${version} ships ${
2766
+ many ? 'their' : 'its'
2767
+ } commits as well as its own.${stale}`,
2768
+ )
2769
+ }
2770
+
2535
2771
  // Writing the version is the first mutating step, and it used to discover a file it
2536
2772
  // could not write *while writing the others* — aborting with a raw stack trace after
2537
2773
  // some of them had already changed. Every target is checked here instead.
@@ -2595,7 +2831,12 @@ else if (dirty && runs('commit')) {
2595
2831
  )
2596
2832
  }
2597
2833
  } else if (dirty) {
2598
- fail(`working tree is not clean:\n${indent(formatStatus(dirty))}`)
2834
+ const hint = commitExcludedByConfig
2835
+ ? '\n The steps list in release.config.json omits `commit` (it may predate ' +
2836
+ 'commit becoming\n a default step). Add "commit" to it, or pass --commit ' +
2837
+ 'to commit these now.'
2838
+ : ''
2839
+ fail(`working tree is not clean:\n${indent(formatStatus(dirty))}${hint}`)
2599
2840
  } else ok('working tree clean')
2600
2841
 
2601
2842
  if (assistant) {
@@ -2887,6 +3128,7 @@ function draftNotesFor(v) {
2887
3128
  // between two candidates rather than the release.
2888
3129
  const { lastTag, subjects, commits, contributors } = commitsSinceLastTag({
2889
3130
  stable: !isPrerelease,
3131
+ shipped: true,
2890
3132
  })
2891
3133
  if (!commits.length) return null
2892
3134
 
@@ -2979,13 +3221,19 @@ for (const asset of config.assets) {
2979
3221
 
2980
3222
  // Reusing a tag is the resume path, and a resume writes nothing. If this run would still
2981
3223
  // produce a commit, that commit moves HEAD past the tag and the release ends up tagged at
2982
- // the wrong revision — which is silent until someone checks out the tag.
2983
- if (taggedCommit && runs('tag') && (bumping || rolledChangelog)) {
3224
+ // the wrong revision — which is silent until someone checks out the tag, and worse for the
3225
+ // working tree: `publish` sends what is on disk now, not what the tag describes.
3226
+ const wouldCommit = [
3227
+ dirty && runs('commit') && 'the working tree',
3228
+ bumping && 'a version bump',
3229
+ rolledChangelog && 'a changelog entry',
3230
+ ].filter(Boolean)
3231
+ if (taggedCommit && runs('tag') && wouldCommit.length) {
2984
3232
  fail(
2985
3233
  `tag ${tag} already exists at HEAD, but this run would still commit ` +
2986
- `${[bumping && 'a version bump', rolledChangelog && 'a changelog entry'].filter(Boolean).join(' and ')}.\n` +
2987
- ' That commit would leave the tag behind HEAD. Release a new version, or use ' +
2988
- '--only with the steps that remain.',
3234
+ `${wouldCommit.join(' and ')}.\n` +
3235
+ ' That commit would leave the tag behind HEAD, and publish a tree it does not ' +
3236
+ 'describe.\n Release a new version, or use --only with the steps that remain.',
2989
3237
  )
2990
3238
  }
2991
3239