@i-santos/create-package-starter 1.5.0-beta.8 → 1.5.0-beta.9

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.
Files changed (3) hide show
  1. package/README.md +14 -4
  2. package/lib/run.js +584 -64
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -95,15 +95,20 @@ Orchestrate release cycle:
95
95
  - `--title <text>`
96
96
  - `--body-file <path>`
97
97
  - `--draft`
98
+ - `--phase <code|full>` (default: `full`)
98
99
  - `--auto-merge` (default behavior: enabled)
99
100
  - `--watch-checks` (default behavior: enabled)
100
101
  - `--check-timeout <minutes>` (default: `30`)
102
+ - `--confirm-merges` (require confirmation before each merge step)
101
103
  - `--merge-when-green` (default behavior: enabled)
102
104
  - `--merge-method <squash|merge|rebase>` (default: `squash`)
103
105
  - `--wait-release-pr` (default behavior: enabled)
104
106
  - `--release-pr-timeout <minutes>` (default: `30`)
105
107
  - `--merge-release-pr` (default behavior: enabled)
106
108
  - `--verify-npm` (default behavior: enabled)
109
+ - `--confirm-cleanup` (require confirmation before cleanup; only after npm validation pass)
110
+ - `--sync-base <auto|rebase|merge|off>` (default: `auto`; keeps code branch updated with `release/beta`)
111
+ - `--no-resume` (disable automatic resume/reconciliation behavior)
107
112
  - `--no-cleanup` (disable default local cleanup after successful cycle)
108
113
  - `--yes`
109
114
  - `--dry-run`
@@ -221,25 +226,30 @@ Body source priority:
221
226
 
222
227
  Default mode is `auto`:
223
228
  - if current branch starts with `changeset-release/` => `publish`
224
- - else if exactly one open `changeset-release/*` PR exists => `publish`
229
+ - else if current branch is `release/beta` and exactly one open `changeset-release/*` PR exists => `publish`
225
230
  - else => `open-pr`
226
231
 
227
232
  For `open-pr` mode:
228
233
  - runs open-pr flow
229
- - can merge code PR when green
234
+ - auto-syncs feature/fix branch with `origin/release/beta` before PR (default `--sync-base auto`)
235
+ - enables auto-merge for code PR by default
236
+ - auto-resumes release phase if current code branch is already integrated into `release/beta`
237
+ - supports `--phase code` to stop after code PR merge
230
238
  - can wait for release PR creation (`changeset-release/*`)
231
- - can watch checks and merge release PR when green
239
+ - enables auto-merge for release PR by default
232
240
  - validates npm publish (package + version + expected dist-tag)
233
241
  - cleans local branch by default when safety gates are satisfied (`--no-cleanup` to disable)
234
242
 
235
243
  For `publish` mode:
236
244
  - resolves release PR directly
237
245
  - watches checks
238
- - merges when green (policy permitting)
246
+ - enables auto-merge and waits for merge completion (policy permitting)
239
247
 
240
248
  The command is policy-aware:
241
249
  - never bypasses required checks/reviews/rulesets
242
250
  - fails fast with actionable diagnostics when blocked
251
+ - stops with actionable guidance when approval is still required before merge
252
+ - cleanup runs only when npm validation passes
243
253
 
244
254
  ### Protected `release/beta` stable promotion
245
255
 
