@entro314labs/release-kit 2.9.3 → 2.10.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.
package/release.mjs CHANGED
@@ -42,11 +42,13 @@ 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'
48
49
  import { basename, dirname, join, relative, resolve, sep } from 'node:path'
49
50
  import { createInterface } from 'node:readline/promises'
51
+ import { fileURLToPath } from 'node:url'
50
52
 
51
53
  // ─────────────────────────────────────────────────────────────────────────────
52
54
  // CONFIG
@@ -91,6 +93,9 @@ import { createInterface } from 'node:readline/promises'
91
93
  * verify string command run during preflight — a project's own gate (tests,
92
94
  * build). Non-zero aborts before anything mutates, instead of a
93
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`
94
99
  * assistant string|object drafting CLI for commit messages and notes. A key of
95
100
  * ASSISTANTS, "auto" for the first available, or null. The
96
101
  * object form { tool, model, effort } also pins which model and
@@ -115,9 +120,10 @@ import { createInterface } from 'node:readline/promises'
115
120
  const STEPS = ['commit', 'version', 'changelog', 'tag', 'push', 'publish', 'release']
116
121
 
117
122
  /**
118
- * All seven. `commit` is a conditional default: it no-ops on a clean tree, and on a dirty
119
- * tree it proceeds only when a drafting assistant is configured — otherwise preflight
120
- * still refuses the unclean tree. Opt out with `--skip commit` or a `steps` config.
123
+ * All seven. `commit` no-ops on a clean tree; on a dirty tree it stages everything and
124
+ * commits it, with a drafted message when an assistant is configured and a generated
125
+ * `chore:` message naming the files otherwise. Opt out with `--skip commit` or a `steps`
126
+ * config, which restores the refusal on a dirty tree.
121
127
  */
122
128
  const DEFAULT_STEPS = [...STEPS]
123
129
 
@@ -145,6 +151,7 @@ const DEFAULTS = {
145
151
  '^(fixup|squash)!',
146
152
  ],
147
153
  verify: null,
154
+ requireGreen: false,
148
155
  hooks: {},
149
156
  }
150
157
 
@@ -185,11 +192,13 @@ release-kit — tag, publish, and release a JS/TS/Node project.
185
192
 
186
193
  Target (optional; defaults to the version already in package.json):
187
194
  <x.y.z> release this exact version
195
+ auto infer the bump from the commits since the last tag
188
196
  patch minor major bump from the current version
189
197
  prepatch preminor premajor prerelease
190
198
  prerelease bump; needs --preid unless it can be inferred
191
199
 
192
- Steps, in the fixed order they run. All but "commit" run by default:
200
+ Steps, in the fixed order they run. All seven run by default; "commit" no-ops on a
201
+ clean tree:
193
202
  ${STEPS.join(' ')}
194
203
 
195
204
  Subcommands (they check, print or copy, and never start a release):
@@ -653,11 +662,29 @@ const CHANGELOG_SECTIONS = [
653
662
  { type: 'chore', section: 'Miscellaneous Chores' },
654
663
  ]
655
664
 
665
+ /**
666
+ * The `Notes:` trailer in a commit body: the author's own wording for the release-notes
667
+ * entry, which beats anything derived from the subject. `Notes: no-notes` keeps the commit
668
+ * out of the notes altogether — a refactor that has to be a `fix:` for the version bump but
669
+ * that no reader upgrading needs to hear about. GitHub Desktop runs its notes this way.
670
+ *
671
+ * The version bump is not affected either way: the trailer decides what is said about a
672
+ * change, not whether it happened.
673
+ *
674
+ * @returns {{text: string|null, excluded: boolean}}
675
+ */
676
+ function notesTrailer(body = '') {
677
+ const text = /^Notes:[ \t]*(\S.*)$/im.exec(body)?.[1]?.trim() ?? null
678
+ const excluded = !!text && /^no-notes$/i.test(text)
679
+ return { text: excluded ? null : text, excluded }
680
+ }
681
+
656
682
  /**
657
683
  * Parse a commit into the parts a release cares about.
658
684
  *
659
685
  * @returns {{type: string, scope: string|null, breaking: boolean, subject: string,
660
- * releaseAs: string|null} | null} null when the subject is not Conventional Commits
686
+ * releaseAs: string|null, notes: string|null, noNotes: boolean} | null} null when the
687
+ * subject is not Conventional Commits
661
688
  */
662
689
  function parseCommit(subject, body = '', hash = '') {
663
690
  // Conventional Commits does not restrict the type to letters — `i18n:` and `a11y:` are
@@ -676,6 +703,7 @@ function parseCommit(subject, body = '', hash = '') {
676
703
  // A BREAKING CHANGE footer usually explains the break far better than the subject does.
677
704
  const breakingNote =
678
705
  /^BREAKING[ -]CHANGE:\s*([\s\S]+?)(?=\n\n|$)/m.exec(body)?.[1]?.trim() ?? null
706
+ const trailer = notesTrailer(body)
679
707
  return {
680
708
  type: type.toLowerCase(),
681
709
  scope: scope ?? null,
@@ -685,6 +713,8 @@ function parseCommit(subject, body = '', hash = '') {
685
713
  releaseAs,
686
714
  closes: [...new Set(closes)],
687
715
  breakingNote,
716
+ notes: trailer.text,
717
+ noNotes: trailer.excluded,
688
718
  }
689
719
  }
690
720
 
@@ -723,7 +753,9 @@ function inferBump(commits, currentVersion, strategy = 'conventional') {
723
753
  * @returns {string | null} markdown body, or null when nothing visible changed
724
754
  */
725
755
  function changelogFromCommits(commits, links = null, hidden = [], contributors = []) {
726
- const parsed = commits.map((c) => parseCommit(c.subject, c.body, c.hash)).filter(Boolean)
756
+ const parsed = commits
757
+ .map((c) => parseCommit(c.subject, c.body, c.hash))
758
+ .filter((c) => c && !c.noNotes)
727
759
  const lines = []
728
760
 
729
761
  /** One bullet: scope, text, a link to the commit, and any issues it closes. */
@@ -741,8 +773,9 @@ function changelogFromCommits(commits, links = null, hidden = [], contributors =
741
773
  const breaking = parsed.filter((c) => c.breaking)
742
774
  if (breaking.length) {
743
775
  lines.push('### ⚠ BREAKING CHANGES', '')
744
- // The footer explains the break; the subject only says what changed.
745
- for (const c of breaking) lines.push(bullet(c, c.breakingNote ?? c.subject))
776
+ // The footer explains the break; the subject only says what changed. A `Notes:`
777
+ // trailer beats both: it is the author's wording written for exactly this list.
778
+ for (const c of breaking) lines.push(bullet(c, c.notes ?? c.breakingNote ?? c.subject))
746
779
  lines.push('')
747
780
  }
748
781
 
@@ -764,7 +797,7 @@ function changelogFromCommits(commits, links = null, hidden = [], contributors =
764
797
  )
765
798
  if (!inSection.length) continue
766
799
  lines.push(`### ${section}`, '')
767
- for (const c of inSection) lines.push(bullet(c, c.subject))
800
+ for (const c of inSection) lines.push(bullet(c, c.notes ?? c.subject))
768
801
  lines.push('')
769
802
  }
770
803
 
@@ -776,7 +809,7 @@ function changelogFromCommits(commits, links = null, hidden = [], contributors =
776
809
  )
