@entro314labs/release-kit 2.9.0 → 2.9.2
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 +3 -3
- package/release.mjs +342 -94
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.2",
|
|
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",
|
|
@@ -49,8 +49,8 @@
|
|
|
49
49
|
"release": "node release.mjs"
|
|
50
50
|
},
|
|
51
51
|
"devDependencies": {
|
|
52
|
-
"oxfmt": "^0.
|
|
53
|
-
"oxlint": "^1.
|
|
52
|
+
"oxfmt": "^0.65.0",
|
|
53
|
+
"oxlint": "^1.80.0",
|
|
54
54
|
"semver": "^7.8.5"
|
|
55
55
|
},
|
|
56
56
|
"engines": {
|
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'))
|
|
@@ -684,10 +748,19 @@ function changelogFromCommits(commits, links = null, hidden = [], contributors =
|
|
|
684
748
|
|
|
685
749
|
const known = new Set(CHANGELOG_SECTIONS.map((s) => s.type))
|
|
686
750
|
const hiddenTypes = new Set(hidden)
|
|
751
|
+
// A dependency bump is conventionally written `chore(deps):` or `build(deps):` — the scope
|
|
752
|
+
// carries the meaning, and it is what dependabot, renovate and this tool's own drafter
|
|
753
|
+
// emit, because `deps:` is not in @commitlint/config-conventional's type-enum. Without
|
|
754
|
+
// this those bumps sit in Miscellaneous Chores and Build System while the Dependencies
|
|
755
|
+
// section stays empty. `fix(deps):` is left where it is: a fix is still a bug fix.
|
|
756
|
+
const isDependency = (c) =>
|
|
757
|
+
c.type === 'deps' || (c.scope === 'deps' && (c.type === 'chore' || c.type === 'build'))
|
|
687
758
|
for (const { type, section } of CHANGELOG_SECTIONS) {
|
|
688
759
|
if (hiddenTypes.has(type)) continue
|
|
689
|
-
const inSection = parsed.filter(
|
|
690
|
-
|
|
760
|
+
const inSection = parsed.filter((c) =>
|
|
761
|
+
type === 'deps'
|
|
762
|
+
? isDependency(c)
|
|
763
|
+
: !isDependency(c) && (c.type === type || (type === 'feat' && c.type === 'feature')),
|
|
691
764
|
)
|
|
692
765
|
if (!inSection.length) continue
|
|
693
766
|
lines.push(`### ${section}`, '')
|
|
@@ -849,7 +922,24 @@ const CHANGELOG_TYPES = CHANGELOG_SECTIONS.map((s) => s.type)
|
|
|
849
922
|
|
|
850
923
|
/** The drafter's types plus `feature`, the alias `changelogFromCommits` folds into feat. */
|
|
851
924
|
const KNOWN_TYPES = new Set([...CHANGELOG_TYPES, 'feature'])
|
|
852
|
-
|
|
925
|
+
|
|
926
|
+
/**
|
|
927
|
+
* The types this tool will write into somebody else's repository.
|
|
928
|
+
*
|
|
929
|
+
* Reading history is permissive on purpose — KNOWN_TYPES files whatever people wrote — but
|
|
930
|
+
* drafting is not, because a drafted subject has to survive the repository's own commit-msg
|
|
931
|
+
* hook. The near-universal gate is @commitlint/config-conventional, whose type-enum is
|
|
932
|
+
* exactly CHANGELOG_TYPES minus `deps`, and a drafted `deps: update dev tooling` aborted a
|
|
933
|
+
* release mid-commit with "type must be one of [build, chore, ci, ...]".
|
|
934
|
+
*
|
|
935
|
+
* Dependency work is still drafted and still reaches the Dependencies section: as
|
|
936
|
+
* `chore(deps):`, the scope dependabot and renovate emit, which `changelogFromCommits`
|
|
937
|
+
* routes there. Only the spelling the enum refuses is off the table.
|
|
938
|
+
*/
|
|
939
|
+
const WRITE_TYPES = CHANGELOG_TYPES.filter((type) => type !== 'deps')
|
|
940
|
+
|
|
941
|
+
/** The gate on a drafted subject — WRITE_TYPES, not KNOWN_TYPES. See WRITE_TYPES. */
|
|
942
|
+
const CONVENTIONAL_RE = new RegExp(`^(${WRITE_TYPES.join('|')})(\\([^)]+\\))?!?: .+`)
|
|
853
943
|
|
|
854
944
|
/**
|
|
855
945
|
* Check commit subjects against the grammar the rest of this file reads.
|
|
@@ -907,7 +997,9 @@ function draftCommitMessage() {
|
|
|
907
997
|
'Write a Conventional Commits message for these staged changes.',
|
|
908
998
|
'',
|
|
909
999
|
'Rules:',
|
|
910
|
-
`- Subject: "<type>(<optional scope>): <description>" where type is one of ${
|
|
1000
|
+
`- Subject: "<type>(<optional scope>): <description>" where type is one of ${WRITE_TYPES.join(', ')}.`,
|
|
1001
|
+
'- For a dependency update use the `deps` scope — `chore(deps): ...` — which the',
|
|
1002
|
+
' changelog files under Dependencies.',
|
|
911
1003
|
'- Subject in the imperative mood, no trailing period, under 72 characters.',
|
|
912
1004
|
'- Add a body only if the change needs explanation; separate it with a blank line.',
|
|
913
1005
|
'- Output the raw commit message and nothing else: no markdown fences, no preamble.',
|
|
@@ -2245,6 +2337,20 @@ if (skippedSteps) for (const name of parseStepList(skippedSteps)) steps.delete(n
|
|
|
2245
2337
|
if (autoCommit) steps.add('commit')
|
|
2246
2338
|
const runs = (name) => steps.has(name)
|
|
2247
2339
|
|
|
2340
|
+
/**
|
|
2341
|
+
* True when the commit step is off because a config `steps` list omits it — as opposed to
|
|
2342
|
+
* being switched off for this run with --skip or --only. Configs written before `commit`
|
|
2343
|
+
* became a default step omit it without ever having chosen to, so a dirty-tree refusal
|
|
2344
|
+
* caused by one deserves a hint that the flag-driven refusal does not: the flag user just
|
|
2345
|
+
* asked for exactly this.
|
|
2346
|
+
*/
|
|
2347
|
+
const commitExcludedByConfig =
|
|
2348
|
+
!runs('commit') &&
|
|
2349
|
+
!onlySteps &&
|
|
2350
|
+
!(skippedSteps && parseStepList(skippedSteps).includes('commit')) &&
|
|
2351
|
+
Array.isArray(config.steps) &&
|
|
2352
|
+
!config.steps.includes('commit')
|
|
2353
|
+
|
|
2248
2354
|
/**
|
|
2249
2355
|
* The drafting tool, resolved from --assistant then config. "auto" picks the first one
|
|
2250
2356
|
* present on PATH; a named tool must be known and installed, otherwise it is an error
|
|
@@ -2280,6 +2386,147 @@ if (assistantChoice !== 'none' && assistantChoice !== null) {
|
|
|
2280
2386
|
}
|
|
2281
2387
|
const assistant = assistantName ? ASSISTANTS[assistantName] : null
|
|
2282
2388
|
|
|
2389
|
+
/**
|
|
2390
|
+
* Registries whose preflight can be run, keyed by the first word of the publish command.
|
|
2391
|
+
* Each declares how that CLI answers "who am I", "does this version already exist" and
|
|
2392
|
+
* "is this package there at all"; any may be null when the tool has no such notion. A
|
|
2393
|
+
* publish command outside this table (vsce, a shell pipeline) is run as written with no
|
|
2394
|
+
* preflight — it cannot be introspected, and guessing would invent failures.
|
|
2395
|
+
*
|
|
2396
|
+
* `exists` is what separates "that version was never published" from "the registry did not
|
|
2397
|
+
* answer". Both make the version lookup exit non-zero, and only the first one means the
|
|
2398
|
+
* release is unfinished — see `versionShipped`.
|
|
2399
|
+
*/
|
|
2400
|
+
const REGISTRIES = {
|
|
2401
|
+
npm: {
|
|
2402
|
+
whoami: ['whoami'],
|
|
2403
|
+
published: (name, v) => ['view', `${name}@${v}`, 'version'],
|
|
2404
|
+
exists: (name) => ['view', name, 'version'],
|
|
2405
|
+
},
|
|
2406
|
+
pnpm: {
|
|
2407
|
+
whoami: ['whoami'],
|
|
2408
|
+
published: (name, v) => ['view', `${name}@${v}`, 'version'],
|
|
2409
|
+
exists: (name) => ['view', name, 'version'],
|
|
2410
|
+
},
|
|
2411
|
+
bun: {
|
|
2412
|
+
whoami: ['pm', 'whoami'],
|
|
2413
|
+
published: (name, v) => ['pm', 'view', `${name}@${v}`, 'version'],
|
|
2414
|
+
exists: (name) => ['pm', 'view', name, 'version'],
|
|
2415
|
+
},
|
|
2416
|
+
// uv authenticates with a token from the environment rather than a logged-in session,
|
|
2417
|
+
// and skips duplicate uploads itself via --check-url, so there is no version lookup.
|
|
2418
|
+
uv: { env: ['UV_PUBLISH_TOKEN', 'UV_PUBLISH_PASSWORD'], login: 'set UV_PUBLISH_TOKEN' },
|
|
2419
|
+
// cargo has no "who am I": crates.io auth is a token, either in the environment or in
|
|
2420
|
+
// the credentials file `cargo login` writes. `cargo info` is the version lookup, and
|
|
2421
|
+
// exits non-zero for a version the index does not carry (cargo 1.82+).
|
|
2422
|
+
cargo: {
|
|
2423
|
+
env: ['CARGO_REGISTRY_TOKEN', 'CARGO_REGISTRIES_CRATES_IO_TOKEN'],
|
|
2424
|
+
credentials: [
|
|
2425
|
+
join(homedir(), '.cargo', 'credentials.toml'),
|
|
2426
|
+
join(homedir(), '.cargo', 'credentials'),
|
|
2427
|
+
],
|
|
2428
|
+
login: 'run `cargo login`, or set CARGO_REGISTRY_TOKEN',
|
|
2429
|
+
published: (name, v) => ['info', `${name}@${v}`],
|
|
2430
|
+
exists: (name) => ['info', name],
|
|
2431
|
+
},
|
|
2432
|
+
// For Go the tag is the release; `go list` warms the module proxy and doubles as the
|
|
2433
|
+
// check for whether this version is already resolvable.
|
|
2434
|
+
go: {
|
|
2435
|
+
published: (name, v) => ['list', '-m', `${name}@${v}`],
|
|
2436
|
+
exists: (name) => ['list', '-m', `${name}@latest`],
|
|
2437
|
+
},
|
|
2438
|
+
}
|
|
2439
|
+
|
|
2440
|
+
/**
|
|
2441
|
+
* Which manifest records the name a registry knows this project by, when it is not the one
|
|
2442
|
+
* `projectName` came from. They are not always the same string: a Tauri plugin publishes as
|
|
2443
|
+
* `@tauri-apps/plugin-x` on npm and `tauri-plugin-x` on crates.io, so looking the crate up
|
|
2444
|
+
* under its npm name would report every version as unpublished.
|
|
2445
|
+
*/
|
|
2446
|
+
const NAME_MANIFEST_BY_CLI = { cargo: 'Cargo.toml' }
|
|
2447
|
+
|
|
2448
|
+
function registryName(cli) {
|
|
2449
|
+
const manifest = NAME_MANIFEST_BY_CLI[cli]
|
|
2450
|
+
if (!manifest) return projectName
|
|
2451
|
+
const source = versionTargets.find((entry) => basename(entry.path) === manifest)
|
|
2452
|
+
return (source && readNameFrom(source)) ?? projectName
|
|
2453
|
+
}
|
|
2454
|
+
|
|
2455
|
+
/**
|
|
2456
|
+
* `publish` is one command or several, because one source tree can own a package in more
|
|
2457
|
+
* than one ecosystem. They run in the configured order.
|
|
2458
|
+
*/
|
|
2459
|
+
const publishList = config.publish == null ? [] : [config.publish].flat()
|
|
2460
|
+
if (publishList.some((entry) => typeof entry !== 'string')) {
|
|
2461
|
+
abort('publish must be a command string, an array of command strings, or null')
|
|
2462
|
+
}
|
|
2463
|
+
|
|
2464
|
+
/**
|
|
2465
|
+
* One answer per version, per run: the lookups are network calls, and the same version is
|
|
2466
|
+
* asked about by the baseline walk and again by preflight.
|
|
2467
|
+
*/
|
|
2468
|
+
const shippedCache = new Map()
|
|
2469
|
+
|
|
2470
|
+
/**
|
|
2471
|
+
* Whether a version actually reached every registry this project publishes to.
|
|
2472
|
+
*
|
|
2473
|
+
* A tag is not a release. The tag and the push happen before the publish, so a publish that
|
|
2474
|
+
* fails — a failing prepublish gate, an expired npm session, a network drop — leaves the
|
|
2475
|
+
* version tagged, pushed and changelogged but absent from the registry. Nothing downstream
|
|
2476
|
+
* has it, and until this could be asked, nothing upstream knew.
|
|
2477
|
+
*
|
|
2478
|
+
* "Not there" and "could not ask" are the same exit code from every one of these CLIs, and
|
|
2479
|
+
* conflating them is dangerous in one direction only: reading an unreachable registry as
|
|
2480
|
+
* "nothing was ever published" would drag the notes baseline back through the whole
|
|
2481
|
+
* history. The bare-name lookup separates them — a package whose own name resolves is a
|
|
2482
|
+
* registry that answered.
|
|
2483
|
+
*
|
|
2484
|
+
* @param {string} v
|
|
2485
|
+
* @returns {boolean | null} null when nothing here can answer
|
|
2486
|
+
*/
|
|
2487
|
+
function versionShipped(v) {
|
|
2488
|
+
if (shippedCache.has(v)) return shippedCache.get(v)
|
|
2489
|
+
let answer = null
|
|
2490
|
+
for (const template of publishList) {
|
|
2491
|
+
const cli = template.trim().split(/\s+/)[0]
|
|
2492
|
+
const registry = REGISTRIES[cli]
|
|
2493
|
+
if (!registry?.published || !registry.exists) continue
|
|
2494
|
+
const name = registryName(cli)
|
|
2495
|
+
if (succeeds(cli, registry.published(name, v))) {
|
|
2496
|
+
answer ??= true
|
|
2497
|
+
continue
|
|
2498
|
+
}
|
|
2499
|
+
// One registry missing the version is enough: the release did not finish everywhere,
|
|
2500
|
+
// and the half that is missing is the half still owed to its consumers.
|
|
2501
|
+
if (succeeds(cli, registry.exists(name))) {
|
|
2502
|
+
answer = false
|
|
2503
|
+
break
|
|
2504
|
+
}
|
|
2505
|
+
answer = null
|
|
2506
|
+
break
|
|
2507
|
+
}
|
|
2508
|
+
shippedCache.set(v, answer)
|
|
2509
|
+
return answer
|
|
2510
|
+
}
|
|
2511
|
+
|
|
2512
|
+
/**
|
|
2513
|
+
* The release that was started and never finished: the newest tag, sitting at HEAD, whose
|
|
2514
|
+
* version never reached the registry.
|
|
2515
|
+
*
|
|
2516
|
+
* Re-running the same command is the documented way to recover from a release that died
|
|
2517
|
+
* partway through, and `auto` was the one target that could not: it resolves a version from
|
|
2518
|
+
* the commits since the last tag, finds none, and aborts with "nothing to release" — while
|
|
2519
|
+
* the thing left to do is the publish the previous run never got to.
|
|
2520
|
+
*
|
|
2521
|
+
* @returns {{name: string, version: string} | null}
|
|
2522
|
+
*/
|
|
2523
|
+
function unfinishedRelease() {
|
|
2524
|
+
const [newest] = releaseTags()
|
|
2525
|
+
if (!newest || versionShipped(newest.version) !== false) return null
|
|
2526
|
+
const at = tryRead('git', ['rev-list', '-n', '1', newest.name])
|
|
2527
|
+
return at && at === tryRead('git', ['rev-parse', 'HEAD']) ? newest : null
|
|
2528
|
+
}
|
|
2529
|
+
|
|
2283
2530
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
2284
2531
|
// RESOLVE THE TARGET VERSION
|
|
2285
2532
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -2292,6 +2539,9 @@ say(
|
|
|
2292
2539
|
/** What `auto` inferred, kept so preflight can show the reasoning. */
|
|
2293
2540
|
let autoBump = null
|
|
2294
2541
|
|
|
2542
|
+
/** The tag of a previous release this run is finishing rather than starting. */
|
|
2543
|
+
let resuming = null
|
|
2544
|
+
|
|
2295
2545
|
let version
|
|
2296
2546
|
if (!target) {
|
|
2297
2547
|
if (!currentVersion) {
|
|
@@ -2308,24 +2558,35 @@ if (!target) {
|
|
|
2308
2558
|
'from.\n Pass the first version explicitly: release-kit 0.1.0',
|
|
2309
2559
|
)
|
|
2310
2560
|
}
|
|
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
|
|
2561
|
+
// A release that died after the tag and before the publish is finished by re-running the
|
|
2562
|
+
// same command — but only while nothing new has happened. A commit or a working tree that
|
|
2563
|
+
// `--commit` is about to turn into one moves HEAD past the tag, and publishing then would
|
|
2564
|
+
// ship a tree the tag does not describe; that work belongs in the next version, which is
|
|
2565
|
+
// what the baseline below makes sure it is released as.
|
|
2566
|
+
const wouldCommitMore = !!tryRead('git', ['status', '--porcelain']) && runs('commit')
|
|
2567
|
+
const pending = wouldCommitMore ? null : unfinishedRelease()
|
|
2568
|
+
if (pending) {
|
|
2569
|
+
;({ name: resuming, version } = pending)
|
|
2323
2570
|
} else {
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
|
|
2327
|
-
|
|
2328
|
-
|
|
2571
|
+
const { commits, lastTag } = commitsSinceLastTag({ shipped: true })
|
|
2572
|
+
if (!commits.length) {
|
|
2573
|
+
abort(
|
|
2574
|
+
`no releasable commits since ${lastTag ?? 'the start of the project'} — nothing to release`,
|
|
2575
|
+
)
|
|
2576
|
+
}
|
|
2577
|
+
autoBump = inferBump(commits, currentVersion, config.versioning)
|
|
2578
|
+
if (autoBump.releaseAs) {
|
|
2579
|
+
if (!parseVersion(autoBump.releaseAs)) {
|
|
2580
|
+
abort(`Release-As: ${autoBump.releaseAs} in a commit is not a semver version`)
|
|
2581
|
+
}
|
|
2582
|
+
version = autoBump.releaseAs
|
|
2583
|
+
} else {
|
|
2584
|
+
version = incrementVersion(
|
|
2585
|
+
currentVersion,
|
|
2586
|
+
autoBump.bump,
|
|
2587
|
+
requestedPreid ?? preidOf(currentVersion),
|
|
2588
|
+
)
|
|
2589
|
+
}
|
|
2329
2590
|
}
|
|
2330
2591
|
} else if (BUMPS.has(target)) {
|
|
2331
2592
|
if (!currentVersion) {
|
|
@@ -2419,64 +2680,6 @@ function dirtyPaths() {
|
|
|
2419
2680
|
)
|
|
2420
2681
|
}
|
|
2421
2682
|
|
|
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
2683
|
/** Each publish command with the CLI it drives, that CLI's preflight row, and its name. */
|
|
2481
2684
|
const publishTargets = runs('publish')
|
|
2482
2685
|
? publishList.map((template) => {
|
|
@@ -2532,6 +2735,39 @@ if (autoBump) {
|
|
|
2532
2735
|
}
|
|
2533
2736
|
}
|
|
2534
2737
|
|
|
2738
|
+
if (resuming) {
|
|
2739
|
+
ok(`finishing ${resuming}: it was tagged and pushed, but never reached the registry`)
|
|
2740
|
+
}
|
|
2741
|
+
|
|
2742
|
+
// A previous release that never shipped is not history — its commits are still owed to
|
|
2743
|
+
// whoever installs this package, and they are in this release's range because of it. Say
|
|
2744
|
+
// so: the changelog keeps the section that was written for that version, and a section
|
|
2745
|
+
// naming a version no registry carries is worth a human deciding about.
|
|
2746
|
+
const absorbed = absorbedReleaseTags({ stable: !isPrerelease }).filter(
|
|
2747
|
+
(entry) => entry.version !== version,
|
|
2748
|
+
)
|
|
2749
|
+
if (absorbed.length) {
|
|
2750
|
+
const names = absorbed.map((entry) => entry.name).join(', ')
|
|
2751
|
+
const many = absorbed.length > 1
|
|
2752
|
+
const existingChangelog =
|
|
2753
|
+
config.changelog && existsSync(config.changelog) ? readFileSync(config.changelog, 'utf8') : null
|
|
2754
|
+
const documented = absorbed
|
|
2755
|
+
.filter((entry) => existingChangelog && changelogSection(existingChangelog, entry.version))
|
|
2756
|
+
.map((entry) => entry.version)
|
|
2757
|
+
const stale = documented.length
|
|
2758
|
+
? `\n ${config.changelog} still documents ${documented.join(', ')} — ${
|
|
2759
|
+
documented.length > 1 ? 'versions' : 'a version'
|
|
2760
|
+
} no registry carries. Fold ${
|
|
2761
|
+
documented.length > 1 ? 'those sections' : 'that section'
|
|
2762
|
+
} into ${version} by hand.`
|
|
2763
|
+
: ''
|
|
2764
|
+
warn(
|
|
2765
|
+
`${names} ${many ? 'were' : 'was'} tagged but never published, so ${version} ships ${
|
|
2766
|
+
many ? 'their' : 'its'
|
|
2767
|
+
} commits as well as its own.${stale}`,
|
|
2768
|
+
)
|
|
2769
|
+
}
|
|
2770
|
+
|
|
2535
2771
|
// Writing the version is the first mutating step, and it used to discover a file it
|
|
2536
2772
|
// could not write *while writing the others* — aborting with a raw stack trace after
|
|
2537
2773
|
// some of them had already changed. Every target is checked here instead.
|
|
@@ -2595,7 +2831,12 @@ else if (dirty && runs('commit')) {
|
|
|
2595
2831
|
)
|
|
2596
2832
|
}
|
|
2597
2833
|
} else if (dirty) {
|
|
2598
|
-
|
|
2834
|
+
const hint = commitExcludedByConfig
|
|
2835
|
+
? '\n The steps list in release.config.json omits `commit` (it may predate ' +
|
|
2836
|
+
'commit becoming\n a default step). Add "commit" to it, or pass --commit ' +
|
|
2837
|
+
'to commit these now.'
|
|
2838
|
+
: ''
|
|
2839
|
+
fail(`working tree is not clean:\n${indent(formatStatus(dirty))}${hint}`)
|
|
2599
2840
|
} else ok('working tree clean')
|
|
2600
2841
|
|
|
2601
2842
|
if (assistant) {
|
|
@@ -2887,6 +3128,7 @@ function draftNotesFor(v) {
|
|
|
2887
3128
|
// between two candidates rather than the release.
|
|
2888
3129
|
const { lastTag, subjects, commits, contributors } = commitsSinceLastTag({
|
|
2889
3130
|
stable: !isPrerelease,
|
|
3131
|
+
shipped: true,
|
|
2890
3132
|
})
|
|
2891
3133
|
if (!commits.length) return null
|
|
2892
3134
|
|
|
@@ -2979,13 +3221,19 @@ for (const asset of config.assets) {
|
|
|
2979
3221
|
|
|
2980
3222
|
// Reusing a tag is the resume path, and a resume writes nothing. If this run would still
|
|
2981
3223
|
// 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
|
-
|
|
3224
|
+
// the wrong revision — which is silent until someone checks out the tag, and worse for the
|
|
3225
|
+
// working tree: `publish` sends what is on disk now, not what the tag describes.
|
|
3226
|
+
const wouldCommit = [
|
|
3227
|
+
dirty && runs('commit') && 'the working tree',
|
|
3228
|
+
bumping && 'a version bump',
|
|
3229
|
+
rolledChangelog && 'a changelog entry',
|
|
3230
|
+
].filter(Boolean)
|
|
3231
|
+
if (taggedCommit && runs('tag') && wouldCommit.length) {
|
|
2984
3232
|
fail(
|
|
2985
3233
|
`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.',
|
|
3234
|
+
`${wouldCommit.join(' and ')}.\n` +
|
|
3235
|
+
' That commit would leave the tag behind HEAD, and publish a tree it does not ' +
|
|
3236
|
+
'describe.\n Release a new version, or use --only with the steps that remain.',
|
|
2989
3237
|
)
|
|
2990
3238
|
}
|
|
2991
3239
|
|