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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,121 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.9.71-alpha.8 (2026-04-05)
4
+
5
+ # v1.9.71-alpha.8
6
+
7
+ ## wip-release: automatic PR flow for protected main (Phase 4)
8
+
9
+ When `git push origin main` fails with GitHub's "protected branch" rejection (`GH006: Changes must be made through a pull request`), wip-release now automatically:
10
+
11
+ 1. Creates a release branch `cc-mini/release-v<version>` at the current commit
12
+ 2. Pushes the branch to origin
13
+ 3. Opens a PR via `gh pr create` with title `release: v<version>`
14
+ 4. Merges the PR via `gh pr merge --merge --delete-branch`
15
+ 5. Pushes the tag separately (tags bypass branch protection on most GitHub setups)
16
+ 6. Fast-forwards local main so downstream steps (deploy-public, etc.) have a clean state
17
+
18
+ Previously this was a 4-command manual workflow every release:
19
+
20
+ ```
21
+ git branch cc-mini/release-alpha-N
22
+ git push -u origin cc-mini/release-alpha-N
23
+ gh pr create --base main --head cc-mini/release-alpha-N --title '...'
24
+ gh pr merge <pr> --merge --delete-branch
25
+ git push origin v<version>
26
+ ```
27
+
28
+ Every release. Every time. Eliminated.
29
+
30
+ ## Fallback behavior
31
+
32
+ If any step of the auto-PR flow fails (gh CLI missing, PR create failure, merge failure, tag push failure), wip-release logs a concrete recovery command for the exact failure mode and continues (non-fatal, matches prior push-failed behavior). The user can always complete the remaining steps manually.
33
+
34
+ ## Direct push still works
35
+
36
+ If the repo allows direct push to main (typical for private staging repos), wip-release tries direct push first and only falls back to the PR flow on the specific GH006 / "protected branch" error. No behavioral change for unprotected repos.
37
+
38
+ ## Files changed
39
+
40
+ - `tools/wip-release/core.mjs`: new `pushReleaseWithAutoPr(repoPath, newVersion, level)` and `logPushFailure(result, tag)` helpers. Three push sites in `release()`, `releaseHotfix()`, `releasePrerelease()` migrated to use the helper.
41
+ - `tools/wip-release/package.json`: 1.9.72 -> 1.9.73
42
+ - `CHANGELOG.md`: entry added
43
+
44
+ ## Verified
45
+
46
+ - Module imports cleanly via `node -e "import('./tools/wip-release/core.mjs')"`.
47
+ - Error detection regex handles GH006 variants: `/protected branch|GH006|Changes must be made through a pull request/i`.
48
+ - All three release tracks (stable, prerelease, hotfix) use the same helper.
49
+
50
+ ## Cross-references
51
+
52
+ - `ai/product/bugs/release-pipeline/2026-04-05--cc-mini--release-pipeline-master-plan.md` Phase 4 (Incident 4)
53
+ - `ai/product/bugs/master-plans/bugs-plan-04-05-2026-002.md` Wave 2 phase 7
54
+ - Prior ship: alpha.7 closed Phases 1, 2, 8 of the same plan.
55
+
56
+ ## 1.9.71-alpha.7 (2026-04-05)
57
+
58
+ # v1.9.71-alpha.7
59
+
60
+ ## wip-release: three hardening fixes for the release pipeline
61
+
62
+ 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).
63
+
64
+ ### Phase 1: refuse non-main invocations (was Incident 1)
65
+
66
+ 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.
67
+
68
+ **Fix.** Extract a shared `enforceMainBranchGuard(repoPath, skipWorktreeCheck)` helper. Call it from all three release functions (`release`, `releaseHotfix`, `releasePrerelease`). The helper enforces two independent conditions:
69
+
70
+ 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.
71
+ 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.
72
+
73
+ Both conditions bypassable via `--skip-worktree-check` for break-glass scenarios.
74
+
75
+ ### Phase 2: tag collision pre-flight (was Incident 2)
76
+
77
+ 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.
78
+
79
+ **Fix.** New `checkTagCollision(repoPath, newVersion)` helper runs after the main-branch guard, before the version bump. It distinguishes two cases:
80
+
81
+ 1. **Tag exists on origin remote.** Legitimate prior release; refuses with a clear message.
82
+ 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>`.
83
+
84
+ Both cases log a clear error before any state mutation.
85
+
86
+ ### Phase 8: sub-tool version drift becomes an error (was Incident 8)
87
+
88
+ 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.
89
+
90
+ **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`.
91
+
92
+ ## New CLI flags
93
+
94
+ - `--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.
95
+
96
+ ## Files changed
97
+
98
+ - `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.
99
+ - `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.
100
+ - `tools/wip-release/package.json`: version bump to 1.9.72.
101
+ - `CHANGELOG.md`: entry added.
102
+
103
+ ## Verified
104
+
105
+ - From a feature worktree: `wip-release alpha --dry-run` refuses with concrete `cd <main-tree>` recovery command. Same for `patch` and `hotfix`.
106
+ - `--skip-worktree-check` bypass works.
107
+ - Module imports cleanly via `node -e "import('./tools/wip-release/core.mjs')"`.
108
+
109
+ ## Known limitation (follow-up)
110
+
111
+ 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.
112
+
113
+ ## Cross-references
114
+
115
+ - `ai/product/bugs/release-pipeline/2026-04-05--cc-mini--release-pipeline-master-plan.md` Phases 1, 2, 8
116
+ - `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)
117
+ - `ai/product/bugs/master-plans/bugs-plan-04-05-2026-002.md` Wave 2 phases 4, 5, 11
118
+
3
119
  ## 1.9.71-alpha.6 (2026-04-05)
