@entro314labs/release-kit 2.8.0 → 2.9.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 +256 -45
  2. package/TRAIN.md +13 -0
  3. package/package.json +1 -1
  4. package/release.mjs +994 -116
  5. package/train.mjs +18 -5
package/release.mjs CHANGED
@@ -37,6 +37,7 @@ import {
37
37
  existsSync,
38
38
  mkdirSync,
39
39
  mkdtempSync,
40
+ readdirSync,
40
41
  readFileSync,
41
42
  writeFileSync,
42
43
  } from 'node:fs'
@@ -61,9 +62,16 @@ import { createInterface } from 'node:readline/promises'
61
62
  * versionFile string|object|null where the project's version lives. Detected from
62
63
  * the repository when unset; null when it versions by tag alone
63
64
  * versionFiles array further files whose version is kept in sync; each is a path
64
- * or { path, pattern }
65
- * publish string publish command. Detected from the version source when unset,
66
- * and only where it is unambiguous; null to publish nothing
65
+ * or { path, pattern }. Written even where versionFile is null,
66
+ * which is how a language with no version of its own — a Go
67
+ * module — keeps one in source. When neither this nor
68
+ * versionFile is configured, a second root manifest and a
69
+ * conventional version constant already on the same version are
70
+ * detected and kept in step
71
+ * publish string|string[] publish command, or several for a project that
72
+ * releases to more than one registry. Detected from the version
73
+ * sources when unset, and only where unambiguous; null to
74
+ * publish nothing
67
75
  * commitMessage string release commit subject
68
76
  * releaseTitle string GitHub release title
69
77
  * assets string[] files attached to the GitHub release
@@ -91,11 +99,11 @@ import { createInterface } from 'node:readline/promises'
91
99
  * committing, is a mistake the tool should not let you express.
92
100
  *
93
101
  * commit commit a dirty working tree (opt-in; touches work that predates the release)
94
- * version write the version into package.json and versionFiles
102
+ * version write the version into the version source and versionFiles
95
103
  * changelog roll [Unreleased] into the version, or add drafted notes
96
104
  * tag annotated git tag carrying the release notes
97
105
  * push push the branch and the tag together
98
- * publish run the configured publish command
106
+ * publish run the configured publish command(s), in order
99
107
  * release create the GitHub release
100
108
  *
101
109
  * `version` and `changelog` write files; those writes are persisted by a release commit
@@ -134,8 +142,24 @@ const DEFAULTS = {
134
142
  '^(fixup|squash)!',
135
143
  ],
136
144
  verify: null,
145
+ hooks: {},
137
146
  }
138
147
 
148
+ /**
149
+ * The points a project can hang its own commands on, in the order they run.
150
+ *
151
+ * `verify` already covers the one gate that matters most — the project's own tests, run
152
+ * during preflight before anything mutates. What it cannot express is work that has to
153
+ * happen *between* the release's own steps: regenerating a file derived from the version,
154
+ * building an artefact the publish command expects to find, telling something downstream
155
+ * that a release landed.
156
+ *
157
+ * They are command lines rather than callbacks because the config is JSON, and they take
158
+ * the same `%v` `%t` `%n` `%d` tokens the publish command does. A non-zero exit aborts the
159
+ * release exactly where it happened, which is the point of running them there.
160
+ */
161
+ const HOOKS = ['beforeVersion', 'afterVersion', 'beforePublish', 'afterPublish', 'afterRelease']
162
+
139
163
  /**
140
164
  * Prerelease identifiers that map to their own npm dist-tag. An identifier outside this
141
165
  * set has no safe home, so `distTagFor` refuses rather than letting a prerelease fall
@@ -165,7 +189,11 @@ Target (optional; defaults to the version already in package.json):
165
189
  Steps, in the fixed order they run. All but "commit" run by default:
166
190
  ${STEPS.join(' ')}
167
191
 
168
- Subcommands (they check or copy, and never start a release):
192
+ Subcommands (they check, print or copy, and never start a release):
193
+ next [<version>|<bump>]
194
+ print the version that target would release, and stop.
195
+ Only the version reaches stdout, so it substitutes:
196
+ VERSION=$(release-kit next auto)
169
197
  lint-commits [<range>]
170
198
  check commit subjects against Conventional Commits
171
199
  (default range: since the last tag)
@@ -210,9 +238,20 @@ const yellow = (s) => paint('33', s)
210
238
 
211
239
  let stepNumber = 0
212
240
  const step = (title) => console.log(`\n${bold(`[${++stepNumber}] ${title}`)}`)
213
- const ok = (message) => console.log(` ${green('ok')} ${message}`)
214
- const warn = (message) => console.log(` ${yellow('warn')} ${message}`)
215
- const note = (message) => console.log(` ${dim(message)}`)
241
+ /**
242
+ * `next` exists to be substituted into a shell command, so its stdout must carry the
243
+ * version and nothing else. Everything the release would narrate still gets said — on
244
+ * stderr, where a human reads it and `$(...)` does not.
245
+ */
246
+ const PRINT_ONLY = process.argv[2] === 'next'
247
+ const say = (line) => {
248
+ if (PRINT_ONLY) process.stderr.write(`${line}\n`)
249
+ else console.log(line)
250
+ }
251
+
252
+ const ok = (message) => say(` ${green('ok')} ${message}`)
253
+ const warn = (message) => say(` ${yellow('warn')} ${message}`)
254
+ const note = (message) => say(` ${dim(message)}`)
216
255
  const indent = (text) =>
217
256
  text
218
257
  .split('\n')
@@ -411,29 +450,120 @@ function runAssistant(prompt) {
411
450
  }
412
451
  }
413
452
 
