@wipcomputer/wip-ai-devops-toolbox 1.9.20
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/.license-guard.json +7 -0
- package/.publish-skill.json +4 -0
- package/CHANGELOG.md +1120 -0
- package/CLA.md +19 -0
- package/DEV-GUIDE-GENERAL-PUBLIC.md +882 -0
- package/LICENSE +52 -0
- package/README.md +238 -0
- package/SKILL.md +728 -0
- package/TECHNICAL.md +282 -0
- package/UNIVERSAL-INTERFACE.md +180 -0
- package/_trash/RELEASE-NOTES-v1-8-0.md +29 -0
- package/_trash/RELEASE-NOTES-v1-8-1.md +7 -0
- package/_trash/RELEASE-NOTES-v1-8-2.md +7 -0
- package/_trash/RELEASE-NOTES-v1-9-0.md +37 -0
- package/_trash/RELEASE-NOTES-v1-9-1.md +38 -0
- package/_trash/RELEASE-NOTES-v1-9-10.md +40 -0
- package/_trash/RELEASE-NOTES-v1-9-2.md +40 -0
- package/_trash/RELEASE-NOTES-v1-9-6.md +72 -0
- package/_trash/RELEASE-NOTES-v1-9-7.md +23 -0
- package/_trash/RELEASE-NOTES-v1-9-9.md +75 -0
- package/_trash/guide 2/DEV-GUIDE.md +487 -0
- package/_trash/guide 2/scripts/deploy-public.sh +152 -0
- package/package.json +27 -0
- package/scripts/SKILL-deploy-public.md +61 -0
- package/scripts/SKILL-post-merge-rename.md +47 -0
- package/scripts/deploy-public.sh +264 -0
- package/scripts/post-merge-rename.sh +205 -0
- package/scripts/publish-skill.sh +134 -0
- package/tools/deploy-public/LICENSE +52 -0
- package/tools/deploy-public/README.md +31 -0
- package/tools/deploy-public/SKILL.md +71 -0
- package/tools/deploy-public/deploy-public.sh +264 -0
- package/tools/deploy-public/package.json +9 -0
- package/tools/ldm-jobs/LICENSE +52 -0
- package/tools/ldm-jobs/README.md +46 -0
- package/tools/ldm-jobs/backup.sh +16 -0
- package/tools/ldm-jobs/branch-protect.sh +39 -0
- package/tools/ldm-jobs/crystal-capture.sh +19 -0
- package/tools/ldm-jobs/setup-shell.sh +27 -0
- package/tools/ldm-jobs/visibility-audit.sh +27 -0
- package/tools/post-merge-rename/LICENSE +52 -0
- package/tools/post-merge-rename/README.md +29 -0
- package/tools/post-merge-rename/SKILL.md +57 -0
- package/tools/post-merge-rename/package.json +9 -0
- package/tools/post-merge-rename/post-merge-rename.sh +122 -0
- package/tools/wip-branch-guard/INSTALL.md +41 -0
- package/tools/wip-branch-guard/guard.mjs +259 -0
- package/tools/wip-branch-guard/package.json +11 -0
- package/tools/wip-file-guard/CHANGELOG.md +6 -0
- package/tools/wip-file-guard/LICENSE +52 -0
- package/tools/wip-file-guard/README.md +113 -0
- package/tools/wip-file-guard/REFERENCE.md +86 -0
- package/tools/wip-file-guard/SKILL.md +105 -0
- package/tools/wip-file-guard/guard.mjs +128 -0
- package/tools/wip-file-guard/openclaw.plugin.json +8 -0
- package/tools/wip-file-guard/package.json +27 -0
- package/tools/wip-file-guard/test.sh +119 -0
- package/tools/wip-license-guard/LICENSE +52 -0
- package/tools/wip-license-guard/README.md +32 -0
- package/tools/wip-license-guard/SKILL.md +65 -0
- package/tools/wip-license-guard/cli.mjs +464 -0
- package/tools/wip-license-guard/core.mjs +310 -0
- package/tools/wip-license-guard/hook.mjs +146 -0
- package/tools/wip-license-guard/package.json +15 -0
- package/tools/wip-license-hook/CHANGELOG.md +17 -0
- package/tools/wip-license-hook/LICENSE +52 -0
- package/tools/wip-license-hook/README.md +200 -0
- package/tools/wip-license-hook/SKILL.md +111 -0
- package/tools/wip-license-hook/dist/cli/index.d.ts +15 -0
- package/tools/wip-license-hook/dist/cli/index.js +170 -0
- package/tools/wip-license-hook/dist/cli/index.js.map +1 -0
- package/tools/wip-license-hook/dist/core/detector.d.ts +12 -0
- package/tools/wip-license-hook/dist/core/detector.js +104 -0
- package/tools/wip-license-hook/dist/core/detector.js.map +1 -0
- package/tools/wip-license-hook/dist/core/index.d.ts +4 -0
- package/tools/wip-license-hook/dist/core/index.js +5 -0
- package/tools/wip-license-hook/dist/core/index.js.map +1 -0
- package/tools/wip-license-hook/dist/core/ledger.d.ts +49 -0
- package/tools/wip-license-hook/dist/core/ledger.js +72 -0
- package/tools/wip-license-hook/dist/core/ledger.js.map +1 -0
- package/tools/wip-license-hook/dist/core/reporter.d.ts +14 -0
- package/tools/wip-license-hook/dist/core/reporter.js +227 -0
- package/tools/wip-license-hook/dist/core/reporter.js.map +1 -0
- package/tools/wip-license-hook/dist/core/scanner.d.ts +39 -0
- package/tools/wip-license-hook/dist/core/scanner.js +325 -0
- package/tools/wip-license-hook/dist/core/scanner.js.map +1 -0
- package/tools/wip-license-hook/hooks/pre-pull.sh +55 -0
- package/tools/wip-license-hook/hooks/pre-push.sh +51 -0
- package/tools/wip-license-hook/mcp-server.mjs +119 -0
- package/tools/wip-license-hook/package-lock.json +54 -0
- package/tools/wip-license-hook/package.json +43 -0
- package/tools/wip-license-hook/src/cli/index.ts +189 -0
- package/tools/wip-license-hook/src/core/detector.ts +130 -0
- package/tools/wip-license-hook/src/core/index.ts +4 -0
- package/tools/wip-license-hook/src/core/ledger.ts +116 -0
- package/tools/wip-license-hook/src/core/reporter.ts +255 -0
- package/tools/wip-license-hook/src/core/scanner.ts +367 -0
- package/tools/wip-license-hook/tsconfig.json +16 -0
- package/tools/wip-readme-format/README.md +49 -0
- package/tools/wip-readme-format/SKILL.md +84 -0
- package/tools/wip-readme-format/format.mjs +570 -0
- package/tools/wip-readme-format/package.json +15 -0
- package/tools/wip-release/CHANGELOG.md +42 -0
- package/tools/wip-release/LICENSE +52 -0
- package/tools/wip-release/README.md +45 -0
- package/tools/wip-release/REFERENCE.md +100 -0
- package/tools/wip-release/SKILL.md +139 -0
- package/tools/wip-release/cli.js +161 -0
- package/tools/wip-release/core.mjs +1174 -0
- package/tools/wip-release/mcp-server.mjs +109 -0
- package/tools/wip-release/package.json +36 -0
- package/tools/wip-repo-init/README.md +38 -0
- package/tools/wip-repo-init/SKILL.md +77 -0
- package/tools/wip-repo-init/init.mjs +142 -0
- package/tools/wip-repo-init/package.json +11 -0
- package/tools/wip-repo-permissions-hook/LICENSE +52 -0
- package/tools/wip-repo-permissions-hook/README.md +86 -0
- package/tools/wip-repo-permissions-hook/SKILL.md +73 -0
- package/tools/wip-repo-permissions-hook/cli.js +83 -0
- package/tools/wip-repo-permissions-hook/core.mjs +122 -0
- package/tools/wip-repo-permissions-hook/guard.mjs +64 -0
- package/tools/wip-repo-permissions-hook/mcp-server.mjs +92 -0
- package/tools/wip-repo-permissions-hook/openclaw.plugin.json +8 -0
- package/tools/wip-repo-permissions-hook/package.json +31 -0
- package/tools/wip-repos/LICENSE +52 -0
- package/tools/wip-repos/README.md +77 -0
- package/tools/wip-repos/SKILL.md +80 -0
- package/tools/wip-repos/cli.mjs +176 -0
- package/tools/wip-repos/core.mjs +290 -0
- package/tools/wip-repos/mcp-server.mjs +157 -0
- package/tools/wip-repos/package.json +34 -0
- package/tools/wip-universal-installer/CHANGELOG.md +57 -0
- package/tools/wip-universal-installer/LICENSE +52 -0
- package/tools/wip-universal-installer/README.md +81 -0
- package/tools/wip-universal-installer/REFERENCE.md +122 -0
- package/tools/wip-universal-installer/SKILL.md +87 -0
- package/tools/wip-universal-installer/SPEC.md +180 -0
- package/tools/wip-universal-installer/detect.mjs +130 -0
- package/tools/wip-universal-installer/examples/minimal/README.md +20 -0
- package/tools/wip-universal-installer/examples/minimal/SKILL.md +28 -0
- package/tools/wip-universal-installer/examples/minimal/cli.mjs +4 -0
- package/tools/wip-universal-installer/examples/minimal/core.mjs +8 -0
- package/tools/wip-universal-installer/examples/minimal/mcp-server.mjs +27 -0
- package/tools/wip-universal-installer/examples/minimal/package.json +12 -0
- package/tools/wip-universal-installer/install.js +930 -0
- package/tools/wip-universal-installer/package.json +36 -0
|
@@ -0,0 +1,1174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* wip-release/core.mjs
|
|
3
|
+
* Local release tool. Bumps version, updates changelog + SKILL.md,
|
|
4
|
+
* commits, tags, publishes to npm + GitHub Packages, creates GitHub release.
|
|
5
|
+
* Zero dependencies.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { execSync, execFileSync } from 'node:child_process';
|
|
9
|
+
import { readFileSync, writeFileSync, existsSync, readdirSync, mkdirSync, renameSync } from 'node:fs';
|
|
10
|
+
import { join, basename } from 'node:path';
|
|
11
|
+
|
|
12
|
+
// ── Version ─────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Read current version from package.json.
|
|
16
|
+
*/
|
|
17
|
+
export function detectCurrentVersion(repoPath) {
|
|
18
|
+
const pkgPath = join(repoPath, 'package.json');
|
|
19
|
+
if (!existsSync(pkgPath)) throw new Error(`No package.json found at ${repoPath}`);
|
|
20
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
|
|
21
|
+
return pkg.version;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Bump a semver string by level.
|
|
26
|
+
*/
|
|
27
|
+
export function bumpSemver(version, level) {
|
|
28
|
+
const [major, minor, patch] = version.split('.').map(Number);
|
|
29
|
+
switch (level) {
|
|
30
|
+
case 'major': return `${major + 1}.0.0`;
|
|
31
|
+
case 'minor': return `${major}.${minor + 1}.0`;
|
|
32
|
+
case 'patch': return `${major}.${minor}.${patch + 1}`;
|
|
33
|
+
default: throw new Error(`Invalid level: ${level}. Use major, minor, or patch.`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Write new version to package.json.
|
|
39
|
+
*/
|
|
40
|
+
function writePackageVersion(repoPath, newVersion) {
|
|
41
|
+
const pkgPath = join(repoPath, 'package.json');
|
|
42
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
|
|
43
|
+
pkg.version = newVersion;
|
|
44
|
+
writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ── SKILL.md ────────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Update version in SKILL.md YAML frontmatter.
|
|
51
|
+
*/
|
|
52
|
+
export function syncSkillVersion(repoPath, newVersion) {
|
|
53
|
+
const skillPath = join(repoPath, 'SKILL.md');
|
|
54
|
+
if (!existsSync(skillPath)) return false;
|
|
55
|
+
|
|
56
|
+
let content = readFileSync(skillPath, 'utf8');
|
|
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).
|
|
75
|
+
const updated = content.replace(
|
|
76
|
+
/^(---[\s\S]*?version:\s*)(?:"[^\n]*|\S+)([\s\S]*?---)/,
|
|
77
|
+
`$1"${newVersion}"$2`
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
if (updated === content) return false;
|
|
81
|
+
writeFileSync(skillPath, updated);
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ── CHANGELOG.md ────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Prepend a new version entry to CHANGELOG.md.
|
|
89
|
+
*/
|
|
90
|
+
export function updateChangelog(repoPath, newVersion, notes) {
|
|
91
|
+
const changelogPath = join(repoPath, 'CHANGELOG.md');
|
|
92
|
+
const date = new Date().toISOString().split('T')[0];
|
|
93
|
+
|
|
94
|
+
// Bug fix #121: never silently default to "Release." when notes are empty.
|
|
95
|
+
// If notes are empty at this point, warn loudly.
|
|
96
|
+
if (!notes || !notes.trim()) {
|
|
97
|
+
console.warn(` ! WARNING: No release notes provided for v${newVersion}. CHANGELOG entry will be minimal.`);
|
|
98
|
+
notes = 'No release notes provided.';
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const entry = `## ${newVersion} (${date})\n\n${notes}\n`;
|
|
102
|
+
|
|
103
|
+
if (!existsSync(changelogPath)) {
|
|
104
|
+
writeFileSync(changelogPath, `# Changelog\n\n${entry}`);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
let content = readFileSync(changelogPath, 'utf8');
|
|
109
|
+
// Insert after the # Changelog header (single newline, no accumulation)
|
|
110
|
+
const headerMatch = content.match(/^# Changelog\s*\n+/);
|
|
111
|
+
if (headerMatch) {
|
|
112
|
+
const insertPoint = headerMatch[0].length;
|
|
113
|
+
content = content.slice(0, insertPoint) + entry + '\n' + content.slice(insertPoint);
|
|
114
|
+
} else {
|
|
115
|
+
content = `# Changelog\n\n${entry}\n${content}`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
writeFileSync(changelogPath, content);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ── Git ─────────────────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Move all RELEASE-NOTES-v*.md files to _trash/.
|
|
125
|
+
* Returns the number of files moved.
|
|
126
|
+
*/
|
|
127
|
+
function trashReleaseNotes(repoPath) {
|
|
128
|
+
const files = readdirSync(repoPath).filter(f => /^RELEASE-NOTES-v.*\.md$/i.test(f));
|
|
129
|
+
if (files.length === 0) return 0;
|
|
130
|
+
|
|
131
|
+
const trashDir = join(repoPath, '_trash');
|
|
132
|
+
if (!existsSync(trashDir)) mkdirSync(trashDir);
|
|
133
|
+
|
|
134
|
+
for (const f of files) {
|
|
135
|
+
renameSync(join(repoPath, f), join(trashDir, f));
|
|
136
|
+
execFileSync('git', ['add', join('_trash', f)], { cwd: repoPath, stdio: 'pipe' });
|
|
137
|
+
execFileSync('git', ['rm', '--cached', f], { cwd: repoPath, stdio: 'pipe' });
|
|
138
|
+
}
|
|
139
|
+
return files.length;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function gitCommitAndTag(repoPath, newVersion, notes) {
|
|
143
|
+
const msg = `v${newVersion}: ${notes || 'Release'}`;
|
|
144
|
+
// Stage known files (ignore missing ones)
|
|
145
|
+
for (const f of ['package.json', 'CHANGELOG.md', 'SKILL.md']) {
|
|
146
|
+
if (existsSync(join(repoPath, f))) {
|
|
147
|
+
execFileSync('git', ['add', f], { cwd: repoPath, stdio: 'pipe' });
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
// Use execFileSync to avoid shell injection via notes
|
|
151
|
+
execFileSync('git', ['commit', '-m', msg], { cwd: repoPath, stdio: 'pipe' });
|
|
152
|
+
execFileSync('git', ['tag', `v${newVersion}`], { cwd: repoPath, stdio: 'pipe' });
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ── Publish ─────────────────────────────────────────────────────────
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Publish to npm via 1Password for auth.
|
|
159
|
+
*/
|
|
160
|
+
export function publishNpm(repoPath) {
|
|
161
|
+
const token = getNpmToken();
|
|
162
|
+
execFileSync('npm', [
|
|
163
|
+
'publish', '--access', 'public',
|
|
164
|
+
`--//registry.npmjs.org/:_authToken=${token}`
|
|
165
|
+
], { cwd: repoPath, stdio: 'inherit' });
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Publish to GitHub Packages.
|
|
170
|
+
*/
|
|
171
|
+
export function publishGitHubPackages(repoPath) {
|
|
172
|
+
const ghToken = execSync('gh auth token', { encoding: 'utf8' }).trim();
|
|
173
|
+
execFileSync('npm', [
|
|
174
|
+
'publish',
|
|
175
|
+
'--registry', 'https://npm.pkg.github.com',
|
|
176
|
+
`--//npm.pkg.github.com/:_authToken=${ghToken}`
|
|
177
|
+
], { cwd: repoPath, stdio: 'inherit' });
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Categorize a commit message into a section.
|
|
182
|
+
* Returns: 'changes', 'fixes', 'docs', 'internal'
|
|
183
|
+
*/
|
|
184
|
+
function categorizeCommit(subject) {
|
|
185
|
+
const lower = subject.toLowerCase();
|
|
186
|
+
|
|
187
|
+
// Fixes
|
|
188
|
+
if (lower.startsWith('fix') || lower.startsWith('hotfix') || lower.startsWith('bugfix') ||
|
|
189
|
+
lower.includes('fix:') || lower.includes('bug:')) {
|
|
190
|
+
return 'fixes';
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Docs
|
|
194
|
+
if (lower.startsWith('doc') || lower.startsWith('readme') ||
|
|
195
|
+
lower.includes('docs:') || lower.includes('doc:') ||
|
|
196
|
+
lower.startsWith('update readme') || lower.startsWith('rewrite readme') ||
|
|
197
|
+
lower.startsWith('update technical') || lower.startsWith('rewrite relay') ||
|
|
198
|
+
lower.startsWith('update relay')) {
|
|
199
|
+
return 'docs';
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Internal (skip in release notes)
|
|
203
|
+
if (lower.startsWith('chore') || lower.startsWith('auto-commit') ||
|
|
204
|
+
lower.startsWith('merge pull request') || lower.startsWith('merge branch') ||
|
|
205
|
+
lower.match(/^v\d+\.\d+\.\d+/) || lower.startsWith('mark ') ||
|
|
206
|
+
lower.startsWith('clean up todo') || lower.startsWith('keep ')) {
|
|
207
|
+
return 'internal';
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Everything else is a change
|
|
211
|
+
return 'changes';
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Check release notes quality. Returns { ok, issues[] }.
|
|
216
|
+
*
|
|
217
|
+
* notesSource: 'file' (RELEASE-NOTES-v*.md or --notes-file),
|
|
218
|
+
* 'dev-update' (ai/dev-updates/ fallback),
|
|
219
|
+
* 'flag' (bare --notes="string"),
|
|
220
|
+
* 'none' (nothing provided).
|
|
221
|
+
*
|
|
222
|
+
* For minor/major: BLOCKS if notes came from bare --notes flag or are missing.
|
|
223
|
+
* Agents must write a RELEASE-NOTES-v{version}.md file and commit it.
|
|
224
|
+
* For patch: WARNS only.
|
|
225
|
+
*/
|
|
226
|
+
function checkReleaseNotes(notes, notesSource, level) {
|
|
227
|
+
const issues = [];
|
|
228
|
+
const isMinorOrMajor = level === 'minor' || level === 'major';
|
|
229
|
+
|
|
230
|
+
if (!notes) {
|
|
231
|
+
issues.push('No release notes provided. Write a RELEASE-NOTES-v{version}.md file.');
|
|
232
|
+
return { ok: false, issues };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Bare --notes flag is not acceptable for minor/major.
|
|
236
|
+
// Agents must write a file, not pass a one-liner.
|
|
237
|
+
if (notesSource === 'flag') {
|
|
238
|
+
if (isMinorOrMajor) {
|
|
239
|
+
issues.push('Release notes came from --notes flag, not a file.');
|
|
240
|
+
issues.push('Write RELEASE-NOTES-v{version}.md (dashes not dots) and commit it.');
|
|
241
|
+
issues.push('wip-release auto-detects the file. No --notes flag needed.');
|
|
242
|
+
} else if (notes.length < 50) {
|
|
243
|
+
issues.push('Release notes are very short. Consider writing a RELEASE-NOTES file.');
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Check for changelog-style one-liners regardless of source
|
|
248
|
+
const looksLikeChangelog = /^(fix|add|update|remove|bump|chore|refactor|docs?)[\s:]/i.test(notes);
|
|
249
|
+
if (looksLikeChangelog && notes.length < 100) {
|
|
250
|
+
issues.push('Notes look like a changelog entry, not a narrative.');
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return { ok: issues.length === 0, issues };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Check if a file was modified in commits since the last git tag.
|
|
258
|
+
*/
|
|
259
|
+
function fileModifiedSinceLastTag(repoPath, relativePath) {
|
|
260
|
+
try {
|
|
261
|
+
const lastTag = execFileSync('git', ['describe', '--tags', '--abbrev=0'],
|
|
262
|
+
{ cwd: repoPath, encoding: 'utf8' }).trim();
|
|
263
|
+
const diff = execFileSync('git', ['diff', '--name-only', lastTag, 'HEAD'],
|
|
264
|
+
{ cwd: repoPath, encoding: 'utf8' });
|
|
265
|
+
return diff.split('\n').some(f => f.trim() === relativePath);
|
|
266
|
+
} catch {
|
|
267
|
+
// No tags yet or git error ... skip check
|
|
268
|
+
return true;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Check that product docs were updated for this release.
|
|
274
|
+
* Returns { missing: string[], ok: boolean, skipped: boolean }.
|
|
275
|
+
* Only runs if ai/ directory structure exists.
|
|
276
|
+
*/
|
|
277
|
+
function checkProductDocs(repoPath) {
|
|
278
|
+
const missing = [];
|
|
279
|
+
|
|
280
|
+
// Skip repos without ai/ structure
|
|
281
|
+
const aiDir = join(repoPath, 'ai');
|
|
282
|
+
if (!existsSync(aiDir)) return { missing: [], ok: true, skipped: true };
|
|
283
|
+
|
|
284
|
+
// 1. Dev update: must have a file modified since last release tag.
|
|
285
|
+
// Old check ("any file from last 3 days") let the same stale file pass
|
|
286
|
+
// across 11 releases in one session. Now uses the same git-based check
|
|
287
|
+
// as roadmap and readme-first: was the file actually changed since the tag?
|
|
288
|
+
const devUpdatesDir = join(aiDir, 'dev-updates');
|
|
289
|
+
if (existsSync(devUpdatesDir)) {
|
|
290
|
+
const files = readdirSync(devUpdatesDir).filter(f => f.endsWith('.md'));
|
|
291
|
+
if (files.length === 0) {
|
|
292
|
+
missing.push('ai/dev-updates/ (no dev update files)');
|
|
293
|
+
} else {
|
|
294
|
+
const anyModified = files.some(f =>
|
|
295
|
+
fileModifiedSinceLastTag(repoPath, `ai/dev-updates/${f}`)
|
|
296
|
+
);
|
|
297
|
+
if (!anyModified) {
|
|
298
|
+
missing.push('ai/dev-updates/ (no dev update modified since last release)');
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// 2. Roadmap: modified since last tag
|
|
304
|
+
const roadmapPath = 'ai/product/plans-prds/roadmap.md';
|
|
305
|
+
if (existsSync(join(repoPath, roadmapPath))) {
|
|
306
|
+
if (!fileModifiedSinceLastTag(repoPath, roadmapPath)) {
|
|
307
|
+
missing.push('ai/product/plans-prds/roadmap.md (not updated since last release)');
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// 3. Readme-first: modified since last tag
|
|
312
|
+
const readmeFirstPath = 'ai/product/readme-first-product.md';
|
|
313
|
+
if (existsSync(join(repoPath, readmeFirstPath))) {
|
|
314
|
+
if (!fileModifiedSinceLastTag(repoPath, readmeFirstPath)) {
|
|
315
|
+
missing.push('ai/product/readme-first-product.md (not updated since last release)');
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
return { missing, ok: missing.length === 0, skipped: false };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Build release notes with narrative first, commit details second.
|
|
324
|
+
*
|
|
325
|
+
* Release notes should tell the story: what was built, why, and why it matters.
|
|
326
|
+
* Commit history is included as supporting detail, not the main content.
|
|
327
|
+
* ai/ files are excluded from the files-changed stats.
|
|
328
|
+
*/
|
|
329
|
+
export function buildReleaseNotes(repoPath, currentVersion, newVersion, notes) {
|
|
330
|
+
const slug = detectRepoSlug(repoPath);
|
|
331
|
+
const pkg = JSON.parse(readFileSync(join(repoPath, 'package.json'), 'utf8'));
|
|
332
|
+
const lines = [];
|
|
333
|
+
|
|
334
|
+
// Narrative summary (the main content of the release notes)
|
|
335
|
+
if (notes) {
|
|
336
|
+
lines.push(notes);
|
|
337
|
+
lines.push('');
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Gather commits since last tag
|
|
341
|
+
const prevTag = `v${currentVersion}`;
|
|
342
|
+
let rawCommits = [];
|
|
343
|
+
try {
|
|
344
|
+
const raw = execFileSync('git', [
|
|
345
|
+
'log', `${prevTag}..HEAD`, '--pretty=format:%h\t%s'
|
|
346
|
+
], { cwd: repoPath, encoding: 'utf8' }).trim();
|
|
347
|
+
if (raw) rawCommits = raw.split('\n').map(line => {
|
|
348
|
+
const [hash, ...rest] = line.split('\t');
|
|
349
|
+
return { hash, subject: rest.join('\t') };
|
|
350
|
+
});
|
|
351
|
+
} catch {
|
|
352
|
+
try {
|
|
353
|
+
const raw = execFileSync('git', [
|
|
354
|
+
'log', '--pretty=format:%h\t%s', '-30'
|
|
355
|
+
], { cwd: repoPath, encoding: 'utf8' }).trim();
|
|
356
|
+
if (raw) rawCommits = raw.split('\n').map(line => {
|
|
357
|
+
const [hash, ...rest] = line.split('\t');
|
|
358
|
+
return { hash, subject: rest.join('\t') };
|
|
359
|
+
});
|
|
360
|
+
} catch {}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Categorize commits
|
|
364
|
+
const categories = { changes: [], fixes: [], docs: [], internal: [] };
|
|
365
|
+
for (const commit of rawCommits) {
|
|
366
|
+
const cat = categorizeCommit(commit.subject);
|
|
367
|
+
categories[cat].push(commit);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Commit details section (supporting detail, not the headline)
|
|
371
|
+
const hasCommits = categories.changes.length + categories.fixes.length + categories.docs.length > 0;
|
|
372
|
+
if (hasCommits) {
|
|
373
|
+
lines.push('<details>');
|
|
374
|
+
lines.push('<summary>What changed (commits)</summary>');
|
|
375
|
+
lines.push('');
|
|
376
|
+
|
|
377
|
+
if (categories.changes.length > 0) {
|
|
378
|
+
lines.push('**Changes**');
|
|
379
|
+
for (const c of categories.changes) {
|
|
380
|
+
lines.push(`- ${c.subject} (${c.hash})`);
|
|
381
|
+
}
|
|
382
|
+
lines.push('');
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
if (categories.fixes.length > 0) {
|
|
386
|
+
lines.push('**Fixes**');
|
|
387
|
+
for (const c of categories.fixes) {
|
|
388
|
+
lines.push(`- ${c.subject} (${c.hash})`);
|
|
389
|
+
}
|
|
390
|
+
lines.push('');
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
if (categories.docs.length > 0) {
|
|
394
|
+
lines.push('**Docs**');
|
|
395
|
+
for (const c of categories.docs) {
|
|
396
|
+
lines.push(`- ${c.subject} (${c.hash})`);
|
|
397
|
+
}
|
|
398
|
+
lines.push('');
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
lines.push('</details>');
|
|
402
|
+
lines.push('');
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// Install section
|
|
406
|
+
lines.push('### Install');
|
|
407
|
+
lines.push('```bash');
|
|
408
|
+
lines.push(`npm install -g ${pkg.name}@${newVersion}`);
|
|
409
|
+
lines.push('```');
|
|
410
|
+
lines.push('');
|
|
411
|
+
lines.push('Or update your local clone:');
|
|
412
|
+
lines.push('```bash');
|
|
413
|
+
lines.push('git pull origin main');
|
|
414
|
+
lines.push('```');
|
|
415
|
+
lines.push('');
|
|
416
|
+
|
|
417
|
+
// Attribution
|
|
418
|
+
lines.push('---');
|
|
419
|
+
lines.push('');
|
|
420
|
+
lines.push('Built by Parker Todd Brooks, Lēsa (OpenClaw, Claude Opus 4.6), Claude Code (Claude Opus 4.6).');
|
|
421
|
+
|
|
422
|
+
// Compare URL
|
|
423
|
+
if (slug) {
|
|
424
|
+
lines.push('');
|
|
425
|
+
lines.push(`Full changelog: https://github.com/${slug}/compare/v${currentVersion}...v${newVersion}`);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
return lines.join('\n');
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Create a GitHub release with detailed notes.
|
|
433
|
+
*/
|
|
434
|
+
export function createGitHubRelease(repoPath, newVersion, notes, currentVersion) {
|
|
435
|
+
const repoSlug = detectRepoSlug(repoPath);
|
|
436
|
+
const body = buildReleaseNotes(repoPath, currentVersion, newVersion, notes);
|
|
437
|
+
|
|
438
|
+
// Write notes to a temp file to avoid shell escaping issues
|
|
439
|
+
const tmpFile = join(repoPath, '.release-notes-tmp.md');
|
|
440
|
+
writeFileSync(tmpFile, body);
|
|
441
|
+
|
|
442
|
+
try {
|
|
443
|
+
execFileSync('gh', [
|
|
444
|
+
'release', 'create', `v${newVersion}`,
|
|
445
|
+
'--title', `v${newVersion}`,
|
|
446
|
+
'--notes-file', '.release-notes-tmp.md',
|
|
447
|
+
'--repo', repoSlug
|
|
448
|
+
], { cwd: repoPath, stdio: 'inherit' });
|
|
449
|
+
|
|
450
|
+
// Bug fix #121: verify the release was actually created
|
|
451
|
+
try {
|
|
452
|
+
const verify = execFileSync('gh', [
|
|
453
|
+
'release', 'view', `v${newVersion}`,
|
|
454
|
+
'--repo', repoSlug, '--json', 'body', '--jq', '.body | length'
|
|
455
|
+
], { cwd: repoPath, encoding: 'utf8' }).trim();
|
|
456
|
+
const bodyLen = parseInt(verify, 10);
|
|
457
|
+
if (bodyLen < 50) {
|
|
458
|
+
console.warn(` ! GitHub release body is only ${bodyLen} chars. Notes may be truncated.`);
|
|
459
|
+
}
|
|
460
|
+
} catch {}
|
|
461
|
+
} finally {
|
|
462
|
+
try { execFileSync('rm', ['-f', tmpFile]); } catch {}
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Publish skill to ClawHub.
|
|
468
|
+
*/
|
|
469
|
+
export function publishClawHub(repoPath, newVersion, notes) {
|
|
470
|
+
const skillPath = join(repoPath, 'SKILL.md');
|
|
471
|
+
if (!existsSync(skillPath)) return false;
|
|
472
|
+
|
|
473
|
+
const slug = detectSkillSlug(repoPath);
|
|
474
|
+
const changelog = notes || 'Release.';
|
|
475
|
+
|
|
476
|
+
execFileSync('clawhub', [
|
|
477
|
+
'publish', repoPath,
|
|
478
|
+
'--slug', slug,
|
|
479
|
+
'--version', newVersion,
|
|
480
|
+
'--changelog', changelog
|
|
481
|
+
], { cwd: repoPath, stdio: 'inherit' });
|
|
482
|
+
return true;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// ── Skill Publish ────────────────────────────────────────────────────
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* Publish SKILL.md to website as plain text.
|
|
489
|
+
*
|
|
490
|
+
* Auto-detects: if SKILL.md exists and WIP_WEBSITE_REPO is set,
|
|
491
|
+
* publishes automatically. No config file needed.
|
|
492
|
+
*
|
|
493
|
+
* Name resolution (first match wins):
|
|
494
|
+
* 1. .publish-skill.json { "name": "memory-crystal" }
|
|
495
|
+
* 2. SKILL.md frontmatter name: field
|
|
496
|
+
* 3. Directory name (basename of repoPath)
|
|
497
|
+
*
|
|
498
|
+
* Copies SKILL.md to {website}/wip.computer/install/{name}.txt
|
|
499
|
+
* Then runs deploy.sh to push to VPS.
|
|
500
|
+
*
|
|
501
|
+
* Non-blocking: returns result, never throws.
|
|
502
|
+
*/
|
|
503
|
+
export function publishSkillToWebsite(repoPath) {
|
|
504
|
+
// Resolve website repo: .publish-skill.json > env var
|
|
505
|
+
let websiteRepo;
|
|
506
|
+
let targetName;
|
|
507
|
+
const configPath = join(repoPath, '.publish-skill.json');
|
|
508
|
+
let publishConfig = {};
|
|
509
|
+
if (existsSync(configPath)) {
|
|
510
|
+
try { publishConfig = JSON.parse(readFileSync(configPath, 'utf8')); } catch {}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
websiteRepo = publishConfig.websiteRepo || process.env.WIP_WEBSITE_REPO;
|
|
514
|
+
if (!websiteRepo) return { skipped: true, reason: 'no websiteRepo in .publish-skill.json and WIP_WEBSITE_REPO not set' };
|
|
515
|
+
|
|
516
|
+
// Find SKILL.md: check root, then skills/*/SKILL.md
|
|
517
|
+
let skillFile = join(repoPath, 'SKILL.md');
|
|
518
|
+
if (!existsSync(skillFile)) {
|
|
519
|
+
const skillsDir = join(repoPath, 'skills');
|
|
520
|
+
if (existsSync(skillsDir)) {
|
|
521
|
+
for (const sub of readdirSync(skillsDir)) {
|
|
522
|
+
const candidate = join(skillsDir, sub, 'SKILL.md');
|
|
523
|
+
if (existsSync(candidate)) { skillFile = candidate; break; }
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
if (!existsSync(skillFile)) return { skipped: true, reason: 'no SKILL.md found' };
|
|
528
|
+
|
|
529
|
+
// Resolve target name: config > package.json > directory name
|
|
530
|
+
// SKILL.md frontmatter name is skipped because it's a short slug
|
|
531
|
+
// (e.g., "memory") not the full install name (e.g., "memory-crystal").
|
|
532
|
+
|
|
533
|
+
// 1. Explicit config (optional, overrides auto-detect)
|
|
534
|
+
if (publishConfig.name) targetName = publishConfig.name;
|
|
535
|
+
|
|
536
|
+
// 2. package.json name (strip @scope/ prefix, most reliable)
|
|
537
|
+
if (!targetName) {
|
|
538
|
+
const pkgPath = join(repoPath, 'package.json');
|
|
539
|
+
if (existsSync(pkgPath)) {
|
|
540
|
+
try {
|
|
541
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
|
|
542
|
+
if (pkg.name) targetName = pkg.name.replace(/^@[^/]+\//, '');
|
|
543
|
+
} catch {}
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// 3. Directory name fallback (strip -private suffix)
|
|
548
|
+
if (!targetName) {
|
|
549
|
+
targetName = basename(repoPath).replace(/-private$/, '').toLowerCase();
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// Copy to website install dir
|
|
553
|
+
const installDir = join(websiteRepo, 'wip.computer', 'install');
|
|
554
|
+
if (!existsSync(installDir)) {
|
|
555
|
+
try { mkdirSync(installDir, { recursive: true }); } catch {}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
const targetFile = join(installDir, `${targetName}.txt`);
|
|
559
|
+
try {
|
|
560
|
+
const content = readFileSync(skillFile, 'utf8');
|
|
561
|
+
writeFileSync(targetFile, content);
|
|
562
|
+
} catch (e) {
|
|
563
|
+
return { ok: false, error: `copy failed: ${e.message}` };
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// Deploy to VPS (non-blocking ... warn on failure)
|
|
567
|
+
const deployScript = join(websiteRepo, 'deploy.sh');
|
|
568
|
+
if (existsSync(deployScript)) {
|
|
569
|
+
try {
|
|
570
|
+
execSync(`bash deploy.sh`, { cwd: websiteRepo, stdio: 'pipe', timeout: 30000 });
|
|
571
|
+
} catch (e) {
|
|
572
|
+
return { ok: true, deployed: false, target: targetName, error: `deploy failed: ${e.message}` };
|
|
573
|
+
}
|
|
574
|
+
} else {
|
|
575
|
+
return { ok: true, deployed: false, target: targetName, error: 'no deploy.sh found' };
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
return { ok: true, deployed: true, target: targetName };
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// ── Helpers ──────────────────────────────────────────────────────────
|
|
582
|
+
|
|
583
|
+
function getNpmToken() {
|
|
584
|
+
try {
|
|
585
|
+
return execSync(
|
|
586
|
+
`OP_SERVICE_ACCOUNT_TOKEN=$(cat ~/.openclaw/secrets/op-sa-token) op item get "npm Token" --vault "Agent Secrets" --fields label=password --reveal 2>/dev/null`,
|
|
587
|
+
{ encoding: 'utf8' }
|
|
588
|
+
).trim();
|
|
589
|
+
} catch {
|
|
590
|
+
throw new Error('Could not fetch npm token from 1Password. Check op CLI and SA token.');
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function detectSkillSlug(repoPath) {
|
|
595
|
+
// Read the name field from SKILL.md frontmatter (agentskills.io spec: lowercase-hyphen slug).
|
|
596
|
+
// Falls back to directory name.
|
|
597
|
+
const skillPath = join(repoPath, 'SKILL.md');
|
|
598
|
+
if (existsSync(skillPath)) {
|
|
599
|
+
const content = readFileSync(skillPath, 'utf8');
|
|
600
|
+
const nameMatch = content.match(/^---[\s\S]*?\nname:\s*(.+?)\n/);
|
|
601
|
+
if (nameMatch) {
|
|
602
|
+
const name = nameMatch[1].trim().replace(/^["']|["']$/g, '');
|
|
603
|
+
// Only use if it looks like a slug (lowercase, hyphens)
|
|
604
|
+
if (/^[a-z][a-z0-9-]*$/.test(name)) return name;
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
return basename(repoPath).toLowerCase();
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
function detectRepoSlug(repoPath) {
|
|
611
|
+
try {
|
|
612
|
+
const url = execSync('git remote get-url origin', { cwd: repoPath, encoding: 'utf8' }).trim();
|
|
613
|
+
// git@github.com:wipcomputer/wip-grok.git or https://github.com/wipcomputer/wip-grok.git
|
|
614
|
+
const match = url.match(/github\.com[:/](.+?)(?:\.git)?$/);
|
|
615
|
+
return match ? match[1] : null;
|
|
616
|
+
} catch {
|
|
617
|
+
return null;
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// ── Stale Branch Check ──────────────────────────────────────────────
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* Check for remote branches that are already merged into origin/main.
|
|
625
|
+
* These should be cleaned up before releasing.
|
|
626
|
+
*
|
|
627
|
+
* For patch: WARN (non-blocking, just print stale branches).
|
|
628
|
+
* For minor/major: BLOCK (return { failed: true }).
|
|
629
|
+
*
|
|
630
|
+
* Filters out origin/main, origin/HEAD, and already-renamed --merged- branches.
|
|
631
|
+
*/
|
|
632
|
+
export function checkStaleBranches(repoPath, level) {
|
|
633
|
+
try {
|
|
634
|
+
// Fetch latest remote state so --merged check is accurate
|
|
635
|
+
try {
|
|
636
|
+
execFileSync('git', ['fetch', '--prune'], { cwd: repoPath, stdio: 'pipe' });
|
|
637
|
+
} catch {
|
|
638
|
+
// Non-fatal: proceed with local state if fetch fails
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
const raw = execFileSync('git', ['branch', '-r', '--merged', 'origin/main'], {
|
|
642
|
+
cwd: repoPath, encoding: 'utf8'
|
|
643
|
+
}).trim();
|
|
644
|
+
|
|
645
|
+
if (!raw) return { stale: [], ok: true };
|
|
646
|
+
|
|
647
|
+
const stale = raw.split('\n')
|
|
648
|
+
.map(b => b.trim())
|
|
649
|
+
.filter(b =>
|
|
650
|
+
b &&
|
|
651
|
+
!b.includes('origin/main') &&
|
|
652
|
+
!b.includes('origin/HEAD') &&
|
|
653
|
+
!b.includes('--merged-')
|
|
654
|
+
);
|
|
655
|
+
|
|
656
|
+
if (stale.length === 0) return { stale: [], ok: true };
|
|
657
|
+
|
|
658
|
+
const isMinorOrMajor = level === 'minor' || level === 'major';
|
|
659
|
+
return {
|
|
660
|
+
stale,
|
|
661
|
+
ok: !isMinorOrMajor,
|
|
662
|
+
blocked: isMinorOrMajor,
|
|
663
|
+
};
|
|
664
|
+
} catch {
|
|
665
|
+
// Git command failed... skip check gracefully
|
|
666
|
+
return { stale: [], ok: true, skipped: true };
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// ── Main ────────────────────────────────────────────────────────────
|
|
671
|
+
|
|
672
|
+
/**
|
|
673
|
+
* Run the full release pipeline.
|
|
674
|
+
*/
|
|
675
|
+
export async function release({ repoPath, level, notes, notesSource, dryRun, noPublish, skipProductCheck, skipStaleCheck, skipWorktreeCheck }) {
|
|
676
|
+
repoPath = repoPath || process.cwd();
|
|
677
|
+
const currentVersion = detectCurrentVersion(repoPath);
|
|
678
|
+
const newVersion = bumpSemver(currentVersion, level);
|
|
679
|
+
const repoName = basename(repoPath);
|
|
680
|
+
|
|
681
|
+
console.log('');
|
|
682
|
+
console.log(` ${repoName}: ${currentVersion} -> ${newVersion} (${level})`);
|
|
683
|
+
console.log(` ${'─'.repeat(40)}`);
|
|
684
|
+
|
|
685
|
+
// -1. Worktree guard: block releases from linked worktrees
|
|
686
|
+
if (!skipWorktreeCheck) {
|
|
687
|
+
try {
|
|
688
|
+
const gitDir = execFileSync('git', ['rev-parse', '--git-dir'], {
|
|
689
|
+
cwd: repoPath, encoding: 'utf8'
|
|
690
|
+
}).trim();
|
|
691
|
+
|
|
692
|
+
// Linked worktrees have "/worktrees/" in their git-dir path
|
|
693
|
+
if (gitDir.includes('/worktrees/')) {
|
|
694
|
+
// Get the main working tree path from `git worktree list`
|
|
695
|
+
const worktreeList = execFileSync('git', ['worktree', 'list', '--porcelain'], {
|
|
696
|
+
cwd: repoPath, encoding: 'utf8'
|
|
697
|
+
});
|
|
698
|
+
const mainWorktree = worktreeList.split('\n')
|
|
699
|
+
.find(line => line.startsWith('worktree '));
|
|
700
|
+
const mainPath = mainWorktree ? mainWorktree.replace('worktree ', '') : '(unknown)';
|
|
701
|
+
|
|
702
|
+
console.log(` \u2717 wip-release must run from the main working tree, not a worktree.`);
|
|
703
|
+
console.log(` Current: ${repoPath}`);
|
|
704
|
+
console.log(` Main working tree: ${mainPath}`);
|
|
705
|
+
console.log(` Switch to the main working tree and run again.`);
|
|
706
|
+
console.log('');
|
|
707
|
+
return { currentVersion, newVersion, dryRun: false, failed: true };
|
|
708
|
+
}
|
|
709
|
+
console.log(' \u2713 Running from main working tree');
|
|
710
|
+
} catch {
|
|
711
|
+
// Git command failed... skip check gracefully
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// 0. License compliance gate
|
|
716
|
+
const configPath = join(repoPath, '.license-guard.json');
|
|
717
|
+
if (existsSync(configPath)) {
|
|
718
|
+
const config = JSON.parse(readFileSync(configPath, 'utf8'));
|
|
719
|
+
const licenseIssues = [];
|
|
720
|
+
|
|
721
|
+
const licensePath = join(repoPath, 'LICENSE');
|
|
722
|
+
if (!existsSync(licensePath)) {
|
|
723
|
+
licenseIssues.push('LICENSE file is missing');
|
|
724
|
+
} else {
|
|
725
|
+
const licenseText = readFileSync(licensePath, 'utf8');
|
|
726
|
+
if (!licenseText.includes(config.copyright)) {
|
|
727
|
+
licenseIssues.push(`LICENSE copyright does not match "${config.copyright}"`);
|
|
728
|
+
}
|
|
729
|
+
if (config.license === 'MIT+AGPL' && !licenseText.includes('AGPL') && !licenseText.includes('GNU Affero')) {
|
|
730
|
+
licenseIssues.push('LICENSE is MIT-only but config requires MIT+AGPL');
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
if (!existsSync(join(repoPath, 'CLA.md'))) {
|
|
735
|
+
licenseIssues.push('CLA.md is missing');
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
const readmePath = join(repoPath, 'README.md');
|
|
739
|
+
if (existsSync(readmePath)) {
|
|
740
|
+
const readme = readFileSync(readmePath, 'utf8');
|
|
741
|
+
if (!readme.includes('## License')) licenseIssues.push('README.md missing ## License section');
|
|
742
|
+
if (config.license === 'MIT+AGPL' && !readme.includes('AGPL')) licenseIssues.push('README.md License section missing AGPL reference');
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
if (licenseIssues.length > 0) {
|
|
746
|
+
console.log(` ✗ License compliance failed:`);
|
|
747
|
+
for (const issue of licenseIssues) console.log(` - ${issue}`);
|
|
748
|
+
console.log(`\n Run \`wip-license-guard check --fix\` to auto-repair, then try again.`);
|
|
749
|
+
console.log('');
|
|
750
|
+
return { currentVersion, newVersion, dryRun: false, failed: true };
|
|
751
|
+
}
|
|
752
|
+
console.log(` ✓ License compliance passed`);
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
// 0.5. Product docs check
|
|
756
|
+
if (!skipProductCheck) {
|
|
757
|
+
const productCheck = checkProductDocs(repoPath);
|
|
758
|
+
if (!productCheck.skipped) {
|
|
759
|
+
if (productCheck.ok) {
|
|
760
|
+
console.log(' ✓ Product docs up to date');
|
|
761
|
+
} else {
|
|
762
|
+
const isMinorOrMajor = level === 'minor' || level === 'major';
|
|
763
|
+
const prefix = isMinorOrMajor ? '✗' : '!';
|
|
764
|
+
console.log(` ${prefix} Product docs need attention:`);
|
|
765
|
+
for (const m of productCheck.missing) console.log(` - ${m}`);
|
|
766
|
+
if (isMinorOrMajor) {
|
|
767
|
+
console.log('');
|
|
768
|
+
console.log(' Update product docs before a minor/major release.');
|
|
769
|
+
console.log(' Use --skip-product-check to override.');
|
|
770
|
+
console.log('');
|
|
771
|
+
return { currentVersion, newVersion, dryRun: false, failed: true };
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// 0.75. Release notes quality gate
|
|
778
|
+
{
|
|
779
|
+
const notesCheck = checkReleaseNotes(notes, notesSource || 'flag', level);
|
|
780
|
+
if (notesCheck.ok) {
|
|
781
|
+
const sourceLabel = notesSource === 'file' ? 'from file' : notesSource === 'dev-update' ? 'from dev update' : 'from --notes';
|
|
782
|
+
console.log(` ✓ Release notes OK (${sourceLabel})`);
|
|
783
|
+
} else {
|
|
784
|
+
const isMinorOrMajor = level === 'minor' || level === 'major';
|
|
785
|
+
const prefix = isMinorOrMajor ? '✗' : '!';
|
|
786
|
+
console.log(` ${prefix} Release notes need attention:`);
|
|
787
|
+
for (const issue of notesCheck.issues) console.log(` - ${issue}`);
|
|
788
|
+
if (isMinorOrMajor) {
|
|
789
|
+
console.log('');
|
|
790
|
+
console.log(' Minor/major releases require a RELEASE-NOTES file, not a --notes one-liner.');
|
|
791
|
+
console.log(' Write RELEASE-NOTES-v{version}.md (dashes not dots), commit it, then release.');
|
|
792
|
+
console.log('');
|
|
793
|
+
return { currentVersion, newVersion, dryRun: false, failed: true };
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
// 0.8. Stale remote branch check
|
|
799
|
+
if (!skipStaleCheck) {
|
|
800
|
+
const staleCheck = checkStaleBranches(repoPath, level);
|
|
801
|
+
if (staleCheck.skipped) {
|
|
802
|
+
// Silently skip if git command failed
|
|
803
|
+
} else if (staleCheck.stale.length === 0) {
|
|
804
|
+
console.log(' ✓ No stale remote branches');
|
|
805
|
+
} else {
|
|
806
|
+
const isMinorOrMajor = level === 'minor' || level === 'major';
|
|
807
|
+
const prefix = isMinorOrMajor ? '✗' : '!';
|
|
808
|
+
console.log(` ${prefix} Stale remote branches merged into main:`);
|
|
809
|
+
for (const b of staleCheck.stale) console.log(` - ${b}`);
|
|
810
|
+
if (isMinorOrMajor) {
|
|
811
|
+
console.log('');
|
|
812
|
+
console.log(' Clean up stale branches before a minor/major release.');
|
|
813
|
+
console.log(' Delete them with: git push origin --delete <branch>');
|
|
814
|
+
console.log(' Use --skip-stale-check to override.');
|
|
815
|
+
console.log('');
|
|
816
|
+
return { currentVersion, newVersion, dryRun: false, failed: true };
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
if (dryRun) {
|
|
822
|
+
// Product docs check (dry-run)
|
|
823
|
+
if (!skipProductCheck) {
|
|
824
|
+
const productCheck = checkProductDocs(repoPath);
|
|
825
|
+
if (!productCheck.skipped) {
|
|
826
|
+
if (productCheck.ok) {
|
|
827
|
+
console.log(' [dry run] ✓ Product docs up to date');
|
|
828
|
+
} else {
|
|
829
|
+
const isMinorOrMajor = level === 'minor' || level === 'major';
|
|
830
|
+
console.log(` [dry run] ${isMinorOrMajor ? '✗ Would BLOCK' : '! Would WARN'}: product docs need updates`);
|
|
831
|
+
for (const m of productCheck.missing) console.log(` - ${m}`);
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
// Release notes check (dry-run)
|
|
836
|
+
{
|
|
837
|
+
const notesCheck = checkReleaseNotes(notes, notesSource || 'flag', level);
|
|
838
|
+
if (notesCheck.ok) {
|
|
839
|
+
const sourceLabel = notesSource === 'file' ? 'from file' : notesSource === 'dev-update' ? 'from dev update' : 'from --notes';
|
|
840
|
+
console.log(` [dry run] ✓ Release notes OK (${sourceLabel})`);
|
|
841
|
+
} else {
|
|
842
|
+
const isMinorOrMajor = level === 'minor' || level === 'major';
|
|
843
|
+
console.log(` [dry run] ${isMinorOrMajor ? '✗ Would BLOCK' : '! Would WARN'}: release notes need attention`);
|
|
844
|
+
for (const issue of notesCheck.issues) console.log(` - ${issue}`);
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
// Stale branch check (dry-run)
|
|
848
|
+
if (!skipStaleCheck) {
|
|
849
|
+
const staleCheck = checkStaleBranches(repoPath, level);
|
|
850
|
+
if (!staleCheck.skipped && staleCheck.stale.length > 0) {
|
|
851
|
+
const isMinorOrMajor = level === 'minor' || level === 'major';
|
|
852
|
+
console.log(` [dry run] ${isMinorOrMajor ? '✗ Would BLOCK' : '! Would WARN'}: stale remote branches`);
|
|
853
|
+
for (const b of staleCheck.stale) console.log(` - ${b}`);
|
|
854
|
+
} else if (!staleCheck.skipped) {
|
|
855
|
+
console.log(' [dry run] ✓ No stale remote branches');
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
const hasSkill = existsSync(join(repoPath, 'SKILL.md'));
|
|
859
|
+
console.log(` [dry run] Would bump package.json to ${newVersion}`);
|
|
860
|
+
if (hasSkill) console.log(` [dry run] Would update SKILL.md version`);
|
|
861
|
+
console.log(` [dry run] Would update CHANGELOG.md`);
|
|
862
|
+
console.log(` [dry run] Would commit and tag v${newVersion}`);
|
|
863
|
+
if (!noPublish) {
|
|
864
|
+
console.log(` [dry run] Would publish to npm (@wipcomputer scope)`);
|
|
865
|
+
console.log(` [dry run] Would publish to GitHub Packages`);
|
|
866
|
+
console.log(` [dry run] Would create GitHub release v${newVersion}`);
|
|
867
|
+
if (hasSkill) console.log(` [dry run] Would publish to ClawHub`);
|
|
868
|
+
// Skill-to-website dry run (auto-detects SKILL.md, no config needed)
|
|
869
|
+
if (hasSkill) {
|
|
870
|
+
const envSet = !!process.env.WIP_WEBSITE_REPO;
|
|
871
|
+
if (envSet) {
|
|
872
|
+
// Resolve name same way as publishSkillToWebsite
|
|
873
|
+
let dryName;
|
|
874
|
+
const publishConfig = join(repoPath, '.publish-skill.json');
|
|
875
|
+
if (existsSync(publishConfig)) {
|
|
876
|
+
try { dryName = JSON.parse(readFileSync(publishConfig, 'utf8')).name; } catch {}
|
|
877
|
+
}
|
|
878
|
+
if (!dryName) {
|
|
879
|
+
const pkgPath = join(repoPath, 'package.json');
|
|
880
|
+
if (existsSync(pkgPath)) {
|
|
881
|
+
try { dryName = JSON.parse(readFileSync(pkgPath, 'utf8')).name?.replace(/^@[^/]+\//, ''); } catch {}
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
if (!dryName) dryName = basename(repoPath).replace(/-private$/, '').toLowerCase();
|
|
885
|
+
console.log(` [dry run] Would publish SKILL.md to website: install/${dryName}.txt`);
|
|
886
|
+
} else {
|
|
887
|
+
console.log(` [dry run] Would publish SKILL.md to website but WIP_WEBSITE_REPO not set`);
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
console.log('');
|
|
892
|
+
console.log(` Dry run complete. No changes made.`);
|
|
893
|
+
console.log('');
|
|
894
|
+
return { currentVersion, newVersion, dryRun: true };
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
// 1. Bump package.json
|
|
898
|
+
writePackageVersion(repoPath, newVersion);
|
|
899
|
+
console.log(` ✓ package.json -> ${newVersion}`);
|
|
900
|
+
|
|
901
|
+
// 1.5. Bump sub-tool versions in toolbox repos (tools/*/)
|
|
902
|
+
const toolsDir = join(repoPath, 'tools');
|
|
903
|
+
if (existsSync(toolsDir)) {
|
|
904
|
+
let subBumped = 0;
|
|
905
|
+
try {
|
|
906
|
+
const entries = readdirSync(toolsDir, { withFileTypes: true });
|
|
907
|
+
for (const entry of entries) {
|
|
908
|
+
if (!entry.isDirectory()) continue;
|
|
909
|
+
const subPkgPath = join(toolsDir, entry.name, 'package.json');
|
|
910
|
+
if (existsSync(subPkgPath)) {
|
|
911
|
+
try {
|
|
912
|
+
const subPkg = JSON.parse(readFileSync(subPkgPath, 'utf8'));
|
|
913
|
+
subPkg.version = newVersion;
|
|
914
|
+
writeFileSync(subPkgPath, JSON.stringify(subPkg, null, 2) + '\n');
|
|
915
|
+
subBumped++;
|
|
916
|
+
} catch {}
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
} catch {}
|
|
920
|
+
if (subBumped > 0) {
|
|
921
|
+
console.log(` ✓ ${subBumped} sub-tool(s) -> ${newVersion}`);
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
// 2. Sync SKILL.md
|
|
926
|
+
if (syncSkillVersion(repoPath, newVersion)) {
|
|
927
|
+
console.log(` ✓ SKILL.md -> ${newVersion}`);
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
// 3. Update CHANGELOG.md
|
|
931
|
+
updateChangelog(repoPath, newVersion, notes);
|
|
932
|
+
console.log(` ✓ CHANGELOG.md updated`);
|
|
933
|
+
|
|
934
|
+
// 3.5. Move RELEASE-NOTES-v*.md to _trash/
|
|
935
|
+
const trashed = trashReleaseNotes(repoPath);
|
|
936
|
+
if (trashed > 0) {
|
|
937
|
+
console.log(` ✓ Moved ${trashed} RELEASE-NOTES file(s) to _trash/`);
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
// 4. Git commit + tag
|
|
941
|
+
gitCommitAndTag(repoPath, newVersion, notes);
|
|
942
|
+
console.log(` ✓ Committed and tagged v${newVersion}`);
|
|
943
|
+
|
|
944
|
+
// 5. Push commit + tag
|
|
945
|
+
try {
|
|
946
|
+
execSync('git push && git push --tags', { cwd: repoPath, stdio: 'pipe' });
|
|
947
|
+
console.log(` ✓ Pushed to remote`);
|
|
948
|
+
} catch {
|
|
949
|
+
console.log(` ! Push failed (maybe branch protection). Push manually.`);
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
// Distribution results collector (#104)
|
|
953
|
+
const distResults = [];
|
|
954
|
+
|
|
955
|
+
if (!noPublish) {
|
|
956
|
+
// 6. npm publish
|
|
957
|
+
try {
|
|
958
|
+
publishNpm(repoPath);
|
|
959
|
+
const pkg = JSON.parse(readFileSync(join(repoPath, 'package.json'), 'utf8'));
|
|
960
|
+
distResults.push({ target: 'npm', status: 'ok', detail: `${pkg.name}@${newVersion}` });
|
|
961
|
+
console.log(` ✓ Published to npm`);
|
|
962
|
+
} catch (e) {
|
|
963
|
+
distResults.push({ target: 'npm', status: 'failed', detail: e.message });
|
|
964
|
+
console.log(` ✗ npm publish failed: ${e.message}`);
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
// 7. GitHub Packages
|
|
968
|
+
try {
|
|
969
|
+
publishGitHubPackages(repoPath);
|
|
970
|
+
distResults.push({ target: 'GitHub Packages', status: 'ok', detail: `${newVersion}` });
|
|
971
|
+
console.log(` ✓ Published to GitHub Packages`);
|
|
972
|
+
} catch (e) {
|
|
973
|
+
distResults.push({ target: 'GitHub Packages', status: 'failed', detail: e.message });
|
|
974
|
+
console.log(` ✗ GitHub Packages publish failed: ${e.message}`);
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
// 8. GitHub release
|
|
978
|
+
try {
|
|
979
|
+
createGitHubRelease(repoPath, newVersion, notes, currentVersion);
|
|
980
|
+
distResults.push({ target: 'GitHub', status: 'ok', detail: `v${newVersion}` });
|
|
981
|
+
console.log(` ✓ GitHub release v${newVersion} created`);
|
|
982
|
+
} catch (e) {
|
|
983
|
+
distResults.push({ target: 'GitHub', status: 'failed', detail: e.message });
|
|
984
|
+
console.log(` ✗ GitHub release failed: ${e.message}`);
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
// 9. ClawHub skill publish (root + sub-tools)
|
|
988
|
+
const rootSkill = join(repoPath, 'SKILL.md');
|
|
989
|
+
const toolsDir = join(repoPath, 'tools');
|
|
990
|
+
|
|
991
|
+
// Publish root SKILL.md
|
|
992
|
+
if (existsSync(rootSkill)) {
|
|
993
|
+
try {
|
|
994
|
+
publishClawHub(repoPath, newVersion, notes);
|
|
995
|
+
const slug = detectSkillSlug(repoPath);
|
|
996
|
+
distResults.push({ target: `ClawHub`, status: 'ok', detail: `${slug}@${newVersion}` });
|
|
997
|
+
console.log(` ✓ Published to ClawHub: ${slug}`);
|
|
998
|
+
} catch (e) {
|
|
999
|
+
distResults.push({ target: 'ClawHub (root)', status: 'failed', detail: e.message });
|
|
1000
|
+
console.log(` ✗ ClawHub publish failed: ${e.message}`);
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
// Publish each sub-tool SKILL.md (#97)
|
|
1005
|
+
if (existsSync(toolsDir)) {
|
|
1006
|
+
for (const tool of readdirSync(toolsDir)) {
|
|
1007
|
+
const toolPath = join(toolsDir, tool);
|
|
1008
|
+
const toolSkill = join(toolPath, 'SKILL.md');
|
|
1009
|
+
if (existsSync(toolSkill)) {
|
|
1010
|
+
try {
|
|
1011
|
+
publishClawHub(toolPath, newVersion, notes);
|
|
1012
|
+
const slug = detectSkillSlug(toolPath);
|
|
1013
|
+
distResults.push({ target: `ClawHub`, status: 'ok', detail: `${slug}@${newVersion}` });
|
|
1014
|
+
console.log(` ✓ Published to ClawHub: ${slug}`);
|
|
1015
|
+
} catch (e) {
|
|
1016
|
+
const slug = detectSkillSlug(toolPath);
|
|
1017
|
+
distResults.push({ target: `ClawHub (${slug})`, status: 'failed', detail: e.message });
|
|
1018
|
+
console.log(` ✗ ClawHub publish failed for ${slug}: ${e.message}`);
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
// 9.5. Publish SKILL.md to website as plain text
|
|
1025
|
+
const skillWebResult = publishSkillToWebsite(repoPath);
|
|
1026
|
+
if (skillWebResult.skipped) {
|
|
1027
|
+
// Silent skip ... no config or env var
|
|
1028
|
+
} else if (skillWebResult.ok) {
|
|
1029
|
+
const deployNote = skillWebResult.deployed ? '' : ' (copied, deploy skipped)';
|
|
1030
|
+
distResults.push({ target: 'Website', status: 'ok', detail: `install/${skillWebResult.target}.txt${deployNote}` });
|
|
1031
|
+
console.log(` ✓ Published to website: install/${skillWebResult.target}.txt${deployNote}`);
|
|
1032
|
+
if (!skillWebResult.deployed && skillWebResult.error) {
|
|
1033
|
+
console.log(` ! ${skillWebResult.error}`);
|
|
1034
|
+
}
|
|
1035
|
+
} else {
|
|
1036
|
+
distResults.push({ target: 'Website', status: 'failed', detail: skillWebResult.error });
|
|
1037
|
+
console.log(` ✗ Website publish failed: ${skillWebResult.error}`);
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
// Distribution summary (#104)
|
|
1042
|
+
if (distResults.length > 0) {
|
|
1043
|
+
console.log('');
|
|
1044
|
+
console.log(' Distribution:');
|
|
1045
|
+
for (const r of distResults) {
|
|
1046
|
+
const icon = r.status === 'ok' ? '✓' : '✗';
|
|
1047
|
+
console.log(` ${icon} ${r.target}: ${r.detail}`);
|
|
1048
|
+
}
|
|
1049
|
+
const failed = distResults.filter(r => r.status !== 'ok');
|
|
1050
|
+
if (failed.length > 0) {
|
|
1051
|
+
console.log(`\n ! ${failed.length} of ${distResults.length} target(s) failed.`);
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
// 10. Post-merge branch cleanup: rename merged branches with --merged-YYYY-MM-DD
|
|
1056
|
+
try {
|
|
1057
|
+
const merged = execSync(
|
|
1058
|
+
'git branch --merged main', { cwd: repoPath, encoding: 'utf8' }
|
|
1059
|
+
).split('\n')
|
|
1060
|
+
.map(b => b.trim())
|
|
1061
|
+
.filter(b => b && b !== 'main' && b !== 'master' && !b.startsWith('*') && !b.includes('--merged-'));
|
|
1062
|
+
|
|
1063
|
+
if (merged.length > 0) {
|
|
1064
|
+
console.log(` Scanning ${merged.length} merged branch(es) for rename...`);
|
|
1065
|
+
for (const branch of merged) {
|
|
1066
|
+
const current = execSync('git branch --show-current', { cwd: repoPath, encoding: 'utf8' }).trim();
|
|
1067
|
+
if (branch === current) continue;
|
|
1068
|
+
|
|
1069
|
+
let mergeDate;
|
|
1070
|
+
try {
|
|
1071
|
+
const mergeBase = execSync(`git merge-base main ${branch}`, { cwd: repoPath, encoding: 'utf8' }).trim();
|
|
1072
|
+
mergeDate = execSync(
|
|
1073
|
+
`git log main --format="%ai" --ancestry-path ${mergeBase}..main`,
|
|
1074
|
+
{ cwd: repoPath, encoding: 'utf8' }
|
|
1075
|
+
).trim().split('\n').pop().split(' ')[0];
|
|
1076
|
+
} catch {}
|
|
1077
|
+
if (!mergeDate) {
|
|
1078
|
+
try {
|
|
1079
|
+
mergeDate = execSync(`git log ${branch} -1 --format="%ai"`, { cwd: repoPath, encoding: 'utf8' }).trim().split(' ')[0];
|
|
1080
|
+
} catch {}
|
|
1081
|
+
}
|
|
1082
|
+
if (!mergeDate) continue;
|
|
1083
|
+
|
|
1084
|
+
const newName = `${branch}--merged-${mergeDate}`;
|
|
1085
|
+
try {
|
|
1086
|
+
execSync(`git branch -m "${branch}" "${newName}"`, { cwd: repoPath, stdio: 'pipe' });
|
|
1087
|
+
execSync(`git push origin "${newName}"`, { cwd: repoPath, stdio: 'pipe' });
|
|
1088
|
+
execSync(`git push origin --delete "${branch}"`, { cwd: repoPath, stdio: 'pipe' });
|
|
1089
|
+
console.log(` ✓ Renamed: ${branch} -> ${newName}`);
|
|
1090
|
+
} catch (e) {
|
|
1091
|
+
console.log(` ! Could not rename ${branch}: ${e.message}`);
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
} catch (e) {
|
|
1096
|
+
// Non-fatal: branch cleanup is a convenience, not a blocker
|
|
1097
|
+
console.log(` ! Branch cleanup skipped: ${e.message}`);
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
// 11. Prune old merged branches (keep last 3 per developer prefix)
|
|
1101
|
+
try {
|
|
1102
|
+
const KEEP_COUNT = 3;
|
|
1103
|
+
const remoteBranches = execSync(
|
|
1104
|
+
'git branch -r', { cwd: repoPath, encoding: 'utf8' }
|
|
1105
|
+
).split('\n')
|
|
1106
|
+
.map(b => b.trim())
|
|
1107
|
+
.filter(b => b && !b.includes('HEAD') && b.includes('--merged-'))
|
|
1108
|
+
.map(b => b.replace('origin/', ''));
|
|
1109
|
+
|
|
1110
|
+
if (remoteBranches.length > 0) {
|
|
1111
|
+
// Group by developer prefix (everything before first /)
|
|
1112
|
+
const byPrefix = {};
|
|
1113
|
+
for (const branch of remoteBranches) {
|
|
1114
|
+
const prefix = branch.split('/')[0];
|
|
1115
|
+
if (!byPrefix[prefix]) byPrefix[prefix] = [];
|
|
1116
|
+
byPrefix[prefix].push(branch);
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
let pruned = 0;
|
|
1120
|
+
for (const [prefix, branches] of Object.entries(byPrefix)) {
|
|
1121
|
+
// Sort by date descending (date is at the end: --merged-YYYY-MM-DD)
|
|
1122
|
+
branches.sort((a, b) => {
|
|
1123
|
+
const dateA = a.match(/--merged-(\d{4}-\d{2}-\d{2})/)?.[1] || '';
|
|
1124
|
+
const dateB = b.match(/--merged-(\d{4}-\d{2}-\d{2})/)?.[1] || '';
|
|
1125
|
+
return dateB.localeCompare(dateA);
|
|
1126
|
+
});
|
|
1127
|
+
|
|
1128
|
+
for (let i = KEEP_COUNT; i < branches.length; i++) {
|
|
1129
|
+
try {
|
|
1130
|
+
execSync(`git push origin --delete "${branches[i]}"`, { cwd: repoPath, stdio: 'pipe' });
|
|
1131
|
+
execSync(`git branch -d "${branches[i]}" 2>/dev/null || true`, { cwd: repoPath, stdio: 'pipe', shell: true });
|
|
1132
|
+
pruned++;
|
|
1133
|
+
} catch {}
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
if (pruned > 0) {
|
|
1138
|
+
console.log(` ✓ Pruned ${pruned} old merged branch(es)`);
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
// Clean stale branches (merged into main but never renamed)
|
|
1143
|
+
const current = execSync('git branch --show-current', { cwd: repoPath, encoding: 'utf8' }).trim();
|
|
1144
|
+
const allRemote = execSync(
|
|
1145
|
+
'git branch -r', { cwd: repoPath, encoding: 'utf8' }
|
|
1146
|
+
).split('\n')
|
|
1147
|
+
.map(b => b.trim())
|
|
1148
|
+
.filter(b => b && !b.includes('HEAD') && !b.includes('origin/main') && !b.includes('--merged-'))
|
|
1149
|
+
.map(b => b.replace('origin/', ''));
|
|
1150
|
+
|
|
1151
|
+
let staleCleaned = 0;
|
|
1152
|
+
for (const branch of allRemote) {
|
|
1153
|
+
if (branch === current) continue;
|
|
1154
|
+
try {
|
|
1155
|
+
execSync(`git merge-base --is-ancestor origin/${branch} origin/main`, { cwd: repoPath, stdio: 'pipe' });
|
|
1156
|
+
// If we get here, branch is fully merged
|
|
1157
|
+
execSync(`git push origin --delete "${branch}"`, { cwd: repoPath, stdio: 'pipe' });
|
|
1158
|
+
execSync(`git branch -d "${branch}" 2>/dev/null || true`, { cwd: repoPath, stdio: 'pipe', shell: true });
|
|
1159
|
+
staleCleaned++;
|
|
1160
|
+
} catch {}
|
|
1161
|
+
}
|
|
1162
|
+
if (staleCleaned > 0) {
|
|
1163
|
+
console.log(` ✓ Cleaned ${staleCleaned} stale branch(es)`);
|
|
1164
|
+
}
|
|
1165
|
+
} catch (e) {
|
|
1166
|
+
console.log(` ! Branch prune skipped: ${e.message}`);
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
console.log('');
|
|
1170
|
+
console.log(` Done. ${repoName} v${newVersion} released.`);
|
|
1171
|
+
console.log('');
|
|
1172
|
+
|
|
1173
|
+
return { currentVersion, newVersion, dryRun: false };
|
|
1174
|
+
}
|