4
120
 
5
121
  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
@@ -0,0 +1,50 @@
1
+ # v1.9.71-alpha.8
2
+
3
+ ## wip-release: automatic PR flow for protected main (Phase 4)
4
+
5
+ When `git push origin main` fails with GitHub's "protected branch" rejection (`GH006: Changes must be made through a pull request`), wip-release now automatically:
6
+
7
+ 1. Creates a release branch `cc-mini/release-v<version>` at the current commit
8
+ 2. Pushes the branch to origin
9
+ 3. Opens a PR via `gh pr create` with title `release: v<version>`
10
+ 4. Merges the PR via `gh pr merge --merge --delete-branch`
11
+ 5. Pushes the tag separately (tags bypass branch protection on most GitHub setups)
12
+ 6. Fast-forwards local main so downstream steps (deploy-public, etc.) have a clean state
13
+
14
+ Previously this was a 4-command manual workflow every release:
15
+
16
+ ```
17
+ git branch cc-mini/release-alpha-N
18
+ git push -u origin cc-mini/release-alpha-N
19
+ gh pr create --base main --head cc-mini/release-alpha-N --title '...'
20
+ gh pr merge <pr> --merge --delete-branch
21
+ git push origin v<version>
22
+ ```
23
+
24
+ Every release. Every time. Eliminated.
25
+
26
+ ## Fallback behavior
27
+
28
+ If any step of the auto-PR flow fails (gh CLI missing, PR create failure, merge failure, tag push failure), wip-release logs a concrete recovery command for the exact failure mode and continues (non-fatal, matches prior push-failed behavior). The user can always complete the remaining steps manually.
29
+
30
+ ## Direct push still works
31
+
32
+ If the repo allows direct push to main (typical for private staging repos), wip-release tries direct push first and only falls back to the PR flow on the specific GH006 / "protected branch" error. No behavioral change for unprotected repos.
33
+
34
+ ## Files changed
35
+
36
+ - `tools/wip-release/core.mjs`: new `pushReleaseWithAutoPr(repoPath, newVersion, level)` and `logPushFailure(result, tag)` helpers. Three push sites in `release()`, `releaseHotfix()`, `releasePrerelease()` migrated to use the helper.
37
+ - `tools/wip-release/package.json`: 1.9.72 -> 1.9.73
38
+ - `CHANGELOG.md`: entry added
39
+
40
+ ## Verified
41
+
42
+ - Module imports cleanly via `node -e "import('./tools/wip-release/core.mjs')"`.
43
+ - Error detection regex handles GH006 variants: `/protected branch|GH006|Changes must be made through a pull request/i`.
44
+ - All three release tracks (stable, prerelease, hotfix) use the same helper.
45
+
46
+ ## Cross-references
47
+
48
+ - `ai/product/bugs/release-pipeline/2026-04-05--cc-mini--release-pipeline-master-plan.md` Phase 4 (Incident 4)
49
+ - `ai/product/bugs/master-plans/bugs-plan-04-05-2026-002.md` Wave 2 phase 7
50
+ - Prior ship: alpha.7 closed Phases 1, 2, 8 of the same plan.
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.8",
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,329 @@ 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
+ /**
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
+ function logMainBranchGuardFailure(result) {
1630
+ if (result.reason === 'linked-worktree') {
1631
+ console.log(` \u2717 wip-release must run from the main working tree, not a worktree.`);
1632
+ console.log(` Current: ${result.currentPath}`);
1633
+ console.log(` Main working tree: ${result.mainPath}`);
1634
+ console.log(` Switch to the main working tree and run again:`);
1635
+ console.log(` cd ${result.mainPath} && wip-release <track>`);
1636
+ } else if (result.reason === 'non-main-branch') {
1637
+ console.log(` \u2717 wip-release must run on the main branch, not a feature branch.`);
1638
+ console.log(` Current branch: ${result.branch}`);
1639
+ console.log(` Switch to main and pull latest:`);
1640
+ console.log(` git checkout main && git pull && wip-release <track>`);
1641
+ }
1642
+ console.log('');
1643
+ }
1644
+
1326
1645
  /**
1327
1646
  * Run the full release pipeline.
1328
1647
  */
