@wipcomputer/wip-release 1.9.67 → 1.9.72

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (5) hide show
  1. package/REFERENCE.md +154 -9
  2. package/SKILL.md +42 -3
  3. package/cli.js +94 -32
  4. package/core.mjs +702 -50
  5. package/package.json +1 -1
package/REFERENCE.md CHANGED
@@ -3,15 +3,47 @@
3
3
 
4
4
  Detailed usage, pipeline steps, flags, auth, and module API.
5
5
 
6
+ ## Release Tracks
7
+
8
+ Four release tracks, each with different behavior:
9
+
10
+ | Track | npm tag | Public code sync | Public release notes | Default notes |
11
+ |-------|---------|-----------------|---------------------|---------------|
12
+ | Alpha | @alpha | No | No (opt in with --release-notes) | Silent |
13
+ | Beta | @beta | No | Yes, prerelease (opt out with --no-release-notes) | Visible |
14
+ | Hotfix | @latest | No | Yes (opt out with --no-release-notes) | Visible |
15
+ | Stable | @latest | Yes, full deploy-public | Yes, full notes | Full deploy |
16
+
17
+ ### Version numbering
18
+
19
+ - Alpha: `1.9.68-alpha.1`, `1.9.68-alpha.2` (increments on repeat)
20
+ - Beta: `1.9.68-beta.1`, `1.9.68-beta.2` (increments on repeat)
21
+ - Hotfix: normal patch bump (`1.9.67` -> `1.9.68`)
22
+ - Stable: normal bump (patch/minor/major)
23
+
6
24
  ## Usage
7
25
 
8
26
  Run from inside any repo:
9
27
 
10
28
  ```bash
29
+ # Stable (existing behavior)
11
30
  wip-release patch # 1.0.0 -> 1.0.1
12
31
  wip-release minor # 1.0.0 -> 1.1.0
13
32
  wip-release major # 1.0.0 -> 2.0.0
14
33
 
34
+ # Alpha
35
+ wip-release alpha # 1.0.1-alpha.1 (npm @alpha, silent)
36
+ wip-release alpha --release-notes # 1.0.1-alpha.1 (npm @alpha + prerelease on public)
37
+
38
+ # Beta
39
+ wip-release beta # 1.0.1-beta.1 (npm @beta + prerelease on public)
40
+ wip-release beta --no-release-notes # 1.0.1-beta.1 (npm @beta, skip notes)
41
+
42
+ # Hotfix
43
+ wip-release hotfix # 1.0.0 -> 1.0.1 (npm @latest + release on public)
44
+ wip-release hotfix --no-release-notes # skip public release notes
45
+
46
+ # Common flags
15
47
  wip-release patch --notes="fix auth config" # with changelog note
16
48
  wip-release minor --dry-run # preview, no changes
17
49
  wip-release patch --no-publish # bump + tag only
@@ -19,6 +51,8 @@ wip-release patch --no-publish # bump + tag only
19
51
 
20
52
  ## What It Does
21
53
 
54
+ ### Stable pipeline
55
+
22
56
  ```
23
57
  wip-grok: 1.0.0 -> 1.0.1 (patch)
24
58
  ────────────────────────────────────────
@@ -28,32 +62,97 @@ wip-release patch --no-publish # bump + tag only
28
62
  ✓ Committed and tagged v1.0.1
29
63
  ✓ Pushed to remote
30
64
  ✓ Published to npm
31
- Published to GitHub Packages
65
+ - GitHub Packages: handled by deploy-public.sh
32
66
  ✓ GitHub release v1.0.1 created
33
67
  ✓ Published to ClawHub
34
68
 
35
69
  Done. wip-grok v1.0.1 released.
36
70
  ```
37
71
 
72
+ ### Alpha/beta pipeline
73
+
74
+ ```
75
+ wip-grok: 1.0.0 -> 1.0.1-alpha.1 (alpha)
76
+ ────────────────────────────────────────
77
+ ✓ package.json -> 1.0.1-alpha.1
78
+ ✓ CHANGELOG.md updated
79
+ ✓ Committed and tagged v1.0.1-alpha.1
80
+ ✓ Pushed to remote
81
+ ✓ Published to npm @alpha
82
+ - GitHub prerelease: skipped (silent alpha)
83
+
84
+ Done. wip-grok v1.0.1-alpha.1 (alpha) released.
85
+ ```
86
+
87
+ ### Hotfix pipeline
88
+
89
+ ```
90
+ wip-grok: 1.0.0 -> 1.0.1 (hotfix)
91
+ ────────────────────────────────────────
92
+ ✓ package.json -> 1.0.1
93
+ ✓ SKILL.md -> 1.0.1
94
+ ✓ CHANGELOG.md updated
95
+ ✓ Committed and tagged v1.0.1
96
+ ✓ Pushed to remote
97
+ ✓ Published to npm @latest
98
+ ✓ GitHub release v1.0.1 created on public repo
99
+ - deploy-public: skipped (hotfix)
100
+
101
+ Done. wip-grok v1.0.1 (hotfix) released.
102
+ ```
103
+
38
104
  ## Pipeline Steps
39
105
 
106
+ ### Stable (patch/minor/major)
107
+
40
108
  1. **Bump `package.json`** ... patch, minor, or major
41
109
  2. **Sync `SKILL.md`** ... updates version in YAML frontmatter (if file exists)
42
110
  3. **Update `CHANGELOG.md`** ... prepends new version entry with date and notes
43
111
  4. **Git commit + tag** ... commits changed files, creates `vX.Y.Z` tag
44
112
  5. **Push** ... pushes commit and tag to remote
45
- 6. **npm publish** ... publishes to npmjs.com (auth via 1Password)
46
- 7. **GitHub Packages** ... publishes to npm.pkg.github.com
47
- 8. **GitHub release** ... creates release with changelog notes
113
+ 6. **npm publish** ... publishes to npmjs.com with @latest (auth via 1Password)
114
+ 7. **GitHub Packages** ... handled by deploy-public.sh
115
+ 8. **GitHub release** ... creates release on private repo with changelog notes
48
116
  9. **ClawHub publish** ... publishes skill to ClawHub (if SKILL.md exists)
117
+ 10. **Branch cleanup** ... renames/prunes merged branches
118
+ 11. **Worktree cleanup** ... prunes merged worktrees
119
+
120
+ ### Alpha/Beta
121
+
122
+ 1. **Bump `package.json`** ... adds prerelease suffix (-alpha.N / -beta.N)
123
+ 2. **Update `CHANGELOG.md`** ... lightweight prerelease entry
124
+ 3. **Git commit + tag** ... commits changed files, creates tag
125
+ 4. **Push** ... pushes commit and tag to remote
126
+ 5. **npm publish** ... publishes with --tag alpha or --tag beta
127
+ 6. **GitHub prerelease** ... (beta: on by default, alpha: opt-in with --release-notes)
128
+
129
+ ### Hotfix
130
+
131
+ 1. **Bump `package.json`** ... patch bump (no suffix)
132
+ 2. **Sync `SKILL.md`** ... updates version in YAML frontmatter
133
+ 3. **Update `CHANGELOG.md`** ... prepends new version entry
134
+ 4. **Git commit + tag** ... commits changed files, creates tag
135
+ 5. **Push** ... pushes commit and tag to remote
136
+ 6. **npm publish** ... publishes with --tag latest
137
+ 7. **GitHub release** ... creates release on public repo (opt out with --no-release-notes)
138
+ 8. **ClawHub publish** ... publishes skill to ClawHub (if SKILL.md exists)
139
+ 9. **No deploy-public** ... code sync is skipped
49
140
 
50
141
  ## Flags
51
142
 
52
143
  | Flag | What |
53
144
  |------|------|
54
145
  | `--notes="text"` | Changelog entry text |
146
+ | `--notes-file=path` | Read release narrative from a markdown file |
147
+ | `--release-notes` | Opt in to public release notes (alpha only) |
148
+ | `--no-release-notes` | Opt out of public release notes (beta, hotfix) |
55
149
  | `--dry-run` | Show what would happen, change nothing |
56
150
  | `--no-publish` | Bump + tag only, skip npm and GitHub release |
151
+ | `--skip-product-check` | Skip product docs gate (stable only) |
152
+ | `--skip-stale-check` | Skip stale remote branch check (stable only) |
153
+ | `--skip-worktree-check` | Skip worktree guard |
154
+ | `--skip-tech-docs-check` | Skip technical docs check (stable only) |
155
+ | `--skip-coverage-check` | Skip interface coverage check (stable only) |
57
156
 
58
157
  ## Auth
59
158
 
@@ -66,15 +165,26 @@ Requires:
66
165
  - `gh` CLI authenticated (for GitHub Packages and releases)
67
166
  - `clawhub` CLI authenticated (for ClawHub skill publishing)
68
167
 
168
+ ## ldm install Integration
169
+
170
+ The installer checks different npm tags based on the track:
171
+
172
+ ```bash
173
+ ldm install # checks @latest (stable + hotfix)
174
+ ldm install --beta # checks @beta tag
175
+ ldm install --alpha # checks @alpha tag
176
+ ```
177
+
69
178
  ## As a Module
70
179
 
71
180
  ```javascript
