@entro314labs/release-kit 2.2.0 → 2.2.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.
Files changed (3) hide show
  1. package/README.md +35 -0
  2. package/package.json +1 -1
  3. package/release.mjs +93 -11
package/README.md CHANGED
@@ -305,6 +305,10 @@ Anything else runs as written with no preflight. The project name comes from the
305
305
  `name` in `package.json`, `Cargo.toml` or `pyproject.toml`, `module` in `go.mod` — falling
306
306
  back to the repository directory.
307
307
 
308
+ With no `versionFile` configured it is detected from the repository — `package.json`,
309
+ `pyproject.toml`, `Cargo.toml`, then `VERSION` — so most projects need no config for it at
310
+ all. Set it explicitly to override, or to `null` for a repository that versions by tag.
311
+
308
312
  The format is inferred from the file name: `.json` reads the `"version"` field, `.toml`
309
313
  reads the first `version = "x.y.z"` line, and any other file is treated as containing just
310
314
  the version. Only the version itself is rewritten, so comments and formatting survive — and
@@ -346,6 +350,37 @@ name, `%d` npm dist-tag. In the `publish` command line the substituted values ar
346
350
  shell-quoted, so a version carrying shell metacharacters is passed through as one literal
347
351
  argument.
348
352
 
353
+ ### Continuous integration
354
+
355
+ Non-interactive by default: the confirmation prompt is skipped when stdin is not a TTY, and
356
+ `gh` picks up `GITHUB_TOKEN` on its own. Pass `--yes` to be explicit.
357
+
358
+ ```yaml
359
+ - uses: actions/checkout@v5
360
+ with:
361
+ fetch-depth: 0 # release notes and the last-tag lookup need real history
362
+ - id: release
363
+ run: npx @entro314labs/release-kit@2.1.0 minor --yes
364
+ env:
365
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
366
+ - run: echo "shipped ${{ steps.release.outputs.tag }} ${{ steps.release.outputs.release-url }}"
367
+ ```
368
+
369
+ On success it writes to `$GITHUB_OUTPUT`, so later steps can act on what happened instead of
370
+ re-deriving it: `version`, `tag`, `name`, `dist-tag`, `steps`, `published`, `release-url`.
371
+ Nothing is written on a dry run, and an unwritable `$GITHUB_OUTPUT` never fails a release
372
+ that already completed.
373
+
374
+ Three things CI does that are worth knowing about:
375
+
376
+ - **`fetch-depth: 0`.** The default checkout is a shallow clone, which hides the history
377
+ release notes are drafted from. It still releases correctly, but the notes describe a
378
+ fraction of the work, so a shallow clone is called out as a warning.
379
+ - **Detached HEAD.** Tag and pull-request checkouts leave no branch to push, which is a
380
+ preflight failure rather than a confusing push error.
381
+ - **Signing.** Runners have no signing key, so disable it for the run rather than shipping
382
+ keys around: `git -c commit.gpgsign=false -c tag.gpgsign=false`.
383
+
349
384
  ### Signing
350
385
 
351
386
  Signing is git's, not this tool's: commits and tags are made with plain `git commit` and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@entro314labs/release-kit",
3
- "version": "2.2.0",
3
+ "version": "2.2.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",
package/release.mjs CHANGED
@@ -32,7 +32,14 @@
32
32
  */
33
33
 
34
34
  import { execFileSync, execSync } from 'node:child_process'
35
- import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
35
+ import {
36
+ appendFileSync,
37
+ existsSync,
38
+ mkdirSync,
39
+ mkdtempSync,
40
+ readFileSync,
41
+ writeFileSync,
42
+ } from 'node:fs'
36
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'
@@ -51,8 +58,8 @@ import { createInterface } from 'node:readline/promises'
51
58
  * branch string the only branch a release may run from; null to allow any
52
59
  * remote string git remote to push to
53
60
  * changelog string changelog path; null to disable changelog handling