1329
- export async function release({ repoPath, level, notes, notesSource, dryRun, noPublish, skipProductCheck, skipStaleCheck, skipWorktreeCheck, skipTechDocsCheck, skipCoverageCheck }) {
1648
+ export async function release({ repoPath, level, notes, notesSource, dryRun, noPublish, skipProductCheck, skipStaleCheck, skipWorktreeCheck, skipTechDocsCheck, skipCoverageCheck, allowSubToolDrift }) {
1330
1649
  repoPath = repoPath || process.cwd();
1331
1650
  const currentVersion = detectCurrentVersion(repoPath);
1332
1651
  const newVersion = bumpSemver(currentVersion, level);
@@ -1336,33 +1655,15 @@ export async function release({ repoPath, level, notes, notesSource, dryRun, noP
1336
1655
  console.log(` ${repoName}: ${currentVersion} -> ${newVersion} (${level})`);
1337
1656
  console.log(` ${'─'.repeat(40)}`);
1338
1657
 
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
1658
+ // -1. Main-branch guard: block releases from linked worktrees or non-main branches
1659
+ {
1660
+ const guardResult = enforceMainBranchGuard(repoPath, skipWorktreeCheck);
1661
+ if (!guardResult.ok) {
1662
+ logMainBranchGuardFailure(guardResult);
1663
+ return { currentVersion, newVersion, dryRun: false, failed: true };
1664
+ }
1665
+ if (!guardResult.skipped) {
1666
+ console.log(` \u2713 Running from main working tree on ${guardResult.branch ?? 'main'}`);
1366
1667
  }
1367
1668
  }
1368
1669
 
@@ -1671,37 +1972,23 @@ export async function release({ repoPath, level, notes, notesSource, dryRun, noP
1671
1972
  return { currentVersion, newVersion, dryRun: true };
1672
1973
  }
1673
1974
 
1975
+ // 1.25. Pre-bump tag collision check (Phase 2).
1976
+ {
1977
+ const collision = checkTagCollision(repoPath, newVersion);
1978
+ if (!collision.ok) {
1979
+ return { currentVersion, newVersion, dryRun: false, failed: true };
1980
+ }
1981
+ }
1982
+
1674
1983
  // 1. Bump package.json
1675
1984
  writePackageVersion(repoPath, newVersion);
1676
1985
  console.log(` ✓ package.json -> ${newVersion}`);
1677
1986
 
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 {}
1987
+ // 1.5. Validate sub-tool version bumps (Phase 8: error by default)
1988
+ {
1989
+ const subToolResult = validateSubToolVersions(repoPath, allowSubToolDrift);
1990
+ if (!subToolResult.ok) {
1991
+ return { currentVersion, newVersion, dryRun: false, failed: true };
1705
1992
  }
1706
1993
  }
1707
1994
 
@@ -1730,12 +2017,14 @@ export async function release({ repoPath, level, notes, notesSource, dryRun, noP
1730
2017
  gitCommitAndTag(repoPath, newVersion, notes);
1731
2018
  console.log(` ✓ Committed and tagged v${newVersion}`);
1732
2019
 
1733
- // 5. Push commit + tag
1734
- try {
1735
- execSync('git push && git push --tags', { cwd: repoPath, stdio: 'pipe' });
1736
- console.log(` ✓ Pushed to remote`);
1737
- } catch {
1738
- console.log(` ! Push failed (maybe branch protection). Push manually.`);
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
+ }
1739
2028
  }
1740
2029
 
1741
2030
  // Distribution results collector (#104)
@@ -2017,7 +2306,7 @@ export async function release({ repoPath, level, notes, notesSource, dryRun, noP
2017
2306
  * No deploy-public. No code sync. No CHANGELOG gate. No product docs gate.
2018
2307
  * Lightweight: bump version, npm publish with tag, optional GitHub prerelease.
2019
2308
  */
2020
- export async function releasePrerelease({ repoPath, track, notes, dryRun, noPublish, publishReleaseNotes }) {
2309
+ export async function releasePrerelease({ repoPath, track, notes, dryRun, noPublish, publishReleaseNotes, skipWorktreeCheck, allowSubToolDrift }) {
2021
2310
  repoPath = repoPath || process.cwd();
2022
2311
  const currentVersion = detectCurrentVersion(repoPath);
2023
2312
  const newVersion = bumpPrerelease(currentVersion, track);
@@ -2027,6 +2316,20 @@ export async function releasePrerelease({ repoPath, track, notes, dryRun, noPubl
2027
2316
  console.log(` ${repoName}: ${currentVersion} -> ${newVersion} (${track})`);
2028
2317
  console.log(` ${'─'.repeat(40)}`);
2029
2318
 
2319
+ // Main-branch guard: worktree + non-main branch check via shared helper.
2320
+ // Runs before the dry-run short-circuit so preview output from a feature
2321
+ // branch still refuses instead of printing a misleading "would bump" plan.
2322
+ {
2323
+ const guardResult = enforceMainBranchGuard(repoPath, skipWorktreeCheck);
2324
+ if (!guardResult.ok) {
2325
+ logMainBranchGuardFailure(guardResult);
2326
+ return { currentVersion, newVersion, dryRun: false, failed: true };
2327
+ }
2328
+ if (!guardResult.skipped) {
2329
+ console.log(` \u2713 Running from main working tree on ${guardResult.branch ?? 'main'}`);
2330
+ }
2331
+ }
2332
+
2030
2333
  if (dryRun) {
2031
2334
  console.log(` [dry run] Would bump package.json to ${newVersion}`);
2032
2335
  if (!noPublish) {
@@ -2043,38 +2346,23 @@ export async function releasePrerelease({ repoPath, track, notes, dryRun, noPubl
2043
2346
  return { currentVersion, newVersion, dryRun: true };
2044
2347
  }
2045
2348
 
2349
+ // 1.25. Pre-bump tag collision check (Phase 2).
2350
+ {
2351
+ const collision = checkTagCollision(repoPath, newVersion);
2352
+ if (!collision.ok) {
2353
+ return { currentVersion, newVersion, dryRun: false, failed: true };
2354
+ }
2355
+ }
2356
+
2046
2357
  // 1. Bump package.json
2047
2358
  writePackageVersion(repoPath, newVersion);
2048
2359
  console.log(` \u2713 package.json -> ${newVersion}`);
2049
2360
 
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 {}
2361
+ // 1.5. Validate sub-tool version bumps (Phase 8: error by default)
2362
+ {
2363
+ const subToolResult = validateSubToolVersions(repoPath, allowSubToolDrift);
2364
+ if (!subToolResult.ok) {
2365
+ return { currentVersion, newVersion, dryRun: false, failed: true };
2078
2366
  }
2079
2367
  }
2080
2368
 
@@ -2093,12 +2381,14 @@ export async function releasePrerelease({ repoPath, track, notes, dryRun, noPubl
2093
2381
  execFileSync('git', ['tag', `v${newVersion}`], { cwd: repoPath, stdio: 'pipe' });
2094
2382
  console.log(` \u2713 Committed and tagged v${newVersion}`);
2095
2383
 
2096
- // 4. Push commit + tag
2097
- try {
2098
- execSync('git push && git push --tags', { cwd: repoPath, stdio: 'pipe' });
2099
- console.log(` \u2713 Pushed to remote`);
2100
- } catch {
2101
- console.log(` ! Push failed. Push manually.`);
2384
+ // 4. Push commit + tag (with auto-PR fallback on protected main, Phase 4)
2385
+ {
2386
+ const pushResult = pushReleaseWithAutoPr(repoPath, newVersion, track);
2387
+ if (pushResult.ok) {
2388
+ console.log(` \u2713 Pushed to remote (${pushResult.via})`);
2389
+ } else {
2390
+ logPushFailure(pushResult, `v${newVersion}`);
2391
+ }
2102
2392
  }
2103
2393
 
2104
2394
  const distResults = [];
@@ -2158,7 +2448,7 @@ export async function releasePrerelease({ repoPath, track, notes, dryRun, noPubl
2158
2448
  * Lighter gates than stable: no product docs check, no stale branch check.
2159
2449
  * Still runs: worktree guard, license compliance, tests.
2160
2450
  */
2161
- export async function releaseHotfix({ repoPath, notes, notesSource, dryRun, noPublish, publishReleaseNotes, skipWorktreeCheck }) {
2451
+ export async function releaseHotfix({ repoPath, notes, notesSource, dryRun, noPublish, publishReleaseNotes, skipWorktreeCheck, allowSubToolDrift }) {
2162
2452
  repoPath = repoPath || process.cwd();
2163
2453
  const currentVersion = detectCurrentVersion(repoPath);
2164
2454
  const newVersion = bumpSemver(currentVersion, 'patch');
@@ -2168,27 +2458,16 @@ export async function releaseHotfix({ repoPath, notes, notesSource, dryRun, noPu
2168
2458
  console.log(` ${repoName}: ${currentVersion} -> ${newVersion} (hotfix)`);
2169
2459
  console.log(` ${'─'.repeat(40)}`);
2170
2460
 
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 {}
2461
+ // Main-branch guard: worktree + non-main branch check via shared helper
2462
+ {
2463
+ const guardResult = enforceMainBranchGuard(repoPath, skipWorktreeCheck);
2464
+ if (!guardResult.ok) {
2465
+ logMainBranchGuardFailure(guardResult);
2466
+ return { currentVersion, newVersion, dryRun: false, failed: true };
2467
+ }
2468
+ if (!guardResult.skipped) {
2469
+ console.log(` \u2713 Running from main working tree on ${guardResult.branch ?? 'main'}`);
2470
+ }
2192
2471
  }
2193
2472
 
2194
2473
  // License compliance gate
@@ -2275,35 +2554,23 @@ export async function releaseHotfix({ repoPath, notes, notesSource, dryRun, noPu
2275
2554
  return { currentVersion, newVersion, dryRun: true };
2276
2555
  }
2277
2556
 
2557
+ // 1.25. Pre-bump tag collision check (Phase 2).
2558
+ {
2559
+ const collision = checkTagCollision(repoPath, newVersion);
2560
+ if (!collision.ok) {
2561
+ return { currentVersion, newVersion, dryRun: false, failed: true };
2562
+ }
2563
+ }
2564
+
2278
2565
  // 1. Bump package.json
2279
2566
  writePackageVersion(repoPath, newVersion);
2280
2567
  console.log(` \u2713 package.json -> ${newVersion}`);
2281
2568
 
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 {}
2569
+ // 1.5. Validate sub-tool version bumps (Phase 8: error by default)
2570
+ {
2571
+ const subToolResult = validateSubToolVersions(repoPath, allowSubToolDrift);
2572
+ if (!subToolResult.ok) {
2573
+ return { currentVersion, newVersion, dryRun: false, failed: true };
2307
2574
  }
2308
2575
  }
2309
2576
 
@@ -2326,12 +2593,14 @@ export async function releaseHotfix({ repoPath, notes, notesSource, dryRun, noPu
2326
2593
  gitCommitAndTag(repoPath, newVersion, notes);
2327
2594
  console.log(` \u2713 Committed and tagged v${newVersion}`);
2328
2595
 
2329
- // 5. Push commit + tag
2330
- try {
2331
- execSync('git push && git push --tags', { cwd: repoPath, stdio: 'pipe' });
2332
- console.log(` \u2713 Pushed to remote`);
2333
- } catch {
2334
- console.log(` ! Push failed. Push manually.`);
2596
+ // 5. Push commit + tag (with auto-PR fallback on protected main, Phase 4)
2597
+ {
2598
+ const pushResult = pushReleaseWithAutoPr(repoPath, newVersion, 'hotfix');
2599
+ if (pushResult.ok) {
2600
+ console.log(` \u2713 Pushed to remote (${pushResult.via})`);
2601
+ } else {
2602
+ logPushFailure(pushResult, `v${newVersion}`);
2603
+ }
2335
2604
  }
2336
2605
 
2337
2606
  const distResults = [];
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wipcomputer/wip-release",
3
- "version": "1.9.70",
3
+ "version": "1.9.73",
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",