414
- /** Commit subjects since the last tag, with release and merge commits filtered out. */
415
- function commitsSinceLastTag() {
453
+ /**
454
+ * The repository's release tags that are reachable from HEAD, highest version first.
455
+ *
456
+ * `git describe --tags --abbrev=0` answers a different question — "the nearest tag of any
457
+ * kind" — and it is wrong in two ways that were both observed. A repository carrying tags
458
+ * that are not releases gets one of those as its baseline: a single rolling `latest-beta`
459
+ * marker, which tauri-release-kit maintains for its update channels, made a release abort
460
+ * with "no releasable commits since latest-beta". And "nearest ancestor" is not "latest
461
+ * release": a patch tagged on top of a later minor drags the baseline backwards.
462
+ *
463
+ * Only tags carrying the configured prefix and a parseable version count, and they are
464
+ * ordered by semver precedence rather than by position in the history. `--merged HEAD`
465
+ * keeps a tag made on another branch out of this branch's history, and degrades correctly
466
+ * in a shallow clone: a tag whose commit was not fetched is simply not listed.
467
+ *
468
+ * @returns {{name: string, version: string}[]}
469
+ */
470
+ function releaseTags(prefix = config.tagPrefix ?? '') {
471
+ const listed = tryRead('git', ['tag', '--list', `${prefix}*`, '--merged', 'HEAD']) ?? ''
472
+ return listed
473
+ .split('\n')
474
+ .map((name) => name.trim())
475
+ .filter(Boolean)
476
+ .map((name) => ({ name, version: name.slice(prefix.length) }))
477
+ .filter(({ version }) => parseVersion(version))
478
+ .sort((a, b) => compareVersions(b.version, a.version))
479
+ }
480
+
481
+ /**
482
+ * The tag a release reads its history from.
483
+ *
484
+ * @param {{stable?: boolean}} [options] `stable` when the version being released has no
485
+ * prerelease identifier, which rolls the release candidates leading to it up into it:
486
+ * their work is what is shipping now, and reading from the last candidate describes only
487
+ * the gap between the last two candidates. Promoting `2.0.0-rc.2` to `2.0.0` that way
488
+ * produced empty notes, because the one commit in range was the release chore.
489
+ * Releasing a candidate keeps the full ordering, so each candidate's notes say what
490
+ * changed in that candidate rather than repeating the whole cycle.
491
+ * @returns {string | null}
492
+ */
493
+ function lastReleaseTag({ stable = false, prefix } = {}) {
494
+ const tags = releaseTags(prefix)
495
+ const eligible = stable ? tags.filter(({ version }) => !parseVersion(version).pre.length) : tags
496
+ return eligible[0]?.name ?? null
497
+ }
498
+
499
+ /** Commit subjects since the last release tag, with release and merge commits filtered out. */
500
+ function commitsSinceLastTag(options) {
416
501
  const ignored = (config.ignoreCommits ?? []).map((pattern) => new RegExp(pattern, 'i'))
417
- const lastTag = tryRead('git', ['describe', '--tags', '--abbrev=0'])
502
+ const lastTag = lastReleaseTag(options)
418
503
  const range = lastTag ? `${lastTag}..HEAD` : 'HEAD'
419
504
  // %B is the whole message: bodies carry BREAKING CHANGE and Release-As footers. A record
420
505
  // separator keeps multi-line messages parseable when splitting the log back apart.
421
- // %h first, then the message: the hash is what links each bullet back to its commit.
422
- const raw = tryRead('git', ['log', `--format=%h%x1f%B%x1e`, range]) ?? ''
506
+ // %h first, then the author, then the message: the hash is what links each bullet back
507
+ // to its commit, and the author is what says who is new here.
508
+ const raw = tryRead('git', ['log', `--format=%h%x1f%an%x1f%ae%x1f%B%x1e`, range]) ?? ''
423
509
  const commits = raw
424
510
  .split('\u001E')
425
511
  .map((entry) => entry.trim())
426
512
  .filter(Boolean)
427
513
  .map((entry) => {
428
- const [hash, message = ''] = entry.split('\u001F')
514
+ const [hash, author = '', email = '', message = ''] = entry.split('\u001F')
429
515
  const [subject, ...rest] = message.split('\n')
430
- return { hash: hash.trim(), subject: subject.trim(), body: rest.join('\n').trim() }
516
+ return {
517
+ hash: hash.trim(),
518
+ author: author.trim(),
519
+ email: email.trim().toLowerCase(),
520
+ subject: subject.trim(),
521
+ body: rest.join('\n').trim(),
522
+ }
431
523
  })
432
524
  // Bookkeeping rather than change: the previous release's own commit, merges that
433
525
  // duplicate the branch they bring in, and markers meant to be autosquashed away.
434
526
  .filter(({ subject }) => subject && !ignored.some((re) => re.test(subject)))
435
527
  const kept = withoutRevertedCommits(commits)
436
- return { lastTag, commits: kept, subjects: kept.map((c) => c.subject) }
528
+ return {
529
+ lastTag,
530
+ commits: kept,
531
+ subjects: kept.map((c) => c.subject),
532
+ contributors: newContributors(lastTag, kept),
533
+ }
534
+ }
535
+
536
+ /**
537
+ * The people whose first commit to this repository is in this release.
538
+ *
539
+ * git-cliff derives this from the forge's API, which needs a token, a network and a
540
+ * forge. The repository already knows: an author absent from every commit before the
541
+ * previous tag has not contributed before. That answer is exact, offline, and the same on
542
+ * GitHub, GitLab and a bare remote — and it degrades with a shallow clone exactly as the
543
+ * rest of the notes do, which is already warned about.
544
+ *
545
+ * The first release has no "before", so everyone would be new and the section would say
546
+ * nothing; it is skipped there.
547
+ *
548
+ * @returns {string[]} display names, GitHub handles where the email carries one
549
+ */
550
+ function newContributors(lastTag, commits) {
551
+ if (!lastTag || !commits.length) return []
552
+ const before = new Set(
553
+ (tryRead('git', ['log', '--format=%ae', lastTag]) ?? '')
554
+ .split('\n')
555
+ .map((email) => email.trim().toLowerCase())
556
+ .filter(Boolean),
557
+ )
558
+ const seen = new Map()
559
+ for (const { author, email } of commits) {
560
+ if (!email || before.has(email) || seen.has(email)) continue
561
+ // A GitHub noreply address carries the account handle, which is what a reader can
562
+ // actually follow; anything else falls back to the name on the commit.
563
+ const handle = /^(?:\d+\+)?([^@]+)@users\.noreply\.github\.com$/.exec(email)?.[1]
564
+ seen.set(email, handle ? `@${handle}` : author)
565
+ }
566
+ return [...seen.values()].filter(Boolean)
437
567
  }
438
568
 
439
569
  /**
@@ -528,7 +658,7 @@ function inferBump(commits, currentVersion, strategy = 'conventional') {
528
658
  *
529
659
  * @returns {string | null} markdown body, or null when nothing visible changed
530
660
  */
531
- function changelogFromCommits(commits, links = null, hidden = []) {
661
+ function changelogFromCommits(commits, links = null, hidden = [], contributors = []) {
532
662
  const parsed = commits.map((c) => parseCommit(c.subject, c.body, c.hash)).filter(Boolean)
533
663
  const lines = []
534
664
 
@@ -577,6 +707,14 @@ function changelogFromCommits(commits, links = null, hidden = []) {
577
707
  lines.push('')
578
708
  }
579
709
 
710
+ // Last, and only when there is something above it: a list of names is not release notes
711
+ // on its own, and a release with no described changes should still say so.
712
+ if (lines.length && contributors.length) {
713
+ lines.push('### New Contributors', '')
714
+ for (const name of contributors) lines.push(`- ${name} made their first contribution`)
715
+ lines.push('')
716
+ }
717
+
580
718
  return lines.length ? lines.join('\n').trim() : null
581
719
  }
582
720
 
@@ -586,11 +724,28 @@ function changelogFromCommits(commits, links = null, hidden = []) {
586
724
  * differ: Bitbucket uses /issue/ and /commits/ where GitHub uses /issues/ and /commit/.
587
725
  */
588
726
  const HOSTS = {
589
- 'github.com': { issue: 'issues', commit: 'commit' },
590
- 'gitlab.com': { issue: 'issues', commit: 'commit' },
591
- 'bitbucket.org': { issue: 'issue', commit: 'commits' },
727
+ 'github.com': {
728
+ issue: 'issues',
729
+ commit: 'commit',
730
+ compare: 'compare/%f...%t',
731
+ tag: 'releases/tag/%t',
732
+ },
733
+ 'gitlab.com': { issue: 'issues', commit: 'commit', compare: 'compare/%f...%t', tag: '-/tags/%t' },
734
+ // Bitbucket reverses the operands and separates them with two dots, and keeps tags under
735
+ // /commits/tag/ rather than a releases page it does not have.
736
+ 'bitbucket.org': {
737
+ issue: 'issue',
738
+ commit: 'commits',
739
+ compare: 'branches/compare/%t..%f',
740
+ tag: 'commits/tag/%t',
741
+ },
742
+ }
743
+ const DEFAULT_HOST = {
744
+ issue: 'issues',
745
+ commit: 'commit',
746
+ compare: 'compare/%f...%t',
747
+ tag: 'releases/tag/%t',
592
748
  }
593
- const DEFAULT_HOST = { issue: 'issues', commit: 'commit' }
594
749
 
595
750
  /** Words that mark an issue reference as closed by the commit. */
596
751
  const CLOSES = /\b(?:close[sd]?|closing|fix(?:e[sd])?|fixing|resolve[sd]?|resolving)\s+#(\d+)/gi
@@ -611,10 +766,13 @@ function remoteLinks(remote) {
611
766
  const [, host, path] = web ?? scp ?? []
612
767
  if (!host || !path) return null
613
768
  const shape = HOSTS[host.toLowerCase()] ?? DEFAULT_HOST
769
+ const base = `https://${host}/${path}`
614
770
  return {
615
- base: `https://${host}/${path}`,
616
- issue: `https://${host}/${path}/${shape.issue}`,
617
- commit: `https://${host}/${path}/${shape.commit}`,
771
+ base,
772
+ issue: `${base}/${shape.issue}`,
773
+ commit: `${base}/${shape.commit}`,
774
+ compare: (from, to) => `${base}/${shape.compare.replace('%f', from).replace('%t', to)}`,
775
+ tag: (name) => `${base}/${shape.tag.replace('%t', name)}`,
618
776
  }
619
777
  }
620
778
 
@@ -871,6 +1029,103 @@ function mutate(command, args, options = {}) {
871
1029
  }
872
1030
  }
873
1031
 
1032
+ /**
1033
+ * Lockfiles that record the releasing project's own version, and the command that brings
1034
+ * each back into step.
1035
+ *
1036
+ * A lockfile is not rewritten by pattern like a manifest is: `package-lock.json` carries
1037
+ * the version in two places, `uv.lock` carries it inside the `[[package]]` block for the
1038
+ * project among all its dependencies, and both formats change shape between tool versions.
1039
+ * The tool that owns the file is the only thing that can be trusted to edit it, so each
1040
+ * one is refreshed by running that tool.
1041
+ *
1042
+ * `manifest` scopes the refresh: a polyglot repository can hold a `uv.lock` for a Python
1043
+ * component that this release is not versioning, and regenerating it would put an
1044
+ * unrelated change in the release commit.
1045
+ *
1046
+ * pnpm and Cargo are deliberately absent. `pnpm-lock.yaml` records no root version, so it
1047
+ * never goes stale; `Cargo.lock` does, and is already kept in step as a version file with
1048
+ * a pattern scoped to the crate.
1049
+ */
1050
+ const LOCKFILES = [
1051
+ {
1052
+ path: 'package-lock.json',
1053
+ manifest: 'package.json',
1054
+ command: ['npm', ['install', '--package-lock-only', '--ignore-scripts', '--silent']],
1055
+ },
1056
+ {
1057
+ path: 'npm-shrinkwrap.json',
1058
+ manifest: 'package.json',
1059
+ command: ['npm', ['install', '--package-lock-only', '--ignore-scripts', '--silent']],
1060
+ },
1061
+ { path: 'uv.lock', manifest: 'pyproject.toml', command: ['uv', ['lock', '--quiet']] },
1062
+ ]
1063
+
1064
+ /**
1065
+ * Bring every lockfile belonging to a manifest this release wrote back into step.
1066
+ *
1067
+ * A missing tool is a warning rather than an abort: the lockfile is left exactly as stale
1068
+ * as it already was, which is the behaviour without this step at all, and no release
1069
+ * should die because a lock tool is not installed on the machine cutting it.
1070
+ *
1071
+ * @param {string[]} written paths the version step wrote
1072
+ */
1073
+ function refreshLockfiles(written) {
1074
+ const done = new Set()
1075
+ for (const { path, manifest, command } of LOCKFILES) {
1076
+ if (!existsSync(path) || !written.includes(manifest)) continue
1077
+ const [tool, args] = command
1078
+ // npm writes whichever of the two lockfiles the project has; running it twice is one
1079
+ // pointless install, not two different edits.
1080
+ if (!done.has(tool)) {
1081
+ if (!dryRun && !succeeds(tool, ['--version'])) {
1082
+ warn(`${path} records the version and ${tool} is not installed — leaving it stale`)
1083
+ continue
1084
+ }
1085
+ mutate(tool, args)
1086
+ done.add(tool)
1087
+ }
1088
+ staged.push(path)
1089
+ }
1090
+ }
1091
+
1092
+ /**
1093
+ * Push the release commit and its tag as one transaction.
1094
+ *
1095
+ * `--follow-tags` and `--atomic` answer different questions: the first decides *which*
1096
+ * refs are sent, the second decides whether they land together. With only the first, a
1097
+ * server is free to accept the branch and reject the tag — which is precisely the split
1098
+ * this step exists to prevent, leaving a release commit on the remote with no tag, or a
1099
+ * tag with no commit behind it.
1100
+ *
1101
+ * Not every server implements the atomic capability, so a refusal on those grounds falls
1102
+ * back to the plain push. Nothing else does: a rejected non-fast-forward retried without
1103
+ * `--atomic` would push one ref and not the other, which is worse than failing.
1104
+ */
1105
+ function pushBranchAndTag(branchRef) {
1106
+ const args = ['push', '--follow-tags', config.remote, branchRef]
1107
+ const atomic = ['push', '--follow-tags', '--atomic', config.remote, branchRef]
1108
+ const line = formatCommand('git', atomic)
1109
+ if (dryRun) {
1110
+ console.log(` ${yellow('would run:')} ${line}`)
1111
+ return
1112
+ }
1113
+ console.log(` ${dim(`$ ${line}`)}`)
1114
+ try {
1115
+ execFileSync('git', atomic, { stdio: ['pipe', 'inherit', 'pipe'] })
1116
+ return
1117
+ } catch (err) {
1118
+ const stderr = `${err.stderr ?? ''}`
1119
+ process.stderr.write(stderr)
1120
+ if (!/atomic/i.test(stderr)) abortMidRelease(line)
1121
+ }
1122
+ warn(
1123
+ `${config.remote} does not support atomic pushes — sending the branch and tag in one ` +
1124
+ 'call, but not as one transaction',
1125
+ )
1126
+ mutate('git', args)
1127
+ }
1128
+
874
1129
  /**
875
1130
  * Mutating shell command, for configured strings like `publish` that are written as a
876
1131
  * whole command line rather than an argv. Shell metacharacters are the author's to own.
@@ -1072,6 +1327,65 @@ function insertChangelogSection(text, version, date, body) {
1072
1327
  return `${trimmed}\n\n${entry}`
1073
1328
  }
1074
1329
 
1330
+ /**
1331
+ * Write the link reference definitions a Keep a Changelog document's headings depend on.
1332
+ *
1333
+ * `## [1.2.3]` is a markdown link *reference*: without a matching `[1.2.3]: <url>` at the
1334
+ * foot of the file it renders as literal bracketed text. Sections were being written in
1335
+ * that shape and the definitions were never written at all, so every heading in every
1336
+ * changelog this tool has ever rolled is a dead reference.
1337
+ *
1338
+ * Every bracketed heading in the document gets one, not only the version being released,
1339
+ * so a changelog that never had them is repaired in one release rather than from here on.
1340
+ * Each version links to the diff since the version below it; the oldest links to its own
1341
+ * tag, having no predecessor to compare against. `[Unreleased]` compares the newest
1342
+ * version against `HEAD`.
1343
+ *
1344
+ * Definitions for labels that are not headings are left exactly where they are — those are
1345
+ * the author's own links, and this owns only what it can derive.
1346
+ *
1347
+ * @param {ReturnType<typeof remoteLinks>} links
1348
+ * @returns {string} the document with its definitions rewritten
1349
+ */
1350
+ function withChangelogLinks(text, links, tagPrefix = '') {
1351
+ if (!links) return text
1352
+ const labels = [...text.matchAll(/^## \[([^\]]+)\]/gm)].map((m) => m[1])
1353
+ if (!labels.length) return text
1354
+
1355
+ const versions = labels
1356
+ .filter((label) => parseVersion(label.replace(/^v/, '')))
1357
+ .sort((a, b) => compareVersions(b.replace(/^v/, ''), a.replace(/^v/, '')))
1358
+ const unreleased = labels.find((label) => /^unreleased$/i.test(label))
1359
+
1360
+ const tagged = (label) => `${tagPrefix}${label.replace(/^v/, '')}`
1361
+ const definitions = []
1362
+ if (unreleased && versions.length) {
1363
+ definitions.push(`[${unreleased}]: ${links.compare(tagged(versions[0]), 'HEAD')}`)
1364
+ }
1365
+ versions.forEach((version, index) => {
1366
+ const previous = versions[index + 1]
1367
+ definitions.push(
1368
+ `[${version}]: ${
1369
+ previous ? links.compare(tagged(previous), tagged(version)) : links.tag(tagged(version))
1370
+ }`,
1371
+ )
1372
+ })
1373
+ if (!definitions.length) return text
1374
+
1375
+ // Drop the existing definitions for the labels being rewritten, wherever they sit, so
1376
+ // running this twice produces the same document rather than a second copy.
1377
+ const managed = new Set([...(unreleased ? [unreleased] : []), ...versions])
1378
+ const body = text
1379
+ .split('\n')
1380
+ .filter((line) => {
1381
+ const label = /^\[([^\]]+)\]:\s/.exec(line)?.[1]
1382
+ return !(label && managed.has(label))
1383
+ })
1384
+ .join('\n')
1385
+
1386
+ return `${body.trimEnd()}\n\n${definitions.join('\n')}\n`
1387
+ }
1388
+
1075
1389
  /**
1076
1390
  * Promote `## [Unreleased]` to a released version and reopen an empty one above it.
1077
1391
  *
@@ -1152,6 +1466,73 @@ function readNameFrom(entry) {
1152
1466
  /** Normalise a versionFile / versionFiles entry to { path, pattern }. */
1153
1467
  const versionSource = (entry) => (typeof entry === 'string' ? { path: entry } : entry)
1154
1468
 
1469
+ /**
1470
+ * Expand a path that may contain `*` segments into the paths that exist, in sorted order.
1471
+ *
1472
+ * A desktop app carries the same version in a per-platform config for every platform it
1473
+ * ships, and a Cargo workspace lists its members as `crates/*`. Writing those out one by
1474
+ * one is the config the glob replaces. `*` matches within a single path segment, which is
1475
+ * what both of those shapes need and is all Go's `filepath.Glob` — the shape changie's
1476
+ * `replacements` use — offers either.
1477
+ *
1478
+ * @returns {string[]} matching paths; a pattern with no `*` yields itself when it exists
1479
+ */
1480
+ function expandPaths(pattern) {
1481
+ if (!pattern.includes('*')) return existsSync(pattern) ? [pattern] : []
1482
+ const absolute = pattern.startsWith('/')
1483
+ let current = [absolute ? '/' : '.']
1484
+ for (const segment of pattern.split('/').filter(Boolean)) {
1485
+ const next = []
1486
+ if (segment.includes('*')) {
1487
+ const shape = new RegExp(`^${segment.split('*').map(escapeRe).join('[^/]*')}$`)
1488
+ for (const dir of current) {
1489
+ let entries
1490
+ try {
1491
+ entries = readdirSync(dir).sort()
1492
+ } catch {
1493
+ continue
1494
+ }
1495
+ for (const entry of entries) if (shape.test(entry)) next.push(join(dir, entry))
1496
+ }
1497
+ } else {
1498
+ for (const dir of current) {
1499
+ const joined = join(dir, segment)
1500
+ if (existsSync(joined)) next.push(joined)
1501
+ }
1502
+ }
1503
+ current = next
1504
+ }
1505
+ return current
1506
+ }
1507
+
1508
+ /**
1509
+ * The crates in a Cargo workspace whose version this bump owns: the members that inherit
1510
+ * it with `version.workspace = true`, which is how a workspace keeps its crates in step.
1511
+ *
1512
+ * A member pinning its own number is versioned separately and is left out, the same rule
1513
+ * the companion-manifest detection uses.
1514
+ *
1515
+ * @returns {string[]} crate names
1516
+ */
1517
+ function workspaceCrates(manifestPath) {
1518
+ const text = readFileSync(manifestPath, 'utf8')
1519
+ const members = /^members\s*=\s*\[([\s\S]*?)\]/m.exec(text)?.[1]
1520
+ if (!members) return []
1521
+ const root = dirname(manifestPath)
1522
+ const names = []
1523
+ for (const entry of members.matchAll(/"([^"]+)"/g)) {
1524
+ for (const dir of expandPaths(join(root, entry[1]))) {
1525
+ const memberManifest = join(dir, 'Cargo.toml')
1526
+ if (!existsSync(memberManifest)) continue
1527
+ const member = readFileSync(memberManifest, 'utf8')
1528
+ if (!/^version(?:\.workspace)?\s*=\s*\{?\s*workspace\s*=\s*true/m.test(member)) continue
1529
+ const name = NAME_PATTERNS.toml.exec(member)?.[1]
1530
+ if (name) names.push(name)
1531
+ }
1532
+ }
1533
+ return names
1534
+ }
1535
+
1155
1536
  /**
1156
1537
  * A lockfile records a version for every dependency — hundreds of them — so the first
1157
1538
  * `version = "…"` in the file belongs to whichever crate sorts first, not to this project.
@@ -1159,21 +1540,27 @@ const versionSource = (entry) => (typeof entry === 'string' ? { path: entry } :
1159
1540
  */
1160
1541
  function cargoLockPattern(lockPath) {
1161
1542
  const sibling = join(dirname(lockPath), 'Cargo.toml')
1162
- const crate = existsSync(sibling) ? readNameFrom({ path: sibling }) : null
1163
- if (!crate) {
1543
+ // A workspace root carries no `[package]` of its own; what it owns is every member that
1544
+ // inherits the version with `version.workspace = true`, and the lockfile records a
1545
+ // block for each of them.
1546
+ const crates = existsSync(sibling)
1547
+ ? [...new Set([readNameFrom({ path: sibling }), ...workspaceCrates(sibling)].filter(Boolean))]
1548
+ : []
1549
+ if (!crates.length) {
1164
1550
  throw new Error(
1165
1551
  `${lockPath} lists every dependency's version, so it needs to know which package is ` +
1166
- `yours.\n No Cargo.toml beside it to read the name from — give an explicit ` +
1167
- `pattern:\n { "path": "${lockPath}", "pattern": "name = \\"<crate>\\"\\nversion = ` +
1168
- `\\"(.+)\\"" }`,
1552
+ `yours.\n No Cargo.toml beside it naming a crate or a workspace member that ` +
1553
+ `inherits the version — give an explicit pattern:\n { "path": "${lockPath}", ` +
1554
+ `"pattern": "name = \\"<crate>\\"\\nversion = \\"(.+)\\"" }`,
1169
1555
  )
1170
1556
  }
1171
- return new RegExp(`\\[\\[package\\]\\]\\nname = "${escapeRe(crate)}"\\nversion = "([^"]*)"`)
1557
+ const names = crates.map(escapeRe).join('|')
1558
+ return new RegExp(`\\[\\[package\\]\\]\\nname = "(?:${names})"\\nversion = "([^"]*)"`, 'g')
1172
1559
  }
1173
1560
 
1174
1561
  /** The regex for a source, or null when the whole file is the version. */
1175
- function patternFor({ path, pattern }) {
1176
- if (pattern) return new RegExp(pattern, 'm')
1562
+ function patternFor({ path, pattern, all = false }) {
1563
+ if (pattern) return new RegExp(pattern, all ? 'mg' : 'm')
1177
1564
  if (basename(path) === 'Cargo.lock') return cargoLockPattern(path)
1178
1565
  if (path.endsWith('.json')) return VERSION_PATTERNS.json
1179
1566
  if (path.endsWith('.toml')) return VERSION_PATTERNS.toml
@@ -1184,31 +1571,176 @@ function patternFor({ path, pattern }) {
1184
1571
  function readVersionFrom(entry) {
1185
1572
  const source = versionSource(entry)
1186
1573
  const text = readFileSync(source.path, 'utf8')
1187
- const pattern = patternFor(source)
1188
- if (!pattern) return text.trim() || null
1189
- const match = pattern.exec(text)
1574
+ const { kind, shape } = versionMode(source, text)
1575
+ if (kind === 'bare') return text.trim() || null
1576
+ if (kind === 'markers') {
1577
+ // The first line under a `version` marker is the one that carries the whole version;
1578
+ // a `major` or `date` marker carries only a piece of it.
1579
+ let scope = null
1580
+ for (const line of text.split('\n')) {
1581
+ const starting = MARKER_START.exec(line)?.[1]
1582
+ if (starting) {
1583
+ scope = starting
1584
+ continue
1585
+ }
1586
+ if (scope && MARKER_END.test(line)) {
1587
+ scope = null
1588
+ continue
1589
+ }
1590
+ const active = MARKER_INLINE.exec(line)?.[1] ?? scope
1591
+ if (active === 'version') {
1592
+ const found = MARKER_VERSION.exec(line)?.[0]
1593
+ if (found) return found
1594
+ }
1595
+ }
1596
+ return null
1597
+ }
1598
+ const match = shape.exec(text)
1190
1599
  return match ? match[1] : null
1191
1600
  }
1192
1601
 
1602
+ /**
1603
+ * Version markers: a comment naming the version, put on the line that carries it.
1604
+ *
1605
+ * A `pattern` can already reach any file, but writing one is a regex per file, and the
1606
+ * files that most want keeping in step — a README install line, a badge URL, a Dockerfile
1607
+ * tag, a Helm chart — are exactly the ones where a regex is fiddliest to get right and
1608
+ * easiest to get subtly wrong. release-please solved this with a marker comment on the
1609
+ * line instead, and the convention travels: the file says which of its numbers is the
1610
+ * version, so nothing outside it has to describe where that number sits.
1611
+ *
1612
+ * npm i acme@1.2.3 <!-- x-release-kit-version -->
1613
+ * FROM acme:1.2 # x-release-kit-minor
1614
+ * Released 2026-08-20 <!-- x-release-kit-date -->
1615
+ *
1616
+ * A block form covers a run of lines, for a fenced example that should not carry a comment
1617
+ * on every line:
1618
+ *
1619
+ * <!-- x-release-kit-start-version -->
1620
+ * ```sh
1621
+ * npm i acme@1.2.3
1622
+ * ```
1623
+ * <!-- x-release-kit-end -->
1624
+ */
1625
+ // `version-date` comes first: the alternation is ordered, and `version` would otherwise
1626
+ // match its prefix and leave the date alone.
1627
+ const SCOPES = 'version-date|major|minor|patch|version|date'
1628
+ const MARKER_INLINE = new RegExp(`x-release-kit-(${SCOPES})\\b`)
1629
+ const MARKER_START = new RegExp(`x-release-kit-start-(${SCOPES})\\b`)
1630
+ const MARKER_END = /x-release-kit-end\b/
1631
+ const MARKER_VERSION = /\d+\.\d+\.\d+(?:-[0-9a-z.-]+)?(?:\+[0-9a-z.-]+)?/i
1632
+ const MARKER_NUMBER = /\b\d+\b/
1633
+ const MARKER_DATE = /\d{4}-\d{2}-\d{2}/
1634
+
1635
+ /** Whether a file opts into marker rewriting at all. */
1636
+ const hasVersionMarkers = (text) => MARKER_INLINE.test(text) || MARKER_START.test(text)
1637
+
1638
+ /**
1639
+ * Rewrite the marked numbers in a file.
1640
+ *
1641
+ * A marker whose line carries nothing to replace is left alone rather than guessed at: a
1642
+ * heading above a block, or a comment on its own line, is a normal thing to find.
1643
+ *
1644
+ * @returns {string} the rewritten text
1645
+ */
1646
+ function applyVersionMarkers(text, version, date) {
1647
+ const { major, minor, patch } = parseVersion(version)
1648
+ const replacements = {
1649
+ version: [MARKER_VERSION, version],
1650
+ major: [MARKER_NUMBER, String(major)],
1651
+ minor: [MARKER_NUMBER, String(minor)],
1652
+ patch: [MARKER_NUMBER, String(patch)],
1653
+ date: [MARKER_DATE, date],
1654
+ }
1655
+ // One line carrying both, which is the shape of an AppStream <release> tag.
1656
+ const versionAndDate = (line) => line.replace(MARKER_VERSION, version).replace(MARKER_DATE, date)
1657
+ let scope = null
1658
+ return text
1659
+ .split('\n')
1660
+ .map((line) => {
1661
+ const inline = MARKER_INLINE.exec(line)?.[1]
1662
+ const starting = MARKER_START.exec(line)?.[1]
1663
+ // A start marker opens a block; its own line is not rewritten, since the marker
1664
+ // comment is the whole content of it.
1665
+ if (starting) {
1666
+ scope = starting
1667
+ return line
1668
+ }
1669
+ if (scope && MARKER_END.test(line)) {
1670
+ scope = null
1671
+ return line
1672
+ }
1673
+ const active = inline ?? scope
1674
+ if (!active) return line
1675
+ if (active === 'version-date') return versionAndDate(line)
1676
+ const [shape, value] = replacements[active]
1677
+ return line.replace(shape, value)
1678
+ })
1679
+ .join('\n')
1680
+ }
1681
+
1682
+ /**
1683
+ * Where a file keeps its version, most specific first: the `pattern` the entry was
1684
+ * configured with, the markers the file carries, the shape its extension implies, and —
1685
+ * for a plain `VERSION` file — being nothing but the version.
1686
+ *
1687
+ * Reading and writing both go through this, so they can never disagree about which of a
1688
+ * file's numbers is the version.
1689
+ *
1690
+ * @returns {{kind: 'pattern'|'markers'|'bare', shape: RegExp|null}}
1691
+ */
1692
+ function versionMode(source, text) {
1693
+ if (source.pattern) return { kind: 'pattern', shape: patternFor(source) }
1694
+ if (hasVersionMarkers(text)) return { kind: 'markers', shape: null }
1695
+ const inferred = patternFor(source)
1696
+ return inferred ? { kind: 'pattern', shape: inferred } : { kind: 'bare', shape: null }
1697
+ }
1698
+
1193
1699
  /**
1194
1700
  * Replace the version in a source file, touching nothing else: only the captured range is
1195
1701
  * rewritten, so formatting, key order and comments all survive.
1196
1702
  *
1197
- * @param {{dryRun?: boolean}} [options] report the change without making it
1703
+ * Four ways a file says where its version is, most specific first: the `pattern` it was
1704
+ * configured with, the markers it carries, the shape its extension implies, and — for a
1705
+ * plain `VERSION` file — being nothing but the version.
1706
+ *
1707
+ * @param {{dryRun?: boolean, date?: string}} [options] report the change without making
1708
+ * it; the date written for a `x-release-kit-date` marker
1198
1709
  * @returns {boolean} whether the file needed changing
1199
1710
  */
1200
- function writeVersionInto(entry, version, { dryRun = false } = {}) {
1711
+ function writeVersionInto(entry, version, { dryRun = false, date } = {}) {
1201
1712
  const source = versionSource(entry)
1202
1713
  const text = readFileSync(source.path, 'utf8')
1203
- const pattern = patternFor(source)
1714
+ const { kind, shape } = versionMode(source, text)
1204
1715
 
1205
1716
  let updated
1206
- if (pattern) {
1207
- const match = pattern.exec(text)
1208
- if (!match) throw new Error(`${source.path} has no version matching ${pattern}`)
1209
- const start = match.index + match[0].indexOf(match[1])
1210
- updated = text.slice(0, start) + version + text.slice(start + match[1].length)
1717
+ if (kind === 'pattern') {
1718
+ if (!shape.test(text)) {
1719
+ if (source.optional) return false
1720
+ throw new Error(`${source.path} has no version matching ${shape}`)
1721
+ }
1722
+ shape.lastIndex = 0
1723
+ // A global pattern rewrites every match rather than the first: a Cargo.lock records
1724
+ // one block per crate, and a workspace bump owns all the members inheriting from it.
1725
+ updated = text.replace(shape, (match, captured) => {
1726
+ const at = match.indexOf(captured)
1727
+ return match.slice(0, at) + version + match.slice(at + captured.length)
1728
+ })
1729
+ } else if (kind === 'markers') {
1730
+ updated = applyVersionMarkers(text, version, date ?? new Date().toISOString().slice(0, 10))
1211
1731
  } else {
1732
+ // The last resort overwrites the file with the version, which is right for a VERSION
1733
+ // file and catastrophic for anything else. A file that is not already just a version
1734
+ // was listed by mistake, or wants a marker or a pattern — say so rather than shred it.
1735
+ const existing = text.trim()
1736
+ if (existing && !parseVersion(existing)) {
1737
+ throw new Error(
1738
+ `${source.path} is not a file containing only a version, and carries no ` +
1739
+ 'x-release-kit-version marker.\n Writing the version into it would replace ' +
1740
+ 'everything else in it. Mark the line that holds the version, or give the entry ' +
1741
+ 'a "pattern".',
1742
+ )
1743
+ }
1212
1744
  updated = `${version}\n`
1213
1745
  }
1214
1746
 
@@ -1221,7 +1753,9 @@ function writeVersionInto(entry, version, { dryRun = false } = {}) {
1221
1753
  // ARGUMENTS
1222
1754
  // ─────────────────────────────────────────────────────────────────────────────
1223
1755
 
1224
- const argv = process.argv.slice(2)
1756
+ // `next` is a modifier on the ordinary target resolution, not a mode of its own: it takes
1757
+ // the same target argument and stops once the version is known.
1758
+ const argv = process.argv.slice(PRINT_ONLY ? 3 : 2)
1225
1759
  const BUMPS = new Set([
1226
1760
  'auto',
1227
1761
  'major',
@@ -1313,9 +1847,9 @@ if (flag('--sync')) {
1313
1847
  if (argv[0] === 'lint-commits') {
1314
1848
  const rest = argv.slice(1)
1315
1849
  const subjectAt = rest.indexOf('--subject')
1316
- const ignored = { ...DEFAULTS, ...readUserConfig() }.ignoreCommits.map(
1317
- (pattern) => new RegExp(pattern, 'i'),
1318
- )
1850
+ // This subcommand runs before `config` is bound, so it resolves its own.
1851
+ const lintConfig = { ...DEFAULTS, ...readUserConfig() }
1852
+ const ignored = lintConfig.ignoreCommits.map((pattern) => new RegExp(pattern, 'i'))
1319
1853
 
1320
1854
  let subjects
1321
1855
  let scope
@@ -1328,7 +1862,7 @@ if (argv[0] === 'lint-commits') {
1328
1862
  scope = null
1329
1863
  } else {
1330
1864
  if (!tryRead('git', ['rev-parse', '--show-toplevel'])) abort('not inside a git repository')
1331
- const lastTag = tryRead('git', ['describe', '--tags', '--abbrev=0'])
1865
+ const lastTag = lastReleaseTag({ prefix: lintConfig.tagPrefix ?? '' })
1332
1866
  const range =
1333
1867
  rest.find((arg) => !arg.startsWith('-')) ?? (lastTag ? `${lastTag}..HEAD` : 'HEAD')
1334
1868
  // Merges carry no prose of their own, and %s is enough: nothing here reads the body.
@@ -1434,6 +1968,14 @@ const userConfig = readUserConfig()
1434
1968
  const config = { ...DEFAULTS, ...userConfig }
1435
1969
  const unknownKeys = Object.keys(config).filter((key) => !(key in DEFAULTS))
1436
1970
  if (unknownKeys.length) abort(`release.config.json has unknown keys: ${unknownKeys.join(', ')}`)
1971
+ // A misspelled hook name is a hook that silently never runs, which is the failure mode
1972
+ // this file refuses everywhere else it takes a name.
1973
+ const unknownHooks = Object.keys(config.hooks ?? {}).filter((key) => !HOOKS.includes(key))
1974
+ if (unknownHooks.length) {
1975
+ abort(
1976
+ `release.config.json has unknown hooks: ${unknownHooks.join(', ')}\n Known: ${HOOKS.join(', ')}`,
1977
+ )
1978
+ }
1437
1979
 
1438
1980
  /**
1439
1981
  * Which steps run: config provides the baseline, --only replaces it, --skip subtracts, and
@@ -1455,17 +1997,94 @@ const parseStepList = (value) =>
1455
1997
  const manifest = existsSync('package.json') ? readJson('package.json') : null
1456
1998
 
1457
1999
  /**
1458
- * Where this project keeps its version, when the config does not say. Checked in order of
1459
- * how definitively each file identifies a repository. `go.mod` resolves to null because Go
1460
- * modules carry no version — the tag is the version.
2000
+ * Files that identify a repository, most definitive first. The first one present is where
2001
+ * the version is read from and written to. `go.mod` is absent because Go modules carry no
2002
+ * version — the tag is the version.
1461
2003
  */
2004
+ const MANIFESTS = ['package.json', 'pyproject.toml', 'Cargo.toml', 'VERSION']
2005
+
2006
+ /** Where this project keeps its version, when the config does not say. */
1462
2007
  function detectVersionFile() {
1463
- for (const candidate of ['package.json', 'pyproject.toml', 'Cargo.toml', 'VERSION']) {
1464
- if (existsSync(candidate)) return candidate
1465
- }
2008
+ for (const candidate of MANIFESTS) if (existsSync(candidate)) return candidate
1466
2009
  return null
1467
2010
  }
1468
2011
 
2012
+ /**
2013
+ * Manifests that name an ecosystem, so a second one present means a second registry. A
2014
+ * bare VERSION file is deliberately not one: it names nothing, and a repository keeping an
2015
+ * unrelated VERSION beside its manifest should not be told the two disagree.
2016
+ */
2017
+ const ECOSYSTEM_MANIFESTS = ['package.json', 'pyproject.toml', 'Cargo.toml']
2018
+
2019
+ /**
2020
+ * Some repositories release one source tree to two ecosystems at once — a Tauri plugin is
2021
+ * a crate and an npm package, a maturin project is a crate and a wheel — and carry the
2022
+ * version in both manifests. Those bump together with no config.
2023
+ *
2024
+ * The safety rule is that they must already agree. Two manifests on different versions are
2025
+ * two independent version lines, and dragging one to the other's number is a silent, wrong
2026
+ * release; say so and touch nothing instead.
2027
+ *
2028
+ * @returns {string[]} further files to keep in step with the primary version source
2029
+ */
2030
+ function detectCompanionFiles(primaryPath, primaryVersion) {
2031
+ const companions = []
2032
+ for (const candidate of ECOSYSTEM_MANIFESTS) {
2033
+ if (candidate === primaryPath || !existsSync(candidate)) continue
2034
+ const found = readVersionFrom({ path: candidate })
2035
+ if (found !== primaryVersion) {
2036
+ warn(
2037
+ `${candidate} is at ${found ?? 'no readable version'} while ${primaryPath} is at ` +
2038
+ `${primaryVersion}, so they are versioned separately — leaving ${candidate} alone.\n` +
2039
+ ' Add it to "versionFiles" in release.config.json to bump them together.',
2040
+ )
2041
+ continue
2042
+ }
2043
+ companions.push(candidate)
2044
+ // The lockfile pins the crate's own version too, so a bump leaves it stale.
2045
+ if (candidate === 'Cargo.toml' && existsSync('Cargo.lock')) companions.push('Cargo.lock')
2046
+ }
2047
+ return companions
2048
+ }
2049
+
2050
+ /**
2051
+ * Where a language records no version of its own, a project that wants `--version` to work
2052
+ * keeps one in source instead: a Go module has only `go.mod`, which carries no version at
2053
+ * all, so the number lives in a `version.go` the tag is supposed to match.
2054
+ *
2055
+ * These mirror the tag rather than define it — `go get` resolves a tag, not a constant —
2056
+ * so they are detected as files to keep in step, never as the source of truth.
2057
+ *
2058
+ * A candidate is adopted only when it already carries the current version, the same rule
2059
+ * the companion manifests use. Here it does a second job: it rules out the `var Version =
2060
+ * "dev"` placeholder that a build replaces with -ldflags, which is not a version to bump
2061
+ * and is common enough that warning about it every release would be pure noise. Mismatches
2062
+ * are therefore skipped silently, unlike a manifest on its own version line.
2063
+ *
2064
+ * Anything outside this table is three lines of `versionFiles` config with a `pattern`;
2065
+ * this covers the convention that comes up without one.
2066
+ */
2067
+ const VERSION_MIRRORS = [
2068
+ {
2069
+ // `const Version = "1.2.0"`, `var Version = "1.2.0"`, and the same inside a const
2070
+ // block or with an explicit `string` type.
2071
+ pattern: '^\\s*(?:const\\s+|var\\s+)?[Vv]ersion\\s*(?:string\\s*)?=\\s*"(.+)"',
2072
+ paths: ['version.go', 'internal/version/version.go', 'pkg/version/version.go'],
2073
+ },
2074
+ ]
2075
+
2076
+ /** @returns {{path: string, pattern: string}[]} source files already carrying `version` */
2077
+ function detectVersionMirrors(version) {
2078
+ const found = []
2079
+ for (const { paths, pattern } of VERSION_MIRRORS) {
2080
+ for (const path of paths) {
2081
+ if (!existsSync(path)) continue
2082
+ if (readVersionFrom({ path, pattern }) === version) found.push({ path, pattern })
2083
+ }
2084
+ }
2085
+ return found
2086
+ }
2087
+
1469
2088
  /**
1470
2089
  * The publish command implied by a project's manifest, but only where one ecosystem
1471
2090
  * obviously owns it. Python has several publishers (uv, twine, poetry, flit) and Go has
@@ -1477,6 +2096,26 @@ const PUBLISH_BY_MANIFEST = {
1477
2096
  'Cargo.toml': 'cargo publish',
1478
2097
  }
1479
2098
 
2099
+ /**
2100
+ * A manifest that must not be published says so in itself. Detection honours that: a
2101
+ * private package or an unpublishable crate is one this repository releases by tag alone,
2102
+ * and detecting a command for it would attempt the one thing the manifest forbids.
2103
+ */
2104
+ function detectablePublish(path) {
2105
+ if (basename(path) === 'package.json') return !readJson(path).private
2106
+ if (basename(path) === 'Cargo.toml') {
2107
+ const text = readFileSync(path, 'utf8')
2108
+ if (/^publish\s*=\s*false/m.test(text)) return false
2109
+ // A crate built only as a cdylib is a native extension module — what maturin and
2110
+ // napi-rs compile into a wheel or a .node — not a library anyone depends on from
2111
+ // crates.io. Its version travels with the package it is built into, which is why the
2112
+ // two match; publishing it to crates.io is the one thing nobody asked for.
2113
+ const crateTypes = /^crate-type\s*=\s*\[([^\]]*)\]/m.exec(text)?.[1]
2114
+ if (crateTypes?.includes('cdylib') && !crateTypes.includes('rlib')) return false
2115
+ }
2116
+ return true
2117
+ }
2118
+
1480
2119
  // An explicit `versionFile: null` means "versions by tag" and must not be re-detected, so
1481
2120
  // the distinction is between the key being absent and the key being set to null.
1482
2121
  const configuredVersionFile = Object.hasOwn(userConfig, 'versionFile')
@@ -1502,11 +2141,7 @@ if (versionFile && !existsSync(versionFile.path)) {
1502
2141
  * the version has to be typed out in full every time.
1503
2142
  */
1504
2143
  function versionFromLastTag() {
1505
- const tag = tryRead('git', ['describe', '--tags', '--abbrev=0'])
1506
- if (!tag) return null
1507
- const bare =
1508
- config.tagPrefix && tag.startsWith(config.tagPrefix) ? tag.slice(config.tagPrefix.length) : tag
1509
- return parseVersion(bare) ? bare : null
2144
+ return releaseTags()[0]?.version ?? null
1510
2145
  }
1511
2146
 
1512
2147
  const currentVersion = versionFile ? readVersionFrom(versionFile) : versionFromLastTag()
@@ -1524,13 +2159,73 @@ const goModule = existsSync('go.mod')
1524
2159
  const projectName =
1525
2160
  manifest?.name ?? (versionFile ? readNameFrom(versionFile) : null) ?? goModule ?? basename(root)
1526
2161
 
2162
+ /**
2163
+ * Manifests found beside the primary one that carry the same version. Detected only when
2164
+ * the project has said nothing about either key: a project that listed its own
2165
+ * `versionFiles` has already answered this question, and quietly appending to that answer
2166
+ * would release files it deliberately left out.
2167
+ */
2168
+ const detecting =
2169
+ !!currentVersion &&
2170
+ !Object.hasOwn(userConfig, 'versionFile') &&
2171
+ !Object.hasOwn(userConfig, 'versionFiles')
2172
+ const companionFiles = detecting
2173
+ ? [
2174
+ ...(versionFile ? detectCompanionFiles(versionFile.path, currentVersion) : []),
2175
+ ...detectVersionMirrors(currentVersion),
2176
+ ]
2177
+ : []
2178
+ if (companionFiles.length) {
2179
+ config.versionFiles = companionFiles
2180
+ note(
2181
+ `also versioned in ${companionFiles.map((entry) => versionSource(entry).path).join(', ')} ` +
2182
+ '(detected)',
2183
+ )
2184
+ }
2185
+
2186
+ /**
2187
+ * Every file the version is written into: the source of truth first, then the files kept
2188
+ * in step with it. A repository that versions by tag alone has no source of truth here and
2189
+ * may still have mirrors to write — a Go module's `version.go` is exactly that — so this
2190
+ * is what the version step works from, rather than `versionFile` being required.
2191
+ */
2192
+ /**
2193
+ * Every file the version is written into, with `*` in a `versionFiles` path expanded to
2194
+ * the files it matches.
2195
+ *
2196
+ * A pattern matching nothing is an error rather than a quiet skip: it was written to keep
2197
+ * files in step, and silently keeping none of them in step is the failure it was meant to
2198
+ * prevent. `versionFile` is never globbed — the source of truth is one file, and a glob
2199
+ * that resolved to two would make which one wins an accident of directory order.
2200
+ */
2201
+ const versionTargets = [
2202
+ ...(versionFile ? [versionSource(versionFile)] : []),
2203
+ ...config.versionFiles.map(versionSource).flatMap((source) => {
2204
+ if (!source.path.includes('*')) return [source]
2205
+ const matched = expandPaths(source.path)
2206
+ if (!matched.length) abort(`versionFiles pattern ${source.path} matched no files`)
2207
+ // A glob says "every file of this shape", and some of them legitimately carry no
2208
+ // version — a Tauri per-OS overlay holds only the keys it overrides. Being unable to
2209
+ // write one is expected here, unlike a path someone named on purpose.
2210
+ return matched.map((path) => Object.assign({}, source, { path, optional: true }))
2211
+ }),
2212
+ ]
2213
+
1527
2214
  // Same rule as versionFile: an explicit `publish: null` means "publish nothing" and is
1528
2215
  // never re-detected. Unset means "work it out", and working it out can yield nothing.
1529
2216
  if (!Object.hasOwn(userConfig, 'publish')) {
1530
2217
  // Preflight already reports "no publish command configured" when the step runs, so
1531
2218
  // there is nothing to say here — and `runs` is not resolved this early.
1532
- config.publish =
1533
- (versionFile ? PUBLISH_BY_MANIFEST[basename(versionFile.path)] : undefined) ?? null
2219
+ //
2220
+ // Two manifests releasing in step means two registries: the npm command comes first
2221
+ // because it is the recoverable one — npm allows an unpublish for 72 hours, crates.io
2222
+ // never does — so a half-finished publish leaves the undoable half undone.
2223
+ const detected = [versionFile, ...companionFiles]
2224
+ .filter(Boolean)
2225
+ .map((entry) => versionSource(entry).path)
2226
+ .filter((path) => PUBLISH_BY_MANIFEST[basename(path)] && detectablePublish(path))
2227
+ .map((path) => PUBLISH_BY_MANIFEST[basename(path)])
2228
+ config.publish = detected.length ? detected : null
1534
2229
  }
1535
2230
 
1536
2231
  // Validate every name that was asked for, not just the ones that survive: a typo in
@@ -1589,7 +2284,7 @@ const assistant = assistantName ? ASSISTANTS[assistantName] : null
1589
2284
  // RESOLVE THE TARGET VERSION
1590
2285
  // ─────────────────────────────────────────────────────────────────────────────
1591
2286
 
1592
- console.log(
2287
+ say(
1593
2288
  bold(`${projectName} release`) +
1594
2289
  (dryRun ? ` ${yellow('(dry run — nothing will execute)')}` : ''),
1595
2290
  )
@@ -1653,8 +2348,13 @@ if (!target) {
1653
2348
  }
1654
2349
 
1655
2350
  const tag = `${config.tagPrefix}${version}`
2351
+ if (PRINT_ONLY) {
2352
+ console.log(version)
2353
+ process.exit(0)
2354
+ }
2355
+
1656
2356
  const isPrerelease = parseVersion(version).pre.length > 0
1657
- const bumping = !!versionFile && version !== currentVersion && runs('version')
2357
+ const bumping = versionTargets.length > 0 && version !== currentVersion && runs('version')
1658
2358
 
1659
2359
  let distTag
1660
2360
  try {
@@ -1681,7 +2381,43 @@ const expand = (template) => expandWith(template, (value) => value)
1681
2381
  const shellQuote = (value) => `'${value.replaceAll("'", `'\\''`)}'`
1682
2382
  const expandShell = (template) => expandWith(template, shellQuote)
1683
2383
 
1684
- const publishCommand = runs('publish') && config.publish ? expandShell(config.publish) : null
2384
+ /**
2385
+ * Run a lifecycle hook, if the project configured one.
2386
+ *
2387
+ * Anything a hook leaves modified is staged for the release commit. Preflight has already
2388
+ * established that the tree was clean (or that the `commit` step is committing all of it),
2389
+ * so a file that is dirty now was produced by this release and belongs in it — which is
2390
+ * what makes `afterVersion` useful for regenerating a file derived from the version.
2391
+ *
2392
+ * @param {string} name one of HOOKS
2393
+ */
2394
+ function runHook(name) {
2395
+ const configured = config.hooks?.[name]
2396
+ if (!configured) return
2397
+ const commands = Array.isArray(configured) ? configured : [configured]
2398
+ step(`Hook ${name}`)
2399
+ for (const command of commands) mutateShell(expandShell(command))
2400
+ if (dryRun) return
2401
+ for (const path of dirtyPaths()) if (!staged.includes(path)) staged.push(path)
2402
+ }
2403
+
2404
+ /**
2405
+ * Paths git reports as changed, whatever the change is.
2406
+ *
2407
+ * The status column cannot be sliced at a fixed offset: the capture is trimmed, which
2408
+ * strips the leading space off the first entry only, so ` M file` arrives as `M file`
2409
+ * while the rest keep theirs. Split on the gap after the code instead.
2410
+ */
2411
+ function dirtyPaths() {
2412
+ return (
2413
+ (tryRead('git', ['status', '--porcelain']) ?? '')
2414
+ .split('\n')
2415
+ .map((line) => /^\s*\S{1,2}\s+(.+)$/.exec(line)?.[1]?.trim())
2416
+ .filter(Boolean)
2417
+ // A rename reads as "old -> new"; the new path is the one to stage.
2418
+ .map((path) => path.split(' -> ').at(-1))
2419
+ )
2420
+ }
1685
2421
 
1686
2422
  /**
1687
2423
  * Registries whose preflight can be run, keyed by the first word of the publish command.
@@ -1700,13 +2436,58 @@ const REGISTRIES = {
1700
2436
  // uv authenticates with a token from the environment rather than a logged-in session,
1701
2437
  // and skips duplicate uploads itself via --check-url, so there is no version lookup.
1702
2438
  uv: { env: ['UV_PUBLISH_TOKEN', 'UV_PUBLISH_PASSWORD'], login: 'set UV_PUBLISH_TOKEN' },
2439
+ // cargo has no "who am I": crates.io auth is a token, either in the environment or in
2440
+ // the credentials file `cargo login` writes. `cargo info` is the version lookup, and
2441
+ // exits non-zero for a version the index does not carry (cargo 1.82+).
2442
+ cargo: {
2443
+ env: ['CARGO_REGISTRY_TOKEN', 'CARGO_REGISTRIES_CRATES_IO_TOKEN'],
2444
+ credentials: [
2445
+ join(homedir(), '.cargo', 'credentials.toml'),
2446
+ join(homedir(), '.cargo', 'credentials'),
2447
+ ],
2448
+ login: 'run `cargo login`, or set CARGO_REGISTRY_TOKEN',
2449
+ published: (name, v) => ['info', `${name}@${v}`],
2450
+ },
1703
2451
  // For Go the tag is the release; `go list` warms the module proxy and doubles as the
1704
2452
  // check for whether this version is already resolvable.
1705
2453
  go: { published: (name, v) => ['list', '-m', `${name}@${v}`] },
1706
2454
  }
1707
2455
 
1708
- const publishCli = publishCommand?.trim().split(/\s+/)[0]
1709
- const registry = publishCli ? REGISTRIES[publishCli] : null
2456
+ /**
2457
+ * Which manifest records the name a registry knows this project by, when it is not the one
2458
+ * `projectName` came from. They are not always the same string: a Tauri plugin publishes as
2459
+ * `@tauri-apps/plugin-x` on npm and `tauri-plugin-x` on crates.io, so looking the crate up
2460
+ * under its npm name would report every version as unpublished.
2461
+ */
2462
+ const NAME_MANIFEST_BY_CLI = { cargo: 'Cargo.toml' }
2463
+
2464
+ function registryName(cli) {
2465
+ const manifest = NAME_MANIFEST_BY_CLI[cli]
2466
+ if (!manifest) return projectName
2467
+ const source = versionTargets.find((entry) => basename(entry.path) === manifest)
2468
+ return (source && readNameFrom(source)) ?? projectName
2469
+ }
2470
+
2471
+ /**
2472
+ * `publish` is one command or several, because one source tree can own a package in more
2473
+ * than one ecosystem. They run in the configured order.
2474
+ */
2475
+ const publishList = config.publish == null ? [] : [config.publish].flat()
2476
+ if (publishList.some((entry) => typeof entry !== 'string')) {
2477
+ abort('publish must be a command string, an array of command strings, or null')
2478
+ }
2479
+
2480
+ /** Each publish command with the CLI it drives, that CLI's preflight row, and its name. */
2481
+ const publishTargets = runs('publish')
2482
+ ? publishList.map((template) => {
2483
+ const command = expandShell(template)
2484
+ const cli = command.trim().split(/\s+/)[0]
2485
+ return { command, cli, registry: REGISTRIES[cli] ?? null, name: registryName(cli) }
2486
+ })
2487
+ : []
2488
+
2489
+ /** npm-family commands are the ones a `"private": true` package.json forbids. */
2490
+ const NPM_CLIS = new Set(['npm', 'pnpm', 'bun'])
1710
2491
 
1711
2492
  /**
1712
2493
  * CI publishing over OIDC ("trusted publishing") carries no token at all: `whoami` fails
@@ -1751,10 +2532,35 @@ if (autoBump) {
1751
2532
  }
1752
2533
  }
1753
2534
 
1754
- if (bumping && compareVersions(version, currentVersion) <= 0) {
2535
+ // Writing the version is the first mutating step, and it used to discover a file it
2536
+ // could not write *while writing the others* — aborting with a raw stack trace after
2537
+ // some of them had already changed. Every target is checked here instead.
2538
+ if (bumping) {
2539
+ for (const source of versionTargets) {
2540
+ if (!existsSync(source.path)) {
2541
+ fail(`versionFiles entry ${source.path} does not exist`)
2542
+ continue
2543
+ }
2544
+ if (source.optional) continue
2545
+ const text = readFileSync(source.path, 'utf8')
2546
+ const { kind, shape } = versionMode(source, text)
2547
+ if (kind === 'pattern' && !shape.test(text)) {
2548
+ fail(
2549
+ `${source.path} has no version for release-kit to replace.\n` +
2550
+ ' Remove it from versionFiles, mark the line with x-release-kit-version, ' +
2551
+ 'or give the entry a "pattern".',
2552
+ )
2553
+ }
2554
+ }
2555
+ }
2556
+
2557
+ if (bumping && currentVersion && compareVersions(version, currentVersion) <= 0) {
1755
2558
  fail(`${version} is not greater than the current version ${currentVersion}`)
1756
- } else if (bumping) {
2559
+ } else if (bumping && currentVersion) {
1757
2560
  ok(`version ${currentVersion} → ${version}`)
2561
+ } else if (bumping) {
2562
+ // No manifest and no tag to read a version from, but files to write one into.
2563
+ ok(`writing ${version} into ${versionTargets.map((source) => source.path).join(', ')}`)
1758
2564
  } else if (versionFile) {
1759
2565
  ok(`releasing the version already in ${versionFile.path} (${version})`)
1760
2566
  } else {
@@ -1848,7 +2654,7 @@ if (!succeeds('git', ['remote', 'get-url', config.remote])) {
1848
2654
  // that is a failure; commit-derived notes merely come out partial, so that is a warning.
1849
2655
  let shallowHidesHistory = false
1850
2656
  if (shallow) {
1851
- const reachableTag = tryRead('git', ['describe', '--tags', '--abbrev=0'])
2657
+ const reachableTag = lastReleaseTag()
1852
2658
  shallowHidesHistory = !reachableTag
1853
2659
  if (reachableTag) {
1854
2660
  ok(
@@ -1953,37 +2759,70 @@ if (!runs('release')) {
1953
2759
  if (releaseExists) note(`a GitHub release for ${tag} already exists — will skip that step`)
1954
2760
  }
1955
2761
 
1956
- let alreadyPublished = false
1957
- if (!publishCommand) {
1958
- note(runs('publish') ? 'no publish command configured' : 'publish step not selected')
1959
- } else if (manifest?.private) {
1960
- fail('package.json is private but a publish command is configured')
1961
- } else if (!registry) {
1962
- ok(`publish: ${publishCommand}`)
1963
- } else {
2762
+ /** Whether one publish CLI can publish at all: OIDC, a token, or a live session. */
2763
+ function checkCredentials({ cli, registry, command }) {
1964
2764
  if (isTrustedPublishing) {
1965
- ok('trusted publishing (OIDC) — no token needed')
1966
- } else if (registry.env) {
1967
- // Token-in-the-environment auth: there is no session to interrogate, only credentials.
2765
+ ok(`${cli}: trusted publishing (OIDC) — no token needed`)
2766
+ // Provenance is the other half of what OIDC makes possible: a signed attestation
2767
+ // tying the published artefact to the workflow and commit that produced it. It is
2768
+ // not added to the command here — npm generates it for a trusted publish on its own,
2769
+ // and forcing the flag fails outright for a private package or a registry that
2770
+ // cannot receive one. Saying so is what turns "available" into "used".
2771
+ if (NPM_CLIS.has(cli) && !/--provenance\b/.test(command ?? '')) {
2772
+ note(
2773
+ `${cli}: OIDC also allows a signed provenance attestation — add --provenance to ` +
2774
+ 'the publish command if the registry accepts one and the package is public',
2775
+ )
2776
+ }
2777
+ return
2778
+ }
2779
+ if (registry.env) {
2780
+ // Token auth: there is no session to interrogate, only credentials to find.
1968
2781
  const found = registry.env.find((name) => process.env[name])
1969
- if (found) ok(`${publishCli} credentials found (${found})`)
1970
- else fail(`${publishCli} has no publish credentials — ${registry.login}`)
1971
- } else if (registry.whoami) {
1972
- const user = tryRead(publishCli, registry.whoami)
2782
+ if (found) {
2783
+ ok(`${cli} credentials found (${found})`)
2784
+ return
2785
+ }
2786
+ const file = registry.credentials?.find((path) => existsSync(path))
2787
+ if (file) ok(`${cli} credentials found (${file})`)
2788
+ else fail(`${cli} has no publish credentials — ${registry.login}`)
2789
+ return
2790
+ }
2791
+ if (registry.whoami) {
2792
+ const user = tryRead(cli, registry.whoami)
1973
2793
  if (user === null) {
1974
2794
  // npm replaced long-lived tokens with two-hour sessions in December 2025, so the
1975
2795
  // usual cause is an expired session rather than a missing login.
1976
2796
  fail(
1977
- `${publishCli} is not authenticated — run \`${publishCli} login\`. ` +
2797
+ `${cli} is not authenticated — run \`${cli} login\`. ` +
1978
2798
  'npm logins are two-hour sessions, so an earlier one may have expired.',
1979
2799
  )
1980
- } else ok(`${publishCli} authenticated (${user || 'unknown user'})`)
2800
+ } else ok(`${cli} authenticated (${user || 'unknown user'})`)
1981
2801
  }
2802
+ }
1982
2803
 
1983
- if (registry.published) {
1984
- alreadyPublished = succeeds(publishCli, registry.published(projectName, version))
1985
- if (alreadyPublished) {
1986
- note(`${projectName}@${version} is already published — will skip the publish step`)
2804
+ /** Commands whose version is already on the registry, so the publish step skips them. */
2805
+ const alreadyPublished = new Set()
2806
+ if (!publishTargets.length) {
2807
+ note(runs('publish') ? 'no publish command configured' : 'publish step not selected')
2808
+ } else if (manifest?.private && publishTargets.some((target) => NPM_CLIS.has(target.cli))) {
2809
+ fail('package.json is private but an npm publish command is configured')
2810
+ } else {
2811
+ // One CLI can appear more than once; interrogating it twice says the same thing twice.
2812
+ const authenticated = new Set()
2813
+ for (const target of publishTargets) {
2814
+ ok(`publish: ${target.command}`)
2815
+ if (!target.registry) continue
2816
+ if (!authenticated.has(target.cli)) {
2817
+ authenticated.add(target.cli)
2818
+ checkCredentials(target)
2819
+ }
2820
+ if (
2821
+ target.registry.published &&
2822
+ succeeds(target.cli, target.registry.published(target.name, version))
2823
+ ) {
2824
+ alreadyPublished.add(target.command)
2825
+ note(`${target.name}@${version} is already published — will skip \`${target.command}\``)
1987
2826
  }
1988
2827
  }
1989
2828
  }
@@ -2027,13 +2866,28 @@ let draftedNotes = null
2027
2866
  */
2028
2867
  const notesDeferred = !!(dirty && runs('commit'))
2029
2868
 
2869
+ /**
2870
+ * Set only when preflight found nothing to release with and left the drafting to the
2871
+ * post-commit step. Deferral has to be recorded rather than re-derived from `notesDeferred`
2872
+ * there: a dirty tree is what makes drafting possible to defer, not what makes it necessary.
2873
+ * A hand-written changelog section has already answered the question, and re-drafting over
2874
+ * it would discard the notes the confirmation prompt showed and append a second section for
2875
+ * the same version.
2876
+ */
2877
+ let notesPending = false
2878
+
2030
2879
  /**
2031
2880
  * Notes for a version, in descending order of how much they can be trusted:
2032
2881
  * an assistant's prose when one is configured, otherwise the commits grouped by
2033
2882
  * Conventional Commit type. Only when neither yields anything does GitHub generate them.
2034
2883
  */
2035
2884
  function draftNotesFor(v) {
2036
- const { lastTag, subjects, commits } = commitsSinceLastTag()
2885
+ // A stable release absorbs the candidates that led to it: their commits are what it
2886
+ // ships, and reading from the last candidate leaves the notes describing the gap
2887
+ // between two candidates rather than the release.
2888
+ const { lastTag, subjects, commits, contributors } = commitsSinceLastTag({
2889
+ stable: !isPrerelease,
2890
+ })
2037
2891
  if (!commits.length) return null
2038
2892
 
2039
2893
  // Notes are built from Conventional Commits, so anything not written that way is simply
@@ -2048,7 +2902,12 @@ function draftNotesFor(v) {
2048
2902
  }
2049
2903
  // An explicitly named source wins over the assistant being merely available.
2050
2904
  if (!assistant || notesSource === 'commits')
2051
- return changelogFromCommits(commits, remoteLinks(config.remote), config.hiddenTypes)
2905
+ return changelogFromCommits(
2906
+ commits,
2907
+ remoteLinks(config.remote),
2908
+ config.hiddenTypes,
2909
+ contributors,
2910
+ )
2052
2911
  if (shallowHidesHistory) {
2053
2912
  warn(
2054
2913
  `shallow clone: only ${subjects.length} commit(s) are visible, so the notes will ` +
@@ -2058,7 +2917,7 @@ function draftNotesFor(v) {
2058
2917
  note(`drafting notes from ${subjects.length} commit(s) with ${assistantName}...`)
2059
2918
  return (
2060
2919
  draftReleaseNotes(v, commits, lastTag, remoteLinks(config.remote)) ??
2061
- changelogFromCommits(commits, remoteLinks(config.remote), config.hiddenTypes)
2920
+ changelogFromCommits(commits, remoteLinks(config.remote), config.hiddenTypes, contributors)
2062
2921
  )
2063
2922
  }
2064
2923
  const changelogText =
@@ -2091,6 +2950,7 @@ if (notesSource === 'github') {
2091
2950
  // Generate, either because nothing was written or because a source was named.
2092
2951
  if (!notes) {
2093
2952
  if (notesDeferred) {
2953
+ notesPending = true
2094
2954
  ok(`release notes will be ${assistant ? 'drafted' : 'generated'} after the commit`)
2095
2955
  } else {
2096
2956
  draftedNotes = draftNotesFor(version)
@@ -2211,33 +3071,38 @@ if (dirty && runs('commit')) {
2211
3071
  else mutate('git', ['commit', '-m', commitMessage])
2212
3072
 
2213
3073
  // Now that the commit exists it is part of the release, so the notes can describe it.
2214
- if (notesDeferred && !dryRun) {
3074
+ if (notesPending && !dryRun) {
2215
3075
  draftedNotes = draftNotesFor(version)
2216
3076
  if (draftedNotes) notes = draftedNotes
2217
3077
  }
2218
3078
  }
2219
3079
 
3080
+ runHook('beforeVersion')
3081
+
2220
3082
  if (bumping) {
2221
3083
  step(`Write version ${version}`)
2222
- for (const entry of [versionFile, ...config.versionFiles]) {
2223
- const source = versionSource(entry)
3084
+ const releaseDate = new Date().toISOString().slice(0, 10)
3085
+ for (const source of versionTargets) {
2224
3086
  if (!existsSync(source.path)) abort(`versionFiles entry ${source.path} does not exist`)
2225
- if (writeVersionInto(source, version, { dryRun })) {
3087
+ if (writeVersionInto(source, version, { dryRun, date: releaseDate })) {
2226
3088
  staged.push(source.path)
2227
3089
  console.log(` ${dryRun ? yellow('would write') : dim('wrote')} ${source.path}`)
2228
3090
  }
2229
3091
  }
2230
- // A package-lock.json embeds the root version twice, so it goes stale on a bump.
2231
- if (existsSync('package-lock.json')) {
2232
- mutate('npm', ['install', '--package-lock-only', '--ignore-scripts', '--silent'])
2233
- staged.push('package-lock.json')
2234
- }
3092
+ refreshLockfiles(versionTargets.map((source) => source.path))
2235
3093
  }
2236
3094
 
3095
+ // After the version is on disk and before the release commit, so a file the hook
3096
+ // regenerates from the version rides in that commit rather than being left behind.
3097
+ runHook('afterVersion')
3098
+
3099
+ /** The version headings a changelog carries are dead link references without these. */
3100
+ const linked = (text) => withChangelogLinks(text, remoteLinks(config.remote), config.tagPrefix)
3101
+
2237
3102
  if (rolledChangelog && runs('changelog')) {
2238
3103
  step(`Roll ${config.changelog} to ${version}`)
2239
3104
  if (dryRun) console.log(` ${yellow('would write')} ${config.changelog}`)
2240
- else writeFileSync(config.changelog, rolledChangelog)
3105
+ else writeFileSync(config.changelog, linked(rolledChangelog))
2241
3106
  staged.push(config.changelog)
2242
3107
  } else if (runs('changelog') && draftedNotes && config.changelog && existsSync(config.changelog)) {
2243
3108
  step(`Add the drafted ${version} section to ${config.changelog}`)
@@ -2245,11 +3110,13 @@ if (rolledChangelog && runs('changelog')) {
2245
3110
  else {
2246
3111
  writeFileSync(
2247
3112
  config.changelog,
2248
- insertChangelogSection(
2249
- readFileSync(config.changelog, 'utf8'),
2250
- version,
2251
- new Date().toISOString().slice(0, 10),
2252
- draftedNotes,
3113
+ linked(
3114
+ insertChangelogSection(
3115
+ readFileSync(config.changelog, 'utf8'),
3116
+ version,
3117
+ new Date().toISOString().slice(0, 10),
3118
+ draftedNotes,
3119
+ ),
2253
3120
  ),
2254
3121
  )
2255
3122
  }
@@ -2294,15 +3161,25 @@ if (runs('tag') && !taggedCommit) {
2294
3161
 
2295
3162
  if (runs('push')) {
2296
3163
  step(`Push branch and tag to ${config.remote}`)
2297
- // --follow-tags sends the commit and the tag in one call; pushing them separately is how
2298
- // a tag ends up on the remote without its commit, or a release without its tag.
2299
- mutate('git', ['push', '--follow-tags', config.remote, branch ?? 'HEAD'])
3164
+ pushBranchAndTag(branch ?? 'HEAD')
2300
3165
  }
2301
3166
 
2302
- if (publishCommand && !alreadyPublished) {
2303
- step(`Publish to the registry (dist-tag ${distTag})`)
2304
- mutateShell(publishCommand)
3167
+ // After the tag is pushed and before anything is published: the point where an artefact
3168
+ // the publish command expects to find has to exist.
3169
+ if (publishTargets.length) runHook('beforePublish')
3170
+
3171
+ let publishedSomething = false
3172
+ for (const target of publishTargets) {
3173
+ if (alreadyPublished.has(target.command)) continue
3174
+ step(
3175
+ `Publish ${target.name} (${target.cli}${NPM_CLIS.has(target.cli) ? `, dist-tag ${distTag}` : ''})`,
3176
+ )
3177
+ mutateShell(target.command)
3178
+ publishedSomething = true
2305
3179
  }
3180
+ // Only when something was actually published: a re-run that skipped every already-published
3181
+ // target published nothing, and telling downstream otherwise is a lie it may act on.
3182
+ if (publishedSomething) runHook('afterPublish')
2306
3183
 
2307
3184
  if (runs('release') && !releaseExists) {
2308
3185
  step(`GitHub release ${tag}`)
@@ -2318,6 +3195,7 @@ if (runs('release') && !releaseExists) {
2318
3195
  ...config.assets,
2319
3196
  ]
2320
3197
  mutate('gh', args, notes ? { input: `${notes}\n` } : {})
3198
+ runHook('afterRelease')
2321
3199
  }
2322
3200
 
2323
3201
  /**
@@ -2337,7 +3215,7 @@ function emitOutputs() {
2337
3215
  name: projectName,
2338
3216
  'dist-tag': distTag,
2339
3217
  steps: STEPS.filter(runs).join(','),
2340
- published: String(!!publishCommand && !alreadyPublished),
3218
+ published: String(publishTargets.some((target) => !alreadyPublished.has(target.command))),
2341
3219
  'release-url': releaseUrl,
2342
3220
  }
2343
3221
  try {