@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.
Files changed (3) hide show
  1. package/README.md +96 -21
  2. package/package.json +1 -1
  3. 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
- **As a devDependency** — the normal choice. Updates arrive through your package manager.
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
- **Without installing** — for a one-off release, or a project you do not want to add a
83
- dependency to:
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
- npx @entro314labs/release-kit --dry-run
88
+ npm i -g @entro314labs/release-kit
89
+ release-kit minor
87
90
  ```
88
91
 
89
- **Vendored** — for a project that should not depend on the registry it is about to publish
90
- to, or one that needs releases to work offline. `--sync` copies the file into
91
- `scripts/release.mjs`:
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 --sync .
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
- "scripts": {
100
- "release": "node scripts/release.mjs"
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
- All three run the same file. Zero-config works on the conventions below; add a
106
- [`release.config.json`](#️-configuration) only for what differs.
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+ (uses `node:readline/promises` and `Array.prototype.at`)
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.0",
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 { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
36
- import { tmpdir } from 'node:os'
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 (config.branch && branch !== config.branch) {
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
  )