@entro314labs/release-kit 2.5.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 +16 -1
  2. package/package.json +1 -1
  3. package/release.mjs +118 -23
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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@entro314labs/release-kit",
3
- "version": "2.5.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
@@ -644,8 +644,36 @@ function draftCommitMessage() {
644
644
  *
645
645
  * @returns {string | null} markdown body (no version heading), or null
646
646
  */
647
- function draftReleaseNotes(version, subjects, lastTag) {
648
- 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
649
677
 
650
678
  const prompt = [
651
679
  `Write release notes for version ${version}.`,
@@ -654,18 +682,23 @@ function draftReleaseNotes(version, subjects, lastTag) {
654
682
  '- Group the changes under Keep a Changelog headings (`### Added`, `### Changed`,',
655
683
  ' `### Fixed`, `### Removed`), including only the headings that apply.',
656
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.',
657
688
  '- Omit internal chores: CI, linting, formatting, dependency bumps, version bumps.',
658
689
  '- Write for someone upgrading: say what changed for them, not which files moved.',
659
690
  '- Plain, factual language. No hype, no emoji, no concluding summary.',
660
691
  '- Output only the markdown body: no version heading, no code fences, no attribution.',
661
692
  '- Do NOT explain your reasoning or add any commentary before or after the notes.',
662
693
  '',
663
- `Commit subjects since ${lastTag ?? 'the start of the project'}:`,
664
- ...subjects.map((s) => `- ${s}`),
694
+ `Commits since ${lastTag ?? 'the start of the project'}:`,
695
+ ...commits.map((c) => `- ${(c.hash ?? '').slice(0, 7)} ${c.subject}`),
665
696
  ].join('\n')
666
697
 
667
698
  const drafted = runAssistant(prompt)
668
- return drafted ? cleanNotes(drafted) : null
699
+ if (!drafted) return null
700
+ const cleaned = cleanNotes(drafted)
701
+ return cleaned ? linkCitedCommits(cleaned, commits, links) : null
669
702
  }
670
703
 
671
704
  /** One-line rendering of an argv, so a multi-line arg (release notes) stays readable. */
@@ -855,8 +888,50 @@ function changelogSection(text, version) {
855
888
  }
856
889
 
857
890
  /**
858
- * Rewrite a `## [Unreleased]` heading as the released version, and open a fresh
859
- * `## [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.
860
935
  *
861
936
  * @returns {string | null} the updated document, or null when there is nothing to roll
862
937
  */
@@ -870,23 +945,18 @@ function rollUnreleased(text, version, date) {
870
945
  if (!match) return null
871
946
 
872
947
  // An empty [Unreleased] has nothing to promote; rolling it produces an empty section.
873
- const rest = text.slice(match.index + match[0].length)
874
- const next = /^## /m.exec(rest)
875
- 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()
876
951
  if (!body) return null
877
- const released = `## [Unreleased]\n\n## [${version}] - ${date}`
878
- return text.slice(0, match.index) + released + text.slice(match.index + match[0].length)
879
- }
880
952
 
881
- /**
882
- * Insert a section for a version above the newest existing one, so drafted notes are kept
883
- * in the changelog rather than only reaching the tag and the GitHub release.
884
- */
885
- function insertChangelogSection(text, version, date, body) {
886
- const entry = `## [${version}] - ${date}\n\n${body}\n`
887
- const firstSection = /^## /m.exec(text)
888
- if (!firstSection) return `${text.trimEnd()}\n\n${entry}`
889
- 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)}`
890
960
  }
891
961
 
892
962
  // ─────────────────────────────────────────────────────────────────────────────
@@ -1649,6 +1719,18 @@ if (!publishCommand) {
1649
1719
  }
1650
1720
  }
1651
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
+
1652
1734
  // Notes: the changelog section for this version, else a draft, else GitHub generates them.
1653
1735
  let notes = null
1654
1736
  let rolledChangelog = null
@@ -1668,6 +1750,17 @@ const notesDeferred = !!(dirty && runs('commit') && assistant)
1668
1750
  function draftNotesFor(v) {
1669
1751
  const { lastTag, subjects, commits } = commitsSinceLastTag()
1670
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
+ }
1671
1764
  if (!assistant) return changelogFromCommits(commits, remoteLinks(config.remote))
1672
1765
  if (shallow) {
1673
1766
  warn(
@@ -1677,7 +1770,7 @@ function draftNotesFor(v) {
1677
1770
  }
1678
1771
  note(`drafting notes from ${subjects.length} commit(s) with ${assistantName}...`)
1679
1772
  return (
1680
- draftReleaseNotes(v, subjects, lastTag) ??
1773
+ draftReleaseNotes(v, commits, lastTag, remoteLinks(config.remote)) ??
1681
1774
  changelogFromCommits(commits, remoteLinks(config.remote))
1682
1775
  )
1683
1776
  }
@@ -1686,11 +1779,13 @@ if (config.changelog && existsSync(config.changelog)) {
1686
1779
  notes = changelogSection(text, version)
1687
1780
  if (notes) {
1688
1781
  ok(`${config.changelog} has a ${version} section`)
1782
+ reportChangelogOrder(text)
1689
1783
  } else {
1690
1784
  rolledChangelog = rollUnreleased(text, version, new Date().toISOString().slice(0, 10))
1691
1785
  if (rolledChangelog) {
1692
1786
  notes = changelogSection(rolledChangelog, version)
1693
1787
  ok(`${config.changelog}: [Unreleased] will become [${version}]`)
1788
+ reportChangelogOrder(text)
1694
1789
  } else {
1695
1790
  draftedNotes = notesDeferred ? null : draftNotesFor(version)
1696
1791
  if (notesDeferred) {