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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -86,6 +86,10 @@ Orchestrate release cycle:
86
86
  - `release-cycle`
87
87
  - `--repo <owner/repo>` (optional; inferred from `remote.origin.url` when omitted)
88
88
  - `--mode <auto|open-pr|publish>` (default: `auto`)
89
+ - `--track <auto|beta|stable>` (default: `auto`)
90
+ - `--promote-stable` (explicitly trigger stable promotion path; only valid from `release/beta`)
91
+ - `--promote-type <patch|minor|major>` (default: `patch`)
92
+ - `--promote-summary <text>`
89
93
  - `--head <branch>`
90
94
  - `--base <branch>`
91
95
  - `--title <text>`
@@ -99,6 +103,8 @@ Orchestrate release cycle:
99
103
  - `--wait-release-pr` (default behavior: enabled)
100
104
  - `--release-pr-timeout <minutes>` (default: `30`)
101
105
  - `--merge-release-pr` (default behavior: enabled)
106
+ - `--verify-npm` (default behavior: enabled)
107
+ - `--no-cleanup` (disable default local cleanup after successful cycle)
102
108
  - `--yes`
103
109
  - `--dry-run`
104
110
 
@@ -127,6 +133,7 @@ The generated and managed baseline includes:
127
133
  - `.changeset/README.md`
128
134
  - `.github/workflows/ci.yml`
129
135
  - `.github/workflows/release.yml`
136
+ - `.github/workflows/promote-stable.yml`
130
137
  - `.github/PULL_REQUEST_TEMPLATE.md`
131
138
  - `.github/CODEOWNERS`
132
139
  - `CONTRIBUTING.md`
@@ -222,6 +229,8 @@ For `open-pr` mode:
222
229
  - can merge code PR when green
223
230
  - can wait for release PR creation (`changeset-release/*`)
224
231
  - can watch checks and merge release PR when green
232
+ - validates npm publish (package + version + expected dist-tag)
233
+ - cleans local branch by default when safety gates are satisfied (`--no-cleanup` to disable)
225
234
 
226
235
  For `publish` mode:
227
236
  - resolves release PR directly
@@ -232,6 +241,17 @@ The command is policy-aware:
232
241
  - never bypasses required checks/reviews/rulesets
233
242
  - fails fast with actionable diagnostics when blocked
234
243
 
244
+ ### Protected `release/beta` stable promotion
245
+
246
+ When `release/beta` is protected (PR-only), stable promotion in `release-cycle --promote-stable` uses a hybrid flow:
247
+
248
+ 1. dispatch `.github/workflows/promote-stable.yml`
249
+ 2. workflow creates `promote/stable-*` branch
250
+ 3. workflow opens PR `promote/stable-* -> release/beta` and enables auto-merge
251
+ 4. after merge, cycle continues with `release/beta -> main` and release PR progression
252
+
253
+ No direct push to `release/beta` is used in this path.
254
+
235
255
  `release-auth` modes:
236
256
  - `pat` (recommended default): uses `CHANGESETS_GH_TOKEN` fallback to `GITHUB_TOKEN`
237
257
  - `app`: generates token via GitHub App (`GH_APP_ID` or `GH_APP_CLIENT_ID`, plus `GH_APP_PRIVATE_KEY`)
package/lib/run.js CHANGED
@@ -7,6 +7,7 @@ const CHANGESETS_DEP = '@changesets/cli';
7
7
  const CHANGESETS_DEP_VERSION = '^2.29.7';
8
8
  const DEFAULT_BASE_BRANCH = 'main';
9
9
  const DEFAULT_BETA_BRANCH = 'release/beta';
10
+ const DEFAULT_PROMOTE_WORKFLOW = 'promote-stable.yml';
10
11
  const DEFAULT_RULESET_NAME = 'Default main branch protection';
11
12
  const REQUIRED_CHECK_CONTEXT = 'required-check';
12
13
  const DEFAULT_RELEASE_AUTH = 'pat';
@@ -26,6 +27,7 @@ const MANAGED_FILE_SPECS = [
26
27
  ['.changeset/README.md', '.changeset/README.md'],
27
28
  ['.github/workflows/ci.yml', '.github/workflows/ci.yml'],
28
29
  ['.github/workflows/release.yml', '.github/workflows/release.yml'],
30
+ ['.github/workflows/promote-stable.yml', '.github/workflows/promote-stable.yml'],
29
31
  ['.github/workflows/auto-retarget-pr.yml', '.github/workflows/auto-retarget-pr.yml'],
30
32
  ['.github/PULL_REQUEST_TEMPLATE.md', '.github/PULL_REQUEST_TEMPLATE.md'],
31
33
  ['.github/CODEOWNERS', '.github/CODEOWNERS'],
@@ -43,7 +45,7 @@ function usage() {
43
45
  ' create-package-starter setup-github [--repo <owner/repo>] [--default-branch <branch>] [--ruleset <path>] [--dry-run]',
44
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]',
45
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]',
46
- ' create-package-starter release-cycle [--repo <owner/repo>] [--mode auto|open-pr|publish] [--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] [--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]',
47
49
  ' create-package-starter promote-stable [--dir <directory>] [--type patch|minor|major] [--summary <text>] [--dry-run]',
