@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.
Files changed (3) hide show
  1. package/README.md +83 -42
  2. package/package.json +1 -1
  3. 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 with a drafted message ([assistant](#-assistant-optional) required) |
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` is a conditional default: it no-ops on a clean tree, and on a dirty tree it
208
- proceeds only when a drafting [assistant](#-assistant-optional) is configured — without
209
- one, preflight still refuses the unclean tree (with a hint), exactly as before. So
210
- `release-kit auto --assistant auto` releases a dirty tree end to end: stage, drafted
211
- commit, then the rest of the pipeline. Opt out with `--skip commit` or a `steps` config.
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
- - A shallow clone is reported, since it truncates the history notes come from _(warning)_
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
- | `assistant` | `null` | Drafting CLI: a name, `"auto"`, or `{ tool, model, effort }` |
435
- | `commitMessage` | `"chore(release): %t"` | Release commit subject |
436
- | `releaseTitle` | `"%t"` | GitHub release title |
437
- | `assets` | `[]` | Files attached to the GitHub release |
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
- [`amannn/action-semantic-pull-request`](https://github.com/amannn/action-semantic-pull-request)
473
- enforces it. Without something like it, work silently goes missing from release notes —
474
- release-kit says how many commits are not Conventional Commits, but it cannot fix them
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
- - **A dirty working tree is committed instead of refusing to release.** The tree is
594
- staged, a Conventional Commits message is drafted for the staged diff, and the commit is
595
- made — by default, whenever an assistant is configured (`--skip commit` opts out). The subject is validated
596
- against the Conventional Commits grammar; an answer that does not parse is rejected rather
597
- than committed. Attribution lines (`Co-Authored-By`, `Generated with`) are stripped, so
598
- the tool never signs your commits.
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.7.0",
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('RELEASE ABORTED'))} — ${message}\n`)
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
- const CONVENTIONAL_TYPES = 'build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test'
628
- const CONVENTIONAL_RE = new RegExp(`^(${CONVENTIONAL_TYPES})(\\([^)]+\\))?!?: .+`)
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 ${CONVENTIONAL_TYPES.split('|').join(', ')}.`,
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
- return CONVENTIONAL_RE.test(subject) ? message : null
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 = existsSync('release.config.json') ? readJson('release.config.json') : {}
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') && assistant) {
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) hides the history that release notes
1631
- // and the last-tag lookup are derived from. It still releases correctly; the notes just
1632
- // silently describe a fraction of the work, so say so before that happens.
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') && assistant)
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 (shallow) {
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('release notes will be drafted after the commit')
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
- mutate('git', ['reset', '--quiet'])
1925
- abort(
1926
- `${assistantName} could not draft a Conventional Commits message for these changes.\n\n` +
1927
- ' Commit them yourself and re-run, or release with --skip commit.',
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
  }