@oorabona/release-it-preset 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -575,6 +575,12 @@ pnpm release-it-preset init
575
575
 
576
576
  # Non-interactive mode (skip prompts, use defaults)
577
577
  pnpm release-it-preset init --yes
578
+
579
+ # Also scaffold a GitHub Actions publish workflow
580
+ pnpm release-it-preset init --yes --with-workflows
581
+
582
+ # Use a custom workflow filename (default: release.yml)
583
+ pnpm release-it-preset init --yes --with-workflows --workflow-name=publish.yml
578
584
  ```
579
585
 
580
586
  **What it does:**
@@ -896,7 +902,7 @@ pnpm release-it-preset default
896
902
  - The `extends` field loads the preset
897
903
  - release-it merges your overrides on top via c12
898
904
  - **Your values take precedence** over preset defaults
899
- - CLI validates that `extends` matches the command
905
+ - CLI validates that `extends` is present; mismatched preset name warns and uses the invoked preset's config for that run
900
906
 
901
907
  **Pros:**
902
908
  - ✅ **Recommended for customization**
@@ -951,7 +957,7 @@ pnpm release-it-preset default
951
957
 
952
958
  **Why `extends` is required:** Without it, release-it only loads your config file and uses release-it's own defaults. The preset is never loaded, so you lose important defaults like `npm.publish: false`.
953
959
 
954
- #### Error 2: Preset mismatch
960
+ #### Note: Preset mismatch (warning, not error)
955
961
 
956
962
  ```bash
957
963
  # .release-it.json extends "default":
@@ -962,15 +968,14 @@ pnpm release-it-preset default
962
968
  # But you run:
963
969
  pnpm release-it-preset hotfix
964
970
 
