@entro314labs/release-kit 2.9.4 → 2.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (5) hide show
  1. package/README.md +221 -42
  2. package/TRAIN.md +78 -41
  3. package/package.json +3 -3
  4. package/release.mjs +671 -47
  5. package/train.mjs +328 -33
package/release.mjs CHANGED
@@ -42,6 +42,7 @@ import {
42
42
  mkdtempSync,
43
43
  readdirSync,
44
44
  readFileSync,
45
+ statSync,
45
46
  writeFileSync,
46
47
  } from 'node:fs'
47
48
  import { homedir, tmpdir } from 'node:os'
@@ -92,6 +93,9 @@ import { fileURLToPath } from 'node:url'
92
93
  * verify string command run during preflight — a project's own gate (tests,
93
94
  * build). Non-zero aborts before anything mutates, instead of a
94
95
  * prepublishOnly hook failing after the commit, tag and push
96
+ * requireGreen boolean refuse to release a HEAD that GitHub does not report green:
97
+ * pushed, every check run and commit status finished, none of
98
+ * them failed. See `checkHeadIsGreen`
95
99
  * assistant string|object drafting CLI for commit messages and notes. A key of
96
100
  * ASSISTANTS, "auto" for the first available, or null. The
97
101
  * object form { tool, model, effort } also pins which model and
@@ -147,6 +151,7 @@ const DEFAULTS = {
147
151
  '^(fixup|squash)!',
148
152
  ],
149
153
  verify: null,
154
+ requireGreen: false,
150
155
  hooks: {},
151
156
  }
152
157
 
@@ -215,6 +220,8 @@ Flags:
215
220
  drafted Conventional Commits message instead of refusing to release
216
221
  --preid <id> prerelease identifier (alpha, beta, rc, next, nightly, canary)
217
222
  --dist-tag <name> override the npm dist-tag (default: derived from the version)
223
+ --package release the package in this directory, one of several in the
224
+ repository: its own tag (<name>@<version>), commits and publish
218
225
  --dry-run print every step and execute nothing
219
226
  --yes, -y skip the confirmation prompt
220
227
  --notes-file <path> write the resolved release notes to a file for the next tool
@@ -281,7 +288,9 @@ const formatStatus = (porcelain) =>
281
288
  .join('\n')
282
289
 
283
290
  function abort(message, title = 'RELEASE ABORTED') {
284
- console.log(`\n${red(bold(title))} — ${message}\n`)
291
+ // Through `say`: under `next`, stdout carries the version alone, and an abort captured by
292
+ // `$(...)` would otherwise become the "version".
293
+ say(`\n${red(bold(title))} — ${message}\n`)
285
294
  process.exit(1)
286
295
  }
287
296
 
