@entro314labs/release-kit 2.2.2 → 2.3.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 +14 -1
- package/package.json +1 -1
- package/release.mjs +177 -15
package/README.md
CHANGED
|
@@ -144,6 +144,15 @@ pnpm release -- --help
|
|
|
144
144
|
The target is optional. With no target it releases whatever version `package.json`
|
|
145
145
|
already says — which is the mode to use when a version bump landed in an earlier commit.
|
|
146
146
|
|
|
147
|
+
| `auto` | derived from the Conventional Commits since the last tag |
|
|
148
|
+
|
|
149
|
+
`auto` follows release-please's rules: a breaking change (`!` or a `BREAKING CHANGE:`
|
|
150
|
+
footer) is a major, a `feat:` is a minor, anything else is a patch. Below `1.0.0` that is
|
|
151
|
+
softened — a breaking change bumps the minor rather than jumping to `1.0.0`. A commit body
|
|
152
|
+
containing `Release-As: 2.0.0` pins the version outright. It always prints what it inferred
|
|
153
|
+
and why before doing anything. Set `"versioning"` to `always-patch`, `always-minor` or
|
|
154
|
+
`always-major` to never infer.
|
|
155
|
+
|
|
147
156
|
| Target | From `1.2.3` | From `2.0.0-beta.1` |
|
|
148
157
|
| ------------------------------------ | ------------------------------------------------ | ------------------- |
|
|
149
158
|
| _(none)_ | `1.2.3` | `2.0.0-beta.1` |
|
|
@@ -217,7 +226,11 @@ Notes resolve in this order:
|
|
|
217
226
|
section ends at the next `##` heading or `---` rule.
|
|
218
227
|
2. The `## [Unreleased]` section, if the version has no section of its own — this is the
|
|
219
228
|
same content that step 2 above is about to promote.
|
|
220
|
-
3.
|
|
229
|
+
3. The commits grouped by Conventional Commit type — Features, Bug Fixes, Performance
|
|
230
|
+
Improvements, Reverts, with breaking changes first and chores, CI and docs hidden. This
|
|
231
|
+
is deterministic and needs nothing installed, so decent notes are the default rather
|
|
232
|
+
than something that requires an assistant.
|
|
233
|
+
4. Otherwise GitHub generates them from the commits since the previous tag.
|
|
221
234
|
|
|
222
235
|
The same text becomes the tag annotation, the GitHub release body, and (when rolled) the
|
|
223
236
|
changelog entry. It is written once and lands in three places.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@entro314labs/release-kit",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.3.0",
|
|
4
4
|
"description": "Single-file, zero-dependency release mechanism for JS/TS/Node projects: version bump, changelog roll, commit, annotated tag, push, publish, GitHub release",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"changelog",
|
package/release.mjs
CHANGED
|
@@ -66,6 +66,8 @@ import { createInterface } from 'node:readline/promises'
|
|
|
66
66
|
* commitMessage string release commit subject
|
|
67
67
|
* releaseTitle string GitHub release title
|
|
68
68
|
* assets string[] files attached to the GitHub release
|
|
69
|
+
* versioning string how `auto` derives a bump: "conventional", or
|
|
70
|
+
* always-patch / always-minor / always-major to never infer
|
|
69
71
|
* assistant string|object drafting CLI for commit messages and notes. A key of
|
|
70
72
|
* ASSISTANTS, "auto" for the first available, or null. The
|
|
71
73
|
* object form { tool, model, effort } also pins which model and
|
|
@@ -105,6 +107,7 @@ const DEFAULTS = {
|
|
|
105
107
|
releaseTitle: '%t',
|
|
106
108
|
assets: [],
|
|
107
109
|
assistant: null,
|
|
110
|
+
versioning: 'conventional',
|
|
108
111
|
}
|
|
109
112
|
|
|
110
113
|
/**
|
|
@@ -360,16 +363,122 @@ function runAssistant(prompt) {
|
|
|
360
363
|
function commitsSinceLastTag() {
|
|
361
364
|
const lastTag = tryRead('git', ['describe', '--tags', '--abbrev=0'])
|
|
362
365
|
const range = lastTag ? `${lastTag}..HEAD` : 'HEAD'
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
)
|
|
366
|
+
// %B is the whole message: bodies carry BREAKING CHANGE and Release-As footers. A record
|
|
367
|
+
// separator keeps multi-line messages parseable when splitting the log back apart.
|
|
368
|
+
const raw = tryRead('git', ['log', `--format=%B%x1e`, range]) ?? ''
|
|
369
|
+
const commits = raw
|
|
370
|
+
.split('\u001e')
|
|
371
|
+
.map((entry) => entry.trim())
|
|
372
|
+
.filter(Boolean)
|
|
373
|
+
.map((entry) => {
|
|
374
|
+
const [subject, ...rest] = entry.split('\n')
|
|
375
|
+
return { subject: subject.trim(), body: rest.join('\n').trim() }
|
|
376
|
+
})
|
|
377
|
+
.filter(
|
|
378
|
+
({ subject }) =>
|
|
379
|
+
subject &&
|
|
380
|
+
!/^chore\(release\)/i.test(subject) &&
|
|
381
|
+
!/^Merge (branch|pull request|remote)/i.test(subject) &&
|
|
382
|
+
!/^wip\b/i.test(subject) &&
|
|
383
|
+
!/^(fixup|squash)!/.test(subject),
|
|
384
|
+
)
|
|
385
|
+
return { lastTag, commits, subjects: commits.map((c) => c.subject) }
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Conventional Commit types and the changelog heading each lands under, following
|
|
390
|
+
* release-please's defaults. Types marked hidden are real changes but not release notes:
|
|
391
|
+
* a reader upgrading does not need to know the CI config moved.
|
|
392
|
+
*/
|
|
393
|
+
const CHANGELOG_SECTIONS = [
|
|
394
|
+
{ type: 'feat', section: 'Features' },
|
|
395
|
+
{ type: 'fix', section: 'Bug Fixes' },
|
|
396
|
+
{ type: 'perf', section: 'Performance Improvements' },
|
|
397
|
+
{ type: 'revert', section: 'Reverts' },
|
|
398
|
+
{ type: 'docs', section: 'Documentation', hidden: true },
|
|
399
|
+
{ type: 'style', section: 'Styles', hidden: true },
|
|
400
|
+
{ type: 'refactor', section: 'Code Refactoring', hidden: true },
|
|
401
|
+
{ type: 'test', section: 'Tests', hidden: true },
|
|
402
|
+
{ type: 'build', section: 'Build System', hidden: true },
|
|
403
|
+
{ type: 'ci', section: 'Continuous Integration', hidden: true },
|
|
404
|
+
{ type: 'chore', section: 'Miscellaneous Chores', hidden: true },
|
|
405
|
+
]
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Parse a commit into the parts a release cares about.
|
|
409
|
+
*
|
|
410
|
+
* @returns {{type: string, scope: string|null, breaking: boolean, subject: string,
|
|
411
|
+
* releaseAs: string|null} | null} null when the subject is not Conventional Commits
|
|
412
|
+
*/
|
|
413
|
+
function parseCommit(subject, body = '') {
|
|
414
|
+
const match = /^([a-z]+)(?:\(([^)]+)\))?(!)?: (.+)$/i.exec(subject)
|
|
415
|
+
if (!match) return null
|
|
416
|
+
const [, type, scope, bang, text] = match
|
|
417
|
+
// A breaking change is marked either by `!` in the header or a BREAKING CHANGE footer.
|
|
418
|
+
const breaking = !!bang || /^BREAKING[ -]CHANGE:/m.test(body)
|
|
419
|
+
// `Release-As: 1.2.3` in a commit body pins the next version, so the decision can live
|
|
420
|
+
// in git history rather than on the command line.
|
|
421
|
+
const releaseAs = /^Release-As:\s*v?(\S+)/im.exec(body)?.[1] ?? null
|
|
422
|
+
return { type: type.toLowerCase(), scope: scope ?? null, breaking, subject: text, releaseAs }
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* The bump implied by a set of commits, following release-please's default strategy:
|
|
427
|
+
* a breaking change is major, a feature is minor, anything else is a patch.
|
|
428
|
+
*
|
|
429
|
+
* Below 1.0.0 that is usually wrong — a breaking change in a 0.x project conventionally
|
|
430
|
+
* bumps the minor, not to 1.0.0 — so `preMajor` softens both by one level.
|
|
431
|
+
*
|
|
432
|
+
* @returns {{bump: string, breaking: string[], features: string[], releaseAs: string|null}}
|
|
433
|
+
*/
|
|
434
|
+
function inferBump(commits, currentVersion, strategy = 'conventional') {
|
|
435
|
+
const parsed = commits.map((c) => parseCommit(c.subject, c.body)).filter(Boolean)
|
|
436
|
+
const releaseAs = parsed.find((c) => c.releaseAs)?.releaseAs ?? null
|
|
437
|
+
const breaking = parsed.filter((c) => c.breaking).map((c) => c.subject)
|
|
438
|
+
const features = parsed
|
|
439
|
+
.filter((c) => !c.breaking && /^feat(ure)?$/.test(c.type))
|
|
440
|
+
.map((c) => c.subject)
|
|
441
|
+
|
|
442
|
+
if (strategy !== 'conventional') {
|
|
443
|
+
return { bump: strategy.replace(/^always-/, ''), breaking, features, releaseAs }
|
|
372
444
|
}
|
|
445
|
+
|
|
446
|
+
const preMajor = currentVersion ? parseVersion(currentVersion)?.major === 0 : false
|
|
447
|
+
let bump = 'patch'
|
|
448
|
+
if (breaking.length) bump = preMajor ? 'minor' : 'major'
|
|
449
|
+
else if (features.length) bump = 'minor'
|
|
450
|
+
return { bump, breaking, features, releaseAs }
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Group commits into changelog sections, hiding the types that are not release notes.
|
|
455
|
+
* Deterministic and offline: this is what a project gets without an assistant.
|
|
456
|
+
*
|
|
457
|
+
* @returns {string | null} markdown body, or null when nothing visible changed
|
|
458
|
+
*/
|
|
459
|
+
function changelogFromCommits(commits) {
|
|
460
|
+
const parsed = commits.map((c) => parseCommit(c.subject, c.body)).filter(Boolean)
|
|
461
|
+
const lines = []
|
|
462
|
+
|
|
463
|
+
const breaking = parsed.filter((c) => c.breaking)
|
|
464
|
+
if (breaking.length) {
|
|
465
|
+
lines.push('### ⚠ BREAKING CHANGES', '')
|
|
466
|
+
for (const c of breaking) lines.push(`- ${c.scope ? `**${c.scope}:** ` : ''}${c.subject}`)
|
|
467
|
+
lines.push('')
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
for (const { type, section, hidden } of CHANGELOG_SECTIONS) {
|
|
471
|
+
if (hidden) continue
|
|
472
|
+
const inSection = parsed.filter(
|
|
473
|
+
(c) => c.type === type || (type === 'feat' && c.type === 'feature'),
|
|
474
|
+
)
|
|
475
|
+
if (!inSection.length) continue
|
|
476
|
+
lines.push(`### ${section}`, '')
|
|
477
|
+
for (const c of inSection) lines.push(`- ${c.scope ? `**${c.scope}:** ` : ''}${c.subject}`)
|
|
478
|
+
lines.push('')
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
return lines.length ? lines.join('\n').trim() : null
|
|
373
482
|
}
|
|
374
483
|
|
|
375
484
|
const CONVENTIONAL_TYPES = 'build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test'
|
|
@@ -742,7 +851,16 @@ function writeVersionInto(entry, version) {
|
|
|
742
851
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
743
852
|
|
|
744
853
|
const argv = process.argv.slice(2)
|
|
745
|
-
const BUMPS = new Set([
|
|
854
|
+
const BUMPS = new Set([
|
|
855
|
+
'auto',
|
|
856
|
+
'major',
|
|
857
|
+
'minor',
|
|
858
|
+
'patch',
|
|
859
|
+
'premajor',
|
|
860
|
+
'preminor',
|
|
861
|
+
'prepatch',
|
|
862
|
+
'prerelease',
|
|
863
|
+
])
|
|
746
864
|
|
|
747
865
|
const flag = (name) => argv.includes(name)
|
|
748
866
|
const option = (name) => {
|
|
@@ -987,6 +1105,9 @@ console.log(
|
|
|
987
1105
|
(dryRun ? ` ${yellow('(dry run — nothing will execute)')}` : ''),
|
|
988
1106
|
)
|
|
989
1107
|
|
|
1108
|
+
/** What `auto` inferred, kept so preflight can show the reasoning. */
|
|
1109
|
+
let autoBump = null
|
|
1110
|
+
|
|
990
1111
|
let version
|
|
991
1112
|
if (!target) {
|
|
992
1113
|
if (!currentVersion) {
|
|
@@ -996,6 +1117,29 @@ if (!target) {
|
|
|
996
1117
|
)
|
|
997
1118
|
}
|
|
998
1119
|
version = currentVersion
|
|
1120
|
+
} else if (target === 'auto') {
|
|
1121
|
+
if (!currentVersion) {
|
|
1122
|
+
abort('auto needs a versionFile to bump from. Pass a version explicitly instead.')
|
|
1123
|
+
}
|
|
1124
|
+
const { commits, lastTag } = commitsSinceLastTag()
|
|
1125
|
+
if (!commits.length) {
|
|
1126
|
+
abort(
|
|
1127
|
+
`no releasable commits since ${lastTag ?? 'the start of the project'} — nothing to release`,
|
|
1128
|
+
)
|
|
1129
|
+
}
|
|
1130
|
+
autoBump = inferBump(commits, currentVersion, config.versioning)
|
|
1131
|
+
if (autoBump.releaseAs) {
|
|
1132
|
+
if (!parseVersion(autoBump.releaseAs)) {
|
|
1133
|
+
abort(`Release-As: ${autoBump.releaseAs} in a commit is not a semver version`)
|
|
1134
|
+
}
|
|
1135
|
+
version = autoBump.releaseAs
|
|
1136
|
+
} else {
|
|
1137
|
+
version = incrementVersion(
|
|
1138
|
+
currentVersion,
|
|
1139
|
+
autoBump.bump,
|
|
1140
|
+
requestedPreid ?? preidOf(currentVersion),
|
|
1141
|
+
)
|
|
1142
|
+
}
|
|
999
1143
|
} else if (BUMPS.has(target)) {
|
|
1000
1144
|
if (!currentVersion) {
|
|
1001
1145
|
abort(`a ${target} bump needs a versionFile to bump from. Pass a version explicitly instead.`)
|
|
@@ -1098,6 +1242,20 @@ const fail = (message) => {
|
|
|
1098
1242
|
problems.push(message)
|
|
1099
1243
|
}
|
|
1100
1244
|
|
|
1245
|
+
if (autoBump) {
|
|
1246
|
+
const reason = autoBump.releaseAs
|
|
1247
|
+
? `Release-As: ${autoBump.releaseAs} in a commit`
|
|
1248
|
+
: autoBump.breaking.length
|
|
1249
|
+
? `${autoBump.breaking.length} breaking change(s)`
|
|
1250
|
+
: autoBump.features.length
|
|
1251
|
+
? `${autoBump.features.length} feature(s), no breaking changes`
|
|
1252
|
+
: 'no features or breaking changes'
|
|
1253
|
+
ok(`auto: ${autoBump.bump} — ${reason}`)
|
|
1254
|
+
for (const subject of [...autoBump.breaking, ...autoBump.features].slice(0, 5)) {
|
|
1255
|
+
console.log(dim(` ${subject}`))
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1101
1259
|
if (bumping && compareVersions(version, currentVersion) <= 0) {
|
|
1102
1260
|
fail(`${version} is not greater than the current version ${currentVersion}`)
|
|
1103
1261
|
} else if (bumping) {
|
|
@@ -1269,11 +1427,15 @@ let draftedNotes = null
|
|
|
1269
1427
|
*/
|
|
1270
1428
|
const notesDeferred = !!(dirty && runs('commit') && assistant)
|
|
1271
1429
|
|
|
1272
|
-
/**
|
|
1430
|
+
/**
|
|
1431
|
+
* Notes for a version, in descending order of how much they can be trusted:
|
|
1432
|
+
* an assistant's prose when one is configured, otherwise the commits grouped by
|
|
1433
|
+
* Conventional Commit type. Only when neither yields anything does GitHub generate them.
|
|
1434
|
+
*/
|
|
1273
1435
|
function draftNotesFor(v) {
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
if (!
|
|
1436
|
+
const { lastTag, subjects, commits } = commitsSinceLastTag()
|
|
1437
|
+
if (!commits.length) return null
|
|
1438
|
+
if (!assistant) return changelogFromCommits(commits)
|
|
1277
1439
|
if (shallow) {
|
|
1278
1440
|
warn(
|
|
1279
1441
|
`shallow clone: only ${subjects.length} commit(s) are visible, so the notes will ` +
|
|
@@ -1281,7 +1443,7 @@ function draftNotesFor(v) {
|
|
|
1281
1443
|
)
|
|
1282
1444
|
}
|
|
1283
1445
|
note(`drafting notes from ${subjects.length} commit(s) with ${assistantName}...`)
|
|
1284
|
-
return draftReleaseNotes(v, subjects, lastTag)
|
|
1446
|
+
return draftReleaseNotes(v, subjects, lastTag) ?? changelogFromCommits(commits)
|
|
1285
1447
|
}
|
|
1286
1448
|
if (config.changelog && existsSync(config.changelog)) {
|
|
1287
1449
|
const text = readFileSync(config.changelog, 'utf8')
|