@entro314labs/release-kit 2.3.2 → 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 +208 -36
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
|
|
|
@@ -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)
|
|
@@ -900,6 +992,7 @@ const skippedSteps = option('--skip')
|
|
|
900
992
|
const explicitDistTag = option('--dist-tag')
|
|
901
993
|
const requestedPreid = option('--preid')
|
|
902
994
|
const autoCommit = flag('--commit')
|
|
995
|
+
const requestedNotesFile = option('--notes-file')
|
|
903
996
|
const requestedAssistant = option('--assistant')
|
|
904
997
|
const requestedModel = option('--assistant-model')
|
|
905
998
|
const requestedEffort = option('--assistant-effort')
|
|
@@ -929,8 +1022,10 @@ if (flag('--sync')) {
|
|
|
929
1022
|
const projectRoot = resolve(target)
|
|
930
1023
|
const destination = join(projectRoot, 'scripts', basename(self))
|
|
931
1024
|
if (destination === self) continue
|
|
932
|
-
|
|
933
|
-
|
|
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`)
|
|
934
1029
|
continue
|
|
935
1030
|
}
|
|
936
1031
|
const current = existsSync(destination) ? readFileSync(destination, 'utf8') : null
|
|
@@ -943,9 +1038,15 @@ if (flag('--sync')) {
|
|
|
943
1038
|
writeFileSync(destination, source)
|
|
944
1039
|
}
|
|
945
1040
|
ok(`${target}: ${current === null ? 'installed' : 'updated'}${dryRun ? ' (dry run)' : ''}`)
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
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)}\``)
|
|
949
1050
|
}
|
|
950
1051
|
}
|
|
951
1052
|
process.exit(0)
|
|
@@ -960,6 +1061,7 @@ const VALUE_OPTIONS = new Set([
|
|
|
960
1061
|
'--skip',
|
|
961
1062
|
'--preid',
|
|
962
1063
|
'--dist-tag',
|
|
1064
|
+
'--notes-file',
|
|
963
1065
|
'--assistant',
|
|
964
1066
|
'--assistant-model',
|
|
965
1067
|
'--assistant-effort',
|
|
@@ -1035,6 +1137,17 @@ function detectVersionFile() {
|
|
|
1035
1137
|
return null
|
|
1036
1138
|
}
|
|
1037
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
|
+
|
|
1038
1151
|
// An explicit `versionFile: null` means "versions by tag" and must not be re-detected, so
|
|
1039
1152
|
// the distinction is between the key being absent and the key being set to null.
|
|
1040
1153
|
const configuredVersionFile = Object.hasOwn(userConfig, 'versionFile')
|
|
@@ -1069,6 +1182,15 @@ const goModule = existsSync('go.mod')
|
|
|
1069
1182
|
const projectName =
|
|
1070
1183
|
manifest?.name ?? (versionFile ? readNameFrom(versionFile) : null) ?? goModule ?? basename(root)
|
|
1071
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
|
+
|
|
1072
1194
|
// Validate every name that was asked for, not just the ones that survive: a typo in
|
|
1073
1195
|
// --skip would otherwise delete nothing and silently run the step you meant to drop.
|
|
1074
1196
|
const requestedStepNames = [
|
|
@@ -1294,8 +1416,24 @@ if (bumping && compareVersions(version, currentVersion) <= 0) {
|
|
|
1294
1416
|
const dirty = tryRead('git', ['status', '--porcelain'])
|
|
1295
1417
|
if (dirty === null) fail('could not read git status')
|
|
1296
1418
|
else if (dirty && runs('commit')) {
|
|
1297
|
-
|
|
1419
|
+
const entries = dirty.split('\n')
|
|
1420
|
+
ok(`working tree has ${entries.length} change(s) — will be committed first`)
|
|
1298
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
|
+
}
|
|
1299
1437
|
} else if (dirty) {
|
|
1300
1438
|
fail(`working tree is not clean:\n${indent(formatStatus(dirty))}`)
|
|
1301
1439
|
} else ok('working tree clean')
|
|
@@ -1476,7 +1614,7 @@ const notesDeferred = !!(dirty && runs('commit') && assistant)
|
|
|
1476
1614
|
function draftNotesFor(v) {
|
|
1477
1615
|
const { lastTag, subjects, commits } = commitsSinceLastTag()
|
|
1478
1616
|
if (!commits.length) return null
|
|
1479
|
-
if (!assistant) return changelogFromCommits(commits)
|
|
1617
|
+
if (!assistant) return changelogFromCommits(commits, remoteLinks(config.remote))
|
|
1480
1618
|
if (shallow) {
|
|
1481
1619
|
warn(
|
|
1482
1620
|
`shallow clone: only ${subjects.length} commit(s) are visible, so the notes will ` +
|
|
@@ -1484,7 +1622,10 @@ function draftNotesFor(v) {
|
|
|
1484
1622
|
)
|
|
1485
1623
|
}
|
|
1486
1624
|
note(`drafting notes from ${subjects.length} commit(s) with ${assistantName}...`)
|
|
1487
|
-
return
|
|
1625
|
+
return (
|
|
1626
|
+
draftReleaseNotes(v, subjects, lastTag) ??
|
|
1627
|
+
changelogFromCommits(commits, remoteLinks(config.remote))
|
|
1628
|
+
)
|
|
1488
1629
|
}
|
|
1489
1630
|
if (config.changelog && existsSync(config.changelog)) {
|
|
1490
1631
|
const text = readFileSync(config.changelog, 'utf8')
|
|
@@ -1548,6 +1689,29 @@ if (problems.length) {
|
|
|
1548
1689
|
// CONFIRM
|
|
1549
1690
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
1550
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
|
+
|
|
1551
1715
|
if (!assumeYes && !dryRun && process.stdin.isTTY) {
|
|
1552
1716
|
if (notes) console.log(`\n${bold('Release notes')}\n${indent(notes)}`)
|
|
1553
1717
|
const rl = createInterface({ input: process.stdin, output: process.stdout })
|
|
@@ -1560,7 +1724,11 @@ if (!assumeYes && !dryRun && process.stdin.isTTY) {
|
|
|
1560
1724
|
} finally {
|
|
1561
1725
|
rl.close()
|
|
1562
1726
|
}
|
|
1563
|
-
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
|
+
}
|
|
1564
1732
|
}
|
|
1565
1733
|
|
|
1566
1734
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -1571,18 +1739,8 @@ const staged = []
|
|
|
1571
1739
|
|
|
1572
1740
|
if (dirty && runs('commit')) {
|
|
1573
1741
|
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'])
|
|
1742
|
+
if (dryRun) console.log(` ${yellow('would run:')} git commit -m <drafted at run time>`)
|
|
1743
|
+
else mutate('git', ['commit', '-m', commitMessage])
|
|
1586
1744
|
|
|
1587
1745
|
// Now that the commit exists it is part of the release, so the notes can describe it.
|
|
1588
1746
|
if (notesDeferred && !dryRun) {
|
|
@@ -1636,6 +1794,20 @@ if (staged.length) {
|
|
|
1636
1794
|
mutate('git', ['commit', '-m', expand(config.commitMessage)])
|
|
1637
1795
|
}
|
|
1638
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
|
+
|
|
1639
1811
|
if (runs('tag') && !taggedCommit) {
|
|
1640
1812
|
step(`Annotated tag ${tag}`)
|
|
1641
1813
|
// The notes become the tag annotation too, so a CI release workflow can read them
|