@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.
- package/README.md +16 -1
- package/package.json +1 -1
- 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.
|
|
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.
|
|
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
|
-
|
|
648
|
-
|
|
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
|
-
`
|
|
664
|
-
...
|
|
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
|
-
|
|
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
|
-
*
|
|
859
|
-
*
|
|
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
|
|
874
|
-
const next = /^## /m.exec(
|
|
875
|
-
const body = (next ?
|
|
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
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
const
|
|
887
|
-
|
|
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,
|
|
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) {
|