@wipcomputer/wip-release 1.9.73 → 1.9.75

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 +383 -23
  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
@@ -1408,6 +1408,67 @@ function enforceMainBranchGuard(repoPath, skipWorktreeCheck) {
1408
1408
  * Related: `ai/product/bugs/release-pipeline/2026-04-05--cc-mini--release-pipeline-master-plan.md`
1409
1409
  * Phase 8.
1410
1410
  */
1411
+ /**
1412
+ * Phase 5: Auto-bump sub-tool patch versions when their files changed since last tag.
1413
+ * Called before validateSubToolVersions so drift is fixed before validation runs.
1414
+ * Returns the number of sub-tools bumped.
1415
+ */
1416
+ function autoFixSubToolVersions(repoPath) {
1417
+ const toolsDir = join(repoPath, 'tools');
1418
+ if (!existsSync(toolsDir)) return 0;
1419
+ let lastTag = null;
1420
+ try {
1421
+ lastTag = execFileSync('git', ['describe', '--tags', '--abbrev=0'], {
1422
+ cwd: repoPath, encoding: 'utf8'
1423
+ }).trim();
1424
+ } catch {
1425
+ return 0;
1426
+ }
1427
+ if (!lastTag) return 0;
1428
+
1429
+ let bumped = 0;
1430
+ let entries;
1431
+ try {
1432
+ entries = readdirSync(toolsDir, { withFileTypes: true });
1433
+ } catch {
1434
+ return 0;
1435
+ }
1436
+ for (const entry of entries) {
1437
+ if (!entry.isDirectory()) continue;
1438
+ const subDir = `tools/${entry.name}`;
1439
+ const subPkgPath = join(toolsDir, entry.name, 'package.json');
1440
+ if (!existsSync(subPkgPath)) continue;
1441
+ try {
1442
+ const diff = execFileSync('git', ['diff', '--name-only', lastTag, 'HEAD', '--', subDir], {
1443
+ cwd: repoPath, encoding: 'utf8'
1444
+ }).trim();
1445
+ if (!diff) continue;
1446
+ const subPkg = JSON.parse(readFileSync(subPkgPath, 'utf8'));
1447
+ const currentSubVersion = subPkg.version;
1448
+ let oldSubVersion = null;
1449
+ try {
1450
+ oldSubVersion = JSON.parse(
1451
+ execFileSync('git', ['show', `${lastTag}:${subDir}/package.json`], {
1452
+ cwd: repoPath, encoding: 'utf8'
1453
+ })
1454
+ ).version;
1455
+ } catch {}
1456
+ if (currentSubVersion === oldSubVersion) {
1457
+ const parts = currentSubVersion.split('.');
1458
+ parts[2] = String(Number(parts[2]) + 1);
1459
+ const newSubVersion = parts.join('.');
1460
+ subPkg.version = newSubVersion;
1461
+ writeFileSync(subPkgPath, JSON.stringify(subPkg, null, 2) + '\n');
1462
+ console.log(` ✓ Auto-bumped ${entry.name}: ${currentSubVersion} -> ${newSubVersion}`);
1463
+ bumped++;
1464
+ }
1465
+ } catch (err) {
1466
+ console.log(` ! Auto-bump failed for ${entry.name}: ${err.message}`);
1467
+ }
1468
+ }
1469
+ return bumped;
1470
+ }
1471
+
1411
1472
  function validateSubToolVersions(repoPath, allowSubToolDrift) {
1412
1473
  const toolsDir = join(repoPath, 'tools');
1413
1474
  if (!existsSync(toolsDir)) {
@@ -1626,6 +1687,79 @@ function logPushFailure(result, tag) {
1626
1687
  }
1627
1688
  }
1628
1689
 
1690
+ /**
1691
+ * Resolve the public GitHub repo name for a private-to-public mirror setup.
1692
+ *
1693
+ * Strategy:
1694
+ * 1. Read `origin` remote URL (e.g. git@github.com:owner/repo-private.git)
1695
+ * 2. Strip `-private` suffix to get the public repo name
1696
+ * 3. Return `owner/repo` format suitable for gh CLI
1697
+ *
1698
+ * Returns null if the remote URL cannot be parsed or the repo name does not
1699
+ * end in `-private` (in which case the caller should skip deploy-public as a
1700
+ * no-op rather than error).
1701
+ */
1702
+ function resolvePublicRepoName(repoPath) {
1703
+ try {
1704
+ const remoteUrl = execFileSync('git', ['remote', 'get-url', 'origin'], {
1705
+ cwd: repoPath, encoding: 'utf8'
1706
+ }).trim();
1707
+ // Match owner/repo from either SSH (git@github.com:owner/repo.git) or HTTPS
1708
+ const match = remoteUrl.match(/github\.com[:/]([^/]+)\/(.+?)(\.git)?$/);
1709
+ if (!match) return null;
1710
+ const [, owner, repoName] = match;
1711
+ if (!repoName.endsWith('-private')) {
1712
+ return null;
1713
+ }
1714
+ return `${owner}/${repoName.replace(/-private$/, '')}`;
1715
+ } catch {
1716
+ return null;
1717
+ }
1718
+ }
1719
+
1720
+ /**
1721
+ * Run deploy-public.sh as the final step of the release pipeline.
1722
+ *
1723
+ * Previously deploy-public was a separate manual step. Every release that
1724
+ * forgot it left the public mirror stale, so `ldm install` pulled old code
1725
+ * from the public repo. Now wip-release invokes it automatically for stable
1726
+ * and prerelease tracks (hotfix still skips, per prior convention).
1727
+ *
1728
+ * Callable with `--no-deploy-public` to opt out.
1729
+ *
1730
+ * Skips silently if:
1731
+ * - The repo has no tools/deploy-public/deploy-public.sh (not a toolbox-
1732
+ * style repo, or deploy-public is provided by a parent install)
1733
+ * - The origin remote is not a -private repo (no public mirror to sync)
1734
+ *
1735
+ * Related: `ai/product/bugs/release-pipeline/2026-04-05--cc-mini--release-pipeline-master-plan.md` Phase 6.
1736
+ */
1737
+ function runDeployPublic(repoPath, { skip } = {}) {
1738
+ if (skip) {
1739
+ return { ok: true, skipped: true, reason: 'flag' };
1740
+ }
1741
+ const scriptPath = join(repoPath, 'tools', 'deploy-public', 'deploy-public.sh');
1742
+ if (!existsSync(scriptPath)) {
1743
+ return { ok: true, skipped: true, reason: 'no-script' };
1744
+ }
1745
+ const publicRepo = resolvePublicRepoName(repoPath);
1746
+ if (!publicRepo) {
1747
+ return { ok: true, skipped: true, reason: 'not-private-repo' };
1748
+ }
1749
+ try {
1750
+ execFileSync('bash', [scriptPath, repoPath, publicRepo], {
1751
+ cwd: repoPath, stdio: 'inherit',
1752
+ });
1753
+ return { ok: true, publicRepo };
1754
+ } catch (err) {
1755
+ return {
1756
+ ok: false,
1757
+ detail: String(err?.stderr ?? err?.message ?? err),
1758
+ publicRepo,
1759
+ };
1760
+ }
1761
+ }
1762
+
1629
1763
  function logMainBranchGuardFailure(result) {
1630
1764
  if (result.reason === 'linked-worktree') {
1631
1765
  console.log(` \u2717 wip-release must run from the main working tree, not a worktree.`);
@@ -1645,7 +1779,7 @@ function logMainBranchGuardFailure(result) {
1645
1779
  /**
1646
1780
  * Run the full release pipeline.
1647
1781
  */
1648
- export async function release({ repoPath, level, notes, notesSource, dryRun, noPublish, skipProductCheck, skipStaleCheck, skipWorktreeCheck, skipTechDocsCheck, skipCoverageCheck, allowSubToolDrift }) {
1782
+ export async function release({ repoPath, level, notes, notesSource, dryRun, noPublish, skipProductCheck, skipStaleCheck, skipWorktreeCheck, skipTechDocsCheck, skipCoverageCheck, allowSubToolDrift, noDeployPublic }) {
1649
1783
  repoPath = repoPath || process.cwd();
1650
1784
  const currentVersion = detectCurrentVersion(repoPath);
1651
1785
  const newVersion = bumpSemver(currentVersion, level);
@@ -1667,9 +1801,17 @@ export async function release({ repoPath, level, notes, notesSource, dryRun, noP
1667
1801
  }
1668
1802
  }
1669
1803
 
1670
- // 0. License compliance gate
1804
+ // 0. License compliance gate (MANDATORY: blocks release if .license-guard.json missing)
1671
1805
  const configPath = join(repoPath, '.license-guard.json');
1672
- if (existsSync(configPath)) {
1806
+ if (!existsSync(configPath)) {
1807
+ console.log(` ✗ .license-guard.json not found.`);
1808
+ console.log(` Every repo must have .license-guard.json to release.`);
1809
+ console.log(` Run: wip-repo-init (scaffolds ai/, .license-guard.json, CLA.md)`);
1810
+ console.log(` Or: wip-license-guard init`);
1811
+ console.log('');
1812
+ return { currentVersion, newVersion, dryRun: false, failed: true };
1813
+ }
1814
+ {
1673
1815
  const config = JSON.parse(readFileSync(configPath, 'utf8'));
1674
1816
  const licenseIssues = [];
1675
1817
 
@@ -1697,6 +1839,24 @@ export async function release({ repoPath, level, notes, notesSource, dryRun, noP
1697
1839
  if (config.license === 'MIT+AGPL' && !readme.includes('AGPL')) licenseIssues.push('README.md License section missing AGPL reference');
1698
1840
  }
1699
1841
 
1842
+ // .npmignore must exclude ai/ if repo has ai/ directory
1843
+ const aiDir = join(repoPath, 'ai');
1844
+ if (existsSync(aiDir)) {
1845
+ const npmignorePath = join(repoPath, '.npmignore');
1846
+ const pkgJson = JSON.parse(readFileSync(join(repoPath, 'package.json'), 'utf8'));
1847
+ const hasFilesWhitelist = Array.isArray(pkgJson.files);
1848
+ if (!hasFilesWhitelist) {
1849
+ if (!existsSync(npmignorePath)) {
1850
+ licenseIssues.push('.npmignore is missing (ai/ directory exists and could leak to npm)');
1851
+ } else {
1852
+ const npmignore = readFileSync(npmignorePath, 'utf8');
1853
+ if (!npmignore.includes('ai/')) {
1854
+ licenseIssues.push('.npmignore does not exclude ai/ (plans and bugs could leak to npm)');
1855
+ }
1856
+ }
1857
+ }
1858
+ }
1859
+
1700
1860
  if (licenseIssues.length > 0) {
1701
1861
  console.log(` ✗ License compliance failed:`);
1702
1862
  for (const issue of licenseIssues) console.log(` - ${issue}`);
@@ -1984,6 +2144,14 @@ export async function release({ repoPath, level, notes, notesSource, dryRun, noP
1984
2144
  writePackageVersion(repoPath, newVersion);
1985
2145
  console.log(` ✓ package.json -> ${newVersion}`);
1986
2146
 
2147
+ // 1.25. Auto-bump sub-tool versions (Phase 5: auto-fix before validation)
2148
+ {
2149
+ const autoBumped = autoFixSubToolVersions(repoPath);
2150
+ if (autoBumped > 0) {
2151
+ console.log(` ✓ Auto-bumped ${autoBumped} sub-tool(s)`);
2152
+ }
2153
+ }
2154
+
1987
2155
  // 1.5. Validate sub-tool version bumps (Phase 8: error by default)
1988
2156
  {
1989
2157
  const subToolResult = validateSubToolVersions(repoPath, allowSubToolDrift);
@@ -2013,35 +2181,76 @@ export async function release({ repoPath, level, notes, notesSource, dryRun, noP
2013
2181
  console.log(` ✓ Product docs synced to v${newVersion} (${docsUpdated} file(s))`);
2014
2182
  }
2015
2183
 
2016
- // 4. Git commit + tag
2017
- gitCommitAndTag(repoPath, newVersion, notes);
2018
- console.log(` ✓ Committed and tagged v${newVersion}`);
2019
-
2020
- // 5. Push commit + tag (with auto-PR fallback on protected main, Phase 4)
2021
- {
2022
- const pushResult = pushReleaseWithAutoPr(repoPath, newVersion, level);
2023
- if (pushResult.ok) {
2024
- console.log(` ✓ Pushed to remote (${pushResult.via})`);
2025
- } else {
2026
- logPushFailure(pushResult, `v${newVersion}`);
2027
- }
2028
- }
2029
-
2030
2184
  // Distribution results collector (#104)
2031
2185
  const distResults = [];
2032
2186
 
2187
+ // 4. npm publish BEFORE commit (Phase 3: true publish-before-commit)
2188
+ // Files are bumped and staged but NOT committed. If npm fails, we just
2189
+ // revert the file changes. No commit, no tag, no remote state to clean up.
2033
2190
  if (!noPublish) {
2034
- // 6. npm publish
2035
2191
  try {
2036
2192
  publishNpm(repoPath);
2037
2193
  const pkg = JSON.parse(readFileSync(join(repoPath, 'package.json'), 'utf8'));
2038
2194
  distResults.push({ target: 'npm', status: 'ok', detail: `${pkg.name}@${newVersion}` });
2039
2195
  console.log(` ✓ Published to npm`);
2040
2196
  } catch (e) {
2041
- distResults.push({ target: 'npm', status: 'failed', detail: e.message });
2042
2197
  console.log(` ✗ npm publish failed: ${e.message}`);
2198
+ console.log(` Reverting file changes (no commit was made)...`);
2199
+ try {
2200
+ execSync('git checkout -- .', { cwd: repoPath, stdio: 'pipe' });
2201
+ // Clean up any new files (like trashed release notes)
2202
+ execSync('git clean -fd _trash/', { cwd: repoPath, stdio: 'pipe' });
2203
+ console.log(` ✓ Reverted. Working tree is clean. Fix the issue and try again.`);
2204
+ } catch (revertErr) {
2205
+ console.log(` ✗ Revert failed: ${revertErr.message}`);
2206
+ console.log(` Manual cleanup: git checkout -- .`);
2207
+ }
2208
+ console.log('');
2209
+ return { currentVersion, newVersion, dryRun: false, failed: true };
2043
2210
  }
2044
2211
 
2212
+ // Phase 5: Auto-publish changed sub-tools to npm
2213
+ const toolsDir = join(repoPath, 'tools');
2214
+ if (existsSync(toolsDir)) {
2215
+ for (const tool of readdirSync(toolsDir)) {
2216
+ const toolPath = join(toolsDir, tool);
2217
+ const toolPkg = join(toolPath, 'package.json');
2218
+ if (!existsSync(toolPkg)) continue;
2219
+ const pkg = JSON.parse(readFileSync(toolPkg, 'utf8'));
2220
+ if (!pkg.name || pkg.private) continue;
2221
+ try {
2222
+ publishNpm(toolPath);
2223
+ distResults.push({ target: 'npm', status: 'ok', detail: `${pkg.name}@${pkg.version}` });
2224
+ console.log(` ✓ Published sub-tool: ${pkg.name}@${pkg.version}`);
2225
+ } catch (e) {
2226
+ // Sub-tool publish failure is non-fatal but loud (Phase 7)
2227
+ const msg = e.message || '';
2228
+ if (msg.includes('previously published') || msg.includes('cannot publish over')) {
2229
+ // Already published at this version. Not an error.
2230
+ } else {
2231
+ distResults.push({ target: 'npm', status: 'failed', detail: `${pkg.name}: ${msg}` });
2232
+ console.log(` ✗ Sub-tool ${pkg.name} publish failed: ${msg}`);
2233
+ }
2234
+ }
2235
+ }
2236
+ }
2237
+ }
2238
+
2239
+ // 5. Git commit + tag (AFTER npm publish succeeds)
2240
+ gitCommitAndTag(repoPath, newVersion, notes);
2241
+ console.log(` ✓ Committed and tagged v${newVersion}`);
2242
+
2243
+ // 5.5. Push commit + tag
2244
+ {
2245
+ const pushResult = pushReleaseWithAutoPr(repoPath, newVersion, level);
2246
+ if (pushResult.ok) {
2247
+ console.log(` ✓ Pushed to remote (${pushResult.via})`);
2248
+ } else {
2249
+ logPushFailure(pushResult, `v${newVersion}`);
2250
+ }
2251
+ }
2252
+
2253
+ if (!noPublish) {
2045
2254
  // 7. GitHub Packages ... SKIPPED from private repos.
2046
2255
  // deploy-public.sh publishes to GitHub Packages from the public repo clone.
2047
2256
  // Publishing from private ties the package to the private repo, making it
@@ -2288,6 +2497,29 @@ export async function release({ repoPath, level, notes, notesSource, dryRun, noP
2288
2497
  }) + '\n');
2289
2498
  } catch {}
2290
2499
 
2500
+ // 12. deploy-public: sync private -> public mirror (Phase 6)
2501
+ // Runs at the end of every stable release unless --no-deploy-public is set
2502
+ // or the repo has no deploy-public.sh script. Skips silently for non-
2503
+ // -private repos (no public mirror to sync).
2504
+ {
2505
+ const dp = runDeployPublic(repoPath, { skip: noDeployPublic });
2506
+ if (dp.skipped) {
2507
+ if (dp.reason === 'flag') {
2508
+ console.log(` - deploy-public: skipped (--no-deploy-public)`);
2509
+ } else if (dp.reason === 'no-script') {
2510
+ console.log(` - deploy-public: skipped (no tools/deploy-public/deploy-public.sh)`);
2511
+ } else if (dp.reason === 'not-private-repo') {
2512
+ console.log(` - deploy-public: skipped (origin is not a -private repo)`);
2513
+ }
2514
+ } else if (dp.ok) {
2515
+ console.log(` \u2713 deploy-public: synced to ${dp.publicRepo}`);
2516
+ } else {
2517
+ console.log(` \u2717 deploy-public failed for ${dp.publicRepo}`);
2518
+ if (dp.detail) console.log(` ${dp.detail.split('\n')[0]}`);
2519
+ console.log(` Manual recovery: bash tools/deploy-public/deploy-public.sh ${repoPath} ${dp.publicRepo}`);
2520
+ }
2521
+ }
2522
+
2291
2523
  console.log('');
2292
2524
  console.log(` Done. ${repoName} v${newVersion} released.`);
2293
2525
  console.log('');
@@ -2306,7 +2538,7 @@ export async function release({ repoPath, level, notes, notesSource, dryRun, noP
2306
2538
  * No deploy-public. No code sync. No CHANGELOG gate. No product docs gate.
2307
2539
  * Lightweight: bump version, npm publish with tag, optional GitHub prerelease.
2308
2540
  */
2309
- export async function releasePrerelease({ repoPath, track, notes, dryRun, noPublish, publishReleaseNotes, skipWorktreeCheck, allowSubToolDrift }) {
2541
+ export async function releasePrerelease({ repoPath, track, notes, dryRun, noPublish, publishReleaseNotes, skipWorktreeCheck, allowSubToolDrift, noDeployPublic }) {
2310
2542
  repoPath = repoPath || process.cwd();
2311
2543
  const currentVersion = detectCurrentVersion(repoPath);
2312
2544
  const newVersion = bumpPrerelease(currentVersion, track);
@@ -2330,6 +2562,66 @@ export async function releasePrerelease({ repoPath, track, notes, dryRun, noPubl
2330
2562
  }
2331
2563
  }
2332
2564
 
2565
+ // License compliance gate (MANDATORY: blocks release if .license-guard.json missing)
2566
+ {
2567
+ const configPath = join(repoPath, '.license-guard.json');
2568
+ if (!existsSync(configPath)) {
2569
+ console.log(` \u2717 .license-guard.json not found.`);
2570
+ console.log(` Every repo must have .license-guard.json to release.`);
2571
+ console.log(` Run: wip-repo-init (scaffolds ai/, .license-guard.json, CLA.md)`);
2572
+ console.log(` Or: wip-license-guard init`);
2573
+ console.log('');
2574
+ return { currentVersion, newVersion, dryRun: false, failed: true };
2575
+ }
2576
+ const config = JSON.parse(readFileSync(configPath, 'utf8'));
2577
+ const licenseIssues = [];
2578
+ const licensePath = join(repoPath, 'LICENSE');
2579
+ if (!existsSync(licensePath)) {
2580
+ licenseIssues.push('LICENSE file is missing');
2581
+ } else {
2582
+ const licenseText = readFileSync(licensePath, 'utf8');
2583
+ if (!licenseText.includes(config.copyright)) {
2584
+ licenseIssues.push(`LICENSE copyright does not match "${config.copyright}"`);
2585
+ }
2586
+ if (config.license === 'MIT+AGPL' && !licenseText.includes('AGPL') && !licenseText.includes('GNU Affero')) {
2587
+ licenseIssues.push('LICENSE is MIT-only but config requires MIT+AGPL');
2588
+ }
2589
+ }
2590
+ if (!existsSync(join(repoPath, 'CLA.md'))) {
2591
+ licenseIssues.push('CLA.md is missing');
2592
+ }
2593
+ const readmePath = join(repoPath, 'README.md');
2594
+ if (existsSync(readmePath)) {
2595
+ const readme = readFileSync(readmePath, 'utf8');
2596
+ if (!readme.includes('## License')) licenseIssues.push('README.md missing ## License section');
2597
+ if (config.license === 'MIT+AGPL' && !readme.includes('AGPL')) licenseIssues.push('README.md License section missing AGPL reference');
2598
+ }
2599
+ const aiDir = join(repoPath, 'ai');
2600
+ if (existsSync(aiDir)) {
2601
+ const npmignorePath = join(repoPath, '.npmignore');
2602
+ const pkgJson = JSON.parse(readFileSync(join(repoPath, 'package.json'), 'utf8'));
2603
+ const hasFilesWhitelist = Array.isArray(pkgJson.files);
2604
+ if (!hasFilesWhitelist) {
2605
+ if (!existsSync(npmignorePath)) {
2606
+ licenseIssues.push('.npmignore is missing (ai/ directory exists and could leak to npm)');
2607
+ } else {
2608
+ const npmignore = readFileSync(npmignorePath, 'utf8');
2609
+ if (!npmignore.includes('ai/')) {
2610
+ licenseIssues.push('.npmignore does not exclude ai/ (plans and bugs could leak to npm)');
2611
+ }
2612
+ }
2613
+ }
2614
+ }
2615
+ if (licenseIssues.length > 0) {
2616
+ console.log(` \u2717 License compliance failed:`);
2617
+ for (const issue of licenseIssues) console.log(` - ${issue}`);
2618
+ console.log(`\n Run \`wip-license-guard check --fix\` to auto-repair, then try again.`);
2619
+ console.log('');
2620
+ return { currentVersion, newVersion, dryRun: false, failed: true };
2621
+ }
2622
+ console.log(` \u2713 License compliance passed`);
2623
+ }
2624
+
2333
2625
  if (dryRun) {
2334
2626
  console.log(` [dry run] Would bump package.json to ${newVersion}`);
2335
2627
  if (!noPublish) {
@@ -2358,6 +2650,14 @@ export async function releasePrerelease({ repoPath, track, notes, dryRun, noPubl
2358
2650
  writePackageVersion(repoPath, newVersion);
2359
2651
  console.log(` \u2713 package.json -> ${newVersion}`);
2360
2652
 
2653
+ // 1.25. Auto-bump sub-tool versions (Phase 5)
2654
+ {
2655
+ const autoBumped = autoFixSubToolVersions(repoPath);
2656
+ if (autoBumped > 0) {
2657
+ console.log(` \u2713 Auto-bumped ${autoBumped} sub-tool(s)`);
2658
+ }
2659
+ }
2660
+
2361
2661
  // 1.5. Validate sub-tool version bumps (Phase 8: error by default)
2362
2662
  {
2363
2663
  const subToolResult = validateSubToolVersions(repoPath, allowSubToolDrift);
@@ -2405,6 +2705,31 @@ export async function releasePrerelease({ repoPath, track, notes, dryRun, noPubl
2405
2705
  console.log(` \u2717 npm publish failed: ${e.message}`);
2406
2706
  }
2407
2707
 
2708
+ // 5.5. Auto-publish changed sub-tools to npm (Phase 5)
2709
+ const toolsDir = join(repoPath, 'tools');
2710
+ if (existsSync(toolsDir)) {
2711
+ for (const tool of readdirSync(toolsDir)) {
2712
+ const toolPath = join(toolsDir, tool);
2713
+ const toolPkg = join(toolPath, 'package.json');
2714
+ if (!existsSync(toolPkg)) continue;
2715
+ const pkg = JSON.parse(readFileSync(toolPkg, 'utf8'));
2716
+ if (!pkg.name || pkg.private) continue;
2717
+ try {
2718
+ publishNpm(toolPath);
2719
+ distResults.push({ target: 'npm', status: 'ok', detail: `${pkg.name}@${pkg.version}` });
2720
+ console.log(` \u2713 Published sub-tool: ${pkg.name}@${pkg.version}`);
2721
+ } catch (e) {
2722
+ const msg = e.message || '';
2723
+ if (msg.includes('previously published') || msg.includes('cannot publish over')) {
2724
+ // Already published at this version. Not an error.
2725
+ } else {
2726
+ distResults.push({ target: 'npm', status: 'failed', detail: `${pkg.name}: ${msg}` });
2727
+ console.log(` \u2717 Sub-tool ${pkg.name} publish failed: ${msg}`);
2728
+ }
2729
+ }
2730
+ }
2731
+ }
2732
+
2408
2733
  // 6. GitHub prerelease on public repo (if opted in)
2409
2734
  if (publishReleaseNotes) {
2410
2735
  try {
@@ -2430,6 +2755,30 @@ export async function releasePrerelease({ repoPath, track, notes, dryRun, noPubl
2430
2755
  }
2431
2756
  }
2432
2757
 
2758
+ // deploy-public: sync private -> public mirror (Phase 6)
2759
+ // Beta: deploy to public (testing distribution pipeline)
2760
+ // Alpha: NEVER deploy to public (dev-only)
2761
+ if (track === 'alpha') {
2762
+ console.log(` - deploy-public: skipped (alpha is dev-only, never goes to public)`);
2763
+ } else {
2764
+ const dp = runDeployPublic(repoPath, { skip: noDeployPublic });
2765
+ if (dp.skipped) {
2766
+ if (dp.reason === 'flag') {
2767
+ console.log(` - deploy-public: skipped (--no-deploy-public)`);
2768
+ } else if (dp.reason === 'no-script') {
2769
+ console.log(` - deploy-public: skipped (no deploy-public.sh)`);
2770
+ } else if (dp.reason === 'not-private-repo') {
2771
+ console.log(` - deploy-public: skipped (origin is not a -private repo)`);
2772
+ }
2773
+ } else if (dp.ok) {
2774
+ console.log(` \u2713 deploy-public: synced to ${dp.publicRepo}`);
2775
+ } else {
2776
+ console.log(` \u2717 deploy-public failed for ${dp.publicRepo}`);
2777
+ if (dp.detail) console.log(` ${dp.detail.split('\n')[0]}`);
2778
+ console.log(` Manual recovery: bash tools/deploy-public/deploy-public.sh ${repoPath} ${dp.publicRepo}`);
2779
+ }
2780
+ }
2781
+
2433
2782
  console.log('');
2434
2783
  console.log(` Done. ${repoName} v${newVersion} (${track}) released.`);
2435
2784
  console.log('');
@@ -2470,9 +2819,17 @@ export async function releaseHotfix({ repoPath, notes, notesSource, dryRun, noPu
2470
2819
  }
2471
2820
  }
2472
2821
 
2473
- // License compliance gate
2474
- const configPath = join(repoPath, '.license-guard.json');
2475
- if (existsSync(configPath)) {
2822
+ // License compliance gate (MANDATORY: blocks release if .license-guard.json missing)
2823
+ {
2824
+ const configPath = join(repoPath, '.license-guard.json');
2825
+ if (!existsSync(configPath)) {
2826
+ console.log(` \u2717 .license-guard.json not found.`);
2827
+ console.log(` Every repo must have .license-guard.json to release.`);
2828
+ console.log(` Run: wip-repo-init (scaffolds ai/, .license-guard.json, CLA.md)`);
2829
+ console.log(` Or: wip-license-guard init`);
2830
+ console.log('');
2831
+ return { currentVersion, newVersion, dryRun: false, failed: true };
2832
+ }
2476
2833
  const config = JSON.parse(readFileSync(configPath, 'utf8'));
2477
2834
  const licenseIssues = [];
2478
2835
  const licensePath = join(repoPath, 'LICENSE');
@@ -2484,6 +2841,9 @@ export async function releaseHotfix({ repoPath, notes, notesSource, dryRun, noPu
2484
2841
  licenseIssues.push(`LICENSE copyright does not match "${config.copyright}"`);
2485
2842
  }
2486
2843
  }
2844
+ if (!existsSync(join(repoPath, 'CLA.md'))) {
2845
+ licenseIssues.push('CLA.md is missing');
2846
+ }
2487
2847
  if (licenseIssues.length > 0) {
2488
2848
  console.log(` \u2717 License compliance failed:`);
2489
2849
  for (const issue of licenseIssues) console.log(` - ${issue}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wipcomputer/wip-release",
3
- "version": "1.9.73",
3
+ "version": "1.9.75",
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",