@i-santos/create-package-starter 1.4.0 → 1.5.0-beta.10

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/lib/run.js CHANGED
@@ -1,38 +1,66 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
3
  const { spawnSync } = require('child_process');
4
+ const readline = require('readline/promises');
4
5
 
5
6
  const CHANGESETS_DEP = '@changesets/cli';
6
7
  const CHANGESETS_DEP_VERSION = '^2.29.7';
7
8
  const DEFAULT_BASE_BRANCH = 'main';
9
+ const DEFAULT_BETA_BRANCH = 'release/beta';
10
+ const DEFAULT_PROMOTE_WORKFLOW = 'promote-stable.yml';
8
11
  const DEFAULT_RULESET_NAME = 'Default main branch protection';
12
+ const REQUIRED_CHECK_CONTEXT = 'required-check';
13
+ const DEFAULT_RELEASE_AUTH = 'pat';
14
+ const RELEASE_AUTH_MODES = new Set(['github-token', 'pat', 'app', 'manual-trigger']);
15
+ const RELEASE_AUTH_APP_REQUIRED_SECRETS = ['GH_APP_PRIVATE_KEY'];
16
+ const RELEASE_AUTH_APP_ID_SECRETS = ['GH_APP_CLIENT_ID', 'GH_APP_ID'];
17
+ const RELEASE_AUTH_DOC_LINKS = {
18
+ overview: 'https://docs.github.com/apps',
19
+ create: 'https://docs.github.com/apps/creating-github-apps/registering-a-github-app/registering-a-github-app',
20
+ install: 'https://docs.github.com/apps/using-github-apps/installing-your-own-github-app',
21
+ secrets: 'https://docs.github.com/actions/security-guides/using-secrets-in-github-actions',
22
+ internal: 'https://github.com/i-santos/package-starter/blob/main/docs/release-auth-github-app.md'
23
+ };
9
24
 
10
25
  const MANAGED_FILE_SPECS = [
11
26
  ['.changeset/config.json', '.changeset/config.json'],
12
27
  ['.changeset/README.md', '.changeset/README.md'],
13
28
  ['.github/workflows/ci.yml', '.github/workflows/ci.yml'],
14
29
  ['.github/workflows/release.yml', '.github/workflows/release.yml'],
30
+ ['.github/workflows/promote-stable.yml', '.github/workflows/promote-stable.yml'],
31
+ ['.github/workflows/auto-retarget-pr.yml', '.github/workflows/auto-retarget-pr.yml'],
15
32
  ['.github/PULL_REQUEST_TEMPLATE.md', '.github/PULL_REQUEST_TEMPLATE.md'],
16
33
  ['.github/CODEOWNERS', '.github/CODEOWNERS'],
17
34
  ['CONTRIBUTING.md', 'CONTRIBUTING.md'],
18
35
  ['README.md', 'README.md'],
19
- ['.gitignore', '.gitignore']
36
+ ['.gitignore', 'gitignore']
20
37
  ];
38
+ const INIT_CREATE_ONLY_FILES = new Set(['README.md', 'CONTRIBUTING.md']);
21
39
 
22
40
  function usage() {
23
41
  return [
24
42
  'Usage:',
25
- ' create-package-starter --name <name> [--out <directory>] [--default-branch <branch>]',
26
- ' create-package-starter init [--dir <directory>] [--force] [--cleanup-legacy-release] [--scope <scope>] [--default-branch <branch>]',
43
+ ' create-package-starter --name <name> [--out <directory>] [--default-branch <branch>] [--release-auth github-token|pat|app|manual-trigger]',
44
+ ' create-package-starter init [--dir <directory>] [--force] [--cleanup-legacy-release] [--scope <scope>] [--default-branch <branch>] [--with-github] [--with-npm] [--with-beta] [--repo <owner/repo>] [--beta-branch <branch>] [--ruleset <path>] [--release-auth github-token|pat|app|manual-trigger] [--dry-run] [--yes]',
27
45
  ' create-package-starter setup-github [--repo <owner/repo>] [--default-branch <branch>] [--ruleset <path>] [--dry-run]',
46
+ ' create-package-starter setup-beta [--dir <directory>] [--repo <owner/repo>] [--beta-branch <branch>] [--default-branch <branch>] [--release-auth github-token|pat|app|manual-trigger] [--force] [--dry-run] [--yes]',
47
+ ' create-package-starter open-pr [--repo <owner/repo>] [--base <branch>] [--head <branch>] [--title <text>] [--body <text>] [--body-file <path>] [--template <path>] [--draft] [--auto-merge] [--watch-checks] [--check-timeout <minutes>] [--yes] [--dry-run]',
48
+ ' create-package-starter release-cycle [--repo <owner/repo>] [--mode auto|open-pr|publish] [--phase code|full] [--track auto|beta|stable] [--promote-stable] [--promote-type patch|minor|major] [--promote-summary <text>] [--head <branch>] [--base <branch>] [--title <text>] [--body-file <path>] [--update-pr-description] [--draft] [--auto-merge] [--watch-checks] [--check-timeout <minutes>] [--confirm-merges] [--merge-when-green] [--merge-method squash|merge|rebase] [--wait-release-pr] [--release-pr-timeout <minutes>] [--merge-release-pr] [--verify-npm] [--confirm-cleanup] [--sync-base auto|rebase|merge|off] [--no-resume] [--no-cleanup] [--yes] [--dry-run]',
49
+ ' create-package-starter promote-stable [--dir <directory>] [--type patch|minor|major] [--summary <text>] [--dry-run]',
28
50
  ' create-package-starter setup-npm [--dir <directory>] [--publish-first] [--dry-run]',
29
51
  '',
30
52
  'Examples:',
31
53
  ' create-package-starter --name hello-package',
32
- ' create-package-starter --name @i-santos/swarm --out ./packages',
54
+ ' create-package-starter --name @i-santos/swarm --out ./packages --release-auth pat',
33
55
  ' create-package-starter init --dir ./my-package',
34
56
  ' create-package-starter init --cleanup-legacy-release',
35
57
  ' create-package-starter setup-github --repo i-santos/firestack --dry-run',
58
+ ' create-package-starter init --dir . --with-github --with-beta --with-npm --yes',
59
+ ' create-package-starter setup-beta --dir . --beta-branch release/beta --release-auth app',
60
+ ' create-package-starter open-pr --auto-merge --watch-checks',
61
+ ' create-package-starter release-cycle --yes',
62
+ ' create-package-starter release-cycle --promote-stable --promote-type minor --yes',
63
+ ' create-package-starter promote-stable --dir . --type patch --summary "Promote beta to stable"',
36
64
  ' create-package-starter setup-npm --dir . --publish-first'
37
65
  ].join('\n');
38
66
  }