72
- import { release, detectCurrentVersion, bumpSemver } from '@wipcomputer/wip-release';
181
+ import { release, releasePrerelease, releaseHotfix, detectCurrentVersion, bumpSemver, bumpPrerelease } from '@wipcomputer/wip-release';
73
182
 
74
183
  const current = detectCurrentVersion('/path/to/repo');
75
184
  const next = bumpSemver(current, 'minor');
76
185
  console.log(`${current} -> ${next}`);
77
186
 
187
+ // Stable release
78
188
  await release({
79
189
  repoPath: '/path/to/repo',
80
190
  level: 'patch',
@@ -82,19 +192,54 @@ await release({
82
192
  dryRun: false,
83
193
  noPublish: false,
84
194
  });
195
+
196
+ // Alpha prerelease
197
+ await releasePrerelease({
198
+ repoPath: '/path/to/repo',
199
+ track: 'alpha',
200
+ notes: 'testing new feature',
201
+ dryRun: false,
202
+ noPublish: false,
203
+ publishReleaseNotes: false,
204
+ });
205
+
206
+ // Beta prerelease
207
+ await releasePrerelease({
208
+ repoPath: '/path/to/repo',
209
+ track: 'beta',
210
+ notes: 'beta candidate',
211
+ dryRun: false,
212
+ noPublish: false,
213
+ publishReleaseNotes: true,
214
+ });
215
+
216
+ // Hotfix
217
+ await releaseHotfix({
218
+ repoPath: '/path/to/repo',
219
+ notes: 'critical fix',
220
+ dryRun: false,
221
+ noPublish: false,
222
+ publishReleaseNotes: true,
223
+ });
85
224
  ```
86
225
 
87
226
  ## Exports
88
227
 
89
228
  | Function | What |
90
229
  |----------|------|
91
- | `release({ repoPath, level, notes, dryRun, noPublish })` | Full pipeline |
230
+ | `release({ repoPath, level, notes, dryRun, noPublish })` | Full stable pipeline |
231
+ | `releasePrerelease({ repoPath, track, notes, dryRun, noPublish, publishReleaseNotes })` | Alpha/beta pipeline |
232
+ | `releaseHotfix({ repoPath, notes, dryRun, noPublish, publishReleaseNotes })` | Hotfix pipeline |
92
233
  | `detectCurrentVersion(repoPath)` | Read version from package.json |
93
- | `bumpSemver(version, level)` | Bump a semver string |
234
+ | `bumpSemver(version, level)` | Bump a semver string (patch/minor/major) |
235
+ | `bumpPrerelease(version, track)` | Bump a prerelease version (alpha/beta) |
94
236
  | `syncSkillVersion(repoPath, newVersion)` | Update SKILL.md frontmatter |
95
237
  | `updateChangelog(repoPath, newVersion, notes)` | Prepend to CHANGELOG.md |
96
- | `publishNpm(repoPath)` | Publish to npmjs.com |
238
+ | `publishNpm(repoPath)` | Publish to npmjs.com (@latest) |
239
+ | `publishNpmWithTag(repoPath, tag)` | Publish to npmjs.com with specific tag |
97
240
  | `publishGitHubPackages(repoPath)` | Publish to npm.pkg.github.com |
98
- | `createGitHubRelease(repoPath, newVersion, notes, currentVersion)` | Create GitHub release with rich notes |
241
+ | `createGitHubRelease(repoPath, newVersion, notes, currentVersion)` | Create GitHub release on private repo |
242
+ | `createGitHubPrerelease(repoPath, newVersion, notes)` | Create GitHub prerelease on public repo |
243
+ | `createGitHubReleaseOnPublic(repoPath, newVersion, notes, currentVersion)` | Create GitHub release on public repo |
99
244
  | `buildReleaseNotes(repoPath, currentVersion, newVersion, notes)` | Generate detailed release notes |
100
245
  | `publishClawHub(repoPath, newVersion, notes)` | Publish skill to ClawHub |
package/SKILL.md CHANGED
@@ -46,6 +46,8 @@ Local release pipeline. One command bumps version, updates all docs, publishes e
46
46
  - Releasing a new version of any @wipcomputer package
47
47
  - After merging a PR to main and you need to publish
48
48
  - Bumping version + changelog + SKILL.md in one step
49
+ - Publishing alpha or beta prereleases for testing
50
+ - Pushing hotfixes to npm without full deploy-public
49
51
 
50
52
  **Use --dry-run for:**
51
53
  - Previewing what a release would do before committing
@@ -55,18 +57,48 @@ Local release pipeline. One command bumps version, updates all docs, publishes e
55
57
 
56
58
  ### Do NOT Use For
57
59
 
58
- - Pre-release / alpha versions (not yet supported)
59
60
  - Repos without a package.json
60
61
 
62
+ ## Release Tracks
63
+
64
+ Four release tracks. Each has different behavior for npm tags, public repo sync, and release notes.
65
+
66
+ | Track | Command | npm tag | Public code sync | Public release notes |
67
+ |-------|---------|---------|-----------------|---------------------|
68
+ | Alpha | `wip-release alpha` | @alpha | No | No (opt in with --release-notes) |
69
+ | Beta | `wip-release beta` | @beta | No | Yes, prerelease (opt out with --no-release-notes) |
70
+ | Hotfix | `wip-release hotfix` | @latest | No | Yes (opt out with --no-release-notes) |
71
+ | Stable | `wip-release patch/minor/major` | @latest | Yes (deploy-public) | Yes, full notes |
72
+
73
+ ### Version numbering
74
+
75
+ - Alpha: `1.9.68-alpha.1`, `1.9.68-alpha.2`, etc.
76
+ - Beta: `1.9.68-beta.1`, `1.9.68-beta.2`, etc.
77
+ - Hotfix: normal version bump (patch)
78
+ - Stable: normal version bump (patch/minor/major)
79
+
61
80
  ## API Reference
62
81
 
63
82
  ### CLI
64
83
 
65
84
  ```bash
66
- wip-release patch --notes="fix X" # full pipeline
85
+ # Stable (existing behavior)
86
+ wip-release patch # full pipeline
67
87
  wip-release minor --dry-run # preview only
68
88
  wip-release major --no-publish # bump + tag only
69
89
  wip-release patch --skip-product-check # skip product docs gate
90
+
91
+ # Alpha
92
+ wip-release alpha # npm @alpha, no public notes
93
+ wip-release alpha --release-notes # npm @alpha + prerelease notes on public
94
+
95
+ # Beta
96
+ wip-release beta # npm @beta + prerelease notes on public
97
+ wip-release beta --no-release-notes # npm @beta, skip public notes
98
+
99
+ # Hotfix
100
+ wip-release hotfix # npm @latest + public release notes
101
+ wip-release hotfix --no-release-notes # npm @latest, skip public notes
70
102
  ```
71
103
 
72
104
  ### Product Docs Gate
@@ -108,9 +140,16 @@ After publishing, wip-release auto-copies SKILL.md to your website as plain text
108
140
  ### Module
109
141
 
110
142
  ```javascript
111
- import { release, detectCurrentVersion, bumpSemver, syncSkillVersion } from '@wipcomputer/wip-release';
143
+ import { release, releasePrerelease, releaseHotfix, detectCurrentVersion, bumpSemver, bumpPrerelease } from '@wipcomputer/wip-release';
112
144
 
145
+ // Stable release
113
146
  await release({ repoPath: '.', level: 'patch', notes: 'fix', dryRun: false, noPublish: false });
147
+
148
+ // Alpha/beta prerelease
149
+ await releasePrerelease({ repoPath: '.', track: 'alpha', notes: 'testing', dryRun: false, noPublish: false, publishReleaseNotes: false });
150
+
151
+ // Hotfix
152
+ await releaseHotfix({ repoPath: '.', notes: 'critical fix', dryRun: false, noPublish: false, publishReleaseNotes: true });
114
153
  ```
115
154
 
116
155
  ## Troubleshooting
package/cli.js CHANGED
@@ -5,10 +5,10 @@
5
5
  * Release tool CLI. Bumps version, updates docs, publishes.
6
6
  */
7
7
 
8
- import { release, detectCurrentVersion, collectMergedPRNotes } from './core.mjs';
8
+ import { release, releasePrerelease, releaseHotfix, detectCurrentVersion, collectMergedPRNotes } from './core.mjs';
9
9
 
10
10
  const args = process.argv.slice(2);
11
- const level = args.find(a => ['patch', 'minor', 'major'].includes(a));
11
+ const level = args.find(a => ['patch', 'minor', 'major', 'alpha', 'beta', 'hotfix'].includes(a));
12
12
 
13
13
  function flag(name) {
14
14
  const prefix = `--${name}=`;
@@ -23,6 +23,9 @@ 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');
27
+ const wantReleaseNotes = args.includes('--release-notes');
28
+ const noReleaseNotes = args.includes('--no-release-notes');
26
29
  const notesFilePath = flag('notes-file');
27
30
  let notes = flag('notes');
28
31
  // Bug fix #121: use strict check, not truthiness. --notes="" is empty, not absent.
@@ -51,13 +54,15 @@ let notesSource = (notes !== null && notes !== undefined && notes !== '') ? 'fla
51
54
  }
52
55
  notes = readFileSync(resolved, 'utf8').trim();
53
56
  notesSource = 'file';
54
- } else if (level) {
57
+ } else if (level && ['patch', 'minor', 'major', 'hotfix'].includes(level)) {
55
58
  // 2. Auto-detect RELEASE-NOTES-v{version}.md (ALWAYS checks, even if --notes provided)
59
+ // Only for stable levels and hotfix. Alpha/beta skip this.
56
60
  try {
57
61
  const { detectCurrentVersion, bumpSemver } = await import('./core.mjs');
58
62
  const cwd = process.cwd();
59
63
  const currentVersion = detectCurrentVersion(cwd);
60
- const newVersion = bumpSemver(currentVersion, level);
64
+ const bumpLevel = level === 'hotfix' ? 'patch' : level;
65
+ const newVersion = bumpSemver(currentVersion, bumpLevel);
61
66
  const dashed = newVersion.replace(/\./g, '-');
62
67
  const autoFile = join(cwd, `RELEASE-NOTES-v${dashed}.md`);
63
68
  if (existsSync(autoFile)) {
@@ -75,12 +80,13 @@ let notesSource = (notes !== null && notes !== undefined && notes !== '') ? 'fla
75
80
  // 2.5. Auto-combine release notes from merged PRs since last tag (#237)
76
81
  // Only runs when no single RELEASE-NOTES file was found on disk.
77
82
  // Scans git merge history for RELEASE-NOTES files committed on PR branches.
78
- if (level && notesSource !== 'file') {
83
+ if (level && ['patch', 'minor', 'major', 'hotfix'].includes(level) && notesSource !== 'file') {
79
84
  try {
80
85
  const { collectMergedPRNotes, detectCurrentVersion: dcv, bumpSemver: bs } = await import('./core.mjs');
81
86
  const cwd = process.cwd();
82
87
  const cv = dcv(cwd);
83
- const nv = bs(cv, level);
88
+ const bumpLevel = level === 'hotfix' ? 'patch' : level;
89
+ const nv = bs(cv, bumpLevel);
84
90
  const combined = collectMergedPRNotes(cwd, cv, nv);
85
91
  if (combined) {
86
92
  if (flagNotes && flagNotes !== combined.notes) {
@@ -144,27 +150,38 @@ if (!level || args.includes('--help') || args.includes('-h')) {
144
150
  console.log(`wip-release ... local release tool${current}
145
151
 
146
152
  Usage:
147
- wip-release patch 1.0.0 -> 1.0.1
148
- wip-release minor 1.0.0 -> 1.1.0
149
- wip-release major 1.0.0 -> 2.0.0
153
+ wip-release patch 1.0.0 -> 1.0.1 (stable)
154
+ wip-release minor 1.0.0 -> 1.1.0 (stable)
155
+ wip-release major 1.0.0 -> 2.0.0 (stable)
156
+ wip-release alpha 1.0.1-alpha.1 (prerelease)
157
+ wip-release beta 1.0.1-beta.1 (prerelease)
158
+ wip-release hotfix 1.0.0 -> 1.0.1 (hotfix, no deploy-public)
159
+
160
+ Release tracks:
161
+ alpha npm @alpha tag, no public notes (opt in with --release-notes)
162
+ beta npm @beta tag, prerelease notes on public (opt out with --no-release-notes)
163
+ hotfix npm @latest tag, release notes on public (opt out with --no-release-notes)
164
+ stable npm @latest tag, deploy-public, full notes (patch/minor/major)
150
165
 
151
166
  Flags:
152
167
  --notes="description" Release narrative (what was built and why)
153
168
  --notes-file=path Read release narrative from a markdown file
169
+ --release-notes Opt in to public release notes (alpha only)
170
+ --no-release-notes Opt out of public release notes (beta, hotfix)
154
171
  --dry-run Show what would happen, change nothing
155
172
  --no-publish Bump + tag only, skip npm/GitHub
156
173
  --skip-product-check Skip product docs check (dev update, roadmap, readme-first)
157
174
  --skip-stale-check Skip stale remote branch check
158
- --skip-worktree-check Skip worktree guard (allow release from worktree)
175
+ --skip-worktree-check Skip main-branch + worktree guard (break-glass only)
176
+ --allow-sub-tool-drift Allow release even if a sub-tool's files changed since the last tag without a version bump (error by default)
159
177
 
160
- Release notes (REQUIRED, must be a file on disk):
178
+ Release notes (REQUIRED for stable, optional for other tracks):
161
179
  1. --notes-file=path Explicit file path
162
180
  2. RELEASE-NOTES-v{ver}.md In repo root (auto-detected)
163
181
  3. Merged PR notes Auto-combined from git history (#237)
164
182
  4. ai/dev-updates/YYYY-MM-DD* Today's dev update (auto-detected)
165
- The --notes flag is NOT accepted. Write a file. Commit it on your branch.
166
- The file shows up in the PR diff so it can be reviewed before merge.
167
- When batching multiple PRs, each PR's RELEASE-NOTES are auto-combined.
183
+ For stable releases: the --notes flag is NOT accepted. Write a file.
184
+ For alpha/beta/hotfix: --notes="text" is accepted as a convenience.
168
185
 
169
186
  Skill publish to website:
170
187
  Add .publish-skill.json to repo root: { "name": "my-tool" }
@@ -172,7 +189,7 @@ Skill publish to website:
172
189
  After release, SKILL.md is copied to {website}/wip.computer/install/{name}.txt
173
190
  and deploy.sh is run to push to VPS.
174
191
 
175
- Pipeline:
192
+ Pipeline (stable):
176
193
  1. Bump package.json version
177
194
  2. Sync SKILL.md version (if exists)
178
195
  3. Update CHANGELOG.md
@@ -181,23 +198,68 @@ Pipeline:
181
198
  6. npm publish (via 1Password)
182
199
  7. GitHub Packages publish
183
200
  8. GitHub release create
184
- 9. Publish SKILL.md to website (if configured)`);
201
+ 9. Publish SKILL.md to website (if configured)
202
+
203
+ Pipeline (alpha/beta):
204
+ 1. Bump version with prerelease suffix (-alpha.N / -beta.N)
205
+ 2. npm publish with --tag alpha or --tag beta
206
+ 3. GitHub prerelease (beta default, alpha opt-in)
207
+
208
+ Pipeline (hotfix):
209
+ 1. Bump patch version (no suffix)
210
+ 2. npm publish with --tag latest
211
+ 3. GitHub release (no deploy-public)`);
185
212
  process.exit(level ? 0 : 1);
186
213
  }
187
214
 
188
- release({
189
- repoPath: process.cwd(),
190
- level,
191
- notes,
192
- notesSource,
193
- dryRun,
194
- noPublish,
195
- skipProductCheck,
196
- skipStaleCheck,
197
- skipWorktreeCheck,
198
- skipTechDocsCheck,
199
- skipCoverageCheck,
200
- }).catch(err => {
201
- console.error(` ${err.message}`);
202
- process.exit(1);
203
- });
215
+ // Route to the correct release function based on track
216
+ if (level === 'alpha' || level === 'beta') {
217
+ // Prerelease track: alpha or beta
218
+ releasePrerelease({
219
+ repoPath: process.cwd(),
220
+ track: level,
221
+ notes,
222
+ dryRun,
223
+ noPublish,
224
+ publishReleaseNotes: level === 'alpha' ? wantReleaseNotes : !noReleaseNotes,
225
+ skipWorktreeCheck,
226
+ allowSubToolDrift,
227
+ }).catch(err => {
228
+ console.error(` \u2717 ${err.message}`);
229
+ process.exit(1);
230
+ });
231
+ } else if (level === 'hotfix') {
232
+ // Hotfix track: patch bump, @latest tag, no deploy-public
233
+ releaseHotfix({
234
+ repoPath: process.cwd(),
235
+ notes,
236
+ notesSource,
237
+ dryRun,
238
+ noPublish,
239
+ publishReleaseNotes: !noReleaseNotes,
240
+ skipWorktreeCheck,
241
+ allowSubToolDrift,
242
+ }).catch(err => {
243
+ console.error(` \u2717 ${err.message}`);
244
+ process.exit(1);
245
+ });
246
+ } else {
247
+ // Stable track: patch, minor, major
248
+ release({
249
+ repoPath: process.cwd(),
250
+ level,
251
+ notes,
252
+ notesSource,
253
+ dryRun,
254
+ noPublish,
255
+ skipProductCheck,
256
+ skipStaleCheck,
257
+ skipWorktreeCheck,
258
+ skipTechDocsCheck,
259
+ skipCoverageCheck,
260
+ allowSubToolDrift,
261
+ }).catch(err => {
262
+ console.error(` \u2717 ${err.message}`);
263
+ process.exit(1);
264
+ });
265
+ }
package/core.mjs CHANGED
@@ -25,7 +25,8 @@ export function detectCurrentVersion(repoPath) {
25
25
  * Bump a semver string by level.
26
26
  */
27
27
  export function bumpSemver(version, level) {
28
- const [major, minor, patch] = version.split('.').map(Number);
28
+ const base = version.replace(/-.*$/, '');
29
+ const [major, minor, patch] = base.split('.').map(Number);
29
30
  switch (level) {
30
31
  case 'major': return `${major + 1}.0.0`;
31
32
  case 'minor': return `${major}.${minor + 1}.0`;
@@ -34,6 +35,37 @@ export function bumpSemver(version, level) {
34
35
  }
35
36
  }
36
37
 
38
+ /**
39
+ * Bump a version string for prerelease tracks (alpha, beta).
40
+ *
41
+ * If the current version already has the same prerelease prefix,
42
+ * increment the counter: 1.2.3-alpha.1 -> 1.2.3-alpha.2
43
+ *
44
+ * If the current version is a clean release or a different prerelease,
45
+ * bump patch and start at .1: 1.2.3 -> 1.2.4-alpha.1
46
+ */
47
+ export function bumpPrerelease(version, track) {
48
+ // Check if current version already has this prerelease prefix
49
+ const preMatch = version.match(new RegExp(`^(\\d+\\.\\d+\\.\\d+)-${track}\\.(\\d+)$`));
50
+ if (preMatch) {
51
+ // Same track: increment the counter
52
+ const base = preMatch[1];
53
+ const counter = parseInt(preMatch[2], 10);
54
+ return `${base}-${track}.${counter + 1}`;
55
+ }
56
+
57
+ // Strip any existing prerelease suffix to get the base version
58
+ const baseMatch = version.match(/^(\d+)\.(\d+)\.(\d+)/);
59
+ if (!baseMatch) throw new Error(`Cannot parse version: ${version}`);
60
+
61
+ const major = parseInt(baseMatch[1], 10);
62
+ const minor = parseInt(baseMatch[2], 10);
63
+ const patch = parseInt(baseMatch[3], 10);
64
+
65
+ // Bump patch and start prerelease at .1
66
+ return `${major}.${minor}.${patch + 1}-${track}.1`;
67
+ }
68
+
37
69
  /**
38
70
  * Write new version to package.json.
39
71
  */
@@ -200,6 +232,18 @@ export function publishNpm(repoPath) {
200
232
  ], { cwd: repoPath, stdio: 'inherit' });
201
233
  }
202
234
 
235
+ /**
236
+ * Publish to npm with a specific dist-tag (alpha, beta, latest).
237
+ */
238
+ export function publishNpmWithTag(repoPath, tag) {
239
+ const token = getNpmToken();
240
+ execFileSync('npm', [
241
+ 'publish', '--access', 'public',
242
+ '--tag', tag,
243
+ `--//registry.npmjs.org/:_authToken=${token}`
244
+ ], { cwd: repoPath, stdio: 'inherit' });
245
+ }
246
+
203
247
  /**
204
248
  * Publish to GitHub Packages.
205
249
  */
@@ -1018,6 +1062,61 @@ export function createGitHubRelease(repoPath, newVersion, notes, currentVersion)
1018
1062
  }
