@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.
- package/README.md +35 -0
- package/package.json +1 -1
- 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.
|
|
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 {
|
|
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
|
|
55
|
-
* repository versions by
|
|
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:
|
|
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
|
|
854
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
)
|