@entro314labs/release-kit 2.3.1 → 2.3.3
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 +90 -2
- package/package.json +1 -1
- package/release.mjs +228 -35
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.
|
|
@@ -528,6 +537,80 @@ One row in `ASSISTANTS` in `release.mjs`: the command, the args that make it rea
|
|
|
528
537
|
stdin, and how it spells model and effort. Tools whose stdout carries session scaffolding
|
|
529
538
|
declare `outputFile` and the answer is read from there instead.
|
|
530
539
|
|
|
540
|
+
## 📚 Libraries versus apps
|
|
541
|
+
|
|
542
|
+
Publishing splits in two, and which one you are decides the `steps` you want.
|
|
543
|
+
|
|
544
|
+
**A library, package or tool publishes its source.** The registry receives what is already
|
|
545
|
+
in the repository — `npm publish`, `cargo publish`, `uv publish` — and there is nothing to
|
|
546
|
+
build first. release-kit does the whole thing:
|
|
547
|
+
|
|
548
|
+
```json
|
|
549
|
+
{}
|
|
550
|
+
```
|
|
551
|
+
|
|
552
|
+
Defaults are already correct: version → changelog → tag → push → publish → release.
|
|
553
|
+
|
|
554
|
+
**An app has to be built before anything can be published.** Binaries, installers, bundles,
|
|
555
|
+
container images: the artifact does not exist until something makes it. That build belongs
|
|
556
|
+
to a build tool, and it changes where release-kit stops.
|
|
557
|
+
|
|
558
|
+
### Apps with a simple build
|
|
559
|
+
|
|
560
|
+
If the build runs before the release and leaves files on disk, release-kit can attach them
|
|
561
|
+
itself. Nothing else is needed:
|
|
562
|
+
|
|
563
|
+
```json
|
|
564
|
+
{ "assets": ["dist/app-macos.zip", "dist/app-linux.tar.gz"], "publish": null }
|
|
565
|
+
```
|
|
566
|
+
|
|
567
|
+
Preflight fails if a listed asset is missing, so a release cannot quietly ship without its
|
|
568
|
+
binaries.
|
|
569
|
+
|
|
570
|
+
### Apps with a real build pipeline
|
|
571
|
+
|
|
572
|
+
goreleaser, cargo-dist and electron-builder build for many targets and create the GitHub
|
|
573
|
+
release themselves, with the artifacts attached. That is their job. release-kit's work ends
|
|
574
|
+
at the pushed tag:
|
|
575
|
+
|
|
576
|
+
```json
|
|
577
|
+
{ "steps": ["version", "changelog", "tag", "push"], "notesFile": "dist-notes.md" }
|
|
578
|
+
```
|
|
579
|
+
|
|
580
|
+
Nothing after `push` — no `publish`, no `release`. The tag push is the handoff, and it is
|
|
581
|
+
what triggers the build workflow:
|
|
582
|
+
|
|
583
|
+
```yaml
|
|
584
|
+
on:
|
|
585
|
+
push:
|
|
586
|
+
tags: ['v*']
|
|
587
|
+
|
|
588
|
+
jobs:
|
|
589
|
+
build:
|
|
590
|
+
steps:
|
|
591
|
+
- uses: actions/checkout@v5
|
|
592
|
+
with: { fetch-depth: 0 }
|
|
593
|
+
- run: goreleaser release --clean --release-notes dist-notes.md
|
|
594
|
+
```
|
|
595
|
+
|
|
596
|
+
**Do not leave `release` in `steps` here.** goreleaser creates the GitHub release itself; if
|
|
597
|
+
release-kit has already created one for that tag, goreleaser fails. Exactly one of them
|
|
598
|
+
should own it, and it should be the one attaching the binaries.
|
|
599
|
+
|
|
600
|
+
`notesFile` exists for this handoff: goreleaser's `--release-notes` takes a file and skips
|
|
601
|
+
its own changelog generation. The notes are also in the annotated tag, but reading them back
|
|
602
|
+
with `git tag --format='%(contents)'` embeds the signature when tags are signed, which then
|
|
603
|
+
appears in your published release notes. `notesFile` writes the text itself.
|
|
604
|
+
|
|
605
|
+
### Both at once
|
|
606
|
+
|
|
607
|
+
A project can be both — a Rust crate that also ships binaries, say. Publish the library from
|
|
608
|
+
release-kit and let the build tool handle the binaries and the release:
|
|
609
|
+
|
|
610
|
+
```json
|
|
611
|
+
{ "publish": "cargo publish", "steps": ["version", "changelog", "tag", "push", "publish"] }
|
|
612
|
+
```
|
|
613
|
+
|
|
531
614
|
## 🔄 Keeping vendored copies in sync
|
|
532
615
|
|
|
533
616
|
Installed as a dependency, updates come from your package manager and there is nothing to
|
|
@@ -552,6 +635,11 @@ including a directory that is not a repository.
|
|
|
552
635
|
- Whatever the `publish` command needs — for the default, a live `npm login` session
|
|
553
636
|
(two hours) or an OIDC trusted-publishing environment
|
|
554
637
|
|
|
638
|
+
## 🗺 Roadmap
|
|
639
|
+
|
|
640
|
+
Known defects, missing infrastructure, and the ideas that were considered and declined —
|
|
641
|
+
with the reasoning — are in [ROADMAP.md](ROADMAP.md).
|
|
642
|
+
|
|
555
643
|
## 🤝 Contributing
|
|
556
644
|
|
|
557
645
|
The tool releases itself, so a change ships the same way it would in any consuming project:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@entro314labs/release-kit",
|
|
3
|
-
"version": "2.3.
|
|
3
|
+
"version": "2.3.3",
|
|
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
|
@@ -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
|
|
|
@@ -756,9 +847,19 @@ function changelogSection(text, version) {
|
|
|
756
847
|
* @returns {string | null} the updated document, or null when there is nothing to roll
|
|
757
848
|
*/
|
|
758
849
|
function rollUnreleased(text, version, date) {
|
|
850
|
+
// A heading for this version already exists — possibly with an empty body, which
|
|
851
|
+
// `changelogSection` reports as absent. Rolling again would duplicate the heading.
|
|
852
|
+
if (new RegExp(`^##\\s+\\[?v?${escapeRe(version)}\\]?(?![\\w.-])`, 'm').test(text)) return null
|
|
853
|
+
|
|
759
854
|
const heading = /^##\s+\[?Unreleased\]?[^\n]*$/im
|
|
760
855
|
const match = heading.exec(text)
|
|
761
856
|
if (!match) return null
|
|
857
|
+
|
|
858
|
+
// 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()
|
|
862
|
+
if (!body) return null
|
|
762
863
|
const released = `## [Unreleased]\n\n## [${version}] - ${date}`
|
|
763
864
|
return text.slice(0, match.index) + released + text.slice(match.index + match[0].length)
|
|
764
865
|
}
|
|
@@ -891,6 +992,7 @@ const skippedSteps = option('--skip')
|
|
|
891
992
|
const explicitDistTag = option('--dist-tag')
|
|
892
993
|
const requestedPreid = option('--preid')
|
|
893
994
|
const autoCommit = flag('--commit')
|
|
995
|
+
const requestedNotesFile = option('--notes-file')
|
|
894
996
|
const requestedAssistant = option('--assistant')
|
|
895
997
|
const requestedModel = option('--assistant-model')
|
|
896
998
|
const requestedEffort = option('--assistant-effort')
|
|
@@ -920,8 +1022,10 @@ if (flag('--sync')) {
|
|
|
920
1022
|
const projectRoot = resolve(target)
|
|
921
1023
|
const destination = join(projectRoot, 'scripts', basename(self))
|
|
922
1024
|
if (destination === self) continue
|
|
923
|
-
|
|
924
|
-
|
|
1025
|
+
// Any directory can hold a vendored copy: a manifest is only needed to wire up a
|
|
1026
|
+
// script, which Rust, Python and Go projects do not have and do not need.
|
|
1027
|
+
if (!existsSync(projectRoot)) {
|
|
1028
|
+
warn(`${target}: no such directory — skipped`)
|
|
925
1029
|
continue
|
|
926
1030
|
}
|
|
927
1031
|
const current = existsSync(destination) ? readFileSync(destination, 'utf8') : null
|
|
@@ -934,9 +1038,15 @@ if (flag('--sync')) {
|
|
|
934
1038
|
writeFileSync(destination, source)
|
|
935
1039
|
}
|
|
936
1040
|
ok(`${target}: ${current === null ? 'installed' : 'updated'}${dryRun ? ' (dry run)' : ''}`)
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
1041
|
+
|
|
1042
|
+
const manifestPath = join(projectRoot, 'package.json')
|
|
1043
|
+
if (existsSync(manifestPath)) {
|
|
1044
|
+
const scripts = readJson(manifestPath).scripts ?? {}
|
|
1045
|
+
if (!scripts.release) {
|
|
1046
|
+
warn(`${target}: add "release": "node scripts/${basename(self)}" to package.json`)
|
|
1047
|
+
}
|
|
1048
|
+
} else {
|
|
1049
|
+
note(`${target}: run it with \`node scripts/${basename(self)}\``)
|
|
940
1050
|
}
|
|
941
1051
|
}
|
|
942
1052
|
process.exit(0)
|
|
@@ -951,6 +1061,7 @@ const VALUE_OPTIONS = new Set([
|
|
|
951
1061
|
'--skip',
|
|
952
1062
|
'--preid',
|
|
953
1063
|
'--dist-tag',
|
|
1064
|
+
'--notes-file',
|
|
954
1065
|
'--assistant',
|
|
955
1066
|
'--assistant-model',
|
|
956
1067
|
'--assistant-effort',
|
|
@@ -1026,6 +1137,17 @@ function detectVersionFile() {
|
|
|
1026
1137
|
return null
|
|
1027
1138
|
}
|
|
1028
1139
|
|
|
1140
|
+
/**
|
|
1141
|
+
* The publish command implied by a project's manifest, but only where one ecosystem
|
|
1142
|
+
* obviously owns it. Python has several publishers (uv, twine, poetry, flit) and Go has
|
|
1143
|
+
* none, so those get nothing rather than a guess — publishing to the wrong registry is a
|
|
1144
|
+
* far worse failure than being asked to configure it.
|
|
1145
|
+
*/
|
|
1146
|
+
const PUBLISH_BY_MANIFEST = {
|
|
1147
|
+
'package.json': 'npm publish --tag %d',
|
|
1148
|
+
'Cargo.toml': 'cargo publish',
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1029
1151
|
// An explicit `versionFile: null` means "versions by tag" and must not be re-detected, so
|
|
1030
1152
|
// the distinction is between the key being absent and the key being set to null.
|
|
1031
1153
|
const configuredVersionFile = Object.hasOwn(userConfig, 'versionFile')
|
|
@@ -1060,6 +1182,15 @@ const goModule = existsSync('go.mod')
|
|
|
1060
1182
|
const projectName =
|
|
1061
1183
|
manifest?.name ?? (versionFile ? readNameFrom(versionFile) : null) ?? goModule ?? basename(root)
|
|
1062
1184
|
|
|
1185
|
+
// Same rule as versionFile: an explicit `publish: null` means "publish nothing" and is
|
|
1186
|
+
// never re-detected. Unset means "work it out", and working it out can yield nothing.
|
|
1187
|
+
if (!Object.hasOwn(userConfig, 'publish')) {
|
|
1188
|
+
// Preflight already reports "no publish command configured" when the step runs, so
|
|
1189
|
+
// there is nothing to say here — and `runs` is not resolved this early.
|
|
1190
|
+
config.publish =
|
|
1191
|
+
(versionFile ? PUBLISH_BY_MANIFEST[basename(versionFile.path)] : undefined) ?? null
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1063
1194
|
// Validate every name that was asked for, not just the ones that survive: a typo in
|
|
1064
1195
|
// --skip would otherwise delete nothing and silently run the step you meant to drop.
|
|
1065
1196
|
const requestedStepNames = [
|
|
@@ -1285,8 +1416,24 @@ if (bumping && compareVersions(version, currentVersion) <= 0) {
|
|
|
1285
1416
|
const dirty = tryRead('git', ['status', '--porcelain'])
|
|
1286
1417
|
if (dirty === null) fail('could not read git status')
|
|
1287
1418
|
else if (dirty && runs('commit')) {
|
|
1288
|
-
|
|
1419
|
+
const entries = dirty.split('\n')
|
|
1420
|
+
ok(`working tree has ${entries.length} change(s) — will be committed first`)
|
|
1289
1421
|
console.log(dim(indent(formatStatus(dirty))))
|
|
1422
|
+
// One commit gets one subject. A change set spanning several top-level directories is
|
|
1423
|
+
// usually several pieces of work, and no honest Conventional Commits subject covers it.
|
|
1424
|
+
const areas = new Set(
|
|
1425
|
+
entries.map(
|
|
1426
|
+
(entry) => entry.trim().split(/\s+/).slice(1).join(' ').replaceAll('"', '').split('/')[0],
|
|
1427
|
+
),
|
|
1428
|
+
)
|
|
1429
|
+
if (areas.size > 2) {
|
|
1430
|
+
warn(
|
|
1431
|
+
`these span ${areas.size} top-level paths (${[...areas].slice(0, 4).join(', ')}${areas.size > 4 ? ', …' : ''}), ` +
|
|
1432
|
+
'which is usually more than one piece of work.\n' +
|
|
1433
|
+
' One commit gets one subject: consider committing them yourself, then ' +
|
|
1434
|
+
'releasing with --skip commit.',
|
|
1435
|
+
)
|
|
1436
|
+
}
|
|
1290
1437
|
} else if (dirty) {
|
|
1291
1438
|
fail(`working tree is not clean:\n${indent(formatStatus(dirty))}`)
|
|
1292
1439
|
} else ok('working tree clean')
|
|
@@ -1467,7 +1614,7 @@ const notesDeferred = !!(dirty && runs('commit') && assistant)
|
|
|
1467
1614
|
function draftNotesFor(v) {
|
|
1468
1615
|
const { lastTag, subjects, commits } = commitsSinceLastTag()
|
|
1469
1616
|
if (!commits.length) return null
|
|
1470
|
-
if (!assistant) return changelogFromCommits(commits)
|
|
1617
|
+
if (!assistant) return changelogFromCommits(commits, remoteLinks(config.remote))
|
|
1471
1618
|
if (shallow) {
|
|
1472
1619
|
warn(
|
|
1473
1620
|
`shallow clone: only ${subjects.length} commit(s) are visible, so the notes will ` +
|
|
@@ -1475,7 +1622,10 @@ function draftNotesFor(v) {
|
|
|
1475
1622
|
)
|
|
1476
1623
|
}
|
|
1477
1624
|
note(`drafting notes from ${subjects.length} commit(s) with ${assistantName}...`)
|
|
1478
|
-
return
|
|
1625
|
+
return (
|
|
1626
|
+
draftReleaseNotes(v, subjects, lastTag) ??
|
|
1627
|
+
changelogFromCommits(commits, remoteLinks(config.remote))
|
|
1628
|
+
)
|
|
1479
1629
|
}
|
|
1480
1630
|
if (config.changelog && existsSync(config.changelog)) {
|
|
1481
1631
|
const text = readFileSync(config.changelog, 'utf8')
|
|
@@ -1515,6 +1665,18 @@ for (const asset of config.assets) {
|
|
|
1515
1665
|
else fail(`asset ${asset} does not exist`)
|
|
1516
1666
|
}
|
|
1517
1667
|
|
|
1668
|
+
// Reusing a tag is the resume path, and a resume writes nothing. If this run would still
|
|
1669
|
+
// produce a commit, that commit moves HEAD past the tag and the release ends up tagged at
|
|
1670
|
+
// the wrong revision — which is silent until someone checks out the tag.
|
|
1671
|
+
if (taggedCommit && runs('tag') && (bumping || rolledChangelog)) {
|
|
1672
|
+
fail(
|
|
1673
|
+
`tag ${tag} already exists at HEAD, but this run would still commit ` +
|
|
1674
|
+
`${[bumping && 'a version bump', rolledChangelog && 'a changelog entry'].filter(Boolean).join(' and ')}.\n` +
|
|
1675
|
+
' That commit would leave the tag behind HEAD. Release a new version, or use ' +
|
|
1676
|
+
'--only with the steps that remain.',
|
|
1677
|
+
)
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1518
1680
|
if (problems.length) {
|
|
1519
1681
|
const summary = `${problems.length} preflight check(s) failed:\n - ${problems.join('\n - ')}`
|
|
1520
1682
|
if (!dryRun) abort(summary)
|
|
@@ -1527,6 +1689,29 @@ if (problems.length) {
|
|
|
1527
1689
|
// CONFIRM
|
|
1528
1690
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
1529
1691
|
|
|
1692
|
+
/**
|
|
1693
|
+
* Stage the working tree and draft its commit message before the confirmation prompt, so
|
|
1694
|
+
* what gets approved is the message that will actually be written. Staging is the first
|
|
1695
|
+
* mutation, and it is undone if the release is declined — `git add` touches only the index,
|
|
1696
|
+
* never the working tree, so restoring it is exact.
|
|
1697
|
+
*/
|
|
1698
|
+
let commitMessage = null
|
|
1699
|
+
let didStage = false
|
|
1700
|
+
if (dirty && runs('commit') && !dryRun) {
|
|
1701
|
+
step('Stage the working tree')
|
|
1702
|
+
mutate('git', ['add', '--all'])
|
|
1703
|
+
didStage = true
|
|
1704
|
+
commitMessage = draftCommitMessage()
|
|
1705
|
+
if (!commitMessage) {
|
|
1706
|
+
mutate('git', ['reset', '--quiet'])
|
|
1707
|
+
abort(
|
|
1708
|
+
`${assistantName} could not draft a Conventional Commits message for these changes.\n\n` +
|
|
1709
|
+
' Commit them yourself and re-run, or run without --commit.',
|
|
1710
|
+
)
|
|
1711
|
+
}
|
|
1712
|
+
console.log(indent(commitMessage))
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1530
1715
|
if (!assumeYes && !dryRun && process.stdin.isTTY) {
|
|
1531
1716
|
if (notes) console.log(`\n${bold('Release notes')}\n${indent(notes)}`)
|
|
1532
1717
|
const rl = createInterface({ input: process.stdin, output: process.stdout })
|
|
@@ -1539,7 +1724,11 @@ if (!assumeYes && !dryRun && process.stdin.isTTY) {
|
|
|
1539
1724
|
} finally {
|
|
1540
1725
|
rl.close()
|
|
1541
1726
|
}
|
|
1542
|
-
if (!/^y(es)?$/i.test(answer.trim()))
|
|
1727
|
+
if (!/^y(es)?$/i.test(answer.trim())) {
|
|
1728
|
+
// Leave the index exactly as it was found.
|
|
1729
|
+
if (didStage) mutate('git', ['reset', '--quiet'])
|
|
1730
|
+
abort('cancelled')
|
|
1731
|
+
}
|
|
1543
1732
|
}
|
|
1544
1733
|
|
|
1545
1734
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -1550,18 +1739,8 @@ const staged = []
|
|
|
1550
1739
|
|
|
1551
1740
|
if (dirty && runs('commit')) {
|
|
1552
1741
|
step('Commit the working tree')
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
// in the tree. Under --dry-run nothing was staged, so there is nothing to describe.
|
|
1556
|
-
const message = dryRun ? null : draftCommitMessage()
|
|
1557
|
-
if (!message && !dryRun) {
|
|
1558
|
-
abort(
|
|
1559
|
-
`${assistantName} could not draft a Conventional Commits message for the staged changes.\n\n` +
|
|
1560
|
-
' Commit them yourself and re-run, or run without --commit.',
|
|
1561
|
-
)
|
|
1562
|
-
}
|
|
1563
|
-
console.log(indent(message ?? '<drafted at run time>'))
|
|
1564
|
-
mutate('git', ['commit', '-m', message ?? 'chore: working tree'])
|
|
1742
|
+
if (dryRun) console.log(` ${yellow('would run:')} git commit -m <drafted at run time>`)
|
|
1743
|
+
else mutate('git', ['commit', '-m', commitMessage])
|
|
1565
1744
|
|
|
1566
1745
|
// Now that the commit exists it is part of the release, so the notes can describe it.
|
|
1567
1746
|
if (notesDeferred && !dryRun) {
|
|
@@ -1615,6 +1794,20 @@ if (staged.length) {
|
|
|
1615
1794
|
mutate('git', ['commit', '-m', expand(config.commitMessage)])
|
|
1616
1795
|
}
|
|
1617
1796
|
|
|
1797
|
+
/**
|
|
1798
|
+
* Hand the notes to whatever builds and publishes next. goreleaser, cargo-dist and similar
|
|
1799
|
+
* take release notes as a file and then own the GitHub release themselves.
|
|
1800
|
+
*
|
|
1801
|
+
* The notes are also in the annotated tag, but reading them back with `%(contents)` embeds
|
|
1802
|
+
* the signature when tags are signed — this writes the text itself, with no such trap.
|
|
1803
|
+
*/
|
|
1804
|
+
const notesFile = requestedNotesFile ?? config.notesFile
|
|
1805
|
+
if (notesFile) {
|
|
1806
|
+
step(`Write release notes to ${notesFile}`)
|
|
1807
|
+
if (dryRun) console.log(` ${yellow('would write')} ${notesFile}`)
|
|
1808
|
+
else writeFileSync(notesFile, `${notes ?? `${projectName} ${tag}`}\n`)
|
|
1809
|
+
}
|
|
1810
|
+
|
|
1618
1811
|
if (runs('tag') && !taggedCommit) {
|
|
1619
1812
|
step(`Annotated tag ${tag}`)
|
|
1620
1813
|
// The notes become the tag annotation too, so a CI release workflow can read them
|