1019
1063
  }
1020
1064
 
1065
+ /**
1066
+ * Create a GitHub prerelease on the PUBLIC repo (no code sync).
1067
+ * Used by alpha (opt-in) and beta (default) tracks.
1068
+ */
1069
+ export function createGitHubPrerelease(repoPath, newVersion, notes) {
1070
+ const repoSlug = detectRepoSlug(repoPath);
1071
+ if (!repoSlug) throw new Error('Cannot detect repo slug from git remote');
1072
+
1073
+ // Target the public repo (strip -private suffix)
1074
+ const publicSlug = repoSlug.replace(/-private$/, '');
1075
+ const body = notes || `Prerelease ${newVersion}`;
1076
+
1077
+ const tmpFile = join(repoPath, '.release-notes-tmp.md');
1078
+ writeFileSync(tmpFile, body);
1079
+
1080
+ try {
1081
+ execFileSync('gh', [
1082
+ 'release', 'create', `v${newVersion}`,
1083
+ '--title', `v${newVersion}`,
1084
+ '--notes-file', '.release-notes-tmp.md',
1085
+ '--prerelease',
1086
+ '--repo', publicSlug
1087
+ ], { cwd: repoPath, stdio: 'inherit' });
1088
+ } finally {
1089
+ try { execFileSync('rm', ['-f', tmpFile]); } catch {}
1090
+ }
1091
+ }
1092
+
1093
+ /**
1094
+ * Create a GitHub release on the PUBLIC repo (no code sync).
1095
+ * Used by the hotfix track.
1096
+ */
1097
+ export function createGitHubReleaseOnPublic(repoPath, newVersion, notes, currentVersion) {
1098
+ const repoSlug = detectRepoSlug(repoPath);
1099
+ if (!repoSlug) throw new Error('Cannot detect repo slug from git remote');
1100
+
1101
+ // Target the public repo (strip -private suffix)
1102
+ const publicSlug = repoSlug.replace(/-private$/, '');
1103
+ const body = buildReleaseNotes(repoPath, currentVersion, newVersion, notes);
1104
+
1105
+ const tmpFile = join(repoPath, '.release-notes-tmp.md');
1106
+ writeFileSync(tmpFile, body);
1107
+
1108
+ try {
1109
+ execFileSync('gh', [
1110
+ 'release', 'create', `v${newVersion}`,
1111
+ '--title', `v${newVersion}`,
1112
+ '--notes-file', '.release-notes-tmp.md',
1113
+ '--repo', publicSlug
1114
+ ], { cwd: repoPath, stdio: 'inherit' });
1115
+ } finally {
1116
+ try { execFileSync('rm', ['-f', tmpFile]); } catch {}
1117
+ }
1118
+ }
1119
+
1021
1120
  /**
1022
1121
  * Publish skill to ClawHub.
1023
1122
  */
