@entro314labs/release-kit 2.9.2 → 2.9.4
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 +68 -38
- package/TRAIN.md +5 -1
- package/package.json +4 -4
- package/release.mjs +173 -22
- package/train.mjs +50 -11
package/README.md
CHANGED
|
@@ -185,20 +185,22 @@ Prerelease bumps need `--preid` unless the current version already carries one t
|
|
|
185
185
|
|
|
186
186
|
### Flags
|
|
187
187
|
|
|
188
|
-
| Flag | Effect
|
|
189
|
-
| ---------------------------- |
|
|
190
|
-
| `--only <steps>` | Run only these steps, comma-separated.
|
|
191
|
-
| `--skip <steps>` | Run every step except these.
|
|
192
|
-
| `--commit` | Force the `commit` step on when a `steps` config removed it.
|
|
193
|
-
| `--dry-run` | Print every step, execute nothing. Preflight still runs and still reports.
|
|
194
|
-
| `--yes`, `-y` | Skip the confirmation prompt.
|
|
195
|
-
| `--preid <id>` | Prerelease identifier: `alpha`, `beta`, `rc`, `next`, `nightly`, `canary`.
|
|
196
|
-
| `--dist-tag <name>` | Override the npm dist-tag. Always wins over the derived one.
|
|
197
|
-
| `--
|
|
198
|
-
| `--
|
|
199
|
-
| `--assistant
|
|
200
|
-
| `--
|
|
201
|
-
| `--
|
|
188
|
+
| Flag | Effect |
|
|
189
|
+
| ---------------------------- | ----------------------------------------------------------------------------- |
|
|
190
|
+
| `--only <steps>` | Run only these steps, comma-separated. |
|
|
191
|
+
| `--skip <steps>` | Run every step except these. |
|
|
192
|
+
| `--commit` | Force the `commit` step on when a `steps` config removed it. |
|
|
193
|
+
| `--dry-run` | Print every step, execute nothing. Preflight still runs and still reports. |
|
|
194
|
+
| `--yes`, `-y` | Skip the confirmation prompt. |
|
|
195
|
+
| `--preid <id>` | Prerelease identifier: `alpha`, `beta`, `rc`, `next`, `nightly`, `canary`. |
|
|
196
|
+
| `--dist-tag <name>` | Override the npm dist-tag. Always wins over the derived one. |
|
|
197
|
+
| `--notes <source>` | Where notes come from: `auto`, `changelog`, `assistant`, `commits`, `github`. |
|
|
198
|
+
| `--notes-file <path>` | Write the resolved notes to a file for the next tool — see `notesFile`. |
|
|
199
|
+
| `--assistant <name>` | Drafting CLI: `auto`, `none`, `claude`, `codex`. |
|
|
200
|
+
| `--assistant-model <name>` | Model the assistant runs with. |
|
|
201
|
+
| `--assistant-effort <level>` | Reasoning effort the assistant runs with. |
|
|
202
|
+
| `--sync <dir>...` | Copy this script into other projects and exit. Touches no git state. |
|
|
203
|
+
| `--help`, `-h` | Full flag list. |
|
|
202
204
|
|
|
203
205
|
### Linting commits
|
|
204
206
|
|
|
@@ -350,6 +352,13 @@ rather than stopping at the first problem.
|
|
|
350
352
|
|
|
351
353
|
- The target version is greater than the current one — and for `auto`, which bump the
|
|
352
354
|
commits imply and why
|
|
355
|
+
- The version step will write the target, when the target differs from what the files say
|
|
356
|
+
— a tag naming one version over a manifest carrying another is refused, since
|
|
357
|
+
`npm publish` sends the manifest
|
|
358
|
+
- A relative bump is not counting from a version a dead run wrote and never committed
|
|
359
|
+
(the file says one thing on disk and another at `HEAD`), and not skipping past a release
|
|
360
|
+
tagged at `HEAD` that never reached the registry — both are refused with the command that
|
|
361
|
+
finishes the earlier release instead
|
|
353
362
|
- Working tree is clean, or listed for commit when the `commit` step runs
|
|
354
363
|
- On the configured branch, and not on a detached HEAD
|
|
355
364
|
- The remote exists, is reachable, and the branch is not behind it
|
|
@@ -393,6 +402,13 @@ last tag, and after a failed publish there are none — the tag it would read fr
|
|
|
393
402
|
the dead run made. Rather than aborting with "no releasable commits", it finishes that
|
|
394
403
|
release: same version, same tag, the steps that remain.
|
|
395
404
|
|
|
405
|
+
A relative bump is the one target that cannot be re-run as-is, because it counts from the
|
|
406
|
+
version the dead run already wrote: `minor` after a dead `minor` would release `1.2.0`
|
|
407
|
+
from the commit `v1.1.0` already tags. Preflight refuses both shapes of that — the bump
|
|
408
|
+
written but never committed, and the tag at `HEAD` that never reached the registry — and
|
|
409
|
+
names the command that finishes the earlier release: `release-kit 1.1.0`, or no target, or
|
|
410
|
+
`auto`.
|
|
411
|
+
|
|
396
412
|
### A release that was never published
|
|
397
413
|
|
|
398
414
|
A tag is not a release. The tag and the push happen before the publish, so a publish that
|
|
@@ -666,23 +682,27 @@ execution is not wired up yet.
|
|
|
666
682
|
`release.config.json`, beside `package.json`. Every key is optional; unknown keys abort
|
|
667
683
|
rather than being silently ignored.
|
|
668
684
|
|
|
669
|
-
| Key | Default | Meaning
|
|
670
|
-
| --------------- | ---------------------- |
|
|
671
|
-
| `steps` | all
|
|
672
|
-
| `tagPrefix` | `"v"` | Prepended to the version to form the tag
|
|
673
|
-
| `branch` | `"main"` | The only branch a release may run from; `null` allows any
|
|
674
|
-
| `remote` | `"origin"` | Git remote to push to
|
|
675
|
-
| `changelog` | `"CHANGELOG.md"` | Changelog path; `null` for a project without one
|
|
676
|
-
| `versionFile` | detected | Where the version lives; `null` versions by tag alone
|
|
677
|
-
| `versionFiles` | detected | Further files kept in sync; a path or `{ path, pattern }`
|
|
678
|
-
| `publish` | detected | Publish command, or an array of them; `null` publishes nothing
|
|
679
|
-
| `versioning` | `"conventional"` | How `auto` infers; or `always-patch` / `-minor` / `-major`
|
|
680
|
-
| `verify` | `null` | Command run during preflight; non-zero aborts before anything mutates
|
|
681
|
-
| `hooks` | `{}` | Commands run between the steps — see [Hooks](#-hooks)
|
|
682
|
-
| `assistant` | `null` | Drafting CLI: a name, `"auto"`, or `{ tool, model, effort }`
|
|
683
|
-
| `commitMessage` | `"chore(release): %t"` | Release commit subject
|
|
684
|
-
| `releaseTitle` | `"%t"` | GitHub release title
|
|
685
|
-
| `assets` | `[]` | Files attached to the GitHub release
|
|
685
|
+
| Key | Default | Meaning |
|
|
686
|
+
| --------------- | ---------------------- | ----------------------------------------------------------------------- |
|
|
687
|
+
| `steps` | all seven | Which steps run; the order is fixed. `commit` no-ops on a clean tree |
|
|
688
|
+
| `tagPrefix` | `"v"` | Prepended to the version to form the tag |
|
|
689
|
+
| `branch` | `"main"` | The only branch a release may run from; `null` allows any |
|
|
690
|
+
| `remote` | `"origin"` | Git remote to push to |
|
|
691
|
+
| `changelog` | `"CHANGELOG.md"` | Changelog path; `null` for a project without one |
|
|
692
|
+
| `versionFile` | detected | Where the version lives; `null` versions by tag alone |
|
|
693
|
+
| `versionFiles` | detected | Further files kept in sync; a path or `{ path, pattern }` |
|
|
694
|
+
| `publish` | detected | Publish command, or an array of them; `null` publishes nothing |
|
|
695
|
+
| `versioning` | `"conventional"` | How `auto` infers; or `always-patch` / `-minor` / `-major` |
|
|
696
|
+
| `verify` | `null` | Command run during preflight; non-zero aborts before anything mutates |
|
|
697
|
+
| `hooks` | `{}` | Commands run between the steps — see [Hooks](#-hooks) |
|
|
698
|
+
| `assistant` | `null` | Drafting CLI: a name, `"auto"`, or `{ tool, model, effort }` |
|
|
699
|
+
| `commitMessage` | `"chore(release): %t"` | Release commit subject |
|
|
700
|
+
| `releaseTitle` | `"%t"` | GitHub release title |
|
|
701
|
+
| `assets` | `[]` | Files attached to the GitHub release |
|
|
702
|
+
| `notes` | `"auto"` | Notes source; or force `changelog` / `assistant` / `commits` / `github` |
|
|
703
|
+
| `notesFile` | `null` | Write the resolved notes here for the tool that runs next |
|
|
704
|
+
| `hiddenTypes` | `[]` | Commit types left out of commit-derived notes |
|
|
705
|
+
| `ignoreCommits` | release, merge, wip… | Regexes for commits that are bookkeeping rather than change |
|
|
686
706
|
|
|
687
707
|
Command and message strings expand four tokens: `%v` version, `%t` tag, `%n` package
|
|
688
708
|
name, `%d` npm dist-tag. In the `publish` command line the substituted values are
|
|
@@ -774,7 +794,10 @@ Two npm behaviours are handled automatically:
|
|
|
774
794
|
at all.** In GitHub Actions with
|
|
775
795
|
`id-token: write`, or GitLab CI/CircleCI with `NPM_ID_TOKEN`, `whoami` fails while
|
|
776
796
|
`publish` succeeds. That environment is detected and the auth check is skipped, so a
|
|
777
|
-
valid CI release is not aborted over a missing token it does not need.
|
|
797
|
+
valid CI release is not aborted over a missing token it does not need. That covers the
|
|
798
|
+
CLIs that exchange the OIDC token themselves — npm, pnpm, bun and `uv publish`. cargo is
|
|
799
|
+
not one of them: crates.io's trusted publishing goes through an action that turns the
|
|
800
|
+
token into `CARGO_REGISTRY_TOKEN`, so the cargo credential check still applies in CI.
|
|
778
801
|
|
|
779
802
|
### Examples
|
|
780
803
|
|
|
@@ -933,11 +956,13 @@ release themselves, with the artifacts attached. That is their job. release-kit'
|
|
|
933
956
|
at the pushed tag:
|
|
934
957
|
|
|
935
958
|
```json
|
|
936
|
-
{ "steps": ["commit", "version", "changelog", "tag", "push"]
|
|
959
|
+
{ "steps": ["commit", "version", "changelog", "tag", "push"] }
|
|
937
960
|
```
|
|
938
961
|
|
|
939
962
|
Nothing after `push` — no `publish`, no `release`. The tag push is the handoff, and it is
|
|
940
|
-
what triggers the build workflow
|
|
963
|
+
what triggers the build workflow. The notes travel in the annotated tag, which is the one
|
|
964
|
+
thing that reaches a fresh checkout on another machine; the signature block a signed tag
|
|
965
|
+
appends is stripped before the file is handed over:
|
|
941
966
|
|
|
942
967
|
```yaml
|
|
943
968
|
on:
|
|
@@ -949,6 +974,10 @@ jobs:
|
|
|
949
974
|
steps:
|
|
950
975
|
- uses: actions/checkout@v5
|
|
951
976
|
with: { fetch-depth: 0 }
|
|
977
|
+
- name: Read the release notes off the tag
|
|
978
|
+
run: |
|
|
979
|
+
git tag -l --format='%(contents)' "$GITHUB_REF_NAME" \
|
|
980
|
+
| sed '/^-----BEGIN [A-Z ]*SIGNATURE-----$/,$d' > dist-notes.md
|
|
952
981
|
- run: goreleaser release --clean --release-notes dist-notes.md
|
|
953
982
|
```
|
|
954
983
|
|
|
@@ -968,10 +997,11 @@ generated by different code from the same commits, and they drift.
|
|
|
968
997
|
release-kit has already created one for that tag, goreleaser fails. Exactly one of them
|
|
969
998
|
should own it, and it should be the one attaching the binaries.
|
|
970
999
|
|
|
971
|
-
`notesFile`
|
|
972
|
-
|
|
973
|
-
with
|
|
974
|
-
|
|
1000
|
+
`notesFile` is the same handoff for a build tool that runs next **on the same machine**:
|
|
1001
|
+
`release-kit auto && goreleaser release --release-notes dist-notes.md`. It writes the text
|
|
1002
|
+
itself, with no signature to strip. It is written after the release commit and is not part
|
|
1003
|
+
of it, so a workflow on another machine never sees it — that is what the tag read above is
|
|
1004
|
+
for.
|
|
975
1005
|
|
|
976
1006
|
### Both at once
|
|
977
1007
|
|
package/TRAIN.md
CHANGED
|
@@ -12,7 +12,11 @@ readable, vendorable, zero dependencies.
|
|
|
12
12
|
**Status: prototype.** Discovery, graph derivation, registry-aware change detection,
|
|
13
13
|
cascade, planning, whole-train preflight, `seed-tags`, and the train summary work.
|
|
14
14
|
Execution (running release-kit per package) is not implemented yet — `train` without
|
|
15
|
-
`--dry-run` says so and exits.
|
|
15
|
+
`--dry-run` says so and exits. Three things the design below describes are not built
|
|
16
|
+
either, and the plan does not claim them: taking `packages` from `pnpm-workspace.yaml` /
|
|
17
|
+
`workspaces` when the config omits it (the config must list them today), and the two
|
|
18
|
+
per-package authentication checks in the preflight table (publish CLI and `gh`), which
|
|
19
|
+
each package's own release-kit run performs when execution lands.
|
|
16
20
|
|
|
17
21
|
```sh
|
|
18
22
|
release-train graph # print the derived dependency graph and topo order
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@entro314labs/release-kit",
|
|
3
|
-
"version": "2.9.
|
|
3
|
+
"version": "2.9.4",
|
|
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,11 +49,11 @@
|
|
|
49
49
|
"release": "node release.mjs"
|
|
50
50
|
},
|
|
51
51
|
"devDependencies": {
|
|
52
|
-
"oxfmt": "^0.
|
|
53
|
-
"oxlint": "^1.
|
|
52
|
+
"oxfmt": "^0.67.0",
|
|
53
|
+
"oxlint": "^1.82.0",
|
|
54
54
|
"semver": "^7.8.5"
|
|
55
55
|
},
|
|
56
56
|
"engines": {
|
|
57
|
-
"node": ">=
|
|
57
|
+
"node": ">=24"
|
|
58
58
|
}
|
|
59
59
|
}
|
package/release.mjs
CHANGED
|
@@ -47,6 +47,7 @@ import {
|
|
|
47
47
|
import { homedir, tmpdir } from 'node:os'
|
|
48
48
|
import { basename, dirname, join, relative, resolve, sep } from 'node:path'
|
|
49
49
|
import { createInterface } from 'node:readline/promises'
|
|
50
|
+
import { fileURLToPath } from 'node:url'
|
|
50
51
|
|
|
51
52
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
52
53
|
// CONFIG
|
|
@@ -115,9 +116,10 @@ import { createInterface } from 'node:readline/promises'
|
|
|
115
116
|
const STEPS = ['commit', 'version', 'changelog', 'tag', 'push', 'publish', 'release']
|
|
116
117
|
|
|
117
118
|
/**
|
|
118
|
-
* All seven. `commit`
|
|
119
|
-
*
|
|
120
|
-
*
|
|
119
|
+
* All seven. `commit` no-ops on a clean tree; on a dirty tree it stages everything and
|
|
120
|
+
* commits it, with a drafted message when an assistant is configured and a generated
|
|
121
|
+
* `chore:` message naming the files otherwise. Opt out with `--skip commit` or a `steps`
|
|
122
|
+
* config, which restores the refusal on a dirty tree.
|
|
121
123
|
*/
|
|
122
124
|
const DEFAULT_STEPS = [...STEPS]
|
|
123
125
|
|
|
@@ -185,11 +187,13 @@ release-kit — tag, publish, and release a JS/TS/Node project.
|
|
|
185
187
|
|
|
186
188
|
Target (optional; defaults to the version already in package.json):
|
|
187
189
|
<x.y.z> release this exact version
|
|
190
|
+
auto infer the bump from the commits since the last tag
|
|
188
191
|
patch minor major bump from the current version
|
|
189
192
|
prepatch preminor premajor prerelease
|
|
190
193
|
prerelease bump; needs --preid unless it can be inferred
|
|
191
194
|
|
|
192
|
-
Steps, in the fixed order they run. All
|
|
195
|
+
Steps, in the fixed order they run. All seven run by default; "commit" no-ops on a
|
|
196
|
+
clean tree:
|
|
193
197
|
${STEPS.join(' ')}
|
|
194
198
|
|
|
195
199
|
Subcommands (they check, print or copy, and never start a release):
|
|
@@ -1352,7 +1356,7 @@ function distTagFor(version, explicitTag) {
|
|
|
1352
1356
|
throw new Error(
|
|
1353
1357
|
`prerelease identifier "${label}" maps to no known dist-tag ` +
|
|
1354
1358
|
`(${[...KNOWN_CHANNELS].sort().join(', ')}). Publishing it as "latest" would ` +
|
|
1355
|
-
`clobber the stable line — pass --tag <
|
|
1359
|
+
`clobber the stable line — pass --dist-tag <name> to choose one explicitly.`,
|
|
1356
1360
|
)
|
|
1357
1361
|
}
|
|
1358
1362
|
|
|
@@ -1405,16 +1409,26 @@ const sectionOffsets = (text) => [...text.matchAll(/^## .*$/gm)].map((m) => m.in
|
|
|
1405
1409
|
*/
|
|
1406
1410
|
function insertChangelogSection(text, version, date, body) {
|
|
1407
1411
|
const entry = `## [${version}] - ${date}\n\n${body}\n`
|
|
1408
|
-
|
|
1412
|
+
const offsets = sectionOffsets(text)
|
|
1413
|
+
let comparable = false
|
|
1414
|
+
for (const offset of offsets) {
|
|
1409
1415
|
const heading = /^## \[?v?([\d.]+(?:-[\w.]+)?)\]?/m.exec(
|
|
1410
1416
|
text.slice(offset, text.indexOf('\n', offset)),
|
|
1411
1417
|
)
|
|
1412
|
-
//
|
|
1413
|
-
|
|
1418
|
+
// Headings that carry no version — [Unreleased], and the date headings a changelog
|
|
1419
|
+
// that never adopted semver is made of — are not positions this can order against.
|
|
1420
|
+
if (!heading || !parseVersion(heading[1])) continue
|
|
1421
|
+
comparable = true
|
|
1414
1422
|
if (compareVersions(version, heading[1]) > 0) {
|
|
1415
1423
|
return `${text.slice(0, offset)}${entry}\n${text.slice(offset)}`
|
|
1416
1424
|
}
|
|
1417
1425
|
}
|
|
1426
|
+
// Falling out of the loop means every version section is newer, so the release belongs
|
|
1427
|
+
// at the foot. With nothing to compare against it belongs at the head instead: appending
|
|
1428
|
+
// to a date-headed changelog would file the release below its oldest entry.
|
|
1429
|
+
if (!comparable && offsets.length) {
|
|
1430
|
+
return `${text.slice(0, offsets[0])}${entry}\n${text.slice(offsets[0])}`
|
|
1431
|
+
}
|
|
1418
1432
|
const trimmed = text.trimEnd()
|
|
1419
1433
|
return `${trimmed}\n\n${entry}`
|
|
1420
1434
|
}
|
|
@@ -1662,7 +1676,18 @@ function patternFor({ path, pattern, all = false }) {
|
|
|
1662
1676
|
/** @returns {string | null} the version recorded in a source file */
|
|
1663
1677
|
function readVersionFrom(entry) {
|
|
1664
1678
|
const source = versionSource(entry)
|
|
1665
|
-
|
|
1679
|
+
return versionInText(source, readFileSync(source.path, 'utf8'))
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
/**
|
|
1683
|
+
* The version a source file's text carries, resolved the same way it will be written.
|
|
1684
|
+
*
|
|
1685
|
+
* Split from `readVersionFrom` so the text can come from somewhere other than the working
|
|
1686
|
+
* tree — `git show HEAD:<path>` — and still be read by the resolver the write uses.
|
|
1687
|
+
*
|
|
1688
|
+
* @returns {string | null}
|
|
1689
|
+
*/
|
|
1690
|
+
function versionInText(source, text) {
|
|
1666
1691
|
const { kind, shape } = versionMode(source, text)
|
|
1667
1692
|
if (kind === 'bare') return text.trim() || null
|
|
1668
1693
|
if (kind === 'markers') {
|
|
@@ -1885,7 +1910,11 @@ if (flag('--help') || flag('-h')) {
|
|
|
1885
1910
|
|
|
1886
1911
|
// --sync copies this file into other projects and exits; it touches no git state.
|
|
1887
1912
|
if (flag('--sync')) {
|
|
1888
|
-
|
|
1913
|
+
// A URL's pathname is percent-encoded and keeps the leading slash before a Windows
|
|
1914
|
+
// drive letter, so a script installed under a directory with a space in its name — or
|
|
1915
|
+
// anywhere on Windows — was reported as "piped from stdin". fileURLToPath is the
|
|
1916
|
+
// inverse of what Node did to build import.meta.url.
|
|
1917
|
+
const self = fileURLToPath(import.meta.url)
|
|
1889
1918
|
// Piped from stdin (`curl … | node -`) there is no file to copy: import.meta.url points
|
|
1890
1919
|
// at a synthetic [eval] path. Say so instead of failing on a missing file.
|
|
1891
1920
|
if (!existsSync(self)) {
|
|
@@ -2012,6 +2041,9 @@ const VALUE_OPTIONS = new Set([
|
|
|
2012
2041
|
'--assistant-effort',
|
|
2013
2042
|
])
|
|
2014
2043
|
|
|
2044
|
+
/** Flags that take no value. With VALUE_OPTIONS, the whole vocabulary this file accepts. */
|
|
2045
|
+
const BOOLEAN_FLAGS = new Set(['--dry-run', '--yes', '-y', '--commit', '--help', '-h', '--sync'])
|
|
2046
|
+
|
|
2015
2047
|
/** The version or bump target: the only argument that is neither a flag nor a flag's value. */
|
|
2016
2048
|
const positionals = []
|
|
2017
2049
|
for (let i = 0; i < argv.length; i += 1) {
|
|
@@ -2019,7 +2051,20 @@ for (let i = 0; i < argv.length; i += 1) {
|
|
|
2019
2051
|
i += 1
|
|
2020
2052
|
continue
|
|
2021
2053
|
}
|
|
2022
|
-
if (!argv[i].startsWith('-'))
|
|
2054
|
+
if (!argv[i].startsWith('-')) {
|
|
2055
|
+
positionals.push(argv[i])
|
|
2056
|
+
continue
|
|
2057
|
+
}
|
|
2058
|
+
// An unknown flag used to be dropped without a word, and `--auto` is the one people
|
|
2059
|
+
// reach for: it released whatever version package.json already carried, which is a
|
|
2060
|
+
// different release from the `auto` they asked for.
|
|
2061
|
+
if (!BOOLEAN_FLAGS.has(argv[i])) {
|
|
2062
|
+
const bare = argv[i].replace(/^-+/, '')
|
|
2063
|
+
const hint = BUMPS.has(bare)
|
|
2064
|
+
? `\n The target is positional: ${INVOCATION} ${bare}, not ${argv[i]}`
|
|
2065
|
+
: ''
|
|
2066
|
+
abort(`unknown flag: ${argv[i]}${hint}`)
|
|
2067
|
+
}
|
|
2023
2068
|
}
|
|
2024
2069
|
const target = positionals[0]
|
|
2025
2070
|
// A second positional is always a mistake, and silently ignoring it changes the release
|
|
@@ -2542,6 +2587,23 @@ let autoBump = null
|
|
|
2542
2587
|
/** The tag of a previous release this run is finishing rather than starting. */
|
|
2543
2588
|
let resuming = null
|
|
2544
2589
|
|
|
2590
|
+
/**
|
|
2591
|
+
* A release tagged at HEAD that never reached the registry, found while resolving a
|
|
2592
|
+
* relative bump. `auto` finishes such a release; a `patch`/`minor`/`major` cannot — it
|
|
2593
|
+
* would bump past it, tag a second version on the same commit, and leave the first one
|
|
2594
|
+
* unpublished for good. Preflight refuses it and names the command that finishes it.
|
|
2595
|
+
*/
|
|
2596
|
+
let unfinishedAtHead = null
|
|
2597
|
+
|
|
2598
|
+
/**
|
|
2599
|
+
* A release that died after the tag and before the publish is finished by re-running the
|
|
2600
|
+
* same command — but only while nothing new has happened. A commit or a working tree that
|
|
2601
|
+
* `--commit` is about to turn into one moves HEAD past the tag, and publishing then would
|
|
2602
|
+
* ship a tree the tag does not describe; that work belongs in the next version, which is
|
|
2603
|
+
* what the shipped-tag baseline makes sure it is released as.
|
|
2604
|
+
*/
|
|
2605
|
+
const wouldCommitMore = !!tryRead('git', ['status', '--porcelain']) && runs('commit')
|
|
2606
|
+
|
|
2545
2607
|
let version
|
|
2546
2608
|
if (!target) {
|
|
2547
2609
|
if (!currentVersion) {
|
|
@@ -2558,12 +2620,6 @@ if (!target) {
|
|
|
2558
2620
|
'from.\n Pass the first version explicitly: release-kit 0.1.0',
|
|
2559
2621
|
)
|
|
2560
2622
|
}
|
|
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
2623
|
const pending = wouldCommitMore ? null : unfinishedRelease()
|
|
2568
2624
|
if (pending) {
|
|
2569
2625
|
;({ name: resuming, version } = pending)
|
|
@@ -2601,6 +2657,10 @@ if (!target) {
|
|
|
2601
2657
|
`a ${target} bump from a stable version needs --preid <${[...KNOWN_CHANNELS].sort().join('|')}>`,
|
|
2602
2658
|
)
|
|
2603
2659
|
}
|
|
2660
|
+
// The same question `auto` asks, with the opposite answer: `auto` finishes the release
|
|
2661
|
+
// it finds at HEAD, a named bump would skip past it. Recorded here, refused in preflight
|
|
2662
|
+
// with the rest.
|
|
2663
|
+
unfinishedAtHead = wouldCommitMore ? null : unfinishedRelease()
|
|
2604
2664
|
version = incrementVersion(currentVersion, target, preid)
|
|
2605
2665
|
} else if (parseVersion(target)) {
|
|
2606
2666
|
version = target
|
|
@@ -2739,6 +2799,33 @@ if (resuming) {
|
|
|
2739
2799
|
ok(`finishing ${resuming}: it was tagged and pushed, but never reached the registry`)
|
|
2740
2800
|
}
|
|
2741
2801
|
|
|
2802
|
+
if (unfinishedAtHead) {
|
|
2803
|
+
fail(
|
|
2804
|
+
`${unfinishedAtHead.name} is tagged at HEAD but never reached the registry, and a ` +
|
|
2805
|
+
`${target} bump would release ${version} from the same commit and leave it that way.\n` +
|
|
2806
|
+
` Finish it instead: re-run with no target, or with auto.`,
|
|
2807
|
+
)
|
|
2808
|
+
}
|
|
2809
|
+
|
|
2810
|
+
// A run that wrote the version and died before its release commit — a failing lockfile
|
|
2811
|
+
// refresh, an afterVersion hook, a commit hook — leaves the bump on disk and nowhere
|
|
2812
|
+
// else. The current version is then read from that file, and a relative bump counts from
|
|
2813
|
+
// it: `minor` after a dead `minor` released 1.2.0 with 1.1.0 tagged nowhere, its changelog
|
|
2814
|
+
// section documenting a version that never existed. Only a bump is affected; an explicit
|
|
2815
|
+
// version, or none, releases what is on disk and the commit step carries it.
|
|
2816
|
+
if (target && BUMPS.has(target) && versionFile && currentVersion) {
|
|
2817
|
+
const committed = tryRead('git', ['show', `HEAD:${versionFile.path}`])
|
|
2818
|
+
const headVersion = committed === null ? null : versionInText(versionFile, committed)
|
|
2819
|
+
if (headVersion && headVersion !== currentVersion) {
|
|
2820
|
+
fail(
|
|
2821
|
+
`${versionFile.path} says ${currentVersion} on disk but ${headVersion} at HEAD — an ` +
|
|
2822
|
+
`uncommitted version bump, which a ${target} bump would count from and skip past.\n` +
|
|
2823
|
+
` Finish it with \`${INVOCATION} ${currentVersion}\`, or restore the file: ` +
|
|
2824
|
+
`git restore ${versionFile.path}`,
|
|
2825
|
+
)
|
|
2826
|
+
}
|
|
2827
|
+
}
|
|
2828
|
+
|
|
2742
2829
|
// A previous release that never shipped is not history — its commits are still owed to
|
|
2743
2830
|
// whoever installs this package, and they are in this release's range because of it. Say
|
|
2744
2831
|
// so: the changelog keeps the section that was written for that version, and a section
|
|
@@ -2746,7 +2833,9 @@ if (resuming) {
|
|
|
2746
2833
|
const absorbed = absorbedReleaseTags({ stable: !isPrerelease }).filter(
|
|
2747
2834
|
(entry) => entry.version !== version,
|
|
2748
2835
|
)
|
|
2749
|
-
|
|
2836
|
+
// Not when the run is being refused for exactly this: saying the commits ship in a
|
|
2837
|
+
// version that will not be released contradicts the failure above it.
|
|
2838
|
+
if (absorbed.length && !unfinishedAtHead) {
|
|
2750
2839
|
const names = absorbed.map((entry) => entry.name).join(', ')
|
|
2751
2840
|
const many = absorbed.length > 1
|
|
2752
2841
|
const existingChangelog =
|
|
@@ -2779,13 +2868,32 @@ if (bumping) {
|
|
|
2779
2868
|
}
|
|
2780
2869
|
if (source.optional) continue
|
|
2781
2870
|
const text = readFileSync(source.path, 'utf8')
|
|
2782
|
-
|
|
2871
|
+
// Resolving the mode can itself refuse — a Cargo.lock with nothing beside it to say
|
|
2872
|
+
// which crate is yours — and the write step must not be where that is discovered.
|
|
2873
|
+
let mode
|
|
2874
|
+
try {
|
|
2875
|
+
mode = versionMode(source, text)
|
|
2876
|
+
} catch (err) {
|
|
2877
|
+
fail(err.message)
|
|
2878
|
+
continue
|
|
2879
|
+
}
|
|
2880
|
+
const { kind, shape } = mode
|
|
2783
2881
|
if (kind === 'pattern' && !shape.test(text)) {
|
|
2784
2882
|
fail(
|
|
2785
2883
|
`${source.path} has no version for release-kit to replace.\n` +
|
|
2786
2884
|
' Remove it from versionFiles, mark the line with x-release-kit-version, ' +
|
|
2787
2885
|
'or give the entry a "pattern".',
|
|
2788
2886
|
)
|
|
2887
|
+
} else if (kind === 'bare' && text.trim() && !parseVersion(text.trim())) {
|
|
2888
|
+
// The whole-file mode is right for a VERSION file and catastrophic for anything
|
|
2889
|
+
// else. `writeVersionInto` refuses it too, but by then the files before it in the
|
|
2890
|
+
// list have already changed.
|
|
2891
|
+
fail(
|
|
2892
|
+
`${source.path} is not a file containing only a version, and carries no ` +
|
|
2893
|
+
'x-release-kit-version marker.\n Writing the version into it would replace ' +
|
|
2894
|
+
'everything else in it. Mark the line that holds the version, or give the entry ' +
|
|
2895
|
+
'a "pattern".',
|
|
2896
|
+
)
|
|
2789
2897
|
}
|
|
2790
2898
|
}
|
|
2791
2899
|
}
|
|
@@ -2797,6 +2905,17 @@ if (bumping && currentVersion && compareVersions(version, currentVersion) <= 0)
|
|
|
2797
2905
|
} else if (bumping) {
|
|
2798
2906
|
// No manifest and no tag to read a version from, but files to write one into.
|
|
2799
2907
|
ok(`writing ${version} into ${versionTargets.map((source) => source.path).join(', ')}`)
|
|
2908
|
+
} else if (versionTargets.length && version !== currentVersion) {
|
|
2909
|
+
// The version step is off and the target is not what the files say. The tag would name
|
|
2910
|
+
// one version and the manifest — which is what `npm publish` sends and what the build
|
|
2911
|
+
// compiles in — another. There is no release in which those two are allowed to differ.
|
|
2912
|
+
const files = versionTargets.map((source) => source.path).join(', ')
|
|
2913
|
+
fail(
|
|
2914
|
+
`the version step is not selected, but ${version} is not the version in ${files}` +
|
|
2915
|
+
`${currentVersion ? ` (${currentVersion})` : ''}.\n` +
|
|
2916
|
+
' The tag would say one version and the files another. Add "version" to the ' +
|
|
2917
|
+
'steps, or release the version already there.',
|
|
2918
|
+
)
|
|
2800
2919
|
} else if (versionFile) {
|
|
2801
2920
|
ok(`releasing the version already in ${versionFile.path} (${version})`)
|
|
2802
2921
|
} else {
|
|
@@ -3000,9 +3119,17 @@ if (!runs('release')) {
|
|
|
3000
3119
|
if (releaseExists) note(`a GitHub release for ${tag} already exists — will skip that step`)
|
|
3001
3120
|
}
|
|
3002
3121
|
|
|
3122
|
+
/**
|
|
3123
|
+
* CLIs that publish over OIDC with no token of their own once the CI job can mint one:
|
|
3124
|
+
* npm, pnpm and bun exchange it with the registry themselves, and so does `uv publish`.
|
|
3125
|
+
* cargo does not — crates.io's trusted publishing goes through an action that turns the
|
|
3126
|
+
* OIDC token into a `CARGO_REGISTRY_TOKEN`, so for cargo the token check still stands.
|
|
3127
|
+
*/
|
|
3128
|
+
const OIDC_CLIS = new Set([...NPM_CLIS, 'uv'])
|
|
3129
|
+
|
|
3003
3130
|
/** Whether one publish CLI can publish at all: OIDC, a token, or a live session. */
|
|
3004
3131
|
function checkCredentials({ cli, registry, command }) {
|
|
3005
|
-
if (isTrustedPublishing) {
|
|
3132
|
+
if (isTrustedPublishing && OIDC_CLIS.has(cli)) {
|
|
3006
3133
|
ok(`${cli}: trusted publishing (OIDC) — no token needed`)
|
|
3007
3134
|
// Provenance is the other half of what OIDC makes possible: a signed attestation
|
|
3008
3135
|
// tying the published artefact to the workflow and commit that produced it. It is
|
|
@@ -3226,7 +3353,9 @@ for (const asset of config.assets) {
|
|
|
3226
3353
|
const wouldCommit = [
|
|
3227
3354
|
dirty && runs('commit') && 'the working tree',
|
|
3228
3355
|
bumping && 'a version bump',
|
|
3229
|
-
|
|
3356
|
+
// The roll is computed whenever [Unreleased] is populated, because the notes come from
|
|
3357
|
+
// it either way; it is only written — and only becomes a commit — when the step runs.
|
|
3358
|
+
rolledChangelog && runs('changelog') && 'a changelog entry',
|
|
3230
3359
|
].filter(Boolean)
|
|
3231
3360
|
if (taggedCommit && runs('tag') && wouldCommit.length) {
|
|
3232
3361
|
fail(
|
|
@@ -3429,15 +3558,37 @@ for (const target of publishTargets) {
|
|
|
3429
3558
|
// target published nothing, and telling downstream otherwise is a lie it may act on.
|
|
3430
3559
|
if (publishedSomething) runHook('afterPublish')
|
|
3431
3560
|
|
|
3561
|
+
/**
|
|
3562
|
+
* Whether a stable release above this version already exists, anywhere in the
|
|
3563
|
+
* repository — not only in this branch's history, since a patch on an older line is
|
|
3564
|
+
* cut from a branch the newer minor was never merged into.
|
|
3565
|
+
*
|
|
3566
|
+
* GitHub's "Latest" badge is what `releases/latest` resolves to, and `--latest`
|
|
3567
|
+
* forces it. A backport patch that took it would point every "download the latest
|
|
3568
|
+
* release" link at the older line.
|
|
3569
|
+
*/
|
|
3570
|
+
function supersededByExistingRelease() {
|
|
3571
|
+
const listed = tryRead('git', ['tag', '--list', `${config.tagPrefix}*`]) ?? ''
|
|
3572
|
+
return listed
|
|
3573
|
+
.split('\n')
|
|
3574
|
+
.map((name) => name.trim().slice(config.tagPrefix.length))
|
|
3575
|
+
.filter((v) => parseVersion(v) && !parseVersion(v).pre.length)
|
|
3576
|
+
.some((v) => compareVersions(v, version) > 0)
|
|
3577
|
+
}
|
|
3578
|
+
|
|
3432
3579
|
if (runs('release') && !releaseExists) {
|
|
3433
3580
|
step(`GitHub release ${tag}`)
|
|
3581
|
+
const latest = !isPrerelease && !supersededByExistingRelease()
|
|
3582
|
+
if (!isPrerelease && !latest) {
|
|
3583
|
+
note(`a newer stable release is already tagged — ${tag} will not be marked Latest`)
|
|
3584
|
+
}
|
|
3434
3585
|
const args = [
|
|
3435
3586
|
'release',
|
|
3436
3587
|
'create',
|
|
3437
3588
|
tag,
|
|
3438
3589
|
'--title',
|
|
3439
3590
|
expand(config.releaseTitle),
|
|
3440
|
-
isPrerelease ? '--prerelease' :
|
|
3591
|
+
isPrerelease ? '--prerelease' : `--latest=${latest}`,
|
|
3441
3592
|
// Notes arrive on stdin, so there is no temp file and nothing to escape.
|
|
3442
3593
|
...(notes ? ['--notes-file', '-'] : ['--generate-notes']),
|
|
3443
3594
|
...config.assets,
|
package/train.mjs
CHANGED
|
@@ -78,6 +78,11 @@ export function parseSemver(version) {
|
|
|
78
78
|
return { major: +match[1], minor: +match[2], patch: +match[3], prerelease: match[4] ?? null }
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
+
/**
|
|
82
|
+
* Precedence per semver §11, the same ordering release.mjs `compareVersions` applies. It
|
|
83
|
+
* has to be, because this decides which tag is a package's last release: ranking every
|
|
84
|
+
* prerelease equal left `rc.9` and `rc.10` in `git tag` order, which is alphabetical.
|
|
85
|
+
*/
|
|
81
86
|
export function compareSemver(a, b) {
|
|
82
87
|
const pa = parseSemver(a)
|
|
83
88
|
const pb = parseSemver(b)
|
|
@@ -85,18 +90,45 @@ export function compareSemver(a, b) {
|
|
|
85
90
|
for (const key of ['major', 'minor', 'patch']) {
|
|
86
91
|
if (pa[key] !== pb[key]) return pa[key] - pb[key]
|
|
87
92
|
}
|
|
88
|
-
if (pa.prerelease && !pb.prerelease) return
|
|
89
|
-
if (!pa.prerelease
|
|
93
|
+
if (!pa.prerelease && !pb.prerelease) return 0
|
|
94
|
+
if (!pa.prerelease) return 1
|
|
95
|
+
if (!pb.prerelease) return -1
|
|
96
|
+
const x = pa.prerelease.split('.')
|
|
97
|
+
const y = pb.prerelease.split('.')
|
|
98
|
+
for (let i = 0; i < Math.max(x.length, y.length); i += 1) {
|
|
99
|
+
const left = x[i]
|
|
100
|
+
const right = y[i]
|
|
101
|
+
if (left === undefined) return -1
|
|
102
|
+
if (right === undefined) return 1
|
|
103
|
+
if (left === right) continue
|
|
104
|
+
const leftNumeric = /^\d+$/.test(left)
|
|
105
|
+
const rightNumeric = /^\d+$/.test(right)
|
|
106
|
+
if (leftNumeric && rightNumeric) return Number(left) - Number(right)
|
|
107
|
+
// Numeric identifiers always have lower precedence than alphanumeric ones.
|
|
108
|
+
if (leftNumeric !== rightNumeric) return leftNumeric ? -1 : 1
|
|
109
|
+
return left < right ? -1 : 1
|
|
110
|
+
}
|
|
90
111
|
return 0
|
|
91
112
|
}
|
|
92
113
|
|
|
114
|
+
/**
|
|
115
|
+
* The bumps a train plans, with release.mjs `incrementVersion`'s arithmetic for them: a
|
|
116
|
+
* major/minor/patch off a prerelease releases that prerelease's base when the base already
|
|
117
|
+
* satisfies the bump, so `2.0.0-beta.1` + `patch` is `2.0.0` — not the `2.0.1` a naive
|
|
118
|
+
* increment gives, which release-kit would then never produce.
|
|
119
|
+
*/
|
|
93
120
|
export function bumpSemver(version, bump) {
|
|
94
121
|
const v = parseSemver(version)
|
|
95
122
|
if (!v) return null
|
|
96
123
|
if (bump === 'as-is') return version
|
|
97
|
-
|
|
98
|
-
if (bump === '
|
|
99
|
-
|
|
124
|
+
const pre = v.prerelease !== null
|
|
125
|
+
if (bump === 'major') {
|
|
126
|
+
return pre && v.minor === 0 && v.patch === 0 ? `${v.major}.0.0` : `${v.major + 1}.0.0`
|
|
127
|
+
}
|
|
128
|
+
if (bump === 'minor') {
|
|
129
|
+
return pre && v.patch === 0 ? `${v.major}.${v.minor}.0` : `${v.major}.${v.minor + 1}.0`
|
|
130
|
+
}
|
|
131
|
+
return pre ? `${v.major}.${v.minor}.${v.patch}` : `${v.major}.${v.minor}.${v.patch + 1}`
|
|
100
132
|
}
|
|
101
133
|
|
|
102
134
|
/**
|
|
@@ -339,6 +371,8 @@ export function discover(rootDir, config) {
|
|
|
339
371
|
repoRelPath: repoDir ? relative(repoDir, dir) || '.' : null,
|
|
340
372
|
publish: entry.publish !== false,
|
|
341
373
|
branch: releaseConfig.branch === undefined ? 'main' : releaseConfig.branch,
|
|
374
|
+
// The prefix release-kit will tag with, and so the one its history is read under.
|
|
375
|
+
tagPrefix: releaseConfig.tagPrefix ?? 'v',
|
|
342
376
|
...manifest,
|
|
343
377
|
})
|
|
344
378
|
}
|
|
@@ -416,19 +450,24 @@ export function topoSort(orderEdges) {
|
|
|
416
450
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
417
451
|
|
|
418
452
|
/**
|
|
419
|
-
* Tag scheme:
|
|
420
|
-
*
|
|
453
|
+
* Tag scheme: the package's own `tagPrefix` (`v` unless its release.config.json says
|
|
454
|
+
* otherwise) when the repo owns exactly one member, `<name>@<version>` when it owns
|
|
455
|
+
* several — which keeps single-package repos identical to standalone release-kit.
|
|
421
456
|
*/
|
|
422
457
|
export function tagPatternFor(member, repoMemberCount) {
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
458
|
+
if (repoMemberCount > 1) return { prefix: `${member.name}@`, glob: `${member.name}@*` }
|
|
459
|
+
const prefix = member.tagPrefix ?? 'v'
|
|
460
|
+
return { prefix, glob: `${prefix}*` }
|
|
426
461
|
}
|
|
427
462
|
|
|
428
463
|
function lastReleaseTag(member, repoMemberCount) {
|
|
429
464
|
if (!member.repoDir) return null
|
|
430
465
|
const { prefix, glob } = tagPatternFor(member, repoMemberCount)
|
|
431
|
-
|
|
466
|
+
// `--merged HEAD`, as release.mjs reads it: a tag made on another branch is not this
|
|
467
|
+
// branch's last release, and the commits it would hide are still unreleased here.
|
|
468
|
+
const tags = git(member.repoDir, ['tag', '--list', glob, '--merged', 'HEAD'], {
|
|
469
|
+
allowFailure: true,
|
|
470
|
+
})
|
|
432
471
|
if (!tags) return null
|
|
433
472
|
const versions = tags
|
|
434
473
|
.split('\n')
|