777
810
  if (other.length) {
778
811
  lines.push('### Other Changes', '')
779
- for (const c of other) lines.push(bullet(c, c.subject))
812
+ for (const c of other) lines.push(bullet(c, c.notes ?? c.subject))
780
813
  lines.push('')
781
814
  }
782
815
 
@@ -1062,13 +1095,41 @@ function linkCitedCommits(notes, commits, links) {
1062
1095
  })
1063
1096
  }
1064
1097
 
1098
+ /**
1099
+ * The marker a drafting model puts on an entry it is not sure of — whether the change is
1100
+ * user-facing, or what it means for someone upgrading. The alternative is a confident guess
1101
+ * published as fact; a flagged entry is a question for the human cutting the release.
1102
+ */
1103
+ const UNSURE = '[???]'
1104
+
1105
+ /**
1106
+ * Drafted entries the model flagged as unsure, as the lines it wrote them on. Drafts are
1107
+ * validated, not trusted: like an invented commit hash, a flagged entry must not reach a
1108
+ * tag, a changelog or a release page without someone having looked at it.
1109
+ *
1110
+ * @returns {string[]}
1111
+ */
1112
+ function uncertainEntries(notes) {
1113
+ return (notes ?? '')
1114
+ .split('\n')
1115
+ .map((line) => line.trim())
1116
+ .filter((line) => line.includes(UNSURE))
1117
+ }
1118
+
1119
+ /** The notes with the marker removed, once a human has reviewed the flagged entries. */
1120
+ const withoutUnsureMarkers = (notes) => notes.replaceAll(`${UNSURE} `, '').replaceAll(UNSURE, '')
1121
+
1065
1122
  /**
1066
1123
  * Draft release notes from the commit log.
1067
1124
  *
1068
1125
  * @returns {string | null} markdown body (no version heading), or null
1069
1126
  */
