@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.
Files changed (3) hide show
  1. package/README.md +14 -1
  2. package/package.json +1 -1
  3. 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. 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@entro314labs/release-kit",
3
- "version": "2.2.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
- 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 }
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(['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) => {
@@ -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
- /** 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
+ */
1273
1435
  function draftNotesFor(v) {
1274
- if (!assistant) return null
1275
- const { lastTag, subjects } = commitsSinceLastTag()
1276
- if (!subjects.length) return null
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')