965
- # Configuration mismatch error!
966
- # CLI preset: hotfix
967
- # .release-it.json extends: default
968
- #
969
- # Either:
970
- # 1. Run: release-it-preset default
971
- # 2. Update .release-it.json extends to: "@oorabona/release-it-preset/config/hotfix"
971
+ # ⚠️ Note: your .release-it.json extends "default"
972
+ # but you invoked the "hotfix" preset.
973
+ # Using "hotfix" config directly; .release-it.json customizations are ignored for this run.
974
+ # To use your customizations, run: release-it-preset default
972
975
  ```
973
976
 
977
+ > **Note**: invoking a preset different from your `.release-it.json` extends value now warns and uses the invoked preset's config (was: hard error). Use the matching name to keep your customizations.
978
+
974
979
  ---
975
980
 
976
981
  ### Which Mode Should I Use?
package/bin/cli.js CHANGED
@@ -160,21 +160,19 @@ function handleReleaseCommand(configName, args) {
160
160
  const extendsPreset = extendsMatch?.[1];
161
161
 
162
162
  if (extendsPreset && extendsPreset !== configName) {
163
- console.error(`\n❌ Configuration mismatch error!`);
164
- console.error(` CLI preset: ${configName}`);
165
- console.error(` .release-it.json extends: ${extendsPreset}`);
166
- console.error(``);
167
- console.error(`Either:`);
168
- console.error(` 1. Run: release-it-preset ${extendsPreset}`);
169
- console.error(` → Use the preset specified in your config file`);
170
- console.error(``);
171
- console.error(` 2. Update .release-it.json extends to: "${expectedExtends}"`);
172
- console.error(` → Match your config file to the CLI command\n`);
173
- process.exit(1);
163
+ console.warn(`⚠️ Note: your .release-it.json extends "${extendsPreset}"`);
164
+ console.warn(` but you invoked the "${configName}" preset.`);
165
+ console.warn(` Using "${configName}" config directly; .release-it.json customizations are ignored for this run.`);
166
+ console.warn(` To use your customizations, run: release-it-preset ${extendsPreset}`);
167
+ console.warn(``);
168
+ // Force --config flag to use OUR preset, ignore user's .release-it.json
169
+ fullArgs = ['--config', configPath, ...args];
170
+ } else {
171
+ console.log(`✅ Config validated: preset "${configName}"`);
172
+ console.log(`📝 Using: ${userConfigPath}\n`);
173
+ // Let release-it discover .release-it.json and merge via extends
174
+ fullArgs = [...args];
174
175
  }
175
-
176
- console.log(`✅ Config validated: preset "${configName}"`);
177
- console.log(`📝 Using: ${userConfigPath}\n`);
178
176
  } catch (error) {
179
177
  if (error instanceof SyntaxError) {
180
178
  console.error(`❌ Failed to parse .release-it.json: ${error.message}`);
@@ -183,9 +181,6 @@ function handleReleaseCommand(configName, args) {
183
181
  }
184
182
  process.exit(1);
185
183
  }
186
-
187
- // Let release-it discover .release-it.json and merge via extends
188
- fullArgs = [...args];
189
184
  } else {
190
185
  // No user config - use preset directly
191
186
  console.log(`📝 Using preset config directly: ${configPath}`);
package/bin/validators.js CHANGED
@@ -117,6 +117,40 @@ export function sanitizeArgs(args) {
117
117
  * @throws {Error} If validation fails (invalid extension, too deep, missing file, etc.)
118
118
  * @returns {string} Absolute path to validated config file
119
119
  */
120
+
121
+ /**
122
+ * Validates a workflow filename for use with `init --workflow-name`.
123
+ *
124
+ * Allowed: simple filenames matching ^[A-Za-z0-9._-]+\.ya?ml$
125
+ * Rejected: path components (subdir/), traversal (../), wrong extension.
126
+ *
127
+ * @param {string} name - The workflow filename to validate
128
+ * @throws {Error} If the name contains path separators, traversal, or wrong extension
129
+ * @returns {string} The validated filename
130
+ */
131
+ export function validateWorkflowName(name) {
132
+ if (!name || name.length === 0) {
133
+ throw new Error(
134
+ `Workflow name cannot be empty.\n` +
135
+ `Expected a simple filename like "release.yml" or "publish.yml".`
136
+ );
137
+ }
138
+
139
+ // Reject any path separators or traversal — name must be a single filename component
140
+ const WORKFLOW_NAME_RE = /^[A-Za-z0-9._-]+\.ya?ml$/;
141
+ if (!WORKFLOW_NAME_RE.test(name)) {
142
+ throw new Error(
143
+ `Invalid workflow name: "${name}"\n` +
144
+ `Workflow name must be a simple filename matching ^[A-Za-z0-9._-]+\\.ya?ml$\n` +
145
+ `Examples: release.yml, publish.yml, release_it.yml\n` +
146
+ `Path components (subdir/foo.yml) and traversal (../etc.yml) are not allowed.`
147
+ );
148
+ }
149
+
150
+ return name;
151
+ }
152
+
153
+
120
154
  export function validateConfigPath(configPath) {
121
155
  // 1. Whitelist config file extensions (defense in depth)
122
156
  const allowedExtensions = ['.json', '.js', '.cjs', '.mjs', '.yaml', '.yml', '.toml'];
@@ -13,9 +13,27 @@
13
13
  * Options:
14
14
  * --yes Skip prompts and use defaults
15
15
  */
16
- import { existsSync, readFileSync, writeFileSync } from 'node:fs';
16
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
17
17
  import { createInterface } from 'node:readline';
18
+ import { dirname, join } from 'node:path';
19
+ import { fileURLToPath } from 'node:url';
18
20
  import { runScript } from './lib/run-script.js';
21
+ import { parsePnpmWorkspaceYaml, parseWorkspacesFromPackageJson, resolvePackagePaths, } from './lib/workspace-detect.js';
22
+ import { ValidationError } from './lib/errors.js';
23
+ // Single source of truth for workflow name validation within this file.
24
+ // NOTE: bin/validators.js cannot be imported here — TypeScript compiles scripts/ to
25
+ // dist/scripts/ so a relative '../bin/' import resolves to 'dist/bin/' at runtime
26
+ // (wrong). The canonical regex lives in bin/validators.js:validateWorkflowName and
27
+ // is kept in sync here manually (same pattern: ^[A-Za-z0-9._-]+\.ya?ml$).
28
+ const WORKFLOW_NAME_REGEX = /^[A-Za-z0-9._-]+\.ya?ml$/;
29
+ function assertValidWorkflowName(name) {
30
+ if (!WORKFLOW_NAME_REGEX.test(name)) {
31
+ throw new ValidationError(`Invalid workflow name: "${name}"\n` +
32
+ `Workflow name must match [A-Za-z0-9._-]+\\.ya?ml (no path components, no traversal).\n` +
33
+ `Examples: release.yml, publish.yml\n` +
34
+ `Path components and traversal (../etc.yml) are not allowed.`);
35
+ }
36
+ }
19
37
  const CHANGELOG_TEMPLATE = `# Changelog
20
38
 
21
39
  All notable changes to this project will be documented in this file.
@@ -34,6 +52,9 @@ const RELEASE_IT_CONFIG = `{
34
52
  `;
