@entro314labs/release-kit 2.7.0 → 2.8.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 +83 -42
- package/package.json +1 -1
- package/release.mjs +276 -26
package/README.md
CHANGED
|
@@ -189,26 +189,58 @@ Prerelease bumps need `--preid` unless the current version already carries one t
|
|
|
189
189
|
| `--sync <dir>...` | Copy this script into other projects and exit. Touches no git state. |
|
|
190
190
|
| `--help`, `-h` | Full flag list. |
|
|
191
191
|
|
|
192
|
+
### Linting commits
|
|
193
|
+
|
|
194
|
+
A subject that is not Conventional Commits is invisible: it contributes nothing to the
|
|
195
|
+
inferred bump and never reaches the changelog. `lint-commits` checks subjects against the
|
|
196
|
+
same parser the release uses, so the gate and the release can never disagree about what
|
|
197
|
+
counts.
|
|
198
|
+
|
|
199
|
+
```bash
|
|
200
|
+
release-kit lint-commits # since the last tag
|
|
201
|
+
release-kit lint-commits main..HEAD # an explicit range
|
|
202
|
+
release-kit lint-commits --subject "feat: x" # one subject — a pull request title
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
It exits non-zero on a subject the release cannot read. A type outside the changelog table
|
|
206
|
+
— `security:`, `i18n:` — is a warning, not a failure: those still get printed, under _Other
|
|
207
|
+
Changes_. Release commits, merges and `fixup!`/`squash!` markers are skipped, per the same
|
|
208
|
+
`ignoreCommits` config the release notes use.
|
|
209
|
+
|
|
210
|
+
Local commit-msg hooks cannot cover the case that matters most. If you squash-merge, the
|
|
211
|
+
commit released is the **pull request title**, which no hook ever sees — check it in CI:
|
|
212
|
+
|
|
213
|
+
```yaml
|
|
214
|
+
- name: Lint the pull request title
|
|
215
|
+
env:
|
|
216
|
+
TITLE: ${{ github.event.pull_request.title }}
|
|
217
|
+
run: npx @entro314labs/release-kit@2.8.0 lint-commits --subject "$TITLE"
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
Pass the title through `env`, never through `${{ }}` inside `run:` — a pull request title is
|
|
221
|
+
attacker-controlled text and interpolating it into a shell command is a script injection.
|
|
222
|
+
|
|
192
223
|
## 🧩 Steps
|
|
193
224
|
|
|
194
225
|
A release is seven named steps. They always run in this order — `steps` selects which of
|
|
195
226
|
them execute, it never reorders them.
|
|
196
227
|
|
|
197
|
-
| Step | Default | What it does
|
|
198
|
-
| ----------- | ------- |
|
|
199
|
-
| `commit` | on\* | Commit a dirty working tree
|
|
200
|
-
| `version` | on | Write the version into `package.json` and `versionFiles`
|
|
201
|
-
| `changelog` | on | Roll `[Unreleased]` into the version, or add drafted notes
|
|
202
|
-
| `tag` | on | Annotated git tag carrying the release notes
|
|
203
|
-
| `push` | on | Push the branch and tag together (`--follow-tags`)
|
|
204
|
-
| `publish` | on | Run the configured `publish` command
|
|
205
|
-
| `release` | on | Create the GitHub release
|
|
206
|
-
|
|
207
|
-
\* `commit`
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
228
|
+
| Step | Default | What it does |
|
|
229
|
+
| ----------- | ------- | -------------------------------------------------------------------------------------------------- |
|
|
230
|
+
| `commit` | on\* | Commit a dirty working tree — drafted with an [assistant](#-assistant-optional), generated without |
|
|
231
|
+
| `version` | on | Write the version into `package.json` and `versionFiles` |
|
|
232
|
+
| `changelog` | on | Roll `[Unreleased]` into the version, or add drafted notes |
|
|
233
|
+
| `tag` | on | Annotated git tag carrying the release notes |
|
|
234
|
+
| `push` | on | Push the branch and tag together (`--follow-tags`) |
|
|
235
|
+
| `publish` | on | Run the configured `publish` command |
|
|
236
|
+
| `release` | on | Create the GitHub release |
|
|
237
|
+
|
|
238
|
+
\* `commit` no-ops on a clean tree. On a dirty tree it stages everything and commits:
|
|
239
|
+
with an [assistant](#-assistant-optional) configured the message is drafted from the
|
|
240
|
+
diff; without one it degrades gracefully to a generated `chore:` message naming the
|
|
241
|
+
changed files — a release is never blocked because a text generator was unavailable.
|
|
242
|
+
Opt out with `--skip commit` or a `steps` config, which restores the refusal on a dirty
|
|
243
|
+
tree.
|
|
212
244
|
|
|
213
245
|
`version` and `changelog` write files; those writes are persisted by a release commit made
|
|
214
246
|
automatically when either step runs.
|
|
@@ -289,7 +321,14 @@ rather than stopping at the first problem.
|
|
|
289
321
|
- Commit and tag signing can actually sign, and the key is one GitHub will accept
|
|
290
322
|
- The publishing CLI is authenticated, and the version is not already published
|
|
291
323
|
- Configured release assets exist
|
|
292
|
-
-
|
|
324
|
+
- The configured `verify` command passes — the project's own gate (tests, build) runs
|
|
325
|
+
before anything mutates, instead of a `prepublishOnly` hook failing after the commit,
|
|
326
|
+
tag and push
|
|
327
|
+
- `package.json`'s `repository` matches the git remote, so the registry's "Repository"
|
|
328
|
+
link is not broken _(warning)_
|
|
329
|
+
- A shallow clone only matters when it truncates the history the release reads: with the
|
|
330
|
+
previous tag reachable it passes, without one it fails `auto` (the bump would be inferred
|
|
331
|
+
from partial history) and warns otherwise
|
|
293
332
|
- A changelog section for the version exists _(warning — it falls back to generated notes)_
|
|
294
333
|
|
|
295
334
|
Under `--dry-run` the failures are reported and then the remaining steps are shown anyway,
|
|
@@ -420,21 +459,22 @@ execution is not wired up yet.
|
|
|
420
459
|
`release.config.json`, beside `package.json`. Every key is optional; unknown keys abort
|
|
421
460
|
rather than being silently ignored.
|
|
422
461
|
|
|
423
|
-
| Key | Default | Meaning
|
|
424
|
-
| --------------- | ------------------------ |
|
|
425
|
-
| `steps` | all but `commit` | Which steps run; the order is fixed
|
|
426
|
-
| `tagPrefix` | `"v"` | Prepended to the version to form the tag
|
|
427
|
-
| `branch` | `"main"` | The only branch a release may run from; `null` allows any
|
|
428
|
-
| `remote` | `"origin"` | Git remote to push to
|
|
429
|
-
| `changelog` | `"CHANGELOG.md"` | Changelog path; `null` for a project without one
|
|
430
|
-
| `versionFile` | detected | Where the version lives; `null` versions by tag alone
|
|
431
|
-
| `versionFiles` | `[]` | Further files kept in sync; a path or `{ path, pattern }`
|
|
432
|
-
| `publish` | `"npm publish --tag %d"` | Publish command; `null` means none is configured
|
|
433
|
-
| `versioning` | `"conventional"` | How `auto` infers; or `always-patch` / `-minor` / `-major`
|
|
434
|
-
| `
|
|
435
|
-
| `
|
|
436
|
-
| `
|
|
437
|
-
| `
|
|
462
|
+
| Key | Default | Meaning |
|
|
463
|
+
| --------------- | ------------------------ | --------------------------------------------------------------------- |
|
|
464
|
+
| `steps` | all but `commit` | Which steps run; the order is fixed |
|
|
465
|
+
| `tagPrefix` | `"v"` | Prepended to the version to form the tag |
|
|
466
|
+
| `branch` | `"main"` | The only branch a release may run from; `null` allows any |
|
|
467
|
+
| `remote` | `"origin"` | Git remote to push to |
|
|
468
|
+
| `changelog` | `"CHANGELOG.md"` | Changelog path; `null` for a project without one |
|
|
469
|
+
| `versionFile` | detected | Where the version lives; `null` versions by tag alone |
|
|
470
|
+
| `versionFiles` | `[]` | Further files kept in sync; a path or `{ path, pattern }` |
|
|
471
|
+
| `publish` | `"npm publish --tag %d"` | Publish command; `null` means none is configured |
|
|
472
|
+
| `versioning` | `"conventional"` | How `auto` infers; or `always-patch` / `-minor` / `-major` |
|
|
473
|
+
| `verify` | `null` | Command run during preflight; non-zero aborts before anything mutates |
|
|
474
|
+
| `assistant` | `null` | Drafting CLI: a name, `"auto"`, or `{ tool, model, effort }` |
|
|
475
|
+
| `commitMessage` | `"chore(release): %t"` | Release commit subject |
|
|
476
|
+
| `releaseTitle` | `"%t"` | GitHub release title |
|
|
477
|
+
| `assets` | `[]` | Files attached to the GitHub release |
|
|
438
478
|
|
|
439
479
|
Command and message strings expand four tokens: `%v` version, `%t` tag, `%n` package
|
|
440
480
|
name, `%d` npm dist-tag. In the `publish` command line the substituted values are
|
|
@@ -468,11 +508,11 @@ Two upstream habits make commit-derived notes trustworthy, and neither is releas
|
|
|
468
508
|
check workflow succeeds, with `if: github.repository_owner == 'your-org'` so a fork never
|
|
469
509
|
tries to release.
|
|
470
510
|
- **Validate pull request titles.** A squash-merge takes its subject from the PR title, so
|
|
471
|
-
that title becomes the commit the notes are built from.
|
|
472
|
-
[`
|
|
473
|
-
|
|
474
|
-
release-kit says how many commits are not Conventional Commits, but it
|
|
475
|
-
after the fact.
|
|
511
|
+
that title becomes the commit the notes are built from. Check it with
|
|
512
|
+
[`lint-commits`](#linting-commits), which uses this tool's own parser rather than a second
|
|
513
|
+
opinion about the grammar. Without something like it, work silently goes missing from
|
|
514
|
+
release notes — release-kit says how many commits are not Conventional Commits, but it
|
|
515
|
+
cannot fix them after the fact.
|
|
476
516
|
|
|
477
517
|
Three things CI does that are worth knowing about:
|
|
478
518
|
|
|
@@ -590,12 +630,13 @@ downgrade, so a configured pipeline fails loudly; `"auto"` degrades quietly by d
|
|
|
590
630
|
|
|
591
631
|
### What it does
|
|
592
632
|
|
|
593
|
-
- **
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
the tool never
|
|
633
|
+
- **Commit messages for a dirty working tree are drafted from the staged diff**, instead
|
|
634
|
+
of the generated `chore:` message used when no assistant is available. The draft is
|
|
635
|
+
validated, not trusted: a subject that is not Conventional Commits, or a message naming
|
|
636
|
+
a version the staged changes never touch (a model narrating an unchanged `"version"`
|
|
637
|
+
context line), falls back to the generated message rather than being committed.
|
|
638
|
+
Attribution lines (`Co-Authored-By`, `Generated with`) are stripped, so the tool never
|
|
639
|
+
signs your commits.
|
|
599
640
|
- **Release notes** are drafted from the commits since the last tag when `CHANGELOG.md` has
|
|
600
641
|
no section for the version. Each bullet ends with a link to the commits it covers: the
|
|
601
642
|
assistant is given the short hashes and asked to cite them, and every citation is checked
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@entro314labs/release-kit",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.8.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
|
@@ -77,6 +77,9 @@ import { createInterface } from 'node:readline/promises'
|
|
|
77
77
|
* release commits, merges, work-in-progress, autosquash markers
|
|
78
78
|
* versioning string how `auto` derives a bump: "conventional", or
|
|
79
79
|
* always-patch / always-minor / always-major to never infer
|
|
80
|
+
* verify string command run during preflight — a project's own gate (tests,
|
|
81
|
+
* build). Non-zero aborts before anything mutates, instead of a
|
|
82
|
+
* prepublishOnly hook failing after the commit, tag and push
|
|
80
83
|
* assistant string|object drafting CLI for commit messages and notes. A key of
|
|
81
84
|
* ASSISTANTS, "auto" for the first available, or null. The
|
|
82
85
|
* object form { tool, model, effort } also pins which model and
|
|
@@ -130,6 +133,7 @@ const DEFAULTS = {
|
|
|
130
133
|
'^wip\\b',
|
|
131
134
|
'^(fixup|squash)!',
|
|
132
135
|
],
|
|
136
|
+
verify: null,
|
|
133
137
|
}
|
|
134
138
|
|
|
135
139
|
/**
|
|
@@ -161,6 +165,13 @@ Target (optional; defaults to the version already in package.json):
|
|
|
161
165
|
Steps, in the fixed order they run. All but "commit" run by default:
|
|
162
166
|
${STEPS.join(' ')}
|
|
163
167
|
|
|
168
|
+
Subcommands (they check or copy, and never start a release):
|
|
169
|
+
lint-commits [<range>]
|
|
170
|
+
check commit subjects against Conventional Commits
|
|
171
|
+
(default range: since the last tag)
|
|
172
|
+
lint-commits --subject <text>
|
|
173
|
+
check one subject — a pull request title before it is squashed
|
|
174
|
+
|
|
164
175
|
Flags:
|
|
165
176
|
--only <steps> run only these steps, comma-separated
|
|
166
177
|
--skip <steps> run every step except these
|
|
@@ -223,8 +234,8 @@ const formatStatus = (porcelain) =>
|
|
|
223
234
|
})
|
|
224
235
|
.join('\n')
|
|
225
236
|
|
|
226
|
-
function abort(message) {
|
|
227
|
-
console.log(`\n${red(bold(
|
|
237
|
+
function abort(message, title = 'RELEASE ABORTED') {
|
|
238
|
+
console.log(`\n${red(bold(title))} — ${message}\n`)
|
|
228
239
|
process.exit(1)
|
|
229
240
|
}
|
|
230
241
|
|
|
@@ -624,8 +635,104 @@ function withoutRevertedCommits(commits) {
|
|
|
624
635
|
)
|
|
625
636
|
}
|
|
626
637
|
|
|
627
|
-
|
|
628
|
-
|
|
638
|
+
/**
|
|
639
|
+
* Semver strings a drafted message names that the staged changes never touch. The prompt
|
|
640
|
+
* forbids narrating versions, but a model can still read an unchanged `"version"` context
|
|
641
|
+
* line and describe it as work — a draft once claimed "release v1.4.5" for a commit that
|
|
642
|
+
* changed no version at all. Only added and removed lines are the change.
|
|
643
|
+
*/
|
|
644
|
+
function inventedVersions(message, changedLines) {
|
|
645
|
+
const versions = message.match(/\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?/g) ?? []
|
|
646
|
+
return [...new Set(versions)].filter((version) => !changedLines.includes(version))
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/**
|
|
650
|
+
* A repository URL reduced to what identifies the repository: `git+` and protocol
|
|
651
|
+
* prefixes, the `git@host:` shorthand, a trailing `.git` and letter case all vary between
|
|
652
|
+
* package.json and a git remote without meaning a different repo.
|
|
653
|
+
*/
|
|
654
|
+
function normalizeRepoUrl(url) {
|
|
655
|
+
if (!url) return null
|
|
656
|
+
return url
|
|
657
|
+
.trim()
|
|
658
|
+
.replace(/^git\+/, '')
|
|
659
|
+
.replace(/^git@([^:]+):/, 'https://$1/')
|
|
660
|
+
.replace(/^ssh:\/\/git@/, 'https://')
|
|
661
|
+
.replace(/^git:\/\//, 'https://')
|
|
662
|
+
.replace(/\.git$/, '')
|
|
663
|
+
.replace(/\/+$/, '')
|
|
664
|
+
.toLowerCase()
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* A deterministic Conventional Commits message built from the staged file list — the
|
|
669
|
+
* floor under the drafting assistant. A release is never blocked because a text
|
|
670
|
+
* generator was unavailable or produced an unusable answer: the honest fallback is a
|
|
671
|
+
* chore commit that names what it touches, with the full paths in the body.
|
|
672
|
+
*/
|
|
673
|
+
function fallbackCommitMessage(files) {
|
|
674
|
+
const named = `chore: update ${files.map((file) => basename(file)).join(' and ')}`
|
|
675
|
+
const subject =
|
|
676
|
+
files.length > 0 && files.length <= 2 && named.length <= 72
|
|
677
|
+
? named
|
|
678
|
+
: `chore: update ${files.length} files`
|
|
679
|
+
const body = files.length > 2 ? files.map((file) => `- ${file}`).join('\n') : ''
|
|
680
|
+
return body ? `${subject}\n\n${body}` : subject
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/**
|
|
684
|
+
* The types worth writing: every one has a changelog section of its own.
|
|
685
|
+
*
|
|
686
|
+
* Derived from CHANGELOG_SECTIONS rather than written out again, because the hand-kept
|
|
687
|
+
* copy had already drifted — it omitted `deps`, so the drafter could never produce a
|
|
688
|
+
* subject for the Dependencies section the changelog has always had.
|
|
689
|
+
*/
|
|
690
|
+
const CHANGELOG_TYPES = CHANGELOG_SECTIONS.map((s) => s.type)
|
|
691
|
+
|
|
692
|
+
/** The drafter's types plus `feature`, the alias `changelogFromCommits` folds into feat. */
|
|
693
|
+
const KNOWN_TYPES = new Set([...CHANGELOG_TYPES, 'feature'])
|
|
694
|
+
const CONVENTIONAL_RE = new RegExp(`^(${[...KNOWN_TYPES].join('|')})(\\([^)]+\\))?!?: .+`)
|
|
695
|
+
|
|
696
|
+
/**
|
|
697
|
+
* Check commit subjects against the grammar the rest of this file reads.
|
|
698
|
+
*
|
|
699
|
+
* Two severities, because the two failures do not cost the same:
|
|
700
|
+
*
|
|
701
|
+
* - A subject `parseCommit` cannot read is invisible. It contributes nothing to the
|
|
702
|
+
* inferred bump and never reaches the changelog, so the work simply disappears.
|
|
703
|
+
* - A type outside CHANGELOG_SECTIONS is merely unfiled: `changelogFromCommits` still
|
|
704
|
+
* prints it under "Other Changes". `security:` and `i18n:` warn rather than fail —
|
|
705
|
+
* refusing them would make this stricter than the tool it is meant to protect.
|
|
706
|
+
*
|
|
707
|
+
* Case is not one of the failures: `parseCommit` folds the type, so `Feat:` bumps and files
|
|
708
|
+
* exactly as `feat:` does. The drafter is stricter about its own output than this is about
|
|
709
|
+
* anybody's commits, and deliberately so.
|
|
710
|
+
*
|
|
711
|
+
* @param {{subject: string, hash?: string}[]} commits
|
|
712
|
+
* @returns {{subject: string, hash: string, level: 'error'|'warn', reason: string}[]}
|
|
713
|
+
*/
|
|
714
|
+
function lintSubjects(commits) {
|
|
715
|
+
const findings = []
|
|
716
|
+
for (const { subject, hash = '' } of commits) {
|
|
717
|
+
const parsed = parseCommit(subject)
|
|
718
|
+
if (!parsed) {
|
|
719
|
+
findings.push({
|
|
720
|
+
subject,
|
|
721
|
+
hash,
|
|
722
|
+
level: 'error',
|
|
723
|
+
reason: `not Conventional Commits — expected "<type>(<scope>): <description>" with type one of ${CHANGELOG_TYPES.join(', ')}`,
|
|
724
|
+
})
|
|
725
|
+
} else if (!KNOWN_TYPES.has(parsed.type)) {
|
|
726
|
+
findings.push({
|
|
727
|
+
subject,
|
|
728
|
+
hash,
|
|
729
|
+
level: 'warn',
|
|
730
|
+
reason: `type "${parsed.type}" has no changelog section — it lands under "Other Changes"`,
|
|
731
|
+
})
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
return findings
|
|
735
|
+
}
|
|
629
736
|
|
|
630
737
|
/**
|
|
631
738
|
* Draft a Conventional Commits message for the staged changes.
|
|
@@ -642,11 +749,15 @@ function draftCommitMessage() {
|
|
|
642
749
|
'Write a Conventional Commits message for these staged changes.',
|
|
643
750
|
'',
|
|
644
751
|
'Rules:',
|
|
645
|
-
`- Subject: "<type>(<optional scope>): <description>" where type is one of ${
|
|
752
|
+
`- Subject: "<type>(<optional scope>): <description>" where type is one of ${CHANGELOG_TYPES.join(', ')}.`,
|
|
646
753
|
'- Subject in the imperative mood, no trailing period, under 72 characters.',
|
|
647
754
|
'- Add a body only if the change needs explanation; separate it with a blank line.',
|
|
648
755
|
'- Output the raw commit message and nothing else: no markdown fences, no preamble.',
|
|
649
756
|
'- Do NOT add Co-Authored-By, Signed-off-by, or any attribution or tool credit.',
|
|
757
|
+
'- Describe only lines that are added or removed in the diff. Unchanged context lines',
|
|
758
|
+
' (including any version fields they show) are not part of this change.',
|
|
759
|
+
'- Never mention version numbers, releases, or version bumps: the release tooling',
|
|
760
|
+
' handles versioning separately, and this commit is not the release.',
|
|
650
761
|
'',
|
|
651
762
|
'Files changed:',
|
|
652
763
|
stat,
|
|
@@ -658,7 +769,19 @@ function draftCommitMessage() {
|
|
|
658
769
|
const message = runAssistant(prompt)
|
|
659
770
|
if (!message) return null
|
|
660
771
|
const [subject] = message.split('\n')
|
|
661
|
-
|
|
772
|
+
if (!CONVENTIONAL_RE.test(subject)) return null
|
|
773
|
+
// The prompt forbids narrating versions; this is the deterministic backstop — the same
|
|
774
|
+
// pattern as the citation check on drafted notes: validated, not trusted.
|
|
775
|
+
const changedLines = (tryRead('git', ['diff', '--cached', '--unified=0']) ?? '')
|
|
776
|
+
.split('\n')
|
|
777
|
+
.filter((line) => /^[+-](?![+-])/.test(line))
|
|
778
|
+
.join('\n')
|
|
779
|
+
const invented = inventedVersions(message, changedLines)
|
|
780
|
+
if (invented.length) {
|
|
781
|
+
note(`draft rejected: it names ${invented.join(', ')}, which the staged changes never touch`)
|
|
782
|
+
return null
|
|
783
|
+
}
|
|
784
|
+
return message
|
|
662
785
|
}
|
|
663
786
|
|
|
664
787
|
/**
|
|
@@ -987,6 +1110,10 @@ function rollUnreleased(text, version, date) {
|
|
|
987
1110
|
|
|
988
1111
|
const readJson = (path) => JSON.parse(readFileSync(path, 'utf8'))
|
|
989
1112
|
|
|
1113
|
+
/** The project's release.config.json, or {} when it has none. */
|
|
1114
|
+
const readUserConfig = () =>
|
|
1115
|
+
existsSync('release.config.json') ? readJson('release.config.json') : {}
|
|
1116
|
+
|
|
990
1117
|
/**
|
|
991
1118
|
* Where a project keeps its version. The format is inferred from the file name, so the
|
|
992
1119
|
* common cases need nothing but a path:
|
|
@@ -1180,6 +1307,69 @@ if (flag('--sync')) {
|
|
|
1180
1307
|
process.exit(0)
|
|
1181
1308
|
}
|
|
1182
1309
|
|
|
1310
|
+
// lint-commits checks subjects and exits; like --sync it starts no release. It is handled
|
|
1311
|
+
// before the positional parsing below because it takes a range, and a second positional is
|
|
1312
|
+
// otherwise a mistake.
|
|
1313
|
+
if (argv[0] === 'lint-commits') {
|
|
1314
|
+
const rest = argv.slice(1)
|
|
1315
|
+
const subjectAt = rest.indexOf('--subject')
|
|
1316
|
+
const ignored = { ...DEFAULTS, ...readUserConfig() }.ignoreCommits.map(
|
|
1317
|
+
(pattern) => new RegExp(pattern, 'i'),
|
|
1318
|
+
)
|
|
1319
|
+
|
|
1320
|
+
let subjects
|
|
1321
|
+
let scope
|
|
1322
|
+
if (subjectAt !== -1) {
|
|
1323
|
+
// A squash merge takes its subject from the pull request title, which is therefore the
|
|
1324
|
+
// commit this repository will parse — and the one no commit-msg hook ever sees.
|
|
1325
|
+
const text = rest[subjectAt + 1]
|
|
1326
|
+
if (text === undefined) abort('--subject needs the text to check', 'COMMIT LINT FAILED')
|
|
1327
|
+
subjects = [{ hash: '', subject: text.trim() }]
|
|
1328
|
+
scope = null
|
|
1329
|
+
} else {
|
|
1330
|
+
if (!tryRead('git', ['rev-parse', '--show-toplevel'])) abort('not inside a git repository')
|
|
1331
|
+
const lastTag = tryRead('git', ['describe', '--tags', '--abbrev=0'])
|
|
1332
|
+
const range =
|
|
1333
|
+
rest.find((arg) => !arg.startsWith('-')) ?? (lastTag ? `${lastTag}..HEAD` : 'HEAD')
|
|
1334
|
+
// Merges carry no prose of their own, and %s is enough: nothing here reads the body.
|
|
1335
|
+
const raw = tryRead('git', ['log', '--no-merges', '--format=%h%x1f%s', range])
|
|
1336
|
+
if (raw === null) {
|
|
1337
|
+
abort(
|
|
1338
|
+
`\`git log ${range}\` failed — is that a range in this repository?`,
|
|
1339
|
+
'COMMIT LINT FAILED',
|
|
1340
|
+
)
|
|
1341
|
+
}
|
|
1342
|
+
subjects = raw
|
|
1343
|
+
.split('\n')
|
|
1344
|
+
.filter(Boolean)
|
|
1345
|
+
.map((line) => {
|
|
1346
|
+
const [hash, subject = ''] = line.split('\u001F')
|
|
1347
|
+
return { hash: hash.trim(), subject: subject.trim() }
|
|
1348
|
+
})
|
|
1349
|
+
// The bookkeeping `commitsSinceLastTag` drops for the same reason: a release commit,
|
|
1350
|
+
// a merge or an autosquash marker is nobody's prose to fix.
|
|
1351
|
+
.filter(({ subject }) => subject && !ignored.some((re) => re.test(subject)))
|
|
1352
|
+
scope = range
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
const findings = lintSubjects(subjects)
|
|
1356
|
+
for (const { hash, subject, level, reason } of findings) {
|
|
1357
|
+
const label = level === 'error' ? red('error') : yellow('warn')
|
|
1358
|
+
console.log(` ${label} ${hash ? `${dim(hash)} ` : ''}${subject}\n ${dim(reason)}`)
|
|
1359
|
+
}
|
|
1360
|
+
const errors = findings.filter((f) => f.level === 'error').length
|
|
1361
|
+
if (errors) {
|
|
1362
|
+
abort(
|
|
1363
|
+
`${errors} subject${errors === 1 ? '' : 's'} the changelog and the version bump cannot read`,
|
|
1364
|
+
'COMMIT LINT FAILED',
|
|
1365
|
+
)
|
|
1366
|
+
}
|
|
1367
|
+
const counted = `${subjects.length} subject${subjects.length === 1 ? '' : 's'}`
|
|
1368
|
+
const unfiled = findings.length ? `, ${findings.length} unfiled` : ''
|
|
1369
|
+
ok(`${scope ? `${scope}: ` : ''}${counted} valid${unfiled}`)
|
|
1370
|
+
process.exit(0)
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1183
1373
|
/**
|
|
1184
1374
|
* Options that consume the argument after them. Without this list a positional target is
|
|
1185
1375
|
* found by guessing, and `--only tag,push` gets read as the version to release.
|
|
@@ -1240,7 +1430,7 @@ if (existsSync(localManifest) && localManifest !== rootManifest) {
|
|
|
1240
1430
|
}
|
|
1241
1431
|
process.chdir(root)
|
|
1242
1432
|
|
|
1243
|
-
const userConfig =
|
|
1433
|
+
const userConfig = readUserConfig()
|
|
1244
1434
|
const config = { ...DEFAULTS, ...userConfig }
|
|
1245
1435
|
const unknownKeys = Object.keys(config).filter((key) => !(key in DEFAULTS))
|
|
1246
1436
|
if (unknownKeys.length) abort(`release.config.json has unknown keys: ${unknownKeys.join(', ')}`)
|
|
@@ -1573,10 +1763,16 @@ if (bumping && compareVersions(version, currentVersion) <= 0) {
|
|
|
1573
1763
|
|
|
1574
1764
|
const dirty = tryRead('git', ['status', '--porcelain'])
|
|
1575
1765
|
if (dirty === null) fail('could not read git status')
|
|
1576
|
-
else if (dirty && runs('commit')
|
|
1766
|
+
else if (dirty && runs('commit')) {
|
|
1577
1767
|
const entries = dirty.split('\n')
|
|
1578
1768
|
ok(`working tree has ${entries.length} change(s) — will be committed first`)
|
|
1579
1769
|
console.log(dim(indent(formatStatus(dirty))))
|
|
1770
|
+
if (!assistant) {
|
|
1771
|
+
note(
|
|
1772
|
+
'no assistant configured: the commit message will name the files — ' +
|
|
1773
|
+
'`--assistant auto` drafts a real one',
|
|
1774
|
+
)
|
|
1775
|
+
}
|
|
1580
1776
|
// One commit gets one subject. A change set spanning several top-level directories is
|
|
1581
1777
|
// usually several pieces of work, and no honest Conventional Commits subject covers it.
|
|
1582
1778
|
const areas = new Set(
|
|
@@ -1592,12 +1788,6 @@ else if (dirty && runs('commit') && assistant) {
|
|
|
1592
1788
|
'releasing with --skip commit.',
|
|
1593
1789
|
)
|
|
1594
1790
|
}
|
|
1595
|
-
} else if (dirty && runs('commit')) {
|
|
1596
|
-
fail(
|
|
1597
|
-
`working tree is not clean:\n${indent(formatStatus(dirty))}\n` +
|
|
1598
|
-
' Configure a drafting assistant (`--assistant auto`, or "assistant" in ' +
|
|
1599
|
-
'release.config.json) to have these committed automatically, or commit them yourself.',
|
|
1600
|
-
)
|
|
1601
1791
|
} else if (dirty) {
|
|
1602
1792
|
fail(`working tree is not clean:\n${indent(formatStatus(dirty))}`)
|
|
1603
1793
|
} else ok('working tree clean')
|
|
@@ -1627,9 +1817,9 @@ else if (detached) {
|
|
|
1627
1817
|
fail(`on '${branch}', expected '${config.branch}'`)
|
|
1628
1818
|
} else ok(`on ${branch}`)
|
|
1629
1819
|
|
|
1630
|
-
// A shallow clone (CI checkouts default to depth 1)
|
|
1631
|
-
//
|
|
1632
|
-
//
|
|
1820
|
+
// A shallow clone (CI checkouts default to depth 1) is only a problem when it truncates
|
|
1821
|
+
// the history the release actually reads. Whether it does is checked after the fetch
|
|
1822
|
+
// below, where the answer is most accurate.
|
|
1633
1823
|
const shallow = tryRead('git', ['rev-parse', '--is-shallow-repository']) === 'true'
|
|
1634
1824
|
|
|
1635
1825
|
if (!succeeds('git', ['remote', 'get-url', config.remote])) {
|
|
@@ -1652,6 +1842,50 @@ if (!succeeds('git', ['remote', 'get-url', config.remote])) {
|
|
|
1652
1842
|
}
|
|
1653
1843
|
}
|
|
1654
1844
|
|
|
1845
|
+
// If the previous release tag is reachable from HEAD, a shallow clone hides nothing the
|
|
1846
|
+
// release reads — notes and `auto` see the whole span. No reachable tag means the history
|
|
1847
|
+
// is provably truncated: `auto` would infer the bump from a fraction of the commits, so
|
|
1848
|
+
// that is a failure; commit-derived notes merely come out partial, so that is a warning.
|
|
1849
|
+
let shallowHidesHistory = false
|
|
1850
|
+
if (shallow) {
|
|
1851
|
+
const reachableTag = tryRead('git', ['describe', '--tags', '--abbrev=0'])
|
|
1852
|
+
shallowHidesHistory = !reachableTag
|
|
1853
|
+
if (reachableTag) {
|
|
1854
|
+
ok(
|
|
1855
|
+
`shallow clone, but history back to ${reachableTag} is visible — notes and auto are complete`,
|
|
1856
|
+
)
|
|
1857
|
+
} else if (autoBump) {
|
|
1858
|
+
fail(
|
|
1859
|
+
'shallow clone hides the history `auto` infers the bump from — no previous tag is ' +
|
|
1860
|
+
'reachable.\n Fetch full history: fetch-depth: 0 in CI, or git fetch --unshallow.',
|
|
1861
|
+
)
|
|
1862
|
+
} else {
|
|
1863
|
+
warn(
|
|
1864
|
+
'shallow clone: no previous tag is reachable, so notes drafted from commits will ' +
|
|
1865
|
+
'describe only the visible history. Fetch full history (fetch-depth: 0, or ' +
|
|
1866
|
+
'git fetch --unshallow).',
|
|
1867
|
+
)
|
|
1868
|
+
}
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1871
|
+
// The registry's "Repository" link comes from the manifest, not from git — a mismatch
|
|
1872
|
+
// ships a broken link with every publish, and npm only warns after the fact.
|
|
1873
|
+
if (existsSync('package.json')) {
|
|
1874
|
+
const repoField = readJson('package.json').repository
|
|
1875
|
+
const declared = typeof repoField === 'string' ? repoField : repoField?.url
|
|
1876
|
+
const remoteUrl = tryRead('git', ['remote', 'get-url', config.remote])
|
|
1877
|
+
if (declared && remoteUrl && /:\/\/|@/.test(declared)) {
|
|
1878
|
+
if (normalizeRepoUrl(declared) !== normalizeRepoUrl(remoteUrl)) {
|
|
1879
|
+
warn(
|
|
1880
|
+
`package.json repository is ${declared}, but ${config.remote} is ${remoteUrl} — ` +
|
|
1881
|
+
'the registry will link the wrong repository',
|
|
1882
|
+
)
|
|
1883
|
+
} else if (!/^git\+.*\.git$/.test(declared)) {
|
|
1884
|
+
note(`npm normalizes repository.url on publish — \`npm pkg fix\` writes that form`)
|
|
1885
|
+
}
|
|
1886
|
+
}
|
|
1887
|
+
}
|
|
1888
|
+
|
|
1655
1889
|
// Signing is configured per repository and inherited, never managed here — git already
|
|
1656
1890
|
// owns that. But a signing setup that cannot produce a signature fails at the commit step,
|
|
1657
1891
|
// after the version has been written, so it is worth catching before anything mutates.
|
|
@@ -1791,7 +2025,7 @@ let draftedNotes = null
|
|
|
1791
2025
|
* True when --commit still has to create a commit. Notes drafted before that commit would
|
|
1792
2026
|
* describe an incomplete release, so drafting waits until the working tree is committed.
|
|
1793
2027
|
*/
|
|
1794
|
-
const notesDeferred = !!(dirty && runs('commit')
|
|
2028
|
+
const notesDeferred = !!(dirty && runs('commit'))
|
|
1795
2029
|
|
|
1796
2030
|
/**
|
|
1797
2031
|
* Notes for a version, in descending order of how much they can be trusted:
|
|
@@ -1815,7 +2049,7 @@ function draftNotesFor(v) {
|
|
|
1815
2049
|
// An explicitly named source wins over the assistant being merely available.
|
|
1816
2050
|
if (!assistant || notesSource === 'commits')
|
|
1817
2051
|
return changelogFromCommits(commits, remoteLinks(config.remote), config.hiddenTypes)
|
|
1818
|
-
if (
|
|
2052
|
+
if (shallowHidesHistory) {
|
|
1819
2053
|
warn(
|
|
1820
2054
|
`shallow clone: only ${subjects.length} commit(s) are visible, so the notes will ` +
|
|
1821
2055
|
'describe part of the release. Check out with full history (fetch-depth: 0).',
|
|
@@ -1857,7 +2091,7 @@ if (notesSource === 'github') {
|
|
|
1857
2091
|
// Generate, either because nothing was written or because a source was named.
|
|
1858
2092
|
if (!notes) {
|
|
1859
2093
|
if (notesDeferred) {
|
|
1860
|
-
ok(
|
|
2094
|
+
ok(`release notes will be ${assistant ? 'drafted' : 'generated'} after the commit`)
|
|
1861
2095
|
} else {
|
|
1862
2096
|
draftedNotes = draftNotesFor(version)
|
|
1863
2097
|
if (draftedNotes) {
|
|
@@ -1895,6 +2129,19 @@ if (taggedCommit && runs('tag') && (bumping || rolledChangelog)) {
|
|
|
1895
2129
|
)
|
|
1896
2130
|
}
|
|
1897
2131
|
|
|
2132
|
+
// The project's own gate, run while nothing has mutated. Without this, a prepublishOnly
|
|
2133
|
+
// hook is the gate — and it fails at the publish step, after the commit, tag and push.
|
|
2134
|
+
if (config.verify) {
|
|
2135
|
+
note(`running verify: ${config.verify}`)
|
|
2136
|
+
try {
|
|
2137
|
+
execSync(config.verify, { stdio: 'pipe', encoding: 'utf8' })
|
|
2138
|
+
ok(`verify passed: ${config.verify}`)
|
|
2139
|
+
} catch (err) {
|
|
2140
|
+
const tail = `${err.stdout ?? ''}${err.stderr ?? ''}`.trim().split('\n').slice(-12).join('\n')
|
|
2141
|
+
fail(`verify failed: ${config.verify}\n${indent(tail)}`)
|
|
2142
|
+
}
|
|
2143
|
+
}
|
|
2144
|
+
|
|
1898
2145
|
if (problems.length) {
|
|
1899
2146
|
const summary = `${problems.length} preflight check(s) failed:\n - ${problems.join('\n - ')}`
|
|
1900
2147
|
if (!dryRun) abort(summary)
|
|
@@ -1919,13 +2166,16 @@ if (dirty && runs('commit') && !dryRun) {
|
|
|
1919
2166
|
step('Stage the working tree')
|
|
1920
2167
|
mutate('git', ['add', '--all'])
|
|
1921
2168
|
didStage = true
|
|
1922
|
-
commitMessage = draftCommitMessage()
|
|
2169
|
+
commitMessage = assistant ? draftCommitMessage() : null
|
|
1923
2170
|
if (!commitMessage) {
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
2171
|
+
// No assistant, or its draft was unusable: never block on a text generator. The
|
|
2172
|
+
// deterministic floor is a chore commit that names what it touches.
|
|
2173
|
+
if (assistant) {
|
|
2174
|
+
note(`${assistantName} produced no usable message — falling back to a generated one`)
|
|
2175
|
+
}
|
|
2176
|
+
const files =
|
|
2177
|
+
tryRead('git', ['diff', '--cached', '--name-only'])?.split('\n').filter(Boolean) ?? []
|
|
2178
|
+
commitMessage = fallbackCommitMessage(files)
|
|
1929
2179
|
}
|
|
1930
2180
|
console.log(indent(commitMessage))
|
|
1931
2181
|
}
|