@entro314labs/release-kit 1.0.3 → 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 +459 -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
|
|
@@ -509,6 +783,68 @@ const config = {
|
|
|
509
783
|
const unknownKeys = Object.keys(config).filter((key) => !(key in DEFAULTS))
|
|
510
784
|
if (unknownKeys.length) abort(`release.config.json has unknown keys: ${unknownKeys.join(', ')}`)
|
|
511
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
|
+
|
|
512
848
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
513
849
|
// RESOLVE THE TARGET VERSION
|
|
514
850
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -536,7 +872,7 @@ if (!target) {
|
|
|
536
872
|
|
|
537
873
|
const tag = `${config.tagPrefix}${version}`
|
|
538
874
|
const isPrerelease = parseVersion(version).pre.length > 0
|
|
539
|
-
const bumping = version !== pkg.version
|
|
875
|
+
const bumping = version !== pkg.version && runs('version')
|
|
540
876
|
|
|
541
877
|
let distTag
|
|
542
878
|
try {
|
|
@@ -563,7 +899,7 @@ const expand = (template) => expandWith(template, (value) => value)
|
|
|
563
899
|
const shellQuote = (value) => `'${value.replaceAll("'", `'\\''`)}'`
|
|
564
900
|
const expandShell = (template) => expandWith(template, shellQuote)
|
|
565
901
|
|
|
566
|
-
const publishCommand =
|
|
902
|
+
const publishCommand = runs('publish') && config.publish ? expandShell(config.publish) : null
|
|
567
903
|
|
|
568
904
|
/**
|
|
569
905
|
* npm and pnpm answer `whoami` and `view` identically and share `~/.npmrc`, so whichever
|
|
@@ -588,6 +924,7 @@ const isTrustedPublishing =
|
|
|
588
924
|
!!process.env.NPM_ID_TOKEN
|
|
589
925
|
|
|
590
926
|
console.log(` ${dim(`${pkg.version} → ${version} tag ${tag} dist-tag ${distTag}`)}`)
|
|
927
|
+
console.log(` ${dim(`steps: ${STEPS.filter(runs).join(' → ')}`)}`)
|
|
591
928
|
|
|
592
929
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
593
930
|
// PREFLIGHT — every check runs, then it aborts once with all of the failures
|
|
@@ -611,8 +948,31 @@ if (bumping && compareVersions(version, pkg.version) <= 0) {
|
|
|
611
948
|
|
|
612
949
|
const dirty = tryRead('git', ['status', '--porcelain'])
|
|
613
950
|
if (dirty === null) fail('could not read git status')
|
|
614
|
-
else if (dirty
|
|
615
|
-
|
|
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
|
+
}
|
|
616
976
|
|
|
617
977
|
const branch = tryRead('git', ['rev-parse', '--abbrev-ref', 'HEAD'])
|
|
618
978
|
if (!branch) fail('could not read the current branch')
|
|
@@ -642,7 +1002,9 @@ if (!succeeds('git', ['remote', 'get-url', config.remote])) {
|
|
|
642
1002
|
|
|
643
1003
|
const head = tryRead('git', ['rev-parse', 'HEAD'])
|
|
644
1004
|
const taggedCommit = tryRead('git', ['rev-list', '-n', '1', tag])
|
|
645
|
-
if (
|
|
1005
|
+
if (!runs('tag')) {
|
|
1006
|
+
note('tag step not selected')
|
|
1007
|
+
} else if (taggedCommit && bumping) {
|
|
646
1008
|
fail(`tag ${tag} already exists — release a different version`)
|
|
647
1009
|
} else if (taggedCommit && taggedCommit !== head) {
|
|
648
1010
|
fail(`tag ${tag} already exists at ${taggedCommit.slice(0, 8)}, not at HEAD`)
|
|
@@ -653,8 +1015,8 @@ if (taggedCommit && bumping) {
|
|
|
653
1015
|
}
|
|
654
1016
|
|
|
655
1017
|
let releaseExists = false
|
|
656
|
-
if (
|
|
657
|
-
note('
|
|
1018
|
+
if (!runs('release')) {
|
|
1019
|
+
note('release step not selected')
|
|
658
1020
|
} else if (!succeeds('gh', ['--version'])) {
|
|
659
1021
|
fail('the GitHub CLI (`gh`) is not installed — https://cli.github.com')
|
|
660
1022
|
} else if (!succeeds('gh', ['auth', 'status'])) {
|
|
@@ -667,7 +1029,7 @@ if (skipRelease) {
|
|
|
667
1029
|
|
|
668
1030
|
let alreadyPublished = false
|
|
669
1031
|
if (!publishCommand) {
|
|
670
|
-
note(
|
|
1032
|
+
note(runs('publish') ? 'no publish command configured' : 'publish step not selected')
|
|
671
1033
|
} else if (pkg.private) {
|
|
672
1034
|
fail('package.json is private but a publish command is configured')
|
|
673
1035
|
} else if (!registryCli) {
|
|
@@ -692,9 +1054,25 @@ if (!publishCommand) {
|
|
|
692
1054
|
}
|
|
693
1055
|
}
|
|
694
1056
|
|
|
695
|
-
// 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.
|
|
696
1058
|
let notes = null
|
|
697
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
|
+
}
|
|
698
1076
|
if (config.changelog && existsSync(config.changelog)) {
|
|
699
1077
|
const text = readFileSync(config.changelog, 'utf8')
|
|
700
1078
|
notes = changelogSection(text, version)
|
|
@@ -706,13 +1084,26 @@ if (config.changelog && existsSync(config.changelog)) {
|
|
|
706
1084
|
notes = changelogSection(rolledChangelog, version)
|
|
707
1085
|
ok(`${config.changelog}: [Unreleased] will become [${version}]`)
|
|
708
1086
|
} else {
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
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
|
+
}
|
|
712
1098
|
}
|
|
713
1099
|
}
|
|
714
|
-
} else
|
|
715
|
-
|
|
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`)
|
|
716
1107
|
}
|
|
717
1108
|
|
|
718
1109
|
for (const asset of config.assets) {
|
|
@@ -753,6 +1144,28 @@ if (!assumeYes && !dryRun && process.stdin.isTTY) {
|
|
|
753
1144
|
|
|
754
1145
|
const staged = []
|
|
755
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
|
+
|
|
756
1169
|
if (bumping) {
|
|
757
1170
|
step(`Write version ${version}`)
|
|
758
1171
|
for (const file of ['package.json', ...config.versionFiles]) {
|
|
@@ -769,11 +1182,26 @@ if (bumping) {
|
|
|
769
1182
|
}
|
|
770
1183
|
}
|
|
771
1184
|
|
|
772
|
-
if (rolledChangelog) {
|
|
1185
|
+
if (rolledChangelog && runs('changelog')) {
|
|
773
1186
|
step(`Roll ${config.changelog} to ${version}`)
|
|
774
1187
|
if (dryRun) console.log(` ${yellow('would write')} ${config.changelog}`)
|
|
775
1188
|
else writeFileSync(config.changelog, rolledChangelog)
|
|
776
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)
|
|
777
1205
|
}
|
|
778
1206
|
|
|
779
1207
|
if (staged.length) {
|
|
@@ -782,7 +1210,7 @@ if (staged.length) {
|
|
|
782
1210
|
mutate('git', ['commit', '-m', expand(config.commitMessage)])
|
|
783
1211
|
}
|
|
784
1212
|
|
|
785
|
-
if (!taggedCommit) {
|
|
1213
|
+
if (runs('tag') && !taggedCommit) {
|
|
786
1214
|
step(`Annotated tag ${tag}`)
|
|
787
1215
|
// The notes become the tag annotation too, so a CI release workflow can read them
|
|
788
1216
|
// straight off the tag instead of re-deriving them. --cleanup=verbatim is required:
|
|
@@ -798,17 +1226,19 @@ if (!taggedCommit) {
|
|
|
798
1226
|
])
|
|
799
1227
|
}
|
|
800
1228
|
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
//
|
|
804
|
-
|
|
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
|
+
}
|
|
805
1235
|
|
|
806
1236
|
if (publishCommand && !alreadyPublished) {
|
|
807
1237
|
step(`Publish to the registry (dist-tag ${distTag})`)
|
|
808
1238
|
mutateShell(publishCommand)
|
|
809
1239
|
}
|
|
810
1240
|
|
|
811
|
-
if (
|
|
1241
|
+
if (runs('release') && !releaseExists) {
|
|
812
1242
|
step(`GitHub release ${tag}`)
|
|
813
1243
|
const args = [
|
|
814
1244
|
'release',
|