@@ -1224,10 +1323,210 @@ export function checkStaleBranches(repoPath, level) {
1224
1323
 
1225
1324
  // ── Main ────────────────────────────────────────────────────────────
1226
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
+
1227
1526
  /**
1228
1527
  * Run the full release pipeline.
1229
1528
  */
1230
- 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 }) {
1231
1530
  repoPath = repoPath || process.cwd();
1232
1531
  const currentVersion = detectCurrentVersion(repoPath);
1233
1532
  const newVersion = bumpSemver(currentVersion, level);
@@ -1237,33 +1536,15 @@ export async function release({ repoPath, level, notes, notesSource, dryRun, noP
1237
1536
  console.log(` ${repoName}: ${currentVersion} -> ${newVersion} (${level})`);
1238
1537
  console.log(` ${'─'.repeat(40)}`);
1239
1538
 
1240
- // -1. Worktree guard: block releases from linked worktrees
1241
- if (!skipWorktreeCheck) {
1242
- try {
1243
- const gitDir = execFileSync('git', ['rev-parse', '--git-dir'], {
1244
- cwd: repoPath, encoding: 'utf8'
1245
- }).trim();
1246
-
1247
- // Linked worktrees have "/worktrees/" in their git-dir path
1248
- if (gitDir.includes('/worktrees/')) {
1249
- // Get the main working tree path from `git worktree list`
1250
- const worktreeList = execFileSync('git', ['worktree', 'list', '--porcelain'], {
1251
- cwd: repoPath, encoding: 'utf8'
1252
- });
1253
- const mainWorktree = worktreeList.split('\n')
1254
- .find(line => line.startsWith('worktree '));
1255
- const mainPath = mainWorktree ? mainWorktree.replace('worktree ', '') : '(unknown)';
1256
-
1257
- console.log(` \u2717 wip-release must run from the main working tree, not a worktree.`);
1258
- console.log(` Current: ${repoPath}`);
1259
- console.log(` Main working tree: ${mainPath}`);
1260
- console.log(` Switch to the main working tree and run again.`);
1261
- console.log('');
1262
- return { currentVersion, newVersion, dryRun: false, failed: true };
1263
- }
1264
- console.log(' \u2713 Running from main working tree');
1265
- } catch {
1266
- // 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'}`);
1267
1548
  }
1268
1549
  }
1269
1550
 
@@ -1572,31 +1853,23 @@ export async function release({ repoPath, level, notes, notesSource, dryRun, noP
1572
1853
  return { currentVersion, newVersion, dryRun: true };
1573
1854
  }
1574
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
+
1575
1864
  // 1. Bump package.json
1576
1865
  writePackageVersion(repoPath, newVersion);
1577
1866
  console.log(` ✓ package.json -> ${newVersion}`);
1578
1867
 
1579
- // 1.5. Bump sub-tool versions in toolbox repos (tools/*/)
1580
- const toolsDir = join(repoPath, 'tools');
1581
- if (existsSync(toolsDir)) {
1582
- let subBumped = 0;
1583
- try {
1584
- const entries = readdirSync(toolsDir, { withFileTypes: true });
1585
- for (const entry of entries) {
1586
- if (!entry.isDirectory()) continue;
1587
- const subPkgPath = join(toolsDir, entry.name, 'package.json');
1588
- if (existsSync(subPkgPath)) {
1589
- try {
1590
- const subPkg = JSON.parse(readFileSync(subPkgPath, 'utf8'));
1591
- subPkg.version = newVersion;
1592
- writeFileSync(subPkgPath, JSON.stringify(subPkg, null, 2) + '\n');
1593
- subBumped++;
1594
- } catch {}
1595
- }
1596
- }
1597
- } catch {}
1598
- if (subBumped > 0) {
1599
- console.log(` ✓ ${subBumped} sub-tool(s) -> ${newVersion}`);
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 };
1600
1873
  }
1601
1874
  }
1602
1875
 
@@ -1900,3 +2173,382 @@ export async function release({ repoPath, level, notes, notesSource, dryRun, noP
1900
2173
 
1901
2174
  return { currentVersion, newVersion, dryRun: false };
1902
2175
  }
2176
+
2177
+ // ── Prerelease Track (Alpha / Beta) ────────────────────────────────
2178
+
2179
+ /**
2180
+ * Release an alpha or beta prerelease.
2181
+ *
2182
+ * Alpha: npm @alpha, no public release notes by default (opt in with publishReleaseNotes).
2183
+ * Beta: npm @beta, prerelease notes on public repo by default (opt out with publishReleaseNotes=false).
2184
+ *
2185
+ * No deploy-public. No code sync. No CHANGELOG gate. No product docs gate.
2186
+ * Lightweight: bump version, npm publish with tag, optional GitHub prerelease.
2187
+ */
2188
+ export async function releasePrerelease({ repoPath, track, notes, dryRun, noPublish, publishReleaseNotes, skipWorktreeCheck, allowSubToolDrift }) {
2189
+ repoPath = repoPath || process.cwd();
2190
+ const currentVersion = detectCurrentVersion(repoPath);
2191
+ const newVersion = bumpPrerelease(currentVersion, track);
2192
+ const repoName = basename(repoPath);
2193
+
2194
+ console.log('');
2195
+ console.log(` ${repoName}: ${currentVersion} -> ${newVersion} (${track})`);
2196
+ console.log(` ${'─'.repeat(40)}`);
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
+
2212
+ if (dryRun) {
2213
+ console.log(` [dry run] Would bump package.json to ${newVersion}`);
2214
+ if (!noPublish) {
2215
+ console.log(` [dry run] Would npm publish with --tag ${track}`);
2216
+ if (publishReleaseNotes) {
2217
+ console.log(` [dry run] Would create GitHub prerelease v${newVersion} on public repo`);
2218
+ } else {
2219
+ console.log(` [dry run] No GitHub prerelease (silent)`);
2220
+ }
2221
+ }
2222
+ console.log('');
2223
+ console.log(` Dry run complete. No changes made.`);
2224
+ console.log('');
2225
+ return { currentVersion, newVersion, dryRun: true };
2226
+ }
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
+
2236
+ // 1. Bump package.json
2237
+ writePackageVersion(repoPath, newVersion);
2238
+ console.log(` \u2713 package.json -> ${newVersion}`);
2239
+
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 };
2245
+ }
2246
+ }
2247
+
2248
+ // 2. Update CHANGELOG.md (lightweight entry)
2249
+ updateChangelog(repoPath, newVersion, notes || `${track} prerelease`);
2250
+ console.log(` \u2713 CHANGELOG.md updated`);
2251
+
2252
+ // 3. Git commit + tag
2253
+ const msg = `v${newVersion}: ${track} prerelease`;
2254
+ for (const f of ['package.json', 'CHANGELOG.md']) {
2255
+ if (existsSync(join(repoPath, f))) {
2256
+ execFileSync('git', ['add', f], { cwd: repoPath, stdio: 'pipe' });
2257
+ }
2258
+ }
2259
+ execFileSync('git', ['commit', '--no-verify', '-m', msg], { cwd: repoPath, stdio: 'pipe' });
2260
+ execFileSync('git', ['tag', `v${newVersion}`], { cwd: repoPath, stdio: 'pipe' });
2261
+ console.log(` \u2713 Committed and tagged v${newVersion}`);
2262
+
2263
+ // 4. Push commit + tag
2264
+ try {
2265
+ execSync('git push && git push --tags', { cwd: repoPath, stdio: 'pipe' });
2266
+ console.log(` \u2713 Pushed to remote`);
2267
+ } catch {
2268
+ console.log(` ! Push failed. Push manually.`);
2269
+ }
2270
+
2271
+ const distResults = [];
2272
+
2273
+ if (!noPublish) {
2274
+ // 5. npm publish with dist-tag
2275
+ try {
2276
+ publishNpmWithTag(repoPath, track);
2277
+ const pkg = JSON.parse(readFileSync(join(repoPath, 'package.json'), 'utf8'));
2278
+ distResults.push({ target: 'npm', status: 'ok', detail: `${pkg.name}@${newVersion} (tag: ${track})` });
2279
+ console.log(` \u2713 Published to npm @${track}`);
2280
+ } catch (e) {
2281
+ distResults.push({ target: 'npm', status: 'failed', detail: e.message });
2282
+ console.log(` \u2717 npm publish failed: ${e.message}`);
2283
+ }
2284
+
2285
+ // 6. GitHub prerelease on public repo (if opted in)
2286
+ if (publishReleaseNotes) {
2287
+ try {
2288
+ createGitHubPrerelease(repoPath, newVersion, notes || `${track} prerelease`);
2289
+ distResults.push({ target: 'GitHub', status: 'ok', detail: `v${newVersion} (prerelease)` });
2290
+ console.log(` \u2713 GitHub prerelease v${newVersion} created on public repo`);
2291
+ } catch (e) {
2292
+ distResults.push({ target: 'GitHub', status: 'failed', detail: e.message });
2293
+ console.log(` \u2717 GitHub prerelease failed: ${e.message}`);
2294
+ }
2295
+ } else {
2296
+ console.log(` - GitHub prerelease: skipped (silent ${track})`);
2297
+ }
2298
+ }
2299
+
2300
+ // Distribution summary
2301
+ if (distResults.length > 0) {
2302
+ console.log('');
2303
+ console.log(' Distribution:');
2304
+ for (const r of distResults) {
2305
+ const icon = r.status === 'ok' ? '\u2713' : '\u2717';
2306
+ console.log(` ${icon} ${r.target}: ${r.detail}`);
2307
+ }
2308
+ }
2309
+
2310
+ console.log('');
2311
+ console.log(` Done. ${repoName} v${newVersion} (${track}) released.`);
2312
+ console.log('');
2313
+
2314
+ return { currentVersion, newVersion, dryRun: false };
2315
+ }
2316
+
2317
+ // ── Hotfix Track ────────────────────────────────────────────────────
2318
+
2319
+ /**
2320
+ * Release a hotfix.
2321
+ *
2322
+ * Same as stable patch but: no deploy-public, no code sync.
2323
+ * Publishes to npm @latest, creates GitHub release on public repo (opt out with publishReleaseNotes=false).
2324
+ *
2325
+ * Lighter gates than stable: no product docs check, no stale branch check.
2326
+ * Still runs: worktree guard, license compliance, tests.
2327
+ */
2328
+ export async function releaseHotfix({ repoPath, notes, notesSource, dryRun, noPublish, publishReleaseNotes, skipWorktreeCheck, allowSubToolDrift }) {
2329
+ repoPath = repoPath || process.cwd();
2330
+ const currentVersion = detectCurrentVersion(repoPath);
2331
+ const newVersion = bumpSemver(currentVersion, 'patch');
2332
+ const repoName = basename(repoPath);
2333
+
2334
+ console.log('');
2335
+ console.log(` ${repoName}: ${currentVersion} -> ${newVersion} (hotfix)`);
2336
+ console.log(` ${'─'.repeat(40)}`);
2337
+
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
+ }
2348
+ }
2349
+
2350
+ // License compliance gate
2351
+ const configPath = join(repoPath, '.license-guard.json');
2352
+ if (existsSync(configPath)) {
2353
+ const config = JSON.parse(readFileSync(configPath, 'utf8'));
2354
+ const licenseIssues = [];
2355
+ const licensePath = join(repoPath, 'LICENSE');
2356
+ if (!existsSync(licensePath)) {
2357
+ licenseIssues.push('LICENSE file is missing');
2358
+ } else {
2359
+ const licenseText = readFileSync(licensePath, 'utf8');
2360
+ if (!licenseText.includes(config.copyright)) {
2361
+ licenseIssues.push(`LICENSE copyright does not match "${config.copyright}"`);
2362
+ }
2363
+ }
2364
+ if (licenseIssues.length > 0) {
2365
+ console.log(` \u2717 License compliance failed:`);
2366
+ for (const issue of licenseIssues) console.log(` - ${issue}`);
2367
+ console.log('');
2368
+ return { currentVersion, newVersion, dryRun: false, failed: true };
2369
+ }
2370
+ console.log(` \u2713 License compliance passed`);
2371
+ }
2372
+
2373
+ // Release notes: hotfix accepts --notes flag as convenience (no file-only gate)
2374
+ if (!notes) {
2375
+ console.log(` ! No release notes provided. Hotfix will have minimal notes.`);
2376
+ notes = 'Hotfix release.';
2377
+ }
2378
+
2379
+ // Run tests if they exist
2380
+ {
2381
+ const toolsDir = join(repoPath, 'tools');
2382
+ const testFiles = [];
2383
+ if (existsSync(toolsDir)) {
2384
+ for (const sub of readdirSync(toolsDir)) {
2385
+ const testPath = join(toolsDir, sub, 'test.sh');
2386
+ if (existsSync(testPath)) testFiles.push({ tool: sub, path: testPath });
2387
+ }
2388
+ }
2389
+ const rootTest = join(repoPath, 'test.sh');
2390
+ if (existsSync(rootTest)) testFiles.push({ tool: '(root)', path: rootTest });
2391
+
2392
+ if (testFiles.length > 0) {
2393
+ let allPassed = true;
2394
+ for (const { tool, path } of testFiles) {
2395
+ try {
2396
+ execFileSync('bash', [path], { cwd: dirname(path), stdio: 'pipe', timeout: 30000 });
2397
+ console.log(` \u2713 Tests passed: ${tool}`);
2398
+ } catch (e) {
2399
+ allPassed = false;
2400
+ console.log(` \u2717 Tests FAILED: ${tool}`);
2401
+ const output = (e.stdout || '').toString().trim();
2402
+ if (output) {
2403
+ for (const line of output.split('\n').slice(-5)) console.log(` ${line}`);
2404
+ }
2405
+ }
2406
+ }
2407
+ if (!allPassed) {
2408
+ console.log('');
2409
+ console.log(' Fix failing tests before releasing.');
2410
+ console.log('');
2411
+ return { currentVersion, newVersion, dryRun: false, failed: true };
2412
+ }
2413
+ }
2414
+ }
2415
+
2416
+ if (dryRun) {
2417
+ console.log(` [dry run] Would bump package.json to ${newVersion}`);
2418
+ console.log(` [dry run] Would update CHANGELOG.md`);
2419
+ if (!noPublish) {
2420
+ console.log(` [dry run] Would npm publish with --tag latest`);
2421
+ if (publishReleaseNotes) {
2422
+ console.log(` [dry run] Would create GitHub release v${newVersion} on public repo`);
2423
+ } else {
2424
+ console.log(` [dry run] No GitHub release (--no-release-notes)`);
2425
+ }
2426
+ console.log(` [dry run] No deploy-public (hotfix)`);
2427
+ }
2428
+ console.log('');
2429
+ console.log(` Dry run complete. No changes made.`);
2430
+ console.log('');
2431
+ return { currentVersion, newVersion, dryRun: true };
2432
+ }
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
+
2442
+ // 1. Bump package.json
2443
+ writePackageVersion(repoPath, newVersion);
2444
+ console.log(` \u2713 package.json -> ${newVersion}`);
2445
+
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 };
2451
+ }
2452
+ }
2453
+
2454
+ // 2. Sync SKILL.md
2455
+ if (syncSkillVersion(repoPath, newVersion)) {
2456
+ console.log(` \u2713 SKILL.md -> ${newVersion}`);
2457
+ }
2458
+
2459
+ // 3. Update CHANGELOG.md
2460
+ updateChangelog(repoPath, newVersion, notes);
2461
+ console.log(` \u2713 CHANGELOG.md updated`);
2462
+
2463
+ // 3.5. Move RELEASE-NOTES-v*.md to _trash/
2464
+ const trashed = trashReleaseNotes(repoPath);
2465
+ if (trashed > 0) {
2466
+ console.log(` \u2713 Moved ${trashed} RELEASE-NOTES file(s) to _trash/`);
2467
+ }
2468
+
2469
+ // 4. Git commit + tag
2470
+ gitCommitAndTag(repoPath, newVersion, notes);
2471
+ console.log(` \u2713 Committed and tagged v${newVersion}`);
2472
+
2473
+ // 5. Push commit + tag
2474
+ try {
2475
+ execSync('git push && git push --tags', { cwd: repoPath, stdio: 'pipe' });
2476
+ console.log(` \u2713 Pushed to remote`);
2477
+ } catch {
2478
+ console.log(` ! Push failed. Push manually.`);
2479
+ }
2480
+
2481
+ const distResults = [];
2482
+
2483
+ if (!noPublish) {
2484
+ // 6. npm publish with @latest tag
2485
+ try {
2486
+ publishNpmWithTag(repoPath, 'latest');
2487
+ const pkg = JSON.parse(readFileSync(join(repoPath, 'package.json'), 'utf8'));
2488
+ distResults.push({ target: 'npm', status: 'ok', detail: `${pkg.name}@${newVersion}` });
2489
+ console.log(` \u2713 Published to npm @latest`);
2490
+ } catch (e) {
2491
+ distResults.push({ target: 'npm', status: 'failed', detail: e.message });
2492
+ console.log(` \u2717 npm publish failed: ${e.message}`);
2493
+ }
2494
+
2495
+ // 7. GitHub release on public repo (not prerelease)
2496
+ if (publishReleaseNotes) {
2497
+ try {
2498
+ createGitHubReleaseOnPublic(repoPath, newVersion, notes, currentVersion);
2499
+ distResults.push({ target: 'GitHub', status: 'ok', detail: `v${newVersion}` });
2500
+ console.log(` \u2713 GitHub release v${newVersion} created on public repo`);
2501
+ } catch (e) {
2502
+ distResults.push({ target: 'GitHub', status: 'failed', detail: e.message });
2503
+ console.log(` \u2717 GitHub release failed: ${e.message}`);
2504
+ }
2505
+ } else {
2506
+ console.log(` - GitHub release: skipped (--no-release-notes)`);
2507
+ }
2508
+
2509
+ // No deploy-public for hotfix
2510
+ console.log(` - deploy-public: skipped (hotfix)`);
2511
+
2512
+ // 8. ClawHub skill publish
2513
+ const rootSkill = join(repoPath, 'SKILL.md');
2514
+ if (existsSync(rootSkill)) {
2515
+ try {
2516
+ publishClawHub(repoPath, newVersion, notes);
2517
+ const slug = detectSkillSlug(repoPath);
2518
+ distResults.push({ target: 'ClawHub', status: 'ok', detail: `${slug}@${newVersion}` });
2519
+ console.log(` \u2713 Published to ClawHub: ${slug}`);
2520
+ } catch (e) {
2521
+ distResults.push({ target: 'ClawHub', status: 'failed', detail: e.message });
2522
+ console.log(` \u2717 ClawHub publish failed: ${e.message}`);
2523
+ }
2524
+ }
2525
+ }
2526
+
2527
+ // Distribution summary
2528
+ if (distResults.length > 0) {
2529
+ console.log('');
2530
+ console.log(' Distribution:');
2531
+ for (const r of distResults) {
2532
+ const icon = r.status === 'ok' ? '\u2713' : '\u2717';
2533
+ console.log(` ${icon} ${r.target}: ${r.detail}`);
2534
+ }
2535
+ }
2536
+
2537
+ // Write release marker
2538
+ try {
2539
+ const markerDir = join(process.env.HOME || '', '.ldm', 'state');
2540
+ mkdirSync(markerDir, { recursive: true });
2541
+ writeFileSync(join(markerDir, '.last-release'), JSON.stringify({
2542
+ repo: repoName,
2543
+ version: newVersion,
2544
+ timestamp: new Date().toISOString(),
2545
+ track: 'hotfix',
2546
+ }) + '\n');
2547
+ } catch {}
2548
+
2549
+ console.log('');
2550
+ console.log(` Done. ${repoName} v${newVersion} (hotfix) released.`);
2551
+ console.log('');
2552
+
2553
+ return { currentVersion, newVersion, dryRun: false };
2554
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wipcomputer/wip-release",
3
- "version": "1.9.67",
3
+ "version": "1.9.72",
4
4
  "type": "module",
5
5
  "description": "One-command release pipeline. Bumps version, updates changelog + SKILL.md, publishes to npm + GitHub.",
6
6
  "main": "core.mjs",