@entro314labs/release-kit 2.9.0 → 2.9.1
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 +36 -5
- package/package.json +1 -1
- package/release.mjs +310 -90
package/README.md
CHANGED
|
@@ -265,10 +265,14 @@ release-kit minor --skip commit # never touch uncommitted work
|
|
|
265
265
|
Or fix it per project, and just run `release-kit minor`:
|
|
266
266
|
|
|
267
267
|
```json
|
|
268
|
-
{ "steps": ["version", "changelog", "tag", "push", "release"] }
|
|
268
|
+
{ "steps": ["commit", "version", "changelog", "tag", "push", "release"] }
|
|
269
269
|
```
|
|
270
270
|
|
|
271
|
-
`steps` decides **what** runs
|
|
271
|
+
`steps` decides **what** runs — and an explicit list is complete: a step not named does
|
|
272
|
+
not run, including `commit`. A `steps` list written before `commit` became a default step
|
|
273
|
+
therefore opts out of it without having chosen to; add `"commit"` to the list (as the
|
|
274
|
+
examples here do), or pass `--commit` for one run. Every other key describes **how** a
|
|
275
|
+
step behaves — `publish`
|
|
272
276
|
is the command, `changelog` is the file. A step whose configuration is `null` runs as a
|
|
273
277
|
no-op and says so, rather than silently meaning "skip".
|
|
274
278
|
|
|
@@ -353,6 +357,8 @@ rather than stopping at the first problem.
|
|
|
353
357
|
- `gh` is installed and authenticated
|
|
354
358
|
- Commit and tag signing can actually sign, and the key is one GitHub will accept
|
|
355
359
|
- The publishing CLI is authenticated, and the version is not already published
|
|
360
|
+
- The previous release actually reached the registry — one that did not is either finished
|
|
361
|
+
by this run or absorbed into it _(warning)_
|
|
356
362
|
- Configured release assets exist
|
|
357
363
|
- The configured `verify` command passes — the project's own gate (tests, build) runs
|
|
358
364
|
before anything mutates, instead of a `prepublishOnly` hook failing after the commit,
|
|
@@ -382,6 +388,31 @@ Re-run the same command. Every step is idempotent:
|
|
|
382
388
|
So a run that dies at the publish step (2FA timeout, flaky network) picks up exactly where
|
|
383
389
|
it stopped. There is no cleanup step, no `--resume`, and nothing to remember.
|
|
384
390
|
|
|
391
|
+
`auto` is included in that. It normally resolves the version from the commits since the
|
|
392
|
+
last tag, and after a failed publish there are none — the tag it would read from is the one
|
|
393
|
+
the dead run made. Rather than aborting with "no releasable commits", it finishes that
|
|
394
|
+
release: same version, same tag, the steps that remain.
|
|
395
|
+
|
|
396
|
+
### A release that was never published
|
|
397
|
+
|
|
398
|
+
A tag is not a release. The tag and the push happen before the publish, so a publish that
|
|
399
|
+
fails leaves the version tagged, pushed and written into the changelog while no registry
|
|
400
|
+
carries it — and everything that reads "the last release" from tags then reads it wrong.
|
|
401
|
+
|
|
402
|
+
Once history has moved past that tag, finishing it is no longer possible: publishing sends
|
|
403
|
+
what is on disk, and that is no longer what the tag describes. The next release absorbs it
|
|
404
|
+
instead. History is read from the last tag whose version actually reached the registry, so
|
|
405
|
+
the unpublished release's commits are in range for both the notes and the bump `auto`
|
|
406
|
+
infers — a feature that never shipped still makes the next release a minor. Preflight says
|
|
407
|
+
which tags were absorbed, and points at the changelog sections that now document versions
|
|
408
|
+
no registry carries.
|
|
409
|
+
|
|
410
|
+
This costs one registry lookup per release, and the registry is the only thing asked: a
|
|
411
|
+
project configured with `"publish": null` has nothing that can answer, so it reads history
|
|
412
|
+
from tags as it always did. When the registry does not answer at all — offline, a proxy, an
|
|
413
|
+
expired session, a private package with no credentials — nothing is concluded from the
|
|
414
|
+
silence, and history is again read exactly as it was before.
|
|
415
|
+
|
|
385
416
|
The one case that is not recoverable by re-running is a tag that exists at a _different_
|
|
386
417
|
commit than `HEAD`. That is a genuine conflict, and it aborts rather than guessing.
|
|
387
418
|
|
|
@@ -599,7 +630,7 @@ in one release, across three formats, with no scripting:
|
|
|
599
630
|
"apps/desktop/src-tauri/Cargo.lock"
|
|
600
631
|
],
|
|
601
632
|
"publish": null,
|
|
602
|
-
"steps": ["version", "changelog", "tag", "push"]
|
|
633
|
+
"steps": ["commit", "version", "changelog", "tag", "push"]
|
|
603
634
|
}
|
|
604
635
|
```
|
|
605
636
|
|
|
@@ -902,7 +933,7 @@ release themselves, with the artifacts attached. That is their job. release-kit'
|
|
|
902
933
|
at the pushed tag:
|
|
903
934
|
|
|
904
935
|
```json
|
|
905
|
-
{ "steps": ["version", "changelog", "tag", "push"], "notesFile": "dist-notes.md" }
|
|
936
|
+
{ "steps": ["commit", "version", "changelog", "tag", "push"], "notesFile": "dist-notes.md" }
|
|
906
937
|
```
|
|
907
938
|
|
|
908
939
|
Nothing after `push` — no `publish`, no `release`. The tag push is the handoff, and it is
|
|
@@ -948,7 +979,7 @@ A project can be both — a Rust crate that also ships binaries, say. Publish th
|
|
|
948
979
|
release-kit and let the build tool handle the binaries and the release:
|
|
949
980
|
|
|
950
981
|
```json
|
|
951
|
-
{ "publish": "cargo publish", "steps": ["version", "changelog", "tag", "push", "publish"] }
|
|
982
|
+
{ "publish": "cargo publish", "steps": ["commit", "version", "changelog", "tag", "push", "publish"] }
|
|
952
983
|
```
|
|
953
984
|
|
|
954
985
|
## 🔄 Keeping vendored copies in sync
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@entro314labs/release-kit",
|
|
3
|
-
"version": "2.9.
|
|
3
|
+
"version": "2.9.1",
|
|
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
|
@@ -24,7 +24,10 @@
|
|
|
24
24
|
* - Every step is idempotent. A run interrupted partway through (a publish timeout, a
|
|
25
25
|
* network failure) can be re-run: an already-written version, an existing tag at HEAD,
|
|
26
26
|
* an already-published version and an existing release are each detected and skipped.
|
|
27
|
-
* There is no cleanup step and no --resume flag.
|
|
27
|
+
* There is no cleanup step and no --resume flag. `auto` re-run that way finishes the
|
|
28
|
+
* unpublished release rather than reporting nothing to do, and once history has moved
|
|
29
|
+
* on past it, the version that does ship carries its commits — a tag is not a release,
|
|
30
|
+
* and work that never reached a registry is still unreleased.
|
|
28
31
|
*
|
|
29
32
|
* Configuration is optional. Defaults are the conventions (package.json version,
|
|
30
33
|
* CHANGELOG.md, main branch, `v` tag prefix, npm publish); a release.config.json beside
|
|
@@ -354,7 +357,28 @@ const ASSISTANTS = {
|
|
|
354
357
|
},
|
|
355
358
|
codex: {
|
|
356
359
|
command: 'codex',
|
|
357
|
-
|
|
360
|
+
// A draft needs a bare model, but `codex exec` boots the user's whole session by
|
|
361
|
+
// default — plugins (with their MCP servers, hooks and skills), memories, apps and a
|
|
362
|
+
// notify program — several thousand tokens of context and seconds of startup that a
|
|
363
|
+
// one-shot prose prompt never uses. All four features are stable flags; an unknown
|
|
364
|
+
// flag on some future codex makes the draft fail closed into the deterministic
|
|
365
|
+
// fallback, which is this tool's contract for every assistant failure.
|
|
366
|
+
args: [
|
|
367
|
+
'exec',
|
|
368
|
+
'--skip-git-repo-check',
|
|
369
|
+
'--sandbox',
|
|
370
|
+
'read-only',
|
|
371
|
+
'--disable',
|
|
372
|
+
'plugins',
|
|
373
|
+
'--disable',
|
|
374
|
+
'hooks',
|
|
375
|
+
'--disable',
|
|
376
|
+
'memories',
|
|
377
|
+
'--disable',
|
|
378
|
+
'apps',
|
|
379
|
+
'-c',
|
|
380
|
+
'notify=[]',
|
|
381
|
+
],
|
|
358
382
|
probe: ['--version'],
|
|
359
383
|
model: (m) => ['-m', m],
|
|
360
384
|
effort: (e) => ['-c', `model_reasoning_effort="${e}"`],
|
|
@@ -481,21 +505,61 @@ function releaseTags(prefix = config.tagPrefix ?? '') {
|
|
|
481
505
|
/**
|
|
482
506
|
* The tag a release reads its history from.
|
|
483
507
|
*
|
|
484
|
-
* @param {{stable?: boolean}} [options] `stable` when the version being
|
|
485
|
-
* prerelease identifier, which rolls the release candidates leading to it
|
|
486
|
-
* their work is what is shipping now, and reading from the last candidate
|
|
487
|
-
* the gap between the last two candidates. Promoting `2.0.0-rc.2` to
|
|
488
|
-
* produced empty notes, because the one commit in range was the release
|
|
489
|
-
* Releasing a candidate keeps the full ordering, so each candidate's notes say what
|
|
508
|
+
* @param {{stable?: boolean, shipped?: boolean}} [options] `stable` when the version being
|
|
509
|
+
* released has no prerelease identifier, which rolls the release candidates leading to it
|
|
510
|
+
* up into it: their work is what is shipping now, and reading from the last candidate
|
|
511
|
+
* describes only the gap between the last two candidates. Promoting `2.0.0-rc.2` to
|
|
512
|
+
* `2.0.0` that way produced empty notes, because the one commit in range was the release
|
|
513
|
+
* chore. Releasing a candidate keeps the full ordering, so each candidate's notes say what
|
|
490
514
|
* changed in that candidate rather than repeating the whole cycle.
|
|
515
|
+
*
|
|
516
|
+
* `shipped` skips tags whose version never reached the registry — see
|
|
517
|
+
* `absorbedReleaseTags` for why, and `versionShipped` for how that is established.
|
|
491
518
|
* @returns {string | null}
|
|
492
519
|
*/
|
|
493
|
-
function lastReleaseTag({ stable = false, prefix } = {}) {
|
|
520
|
+
function lastReleaseTag({ stable = false, prefix, shipped = false } = {}) {
|
|
494
521
|
const tags = releaseTags(prefix)
|
|
495
522
|
const eligible = stable ? tags.filter(({ version }) => !parseVersion(version).pre.length) : tags
|
|
523
|
+
if (!shipped) return eligible[0]?.name ?? null
|
|
524
|
+
for (const tag of eligible) {
|
|
525
|
+
const state = versionShipped(tag.version)
|
|
526
|
+
if (state === true) return tag.name
|
|
527
|
+
// Nothing could answer. Walking further asks the same unanswerable question about older
|
|
528
|
+
// versions, and treating silence as "never published" would reach back to the first
|
|
529
|
+
// commit in the repository — so this reads history exactly as it did before.
|
|
530
|
+
if (state === null) break
|
|
531
|
+
}
|
|
532
|
+
// Either the registry went quiet, or no tag this project ever made is on it — a project
|
|
533
|
+
// that tags and publishes by hand looks exactly like that. Neither is evidence that the
|
|
534
|
+
// last release failed, so the newest tag stays the baseline.
|
|
496
535
|
return eligible[0]?.name ?? null
|
|
497
536
|
}
|
|
498
537
|
|
|
538
|
+
/**
|
|
539
|
+
* The tags this release is about to absorb: versions that were tagged, pushed and written
|
|
540
|
+
* into the changelog, and then never published.
|
|
541
|
+
*
|
|
542
|
+
* Their commits are still unreleased work — the tag says otherwise, and that is what made
|
|
543
|
+
* them disappear. `2.0.1` failed to publish, `2.0.2` read its history from the `v2.0.1` tag
|
|
544
|
+
* and shipped notes covering one commit, and the ten commits `2.0.1` was made of are named
|
|
545
|
+
* in no release anyone can install. Reading from the last *shipped* tag puts them back in
|
|
546
|
+
* range, both for the notes and for the bump `auto` infers from them.
|
|
547
|
+
*
|
|
548
|
+
* @returns {{name: string, version: string}[]} newest first, empty in the ordinary case
|
|
549
|
+
*/
|
|
550
|
+
function absorbedReleaseTags({ stable = false } = {}) {
|
|
551
|
+
const tags = releaseTags()
|
|
552
|
+
const eligible = stable ? tags.filter(({ version }) => !parseVersion(version).pre.length) : tags
|
|
553
|
+
const baseline = lastReleaseTag({ stable, shipped: true })
|
|
554
|
+
const absorbed = []
|
|
555
|
+
for (const tag of eligible) {
|
|
556
|
+
if (tag.name === baseline) break
|
|
557
|
+
if (versionShipped(tag.version) !== false) break
|
|
558
|
+
absorbed.push(tag)
|
|
559
|
+
}
|
|
560
|
+
return absorbed
|
|
561
|
+
}
|
|
562
|
+
|
|
499
563
|
/** Commit subjects since the last release tag, with release and merge commits filtered out. */
|
|
500
564
|
function commitsSinceLastTag(options) {
|
|
501
565
|
const ignored = (config.ignoreCommits ?? []).map((pattern) => new RegExp(pattern, 'i'))
|
|
@@ -2245,6 +2309,20 @@ if (skippedSteps) for (const name of parseStepList(skippedSteps)) steps.delete(n
|
|
|
2245
2309
|
if (autoCommit) steps.add('commit')
|
|
2246
2310
|
const runs = (name) => steps.has(name)
|
|
2247
2311
|
|
|
2312
|
+
/**
|
|
2313
|
+
* True when the commit step is off because a config `steps` list omits it — as opposed to
|
|
2314
|
+
* being switched off for this run with --skip or --only. Configs written before `commit`
|
|
2315
|
+
* became a default step omit it without ever having chosen to, so a dirty-tree refusal
|
|
2316
|
+
* caused by one deserves a hint that the flag-driven refusal does not: the flag user just
|
|
2317
|
+
* asked for exactly this.
|
|
2318
|
+
*/
|
|
2319
|
+
const commitExcludedByConfig =
|
|
2320
|
+
!runs('commit') &&
|
|
2321
|
+
!onlySteps &&
|
|
2322
|
+
!(skippedSteps && parseStepList(skippedSteps).includes('commit')) &&
|
|
2323
|
+
Array.isArray(config.steps) &&
|
|
2324
|
+
!config.steps.includes('commit')
|
|
2325
|
+
|
|
2248
2326
|
/**
|
|
2249
2327
|
* The drafting tool, resolved from --assistant then config. "auto" picks the first one
|
|
2250
2328
|
* present on PATH; a named tool must be known and installed, otherwise it is an error
|
|
@@ -2280,6 +2358,147 @@ if (assistantChoice !== 'none' && assistantChoice !== null) {
|
|
|
2280
2358
|
}
|
|
2281
2359
|
const assistant = assistantName ? ASSISTANTS[assistantName] : null
|
|
2282
2360
|
|
|
2361
|
+
/**
|
|
2362
|
+
* Registries whose preflight can be run, keyed by the first word of the publish command.
|
|
2363
|
+
* Each declares how that CLI answers "who am I", "does this version already exist" and
|
|
2364
|
+
* "is this package there at all"; any may be null when the tool has no such notion. A
|
|
2365
|
+
* publish command outside this table (vsce, a shell pipeline) is run as written with no
|
|
2366
|
+
* preflight — it cannot be introspected, and guessing would invent failures.
|
|
2367
|
+
*
|
|
2368
|
+
* `exists` is what separates "that version was never published" from "the registry did not
|
|
2369
|
+
* answer". Both make the version lookup exit non-zero, and only the first one means the
|
|
2370
|
+
* release is unfinished — see `versionShipped`.
|
|
2371
|
+
*/
|
|
2372
|
+
const REGISTRIES = {
|
|
2373
|
+
npm: {
|
|
2374
|
+
whoami: ['whoami'],
|
|
2375
|
+
published: (name, v) => ['view', `${name}@${v}`, 'version'],
|
|
2376
|
+
exists: (name) => ['view', name, 'version'],
|
|
2377
|
+
},
|
|
2378
|
+
pnpm: {
|
|
2379
|
+
whoami: ['whoami'],
|
|
2380
|
+
published: (name, v) => ['view', `${name}@${v}`, 'version'],
|
|
2381
|
+
exists: (name) => ['view', name, 'version'],
|
|
2382
|
+
},
|
|
2383
|
+
bun: {
|
|
2384
|
+
whoami: ['pm', 'whoami'],
|
|
2385
|
+
published: (name, v) => ['pm', 'view', `${name}@${v}`, 'version'],
|
|
2386
|
+
exists: (name) => ['pm', 'view', name, 'version'],
|
|
2387
|
+
},
|
|
2388
|
+
// uv authenticates with a token from the environment rather than a logged-in session,
|
|
2389
|
+
// and skips duplicate uploads itself via --check-url, so there is no version lookup.
|
|
2390
|
+
uv: { env: ['UV_PUBLISH_TOKEN', 'UV_PUBLISH_PASSWORD'], login: 'set UV_PUBLISH_TOKEN' },
|
|
2391
|
+
// cargo has no "who am I": crates.io auth is a token, either in the environment or in
|
|
2392
|
+
// the credentials file `cargo login` writes. `cargo info` is the version lookup, and
|
|
2393
|
+
// exits non-zero for a version the index does not carry (cargo 1.82+).
|
|
2394
|
+
cargo: {
|
|
2395
|
+
env: ['CARGO_REGISTRY_TOKEN', 'CARGO_REGISTRIES_CRATES_IO_TOKEN'],
|
|
2396
|
+
credentials: [
|
|
2397
|
+
join(homedir(), '.cargo', 'credentials.toml'),
|
|
2398
|
+
join(homedir(), '.cargo', 'credentials'),
|
|
2399
|
+
],
|
|
2400
|
+
login: 'run `cargo login`, or set CARGO_REGISTRY_TOKEN',
|
|
2401
|
+
published: (name, v) => ['info', `${name}@${v}`],
|
|
2402
|
+
exists: (name) => ['info', name],
|
|
2403
|
+
},
|
|
2404
|
+
// For Go the tag is the release; `go list` warms the module proxy and doubles as the
|
|
2405
|
+
// check for whether this version is already resolvable.
|
|
2406
|
+
go: {
|
|
2407
|
+
published: (name, v) => ['list', '-m', `${name}@${v}`],
|
|
2408
|
+
exists: (name) => ['list', '-m', `${name}@latest`],
|
|
2409
|
+
},
|
|
2410
|
+
}
|
|
2411
|
+
|
|
2412
|
+
/**
|
|
2413
|
+
* Which manifest records the name a registry knows this project by, when it is not the one
|
|
2414
|
+
* `projectName` came from. They are not always the same string: a Tauri plugin publishes as
|
|
2415
|
+
* `@tauri-apps/plugin-x` on npm and `tauri-plugin-x` on crates.io, so looking the crate up
|
|
2416
|
+
* under its npm name would report every version as unpublished.
|
|
2417
|
+
*/
|
|
2418
|
+
const NAME_MANIFEST_BY_CLI = { cargo: 'Cargo.toml' }
|
|
2419
|
+
|
|
2420
|
+
function registryName(cli) {
|
|
2421
|
+
const manifest = NAME_MANIFEST_BY_CLI[cli]
|
|
2422
|
+
if (!manifest) return projectName
|
|
2423
|
+
const source = versionTargets.find((entry) => basename(entry.path) === manifest)
|
|
2424
|
+
return (source && readNameFrom(source)) ?? projectName
|
|
2425
|
+
}
|
|
2426
|
+
|
|
2427
|
+
/**
|
|
2428
|
+
* `publish` is one command or several, because one source tree can own a package in more
|
|
2429
|
+
* than one ecosystem. They run in the configured order.
|
|
2430
|
+
*/
|
|
2431
|
+
const publishList = config.publish == null ? [] : [config.publish].flat()
|
|
2432
|
+
if (publishList.some((entry) => typeof entry !== 'string')) {
|
|
2433
|
+
abort('publish must be a command string, an array of command strings, or null')
|
|
2434
|
+
}
|
|
2435
|
+
|
|
2436
|
+
/**
|
|
2437
|
+
* One answer per version, per run: the lookups are network calls, and the same version is
|
|
2438
|
+
* asked about by the baseline walk and again by preflight.
|
|
2439
|
+
*/
|
|
2440
|
+
const shippedCache = new Map()
|
|
2441
|
+
|
|
2442
|
+
/**
|
|
2443
|
+
* Whether a version actually reached every registry this project publishes to.
|
|
2444
|
+
*
|
|
2445
|
+
* A tag is not a release. The tag and the push happen before the publish, so a publish that
|
|
2446
|
+
* fails — a failing prepublish gate, an expired npm session, a network drop — leaves the
|
|
2447
|
+
* version tagged, pushed and changelogged but absent from the registry. Nothing downstream
|
|
2448
|
+
* has it, and until this could be asked, nothing upstream knew.
|
|
2449
|
+
*
|
|
2450
|
+
* "Not there" and "could not ask" are the same exit code from every one of these CLIs, and
|
|
2451
|
+
* conflating them is dangerous in one direction only: reading an unreachable registry as
|
|
2452
|
+
* "nothing was ever published" would drag the notes baseline back through the whole
|
|
2453
|
+
* history. The bare-name lookup separates them — a package whose own name resolves is a
|
|
2454
|
+
* registry that answered.
|
|
2455
|
+
*
|
|
2456
|
+
* @param {string} v
|
|
2457
|
+
* @returns {boolean | null} null when nothing here can answer
|
|
2458
|
+
*/
|
|
2459
|
+
function versionShipped(v) {
|
|
2460
|
+
if (shippedCache.has(v)) return shippedCache.get(v)
|
|
2461
|
+
let answer = null
|
|
2462
|
+
for (const template of publishList) {
|
|
2463
|
+
const cli = template.trim().split(/\s+/)[0]
|
|
2464
|
+
const registry = REGISTRIES[cli]
|
|
2465
|
+
if (!registry?.published || !registry.exists) continue
|
|
2466
|
+
const name = registryName(cli)
|
|
2467
|
+
if (succeeds(cli, registry.published(name, v))) {
|
|
2468
|
+
answer ??= true
|
|
2469
|
+
continue
|
|
2470
|
+
}
|
|
2471
|
+
// One registry missing the version is enough: the release did not finish everywhere,
|
|
2472
|
+
// and the half that is missing is the half still owed to its consumers.
|
|
2473
|
+
if (succeeds(cli, registry.exists(name))) {
|
|
2474
|
+
answer = false
|
|
2475
|
+
break
|
|
2476
|
+
}
|
|
2477
|
+
answer = null
|
|
2478
|
+
break
|
|
2479
|
+
}
|
|
2480
|
+
shippedCache.set(v, answer)
|
|
2481
|
+
return answer
|
|
2482
|
+
}
|
|
2483
|
+
|
|
2484
|
+
/**
|
|
2485
|
+
* The release that was started and never finished: the newest tag, sitting at HEAD, whose
|
|
2486
|
+
* version never reached the registry.
|
|
2487
|
+
*
|
|
2488
|
+
* Re-running the same command is the documented way to recover from a release that died
|
|
2489
|
+
* partway through, and `auto` was the one target that could not: it resolves a version from
|
|
2490
|
+
* the commits since the last tag, finds none, and aborts with "nothing to release" — while
|
|
2491
|
+
* the thing left to do is the publish the previous run never got to.
|
|
2492
|
+
*
|
|
2493
|
+
* @returns {{name: string, version: string} | null}
|
|
2494
|
+
*/
|
|
2495
|
+
function unfinishedRelease() {
|
|
2496
|
+
const [newest] = releaseTags()
|
|
2497
|
+
if (!newest || versionShipped(newest.version) !== false) return null
|
|
2498
|
+
const at = tryRead('git', ['rev-list', '-n', '1', newest.name])
|
|
2499
|
+
return at && at === tryRead('git', ['rev-parse', 'HEAD']) ? newest : null
|
|
2500
|
+
}
|
|
2501
|
+
|
|
2283
2502
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
2284
2503
|
// RESOLVE THE TARGET VERSION
|
|
2285
2504
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -2292,6 +2511,9 @@ say(
|
|
|
2292
2511
|
/** What `auto` inferred, kept so preflight can show the reasoning. */
|
|
2293
2512
|
let autoBump = null
|
|
2294
2513
|
|
|
2514
|
+
/** The tag of a previous release this run is finishing rather than starting. */
|
|
2515
|
+
let resuming = null
|
|
2516
|
+
|
|
2295
2517
|
let version
|
|
2296
2518
|
if (!target) {
|
|
2297
2519
|
if (!currentVersion) {
|
|
@@ -2308,24 +2530,35 @@ if (!target) {
|
|
|
2308
2530
|
'from.\n Pass the first version explicitly: release-kit 0.1.0',
|
|
2309
2531
|
)
|
|
2310
2532
|
}
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
if (
|
|
2319
|
-
|
|
2320
|
-
abort(`Release-As: ${autoBump.releaseAs} in a commit is not a semver version`)
|
|
2321
|
-
}
|
|
2322
|
-
version = autoBump.releaseAs
|
|
2533
|
+
// A release that died after the tag and before the publish is finished by re-running the
|
|
2534
|
+
// same command — but only while nothing new has happened. A commit or a working tree that
|
|
2535
|
+
// `--commit` is about to turn into one moves HEAD past the tag, and publishing then would
|
|
2536
|
+
// ship a tree the tag does not describe; that work belongs in the next version, which is
|
|
2537
|
+
// what the baseline below makes sure it is released as.
|
|
2538
|
+
const wouldCommitMore = !!tryRead('git', ['status', '--porcelain']) && runs('commit')
|
|
2539
|
+
const pending = wouldCommitMore ? null : unfinishedRelease()
|
|
2540
|
+
if (pending) {
|
|
2541
|
+
;({ name: resuming, version } = pending)
|
|
2323
2542
|
} else {
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
|
|
2327
|
-
|
|
2328
|
-
|
|
2543
|
+
const { commits, lastTag } = commitsSinceLastTag({ shipped: true })
|
|
2544
|
+
if (!commits.length) {
|
|
2545
|
+
abort(
|
|
2546
|
+
`no releasable commits since ${lastTag ?? 'the start of the project'} — nothing to release`,
|
|
2547
|
+
)
|
|
2548
|
+
}
|
|
2549
|
+
autoBump = inferBump(commits, currentVersion, config.versioning)
|
|
2550
|
+
if (autoBump.releaseAs) {
|
|
2551
|
+
if (!parseVersion(autoBump.releaseAs)) {
|
|
2552
|
+
abort(`Release-As: ${autoBump.releaseAs} in a commit is not a semver version`)
|
|
2553
|
+
}
|
|
2554
|
+
version = autoBump.releaseAs
|
|
2555
|
+
} else {
|
|
2556
|
+
version = incrementVersion(
|
|
2557
|
+
currentVersion,
|
|
2558
|
+
autoBump.bump,
|
|
2559
|
+
requestedPreid ?? preidOf(currentVersion),
|
|
2560
|
+
)
|
|
2561
|
+
}
|
|
2329
2562
|
}
|
|
2330
2563
|
} else if (BUMPS.has(target)) {
|
|
2331
2564
|
if (!currentVersion) {
|
|
@@ -2419,64 +2652,6 @@ function dirtyPaths() {
|
|
|
2419
2652
|
)
|
|
2420
2653
|
}
|
|
2421
2654
|
|
|
2422
|
-
/**
|
|
2423
|
-
* Registries whose preflight can be run, keyed by the first word of the publish command.
|
|
2424
|
-
* Each declares how that CLI answers "who am I" and "does this version already exist";
|
|
2425
|
-
* either may be null when the tool has no such notion. A publish command outside this
|
|
2426
|
-
* table (vsce, a shell pipeline) is run as written with no preflight — it cannot be
|
|
2427
|
-
* introspected, and guessing would invent failures.
|
|
2428
|
-
*/
|
|
2429
|
-
const REGISTRIES = {
|
|
2430
|
-
npm: { whoami: ['whoami'], published: (name, v) => ['view', `${name}@${v}`, 'version'] },
|
|
2431
|
-
pnpm: { whoami: ['whoami'], published: (name, v) => ['view', `${name}@${v}`, 'version'] },
|
|
2432
|
-
bun: {
|
|
2433
|
-
whoami: ['pm', 'whoami'],
|
|
2434
|
-
published: (name, v) => ['pm', 'view', `${name}@${v}`, 'version'],
|
|
2435
|
-
},
|
|
2436
|
-
// uv authenticates with a token from the environment rather than a logged-in session,
|
|
2437
|
-
// and skips duplicate uploads itself via --check-url, so there is no version lookup.
|
|
2438
|
-
uv: { env: ['UV_PUBLISH_TOKEN', 'UV_PUBLISH_PASSWORD'], login: 'set UV_PUBLISH_TOKEN' },
|
|
2439
|
-
// cargo has no "who am I": crates.io auth is a token, either in the environment or in
|
|
2440
|
-
// the credentials file `cargo login` writes. `cargo info` is the version lookup, and
|
|
2441
|
-
// exits non-zero for a version the index does not carry (cargo 1.82+).
|
|
2442
|
-
cargo: {
|
|
2443
|
-
env: ['CARGO_REGISTRY_TOKEN', 'CARGO_REGISTRIES_CRATES_IO_TOKEN'],
|
|
2444
|
-
credentials: [
|
|
2445
|
-
join(homedir(), '.cargo', 'credentials.toml'),
|
|
2446
|
-
join(homedir(), '.cargo', 'credentials'),
|
|
2447
|
-
],
|
|
2448
|
-
login: 'run `cargo login`, or set CARGO_REGISTRY_TOKEN',
|
|
2449
|
-
published: (name, v) => ['info', `${name}@${v}`],
|
|
2450
|
-
},
|
|
2451
|
-
// For Go the tag is the release; `go list` warms the module proxy and doubles as the
|
|
2452
|
-
// check for whether this version is already resolvable.
|
|
2453
|
-
go: { published: (name, v) => ['list', '-m', `${name}@${v}`] },
|
|
2454
|
-
}
|
|
2455
|
-
|
|
2456
|
-
/**
|
|
2457
|
-
* Which manifest records the name a registry knows this project by, when it is not the one
|
|
2458
|
-
* `projectName` came from. They are not always the same string: a Tauri plugin publishes as
|
|
2459
|
-
* `@tauri-apps/plugin-x` on npm and `tauri-plugin-x` on crates.io, so looking the crate up
|
|
2460
|
-
* under its npm name would report every version as unpublished.
|
|
2461
|
-
*/
|
|
2462
|
-
const NAME_MANIFEST_BY_CLI = { cargo: 'Cargo.toml' }
|
|
2463
|
-
|
|
2464
|
-
function registryName(cli) {
|
|
2465
|
-
const manifest = NAME_MANIFEST_BY_CLI[cli]
|
|
2466
|
-
if (!manifest) return projectName
|
|
2467
|
-
const source = versionTargets.find((entry) => basename(entry.path) === manifest)
|
|
2468
|
-
return (source && readNameFrom(source)) ?? projectName
|
|
2469
|
-
}
|
|
2470
|
-
|
|
2471
|
-
/**
|
|
2472
|
-
* `publish` is one command or several, because one source tree can own a package in more
|
|
2473
|
-
* than one ecosystem. They run in the configured order.
|
|
2474
|
-
*/
|
|
2475
|
-
const publishList = config.publish == null ? [] : [config.publish].flat()
|
|
2476
|
-
if (publishList.some((entry) => typeof entry !== 'string')) {
|
|
2477
|
-
abort('publish must be a command string, an array of command strings, or null')
|
|
2478
|
-
}
|
|
2479
|
-
|
|
2480
2655
|
/** Each publish command with the CLI it drives, that CLI's preflight row, and its name. */
|
|
2481
2656
|
const publishTargets = runs('publish')
|
|
2482
2657
|
? publishList.map((template) => {
|
|
@@ -2532,6 +2707,39 @@ if (autoBump) {
|
|
|
2532
2707
|
}
|
|
2533
2708
|
}
|
|
2534
2709
|
|
|
2710
|
+
if (resuming) {
|
|
2711
|
+
ok(`finishing ${resuming}: it was tagged and pushed, but never reached the registry`)
|
|
2712
|
+
}
|
|
2713
|
+
|
|
2714
|
+
// A previous release that never shipped is not history — its commits are still owed to
|
|
2715
|
+
// whoever installs this package, and they are in this release's range because of it. Say
|
|
2716
|
+
// so: the changelog keeps the section that was written for that version, and a section
|
|
2717
|
+
// naming a version no registry carries is worth a human deciding about.
|
|
2718
|
+
const absorbed = absorbedReleaseTags({ stable: !isPrerelease }).filter(
|
|
2719
|
+
(entry) => entry.version !== version,
|
|
2720
|
+
)
|
|
2721
|
+
if (absorbed.length) {
|
|
2722
|
+
const names = absorbed.map((entry) => entry.name).join(', ')
|
|
2723
|
+
const many = absorbed.length > 1
|
|
2724
|
+
const existingChangelog =
|
|
2725
|
+
config.changelog && existsSync(config.changelog) ? readFileSync(config.changelog, 'utf8') : null
|
|
2726
|
+
const documented = absorbed
|
|
2727
|
+
.filter((entry) => existingChangelog && changelogSection(existingChangelog, entry.version))
|
|
2728
|
+
.map((entry) => entry.version)
|
|
2729
|
+
const stale = documented.length
|
|
2730
|
+
? `\n ${config.changelog} still documents ${documented.join(', ')} — ${
|
|
2731
|
+
documented.length > 1 ? 'versions' : 'a version'
|
|
2732
|
+
} no registry carries. Fold ${
|
|
2733
|
+
documented.length > 1 ? 'those sections' : 'that section'
|
|
2734
|
+
} into ${version} by hand.`
|
|
2735
|
+
: ''
|
|
2736
|
+
warn(
|
|
2737
|
+
`${names} ${many ? 'were' : 'was'} tagged but never published, so ${version} ships ${
|
|
2738
|
+
many ? 'their' : 'its'
|
|
2739
|
+
} commits as well as its own.${stale}`,
|
|
2740
|
+
)
|
|
2741
|
+
}
|
|
2742
|
+
|
|
2535
2743
|
// Writing the version is the first mutating step, and it used to discover a file it
|
|
2536
2744
|
// could not write *while writing the others* — aborting with a raw stack trace after
|
|
2537
2745
|
// some of them had already changed. Every target is checked here instead.
|
|
@@ -2595,7 +2803,12 @@ else if (dirty && runs('commit')) {
|
|
|
2595
2803
|
)
|
|
2596
2804
|
}
|
|
2597
2805
|
} else if (dirty) {
|
|
2598
|
-
|
|
2806
|
+
const hint = commitExcludedByConfig
|
|
2807
|
+
? '\n The steps list in release.config.json omits `commit` (it may predate ' +
|
|
2808
|
+
'commit becoming\n a default step). Add "commit" to it, or pass --commit ' +
|
|
2809
|
+
'to commit these now.'
|
|
2810
|
+
: ''
|
|
2811
|
+
fail(`working tree is not clean:\n${indent(formatStatus(dirty))}${hint}`)
|
|
2599
2812
|
} else ok('working tree clean')
|
|
2600
2813
|
|
|
2601
2814
|
if (assistant) {
|
|
@@ -2887,6 +3100,7 @@ function draftNotesFor(v) {
|
|
|
2887
3100
|
// between two candidates rather than the release.
|
|
2888
3101
|
const { lastTag, subjects, commits, contributors } = commitsSinceLastTag({
|
|
2889
3102
|
stable: !isPrerelease,
|
|
3103
|
+
shipped: true,
|
|
2890
3104
|
})
|
|
2891
3105
|
if (!commits.length) return null
|
|
2892
3106
|
|
|
@@ -2979,13 +3193,19 @@ for (const asset of config.assets) {
|
|
|
2979
3193
|
|
|
2980
3194
|
// Reusing a tag is the resume path, and a resume writes nothing. If this run would still
|
|
2981
3195
|
// produce a commit, that commit moves HEAD past the tag and the release ends up tagged at
|
|
2982
|
-
// the wrong revision — which is silent until someone checks out the tag
|
|
2983
|
-
|
|
3196
|
+
// the wrong revision — which is silent until someone checks out the tag, and worse for the
|
|
3197
|
+
// working tree: `publish` sends what is on disk now, not what the tag describes.
|
|
3198
|
+
const wouldCommit = [
|
|
3199
|
+
dirty && runs('commit') && 'the working tree',
|
|
3200
|
+
bumping && 'a version bump',
|
|
3201
|
+
rolledChangelog && 'a changelog entry',
|
|
3202
|
+
].filter(Boolean)
|
|
3203
|
+
if (taggedCommit && runs('tag') && wouldCommit.length) {
|
|
2984
3204
|
fail(
|
|
2985
3205
|
`tag ${tag} already exists at HEAD, but this run would still commit ` +
|
|
2986
|
-
`${
|
|
2987
|
-
' That commit would leave the tag behind HEAD
|
|
2988
|
-
'--only with the steps that remain.',
|
|
3206
|
+
`${wouldCommit.join(' and ')}.\n` +
|
|
3207
|
+
' That commit would leave the tag behind HEAD, and publish a tree it does not ' +
|
|
3208
|
+
'describe.\n Release a new version, or use --only with the steps that remain.',
|
|
2989
3209
|
)
|
|
2990
3210
|
}
|
|
2991
3211
|
|