@oorabona/release-it-preset 0.8.1 → 0.10.1

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
@@ -4,6 +4,7 @@ Shared [release-it](https://github.com/release-it/release-it) configuration and
4
4
 
5
5
  [![codecov](https://codecov.io/github/oorabona/release-it-preset/graph/badge.svg?token=6RMN34Z7TX)](https://codecov.io/github/oorabona/release-it-preset)
6
6
  [![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
+ [![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)
7
8
  [![NPM Version](https://img.shields.io/npm/v/release-it-preset.svg)](https://npmjs.org/package/@oorabona/release-it-preset)
8
9
  [![NPM Downloads](https://img.shields.io/npm/dm/release-it-preset.svg)](https://npmjs.org/package/@oorabona/release-it-preset)
9
10
  [![TypeScript](https://img.shields.io/badge/TypeScript-5.3+-blue.svg)](https://www.typescriptlang.org/)
@@ -16,6 +17,11 @@ Shared [release-it](https://github.com/release-it/release-it) configuration and
16
17
  - [Quick Start](#quick-start)
17
18
  - [Available Configurations](#available-configurations)
18
19
  - [CLI Usage](#cli-usage)
20
+ - [Zero-Config Mode (Auto-Detection)](#zero-config-mode-auto-detection)
21
+ - [Preset Selection Mode](#preset-selection-mode)
22
+ - [Passthrough Mode (Custom Config Override)](#passthrough-mode-custom-config-override)
23
+ - [Monorepo Support](#monorepo-support)
24
+ - [Utility Commands](#utility-commands)
19
25
  - [Scripts](#scripts)
20
26
  - [Environment Variables](#environment-variables)
21
27
  - [Configuration Override](#configuration-override)
@@ -37,6 +43,9 @@ Shared [release-it](https://github.com/release-it/release-it) configuration and
37
43
  - 🔄 Republish and retry mechanisms for failed releases
38
44
  - ⚡ Hotfix release support
39
45
  - 🎯 Environment variable configuration
46
+ - 🔍 **NEW v0.9.0:** Zero-config auto-detection mode
47
+ - 🏢 **NEW v0.9.0:** Monorepo support with parent directory config references
48
+ - ⚙️ **NEW v0.9.0:** Passthrough mode for custom config files
40
49
 
41
50
  ## Installation
42
51
 
@@ -331,9 +340,42 @@ Features:
331
340
 
332
341
  ## CLI Usage
333
342
 
334
- The package provides a `release-it-preset` CLI with two types of commands:
343
+ The package provides a `release-it-preset` CLI with four operating modes:
335
344
 
336
- ### Release Commands
345
+ 1. **Zero-Config Mode** (auto-detection) - No arguments needed
346
+ 2. **Preset Selection Mode** - Specify which preset to use
347
+ 3. **Passthrough Mode** - Direct config file override
348
+ 4. **Utility Mode** - Helper commands
349
+
350
+ ### Zero-Config Mode (Auto-Detection)
351
+
352
+ **NEW in v0.9.0** - The CLI can automatically detect which preset to use from your `.release-it.json`:
353
+
354
+ ```bash
355
+ # Just run release-it-preset with no arguments
356
+ pnpm release-it-preset
357
+
358
+ # 🔍 Auto-detected preset: default
359
+ # ✅ Config validated: preset "default"
360
+ # 📝 Using: /path/to/.release-it.json
361
+ ```
362
+
363
+ **How it works:**
364
+ 1. CLI reads your `.release-it.json`
365
+ 2. Extracts the preset name from the `extends` field
366
+ 3. Runs that preset automatically
367
+
368
+ **Requirements:**
369
+ - `.release-it.json` must exist
370
+ - Must have `extends` field like `"@oorabona/release-it-preset/config/default"`
371
+
372
+ **Benefits:**
373
+ - ✅ Shortest command possible
374
+ - ✅ Config file is source of truth
375
+ - ✅ No need to remember preset names
376
+ - ✅ Follows industry standards (ESLint, TypeScript, Prettier)
377
+
378
+ ### Preset Selection Mode
337
379
 
338
380
  Run release-it with specific configurations:
339
381
 
@@ -350,6 +392,92 @@ pnpm release-it-preset manual-changelog
350
392
 
351
393
  All additional arguments are passed through to release-it.
352
394
 
395
+ ### Passthrough Mode (Custom Config Override)
396
+
397
+ **NEW in v0.9.0** - Use a custom config file and bypass preset validation:
398
+
399
+ ```bash
400
+ # Use custom config file
401
+ pnpm release-it-preset --config .release-it-manual.json
402
+
403
+ # 🔀 Passthrough mode: using config .release-it-manual.json
404
+ # Bypassing preset validation - direct release-it invocation
405
+ ```
406
+
407
+ **Use cases:**
408
+ - **Switching presets occasionally** - Have multiple config files for different scenarios
409
+ - **Monorepo workflows** - Reference shared configs from parent directories
410
+ - **Advanced customization** - Full control over release-it configuration
411
+
412
+ **Example workflow:**
413
+
414
+ ```json
415
+ // .release-it.json (default - 95% of time)
416
+ {
417
+ "extends": "@oorabona/release-it-preset/config/default",
418
+ "git": { "requireBranch": "develop" }
419
+ }
420
+
421
+ // .release-it-manual.json (rare - 5% of time)
422
+ {
423
+ "extends": "@oorabona/release-it-preset/config/manual-changelog",
424
+ "git": { "requireBranch": "develop" }
425
+ }
426
+ ```
427
+
428
+ ```bash
429
+ # Normal release
430
+ pnpm release-it-preset # Auto-detects default
431
+
432
+ # Manual changelog release (rare)
433
+ pnpm release-it-preset --config .release-it-manual.json
434
+ ```
435
+
436
+ **Benefits:**
437
+ - ✅ No need to edit `.release-it.json` to switch presets
438
+ - ✅ Config files are explicit and version-controlled
439
+ - ✅ Works with monorepo parent directory references
440
+
441
+ ### Monorepo Support
442
+
443
+ **NEW in v0.9.0** - Parent directory config references are now supported:
444
+
445
+ ```bash
446
+ # Monorepo structure
447
+ /my-monorepo/
448
+ ├── .release-it-base.json # Shared configuration
449
+ ├── packages/
450
+ │ ├── core/
451
+ │ │ └── .release-it.json # extends: ../../.release-it-base.json
452
+ │ └── utils/
453
+ │ └── .release-it.json # extends: ../../.release-it-base.json
454
+ ```
455
+
456
+ ```json
457
+ // packages/core/.release-it.json
458
+ {
459
+ "extends": [
460
+ "../../.release-it-base.json", // ✅ Parent reference allowed!
461
+ "@oorabona/release-it-preset/config/default"
462
+ ]
463
+ }
464
+ ```
465
+
466
+ **Security validation:**
467
+ - ✅ Parent directory references (`../`) supported (up to 5 levels)
468
+ - ✅ Config file extension whitelist (`.json`, `.js`, `.cjs`, `.mjs`, `.yaml`, `.yml`, `.toml`)
469
+ - ✅ File existence validation
470
+ - ❌ Absolute paths blocked (use relative paths)
471
+ - ❌ Excessive traversal blocked (max `../../../../../../`)
472
+
473
+ **Why this is safe:**
474
+ - Config files are trusted code (developer controls the repository)
475
+ - Industry standard pattern (TypeScript, ESLint, Prettier all allow `../`)
476
+ - Multiple validation layers prevent abuse
477
+ - No privilege escalation in CLI tool context
478
+
479
+ See [examples/monorepo-workflow.md](examples/monorepo-workflow.md) for complete monorepo guide.
480
+
353
481
  ### Utility Commands
354
482
 
355
483
  Helper commands for project setup and maintenance:
package/bin/cli.js CHANGED
@@ -27,7 +27,7 @@ import { spawn } from 'node:child_process';
27
27
  import { existsSync, readFileSync } from 'node:fs';
28
28
  import { fileURLToPath } from 'node:url';
29
29
  import { dirname, join } from 'node:path';
30
- import { validateConfigName, validateUtilityCommand, sanitizeArgs } from './validators.js';
30
+ import { validateConfigName, validateUtilityCommand, sanitizeArgs, validateConfigPath } from './validators.js';
31
31
 
32
32
  const __filename = fileURLToPath(import.meta.url);
33
33
  const __dirname = dirname(__filename);
@@ -54,7 +54,14 @@ const UTILITY_COMMANDS = {
54
54
 
55
55
  function showHelp() {
56
56
  console.log(`
57
- Usage: release-it-preset <command> [...args]
57
+ Usage: release-it-preset [command] [...args]
58
+ release-it-preset --config <file> [...args]
59
+
60
+ CLI Modes:
61
+ 1. Auto-detection (no command) - Reads preset from .release-it.json
62
+ 2. Preset selection - Specify preset command
63
+ 3. Passthrough (--config) - Direct config file, bypass validation
64
+ 4. Utility commands - Helper scripts
58
65
 
59
66
  Release Commands:
60
67
  default Full release with changelog, git, GitHub, and npm
@@ -73,19 +80,28 @@ Utility Commands:
73
80
  check-pr Evaluate PR hygiene (branch diff, changelog status, conventions)
74
81
  retry-publish-preflight Run retry publish safety checks without executing release
75
82
 
83
+ Passthrough Mode:
84
+ --config <file> Use custom config file, bypass preset validation
85
+
76
86
  Examples:
87
+ # Zero-config (auto-detect from .release-it.json)
88
+ release-it-preset
89
+
77
90
  # Release commands
78
91
  release-it-preset default --dry-run
79
92
  release-it-preset hotfix --verbose
80
93
  release-it-preset changelog-only --ci
81
94
 
95
+ # Passthrough mode (custom config)
96
+ release-it-preset --config .release-it-manual.json
97
+
98
+ # Monorepo (parent config reference)
99
+ release-it-preset --config ../../.release-it-base.json
100
+
82
101
  # Utility commands
83
102
  release-it-preset init
84
103
  release-it-preset update
85
104
  release-it-preset validate
86
- release-it-preset check
87
- release-it-preset check-pr
88
- release-it-preset retry-publish-preflight
89
105
 
90
106
  For release-it options, see: https://github.com/release-it/release-it
91
107
  For environment variables, see: https://github.com/oorabona/release-it-preset#environment-variables
@@ -138,7 +154,7 @@ function handleReleaseCommand(configName, args) {
138
154
  }
139
155
 
140
156
  // Validate extends matches CLI preset
141
- const extendsMatch = userConfig.extends.match(/@oorabona\/release-it-preset\/config\/(\w+)/);
157
+ const extendsMatch = userConfig.extends.match(/@oorabona\/release-it-preset\/config\/([\w-]+)/);
142
158
  const extendsPreset = extendsMatch?.[1];
143
159
 
144
160
  if (extendsPreset && extendsPreset !== configName) {
@@ -237,10 +253,53 @@ function handleUtilityCommand(commandName, args) {
237
253
  });
238
254
  }
239
255
 
256
+ function passthroughToReleaseIt(args) {
257
+ // Extract --config value
258
+ const configIndex = args.indexOf('--config');
259
+ if (configIndex === -1 || configIndex === args.length - 1) {
260
+ console.error('❌ --config requires a file path argument\n');
261
+ console.error('Usage: release-it-preset --config <file>');
262
+ process.exit(1);
263
+ }
264
+
265
+ const configPath = args[configIndex + 1];
266
+
267
+ // Security validation
268
+ try {
269
+ validateConfigPath(configPath);
270
+ sanitizeArgs(args);
271
+
272
+ console.log(`🔀 Passthrough mode: using config ${configPath}`);
273
+ console.log(` Bypassing preset validation - direct release-it invocation\n`);
274
+ } catch (error) {
275
+ console.error(`❌ Configuration validation failed: ${error.message}`);
276
+ process.exit(1);
277
+ }
278
+
279
+ // Delegate to release-it
280
+ const releaseItCommand = 'release-it';
281
+ const child = spawn(releaseItCommand, args, {
282
+ stdio: 'inherit',
283
+ shell: false, // Security: prevent command injection
284
+ });
285
+
286
+ child.on('error', (error) => {
287
+ console.error(`❌ Failed to start release-it: ${error.message}`);
288
+ console.error(`\nMake sure release-it is installed:`);
289
+ console.error(` pnpm add -D release-it`);
290
+ process.exit(1);
291
+ });
292
+
293
+ child.on('close', (code) => {
294
+ process.exit(code ?? 0);
295
+ });
296
+ }
297
+
240
298
  function main() {
241
299
  const args = process.argv.slice(2);
242
300
 
243
- if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
301
+ // Handle --help
302
+ if (args.includes('--help') || args.includes('-h')) {
244
303
  showHelp();
245
304
  process.exit(0);
246
305
  }
@@ -248,13 +307,66 @@ function main() {
248
307
  const command = args[0];
249
308
  const commandArgs = args.slice(1);
250
309
 
251
- // Check if it's a release config
310
+ // Check for conflicting arguments (preset command + --config)
311
+ if (args.includes('--config') && RELEASE_CONFIGS[command]) {
312
+ console.error('❌ Conflicting arguments detected!\n');
313
+ console.error(' You specified both a preset command and --config flag.');
314
+ console.error('');
315
+ console.error(' Either:');
316
+ console.error(` 1. Use preset: release-it-preset ${command}`);
317
+ console.error(` 2. Use config: release-it-preset --config <file>`);
318
+ console.error('');
319
+ console.error(' Do not mix both approaches.');
320
+ process.exit(1);
321
+ }
322
+
323
+ // MODE 1: Passthrough - Direct release-it with custom config
324
+ if (args.includes('--config')) {
325
+ passthroughToReleaseIt(args);
326
+ return;
327
+ }
328
+
329
+ // MODE 2: Auto-detection - No arguments, read preset from .release-it.json
330
+ if (args.length === 0) {
331
+ const userConfigPath = join(process.cwd(), '.release-it.json');
332
+
333
+ if (!existsSync(userConfigPath)) {
334
+ console.error('❌ No command specified and no .release-it.json found\n');
335
+ console.error('Either:');
336
+ console.error(' 1. Run: release-it-preset init');
337
+ console.error(' 2. Run: release-it-preset <command>\n');
338
+ showHelp();
339
+ process.exit(1);
340
+ }
341
+
342
+ try {
343
+ const config = JSON.parse(readFileSync(userConfigPath, 'utf8'));
344
+ const extendsMatch = config.extends?.match(/@oorabona\/release-it-preset\/config\/([\w-]+)/);
345
+
346
+ if (!extendsMatch) {
347
+ console.error('❌ .release-it.json does not extend a known preset\n');
348
+ console.error('Expected extends field like:');
349
+ console.error(' "@oorabona/release-it-preset/config/default"\n');
350
+ process.exit(1);
351
+ }
352
+
353
+ const preset = extendsMatch[1];
354
+ console.log(`🔍 Auto-detected preset: ${preset}`);
355
+ handleReleaseCommand(preset, []);
356
+ return;
357
+ } catch (error) {
358
+ console.error(`❌ Error reading .release-it.json: ${error.message}`);
359
+ process.exit(1);
360
+ }
361
+ }
362
+
363
+ // MODE 3: Preset command (existing logic)
252
364
  if (RELEASE_CONFIGS[command]) {
253
365
  handleReleaseCommand(command, commandArgs);
254
366
  return;
255
367
  }
256
368
 
257
- // Check if it's a utility command
369
+ // MODE 4: Utility command (existing logic)
258
370
  if (UTILITY_COMMANDS[command]) {
259
371
  handleUtilityCommand(command, commandArgs);
260
372
  return;
@@ -264,6 +376,7 @@ function main() {
264
376
  console.error(`❌ Unknown command: ${command}`);
265
377
  console.error(`\nAvailable release configs: ${Object.keys(RELEASE_CONFIGS).join(', ')}`);
266
378
  console.error(`Available utility commands: ${Object.keys(UTILITY_COMMANDS).join(', ')}`);
379
+ console.error(`\nFor direct config file usage: release-it-preset --config <file>`);
267
380
  console.error(`\nRun 'release-it-preset --help' for more information.`);
268
381
  process.exit(1);
269
382
  }
package/bin/validators.js CHANGED
@@ -10,6 +10,9 @@
10
10
  * - Fail securely
11
11
  */
12
12
 
13
+ import { existsSync, statSync } from 'node:fs';
14
+ import { extname, isAbsolute, resolve } from 'node:path';
15
+
13
16
  /**
14
17
  * Validates that a config name is in the allowed list
15
18
  *
@@ -96,26 +99,75 @@ export function sanitizeArgs(args) {
96
99
  }
97
100
 
98
101
  /**
99
- * Validates that a path does not contain directory traversal attempts
102
+ * Validates config file paths with monorepo support
103
+ *
104
+ * Security approach: Defense in depth with multiple validation layers
105
+ * - Whitelist allowed file extensions
106
+ * - Limit parent directory traversal depth (monorepo support)
107
+ * - Reject absolute paths from CLI
108
+ * - Validate file existence
109
+ *
110
+ * Why we allow ".." (parent directory references):
111
+ * - Standard pattern in monorepos (TypeScript, ESLint, Prettier all allow it)
112
+ * - Developer controls the environment (config files are trusted code boundary)
113
+ * - No privilege escalation possible in CLI tool context
114
+ * - Multiple validation layers prevent abuse
100
115
  *
101
- * @param {string} path - The path to validate
102
- * @throws {Error} If path contains traversal patterns
103
- * @returns {string} The validated path
116
+ * @param {string} configPath - The config file path to validate (relative or absolute)
117
+ * @throws {Error} If validation fails (invalid extension, too deep, missing file, etc.)
118
+ * @returns {string} Absolute path to validated config file
104
119
  */
105
- export function validatePath(path) {
106
- // Check for directory traversal patterns
107
- if (path.includes('..')) {
120
+ export function validateConfigPath(configPath) {
121
+ // 1. Whitelist config file extensions (defense in depth)
122
+ const allowedExtensions = ['.json', '.js', '.cjs', '.mjs', '.yaml', '.yml', '.toml'];
123
+ const ext = extname(configPath).toLowerCase();
124
+
125
+ if (!allowedExtensions.includes(ext)) {
126
+ throw new Error(
127
+ `Invalid config file extension: "${ext}"\n` +
128
+ `Allowed: ${allowedExtensions.join(', ')}\n` +
129
+ `This restriction prevents reading non-config files.`
130
+ );
131
+ }
132
+
133
+ // 2. Limit parent directory traversal depth (max 5 levels for monorepo support)
134
+ const upwardLevels = (configPath.match(/\.\.\//g) || []).length;
135
+ if (upwardLevels > 5) {
136
+ throw new Error(
137
+ `Too many parent directory references: ${upwardLevels}\n` +
138
+ `Maximum allowed: 5 levels (../../../../../../)\n` +
139
+ `This prevents accidental access to system directories.`
140
+ );
141
+ }
142
+
143
+ // 3. Reject absolute paths from CLI (could reference system files)
144
+ if (isAbsolute(configPath)) {
145
+ throw new Error(
146
+ `Absolute paths not allowed: "${configPath}"\n` +
147
+ `Use relative paths from your project directory.\n` +
148
+ `For monorepos, use parent references like ../../config.json`
149
+ );
150
+ }
151
+
152
+ // 4. Resolve to absolute path and validate file exists
153
+ const resolved = resolve(process.cwd(), configPath);
154
+
155
+ if (!existsSync(resolved)) {
108
156
  throw new Error(
109
- `Path contains directory traversal pattern (..) which is not allowed: "${path}"`
157
+ `Config file not found: "${configPath}"\n` +
158
+ `Resolved to: ${resolved}\n` +
159
+ `Check that the file exists and the path is correct.`
110
160
  );
111
161
  }
112
162
 
113
- // Check for absolute paths (we expect relative paths)
114
- if (path.startsWith('/') || /^[a-zA-Z]:/.test(path)) {
163
+ // 5. Validate it's a file (not a directory or symlink to avoid confusion)
164
+ const stats = statSync(resolved);
165
+ if (!stats.isFile()) {
115
166
  throw new Error(
116
- `Absolute paths are not allowed: "${path}"`
167
+ `Config path must be a file, not a directory: "${configPath}"\n` +
168
+ `Resolved to: ${resolved}`
117
169
  );
118
170
  }
119
171
 
120
- return path;
172
+ return resolved;
121
173
  }
@@ -13,8 +13,8 @@
13
13
  * Git configuration defaults
14
14
  */
15
15
  export const GIT_DEFAULTS = {
16
- COMMIT_MESSAGE: 'release: bump v${version}',
17
- HOTFIX_COMMIT_MESSAGE: 'hotfix: bump v${version}',
16
+ COMMIT_MESSAGE: 'chore(release): v${version}',
17
+ HOTFIX_COMMIT_MESSAGE: 'chore(hotfix): v${version}',
18
18
  TAG_NAME: 'v${version}',
19
19
  REQUIRE_BRANCH: 'main',
20
20
  REMOTE: 'origin',
@@ -22,7 +22,9 @@ export const GIT_DEFAULTS = {
22
22
 
23
23
  /**
24
24
  * Default git changelog command
25
- * Filters out commits matching release/hotfix/ci patterns
25
+ * Filters out commits matching release/hotfix/ci patterns, in both
26
+ * legacy form (`release: bump ...`) and Conventional Commits form
27
+ * (`chore(release): ...`, `chore(hotfix): ...`).
26
28
  */
27
29
  export const DEFAULT_CHANGELOG_COMMAND = [
28
30
  'git log',
@@ -36,6 +38,9 @@ export const DEFAULT_CHANGELOG_COMMAND = [
36
38
  '--grep="^Hotfix"',
37
39
  '--grep="^ci"',
38
40
  '--grep="^CI"',
41
+ '--grep="^chore(release)"',
42
+ '--grep="^chore(hotfix)"',
43
+ '--grep="^chore(ci)"',
39
44
  '--invert-grep',
40
45
  ].join(' ');
41
46
 
@@ -15,6 +15,7 @@
15
15
  import { execSync } from 'node:child_process';
16
16
  import { existsSync, readFileSync } from 'node:fs';
17
17
  import { getGitHubRepoUrl } from './lib/git-utils.js';
18
+ import { runScript } from './lib/run-script.js';
18
19
  export function safeExec(command, deps) {
19
20
  try {
20
21
  return deps.execSync(command, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
@@ -256,7 +257,7 @@ export function displayNpmStatus(deps, npmUsername) {
256
257
  */
257
258
  /* c8 ignore start */
258
259
  if (import.meta.url === `file://${process.argv[1]}`) {
259
- async function main() {
260
+ void runScript({ error: console.error, exit: process.exit }, async () => {
260
261
  console.log('🔍 Checking release configuration and project status...');
261
262
  const deps = {
262
263
  execSync,
@@ -276,10 +277,6 @@ if (import.meta.url === `file://${process.argv[1]}`) {
276
277
  console.log('\n✨ Check complete!');
277
278
  console.log('\nTo validate release readiness, run:');
278
279
  console.log(' pnpm release-it-preset validate\n');
279
- }
280
- main().catch((error) => {
281
- console.error('❌ Check failed:', error);
282
- process.exit(1);
283
280
  });
284
281
  }
285
282
  /* c8 ignore end */
@@ -13,6 +13,7 @@
13
13
  import { execSync } from 'node:child_process';
14
14
  import { appendFileSync } from 'node:fs';
15
15
  import { STRICT_CONVENTIONAL_COMMIT_REGEX } from './lib/commit-parser.js';
16
+ import { runScript } from './lib/run-script.js';
16
17
  const SKIP_CHANGELOG_REGEX = /\[skip-changelog]/i;
17
18
  export function safeExec(command, deps) {
18
19
  try {
@@ -155,10 +156,12 @@ export function renderSummary(result, deps) {
155
156
  }
156
157
  /* c8 ignore start */
157
158
  if (import.meta.url === `file://${process.argv[1]}`) {
158
- const deps = createDefaultDeps();
159
- const args = parseArgs(process.argv.slice(2));
160
- const result = runPrCheck(args, deps);
161
- writeOutputs(result, deps);
162
- renderSummary(result, deps);
159
+ void runScript({ error: console.error, exit: process.exit }, () => {
160
+ const deps = createDefaultDeps();
161
+ const args = parseArgs(process.argv.slice(2));
162
+ const result = runPrCheck(args, deps);
163
+ writeOutputs(result, deps);
164
+ renderSummary(result, deps);
165
+ });
163
166
  }
164
167
  /* c8 ignore end */
@@ -18,6 +18,7 @@ import { readFileSync } from 'node:fs';
18
18
  import { join } from 'node:path';
19
19
  import { validateAndNormalizeSemver } from './lib/semver-utils.js';
20
20
  import { escapeRegExp } from './lib/string-utils.js';
21
+ import { runScript } from './lib/run-script.js';
21
22
  export function extractChangelog(version, deps) {
22
23
  // Validate semver format
23
24
  const normalizedVersion = validateAndNormalizeSemver(version);
@@ -45,22 +46,19 @@ export function extractChangelog(version, deps) {
45
46
  */
46
47
  /* c8 ignore start */
47
48
  if (import.meta.url === `file://${process.argv[1]}`) {
48
- const version = process.argv[2];
49
- if (!version) {
50
- console.error('Usage: tsx scripts/extract-changelog.ts <version>');
51
- process.exit(1);
52
- }
53
- try {
49
+ void runScript({ error: console.error, exit: process.exit }, () => {
50
+ const version = process.argv[2];
51
+ if (!version) {
52
+ console.error('Usage: tsx scripts/extract-changelog.ts <version>');
53
+ process.exit(1);
54
+ return;
55
+ }
54
56
  const result = extractChangelog(version, {
55
57
  readFileSync,
56
58
  getEnv: (key) => process.env[key],
57
59
  getCwd: () => process.cwd(),
58
60
  });
59
61
  console.log(result);
60
- }
61
- catch (error) {
62
- console.error(`❌ ${error instanceof Error ? error.message : error}`);
63
- process.exit(1);
64
- }
62
+ });
65
63
  }
66
64
  /* c8 ignore end */
@@ -15,6 +15,7 @@
15
15
  */
16
16
  import { existsSync, readFileSync, writeFileSync } from 'node:fs';
17
17
  import { createInterface } from 'node:readline';
18
+ import { runScript } from './lib/run-script.js';
18
19
  const CHANGELOG_TEMPLATE = `# Changelog
19
20
 
20
21
  All notable changes to this project will be documented in this file.
@@ -163,29 +164,28 @@ export async function initProject(options, deps) {
163
164
  */
164
165
  /* c8 ignore start */
165
166
  if (import.meta.url === `file://${process.argv[1]}`) {
166
- async function realPrompt(question) {
167
- const rl = createInterface({
168
- input: process.stdin,
169
- output: process.stdout,
170
- });
171
- return new Promise((resolve) => {
172
- rl.question(`${question} (y/N): `, (answer) => {
173
- rl.close();
174
- resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes');
167
+ void runScript({ error: console.error, exit: process.exit }, async () => {
168
+ async function realPrompt(question) {
169
+ const rl = createInterface({
170
+ input: process.stdin,
171
+ output: process.stdout,
172
+ });
173
+ return new Promise((resolve) => {
174
+ rl.question(`${question} (y/N): `, (answer) => {
175
+ rl.close();
176
+ resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes');
177
+ });
175
178
  });
179
+ }
180
+ const options = parseArgs();
181
+ await initProject(options, {
182
+ existsSync,
183
+ readFileSync,
184
+ writeFileSync,
185
+ prompt: realPrompt,
186
+ log: console.log,
187
+ warn: console.warn,
176
188
  });
177
- }
178
- const options = parseArgs();
179
- initProject(options, {
180
- existsSync,
181
- readFileSync,
182
- writeFileSync,
183
- prompt: realPrompt,
184
- log: console.log,
185
- warn: console.warn,
186
- }).catch((error) => {
187
- console.error('❌ Initialization failed:', error);
188
- process.exit(1);
189
189
  });
190
190
  }
191
191
  /* c8 ignore end */
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Typed error hierarchy for script-level failures.
3
+ *
4
+ * Each subclass maps to a specific CLI exit code so that callers and
5
+ * the `runScript()` wrapper can surface the right exit code without
6
+ * inspecting raw strings.
7
+ *
8
+ * Usage:
9
+ * throw new ValidationError('CHANGELOG.md not found')
10
+ * // → exits with code 2
11
+ *
12
+ * throw new GitError('git tag not found')
13
+ * // → exits with code 1
14
+ */
15
+ /**
16
+ * Base class for all script-level errors that map to a CLI exit code.
17
+ * Throw a subclass to signal a specific failure mode; uncaught throws
18
+ * fall through to exit code 1 via runScript().
19
+ */
20
+ export class ScriptError extends Error {
21
+ exitCode;
22
+ constructor(message, options) {
23
+ super(message, options?.cause !== undefined ? { cause: options.cause } : undefined);
24
+ this.name = this.constructor.name;
25
+ this.exitCode = options?.exitCode ?? 1;
26
+ }
27
+ }
28
+ /** Pre-flight or input-validation failure (e.g. missing CHANGELOG, wrong branch, dirty tree). Exit 2. */
29
+ export class ValidationError extends ScriptError {
30
+ constructor(message, options) {
31
+ super(message, { exitCode: 2, cause: options?.cause });
32
+ }
33
+ }
34
+ /** Git operation failure (tag not found, command non-zero, parse error on git output). Exit 1. */
35
+ export class GitError extends ScriptError {
36
+ constructor(message, options) {
37
+ super(message, { exitCode: 1, cause: options?.cause });
38
+ }
39
+ }
40
+ /** Changelog parse/write failure (malformed file, missing [Unreleased], etc.). Exit 1. */
41
+ export class ChangelogError extends ScriptError {
42
+ constructor(message, options) {
43
+ super(message, { exitCode: 1, cause: options?.cause });
44
+ }
45
+ }
@@ -0,0 +1,28 @@
1
+ import { ScriptError } from './errors.js';
2
+ /**
3
+ * Wraps a script's main function with consistent error handling and
4
+ * exit-code mapping. ScriptError subclasses exit with their specific
5
+ * exitCode; any other thrown value exits 1 with a generic message.
6
+ *
7
+ * The function may be sync or async. Returns a promise that resolves
8
+ * before exit() is called for the failure path; on success the promise
9
+ * simply resolves (exit() is not invoked — caller can let process end
10
+ * naturally with code 0).
11
+ */
12
+ export async function runScript(deps, fn) {
13
+ try {
14
+ await fn();
15
+ }
16
+ catch (err) {
17
+ if (err instanceof ScriptError) {
18
+ deps.error(`❌ ${err.message}`);
19
+ deps.exit(err.exitCode);
20
+ }
21
+ if (err instanceof Error) {
22
+ deps.error(`❌ Unexpected error: ${err.message}`);
23
+ deps.exit(1);
24
+ }
25
+ deps.error(`❌ Unknown error: ${String(err)}`);
26
+ deps.exit(1);
27
+ }
28
+ }
@@ -21,6 +21,7 @@ import { execSync } from 'node:child_process';
21
21
  import { readFileSync, writeFileSync } from 'node:fs';
22
22
  import { getGitHubRepoUrl } from './lib/git-utils.js';
23
23
  import { CONVENTIONAL_COMMIT_REGEX } from './lib/commit-parser.js';
24
+ import { runScript } from './lib/run-script.js';
24
25
  /**
25
26
  * Extract all conventional commit patterns from a commit body
26
27
  */
@@ -98,7 +99,14 @@ export function parseCommitsWithMultiplePrefixes(gitOutput, repoUrl) {
98
99
  const firstLine = body.split('\n')[0].trim();
99
100
  if (firstLine) {
100
101
  const lowerFirstLine = firstLine.toLowerCase();
101
- const ignoredPatterns = [/^release\b/, /^hotfix\b/, /^ci\b/];
102
+ const ignoredPatterns = [
103
+ /^release\b/,
104
+ /^hotfix\b/,
105
+ /^ci\b/,
106
+ /^chore\(release\)/i,
107
+ /^chore\(hotfix\)/i,
108
+ /^chore\(ci\)/i,
109
+ ];
102
110
  const shouldSkip = ignoredPatterns.some((pattern) => pattern.test(lowerFirstLine));
103
111
  if (shouldSkip) {
104
112
  continue;
@@ -223,20 +231,14 @@ export function populateChangelog(deps) {
223
231
  */
224
232
  /* c8 ignore start */
225
233
  if (import.meta.url === `file://${process.argv[1]}`) {
226
- try {
227
- populateChangelog({
228
- execSync,
229
- readFileSync,
230
- writeFileSync,
231
- getEnv: (key) => process.env[key],
232
- log: console.log,
233
- warn: console.warn,
234
- error: console.error,
235
- });
236
- }
237
- catch (error) {
238
- console.error('❌ Failed to populate [Unreleased] section:', error);
239
- process.exit(1);
240
- }
234
+ void runScript({ error: console.error, exit: process.exit }, () => populateChangelog({
235
+ execSync,
236
+ readFileSync,
237
+ writeFileSync,
238
+ getEnv: (key) => process.env[key],
239
+ log: console.log,
240
+ warn: console.warn,
241
+ error: console.error,
242
+ }));
241
243
  }
242
244
  /* c8 ignore end */
@@ -24,6 +24,7 @@ import { execSync } from 'node:child_process';
24
24
  import { getGitHubRepoUrl } from './lib/git-utils.js';
25
25
  import { escapeRegExp } from './lib/string-utils.js';
26
26
  import { validateAndNormalizeSemver } from './lib/semver-utils.js';
27
+ import { runScript } from './lib/run-script.js';
27
28
  export function updateReferenceLinks(changelog, versionLabels, linkTarget, unreleasedLine) {
28
29
  const lines = changelog.split(/\r?\n/);
29
30
  const updatedLines = [];
@@ -162,26 +163,19 @@ export function republishChangelog(version, deps) {
162
163
  */
163
164
  /* c8 ignore start */
164
165
  if (import.meta.url === `file://${process.argv[1]}`) {
165
- async function main() {
166
- try {
167
- const pkg = await import(join(process.cwd(), 'package.json'), { with: { type: 'json' } });
168
- const version = pkg.default.version;
169
- republishChangelog(version, {
170
- execSync,
171
- readFileSync,
172
- writeFileSync,
173
- getEnv: (key) => process.env[key],
174
- getCwd: () => process.cwd(),
175
- getDate: () => new Date().toISOString().split('T')[0],
176
- log: console.log,
177
- warn: console.warn,
178
- });
179
- }
180
- catch (error) {
181
- console.error(`❌ ${error instanceof Error ? error.message : error}`);
182
- process.exit(1);
183
- }
184
- }
185
- main();
166
+ void runScript({ error: console.error, exit: process.exit }, async () => {
167
+ const pkg = await import(join(process.cwd(), 'package.json'), { with: { type: 'json' } });
168
+ const version = pkg.default.version;
169
+ republishChangelog(version, {
170
+ execSync,
171
+ readFileSync,
172
+ writeFileSync,
173
+ getEnv: (key) => process.env[key],
174
+ getCwd: () => process.cwd(),
175
+ getDate: () => new Date().toISOString().split('T')[0],
176
+ log: console.log,
177
+ warn: console.warn,
178
+ });
179
+ });
186
180
  }
187
181
  /* c8 ignore end */
@@ -17,6 +17,7 @@
17
17
  */
18
18
  import { execSync } from 'node:child_process';
19
19
  import { readFileSync } from 'node:fs';
20
+ import { runScript } from './lib/run-script.js';
20
21
  export function retryPublish(deps) {
21
22
  deps.log('🔄 Starting retry publish process...');
22
23
  const currentBranch = deps.execSync('git rev-parse --abbrev-ref HEAD', { encoding: 'utf8' }).trim();
@@ -82,17 +83,13 @@ export function retryPublish(deps) {
82
83
  */
83
84
  /* c8 ignore start */
84
85
  if (import.meta.url === `file://${process.argv[1]}`) {
85
- try {
86
+ void runScript({ error: console.error, exit: process.exit }, () => {
86
87
  retryPublish({
87
88
  execSync,
88
89
  readFileSync,
89
90
  log: console.log,
90
91
  warn: console.warn,
91
92
  });
92
- }
93
- catch (error) {
94
- console.error(`❌ Pre-flight checks failed: ${error instanceof Error ? error.message : error}`);
95
- process.exit(1);
96
- }
93
+ });
97
94
  }
98
95
  /* c8 ignore end */
@@ -18,6 +18,8 @@
18
18
  */
19
19
  import { execSync } from 'node:child_process';
20
20
  import { existsSync, readFileSync } from 'node:fs';
21
+ import { ValidationError } from './lib/errors.js';
22
+ import { runScript } from './lib/run-script.js';
21
23
  export function parseArgs(argv) {
22
24
  return {
23
25
  allowDirty: argv.includes('--allow-dirty'),
@@ -248,7 +250,7 @@ export function validateRelease(deps, options) {
248
250
  */
249
251
  /* c8 ignore start */
250
252
  if (import.meta.url === `file://${process.argv[1]}`) {
251
- async function main() {
253
+ void runScript({ error: console.error, exit: process.exit }, async () => {
252
254
  const options = parseArgs(process.argv.slice(2));
253
255
  console.log('🔍 Validating release readiness...\n');
254
256
  const deps = {
@@ -273,16 +275,10 @@ if (import.meta.url === `file://${process.argv[1]}`) {
273
275
  console.log();
274
276
  if (allPassed) {
275
277
  console.log('✨ All validations passed! Ready to release.');
276
- process.exit(0);
277
278
  }
278
279
  else {
279
- console.log('Some validations failed. Please fix the issues above before releasing.');
280
- process.exit(1);
280
+ throw new ValidationError('Some validations failed. Please fix the issues above before releasing.');
281
281
  }
282
- }
283
- main().catch((error) => {
284
- console.error('❌ Validation failed:', error);
285
- process.exit(1);
286
282
  });
287
283
  }
288
284
  /* c8 ignore end */
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Typed error hierarchy for script-level failures.
3
+ *
4
+ * Each subclass maps to a specific CLI exit code so that callers and
5
+ * the `runScript()` wrapper can surface the right exit code without
6
+ * inspecting raw strings.
7
+ *
8
+ * Usage:
9
+ * throw new ValidationError('CHANGELOG.md not found')
10
+ * // → exits with code 2
11
+ *
12
+ * throw new GitError('git tag not found')
13
+ * // → exits with code 1
14
+ */
15
+ /**
16
+ * Base class for all script-level errors that map to a CLI exit code.
17
+ * Throw a subclass to signal a specific failure mode; uncaught throws
18
+ * fall through to exit code 1 via runScript().
19
+ */
20
+ export declare class ScriptError extends Error {
21
+ readonly exitCode: number;
22
+ constructor(message: string, options?: {
23
+ exitCode?: number;
24
+ cause?: unknown;
25
+ });
26
+ }
27
+ /** Pre-flight or input-validation failure (e.g. missing CHANGELOG, wrong branch, dirty tree). Exit 2. */
28
+ export declare class ValidationError extends ScriptError {
29
+ constructor(message: string, options?: {
30
+ cause?: unknown;
31
+ });
32
+ }
33
+ /** Git operation failure (tag not found, command non-zero, parse error on git output). Exit 1. */
34
+ export declare class GitError extends ScriptError {
35
+ constructor(message: string, options?: {
36
+ cause?: unknown;
37
+ });
38
+ }
39
+ /** Changelog parse/write failure (malformed file, missing [Unreleased], etc.). Exit 1. */
40
+ export declare class ChangelogError extends ScriptError {
41
+ constructor(message: string, options?: {
42
+ cause?: unknown;
43
+ });
44
+ }
@@ -0,0 +1,15 @@
1
+ export interface RunScriptDeps {
2
+ error: (msg: string) => void;
3
+ exit: (code: number) => never;
4
+ }
5
+ /**
6
+ * Wraps a script's main function with consistent error handling and
7
+ * exit-code mapping. ScriptError subclasses exit with their specific
8
+ * exitCode; any other thrown value exits 1 with a generic message.
9
+ *
10
+ * The function may be sync or async. Returns a promise that resolves
11
+ * before exit() is called for the failure path; on success the promise
12
+ * simply resolves (exit() is not invoked — caller can let process end
13
+ * naturally with code 0).
14
+ */
15
+ export declare function runScript(deps: RunScriptDeps, fn: () => void | Promise<void>): Promise<void>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oorabona/release-it-preset",
3
- "version": "0.8.1",
3
+ "version": "0.10.1",
4
4
  "description": "Shared release-it configuration and scripts for the organisation",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -69,23 +69,25 @@
69
69
  "test:unit": "vitest run tests/unit/*.test.ts",
70
70
  "test:unit:watch": "vitest tests/unit/*.test.ts",
71
71
  "test:unit:coverage": "vitest run tests/unit/*.test.ts --coverage",
72
+ "test:e2e": "vitest run tests/e2e/*.test.ts --testTimeout=30000",
72
73
  "test:watch": "vitest",
73
74
  "test:coverage": "vitest run --coverage",
74
75
  "test:ui": "vitest --ui",
75
76
  "prepublishOnly": "pnpm build && echo 'Running prepublish checks...' && test -f README.md && test -f LICENSE"
76
77
  },
77
78
  "peerDependencies": {
78
- "release-it": "^19.0.0"
79
+ "release-it": "^20.0.0"
79
80
  },
80
81
  "devDependencies": {
81
- "@biomejs/biome": "^2.2.5",
82
- "@types/node": "^24.6.2",
83
- "@vitest/coverage-v8": "^3.2.4",
84
- "nano-staged": "^0.8.0",
85
- "rimraf": "^6.0.1",
86
- "tsx": "^4.20.6",
87
- "typescript": "^5.7.3",
88
- "vitest": "^3.2.4"
82
+ "@biomejs/biome": "^2.4.13",
83
+ "@types/node": "^25.6.0",
84
+ "@vitest/coverage-v8": "^4.1.5",
85
+ "nano-staged": "^1.0.2",
86
+ "release-it": "^20.0.1",
87
+ "rimraf": "^6.1.3",
88
+ "tsx": "^4.21.0",
89
+ "typescript": "^6.0.3",
90
+ "vitest": "^4.1.5"
89
91
  },
90
92
  "engines": {
91
93
  "node": ">=18.0.0"