@wipcomputer/wip-ai-devops-toolbox 1.9.71-alpha.6 → 1.9.71-alpha.7

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/CHANGELOG.md CHANGED
@@ -1,5 +1,68 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.9.71-alpha.7 (2026-04-05)
4
+
5
+ # v1.9.71-alpha.7
6
+
7
+ ## wip-release: three hardening fixes for the release pipeline
8
+
9
+ Ships three related wip-release fixes in one release, each targeting a release-pipeline master-plan phase. See `ai/product/bugs/release-pipeline/2026-04-05--cc-mini--release-pipeline-master-plan.md` for the full context (7 incidents we hit today while trying to ship a single guard fix, 8 phases of forward work).
10
+
11
+ ### Phase 1: refuse non-main invocations (was Incident 1)
12
+
13
+ Earlier today `wip-release alpha` ran from a feature worktree because `releasePrerelease()` had no worktree check at all (only `release()` and `releaseHotfix()` did). The result was a botched release commit on the worktree branch, never pushed to main, plus a cascade of downstream pipeline failures.
14
+
15
+ **Fix.** Extract a shared `enforceMainBranchGuard(repoPath, skipWorktreeCheck)` helper. Call it from all three release functions (`release`, `releaseHotfix`, `releasePrerelease`). The helper enforces two independent conditions:
16
+
17
+ 1. **Linked worktree check.** If `git rev-parse --git-dir` resolves under `.git/worktrees/`, refuse with a ready-to-paste `cd <main-tree>` recovery command.
18
+ 2. **Current branch check.** Even from the main working tree, `git branch --show-current` must be `main` or `master`. Refuse with `git checkout main && git pull && wip-release <track>` recovery command.
19
+
20
+ Both conditions bypassable via `--skip-worktree-check` for break-glass scenarios.
21
+
22
+ ### Phase 2: tag collision pre-flight (was Incident 2)
23
+
24
+ Earlier today the pipeline also failed mid-release because `v1.9.71-alpha.4` and `v1.9.71-alpha.5` existed as local-only tags from prior failed releases. `wip-release alpha` tried to bump to alpha.5, hit the existing tag, and aborted. The release tool had no recovery path.
25
+
26
+ **Fix.** New `checkTagCollision(repoPath, newVersion)` helper runs after the main-branch guard, before the version bump. It distinguishes two cases:
27
+
28
+ 1. **Tag exists on origin remote.** Legitimate prior release; refuses with a clear message.
29
+ 2. **Tag exists locally but NOT on origin.** Stale leftover from a failed release; refuses but prints the safe recovery command: `git tag -d <tag> && wip-release <track>`.
30
+
31
+ Both cases log a clear error before any state mutation.
32
+
33
+ ### Phase 8: sub-tool version drift becomes an error (was Incident 8)
34
+
35
+ Previously, if `tools/<sub-tool>/` files changed since the last git tag but `tools/<sub-tool>/package.json` version did not bump, `wip-release` printed a WARNING and proceeded. This silently shipped at least one "committed but never deployed" bug today: the guard fix had new code in `tools/wip-branch-guard/guard.mjs` but the same version, so `ldm install` ignored the sub-tool on redeploy.
36
+
37
+ **Fix.** New `validateSubToolVersions(repoPath, allowSubToolDrift)` helper replaces the three in-line duplicated drift checks in `release`, `releaseHotfix`, and `releasePrerelease`. Sub-tool drift without a version bump is now a hard refusal unless the caller passes `--allow-sub-tool-drift`.
38
+
39
+ ## New CLI flags
40
+
41
+ - `--allow-sub-tool-drift` — Allow release even if a sub-tool's files changed since the last tag without a version bump. Default behavior is to refuse.
42
+
43
+ ## Files changed
44
+
45
+ - `tools/wip-release/core.mjs`: new `enforceMainBranchGuard`, `logMainBranchGuardFailure`, `checkTagCollision`, `validateSubToolVersions` helpers. Inline checks in `release`, `releaseHotfix`, `releasePrerelease` replaced with calls to the helpers. `allowSubToolDrift` threaded through all three signatures.
46
+ - `tools/wip-release/cli.js`: parses `--allow-sub-tool-drift`, passes it to all three release functions. `skipWorktreeCheck` now also passed to `releasePrerelease` (was missing). Help text updated.
47
+ - `tools/wip-release/package.json`: version bump to 1.9.72.
48
+ - `CHANGELOG.md`: entry added.
49
+
50
+ ## Verified
51
+
52
+ - From a feature worktree: `wip-release alpha --dry-run` refuses with concrete `cd <main-tree>` recovery command. Same for `patch` and `hotfix`.
53
+ - `--skip-worktree-check` bypass works.
54
+ - Module imports cleanly via `node -e "import('./tools/wip-release/core.mjs')"`.
55
+
56
+ ## Known limitation (follow-up)
57
+
58
+ The tag collision and sub-tool drift checks run in live release mode, not in dry-run preview. Dry-run still shows "would bump" for a version that would actually fail later. Follow-up: move both checks before the dry-run short-circuit so preview is a faithful preflight. Tracked in the release-pipeline master plan as a small cleanup.
59
+
60
+ ## Cross-references
61
+
62
+ - `ai/product/bugs/release-pipeline/2026-04-05--cc-mini--release-pipeline-master-plan.md` Phases 1, 2, 8
63
+ - `ai/product/bugs/guard/2026-04-05--cc-mini--guard-master-plan.md` Phases 3, 4 (partial, not all covered here; auto-publish sub-tool remains deferred to a follow-up PR)
64
+ - `ai/product/bugs/master-plans/bugs-plan-04-05-2026-002.md` Wave 2 phases 4, 5, 11
65
+
3
66
  ## 1.9.71-alpha.6 (2026-04-05)