package/lib/run.js CHANGED
@@ -45,7 +45,7 @@ function usage() {
45
45
  ' create-package-starter setup-github [--repo <owner/repo>] [--default-branch <branch>] [--ruleset <path>] [--dry-run]',
46
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
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] [--track auto|beta|stable] [--promote-stable] [--promote-type patch|minor|major] [--promote-summary <text>] [--head <branch>] [--base <branch>] [--title <text>] [--body-file <path>] [--draft] [--auto-merge] [--watch-checks] [--check-timeout <minutes>] [--merge-when-green] [--merge-method squash|merge|rebase] [--wait-release-pr] [--release-pr-timeout <minutes>] [--merge-release-pr] [--verify-npm] [--no-cleanup] [--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
49
  ' create-package-starter promote-stable [--dir <directory>] [--type patch|minor|major] [--summary <text>] [--dry-run]',
50
50
  ' create-package-starter setup-npm [--dir <directory>] [--publish-first] [--dry-run]',
51
51
  '',
@@ -446,6 +446,7 @@ function parseOpenPrArgs(argv) {
446
446
  autoMerge: false,
447
447
  watchChecks: false,
448
448
  checkTimeout: 30,
449
+ updateExistingPr: true,
449
450
  yes: false,
450
451
  dryRun: false
451
452
  };
@@ -496,11 +497,16 @@ function parseOpenPrArgs(argv) {
496
497
  }
497
498
 
498
499
  if (token === '--check-timeout') {
499
- args.checkTimeout = Number.parseInt(parseValueFlag(argv, i, '--check-timeout'), 10);
500
+ args.checkTimeout = Number.parseFloat(parseValueFlag(argv, i, '--check-timeout'));
500
501
  i += 1;
501
502
  continue;
502
503
  }
503
504
 
505
+ if (token === '--update-pr-description') {
506
+ args.updateExistingPr = true;
507
+ continue;
508
+ }
509
+
504
510
  if (token === '--draft') {
505
511
  args.draft = true;
506
512
  continue;
@@ -534,8 +540,8 @@ function parseOpenPrArgs(argv) {
534
540
  throw new Error(`Invalid argument: ${token}\n\n${usage()}`);
535
541
  }
536
542
 
537
- if (!Number.isInteger(args.checkTimeout) || args.checkTimeout <= 0) {
538
- throw new Error('Invalid --check-timeout value. Expected a positive integer (minutes).');
543
+ if (!Number.isFinite(args.checkTimeout) || args.checkTimeout <= 0) {
544
+ throw new Error('Invalid --check-timeout value. Expected a positive number (minutes).');
539
545
  }
540
546
 
541
547
  return args;
@@ -545,6 +551,8 @@ function parseReleaseCycleArgs(argv) {
545
551
  const args = {
546
552
  repo: '',
547
553
  mode: 'auto',
554
+ phase: 'full',
555
+ phaseProvided: false,
548
556
  track: 'auto',
549
557
  promoteStable: false,
550
558
  promoteType: 'patch',
@@ -553,16 +561,21 @@ function parseReleaseCycleArgs(argv) {
553
561
  base: '',
554
562
  title: '',
555
563
  bodyFile: '',
564
+ updatePrDescription: false,
556
565
  draft: false,
557
566
  autoMerge: true,
558
567
  watchChecks: true,
559
568
  checkTimeout: 30,
569
+ confirmMerges: false,
570
+ syncBase: 'auto',
571
+ resume: true,
560
572
  mergeWhenGreen: true,
561
573
  mergeMethod: 'squash',
562
574
  waitReleasePr: true,
563
575
  releasePrTimeout: 30,
564
576
  mergeReleasePr: true,
565
577
  verifyNpm: true,
578
+ confirmCleanup: false,
566
579
  noCleanup: false,
567
580
  yes: false,
568
581
  dryRun: false
@@ -583,6 +596,13 @@ function parseReleaseCycleArgs(argv) {
583
596
  continue;
584
597
  }
585
598
 
599
+ if (token === '--phase') {
600
+ args.phase = parseValueFlag(argv, i, '--phase');
601
+ args.phaseProvided = true;
602
+ i += 1;
603
+ continue;
604
+ }
605
+
586
606
  if (token === '--track') {
587
607
  args.track = parseValueFlag(argv, i, '--track');
588
608
  i += 1;
@@ -625,14 +645,19 @@ function parseReleaseCycleArgs(argv) {
625
645
  continue;
626
646
  }
627
647
 
648
+ if (token === '--update-pr-description') {
649
+ args.updatePrDescription = true;
650
+ continue;
651
+ }
652
+
628
653
  if (token === '--check-timeout') {
629
- args.checkTimeout = Number.parseInt(parseValueFlag(argv, i, '--check-timeout'), 10);
654
+ args.checkTimeout = Number.parseFloat(parseValueFlag(argv, i, '--check-timeout'));
630
655
  i += 1;
631
656
  continue;
632
657
  }
633
658
 
634
659
  if (token === '--release-pr-timeout') {
635
- args.releasePrTimeout = Number.parseInt(parseValueFlag(argv, i, '--release-pr-timeout'), 10);
660
+ args.releasePrTimeout = Number.parseFloat(parseValueFlag(argv, i, '--release-pr-timeout'));
636
661
  i += 1;
637
662
  continue;
638
663
  }
@@ -658,6 +683,22 @@ function parseReleaseCycleArgs(argv) {
658
683
  continue;
659
684
  }
660
685
 
686
+ if (token === '--confirm-merges') {
687
+ args.confirmMerges = true;
688
+ continue;
689
+ }
690
+
691
+ if (token === '--sync-base') {
692
+ args.syncBase = parseValueFlag(argv, i, '--sync-base');
693
+ i += 1;
694
+ continue;
695
+ }
696
+
697
+ if (token === '--no-resume') {
698
+ args.resume = false;
699
+ continue;
700
+ }
701
+
661
702
  if (token === '--merge-when-green') {
662
703
  args.mergeWhenGreen = true;
663
704
  continue;
@@ -683,6 +724,11 @@ function parseReleaseCycleArgs(argv) {
683
724
  continue;
684
725
  }
685
726
 
727
+ if (token === '--confirm-cleanup') {
728
+ args.confirmCleanup = true;
729
+ continue;
730
+ }
731
+
686
732
  if (token === '--no-cleanup') {
687
733
  args.noCleanup = true;
688
734
  continue;
@@ -710,6 +756,10 @@ function parseReleaseCycleArgs(argv) {
710
756
  throw new Error('Invalid --mode value. Expected auto, open-pr, or publish.');
711
757
  }
712
758
 
759
+ if (!['code', 'full'].includes(args.phase)) {
760
+ throw new Error('Invalid --phase value. Expected code or full.');
761
+ }
762
+
713
763
  if (!['auto', 'beta', 'stable'].includes(args.track)) {
714
764
  throw new Error('Invalid --track value. Expected auto, beta, or stable.');
715
765
  }
@@ -718,16 +768,20 @@ function parseReleaseCycleArgs(argv) {
718
768
  throw new Error('Invalid --promote-type value. Expected patch, minor, or major.');
719
769
  }
720
770
 
771
+ if (!['auto', 'rebase', 'merge', 'off'].includes(args.syncBase)) {
772
+ throw new Error('Invalid --sync-base value. Expected auto, rebase, merge, or off.');
773
+ }
774
+
721
775
  if (!['squash', 'merge', 'rebase'].includes(args.mergeMethod)) {
722
776
  throw new Error('Invalid --merge-method value. Expected squash, merge, or rebase.');
723
777
  }
724
778
 
725
- if (!Number.isInteger(args.checkTimeout) || args.checkTimeout <= 0) {
726
- throw new Error('Invalid --check-timeout value. Expected a positive integer (minutes).');
779
+ if (!Number.isFinite(args.checkTimeout) || args.checkTimeout <= 0) {
780
+ throw new Error('Invalid --check-timeout value. Expected a positive number (minutes).');
727
781
  }
728
782
 
729
- if (!Number.isInteger(args.releasePrTimeout) || args.releasePrTimeout <= 0) {
730
- throw new Error('Invalid --release-pr-timeout value. Expected a positive integer (minutes).');
783
+ if (!Number.isFinite(args.releasePrTimeout) || args.releasePrTimeout <= 0) {
784
+ throw new Error('Invalid --release-pr-timeout value. Expected a positive number (minutes).');
731
785
  }
732
786
 
733
787
  return args;
@@ -1281,6 +1335,29 @@ function sleepMs(milliseconds) {
1281
1335
  Atomics.wait(view, 0, 0, milliseconds);
1282
1336
  }
1283
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
+
1284
1361
  function resolveGitContext(args, deps) {
1285
1362
  const insideWorkTree = deps.exec('git', ['rev-parse', '--is-inside-work-tree']);
1286
1363
  if (insideWorkTree.status !== 0 || insideWorkTree.stdout.trim() !== 'true') {
@@ -1454,6 +1531,14 @@ function ensureBranchPushed(repo, head, deps) {
1454
1531
 
1455
1532
  function createOrUpdatePr(context, body, args, deps) {
1456
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
+
1457
1542
  const bodyFilePath = path.join(process.cwd(), `.tmp-pr-body-${Date.now()}.md`);
1458
1543
  fs.writeFileSync(bodyFilePath, body);
1459
1544
 
@@ -1540,8 +1625,8 @@ function getPrCheckState(repo, prNumber, deps) {
1540
1625
  }
1541
1626
 
1542
1627
  function watchPrChecks(repo, prNumber, timeoutMinutes, deps) {
1543
- const timeoutAt = Date.now() + timeoutMinutes * 60 * 1000;
1544
- while (Date.now() <= timeoutAt) {
1628
+ const timeoutAt = nowMs(deps) + timeoutMinutes * 60 * 1000;
1629
+ while (nowMs(deps) <= timeoutAt) {
1545
1630
  const state = getPrCheckState(repo, prNumber, deps);
1546
1631
  if (state.failed > 0) {
1547
1632
  throw new Error(`PR #${prNumber} has failing required checks.`);
@@ -1551,7 +1636,7 @@ function watchPrChecks(repo, prNumber, timeoutMinutes, deps) {
1551
1636
  return 'green';
1552
1637
  }
1553
1638
 
1554
- sleepMs(5000);
1639
+ waitForNextPoll(timeoutAt, 5000, deps);
1555
1640
  }
1556
1641
 
1557
1642
  throw new Error(`Timed out waiting for checks on PR #${prNumber} after ${timeoutMinutes} minutes.`);
@@ -1569,6 +1654,237 @@ function mergePrWhenGreen(repo, prNumber, mergeMethod, deps) {
1569
1654
  }
1570
1655
  }
1571
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
+
1572
1888
  function findReleasePrs(repo, deps) {
1573
1889
  const prs = listOpenPullRequests(repo, deps);
1574
1890
  return prs.filter(
@@ -1579,8 +1895,8 @@ function findReleasePrs(repo, deps) {
1579
1895
  }
1580
1896
 
1581
1897
  function waitForReleasePr(repo, timeoutMinutes, deps) {
1582
- const timeoutAt = Date.now() + timeoutMinutes * 60 * 1000;
1583
- while (Date.now() <= timeoutAt) {
1898
+ const timeoutAt = nowMs(deps) + timeoutMinutes * 60 * 1000;
1899
+ while (nowMs(deps) <= timeoutAt) {
1584
1900
  const releasePrs = findReleasePrs(repo, deps);
1585
1901
  if (releasePrs.length === 1) {
1586
1902
  return releasePrs[0];
@@ -1589,7 +1905,7 @@ function waitForReleasePr(repo, timeoutMinutes, deps) {
1589
1905
  throw new Error(`Multiple release PRs detected: ${releasePrs.map((item) => item.url).join(', ')}`);
1590
1906
  }
1591
1907
 
1592
- sleepMs(5000);
1908
+ waitForNextPoll(timeoutAt, 5000, deps);
1593
1909
  }
1594
1910
 
1595
1911
  throw new Error(`Timed out waiting for release PR after ${timeoutMinutes} minutes.`);
@@ -1640,8 +1956,8 @@ function findPromotionPrs(repo, deps) {
1640
1956
  }
1641
1957
 
1642
1958
  function waitForPromotionPr(repo, timeoutMinutes, deps) {
1643
- const timeoutAt = Date.now() + timeoutMinutes * 60 * 1000;
1644
- while (Date.now() <= timeoutAt) {
1959
+ const timeoutAt = nowMs(deps) + timeoutMinutes * 60 * 1000;
1960
+ while (nowMs(deps) <= timeoutAt) {
1645
1961
  const promotionPrs = findPromotionPrs(repo, deps);
1646
1962
  if (promotionPrs.length === 1) {
1647
1963
  return promotionPrs[0];
@@ -1652,7 +1968,7 @@ function waitForPromotionPr(repo, timeoutMinutes, deps) {
1652
1968
  return promotionPrs[0];
1653
1969
  }
1654
1970
 
1655
- sleepMs(5000);
1971
+ waitForNextPoll(timeoutAt, 5000, deps);
1656
1972
  }
1657
1973
 
1658
1974
  throw new Error(`Timed out waiting for promotion PR after ${timeoutMinutes} minutes.`);
@@ -1678,11 +1994,11 @@ function getRemotePackageVersion(repo, ref, deps) {
1678
1994
  }
1679
1995
 
1680
1996
  function validateNpmPublishedVersionAndTag(packageName, expectedVersion, expectedTag, timeoutMinutes, deps) {
1681
- const timeoutAt = Date.now() + timeoutMinutes * 60 * 1000;
1997
+ const timeoutAt = nowMs(deps) + timeoutMinutes * 60 * 1000;
1682
1998
  let lastObservedVersion = '';
1683
1999
  let lastObservedTagVersion = '';
1684
2000
 
1685
- while (Date.now() <= timeoutAt) {
2001
+ while (nowMs(deps) <= timeoutAt) {
1686
2002
  const versionResult = deps.exec('npm', ['view', packageName, 'version', '--json']);
1687
2003
  const tagsResult = deps.exec('npm', ['view', packageName, 'dist-tags', '--json']);
1688
2004
  if (versionResult.status === 0 && tagsResult.status === 0) {
@@ -1701,7 +2017,7 @@ function validateNpmPublishedVersionAndTag(packageName, expectedVersion, expecte
1701
2017
  }
1702
2018
  }
1703
2019
 
1704
- sleepMs(10000);
2020
+ waitForNextPoll(timeoutAt, 10000, deps);
1705
2021
  }
1706
2022
 
1707
2023
  return {
@@ -1739,6 +2055,103 @@ function isCleanupCandidateBranch(branchName) {
1739
2055
  return /^(feat|fix|chore|refactor|test)\//.test(branchName);
1740
2056
  }
1741
2057
 
2058
+ function syncBranchWithBase({
2059
+ deps,
2060
+ headBranch,
2061
+ baseBranch,
2062
+ strategy,
2063
+ reporter,
2064
+ summary,
2065
+ dryRun
2066
+ }) {
2067
+ if (strategy === 'off') {
2068
+ summary.actionsSkipped.push('sync base branch');
2069
+ return {
2070
+ synchronized: false,
2071
+ wasBehind: false
2072
+ };
2073
+ }
2074
+
2075
+ reporter.start('release-cycle-sync-fetch', `Fetching origin/${baseBranch}...`);
2076
+ const fetch = deps.exec('git', ['fetch', 'origin', baseBranch]);
2077
+ if (fetch.status !== 0) {
2078
+ throw new Error(`Failed to fetch origin/${baseBranch}: ${(fetch.stderr || fetch.stdout || '').trim()}`);
2079
+ }
2080
+ reporter.ok('release-cycle-sync-fetch', `Fetched origin/${baseBranch}.`);
2081
+
2082
+ const behindCheck = deps.exec('git', ['rev-list', '--left-right', '--count', `${headBranch}...origin/${baseBranch}`]);
2083
+ if (behindCheck.status !== 0) {
2084
+ throw new Error(`Failed to compare ${headBranch} against origin/${baseBranch}.`);
2085
+ }
2086
+
2087
+ const parts = (behindCheck.stdout || '').trim().split(/\s+/);
2088
+ const behindCount = Number.parseInt(parts[1] || '0', 10);
2089
+ const isBehind = Number.isInteger(behindCount) && behindCount > 0;
2090
+ if (!isBehind) {
2091
+ summary.actionsPerformed.push(`sync base: ${headBranch} already up to date with origin/${baseBranch}`);
2092
+ return {
2093
+ synchronized: true,
2094
+ wasBehind: false
2095
+ };
2096
+ }
2097
+
2098
+ const effectiveStrategy = strategy === 'auto' ? 'rebase' : strategy;
2099
+ if (dryRun) {
2100
+ summary.actionsPerformed.push(`dry-run: would ${effectiveStrategy} ${headBranch} onto origin/${baseBranch}`);
2101
+ return {
2102
+ synchronized: false,
2103
+ wasBehind: true
2104
+ };
2105
+ }
2106
+
2107
+ if (effectiveStrategy === 'rebase') {
2108
+ reporter.start('release-cycle-sync-rebase', `Rebasing ${headBranch} onto origin/${baseBranch}...`);
2109
+ const rebase = deps.exec('git', ['rebase', `origin/${baseBranch}`]);
2110
+ if (rebase.status !== 0) {
2111
+ throw new Error(
2112
+ [
2113
+ `Rebase failed while syncing ${headBranch} with origin/${baseBranch}.`,
2114
+ 'Resolve conflicts, then run `git rebase --continue` or `git rebase --abort`.',
2115
+ (rebase.stderr || rebase.stdout || '').trim()
2116
+ ].filter(Boolean).join('\n')
2117
+ );
2118
+ }
2119
+ reporter.ok('release-cycle-sync-rebase', `${headBranch} rebased onto origin/${baseBranch}.`);
2120
+ summary.actionsPerformed.push(`sync base: rebased ${headBranch} onto origin/${baseBranch}`);
2121
+ return {
2122
+ synchronized: true,
2123
+ wasBehind: true
2124
+ };
2125
+ }
2126
+
2127
+ reporter.start('release-cycle-sync-merge', `Merging origin/${baseBranch} into ${headBranch}...`);
2128
+ const merge = deps.exec('git', ['merge', '--no-edit', `origin/${baseBranch}`]);
2129
+ if (merge.status !== 0) {
2130
+ throw new Error(
2131
+ [
2132
+ `Merge failed while syncing ${headBranch} with origin/${baseBranch}.`,
2133
+ 'Resolve conflicts and commit merge before rerunning.',
2134
+ (merge.stderr || merge.stdout || '').trim()
2135
+ ].filter(Boolean).join('\n')
2136
+ );
2137
+ }
2138
+ reporter.ok('release-cycle-sync-merge', `Merged origin/${baseBranch} into ${headBranch}.`);
2139
+ summary.actionsPerformed.push(`sync base: merged origin/${baseBranch} into ${headBranch}`);
2140
+ return {
2141
+ synchronized: true,
2142
+ wasBehind: true
2143
+ };
2144
+ }
2145
+
2146
+ function isHeadIntegratedIntoBase(headRef, baseBranch, deps) {
2147
+ const fetch = deps.exec('git', ['fetch', 'origin', baseBranch]);
2148
+ if (fetch.status !== 0) {
2149
+ return false;
2150
+ }
2151
+ const ancestor = deps.exec('git', ['merge-base', '--is-ancestor', headRef, `origin/${baseBranch}`]);
2152
+ return ancestor.status === 0;
2153
+ }
2154
+
1742
2155
  function runLocalCleanup({
1743
2156
  deps,
1744
2157
  originalBranch,
@@ -2406,6 +2819,9 @@ async function runOpenPrFlow(args, dependencies = {}) {
2406
2819
  summary.prAction = `${prResult.action} (#${prResult.number})`;
2407
2820
  summary.prUrl = prResult.url || 'n/a';
2408
2821
  summary.actionsPerformed.push(`pr ${prResult.action}: #${prResult.number}`);
2822
+ if (prResult.action === 'reused') {
2823
+ summary.warnings.push('Existing PR reused without body/title changes. Use --update-pr-description to refresh PR content.');
2824
+ }
2409
2825
 
2410
2826
  if (args.autoMerge) {
2411
2827
  reporter.start('open-pr-auto-merge', `Enabling auto-merge for PR #${prResult.number}...`);
@@ -2452,6 +2868,7 @@ async function runReleaseCycle(args, dependencies = {}) {
2452
2868
  const summary = createOrchestrationSummary();
2453
2869
  const reporter = new StepReporter();
2454
2870
  const originalBranch = deps.exec('git', ['rev-parse', '--abbrev-ref', 'HEAD']).stdout.trim();
2871
+ const useAutoMerge = args.autoMerge;
2455
2872
 
2456
2873
  reporter.start('release-cycle-preflight-gh', 'Validating GitHub CLI and authentication...');
2457
2874
  ensureGhAvailable(deps);
@@ -2459,6 +2876,7 @@ async function runReleaseCycle(args, dependencies = {}) {
2459
2876
 
2460
2877
  const gitContext = resolveGitContext(args, deps);
2461
2878
  summary.repoResolved = gitContext.repo;
2879
+ const effectivePhase = args.phase;
2462
2880
  const requestedTrack = args.track === 'auto' ? (args.promoteStable ? 'stable' : 'beta') : args.track;
2463
2881
  if (args.promoteStable && gitContext.head !== DEFAULT_BETA_BRANCH) {
2464
2882
  throw new Error(`--promote-stable is only allowed when running from "${DEFAULT_BETA_BRANCH}".`);
@@ -2471,7 +2889,6 @@ async function runReleaseCycle(args, dependencies = {}) {
2471
2889
  }
2472
2890
  summary.actionsPerformed.push(`release track: ${requestedTrack}`);
2473
2891
  summary.releaseTrack = requestedTrack;
2474
-
2475
2892
  let detectedMode = args.mode;
2476
2893
  if (args.promoteStable) {
2477
2894
  detectedMode = 'open-pr';
@@ -2479,6 +2896,8 @@ async function runReleaseCycle(args, dependencies = {}) {
2479
2896
  const releasePrs = findReleasePrs(gitContext.repo, deps);
2480
2897
  if (gitContext.head.startsWith('changeset-release/')) {
2481
2898
  detectedMode = 'publish';
2899
+ } else if (gitContext.head !== DEFAULT_BETA_BRANCH) {
2900
+ detectedMode = 'open-pr';
2482
2901
  } else if (releasePrs.length === 1) {
2483
2902
  detectedMode = 'publish';
2484
2903
  } else if (releasePrs.length > 1) {
@@ -2551,40 +2970,95 @@ async function runReleaseCycle(args, dependencies = {}) {
2551
2970
  summary.promotionPr = 'skipped';
2552
2971
  }
2553
2972
 
2554
- const openPrResult = await runOpenPrFlow(
2555
- {
2556
- ...args,
2557
- head: args.promoteStable ? DEFAULT_BETA_BRANCH : args.head,
2558
- base: args.promoteStable ? DEFAULT_BASE_BRANCH : args.base,
2559
- autoMerge: args.autoMerge,
2560
- watchChecks: args.watchChecks,
2561
- checkTimeout: args.checkTimeout,
2562
- mergeMethod: args.mergeMethod,
2563
- skipPush: args.promoteStable,
2564
- printSummary: false
2565
- },
2566
- dependencies
2567
- );
2568
-
2569
- const codePr = openPrResult.pr;
2570
- summary.prAction = openPrResult.summary.prAction;
2571
- summary.prUrl = openPrResult.summary.prUrl;
2572
- summary.branchPushed = openPrResult.summary.branchPushed;
2573
- summary.autoMerge = openPrResult.summary.autoMerge;
2574
- summary.checks = openPrResult.summary.checks;
2575
- summary.actionsPerformed.push(...openPrResult.summary.actionsPerformed);
2576
- summary.actionsSkipped.push(...openPrResult.summary.actionsSkipped);
2577
- summary.warnings.push(...openPrResult.summary.warnings);
2578
-
2579
- if (args.mergeWhenGreen && codePr && !args.dryRun) {
2580
- reporter.start('release-cycle-merge-code-pr', `Merging code PR #${codePr.number}...`);
2581
- mergePrWhenGreen(gitContext.repo, codePr.number, args.mergeMethod, deps);
2582
- reporter.ok('release-cycle-merge-code-pr', `Code PR #${codePr.number} merged.`);
2583
- summary.merge = `code pr merged (#${codePr.number})`;
2584
- summary.actionsPerformed.push(`code pr merged: #${codePr.number}`);
2973
+ const canResumeFromMergedCode = args.resume
2974
+ && !args.promoteStable
2975
+ && gitContext.head !== DEFAULT_BETA_BRANCH
2976
+ && !gitContext.head.startsWith('changeset-release/')
2977
+ && isHeadIntegratedIntoBase('HEAD', DEFAULT_BETA_BRANCH, deps);
2978
+
2979
+ let codePr = null;
2980
+ if (canResumeFromMergedCode) {
2981
+ summary.prAction = 'skipped (resume: code already merged)';
2982
+ summary.prUrl = 'n/a';
2983
+ summary.branchPushed = 'skipped (resume)';
2984
+ summary.autoMerge = 'skipped (resume)';
2985
+ summary.checks = 'skipped (resume)';
2986
+ summary.merge = 'skipped (resume: already merged)';
2987
+ summary.actionsPerformed.push(`resume detected: ${gitContext.head} already integrated into ${DEFAULT_BETA_BRANCH}`);
2988
+ summary.actionsSkipped.push('open/update code pr (resume)');
2585
2989
  } else {
2586
- summary.merge = args.dryRun ? 'dry-run: would merge code PR' : 'skipped';
2587
- summary.actionsSkipped.push('merge code pr');
2990
+ if (!args.promoteStable && gitContext.head !== DEFAULT_BETA_BRANCH && !gitContext.head.startsWith('changeset-release/')) {
2991
+ syncBranchWithBase({
2992
+ deps,
2993
+ headBranch: gitContext.head,
2994
+ baseBranch: DEFAULT_BETA_BRANCH,
2995
+ strategy: args.syncBase,
2996
+ reporter,
2997
+ summary,
2998
+ dryRun: args.dryRun
2999
+ });
3000
+ }
3001
+
3002
+ const openPrResult = await runOpenPrFlow(
3003
+ {
3004
+ ...args,
3005
+ head: args.promoteStable ? DEFAULT_BETA_BRANCH : args.head,
3006
+ base: args.promoteStable ? DEFAULT_BASE_BRANCH : args.base,
3007
+ autoMerge: useAutoMerge,
3008
+ watchChecks: args.watchChecks,
3009
+ checkTimeout: args.checkTimeout,
3010
+ mergeMethod: args.mergeMethod,
3011
+ updateExistingPr: args.updatePrDescription,
3012
+ skipPush: args.promoteStable,
3013
+ printSummary: false
3014
+ },
3015
+ dependencies
3016
+ );
3017
+
3018
+ codePr = openPrResult.pr;
3019
+ summary.prAction = openPrResult.summary.prAction;
3020
+ summary.prUrl = openPrResult.summary.prUrl;
3021
+ summary.branchPushed = openPrResult.summary.branchPushed;
3022
+ summary.autoMerge = openPrResult.summary.autoMerge;
3023
+ summary.checks = openPrResult.summary.checks;
3024
+ summary.actionsPerformed.push(...openPrResult.summary.actionsPerformed);
3025
+ summary.actionsSkipped.push(...openPrResult.summary.actionsSkipped);
3026
+ summary.warnings.push(...openPrResult.summary.warnings);
3027
+
3028
+ if (args.mergeWhenGreen && codePr && !args.dryRun) {
3029
+ reporter.start('release-cycle-merge-code-ready', `Checking merge readiness for code PR #${codePr.number}...`);
3030
+ const codeReadiness = waitForPrMergeReadinessOrThrow(
3031
+ gitContext.repo,
3032
+ codePr.number,
3033
+ `Code PR #${codePr.number}`,
3034
+ args.checkTimeout,
3035
+ deps
3036
+ );
3037
+ await confirmMergeIfNeeded(args, codeReadiness, `Code PR #${codePr.number}`);
3038
+ reporter.ok('release-cycle-merge-code-ready', `Code PR #${codePr.number} is ready for merge.`);
3039
+ reporter.start('release-cycle-code-auto-merge', `Enabling auto-merge for code PR #${codePr.number}...`);
3040
+ enablePrAutoMerge(gitContext.repo, codePr.number, args.mergeMethod, deps);
3041
+ reporter.ok('release-cycle-code-auto-merge', `Auto-merge enabled for code PR #${codePr.number}.`);
3042
+ reporter.start('release-cycle-wait-code-merge', `Waiting for code PR #${codePr.number} merge...`);
3043
+ waitForPrMerged(gitContext.repo, codePr.number, args.releasePrTimeout, deps);
3044
+ reporter.ok('release-cycle-wait-code-merge', `Code PR #${codePr.number} merged.`);
3045
+ summary.actionsPerformed.push(`code pr merged: #${codePr.number}`);
3046
+ summary.merge = `code pr merged (#${codePr.number})`;
3047
+ } else {
3048
+ summary.merge = args.dryRun ? 'dry-run: would merge code PR' : 'skipped';
3049
+ summary.actionsSkipped.push('merge code pr');
3050
+ }
3051
+ }
3052
+
3053
+ if (effectivePhase === 'code') {
3054
+ summary.releasePr = 'skipped (phase=code)';
3055
+ summary.npmValidation = 'skipped (phase=code)';
3056
+ summary.cleanup = 'skipped (phase=code; requires npm validation)';
3057
+ summary.actionsSkipped.push('wait release pr (phase=code)');
3058
+ summary.actionsSkipped.push('verify npm (phase=code)');
3059
+ summary.actionsSkipped.push('cleanup (phase=code)');
3060
+ printOrchestrationSummary(`release-cycle completed in ${detectedMode} mode`, summary);
3061
+ return;
2588
3062
  }
2589
3063
 
2590
3064
  let mergedReleasePr = null;
@@ -2605,11 +3079,26 @@ async function runReleaseCycle(args, dependencies = {}) {
2605
3079
  }
2606
3080
 
2607
3081
  if (args.mergeReleasePr) {
2608
- reporter.start('release-cycle-merge-release-pr', `Merging release PR #${releasePr.number}...`);
2609
- mergePrWhenGreen(gitContext.repo, releasePr.number, args.mergeMethod, deps);
2610
- reporter.ok('release-cycle-merge-release-pr', `Release PR #${releasePr.number} merged.`);
3082
+ reporter.start('release-cycle-merge-release-ready', `Checking merge readiness for release PR #${releasePr.number}...`);
3083
+ const releaseReadiness = waitForPrMergeReadinessOrThrow(
3084
+ gitContext.repo,
3085
+ releasePr.number,
3086
+ `Release PR #${releasePr.number}`,
3087
+ args.checkTimeout,
3088
+ deps,
3089
+ { allowBehindTransient: true }
3090
+ );
3091
+ await confirmMergeIfNeeded(args, releaseReadiness, `Release PR #${releasePr.number}`);
3092
+ reporter.ok('release-cycle-merge-release-ready', `Release PR #${releasePr.number} is ready for merge.`);
3093
+ reporter.start('release-cycle-release-auto-merge', `Enabling auto-merge for release PR #${releasePr.number}...`);
3094
+ enablePrAutoMerge(gitContext.repo, releasePr.number, args.mergeMethod, deps);
3095
+ reporter.ok('release-cycle-release-auto-merge', `Auto-merge enabled for release PR #${releasePr.number}.`);
3096
+ reporter.start('release-cycle-release-wait-merge', `Waiting for release PR #${releasePr.number} merge...`);
3097
+ waitForPrMerged(gitContext.repo, releasePr.number, args.releasePrTimeout, deps);
3098
+ reporter.ok('release-cycle-release-wait-merge', `Release PR #${releasePr.number} merged.`);
2611
3099
  summary.releasePr = `merged (#${releasePr.number})`;
2612
3100
  summary.actionsPerformed.push(`release pr merged: #${releasePr.number}`);
3101
+ summary.autoMerge = 'enabled (code + release)';
2613
3102
  mergedReleasePr = releasePr;
2614
3103
  } else {
2615
3104
  summary.actionsSkipped.push('merge release pr');
@@ -2620,6 +3109,7 @@ async function runReleaseCycle(args, dependencies = {}) {
2620
3109
  summary.actionsSkipped.push('wait release pr');
2621
3110
  }
2622
3111
 
3112
+ let npmValidationPassed = false;
2623
3113
  if (args.verifyNpm && !args.dryRun && mergedReleasePr) {
2624
3114
  reporter.start('release-cycle-verify-npm', 'Validating npm publish and dist-tag...');
2625
3115
  const targetRef = args.promoteStable ? DEFAULT_BASE_BRANCH : DEFAULT_BETA_BRANCH;
@@ -2646,6 +3136,7 @@ async function runReleaseCycle(args, dependencies = {}) {
2646
3136
  reporter.ok('release-cycle-verify-npm', `${remotePackage.name}@${remotePackage.version} validated on tag ${expectedTag}.`);
2647
3137
  summary.actionsPerformed.push(`npm validation: ${remotePackage.name}@${remotePackage.version} (${expectedTag})`);
2648
3138
  summary.npmValidation = `pass (${expectedTag} -> ${remotePackage.version})`;
3139
+ npmValidationPassed = true;
2649
3140
  } else if (!args.verifyNpm) {
2650
3141
  summary.actionsSkipped.push('verify npm');
2651
3142
  summary.npmValidation = 'skipped';
@@ -2655,7 +3146,10 @@ async function runReleaseCycle(args, dependencies = {}) {
2655
3146
  summary.npmValidation = 'skipped (release pr not merged)';
2656
3147
  }
2657
3148
 
2658
- if (!args.dryRun) {
3149
+ if (!args.dryRun && npmValidationPassed) {
3150
+ if (args.confirmCleanup && !args.yes) {
3151
+ await confirmOrThrow('Release completed and npm validation passed.\nProceed with local cleanup now?');
3152
+ }
2659
3153
  runLocalCleanup({
2660
3154
  deps,
2661
3155
  originalBranch,
@@ -2664,6 +3158,9 @@ async function runReleaseCycle(args, dependencies = {}) {
2664
3158
  summary,
2665
3159
  reporter
2666
3160
  });
3161
+ } else if (!args.dryRun && !npmValidationPassed) {
3162
+ summary.actionsSkipped.push('cleanup (npm validation did not pass)');
3163
+ summary.cleanup = 'skipped (requires npm validation pass)';
2667
3164
  } else {
2668
3165
  summary.actionsSkipped.push('cleanup (dry-run)');
2669
3166
  summary.cleanup = 'skipped (dry-run)';
@@ -2711,11 +3208,26 @@ async function runReleaseCycle(args, dependencies = {}) {
2711
3208
  summary.merge = `dry-run: would merge release PR #${releasePr.number}`;
2712
3209
  summary.releasePr = `dry-run: would merge (#${releasePr.number})`;
2713
3210
  } else {
2714
- reporter.start('release-cycle-publish-merge', `Merging release PR #${releasePr.number}...`);
2715
- mergePrWhenGreen(gitContext.repo, releasePr.number, args.mergeMethod, deps);
2716
- reporter.ok('release-cycle-publish-merge', `Release PR #${releasePr.number} merged.`);
3211
+ reporter.start('release-cycle-publish-merge-ready', `Checking merge readiness for release PR #${releasePr.number}...`);
3212
+ const publishReadiness = waitForPrMergeReadinessOrThrow(
3213
+ gitContext.repo,
3214
+ releasePr.number,
3215
+ `Release PR #${releasePr.number}`,
3216
+ args.checkTimeout,
3217
+ deps,
3218
+ { allowBehindTransient: true }
3219
+ );
3220
+ await confirmMergeIfNeeded(args, publishReadiness, `Release PR #${releasePr.number}`);
3221
+ reporter.ok('release-cycle-publish-merge-ready', `Release PR #${releasePr.number} is ready for merge.`);
3222
+ reporter.start('release-cycle-publish-auto-merge', `Enabling auto-merge for release PR #${releasePr.number}...`);
3223
+ enablePrAutoMerge(gitContext.repo, releasePr.number, args.mergeMethod, deps);
3224
+ reporter.ok('release-cycle-publish-auto-merge', `Auto-merge enabled for release PR #${releasePr.number}.`);
3225
+ reporter.start('release-cycle-publish-wait-merge', `Waiting for release PR #${releasePr.number} merge...`);
3226
+ waitForPrMerged(gitContext.repo, releasePr.number, args.releasePrTimeout, deps);
3227
+ reporter.ok('release-cycle-publish-wait-merge', `Release PR #${releasePr.number} merged.`);
2717
3228
  summary.merge = `merged release pr (#${releasePr.number})`;
2718
3229
  summary.releasePr = `merged (#${releasePr.number})`;
3230
+ summary.autoMerge = 'enabled (release)';
2719
3231
  summary.actionsPerformed.push(`release pr merged: #${releasePr.number}`);
2720
3232
  }
2721
3233
  } else {
@@ -2724,6 +3236,7 @@ async function runReleaseCycle(args, dependencies = {}) {
2724
3236
  summary.actionsSkipped.push('merge release pr');
2725
3237
  }
2726
3238
 
3239
+ let npmValidationPassed = false;
2727
3240
  if (args.verifyNpm && !args.dryRun && (args.mergeReleasePr || args.mergeWhenGreen)) {
2728
3241
  reporter.start('release-cycle-verify-npm', 'Validating npm publish and dist-tag...');
2729
3242
  const targetRef = requestedTrack === 'stable' ? DEFAULT_BASE_BRANCH : DEFAULT_BETA_BRANCH;
@@ -2750,6 +3263,7 @@ async function runReleaseCycle(args, dependencies = {}) {
2750
3263
  reporter.ok('release-cycle-verify-npm', `${remotePackage.name}@${remotePackage.version} validated on tag ${expectedTag}.`);
2751
3264
  summary.actionsPerformed.push(`npm validation: ${remotePackage.name}@${remotePackage.version} (${expectedTag})`);
2752
3265
  summary.npmValidation = `pass (${expectedTag} -> ${remotePackage.version})`;
3266
+ npmValidationPassed = true;
2753
3267
  } else if (!args.verifyNpm) {
2754
3268
  summary.npmValidation = 'skipped';
2755
3269
  } else if (args.dryRun) {
@@ -2758,7 +3272,10 @@ async function runReleaseCycle(args, dependencies = {}) {
2758
3272
  summary.npmValidation = 'skipped (release pr not merged)';
2759
3273
  }
2760
3274
 
2761
- if (!args.dryRun) {
3275
+ if (!args.dryRun && npmValidationPassed) {
3276
+ if (args.confirmCleanup && !args.yes) {
3277
+ await confirmOrThrow('Release completed and npm validation passed.\nProceed with local cleanup now?');
3278
+ }
2762
3279
  runLocalCleanup({
2763
3280
  deps,
2764
3281
  originalBranch,
@@ -2767,6 +3284,9 @@ async function runReleaseCycle(args, dependencies = {}) {
2767
3284
  summary,
2768
3285
  reporter
2769
3286
  });
3287
+ } else if (!args.dryRun && !npmValidationPassed) {
3288
+ summary.actionsSkipped.push('cleanup (npm validation did not pass)');
3289
+ summary.cleanup = 'skipped (requires npm validation pass)';
2770
3290
  } else {
2771
3291
  summary.cleanup = 'skipped (dry-run)';
2772
3292
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@i-santos/create-package-starter",
3
- "version": "1.5.0-beta.8",
3
+ "version": "1.5.0-beta.9",
4
4
  "description": "Scaffold new npm packages with a standardized Changesets release workflow",
5
5
  "license": "MIT",
6
6
  "author": "Igor Santos",