@entro314labs/release-kit 2.2.1 → 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 +18 -1
- package/package.json +1 -1
- package/release.mjs +209 -23
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.
|
|
@@ -305,6 +318,10 @@ Anything else runs as written with no preflight. The project name comes from the
|
|
|
305
318
|
`name` in `package.json`, `Cargo.toml` or `pyproject.toml`, `module` in `go.mod` — falling
|
|
306
319
|
back to the repository directory.
|
|
307
320
|
|
|
321
|
+
With no `versionFile` configured it is detected from the repository — `package.json`,
|
|
322
|
+
`pyproject.toml`, `Cargo.toml`, then `VERSION` — so most projects need no config for it at
|
|
323
|
+
all. Set it explicitly to override, or to `null` for a repository that versions by tag.
|
|
324
|
+
|
|
308
325
|
The format is inferred from the file name: `.json` reads the `"version"` field, `.toml`
|
|
309
326
|
reads the first `version = "x.y.z"` line, and any other file is treated as containing just
|
|
310
327
|
the version. Only the version itself is rewritten, so comments and formatting survive — and
|
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
|
@@ -58,14 +58,16 @@ import { createInterface } from 'node:readline/promises'
|
|
|
58
58
|
* branch string the only branch a release may run from; null to allow any
|
|
59
59
|
* remote string git remote to push to
|
|
60
60
|
* changelog string changelog path; null to disable changelog handling
|
|
61
|
-
* versionFile string|object|null where the project's version lives
|
|
62
|
-
* repository versions by
|
|
61
|
+
* versionFile string|object|null where the project's version lives. Detected from
|
|
62
|
+
* the repository when unset; null when it versions by tag alone
|
|
63
63
|
* versionFiles array further files whose version is kept in sync; each is a path
|
|
64
64
|
* or { path, pattern }
|
|
65
65
|
* publish string publish command; null to skip publishing entirely
|
|
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
|
|
@@ -98,13 +100,14 @@ const DEFAULTS = {
|
|
|
98
100
|
branch: 'main',
|
|
99
101
|
remote: 'origin',
|
|
100
102
|
changelog: 'CHANGELOG.md',
|
|
101
|
-
versionFile:
|
|
103
|
+
versionFile: undefined,
|
|
102
104
|
versionFiles: [],
|
|
103
105
|
publish: 'npm publish --tag %d',
|
|
104
106
|
commitMessage: 'chore(release): %t',
|
|
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 }
|
|
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('')
|
|
372
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) => {
|
|
@@ -857,10 +975,8 @@ if (existsSync(localManifest) && localManifest !== rootManifest) {
|
|
|
857
975
|
}
|
|
858
976
|
process.chdir(root)
|
|
859
977
|
|
|
860
|
-
const
|
|
861
|
-
|
|
862
|
-
...(existsSync('release.config.json') ? readJson('release.config.json') : {}),
|
|
863
|
-
}
|
|
978
|
+
const userConfig = existsSync('release.config.json') ? readJson('release.config.json') : {}
|
|
979
|
+
const config = { ...DEFAULTS, ...userConfig }
|
|
864
980
|
const unknownKeys = Object.keys(config).filter((key) => !(key in DEFAULTS))
|
|
865
981
|
if (unknownKeys.length) abort(`release.config.json has unknown keys: ${unknownKeys.join(', ')}`)
|
|
866
982
|
|
|
@@ -881,7 +997,33 @@ const parseStepList = (value) =>
|
|
|
881
997
|
* tag alone and the version has to be passed explicitly.
|
|
882
998
|
*/
|
|
883
999
|
const manifest = existsSync('package.json') ? readJson('package.json') : null
|
|
884
|
-
|
|
1000
|
+
|
|
1001
|
+
/**
|
|
1002
|
+
* Where this project keeps its version, when the config does not say. Checked in order of
|
|
1003
|
+
* how definitively each file identifies a repository. `go.mod` resolves to null because Go
|
|
1004
|
+
* modules carry no version — the tag is the version.
|
|
1005
|
+
*/
|
|
1006
|
+
function detectVersionFile() {
|
|
1007
|
+
for (const candidate of ['package.json', 'pyproject.toml', 'Cargo.toml', 'VERSION']) {
|
|
1008
|
+
if (existsSync(candidate)) return candidate
|
|
1009
|
+
}
|
|
1010
|
+
return null
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
// An explicit `versionFile: null` means "versions by tag" and must not be re-detected, so
|
|
1014
|
+
// the distinction is between the key being absent and the key being set to null.
|
|
1015
|
+
const configuredVersionFile = Object.hasOwn(userConfig, 'versionFile')
|
|
1016
|
+
? userConfig.versionFile
|
|
1017
|
+
: detectVersionFile()
|
|
1018
|
+
const versionFile = configuredVersionFile ? versionSource(configuredVersionFile) : null
|
|
1019
|
+
// Only worth saying when it is not the conventional default.
|
|
1020
|
+
if (
|
|
1021
|
+
!Object.hasOwn(userConfig, 'versionFile') &&
|
|
1022
|
+
versionFile &&
|
|
1023
|
+
versionFile.path !== 'package.json'
|
|
1024
|
+
) {
|
|
1025
|
+
note(`version source: ${versionFile.path} (detected)`)
|
|
1026
|
+
}
|
|
885
1027
|
|
|
886
1028
|
if (versionFile && !existsSync(versionFile.path)) {
|
|
887
1029
|
abort(`versionFile ${versionFile.path} does not exist`)
|
|
@@ -963,6 +1105,9 @@ console.log(
|
|
|
963
1105
|
(dryRun ? ` ${yellow('(dry run — nothing will execute)')}` : ''),
|
|
964
1106
|
)
|
|
965
1107
|
|
|
1108
|
+
/** What `auto` inferred, kept so preflight can show the reasoning. */
|
|
1109
|
+
let autoBump = null
|
|
1110
|
+
|
|
966
1111
|
let version
|
|
967
1112
|
if (!target) {
|
|
968
1113
|
if (!currentVersion) {
|
|
@@ -972,6 +1117,29 @@ if (!target) {
|
|
|
972
1117
|
)
|
|
973
1118
|
}
|
|
974
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
|
+
}
|
|
975
1143
|
} else if (BUMPS.has(target)) {
|
|
976
1144
|
if (!currentVersion) {
|
|
977
1145
|
abort(`a ${target} bump needs a versionFile to bump from. Pass a version explicitly instead.`)
|
|
@@ -1074,6 +1242,20 @@ const fail = (message) => {
|
|
|
1074
1242
|
problems.push(message)
|
|
1075
1243
|
}
|
|
1076
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
|
+
|
|
1077
1259
|
if (bumping && compareVersions(version, currentVersion) <= 0) {
|
|
1078
1260
|
fail(`${version} is not greater than the current version ${currentVersion}`)
|
|
1079
1261
|
} else if (bumping) {
|
|
@@ -1245,11 +1427,15 @@ let draftedNotes = null
|
|
|
1245
1427
|
*/
|
|
1246
1428
|
const notesDeferred = !!(dirty && runs('commit') && assistant)
|
|
1247
1429
|
|
|
1248
|
-
/**
|
|
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
|
+
*/
|
|
1249
1435
|
function draftNotesFor(v) {
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
if (!
|
|
1436
|
+
const { lastTag, subjects, commits } = commitsSinceLastTag()
|
|
1437
|
+
if (!commits.length) return null
|
|
1438
|
+
if (!assistant) return changelogFromCommits(commits)
|
|
1253
1439
|
if (shallow) {
|
|
1254
1440
|
warn(
|
|
1255
1441
|
`shallow clone: only ${subjects.length} commit(s) are visible, so the notes will ` +
|
|
@@ -1257,7 +1443,7 @@ function draftNotesFor(v) {
|
|
|
1257
1443
|
)
|
|
1258
1444
|
}
|
|
1259
1445
|
note(`drafting notes from ${subjects.length} commit(s) with ${assistantName}...`)
|
|
1260
|
-
return draftReleaseNotes(v, subjects, lastTag)
|
|
1446
|
+
return draftReleaseNotes(v, subjects, lastTag) ?? changelogFromCommits(commits)
|
|
1261
1447
|
}
|
|
1262
1448
|
if (config.changelog && existsSync(config.changelog)) {
|
|
1263
1449
|
const text = readFileSync(config.changelog, 'utf8')
|