@entro314labs/release-kit 1.0.2 → 2.0.0
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 +97 -31
- package/package.json +1 -1
- package/release.mjs +474 -29
package/README.md
CHANGED
|
@@ -54,7 +54,8 @@ Released v2.5.0
|
|
|
54
54
|
| ----------------------------------------------------------------------- | --------------------------------------- |
|
|
55
55
|
| [📦 Install](#-install) | package, `npx`, or vendored file |
|
|
56
56
|
| [⚡ Usage](#-usage) | targets, bumps, flags |
|
|
57
|
-
| [
|
|
57
|
+
| [🧩 Steps](#-steps) | the seven steps and how to select them |
|
|
58
|
+
| [🤖 Assistant](#-assistant-optional) | optional AI drafting |
|
|
58
59
|
| [✅ Preflight](#-preflight) | what is checked before anything mutates |
|
|
59
60
|
| [♻️ Recovering from a failed run](#️-recovering-from-a-failed-run) | why re-running is safe |
|
|
60
61
|
| [⚙️ Configuration](#️-configuration) | `release.config.json`, publishing, auth |
|
|
@@ -136,35 +137,50 @@ Prerelease bumps need `--preid` unless the current version already carries one t
|
|
|
136
137
|
|
|
137
138
|
### Flags
|
|
138
139
|
|
|
139
|
-
| Flag
|
|
140
|
-
|
|
|
141
|
-
| `--dry-run`
|
|
142
|
-
| `--yes`, `-y`
|
|
143
|
-
| `--preid <id>`
|
|
144
|
-
| `--tag <
|
|
145
|
-
| `--
|
|
146
|
-
| `--skip
|
|
147
|
-
| `--sync <dir>...`
|
|
148
|
-
| `--help`, `-h`
|
|
149
|
-
|
|
150
|
-
##
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
140
|
+
| Flag | Effect |
|
|
141
|
+
| ------------------- | -------------------------------------------------------------------------- |
|
|
142
|
+
| `--dry-run` | Print every step, execute nothing. Preflight still runs and still reports. |
|
|
143
|
+
| `--yes`, `-y` | Skip the confirmation prompt. |
|
|
144
|
+
| `--preid <id>` | Prerelease identifier: `alpha`, `beta`, `rc`, `next`, `nightly`, `canary`. |
|
|
145
|
+
| `--dist-tag <name>` | Override the npm dist-tag. Always wins over the derived one. |
|
|
146
|
+
| `--only <steps>` | Run only these steps, comma-separated. |
|
|
147
|
+
| `--skip <steps>` | Run every step except these. |
|
|
148
|
+
| `--sync <dir>...` | Copy this script into other projects and exit. Touches no git state. |
|
|
149
|
+
| `--help`, `-h` | Full flag list. |
|
|
150
|
+
|
|
151
|
+
## 🧩 Steps
|
|
152
|
+
|
|
153
|
+
A release is seven named steps. They always run in this order — `steps` selects which of
|
|
154
|
+
them execute, it never reorders them.
|
|
155
|
+
|
|
156
|
+
| Step | Default | What it does |
|
|
157
|
+
| ----------- | ------- | ----------------------------------------------------------------------------------------------- |
|
|
158
|
+
| `commit` | off | Commit a dirty working tree with a drafted message ([assistant](#-assistant-optional) required) |
|
|
159
|
+
| `version` | on | Write the version into `package.json` and `versionFiles` |
|
|
160
|
+
| `changelog` | on | Roll `[Unreleased]` into the version, or add drafted notes |
|
|
161
|
+
| `tag` | on | Annotated git tag carrying the release notes |
|
|
162
|
+
| `push` | on | Push the branch and tag together (`--follow-tags`) |
|
|
163
|
+
| `publish` | on | Run the configured `publish` command |
|
|
164
|
+
| `release` | on | Create the GitHub release |
|
|
165
|
+
|
|
166
|
+
`version` and `changelog` write files; those writes are persisted by a release commit made
|
|
167
|
+
automatically when either step runs.
|
|
168
|
+
|
|
169
|
+
```sh
|
|
170
|
+
release-kit minor --skip publish # everything but publish
|
|
171
|
+
release-kit --only tag,push,release # a version already committed elsewhere
|
|
172
|
+
release-kit minor --commit # add the opt-in commit step
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
Or fix it per project, and just run `release-kit minor`:
|
|
176
|
+
|
|
177
|
+
```json
|
|
178
|
+
{ "steps": ["commit", "version", "changelog", "tag", "push", "release"] }
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
`steps` decides **what** runs. Every other key describes **how** a step behaves — `publish`
|
|
182
|
+
is the command, `changelog` is the file. A step whose configuration is `null` runs as a
|
|
183
|
+
no-op and says so, rather than silently meaning "skip".
|
|
168
184
|
|
|
169
185
|
### Release notes
|
|
170
186
|
|
|
@@ -194,7 +210,7 @@ version, never guessed:
|
|
|
194
210
|
|
|
195
211
|
The refusal is deliberate: an unrecognised prerelease identifier has no safe channel, and
|
|
196
212
|
falling through to `latest` would put a prerelease on the stable line where every
|
|
197
|
-
`npm install` picks it up. Pass `--tag <
|
|
213
|
+
`npm install` picks it up. Pass `--dist-tag <name>` to choose a channel explicitly.
|
|
198
214
|
|
|
199
215
|
## ✅ Preflight
|
|
200
216
|
|
|
@@ -313,6 +329,56 @@ A project releasing off a non-default branch with a different tag scheme:
|
|
|
313
329
|
}
|
|
314
330
|
```
|
|
315
331
|
|
|
332
|
+
## 🤖 Assistant (optional)
|
|
333
|
+
|
|
334
|
+
An assistant is an AI CLI already installed on your machine. When one is configured,
|
|
335
|
+
release-kit can write the Conventional Commits message for a dirty working tree and draft
|
|
336
|
+
release notes from the commit log. It is **off by default**, and every failure — not
|
|
337
|
+
installed, not authenticated, timed out, unusable answer — falls back to the behaviour you
|
|
338
|
+
already have. A release is never blocked because a text generator was unavailable.
|
|
339
|
+
|
|
340
|
+
```sh
|
|
341
|
+
pnpm release minor --commit --assistant claude --assistant-model sonnet --assistant-effort low
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
| Tool | Invocation | Model | Effort |
|
|
345
|
+
| -------- | ------------ | ------------------- | -------------------------------- |
|
|
346
|
+
| `claude` | `claude -p` | `--assistant-model` | `--assistant-effort` (low … max) |
|
|
347
|
+
| `codex` | `codex exec` | `-m` | `-c model_reasoning_effort=` |
|
|
348
|
+
|
|
349
|
+
Configure it once instead:
|
|
350
|
+
|
|
351
|
+
```json
|
|
352
|
+
{
|
|
353
|
+
"assistant": { "tool": "claude", "model": "sonnet", "effort": "low" }
|
|
354
|
+
}
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
`"assistant": "auto"` picks the first tool found on PATH; `"claude"` is shorthand for
|
|
358
|
+
`{ "tool": "claude" }`. Naming a tool that is not installed is an error rather than a silent
|
|
359
|
+
downgrade, so a configured pipeline fails loudly; `"auto"` degrades quietly by design.
|
|
360
|
+
|
|
361
|
+
### What it does
|
|
362
|
+
|
|
363
|
+
- **`--commit`** stages the working tree, drafts a Conventional Commits message for the
|
|
364
|
+
staged diff, and commits — instead of refusing to release. The subject is validated
|
|
365
|
+
against the Conventional Commits grammar; an answer that does not parse is rejected rather
|
|
366
|
+
than committed. Attribution lines (`Co-Authored-By`, `Generated with`) are stripped, so
|
|
367
|
+
the tool never signs your commits.
|
|
368
|
+
- **Release notes** are drafted from the commits since the last tag when `CHANGELOG.md` has
|
|
369
|
+
no section for the version. They are written into the changelog, used as the tag
|
|
370
|
+
annotation, and posted as the GitHub release body — the same "written once, lands in three
|
|
371
|
+
places" path a hand-written section takes.
|
|
372
|
+
|
|
373
|
+
With `--commit`, notes are drafted _after_ that commit lands, so they describe the change it
|
|
374
|
+
just made. Merge, release, `WIP` and `fixup!`/`squash!` commits are excluded from the prompt.
|
|
375
|
+
|
|
376
|
+
### Adding another tool
|
|
377
|
+
|
|
378
|
+
One row in `ASSISTANTS` in `release.mjs`: the command, the args that make it read a prompt on
|
|
379
|
+
stdin, and how it spells model and effort. Tools whose stdout carries session scaffolding
|
|
380
|
+
declare `outputFile` and the answer is read from there instead.
|
|
381
|
+
|
|
316
382
|
## 🔄 Keeping vendored copies in sync
|
|
317
383
|
|
|
318
384
|
Installed as a dependency, updates come from your package manager and there is nothing to
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@entro314labs/release-kit",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
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,8 @@
|
|
|
32
32
|
*/
|
|
33
33
|
|
|
34
34
|
import { execFileSync, execSync } from 'node:child_process'
|
|
35
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
35
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
|
|
36
|
+
import { tmpdir } from 'node:os'
|
|
36
37
|
import { basename, join, relative, resolve, sep } from 'node:path'
|
|
37
38
|
import { createInterface } from 'node:readline/promises'
|
|
38
39
|
|
|
@@ -55,8 +56,34 @@ import { createInterface } from 'node:readline/promises'
|
|
|
55
56
|
* commitMessage string release commit subject
|
|
56
57
|
* releaseTitle string GitHub release title
|
|
57
58
|
* assets string[] files attached to the GitHub release
|
|
59
|
+
* assistant string|object drafting CLI for commit messages and notes. A key of
|
|
60
|
+
* ASSISTANTS, "auto" for the first available, or null. The
|
|
61
|
+
* object form { tool, model, effort } also pins which model and
|
|
62
|
+
* reasoning effort that CLI is invoked with.
|
|
58
63
|
*/
|
|
64
|
+
/**
|
|
65
|
+
* The release pipeline, in the only order that is safe to run it. `steps` selects which of
|
|
66
|
+
* these execute; it never reorders them — publishing before tagging, or pushing before
|
|
67
|
+
* committing, is a mistake the tool should not let you express.
|
|
68
|
+
*
|
|
69
|
+
* commit commit a dirty working tree (opt-in; touches work that predates the release)
|
|
70
|
+
* version write the version into package.json and versionFiles
|
|
71
|
+
* changelog roll [Unreleased] into the version, or add drafted notes
|
|
72
|
+
* tag annotated git tag carrying the release notes
|
|
73
|
+
* push push the branch and the tag together
|
|
74
|
+
* publish run the configured publish command
|
|
75
|
+
* release create the GitHub release
|
|
76
|
+
*
|
|
77
|
+
* `version` and `changelog` write files; those writes are persisted by a release commit
|
|
78
|
+
* made automatically when either step runs.
|
|
79
|
+
*/
|
|
80
|
+
const STEPS = ['commit', 'version', 'changelog', 'tag', 'push', 'publish', 'release']
|
|
81
|
+
|
|
82
|
+
/** Everything but `commit`, which is opt-in because it commits work you did not stage. */
|
|
83
|
+
const DEFAULT_STEPS = STEPS.filter((name) => name !== 'commit')
|
|
84
|
+
|
|
59
85
|
const DEFAULTS = {
|
|
86
|
+
steps: DEFAULT_STEPS,
|
|
60
87
|
tagPrefix: 'v',
|
|
61
88
|
branch: 'main',
|
|
62
89
|
remote: 'origin',
|
|
@@ -66,6 +93,7 @@ const DEFAULTS = {
|
|
|
66
93
|
commitMessage: 'chore(release): %t',
|
|
67
94
|
releaseTitle: '%t',
|
|
68
95
|
assets: [],
|
|
96
|
+
assistant: null,
|
|
69
97
|
}
|
|
70
98
|
|
|
71
99
|
/**
|
|
@@ -94,13 +122,23 @@ Target (optional; defaults to the version already in package.json):
|
|
|
94
122
|
prepatch preminor premajor prerelease
|
|
95
123
|
prerelease bump; needs --preid unless it can be inferred
|
|
96
124
|
|
|
125
|
+
Steps, in the fixed order they run. All but "commit" run by default:
|
|
126
|
+
${STEPS.join(' ')}
|
|
127
|
+
|
|
97
128
|
Flags:
|
|
129
|
+
--only <steps> run only these steps, comma-separated
|
|
130
|
+
--skip <steps> run every step except these
|
|
131
|
+
--commit add the opt-in commit step: commit a dirty working tree with a
|
|
132
|
+
drafted Conventional Commits message instead of refusing to release
|
|
98
133
|
--preid <id> prerelease identifier (alpha, beta, rc, next, nightly, canary)
|
|
99
|
-
--tag <
|
|
134
|
+
--dist-tag <name> override the npm dist-tag (default: derived from the version)
|
|
100
135
|
--dry-run print every step and execute nothing
|
|
101
136
|
--yes, -y skip the confirmation prompt
|
|
102
|
-
--
|
|
103
|
-
--
|
|
137
|
+
--assistant <name> drafting CLI to use: auto, none, claude, codex
|
|
138
|
+
--assistant-model <name>
|
|
139
|
+
model the assistant runs with (e.g. sonnet, opus)
|
|
140
|
+
--assistant-effort <level>
|
|
141
|
+
reasoning effort the assistant runs with (low … max)
|
|
104
142
|
--sync <dir>... copy this script into other projects' scripts/ and exit
|
|
105
143
|
--help, -h show this
|
|
106
144
|
|
|
@@ -192,6 +230,203 @@ function tryRead(command, args) {
|
|
|
192
230
|
*/
|
|
193
231
|
const succeeds = (command, args) => tryRead(command, args) !== null
|
|
194
232
|
|
|
233
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
234
|
+
// ASSISTANTS — optional CLIs that draft prose (commit messages, release notes)
|
|
235
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Drafting tools, tried in this order when `assistant` is "auto". Each is a CLI that
|
|
239
|
+
* reads a prompt on stdin and writes plain text to stdout. Supporting another harness is
|
|
240
|
+
* one row here; nothing else in the file names a specific tool.
|
|
241
|
+
*/
|
|
242
|
+
const ASSISTANTS = {
|
|
243
|
+
claude: {
|
|
244
|
+
command: 'claude',
|
|
245
|
+
args: ['-p'],
|
|
246
|
+
probe: ['--version'],
|
|
247
|
+
model: (m) => ['--model', m],
|
|
248
|
+
effort: (e) => ['--effort', e],
|
|
249
|
+
},
|
|
250
|
+
codex: {
|
|
251
|
+
command: 'codex',
|
|
252
|
+
args: ['exec', '--skip-git-repo-check', '--sandbox', 'read-only'],
|
|
253
|
+
probe: ['--version'],
|
|
254
|
+
model: (m) => ['-m', m],
|
|
255
|
+
effort: (e) => ['-c', `model_reasoning_effort="${e}"`],
|
|
256
|
+
// `codex exec` streams session scaffolding (MCP notices, hook logs) to stdout, so the
|
|
257
|
+
// answer is only clean when written to a file with --output-last-message.
|
|
258
|
+
outputFile: (path) => ['--output-last-message', path],
|
|
259
|
+
},
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** How long a draft may take before the release gives up and falls back. */
|
|
263
|
+
const DRAFT_TIMEOUT_MS = 180_000
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Lines a drafting tool may append that must never reach a commit message or release
|
|
267
|
+
* notes: attribution for the tool itself, and the markdown fences models wrap output in.
|
|
268
|
+
*/
|
|
269
|
+
function cleanDraft(text) {
|
|
270
|
+
return text
|
|
271
|
+
.replace(/^\s*```[a-z]*\s*\n?/i, '')
|
|
272
|
+
.replace(/\n?```\s*$/, '')
|
|
273
|
+
.split('\n')
|
|
274
|
+
.filter(
|
|
275
|
+
(line) =>
|
|
276
|
+
!/^\s*co-authored-by:/i.test(line) &&
|
|
277
|
+
!/^\s*(🤖\s*)?generated with/i.test(line) &&
|
|
278
|
+
!/^\s*signed-off-by:\s*claude/i.test(line),
|
|
279
|
+
)
|
|
280
|
+
.join('\n')
|
|
281
|
+
.trim()
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Release notes carry two artefacts a commit message does not: stray code fences around
|
|
286
|
+
* part of the output, and a trailing paragraph explaining the model's reasoning. Keep the
|
|
287
|
+
* document only up to its last heading or list item; prose after that is commentary.
|
|
288
|
+
*/
|
|
289
|
+
function cleanNotes(text) {
|
|
290
|
+
const lines = cleanDraft(text)
|
|
291
|
+
.split('\n')
|
|
292
|
+
.filter((line) => !/^\s*```/.test(line))
|
|
293
|
+
let end = lines.length
|
|
294
|
+
while (end > 0) {
|
|
295
|
+
const line = lines[end - 1]
|
|
296
|
+
if (!line.trim()) {
|
|
297
|
+
end -= 1
|
|
298
|
+
continue
|
|
299
|
+
}
|
|
300
|
+
// A heading, a bullet, or an indented continuation of a bullet: the document proper.
|
|
301
|
+
if (/^\s*[-*+] /.test(line) || /^#{1,6} /.test(line) || /^\s+\S/.test(line)) break
|
|
302
|
+
end -= 1
|
|
303
|
+
}
|
|
304
|
+
return lines.slice(0, end).join('\n').trim() || null
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Run the selected assistant against a prompt.
|
|
309
|
+
*
|
|
310
|
+
* Every failure mode — not installed, not authenticated, timed out, empty answer — returns
|
|
311
|
+
* null rather than throwing. Drafting is a convenience; a release must never be blocked
|
|
312
|
+
* because a text generator was unavailable.
|
|
313
|
+
*
|
|
314
|
+
* @returns {string | null} the cleaned draft, or null when unavailable
|
|
315
|
+
*/
|
|
316
|
+
function runAssistant(prompt) {
|
|
317
|
+
if (!assistant) return null
|
|
318
|
+
|
|
319
|
+
const args = [...assistant.args]
|
|
320
|
+
if (assistantModel && assistant.model) args.push(...assistant.model(assistantModel))
|
|
321
|
+
if (assistantEffort && assistant.effort) args.push(...assistant.effort(assistantEffort))
|
|
322
|
+
|
|
323
|
+
// Tools whose stdout carries session scaffolding hand back the answer through a file.
|
|
324
|
+
const answerFile = assistant.outputFile
|
|
325
|
+
? join(mkdtempSync(join(tmpdir(), 'release-kit-')), 'answer.md')
|
|
326
|
+
: null
|
|
327
|
+
if (answerFile) args.push(...assistant.outputFile(answerFile))
|
|
328
|
+
|
|
329
|
+
try {
|
|
330
|
+
const stdout = execFileSync(assistant.command, args, {
|
|
331
|
+
input: prompt,
|
|
332
|
+
encoding: 'utf8',
|
|
333
|
+
timeout: DRAFT_TIMEOUT_MS,
|
|
334
|
+
stdio: ['pipe', 'pipe', 'ignore'],
|
|
335
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
336
|
+
})
|
|
337
|
+
const answer = answerFile
|
|
338
|
+
? existsSync(answerFile)
|
|
339
|
+
? readFileSync(answerFile, 'utf8')
|
|
340
|
+
: ''
|
|
341
|
+
: stdout
|
|
342
|
+
return cleanDraft(answer) || null
|
|
343
|
+
} catch {
|
|
344
|
+
return null
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** Commit subjects since the last tag, with release and merge commits filtered out. */
|
|
349
|
+
function commitsSinceLastTag() {
|
|
350
|
+
const lastTag = tryRead('git', ['describe', '--tags', '--abbrev=0'])
|
|
351
|
+
const range = lastTag ? `${lastTag}..HEAD` : 'HEAD'
|
|
352
|
+
const log = tryRead('git', ['log', '--format=%s', range]) ?? ''
|
|
353
|
+
return {
|
|
354
|
+
lastTag,
|
|
355
|
+
subjects: log
|
|
356
|
+
.split('\n')
|
|
357
|
+
.filter(
|
|
358
|
+
(s) =>
|
|
359
|
+
s && !/^chore\(release\)/i.test(s) && !/^Merge (branch|pull request|remote)/i.test(s),
|
|
360
|
+
),
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const CONVENTIONAL_TYPES = 'build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test'
|
|
365
|
+
const CONVENTIONAL_RE = new RegExp(`^(${CONVENTIONAL_TYPES})(\\([^)]+\\))?!?: .+`)
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Draft a Conventional Commits message for the staged changes.
|
|
369
|
+
*
|
|
370
|
+
* @returns {string | null} a message whose subject is valid Conventional Commits, or null
|
|
371
|
+
*/
|
|
372
|
+
function draftCommitMessage() {
|
|
373
|
+
const stat = tryRead('git', ['diff', '--cached', '--stat'])
|
|
374
|
+
// Cap the diff: a large one wastes the context window and rarely improves the subject.
|
|
375
|
+
const diff = tryRead('git', ['diff', '--cached', '--unified=1'])?.slice(0, 12_000)
|
|
376
|
+
if (!stat) return null
|
|
377
|
+
|
|
378
|
+
const prompt = [
|
|
379
|
+
'Write a Conventional Commits message for these staged changes.',
|
|
380
|
+
'',
|
|
381
|
+
'Rules:',
|
|
382
|
+
`- Subject: "<type>(<optional scope>): <description>" where type is one of ${CONVENTIONAL_TYPES.split('|').join(', ')}.`,
|
|
383
|
+
'- Subject in the imperative mood, no trailing period, under 72 characters.',
|
|
384
|
+
'- Add a body only if the change needs explanation; separate it with a blank line.',
|
|
385
|
+
'- Output the raw commit message and nothing else: no markdown fences, no preamble.',
|
|
386
|
+
'- Do NOT add Co-Authored-By, Signed-off-by, or any attribution or tool credit.',
|
|
387
|
+
'',
|
|
388
|
+
'Files changed:',
|
|
389
|
+
stat,
|
|
390
|
+
'',
|
|
391
|
+
'Diff:',
|
|
392
|
+
diff ?? '(unavailable)',
|
|
393
|
+
].join('\n')
|
|
394
|
+
|
|
395
|
+
const message = runAssistant(prompt)
|
|
396
|
+
if (!message) return null
|
|
397
|
+
const [subject] = message.split('\n')
|
|
398
|
+
return CONVENTIONAL_RE.test(subject) ? message : null
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Draft release notes from the commits since the last tag, in the changelog's own style.
|
|
403
|
+
*
|
|
404
|
+
* @returns {string | null} markdown body (no version heading), or null
|
|
405
|
+
*/
|
|
406
|
+
function draftReleaseNotes(version, subjects, lastTag) {
|
|
407
|
+
if (!subjects.length) return null
|
|
408
|
+
|
|
409
|
+
const prompt = [
|
|
410
|
+
`Write release notes for version ${version}.`,
|
|
411
|
+
'',
|
|
412
|
+
'Rules:',
|
|
413
|
+
'- Group the changes under Keep a Changelog headings (`### Added`, `### Changed`,',
|
|
414
|
+
' `### Fixed`, `### Removed`), including only the headings that apply.',
|
|
415
|
+
'- One bullet per user-visible change. Merge related commits into a single bullet.',
|
|
416
|
+
'- Omit internal chores: CI, linting, formatting, dependency bumps, version bumps.',
|
|
417
|
+
'- Write for someone upgrading: say what changed for them, not which files moved.',
|
|
418
|
+
'- Plain, factual language. No hype, no emoji, no concluding summary.',
|
|
419
|
+
'- Output only the markdown body: no version heading, no code fences, no attribution.',
|
|
420
|
+
'- Do NOT explain your reasoning or add any commentary before or after the notes.',
|
|
421
|
+
'',
|
|
422
|
+
`Commit subjects since ${lastTag ?? 'the start of the project'}:`,
|
|
423
|
+
...subjects.map((s) => `- ${s}`),
|
|
424
|
+
].join('\n')
|
|
425
|
+
|
|
426
|
+
const drafted = runAssistant(prompt)
|
|
427
|
+
return drafted ? cleanNotes(drafted) : null
|
|
428
|
+
}
|
|
429
|
+
|
|
195
430
|
/** One-line rendering of an argv, so a multi-line arg (release notes) stays readable. */
|
|
196
431
|
const formatCommand = (command, args) =>
|
|
197
432
|
[
|
|
@@ -392,6 +627,17 @@ function rollUnreleased(text, version, date) {
|
|
|
392
627
|
return text.slice(0, match.index) + released + text.slice(match.index + match[0].length)
|
|
393
628
|
}
|
|
394
629
|
|
|
630
|
+
/**
|
|
631
|
+
* Insert a section for a version above the newest existing one, so drafted notes are kept
|
|
632
|
+
* in the changelog rather than only reaching the tag and the GitHub release.
|
|
633
|
+
*/
|
|
634
|
+
function insertChangelogSection(text, version, date, body) {
|
|
635
|
+
const entry = `## [${version}] - ${date}\n\n${body}\n`
|
|
636
|
+
const firstSection = /^## /m.exec(text)
|
|
637
|
+
if (!firstSection) return `${text.trimEnd()}\n\n${entry}`
|
|
638
|
+
return `${text.slice(0, firstSection.index)}${entry}\n${text.slice(firstSection.index)}`
|
|
639
|
+
}
|
|
640
|
+
|
|
395
641
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
396
642
|
// JSON FILES
|
|
397
643
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -429,10 +675,14 @@ const option = (name) => {
|
|
|
429
675
|
|
|
430
676
|
const dryRun = flag('--dry-run')
|
|
431
677
|
const assumeYes = flag('--yes') || flag('-y')
|
|
432
|
-
const
|
|
433
|
-
const
|
|
434
|
-
const explicitDistTag = option('--tag')
|
|
678
|
+
const onlySteps = option('--only')
|
|
679
|
+
const skippedSteps = option('--skip')
|
|
680
|
+
const explicitDistTag = option('--dist-tag')
|
|
435
681
|
const requestedPreid = option('--preid')
|
|
682
|
+
const autoCommit = flag('--commit')
|
|
683
|
+
const requestedAssistant = option('--assistant')
|
|
684
|
+
const requestedModel = option('--assistant-model')
|
|
685
|
+
const requestedEffort = option('--assistant-effort')
|
|
436
686
|
|
|
437
687
|
if (flag('--help') || flag('-h')) {
|
|
438
688
|
console.log(USAGE)
|
|
@@ -472,7 +722,31 @@ if (flag('--sync')) {
|
|
|
472
722
|
process.exit(0)
|
|
473
723
|
}
|
|
474
724
|
|
|
475
|
-
|
|
725
|
+
/**
|
|
726
|
+
* Options that consume the argument after them. Without this list a positional target is
|
|
727
|
+
* found by guessing, and `--only tag,push` gets read as the version to release.
|
|
728
|
+
*/
|
|
729
|
+
const VALUE_OPTIONS = new Set([
|
|
730
|
+
'--only',
|
|
731
|
+
'--skip',
|
|
732
|
+
'--preid',
|
|
733
|
+
'--dist-tag',
|
|
734
|
+
'--assistant',
|
|
735
|
+
'--assistant-model',
|
|
736
|
+
'--assistant-effort',
|
|
737
|
+
])
|
|
738
|
+
|
|
739
|
+
/** The version or bump target: the first argument that is neither a flag nor a flag's value. */
|
|
740
|
+
const target = (() => {
|
|
741
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
742
|
+
if (VALUE_OPTIONS.has(argv[i])) {
|
|
743
|
+
i += 1
|
|
744
|
+
continue
|
|
745
|
+
}
|
|
746
|
+
if (!argv[i].startsWith('-')) return argv[i]
|
|
747
|
+
}
|
|
748
|
+
return undefined
|
|
749
|
+
})()
|
|
476
750
|
|
|
477
751
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
478
752
|
// SETUP
|
|
@@ -480,6 +754,21 @@ const target = argv.find((a) => !a.startsWith('-') && a !== explicitDistTag && a
|
|
|
480
754
|
|
|
481
755
|
const root = tryRead('git', ['rev-parse', '--show-toplevel'])
|
|
482
756
|
if (!root) abort('not inside a git repository')
|
|
757
|
+
|
|
758
|
+
// A release is scoped to the repository: the version, the tag and the push all belong to
|
|
759
|
+
// one git history, so the package released is the one at the git root. Refuse when invoked
|
|
760
|
+
// from a nested package instead — silently releasing the parent is the worse outcome.
|
|
761
|
+
const localManifest = resolve('package.json')
|
|
762
|
+
const rootManifest = join(root, 'package.json')
|
|
763
|
+
if (existsSync(localManifest) && localManifest !== rootManifest) {
|
|
764
|
+
abort(
|
|
765
|
+
`${relative(root, localManifest)} is a nested package, but a release covers the whole ` +
|
|
766
|
+
`repository.\n\n Running here would release ${
|
|
767
|
+
existsSync(rootManifest) ? readJson(rootManifest).name : 'the repository root'
|
|
768
|
+
} instead.\n` +
|
|
769
|
+
' release-kit handles one package per repository; it does not release workspace members.',
|
|
770
|
+
)
|
|
771
|
+
}
|
|
483
772
|
process.chdir(root)
|
|
484
773
|
|
|
485
774
|
if (!existsSync('package.json')) abort(`no package.json at ${root}`)
|
|
@@ -494,6 +783,68 @@ const config = {
|
|
|
494
783
|
const unknownKeys = Object.keys(config).filter((key) => !(key in DEFAULTS))
|
|
495
784
|
if (unknownKeys.length) abort(`release.config.json has unknown keys: ${unknownKeys.join(', ')}`)
|
|
496
785
|
|
|
786
|
+
/**
|
|
787
|
+
* Which steps run: config provides the baseline, --only replaces it, --skip subtracts, and
|
|
788
|
+
* --commit adds the opt-in step. Unknown names are an error rather than a silent no-op.
|
|
789
|
+
*/
|
|
790
|
+
const parseStepList = (value) =>
|
|
791
|
+
value
|
|
792
|
+
.split(',')
|
|
793
|
+
.map((name) => name.trim())
|
|
794
|
+
.filter(Boolean)
|
|
795
|
+
|
|
796
|
+
// Validate every name that was asked for, not just the ones that survive: a typo in
|
|
797
|
+
// --skip would otherwise delete nothing and silently run the step you meant to drop.
|
|
798
|
+
const requestedStepNames = [
|
|
799
|
+
...(onlySteps ? parseStepList(onlySteps) : []),
|
|
800
|
+
...(skippedSteps ? parseStepList(skippedSteps) : []),
|
|
801
|
+
...(Array.isArray(config.steps) ? config.steps : []),
|
|
802
|
+
]
|
|
803
|
+
const unknownSteps = [...new Set(requestedStepNames)].filter((name) => !STEPS.includes(name))
|
|
804
|
+
if (unknownSteps.length) {
|
|
805
|
+
abort(`unknown step(s): ${unknownSteps.join(', ')}\n Known steps: ${STEPS.join(', ')}`)
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
const steps = new Set(onlySteps ? parseStepList(onlySteps) : (config.steps ?? DEFAULT_STEPS))
|
|
809
|
+
if (skippedSteps) for (const name of parseStepList(skippedSteps)) steps.delete(name)
|
|
810
|
+
if (autoCommit) steps.add('commit')
|
|
811
|
+
const runs = (name) => steps.has(name)
|
|
812
|
+
|
|
813
|
+
/**
|
|
814
|
+
* The drafting tool, resolved from --assistant then config. "auto" picks the first one
|
|
815
|
+
* present on PATH; a named tool must be known and installed, otherwise it is an error
|
|
816
|
+
* rather than a silent downgrade to no drafting.
|
|
817
|
+
*/
|
|
818
|
+
const assistantConfig =
|
|
819
|
+
config.assistant && typeof config.assistant === 'object' ? config.assistant : {}
|
|
820
|
+
const assistantChoice =
|
|
821
|
+
requestedAssistant ??
|
|
822
|
+
(typeof config.assistant === 'string' ? config.assistant : assistantConfig.tool) ??
|
|
823
|
+
'none'
|
|
824
|
+
const assistantModel = requestedModel ?? assistantConfig.model ?? null
|
|
825
|
+
const assistantEffort = requestedEffort ?? assistantConfig.effort ?? null
|
|
826
|
+
let assistantName = null
|
|
827
|
+
if (assistantChoice !== 'none' && assistantChoice !== null) {
|
|
828
|
+
const candidates =
|
|
829
|
+
assistantChoice === 'auto'
|
|
830
|
+
? Object.keys(ASSISTANTS)
|
|
831
|
+
: [assistantChoice].filter((name) => {
|
|
832
|
+
if (name in ASSISTANTS) return true
|
|
833
|
+
abort(
|
|
834
|
+
`unknown assistant "${name}" (known: ${Object.keys(ASSISTANTS).join(', ')}, auto, none)`,
|
|
835
|
+
)
|
|
836
|
+
return false
|
|
837
|
+
})
|
|
838
|
+
assistantName =
|
|
839
|
+
candidates.find((name) => succeeds(ASSISTANTS[name].command, ASSISTANTS[name].probe)) ?? null
|
|
840
|
+
if (!assistantName && assistantChoice !== 'auto') {
|
|
841
|
+
abort(
|
|
842
|
+
`assistant "${assistantChoice}" is configured but \`${ASSISTANTS[assistantChoice].command}\` is not on PATH`,
|
|
843
|
+
)
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
const assistant = assistantName ? ASSISTANTS[assistantName] : null
|
|
847
|
+
|
|
497
848
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
498
849
|
// RESOLVE THE TARGET VERSION
|
|
499
850
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -521,7 +872,7 @@ if (!target) {
|
|
|
521
872
|
|
|
522
873
|
const tag = `${config.tagPrefix}${version}`
|
|
523
874
|
const isPrerelease = parseVersion(version).pre.length > 0
|
|
524
|
-
const bumping = version !== pkg.version
|
|
875
|
+
const bumping = version !== pkg.version && runs('version')
|
|
525
876
|
|
|
526
877
|
let distTag
|
|
527
878
|
try {
|
|
@@ -548,7 +899,7 @@ const expand = (template) => expandWith(template, (value) => value)
|
|
|
548
899
|
const shellQuote = (value) => `'${value.replaceAll("'", `'\\''`)}'`
|
|
549
900
|
const expandShell = (template) => expandWith(template, shellQuote)
|
|
550
901
|
|
|
551
|
-
const publishCommand =
|
|
902
|
+
const publishCommand = runs('publish') && config.publish ? expandShell(config.publish) : null
|
|
552
903
|
|
|
553
904
|
/**
|
|
554
905
|
* npm and pnpm answer `whoami` and `view` identically and share `~/.npmrc`, so whichever
|
|
@@ -573,6 +924,7 @@ const isTrustedPublishing =
|
|
|
573
924
|
!!process.env.NPM_ID_TOKEN
|
|
574
925
|
|
|
575
926
|
console.log(` ${dim(`${pkg.version} → ${version} tag ${tag} dist-tag ${distTag}`)}`)
|
|
927
|
+
console.log(` ${dim(`steps: ${STEPS.filter(runs).join(' → ')}`)}`)
|
|
576
928
|
|
|
577
929
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
578
930
|
// PREFLIGHT — every check runs, then it aborts once with all of the failures
|
|
@@ -596,8 +948,31 @@ if (bumping && compareVersions(version, pkg.version) <= 0) {
|
|
|
596
948
|
|
|
597
949
|
const dirty = tryRead('git', ['status', '--porcelain'])
|
|
598
950
|
if (dirty === null) fail('could not read git status')
|
|
599
|
-
else if (dirty
|
|
600
|
-
|
|
951
|
+
else if (dirty && runs('commit')) {
|
|
952
|
+
ok(`working tree has ${dirty.split('\n').length} change(s) — will be committed first`)
|
|
953
|
+
console.log(dim(indent(formatStatus(dirty))))
|
|
954
|
+
} else if (dirty) {
|
|
955
|
+
fail(`working tree is not clean:\n${indent(formatStatus(dirty))}`)
|
|
956
|
+
} else ok('working tree clean')
|
|
957
|
+
|
|
958
|
+
if (runs('commit') && !assistant) {
|
|
959
|
+
fail(
|
|
960
|
+
'the commit step needs a drafting assistant to write the message. Configure one with ' +
|
|
961
|
+
'`--assistant auto`, or set "assistant" in release.config.json.',
|
|
962
|
+
)
|
|
963
|
+
} else if (assistant) {
|
|
964
|
+
const detail = [
|
|
965
|
+
assistantModel && `model ${assistantModel}`,
|
|
966
|
+
assistantEffort && `effort ${assistantEffort}`,
|
|
967
|
+
]
|
|
968
|
+
.filter(Boolean)
|
|
969
|
+
.join(', ')
|
|
970
|
+
ok(`assistant: ${assistantName}${detail ? ` (${detail})` : ''}`)
|
|
971
|
+
if (assistantModel && !assistant.model)
|
|
972
|
+
warn(`${assistantName} takes no model flag — --model ignored`)
|
|
973
|
+
if (assistantEffort && !assistant.effort)
|
|
974
|
+
warn(`${assistantName} takes no effort flag — --effort ignored`)
|
|
975
|
+
}
|
|
601
976
|
|
|
602
977
|
const branch = tryRead('git', ['rev-parse', '--abbrev-ref', 'HEAD'])
|
|
603
978
|
if (!branch) fail('could not read the current branch')
|
|
@@ -627,7 +1002,9 @@ if (!succeeds('git', ['remote', 'get-url', config.remote])) {
|
|
|
627
1002
|
|
|
628
1003
|
const head = tryRead('git', ['rev-parse', 'HEAD'])
|
|
629
1004
|
const taggedCommit = tryRead('git', ['rev-list', '-n', '1', tag])
|
|
630
|
-
if (
|
|
1005
|
+
if (!runs('tag')) {
|
|
1006
|
+
note('tag step not selected')
|
|
1007
|
+
} else if (taggedCommit && bumping) {
|
|
631
1008
|
fail(`tag ${tag} already exists — release a different version`)
|
|
632
1009
|
} else if (taggedCommit && taggedCommit !== head) {
|
|
633
1010
|
fail(`tag ${tag} already exists at ${taggedCommit.slice(0, 8)}, not at HEAD`)
|
|
@@ -638,8 +1015,8 @@ if (taggedCommit && bumping) {
|
|
|
638
1015
|
}
|
|
639
1016
|
|
|
640
1017
|
let releaseExists = false
|
|
641
|
-
if (
|
|
642
|
-
note('
|
|
1018
|
+
if (!runs('release')) {
|
|
1019
|
+
note('release step not selected')
|
|
643
1020
|
} else if (!succeeds('gh', ['--version'])) {
|
|
644
1021
|
fail('the GitHub CLI (`gh`) is not installed — https://cli.github.com')
|
|
645
1022
|
} else if (!succeeds('gh', ['auth', 'status'])) {
|
|
@@ -652,7 +1029,7 @@ if (skipRelease) {
|
|
|
652
1029
|
|
|
653
1030
|
let alreadyPublished = false
|
|
654
1031
|
if (!publishCommand) {
|
|
655
|
-
note(
|
|
1032
|
+
note(runs('publish') ? 'no publish command configured' : 'publish step not selected')
|
|
656
1033
|
} else if (pkg.private) {
|
|
657
1034
|
fail('package.json is private but a publish command is configured')
|
|
658
1035
|
} else if (!registryCli) {
|
|
@@ -677,9 +1054,25 @@ if (!publishCommand) {
|
|
|
677
1054
|
}
|
|
678
1055
|
}
|
|
679
1056
|
|
|
680
|
-
// Notes: the changelog section for this version, else GitHub generates them
|
|
1057
|
+
// Notes: the changelog section for this version, else a draft, else GitHub generates them.
|
|
681
1058
|
let notes = null
|
|
682
1059
|
let rolledChangelog = null
|
|
1060
|
+
let draftedNotes = null
|
|
1061
|
+
|
|
1062
|
+
/**
|
|
1063
|
+
* True when --commit still has to create a commit. Notes drafted before that commit would
|
|
1064
|
+
* describe an incomplete release, so drafting waits until the working tree is committed.
|
|
1065
|
+
*/
|
|
1066
|
+
const notesDeferred = !!(dirty && runs('commit') && assistant)
|
|
1067
|
+
|
|
1068
|
+
/** Draft notes from the commit log, reporting what it is doing since it takes a moment. */
|
|
1069
|
+
function draftNotesFor(v) {
|
|
1070
|
+
if (!assistant) return null
|
|
1071
|
+
const { lastTag, subjects } = commitsSinceLastTag()
|
|
1072
|
+
if (!subjects.length) return null
|
|
1073
|
+
note(`drafting notes from ${subjects.length} commit(s) with ${assistantName}...`)
|
|
1074
|
+
return draftReleaseNotes(v, subjects, lastTag)
|
|
1075
|
+
}
|
|
683
1076
|
if (config.changelog && existsSync(config.changelog)) {
|
|
684
1077
|
const text = readFileSync(config.changelog, 'utf8')
|
|
685
1078
|
notes = changelogSection(text, version)
|
|
@@ -691,13 +1084,26 @@ if (config.changelog && existsSync(config.changelog)) {
|
|
|
691
1084
|
notes = changelogSection(rolledChangelog, version)
|
|
692
1085
|
ok(`${config.changelog}: [Unreleased] will become [${version}]`)
|
|
693
1086
|
} else {
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
1087
|
+
draftedNotes = notesDeferred ? null : draftNotesFor(version)
|
|
1088
|
+
if (notesDeferred) {
|
|
1089
|
+
ok(`${config.changelog}: a ${version} section will be drafted after the commit`)
|
|
1090
|
+
} else if (draftedNotes) {
|
|
1091
|
+
notes = draftedNotes
|
|
1092
|
+
ok(`${config.changelog}: a ${version} section will be drafted by ${assistantName}`)
|
|
1093
|
+
} else {
|
|
1094
|
+
warn(
|
|
1095
|
+
`${config.changelog} has no ${version} or [Unreleased] section — GitHub will generate the notes`,
|
|
1096
|
+
)
|
|
1097
|
+
}
|
|
697
1098
|
}
|
|
698
1099
|
}
|
|
699
|
-
} else
|
|
700
|
-
|
|
1100
|
+
} else {
|
|
1101
|
+
// No changelog file at all: there is nothing to roll, but notes can still be drafted for
|
|
1102
|
+
// the tag annotation and the GitHub release.
|
|
1103
|
+
notes = notesDeferred ? null : draftNotesFor(version)
|
|
1104
|
+
if (notesDeferred) ok('release notes will be drafted after the commit')
|
|
1105
|
+
else if (notes) ok(`release notes drafted by ${assistantName}`)
|
|
1106
|
+
else if (config.changelog) note(`no ${config.changelog} — GitHub will generate the notes`)
|
|
701
1107
|
}
|
|
702
1108
|
|
|
703
1109
|
for (const asset of config.assets) {
|
|
@@ -738,6 +1144,28 @@ if (!assumeYes && !dryRun && process.stdin.isTTY) {
|
|
|
738
1144
|
|
|
739
1145
|
const staged = []
|
|
740
1146
|
|
|
1147
|
+
if (dirty && runs('commit')) {
|
|
1148
|
+
step('Commit the working tree')
|
|
1149
|
+
mutate('git', ['add', '--all'])
|
|
1150
|
+
// Draft after staging: the message describes what is staged, not what happens to be
|
|
1151
|
+
// in the tree. Under --dry-run nothing was staged, so there is nothing to describe.
|
|
1152
|
+
const message = dryRun ? null : draftCommitMessage()
|
|
1153
|
+
if (!message && !dryRun) {
|
|
1154
|
+
abort(
|
|
1155
|
+
`${assistantName} could not draft a Conventional Commits message for the staged changes.\n\n` +
|
|
1156
|
+
' Commit them yourself and re-run, or run without --commit.',
|
|
1157
|
+
)
|
|
1158
|
+
}
|
|
1159
|
+
console.log(indent(message ?? '<drafted at run time>'))
|
|
1160
|
+
mutate('git', ['commit', '-m', message ?? 'chore: working tree'])
|
|
1161
|
+
|
|
1162
|
+
// Now that the commit exists it is part of the release, so the notes can describe it.
|
|
1163
|
+
if (notesDeferred && !dryRun) {
|
|
1164
|
+
draftedNotes = draftNotesFor(version)
|
|
1165
|
+
if (draftedNotes) notes = draftedNotes
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
|
|
741
1169
|
if (bumping) {
|
|
742
1170
|
step(`Write version ${version}`)
|
|
743
1171
|
for (const file of ['package.json', ...config.versionFiles]) {
|
|
@@ -754,11 +1182,26 @@ if (bumping) {
|
|
|
754
1182
|
}
|
|
755
1183
|
}
|
|
756
1184
|
|
|
757
|
-
if (rolledChangelog) {
|
|
1185
|
+
if (rolledChangelog && runs('changelog')) {
|
|
758
1186
|
step(`Roll ${config.changelog} to ${version}`)
|
|
759
1187
|
if (dryRun) console.log(` ${yellow('would write')} ${config.changelog}`)
|
|
760
1188
|
else writeFileSync(config.changelog, rolledChangelog)
|
|
761
1189
|
staged.push(config.changelog)
|
|
1190
|
+
} else if (runs('changelog') && draftedNotes && config.changelog && existsSync(config.changelog)) {
|
|
1191
|
+
step(`Add the drafted ${version} section to ${config.changelog}`)
|
|
1192
|
+
if (dryRun) console.log(` ${yellow('would write')} ${config.changelog}`)
|
|
1193
|
+
else {
|
|
1194
|
+
writeFileSync(
|
|
1195
|
+
config.changelog,
|
|
1196
|
+
insertChangelogSection(
|
|
1197
|
+
readFileSync(config.changelog, 'utf8'),
|
|
1198
|
+
version,
|
|
1199
|
+
new Date().toISOString().slice(0, 10),
|
|
1200
|
+
draftedNotes,
|
|
1201
|
+
),
|
|
1202
|
+
)
|
|
1203
|
+
}
|
|
1204
|
+
staged.push(config.changelog)
|
|
762
1205
|
}
|
|
763
1206
|
|
|
764
1207
|
if (staged.length) {
|
|
@@ -767,7 +1210,7 @@ if (staged.length) {
|
|
|
767
1210
|
mutate('git', ['commit', '-m', expand(config.commitMessage)])
|
|
768
1211
|
}
|
|
769
1212
|
|
|
770
|
-
if (!taggedCommit) {
|
|
1213
|
+
if (runs('tag') && !taggedCommit) {
|
|
771
1214
|
step(`Annotated tag ${tag}`)
|
|
772
1215
|
// The notes become the tag annotation too, so a CI release workflow can read them
|
|
773
1216
|
// straight off the tag instead of re-deriving them. --cleanup=verbatim is required:
|
|
@@ -783,17 +1226,19 @@ if (!taggedCommit) {
|
|
|
783
1226
|
])
|
|
784
1227
|
}
|
|
785
1228
|
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
//
|
|
789
|
-
|
|
1229
|
+
if (runs('push')) {
|
|
1230
|
+
step(`Push branch and tag to ${config.remote}`)
|
|
1231
|
+
// --follow-tags sends the commit and the tag in one call; pushing them separately is how
|
|
1232
|
+
// a tag ends up on the remote without its commit, or a release without its tag.
|
|
1233
|
+
mutate('git', ['push', '--follow-tags', config.remote, branch ?? 'HEAD'])
|
|
1234
|
+
}
|
|
790
1235
|
|
|
791
1236
|
if (publishCommand && !alreadyPublished) {
|
|
792
1237
|
step(`Publish to the registry (dist-tag ${distTag})`)
|
|
793
1238
|
mutateShell(publishCommand)
|
|
794
1239
|
}
|
|
795
1240
|
|
|
796
|
-
if (
|
|
1241
|
+
if (runs('release') && !releaseExists) {
|
|
797
1242
|
step(`GitHub release ${tag}`)
|
|
798
1243
|
const args = [
|
|
799
1244
|
'release',
|