@entro314labs/release-kit 2.3.2 → 2.4.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 +129 -2
- package/package.json +5 -3
- package/release.mjs +232 -39
package/README.md
CHANGED
|
@@ -55,6 +55,7 @@ Released v2.5.0
|
|
|
55
55
|
| [📦 Install](#-install) | package, `npx`, or vendored file |
|
|
56
56
|
| [⚡ Usage](#-usage) | targets, bumps, flags |
|
|
57
57
|
| [🧩 Steps](#-steps) | the seven steps and how to select them |
|
|
58
|
+
| [📚 Libraries versus apps](#-libraries-versus-apps) | which steps you want, and why |
|
|
58
59
|
| [🤖 Assistant](#-assistant-optional) | optional AI drafting |
|
|
59
60
|
| [🌍 Any language](#-any-language) | Rust, Python, tag-only, anything |
|
|
60
61
|
| [✅ Preflight](#-preflight) | what is checked before anything mutates |
|
|
@@ -231,8 +232,11 @@ Notes resolve in this order:
|
|
|
231
232
|
2. The `## [Unreleased]` section, if the version has no section of its own — this is the
|
|
232
233
|
same content that step 2 above is about to promote.
|
|
233
234
|
3. The commits grouped by Conventional Commit type — Features, Bug Fixes, Performance
|
|
234
|
-
Improvements, Reverts, with breaking changes first and chores, CI and docs hidden.
|
|
235
|
-
|
|
235
|
+
Improvements, Reverts, with breaking changes first and chores, CI and docs hidden. Each
|
|
236
|
+
bullet links to its commit, and `closes #12` / `fixes #34` in a message becomes a link to
|
|
237
|
+
the issue. A `BREAKING CHANGE:` footer is used in place of the subject, since it explains
|
|
238
|
+
the break. A commit reverted within the same release drops out along with its revert.
|
|
239
|
+
All deterministic and needing nothing installed, so decent notes are the default rather
|
|
236
240
|
than something that requires an assistant.
|
|
237
241
|
4. Otherwise GitHub generates them from the commits since the previous tag.
|
|
238
242
|
|
|
@@ -323,6 +327,11 @@ Anything else runs as written with no preflight. The project name comes from the
|
|
|
323
327
|
`name` in `package.json`, `Cargo.toml` or `pyproject.toml`, `module` in `go.mod` — falling
|
|
324
328
|
back to the repository directory.
|
|
325
329
|
|
|
330
|
+
`publish` is detected too, but only where one ecosystem obviously owns it: `package.json`
|
|
331
|
+
gets `npm publish`, `Cargo.toml` gets `cargo publish`. Python has several publishers (uv,
|
|
332
|
+
twine, poetry, flit) and Go has none, so those get nothing rather than a guess — publishing
|
|
333
|
+
to the wrong registry is a far worse failure than being asked to configure it.
|
|
334
|
+
|
|
326
335
|
With no `versionFile` configured it is detected from the repository — `package.json`,
|
|
327
336
|
`pyproject.toml`, `Cargo.toml`, then `VERSION` — so most projects need no config for it at
|
|
328
337
|
all. Set it explicitly to override, or to `null` for a repository that versions by tag.
|
|
@@ -333,6 +342,11 @@ the version. Only the version itself is rewritten, so comments and formatting su
|
|
|
333
342
|
because the TOML match is anchored to the start of a line, a dependency's
|
|
334
343
|
`serde = { version = "1.0" }` is left alone.
|
|
335
344
|
|
|
345
|
+
**Lockfiles are scoped automatically.** A `Cargo.lock` records a version for every
|
|
346
|
+
dependency — hundreds of them — so matching the first `version = "…"` would rewrite an
|
|
347
|
+
unrelated crate. Listing one rewrites only the `[[package]]` block whose name matches the
|
|
348
|
+
crate in the sibling `Cargo.toml`; with no sibling to read, it refuses rather than guesses.
|
|
349
|
+
|
|
336
350
|
For anything else, give a pattern with one capture group around the version. `versionFiles`
|
|
337
351
|
takes the same entries, so several files stay in sync across formats:
|
|
338
352
|
|
|
@@ -343,6 +357,29 @@ takes the same entries, so several files stay in sync across formats:
|
|
|
343
357
|
}
|
|
344
358
|
```
|
|
345
359
|
|
|
360
|
+
A desktop app usually carries the same version in a lot of places at once — a workspace
|
|
361
|
+
manifest, per-platform bundle configs, a crate manifest and its lockfile. They stay in step
|
|
362
|
+
in one release, across three formats, with no scripting:
|
|
363
|
+
|
|
364
|
+
```json
|
|
365
|
+
{
|
|
366
|
+
"versionFiles": [
|
|
367
|
+
"apps/desktop/package.json",
|
|
368
|
+
"apps/desktop/src-tauri/tauri.conf.json",
|
|
369
|
+
"apps/desktop/src-tauri/tauri.macos.conf.json",
|
|
370
|
+
"apps/desktop/src-tauri/tauri.windows.conf.json",
|
|
371
|
+
"apps/desktop/src-tauri/tauri.linux.conf.json",
|
|
372
|
+
"apps/desktop/src-tauri/Cargo.toml",
|
|
373
|
+
"apps/desktop/src-tauri/Cargo.lock"
|
|
374
|
+
],
|
|
375
|
+
"publish": null,
|
|
376
|
+
"steps": ["version", "changelog", "tag", "push"]
|
|
377
|
+
}
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
Stopping at `push` because the tag is what triggers the build pipeline — see
|
|
381
|
+
[Libraries versus apps](#-libraries-versus-apps).
|
|
382
|
+
|
|
346
383
|
The project name comes from the manifest when there is one (`name` in `package.json`,
|
|
347
384
|
`Cargo.toml` or `pyproject.toml`), and falls back to the repository directory.
|
|
348
385
|
|
|
@@ -528,6 +565,80 @@ One row in `ASSISTANTS` in `release.mjs`: the command, the args that make it rea
|
|
|
528
565
|
stdin, and how it spells model and effort. Tools whose stdout carries session scaffolding
|
|
529
566
|
declare `outputFile` and the answer is read from there instead.
|
|
530
567
|
|
|
568
|
+
## 📚 Libraries versus apps
|
|
569
|
+
|
|
570
|
+
Publishing splits in two, and which one you are decides the `steps` you want.
|
|
571
|
+
|
|
572
|
+
**A library, package or tool publishes its source.** The registry receives what is already
|
|
573
|
+
in the repository — `npm publish`, `cargo publish`, `uv publish` — and there is nothing to
|
|
574
|
+
build first. release-kit does the whole thing:
|
|
575
|
+
|
|
576
|
+
```json
|
|
577
|
+
{}
|
|
578
|
+
```
|
|
579
|
+
|
|
580
|
+
Defaults are already correct: version → changelog → tag → push → publish → release.
|
|
581
|
+
|
|
582
|
+
**An app has to be built before anything can be published.** Binaries, installers, bundles,
|
|
583
|
+
container images: the artifact does not exist until something makes it. That build belongs
|
|
584
|
+
to a build tool, and it changes where release-kit stops.
|
|
585
|
+
|
|
586
|
+
### Apps with a simple build
|
|
587
|
+
|
|
588
|
+
If the build runs before the release and leaves files on disk, release-kit can attach them
|
|
589
|
+
itself. Nothing else is needed:
|
|
590
|
+
|
|
591
|
+
```json
|
|
592
|
+
{ "assets": ["dist/app-macos.zip", "dist/app-linux.tar.gz"], "publish": null }
|
|
593
|
+
```
|
|
594
|
+
|
|
595
|
+
Preflight fails if a listed asset is missing, so a release cannot quietly ship without its
|
|
596
|
+
binaries.
|
|
597
|
+
|
|
598
|
+
### Apps with a real build pipeline
|
|
599
|
+
|
|
600
|
+
goreleaser, cargo-dist and electron-builder build for many targets and create the GitHub
|
|
601
|
+
release themselves, with the artifacts attached. That is their job. release-kit's work ends
|
|
602
|
+
at the pushed tag:
|
|
603
|
+
|
|
604
|
+
```json
|
|
605
|
+
{ "steps": ["version", "changelog", "tag", "push"], "notesFile": "dist-notes.md" }
|
|
606
|
+
```
|
|
607
|
+
|
|
608
|
+
Nothing after `push` — no `publish`, no `release`. The tag push is the handoff, and it is
|
|
609
|
+
what triggers the build workflow:
|
|
610
|
+
|
|
611
|
+
```yaml
|
|
612
|
+
on:
|
|
613
|
+
push:
|
|
614
|
+
tags: ['v*']
|
|
615
|
+
|
|
616
|
+
jobs:
|
|
617
|
+
build:
|
|
618
|
+
steps:
|
|
619
|
+
- uses: actions/checkout@v5
|
|
620
|
+
with: { fetch-depth: 0 }
|
|
621
|
+
- run: goreleaser release --clean --release-notes dist-notes.md
|
|
622
|
+
```
|
|
623
|
+
|
|
624
|
+
**Do not leave `release` in `steps` here.** goreleaser creates the GitHub release itself; if
|
|
625
|
+
release-kit has already created one for that tag, goreleaser fails. Exactly one of them
|
|
626
|
+
should own it, and it should be the one attaching the binaries.
|
|
627
|
+
|
|
628
|
+
`notesFile` exists for this handoff: goreleaser's `--release-notes` takes a file and skips
|
|
629
|
+
its own changelog generation. The notes are also in the annotated tag, but reading them back
|
|
630
|
+
with `git tag --format='%(contents)'` embeds the signature when tags are signed, which then
|
|
631
|
+
appears in your published release notes. `notesFile` writes the text itself.
|
|
632
|
+
|
|
633
|
+
### Both at once
|
|
634
|
+
|
|
635
|
+
A project can be both — a Rust crate that also ships binaries, say. Publish the library from
|
|
636
|
+
release-kit and let the build tool handle the binaries and the release:
|
|
637
|
+
|
|
638
|
+
```json
|
|
639
|
+
{ "publish": "cargo publish", "steps": ["version", "changelog", "tag", "push", "publish"] }
|
|
640
|
+
```
|
|
641
|
+
|
|
531
642
|
## 🔄 Keeping vendored copies in sync
|
|
532
643
|
|
|
533
644
|
Installed as a dependency, updates come from your package manager and there is nothing to
|
|
@@ -552,8 +663,24 @@ including a directory that is not a repository.
|
|
|
552
663
|
- Whatever the `publish` command needs — for the default, a live `npm login` session
|
|
553
664
|
(two hours) or an OIDC trusted-publishing environment
|
|
554
665
|
|
|
666
|
+
## 🗺 Roadmap
|
|
667
|
+
|
|
668
|
+
Known defects, missing infrastructure, and the ideas that were considered and declined —
|
|
669
|
+
with the reasoning — are in [ROADMAP.md](ROADMAP.md).
|
|
670
|
+
|
|
555
671
|
## 🤝 Contributing
|
|
556
672
|
|
|
673
|
+
```sh
|
|
674
|
+
pnpm install
|
|
675
|
+
pnpm test # 63 tests, node --test, no framework
|
|
676
|
+
pnpm check # format + lint + tests, the same gate CI runs
|
|
677
|
+
```
|
|
678
|
+
|
|
679
|
+
`test/` holds unit suites for the pure functions and an integration suite that builds real
|
|
680
|
+
throwaway repositories with a real bare remote and stubbed `gh`/`npm`. The integration tests
|
|
681
|
+
pin defects found in use, so a name like "refuses to reuse a tag while still producing a
|
|
682
|
+
commit" is describing something that actually happened.
|
|
683
|
+
|
|
557
684
|
The tool releases itself, so a change ships the same way it would in any consuming project:
|
|
558
685
|
add a `## [Unreleased]` entry to `CHANGELOG.md`, then run `pnpm release <bump>` from a clone.
|
|
559
686
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@entro314labs/release-kit",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.4.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",
|
|
@@ -41,12 +41,14 @@
|
|
|
41
41
|
"format:check": "oxfmt --check .",
|
|
42
42
|
"lint": "oxlint .",
|
|
43
43
|
"lint:ci": "oxlint --deny-warnings .",
|
|
44
|
-
"
|
|
44
|
+
"test": "node --test \"test/**/*.test.mjs\"",
|
|
45
|
+
"check": "pnpm run format:check && pnpm run lint:ci && pnpm run test",
|
|
45
46
|
"release": "node release.mjs"
|
|
46
47
|
},
|
|
47
48
|
"devDependencies": {
|
|
48
49
|
"oxfmt": "^0.63.0",
|
|
49
|
-
"oxlint": "^1.78.0"
|
|
50
|
+
"oxlint": "^1.78.0",
|
|
51
|
+
"semver": "^7.8.5"
|
|
50
52
|
},
|
|
51
53
|
"engines": {
|
|
52
54
|
"node": ">=22"
|
package/release.mjs
CHANGED
|
@@ -41,7 +41,7 @@ import {
|
|
|
41
41
|
writeFileSync,
|
|
42
42
|
} from 'node:fs'
|
|
43
43
|
import { homedir, tmpdir } from 'node:os'
|
|
44
|
-
import { basename, join, relative, resolve, sep } from 'node:path'
|
|
44
|
+
import { basename, dirname, join, relative, resolve, sep } from 'node:path'
|
|
45
45
|
import { createInterface } from 'node:readline/promises'
|
|
46
46
|
|
|
47
47
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -62,10 +62,13 @@ import { createInterface } from 'node:readline/promises'
|
|
|
62
62
|
* the repository when unset; null when it versions by tag alone
|
|
63
63
|
* versionFiles array further files whose version is kept in sync; each is a path
|
|
64
64
|
* or { path, pattern }
|
|
65
|
-
* publish string publish command
|
|
65
|
+
* publish string publish command. Detected from the version source when unset,
|
|
66
|
+
* and only where it is unambiguous; null to publish nothing
|
|
66
67
|
* commitMessage string release commit subject
|
|
67
68
|
* releaseTitle string GitHub release title
|
|
68
69
|
* assets string[] files attached to the GitHub release
|
|
70
|
+
* notesFile string write the resolved release notes here, for a build tool that
|
|
71
|
+
* takes them as a file (goreleaser --release-notes, and similar)
|
|
69
72
|
* versioning string how `auto` derives a bump: "conventional", or
|
|
70
73
|
* always-patch / always-minor / always-major to never infer
|
|
71
74
|
* assistant string|object drafting CLI for commit messages and notes. A key of
|
|
@@ -102,11 +105,12 @@ const DEFAULTS = {
|
|
|
102
105
|
changelog: 'CHANGELOG.md',
|
|
103
106
|
versionFile: undefined,
|
|
104
107
|
versionFiles: [],
|
|
105
|
-
publish:
|
|
108
|
+
publish: undefined,
|
|
106
109
|
commitMessage: 'chore(release): %t',
|
|
107
110
|
releaseTitle: '%t',
|
|
108
111
|
assets: [],
|
|
109
112
|
assistant: null,
|
|
113
|
+
notesFile: null,
|
|
110
114
|
versioning: 'conventional',
|
|
111
115
|
}
|
|
112
116
|
|
|
@@ -148,6 +152,7 @@ Flags:
|
|
|
148
152
|
--dist-tag <name> override the npm dist-tag (default: derived from the version)
|
|
149
153
|
--dry-run print every step and execute nothing
|
|
150
154
|
--yes, -y skip the confirmation prompt
|
|
155
|
+
--notes-file <path> write the resolved release notes to a file for the next tool
|
|
151
156
|
--assistant <name> drafting CLI to use: auto, none, claude, codex
|
|
152
157
|
--assistant-model <name>
|
|
153
158
|
model the assistant runs with (e.g. sonnet, opus)
|
|
@@ -381,14 +386,16 @@ function commitsSinceLastTag() {
|
|
|
381
386
|
const range = lastTag ? `${lastTag}..HEAD` : 'HEAD'
|
|
382
387
|
// %B is the whole message: bodies carry BREAKING CHANGE and Release-As footers. A record
|
|
383
388
|
// separator keeps multi-line messages parseable when splitting the log back apart.
|
|
384
|
-
|
|
389
|
+
// %h first, then the message: the hash is what links each bullet back to its commit.
|
|
390
|
+
const raw = tryRead('git', ['log', `--format=%h%x1f%B%x1e`, range]) ?? ''
|
|
385
391
|
const commits = raw
|
|
386
|
-
.split('\
|
|
392
|
+
.split('\u001E')
|
|
387
393
|
.map((entry) => entry.trim())
|
|
388
394
|
.filter(Boolean)
|
|
389
395
|
.map((entry) => {
|
|
390
|
-
const [
|
|
391
|
-
|
|
396
|
+
const [hash, message = ''] = entry.split('\u001F')
|
|
397
|
+
const [subject, ...rest] = message.split('\n')
|
|
398
|
+
return { hash: hash.trim(), subject: subject.trim(), body: rest.join('\n').trim() }
|
|
392
399
|
})
|
|
393
400
|
.filter(
|
|
394
401
|
({ subject }) =>
|
|
@@ -398,7 +405,8 @@ function commitsSinceLastTag() {
|
|
|
398
405
|
!/^wip\b/i.test(subject) &&
|
|
399
406
|
!/^(fixup|squash)!/.test(subject),
|
|
400
407
|
)
|
|
401
|
-
|
|
408
|
+
const kept = withoutRevertedCommits(commits)
|
|
409
|
+
return { lastTag, commits: kept, subjects: kept.map((c) => c.subject) }
|
|
402
410
|
}
|
|
403
411
|
|
|
404
412
|
/**
|
|
@@ -426,16 +434,31 @@ const CHANGELOG_SECTIONS = [
|
|
|
426
434
|
* @returns {{type: string, scope: string|null, breaking: boolean, subject: string,
|
|
427
435
|
* releaseAs: string|null} | null} null when the subject is not Conventional Commits
|
|
428
436
|
*/
|
|
429
|
-
function parseCommit(subject, body = '') {
|
|
437
|
+
function parseCommit(subject, body = '', hash = '') {
|
|
430
438
|
const match = /^([a-z]+)(?:\(([^)]+)\))?(!)?: (.+)$/i.exec(subject)
|
|
431
439
|
if (!match) return null
|
|
432
440
|
const [, type, scope, bang, text] = match
|
|
441
|
+
const shortHash = hash.slice(0, 7)
|
|
433
442
|
// A breaking change is marked either by `!` in the header or a BREAKING CHANGE footer.
|
|
434
443
|
const breaking = !!bang || /^BREAKING[ -]CHANGE:/m.test(body)
|
|
435
444
|
// `Release-As: 1.2.3` in a commit body pins the next version, so the decision can live
|
|
436
445
|
// in git history rather than on the command line.
|
|
437
446
|
const releaseAs = /^Release-As:\s*v?(\S+)/im.exec(body)?.[1] ?? null
|
|
438
|
-
|
|
447
|
+
// Issues this commit closes, so the notes can link them the way a reader expects.
|
|
448
|
+
const closes = [...`${subject}\n${body}`.matchAll(CLOSES)].map((m) => m[1])
|
|
449
|
+
// A BREAKING CHANGE footer usually explains the break far better than the subject does.
|
|
450
|
+
const breakingNote =
|
|
451
|
+
/^BREAKING[ -]CHANGE:\s*([\s\S]+?)(?=\n\n|$)/m.exec(body)?.[1]?.trim() ?? null
|
|
452
|
+
return {
|
|
453
|
+
type: type.toLowerCase(),
|
|
454
|
+
scope: scope ?? null,
|
|
455
|
+
breaking,
|
|
456
|
+
subject: text,
|
|
457
|
+
hash: shortHash,
|
|
458
|
+
releaseAs,
|
|
459
|
+
closes: [...new Set(closes)],
|
|
460
|
+
breakingNote,
|
|
461
|
+
}
|
|
439
462
|
}
|
|
440
463
|
|
|
441
464
|
/**
|
|
@@ -448,7 +471,7 @@ function parseCommit(subject, body = '') {
|
|
|
448
471
|
* @returns {{bump: string, breaking: string[], features: string[], releaseAs: string|null}}
|
|
449
472
|
*/
|
|
450
473
|
function inferBump(commits, currentVersion, strategy = 'conventional') {
|
|
451
|
-
const parsed = commits.map((c) => parseCommit(c.subject, c.body)).filter(Boolean)
|
|
474
|
+
const parsed = commits.map((c) => parseCommit(c.subject, c.body, c.hash)).filter(Boolean)
|
|
452
475
|
const releaseAs = parsed.find((c) => c.releaseAs)?.releaseAs ?? null
|
|
453
476
|
const breaking = parsed.filter((c) => c.breaking).map((c) => c.subject)
|
|
454
477
|
const features = parsed
|
|
@@ -472,14 +495,27 @@ function inferBump(commits, currentVersion, strategy = 'conventional') {
|
|
|
472
495
|
*
|
|
473
496
|
* @returns {string | null} markdown body, or null when nothing visible changed
|
|
474
497
|
*/
|
|
475
|
-
function changelogFromCommits(commits) {
|
|
476
|
-
const parsed = commits.map((c) => parseCommit(c.subject, c.body)).filter(Boolean)
|
|
498
|
+
function changelogFromCommits(commits, links = null) {
|
|
499
|
+
const parsed = commits.map((c) => parseCommit(c.subject, c.body, c.hash)).filter(Boolean)
|
|
477
500
|
const lines = []
|
|
478
501
|
|
|
502
|
+
/** One bullet: scope, text, a link to the commit, and any issues it closes. */
|
|
503
|
+
const bullet = (commit, text) => {
|
|
504
|
+
const scope = commit.scope ? `**${commit.scope}:** ` : ''
|
|
505
|
+
const parts = []
|
|
506
|
+
if (links && commit.hash) parts.push(`([${commit.hash}](${links.commit}/${commit.hash}))`)
|
|
507
|
+
if (commit.closes.length) {
|
|
508
|
+
const issues = commit.closes.map((n) => (links ? `[#${n}](${links.issue}/${n})` : `#${n}`))
|
|
509
|
+
parts.push(`closes ${issues.join(', ')}`)
|
|
510
|
+
}
|
|
511
|
+
return `- ${scope}${text}${parts.length ? ` ${parts.join(', ')}` : ''}`
|
|
512
|
+
}
|
|
513
|
+
|
|
479
514
|
const breaking = parsed.filter((c) => c.breaking)
|
|
480
515
|
if (breaking.length) {
|
|
481
516
|
lines.push('### ⚠ BREAKING CHANGES', '')
|
|
482
|
-
|
|
517
|
+
// The footer explains the break; the subject only says what changed.
|
|
518
|
+
for (const c of breaking) lines.push(bullet(c, c.breakingNote ?? c.subject))
|
|
483
519
|
lines.push('')
|
|
484
520
|
}
|
|
485
521
|
|
|
@@ -490,13 +526,68 @@ function changelogFromCommits(commits) {
|
|
|
490
526
|
)
|
|
491
527
|
if (!inSection.length) continue
|
|
492
528
|
lines.push(`### ${section}`, '')
|
|
493
|
-
for (const c of inSection) lines.push(
|
|
529
|
+
for (const c of inSection) lines.push(bullet(c, c.subject))
|
|
494
530
|
lines.push('')
|
|
495
531
|
}
|
|
496
532
|
|
|
497
533
|
return lines.length ? lines.join('\n').trim() : null
|
|
498
534
|
}
|
|
499
535
|
|
|
536
|
+
/**
|
|
537
|
+
* Per-forge URL shapes and the words that close an issue, following
|
|
538
|
+
* @semantic-release/release-notes-generator's hosts-config. The path segments genuinely
|
|
539
|
+
* differ: Bitbucket uses /issue/ and /commits/ where GitHub uses /issues/ and /commit/.
|
|
540
|
+
*/
|
|
541
|
+
const HOSTS = {
|
|
542
|
+
'github.com': { issue: 'issues', commit: 'commit' },
|
|
543
|
+
'gitlab.com': { issue: 'issues', commit: 'commit' },
|
|
544
|
+
'bitbucket.org': { issue: 'issue', commit: 'commits' },
|
|
545
|
+
}
|
|
546
|
+
const DEFAULT_HOST = { issue: 'issues', commit: 'commit' }
|
|
547
|
+
|
|
548
|
+
/** Words that mark an issue reference as closed by the commit. */
|
|
549
|
+
const CLOSES = /\b(?:close[sd]?|closing|fix(?:e[sd])?|fixing|resolve[sd]?|resolving)\s+#(\d+)/gi
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* The repository behind `origin`, for building links.
|
|
553
|
+
*
|
|
554
|
+
* Handles both URL forms git uses: `https://host/owner/repo.git` and the SCP-like
|
|
555
|
+
* `git@host:owner/repo.git`, which is not a URL and does not parse as one.
|
|
556
|
+
*
|
|
557
|
+
* @returns {{base: string, issue: string, commit: string} | null}
|
|
558
|
+
*/
|
|
559
|
+
function remoteLinks(remote) {
|
|
560
|
+
const url = tryRead('git', ['remote', 'get-url', remote])
|
|
561
|
+
if (!url) return null
|
|
562
|
+
const scp = /^(?:[^@]+@)?([^:/]+):(.+?)(?:\.git)?$/.exec(url.replace(/^ssh:\/\//, ''))
|
|
563
|
+
const web = /^[a-z+]+:\/\/(?:[^@]+@)?([^/]+)\/(.+?)(?:\.git)?$/i.exec(url)
|
|
564
|
+
const [, host, path] = web ?? scp ?? []
|
|
565
|
+
if (!host || !path) return null
|
|
566
|
+
const shape = HOSTS[host.toLowerCase()] ?? DEFAULT_HOST
|
|
567
|
+
return {
|
|
568
|
+
base: `https://${host}/${path}`,
|
|
569
|
+
issue: `https://${host}/${path}/${shape.issue}`,
|
|
570
|
+
commit: `https://${host}/${path}/${shape.commit}`,
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Drop commits that were reverted within the same release, and the reverts themselves —
|
|
576
|
+
* neither belongs in notes describing what changed. A `git revert` records the reverted
|
|
577
|
+
* hash in its body, which is what pairs them up.
|
|
578
|
+
*/
|
|
579
|
+
function withoutRevertedCommits(commits) {
|
|
580
|
+
const reverted = new Set()
|
|
581
|
+
for (const { body } of commits) {
|
|
582
|
+
const match = /This reverts commit ([0-9a-f]{7,40})/i.exec(body ?? '')
|
|
583
|
+
if (match) reverted.add(match[1].slice(0, 7))
|
|
584
|
+
}
|
|
585
|
+
if (!reverted.size) return commits
|
|
586
|
+
return commits.filter(
|
|
587
|
+
(c) => !reverted.has((c.hash ?? '').slice(0, 7)) && !/This reverts commit/i.test(c.body ?? ''),
|
|
588
|
+
)
|
|
589
|
+
}
|
|
590
|
+
|
|
500
591
|
const CONVENTIONAL_TYPES = 'build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test'
|
|
501
592
|
const CONVENTIONAL_RE = new RegExp(`^(${CONVENTIONAL_TYPES})(\\([^)]+\\))?!?: .+`)
|
|
502
593
|
|
|
@@ -766,7 +857,8 @@ function rollUnreleased(text, version, date) {
|
|
|
766
857
|
|
|
767
858
|
// An empty [Unreleased] has nothing to promote; rolling it produces an empty section.
|
|
768
859
|
const rest = text.slice(match.index + match[0].length)
|
|
769
|
-
const
|
|
860
|
+
const next = /^## /m.exec(rest)
|
|
861
|
+
const body = (next ? rest.slice(0, next.index) : rest).trim()
|
|
770
862
|
if (!body) return null
|
|
771
863
|
const released = `## [Unreleased]\n\n## [${version}] - ${date}`
|
|
772
864
|
return text.slice(0, match.index) + released + text.slice(match.index + match[0].length)
|
|
@@ -827,9 +919,29 @@ function readNameFrom(entry) {
|
|
|
827
919
|
/** Normalise a versionFile / versionFiles entry to { path, pattern }. */
|
|
828
920
|
const versionSource = (entry) => (typeof entry === 'string' ? { path: entry } : entry)
|
|
829
921
|
|
|
922
|
+
/**
|
|
923
|
+
* A lockfile records a version for every dependency — hundreds of them — so the first
|
|
924
|
+
* `version = "…"` in the file belongs to whichever crate sorts first, not to this project.
|
|
925
|
+
* Rewriting it corrupts an unrelated dependency, silently. Scope to the named package block.
|
|
926
|
+
*/
|
|
927
|
+
function cargoLockPattern(lockPath) {
|
|
928
|
+
const sibling = join(dirname(lockPath), 'Cargo.toml')
|
|
929
|
+
const crate = existsSync(sibling) ? readNameFrom({ path: sibling }) : null
|
|
930
|
+
if (!crate) {
|
|
931
|
+
throw new Error(
|
|
932
|
+
`${lockPath} lists every dependency's version, so it needs to know which package is ` +
|
|
933
|
+
`yours.\n No Cargo.toml beside it to read the name from — give an explicit ` +
|
|
934
|
+
`pattern:\n { "path": "${lockPath}", "pattern": "name = \\"<crate>\\"\\nversion = ` +
|
|
935
|
+
`\\"(.+)\\"" }`,
|
|
936
|
+
)
|
|
937
|
+
}
|
|
938
|
+
return new RegExp(`\\[\\[package\\]\\]\\nname = "${escapeRe(crate)}"\\nversion = "([^"]*)"`)
|
|
939
|
+
}
|
|
940
|
+
|
|
830
941
|
/** The regex for a source, or null when the whole file is the version. */
|
|
831
942
|
function patternFor({ path, pattern }) {
|
|
832
943
|
if (pattern) return new RegExp(pattern, 'm')
|
|
944
|
+
if (basename(path) === 'Cargo.lock') return cargoLockPattern(path)
|
|
833
945
|
if (path.endsWith('.json')) return VERSION_PATTERNS.json
|
|
834
946
|
if (path.endsWith('.toml')) return VERSION_PATTERNS.toml
|
|
835
947
|
return null
|
|
@@ -849,9 +961,10 @@ function readVersionFrom(entry) {
|
|
|
849
961
|
* Replace the version in a source file, touching nothing else: only the captured range is
|
|
850
962
|
* rewritten, so formatting, key order and comments all survive.
|
|
851
963
|
*
|
|
964
|
+
* @param {{dryRun?: boolean}} [options] report the change without making it
|
|
852
965
|
* @returns {boolean} whether the file needed changing
|
|
853
966
|
*/
|
|
854
|
-
function writeVersionInto(entry, version) {
|
|
967
|
+
function writeVersionInto(entry, version, { dryRun = false } = {}) {
|
|
855
968
|
const source = versionSource(entry)
|
|
856
969
|
const text = readFileSync(source.path, 'utf8')
|
|
857
970
|
const pattern = patternFor(source)
|
|
@@ -900,6 +1013,7 @@ const skippedSteps = option('--skip')
|
|
|
900
1013
|
const explicitDistTag = option('--dist-tag')
|
|
901
1014
|
const requestedPreid = option('--preid')
|
|
902
1015
|
const autoCommit = flag('--commit')
|
|
1016
|
+
const requestedNotesFile = option('--notes-file')
|
|
903
1017
|
const requestedAssistant = option('--assistant')
|
|
904
1018
|
const requestedModel = option('--assistant-model')
|
|
905
1019
|
const requestedEffort = option('--assistant-effort')
|
|
@@ -929,8 +1043,10 @@ if (flag('--sync')) {
|
|
|
929
1043
|
const projectRoot = resolve(target)
|
|
930
1044
|
const destination = join(projectRoot, 'scripts', basename(self))
|
|
931
1045
|
if (destination === self) continue
|
|
932
|
-
|
|
933
|
-
|
|
1046
|
+
// Any directory can hold a vendored copy: a manifest is only needed to wire up a
|
|
1047
|
+
// script, which Rust, Python and Go projects do not have and do not need.
|
|
1048
|
+
if (!existsSync(projectRoot)) {
|
|
1049
|
+
warn(`${target}: no such directory — skipped`)
|
|
934
1050
|
continue
|
|
935
1051
|
}
|
|
936
1052
|
const current = existsSync(destination) ? readFileSync(destination, 'utf8') : null
|
|
@@ -943,9 +1059,15 @@ if (flag('--sync')) {
|
|
|
943
1059
|
writeFileSync(destination, source)
|
|
944
1060
|
}
|
|
945
1061
|
ok(`${target}: ${current === null ? 'installed' : 'updated'}${dryRun ? ' (dry run)' : ''}`)
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
1062
|
+
|
|
1063
|
+
const manifestPath = join(projectRoot, 'package.json')
|
|
1064
|
+
if (existsSync(manifestPath)) {
|
|
1065
|
+
const scripts = readJson(manifestPath).scripts ?? {}
|
|
1066
|
+
if (!scripts.release) {
|
|
1067
|
+
warn(`${target}: add "release": "node scripts/${basename(self)}" to package.json`)
|
|
1068
|
+
}
|
|
1069
|
+
} else {
|
|
1070
|
+
note(`${target}: run it with \`node scripts/${basename(self)}\``)
|
|
949
1071
|
}
|
|
950
1072
|
}
|
|
951
1073
|
process.exit(0)
|
|
@@ -960,6 +1082,7 @@ const VALUE_OPTIONS = new Set([
|
|
|
960
1082
|
'--skip',
|
|
961
1083
|
'--preid',
|
|
962
1084
|
'--dist-tag',
|
|
1085
|
+
'--notes-file',
|
|
963
1086
|
'--assistant',
|
|
964
1087
|
'--assistant-model',
|
|
965
1088
|
'--assistant-effort',
|
|
@@ -1035,6 +1158,17 @@ function detectVersionFile() {
|
|
|
1035
1158
|
return null
|
|
1036
1159
|
}
|
|
1037
1160
|
|
|
1161
|
+
/**
|
|
1162
|
+
* The publish command implied by a project's manifest, but only where one ecosystem
|
|
1163
|
+
* obviously owns it. Python has several publishers (uv, twine, poetry, flit) and Go has
|
|
1164
|
+
* none, so those get nothing rather than a guess — publishing to the wrong registry is a
|
|
1165
|
+
* far worse failure than being asked to configure it.
|
|
1166
|
+
*/
|
|
1167
|
+
const PUBLISH_BY_MANIFEST = {
|
|
1168
|
+
'package.json': 'npm publish --tag %d',
|
|
1169
|
+
'Cargo.toml': 'cargo publish',
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1038
1172
|
// An explicit `versionFile: null` means "versions by tag" and must not be re-detected, so
|
|
1039
1173
|
// the distinction is between the key being absent and the key being set to null.
|
|
1040
1174
|
const configuredVersionFile = Object.hasOwn(userConfig, 'versionFile')
|
|
@@ -1069,6 +1203,15 @@ const goModule = existsSync('go.mod')
|
|
|
1069
1203
|
const projectName =
|
|
1070
1204
|
manifest?.name ?? (versionFile ? readNameFrom(versionFile) : null) ?? goModule ?? basename(root)
|
|
1071
1205
|
|
|
1206
|
+
// Same rule as versionFile: an explicit `publish: null` means "publish nothing" and is
|
|
1207
|
+
// never re-detected. Unset means "work it out", and working it out can yield nothing.
|
|
1208
|
+
if (!Object.hasOwn(userConfig, 'publish')) {
|
|
1209
|
+
// Preflight already reports "no publish command configured" when the step runs, so
|
|
1210
|
+
// there is nothing to say here — and `runs` is not resolved this early.
|
|
1211
|
+
config.publish =
|
|
1212
|
+
(versionFile ? PUBLISH_BY_MANIFEST[basename(versionFile.path)] : undefined) ?? null
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1072
1215
|
// Validate every name that was asked for, not just the ones that survive: a typo in
|
|
1073
1216
|
// --skip would otherwise delete nothing and silently run the step you meant to drop.
|
|
1074
1217
|
const requestedStepNames = [
|
|
@@ -1294,8 +1437,24 @@ if (bumping && compareVersions(version, currentVersion) <= 0) {
|
|
|
1294
1437
|
const dirty = tryRead('git', ['status', '--porcelain'])
|
|
1295
1438
|
if (dirty === null) fail('could not read git status')
|
|
1296
1439
|
else if (dirty && runs('commit')) {
|
|
1297
|
-
|
|
1440
|
+
const entries = dirty.split('\n')
|
|
1441
|
+
ok(`working tree has ${entries.length} change(s) — will be committed first`)
|
|
1298
1442
|
console.log(dim(indent(formatStatus(dirty))))
|
|
1443
|
+
// One commit gets one subject. A change set spanning several top-level directories is
|
|
1444
|
+
// usually several pieces of work, and no honest Conventional Commits subject covers it.
|
|
1445
|
+
const areas = new Set(
|
|
1446
|
+
entries.map(
|
|
1447
|
+
(entry) => entry.trim().split(/\s+/).slice(1).join(' ').replaceAll('"', '').split('/')[0],
|
|
1448
|
+
),
|
|
1449
|
+
)
|
|
1450
|
+
if (areas.size > 2) {
|
|
1451
|
+
warn(
|
|
1452
|
+
`these span ${areas.size} top-level paths (${[...areas].slice(0, 4).join(', ')}${areas.size > 4 ? ', …' : ''}), ` +
|
|
1453
|
+
'which is usually more than one piece of work.\n' +
|
|
1454
|
+
' One commit gets one subject: consider committing them yourself, then ' +
|
|
1455
|
+
'releasing with --skip commit.',
|
|
1456
|
+
)
|
|
1457
|
+
}
|
|
1299
1458
|
} else if (dirty) {
|
|
1300
1459
|
fail(`working tree is not clean:\n${indent(formatStatus(dirty))}`)
|
|
1301
1460
|
} else ok('working tree clean')
|
|
@@ -1476,7 +1635,7 @@ const notesDeferred = !!(dirty && runs('commit') && assistant)
|
|
|
1476
1635
|
function draftNotesFor(v) {
|
|
1477
1636
|
const { lastTag, subjects, commits } = commitsSinceLastTag()
|
|
1478
1637
|
if (!commits.length) return null
|
|
1479
|
-
if (!assistant) return changelogFromCommits(commits)
|
|
1638
|
+
if (!assistant) return changelogFromCommits(commits, remoteLinks(config.remote))
|
|
1480
1639
|
if (shallow) {
|
|
1481
1640
|
warn(
|
|
1482
1641
|
`shallow clone: only ${subjects.length} commit(s) are visible, so the notes will ` +
|
|
@@ -1484,7 +1643,10 @@ function draftNotesFor(v) {
|
|
|
1484
1643
|
)
|
|
1485
1644
|
}
|
|
1486
1645
|
note(`drafting notes from ${subjects.length} commit(s) with ${assistantName}...`)
|
|
1487
|
-
return
|
|
1646
|
+
return (
|
|
1647
|
+
draftReleaseNotes(v, subjects, lastTag) ??
|
|
1648
|
+
changelogFromCommits(commits, remoteLinks(config.remote))
|
|
1649
|
+
)
|
|
1488
1650
|
}
|
|
1489
1651
|
if (config.changelog && existsSync(config.changelog)) {
|
|
1490
1652
|
const text = readFileSync(config.changelog, 'utf8')
|
|
@@ -1548,6 +1710,29 @@ if (problems.length) {
|
|
|
1548
1710
|
// CONFIRM
|
|
1549
1711
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
1550
1712
|
|
|
1713
|
+
/**
|
|
1714
|
+
* Stage the working tree and draft its commit message before the confirmation prompt, so
|
|
1715
|
+
* what gets approved is the message that will actually be written. Staging is the first
|
|
1716
|
+
* mutation, and it is undone if the release is declined — `git add` touches only the index,
|
|
1717
|
+
* never the working tree, so restoring it is exact.
|
|
1718
|
+
*/
|
|
1719
|
+
let commitMessage = null
|
|
1720
|
+
let didStage = false
|
|
1721
|
+
if (dirty && runs('commit') && !dryRun) {
|
|
1722
|
+
step('Stage the working tree')
|
|
1723
|
+
mutate('git', ['add', '--all'])
|
|
1724
|
+
didStage = true
|
|
1725
|
+
commitMessage = draftCommitMessage()
|
|
1726
|
+
if (!commitMessage) {
|
|
1727
|
+
mutate('git', ['reset', '--quiet'])
|
|
1728
|
+
abort(
|
|
1729
|
+
`${assistantName} could not draft a Conventional Commits message for these changes.\n\n` +
|
|
1730
|
+
' Commit them yourself and re-run, or run without --commit.',
|
|
1731
|
+
)
|
|
1732
|
+
}
|
|
1733
|
+
console.log(indent(commitMessage))
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1551
1736
|
if (!assumeYes && !dryRun && process.stdin.isTTY) {
|
|
1552
1737
|
if (notes) console.log(`\n${bold('Release notes')}\n${indent(notes)}`)
|
|
1553
1738
|
const rl = createInterface({ input: process.stdin, output: process.stdout })
|
|
@@ -1560,7 +1745,11 @@ if (!assumeYes && !dryRun && process.stdin.isTTY) {
|
|
|
1560
1745
|
} finally {
|
|
1561
1746
|
rl.close()
|
|
1562
1747
|
}
|
|
1563
|
-
if (!/^y(es)?$/i.test(answer.trim()))
|
|
1748
|
+
if (!/^y(es)?$/i.test(answer.trim())) {
|
|
1749
|
+
// Leave the index exactly as it was found.
|
|
1750
|
+
if (didStage) mutate('git', ['reset', '--quiet'])
|
|
1751
|
+
abort('cancelled')
|
|
1752
|
+
}
|
|
1564
1753
|
}
|
|
1565
1754
|
|
|
1566
1755
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -1571,18 +1760,8 @@ const staged = []
|
|
|
1571
1760
|
|
|
1572
1761
|
if (dirty && runs('commit')) {
|
|
1573
1762
|
step('Commit the working tree')
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
// in the tree. Under --dry-run nothing was staged, so there is nothing to describe.
|
|
1577
|
-
const message = dryRun ? null : draftCommitMessage()
|
|
1578
|
-
if (!message && !dryRun) {
|
|
1579
|
-
abort(
|
|
1580
|
-
`${assistantName} could not draft a Conventional Commits message for the staged changes.\n\n` +
|
|
1581
|
-
' Commit them yourself and re-run, or run without --commit.',
|
|
1582
|
-
)
|
|
1583
|
-
}
|
|
1584
|
-
console.log(indent(message ?? '<drafted at run time>'))
|
|
1585
|
-
mutate('git', ['commit', '-m', message ?? 'chore: working tree'])
|
|
1763
|
+
if (dryRun) console.log(` ${yellow('would run:')} git commit -m <drafted at run time>`)
|
|
1764
|
+
else mutate('git', ['commit', '-m', commitMessage])
|
|
1586
1765
|
|
|
1587
1766
|
// Now that the commit exists it is part of the release, so the notes can describe it.
|
|
1588
1767
|
if (notesDeferred && !dryRun) {
|
|
@@ -1596,7 +1775,7 @@ if (bumping) {
|
|
|
1596
1775
|
for (const entry of [versionFile, ...config.versionFiles]) {
|
|
1597
1776
|
const source = versionSource(entry)
|
|
1598
1777
|
if (!existsSync(source.path)) abort(`versionFiles entry ${source.path} does not exist`)
|
|
1599
|
-
if (writeVersionInto(source, version)) {
|
|
1778
|
+
if (writeVersionInto(source, version, { dryRun })) {
|
|
1600
1779
|
staged.push(source.path)
|
|
1601
1780
|
console.log(` ${dryRun ? yellow('would write') : dim('wrote')} ${source.path}`)
|
|
1602
1781
|
}
|
|
@@ -1636,6 +1815,20 @@ if (staged.length) {
|
|
|
1636
1815
|
mutate('git', ['commit', '-m', expand(config.commitMessage)])
|
|
1637
1816
|
}
|
|
1638
1817
|
|
|
1818
|
+
/**
|
|
1819
|
+
* Hand the notes to whatever builds and publishes next. goreleaser, cargo-dist and similar
|
|
1820
|
+
* take release notes as a file and then own the GitHub release themselves.
|
|
1821
|
+
*
|
|
1822
|
+
* The notes are also in the annotated tag, but reading them back with `%(contents)` embeds
|
|
1823
|
+
* the signature when tags are signed — this writes the text itself, with no such trap.
|
|
1824
|
+
*/
|
|
1825
|
+
const notesFile = requestedNotesFile ?? config.notesFile
|
|
1826
|
+
if (notesFile) {
|
|
1827
|
+
step(`Write release notes to ${notesFile}`)
|
|
1828
|
+
if (dryRun) console.log(` ${yellow('would write')} ${notesFile}`)
|
|
1829
|
+
else writeFileSync(notesFile, `${notes ?? `${projectName} ${tag}`}\n`)
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1639
1832
|
if (runs('tag') && !taggedCommit) {
|
|
1640
1833
|
step(`Annotated tag ${tag}`)
|
|
1641
1834
|
// The notes become the tag annotation too, so a CI release workflow can read them
|