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