54
- * versionFile string|object|null where the project's version lives; null when the
55
- * repository versions by git tag alone
61
+ * versionFile string|object|null where the project's version lives. Detected from
62
+ * the repository when unset; null when it versions by tag alone
56
63
  * versionFiles array further files whose version is kept in sync; each is a path
57
64
  * or { path, pattern }
58
65
  * publish string publish command; null to skip publishing entirely
@@ -91,7 +98,7 @@ const DEFAULTS = {
91
98
  branch: 'main',
92
99
  remote: 'origin',
93
100
  changelog: 'CHANGELOG.md',
94
- versionFile: 'package.json',
101
+ versionFile: undefined,
95
102
  versionFiles: [],
96
103
  publish: 'npm publish --tag %d',
97
104
  commitMessage: 'chore(release): %t',
@@ -850,10 +857,8 @@ if (existsSync(localManifest) && localManifest !== rootManifest) {
850
857
  }
851
858
  process.chdir(root)
852
859
 
853
- const config = {
854
- ...DEFAULTS,
855
- ...(existsSync('release.config.json') ? readJson('release.config.json') : {}),
856
- }
860
+ const userConfig = existsSync('release.config.json') ? readJson('release.config.json') : {}
861
+ const config = { ...DEFAULTS, ...userConfig }
857
862
  const unknownKeys = Object.keys(config).filter((key) => !(key in DEFAULTS))
858
863
  if (unknownKeys.length) abort(`release.config.json has unknown keys: ${unknownKeys.join(', ')}`)
859
864
 
@@ -874,7 +879,33 @@ const parseStepList = (value) =>
874
879
  * tag alone and the version has to be passed explicitly.
875
880
  */
876
881
  const manifest = existsSync('package.json') ? readJson('package.json') : null
877
- const versionFile = config.versionFile ? versionSource(config.versionFile) : null
882
+
883
+ /**
884
+ * Where this project keeps its version, when the config does not say. Checked in order of
885
+ * how definitively each file identifies a repository. `go.mod` resolves to null because Go
886
+ * modules carry no version — the tag is the version.
887
+ */
888
+ function detectVersionFile() {
889
+ for (const candidate of ['package.json', 'pyproject.toml', 'Cargo.toml', 'VERSION']) {
890
+ if (existsSync(candidate)) return candidate
891
+ }
892
+ return null
893
+ }
894
+
895
+ // An explicit `versionFile: null` means "versions by tag" and must not be re-detected, so
896
+ // the distinction is between the key being absent and the key being set to null.
897
+ const configuredVersionFile = Object.hasOwn(userConfig, 'versionFile')
898
+ ? userConfig.versionFile
899
+ : detectVersionFile()
900
+ const versionFile = configuredVersionFile ? versionSource(configuredVersionFile) : null
901
+ // Only worth saying when it is not the conventional default.
902
+ if (
903
+ !Object.hasOwn(userConfig, 'versionFile') &&
904
+ versionFile &&
905
+ versionFile.path !== 'package.json'
906
+ ) {
907
+ note(`version source: ${versionFile.path} (detected)`)
908
+ }
878
909
 
879
910
  if (versionFile && !existsSync(versionFile.path)) {
880
911
  abort(`versionFile ${versionFile.path} does not exist`)
@@ -1106,11 +1137,21 @@ if (runs('commit') && !assistant) {
1106
1137
  }
1107
1138
 
1108
1139
  const branch = tryRead('git', ['rev-parse', '--abbrev-ref', 'HEAD'])
1140
+ // A detached HEAD has no branch to push, and reports itself as the literal "HEAD", which
1141
+ // would otherwise be compared against config.branch and pushed as a ref of that name.
1142
+ const detached = branch === 'HEAD'
1109
1143
  if (!branch) fail('could not read the current branch')
1110
- else if (config.branch && branch !== config.branch) {
1144
+ else if (detached) {
1145
+ fail('HEAD is detached — a release needs a branch to push. Check one out first.')
1146
+ } else if (config.branch && branch !== config.branch) {
1111
1147
  fail(`on '${branch}', expected '${config.branch}'`)
1112
1148
  } else ok(`on ${branch}`)
1113
1149
 
1150
+ // A shallow clone (CI checkouts default to depth 1) hides the history that release notes
1151
+ // and the last-tag lookup are derived from. It still releases correctly; the notes just
1152
+ // silently describe a fraction of the work, so say so before that happens.
1153
+ const shallow = tryRead('git', ['rev-parse', '--is-shallow-repository']) === 'true'
1154
+
1114
1155
  if (!succeeds('git', ['remote', 'get-url', config.remote])) {
1115
1156
  fail(`no '${config.remote}' remote configured`)
1116
1157
  } else {
@@ -1118,7 +1159,7 @@ if (!succeeds('git', ['remote', 'get-url', config.remote])) {
1118
1159
  // Fetch so the tag and behind-remote checks below see the real remote state.
1119
1160
  if (!succeeds('git', ['fetch', '--quiet', '--tags', config.remote])) {
1120
1161
  fail(`could not fetch from ${config.remote}`)
1121
- } else if (branch) {
1162
+ } else if (branch && !detached) {
1122
1163
  const upstream = `${config.remote}/${branch}`
1123
1164
  if (!succeeds('git', ['rev-parse', '--verify', '--quiet', `refs/remotes/${upstream}`])) {
1124
1165
  note(`${upstream} does not exist yet — the push will create it`)
@@ -1233,6 +1274,12 @@ function draftNotesFor(v) {
1233
1274
  if (!assistant) return null
1234
1275
  const { lastTag, subjects } = commitsSinceLastTag()
1235
1276
  if (!subjects.length) return null
1277
+ if (shallow) {
1278
+ warn(
1279
+ `shallow clone: only ${subjects.length} commit(s) are visible, so the notes will ` +
1280
+ 'describe part of the release. Check out with full history (fetch-depth: 0).',
1281
+ )
1282
+ }
1236
1283
  note(`drafting notes from ${subjects.length} commit(s) with ${assistantName}...`)
1237
1284
  return draftReleaseNotes(v, subjects, lastTag)
1238
1285
  }
@@ -1418,6 +1465,41 @@ if (runs('release') && !releaseExists) {
1418
1465
  mutate('gh', args, notes ? { input: `${notes}\n` } : {})
1419
1466
  }
1420
1467
 
1468
+ /**
1469
+ * Hand the result back to whatever is orchestrating this. GitHub Actions reads key=value
1470
+ * pairs from $GITHUB_OUTPUT, so a workflow can gate later steps on what actually happened
1471
+ * rather than re-deriving it from the repository.
1472
+ */
1473
+ function emitOutputs() {
1474
+ const file = process.env.GITHUB_OUTPUT
1475
+ if (!file || dryRun) return
1476
+ const releaseUrl = runs('release')
1477
+ ? (tryRead('gh', ['release', 'view', tag, '--json', 'url', '--jq', '.url']) ?? '')
1478
+ : ''
1479
+ const outputs = {
1480
+ version,
1481
+ tag,
1482
+ name: projectName,
1483
+ 'dist-tag': distTag,
1484
+ steps: STEPS.filter(runs).join(','),
1485
+ published: String(!!publishCommand && !alreadyPublished),
1486
+ 'release-url': releaseUrl,
1487
+ }
1488
+ try {
1489
+ appendFileSync(
1490
+ file,
1491
+ `${Object.entries(outputs)
1492
+ .map(([key, value]) => `${key}=${value}`)
1493
+ .join('\n')}\n`,
1494
+ )
1495
+ note(`wrote ${Object.keys(outputs).length} outputs to $GITHUB_OUTPUT`)
1496
+ } catch {
1497
+ // Outputs are a convenience; never fail a completed release over them.
1498
+ }
1499
+ }
1500
+
1501
+ emitOutputs()
1502
+
1421
1503
  console.log(
1422
1504
  `\n${green(bold(dryRun ? 'Dry run complete — nothing was changed.' : `Released ${tag}`))}`,
1423
1505
  )