@wipcomputer/wip-release 1.9.74 → 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 (2) hide show
  1. package/core.mjs +266 -26
  2. package/package.json +1 -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)) {
@@ -1740,9 +1801,17 @@ export async function release({ repoPath, level, notes, notesSource, dryRun, noP
1740
1801
  }
1741
1802
  }
1742
1803
 
1743
- // 0. License compliance gate
1804
+ // 0. License compliance gate (MANDATORY: blocks release if .license-guard.json missing)
1744
1805
  const configPath = join(repoPath, '.license-guard.json');
1745
- 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
+ {
1746
1815
  const config = JSON.parse(readFileSync(configPath, 'utf8'));
1747
1816
  const licenseIssues = [];
1748
1817
 
@@ -1770,6 +1839,24 @@ export async function release({ repoPath, level, notes, notesSource, dryRun, noP
1770
1839
  if (config.license === 'MIT+AGPL' && !readme.includes('AGPL')) licenseIssues.push('README.md License section missing AGPL reference');
1771
1840
  }
1772
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
+
1773
1860
  if (licenseIssues.length > 0) {
1774
1861
  console.log(` ✗ License compliance failed:`);
1775
1862
  for (const issue of licenseIssues) console.log(` - ${issue}`);
@@ -2057,6 +2144,14 @@ export async function release({ repoPath, level, notes, notesSource, dryRun, noP
2057
2144
  writePackageVersion(repoPath, newVersion);
2058
2145
  console.log(` ✓ package.json -> ${newVersion}`);
2059
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
+
2060
2155
  // 1.5. Validate sub-tool version bumps (Phase 8: error by default)
2061
2156
  {
2062
2157
  const subToolResult = validateSubToolVersions(repoPath, allowSubToolDrift);
@@ -2086,35 +2181,76 @@ export async function release({ repoPath, level, notes, notesSource, dryRun, noP
2086
2181
  console.log(` ✓ Product docs synced to v${newVersion} (${docsUpdated} file(s))`);
2087
2182
  }
2088
2183
 
2089
- // 4. Git commit + tag
2090
- gitCommitAndTag(repoPath, newVersion, notes);
2091
- console.log(` ✓ Committed and tagged v${newVersion}`);
2092
-
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
- }
2101
- }
2102
-
2103
2184
  // Distribution results collector (#104)
2104
2185
  const distResults = [];
2105
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.
2106
2190
  if (!noPublish) {
2107
- // 6. npm publish
2108
2191
  try {
2109
2192
  publishNpm(repoPath);
2110
2193
  const pkg = JSON.parse(readFileSync(join(repoPath, 'package.json'), 'utf8'));
2111
2194
  distResults.push({ target: 'npm', status: 'ok', detail: `${pkg.name}@${newVersion}` });
2112
2195
  console.log(` ✓ Published to npm`);
2113
2196
  } catch (e) {
2114
- distResults.push({ target: 'npm', status: 'failed', detail: e.message });
2115
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 };
2116
2210
  }
2117
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) {
2118
2254
  // 7. GitHub Packages ... SKIPPED from private repos.
2119
2255
  // deploy-public.sh publishes to GitHub Packages from the public repo clone.
2120
2256
  // Publishing from private ties the package to the private repo, making it
@@ -2426,6 +2562,66 @@ export async function releasePrerelease({ repoPath, track, notes, dryRun, noPubl
2426
2562
  }
2427
2563
  }
2428
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
+
2429
2625
  if (dryRun) {
2430
2626
  console.log(` [dry run] Would bump package.json to ${newVersion}`);
2431
2627
  if (!noPublish) {
@@ -2454,6 +2650,14 @@ export async function releasePrerelease({ repoPath, track, notes, dryRun, noPubl
2454
2650
  writePackageVersion(repoPath, newVersion);
2455
2651
  console.log(` \u2713 package.json -> ${newVersion}`);
2456
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
+
2457
2661
  // 1.5. Validate sub-tool version bumps (Phase 8: error by default)
2458
2662
  {
2459
2663
  const subToolResult = validateSubToolVersions(repoPath, allowSubToolDrift);
@@ -2501,6 +2705,31 @@ export async function releasePrerelease({ repoPath, track, notes, dryRun, noPubl
2501
2705
  console.log(` \u2717 npm publish failed: ${e.message}`);
2502
2706
  }
2503
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
+
2504
2733
  // 6. GitHub prerelease on public repo (if opted in)
2505
2734
  if (publishReleaseNotes) {
2506
2735
  try {
@@ -2527,11 +2756,11 @@ export async function releasePrerelease({ repoPath, track, notes, dryRun, noPubl
2527
2756
  }
2528
2757
 
2529
2758
  // 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
- {
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 {
2535
2764
  const dp = runDeployPublic(repoPath, { skip: noDeployPublic });
2536
2765
  if (dp.skipped) {
2537
2766
  if (dp.reason === 'flag') {
@@ -2590,9 +2819,17 @@ export async function releaseHotfix({ repoPath, notes, notesSource, dryRun, noPu
2590
2819
  }
2591
2820
  }
2592
2821
 
2593
- // License compliance gate
2594
- const configPath = join(repoPath, '.license-guard.json');
2595
- 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
+ }
2596
2833
  const config = JSON.parse(readFileSync(configPath, 'utf8'));
2597
2834
  const licenseIssues = [];
2598
2835
  const licensePath = join(repoPath, 'LICENSE');
@@ -2604,6 +2841,9 @@ export async function releaseHotfix({ repoPath, notes, notesSource, dryRun, noPu
2604
2841
  licenseIssues.push(`LICENSE copyright does not match "${config.copyright}"`);
2605
2842
  }
2606
2843
  }
2844
+ if (!existsSync(join(repoPath, 'CLA.md'))) {
2845
+ licenseIssues.push('CLA.md is missing');
2846
+ }
2607
2847
  if (licenseIssues.length > 0) {
2608
2848
  console.log(` \u2717 License compliance failed:`);
2609
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.74",
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",