@wipcomputer/wip-release 1.2.4 → 1.9.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 +0 -3
- package/LICENSE +33 -2
- package/README.md +8 -3
- package/SKILL.md +50 -15
- package/cli.js +77 -2
- package/core.mjs +641 -38
- package/mcp-server.mjs +109 -0
- package/package.json +6 -3
package/CHANGELOG.md
CHANGED
package/LICENSE
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
|
-
MIT
|
|
1
|
+
Dual License: MIT + AGPLv3
|
|
2
2
|
|
|
3
|
-
Copyright (c) 2026
|
|
3
|
+
Copyright (c) 2026 WIP Computer, Inc.
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
1. MIT License (local and personal use)
|
|
7
|
+
---------------------------------------
|
|
4
8
|
|
|
5
9
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
10
|
of this software and associated documentation files (the "Software"), to deal
|
|
@@ -19,3 +23,30 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
|
19
23
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
24
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
25
|
SOFTWARE.
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
2. GNU Affero General Public License v3.0 (commercial and cloud use)
|
|
29
|
+
--------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
If you run this software as part of a hosted service, cloud platform,
|
|
32
|
+
marketplace listing, or any network-accessible offering for commercial
|
|
33
|
+
purposes, the AGPLv3 terms apply. You must either:
|
|
34
|
+
|
|
35
|
+
a) Release your complete source code under AGPLv3, or
|
|
36
|
+
b) Obtain a commercial license.
|
|
37
|
+
|
|
38
|
+
This program is free software: you can redistribute it and/or modify
|
|
39
|
+
it under the terms of the GNU Affero General Public License as published
|
|
40
|
+
by the Free Software Foundation, either version 3 of the License, or
|
|
41
|
+
(at your option) any later version.
|
|
42
|
+
|
|
43
|
+
This program is distributed in the hope that it will be useful,
|
|
44
|
+
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
45
|
+
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
46
|
+
GNU Affero General Public License for more details.
|
|
47
|
+
|
|
48
|
+
You should have received a copy of the GNU Affero General Public License
|
|
49
|
+
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
AGPLv3 for personal use is free. Commercial licenses available.
|
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
You ship a fix. Now you have to bump package.json, update CHANGELOG.md, sync the version in SKILL.md, commit, tag, push, publish to npm, publish to GitHub Packages, and create a GitHub release. Every time. Miss a step and versions drift.
|
|
8
8
|
|
|
9
|
-
`wip-release` does all of it in one command.
|
|
9
|
+
`wip-release` does all of it in one command. It also checks that product docs (dev update, roadmap, readme-first) are up to date before publishing. Patches get a warning. Minor and major releases are blocked until docs are updated.
|
|
10
10
|
|
|
11
11
|
## Install
|
|
12
12
|
|
|
@@ -35,6 +35,11 @@ See [REFERENCE.md](REFERENCE.md) for full usage, pipeline steps, flags, auth, an
|
|
|
35
35
|
|
|
36
36
|
## License
|
|
37
37
|
|
|
38
|
-
|
|
38
|
+
```
|
|
39
|
+
CLI, MCP server, skills MIT (use anywhere, no restrictions)
|
|
40
|
+
Hosted or cloud service use AGPL (network service distribution)
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
AGPL for personal use is free.
|
|
39
44
|
|
|
40
|
-
Built by Parker Todd Brooks,
|
|
45
|
+
Built by Parker Todd Brooks, Lēsa (OpenClaw, Claude Opus 4.6), Claude Code (Claude Opus 4.6).
|
package/SKILL.md
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
---
|
|
2
|
-
name:
|
|
3
|
-
version: 1.2.4
|
|
2
|
+
name: wip-release
|
|
4
3
|
description: One-command release pipeline. Bumps version, updates changelog + SKILL.md, publishes to npm + GitHub.
|
|
5
|
-
|
|
4
|
+
license: MIT
|
|
5
|
+
interface: [cli, module, mcp]
|
|
6
6
|
metadata:
|
|
7
|
+
display-name: "Release Pipeline"
|
|
8
|
+
version: "1.2.4"
|
|
9
|
+
homepage: "https://github.com/wipcomputer/wip-release"
|
|
10
|
+
author: "Parker Todd Brooks"
|
|
7
11
|
category: dev-tools
|
|
8
12
|
capabilities:
|
|
9
13
|
- version-bump
|
|
@@ -11,22 +15,25 @@ metadata:
|
|
|
11
15
|
- skill-sync
|
|
12
16
|
- npm-publish
|
|
13
17
|
- github-release
|
|
14
|
-
dependencies: []
|
|
15
|
-
interface: CLI
|
|
16
18
|
requires:
|
|
17
|
-
|
|
19
|
+
bins: [git, npm, gh, op, clawhub]
|
|
18
20
|
secrets:
|
|
19
21
|
- path: ~/.openclaw/secrets/op-sa-token
|
|
20
22
|
description: 1Password service account token
|
|
21
23
|
- vault: Agent Secrets
|
|
22
24
|
item: npm Token
|
|
23
25
|
description: npm publish token
|
|
24
|
-
openclaw:
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
26
|
+
openclaw:
|
|
27
|
+
requires:
|
|
28
|
+
bins: [git, npm, gh, op]
|
|
29
|
+
install:
|
|
30
|
+
- id: node
|
|
31
|
+
kind: node
|
|
32
|
+
package: "@wipcomputer/wip-release"
|
|
33
|
+
bins: [wip-release]
|
|
34
|
+
label: "Install via npm"
|
|
35
|
+
emoji: "🚀"
|
|
36
|
+
compatibility: Requires git, npm, gh, op (1Password CLI). Node.js 18+.
|
|
30
37
|
---
|
|
31
38
|
|
|
32
39
|
# wip-release
|
|
@@ -56,11 +63,25 @@ Local release pipeline. One command bumps version, updates all docs, publishes e
|
|
|
56
63
|
### CLI
|
|
57
64
|
|
|
58
65
|
```bash
|
|
59
|
-
wip-release patch --notes="fix X"
|
|
60
|
-
wip-release minor --dry-run
|
|
61
|
-
wip-release major --no-publish
|
|
66
|
+
wip-release patch --notes="fix X" # full pipeline
|
|
67
|
+
wip-release minor --dry-run # preview only
|
|
68
|
+
wip-release major --no-publish # bump + tag only
|
|
69
|
+
wip-release patch --skip-product-check # skip product docs gate
|
|
62
70
|
```
|
|
63
71
|
|
|
72
|
+
### Product Docs Gate
|
|
73
|
+
|
|
74
|
+
wip-release checks that product docs (dev update, roadmap, readme-first) were updated before publishing. Only runs on repos with an `ai/` directory.
|
|
75
|
+
|
|
76
|
+
- **patch**: warns if product docs are stale (non-blocking)
|
|
77
|
+
- **minor/major**: blocks release until product docs are updated
|
|
78
|
+
- **--skip-product-check**: bypasses the gate
|
|
79
|
+
|
|
80
|
+
Checks:
|
|
81
|
+
1. `ai/dev-updates/` has a file from the last 3 days
|
|
82
|
+
2. `ai/product/plans-prds/roadmap.md` was modified since last release
|
|
83
|
+
3. `ai/product/readme-first-product.md` was modified since last release
|
|
84
|
+
|
|
64
85
|
### Module
|
|
65
86
|
|
|
66
87
|
```javascript
|
|
@@ -79,3 +100,17 @@ Branch protection may prevent direct pushes. Make sure you're on main after merg
|
|
|
79
100
|
|
|
80
101
|
### SKILL.md not updated
|
|
81
102
|
Only updates if the file has a YAML frontmatter `version:` field between `---` markers.
|
|
103
|
+
|
|
104
|
+
## MCP
|
|
105
|
+
|
|
106
|
+
Tools: `release`, `release_status`
|
|
107
|
+
|
|
108
|
+
Add to `.mcp.json`:
|
|
109
|
+
```json
|
|
110
|
+
{
|
|
111
|
+
"wip-release": {
|
|
112
|
+
"command": "node",
|
|
113
|
+
"args": ["/path/to/tools/wip-release/mcp-server.mjs"]
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
```
|
package/cli.js
CHANGED
|
@@ -18,7 +18,69 @@ function flag(name) {
|
|
|
18
18
|
|
|
19
19
|
const dryRun = args.includes('--dry-run');
|
|
20
20
|
const noPublish = args.includes('--no-publish');
|
|
21
|
-
const
|
|
21
|
+
const skipProductCheck = args.includes('--skip-product-check');
|
|
22
|
+
const skipStaleCheck = args.includes('--skip-stale-check');
|
|
23
|
+
const notesFilePath = flag('notes-file');
|
|
24
|
+
let notes = flag('notes');
|
|
25
|
+
let notesSource = notes ? 'flag' : 'none'; // track where notes came from
|
|
26
|
+
|
|
27
|
+
// Auto-detect RELEASE-NOTES-v{version}.md if no --notes or --notes-file provided.
|
|
28
|
+
// Also supports explicit --notes-file for custom paths.
|
|
29
|
+
{
|
|
30
|
+
const { readFileSync, existsSync } = await import('node:fs');
|
|
31
|
+
const { resolve, join } = await import('node:path');
|
|
32
|
+
|
|
33
|
+
if (notesFilePath) {
|
|
34
|
+
// Explicit --notes-file
|
|
35
|
+
const resolved = resolve(notesFilePath);
|
|
36
|
+
if (!existsSync(resolved)) {
|
|
37
|
+
console.error(` ✗ Notes file not found: ${resolved}`);
|
|
38
|
+
process.exit(1);
|
|
39
|
+
}
|
|
40
|
+
notes = readFileSync(resolved, 'utf8').trim();
|
|
41
|
+
notesSource = 'file';
|
|
42
|
+
} else if (!notes && level) {
|
|
43
|
+
// Auto-detect: compute the next version and look for RELEASE-NOTES-v{version}.md
|
|
44
|
+
try {
|
|
45
|
+
const { detectCurrentVersion, bumpSemver } = await import('./core.mjs');
|
|
46
|
+
const cwd = process.cwd();
|
|
47
|
+
const currentVersion = detectCurrentVersion(cwd);
|
|
48
|
+
const newVersion = bumpSemver(currentVersion, level);
|
|
49
|
+
const dashed = newVersion.replace(/\./g, '-');
|
|
50
|
+
const autoFile = join(cwd, `RELEASE-NOTES-v${dashed}.md`);
|
|
51
|
+
if (existsSync(autoFile)) {
|
|
52
|
+
notes = readFileSync(autoFile, 'utf8').trim();
|
|
53
|
+
notesSource = 'file';
|
|
54
|
+
console.log(` ✓ Found RELEASE-NOTES-v${dashed}.md`);
|
|
55
|
+
}
|
|
56
|
+
} catch {}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Auto-detect dev update from ai/dev-updates/ if notes are missing or thin
|
|
60
|
+
if (level && (!notes || notes.length < 100)) {
|
|
61
|
+
try {
|
|
62
|
+
const { readdirSync } = await import('node:fs');
|
|
63
|
+
const devUpdatesDir = join(process.cwd(), 'ai', 'dev-updates');
|
|
64
|
+
if (existsSync(devUpdatesDir)) {
|
|
65
|
+
const today = new Date().toISOString().split('T')[0];
|
|
66
|
+
const todayFiles = readdirSync(devUpdatesDir)
|
|
67
|
+
.filter(f => f.startsWith(today) && f.endsWith('.md'))
|
|
68
|
+
.sort()
|
|
69
|
+
.reverse();
|
|
70
|
+
|
|
71
|
+
if (todayFiles.length > 0) {
|
|
72
|
+
const devUpdatePath = join(devUpdatesDir, todayFiles[0]);
|
|
73
|
+
const devUpdateContent = readFileSync(devUpdatePath, 'utf8').trim();
|
|
74
|
+
if (devUpdateContent.length > (notes || '').length) {
|
|
75
|
+
notes = devUpdateContent;
|
|
76
|
+
notesSource = 'dev-update';
|
|
77
|
+
console.log(` ✓ Found dev update: ai/dev-updates/${todayFiles[0]}`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
} catch {}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
22
84
|
|
|
23
85
|
if (!level || args.includes('--help') || args.includes('-h')) {
|
|
24
86
|
const cwd = process.cwd();
|
|
@@ -33,9 +95,19 @@ Usage:
|
|
|
33
95
|
wip-release major 1.0.0 -> 2.0.0
|
|
34
96
|
|
|
35
97
|
Flags:
|
|
36
|
-
--notes="description"
|
|
98
|
+
--notes="description" Release narrative (what was built and why)
|
|
99
|
+
--notes-file=path Read release narrative from a markdown file
|
|
37
100
|
--dry-run Show what would happen, change nothing
|
|
38
101
|
--no-publish Bump + tag only, skip npm/GitHub
|
|
102
|
+
--skip-product-check Skip product docs check (dev update, roadmap, readme-first)
|
|
103
|
+
--skip-stale-check Skip stale remote branch check
|
|
104
|
+
|
|
105
|
+
Release notes:
|
|
106
|
+
Auto-detects notes from three sources (first match wins):
|
|
107
|
+
1. --notes-file=path Explicit file path
|
|
108
|
+
2. RELEASE-NOTES-v{ver}.md In repo root (e.g. RELEASE-NOTES-v1-7-4.md)
|
|
109
|
+
3. ai/dev-updates/YYYY-MM-DD* Today's dev update files (most recent first)
|
|
110
|
+
Write dev updates as you work. wip-release picks them up automatically.
|
|
39
111
|
|
|
40
112
|
Pipeline:
|
|
41
113
|
1. Bump package.json version
|
|
@@ -53,8 +125,11 @@ release({
|
|
|
53
125
|
repoPath: process.cwd(),
|
|
54
126
|
level,
|
|
55
127
|
notes,
|
|
128
|
+
notesSource,
|
|
56
129
|
dryRun,
|
|
57
130
|
noPublish,
|
|
131
|
+
skipProductCheck,
|
|
132
|
+
skipStaleCheck,
|
|
58
133
|
}).catch(err => {
|
|
59
134
|
console.error(` ✗ ${err.message}`);
|
|
60
135
|
process.exit(1);
|
package/core.mjs
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { execSync, execFileSync } from 'node:child_process';
|
|
9
|
-
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
9
|
+
import { readFileSync, writeFileSync, existsSync, readdirSync, mkdirSync, renameSync } from 'node:fs';
|
|
10
10
|
import { join, basename } from 'node:path';
|
|
11
11
|
|
|
12
12
|
// ── Version ─────────────────────────────────────────────────────────
|
|
@@ -54,10 +54,27 @@ export function syncSkillVersion(repoPath, newVersion) {
|
|
|
54
54
|
if (!existsSync(skillPath)) return false;
|
|
55
55
|
|
|
56
56
|
let content = readFileSync(skillPath, 'utf8');
|
|
57
|
-
|
|
57
|
+
|
|
58
|
+
// Check for staleness: if SKILL.md version is more than a patch behind,
|
|
59
|
+
// warn that content may need updating (not just the version number)
|
|
60
|
+
const skillVersionMatch = content.match(/^---[\s\S]*?version:\s*"?(\d+\.\d+\.\d+)"?[\s\S]*?---/);
|
|
61
|
+
if (skillVersionMatch) {
|
|
62
|
+
const skillVersion = skillVersionMatch[1];
|
|
63
|
+
const [sMaj, sMin] = skillVersion.split('.').map(Number);
|
|
64
|
+
const [nMaj, nMin] = newVersion.split('.').map(Number);
|
|
65
|
+
if (nMaj > sMaj || nMin > sMin + 1) {
|
|
66
|
+
console.warn(` ! SKILL.md is at ${skillVersion}, releasing ${newVersion}`);
|
|
67
|
+
console.warn(` SKILL.md content may be stale. Review tool list and interfaces.`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Match version line in YAML frontmatter (between --- markers).
|
|
72
|
+
// Uses "[^\n]* for quoted values (including corrupted multi-quote strings
|
|
73
|
+
// like "1.9.5".9.4".9.3") or \S+ for unquoted values. This replaces the
|
|
74
|
+
// ENTIRE value on the line, preventing the accumulation bug (#71).
|
|
58
75
|
const updated = content.replace(
|
|
59
|
-
/^(---[\s\S]*?
|
|
60
|
-
`$1$
|
|
76
|
+
/^(---[\s\S]*?version:\s*)(?:"[^\n]*|\S+)([\s\S]*?---)/,
|
|
77
|
+
`$1"${newVersion}"$2`
|
|
61
78
|
);
|
|
62
79
|
|
|
63
80
|
if (updated === content) return false;
|
|
@@ -95,6 +112,25 @@ export function updateChangelog(repoPath, newVersion, notes) {
|
|
|
95
112
|
|
|
96
113
|
// ── Git ─────────────────────────────────────────────────────────────
|
|
97
114
|
|
|
115
|
+
/**
|
|
116
|
+
* Move all RELEASE-NOTES-v*.md files to _trash/.
|
|
117
|
+
* Returns the number of files moved.
|
|
118
|
+
*/
|
|
119
|
+
function trashReleaseNotes(repoPath) {
|
|
120
|
+
const files = readdirSync(repoPath).filter(f => /^RELEASE-NOTES-v.*\.md$/i.test(f));
|
|
121
|
+
if (files.length === 0) return 0;
|
|
122
|
+
|
|
123
|
+
const trashDir = join(repoPath, '_trash');
|
|
124
|
+
if (!existsSync(trashDir)) mkdirSync(trashDir);
|
|
125
|
+
|
|
126
|
+
for (const f of files) {
|
|
127
|
+
renameSync(join(repoPath, f), join(trashDir, f));
|
|
128
|
+
execFileSync('git', ['add', join('_trash', f)], { cwd: repoPath, stdio: 'pipe' });
|
|
129
|
+
execFileSync('git', ['rm', '--cached', f], { cwd: repoPath, stdio: 'pipe' });
|
|
130
|
+
}
|
|
131
|
+
return files.length;
|
|
132
|
+
}
|
|
133
|
+
|
|
98
134
|
function gitCommitAndTag(repoPath, newVersion, notes) {
|
|
99
135
|
const msg = `v${newVersion}: ${notes || 'Release'}`;
|
|
100
136
|
// Stage known files (ignore missing ones)
|
|
@@ -134,55 +170,223 @@ export function publishGitHubPackages(repoPath) {
|
|
|
134
170
|
}
|
|
135
171
|
|
|
136
172
|
/**
|
|
137
|
-
*
|
|
173
|
+
* Categorize a commit message into a section.
|
|
174
|
+
* Returns: 'changes', 'fixes', 'docs', 'internal'
|
|
175
|
+
*/
|
|
176
|
+
function categorizeCommit(subject) {
|
|
177
|
+
const lower = subject.toLowerCase();
|
|
178
|
+
|
|
179
|
+
// Fixes
|
|
180
|
+
if (lower.startsWith('fix') || lower.startsWith('hotfix') || lower.startsWith('bugfix') ||
|
|
181
|
+
lower.includes('fix:') || lower.includes('bug:')) {
|
|
182
|
+
return 'fixes';
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Docs
|
|
186
|
+
if (lower.startsWith('doc') || lower.startsWith('readme') ||
|
|
187
|
+
lower.includes('docs:') || lower.includes('doc:') ||
|
|
188
|
+
lower.startsWith('update readme') || lower.startsWith('rewrite readme') ||
|
|
189
|
+
lower.startsWith('update technical') || lower.startsWith('rewrite relay') ||
|
|
190
|
+
lower.startsWith('update relay')) {
|
|
191
|
+
return 'docs';
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Internal (skip in release notes)
|
|
195
|
+
if (lower.startsWith('chore') || lower.startsWith('auto-commit') ||
|
|
196
|
+
lower.startsWith('merge pull request') || lower.startsWith('merge branch') ||
|
|
197
|
+
lower.match(/^v\d+\.\d+\.\d+/) || lower.startsWith('mark ') ||
|
|
198
|
+
lower.startsWith('clean up todo') || lower.startsWith('keep ')) {
|
|
199
|
+
return 'internal';
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Everything else is a change
|
|
203
|
+
return 'changes';
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Check release notes quality. Returns { ok, issues[] }.
|
|
208
|
+
*
|
|
209
|
+
* notesSource: 'file' (RELEASE-NOTES-v*.md or --notes-file),
|
|
210
|
+
* 'dev-update' (ai/dev-updates/ fallback),
|
|
211
|
+
* 'flag' (bare --notes="string"),
|
|
212
|
+
* 'none' (nothing provided).
|
|
213
|
+
*
|
|
214
|
+
* For minor/major: BLOCKS if notes came from bare --notes flag or are missing.
|
|
215
|
+
* Agents must write a RELEASE-NOTES-v{version}.md file and commit it.
|
|
216
|
+
* For patch: WARNS only.
|
|
217
|
+
*/
|
|
218
|
+
function checkReleaseNotes(notes, notesSource, level) {
|
|
219
|
+
const issues = [];
|
|
220
|
+
const isMinorOrMajor = level === 'minor' || level === 'major';
|
|
221
|
+
|
|
222
|
+
if (!notes) {
|
|
223
|
+
issues.push('No release notes provided. Write a RELEASE-NOTES-v{version}.md file.');
|
|
224
|
+
return { ok: false, issues };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Bare --notes flag is not acceptable for minor/major.
|
|
228
|
+
// Agents must write a file, not pass a one-liner.
|
|
229
|
+
if (notesSource === 'flag') {
|
|
230
|
+
if (isMinorOrMajor) {
|
|
231
|
+
issues.push('Release notes came from --notes flag, not a file.');
|
|
232
|
+
issues.push('Write RELEASE-NOTES-v{version}.md (dashes not dots) and commit it.');
|
|
233
|
+
issues.push('wip-release auto-detects the file. No --notes flag needed.');
|
|
234
|
+
} else if (notes.length < 50) {
|
|
235
|
+
issues.push('Release notes are very short. Consider writing a RELEASE-NOTES file.');
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Check for changelog-style one-liners regardless of source
|
|
240
|
+
const looksLikeChangelog = /^(fix|add|update|remove|bump|chore|refactor|docs?)[\s:]/i.test(notes);
|
|
241
|
+
if (looksLikeChangelog && notes.length < 100) {
|
|
242
|
+
issues.push('Notes look like a changelog entry, not a narrative.');
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return { ok: issues.length === 0, issues };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Check if a file was modified in commits since the last git tag.
|
|
250
|
+
*/
|
|
251
|
+
function fileModifiedSinceLastTag(repoPath, relativePath) {
|
|
252
|
+
try {
|
|
253
|
+
const lastTag = execFileSync('git', ['describe', '--tags', '--abbrev=0'],
|
|
254
|
+
{ cwd: repoPath, encoding: 'utf8' }).trim();
|
|
255
|
+
const diff = execFileSync('git', ['diff', '--name-only', lastTag, 'HEAD'],
|
|
256
|
+
{ cwd: repoPath, encoding: 'utf8' });
|
|
257
|
+
return diff.split('\n').some(f => f.trim() === relativePath);
|
|
258
|
+
} catch {
|
|
259
|
+
// No tags yet or git error ... skip check
|
|
260
|
+
return true;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Check that product docs were updated for this release.
|
|
266
|
+
* Returns { missing: string[], ok: boolean, skipped: boolean }.
|
|
267
|
+
* Only runs if ai/ directory structure exists.
|
|
268
|
+
*/
|
|
269
|
+
function checkProductDocs(repoPath) {
|
|
270
|
+
const missing = [];
|
|
271
|
+
|
|
272
|
+
// Skip repos without ai/ structure
|
|
273
|
+
const aiDir = join(repoPath, 'ai');
|
|
274
|
+
if (!existsSync(aiDir)) return { missing: [], ok: true, skipped: true };
|
|
275
|
+
|
|
276
|
+
// 1. Dev update: file from today (or last 3 days)
|
|
277
|
+
const devUpdatesDir = join(aiDir, 'dev-updates');
|
|
278
|
+
if (existsSync(devUpdatesDir)) {
|
|
279
|
+
const now = new Date();
|
|
280
|
+
const recentDates = [];
|
|
281
|
+
for (let i = 0; i < 3; i++) {
|
|
282
|
+
const d = new Date(now);
|
|
283
|
+
d.setDate(d.getDate() - i);
|
|
284
|
+
recentDates.push(d.toISOString().split('T')[0]);
|
|
285
|
+
}
|
|
286
|
+
const files = readdirSync(devUpdatesDir).filter(f => f.endsWith('.md'));
|
|
287
|
+
const hasRecent = files.some(f => recentDates.some(d => f.startsWith(d)));
|
|
288
|
+
if (!hasRecent) missing.push('ai/dev-updates/ (no dev update from last 3 days)');
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// 2. Roadmap: modified since last tag
|
|
292
|
+
const roadmapPath = 'ai/product/plans-prds/roadmap.md';
|
|
293
|
+
if (existsSync(join(repoPath, roadmapPath))) {
|
|
294
|
+
if (!fileModifiedSinceLastTag(repoPath, roadmapPath)) {
|
|
295
|
+
missing.push('ai/product/plans-prds/roadmap.md (not updated since last release)');
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// 3. Readme-first: modified since last tag
|
|
300
|
+
const readmeFirstPath = 'ai/product/readme-first-product.md';
|
|
301
|
+
if (existsSync(join(repoPath, readmeFirstPath))) {
|
|
302
|
+
if (!fileModifiedSinceLastTag(repoPath, readmeFirstPath)) {
|
|
303
|
+
missing.push('ai/product/readme-first-product.md (not updated since last release)');
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
return { missing, ok: missing.length === 0, skipped: false };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Build release notes with narrative first, commit details second.
|
|
312
|
+
*
|
|
313
|
+
* Release notes should tell the story: what was built, why, and why it matters.
|
|
314
|
+
* Commit history is included as supporting detail, not the main content.
|
|
315
|
+
* ai/ files are excluded from the files-changed stats.
|
|
138
316
|
*/
|
|
139
317
|
export function buildReleaseNotes(repoPath, currentVersion, newVersion, notes) {
|
|
140
318
|
const slug = detectRepoSlug(repoPath);
|
|
141
319
|
const pkg = JSON.parse(readFileSync(join(repoPath, 'package.json'), 'utf8'));
|
|
142
320
|
const lines = [];
|
|
143
321
|
|
|
144
|
-
//
|
|
145
|
-
lines.push('## What changed\n');
|
|
322
|
+
// Narrative summary (the main content of the release notes)
|
|
146
323
|
if (notes) {
|
|
147
324
|
lines.push(notes);
|
|
148
325
|
lines.push('');
|
|
149
326
|
}
|
|
150
327
|
|
|
151
|
-
//
|
|
328
|
+
// Gather commits since last tag
|
|
152
329
|
const prevTag = `v${currentVersion}`;
|
|
153
|
-
let
|
|
330
|
+
let rawCommits = [];
|
|
154
331
|
try {
|
|
155
|
-
|
|
156
|
-
'log', `${prevTag}..HEAD`, '--pretty=format
|
|
332
|
+
const raw = execFileSync('git', [
|
|
333
|
+
'log', `${prevTag}..HEAD`, '--pretty=format:%h\t%s'
|
|
157
334
|
], { cwd: repoPath, encoding: 'utf8' }).trim();
|
|
335
|
+
if (raw) rawCommits = raw.split('\n').map(line => {
|
|
336
|
+
const [hash, ...rest] = line.split('\t');
|
|
337
|
+
return { hash, subject: rest.join('\t') };
|
|
338
|
+
});
|
|
158
339
|
} catch {
|
|
159
|
-
// No previous tag ... show all commits on branch
|
|
160
340
|
try {
|
|
161
|
-
|
|
162
|
-
'log', '--pretty=format
|
|
341
|
+
const raw = execFileSync('git', [
|
|
342
|
+
'log', '--pretty=format:%h\t%s', '-30'
|
|
163
343
|
], { cwd: repoPath, encoding: 'utf8' }).trim();
|
|
344
|
+
if (raw) rawCommits = raw.split('\n').map(line => {
|
|
345
|
+
const [hash, ...rest] = line.split('\t');
|
|
346
|
+
return { hash, subject: rest.join('\t') };
|
|
347
|
+
});
|
|
164
348
|
} catch {}
|
|
165
349
|
}
|
|
166
350
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
351
|
+
// Categorize commits
|
|
352
|
+
const categories = { changes: [], fixes: [], docs: [], internal: [] };
|
|
353
|
+
for (const commit of rawCommits) {
|
|
354
|
+
const cat = categorizeCommit(commit.subject);
|
|
355
|
+
categories[cat].push(commit);
|
|
171
356
|
}
|
|
172
357
|
|
|
173
|
-
//
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
358
|
+
// Commit details section (supporting detail, not the headline)
|
|
359
|
+
const hasCommits = categories.changes.length + categories.fixes.length + categories.docs.length > 0;
|
|
360
|
+
if (hasCommits) {
|
|
361
|
+
lines.push('<details>');
|
|
362
|
+
lines.push('<summary>What changed (commits)</summary>');
|
|
363
|
+
lines.push('');
|
|
364
|
+
|
|
365
|
+
if (categories.changes.length > 0) {
|
|
366
|
+
lines.push('**Changes**');
|
|
367
|
+
for (const c of categories.changes) {
|
|
368
|
+
lines.push(`- ${c.subject} (${c.hash})`);
|
|
369
|
+
}
|
|
370
|
+
lines.push('');
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
if (categories.fixes.length > 0) {
|
|
374
|
+
lines.push('**Fixes**');
|
|
375
|
+
for (const c of categories.fixes) {
|
|
376
|
+
lines.push(`- ${c.subject} (${c.hash})`);
|
|
377
|
+
}
|
|
378
|
+
lines.push('');
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
if (categories.docs.length > 0) {
|
|
382
|
+
lines.push('**Docs**');
|
|
383
|
+
for (const c of categories.docs) {
|
|
384
|
+
lines.push(`- ${c.subject} (${c.hash})`);
|
|
385
|
+
}
|
|
386
|
+
lines.push('');
|
|
387
|
+
}
|
|
180
388
|
|
|
181
|
-
|
|
182
|
-
lines.push('### Files changed\n');
|
|
183
|
-
lines.push('```');
|
|
184
|
-
lines.push(filesChanged);
|
|
185
|
-
lines.push('```');
|
|
389
|
+
lines.push('</details>');
|
|
186
390
|
lines.push('');
|
|
187
391
|
}
|
|
188
392
|
|
|
@@ -198,9 +402,13 @@ export function buildReleaseNotes(repoPath, currentVersion, newVersion, notes) {
|
|
|
198
402
|
lines.push('```');
|
|
199
403
|
lines.push('');
|
|
200
404
|
|
|
405
|
+
// Attribution
|
|
406
|
+
lines.push('---');
|
|
407
|
+
lines.push('');
|
|
408
|
+
lines.push('Built by Parker Todd Brooks, Lēsa (OpenClaw, Claude Opus 4.6), Claude Code (Claude Opus 4.6).');
|
|
409
|
+
|
|
201
410
|
// Compare URL
|
|
202
411
|
if (slug) {
|
|
203
|
-
lines.push('---');
|
|
204
412
|
lines.push('');
|
|
205
413
|
lines.push(`Full changelog: https://github.com/${slug}/compare/v${currentVersion}...v${newVersion}`);
|
|
206
414
|
}
|
|
@@ -264,8 +472,18 @@ function getNpmToken() {
|
|
|
264
472
|
}
|
|
265
473
|
|
|
266
474
|
function detectSkillSlug(repoPath) {
|
|
267
|
-
//
|
|
268
|
-
//
|
|
475
|
+
// Read the name field from SKILL.md frontmatter (agentskills.io spec: lowercase-hyphen slug).
|
|
476
|
+
// Falls back to directory name.
|
|
477
|
+
const skillPath = join(repoPath, 'SKILL.md');
|
|
478
|
+
if (existsSync(skillPath)) {
|
|
479
|
+
const content = readFileSync(skillPath, 'utf8');
|
|
480
|
+
const nameMatch = content.match(/^---[\s\S]*?\nname:\s*(.+?)\n/);
|
|
481
|
+
if (nameMatch) {
|
|
482
|
+
const name = nameMatch[1].trim().replace(/^["']|["']$/g, '');
|
|
483
|
+
// Only use if it looks like a slug (lowercase, hyphens)
|
|
484
|
+
if (/^[a-z][a-z0-9-]*$/.test(name)) return name;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
269
487
|
return basename(repoPath).toLowerCase();
|
|
270
488
|
}
|
|
271
489
|
|
|
@@ -280,12 +498,61 @@ function detectRepoSlug(repoPath) {
|
|
|
280
498
|
}
|
|
281
499
|
}
|
|
282
500
|
|
|
501
|
+
// ── Stale Branch Check ──────────────────────────────────────────────
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* Check for remote branches that are already merged into origin/main.
|
|
505
|
+
* These should be cleaned up before releasing.
|
|
506
|
+
*
|
|
507
|
+
* For patch: WARN (non-blocking, just print stale branches).
|
|
508
|
+
* For minor/major: BLOCK (return { failed: true }).
|
|
509
|
+
*
|
|
510
|
+
* Filters out origin/main, origin/HEAD, and already-renamed --merged- branches.
|
|
511
|
+
*/
|
|
512
|
+
export function checkStaleBranches(repoPath, level) {
|
|
513
|
+
try {
|
|
514
|
+
// Fetch latest remote state so --merged check is accurate
|
|
515
|
+
try {
|
|
516
|
+
execFileSync('git', ['fetch', '--prune'], { cwd: repoPath, stdio: 'pipe' });
|
|
517
|
+
} catch {
|
|
518
|
+
// Non-fatal: proceed with local state if fetch fails
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const raw = execFileSync('git', ['branch', '-r', '--merged', 'origin/main'], {
|
|
522
|
+
cwd: repoPath, encoding: 'utf8'
|
|
523
|
+
}).trim();
|
|
524
|
+
|
|
525
|
+
if (!raw) return { stale: [], ok: true };
|
|
526
|
+
|
|
527
|
+
const stale = raw.split('\n')
|
|
528
|
+
.map(b => b.trim())
|
|
529
|
+
.filter(b =>
|
|
530
|
+
b &&
|
|
531
|
+
!b.includes('origin/main') &&
|
|
532
|
+
!b.includes('origin/HEAD') &&
|
|
533
|
+
!b.includes('--merged-')
|
|
534
|
+
);
|
|
535
|
+
|
|
536
|
+
if (stale.length === 0) return { stale: [], ok: true };
|
|
537
|
+
|
|
538
|
+
const isMinorOrMajor = level === 'minor' || level === 'major';
|
|
539
|
+
return {
|
|
540
|
+
stale,
|
|
541
|
+
ok: !isMinorOrMajor,
|
|
542
|
+
blocked: isMinorOrMajor,
|
|
543
|
+
};
|
|
544
|
+
} catch {
|
|
545
|
+
// Git command failed... skip check gracefully
|
|
546
|
+
return { stale: [], ok: true, skipped: true };
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
283
550
|
// ── Main ────────────────────────────────────────────────────────────
|
|
284
551
|
|
|
285
552
|
/**
|
|
286
553
|
* Run the full release pipeline.
|
|
287
554
|
*/
|
|
288
|
-
export async function release({ repoPath, level, notes, dryRun, noPublish }) {
|
|
555
|
+
export async function release({ repoPath, level, notes, notesSource, dryRun, noPublish, skipProductCheck, skipStaleCheck }) {
|
|
289
556
|
repoPath = repoPath || process.cwd();
|
|
290
557
|
const currentVersion = detectCurrentVersion(repoPath);
|
|
291
558
|
const newVersion = bumpSemver(currentVersion, level);
|
|
@@ -295,7 +562,149 @@ export async function release({ repoPath, level, notes, dryRun, noPublish }) {
|
|
|
295
562
|
console.log(` ${repoName}: ${currentVersion} -> ${newVersion} (${level})`);
|
|
296
563
|
console.log(` ${'─'.repeat(40)}`);
|
|
297
564
|
|
|
565
|
+
// 0. License compliance gate
|
|
566
|
+
const configPath = join(repoPath, '.license-guard.json');
|
|
567
|
+
if (existsSync(configPath)) {
|
|
568
|
+
const config = JSON.parse(readFileSync(configPath, 'utf8'));
|
|
569
|
+
const licenseIssues = [];
|
|
570
|
+
|
|
571
|
+
const licensePath = join(repoPath, 'LICENSE');
|
|
572
|
+
if (!existsSync(licensePath)) {
|
|
573
|
+
licenseIssues.push('LICENSE file is missing');
|
|
574
|
+
} else {
|
|
575
|
+
const licenseText = readFileSync(licensePath, 'utf8');
|
|
576
|
+
if (!licenseText.includes(config.copyright)) {
|
|
577
|
+
licenseIssues.push(`LICENSE copyright does not match "${config.copyright}"`);
|
|
578
|
+
}
|
|
579
|
+
if (config.license === 'MIT+AGPL' && !licenseText.includes('AGPL') && !licenseText.includes('GNU Affero')) {
|
|
580
|
+
licenseIssues.push('LICENSE is MIT-only but config requires MIT+AGPL');
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
if (!existsSync(join(repoPath, 'CLA.md'))) {
|
|
585
|
+
licenseIssues.push('CLA.md is missing');
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
const readmePath = join(repoPath, 'README.md');
|
|
589
|
+
if (existsSync(readmePath)) {
|
|
590
|
+
const readme = readFileSync(readmePath, 'utf8');
|
|
591
|
+
if (!readme.includes('## License')) licenseIssues.push('README.md missing ## License section');
|
|
592
|
+
if (config.license === 'MIT+AGPL' && !readme.includes('AGPL')) licenseIssues.push('README.md License section missing AGPL reference');
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
if (licenseIssues.length > 0) {
|
|
596
|
+
console.log(` ✗ License compliance failed:`);
|
|
597
|
+
for (const issue of licenseIssues) console.log(` - ${issue}`);
|
|
598
|
+
console.log(`\n Run \`wip-license-guard check --fix\` to auto-repair, then try again.`);
|
|
599
|
+
console.log('');
|
|
600
|
+
return { currentVersion, newVersion, dryRun: false, failed: true };
|
|
601
|
+
}
|
|
602
|
+
console.log(` ✓ License compliance passed`);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// 0.5. Product docs check
|
|
606
|
+
if (!skipProductCheck) {
|
|
607
|
+
const productCheck = checkProductDocs(repoPath);
|
|
608
|
+
if (!productCheck.skipped) {
|
|
609
|
+
if (productCheck.ok) {
|
|
610
|
+
console.log(' ✓ Product docs up to date');
|
|
611
|
+
} else {
|
|
612
|
+
const isMinorOrMajor = level === 'minor' || level === 'major';
|
|
613
|
+
const prefix = isMinorOrMajor ? '✗' : '!';
|
|
614
|
+
console.log(` ${prefix} Product docs need attention:`);
|
|
615
|
+
for (const m of productCheck.missing) console.log(` - ${m}`);
|
|
616
|
+
if (isMinorOrMajor) {
|
|
617
|
+
console.log('');
|
|
618
|
+
console.log(' Update product docs before a minor/major release.');
|
|
619
|
+
console.log(' Use --skip-product-check to override.');
|
|
620
|
+
console.log('');
|
|
621
|
+
return { currentVersion, newVersion, dryRun: false, failed: true };
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// 0.75. Release notes quality gate
|
|
628
|
+
{
|
|
629
|
+
const notesCheck = checkReleaseNotes(notes, notesSource || 'flag', level);
|
|
630
|
+
if (notesCheck.ok) {
|
|
631
|
+
const sourceLabel = notesSource === 'file' ? 'from file' : notesSource === 'dev-update' ? 'from dev update' : 'from --notes';
|
|
632
|
+
console.log(` ✓ Release notes OK (${sourceLabel})`);
|
|
633
|
+
} else {
|
|
634
|
+
const isMinorOrMajor = level === 'minor' || level === 'major';
|
|
635
|
+
const prefix = isMinorOrMajor ? '✗' : '!';
|
|
636
|
+
console.log(` ${prefix} Release notes need attention:`);
|
|
637
|
+
for (const issue of notesCheck.issues) console.log(` - ${issue}`);
|
|
638
|
+
if (isMinorOrMajor) {
|
|
639
|
+
console.log('');
|
|
640
|
+
console.log(' Minor/major releases require a RELEASE-NOTES file, not a --notes one-liner.');
|
|
641
|
+
console.log(' Write RELEASE-NOTES-v{version}.md (dashes not dots), commit it, then release.');
|
|
642
|
+
console.log('');
|
|
643
|
+
return { currentVersion, newVersion, dryRun: false, failed: true };
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// 0.8. Stale remote branch check
|
|
649
|
+
if (!skipStaleCheck) {
|
|
650
|
+
const staleCheck = checkStaleBranches(repoPath, level);
|
|
651
|
+
if (staleCheck.skipped) {
|
|
652
|
+
// Silently skip if git command failed
|
|
653
|
+
} else if (staleCheck.stale.length === 0) {
|
|
654
|
+
console.log(' ✓ No stale remote branches');
|
|
655
|
+
} else {
|
|
656
|
+
const isMinorOrMajor = level === 'minor' || level === 'major';
|
|
657
|
+
const prefix = isMinorOrMajor ? '✗' : '!';
|
|
658
|
+
console.log(` ${prefix} Stale remote branches merged into main:`);
|
|
659
|
+
for (const b of staleCheck.stale) console.log(` - ${b}`);
|
|
660
|
+
if (isMinorOrMajor) {
|
|
661
|
+
console.log('');
|
|
662
|
+
console.log(' Clean up stale branches before a minor/major release.');
|
|
663
|
+
console.log(' Delete them with: git push origin --delete <branch>');
|
|
664
|
+
console.log(' Use --skip-stale-check to override.');
|
|
665
|
+
console.log('');
|
|
666
|
+
return { currentVersion, newVersion, dryRun: false, failed: true };
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
|
|
298
671
|
if (dryRun) {
|
|
672
|
+
// Product docs check (dry-run)
|
|
673
|
+
if (!skipProductCheck) {
|
|
674
|
+
const productCheck = checkProductDocs(repoPath);
|
|
675
|
+
if (!productCheck.skipped) {
|
|
676
|
+
if (productCheck.ok) {
|
|
677
|
+
console.log(' [dry run] ✓ Product docs up to date');
|
|
678
|
+
} else {
|
|
679
|
+
const isMinorOrMajor = level === 'minor' || level === 'major';
|
|
680
|
+
console.log(` [dry run] ${isMinorOrMajor ? '✗ Would BLOCK' : '! Would WARN'}: product docs need updates`);
|
|
681
|
+
for (const m of productCheck.missing) console.log(` - ${m}`);
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
// Release notes check (dry-run)
|
|
686
|
+
{
|
|
687
|
+
const notesCheck = checkReleaseNotes(notes, notesSource || 'flag', level);
|
|
688
|
+
if (notesCheck.ok) {
|
|
689
|
+
const sourceLabel = notesSource === 'file' ? 'from file' : notesSource === 'dev-update' ? 'from dev update' : 'from --notes';
|
|
690
|
+
console.log(` [dry run] ✓ Release notes OK (${sourceLabel})`);
|
|
691
|
+
} else {
|
|
692
|
+
const isMinorOrMajor = level === 'minor' || level === 'major';
|
|
693
|
+
console.log(` [dry run] ${isMinorOrMajor ? '✗ Would BLOCK' : '! Would WARN'}: release notes need attention`);
|
|
694
|
+
for (const issue of notesCheck.issues) console.log(` - ${issue}`);
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
// Stale branch check (dry-run)
|
|
698
|
+
if (!skipStaleCheck) {
|
|
699
|
+
const staleCheck = checkStaleBranches(repoPath, level);
|
|
700
|
+
if (!staleCheck.skipped && staleCheck.stale.length > 0) {
|
|
701
|
+
const isMinorOrMajor = level === 'minor' || level === 'major';
|
|
702
|
+
console.log(` [dry run] ${isMinorOrMajor ? '✗ Would BLOCK' : '! Would WARN'}: stale remote branches`);
|
|
703
|
+
for (const b of staleCheck.stale) console.log(` - ${b}`);
|
|
704
|
+
} else if (!staleCheck.skipped) {
|
|
705
|
+
console.log(' [dry run] ✓ No stale remote branches');
|
|
706
|
+
}
|
|
707
|
+
}
|
|
299
708
|
const hasSkill = existsSync(join(repoPath, 'SKILL.md'));
|
|
300
709
|
console.log(` [dry run] Would bump package.json to ${newVersion}`);
|
|
301
710
|
if (hasSkill) console.log(` [dry run] Would update SKILL.md version`);
|
|
@@ -317,6 +726,30 @@ export async function release({ repoPath, level, notes, dryRun, noPublish }) {
|
|
|
317
726
|
writePackageVersion(repoPath, newVersion);
|
|
318
727
|
console.log(` ✓ package.json -> ${newVersion}`);
|
|
319
728
|
|
|
729
|
+
// 1.5. Bump sub-tool versions in toolbox repos (tools/*/)
|
|
730
|
+
const toolsDir = join(repoPath, 'tools');
|
|
731
|
+
if (existsSync(toolsDir)) {
|
|
732
|
+
let subBumped = 0;
|
|
733
|
+
try {
|
|
734
|
+
const entries = readdirSync(toolsDir, { withFileTypes: true });
|
|
735
|
+
for (const entry of entries) {
|
|
736
|
+
if (!entry.isDirectory()) continue;
|
|
737
|
+
const subPkgPath = join(toolsDir, entry.name, 'package.json');
|
|
738
|
+
if (existsSync(subPkgPath)) {
|
|
739
|
+
try {
|
|
740
|
+
const subPkg = JSON.parse(readFileSync(subPkgPath, 'utf8'));
|
|
741
|
+
subPkg.version = newVersion;
|
|
742
|
+
writeFileSync(subPkgPath, JSON.stringify(subPkg, null, 2) + '\n');
|
|
743
|
+
subBumped++;
|
|
744
|
+
} catch {}
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
} catch {}
|
|
748
|
+
if (subBumped > 0) {
|
|
749
|
+
console.log(` ✓ ${subBumped} sub-tool(s) -> ${newVersion}`);
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
|
|
320
753
|
// 2. Sync SKILL.md
|
|
321
754
|
if (syncSkillVersion(repoPath, newVersion)) {
|
|
322
755
|
console.log(` ✓ SKILL.md -> ${newVersion}`);
|
|
@@ -326,6 +759,12 @@ export async function release({ repoPath, level, notes, dryRun, noPublish }) {
|
|
|
326
759
|
updateChangelog(repoPath, newVersion, notes);
|
|
327
760
|
console.log(` ✓ CHANGELOG.md updated`);
|
|
328
761
|
|
|
762
|
+
// 3.5. Move RELEASE-NOTES-v*.md to _trash/
|
|
763
|
+
const trashed = trashReleaseNotes(repoPath);
|
|
764
|
+
if (trashed > 0) {
|
|
765
|
+
console.log(` ✓ Moved ${trashed} RELEASE-NOTES file(s) to _trash/`);
|
|
766
|
+
}
|
|
767
|
+
|
|
329
768
|
// 4. Git commit + tag
|
|
330
769
|
gitCommitAndTag(repoPath, newVersion, notes);
|
|
331
770
|
console.log(` ✓ Committed and tagged v${newVersion}`);
|
|
@@ -338,41 +777,205 @@ export async function release({ repoPath, level, notes, dryRun, noPublish }) {
|
|
|
338
777
|
console.log(` ! Push failed (maybe branch protection). Push manually.`);
|
|
339
778
|
}
|
|
340
779
|
|
|
780
|
+
// Distribution results collector (#104)
|
|
781
|
+
const distResults = [];
|
|
782
|
+
|
|
341
783
|
if (!noPublish) {
|
|
342
784
|
// 6. npm publish
|
|
343
785
|
try {
|
|
344
786
|
publishNpm(repoPath);
|
|
787
|
+
const pkg = JSON.parse(readFileSync(join(repoPath, 'package.json'), 'utf8'));
|
|
788
|
+
distResults.push({ target: 'npm', status: 'ok', detail: `${pkg.name}@${newVersion}` });
|
|
345
789
|
console.log(` ✓ Published to npm`);
|
|
346
790
|
} catch (e) {
|
|
791
|
+
distResults.push({ target: 'npm', status: 'failed', detail: e.message });
|
|
347
792
|
console.log(` ✗ npm publish failed: ${e.message}`);
|
|
348
793
|
}
|
|
349
794
|
|
|
350
795
|
// 7. GitHub Packages
|
|
351
796
|
try {
|
|
352
797
|
publishGitHubPackages(repoPath);
|
|
798
|
+
distResults.push({ target: 'GitHub Packages', status: 'ok', detail: `${newVersion}` });
|
|
353
799
|
console.log(` ✓ Published to GitHub Packages`);
|
|
354
800
|
} catch (e) {
|
|
801
|
+
distResults.push({ target: 'GitHub Packages', status: 'failed', detail: e.message });
|
|
355
802
|
console.log(` ✗ GitHub Packages publish failed: ${e.message}`);
|
|
356
803
|
}
|
|
357
804
|
|
|
358
805
|
// 8. GitHub release
|
|
359
806
|
try {
|
|
360
807
|
createGitHubRelease(repoPath, newVersion, notes, currentVersion);
|
|
808
|
+
distResults.push({ target: 'GitHub', status: 'ok', detail: `v${newVersion}` });
|
|
361
809
|
console.log(` ✓ GitHub release v${newVersion} created`);
|
|
362
810
|
} catch (e) {
|
|
811
|
+
distResults.push({ target: 'GitHub', status: 'failed', detail: e.message });
|
|
363
812
|
console.log(` ✗ GitHub release failed: ${e.message}`);
|
|
364
813
|
}
|
|
365
814
|
|
|
366
|
-
// 9. ClawHub skill publish
|
|
367
|
-
const
|
|
368
|
-
|
|
815
|
+
// 9. ClawHub skill publish (root + sub-tools)
|
|
816
|
+
const rootSkill = join(repoPath, 'SKILL.md');
|
|
817
|
+
const toolsDir = join(repoPath, 'tools');
|
|
818
|
+
|
|
819
|
+
// Publish root SKILL.md
|
|
820
|
+
if (existsSync(rootSkill)) {
|
|
369
821
|
try {
|
|
370
822
|
publishClawHub(repoPath, newVersion, notes);
|
|
371
|
-
|
|
823
|
+
const slug = detectSkillSlug(repoPath);
|
|
824
|
+
distResults.push({ target: `ClawHub`, status: 'ok', detail: `${slug}@${newVersion}` });
|
|
825
|
+
console.log(` ✓ Published to ClawHub: ${slug}`);
|
|
372
826
|
} catch (e) {
|
|
827
|
+
distResults.push({ target: 'ClawHub (root)', status: 'failed', detail: e.message });
|
|
373
828
|
console.log(` ✗ ClawHub publish failed: ${e.message}`);
|
|
374
829
|
}
|
|
375
830
|
}
|
|
831
|
+
|
|
832
|
+
// Publish each sub-tool SKILL.md (#97)
|
|
833
|
+
if (existsSync(toolsDir)) {
|
|
834
|
+
for (const tool of readdirSync(toolsDir)) {
|
|
835
|
+
const toolPath = join(toolsDir, tool);
|
|
836
|
+
const toolSkill = join(toolPath, 'SKILL.md');
|
|
837
|
+
if (existsSync(toolSkill)) {
|
|
838
|
+
try {
|
|
839
|
+
publishClawHub(toolPath, newVersion, notes);
|
|
840
|
+
const slug = detectSkillSlug(toolPath);
|
|
841
|
+
distResults.push({ target: `ClawHub`, status: 'ok', detail: `${slug}@${newVersion}` });
|
|
842
|
+
console.log(` ✓ Published to ClawHub: ${slug}`);
|
|
843
|
+
} catch (e) {
|
|
844
|
+
const slug = detectSkillSlug(toolPath);
|
|
845
|
+
distResults.push({ target: `ClawHub (${slug})`, status: 'failed', detail: e.message });
|
|
846
|
+
console.log(` ✗ ClawHub publish failed for ${slug}: ${e.message}`);
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
// Distribution summary (#104)
|
|
854
|
+
if (distResults.length > 0) {
|
|
855
|
+
console.log('');
|
|
856
|
+
console.log(' Distribution:');
|
|
857
|
+
for (const r of distResults) {
|
|
858
|
+
const icon = r.status === 'ok' ? '✓' : '✗';
|
|
859
|
+
console.log(` ${icon} ${r.target}: ${r.detail}`);
|
|
860
|
+
}
|
|
861
|
+
const failed = distResults.filter(r => r.status !== 'ok');
|
|
862
|
+
if (failed.length > 0) {
|
|
863
|
+
console.log(`\n ! ${failed.length} of ${distResults.length} target(s) failed.`);
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
// 10. Post-merge branch cleanup: rename merged branches with --merged-YYYY-MM-DD
|
|
868
|
+
try {
|
|
869
|
+
const merged = execSync(
|
|
870
|
+
'git branch --merged main', { cwd: repoPath, encoding: 'utf8' }
|
|
871
|
+
).split('\n')
|
|
872
|
+
.map(b => b.trim())
|
|
873
|
+
.filter(b => b && b !== 'main' && b !== 'master' && !b.startsWith('*') && !b.includes('--merged-'));
|
|
874
|
+
|
|
875
|
+
if (merged.length > 0) {
|
|
876
|
+
console.log(` Scanning ${merged.length} merged branch(es) for rename...`);
|
|
877
|
+
for (const branch of merged) {
|
|
878
|
+
const current = execSync('git branch --show-current', { cwd: repoPath, encoding: 'utf8' }).trim();
|
|
879
|
+
if (branch === current) continue;
|
|
880
|
+
|
|
881
|
+
let mergeDate;
|
|
882
|
+
try {
|
|
883
|
+
const mergeBase = execSync(`git merge-base main ${branch}`, { cwd: repoPath, encoding: 'utf8' }).trim();
|
|
884
|
+
mergeDate = execSync(
|
|
885
|
+
`git log main --format="%ai" --ancestry-path ${mergeBase}..main`,
|
|
886
|
+
{ cwd: repoPath, encoding: 'utf8' }
|
|
887
|
+
).trim().split('\n').pop().split(' ')[0];
|
|
888
|
+
} catch {}
|
|
889
|
+
if (!mergeDate) {
|
|
890
|
+
try {
|
|
891
|
+
mergeDate = execSync(`git log ${branch} -1 --format="%ai"`, { cwd: repoPath, encoding: 'utf8' }).trim().split(' ')[0];
|
|
892
|
+
} catch {}
|
|
893
|
+
}
|
|
894
|
+
if (!mergeDate) continue;
|
|
895
|
+
|
|
896
|
+
const newName = `${branch}--merged-${mergeDate}`;
|
|
897
|
+
try {
|
|
898
|
+
execSync(`git branch -m "${branch}" "${newName}"`, { cwd: repoPath, stdio: 'pipe' });
|
|
899
|
+
execSync(`git push origin "${newName}"`, { cwd: repoPath, stdio: 'pipe' });
|
|
900
|
+
execSync(`git push origin --delete "${branch}"`, { cwd: repoPath, stdio: 'pipe' });
|
|
901
|
+
console.log(` ✓ Renamed: ${branch} -> ${newName}`);
|
|
902
|
+
} catch (e) {
|
|
903
|
+
console.log(` ! Could not rename ${branch}: ${e.message}`);
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
} catch (e) {
|
|
908
|
+
// Non-fatal: branch cleanup is a convenience, not a blocker
|
|
909
|
+
console.log(` ! Branch cleanup skipped: ${e.message}`);
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
// 11. Prune old merged branches (keep last 3 per developer prefix)
|
|
913
|
+
try {
|
|
914
|
+
const KEEP_COUNT = 3;
|
|
915
|
+
const remoteBranches = execSync(
|
|
916
|
+
'git branch -r', { cwd: repoPath, encoding: 'utf8' }
|
|
917
|
+
).split('\n')
|
|
918
|
+
.map(b => b.trim())
|
|
919
|
+
.filter(b => b && !b.includes('HEAD') && b.includes('--merged-'))
|
|
920
|
+
.map(b => b.replace('origin/', ''));
|
|
921
|
+
|
|
922
|
+
if (remoteBranches.length > 0) {
|
|
923
|
+
// Group by developer prefix (everything before first /)
|
|
924
|
+
const byPrefix = {};
|
|
925
|
+
for (const branch of remoteBranches) {
|
|
926
|
+
const prefix = branch.split('/')[0];
|
|
927
|
+
if (!byPrefix[prefix]) byPrefix[prefix] = [];
|
|
928
|
+
byPrefix[prefix].push(branch);
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
let pruned = 0;
|
|
932
|
+
for (const [prefix, branches] of Object.entries(byPrefix)) {
|
|
933
|
+
// Sort by date descending (date is at the end: --merged-YYYY-MM-DD)
|
|
934
|
+
branches.sort((a, b) => {
|
|
935
|
+
const dateA = a.match(/--merged-(\d{4}-\d{2}-\d{2})/)?.[1] || '';
|
|
936
|
+
const dateB = b.match(/--merged-(\d{4}-\d{2}-\d{2})/)?.[1] || '';
|
|
937
|
+
return dateB.localeCompare(dateA);
|
|
938
|
+
});
|
|
939
|
+
|
|
940
|
+
for (let i = KEEP_COUNT; i < branches.length; i++) {
|
|
941
|
+
try {
|
|
942
|
+
execSync(`git push origin --delete "${branches[i]}"`, { cwd: repoPath, stdio: 'pipe' });
|
|
943
|
+
execSync(`git branch -d "${branches[i]}" 2>/dev/null || true`, { cwd: repoPath, stdio: 'pipe', shell: true });
|
|
944
|
+
pruned++;
|
|
945
|
+
} catch {}
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
if (pruned > 0) {
|
|
950
|
+
console.log(` ✓ Pruned ${pruned} old merged branch(es)`);
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
// Clean stale branches (merged into main but never renamed)
|
|
955
|
+
const current = execSync('git branch --show-current', { cwd: repoPath, encoding: 'utf8' }).trim();
|
|
956
|
+
const allRemote = execSync(
|
|
957
|
+
'git branch -r', { cwd: repoPath, encoding: 'utf8' }
|
|
958
|
+
).split('\n')
|
|
959
|
+
.map(b => b.trim())
|
|
960
|
+
.filter(b => b && !b.includes('HEAD') && !b.includes('origin/main') && !b.includes('--merged-'))
|
|
961
|
+
.map(b => b.replace('origin/', ''));
|
|
962
|
+
|
|
963
|
+
let staleCleaned = 0;
|
|
964
|
+
for (const branch of allRemote) {
|
|
965
|
+
if (branch === current) continue;
|
|
966
|
+
try {
|
|
967
|
+
execSync(`git merge-base --is-ancestor origin/${branch} origin/main`, { cwd: repoPath, stdio: 'pipe' });
|
|
968
|
+
// If we get here, branch is fully merged
|
|
969
|
+
execSync(`git push origin --delete "${branch}"`, { cwd: repoPath, stdio: 'pipe' });
|
|
970
|
+
execSync(`git branch -d "${branch}" 2>/dev/null || true`, { cwd: repoPath, stdio: 'pipe', shell: true });
|
|
971
|
+
staleCleaned++;
|
|
972
|
+
} catch {}
|
|
973
|
+
}
|
|
974
|
+
if (staleCleaned > 0) {
|
|
975
|
+
console.log(` ✓ Cleaned ${staleCleaned} stale branch(es)`);
|
|
976
|
+
}
|
|
977
|
+
} catch (e) {
|
|
978
|
+
console.log(` ! Branch prune skipped: ${e.message}`);
|
|
376
979
|
}
|
|
377
980
|
|
|
378
981
|
console.log('');
|
package/mcp-server.mjs
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// wip-release/mcp-server.mjs
|
|
3
|
+
// MCP server exposing release pipeline as tools.
|
|
4
|
+
// Wraps core.mjs. Registered via .mcp.json.
|
|
5
|
+
|
|
6
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
7
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
8
|
+
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
9
|
+
import {
|
|
10
|
+
release, detectCurrentVersion, bumpSemver, buildReleaseNotes,
|
|
11
|
+
} from './core.mjs';
|
|
12
|
+
|
|
13
|
+
const server = new Server(
|
|
14
|
+
{ name: 'wip-release', version: '1.3.0' },
|
|
15
|
+
{ capabilities: { tools: {} } }
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
// ── Tool Definitions ──
|
|
19
|
+
|
|
20
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
21
|
+
tools: [
|
|
22
|
+
{
|
|
23
|
+
name: 'release',
|
|
24
|
+
description: 'Run the full release pipeline. Bumps version, updates changelog + SKILL.md, commits, tags, publishes to npm + GitHub. Must be run from repo root or provide repoPath.',
|
|
25
|
+
inputSchema: {
|
|
26
|
+
type: 'object',
|
|
27
|
+
properties: {
|
|
28
|
+
repoPath: { type: 'string', description: 'Absolute path to the repo. Defaults to cwd.' },
|
|
29
|
+
level: { type: 'string', enum: ['patch', 'minor', 'major'], description: 'Semver bump level' },
|
|
30
|
+
notes: { type: 'string', description: 'Changelog entry and release notes summary' },
|
|
31
|
+
dryRun: { type: 'boolean', description: 'Preview only, no changes', default: false },
|
|
32
|
+
noPublish: { type: 'boolean', description: 'Bump + tag only, skip npm/GitHub publish', default: false },
|
|
33
|
+
skipProductCheck: { type: 'boolean', description: 'Skip product doc freshness check', default: false },
|
|
34
|
+
},
|
|
35
|
+
required: ['level', 'notes'],
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
name: 'release_status',
|
|
40
|
+
description: 'Check current version and what the next version would be for a given bump level.',
|
|
41
|
+
inputSchema: {
|
|
42
|
+
type: 'object',
|
|
43
|
+
properties: {
|
|
44
|
+
repoPath: { type: 'string', description: 'Absolute path to the repo. Defaults to cwd.' },
|
|
45
|
+
level: { type: 'string', enum: ['patch', 'minor', 'major'], description: 'Semver bump level to preview' },
|
|
46
|
+
},
|
|
47
|
+
required: ['level'],
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
],
|
|
51
|
+
}));
|
|
52
|
+
|
|
53
|
+
// ── Tool Handlers ──
|
|
54
|
+
|
|
55
|
+
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
56
|
+
const { name, arguments: args } = req.params;
|
|
57
|
+
|
|
58
|
+
if (name === 'release') {
|
|
59
|
+
try {
|
|
60
|
+
const result = await release({
|
|
61
|
+
repoPath: args.repoPath || process.cwd(),
|
|
62
|
+
level: args.level,
|
|
63
|
+
notes: args.notes,
|
|
64
|
+
dryRun: args.dryRun || false,
|
|
65
|
+
notesSource: 'flag', // MCP always passes notes directly
|
|
66
|
+
noPublish: args.noPublish || false,
|
|
67
|
+
skipProductCheck: args.skipProductCheck || false,
|
|
68
|
+
});
|
|
69
|
+
return {
|
|
70
|
+
content: [{
|
|
71
|
+
type: 'text',
|
|
72
|
+
text: `Release complete: ${result.currentVersion} -> ${result.newVersion}${result.dryRun ? ' (dry run)' : ''}`,
|
|
73
|
+
}],
|
|
74
|
+
};
|
|
75
|
+
} catch (err) {
|
|
76
|
+
return {
|
|
77
|
+
content: [{ type: 'text', text: `Release failed: ${err.message}` }],
|
|
78
|
+
isError: true,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (name === 'release_status') {
|
|
84
|
+
try {
|
|
85
|
+
const repoPath = args.repoPath || process.cwd();
|
|
86
|
+
const current = detectCurrentVersion(repoPath);
|
|
87
|
+
const next = bumpSemver(current, args.level);
|
|
88
|
+
return {
|
|
89
|
+
content: [{
|
|
90
|
+
type: 'text',
|
|
91
|
+
text: `Current: ${current}\nNext (${args.level}): ${next}`,
|
|
92
|
+
}],
|
|
93
|
+
};
|
|
94
|
+
} catch (err) {
|
|
95
|
+
return {
|
|
96
|
+
content: [{ type: 'text', text: `Status check failed: ${err.message}` }],
|
|
97
|
+
isError: true,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
content: [{ type: 'text', text: `Unknown tool: ${name}` }],
|
|
104
|
+
isError: true,
|
|
105
|
+
};
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const transport = new StdioServerTransport();
|
|
109
|
+
await server.connect(transport);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wipcomputer/wip-release",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.9.7",
|
|
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",
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
"./cli": "./cli.js"
|
|
13
13
|
},
|
|
14
14
|
"scripts": {
|
|
15
|
-
"test": "node cli.
|
|
15
|
+
"test": "node cli.js --help"
|
|
16
16
|
},
|
|
17
17
|
"keywords": [
|
|
18
18
|
"release",
|
|
@@ -29,5 +29,8 @@
|
|
|
29
29
|
"type": "git",
|
|
30
30
|
"url": "git+https://github.com/wipcomputer/wip-release.git"
|
|
31
31
|
},
|
|
32
|
-
"homepage": "https://github.com/wipcomputer/wip-
|
|
32
|
+
"homepage": "https://github.com/wipcomputer/wip-ai-devops-toolbox",
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"@modelcontextprotocol/sdk": "^1.0.0"
|
|
35
|
+
}
|
|
33
36
|
}
|