@wipcomputer/wip-release 1.9.72 → 1.9.74

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/cli.js +4 -0
  2. package/core.mjs +265 -20
  3. package/package.json +1 -1
package/cli.js CHANGED
@@ -24,6 +24,7 @@ const skipWorktreeCheck = args.includes('--skip-worktree-check');
24
24
  const skipTechDocsCheck = args.includes('--skip-tech-docs-check');
25
25
  const skipCoverageCheck = args.includes('--skip-coverage-check');
26
26
  const allowSubToolDrift = args.includes('--allow-sub-tool-drift');
27
+ const noDeployPublic = args.includes('--no-deploy-public');
27
28
  const wantReleaseNotes = args.includes('--release-notes');
28
29
  const noReleaseNotes = args.includes('--no-release-notes');
29
30
  const notesFilePath = flag('notes-file');
@@ -174,6 +175,7 @@ Flags:
174
175
  --skip-stale-check Skip stale remote branch check
175
176
  --skip-worktree-check Skip main-branch + worktree guard (break-glass only)
176
177
  --allow-sub-tool-drift Allow release even if a sub-tool's files changed since the last tag without a version bump (error by default)
178
+ --no-deploy-public Skip the deploy-public.sh step at the end of stable and prerelease flows (runs by default for -private repos)
177
179
 
178
180
  Release notes (REQUIRED for stable, optional for other tracks):
179
181
  1. --notes-file=path Explicit file path
