@entro314labs/release-kit 2.4.0 → 2.6.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 +28 -1
  2. package/package.json +1 -1
  3. package/release.mjs +157 -29
package/README.md CHANGED
@@ -430,6 +430,18 @@ re-deriving it: `version`, `tag`, `name`, `dist-tag`, `steps`, `published`, `rel
430
430
  Nothing is written on a dry run, and an unwritable `$GITHUB_OUTPUT` never fails a release
431
431
  that already completed.
432
432
 
433
+ Two upstream habits make commit-derived notes trustworthy, and neither is release-kit's job:
434
+
435
+ - **Gate the release on CI, and guard against forks.** Trigger on `workflow_run` after your
436
+ check workflow succeeds, with `if: github.repository_owner == 'your-org'` so a fork never
437
+ tries to release.
438
+ - **Validate pull request titles.** A squash-merge takes its subject from the PR title, so
439
+ that title becomes the commit the notes are built from.
440
+ [`amannn/action-semantic-pull-request`](https://github.com/amannn/action-semantic-pull-request)
441
+ enforces it. Without something like it, work silently goes missing from release notes —
442
+ release-kit says how many commits are not Conventional Commits, but it cannot fix them
443
+ after the fact.
444
+
433
445
  Three things CI does that are worth knowing about:
434
446
 
435
447
  - **`fetch-depth: 0`.** The default checkout is a shallow clone, which hides the history
@@ -552,7 +564,10 @@ downgrade, so a configured pipeline fails loudly; `"auto"` degrades quietly by d
552
564
  than committed. Attribution lines (`Co-Authored-By`, `Generated with`) are stripped, so
553
565
  the tool never signs your commits.
554
566
  - **Release notes** are drafted from the commits since the last tag when `CHANGELOG.md` has
555
- no section for the version. They are written into the changelog, used as the tag
567
+ no section for the version. Each bullet ends with a link to the commits it covers: the
568
+ assistant is given the short hashes and asked to cite them, and every citation is checked
569
+ against the commits that actually exist. Models invent plausible-looking hashes, so an
570
+ unrecognised one is removed rather than published as a link to nothing. They are written into the changelog, used as the tag
556
571
  annotation, and posted as the GitHub release body — the same "written once, lands in three
557
572
  places" path a hand-written section takes.
558
573
 
@@ -621,6 +636,18 @@ jobs:
621
636
  - run: goreleaser release --clean --release-notes dist-notes.md
622
637
  ```
623
638
 
639
+ A Go project has no version file at all — the tag is the version — so it is
640
+ `"versionFile": null` and the version is passed explicitly, or inferred with `auto`:
641
+
642
+ ```sh
643
+ release-kit auto # bump from the commits, tag, push; goreleaser takes it from there
644
+ ```
645
+
646
+ **Only one of the two should write release notes.** goreleaser groups conventional commits
647
+ itself; `--release-notes` makes it use yours instead and skip its own generation. Leaving
648
+ both on means the notes in the GitHub release and the notes in your `CHANGELOG.md` are
649
+ generated by different code from the same commits, and they drift.
650
+
624
651
  **Do not leave `release` in `steps` here.** goreleaser creates the GitHub release itself; if
625
652
  release-kit has already created one for that tag, goreleaser fails. Exactly one of them
626
653
  should own it, and it should be the one attaching the binaries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@entro314labs/release-kit",
3
- "version": "2.4.0",
3
+ "version": "2.6.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
@@ -419,6 +419,7 @@ const CHANGELOG_SECTIONS = [
419
419
  { type: 'fix', section: 'Bug Fixes' },
420
420
  { type: 'perf', section: 'Performance Improvements' },
421
421
  { type: 'revert', section: 'Reverts' },
422
+ { type: 'deps', section: 'Dependencies' },
422
423
  { type: 'docs', section: 'Documentation', hidden: true },
423
424
  { type: 'style', section: 'Styles', hidden: true },
424
425
  { type: 'refactor', section: 'Code Refactoring', hidden: true },
@@ -435,7 +436,9 @@ const CHANGELOG_SECTIONS = [
435
436
  * releaseAs: string|null} | null} null when the subject is not Conventional Commits
436
437
  */