4
67
 
5
68
  Guard 1.9.72: allow git stash push on main to unblock native untracked-file escape hatch
@@ -0,0 +1,60 @@
1
+ # v1.9.71-alpha.7
2
+
3
+ ## wip-release: three hardening fixes for the release pipeline
4
+
5
+ Ships three related wip-release fixes in one release, each targeting a release-pipeline master-plan phase. See `ai/product/bugs/release-pipeline/2026-04-05--cc-mini--release-pipeline-master-plan.md` for the full context (7 incidents we hit today while trying to ship a single guard fix, 8 phases of forward work).
6
+
7
+ ### Phase 1: refuse non-main invocations (was Incident 1)
8
+
9
+ Earlier today `wip-release alpha` ran from a feature worktree because `releasePrerelease()` had no worktree check at all (only `release()` and `releaseHotfix()` did). The result was a botched release commit on the worktree branch, never pushed to main, plus a cascade of downstream pipeline failures.
10
+
11
+ **Fix.** Extract a shared `enforceMainBranchGuard(repoPath, skipWorktreeCheck)` helper. Call it from all three release functions (`release`, `releaseHotfix`, `releasePrerelease`). The helper enforces two independent conditions:
12
+
13
+ 1. **Linked worktree check.** If `git rev-parse --git-dir` resolves under `.git/worktrees/`, refuse with a ready-to-paste `cd <main-tree>` recovery command.
14
+ 2. **Current branch check.** Even from the main working tree, `git branch --show-current` must be `main` or `master`. Refuse with `git checkout main && git pull && wip-release <track>` recovery command.
15
+
16
+ Both conditions bypassable via `--skip-worktree-check` for break-glass scenarios.
17
+
18
+ ### Phase 2: tag collision pre-flight (was Incident 2)
19
+
20
+ Earlier today the pipeline also failed mid-release because `v1.9.71-alpha.4` and `v1.9.71-alpha.5` existed as local-only tags from prior failed releases. `wip-release alpha` tried to bump to alpha.5, hit the existing tag, and aborted. The release tool had no recovery path.
21
+
22
+ **Fix.** New `checkTagCollision(repoPath, newVersion)` helper runs after the main-branch guard, before the version bump. It distinguishes two cases:
23
+
24
+ 1. **Tag exists on origin remote.** Legitimate prior release; refuses with a clear message.
25
+ 2. **Tag exists locally but NOT on origin.** Stale leftover from a failed release; refuses but prints the safe recovery command: `git tag -d <tag> && wip-release <track>`.
26
+
27
+ Both cases log a clear error before any state mutation.
28
+
29
+ ### Phase 8: sub-tool version drift becomes an error (was Incident 8)
30
+
31
+ Previously, if `tools/<sub-tool>/` files changed since the last git tag but `tools/<sub-tool>/package.json` version did not bump, `wip-release` printed a WARNING and proceeded. This silently shipped at least one "committed but never deployed" bug today: the guard fix had new code in `tools/wip-branch-guard/guard.mjs` but the same version, so `ldm install` ignored the sub-tool on redeploy.
32
+
33
+ **Fix.** New `validateSubToolVersions(repoPath, allowSubToolDrift)` helper replaces the three in-line duplicated drift checks in `release`, `releaseHotfix`, and `releasePrerelease`. Sub-tool drift without a version bump is now a hard refusal unless the caller passes `--allow-sub-tool-drift`.
34
+
35
+ ## New CLI flags
36
+
37
+ - `--allow-sub-tool-drift` — Allow release even if a sub-tool's files changed since the last tag without a version bump. Default behavior is to refuse.
38
+
39
+ ## Files changed
40
+
41
+ - `tools/wip-release/core.mjs`: new `enforceMainBranchGuard`, `logMainBranchGuardFailure`, `checkTagCollision`, `validateSubToolVersions` helpers. Inline checks in `release`, `releaseHotfix`, `releasePrerelease` replaced with calls to the helpers. `allowSubToolDrift` threaded through all three signatures.
42
+ - `tools/wip-release/cli.js`: parses `--allow-sub-tool-drift`, passes it to all three release functions. `skipWorktreeCheck` now also passed to `releasePrerelease` (was missing). Help text updated.
43
+ - `tools/wip-release/package.json`: version bump to 1.9.72.
44
+ - `CHANGELOG.md`: entry added.
45
+
46
+ ## Verified
47
+
48
+ - From a feature worktree: `wip-release alpha --dry-run` refuses with concrete `cd <main-tree>` recovery command. Same for `patch` and `hotfix`.
49
+ - `--skip-worktree-check` bypass works.
50
+ - Module imports cleanly via `node -e "import('./tools/wip-release/core.mjs')"`.
51
+
52
+ ## Known limitation (follow-up)
53
+
54
+ The tag collision and sub-tool drift checks run in live release mode, not in dry-run preview. Dry-run still shows "would bump" for a version that would actually fail later. Follow-up: move both checks before the dry-run short-circuit so preview is a faithful preflight. Tracked in the release-pipeline master plan as a small cleanup.
55
+
56
+ ## Cross-references
57
+
58
+ - `ai/product/bugs/release-pipeline/2026-04-05--cc-mini--release-pipeline-master-plan.md` Phases 1, 2, 8
59
+ - `ai/product/bugs/guard/2026-04-05--cc-mini--guard-master-plan.md` Phases 3, 4 (partial, not all covered here; auto-publish sub-tool remains deferred to a follow-up PR)
60
+ - `ai/product/bugs/master-plans/bugs-plan-04-05-2026-002.md` Wave 2 phases 4, 5, 11
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wipcomputer/wip-ai-devops-toolbox",
3
- "version": "1.9.71-alpha.6",
3
+ "version": "1.9.71-alpha.7",
4
4
  "type": "module",