@@ -224,6 +226,7 @@ if (level === 'alpha' || level === 'beta') {
224
226
  publishReleaseNotes: level === 'alpha' ? wantReleaseNotes : !noReleaseNotes,
225
227
  skipWorktreeCheck,
226
228
  allowSubToolDrift,
229
+ noDeployPublic,
227
230
  }).catch(err => {
228
231
  console.error(` \u2717 ${err.message}`);
229
232
  process.exit(1);
@@ -258,6 +261,7 @@ if (level === 'alpha' || level === 'beta') {
258
261
  skipTechDocsCheck,
259
262
  skipCoverageCheck,
260
263
  allowSubToolDrift,
264
+ noDeployPublic,
261
265
  }).catch(err => {
262
266
  console.error(` \u2717 ${err.message}`);
263
267
  process.exit(1);
package/core.mjs CHANGED
@@ -1507,6 +1507,198 @@ function checkTagCollision(repoPath, newVersion) {
1507
1507
  return { ok: true };
1508
1508
  }
1509
1509
 
1510
+ /**
1511
+ * Push the release commit + tag, handling protected-main rejection via
1512
+ * automatic PR flow.
1513
+ *
1514
+ * If `git push origin main` succeeds directly, we also push tags and return.
1515
+ * If it fails with a "protected branch" error (GH006), create a temporary
1516
+ * release branch from the current HEAD, push the branch, open a PR targeting
1517
+ * main, auto-merge it via `gh pr merge --merge --delete-branch`, then push
1518
+ * the tag separately (tags bypass branch protection).
1519
+ *
1520
+ * Phase 4 of the release-pipeline master plan. Earlier today this entire
1521
+ * flow was done manually every time, adding 2-3 minutes per release.
1522
+ *
1523
+ * Returns `{ ok: true, via }` on success where `via` is `"direct"` or `"pr"`.
1524
+ * Returns `{ ok: false, reason, detail }` on failure; caller logs and
1525
+ * continues (matches prior non-fatal push behavior).
1526
+ */
1527
+ function pushReleaseWithAutoPr(repoPath, newVersion, level) {
1528
+ const tag = `v${newVersion}`;
1529
+ // 1. Try direct push first. Most repos allow it.
1530
+ try {
1531
+ execFileSync('git', ['push'], { cwd: repoPath, stdio: 'pipe' });
1532
+ execFileSync('git', ['push', 'origin', tag], { cwd: repoPath, stdio: 'pipe' });
1533
+ return { ok: true, via: 'direct' };
1534
+ } catch (err) {
1535
+ const msg = String(err?.stderr ?? err?.message ?? err);
1536
+ const isProtected = /protected branch|GH006|Changes must be made through a pull request/i.test(msg);
1537
+ if (!isProtected) {
1538
+ return { ok: false, reason: 'push-failed', detail: msg };
1539
+ }
1540
+ }
1541
+
1542
+ // 2. Protected main. Open auto-PR flow.
1543
+ const releaseBranch = `cc-mini/release-${tag}`;
1544
+ console.log(` - Direct push to main refused (protected). Opening release PR...`);
1545
+ try {
1546
+ // Create branch at current HEAD
1547
+ execFileSync('git', ['branch', releaseBranch], { cwd: repoPath, stdio: 'pipe' });
1548
+ execFileSync('git', ['push', '-u', 'origin', releaseBranch], {
1549
+ cwd: repoPath, stdio: 'pipe'
1550
+ });
1551
+ } catch (err) {
1552
+ return {
1553
+ ok: false,
1554
+ reason: 'branch-push-failed',
1555
+ detail: String(err?.stderr ?? err?.message ?? err),
1556
+ };
1557
+ }
1558
+
1559
+ // 3. Create and merge PR via gh CLI
1560
+ try {
1561
+ const prTitle = `release: ${tag}`;
1562
+ const prBody = `Release commit for ${tag} (${level}). Auto-generated by wip-release.`;
1563
+ execFileSync('gh', [
1564
+ 'pr', 'create',
1565
+ '--base', 'main',
1566
+ '--head', releaseBranch,
1567
+ '--title', prTitle,
1568
+ '--body', prBody,
1569
+ ], { cwd: repoPath, stdio: 'pipe' });
1570
+ execFileSync('gh', [
1571
+ 'pr', 'merge', releaseBranch,
1572
+ '--merge', '--delete-branch',
1573
+ ], { cwd: repoPath, stdio: 'pipe' });
1574
+ console.log(` ✓ Release PR merged`);
1575
+ } catch (err) {
1576
+ return {
1577
+ ok: false,
1578
+ reason: 'gh-pr-failed',
1579
+ detail: String(err?.stderr ?? err?.message ?? err),
1580
+ };
1581
+ }
1582
+
1583
+ // 4. Push the tag separately. Tags bypass branch protection on most
1584
+ // GitHub setups, but if this fails the user can push the tag manually.
1585
+ try {
1586
+ execFileSync('git', ['push', 'origin', tag], { cwd: repoPath, stdio: 'pipe' });
1587
+ console.log(` ✓ Pushed tag ${tag}`);
1588
+ } catch (err) {
1589
+ return {
1590
+ ok: false,
1591
+ reason: 'tag-push-failed',
1592
+ detail: String(err?.stderr ?? err?.message ?? err),
1593
+ partialSuccess: 'pr-merged',
1594
+ };
1595
+ }
1596
+
1597
+ // 5. Pull latest main so local HEAD reflects the merge commit. This
1598
+ // keeps subsequent git operations (like deploy-public) happy.
1599
+ try {
1600
+ execFileSync('git', ['fetch', 'origin', 'main'], { cwd: repoPath, stdio: 'pipe' });
1601
+ execFileSync('git', ['merge', '--ff-only', 'origin/main'], {
1602
+ cwd: repoPath, stdio: 'pipe'
1603
+ });
1604
+ } catch {
1605
+ // Non-fatal: main may have diverged (unlikely here). Deploy-public
1606
+ // will handle its own state.
1607
+ }
1608
+
1609
+ return { ok: true, via: 'pr' };
1610
+ }
1611
+
1612
+ function logPushFailure(result, tag) {
1613
+ console.log(` ! Push failed: ${result.reason}`);
1614
+ if (result.detail) {
1615
+ console.log(` ${result.detail.split('\n')[0]}`);
1616
+ }
1617
+ console.log(` Manual recovery:`);
1618
+ if (result.reason === 'push-failed' || result.reason === 'branch-push-failed') {
1619
+ console.log(` git push && git push origin ${tag}`);
1620
+ } else if (result.reason === 'gh-pr-failed') {
1621
+ console.log(` Open a PR for cc-mini/release-${tag} targeting main, merge, then:`);
1622
+ console.log(` git push origin ${tag}`);
1623
+ } else if (result.reason === 'tag-push-failed') {
1624
+ console.log(` PR already merged. Just push the tag:`);
1625
+ console.log(` git push origin ${tag}`);
1626
+ }
1627
+ }
1628
+
1629
+ /**
1630
+ * Resolve the public GitHub repo name for a private-to-public mirror setup.
1631
+ *
1632
+ * Strategy:
1633
+ * 1. Read `origin` remote URL (e.g. git@github.com:owner/repo-private.git)
1634
+ * 2. Strip `-private` suffix to get the public repo name
1635
+ * 3. Return `owner/repo` format suitable for gh CLI
1636
+ *
1637
+ * Returns null if the remote URL cannot be parsed or the repo name does not
1638
+ * end in `-private` (in which case the caller should skip deploy-public as a
1639
+ * no-op rather than error).
1640
+ */
1641
+ function resolvePublicRepoName(repoPath) {
1642
+ try {
1643
+ const remoteUrl = execFileSync('git', ['remote', 'get-url', 'origin'], {
1644
+ cwd: repoPath, encoding: 'utf8'
1645
+ }).trim();
1646
+ // Match owner/repo from either SSH (git@github.com:owner/repo.git) or HTTPS
1647
+ const match = remoteUrl.match(/github\.com[:/]([^/]+)\/(.+?)(\.git)?$/);
1648
+ if (!match) return null;
1649
+ const [, owner, repoName] = match;
1650
+ if (!repoName.endsWith('-private')) {
1651
+ return null;
1652
+ }
1653
+ return `${owner}/${repoName.replace(/-private$/, '')}`;
1654
+ } catch {
1655
+ return null;
1656
+ }
1657
+ }
1658
+
1659
+ /**
1660
+ * Run deploy-public.sh as the final step of the release pipeline.
1661
+ *
1662
+ * Previously deploy-public was a separate manual step. Every release that
1663
+ * forgot it left the public mirror stale, so `ldm install` pulled old code
1664
+ * from the public repo. Now wip-release invokes it automatically for stable
1665
+ * and prerelease tracks (hotfix still skips, per prior convention).
1666
+ *
1667
+ * Callable with `--no-deploy-public` to opt out.
1668
+ *
1669
+ * Skips silently if:
1670
+ * - The repo has no tools/deploy-public/deploy-public.sh (not a toolbox-
1671
+ * style repo, or deploy-public is provided by a parent install)
1672
+ * - The origin remote is not a -private repo (no public mirror to sync)
1673
+ *
1674
+ * Related: `ai/product/bugs/release-pipeline/2026-04-05--cc-mini--release-pipeline-master-plan.md` Phase 6.
1675
+ */
1676
+ function runDeployPublic(repoPath, { skip } = {}) {
1677
+ if (skip) {
1678
+ return { ok: true, skipped: true, reason: 'flag' };
1679
+ }
1680
+ const scriptPath = join(repoPath, 'tools', 'deploy-public', 'deploy-public.sh');
1681
+ if (!existsSync(scriptPath)) {
1682
+ return { ok: true, skipped: true, reason: 'no-script' };
1683
+ }
1684
+ const publicRepo = resolvePublicRepoName(repoPath);
1685
+ if (!publicRepo) {
1686
+ return { ok: true, skipped: true, reason: 'not-private-repo' };
1687
+ }
1688
+ try {
1689
+ execFileSync('bash', [scriptPath, repoPath, publicRepo], {
1690
+ cwd: repoPath, stdio: 'inherit',
1691
+ });
1692
+ return { ok: true, publicRepo };
1693
+ } catch (err) {
1694
+ return {
1695
+ ok: false,
1696
+ detail: String(err?.stderr ?? err?.message ?? err),
1697
+ publicRepo,
1698
+ };
1699
+ }
1700
+ }
1701
+
1510
1702
  function logMainBranchGuardFailure(result) {
1511
1703
  if (result.reason === 'linked-worktree') {
1512
1704
  console.log(` \u2717 wip-release must run from the main working tree, not a worktree.`);
@@ -1526,7 +1718,7 @@ function logMainBranchGuardFailure(result) {
1526
1718
  /**
1527
1719
  * Run the full release pipeline.
1528
1720
  */
1529
- export async function release({ repoPath, level, notes, notesSource, dryRun, noPublish, skipProductCheck, skipStaleCheck, skipWorktreeCheck, skipTechDocsCheck, skipCoverageCheck, allowSubToolDrift }) {
1721
+ export async function release({ repoPath, level, notes, notesSource, dryRun, noPublish, skipProductCheck, skipStaleCheck, skipWorktreeCheck, skipTechDocsCheck, skipCoverageCheck, allowSubToolDrift, noDeployPublic }) {
1530
1722
  repoPath = repoPath || process.cwd();
1531
1723
  const currentVersion = detectCurrentVersion(repoPath);
1532
1724
  const newVersion = bumpSemver(currentVersion, level);
@@ -1898,12 +2090,14 @@ export async function release({ repoPath, level, notes, notesSource, dryRun, noP
1898
2090
  gitCommitAndTag(repoPath, newVersion, notes);
1899
2091
  console.log(` ✓ Committed and tagged v${newVersion}`);
1900
2092
 
1901
- // 5. Push commit + tag
1902
- try {
1903
- execSync('git push && git push --tags', { cwd: repoPath, stdio: 'pipe' });
1904
- console.log(` ✓ Pushed to remote`);
1905
- } catch {
1906
- console.log(` ! Push failed (maybe branch protection). Push manually.`);
2093
+ // 5. Push commit + tag (with auto-PR fallback on protected main, Phase 4)
2094
+ {
2095
+ const pushResult = pushReleaseWithAutoPr(repoPath, newVersion, level);
2096
+ if (pushResult.ok) {
2097
+ console.log(` Pushed to remote (${pushResult.via})`);
2098
+ } else {
2099
+ logPushFailure(pushResult, `v${newVersion}`);
2100
+ }
1907
2101
  }
1908
2102
 
1909
2103
  // Distribution results collector (#104)
@@ -2167,6 +2361,29 @@ export async function release({ repoPath, level, notes, notesSource, dryRun, noP
2167
2361
  }) + '\n');
2168
2362
  } catch {}
2169
2363
 
2364
+ // 12. deploy-public: sync private -> public mirror (Phase 6)
2365
+ // Runs at the end of every stable release unless --no-deploy-public is set
2366
+ // or the repo has no deploy-public.sh script. Skips silently for non-
2367
+ // -private repos (no public mirror to sync).
2368
+ {
2369
+ const dp = runDeployPublic(repoPath, { skip: noDeployPublic });
2370
+ if (dp.skipped) {
2371
+ if (dp.reason === 'flag') {
2372
+ console.log(` - deploy-public: skipped (--no-deploy-public)`);
2373
+ } else if (dp.reason === 'no-script') {
2374
+ console.log(` - deploy-public: skipped (no tools/deploy-public/deploy-public.sh)`);
2375
+ } else if (dp.reason === 'not-private-repo') {
2376
+ console.log(` - deploy-public: skipped (origin is not a -private repo)`);
2377
+ }
2378
+ } else if (dp.ok) {
2379
+ console.log(` \u2713 deploy-public: synced to ${dp.publicRepo}`);
2380
+ } else {
2381
+ console.log(` \u2717 deploy-public failed for ${dp.publicRepo}`);
2382
+ if (dp.detail) console.log(` ${dp.detail.split('\n')[0]}`);
2383
+ console.log(` Manual recovery: bash tools/deploy-public/deploy-public.sh ${repoPath} ${dp.publicRepo}`);
2384
+ }
2385
+ }
2386
+
2170
2387
  console.log('');
2171
2388
  console.log(` Done. ${repoName} v${newVersion} released.`);
2172
2389
  console.log('');
@@ -2185,7 +2402,7 @@ export async function release({ repoPath, level, notes, notesSource, dryRun, noP
2185
2402
  * No deploy-public. No code sync. No CHANGELOG gate. No product docs gate.
2186
2403
  * Lightweight: bump version, npm publish with tag, optional GitHub prerelease.
2187
2404
  */
2188
- export async function releasePrerelease({ repoPath, track, notes, dryRun, noPublish, publishReleaseNotes, skipWorktreeCheck, allowSubToolDrift }) {
2405
+ export async function releasePrerelease({ repoPath, track, notes, dryRun, noPublish, publishReleaseNotes, skipWorktreeCheck, allowSubToolDrift, noDeployPublic }) {
2189
2406
  repoPath = repoPath || process.cwd();
2190
2407
  const currentVersion = detectCurrentVersion(repoPath);
2191
2408
  const newVersion = bumpPrerelease(currentVersion, track);
@@ -2260,12 +2477,14 @@ export async function releasePrerelease({ repoPath, track, notes, dryRun, noPubl
2260
2477
  execFileSync('git', ['tag', `v${newVersion}`], { cwd: repoPath, stdio: 'pipe' });
2261
2478
  console.log(` \u2713 Committed and tagged v${newVersion}`);
2262
2479
 
2263
- // 4. Push commit + tag
2264
- try {
2265
- execSync('git push && git push --tags', { cwd: repoPath, stdio: 'pipe' });
2266
- console.log(` \u2713 Pushed to remote`);
2267
- } catch {
2268
- console.log(` ! Push failed. Push manually.`);
2480
+ // 4. Push commit + tag (with auto-PR fallback on protected main, Phase 4)
2481
+ {
2482
+ const pushResult = pushReleaseWithAutoPr(repoPath, newVersion, track);
2483
+ if (pushResult.ok) {
2484
+ console.log(` \u2713 Pushed to remote (${pushResult.via})`);
2485
+ } else {
2486
+ logPushFailure(pushResult, `v${newVersion}`);
2487
+ }
2269
2488
  }
2270
2489
 
2271
2490
  const distResults = [];
@@ -2307,6 +2526,30 @@ export async function releasePrerelease({ repoPath, track, notes, dryRun, noPubl
2307
2526
  }
2308
2527
  }
2309
2528
 
2529
+ // deploy-public: sync private -> public mirror (Phase 6)
2530
+ // Prerelease tracks (alpha/beta) also run deploy-public so the public
2531
+ // mirror stays current. Flagged in the bridge master plan and the
2532
+ // release-pipeline master plan: forgetting deploy-public left the public
2533
+ // repo stale across multiple days of alpha releases.
2534
+ {
2535
+ const dp = runDeployPublic(repoPath, { skip: noDeployPublic });
2536
+ if (dp.skipped) {
2537
+ if (dp.reason === 'flag') {
2538
+ console.log(` - deploy-public: skipped (--no-deploy-public)`);
2539
+ } else if (dp.reason === 'no-script') {
2540
+ console.log(` - deploy-public: skipped (no deploy-public.sh)`);
2541
+ } else if (dp.reason === 'not-private-repo') {
2542
+ console.log(` - deploy-public: skipped (origin is not a -private repo)`);
2543
+ }
2544
+ } else if (dp.ok) {
2545
+ console.log(` \u2713 deploy-public: synced to ${dp.publicRepo}`);
2546
+ } else {
2547
+ console.log(` \u2717 deploy-public failed for ${dp.publicRepo}`);
2548
+ if (dp.detail) console.log(` ${dp.detail.split('\n')[0]}`);
2549
+ console.log(` Manual recovery: bash tools/deploy-public/deploy-public.sh ${repoPath} ${dp.publicRepo}`);
2550
+ }
2551
+ }
2552
+
2310
2553
  console.log('');
2311
2554
  console.log(` Done. ${repoName} v${newVersion} (${track}) released.`);
2312
2555
  console.log('');
@@ -2470,12 +2713,14 @@ export async function releaseHotfix({ repoPath, notes, notesSource, dryRun, noPu
2470
2713
  gitCommitAndTag(repoPath, newVersion, notes);
2471
2714
  console.log(` \u2713 Committed and tagged v${newVersion}`);
2472
2715
 
2473
- // 5. Push commit + tag
2474
- try {
2475
- execSync('git push && git push --tags', { cwd: repoPath, stdio: 'pipe' });
2476
- console.log(` \u2713 Pushed to remote`);
2477
- } catch {
2478
- console.log(` ! Push failed. Push manually.`);
2716
+ // 5. Push commit + tag (with auto-PR fallback on protected main, Phase 4)
2717
+ {
2718
+ const pushResult = pushReleaseWithAutoPr(repoPath, newVersion, 'hotfix');
2719
+ if (pushResult.ok) {
2720
+ console.log(` \u2713 Pushed to remote (${pushResult.via})`);
2721
+ } else {
2722
+ logPushFailure(pushResult, `v${newVersion}`);
2723
+ }
2479
2724
  }
2480
2725
 
2481
2726
  const distResults = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wipcomputer/wip-release",
3
- "version": "1.9.72",
3
+ "version": "1.9.74",
4
4
  "type": "module",
5
5
  "description": "One-command release pipeline. Bumps version, updates changelog + SKILL.md, publishes to npm + GitHub.",
6
6
  "main": "core.mjs",