@entro314labs/release-kit 2.7.0 → 2.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +322 -70
- package/TRAIN.md +13 -0
- package/package.json +1 -1
- package/release.mjs +1264 -136
- package/train.mjs +18 -5
package/release.mjs
CHANGED
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
existsSync,
|
|
38
38
|
mkdirSync,
|
|
39
39
|
mkdtempSync,
|
|
40
|
+
readdirSync,
|
|
40
41
|
readFileSync,
|
|
41
42
|
writeFileSync,
|
|
42
43
|
} from 'node:fs'
|
|
@@ -61,9 +62,16 @@ import { createInterface } from 'node:readline/promises'
|
|
|
61
62
|
* versionFile string|object|null where the project's version lives. Detected from
|
|
62
63
|
* the repository when unset; null when it versions by tag alone
|
|
63
64
|
* versionFiles array further files whose version is kept in sync; each is a path
|
|
64
|
-
* or { path, pattern }
|
|
65
|
-
*
|
|
66
|
-
*
|
|
65
|
+
* or { path, pattern }. Written even where versionFile is null,
|
|
66
|
+
* which is how a language with no version of its own — a Go
|
|
67
|
+
* module — keeps one in source. When neither this nor
|
|
68
|
+
* versionFile is configured, a second root manifest and a
|
|
69
|
+
* conventional version constant already on the same version are
|
|
70
|
+
* detected and kept in step
|
|
71
|
+
* publish string|string[] publish command, or several for a project that
|
|
72
|
+
* releases to more than one registry. Detected from the version
|
|
73
|
+
* sources when unset, and only where unambiguous; null to
|
|
74
|
+
* publish nothing
|
|
67
75
|
* commitMessage string release commit subject
|
|
68
76
|
* releaseTitle string GitHub release title
|
|
69
77
|
* assets string[] files attached to the GitHub release
|
|
@@ -77,6 +85,9 @@ import { createInterface } from 'node:readline/promises'
|
|
|
77
85
|
* release commits, merges, work-in-progress, autosquash markers
|
|
78
86
|
* versioning string how `auto` derives a bump: "conventional", or
|
|
79
87
|
* always-patch / always-minor / always-major to never infer
|
|
88
|
+
* verify string command run during preflight — a project's own gate (tests,
|
|
89
|
+
* build). Non-zero aborts before anything mutates, instead of a
|
|
90
|
+
* prepublishOnly hook failing after the commit, tag and push
|
|
80
91
|
* assistant string|object drafting CLI for commit messages and notes. A key of
|
|
81
92
|
* ASSISTANTS, "auto" for the first available, or null. The
|
|
82
93
|
* object form { tool, model, effort } also pins which model and
|
|
@@ -88,11 +99,11 @@ import { createInterface } from 'node:readline/promises'
|
|
|
88
99
|
* committing, is a mistake the tool should not let you express.
|
|
89
100
|
*
|
|
90
101
|
* commit commit a dirty working tree (opt-in; touches work that predates the release)
|
|
91
|
-
* version write the version into
|
|
102
|
+
* version write the version into the version source and versionFiles
|
|
92
103
|
* changelog roll [Unreleased] into the version, or add drafted notes
|
|
93
104
|
* tag annotated git tag carrying the release notes
|
|
94
105
|
* push push the branch and the tag together
|
|
95
|
-
* publish run the configured publish command
|
|
106
|
+
* publish run the configured publish command(s), in order
|
|
96
107
|
* release create the GitHub release
|
|
97
108
|
*
|
|
98
109
|
* `version` and `changelog` write files; those writes are persisted by a release commit
|
|
@@ -130,8 +141,25 @@ const DEFAULTS = {
|
|
|
130
141
|
'^wip\\b',
|
|
131
142
|
'^(fixup|squash)!',
|
|
132
143
|
],
|
|
144
|
+
verify: null,
|
|
145
|
+
hooks: {},
|
|
133
146
|
}
|
|
134
147
|
|
|
148
|
+
/**
|
|
149
|
+
* The points a project can hang its own commands on, in the order they run.
|
|
150
|
+
*
|
|
151
|
+
* `verify` already covers the one gate that matters most — the project's own tests, run
|
|
152
|
+
* during preflight before anything mutates. What it cannot express is work that has to
|
|
153
|
+
* happen *between* the release's own steps: regenerating a file derived from the version,
|
|
154
|
+
* building an artefact the publish command expects to find, telling something downstream
|
|
155
|
+
* that a release landed.
|
|
156
|
+
*
|
|
157
|
+
* They are command lines rather than callbacks because the config is JSON, and they take
|
|
158
|
+
* the same `%v` `%t` `%n` `%d` tokens the publish command does. A non-zero exit aborts the
|
|
159
|
+
* release exactly where it happened, which is the point of running them there.
|
|
160
|
+
*/
|
|
161
|
+
const HOOKS = ['beforeVersion', 'afterVersion', 'beforePublish', 'afterPublish', 'afterRelease']
|
|
162
|
+
|
|
135
163
|
/**
|
|
136
164
|
* Prerelease identifiers that map to their own npm dist-tag. An identifier outside this
|
|
137
165
|
* set has no safe home, so `distTagFor` refuses rather than letting a prerelease fall
|
|
@@ -161,6 +189,17 @@ Target (optional; defaults to the version already in package.json):
|
|
|
161
189
|
Steps, in the fixed order they run. All but "commit" run by default:
|
|
162
190
|
${STEPS.join(' ')}
|
|
163
191
|
|
|
192
|
+
Subcommands (they check, print or copy, and never start a release):
|
|
193
|
+
next [<version>|<bump>]
|
|
194
|
+
print the version that target would release, and stop.
|
|
195
|
+
Only the version reaches stdout, so it substitutes:
|
|
196
|
+
VERSION=$(release-kit next auto)
|
|
197
|
+
lint-commits [<range>]
|
|
198
|
+
check commit subjects against Conventional Commits
|
|
199
|
+
(default range: since the last tag)
|
|
200
|
+
lint-commits --subject <text>
|
|
201
|
+
check one subject — a pull request title before it is squashed
|
|
202
|
+
|
|
164
203
|
Flags:
|
|
165
204
|
--only <steps> run only these steps, comma-separated
|
|
166
205
|
--skip <steps> run every step except these
|
|
@@ -199,9 +238,20 @@ const yellow = (s) => paint('33', s)
|
|
|
199
238
|
|
|
200
239
|
let stepNumber = 0
|
|
201
240
|
const step = (title) => console.log(`\n${bold(`[${++stepNumber}] ${title}`)}`)
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
241
|
+
/**
|
|
242
|
+
* `next` exists to be substituted into a shell command, so its stdout must carry the
|
|
243
|
+
* version and nothing else. Everything the release would narrate still gets said — on
|
|
244
|
+
* stderr, where a human reads it and `$(...)` does not.
|
|
245
|
+
*/
|
|
246
|
+
const PRINT_ONLY = process.argv[2] === 'next'
|
|
247
|
+
const say = (line) => {
|
|
248
|
+
if (PRINT_ONLY) process.stderr.write(`${line}\n`)
|
|
249
|
+
else console.log(line)
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const ok = (message) => say(` ${green('ok')} ${message}`)
|
|
253
|
+
const warn = (message) => say(` ${yellow('warn')} ${message}`)
|
|
254
|
+
const note = (message) => say(` ${dim(message)}`)
|
|
205
255
|
const indent = (text) =>
|
|
206
256
|
text
|
|
207
257
|
.split('\n')
|
|
@@ -223,8 +273,8 @@ const formatStatus = (porcelain) =>
|
|
|
223
273
|
})
|
|
224
274
|
.join('\n')
|
|
225
275
|
|
|
226
|
-
function abort(message) {
|
|
227
|
-
console.log(`\n${red(bold(
|
|
276
|
+
function abort(message, title = 'RELEASE ABORTED') {
|
|
277
|
+
console.log(`\n${red(bold(title))} — ${message}\n`)
|
|
228
278
|
process.exit(1)
|
|
229
279
|
}
|
|
230
280
|
|
|
@@ -400,29 +450,120 @@ function runAssistant(prompt) {
|
|
|
400
450
|
}
|
|
401
451
|
}
|
|
402
452
|
|
|
403
|
-
/**
|
|
404
|
-
|
|
453
|
+
/**
|
|
454
|
+
* The repository's release tags that are reachable from HEAD, highest version first.
|
|
455
|
+
*
|
|
456
|
+
* `git describe --tags --abbrev=0` answers a different question — "the nearest tag of any
|
|
457
|
+
* kind" — and it is wrong in two ways that were both observed. A repository carrying tags
|
|
458
|
+
* that are not releases gets one of those as its baseline: a single rolling `latest-beta`
|
|
459
|
+
* marker, which tauri-release-kit maintains for its update channels, made a release abort
|
|
460
|
+
* with "no releasable commits since latest-beta". And "nearest ancestor" is not "latest
|
|
461
|
+
* release": a patch tagged on top of a later minor drags the baseline backwards.
|
|
462
|
+
*
|
|
463
|
+
* Only tags carrying the configured prefix and a parseable version count, and they are
|
|
464
|
+
* ordered by semver precedence rather than by position in the history. `--merged HEAD`
|
|
465
|
+
* keeps a tag made on another branch out of this branch's history, and degrades correctly
|
|
466
|
+
* in a shallow clone: a tag whose commit was not fetched is simply not listed.
|
|
467
|
+
*
|
|
468
|
+
* @returns {{name: string, version: string}[]}
|
|
469
|
+
*/
|
|
470
|
+
function releaseTags(prefix = config.tagPrefix ?? '') {
|
|
471
|
+
const listed = tryRead('git', ['tag', '--list', `${prefix}*`, '--merged', 'HEAD']) ?? ''
|
|
472
|
+
return listed
|
|
473
|
+
.split('\n')
|
|
474
|
+
.map((name) => name.trim())
|
|
475
|
+
.filter(Boolean)
|
|
476
|
+
.map((name) => ({ name, version: name.slice(prefix.length) }))
|
|
477
|
+
.filter(({ version }) => parseVersion(version))
|
|
478
|
+
.sort((a, b) => compareVersions(b.version, a.version))
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* The tag a release reads its history from.
|
|
483
|
+
*
|
|
484
|
+
* @param {{stable?: boolean}} [options] `stable` when the version being released has no
|
|
485
|
+
* prerelease identifier, which rolls the release candidates leading to it up into it:
|
|
486
|
+
* their work is what is shipping now, and reading from the last candidate describes only
|
|
487
|
+
* the gap between the last two candidates. Promoting `2.0.0-rc.2` to `2.0.0` that way
|
|
488
|
+
* produced empty notes, because the one commit in range was the release chore.
|
|
489
|
+
* Releasing a candidate keeps the full ordering, so each candidate's notes say what
|
|
490
|
+
* changed in that candidate rather than repeating the whole cycle.
|
|
491
|
+
* @returns {string | null}
|
|
492
|
+
*/
|
|
493
|
+
function lastReleaseTag({ stable = false, prefix } = {}) {
|
|
494
|
+
const tags = releaseTags(prefix)
|
|
495
|
+
const eligible = stable ? tags.filter(({ version }) => !parseVersion(version).pre.length) : tags
|
|
496
|
+
return eligible[0]?.name ?? null
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/** Commit subjects since the last release tag, with release and merge commits filtered out. */
|
|
500
|
+
function commitsSinceLastTag(options) {
|
|
405
501
|
const ignored = (config.ignoreCommits ?? []).map((pattern) => new RegExp(pattern, 'i'))
|
|
406
|
-
const lastTag =
|
|
502
|
+
const lastTag = lastReleaseTag(options)
|
|
407
503
|
const range = lastTag ? `${lastTag}..HEAD` : 'HEAD'
|
|
408
504
|
// %B is the whole message: bodies carry BREAKING CHANGE and Release-As footers. A record
|
|
409
505
|
// separator keeps multi-line messages parseable when splitting the log back apart.
|
|
410
|
-
// %h first, then the message: the hash is what links each bullet back
|
|
411
|
-
|
|
506
|
+
// %h first, then the author, then the message: the hash is what links each bullet back
|
|
507
|
+
// to its commit, and the author is what says who is new here.
|
|
508
|
+
const raw = tryRead('git', ['log', `--format=%h%x1f%an%x1f%ae%x1f%B%x1e`, range]) ?? ''
|
|
412
509
|
const commits = raw
|
|
413
510
|
.split('\u001E')
|
|
414
511
|
.map((entry) => entry.trim())
|
|
415
512
|
.filter(Boolean)
|
|
416
513
|
.map((entry) => {
|
|
417
|
-
const [hash, message = ''] = entry.split('\u001F')
|
|
514
|
+
const [hash, author = '', email = '', message = ''] = entry.split('\u001F')
|
|
418
515
|
const [subject, ...rest] = message.split('\n')
|
|
419
|
-
return {
|
|
516
|
+
return {
|
|
517
|
+
hash: hash.trim(),
|
|
518
|
+
author: author.trim(),
|
|
519
|
+
email: email.trim().toLowerCase(),
|
|
520
|
+
subject: subject.trim(),
|
|
521
|
+
body: rest.join('\n').trim(),
|
|
522
|
+
}
|
|
420
523
|
})
|
|
421
524
|
// Bookkeeping rather than change: the previous release's own commit, merges that
|
|
422
525
|
// duplicate the branch they bring in, and markers meant to be autosquashed away.
|
|
423
526
|
.filter(({ subject }) => subject && !ignored.some((re) => re.test(subject)))
|
|
424
527
|
const kept = withoutRevertedCommits(commits)
|
|
425
|
-
return {
|
|
528
|
+
return {
|
|
529
|
+
lastTag,
|
|
530
|
+
commits: kept,
|
|
531
|
+
subjects: kept.map((c) => c.subject),
|
|
532
|
+
contributors: newContributors(lastTag, kept),
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* The people whose first commit to this repository is in this release.
|
|
538
|
+
*
|
|
539
|
+
* git-cliff derives this from the forge's API, which needs a token, a network and a
|
|
540
|
+
* forge. The repository already knows: an author absent from every commit before the
|
|
541
|
+
* previous tag has not contributed before. That answer is exact, offline, and the same on
|
|
542
|
+
* GitHub, GitLab and a bare remote — and it degrades with a shallow clone exactly as the
|
|
543
|
+
* rest of the notes do, which is already warned about.
|
|
544
|
+
*
|
|
545
|
+
* The first release has no "before", so everyone would be new and the section would say
|
|
546
|
+
* nothing; it is skipped there.
|
|
547
|
+
*
|
|
548
|
+
* @returns {string[]} display names, GitHub handles where the email carries one
|
|
549
|
+
*/
|
|
550
|
+
function newContributors(lastTag, commits) {
|
|
551
|
+
if (!lastTag || !commits.length) return []
|
|
552
|
+
const before = new Set(
|
|
553
|
+
(tryRead('git', ['log', '--format=%ae', lastTag]) ?? '')
|
|
554
|
+
.split('\n')
|
|
555
|
+
.map((email) => email.trim().toLowerCase())
|
|
556
|
+
.filter(Boolean),
|
|
557
|
+
)
|
|
558
|
+
const seen = new Map()
|
|
559
|
+
for (const { author, email } of commits) {
|
|
560
|
+
if (!email || before.has(email) || seen.has(email)) continue
|
|
561
|
+
// A GitHub noreply address carries the account handle, which is what a reader can
|
|
562
|
+
// actually follow; anything else falls back to the name on the commit.
|
|
563
|
+
const handle = /^(?:\d+\+)?([^@]+)@users\.noreply\.github\.com$/.exec(email)?.[1]
|
|
564
|
+
seen.set(email, handle ? `@${handle}` : author)
|
|
565
|
+
}
|
|
566
|
+
return [...seen.values()].filter(Boolean)
|
|
426
567
|
}
|
|
427
568
|
|
|
428
569
|
/**
|
|
@@ -517,7 +658,7 @@ function inferBump(commits, currentVersion, strategy = 'conventional') {
|
|
|
517
658
|
*
|
|
518
659
|
* @returns {string | null} markdown body, or null when nothing visible changed
|
|
519
660
|
*/
|
|
520
|
-
function changelogFromCommits(commits, links = null, hidden = []) {
|
|
661
|
+
function changelogFromCommits(commits, links = null, hidden = [], contributors = []) {
|
|
521
662
|
const parsed = commits.map((c) => parseCommit(c.subject, c.body, c.hash)).filter(Boolean)
|
|
522
663
|
const lines = []
|
|
523
664
|
|
|
@@ -566,6 +707,14 @@ function changelogFromCommits(commits, links = null, hidden = []) {
|
|
|
566
707
|
lines.push('')
|
|
567
708
|
}
|
|
568
709
|
|
|
710
|
+
// Last, and only when there is something above it: a list of names is not release notes
|
|
711
|
+
// on its own, and a release with no described changes should still say so.
|
|
712
|
+
if (lines.length && contributors.length) {
|
|
713
|
+
lines.push('### New Contributors', '')
|
|
714
|
+
for (const name of contributors) lines.push(`- ${name} made their first contribution`)
|
|
715
|
+
lines.push('')
|
|
716
|
+
}
|
|
717
|
+
|
|
569
718
|
return lines.length ? lines.join('\n').trim() : null
|
|
570
719
|
}
|
|
571
720
|
|
|
@@ -575,11 +724,28 @@ function changelogFromCommits(commits, links = null, hidden = []) {
|
|
|
575
724
|
* differ: Bitbucket uses /issue/ and /commits/ where GitHub uses /issues/ and /commit/.
|
|
576
725
|
*/
|
|
577
726
|
const HOSTS = {
|
|
578
|
-
'github.com': {
|
|
579
|
-
|
|
580
|
-
|
|
727
|
+
'github.com': {
|
|
728
|
+
issue: 'issues',
|
|
729
|
+
commit: 'commit',
|
|
730
|
+
compare: 'compare/%f...%t',
|
|
731
|
+
tag: 'releases/tag/%t',
|
|
732
|
+
},
|
|
733
|
+
'gitlab.com': { issue: 'issues', commit: 'commit', compare: 'compare/%f...%t', tag: '-/tags/%t' },
|
|
734
|
+
// Bitbucket reverses the operands and separates them with two dots, and keeps tags under
|
|
735
|
+
// /commits/tag/ rather than a releases page it does not have.
|
|
736
|
+
'bitbucket.org': {
|
|
737
|
+
issue: 'issue',
|
|
738
|
+
commit: 'commits',
|
|
739
|
+
compare: 'branches/compare/%t..%f',
|
|
740
|
+
tag: 'commits/tag/%t',
|
|
741
|
+
},
|
|
742
|
+
}
|
|
743
|
+
const DEFAULT_HOST = {
|
|
744
|
+
issue: 'issues',
|
|
745
|
+
commit: 'commit',
|
|
746
|
+
compare: 'compare/%f...%t',
|
|
747
|
+
tag: 'releases/tag/%t',
|
|
581
748
|
}
|
|
582
|
-
const DEFAULT_HOST = { issue: 'issues', commit: 'commit' }
|
|
583
749
|
|
|
584
750
|
/** Words that mark an issue reference as closed by the commit. */
|
|
585
751
|
const CLOSES = /\b(?:close[sd]?|closing|fix(?:e[sd])?|fixing|resolve[sd]?|resolving)\s+#(\d+)/gi
|
|
@@ -600,10 +766,13 @@ function remoteLinks(remote) {
|
|
|
600
766
|
const [, host, path] = web ?? scp ?? []
|
|
601
767
|
if (!host || !path) return null
|
|
602
768
|
const shape = HOSTS[host.toLowerCase()] ?? DEFAULT_HOST
|
|
769
|
+
const base = `https://${host}/${path}`
|
|
603
770
|
return {
|
|
604
|
-
base
|
|
605
|
-
issue:
|
|
606
|
-
commit:
|
|
771
|
+
base,
|
|
772
|
+
issue: `${base}/${shape.issue}`,
|
|
773
|
+
commit: `${base}/${shape.commit}`,
|
|
774
|
+
compare: (from, to) => `${base}/${shape.compare.replace('%f', from).replace('%t', to)}`,
|
|
775
|
+
tag: (name) => `${base}/${shape.tag.replace('%t', name)}`,
|
|
607
776
|
}
|
|
608
777
|
}
|
|
609
778
|
|
|
@@ -624,8 +793,104 @@ function withoutRevertedCommits(commits) {
|
|
|
624
793
|
)
|
|
625
794
|
}
|
|
626
795
|
|
|
627
|
-
|
|
628
|
-
|
|
796
|
+
/**
|
|
797
|
+
* Semver strings a drafted message names that the staged changes never touch. The prompt
|
|
798
|
+
* forbids narrating versions, but a model can still read an unchanged `"version"` context
|
|
799
|
+
* line and describe it as work — a draft once claimed "release v1.4.5" for a commit that
|
|
800
|
+
* changed no version at all. Only added and removed lines are the change.
|
|
801
|
+
*/
|
|
802
|
+
function inventedVersions(message, changedLines) {
|
|
803
|
+
const versions = message.match(/\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?/g) ?? []
|
|
804
|
+
return [...new Set(versions)].filter((version) => !changedLines.includes(version))
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
/**
|
|
808
|
+
* A repository URL reduced to what identifies the repository: `git+` and protocol
|
|
809
|
+
* prefixes, the `git@host:` shorthand, a trailing `.git` and letter case all vary between
|
|
810
|
+
* package.json and a git remote without meaning a different repo.
|
|
811
|
+
*/
|
|
812
|
+
function normalizeRepoUrl(url) {
|
|
813
|
+
if (!url) return null
|
|
814
|
+
return url
|
|
815
|
+
.trim()
|
|
816
|
+
.replace(/^git\+/, '')
|
|
817
|
+
.replace(/^git@([^:]+):/, 'https://$1/')
|
|
818
|
+
.replace(/^ssh:\/\/git@/, 'https://')
|
|
819
|
+
.replace(/^git:\/\//, 'https://')
|
|
820
|
+
.replace(/\.git$/, '')
|
|
821
|
+
.replace(/\/+$/, '')
|
|
822
|
+
.toLowerCase()
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
/**
|
|
826
|
+
* A deterministic Conventional Commits message built from the staged file list — the
|
|
827
|
+
* floor under the drafting assistant. A release is never blocked because a text
|
|
828
|
+
* generator was unavailable or produced an unusable answer: the honest fallback is a
|
|
829
|
+
* chore commit that names what it touches, with the full paths in the body.
|
|
830
|
+
*/
|
|
831
|
+
function fallbackCommitMessage(files) {
|
|
832
|
+
const named = `chore: update ${files.map((file) => basename(file)).join(' and ')}`
|
|
833
|
+
const subject =
|
|
834
|
+
files.length > 0 && files.length <= 2 && named.length <= 72
|
|
835
|
+
? named
|
|
836
|
+
: `chore: update ${files.length} files`
|
|
837
|
+
const body = files.length > 2 ? files.map((file) => `- ${file}`).join('\n') : ''
|
|
838
|
+
return body ? `${subject}\n\n${body}` : subject
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
/**
|
|
842
|
+
* The types worth writing: every one has a changelog section of its own.
|
|
843
|
+
*
|
|
844
|
+
* Derived from CHANGELOG_SECTIONS rather than written out again, because the hand-kept
|
|
845
|
+
* copy had already drifted — it omitted `deps`, so the drafter could never produce a
|
|
846
|
+
* subject for the Dependencies section the changelog has always had.
|
|
847
|
+
*/
|
|
848
|
+
const CHANGELOG_TYPES = CHANGELOG_SECTIONS.map((s) => s.type)
|
|
849
|
+
|
|
850
|
+
/** The drafter's types plus `feature`, the alias `changelogFromCommits` folds into feat. */
|
|
851
|
+
const KNOWN_TYPES = new Set([...CHANGELOG_TYPES, 'feature'])
|
|
852
|
+
const CONVENTIONAL_RE = new RegExp(`^(${[...KNOWN_TYPES].join('|')})(\\([^)]+\\))?!?: .+`)
|
|
853
|
+
|
|
854
|
+
/**
|
|
855
|
+
* Check commit subjects against the grammar the rest of this file reads.
|
|
856
|
+
*
|
|
857
|
+
* Two severities, because the two failures do not cost the same:
|
|
858
|
+
*
|
|
859
|
+
* - A subject `parseCommit` cannot read is invisible. It contributes nothing to the
|
|
860
|
+
* inferred bump and never reaches the changelog, so the work simply disappears.
|
|
861
|
+
* - A type outside CHANGELOG_SECTIONS is merely unfiled: `changelogFromCommits` still
|
|
862
|
+
* prints it under "Other Changes". `security:` and `i18n:` warn rather than fail —
|
|
863
|
+
* refusing them would make this stricter than the tool it is meant to protect.
|
|
864
|
+
*
|
|
865
|
+
* Case is not one of the failures: `parseCommit` folds the type, so `Feat:` bumps and files
|
|
866
|
+
* exactly as `feat:` does. The drafter is stricter about its own output than this is about
|
|
867
|
+
* anybody's commits, and deliberately so.
|
|
868
|
+
*
|
|
869
|
+
* @param {{subject: string, hash?: string}[]} commits
|
|
870
|
+
* @returns {{subject: string, hash: string, level: 'error'|'warn', reason: string}[]}
|
|
871
|
+
*/
|
|
872
|
+
function lintSubjects(commits) {
|
|
873
|
+
const findings = []
|
|
874
|
+
for (const { subject, hash = '' } of commits) {
|
|
875
|
+
const parsed = parseCommit(subject)
|
|
876
|
+
if (!parsed) {
|
|
877
|
+
findings.push({
|
|
878
|
+
subject,
|
|
879
|
+
hash,
|
|
880
|
+
level: 'error',
|
|
881
|
+
reason: `not Conventional Commits — expected "<type>(<scope>): <description>" with type one of ${CHANGELOG_TYPES.join(', ')}`,
|
|
882
|
+
})
|
|
883
|
+
} else if (!KNOWN_TYPES.has(parsed.type)) {
|
|
884
|
+
findings.push({
|
|
885
|
+
subject,
|
|
886
|
+
hash,
|
|
887
|
+
level: 'warn',
|
|
888
|
+
reason: `type "${parsed.type}" has no changelog section — it lands under "Other Changes"`,
|
|
889
|
+
})
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
return findings
|
|
893
|
+
}
|
|
629
894
|
|
|
630
895
|
/**
|
|
631
896
|
* Draft a Conventional Commits message for the staged changes.
|
|
@@ -642,11 +907,15 @@ function draftCommitMessage() {
|
|
|
642
907
|
'Write a Conventional Commits message for these staged changes.',
|
|
643
908
|
'',
|
|
644
909
|
'Rules:',
|
|
645
|
-
`- Subject: "<type>(<optional scope>): <description>" where type is one of ${
|
|
910
|
+
`- Subject: "<type>(<optional scope>): <description>" where type is one of ${CHANGELOG_TYPES.join(', ')}.`,
|
|
646
911
|
'- Subject in the imperative mood, no trailing period, under 72 characters.',
|
|
647
912
|
'- Add a body only if the change needs explanation; separate it with a blank line.',
|
|
648
913
|
'- Output the raw commit message and nothing else: no markdown fences, no preamble.',
|
|
649
914
|
'- Do NOT add Co-Authored-By, Signed-off-by, or any attribution or tool credit.',
|
|
915
|
+
'- Describe only lines that are added or removed in the diff. Unchanged context lines',
|
|
916
|
+
' (including any version fields they show) are not part of this change.',
|
|
917
|
+
'- Never mention version numbers, releases, or version bumps: the release tooling',
|
|
918
|
+
' handles versioning separately, and this commit is not the release.',
|
|
650
919
|
'',
|
|
651
920
|
'Files changed:',
|
|
652
921
|
stat,
|
|
@@ -658,7 +927,19 @@ function draftCommitMessage() {
|
|
|
658
927
|
const message = runAssistant(prompt)
|
|
659
928
|
if (!message) return null
|
|
660
929
|
const [subject] = message.split('\n')
|
|
661
|
-
|
|
930
|
+
if (!CONVENTIONAL_RE.test(subject)) return null
|
|
931
|
+
// The prompt forbids narrating versions; this is the deterministic backstop — the same
|
|
932
|
+
// pattern as the citation check on drafted notes: validated, not trusted.
|
|
933
|
+
const changedLines = (tryRead('git', ['diff', '--cached', '--unified=0']) ?? '')
|
|
934
|
+
.split('\n')
|
|
935
|
+
.filter((line) => /^[+-](?![+-])/.test(line))
|
|
936
|
+
.join('\n')
|
|
937
|
+
const invented = inventedVersions(message, changedLines)
|
|
938
|
+
if (invented.length) {
|
|
939
|
+
note(`draft rejected: it names ${invented.join(', ')}, which the staged changes never touch`)
|
|
940
|
+
return null
|
|
941
|
+
}
|
|
942
|
+
return message
|
|
662
943
|
}
|
|
663
944
|
|
|
664
945
|
/**
|
|
@@ -748,6 +1029,103 @@ function mutate(command, args, options = {}) {
|
|
|
748
1029
|
}
|
|
749
1030
|
}
|
|
750
1031
|
|
|
1032
|
+
/**
|
|
1033
|
+
* Lockfiles that record the releasing project's own version, and the command that brings
|
|
1034
|
+
* each back into step.
|
|
1035
|
+
*
|
|
1036
|
+
* A lockfile is not rewritten by pattern like a manifest is: `package-lock.json` carries
|
|
1037
|
+
* the version in two places, `uv.lock` carries it inside the `[[package]]` block for the
|
|
1038
|
+
* project among all its dependencies, and both formats change shape between tool versions.
|
|
1039
|
+
* The tool that owns the file is the only thing that can be trusted to edit it, so each
|
|
1040
|
+
* one is refreshed by running that tool.
|
|
1041
|
+
*
|
|
1042
|
+
* `manifest` scopes the refresh: a polyglot repository can hold a `uv.lock` for a Python
|
|
1043
|
+
* component that this release is not versioning, and regenerating it would put an
|
|
1044
|
+
* unrelated change in the release commit.
|
|
1045
|
+
*
|
|
1046
|
+
* pnpm and Cargo are deliberately absent. `pnpm-lock.yaml` records no root version, so it
|
|
1047
|
+
* never goes stale; `Cargo.lock` does, and is already kept in step as a version file with
|
|
1048
|
+
* a pattern scoped to the crate.
|
|
1049
|
+
*/
|
|
1050
|
+
const LOCKFILES = [
|
|
1051
|
+
{
|
|
1052
|
+
path: 'package-lock.json',
|
|
1053
|
+
manifest: 'package.json',
|
|
1054
|
+
command: ['npm', ['install', '--package-lock-only', '--ignore-scripts', '--silent']],
|
|
1055
|
+
},
|
|
1056
|
+
{
|
|
1057
|
+
path: 'npm-shrinkwrap.json',
|
|
1058
|
+
manifest: 'package.json',
|
|
1059
|
+
command: ['npm', ['install', '--package-lock-only', '--ignore-scripts', '--silent']],
|
|
1060
|
+
},
|
|
1061
|
+
{ path: 'uv.lock', manifest: 'pyproject.toml', command: ['uv', ['lock', '--quiet']] },
|
|
1062
|
+
]
|
|
1063
|
+
|
|
1064
|
+
/**
|
|
1065
|
+
* Bring every lockfile belonging to a manifest this release wrote back into step.
|
|
1066
|
+
*
|
|
1067
|
+
* A missing tool is a warning rather than an abort: the lockfile is left exactly as stale
|
|
1068
|
+
* as it already was, which is the behaviour without this step at all, and no release
|
|
1069
|
+
* should die because a lock tool is not installed on the machine cutting it.
|
|
1070
|
+
*
|
|
1071
|
+
* @param {string[]} written paths the version step wrote
|
|
1072
|
+
*/
|
|
1073
|
+
function refreshLockfiles(written) {
|
|
1074
|
+
const done = new Set()
|
|
1075
|
+
for (const { path, manifest, command } of LOCKFILES) {
|
|
1076
|
+
if (!existsSync(path) || !written.includes(manifest)) continue
|
|
1077
|
+
const [tool, args] = command
|
|
1078
|
+
// npm writes whichever of the two lockfiles the project has; running it twice is one
|
|
1079
|
+
// pointless install, not two different edits.
|
|
1080
|
+
if (!done.has(tool)) {
|
|
1081
|
+
if (!dryRun && !succeeds(tool, ['--version'])) {
|
|
1082
|
+
warn(`${path} records the version and ${tool} is not installed — leaving it stale`)
|
|
1083
|
+
continue
|
|
1084
|
+
}
|
|
1085
|
+
mutate(tool, args)
|
|
1086
|
+
done.add(tool)
|
|
1087
|
+
}
|
|
1088
|
+
staged.push(path)
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
/**
|
|
1093
|
+
* Push the release commit and its tag as one transaction.
|
|
1094
|
+
*
|
|
1095
|
+
* `--follow-tags` and `--atomic` answer different questions: the first decides *which*
|
|
1096
|
+
* refs are sent, the second decides whether they land together. With only the first, a
|
|
1097
|
+
* server is free to accept the branch and reject the tag — which is precisely the split
|
|
1098
|
+
* this step exists to prevent, leaving a release commit on the remote with no tag, or a
|
|
1099
|
+
* tag with no commit behind it.
|
|
1100
|
+
*
|
|
1101
|
+
* Not every server implements the atomic capability, so a refusal on those grounds falls
|
|
1102
|
+
* back to the plain push. Nothing else does: a rejected non-fast-forward retried without
|
|
1103
|
+
* `--atomic` would push one ref and not the other, which is worse than failing.
|
|
1104
|
+
*/
|
|
1105
|
+
function pushBranchAndTag(branchRef) {
|
|
1106
|
+
const args = ['push', '--follow-tags', config.remote, branchRef]
|
|
1107
|
+
const atomic = ['push', '--follow-tags', '--atomic', config.remote, branchRef]
|
|
1108
|
+
const line = formatCommand('git', atomic)
|
|
1109
|
+
if (dryRun) {
|
|
1110
|
+
console.log(` ${yellow('would run:')} ${line}`)
|
|
1111
|
+
return
|
|
1112
|
+
}
|
|
1113
|
+
console.log(` ${dim(`$ ${line}`)}`)
|
|
1114
|
+
try {
|
|
1115
|
+
execFileSync('git', atomic, { stdio: ['pipe', 'inherit', 'pipe'] })
|
|
1116
|
+
return
|
|
1117
|
+
} catch (err) {
|
|
1118
|
+
const stderr = `${err.stderr ?? ''}`
|
|
1119
|
+
process.stderr.write(stderr)
|
|
1120
|
+
if (!/atomic/i.test(stderr)) abortMidRelease(line)
|
|
1121
|
+
}
|
|
1122
|
+
warn(
|
|
1123
|
+
`${config.remote} does not support atomic pushes — sending the branch and tag in one ` +
|
|
1124
|
+
'call, but not as one transaction',
|
|
1125
|
+
)
|
|
1126
|
+
mutate('git', args)
|
|
1127
|
+
}
|
|
1128
|
+
|
|
751
1129
|
/**
|
|
752
1130
|
* Mutating shell command, for configured strings like `publish` that are written as a
|
|
753
1131
|
* whole command line rather than an argv. Shell metacharacters are the author's to own.
|
|
@@ -949,6 +1327,65 @@ function insertChangelogSection(text, version, date, body) {
|
|
|
949
1327
|
return `${trimmed}\n\n${entry}`
|
|
950
1328
|
}
|
|
951
1329
|
|
|
1330
|
+
/**
|
|
1331
|
+
* Write the link reference definitions a Keep a Changelog document's headings depend on.
|
|
1332
|
+
*
|
|
1333
|
+
* `## [1.2.3]` is a markdown link *reference*: without a matching `[1.2.3]: <url>` at the
|
|
1334
|
+
* foot of the file it renders as literal bracketed text. Sections were being written in
|
|
1335
|
+
* that shape and the definitions were never written at all, so every heading in every
|
|
1336
|
+
* changelog this tool has ever rolled is a dead reference.
|
|
1337
|
+
*
|
|
1338
|
+
* Every bracketed heading in the document gets one, not only the version being released,
|
|
1339
|
+
* so a changelog that never had them is repaired in one release rather than from here on.
|
|
1340
|
+
* Each version links to the diff since the version below it; the oldest links to its own
|
|
1341
|
+
* tag, having no predecessor to compare against. `[Unreleased]` compares the newest
|
|
1342
|
+
* version against `HEAD`.
|
|
1343
|
+
*
|
|
1344
|
+
* Definitions for labels that are not headings are left exactly where they are — those are
|
|
1345
|
+
* the author's own links, and this owns only what it can derive.
|
|
1346
|
+
*
|
|
1347
|
+
* @param {ReturnType<typeof remoteLinks>} links
|
|
1348
|
+
* @returns {string} the document with its definitions rewritten
|
|
1349
|
+
*/
|
|
1350
|
+
function withChangelogLinks(text, links, tagPrefix = '') {
|
|
1351
|
+
if (!links) return text
|
|
1352
|
+
const labels = [...text.matchAll(/^## \[([^\]]+)\]/gm)].map((m) => m[1])
|
|
1353
|
+
if (!labels.length) return text
|
|
1354
|
+
|
|
1355
|
+
const versions = labels
|
|
1356
|
+
.filter((label) => parseVersion(label.replace(/^v/, '')))
|
|
1357
|
+
.sort((a, b) => compareVersions(b.replace(/^v/, ''), a.replace(/^v/, '')))
|
|
1358
|
+
const unreleased = labels.find((label) => /^unreleased$/i.test(label))
|
|
1359
|
+
|
|
1360
|
+
const tagged = (label) => `${tagPrefix}${label.replace(/^v/, '')}`
|
|
1361
|
+
const definitions = []
|
|
1362
|
+
if (unreleased && versions.length) {
|
|
1363
|
+
definitions.push(`[${unreleased}]: ${links.compare(tagged(versions[0]), 'HEAD')}`)
|
|
1364
|
+
}
|
|
1365
|
+
versions.forEach((version, index) => {
|
|
1366
|
+
const previous = versions[index + 1]
|
|
1367
|
+
definitions.push(
|
|
1368
|
+
`[${version}]: ${
|
|
1369
|
+
previous ? links.compare(tagged(previous), tagged(version)) : links.tag(tagged(version))
|
|
1370
|
+
}`,
|
|
1371
|
+
)
|
|
1372
|
+
})
|
|
1373
|
+
if (!definitions.length) return text
|
|
1374
|
+
|
|
1375
|
+
// Drop the existing definitions for the labels being rewritten, wherever they sit, so
|
|
1376
|
+
// running this twice produces the same document rather than a second copy.
|
|
1377
|
+
const managed = new Set([...(unreleased ? [unreleased] : []), ...versions])
|
|
1378
|
+
const body = text
|
|
1379
|
+
.split('\n')
|
|
1380
|
+
.filter((line) => {
|
|
1381
|
+
const label = /^\[([^\]]+)\]:\s/.exec(line)?.[1]
|
|
1382
|
+
return !(label && managed.has(label))
|
|
1383
|
+
})
|
|
1384
|
+
.join('\n')
|
|
1385
|
+
|
|
1386
|
+
return `${body.trimEnd()}\n\n${definitions.join('\n')}\n`
|
|
1387
|
+
}
|
|
1388
|
+
|
|
952
1389
|
/**
|
|
953
1390
|
* Promote `## [Unreleased]` to a released version and reopen an empty one above it.
|
|
954
1391
|
*
|
|
@@ -987,6 +1424,10 @@ function rollUnreleased(text, version, date) {
|
|
|
987
1424
|
|
|
988
1425
|
const readJson = (path) => JSON.parse(readFileSync(path, 'utf8'))
|
|
989
1426
|
|
|
1427
|
+
/** The project's release.config.json, or {} when it has none. */
|
|
1428
|
+
const readUserConfig = () =>
|
|
1429
|
+
existsSync('release.config.json') ? readJson('release.config.json') : {}
|
|
1430
|
+
|
|
990
1431
|
/**
|
|
991
1432
|
* Where a project keeps its version. The format is inferred from the file name, so the
|
|
992
1433
|
* common cases need nothing but a path:
|
|
@@ -1025,6 +1466,73 @@ function readNameFrom(entry) {
|
|
|
1025
1466
|
/** Normalise a versionFile / versionFiles entry to { path, pattern }. */
|
|
1026
1467
|
const versionSource = (entry) => (typeof entry === 'string' ? { path: entry } : entry)
|
|
1027
1468
|
|
|
1469
|
+
/**
|
|
1470
|
+
* Expand a path that may contain `*` segments into the paths that exist, in sorted order.
|
|
1471
|
+
*
|
|
1472
|
+
* A desktop app carries the same version in a per-platform config for every platform it
|
|
1473
|
+
* ships, and a Cargo workspace lists its members as `crates/*`. Writing those out one by
|
|
1474
|
+
* one is the config the glob replaces. `*` matches within a single path segment, which is
|
|
1475
|
+
* what both of those shapes need and is all Go's `filepath.Glob` — the shape changie's
|
|
1476
|
+
* `replacements` use — offers either.
|
|
1477
|
+
*
|
|
1478
|
+
* @returns {string[]} matching paths; a pattern with no `*` yields itself when it exists
|
|
1479
|
+
*/
|
|
1480
|
+
function expandPaths(pattern) {
|
|
1481
|
+
if (!pattern.includes('*')) return existsSync(pattern) ? [pattern] : []
|
|
1482
|
+
const absolute = pattern.startsWith('/')
|
|
1483
|
+
let current = [absolute ? '/' : '.']
|
|
1484
|
+
for (const segment of pattern.split('/').filter(Boolean)) {
|
|
1485
|
+
const next = []
|
|
1486
|
+
if (segment.includes('*')) {
|
|
1487
|
+
const shape = new RegExp(`^${segment.split('*').map(escapeRe).join('[^/]*')}$`)
|
|
1488
|
+
for (const dir of current) {
|
|
1489
|
+
let entries
|
|
1490
|
+
try {
|
|
1491
|
+
entries = readdirSync(dir).sort()
|
|
1492
|
+
} catch {
|
|
1493
|
+
continue
|
|
1494
|
+
}
|
|
1495
|
+
for (const entry of entries) if (shape.test(entry)) next.push(join(dir, entry))
|
|
1496
|
+
}
|
|
1497
|
+
} else {
|
|
1498
|
+
for (const dir of current) {
|
|
1499
|
+
const joined = join(dir, segment)
|
|
1500
|
+
if (existsSync(joined)) next.push(joined)
|
|
1501
|
+
}
|
|
1502
|
+
}
|
|
1503
|
+
current = next
|
|
1504
|
+
}
|
|
1505
|
+
return current
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
/**
|
|
1509
|
+
* The crates in a Cargo workspace whose version this bump owns: the members that inherit
|
|
1510
|
+
* it with `version.workspace = true`, which is how a workspace keeps its crates in step.
|
|
1511
|
+
*
|
|
1512
|
+
* A member pinning its own number is versioned separately and is left out, the same rule
|
|
1513
|
+
* the companion-manifest detection uses.
|
|
1514
|
+
*
|
|
1515
|
+
* @returns {string[]} crate names
|
|
1516
|
+
*/
|
|
1517
|
+
function workspaceCrates(manifestPath) {
|
|
1518
|
+
const text = readFileSync(manifestPath, 'utf8')
|
|
1519
|
+
const members = /^members\s*=\s*\[([\s\S]*?)\]/m.exec(text)?.[1]
|
|
1520
|
+
if (!members) return []
|
|
1521
|
+
const root = dirname(manifestPath)
|
|
1522
|
+
const names = []
|
|
1523
|
+
for (const entry of members.matchAll(/"([^"]+)"/g)) {
|
|
1524
|
+
for (const dir of expandPaths(join(root, entry[1]))) {
|
|
1525
|
+
const memberManifest = join(dir, 'Cargo.toml')
|
|
1526
|
+
if (!existsSync(memberManifest)) continue
|
|
1527
|
+
const member = readFileSync(memberManifest, 'utf8')
|
|
1528
|
+
if (!/^version(?:\.workspace)?\s*=\s*\{?\s*workspace\s*=\s*true/m.test(member)) continue
|
|
1529
|
+
const name = NAME_PATTERNS.toml.exec(member)?.[1]
|
|
1530
|
+
if (name) names.push(name)
|
|
1531
|
+
}
|
|
1532
|
+
}
|
|
1533
|
+
return names
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1028
1536
|
/**
|
|
1029
1537
|
* A lockfile records a version for every dependency — hundreds of them — so the first
|
|
1030
1538
|
* `version = "…"` in the file belongs to whichever crate sorts first, not to this project.
|
|
@@ -1032,21 +1540,27 @@ const versionSource = (entry) => (typeof entry === 'string' ? { path: entry } :
|
|
|
1032
1540
|
*/
|
|
1033
1541
|
function cargoLockPattern(lockPath) {
|
|
1034
1542
|
const sibling = join(dirname(lockPath), 'Cargo.toml')
|
|
1035
|
-
|
|
1036
|
-
|
|
1543
|
+
// A workspace root carries no `[package]` of its own; what it owns is every member that
|
|
1544
|
+
// inherits the version with `version.workspace = true`, and the lockfile records a
|
|
1545
|
+
// block for each of them.
|
|
1546
|
+
const crates = existsSync(sibling)
|
|
1547
|
+
? [...new Set([readNameFrom({ path: sibling }), ...workspaceCrates(sibling)].filter(Boolean))]
|
|
1548
|
+
: []
|
|
1549
|
+
if (!crates.length) {
|
|
1037
1550
|
throw new Error(
|
|
1038
1551
|
`${lockPath} lists every dependency's version, so it needs to know which package is ` +
|
|
1039
|
-
`yours.\n No Cargo.toml beside it
|
|
1040
|
-
`pattern:\n { "path": "${lockPath}",
|
|
1041
|
-
|
|
1552
|
+
`yours.\n No Cargo.toml beside it naming a crate or a workspace member that ` +
|
|
1553
|
+
`inherits the version — give an explicit pattern:\n { "path": "${lockPath}", ` +
|
|
1554
|
+
`"pattern": "name = \\"<crate>\\"\\nversion = \\"(.+)\\"" }`,
|
|
1042
1555
|
)
|
|
1043
1556
|
}
|
|
1044
|
-
|
|
1557
|
+
const names = crates.map(escapeRe).join('|')
|
|
1558
|
+
return new RegExp(`\\[\\[package\\]\\]\\nname = "(?:${names})"\\nversion = "([^"]*)"`, 'g')
|
|
1045
1559
|
}
|
|
1046
1560
|
|
|
1047
1561
|
/** The regex for a source, or null when the whole file is the version. */
|
|
1048
|
-
function patternFor({ path, pattern }) {
|
|
1049
|
-
if (pattern) return new RegExp(pattern, 'm')
|
|
1562
|
+
function patternFor({ path, pattern, all = false }) {
|
|
1563
|
+
if (pattern) return new RegExp(pattern, all ? 'mg' : 'm')
|
|
1050
1564
|
if (basename(path) === 'Cargo.lock') return cargoLockPattern(path)
|
|
1051
1565
|
if (path.endsWith('.json')) return VERSION_PATTERNS.json
|
|
1052
1566
|
if (path.endsWith('.toml')) return VERSION_PATTERNS.toml
|
|
@@ -1057,31 +1571,176 @@ function patternFor({ path, pattern }) {
|
|
|
1057
1571
|
function readVersionFrom(entry) {
|
|
1058
1572
|
const source = versionSource(entry)
|
|
1059
1573
|
const text = readFileSync(source.path, 'utf8')
|
|
1060
|
-
const
|
|
1061
|
-
if (
|
|
1062
|
-
|
|
1574
|
+
const { kind, shape } = versionMode(source, text)
|
|
1575
|
+
if (kind === 'bare') return text.trim() || null
|
|
1576
|
+
if (kind === 'markers') {
|
|
1577
|
+
// The first line under a `version` marker is the one that carries the whole version;
|
|
1578
|
+
// a `major` or `date` marker carries only a piece of it.
|
|
1579
|
+
let scope = null
|
|
1580
|
+
for (const line of text.split('\n')) {
|
|
1581
|
+
const starting = MARKER_START.exec(line)?.[1]
|
|
1582
|
+
if (starting) {
|
|
1583
|
+
scope = starting
|
|
1584
|
+
continue
|
|
1585
|
+
}
|
|
1586
|
+
if (scope && MARKER_END.test(line)) {
|
|
1587
|
+
scope = null
|
|
1588
|
+
continue
|
|
1589
|
+
}
|
|
1590
|
+
const active = MARKER_INLINE.exec(line)?.[1] ?? scope
|
|
1591
|
+
if (active === 'version') {
|
|
1592
|
+
const found = MARKER_VERSION.exec(line)?.[0]
|
|
1593
|
+
if (found) return found
|
|
1594
|
+
}
|
|
1595
|
+
}
|
|
1596
|
+
return null
|
|
1597
|
+
}
|
|
1598
|
+
const match = shape.exec(text)
|
|
1063
1599
|
return match ? match[1] : null
|
|
1064
1600
|
}
|
|
1065
1601
|
|
|
1602
|
+
/**
|
|
1603
|
+
* Version markers: a comment naming the version, put on the line that carries it.
|
|
1604
|
+
*
|
|
1605
|
+
* A `pattern` can already reach any file, but writing one is a regex per file, and the
|
|
1606
|
+
* files that most want keeping in step — a README install line, a badge URL, a Dockerfile
|
|
1607
|
+
* tag, a Helm chart — are exactly the ones where a regex is fiddliest to get right and
|
|
1608
|
+
* easiest to get subtly wrong. release-please solved this with a marker comment on the
|
|
1609
|
+
* line instead, and the convention travels: the file says which of its numbers is the
|
|
1610
|
+
* version, so nothing outside it has to describe where that number sits.
|
|
1611
|
+
*
|
|
1612
|
+
* npm i acme@1.2.3 <!-- x-release-kit-version -->
|
|
1613
|
+
* FROM acme:1.2 # x-release-kit-minor
|
|
1614
|
+
* Released 2026-08-20 <!-- x-release-kit-date -->
|
|
1615
|
+
*
|
|
1616
|
+
* A block form covers a run of lines, for a fenced example that should not carry a comment
|
|
1617
|
+
* on every line:
|
|
1618
|
+
*
|
|
1619
|
+
* <!-- x-release-kit-start-version -->
|
|
1620
|
+
* ```sh
|
|
1621
|
+
* npm i acme@1.2.3
|
|
1622
|
+
* ```
|
|
1623
|
+
* <!-- x-release-kit-end -->
|
|
1624
|
+
*/
|
|
1625
|
+
// `version-date` comes first: the alternation is ordered, and `version` would otherwise
|
|
1626
|
+
// match its prefix and leave the date alone.
|
|
1627
|
+
const SCOPES = 'version-date|major|minor|patch|version|date'
|
|
1628
|
+
const MARKER_INLINE = new RegExp(`x-release-kit-(${SCOPES})\\b`)
|
|
1629
|
+
const MARKER_START = new RegExp(`x-release-kit-start-(${SCOPES})\\b`)
|
|
1630
|
+
const MARKER_END = /x-release-kit-end\b/
|
|
1631
|
+
const MARKER_VERSION = /\d+\.\d+\.\d+(?:-[0-9a-z.-]+)?(?:\+[0-9a-z.-]+)?/i
|
|
1632
|
+
const MARKER_NUMBER = /\b\d+\b/
|
|
1633
|
+
const MARKER_DATE = /\d{4}-\d{2}-\d{2}/
|
|
1634
|
+
|
|
1635
|
+
/** Whether a file opts into marker rewriting at all. */
|
|
1636
|
+
const hasVersionMarkers = (text) => MARKER_INLINE.test(text) || MARKER_START.test(text)
|
|
1637
|
+
|
|
1638
|
+
/**
|
|
1639
|
+
* Rewrite the marked numbers in a file.
|
|
1640
|
+
*
|
|
1641
|
+
* A marker whose line carries nothing to replace is left alone rather than guessed at: a
|
|
1642
|
+
* heading above a block, or a comment on its own line, is a normal thing to find.
|
|
1643
|
+
*
|
|
1644
|
+
* @returns {string} the rewritten text
|
|
1645
|
+
*/
|
|
1646
|
+
function applyVersionMarkers(text, version, date) {
|
|
1647
|
+
const { major, minor, patch } = parseVersion(version)
|
|
1648
|
+
const replacements = {
|
|
1649
|
+
version: [MARKER_VERSION, version],
|
|
1650
|
+
major: [MARKER_NUMBER, String(major)],
|
|
1651
|
+
minor: [MARKER_NUMBER, String(minor)],
|
|
1652
|
+
patch: [MARKER_NUMBER, String(patch)],
|
|
1653
|
+
date: [MARKER_DATE, date],
|
|
1654
|
+
}
|
|
1655
|
+
// One line carrying both, which is the shape of an AppStream <release> tag.
|
|
1656
|
+
const versionAndDate = (line) => line.replace(MARKER_VERSION, version).replace(MARKER_DATE, date)
|
|
1657
|
+
let scope = null
|
|
1658
|
+
return text
|
|
1659
|
+
.split('\n')
|
|
1660
|
+
.map((line) => {
|
|
1661
|
+
const inline = MARKER_INLINE.exec(line)?.[1]
|
|
1662
|
+
const starting = MARKER_START.exec(line)?.[1]
|
|
1663
|
+
// A start marker opens a block; its own line is not rewritten, since the marker
|
|
1664
|
+
// comment is the whole content of it.
|
|
1665
|
+
if (starting) {
|
|
1666
|
+
scope = starting
|
|
1667
|
+
return line
|
|
1668
|
+
}
|
|
1669
|
+
if (scope && MARKER_END.test(line)) {
|
|
1670
|
+
scope = null
|
|
1671
|
+
return line
|
|
1672
|
+
}
|
|
1673
|
+
const active = inline ?? scope
|
|
1674
|
+
if (!active) return line
|
|
1675
|
+
if (active === 'version-date') return versionAndDate(line)
|
|
1676
|
+
const [shape, value] = replacements[active]
|
|
1677
|
+
return line.replace(shape, value)
|
|
1678
|
+
})
|
|
1679
|
+
.join('\n')
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
/**
|
|
1683
|
+
* Where a file keeps its version, most specific first: the `pattern` the entry was
|
|
1684
|
+
* configured with, the markers the file carries, the shape its extension implies, and —
|
|
1685
|
+
* for a plain `VERSION` file — being nothing but the version.
|
|
1686
|
+
*
|
|
1687
|
+
* Reading and writing both go through this, so they can never disagree about which of a
|
|
1688
|
+
* file's numbers is the version.
|
|
1689
|
+
*
|
|
1690
|
+
* @returns {{kind: 'pattern'|'markers'|'bare', shape: RegExp|null}}
|
|
1691
|
+
*/
|
|
1692
|
+
function versionMode(source, text) {
|
|
1693
|
+
if (source.pattern) return { kind: 'pattern', shape: patternFor(source) }
|
|
1694
|
+
if (hasVersionMarkers(text)) return { kind: 'markers', shape: null }
|
|
1695
|
+
const inferred = patternFor(source)
|
|
1696
|
+
return inferred ? { kind: 'pattern', shape: inferred } : { kind: 'bare', shape: null }
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1066
1699
|
/**
|
|
1067
1700
|
* Replace the version in a source file, touching nothing else: only the captured range is
|
|
1068
1701
|
* rewritten, so formatting, key order and comments all survive.
|
|
1069
1702
|
*
|
|
1070
|
-
*
|
|
1703
|
+
* Four ways a file says where its version is, most specific first: the `pattern` it was
|
|
1704
|
+
* configured with, the markers it carries, the shape its extension implies, and — for a
|
|
1705
|
+
* plain `VERSION` file — being nothing but the version.
|
|
1706
|
+
*
|
|
1707
|
+
* @param {{dryRun?: boolean, date?: string}} [options] report the change without making
|
|
1708
|
+
* it; the date written for a `x-release-kit-date` marker
|
|
1071
1709
|
* @returns {boolean} whether the file needed changing
|
|
1072
1710
|
*/
|
|
1073
|
-
function writeVersionInto(entry, version, { dryRun = false } = {}) {
|
|
1711
|
+
function writeVersionInto(entry, version, { dryRun = false, date } = {}) {
|
|
1074
1712
|
const source = versionSource(entry)
|
|
1075
1713
|
const text = readFileSync(source.path, 'utf8')
|
|
1076
|
-
const
|
|
1714
|
+
const { kind, shape } = versionMode(source, text)
|
|
1077
1715
|
|
|
1078
1716
|
let updated
|
|
1079
|
-
if (pattern) {
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1717
|
+
if (kind === 'pattern') {
|
|
1718
|
+
if (!shape.test(text)) {
|
|
1719
|
+
if (source.optional) return false
|
|
1720
|
+
throw new Error(`${source.path} has no version matching ${shape}`)
|
|
1721
|
+
}
|
|
1722
|
+
shape.lastIndex = 0
|
|
1723
|
+
// A global pattern rewrites every match rather than the first: a Cargo.lock records
|
|
1724
|
+
// one block per crate, and a workspace bump owns all the members inheriting from it.
|
|
1725
|
+
updated = text.replace(shape, (match, captured) => {
|
|
1726
|
+
const at = match.indexOf(captured)
|
|
1727
|
+
return match.slice(0, at) + version + match.slice(at + captured.length)
|
|
1728
|
+
})
|
|
1729
|
+
} else if (kind === 'markers') {
|
|
1730
|
+
updated = applyVersionMarkers(text, version, date ?? new Date().toISOString().slice(0, 10))
|
|
1084
1731
|
} else {
|
|
1732
|
+
// The last resort overwrites the file with the version, which is right for a VERSION
|
|
1733
|
+
// file and catastrophic for anything else. A file that is not already just a version
|
|
1734
|
+
// was listed by mistake, or wants a marker or a pattern — say so rather than shred it.
|
|
1735
|
+
const existing = text.trim()
|
|
1736
|
+
if (existing && !parseVersion(existing)) {
|
|
1737
|
+
throw new Error(
|
|
1738
|
+
`${source.path} is not a file containing only a version, and carries no ` +
|
|
1739
|
+
'x-release-kit-version marker.\n Writing the version into it would replace ' +
|
|
1740
|
+
'everything else in it. Mark the line that holds the version, or give the entry ' +
|
|
1741
|
+
'a "pattern".',
|
|
1742
|
+
)
|
|
1743
|
+
}
|
|
1085
1744
|
updated = `${version}\n`
|
|
1086
1745
|
}
|
|
1087
1746
|
|
|
@@ -1094,7 +1753,9 @@ function writeVersionInto(entry, version, { dryRun = false } = {}) {
|
|
|
1094
1753
|
// ARGUMENTS
|
|
1095
1754
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
1096
1755
|
|
|
1097
|
-
|
|
1756
|
+
// `next` is a modifier on the ordinary target resolution, not a mode of its own: it takes
|
|
1757
|
+
// the same target argument and stops once the version is known.
|
|
1758
|
+
const argv = process.argv.slice(PRINT_ONLY ? 3 : 2)
|
|
1098
1759
|
const BUMPS = new Set([
|
|
1099
1760
|
'auto',
|
|
1100
1761
|
'major',
|
|
@@ -1180,6 +1841,69 @@ if (flag('--sync')) {
|
|
|
1180
1841
|
process.exit(0)
|
|
1181
1842
|
}
|
|
1182
1843
|
|
|
1844
|
+
// lint-commits checks subjects and exits; like --sync it starts no release. It is handled
|
|
1845
|
+
// before the positional parsing below because it takes a range, and a second positional is
|
|
1846
|
+
// otherwise a mistake.
|
|
1847
|
+
if (argv[0] === 'lint-commits') {
|
|
1848
|
+
const rest = argv.slice(1)
|
|
1849
|
+
const subjectAt = rest.indexOf('--subject')
|
|
1850
|
+
// This subcommand runs before `config` is bound, so it resolves its own.
|
|
1851
|
+
const lintConfig = { ...DEFAULTS, ...readUserConfig() }
|
|
1852
|
+
const ignored = lintConfig.ignoreCommits.map((pattern) => new RegExp(pattern, 'i'))
|
|
1853
|
+
|
|
1854
|
+
let subjects
|
|
1855
|
+
let scope
|
|
1856
|
+
if (subjectAt !== -1) {
|
|
1857
|
+
// A squash merge takes its subject from the pull request title, which is therefore the
|
|
1858
|
+
// commit this repository will parse — and the one no commit-msg hook ever sees.
|
|
1859
|
+
const text = rest[subjectAt + 1]
|
|
1860
|
+
if (text === undefined) abort('--subject needs the text to check', 'COMMIT LINT FAILED')
|
|
1861
|
+
subjects = [{ hash: '', subject: text.trim() }]
|
|
1862
|
+
scope = null
|
|
1863
|
+
} else {
|
|
1864
|
+
if (!tryRead('git', ['rev-parse', '--show-toplevel'])) abort('not inside a git repository')
|
|
1865
|
+
const lastTag = lastReleaseTag({ prefix: lintConfig.tagPrefix ?? '' })
|
|
1866
|
+
const range =
|
|
1867
|
+
rest.find((arg) => !arg.startsWith('-')) ?? (lastTag ? `${lastTag}..HEAD` : 'HEAD')
|
|
1868
|
+
// Merges carry no prose of their own, and %s is enough: nothing here reads the body.
|
|
1869
|
+
const raw = tryRead('git', ['log', '--no-merges', '--format=%h%x1f%s', range])
|
|
1870
|
+
if (raw === null) {
|
|
1871
|
+
abort(
|
|
1872
|
+
`\`git log ${range}\` failed — is that a range in this repository?`,
|
|
1873
|
+
'COMMIT LINT FAILED',
|
|
1874
|
+
)
|
|
1875
|
+
}
|
|
1876
|
+
subjects = raw
|
|
1877
|
+
.split('\n')
|
|
1878
|
+
.filter(Boolean)
|
|
1879
|
+
.map((line) => {
|
|
1880
|
+
const [hash, subject = ''] = line.split('\u001F')
|
|
1881
|
+
return { hash: hash.trim(), subject: subject.trim() }
|
|
1882
|
+
})
|
|
1883
|
+
// The bookkeeping `commitsSinceLastTag` drops for the same reason: a release commit,
|
|
1884
|
+
// a merge or an autosquash marker is nobody's prose to fix.
|
|
1885
|
+
.filter(({ subject }) => subject && !ignored.some((re) => re.test(subject)))
|
|
1886
|
+
scope = range
|
|
1887
|
+
}
|
|
1888
|
+
|
|
1889
|
+
const findings = lintSubjects(subjects)
|
|
1890
|
+
for (const { hash, subject, level, reason } of findings) {
|
|
1891
|
+
const label = level === 'error' ? red('error') : yellow('warn')
|
|
1892
|
+
console.log(` ${label} ${hash ? `${dim(hash)} ` : ''}${subject}\n ${dim(reason)}`)
|
|
1893
|
+
}
|
|
1894
|
+
const errors = findings.filter((f) => f.level === 'error').length
|
|
1895
|
+
if (errors) {
|
|
1896
|
+
abort(
|
|
1897
|
+
`${errors} subject${errors === 1 ? '' : 's'} the changelog and the version bump cannot read`,
|
|
1898
|
+
'COMMIT LINT FAILED',
|
|
1899
|
+
)
|
|
1900
|
+
}
|
|
1901
|
+
const counted = `${subjects.length} subject${subjects.length === 1 ? '' : 's'}`
|
|
1902
|
+
const unfiled = findings.length ? `, ${findings.length} unfiled` : ''
|
|
1903
|
+
ok(`${scope ? `${scope}: ` : ''}${counted} valid${unfiled}`)
|
|
1904
|
+
process.exit(0)
|
|
1905
|
+
}
|
|
1906
|
+
|
|
1183
1907
|
/**
|
|
1184
1908
|
* Options that consume the argument after them. Without this list a positional target is
|
|
1185
1909
|
* found by guessing, and `--only tag,push` gets read as the version to release.
|
|
@@ -1240,10 +1964,18 @@ if (existsSync(localManifest) && localManifest !== rootManifest) {
|
|
|
1240
1964
|
}
|
|
1241
1965
|
process.chdir(root)
|
|
1242
1966
|
|
|
1243
|
-
const userConfig =
|
|
1967
|
+
const userConfig = readUserConfig()
|
|
1244
1968
|
const config = { ...DEFAULTS, ...userConfig }
|
|
1245
1969
|
const unknownKeys = Object.keys(config).filter((key) => !(key in DEFAULTS))
|
|
1246
1970
|
if (unknownKeys.length) abort(`release.config.json has unknown keys: ${unknownKeys.join(', ')}`)
|
|
1971
|
+
// A misspelled hook name is a hook that silently never runs, which is the failure mode
|
|
1972
|
+
// this file refuses everywhere else it takes a name.
|
|
1973
|
+
const unknownHooks = Object.keys(config.hooks ?? {}).filter((key) => !HOOKS.includes(key))
|
|
1974
|
+
if (unknownHooks.length) {
|
|
1975
|
+
abort(
|
|
1976
|
+
`release.config.json has unknown hooks: ${unknownHooks.join(', ')}\n Known: ${HOOKS.join(', ')}`,
|
|
1977
|
+
)
|
|
1978
|
+
}
|
|
1247
1979
|
|
|
1248
1980
|
/**
|
|
1249
1981
|
* Which steps run: config provides the baseline, --only replaces it, --skip subtracts, and
|
|
@@ -1265,17 +1997,94 @@ const parseStepList = (value) =>
|
|
|
1265
1997
|
const manifest = existsSync('package.json') ? readJson('package.json') : null
|
|
1266
1998
|
|
|
1267
1999
|
/**
|
|
1268
|
-
*
|
|
1269
|
-
*
|
|
1270
|
-
*
|
|
2000
|
+
* Files that identify a repository, most definitive first. The first one present is where
|
|
2001
|
+
* the version is read from and written to. `go.mod` is absent because Go modules carry no
|
|
2002
|
+
* version — the tag is the version.
|
|
1271
2003
|
*/
|
|
2004
|
+
const MANIFESTS = ['package.json', 'pyproject.toml', 'Cargo.toml', 'VERSION']
|
|
2005
|
+
|
|
2006
|
+
/** Where this project keeps its version, when the config does not say. */
|
|
1272
2007
|
function detectVersionFile() {
|
|
1273
|
-
for (const candidate of
|
|
1274
|
-
if (existsSync(candidate)) return candidate
|
|
1275
|
-
}
|
|
2008
|
+
for (const candidate of MANIFESTS) if (existsSync(candidate)) return candidate
|
|
1276
2009
|
return null
|
|
1277
2010
|
}
|
|
1278
2011
|
|
|
2012
|
+
/**
|
|
2013
|
+
* Manifests that name an ecosystem, so a second one present means a second registry. A
|
|
2014
|
+
* bare VERSION file is deliberately not one: it names nothing, and a repository keeping an
|
|
2015
|
+
* unrelated VERSION beside its manifest should not be told the two disagree.
|
|
2016
|
+
*/
|
|
2017
|
+
const ECOSYSTEM_MANIFESTS = ['package.json', 'pyproject.toml', 'Cargo.toml']
|
|
2018
|
+
|
|
2019
|
+
/**
|
|
2020
|
+
* Some repositories release one source tree to two ecosystems at once — a Tauri plugin is
|
|
2021
|
+
* a crate and an npm package, a maturin project is a crate and a wheel — and carry the
|
|
2022
|
+
* version in both manifests. Those bump together with no config.
|
|
2023
|
+
*
|
|
2024
|
+
* The safety rule is that they must already agree. Two manifests on different versions are
|
|
2025
|
+
* two independent version lines, and dragging one to the other's number is a silent, wrong
|
|
2026
|
+
* release; say so and touch nothing instead.
|
|
2027
|
+
*
|
|
2028
|
+
* @returns {string[]} further files to keep in step with the primary version source
|
|
2029
|
+
*/
|
|
2030
|
+
function detectCompanionFiles(primaryPath, primaryVersion) {
|
|
2031
|
+
const companions = []
|
|
2032
|
+
for (const candidate of ECOSYSTEM_MANIFESTS) {
|
|
2033
|
+
if (candidate === primaryPath || !existsSync(candidate)) continue
|
|
2034
|
+
const found = readVersionFrom({ path: candidate })
|
|
2035
|
+
if (found !== primaryVersion) {
|
|
2036
|
+
warn(
|
|
2037
|
+
`${candidate} is at ${found ?? 'no readable version'} while ${primaryPath} is at ` +
|
|
2038
|
+
`${primaryVersion}, so they are versioned separately — leaving ${candidate} alone.\n` +
|
|
2039
|
+
' Add it to "versionFiles" in release.config.json to bump them together.',
|
|
2040
|
+
)
|
|
2041
|
+
continue
|
|
2042
|
+
}
|
|
2043
|
+
companions.push(candidate)
|
|
2044
|
+
// The lockfile pins the crate's own version too, so a bump leaves it stale.
|
|
2045
|
+
if (candidate === 'Cargo.toml' && existsSync('Cargo.lock')) companions.push('Cargo.lock')
|
|
2046
|
+
}
|
|
2047
|
+
return companions
|
|
2048
|
+
}
|
|
2049
|
+
|
|
2050
|
+
/**
|
|
2051
|
+
* Where a language records no version of its own, a project that wants `--version` to work
|
|
2052
|
+
* keeps one in source instead: a Go module has only `go.mod`, which carries no version at
|
|
2053
|
+
* all, so the number lives in a `version.go` the tag is supposed to match.
|
|
2054
|
+
*
|
|
2055
|
+
* These mirror the tag rather than define it — `go get` resolves a tag, not a constant —
|
|
2056
|
+
* so they are detected as files to keep in step, never as the source of truth.
|
|
2057
|
+
*
|
|
2058
|
+
* A candidate is adopted only when it already carries the current version, the same rule
|
|
2059
|
+
* the companion manifests use. Here it does a second job: it rules out the `var Version =
|
|
2060
|
+
* "dev"` placeholder that a build replaces with -ldflags, which is not a version to bump
|
|
2061
|
+
* and is common enough that warning about it every release would be pure noise. Mismatches
|
|
2062
|
+
* are therefore skipped silently, unlike a manifest on its own version line.
|
|
2063
|
+
*
|
|
2064
|
+
* Anything outside this table is three lines of `versionFiles` config with a `pattern`;
|
|
2065
|
+
* this covers the convention that comes up without one.
|
|
2066
|
+
*/
|
|
2067
|
+
const VERSION_MIRRORS = [
|
|
2068
|
+
{
|
|
2069
|
+
// `const Version = "1.2.0"`, `var Version = "1.2.0"`, and the same inside a const
|
|
2070
|
+
// block or with an explicit `string` type.
|
|
2071
|
+
pattern: '^\\s*(?:const\\s+|var\\s+)?[Vv]ersion\\s*(?:string\\s*)?=\\s*"(.+)"',
|
|
2072
|
+
paths: ['version.go', 'internal/version/version.go', 'pkg/version/version.go'],
|
|
2073
|
+
},
|
|
2074
|
+
]
|
|
2075
|
+
|
|
2076
|
+
/** @returns {{path: string, pattern: string}[]} source files already carrying `version` */
|
|
2077
|
+
function detectVersionMirrors(version) {
|
|
2078
|
+
const found = []
|
|
2079
|
+
for (const { paths, pattern } of VERSION_MIRRORS) {
|
|
2080
|
+
for (const path of paths) {
|
|
2081
|
+
if (!existsSync(path)) continue
|
|
2082
|
+
if (readVersionFrom({ path, pattern }) === version) found.push({ path, pattern })
|
|
2083
|
+
}
|
|
2084
|
+
}
|
|
2085
|
+
return found
|
|
2086
|
+
}
|
|
2087
|
+
|
|
1279
2088
|
/**
|
|
1280
2089
|
* The publish command implied by a project's manifest, but only where one ecosystem
|
|
1281
2090
|
* obviously owns it. Python has several publishers (uv, twine, poetry, flit) and Go has
|
|
@@ -1287,6 +2096,26 @@ const PUBLISH_BY_MANIFEST = {
|
|
|
1287
2096
|
'Cargo.toml': 'cargo publish',
|
|
1288
2097
|
}
|
|
1289
2098
|
|
|
2099
|
+
/**
|
|
2100
|
+
* A manifest that must not be published says so in itself. Detection honours that: a
|
|
2101
|
+
* private package or an unpublishable crate is one this repository releases by tag alone,
|
|
2102
|
+
* and detecting a command for it would attempt the one thing the manifest forbids.
|
|
2103
|
+
*/
|
|
2104
|
+
function detectablePublish(path) {
|
|
2105
|
+
if (basename(path) === 'package.json') return !readJson(path).private
|
|
2106
|
+
if (basename(path) === 'Cargo.toml') {
|
|
2107
|
+
const text = readFileSync(path, 'utf8')
|
|
2108
|
+
if (/^publish\s*=\s*false/m.test(text)) return false
|
|
2109
|
+
// A crate built only as a cdylib is a native extension module — what maturin and
|
|
2110
|
+
// napi-rs compile into a wheel or a .node — not a library anyone depends on from
|
|
2111
|
+
// crates.io. Its version travels with the package it is built into, which is why the
|
|
2112
|
+
// two match; publishing it to crates.io is the one thing nobody asked for.
|
|
2113
|
+
const crateTypes = /^crate-type\s*=\s*\[([^\]]*)\]/m.exec(text)?.[1]
|
|
2114
|
+
if (crateTypes?.includes('cdylib') && !crateTypes.includes('rlib')) return false
|
|
2115
|
+
}
|
|
2116
|
+
return true
|
|
2117
|
+
}
|
|
2118
|
+
|
|
1290
2119
|
// An explicit `versionFile: null` means "versions by tag" and must not be re-detected, so
|
|
1291
2120
|
// the distinction is between the key being absent and the key being set to null.
|
|
1292
2121
|
const configuredVersionFile = Object.hasOwn(userConfig, 'versionFile')
|
|
@@ -1312,11 +2141,7 @@ if (versionFile && !existsSync(versionFile.path)) {
|
|
|
1312
2141
|
* the version has to be typed out in full every time.
|
|
1313
2142
|
*/
|
|
1314
2143
|
function versionFromLastTag() {
|
|
1315
|
-
|
|
1316
|
-
if (!tag) return null
|
|
1317
|
-
const bare =
|
|
1318
|
-
config.tagPrefix && tag.startsWith(config.tagPrefix) ? tag.slice(config.tagPrefix.length) : tag
|
|
1319
|
-
return parseVersion(bare) ? bare : null
|
|
2144
|
+
return releaseTags()[0]?.version ?? null
|
|
1320
2145
|
}
|
|
1321
2146
|
|
|
1322
2147
|
const currentVersion = versionFile ? readVersionFrom(versionFile) : versionFromLastTag()
|
|
@@ -1334,13 +2159,73 @@ const goModule = existsSync('go.mod')
|
|
|
1334
2159
|
const projectName =
|
|
1335
2160
|
manifest?.name ?? (versionFile ? readNameFrom(versionFile) : null) ?? goModule ?? basename(root)
|
|
1336
2161
|
|
|
2162
|
+
/**
|
|
2163
|
+
* Manifests found beside the primary one that carry the same version. Detected only when
|
|
2164
|
+
* the project has said nothing about either key: a project that listed its own
|
|
2165
|
+
* `versionFiles` has already answered this question, and quietly appending to that answer
|
|
2166
|
+
* would release files it deliberately left out.
|
|
2167
|
+
*/
|
|
2168
|
+
const detecting =
|
|
2169
|
+
!!currentVersion &&
|
|
2170
|
+
!Object.hasOwn(userConfig, 'versionFile') &&
|
|
2171
|
+
!Object.hasOwn(userConfig, 'versionFiles')
|
|
2172
|
+
const companionFiles = detecting
|
|
2173
|
+
? [
|
|
2174
|
+
...(versionFile ? detectCompanionFiles(versionFile.path, currentVersion) : []),
|
|
2175
|
+
...detectVersionMirrors(currentVersion),
|
|
2176
|
+
]
|
|
2177
|
+
: []
|
|
2178
|
+
if (companionFiles.length) {
|
|
2179
|
+
config.versionFiles = companionFiles
|
|
2180
|
+
note(
|
|
2181
|
+
`also versioned in ${companionFiles.map((entry) => versionSource(entry).path).join(', ')} ` +
|
|
2182
|
+
'(detected)',
|
|
2183
|
+
)
|
|
2184
|
+
}
|
|
2185
|
+
|
|
2186
|
+
/**
|
|
2187
|
+
* Every file the version is written into: the source of truth first, then the files kept
|
|
2188
|
+
* in step with it. A repository that versions by tag alone has no source of truth here and
|
|
2189
|
+
* may still have mirrors to write — a Go module's `version.go` is exactly that — so this
|
|
2190
|
+
* is what the version step works from, rather than `versionFile` being required.
|
|
2191
|
+
*/
|
|
2192
|
+
/**
|
|
2193
|
+
* Every file the version is written into, with `*` in a `versionFiles` path expanded to
|
|
2194
|
+
* the files it matches.
|
|
2195
|
+
*
|
|
2196
|
+
* A pattern matching nothing is an error rather than a quiet skip: it was written to keep
|
|
2197
|
+
* files in step, and silently keeping none of them in step is the failure it was meant to
|
|
2198
|
+
* prevent. `versionFile` is never globbed — the source of truth is one file, and a glob
|
|
2199
|
+
* that resolved to two would make which one wins an accident of directory order.
|
|
2200
|
+
*/
|
|
2201
|
+
const versionTargets = [
|
|
2202
|
+
...(versionFile ? [versionSource(versionFile)] : []),
|
|
2203
|
+
...config.versionFiles.map(versionSource).flatMap((source) => {
|
|
2204
|
+
if (!source.path.includes('*')) return [source]
|
|
2205
|
+
const matched = expandPaths(source.path)
|
|
2206
|
+
if (!matched.length) abort(`versionFiles pattern ${source.path} matched no files`)
|
|
2207
|
+
// A glob says "every file of this shape", and some of them legitimately carry no
|
|
2208
|
+
// version — a Tauri per-OS overlay holds only the keys it overrides. Being unable to
|
|
2209
|
+
// write one is expected here, unlike a path someone named on purpose.
|
|
2210
|
+
return matched.map((path) => Object.assign({}, source, { path, optional: true }))
|
|
2211
|
+
}),
|
|
2212
|
+
]
|
|
2213
|
+
|
|
1337
2214
|
// Same rule as versionFile: an explicit `publish: null` means "publish nothing" and is
|
|
1338
2215
|
// never re-detected. Unset means "work it out", and working it out can yield nothing.
|
|
1339
2216
|
if (!Object.hasOwn(userConfig, 'publish')) {
|
|
1340
2217
|
// Preflight already reports "no publish command configured" when the step runs, so
|
|
1341
2218
|
// there is nothing to say here — and `runs` is not resolved this early.
|
|
1342
|
-
|
|
1343
|
-
|
|
2219
|
+
//
|
|
2220
|
+
// Two manifests releasing in step means two registries: the npm command comes first
|
|
2221
|
+
// because it is the recoverable one — npm allows an unpublish for 72 hours, crates.io
|
|
2222
|
+
// never does — so a half-finished publish leaves the undoable half undone.
|
|
2223
|
+
const detected = [versionFile, ...companionFiles]
|
|
2224
|
+
.filter(Boolean)
|
|
2225
|
+
.map((entry) => versionSource(entry).path)
|
|
2226
|
+
.filter((path) => PUBLISH_BY_MANIFEST[basename(path)] && detectablePublish(path))
|
|
2227
|
+
.map((path) => PUBLISH_BY_MANIFEST[basename(path)])
|
|
2228
|
+
config.publish = detected.length ? detected : null
|
|
1344
2229
|
}
|
|
1345
2230
|
|
|
1346
2231
|
// Validate every name that was asked for, not just the ones that survive: a typo in
|
|
@@ -1399,7 +2284,7 @@ const assistant = assistantName ? ASSISTANTS[assistantName] : null
|
|
|
1399
2284
|
// RESOLVE THE TARGET VERSION
|
|
1400
2285
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
1401
2286
|
|
|
1402
|
-
|
|
2287
|
+
say(
|
|
1403
2288
|
bold(`${projectName} release`) +
|
|
1404
2289
|
(dryRun ? ` ${yellow('(dry run — nothing will execute)')}` : ''),
|
|
1405
2290
|
)
|
|
@@ -1463,8 +2348,13 @@ if (!target) {
|
|
|
1463
2348
|
}
|
|
1464
2349
|
|
|
1465
2350
|
const tag = `${config.tagPrefix}${version}`
|
|
2351
|
+
if (PRINT_ONLY) {
|
|
2352
|
+
console.log(version)
|
|
2353
|
+
process.exit(0)
|
|
2354
|
+
}
|
|
2355
|
+
|
|
1466
2356
|
const isPrerelease = parseVersion(version).pre.length > 0
|
|
1467
|
-
const bumping =
|
|
2357
|
+
const bumping = versionTargets.length > 0 && version !== currentVersion && runs('version')
|
|
1468
2358
|
|
|
1469
2359
|
let distTag
|
|
1470
2360
|
try {
|
|
@@ -1491,7 +2381,43 @@ const expand = (template) => expandWith(template, (value) => value)
|
|
|
1491
2381
|
const shellQuote = (value) => `'${value.replaceAll("'", `'\\''`)}'`
|
|
1492
2382
|
const expandShell = (template) => expandWith(template, shellQuote)
|
|
1493
2383
|
|
|
1494
|
-
|
|
2384
|
+
/**
|
|
2385
|
+
* Run a lifecycle hook, if the project configured one.
|
|
2386
|
+
*
|
|
2387
|
+
* Anything a hook leaves modified is staged for the release commit. Preflight has already
|
|
2388
|
+
* established that the tree was clean (or that the `commit` step is committing all of it),
|
|
2389
|
+
* so a file that is dirty now was produced by this release and belongs in it — which is
|
|
2390
|
+
* what makes `afterVersion` useful for regenerating a file derived from the version.
|
|
2391
|
+
*
|
|
2392
|
+
* @param {string} name one of HOOKS
|
|
2393
|
+
*/
|
|
2394
|
+
function runHook(name) {
|
|
2395
|
+
const configured = config.hooks?.[name]
|
|
2396
|
+
if (!configured) return
|
|
2397
|
+
const commands = Array.isArray(configured) ? configured : [configured]
|
|
2398
|
+
step(`Hook ${name}`)
|
|
2399
|
+
for (const command of commands) mutateShell(expandShell(command))
|
|
2400
|
+
if (dryRun) return
|
|
2401
|
+
for (const path of dirtyPaths()) if (!staged.includes(path)) staged.push(path)
|
|
2402
|
+
}
|
|
2403
|
+
|
|
2404
|
+
/**
|
|
2405
|
+
* Paths git reports as changed, whatever the change is.
|
|
2406
|
+
*
|
|
2407
|
+
* The status column cannot be sliced at a fixed offset: the capture is trimmed, which
|
|
2408
|
+
* strips the leading space off the first entry only, so ` M file` arrives as `M file`
|
|
2409
|
+
* while the rest keep theirs. Split on the gap after the code instead.
|
|
2410
|
+
*/
|
|
2411
|
+
function dirtyPaths() {
|
|
2412
|
+
return (
|
|
2413
|
+
(tryRead('git', ['status', '--porcelain']) ?? '')
|
|
2414
|
+
.split('\n')
|
|
2415
|
+
.map((line) => /^\s*\S{1,2}\s+(.+)$/.exec(line)?.[1]?.trim())
|
|
2416
|
+
.filter(Boolean)
|
|
2417
|
+
// A rename reads as "old -> new"; the new path is the one to stage.
|
|
2418
|
+
.map((path) => path.split(' -> ').at(-1))
|
|
2419
|
+
)
|
|
2420
|
+
}
|
|
1495
2421
|
|
|
1496
2422
|
/**
|
|
1497
2423
|
* Registries whose preflight can be run, keyed by the first word of the publish command.
|
|
@@ -1510,13 +2436,58 @@ const REGISTRIES = {
|
|
|
1510
2436
|
// uv authenticates with a token from the environment rather than a logged-in session,
|
|
1511
2437
|
// and skips duplicate uploads itself via --check-url, so there is no version lookup.
|
|
1512
2438
|
uv: { env: ['UV_PUBLISH_TOKEN', 'UV_PUBLISH_PASSWORD'], login: 'set UV_PUBLISH_TOKEN' },
|
|
2439
|
+
// cargo has no "who am I": crates.io auth is a token, either in the environment or in
|
|
2440
|
+
// the credentials file `cargo login` writes. `cargo info` is the version lookup, and
|
|
2441
|
+
// exits non-zero for a version the index does not carry (cargo 1.82+).
|
|
2442
|
+
cargo: {
|
|
2443
|
+
env: ['CARGO_REGISTRY_TOKEN', 'CARGO_REGISTRIES_CRATES_IO_TOKEN'],
|
|
2444
|
+
credentials: [
|
|
2445
|
+
join(homedir(), '.cargo', 'credentials.toml'),
|
|
2446
|
+
join(homedir(), '.cargo', 'credentials'),
|
|
2447
|
+
],
|
|
2448
|
+
login: 'run `cargo login`, or set CARGO_REGISTRY_TOKEN',
|
|
2449
|
+
published: (name, v) => ['info', `${name}@${v}`],
|
|
2450
|
+
},
|
|
1513
2451
|
// For Go the tag is the release; `go list` warms the module proxy and doubles as the
|
|
1514
2452
|
// check for whether this version is already resolvable.
|
|
1515
2453
|
go: { published: (name, v) => ['list', '-m', `${name}@${v}`] },
|
|
1516
2454
|
}
|
|
1517
2455
|
|
|
1518
|
-
|
|
1519
|
-
|
|
2456
|
+
/**
|
|
2457
|
+
* Which manifest records the name a registry knows this project by, when it is not the one
|
|
2458
|
+
* `projectName` came from. They are not always the same string: a Tauri plugin publishes as
|
|
2459
|
+
* `@tauri-apps/plugin-x` on npm and `tauri-plugin-x` on crates.io, so looking the crate up
|
|
2460
|
+
* under its npm name would report every version as unpublished.
|
|
2461
|
+
*/
|
|
2462
|
+
const NAME_MANIFEST_BY_CLI = { cargo: 'Cargo.toml' }
|
|
2463
|
+
|
|
2464
|
+
function registryName(cli) {
|
|
2465
|
+
const manifest = NAME_MANIFEST_BY_CLI[cli]
|
|
2466
|
+
if (!manifest) return projectName
|
|
2467
|
+
const source = versionTargets.find((entry) => basename(entry.path) === manifest)
|
|
2468
|
+
return (source && readNameFrom(source)) ?? projectName
|
|
2469
|
+
}
|
|
2470
|
+
|
|
2471
|
+
/**
|
|
2472
|
+
* `publish` is one command or several, because one source tree can own a package in more
|
|
2473
|
+
* than one ecosystem. They run in the configured order.
|
|
2474
|
+
*/
|
|
2475
|
+
const publishList = config.publish == null ? [] : [config.publish].flat()
|
|
2476
|
+
if (publishList.some((entry) => typeof entry !== 'string')) {
|
|
2477
|
+
abort('publish must be a command string, an array of command strings, or null')
|
|
2478
|
+
}
|
|
2479
|
+
|
|
2480
|
+
/** Each publish command with the CLI it drives, that CLI's preflight row, and its name. */
|
|
2481
|
+
const publishTargets = runs('publish')
|
|
2482
|
+
? publishList.map((template) => {
|
|
2483
|
+
const command = expandShell(template)
|
|
2484
|
+
const cli = command.trim().split(/\s+/)[0]
|
|
2485
|
+
return { command, cli, registry: REGISTRIES[cli] ?? null, name: registryName(cli) }
|
|
2486
|
+
})
|
|
2487
|
+
: []
|
|
2488
|
+
|
|
2489
|
+
/** npm-family commands are the ones a `"private": true` package.json forbids. */
|
|
2490
|
+
const NPM_CLIS = new Set(['npm', 'pnpm', 'bun'])
|
|
1520
2491
|
|
|
1521
2492
|
/**
|
|
1522
2493
|
* CI publishing over OIDC ("trusted publishing") carries no token at all: `whoami` fails
|
|
@@ -1561,10 +2532,35 @@ if (autoBump) {
|
|
|
1561
2532
|
}
|
|
1562
2533
|
}
|
|
1563
2534
|
|
|
1564
|
-
|
|
2535
|
+
// Writing the version is the first mutating step, and it used to discover a file it
|
|
2536
|
+
// could not write *while writing the others* — aborting with a raw stack trace after
|
|
2537
|
+
// some of them had already changed. Every target is checked here instead.
|
|
2538
|
+
if (bumping) {
|
|
2539
|
+
for (const source of versionTargets) {
|
|
2540
|
+
if (!existsSync(source.path)) {
|
|
2541
|
+
fail(`versionFiles entry ${source.path} does not exist`)
|
|
2542
|
+
continue
|
|
2543
|
+
}
|
|
2544
|
+
if (source.optional) continue
|
|
2545
|
+
const text = readFileSync(source.path, 'utf8')
|
|
2546
|
+
const { kind, shape } = versionMode(source, text)
|
|
2547
|
+
if (kind === 'pattern' && !shape.test(text)) {
|
|
2548
|
+
fail(
|
|
2549
|
+
`${source.path} has no version for release-kit to replace.\n` +
|
|
2550
|
+
' Remove it from versionFiles, mark the line with x-release-kit-version, ' +
|
|
2551
|
+
'or give the entry a "pattern".',
|
|
2552
|
+
)
|
|
2553
|
+
}
|
|
2554
|
+
}
|
|
2555
|
+
}
|
|
2556
|
+
|
|
2557
|
+
if (bumping && currentVersion && compareVersions(version, currentVersion) <= 0) {
|
|
1565
2558
|
fail(`${version} is not greater than the current version ${currentVersion}`)
|
|
1566
|
-
} else if (bumping) {
|
|
2559
|
+
} else if (bumping && currentVersion) {
|
|
1567
2560
|
ok(`version ${currentVersion} → ${version}`)
|
|
2561
|
+
} else if (bumping) {
|
|
2562
|
+
// No manifest and no tag to read a version from, but files to write one into.
|
|
2563
|
+
ok(`writing ${version} into ${versionTargets.map((source) => source.path).join(', ')}`)
|
|
1568
2564
|
} else if (versionFile) {
|
|
1569
2565
|
ok(`releasing the version already in ${versionFile.path} (${version})`)
|
|
1570
2566
|
} else {
|
|
@@ -1573,10 +2569,16 @@ if (bumping && compareVersions(version, currentVersion) <= 0) {
|
|
|
1573
2569
|
|
|
1574
2570
|
const dirty = tryRead('git', ['status', '--porcelain'])
|
|
1575
2571
|
if (dirty === null) fail('could not read git status')
|
|
1576
|
-
else if (dirty && runs('commit')
|
|
2572
|
+
else if (dirty && runs('commit')) {
|
|
1577
2573
|
const entries = dirty.split('\n')
|
|
1578
2574
|
ok(`working tree has ${entries.length} change(s) — will be committed first`)
|
|
1579
2575
|
console.log(dim(indent(formatStatus(dirty))))
|
|
2576
|
+
if (!assistant) {
|
|
2577
|
+
note(
|
|
2578
|
+
'no assistant configured: the commit message will name the files — ' +
|
|
2579
|
+
'`--assistant auto` drafts a real one',
|
|
2580
|
+
)
|
|
2581
|
+
}
|
|
1580
2582
|
// One commit gets one subject. A change set spanning several top-level directories is
|
|
1581
2583
|
// usually several pieces of work, and no honest Conventional Commits subject covers it.
|
|
1582
2584
|
const areas = new Set(
|
|
@@ -1592,12 +2594,6 @@ else if (dirty && runs('commit') && assistant) {
|
|
|
1592
2594
|
'releasing with --skip commit.',
|
|
1593
2595
|
)
|
|
1594
2596
|
}
|
|
1595
|
-
} else if (dirty && runs('commit')) {
|
|
1596
|
-
fail(
|
|
1597
|
-
`working tree is not clean:\n${indent(formatStatus(dirty))}\n` +
|
|
1598
|
-
' Configure a drafting assistant (`--assistant auto`, or "assistant" in ' +
|
|
1599
|
-
'release.config.json) to have these committed automatically, or commit them yourself.',
|
|
1600
|
-
)
|
|
1601
2597
|
} else if (dirty) {
|
|
1602
2598
|
fail(`working tree is not clean:\n${indent(formatStatus(dirty))}`)
|
|
1603
2599
|
} else ok('working tree clean')
|
|
@@ -1627,9 +2623,9 @@ else if (detached) {
|
|
|
1627
2623
|
fail(`on '${branch}', expected '${config.branch}'`)
|
|
1628
2624
|
} else ok(`on ${branch}`)
|
|
1629
2625
|
|
|
1630
|
-
// A shallow clone (CI checkouts default to depth 1)
|
|
1631
|
-
//
|
|
1632
|
-
//
|
|
2626
|
+
// A shallow clone (CI checkouts default to depth 1) is only a problem when it truncates
|
|
2627
|
+
// the history the release actually reads. Whether it does is checked after the fetch
|
|
2628
|
+
// below, where the answer is most accurate.
|
|
1633
2629
|
const shallow = tryRead('git', ['rev-parse', '--is-shallow-repository']) === 'true'
|
|
1634
2630
|
|
|
1635
2631
|
if (!succeeds('git', ['remote', 'get-url', config.remote])) {
|
|
@@ -1652,6 +2648,50 @@ if (!succeeds('git', ['remote', 'get-url', config.remote])) {
|
|
|
1652
2648
|
}
|
|
1653
2649
|
}
|
|
1654
2650
|
|
|
2651
|
+
// If the previous release tag is reachable from HEAD, a shallow clone hides nothing the
|
|
2652
|
+
// release reads — notes and `auto` see the whole span. No reachable tag means the history
|
|
2653
|
+
// is provably truncated: `auto` would infer the bump from a fraction of the commits, so
|
|
2654
|
+
// that is a failure; commit-derived notes merely come out partial, so that is a warning.
|
|
2655
|
+
let shallowHidesHistory = false
|
|
2656
|
+
if (shallow) {
|
|
2657
|
+
const reachableTag = lastReleaseTag()
|
|
2658
|
+
shallowHidesHistory = !reachableTag
|
|
2659
|
+
if (reachableTag) {
|
|
2660
|
+
ok(
|
|
2661
|
+
`shallow clone, but history back to ${reachableTag} is visible — notes and auto are complete`,
|
|
2662
|
+
)
|
|
2663
|
+
} else if (autoBump) {
|
|
2664
|
+
fail(
|
|
2665
|
+
'shallow clone hides the history `auto` infers the bump from — no previous tag is ' +
|
|
2666
|
+
'reachable.\n Fetch full history: fetch-depth: 0 in CI, or git fetch --unshallow.',
|
|
2667
|
+
)
|
|
2668
|
+
} else {
|
|
2669
|
+
warn(
|
|
2670
|
+
'shallow clone: no previous tag is reachable, so notes drafted from commits will ' +
|
|
2671
|
+
'describe only the visible history. Fetch full history (fetch-depth: 0, or ' +
|
|
2672
|
+
'git fetch --unshallow).',
|
|
2673
|
+
)
|
|
2674
|
+
}
|
|
2675
|
+
}
|
|
2676
|
+
|
|
2677
|
+
// The registry's "Repository" link comes from the manifest, not from git — a mismatch
|
|
2678
|
+
// ships a broken link with every publish, and npm only warns after the fact.
|
|
2679
|
+
if (existsSync('package.json')) {
|
|
2680
|
+
const repoField = readJson('package.json').repository
|
|
2681
|
+
const declared = typeof repoField === 'string' ? repoField : repoField?.url
|
|
2682
|
+
const remoteUrl = tryRead('git', ['remote', 'get-url', config.remote])
|
|
2683
|
+
if (declared && remoteUrl && /:\/\/|@/.test(declared)) {
|
|
2684
|
+
if (normalizeRepoUrl(declared) !== normalizeRepoUrl(remoteUrl)) {
|
|
2685
|
+
warn(
|
|
2686
|
+
`package.json repository is ${declared}, but ${config.remote} is ${remoteUrl} — ` +
|
|
2687
|
+
'the registry will link the wrong repository',
|
|
2688
|
+
)
|
|
2689
|
+
} else if (!/^git\+.*\.git$/.test(declared)) {
|
|
2690
|
+
note(`npm normalizes repository.url on publish — \`npm pkg fix\` writes that form`)
|
|
2691
|
+
}
|
|
2692
|
+
}
|
|
2693
|
+
}
|
|
2694
|
+
|
|
1655
2695
|
// Signing is configured per repository and inherited, never managed here — git already
|
|
1656
2696
|
// owns that. But a signing setup that cannot produce a signature fails at the commit step,
|
|
1657
2697
|
// after the version has been written, so it is worth catching before anything mutates.
|
|
@@ -1719,37 +2759,70 @@ if (!runs('release')) {
|
|
|
1719
2759
|
if (releaseExists) note(`a GitHub release for ${tag} already exists — will skip that step`)
|
|
1720
2760
|
}
|
|
1721
2761
|
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
note(runs('publish') ? 'no publish command configured' : 'publish step not selected')
|
|
1725
|
-
} else if (manifest?.private) {
|
|
1726
|
-
fail('package.json is private but a publish command is configured')
|
|
1727
|
-
} else if (!registry) {
|
|
1728
|
-
ok(`publish: ${publishCommand}`)
|
|
1729
|
-
} else {
|
|
2762
|
+
/** Whether one publish CLI can publish at all: OIDC, a token, or a live session. */
|
|
2763
|
+
function checkCredentials({ cli, registry, command }) {
|
|
1730
2764
|
if (isTrustedPublishing) {
|
|
1731
|
-
ok(
|
|
1732
|
-
|
|
1733
|
-
//
|
|
2765
|
+
ok(`${cli}: trusted publishing (OIDC) — no token needed`)
|
|
2766
|
+
// Provenance is the other half of what OIDC makes possible: a signed attestation
|
|
2767
|
+
// tying the published artefact to the workflow and commit that produced it. It is
|
|
2768
|
+
// not added to the command here — npm generates it for a trusted publish on its own,
|
|
2769
|
+
// and forcing the flag fails outright for a private package or a registry that
|
|
2770
|
+
// cannot receive one. Saying so is what turns "available" into "used".
|
|
2771
|
+
if (NPM_CLIS.has(cli) && !/--provenance\b/.test(command ?? '')) {
|
|
2772
|
+
note(
|
|
2773
|
+
`${cli}: OIDC also allows a signed provenance attestation — add --provenance to ` +
|
|
2774
|
+
'the publish command if the registry accepts one and the package is public',
|
|
2775
|
+
)
|
|
2776
|
+
}
|
|
2777
|
+
return
|
|
2778
|
+
}
|
|
2779
|
+
if (registry.env) {
|
|
2780
|
+
// Token auth: there is no session to interrogate, only credentials to find.
|
|
1734
2781
|
const found = registry.env.find((name) => process.env[name])
|
|
1735
|
-
if (found)
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
2782
|
+
if (found) {
|
|
2783
|
+
ok(`${cli} credentials found (${found})`)
|
|
2784
|
+
return
|
|
2785
|
+
}
|
|
2786
|
+
const file = registry.credentials?.find((path) => existsSync(path))
|
|
2787
|
+
if (file) ok(`${cli} credentials found (${file})`)
|
|
2788
|
+
else fail(`${cli} has no publish credentials — ${registry.login}`)
|
|
2789
|
+
return
|
|
2790
|
+
}
|
|
2791
|
+
if (registry.whoami) {
|
|
2792
|
+
const user = tryRead(cli, registry.whoami)
|
|
1739
2793
|
if (user === null) {
|
|
1740
2794
|
// npm replaced long-lived tokens with two-hour sessions in December 2025, so the
|
|
1741
2795
|
// usual cause is an expired session rather than a missing login.
|
|
1742
2796
|
fail(
|
|
1743
|
-
`${
|
|
2797
|
+
`${cli} is not authenticated — run \`${cli} login\`. ` +
|
|
1744
2798
|
'npm logins are two-hour sessions, so an earlier one may have expired.',
|
|
1745
2799
|
)
|
|
1746
|
-
} else ok(`${
|
|
2800
|
+
} else ok(`${cli} authenticated (${user || 'unknown user'})`)
|
|
1747
2801
|
}
|
|
2802
|
+
}
|
|
1748
2803
|
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
2804
|
+
/** Commands whose version is already on the registry, so the publish step skips them. */
|
|
2805
|
+
const alreadyPublished = new Set()
|
|
2806
|
+
if (!publishTargets.length) {
|
|
2807
|
+
note(runs('publish') ? 'no publish command configured' : 'publish step not selected')
|
|
2808
|
+
} else if (manifest?.private && publishTargets.some((target) => NPM_CLIS.has(target.cli))) {
|
|
2809
|
+
fail('package.json is private but an npm publish command is configured')
|
|
2810
|
+
} else {
|
|
2811
|
+
// One CLI can appear more than once; interrogating it twice says the same thing twice.
|
|
2812
|
+
const authenticated = new Set()
|
|
2813
|
+
for (const target of publishTargets) {
|
|
2814
|
+
ok(`publish: ${target.command}`)
|
|
2815
|
+
if (!target.registry) continue
|
|
2816
|
+
if (!authenticated.has(target.cli)) {
|
|
2817
|
+
authenticated.add(target.cli)
|
|
2818
|
+
checkCredentials(target)
|
|
2819
|
+
}
|
|
2820
|
+
if (
|
|
2821
|
+
target.registry.published &&
|
|
2822
|
+
succeeds(target.cli, target.registry.published(target.name, version))
|
|
2823
|
+
) {
|
|
2824
|
+
alreadyPublished.add(target.command)
|
|
2825
|
+
note(`${target.name}@${version} is already published — will skip \`${target.command}\``)
|
|
1753
2826
|
}
|
|
1754
2827
|
}
|
|
1755
2828
|
}
|
|
@@ -1791,7 +2864,17 @@ let draftedNotes = null
|
|
|
1791
2864
|
* True when --commit still has to create a commit. Notes drafted before that commit would
|
|
1792
2865
|
* describe an incomplete release, so drafting waits until the working tree is committed.
|
|
1793
2866
|
*/
|
|
1794
|
-
const notesDeferred = !!(dirty && runs('commit')
|
|
2867
|
+
const notesDeferred = !!(dirty && runs('commit'))
|
|
2868
|
+
|
|
2869
|
+
/**
|
|
2870
|
+
* Set only when preflight found nothing to release with and left the drafting to the
|
|
2871
|
+
* post-commit step. Deferral has to be recorded rather than re-derived from `notesDeferred`
|
|
2872
|
+
* there: a dirty tree is what makes drafting possible to defer, not what makes it necessary.
|
|
2873
|
+
* A hand-written changelog section has already answered the question, and re-drafting over
|
|
2874
|
+
* it would discard the notes the confirmation prompt showed and append a second section for
|
|
2875
|
+
* the same version.
|
|
2876
|
+
*/
|
|
2877
|
+
let notesPending = false
|
|
1795
2878
|
|
|
1796
2879
|
/**
|
|
1797
2880
|
* Notes for a version, in descending order of how much they can be trusted:
|
|
@@ -1799,7 +2882,12 @@ const notesDeferred = !!(dirty && runs('commit') && assistant)
|
|
|
1799
2882
|
* Conventional Commit type. Only when neither yields anything does GitHub generate them.
|
|
1800
2883
|
*/
|
|
1801
2884
|
function draftNotesFor(v) {
|
|
1802
|
-
|
|
2885
|
+
// A stable release absorbs the candidates that led to it: their commits are what it
|
|
2886
|
+
// ships, and reading from the last candidate leaves the notes describing the gap
|
|
2887
|
+
// between two candidates rather than the release.
|
|
2888
|
+
const { lastTag, subjects, commits, contributors } = commitsSinceLastTag({
|
|
2889
|
+
stable: !isPrerelease,
|
|
2890
|
+
})
|
|
1803
2891
|
if (!commits.length) return null
|
|
1804
2892
|
|
|
1805
2893
|
// Notes are built from Conventional Commits, so anything not written that way is simply
|
|
@@ -1814,8 +2902,13 @@ function draftNotesFor(v) {
|
|
|
1814
2902
|
}
|
|
1815
2903
|
// An explicitly named source wins over the assistant being merely available.
|
|
1816
2904
|
if (!assistant || notesSource === 'commits')
|
|
1817
|
-
return changelogFromCommits(
|
|
1818
|
-
|
|
2905
|
+
return changelogFromCommits(
|
|
2906
|
+
commits,
|
|
2907
|
+
remoteLinks(config.remote),
|
|
2908
|
+
config.hiddenTypes,
|
|
2909
|
+
contributors,
|
|
2910
|
+
)
|
|
2911
|
+
if (shallowHidesHistory) {
|
|
1819
2912
|
warn(
|
|
1820
2913
|
`shallow clone: only ${subjects.length} commit(s) are visible, so the notes will ` +
|
|
1821
2914
|
'describe part of the release. Check out with full history (fetch-depth: 0).',
|
|
@@ -1824,7 +2917,7 @@ function draftNotesFor(v) {
|
|
|
1824
2917
|
note(`drafting notes from ${subjects.length} commit(s) with ${assistantName}...`)
|
|
1825
2918
|
return (
|
|
1826
2919
|
draftReleaseNotes(v, commits, lastTag, remoteLinks(config.remote)) ??
|
|
1827
|
-
changelogFromCommits(commits, remoteLinks(config.remote), config.hiddenTypes)
|
|
2920
|
+
changelogFromCommits(commits, remoteLinks(config.remote), config.hiddenTypes, contributors)
|
|
1828
2921
|
)
|
|
1829
2922
|
}
|
|
1830
2923
|
const changelogText =
|
|
@@ -1857,7 +2950,8 @@ if (notesSource === 'github') {
|
|
|
1857
2950
|
// Generate, either because nothing was written or because a source was named.
|
|
1858
2951
|
if (!notes) {
|
|
1859
2952
|
if (notesDeferred) {
|
|
1860
|
-
|
|
2953
|
+
notesPending = true
|
|
2954
|
+
ok(`release notes will be ${assistant ? 'drafted' : 'generated'} after the commit`)
|
|
1861
2955
|
} else {
|
|
1862
2956
|
draftedNotes = draftNotesFor(version)
|
|
1863
2957
|
if (draftedNotes) {
|
|
@@ -1895,6 +2989,19 @@ if (taggedCommit && runs('tag') && (bumping || rolledChangelog)) {
|
|
|
1895
2989
|
)
|
|
1896
2990
|
}
|
|
1897
2991
|
|
|
2992
|
+
// The project's own gate, run while nothing has mutated. Without this, a prepublishOnly
|
|
2993
|
+
// hook is the gate — and it fails at the publish step, after the commit, tag and push.
|
|
2994
|
+
if (config.verify) {
|
|
2995
|
+
note(`running verify: ${config.verify}`)
|
|
2996
|
+
try {
|
|
2997
|
+
execSync(config.verify, { stdio: 'pipe', encoding: 'utf8' })
|
|
2998
|
+
ok(`verify passed: ${config.verify}`)
|
|
2999
|
+
} catch (err) {
|
|
3000
|
+
const tail = `${err.stdout ?? ''}${err.stderr ?? ''}`.trim().split('\n').slice(-12).join('\n')
|
|
3001
|
+
fail(`verify failed: ${config.verify}\n${indent(tail)}`)
|
|
3002
|
+
}
|
|
3003
|
+
}
|
|
3004
|
+
|
|
1898
3005
|
if (problems.length) {
|
|
1899
3006
|
const summary = `${problems.length} preflight check(s) failed:\n - ${problems.join('\n - ')}`
|
|
1900
3007
|
if (!dryRun) abort(summary)
|
|
@@ -1919,13 +3026,16 @@ if (dirty && runs('commit') && !dryRun) {
|
|
|
1919
3026
|
step('Stage the working tree')
|
|
1920
3027
|
mutate('git', ['add', '--all'])
|
|
1921
3028
|
didStage = true
|
|
1922
|
-
commitMessage = draftCommitMessage()
|
|
3029
|
+
commitMessage = assistant ? draftCommitMessage() : null
|
|
1923
3030
|
if (!commitMessage) {
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
3031
|
+
// No assistant, or its draft was unusable: never block on a text generator. The
|
|
3032
|
+
// deterministic floor is a chore commit that names what it touches.
|
|
3033
|
+
if (assistant) {
|
|
3034
|
+
note(`${assistantName} produced no usable message — falling back to a generated one`)
|
|
3035
|
+
}
|
|
3036
|
+
const files =
|
|
3037
|
+
tryRead('git', ['diff', '--cached', '--name-only'])?.split('\n').filter(Boolean) ?? []
|
|
3038
|
+
commitMessage = fallbackCommitMessage(files)
|
|
1929
3039
|
}
|
|
1930
3040
|
console.log(indent(commitMessage))
|
|
1931
3041
|
}
|
|
@@ -1961,33 +3071,38 @@ if (dirty && runs('commit')) {
|
|
|
1961
3071
|
else mutate('git', ['commit', '-m', commitMessage])
|
|
1962
3072
|
|
|
1963
3073
|
// Now that the commit exists it is part of the release, so the notes can describe it.
|
|
1964
|
-
if (
|
|
3074
|
+
if (notesPending && !dryRun) {
|
|
1965
3075
|
draftedNotes = draftNotesFor(version)
|
|
1966
3076
|
if (draftedNotes) notes = draftedNotes
|
|
1967
3077
|
}
|
|
1968
3078
|
}
|
|
1969
3079
|
|
|
3080
|
+
runHook('beforeVersion')
|
|
3081
|
+
|
|
1970
3082
|
if (bumping) {
|
|
1971
3083
|
step(`Write version ${version}`)
|
|
1972
|
-
|
|
1973
|
-
|
|
3084
|
+
const releaseDate = new Date().toISOString().slice(0, 10)
|
|
3085
|
+
for (const source of versionTargets) {
|
|
1974
3086
|
if (!existsSync(source.path)) abort(`versionFiles entry ${source.path} does not exist`)
|
|
1975
|
-
if (writeVersionInto(source, version, { dryRun })) {
|
|
3087
|
+
if (writeVersionInto(source, version, { dryRun, date: releaseDate })) {
|
|
1976
3088
|
staged.push(source.path)
|
|
1977
3089
|
console.log(` ${dryRun ? yellow('would write') : dim('wrote')} ${source.path}`)
|
|
1978
3090
|
}
|
|
1979
3091
|
}
|
|
1980
|
-
|
|
1981
|
-
if (existsSync('package-lock.json')) {
|
|
1982
|
-
mutate('npm', ['install', '--package-lock-only', '--ignore-scripts', '--silent'])
|
|
1983
|
-
staged.push('package-lock.json')
|
|
1984
|
-
}
|
|
3092
|
+
refreshLockfiles(versionTargets.map((source) => source.path))
|
|
1985
3093
|
}
|
|
1986
3094
|
|
|
3095
|
+
// After the version is on disk and before the release commit, so a file the hook
|
|
3096
|
+
// regenerates from the version rides in that commit rather than being left behind.
|
|
3097
|
+
runHook('afterVersion')
|
|
3098
|
+
|
|
3099
|
+
/** The version headings a changelog carries are dead link references without these. */
|
|
3100
|
+
const linked = (text) => withChangelogLinks(text, remoteLinks(config.remote), config.tagPrefix)
|
|
3101
|
+
|
|
1987
3102
|
if (rolledChangelog && runs('changelog')) {
|
|
1988
3103
|
step(`Roll ${config.changelog} to ${version}`)
|
|
1989
3104
|
if (dryRun) console.log(` ${yellow('would write')} ${config.changelog}`)
|
|
1990
|
-
else writeFileSync(config.changelog, rolledChangelog)
|
|
3105
|
+
else writeFileSync(config.changelog, linked(rolledChangelog))
|
|
1991
3106
|
staged.push(config.changelog)
|
|
1992
3107
|
} else if (runs('changelog') && draftedNotes && config.changelog && existsSync(config.changelog)) {
|
|
1993
3108
|
step(`Add the drafted ${version} section to ${config.changelog}`)
|
|
@@ -1995,11 +3110,13 @@ if (rolledChangelog && runs('changelog')) {
|
|
|
1995
3110
|
else {
|
|
1996
3111
|
writeFileSync(
|
|
1997
3112
|
config.changelog,
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
3113
|
+
linked(
|
|
3114
|
+
insertChangelogSection(
|
|
3115
|
+
readFileSync(config.changelog, 'utf8'),
|
|
3116
|
+
version,
|
|
3117
|
+
new Date().toISOString().slice(0, 10),
|
|
3118
|
+
draftedNotes,
|
|
3119
|
+
),
|
|
2003
3120
|
),
|
|
2004
3121
|
)
|
|
2005
3122
|
}
|
|
@@ -2044,15 +3161,25 @@ if (runs('tag') && !taggedCommit) {
|
|
|
2044
3161
|
|
|
2045
3162
|
if (runs('push')) {
|
|
2046
3163
|
step(`Push branch and tag to ${config.remote}`)
|
|
2047
|
-
|
|
2048
|
-
// a tag ends up on the remote without its commit, or a release without its tag.
|
|
2049
|
-
mutate('git', ['push', '--follow-tags', config.remote, branch ?? 'HEAD'])
|
|
3164
|
+
pushBranchAndTag(branch ?? 'HEAD')
|
|
2050
3165
|
}
|
|
2051
3166
|
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
3167
|
+
// After the tag is pushed and before anything is published: the point where an artefact
|
|
3168
|
+
// the publish command expects to find has to exist.
|
|
3169
|
+
if (publishTargets.length) runHook('beforePublish')
|
|
3170
|
+
|
|
3171
|
+
let publishedSomething = false
|
|
3172
|
+
for (const target of publishTargets) {
|
|
3173
|
+
if (alreadyPublished.has(target.command)) continue
|
|
3174
|
+
step(
|
|
3175
|
+
`Publish ${target.name} (${target.cli}${NPM_CLIS.has(target.cli) ? `, dist-tag ${distTag}` : ''})`,
|
|
3176
|
+
)
|
|
3177
|
+
mutateShell(target.command)
|
|
3178
|
+
publishedSomething = true
|
|
2055
3179
|
}
|
|
3180
|
+
// Only when something was actually published: a re-run that skipped every already-published
|
|
3181
|
+
// target published nothing, and telling downstream otherwise is a lie it may act on.
|
|
3182
|
+
if (publishedSomething) runHook('afterPublish')
|
|
2056
3183
|
|
|
2057
3184
|
if (runs('release') && !releaseExists) {
|
|
2058
3185
|
step(`GitHub release ${tag}`)
|
|
@@ -2068,6 +3195,7 @@ if (runs('release') && !releaseExists) {
|
|
|
2068
3195
|
...config.assets,
|
|
2069
3196
|
]
|
|
2070
3197
|
mutate('gh', args, notes ? { input: `${notes}\n` } : {})
|
|
3198
|
+
runHook('afterRelease')
|
|
2071
3199
|
}
|
|
2072
3200
|
|
|
2073
3201
|
/**
|
|
@@ -2087,7 +3215,7 @@ function emitOutputs() {
|
|
|
2087
3215
|
name: projectName,
|
|
2088
3216
|
'dist-tag': distTag,
|
|
2089
3217
|
steps: STEPS.filter(runs).join(','),
|
|
2090
|
-
published: String(
|
|
3218
|
+
published: String(publishTargets.some((target) => !alreadyPublished.has(target.command))),
|
|
2091
3219
|
'release-url': releaseUrl,
|
|
2092
3220
|
}
|
|
2093
3221
|
try {
|