48
50
  ' create-package-starter setup-npm [--dir <directory>] [--publish-first] [--dry-run]',
49
51
  '',
@@ -57,6 +59,7 @@ function usage() {
57
59
  ' create-package-starter setup-beta --dir . --beta-branch release/beta --release-auth app',
58
60
  ' create-package-starter open-pr --auto-merge --watch-checks',
59
61
  ' create-package-starter release-cycle --yes',
62
+ ' create-package-starter release-cycle --promote-stable --promote-type minor --yes',
60
63
  ' create-package-starter promote-stable --dir . --type patch --summary "Promote beta to stable"',
61
64
  ' create-package-starter setup-npm --dir . --publish-first'
62
65
  ].join('\n');
@@ -542,6 +545,10 @@ function parseReleaseCycleArgs(argv) {
542
545
  const args = {
543
546
  repo: '',
544
547
  mode: 'auto',
548
+ track: 'auto',
549
+ promoteStable: false,
550
+ promoteType: 'patch',
551
+ promoteSummary: 'Promote beta track to stable release.',
545
552
  head: '',
546
553
  base: '',
547
554
  title: '',
@@ -555,6 +562,8 @@ function parseReleaseCycleArgs(argv) {
555
562
  waitReleasePr: true,
556
563
  releasePrTimeout: 30,
557
564
  mergeReleasePr: true,
565
+ verifyNpm: true,
566
+ noCleanup: false,
558
567
  yes: false,
559
568
  dryRun: false
560
569
  };
@@ -574,6 +583,24 @@ function parseReleaseCycleArgs(argv) {
574
583
  continue;
575
584
  }
576
585
 
586
+ if (token === '--track') {
587
+ args.track = parseValueFlag(argv, i, '--track');
588
+ i += 1;
589
+ continue;
590
+ }
591
+
592
+ if (token === '--promote-type') {
593
+ args.promoteType = parseValueFlag(argv, i, '--promote-type');
594
+ i += 1;
595
+ continue;
596
+ }
597
+
598
+ if (token === '--promote-summary') {
599
+ args.promoteSummary = parseValueFlag(argv, i, '--promote-summary');
600
+ i += 1;
601
+ continue;
602
+ }
603
+
577
604
  if (token === '--head') {
578
605
  args.head = parseValueFlag(argv, i, '--head');
579
606
  i += 1;
@@ -646,6 +673,21 @@ function parseReleaseCycleArgs(argv) {
646
673
  continue;
647
674
  }
648
675
 
676
+ if (token === '--promote-stable') {
677
+ args.promoteStable = true;
678
+ continue;
679
+ }
680
+
681
+ if (token === '--verify-npm') {
682
+ args.verifyNpm = true;
683
+ continue;
684
+ }
685
+
686
+ if (token === '--no-cleanup') {
687
+ args.noCleanup = true;
688
+ continue;
689
+ }
690
+
649
691
  if (token === '--yes') {
650
692
  args.yes = true;
651
693
  continue;
@@ -668,6 +710,14 @@ function parseReleaseCycleArgs(argv) {
668
710
  throw new Error('Invalid --mode value. Expected auto, open-pr, or publish.');
669
711
  }
670
712
 
713
+ if (!['auto', 'beta', 'stable'].includes(args.track)) {
714
+ throw new Error('Invalid --track value. Expected auto, beta, or stable.');
715
+ }
716
+
717
+ if (!['patch', 'minor', 'major'].includes(args.promoteType)) {
718
+ throw new Error('Invalid --promote-type value. Expected patch, minor, or major.');
719
+ }
720
+
671
721
  if (!['squash', 'merge', 'rebase'].includes(args.mergeMethod)) {
672
722
  throw new Error('Invalid --merge-method value. Expected squash, merge, or rebase.');
673
723
  }
@@ -1161,6 +1211,11 @@ function createOrchestrationSummary() {
1161
1211
  checks: '',
1162
1212
  merge: '',
1163
1213
  releasePr: '',
1214
+ releaseTrack: '',
1215
+ promotionWorkflow: '',
1216
+ promotionPr: '',
1217
+ npmValidation: '',
1218
+ cleanup: '',
1164
1219
  actionsPerformed: [],
1165
1220
  actionsSkipped: [],
1166
1221
  warnings: [],
@@ -1195,6 +1250,11 @@ function printOrchestrationSummary(title, summary) {
1195
1250
  console.log(` - checks watched result: ${summary.checks || 'n/a'}`);
1196
1251
  console.log(` - merge performed/skipped: ${summary.merge || 'n/a'}`);
1197
1252
  console.log(` - release pr discovered/merged: ${summary.releasePr || 'n/a'}`);
1253
+ console.log(` - release track: ${summary.releaseTrack || 'n/a'}`);
1254
+ console.log(` - promotion workflow run: ${summary.promotionWorkflow || 'n/a'}`);
1255
+ console.log(` - promotion PR: ${summary.promotionPr || 'n/a'}`);
1256
+ console.log(` - npm validation: ${summary.npmValidation || 'n/a'}`);
1257
+ console.log(` - cleanup: ${summary.cleanup || 'n/a'}`);
1198
1258
  console.log('actions performed:');
1199
1259
  formatList(summary.actionsPerformed).forEach((line) => console.log(line));
1200
1260
  console.log('actions skipped:');
@@ -1548,6 +1608,206 @@ async function confirmDetectedModeIfNeeded(args, mode, planText) {
1548
1608
  );
1549
1609
  }
1550
1610
 
1611
+ function ghApiJson(deps, method, endpoint, payload) {
1612
+ const result = ghApi(deps, method, endpoint, payload);
1613
+ if (result.status !== 0) {
1614
+ throw new Error(`GitHub API ${method} ${endpoint} failed: ${result.stderr || result.stdout}`.trim());
1615
+ }
1616
+
1617
+ return parseJsonSafely(result.stdout || '{}', {});
1618
+ }
1619
+
1620
+ function dispatchPromoteStableWorkflow(repo, args, deps) {
1621
+ const endpoint = `/repos/${repo}/actions/workflows/${encodeURIComponent(DEFAULT_PROMOTE_WORKFLOW)}/dispatches`;
1622
+ const payload = {
1623
+ ref: args.head || DEFAULT_BETA_BRANCH,
1624
+ inputs: {
1625
+ promote_type: args.promoteType,
1626
+ summary: args.promoteSummary,
1627
+ target_beta_branch: DEFAULT_BETA_BRANCH
1628
+ }
1629
+ };
1630
+ ghApiJson(deps, 'POST', endpoint, payload);
1631
+ }
1632
+
1633
+ function findPromotionPrs(repo, deps) {
1634
+ const prs = listOpenPullRequests(repo, deps);
1635
+ return prs.filter(
1636
+ (item) => item.baseRefName === DEFAULT_BETA_BRANCH
1637
+ && typeof item.headRefName === 'string'
1638
+ && item.headRefName.startsWith('promote/stable-')
1639
+ );
1640
+ }
1641
+
1642
+ function waitForPromotionPr(repo, timeoutMinutes, deps) {
1643
+ const timeoutAt = Date.now() + timeoutMinutes * 60 * 1000;
1644
+ while (Date.now() <= timeoutAt) {
1645
+ const promotionPrs = findPromotionPrs(repo, deps);
1646
+ if (promotionPrs.length === 1) {
1647
+ return promotionPrs[0];
1648
+ }
1649
+
1650
+ if (promotionPrs.length > 1) {
1651
+ promotionPrs.sort((a, b) => b.number - a.number);
1652
+ return promotionPrs[0];
1653
+ }
1654
+
1655
+ sleepMs(5000);
1656
+ }
1657
+
1658
+ throw new Error(`Timed out waiting for promotion PR after ${timeoutMinutes} minutes.`);
1659
+ }
1660
+
1661
+ function getRemotePackageVersion(repo, ref, deps) {
1662
+ const endpoint = `/repos/${repo}/contents/package.json?ref=${encodeURIComponent(ref)}`;
1663
+ const contentResponse = ghApiJson(deps, 'GET', endpoint);
1664
+ if (!contentResponse.content) {
1665
+ throw new Error(`Could not read package.json content from ${repo}@${ref}.`);
1666
+ }
1667
+
1668
+ const decoded = Buffer.from(String(contentResponse.content).replace(/\n/g, ''), 'base64').toString('utf8');
1669
+ const parsed = parseJsonSafely(decoded, {});
1670
+ if (!parsed.name || !parsed.version) {
1671
+ throw new Error(`package.json from ${repo}@${ref} must include name and version.`);
1672
+ }
1673
+
1674
+ return {
1675
+ name: parsed.name,
1676
+ version: parsed.version
1677
+ };
1678
+ }
1679
+
1680
+ function validateNpmPublishedVersionAndTag(packageName, expectedVersion, expectedTag, timeoutMinutes, deps) {
1681
+ const timeoutAt = Date.now() + timeoutMinutes * 60 * 1000;
1682
+ let lastObservedVersion = '';
1683
+ let lastObservedTagVersion = '';
1684
+
1685
+ while (Date.now() <= timeoutAt) {
1686
+ const versionResult = deps.exec('npm', ['view', packageName, 'version', '--json']);
1687
+ const tagsResult = deps.exec('npm', ['view', packageName, 'dist-tags', '--json']);
1688
+ if (versionResult.status === 0 && tagsResult.status === 0) {
1689
+ const observedVersion = String(parseJsonSafely(versionResult.stdout || '""', '') || '');
1690
+ const tags = parseJsonSafely(tagsResult.stdout || '{}', {});
1691
+ const observedTagVersion = tags && tags[expectedTag] ? String(tags[expectedTag]) : '';
1692
+ lastObservedVersion = observedVersion;
1693
+ lastObservedTagVersion = observedTagVersion;
1694
+
1695
+ if (observedVersion === expectedVersion && observedTagVersion === expectedVersion) {
1696
+ return {
1697
+ status: 'pass',
1698
+ observedVersion,
1699
+ observedTagVersion
1700
+ };
1701
+ }
1702
+ }
1703
+
1704
+ sleepMs(10000);
1705
+ }
1706
+
1707
+ return {
1708
+ status: 'timeout',
1709
+ observedVersion: lastObservedVersion,
1710
+ observedTagVersion: lastObservedTagVersion
1711
+ };
1712
+ }
1713
+
1714
+ function ensureWorkingTreeClean(deps) {
1715
+ const status = deps.exec('git', ['status', '--porcelain']);
1716
+ if (status.status !== 0) {
1717
+ throw new Error('Failed to inspect working tree status.');
1718
+ }
1719
+
1720
+ return status.stdout.trim() === '';
1721
+ }
1722
+
1723
+ function isProtectedOrGeneratedBranch(branchName) {
1724
+ if (!branchName) {
1725
+ return true;
1726
+ }
1727
+
1728
+ return branchName === DEFAULT_BASE_BRANCH
1729
+ || branchName === DEFAULT_BETA_BRANCH
1730
+ || branchName.startsWith('changeset-release/')
1731
+ || branchName.startsWith('promote/');
1732
+ }
1733
+
1734
+ function isCleanupCandidateBranch(branchName) {
1735
+ if (!branchName) {
1736
+ return false;
1737
+ }
1738
+
1739
+ return /^(feat|fix|chore|refactor|test)\//.test(branchName);
1740
+ }
1741
+
1742
+ function runLocalCleanup({
1743
+ deps,
1744
+ originalBranch,
1745
+ targetBaseBranch,
1746
+ shouldRun,
1747
+ summary,
1748
+ reporter
1749
+ }) {
1750
+ if (!shouldRun) {
1751
+ summary.actionsSkipped.push('cleanup');
1752
+ summary.cleanup = 'skipped';
1753
+ summary.warnings.push('Local cleanup skipped by configuration (--no-cleanup).');
1754
+ return;
1755
+ }
1756
+
1757
+ if (!isCleanupCandidateBranch(originalBranch)) {
1758
+ summary.actionsSkipped.push('cleanup');
1759
+ summary.cleanup = 'skipped';
1760
+ summary.warnings.push(`Cleanup skipped: branch "${originalBranch}" is not an allowed code branch pattern.`);
1761
+ return;
1762
+ }
1763
+
1764
+ if (isProtectedOrGeneratedBranch(originalBranch)) {
1765
+ summary.actionsSkipped.push('cleanup');
1766
+ summary.cleanup = 'skipped';
1767
+ summary.warnings.push(`Cleanup skipped: branch "${originalBranch}" is protected or generated.`);
1768
+ return;
1769
+ }
1770
+
1771
+ if (!ensureWorkingTreeClean(deps)) {
1772
+ summary.actionsSkipped.push('cleanup');
1773
+ summary.cleanup = 'skipped';
1774
+ summary.warnings.push('Cleanup skipped: working tree is not clean.');
1775
+ return;
1776
+ }
1777
+
1778
+ reporter.start('release-cycle-cleanup-checkout', `Checking out ${targetBaseBranch}...`);
1779
+ const checkout = deps.exec('git', ['checkout', targetBaseBranch]);
1780
+ if (checkout.status !== 0) {
1781
+ summary.cleanup = 'failed';
1782
+ summary.warnings.push(`Cleanup failed: could not checkout ${targetBaseBranch}: ${(checkout.stderr || checkout.stdout || '').trim()}`);
1783
+ reporter.warn('release-cycle-cleanup-checkout', `Could not checkout ${targetBaseBranch}.`);
1784
+ return;
1785
+ }
1786
+ reporter.ok('release-cycle-cleanup-checkout', `Checked out ${targetBaseBranch}.`);
1787
+
1788
+ reporter.start('release-cycle-cleanup-pull', `Pulling latest ${targetBaseBranch}...`);
1789
+ const pull = deps.exec('git', ['pull']);
1790
+ if (pull.status !== 0) {
1791
+ summary.cleanup = 'failed';
1792
+ summary.warnings.push(`Cleanup warning: could not pull ${targetBaseBranch}: ${(pull.stderr || pull.stdout || '').trim()}`);
1793
+ reporter.warn('release-cycle-cleanup-pull', `Could not pull ${targetBaseBranch}.`);
1794
+ } else {
1795
+ reporter.ok('release-cycle-cleanup-pull', `Pulled ${targetBaseBranch}.`);
1796
+ }
1797
+
1798
+ reporter.start('release-cycle-cleanup-delete', `Deleting local branch ${originalBranch}...`);
1799
+ const deleteResult = deps.exec('git', ['branch', '-d', originalBranch]);
1800
+ if (deleteResult.status !== 0) {
1801
+ summary.cleanup = 'failed';
1802
+ summary.warnings.push(`Cleanup warning: could not delete ${originalBranch}: ${(deleteResult.stderr || deleteResult.stdout || '').trim()}`);
1803
+ reporter.warn('release-cycle-cleanup-delete', `Could not delete ${originalBranch}.`);
1804
+ } else {
1805
+ summary.actionsPerformed.push(`cleanup deleted branch: ${originalBranch}`);
1806
+ summary.cleanup = 'completed';
1807
+ reporter.ok('release-cycle-cleanup-delete', `Deleted ${originalBranch}.`);
1808
+ }
1809
+ }
1810
+
1551
1811
  function ensureFileFromTemplate(targetPath, templatePath, options) {
1552
1812
  const exists = fs.existsSync(targetPath);
1553
1813
 
@@ -2126,13 +2386,18 @@ async function runOpenPrFlow(args, dependencies = {}) {
2126
2386
  };
2127
2387
  }
2128
2388
 
2129
- reporter.start('open-pr-push', `Pushing branch "${context.head}"...`);
2130
- const pushResult = ensureBranchPushed(context.repo, context.head, deps);
2131
- reporter.ok('open-pr-push', `Branch "${context.head}" pushed (${pushResult.status}).`);
2132
- summary.branchPushed = `${context.head} (${pushResult.status})`;
2133
- summary.actionsPerformed.push(`branch pushed: ${context.head}`);
2134
- if (pushResult.status === 'up-to-date') {
2135
- summary.warnings.push(`Branch "${context.head}" had no new commits to push.`);
2389
+ if (args.skipPush) {
2390
+ summary.branchPushed = `skipped (${context.head})`;
2391
+ summary.actionsSkipped.push(`push skipped: ${context.head}`);
2392
+ } else {
2393
+ reporter.start('open-pr-push', `Pushing branch "${context.head}"...`);
2394
+ const pushResult = ensureBranchPushed(context.repo, context.head, deps);
2395
+ reporter.ok('open-pr-push', `Branch "${context.head}" pushed (${pushResult.status}).`);
2396
+ summary.branchPushed = `${context.head} (${pushResult.status})`;
2397
+ summary.actionsPerformed.push(`branch pushed: ${context.head}`);
2398
+ if (pushResult.status === 'up-to-date') {
2399
+ summary.warnings.push(`Branch "${context.head}" had no new commits to push.`);
2400
+ }
2136
2401
  }
2137
2402
 
2138
2403
  reporter.start('open-pr-upsert', 'Creating or updating pull request...');
@@ -2144,7 +2409,7 @@ async function runOpenPrFlow(args, dependencies = {}) {
2144
2409
 
2145
2410
  if (args.autoMerge) {
2146
2411
  reporter.start('open-pr-auto-merge', `Enabling auto-merge for PR #${prResult.number}...`);
2147
- enablePrAutoMerge(context.repo, prResult.number, 'squash', deps);
2412
+ enablePrAutoMerge(context.repo, prResult.number, args.mergeMethod || 'squash', deps);
2148
2413
  reporter.ok('open-pr-auto-merge', `Auto-merge enabled for PR #${prResult.number}.`);
2149
2414
  summary.autoMerge = 'enabled';
2150
2415
  summary.actionsPerformed.push(`auto-merge enabled for #${prResult.number}`);
@@ -2186,6 +2451,7 @@ async function runReleaseCycle(args, dependencies = {}) {
2186
2451
  };
2187
2452
  const summary = createOrchestrationSummary();
2188
2453
  const reporter = new StepReporter();
2454
+ const originalBranch = deps.exec('git', ['rev-parse', '--abbrev-ref', 'HEAD']).stdout.trim();
2189
2455
 
2190
2456
  reporter.start('release-cycle-preflight-gh', 'Validating GitHub CLI and authentication...');
2191
2457
  ensureGhAvailable(deps);
@@ -2193,9 +2459,23 @@ async function runReleaseCycle(args, dependencies = {}) {
2193
2459
 
2194
2460
  const gitContext = resolveGitContext(args, deps);
2195
2461
  summary.repoResolved = gitContext.repo;
2462
+ const requestedTrack = args.track === 'auto' ? (args.promoteStable ? 'stable' : 'beta') : args.track;
2463
+ if (args.promoteStable && gitContext.head !== DEFAULT_BETA_BRANCH) {
2464
+ throw new Error(`--promote-stable is only allowed when running from "${DEFAULT_BETA_BRANCH}".`);
2465
+ }
2466
+ if (requestedTrack === 'stable' && !args.promoteStable) {
2467
+ throw new Error('Stable track requires --promote-stable for explicit promotion.');
2468
+ }
2469
+ if (gitContext.head !== DEFAULT_BETA_BRANCH && requestedTrack === 'stable') {
2470
+ throw new Error(`Stable track is only supported from "${DEFAULT_BETA_BRANCH}".`);
2471
+ }
2472
+ summary.actionsPerformed.push(`release track: ${requestedTrack}`);
2473
+ summary.releaseTrack = requestedTrack;
2196
2474
 
2197
2475
  let detectedMode = args.mode;
2198
- if (detectedMode === 'auto') {
2476
+ if (args.promoteStable) {
2477
+ detectedMode = 'open-pr';
2478
+ } else if (detectedMode === 'auto') {
2199
2479
  const releasePrs = findReleasePrs(gitContext.repo, deps);
2200
2480
  if (gitContext.head.startsWith('changeset-release/')) {
2201
2481
  detectedMode = 'publish';
@@ -2218,12 +2498,69 @@ async function runReleaseCycle(args, dependencies = {}) {
2218
2498
  );
2219
2499
 
2220
2500
  if (detectedMode === 'open-pr') {
2501
+ if (args.promoteStable) {
2502
+ reporter.start('release-cycle-promote-dispatch', `Dispatching ${DEFAULT_PROMOTE_WORKFLOW}...`);
2503
+ if (args.dryRun) {
2504
+ reporter.warn('release-cycle-promote-dispatch', `Dry-run: would dispatch ${DEFAULT_PROMOTE_WORKFLOW}.`);
2505
+ summary.actionsPerformed.push(`dry-run: dispatch ${DEFAULT_PROMOTE_WORKFLOW}`);
2506
+ summary.promotionWorkflow = `dry-run: ${DEFAULT_PROMOTE_WORKFLOW}`;
2507
+ } else {
2508
+ dispatchPromoteStableWorkflow(gitContext.repo, {
2509
+ ...args,
2510
+ head: DEFAULT_BETA_BRANCH
2511
+ }, deps);
2512
+ reporter.ok('release-cycle-promote-dispatch', `Dispatched ${DEFAULT_PROMOTE_WORKFLOW}.`);
2513
+ summary.actionsPerformed.push(`promotion workflow dispatched: ${DEFAULT_PROMOTE_WORKFLOW}`);
2514
+ summary.promotionWorkflow = `dispatched: ${DEFAULT_PROMOTE_WORKFLOW}`;
2515
+ }
2516
+
2517
+ if (!args.dryRun) {
2518
+ reporter.start('release-cycle-promote-pr', 'Waiting for promotion PR...');
2519
+ const promotionPr = waitForPromotionPr(gitContext.repo, args.releasePrTimeout, deps);
2520
+ reporter.ok('release-cycle-promote-pr', `Promotion PR found: #${promotionPr.number}`);
2521
+ summary.actionsPerformed.push(`promotion pr discovered: #${promotionPr.number}`);
2522
+ summary.promotionPr = `found (#${promotionPr.number})`;
2523
+
2524
+ if (args.watchChecks) {
2525
+ reporter.start('release-cycle-promote-checks', `Watching promotion PR checks #${promotionPr.number}...`);
2526
+ watchPrChecks(gitContext.repo, promotionPr.number, args.checkTimeout, deps);
2527
+ reporter.ok('release-cycle-promote-checks', `Promotion PR checks green (#${promotionPr.number}).`);
2528
+ }
2529
+
2530
+ if (args.mergeWhenGreen) {
2531
+ reporter.start('release-cycle-promote-merge', `Merging promotion PR #${promotionPr.number}...`);
2532
+ mergePrWhenGreen(gitContext.repo, promotionPr.number, args.mergeMethod, deps);
2533
+ reporter.ok('release-cycle-promote-merge', `Promotion PR #${promotionPr.number} merged.`);
2534
+ summary.actionsPerformed.push(`promotion pr merged: #${promotionPr.number}`);
2535
+ summary.promotionPr = `merged (#${promotionPr.number})`;
2536
+ }
2537
+
2538
+ reporter.start('release-cycle-sync-beta', `Syncing local ${DEFAULT_BETA_BRANCH} branch...`);
2539
+ const checkoutBeta = deps.exec('git', ['checkout', DEFAULT_BETA_BRANCH]);
2540
+ if (checkoutBeta.status !== 0) {
2541
+ throw new Error(`Could not checkout ${DEFAULT_BETA_BRANCH}: ${(checkoutBeta.stderr || checkoutBeta.stdout || '').trim()}`);
2542
+ }
2543
+ const pullBeta = deps.exec('git', ['pull']);
2544
+ if (pullBeta.status !== 0) {
2545
+ throw new Error(`Could not pull ${DEFAULT_BETA_BRANCH}: ${(pullBeta.stderr || pullBeta.stdout || '').trim()}`);
2546
+ }
2547
+ reporter.ok('release-cycle-sync-beta', `${DEFAULT_BETA_BRANCH} synced.`);
2548
+ }
2549
+ } else {
2550
+ summary.promotionWorkflow = 'skipped';
2551
+ summary.promotionPr = 'skipped';
2552
+ }
2553
+
2221
2554
  const openPrResult = await runOpenPrFlow(
2222
2555
  {
2223
2556
  ...args,
2557
+ head: args.promoteStable ? DEFAULT_BETA_BRANCH : args.head,
2558
+ base: args.promoteStable ? DEFAULT_BASE_BRANCH : args.base,
2224
2559
  autoMerge: args.autoMerge,
2225
2560
  watchChecks: args.watchChecks,
2226
2561
  checkTimeout: args.checkTimeout,
2562
+ mergeMethod: args.mergeMethod,
2563
+ skipPush: args.promoteStable,
2227
2564
  printSummary: false
2228
2565
  },
2229
2566
  dependencies
@@ -2250,6 +2587,7 @@ async function runReleaseCycle(args, dependencies = {}) {
2250
2587
  summary.actionsSkipped.push('merge code pr');
2251
2588
  }
2252
2589
 
2590
+ let mergedReleasePr = null;
2253
2591
  if (args.waitReleasePr) {
2254
2592
  if (args.dryRun) {
2255
2593
  summary.releasePr = `dry-run: would wait release PR (${args.releasePrTimeout}m)`;
@@ -2272,6 +2610,7 @@ async function runReleaseCycle(args, dependencies = {}) {
2272
2610
  reporter.ok('release-cycle-merge-release-pr', `Release PR #${releasePr.number} merged.`);
2273
2611
  summary.releasePr = `merged (#${releasePr.number})`;
2274
2612
  summary.actionsPerformed.push(`release pr merged: #${releasePr.number}`);
2613
+ mergedReleasePr = releasePr;
2275
2614
  } else {
2276
2615
  summary.actionsSkipped.push('merge release pr');
2277
2616
  }
@@ -2281,6 +2620,55 @@ async function runReleaseCycle(args, dependencies = {}) {
2281
2620
  summary.actionsSkipped.push('wait release pr');
2282
2621
  }
2283
2622
 
2623
+ if (args.verifyNpm && !args.dryRun && mergedReleasePr) {
2624
+ reporter.start('release-cycle-verify-npm', 'Validating npm publish and dist-tag...');
2625
+ const targetRef = args.promoteStable ? DEFAULT_BASE_BRANCH : DEFAULT_BETA_BRANCH;
2626
+ const expectedTag = requestedTrack === 'stable' ? 'latest' : 'beta';
2627
+ const remotePackage = getRemotePackageVersion(gitContext.repo, targetRef, deps);
2628
+ const npmValidation = validateNpmPublishedVersionAndTag(
2629
+ remotePackage.name,
2630
+ remotePackage.version,
2631
+ expectedTag,
2632
+ args.releasePrTimeout,
2633
+ deps
2634
+ );
2635
+ if (npmValidation.status !== 'pass') {
2636
+ summary.npmValidation = `failed (${expectedTag})`;
2637
+ throw new Error(
2638
+ [
2639
+ 'npm validation failed after release merge.',
2640
+ `Expected: ${remotePackage.name}@${remotePackage.version} with dist-tag ${expectedTag}`,
2641
+ `Observed version: ${npmValidation.observedVersion || 'n/a'}`,
2642
+ `Observed tag (${expectedTag}): ${npmValidation.observedTagVersion || 'n/a'}`
2643
+ ].join('\n')
2644
+ );
2645
+ }
2646
+ reporter.ok('release-cycle-verify-npm', `${remotePackage.name}@${remotePackage.version} validated on tag ${expectedTag}.`);
2647
+ summary.actionsPerformed.push(`npm validation: ${remotePackage.name}@${remotePackage.version} (${expectedTag})`);
2648
+ summary.npmValidation = `pass (${expectedTag} -> ${remotePackage.version})`;
2649
+ } else if (!args.verifyNpm) {
2650
+ summary.actionsSkipped.push('verify npm');
2651
+ summary.npmValidation = 'skipped';
2652
+ } else if (args.dryRun) {
2653
+ summary.npmValidation = 'skipped (dry-run)';
2654
+ } else if (!mergedReleasePr) {
2655
+ summary.npmValidation = 'skipped (release pr not merged)';
2656
+ }
2657
+
2658
+ if (!args.dryRun) {
2659
+ runLocalCleanup({
2660
+ deps,
2661
+ originalBranch,
2662
+ targetBaseBranch: requestedTrack === 'stable' ? DEFAULT_BASE_BRANCH : DEFAULT_BETA_BRANCH,
2663
+ shouldRun: !args.noCleanup,
2664
+ summary,
2665
+ reporter
2666
+ });
2667
+ } else {
2668
+ summary.actionsSkipped.push('cleanup (dry-run)');
2669
+ summary.cleanup = 'skipped (dry-run)';
2670
+ }
2671
+
2284
2672
  printOrchestrationSummary(`release-cycle completed in ${detectedMode} mode`, summary);
2285
2673
  return;
2286
2674
  }
@@ -2336,6 +2724,53 @@ async function runReleaseCycle(args, dependencies = {}) {
2336
2724
  summary.actionsSkipped.push('merge release pr');
2337
2725
  }
2338
2726
 
2727
+ if (args.verifyNpm && !args.dryRun && (args.mergeReleasePr || args.mergeWhenGreen)) {
2728
+ reporter.start('release-cycle-verify-npm', 'Validating npm publish and dist-tag...');
2729
+ const targetRef = requestedTrack === 'stable' ? DEFAULT_BASE_BRANCH : DEFAULT_BETA_BRANCH;
2730
+ const expectedTag = requestedTrack === 'stable' ? 'latest' : 'beta';
2731
+ const remotePackage = getRemotePackageVersion(gitContext.repo, targetRef, deps);
2732
+ const npmValidation = validateNpmPublishedVersionAndTag(
2733
+ remotePackage.name,
2734
+ remotePackage.version,
2735
+ expectedTag,
2736
+ args.releasePrTimeout,
2737
+ deps
2738
+ );
2739
+ if (npmValidation.status !== 'pass') {
2740
+ summary.npmValidation = `failed (${expectedTag})`;
2741
+ throw new Error(
2742
+ [
2743
+ 'npm validation failed after release merge.',
2744
+ `Expected: ${remotePackage.name}@${remotePackage.version} with dist-tag ${expectedTag}`,
2745
+ `Observed version: ${npmValidation.observedVersion || 'n/a'}`,
2746
+ `Observed tag (${expectedTag}): ${npmValidation.observedTagVersion || 'n/a'}`
2747
+ ].join('\n')
2748
+ );
2749
+ }
2750
+ reporter.ok('release-cycle-verify-npm', `${remotePackage.name}@${remotePackage.version} validated on tag ${expectedTag}.`);
2751
+ summary.actionsPerformed.push(`npm validation: ${remotePackage.name}@${remotePackage.version} (${expectedTag})`);
2752
+ summary.npmValidation = `pass (${expectedTag} -> ${remotePackage.version})`;
2753
+ } else if (!args.verifyNpm) {
2754
+ summary.npmValidation = 'skipped';
2755
+ } else if (args.dryRun) {
2756
+ summary.npmValidation = 'skipped (dry-run)';
2757
+ } else {
2758
+ summary.npmValidation = 'skipped (release pr not merged)';
2759
+ }
2760
+
2761
+ if (!args.dryRun) {
2762
+ runLocalCleanup({
2763
+ deps,
2764
+ originalBranch,
2765
+ targetBaseBranch: requestedTrack === 'stable' ? DEFAULT_BASE_BRANCH : DEFAULT_BETA_BRANCH,
2766
+ shouldRun: !args.noCleanup,
2767
+ summary,
2768
+ reporter
2769
+ });
2770
+ } else {
2771
+ summary.cleanup = 'skipped (dry-run)';
2772
+ }
2773
+
2339
2774
  printOrchestrationSummary(`release-cycle completed in ${detectedMode} mode`, summary);
2340
2775
  }
2341
2776
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@i-santos/create-package-starter",
3
- "version": "1.5.0-beta.7",
3
+ "version": "1.5.0-beta.8",
4
4
  "description": "Scaffold new npm packages with a standardized Changesets release workflow",
5
5
  "license": "MIT",
6
6
  "author": "Igor Santos",
@@ -0,0 +1,103 @@
1
+ name: Promote Stable
2
+
3
+ on:
4
+ workflow_dispatch:
5
+ inputs:
6
+ promote_type:
7
+ description: Promotion bump type
8
+ required: true
9
+ default: patch
10
+ type: choice
11
+ options:
12
+ - patch
13
+ - minor
14
+ - major
15
+ summary:
16
+ description: Promotion changeset summary
17
+ required: true
18
+ default: Promote beta track to stable release.
19
+ target_beta_branch:
20
+ description: Target beta branch
21
+ required: true
22
+ default: __BETA_BRANCH__
23
+
24
+ permissions:
25
+ contents: write
26
+ pull-requests: write
27
+
28
+ jobs:
29
+ promote:
30
+ runs-on: ubuntu-latest
31
+ outputs:
32
+ promotion_branch: ${{ steps.prepare.outputs.promotion_branch }}
33
+ promotion_pr_number: ${{ steps.pr.outputs.pull-request-number }}
34
+ promotion_pr_url: ${{ steps.pr.outputs.pull-request-url }}
35
+ steps:
36
+ __RELEASE_AUTH_APP_STEP__
37
+
38
+ - name: Checkout
39
+ uses: actions/checkout@v4
40
+ with:
41
+ fetch-depth: 0
42
+ token: __RELEASE_AUTH_CHECKOUT_TOKEN__
43
+ ref: ${{ github.event.inputs.target_beta_branch }}
44
+
45
+ - name: Setup Node.js
46
+ uses: actions/setup-node@v4
47
+ with:
48
+ node-version: 22
49
+ cache: npm
50
+
51
+ - name: Install
52
+ run: npm ci
53
+
54
+ - name: Prepare promotion branch
55
+ id: prepare
56
+ run: |
57
+ promotion_branch="promote/stable-$(date +%s)"
58
+ echo "promotion_branch=$promotion_branch" >> "$GITHUB_OUTPUT"
59
+ git checkout -b "$promotion_branch"
60
+
61
+ - name: Exit prerelease mode
62
+ run: npm run beta:exit
63
+
64
+ - name: Create promotion changeset
65
+ run: |
66
+ mkdir -p .changeset
67
+ file=".changeset/promote-stable-${{ github.run_id }}.md"
68
+ cat > "$file" <<EOF
69
+ ---
70
+ "__PACKAGE_NAME__": ${{ github.event.inputs.promote_type }}
71
+ ---
72
+
73
+ ${{ github.event.inputs.summary }}
74
+ EOF
75
+
76
+ - name: Commit and push
77
+ run: |
78
+ git config user.name "github-actions[bot]"
79
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
80
+ git add .changeset
81
+ git commit -m "chore: promote beta to stable"
82
+ git push --set-upstream origin "${{ steps.prepare.outputs.promotion_branch }}"
83
+
84
+ - name: Open promotion PR
85
+ id: pr
86
+ uses: peter-evans/create-pull-request@v6
87
+ with:
88
+ token: __RELEASE_AUTH_GITHUB_TOKEN__
89
+ base: ${{ github.event.inputs.target_beta_branch }}
90
+ branch: ${{ steps.prepare.outputs.promotion_branch }}
91
+ title: "chore: promote beta to stable"
92
+ body: |
93
+ ## Summary
94
+ - Exit prerelease mode (`changeset pre exit`)
95
+ - Add stable promotion changeset
96
+ - Prepare stable release flow from `${{ github.event.inputs.target_beta_branch }}`
97
+ draft: false
98
+
99
+ - name: Enable auto-merge
100
+ if: steps.pr.outputs.pull-request-number != ''
101
+ run: gh pr merge "${{ steps.pr.outputs.pull-request-number }}" --repo "${{ github.repository }}" --squash --auto
102
+ env:
103
+ GH_TOKEN: __RELEASE_AUTH_GITHUB_TOKEN__