@entro314labs/release-kit 2.9.4 → 2.11.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 +221 -42
- package/TRAIN.md +78 -41
- package/package.json +3 -3
- package/release.mjs +671 -47
- package/train.mjs +328 -33
package/release.mjs
CHANGED
|
@@ -42,6 +42,7 @@ 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'
|
|
@@ -92,6 +93,9 @@ import { fileURLToPath } from 'node:url'
|
|
|
92
93
|
* verify string command run during preflight — a project's own gate (tests,
|
|
93
94
|
* build). Non-zero aborts before anything mutates, instead of a
|
|
94
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`
|
|
95
99
|
* assistant string|object drafting CLI for commit messages and notes. A key of
|
|
96
100
|
* ASSISTANTS, "auto" for the first available, or null. The
|
|
97
101
|
* object form { tool, model, effort } also pins which model and
|
|
@@ -147,6 +151,7 @@ const DEFAULTS = {
|
|
|
147
151
|
'^(fixup|squash)!',
|
|
148
152
|
],
|
|
149
153
|
verify: null,
|
|
154
|
+
requireGreen: false,
|
|
150
155
|
hooks: {},
|
|
151
156
|
}
|
|
152
157
|
|
|
@@ -215,6 +220,8 @@ Flags:
|
|
|
215
220
|
drafted Conventional Commits message instead of refusing to release
|
|
216
221
|
--preid <id> prerelease identifier (alpha, beta, rc, next, nightly, canary)
|
|
217
222
|
--dist-tag <name> override the npm dist-tag (default: derived from the version)
|
|
223
|
+
--package release the package in this directory, one of several in the
|
|
224
|
+
repository: its own tag (<name>@<version>), commits and publish
|
|
218
225
|
--dry-run print every step and execute nothing
|
|
219
226
|
--yes, -y skip the confirmation prompt
|
|
220
227
|
--notes-file <path> write the resolved release notes to a file for the next tool
|
|
@@ -281,7 +288,9 @@ const formatStatus = (porcelain) =>
|
|
|
281
288
|
.join('\n')
|
|
282
289
|
|
|
283
290
|
function abort(message, title = 'RELEASE ABORTED') {
|
|
284
|
-
|
|
291
|
+
// Through `say`: under `next`, stdout carries the version alone, and an abort captured by
|
|
292
|
+
// `$(...)` would otherwise become the "version".
|
|
293
|
+
say(`\n${red(bold(title))} — ${message}\n`)
|
|
285
294
|
process.exit(1)
|
|
286
295
|
}
|
|
287
296
|
|
|
@@ -292,9 +301,15 @@ function abort(message, title = 'RELEASE ABORTED') {
|
|
|
292
301
|
* the real cause under a Node stack trace.
|
|
293
302
|
*/
|
|
294
303
|
function abortMidRelease(commandLine) {
|
|
304
|
+
// A relative bump re-run as-is would count from the version this run already wrote, and
|
|
305
|
+
// preflight refuses it; what finishes the release is the version itself, or no target.
|
|
306
|
+
const rerun =
|
|
307
|
+
target && target !== 'auto' && BUMPS.has(target)
|
|
308
|
+
? 're-run with no target (or `auto`)'
|
|
309
|
+
: 're-run the same command'
|
|
295
310
|
abort(
|
|
296
311
|
`\`${commandLine}\` failed — see its output above.\n\n` +
|
|
297
|
-
|
|
312
|
+
` The release stopped partway through. Fix the cause and ${rerun}:\n` +
|
|
298
313
|
' the steps that already completed are detected and skipped.',
|
|
299
314
|
)
|
|
300
315
|
}
|
|
@@ -573,7 +588,7 @@ function commitsSinceLastTag(options) {
|
|
|
573
588
|
// separator keeps multi-line messages parseable when splitting the log back apart.
|
|
574
589
|
// %h first, then the author, then the message: the hash is what links each bullet back
|
|
575
590
|
// to its commit, and the author is what says who is new here.
|
|
576
|
-
const raw = tryRead('git', ['log', `--format=%h%x1f%an%x1f%ae%x1f%B%x1e`, range]) ?? ''
|
|
591
|
+
const raw = tryRead('git', ['log', `--format=%h%x1f%an%x1f%ae%x1f%B%x1e`, range, ...SCOPE]) ?? ''
|
|
577
592
|
const commits = raw
|
|
578
593
|
.split('\u001E')
|
|
579
594
|
.map((entry) => entry.trim())
|
|
@@ -657,11 +672,29 @@ const CHANGELOG_SECTIONS = [
|
|
|
657
672
|
{ type: 'chore', section: 'Miscellaneous Chores' },
|
|
658
673
|
]
|
|
659
674
|
|
|
675
|
+
/**
|
|
676
|
+
* The `Notes:` trailer in a commit body: the author's own wording for the release-notes
|
|
677
|
+
* entry, which beats anything derived from the subject. `Notes: no-notes` keeps the commit
|
|
678
|
+
* out of the notes altogether — a refactor that has to be a `fix:` for the version bump but
|
|
679
|
+
* that no reader upgrading needs to hear about. GitHub Desktop runs its notes this way.
|
|
680
|
+
*
|
|
681
|
+
* The version bump is not affected either way: the trailer decides what is said about a
|
|
682
|
+
* change, not whether it happened.
|
|
683
|
+
*
|
|
684
|
+
* @returns {{text: string|null, excluded: boolean}}
|
|
685
|
+
*/
|
|
686
|
+
function notesTrailer(body = '') {
|
|
687
|
+
const text = /^Notes:[ \t]*(\S.*)$/im.exec(body)?.[1]?.trim() ?? null
|
|
688
|
+
const excluded = !!text && /^no-notes$/i.test(text)
|
|
689
|
+
return { text: excluded ? null : text, excluded }
|
|
690
|
+
}
|
|
691
|
+
|
|
660
692
|
/**
|
|
661
693
|
* Parse a commit into the parts a release cares about.
|
|
662
694
|
*
|
|
663
695
|
* @returns {{type: string, scope: string|null, breaking: boolean, subject: string,
|
|
664
|
-
* releaseAs: string|null} | null} null when the
|
|
696
|
+
* releaseAs: string|null, notes: string|null, noNotes: boolean} | null} null when the
|
|
697
|
+
* subject is not Conventional Commits
|
|
665
698
|
*/
|
|
666
699
|
function parseCommit(subject, body = '', hash = '') {
|
|
667
700
|
// Conventional Commits does not restrict the type to letters — `i18n:` and `a11y:` are
|
|
@@ -680,6 +713,7 @@ function parseCommit(subject, body = '', hash = '') {
|
|
|
680
713
|
// A BREAKING CHANGE footer usually explains the break far better than the subject does.
|
|
681
714
|
const breakingNote =
|
|
682
715
|
/^BREAKING[ -]CHANGE:\s*([\s\S]+?)(?=\n\n|$)/m.exec(body)?.[1]?.trim() ?? null
|
|
716
|
+
const trailer = notesTrailer(body)
|
|
683
717
|
return {
|
|
684
718
|
type: type.toLowerCase(),
|
|
685
719
|
scope: scope ?? null,
|
|
@@ -689,6 +723,8 @@ function parseCommit(subject, body = '', hash = '') {
|
|
|
689
723
|
releaseAs,
|
|
690
724
|
closes: [...new Set(closes)],
|
|
691
725
|
breakingNote,
|
|
726
|
+
notes: trailer.text,
|
|
727
|
+
noNotes: trailer.excluded,
|
|
692
728
|
}
|
|
693
729
|
}
|
|
694
730
|
|
|
@@ -727,7 +763,9 @@ function inferBump(commits, currentVersion, strategy = 'conventional') {
|
|
|
727
763
|
* @returns {string | null} markdown body, or null when nothing visible changed
|
|
728
764
|
*/
|
|
729
765
|
function changelogFromCommits(commits, links = null, hidden = [], contributors = []) {
|
|
730
|
-
const parsed = commits
|
|
766
|
+
const parsed = commits
|
|
767
|
+
.map((c) => parseCommit(c.subject, c.body, c.hash))
|
|
768
|
+
.filter((c) => c && !c.noNotes)
|
|
731
769
|
const lines = []
|
|
732
770
|
|
|
733
771
|
/** One bullet: scope, text, a link to the commit, and any issues it closes. */
|
|
@@ -745,8 +783,9 @@ function changelogFromCommits(commits, links = null, hidden = [], contributors =
|
|
|
745
783
|
const breaking = parsed.filter((c) => c.breaking)
|
|
746
784
|
if (breaking.length) {
|
|
747
785
|
lines.push('### ⚠ BREAKING CHANGES', '')
|
|
748
|
-
// The footer explains the break; the subject only says what changed.
|
|
749
|
-
|
|
786
|
+
// The footer explains the break; the subject only says what changed. A `Notes:`
|
|
787
|
+
// trailer beats both: it is the author's wording written for exactly this list.
|
|
788
|
+
for (const c of breaking) lines.push(bullet(c, c.notes ?? c.breakingNote ?? c.subject))
|
|
750
789
|
lines.push('')
|
|
751
790
|
}
|
|
752
791
|
|
|
@@ -768,7 +807,7 @@ function changelogFromCommits(commits, links = null, hidden = [], contributors =
|
|
|
768
807
|
)
|
|
769
808
|
if (!inSection.length) continue
|
|
770
809
|
lines.push(`### ${section}`, '')
|
|
771
|
-
for (const c of inSection) lines.push(bullet(c, c.subject))
|
|
810
|
+
for (const c of inSection) lines.push(bullet(c, c.notes ?? c.subject))
|
|
772
811
|
lines.push('')
|
|
773
812
|
}
|
|
774
813
|
|
|
@@ -780,7 +819,7 @@ function changelogFromCommits(commits, links = null, hidden = [], contributors =
|
|
|
780
819
|
)
|
|
781
820
|
if (other.length) {
|
|
782
821
|
lines.push('### Other Changes', '')
|
|
783
|
-
for (const c of other) lines.push(bullet(c, c.subject))
|
|
822
|
+
for (const c of other) lines.push(bullet(c, c.notes ?? c.subject))
|
|
784
823
|
lines.push('')
|
|
785
824
|
}
|
|
786
825
|
|
|
@@ -1066,13 +1105,41 @@ function linkCitedCommits(notes, commits, links) {
|
|
|
1066
1105
|
})
|
|
1067
1106
|
}
|
|
1068
1107
|
|
|
1108
|
+
/**
|
|
1109
|
+
* The marker a drafting model puts on an entry it is not sure of — whether the change is
|
|
1110
|
+
* user-facing, or what it means for someone upgrading. The alternative is a confident guess
|
|
1111
|
+
* published as fact; a flagged entry is a question for the human cutting the release.
|
|
1112
|
+
*/
|
|
1113
|
+
const UNSURE = '[???]'
|
|
1114
|
+
|
|
1115
|
+
/**
|
|
1116
|
+
* Drafted entries the model flagged as unsure, as the lines it wrote them on. Drafts are
|
|
1117
|
+
* validated, not trusted: like an invented commit hash, a flagged entry must not reach a
|
|
1118
|
+
* tag, a changelog or a release page without someone having looked at it.
|
|
1119
|
+
*
|
|
1120
|
+
* @returns {string[]}
|
|
1121
|
+
*/
|
|
1122
|
+
function uncertainEntries(notes) {
|
|
1123
|
+
return (notes ?? '')
|
|
1124
|
+
.split('\n')
|
|
1125
|
+
.map((line) => line.trim())
|
|
1126
|
+
.filter((line) => line.includes(UNSURE))
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
/** The notes with the marker removed, once a human has reviewed the flagged entries. */
|
|
1130
|
+
const withoutUnsureMarkers = (notes) => notes.replaceAll(`${UNSURE} `, '').replaceAll(UNSURE, '')
|
|
1131
|
+
|
|
1069
1132
|
/**
|
|
1070
1133
|
* Draft release notes from the commit log.
|
|
1071
1134
|
*
|
|
1072
1135
|
* @returns {string | null} markdown body (no version heading), or null
|
|
1073
1136
|
*/
|
|
1074
1137
|
function draftReleaseNotes(version, commits, lastTag, links) {
|
|
1075
|
-
|
|
1138
|
+
// `Notes: no-notes` is the author saying this commit is not news; the model never sees it.
|
|
1139
|
+
const listed = commits
|
|
1140
|
+
.map((c) => ({ ...c, trailer: notesTrailer(c.body) }))
|
|
1141
|
+
.filter((c) => !c.trailer.excluded)
|
|
1142
|
+
if (!listed.length) return null
|
|
1076
1143
|
|
|
1077
1144
|
const prompt = [
|
|
1078
1145
|
`Write release notes for version ${version}.`,
|
|
@@ -1084,20 +1151,31 @@ function draftReleaseNotes(version, commits, lastTag, links) {
|
|
|
1084
1151
|
'- End every bullet with the short hashes it covers, in parentheses: `(abc1234)` or',
|
|
1085
1152
|
' `(abc1234, def5678)` when merged. Copy them exactly from the list below and invent',
|
|
1086
1153
|
' nothing — a hash that is not in the list will be removed.',
|
|
1087
|
-
'- Omit internal chores: CI, linting, formatting,
|
|
1154
|
+
'- Omit internal chores: CI, linting, formatting, version bumps and dependency bumps —',
|
|
1155
|
+
' except a component that ships inside the product (a bundled runtime, a sidecar',
|
|
1156
|
+
' binary, an embedded engine or database). Users run that code, so its update is a',
|
|
1157
|
+
' user-visible change: name the component and the version it moved to.',
|
|
1158
|
+
'- For bug fixes, describe what works now, not what was broken.',
|
|
1159
|
+
'- If you cannot tell whether a change is user-facing, or what it means for someone',
|
|
1160
|
+
` upgrading, start that bullet with ${UNSURE} — a person will resolve it. Do not guess.`,
|
|
1161
|
+
"- A commit with a `Notes:` line carries its author's wording for the entry: use that",
|
|
1162
|
+
' text as written, changing it only to fit the heading or to merge it with related work.',
|
|
1088
1163
|
'- Write for someone upgrading: say what changed for them, not which files moved.',
|
|
1089
1164
|
'- Plain, factual language. No hype, no emoji, no concluding summary.',
|
|
1090
1165
|
'- Output only the markdown body: no version heading, no code fences, no attribution.',
|
|
1091
1166
|
'- Do NOT explain your reasoning or add any commentary before or after the notes.',
|
|
1092
1167
|
'',
|
|
1093
1168
|
`Commits since ${lastTag ?? 'the start of the project'}:`,
|
|
1094
|
-
...
|
|
1169
|
+
...listed.flatMap((c) => [
|
|
1170
|
+
`- ${(c.hash ?? '').slice(0, 7)} ${c.subject}`,
|
|
1171
|
+
...(c.trailer.text ? [` Notes: ${c.trailer.text}`] : []),
|
|
1172
|
+
]),
|
|
1095
1173
|
].join('\n')
|
|
1096
1174
|
|
|
1097
1175
|
const drafted = runAssistant(prompt)
|
|
1098
1176
|
if (!drafted) return null
|
|
1099
1177
|
const cleaned = cleanNotes(drafted)
|
|
1100
|
-
return cleaned ? linkCitedCommits(cleaned,
|
|
1178
|
+
return cleaned ? linkCitedCommits(cleaned, listed, links) : null
|
|
1101
1179
|
}
|
|
1102
1180
|
|
|
1103
1181
|
/** One-line rendering of an argv, so a multi-line arg (release notes) stays readable. */
|
|
@@ -1213,7 +1291,9 @@ function pushBranchAndTag(branchRef) {
|
|
|
1213
1291
|
} catch (err) {
|
|
1214
1292
|
const stderr = `${err.stderr ?? ''}`
|
|
1215
1293
|
process.stderr.write(stderr)
|
|
1216
|
-
|
|
1294
|
+
// Only the capability refusal falls back. git's rejection of a ref also names atomic
|
|
1295
|
+
// ("atomic push failed"), and that one must stop here.
|
|
1296
|
+
if (!/does not support --atomic/i.test(stderr)) abortMidRelease(line)
|
|
1217
1297
|
}
|
|
1218
1298
|
warn(
|
|
1219
1299
|
`${config.remote} does not support atomic pushes — sending the branch and tag in one ` +
|
|
@@ -1396,9 +1476,39 @@ function changelogOutOfOrder(text) {
|
|
|
1396
1476
|
return versions.filter((v, i) => i > 0 && compareVersions(versions[i - 1], v) < 0)
|
|
1397
1477
|
}
|
|
1398
1478
|
|
|
1479
|
+
/**
|
|
1480
|
+
* The release candidates of a stable version that the changelog has sections for: for
|
|
1481
|
+
* `2.0.0`, the `2.0.0-rc.1` and `2.0.0-beta.3` headings.
|
|
1482
|
+
*
|
|
1483
|
+
* A stable release reads history from the last stable tag and generates its notes from
|
|
1484
|
+
* those commits, so wording someone edited into a candidate's section does not carry over.
|
|
1485
|
+
* The sections stay in the file; this is how the release notices they exist.
|
|
1486
|
+
*
|
|
1487
|
+
* @returns {string[]} the candidate versions, in file order
|
|
1488
|
+
*/
|
|
1489
|
+
function candidateSections(text, version) {
|
|
1490
|
+
const base = parseVersion(version)
|
|
1491
|
+
if (!base || base.pre.length) return []
|
|
1492
|
+
return [...text.matchAll(/^## \[?v?(\d+\.\d+\.\d+-[\w.]+)\]?/gm)]
|
|
1493
|
+
.map((m) => m[1])
|
|
1494
|
+
.filter((v) => {
|
|
1495
|
+
const parsed = parseVersion(v)
|
|
1496
|
+
return (
|
|
1497
|
+
parsed &&
|
|
1498
|
+
parsed.major === base.major &&
|
|
1499
|
+
parsed.minor === base.minor &&
|
|
1500
|
+
parsed.patch === base.patch
|
|
1501
|
+
)
|
|
1502
|
+
})
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1399
1505
|
/** The `## ` heading offsets in a changelog, in file order. */
|
|
1400
1506
|
const sectionOffsets = (text) => [...text.matchAll(/^## .*$/gm)].map((m) => m.index)
|
|
1401
1507
|
|
|
1508
|
+
/** True when the changelog already has a `## [version]` heading, empty body or not. */
|
|
1509
|
+
const hasVersionHeading = (text, version) =>
|
|
1510
|
+
new RegExp(`^##\\s+\\[?v?${escapeRe(version)}\\]?(?![\\w.-])`, 'm').test(text)
|
|
1511
|
+
|
|
1402
1512
|
/**
|
|
1403
1513
|
* Place a version's section where it belongs: above the first section whose version is
|
|
1404
1514
|
* lower, rather than wherever the file happens to start.
|
|
@@ -1425,9 +1535,13 @@ function insertChangelogSection(text, version, date, body) {
|
|
|
1425
1535
|
}
|
|
1426
1536
|
// Falling out of the loop means every version section is newer, so the release belongs
|
|
1427
1537
|
// at the foot. With nothing to compare against it belongs at the head instead: appending
|
|
1428
|
-
// to a date-headed changelog would file the release below its oldest entry.
|
|
1429
|
-
|
|
1430
|
-
|
|
1538
|
+
// to a date-headed changelog would file the release below its oldest entry. The head is
|
|
1539
|
+
// below [Unreleased], though, which stays the first section — a first release drafted into
|
|
1540
|
+
// a changelog holding only an empty [Unreleased] used to land above it.
|
|
1541
|
+
if (!comparable) {
|
|
1542
|
+
const unreleased = /^## \[?Unreleased\]?/i
|
|
1543
|
+
const first = offsets.find((offset) => !unreleased.test(text.slice(offset)))
|
|
1544
|
+
if (first !== undefined) return `${text.slice(0, first)}${entry}\n${text.slice(first)}`
|
|
1431
1545
|
}
|
|
1432
1546
|
const trimmed = text.trimEnd()
|
|
1433
1547
|
return `${trimmed}\n\n${entry}`
|
|
@@ -1503,7 +1617,7 @@ function withChangelogLinks(text, links, tagPrefix = '') {
|
|
|
1503
1617
|
function rollUnreleased(text, version, date) {
|
|
1504
1618
|
// A heading for this version already exists — possibly with an empty body, which
|
|
1505
1619
|
// `changelogSection` reports as absent. Rolling again would duplicate the heading.
|
|
1506
|
-
if (
|
|
1620
|
+
if (hasVersionHeading(text, version)) return null
|
|
1507
1621
|
|
|
1508
1622
|
const heading = /^##\s+\[?Unreleased\]?[^\n]*$/im
|
|
1509
1623
|
const match = heading.exec(text)
|
|
@@ -1611,6 +1725,53 @@ function expandPaths(pattern) {
|
|
|
1611
1725
|
return current
|
|
1612
1726
|
}
|
|
1613
1727
|
|
|
1728
|
+
/**
|
|
1729
|
+
* crates.io's default ceiling on an uploaded `.crate`, in bytes: `10 * 1024 * 1024` in
|
|
1730
|
+
* crates.io's src/config/publish_limits.rs. The registry can raise it for one crate on
|
|
1731
|
+
* request, but a crate over it is otherwise refused at upload — after the tag and the push.
|
|
1732
|
+
*/
|
|
1733
|
+
const CRATES_IO_MAX_BYTES = 10 * 1024 * 1024
|
|
1734
|
+
|
|
1735
|
+
/**
|
|
1736
|
+
* The `cargo package` argv that builds exactly what a `cargo publish` command would upload,
|
|
1737
|
+
* so preflight can find a missing file or an oversized archive before anything is tagged.
|
|
1738
|
+
*
|
|
1739
|
+
* Deriving it from the publish command rather than guessing keeps the selection the same:
|
|
1740
|
+
* `-p`, `--workspace`, `--manifest-path`, `--features` and `--no-verify` mean the same thing
|
|
1741
|
+
* to both subcommands, so a workspace packages each crate it will publish. Only the flags
|
|
1742
|
+
* `cargo package` does not take are dropped (`--dry-run`, `--token`).
|
|
1743
|
+
*
|
|
1744
|
+
* `--locked` is added when the repository has a `Cargo.lock`: publishing verifies against
|
|
1745
|
+
* it, and a stale one is a failure better found now. Without a lockfile it would refuse
|
|
1746
|
+
* outright, and plenty of libraries do not commit one. `--allow-dirty` is added only when
|
|
1747
|
+
* the tree is dirty — preflight reports that on its own, or the commit step is about to
|
|
1748
|
+
* make it clean — so the package check still says something useful.
|
|
1749
|
+
*
|
|
1750
|
+
* A command that is not a plain `cargo publish …` (a pipeline, quoting, an env prefix) is
|
|
1751
|
+
* not taken apart: there is no reliable way to know what it uploads.
|
|
1752
|
+
*
|
|
1753
|
+
* @returns {string[] | null} args for `cargo`, or null when it cannot be derived
|
|
1754
|
+
*/
|
|
1755
|
+
function cargoPackageArgs(command, { dirty = false, lockfile = false } = {}) {
|
|
1756
|
+
if (!/^cargo\s+publish(?:\s|$)/.test(command.trim()) || /[;&|<>`$()'"\\]/.test(command)) {
|
|
1757
|
+
return null
|
|
1758
|
+
}
|
|
1759
|
+
const words = command.trim().split(/\s+/).slice(2)
|
|
1760
|
+
const args = ['package']
|
|
1761
|
+
for (let i = 0; i < words.length; i += 1) {
|
|
1762
|
+
const word = words[i]
|
|
1763
|
+
if (word === '--dry-run' || word === '-n' || word.startsWith('--token=')) continue
|
|
1764
|
+
if (word === '--token') {
|
|
1765
|
+
i += 1
|
|
1766
|
+
continue
|
|
1767
|
+
}
|
|
1768
|
+
args.push(word)
|
|
1769
|
+
}
|
|
1770
|
+
if (lockfile && !args.includes('--locked')) args.push('--locked')
|
|
1771
|
+
if (dirty && !args.includes('--allow-dirty')) args.push('--allow-dirty')
|
|
1772
|
+
return args
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1614
1775
|
/**
|
|
1615
1776
|
* The crates in a Cargo workspace whose version this bump owns: the members that inherit
|
|
1616
1777
|
* it with `version.workspace = true`, which is how a workspace keeps its crates in step.
|
|
@@ -2042,7 +2203,16 @@ const VALUE_OPTIONS = new Set([
|
|
|
2042
2203
|
])
|
|
2043
2204
|
|
|
2044
2205
|
/** Flags that take no value. With VALUE_OPTIONS, the whole vocabulary this file accepts. */
|
|
2045
|
-
const BOOLEAN_FLAGS = new Set([
|
|
2206
|
+
const BOOLEAN_FLAGS = new Set([
|
|
2207
|
+
'--dry-run',
|
|
2208
|
+
'--yes',
|
|
2209
|
+
'-y',
|
|
2210
|
+
'--commit',
|
|
2211
|
+
'--package',
|
|
2212
|
+
'--help',
|
|
2213
|
+
'-h',
|
|
2214
|
+
'--sync',
|
|
2215
|
+
])
|
|
2046
2216
|
|
|
2047
2217
|
/** The version or bump target: the only argument that is neither a flag nor a flag's value. */
|
|
2048
2218
|
const positionals = []
|
|
@@ -2077,6 +2247,14 @@ if (positionals.length > 1) {
|
|
|
2077
2247
|
: ''
|
|
2078
2248
|
abort(`unexpected argument${extras.length > 1 ? 's' : ''}: ${extras.join(' ')}${hint}`)
|
|
2079
2249
|
}
|
|
2250
|
+
// Only a pre* bump reads --preid. Anywhere else it was accepted and dropped, so
|
|
2251
|
+
// `auto --preid beta` released a stable version to the `latest` dist-tag.
|
|
2252
|
+
if (requestedPreid !== undefined && !target?.startsWith('pre')) {
|
|
2253
|
+
abort(
|
|
2254
|
+
`--preid only applies to prepatch, preminor, premajor and prerelease, not ${target ? `"${target}"` : 'a release with no target'}.\n` +
|
|
2255
|
+
` For a ${requestedPreid} prerelease: release-kit prerelease --preid ${requestedPreid}`,
|
|
2256
|
+
)
|
|
2257
|
+
}
|
|
2080
2258
|
|
|
2081
2259
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
2082
2260
|
// SETUP
|
|
@@ -2085,26 +2263,89 @@ if (positionals.length > 1) {
|
|
|
2085
2263
|
const root = tryRead('git', ['rev-parse', '--show-toplevel'])
|
|
2086
2264
|
if (!root) abort('not inside a git repository')
|
|
2087
2265
|
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2266
|
+
/**
|
|
2267
|
+
* A release is scoped to the repository: the version, the tag and the push all belong to
|
|
2268
|
+
* one git history, so the package released is the one at the git root. `--package` scopes
|
|
2269
|
+
* it to the working directory instead — one package among several in the repository, as
|
|
2270
|
+
* release-train runs it. Its config, manifest, changelog, verify and publish are that
|
|
2271
|
+
* directory's; the commits it reads and the working tree it checks are limited to the
|
|
2272
|
+
* directory; and it tags `<name>@<version>` unless its config names a `tagPrefix`, so
|
|
2273
|
+
* packages sharing a history keep separate tags.
|
|
2274
|
+
*/
|
|
2275
|
+
const packageMode = flag('--package')
|
|
2276
|
+
|
|
2277
|
+
// Without --package, refuse a nested package rather than silently releasing the parent.
|
|
2091
2278
|
const localManifest = resolve('package.json')
|
|
2092
2279
|
const rootManifest = join(root, 'package.json')
|
|
2093
|
-
if (existsSync(localManifest) && localManifest !== rootManifest) {
|
|
2280
|
+
if (!packageMode && existsSync(localManifest) && localManifest !== rootManifest) {
|
|
2094
2281
|
abort(
|
|
2095
2282
|
`${relative(root, localManifest)} is a nested package, but a release covers the whole ` +
|
|
2096
2283
|
`repository.\n\n Running here would release ${
|
|
2097
2284
|
existsSync(rootManifest) ? readJson(rootManifest).name : 'the repository root'
|
|
2098
2285
|
} instead.\n` +
|
|
2099
|
-
' release
|
|
2286
|
+
' To release this package on its own — its own tag, commits, changelog and publish —\n' +
|
|
2287
|
+
' run with --package (release-train does, for a repository holding several).',
|
|
2100
2288
|
)
|
|
2101
2289
|
}
|
|
2102
|
-
process.chdir(root)
|
|
2290
|
+
if (!packageMode) process.chdir(root)
|
|
2291
|
+
|
|
2292
|
+
/** The pathspec a package release limits `git log`, `git status` and `git add` to. */
|
|
2293
|
+
const SCOPE = packageMode ? ['--', '.'] : []
|
|
2103
2294
|
|
|
2104
2295
|
const userConfig = readUserConfig()
|
|
2105
2296
|
const config = { ...DEFAULTS, ...userConfig }
|
|
2106
2297
|
const unknownKeys = Object.keys(config).filter((key) => !(key in DEFAULTS))
|
|
2107
2298
|
if (unknownKeys.length) abort(`release.config.json has unknown keys: ${unknownKeys.join(', ')}`)
|
|
2299
|
+
|
|
2300
|
+
/**
|
|
2301
|
+
* What each key has to hold. A known key with the wrong shape was accepted and then read as
|
|
2302
|
+
* if it were right: `"steps": "tag,push"` ran no step at all and still reported a release,
|
|
2303
|
+
* `"tagPrefix": null` tagged `null1.1.0`, and a string where an array belongs crashed with a
|
|
2304
|
+
* TypeError after the tag was pushed.
|
|
2305
|
+
*/
|
|
2306
|
+
const isString = (value) => typeof value === 'string'
|
|
2307
|
+
const isStringOrNull = (value) => value === null || isString(value)
|
|
2308
|
+
const isStringArray = (value) => Array.isArray(value) && value.every(isString)
|
|
2309
|
+
const isObject = (value) => !!value && typeof value === 'object' && !Array.isArray(value)
|
|
2310
|
+
const CONFIG_SHAPES = {
|
|
2311
|
+
steps: [isStringArray, 'an array of step names'],
|
|
2312
|
+
tagPrefix: [isString, 'a string'],
|
|
2313
|
+
branch: [isStringOrNull, 'a string, or null'],
|
|
2314
|
+
remote: [isString, 'a string'],
|
|
2315
|
+
changelog: [isStringOrNull, 'a string, or null'],
|
|
2316
|
+
versionFile: [
|
|
2317
|
+
(v) => v === undefined || isStringOrNull(v) || isObject(v),
|
|
2318
|
+
'a path, an object, or null',
|
|
2319
|
+
],
|
|
2320
|
+
versionFiles: [
|
|
2321
|
+
(v) => Array.isArray(v) && v.every((f) => isString(f) || (isObject(f) && isString(f.path))),
|
|
2322
|
+
'an array of paths or { path, pattern } objects',
|
|
2323
|
+
],
|
|
2324
|
+
publish: [
|
|
2325
|
+
(v) => v === undefined || isStringOrNull(v) || isStringArray(v),
|
|
2326
|
+
'a command, an array of commands, or null',
|
|
2327
|
+
],
|
|
2328
|
+
commitMessage: [isString, 'a string'],
|
|
2329
|
+
releaseTitle: [isString, 'a string'],
|
|
2330
|
+
assets: [isStringArray, 'an array of paths'],
|
|
2331
|
+
assistant: [(v) => isStringOrNull(v) || isObject(v), 'a name, an object, or null'],
|
|
2332
|
+
notesFile: [isStringOrNull, 'a path, or null'],
|
|
2333
|
+
versioning: [
|
|
2334
|
+
(v) => ['conventional', 'always-patch', 'always-minor', 'always-major'].includes(v),
|
|
2335
|
+
'one of conventional, always-patch, always-minor, always-major',
|
|
2336
|
+
],
|
|
2337
|
+
notes: [isString, 'a string'],
|
|
2338
|
+
hiddenTypes: [isStringArray, 'an array of commit types'],
|
|
2339
|
+
ignoreCommits: [isStringArray, 'an array of regexes'],
|
|
2340
|
+
verify: [isStringOrNull, 'a command, or null'],
|
|
2341
|
+
requireGreen: [(v) => typeof v === 'boolean', 'true or false'],
|
|
2342
|
+
hooks: [(v) => isObject(v) && Object.values(v).every(isString), 'an object of command strings'],
|
|
2343
|
+
}
|
|
2344
|
+
const misshapen = Object.entries(CONFIG_SHAPES)
|
|
2345
|
+
.filter(([key, [valid]]) => !valid(config[key]))
|
|
2346
|
+
.map(([key, [, expected]]) => ` ${key} must be ${expected}, not ${JSON.stringify(config[key])}`)
|
|
2347
|
+
if (misshapen.length)
|
|
2348
|
+
abort(`release.config.json has keys of the wrong type:\n${misshapen.join('\n')}`)
|
|
2108
2349
|
// A misspelled hook name is a hook that silently never runs, which is the failure mode
|
|
2109
2350
|
// this file refuses everywhere else it takes a name.
|
|
2110
2351
|
const unknownHooks = Object.keys(config.hooks ?? {}).filter((key) => !HOOKS.includes(key))
|
|
@@ -2166,6 +2407,10 @@ const ECOSYSTEM_MANIFESTS = ['package.json', 'pyproject.toml', 'Cargo.toml']
|
|
|
2166
2407
|
*/
|
|
2167
2408
|
function detectCompanionFiles(primaryPath, primaryVersion) {
|
|
2168
2409
|
const companions = []
|
|
2410
|
+
// The lockfile pins the crate's own version, so a bump leaves it stale — whether
|
|
2411
|
+
// Cargo.toml is the version source or a manifest kept in step with one. A plain crate was
|
|
2412
|
+
// the case missed: `cargo publish` then refused the dirty lockfile, after the push.
|
|
2413
|
+
if (primaryPath === 'Cargo.toml' && existsSync('Cargo.lock')) companions.push('Cargo.lock')
|
|
2169
2414
|
for (const candidate of ECOSYSTEM_MANIFESTS) {
|
|
2170
2415
|
if (candidate === primaryPath || !existsSync(candidate)) continue
|
|
2171
2416
|
const found = readVersionFrom({ path: candidate })
|
|
@@ -2281,6 +2526,19 @@ function versionFromLastTag() {
|
|
|
2281
2526
|
return releaseTags()[0]?.version ?? null
|
|
2282
2527
|
}
|
|
2283
2528
|
|
|
2529
|
+
// A package's tags are `<name>@<version>`, the scheme release-train reads, unless its
|
|
2530
|
+
// config names a prefix. Set before anything reads a tag.
|
|
2531
|
+
if (packageMode && !Object.hasOwn(userConfig, 'tagPrefix')) {
|
|
2532
|
+
const name = manifest?.name ?? (versionFile ? readNameFrom(versionFile) : null)
|
|
2533
|
+
if (!name) {
|
|
2534
|
+
abort(
|
|
2535
|
+
'--package tags a release <name>@<version>, and no package name was found here.\n' +
|
|
2536
|
+
' Set "tagPrefix" in this package\'s release.config.json.',
|
|
2537
|
+
)
|
|
2538
|
+
}
|
|
2539
|
+
config.tagPrefix = `${name}@`
|
|
2540
|
+
}
|
|
2541
|
+
|
|
2284
2542
|
const currentVersion = versionFile ? readVersionFrom(versionFile) : versionFromLastTag()
|
|
2285
2543
|
if (versionFile && !currentVersion) {
|
|
2286
2544
|
abort(`could not read a version from ${versionFile.path}`)
|
|
@@ -2294,7 +2552,10 @@ const goModule = existsSync('go.mod')
|
|
|
2294
2552
|
? (/^module\s+(\S+)/m.exec(readFileSync('go.mod', 'utf8'))?.[1] ?? null)
|
|
2295
2553
|
: null
|
|
2296
2554
|
const projectName =
|
|
2297
|
-
manifest?.name ??
|
|
2555
|
+
manifest?.name ??
|
|
2556
|
+
(versionFile ? readNameFrom(versionFile) : null) ??
|
|
2557
|
+
goModule ??
|
|
2558
|
+
basename(process.cwd())
|
|
2298
2559
|
|
|
2299
2560
|
/**
|
|
2300
2561
|
* Manifests found beside the primary one that carry the same version. Detected only when
|
|
@@ -2567,9 +2828,35 @@ function versionShipped(v) {
|
|
|
2567
2828
|
*/
|
|
2568
2829
|
function unfinishedRelease() {
|
|
2569
2830
|
const [newest] = releaseTags()
|
|
2570
|
-
if (!newest
|
|
2831
|
+
if (!newest) return null
|
|
2571
2832
|
const at = tryRead('git', ['rev-list', '-n', '1', newest.name])
|
|
2572
|
-
|
|
2833
|
+
if (!at || at !== tryRead('git', ['rev-parse', 'HEAD'])) return null
|
|
2834
|
+
const shipped = versionShipped(newest.version)
|
|
2835
|
+
if (shipped === false) return { ...newest, missing: 'the registry' }
|
|
2836
|
+
// With no registry to ask — `publish: null`, or one that could not answer — the push is
|
|
2837
|
+
// the last step that leaves a mark to check. A tag at HEAD the remote does not have is a
|
|
2838
|
+
// release that died before or during the push.
|
|
2839
|
+
if (shipped === null && runs('push') && tagOnRemote(newest.name) === false) {
|
|
2840
|
+
return { ...newest, missing: config.remote }
|
|
2841
|
+
}
|
|
2842
|
+
return null
|
|
2843
|
+
}
|
|
2844
|
+
|
|
2845
|
+
/**
|
|
2846
|
+
* Whether the remote has this tag: false only when it answered without it, null when it
|
|
2847
|
+
* could not be asked.
|
|
2848
|
+
*
|
|
2849
|
+
* @param {string} name
|
|
2850
|
+
* @returns {boolean | null}
|
|
2851
|
+
*/
|
|
2852
|
+
function tagOnRemote(name) {
|
|
2853
|
+
try {
|
|
2854
|
+
read('git', ['ls-remote', '--exit-code', '--tags', config.remote, `refs/tags/${name}`])
|
|
2855
|
+
return true
|
|
2856
|
+
} catch (err) {
|
|
2857
|
+
// --exit-code exits 2 for "no matching refs"; anything else is the remote not answering.
|
|
2858
|
+
return err.status === 2 ? false : null
|
|
2859
|
+
}
|
|
2573
2860
|
}
|
|
2574
2861
|
|
|
2575
2862
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -2586,6 +2873,8 @@ let autoBump = null
|
|
|
2586
2873
|
|
|
2587
2874
|
/** The tag of a previous release this run is finishing rather than starting. */
|
|
2588
2875
|
let resuming = null
|
|
2876
|
+
/** What that release never reached: the registry, or the remote. */
|
|
2877
|
+
let resumingMissing = null
|
|
2589
2878
|
|
|
2590
2879
|
/**
|
|
2591
2880
|
* A release tagged at HEAD that never reached the registry, found while resolving a
|
|
@@ -2602,7 +2891,7 @@ let unfinishedAtHead = null
|
|
|
2602
2891
|
* ship a tree the tag does not describe; that work belongs in the next version, which is
|
|
2603
2892
|
* what the shipped-tag baseline makes sure it is released as.
|
|
2604
2893
|
*/
|
|
2605
|
-
const wouldCommitMore = !!tryRead('git', ['status', '--porcelain']) && runs('commit')
|
|
2894
|
+
const wouldCommitMore = !!tryRead('git', ['status', '--porcelain', ...SCOPE]) && runs('commit')
|
|
2606
2895
|
|
|
2607
2896
|
let version
|
|
2608
2897
|
if (!target) {
|
|
@@ -2622,7 +2911,7 @@ if (!target) {
|
|
|
2622
2911
|
}
|
|
2623
2912
|
const pending = wouldCommitMore ? null : unfinishedRelease()
|
|
2624
2913
|
if (pending) {
|
|
2625
|
-
;({ name: resuming, version } = pending)
|
|
2914
|
+
;({ name: resuming, version, missing: resumingMissing } = pending)
|
|
2626
2915
|
} else {
|
|
2627
2916
|
const { commits, lastTag } = commitsSinceLastTag({ shipped: true })
|
|
2628
2917
|
if (!commits.length) {
|
|
@@ -2731,12 +3020,15 @@ function runHook(name) {
|
|
|
2731
3020
|
*/
|
|
2732
3021
|
function dirtyPaths() {
|
|
2733
3022
|
return (
|
|
2734
|
-
(tryRead('git', ['status', '--porcelain']) ?? '')
|
|
3023
|
+
(tryRead('git', ['status', '--porcelain', ...SCOPE]) ?? '')
|
|
2735
3024
|
.split('\n')
|
|
2736
3025
|
.map((line) => /^\s*\S{1,2}\s+(.+)$/.exec(line)?.[1]?.trim())
|
|
2737
3026
|
.filter(Boolean)
|
|
2738
3027
|
// A rename reads as "old -> new"; the new path is the one to stage.
|
|
2739
3028
|
.map((path) => path.split(' -> ').at(-1))
|
|
3029
|
+
// Porcelain paths are relative to the repository root; everything they are compared
|
|
3030
|
+
// with and staged as is relative to the working directory, which --package moves.
|
|
3031
|
+
.map((path) => relative(process.cwd(), join(root, path)))
|
|
2740
3032
|
)
|
|
2741
3033
|
}
|
|
2742
3034
|
|
|
@@ -2796,12 +3088,12 @@ if (autoBump) {
|
|
|
2796
3088
|
}
|
|
2797
3089
|
|
|
2798
3090
|
if (resuming) {
|
|
2799
|
-
ok(`finishing ${resuming}: it was tagged
|
|
3091
|
+
ok(`finishing ${resuming}: it was tagged, but never reached ${resumingMissing}`)
|
|
2800
3092
|
}
|
|
2801
3093
|
|
|
2802
3094
|
if (unfinishedAtHead) {
|
|
2803
3095
|
fail(
|
|
2804
|
-
`${unfinishedAtHead.name} is tagged at HEAD but never reached
|
|
3096
|
+
`${unfinishedAtHead.name} is tagged at HEAD but never reached ${unfinishedAtHead.missing}, and a ` +
|
|
2805
3097
|
`${target} bump would release ${version} from the same commit and leave it that way.\n` +
|
|
2806
3098
|
` Finish it instead: re-run with no target, or with auto.`,
|
|
2807
3099
|
)
|
|
@@ -2898,6 +3190,31 @@ if (bumping) {
|
|
|
2898
3190
|
}
|
|
2899
3191
|
}
|
|
2900
3192
|
|
|
3193
|
+
// A configured list is never extended, so a Cargo.toml written without the Cargo.lock beside
|
|
3194
|
+
// it leaves the lockfile on the old version. Publishing is where that breaks: `cargo publish`
|
|
3195
|
+
// refuses a dirty tree, after the tag and the push. Say so before either.
|
|
3196
|
+
if (bumping && publishTargets.some((target) => target.cli === 'cargo')) {
|
|
3197
|
+
const written = new Set(versionTargets.map((source) => source.path))
|
|
3198
|
+
for (const source of versionTargets) {
|
|
3199
|
+
if (basename(source.path) !== 'Cargo.toml') continue
|
|
3200
|
+
const lockPath = join(dirname(source.path), 'Cargo.lock')
|
|
3201
|
+
if (written.has(lockPath) || !existsSync(lockPath)) continue
|
|
3202
|
+
let recorded = null
|
|
3203
|
+
try {
|
|
3204
|
+
recorded = readVersionFrom({ path: lockPath })
|
|
3205
|
+
} catch {
|
|
3206
|
+
// A lockfile with nothing beside it to scope by: not this check's question.
|
|
3207
|
+
}
|
|
3208
|
+
if (recorded && recorded !== version) {
|
|
3209
|
+
fail(
|
|
3210
|
+
`${lockPath} records ${readNameFrom(source) ?? 'the crate'} ${recorded}, and nothing ` +
|
|
3211
|
+
`will bump it — \`cargo publish\` would refuse the stale lockfile after the push.\n` +
|
|
3212
|
+
` Add "${lockPath}" to versionFiles in release.config.json.`,
|
|
3213
|
+
)
|
|
3214
|
+
}
|
|
3215
|
+
}
|
|
3216
|
+
}
|
|
3217
|
+
|
|
2901
3218
|
if (bumping && currentVersion && compareVersions(version, currentVersion) <= 0) {
|
|
2902
3219
|
fail(`${version} is not greater than the current version ${currentVersion}`)
|
|
2903
3220
|
} else if (bumping && currentVersion) {
|
|
@@ -2922,7 +3239,7 @@ if (bumping && currentVersion && compareVersions(version, currentVersion) <= 0)
|
|
|
2922
3239
|
ok(`releasing ${version} (no version file; the tag is the version)`)
|
|
2923
3240
|
}
|
|
2924
3241
|
|
|
2925
|
-
const dirty = tryRead('git', ['status', '--porcelain'])
|
|
3242
|
+
const dirty = tryRead('git', ['status', '--porcelain', ...SCOPE])
|
|
2926
3243
|
if (dirty === null) fail('could not read git status')
|
|
2927
3244
|
else if (dirty && runs('commit')) {
|
|
2928
3245
|
const entries = dirty.split('\n')
|
|
@@ -3008,6 +3325,123 @@ if (!succeeds('git', ['remote', 'get-url', config.remote])) {
|
|
|
3008
3325
|
}
|
|
3009
3326
|
}
|
|
3010
3327
|
|
|
3328
|
+
/**
|
|
3329
|
+
* Check run conclusions that mean the commit is not fit to release. `stale` is GitHub giving
|
|
3330
|
+
* up on a run that never finished, which is not a pass either.
|
|
3331
|
+
*/
|
|
3332
|
+
const RED_CONCLUSIONS = new Set(['failure', 'cancelled', 'timed_out', 'action_required', 'stale'])
|
|
3333
|
+
|
|
3334
|
+
/**
|
|
3335
|
+
* Whether GitHub reports the commit being released as green — the gate `requireGreen` opts
|
|
3336
|
+
* into, so a release cannot be cut from a commit CI failed on or has not finished with.
|
|
3337
|
+
*
|
|
3338
|
+
* Both of GitHub's check systems are read. Check runs are what Actions and GitHub Apps
|
|
3339
|
+
* report; commit statuses are the older API that external CI (Buildkite, Jenkins, older
|
|
3340
|
+
* integrations) still posts to. Reading only one would call a commit green while the other
|
|
3341
|
+
* system has it red. The combined status's own `state` is not used: it says `pending` for a
|
|
3342
|
+
* commit with no statuses at all, so the individual statuses are what count.
|
|
3343
|
+
*
|
|
3344
|
+
* Only `success` counts as a pass. `neutral` and `skipped` are not failures, but a commit
|
|
3345
|
+
* whose every check was skipped has not been verified by anything, and zero checks is the
|
|
3346
|
+
* same answer — both are refused rather than read as green.
|
|
3347
|
+
*
|
|
3348
|
+
* When this runs inside GitHub Actions, the job running it is itself an unfinished check run
|
|
3349
|
+
* on the same commit — the `workflow_run` recipe in the README releases exactly the commit
|
|
3350
|
+
* it runs on — and waiting for it would wait forever. Check runs belonging to the current
|
|
3351
|
+
* workflow run are therefore skipped; jobs in the same workflow are what `needs:` orders.
|
|
3352
|
+
*
|
|
3353
|
+
* `{owner}/{repo}` is filled in by gh from the checkout, the same way `gh release create`
|
|
3354
|
+
* resolves the repository the release step publishes to.
|
|
3355
|
+
*/
|
|
3356
|
+
function checkHeadIsGreen(sha) {
|
|
3357
|
+
const api = (path, jq) => tryRead('gh', ['api', '--paginate', path, '--jq', jq])
|
|
3358
|
+
const checkRuns = api(
|
|
3359
|
+
`repos/{owner}/{repo}/commits/${sha}/check-runs?per_page=100`,
|
|
3360
|
+
'.check_runs[] | [.name, .status, (.conclusion // ""), (.details_url // "")] | @tsv',
|
|
3361
|
+
)
|
|
3362
|
+
const statuses = api(
|
|
3363
|
+
`repos/{owner}/{repo}/commits/${sha}/status?per_page=100`,
|
|
3364
|
+
'.statuses[] | [.context, .state] | @tsv',
|
|
3365
|
+
)
|
|
3366
|
+
if (checkRuns === null || statuses === null) {
|
|
3367
|
+
fail(`requireGreen: could not read the checks on ${sha.slice(0, 8)} from GitHub (gh api)`)
|
|
3368
|
+
return
|
|
3369
|
+
}
|
|
3370
|
+
const ownRun =
|
|
3371
|
+
process.env.GITHUB_ACTIONS === 'true' && process.env.GITHUB_RUN_ID
|
|
3372
|
+
? `/actions/runs/${process.env.GITHUB_RUN_ID}/`
|
|
3373
|
+
: null
|
|
3374
|
+
const rows = (text) =>
|
|
3375
|
+
text
|
|
3376
|
+
.split('\n')
|
|
3377
|
+
.filter((line) => line.trim())
|
|
3378
|
+
.map((line) => line.split('\t'))
|
|
3379
|
+
|
|
3380
|
+
const red = []
|
|
3381
|
+
const waiting = []
|
|
3382
|
+
let passed = 0
|
|
3383
|
+
for (const [name, status, conclusion, url = ''] of rows(checkRuns)) {
|
|
3384
|
+
if (ownRun && url.includes(ownRun)) continue
|
|
3385
|
+
if (status !== 'completed') waiting.push(`${name} (${status})`)
|
|
3386
|
+
else if (RED_CONCLUSIONS.has(conclusion)) red.push(`${name} (${conclusion})`)
|
|
3387
|
+
else if (conclusion === 'success') passed += 1
|
|
3388
|
+
}
|
|
3389
|
+
for (const [context, state] of rows(statuses)) {
|
|
3390
|
+
if (state === 'failure' || state === 'error') red.push(`${context} (${state})`)
|
|
3391
|
+
else if (state === 'pending') waiting.push(`${context} (pending)`)
|
|
3392
|
+
else if (state === 'success') passed += 1
|
|
3393
|
+
}
|
|
3394
|
+
|
|
3395
|
+
const at = `HEAD (${sha.slice(0, 8)})`
|
|
3396
|
+
if (red.length) {
|
|
3397
|
+
fail(`requireGreen: ${at} is not green — ${red.join(', ')}`)
|
|
3398
|
+
} else if (waiting.length) {
|
|
3399
|
+
fail(
|
|
3400
|
+
`requireGreen: checks on ${at} have not finished — ${waiting.join(', ')}.\n` +
|
|
3401
|
+
' Wait for them to complete, then re-run.',
|
|
3402
|
+
)
|
|
3403
|
+
} else if (!passed) {
|
|
3404
|
+
fail(
|
|
3405
|
+
`requireGreen: no check has passed on ${at} — nothing has verified it.\n` +
|
|
3406
|
+
' Push it and wait for CI, or turn requireGreen off for a repository without CI.',
|
|
3407
|
+
)
|
|
3408
|
+
} else {
|
|
3409
|
+
ok(`${at} is green (${passed} check${passed === 1 ? '' : 's'} passed)`)
|
|
3410
|
+
}
|
|
3411
|
+
}
|
|
3412
|
+
|
|
3413
|
+
// Opt-in: the commit being released has to be one CI has seen and passed. That means it is
|
|
3414
|
+
// on the remote — a local commit has no checks to read — and that what the commit step is
|
|
3415
|
+
// about to add is not part of the release, since CI never saw that either.
|
|
3416
|
+
if (config.requireGreen) {
|
|
3417
|
+
const upstream = branch && !detached ? `${config.remote}/${branch}` : null
|
|
3418
|
+
const sha = tryRead('git', ['rev-parse', 'HEAD'])
|
|
3419
|
+
const onRemote =
|
|
3420
|
+
upstream && succeeds('git', ['rev-parse', '--verify', '--quiet', `refs/remotes/${upstream}`])
|
|
3421
|
+
const ahead = onRemote ? tryRead('git', ['rev-list', '--count', `${upstream}..HEAD`]) : null
|
|
3422
|
+
if (dirty && runs('commit')) {
|
|
3423
|
+
fail(
|
|
3424
|
+
'requireGreen: the working tree would be committed and released without CI having ' +
|
|
3425
|
+
'run on it.\n Commit and push it, wait for the checks, then release.',
|
|
3426
|
+
)
|
|
3427
|
+
}
|
|
3428
|
+
if (!succeeds('gh', ['--version']) || !succeeds('gh', ['auth', 'status'])) {
|
|
3429
|
+
fail('requireGreen reads the checks with `gh`, which is not installed or not authenticated')
|
|
3430
|
+
} else if (!onRemote) {
|
|
3431
|
+
fail(
|
|
3432
|
+
`requireGreen: ${upstream ?? 'this branch'} does not exist on ${config.remote}, so no ` +
|
|
3433
|
+
'check has run on HEAD. Push it and wait for CI.',
|
|
3434
|
+
)
|
|
3435
|
+
} else if (ahead !== '0') {
|
|
3436
|
+
fail(
|
|
3437
|
+
`requireGreen: HEAD is ${ahead ?? 'an unknown number of'} commit(s) ahead of ` +
|
|
3438
|
+
`${upstream}, so CI has not seen it. Push it and wait for the checks.`,
|
|
3439
|
+
)
|
|
3440
|
+
} else if (sha) {
|
|
3441
|
+
checkHeadIsGreen(sha)
|
|
3442
|
+
}
|
|
3443
|
+
}
|
|
3444
|
+
|
|
3011
3445
|
// If the previous release tag is reachable from HEAD, a shallow clone hides nothing the
|
|
3012
3446
|
// release reads — notes and `auto` see the whole span. No reachable tag means the history
|
|
3013
3447
|
// is provably truncated: `auto` would infer the bump from a fraction of the commits, so
|
|
@@ -3127,10 +3561,32 @@ if (!runs('release')) {
|
|
|
3127
3561
|
*/
|
|
3128
3562
|
const OIDC_CLIS = new Set([...NPM_CLIS, 'uv'])
|
|
3129
3563
|
|
|
3564
|
+
/**
|
|
3565
|
+
* The oldest npm CLI that publishes over OIDC. An older one finds no token and fails the
|
|
3566
|
+
* publish — after the tag and the push, since nothing before that step asks it anything.
|
|
3567
|
+
* https://docs.npmjs.com/trusted-publishers states the minimum; pnpm and bun implement the
|
|
3568
|
+
* exchange themselves and document no equivalent, so only npm is checked.
|
|
3569
|
+
*/
|
|
3570
|
+
const NPM_OIDC_MIN = '11.5.1'
|
|
3571
|
+
|
|
3130
3572
|
/** Whether one publish CLI can publish at all: OIDC, a token, or a live session. */
|
|
3131
3573
|
function checkCredentials({ cli, registry, command }) {
|
|
3132
3574
|
if (isTrustedPublishing && OIDC_CLIS.has(cli)) {
|
|
3133
3575
|
ok(`${cli}: trusted publishing (OIDC) — no token needed`)
|
|
3576
|
+
if (cli === 'npm') {
|
|
3577
|
+
const npmVersion = tryRead('npm', ['--version'])
|
|
3578
|
+
if (!npmVersion || !parseVersion(npmVersion)) {
|
|
3579
|
+
warn(
|
|
3580
|
+
`npm: could not read its version — trusted publishing needs npm ${NPM_OIDC_MIN} or later`,
|
|
3581
|
+
)
|
|
3582
|
+
} else if (compareVersions(npmVersion, NPM_OIDC_MIN) < 0) {
|
|
3583
|
+
fail(
|
|
3584
|
+
`npm ${npmVersion} cannot publish with trusted publishing, which needs npm ` +
|
|
3585
|
+
`${NPM_OIDC_MIN} or later — the publish would fail after the tag and push.\n` +
|
|
3586
|
+
' Update it before releasing: npm install -g npm@latest',
|
|
3587
|
+
)
|
|
3588
|
+
}
|
|
3589
|
+
}
|
|
3134
3590
|
// Provenance is the other half of what OIDC makes possible: a signed attestation
|
|
3135
3591
|
// tying the published artefact to the workflow and commit that produced it. It is
|
|
3136
3592
|
// not added to the command here — npm generates it for a trusted publish on its own,
|
|
@@ -3169,6 +3625,75 @@ function checkCredentials({ cli, registry, command }) {
|
|
|
3169
3625
|
}
|
|
3170
3626
|
}
|
|
3171
3627
|
|
|
3628
|
+
/**
|
|
3629
|
+
* Package what `cargo publish` will upload, before the tag and the push rather than after.
|
|
3630
|
+
*
|
|
3631
|
+
* `cargo publish` packages and verifies as its first act, so a file left out by `include`
|
|
3632
|
+
* or `exclude`, a manifest crates.io rejects, a crate that does not build from its own
|
|
3633
|
+
* archive, or one over the upload limit is discovered only once the release is tagged and
|
|
3634
|
+
* pushed. This runs the same packaging now. It builds the crate, as publishing does, so it
|
|
3635
|
+
* costs a compile.
|
|
3636
|
+
*
|
|
3637
|
+
* It packages the tree as it is — the version is not bumped yet. The version number is the
|
|
3638
|
+
* one thing that differs from what will be uploaded, and it changes neither the file list,
|
|
3639
|
+
* the metadata, nor the size.
|
|
3640
|
+
*/
|
|
3641
|
+
function checkCratePackage(target) {
|
|
3642
|
+
const args = cargoPackageArgs(target.command, {
|
|
3643
|
+
dirty: !!dirty,
|
|
3644
|
+
lockfile: existsSync('Cargo.lock'),
|
|
3645
|
+
})
|
|
3646
|
+
if (!args) {
|
|
3647
|
+
note(`cargo: \`${target.command}\` is not a plain cargo publish — not packaging it first`)
|
|
3648
|
+
return
|
|
3649
|
+
}
|
|
3650
|
+
const started = Date.now()
|
|
3651
|
+
try {
|
|
3652
|
+
// A verify build prints every crate it compiles; Node's 1 MiB default would overflow.
|
|
3653
|
+
execFileSync('cargo', args, { stdio: 'pipe', encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 })
|
|
3654
|
+
} catch (err) {
|
|
3655
|
+
const tail = `${err.stdout ?? ''}${err.stderr ?? ''}`.trim().split('\n').slice(-12).join('\n')
|
|
3656
|
+
fail(`\`cargo ${args.join(' ')}\` failed — \`${target.command}\` would too:\n${indent(tail)}`)
|
|
3657
|
+
return
|
|
3658
|
+
}
|
|
3659
|
+
const metadata = tryRead('cargo', ['metadata', '--no-deps', '--format-version', '1'])
|
|
3660
|
+
let targetDir = null
|
|
3661
|
+
try {
|
|
3662
|
+
targetDir = metadata ? JSON.parse(metadata).target_directory : null
|
|
3663
|
+
} catch {
|
|
3664
|
+
targetDir = null
|
|
3665
|
+
}
|
|
3666
|
+
const packageDir = targetDir ? join(targetDir, 'package') : null
|
|
3667
|
+
// Only archives this run wrote: target/package keeps every earlier version too.
|
|
3668
|
+
const crates =
|
|
3669
|
+
packageDir && existsSync(packageDir)
|
|
3670
|
+
? readdirSync(packageDir)
|
|
3671
|
+
.filter((name) => name.endsWith('.crate'))
|
|
3672
|
+
.map((name) => {
|
|
3673
|
+
const { size, mtimeMs } = statSync(join(packageDir, name))
|
|
3674
|
+
return { name, size, mtimeMs }
|
|
3675
|
+
})
|
|
3676
|
+
.filter(({ mtimeMs }) => mtimeMs >= started - 1000)
|
|
3677
|
+
: []
|
|
3678
|
+
if (!crates.length) {
|
|
3679
|
+
warn('cargo package passed, but no .crate it wrote was found to check against the size limit')
|
|
3680
|
+
return
|
|
3681
|
+
}
|
|
3682
|
+
const limitMb = (CRATES_IO_MAX_BYTES / 1024 / 1024).toFixed(0)
|
|
3683
|
+
for (const { name, size } of crates) {
|
|
3684
|
+
const mb = (size / 1024 / 1024).toFixed(1)
|
|
3685
|
+
if (size > CRATES_IO_MAX_BYTES) {
|
|
3686
|
+
fail(
|
|
3687
|
+
`${name} is ${mb} MiB, over crates.io's ${limitMb} MiB upload limit.\n` +
|
|
3688
|
+
' Trim it with `include`/`exclude` in Cargo.toml (`cargo package --list` shows ' +
|
|
3689
|
+
'what goes in), or ask crates.io to raise the limit for this crate.',
|
|
3690
|
+
)
|
|
3691
|
+
} else {
|
|
3692
|
+
ok(`cargo package: ${name} (${mb} MiB)`)
|
|
3693
|
+
}
|
|
3694
|
+
}
|
|
3695
|
+
}
|
|
3696
|
+
|
|
3172
3697
|
/** Commands whose version is already on the registry, so the publish step skips them. */
|
|
3173
3698
|
const alreadyPublished = new Set()
|
|
3174
3699
|
if (!publishTargets.length) {
|
|
@@ -3192,6 +3717,7 @@ if (!publishTargets.length) {
|
|
|
3192
3717
|
alreadyPublished.add(target.command)
|
|
3193
3718
|
note(`${target.name}@${version} is already published — will skip \`${target.command}\``)
|
|
3194
3719
|
}
|
|
3720
|
+
if (target.cli === 'cargo' && !alreadyPublished.has(target.command)) checkCratePackage(target)
|
|
3195
3721
|
}
|
|
3196
3722
|
}
|
|
3197
3723
|
|
|
@@ -3244,6 +3770,18 @@ const notesDeferred = !!(dirty && runs('commit'))
|
|
|
3244
3770
|
*/
|
|
3245
3771
|
let notesPending = false
|
|
3246
3772
|
|
|
3773
|
+
/**
|
|
3774
|
+
* Entries the assistant flagged as unsure in the notes it drafted — see `uncertainEntries`.
|
|
3775
|
+
* Only a draft can carry them: hand-written and commit-derived notes are someone's words.
|
|
3776
|
+
*/
|
|
3777
|
+
let unsure = []
|
|
3778
|
+
|
|
3779
|
+
/**
|
|
3780
|
+
* Whether a person answers the confirmation prompt. Under --yes, or with no terminal to ask
|
|
3781
|
+
* on, nobody reviews anything before it is tagged and published.
|
|
3782
|
+
*/
|
|
3783
|
+
const confirming = !assumeYes && !dryRun && !!process.stdin.isTTY
|
|
3784
|
+
|
|
3247
3785
|
/**
|
|
3248
3786
|
* Notes for a version, in descending order of how much they can be trusted:
|
|
3249
3787
|
* an assistant's prose when one is configured, otherwise the commits grouped by
|
|
@@ -3284,11 +3822,30 @@ function draftNotesFor(v) {
|
|
|
3284
3822
|
)
|
|
3285
3823
|
}
|
|
3286
3824
|
note(`drafting notes from ${subjects.length} commit(s) with ${assistantName}...`)
|
|
3825
|
+
const drafted = draftReleaseNotes(v, commits, lastTag, remoteLinks(config.remote))
|
|
3826
|
+
unsure = uncertainEntries(drafted)
|
|
3287
3827
|
return (
|
|
3288
|
-
|
|
3828
|
+
drafted ??
|
|
3289
3829
|
changelogFromCommits(commits, remoteLinks(config.remote), config.hiddenTypes, contributors)
|
|
3290
3830
|
)
|
|
3291
3831
|
}
|
|
3832
|
+
|
|
3833
|
+
/**
|
|
3834
|
+
* The flagged entries, listed for whoever has to resolve them. Non-interactively nobody can,
|
|
3835
|
+
* so preflight refuses; at the prompt the person answering is the review.
|
|
3836
|
+
*/
|
|
3837
|
+
const listUnsure = () => {
|
|
3838
|
+
const one = unsure.length === 1
|
|
3839
|
+
const what = `${unsure.length} drafted entr${one ? 'y' : 'ies'} ${UNSURE}`
|
|
3840
|
+
return `${assistantName} marked ${what} — it could not tell what ${one ? 'it means' : 'they mean'} for users:\n${indent(unsure.join('\n'))}`
|
|
3841
|
+
}
|
|
3842
|
+
|
|
3843
|
+
/** What to do about flagged entries when nobody is at the prompt to review them. */
|
|
3844
|
+
const handWritten = config.changelog
|
|
3845
|
+
? `, or write the notes into [Unreleased] in ${config.changelog} with them resolved — a hand-written section wins over a draft`
|
|
3846
|
+
: ''
|
|
3847
|
+
const UNSURE_FIX = ` Re-run without --yes in a terminal to review them at the prompt${handWritten}.`
|
|
3848
|
+
|
|
3292
3849
|
const changelogText =
|
|
3293
3850
|
config.changelog && existsSync(config.changelog) ? readFileSync(config.changelog, 'utf8') : null
|
|
3294
3851
|
if (changelogText) reportChangelogOrder(changelogText)
|
|
@@ -3318,18 +3875,37 @@ if (notesSource === 'github') {
|
|
|
3318
3875
|
|
|
3319
3876
|
// Generate, either because nothing was written or because a source was named.
|
|
3320
3877
|
if (!notes) {
|
|
3878
|
+
// Candidates' sections are not a source for the stable release: its notes come from all
|
|
3879
|
+
// the commits since the last stable tag. Say so while there is still time to write the
|
|
3880
|
+
// wording that should survive into [Unreleased] — nothing else would.
|
|
3881
|
+
const candidates =
|
|
3882
|
+
notesSource === 'auto' && changelogText ? candidateSections(changelogText, version) : []
|
|
3883
|
+
if (candidates.length) {
|
|
3884
|
+
warn(
|
|
3885
|
+
`${config.changelog} has sections for ${candidates.join(', ')}, but ${version} ` +
|
|
3886
|
+
'has none of its own, so its notes are generated from every commit since the last ' +
|
|
3887
|
+
'stable release.\n Anything edited into those sections is not carried over. ' +
|
|
3888
|
+
`To release with it, write the ${version} notes into [Unreleased] and re-run.`,
|
|
3889
|
+
)
|
|
3890
|
+
}
|
|
3321
3891
|
if (notesDeferred) {
|
|
3322
3892
|
notesPending = true
|
|
3323
3893
|
ok(`release notes will be ${assistant ? 'drafted' : 'generated'} after the commit`)
|
|
3324
3894
|
} else {
|
|
3325
3895
|
draftedNotes = draftNotesFor(version)
|
|
3896
|
+
if (unsure.length) {
|
|
3897
|
+
if (confirming) warn(`${listUnsure()}\n They are shown again at the prompt.`)
|
|
3898
|
+
else fail(`${listUnsure()}\n${UNSURE_FIX}`)
|
|
3899
|
+
}
|
|
3326
3900
|
if (draftedNotes) {
|
|
3327
3901
|
notes = draftedNotes
|
|
3328
|
-
ok
|
|
3329
|
-
|
|
3330
|
-
|
|
3331
|
-
|
|
3332
|
-
|
|
3902
|
+
// An "ok" under the failure above would contradict it: these notes are refused.
|
|
3903
|
+
if (!unsure.length || confirming)
|
|
3904
|
+
ok(
|
|
3905
|
+
notesSource === 'commits' || !assistant
|
|
3906
|
+
? 'release notes built from the commit log'
|
|
3907
|
+
: `release notes drafted by ${assistantName}`,
|
|
3908
|
+
)
|
|
3333
3909
|
} else if (notesSource === 'auto') {
|
|
3334
3910
|
warn(
|
|
3335
3911
|
`no ${config.changelog ?? 'changelog'} section and nothing to draft from — GitHub will generate the notes`,
|
|
@@ -3346,6 +3922,16 @@ for (const asset of config.assets) {
|
|
|
3346
3922
|
else fail(`asset ${asset} does not exist`)
|
|
3347
3923
|
}
|
|
3348
3924
|
|
|
3925
|
+
/**
|
|
3926
|
+
* Whether drafted notes still have to be filed in the changelog. A named source (`commits`,
|
|
3927
|
+
* `assistant`) drafts even when the section exists — a resume, or one written by hand — and
|
|
3928
|
+
* filing it again would duplicate the heading and commit past a reused tag.
|
|
3929
|
+
*/
|
|
3930
|
+
const draftedSectionMissing = () =>
|
|
3931
|
+
!!config.changelog &&
|
|
3932
|
+
existsSync(config.changelog) &&
|
|
3933
|
+
!hasVersionHeading(readFileSync(config.changelog, 'utf8'), version)
|
|
3934
|
+
|
|
3349
3935
|
// Reusing a tag is the resume path, and a resume writes nothing. If this run would still
|
|
3350
3936
|
// produce a commit, that commit moves HEAD past the tag and the release ends up tagged at
|
|
3351
3937
|
// the wrong revision — which is silent until someone checks out the tag, and worse for the
|
|
@@ -3356,6 +3942,7 @@ const wouldCommit = [
|
|
|
3356
3942
|
// The roll is computed whenever [Unreleased] is populated, because the notes come from
|
|
3357
3943
|
// it either way; it is only written — and only becomes a commit — when the step runs.
|
|
3358
3944
|
rolledChangelog && runs('changelog') && 'a changelog entry',
|
|
3945
|
+
draftedNotes && runs('changelog') && draftedSectionMissing() && 'a drafted changelog section',
|
|
3359
3946
|
].filter(Boolean)
|
|
3360
3947
|
if (taggedCommit && runs('tag') && wouldCommit.length) {
|
|
3361
3948
|
fail(
|
|
@@ -3371,7 +3958,8 @@ if (taggedCommit && runs('tag') && wouldCommit.length) {
|
|
|
3371
3958
|
if (config.verify) {
|
|
3372
3959
|
note(`running verify: ${config.verify}`)
|
|
3373
3960
|
try {
|
|
3374
|
-
|
|
3961
|
+
// A test suite or a build can print far more than Node's 1 MiB default capture.
|
|
3962
|
+
execSync(config.verify, { stdio: 'pipe', encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 })
|
|
3375
3963
|
ok(`verify passed: ${config.verify}`)
|
|
3376
3964
|
} catch (err) {
|
|
3377
3965
|
const tail = `${err.stdout ?? ''}${err.stderr ?? ''}`.trim().split('\n').slice(-12).join('\n')
|
|
@@ -3401,7 +3989,7 @@ let commitMessage = null
|
|
|
3401
3989
|
let didStage = false
|
|
3402
3990
|
if (dirty && runs('commit') && !dryRun) {
|
|
3403
3991
|
step('Stage the working tree')
|
|
3404
|
-
mutate('git', ['add', '--all'])
|
|
3992
|
+
mutate('git', ['add', '--all', ...SCOPE])
|
|
3405
3993
|
didStage = true
|
|
3406
3994
|
commitMessage = assistant ? draftCommitMessage() : null
|
|
3407
3995
|
if (!commitMessage) {
|
|
@@ -3417,19 +4005,43 @@ if (dirty && runs('commit') && !dryRun) {
|
|
|
3417
4005
|
console.log(indent(commitMessage))
|
|
3418
4006
|
}
|
|
3419
4007
|
|
|
3420
|
-
|
|
3421
|
-
|
|
4008
|
+
/** Ask a yes/no question on the terminal; anything but yes is no. */
|
|
4009
|
+
async function ask(question) {
|
|
3422
4010
|
const rl = createInterface({ input: process.stdin, output: process.stdout })
|
|
3423
4011
|
let answer = ''
|
|
3424
4012
|
try {
|
|
3425
|
-
answer = await rl.question(
|
|
4013
|
+
answer = await rl.question(question)
|
|
3426
4014
|
} catch {
|
|
3427
4015
|
// Ctrl+C or Ctrl+D at the prompt rejects the question. That is a decline, not a
|
|
3428
4016
|
// crash — without this it exits on an unhandled AbortError and a stack trace.
|
|
3429
4017
|
} finally {
|
|
3430
4018
|
rl.close()
|
|
3431
4019
|
}
|
|
3432
|
-
|
|
4020
|
+
return /^y(es)?$/i.test(answer.trim())
|
|
4021
|
+
}
|
|
4022
|
+
|
|
4023
|
+
/**
|
|
4024
|
+
* Show the notes, with the entries the assistant was unsure of called out, and ask. A yes is
|
|
4025
|
+
* the review those entries were waiting for, so the marker comes off before anything is
|
|
4026
|
+
* written: `[???]` in a tag or on a release page helps nobody.
|
|
4027
|
+
*/
|
|
4028
|
+
async function confirmRelease(question) {
|
|
4029
|
+
if (notes) console.log(`\n${bold('Release notes')}\n${indent(notes)}`)
|
|
4030
|
+
if (unsure.length) {
|
|
4031
|
+
console.log(`\n${yellow(bold('Review these'))} — ${listUnsure()}`)
|
|
4032
|
+
console.log(dim(' Answering y releases them as shown, without the marker.'))
|
|
4033
|
+
}
|
|
4034
|
+
const yes = await ask(question)
|
|
4035
|
+
if (yes && unsure.length) {
|
|
4036
|
+
notes = withoutUnsureMarkers(notes)
|
|
4037
|
+
if (draftedNotes) draftedNotes = withoutUnsureMarkers(draftedNotes)
|
|
4038
|
+
unsure = []
|
|
4039
|
+
}
|
|
4040
|
+
return yes
|
|
4041
|
+
}
|
|
4042
|
+
|
|
4043
|
+
if (confirming) {
|
|
4044
|
+
if (!(await confirmRelease(`\nRelease ${bold(tag)} of ${projectName}? [y/N] `))) {
|
|
3433
4045
|
// Leave the index exactly as it was found.
|
|
3434
4046
|
if (didStage) mutate('git', ['reset', '--quiet'])
|
|
3435
4047
|
abort('cancelled')
|
|
@@ -3451,6 +4063,18 @@ if (dirty && runs('commit')) {
|
|
|
3451
4063
|
if (notesPending && !dryRun) {
|
|
3452
4064
|
draftedNotes = draftNotesFor(version)
|
|
3453
4065
|
if (draftedNotes) notes = draftedNotes
|
|
4066
|
+
// These notes were drafted after the prompt, so nobody has seen them. Flagged entries
|
|
4067
|
+
// get the review preflight would have given them; the commit just made stays, and a
|
|
4068
|
+
// re-run drafts from a clean tree — before the prompt, where they can be reviewed.
|
|
4069
|
+
if (unsure.length) {
|
|
4070
|
+
const committed =
|
|
4071
|
+
' The working tree is committed; nothing else has changed. Re-running drafts the ' +
|
|
4072
|
+
'notes during preflight, before anything else happens.'
|
|
4073
|
+
if (!confirming) abort(`${listUnsure()}\n${UNSURE_FIX}\n${committed}`)
|
|
4074
|
+
if (!(await confirmRelease(`\nRelease ${bold(tag)} with these notes? [y/N] `))) {
|
|
4075
|
+
abort(`cancelled\n${committed}`)
|
|
4076
|
+
}
|
|
4077
|
+
}
|
|
3454
4078
|
}
|
|
3455
4079
|
}
|
|
3456
4080
|
|
|
@@ -3481,7 +4105,7 @@ if (rolledChangelog && runs('changelog')) {
|
|
|
3481
4105
|
if (dryRun) console.log(` ${yellow('would write')} ${config.changelog}`)
|
|
3482
4106
|
else writeFileSync(config.changelog, linked(rolledChangelog))
|
|
3483
4107
|
staged.push(config.changelog)
|
|
3484
|
-
} else if (runs('changelog') && draftedNotes &&
|
|
4108
|
+
} else if (runs('changelog') && draftedNotes && draftedSectionMissing()) {
|
|
3485
4109
|
step(`Add the drafted ${version} section to ${config.changelog}`)
|
|
3486
4110
|
if (dryRun) console.log(` ${yellow('would write')} ${config.changelog}`)
|
|
3487
4111
|
else {
|