@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.
Files changed (3) hide show
  1. package/README.md +18 -1
  2. package/package.json +1 -1
  3. 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. Otherwise GitHub generates them from the commits since the previous tag.
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.2.1",
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; null when the
62
- * repository versions by git tag alone
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: 'package.json',
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
- const log = tryRead('git', ['log', '--format=%s', range]) ?? ''
364
- return {
365
- lastTag,
366
- subjects: log
367
- .split('\n')
368
- .filter(
369
- (s) =>
370
- s && !/^chore\(release\)/i.test(s) && !/^Merge (branch|pull request|remote)/i.test(s),
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(['major', 'minor', 'patch', 'premajor', 'preminor', 'prepatch', 'prerelease'])
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 config = {
861
- ...DEFAULTS,
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
- const versionFile = config.versionFile ? versionSource(config.versionFile) : null
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
- /** Draft notes from the commit log, reporting what it is doing since it takes a moment. */
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
- if (!assistant) return null
1251
- const { lastTag, subjects } = commitsSinceLastTag()
1252
- if (!subjects.length) return null
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')