1070
1127
  function draftReleaseNotes(version, commits, lastTag, links) {
1071
- if (!commits.length) return null
1128
+ // `Notes: no-notes` is the author saying this commit is not news; the model never sees it.
1129
+ const listed = commits
1130
+ .map((c) => ({ ...c, trailer: notesTrailer(c.body) }))
1131
+ .filter((c) => !c.trailer.excluded)
1132
+ if (!listed.length) return null
1072
1133
 
1073
1134
  const prompt = [
1074
1135
  `Write release notes for version ${version}.`,
@@ -1080,20 +1141,31 @@ function draftReleaseNotes(version, commits, lastTag, links) {
1080
1141
  '- End every bullet with the short hashes it covers, in parentheses: `(abc1234)` or',
1081
1142
  ' `(abc1234, def5678)` when merged. Copy them exactly from the list below and invent',
1082
1143
  ' nothing — a hash that is not in the list will be removed.',
1083
- '- Omit internal chores: CI, linting, formatting, dependency bumps, version bumps.',
1144
+ '- Omit internal chores: CI, linting, formatting, version bumps and dependency bumps —',
1145
+ ' except a component that ships inside the product (a bundled runtime, a sidecar',
1146
+ ' binary, an embedded engine or database). Users run that code, so its update is a',
1147
+ ' user-visible change: name the component and the version it moved to.',
1148
+ '- For bug fixes, describe what works now, not what was broken.',
1149
+ '- If you cannot tell whether a change is user-facing, or what it means for someone',
1150
+ ` upgrading, start that bullet with ${UNSURE} — a person will resolve it. Do not guess.`,
1151
+ "- A commit with a `Notes:` line carries its author's wording for the entry: use that",
1152
+ ' text as written, changing it only to fit the heading or to merge it with related work.',
1084
1153
  '- Write for someone upgrading: say what changed for them, not which files moved.',
1085
1154
  '- Plain, factual language. No hype, no emoji, no concluding summary.',
1086
1155
  '- Output only the markdown body: no version heading, no code fences, no attribution.',
1087
1156
  '- Do NOT explain your reasoning or add any commentary before or after the notes.',
1088
1157
  '',
1089
1158
  `Commits since ${lastTag ?? 'the start of the project'}:`,
1090
- ...commits.map((c) => `- ${(c.hash ?? '').slice(0, 7)} ${c.subject}`),
1159
+ ...listed.flatMap((c) => [
1160
+ `- ${(c.hash ?? '').slice(0, 7)} ${c.subject}`,
1161
+ ...(c.trailer.text ? [` Notes: ${c.trailer.text}`] : []),
1162
+ ]),
1091
1163
  ].join('\n')
1092
1164
 
1093
1165
  const drafted = runAssistant(prompt)
1094
1166
  if (!drafted) return null
1095
1167
  const cleaned = cleanNotes(drafted)
1096
- return cleaned ? linkCitedCommits(cleaned, commits, links) : null
1168
+ return cleaned ? linkCitedCommits(cleaned, listed, links) : null
1097
1169
  }
1098
1170
 
1099
1171
  /** One-line rendering of an argv, so a multi-line arg (release notes) stays readable. */
@@ -1352,7 +1424,7 @@ function distTagFor(version, explicitTag) {
1352
1424
  throw new Error(
1353
1425
  `prerelease identifier "${label}" maps to no known dist-tag ` +
1354
1426
  `(${[...KNOWN_CHANNELS].sort().join(', ')}). Publishing it as "latest" would ` +
1355
- `clobber the stable line — pass --tag <dist-tag> to choose one explicitly.`,
1427
+ `clobber the stable line — pass --dist-tag <name> to choose one explicitly.`,
1356
1428
  )
1357
1429
  }
1358
1430
 
@@ -1392,6 +1464,32 @@ function changelogOutOfOrder(text) {
1392
1464
  return versions.filter((v, i) => i > 0 && compareVersions(versions[i - 1], v) < 0)
1393
1465
  }
1394
1466
 
1467
+ /**
1468
+ * The release candidates of a stable version that the changelog has sections for: for
1469
+ * `2.0.0`, the `2.0.0-rc.1` and `2.0.0-beta.3` headings.
1470
+ *
1471
+ * A stable release reads history from the last stable tag and generates its notes from
1472
+ * those commits, so wording someone edited into a candidate's section does not carry over.
1473
+ * The sections stay in the file; this is how the release notices they exist.
1474
+ *
1475
+ * @returns {string[]} the candidate versions, in file order
1476
+ */
1477
+ function candidateSections(text, version) {
1478
+ const base = parseVersion(version)
1479
+ if (!base || base.pre.length) return []
1480
+ return [...text.matchAll(/^## \[?v?(\d+\.\d+\.\d+-[\w.]+)\]?/gm)]
1481
+ .map((m) => m[1])
1482
+ .filter((v) => {
1483
+ const parsed = parseVersion(v)
1484
+ return (
1485
+ parsed &&
1486
+ parsed.major === base.major &&
1487
+ parsed.minor === base.minor &&
1488
+ parsed.patch === base.patch
1489
+ )
1490
+ })
1491
+ }
1492
+
1395
1493
  /** The `## ` heading offsets in a changelog, in file order. */
1396
1494
  const sectionOffsets = (text) => [...text.matchAll(/^## .*$/gm)].map((m) => m.index)
1397
1495
 
@@ -1421,9 +1519,13 @@ function insertChangelogSection(text, version, date, body) {
1421
1519
  }
1422
1520
  // Falling out of the loop means every version section is newer, so the release belongs
1423
1521
  // at the foot. With nothing to compare against it belongs at the head instead: appending
1424
- // to a date-headed changelog would file the release below its oldest entry.
1425
- if (!comparable && offsets.length) {
1426
- return `${text.slice(0, offsets[0])}${entry}\n${text.slice(offsets[0])}`
1522
+ // to a date-headed changelog would file the release below its oldest entry. The head is
1523
+ // below [Unreleased], though, which stays the first section — a first release drafted into
1524
+ // a changelog holding only an empty [Unreleased] used to land above it.
1525
+ if (!comparable) {
1526
+ const unreleased = /^## \[?Unreleased\]?/i
1527
+ const first = offsets.find((offset) => !unreleased.test(text.slice(offset)))
1528
+ if (first !== undefined) return `${text.slice(0, first)}${entry}\n${text.slice(first)}`
1427
1529
  }
1428
1530
  const trimmed = text.trimEnd()
1429
1531
  return `${trimmed}\n\n${entry}`
@@ -1607,6 +1709,53 @@ function expandPaths(pattern) {
1607
1709
  return current
1608
1710
  }
1609
1711
 
1712
+ /**
1713
+ * crates.io's default ceiling on an uploaded `.crate`, in bytes: `10 * 1024 * 1024` in
1714
+ * crates.io's src/config/publish_limits.rs. The registry can raise it for one crate on
1715
+ * request, but a crate over it is otherwise refused at upload — after the tag and the push.
1716
+ */
1717
+ const CRATES_IO_MAX_BYTES = 10 * 1024 * 1024
1718
+
1719
+ /**
1720
+ * The `cargo package` argv that builds exactly what a `cargo publish` command would upload,
1721
+ * so preflight can find a missing file or an oversized archive before anything is tagged.
1722
+ *
1723
+ * Deriving it from the publish command rather than guessing keeps the selection the same:
1724
+ * `-p`, `--workspace`, `--manifest-path`, `--features` and `--no-verify` mean the same thing
1725
+ * to both subcommands, so a workspace packages each crate it will publish. Only the flags
1726
+ * `cargo package` does not take are dropped (`--dry-run`, `--token`).
1727
+ *
1728
+ * `--locked` is added when the repository has a `Cargo.lock`: publishing verifies against
1729
+ * it, and a stale one is a failure better found now. Without a lockfile it would refuse
1730
+ * outright, and plenty of libraries do not commit one. `--allow-dirty` is added only when
1731
+ * the tree is dirty — preflight reports that on its own, or the commit step is about to
1732
+ * make it clean — so the package check still says something useful.
1733
+ *
1734
+ * A command that is not a plain `cargo publish …` (a pipeline, quoting, an env prefix) is
1735
+ * not taken apart: there is no reliable way to know what it uploads.
1736
+ *
1737
+ * @returns {string[] | null} args for `cargo`, or null when it cannot be derived
1738
+ */
1739
+ function cargoPackageArgs(command, { dirty = false, lockfile = false } = {}) {
1740
+ if (!/^cargo\s+publish(?:\s|$)/.test(command.trim()) || /[;&|<>`$()'"\\]/.test(command)) {
1741
+ return null
1742
+ }
1743
+ const words = command.trim().split(/\s+/).slice(2)
1744
+ const args = ['package']
1745
+ for (let i = 0; i < words.length; i += 1) {
1746
+ const word = words[i]
1747
+ if (word === '--dry-run' || word === '-n' || word.startsWith('--token=')) continue
1748
+ if (word === '--token') {
1749
+ i += 1
1750
+ continue
1751
+ }
1752
+ args.push(word)
1753
+ }
1754
+ if (lockfile && !args.includes('--locked')) args.push('--locked')
1755
+ if (dirty && !args.includes('--allow-dirty')) args.push('--allow-dirty')
1756
+ return args
1757
+ }
1758
+
1610
1759
  /**
1611
1760
  * The crates in a Cargo workspace whose version this bump owns: the members that inherit
1612
1761
  * it with `version.workspace = true`, which is how a workspace keeps its crates in step.
@@ -1672,7 +1821,18 @@ function patternFor({ path, pattern, all = false }) {
1672
1821
  /** @returns {string | null} the version recorded in a source file */
1673
1822
  function readVersionFrom(entry) {
1674
1823
  const source = versionSource(entry)
1675
- const text = readFileSync(source.path, 'utf8')
1824
+ return versionInText(source, readFileSync(source.path, 'utf8'))
1825
+ }
1826
+
1827
+ /**
1828
+ * The version a source file's text carries, resolved the same way it will be written.
1829
+ *
1830
+ * Split from `readVersionFrom` so the text can come from somewhere other than the working
1831
+ * tree — `git show HEAD:<path>` — and still be read by the resolver the write uses.
1832
+ *
1833
+ * @returns {string | null}
1834
+ */
1835
+ function versionInText(source, text) {
1676
1836
  const { kind, shape } = versionMode(source, text)
1677
1837
  if (kind === 'bare') return text.trim() || null
1678
1838
  if (kind === 'markers') {
@@ -1895,7 +2055,11 @@ if (flag('--help') || flag('-h')) {
1895
2055
 
1896
2056
  // --sync copies this file into other projects and exits; it touches no git state.
1897
2057
  if (flag('--sync')) {
1898
- const self = new URL(import.meta.url).pathname
2058
+ // A URL's pathname is percent-encoded and keeps the leading slash before a Windows
2059
+ // drive letter, so a script installed under a directory with a space in its name — or
2060
+ // anywhere on Windows — was reported as "piped from stdin". fileURLToPath is the
2061
+ // inverse of what Node did to build import.meta.url.
2062
+ const self = fileURLToPath(import.meta.url)
1899
2063
  // Piped from stdin (`curl … | node -`) there is no file to copy: import.meta.url points
1900
2064
  // at a synthetic [eval] path. Say so instead of failing on a missing file.
1901
2065
  if (!existsSync(self)) {
@@ -2022,6 +2186,9 @@ const VALUE_OPTIONS = new Set([
2022
2186
  '--assistant-effort',
2023
2187
  ])
2024
2188
 
2189
+ /** Flags that take no value. With VALUE_OPTIONS, the whole vocabulary this file accepts. */
2190
+ const BOOLEAN_FLAGS = new Set(['--dry-run', '--yes', '-y', '--commit', '--help', '-h', '--sync'])
2191
+
2025
2192
  /** The version or bump target: the only argument that is neither a flag nor a flag's value. */
2026
2193
  const positionals = []
2027
2194
  for (let i = 0; i < argv.length; i += 1) {
@@ -2029,7 +2196,20 @@ for (let i = 0; i < argv.length; i += 1) {
2029
2196
  i += 1
2030
2197
  continue
2031
2198
  }
2032
- if (!argv[i].startsWith('-')) positionals.push(argv[i])
2199
+ if (!argv[i].startsWith('-')) {
2200
+ positionals.push(argv[i])
2201
+ continue
2202
+ }
2203
+ // An unknown flag used to be dropped without a word, and `--auto` is the one people
2204
+ // reach for: it released whatever version package.json already carried, which is a
2205
+ // different release from the `auto` they asked for.
2206
+ if (!BOOLEAN_FLAGS.has(argv[i])) {
2207
+ const bare = argv[i].replace(/^-+/, '')
2208
+ const hint = BUMPS.has(bare)
2209
+ ? `\n The target is positional: ${INVOCATION} ${bare}, not ${argv[i]}`
2210
+ : ''
2211
+ abort(`unknown flag: ${argv[i]}${hint}`)
2212
+ }
2033
2213
  }
2034
2214
  const target = positionals[0]
2035
2215
  // A second positional is always a mistake, and silently ignoring it changes the release
@@ -2131,6 +2311,10 @@ const ECOSYSTEM_MANIFESTS = ['package.json', 'pyproject.toml', 'Cargo.toml']
2131
2311
  */
2132
2312
  function detectCompanionFiles(primaryPath, primaryVersion) {
2133
2313
  const companions = []
2314
+ // The lockfile pins the crate's own version, so a bump leaves it stale — whether
2315
+ // Cargo.toml is the version source or a manifest kept in step with one. A plain crate was
2316
+ // the case missed: `cargo publish` then refused the dirty lockfile, after the push.
2317
+ if (primaryPath === 'Cargo.toml' && existsSync('Cargo.lock')) companions.push('Cargo.lock')
2134
2318
  for (const candidate of ECOSYSTEM_MANIFESTS) {
2135
2319
  if (candidate === primaryPath || !existsSync(candidate)) continue
2136
2320
  const found = readVersionFrom({ path: candidate })
@@ -2552,6 +2736,23 @@ let autoBump = null
2552
2736
  /** The tag of a previous release this run is finishing rather than starting. */
2553
2737
  let resuming = null
2554
2738
 
2739
+ /**
2740
+ * A release tagged at HEAD that never reached the registry, found while resolving a
2741
+ * relative bump. `auto` finishes such a release; a `patch`/`minor`/`major` cannot — it
2742
+ * would bump past it, tag a second version on the same commit, and leave the first one
2743
+ * unpublished for good. Preflight refuses it and names the command that finishes it.
2744
+ */
2745
+ let unfinishedAtHead = null
2746
+
2747
+ /**
2748
+ * A release that died after the tag and before the publish is finished by re-running the
2749
+ * same command — but only while nothing new has happened. A commit or a working tree that
2750
+ * `--commit` is about to turn into one moves HEAD past the tag, and publishing then would
2751
+ * ship a tree the tag does not describe; that work belongs in the next version, which is
2752
+ * what the shipped-tag baseline makes sure it is released as.
2753
+ */
2754
+ const wouldCommitMore = !!tryRead('git', ['status', '--porcelain']) && runs('commit')
2755
+
2555
2756
  let version
2556
2757
  if (!target) {
2557
2758
  if (!currentVersion) {
@@ -2568,12 +2769,6 @@ if (!target) {
2568
2769
  'from.\n Pass the first version explicitly: release-kit 0.1.0',
2569
2770
  )
2570
2771
  }
2571
- // A release that died after the tag and before the publish is finished by re-running the
2572
- // same command — but only while nothing new has happened. A commit or a working tree that
2573
- // `--commit` is about to turn into one moves HEAD past the tag, and publishing then would
2574
- // ship a tree the tag does not describe; that work belongs in the next version, which is
2575
- // what the baseline below makes sure it is released as.
2576
- const wouldCommitMore = !!tryRead('git', ['status', '--porcelain']) && runs('commit')
2577
2772
  const pending = wouldCommitMore ? null : unfinishedRelease()
2578
2773
  if (pending) {
2579
2774
  ;({ name: resuming, version } = pending)
@@ -2611,6 +2806,10 @@ if (!target) {
2611
2806
  `a ${target} bump from a stable version needs --preid <${[...KNOWN_CHANNELS].sort().join('|')}>`,
2612
2807
  )
2613
2808
  }
2809
+ // The same question `auto` asks, with the opposite answer: `auto` finishes the release
2810
+ // it finds at HEAD, a named bump would skip past it. Recorded here, refused in preflight
2811
+ // with the rest.
2812
+ unfinishedAtHead = wouldCommitMore ? null : unfinishedRelease()
2614
2813
  version = incrementVersion(currentVersion, target, preid)
2615
2814
  } else if (parseVersion(target)) {
2616
2815
  version = target
@@ -2749,6 +2948,33 @@ if (resuming) {
2749
2948
  ok(`finishing ${resuming}: it was tagged and pushed, but never reached the registry`)
2750
2949
  }
2751
2950
 
2951
+ if (unfinishedAtHead) {
2952
+ fail(
2953
+ `${unfinishedAtHead.name} is tagged at HEAD but never reached the registry, and a ` +
2954
+ `${target} bump would release ${version} from the same commit and leave it that way.\n` +
2955
+ ` Finish it instead: re-run with no target, or with auto.`,
2956
+ )
2957
+ }
2958
+
2959
+ // A run that wrote the version and died before its release commit — a failing lockfile
2960
+ // refresh, an afterVersion hook, a commit hook — leaves the bump on disk and nowhere
2961
+ // else. The current version is then read from that file, and a relative bump counts from
2962
+ // it: `minor` after a dead `minor` released 1.2.0 with 1.1.0 tagged nowhere, its changelog
2963
+ // section documenting a version that never existed. Only a bump is affected; an explicit
2964
+ // version, or none, releases what is on disk and the commit step carries it.
2965
+ if (target && BUMPS.has(target) && versionFile && currentVersion) {
2966
+ const committed = tryRead('git', ['show', `HEAD:${versionFile.path}`])
2967
+ const headVersion = committed === null ? null : versionInText(versionFile, committed)
2968
+ if (headVersion && headVersion !== currentVersion) {
2969
+ fail(
2970
+ `${versionFile.path} says ${currentVersion} on disk but ${headVersion} at HEAD — an ` +
2971
+ `uncommitted version bump, which a ${target} bump would count from and skip past.\n` +
2972
+ ` Finish it with \`${INVOCATION} ${currentVersion}\`, or restore the file: ` +
2973
+ `git restore ${versionFile.path}`,
2974
+ )
2975
+ }
2976
+ }
2977
+
2752
2978
  // A previous release that never shipped is not history — its commits are still owed to
2753
2979
  // whoever installs this package, and they are in this release's range because of it. Say
2754
2980
  // so: the changelog keeps the section that was written for that version, and a section
@@ -2756,7 +2982,9 @@ if (resuming) {
2756
2982
  const absorbed = absorbedReleaseTags({ stable: !isPrerelease }).filter(
2757
2983
  (entry) => entry.version !== version,
2758
2984
  )
2759
- if (absorbed.length) {
2985
+ // Not when the run is being refused for exactly this: saying the commits ship in a
2986
+ // version that will not be released contradicts the failure above it.
2987
+ if (absorbed.length && !unfinishedAtHead) {
2760
2988
  const names = absorbed.map((entry) => entry.name).join(', ')
2761
2989
  const many = absorbed.length > 1
2762
2990
  const existingChangelog =
@@ -2789,13 +3017,57 @@ if (bumping) {
2789
3017
  }
2790
3018
  if (source.optional) continue
2791
3019
  const text = readFileSync(source.path, 'utf8')
2792
- const { kind, shape } = versionMode(source, text)
3020
+ // Resolving the mode can itself refuse — a Cargo.lock with nothing beside it to say
3021
+ // which crate is yours — and the write step must not be where that is discovered.
3022
+ let mode
3023
+ try {
3024
+ mode = versionMode(source, text)
3025
+ } catch (err) {
3026
+ fail(err.message)
3027
+ continue
3028
+ }
3029
+ const { kind, shape } = mode
2793
3030
  if (kind === 'pattern' && !shape.test(text)) {
2794
3031
  fail(
2795
3032
  `${source.path} has no version for release-kit to replace.\n` +
2796
3033
  ' Remove it from versionFiles, mark the line with x-release-kit-version, ' +
2797
3034
  'or give the entry a "pattern".',
2798
3035
  )
3036
+ } else if (kind === 'bare' && text.trim() && !parseVersion(text.trim())) {
3037
+ // The whole-file mode is right for a VERSION file and catastrophic for anything
3038
+ // else. `writeVersionInto` refuses it too, but by then the files before it in the
3039
+ // list have already changed.
3040
+ fail(
3041
+ `${source.path} is not a file containing only a version, and carries no ` +
3042
+ 'x-release-kit-version marker.\n Writing the version into it would replace ' +
3043
+ 'everything else in it. Mark the line that holds the version, or give the entry ' +
3044
+ 'a "pattern".',
3045
+ )
3046
+ }
3047
+ }
3048
+ }
3049
+
3050
+ // A configured list is never extended, so a Cargo.toml written without the Cargo.lock beside
3051
+ // it leaves the lockfile on the old version. Publishing is where that breaks: `cargo publish`
3052
+ // refuses a dirty tree, after the tag and the push. Say so before either.
3053
+ if (bumping && publishTargets.some((target) => target.cli === 'cargo')) {
3054
+ const written = new Set(versionTargets.map((source) => source.path))
3055
+ for (const source of versionTargets) {
3056
+ if (basename(source.path) !== 'Cargo.toml') continue
3057
+ const lockPath = join(dirname(source.path), 'Cargo.lock')
3058
+ if (written.has(lockPath) || !existsSync(lockPath)) continue
3059
+ let recorded = null
3060
+ try {
3061
+ recorded = readVersionFrom({ path: lockPath })
3062
+ } catch {
3063
+ // A lockfile with nothing beside it to scope by: not this check's question.
3064
+ }
3065
+ if (recorded && recorded !== version) {
3066
+ fail(
3067
+ `${lockPath} records ${readNameFrom(source) ?? 'the crate'} ${recorded}, and nothing ` +
3068
+ `will bump it — \`cargo publish\` would refuse the stale lockfile after the push.\n` +
3069
+ ` Add "${lockPath}" to versionFiles in release.config.json.`,
3070
+ )
2799
3071
  }
2800
3072
  }
2801
3073
  }
@@ -2807,6 +3079,17 @@ if (bumping && currentVersion && compareVersions(version, currentVersion) <= 0)
2807
3079
  } else if (bumping) {
2808
3080
  // No manifest and no tag to read a version from, but files to write one into.
2809
3081
  ok(`writing ${version} into ${versionTargets.map((source) => source.path).join(', ')}`)
3082
+ } else if (versionTargets.length && version !== currentVersion) {
3083
+ // The version step is off and the target is not what the files say. The tag would name
3084
+ // one version and the manifest — which is what `npm publish` sends and what the build
3085
+ // compiles in — another. There is no release in which those two are allowed to differ.
3086
+ const files = versionTargets.map((source) => source.path).join(', ')
3087
+ fail(
3088
+ `the version step is not selected, but ${version} is not the version in ${files}` +
3089
+ `${currentVersion ? ` (${currentVersion})` : ''}.\n` +
3090
+ ' The tag would say one version and the files another. Add "version" to the ' +
3091
+ 'steps, or release the version already there.',
3092
+ )
2810
3093
  } else if (versionFile) {
2811
3094
  ok(`releasing the version already in ${versionFile.path} (${version})`)
2812
3095
  } else {
@@ -2899,6 +3182,123 @@ if (!succeeds('git', ['remote', 'get-url', config.remote])) {
2899
3182
  }
2900
3183
  }
2901
3184
 
3185
+ /**
3186
+ * Check run conclusions that mean the commit is not fit to release. `stale` is GitHub giving
3187
+ * up on a run that never finished, which is not a pass either.
3188
+ */
3189
+ const RED_CONCLUSIONS = new Set(['failure', 'cancelled', 'timed_out', 'action_required', 'stale'])
3190
+
3191
+ /**
3192
+ * Whether GitHub reports the commit being released as green — the gate `requireGreen` opts
3193
+ * into, so a release cannot be cut from a commit CI failed on or has not finished with.
3194
+ *
3195
+ * Both of GitHub's check systems are read. Check runs are what Actions and GitHub Apps
3196
+ * report; commit statuses are the older API that external CI (Buildkite, Jenkins, older
3197
+ * integrations) still posts to. Reading only one would call a commit green while the other
3198
+ * system has it red. The combined status's own `state` is not used: it says `pending` for a
3199
+ * commit with no statuses at all, so the individual statuses are what count.
3200
+ *
3201
+ * Only `success` counts as a pass. `neutral` and `skipped` are not failures, but a commit
3202
+ * whose every check was skipped has not been verified by anything, and zero checks is the
3203
+ * same answer — both are refused rather than read as green.
3204
+ *
3205
+ * When this runs inside GitHub Actions, the job running it is itself an unfinished check run
3206
+ * on the same commit — the `workflow_run` recipe in the README releases exactly the commit
3207
+ * it runs on — and waiting for it would wait forever. Check runs belonging to the current
3208
+ * workflow run are therefore skipped; jobs in the same workflow are what `needs:` orders.
3209
+ *
3210
+ * `{owner}/{repo}` is filled in by gh from the checkout, the same way `gh release create`
3211
+ * resolves the repository the release step publishes to.
3212
+ */
3213
+ function checkHeadIsGreen(sha) {
3214
+ const api = (path, jq) => tryRead('gh', ['api', '--paginate', path, '--jq', jq])
3215
+ const checkRuns = api(
3216
+ `repos/{owner}/{repo}/commits/${sha}/check-runs?per_page=100`,
3217
+ '.check_runs[] | [.name, .status, (.conclusion // ""), (.details_url // "")] | @tsv',
3218
+ )
3219
+ const statuses = api(
3220
+ `repos/{owner}/{repo}/commits/${sha}/status?per_page=100`,
3221
+ '.statuses[] | [.context, .state] | @tsv',
3222
+ )
3223
+ if (checkRuns === null || statuses === null) {
3224
+ fail(`requireGreen: could not read the checks on ${sha.slice(0, 8)} from GitHub (gh api)`)
3225
+ return
3226
+ }
3227
+ const ownRun =
3228
+ process.env.GITHUB_ACTIONS === 'true' && process.env.GITHUB_RUN_ID
3229
+ ? `/actions/runs/${process.env.GITHUB_RUN_ID}/`
3230
+ : null
3231
+ const rows = (text) =>
3232
+ text
3233
+ .split('\n')
3234
+ .filter((line) => line.trim())
3235
+ .map((line) => line.split('\t'))
3236
+
3237
+ const red = []
3238
+ const waiting = []
3239
+ let passed = 0
3240
+ for (const [name, status, conclusion, url = ''] of rows(checkRuns)) {
3241
+ if (ownRun && url.includes(ownRun)) continue
3242
+ if (status !== 'completed') waiting.push(`${name} (${status})`)
3243
+ else if (RED_CONCLUSIONS.has(conclusion)) red.push(`${name} (${conclusion})`)
3244
+ else if (conclusion === 'success') passed += 1
3245
+ }
3246
+ for (const [context, state] of rows(statuses)) {
3247
+ if (state === 'failure' || state === 'error') red.push(`${context} (${state})`)
3248
+ else if (state === 'pending') waiting.push(`${context} (pending)`)
3249
+ else if (state === 'success') passed += 1
3250
+ }
3251
+
3252
+ const at = `HEAD (${sha.slice(0, 8)})`
3253
+ if (red.length) {
3254
+ fail(`requireGreen: ${at} is not green — ${red.join(', ')}`)
3255
+ } else if (waiting.length) {
3256
+ fail(
3257
+ `requireGreen: checks on ${at} have not finished — ${waiting.join(', ')}.\n` +
3258
+ ' Wait for them to complete, then re-run.',
3259
+ )
3260
+ } else if (!passed) {
3261
+ fail(
3262
+ `requireGreen: no check has passed on ${at} — nothing has verified it.\n` +
3263
+ ' Push it and wait for CI, or turn requireGreen off for a repository without CI.',
3264
+ )
3265
+ } else {
3266
+ ok(`${at} is green (${passed} check${passed === 1 ? '' : 's'} passed)`)
3267
+ }
3268
+ }
3269
+
3270
+ // Opt-in: the commit being released has to be one CI has seen and passed. That means it is
3271
+ // on the remote — a local commit has no checks to read — and that what the commit step is
3272
+ // about to add is not part of the release, since CI never saw that either.
3273
+ if (config.requireGreen) {
3274
+ const upstream = branch && !detached ? `${config.remote}/${branch}` : null
3275
+ const sha = tryRead('git', ['rev-parse', 'HEAD'])
3276
+ const onRemote =
3277
+ upstream && succeeds('git', ['rev-parse', '--verify', '--quiet', `refs/remotes/${upstream}`])
3278
+ const ahead = onRemote ? tryRead('git', ['rev-list', '--count', `${upstream}..HEAD`]) : null
3279
+ if (dirty && runs('commit')) {
3280
+ fail(
3281
+ 'requireGreen: the working tree would be committed and released without CI having ' +
3282
+ 'run on it.\n Commit and push it, wait for the checks, then release.',
3283
+ )
3284
+ }
3285
+ if (!succeeds('gh', ['--version']) || !succeeds('gh', ['auth', 'status'])) {
3286
+ fail('requireGreen reads the checks with `gh`, which is not installed or not authenticated')
3287
+ } else if (!onRemote) {
3288
+ fail(
3289
+ `requireGreen: ${upstream ?? 'this branch'} does not exist on ${config.remote}, so no ` +
3290
+ 'check has run on HEAD. Push it and wait for CI.',
3291
+ )
3292
+ } else if (ahead !== '0') {
3293
+ fail(
3294
+ `requireGreen: HEAD is ${ahead ?? 'an unknown number of'} commit(s) ahead of ` +
3295
+ `${upstream}, so CI has not seen it. Push it and wait for the checks.`,
3296
+ )
3297
+ } else if (sha) {
3298
+ checkHeadIsGreen(sha)
3299
+ }
3300
+ }
3301
+
2902
3302
  // If the previous release tag is reachable from HEAD, a shallow clone hides nothing the
2903
3303
  // release reads — notes and `auto` see the whole span. No reachable tag means the history
2904
3304
  // is provably truncated: `auto` would infer the bump from a fraction of the commits, so
@@ -3010,10 +3410,40 @@ if (!runs('release')) {
3010
3410
  if (releaseExists) note(`a GitHub release for ${tag} already exists — will skip that step`)
3011
3411
  }
3012
3412
 
3413
+ /**
3414
+ * CLIs that publish over OIDC with no token of their own once the CI job can mint one:
3415
+ * npm, pnpm and bun exchange it with the registry themselves, and so does `uv publish`.
3416
+ * cargo does not — crates.io's trusted publishing goes through an action that turns the
3417
+ * OIDC token into a `CARGO_REGISTRY_TOKEN`, so for cargo the token check still stands.
3418
+ */
3419
+ const OIDC_CLIS = new Set([...NPM_CLIS, 'uv'])
3420
+
3421
+ /**
3422
+ * The oldest npm CLI that publishes over OIDC. An older one finds no token and fails the
3423
+ * publish — after the tag and the push, since nothing before that step asks it anything.
3424
+ * https://docs.npmjs.com/trusted-publishers states the minimum; pnpm and bun implement the
3425
+ * exchange themselves and document no equivalent, so only npm is checked.
3426
+ */
3427
+ const NPM_OIDC_MIN = '11.5.1'
3428
+
3013
3429
  /** Whether one publish CLI can publish at all: OIDC, a token, or a live session. */
3014
3430
  function checkCredentials({ cli, registry, command }) {
3015
- if (isTrustedPublishing) {
3431
+ if (isTrustedPublishing && OIDC_CLIS.has(cli)) {
3016
3432
  ok(`${cli}: trusted publishing (OIDC) — no token needed`)
3433
+ if (cli === 'npm') {
3434
+ const npmVersion = tryRead('npm', ['--version'])
3435
+ if (!npmVersion || !parseVersion(npmVersion)) {
3436
+ warn(
3437
+ `npm: could not read its version — trusted publishing needs npm ${NPM_OIDC_MIN} or later`,
3438
+ )
3439
+ } else if (compareVersions(npmVersion, NPM_OIDC_MIN) < 0) {
3440
+ fail(
3441
+ `npm ${npmVersion} cannot publish with trusted publishing, which needs npm ` +
3442
+ `${NPM_OIDC_MIN} or later — the publish would fail after the tag and push.\n` +
3443
+ ' Update it before releasing: npm install -g npm@latest',
3444
+ )
3445
+ }
3446
+ }
3017
3447
  // Provenance is the other half of what OIDC makes possible: a signed attestation
3018
3448
  // tying the published artefact to the workflow and commit that produced it. It is
3019
3449
  // not added to the command here — npm generates it for a trusted publish on its own,
@@ -3052,6 +3482,75 @@ function checkCredentials({ cli, registry, command }) {
3052
3482
  }
3053
3483
  }
3054
3484
 
3485
+ /**
3486
+ * Package what `cargo publish` will upload, before the tag and the push rather than after.
3487
+ *
3488
+ * `cargo publish` packages and verifies as its first act, so a file left out by `include`
3489
+ * or `exclude`, a manifest crates.io rejects, a crate that does not build from its own
3490
+ * archive, or one over the upload limit is discovered only once the release is tagged and
3491
+ * pushed. This runs the same packaging now. It builds the crate, as publishing does, so it
3492
+ * costs a compile.
3493
+ *
3494
+ * It packages the tree as it is — the version is not bumped yet. The version number is the
3495
+ * one thing that differs from what will be uploaded, and it changes neither the file list,
3496
+ * the metadata, nor the size.
3497
+ */
3498
+ function checkCratePackage(target) {
3499
+ const args = cargoPackageArgs(target.command, {
3500
+ dirty: !!dirty,
3501
+ lockfile: existsSync('Cargo.lock'),
3502
+ })
3503
+ if (!args) {
3504
+ note(`cargo: \`${target.command}\` is not a plain cargo publish — not packaging it first`)
3505
+ return
3506
+ }
3507
+ const started = Date.now()
3508
+ try {
3509
+ // A verify build prints every crate it compiles; Node's 1 MiB default would overflow.
3510
+ execFileSync('cargo', args, { stdio: 'pipe', encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 })
3511
+ } catch (err) {
3512
+ const tail = `${err.stdout ?? ''}${err.stderr ?? ''}`.trim().split('\n').slice(-12).join('\n')
3513
+ fail(`\`cargo ${args.join(' ')}\` failed — \`${target.command}\` would too:\n${indent(tail)}`)
3514
+ return
3515
+ }
3516
+ const metadata = tryRead('cargo', ['metadata', '--no-deps', '--format-version', '1'])
3517
+ let targetDir = null
3518
+ try {
3519
+ targetDir = metadata ? JSON.parse(metadata).target_directory : null
3520
+ } catch {
3521
+ targetDir = null
3522
+ }
3523
+ const packageDir = targetDir ? join(targetDir, 'package') : null
3524
+ // Only archives this run wrote: target/package keeps every earlier version too.
3525
+ const crates =
3526
+ packageDir && existsSync(packageDir)
3527
+ ? readdirSync(packageDir)
3528
+ .filter((name) => name.endsWith('.crate'))
3529
+ .map((name) => {
3530
+ const { size, mtimeMs } = statSync(join(packageDir, name))
3531
+ return { name, size, mtimeMs }
3532
+ })
3533
+ .filter(({ mtimeMs }) => mtimeMs >= started - 1000)
3534
+ : []
3535
+ if (!crates.length) {
3536
+ warn('cargo package passed, but no .crate it wrote was found to check against the size limit')
3537
+ return
3538
+ }
3539
+ const limitMb = (CRATES_IO_MAX_BYTES / 1024 / 1024).toFixed(0)
3540
+ for (const { name, size } of crates) {
3541
+ const mb = (size / 1024 / 1024).toFixed(1)
3542
+ if (size > CRATES_IO_MAX_BYTES) {
3543
+ fail(
3544
+ `${name} is ${mb} MiB, over crates.io's ${limitMb} MiB upload limit.\n` +
3545
+ ' Trim it with `include`/`exclude` in Cargo.toml (`cargo package --list` shows ' +
3546
+ 'what goes in), or ask crates.io to raise the limit for this crate.',
3547
+ )
3548
+ } else {
3549
+ ok(`cargo package: ${name} (${mb} MiB)`)
3550
+ }
3551
+ }
3552
+ }
3553
+
3055
3554
  /** Commands whose version is already on the registry, so the publish step skips them. */
3056
3555
  const alreadyPublished = new Set()
3057
3556
  if (!publishTargets.length) {
@@ -3075,6 +3574,7 @@ if (!publishTargets.length) {
3075
3574
  alreadyPublished.add(target.command)
3076
3575
  note(`${target.name}@${version} is already published — will skip \`${target.command}\``)
3077
3576
  }
3577
+ if (target.cli === 'cargo' && !alreadyPublished.has(target.command)) checkCratePackage(target)
3078
3578
  }
3079
3579
  }
3080
3580
 
@@ -3127,6 +3627,18 @@ const notesDeferred = !!(dirty && runs('commit'))
3127
3627
  */
3128
3628
  let notesPending = false
3129
3629
 
3630
+ /**
3631
+ * Entries the assistant flagged as unsure in the notes it drafted — see `uncertainEntries`.
3632
+ * Only a draft can carry them: hand-written and commit-derived notes are someone's words.
3633
+ */
3634
+ let unsure = []
3635
+
3636
+ /**
3637
+ * Whether a person answers the confirmation prompt. Under --yes, or with no terminal to ask
3638
+ * on, nobody reviews anything before it is tagged and published.
3639
+ */
3640
+ const confirming = !assumeYes && !dryRun && !!process.stdin.isTTY
3641
+
3130
3642
  /**
3131
3643
  * Notes for a version, in descending order of how much they can be trusted:
3132
3644
  * an assistant's prose when one is configured, otherwise the commits grouped by
@@ -3167,11 +3679,30 @@ function draftNotesFor(v) {
3167
3679
  )
3168
3680
  }
3169
3681
  note(`drafting notes from ${subjects.length} commit(s) with ${assistantName}...`)
3682
+ const drafted = draftReleaseNotes(v, commits, lastTag, remoteLinks(config.remote))
3683
+ unsure = uncertainEntries(drafted)
3170
3684
  return (
3171
- draftReleaseNotes(v, commits, lastTag, remoteLinks(config.remote)) ??
3685
+ drafted ??
3172
3686
  changelogFromCommits(commits, remoteLinks(config.remote), config.hiddenTypes, contributors)
3173
3687
  )
3174
3688
  }
3689
+
3690
+ /**
3691
+ * The flagged entries, listed for whoever has to resolve them. Non-interactively nobody can,
3692
+ * so preflight refuses; at the prompt the person answering is the review.
3693
+ */
3694
+ const listUnsure = () => {
3695
+ const one = unsure.length === 1
3696
+ const what = `${unsure.length} drafted entr${one ? 'y' : 'ies'} ${UNSURE}`
3697
+ return `${assistantName} marked ${what} — it could not tell what ${one ? 'it means' : 'they mean'} for users:\n${indent(unsure.join('\n'))}`
3698
+ }
3699
+
3700
+ /** What to do about flagged entries when nobody is at the prompt to review them. */
3701
+ const handWritten = config.changelog
3702
+ ? `, or write the notes into [Unreleased] in ${config.changelog} with them resolved — a hand-written section wins over a draft`
3703
+ : ''
3704
+ const UNSURE_FIX = ` Re-run without --yes in a terminal to review them at the prompt${handWritten}.`
3705
+
3175
3706
  const changelogText =
3176
3707
  config.changelog && existsSync(config.changelog) ? readFileSync(config.changelog, 'utf8') : null
3177
3708
  if (changelogText) reportChangelogOrder(changelogText)
@@ -3201,18 +3732,37 @@ if (notesSource === 'github') {
3201
3732
 
3202
3733
  // Generate, either because nothing was written or because a source was named.
3203
3734
  if (!notes) {
3735
+ // Candidates' sections are not a source for the stable release: its notes come from all
3736
+ // the commits since the last stable tag. Say so while there is still time to write the
3737
+ // wording that should survive into [Unreleased] — nothing else would.
3738
+ const candidates =
3739
+ notesSource === 'auto' && changelogText ? candidateSections(changelogText, version) : []
3740
+ if (candidates.length) {
3741
+ warn(
3742
+ `${config.changelog} has sections for ${candidates.join(', ')}, but ${version} ` +
3743
+ 'has none of its own, so its notes are generated from every commit since the last ' +
3744
+ 'stable release.\n Anything edited into those sections is not carried over. ' +
3745
+ `To release with it, write the ${version} notes into [Unreleased] and re-run.`,
3746
+ )
3747
+ }
3204
3748
  if (notesDeferred) {
3205
3749
  notesPending = true
3206
3750
  ok(`release notes will be ${assistant ? 'drafted' : 'generated'} after the commit`)
3207
3751
  } else {
3208
3752
  draftedNotes = draftNotesFor(version)
3753
+ if (unsure.length) {
3754
+ if (confirming) warn(`${listUnsure()}\n They are shown again at the prompt.`)
3755
+ else fail(`${listUnsure()}\n${UNSURE_FIX}`)
3756
+ }
3209
3757
  if (draftedNotes) {
3210
3758
  notes = draftedNotes
3211
- ok(
3212
- notesSource === 'commits' || !assistant
3213
- ? 'release notes built from the commit log'
3214
- : `release notes drafted by ${assistantName}`,
3215
- )
3759
+ // An "ok" under the failure above would contradict it: these notes are refused.
3760
+ if (!unsure.length || confirming)
3761
+ ok(
3762
+ notesSource === 'commits' || !assistant
3763
+ ? 'release notes built from the commit log'
3764
+ : `release notes drafted by ${assistantName}`,
3765
+ )
3216
3766
  } else if (notesSource === 'auto') {
3217
3767
  warn(
3218
3768
  `no ${config.changelog ?? 'changelog'} section and nothing to draft from — GitHub will generate the notes`,
@@ -3236,7 +3786,9 @@ for (const asset of config.assets) {
3236
3786
  const wouldCommit = [
3237
3787
  dirty && runs('commit') && 'the working tree',
3238
3788
  bumping && 'a version bump',
3239
- rolledChangelog && 'a changelog entry',
3789
+ // The roll is computed whenever [Unreleased] is populated, because the notes come from
3790
+ // it either way; it is only written — and only becomes a commit — when the step runs.
3791
+ rolledChangelog && runs('changelog') && 'a changelog entry',
3240
3792
  ].filter(Boolean)
3241
3793
  if (taggedCommit && runs('tag') && wouldCommit.length) {
3242
3794
  fail(
@@ -3252,7 +3804,8 @@ if (taggedCommit && runs('tag') && wouldCommit.length) {
3252
3804
  if (config.verify) {
3253
3805
  note(`running verify: ${config.verify}`)
3254
3806
  try {
3255
- execSync(config.verify, { stdio: 'pipe', encoding: 'utf8' })
3807
+ // A test suite or a build can print far more than Node's 1 MiB default capture.
3808
+ execSync(config.verify, { stdio: 'pipe', encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 })
3256
3809
  ok(`verify passed: ${config.verify}`)
3257
3810
  } catch (err) {
3258
3811
  const tail = `${err.stdout ?? ''}${err.stderr ?? ''}`.trim().split('\n').slice(-12).join('\n')
@@ -3298,19 +3851,43 @@ if (dirty && runs('commit') && !dryRun) {
3298
3851
  console.log(indent(commitMessage))
3299
3852
  }
3300
3853
 
3301
- if (!assumeYes && !dryRun && process.stdin.isTTY) {
3302
- if (notes) console.log(`\n${bold('Release notes')}\n${indent(notes)}`)
3854
+ /** Ask a yes/no question on the terminal; anything but yes is no. */
3855
+ async function ask(question) {
3303
3856
  const rl = createInterface({ input: process.stdin, output: process.stdout })
3304
3857
  let answer = ''
3305
3858
  try {
3306
- answer = await rl.question(`\nRelease ${bold(tag)} of ${projectName}? [y/N] `)
3859
+ answer = await rl.question(question)
3307
3860
  } catch {
3308
3861
  // Ctrl+C or Ctrl+D at the prompt rejects the question. That is a decline, not a
3309
3862
  // crash — without this it exits on an unhandled AbortError and a stack trace.
3310
3863
  } finally {
3311
3864
  rl.close()
3312
3865
  }
3313
- if (!/^y(es)?$/i.test(answer.trim())) {
3866
+ return /^y(es)?$/i.test(answer.trim())
3867
+ }
3868
+
3869
+ /**
3870
+ * Show the notes, with the entries the assistant was unsure of called out, and ask. A yes is
3871
+ * the review those entries were waiting for, so the marker comes off before anything is
3872
+ * written: `[???]` in a tag or on a release page helps nobody.
3873
+ */
3874
+ async function confirmRelease(question) {
3875
+ if (notes) console.log(`\n${bold('Release notes')}\n${indent(notes)}`)
3876
+ if (unsure.length) {
3877
+ console.log(`\n${yellow(bold('Review these'))} — ${listUnsure()}`)
3878
+ console.log(dim(' Answering y releases them as shown, without the marker.'))
3879
+ }
3880
+ const yes = await ask(question)
3881
+ if (yes && unsure.length) {
3882
+ notes = withoutUnsureMarkers(notes)
3883
+ if (draftedNotes) draftedNotes = withoutUnsureMarkers(draftedNotes)
3884
+ unsure = []
3885
+ }
3886
+ return yes
3887
+ }
3888
+
3889
+ if (confirming) {
3890
+ if (!(await confirmRelease(`\nRelease ${bold(tag)} of ${projectName}? [y/N] `))) {
3314
3891
  // Leave the index exactly as it was found.
3315
3892
  if (didStage) mutate('git', ['reset', '--quiet'])
3316
3893
  abort('cancelled')
@@ -3332,6 +3909,18 @@ if (dirty && runs('commit')) {
3332
3909
  if (notesPending && !dryRun) {
3333
3910
  draftedNotes = draftNotesFor(version)
3334
3911
  if (draftedNotes) notes = draftedNotes
3912
+ // These notes were drafted after the prompt, so nobody has seen them. Flagged entries
3913
+ // get the review preflight would have given them; the commit just made stays, and a
3914
+ // re-run drafts from a clean tree — before the prompt, where they can be reviewed.
3915
+ if (unsure.length) {
3916
+ const committed =
3917
+ ' The working tree is committed; nothing else has changed. Re-running drafts the ' +
3918
+ 'notes during preflight, before anything else happens.'
3919
+ if (!confirming) abort(`${listUnsure()}\n${UNSURE_FIX}\n${committed}`)
3920
+ if (!(await confirmRelease(`\nRelease ${bold(tag)} with these notes? [y/N] `))) {
3921
+ abort(`cancelled\n${committed}`)
3922
+ }
3923
+ }
3335
3924
  }
3336
3925
  }
3337
3926
 
@@ -3439,15 +4028,37 @@ for (const target of publishTargets) {
3439
4028
  // target published nothing, and telling downstream otherwise is a lie it may act on.
3440
4029
  if (publishedSomething) runHook('afterPublish')
3441
4030
 
4031
+ /**
4032
+ * Whether a stable release above this version already exists, anywhere in the
4033
+ * repository — not only in this branch's history, since a patch on an older line is
4034
+ * cut from a branch the newer minor was never merged into.
4035
+ *
4036
+ * GitHub's "Latest" badge is what `releases/latest` resolves to, and `--latest`
4037
+ * forces it. A backport patch that took it would point every "download the latest
4038
+ * release" link at the older line.
4039
+ */
4040
+ function supersededByExistingRelease() {
4041
+ const listed = tryRead('git', ['tag', '--list', `${config.tagPrefix}*`]) ?? ''
4042
+ return listed
4043
+ .split('\n')
4044
+ .map((name) => name.trim().slice(config.tagPrefix.length))
4045
+ .filter((v) => parseVersion(v) && !parseVersion(v).pre.length)
4046
+ .some((v) => compareVersions(v, version) > 0)
4047
+ }
4048
+
3442
4049
  if (runs('release') && !releaseExists) {
3443
4050
  step(`GitHub release ${tag}`)
4051
+ const latest = !isPrerelease && !supersededByExistingRelease()
4052
+ if (!isPrerelease && !latest) {
4053
+ note(`a newer stable release is already tagged — ${tag} will not be marked Latest`)
4054
+ }
3444
4055
  const args = [
3445
4056
  'release',
3446
4057
  'create',
3447
4058
  tag,
3448
4059
  '--title',
3449
4060
  expand(config.releaseTitle),
3450
- isPrerelease ? '--prerelease' : '--latest',
4061
+ isPrerelease ? '--prerelease' : `--latest=${latest}`,
3451
4062
  // Notes arrive on stdin, so there is no temp file and nothing to escape.
3452
4063
  ...(notes ? ['--notes-file', '-'] : ['--generate-notes']),
3453
4064
  ...config.assets,