@entro314labs/release-kit 2.2.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 +31 -0
  2. package/package.json +1 -1
  3. package/release.mjs +61 -3
package/README.md CHANGED
@@ -346,6 +346,37 @@ name, `%d` npm dist-tag. In the `publish` command line the substituted values ar
346
346
  shell-quoted, so a version carrying shell metacharacters is passed through as one literal
347
347
  argument.
348
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
+
349
380
  ### Signing
350
381
 
351
382
  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.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,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'
@@ -1106,11 +1113,21 @@ if (runs('commit') && !assistant) {
1106
1113
  }
1107
1114
 
1108
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'
1109
1119
  if (!branch) fail('could not read the current branch')
1110
- 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) {
1111
1123
  fail(`on '${branch}', expected '${config.branch}'`)
1112
1124
  } else ok(`on ${branch}`)
1113
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
+
1114
1131
  if (!succeeds('git', ['remote', 'get-url', config.remote])) {
1115
1132
  fail(`no '${config.remote}' remote configured`)
1116
1133
  } else {
@@ -1118,7 +1135,7 @@ if (!succeeds('git', ['remote', 'get-url', config.remote])) {
1118
1135
  // Fetch so the tag and behind-remote checks below see the real remote state.
1119
1136
  if (!succeeds('git', ['fetch', '--quiet', '--tags', config.remote])) {
1120
1137
  fail(`could not fetch from ${config.remote}`)
1121
- } else if (branch) {
1138
+ } else if (branch && !detached) {
1122
1139
  const upstream = `${config.remote}/${branch}`
1123
1140
  if (!succeeds('git', ['rev-parse', '--verify', '--quiet', `refs/remotes/${upstream}`])) {
1124
1141
  note(`${upstream} does not exist yet — the push will create it`)
@@ -1233,6 +1250,12 @@ function draftNotesFor(v) {
1233
1250
  if (!assistant) return null
1234
1251
  const { lastTag, subjects } = commitsSinceLastTag()
1235
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
+ }
1236
1259
  note(`drafting notes from ${subjects.length} commit(s) with ${assistantName}...`)
1237
1260
  return draftReleaseNotes(v, subjects, lastTag)
1238
1261
  }
@@ -1418,6 +1441,41 @@ if (runs('release') && !releaseExists) {
1418
1441
  mutate('gh', args, notes ? { input: `${notes}\n` } : {})
1419
1442
  }
1420
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
+
1421
1479
  console.log(
1422
1480
  `\n${green(bold(dryRun ? 'Dry run complete — nothing was changed.' : `Released ${tag}`))}`,
1423
1481
  )