@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.
- package/README.md +256 -45
- package/TRAIN.md +13 -0
- package/package.json +1 -1
- package/release.mjs +994 -116
- 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
|
-
*
|
|
66
|
-
*
|
|
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
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
/**
|
|
415
|
-
|
|
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 =
|
|
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
|
|
422
|
-
|
|
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 {
|
|
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 {
|
|
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': {
|
|
590
|
-
|
|
591
|
-
|
|
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
|
|
616
|
-
issue:
|
|
617
|
-
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
|
-
|
|
1163
|
-
|
|
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
|
|
1167
|
-
`pattern:\n { "path": "${lockPath}",
|
|
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
|
-
|
|
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
|
|
1188
|
-
if (
|
|
1189
|
-
|
|
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
|
-
*
|
|
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
|
|
1714
|
+
const { kind, shape } = versionMode(source, text)
|
|
1204
1715
|
|
|
1205
1716
|
let updated
|
|
1206
|
-
if (pattern) {
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1317
|
-
|
|
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 =
|
|
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
|
-
*
|
|
1459
|
-
*
|
|
1460
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1533
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
1709
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
1957
|
-
|
|
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(
|
|
1966
|
-
|
|
1967
|
-
//
|
|
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)
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
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
|
-
`${
|
|
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(`${
|
|
2800
|
+
} else ok(`${cli} authenticated (${user || 'unknown user'})`)
|
|
1981
2801
|
}
|
|
2802
|
+
}
|
|
1982
2803
|
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
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
|
-
|
|
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(
|
|
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 (
|
|
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
|
-
|
|
2223
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2303
|
-
|
|
2304
|
-
|
|
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(
|
|
3218
|
+
published: String(publishTargets.some((target) => !alreadyPublished.has(target.command))),
|
|
2341
3219
|
'release-url': releaseUrl,
|
|
2342
3220
|
}
|
|
2343
3221
|
try {
|