@oorabona/release-it-preset 0.14.0 → 1.0.0-rc.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 CHANGED
@@ -2,18 +2,49 @@
2
2
 
3
3
  Shared [release-it](https://github.com/release-it/release-it) configuration and scripts for automated versioning, changelog generation, and package publishing.
4
4
 
5
- [![codecov](https://codecov.io/github/oorabona/release-it-preset/graph/badge.svg?token=6RMN34Z7TX)](https://codecov.io/github/oorabona/release-it-preset)
5
+ [![NPM Version](https://img.shields.io/npm/v/@oorabona/release-it-preset.svg)](https://npmjs.org/package/@oorabona/release-it-preset)
6
+ [![NPM Downloads](https://img.shields.io/npm/dm/@oorabona/release-it-preset.svg)](https://npmjs.org/package/@oorabona/release-it-preset)
7
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
8
+ [![Node](https://img.shields.io/node/v/@oorabona/release-it-preset.svg)](https://nodejs.org/)
9
+ [![OIDC trusted publishing](https://img.shields.io/badge/npm-OIDC%20trusted%20publishing-green.svg)](https://docs.npmjs.com/trusted-publishers)
6
10
  [![CI](https://github.com/oorabona/release-it-preset/actions/workflows/ci.yml/badge.svg)](https://github.com/oorabona/release-it-preset/actions/workflows/ci.yml)
7
11
  [![Audit](https://github.com/oorabona/release-it-preset/actions/workflows/audit.yml/badge.svg)](https://github.com/oorabona/release-it-preset/actions/workflows/audit.yml)
8
- [![NPM Version](https://img.shields.io/npm/v/release-it-preset.svg)](https://npmjs.org/package/@oorabona/release-it-preset)
9
- [![NPM Downloads](https://img.shields.io/npm/dm/release-it-preset.svg)](https://npmjs.org/package/@oorabona/release-it-preset)
12
+ [![codecov](https://codecov.io/github/oorabona/release-it-preset/graph/badge.svg?token=6RMN34Z7TX)](https://codecov.io/github/oorabona/release-it-preset)
10
13
  [![TypeScript](https://img.shields.io/badge/TypeScript-5.3+-blue.svg)](https://www.typescriptlang.org/)
11
- [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
14
+
15
+ ## Why this preset?
16
+
17
+ Most release workflows fall into one of three traps: too much manual work (plain release-it, you assemble everything), too much ceremony (changesets, great for 5+ maintainers, heavy for one), or too much automation with too little control (semantic-release, hands-off by design and format).
18
+
19
+ `@oorabona/release-it-preset` occupies the productive middle ground for solo and small-team JavaScript package maintainers who want:
20
+
21
+ - **Human-readable changelogs.** Keep a Changelog format (Added/Fixed/Changed/Removed/Security) generated automatically from conventional commits — no manual entry writing, no machine-format diffs.
22
+ - **OIDC publishing without CI plumbing.** Import the reusable `publish.yml` workflow in three lines. OIDC trusted publishing with npm provenance ships on day one, no `NPM_TOKEN` secret required.
23
+ - **Diagnostic confidence before release.** Run `release-it-preset doctor` to surface every misconfiguration — git auth, npm auth, changelog hygiene, branch requirements — before anything breaks in CI.
24
+ - **Recovery presets for the real world.** Dedicated `republish` and `retry-publish` configs handle the scenarios other tools pretend don't happen.
25
+
26
+ **Pick this preset** if you maintain one or a few npm packages, write Keep a Changelog, deploy from GitHub Actions, and want pre-built OIDC publishing without adopting changesets or semantic-release's philosophy.
27
+
28
+ **Do not pick this preset** if you have a large monorepo with cross-package dependency management needs (use [changesets](https://github.com/changesets/changesets)) or if you want zero human involvement in versioning decisions (use [semantic-release](https://github.com/semantic-release/semantic-release)).
29
+
30
+ ## Ecosystem positioning
31
+
32
+ | Tool | Strength | When to prefer it |
33
+ |---|---|---|
34
+ | **`@oorabona/release-it-preset`** (this) | Keep a Changelog discipline + OIDC workflows + `doctor` CLI + recovery presets | Solo / small-team JS maintainer, human-curated changelogs, GitHub Actions CI |
35
+ | [release-it](https://github.com/release-it/release-it) (plain) | Maximum flexibility, smallest opinion footprint | You want to assemble each piece yourself |
36
+ | [changesets](https://github.com/changesets/changesets) | PR-driven versioning, fixed/linked package versions | 5+ maintainer monorepo, every change deserves explicit intent |
37
+ | [semantic-release](https://github.com/semantic-release/semantic-release) | Fully-automated, zero human intervention | Branch-driven release pipelines, no human review of changelogs |
38
+ | [release-please](https://github.com/googleapis/release-please) | GitHub Release PR pattern, 20+ language strategies | Polyglot repos, GitHub-native PR-driven workflow |
39
+ | [`@release-it-plugins/workspaces`](https://github.com/release-it-plugins/workspaces) | Multi-package iteration + cross-pkg dep sync | Monorepo with bulk publish — composes with this preset (see [Composing with `@release-it-plugins/workspaces`](#composing-with-release-it-pluginsworkspaces)) |
12
40
 
13
41
  ## Table of Contents
14
42
 
43
+ - [Why this preset?](#why-this-preset)
44
+ - [Ecosystem positioning](#ecosystem-positioning)
15
45
  - [Features](#features)
16
46
  - [Installation](#installation)
47
+ - [Install patterns](#install-patterns)
17
48
  - [Quick Start](#quick-start)
18
49
  - [Available Configurations](#available-configurations)
19
50
  - [CLI Usage](#cli-usage)
@@ -21,6 +52,7 @@ Shared [release-it](https://github.com/release-it/release-it) configuration and
21
52
  - [Preset Selection Mode](#preset-selection-mode)
22
53
  - [Passthrough Mode (Custom Config Override)](#passthrough-mode-custom-config-override)
23
54
  - [Monorepo Support](#monorepo-support)
55
+ - [Composing with `@release-it-plugins/workspaces`](#composing-with-release-it-pluginsworkspaces)
24
56
  - [Utility Commands](#utility-commands)
25
57
  - [Scripts](#scripts)
26
58
  - [Environment Variables](#environment-variables)
@@ -30,8 +62,11 @@ Shared [release-it](https://github.com/release-it/release-it) configuration and
30
62
  - [GitHub Actions Workflows](#github-actions-workflows)
31
63
  - [Reusable Workflows](#reusable-workflows)
32
64
  - [Workflow Reference](#workflow-reference)
65
+ - [Exit codes](#exit-codes)
33
66
  - [Best Practices](#best-practices)
34
67
  - [Troubleshooting](#troubleshooting)
68
+ - [Public API](#public-api)
69
+ - [Contributing](#contributing)
35
70
 
36
71
  ## Features
37
72
 
@@ -53,6 +88,20 @@ Shared [release-it](https://github.com/release-it/release-it) configuration and
53
88
  pnpm add -D @oorabona/release-it-preset release-it
54
89
  ```
55
90
 
91
+ ### Install patterns
92
+
93
+ | Use case | Command | Notes |
94
+ |---|---|---|
95
+ | **Try without installing** | `pnpm dlx @oorabona/release-it-preset doctor` | Fetch + run, no install. Use for evaluating the preset on an existing repo. |
96
+ | **One-shot npx** | `npx -y @oorabona/release-it-preset doctor` | Same idea, npm-flavored |
97
+ | **Adopt as devDep** (recommended) | `pnpm add -D @oorabona/release-it-preset release-it` | Pins via lockfile, idiomatic for projects |
98
+ | **CI usage** | `pnpm install --frozen-lockfile && pnpm exec release-it-preset retry-publish --ci` | Lockfile-deterministic, no prompts |
99
+ | **Diagnostic on any repo** | `pnpm dlx @oorabona/release-it-preset doctor` | Works against the cwd's git/package.json/CHANGELOG; great for quick health checks |
100
+
101
+ **Global install is not recommended** — pin per-project for reproducibility. The preset is small (<20KB unpacked); CI overhead is negligible.
102
+
103
+ The peer requirement is `release-it ^19.0.0 || ^20.0.0`. CI runs against the upper bound (v20.x) on every commit; v19 was smoke-tested manually before the constraint was widened. v20 is recommended for the OIDC trusted publishing handshake (npm ≥ 11.5.1, Node ≥ 24); v19 is supported for composing with [`@release-it-plugins/workspaces`](#composing-with-release-it-pluginsworkspaces) (its peer maxes at v19 today).
104
+
56
105
  ## Quick Start
57
106
 
58
107
  ### Option 1: Using the CLI (Recommended)
@@ -476,7 +525,37 @@ pnpm release-it-preset --config .release-it-manual.json
476
525
  - Multiple validation layers prevent abuse
477
526
  - No privilege escalation in CLI tool context
478
527
 
479
- See [examples/monorepo-workflow.md](examples/monorepo-workflow.md) for complete monorepo guide.
528
+ See [examples/monorepo-workflow.md](examples/monorepo-workflow.md) for complete monorepo guide and [examples/monorepo/](examples/monorepo/) for a runnable workspace demo.
529
+
530
+ ### Composing with `@release-it-plugins/workspaces`
531
+
532
+ This preset focuses on a single package per release-it run. If your monorepo needs **bulk publish** (iterate over every workspace package + sync cross-package dependency versions), compose this preset with [`@release-it-plugins/workspaces`](https://github.com/release-it-plugins/workspaces) — the canonical release-it plugin for that workflow.
533
+
534
+ ```bash
535
+ # Install both
536
+ pnpm add -D @oorabona/release-it-preset @release-it-plugins/workspaces release-it@^19
537
+ ```
538
+
539
+ ```jsonc
540
+ // .release-it.json — extends our preset AND loads the workspaces plugin
541
+ {
542
+ "extends": "@oorabona/release-it-preset/config/default",
543
+ "plugins": {
544
+ "@release-it-plugins/workspaces": true
545
+ }
546
+ }
547
+ ```
548
+
549
+ **Peer compatibility note:** `@release-it-plugins/workspaces` v5.0.3 declares peer `release-it ^17 || ^18 || ^19`. Our preset declares peer `^19 || ^20`. The intersection is `^19`, so when composing with the workspaces plugin you must pin release-it to v19. v20 standalone (without the workspaces plugin) is fully supported and recommended for new projects.
550
+
551
+ `release-it-preset doctor` does **not** check whether the workspaces plugin is loaded — it's an opt-in composition. Run it manually after install if you want to verify the plugin's own preflight checks.
552
+
553
+ When this composition is right for you:
554
+ - You release multiple packages from one repo with **synchronized versions** (all bumped together).
555
+ - You want **cross-package dependency sync** (when `pkg-a` bumps to 2.0, `pkg-b`'s reference auto-updates).
556
+
557
+ When our preset alone is enough:
558
+ - **Independent versioning** per package (each package releases when ready). Use `GIT_CHANGELOG_PATH=packages/<pkg>` to scope the CHANGELOG. See [examples/monorepo/](examples/monorepo/) for the runnable demo.
480
559
 
481
560
  ### Utility Commands
482
561
 
@@ -556,6 +635,45 @@ pnpm release-it-preset check
556
635
 
557
636
  Useful for debugging release issues.
558
637
 
638
+ #### `doctor` - Release Readiness Diagnostic
639
+
640
+ Runs a structured checklist across four categories and outputs a readiness score:
641
+
642
+ ```bash
643
+ pnpm release-it-preset doctor
644
+ pnpm release-it-preset doctor --json
645
+ ```
646
+
647
+ **What it checks:**
648
+
649
+ | Category | Checks |
650
+ |----------|--------|
651
+ | Environment | Known env vars, source (env / default / unset), publish-mode consistency |
652
+ | Repository | Git repo presence, branch vs `GIT_REQUIRE_BRANCH`, latest tag, commit count, dirty WD, upstream tracking, remote URL |
653
+ | Configuration | `CHANGELOG.md` exists + Keep a Changelog format + `[Unreleased]` content, `.release-it.json` parseable + `extends` field, `package.json` valid semver version |
654
+ | Readiness Summary | `PASS`/`WARN`/`FAIL` counts, score `N/M checks passing`, status (`READY`/`WARNINGS`/`BLOCKED`), actionable recommendations |
655
+
656
+ **Exit codes:**
657
+ - `0` — status is `READY` or `WARNINGS`
658
+ - `1` — status is `BLOCKED` (at least one `FAIL`)
659
+
660
+ **`--json` output shape:**
661
+ ```json
662
+ {
663
+ "environment": { "checks": [...], "vars": [...], "status": "PASS" },
664
+ "repository": { "checks": [...], "status": "WARN" },
665
+ "configuration": { "checks": [...], "status": "PASS" },
666
+ "summary": {
667
+ "pass": 10, "warn": 2, "fail": 0, "total": 12,
668
+ "score": "10/12 checks passing",
669
+ "status": "WARNINGS",
670
+ "recommendations": ["Review 2 warning(s) before releasing"]
671
+ }
672
+ }
673
+ ```
674
+
675
+ Use `doctor` as a pre-release sanity check, and `check` for the full verbose configuration dump.
676
+
559
677
  #### `check-pr` - Pull Request Hygiene
560
678
 
561
679
  Evaluates PR readiness by analysing commits and changelog changes. Designed for CI usage but safe locally when the required environment variables are set (`PR_BASE_REF`, `PR_HEAD_REF`).
@@ -662,6 +780,30 @@ Customize behavior with environment variables:
662
780
  - `CHANGELOG_FILE` - Changelog file path (default: `CHANGELOG.md`)
663
781
  - `GIT_CHANGELOG_PATH` - Optional. When set to a repository-relative path (e.g. `packages/tar-xz`), restrict changelog generation to commits touching that path. Useful for monorepo per-package CHANGELOG files. Empty / unset = repository-wide (default).
664
782
  - `GIT_CHANGELOG_SINCE` - Optional. Override the `since` baseline for changelog generation (any git ref: SHA, tag, branch). When set, bypasses both the per-package release-commit detection and the `git describe --tags` fallback. Useful for monorepo workspaces with non-standard release commit patterns. Empty / unset = use auto-detection.
783
+ - `CHANGELOG_TYPE_MAP` - Optional. JSON string mapping commit types to CHANGELOG section headings. Merged on top of `.changelog-types.json` (if present) and the built-in defaults. Use `false` as a value to suppress a type entirely. Example: `CHANGELOG_TYPE_MAP='{"ops":"### Operations","deps":"### Dependencies"}'`.
784
+
785
+ ### Custom type map (`.changelog-types.json`)
786
+
787
+ Create a `.changelog-types.json` file in your project root to override or extend the built-in commit-type → section mapping at the project level. The file is merged on top of the built-in defaults; individual keys can be overridden without touching the rest.
788
+
789
+ **Resolution order** (highest priority wins):
790
+ 1. `CHANGELOG_TYPE_MAP` env var (runtime override, e.g. in CI)
791
+ 2. `.changelog-types.json` project file
792
+ 3. Built-in defaults
793
+
794
+ **Example `.changelog-types.json`:**
795
+ ```json
796
+ {
797
+ "deps": "### Dependencies",
798
+ "ops": "### Operations",
799
+ "ci": false
800
+ }
801
+ ```
802
+ - String values must be a valid `### Section Heading`.
803
+ - `false` suppresses the type (no CHANGELOG entry emitted).
804
+ - Malformed JSON or invalid values → warning logged, layer ignored, lower-priority map used.
805
+
806
+ **BREAKING CHANGE: footer parsing** (Conventional Commits 1.0.0 §6): `BREAKING CHANGE:` is recognised as a footer only when it appears after a blank-line separator from the preceding paragraph. Mid-body occurrences without the blank line do not promote the commit to breaking. Multiple `BREAKING CHANGE:` lines in the same footer paragraph each emit a separate entry under `### ⚠️ BREAKING CHANGES`.
665
807
 
666
808
  ### Git
667
809
  - `GIT_COMMIT_MESSAGE` - Commit message template (default: `release: bump v${version}`)
@@ -1470,11 +1612,30 @@ Common issues:
1470
1612
 
1471
1613
  This can appear if you interrupt a release, tweak `CHANGELOG.md`, then retry with the same version. The preset automatically passes `--allow-same-version` to `npm version`, so simply re-run `pnpm release` (or `pnpm release-it-preset default --retry`) and select the same version—`npm` will no longer abort.
1472
1614
 
1615
+ ## Exit codes
1616
+
1617
+ The CLI follows this convention (stable from v1.0.0 onward):
1618
+
1619
+ | Code | Meaning | Examples |
1620
+ |---|---|---|
1621
+ | `0` | Success | Command completed; for `doctor`, `READY` or `WARNINGS` status |
1622
+ | `1` | General failure | Unhandled error, validation failure, `doctor` `BLOCKED` status |
1623
+ | `2` | Precondition failure (CI-friendly) | `validate` reports CHANGELOG missing or `[Unreleased]` empty |
1624
+ | `3..9` | **Reserved** | Not currently emitted; reserved for future contract additions |
1625
+
1626
+ In CI scripts, distinguish `exit 1` (try-again-friendly) from `exit 2` (precondition not met — require operator action) when chaining commands.
1627
+
1628
+ ## Public API
1629
+
1630
+ The full **stable surface** (CLI commands, environment variables, config exports, GHA workflow inputs, exit codes) is documented in [`docs/PUBLIC_API.md`](docs/PUBLIC_API.md). Items not listed there are internal and may change in any version.
1631
+
1473
1632
  ## License
1474
1633
 
1475
- MIT
1634
+ MIT — see [`LICENSE`](LICENSE).
1476
1635
 
1477
1636
  ## Contributing
1478
1637
 
1479
- Contributions are welcome! Please open an issue or pull request.
1638
+ PRs and issues welcome. Please read [`CONTRIBUTING.md`](CONTRIBUTING.md) before opening a pull request — it covers the Conventional Commits requirement, branch prefixes, the pre-PR checklist, and the testing conventions. By participating you agree to abide by the [Code of Conduct](CODE_OF_CONDUCT.md) (Contributor Covenant 2.1).
1639
+
1640
+ For security concerns, please email `olivier.orabona@gmail.com` directly rather than opening a public issue. See [`SECURITY.md`](SECURITY.md) for the disclosure policy.
1480
1641
 
package/bin/cli.js CHANGED
@@ -48,6 +48,7 @@ const UTILITY_COMMANDS = {
48
48
  update: 'populate-unreleased-changelog',
49
49
  validate: 'validate-release',
50
50
  check: 'check-config',
51
+ doctor: 'doctor',
51
52
  'check-pr': 'check-pr-status',
52
53
  'retry-publish-preflight': 'retry-publish',
53
54
  };
@@ -77,6 +78,7 @@ Utility Commands:
77
78
  update Update [Unreleased] section from commits
78
79
  validate [--allow-dirty] Validate project is ready for release
79
80
  check Display configuration and project status
81
+ doctor Run diagnostic checklist and show readiness score
80
82
  check-pr Evaluate PR hygiene (branch diff, changelog status, conventions)
81
83
  retry-publish-preflight Run retry publish safety checks without executing release
82
84
 
@@ -222,7 +224,7 @@ function handleUtilityCommand(commandName, args) {
222
224
  const compiledPath = join(__dirname, '..', 'dist', 'scripts', `${base}.js`);
223
225
  const sourcePath = join(__dirname, '..', 'scripts', `${base}.ts`);
224
226
 
225
- console.log(`🔧 Running utility command: ${commandName}\n`);
227
+ console.error(`🔧 Running utility command: ${commandName}\n`);
226
228
 
227
229
  // Prefer compiled script; fallback to tsx source if not built yet (developer convenience)
228
230
  import('node:fs').then(fs => {
@@ -230,7 +232,7 @@ function handleUtilityCommand(commandName, args) {
230
232
  const runner = useCompiled ? 'node' : 'tsx';
231
233
  const target = useCompiled ? compiledPath : sourcePath;
232
234
  if (!useCompiled) {
233
- console.log('ℹ️ Compiled script not found, falling back to tsx source execution (dev mode).');
235
+ console.error('ℹ️ Compiled script not found, falling back to tsx source execution (dev mode).');
234
236
  }
235
237
 
236
238
  const child = spawn(runner, [target, ...args], {
@@ -0,0 +1,530 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Doctor — diagnostic checklist + readiness score for release-it-preset
4
+ *
5
+ * Inspects four categories:
6
+ * 1. Environment — all known env vars, source (env vs default)
7
+ * 2. Repository — git state (branch, tag, dirty WD, upstream)
8
+ * 3. Configuration — CHANGELOG.md, .release-it.json, package.json
9
+ * 4. Summary — READY / WARNINGS / BLOCKED + score
10
+ *
11
+ * Usage:
12
+ * node dist/scripts/doctor.js
13
+ * node dist/scripts/doctor.js --json
14
+ */
15
+ import { execSync } from 'node:child_process';
16
+ import { existsSync, readFileSync } from 'node:fs';
17
+ import { isValidSemver } from './lib/semver-utils.js';
18
+ // ---------------------------------------------------------------------------
19
+ // Helpers
20
+ // ---------------------------------------------------------------------------
21
+ export function safeExec(command, deps) {
22
+ try {
23
+ return deps.execSync(command, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
24
+ }
25
+ catch {
26
+ return null;
27
+ }
28
+ }
29
+ function worstStatus(statuses) {
30
+ if (statuses.includes('FAIL'))
31
+ return 'FAIL';
32
+ if (statuses.includes('WARN'))
33
+ return 'WARN';
34
+ return 'PASS';
35
+ }
36
+ // ---------------------------------------------------------------------------
37
+ // ENV VAR CATALOG
38
+ // ---------------------------------------------------------------------------
39
+ const ENV_VAR_CATALOG = [
40
+ { name: 'CHANGELOG_FILE', defaultValue: 'CHANGELOG.md' },
41
+ { name: 'GIT_CHANGELOG_PATH' },
42
+ { name: 'GIT_CHANGELOG_SINCE' },
43
+ { name: 'GIT_COMMIT_MESSAGE', defaultValue: 'release: bump v${version}' },
44
+ { name: 'GIT_TAG_NAME', defaultValue: 'v${version}' },
45
+ { name: 'GIT_REQUIRE_BRANCH', defaultValue: 'main' },
46
+ { name: 'GIT_REQUIRE_UPSTREAM', defaultValue: 'false' },
47
+ { name: 'GIT_REQUIRE_CLEAN', defaultValue: 'false' },
48
+ { name: 'GIT_REMOTE', defaultValue: 'origin' },
49
+ { name: 'GIT_CHANGELOG_COMMAND' },
50
+ { name: 'GIT_CHANGELOG_DESCRIBE_COMMAND' },
51
+ { name: 'GITHUB_RELEASE', defaultValue: 'false' },
52
+ { name: 'GITHUB_REPOSITORY' },
53
+ { name: 'NPM_PUBLISH', defaultValue: 'false' },
54
+ { name: 'NPM_SKIP_CHECKS', defaultValue: 'false' },
55
+ { name: 'NPM_ACCESS', defaultValue: 'public' },
56
+ { name: 'NPM_TAG' },
57
+ ];
58
+ // ---------------------------------------------------------------------------
59
+ // 1. Environment
60
+ // ---------------------------------------------------------------------------
61
+ export function collectEnvironment(deps) {
62
+ const vars = ENV_VAR_CATALOG.map(({ name, defaultValue }) => {
63
+ const value = deps.getEnv(name);
64
+ if (value !== undefined) {
65
+ return { name, value, source: 'env' };
66
+ }
67
+ if (defaultValue !== undefined) {
68
+ return { name, value: defaultValue, source: 'default', defaultValue };
69
+ }
70
+ return { name, value: undefined, source: 'unset', defaultValue };
71
+ });
72
+ const checks = [];
73
+ const githubRelease = deps.getEnv('GITHUB_RELEASE');
74
+ const githubRepo = deps.getEnv('GITHUB_REPOSITORY');
75
+ const npmPublish = deps.getEnv('NPM_PUBLISH');
76
+ if (githubRelease === 'true' && !githubRepo) {
77
+ checks.push({
78
+ name: 'GITHUB_REPOSITORY set when GITHUB_RELEASE=true',
79
+ status: 'WARN',
80
+ value: '<unset>',
81
+ detail: 'Set GITHUB_REPOSITORY=owner/repo to enable GitHub releases',
82
+ });
83
+ }
84
+ else {
85
+ checks.push({
86
+ name: 'GitHub release configuration',
87
+ status: 'PASS',
88
+ value: githubRelease === 'true' ? 'enabled' : 'disabled (default)',
89
+ });
90
+ }
91
+ if (npmPublish === 'true') {
92
+ checks.push({
93
+ name: 'npm publish configuration',
94
+ status: 'PASS',
95
+ value: 'enabled (NPM_PUBLISH=true)',
96
+ });
97
+ }
98
+ else {
99
+ checks.push({
100
+ name: 'npm publish configuration',
101
+ status: 'PASS',
102
+ value: 'disabled (default — safe for local runs)',
103
+ });
104
+ }
105
+ return {
106
+ vars,
107
+ checks,
108
+ status: worstStatus(checks.map((c) => c.status)),
109
+ };
110
+ }
111
+ // ---------------------------------------------------------------------------
112
+ // 2. Repository
113
+ // ---------------------------------------------------------------------------
114
+ export function inspectRepository(deps) {
115
+ const checks = [];
116
+ const isGitRepo = safeExec('git rev-parse --git-dir', deps) !== null;
117
+ if (!isGitRepo) {
118
+ checks.push({
119
+ name: 'Git repository',
120
+ status: 'FAIL',
121
+ value: 'not a git repository',
122
+ detail: 'Run doctor from inside a git repository',
123
+ });
124
+ return { checks, status: 'FAIL' };
125
+ }
126
+ checks.push({ name: 'Git repository', status: 'PASS', value: 'yes' });
127
+ const branch = safeExec('git rev-parse --abbrev-ref HEAD', deps);
128
+ const requiredBranch = deps.getEnv('GIT_REQUIRE_BRANCH') ?? 'main';
129
+ if (!branch) {
130
+ checks.push({
131
+ name: 'Current branch',
132
+ status: 'WARN',
133
+ value: 'unknown',
134
+ detail: 'Could not determine current branch',
135
+ });
136
+ }
137
+ else if (requiredBranch && branch !== requiredBranch) {
138
+ checks.push({
139
+ name: 'Current branch',
140
+ status: 'WARN',
141
+ value: branch,
142
+ detail: `GIT_REQUIRE_BRANCH is "${requiredBranch}" — release will fail on this branch`,
143
+ });
144
+ }
145
+ else {
146
+ checks.push({ name: 'Current branch', status: 'PASS', value: branch });
147
+ }
148
+ const latestTag = safeExec('git describe --tags --abbrev=0', deps);
149
+ if (!latestTag) {
150
+ checks.push({
151
+ name: 'Latest tag',
152
+ status: 'WARN',
153
+ value: 'none',
154
+ detail: 'No tags found — first release scenario',
155
+ });
156
+ }
157
+ else {
158
+ checks.push({ name: 'Latest tag', status: 'PASS', value: latestTag });
159
+ }
160
+ let commitCount = 0;
161
+ if (latestTag) {
162
+ const countStr = safeExec(`git rev-list "${latestTag}"..HEAD --count`, deps);
163
+ commitCount = countStr ? parseInt(countStr, 10) : 0;
164
+ }
165
+ else {
166
+ const countStr = safeExec('git rev-list HEAD --count', deps);
167
+ commitCount = countStr ? parseInt(countStr, 10) : 0;
168
+ }
169
+ checks.push({
170
+ name: 'Commits since last tag',
171
+ status: 'PASS',
172
+ value: String(commitCount),
173
+ });
174
+ const dirtyOutput = safeExec('git status --porcelain', deps);
175
+ const isDirty = dirtyOutput !== null && dirtyOutput.length > 0;
176
+ if (isDirty) {
177
+ checks.push({
178
+ name: 'Working directory clean',
179
+ status: 'WARN',
180
+ value: 'dirty',
181
+ detail: 'Uncommitted changes present — release may fail if GIT_REQUIRE_CLEAN=true',
182
+ });
183
+ }
184
+ else {
185
+ checks.push({ name: 'Working directory clean', status: 'PASS', value: 'yes' });
186
+ }
187
+ const upstream = safeExec('git rev-parse --abbrev-ref @{u}', deps);
188
+ if (!upstream) {
189
+ checks.push({
190
+ name: 'Upstream tracking branch',
191
+ status: 'WARN',
192
+ value: 'none',
193
+ detail: 'No upstream set — git push will fail. Run: git push -u origin <branch>',
194
+ });
195
+ }
196
+ else {
197
+ checks.push({ name: 'Upstream tracking branch', status: 'PASS', value: upstream });
198
+ }
199
+ const remote = deps.getEnv('GIT_REMOTE') ?? 'origin';
200
+ const remoteUrl = safeExec(`git config --get remote.${remote}.url`, deps);
201
+ if (!remoteUrl) {
202
+ checks.push({
203
+ name: `Git remote (${remote})`,
204
+ status: 'WARN',
205
+ value: 'not configured',
206
+ detail: `Remote "${remote}" not found. Set GIT_REMOTE or run: git remote add origin <url>`,
207
+ });
208
+ }
209
+ else {
210
+ checks.push({ name: `Git remote (${remote})`, status: 'PASS', value: remoteUrl });
211
+ }
212
+ return { checks, status: worstStatus(checks.map((c) => c.status)) };
213
+ }
214
+ // ---------------------------------------------------------------------------
215
+ // 3. Configuration
216
+ // ---------------------------------------------------------------------------
217
+ // ---------------------------------------------------------------------------
218
+ // Workspace integration helper (used by validateConfiguration)
219
+ // ---------------------------------------------------------------------------
220
+ function detectWorkspaceIntegration(deps) {
221
+ const hasPnpmWorkspace = deps.existsSync('pnpm-workspace.yaml');
222
+ let hasWorkspacesField = false;
223
+ if (!hasPnpmWorkspace && deps.existsSync('package.json')) {
224
+ try {
225
+ const raw = deps.readFileSync('package.json', 'utf8');
226
+ const pkg = JSON.parse(raw);
227
+ const ws = pkg.workspaces;
228
+ hasWorkspacesField =
229
+ Array.isArray(ws) ||
230
+ (typeof ws === 'object' && ws !== null && Array.isArray(ws.packages));
231
+ }
232
+ catch {
233
+ // package.json parse errors are reported by the version check — skip here
234
+ }
235
+ }
236
+ const workspaceSetup = hasPnpmWorkspace || hasWorkspacesField;
237
+ if (!workspaceSetup) {
238
+ return { name: 'Workspace integration', status: 'PASS', value: 'not a monorepo' };
239
+ }
240
+ const pluginInstalled = deps.existsSync('node_modules/@release-it-plugins/workspaces/package.json');
241
+ if (pluginInstalled) {
242
+ return { name: 'Workspace integration', status: 'PASS', value: 'plugin installed' };
243
+ }
244
+ const source = hasPnpmWorkspace ? 'pnpm-workspace.yaml present' : 'package.json workspaces field';
245
+ return {
246
+ name: 'Workspace integration',
247
+ status: 'WARN',
248
+ value: `Workspace setup detected (no plugin loaded): ${source}`,
249
+ detail: [
250
+ 'For multi-package publish + cross-pkg dep sync, run:',
251
+ ' pnpm add -D @release-it-plugins/workspaces',
252
+ 'Then add `"plugins": {"@release-it-plugins/workspaces": true}` to .release-it.json.',
253
+ 'Skip if you only need per-package CHANGELOG (use GIT_CHANGELOG_PATH).',
254
+ ].join('\n'),
255
+ };
256
+ }
257
+ export function validateConfiguration(deps) {
258
+ const checks = [];
259
+ const changelogPath = deps.getEnv('CHANGELOG_FILE') ?? 'CHANGELOG.md';
260
+ if (!deps.existsSync(changelogPath)) {
261
+ checks.push({
262
+ name: `${changelogPath} exists`,
263
+ status: 'FAIL',
264
+ value: 'missing',
265
+ detail: `Run: release-it-preset init OR create ${changelogPath} manually`,
266
+ });
267
+ }
268
+ else {
269
+ checks.push({ name: `${changelogPath} exists`, status: 'PASS', value: 'yes' });
270
+ const content = deps.readFileSync(changelogPath, 'utf8');
271
+ const hasKacHeader = /^# Changelog/m.test(content);
272
+ if (!hasKacHeader) {
273
+ checks.push({
274
+ name: 'Keep a Changelog format',
275
+ status: 'FAIL',
276
+ value: 'invalid',
277
+ detail: 'CHANGELOG.md must start with "# Changelog" (Keep a Changelog format)',
278
+ });
279
+ }
280
+ else {
281
+ checks.push({ name: 'Keep a Changelog format', status: 'PASS', value: 'valid' });
282
+ }
283
+ const unreleasedMatch = content.match(/## \[Unreleased\]([\s\S]*?)(?=## \[|$)/);
284
+ if (!unreleasedMatch) {
285
+ checks.push({
286
+ name: '[Unreleased] section',
287
+ status: 'FAIL',
288
+ value: 'missing',
289
+ detail: 'Add "## [Unreleased]" section — run: release-it-preset update',
290
+ });
291
+ }
292
+ else {
293
+ const unreleasedContent = unreleasedMatch[1].trim();
294
+ const hasChanges = /^-/m.test(unreleasedContent);
295
+ if (!unreleasedContent || !hasChanges) {
296
+ checks.push({
297
+ name: '[Unreleased] section',
298
+ status: 'WARN',
299
+ value: 'empty',
300
+ detail: 'No entries yet — run: release-it-preset update',
301
+ });
302
+ }
303
+ else {
304
+ checks.push({ name: '[Unreleased] section', status: 'PASS', value: 'has content' });
305
+ }
306
+ }
307
+ }
308
+ const hasReleaseItJson = deps.existsSync('.release-it.json');
309
+ if (!hasReleaseItJson) {
310
+ checks.push({
311
+ name: '.release-it.json exists',
312
+ status: 'WARN',
313
+ value: 'missing',
314
+ detail: 'Optional but recommended. Run: release-it-preset init',
315
+ });
316
+ }
317
+ else {
318
+ checks.push({ name: '.release-it.json exists', status: 'PASS', value: 'yes' });
319
+ try {
320
+ const raw = deps.readFileSync('.release-it.json', 'utf8');
321
+ const config = JSON.parse(raw);
322
+ const extendsField = config.extends;
323
+ if (!extendsField) {
324
+ checks.push({
325
+ name: '.release-it.json extends preset',
326
+ status: 'WARN',
327
+ value: 'no extends field',
328
+ detail: 'Add "extends": "@oorabona/release-it-preset/config/<name>" for CLI auto-detection',
329
+ });
330
+ }
331
+ else if (!/@oorabona\/release-it-preset\/config\/[\w-]+/.test(extendsField)) {
332
+ checks.push({
333
+ name: '.release-it.json extends preset',
334
+ status: 'WARN',
335
+ value: extendsField,
336
+ detail: 'extends does not point to @oorabona/release-it-preset/config/<name>',
337
+ });
338
+ }
339
+ else {
340
+ checks.push({
341
+ name: '.release-it.json extends preset',
342
+ status: 'PASS',
343
+ value: extendsField,
344
+ });
345
+ }
346
+ }
347
+ catch {
348
+ checks.push({
349
+ name: '.release-it.json parseable',
350
+ status: 'FAIL',
351
+ value: 'parse error',
352
+ detail: '.release-it.json contains invalid JSON',
353
+ });
354
+ }
355
+ }
356
+ if (!deps.existsSync('package.json')) {
357
+ checks.push({
358
+ name: 'package.json exists',
359
+ status: 'FAIL',
360
+ value: 'missing',
361
+ detail: 'package.json is required for release-it',
362
+ });
363
+ }
364
+ else {
365
+ try {
366
+ const raw = deps.readFileSync('package.json', 'utf8');
367
+ const pkg = JSON.parse(raw);
368
+ const version = pkg.version;
369
+ if (!version) {
370
+ checks.push({
371
+ name: 'package.json version',
372
+ status: 'FAIL',
373
+ value: 'missing',
374
+ detail: 'Add "version" field to package.json',
375
+ });
376
+ }
377
+ else if (!isValidSemver(version)) {
378
+ checks.push({
379
+ name: 'package.json version',
380
+ status: 'FAIL',
381
+ value: version,
382
+ detail: `"${version}" is not a valid semver string`,
383
+ });
384
+ }
385
+ else {
386
+ checks.push({ name: 'package.json version', status: 'PASS', value: version });
387
+ }
388
+ }
389
+ catch {
390
+ checks.push({
391
+ name: 'package.json parseable',
392
+ status: 'FAIL',
393
+ value: 'parse error',
394
+ detail: 'package.json contains invalid JSON',
395
+ });
396
+ }
397
+ }
398
+ checks.push(detectWorkspaceIntegration(deps));
399
+ return { checks, status: worstStatus(checks.map((c) => c.status)) };
400
+ }
401
+ // ---------------------------------------------------------------------------
402
+ // 4. Summary
403
+ // ---------------------------------------------------------------------------
404
+ export function summarize(report) {
405
+ const allChecks = [
406
+ ...report.environment.checks,
407
+ ...report.repository.checks,
408
+ ...report.configuration.checks,
409
+ ];
410
+ const pass = allChecks.filter((c) => c.status === 'PASS').length;
411
+ const warn = allChecks.filter((c) => c.status === 'WARN').length;
412
+ const fail = allChecks.filter((c) => c.status === 'FAIL').length;
413
+ const total = allChecks.length;
414
+ const score = `${pass}/${total} checks passing`;
415
+ let status;
416
+ if (fail > 0) {
417
+ status = 'BLOCKED';
418
+ }
419
+ else if (warn > 0) {
420
+ status = 'WARNINGS';
421
+ }
422
+ else {
423
+ status = 'READY';
424
+ }
425
+ const recommendations = [];
426
+ if (fail > 0) {
427
+ const failedNames = allChecks.filter((c) => c.status === 'FAIL').map((c) => c.name);
428
+ const preview = failedNames.slice(0, 2).join(', ') + (failedNames.length > 2 ? '...' : '');
429
+ recommendations.push(`Fix ${fail} blocking issue(s): ${preview}`);
430
+ }
431
+ if (warn > 0) {
432
+ recommendations.push(`Review ${warn} warning(s) before releasing`);
433
+ }
434
+ if (status === 'READY') {
435
+ recommendations.push('All checks pass — run: release-it-preset validate && release-it-preset default');
436
+ }
437
+ return { pass, warn, fail, total, score, status, recommendations };
438
+ }
439
+ // ---------------------------------------------------------------------------
440
+ // Main exported function (DI)
441
+ // ---------------------------------------------------------------------------
442
+ export function runDoctor(deps) {
443
+ const environment = collectEnvironment(deps);
444
+ const repository = inspectRepository(deps);
445
+ const configuration = validateConfiguration(deps);
446
+ const summary = summarize({ environment, repository, configuration });
447
+ return { environment, repository, configuration, summary };
448
+ }
449
+ // ---------------------------------------------------------------------------
450
+ // Formatting
451
+ // ---------------------------------------------------------------------------
452
+ const ICONS = {
453
+ PASS: '[PASS]',
454
+ WARN: '[WARN]',
455
+ FAIL: '[FAIL]',
456
+ };
457
+ const STATUS_LABELS = {
458
+ READY: 'READY',
459
+ WARNINGS: 'WARNINGS',
460
+ BLOCKED: 'BLOCKED',
461
+ };
462
+ export function formatHuman(report) {
463
+ const lines = [];
464
+ function sectionHeader(title) {
465
+ lines.push('');
466
+ lines.push(title);
467
+ lines.push('-'.repeat(60));
468
+ }
469
+ function renderChecks(checks) {
470
+ for (const check of checks) {
471
+ const icon = ICONS[check.status];
472
+ lines.push(` ${icon} ${check.name}: ${check.value}`);
473
+ if (check.detail) {
474
+ lines.push(` ${check.detail}`);
475
+ }
476
+ }
477
+ }
478
+ lines.push('');
479
+ lines.push('release-it-preset doctor');
480
+ lines.push('='.repeat(60));
481
+ sectionHeader('1. Environment');
482
+ renderChecks(report.environment.checks);
483
+ lines.push('');
484
+ lines.push(` Environment variables (${report.environment.vars.length} total):`);
485
+ for (const v of report.environment.vars) {
486
+ const srcLabel = v.source === 'env' ? '(env)' : v.source === 'default' ? '(default)' : '(unset)';
487
+ const displayVal = v.source === 'unset' ? '<not set>' : (v.value ?? '<not set>');
488
+ lines.push(` ${v.name.padEnd(35)} ${displayVal.padEnd(30)} ${srcLabel}`);
489
+ }
490
+ sectionHeader('2. Repository');
491
+ renderChecks(report.repository.checks);
492
+ sectionHeader('3. Configuration');
493
+ renderChecks(report.configuration.checks);
494
+ sectionHeader('4. Readiness Summary');
495
+ const { summary } = report;
496
+ lines.push(` Status : ${STATUS_LABELS[summary.status]}`);
497
+ lines.push(` Score : ${summary.score} (PASS: ${summary.pass}, WARN: ${summary.warn}, FAIL: ${summary.fail})`);
498
+ if (summary.recommendations.length > 0) {
499
+ lines.push('');
500
+ lines.push(' Recommendations:');
501
+ for (const rec of summary.recommendations) {
502
+ lines.push(` * ${rec}`);
503
+ }
504
+ }
505
+ lines.push('');
506
+ return lines.join('\n');
507
+ }
508
+ export function formatJson(report) {
509
+ return JSON.stringify(report, null, 2);
510
+ }
511
+ // ---------------------------------------------------------------------------
512
+ // CLI entry (guarded)
513
+ // ---------------------------------------------------------------------------
514
+ if (import.meta.url === `file://${process.argv[1]}`) {
515
+ const isJson = process.argv.includes('--json');
516
+ const deps = {
517
+ execSync,
518
+ existsSync,
519
+ readFileSync,
520
+ getEnv: (key) => process.env[key],
521
+ };
522
+ const report = runDoctor(deps);
523
+ if (isJson) {
524
+ process.stdout.write(formatJson(report) + '\n');
525
+ }
526
+ else {
527
+ process.stdout.write(formatHuman(report));
528
+ }
529
+ process.exit(report.summary.status === 'BLOCKED' ? 1 : 0);
530
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Configurable commit-type → CHANGELOG section mapping.
3
+ *
4
+ * Resolution order (highest priority wins):
5
+ * 1. CHANGELOG_TYPE_MAP env var (JSON string)
6
+ * 2. .changelog-types.json file (project root)
7
+ * 3. Built-in defaults below
8
+ *
9
+ * A value of `false` means "skip this type entirely" (no changelog entry).
10
+ */
11
+ /**
12
+ * Built-in commit-type → CHANGELOG section map.
13
+ * Values are `### SectionName` strings or `false` to suppress.
14
+ */
15
+ export const BUILTIN_TYPE_MAP = {
16
+ feat: '### Added',
17
+ feature: '### Added',
18
+ add: '### Added',
19
+ fix: '### Fixed',
20
+ bugfix: '### Fixed',
21
+ security: '### Security',
22
+ perf: '### Changed',
23
+ refactor: '### Changed',
24
+ style: '### Changed',
25
+ docs: '### Changed',
26
+ test: '### Changed',
27
+ chore: '### Changed',
28
+ build: '### Changed',
29
+ deps: '### Changed',
30
+ dependency: '### Changed',
31
+ dependencies: '### Changed',
32
+ revert: '### Changed',
33
+ remove: '### Removed',
34
+ removed: '### Removed',
35
+ delete: '### Removed',
36
+ deleted: '### Removed',
37
+ ci: false,
38
+ release: false,
39
+ hotfix: false,
40
+ misc: '### Changed',
41
+ };
42
+ const CHANGELOG_TYPES_FILE = '.changelog-types.json';
43
+ /**
44
+ * Validate that every value in the map is either a string or false.
45
+ * Throws on the first invalid entry.
46
+ */
47
+ function validateMapStructure(map) {
48
+ if (typeof map !== 'object' || map === null || Array.isArray(map)) {
49
+ throw new TypeError('Type map must be a plain object');
50
+ }
51
+ for (const [key, value] of Object.entries(map)) {
52
+ if (typeof value !== 'string' && value !== false) {
53
+ throw new TypeError(`Invalid value for key "${key}": expected string or false, got ${typeof value}`);
54
+ }
55
+ }
56
+ }
57
+ /**
58
+ * Load the commit-type → CHANGELOG section mapping.
59
+ *
60
+ * Priority:
61
+ * 1. CHANGELOG_TYPE_MAP env var (JSON, merged on top of file + built-in)
62
+ * 2. .changelog-types.json project file (merged on top of built-in)
63
+ * 3. BUILTIN_TYPE_MAP (base)
64
+ *
65
+ * Malformed JSON or invalid structure → WARN + ignore that layer (fall back to lower priority).
66
+ */
67
+ export function loadChangelogTypeMap(deps) {
68
+ let resolved = { ...BUILTIN_TYPE_MAP };
69
+ // Layer 1: project-level file override
70
+ let fileContent;
71
+ try {
72
+ fileContent = deps.readFileSync(CHANGELOG_TYPES_FILE, 'utf8');
73
+ }
74
+ catch (err) {
75
+ // ENOENT (file does not exist) is the expected case — silently skip.
76
+ // Other I/O errors (EACCES permission denied, EISDIR is a directory, etc.)
77
+ // are real problems and surfaced as a WARN so the user can investigate.
78
+ const e = err;
79
+ if (e?.code !== 'ENOENT') {
80
+ deps.warn(`Cannot read ${CHANGELOG_TYPES_FILE}: ${e?.message ?? String(err)}. Skipping file override.`);
81
+ }
82
+ fileContent = undefined;
83
+ }
84
+ if (fileContent !== undefined) {
85
+ try {
86
+ const parsed = JSON.parse(fileContent);
87
+ validateMapStructure(parsed);
88
+ resolved = { ...resolved, ...parsed };
89
+ }
90
+ catch (err) {
91
+ deps.warn(`Invalid ${CHANGELOG_TYPES_FILE}: ${err.message}. Using built-in type map.`);
92
+ }
93
+ }
94
+ // Layer 2: env var override (highest priority)
95
+ const envValue = deps.getEnv('CHANGELOG_TYPE_MAP');
96
+ if (envValue) {
97
+ try {
98
+ const parsed = JSON.parse(envValue);
99
+ validateMapStructure(parsed);
100
+ resolved = { ...resolved, ...parsed };
101
+ }
102
+ catch (err) {
103
+ deps.warn(`Invalid CHANGELOG_TYPE_MAP env var: ${err.message}. Using file/built-in type map.`);
104
+ }
105
+ }
106
+ return resolved;
107
+ }
@@ -23,6 +23,7 @@ import { getGitHubRepoUrl } from './lib/git-utils.js';
23
23
  import { CONVENTIONAL_COMMIT_REGEX } from './lib/commit-parser.js';
24
24
  import { runScript } from './lib/run-script.js';
25
25
  import { ValidationError } from './lib/errors.js';
26
+ import { BUILTIN_TYPE_MAP, loadChangelogTypeMap } from './lib/changelog-types.js';
26
27
  /**
27
28
  * Extract all conventional commit patterns from a commit body
28
29
  */
@@ -46,43 +47,19 @@ export function extractConventionalCommitParts(commitBody, sha) {
46
47
  return parts;
47
48
  }
48
49
  /**
49
- * Normalize commit types to standard changelog categories
50
+ * Normalize a commit type to a CHANGELOG section heading.
51
+ * Uses `typeMap` (defaults to BUILTIN_TYPE_MAP) so callers can inject
52
+ * a custom or project-level override without touching this function.
53
+ * Returns false when the type should be suppressed entirely.
50
54
  */
51
- export function normalizeCommitType(type) {
52
- const typeMap = {
53
- feat: '### Added',
54
- feature: '### Added',
55
- add: '### Added',
56
- fix: '### Fixed',
57
- bugfix: '### Fixed',
58
- security: '### Security',
59
- perf: '### Changed',
60
- refactor: '### Changed',
61
- style: '### Changed',
62
- docs: '### Changed',
63
- test: '### Changed',
64
- chore: '### Changed',
65
- build: '### Changed',
66
- deps: '### Changed',
67
- dependency: '### Changed',
68
- dependencies: '### Changed',
69
- revert: '### Changed',
70
- remove: '### Removed',
71
- removed: '### Removed',
72
- delete: '### Removed',
73
- deleted: '### Removed',
74
- ci: false,
75
- release: false,
76
- hotfix: false,
77
- misc: '### Changed',
78
- };
55
+ export function normalizeCommitType(type, typeMap = BUILTIN_TYPE_MAP) {
79
56
  const result = typeMap[type.toLowerCase()];
80
57
  return result !== undefined ? result : '### Changed';
81
58
  }
82
59
  /**
83
60
  * Parse git log output and extract all conventional commit parts
84
61
  */
85
- export function parseCommitsWithMultiplePrefixes(gitOutput, repoUrl) {
62
+ export function parseCommitsWithMultiplePrefixes(gitOutput, repoUrl, typeMap = BUILTIN_TYPE_MAP) {
86
63
  if (!gitOutput)
87
64
  return '';
88
65
  const commitEntries = gitOutput.split('|||END|||').filter((entry) => entry.trim());
@@ -98,8 +75,9 @@ export function parseCommitsWithMultiplePrefixes(gitOutput, repoUrl) {
98
75
  // Compute the header block: first contiguous run of non-empty lines.
99
76
  // This prevents paragraph-separated footer tokens like "Refs: #42" or
100
77
  // "Co-authored-by: ..." from matching the conventional-commit regex
101
- // via the 'gm' flag in extractConventionalCommitParts. AC#5 (consecutive
102
- // multi-prefix lines) is preserved because those lines share no blank line.
78
+ // via the 'gm' flag in extractConventionalCommitParts. Consecutive
79
+ // multi-prefix lines (e.g. "feat: x\nfix: y") are preserved because
80
+ // they share no blank line — see #23 for the original use case.
103
81
  const headerBlock = body.split('\n').reduce((acc, line) => {
104
82
  if (!acc.done) {
105
83
  if (line.trim() === '') {
@@ -112,19 +90,35 @@ export function parseCommitsWithMultiplePrefixes(gitOutput, repoUrl) {
112
90
  return acc;
113
91
  }, { lines: [], done: false }).lines.join('\n');
114
92
  const parts = extractConventionalCommitParts(headerBlock, shortSha);
115
- // Detect "BREAKING CHANGE:" trailer in the full body (not just header).
116
- // This handles the footer-style breaking annotation per Conventional Commits spec.
117
- const breakingFooterMatch = /^BREAKING[- ]CHANGE:\s*(.+)/m.exec(body);
118
- if (breakingFooterMatch) {
93
+ // Detect "BREAKING CHANGE:" trailers only in the LAST paragraph of the body,
94
+ // AND only when the body has more than one paragraph (i.e., there is at least one
95
+ // blank-line separator). Per Conventional Commits 1.0.0 §6, a footer requires a
96
+ // blank line separating it from the preceding content. A "BREAKING CHANGE:" that
97
+ // appears on a line immediately after the subject line (no blank line) is mid-body
98
+ // prose, NOT a footer, and must NOT promote the commit to breaking.
99
+ //
100
+ // matchAll() is used so multiple BREAKING CHANGE: lines in the same last
101
+ // paragraph each emit a separate breaking entry.
102
+ // CRLF safety: accept both LF and CRLF line endings so commits authored on
103
+ // Windows produce the same output (a paragraph separator can be \n\n or \r\n\r\n).
104
+ const paragraphs = body.split(/\r?\n[ \t]*\r?\n/);
105
+ const hasFooterSection = paragraphs.length > 1;
106
+ const breakingFooterMatches = hasFooterSection
107
+ ? [...(paragraphs[paragraphs.length - 1] ?? '').matchAll(/^BREAKING[- ]CHANGE:\s*(.+)$/gm)]
108
+ : [];
109
+ if (breakingFooterMatches.length > 0) {
119
110
  if (parts.length > 0) {
120
- // Promote the first emitted part to breaking.
111
+ // Promote the first conventional-commit part to breaking so it appears in
112
+ // the BREAKING CHANGES section with the commit's own description.
121
113
  parts[0] = { ...parts[0], breaking: true };
122
114
  }
123
- else {
124
- // No leading conventional prefix found; emit a standalone breaking entry.
115
+ // Each BREAKING CHANGE: footer line emits its own breaking entry with the
116
+ // footer's description (distinct from the commit subject).
117
+ // Multiple footer lines → multiple entries.
118
+ for (const m of breakingFooterMatches) {
125
119
  parts.push({
126
120
  type: 'misc',
127
- description: breakingFooterMatch[1].trim(),
121
+ description: m[1].trim(),
128
122
  sha: shortSha,
129
123
  breaking: true,
130
124
  });
@@ -161,11 +155,16 @@ export function parseCommitsWithMultiplePrefixes(gitOutput, repoUrl) {
161
155
  const groupedParts = {};
162
156
  const breakingChanges = [];
163
157
  for (const part of allParts) {
164
- // Collect breaking changes separately
158
+ // Breaking parts go ONLY into the BREAKING CHANGES section.
159
+ // They are NOT also added to their native section (e.g. ### Added), which
160
+ // would produce duplicate entries. The breaking indicator in the native
161
+ // section was confusing — the dedicated ### ⚠️ BREAKING CHANGES section
162
+ // already provides full visibility.
165
163
  if (part.breaking) {
166
164
  breakingChanges.push(part);
165
+ continue;
167
166
  }
168
- const sectionName = normalizeCommitType(part.type);
167
+ const sectionName = normalizeCommitType(part.type, typeMap);
169
168
  if (sectionName === false) {
170
169
  continue;
171
170
  }
@@ -174,8 +173,19 @@ export function parseCommitsWithMultiplePrefixes(gitOutput, repoUrl) {
174
173
  }
175
174
  groupedParts[sectionName].push(part);
176
175
  }
176
+ // Build the final ordered section list.
177
+ // Custom sections (from typeMap overrides) are appended after the standard order.
177
178
  const sections = [];
178
- const sectionOrder = ['### Added', '### Fixed', '### Changed', '### Removed', '### Security'];
179
+ const standardSectionOrder = [
180
+ '### Added',
181
+ '### Fixed',
182
+ '### Changed',
183
+ '### Removed',
184
+ '### Security',
185
+ ];
186
+ // Collect any custom section names not in the standard order
187
+ const customSections = Object.keys(groupedParts).filter((s) => !standardSectionOrder.includes(s));
188
+ const sectionOrder = [...standardSectionOrder, ...customSections];
179
189
  // Add BREAKING CHANGES section first if there are any
180
190
  if (breakingChanges.length > 0) {
181
191
  sections.push('### ⚠️ BREAKING CHANGES');
@@ -191,9 +201,8 @@ export function parseCommitsWithMultiplePrefixes(gitOutput, repoUrl) {
191
201
  sections.push(sectionTitle);
192
202
  sections.push(...groupedParts[sectionTitle].map((part) => {
193
203
  const scopePart = part.scope ? ` (${part.scope})` : '';
194
- const breakingIndicator = part.breaking ? ' ⚠️ BREAKING' : '';
195
204
  const linkPart = repoUrl ? ` ([${part.sha}](${repoUrl}/commit/${part.sha}))` : ` (${part.sha})`;
196
- return `- ${part.description}${scopePart}${breakingIndicator}${linkPart}`;
205
+ return `- ${part.description}${scopePart}${linkPart}`;
197
206
  }));
198
207
  sections.push('');
199
208
  }
@@ -289,7 +298,12 @@ export function populateChangelog(deps) {
289
298
  getEnv: deps.getEnv,
290
299
  warn: deps.warn,
291
300
  });
292
- const commits = parseCommitsWithMultiplePrefixes(gitOutput, repoUrl);
301
+ const typeMap = loadChangelogTypeMap({
302
+ readFileSync: deps.readFileSync,
303
+ getEnv: deps.getEnv,
304
+ warn: deps.warn,
305
+ });
306
+ const commits = parseCommitsWithMultiplePrefixes(gitOutput, repoUrl, typeMap);
293
307
  const changelog = deps.readFileSync(changelogPath, 'utf8');
294
308
  const unreleasedContent = commits && commits.trim() ? commits : 'No changes yet.';
295
309
  const unreleasedRegex = /## \[Unreleased\][\s\S]*?(?=## \[|$)/;
@@ -0,0 +1,71 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Doctor — diagnostic checklist + readiness score for release-it-preset
4
+ *
5
+ * Inspects four categories:
6
+ * 1. Environment — all known env vars, source (env vs default)
7
+ * 2. Repository — git state (branch, tag, dirty WD, upstream)
8
+ * 3. Configuration — CHANGELOG.md, .release-it.json, package.json
9
+ * 4. Summary — READY / WARNINGS / BLOCKED + score
10
+ *
11
+ * Usage:
12
+ * node dist/scripts/doctor.js
13
+ * node dist/scripts/doctor.js --json
14
+ */
15
+ import type { ExecSyncOptions } from 'node:child_process';
16
+ import { existsSync, readFileSync } from 'node:fs';
17
+ export type CheckStatus = 'PASS' | 'WARN' | 'FAIL';
18
+ export interface CheckResult {
19
+ name: string;
20
+ status: CheckStatus;
21
+ value: string;
22
+ detail?: string;
23
+ }
24
+ export interface EnvVarInfo {
25
+ name: string;
26
+ value: string | undefined;
27
+ source: 'env' | 'default' | 'unset';
28
+ defaultValue?: string;
29
+ }
30
+ export interface EnvironmentSection {
31
+ checks: CheckResult[];
32
+ vars: EnvVarInfo[];
33
+ status: CheckStatus;
34
+ }
35
+ export interface RepositorySection {
36
+ checks: CheckResult[];
37
+ status: CheckStatus;
38
+ }
39
+ export interface ConfigurationSection {
40
+ checks: CheckResult[];
41
+ status: CheckStatus;
42
+ }
43
+ export interface DoctorSummary {
44
+ pass: number;
45
+ warn: number;
46
+ fail: number;
47
+ total: number;
48
+ score: string;
49
+ status: 'READY' | 'WARNINGS' | 'BLOCKED';
50
+ recommendations: string[];
51
+ }
52
+ export interface DoctorReport {
53
+ environment: EnvironmentSection;
54
+ repository: RepositorySection;
55
+ configuration: ConfigurationSection;
56
+ summary: DoctorSummary;
57
+ }
58
+ export interface DoctorDeps {
59
+ execSync: (command: string, options?: ExecSyncOptions) => Buffer | string;
60
+ existsSync: typeof existsSync;
61
+ readFileSync: typeof readFileSync;
62
+ getEnv: (key: string) => string | undefined;
63
+ }
64
+ export declare function safeExec(command: string, deps: DoctorDeps): string | null;
65
+ export declare function collectEnvironment(deps: DoctorDeps): EnvironmentSection;
66
+ export declare function inspectRepository(deps: DoctorDeps): RepositorySection;
67
+ export declare function validateConfiguration(deps: DoctorDeps): ConfigurationSection;
68
+ export declare function summarize(report: Omit<DoctorReport, 'summary'>): DoctorSummary;
69
+ export declare function runDoctor(deps: DoctorDeps): DoctorReport;
70
+ export declare function formatHuman(report: DoctorReport): string;
71
+ export declare function formatJson(report: DoctorReport): string;
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Configurable commit-type → CHANGELOG section mapping.
3
+ *
4
+ * Resolution order (highest priority wins):
5
+ * 1. CHANGELOG_TYPE_MAP env var (JSON string)
6
+ * 2. .changelog-types.json file (project root)
7
+ * 3. Built-in defaults below
8
+ *
9
+ * A value of `false` means "skip this type entirely" (no changelog entry).
10
+ */
11
+ import type { readFileSync as ReadFileSyncFn } from 'node:fs';
12
+ /**
13
+ * Dependencies for loadChangelogTypeMap — follows the project DI pattern.
14
+ */
15
+ export interface ChangelogTypeDeps {
16
+ readFileSync: typeof ReadFileSyncFn;
17
+ getEnv: (key: string) => string | undefined;
18
+ warn: (message: string) => void;
19
+ }
20
+ /**
21
+ * Built-in commit-type → CHANGELOG section map.
22
+ * Values are `### SectionName` strings or `false` to suppress.
23
+ */
24
+ export declare const BUILTIN_TYPE_MAP: Record<string, string | false>;
25
+ /**
26
+ * Load the commit-type → CHANGELOG section mapping.
27
+ *
28
+ * Priority:
29
+ * 1. CHANGELOG_TYPE_MAP env var (JSON, merged on top of file + built-in)
30
+ * 2. .changelog-types.json project file (merged on top of built-in)
31
+ * 3. BUILTIN_TYPE_MAP (base)
32
+ *
33
+ * Malformed JSON or invalid structure → WARN + ignore that layer (fall back to lower priority).
34
+ */
35
+ export declare function loadChangelogTypeMap(deps: ChangelogTypeDeps): Record<string, string | false>;
@@ -43,13 +43,16 @@ export interface CommitPart {
43
43
  */
44
44
  export declare function extractConventionalCommitParts(commitBody: string, sha: string): CommitPart[];
45
45
  /**
46
- * Normalize commit types to standard changelog categories
46
+ * Normalize a commit type to a CHANGELOG section heading.
47
+ * Uses `typeMap` (defaults to BUILTIN_TYPE_MAP) so callers can inject
48
+ * a custom or project-level override without touching this function.
49
+ * Returns false when the type should be suppressed entirely.
47
50
  */
48
- export declare function normalizeCommitType(type: string): string | false;
51
+ export declare function normalizeCommitType(type: string, typeMap?: Record<string, string | false>): string | false;
49
52
  /**
50
53
  * Parse git log output and extract all conventional commit parts
51
54
  */
52
- export declare function parseCommitsWithMultiplePrefixes(gitOutput: string, repoUrl: string): string;
55
+ export declare function parseCommitsWithMultiplePrefixes(gitOutput: string, repoUrl: string, typeMap?: Record<string, string | false>): string;
53
56
  /**
54
57
  * Resolve the `since` baseline for changelog generation.
55
58
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oorabona/release-it-preset",
3
- "version": "0.14.0",
3
+ "version": "1.0.0-rc.0",
4
4
  "description": "Shared release-it preset with OIDC trusted publishing, smart npm dist-tag selection, and monorepo per-package changelog support",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -85,7 +85,7 @@
85
85
  "prepublishOnly": "pnpm build && echo 'Running prepublish checks...' && test -f README.md && test -f LICENSE"
86
86
  },
87
87
  "peerDependencies": {
88
- "release-it": "^20.0.0"
88
+ "release-it": "^19.0.0 || ^20.0.0"
89
89
  },
90
90
  "devDependencies": {
91
91
  "@biomejs/biome": "^2.4.13",
@@ -96,10 +96,11 @@
96
96
  "rimraf": "^6.1.3",
97
97
  "tsx": "^4.21.0",
98
98
  "typescript": "^6.0.3",
99
+ "vite": "^7.3.2",
99
100
  "vitest": "^4.1.5"
100
101
  },
101
102
  "engines": {
102
- "node": ">=18.0.0"
103
+ "node": ">=20.19.0"
103
104
  },
104
105
  "packageManager": "pnpm@10.17.1"
105
106
  }