@entro314labs/release-kit 2.9.0 → 2.9.1

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 +1 -1
  3. package/release.mjs +310 -90
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.1",
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",
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'))
@@ -2245,6 +2309,20 @@ if (skippedSteps) for (const name of parseStepList(skippedSteps)) steps.delete(n
2245
2309
  if (autoCommit) steps.add('commit')
2246
2310
  const runs = (name) => steps.has(name)
2247
2311
 
2312
+ /**
2313
+ * True when the commit step is off because a config `steps` list omits it — as opposed to
2314
+ * being switched off for this run with --skip or --only. Configs written before `commit`
2315
+ * became a default step omit it without ever having chosen to, so a dirty-tree refusal
2316
+ * caused by one deserves a hint that the flag-driven refusal does not: the flag user just
2317
+ * asked for exactly this.
2318
+ */
2319
+ const commitExcludedByConfig =
2320
+ !runs('commit') &&
2321
+ !onlySteps &&
2322
+ !(skippedSteps && parseStepList(skippedSteps).includes('commit')) &&
2323
+ Array.isArray(config.steps) &&
2324
+ !config.steps.includes('commit')
2325
+
2248
2326
  /**
2249
2327
  * The drafting tool, resolved from --assistant then config. "auto" picks the first one
2250
2328
  * present on PATH; a named tool must be known and installed, otherwise it is an error
@@ -2280,6 +2358,147 @@ if (assistantChoice !== 'none' && assistantChoice !== null) {
2280
2358
  }
2281
2359
  const assistant = assistantName ? ASSISTANTS[assistantName] : null
2282
2360
 
2361
+ /**
2362
+ * Registries whose preflight can be run, keyed by the first word of the publish command.
2363
+ * Each declares how that CLI answers "who am I", "does this version already exist" and
2364
+ * "is this package there at all"; any may be null when the tool has no such notion. A
2365
+ * publish command outside this table (vsce, a shell pipeline) is run as written with no
2366
+ * preflight — it cannot be introspected, and guessing would invent failures.
2367
+ *
2368
+ * `exists` is what separates "that version was never published" from "the registry did not
2369
+ * answer". Both make the version lookup exit non-zero, and only the first one means the
2370
+ * release is unfinished — see `versionShipped`.
2371
+ */
2372
+ const REGISTRIES = {
2373
+ npm: {
2374
+ whoami: ['whoami'],
2375
+ published: (name, v) => ['view', `${name}@${v}`, 'version'],
2376
+ exists: (name) => ['view', name, 'version'],
2377
+ },
2378
+ pnpm: {
2379
+ whoami: ['whoami'],
2380
+ published: (name, v) => ['view', `${name}@${v}`, 'version'],
2381
+ exists: (name) => ['view', name, 'version'],
2382
+ },
2383
+ bun: {
2384
+ whoami: ['pm', 'whoami'],
2385
+ published: (name, v) => ['pm', 'view', `${name}@${v}`, 'version'],
2386
+ exists: (name) => ['pm', 'view', name, 'version'],
2387
+ },
2388
+ // uv authenticates with a token from the environment rather than a logged-in session,
2389
+ // and skips duplicate uploads itself via --check-url, so there is no version lookup.
2390
+ uv: { env: ['UV_PUBLISH_TOKEN', 'UV_PUBLISH_PASSWORD'], login: 'set UV_PUBLISH_TOKEN' },
2391
+ // cargo has no "who am I": crates.io auth is a token, either in the environment or in
2392
+ // the credentials file `cargo login` writes. `cargo info` is the version lookup, and
2393
+ // exits non-zero for a version the index does not carry (cargo 1.82+).
2394
+ cargo: {
2395
+ env: ['CARGO_REGISTRY_TOKEN', 'CARGO_REGISTRIES_CRATES_IO_TOKEN'],
2396
+ credentials: [
2397
+ join(homedir(), '.cargo', 'credentials.toml'),
2398
+ join(homedir(), '.cargo', 'credentials'),
2399
+ ],
2400
+ login: 'run `cargo login`, or set CARGO_REGISTRY_TOKEN',
2401
+ published: (name, v) => ['info', `${name}@${v}`],
2402
+ exists: (name) => ['info', name],
2403
+ },
2404
+ // For Go the tag is the release; `go list` warms the module proxy and doubles as the
2405
+ // check for whether this version is already resolvable.
2406
+ go: {
2407
+ published: (name, v) => ['list', '-m', `${name}@${v}`],
2408
+ exists: (name) => ['list', '-m', `${name}@latest`],
2409
+ },
2410
+ }
2411
+
2412
+ /**
2413
+ * Which manifest records the name a registry knows this project by, when it is not the one
2414
+ * `projectName` came from. They are not always the same string: a Tauri plugin publishes as
2415
+ * `@tauri-apps/plugin-x` on npm and `tauri-plugin-x` on crates.io, so looking the crate up
2416
+ * under its npm name would report every version as unpublished.
2417
+ */
2418
+ const NAME_MANIFEST_BY_CLI = { cargo: 'Cargo.toml' }
2419
+
2420
+ function registryName(cli) {
2421
+ const manifest = NAME_MANIFEST_BY_CLI[cli]
2422
+ if (!manifest) return projectName
2423
+ const source = versionTargets.find((entry) => basename(entry.path) === manifest)
2424
+ return (source && readNameFrom(source)) ?? projectName
2425
+ }
2426
+
2427
+ /**
2428
+ * `publish` is one command or several, because one source tree can own a package in more
2429
+ * than one ecosystem. They run in the configured order.
2430
+ */
2431
+ const publishList = config.publish == null ? [] : [config.publish].flat()
2432
+ if (publishList.some((entry) => typeof entry !== 'string')) {
2433
+ abort('publish must be a command string, an array of command strings, or null')
2434
+ }
2435
+
2436
+ /**
2437
+ * One answer per version, per run: the lookups are network calls, and the same version is
2438
+ * asked about by the baseline walk and again by preflight.
2439
+ */
2440
+ const shippedCache = new Map()
2441
+
2442
+ /**
2443
+ * Whether a version actually reached every registry this project publishes to.
2444
+ *
2445
+ * A tag is not a release. The tag and the push happen before the publish, so a publish that
2446
+ * fails — a failing prepublish gate, an expired npm session, a network drop — leaves the
2447
+ * version tagged, pushed and changelogged but absent from the registry. Nothing downstream
2448
+ * has it, and until this could be asked, nothing upstream knew.
2449
+ *
2450
+ * "Not there" and "could not ask" are the same exit code from every one of these CLIs, and
2451
+ * conflating them is dangerous in one direction only: reading an unreachable registry as
2452
+ * "nothing was ever published" would drag the notes baseline back through the whole
2453
+ * history. The bare-name lookup separates them — a package whose own name resolves is a
2454
+ * registry that answered.
2455
+ *
2456
+ * @param {string} v
2457
+ * @returns {boolean | null} null when nothing here can answer
2458
+ */
2459
+ function versionShipped(v) {
2460
+ if (shippedCache.has(v)) return shippedCache.get(v)
2461
+ let answer = null
2462
+ for (const template of publishList) {
2463
+ const cli = template.trim().split(/\s+/)[0]
2464
+ const registry = REGISTRIES[cli]
2465
+ if (!registry?.published || !registry.exists) continue
2466
+ const name = registryName(cli)
2467
+ if (succeeds(cli, registry.published(name, v))) {
2468
+ answer ??= true
2469
+ continue
2470
+ }
2471
+ // One registry missing the version is enough: the release did not finish everywhere,
2472
+ // and the half that is missing is the half still owed to its consumers.
2473
+ if (succeeds(cli, registry.exists(name))) {
2474
+ answer = false
2475
+ break
2476
+ }
2477
+ answer = null
2478
+ break
2479
+ }
2480
+ shippedCache.set(v, answer)
2481
+ return answer
2482
+ }
2483
+
2484
+ /**
2485
+ * The release that was started and never finished: the newest tag, sitting at HEAD, whose
2486
+ * version never reached the registry.
2487
+ *
2488
+ * Re-running the same command is the documented way to recover from a release that died
2489
+ * partway through, and `auto` was the one target that could not: it resolves a version from
2490
+ * the commits since the last tag, finds none, and aborts with "nothing to release" — while
2491
+ * the thing left to do is the publish the previous run never got to.
2492
+ *
2493
+ * @returns {{name: string, version: string} | null}
2494
+ */
2495
+ function unfinishedRelease() {
2496
+ const [newest] = releaseTags()
2497
+ if (!newest || versionShipped(newest.version) !== false) return null
2498
+ const at = tryRead('git', ['rev-list', '-n', '1', newest.name])
2499
+ return at && at === tryRead('git', ['rev-parse', 'HEAD']) ? newest : null
2500
+ }
2501
+
2283
2502
  // ─────────────────────────────────────────────────────────────────────────────
2284
2503
  // RESOLVE THE TARGET VERSION
2285
2504
  // ─────────────────────────────────────────────────────────────────────────────
@@ -2292,6 +2511,9 @@ say(
2292
2511
  /** What `auto` inferred, kept so preflight can show the reasoning. */
2293
2512
  let autoBump = null
2294
2513
 
2514
+ /** The tag of a previous release this run is finishing rather than starting. */
2515
+ let resuming = null
2516
+
2295
2517
  let version
2296
2518
  if (!target) {
2297
2519
  if (!currentVersion) {
@@ -2308,24 +2530,35 @@ if (!target) {
2308
2530
  'from.\n Pass the first version explicitly: release-kit 0.1.0',
2309
2531
  )
2310
2532
  }
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
2533
+ // A release that died after the tag and before the publish is finished by re-running the
2534
+ // same command — but only while nothing new has happened. A commit or a working tree that
2535
+ // `--commit` is about to turn into one moves HEAD past the tag, and publishing then would
2536
+ // ship a tree the tag does not describe; that work belongs in the next version, which is
2537
+ // what the baseline below makes sure it is released as.
2538
+ const wouldCommitMore = !!tryRead('git', ['status', '--porcelain']) && runs('commit')
2539
+ const pending = wouldCommitMore ? null : unfinishedRelease()
2540
+ if (pending) {
2541
+ ;({ name: resuming, version } = pending)
2323
2542
  } else {
2324
- version = incrementVersion(
2325
- currentVersion,
2326
- autoBump.bump,
2327
- requestedPreid ?? preidOf(currentVersion),
2328
- )
2543
+ const { commits, lastTag } = commitsSinceLastTag({ shipped: true })
2544
+ if (!commits.length) {
2545
+ abort(
2546
+ `no releasable commits since ${lastTag ?? 'the start of the project'} — nothing to release`,
2547
+ )
2548
+ }
2549
+ autoBump = inferBump(commits, currentVersion, config.versioning)
2550
+ if (autoBump.releaseAs) {
2551
+ if (!parseVersion(autoBump.releaseAs)) {
2552
+ abort(`Release-As: ${autoBump.releaseAs} in a commit is not a semver version`)
2553
+ }
2554
+ version = autoBump.releaseAs
2555
+ } else {
2556
+ version = incrementVersion(
2557
+ currentVersion,
2558
+ autoBump.bump,
2559
+ requestedPreid ?? preidOf(currentVersion),
2560
+ )
2561
+ }
2329
2562
  }
2330
2563
  } else if (BUMPS.has(target)) {
2331
2564
  if (!currentVersion) {
@@ -2419,64 +2652,6 @@ function dirtyPaths() {
2419
2652
  )
2420
2653
  }
2421
2654
 
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
2655
  /** Each publish command with the CLI it drives, that CLI's preflight row, and its name. */
2481
2656
  const publishTargets = runs('publish')
2482
2657
  ? publishList.map((template) => {
@@ -2532,6 +2707,39 @@ if (autoBump) {
2532
2707
  }
2533
2708
  }
2534
2709
 
2710
+ if (resuming) {
2711
+ ok(`finishing ${resuming}: it was tagged and pushed, but never reached the registry`)
2712
+ }
2713
+
2714
+ // A previous release that never shipped is not history — its commits are still owed to
2715
+ // whoever installs this package, and they are in this release's range because of it. Say
2716
+ // so: the changelog keeps the section that was written for that version, and a section
2717
+ // naming a version no registry carries is worth a human deciding about.
2718
+ const absorbed = absorbedReleaseTags({ stable: !isPrerelease }).filter(
2719
+ (entry) => entry.version !== version,
2720
+ )
2721
+ if (absorbed.length) {
2722
+ const names = absorbed.map((entry) => entry.name).join(', ')
2723
+ const many = absorbed.length > 1
2724
+ const existingChangelog =
2725
+ config.changelog && existsSync(config.changelog) ? readFileSync(config.changelog, 'utf8') : null
2726
+ const documented = absorbed
2727
+ .filter((entry) => existingChangelog && changelogSection(existingChangelog, entry.version))
2728
+ .map((entry) => entry.version)
2729
+ const stale = documented.length
2730
+ ? `\n ${config.changelog} still documents ${documented.join(', ')} — ${
2731
+ documented.length > 1 ? 'versions' : 'a version'
2732
+ } no registry carries. Fold ${
2733
+ documented.length > 1 ? 'those sections' : 'that section'
2734
+ } into ${version} by hand.`
2735
+ : ''
2736
+ warn(
2737
+ `${names} ${many ? 'were' : 'was'} tagged but never published, so ${version} ships ${
2738
+ many ? 'their' : 'its'
2739
+ } commits as well as its own.${stale}`,
2740
+ )
2741
+ }
2742
+
2535
2743
  // Writing the version is the first mutating step, and it used to discover a file it
2536
2744
  // could not write *while writing the others* — aborting with a raw stack trace after
2537
2745
  // some of them had already changed. Every target is checked here instead.
@@ -2595,7 +2803,12 @@ else if (dirty && runs('commit')) {
2595
2803
  )
2596
2804
  }
2597
2805
  } else if (dirty) {
2598
- fail(`working tree is not clean:\n${indent(formatStatus(dirty))}`)
2806
+ const hint = commitExcludedByConfig
2807
+ ? '\n The steps list in release.config.json omits `commit` (it may predate ' +
2808
+ 'commit becoming\n a default step). Add "commit" to it, or pass --commit ' +
2809
+ 'to commit these now.'
2810
+ : ''
2811
+ fail(`working tree is not clean:\n${indent(formatStatus(dirty))}${hint}`)
2599
2812
  } else ok('working tree clean')
2600
2813
 
2601
2814
  if (assistant) {
@@ -2887,6 +3100,7 @@ function draftNotesFor(v) {
2887
3100
  // between two candidates rather than the release.
2888
3101
  const { lastTag, subjects, commits, contributors } = commitsSinceLastTag({
2889
3102
  stable: !isPrerelease,
3103
+ shipped: true,
2890
3104
  })
2891
3105
  if (!commits.length) return null
2892
3106
 
@@ -2979,13 +3193,19 @@ for (const asset of config.assets) {
2979
3193
 
2980
3194
  // Reusing a tag is the resume path, and a resume writes nothing. If this run would still
2981
3195
  // 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)) {
3196
+ // the wrong revision — which is silent until someone checks out the tag, and worse for the
3197
+ // working tree: `publish` sends what is on disk now, not what the tag describes.
3198
+ const wouldCommit = [
3199
+ dirty && runs('commit') && 'the working tree',
3200
+ bumping && 'a version bump',
3201
+ rolledChangelog && 'a changelog entry',
3202
+ ].filter(Boolean)
3203
+ if (taggedCommit && runs('tag') && wouldCommit.length) {
2984
3204
  fail(
2985
3205
  `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.',
3206
+ `${wouldCommit.join(' and ')}.\n` +
3207
+ ' That commit would leave the tag behind HEAD, and publish a tree it does not ' +
3208
+ 'describe.\n Release a new version, or use --only with the steps that remain.',
2989
3209
  )
2990
3210
  }
2991
3211