437
438
  function parseCommit(subject, body = '', hash = '') {
438
- const match = /^([a-z]+)(?:\(([^)]+)\))?(!)?: (.+)$/i.exec(subject)
439
+ // Conventional Commits does not restrict the type to letters — `i18n:` and `a11y:` are
440
+ // types people really use, and `[a-z]+` silently failed to parse them at all.
441
+ const match = /^([a-z][a-z0-9-]*)(?:\(([^)]+)\))?(!)?: (.+)$/i.exec(subject)
439
442
  if (!match) return null
440
443
  const [, type, scope, bang, text] = match
441
444
  const shortHash = hash.slice(0, 7)
@@ -519,6 +522,7 @@ function changelogFromCommits(commits, links = null) {
519
522
  lines.push('')
520
523
  }
521
524
 
525
+ const known = new Set(CHANGELOG_SECTIONS.map((s) => s.type))
522
526
  for (const { type, section, hidden } of CHANGELOG_SECTIONS) {
523
527
  if (hidden) continue
524
528
  const inSection = parsed.filter(
@@ -530,6 +534,16 @@ function changelogFromCommits(commits, links = null) {
530
534
  lines.push('')
531
535
  }
532
536
 
537
+ // A conventional type nobody anticipated — `security:`, `i18n:` — is still a change
538
+ // someone made deliberately. Dropping it silently is how a security fix goes unmentioned.
539
+ // The hidden types are excluded because hiding them is the point.
540
+ const other = parsed.filter((c) => !known.has(c.type) && c.type !== 'feature')
541
+ if (other.length) {
542
+ lines.push('### Other Changes', '')
543
+ for (const c of other) lines.push(bullet(c, c.subject))
544
+ lines.push('')
545
+ }
546
+
533
547
  return lines.length ? lines.join('\n').trim() : null
534
548
  }
535
549
 
@@ -630,8 +644,36 @@ function draftCommitMessage() {
630
644
  *
631
645
  * @returns {string | null} markdown body (no version heading), or null
632
646
  */
633
- function draftReleaseNotes(version, subjects, lastTag) {
634
- if (!subjects.length) return null
647
+ /**
648
+ * Attach commit links to a drafted bullet's citation.
649
+ *
650
+ * The model is asked to end each bullet with the short hashes it covers. Models invent
651
+ * plausible-looking hashes, so every citation is checked against the commits that actually
652
+ * exist: real ones become links, invented ones are removed rather than published.
653
+ *
654
+ * @returns {string} the notes with citations resolved
655
+ */
656
+ function linkCitedCommits(notes, commits, links) {
657
+ const known = new Set(commits.map((c) => (c.hash ?? '').slice(0, 7)).filter(Boolean))
658
+ return notes.replace(/\s*\(([0-9a-f]{7,40}(?:\s*,\s*[0-9a-f]{7,40})*)\)\s*$/gim, (_, cited) => {
659
+ const real = [
660
+ ...new Set(cited.split(/\s*,\s*/).map((h) => h.toLowerCase().slice(0, 7))),
661
+ ].filter((h) => known.has(h))
662
+ if (!real.length) return ''
663
+ const rendered = links
664
+ ? real.map((h) => `[${h}](${links.commit}/${h})`)
665
+ : real.map((h) => `\`${h}\``)
666
+ return ` (${rendered.join(', ')})`
667
+ })
668
+ }
669
+
670
+ /**
671
+ * Draft release notes from the commit log.
672
+ *
673
+ * @returns {string | null} markdown body (no version heading), or null
674
+ */
675
+ function draftReleaseNotes(version, commits, lastTag, links) {
676
+ if (!commits.length) return null
635
677
 
636
678
  const prompt = [
637
679
  `Write release notes for version ${version}.`,
@@ -640,18 +682,23 @@ function draftReleaseNotes(version, subjects, lastTag) {
640
682
  '- Group the changes under Keep a Changelog headings (`### Added`, `### Changed`,',
641
683
  ' `### Fixed`, `### Removed`), including only the headings that apply.',
642
684
  '- One bullet per user-visible change. Merge related commits into a single bullet.',
685
+ '- End every bullet with the short hashes it covers, in parentheses: `(abc1234)` or',
686
+ ' `(abc1234, def5678)` when merged. Copy them exactly from the list below and invent',
687
+ ' nothing — a hash that is not in the list will be removed.',
643
688
  '- Omit internal chores: CI, linting, formatting, dependency bumps, version bumps.',
644
689
  '- Write for someone upgrading: say what changed for them, not which files moved.',
645
690
  '- Plain, factual language. No hype, no emoji, no concluding summary.',
646
691
  '- Output only the markdown body: no version heading, no code fences, no attribution.',
647
692
  '- Do NOT explain your reasoning or add any commentary before or after the notes.',
648
693
  '',
649
- `Commit subjects since ${lastTag ?? 'the start of the project'}:`,
650
- ...subjects.map((s) => `- ${s}`),
694
+ `Commits since ${lastTag ?? 'the start of the project'}:`,
695
+ ...commits.map((c) => `- ${(c.hash ?? '').slice(0, 7)} ${c.subject}`),
651
696
  ].join('\n')
652
697
 
653
698
  const drafted = runAssistant(prompt)
654
- return drafted ? cleanNotes(drafted) : null
699
+ if (!drafted) return null
700
+ const cleaned = cleanNotes(drafted)
701
+ return cleaned ? linkCitedCommits(cleaned, commits, links) : null
655
702
  }
656
703
 
657
704
  /** One-line rendering of an argv, so a multi-line arg (release notes) stays readable. */
@@ -841,8 +888,50 @@ function changelogSection(text, version) {
841
888
  }
842
889
 
843
890
  /**
844
- * Rewrite a `## [Unreleased]` heading as the released version, and open a fresh
845
- * `## [Unreleased]` above it for the next cycle.
891
+ * Version sections that sit above a newer one.
892
+ *
893
+ * Placement only keeps a changelog tidy going forward; a file already out of order stays
894
+ * that way, and its disorder is invisible until a release lands somewhere surprising.
895
+ *
896
+ * @returns {string[]} the versions found out of order, newest-first order being expected
897
+ */
898
+ function changelogOutOfOrder(text) {
899
+ const versions = [...text.matchAll(/^## \[?v?(\d+\.\d+\.\d+(?:-[\w.]+)?)\]?/gm)].map((m) => m[1])
900
+ return versions.filter((v, i) => i > 0 && compareVersions(versions[i - 1], v) < 0)
901
+ }
902
+
903
+ /** The `## ` heading offsets in a changelog, in file order. */
904
+ const sectionOffsets = (text) => [...text.matchAll(/^## .*$/gm)].map((m) => m.index)
905
+
906
+ /**
907
+ * Place a version's section where it belongs: above the first section whose version is
908
+ * lower, rather than wherever the file happens to start.
909
+ *
910
+ * Blindly inserting at the top is correct only while the file is already newest-first. A
911
+ * changelog that drifts out of order once then stays that way, and every release makes it
912
+ * worse — which is how a released version ends up sitting between two older ones.
913
+ */
914
+ function insertChangelogSection(text, version, date, body) {
915
+ const entry = `## [${version}] - ${date}\n\n${body}\n`
916
+ for (const offset of sectionOffsets(text)) {
917
+ const heading = /^## \[?v?([\d.]+(?:-[\w.]+)?)\]?/m.exec(
918
+ text.slice(offset, text.indexOf('\n', offset)),
919
+ )
920
+ // An [Unreleased] heading has no version and always stays above the releases.
921
+ if (!heading) continue
922
+ if (compareVersions(version, heading[1]) > 0) {
923
+ return `${text.slice(0, offset)}${entry}\n${text.slice(offset)}`
924
+ }
925
+ }
926
+ const trimmed = text.trimEnd()
927
+ return `${trimmed}\n\n${entry}`
928
+ }
929
+
930
+ /**
931
+ * Promote `## [Unreleased]` to a released version and reopen an empty one above it.
932
+ *
933
+ * The section is lifted out and re-placed in version order, so a misplaced `[Unreleased]`
934
+ * does not drag the new release into the middle of the file with it.
846
935
  *
847
936
  * @returns {string | null} the updated document, or null when there is nothing to roll
848
937
  */
@@ -856,23 +945,18 @@ function rollUnreleased(text, version, date) {
856
945
  if (!match) return null
857
946
 
858
947
  // An empty [Unreleased] has nothing to promote; rolling it produces an empty section.
859
- const rest = text.slice(match.index + match[0].length)
860
- const next = /^## /m.exec(rest)
861
- const body = (next ? rest.slice(0, next.index) : rest).trim()
948
+ const after = text.slice(match.index + match[0].length)
949
+ const next = /^## /m.exec(after)
950
+ const body = (next ? after.slice(0, next.index) : after).trim()
862
951
  if (!body) return null
863
- const released = `## [Unreleased]\n\n## [${version}] - ${date}`
864
- return text.slice(0, match.index) + released + text.slice(match.index + match[0].length)
865
- }
866
952
 
867
- /**
868
- * Insert a section for a version above the newest existing one, so drafted notes are kept
869
- * in the changelog rather than only reaching the tag and the GitHub release.
870
- */
871
- function insertChangelogSection(text, version, date, body) {
872
- const entry = `## [${version}] - ${date}\n\n${body}\n`
873
- const firstSection = /^## /m.exec(text)
874
- if (!firstSection) return `${text.trimEnd()}\n\n${entry}`
875
- return `${text.slice(0, firstSection.index)}${entry}\n${text.slice(firstSection.index)}`
953
+ // Remove the section wherever it sits, then place the release by version.
954
+ const withoutUnreleased = text.slice(0, match.index) + (next ? after.slice(next.index) : '')
955
+ const placed = insertChangelogSection(withoutUnreleased.trimEnd() + '\n', version, date, body)
956
+
957
+ // A fresh [Unreleased] belongs above every release, whatever the file looked like before.
958
+ const first = sectionOffsets(placed)[0] ?? placed.length
959
+ return `${placed.slice(0, first)}## [Unreleased]\n\n${placed.slice(first)}`
876
960
  }
877
961
 
878
962
  // ─────────────────────────────────────────────────────────────────────────────
@@ -1188,7 +1272,20 @@ if (versionFile && !existsSync(versionFile.path)) {
1188
1272
  abort(`versionFile ${versionFile.path} does not exist`)
1189
1273
  }
1190
1274
 
1191
- const currentVersion = versionFile ? readVersionFrom(versionFile) : null
1275
+ /**
1276
+ * Where a repository versions by tag alone — a Go module, a docs site — the latest tag is
1277
+ * the current version. Without this, `auto` and every bump have nothing to work from and
1278
+ * the version has to be typed out in full every time.
1279
+ */
1280
+ function versionFromLastTag() {
1281
+ const tag = tryRead('git', ['describe', '--tags', '--abbrev=0'])
1282
+ if (!tag) return null
1283
+ const bare =
1284
+ config.tagPrefix && tag.startsWith(config.tagPrefix) ? tag.slice(config.tagPrefix.length) : tag
1285
+ return parseVersion(bare) ? bare : null
1286
+ }
1287
+
1288
+ const currentVersion = versionFile ? readVersionFrom(versionFile) : versionFromLastTag()
1192
1289
  if (versionFile && !currentVersion) {
1193
1290
  abort(`could not read a version from ${versionFile.path}`)
1194
1291
  }
@@ -1280,14 +1377,17 @@ let version
1280
1377
  if (!target) {
1281
1378
  if (!currentVersion) {
1282
1379
  abort(
1283
- 'this repository has no versionFile, so there is no version to default to.\n' +
1284
- ' Pass one explicitly: release-kit 1.2.3',
1380
+ 'nothing to default to: this repository has no versionFile and no tag to read a ' +
1381
+ 'version from.\n Pass one explicitly: release-kit 1.2.3',
1285
1382
  )
1286
1383
  }
1287
1384
  version = currentVersion
1288
1385
  } else if (target === 'auto') {
1289
1386
  if (!currentVersion) {
1290
- abort('auto needs a versionFile to bump from. Pass a version explicitly instead.')
1387
+ abort(
1388
+ 'auto has nothing to bump from: there is no versionFile and no tag to read a version ' +
1389
+ 'from.\n Pass the first version explicitly: release-kit 0.1.0',
1390
+ )
1291
1391
  }
1292
1392
  const { commits, lastTag } = commitsSinceLastTag()
1293
1393
  if (!commits.length) {
@@ -1310,7 +1410,10 @@ if (!target) {
1310
1410
  }
1311
1411
  } else if (BUMPS.has(target)) {
1312
1412
  if (!currentVersion) {
1313
- abort(`a ${target} bump needs a versionFile to bump from. Pass a version explicitly instead.`)
1413
+ abort(
1414
+ `a ${target} bump has nothing to bump from: no versionFile, and no tag to read a ` +
1415
+ 'version from.\n Pass the version explicitly instead.',
1416
+ )
1314
1417
  }
1315
1418
  const preid = requestedPreid ?? preidOf(currentVersion)
1316
1419
  if (target.startsWith('pre') && !preid) {
@@ -1616,6 +1719,18 @@ if (!publishCommand) {
1616
1719
  }
1617
1720
  }
1618
1721
 
1722
+ /** Say so when the changelog is not newest-first, since placement cannot repair it. */
1723
+ function reportChangelogOrder(text) {
1724
+ const misplaced = changelogOutOfOrder(text)
1725
+ if (misplaced.length) {
1726
+ warn(
1727
+ `${config.changelog} is not in newest-first order (${misplaced.slice(0, 3).join(', ')}` +
1728
+ `${misplaced.length > 3 ? ', …' : ''} sit above a newer version).\n` +
1729
+ ' New sections are placed correctly, but the existing order is left alone.',
1730
+ )
1731
+ }
1732
+ }
1733
+
1619
1734
  // Notes: the changelog section for this version, else a draft, else GitHub generates them.
1620
1735
  let notes = null
1621
1736
  let rolledChangelog = null
@@ -1635,6 +1750,17 @@ const notesDeferred = !!(dirty && runs('commit') && assistant)
1635
1750
  function draftNotesFor(v) {
1636
1751
  const { lastTag, subjects, commits } = commitsSinceLastTag()
1637
1752
  if (!commits.length) return null
1753
+
1754
+ // Notes are built from Conventional Commits, so anything not written that way is simply
1755
+ // absent from them. A squash-merge takes its subject from the pull request title, which
1756
+ // is where this usually goes wrong — and silently, since the release still succeeds.
1757
+ const unconventional = commits.filter((c) => !parseCommit(c.subject, c.body, c.hash)).length
1758
+ if (unconventional) {
1759
+ warn(
1760
+ `${unconventional} of ${commits.length} commit(s) are not Conventional Commits, so they ` +
1761
+ 'will not appear in the notes.',
1762
+ )
1763
+ }
1638
1764
  if (!assistant) return changelogFromCommits(commits, remoteLinks(config.remote))
1639
1765
  if (shallow) {
1640
1766
  warn(
@@ -1644,7 +1770,7 @@ function draftNotesFor(v) {
1644
1770
  }
1645
1771
  note(`drafting notes from ${subjects.length} commit(s) with ${assistantName}...`)
1646
1772
  return (
1647
- draftReleaseNotes(v, subjects, lastTag) ??
1773
+ draftReleaseNotes(v, commits, lastTag, remoteLinks(config.remote)) ??
1648
1774
  changelogFromCommits(commits, remoteLinks(config.remote))
1649
1775
  )
1650
1776
  }
@@ -1653,11 +1779,13 @@ if (config.changelog && existsSync(config.changelog)) {
1653
1779
  notes = changelogSection(text, version)
1654
1780
  if (notes) {
1655
1781
  ok(`${config.changelog} has a ${version} section`)
1782
+ reportChangelogOrder(text)
1656
1783
  } else {
1657
1784
  rolledChangelog = rollUnreleased(text, version, new Date().toISOString().slice(0, 10))
1658
1785
  if (rolledChangelog) {
1659
1786
  notes = changelogSection(rolledChangelog, version)
1660
1787
  ok(`${config.changelog}: [Unreleased] will become [${version}]`)
1788
+ reportChangelogOrder(text)
1661
1789
  } else {
1662
1790
  draftedNotes = notesDeferred ? null : draftNotesFor(version)
1663
1791
  if (notesDeferred) {