35
53
  const SUGGESTED_SCRIPTS = {
36
54
  'release': 'release-it-preset default',
55
+ 'release:patch': 'release-it-preset default patch',
56
+ 'release:minor': 'release-it-preset default minor',
57
+ 'release:major': 'release-it-preset default major',
37
58
  'release:hotfix': 'release-it-preset hotfix',
38
59
  'release:dry': 'release-it-preset default --dry-run',
39
60
  'changelog:update': 'release-it-preset update',
@@ -41,8 +62,13 @@ const SUGGESTED_SCRIPTS = {
41
62
  export function parseArgs(args) {
42
63
  /* c8 ignore next */
43
64
  const argv = args || process.argv.slice(2);
65
+ // Extract --workflow-name=<value>
66
+ const workflowNameArg = argv.find(a => a.startsWith('--workflow-name='));
67
+ const workflowName = workflowNameArg ? workflowNameArg.slice('--workflow-name='.length) : 'release.yml';
44
68
  return {
45
69
  yes: argv.includes('--yes') || argv.includes('-y'),
70
+ withWorkflows: argv.includes('--with-workflows'),
71
+ workflowName,
46
72
  };
47
73
  }
48
74
  export async function createChangelog(options, deps) {
@@ -132,27 +158,144 @@ export async function updatePackageJson(options, deps) {
132
158
  return false;
133
159
  }
134
160
  }
161
+ /**
162
+ * Write the GitHub Actions workflow file to .github/workflows/<name>.
163
+ * Skips silently if the file already exists (existing skip-on-conflict policy).
164
+ */
165
+ export async function writeWorkflow(options, deps) {
166
+ // Defense-in-depth: validate workflow name before any path computation.
167
+ // The CLI entry point also validates, but programmatic callers (tests, library use) bypass it.
168
+ assertValidWorkflowName(options.workflowName);
169
+ const workflowDir = join('.github', 'workflows');
170
+ const workflowPath = join(workflowDir, options.workflowName);
171
+ if (deps.existsSync(workflowPath)) {
172
+ deps.log(`ℹ️ ${workflowPath} already exists — skipping.`);
173
+ deps.log(` To integrate manually, review the template and merge into your existing workflow.`);
174
+ return false;
175
+ }
176
+ // Resolve template path: try compiled position first, fall back to source position.
177
+ // compiled: dist/scripts/init-project.js → ../../scripts/templates/... (2 hops up)
178
+ // source: scripts/init-project.ts → ./templates/... (sibling dir)
179
+ const __filename = fileURLToPath(import.meta.url);
180
+ const __dirname = dirname(__filename);
181
+ const compiledPath = join(__dirname, '..', '..', 'scripts', 'templates', 'workflows', 'release.yml.template');
182
+ const sourcePath = join(__dirname, 'templates', 'workflows', 'release.yml.template');
183
+ const templatePath = deps.existsSync(compiledPath) ? compiledPath : sourcePath;
184
+ let templateContent;
185
+ try {
186
+ templateContent = deps.readFileSync(templatePath, 'utf8');
187
+ }
188
+ catch (error) {
189
+ throw new ValidationError(`Failed to read workflow template at "${templatePath}".\n` +
190
+ `The template ships in scripts/templates/ — if it is missing, reinstall the package.\n` +
191
+ `Original error: ${error}`);
192
+ }
193
+ // Ensure .github/workflows/ exists
194
+ if (!deps.existsSync(workflowDir)) {
195
+ deps.mkdirSync(workflowDir, { recursive: true });
196
+ }
197
+ deps.writeFileSync(workflowPath, templateContent);
198
+ deps.log(`✅ Created ${workflowPath}`);
199
+ return true;
200
+ }
201
+ /**
202
+ * Detect workspaces from pnpm-workspace.yaml or package.json#workspaces.
203
+ * Returns resolved absolute package directory paths.
204
+ * Returns empty array if no workspace config found.
205
+ * Throws ValidationError if workspace patterns escape the project root.
206
+ */
207
+ export function detectWorkspaces(projectRoot, deps) {
208
+ const pnpmWorkspaceFile = join(projectRoot, 'pnpm-workspace.yaml');
209
+ const packageJsonFile = join(projectRoot, 'package.json');
210
+ let patterns = [];
211
+ let workspaceConfigExists = false;
212
+ if (deps.existsSync(pnpmWorkspaceFile)) {
213
+ workspaceConfigExists = true;
214
+ const content = deps.readFileSync(pnpmWorkspaceFile, 'utf8');
215
+ patterns = parsePnpmWorkspaceYaml(content);
216
+ }
217
+ else if (deps.existsSync(packageJsonFile)) {
218
+ const content = deps.readFileSync(packageJsonFile, 'utf8');
219
+ patterns = parseWorkspacesFromPackageJson(content);
220
+ if (patterns.length > 0) {
221
+ workspaceConfigExists = true;
222
+ }
223
+ }
224
+ if (patterns.length === 0) {
225
+ if (workspaceConfigExists) {
226
+ deps.warn(`ℹ️ Workspace config file found but no packages declared/resolved — ` +
227
+ `treating as single-package init.`);
228
+ }
229
+ return [];
230
+ }
231
+ return resolvePackagePaths(patterns, projectRoot, deps);
232
+ }
233
+ /**
234
+ * Scaffold per-package .release-it.json for each detected workspace package.
235
+ * Skips packages that already have .release-it.json (skip-on-conflict policy).
236
+ * Does NOT write a root .release-it.json (would conflict with per-package configs).
237
+ */
238
+ export async function scaffoldWorkspacePackages(packageDirs, deps) {
239
+ let created = 0;
240
+ for (const pkgDir of packageDirs) {
241
+ const configPath = join(pkgDir, '.release-it.json');
242
+ if (deps.existsSync(configPath)) {
243
+ deps.log(`ℹ️ ${configPath} already exists — skipping`);
244
+ continue;
245
+ }
246
+ deps.writeFileSync(configPath, RELEASE_IT_CONFIG);
247
+ deps.log(`✅ Created ${configPath}`);
248
+ created++;
249
+ }
250
+ return created;
251
+ }
135
252
  export async function initProject(options, deps) {
136
253
  deps.log('🚀 Initializing project with release-it-preset\n');
137
254
  if (options.yes) {
138
255
  deps.log('ℹ️ Running in --yes mode (non-interactive)\n');
139
256
  }
257
+ // Detect monorepo workspaces before deciding what to scaffold
258
+ const projectRoot = process.cwd();
259
+ const workspaceDirs = detectWorkspaces(projectRoot, deps);
260
+ const isMonorepo = workspaceDirs.length > 0;
140
261
  const results = {
141
262
  changelog: await createChangelog(options, deps),
142
- releaseIt: await createReleaseItConfig(options, deps),
143
- packageJson: await updatePackageJson(options, deps),
263
+ // In monorepo mode: per-package configs, NO root .release-it.json
264
+ releaseIt: isMonorepo ? false : await createReleaseItConfig(options, deps),
265
+ packageJson: isMonorepo ? false : await updatePackageJson(options, deps),
266
+ workflow: options.withWorkflows ? await writeWorkflow(options, deps) : false,
267
+ monorepoPackages: isMonorepo ? await scaffoldWorkspacePackages(workspaceDirs, deps) : 0,
144
268
  };
145
269
  deps.log('\n📊 Summary:');
146
270
  deps.log(` CHANGELOG.md: ${results.changelog ? '✅ Created' : '⏭️ Skipped'}`);
147
- deps.log(` .release-it.json: ${results.releaseIt ? '✅ Created' : '⏭️ Skipped'}`);
148
- deps.log(` package.json: ${results.packageJson ? '✅ Updated' : '⏭️ Skipped'}`);
149
- const anyCreated = Object.values(results).some((v) => v);
271
+ if (isMonorepo) {
272
+ deps.log(` workspace packages: ${results.monorepoPackages} .release-it.json created`);
273
+ }
274
+ else {
275
+ deps.log(` .release-it.json: ${results.releaseIt ? '✅ Created' : '⏭️ Skipped'}`);
276
+ deps.log(` package.json: ${results.packageJson ? '✅ Updated' : '⏭️ Skipped'}`);
277
+ }
278
+ if (options.withWorkflows) {
279
+ deps.log(` workflow: ${results.workflow ? '✅ Created' : '⏭️ Skipped'}`);
280
+ }
281
+ const anyCreated = results.changelog ||
282
+ results.releaseIt ||
283
+ results.packageJson ||
284
+ results.workflow ||
285
+ results.monorepoPackages > 0;
150
286
  if (anyCreated) {
151
287
  deps.log('\n🎉 Initialization complete!');
152
288
  deps.log('\nNext steps:');
153
289
  deps.log(' 1. Review the generated files');
154
290
  deps.log(' 2. Update CHANGELOG.md [Unreleased] section');
155
- deps.log(' 3. Run: pnpm release-it-preset default --dry-run');
291
+ if (isMonorepo) {
292
+ deps.log(' 3. Release a package: pnpm -F <package-name> exec release-it-preset default --dry-run');
293
+ deps.log(' (use `pnpm -F <package-name> exec release-it-preset default` to release)');
294
+ deps.log(' Note: per-package CHANGELOG.md is not auto-created — set CHANGELOG_FILE=../CHANGELOG.md per package, or run `release-it-preset update` from the root and copy entries manually.');
295
+ }
296
+ else {
297
+ deps.log(' 3. Run: pnpm release-it-preset default --dry-run');
298
+ }
156
299
  }
157
300
  else {
158
301
  deps.log('\n✨ All files already exist, nothing to do!');
@@ -178,10 +321,18 @@ if (import.meta.url === `file://${process.argv[1]}`) {
178
321
  });
179
322
  }
180
323
  const options = parseArgs();
324
+ // Validate workflow name whenever explicitly provided (even without --with-workflows).
325
+ const argv = process.argv.slice(2);
326
+ const workflowNameExplicit = argv.some(a => a.startsWith('--workflow-name='));
327
+ if (workflowNameExplicit || options.withWorkflows) {
328
+ assertValidWorkflowName(options.workflowName);
329
+ }
181
330
  await initProject(options, {
182
331
  existsSync,
183
332
  readFileSync,
184
333
  writeFileSync,
334
+ mkdirSync,
335
+ readdirSync,
185
336
  prompt: realPrompt,
186
337
  log: console.log,
187
338
  warn: console.warn,
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Workspace detection utilities for pnpm-workspace.yaml and package.json#workspaces.
3
+ *
4
+ * Scope: block-list `packages:` in pnpm-workspace.yaml only.
5
+ * Flow-style arrays ( packages: [...] ) are rejected with a clear error.
6
+ * Block-list of strings supported. Flow-style arrays AND alias references (`*alias`) are
7
+ * rejected with `ValidationError`. Anchors (`&name`) on entries are silently treated as
8
+ * opaque pattern strings.
9
+ *
10
+ * All functions are pure (no direct FS access) — dependencies are injected for testability.
11
+ */
12
+ import { isAbsolute, join, relative, resolve, sep } from 'node:path';
13
+ import { ValidationError } from './errors.js';
14
+ /**
15
+ * Parse the `packages:` block-list section of a pnpm-workspace.yaml file.
16
+ *
17
+ * Only block-sequence form is supported:
18
+ * packages:
19
+ * - 'packages/*'
20
+ * - 'apps/*'
21
+ *
22
+ * Flow-style arrays ( packages: ['a', 'b'] ) are rejected with a ValidationError
23
+ * pointing users toward the block-list form.
24
+ *
25
+ * @param content - Raw YAML file content
26
+ * @returns Array of glob patterns from the packages: block-list, or empty array if no packages: key found
27
+ * @throws ValidationError if flow-style array syntax is detected
28
+ */
29
+ export function parsePnpmWorkspaceYaml(content) {
30
+ const lines = content.split('\n');
31
+ // Find the `packages:` key
32
+ const packagesLineIdx = lines.findIndex(l => /^packages\s*:/.test(l));
33
+ if (packagesLineIdx === -1) {
34
+ return [];
35
+ }
36
+ // Check for unsupported syntax on the same line
37
+ const packagesLine = lines[packagesLineIdx];
38
+ // Flow-style arrays: packages: ['a', 'b'] or packages: [a, b]
39
+ if (/^packages\s*:\s*\[/.test(packagesLine)) {
40
+ throw new ValidationError(`pnpm-workspace.yaml uses flow-style array for "packages:" which is not supported.\n` +
41
+ `Please convert to block-list form:\n` +
42
+ ` packages:\n` +
43
+ ` - 'packages/*'\n` +
44
+ ` - 'apps/*'\n` +
45
+ `If you need flow-style support, open an issue at https://github.com/oorabona/release-it-preset/issues`);
46
+ }
47
+ // YAML alias reference: packages: *anchorName
48
+ if (/^packages\s*:\s*\*\S+/.test(packagesLine)) {
49
+ throw new ValidationError(`pnpm-workspace.yaml uses a YAML alias reference for "packages:" which is not supported.\n` +
50
+ `Please convert to block-list form:\n` +
51
+ ` packages:\n` +
52
+ ` - 'packages/*'\n` +
53
+ ` - 'apps/*'\n` +
54
+ `Anchor/alias support is not planned; if you need it, open an issue at https://github.com/oorabona/release-it-preset/issues`);
55
+ }
56
+ // Collect subsequent indented list items (- '...' or - "..." or - bare)
57
+ const patterns = [];
58
+ for (let i = packagesLineIdx + 1; i < lines.length; i++) {
59
+ const line = lines[i];
60
+ // Stop if we hit a non-indented, non-empty line (new top-level key)
61
+ if (line.length > 0 && !/^\s/.test(line)) {
62
+ break;
63
+ }
64
+ // Match a block-sequence item
65
+ const match = line.match(/^\s+-\s+['"]?([^'"#\n]+?)['"]?\s*(?:#.*)?$/);
66
+ if (match) {
67
+ patterns.push(match[1].trim());
68
+ }
69
+ }
70
+ return patterns;
71
+ }
72
+ /**
73
+ * Parse the `workspaces` field from a package.json content string.
74
+ *
75
+ * Supports:
76
+ * "workspaces": ["packages/*"] — array form
77
+ * "workspaces": {"packages": [...]} — object form (Yarn-style)
78
+ *
79
+ * @param content - Raw package.json content
80
+ * @returns Array of glob patterns, or empty array if workspaces not present
81
+ */
82
+ export function parseWorkspacesFromPackageJson(content) {
83
+ let pkg;
84
+ try {
85
+ pkg = JSON.parse(content);
86
+ }
87
+ catch {
88
+ return [];
89
+ }
90
+ if (typeof pkg !== 'object' || pkg === null || !('workspaces' in pkg)) {
91
+ return [];
92
+ }
93
+ const ws = pkg.workspaces;
94
+ if (Array.isArray(ws)) {
95
+ return ws.filter((v) => typeof v === 'string');
96
+ }
97
+ if (typeof ws === 'object' && ws !== null && 'packages' in ws) {
98
+ const pkgs = ws.packages;
99
+ if (Array.isArray(pkgs)) {
100
+ return pkgs.filter((v) => typeof v === 'string');
101
+ }
102
+ }
103
+ return [];
104
+ }
105
+ /**
106
+ * Expand glob patterns to resolved package directories that contain a package.json.
107
+ *
108
+ * Only supports single-level glob expansion: `packages/*` expands to immediate
109
+ * children of `packages/`. Nested globs (`packages/*\/src`) are not supported
110
+ * and are returned as-is only if the literal path has a package.json.
111
+ *
112
+ * Each resolved path is validated to be contained within projectRoot using
113
+ * path.resolve + path.relative containment check. Paths escaping the root
114
+ * (e.g. `../etc`) throw a ValidationError.
115
+ *
116
+ * @param patterns - Glob patterns from workspace config
117
+ * @param projectRoot - Absolute path to the project root
118
+ * @param deps - Injected FS dependencies
119
+ * @returns Array of absolute paths to valid package directories
120
+ */
121
+ export function resolvePackagePaths(patterns, projectRoot, deps) {
122
+ const result = [];
123
+ for (const pattern of patterns) {
124
+ const resolved = resolve(projectRoot, pattern);
125
+ // Containment check: relative path must NOT start with '..' or be absolute
126
+ // (isAbsolute guard handles Windows cross-drive paths where relative() returns
127
+ // an absolute path that doesn't start with '..', bypassing the startsWith check)
128
+ const rel = relative(projectRoot, resolved);
129
+ if (isAbsolute(rel) || rel === '..' || rel.startsWith(`..${sep}`)) {
130
+ throw new ValidationError(`Workspace pattern "${pattern}" resolves outside the project root.\n` +
131
+ `Resolved: ${resolved}\n` +
132
+ `Project root: ${projectRoot}\n` +
133
+ `Each workspace package must live under the project root.`);
134
+ }
135
+ // Single-level glob expansion: path ends with /*
136
+ if (pattern.endsWith('/*')) {
137
+ const parentDir = resolve(projectRoot, pattern.slice(0, -2));
138
+ // Containment check on the parent dir too (same isAbsolute guard)
139
+ const parentRel = relative(projectRoot, parentDir);
140
+ if (isAbsolute(parentRel) || parentRel === '..' || parentRel.startsWith(`..${sep}`)) {
141
+ throw new ValidationError(`Workspace pattern "${pattern}" resolves outside the project root.\n` +
142
+ `Resolved parent: ${parentDir}\n` +
143
+ `Project root: ${projectRoot}\n` +
144
+ `Each workspace package must live under the project root.`);
145
+ }
146
+ if (!deps.existsSync(parentDir)) {
147
+ continue;
148
+ }
149
+ let entries;
150
+ try {
151
+ entries = deps.readdirSync(parentDir);
152
+ }
153
+ catch {
154
+ continue;
155
+ }
156
+ for (const entry of entries) {
157
+ const pkgDir = join(parentDir, entry);
158
+ const pkgJson = join(pkgDir, 'package.json');
159
+ if (deps.existsSync(pkgJson)) {
160
+ result.push(pkgDir);
161
+ }
162
+ }
163
+ }
164
+ else {
165
+ // Non-glob: treat as literal directory path
166
+ const pkgJson = join(resolved, 'package.json');
167
+ if (deps.existsSync(pkgJson)) {
168
+ result.push(resolved);
169
+ }
170
+ }
171
+ }
172
+ return result;
173
+ }
@@ -13,14 +13,18 @@
13
13
  * Options:
14
14
  * --yes Skip prompts and use defaults
15
15
  */
16
- import { existsSync, readFileSync, writeFileSync } from 'node:fs';
16
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
17
17
  interface Options {
18
18
  yes: boolean;
19
+ withWorkflows: boolean;
20
+ workflowName: string;
19
21
  }
20
22
  export interface InitProjectDeps {
21
23
  existsSync: typeof existsSync;
22
24
  readFileSync: typeof readFileSync;
23
25
  writeFileSync: typeof writeFileSync;
26
+ mkdirSync: typeof mkdirSync;
27
+ readdirSync: typeof readdirSync;
24
28
  prompt: (question: string) => Promise<boolean>;
25
29
  log: (message: string) => void;
26
30
  warn: (message: string) => void;
@@ -29,9 +33,29 @@ export declare function parseArgs(args?: string[]): Options;
29
33
  export declare function createChangelog(options: Options, deps: InitProjectDeps): Promise<boolean>;
30
34
  export declare function createReleaseItConfig(options: Options, deps: InitProjectDeps): Promise<boolean>;
31
35
  export declare function updatePackageJson(options: Options, deps: InitProjectDeps): Promise<boolean>;
36
+ /**
37
+ * Write the GitHub Actions workflow file to .github/workflows/<name>.
38
+ * Skips silently if the file already exists (existing skip-on-conflict policy).
39
+ */
40
+ export declare function writeWorkflow(options: Options, deps: InitProjectDeps): Promise<boolean>;
41
+ /**
42
+ * Detect workspaces from pnpm-workspace.yaml or package.json#workspaces.
43
+ * Returns resolved absolute package directory paths.
44
+ * Returns empty array if no workspace config found.
45
+ * Throws ValidationError if workspace patterns escape the project root.
46
+ */
47
+ export declare function detectWorkspaces(projectRoot: string, deps: InitProjectDeps): string[];
48
+ /**
49
+ * Scaffold per-package .release-it.json for each detected workspace package.
50
+ * Skips packages that already have .release-it.json (skip-on-conflict policy).
51
+ * Does NOT write a root .release-it.json (would conflict with per-package configs).
52
+ */
53
+ export declare function scaffoldWorkspacePackages(packageDirs: string[], deps: InitProjectDeps): Promise<number>;
32
54
  export declare function initProject(options: Options, deps: InitProjectDeps): Promise<{
33
55
  changelog: boolean;
34
56
  releaseIt: boolean;
35
57
  packageJson: boolean;
58
+ workflow: boolean;
59
+ monorepoPackages: number;
36
60
  }>;
37
61
  export {};
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Workspace detection utilities for pnpm-workspace.yaml and package.json#workspaces.
3
+ *
4
+ * Scope: block-list `packages:` in pnpm-workspace.yaml only.
5
+ * Flow-style arrays ( packages: [...] ) are rejected with a clear error.
6
+ * Block-list of strings supported. Flow-style arrays AND alias references (`*alias`) are
7
+ * rejected with `ValidationError`. Anchors (`&name`) on entries are silently treated as
8
+ * opaque pattern strings.
9
+ *
10
+ * All functions are pure (no direct FS access) — dependencies are injected for testability.
11
+ */
12
+ export interface WorkspaceDetectDeps {
13
+ existsSync: (path: string) => boolean;
14
+ readdirSync: (path: string) => string[];
15
+ }
16
+ /**
17
+ * Parse the `packages:` block-list section of a pnpm-workspace.yaml file.
18
+ *
19
+ * Only block-sequence form is supported:
20
+ * packages:
21
+ * - 'packages/*'
22
+ * - 'apps/*'
23
+ *
24
+ * Flow-style arrays ( packages: ['a', 'b'] ) are rejected with a ValidationError
25
+ * pointing users toward the block-list form.
26
+ *
27
+ * @param content - Raw YAML file content
28
+ * @returns Array of glob patterns from the packages: block-list, or empty array if no packages: key found
29
+ * @throws ValidationError if flow-style array syntax is detected
30
+ */
31
+ export declare function parsePnpmWorkspaceYaml(content: string): string[];
32
+ /**
33
+ * Parse the `workspaces` field from a package.json content string.
34
+ *
35
+ * Supports:
36
+ * "workspaces": ["packages/*"] — array form
37
+ * "workspaces": {"packages": [...]} — object form (Yarn-style)
38
+ *
39
+ * @param content - Raw package.json content
40
+ * @returns Array of glob patterns, or empty array if workspaces not present
41
+ */
42
+ export declare function parseWorkspacesFromPackageJson(content: string): string[];
43
+ /**
44
+ * Expand glob patterns to resolved package directories that contain a package.json.
45
+ *
46
+ * Only supports single-level glob expansion: `packages/*` expands to immediate
47
+ * children of `packages/`. Nested globs (`packages/*\/src`) are not supported
48
+ * and are returned as-is only if the literal path has a package.json.
49
+ *
50
+ * Each resolved path is validated to be contained within projectRoot using
51
+ * path.resolve + path.relative containment check. Paths escaping the root
52
+ * (e.g. `../etc`) throw a ValidationError.
53
+ *
54
+ * @param patterns - Glob patterns from workspace config
55
+ * @param projectRoot - Absolute path to the project root
56
+ * @param deps - Injected FS dependencies
57
+ * @returns Array of absolute paths to valid package directories
58
+ */
59
+ export declare function resolvePackagePaths(patterns: string[], projectRoot: string, deps: WorkspaceDetectDeps): string[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oorabona/release-it-preset",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "Release tooling for solo and small-team JS maintainers: human-curated changelogs, OIDC zero-config publishing, recovery presets, monorepo support, pre-release diagnostics.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -53,6 +53,7 @@
53
53
  "config",
54
54
  "dist/scripts",
55
55
  "dist/types",
56
+ "scripts/templates",
56
57
  "README.md",
57
58
  "LICENSE"
58
59
  ],
@@ -61,13 +62,13 @@
61
62
  "release": "pnpm run release:default",
62
63
  "release:default": "node ./bin/cli.js default",
63
64
  "release:default:dry-run": "node ./bin/cli.js default --dry-run",
64
- "release:no-changelog": "node ./bin/cli.js no-changelog",
65
- "release:changelog-only": "node ./bin/cli.js changelog-only",
66
- "release:manual-changelog": "node ./bin/cli.js manual-changelog",
65
+ "release:dev:no-changelog": "node ./bin/cli.js no-changelog",
66
+ "release:dev:changelog-only": "node ./bin/cli.js changelog-only",
67
+ "release:dev:manual-changelog": "node ./bin/cli.js manual-changelog",
67
68
  "release:hotfix": "node ./bin/cli.js hotfix",
68
- "release:republish": "node ./bin/cli.js republish",
69
- "release:retry-preflight": "node ./bin/cli.js retry-publish-preflight",
70
- "release:retry-publish": "node ./bin/cli.js retry-publish",
69
+ "release:dev:republish": "node ./bin/cli.js republish",
70
+ "release:dev:retry-preflight": "node ./bin/cli.js retry-publish-preflight",
71
+ "release:dev:retry-publish": "node ./bin/cli.js retry-publish",
71
72
  "release:update": "node ./bin/cli.js update",
72
73
  "release:validate": "node ./bin/cli.js validate",
73
74
  "release:validate:allow-dirty": "node ./bin/cli.js validate --allow-dirty",
@@ -84,7 +85,12 @@
84
85
  "test:watch": "vitest",
85
86
  "test:coverage": "vitest run --coverage",
86
87
  "test:ui": "vitest --ui",
87
- "prepublishOnly": "pnpm build && echo 'Running prepublish checks...' && test -f README.md && test -f LICENSE"
88
+ "prepublishOnly": "pnpm build && echo 'Running prepublish checks...' && test -f README.md && test -f LICENSE",
89
+ "release:patch": "node ./bin/cli.js default patch",
90
+ "release:minor": "node ./bin/cli.js default minor",
91
+ "release:major": "node ./bin/cli.js default major",
92
+ "release:dry": "node ./bin/cli.js default --dry-run",
93
+ "changelog:update": "node ./bin/cli.js update"
88
94
  },
89
95
  "peerDependencies": {
90
96
  "release-it": "^19.0.0 || ^20.0.0"
@@ -0,0 +1,63 @@
1
+ # GitHub Actions workflow for npm + GitHub release publishing.
2
+ # Generated by: release-it-preset init --with-workflows
3
+ #
4
+ # PREREQUISITE: Configure OIDC trusted publishing for this workflow file at
5
+ # https://www.npmjs.com/settings/<your-org>/packages — select "GitHub Actions"
6
+ # and set the workflow path to match this file's location in the repository.
7
+ # Without OIDC enrollment the publish step will fail with a 401 on first tag push.
8
+ # Troubleshooting: https://docs.npmjs.com/trusted-publishers
9
+
10
+ name: Publish Package
11
+
12
+ on:
13
+ push:
14
+ tags:
15
+ - 'v*'
16
+ workflow_dispatch:
17
+ inputs:
18
+ tag:
19
+ description: 'Tag to publish (e.g. v1.2.3). Required when triggered from a branch.'
20
+ type: string
21
+ required: false
22
+
23
+ permissions:
24
+ contents: write
25
+ id-token: write # Required for npm OIDC trusted publishing
26
+
27
+ env:
28
+ NODE_VERSION: '24' # npm >= 11.5.1 ships with Node 24, required for OIDC trusted publishing
29
+
30
+ jobs:
31
+ publish:
32
+ runs-on: ubuntu-latest
33
+ steps:
34
+ - name: Checkout code
35
+ uses: actions/checkout@v6
36
+ with:
37
+ fetch-depth: 0
38
+ ref: ${{ inputs.tag || github.ref }}
39
+ fetch-tags: true
40
+
41
+ - name: Setup pnpm
42
+ uses: pnpm/action-setup@v6
43
+
44
+ - name: Setup Node.js
45
+ uses: actions/setup-node@v6
46
+ with:
47
+ node-version: ${{ env.NODE_VERSION }}
48
+ cache: 'pnpm'
49
+ registry-url: 'https://registry.npmjs.org'
50
+
51
+ - name: Install dependencies
52
+ run: pnpm install --frozen-lockfile
53
+
54
+ - name: Run pre-flight checks
55
+ run: pnpm exec release-it-preset retry-publish-preflight
56
+
57
+ - name: Publish (npm + GitHub release)
58
+ env:
59
+ NPM_PUBLISH: 'true'
60
+ GITHUB_RELEASE: 'true'
61
+ NPM_SKIP_CHECKS: 'true'
62
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
63
+ run: pnpm exec release-it-preset retry-publish --ci