@@ -49,7 +77,9 @@ function parseValueFlag(argv, index, flag) {
49
77
  function parseCreateArgs(argv) {
50
78
  const args = {
51
79
  out: process.cwd(),
52
- defaultBranch: DEFAULT_BASE_BRANCH
80
+ defaultBranch: DEFAULT_BASE_BRANCH,
81
+ releaseAuth: DEFAULT_RELEASE_AUTH,
82
+ releaseAuthProvided: false
53
83
  };
54
84
 
55
85
  for (let i = 0; i < argv.length; i += 1) {
@@ -73,6 +103,13 @@ function parseCreateArgs(argv) {
73
103
  continue;
74
104
  }
75
105
 
106
+ if (token === '--release-auth') {
107
+ args.releaseAuth = parseValueFlag(argv, i, '--release-auth');
108
+ args.releaseAuthProvided = true;
109
+ i += 1;
110
+ continue;
111
+ }
112
+
76
113
  if (token === '--help' || token === '-h') {
77
114
  args.help = true;
78
115
  continue;
@@ -90,7 +127,17 @@ function parseInitArgs(argv) {
90
127
  force: false,
91
128
  cleanupLegacyRelease: false,
92
129
  defaultBranch: DEFAULT_BASE_BRANCH,
93
- scope: ''
130
+ betaBranch: DEFAULT_BETA_BRANCH,
131
+ scope: '',
132
+ repo: '',
133
+ ruleset: '',
134
+ releaseAuth: DEFAULT_RELEASE_AUTH,
135
+ releaseAuthProvided: false,
136
+ withGithub: false,
137
+ withNpm: false,
138
+ withBeta: false,
139
+ dryRun: false,
140
+ yes: false
94
141
  };
95
142
 
96
143
  for (let i = 0; i < argv.length; i += 1) {
@@ -114,6 +161,56 @@ function parseInitArgs(argv) {
114
161
  continue;
115
162
  }
116
163
 
164
+ if (token === '--beta-branch') {
165
+ args.betaBranch = parseValueFlag(argv, i, '--beta-branch');
166
+ i += 1;
167
+ continue;
168
+ }
169
+
170
+ if (token === '--repo') {
171
+ args.repo = parseValueFlag(argv, i, '--repo');
172
+ i += 1;
173
+ continue;
174
+ }
175
+
176
+ if (token === '--ruleset') {
177
+ args.ruleset = parseValueFlag(argv, i, '--ruleset');
178
+ i += 1;
179
+ continue;
180
+ }
181
+
182
+ if (token === '--release-auth') {
183
+ args.releaseAuth = parseValueFlag(argv, i, '--release-auth');
184
+ args.releaseAuthProvided = true;
185
+ i += 1;
186
+ continue;
187
+ }
188
+
189
+ if (token === '--with-github') {
190
+ args.withGithub = true;
191
+ continue;
192
+ }
193
+
194
+ if (token === '--with-npm') {
195
+ args.withNpm = true;
196
+ continue;
197
+ }
198
+
199
+ if (token === '--with-beta') {
200
+ args.withBeta = true;
201
+ continue;
202
+ }
203
+
204
+ if (token === '--dry-run') {
205
+ args.dryRun = true;
206
+ continue;
207
+ }
208
+
209
+ if (token === '--yes') {
210
+ args.yes = true;
211
+ continue;
212
+ }
213
+
117
214
  if (token === '--force') {
118
215
  args.force = true;
119
216
  continue;
@@ -215,549 +312,3988 @@ function parseSetupNpmArgs(argv) {
215
312
  return args;
216
313
  }
217
314
 
218
- function parseArgs(argv) {
219
- if (argv[0] === 'init') {
220
- return {
221
- mode: 'init',
222
- args: parseInitArgs(argv.slice(1))
223
- };
224
- }
315
+ function parseSetupBetaArgs(argv) {
316
+ const args = {
317
+ dir: process.cwd(),
318
+ betaBranch: DEFAULT_BETA_BRANCH,
319
+ defaultBranch: DEFAULT_BASE_BRANCH,
320
+ releaseAuth: DEFAULT_RELEASE_AUTH,
321
+ releaseAuthProvided: false,
322
+ force: false,
323
+ yes: false,
324
+ dryRun: false
325
+ };
225
326
 
226
- if (argv[0] === 'setup-github') {
227
- return {
228
- mode: 'setup-github',
229
- args: parseSetupGithubArgs(argv.slice(1))
230
- };
231
- }
327
+ for (let i = 0; i < argv.length; i += 1) {
328
+ const token = argv[i];
232
329
 
233
- if (argv[0] === 'setup-npm') {
234
- return {
235
- mode: 'setup-npm',
236
- args: parseSetupNpmArgs(argv.slice(1))
237
- };
238
- }
330
+ if (token === '--dir') {
331
+ args.dir = parseValueFlag(argv, i, '--dir');
332
+ i += 1;
333
+ continue;
334
+ }
239
335
 
240
- return {
241
- mode: 'create',
242
- args: parseCreateArgs(argv)
243
- };
244
- }
336
+ if (token === '--repo') {
337
+ args.repo = parseValueFlag(argv, i, '--repo');
338
+ i += 1;
339
+ continue;
340
+ }
245
341
 
246
- function validateName(name) {
247
- if (typeof name !== 'string') {
248
- return false;
249
- }
342
+ if (token === '--beta-branch') {
343
+ args.betaBranch = parseValueFlag(argv, i, '--beta-branch');
344
+ i += 1;
345
+ continue;
346
+ }
250
347
 
251
- const plain = /^[a-z0-9][a-z0-9._-]*$/;
252
- const scoped = /^@[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*$/;
253
- return plain.test(name) || scoped.test(name);
254
- }
348
+ if (token === '--default-branch') {
349
+ args.defaultBranch = parseValueFlag(argv, i, '--default-branch');
350
+ i += 1;
351
+ continue;
352
+ }
255
353
 
256
- function packageDirFromName(packageName) {
257
- const parts = packageName.split('/');
258
- return parts[parts.length - 1];
259
- }
354
+ if (token === '--release-auth') {
355
+ args.releaseAuth = parseValueFlag(argv, i, '--release-auth');
356
+ args.releaseAuthProvided = true;
357
+ i += 1;
358
+ continue;
359
+ }
260
360
 
261
- function deriveScope(argsScope, packageName) {
262
- if (argsScope) {
263
- return argsScope;
264
- }
361
+ if (token === '--force') {
362
+ args.force = true;
363
+ continue;
364
+ }
265
365
 
266
- if (typeof packageName === 'string' && packageName.startsWith('@')) {
267
- const first = packageName.split('/')[0];
268
- return first.slice(1);
269
- }
366
+ if (token === '--yes') {
367
+ args.yes = true;
368
+ continue;
369
+ }
270
370
 
271
- return 'team';
272
- }
371
+ if (token === '--dry-run') {
372
+ args.dryRun = true;
373
+ continue;
374
+ }
273
375
 
274
- function renderTemplateString(source, variables) {
275
- let output = source;
376
+ if (token === '--help' || token === '-h') {
377
+ args.help = true;
378
+ continue;
379
+ }
276
380
 
277
- for (const [key, value] of Object.entries(variables)) {
278
- output = output.replace(new RegExp(`__${key}__`, 'g'), value);
381
+ throw new Error(`Invalid argument: ${token}\n\n${usage()}`);
279
382
  }
280
383
 
281
- return output;
384
+ return args;
282
385
  }
283
386
 
284
- function copyDirRecursive(sourceDir, targetDir, variables, relativeBase = '') {
285
- fs.mkdirSync(targetDir, { recursive: true });
286
- const entries = fs.readdirSync(sourceDir, { withFileTypes: true });
287
- const createdFiles = [];
387
+ function parsePromoteStableArgs(argv) {
388
+ const args = {
389
+ dir: process.cwd(),
390
+ type: 'patch',
391
+ summary: 'Promote beta track to stable release.',
392
+ dryRun: false
393
+ };
288
394
 
289
- for (const entry of entries) {
290
- const srcPath = path.join(sourceDir, entry.name);
291
- const destPath = path.join(targetDir, entry.name);
292
- const relativePath = path.posix.join(relativeBase, entry.name);
395
+ for (let i = 0; i < argv.length; i += 1) {
396
+ const token = argv[i];
293
397
 
294
- if (entry.isDirectory()) {
295
- createdFiles.push(...copyDirRecursive(srcPath, destPath, variables, relativePath));
398
+ if (token === '--dir') {
399
+ args.dir = parseValueFlag(argv, i, '--dir');
400
+ i += 1;
296
401
  continue;
297
402
  }
298
403
 
299
- const source = fs.readFileSync(srcPath, 'utf8');
300
- const rendered = renderTemplateString(source, variables);
301
- fs.writeFileSync(destPath, rendered);
302
- createdFiles.push(relativePath);
303
- }
404
+ if (token === '--type') {
405
+ args.type = parseValueFlag(argv, i, '--type');
406
+ i += 1;
407
+ continue;
408
+ }
304
409
 
305
- return createdFiles;
306
- }
410
+ if (token === '--summary') {
411
+ args.summary = parseValueFlag(argv, i, '--summary');
412
+ i += 1;
413
+ continue;
414
+ }
307
415
 
308
- function readJsonFile(filePath) {
309
- let raw;
416
+ if (token === '--dry-run') {
417
+ args.dryRun = true;
418
+ continue;
419
+ }
310
420
 
311
- try {
312
- raw = fs.readFileSync(filePath, 'utf8');
313
- } catch (error) {
314
- throw new Error(`Failed to read ${filePath}: ${error.message}`);
421
+ if (token === '--help' || token === '-h') {
422
+ args.help = true;
423
+ continue;
424
+ }
425
+
426
+ throw new Error(`Invalid argument: ${token}\n\n${usage()}`);
315
427
  }
316
428
 
317
- try {
318
- return JSON.parse(raw);
319
- } catch (error) {
320
- throw new Error(`Invalid JSON in ${filePath}: ${error.message}`);
429
+ if (!['patch', 'minor', 'major'].includes(args.type)) {
430
+ throw new Error(`Invalid --type value: ${args.type}. Expected patch, minor, or major.`);
321
431
  }
322
- }
323
432
 
324
- function writeJsonFile(filePath, value) {
325
- fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`);
433
+ return args;
326
434
  }
327
435
 
328
- function createSummary() {
329
- return {
330
- createdFiles: [],
331
- overwrittenFiles: [],
332
- skippedFiles: [],
333
- updatedScriptKeys: [],
334
- skippedScriptKeys: [],
335
- removedScriptKeys: [],
336
- updatedDependencyKeys: [],
337
- skippedDependencyKeys: [],
338
- warnings: []
436
+ function parseOpenPrArgs(argv) {
437
+ const args = {
438
+ repo: '',
439
+ base: '',
440
+ head: '',
441
+ title: '',
442
+ body: '',
443
+ bodyFile: '',
444
+ template: '',
445
+ draft: false,
446
+ autoMerge: false,
447
+ watchChecks: false,
448
+ checkTimeout: 30,
449
+ updateExistingPr: true,
450
+ yes: false,
451
+ dryRun: false
339
452
  };
340
- }
341
453
 
342
- function printSummary(title, summary) {
343
- const list = (values) => (values.length ? values.join(', ') : 'none');
454
+ for (let i = 0; i < argv.length; i += 1) {
455
+ const token = argv[i];
344
456
 
345
- console.log(title);
346
- console.log(`files created: ${list(summary.createdFiles)}`);
347
- console.log(`files overwritten: ${list(summary.overwrittenFiles)}`);
348
- console.log(`files skipped: ${list(summary.skippedFiles)}`);
349
- console.log(`scripts updated: ${list(summary.updatedScriptKeys)}`);
350
- console.log(`scripts skipped: ${list(summary.skippedScriptKeys)}`);
351
- console.log(`scripts removed: ${list(summary.removedScriptKeys)}`);
352
- console.log(`dependencies updated: ${list(summary.updatedDependencyKeys)}`);
353
- console.log(`dependencies skipped: ${list(summary.skippedDependencyKeys)}`);
354
- console.log(`warnings: ${list(summary.warnings)}`);
355
- }
457
+ if (token === '--repo') {
458
+ args.repo = parseValueFlag(argv, i, '--repo');
459
+ i += 1;
460
+ continue;
461
+ }
356
462
 
357
- function ensureFileFromTemplate(targetPath, templatePath, options) {
358
- const exists = fs.existsSync(targetPath);
463
+ if (token === '--base') {
464
+ args.base = parseValueFlag(argv, i, '--base');
465
+ i += 1;
466
+ continue;
467
+ }
359
468
 
360
- if (exists && !options.force) {
361
- return 'skipped';
362
- }
469
+ if (token === '--head') {
470
+ args.head = parseValueFlag(argv, i, '--head');
471
+ i += 1;
472
+ continue;
473
+ }
363
474
 
364
- const source = fs.readFileSync(templatePath, 'utf8');
365
- const rendered = renderTemplateString(source, options.variables);
475
+ if (token === '--title') {
476
+ args.title = parseValueFlag(argv, i, '--title');
477
+ i += 1;
478
+ continue;
479
+ }
366
480
 
367
- fs.mkdirSync(path.dirname(targetPath), { recursive: true });
368
- fs.writeFileSync(targetPath, rendered);
481
+ if (token === '--body') {
482
+ args.body = parseValueFlag(argv, i, '--body');
483
+ i += 1;
484
+ continue;
485
+ }
369
486
 
370
- if (exists) {
371
- return 'overwritten';
372
- }
487
+ if (token === '--body-file') {
488
+ args.bodyFile = parseValueFlag(argv, i, '--body-file');
489
+ i += 1;
490
+ continue;
491
+ }
373
492
 
374
- return 'created';
375
- }
493
+ if (token === '--template') {
494
+ args.template = parseValueFlag(argv, i, '--template');
495
+ i += 1;
496
+ continue;
497
+ }
376
498
 
377
- function detectEquivalentManagedFile(packageDir, targetRelativePath) {
378
- if (targetRelativePath !== '.github/PULL_REQUEST_TEMPLATE.md') {
379
- return targetRelativePath;
380
- }
499
+ if (token === '--check-timeout') {
500
+ args.checkTimeout = Number.parseFloat(parseValueFlag(argv, i, '--check-timeout'));
501
+ i += 1;
502
+ continue;
503
+ }
381
504
 
382
- const canonicalPath = path.join(packageDir, targetRelativePath);
383
- if (fs.existsSync(canonicalPath)) {
384
- return targetRelativePath;
385
- }
505
+ if (token === '--update-pr-description') {
506
+ args.updateExistingPr = true;
507
+ continue;
508
+ }
386
509
 
387
- const legacyLowercase = '.github/pull_request_template.md';
388
- if (fs.existsSync(path.join(packageDir, legacyLowercase))) {
389
- return legacyLowercase;
390
- }
510
+ if (token === '--draft') {
511
+ args.draft = true;
512
+ continue;
513
+ }
391
514
 
392
- return targetRelativePath;
393
- }
515
+ if (token === '--auto-merge') {
516
+ args.autoMerge = true;
517
+ continue;
518
+ }
394
519
 
395
- function updateManagedFiles(packageDir, templateDir, options, summary) {
396
- for (const [targetRelativePath, templateRelativePath] of MANAGED_FILE_SPECS) {
397
- const effectiveTargetRelative = detectEquivalentManagedFile(packageDir, targetRelativePath);
398
- const targetPath = path.join(packageDir, effectiveTargetRelative);
399
- const templatePath = path.join(templateDir, templateRelativePath);
520
+ if (token === '--watch-checks') {
521
+ args.watchChecks = true;
522
+ continue;
523
+ }
400
524
 
401
- if (!fs.existsSync(templatePath)) {
402
- throw new Error(`Template not found: ${templatePath}`);
525
+ if (token === '--yes') {
526
+ args.yes = true;
527
+ continue;
403
528
  }
404
529
 
405
- const result = ensureFileFromTemplate(targetPath, templatePath, {
406
- force: options.force,
407
- variables: options.variables
408
- });
530
+ if (token === '--dry-run') {
531
+ args.dryRun = true;
532
+ continue;
533
+ }
409
534
 
410
- if (result === 'created') {
411
- summary.createdFiles.push(targetRelativePath);
412
- } else if (result === 'overwritten') {
413
- summary.overwrittenFiles.push(targetRelativePath);
414
- } else {
415
- summary.skippedFiles.push(targetRelativePath);
535
+ if (token === '--help' || token === '-h') {
536
+ args.help = true;
537
+ continue;
416
538
  }
539
+
540
+ throw new Error(`Invalid argument: ${token}\n\n${usage()}`);
541
+ }
542
+
543
+ if (!Number.isFinite(args.checkTimeout) || args.checkTimeout <= 0) {
544
+ throw new Error('Invalid --check-timeout value. Expected a positive number (minutes).');
417
545
  }
546
+
547
+ return args;
418
548
  }
419
549
 
420
- function removeLegacyReleaseScripts(packageJson, summary) {
421
- const keys = Object.keys(packageJson.scripts || {});
550
+ function parseReleaseCycleArgs(argv) {
551
+ const args = {
552
+ repo: '',
553
+ mode: 'auto',
554
+ phase: 'full',
555
+ phaseProvided: false,
556
+ track: 'auto',
557
+ promoteStable: false,
558
+ promoteType: 'patch',
559
+ promoteSummary: 'Promote beta track to stable release.',
560
+ head: '',
561
+ base: '',
562
+ title: '',
563
+ bodyFile: '',
564
+ updatePrDescription: false,
565
+ draft: false,
566
+ autoMerge: true,
567
+ watchChecks: true,
568
+ checkTimeout: 30,
569
+ confirmMerges: false,
570
+ syncBase: 'auto',
571
+ resume: true,
572
+ mergeWhenGreen: true,
573
+ mergeMethod: 'squash',
574
+ waitReleasePr: true,
575
+ releasePrTimeout: 30,
576
+ mergeReleasePr: true,
577
+ verifyNpm: true,
578
+ confirmCleanup: false,
579
+ noCleanup: false,
580
+ yes: false,
581
+ dryRun: false
582
+ };
422
583
 
423
- for (const key of keys) {
424
- const isLegacy = key === 'release:dist-tags'
425
- || key.startsWith('release:beta')
426
- || key.startsWith('release:stable')
427
- || key.startsWith('release:promote')
428
- || key.startsWith('release:rollback');
584
+ for (let i = 0; i < argv.length; i += 1) {
585
+ const token = argv[i];
429
586
 
430
- if (!isLegacy) {
587
+ if (token === '--repo') {
588
+ args.repo = parseValueFlag(argv, i, '--repo');
589
+ i += 1;
431
590
  continue;
432
591
  }
433
592
 
434
- delete packageJson.scripts[key];
435
- summary.removedScriptKeys.push(key);
436
- }
437
- }
593
+ if (token === '--mode') {
594
+ args.mode = parseValueFlag(argv, i, '--mode');
595
+ i += 1;
596
+ continue;
597
+ }
438
598
 
439
- function configureExistingPackage(packageDir, templateDir, options) {
440
- if (!fs.existsSync(packageDir)) {
441
- throw new Error(`Directory not found: ${packageDir}`);
442
- }
599
+ if (token === '--phase') {
600
+ args.phase = parseValueFlag(argv, i, '--phase');
601
+ args.phaseProvided = true;
602
+ i += 1;
603
+ continue;
604
+ }
443
605
 
444
- const packageJsonPath = path.join(packageDir, 'package.json');
445
- if (!fs.existsSync(packageJsonPath)) {
446
- throw new Error(`package.json not found in ${packageDir}`);
447
- }
606
+ if (token === '--track') {
607
+ args.track = parseValueFlag(argv, i, '--track');
608
+ i += 1;
609
+ continue;
610
+ }
448
611
 
449
- const packageJson = readJsonFile(packageJsonPath);
450
- packageJson.scripts = packageJson.scripts || {};
451
- packageJson.devDependencies = packageJson.devDependencies || {};
612
+ if (token === '--promote-type') {
613
+ args.promoteType = parseValueFlag(argv, i, '--promote-type');
614
+ i += 1;
615
+ continue;
616
+ }
452
617
 
453
- const summary = createSummary();
618
+ if (token === '--promote-summary') {
619
+ args.promoteSummary = parseValueFlag(argv, i, '--promote-summary');
620
+ i += 1;
621
+ continue;
622
+ }
454
623
 
455
- const desiredScripts = {
456
- check: 'npm run test',
457
- changeset: 'changeset',
458
- 'version-packages': 'changeset version',
459
- release: 'npm run check && changeset publish'
460
- };
624
+ if (token === '--head') {
625
+ args.head = parseValueFlag(argv, i, '--head');
626
+ i += 1;
627
+ continue;
628
+ }
461
629
 
462
- let packageJsonChanged = false;
630
+ if (token === '--base') {
631
+ args.base = parseValueFlag(argv, i, '--base');
632
+ i += 1;
633
+ continue;
634
+ }
463
635
 
464
- for (const [key, value] of Object.entries(desiredScripts)) {
465
- const exists = Object.prototype.hasOwnProperty.call(packageJson.scripts, key);
636
+ if (token === '--title') {
637
+ args.title = parseValueFlag(argv, i, '--title');
638
+ i += 1;
639
+ continue;
640
+ }
466
641
 
467
- if (key === 'check') {
468
- if (!exists) {
469
- packageJson.scripts[key] = value;
470
- packageJsonChanged = true;
471
- summary.updatedScriptKeys.push(key);
472
- } else if (options.force && packageJson.scripts[key] !== value) {
473
- packageJson.scripts[key] = value;
474
- packageJsonChanged = true;
475
- summary.updatedScriptKeys.push(key);
476
- } else {
477
- summary.skippedScriptKeys.push(key);
478
- }
642
+ if (token === '--body-file') {
643
+ args.bodyFile = parseValueFlag(argv, i, '--body-file');
644
+ i += 1;
479
645
  continue;
480
646
  }
481
647
 
482
- if (!exists || options.force) {
483
- if (!exists || packageJson.scripts[key] !== value) {
484
- packageJson.scripts[key] = value;
485
- packageJsonChanged = true;
486
- }
487
- summary.updatedScriptKeys.push(key);
648
+ if (token === '--update-pr-description') {
649
+ args.updatePrDescription = true;
488
650
  continue;
489
651
  }
490
652
 
491
- summary.skippedScriptKeys.push(key);
492
- }
653
+ if (token === '--check-timeout') {
654
+ args.checkTimeout = Number.parseFloat(parseValueFlag(argv, i, '--check-timeout'));
655
+ i += 1;
656
+ continue;
657
+ }
493
658
 
494
- const depExists = Object.prototype.hasOwnProperty.call(packageJson.devDependencies, CHANGESETS_DEP);
659
+ if (token === '--release-pr-timeout') {
660
+ args.releasePrTimeout = Number.parseFloat(parseValueFlag(argv, i, '--release-pr-timeout'));
661
+ i += 1;
662
+ continue;
663
+ }
495
664
 
496
- if (!depExists || options.force) {
497
- if (!depExists || packageJson.devDependencies[CHANGESETS_DEP] !== CHANGESETS_DEP_VERSION) {
498
- packageJson.devDependencies[CHANGESETS_DEP] = CHANGESETS_DEP_VERSION;
499
- packageJsonChanged = true;
665
+ if (token === '--merge-method') {
666
+ args.mergeMethod = parseValueFlag(argv, i, '--merge-method');
667
+ i += 1;
668
+ continue;
500
669
  }
501
- summary.updatedDependencyKeys.push(CHANGESETS_DEP);
502
- } else {
503
- summary.skippedDependencyKeys.push(CHANGESETS_DEP);
504
- }
505
670
 
506
- if (options.cleanupLegacyRelease) {
507
- const before = summary.removedScriptKeys.length;
508
- removeLegacyReleaseScripts(packageJson, summary);
509
- if (summary.removedScriptKeys.length > before) {
510
- packageJsonChanged = true;
671
+ if (token === '--draft') {
672
+ args.draft = true;
673
+ continue;
511
674
  }
512
- }
513
675
 
514
- const packageName = packageJson.name || packageDirFromName(path.basename(packageDir));
676
+ if (token === '--auto-merge') {
677
+ args.autoMerge = true;
678
+ continue;
679
+ }
515
680
 
516
- updateManagedFiles(packageDir, templateDir, {
517
- force: options.force,
518
- variables: {
519
- PACKAGE_NAME: packageName,
520
- DEFAULT_BRANCH: options.defaultBranch,
521
- SCOPE: deriveScope(options.scope, packageName)
681
+ if (token === '--watch-checks') {
682
+ args.watchChecks = true;
683
+ continue;
522
684
  }
523
- }, summary);
524
685
 
525
- if (packageJsonChanged) {
526
- writeJsonFile(packageJsonPath, packageJson);
527
- }
686
+ if (token === '--confirm-merges') {
687
+ args.confirmMerges = true;
688
+ continue;
689
+ }
528
690
 
529
- return summary;
530
- }
691
+ if (token === '--sync-base') {
692
+ args.syncBase = parseValueFlag(argv, i, '--sync-base');
693
+ i += 1;
694
+ continue;
695
+ }
531
696
 
532
- function createNewPackage(args) {
533
- if (!validateName(args.name)) {
534
- throw new Error('Provide a valid package name with --name (for example: hello-package or @i-santos/swarm).');
535
- }
697
+ if (token === '--no-resume') {
698
+ args.resume = false;
699
+ continue;
700
+ }
536
701
 
537
- const packageRoot = path.resolve(__dirname, '..');
538
- const templateDir = path.join(packageRoot, 'template');
702
+ if (token === '--merge-when-green') {
703
+ args.mergeWhenGreen = true;
704
+ continue;
705
+ }
539
706
 
540
- if (!fs.existsSync(templateDir)) {
541
- throw new Error(`Template not found in ${templateDir}`);
542
- }
707
+ if (token === '--wait-release-pr') {
708
+ args.waitReleasePr = true;
709
+ continue;
710
+ }
543
711
 
544
- const outputDir = path.resolve(args.out);
545
- const targetDir = path.join(outputDir, packageDirFromName(args.name));
712
+ if (token === '--merge-release-pr') {
713
+ args.mergeReleasePr = true;
714
+ continue;
715
+ }
546
716
 
547
- if (fs.existsSync(targetDir)) {
548
- throw new Error(`Directory already exists: ${targetDir}`);
549
- }
717
+ if (token === '--promote-stable') {
718
+ args.promoteStable = true;
719
+ continue;
720
+ }
550
721
 
551
- const summary = createSummary();
722
+ if (token === '--verify-npm') {
723
+ args.verifyNpm = true;
724
+ continue;
725
+ }
552
726
 
553
- const createdFiles = copyDirRecursive(templateDir, targetDir, {
554
- PACKAGE_NAME: args.name,
555
- DEFAULT_BRANCH: args.defaultBranch,
556
- SCOPE: deriveScope('', args.name)
557
- });
727
+ if (token === '--confirm-cleanup') {
728
+ args.confirmCleanup = true;
729
+ continue;
730
+ }
558
731
 
559
- summary.createdFiles.push(...createdFiles);
732
+ if (token === '--no-cleanup') {
733
+ args.noCleanup = true;
734
+ continue;
735
+ }
560
736
 
561
- summary.updatedScriptKeys.push('check', 'changeset', 'version-packages', 'release');
562
- summary.updatedDependencyKeys.push(CHANGESETS_DEP);
737
+ if (token === '--yes') {
738
+ args.yes = true;
739
+ continue;
740
+ }
563
741
 
564
- printSummary(`Package created in ${targetDir}`, summary);
565
- }
742
+ if (token === '--dry-run') {
743
+ args.dryRun = true;
744
+ continue;
745
+ }
566
746
 
567
- function initExistingPackage(args) {
568
- const packageRoot = path.resolve(__dirname, '..');
569
- const templateDir = path.join(packageRoot, 'template');
570
- const targetDir = path.resolve(args.dir);
747
+ if (token === '--help' || token === '-h') {
748
+ args.help = true;
749
+ continue;
750
+ }
571
751
 
572
- const summary = configureExistingPackage(targetDir, templateDir, args);
573
- printSummary(`Project initialized in ${targetDir}`, summary);
574
- }
752
+ throw new Error(`Invalid argument: ${token}\n\n${usage()}`);
753
+ }
575
754
 
576
- function execCommand(command, args, options = {}) {
577
- return spawnSync(command, args, {
578
- encoding: 'utf8',
579
- ...options
580
- });
581
- }
755
+ if (!['auto', 'open-pr', 'publish'].includes(args.mode)) {
756
+ throw new Error('Invalid --mode value. Expected auto, open-pr, or publish.');
757
+ }
582
758
 
583
- function parseRepoFromRemote(remoteUrl) {
584
- const trimmed = remoteUrl.trim();
585
- const httpsMatch = trimmed.match(/github\.com[/:]([^/]+\/[^/.]+)(?:\.git)?$/);
759
+ if (!['code', 'full'].includes(args.phase)) {
760
+ throw new Error('Invalid --phase value. Expected code or full.');
761
+ }
586
762
 
587
- if (httpsMatch) {
588
- return httpsMatch[1];
763
+ if (!['auto', 'beta', 'stable'].includes(args.track)) {
764
+ throw new Error('Invalid --track value. Expected auto, beta, or stable.');
589
765
  }
590
766
 
591
- return '';
592
- }
767
+ if (!['patch', 'minor', 'major'].includes(args.promoteType)) {
768
+ throw new Error('Invalid --promote-type value. Expected patch, minor, or major.');
769
+ }
593
770
 
594
- function resolveRepo(args, deps) {
595
- if (args.repo) {
596
- return args.repo;
771
+ if (!['auto', 'rebase', 'merge', 'off'].includes(args.syncBase)) {
772
+ throw new Error('Invalid --sync-base value. Expected auto, rebase, merge, or off.');
597
773
  }
598
774
 
599
- const remote = deps.exec('git', ['config', '--get', 'remote.origin.url']);
600
- if (remote.status !== 0 || !remote.stdout.trim()) {
601
- throw new Error('Could not infer repository. Use --repo <owner/repo>.');
775
+ if (!['squash', 'merge', 'rebase'].includes(args.mergeMethod)) {
776
+ throw new Error('Invalid --merge-method value. Expected squash, merge, or rebase.');
602
777
  }
603
778
 
604
- const repo = parseRepoFromRemote(remote.stdout);
605
- if (!repo) {
606
- throw new Error('Could not parse GitHub repository from remote.origin.url. Use --repo <owner/repo>.');
779
+ if (!Number.isFinite(args.checkTimeout) || args.checkTimeout <= 0) {
780
+ throw new Error('Invalid --check-timeout value. Expected a positive number (minutes).');
781
+ }
782
+
783
+ if (!Number.isFinite(args.releasePrTimeout) || args.releasePrTimeout <= 0) {
784
+ throw new Error('Invalid --release-pr-timeout value. Expected a positive number (minutes).');
785
+ }
786
+
787
+ return args;
788
+ }
789
+
790
+ function validateReleaseAuthMode(mode, flagName = '--release-auth') {
791
+ if (!RELEASE_AUTH_MODES.has(mode)) {
792
+ throw new Error(`Invalid ${flagName} value: ${mode}. Expected one of: github-token, pat, app, manual-trigger.`);
793
+ }
794
+ }
795
+
796
+ function parseArgs(argv) {
797
+ if (argv[0] === 'init') {
798
+ const args = parseInitArgs(argv.slice(1));
799
+ validateReleaseAuthMode(args.releaseAuth);
800
+ return {
801
+ mode: 'init',
802
+ args
803
+ };
804
+ }
805
+
806
+ if (argv[0] === 'setup-github') {
807
+ return {
808
+ mode: 'setup-github',
809
+ args: parseSetupGithubArgs(argv.slice(1))
810
+ };
811
+ }
812
+
813
+ if (argv[0] === 'setup-npm') {
814
+ return {
815
+ mode: 'setup-npm',
816
+ args: parseSetupNpmArgs(argv.slice(1))
817
+ };
818
+ }
819
+
820
+ if (argv[0] === 'setup-beta') {
821
+ const args = parseSetupBetaArgs(argv.slice(1));
822
+ validateReleaseAuthMode(args.releaseAuth);
823
+ return {
824
+ mode: 'setup-beta',
825
+ args
826
+ };
827
+ }
828
+
829
+ if (argv[0] === 'promote-stable') {
830
+ return {
831
+ mode: 'promote-stable',
832
+ args: parsePromoteStableArgs(argv.slice(1))
833
+ };
834
+ }
835
+
836
+ if (argv[0] === 'open-pr') {
837
+ return {
838
+ mode: 'open-pr',
839
+ args: parseOpenPrArgs(argv.slice(1))
840
+ };
841
+ }
842
+
843
+ if (argv[0] === 'release-cycle') {
844
+ return {
845
+ mode: 'release-cycle',
846
+ args: parseReleaseCycleArgs(argv.slice(1))
847
+ };
848
+ }
849
+
850
+ const args = parseCreateArgs(argv);
851
+ validateReleaseAuthMode(args.releaseAuth);
852
+ return {
853
+ mode: 'create',
854
+ args
855
+ };
856
+ }
857
+
858
+ function validateName(name) {
859
+ if (typeof name !== 'string') {
860
+ return false;
861
+ }
862
+
863
+ const plain = /^[a-z0-9][a-z0-9._-]*$/;
864
+ const scoped = /^@[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*$/;
865
+ return plain.test(name) || scoped.test(name);
866
+ }
867
+
868
+ function packageDirFromName(packageName) {
869
+ const parts = packageName.split('/');
870
+ return parts[parts.length - 1];
871
+ }
872
+
873
+ function deriveScope(argsScope, packageName) {
874
+ if (argsScope) {
875
+ return argsScope;
876
+ }
877
+
878
+ if (typeof packageName === 'string' && packageName.startsWith('@')) {
879
+ const first = packageName.split('/')[0];
880
+ return first.slice(1);
881
+ }
882
+
883
+ return 'team';
884
+ }
885
+
886
+ function buildReleaseAuthVariables(releaseAuthMode) {
887
+ if (releaseAuthMode === 'github-token') {
888
+ return {
889
+ RELEASE_AUTH_APP_STEP: '',
890
+ RELEASE_AUTH_CHECKOUT_TOKEN: '${{ secrets.GITHUB_TOKEN }}',
891
+ RELEASE_AUTH_GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
892
+ };
893
+ }
894
+
895
+ if (releaseAuthMode === 'pat') {
896
+ return {
897
+ RELEASE_AUTH_APP_STEP: '',
898
+ RELEASE_AUTH_CHECKOUT_TOKEN: '${{ secrets.CHANGESETS_GH_TOKEN || secrets.GITHUB_TOKEN }}',
899
+ RELEASE_AUTH_GITHUB_TOKEN: '${{ secrets.CHANGESETS_GH_TOKEN || secrets.GITHUB_TOKEN }}'
900
+ };
901
+ }
902
+
903
+ if (releaseAuthMode === 'app') {
904
+ return {
905
+ RELEASE_AUTH_APP_STEP: [
906
+ ' - name: Generate GitHub App token',
907
+ ' id: app-token',
908
+ ' uses: actions/create-github-app-token@v1',
909
+ ' with:',
910
+ ' app-id: ${{ secrets.GH_APP_ID || secrets.GH_APP_CLIENT_ID }}',
911
+ ' private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}',
912
+ ''
913
+ ].join('\n'),
914
+ RELEASE_AUTH_CHECKOUT_TOKEN: '${{ steps.app-token.outputs.token }}',
915
+ RELEASE_AUTH_GITHUB_TOKEN: '${{ steps.app-token.outputs.token }}'
916
+ };
917
+ }
918
+
919
+ return {
920
+ RELEASE_AUTH_APP_STEP: '',
921
+ RELEASE_AUTH_CHECKOUT_TOKEN: '${{ secrets.GITHUB_TOKEN }}',
922
+ RELEASE_AUTH_GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
923
+ };
924
+ }
925
+
926
+ function appendReleaseAuthWarnings(summary, releaseAuthMode, options = {}) {
927
+ if (releaseAuthMode === 'manual-trigger') {
928
+ summary.warnings.push('release-auth recommendation: use pat/app when you need automatic CI retriggers for release PR updates.');
929
+ summary.warnings.push('manual-trigger mode selected: release PR updates may not retrigger CI automatically.');
930
+ summary.warnings.push('If release PR checks are pending, push an empty commit to changeset-release/* to retrigger CI.');
931
+ return;
932
+ }
933
+
934
+ if (releaseAuthMode === 'app') {
935
+ summary.warnings.push('release-auth recommendation: app mode is preferred for long-lived org/repo automation.');
936
+ const missing = options.missingAppSecrets || [];
937
+ if (missing.length > 0) {
938
+ summary.warnings.push(`release-auth app mode selected: missing repository secrets: ${missing.join(', ')}`);
939
+ } else if (options.appSecretsChecked) {
940
+ summary.warnings.push('release-auth app mode selected: required repository secrets detected.');
941
+ } else {
942
+ summary.warnings.push('release-auth app mode selected: ensure GH_APP_CLIENT_ID (or GH_APP_ID) and GH_APP_PRIVATE_KEY repository secrets are configured.');
943
+ }
944
+ summary.warnings.push(`GitHub Apps overview: ${RELEASE_AUTH_DOC_LINKS.overview}`);
945
+ summary.warnings.push(`Create GitHub App: ${RELEASE_AUTH_DOC_LINKS.create}`);
946
+ summary.warnings.push(`Install GitHub App: ${RELEASE_AUTH_DOC_LINKS.install}`);
947
+ summary.warnings.push(`Manage Actions secrets: ${RELEASE_AUTH_DOC_LINKS.secrets}`);
948
+ summary.warnings.push(`Project guide: ${RELEASE_AUTH_DOC_LINKS.internal}`);
949
+ return;
950
+ }
951
+
952
+ if (releaseAuthMode === 'pat') {
953
+ summary.warnings.push('release-auth recommendation: pat mode is the fastest setup for solo/small projects.');
954
+ summary.warnings.push('release-auth pat mode selected: ensure CHANGESETS_GH_TOKEN secret is configured for reliable release PR check retriggers.');
955
+ return;
956
+ }
957
+
958
+ summary.warnings.push('release-auth recommendation: github-token mode is simplest, but may skip downstream workflow retriggers.');
959
+ }
960
+
961
+ async function resolveReleaseAuthSelection(args, summary, options = {}) {
962
+ if (args.releaseAuthProvided) {
963
+ return;
964
+ }
965
+
966
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
967
+ summary.warnings.push(`--release-auth not provided in non-interactive mode. Defaulting to "${args.releaseAuth}".`);
968
+ return;
969
+ }
970
+
971
+ const selected = await askChoice(
972
+ `${options.contextLabel || 'Select release auth mode'}:`,
973
+ ['pat', 'app', 'github-token', 'manual-trigger'],
974
+ 0
975
+ );
976
+
977
+ args.releaseAuth = selected;
978
+ summary.warnings.push(`release-auth selected interactively: ${selected}`);
979
+ }
980
+
981
+ function renderTemplateString(source, variables) {
982
+ let output = source;
983
+
984
+ for (const [key, value] of Object.entries(variables)) {
985
+ output = output.replace(new RegExp(`__${key}__`, 'g'), value);
986
+ }
987
+
988
+ return output;
989
+ }
990
+
991
+ function copyDirRecursive(sourceDir, targetDir, variables, relativeBase = '') {
992
+ fs.mkdirSync(targetDir, { recursive: true });
993
+ const entries = fs.readdirSync(sourceDir, { withFileTypes: true });
994
+ const createdFiles = [];
995
+
996
+ for (const entry of entries) {
997
+ const srcPath = path.join(sourceDir, entry.name);
998
+ const destinationEntryName = relativeBase === '' && entry.name === 'gitignore'
999
+ ? '.gitignore'
1000
+ : entry.name;
1001
+ const destPath = path.join(targetDir, destinationEntryName);
1002
+ const relativePath = path.posix.join(relativeBase, destinationEntryName);
1003
+
1004
+ if (entry.isDirectory()) {
1005
+ createdFiles.push(...copyDirRecursive(srcPath, destPath, variables, relativePath));
1006
+ continue;
1007
+ }
1008
+
1009
+ const source = fs.readFileSync(srcPath, 'utf8');
1010
+ const rendered = renderTemplateString(source, variables);
1011
+ fs.writeFileSync(destPath, rendered);
1012
+ createdFiles.push(relativePath);
1013
+ }
1014
+
1015
+ return createdFiles;
1016
+ }
1017
+
1018
+ function readJsonFile(filePath) {
1019
+ let raw;
1020
+
1021
+ try {
1022
+ raw = fs.readFileSync(filePath, 'utf8');
1023
+ } catch (error) {
1024
+ throw new Error(`Failed to read ${filePath}: ${error.message}`);
1025
+ }
1026
+
1027
+ try {
1028
+ return JSON.parse(raw);
1029
+ } catch (error) {
1030
+ throw new Error(`Invalid JSON in ${filePath}: ${error.message}`);
1031
+ }
1032
+ }
1033
+
1034
+ function writeJsonFile(filePath, value) {
1035
+ fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`);
1036
+ }
1037
+
1038
+ function createSummary() {
1039
+ return {
1040
+ createdFiles: [],
1041
+ overwrittenFiles: [],
1042
+ skippedFiles: [],
1043
+ updatedScriptKeys: [],
1044
+ skippedScriptKeys: [],
1045
+ removedScriptKeys: [],
1046
+ updatedDependencyKeys: [],
1047
+ skippedDependencyKeys: [],
1048
+ warnings: []
1049
+ };
1050
+ }
1051
+
1052
+ function printSummary(title, summary) {
1053
+ const unique = (values) => [...new Set(values)];
1054
+ const formatList = (values) => {
1055
+ const normalized = unique(values);
1056
+ if (!normalized.length) {
1057
+ return [' - none'];
1058
+ }
1059
+
1060
+ return normalized.map((item) => ` - ${item}`);
1061
+ };
1062
+
1063
+ console.log(title);
1064
+ console.log('');
1065
+ console.log('Preflight');
1066
+ console.log(' - completed');
1067
+ console.log('');
1068
+ console.log('Apply');
1069
+ console.log('files created:');
1070
+ formatList(summary.createdFiles).forEach((line) => console.log(line));
1071
+ console.log('files overwritten:');
1072
+ formatList(summary.overwrittenFiles).forEach((line) => console.log(line));
1073
+ console.log('files skipped:');
1074
+ formatList(summary.skippedFiles).forEach((line) => console.log(line));
1075
+ console.log('scripts updated:');
1076
+ formatList(summary.updatedScriptKeys).forEach((line) => console.log(line));
1077
+ console.log('scripts skipped:');
1078
+ formatList(summary.skippedScriptKeys).forEach((line) => console.log(line));
1079
+ console.log('scripts removed:');
1080
+ formatList(summary.removedScriptKeys).forEach((line) => console.log(line));
1081
+ console.log('dependencies updated:');
1082
+ formatList(summary.updatedDependencyKeys).forEach((line) => console.log(line));
1083
+ console.log('dependencies skipped:');
1084
+ formatList(summary.skippedDependencyKeys).forEach((line) => console.log(line));
1085
+ console.log('');
1086
+ console.log('Summary');
1087
+ console.log('warnings:');
1088
+ formatList(summary.warnings).forEach((line) => console.log(line));
1089
+ }
1090
+
1091
+ class StepReporter {
1092
+ constructor() {
1093
+ this.active = null;
1094
+ this.frames = ['-', '\\', '|', '/'];
1095
+ this.frameIndex = 0;
1096
+ }
1097
+
1098
+ canSpin() {
1099
+ return Boolean(process.stdout.isTTY) && process.env.CI !== 'true';
1100
+ }
1101
+
1102
+ start(stepId, message) {
1103
+ this.stop();
1104
+ if (!this.canSpin()) {
1105
+ logStep('run', message);
1106
+ return;
1107
+ }
1108
+
1109
+ this.active = {
1110
+ id: stepId,
1111
+ message,
1112
+ timer: setInterval(() => {
1113
+ const frame = this.frames[this.frameIndex % this.frames.length];
1114
+ this.frameIndex += 1;
1115
+ process.stdout.write(`\r${frame} ${message}`);
1116
+ }, 80)
1117
+ };
1118
+ }
1119
+
1120
+ end(status, message) {
1121
+ if (this.active && this.active.timer) {
1122
+ clearInterval(this.active.timer);
1123
+ const finalLabel = status === 'ok'
1124
+ ? 'OK'
1125
+ : status === 'warn'
1126
+ ? 'WARN'
1127
+ : 'ERR';
1128
+ process.stdout.write(`\r[${finalLabel}] ${message}\n`);
1129
+ this.active = null;
1130
+ return;
1131
+ }
1132
+
1133
+ logStep(status, message);
1134
+ }
1135
+
1136
+ ok(stepId, message) {
1137
+ this.end('ok', message);
1138
+ }
1139
+
1140
+ warn(stepId, message) {
1141
+ this.end('warn', message);
1142
+ }
1143
+
1144
+ fail(stepId, message) {
1145
+ this.end('err', message);
1146
+ }
1147
+
1148
+ stop() {
1149
+ if (!this.active || !this.active.timer) {
1150
+ return;
1151
+ }
1152
+
1153
+ clearInterval(this.active.timer);
1154
+ process.stdout.write('\r');
1155
+ this.active = null;
1156
+ }
1157
+ }
1158
+
1159
+ function logStep(status, message) {
1160
+ const labels = {
1161
+ run: '[RUN ]',
1162
+ ok: '[OK ]',
1163
+ warn: '[WARN]',
1164
+ err: '[ERR ]'
1165
+ };
1166
+ const prefix = labels[status] || '[INFO]';
1167
+ const writer = status === 'err' ? console.error : console.log;
1168
+ writer(`${prefix} ${message}`);
1169
+ }
1170
+
1171
+ async function confirmOrThrow(questionText) {
1172
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
1173
+ throw new Error(`Confirmation required but no interactive terminal was detected. Re-run with --yes if you want to proceed non-interactively.`);
1174
+ }
1175
+
1176
+ const rl = readline.createInterface({
1177
+ input: process.stdin,
1178
+ output: process.stdout
1179
+ });
1180
+
1181
+ try {
1182
+ const answer = await rl.question(`${questionText}\nType "yes" to continue: `);
1183
+ if (answer.trim().toLowerCase() !== 'yes') {
1184
+ throw new Error('Operation cancelled by user.');
1185
+ }
1186
+ } finally {
1187
+ rl.close();
1188
+ }
1189
+ }
1190
+
1191
+ async function askYesNo(questionText, defaultValue = false) {
1192
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
1193
+ return defaultValue;
1194
+ }
1195
+
1196
+ const rl = readline.createInterface({
1197
+ input: process.stdin,
1198
+ output: process.stdout
1199
+ });
1200
+
1201
+ try {
1202
+ const suffix = defaultValue ? '[Y/n]' : '[y/N]';
1203
+ const answer = await rl.question(`${questionText} ${suffix} `);
1204
+ const normalized = answer.trim().toLowerCase();
1205
+
1206
+ if (!normalized) {
1207
+ return defaultValue;
1208
+ }
1209
+
1210
+ return normalized === 'y' || normalized === 'yes';
1211
+ } finally {
1212
+ rl.close();
1213
+ }
1214
+ }
1215
+
1216
+ async function askChoice(questionText, choices, defaultIndex = 0) {
1217
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
1218
+ return choices[defaultIndex];
1219
+ }
1220
+
1221
+ const rl = readline.createInterface({
1222
+ input: process.stdin,
1223
+ output: process.stdout
1224
+ });
1225
+
1226
+ try {
1227
+ const lines = choices.map((choice, index) => `${index + 1}. ${choice}`);
1228
+ const answer = await rl.question(`${questionText}\n${lines.join('\n')}\nSelect option [${defaultIndex + 1}]: `);
1229
+ const trimmed = answer.trim();
1230
+ if (!trimmed) {
1231
+ return choices[defaultIndex];
1232
+ }
1233
+
1234
+ const numeric = Number.parseInt(trimmed, 10);
1235
+ if (Number.isNaN(numeric) || numeric < 1 || numeric > choices.length) {
1236
+ return choices[defaultIndex];
1237
+ }
1238
+
1239
+ return choices[numeric - 1];
1240
+ } finally {
1241
+ rl.close();
1242
+ }
1243
+ }
1244
+
1245
+ function mergeSummary(target, source) {
1246
+ target.createdFiles.push(...source.createdFiles);
1247
+ target.overwrittenFiles.push(...source.overwrittenFiles);
1248
+ target.skippedFiles.push(...source.skippedFiles);
1249
+ target.updatedScriptKeys.push(...source.updatedScriptKeys);
1250
+ target.skippedScriptKeys.push(...source.skippedScriptKeys);
1251
+ target.removedScriptKeys.push(...source.removedScriptKeys);
1252
+ target.updatedDependencyKeys.push(...source.updatedDependencyKeys);
1253
+ target.skippedDependencyKeys.push(...source.skippedDependencyKeys);
1254
+ target.warnings.push(...source.warnings);
1255
+ }
1256
+
1257
+ function createOrchestrationSummary() {
1258
+ return {
1259
+ modeDetected: '',
1260
+ repoResolved: '',
1261
+ branchPushed: '',
1262
+ prAction: '',
1263
+ prUrl: '',
1264
+ autoMerge: '',
1265
+ checks: '',
1266
+ merge: '',
1267
+ releasePr: '',
1268
+ releaseTrack: '',
1269
+ promotionWorkflow: '',
1270
+ promotionPr: '',
1271
+ npmValidation: '',
1272
+ cleanup: '',
1273
+ actionsPerformed: [],
1274
+ actionsSkipped: [],
1275
+ warnings: [],
1276
+ errors: []
1277
+ };
1278
+ }
1279
+
1280
+ function printOrchestrationSummary(title, summary) {
1281
+ const unique = (values) => [...new Set(values)];
1282
+ const formatList = (values) => {
1283
+ const normalized = unique(values || []);
1284
+ if (!normalized.length) {
1285
+ return [' - none'];
1286
+ }
1287
+
1288
+ return normalized.map((item) => ` - ${item}`);
1289
+ };
1290
+
1291
+ console.log(title);
1292
+ console.log('');
1293
+ console.log('Preflight');
1294
+ console.log(` - mode detected: ${summary.modeDetected || 'n/a'}`);
1295
+ console.log(` - repo resolved: ${summary.repoResolved || 'n/a'}`);
1296
+ console.log('');
1297
+ console.log('Plan');
1298
+ console.log(` - branch pushed: ${summary.branchPushed || 'n/a'}`);
1299
+ console.log(` - pr created/updated/skipped: ${summary.prAction || 'n/a'}`);
1300
+ console.log(` - pr url: ${summary.prUrl || 'n/a'}`);
1301
+ console.log('');
1302
+ console.log('Apply');
1303
+ console.log(` - auto-merge enabled/skipped: ${summary.autoMerge || 'n/a'}`);
1304
+ console.log(` - checks watched result: ${summary.checks || 'n/a'}`);
1305
+ console.log(` - merge performed/skipped: ${summary.merge || 'n/a'}`);
1306
+ console.log(` - release pr discovered/merged: ${summary.releasePr || 'n/a'}`);
1307
+ console.log(` - release track: ${summary.releaseTrack || 'n/a'}`);
1308
+ console.log(` - promotion workflow run: ${summary.promotionWorkflow || 'n/a'}`);
1309
+ console.log(` - promotion PR: ${summary.promotionPr || 'n/a'}`);
1310
+ console.log(` - npm validation: ${summary.npmValidation || 'n/a'}`);
1311
+ console.log(` - cleanup: ${summary.cleanup || 'n/a'}`);
1312
+ console.log('actions performed:');
1313
+ formatList(summary.actionsPerformed).forEach((line) => console.log(line));
1314
+ console.log('actions skipped:');
1315
+ formatList(summary.actionsSkipped).forEach((line) => console.log(line));
1316
+ console.log('');
1317
+ console.log('Summary');
1318
+ console.log('warnings:');
1319
+ formatList(summary.warnings).forEach((line) => console.log(line));
1320
+ console.log('errors:');
1321
+ formatList(summary.errors).forEach((line) => console.log(line));
1322
+ }
1323
+
1324
+ function parseJsonSafely(raw, fallback = {}) {
1325
+ try {
1326
+ return JSON.parse(raw);
1327
+ } catch (error) {
1328
+ return fallback;
1329
+ }
1330
+ }
1331
+
1332
+ function sleepMs(milliseconds) {
1333
+ const shared = new SharedArrayBuffer(4);
1334
+ const view = new Int32Array(shared);
1335
+ Atomics.wait(view, 0, 0, milliseconds);
1336
+ }
1337
+
1338
+ function nowMs(deps) {
1339
+ if (deps && typeof deps.now === 'function') {
1340
+ return Number(deps.now());
1341
+ }
1342
+
1343
+ return Date.now();
1344
+ }
1345
+
1346
+ function waitForNextPoll(timeoutAt, defaultIntervalMs, deps) {
1347
+ const remainingMs = Math.max(0, timeoutAt - nowMs(deps));
1348
+ if (remainingMs <= 0) {
1349
+ return;
1350
+ }
1351
+
1352
+ const pollMs = Math.max(100, Math.min(defaultIntervalMs, remainingMs));
1353
+ if (deps && typeof deps.sleep === 'function') {
1354
+ deps.sleep(pollMs);
1355
+ return;
1356
+ }
1357
+
1358
+ sleepMs(pollMs);
1359
+ }
1360
+
1361
+ function resolveGitContext(args, deps) {
1362
+ const insideWorkTree = deps.exec('git', ['rev-parse', '--is-inside-work-tree']);
1363
+ if (insideWorkTree.status !== 0 || insideWorkTree.stdout.trim() !== 'true') {
1364
+ throw new Error('Current directory is not a git repository.');
1365
+ }
1366
+
1367
+ const headBranch = args.head || deps.exec('git', ['rev-parse', '--abbrev-ref', 'HEAD']).stdout.trim();
1368
+ if (!headBranch || headBranch === 'HEAD') {
1369
+ throw new Error('Detached HEAD is not supported. Checkout a branch and rerun.');
1370
+ }
1371
+
1372
+ const repo = resolveRepo({ repo: args.repo }, deps);
1373
+ const baseBranch = args.base || (headBranch === DEFAULT_BETA_BRANCH ? DEFAULT_BASE_BRANCH : DEFAULT_BETA_BRANCH);
1374
+ const latestTitleResult = deps.exec('git', ['log', '-1', '--pretty=%s']);
1375
+ const latestTitle = latestTitleResult.status === 0 ? latestTitleResult.stdout.trim() : '';
1376
+ const title = args.title || latestTitle || headBranch;
1377
+
1378
+ return {
1379
+ repo,
1380
+ head: headBranch,
1381
+ base: baseBranch,
1382
+ title
1383
+ };
1384
+ }
1385
+
1386
+ function getRecentCommitSubjects(deps, count = 10) {
1387
+ const result = deps.exec('git', ['log', `-n${count}`, '--pretty=%h %s']);
1388
+ if (result.status !== 0) {
1389
+ return [];
1390
+ }
1391
+
1392
+ return result.stdout
1393
+ .split('\n')
1394
+ .map((line) => line.trim())
1395
+ .filter(Boolean);
1396
+ }
1397
+
1398
+ function collectChangesetPackages(cwd) {
1399
+ const changesetDir = path.join(cwd, '.changeset');
1400
+ if (!fs.existsSync(changesetDir)) {
1401
+ return [];
1402
+ }
1403
+
1404
+ const files = fs.readdirSync(changesetDir)
1405
+ .filter((name) => name.endsWith('.md'));
1406
+ const packages = new Set();
1407
+
1408
+ for (const fileName of files) {
1409
+ const fullPath = path.join(changesetDir, fileName);
1410
+ const content = fs.readFileSync(fullPath, 'utf8');
1411
+ const parts = content.split('---');
1412
+ if (parts.length < 3) {
1413
+ continue;
1414
+ }
1415
+
1416
+ const frontmatter = parts[1];
1417
+ const lines = frontmatter.split('\n');
1418
+ for (const line of lines) {
1419
+ const match = line.match(/"([^"]+)"\s*:/);
1420
+ if (match) {
1421
+ packages.add(match[1]);
1422
+ }
1423
+ }
1424
+ }
1425
+
1426
+ return [...packages];
1427
+ }
1428
+
1429
+ function renderPrBodyDeterministic(context, deps, options = {}) {
1430
+ const commits = getRecentCommitSubjects(deps, 10);
1431
+ const changedPackages = collectChangesetPackages(options.cwd || process.cwd());
1432
+ const summaryBlock = [
1433
+ '## Summary',
1434
+ `- Source branch: \`${context.head}\``,
1435
+ `- Target branch: \`${context.base}\``,
1436
+ ''
1437
+ ].join('\n');
1438
+ const changesBlock = [
1439
+ '## Changes',
1440
+ ...(commits.length ? commits.map((item) => `- ${item}`) : ['- No recent commit messages found.']),
1441
+ ''
1442
+ ].join('\n');
1443
+ const releaseImpactBlock = [
1444
+ '## Release Impact',
1445
+ ...(changedPackages.length
1446
+ ? changedPackages.map((name) => `- Changeset package: \`${name}\``)
1447
+ : ['- No explicit package entries found in `.changeset/*.md`.']),
1448
+ ''
1449
+ ].join('\n');
1450
+ const validationBlock = [
1451
+ '## Validation',
1452
+ '- [ ] `npm run check`',
1453
+ '- [ ] CI green',
1454
+ ''
1455
+ ].join('\n');
1456
+ const checklistBlock = [
1457
+ '## Checklist',
1458
+ '- [ ] Scope and risks reviewed',
1459
+ '- [ ] Release impact reviewed',
1460
+ '- [ ] Ready to merge',
1461
+ ''
1462
+ ].join('\n');
1463
+ const generated = [summaryBlock, changesBlock, releaseImpactBlock, validationBlock, checklistBlock].join('\n');
1464
+
1465
+ if (options.body) {
1466
+ return options.body;
1467
+ }
1468
+
1469
+ if (options.bodyFile) {
1470
+ const fullPath = path.resolve(options.cwd || process.cwd(), options.bodyFile);
1471
+ return fs.readFileSync(fullPath, 'utf8');
1472
+ }
1473
+
1474
+ const templatePath = options.template
1475
+ ? path.resolve(options.cwd || process.cwd(), options.template)
1476
+ : path.resolve(options.cwd || process.cwd(), '.github/PULL_REQUEST_TEMPLATE.md');
1477
+ if (fs.existsSync(templatePath)) {
1478
+ const templateContent = fs.readFileSync(templatePath, 'utf8');
1479
+ if (templateContent.includes('<!-- GENERATED_PR_BODY -->')) {
1480
+ return templateContent.replace('<!-- GENERATED_PR_BODY -->', generated);
1481
+ }
1482
+
1483
+ return `${templateContent.trim()}\n\n---\n\n${generated}`;
1484
+ }
1485
+
1486
+ return generated;
1487
+ }
1488
+
1489
+ function listOpenPullRequests(repo, deps) {
1490
+ const list = deps.exec('gh', ['pr', 'list', '--repo', repo, '--state', 'open', '--json', 'number,url,title,headRefName,baseRefName']);
1491
+ if (list.status !== 0) {
1492
+ throw new Error(`Failed to list pull requests: ${list.stderr || list.stdout}`.trim());
1493
+ }
1494
+
1495
+ const parsed = parseJsonSafely(list.stdout || '[]', []);
1496
+ return Array.isArray(parsed) ? parsed : [];
1497
+ }
1498
+
1499
+ function findOpenPrByHeadBase(repo, head, base, deps) {
1500
+ const prs = listOpenPullRequests(repo, deps);
1501
+ return prs.find((item) => item.headRefName === head && item.baseRefName === base) || null;
1502
+ }
1503
+
1504
+ function ensureBranchPushed(repo, head, deps) {
1505
+ const upstream = deps.exec('git', ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}']);
1506
+ const hasUpstream = upstream.status === 0;
1507
+ if (hasUpstream) {
1508
+ const ahead = deps.exec('git', ['rev-list', '--count', '@{u}..HEAD']);
1509
+ const aheadCount = ahead.status === 0 ? Number.parseInt(ahead.stdout.trim(), 10) : 0;
1510
+ const push = deps.exec('git', ['push']);
1511
+ if (push.status !== 0) {
1512
+ throw new Error(`Failed to push branch "${head}": ${push.stderr || push.stdout}`.trim());
1513
+ }
1514
+
1515
+ return {
1516
+ status: aheadCount > 0 ? 'updated' : 'up-to-date',
1517
+ hasUpstream: true
1518
+ };
1519
+ }
1520
+
1521
+ const push = deps.exec('git', ['push', '--set-upstream', 'origin', head]);
1522
+ if (push.status !== 0) {
1523
+ throw new Error(`Failed to push branch "${head}" with upstream: ${push.stderr || push.stdout}`.trim());
1524
+ }
1525
+
1526
+ return {
1527
+ status: 'upstream-set',
1528
+ hasUpstream: false
1529
+ };
1530
+ }
1531
+
1532
+ function createOrUpdatePr(context, body, args, deps) {
1533
+ const existing = findOpenPrByHeadBase(context.repo, context.head, context.base, deps);
1534
+ if (existing && !args.updateExistingPr) {
1535
+ return {
1536
+ action: 'reused',
1537
+ number: existing.number,
1538
+ url: existing.url
1539
+ };
1540
+ }
1541
+
1542
+ const bodyFilePath = path.join(process.cwd(), `.tmp-pr-body-${Date.now()}.md`);
1543
+ fs.writeFileSync(bodyFilePath, body);
1544
+
1545
+ try {
1546
+ if (existing) {
1547
+ const editArgs = ['pr', 'edit', String(existing.number), '--repo', context.repo, '--title', context.title, '--body-file', bodyFilePath];
1548
+ const edit = deps.exec('gh', editArgs);
1549
+ if (edit.status !== 0) {
1550
+ throw new Error(`Failed to update PR #${existing.number}: ${edit.stderr || edit.stdout}`.trim());
1551
+ }
1552
+
1553
+ return {
1554
+ action: 'updated',
1555
+ number: existing.number,
1556
+ url: existing.url
1557
+ };
1558
+ }
1559
+
1560
+ const createArgs = ['pr', 'create', '--repo', context.repo, '--head', context.head, '--base', context.base, '--title', context.title, '--body-file', bodyFilePath];
1561
+ if (args.draft) {
1562
+ createArgs.push('--draft');
1563
+ }
1564
+ const create = deps.exec('gh', createArgs);
1565
+ if (create.status !== 0) {
1566
+ throw new Error(`Failed to create PR: ${create.stderr || create.stdout}`.trim());
1567
+ }
1568
+
1569
+ const url = (create.stdout || '').trim().split('\n').find((line) => line.includes('http')) || '';
1570
+ const created = findOpenPrByHeadBase(context.repo, context.head, context.base, deps);
1571
+ return {
1572
+ action: 'created',
1573
+ number: created ? created.number : 0,
1574
+ url: url || (created ? created.url : '')
1575
+ };
1576
+ } finally {
1577
+ if (fs.existsSync(bodyFilePath)) {
1578
+ fs.unlinkSync(bodyFilePath);
1579
+ }
1580
+ }
1581
+ }
1582
+
1583
+ function enablePrAutoMerge(repo, prNumber, mergeMethod, deps) {
1584
+ const methodFlag = mergeMethod === 'merge'
1585
+ ? '--merge'
1586
+ : mergeMethod === 'rebase'
1587
+ ? '--rebase'
1588
+ : '--squash';
1589
+ const result = deps.exec('gh', ['pr', 'merge', String(prNumber), '--repo', repo, methodFlag, '--auto']);
1590
+ if (result.status !== 0) {
1591
+ throw new Error(`Failed to enable auto-merge for PR #${prNumber}: ${result.stderr || result.stdout}`.trim());
1592
+ }
1593
+ }
1594
+
1595
+ function getPrCheckState(repo, prNumber, deps) {
1596
+ const result = deps.exec('gh', ['pr', 'view', String(prNumber), '--repo', repo, '--json', 'statusCheckRollup,url,number']);
1597
+ if (result.status !== 0) {
1598
+ throw new Error(`Failed to inspect PR #${prNumber} checks: ${result.stderr || result.stdout}`.trim());
1599
+ }
1600
+
1601
+ const parsed = parseJsonSafely(result.stdout || '{}', {});
1602
+ const rollup = Array.isArray(parsed.statusCheckRollup) ? parsed.statusCheckRollup : [];
1603
+ let pending = 0;
1604
+ let failed = 0;
1605
+
1606
+ for (const item of rollup) {
1607
+ const rawState = String(item.conclusion || item.state || item.status || '').toUpperCase();
1608
+ if (!rawState || rawState === 'EXPECTED' || rawState === 'PENDING' || rawState === 'IN_PROGRESS' || rawState === 'QUEUED' || rawState === 'WAITING') {
1609
+ pending += 1;
1610
+ continue;
1611
+ }
1612
+
1613
+ if (rawState === 'SUCCESS' || rawState === 'NEUTRAL' || rawState === 'SKIPPED') {
1614
+ continue;
1615
+ }
1616
+
1617
+ failed += 1;
1618
+ }
1619
+
1620
+ return {
1621
+ pending,
1622
+ failed,
1623
+ total: rollup.length
1624
+ };
1625
+ }
1626
+
1627
+ function watchPrChecks(repo, prNumber, timeoutMinutes, deps) {
1628
+ const timeoutAt = nowMs(deps) + timeoutMinutes * 60 * 1000;
1629
+ while (nowMs(deps) <= timeoutAt) {
1630
+ const state = getPrCheckState(repo, prNumber, deps);
1631
+ if (state.failed > 0) {
1632
+ throw new Error(`PR #${prNumber} has failing required checks.`);
1633
+ }
1634
+
1635
+ if (state.pending === 0) {
1636
+ return 'green';
1637
+ }
1638
+
1639
+ waitForNextPoll(timeoutAt, 5000, deps);
1640
+ }
1641
+
1642
+ throw new Error(`Timed out waiting for checks on PR #${prNumber} after ${timeoutMinutes} minutes.`);
1643
+ }
1644
+
1645
+ function mergePrWhenGreen(repo, prNumber, mergeMethod, deps) {
1646
+ const methodFlag = mergeMethod === 'merge'
1647
+ ? '--merge'
1648
+ : mergeMethod === 'rebase'
1649
+ ? '--rebase'
1650
+ : '--squash';
1651
+ const merge = deps.exec('gh', ['pr', 'merge', String(prNumber), '--repo', repo, methodFlag, '--delete-branch']);
1652
+ if (merge.status !== 0) {
1653
+ throw new Error(`Failed to merge PR #${prNumber}: ${merge.stderr || merge.stdout}`.trim());
1654
+ }
1655
+ }
1656
+
1657
+ function getPrMergeReadiness(repo, prNumber, deps) {
1658
+ const view = deps.exec('gh', [
1659
+ 'pr',
1660
+ 'view',
1661
+ String(prNumber),
1662
+ '--repo',
1663
+ repo,
1664
+ '--json',
1665
+ 'number,url,reviewDecision,mergeStateStatus,isDraft,headRefName'
1666
+ ]);
1667
+ if (view.status !== 0) {
1668
+ throw new Error(`Failed to inspect merge readiness for PR #${prNumber}: ${view.stderr || view.stdout}`.trim());
1669
+ }
1670
+
1671
+ const parsed = parseJsonSafely(view.stdout || '{}', {});
1672
+ return {
1673
+ number: parsed.number || prNumber,
1674
+ url: parsed.url || '',
1675
+ reviewDecision: String(parsed.reviewDecision || '').toUpperCase(),
1676
+ mergeStateStatus: String(parsed.mergeStateStatus || '').toUpperCase(),
1677
+ isDraft: Boolean(parsed.isDraft),
1678
+ headRefName: String(parsed.headRefName || '')
1679
+ };
1680
+ }
1681
+
1682
+ function getLatestWorkflowRunForBranch(repo, branch, deps) {
1683
+ if (!branch) {
1684
+ return null;
1685
+ }
1686
+
1687
+ const runs = deps.exec('gh', [
1688
+ 'run',
1689
+ 'list',
1690
+ '--repo',
1691
+ repo,
1692
+ '--branch',
1693
+ branch,
1694
+ '--json',
1695
+ 'databaseId,workflowName,status,conclusion,url',
1696
+ '--limit',
1697
+ '10'
1698
+ ]);
1699
+ if (runs.status !== 0) {
1700
+ return null;
1701
+ }
1702
+
1703
+ const parsed = parseJsonSafely(runs.stdout || '[]', []);
1704
+ if (!Array.isArray(parsed) || parsed.length === 0) {
1705
+ return null;
1706
+ }
1707
+
1708
+ return parsed[0];
1709
+ }
1710
+
1711
+ function waitForPrMergeReadinessOrThrow(repo, prNumber, label, timeoutMinutes, deps, options = {}) {
1712
+ const timeoutAt = nowMs(deps) + timeoutMinutes * 60 * 1000;
1713
+ let lastReadiness = null;
1714
+ let lastChecks = null;
1715
+ const allowBehindTransient = Boolean(options.allowBehindTransient);
1716
+ let behindObservedAt = 0;
1717
+ while (nowMs(deps) <= timeoutAt) {
1718
+ const mergeState = getPrMergeState(repo, prNumber, deps);
1719
+ if (mergeState.state === 'MERGED' || mergeState.mergedAt) {
1720
+ return {
1721
+ number: prNumber,
1722
+ url: '',
1723
+ reviewDecision: 'APPROVED',
1724
+ mergeStateStatus: 'MERGED',
1725
+ isDraft: false
1726
+ };
1727
+ }
1728
+
1729
+ const readiness = getPrMergeReadiness(repo, prNumber, deps);
1730
+ const checks = getPrCheckState(repo, prNumber, deps);
1731
+ lastReadiness = readiness;
1732
+ lastChecks = checks;
1733
+
1734
+ if (readiness.isDraft) {
1735
+ throw new Error(`${label} is still a draft PR. Mark it ready for review before merge.`);
1736
+ }
1737
+
1738
+ if (readiness.reviewDecision === 'REVIEW_REQUIRED' || readiness.reviewDecision === 'CHANGES_REQUESTED') {
1739
+ throw new Error(
1740
+ [
1741
+ `${label} still requires review approval before merge.`,
1742
+ `reviewDecision: ${readiness.reviewDecision}`,
1743
+ readiness.url ? `PR: ${readiness.url}` : ''
1744
+ ].filter(Boolean).join('\n')
1745
+ );
1746
+ }
1747
+
1748
+ if (checks.failed > 0) {
1749
+ throw new Error(`${label} has failing required checks.`);
1750
+ }
1751
+
1752
+ if (readiness.mergeStateStatus === 'DIRTY') {
1753
+ throw new Error(
1754
+ [
1755
+ `${label} is not mergeable yet due to branch policy/state.`,
1756
+ `mergeStateStatus: ${readiness.mergeStateStatus}`,
1757
+ readiness.url ? `PR: ${readiness.url}` : ''
1758
+ ].filter(Boolean).join('\n')
1759
+ );
1760
+ }
1761
+
1762
+ if (readiness.mergeStateStatus === 'BEHIND') {
1763
+ if (allowBehindTransient) {
1764
+ const workflowRun = getLatestWorkflowRunForBranch(repo, readiness.headRefName, deps);
1765
+ const runStatus = String((workflowRun && workflowRun.status) || '').toLowerCase();
1766
+ const runConclusion = String((workflowRun && workflowRun.conclusion) || '').toLowerCase();
1767
+ const runCompleted = runStatus === 'completed';
1768
+ const runFailed = runCompleted && !['success', 'neutral', 'skipped'].includes(runConclusion);
1769
+ const runInProgress = ['queued', 'in_progress', 'waiting', 'requested', 'pending'].includes(runStatus);
1770
+
1771
+ if (runFailed) {
1772
+ throw new Error(
1773
+ [
1774
+ `${label} stayed BEHIND because latest workflow run failed.`,
1775
+ `workflow: ${workflowRun.workflowName || 'unknown'}`,
1776
+ `status/conclusion: ${workflowRun.status || 'n/a'}/${workflowRun.conclusion || 'n/a'}`,
1777
+ workflowRun.url ? `run: ${workflowRun.url}` : '',
1778
+ readiness.url ? `PR: ${readiness.url}` : ''
1779
+ ].filter(Boolean).join('\n')
1780
+ );
1781
+ }
1782
+
1783
+ if (!behindObservedAt) {
1784
+ behindObservedAt = nowMs(deps);
1785
+ }
1786
+
1787
+ // Avoid waiting the full global timeout when there is no active workflow progress.
1788
+ const behindElapsedMs = nowMs(deps) - behindObservedAt;
1789
+ const stagnationWindowMs = 10 * 1000;
1790
+ if (!runInProgress && behindElapsedMs >= stagnationWindowMs) {
1791
+ throw new Error(
1792
+ [
1793
+ `${label} remained BEHIND for more than 10s with no active workflow progress.`,
1794
+ runCompleted ? `latest workflow conclusion: ${runConclusion || 'n/a'}` : 'latest workflow status: unavailable',
1795
+ workflowRun && workflowRun.url ? `run: ${workflowRun.url}` : '',
1796
+ readiness.url ? `PR: ${readiness.url}` : '',
1797
+ 'If changeset workflow is still updating, rerun release-cycle in a moment.'
1798
+ ].filter(Boolean).join('\n')
1799
+ );
1800
+ }
1801
+
1802
+ waitForNextPoll(timeoutAt, 5000, deps);
1803
+ continue;
1804
+ }
1805
+ throw new Error(
1806
+ [
1807
+ `${label} is not mergeable yet due to branch policy/state.`,
1808
+ `mergeStateStatus: ${readiness.mergeStateStatus}`,
1809
+ readiness.url ? `PR: ${readiness.url}` : ''
1810
+ ].filter(Boolean).join('\n')
1811
+ );
1812
+ }
1813
+
1814
+ const mergeStateReady = readiness.mergeStateStatus === 'CLEAN'
1815
+ || readiness.mergeStateStatus === 'HAS_HOOKS'
1816
+ || readiness.mergeStateStatus === 'UNSTABLE';
1817
+ const mergeStateUnknown = !readiness.mergeStateStatus || readiness.mergeStateStatus === 'UNKNOWN' || readiness.mergeStateStatus === 'BLOCKED';
1818
+ if ((mergeStateReady && checks.pending === 0) || (mergeStateUnknown && checks.pending === 0 && !readiness.mergeStateStatus)) {
1819
+ return readiness;
1820
+ }
1821
+
1822
+ waitForNextPoll(timeoutAt, 5000, deps);
1823
+ }
1824
+
1825
+ throw new Error(
1826
+ [
1827
+ `${label} did not become merge-ready after ${timeoutMinutes} minutes.`,
1828
+ `mergeStateStatus: ${lastReadiness ? (lastReadiness.mergeStateStatus || 'n/a') : 'n/a'}`,
1829
+ `reviewDecision: ${lastReadiness ? (lastReadiness.reviewDecision || 'n/a') : 'n/a'}`,
1830
+ `pending checks: ${lastChecks ? lastChecks.pending : 'n/a'}`,
1831
+ allowBehindTransient ? 'Hint: release PR can stay BEHIND while changeset workflow updates its branch. Wait for workflow completion and rerun if needed.' : '',
1832
+ lastReadiness && lastReadiness.url ? `PR: ${lastReadiness.url}` : ''
1833
+ ].filter(Boolean).join('\n')
1834
+ );
1835
+ }
1836
+
1837
+ async function confirmMergeIfNeeded(args, readiness, label) {
1838
+ if (args.confirmMerges && !args.yes) {
1839
+ await confirmOrThrow(
1840
+ [
1841
+ `${label} is ready for merge.`,
1842
+ `reviewDecision: ${readiness.reviewDecision || 'n/a'}`,
1843
+ `mergeStateStatus: ${readiness.mergeStateStatus || 'n/a'}`,
1844
+ 'Proceed with merge now?'
1845
+ ].join('\n')
1846
+ );
1847
+ }
1848
+ }
1849
+
1850
+ function getPrMergeState(repo, prNumber, deps) {
1851
+ const view = deps.exec('gh', [
1852
+ 'pr',
1853
+ 'view',
1854
+ String(prNumber),
1855
+ '--repo',
1856
+ repo,
1857
+ '--json',
1858
+ 'state,mergedAt'
1859
+ ]);
1860
+ if (view.status !== 0) {
1861
+ throw new Error(`Failed to read PR #${prNumber} merge state: ${view.stderr || view.stdout}`.trim());
1862
+ }
1863
+
1864
+ const parsed = parseJsonSafely(view.stdout || '{}', {});
1865
+ return {
1866
+ state: String(parsed.state || '').toUpperCase(),
1867
+ mergedAt: parsed.mergedAt || ''
1868
+ };
1869
+ }
1870
+
1871
+ function waitForPrMerged(repo, prNumber, timeoutMinutes, deps) {
1872
+ const timeoutAt = nowMs(deps) + timeoutMinutes * 60 * 1000;
1873
+ while (nowMs(deps) <= timeoutAt) {
1874
+ const state = getPrMergeState(repo, prNumber, deps);
1875
+ if (state.state === 'MERGED' || state.mergedAt) {
1876
+ return true;
1877
+ }
1878
+ if (state.state === 'CLOSED') {
1879
+ throw new Error(`PR #${prNumber} was closed without merge.`);
1880
+ }
1881
+
1882
+ waitForNextPoll(timeoutAt, 5000, deps);
1883
+ }
1884
+
1885
+ throw new Error(`Timed out waiting for PR #${prNumber} merge after ${timeoutMinutes} minutes.`);
1886
+ }
1887
+
1888
+ function releaseBaseBranchForTrack(track) {
1889
+ return track === 'stable' ? DEFAULT_BASE_BRANCH : DEFAULT_BETA_BRANCH;
1890
+ }
1891
+
1892
+ function findReleasePrs(repo, deps, options = {}) {
1893
+ const expectedBase = options.expectedBase || '';
1894
+ const prs = listOpenPullRequests(repo, deps);
1895
+ return prs.filter(
1896
+ (item) => item.headRefName
1897
+ && item.headRefName.startsWith('changeset-release/')
1898
+ && (expectedBase
1899
+ ? item.baseRefName === expectedBase
1900
+ : (item.baseRefName === DEFAULT_BASE_BRANCH || item.baseRefName === DEFAULT_BETA_BRANCH))
1901
+ );
1902
+ }
1903
+
1904
+ function waitForReleasePr(repo, timeoutMinutes, deps, options = {}) {
1905
+ const expectedBase = options.expectedBase || '';
1906
+ const timeoutAt = nowMs(deps) + timeoutMinutes * 60 * 1000;
1907
+ while (nowMs(deps) <= timeoutAt) {
1908
+ const releasePrs = findReleasePrs(repo, deps, { expectedBase });
1909
+ if (releasePrs.length === 1) {
1910
+ return releasePrs[0];
1911
+ }
1912
+ if (releasePrs.length > 1) {
1913
+ throw new Error(`Multiple release PRs detected: ${releasePrs.map((item) => item.url).join(', ')}`);
1914
+ }
1915
+
1916
+ waitForNextPoll(timeoutAt, 5000, deps);
1917
+ }
1918
+
1919
+ const baseHint = expectedBase ? ` targeting ${expectedBase}` : '';
1920
+ throw new Error(`Timed out waiting for release PR${baseHint} after ${timeoutMinutes} minutes.`);
1921
+ }
1922
+
1923
+ async function confirmDetectedModeIfNeeded(args, mode, planText) {
1924
+ if (args.yes) {
1925
+ return;
1926
+ }
1927
+
1928
+ await confirmOrThrow(
1929
+ [
1930
+ `release-cycle detected mode: ${mode}`,
1931
+ planText
1932
+ ].join('\n')
1933
+ );
1934
+ }
1935
+
1936
+ function ghApiJson(deps, method, endpoint, payload) {
1937
+ const result = ghApi(deps, method, endpoint, payload);
1938
+ if (result.status !== 0) {
1939
+ throw new Error(`GitHub API ${method} ${endpoint} failed: ${result.stderr || result.stdout}`.trim());
1940
+ }
1941
+
1942
+ return parseJsonSafely(result.stdout || '{}', {});
1943
+ }
1944
+
1945
+ function dispatchPromoteStableWorkflow(repo, args, deps) {
1946
+ const endpoint = `/repos/${repo}/actions/workflows/${encodeURIComponent(DEFAULT_PROMOTE_WORKFLOW)}/dispatches`;
1947
+ const payload = {
1948
+ ref: args.head || DEFAULT_BETA_BRANCH,
1949
+ inputs: {
1950
+ promote_type: args.promoteType,
1951
+ summary: args.promoteSummary,
1952
+ target_beta_branch: DEFAULT_BETA_BRANCH
1953
+ }
1954
+ };
1955
+ ghApiJson(deps, 'POST', endpoint, payload);
1956
+ }
1957
+
1958
+ function findPromotionPrs(repo, deps) {
1959
+ const prs = listOpenPullRequests(repo, deps);
1960
+ return prs.filter(
1961
+ (item) => item.baseRefName === DEFAULT_BETA_BRANCH
1962
+ && typeof item.headRefName === 'string'
1963
+ && item.headRefName.startsWith('promote/stable-')
1964
+ );
1965
+ }
1966
+
1967
+ function waitForPromotionPr(repo, timeoutMinutes, deps) {
1968
+ const timeoutAt = nowMs(deps) + timeoutMinutes * 60 * 1000;
1969
+ while (nowMs(deps) <= timeoutAt) {
1970
+ const promotionPrs = findPromotionPrs(repo, deps);
1971
+ if (promotionPrs.length === 1) {
1972
+ return promotionPrs[0];
1973
+ }
1974
+
1975
+ if (promotionPrs.length > 1) {
1976
+ promotionPrs.sort((a, b) => b.number - a.number);
1977
+ return promotionPrs[0];
1978
+ }
1979
+
1980
+ waitForNextPoll(timeoutAt, 5000, deps);
1981
+ }
1982
+
1983
+ throw new Error(`Timed out waiting for promotion PR after ${timeoutMinutes} minutes.`);
1984
+ }
1985
+
1986
+ function getRemotePackageVersion(repo, ref, deps) {
1987
+ const endpoint = `/repos/${repo}/contents/package.json?ref=${encodeURIComponent(ref)}`;
1988
+ const contentResponse = ghApiJson(deps, 'GET', endpoint);
1989
+ if (!contentResponse.content) {
1990
+ throw new Error(`Could not read package.json content from ${repo}@${ref}.`);
1991
+ }
1992
+
1993
+ const decoded = Buffer.from(String(contentResponse.content).replace(/\n/g, ''), 'base64').toString('utf8');
1994
+ const parsed = parseJsonSafely(decoded, {});
1995
+ if (!parsed.name || !parsed.version) {
1996
+ throw new Error(`package.json from ${repo}@${ref} must include name and version.`);
1997
+ }
1998
+
1999
+ return {
2000
+ name: parsed.name,
2001
+ version: parsed.version
2002
+ };
2003
+ }
2004
+
2005
+ function validateNpmPublishedVersionAndTag(packageName, expectedVersion, expectedTag, timeoutMinutes, deps) {
2006
+ const timeoutAt = nowMs(deps) + timeoutMinutes * 60 * 1000;
2007
+ let lastObservedVersion = '';
2008
+ let lastObservedTagVersion = '';
2009
+
2010
+ while (nowMs(deps) <= timeoutAt) {
2011
+ const versionResult = deps.exec('npm', ['view', packageName, 'version', '--json']);
2012
+ const tagsResult = deps.exec('npm', ['view', packageName, 'dist-tags', '--json']);
2013
+ if (versionResult.status === 0 && tagsResult.status === 0) {
2014
+ const observedVersion = String(parseJsonSafely(versionResult.stdout || '""', '') || '');
2015
+ const tags = parseJsonSafely(tagsResult.stdout || '{}', {});
2016
+ const observedTagVersion = tags && tags[expectedTag] ? String(tags[expectedTag]) : '';
2017
+ lastObservedVersion = observedVersion;
2018
+ lastObservedTagVersion = observedTagVersion;
2019
+
2020
+ if (observedVersion === expectedVersion && observedTagVersion === expectedVersion) {
2021
+ return {
2022
+ status: 'pass',
2023
+ observedVersion,
2024
+ observedTagVersion
2025
+ };
2026
+ }
2027
+ }
2028
+
2029
+ waitForNextPoll(timeoutAt, 10000, deps);
2030
+ }
2031
+
2032
+ return {
2033
+ status: 'timeout',
2034
+ observedVersion: lastObservedVersion,
2035
+ observedTagVersion: lastObservedTagVersion
2036
+ };
2037
+ }
2038
+
2039
+ function ensureWorkingTreeClean(deps) {
2040
+ const status = deps.exec('git', ['status', '--porcelain']);
2041
+ if (status.status !== 0) {
2042
+ throw new Error('Failed to inspect working tree status.');
2043
+ }
2044
+
2045
+ return status.stdout.trim() === '';
2046
+ }
2047
+
2048
+ function isProtectedOrGeneratedBranch(branchName) {
2049
+ if (!branchName) {
2050
+ return true;
2051
+ }
2052
+
2053
+ return branchName === DEFAULT_BASE_BRANCH
2054
+ || branchName === DEFAULT_BETA_BRANCH
2055
+ || branchName.startsWith('changeset-release/')
2056
+ || branchName.startsWith('promote/');
2057
+ }
2058
+
2059
+ function isCleanupCandidateBranch(branchName) {
2060
+ if (!branchName) {
2061
+ return false;
2062
+ }
2063
+
2064
+ return /^(feat|fix|chore|refactor|test)\//.test(branchName);
2065
+ }
2066
+
2067
+ function syncBranchWithBase({
2068
+ deps,
2069
+ headBranch,
2070
+ baseBranch,
2071
+ strategy,
2072
+ reporter,
2073
+ summary,
2074
+ dryRun
2075
+ }) {
2076
+ if (strategy === 'off') {
2077
+ summary.actionsSkipped.push('sync base branch');
2078
+ return {
2079
+ synchronized: false,
2080
+ wasBehind: false
2081
+ };
2082
+ }
2083
+
2084
+ reporter.start('release-cycle-sync-fetch', `Fetching origin/${baseBranch}...`);
2085
+ const fetch = deps.exec('git', ['fetch', 'origin', baseBranch]);
2086
+ if (fetch.status !== 0) {
2087
+ throw new Error(`Failed to fetch origin/${baseBranch}: ${(fetch.stderr || fetch.stdout || '').trim()}`);
2088
+ }
2089
+ reporter.ok('release-cycle-sync-fetch', `Fetched origin/${baseBranch}.`);
2090
+
2091
+ const behindCheck = deps.exec('git', ['rev-list', '--left-right', '--count', `${headBranch}...origin/${baseBranch}`]);
2092
+ if (behindCheck.status !== 0) {
2093
+ throw new Error(`Failed to compare ${headBranch} against origin/${baseBranch}.`);
2094
+ }
2095
+
2096
+ const parts = (behindCheck.stdout || '').trim().split(/\s+/);
2097
+ const behindCount = Number.parseInt(parts[1] || '0', 10);
2098
+ const isBehind = Number.isInteger(behindCount) && behindCount > 0;
2099
+ if (!isBehind) {
2100
+ summary.actionsPerformed.push(`sync base: ${headBranch} already up to date with origin/${baseBranch}`);
2101
+ return {
2102
+ synchronized: true,
2103
+ wasBehind: false
2104
+ };
2105
+ }
2106
+
2107
+ const effectiveStrategy = strategy === 'auto' ? 'rebase' : strategy;
2108
+ if (dryRun) {
2109
+ summary.actionsPerformed.push(`dry-run: would ${effectiveStrategy} ${headBranch} onto origin/${baseBranch}`);
2110
+ return {
2111
+ synchronized: false,
2112
+ wasBehind: true
2113
+ };
2114
+ }
2115
+
2116
+ if (effectiveStrategy === 'rebase') {
2117
+ reporter.start('release-cycle-sync-rebase', `Rebasing ${headBranch} onto origin/${baseBranch}...`);
2118
+ const rebase = deps.exec('git', ['rebase', `origin/${baseBranch}`]);
2119
+ if (rebase.status !== 0) {
2120
+ throw new Error(
2121
+ [
2122
+ `Rebase failed while syncing ${headBranch} with origin/${baseBranch}.`,
2123
+ 'Resolve conflicts, then run `git rebase --continue` or `git rebase --abort`.',
2124
+ (rebase.stderr || rebase.stdout || '').trim()
2125
+ ].filter(Boolean).join('\n')
2126
+ );
2127
+ }
2128
+ reporter.ok('release-cycle-sync-rebase', `${headBranch} rebased onto origin/${baseBranch}.`);
2129
+ summary.actionsPerformed.push(`sync base: rebased ${headBranch} onto origin/${baseBranch}`);
2130
+ return {
2131
+ synchronized: true,
2132
+ wasBehind: true
2133
+ };
2134
+ }
2135
+
2136
+ reporter.start('release-cycle-sync-merge', `Merging origin/${baseBranch} into ${headBranch}...`);
2137
+ const merge = deps.exec('git', ['merge', '--no-edit', `origin/${baseBranch}`]);
2138
+ if (merge.status !== 0) {
2139
+ throw new Error(
2140
+ [
2141
+ `Merge failed while syncing ${headBranch} with origin/${baseBranch}.`,
2142
+ 'Resolve conflicts and commit merge before rerunning.',
2143
+ (merge.stderr || merge.stdout || '').trim()
2144
+ ].filter(Boolean).join('\n')
2145
+ );
2146
+ }
2147
+ reporter.ok('release-cycle-sync-merge', `Merged origin/${baseBranch} into ${headBranch}.`);
2148
+ summary.actionsPerformed.push(`sync base: merged origin/${baseBranch} into ${headBranch}`);
2149
+ return {
2150
+ synchronized: true,
2151
+ wasBehind: true
2152
+ };
2153
+ }
2154
+
2155
+ function isHeadIntegratedIntoBase(headRef, baseBranch, deps) {
2156
+ const fetch = deps.exec('git', ['fetch', 'origin', baseBranch]);
2157
+ if (fetch.status !== 0) {
2158
+ return false;
2159
+ }
2160
+ const ancestor = deps.exec('git', ['merge-base', '--is-ancestor', headRef, `origin/${baseBranch}`]);
2161
+ return ancestor.status === 0;
2162
+ }
2163
+
2164
+ function runLocalCleanup({
2165
+ deps,
2166
+ originalBranch,
2167
+ targetBaseBranch,
2168
+ shouldRun,
2169
+ summary,
2170
+ reporter
2171
+ }) {
2172
+ if (!shouldRun) {
2173
+ summary.actionsSkipped.push('cleanup');
2174
+ summary.cleanup = 'skipped';
2175
+ summary.warnings.push('Local cleanup skipped by configuration (--no-cleanup).');
2176
+ return;
2177
+ }
2178
+
2179
+ if (!isCleanupCandidateBranch(originalBranch)) {
2180
+ summary.actionsSkipped.push('cleanup');
2181
+ summary.cleanup = 'skipped';
2182
+ summary.warnings.push(`Cleanup skipped: branch "${originalBranch}" is not an allowed code branch pattern.`);
2183
+ return;
2184
+ }
2185
+
2186
+ if (isProtectedOrGeneratedBranch(originalBranch)) {
2187
+ summary.actionsSkipped.push('cleanup');
2188
+ summary.cleanup = 'skipped';
2189
+ summary.warnings.push(`Cleanup skipped: branch "${originalBranch}" is protected or generated.`);
2190
+ return;
2191
+ }
2192
+
2193
+ if (!ensureWorkingTreeClean(deps)) {
2194
+ summary.actionsSkipped.push('cleanup');
2195
+ summary.cleanup = 'skipped';
2196
+ summary.warnings.push('Cleanup skipped: working tree is not clean.');
2197
+ return;
2198
+ }
2199
+
2200
+ reporter.start('release-cycle-cleanup-checkout', `Checking out ${targetBaseBranch}...`);
2201
+ const checkout = deps.exec('git', ['checkout', targetBaseBranch]);
2202
+ if (checkout.status !== 0) {
2203
+ summary.cleanup = 'failed';
2204
+ summary.warnings.push(`Cleanup failed: could not checkout ${targetBaseBranch}: ${(checkout.stderr || checkout.stdout || '').trim()}`);
2205
+ reporter.warn('release-cycle-cleanup-checkout', `Could not checkout ${targetBaseBranch}.`);
2206
+ return;
2207
+ }
2208
+ reporter.ok('release-cycle-cleanup-checkout', `Checked out ${targetBaseBranch}.`);
2209
+
2210
+ reporter.start('release-cycle-cleanup-pull', `Pulling latest ${targetBaseBranch}...`);
2211
+ const pull = deps.exec('git', ['pull']);
2212
+ if (pull.status !== 0) {
2213
+ summary.cleanup = 'failed';
2214
+ summary.warnings.push(`Cleanup warning: could not pull ${targetBaseBranch}: ${(pull.stderr || pull.stdout || '').trim()}`);
2215
+ reporter.warn('release-cycle-cleanup-pull', `Could not pull ${targetBaseBranch}.`);
2216
+ } else {
2217
+ reporter.ok('release-cycle-cleanup-pull', `Pulled ${targetBaseBranch}.`);
2218
+ }
2219
+
2220
+ reporter.start('release-cycle-cleanup-delete', `Deleting local branch ${originalBranch}...`);
2221
+ const deleteResult = deps.exec('git', ['branch', '-d', originalBranch]);
2222
+ if (deleteResult.status !== 0) {
2223
+ summary.cleanup = 'failed';
2224
+ summary.warnings.push(`Cleanup warning: could not delete ${originalBranch}: ${(deleteResult.stderr || deleteResult.stdout || '').trim()}`);
2225
+ reporter.warn('release-cycle-cleanup-delete', `Could not delete ${originalBranch}.`);
2226
+ } else {
2227
+ summary.actionsPerformed.push(`cleanup deleted branch: ${originalBranch}`);
2228
+ summary.cleanup = 'completed';
2229
+ reporter.ok('release-cycle-cleanup-delete', `Deleted ${originalBranch}.`);
2230
+ }
2231
+ }
2232
+
2233
+ function ensureFileFromTemplate(targetPath, templatePath, options) {
2234
+ const exists = fs.existsSync(targetPath);
2235
+
2236
+ if (exists && !options.force) {
2237
+ return 'skipped';
2238
+ }
2239
+
2240
+ if (options.dryRun) {
2241
+ return exists ? 'overwritten' : 'created';
2242
+ }
2243
+
2244
+ const source = fs.readFileSync(templatePath, 'utf8');
2245
+ const rendered = renderTemplateString(source, options.variables);
2246
+
2247
+ fs.mkdirSync(path.dirname(targetPath), { recursive: true });
2248
+ fs.writeFileSync(targetPath, rendered);
2249
+
2250
+ if (exists) {
2251
+ return 'overwritten';
2252
+ }
2253
+
2254
+ return 'created';
2255
+ }
2256
+
2257
+ function ensureCreateOnlyFromTemplate(targetPath, templatePath, options) {
2258
+ if (fs.existsSync(targetPath)) {
2259
+ return 'skipped';
2260
+ }
2261
+
2262
+ return ensureFileFromTemplate(targetPath, templatePath, options);
2263
+ }
2264
+
2265
+ function appendGitignoreTemplate(targetPath, templatePath, options) {
2266
+ if (!fs.existsSync(targetPath)) {
2267
+ return ensureFileFromTemplate(targetPath, templatePath, options);
2268
+ }
2269
+
2270
+ const currentRaw = fs.readFileSync(targetPath, 'utf8');
2271
+ const templateRaw = fs.readFileSync(templatePath, 'utf8');
2272
+ const currentSet = new Set(
2273
+ currentRaw
2274
+ .split(/\r?\n/)
2275
+ .map((line) => line.trim())
2276
+ .filter(Boolean)
2277
+ );
2278
+
2279
+ const missingLines = templateRaw
2280
+ .split(/\r?\n/)
2281
+ .map((line) => line.trim())
2282
+ .filter((line) => line && !currentSet.has(line));
2283
+
2284
+ if (!missingLines.length) {
2285
+ return 'skipped';
2286
+ }
2287
+
2288
+ if (options.dryRun) {
2289
+ return 'updated';
2290
+ }
2291
+
2292
+ const needsSeparator = currentRaw.length > 0 && !currentRaw.endsWith('\n');
2293
+ const prefix = needsSeparator ? '\n' : '';
2294
+ fs.appendFileSync(targetPath, `${prefix}${missingLines.join('\n')}\n`);
2295
+ return 'updated';
2296
+ }
2297
+
2298
+ function ensureReleaseWorkflowBranches(content, defaultBranch, betaBranch) {
2299
+ const lines = content.split('\n');
2300
+ const onIndex = lines.findIndex((line) => line.trim() === 'on:');
2301
+
2302
+ if (onIndex < 0) {
2303
+ return null;
2304
+ }
2305
+
2306
+ let onSectionEnd = lines.length;
2307
+ for (let i = onIndex + 1; i < lines.length; i += 1) {
2308
+ const line = lines[i];
2309
+ const isTopLevelKey = line && !line.startsWith(' ') && line.trim().endsWith(':');
2310
+ if (isTopLevelKey) {
2311
+ onSectionEnd = i;
2312
+ break;
2313
+ }
2314
+ }
2315
+
2316
+ const onBlock = lines.slice(onIndex, onSectionEnd);
2317
+ const pushRelativeIndex = onBlock.findIndex((line) => line.trim() === 'push:');
2318
+ if (pushRelativeIndex < 0) {
2319
+ return null;
2320
+ }
2321
+
2322
+ const branchesRelativeIndex = onBlock.findIndex((line) => line.trim() === 'branches:');
2323
+ if (branchesRelativeIndex < 0 || branchesRelativeIndex <= pushRelativeIndex) {
2324
+ return null;
2325
+ }
2326
+
2327
+ const listStart = branchesRelativeIndex + 1;
2328
+ let listEnd = listStart;
2329
+ while (listEnd < onBlock.length && onBlock[listEnd].trim().startsWith('- ')) {
2330
+ listEnd += 1;
2331
+ }
2332
+
2333
+ if (listEnd === listStart) {
2334
+ return null;
2335
+ }
2336
+
2337
+ const existingBranches = onBlock.slice(listStart, listEnd)
2338
+ .map((line) => line.trim().replace(/^- /, '').trim())
2339
+ .filter(Boolean);
2340
+
2341
+ const desiredBranches = [...new Set([defaultBranch, betaBranch])];
2342
+ const mergedBranches = [...existingBranches];
2343
+ for (const branch of desiredBranches) {
2344
+ if (!mergedBranches.includes(branch)) {
2345
+ mergedBranches.push(branch);
2346
+ }
2347
+ }
2348
+
2349
+ const changed = mergedBranches.length !== existingBranches.length
2350
+ || mergedBranches.some((branch, index) => branch !== existingBranches[index]);
2351
+
2352
+ if (!changed) {
2353
+ return {
2354
+ changed: false,
2355
+ content
2356
+ };
2357
+ }
2358
+
2359
+ const updatedOnBlock = [
2360
+ ...onBlock.slice(0, listStart),
2361
+ ...mergedBranches.map((branch) => ` - ${branch}`),
2362
+ ...onBlock.slice(listEnd)
2363
+ ];
2364
+
2365
+ const updatedLines = [
2366
+ ...lines.slice(0, onIndex),
2367
+ ...updatedOnBlock,
2368
+ ...lines.slice(onSectionEnd)
2369
+ ];
2370
+
2371
+ return {
2372
+ changed: true,
2373
+ content: updatedLines.join('\n')
2374
+ };
2375
+ }
2376
+
2377
+ function upsertReleaseWorkflow(targetPath, templatePath, options) {
2378
+ const exists = fs.existsSync(targetPath);
2379
+ if (!exists || options.force) {
2380
+ if (options.dryRun) {
2381
+ return {
2382
+ result: exists ? 'overwritten' : 'created'
2383
+ };
2384
+ }
2385
+
2386
+ const result = ensureFileFromTemplate(targetPath, templatePath, {
2387
+ force: options.force,
2388
+ dryRun: options.dryRun,
2389
+ variables: options.variables
2390
+ });
2391
+ return { result };
2392
+ }
2393
+
2394
+ const current = fs.readFileSync(targetPath, 'utf8');
2395
+ const ensured = ensureReleaseWorkflowBranches(
2396
+ current,
2397
+ options.variables.DEFAULT_BRANCH,
2398
+ options.variables.BETA_BRANCH
2399
+ );
2400
+
2401
+ if (!ensured) {
2402
+ return {
2403
+ result: 'skipped',
2404
+ warning: `Could not safely update trigger branches in ${path.basename(targetPath)}. Use --force to overwrite from template.`
2405
+ };
2406
+ }
2407
+
2408
+ if (!ensured.changed) {
2409
+ return { result: 'skipped' };
2410
+ }
2411
+
2412
+ if (options.dryRun) {
2413
+ return { result: 'updated' };
2414
+ }
2415
+
2416
+ fs.writeFileSync(targetPath, ensured.content);
2417
+ return { result: 'updated' };
2418
+ }
2419
+
2420
+ function detectEquivalentManagedFile(packageDir, targetRelativePath) {
2421
+ if (targetRelativePath !== '.github/PULL_REQUEST_TEMPLATE.md') {
2422
+ return targetRelativePath;
2423
+ }
2424
+
2425
+ const canonicalPath = path.join(packageDir, targetRelativePath);
2426
+ if (fs.existsSync(canonicalPath)) {
2427
+ return targetRelativePath;
2428
+ }
2429
+
2430
+ const legacyLowercase = '.github/pull_request_template.md';
2431
+ if (fs.existsSync(path.join(packageDir, legacyLowercase))) {
2432
+ return legacyLowercase;
2433
+ }
2434
+
2435
+ return targetRelativePath;
2436
+ }
2437
+
2438
+ function updateManagedFiles(packageDir, templateDir, options, summary) {
2439
+ for (const [targetRelativePath, templateRelativePath] of MANAGED_FILE_SPECS) {
2440
+ const effectiveTargetRelative = detectEquivalentManagedFile(packageDir, targetRelativePath);
2441
+ const targetPath = path.join(packageDir, effectiveTargetRelative);
2442
+ const templatePath = path.join(templateDir, templateRelativePath);
2443
+
2444
+ if (!fs.existsSync(templatePath)) {
2445
+ throw new Error(`Template not found: ${templatePath}`);
2446
+ }
2447
+
2448
+ let result;
2449
+ if (targetRelativePath === '.gitignore') {
2450
+ result = appendGitignoreTemplate(targetPath, templatePath, {
2451
+ force: options.force,
2452
+ dryRun: options.dryRun,
2453
+ variables: options.variables
2454
+ });
2455
+ } else if (INIT_CREATE_ONLY_FILES.has(targetRelativePath)) {
2456
+ result = ensureCreateOnlyFromTemplate(targetPath, templatePath, {
2457
+ force: options.force,
2458
+ dryRun: options.dryRun,
2459
+ variables: options.variables
2460
+ });
2461
+ } else {
2462
+ result = ensureFileFromTemplate(targetPath, templatePath, {
2463
+ force: options.force,
2464
+ dryRun: options.dryRun,
2465
+ variables: options.variables
2466
+ });
2467
+ }
2468
+
2469
+ if (result === 'created') {
2470
+ summary.createdFiles.push(targetRelativePath);
2471
+ } else if (result === 'overwritten' || result === 'updated') {
2472
+ summary.overwrittenFiles.push(targetRelativePath);
2473
+ } else {
2474
+ summary.skippedFiles.push(targetRelativePath);
2475
+ }
2476
+ }
2477
+ }
2478
+
2479
+ function removeLegacyReleaseScripts(packageJson, summary) {
2480
+ const keys = Object.keys(packageJson.scripts || {});
2481
+
2482
+ for (const key of keys) {
2483
+ const isLegacy = key === 'release:dist-tags'
2484
+ || key.startsWith('release:beta')
2485
+ || key.startsWith('release:stable')
2486
+ || key.startsWith('release:promote')
2487
+ || key.startsWith('release:rollback');
2488
+
2489
+ if (!isLegacy) {
2490
+ continue;
2491
+ }
2492
+
2493
+ delete packageJson.scripts[key];
2494
+ summary.removedScriptKeys.push(key);
2495
+ }
2496
+ }
2497
+
2498
+ function configureExistingPackage(packageDir, templateDir, options) {
2499
+ if (!fs.existsSync(packageDir)) {
2500
+ throw new Error(`Directory not found: ${packageDir}`);
2501
+ }
2502
+
2503
+ const packageJsonPath = path.join(packageDir, 'package.json');
2504
+ if (!fs.existsSync(packageJsonPath)) {
2505
+ throw new Error(`package.json not found in ${packageDir}`);
2506
+ }
2507
+
2508
+ const packageJson = readJsonFile(packageJsonPath);
2509
+ packageJson.scripts = packageJson.scripts || {};
2510
+ packageJson.devDependencies = packageJson.devDependencies || {};
2511
+
2512
+ const summary = createSummary();
2513
+
2514
+ const desiredScripts = {
2515
+ check: 'npm run test',
2516
+ changeset: 'changeset',
2517
+ 'version-packages': 'changeset version',
2518
+ release: 'npm run check && changeset publish',
2519
+ 'beta:enter': 'changeset pre enter beta',
2520
+ 'beta:exit': 'changeset pre exit',
2521
+ 'beta:version': 'changeset version',
2522
+ 'beta:publish': 'changeset publish',
2523
+ 'beta:promote': 'create-package-starter promote-stable --dir .'
2524
+ };
2525
+
2526
+ let packageJsonChanged = false;
2527
+
2528
+ for (const [key, value] of Object.entries(desiredScripts)) {
2529
+ const exists = Object.prototype.hasOwnProperty.call(packageJson.scripts, key);
2530
+
2531
+ if (key === 'check') {
2532
+ if (!exists) {
2533
+ packageJson.scripts[key] = value;
2534
+ packageJsonChanged = true;
2535
+ summary.updatedScriptKeys.push(key);
2536
+ } else if (options.force && packageJson.scripts[key] !== value) {
2537
+ packageJson.scripts[key] = value;
2538
+ packageJsonChanged = true;
2539
+ summary.updatedScriptKeys.push(key);
2540
+ } else {
2541
+ summary.skippedScriptKeys.push(key);
2542
+ }
2543
+ continue;
2544
+ }
2545
+
2546
+ if (!exists || options.force) {
2547
+ if (!exists || packageJson.scripts[key] !== value) {
2548
+ packageJson.scripts[key] = value;
2549
+ packageJsonChanged = true;
2550
+ }
2551
+ summary.updatedScriptKeys.push(key);
2552
+ continue;
2553
+ }
2554
+
2555
+ summary.skippedScriptKeys.push(key);
2556
+ }
2557
+
2558
+ const depExists = Object.prototype.hasOwnProperty.call(packageJson.devDependencies, CHANGESETS_DEP);
2559
+
2560
+ if (!depExists || options.force) {
2561
+ if (!depExists || packageJson.devDependencies[CHANGESETS_DEP] !== CHANGESETS_DEP_VERSION) {
2562
+ packageJson.devDependencies[CHANGESETS_DEP] = CHANGESETS_DEP_VERSION;
2563
+ packageJsonChanged = true;
2564
+ }
2565
+ summary.updatedDependencyKeys.push(CHANGESETS_DEP);
2566
+ } else {
2567
+ summary.skippedDependencyKeys.push(CHANGESETS_DEP);
2568
+ }
2569
+
2570
+ if (options.cleanupLegacyRelease) {
2571
+ const before = summary.removedScriptKeys.length;
2572
+ removeLegacyReleaseScripts(packageJson, summary);
2573
+ if (summary.removedScriptKeys.length > before) {
2574
+ packageJsonChanged = true;
2575
+ }
2576
+ }
2577
+
2578
+ const packageName = packageJson.name || packageDirFromName(path.basename(packageDir));
2579
+
2580
+ updateManagedFiles(packageDir, templateDir, {
2581
+ force: options.force,
2582
+ dryRun: options.dryRun,
2583
+ variables: {
2584
+ PACKAGE_NAME: packageName,
2585
+ DEFAULT_BRANCH: options.defaultBranch,
2586
+ BETA_BRANCH: options.betaBranch || DEFAULT_BETA_BRANCH,
2587
+ SCOPE: deriveScope(options.scope, packageName),
2588
+ ...buildReleaseAuthVariables(options.releaseAuth || DEFAULT_RELEASE_AUTH)
2589
+ }
2590
+ }, summary);
2591
+
2592
+ if (packageJsonChanged && !options.dryRun) {
2593
+ writeJsonFile(packageJsonPath, packageJson);
2594
+ }
2595
+
2596
+ return summary;
2597
+ }
2598
+
2599
+ function createNewPackage(args) {
2600
+ if (!validateName(args.name)) {
2601
+ throw new Error('Provide a valid package name with --name (for example: hello-package or @i-santos/swarm).');
2602
+ }
2603
+
2604
+ const packageRoot = path.resolve(__dirname, '..');
2605
+ const templateDir = path.join(packageRoot, 'template');
2606
+
2607
+ if (!fs.existsSync(templateDir)) {
2608
+ throw new Error(`Template not found in ${templateDir}`);
2609
+ }
2610
+
2611
+ const outputDir = path.resolve(args.out);
2612
+ const targetDir = path.join(outputDir, packageDirFromName(args.name));
2613
+
2614
+ if (fs.existsSync(targetDir)) {
2615
+ throw new Error(`Directory already exists: ${targetDir}`);
2616
+ }
2617
+
2618
+ const summary = createSummary();
2619
+
2620
+ const createdFiles = copyDirRecursive(templateDir, targetDir, {
2621
+ PACKAGE_NAME: args.name,
2622
+ DEFAULT_BRANCH: args.defaultBranch,
2623
+ BETA_BRANCH: DEFAULT_BETA_BRANCH,
2624
+ SCOPE: deriveScope('', args.name),
2625
+ ...buildReleaseAuthVariables(args.releaseAuth || DEFAULT_RELEASE_AUTH)
2626
+ });
2627
+
2628
+ summary.createdFiles.push(...createdFiles);
2629
+
2630
+ summary.updatedScriptKeys.push('check', 'changeset', 'version-packages', 'release');
2631
+ summary.updatedScriptKeys.push('beta:enter', 'beta:exit', 'beta:version', 'beta:publish', 'beta:promote');
2632
+ summary.updatedScriptKeys.push(`release.auth:${args.releaseAuth}`);
2633
+ summary.updatedDependencyKeys.push(CHANGESETS_DEP);
2634
+ appendReleaseAuthWarnings(summary, args.releaseAuth);
2635
+
2636
+ printSummary(`Package created in ${targetDir}`, summary);
2637
+ }
2638
+
2639
+ async function initExistingPackage(args, dependencies = {}) {
2640
+ const reporter = new StepReporter();
2641
+ const selections = await resolveInitSelections(args);
2642
+ const packageRoot = path.resolve(__dirname, '..');
2643
+ const templateDir = path.join(packageRoot, 'template');
2644
+ const targetDir = path.resolve(args.dir);
2645
+ const overallSummary = createSummary();
2646
+ const deps = {
2647
+ exec: dependencies.exec || execCommand
2648
+ };
2649
+ await resolveReleaseAuthSelection(args, overallSummary, { contextLabel: 'Select release auth mode for release workflow' });
2650
+ overallSummary.updatedScriptKeys.push(`release.auth:${args.releaseAuth}`);
2651
+
2652
+ if (!selections.withGithub && !selections.withNpm && !selections.withBeta && !process.stdin.isTTY) {
2653
+ overallSummary.warnings.push('No --with-* flags were provided in non-interactive mode. Only local init was applied.');
2654
+ }
2655
+
2656
+ const context = prevalidateInitExecution(args, selections, dependencies, reporter);
2657
+ appendReleaseAuthWarnings(overallSummary, args.releaseAuth, {
2658
+ missingAppSecrets: context.missingReleaseAuthAppSecrets,
2659
+ appSecretsChecked: selections.withGithub && args.releaseAuth === 'app'
2660
+ });
2661
+ await confirmInitPlan(args, selections, context, overallSummary);
2662
+
2663
+ reporter.start('local-init', 'Applying local package bootstrap...');
2664
+ const localSummary = configureExistingPackage(targetDir, templateDir, {
2665
+ ...args,
2666
+ dryRun: args.dryRun,
2667
+ betaBranch: args.betaBranch
2668
+ });
2669
+ mergeSummary(overallSummary, localSummary);
2670
+ reporter.ok('local-init', args.dryRun ? 'Local package bootstrap previewed.' : 'Local package bootstrap applied.');
2671
+
2672
+ if (selections.withGithub && selections.withBeta) {
2673
+ ensureBetaWorkflowTriggers(
2674
+ targetDir,
2675
+ templateDir,
2676
+ {
2677
+ force: args.force,
2678
+ dryRun: args.dryRun,
2679
+ defaultBranch: args.defaultBranch,
2680
+ betaBranch: args.betaBranch,
2681
+ packageName: context.packageName,
2682
+ scope: deriveScope(args.scope, context.packageName),
2683
+ releaseAuth: args.releaseAuth
2684
+ },
2685
+ overallSummary,
2686
+ reporter
2687
+ );
2688
+ }
2689
+
2690
+ let repo = context.repo;
2691
+ if (selections.withGithub) {
2692
+ const githubSummary = createSummary();
2693
+ const mainResult = applyGithubMainSetup(
2694
+ {
2695
+ repo: context.repo,
2696
+ defaultBranch: args.defaultBranch,
2697
+ ruleset: args.ruleset,
2698
+ dryRun: args.dryRun
2699
+ },
2700
+ { exec: deps.exec },
2701
+ githubSummary,
2702
+ reporter
2703
+ );
2704
+ repo = mainResult.repo;
2705
+
2706
+ if (selections.withBeta) {
2707
+ applyGithubBetaSetup(
2708
+ {
2709
+ betaBranch: args.betaBranch,
2710
+ defaultBranch: args.defaultBranch,
2711
+ dryRun: args.dryRun
2712
+ },
2713
+ { exec: deps.exec },
2714
+ githubSummary,
2715
+ reporter,
2716
+ repo
2717
+ );
2718
+ }
2719
+
2720
+ mergeSummary(overallSummary, githubSummary);
2721
+ }
2722
+
2723
+ if (selections.withNpm) {
2724
+ const npmSummary = runNpmSetup(
2725
+ {
2726
+ dir: targetDir,
2727
+ dryRun: args.dryRun,
2728
+ publishFirst: false
2729
+ },
2730
+ { exec: deps.exec },
2731
+ {
2732
+ reporter,
2733
+ publishMissingByDefault: true
2734
+ }
2735
+ );
2736
+ mergeSummary(overallSummary, npmSummary);
2737
+ }
2738
+
2739
+ reporter.start('npm-install-final', 'Running npm install...');
2740
+ if (args.dryRun) {
2741
+ overallSummary.warnings.push(`dry-run: would run "npm install" in ${targetDir}`);
2742
+ reporter.warn('npm-install-final', 'Dry-run enabled; npm install was not executed.');
2743
+ } else {
2744
+ const install = deps.exec('npm', ['install'], { cwd: targetDir, stdio: 'inherit' });
2745
+ if (install.status !== 0) {
2746
+ reporter.fail('npm-install-final', 'npm install failed.');
2747
+ throw new Error(`Failed to run npm install in ${targetDir}.`);
2748
+ }
2749
+ reporter.ok('npm-install-final', 'npm install completed.');
2750
+ overallSummary.updatedDependencyKeys.push('npm.install');
2751
+ }
2752
+
2753
+ printSummary(`Project initialized in ${targetDir}`, overallSummary);
2754
+ }
2755
+
2756
+ function execCommand(command, args, options = {}) {
2757
+ return spawnSync(command, args, {
2758
+ encoding: 'utf8',
2759
+ ...options
2760
+ });
2761
+ }
2762
+
2763
+ async function runOpenPrFlow(args, dependencies = {}) {
2764
+ const deps = {
2765
+ exec: dependencies.exec || execCommand
2766
+ };
2767
+ const summary = createOrchestrationSummary();
2768
+ const reporter = new StepReporter();
2769
+
2770
+ reporter.start('open-pr-preflight-gh', 'Validating GitHub CLI and authentication...');
2771
+ ensureGhAvailable(deps);
2772
+ reporter.ok('open-pr-preflight-gh', 'GitHub CLI available and authenticated.');
2773
+
2774
+ reporter.start('open-pr-preflight-git', 'Resolving git context...');
2775
+ const context = resolveGitContext(args, deps);
2776
+ reporter.ok('open-pr-preflight-git', `Using ${context.head} -> ${context.base} in ${context.repo}.`);
2777
+ summary.modeDetected = 'open-pr';
2778
+ summary.repoResolved = context.repo;
2779
+
2780
+ const generatedBody = renderPrBodyDeterministic(context, deps, {
2781
+ cwd: process.cwd(),
2782
+ body: args.body,
2783
+ bodyFile: args.bodyFile,
2784
+ template: args.template
2785
+ });
2786
+
2787
+ const shouldPrintSummary = args.printSummary !== false;
2788
+
2789
+ if (args.dryRun) {
2790
+ summary.branchPushed = `dry-run: would push ${context.head}`;
2791
+ summary.prAction = `dry-run: would create/update PR ${context.head} -> ${context.base}`;
2792
+ summary.prUrl = 'dry-run';
2793
+ summary.autoMerge = args.autoMerge ? 'dry-run: would enable auto-merge' : 'skipped';
2794
+ summary.checks = args.watchChecks ? `dry-run: would watch checks (${args.checkTimeout}m)` : 'skipped';
2795
+ summary.merge = 'skipped';
2796
+ summary.releasePr = 'skipped';
2797
+ summary.actionsPerformed.push('rendered deterministic PR body', 'prepared push/create/update plan');
2798
+ if (!args.body && !args.bodyFile && !args.template) {
2799
+ summary.warnings.push('No body inputs provided; deterministic generated body would be used.');
2800
+ }
2801
+ if (shouldPrintSummary) {
2802
+ printOrchestrationSummary(`open-pr dry-run for ${context.repo}`, summary);
2803
+ }
2804
+ return {
2805
+ summary,
2806
+ context,
2807
+ pr: null
2808
+ };
2809
+ }
2810
+
2811
+ if (args.skipPush) {
2812
+ summary.branchPushed = `skipped (${context.head})`;
2813
+ summary.actionsSkipped.push(`push skipped: ${context.head}`);
2814
+ } else {
2815
+ reporter.start('open-pr-push', `Pushing branch "${context.head}"...`);
2816
+ const pushResult = ensureBranchPushed(context.repo, context.head, deps);
2817
+ reporter.ok('open-pr-push', `Branch "${context.head}" pushed (${pushResult.status}).`);
2818
+ summary.branchPushed = `${context.head} (${pushResult.status})`;
2819
+ summary.actionsPerformed.push(`branch pushed: ${context.head}`);
2820
+ if (pushResult.status === 'up-to-date') {
2821
+ summary.warnings.push(`Branch "${context.head}" had no new commits to push.`);
2822
+ }
2823
+ }
2824
+
2825
+ reporter.start('open-pr-upsert', 'Creating or updating pull request...');
2826
+ const prResult = createOrUpdatePr(context, generatedBody, args, deps);
2827
+ reporter.ok('open-pr-upsert', `PR ${prResult.action}: #${prResult.number}`);
2828
+ summary.prAction = `${prResult.action} (#${prResult.number})`;
2829
+ summary.prUrl = prResult.url || 'n/a';
2830
+ summary.actionsPerformed.push(`pr ${prResult.action}: #${prResult.number}`);
2831
+ if (prResult.action === 'reused') {
2832
+ summary.warnings.push('Existing PR reused without body/title changes. Use --update-pr-description to refresh PR content.');
2833
+ }
2834
+
2835
+ if (args.autoMerge) {
2836
+ reporter.start('open-pr-auto-merge', `Enabling auto-merge for PR #${prResult.number}...`);
2837
+ enablePrAutoMerge(context.repo, prResult.number, args.mergeMethod || 'squash', deps);
2838
+ reporter.ok('open-pr-auto-merge', `Auto-merge enabled for PR #${prResult.number}.`);
2839
+ summary.autoMerge = 'enabled';
2840
+ summary.actionsPerformed.push(`auto-merge enabled for #${prResult.number}`);
2841
+ } else {
2842
+ summary.autoMerge = 'skipped';
2843
+ summary.actionsSkipped.push('auto-merge');
2844
+ }
2845
+
2846
+ if (args.watchChecks) {
2847
+ reporter.start('open-pr-checks', `Watching checks for PR #${prResult.number}...`);
2848
+ watchPrChecks(context.repo, prResult.number, args.checkTimeout, deps);
2849
+ reporter.ok('open-pr-checks', `Checks green for PR #${prResult.number}.`);
2850
+ summary.checks = 'green';
2851
+ summary.actionsPerformed.push(`checks watched for #${prResult.number}`);
2852
+ } else {
2853
+ summary.checks = 'skipped';
2854
+ summary.actionsSkipped.push('watch-checks');
2855
+ }
2856
+
2857
+ if (!args.body && !args.bodyFile && !args.template) {
2858
+ summary.warnings.push('PR body used deterministic generated markdown (no body/template inputs).');
2859
+ }
2860
+ summary.merge = 'skipped';
2861
+ summary.releasePr = 'skipped';
2862
+ if (shouldPrintSummary) {
2863
+ printOrchestrationSummary(`open-pr completed for ${context.repo}`, summary);
2864
+ }
2865
+
2866
+ return {
2867
+ summary,
2868
+ context,
2869
+ pr: prResult
2870
+ };
2871
+ }
2872
+
2873
+ async function runReleaseCycle(args, dependencies = {}) {
2874
+ const deps = {
2875
+ exec: dependencies.exec || execCommand
2876
+ };
2877
+ const summary = createOrchestrationSummary();
2878
+ const reporter = new StepReporter();
2879
+ const originalBranch = deps.exec('git', ['rev-parse', '--abbrev-ref', 'HEAD']).stdout.trim();
2880
+ const useAutoMerge = args.autoMerge;
2881
+
2882
+ reporter.start('release-cycle-preflight-gh', 'Validating GitHub CLI and authentication...');
2883
+ ensureGhAvailable(deps);
2884
+ reporter.ok('release-cycle-preflight-gh', 'GitHub CLI available and authenticated.');
2885
+
2886
+ const gitContext = resolveGitContext(args, deps);
2887
+ summary.repoResolved = gitContext.repo;
2888
+ const effectivePhase = args.phase;
2889
+ const requestedTrack = args.track === 'auto' ? (args.promoteStable ? 'stable' : 'beta') : args.track;
2890
+ if (args.promoteStable && gitContext.head !== DEFAULT_BETA_BRANCH) {
2891
+ throw new Error(`--promote-stable is only allowed when running from "${DEFAULT_BETA_BRANCH}".`);
2892
+ }
2893
+ if (requestedTrack === 'stable' && !args.promoteStable) {
2894
+ throw new Error('Stable track requires --promote-stable for explicit promotion.');
2895
+ }
2896
+ if (gitContext.head !== DEFAULT_BETA_BRANCH && requestedTrack === 'stable') {
2897
+ throw new Error(`Stable track is only supported from "${DEFAULT_BETA_BRANCH}".`);
2898
+ }
2899
+ summary.actionsPerformed.push(`release track: ${requestedTrack}`);
2900
+ summary.releaseTrack = requestedTrack;
2901
+ let detectedMode = args.mode;
2902
+ if (args.promoteStable) {
2903
+ detectedMode = 'open-pr';
2904
+ } else if (detectedMode === 'auto') {
2905
+ const releasePrs = findReleasePrs(gitContext.repo, deps, {
2906
+ expectedBase: releaseBaseBranchForTrack(requestedTrack)
2907
+ });
2908
+ if (gitContext.head.startsWith('changeset-release/')) {
2909
+ detectedMode = 'publish';
2910
+ } else if (gitContext.head !== DEFAULT_BETA_BRANCH) {
2911
+ detectedMode = 'open-pr';
2912
+ } else if (releasePrs.length === 1) {
2913
+ detectedMode = 'publish';
2914
+ } else if (releasePrs.length > 1) {
2915
+ throw new Error(`Multiple candidate release PRs detected: ${releasePrs.map((item) => item.url).join(', ')}`);
2916
+ } else {
2917
+ detectedMode = 'open-pr';
2918
+ }
2919
+ }
2920
+ summary.modeDetected = detectedMode;
2921
+
2922
+ await confirmDetectedModeIfNeeded(
2923
+ args,
2924
+ detectedMode,
2925
+ detectedMode === 'open-pr'
2926
+ ? 'Will create/update code PR, watch checks, merge, then wait/merge release PR.'
2927
+ : 'Will operate on release PR (changeset-release/*), watch checks, and merge when green.'
2928
+ );
2929
+
2930
+ if (detectedMode === 'open-pr') {
2931
+ if (args.promoteStable) {
2932
+ reporter.start('release-cycle-promote-dispatch', `Dispatching ${DEFAULT_PROMOTE_WORKFLOW}...`);
2933
+ if (args.dryRun) {
2934
+ reporter.warn('release-cycle-promote-dispatch', `Dry-run: would dispatch ${DEFAULT_PROMOTE_WORKFLOW}.`);
2935
+ summary.actionsPerformed.push(`dry-run: dispatch ${DEFAULT_PROMOTE_WORKFLOW}`);
2936
+ summary.promotionWorkflow = `dry-run: ${DEFAULT_PROMOTE_WORKFLOW}`;
2937
+ } else {
2938
+ dispatchPromoteStableWorkflow(gitContext.repo, {
2939
+ ...args,
2940
+ head: DEFAULT_BETA_BRANCH
2941
+ }, deps);
2942
+ reporter.ok('release-cycle-promote-dispatch', `Dispatched ${DEFAULT_PROMOTE_WORKFLOW}.`);
2943
+ summary.actionsPerformed.push(`promotion workflow dispatched: ${DEFAULT_PROMOTE_WORKFLOW}`);
2944
+ summary.promotionWorkflow = `dispatched: ${DEFAULT_PROMOTE_WORKFLOW}`;
2945
+ }
2946
+
2947
+ if (!args.dryRun) {
2948
+ reporter.start('release-cycle-promote-pr', 'Waiting for promotion PR...');
2949
+ const promotionPr = waitForPromotionPr(gitContext.repo, args.releasePrTimeout, deps);
2950
+ reporter.ok('release-cycle-promote-pr', `Promotion PR found: #${promotionPr.number}`);
2951
+ summary.actionsPerformed.push(`promotion pr discovered: #${promotionPr.number}`);
2952
+ summary.promotionPr = `found (#${promotionPr.number})`;
2953
+
2954
+ if (args.watchChecks) {
2955
+ reporter.start('release-cycle-promote-checks', `Watching promotion PR checks #${promotionPr.number}...`);
2956
+ watchPrChecks(gitContext.repo, promotionPr.number, args.checkTimeout, deps);
2957
+ reporter.ok('release-cycle-promote-checks', `Promotion PR checks green (#${promotionPr.number}).`);
2958
+ }
2959
+
2960
+ if (args.mergeWhenGreen) {
2961
+ reporter.start('release-cycle-promote-merge', `Merging promotion PR #${promotionPr.number}...`);
2962
+ mergePrWhenGreen(gitContext.repo, promotionPr.number, args.mergeMethod, deps);
2963
+ reporter.ok('release-cycle-promote-merge', `Promotion PR #${promotionPr.number} merged.`);
2964
+ summary.actionsPerformed.push(`promotion pr merged: #${promotionPr.number}`);
2965
+ summary.promotionPr = `merged (#${promotionPr.number})`;
2966
+ }
2967
+
2968
+ reporter.start('release-cycle-sync-beta', `Syncing local ${DEFAULT_BETA_BRANCH} branch...`);
2969
+ const checkoutBeta = deps.exec('git', ['checkout', DEFAULT_BETA_BRANCH]);
2970
+ if (checkoutBeta.status !== 0) {
2971
+ throw new Error(`Could not checkout ${DEFAULT_BETA_BRANCH}: ${(checkoutBeta.stderr || checkoutBeta.stdout || '').trim()}`);
2972
+ }
2973
+ const pullBeta = deps.exec('git', ['pull']);
2974
+ if (pullBeta.status !== 0) {
2975
+ throw new Error(`Could not pull ${DEFAULT_BETA_BRANCH}: ${(pullBeta.stderr || pullBeta.stdout || '').trim()}`);
2976
+ }
2977
+ reporter.ok('release-cycle-sync-beta', `${DEFAULT_BETA_BRANCH} synced.`);
2978
+ }
2979
+ } else {
2980
+ summary.promotionWorkflow = 'skipped';
2981
+ summary.promotionPr = 'skipped';
2982
+ }
2983
+
2984
+ const canResumeFromMergedCode = args.resume
2985
+ && !args.promoteStable
2986
+ && gitContext.head !== DEFAULT_BETA_BRANCH
2987
+ && !gitContext.head.startsWith('changeset-release/')
2988
+ && isHeadIntegratedIntoBase('HEAD', DEFAULT_BETA_BRANCH, deps);
2989
+
2990
+ let codePr = null;
2991
+ if (canResumeFromMergedCode) {
2992
+ summary.prAction = 'skipped (resume: code already merged)';
2993
+ summary.prUrl = 'n/a';
2994
+ summary.branchPushed = 'skipped (resume)';
2995
+ summary.autoMerge = 'skipped (resume)';
2996
+ summary.checks = 'skipped (resume)';
2997
+ summary.merge = 'skipped (resume: already merged)';
2998
+ summary.actionsPerformed.push(`resume detected: ${gitContext.head} already integrated into ${DEFAULT_BETA_BRANCH}`);
2999
+ summary.actionsSkipped.push('open/update code pr (resume)');
3000
+ } else {
3001
+ if (!args.promoteStable && gitContext.head !== DEFAULT_BETA_BRANCH && !gitContext.head.startsWith('changeset-release/')) {
3002
+ syncBranchWithBase({
3003
+ deps,
3004
+ headBranch: gitContext.head,
3005
+ baseBranch: DEFAULT_BETA_BRANCH,
3006
+ strategy: args.syncBase,
3007
+ reporter,
3008
+ summary,
3009
+ dryRun: args.dryRun
3010
+ });
3011
+ }
3012
+
3013
+ const openPrResult = await runOpenPrFlow(
3014
+ {
3015
+ ...args,
3016
+ head: args.promoteStable ? DEFAULT_BETA_BRANCH : args.head,
3017
+ base: args.promoteStable ? DEFAULT_BASE_BRANCH : args.base,
3018
+ autoMerge: useAutoMerge,
3019
+ watchChecks: args.watchChecks,
3020
+ checkTimeout: args.checkTimeout,
3021
+ mergeMethod: args.mergeMethod,
3022
+ updateExistingPr: args.updatePrDescription,
3023
+ skipPush: args.promoteStable,
3024
+ printSummary: false
3025
+ },
3026
+ dependencies
3027
+ );
3028
+
3029
+ codePr = openPrResult.pr;
3030
+ summary.prAction = openPrResult.summary.prAction;
3031
+ summary.prUrl = openPrResult.summary.prUrl;
3032
+ summary.branchPushed = openPrResult.summary.branchPushed;
3033
+ summary.autoMerge = openPrResult.summary.autoMerge;
3034
+ summary.checks = openPrResult.summary.checks;
3035
+ summary.actionsPerformed.push(...openPrResult.summary.actionsPerformed);
3036
+ summary.actionsSkipped.push(...openPrResult.summary.actionsSkipped);
3037
+ summary.warnings.push(...openPrResult.summary.warnings);
3038
+
3039
+ if (args.mergeWhenGreen && codePr && !args.dryRun) {
3040
+ reporter.start('release-cycle-merge-code-ready', `Checking merge readiness for code PR #${codePr.number}...`);
3041
+ const codeReadiness = waitForPrMergeReadinessOrThrow(
3042
+ gitContext.repo,
3043
+ codePr.number,
3044
+ `Code PR #${codePr.number}`,
3045
+ args.checkTimeout,
3046
+ deps
3047
+ );
3048
+ await confirmMergeIfNeeded(args, codeReadiness, `Code PR #${codePr.number}`);
3049
+ reporter.ok('release-cycle-merge-code-ready', `Code PR #${codePr.number} is ready for merge.`);
3050
+ reporter.start('release-cycle-code-auto-merge', `Enabling auto-merge for code PR #${codePr.number}...`);
3051
+ enablePrAutoMerge(gitContext.repo, codePr.number, args.mergeMethod, deps);
3052
+ reporter.ok('release-cycle-code-auto-merge', `Auto-merge enabled for code PR #${codePr.number}.`);
3053
+ reporter.start('release-cycle-wait-code-merge', `Waiting for code PR #${codePr.number} merge...`);
3054
+ waitForPrMerged(gitContext.repo, codePr.number, args.releasePrTimeout, deps);
3055
+ reporter.ok('release-cycle-wait-code-merge', `Code PR #${codePr.number} merged.`);
3056
+ summary.actionsPerformed.push(`code pr merged: #${codePr.number}`);
3057
+ summary.merge = `code pr merged (#${codePr.number})`;
3058
+ } else {
3059
+ summary.merge = args.dryRun ? 'dry-run: would merge code PR' : 'skipped';
3060
+ summary.actionsSkipped.push('merge code pr');
3061
+ }
3062
+ }
3063
+
3064
+ if (effectivePhase === 'code') {
3065
+ summary.releasePr = 'skipped (phase=code)';
3066
+ summary.npmValidation = 'skipped (phase=code)';
3067
+ summary.cleanup = 'skipped (phase=code; requires npm validation)';
3068
+ summary.actionsSkipped.push('wait release pr (phase=code)');
3069
+ summary.actionsSkipped.push('verify npm (phase=code)');
3070
+ summary.actionsSkipped.push('cleanup (phase=code)');
3071
+ printOrchestrationSummary(`release-cycle completed in ${detectedMode} mode`, summary);
3072
+ return;
3073
+ }
3074
+
3075
+ let mergedReleasePr = null;
3076
+ if (args.waitReleasePr) {
3077
+ if (args.dryRun) {
3078
+ summary.releasePr = `dry-run: would wait release PR (${args.releasePrTimeout}m)`;
3079
+ } else {
3080
+ reporter.start('release-cycle-wait-release-pr', 'Waiting for release PR (changeset-release/*)...');
3081
+ const releasePr = waitForReleasePr(gitContext.repo, args.releasePrTimeout, deps, {
3082
+ expectedBase: releaseBaseBranchForTrack(requestedTrack)
3083
+ });
3084
+ reporter.ok('release-cycle-wait-release-pr', `Release PR found: #${releasePr.number}`);
3085
+ summary.releasePr = `found (#${releasePr.number})`;
3086
+ summary.actionsPerformed.push(`release pr discovered: #${releasePr.number}`);
3087
+
3088
+ if (args.watchChecks) {
3089
+ reporter.start('release-cycle-watch-release-checks', `Watching release PR checks #${releasePr.number}...`);
3090
+ watchPrChecks(gitContext.repo, releasePr.number, args.checkTimeout, deps);
3091
+ reporter.ok('release-cycle-watch-release-checks', `Release PR checks green (#${releasePr.number}).`);
3092
+ }
3093
+
3094
+ if (args.mergeReleasePr) {
3095
+ reporter.start('release-cycle-merge-release-ready', `Checking merge readiness for release PR #${releasePr.number}...`);
3096
+ const releaseReadiness = waitForPrMergeReadinessOrThrow(
3097
+ gitContext.repo,
3098
+ releasePr.number,
3099
+ `Release PR #${releasePr.number}`,
3100
+ args.checkTimeout,
3101
+ deps,
3102
+ { allowBehindTransient: true }
3103
+ );
3104
+ await confirmMergeIfNeeded(args, releaseReadiness, `Release PR #${releasePr.number}`);
3105
+ reporter.ok('release-cycle-merge-release-ready', `Release PR #${releasePr.number} is ready for merge.`);
3106
+ reporter.start('release-cycle-release-auto-merge', `Enabling auto-merge for release PR #${releasePr.number}...`);
3107
+ enablePrAutoMerge(gitContext.repo, releasePr.number, args.mergeMethod, deps);
3108
+ reporter.ok('release-cycle-release-auto-merge', `Auto-merge enabled for release PR #${releasePr.number}.`);
3109
+ reporter.start('release-cycle-release-wait-merge', `Waiting for release PR #${releasePr.number} merge...`);
3110
+ waitForPrMerged(gitContext.repo, releasePr.number, args.releasePrTimeout, deps);
3111
+ reporter.ok('release-cycle-release-wait-merge', `Release PR #${releasePr.number} merged.`);
3112
+ summary.releasePr = `merged (#${releasePr.number})`;
3113
+ summary.actionsPerformed.push(`release pr merged: #${releasePr.number}`);
3114
+ summary.autoMerge = 'enabled (code + release)';
3115
+ mergedReleasePr = releasePr;
3116
+ } else {
3117
+ summary.actionsSkipped.push('merge release pr');
3118
+ }
3119
+ }
3120
+ } else {
3121
+ summary.releasePr = 'skipped';
3122
+ summary.actionsSkipped.push('wait release pr');
3123
+ }
3124
+
3125
+ let npmValidationPassed = false;
3126
+ if (args.verifyNpm && !args.dryRun && mergedReleasePr) {
3127
+ reporter.start('release-cycle-verify-npm', 'Validating npm publish and dist-tag...');
3128
+ const targetRef = args.promoteStable ? DEFAULT_BASE_BRANCH : DEFAULT_BETA_BRANCH;
3129
+ const expectedTag = requestedTrack === 'stable' ? 'latest' : 'beta';
3130
+ const remotePackage = getRemotePackageVersion(gitContext.repo, targetRef, deps);
3131
+ const npmValidation = validateNpmPublishedVersionAndTag(
3132
+ remotePackage.name,
3133
+ remotePackage.version,
3134
+ expectedTag,
3135
+ args.releasePrTimeout,
3136
+ deps
3137
+ );
3138
+ if (npmValidation.status !== 'pass') {
3139
+ summary.npmValidation = `failed (${expectedTag})`;
3140
+ throw new Error(
3141
+ [
3142
+ 'npm validation failed after release merge.',
3143
+ `Expected: ${remotePackage.name}@${remotePackage.version} with dist-tag ${expectedTag}`,
3144
+ `Observed version: ${npmValidation.observedVersion || 'n/a'}`,
3145
+ `Observed tag (${expectedTag}): ${npmValidation.observedTagVersion || 'n/a'}`
3146
+ ].join('\n')
3147
+ );
3148
+ }
3149
+ reporter.ok('release-cycle-verify-npm', `${remotePackage.name}@${remotePackage.version} validated on tag ${expectedTag}.`);
3150
+ summary.actionsPerformed.push(`npm validation: ${remotePackage.name}@${remotePackage.version} (${expectedTag})`);
3151
+ summary.npmValidation = `pass (${expectedTag} -> ${remotePackage.version})`;
3152
+ npmValidationPassed = true;
3153
+ } else if (!args.verifyNpm) {
3154
+ summary.actionsSkipped.push('verify npm');
3155
+ summary.npmValidation = 'skipped';
3156
+ } else if (args.dryRun) {
3157
+ summary.npmValidation = 'skipped (dry-run)';
3158
+ } else if (!mergedReleasePr) {
3159
+ summary.npmValidation = 'skipped (release pr not merged)';
3160
+ }
3161
+
3162
+ if (!args.dryRun && npmValidationPassed) {
3163
+ if (args.confirmCleanup && !args.yes) {
3164
+ await confirmOrThrow('Release completed and npm validation passed.\nProceed with local cleanup now?');
3165
+ }
3166
+ runLocalCleanup({
3167
+ deps,
3168
+ originalBranch,
3169
+ targetBaseBranch: requestedTrack === 'stable' ? DEFAULT_BASE_BRANCH : DEFAULT_BETA_BRANCH,
3170
+ shouldRun: !args.noCleanup,
3171
+ summary,
3172
+ reporter
3173
+ });
3174
+ } else if (!args.dryRun && !npmValidationPassed) {
3175
+ summary.actionsSkipped.push('cleanup (npm validation did not pass)');
3176
+ summary.cleanup = 'skipped (requires npm validation pass)';
3177
+ } else {
3178
+ summary.actionsSkipped.push('cleanup (dry-run)');
3179
+ summary.cleanup = 'skipped (dry-run)';
3180
+ }
3181
+
3182
+ printOrchestrationSummary(`release-cycle completed in ${detectedMode} mode`, summary);
3183
+ return;
3184
+ }
3185
+
3186
+ const releasePrCandidates = findReleasePrs(gitContext.repo, deps, {
3187
+ expectedBase: args.track === 'auto' ? '' : releaseBaseBranchForTrack(requestedTrack)
3188
+ });
3189
+ const explicitHeadCandidates = args.head
3190
+ ? releasePrCandidates.filter((item) => item.headRefName === args.head)
3191
+ : releasePrCandidates;
3192
+ if (explicitHeadCandidates.length !== 1) {
3193
+ throw new Error(
3194
+ explicitHeadCandidates.length === 0
3195
+ ? 'No release PR found. Expected an open PR with head changeset-release/*.'
3196
+ : `Ambiguous release PR selection: ${explicitHeadCandidates.map((item) => item.url).join(', ')}`
3197
+ );
3198
+ }
3199
+
3200
+ const releasePr = explicitHeadCandidates[0];
3201
+ const effectivePublishTrack = releasePr.baseRefName === DEFAULT_BASE_BRANCH ? 'stable' : 'beta';
3202
+ summary.releaseTrack = effectivePublishTrack;
3203
+ summary.prAction = `selected release pr (#${releasePr.number})`;
3204
+ summary.prUrl = releasePr.url;
3205
+ summary.branchPushed = 'skipped';
3206
+ summary.autoMerge = 'skipped';
3207
+
3208
+ if (args.watchChecks) {
3209
+ if (args.dryRun) {
3210
+ summary.checks = `dry-run: would watch checks (${args.checkTimeout}m)`;
3211
+ } else {
3212
+ reporter.start('release-cycle-publish-checks', `Watching release PR checks #${releasePr.number}...`);
3213
+ watchPrChecks(gitContext.repo, releasePr.number, args.checkTimeout, deps);
3214
+ reporter.ok('release-cycle-publish-checks', `Release PR checks green (#${releasePr.number}).`);
3215
+ summary.checks = 'green';
3216
+ summary.actionsPerformed.push(`release checks watched: #${releasePr.number}`);
3217
+ }
3218
+ } else {
3219
+ summary.checks = 'skipped';
3220
+ summary.actionsSkipped.push('watch release checks');
3221
+ }
3222
+
3223
+ if (args.mergeReleasePr || args.mergeWhenGreen) {
3224
+ if (args.dryRun) {
3225
+ summary.merge = `dry-run: would merge release PR #${releasePr.number}`;
3226
+ summary.releasePr = `dry-run: would merge (#${releasePr.number})`;
3227
+ } else {
3228
+ reporter.start('release-cycle-publish-merge-ready', `Checking merge readiness for release PR #${releasePr.number}...`);
3229
+ const publishReadiness = waitForPrMergeReadinessOrThrow(
3230
+ gitContext.repo,
3231
+ releasePr.number,
3232
+ `Release PR #${releasePr.number}`,
3233
+ args.checkTimeout,
3234
+ deps,
3235
+ { allowBehindTransient: true }
3236
+ );
3237
+ await confirmMergeIfNeeded(args, publishReadiness, `Release PR #${releasePr.number}`);
3238
+ reporter.ok('release-cycle-publish-merge-ready', `Release PR #${releasePr.number} is ready for merge.`);
3239
+ reporter.start('release-cycle-publish-auto-merge', `Enabling auto-merge for release PR #${releasePr.number}...`);
3240
+ enablePrAutoMerge(gitContext.repo, releasePr.number, args.mergeMethod, deps);
3241
+ reporter.ok('release-cycle-publish-auto-merge', `Auto-merge enabled for release PR #${releasePr.number}.`);
3242
+ reporter.start('release-cycle-publish-wait-merge', `Waiting for release PR #${releasePr.number} merge...`);
3243
+ waitForPrMerged(gitContext.repo, releasePr.number, args.releasePrTimeout, deps);
3244
+ reporter.ok('release-cycle-publish-wait-merge', `Release PR #${releasePr.number} merged.`);
3245
+ summary.merge = `merged release pr (#${releasePr.number})`;
3246
+ summary.releasePr = `merged (#${releasePr.number})`;
3247
+ summary.autoMerge = 'enabled (release)';
3248
+ summary.actionsPerformed.push(`release pr merged: #${releasePr.number}`);
3249
+ }
3250
+ } else {
3251
+ summary.merge = 'skipped';
3252
+ summary.releasePr = `discovered (#${releasePr.number})`;
3253
+ summary.actionsSkipped.push('merge release pr');
3254
+ }
3255
+
3256
+ let npmValidationPassed = false;
3257
+ if (args.verifyNpm && !args.dryRun && (args.mergeReleasePr || args.mergeWhenGreen)) {
3258
+ reporter.start('release-cycle-verify-npm', 'Validating npm publish and dist-tag...');
3259
+ const targetRef = effectivePublishTrack === 'stable' ? DEFAULT_BASE_BRANCH : DEFAULT_BETA_BRANCH;
3260
+ const expectedTag = effectivePublishTrack === 'stable' ? 'latest' : 'beta';
3261
+ const remotePackage = getRemotePackageVersion(gitContext.repo, targetRef, deps);
3262
+ const npmValidation = validateNpmPublishedVersionAndTag(
3263
+ remotePackage.name,
3264
+ remotePackage.version,
3265
+ expectedTag,
3266
+ args.releasePrTimeout,
3267
+ deps
3268
+ );
3269
+ if (npmValidation.status !== 'pass') {
3270
+ summary.npmValidation = `failed (${expectedTag})`;
3271
+ throw new Error(
3272
+ [
3273
+ 'npm validation failed after release merge.',
3274
+ `Expected: ${remotePackage.name}@${remotePackage.version} with dist-tag ${expectedTag}`,
3275
+ `Observed version: ${npmValidation.observedVersion || 'n/a'}`,
3276
+ `Observed tag (${expectedTag}): ${npmValidation.observedTagVersion || 'n/a'}`
3277
+ ].join('\n')
3278
+ );
3279
+ }
3280
+ reporter.ok('release-cycle-verify-npm', `${remotePackage.name}@${remotePackage.version} validated on tag ${expectedTag}.`);
3281
+ summary.actionsPerformed.push(`npm validation: ${remotePackage.name}@${remotePackage.version} (${expectedTag})`);
3282
+ summary.npmValidation = `pass (${expectedTag} -> ${remotePackage.version})`;
3283
+ npmValidationPassed = true;
3284
+ } else if (!args.verifyNpm) {
3285
+ summary.npmValidation = 'skipped';
3286
+ } else if (args.dryRun) {
3287
+ summary.npmValidation = 'skipped (dry-run)';
3288
+ } else {
3289
+ summary.npmValidation = 'skipped (release pr not merged)';
3290
+ }
3291
+
3292
+ if (!args.dryRun && npmValidationPassed) {
3293
+ if (args.confirmCleanup && !args.yes) {
3294
+ await confirmOrThrow('Release completed and npm validation passed.\nProceed with local cleanup now?');
3295
+ }
3296
+ runLocalCleanup({
3297
+ deps,
3298
+ originalBranch,
3299
+ targetBaseBranch: effectivePublishTrack === 'stable' ? DEFAULT_BASE_BRANCH : DEFAULT_BETA_BRANCH,
3300
+ shouldRun: !args.noCleanup,
3301
+ summary,
3302
+ reporter
3303
+ });
3304
+ } else if (!args.dryRun && !npmValidationPassed) {
3305
+ summary.actionsSkipped.push('cleanup (npm validation did not pass)');
3306
+ summary.cleanup = 'skipped (requires npm validation pass)';
3307
+ } else {
3308
+ summary.cleanup = 'skipped (dry-run)';
3309
+ }
3310
+
3311
+ printOrchestrationSummary(`release-cycle completed in ${detectedMode} mode`, summary);
3312
+ }
3313
+
3314
+ function parseRepoFromRemote(remoteUrl) {
3315
+ const trimmed = remoteUrl.trim();
3316
+ const httpsMatch = trimmed.match(/github\.com[/:]([^/]+\/[^/.]+)(?:\.git)?$/);
3317
+
3318
+ if (httpsMatch) {
3319
+ return httpsMatch[1];
3320
+ }
3321
+
3322
+ return '';
3323
+ }
3324
+
3325
+ function resolveRepo(args, deps) {
3326
+ if (args.repo) {
3327
+ return args.repo;
3328
+ }
3329
+
3330
+ const remote = deps.exec('git', ['config', '--get', 'remote.origin.url']);
3331
+ if (remote.status !== 0 || !remote.stdout.trim()) {
3332
+ throw new Error('Could not infer repository. Use --repo <owner/repo>.');
3333
+ }
3334
+
3335
+ const repo = parseRepoFromRemote(remote.stdout);
3336
+ if (!repo) {
3337
+ throw new Error('Could not parse GitHub repository from remote.origin.url. Use --repo <owner/repo>.');
607
3338
  }
608
3339
 
609
3340
  return repo;
610
3341
  }
611
3342
 
612
- function createBaseRulesetPayload(defaultBranch) {
613
- return {
614
- name: DEFAULT_RULESET_NAME,
615
- target: 'branch',
616
- enforcement: 'active',
617
- conditions: {
618
- ref_name: {
619
- include: [`refs/heads/${defaultBranch}`],
620
- exclude: []
3343
+ function createBaseRulesetPayload(defaultBranch) {
3344
+ return {
3345
+ name: DEFAULT_RULESET_NAME,
3346
+ target: 'branch',
3347
+ enforcement: 'active',
3348
+ conditions: {
3349
+ ref_name: {
3350
+ include: [`refs/heads/${defaultBranch}`],
3351
+ exclude: []
3352
+ }
3353
+ },
3354
+ bypass_actors: [],
3355
+ rules: [
3356
+ { type: 'deletion' },
3357
+ { type: 'non_fast_forward' },
3358
+ {
3359
+ type: 'pull_request',
3360
+ parameters: {
3361
+ required_approving_review_count: 0,
3362
+ dismiss_stale_reviews_on_push: true,
3363
+ require_code_owner_review: false,
3364
+ require_last_push_approval: false,
3365
+ required_review_thread_resolution: true
3366
+ }
3367
+ },
3368
+ {
3369
+ type: 'required_status_checks',
3370
+ parameters: {
3371
+ strict_required_status_checks_policy: true,
3372
+ required_status_checks: [
3373
+ {
3374
+ context: REQUIRED_CHECK_CONTEXT
3375
+ }
3376
+ ]
3377
+ }
3378
+ }
3379
+ ]
3380
+ };
3381
+ }
3382
+
3383
+ function createBetaRulesetPayload(betaBranch) {
3384
+ return {
3385
+ name: `Beta branch protection (${betaBranch})`,
3386
+ target: 'branch',
3387
+ enforcement: 'active',
3388
+ conditions: {
3389
+ ref_name: {
3390
+ include: [`refs/heads/${betaBranch}`],
3391
+ exclude: []
3392
+ }
3393
+ },
3394
+ bypass_actors: [],
3395
+ rules: [
3396
+ { type: 'deletion' },
3397
+ { type: 'non_fast_forward' },
3398
+ {
3399
+ type: 'pull_request',
3400
+ parameters: {
3401
+ required_approving_review_count: 0,
3402
+ dismiss_stale_reviews_on_push: true,
3403
+ require_code_owner_review: false,
3404
+ require_last_push_approval: false,
3405
+ required_review_thread_resolution: true
3406
+ }
3407
+ },
3408
+ {
3409
+ type: 'required_status_checks',
3410
+ parameters: {
3411
+ strict_required_status_checks_policy: true,
3412
+ required_status_checks: [
3413
+ {
3414
+ context: REQUIRED_CHECK_CONTEXT
3415
+ }
3416
+ ]
3417
+ }
3418
+ }
3419
+ ]
3420
+ };
3421
+ }
3422
+
3423
+ function createRulesetPayload(args) {
3424
+ if (!args.ruleset) {
3425
+ return createBaseRulesetPayload(args.defaultBranch);
3426
+ }
3427
+
3428
+ const rulesetPath = path.resolve(args.ruleset);
3429
+ if (!fs.existsSync(rulesetPath)) {
3430
+ throw new Error(`Ruleset file not found: ${rulesetPath}`);
3431
+ }
3432
+
3433
+ return readJsonFile(rulesetPath);
3434
+ }
3435
+
3436
+ function ghApi(deps, method, endpoint, payload) {
3437
+ const args = ['api', '--method', method, endpoint];
3438
+
3439
+ if (payload !== undefined) {
3440
+ args.push('--input', '-');
3441
+ }
3442
+
3443
+ return deps.exec('gh', args, {
3444
+ input: payload !== undefined ? `${JSON.stringify(payload)}\n` : undefined
3445
+ });
3446
+ }
3447
+
3448
+ function ensureGhAvailable(deps) {
3449
+ const version = deps.exec('gh', ['--version']);
3450
+ if (version.status !== 0) {
3451
+ throw new Error('GitHub CLI (gh) is required. Install it from https://cli.github.com/ and rerun.');
3452
+ }
3453
+
3454
+ const auth = deps.exec('gh', ['auth', 'status']);
3455
+ if (auth.status !== 0) {
3456
+ throw new Error('GitHub CLI is not authenticated. Run "gh auth login" and rerun.');
3457
+ }
3458
+ }
3459
+
3460
+ function parseJsonOutput(output, fallbackError) {
3461
+ try {
3462
+ return JSON.parse(output);
3463
+ } catch (error) {
3464
+ throw new Error(fallbackError);
3465
+ }
3466
+ }
3467
+
3468
+ function upsertRuleset(deps, repo, rulesetPayload) {
3469
+ const listResult = ghApi(deps, 'GET', `/repos/${repo}/rulesets`);
3470
+ if (listResult.status !== 0) {
3471
+ throw new Error(`Failed to list rulesets: ${listResult.stderr || listResult.stdout}`.trim());
3472
+ }
3473
+
3474
+ const rulesets = parseJsonOutput(listResult.stdout || '[]', 'Failed to parse rulesets response from GitHub API.');
3475
+ const existing = rulesets.find((ruleset) => ruleset.name === rulesetPayload.name);
3476
+
3477
+ if (!existing) {
3478
+ const createResult = ghApi(deps, 'POST', `/repos/${repo}/rulesets`, rulesetPayload);
3479
+ if (createResult.status !== 0) {
3480
+ throw new Error(`Failed to create ruleset: ${createResult.stderr || createResult.stdout}`.trim());
3481
+ }
3482
+
3483
+ return 'created';
3484
+ }
3485
+
3486
+ const updateResult = ghApi(deps, 'PUT', `/repos/${repo}/rulesets/${existing.id}`, rulesetPayload);
3487
+ if (updateResult.status !== 0) {
3488
+ throw new Error(`Failed to update ruleset: ${updateResult.stderr || updateResult.stdout}`.trim());
3489
+ }
3490
+
3491
+ return 'updated';
3492
+ }
3493
+
3494
+ function updateWorkflowPermissions(deps, repo) {
3495
+ const workflowPermissionsPayload = {
3496
+ default_workflow_permissions: 'write',
3497
+ can_approve_pull_request_reviews: true
3498
+ };
3499
+
3500
+ const result = ghApi(
3501
+ deps,
3502
+ 'PUT',
3503
+ `/repos/${repo}/actions/permissions/workflow`,
3504
+ workflowPermissionsPayload
3505
+ );
3506
+
3507
+ if (result.status !== 0) {
3508
+ throw new Error(
3509
+ `Failed to update workflow permissions: ${result.stderr || result.stdout}`.trim()
3510
+ );
3511
+ }
3512
+ }
3513
+
3514
+ function isNotFoundResponse(result) {
3515
+ const output = `${result.stderr || ''}\n${result.stdout || ''}`.toLowerCase();
3516
+ return output.includes('404') || output.includes('not found');
3517
+ }
3518
+
3519
+ function ensureBranchExists(deps, repo, defaultBranch, targetBranch) {
3520
+ const encodedTarget = encodeURIComponent(targetBranch);
3521
+ const getTarget = ghApi(deps, 'GET', `/repos/${repo}/branches/${encodedTarget}`);
3522
+ if (getTarget.status === 0) {
3523
+ return 'exists';
3524
+ }
3525
+
3526
+ if (!isNotFoundResponse(getTarget)) {
3527
+ throw new Error(`Failed to check branch "${targetBranch}": ${getTarget.stderr || getTarget.stdout}`.trim());
3528
+ }
3529
+
3530
+ const encodedDefault = encodeURIComponent(defaultBranch);
3531
+ const getDefaultRef = ghApi(deps, 'GET', `/repos/${repo}/git/ref/heads/${encodedDefault}`);
3532
+ if (getDefaultRef.status !== 0) {
3533
+ throw new Error(`Failed to resolve default branch "${defaultBranch}": ${getDefaultRef.stderr || getDefaultRef.stdout}`.trim());
3534
+ }
3535
+
3536
+ const parsed = parseJsonOutput(getDefaultRef.stdout || '{}', 'Failed to parse default branch ref from GitHub API.');
3537
+ const sha = parsed && parsed.object && parsed.object.sha;
3538
+ if (!sha) {
3539
+ throw new Error(`Could not determine SHA for default branch "${defaultBranch}".`);
3540
+ }
3541
+
3542
+ const createRef = ghApi(deps, 'POST', `/repos/${repo}/git/refs`, {
3543
+ ref: `refs/heads/${targetBranch}`,
3544
+ sha
3545
+ });
3546
+ if (createRef.status !== 0) {
3547
+ throw new Error(`Failed to create branch "${targetBranch}": ${createRef.stderr || createRef.stdout}`.trim());
3548
+ }
3549
+
3550
+ return 'created';
3551
+ }
3552
+
3553
+ function branchExists(deps, repo, targetBranch) {
3554
+ const encodedTarget = encodeURIComponent(targetBranch);
3555
+ const getTarget = ghApi(deps, 'GET', `/repos/${repo}/branches/${encodedTarget}`);
3556
+ if (getTarget.status === 0) {
3557
+ return true;
3558
+ }
3559
+
3560
+ if (isNotFoundResponse(getTarget)) {
3561
+ return false;
3562
+ }
3563
+
3564
+ throw new Error(`Failed to check branch "${targetBranch}": ${getTarget.stderr || getTarget.stdout}`.trim());
3565
+ }
3566
+
3567
+ function findRulesetByName(deps, repo, name) {
3568
+ const listResult = ghApi(deps, 'GET', `/repos/${repo}/rulesets`);
3569
+ if (listResult.status !== 0) {
3570
+ throw new Error(`Failed to list rulesets: ${listResult.stderr || listResult.stdout}`.trim());
3571
+ }
3572
+
3573
+ const rulesets = parseJsonOutput(listResult.stdout || '[]', 'Failed to parse rulesets response from GitHub API.');
3574
+ return rulesets.find((ruleset) => ruleset.name === name) || null;
3575
+ }
3576
+
3577
+ function listRulesets(deps, repo) {
3578
+ const listResult = ghApi(deps, 'GET', `/repos/${repo}/rulesets`);
3579
+ if (listResult.status !== 0) {
3580
+ throw new Error(`Failed to list rulesets: ${listResult.stderr || listResult.stdout}`.trim());
3581
+ }
3582
+
3583
+ return parseJsonOutput(listResult.stdout || '[]', 'Failed to parse rulesets response from GitHub API.');
3584
+ }
3585
+
3586
+ function listActionsSecretNames(deps, repo) {
3587
+ const listResult = ghApi(deps, 'GET', `/repos/${repo}/actions/secrets?per_page=100`);
3588
+ if (listResult.status !== 0) {
3589
+ throw new Error(`Failed to list Actions secrets: ${listResult.stderr || listResult.stdout}`.trim());
3590
+ }
3591
+
3592
+ const parsed = parseJsonOutput(listResult.stdout || '{}', 'Failed to parse Actions secrets response from GitHub API.');
3593
+ const secrets = Array.isArray(parsed.secrets) ? parsed.secrets : [];
3594
+ return secrets
3595
+ .map((item) => item && item.name)
3596
+ .filter(Boolean);
3597
+ }
3598
+
3599
+ function findMissingReleaseAuthAppSecrets(existingNames) {
3600
+ const nameSet = new Set(existingNames || []);
3601
+ const missing = [...RELEASE_AUTH_APP_REQUIRED_SECRETS].filter((name) => !nameSet.has(name));
3602
+ const hasClientOrAppId = RELEASE_AUTH_APP_ID_SECRETS.some((name) => nameSet.has(name));
3603
+ if (!hasClientOrAppId) {
3604
+ missing.push('GH_APP_CLIENT_ID or GH_APP_ID');
3605
+ }
3606
+
3607
+ return missing;
3608
+ }
3609
+
3610
+ function ensureNpmAvailable(deps) {
3611
+ const version = deps.exec('npm', ['--version']);
3612
+ if (version.status !== 0) {
3613
+ throw new Error('npm CLI is required. Install npm and rerun.');
3614
+ }
3615
+ }
3616
+
3617
+ function ensureNpmAuthenticated(deps) {
3618
+ const whoami = deps.exec('npm', ['whoami']);
3619
+ if (whoami.status !== 0) {
3620
+ throw new Error('npm CLI is not authenticated. Run "npm login" and rerun.');
3621
+ }
3622
+ }
3623
+
3624
+ function packageExistsOnNpm(deps, packageName) {
3625
+ const view = deps.exec('npm', ['view', packageName, 'version', '--json']);
3626
+ if (view.status === 0) {
3627
+ return true;
3628
+ }
3629
+
3630
+ const output = `${view.stderr || ''}\n${view.stdout || ''}`.toLowerCase();
3631
+ if (output.includes('e404') || output.includes('not found') || output.includes('404')) {
3632
+ return false;
3633
+ }
3634
+
3635
+ throw new Error(`Failed to check package on npm: ${view.stderr || view.stdout}`.trim());
3636
+ }
3637
+
3638
+ async function resolveInitSelections(args) {
3639
+ const explicit = args.withGithub || args.withNpm || args.withBeta;
3640
+ const selected = {
3641
+ withGithub: args.withGithub,
3642
+ withNpm: args.withNpm,
3643
+ withBeta: args.withBeta
3644
+ };
3645
+
3646
+ if (!explicit) {
3647
+ if (process.stdin.isTTY && process.stdout.isTTY) {
3648
+ selected.withGithub = await askYesNo('Enable GitHub repository setup (rulesets/settings)?', false);
3649
+ selected.withNpm = await askYesNo('Enable npm setup (auth + package check + first publish if needed)?', false);
3650
+ selected.withBeta = selected.withGithub
3651
+ ? await askYesNo(`Enable beta flow setup using branch "${args.betaBranch}"?`, true)
3652
+ : false;
3653
+ } else {
3654
+ selected.withGithub = false;
3655
+ selected.withNpm = false;
3656
+ selected.withBeta = false;
3657
+ }
3658
+ }
3659
+
3660
+ if (selected.withBeta) {
3661
+ selected.withGithub = true;
3662
+ }
3663
+
3664
+ return selected;
3665
+ }
3666
+
3667
+ function summarizePlannedInitActions(selections, args, context) {
3668
+ const lines = [
3669
+ 'This init execution will apply:',
3670
+ '- local managed files/scripts/dependencies bootstrap'
3671
+ ];
3672
+
3673
+ if (selections.withGithub) {
3674
+ lines.push(`- GitHub main settings/ruleset for ${context.repo}`);
3675
+ }
3676
+ if (selections.withBeta) {
3677
+ lines.push(`- beta branch flow for ${args.betaBranch} (create branch if missing + ruleset + workflow triggers)`);
3678
+ }
3679
+ if (selections.withNpm) {
3680
+ if (context.existsOnNpm) {
3681
+ lines.push(`- npm setup for ${context.packageName} (already published; no first publish)`);
3682
+ } else {
3683
+ lines.push(`- npm setup for ${context.packageName} (first publish will run automatically)`);
3684
+ }
3685
+ }
3686
+
3687
+ return lines.join('\n');
3688
+ }
3689
+
3690
+ function upsertCiWorkflow(targetPath, templatePath, options) {
3691
+ return upsertReleaseWorkflow(targetPath, templatePath, options);
3692
+ }
3693
+
3694
+ function ensureBetaWorkflowTriggers(targetDir, templateDir, options, summary, reporter) {
3695
+ const workflowRelativePath = '.github/workflows/release.yml';
3696
+ const workflowTemplatePath = path.join(templateDir, workflowRelativePath);
3697
+ const workflowTargetPath = path.join(targetDir, workflowRelativePath);
3698
+
3699
+ const ciWorkflowRelativePath = '.github/workflows/ci.yml';
3700
+ const ciWorkflowTemplatePath = path.join(templateDir, ciWorkflowRelativePath);
3701
+ const ciWorkflowTargetPath = path.join(targetDir, ciWorkflowRelativePath);
3702
+
3703
+ const variables = {
3704
+ PACKAGE_NAME: options.packageName,
3705
+ DEFAULT_BRANCH: options.defaultBranch,
3706
+ BETA_BRANCH: options.betaBranch,
3707
+ SCOPE: options.scope,
3708
+ ...buildReleaseAuthVariables(options.releaseAuth || DEFAULT_RELEASE_AUTH)
3709
+ };
3710
+
3711
+ reporter.start('workflow-release', `Ensuring ${workflowRelativePath} includes stable+beta triggers...`);
3712
+ const workflowUpsert = upsertReleaseWorkflow(workflowTargetPath, workflowTemplatePath, {
3713
+ force: options.force,
3714
+ dryRun: options.dryRun,
3715
+ variables
3716
+ });
3717
+
3718
+ if (workflowUpsert.result === 'created') {
3719
+ summary.createdFiles.push(workflowRelativePath);
3720
+ reporter.ok('workflow-release', `${workflowRelativePath} created.`);
3721
+ } else if (workflowUpsert.result === 'overwritten' || workflowUpsert.result === 'updated') {
3722
+ summary.overwrittenFiles.push(workflowRelativePath);
3723
+ reporter.ok('workflow-release', `${workflowRelativePath} updated.`);
3724
+ } else {
3725
+ summary.skippedFiles.push(workflowRelativePath);
3726
+ if (workflowUpsert.warning) {
3727
+ summary.warnings.push(workflowUpsert.warning);
3728
+ reporter.warn('workflow-release', workflowUpsert.warning);
3729
+ } else {
3730
+ reporter.warn('workflow-release', `${workflowRelativePath} already configured; kept as-is.`);
3731
+ }
3732
+ }
3733
+
3734
+ reporter.start('workflow-ci', `Ensuring ${ciWorkflowRelativePath} includes stable+beta triggers...`);
3735
+ const ciWorkflowUpsert = upsertCiWorkflow(ciWorkflowTargetPath, ciWorkflowTemplatePath, {
3736
+ force: options.force,
3737
+ dryRun: options.dryRun,
3738
+ variables
3739
+ });
3740
+
3741
+ if (ciWorkflowUpsert.result === 'created') {
3742
+ summary.createdFiles.push(ciWorkflowRelativePath);
3743
+ reporter.ok('workflow-ci', `${ciWorkflowRelativePath} created.`);
3744
+ } else if (ciWorkflowUpsert.result === 'overwritten' || ciWorkflowUpsert.result === 'updated') {
3745
+ summary.overwrittenFiles.push(ciWorkflowRelativePath);
3746
+ reporter.ok('workflow-ci', `${ciWorkflowRelativePath} updated.`);
3747
+ } else {
3748
+ summary.skippedFiles.push(ciWorkflowRelativePath);
3749
+ if (ciWorkflowUpsert.warning) {
3750
+ summary.warnings.push(ciWorkflowUpsert.warning);
3751
+ reporter.warn('workflow-ci', ciWorkflowUpsert.warning);
3752
+ } else {
3753
+ reporter.warn('workflow-ci', `${ciWorkflowRelativePath} already configured; kept as-is.`);
3754
+ }
3755
+ }
3756
+ }
3757
+
3758
+ function prevalidateInitExecution(args, selections, dependencies = {}, reporter = new StepReporter()) {
3759
+ const deps = {
3760
+ exec: dependencies.exec || execCommand
3761
+ };
3762
+
3763
+ const packageRoot = path.resolve(__dirname, '..');
3764
+ const templateDir = path.join(packageRoot, 'template');
3765
+ const targetDir = path.resolve(args.dir);
3766
+ const packageJsonPath = path.join(targetDir, 'package.json');
3767
+ const result = {
3768
+ deps,
3769
+ targetDir,
3770
+ templateDir,
3771
+ packageJsonPath,
3772
+ repo: '',
3773
+ packageName: '',
3774
+ existsOnNpm: true,
3775
+ betaBranchExists: false,
3776
+ existingMainRuleset: null,
3777
+ existingBetaRuleset: null,
3778
+ mainRulesetPayload: null,
3779
+ betaRulesetPayload: createBetaRulesetPayload(args.betaBranch),
3780
+ missingReleaseAuthAppSecrets: []
3781
+ };
3782
+
3783
+ reporter.start('validate-local', 'Validating local project and templates...');
3784
+ if (!fs.existsSync(targetDir)) {
3785
+ reporter.fail('validate-local', `Directory not found: ${targetDir}`);
3786
+ throw new Error(`Directory not found: ${targetDir}`);
3787
+ }
3788
+
3789
+ if (!fs.existsSync(packageJsonPath)) {
3790
+ reporter.fail('validate-local', `package.json not found in ${targetDir}`);
3791
+ throw new Error(`package.json not found in ${targetDir}`);
3792
+ }
3793
+
3794
+ if (!fs.existsSync(templateDir)) {
3795
+ reporter.fail('validate-local', `Template not found in ${templateDir}`);
3796
+ throw new Error(`Template not found in ${templateDir}`);
3797
+ }
3798
+
3799
+ const packageJson = readJsonFile(packageJsonPath);
3800
+ result.packageName = packageJson.name || packageDirFromName(path.basename(targetDir));
3801
+ reporter.ok('validate-local', 'Local project validation complete.');
3802
+
3803
+ if (selections.withGithub) {
3804
+ reporter.start('validate-gh', 'Validating GitHub CLI and authentication...');
3805
+ ensureGhAvailable(deps);
3806
+ reporter.ok('validate-gh', 'GitHub CLI available and authenticated.');
3807
+
3808
+ reporter.start('resolve-repo', 'Resolving repository target...');
3809
+ result.repo = resolveRepo({ repo: args.repo }, deps);
3810
+ reporter.ok('resolve-repo', `Using repository ${result.repo}.`);
3811
+
3812
+ reporter.start('validate-main-branch', `Checking default branch "${args.defaultBranch}"...`);
3813
+ if (!branchExists(deps, result.repo, args.defaultBranch)) {
3814
+ reporter.fail('validate-main-branch', `Default branch "${args.defaultBranch}" was not found in ${result.repo}.`);
3815
+ throw new Error(`Default branch "${args.defaultBranch}" not found in ${result.repo}.`);
3816
+ }
3817
+ reporter.ok('validate-main-branch', `Default branch "${args.defaultBranch}" found.`);
3818
+
3819
+ reporter.start('validate-rulesets', 'Loading existing GitHub rulesets...');
3820
+ const rulesets = listRulesets(deps, result.repo);
3821
+ result.mainRulesetPayload = createRulesetPayload(args);
3822
+ result.existingMainRuleset = rulesets.find((item) => item.name === result.mainRulesetPayload.name) || null;
3823
+ if (selections.withBeta) {
3824
+ result.existingBetaRuleset = rulesets.find((item) => item.name === result.betaRulesetPayload.name) || null;
3825
+ }
3826
+ reporter.ok('validate-rulesets', 'Ruleset scan completed.');
3827
+
3828
+ if (selections.withBeta) {
3829
+ reporter.start('validate-beta-branch', `Checking beta branch "${args.betaBranch}"...`);
3830
+ result.betaBranchExists = branchExists(deps, result.repo, args.betaBranch);
3831
+ reporter.ok(
3832
+ 'validate-beta-branch',
3833
+ result.betaBranchExists
3834
+ ? `Beta branch "${args.betaBranch}" already exists.`
3835
+ : `Beta branch "${args.betaBranch}" will be created from "${args.defaultBranch}".`
3836
+ );
3837
+ }
3838
+
3839
+ if (args.releaseAuth === 'app') {
3840
+ reporter.start('validate-release-auth-app', 'Checking repository secrets for release-auth app mode...');
3841
+ const secretNames = listActionsSecretNames(deps, result.repo);
3842
+ result.missingReleaseAuthAppSecrets = findMissingReleaseAuthAppSecrets(secretNames);
3843
+ if (result.missingReleaseAuthAppSecrets.length > 0) {
3844
+ reporter.warn('validate-release-auth-app', `Missing app secrets: ${result.missingReleaseAuthAppSecrets.join(', ')}`);
3845
+ } else {
3846
+ reporter.ok('validate-release-auth-app', 'Required app secrets detected.');
621
3847
  }
622
- },
623
- bypass_actors: [],
624
- rules: [
625
- { type: 'deletion' },
626
- { type: 'non_fast_forward' },
627
- {
628
- type: 'pull_request',
629
- parameters: {
630
- required_approving_review_count: 0,
631
- dismiss_stale_reviews_on_push: true,
632
- require_code_owner_review: false,
633
- require_last_push_approval: false,
634
- required_review_thread_resolution: true
3848
+ }
3849
+ }
3850
+
3851
+ if (selections.withNpm) {
3852
+ reporter.start('validate-npm', 'Validating npm CLI and authentication...');
3853
+ ensureNpmAvailable(deps);
3854
+ ensureNpmAuthenticated(deps);
3855
+ reporter.ok('validate-npm', 'npm CLI available and authenticated.');
3856
+
3857
+ reporter.start('validate-package-publish', `Checking npm package status for ${result.packageName}...`);
3858
+ result.existsOnNpm = packageExistsOnNpm(deps, result.packageName);
3859
+ reporter.ok(
3860
+ 'validate-package-publish',
3861
+ result.existsOnNpm
3862
+ ? `Package ${result.packageName} already exists on npm.`
3863
+ : `Package ${result.packageName} does not exist on npm; first publish will run.`
3864
+ );
3865
+ }
3866
+
3867
+ return result;
3868
+ }
3869
+
3870
+ async function confirmInitPlan(args, selections, context, summary) {
3871
+ const hasExternalActions = selections.withGithub || selections.withNpm || selections.withBeta;
3872
+ const needsLocalForceConfirm = false;
3873
+
3874
+ if (!hasExternalActions && !needsLocalForceConfirm) {
3875
+ return;
3876
+ }
3877
+
3878
+ if (args.yes) {
3879
+ summary.warnings.push('Confirmation prompts skipped due to --yes.');
3880
+ return;
3881
+ }
3882
+ const details = [summarizePlannedInitActions(selections, args, context)];
3883
+
3884
+ if (args.force) {
3885
+ details.push('- --force will overwrite managed files/scripts/dependencies when applicable.');
3886
+ }
3887
+
3888
+ if (selections.withGithub && context.existingMainRuleset) {
3889
+ details.push(`- Ruleset "${context.mainRulesetPayload.name}" already exists and will be overwritten.`);
3890
+ }
3891
+
3892
+ if (selections.withBeta && context.betaBranchExists) {
3893
+ details.push(`- Branch "${args.betaBranch}" already exists and will be used as beta release flow branch.`);
3894
+ }
3895
+
3896
+ if (selections.withBeta && context.existingBetaRuleset) {
3897
+ details.push(`- Ruleset "${context.betaRulesetPayload.name}" already exists and will be overwritten.`);
3898
+ }
3899
+
3900
+ if (args.releaseAuth === 'app' && context.missingReleaseAuthAppSecrets.length > 0) {
3901
+ details.push(`- release-auth app mode is missing secrets: ${context.missingReleaseAuthAppSecrets.join(', ')}`);
3902
+ }
3903
+
3904
+ await confirmOrThrow(details.join('\n'));
3905
+ }
3906
+
3907
+ function runNpmSetup(args, dependencies = {}, options = {}) {
3908
+ const deps = {
3909
+ exec: dependencies.exec || execCommand
3910
+ };
3911
+ const reporter = options.reporter || new StepReporter();
3912
+ const summary = options.summary || createSummary();
3913
+
3914
+ const targetDir = path.resolve(args.dir);
3915
+ const packageJsonPath = path.join(targetDir, 'package.json');
3916
+ const packageJson = readJsonFile(packageJsonPath);
3917
+ const publishMissingByDefault = Boolean(options.publishMissingByDefault);
3918
+ const shouldPublishFirst = args.publishFirst || publishMissingByDefault;
3919
+
3920
+ reporter.start('npm-auth', 'Checking npm authentication...');
3921
+ ensureNpmAvailable(deps);
3922
+ ensureNpmAuthenticated(deps);
3923
+ reporter.ok('npm-auth', 'npm authentication validated.');
3924
+
3925
+ summary.updatedScriptKeys.push('npm.auth', 'npm.package.lookup');
3926
+
3927
+ if (!packageJson.publishConfig || packageJson.publishConfig.access !== 'public') {
3928
+ summary.warnings.push('package.json publishConfig.access is not "public". First publish may fail for public packages.');
3929
+ }
3930
+
3931
+ reporter.start('npm-exists', `Checking whether ${packageJson.name} exists on npm...`);
3932
+ const existsOnNpm = packageExistsOnNpm(deps, packageJson.name);
3933
+ reporter.ok(
3934
+ 'npm-exists',
3935
+ existsOnNpm
3936
+ ? `Package ${packageJson.name} already exists on npm.`
3937
+ : `Package ${packageJson.name} is not published on npm yet.`
3938
+ );
3939
+
3940
+ if (existsOnNpm) {
3941
+ summary.skippedScriptKeys.push('npm.first_publish');
3942
+ summary.warnings.push(`Package "${packageJson.name}" already exists. First publish is not required.`);
3943
+ } else {
3944
+ summary.updatedScriptKeys.push('npm.first_publish_required');
3945
+ }
3946
+
3947
+ if (!existsOnNpm && !shouldPublishFirst) {
3948
+ summary.warnings.push(`package "${packageJson.name}" was not found on npm. Run "create-package-starter setup-npm --dir ${targetDir} --publish-first" to perform first publish.`);
3949
+ }
3950
+
3951
+ if (!existsOnNpm && shouldPublishFirst) {
3952
+ if (args.dryRun) {
3953
+ summary.warnings.push(`dry-run: would run "npm publish --access public" in ${targetDir}`);
3954
+ } else {
3955
+ reporter.start('npm-publish', `Publishing first version of ${packageJson.name}...`);
3956
+ const publish = deps.exec('npm', ['publish', '--access', 'public'], { cwd: targetDir, stdio: 'inherit' });
3957
+ if (publish.status !== 0) {
3958
+ reporter.fail('npm-publish', 'First publish failed.');
3959
+ const publishOutput = `${publish.stderr || ''}\n${publish.stdout || ''}`.toLowerCase();
3960
+ const isOtpError = publishOutput.includes('eotp') || publishOutput.includes('one-time password');
3961
+
3962
+ if (isOtpError) {
3963
+ throw new Error(
3964
+ [
3965
+ 'First publish failed due to npm 2FA/OTP requirements.',
3966
+ 'This command already delegates to the standard npm publish flow.',
3967
+ 'If npm still requires manual OTP entry, complete publish manually:',
3968
+ ` (cd ${targetDir} && npm publish --access public)`
3969
+ ].join('\n')
3970
+ );
635
3971
  }
3972
+
3973
+ throw new Error('First publish failed. Check npm output above and try again.');
636
3974
  }
637
- ]
638
- };
639
- }
640
3975
 
641
- function createRulesetPayload(args) {
642
- if (!args.ruleset) {
643
- return createBaseRulesetPayload(args.defaultBranch);
3976
+ reporter.ok('npm-publish', `First publish for ${packageJson.name} completed.`);
3977
+ summary.updatedScriptKeys.push('npm.first_publish_done');
3978
+ }
644
3979
  }
645
3980
 
646
- const rulesetPath = path.resolve(args.ruleset);
647
- if (!fs.existsSync(rulesetPath)) {
648
- throw new Error(`Ruleset file not found: ${rulesetPath}`);
649
- }
3981
+ summary.warnings.push('Configure npm Trusted Publisher manually in npm package settings after first publish.');
3982
+ summary.warnings.push('Trusted Publisher requires owner, repository, workflow file (.github/workflows/release.yml), and branch (main by default).');
650
3983
 
651
- return readJsonFile(rulesetPath);
3984
+ return summary;
652
3985
  }
653
3986
 
654
- function ghApi(deps, method, endpoint, payload) {
655
- const args = ['api', '--method', method, endpoint];
3987
+ function setupNpm(args, dependencies = {}) {
3988
+ const targetDir = path.resolve(args.dir);
3989
+ if (!fs.existsSync(targetDir)) {
3990
+ throw new Error(`Directory not found: ${targetDir}`);
3991
+ }
656
3992
 
657
- if (payload !== undefined) {
658
- args.push('--input', '-');
3993
+ const packageJsonPath = path.join(targetDir, 'package.json');
3994
+ if (!fs.existsSync(packageJsonPath)) {
3995
+ throw new Error(`package.json not found in ${targetDir}`);
659
3996
  }
660
3997
 
661
- return deps.exec('gh', args, {
662
- input: payload !== undefined ? `${JSON.stringify(payload)}\n` : undefined
3998
+ const packageJson = readJsonFile(packageJsonPath);
3999
+ if (!packageJson.name) {
4000
+ throw new Error(`package.json in ${targetDir} must define "name".`);
4001
+ }
4002
+
4003
+ const summary = runNpmSetup(args, dependencies, {
4004
+ reporter: new StepReporter(),
4005
+ publishMissingByDefault: false
663
4006
  });
4007
+ printSummary(`npm setup completed for ${packageJson.name}`, summary);
664
4008
  }
665
4009
 
666
- function ensureGhAvailable(deps) {
667
- const version = deps.exec('gh', ['--version']);
668
- if (version.status !== 0) {
669
- throw new Error('GitHub CLI (gh) is required. Install it from https://cli.github.com/ and rerun.');
4010
+ async function setupBeta(args, dependencies = {}) {
4011
+ const deps = {
4012
+ exec: dependencies.exec || execCommand
4013
+ };
4014
+
4015
+ const targetDir = path.resolve(args.dir);
4016
+ if (!fs.existsSync(targetDir)) {
4017
+ throw new Error(`Directory not found: ${targetDir}`);
670
4018
  }
671
4019
 
672
- const auth = deps.exec('gh', ['auth', 'status']);
673
- if (auth.status !== 0) {
674
- throw new Error('GitHub CLI is not authenticated. Run "gh auth login" and rerun.');
4020
+ const packageJsonPath = path.join(targetDir, 'package.json');
4021
+ if (!fs.existsSync(packageJsonPath)) {
4022
+ throw new Error(`package.json not found in ${targetDir}`);
675
4023
  }
676
- }
677
4024
 
678
- function parseJsonOutput(output, fallbackError) {
4025
+ const packageRoot = path.resolve(__dirname, '..');
4026
+ const templateDir = path.join(packageRoot, 'template');
4027
+ const packageJson = readJsonFile(packageJsonPath);
4028
+ packageJson.scripts = packageJson.scripts || {};
4029
+
4030
+ logStep('run', 'Checking GitHub CLI availability and authentication...');
679
4031
  try {
680
- return JSON.parse(output);
4032
+ ensureGhAvailable(deps);
4033
+ logStep('ok', 'GitHub CLI is available and authenticated.');
681
4034
  } catch (error) {
682
- throw new Error(fallbackError);
4035
+ logStep('err', error.message);
4036
+ if (error.message.includes('not authenticated')) {
4037
+ logStep('warn', 'Run "gh auth login" and retry.');
4038
+ }
4039
+ throw error;
683
4040
  }
684
- }
685
4041
 
686
- function upsertRuleset(deps, repo, rulesetPayload) {
687
- const listResult = ghApi(deps, 'GET', `/repos/${repo}/rulesets`);
688
- if (listResult.status !== 0) {
689
- throw new Error(`Failed to list rulesets: ${listResult.stderr || listResult.stdout}`.trim());
690
- }
4042
+ logStep('run', 'Resolving repository target...');
4043
+ const repo = resolveRepo(args, deps);
4044
+ logStep('ok', `Using repository ${repo}.`);
691
4045
 
692
- const rulesets = parseJsonOutput(listResult.stdout || '[]', 'Failed to parse rulesets response from GitHub API.');
693
- const existing = rulesets.find((ruleset) => ruleset.name === rulesetPayload.name);
4046
+ const summary = createSummary();
4047
+ await resolveReleaseAuthSelection(args, summary, { contextLabel: 'Select release auth mode for beta flow' });
4048
+ summary.updatedScriptKeys.push('github.beta_branch', 'github.beta_ruleset', 'actions.default_workflow_permissions');
4049
+ summary.updatedScriptKeys.push(`release.auth:${args.releaseAuth}`);
4050
+ const releaseAuthVariables = buildReleaseAuthVariables(args.releaseAuth || DEFAULT_RELEASE_AUTH);
4051
+ const desiredScripts = {
4052
+ 'beta:enter': 'changeset pre enter beta',
4053
+ 'beta:exit': 'changeset pre exit',
4054
+ 'beta:version': 'changeset version',
4055
+ 'beta:publish': 'changeset publish',
4056
+ 'beta:promote': 'create-package-starter promote-stable --dir .'
4057
+ };
694
4058
 
695
- if (!existing) {
696
- const createResult = ghApi(deps, 'POST', `/repos/${repo}/rulesets`, rulesetPayload);
697
- if (createResult.status !== 0) {
698
- throw new Error(`Failed to create ruleset: ${createResult.stderr || createResult.stdout}`.trim());
4059
+ let packageJsonChanged = false;
4060
+ for (const [key, value] of Object.entries(desiredScripts)) {
4061
+ const exists = Object.prototype.hasOwnProperty.call(packageJson.scripts, key);
4062
+ if (!exists || args.force) {
4063
+ if (!exists || packageJson.scripts[key] !== value) {
4064
+ packageJson.scripts[key] = value;
4065
+ packageJsonChanged = true;
4066
+ }
4067
+ summary.updatedScriptKeys.push(key);
4068
+ } else {
4069
+ summary.skippedScriptKeys.push(key);
699
4070
  }
4071
+ }
700
4072
 
701
- return 'created';
4073
+ const workflowRelativePath = '.github/workflows/release.yml';
4074
+ const workflowTemplatePath = path.join(templateDir, workflowRelativePath);
4075
+ const workflowTargetPath = path.join(targetDir, workflowRelativePath);
4076
+ const ciWorkflowRelativePath = '.github/workflows/ci.yml';
4077
+ const ciWorkflowTemplatePath = path.join(templateDir, ciWorkflowRelativePath);
4078
+ const ciWorkflowTargetPath = path.join(targetDir, ciWorkflowRelativePath);
4079
+ if (!fs.existsSync(workflowTemplatePath)) {
4080
+ throw new Error(`Template not found: ${workflowTemplatePath}`);
4081
+ }
4082
+ if (!fs.existsSync(ciWorkflowTemplatePath)) {
4083
+ throw new Error(`Template not found: ${ciWorkflowTemplatePath}`);
702
4084
  }
703
4085
 
704
- const updateResult = ghApi(deps, 'PUT', `/repos/${repo}/rulesets/${existing.id}`, rulesetPayload);
705
- if (updateResult.status !== 0) {
706
- throw new Error(`Failed to update ruleset: ${updateResult.stderr || updateResult.stdout}`.trim());
4086
+ let missingAppSecrets = [];
4087
+ let appSecretsChecked = false;
4088
+ if (args.releaseAuth === 'app') {
4089
+ try {
4090
+ const secretNames = listActionsSecretNames(deps, repo);
4091
+ missingAppSecrets = findMissingReleaseAuthAppSecrets(secretNames);
4092
+ appSecretsChecked = true;
4093
+ } catch (error) {
4094
+ summary.warnings.push(`Could not validate release-auth app secrets: ${error.message}`);
4095
+ }
707
4096
  }
4097
+ appendReleaseAuthWarnings(summary, args.releaseAuth, { missingAppSecrets, appSecretsChecked });
708
4098
 
709
- return 'updated';
710
- }
4099
+ if (args.dryRun) {
4100
+ logStep('warn', 'Dry-run mode enabled. No remote or file changes will be applied.');
4101
+ const workflowPreview = upsertReleaseWorkflow(workflowTargetPath, workflowTemplatePath, {
4102
+ force: args.force,
4103
+ dryRun: true,
4104
+ variables: {
4105
+ PACKAGE_NAME: packageJson.name || packageDirFromName(path.basename(targetDir)),
4106
+ DEFAULT_BRANCH: args.defaultBranch,
4107
+ BETA_BRANCH: args.betaBranch,
4108
+ SCOPE: deriveScope('', packageJson.name || ''),
4109
+ ...releaseAuthVariables
4110
+ }
4111
+ });
4112
+ if (workflowPreview.result === 'created') {
4113
+ summary.warnings.push(`dry-run: would create ${workflowRelativePath}`);
4114
+ } else if (workflowPreview.result === 'overwritten') {
4115
+ summary.warnings.push(`dry-run: would overwrite ${workflowRelativePath}`);
4116
+ } else if (workflowPreview.result === 'updated') {
4117
+ summary.warnings.push(`dry-run: would update ${workflowRelativePath} trigger branches`);
4118
+ } else {
4119
+ summary.warnings.push(`dry-run: would keep existing ${workflowRelativePath}`);
4120
+ if (workflowPreview.warning) {
4121
+ summary.warnings.push(`dry-run: ${workflowPreview.warning}`);
4122
+ }
4123
+ }
4124
+ const ciWorkflowPreview = upsertReleaseWorkflow(ciWorkflowTargetPath, ciWorkflowTemplatePath, {
4125
+ force: args.force,
4126
+ dryRun: true,
4127
+ variables: {
4128
+ PACKAGE_NAME: packageJson.name || packageDirFromName(path.basename(targetDir)),
4129
+ DEFAULT_BRANCH: args.defaultBranch,
4130
+ BETA_BRANCH: args.betaBranch,
4131
+ SCOPE: deriveScope('', packageJson.name || ''),
4132
+ ...releaseAuthVariables
4133
+ }
4134
+ });
4135
+ if (ciWorkflowPreview.result === 'created') {
4136
+ summary.warnings.push(`dry-run: would create ${ciWorkflowRelativePath}`);
4137
+ } else if (ciWorkflowPreview.result === 'overwritten') {
4138
+ summary.warnings.push(`dry-run: would overwrite ${ciWorkflowRelativePath}`);
4139
+ } else if (ciWorkflowPreview.result === 'updated') {
4140
+ summary.warnings.push(`dry-run: would update ${ciWorkflowRelativePath} trigger branches`);
4141
+ } else {
4142
+ summary.warnings.push(`dry-run: would keep existing ${ciWorkflowRelativePath}`);
4143
+ if (ciWorkflowPreview.warning) {
4144
+ summary.warnings.push(`dry-run: ${ciWorkflowPreview.warning}`);
4145
+ }
4146
+ }
4147
+ if (packageJsonChanged) {
4148
+ summary.warnings.push('dry-run: would update package.json beta scripts');
4149
+ }
4150
+ summary.warnings.push(`dry-run: would ensure branch "${args.betaBranch}" exists in ${repo}`);
4151
+ summary.warnings.push(`dry-run: would upsert ruleset for refs/heads/${args.betaBranch}`);
4152
+ summary.warnings.push(`dry-run: would set Actions workflow permissions to write for ${repo}`);
4153
+ summary.warnings.push(`dry-run: beta branch configured as ${args.betaBranch}`);
4154
+ } else {
4155
+ const betaRulesetPayload = createBetaRulesetPayload(args.betaBranch);
4156
+ const doesBranchExist = branchExists(deps, repo, args.betaBranch);
4157
+ const existingRuleset = findRulesetByName(deps, repo, betaRulesetPayload.name);
711
4158
 
712
- function updateWorkflowPermissions(deps, repo) {
713
- const workflowPermissionsPayload = {
714
- default_workflow_permissions: 'write',
715
- can_approve_pull_request_reviews: true
716
- };
4159
+ if (args.yes) {
4160
+ logStep('warn', 'Confirmation prompts skipped due to --yes.');
4161
+ } else {
4162
+ const confirmDetails = [
4163
+ `This will modify GitHub repository settings for ${repo}:`,
4164
+ '- set Actions workflow permissions to write',
4165
+ `- ensure branch "${args.betaBranch}" exists${doesBranchExist ? ' (already exists)' : ' (will be created)'}`,
4166
+ `- apply branch protection ruleset "${betaRulesetPayload.name}"`,
4167
+ `- require CI status check "${REQUIRED_CHECK_CONTEXT}" on beta branch`,
4168
+ `- update local ${workflowRelativePath} and package.json beta scripts`
4169
+ ];
4170
+
4171
+ if (existingRuleset) {
4172
+ confirmDetails.push(`- existing ruleset "${betaRulesetPayload.name}" will be overwritten`);
4173
+ }
717
4174
 
718
- const result = ghApi(
719
- deps,
720
- 'PUT',
721
- `/repos/${repo}/actions/permissions/workflow`,
722
- workflowPermissionsPayload
723
- );
4175
+ if (missingAppSecrets.length > 0) {
4176
+ confirmDetails.push(`- release-auth app mode is missing secrets: ${missingAppSecrets.join(', ')}`);
4177
+ }
724
4178
 
725
- if (result.status !== 0) {
726
- throw new Error(
727
- `Failed to update workflow permissions: ${result.stderr || result.stdout}`.trim()
728
- );
729
- }
730
- }
4179
+ await confirmOrThrow(confirmDetails.join('\n'));
4180
+ }
731
4181
 
732
- function ensureNpmAvailable(deps) {
733
- const version = deps.exec('npm', ['--version']);
734
- if (version.status !== 0) {
735
- throw new Error('npm CLI is required. Install npm and rerun.');
736
- }
737
- }
4182
+ logStep('run', `Ensuring ${workflowRelativePath} includes stable+beta triggers...`);
4183
+ const workflowUpsert = upsertReleaseWorkflow(workflowTargetPath, workflowTemplatePath, {
4184
+ force: args.force,
4185
+ dryRun: false,
4186
+ variables: {
4187
+ PACKAGE_NAME: packageJson.name || packageDirFromName(path.basename(targetDir)),
4188
+ DEFAULT_BRANCH: args.defaultBranch,
4189
+ BETA_BRANCH: args.betaBranch,
4190
+ SCOPE: deriveScope('', packageJson.name || ''),
4191
+ ...releaseAuthVariables
4192
+ }
4193
+ });
4194
+ const workflowResult = workflowUpsert.result;
4195
+
4196
+ if (workflowResult === 'created') {
4197
+ summary.createdFiles.push(workflowRelativePath);
4198
+ logStep('ok', `${workflowRelativePath} created.`);
4199
+ } else if (workflowResult === 'overwritten') {
4200
+ summary.overwrittenFiles.push(workflowRelativePath);
4201
+ logStep('ok', `${workflowRelativePath} overwritten.`);
4202
+ } else if (workflowResult === 'updated') {
4203
+ summary.overwrittenFiles.push(workflowRelativePath);
4204
+ logStep('ok', `${workflowRelativePath} updated with missing branch triggers.`);
4205
+ } else {
4206
+ summary.skippedFiles.push(workflowRelativePath);
4207
+ if (workflowUpsert.warning) {
4208
+ summary.warnings.push(workflowUpsert.warning);
4209
+ logStep('warn', workflowUpsert.warning);
4210
+ } else {
4211
+ logStep('warn', `${workflowRelativePath} already configured; kept as-is.`);
4212
+ }
4213
+ }
738
4214
 
739
- function ensureNpmAuthenticated(deps) {
740
- const whoami = deps.exec('npm', ['whoami']);
741
- if (whoami.status !== 0) {
742
- throw new Error('npm CLI is not authenticated. Run "npm login" and rerun.');
743
- }
744
- }
4215
+ logStep('run', `Ensuring ${ciWorkflowRelativePath} includes stable+beta triggers...`);
4216
+ const ciWorkflowUpsert = upsertReleaseWorkflow(ciWorkflowTargetPath, ciWorkflowTemplatePath, {
4217
+ force: args.force,
4218
+ dryRun: false,
4219
+ variables: {
4220
+ PACKAGE_NAME: packageJson.name || packageDirFromName(path.basename(targetDir)),
4221
+ DEFAULT_BRANCH: args.defaultBranch,
4222
+ BETA_BRANCH: args.betaBranch,
4223
+ SCOPE: deriveScope('', packageJson.name || ''),
4224
+ ...releaseAuthVariables
4225
+ }
4226
+ });
4227
+ const ciWorkflowResult = ciWorkflowUpsert.result;
4228
+ if (ciWorkflowResult === 'created') {
4229
+ summary.createdFiles.push(ciWorkflowRelativePath);
4230
+ logStep('ok', `${ciWorkflowRelativePath} created.`);
4231
+ } else if (ciWorkflowResult === 'overwritten') {
4232
+ summary.overwrittenFiles.push(ciWorkflowRelativePath);
4233
+ logStep('ok', `${ciWorkflowRelativePath} overwritten.`);
4234
+ } else if (ciWorkflowResult === 'updated') {
4235
+ summary.overwrittenFiles.push(ciWorkflowRelativePath);
4236
+ logStep('ok', `${ciWorkflowRelativePath} updated with missing branch triggers.`);
4237
+ } else {
4238
+ summary.skippedFiles.push(ciWorkflowRelativePath);
4239
+ if (ciWorkflowUpsert.warning) {
4240
+ summary.warnings.push(ciWorkflowUpsert.warning);
4241
+ logStep('warn', ciWorkflowUpsert.warning);
4242
+ } else {
4243
+ logStep('warn', `${ciWorkflowRelativePath} already configured; kept as-is.`);
4244
+ }
4245
+ }
745
4246
 
746
- function packageExistsOnNpm(deps, packageName) {
747
- const view = deps.exec('npm', ['view', packageName, 'version', '--json']);
748
- if (view.status === 0) {
749
- return true;
750
- }
4247
+ if (packageJsonChanged) {
4248
+ logStep('run', 'Updating package.json beta scripts...');
4249
+ writeJsonFile(packageJsonPath, packageJson);
4250
+ logStep('ok', 'package.json beta scripts updated.');
4251
+ } else {
4252
+ logStep('warn', 'package.json beta scripts already present; no changes needed.');
4253
+ }
751
4254
 
752
- const output = `${view.stderr || ''}\n${view.stdout || ''}`.toLowerCase();
753
- if (output.includes('e404') || output.includes('not found') || output.includes('404')) {
754
- return false;
4255
+ logStep('run', 'Applying GitHub Actions workflow permissions...');
4256
+ updateWorkflowPermissions(deps, repo);
4257
+ logStep('ok', 'Workflow permissions configured.');
4258
+
4259
+ logStep('run', `Ensuring branch "${args.betaBranch}" exists...`);
4260
+ const branchResult = ensureBranchExists(deps, repo, args.defaultBranch, args.betaBranch);
4261
+ if (branchResult === 'created') {
4262
+ summary.createdFiles.push(`github-branch:${args.betaBranch}`);
4263
+ logStep('ok', `Branch "${args.betaBranch}" created from "${args.defaultBranch}".`);
4264
+ } else {
4265
+ summary.skippedFiles.push(`github-branch:${args.betaBranch}`);
4266
+ logStep('warn', `Branch "${args.betaBranch}" already exists.`);
4267
+ }
4268
+
4269
+ logStep('run', `Applying protection ruleset to "${args.betaBranch}"...`);
4270
+ const upsertResult = upsertRuleset(deps, repo, betaRulesetPayload);
4271
+ summary.overwrittenFiles.push(`github-beta-ruleset:${upsertResult}`);
4272
+ logStep('ok', `Beta branch ruleset ${upsertResult}.`);
755
4273
  }
756
4274
 
757
- throw new Error(`Failed to check package on npm: ${view.stderr || view.stdout}`.trim());
4275
+ summary.warnings.push(`Trusted Publisher supports a single workflow file per package. Keep publishing on .github/workflows/release.yml for both stable and beta.`);
4276
+ summary.warnings.push(`Next step: run "npm run beta:enter" once on "${args.betaBranch}", commit .changeset/pre.json, and push.`);
4277
+ printSummary(`beta setup completed for ${targetDir}`, summary);
758
4278
  }
759
4279
 
760
- function setupNpm(args, dependencies = {}) {
4280
+ function createChangesetFile(targetDir, packageName, bumpType, summaryText) {
4281
+ const changesetDir = path.join(targetDir, '.changeset');
4282
+ fs.mkdirSync(changesetDir, { recursive: true });
4283
+ const fileName = `promote-stable-${Date.now()}.md`;
4284
+ const filePath = path.join(changesetDir, fileName);
4285
+ const content = [
4286
+ '---',
4287
+ `"${packageName}": ${bumpType}`,
4288
+ '---',
4289
+ '',
4290
+ summaryText
4291
+ ].join('\n');
4292
+ fs.writeFileSync(filePath, `${content}\n`);
4293
+ return path.posix.join('.changeset', fileName);
4294
+ }
4295
+
4296
+ function promoteStable(args, dependencies = {}) {
761
4297
  const deps = {
762
4298
  exec: dependencies.exec || execCommand
763
4299
  };
@@ -772,62 +4308,44 @@ function setupNpm(args, dependencies = {}) {
772
4308
  throw new Error(`package.json not found in ${targetDir}`);
773
4309
  }
774
4310
 
4311
+ const prePath = path.join(targetDir, '.changeset', 'pre.json');
4312
+ if (!fs.existsSync(prePath)) {
4313
+ throw new Error(`No prerelease state found in ${targetDir}. Run "changeset pre enter beta" first.`);
4314
+ }
4315
+
775
4316
  const packageJson = readJsonFile(packageJsonPath);
776
4317
  if (!packageJson.name) {
777
4318
  throw new Error(`package.json in ${targetDir} must define "name".`);
778
4319
  }
779
4320
 
780
- ensureNpmAvailable(deps);
781
- ensureNpmAuthenticated(deps);
782
-
783
4321
  const summary = createSummary();
784
- summary.updatedScriptKeys.push('npm.auth', 'npm.package.lookup');
785
-
786
- if (!packageJson.publishConfig || packageJson.publishConfig.access !== 'public') {
787
- summary.warnings.push('package.json publishConfig.access is not "public". First publish may fail for public packages.');
788
- }
789
-
790
- const existsOnNpm = packageExistsOnNpm(deps, packageJson.name);
791
- if (existsOnNpm) {
792
- summary.skippedScriptKeys.push('npm.first_publish');
793
- } else {
794
- summary.updatedScriptKeys.push('npm.first_publish_required');
795
- }
4322
+ summary.updatedScriptKeys.push('changeset.pre_exit', 'changeset.promote_stable');
796
4323
 
797
- if (!existsOnNpm && !args.publishFirst) {
798
- summary.warnings.push(`package "${packageJson.name}" was not found on npm. Run "create-package-starter setup-npm --dir ${targetDir} --publish-first" to perform first publish.`);
4324
+ if (args.dryRun) {
4325
+ summary.warnings.push(`dry-run: would run "npx @changesets/cli pre exit" in ${targetDir}`);
4326
+ summary.warnings.push(`dry-run: would create promotion changeset for ${packageJson.name} (${args.type})`);
4327
+ summary.warnings.push(`dry-run: promote flow targets stable branch ${DEFAULT_BASE_BRANCH}`);
4328
+ printSummary(`stable promotion dry-run for ${targetDir}`, summary);
4329
+ return;
799
4330
  }
800
4331
 
801
- if (args.publishFirst) {
802
- if (existsOnNpm) {
803
- summary.warnings.push(`package "${packageJson.name}" already exists on npm. Skipping first publish.`);
804
- } else if (args.dryRun) {
805
- summary.warnings.push(`dry-run: would run "npm publish --access public" in ${targetDir}`);
806
- } else {
807
- const publish = deps.exec('npm', ['publish', '--access', 'public'], { cwd: targetDir });
808
- if (publish.status !== 0) {
809
- throw new Error(`First publish failed: ${(publish.stderr || publish.stdout || '').trim()}`);
810
- }
811
- summary.updatedScriptKeys.push('npm.first_publish_done');
812
- }
4332
+ const preExit = deps.exec('npx', ['@changesets/cli', 'pre', 'exit'], { cwd: targetDir });
4333
+ if (preExit.status !== 0) {
4334
+ throw new Error(`Failed to exit prerelease mode: ${(preExit.stderr || preExit.stdout || '').trim()}`);
813
4335
  }
814
4336
 
815
- summary.warnings.push('Configure npm Trusted Publisher manually in npm package settings after first publish.');
816
- summary.warnings.push('Trusted Publisher requires owner, repository, workflow file (.github/workflows/release.yml), and branch (main by default).');
817
-
818
- printSummary(`npm setup completed for ${packageJson.name}`, summary);
4337
+ const createdChangeset = createChangesetFile(targetDir, packageJson.name, args.type, args.summary);
4338
+ summary.createdFiles.push(createdChangeset);
4339
+ summary.warnings.push('Next step: open PR from beta branch to main and merge to publish stable.');
4340
+ printSummary(`stable promotion prepared for ${targetDir}`, summary);
819
4341
  }
820
4342
 
821
- function setupGithub(args, dependencies = {}) {
4343
+ function applyGithubMainSetup(args, dependencies, summary, reporter) {
822
4344
  const deps = {
823
4345
  exec: dependencies.exec || execCommand
824
4346
  };
825
-
826
- ensureGhAvailable(deps);
827
-
828
4347
  const repo = resolveRepo(args, deps);
829
4348
  const rulesetPayload = createRulesetPayload(args);
830
- const summary = createSummary();
831
4349
 
832
4350
  summary.updatedScriptKeys.push(
833
4351
  'repository.default_branch',
@@ -841,10 +4359,10 @@ function setupGithub(args, dependencies = {}) {
841
4359
  summary.warnings.push(`dry-run: would update repository settings for ${repo}`);
842
4360
  summary.warnings.push(`dry-run: would set actions workflow permissions to write for ${repo}`);
843
4361
  summary.warnings.push(`dry-run: would upsert ruleset "${rulesetPayload.name}" for refs/heads/${args.defaultBranch}`);
844
- printSummary(`GitHub settings dry-run for ${repo}`, summary);
845
- return;
4362
+ return { repo, rulesetPayload };
846
4363
  }
847
4364
 
4365
+ reporter.start('github-main-settings', 'Applying GitHub repository settings...');
848
4366
  const repoPayload = {
849
4367
  default_branch: args.defaultBranch,
850
4368
  delete_branch_on_merge: true,
@@ -856,15 +4374,62 @@ function setupGithub(args, dependencies = {}) {
856
4374
 
857
4375
  const patchRepo = ghApi(deps, 'PATCH', `/repos/${repo}`, repoPayload);
858
4376
  if (patchRepo.status !== 0) {
4377
+ reporter.fail('github-main-settings', 'Failed to update repository settings.');
859
4378
  throw new Error(`Failed to update repository settings: ${patchRepo.stderr || patchRepo.stdout}`.trim());
860
4379
  }
4380
+ reporter.ok('github-main-settings', 'Repository settings updated.');
861
4381
 
4382
+ reporter.start('github-workflow-permissions', 'Applying GitHub Actions workflow permissions...');
862
4383
  updateWorkflowPermissions(deps, repo);
4384
+ reporter.ok('github-workflow-permissions', 'Workflow permissions configured.');
863
4385
 
4386
+ reporter.start('github-main-ruleset', `Applying ruleset "${rulesetPayload.name}"...`);
864
4387
  const upsertResult = upsertRuleset(deps, repo, rulesetPayload);
4388
+ reporter.ok('github-main-ruleset', `Ruleset ${upsertResult}.`);
865
4389
  summary.overwrittenFiles.push(`github-ruleset:${upsertResult}`);
4390
+ return { repo, rulesetPayload };
4391
+ }
4392
+
4393
+ function applyGithubBetaSetup(args, dependencies, summary, reporter, repo) {
4394
+ const deps = {
4395
+ exec: dependencies.exec || execCommand
4396
+ };
4397
+ const betaRulesetPayload = createBetaRulesetPayload(args.betaBranch);
4398
+
4399
+ summary.updatedScriptKeys.push('github.beta_branch', 'github.beta_ruleset');
4400
+
4401
+ if (args.dryRun) {
4402
+ summary.warnings.push(`dry-run: would ensure branch "${args.betaBranch}" exists in ${repo}`);
4403
+ summary.warnings.push(`dry-run: would upsert ruleset "${betaRulesetPayload.name}" for refs/heads/${args.betaBranch}`);
4404
+ return;
4405
+ }
4406
+
4407
+ reporter.start('github-beta-branch', `Ensuring branch "${args.betaBranch}" exists...`);
4408
+ const branchResult = ensureBranchExists(deps, repo, args.defaultBranch, args.betaBranch);
4409
+ if (branchResult === 'created') {
4410
+ summary.createdFiles.push(`github-branch:${args.betaBranch}`);
4411
+ reporter.ok('github-beta-branch', `Branch "${args.betaBranch}" created.`);
4412
+ } else {
4413
+ summary.skippedFiles.push(`github-branch:${args.betaBranch}`);
4414
+ reporter.warn('github-beta-branch', `Branch "${args.betaBranch}" already exists.`);
4415
+ }
4416
+
4417
+ reporter.start('github-beta-ruleset', `Applying beta ruleset "${betaRulesetPayload.name}"...`);
4418
+ const upsertResult = upsertRuleset(deps, repo, betaRulesetPayload);
4419
+ summary.overwrittenFiles.push(`github-beta-ruleset:${upsertResult}`);
4420
+ reporter.ok('github-beta-ruleset', `Beta ruleset ${upsertResult}.`);
4421
+ }
4422
+
4423
+ function setupGithub(args, dependencies = {}) {
4424
+ const summary = createSummary();
4425
+ const deps = {
4426
+ exec: dependencies.exec || execCommand
4427
+ };
4428
+ ensureGhAvailable(deps);
866
4429
 
867
- printSummary(`GitHub settings applied to ${repo}`, summary);
4430
+ const reporter = new StepReporter();
4431
+ const { repo } = applyGithubMainSetup(args, dependencies, summary, reporter);
4432
+ printSummary(args.dryRun ? `GitHub settings dry-run for ${repo}` : `GitHub settings applied to ${repo}`, summary);
868
4433
  }
869
4434
 
870
4435
  async function run(argv, dependencies = {}) {
@@ -876,7 +4441,7 @@ async function run(argv, dependencies = {}) {
876
4441
  }
877
4442
 
878
4443
  if (parsed.mode === 'init') {
879
- initExistingPackage(parsed.args);
4444
+ await initExistingPackage(parsed.args, dependencies);
880
4445
  return;
881
4446
  }
882
4447
 
@@ -885,11 +4450,31 @@ async function run(argv, dependencies = {}) {
885
4450
  return;
886
4451
  }
887
4452
 
4453
+ if (parsed.mode === 'setup-beta') {
4454
+ setupBeta(parsed.args, dependencies);
4455
+ return;
4456
+ }
4457
+
4458
+ if (parsed.mode === 'promote-stable') {
4459
+ promoteStable(parsed.args, dependencies);
4460
+ return;
4461
+ }
4462
+
888
4463
  if (parsed.mode === 'setup-npm') {
889
4464
  setupNpm(parsed.args, dependencies);
890
4465
  return;
891
4466
  }
892
4467
 
4468
+ if (parsed.mode === 'open-pr') {
4469
+ await runOpenPrFlow(parsed.args, dependencies);
4470
+ return;
4471
+ }
4472
+
4473
+ if (parsed.mode === 'release-cycle') {
4474
+ await runReleaseCycle(parsed.args, dependencies);
4475
+ return;
4476
+ }
4477
+
893
4478
  createNewPackage(parsed.args);
894
4479
  }
895
4480
 
@@ -897,6 +4482,12 @@ module.exports = {
897
4482
  run,
898
4483
  parseRepoFromRemote,
899
4484
  createBaseRulesetPayload,
4485
+ createBetaRulesetPayload,
900
4486
  setupGithub,
901
- setupNpm
4487
+ setupNpm,
4488
+ setupBeta,
4489
+ promoteStable,
4490
+ runOpenPrFlow,
4491
+ runReleaseCycle,
4492
+ renderPrBodyDeterministic
902
4493
  };