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