@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 +116 -0
- package/RELEASE-NOTES-v1-9-71-alpha-7.md +60 -0
- package/RELEASE-NOTES-v1-9-71-alpha-8.md +50 -0
- package/package.json +1 -1
- package/tools/wip-release/cli.js +7 -1
- package/tools/wip-release/core.mjs +418 -149
- package/tools/wip-release/package.json +1 -1
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
package/tools/wip-release/cli.js
CHANGED
|
@@ -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 (
|
|
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.
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
}
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
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
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
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
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
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
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
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
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
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
|
-
//
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
}
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
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
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
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
|
-
|
|
2331
|
-
|
|
2332
|
-
|
|
2333
|
-
|
|
2334
|
-
|
|
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 = [];
|