@entro314labs/release-kit 2.1.0 → 2.2.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 +96 -21
- package/package.json +1 -1
- package/release.mjs +95 -4
package/README.md
CHANGED
|
@@ -65,45 +65,70 @@ Released v2.5.0
|
|
|
65
65
|
|
|
66
66
|
## 📦 Install
|
|
67
67
|
|
|
68
|
-
|
|
68
|
+
Pick by what the project is, not by preference.
|
|
69
|
+
|
|
70
|
+
### Node projects — devDependency
|
|
71
|
+
|
|
72
|
+
Pins the version, so every machine and CI run behave identically.
|
|
69
73
|
|
|
70
74
|
```sh
|
|
71
75
|
pnpm add -D @entro314labs/release-kit
|
|
72
76
|
```
|
|
73
77
|
|
|
74
78
|
```json
|
|
75
|
-
{
|
|
76
|
-
"scripts": {
|
|
77
|
-
"release": "release-kit"
|
|
78
|
-
}
|
|
79
|
-
}
|
|
79
|
+
{ "scripts": { "release": "release-kit" } }
|
|
80
80
|
```
|
|
81
81
|
|
|
82
|
-
|
|
83
|
-
|
|
82
|
+
### Non-Node projects — global install
|
|
83
|
+
|
|
84
|
+
A Rust, Python or Go repository has no manifest to hang a devDependency on, so install it
|
|
85
|
+
once and use it everywhere.
|
|
84
86
|
|
|
85
87
|
```sh
|
|
86
|
-
|
|
88
|
+
npm i -g @entro314labs/release-kit
|
|
89
|
+
release-kit minor
|
|
87
90
|
```
|
|
88
91
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
+
### CI, any language — pinned npx
|
|
93
|
+
|
|
94
|
+
No global state to drift, no install step, and the version is explicit in the command.
|
|
92
95
|
|
|
93
96
|
```sh
|
|
94
|
-
npx @entro314labs/release-kit --
|
|
97
|
+
npx @entro314labs/release-kit@2.1.0 minor --yes
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### Vendored — no registry at release time
|
|
101
|
+
|
|
102
|
+
For a project that should not depend on the registry it is about to publish to, or that
|
|
103
|
+
needs releases to work offline. The file is self-contained, so a copy is a complete install.
|
|
104
|
+
|
|
105
|
+
```sh
|
|
106
|
+
npx @entro314labs/release-kit --sync . # writes scripts/release.mjs
|
|
95
107
|
```
|
|
96
108
|
|
|
97
109
|
```json
|
|
98
|
-
{
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
110
|
+
{ "scripts": { "release": "node scripts/release.mjs" } }
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
### Piped — nothing installed at all
|
|
114
|
+
|
|
115
|
+
`release.mjs` runs straight from stdin, arguments and all. Useful for a one-off release on a
|
|
116
|
+
machine you do not want to install anything on.
|
|
117
|
+
|
|
118
|
+
```sh
|
|
119
|
+
curl -fsSL https://raw.githubusercontent.com/entro314-labs/release-kit/v2.1.0/release.mjs \
|
|
120
|
+
| node - minor --yes
|
|
103
121
|
```
|
|
104
122
|
|
|
105
|
-
|
|
106
|
-
|
|
123
|
+
Pin the URL to a tag, never `main`: piping an unpinned remote script into an interpreter
|
|
124
|
+
means whatever is at that URL runs against your repository and your credentials. `--sync` is
|
|
125
|
+
the one thing that does not work this way — copying itself needs a file on disk.
|
|
126
|
+
|
|
127
|
+
> **All five paths run the same file and need Node 18+.** That includes the Rust, Python and
|
|
128
|
+
> Go projects: `release-kit` is a Node program regardless of what it is releasing.
|
|
129
|
+
|
|
130
|
+
Zero-config works on the conventions below; add a [`release.config.json`](#️-configuration)
|
|
131
|
+
only for what differs.
|
|
107
132
|
|
|
108
133
|
## ⚡ Usage
|
|
109
134
|
|
|
@@ -224,6 +249,7 @@ rather than stopping at the first problem.
|
|
|
224
249
|
- The remote exists, is reachable, and the branch is not behind it
|
|
225
250
|
- The tag is free — or already exists at `HEAD`, in which case it is reused
|
|
226
251
|
- `gh` is installed and authenticated
|
|
252
|
+
- Commit and tag signing can actually sign, when `commit.gpgsign` or `tag.gpgsign` is on
|
|
227
253
|
- The publishing CLI is authenticated, and the version is not already on the registry
|
|
228
254
|
- Configured release assets exist
|
|
229
255
|
- A changelog section for the version exists _(a warning, not a failure — it falls back
|
|
@@ -320,6 +346,54 @@ name, `%d` npm dist-tag. In the `publish` command line the substituted values ar
|
|
|
320
346
|
shell-quoted, so a version carrying shell metacharacters is passed through as one literal
|
|
321
347
|
argument.
|
|
322
348
|
|
|
349
|
+
### Continuous integration
|
|
350
|
+
|
|
351
|
+
Non-interactive by default: the confirmation prompt is skipped when stdin is not a TTY, and
|
|
352
|
+
`gh` picks up `GITHUB_TOKEN` on its own. Pass `--yes` to be explicit.
|
|
353
|
+
|
|
354
|
+
```yaml
|
|
355
|
+
- uses: actions/checkout@v5
|
|
356
|
+
with:
|
|
357
|
+
fetch-depth: 0 # release notes and the last-tag lookup need real history
|
|
358
|
+
- id: release
|
|
359
|
+
run: npx @entro314labs/release-kit@2.1.0 minor --yes
|
|
360
|
+
env:
|
|
361
|
+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
362
|
+
- run: echo "shipped ${{ steps.release.outputs.tag }} ${{ steps.release.outputs.release-url }}"
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
On success it writes to `$GITHUB_OUTPUT`, so later steps can act on what happened instead of
|
|
366
|
+
re-deriving it: `version`, `tag`, `name`, `dist-tag`, `steps`, `published`, `release-url`.
|
|
367
|
+
Nothing is written on a dry run, and an unwritable `$GITHUB_OUTPUT` never fails a release
|
|
368
|
+
that already completed.
|
|
369
|
+
|
|
370
|
+
Three things CI does that are worth knowing about:
|
|
371
|
+
|
|
372
|
+
- **`fetch-depth: 0`.** The default checkout is a shallow clone, which hides the history
|
|
373
|
+
release notes are drafted from. It still releases correctly, but the notes describe a
|
|
374
|
+
fraction of the work, so a shallow clone is called out as a warning.
|
|
375
|
+
- **Detached HEAD.** Tag and pull-request checkouts leave no branch to push, which is a
|
|
376
|
+
preflight failure rather than a confusing push error.
|
|
377
|
+
- **Signing.** Runners have no signing key, so disable it for the run rather than shipping
|
|
378
|
+
keys around: `git -c commit.gpgsign=false -c tag.gpgsign=false`.
|
|
379
|
+
|
|
380
|
+
### Signing
|
|
381
|
+
|
|
382
|
+
Signing is git's, not this tool's: commits and tags are made with plain `git commit` and
|
|
383
|
+
`git tag`, so they are signed exactly when `commit.gpgsign` and `tag.gpgsign` say to, with
|
|
384
|
+
whatever key `user.signingkey` resolves to. There is no key handling here to get wrong.
|
|
385
|
+
|
|
386
|
+
What it does add is a preflight check, because an unusable key otherwise fails at the commit
|
|
387
|
+
step with the version already written. For CI, where a signing key usually is not present,
|
|
388
|
+
disable signing for that run rather than configuring keys:
|
|
389
|
+
|
|
390
|
+
```sh
|
|
391
|
+
git -c commit.gpgsign=false -c tag.gpgsign=false release-kit minor --yes
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
For commits to show as **Verified** on GitHub, the SSH key must be registered as a _signing_
|
|
395
|
+
key in your account, which is a separate list from authentication keys.
|
|
396
|
+
|
|
323
397
|
### Publishing and authentication
|
|
324
398
|
|
|
325
399
|
The registry preflight (`whoami`, the already-published lookup) runs with whichever CLI the
|
|
@@ -445,7 +519,8 @@ including a directory that is not a repository.
|
|
|
445
519
|
|
|
446
520
|
## 📋 Requirements
|
|
447
521
|
|
|
448
|
-
- Node 18+
|
|
522
|
+
- **Node 18+ — including for Rust, Python and Go projects.** `release-kit` is a Node
|
|
523
|
+
program whatever it releases; there is no standalone binary.
|
|
449
524
|
- `git`
|
|
450
525
|
- `gh`, authenticated — only when creating GitHub releases
|
|
451
526
|
- Whatever the `publish` command needs — for the default, a live `npm login` session
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@entro314labs/release-kit",
|
|
3
|
-
"version": "2.1
|
|
3
|
+
"version": "2.2.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
|
@@ -32,8 +32,15 @@
|
|
|
32
32
|
*/
|
|
33
33
|
|
|
34
34
|
import { execFileSync, execSync } from 'node:child_process'
|
|
35
|
-
import {
|
|
36
|
-
|
|
35
|
+
import {
|
|
36
|
+
appendFileSync,
|
|
37
|
+
existsSync,
|
|
38
|
+
mkdirSync,
|
|
39
|
+
mkdtempSync,
|
|
40
|
+
readFileSync,
|
|
41
|
+
writeFileSync,
|
|
42
|
+
} from 'node:fs'
|
|
43
|
+
import { homedir, tmpdir } from 'node:os'
|
|
37
44
|
import { basename, join, relative, resolve, sep } from 'node:path'
|
|
38
45
|
import { createInterface } from 'node:readline/promises'
|
|
39
46
|
|
|
@@ -762,6 +769,15 @@ if (flag('--help') || flag('-h')) {
|
|
|
762
769
|
// --sync copies this file into other projects and exits; it touches no git state.
|
|
763
770
|
if (flag('--sync')) {
|
|
764
771
|
const self = new URL(import.meta.url).pathname
|
|
772
|
+
// Piped from stdin (`curl … | node -`) there is no file to copy: import.meta.url points
|
|
773
|
+
// at a synthetic [eval] path. Say so instead of failing on a missing file.
|
|
774
|
+
if (!existsSync(self)) {
|
|
775
|
+
abort(
|
|
776
|
+
'--sync copies this script from disk, and it was piped from stdin so there is no ' +
|
|
777
|
+
'file to copy.\n Run it from an installed copy instead: ' +
|
|
778
|
+
'npx @entro314labs/release-kit --sync <dir>',
|
|
779
|
+
)
|
|
780
|
+
}
|
|
765
781
|
const targets = argv.slice(argv.indexOf('--sync') + 1).filter((a) => !a.startsWith('-'))
|
|
766
782
|
if (!targets.length) abort('--sync needs at least one project directory')
|
|
767
783
|
|
|
@@ -1097,11 +1113,21 @@ if (runs('commit') && !assistant) {
|
|
|
1097
1113
|
}
|
|
1098
1114
|
|
|
1099
1115
|
const branch = tryRead('git', ['rev-parse', '--abbrev-ref', 'HEAD'])
|
|
1116
|
+
// A detached HEAD has no branch to push, and reports itself as the literal "HEAD", which
|
|
1117
|
+
// would otherwise be compared against config.branch and pushed as a ref of that name.
|
|
1118
|
+
const detached = branch === 'HEAD'
|
|
1100
1119
|
if (!branch) fail('could not read the current branch')
|
|
1101
|
-
else if (
|
|
1120
|
+
else if (detached) {
|
|
1121
|
+
fail('HEAD is detached — a release needs a branch to push. Check one out first.')
|
|
1122
|
+
} else if (config.branch && branch !== config.branch) {
|
|
1102
1123
|
fail(`on '${branch}', expected '${config.branch}'`)
|
|
1103
1124
|
} else ok(`on ${branch}`)
|
|
1104
1125
|
|
|
1126
|
+
// A shallow clone (CI checkouts default to depth 1) hides the history that release notes
|
|
1127
|
+
// and the last-tag lookup are derived from. It still releases correctly; the notes just
|
|
1128
|
+
// silently describe a fraction of the work, so say so before that happens.
|
|
1129
|
+
const shallow = tryRead('git', ['rev-parse', '--is-shallow-repository']) === 'true'
|
|
1130
|
+
|
|
1105
1131
|
if (!succeeds('git', ['remote', 'get-url', config.remote])) {
|
|
1106
1132
|
fail(`no '${config.remote}' remote configured`)
|
|
1107
1133
|
} else {
|
|
@@ -1109,7 +1135,7 @@ if (!succeeds('git', ['remote', 'get-url', config.remote])) {
|
|
|
1109
1135
|
// Fetch so the tag and behind-remote checks below see the real remote state.
|
|
1110
1136
|
if (!succeeds('git', ['fetch', '--quiet', '--tags', config.remote])) {
|
|
1111
1137
|
fail(`could not fetch from ${config.remote}`)
|
|
1112
|
-
} else if (branch) {
|
|
1138
|
+
} else if (branch && !detached) {
|
|
1113
1139
|
const upstream = `${config.remote}/${branch}`
|
|
1114
1140
|
if (!succeeds('git', ['rev-parse', '--verify', '--quiet', `refs/remotes/${upstream}`])) {
|
|
1115
1141
|
note(`${upstream} does not exist yet — the push will create it`)
|
|
@@ -1122,6 +1148,30 @@ if (!succeeds('git', ['remote', 'get-url', config.remote])) {
|
|
|
1122
1148
|
}
|
|
1123
1149
|
}
|
|
1124
1150
|
|
|
1151
|
+
// Signing is configured per repository and inherited, never managed here — git already
|
|
1152
|
+
// owns that. But a signing setup that cannot produce a signature fails at the commit step,
|
|
1153
|
+
// after the version has been written, so it is worth catching before anything mutates.
|
|
1154
|
+
const signsSomething = ['commit', 'version', 'changelog', 'tag'].some(runs)
|
|
1155
|
+
const signingKeys = ['commit.gpgsign', 'tag.gpgsign'].filter(
|
|
1156
|
+
(key) => tryRead('git', ['config', '--get', key]) === 'true',
|
|
1157
|
+
)
|
|
1158
|
+
if (signsSomething && signingKeys.length) {
|
|
1159
|
+
const format = tryRead('git', ['config', '--get', 'gpg.format']) || 'openpgp'
|
|
1160
|
+
const signingKey = tryRead('git', ['config', '--get', 'user.signingkey'])
|
|
1161
|
+
const keyPath = signingKey?.replace(/^~/, homedir())
|
|
1162
|
+
if (!signingKey) {
|
|
1163
|
+
fail(`${signingKeys.join(' and ')} enabled but user.signingkey is not set`)
|
|
1164
|
+
} else if (format === 'ssh' && /^[~/.]/.test(signingKey) && !existsSync(keyPath)) {
|
|
1165
|
+
fail(
|
|
1166
|
+
`signing key ${signingKey} does not exist.\n` +
|
|
1167
|
+
' Point user.signingkey at a key that is present, or disable signing for this ' +
|
|
1168
|
+
'run with `git -c commit.gpgsign=false -c tag.gpgsign=false`.',
|
|
1169
|
+
)
|
|
1170
|
+
} else {
|
|
1171
|
+
ok(`signing commits and tags (${format})`)
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1125
1175
|
const head = tryRead('git', ['rev-parse', 'HEAD'])
|
|
1126
1176
|
const taggedCommit = tryRead('git', ['rev-list', '-n', '1', tag])
|
|
1127
1177
|
if (!runs('tag')) {
|
|
@@ -1200,6 +1250,12 @@ function draftNotesFor(v) {
|
|
|
1200
1250
|
if (!assistant) return null
|
|
1201
1251
|
const { lastTag, subjects } = commitsSinceLastTag()
|
|
1202
1252
|
if (!subjects.length) return null
|
|
1253
|
+
if (shallow) {
|
|
1254
|
+
warn(
|
|
1255
|
+
`shallow clone: only ${subjects.length} commit(s) are visible, so the notes will ` +
|
|
1256
|
+
'describe part of the release. Check out with full history (fetch-depth: 0).',
|
|
1257
|
+
)
|
|
1258
|
+
}
|
|
1203
1259
|
note(`drafting notes from ${subjects.length} commit(s) with ${assistantName}...`)
|
|
1204
1260
|
return draftReleaseNotes(v, subjects, lastTag)
|
|
1205
1261
|
}
|
|
@@ -1385,6 +1441,41 @@ if (runs('release') && !releaseExists) {
|
|
|
1385
1441
|
mutate('gh', args, notes ? { input: `${notes}\n` } : {})
|
|
1386
1442
|
}
|
|
1387
1443
|
|
|
1444
|
+
/**
|
|
1445
|
+
* Hand the result back to whatever is orchestrating this. GitHub Actions reads key=value
|
|
1446
|
+
* pairs from $GITHUB_OUTPUT, so a workflow can gate later steps on what actually happened
|
|
1447
|
+
* rather than re-deriving it from the repository.
|
|
1448
|
+
*/
|
|
1449
|
+
function emitOutputs() {
|
|
1450
|
+
const file = process.env.GITHUB_OUTPUT
|
|
1451
|
+
if (!file || dryRun) return
|
|
1452
|
+
const releaseUrl = runs('release')
|
|
1453
|
+
? (tryRead('gh', ['release', 'view', tag, '--json', 'url', '--jq', '.url']) ?? '')
|
|
1454
|
+
: ''
|
|
1455
|
+
const outputs = {
|
|
1456
|
+
version,
|
|
1457
|
+
tag,
|
|
1458
|
+
name: projectName,
|
|
1459
|
+
'dist-tag': distTag,
|
|
1460
|
+
steps: STEPS.filter(runs).join(','),
|
|
1461
|
+
published: String(!!publishCommand && !alreadyPublished),
|
|
1462
|
+
'release-url': releaseUrl,
|
|
1463
|
+
}
|
|
1464
|
+
try {
|
|
1465
|
+
appendFileSync(
|
|
1466
|
+
file,
|
|
1467
|
+
`${Object.entries(outputs)
|
|
1468
|
+
.map(([key, value]) => `${key}=${value}`)
|
|
1469
|
+
.join('\n')}\n`,
|
|
1470
|
+
)
|
|
1471
|
+
note(`wrote ${Object.keys(outputs).length} outputs to $GITHUB_OUTPUT`)
|
|
1472
|
+
} catch {
|
|
1473
|
+
// Outputs are a convenience; never fail a completed release over them.
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
emitOutputs()
|
|
1478
|
+
|
|
1388
1479
|
console.log(
|
|
1389
1480
|
`\n${green(bold(dryRun ? 'Dry run complete — nothing was changed.' : `Released ${tag}`))}`,
|
|
1390
1481
|
)
|