@@ -292,9 +301,15 @@ function abort(message, title = 'RELEASE ABORTED') {
292
301
  * the real cause under a Node stack trace.
293
302
  */
294
303
  function abortMidRelease(commandLine) {
304
+ // A relative bump re-run as-is would count from the version this run already wrote, and
305
+ // preflight refuses it; what finishes the release is the version itself, or no target.
306
+ const rerun =
307
+ target && target !== 'auto' && BUMPS.has(target)
308
+ ? 're-run with no target (or `auto`)'
309
+ : 're-run the same command'
295
310
  abort(
296
311
  `\`${commandLine}\` failed — see its output above.\n\n` +
297
- ' The release stopped partway through. Fix the cause and re-run the same command:\n' +
312
+ ` The release stopped partway through. Fix the cause and ${rerun}:\n` +
298
313
  ' the steps that already completed are detected and skipped.',
299
314
  )
300
315
  }
@@ -573,7 +588,7 @@ function commitsSinceLastTag(options) {
573
588
  // separator keeps multi-line messages parseable when splitting the log back apart.
574
589
  // %h first, then the author, then the message: the hash is what links each bullet back
575
590
  // to its commit, and the author is what says who is new here.
576
- const raw = tryRead('git', ['log', `--format=%h%x1f%an%x1f%ae%x1f%B%x1e`, range]) ?? ''
591
+ const raw = tryRead('git', ['log', `--format=%h%x1f%an%x1f%ae%x1f%B%x1e`, range, ...SCOPE]) ?? ''
577
592
  const commits = raw
578
593
  .split('\u001E')
579
594
  .map((entry) => entry.trim())
@@ -657,11 +672,29 @@ const CHANGELOG_SECTIONS = [
657
672
  { type: 'chore', section: 'Miscellaneous Chores' },
658
673
  ]
659
674
 
675
+ /**
676
+ * The `Notes:` trailer in a commit body: the author's own wording for the release-notes
677
+ * entry, which beats anything derived from the subject. `Notes: no-notes` keeps the commit
678
+ * out of the notes altogether — a refactor that has to be a `fix:` for the version bump but
679
+ * that no reader upgrading needs to hear about. GitHub Desktop runs its notes this way.
680
+ *
681
+ * The version bump is not affected either way: the trailer decides what is said about a
682
+ * change, not whether it happened.
683
+ *
684
+ * @returns {{text: string|null, excluded: boolean}}
685
+ */
686
+ function notesTrailer(body = '') {
687
+ const text = /^Notes:[ \t]*(\S.*)$/im.exec(body)?.[1]?.trim() ?? null
688
+ const excluded = !!text && /^no-notes$/i.test(text)
689
+ return { text: excluded ? null : text, excluded }
690
+ }
691
+
660
692
  /**
661
693
  * Parse a commit into the parts a release cares about.
662
694
  *
663
695
  * @returns {{type: string, scope: string|null, breaking: boolean, subject: string,
664
- * releaseAs: string|null} | null} null when the subject is not Conventional Commits
696
+ * releaseAs: string|null, notes: string|null, noNotes: boolean} | null} null when the
697
+ * subject is not Conventional Commits
665
698
  */
666
699
  function parseCommit(subject, body = '', hash = '') {
667
700
  // Conventional Commits does not restrict the type to letters — `i18n:` and `a11y:` are
@@ -680,6 +713,7 @@ function parseCommit(subject, body = '', hash = '') {
680
713
  // A BREAKING CHANGE footer usually explains the break far better than the subject does.
681
714
  const breakingNote =
682
715
  /^BREAKING[ -]CHANGE:\s*([\s\S]+?)(?=\n\n|$)/m.exec(body)?.[1]?.trim() ?? null
716
+ const trailer = notesTrailer(body)
683
717
  return {
684
718
  type: type.toLowerCase(),
685
719
  scope: scope ?? null,
@@ -689,6 +723,8 @@ function parseCommit(subject, body = '', hash = '') {
689
723
  releaseAs,
690
724
  closes: [...new Set(closes)],
691
725
  breakingNote,
726
+ notes: trailer.text,
727
+ noNotes: trailer.excluded,
692
728
  }
693
729
  }
694
730
 
@@ -727,7 +763,9 @@ function inferBump(commits, currentVersion, strategy = 'conventional') {
727
763
  * @returns {string | null} markdown body, or null when nothing visible changed
728
764
  */
729
765
  function changelogFromCommits(commits, links = null, hidden = [], contributors = []) {
730
- const parsed = commits.map((c) => parseCommit(c.subject, c.body, c.hash)).filter(Boolean)
766
+ const parsed = commits
767
+ .map((c) => parseCommit(c.subject, c.body, c.hash))
768
+ .filter((c) => c && !c.noNotes)
731
769
  const lines = []
732
770
 
733
771
  /** One bullet: scope, text, a link to the commit, and any issues it closes. */
@@ -745,8 +783,9 @@ function changelogFromCommits(commits, links = null, hidden = [], contributors =
745
783
  const breaking = parsed.filter((c) => c.breaking)
746
784
  if (breaking.length) {
747
785
  lines.push('### ⚠ BREAKING CHANGES', '')
748
- // The footer explains the break; the subject only says what changed.
749
- for (const c of breaking) lines.push(bullet(c, c.breakingNote ?? c.subject))
786
+ // The footer explains the break; the subject only says what changed. A `Notes:`
787
+ // trailer beats both: it is the author's wording written for exactly this list.
788
+ for (const c of breaking) lines.push(bullet(c, c.notes ?? c.breakingNote ?? c.subject))
750
789
  lines.push('')
751
790
  }
752
791
 
@@ -768,7 +807,7 @@ function changelogFromCommits(commits, links = null, hidden = [], contributors =
768
807
  )
769
808
  if (!inSection.length) continue
770
809
  lines.push(`### ${section}`, '')
771
- for (const c of inSection) lines.push(bullet(c, c.subject))
810
+ for (const c of inSection) lines.push(bullet(c, c.notes ?? c.subject))
772
811
  lines.push('')
773
812
  }
774
813
 
@@ -780,7 +819,7 @@ function changelogFromCommits(commits, links = null, hidden = [], contributors =
780
819
  )
781
820
  if (other.length) {
782
821
  lines.push('### Other Changes', '')
783
- for (const c of other) lines.push(bullet(c, c.subject))
822
+ for (const c of other) lines.push(bullet(c, c.notes ?? c.subject))
784
823
  lines.push('')
785
824
  }
786
825
 
@@ -1066,13 +1105,41 @@ function linkCitedCommits(notes, commits, links) {
1066
1105
  })
1067
1106
  }
1068
1107
 
1108
+ /**
1109
+ * The marker a drafting model puts on an entry it is not sure of — whether the change is
1110
+ * user-facing, or what it means for someone upgrading. The alternative is a confident guess
1111
+ * published as fact; a flagged entry is a question for the human cutting the release.
1112
+ */
1113
+ const UNSURE = '[???]'
1114
+
1115
+ /**
1116
+ * Drafted entries the model flagged as unsure, as the lines it wrote them on. Drafts are
1117
+ * validated, not trusted: like an invented commit hash, a flagged entry must not reach a
1118
+ * tag, a changelog or a release page without someone having looked at it.
1119
+ *
1120
+ * @returns {string[]}
1121
+ */
1122
+ function uncertainEntries(notes) {
1123
+ return (notes ?? '')
1124
+ .split('\n')
1125
+ .map((line) => line.trim())
1126
+ .filter((line) => line.includes(UNSURE))
1127
+ }
1128
+
1129
+ /** The notes with the marker removed, once a human has reviewed the flagged entries. */
1130
+ const withoutUnsureMarkers = (notes) => notes.replaceAll(`${UNSURE} `, '').replaceAll(UNSURE, '')
1131
+
1069
1132
  /**
1070
1133
  * Draft release notes from the commit log.
1071
1134
  *
1072
1135
  * @returns {string | null} markdown body (no version heading), or null
1073
1136
  */
1074
1137
  function draftReleaseNotes(version, commits, lastTag, links) {
1075
- if (!commits.length) return null
1138
+ // `Notes: no-notes` is the author saying this commit is not news; the model never sees it.
1139
+ const listed = commits
1140
+ .map((c) => ({ ...c, trailer: notesTrailer(c.body) }))
1141
+ .filter((c) => !c.trailer.excluded)
1142
+ if (!listed.length) return null
1076
1143
 
1077
1144
  const prompt = [
1078
1145
  `Write release notes for version ${version}.`,
@@ -1084,20 +1151,31 @@ function draftReleaseNotes(version, commits, lastTag, links) {
1084
1151
  '- End every bullet with the short hashes it covers, in parentheses: `(abc1234)` or',
1085
1152
  ' `(abc1234, def5678)` when merged. Copy them exactly from the list below and invent',
1086
1153
  ' nothing — a hash that is not in the list will be removed.',
1087
- '- Omit internal chores: CI, linting, formatting, dependency bumps, version bumps.',
1154
+ '- Omit internal chores: CI, linting, formatting, version bumps and dependency bumps —',
1155
+ ' except a component that ships inside the product (a bundled runtime, a sidecar',
1156
+ ' binary, an embedded engine or database). Users run that code, so its update is a',
1157
+ ' user-visible change: name the component and the version it moved to.',
1158
+ '- For bug fixes, describe what works now, not what was broken.',
1159
+ '- If you cannot tell whether a change is user-facing, or what it means for someone',
1160
+ ` upgrading, start that bullet with ${UNSURE} — a person will resolve it. Do not guess.`,
1161
+ "- A commit with a `Notes:` line carries its author's wording for the entry: use that",
1162
+ ' text as written, changing it only to fit the heading or to merge it with related work.',
1088
1163
  '- Write for someone upgrading: say what changed for them, not which files moved.',
1089
1164
  '- Plain, factual language. No hype, no emoji, no concluding summary.',
1090
1165
  '- Output only the markdown body: no version heading, no code fences, no attribution.',
1091
1166
  '- Do NOT explain your reasoning or add any commentary before or after the notes.',
1092
1167
  '',
1093
1168
  `Commits since ${lastTag ?? 'the start of the project'}:`,
1094
- ...commits.map((c) => `- ${(c.hash ?? '').slice(0, 7)} ${c.subject}`),
1169
+ ...listed.flatMap((c) => [
1170
+ `- ${(c.hash ?? '').slice(0, 7)} ${c.subject}`,
1171
+ ...(c.trailer.text ? [` Notes: ${c.trailer.text}`] : []),
1172
+ ]),
1095
1173
  ].join('\n')
1096
1174
 
1097
1175
  const drafted = runAssistant(prompt)
1098
1176
  if (!drafted) return null
1099
1177
  const cleaned = cleanNotes(drafted)
1100
- return cleaned ? linkCitedCommits(cleaned, commits, links) : null
1178
+ return cleaned ? linkCitedCommits(cleaned, listed, links) : null
1101
1179
  }
1102
1180
 
1103
1181
  /** One-line rendering of an argv, so a multi-line arg (release notes) stays readable. */
@@ -1213,7 +1291,9 @@ function pushBranchAndTag(branchRef) {
1213
1291
  } catch (err) {
1214
1292
  const stderr = `${err.stderr ?? ''}`
1215
1293
  process.stderr.write(stderr)
1216
- if (!/atomic/i.test(stderr)) abortMidRelease(line)
1294
+ // Only the capability refusal falls back. git's rejection of a ref also names atomic
1295
+ // ("atomic push failed"), and that one must stop here.
1296
+ if (!/does not support --atomic/i.test(stderr)) abortMidRelease(line)
1217
1297
  }
1218
1298
  warn(
1219
1299
  `${config.remote} does not support atomic pushes — sending the branch and tag in one ` +
@@ -1396,9 +1476,39 @@ function changelogOutOfOrder(text) {
1396
1476
  return versions.filter((v, i) => i > 0 && compareVersions(versions[i - 1], v) < 0)
1397
1477
  }
1398
1478
 
1479
+ /**
1480
+ * The release candidates of a stable version that the changelog has sections for: for
1481
+ * `2.0.0`, the `2.0.0-rc.1` and `2.0.0-beta.3` headings.
1482
+ *
1483
+ * A stable release reads history from the last stable tag and generates its notes from
1484
+ * those commits, so wording someone edited into a candidate's section does not carry over.
1485
+ * The sections stay in the file; this is how the release notices they exist.
1486
+ *
1487
+ * @returns {string[]} the candidate versions, in file order
1488
+ */
1489
+ function candidateSections(text, version) {
1490
+ const base = parseVersion(version)
1491
+ if (!base || base.pre.length) return []
1492
+ return [...text.matchAll(/^## \[?v?(\d+\.\d+\.\d+-[\w.]+)\]?/gm)]
1493
+ .map((m) => m[1])
1494
+ .filter((v) => {
1495
+ const parsed = parseVersion(v)
1496
+ return (
1497
+ parsed &&
1498
+ parsed.major === base.major &&
1499
+ parsed.minor === base.minor &&
1500
+ parsed.patch === base.patch
1501
+ )
1502
+ })
1503
+ }
1504
+
1399
1505
  /** The `## ` heading offsets in a changelog, in file order. */
1400
1506
  const sectionOffsets = (text) => [...text.matchAll(/^## .*$/gm)].map((m) => m.index)
1401
1507
 
1508
+ /** True when the changelog already has a `## [version]` heading, empty body or not. */
1509
+ const hasVersionHeading = (text, version) =>
1510
+ new RegExp(`^##\\s+\\[?v?${escapeRe(version)}\\]?(?![\\w.-])`, 'm').test(text)
1511
+
1402
1512
  /**
1403
1513
  * Place a version's section where it belongs: above the first section whose version is
1404
1514
  * lower, rather than wherever the file happens to start.
@@ -1425,9 +1535,13 @@ function insertChangelogSection(text, version, date, body) {
1425
1535
  }
1426
1536
  // Falling out of the loop means every version section is newer, so the release belongs
1427
1537
  // at the foot. With nothing to compare against it belongs at the head instead: appending
1428
- // to a date-headed changelog would file the release below its oldest entry.
1429
- if (!comparable && offsets.length) {
1430
- return `${text.slice(0, offsets[0])}${entry}\n${text.slice(offsets[0])}`
1538
+ // to a date-headed changelog would file the release below its oldest entry. The head is
1539
+ // below [Unreleased], though, which stays the first section — a first release drafted into
1540
+ // a changelog holding only an empty [Unreleased] used to land above it.
1541
+ if (!comparable) {
1542
+ const unreleased = /^## \[?Unreleased\]?/i
1543
+ const first = offsets.find((offset) => !unreleased.test(text.slice(offset)))
1544
+ if (first !== undefined) return `${text.slice(0, first)}${entry}\n${text.slice(first)}`
1431
1545
  }
1432
1546
  const trimmed = text.trimEnd()
1433
1547
  return `${trimmed}\n\n${entry}`
@@ -1503,7 +1617,7 @@ function withChangelogLinks(text, links, tagPrefix = '') {
1503
1617
  function rollUnreleased(text, version, date) {
1504
1618
  // A heading for this version already exists — possibly with an empty body, which
1505
1619
  // `changelogSection` reports as absent. Rolling again would duplicate the heading.
1506
- if (new RegExp(`^##\\s+\\[?v?${escapeRe(version)}\\]?(?![\\w.-])`, 'm').test(text)) return null
1620
+ if (hasVersionHeading(text, version)) return null
1507
1621
 
1508
1622
  const heading = /^##\s+\[?Unreleased\]?[^\n]*$/im
1509
1623
  const match = heading.exec(text)
@@ -1611,6 +1725,53 @@ function expandPaths(pattern) {
1611
1725
  return current
1612
1726
  }
1613
1727
 
1728
+ /**
1729
+ * crates.io's default ceiling on an uploaded `.crate`, in bytes: `10 * 1024 * 1024` in
1730
+ * crates.io's src/config/publish_limits.rs. The registry can raise it for one crate on
1731
+ * request, but a crate over it is otherwise refused at upload — after the tag and the push.
1732
+ */
1733
+ const CRATES_IO_MAX_BYTES = 10 * 1024 * 1024
1734
+
1735
+ /**
1736
+ * The `cargo package` argv that builds exactly what a `cargo publish` command would upload,
1737
+ * so preflight can find a missing file or an oversized archive before anything is tagged.
1738
+ *
1739
+ * Deriving it from the publish command rather than guessing keeps the selection the same:
1740
+ * `-p`, `--workspace`, `--manifest-path`, `--features` and `--no-verify` mean the same thing
1741
+ * to both subcommands, so a workspace packages each crate it will publish. Only the flags
1742
+ * `cargo package` does not take are dropped (`--dry-run`, `--token`).
1743
+ *
1744
+ * `--locked` is added when the repository has a `Cargo.lock`: publishing verifies against
1745
+ * it, and a stale one is a failure better found now. Without a lockfile it would refuse
1746
+ * outright, and plenty of libraries do not commit one. `--allow-dirty` is added only when
1747
+ * the tree is dirty — preflight reports that on its own, or the commit step is about to
1748
+ * make it clean — so the package check still says something useful.
1749
+ *
1750
+ * A command that is not a plain `cargo publish …` (a pipeline, quoting, an env prefix) is
1751
+ * not taken apart: there is no reliable way to know what it uploads.
1752
+ *
1753
+ * @returns {string[] | null} args for `cargo`, or null when it cannot be derived
1754
+ */
1755
+ function cargoPackageArgs(command, { dirty = false, lockfile = false } = {}) {
1756
+ if (!/^cargo\s+publish(?:\s|$)/.test(command.trim()) || /[;&|<>`$()'"\\]/.test(command)) {
1757
+ return null
1758
+ }
1759
+ const words = command.trim().split(/\s+/).slice(2)
1760
+ const args = ['package']
1761
+ for (let i = 0; i < words.length; i += 1) {
1762
+ const word = words[i]
1763
+ if (word === '--dry-run' || word === '-n' || word.startsWith('--token=')) continue
1764
+ if (word === '--token') {
1765
+ i += 1
1766
+ continue
1767
+ }
1768
+ args.push(word)
1769
+ }
1770
+ if (lockfile && !args.includes('--locked')) args.push('--locked')
1771
+ if (dirty && !args.includes('--allow-dirty')) args.push('--allow-dirty')
1772
+ return args
1773
+ }
1774
+
1614
1775
  /**
1615
1776
  * The crates in a Cargo workspace whose version this bump owns: the members that inherit
1616
1777
  * it with `version.workspace = true`, which is how a workspace keeps its crates in step.
@@ -2042,7 +2203,16 @@ const VALUE_OPTIONS = new Set([
2042
2203
  ])
2043
2204
 
2044
2205
  /** 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'])
2206
+ const BOOLEAN_FLAGS = new Set([
2207
+ '--dry-run',
2208
+ '--yes',
2209
+ '-y',
2210
+ '--commit',
2211
+ '--package',
2212
+ '--help',
2213
+ '-h',
2214
+ '--sync',
2215
+ ])
2046
2216
 
2047
2217
  /** The version or bump target: the only argument that is neither a flag nor a flag's value. */
2048
2218
  const positionals = []
@@ -2077,6 +2247,14 @@ if (positionals.length > 1) {
2077
2247
  : ''
2078
2248
  abort(`unexpected argument${extras.length > 1 ? 's' : ''}: ${extras.join(' ')}${hint}`)
2079
2249
  }
2250
+ // Only a pre* bump reads --preid. Anywhere else it was accepted and dropped, so
2251
+ // `auto --preid beta` released a stable version to the `latest` dist-tag.
2252
+ if (requestedPreid !== undefined && !target?.startsWith('pre')) {
2253
+ abort(
2254
+ `--preid only applies to prepatch, preminor, premajor and prerelease, not ${target ? `"${target}"` : 'a release with no target'}.\n` +
2255
+ ` For a ${requestedPreid} prerelease: release-kit prerelease --preid ${requestedPreid}`,
2256
+ )
2257
+ }
2080
2258
 
2081
2259
  // ─────────────────────────────────────────────────────────────────────────────
2082
2260
  // SETUP
@@ -2085,26 +2263,89 @@ if (positionals.length > 1) {
2085
2263
  const root = tryRead('git', ['rev-parse', '--show-toplevel'])
2086
2264
  if (!root) abort('not inside a git repository')
2087
2265
 
2088
- // A release is scoped to the repository: the version, the tag and the push all belong to
2089
- // one git history, so the package released is the one at the git root. Refuse when invoked
2090
- // from a nested package instead — silently releasing the parent is the worse outcome.
2266
+ /**
2267
+ * A release is scoped to the repository: the version, the tag and the push all belong to
2268
+ * one git history, so the package released is the one at the git root. `--package` scopes
2269
+ * it to the working directory instead — one package among several in the repository, as
2270
+ * release-train runs it. Its config, manifest, changelog, verify and publish are that
2271
+ * directory's; the commits it reads and the working tree it checks are limited to the
2272
+ * directory; and it tags `<name>@<version>` unless its config names a `tagPrefix`, so
2273
+ * packages sharing a history keep separate tags.
2274
+ */
2275
+ const packageMode = flag('--package')
2276
+
2277
+ // Without --package, refuse a nested package rather than silently releasing the parent.
2091
2278
  const localManifest = resolve('package.json')
2092
2279
  const rootManifest = join(root, 'package.json')
2093
- if (existsSync(localManifest) && localManifest !== rootManifest) {
2280
+ if (!packageMode && existsSync(localManifest) && localManifest !== rootManifest) {
2094
2281
  abort(
2095
2282
  `${relative(root, localManifest)} is a nested package, but a release covers the whole ` +
2096
2283
  `repository.\n\n Running here would release ${
2097
2284
  existsSync(rootManifest) ? readJson(rootManifest).name : 'the repository root'
2098
2285
  } instead.\n` +
2099
- ' release-kit handles one package per repository; it does not release workspace members.',
2286
+ ' To release this package on its own — its own tag, commits, changelog and publish —\n' +
2287
+ ' run with --package (release-train does, for a repository holding several).',
2100
2288
  )
2101
2289
  }
2102
- process.chdir(root)
2290
+ if (!packageMode) process.chdir(root)
2291
+
2292
+ /** The pathspec a package release limits `git log`, `git status` and `git add` to. */
2293
+ const SCOPE = packageMode ? ['--', '.'] : []
2103
2294
 
2104
2295
  const userConfig = readUserConfig()
2105
2296
  const config = { ...DEFAULTS, ...userConfig }
2106
2297
  const unknownKeys = Object.keys(config).filter((key) => !(key in DEFAULTS))
2107
2298
  if (unknownKeys.length) abort(`release.config.json has unknown keys: ${unknownKeys.join(', ')}`)
2299
+
2300
+ /**
2301
+ * What each key has to hold. A known key with the wrong shape was accepted and then read as
2302
+ * if it were right: `"steps": "tag,push"` ran no step at all and still reported a release,
2303
+ * `"tagPrefix": null` tagged `null1.1.0`, and a string where an array belongs crashed with a
2304
+ * TypeError after the tag was pushed.
2305
+ */
2306
+ const isString = (value) => typeof value === 'string'
2307
+ const isStringOrNull = (value) => value === null || isString(value)
2308
+ const isStringArray = (value) => Array.isArray(value) && value.every(isString)
2309
+ const isObject = (value) => !!value && typeof value === 'object' && !Array.isArray(value)
2310
+ const CONFIG_SHAPES = {
2311
+ steps: [isStringArray, 'an array of step names'],
2312
+ tagPrefix: [isString, 'a string'],
2313
+ branch: [isStringOrNull, 'a string, or null'],
2314
+ remote: [isString, 'a string'],
2315
+ changelog: [isStringOrNull, 'a string, or null'],
2316
+ versionFile: [
2317
+ (v) => v === undefined || isStringOrNull(v) || isObject(v),
2318
+ 'a path, an object, or null',
2319
+ ],
2320
+ versionFiles: [
2321
+ (v) => Array.isArray(v) && v.every((f) => isString(f) || (isObject(f) && isString(f.path))),
2322
+ 'an array of paths or { path, pattern } objects',
2323
+ ],
2324
+ publish: [
2325
+ (v) => v === undefined || isStringOrNull(v) || isStringArray(v),
2326
+ 'a command, an array of commands, or null',
2327
+ ],
2328
+ commitMessage: [isString, 'a string'],
2329
+ releaseTitle: [isString, 'a string'],
2330
+ assets: [isStringArray, 'an array of paths'],
2331
+ assistant: [(v) => isStringOrNull(v) || isObject(v), 'a name, an object, or null'],
2332
+ notesFile: [isStringOrNull, 'a path, or null'],
2333
+ versioning: [
2334
+ (v) => ['conventional', 'always-patch', 'always-minor', 'always-major'].includes(v),
2335
+ 'one of conventional, always-patch, always-minor, always-major',
2336
+ ],
2337
+ notes: [isString, 'a string'],
2338
+ hiddenTypes: [isStringArray, 'an array of commit types'],
2339
+ ignoreCommits: [isStringArray, 'an array of regexes'],
2340
+ verify: [isStringOrNull, 'a command, or null'],
2341
+ requireGreen: [(v) => typeof v === 'boolean', 'true or false'],
2342
+ hooks: [(v) => isObject(v) && Object.values(v).every(isString), 'an object of command strings'],
2343
+ }
2344
+ const misshapen = Object.entries(CONFIG_SHAPES)
2345
+ .filter(([key, [valid]]) => !valid(config[key]))
2346
+ .map(([key, [, expected]]) => ` ${key} must be ${expected}, not ${JSON.stringify(config[key])}`)
2347
+ if (misshapen.length)
2348
+ abort(`release.config.json has keys of the wrong type:\n${misshapen.join('\n')}`)
2108
2349
  // A misspelled hook name is a hook that silently never runs, which is the failure mode
2109
2350
  // this file refuses everywhere else it takes a name.
2110
2351
  const unknownHooks = Object.keys(config.hooks ?? {}).filter((key) => !HOOKS.includes(key))
@@ -2166,6 +2407,10 @@ const ECOSYSTEM_MANIFESTS = ['package.json', 'pyproject.toml', 'Cargo.toml']
2166
2407
  */
2167
2408
  function detectCompanionFiles(primaryPath, primaryVersion) {
2168
2409
  const companions = []
2410
+ // The lockfile pins the crate's own version, so a bump leaves it stale — whether
2411
+ // Cargo.toml is the version source or a manifest kept in step with one. A plain crate was
2412
+ // the case missed: `cargo publish` then refused the dirty lockfile, after the push.
2413
+ if (primaryPath === 'Cargo.toml' && existsSync('Cargo.lock')) companions.push('Cargo.lock')
2169
2414
  for (const candidate of ECOSYSTEM_MANIFESTS) {
2170
2415
  if (candidate === primaryPath || !existsSync(candidate)) continue
2171
2416
  const found = readVersionFrom({ path: candidate })
@@ -2281,6 +2526,19 @@ function versionFromLastTag() {
2281
2526
  return releaseTags()[0]?.version ?? null
2282
2527
  }
2283
2528
 
2529
+ // A package's tags are `<name>@<version>`, the scheme release-train reads, unless its
2530
+ // config names a prefix. Set before anything reads a tag.
2531
+ if (packageMode && !Object.hasOwn(userConfig, 'tagPrefix')) {
2532
+ const name = manifest?.name ?? (versionFile ? readNameFrom(versionFile) : null)
2533
+ if (!name) {
2534
+ abort(
2535
+ '--package tags a release <name>@<version>, and no package name was found here.\n' +
2536
+ ' Set "tagPrefix" in this package\'s release.config.json.',
2537
+ )
2538
+ }
2539
+ config.tagPrefix = `${name}@`
2540
+ }
2541
+
2284
2542
  const currentVersion = versionFile ? readVersionFrom(versionFile) : versionFromLastTag()
2285
2543
  if (versionFile && !currentVersion) {
2286
2544
  abort(`could not read a version from ${versionFile.path}`)
@@ -2294,7 +2552,10 @@ const goModule = existsSync('go.mod')
2294
2552
  ? (/^module\s+(\S+)/m.exec(readFileSync('go.mod', 'utf8'))?.[1] ?? null)
2295
2553
  : null
2296
2554
  const projectName =
2297
- manifest?.name ?? (versionFile ? readNameFrom(versionFile) : null) ?? goModule ?? basename(root)
2555
+ manifest?.name ??
2556
+ (versionFile ? readNameFrom(versionFile) : null) ??
2557
+ goModule ??
2558
+ basename(process.cwd())
2298
2559
 
2299
2560
  /**
2300
2561
  * Manifests found beside the primary one that carry the same version. Detected only when
@@ -2567,9 +2828,35 @@ function versionShipped(v) {
2567
2828
  */
2568
2829
  function unfinishedRelease() {
2569
2830
  const [newest] = releaseTags()
2570
- if (!newest || versionShipped(newest.version) !== false) return null
2831
+ if (!newest) return null
2571
2832
  const at = tryRead('git', ['rev-list', '-n', '1', newest.name])
2572
- return at && at === tryRead('git', ['rev-parse', 'HEAD']) ? newest : null
2833
+ if (!at || at !== tryRead('git', ['rev-parse', 'HEAD'])) return null
2834
+ const shipped = versionShipped(newest.version)
2835
+ if (shipped === false) return { ...newest, missing: 'the registry' }
2836
+ // With no registry to ask — `publish: null`, or one that could not answer — the push is
2837
+ // the last step that leaves a mark to check. A tag at HEAD the remote does not have is a
2838
+ // release that died before or during the push.
2839
+ if (shipped === null && runs('push') && tagOnRemote(newest.name) === false) {
2840
+ return { ...newest, missing: config.remote }
2841
+ }
2842
+ return null
2843
+ }
2844
+
2845
+ /**
2846
+ * Whether the remote has this tag: false only when it answered without it, null when it
2847
+ * could not be asked.
2848
+ *
2849
+ * @param {string} name
2850
+ * @returns {boolean | null}
2851
+ */
2852
+ function tagOnRemote(name) {
2853
+ try {
2854
+ read('git', ['ls-remote', '--exit-code', '--tags', config.remote, `refs/tags/${name}`])
2855
+ return true
2856
+ } catch (err) {
2857
+ // --exit-code exits 2 for "no matching refs"; anything else is the remote not answering.
2858
+ return err.status === 2 ? false : null
2859
+ }
2573
2860
  }
2574
2861
 
2575
2862
  // ─────────────────────────────────────────────────────────────────────────────
@@ -2586,6 +2873,8 @@ let autoBump = null
2586
2873
 
2587
2874
  /** The tag of a previous release this run is finishing rather than starting. */
2588
2875
  let resuming = null
2876
+ /** What that release never reached: the registry, or the remote. */
2877
+ let resumingMissing = null
2589
2878
 
2590
2879
  /**
2591
2880
  * A release tagged at HEAD that never reached the registry, found while resolving a
@@ -2602,7 +2891,7 @@ let unfinishedAtHead = null
2602
2891
  * ship a tree the tag does not describe; that work belongs in the next version, which is
2603
2892
  * what the shipped-tag baseline makes sure it is released as.
2604
2893
  */
2605
- const wouldCommitMore = !!tryRead('git', ['status', '--porcelain']) && runs('commit')
2894
+ const wouldCommitMore = !!tryRead('git', ['status', '--porcelain', ...SCOPE]) && runs('commit')
2606
2895
 
2607
2896
  let version
2608
2897
  if (!target) {
@@ -2622,7 +2911,7 @@ if (!target) {
2622
2911
  }
2623
2912
  const pending = wouldCommitMore ? null : unfinishedRelease()
2624
2913
  if (pending) {
2625
- ;({ name: resuming, version } = pending)
2914
+ ;({ name: resuming, version, missing: resumingMissing } = pending)
2626
2915
  } else {
2627
2916
  const { commits, lastTag } = commitsSinceLastTag({ shipped: true })
2628
2917
  if (!commits.length) {
@@ -2731,12 +3020,15 @@ function runHook(name) {
2731
3020
  */
2732
3021
  function dirtyPaths() {
2733
3022
  return (
2734
- (tryRead('git', ['status', '--porcelain']) ?? '')
3023
+ (tryRead('git', ['status', '--porcelain', ...SCOPE]) ?? '')
2735
3024
  .split('\n')
2736
3025
  .map((line) => /^\s*\S{1,2}\s+(.+)$/.exec(line)?.[1]?.trim())
2737
3026
  .filter(Boolean)
2738
3027
  // A rename reads as "old -> new"; the new path is the one to stage.
2739
3028
  .map((path) => path.split(' -> ').at(-1))
3029
+ // Porcelain paths are relative to the repository root; everything they are compared
3030
+ // with and staged as is relative to the working directory, which --package moves.
3031
+ .map((path) => relative(process.cwd(), join(root, path)))
2740
3032
  )
2741
3033
  }
2742
3034
 
@@ -2796,12 +3088,12 @@ if (autoBump) {
2796
3088
  }
2797
3089
 
2798
3090
  if (resuming) {
2799
- ok(`finishing ${resuming}: it was tagged and pushed, but never reached the registry`)
3091
+ ok(`finishing ${resuming}: it was tagged, but never reached ${resumingMissing}`)
2800
3092
  }
2801
3093
 
2802
3094
  if (unfinishedAtHead) {
2803
3095
  fail(
2804
- `${unfinishedAtHead.name} is tagged at HEAD but never reached the registry, and a ` +
3096
+ `${unfinishedAtHead.name} is tagged at HEAD but never reached ${unfinishedAtHead.missing}, and a ` +
2805
3097
  `${target} bump would release ${version} from the same commit and leave it that way.\n` +
2806
3098
  ` Finish it instead: re-run with no target, or with auto.`,
2807
3099
  )
@@ -2898,6 +3190,31 @@ if (bumping) {
2898
3190
  }
2899
3191
  }
2900
3192
 
3193
+ // A configured list is never extended, so a Cargo.toml written without the Cargo.lock beside
3194
+ // it leaves the lockfile on the old version. Publishing is where that breaks: `cargo publish`
3195
+ // refuses a dirty tree, after the tag and the push. Say so before either.
3196
+ if (bumping && publishTargets.some((target) => target.cli === 'cargo')) {
3197
+ const written = new Set(versionTargets.map((source) => source.path))
3198
+ for (const source of versionTargets) {
3199
+ if (basename(source.path) !== 'Cargo.toml') continue
3200
+ const lockPath = join(dirname(source.path), 'Cargo.lock')
3201
+ if (written.has(lockPath) || !existsSync(lockPath)) continue
3202
+ let recorded = null
3203
+ try {
3204
+ recorded = readVersionFrom({ path: lockPath })
3205
+ } catch {
3206
+ // A lockfile with nothing beside it to scope by: not this check's question.
3207
+ }
3208
+ if (recorded && recorded !== version) {
3209
+ fail(
3210
+ `${lockPath} records ${readNameFrom(source) ?? 'the crate'} ${recorded}, and nothing ` +
3211
+ `will bump it — \`cargo publish\` would refuse the stale lockfile after the push.\n` +
3212
+ ` Add "${lockPath}" to versionFiles in release.config.json.`,
3213
+ )
3214
+ }
3215
+ }
3216
+ }
3217
+
2901
3218
  if (bumping && currentVersion && compareVersions(version, currentVersion) <= 0) {
2902
3219
  fail(`${version} is not greater than the current version ${currentVersion}`)
2903
3220
  } else if (bumping && currentVersion) {
@@ -2922,7 +3239,7 @@ if (bumping && currentVersion && compareVersions(version, currentVersion) <= 0)
2922
3239
  ok(`releasing ${version} (no version file; the tag is the version)`)
2923
3240
  }
2924
3241
 
2925
- const dirty = tryRead('git', ['status', '--porcelain'])
3242
+ const dirty = tryRead('git', ['status', '--porcelain', ...SCOPE])
2926
3243
  if (dirty === null) fail('could not read git status')
2927
3244
  else if (dirty && runs('commit')) {
2928
3245
  const entries = dirty.split('\n')
@@ -3008,6 +3325,123 @@ if (!succeeds('git', ['remote', 'get-url', config.remote])) {
3008
3325
  }
3009
3326
  }
3010
3327
 
3328
+ /**
3329
+ * Check run conclusions that mean the commit is not fit to release. `stale` is GitHub giving
3330
+ * up on a run that never finished, which is not a pass either.
3331
+ */
3332
+ const RED_CONCLUSIONS = new Set(['failure', 'cancelled', 'timed_out', 'action_required', 'stale'])
3333
+
3334
+ /**
3335
+ * Whether GitHub reports the commit being released as green — the gate `requireGreen` opts
3336
+ * into, so a release cannot be cut from a commit CI failed on or has not finished with.
3337
+ *
3338
+ * Both of GitHub's check systems are read. Check runs are what Actions and GitHub Apps
3339
+ * report; commit statuses are the older API that external CI (Buildkite, Jenkins, older
3340
+ * integrations) still posts to. Reading only one would call a commit green while the other
3341
+ * system has it red. The combined status's own `state` is not used: it says `pending` for a
3342
+ * commit with no statuses at all, so the individual statuses are what count.
3343
+ *
3344
+ * Only `success` counts as a pass. `neutral` and `skipped` are not failures, but a commit
3345
+ * whose every check was skipped has not been verified by anything, and zero checks is the
3346
+ * same answer — both are refused rather than read as green.
3347
+ *
3348
+ * When this runs inside GitHub Actions, the job running it is itself an unfinished check run
3349
+ * on the same commit — the `workflow_run` recipe in the README releases exactly the commit
3350
+ * it runs on — and waiting for it would wait forever. Check runs belonging to the current
3351
+ * workflow run are therefore skipped; jobs in the same workflow are what `needs:` orders.
3352
+ *
3353
+ * `{owner}/{repo}` is filled in by gh from the checkout, the same way `gh release create`
3354
+ * resolves the repository the release step publishes to.
3355
+ */
3356
+ function checkHeadIsGreen(sha) {
3357
+ const api = (path, jq) => tryRead('gh', ['api', '--paginate', path, '--jq', jq])
3358
+ const checkRuns = api(
3359
+ `repos/{owner}/{repo}/commits/${sha}/check-runs?per_page=100`,
3360
+ '.check_runs[] | [.name, .status, (.conclusion // ""), (.details_url // "")] | @tsv',
3361
+ )
3362
+ const statuses = api(
3363
+ `repos/{owner}/{repo}/commits/${sha}/status?per_page=100`,
3364
+ '.statuses[] | [.context, .state] | @tsv',
3365
+ )
3366
+ if (checkRuns === null || statuses === null) {
3367
+ fail(`requireGreen: could not read the checks on ${sha.slice(0, 8)} from GitHub (gh api)`)
3368
+ return
3369
+ }
3370
+ const ownRun =
3371
+ process.env.GITHUB_ACTIONS === 'true' && process.env.GITHUB_RUN_ID
3372
+ ? `/actions/runs/${process.env.GITHUB_RUN_ID}/`
3373
+ : null
3374
+ const rows = (text) =>
3375
+ text
3376
+ .split('\n')
3377
+ .filter((line) => line.trim())
3378
+ .map((line) => line.split('\t'))
3379
+
3380
+ const red = []
3381
+ const waiting = []
3382
+ let passed = 0
3383
+ for (const [name, status, conclusion, url = ''] of rows(checkRuns)) {
3384
+ if (ownRun && url.includes(ownRun)) continue
3385
+ if (status !== 'completed') waiting.push(`${name} (${status})`)
3386
+ else if (RED_CONCLUSIONS.has(conclusion)) red.push(`${name} (${conclusion})`)
3387
+ else if (conclusion === 'success') passed += 1
3388
+ }
3389
+ for (const [context, state] of rows(statuses)) {
3390
+ if (state === 'failure' || state === 'error') red.push(`${context} (${state})`)
3391
+ else if (state === 'pending') waiting.push(`${context} (pending)`)
3392
+ else if (state === 'success') passed += 1
3393
+ }
3394
+
3395
+ const at = `HEAD (${sha.slice(0, 8)})`
3396
+ if (red.length) {
3397
+ fail(`requireGreen: ${at} is not green — ${red.join(', ')}`)
3398
+ } else if (waiting.length) {
3399
+ fail(
3400
+ `requireGreen: checks on ${at} have not finished — ${waiting.join(', ')}.\n` +
3401
+ ' Wait for them to complete, then re-run.',
3402
+ )
3403
+ } else if (!passed) {
3404
+ fail(
3405
+ `requireGreen: no check has passed on ${at} — nothing has verified it.\n` +
3406
+ ' Push it and wait for CI, or turn requireGreen off for a repository without CI.',
3407
+ )
3408
+ } else {
3409
+ ok(`${at} is green (${passed} check${passed === 1 ? '' : 's'} passed)`)
3410
+ }
3411
+ }
3412
+
3413
+ // Opt-in: the commit being released has to be one CI has seen and passed. That means it is
3414
+ // on the remote — a local commit has no checks to read — and that what the commit step is
3415
+ // about to add is not part of the release, since CI never saw that either.
3416
+ if (config.requireGreen) {
3417
+ const upstream = branch && !detached ? `${config.remote}/${branch}` : null
3418
+ const sha = tryRead('git', ['rev-parse', 'HEAD'])
3419
+ const onRemote =
3420
+ upstream && succeeds('git', ['rev-parse', '--verify', '--quiet', `refs/remotes/${upstream}`])
3421
+ const ahead = onRemote ? tryRead('git', ['rev-list', '--count', `${upstream}..HEAD`]) : null
3422
+ if (dirty && runs('commit')) {
3423
+ fail(
3424
+ 'requireGreen: the working tree would be committed and released without CI having ' +
3425
+ 'run on it.\n Commit and push it, wait for the checks, then release.',
3426
+ )
3427
+ }
3428
+ if (!succeeds('gh', ['--version']) || !succeeds('gh', ['auth', 'status'])) {
3429
+ fail('requireGreen reads the checks with `gh`, which is not installed or not authenticated')
3430
+ } else if (!onRemote) {
3431
+ fail(
3432
+ `requireGreen: ${upstream ?? 'this branch'} does not exist on ${config.remote}, so no ` +
3433
+ 'check has run on HEAD. Push it and wait for CI.',
3434
+ )
3435
+ } else if (ahead !== '0') {
3436
+ fail(
3437
+ `requireGreen: HEAD is ${ahead ?? 'an unknown number of'} commit(s) ahead of ` +
3438
+ `${upstream}, so CI has not seen it. Push it and wait for the checks.`,
3439
+ )
3440
+ } else if (sha) {
3441
+ checkHeadIsGreen(sha)
3442
+ }
3443
+ }
3444
+
3011
3445
  // If the previous release tag is reachable from HEAD, a shallow clone hides nothing the
3012
3446
  // release reads — notes and `auto` see the whole span. No reachable tag means the history
3013
3447
  // is provably truncated: `auto` would infer the bump from a fraction of the commits, so
@@ -3127,10 +3561,32 @@ if (!runs('release')) {
3127
3561
  */
3128
3562
  const OIDC_CLIS = new Set([...NPM_CLIS, 'uv'])
3129
3563
 
3564
+ /**
3565
+ * The oldest npm CLI that publishes over OIDC. An older one finds no token and fails the
3566
+ * publish — after the tag and the push, since nothing before that step asks it anything.
3567
+ * https://docs.npmjs.com/trusted-publishers states the minimum; pnpm and bun implement the
3568
+ * exchange themselves and document no equivalent, so only npm is checked.
3569
+ */
3570
+ const NPM_OIDC_MIN = '11.5.1'
3571
+
3130
3572
  /** Whether one publish CLI can publish at all: OIDC, a token, or a live session. */
3131
3573
  function checkCredentials({ cli, registry, command }) {
3132
3574
  if (isTrustedPublishing && OIDC_CLIS.has(cli)) {
3133
3575
  ok(`${cli}: trusted publishing (OIDC) — no token needed`)
3576
+ if (cli === 'npm') {
3577
+ const npmVersion = tryRead('npm', ['--version'])
3578
+ if (!npmVersion || !parseVersion(npmVersion)) {
3579
+ warn(
3580
+ `npm: could not read its version — trusted publishing needs npm ${NPM_OIDC_MIN} or later`,
3581
+ )
3582
+ } else if (compareVersions(npmVersion, NPM_OIDC_MIN) < 0) {
3583
+ fail(
3584
+ `npm ${npmVersion} cannot publish with trusted publishing, which needs npm ` +
3585
+ `${NPM_OIDC_MIN} or later — the publish would fail after the tag and push.\n` +
3586
+ ' Update it before releasing: npm install -g npm@latest',
3587
+ )
3588
+ }
3589
+ }
3134
3590
  // Provenance is the other half of what OIDC makes possible: a signed attestation
3135
3591
  // tying the published artefact to the workflow and commit that produced it. It is
3136
3592
  // not added to the command here — npm generates it for a trusted publish on its own,
@@ -3169,6 +3625,75 @@ function checkCredentials({ cli, registry, command }) {
3169
3625
  }
3170
3626
  }
3171
3627
 
3628
+ /**
3629
+ * Package what `cargo publish` will upload, before the tag and the push rather than after.
3630
+ *
3631
+ * `cargo publish` packages and verifies as its first act, so a file left out by `include`
3632
+ * or `exclude`, a manifest crates.io rejects, a crate that does not build from its own
3633
+ * archive, or one over the upload limit is discovered only once the release is tagged and
3634
+ * pushed. This runs the same packaging now. It builds the crate, as publishing does, so it
3635
+ * costs a compile.
3636
+ *
3637
+ * It packages the tree as it is — the version is not bumped yet. The version number is the
3638
+ * one thing that differs from what will be uploaded, and it changes neither the file list,
3639
+ * the metadata, nor the size.
3640
+ */
3641
+ function checkCratePackage(target) {
3642
+ const args = cargoPackageArgs(target.command, {
3643
+ dirty: !!dirty,
3644
+ lockfile: existsSync('Cargo.lock'),
3645
+ })
3646
+ if (!args) {
3647
+ note(`cargo: \`${target.command}\` is not a plain cargo publish — not packaging it first`)
3648
+ return
3649
+ }
3650
+ const started = Date.now()
3651
+ try {
3652
+ // A verify build prints every crate it compiles; Node's 1 MiB default would overflow.
3653
+ execFileSync('cargo', args, { stdio: 'pipe', encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 })
3654
+ } catch (err) {
3655
+ const tail = `${err.stdout ?? ''}${err.stderr ?? ''}`.trim().split('\n').slice(-12).join('\n')
3656
+ fail(`\`cargo ${args.join(' ')}\` failed — \`${target.command}\` would too:\n${indent(tail)}`)
3657
+ return
3658
+ }
3659
+ const metadata = tryRead('cargo', ['metadata', '--no-deps', '--format-version', '1'])
3660
+ let targetDir = null
3661
+ try {
3662
+ targetDir = metadata ? JSON.parse(metadata).target_directory : null
3663
+ } catch {
3664
+ targetDir = null
3665
+ }
3666
+ const packageDir = targetDir ? join(targetDir, 'package') : null
3667
+ // Only archives this run wrote: target/package keeps every earlier version too.
3668
+ const crates =
3669
+ packageDir && existsSync(packageDir)
3670
+ ? readdirSync(packageDir)
3671
+ .filter((name) => name.endsWith('.crate'))
3672
+ .map((name) => {
3673
+ const { size, mtimeMs } = statSync(join(packageDir, name))
3674
+ return { name, size, mtimeMs }
3675
+ })
3676
+ .filter(({ mtimeMs }) => mtimeMs >= started - 1000)
3677
+ : []
3678
+ if (!crates.length) {
3679
+ warn('cargo package passed, but no .crate it wrote was found to check against the size limit')
3680
+ return
3681
+ }
3682
+ const limitMb = (CRATES_IO_MAX_BYTES / 1024 / 1024).toFixed(0)
3683
+ for (const { name, size } of crates) {
3684
+ const mb = (size / 1024 / 1024).toFixed(1)
3685
+ if (size > CRATES_IO_MAX_BYTES) {
3686
+ fail(
3687
+ `${name} is ${mb} MiB, over crates.io's ${limitMb} MiB upload limit.\n` +
3688
+ ' Trim it with `include`/`exclude` in Cargo.toml (`cargo package --list` shows ' +
3689
+ 'what goes in), or ask crates.io to raise the limit for this crate.',
3690
+ )
3691
+ } else {
3692
+ ok(`cargo package: ${name} (${mb} MiB)`)
3693
+ }
3694
+ }
3695
+ }
3696
+
3172
3697
  /** Commands whose version is already on the registry, so the publish step skips them. */
3173
3698
  const alreadyPublished = new Set()
3174
3699
  if (!publishTargets.length) {
@@ -3192,6 +3717,7 @@ if (!publishTargets.length) {
3192
3717
  alreadyPublished.add(target.command)
3193
3718
  note(`${target.name}@${version} is already published — will skip \`${target.command}\``)
3194
3719
  }
3720
+ if (target.cli === 'cargo' && !alreadyPublished.has(target.command)) checkCratePackage(target)
3195
3721
  }
3196
3722
  }
3197
3723
 
@@ -3244,6 +3770,18 @@ const notesDeferred = !!(dirty && runs('commit'))
3244
3770
  */
3245
3771
  let notesPending = false
3246
3772
 
3773
+ /**
3774
+ * Entries the assistant flagged as unsure in the notes it drafted — see `uncertainEntries`.
3775
+ * Only a draft can carry them: hand-written and commit-derived notes are someone's words.
3776
+ */
3777
+ let unsure = []
3778
+
3779
+ /**
3780
+ * Whether a person answers the confirmation prompt. Under --yes, or with no terminal to ask
3781
+ * on, nobody reviews anything before it is tagged and published.
3782
+ */
3783
+ const confirming = !assumeYes && !dryRun && !!process.stdin.isTTY
3784
+
3247
3785
  /**
3248
3786
  * Notes for a version, in descending order of how much they can be trusted:
3249
3787
  * an assistant's prose when one is configured, otherwise the commits grouped by
@@ -3284,11 +3822,30 @@ function draftNotesFor(v) {
3284
3822
  )
3285
3823
  }
3286
3824
  note(`drafting notes from ${subjects.length} commit(s) with ${assistantName}...`)
3825
+ const drafted = draftReleaseNotes(v, commits, lastTag, remoteLinks(config.remote))
3826
+ unsure = uncertainEntries(drafted)
3287
3827
  return (
3288
- draftReleaseNotes(v, commits, lastTag, remoteLinks(config.remote)) ??
3828
+ drafted ??
3289
3829
  changelogFromCommits(commits, remoteLinks(config.remote), config.hiddenTypes, contributors)
3290
3830
  )
3291
3831
  }
3832
+
3833
+ /**
3834
+ * The flagged entries, listed for whoever has to resolve them. Non-interactively nobody can,
3835
+ * so preflight refuses; at the prompt the person answering is the review.
3836
+ */
3837
+ const listUnsure = () => {
3838
+ const one = unsure.length === 1
3839
+ const what = `${unsure.length} drafted entr${one ? 'y' : 'ies'} ${UNSURE}`
3840
+ return `${assistantName} marked ${what} — it could not tell what ${one ? 'it means' : 'they mean'} for users:\n${indent(unsure.join('\n'))}`
3841
+ }
3842
+
3843
+ /** What to do about flagged entries when nobody is at the prompt to review them. */
3844
+ const handWritten = config.changelog
3845
+ ? `, or write the notes into [Unreleased] in ${config.changelog} with them resolved — a hand-written section wins over a draft`
3846
+ : ''
3847
+ const UNSURE_FIX = ` Re-run without --yes in a terminal to review them at the prompt${handWritten}.`
3848
+
3292
3849
  const changelogText =
3293
3850
  config.changelog && existsSync(config.changelog) ? readFileSync(config.changelog, 'utf8') : null
3294
3851
  if (changelogText) reportChangelogOrder(changelogText)
@@ -3318,18 +3875,37 @@ if (notesSource === 'github') {
3318
3875
 
3319
3876
  // Generate, either because nothing was written or because a source was named.
3320
3877
  if (!notes) {
3878
+ // Candidates' sections are not a source for the stable release: its notes come from all
3879
+ // the commits since the last stable tag. Say so while there is still time to write the
3880
+ // wording that should survive into [Unreleased] — nothing else would.
3881
+ const candidates =
3882
+ notesSource === 'auto' && changelogText ? candidateSections(changelogText, version) : []
3883
+ if (candidates.length) {
3884
+ warn(
3885
+ `${config.changelog} has sections for ${candidates.join(', ')}, but ${version} ` +
3886
+ 'has none of its own, so its notes are generated from every commit since the last ' +
3887
+ 'stable release.\n Anything edited into those sections is not carried over. ' +
3888
+ `To release with it, write the ${version} notes into [Unreleased] and re-run.`,
3889
+ )
3890
+ }
3321
3891
  if (notesDeferred) {
3322
3892
  notesPending = true
3323
3893
  ok(`release notes will be ${assistant ? 'drafted' : 'generated'} after the commit`)
3324
3894
  } else {
3325
3895
  draftedNotes = draftNotesFor(version)
3896
+ if (unsure.length) {
3897
+ if (confirming) warn(`${listUnsure()}\n They are shown again at the prompt.`)
3898
+ else fail(`${listUnsure()}\n${UNSURE_FIX}`)
3899
+ }
3326
3900
  if (draftedNotes) {
3327
3901
  notes = draftedNotes
3328
- ok(
3329
- notesSource === 'commits' || !assistant
3330
- ? 'release notes built from the commit log'
3331
- : `release notes drafted by ${assistantName}`,
3332
- )
3902
+ // An "ok" under the failure above would contradict it: these notes are refused.
3903
+ if (!unsure.length || confirming)
3904
+ ok(
3905
+ notesSource === 'commits' || !assistant
3906
+ ? 'release notes built from the commit log'
3907
+ : `release notes drafted by ${assistantName}`,
3908
+ )
3333
3909
  } else if (notesSource === 'auto') {
3334
3910
  warn(
3335
3911
  `no ${config.changelog ?? 'changelog'} section and nothing to draft from — GitHub will generate the notes`,
@@ -3346,6 +3922,16 @@ for (const asset of config.assets) {
3346
3922
  else fail(`asset ${asset} does not exist`)
3347
3923
  }
3348
3924
 
3925
+ /**
3926
+ * Whether drafted notes still have to be filed in the changelog. A named source (`commits`,
3927
+ * `assistant`) drafts even when the section exists — a resume, or one written by hand — and
3928
+ * filing it again would duplicate the heading and commit past a reused tag.
3929
+ */
3930
+ const draftedSectionMissing = () =>
3931
+ !!config.changelog &&
3932
+ existsSync(config.changelog) &&
3933
+ !hasVersionHeading(readFileSync(config.changelog, 'utf8'), version)
3934
+
3349
3935
  // Reusing a tag is the resume path, and a resume writes nothing. If this run would still
3350
3936
  // produce a commit, that commit moves HEAD past the tag and the release ends up tagged at
3351
3937
  // the wrong revision — which is silent until someone checks out the tag, and worse for the
@@ -3356,6 +3942,7 @@ const wouldCommit = [
3356
3942
  // The roll is computed whenever [Unreleased] is populated, because the notes come from
3357
3943
  // it either way; it is only written — and only becomes a commit — when the step runs.
3358
3944
  rolledChangelog && runs('changelog') && 'a changelog entry',
3945
+ draftedNotes && runs('changelog') && draftedSectionMissing() && 'a drafted changelog section',
3359
3946
  ].filter(Boolean)
3360
3947
  if (taggedCommit && runs('tag') && wouldCommit.length) {
3361
3948
  fail(
@@ -3371,7 +3958,8 @@ if (taggedCommit && runs('tag') && wouldCommit.length) {
3371
3958
  if (config.verify) {
3372
3959
  note(`running verify: ${config.verify}`)
3373
3960
  try {
3374
- execSync(config.verify, { stdio: 'pipe', encoding: 'utf8' })
3961
+ // A test suite or a build can print far more than Node's 1 MiB default capture.
3962
+ execSync(config.verify, { stdio: 'pipe', encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 })
3375
3963
  ok(`verify passed: ${config.verify}`)
3376
3964
  } catch (err) {
3377
3965
  const tail = `${err.stdout ?? ''}${err.stderr ?? ''}`.trim().split('\n').slice(-12).join('\n')
@@ -3401,7 +3989,7 @@ let commitMessage = null
3401
3989
  let didStage = false
3402
3990
  if (dirty && runs('commit') && !dryRun) {
3403
3991
  step('Stage the working tree')
3404
- mutate('git', ['add', '--all'])
3992
+ mutate('git', ['add', '--all', ...SCOPE])
3405
3993
  didStage = true
3406
3994
  commitMessage = assistant ? draftCommitMessage() : null
3407
3995
  if (!commitMessage) {
@@ -3417,19 +4005,43 @@ if (dirty && runs('commit') && !dryRun) {
3417
4005
  console.log(indent(commitMessage))
3418
4006
  }
3419
4007
 
3420
- if (!assumeYes && !dryRun && process.stdin.isTTY) {
3421
- if (notes) console.log(`\n${bold('Release notes')}\n${indent(notes)}`)
4008
+ /** Ask a yes/no question on the terminal; anything but yes is no. */
4009
+ async function ask(question) {
3422
4010
  const rl = createInterface({ input: process.stdin, output: process.stdout })
3423
4011
  let answer = ''
3424
4012
  try {
3425
- answer = await rl.question(`\nRelease ${bold(tag)} of ${projectName}? [y/N] `)
4013
+ answer = await rl.question(question)
3426
4014
  } catch {
3427
4015
  // Ctrl+C or Ctrl+D at the prompt rejects the question. That is a decline, not a
3428
4016
  // crash — without this it exits on an unhandled AbortError and a stack trace.
3429
4017
  } finally {
3430
4018
  rl.close()
3431
4019
  }
3432
- if (!/^y(es)?$/i.test(answer.trim())) {
4020
+ return /^y(es)?$/i.test(answer.trim())
4021
+ }
4022
+
4023
+ /**
4024
+ * Show the notes, with the entries the assistant was unsure of called out, and ask. A yes is
4025
+ * the review those entries were waiting for, so the marker comes off before anything is
4026
+ * written: `[???]` in a tag or on a release page helps nobody.
4027
+ */
4028
+ async function confirmRelease(question) {
4029
+ if (notes) console.log(`\n${bold('Release notes')}\n${indent(notes)}`)
4030
+ if (unsure.length) {
4031
+ console.log(`\n${yellow(bold('Review these'))} — ${listUnsure()}`)
4032
+ console.log(dim(' Answering y releases them as shown, without the marker.'))
4033
+ }
4034
+ const yes = await ask(question)
4035
+ if (yes && unsure.length) {
4036
+ notes = withoutUnsureMarkers(notes)
4037
+ if (draftedNotes) draftedNotes = withoutUnsureMarkers(draftedNotes)
4038
+ unsure = []
4039
+ }
4040
+ return yes
4041
+ }
4042
+
4043
+ if (confirming) {
4044
+ if (!(await confirmRelease(`\nRelease ${bold(tag)} of ${projectName}? [y/N] `))) {
3433
4045
  // Leave the index exactly as it was found.
3434
4046
  if (didStage) mutate('git', ['reset', '--quiet'])
3435
4047
  abort('cancelled')
@@ -3451,6 +4063,18 @@ if (dirty && runs('commit')) {
3451
4063
  if (notesPending && !dryRun) {
3452
4064
  draftedNotes = draftNotesFor(version)
3453
4065
  if (draftedNotes) notes = draftedNotes
4066
+ // These notes were drafted after the prompt, so nobody has seen them. Flagged entries
4067
+ // get the review preflight would have given them; the commit just made stays, and a
4068
+ // re-run drafts from a clean tree — before the prompt, where they can be reviewed.
4069
+ if (unsure.length) {
4070
+ const committed =
4071
+ ' The working tree is committed; nothing else has changed. Re-running drafts the ' +
4072
+ 'notes during preflight, before anything else happens.'
4073
+ if (!confirming) abort(`${listUnsure()}\n${UNSURE_FIX}\n${committed}`)
4074
+ if (!(await confirmRelease(`\nRelease ${bold(tag)} with these notes? [y/N] `))) {
4075
+ abort(`cancelled\n${committed}`)
4076
+ }
4077
+ }
3454
4078
  }
3455
4079
  }
3456
4080
 
@@ -3481,7 +4105,7 @@ if (rolledChangelog && runs('changelog')) {
3481
4105
  if (dryRun) console.log(` ${yellow('would write')} ${config.changelog}`)
3482
4106
  else writeFileSync(config.changelog, linked(rolledChangelog))
3483
4107
  staged.push(config.changelog)
3484
- } else if (runs('changelog') && draftedNotes && config.changelog && existsSync(config.changelog)) {
4108
+ } else if (runs('changelog') && draftedNotes && draftedSectionMissing()) {
3485
4109
  step(`Add the drafted ${version} section to ${config.changelog}`)
3486
4110
  if (dryRun) console.log(` ${yellow('would write')} ${config.changelog}`)
3487
4111
  else {