5
5
  "description": "The complete AI DevOps toolkit for AI-assisted development teams.",
6
6
  "license": "MIT",
@@ -23,6 +23,7 @@ const skipStaleCheck = args.includes('--skip-stale-check');
23
23
  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
+ const allowSubToolDrift = args.includes('--allow-sub-tool-drift');
26
27
  const wantReleaseNotes = args.includes('--release-notes');
27
28
  const noReleaseNotes = args.includes('--no-release-notes');
28
29
  const notesFilePath = flag('notes-file');
@@ -171,7 +172,8 @@ Flags:
171
172
  --no-publish Bump + tag only, skip npm/GitHub
172
173
  --skip-product-check Skip product docs check (dev update, roadmap, readme-first)
173
174
  --skip-stale-check Skip stale remote branch check
174
- --skip-worktree-check Skip worktree guard (allow release from worktree)
175
+ --skip-worktree-check Skip main-branch + worktree guard (break-glass only)
176
+ --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)
175
177
 
176
178
  Release notes (REQUIRED for stable, optional for other tracks):
177
179
  1. --notes-file=path Explicit file path
@@ -220,6 +222,8 @@ if (level === 'alpha' || level === 'beta') {
220
222
  dryRun,
221
223
  noPublish,
222
224
  publishReleaseNotes: level === 'alpha' ? wantReleaseNotes : !noReleaseNotes,
225
+ skipWorktreeCheck,
226
+ allowSubToolDrift,
223
227
  }).catch(err => {
224
228
  console.error(` \u2717 ${err.message}`);
225
229
  process.exit(1);
@@ -234,6 +238,7 @@ if (level === 'alpha' || level === 'beta') {
234
238
  noPublish,
235
239
  publishReleaseNotes: !noReleaseNotes,
236
240
  skipWorktreeCheck,
241
+ allowSubToolDrift,
237
242
  }).catch(err => {
238
243
  console.error(` \u2717 ${err.message}`);
239
244
  process.exit(1);
@@ -252,6 +257,7 @@ if (level === 'alpha' || level === 'beta') {
252
257
  skipWorktreeCheck,
253
258
  skipTechDocsCheck,
254
259
  skipCoverageCheck,
260
+ allowSubToolDrift,
255
261
  }).catch(err => {
256
262
  console.error(` \u2717 ${err.message}`);
257
263
  process.exit(1);
@@ -1323,10 +1323,210 @@ export function checkStaleBranches(repoPath, level) {
1323
1323
 
1324
1324
  // ── Main ────────────────────────────────────────────────────────────
1325
1325
 
1326
+ /**
1327
+ * Guard: wip-release must run from the main working tree on the main/master branch.
1328
+ *
1329
+ * Two independent conditions are enforced:
1330
+ *
1331
+ * 1. Linked worktree check: `git rev-parse --git-dir` of a linked worktree
1332
+ * resolves to a path under `.git/worktrees/...`. If we see that, the caller
1333
+ * is inside a feature worktree and must switch to the main working tree.
1334
+ * 2. Current branch check: even from the main working tree, `git branch
1335
+ * --show-current` must be `main` or `master`. If a user checked out a feature
1336
+ * branch in the main tree, the release would commit to the wrong branch.
1337
+ *
1338
+ * Both conditions bypassable via `--skip-worktree-check` for break-glass scenarios.
1339
+ *
1340
+ * Returns `{ ok: true }` on pass, or `{ ok: false, reason, currentPath, mainPath, branch }`
1341
+ * on fail so the caller can log and return the standard `{ failed: true }` shape.
1342
+ *
1343
+ * Related: `ai/product/bugs/guard/2026-04-05--cc-mini--guard-master-plan.md` Phase 3,
1344
+ * `ai/product/bugs/release-pipeline/2026-04-05--cc-mini--release-pipeline-master-plan.md`
1345
+ * Phase 1. Earlier today a wip-release alpha ran from a worktree branch because
1346
+ * `releasePrerelease` had no worktree check at all and the other two checks did
1347
+ * not cover the "main tree but non-main branch" case. This helper closes both gaps.
1348
+ */
1349
+ function enforceMainBranchGuard(repoPath, skipWorktreeCheck) {
1350
+ if (skipWorktreeCheck) {
1351
+ return { ok: true, skipped: true };
1352
+ }
1353
+ try {
1354
+ const gitDir = execFileSync('git', ['rev-parse', '--git-dir'], {
1355
+ cwd: repoPath, encoding: 'utf8'
1356
+ }).trim();
1357
+ if (gitDir.includes('/worktrees/')) {
1358
+ const worktreeList = execFileSync('git', ['worktree', 'list', '--porcelain'], {
1359
+ cwd: repoPath, encoding: 'utf8'
1360
+ });
1361
+ const mainWorktree = worktreeList.split('\n')
1362
+ .find(line => line.startsWith('worktree '));
1363
+ const mainPath = mainWorktree ? mainWorktree.replace('worktree ', '') : '(unknown)';
1364
+ return {
1365
+ ok: false,
1366
+ reason: 'linked-worktree',
1367
+ currentPath: repoPath,
1368
+ mainPath,
1369
+ };
1370
+ }
1371
+ const branch = execFileSync('git', ['branch', '--show-current'], {
1372
+ cwd: repoPath, encoding: 'utf8'
1373
+ }).trim();
1374
+ if (branch && branch !== 'main' && branch !== 'master') {
1375
+ return {
1376
+ ok: false,
1377
+ reason: 'non-main-branch',
1378
+ currentPath: repoPath,
1379
+ branch,
1380
+ };
1381
+ }
1382
+ return { ok: true, branch };
1383
+ } catch {
1384
+ // Git command failed: skip check gracefully so release can still run
1385
+ // in CI or unusual environments where git plumbing is restricted.
1386
+ return { ok: true, skipped: true };
1387
+ }
1388
+ }
1389
+
1390
+ /**
1391
+ * Validate that sub-tool package.json versions were bumped when their files changed.
1392
+ *
1393
+ * Scans `tools/*\/package.json` in monorepo-style toolboxes. For each sub-tool
1394
+ * whose files changed since the last git tag, verifies the package.json version
1395
+ * differs from the version at that tag. If not, this used to be a WARNING that
1396
+ * let the release proceed, which shipped at least one "committed but never
1397
+ * deployed" bug earlier today (guard 1.9.71 had new code but the same version,
1398
+ * so ldm install ignored the sub-tool on redeploy).
1399
+ *
1400
+ * Phase 8 of the release-pipeline master plan: WARNING becomes ERROR by default.
1401
+ * Callers who genuinely want to proceed without bumping (e.g., a release that
1402
+ * touches sub-tool files in a non-shipping way like CI config) pass
1403
+ * `allowSubToolDrift: true`.
1404
+ *
1405
+ * Returns `{ ok: true }` on pass, `{ ok: false }` if any sub-tool drift was
1406
+ * detected without the allow flag.
1407
+ *
1408
+ * Related: `ai/product/bugs/release-pipeline/2026-04-05--cc-mini--release-pipeline-master-plan.md`
1409
+ * Phase 8.
1410
+ */
1411
+ function validateSubToolVersions(repoPath, allowSubToolDrift) {
1412
+ const toolsDir = join(repoPath, 'tools');
1413
+ if (!existsSync(toolsDir)) {
1414
+ return { ok: true };
1415
+ }
1416
+ let lastTag = null;
1417
+ try {
1418
+ lastTag = execFileSync('git', ['describe', '--tags', '--abbrev=0'], {
1419
+ cwd: repoPath, encoding: 'utf8'
1420
+ }).trim();
1421
+ } catch {
1422
+ return { ok: true }; // No prior tag, nothing to compare against
1423
+ }
1424
+ if (!lastTag) return { ok: true };
1425
+
1426
+ let driftDetected = false;
1427
+ let entries;
1428
+ try {
1429
+ entries = readdirSync(toolsDir, { withFileTypes: true });
1430
+ } catch {
1431
+ return { ok: true };
1432
+ }
1433
+ for (const entry of entries) {
1434
+ if (!entry.isDirectory()) continue;
1435
+ const subDir = join('tools', entry.name);
1436
+ const subPkgPath = join(toolsDir, entry.name, 'package.json');
1437
+ if (!existsSync(subPkgPath)) continue;
1438
+ try {
1439
+ const diff = execFileSync('git', ['diff', '--name-only', lastTag, 'HEAD', '--', subDir], {
1440
+ cwd: repoPath, encoding: 'utf8'
1441
+ }).trim();
1442
+ if (!diff) continue;
1443
+ const currentSubVersion = JSON.parse(readFileSync(subPkgPath, 'utf8')).version;
1444
+ let oldSubVersion = null;
1445
+ try {
1446
+ oldSubVersion = JSON.parse(
1447
+ execFileSync('git', ['show', `${lastTag}:${subDir}/package.json`], {
1448
+ cwd: repoPath, encoding: 'utf8'
1449
+ })
1450
+ ).version;
1451
+ } catch {}
1452
+ if (currentSubVersion === oldSubVersion) {
1453
+ if (allowSubToolDrift) {
1454
+ console.log(` ! WARNING (allowed by --allow-sub-tool-drift): ${entry.name} has changed files since ${lastTag} but version is still ${currentSubVersion}`);
1455
+ console.log(` Changed: ${diff.split('\n').join(', ')}`);
1456
+ } else {
1457
+ console.log(` \u2717 ${entry.name} has changed files since ${lastTag} but tools/${entry.name}/package.json version is still ${currentSubVersion}.`);
1458
+ console.log(` Changed: ${diff.split('\n').join(', ')}`);
1459
+ console.log(` Bump tools/${entry.name}/package.json before releasing, or pass --allow-sub-tool-drift to override.`);
1460
+ console.log('');
1461
+ driftDetected = true;
1462
+ }
1463
+ }
1464
+ } catch {}
1465
+ }
1466
+ return { ok: !driftDetected };
1467
+ }
1468
+
1469
+ /**
1470
+ * Pre-tag collision check. Returns `{ ok: true }` if no collision, otherwise
1471
+ * `{ ok: false, tag }` with a message logged. Phase 2 of the release-pipeline
1472
+ * master plan: earlier today `wip-release alpha` failed mid-pipeline because
1473
+ * `v1.9.71-alpha.4` and `v1.9.71-alpha.5` existed as local-only tags from
1474
+ * prior failed releases. The release tool has no recovery path; this helper
1475
+ * catches the collision before the bump+commit happens, so the user gets a
1476
+ * clear error and concrete recovery command instead of a mid-pipeline failure.
1477
+ */
1478
+ function checkTagCollision(repoPath, newVersion) {
1479
+ const tag = `v${newVersion}`;
1480
+ try {
1481
+ const localTags = execFileSync('git', ['tag', '-l', tag], {
1482
+ cwd: repoPath, encoding: 'utf8'
1483
+ }).trim();
1484
+ if (localTags === tag) {
1485
+ // Tag exists locally. Is it also on remote?
1486
+ try {
1487
+ const remoteTags = execFileSync('git', ['ls-remote', '--tags', 'origin', tag], {
1488
+ cwd: repoPath, encoding: 'utf8'
1489
+ }).trim();
1490
+ if (remoteTags.includes(tag)) {
1491
+ // Tag is on remote: legitimate prior release. Refuse.
1492
+ console.log(` \u2717 Tag ${tag} already exists on origin (prior release).`);
1493
+ console.log(` Bump the version manually in package.json or run with a different level.`);
1494
+ console.log('');
1495
+ return { ok: false, tag, reason: 'on-remote' };
1496
+ }
1497
+ } catch {}
1498
+ // Tag exists locally but NOT on remote: stale leftover from a failed release.
1499
+ // Refuse with a concrete recovery command so the user knows this is safe to clean up.
1500
+ console.log(` \u2717 Tag ${tag} exists locally but not on origin (stale leftover from a prior failed release).`);
1501
+ console.log(` Safe to delete because it was never pushed. Recover with:`);
1502
+ console.log(` git tag -d ${tag} && wip-release <track>`);
1503
+ console.log('');
1504
+ return { ok: false, tag, reason: 'stale-local' };
1505
+ }
1506
+ } catch {}
1507
+ return { ok: true };
1508
+ }
1509
+
1510
+ function logMainBranchGuardFailure(result) {
1511
+ if (result.reason === 'linked-worktree') {
1512
+ console.log(` \u2717 wip-release must run from the main working tree, not a worktree.`);
1513
+ console.log(` Current: ${result.currentPath}`);
1514
+ console.log(` Main working tree: ${result.mainPath}`);
1515
+ console.log(` Switch to the main working tree and run again:`);
1516
+ console.log(` cd ${result.mainPath} && wip-release <track>`);
1517
+ } else if (result.reason === 'non-main-branch') {
1518
+ console.log(` \u2717 wip-release must run on the main branch, not a feature branch.`);
1519
+ console.log(` Current branch: ${result.branch}`);
1520
+ console.log(` Switch to main and pull latest:`);
1521
+ console.log(` git checkout main && git pull && wip-release <track>`);
1522
+ }
1523
+ console.log('');
1524
+ }
1525
+
1326
1526
  /**
1327
1527
  * Run the full release pipeline.
1328
1528
  */
1329
- export async function release({ repoPath, level, notes, notesSource, dryRun, noPublish, skipProductCheck, skipStaleCheck, skipWorktreeCheck, skipTechDocsCheck, skipCoverageCheck }) {
1529
+ export async function release({ repoPath, level, notes, notesSource, dryRun, noPublish, skipProductCheck, skipStaleCheck, skipWorktreeCheck, skipTechDocsCheck, skipCoverageCheck, allowSubToolDrift }) {
1330
1530
  repoPath = repoPath || process.cwd();
1331
1531
  const currentVersion = detectCurrentVersion(repoPath);
1332
1532
  const newVersion = bumpSemver(currentVersion, level);
@@ -1336,33 +1536,15 @@ export async function release({ repoPath, level, notes, notesSource, dryRun, noP
1336
1536
  console.log(` ${repoName}: ${currentVersion} -> ${newVersion} (${level})`);
1337
1537
  console.log(` ${'─'.repeat(40)}`);
1338
1538
 
1339
- // -1. Worktree guard: block releases from linked worktrees
1340
- if (!skipWorktreeCheck) {
1341
- try {
1342
- const gitDir = execFileSync('git', ['rev-parse', '--git-dir'], {
1343
- cwd: repoPath, encoding: 'utf8'
1344
- }).trim();
1345
-
1346
- // Linked worktrees have "/worktrees/" in their git-dir path
1347
- if (gitDir.includes('/worktrees/')) {
1348
- // Get the main working tree path from `git worktree list`
1349
- const worktreeList = execFileSync('git', ['worktree', 'list', '--porcelain'], {
1350
- cwd: repoPath, encoding: 'utf8'
1351
- });
1352
- const mainWorktree = worktreeList.split('\n')
1353
- .find(line => line.startsWith('worktree '));
1354
- const mainPath = mainWorktree ? mainWorktree.replace('worktree ', '') : '(unknown)';
1355
-
1356
- console.log(` \u2717 wip-release must run from the main working tree, not a worktree.`);
1357
- console.log(` Current: ${repoPath}`);
1358
- console.log(` Main working tree: ${mainPath}`);
1359
- console.log(` Switch to the main working tree and run again.`);
1360
- console.log('');
1361
- return { currentVersion, newVersion, dryRun: false, failed: true };
1362
- }
1363
- console.log(' \u2713 Running from main working tree');
1364
- } catch {
1365
- // Git command failed... skip check gracefully
1539
+ // -1. Main-branch guard: block releases from linked worktrees or non-main branches
1540
+ {
1541
+ const guardResult = enforceMainBranchGuard(repoPath, skipWorktreeCheck);
1542
+ if (!guardResult.ok) {
1543
+ logMainBranchGuardFailure(guardResult);
1544
+ return { currentVersion, newVersion, dryRun: false, failed: true };
1545
+ }
1546
+ if (!guardResult.skipped) {
1547
+ console.log(` \u2713 Running from main working tree on ${guardResult.branch ?? 'main'}`);
1366
1548
  }
1367
1549
  }
1368
1550
 
@@ -1671,37 +1853,23 @@ export async function release({ repoPath, level, notes, notesSource, dryRun, noP
1671
1853
  return { currentVersion, newVersion, dryRun: true };
1672
1854
  }
1673
1855
 
1856
+ // 1.25. Pre-bump tag collision check (Phase 2).
1857
+ {
1858
+ const collision = checkTagCollision(repoPath, newVersion);
1859
+ if (!collision.ok) {
1860
+ return { currentVersion, newVersion, dryRun: false, failed: true };
1861
+ }
1862
+ }
1863
+
1674
1864
  // 1. Bump package.json
1675
1865
  writePackageVersion(repoPath, newVersion);
1676
1866
  console.log(` ✓ package.json -> ${newVersion}`);
1677
1867
 
1678
- // 1.5. Validate sub-tool version bumps in toolbox repos (tools/*/)
1679
- // Sub-tools have independent versions. If files changed since last tag
1680
- // but the version didn't bump, warn. Developer must bump manually.
1681
- const toolsDir = join(repoPath, 'tools');
1682
- if (existsSync(toolsDir)) {
1683
- const lastTag = (() => { try { return execFileSync('git', ['describe', '--tags', '--abbrev=0'], { cwd: repoPath, encoding: 'utf8' }).trim(); } catch { return null; } })();
1684
- if (lastTag) {
1685
- try {
1686
- const entries = readdirSync(toolsDir, { withFileTypes: true });
1687
- for (const entry of entries) {
1688
- if (!entry.isDirectory()) continue;
1689
- const subDir = join('tools', entry.name);
1690
- const subPkgPath = join(toolsDir, entry.name, 'package.json');
1691
- if (!existsSync(subPkgPath)) continue;
1692
- try {
1693
- const diff = execFileSync('git', ['diff', '--name-only', lastTag, 'HEAD', '--', subDir], { cwd: repoPath, encoding: 'utf8' }).trim();
1694
- if (!diff) continue;
1695
- const currentSubVersion = JSON.parse(readFileSync(subPkgPath, 'utf8')).version;
1696
- const oldSubVersion = (() => { try { return JSON.parse(execFileSync('git', ['show', `${lastTag}:${subDir}/package.json`], { cwd: repoPath, encoding: 'utf8' })).version; } catch { return null; } })();
1697
- if (currentSubVersion === oldSubVersion) {
1698
- console.log(` ! WARNING: ${entry.name} has changed files since ${lastTag} but version is still ${currentSubVersion}`);
1699
- console.log(` Changed: ${diff.split('\n').join(', ')}`);
1700
- console.log(` Bump tools/${entry.name}/package.json before releasing.`);
1701
- }
1702
- } catch {}
1703
- }
1704
- } catch {}
1868
+ // 1.5. Validate sub-tool version bumps (Phase 8: error by default)
1869
+ {
1870
+ const subToolResult = validateSubToolVersions(repoPath, allowSubToolDrift);
1871
+ if (!subToolResult.ok) {
1872
+ return { currentVersion, newVersion, dryRun: false, failed: true };
1705
1873
  }
1706
1874
  }
1707
1875
 
@@ -2017,7 +2185,7 @@ export async function release({ repoPath, level, notes, notesSource, dryRun, noP
2017
2185
  * No deploy-public. No code sync. No CHANGELOG gate. No product docs gate.
2018
2186
  * Lightweight: bump version, npm publish with tag, optional GitHub prerelease.
2019
2187
  */
2020
- export async function releasePrerelease({ repoPath, track, notes, dryRun, noPublish, publishReleaseNotes }) {
2188
+ export async function releasePrerelease({ repoPath, track, notes, dryRun, noPublish, publishReleaseNotes, skipWorktreeCheck, allowSubToolDrift }) {
2021
2189
  repoPath = repoPath || process.cwd();
2022
2190
  const currentVersion = detectCurrentVersion(repoPath);
2023
2191
  const newVersion = bumpPrerelease(currentVersion, track);
@@ -2027,6 +2195,20 @@ export async function releasePrerelease({ repoPath, track, notes, dryRun, noPubl
2027
2195
  console.log(` ${repoName}: ${currentVersion} -> ${newVersion} (${track})`);
2028
2196
  console.log(` ${'─'.repeat(40)}`);
2029
2197
 
2198
+ // Main-branch guard: worktree + non-main branch check via shared helper.
2199
+ // Runs before the dry-run short-circuit so preview output from a feature
2200
+ // branch still refuses instead of printing a misleading "would bump" plan.
2201
+ {
2202
+ const guardResult = enforceMainBranchGuard(repoPath, skipWorktreeCheck);
2203
+ if (!guardResult.ok) {
2204
+ logMainBranchGuardFailure(guardResult);
2205
+ return { currentVersion, newVersion, dryRun: false, failed: true };
2206
+ }
2207
+ if (!guardResult.skipped) {
2208
+ console.log(` \u2713 Running from main working tree on ${guardResult.branch ?? 'main'}`);
2209
+ }
2210
+ }
2211
+
2030
2212
  if (dryRun) {
2031
2213
  console.log(` [dry run] Would bump package.json to ${newVersion}`);
2032
2214
  if (!noPublish) {
@@ -2043,38 +2225,23 @@ export async function releasePrerelease({ repoPath, track, notes, dryRun, noPubl
2043
2225
  return { currentVersion, newVersion, dryRun: true };
2044
2226
  }
2045
2227
 
2228
+ // 1.25. Pre-bump tag collision check (Phase 2).
2229
+ {
2230
+ const collision = checkTagCollision(repoPath, newVersion);
2231
+ if (!collision.ok) {
2232
+ return { currentVersion, newVersion, dryRun: false, failed: true };
2233
+ }
2234
+ }
2235
+
2046
2236
  // 1. Bump package.json
2047
2237
  writePackageVersion(repoPath, newVersion);
2048
2238
  console.log(` \u2713 package.json -> ${newVersion}`);
2049
2239
 
2050
- // 1.5. Validate sub-tool version bumps in toolbox repos (tools/*/)
2051
- // If a sub-tool's files changed since the last tag but its version didn't bump, warn.
2052
- const toolsDir = join(repoPath, 'tools');
2053
- if (existsSync(toolsDir)) {
2054
- const lastTag = (() => { try { return execFileSync('git', ['describe', '--tags', '--abbrev=0'], { cwd: repoPath, encoding: 'utf8' }).trim(); } catch { return null; } })();
2055
- if (lastTag) {
2056
- try {
2057
- const entries = readdirSync(toolsDir, { withFileTypes: true });
2058
- for (const entry of entries) {
2059
- if (!entry.isDirectory()) continue;
2060
- const subDir = join('tools', entry.name);
2061
- const subPkgPath = join(toolsDir, entry.name, 'package.json');
2062
- if (!existsSync(subPkgPath)) continue;
2063
- // Check if any files in this sub-tool changed since last tag
2064
- try {
2065
- const diff = execFileSync('git', ['diff', '--name-only', lastTag, 'HEAD', '--', subDir], { cwd: repoPath, encoding: 'utf8' }).trim();
2066
- if (!diff) continue; // No changes, skip
2067
- // Files changed. Check if version was bumped.
2068
- const currentSubVersion = JSON.parse(readFileSync(subPkgPath, 'utf8')).version;
2069
- const oldSubVersion = (() => { try { return JSON.parse(execFileSync('git', ['show', `${lastTag}:${subDir}/package.json`], { cwd: repoPath, encoding: 'utf8' })).version; } catch { return null; } })();
2070
- if (currentSubVersion === oldSubVersion) {
2071
- console.log(` ! WARNING: ${entry.name} has changed files since ${lastTag} but version is still ${currentSubVersion}`);
2072
- console.log(` Changed: ${diff.split('\n').join(', ')}`);
2073
- console.log(` Bump tools/${entry.name}/package.json before releasing.`);
2074
- }
2075
- } catch {}
2076
- }
2077
- } catch {}
2240
+ // 1.5. Validate sub-tool version bumps (Phase 8: error by default)
2241
+ {
2242
+ const subToolResult = validateSubToolVersions(repoPath, allowSubToolDrift);
2243
+ if (!subToolResult.ok) {
2244
+ return { currentVersion, newVersion, dryRun: false, failed: true };
2078
2245
  }
2079
2246
  }
2080
2247
 
@@ -2158,7 +2325,7 @@ export async function releasePrerelease({ repoPath, track, notes, dryRun, noPubl
2158
2325
  * Lighter gates than stable: no product docs check, no stale branch check.
2159
2326
  * Still runs: worktree guard, license compliance, tests.
2160
2327
  */
2161
- export async function releaseHotfix({ repoPath, notes, notesSource, dryRun, noPublish, publishReleaseNotes, skipWorktreeCheck }) {
2328
+ export async function releaseHotfix({ repoPath, notes, notesSource, dryRun, noPublish, publishReleaseNotes, skipWorktreeCheck, allowSubToolDrift }) {
2162
2329
  repoPath = repoPath || process.cwd();
2163
2330
  const currentVersion = detectCurrentVersion(repoPath);
2164
2331
  const newVersion = bumpSemver(currentVersion, 'patch');
@@ -2168,27 +2335,16 @@ export async function releaseHotfix({ repoPath, notes, notesSource, dryRun, noPu
2168
2335
  console.log(` ${repoName}: ${currentVersion} -> ${newVersion} (hotfix)`);
2169
2336
  console.log(` ${'─'.repeat(40)}`);
2170
2337
 
2171
- // Worktree guard
2172
- if (!skipWorktreeCheck) {
2173
- try {
2174
- const gitDir = execFileSync('git', ['rev-parse', '--git-dir'], {
2175
- cwd: repoPath, encoding: 'utf8'
2176
- }).trim();
2177
- if (gitDir.includes('/worktrees/')) {
2178
- const worktreeList = execFileSync('git', ['worktree', 'list', '--porcelain'], {
2179
- cwd: repoPath, encoding: 'utf8'
2180
- });
2181
- const mainWorktree = worktreeList.split('\n')
2182
- .find(line => line.startsWith('worktree '));
2183
- const mainPath = mainWorktree ? mainWorktree.replace('worktree ', '') : '(unknown)';
2184
- console.log(` \u2717 wip-release must run from the main working tree, not a worktree.`);
2185
- console.log(` Current: ${repoPath}`);
2186
- console.log(` Main working tree: ${mainPath}`);
2187
- console.log('');
2188
- return { currentVersion, newVersion, dryRun: false, failed: true };
2189
- }
2190
- console.log(' \u2713 Running from main working tree');
2191
- } catch {}
2338
+ // Main-branch guard: worktree + non-main branch check via shared helper
2339
+ {
2340
+ const guardResult = enforceMainBranchGuard(repoPath, skipWorktreeCheck);
2341
+ if (!guardResult.ok) {
2342
+ logMainBranchGuardFailure(guardResult);
2343
+ return { currentVersion, newVersion, dryRun: false, failed: true };
2344
+ }
2345
+ if (!guardResult.skipped) {
2346
+ console.log(` \u2713 Running from main working tree on ${guardResult.branch ?? 'main'}`);
2347
+ }
2192
2348
  }
2193
2349
 
2194
2350
  // License compliance gate
@@ -2275,35 +2431,23 @@ export async function releaseHotfix({ repoPath, notes, notesSource, dryRun, noPu
2275
2431
  return { currentVersion, newVersion, dryRun: true };
2276
2432
  }
2277
2433
 
2434
+ // 1.25. Pre-bump tag collision check (Phase 2).
2435
+ {
2436
+ const collision = checkTagCollision(repoPath, newVersion);
2437
+ if (!collision.ok) {
2438
+ return { currentVersion, newVersion, dryRun: false, failed: true };
2439
+ }
2440
+ }
2441
+
2278
2442
  // 1. Bump package.json
2279
2443
  writePackageVersion(repoPath, newVersion);
2280
2444
  console.log(` \u2713 package.json -> ${newVersion}`);
2281
2445
 
2282
- // 1.5. Validate sub-tool version bumps in toolbox repos (tools/*/)
2283
- const toolsDir = join(repoPath, 'tools');
2284
- if (existsSync(toolsDir)) {
2285
- const lastTag = (() => { try { return execFileSync('git', ['describe', '--tags', '--abbrev=0'], { cwd: repoPath, encoding: 'utf8' }).trim(); } catch { return null; } })();
2286
- if (lastTag) {
2287
- try {
2288
- const entries = readdirSync(toolsDir, { withFileTypes: true });
2289
- for (const entry of entries) {
2290
- if (!entry.isDirectory()) continue;
2291
- const subDir = join('tools', entry.name);
2292
- const subPkgPath = join(toolsDir, entry.name, 'package.json');
2293
- if (!existsSync(subPkgPath)) continue;
2294
- try {
2295
- const diff = execFileSync('git', ['diff', '--name-only', lastTag, 'HEAD', '--', subDir], { cwd: repoPath, encoding: 'utf8' }).trim();
2296
- if (!diff) continue;
2297
- const currentSubVersion = JSON.parse(readFileSync(subPkgPath, 'utf8')).version;
2298
- const oldSubVersion = (() => { try { return JSON.parse(execFileSync('git', ['show', `${lastTag}:${subDir}/package.json`], { cwd: repoPath, encoding: 'utf8' })).version; } catch { return null; } })();
2299
- if (currentSubVersion === oldSubVersion) {
2300
- console.log(` ! WARNING: ${entry.name} has changed files since ${lastTag} but version is still ${currentSubVersion}`);
2301
- console.log(` Changed: ${diff.split('\n').join(', ')}`);
2302
- console.log(` Bump tools/${entry.name}/package.json before releasing.`);
2303
- }
2304
- } catch {}
2305
- }
2306
- } catch {}
2446
+ // 1.5. Validate sub-tool version bumps (Phase 8: error by default)
2447
+ {
2448
+ const subToolResult = validateSubToolVersions(repoPath, allowSubToolDrift);
2449
+ if (!subToolResult.ok) {
2450
+ return { currentVersion, newVersion, dryRun: false, failed: true };
2307
2451
  }
2308
2452
  }
2309
2453
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wipcomputer/wip-release",
3
- "version": "1.9.70",
3
+ "version": "1.9.72",
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",