@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 +168 -7
- package/bin/cli.js +4 -2
- package/dist/scripts/doctor.js +530 -0
- package/dist/scripts/lib/changelog-types.js +107 -0
- package/dist/scripts/populate-unreleased-changelog.js +60 -46
- package/dist/types/doctor.d.ts +71 -0
- package/dist/types/lib/changelog-types.d.ts +35 -0
- package/dist/types/populate-unreleased-changelog.d.ts +6 -3
- package/package.json +4 -3
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
|
-
[](https://npmjs.org/package/@oorabona/release-it-preset)
|
|
6
|
+
[](https://npmjs.org/package/@oorabona/release-it-preset)
|
|
7
|
+
[](https://opensource.org/licenses/MIT)
|
|
8
|
+
[](https://nodejs.org/)
|
|
9
|
+
[](https://docs.npmjs.com/trusted-publishers)
|
|
6
10
|
[](https://github.com/oorabona/release-it-preset/actions/workflows/ci.yml)
|
|
7
11
|
[](https://github.com/oorabona/release-it-preset/actions/workflows/audit.yml)
|
|
8
|
-
[](https://npmjs.org/package/@oorabona/release-it-preset)
|
|
12
|
+
[](https://codecov.io/github/oorabona/release-it-preset)
|
|
10
13
|
[](https://www.typescriptlang.org/)
|
|
11
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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.
|
|
102
|
-
// multi-prefix lines
|
|
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:"
|
|
116
|
-
//
|
|
117
|
-
|
|
118
|
-
|
|
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
|
|
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
|
-
|
|
124
|
-
|
|
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:
|
|
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
|
-
//
|
|
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
|
|
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}${
|
|
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
|
|
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
|
|
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.
|
|
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": ">=
|
|
103
|
+
"node": ">=20.19.0"
|
|
103
104
|
},
|
|
104
105
|
"packageManager": "pnpm@10.17.1"
|
|
105
106
|
}
|