@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.
- package/README.md +28 -1
- package/package.json +1 -1
- 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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
634
|
-
|
|
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
|
-
`
|
|
650
|
-
...
|
|
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
|
-
|
|
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
|
-
*
|
|
845
|
-
*
|
|
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
|
|
860
|
-
const next = /^## /m.exec(
|
|
861
|
-
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()
|
|
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
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
const
|
|
873
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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,
|
|
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) {
|