@entro314labs/release-kit 2.8.0 → 2.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +292 -50
- package/TRAIN.md +13 -0
- package/package.json +1 -1
- package/release.mjs +1257 -159
- package/train.mjs +18 -5
package/release.mjs
CHANGED
|
@@ -24,7 +24,10 @@
|
|
|
24
24
|
* - Every step is idempotent. A run interrupted partway through (a publish timeout, a
|
|
25
25
|
* network failure) can be re-run: an already-written version, an existing tag at HEAD,
|
|
26
26
|
* an already-published version and an existing release are each detected and skipped.
|
|
27
|
-
* There is no cleanup step and no --resume flag.
|
|
27
|
+
* There is no cleanup step and no --resume flag. `auto` re-run that way finishes the
|
|
28
|
+
* unpublished release rather than reporting nothing to do, and once history has moved
|
|
29
|
+
* on past it, the version that does ship carries its commits — a tag is not a release,
|
|
30
|
+
* and work that never reached a registry is still unreleased.
|
|
28
31
|
*
|
|
29
32
|
* Configuration is optional. Defaults are the conventions (package.json version,
|
|
30
33
|
* CHANGELOG.md, main branch, `v` tag prefix, npm publish); a release.config.json beside
|
|
@@ -37,6 +40,7 @@ import {
|
|
|
37
40
|
existsSync,
|
|
38
41
|
mkdirSync,
|
|
39
42
|
mkdtempSync,
|
|
43
|
+
readdirSync,
|
|
40
44
|
readFileSync,
|
|
41
45
|
writeFileSync,
|
|
42
46
|
} from 'node:fs'
|
|
@@ -61,9 +65,16 @@ import { createInterface } from 'node:readline/promises'
|
|
|
61
65
|
* versionFile string|object|null where the project's version lives. Detected from
|
|
62
66
|
* the repository when unset; null when it versions by tag alone
|
|
63
67
|
* versionFiles array further files whose version is kept in sync; each is a path
|
|
64
|
-
* or { path, pattern }
|
|
65
|
-
*
|
|
66
|
-
*
|
|
68
|
+
* or { path, pattern }. Written even where versionFile is null,
|
|
69
|
+
* which is how a language with no version of its own — a Go
|
|
70
|
+
* module — keeps one in source. When neither this nor
|
|
71
|
+
* versionFile is configured, a second root manifest and a
|
|
72
|
+
* conventional version constant already on the same version are
|
|
73
|
+
* detected and kept in step
|
|
74
|
+
* publish string|string[] publish command, or several for a project that
|
|
75
|
+
* releases to more than one registry. Detected from the version
|
|
76
|
+
* sources when unset, and only where unambiguous; null to
|
|
77
|
+
* publish nothing
|
|
67
78
|
* commitMessage string release commit subject
|
|
68
79
|
* releaseTitle string GitHub release title
|
|
69
80
|
* assets string[] files attached to the GitHub release
|
|
@@ -91,11 +102,11 @@ import { createInterface } from 'node:readline/promises'
|
|
|
91
102
|
* committing, is a mistake the tool should not let you express.
|
|
92
103
|
*
|
|
93
104
|
* commit commit a dirty working tree (opt-in; touches work that predates the release)
|
|
94
|
-
* version write the version into
|
|
105
|
+
* version write the version into the version source and versionFiles
|
|
95
106
|
* changelog roll [Unreleased] into the version, or add drafted notes
|
|
96
107
|
* tag annotated git tag carrying the release notes
|
|
97
108
|
* push push the branch and the tag together
|
|
98
|
-
* publish run the configured publish command
|
|
109
|
+
* publish run the configured publish command(s), in order
|
|
99
110
|
* release create the GitHub release
|
|
100
111
|
*
|
|
101
112
|
* `version` and `changelog` write files; those writes are persisted by a release commit
|
|
@@ -134,8 +145,24 @@ const DEFAULTS = {
|
|
|
134
145
|
'^(fixup|squash)!',
|
|
135
146
|
],
|
|
136
147
|
verify: null,
|
|
148
|
+
hooks: {},
|
|
137
149
|
}
|
|
138
150
|
|
|
151
|
+
/**
|
|
152
|
+
* The points a project can hang its own commands on, in the order they run.
|
|
153
|
+
*
|
|
154
|
+
* `verify` already covers the one gate that matters most — the project's own tests, run
|
|
155
|
+
* during preflight before anything mutates. What it cannot express is work that has to
|
|
156
|
+
* happen *between* the release's own steps: regenerating a file derived from the version,
|
|
157
|
+
* building an artefact the publish command expects to find, telling something downstream
|
|
158
|
+
* that a release landed.
|
|
159
|
+
*
|
|
160
|
+
* They are command lines rather than callbacks because the config is JSON, and they take
|
|
161
|
+
* the same `%v` `%t` `%n` `%d` tokens the publish command does. A non-zero exit aborts the
|
|
162
|
+
* release exactly where it happened, which is the point of running them there.
|
|
163
|
+
*/
|
|
164
|
+
const HOOKS = ['beforeVersion', 'afterVersion', 'beforePublish', 'afterPublish', 'afterRelease']
|
|
165
|
+
|
|
139
166
|
/**
|
|
140
167
|
* Prerelease identifiers that map to their own npm dist-tag. An identifier outside this
|
|
141
168
|
* set has no safe home, so `distTagFor` refuses rather than letting a prerelease fall
|
|
@@ -165,7 +192,11 @@ Target (optional; defaults to the version already in package.json):
|
|
|
165
192
|
Steps, in the fixed order they run. All but "commit" run by default:
|
|
166
193
|
${STEPS.join(' ')}
|
|
167
194
|
|
|
168
|
-
Subcommands (they check or copy, and never start a release):
|
|
195
|
+
Subcommands (they check, print or copy, and never start a release):
|
|
196
|
+
next [<version>|<bump>]
|
|
197
|
+
print the version that target would release, and stop.
|
|
198
|
+
Only the version reaches stdout, so it substitutes:
|
|
199
|
+
VERSION=$(release-kit next auto)
|
|
169
200
|
lint-commits [<range>]
|
|
170
201
|
check commit subjects against Conventional Commits
|
|
171
202
|
(default range: since the last tag)
|
|
@@ -210,9 +241,20 @@ const yellow = (s) => paint('33', s)
|
|
|
210
241
|
|
|
211
242
|
let stepNumber = 0
|
|
212
243
|
const step = (title) => console.log(`\n${bold(`[${++stepNumber}] ${title}`)}`)
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
244
|
+
/**
|
|
245
|
+
* `next` exists to be substituted into a shell command, so its stdout must carry the
|
|
246
|
+
* version and nothing else. Everything the release would narrate still gets said — on
|
|
247
|
+
* stderr, where a human reads it and `$(...)` does not.
|
|
248
|
+
*/
|
|
249
|
+
const PRINT_ONLY = process.argv[2] === 'next'
|
|
250
|
+
const say = (line) => {
|
|
251
|
+
if (PRINT_ONLY) process.stderr.write(`${line}\n`)
|
|
252
|
+
else console.log(line)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const ok = (message) => say(` ${green('ok')} ${message}`)
|
|
256
|
+
const warn = (message) => say(` ${yellow('warn')} ${message}`)
|
|
257
|
+
const note = (message) => say(` ${dim(message)}`)
|
|
216
258
|
const indent = (text) =>
|
|
217
259
|
text
|
|
218
260
|
.split('\n')
|
|
@@ -315,7 +357,28 @@ const ASSISTANTS = {
|
|
|
315
357
|
},
|
|
316
358
|
codex: {
|
|
317
359
|
command: 'codex',
|
|
318
|
-
|
|
360
|
+
// A draft needs a bare model, but `codex exec` boots the user's whole session by
|
|
361
|
+
// default — plugins (with their MCP servers, hooks and skills), memories, apps and a
|
|
362
|
+
// notify program — several thousand tokens of context and seconds of startup that a
|
|
363
|
+
// one-shot prose prompt never uses. All four features are stable flags; an unknown
|
|
364
|
+
// flag on some future codex makes the draft fail closed into the deterministic
|
|
365
|
+
// fallback, which is this tool's contract for every assistant failure.
|
|
366
|
+
args: [
|
|
367
|
+
'exec',
|
|
368
|
+
'--skip-git-repo-check',
|
|
369
|
+
'--sandbox',
|
|
370
|
+
'read-only',
|
|
371
|
+
'--disable',
|
|
372
|
+
'plugins',
|
|
373
|
+
'--disable',
|
|
374
|
+
'hooks',
|
|
375
|
+
'--disable',
|
|
376
|
+
'memories',
|
|
377
|
+
'--disable',
|
|
378
|
+
'apps',
|
|
379
|
+
'-c',
|
|
380
|
+
'notify=[]',
|
|
381
|
+
],
|
|
319
382
|
probe: ['--version'],
|
|
320
383
|
model: (m) => ['-m', m],
|
|
321
384
|
effort: (e) => ['-c', `model_reasoning_effort="${e}"`],
|
|
@@ -411,29 +474,160 @@ function runAssistant(prompt) {
|
|
|
411
474
|
}
|
|
412
475
|
}
|
|
413
476
|
|
|
414
|
-
/**
|
|
415
|
-
|
|
477
|
+
/**
|
|
478
|
+
* The repository's release tags that are reachable from HEAD, highest version first.
|
|
479
|
+
*
|
|
480
|
+
* `git describe --tags --abbrev=0` answers a different question — "the nearest tag of any
|
|
481
|
+
* kind" — and it is wrong in two ways that were both observed. A repository carrying tags
|
|
482
|
+
* that are not releases gets one of those as its baseline: a single rolling `latest-beta`
|
|
483
|
+
* marker, which tauri-release-kit maintains for its update channels, made a release abort
|
|
484
|
+
* with "no releasable commits since latest-beta". And "nearest ancestor" is not "latest
|
|
485
|
+
* release": a patch tagged on top of a later minor drags the baseline backwards.
|
|
486
|
+
*
|
|
487
|
+
* Only tags carrying the configured prefix and a parseable version count, and they are
|
|
488
|
+
* ordered by semver precedence rather than by position in the history. `--merged HEAD`
|
|
489
|
+
* keeps a tag made on another branch out of this branch's history, and degrades correctly
|
|
490
|
+
* in a shallow clone: a tag whose commit was not fetched is simply not listed.
|
|
491
|
+
*
|
|
492
|
+
* @returns {{name: string, version: string}[]}
|
|
493
|
+
*/
|
|
494
|
+
function releaseTags(prefix = config.tagPrefix ?? '') {
|
|
495
|
+
const listed = tryRead('git', ['tag', '--list', `${prefix}*`, '--merged', 'HEAD']) ?? ''
|
|
496
|
+
return listed
|
|
497
|
+
.split('\n')
|
|
498
|
+
.map((name) => name.trim())
|
|
499
|
+
.filter(Boolean)
|
|
500
|
+
.map((name) => ({ name, version: name.slice(prefix.length) }))
|
|
501
|
+
.filter(({ version }) => parseVersion(version))
|
|
502
|
+
.sort((a, b) => compareVersions(b.version, a.version))
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* The tag a release reads its history from.
|
|
507
|
+
*
|
|
508
|
+
* @param {{stable?: boolean, shipped?: boolean}} [options] `stable` when the version being
|
|
509
|
+
* released has no prerelease identifier, which rolls the release candidates leading to it
|
|
510
|
+
* up into it: their work is what is shipping now, and reading from the last candidate
|
|
511
|
+
* describes only the gap between the last two candidates. Promoting `2.0.0-rc.2` to
|
|
512
|
+
* `2.0.0` that way produced empty notes, because the one commit in range was the release
|
|
513
|
+
* chore. Releasing a candidate keeps the full ordering, so each candidate's notes say what
|
|
514
|
+
* changed in that candidate rather than repeating the whole cycle.
|
|
515
|
+
*
|
|
516
|
+
* `shipped` skips tags whose version never reached the registry — see
|
|
517
|
+
* `absorbedReleaseTags` for why, and `versionShipped` for how that is established.
|
|
518
|
+
* @returns {string | null}
|
|
519
|
+
*/
|
|
520
|
+
function lastReleaseTag({ stable = false, prefix, shipped = false } = {}) {
|
|
521
|
+
const tags = releaseTags(prefix)
|
|
522
|
+
const eligible = stable ? tags.filter(({ version }) => !parseVersion(version).pre.length) : tags
|
|
523
|
+
if (!shipped) return eligible[0]?.name ?? null
|
|
524
|
+
for (const tag of eligible) {
|
|
525
|
+
const state = versionShipped(tag.version)
|
|
526
|
+
if (state === true) return tag.name
|
|
527
|
+
// Nothing could answer. Walking further asks the same unanswerable question about older
|
|
528
|
+
// versions, and treating silence as "never published" would reach back to the first
|
|
529
|
+
// commit in the repository — so this reads history exactly as it did before.
|
|
530
|
+
if (state === null) break
|
|
531
|
+
}
|
|
532
|
+
// Either the registry went quiet, or no tag this project ever made is on it — a project
|
|
533
|
+
// that tags and publishes by hand looks exactly like that. Neither is evidence that the
|
|
534
|
+
// last release failed, so the newest tag stays the baseline.
|
|
535
|
+
return eligible[0]?.name ?? null
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* The tags this release is about to absorb: versions that were tagged, pushed and written
|
|
540
|
+
* into the changelog, and then never published.
|
|
541
|
+
*
|
|
542
|
+
* Their commits are still unreleased work — the tag says otherwise, and that is what made
|
|
543
|
+
* them disappear. `2.0.1` failed to publish, `2.0.2` read its history from the `v2.0.1` tag
|
|
544
|
+
* and shipped notes covering one commit, and the ten commits `2.0.1` was made of are named
|
|
545
|
+
* in no release anyone can install. Reading from the last *shipped* tag puts them back in
|
|
546
|
+
* range, both for the notes and for the bump `auto` infers from them.
|
|
547
|
+
*
|
|
548
|
+
* @returns {{name: string, version: string}[]} newest first, empty in the ordinary case
|
|
549
|
+
*/
|
|
550
|
+
function absorbedReleaseTags({ stable = false } = {}) {
|
|
551
|
+
const tags = releaseTags()
|
|
552
|
+
const eligible = stable ? tags.filter(({ version }) => !parseVersion(version).pre.length) : tags
|
|
553
|
+
const baseline = lastReleaseTag({ stable, shipped: true })
|
|
554
|
+
const absorbed = []
|
|
555
|
+
for (const tag of eligible) {
|
|
556
|
+
if (tag.name === baseline) break
|
|
557
|
+
if (versionShipped(tag.version) !== false) break
|
|
558
|
+
absorbed.push(tag)
|
|
559
|
+
}
|
|
560
|
+
return absorbed
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/** Commit subjects since the last release tag, with release and merge commits filtered out. */
|
|
564
|
+
function commitsSinceLastTag(options) {
|
|
416
565
|
const ignored = (config.ignoreCommits ?? []).map((pattern) => new RegExp(pattern, 'i'))
|
|
417
|
-
const lastTag =
|
|
566
|
+
const lastTag = lastReleaseTag(options)
|
|
418
567
|
const range = lastTag ? `${lastTag}..HEAD` : 'HEAD'
|
|
419
568
|
// %B is the whole message: bodies carry BREAKING CHANGE and Release-As footers. A record
|
|
420
569
|
// 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
|
-
|
|
570
|
+
// %h first, then the author, then the message: the hash is what links each bullet back
|
|
571
|
+
// to its commit, and the author is what says who is new here.
|
|
572
|
+
const raw = tryRead('git', ['log', `--format=%h%x1f%an%x1f%ae%x1f%B%x1e`, range]) ?? ''
|
|
423
573
|
const commits = raw
|
|
424
574
|
.split('\u001E')
|
|
425
575
|
.map((entry) => entry.trim())
|
|
426
576
|
.filter(Boolean)
|
|
427
577
|
.map((entry) => {
|
|
428
|
-
const [hash, message = ''] = entry.split('\u001F')
|
|
578
|
+
const [hash, author = '', email = '', message = ''] = entry.split('\u001F')
|
|
429
579
|
const [subject, ...rest] = message.split('\n')
|
|
430
|
-
return {
|
|
580
|
+
return {
|
|
581
|
+
hash: hash.trim(),
|
|
582
|
+
author: author.trim(),
|
|
583
|
+
email: email.trim().toLowerCase(),
|
|
584
|
+
subject: subject.trim(),
|
|
585
|
+
body: rest.join('\n').trim(),
|
|
586
|
+
}
|
|
431
587
|
})
|
|
432
588
|
// Bookkeeping rather than change: the previous release's own commit, merges that
|
|
433
589
|
// duplicate the branch they bring in, and markers meant to be autosquashed away.
|
|
434
590
|
.filter(({ subject }) => subject && !ignored.some((re) => re.test(subject)))
|
|
435
591
|
const kept = withoutRevertedCommits(commits)
|
|
436
|
-
return {
|
|
592
|
+
return {
|
|
593
|
+
lastTag,
|
|
594
|
+
commits: kept,
|
|
595
|
+
subjects: kept.map((c) => c.subject),
|
|
596
|
+
contributors: newContributors(lastTag, kept),
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* The people whose first commit to this repository is in this release.
|
|
602
|
+
*
|
|
603
|
+
* git-cliff derives this from the forge's API, which needs a token, a network and a
|
|
604
|
+
* forge. The repository already knows: an author absent from every commit before the
|
|
605
|
+
* previous tag has not contributed before. That answer is exact, offline, and the same on
|
|
606
|
+
* GitHub, GitLab and a bare remote — and it degrades with a shallow clone exactly as the
|
|
607
|
+
* rest of the notes do, which is already warned about.
|
|
608
|
+
*
|
|
609
|
+
* The first release has no "before", so everyone would be new and the section would say
|
|
610
|
+
* nothing; it is skipped there.
|
|
611
|
+
*
|
|
612
|
+
* @returns {string[]} display names, GitHub handles where the email carries one
|
|
613
|
+
*/
|
|
614
|
+
function newContributors(lastTag, commits) {
|
|
615
|
+
if (!lastTag || !commits.length) return []
|
|
616
|
+
const before = new Set(
|
|
617
|
+
(tryRead('git', ['log', '--format=%ae', lastTag]) ?? '')
|
|
618
|
+
.split('\n')
|
|
619
|
+
.map((email) => email.trim().toLowerCase())
|
|
620
|
+
.filter(Boolean),
|
|
621
|
+
)
|
|
622
|
+
const seen = new Map()
|
|
623
|
+
for (const { author, email } of commits) {
|
|
624
|
+
if (!email || before.has(email) || seen.has(email)) continue
|
|
625
|
+
// A GitHub noreply address carries the account handle, which is what a reader can
|
|
626
|
+
// actually follow; anything else falls back to the name on the commit.
|
|
627
|
+
const handle = /^(?:\d+\+)?([^@]+)@users\.noreply\.github\.com$/.exec(email)?.[1]
|
|
628
|
+
seen.set(email, handle ? `@${handle}` : author)
|
|
629
|
+
}
|
|
630
|
+
return [...seen.values()].filter(Boolean)
|
|
437
631
|
}
|
|
438
632
|
|
|
439
633
|
/**
|
|
@@ -528,7 +722,7 @@ function inferBump(commits, currentVersion, strategy = 'conventional') {
|
|
|
528
722
|
*
|
|
529
723
|
* @returns {string | null} markdown body, or null when nothing visible changed
|
|
530
724
|
*/
|
|
531
|
-
function changelogFromCommits(commits, links = null, hidden = []) {
|
|
725
|
+
function changelogFromCommits(commits, links = null, hidden = [], contributors = []) {
|
|
532
726
|
const parsed = commits.map((c) => parseCommit(c.subject, c.body, c.hash)).filter(Boolean)
|
|
533
727
|
const lines = []
|
|
534
728
|
|
|
@@ -577,6 +771,14 @@ function changelogFromCommits(commits, links = null, hidden = []) {
|
|
|
577
771
|
lines.push('')
|
|
578
772
|
}
|
|
579
773
|
|
|
774
|
+
// Last, and only when there is something above it: a list of names is not release notes
|
|
775
|
+
// on its own, and a release with no described changes should still say so.
|
|
776
|
+
if (lines.length && contributors.length) {
|
|
777
|
+
lines.push('### New Contributors', '')
|
|
778
|
+
for (const name of contributors) lines.push(`- ${name} made their first contribution`)
|
|
779
|
+
lines.push('')
|
|
780
|
+
}
|
|
781
|
+
|
|
580
782
|
return lines.length ? lines.join('\n').trim() : null
|
|
581
783
|
}
|
|
582
784
|
|
|
@@ -586,11 +788,28 @@ function changelogFromCommits(commits, links = null, hidden = []) {
|
|
|
586
788
|
* differ: Bitbucket uses /issue/ and /commits/ where GitHub uses /issues/ and /commit/.
|
|
587
789
|
*/
|
|
588
790
|
const HOSTS = {
|
|
589
|
-
'github.com': {
|
|
590
|
-
|
|
591
|
-
|
|
791
|
+
'github.com': {
|
|
792
|
+
issue: 'issues',
|
|
793
|
+
commit: 'commit',
|
|
794
|
+
compare: 'compare/%f...%t',
|
|
795
|
+
tag: 'releases/tag/%t',
|
|
796
|
+
},
|
|
797
|
+
'gitlab.com': { issue: 'issues', commit: 'commit', compare: 'compare/%f...%t', tag: '-/tags/%t' },
|
|
798
|
+
// Bitbucket reverses the operands and separates them with two dots, and keeps tags under
|
|
799
|
+
// /commits/tag/ rather than a releases page it does not have.
|
|
800
|
+
'bitbucket.org': {
|
|
801
|
+
issue: 'issue',
|
|
802
|
+
commit: 'commits',
|
|
803
|
+
compare: 'branches/compare/%t..%f',
|
|
804
|
+
tag: 'commits/tag/%t',
|
|
805
|
+
},
|
|
806
|
+
}
|
|
807
|
+
const DEFAULT_HOST = {
|
|
808
|
+
issue: 'issues',
|
|
809
|
+
commit: 'commit',
|
|
810
|
+
compare: 'compare/%f...%t',
|
|
811
|
+
tag: 'releases/tag/%t',
|
|
592
812
|
}
|
|
593
|
-
const DEFAULT_HOST = { issue: 'issues', commit: 'commit' }
|
|
594
813
|
|
|
595
814
|
/** Words that mark an issue reference as closed by the commit. */
|
|
596
815
|
const CLOSES = /\b(?:close[sd]?|closing|fix(?:e[sd])?|fixing|resolve[sd]?|resolving)\s+#(\d+)/gi
|
|
@@ -611,10 +830,13 @@ function remoteLinks(remote) {
|
|
|
611
830
|
const [, host, path] = web ?? scp ?? []
|
|
612
831
|
if (!host || !path) return null
|
|
613
832
|
const shape = HOSTS[host.toLowerCase()] ?? DEFAULT_HOST
|
|
833
|
+
const base = `https://${host}/${path}`
|
|
614
834
|
return {
|
|
615
|
-
base
|
|
616
|
-
issue:
|
|
617
|
-
commit:
|
|
835
|
+
base,
|
|
836
|
+
issue: `${base}/${shape.issue}`,
|
|
837
|
+
commit: `${base}/${shape.commit}`,
|
|
838
|
+
compare: (from, to) => `${base}/${shape.compare.replace('%f', from).replace('%t', to)}`,
|
|
839
|
+
tag: (name) => `${base}/${shape.tag.replace('%t', name)}`,
|
|
618
840
|
}
|
|
619
841
|
}
|
|
620
842
|
|
|
@@ -871,6 +1093,103 @@ function mutate(command, args, options = {}) {
|
|
|
871
1093
|
}
|
|
872
1094
|
}
|
|
873
1095
|
|
|
1096
|
+
/**
|
|
1097
|
+
* Lockfiles that record the releasing project's own version, and the command that brings
|
|
1098
|
+
* each back into step.
|
|
1099
|
+
*
|
|
1100
|
+
* A lockfile is not rewritten by pattern like a manifest is: `package-lock.json` carries
|
|
1101
|
+
* the version in two places, `uv.lock` carries it inside the `[[package]]` block for the
|
|
1102
|
+
* project among all its dependencies, and both formats change shape between tool versions.
|
|
1103
|
+
* The tool that owns the file is the only thing that can be trusted to edit it, so each
|
|
1104
|
+
* one is refreshed by running that tool.
|
|
1105
|
+
*
|
|
1106
|
+
* `manifest` scopes the refresh: a polyglot repository can hold a `uv.lock` for a Python
|
|
1107
|
+
* component that this release is not versioning, and regenerating it would put an
|
|
1108
|
+
* unrelated change in the release commit.
|
|
1109
|
+
*
|
|
1110
|
+
* pnpm and Cargo are deliberately absent. `pnpm-lock.yaml` records no root version, so it
|
|
1111
|
+
* never goes stale; `Cargo.lock` does, and is already kept in step as a version file with
|
|
1112
|
+
* a pattern scoped to the crate.
|
|
1113
|
+
*/
|
|
1114
|
+
const LOCKFILES = [
|
|
1115
|
+
{
|
|
1116
|
+
path: 'package-lock.json',
|
|
1117
|
+
manifest: 'package.json',
|
|
1118
|
+
command: ['npm', ['install', '--package-lock-only', '--ignore-scripts', '--silent']],
|
|
1119
|
+
},
|
|
1120
|
+
{
|
|
1121
|
+
path: 'npm-shrinkwrap.json',
|
|
1122
|
+
manifest: 'package.json',
|
|
1123
|
+
command: ['npm', ['install', '--package-lock-only', '--ignore-scripts', '--silent']],
|
|
1124
|
+
},
|
|
1125
|
+
{ path: 'uv.lock', manifest: 'pyproject.toml', command: ['uv', ['lock', '--quiet']] },
|
|
1126
|
+
]
|
|
1127
|
+
|
|
1128
|
+
/**
|
|
1129
|
+
* Bring every lockfile belonging to a manifest this release wrote back into step.
|
|
1130
|
+
*
|
|
1131
|
+
* A missing tool is a warning rather than an abort: the lockfile is left exactly as stale
|
|
1132
|
+
* as it already was, which is the behaviour without this step at all, and no release
|
|
1133
|
+
* should die because a lock tool is not installed on the machine cutting it.
|
|
1134
|
+
*
|
|
1135
|
+
* @param {string[]} written paths the version step wrote
|
|
1136
|
+
*/
|
|
1137
|
+
function refreshLockfiles(written) {
|
|
1138
|
+
const done = new Set()
|
|
1139
|
+
for (const { path, manifest, command } of LOCKFILES) {
|
|
1140
|
+
if (!existsSync(path) || !written.includes(manifest)) continue
|
|
1141
|
+
const [tool, args] = command
|
|
1142
|
+
// npm writes whichever of the two lockfiles the project has; running it twice is one
|
|
1143
|
+
// pointless install, not two different edits.
|
|
1144
|
+
if (!done.has(tool)) {
|
|
1145
|
+
if (!dryRun && !succeeds(tool, ['--version'])) {
|
|
1146
|
+
warn(`${path} records the version and ${tool} is not installed — leaving it stale`)
|
|
1147
|
+
continue
|
|
1148
|
+
}
|
|
1149
|
+
mutate(tool, args)
|
|
1150
|
+
done.add(tool)
|
|
1151
|
+
}
|
|
1152
|
+
staged.push(path)
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
/**
|
|
1157
|
+
* Push the release commit and its tag as one transaction.
|
|
1158
|
+
*
|
|
1159
|
+
* `--follow-tags` and `--atomic` answer different questions: the first decides *which*
|
|
1160
|
+
* refs are sent, the second decides whether they land together. With only the first, a
|
|
1161
|
+
* server is free to accept the branch and reject the tag — which is precisely the split
|
|
1162
|
+
* this step exists to prevent, leaving a release commit on the remote with no tag, or a
|
|
1163
|
+
* tag with no commit behind it.
|
|
1164
|
+
*
|
|
1165
|
+
* Not every server implements the atomic capability, so a refusal on those grounds falls
|
|
1166
|
+
* back to the plain push. Nothing else does: a rejected non-fast-forward retried without
|
|
1167
|
+
* `--atomic` would push one ref and not the other, which is worse than failing.
|
|
1168
|
+
*/
|
|
1169
|
+
function pushBranchAndTag(branchRef) {
|
|
1170
|
+
const args = ['push', '--follow-tags', config.remote, branchRef]
|
|
1171
|
+
const atomic = ['push', '--follow-tags', '--atomic', config.remote, branchRef]
|
|
1172
|
+
const line = formatCommand('git', atomic)
|
|
1173
|
+
if (dryRun) {
|
|
1174
|
+
console.log(` ${yellow('would run:')} ${line}`)
|
|
1175
|
+
return
|
|
1176
|
+
}
|
|
1177
|
+
console.log(` ${dim(`$ ${line}`)}`)
|
|
1178
|
+
try {
|
|
1179
|
+
execFileSync('git', atomic, { stdio: ['pipe', 'inherit', 'pipe'] })
|
|
1180
|
+
return
|
|
1181
|
+
} catch (err) {
|
|
1182
|
+
const stderr = `${err.stderr ?? ''}`
|
|
1183
|
+
process.stderr.write(stderr)
|
|
1184
|
+
if (!/atomic/i.test(stderr)) abortMidRelease(line)
|
|
1185
|
+
}
|
|
1186
|
+
warn(
|
|
1187
|
+
`${config.remote} does not support atomic pushes — sending the branch and tag in one ` +
|
|
1188
|
+
'call, but not as one transaction',
|
|
1189
|
+
)
|
|
1190
|
+
mutate('git', args)
|
|
1191
|
+
}
|
|
1192
|
+
|
|
874
1193
|
/**
|
|
875
1194
|
* Mutating shell command, for configured strings like `publish` that are written as a
|
|
876
1195
|
* whole command line rather than an argv. Shell metacharacters are the author's to own.
|
|
@@ -1072,6 +1391,65 @@ function insertChangelogSection(text, version, date, body) {
|
|
|
1072
1391
|
return `${trimmed}\n\n${entry}`
|
|
1073
1392
|
}
|
|
1074
1393
|
|
|
1394
|
+
/**
|
|
1395
|
+
* Write the link reference definitions a Keep a Changelog document's headings depend on.
|
|
1396
|
+
*
|
|
1397
|
+
* `## [1.2.3]` is a markdown link *reference*: without a matching `[1.2.3]: <url>` at the
|
|
1398
|
+
* foot of the file it renders as literal bracketed text. Sections were being written in
|
|
1399
|
+
* that shape and the definitions were never written at all, so every heading in every
|
|
1400
|
+
* changelog this tool has ever rolled is a dead reference.
|
|
1401
|
+
*
|
|
1402
|
+
* Every bracketed heading in the document gets one, not only the version being released,
|
|
1403
|
+
* so a changelog that never had them is repaired in one release rather than from here on.
|
|
1404
|
+
* Each version links to the diff since the version below it; the oldest links to its own
|
|
1405
|
+
* tag, having no predecessor to compare against. `[Unreleased]` compares the newest
|
|
1406
|
+
* version against `HEAD`.
|
|
1407
|
+
*
|
|
1408
|
+
* Definitions for labels that are not headings are left exactly where they are — those are
|
|
1409
|
+
* the author's own links, and this owns only what it can derive.
|
|
1410
|
+
*
|
|
1411
|
+
* @param {ReturnType<typeof remoteLinks>} links
|
|
1412
|
+
* @returns {string} the document with its definitions rewritten
|
|
1413
|
+
*/
|
|
1414
|
+
function withChangelogLinks(text, links, tagPrefix = '') {
|
|
1415
|
+
if (!links) return text
|
|
1416
|
+
const labels = [...text.matchAll(/^## \[([^\]]+)\]/gm)].map((m) => m[1])
|
|
1417
|
+
if (!labels.length) return text
|
|
1418
|
+
|
|
1419
|
+
const versions = labels
|
|
1420
|
+
.filter((label) => parseVersion(label.replace(/^v/, '')))
|
|
1421
|
+
.sort((a, b) => compareVersions(b.replace(/^v/, ''), a.replace(/^v/, '')))
|
|
1422
|
+
const unreleased = labels.find((label) => /^unreleased$/i.test(label))
|
|
1423
|
+
|
|
1424
|
+
const tagged = (label) => `${tagPrefix}${label.replace(/^v/, '')}`
|
|
1425
|
+
const definitions = []
|
|
1426
|
+
if (unreleased && versions.length) {
|
|
1427
|
+
definitions.push(`[${unreleased}]: ${links.compare(tagged(versions[0]), 'HEAD')}`)
|
|
1428
|
+
}
|
|
1429
|
+
versions.forEach((version, index) => {
|
|
1430
|
+
const previous = versions[index + 1]
|
|
1431
|
+
definitions.push(
|
|
1432
|
+
`[${version}]: ${
|
|
1433
|
+
previous ? links.compare(tagged(previous), tagged(version)) : links.tag(tagged(version))
|
|
1434
|
+
}`,
|
|
1435
|
+
)
|
|
1436
|
+
})
|
|
1437
|
+
if (!definitions.length) return text
|
|
1438
|
+
|
|
1439
|
+
// Drop the existing definitions for the labels being rewritten, wherever they sit, so
|
|
1440
|
+
// running this twice produces the same document rather than a second copy.
|
|
1441
|
+
const managed = new Set([...(unreleased ? [unreleased] : []), ...versions])
|
|
1442
|
+
const body = text
|
|
1443
|
+
.split('\n')
|
|
1444
|
+
.filter((line) => {
|
|
1445
|
+
const label = /^\[([^\]]+)\]:\s/.exec(line)?.[1]
|
|
1446
|
+
return !(label && managed.has(label))
|
|
1447
|
+
})
|
|
1448
|
+
.join('\n')
|
|
1449
|
+
|
|
1450
|
+
return `${body.trimEnd()}\n\n${definitions.join('\n')}\n`
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1075
1453
|
/**
|
|
1076
1454
|
* Promote `## [Unreleased]` to a released version and reopen an empty one above it.
|
|
1077
1455
|
*
|
|
@@ -1152,6 +1530,73 @@ function readNameFrom(entry) {
|
|
|
1152
1530
|
/** Normalise a versionFile / versionFiles entry to { path, pattern }. */
|
|
1153
1531
|
const versionSource = (entry) => (typeof entry === 'string' ? { path: entry } : entry)
|
|
1154
1532
|
|
|
1533
|
+
/**
|
|
1534
|
+
* Expand a path that may contain `*` segments into the paths that exist, in sorted order.
|
|
1535
|
+
*
|
|
1536
|
+
* A desktop app carries the same version in a per-platform config for every platform it
|
|
1537
|
+
* ships, and a Cargo workspace lists its members as `crates/*`. Writing those out one by
|
|
1538
|
+
* one is the config the glob replaces. `*` matches within a single path segment, which is
|
|
1539
|
+
* what both of those shapes need and is all Go's `filepath.Glob` — the shape changie's
|
|
1540
|
+
* `replacements` use — offers either.
|
|
1541
|
+
*
|
|
1542
|
+
* @returns {string[]} matching paths; a pattern with no `*` yields itself when it exists
|
|
1543
|
+
*/
|
|
1544
|
+
function expandPaths(pattern) {
|
|
1545
|
+
if (!pattern.includes('*')) return existsSync(pattern) ? [pattern] : []
|
|
1546
|
+
const absolute = pattern.startsWith('/')
|
|
1547
|
+
let current = [absolute ? '/' : '.']
|
|
1548
|
+
for (const segment of pattern.split('/').filter(Boolean)) {
|
|
1549
|
+
const next = []
|
|
1550
|
+
if (segment.includes('*')) {
|
|
1551
|
+
const shape = new RegExp(`^${segment.split('*').map(escapeRe).join('[^/]*')}$`)
|
|
1552
|
+
for (const dir of current) {
|
|
1553
|
+
let entries
|
|
1554
|
+
try {
|
|
1555
|
+
entries = readdirSync(dir).sort()
|
|
1556
|
+
} catch {
|
|
1557
|
+
continue
|
|
1558
|
+
}
|
|
1559
|
+
for (const entry of entries) if (shape.test(entry)) next.push(join(dir, entry))
|
|
1560
|
+
}
|
|
1561
|
+
} else {
|
|
1562
|
+
for (const dir of current) {
|
|
1563
|
+
const joined = join(dir, segment)
|
|
1564
|
+
if (existsSync(joined)) next.push(joined)
|
|
1565
|
+
}
|
|
1566
|
+
}
|
|
1567
|
+
current = next
|
|
1568
|
+
}
|
|
1569
|
+
return current
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
/**
|
|
1573
|
+
* The crates in a Cargo workspace whose version this bump owns: the members that inherit
|
|
1574
|
+
* it with `version.workspace = true`, which is how a workspace keeps its crates in step.
|
|
1575
|
+
*
|
|
1576
|
+
* A member pinning its own number is versioned separately and is left out, the same rule
|
|
1577
|
+
* the companion-manifest detection uses.
|
|
1578
|
+
*
|
|
1579
|
+
* @returns {string[]} crate names
|
|
1580
|
+
*/
|
|
1581
|
+
function workspaceCrates(manifestPath) {
|
|
1582
|
+
const text = readFileSync(manifestPath, 'utf8')
|
|
1583
|
+
const members = /^members\s*=\s*\[([\s\S]*?)\]/m.exec(text)?.[1]
|
|
1584
|
+
if (!members) return []
|
|
1585
|
+
const root = dirname(manifestPath)
|
|
1586
|
+
const names = []
|
|
1587
|
+
for (const entry of members.matchAll(/"([^"]+)"/g)) {
|
|
1588
|
+
for (const dir of expandPaths(join(root, entry[1]))) {
|
|
1589
|
+
const memberManifest = join(dir, 'Cargo.toml')
|
|
1590
|
+
if (!existsSync(memberManifest)) continue
|
|
1591
|
+
const member = readFileSync(memberManifest, 'utf8')
|
|
1592
|
+
if (!/^version(?:\.workspace)?\s*=\s*\{?\s*workspace\s*=\s*true/m.test(member)) continue
|
|
1593
|
+
const name = NAME_PATTERNS.toml.exec(member)?.[1]
|
|
1594
|
+
if (name) names.push(name)
|
|
1595
|
+
}
|
|
1596
|
+
}
|
|
1597
|
+
return names
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1155
1600
|
/**
|
|
1156
1601
|
* A lockfile records a version for every dependency — hundreds of them — so the first
|
|
1157
1602
|
* `version = "…"` in the file belongs to whichever crate sorts first, not to this project.
|
|
@@ -1159,21 +1604,27 @@ const versionSource = (entry) => (typeof entry === 'string' ? { path: entry } :
|
|
|
1159
1604
|
*/
|
|
1160
1605
|
function cargoLockPattern(lockPath) {
|
|
1161
1606
|
const sibling = join(dirname(lockPath), 'Cargo.toml')
|
|
1162
|
-
|
|
1163
|
-
|
|
1607
|
+
// A workspace root carries no `[package]` of its own; what it owns is every member that
|
|
1608
|
+
// inherits the version with `version.workspace = true`, and the lockfile records a
|
|
1609
|
+
// block for each of them.
|
|
1610
|
+
const crates = existsSync(sibling)
|
|
1611
|
+
? [...new Set([readNameFrom({ path: sibling }), ...workspaceCrates(sibling)].filter(Boolean))]
|
|
1612
|
+
: []
|
|
1613
|
+
if (!crates.length) {
|
|
1164
1614
|
throw new Error(
|
|
1165
1615
|
`${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
|
-
|
|
1616
|
+
`yours.\n No Cargo.toml beside it naming a crate or a workspace member that ` +
|
|
1617
|
+
`inherits the version — give an explicit pattern:\n { "path": "${lockPath}", ` +
|
|
1618
|
+
`"pattern": "name = \\"<crate>\\"\\nversion = \\"(.+)\\"" }`,
|
|
1169
1619
|
)
|
|
1170
1620
|
}
|
|
1171
|
-
|
|
1621
|
+
const names = crates.map(escapeRe).join('|')
|
|
1622
|
+
return new RegExp(`\\[\\[package\\]\\]\\nname = "(?:${names})"\\nversion = "([^"]*)"`, 'g')
|
|
1172
1623
|
}
|
|
1173
1624
|
|
|
1174
1625
|
/** 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')
|
|
1626
|
+
function patternFor({ path, pattern, all = false }) {
|
|
1627
|
+
if (pattern) return new RegExp(pattern, all ? 'mg' : 'm')
|
|
1177
1628
|
if (basename(path) === 'Cargo.lock') return cargoLockPattern(path)
|
|
1178
1629
|
if (path.endsWith('.json')) return VERSION_PATTERNS.json
|
|
1179
1630
|
if (path.endsWith('.toml')) return VERSION_PATTERNS.toml
|
|
@@ -1184,31 +1635,176 @@ function patternFor({ path, pattern }) {
|
|
|
1184
1635
|
function readVersionFrom(entry) {
|
|
1185
1636
|
const source = versionSource(entry)
|
|
1186
1637
|
const text = readFileSync(source.path, 'utf8')
|
|
1187
|
-
const
|
|
1188
|
-
if (
|
|
1189
|
-
|
|
1638
|
+
const { kind, shape } = versionMode(source, text)
|
|
1639
|
+
if (kind === 'bare') return text.trim() || null
|
|
1640
|
+
if (kind === 'markers') {
|
|
1641
|
+
// The first line under a `version` marker is the one that carries the whole version;
|
|
1642
|
+
// a `major` or `date` marker carries only a piece of it.
|
|
1643
|
+
let scope = null
|
|
1644
|
+
for (const line of text.split('\n')) {
|
|
1645
|
+
const starting = MARKER_START.exec(line)?.[1]
|
|
1646
|
+
if (starting) {
|
|
1647
|
+
scope = starting
|
|
1648
|
+
continue
|
|
1649
|
+
}
|
|
1650
|
+
if (scope && MARKER_END.test(line)) {
|
|
1651
|
+
scope = null
|
|
1652
|
+
continue
|
|
1653
|
+
}
|
|
1654
|
+
const active = MARKER_INLINE.exec(line)?.[1] ?? scope
|
|
1655
|
+
if (active === 'version') {
|
|
1656
|
+
const found = MARKER_VERSION.exec(line)?.[0]
|
|
1657
|
+
if (found) return found
|
|
1658
|
+
}
|
|
1659
|
+
}
|
|
1660
|
+
return null
|
|
1661
|
+
}
|
|
1662
|
+
const match = shape.exec(text)
|
|
1190
1663
|
return match ? match[1] : null
|
|
1191
1664
|
}
|
|
1192
1665
|
|
|
1666
|
+
/**
|
|
1667
|
+
* Version markers: a comment naming the version, put on the line that carries it.
|
|
1668
|
+
*
|
|
1669
|
+
* A `pattern` can already reach any file, but writing one is a regex per file, and the
|
|
1670
|
+
* files that most want keeping in step — a README install line, a badge URL, a Dockerfile
|
|
1671
|
+
* tag, a Helm chart — are exactly the ones where a regex is fiddliest to get right and
|
|
1672
|
+
* easiest to get subtly wrong. release-please solved this with a marker comment on the
|
|
1673
|
+
* line instead, and the convention travels: the file says which of its numbers is the
|
|
1674
|
+
* version, so nothing outside it has to describe where that number sits.
|
|
1675
|
+
*
|
|
1676
|
+
* npm i acme@1.2.3 <!-- x-release-kit-version -->
|
|
1677
|
+
* FROM acme:1.2 # x-release-kit-minor
|
|
1678
|
+
* Released 2026-08-20 <!-- x-release-kit-date -->
|
|
1679
|
+
*
|
|
1680
|
+
* A block form covers a run of lines, for a fenced example that should not carry a comment
|
|
1681
|
+
* on every line:
|
|
1682
|
+
*
|
|
1683
|
+
* <!-- x-release-kit-start-version -->
|
|
1684
|
+
* ```sh
|
|
1685
|
+
* npm i acme@1.2.3
|
|
1686
|
+
* ```
|
|
1687
|
+
* <!-- x-release-kit-end -->
|
|
1688
|
+
*/
|
|
1689
|
+
// `version-date` comes first: the alternation is ordered, and `version` would otherwise
|
|
1690
|
+
// match its prefix and leave the date alone.
|
|
1691
|
+
const SCOPES = 'version-date|major|minor|patch|version|date'
|
|
1692
|
+
const MARKER_INLINE = new RegExp(`x-release-kit-(${SCOPES})\\b`)
|
|
1693
|
+
const MARKER_START = new RegExp(`x-release-kit-start-(${SCOPES})\\b`)
|
|
1694
|
+
const MARKER_END = /x-release-kit-end\b/
|
|
1695
|
+
const MARKER_VERSION = /\d+\.\d+\.\d+(?:-[0-9a-z.-]+)?(?:\+[0-9a-z.-]+)?/i
|
|
1696
|
+
const MARKER_NUMBER = /\b\d+\b/
|
|
1697
|
+
const MARKER_DATE = /\d{4}-\d{2}-\d{2}/
|
|
1698
|
+
|
|
1699
|
+
/** Whether a file opts into marker rewriting at all. */
|
|
1700
|
+
const hasVersionMarkers = (text) => MARKER_INLINE.test(text) || MARKER_START.test(text)
|
|
1701
|
+
|
|
1702
|
+
/**
|
|
1703
|
+
* Rewrite the marked numbers in a file.
|
|
1704
|
+
*
|
|
1705
|
+
* A marker whose line carries nothing to replace is left alone rather than guessed at: a
|
|
1706
|
+
* heading above a block, or a comment on its own line, is a normal thing to find.
|
|
1707
|
+
*
|
|
1708
|
+
* @returns {string} the rewritten text
|
|
1709
|
+
*/
|
|
1710
|
+
function applyVersionMarkers(text, version, date) {
|
|
1711
|
+
const { major, minor, patch } = parseVersion(version)
|
|
1712
|
+
const replacements = {
|
|
1713
|
+
version: [MARKER_VERSION, version],
|
|
1714
|
+
major: [MARKER_NUMBER, String(major)],
|
|
1715
|
+
minor: [MARKER_NUMBER, String(minor)],
|
|
1716
|
+
patch: [MARKER_NUMBER, String(patch)],
|
|
1717
|
+
date: [MARKER_DATE, date],
|
|
1718
|
+
}
|
|
1719
|
+
// One line carrying both, which is the shape of an AppStream <release> tag.
|
|
1720
|
+
const versionAndDate = (line) => line.replace(MARKER_VERSION, version).replace(MARKER_DATE, date)
|
|
1721
|
+
let scope = null
|
|
1722
|
+
return text
|
|
1723
|
+
.split('\n')
|
|
1724
|
+
.map((line) => {
|
|
1725
|
+
const inline = MARKER_INLINE.exec(line)?.[1]
|
|
1726
|
+
const starting = MARKER_START.exec(line)?.[1]
|
|
1727
|
+
// A start marker opens a block; its own line is not rewritten, since the marker
|
|
1728
|
+
// comment is the whole content of it.
|
|
1729
|
+
if (starting) {
|
|
1730
|
+
scope = starting
|
|
1731
|
+
return line
|
|
1732
|
+
}
|
|
1733
|
+
if (scope && MARKER_END.test(line)) {
|
|
1734
|
+
scope = null
|
|
1735
|
+
return line
|
|
1736
|
+
}
|
|
1737
|
+
const active = inline ?? scope
|
|
1738
|
+
if (!active) return line
|
|
1739
|
+
if (active === 'version-date') return versionAndDate(line)
|
|
1740
|
+
const [shape, value] = replacements[active]
|
|
1741
|
+
return line.replace(shape, value)
|
|
1742
|
+
})
|
|
1743
|
+
.join('\n')
|
|
1744
|
+
}
|
|
1745
|
+
|
|
1746
|
+
/**
|
|
1747
|
+
* Where a file keeps its version, most specific first: the `pattern` the entry was
|
|
1748
|
+
* configured with, the markers the file carries, the shape its extension implies, and —
|
|
1749
|
+
* for a plain `VERSION` file — being nothing but the version.
|
|
1750
|
+
*
|
|
1751
|
+
* Reading and writing both go through this, so they can never disagree about which of a
|
|
1752
|
+
* file's numbers is the version.
|
|
1753
|
+
*
|
|
1754
|
+
* @returns {{kind: 'pattern'|'markers'|'bare', shape: RegExp|null}}
|
|
1755
|
+
*/
|
|
1756
|
+
function versionMode(source, text) {
|
|
1757
|
+
if (source.pattern) return { kind: 'pattern', shape: patternFor(source) }
|
|
1758
|
+
if (hasVersionMarkers(text)) return { kind: 'markers', shape: null }
|
|
1759
|
+
const inferred = patternFor(source)
|
|
1760
|
+
return inferred ? { kind: 'pattern', shape: inferred } : { kind: 'bare', shape: null }
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1193
1763
|
/**
|
|
1194
1764
|
* Replace the version in a source file, touching nothing else: only the captured range is
|
|
1195
1765
|
* rewritten, so formatting, key order and comments all survive.
|
|
1196
1766
|
*
|
|
1197
|
-
*
|
|
1767
|
+
* Four ways a file says where its version is, most specific first: the `pattern` it was
|
|
1768
|
+
* configured with, the markers it carries, the shape its extension implies, and — for a
|
|
1769
|
+
* plain `VERSION` file — being nothing but the version.
|
|
1770
|
+
*
|
|
1771
|
+
* @param {{dryRun?: boolean, date?: string}} [options] report the change without making
|
|
1772
|
+
* it; the date written for a `x-release-kit-date` marker
|
|
1198
1773
|
* @returns {boolean} whether the file needed changing
|
|
1199
1774
|
*/
|
|
1200
|
-
function writeVersionInto(entry, version, { dryRun = false } = {}) {
|
|
1775
|
+
function writeVersionInto(entry, version, { dryRun = false, date } = {}) {
|
|
1201
1776
|
const source = versionSource(entry)
|
|
1202
1777
|
const text = readFileSync(source.path, 'utf8')
|
|
1203
|
-
const
|
|
1778
|
+
const { kind, shape } = versionMode(source, text)
|
|
1204
1779
|
|
|
1205
1780
|
let updated
|
|
1206
|
-
if (pattern) {
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1781
|
+
if (kind === 'pattern') {
|
|
1782
|
+
if (!shape.test(text)) {
|
|
1783
|
+
if (source.optional) return false
|
|
1784
|
+
throw new Error(`${source.path} has no version matching ${shape}`)
|
|
1785
|
+
}
|
|
1786
|
+
shape.lastIndex = 0
|
|
1787
|
+
// A global pattern rewrites every match rather than the first: a Cargo.lock records
|
|
1788
|
+
// one block per crate, and a workspace bump owns all the members inheriting from it.
|
|
1789
|
+
updated = text.replace(shape, (match, captured) => {
|
|
1790
|
+
const at = match.indexOf(captured)
|
|
1791
|
+
return match.slice(0, at) + version + match.slice(at + captured.length)
|
|
1792
|
+
})
|
|
1793
|
+
} else if (kind === 'markers') {
|
|
1794
|
+
updated = applyVersionMarkers(text, version, date ?? new Date().toISOString().slice(0, 10))
|
|
1211
1795
|
} else {
|
|
1796
|
+
// The last resort overwrites the file with the version, which is right for a VERSION
|
|
1797
|
+
// file and catastrophic for anything else. A file that is not already just a version
|
|
1798
|
+
// was listed by mistake, or wants a marker or a pattern — say so rather than shred it.
|
|
1799
|
+
const existing = text.trim()
|
|
1800
|
+
if (existing && !parseVersion(existing)) {
|
|
1801
|
+
throw new Error(
|
|
1802
|
+
`${source.path} is not a file containing only a version, and carries no ` +
|
|
1803
|
+
'x-release-kit-version marker.\n Writing the version into it would replace ' +
|
|
1804
|
+
'everything else in it. Mark the line that holds the version, or give the entry ' +
|
|
1805
|
+
'a "pattern".',
|
|
1806
|
+
)
|
|
1807
|
+
}
|
|
1212
1808
|
updated = `${version}\n`
|
|
1213
1809
|
}
|
|
1214
1810
|
|
|
@@ -1221,7 +1817,9 @@ function writeVersionInto(entry, version, { dryRun = false } = {}) {
|
|
|
1221
1817
|
// ARGUMENTS
|
|
1222
1818
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
1223
1819
|
|
|
1224
|
-
|
|
1820
|
+
// `next` is a modifier on the ordinary target resolution, not a mode of its own: it takes
|
|
1821
|
+
// the same target argument and stops once the version is known.
|
|
1822
|
+
const argv = process.argv.slice(PRINT_ONLY ? 3 : 2)
|
|
1225
1823
|
const BUMPS = new Set([
|
|
1226
1824
|
'auto',
|
|
1227
1825
|
'major',
|
|
@@ -1313,9 +1911,9 @@ if (flag('--sync')) {
|
|
|
1313
1911
|
if (argv[0] === 'lint-commits') {
|
|
1314
1912
|
const rest = argv.slice(1)
|
|
1315
1913
|
const subjectAt = rest.indexOf('--subject')
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
)
|
|
1914
|
+
// This subcommand runs before `config` is bound, so it resolves its own.
|
|
1915
|
+
const lintConfig = { ...DEFAULTS, ...readUserConfig() }
|
|
1916
|
+
const ignored = lintConfig.ignoreCommits.map((pattern) => new RegExp(pattern, 'i'))
|
|
1319
1917
|
|
|
1320
1918
|
let subjects
|
|
1321
1919
|
let scope
|
|
@@ -1328,7 +1926,7 @@ if (argv[0] === 'lint-commits') {
|
|
|
1328
1926
|
scope = null
|
|
1329
1927
|
} else {
|
|
1330
1928
|
if (!tryRead('git', ['rev-parse', '--show-toplevel'])) abort('not inside a git repository')
|
|
1331
|
-
const lastTag =
|
|
1929
|
+
const lastTag = lastReleaseTag({ prefix: lintConfig.tagPrefix ?? '' })
|
|
1332
1930
|
const range =
|
|
1333
1931
|
rest.find((arg) => !arg.startsWith('-')) ?? (lastTag ? `${lastTag}..HEAD` : 'HEAD')
|
|
1334
1932
|
// Merges carry no prose of their own, and %s is enough: nothing here reads the body.
|
|
@@ -1434,6 +2032,14 @@ const userConfig = readUserConfig()
|
|
|
1434
2032
|
const config = { ...DEFAULTS, ...userConfig }
|
|
1435
2033
|
const unknownKeys = Object.keys(config).filter((key) => !(key in DEFAULTS))
|
|
1436
2034
|
if (unknownKeys.length) abort(`release.config.json has unknown keys: ${unknownKeys.join(', ')}`)
|
|
2035
|
+
// A misspelled hook name is a hook that silently never runs, which is the failure mode
|
|
2036
|
+
// this file refuses everywhere else it takes a name.
|
|
2037
|
+
const unknownHooks = Object.keys(config.hooks ?? {}).filter((key) => !HOOKS.includes(key))
|
|
2038
|
+
if (unknownHooks.length) {
|
|
2039
|
+
abort(
|
|
2040
|
+
`release.config.json has unknown hooks: ${unknownHooks.join(', ')}\n Known: ${HOOKS.join(', ')}`,
|
|
2041
|
+
)
|
|
2042
|
+
}
|
|
1437
2043
|
|
|
1438
2044
|
/**
|
|
1439
2045
|
* Which steps run: config provides the baseline, --only replaces it, --skip subtracts, and
|
|
@@ -1455,17 +2061,94 @@ const parseStepList = (value) =>
|
|
|
1455
2061
|
const manifest = existsSync('package.json') ? readJson('package.json') : null
|
|
1456
2062
|
|
|
1457
2063
|
/**
|
|
1458
|
-
*
|
|
1459
|
-
*
|
|
1460
|
-
*
|
|
2064
|
+
* Files that identify a repository, most definitive first. The first one present is where
|
|
2065
|
+
* the version is read from and written to. `go.mod` is absent because Go modules carry no
|
|
2066
|
+
* version — the tag is the version.
|
|
1461
2067
|
*/
|
|
2068
|
+
const MANIFESTS = ['package.json', 'pyproject.toml', 'Cargo.toml', 'VERSION']
|
|
2069
|
+
|
|
2070
|
+
/** Where this project keeps its version, when the config does not say. */
|
|
1462
2071
|
function detectVersionFile() {
|
|
1463
|
-
for (const candidate of
|
|
1464
|
-
if (existsSync(candidate)) return candidate
|
|
1465
|
-
}
|
|
2072
|
+
for (const candidate of MANIFESTS) if (existsSync(candidate)) return candidate
|
|
1466
2073
|
return null
|
|
1467
2074
|
}
|
|
1468
2075
|
|
|
2076
|
+
/**
|
|
2077
|
+
* Manifests that name an ecosystem, so a second one present means a second registry. A
|
|
2078
|
+
* bare VERSION file is deliberately not one: it names nothing, and a repository keeping an
|
|
2079
|
+
* unrelated VERSION beside its manifest should not be told the two disagree.
|
|
2080
|
+
*/
|
|
2081
|
+
const ECOSYSTEM_MANIFESTS = ['package.json', 'pyproject.toml', 'Cargo.toml']
|
|
2082
|
+
|
|
2083
|
+
/**
|
|
2084
|
+
* Some repositories release one source tree to two ecosystems at once — a Tauri plugin is
|
|
2085
|
+
* a crate and an npm package, a maturin project is a crate and a wheel — and carry the
|
|
2086
|
+
* version in both manifests. Those bump together with no config.
|
|
2087
|
+
*
|
|
2088
|
+
* The safety rule is that they must already agree. Two manifests on different versions are
|
|
2089
|
+
* two independent version lines, and dragging one to the other's number is a silent, wrong
|
|
2090
|
+
* release; say so and touch nothing instead.
|
|
2091
|
+
*
|
|
2092
|
+
* @returns {string[]} further files to keep in step with the primary version source
|
|
2093
|
+
*/
|
|
2094
|
+
function detectCompanionFiles(primaryPath, primaryVersion) {
|
|
2095
|
+
const companions = []
|
|
2096
|
+
for (const candidate of ECOSYSTEM_MANIFESTS) {
|
|
2097
|
+
if (candidate === primaryPath || !existsSync(candidate)) continue
|
|
2098
|
+
const found = readVersionFrom({ path: candidate })
|
|
2099
|
+
if (found !== primaryVersion) {
|
|
2100
|
+
warn(
|
|
2101
|
+
`${candidate} is at ${found ?? 'no readable version'} while ${primaryPath} is at ` +
|
|
2102
|
+
`${primaryVersion}, so they are versioned separately — leaving ${candidate} alone.\n` +
|
|
2103
|
+
' Add it to "versionFiles" in release.config.json to bump them together.',
|
|
2104
|
+
)
|
|
2105
|
+
continue
|
|
2106
|
+
}
|
|
2107
|
+
companions.push(candidate)
|
|
2108
|
+
// The lockfile pins the crate's own version too, so a bump leaves it stale.
|
|
2109
|
+
if (candidate === 'Cargo.toml' && existsSync('Cargo.lock')) companions.push('Cargo.lock')
|
|
2110
|
+
}
|
|
2111
|
+
return companions
|
|
2112
|
+
}
|
|
2113
|
+
|
|
2114
|
+
/**
|
|
2115
|
+
* Where a language records no version of its own, a project that wants `--version` to work
|
|
2116
|
+
* keeps one in source instead: a Go module has only `go.mod`, which carries no version at
|
|
2117
|
+
* all, so the number lives in a `version.go` the tag is supposed to match.
|
|
2118
|
+
*
|
|
2119
|
+
* These mirror the tag rather than define it — `go get` resolves a tag, not a constant —
|
|
2120
|
+
* so they are detected as files to keep in step, never as the source of truth.
|
|
2121
|
+
*
|
|
2122
|
+
* A candidate is adopted only when it already carries the current version, the same rule
|
|
2123
|
+
* the companion manifests use. Here it does a second job: it rules out the `var Version =
|
|
2124
|
+
* "dev"` placeholder that a build replaces with -ldflags, which is not a version to bump
|
|
2125
|
+
* and is common enough that warning about it every release would be pure noise. Mismatches
|
|
2126
|
+
* are therefore skipped silently, unlike a manifest on its own version line.
|
|
2127
|
+
*
|
|
2128
|
+
* Anything outside this table is three lines of `versionFiles` config with a `pattern`;
|
|
2129
|
+
* this covers the convention that comes up without one.
|
|
2130
|
+
*/
|
|
2131
|
+
const VERSION_MIRRORS = [
|
|
2132
|
+
{
|
|
2133
|
+
// `const Version = "1.2.0"`, `var Version = "1.2.0"`, and the same inside a const
|
|
2134
|
+
// block or with an explicit `string` type.
|
|
2135
|
+
pattern: '^\\s*(?:const\\s+|var\\s+)?[Vv]ersion\\s*(?:string\\s*)?=\\s*"(.+)"',
|
|
2136
|
+
paths: ['version.go', 'internal/version/version.go', 'pkg/version/version.go'],
|
|
2137
|
+
},
|
|
2138
|
+
]
|
|
2139
|
+
|
|
2140
|
+
/** @returns {{path: string, pattern: string}[]} source files already carrying `version` */
|
|
2141
|
+
function detectVersionMirrors(version) {
|
|
2142
|
+
const found = []
|
|
2143
|
+
for (const { paths, pattern } of VERSION_MIRRORS) {
|
|
2144
|
+
for (const path of paths) {
|
|
2145
|
+
if (!existsSync(path)) continue
|
|
2146
|
+
if (readVersionFrom({ path, pattern }) === version) found.push({ path, pattern })
|
|
2147
|
+
}
|
|
2148
|
+
}
|
|
2149
|
+
return found
|
|
2150
|
+
}
|
|
2151
|
+
|
|
1469
2152
|
/**
|
|
1470
2153
|
* The publish command implied by a project's manifest, but only where one ecosystem
|
|
1471
2154
|
* obviously owns it. Python has several publishers (uv, twine, poetry, flit) and Go has
|
|
@@ -1477,6 +2160,26 @@ const PUBLISH_BY_MANIFEST = {
|
|
|
1477
2160
|
'Cargo.toml': 'cargo publish',
|
|
1478
2161
|
}
|
|
1479
2162
|
|
|
2163
|
+
/**
|
|
2164
|
+
* A manifest that must not be published says so in itself. Detection honours that: a
|
|
2165
|
+
* private package or an unpublishable crate is one this repository releases by tag alone,
|
|
2166
|
+
* and detecting a command for it would attempt the one thing the manifest forbids.
|
|
2167
|
+
*/
|
|
2168
|
+
function detectablePublish(path) {
|
|
2169
|
+
if (basename(path) === 'package.json') return !readJson(path).private
|
|
2170
|
+
if (basename(path) === 'Cargo.toml') {
|
|
2171
|
+
const text = readFileSync(path, 'utf8')
|
|
2172
|
+
if (/^publish\s*=\s*false/m.test(text)) return false
|
|
2173
|
+
// A crate built only as a cdylib is a native extension module — what maturin and
|
|
2174
|
+
// napi-rs compile into a wheel or a .node — not a library anyone depends on from
|
|
2175
|
+
// crates.io. Its version travels with the package it is built into, which is why the
|
|
2176
|
+
// two match; publishing it to crates.io is the one thing nobody asked for.
|
|
2177
|
+
const crateTypes = /^crate-type\s*=\s*\[([^\]]*)\]/m.exec(text)?.[1]
|
|
2178
|
+
if (crateTypes?.includes('cdylib') && !crateTypes.includes('rlib')) return false
|
|
2179
|
+
}
|
|
2180
|
+
return true
|
|
2181
|
+
}
|
|
2182
|
+
|
|
1480
2183
|
// An explicit `versionFile: null` means "versions by tag" and must not be re-detected, so
|
|
1481
2184
|
// the distinction is between the key being absent and the key being set to null.
|
|
1482
2185
|
const configuredVersionFile = Object.hasOwn(userConfig, 'versionFile')
|
|
@@ -1502,11 +2205,7 @@ if (versionFile && !existsSync(versionFile.path)) {
|
|
|
1502
2205
|
* the version has to be typed out in full every time.
|
|
1503
2206
|
*/
|
|
1504
2207
|
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
|
|
2208
|
+
return releaseTags()[0]?.version ?? null
|
|
1510
2209
|
}
|
|
1511
2210
|
|
|
1512
2211
|
const currentVersion = versionFile ? readVersionFrom(versionFile) : versionFromLastTag()
|
|
@@ -1524,13 +2223,73 @@ const goModule = existsSync('go.mod')
|
|
|
1524
2223
|
const projectName =
|
|
1525
2224
|
manifest?.name ?? (versionFile ? readNameFrom(versionFile) : null) ?? goModule ?? basename(root)
|
|
1526
2225
|
|
|
2226
|
+
/**
|
|
2227
|
+
* Manifests found beside the primary one that carry the same version. Detected only when
|
|
2228
|
+
* the project has said nothing about either key: a project that listed its own
|
|
2229
|
+
* `versionFiles` has already answered this question, and quietly appending to that answer
|
|
2230
|
+
* would release files it deliberately left out.
|
|
2231
|
+
*/
|
|
2232
|
+
const detecting =
|
|
2233
|
+
!!currentVersion &&
|
|
2234
|
+
!Object.hasOwn(userConfig, 'versionFile') &&
|
|
2235
|
+
!Object.hasOwn(userConfig, 'versionFiles')
|
|
2236
|
+
const companionFiles = detecting
|
|
2237
|
+
? [
|
|
2238
|
+
...(versionFile ? detectCompanionFiles(versionFile.path, currentVersion) : []),
|
|
2239
|
+
...detectVersionMirrors(currentVersion),
|
|
2240
|
+
]
|
|
2241
|
+
: []
|
|
2242
|
+
if (companionFiles.length) {
|
|
2243
|
+
config.versionFiles = companionFiles
|
|
2244
|
+
note(
|
|
2245
|
+
`also versioned in ${companionFiles.map((entry) => versionSource(entry).path).join(', ')} ` +
|
|
2246
|
+
'(detected)',
|
|
2247
|
+
)
|
|
2248
|
+
}
|
|
2249
|
+
|
|
2250
|
+
/**
|
|
2251
|
+
* Every file the version is written into: the source of truth first, then the files kept
|
|
2252
|
+
* in step with it. A repository that versions by tag alone has no source of truth here and
|
|
2253
|
+
* may still have mirrors to write — a Go module's `version.go` is exactly that — so this
|
|
2254
|
+
* is what the version step works from, rather than `versionFile` being required.
|
|
2255
|
+
*/
|
|
2256
|
+
/**
|
|
2257
|
+
* Every file the version is written into, with `*` in a `versionFiles` path expanded to
|
|
2258
|
+
* the files it matches.
|
|
2259
|
+
*
|
|
2260
|
+
* A pattern matching nothing is an error rather than a quiet skip: it was written to keep
|
|
2261
|
+
* files in step, and silently keeping none of them in step is the failure it was meant to
|
|
2262
|
+
* prevent. `versionFile` is never globbed — the source of truth is one file, and a glob
|
|
2263
|
+
* that resolved to two would make which one wins an accident of directory order.
|
|
2264
|
+
*/
|
|
2265
|
+
const versionTargets = [
|
|
2266
|
+
...(versionFile ? [versionSource(versionFile)] : []),
|
|
2267
|
+
...config.versionFiles.map(versionSource).flatMap((source) => {
|
|
2268
|
+
if (!source.path.includes('*')) return [source]
|
|
2269
|
+
const matched = expandPaths(source.path)
|
|
2270
|
+
if (!matched.length) abort(`versionFiles pattern ${source.path} matched no files`)
|
|
2271
|
+
// A glob says "every file of this shape", and some of them legitimately carry no
|
|
2272
|
+
// version — a Tauri per-OS overlay holds only the keys it overrides. Being unable to
|
|
2273
|
+
// write one is expected here, unlike a path someone named on purpose.
|
|
2274
|
+
return matched.map((path) => Object.assign({}, source, { path, optional: true }))
|
|
2275
|
+
}),
|
|
2276
|
+
]
|
|
2277
|
+
|
|
1527
2278
|
// Same rule as versionFile: an explicit `publish: null` means "publish nothing" and is
|
|
1528
2279
|
// never re-detected. Unset means "work it out", and working it out can yield nothing.
|
|
1529
2280
|
if (!Object.hasOwn(userConfig, 'publish')) {
|
|
1530
2281
|
// Preflight already reports "no publish command configured" when the step runs, so
|
|
1531
2282
|
// there is nothing to say here — and `runs` is not resolved this early.
|
|
1532
|
-
|
|
1533
|
-
|
|
2283
|
+
//
|
|
2284
|
+
// Two manifests releasing in step means two registries: the npm command comes first
|
|
2285
|
+
// because it is the recoverable one — npm allows an unpublish for 72 hours, crates.io
|
|
2286
|
+
// never does — so a half-finished publish leaves the undoable half undone.
|
|
2287
|
+
const detected = [versionFile, ...companionFiles]
|
|
2288
|
+
.filter(Boolean)
|
|
2289
|
+
.map((entry) => versionSource(entry).path)
|
|
2290
|
+
.filter((path) => PUBLISH_BY_MANIFEST[basename(path)] && detectablePublish(path))
|
|
2291
|
+
.map((path) => PUBLISH_BY_MANIFEST[basename(path)])
|
|
2292
|
+
config.publish = detected.length ? detected : null
|
|
1534
2293
|
}
|
|
1535
2294
|
|
|
1536
2295
|
// Validate every name that was asked for, not just the ones that survive: a typo in
|
|
@@ -1550,6 +2309,20 @@ if (skippedSteps) for (const name of parseStepList(skippedSteps)) steps.delete(n
|
|
|
1550
2309
|
if (autoCommit) steps.add('commit')
|
|
1551
2310
|
const runs = (name) => steps.has(name)
|
|
1552
2311
|
|
|
2312
|
+
/**
|
|
2313
|
+
* True when the commit step is off because a config `steps` list omits it — as opposed to
|
|
2314
|
+
* being switched off for this run with --skip or --only. Configs written before `commit`
|
|
2315
|
+
* became a default step omit it without ever having chosen to, so a dirty-tree refusal
|
|
2316
|
+
* caused by one deserves a hint that the flag-driven refusal does not: the flag user just
|
|
2317
|
+
* asked for exactly this.
|
|
2318
|
+
*/
|
|
2319
|
+
const commitExcludedByConfig =
|
|
2320
|
+
!runs('commit') &&
|
|
2321
|
+
!onlySteps &&
|
|
2322
|
+
!(skippedSteps && parseStepList(skippedSteps).includes('commit')) &&
|
|
2323
|
+
Array.isArray(config.steps) &&
|
|
2324
|
+
!config.steps.includes('commit')
|
|
2325
|
+
|
|
1553
2326
|
/**
|
|
1554
2327
|
* The drafting tool, resolved from --assistant then config. "auto" picks the first one
|
|
1555
2328
|
* present on PATH; a named tool must be known and installed, otherwise it is an error
|
|
@@ -1585,11 +2358,152 @@ if (assistantChoice !== 'none' && assistantChoice !== null) {
|
|
|
1585
2358
|
}
|
|
1586
2359
|
const assistant = assistantName ? ASSISTANTS[assistantName] : null
|
|
1587
2360
|
|
|
2361
|
+
/**
|
|
2362
|
+
* Registries whose preflight can be run, keyed by the first word of the publish command.
|
|
2363
|
+
* Each declares how that CLI answers "who am I", "does this version already exist" and
|
|
2364
|
+
* "is this package there at all"; any may be null when the tool has no such notion. A
|
|
2365
|
+
* publish command outside this table (vsce, a shell pipeline) is run as written with no
|
|
2366
|
+
* preflight — it cannot be introspected, and guessing would invent failures.
|
|
2367
|
+
*
|
|
2368
|
+
* `exists` is what separates "that version was never published" from "the registry did not
|
|
2369
|
+
* answer". Both make the version lookup exit non-zero, and only the first one means the
|
|
2370
|
+
* release is unfinished — see `versionShipped`.
|
|
2371
|
+
*/
|
|
2372
|
+
const REGISTRIES = {
|
|
2373
|
+
npm: {
|
|
2374
|
+
whoami: ['whoami'],
|
|
2375
|
+
published: (name, v) => ['view', `${name}@${v}`, 'version'],
|
|
2376
|
+
exists: (name) => ['view', name, 'version'],
|
|
2377
|
+
},
|
|
2378
|
+
pnpm: {
|
|
2379
|
+
whoami: ['whoami'],
|
|
2380
|
+
published: (name, v) => ['view', `${name}@${v}`, 'version'],
|
|
2381
|
+
exists: (name) => ['view', name, 'version'],
|
|
2382
|
+
},
|
|
2383
|
+
bun: {
|
|
2384
|
+
whoami: ['pm', 'whoami'],
|
|
2385
|
+
published: (name, v) => ['pm', 'view', `${name}@${v}`, 'version'],
|
|
2386
|
+
exists: (name) => ['pm', 'view', name, 'version'],
|
|
2387
|
+
},
|
|
2388
|
+
// uv authenticates with a token from the environment rather than a logged-in session,
|
|
2389
|
+
// and skips duplicate uploads itself via --check-url, so there is no version lookup.
|
|
2390
|
+
uv: { env: ['UV_PUBLISH_TOKEN', 'UV_PUBLISH_PASSWORD'], login: 'set UV_PUBLISH_TOKEN' },
|
|
2391
|
+
// cargo has no "who am I": crates.io auth is a token, either in the environment or in
|
|
2392
|
+
// the credentials file `cargo login` writes. `cargo info` is the version lookup, and
|
|
2393
|
+
// exits non-zero for a version the index does not carry (cargo 1.82+).
|
|
2394
|
+
cargo: {
|
|
2395
|
+
env: ['CARGO_REGISTRY_TOKEN', 'CARGO_REGISTRIES_CRATES_IO_TOKEN'],
|
|
2396
|
+
credentials: [
|
|
2397
|
+
join(homedir(), '.cargo', 'credentials.toml'),
|
|
2398
|
+
join(homedir(), '.cargo', 'credentials'),
|
|
2399
|
+
],
|
|
2400
|
+
login: 'run `cargo login`, or set CARGO_REGISTRY_TOKEN',
|
|
2401
|
+
published: (name, v) => ['info', `${name}@${v}`],
|
|
2402
|
+
exists: (name) => ['info', name],
|
|
2403
|
+
},
|
|
2404
|
+
// For Go the tag is the release; `go list` warms the module proxy and doubles as the
|
|
2405
|
+
// check for whether this version is already resolvable.
|
|
2406
|
+
go: {
|
|
2407
|
+
published: (name, v) => ['list', '-m', `${name}@${v}`],
|
|
2408
|
+
exists: (name) => ['list', '-m', `${name}@latest`],
|
|
2409
|
+
},
|
|
2410
|
+
}
|
|
2411
|
+
|
|
2412
|
+
/**
|
|
2413
|
+
* Which manifest records the name a registry knows this project by, when it is not the one
|
|
2414
|
+
* `projectName` came from. They are not always the same string: a Tauri plugin publishes as
|
|
2415
|
+
* `@tauri-apps/plugin-x` on npm and `tauri-plugin-x` on crates.io, so looking the crate up
|
|
2416
|
+
* under its npm name would report every version as unpublished.
|
|
2417
|
+
*/
|
|
2418
|
+
const NAME_MANIFEST_BY_CLI = { cargo: 'Cargo.toml' }
|
|
2419
|
+
|
|
2420
|
+
function registryName(cli) {
|
|
2421
|
+
const manifest = NAME_MANIFEST_BY_CLI[cli]
|
|
2422
|
+
if (!manifest) return projectName
|
|
2423
|
+
const source = versionTargets.find((entry) => basename(entry.path) === manifest)
|
|
2424
|
+
return (source && readNameFrom(source)) ?? projectName
|
|
2425
|
+
}
|
|
2426
|
+
|
|
2427
|
+
/**
|
|
2428
|
+
* `publish` is one command or several, because one source tree can own a package in more
|
|
2429
|
+
* than one ecosystem. They run in the configured order.
|
|
2430
|
+
*/
|
|
2431
|
+
const publishList = config.publish == null ? [] : [config.publish].flat()
|
|
2432
|
+
if (publishList.some((entry) => typeof entry !== 'string')) {
|
|
2433
|
+
abort('publish must be a command string, an array of command strings, or null')
|
|
2434
|
+
}
|
|
2435
|
+
|
|
2436
|
+
/**
|
|
2437
|
+
* One answer per version, per run: the lookups are network calls, and the same version is
|
|
2438
|
+
* asked about by the baseline walk and again by preflight.
|
|
2439
|
+
*/
|
|
2440
|
+
const shippedCache = new Map()
|
|
2441
|
+
|
|
2442
|
+
/**
|
|
2443
|
+
* Whether a version actually reached every registry this project publishes to.
|
|
2444
|
+
*
|
|
2445
|
+
* A tag is not a release. The tag and the push happen before the publish, so a publish that
|
|
2446
|
+
* fails — a failing prepublish gate, an expired npm session, a network drop — leaves the
|
|
2447
|
+
* version tagged, pushed and changelogged but absent from the registry. Nothing downstream
|
|
2448
|
+
* has it, and until this could be asked, nothing upstream knew.
|
|
2449
|
+
*
|
|
2450
|
+
* "Not there" and "could not ask" are the same exit code from every one of these CLIs, and
|
|
2451
|
+
* conflating them is dangerous in one direction only: reading an unreachable registry as
|
|
2452
|
+
* "nothing was ever published" would drag the notes baseline back through the whole
|
|
2453
|
+
* history. The bare-name lookup separates them — a package whose own name resolves is a
|
|
2454
|
+
* registry that answered.
|
|
2455
|
+
*
|
|
2456
|
+
* @param {string} v
|
|
2457
|
+
* @returns {boolean | null} null when nothing here can answer
|
|
2458
|
+
*/
|
|
2459
|
+
function versionShipped(v) {
|
|
2460
|
+
if (shippedCache.has(v)) return shippedCache.get(v)
|
|
2461
|
+
let answer = null
|
|
2462
|
+
for (const template of publishList) {
|
|
2463
|
+
const cli = template.trim().split(/\s+/)[0]
|
|
2464
|
+
const registry = REGISTRIES[cli]
|
|
2465
|
+
if (!registry?.published || !registry.exists) continue
|
|
2466
|
+
const name = registryName(cli)
|
|
2467
|
+
if (succeeds(cli, registry.published(name, v))) {
|
|
2468
|
+
answer ??= true
|
|
2469
|
+
continue
|
|
2470
|
+
}
|
|
2471
|
+
// One registry missing the version is enough: the release did not finish everywhere,
|
|
2472
|
+
// and the half that is missing is the half still owed to its consumers.
|
|
2473
|
+
if (succeeds(cli, registry.exists(name))) {
|
|
2474
|
+
answer = false
|
|
2475
|
+
break
|
|
2476
|
+
}
|
|
2477
|
+
answer = null
|
|
2478
|
+
break
|
|
2479
|
+
}
|
|
2480
|
+
shippedCache.set(v, answer)
|
|
2481
|
+
return answer
|
|
2482
|
+
}
|
|
2483
|
+
|
|
2484
|
+
/**
|
|
2485
|
+
* The release that was started and never finished: the newest tag, sitting at HEAD, whose
|
|
2486
|
+
* version never reached the registry.
|
|
2487
|
+
*
|
|
2488
|
+
* Re-running the same command is the documented way to recover from a release that died
|
|
2489
|
+
* partway through, and `auto` was the one target that could not: it resolves a version from
|
|
2490
|
+
* the commits since the last tag, finds none, and aborts with "nothing to release" — while
|
|
2491
|
+
* the thing left to do is the publish the previous run never got to.
|
|
2492
|
+
*
|
|
2493
|
+
* @returns {{name: string, version: string} | null}
|
|
2494
|
+
*/
|
|
2495
|
+
function unfinishedRelease() {
|
|
2496
|
+
const [newest] = releaseTags()
|
|
2497
|
+
if (!newest || versionShipped(newest.version) !== false) return null
|
|
2498
|
+
const at = tryRead('git', ['rev-list', '-n', '1', newest.name])
|
|
2499
|
+
return at && at === tryRead('git', ['rev-parse', 'HEAD']) ? newest : null
|
|
2500
|
+
}
|
|
2501
|
+
|
|
1588
2502
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
1589
2503
|
// RESOLVE THE TARGET VERSION
|
|
1590
2504
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
1591
2505
|
|
|
1592
|
-
|
|
2506
|
+
say(
|
|
1593
2507
|
bold(`${projectName} release`) +
|
|
1594
2508
|
(dryRun ? ` ${yellow('(dry run — nothing will execute)')}` : ''),
|
|
1595
2509
|
)
|
|
@@ -1597,6 +2511,9 @@ console.log(
|
|
|
1597
2511
|
/** What `auto` inferred, kept so preflight can show the reasoning. */
|
|
1598
2512
|
let autoBump = null
|
|
1599
2513
|
|
|
2514
|
+
/** The tag of a previous release this run is finishing rather than starting. */
|
|
2515
|
+
let resuming = null
|
|
2516
|
+
|
|
1600
2517
|
let version
|
|
1601
2518
|
if (!target) {
|
|
1602
2519
|
if (!currentVersion) {
|
|
@@ -1613,24 +2530,35 @@ if (!target) {
|
|
|
1613
2530
|
'from.\n Pass the first version explicitly: release-kit 0.1.0',
|
|
1614
2531
|
)
|
|
1615
2532
|
}
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
if (
|
|
1624
|
-
|
|
1625
|
-
abort(`Release-As: ${autoBump.releaseAs} in a commit is not a semver version`)
|
|
1626
|
-
}
|
|
1627
|
-
version = autoBump.releaseAs
|
|
2533
|
+
// A release that died after the tag and before the publish is finished by re-running the
|
|
2534
|
+
// same command — but only while nothing new has happened. A commit or a working tree that
|
|
2535
|
+
// `--commit` is about to turn into one moves HEAD past the tag, and publishing then would
|
|
2536
|
+
// ship a tree the tag does not describe; that work belongs in the next version, which is
|
|
2537
|
+
// what the baseline below makes sure it is released as.
|
|
2538
|
+
const wouldCommitMore = !!tryRead('git', ['status', '--porcelain']) && runs('commit')
|
|
2539
|
+
const pending = wouldCommitMore ? null : unfinishedRelease()
|
|
2540
|
+
if (pending) {
|
|
2541
|
+
;({ name: resuming, version } = pending)
|
|
1628
2542
|
} else {
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
2543
|
+
const { commits, lastTag } = commitsSinceLastTag({ shipped: true })
|
|
2544
|
+
if (!commits.length) {
|
|
2545
|
+
abort(
|
|
2546
|
+
`no releasable commits since ${lastTag ?? 'the start of the project'} — nothing to release`,
|
|
2547
|
+
)
|
|
2548
|
+
}
|
|
2549
|
+
autoBump = inferBump(commits, currentVersion, config.versioning)
|
|
2550
|
+
if (autoBump.releaseAs) {
|
|
2551
|
+
if (!parseVersion(autoBump.releaseAs)) {
|
|
2552
|
+
abort(`Release-As: ${autoBump.releaseAs} in a commit is not a semver version`)
|
|
2553
|
+
}
|
|
2554
|
+
version = autoBump.releaseAs
|
|
2555
|
+
} else {
|
|
2556
|
+
version = incrementVersion(
|
|
2557
|
+
currentVersion,
|
|
2558
|
+
autoBump.bump,
|
|
2559
|
+
requestedPreid ?? preidOf(currentVersion),
|
|
2560
|
+
)
|
|
2561
|
+
}
|
|
1634
2562
|
}
|
|
1635
2563
|
} else if (BUMPS.has(target)) {
|
|
1636
2564
|
if (!currentVersion) {
|
|
@@ -1653,8 +2581,13 @@ if (!target) {
|
|
|
1653
2581
|
}
|
|
1654
2582
|
|
|
1655
2583
|
const tag = `${config.tagPrefix}${version}`
|
|
2584
|
+
if (PRINT_ONLY) {
|
|
2585
|
+
console.log(version)
|
|
2586
|
+
process.exit(0)
|
|
2587
|
+
}
|
|
2588
|
+
|
|
1656
2589
|
const isPrerelease = parseVersion(version).pre.length > 0
|
|
1657
|
-
const bumping =
|
|
2590
|
+
const bumping = versionTargets.length > 0 && version !== currentVersion && runs('version')
|
|
1658
2591
|
|
|
1659
2592
|
let distTag
|
|
1660
2593
|
try {
|
|
@@ -1681,32 +2614,55 @@ const expand = (template) => expandWith(template, (value) => value)
|
|
|
1681
2614
|
const shellQuote = (value) => `'${value.replaceAll("'", `'\\''`)}'`
|
|
1682
2615
|
const expandShell = (template) => expandWith(template, shellQuote)
|
|
1683
2616
|
|
|
1684
|
-
|
|
2617
|
+
/**
|
|
2618
|
+
* Run a lifecycle hook, if the project configured one.
|
|
2619
|
+
*
|
|
2620
|
+
* Anything a hook leaves modified is staged for the release commit. Preflight has already
|
|
2621
|
+
* established that the tree was clean (or that the `commit` step is committing all of it),
|
|
2622
|
+
* so a file that is dirty now was produced by this release and belongs in it — which is
|
|
2623
|
+
* what makes `afterVersion` useful for regenerating a file derived from the version.
|
|
2624
|
+
*
|
|
2625
|
+
* @param {string} name one of HOOKS
|
|
2626
|
+
*/
|
|
2627
|
+
function runHook(name) {
|
|
2628
|
+
const configured = config.hooks?.[name]
|
|
2629
|
+
if (!configured) return
|
|
2630
|
+
const commands = Array.isArray(configured) ? configured : [configured]
|
|
2631
|
+
step(`Hook ${name}`)
|
|
2632
|
+
for (const command of commands) mutateShell(expandShell(command))
|
|
2633
|
+
if (dryRun) return
|
|
2634
|
+
for (const path of dirtyPaths()) if (!staged.includes(path)) staged.push(path)
|
|
2635
|
+
}
|
|
1685
2636
|
|
|
1686
2637
|
/**
|
|
1687
|
-
*
|
|
1688
|
-
*
|
|
1689
|
-
*
|
|
1690
|
-
*
|
|
1691
|
-
*
|
|
2638
|
+
* Paths git reports as changed, whatever the change is.
|
|
2639
|
+
*
|
|
2640
|
+
* The status column cannot be sliced at a fixed offset: the capture is trimmed, which
|
|
2641
|
+
* strips the leading space off the first entry only, so ` M file` arrives as `M file`
|
|
2642
|
+
* while the rest keep theirs. Split on the gap after the code instead.
|
|
1692
2643
|
*/
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
uv: { env: ['UV_PUBLISH_TOKEN', 'UV_PUBLISH_PASSWORD'], login: 'set UV_PUBLISH_TOKEN' },
|
|
1703
|
-
// For Go the tag is the release; `go list` warms the module proxy and doubles as the
|
|
1704
|
-
// check for whether this version is already resolvable.
|
|
1705
|
-
go: { published: (name, v) => ['list', '-m', `${name}@${v}`] },
|
|
2644
|
+
function dirtyPaths() {
|
|
2645
|
+
return (
|
|
2646
|
+
(tryRead('git', ['status', '--porcelain']) ?? '')
|
|
2647
|
+
.split('\n')
|
|
2648
|
+
.map((line) => /^\s*\S{1,2}\s+(.+)$/.exec(line)?.[1]?.trim())
|
|
2649
|
+
.filter(Boolean)
|
|
2650
|
+
// A rename reads as "old -> new"; the new path is the one to stage.
|
|
2651
|
+
.map((path) => path.split(' -> ').at(-1))
|
|
2652
|
+
)
|
|
1706
2653
|
}
|
|
1707
2654
|
|
|
1708
|
-
|
|
1709
|
-
const
|
|
2655
|
+
/** Each publish command with the CLI it drives, that CLI's preflight row, and its name. */
|
|
2656
|
+
const publishTargets = runs('publish')
|
|
2657
|
+
? publishList.map((template) => {
|
|
2658
|
+
const command = expandShell(template)
|
|
2659
|
+
const cli = command.trim().split(/\s+/)[0]
|
|
2660
|
+
return { command, cli, registry: REGISTRIES[cli] ?? null, name: registryName(cli) }
|
|
2661
|
+
})
|
|
2662
|
+
: []
|
|
2663
|
+
|
|
2664
|
+
/** npm-family commands are the ones a `"private": true` package.json forbids. */
|
|
2665
|
+
const NPM_CLIS = new Set(['npm', 'pnpm', 'bun'])
|
|
1710
2666
|
|
|
1711
2667
|
/**
|
|
1712
2668
|
* CI publishing over OIDC ("trusted publishing") carries no token at all: `whoami` fails
|
|
@@ -1751,10 +2707,68 @@ if (autoBump) {
|
|
|
1751
2707
|
}
|
|
1752
2708
|
}
|
|
1753
2709
|
|
|
1754
|
-
if (
|
|
2710
|
+
if (resuming) {
|
|
2711
|
+
ok(`finishing ${resuming}: it was tagged and pushed, but never reached the registry`)
|
|
2712
|
+
}
|
|
2713
|
+
|
|
2714
|
+
// A previous release that never shipped is not history — its commits are still owed to
|
|
2715
|
+
// whoever installs this package, and they are in this release's range because of it. Say
|
|
2716
|
+
// so: the changelog keeps the section that was written for that version, and a section
|
|
2717
|
+
// naming a version no registry carries is worth a human deciding about.
|
|
2718
|
+
const absorbed = absorbedReleaseTags({ stable: !isPrerelease }).filter(
|
|
2719
|
+
(entry) => entry.version !== version,
|
|
2720
|
+
)
|
|
2721
|
+
if (absorbed.length) {
|
|
2722
|
+
const names = absorbed.map((entry) => entry.name).join(', ')
|
|
2723
|
+
const many = absorbed.length > 1
|
|
2724
|
+
const existingChangelog =
|
|
2725
|
+
config.changelog && existsSync(config.changelog) ? readFileSync(config.changelog, 'utf8') : null
|
|
2726
|
+
const documented = absorbed
|
|
2727
|
+
.filter((entry) => existingChangelog && changelogSection(existingChangelog, entry.version))
|
|
2728
|
+
.map((entry) => entry.version)
|
|
2729
|
+
const stale = documented.length
|
|
2730
|
+
? `\n ${config.changelog} still documents ${documented.join(', ')} — ${
|
|
2731
|
+
documented.length > 1 ? 'versions' : 'a version'
|
|
2732
|
+
} no registry carries. Fold ${
|
|
2733
|
+
documented.length > 1 ? 'those sections' : 'that section'
|
|
2734
|
+
} into ${version} by hand.`
|
|
2735
|
+
: ''
|
|
2736
|
+
warn(
|
|
2737
|
+
`${names} ${many ? 'were' : 'was'} tagged but never published, so ${version} ships ${
|
|
2738
|
+
many ? 'their' : 'its'
|
|
2739
|
+
} commits as well as its own.${stale}`,
|
|
2740
|
+
)
|
|
2741
|
+
}
|
|
2742
|
+
|
|
2743
|
+
// Writing the version is the first mutating step, and it used to discover a file it
|
|
2744
|
+
// could not write *while writing the others* — aborting with a raw stack trace after
|
|
2745
|
+
// some of them had already changed. Every target is checked here instead.
|
|
2746
|
+
if (bumping) {
|
|
2747
|
+
for (const source of versionTargets) {
|
|
2748
|
+
if (!existsSync(source.path)) {
|
|
2749
|
+
fail(`versionFiles entry ${source.path} does not exist`)
|
|
2750
|
+
continue
|
|
2751
|
+
}
|
|
2752
|
+
if (source.optional) continue
|
|
2753
|
+
const text = readFileSync(source.path, 'utf8')
|
|
2754
|
+
const { kind, shape } = versionMode(source, text)
|
|
2755
|
+
if (kind === 'pattern' && !shape.test(text)) {
|
|
2756
|
+
fail(
|
|
2757
|
+
`${source.path} has no version for release-kit to replace.\n` +
|
|
2758
|
+
' Remove it from versionFiles, mark the line with x-release-kit-version, ' +
|
|
2759
|
+
'or give the entry a "pattern".',
|
|
2760
|
+
)
|
|
2761
|
+
}
|
|
2762
|
+
}
|
|
2763
|
+
}
|
|
2764
|
+
|
|
2765
|
+
if (bumping && currentVersion && compareVersions(version, currentVersion) <= 0) {
|
|
1755
2766
|
fail(`${version} is not greater than the current version ${currentVersion}`)
|
|
1756
|
-
} else if (bumping) {
|
|
2767
|
+
} else if (bumping && currentVersion) {
|
|
1757
2768
|
ok(`version ${currentVersion} → ${version}`)
|
|
2769
|
+
} else if (bumping) {
|
|
2770
|
+
// No manifest and no tag to read a version from, but files to write one into.
|
|
2771
|
+
ok(`writing ${version} into ${versionTargets.map((source) => source.path).join(', ')}`)
|
|
1758
2772
|
} else if (versionFile) {
|
|
1759
2773
|
ok(`releasing the version already in ${versionFile.path} (${version})`)
|
|
1760
2774
|
} else {
|
|
@@ -1789,7 +2803,12 @@ else if (dirty && runs('commit')) {
|
|
|
1789
2803
|
)
|
|
1790
2804
|
}
|
|
1791
2805
|
} else if (dirty) {
|
|
1792
|
-
|
|
2806
|
+
const hint = commitExcludedByConfig
|
|
2807
|
+
? '\n The steps list in release.config.json omits `commit` (it may predate ' +
|
|
2808
|
+
'commit becoming\n a default step). Add "commit" to it, or pass --commit ' +
|
|
2809
|
+
'to commit these now.'
|
|
2810
|
+
: ''
|
|
2811
|
+
fail(`working tree is not clean:\n${indent(formatStatus(dirty))}${hint}`)
|
|
1793
2812
|
} else ok('working tree clean')
|
|
1794
2813
|
|
|
1795
2814
|
if (assistant) {
|
|
@@ -1848,7 +2867,7 @@ if (!succeeds('git', ['remote', 'get-url', config.remote])) {
|
|
|
1848
2867
|
// that is a failure; commit-derived notes merely come out partial, so that is a warning.
|
|
1849
2868
|
let shallowHidesHistory = false
|
|
1850
2869
|
if (shallow) {
|
|
1851
|
-
const reachableTag =
|
|
2870
|
+
const reachableTag = lastReleaseTag()
|
|
1852
2871
|
shallowHidesHistory = !reachableTag
|
|
1853
2872
|
if (reachableTag) {
|
|
1854
2873
|
ok(
|
|
@@ -1953,37 +2972,70 @@ if (!runs('release')) {
|
|
|
1953
2972
|
if (releaseExists) note(`a GitHub release for ${tag} already exists — will skip that step`)
|
|
1954
2973
|
}
|
|
1955
2974
|
|
|
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 {
|
|
2975
|
+
/** Whether one publish CLI can publish at all: OIDC, a token, or a live session. */
|
|
2976
|
+
function checkCredentials({ cli, registry, command }) {
|
|
1964
2977
|
if (isTrustedPublishing) {
|
|
1965
|
-
ok(
|
|
1966
|
-
|
|
1967
|
-
//
|
|
2978
|
+
ok(`${cli}: trusted publishing (OIDC) — no token needed`)
|
|
2979
|
+
// Provenance is the other half of what OIDC makes possible: a signed attestation
|
|
2980
|
+
// tying the published artefact to the workflow and commit that produced it. It is
|
|
2981
|
+
// not added to the command here — npm generates it for a trusted publish on its own,
|
|
2982
|
+
// and forcing the flag fails outright for a private package or a registry that
|
|
2983
|
+
// cannot receive one. Saying so is what turns "available" into "used".
|
|
2984
|
+
if (NPM_CLIS.has(cli) && !/--provenance\b/.test(command ?? '')) {
|
|
2985
|
+
note(
|
|
2986
|
+
`${cli}: OIDC also allows a signed provenance attestation — add --provenance to ` +
|
|
2987
|
+
'the publish command if the registry accepts one and the package is public',
|
|
2988
|
+
)
|
|
2989
|
+
}
|
|
2990
|
+
return
|
|
2991
|
+
}
|
|
2992
|
+
if (registry.env) {
|
|
2993
|
+
// Token auth: there is no session to interrogate, only credentials to find.
|
|
1968
2994
|
const found = registry.env.find((name) => process.env[name])
|
|
1969
|
-
if (found)
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
2995
|
+
if (found) {
|
|
2996
|
+
ok(`${cli} credentials found (${found})`)
|
|
2997
|
+
return
|
|
2998
|
+
}
|
|
2999
|
+
const file = registry.credentials?.find((path) => existsSync(path))
|
|
3000
|
+
if (file) ok(`${cli} credentials found (${file})`)
|
|
3001
|
+
else fail(`${cli} has no publish credentials — ${registry.login}`)
|
|
3002
|
+
return
|
|
3003
|
+
}
|
|
3004
|
+
if (registry.whoami) {
|
|
3005
|
+
const user = tryRead(cli, registry.whoami)
|
|
1973
3006
|
if (user === null) {
|
|
1974
3007
|
// npm replaced long-lived tokens with two-hour sessions in December 2025, so the
|
|
1975
3008
|
// usual cause is an expired session rather than a missing login.
|
|
1976
3009
|
fail(
|
|
1977
|
-
`${
|
|
3010
|
+
`${cli} is not authenticated — run \`${cli} login\`. ` +
|
|
1978
3011
|
'npm logins are two-hour sessions, so an earlier one may have expired.',
|
|
1979
3012
|
)
|
|
1980
|
-
} else ok(`${
|
|
3013
|
+
} else ok(`${cli} authenticated (${user || 'unknown user'})`)
|
|
1981
3014
|
}
|
|
3015
|
+
}
|
|
1982
3016
|
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
3017
|
+
/** Commands whose version is already on the registry, so the publish step skips them. */
|
|
3018
|
+
const alreadyPublished = new Set()
|
|
3019
|
+
if (!publishTargets.length) {
|
|
3020
|
+
note(runs('publish') ? 'no publish command configured' : 'publish step not selected')
|
|
3021
|
+
} else if (manifest?.private && publishTargets.some((target) => NPM_CLIS.has(target.cli))) {
|
|
3022
|
+
fail('package.json is private but an npm publish command is configured')
|
|
3023
|
+
} else {
|
|
3024
|
+
// One CLI can appear more than once; interrogating it twice says the same thing twice.
|
|
3025
|
+
const authenticated = new Set()
|
|
3026
|
+
for (const target of publishTargets) {
|
|
3027
|
+
ok(`publish: ${target.command}`)
|
|
3028
|
+
if (!target.registry) continue
|
|
3029
|
+
if (!authenticated.has(target.cli)) {
|
|
3030
|
+
authenticated.add(target.cli)
|
|
3031
|
+
checkCredentials(target)
|
|
3032
|
+
}
|
|
3033
|
+
if (
|
|
3034
|
+
target.registry.published &&
|
|
3035
|
+
succeeds(target.cli, target.registry.published(target.name, version))
|
|
3036
|
+
) {
|
|
3037
|
+
alreadyPublished.add(target.command)
|
|
3038
|
+
note(`${target.name}@${version} is already published — will skip \`${target.command}\``)
|
|
1987
3039
|
}
|
|
1988
3040
|
}
|
|
1989
3041
|
}
|
|
@@ -2027,13 +3079,29 @@ let draftedNotes = null
|
|
|
2027
3079
|
*/
|
|
2028
3080
|
const notesDeferred = !!(dirty && runs('commit'))
|
|
2029
3081
|
|
|
3082
|
+
/**
|
|
3083
|
+
* Set only when preflight found nothing to release with and left the drafting to the
|
|
3084
|
+
* post-commit step. Deferral has to be recorded rather than re-derived from `notesDeferred`
|
|
3085
|
+
* there: a dirty tree is what makes drafting possible to defer, not what makes it necessary.
|
|
3086
|
+
* A hand-written changelog section has already answered the question, and re-drafting over
|
|
3087
|
+
* it would discard the notes the confirmation prompt showed and append a second section for
|
|
3088
|
+
* the same version.
|
|
3089
|
+
*/
|
|
3090
|
+
let notesPending = false
|
|
3091
|
+
|
|
2030
3092
|
/**
|
|
2031
3093
|
* Notes for a version, in descending order of how much they can be trusted:
|
|
2032
3094
|
* an assistant's prose when one is configured, otherwise the commits grouped by
|
|
2033
3095
|
* Conventional Commit type. Only when neither yields anything does GitHub generate them.
|
|
2034
3096
|
*/
|
|
2035
3097
|
function draftNotesFor(v) {
|
|
2036
|
-
|
|
3098
|
+
// A stable release absorbs the candidates that led to it: their commits are what it
|
|
3099
|
+
// ships, and reading from the last candidate leaves the notes describing the gap
|
|
3100
|
+
// between two candidates rather than the release.
|
|
3101
|
+
const { lastTag, subjects, commits, contributors } = commitsSinceLastTag({
|
|
3102
|
+
stable: !isPrerelease,
|
|
3103
|
+
shipped: true,
|
|
3104
|
+
})
|
|
2037
3105
|
if (!commits.length) return null
|
|
2038
3106
|
|
|
2039
3107
|
// Notes are built from Conventional Commits, so anything not written that way is simply
|
|
@@ -2048,7 +3116,12 @@ function draftNotesFor(v) {
|
|
|
2048
3116
|
}
|
|
2049
3117
|
// An explicitly named source wins over the assistant being merely available.
|
|
2050
3118
|
if (!assistant || notesSource === 'commits')
|
|
2051
|
-
return changelogFromCommits(
|
|
3119
|
+
return changelogFromCommits(
|
|
3120
|
+
commits,
|
|
3121
|
+
remoteLinks(config.remote),
|
|
3122
|
+
config.hiddenTypes,
|
|
3123
|
+
contributors,
|
|
3124
|
+
)
|
|
2052
3125
|
if (shallowHidesHistory) {
|
|
2053
3126
|
warn(
|
|
2054
3127
|
`shallow clone: only ${subjects.length} commit(s) are visible, so the notes will ` +
|
|
@@ -2058,7 +3131,7 @@ function draftNotesFor(v) {
|
|
|
2058
3131
|
note(`drafting notes from ${subjects.length} commit(s) with ${assistantName}...`)
|
|
2059
3132
|
return (
|
|
2060
3133
|
draftReleaseNotes(v, commits, lastTag, remoteLinks(config.remote)) ??
|
|
2061
|
-
changelogFromCommits(commits, remoteLinks(config.remote), config.hiddenTypes)
|
|
3134
|
+
changelogFromCommits(commits, remoteLinks(config.remote), config.hiddenTypes, contributors)
|
|
2062
3135
|
)
|
|
2063
3136
|
}
|
|
2064
3137
|
const changelogText =
|
|
@@ -2091,6 +3164,7 @@ if (notesSource === 'github') {
|
|
|
2091
3164
|
// Generate, either because nothing was written or because a source was named.
|
|
2092
3165
|
if (!notes) {
|
|
2093
3166
|
if (notesDeferred) {
|
|
3167
|
+
notesPending = true
|
|
2094
3168
|
ok(`release notes will be ${assistant ? 'drafted' : 'generated'} after the commit`)
|
|
2095
3169
|
} else {
|
|
2096
3170
|
draftedNotes = draftNotesFor(version)
|
|
@@ -2119,13 +3193,19 @@ for (const asset of config.assets) {
|
|
|
2119
3193
|
|
|
2120
3194
|
// Reusing a tag is the resume path, and a resume writes nothing. If this run would still
|
|
2121
3195
|
// produce a commit, that commit moves HEAD past the tag and the release ends up tagged at
|
|
2122
|
-
// the wrong revision — which is silent until someone checks out the tag
|
|
2123
|
-
|
|
3196
|
+
// the wrong revision — which is silent until someone checks out the tag, and worse for the
|
|
3197
|
+
// working tree: `publish` sends what is on disk now, not what the tag describes.
|
|
3198
|
+
const wouldCommit = [
|
|
3199
|
+
dirty && runs('commit') && 'the working tree',
|
|
3200
|
+
bumping && 'a version bump',
|
|
3201
|
+
rolledChangelog && 'a changelog entry',
|
|
3202
|
+
].filter(Boolean)
|
|
3203
|
+
if (taggedCommit && runs('tag') && wouldCommit.length) {
|
|
2124
3204
|
fail(
|
|
2125
3205
|
`tag ${tag} already exists at HEAD, but this run would still commit ` +
|
|
2126
|
-
`${
|
|
2127
|
-
' That commit would leave the tag behind HEAD
|
|
2128
|
-
'--only with the steps that remain.',
|
|
3206
|
+
`${wouldCommit.join(' and ')}.\n` +
|
|
3207
|
+
' That commit would leave the tag behind HEAD, and publish a tree it does not ' +
|
|
3208
|
+
'describe.\n Release a new version, or use --only with the steps that remain.',
|
|
2129
3209
|
)
|
|
2130
3210
|
}
|
|
2131
3211
|
|
|
@@ -2211,33 +3291,38 @@ if (dirty && runs('commit')) {
|
|
|
2211
3291
|
else mutate('git', ['commit', '-m', commitMessage])
|
|
2212
3292
|
|
|
2213
3293
|
// Now that the commit exists it is part of the release, so the notes can describe it.
|
|
2214
|
-
if (
|
|
3294
|
+
if (notesPending && !dryRun) {
|
|
2215
3295
|
draftedNotes = draftNotesFor(version)
|
|
2216
3296
|
if (draftedNotes) notes = draftedNotes
|
|
2217
3297
|
}
|
|
2218
3298
|
}
|
|
2219
3299
|
|
|
3300
|
+
runHook('beforeVersion')
|
|
3301
|
+
|
|
2220
3302
|
if (bumping) {
|
|
2221
3303
|
step(`Write version ${version}`)
|
|
2222
|
-
|
|
2223
|
-
|
|
3304
|
+
const releaseDate = new Date().toISOString().slice(0, 10)
|
|
3305
|
+
for (const source of versionTargets) {
|
|
2224
3306
|
if (!existsSync(source.path)) abort(`versionFiles entry ${source.path} does not exist`)
|
|
2225
|
-
if (writeVersionInto(source, version, { dryRun })) {
|
|
3307
|
+
if (writeVersionInto(source, version, { dryRun, date: releaseDate })) {
|
|
2226
3308
|
staged.push(source.path)
|
|
2227
3309
|
console.log(` ${dryRun ? yellow('would write') : dim('wrote')} ${source.path}`)
|
|
2228
3310
|
}
|
|
2229
3311
|
}
|
|
2230
|
-
|
|
2231
|
-
if (existsSync('package-lock.json')) {
|
|
2232
|
-
mutate('npm', ['install', '--package-lock-only', '--ignore-scripts', '--silent'])
|
|
2233
|
-
staged.push('package-lock.json')
|
|
2234
|
-
}
|
|
3312
|
+
refreshLockfiles(versionTargets.map((source) => source.path))
|
|
2235
3313
|
}
|
|
2236
3314
|
|
|
3315
|
+
// After the version is on disk and before the release commit, so a file the hook
|
|
3316
|
+
// regenerates from the version rides in that commit rather than being left behind.
|
|
3317
|
+
runHook('afterVersion')
|
|
3318
|
+
|
|
3319
|
+
/** The version headings a changelog carries are dead link references without these. */
|
|
3320
|
+
const linked = (text) => withChangelogLinks(text, remoteLinks(config.remote), config.tagPrefix)
|
|
3321
|
+
|
|
2237
3322
|
if (rolledChangelog && runs('changelog')) {
|
|
2238
3323
|
step(`Roll ${config.changelog} to ${version}`)
|
|
2239
3324
|
if (dryRun) console.log(` ${yellow('would write')} ${config.changelog}`)
|
|
2240
|
-
else writeFileSync(config.changelog, rolledChangelog)
|
|
3325
|
+
else writeFileSync(config.changelog, linked(rolledChangelog))
|
|
2241
3326
|
staged.push(config.changelog)
|
|
2242
3327
|
} else if (runs('changelog') && draftedNotes && config.changelog && existsSync(config.changelog)) {
|
|
2243
3328
|
step(`Add the drafted ${version} section to ${config.changelog}`)
|
|
@@ -2245,11 +3330,13 @@ if (rolledChangelog && runs('changelog')) {
|
|
|
2245
3330
|
else {
|
|
2246
3331
|
writeFileSync(
|
|
2247
3332
|
config.changelog,
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
3333
|
+
linked(
|
|
3334
|
+
insertChangelogSection(
|
|
3335
|
+
readFileSync(config.changelog, 'utf8'),
|
|
3336
|
+
version,
|
|
3337
|
+
new Date().toISOString().slice(0, 10),
|
|
3338
|
+
draftedNotes,
|
|
3339
|
+
),
|
|
2253
3340
|
),
|
|
2254
3341
|
)
|
|
2255
3342
|
}
|
|
@@ -2294,15 +3381,25 @@ if (runs('tag') && !taggedCommit) {
|
|
|
2294
3381
|
|
|
2295
3382
|
if (runs('push')) {
|
|
2296
3383
|
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'])
|
|
3384
|
+
pushBranchAndTag(branch ?? 'HEAD')
|
|
2300
3385
|
}
|
|
2301
3386
|
|
|
2302
|
-
|
|
2303
|
-
|
|
2304
|
-
|
|
3387
|
+
// After the tag is pushed and before anything is published: the point where an artefact
|
|
3388
|
+
// the publish command expects to find has to exist.
|
|
3389
|
+
if (publishTargets.length) runHook('beforePublish')
|
|
3390
|
+
|
|
3391
|
+
let publishedSomething = false
|
|
3392
|
+
for (const target of publishTargets) {
|
|
3393
|
+
if (alreadyPublished.has(target.command)) continue
|
|
3394
|
+
step(
|
|
3395
|
+
`Publish ${target.name} (${target.cli}${NPM_CLIS.has(target.cli) ? `, dist-tag ${distTag}` : ''})`,
|
|
3396
|
+
)
|
|
3397
|
+
mutateShell(target.command)
|
|
3398
|
+
publishedSomething = true
|
|
2305
3399
|
}
|
|
3400
|
+
// Only when something was actually published: a re-run that skipped every already-published
|
|
3401
|
+
// target published nothing, and telling downstream otherwise is a lie it may act on.
|
|
3402
|
+
if (publishedSomething) runHook('afterPublish')
|
|
2306
3403
|
|
|
2307
3404
|
if (runs('release') && !releaseExists) {
|
|
2308
3405
|
step(`GitHub release ${tag}`)
|
|
@@ -2318,6 +3415,7 @@ if (runs('release') && !releaseExists) {
|
|
|
2318
3415
|
...config.assets,
|
|
2319
3416
|
]
|
|
2320
3417
|
mutate('gh', args, notes ? { input: `${notes}\n` } : {})
|
|
3418
|
+
runHook('afterRelease')
|
|
2321
3419
|
}
|
|
2322
3420
|
|
|
2323
3421
|
/**
|
|
@@ -2337,7 +3435,7 @@ function emitOutputs() {
|
|
|
2337
3435
|
name: projectName,
|
|
2338
3436
|
'dist-tag': distTag,
|
|
2339
3437
|
steps: STEPS.filter(runs).join(','),
|
|
2340
|
-
published: String(
|
|
3438
|
+
published: String(publishTargets.some((target) => !alreadyPublished.has(target.command))),
|
|
2341
3439
|
'release-url': releaseUrl,
|
|
2342
3440
|
}
|
|
2343
3441
|
try {
|