615 lines
20 KiB
JavaScript
615 lines
20 KiB
JavaScript
|
|
const { execSync, spawnSync } = require('child_process');
|
||
|
|
const fs = require('fs');
|
||
|
|
const https = require('https');
|
||
|
|
const os = require('os');
|
||
|
|
const path = require('path');
|
||
|
|
|
||
|
|
function run(cmd, opts = {}) {
|
||
|
|
const { dryRun = false } = opts;
|
||
|
|
if (dryRun) {
|
||
|
|
process.stdout.write(`[dry-run] ${cmd}\n`);
|
||
|
|
return '';
|
||
|
|
}
|
||
|
|
return execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
|
||
|
|
}
|
||
|
|
|
||
|
|
function hasCommand(cmd) {
|
||
|
|
try {
|
||
|
|
if (process.platform === 'win32') {
|
||
|
|
const res = spawnSync('where', [cmd], { stdio: 'ignore' });
|
||
|
|
return res.status === 0;
|
||
|
|
}
|
||
|
|
const res = spawnSync('which', [cmd], { stdio: 'ignore' });
|
||
|
|
return res.status === 0;
|
||
|
|
} catch (e) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function resolveGhExecutable() {
|
||
|
|
if (hasCommand('gh')) return 'gh';
|
||
|
|
const candidates = [
|
||
|
|
'C:\\Program Files\\GitHub CLI\\gh.exe',
|
||
|
|
'C:\\Program Files (x86)\\GitHub CLI\\gh.exe',
|
||
|
|
];
|
||
|
|
for (const p of candidates) {
|
||
|
|
try {
|
||
|
|
if (fs.existsSync(p)) return p;
|
||
|
|
} catch (e) {
|
||
|
|
// ignore
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
function resolveClawhubExecutable() {
|
||
|
|
// On Windows, Node spawn/spawnSync does not always resolve PATHEXT the same way as shells.
|
||
|
|
// Prefer the explicit .cmd shim when available to avoid false "not logged in" detection.
|
||
|
|
if (process.platform === 'win32') {
|
||
|
|
if (hasCommand('clawhub.cmd')) return 'clawhub.cmd';
|
||
|
|
if (hasCommand('clawhub')) return 'clawhub';
|
||
|
|
} else {
|
||
|
|
if (hasCommand('clawhub')) return 'clawhub';
|
||
|
|
}
|
||
|
|
// Common npm global bin location on Windows.
|
||
|
|
const candidates = [
|
||
|
|
'C:\\Users\\Administrator\\AppData\\Roaming\\npm\\clawhub.cmd',
|
||
|
|
'C:\\Users\\Administrator\\AppData\\Roaming\\npm\\clawhub.exe',
|
||
|
|
'C:\\Users\\Administrator\\AppData\\Roaming\\npm\\clawhub.ps1',
|
||
|
|
];
|
||
|
|
for (const p of candidates) {
|
||
|
|
try {
|
||
|
|
if (fs.existsSync(p)) return p;
|
||
|
|
} catch (e) {
|
||
|
|
// ignore
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
function canUseClawhub() {
|
||
|
|
const exe = resolveClawhubExecutable();
|
||
|
|
if (!exe) return { ok: false, reason: 'clawhub CLI not found (install: npm i -g clawhub)' };
|
||
|
|
return { ok: true, exe };
|
||
|
|
}
|
||
|
|
|
||
|
|
function isClawhubLoggedIn() {
|
||
|
|
const exe = resolveClawhubExecutable();
|
||
|
|
if (!exe) return false;
|
||
|
|
try {
|
||
|
|
const res = spawnClawhub(exe, ['whoami'], { stdio: 'ignore' });
|
||
|
|
return res.status === 0;
|
||
|
|
} catch (e) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function spawnClawhub(exe, args, options) {
|
||
|
|
// On Windows, directly spawning a .cmd can be flaky; using cmd.exe preserves argument parsing.
|
||
|
|
// (Using shell:true can break clap/commander style option parsing for some CLIs.)
|
||
|
|
const opts = options || {};
|
||
|
|
if (process.platform === 'win32' && typeof exe === 'string') {
|
||
|
|
const lower = exe.toLowerCase();
|
||
|
|
if (lower.endsWith('.cmd')) {
|
||
|
|
return spawnSync('cmd.exe', ['/d', '/s', '/c', exe, ...(args || [])], opts);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return spawnSync(exe, args || [], opts);
|
||
|
|
}
|
||
|
|
|
||
|
|
function publishToClawhub({ skillDir, slug, name, version, changelog, tags, dryRun }) {
|
||
|
|
const ok = canUseClawhub();
|
||
|
|
if (!ok.ok) throw new Error(ok.reason);
|
||
|
|
|
||
|
|
// Idempotency: if this version already exists on ClawHub, skip publishing.
|
||
|
|
try {
|
||
|
|
const inspect = spawnClawhub(ok.exe, ['inspect', slug, '--version', version], { stdio: 'ignore' });
|
||
|
|
if (inspect.status === 0) {
|
||
|
|
process.stdout.write(`ClawHub already has ${slug}@${version}. Skipping.\n`);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
} catch (e) {
|
||
|
|
// ignore inspect failures; publish will surface errors if needed
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!dryRun && !isClawhubLoggedIn()) {
|
||
|
|
throw new Error('Not logged in to ClawHub. Run: clawhub login');
|
||
|
|
}
|
||
|
|
|
||
|
|
const args = ['publish', skillDir, '--slug', slug, '--name', name, '--version', version];
|
||
|
|
if (changelog) args.push('--changelog', changelog);
|
||
|
|
if (tags) args.push('--tags', tags);
|
||
|
|
|
||
|
|
if (dryRun) {
|
||
|
|
process.stdout.write(`[dry-run] ${ok.exe} ${args.map(a => (/\s/.test(a) ? `"${a}"` : a)).join(' ')}\n`);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Capture output to handle "version already exists" idempotently.
|
||
|
|
const res = spawnClawhub(ok.exe, args, { encoding: 'utf8' });
|
||
|
|
const out = `${res.stdout || ''}\n${res.stderr || ''}`.trim();
|
||
|
|
|
||
|
|
if (res.status === 0) {
|
||
|
|
if (out) process.stdout.write(out + '\n');
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Some clawhub deployments do not support reliable "inspect" by slug.
|
||
|
|
// Treat "Version already exists" as success to make publishing idempotent.
|
||
|
|
if (/version already exists/i.test(out)) {
|
||
|
|
process.stdout.write(`ClawHub already has ${slug}@${version}. Skipping.\n`);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (out) process.stderr.write(out + '\n');
|
||
|
|
throw new Error(`clawhub publish failed for slug ${slug}`);
|
||
|
|
}
|
||
|
|
|
||
|
|
function requireEnv(name, value) {
|
||
|
|
if (!value) {
|
||
|
|
throw new Error(`Missing required env var: ${name}`);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function ensureClean(dryRun) {
|
||
|
|
const status = run('git status --porcelain', { dryRun });
|
||
|
|
if (!dryRun && status) {
|
||
|
|
throw new Error('Working tree is not clean. Commit or stash before publishing.');
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function ensureBranch(expected, dryRun) {
|
||
|
|
const current = run('git rev-parse --abbrev-ref HEAD', { dryRun }) || expected;
|
||
|
|
if (!dryRun && current !== expected) {
|
||
|
|
throw new Error(`Current branch is ${current}. Expected ${expected}.`);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function ensureRemote(remote, dryRun) {
|
||
|
|
try {
|
||
|
|
run(`git remote get-url ${remote}`, { dryRun });
|
||
|
|
} catch (e) {
|
||
|
|
throw new Error(`Remote "${remote}" not found. Add it manually before running this script.`);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function ensureTagAvailable(tag, dryRun) {
|
||
|
|
if (!tag) return;
|
||
|
|
const exists = run(`git tag --list ${tag}`, { dryRun });
|
||
|
|
if (!dryRun && exists) {
|
||
|
|
throw new Error(`Tag ${tag} already exists.`);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function ensureDir(dir, dryRun) {
|
||
|
|
if (dryRun) return;
|
||
|
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||
|
|
}
|
||
|
|
|
||
|
|
function rmDir(dir, dryRun) {
|
||
|
|
if (dryRun) return;
|
||
|
|
if (!fs.existsSync(dir)) return;
|
||
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
||
|
|
}
|
||
|
|
|
||
|
|
function copyDir(src, dest, dryRun) {
|
||
|
|
if (dryRun) return;
|
||
|
|
if (!fs.existsSync(src)) throw new Error(`Missing build output dir: ${src}`);
|
||
|
|
ensureDir(dest, dryRun);
|
||
|
|
const entries = fs.readdirSync(src, { withFileTypes: true });
|
||
|
|
for (const ent of entries) {
|
||
|
|
const s = path.join(src, ent.name);
|
||
|
|
const d = path.join(dest, ent.name);
|
||
|
|
if (ent.isDirectory()) copyDir(s, d, dryRun);
|
||
|
|
else if (ent.isFile()) {
|
||
|
|
ensureDir(path.dirname(d), dryRun);
|
||
|
|
fs.copyFileSync(s, d);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function createReleaseWithGh({ repo, tag, title, notes, notesFile, dryRun }) {
|
||
|
|
if (!repo || !tag) return;
|
||
|
|
const ghExe = resolveGhExecutable();
|
||
|
|
if (!ghExe) {
|
||
|
|
throw new Error('gh CLI not found. Install GitHub CLI or provide a GitHub token for API-based release creation.');
|
||
|
|
}
|
||
|
|
const args = ['release', 'create', tag, '--repo', repo];
|
||
|
|
if (title) args.push('-t', title);
|
||
|
|
if (notesFile) args.push('-F', notesFile);
|
||
|
|
else if (notes) args.push('-n', notes);
|
||
|
|
else args.push('-n', 'Release created by publish script.');
|
||
|
|
|
||
|
|
if (dryRun) {
|
||
|
|
process.stdout.write(`[dry-run] ${ghExe} ${args.join(' ')}\n`);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const res = spawnSync(ghExe, args, { stdio: 'inherit' });
|
||
|
|
if (res.status !== 0) {
|
||
|
|
throw new Error('gh release create failed');
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function canUseGhForRelease() {
|
||
|
|
const ghExe = resolveGhExecutable();
|
||
|
|
if (!ghExe) return { ok: false, reason: 'gh CLI not found' };
|
||
|
|
try {
|
||
|
|
// Non-interactive check: returns 0 when authenticated.
|
||
|
|
const res = spawnSync(ghExe, ['auth', 'status', '-h', 'github.com'], { stdio: 'ignore' });
|
||
|
|
if (res.status === 0) return { ok: true };
|
||
|
|
return { ok: false, reason: 'gh not authenticated (run: gh auth login)' };
|
||
|
|
} catch (e) {
|
||
|
|
return { ok: false, reason: 'failed to check gh auth status' };
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function getGithubToken() {
|
||
|
|
return process.env.GITHUB_TOKEN || process.env.GH_TOKEN || process.env.GITHUB_PAT || '';
|
||
|
|
}
|
||
|
|
|
||
|
|
function readReleaseNotes(notes, notesFile) {
|
||
|
|
if (notesFile) {
|
||
|
|
try {
|
||
|
|
return fs.readFileSync(notesFile, 'utf8');
|
||
|
|
} catch (e) {
|
||
|
|
throw new Error(`Failed to read RELEASE_NOTES_FILE: ${notesFile}`);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if (notes) return String(notes);
|
||
|
|
return '';
|
||
|
|
}
|
||
|
|
|
||
|
|
function githubRequestJson({ method, repo, apiPath, token, body, dryRun }) {
|
||
|
|
if (dryRun) {
|
||
|
|
process.stdout.write(`[dry-run] GitHub API ${method} ${repo} ${apiPath}\n`);
|
||
|
|
return Promise.resolve({ status: 200, json: null });
|
||
|
|
}
|
||
|
|
|
||
|
|
const data = body ? Buffer.from(JSON.stringify(body)) : null;
|
||
|
|
const opts = {
|
||
|
|
method,
|
||
|
|
hostname: 'api.github.com',
|
||
|
|
path: `/repos/${repo}${apiPath}`,
|
||
|
|
headers: {
|
||
|
|
'User-Agent': 'evolver-publish-script',
|
||
|
|
Accept: 'application/vnd.github+json',
|
||
|
|
...(token ? { Authorization: `token ${token}` } : {}),
|
||
|
|
...(data ? { 'Content-Type': 'application/json', 'Content-Length': String(data.length) } : {}),
|
||
|
|
},
|
||
|
|
};
|
||
|
|
|
||
|
|
return new Promise((resolve, reject) => {
|
||
|
|
const req = https.request(opts, res => {
|
||
|
|
let raw = '';
|
||
|
|
res.setEncoding('utf8');
|
||
|
|
res.on('data', chunk => (raw += chunk));
|
||
|
|
res.on('end', () => {
|
||
|
|
let json = null;
|
||
|
|
try {
|
||
|
|
json = raw ? JSON.parse(raw) : null;
|
||
|
|
} catch (e) {
|
||
|
|
json = null;
|
||
|
|
}
|
||
|
|
resolve({ status: res.statusCode || 0, json, raw });
|
||
|
|
});
|
||
|
|
});
|
||
|
|
req.on('error', reject);
|
||
|
|
if (data) req.write(data);
|
||
|
|
req.end();
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
async function ensureReleaseWithApi({ repo, tag, title, notes, notesFile, dryRun }) {
|
||
|
|
if (!repo || !tag) return;
|
||
|
|
|
||
|
|
const token = getGithubToken();
|
||
|
|
if (!dryRun) {
|
||
|
|
requireEnv('GITHUB_TOKEN (or GH_TOKEN/GITHUB_PAT)', token);
|
||
|
|
}
|
||
|
|
|
||
|
|
// If release already exists, skip.
|
||
|
|
const existing = await githubRequestJson({
|
||
|
|
method: 'GET',
|
||
|
|
repo,
|
||
|
|
apiPath: `/releases/tags/${encodeURIComponent(tag)}`,
|
||
|
|
token,
|
||
|
|
dryRun,
|
||
|
|
});
|
||
|
|
|
||
|
|
if (!dryRun && existing.status === 200) {
|
||
|
|
process.stdout.write(`Release already exists for tag ${tag}. Skipping.\n`);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const bodyText = readReleaseNotes(notes, notesFile) || 'Release created by publish script.';
|
||
|
|
const payload = {
|
||
|
|
tag_name: tag,
|
||
|
|
name: title || tag,
|
||
|
|
body: bodyText,
|
||
|
|
draft: false,
|
||
|
|
prerelease: false,
|
||
|
|
};
|
||
|
|
|
||
|
|
const created = await githubRequestJson({
|
||
|
|
method: 'POST',
|
||
|
|
repo,
|
||
|
|
apiPath: '/releases',
|
||
|
|
token,
|
||
|
|
body: payload,
|
||
|
|
dryRun,
|
||
|
|
});
|
||
|
|
|
||
|
|
if (!dryRun && (created.status < 200 || created.status >= 300)) {
|
||
|
|
const msg = (created.json && created.json.message) || created.raw || 'Unknown error';
|
||
|
|
throw new Error(`Failed to create GitHub Release (${created.status}): ${msg}`);
|
||
|
|
}
|
||
|
|
|
||
|
|
process.stdout.write(`Created GitHub Release for tag ${tag}\n`);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Collect unique external contributors from private repo commits since the last release.
|
||
|
|
// Returns an array of "Name <email>" strings suitable for Co-authored-by trailers.
|
||
|
|
// GitHub counts Co-authored-by toward the Contributors graph.
|
||
|
|
function getContributorsSinceLastRelease() {
|
||
|
|
const EXCLUDED = new Set([
|
||
|
|
'evolver-publish@local',
|
||
|
|
'evolver@local',
|
||
|
|
'openclaw@users.noreply.github.com',
|
||
|
|
]);
|
||
|
|
|
||
|
|
try {
|
||
|
|
let baseCommit = '';
|
||
|
|
try {
|
||
|
|
baseCommit = execSync(
|
||
|
|
'git log -n 1 --pretty=%H --grep="chore(release): prepare v"',
|
||
|
|
{ encoding: 'utf8', cwd: process.cwd(), stdio: ['ignore', 'pipe', 'ignore'] }
|
||
|
|
).trim();
|
||
|
|
} catch (_) {}
|
||
|
|
|
||
|
|
const range = baseCommit ? `${baseCommit}..HEAD` : '-30';
|
||
|
|
const raw = execSync(
|
||
|
|
`git log ${range} --pretty="%aN <%aE>"`,
|
||
|
|
{ encoding: 'utf8', cwd: process.cwd(), stdio: ['ignore', 'pipe', 'ignore'] }
|
||
|
|
).trim();
|
||
|
|
|
||
|
|
if (!raw) return [];
|
||
|
|
|
||
|
|
const seen = new Set();
|
||
|
|
const contributors = [];
|
||
|
|
for (const line of raw.split('\n')) {
|
||
|
|
const trimmed = line.trim();
|
||
|
|
if (!trimmed) continue;
|
||
|
|
const emailMatch = trimmed.match(/<([^>]+)>/);
|
||
|
|
const email = emailMatch ? emailMatch[1].toLowerCase() : '';
|
||
|
|
if (EXCLUDED.has(email)) continue;
|
||
|
|
if (seen.has(email)) continue;
|
||
|
|
seen.add(email);
|
||
|
|
contributors.push(trimmed);
|
||
|
|
}
|
||
|
|
return contributors;
|
||
|
|
} catch (_) {
|
||
|
|
return [];
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function main() {
|
||
|
|
const dryRun = String(process.env.DRY_RUN || '').toLowerCase() === 'true';
|
||
|
|
|
||
|
|
const sourceBranch = process.env.SOURCE_BRANCH || 'main';
|
||
|
|
const publicRemote = process.env.PUBLIC_REMOTE || 'public';
|
||
|
|
const publicBranch = process.env.PUBLIC_BRANCH || 'main';
|
||
|
|
const publicRepo = process.env.PUBLIC_REPO || '';
|
||
|
|
const outDir = process.env.PUBLIC_OUT_DIR || 'dist-public';
|
||
|
|
const useBuildOutput = String(process.env.PUBLIC_USE_BUILD_OUTPUT || 'true').toLowerCase() === 'true';
|
||
|
|
const releaseOnly = String(process.env.PUBLIC_RELEASE_ONLY || '').toLowerCase() === 'true';
|
||
|
|
|
||
|
|
const clawhubSkip = String(process.env.CLAWHUB_SKIP || '').toLowerCase() === 'true';
|
||
|
|
const clawhubPublish = String(process.env.CLAWHUB_PUBLISH || '').toLowerCase() === 'false' ? false : !clawhubSkip;
|
||
|
|
// Workaround for registry redirect/auth issues: default to the www endpoint.
|
||
|
|
const clawhubRegistry = process.env.CLAWHUB_REGISTRY || 'https://www.clawhub.ai';
|
||
|
|
|
||
|
|
// If publishing build output, require a repo URL or GH repo slug for cloning.
|
||
|
|
if (useBuildOutput) {
|
||
|
|
requireEnv('PUBLIC_REPO', publicRepo);
|
||
|
|
}
|
||
|
|
|
||
|
|
let releaseTag = process.env.RELEASE_TAG || '';
|
||
|
|
let releaseTitle = process.env.RELEASE_TITLE || '';
|
||
|
|
const releaseNotes = process.env.RELEASE_NOTES || '';
|
||
|
|
const releaseNotesFile = process.env.RELEASE_NOTES_FILE || '';
|
||
|
|
const releaseSkip = String(process.env.RELEASE_SKIP || '').toLowerCase() === 'true';
|
||
|
|
// Default behavior: create release unless explicitly skipped.
|
||
|
|
// Backward compatibility: RELEASE_CREATE=true forces creation.
|
||
|
|
// Note: RELEASE_CREATE=false is ignored; use RELEASE_SKIP=true instead.
|
||
|
|
const releaseCreate = String(process.env.RELEASE_CREATE || '').toLowerCase() === 'true' ? true : !releaseSkip;
|
||
|
|
const releaseUseGh = String(process.env.RELEASE_USE_GH || '').toLowerCase() === 'true';
|
||
|
|
|
||
|
|
// If not provided, infer from build output package.json version.
|
||
|
|
if (!releaseTag && useBuildOutput) {
|
||
|
|
try {
|
||
|
|
const builtPkg = JSON.parse(fs.readFileSync(path.resolve(process.cwd(), outDir, 'package.json'), 'utf8'));
|
||
|
|
if (builtPkg && builtPkg.version) releaseTag = `v${builtPkg.version}`;
|
||
|
|
if (!releaseTitle && releaseTag) releaseTitle = releaseTag;
|
||
|
|
} catch (e) {}
|
||
|
|
}
|
||
|
|
|
||
|
|
const releaseVersion = String(releaseTag || '').startsWith('v') ? String(releaseTag).slice(1) : '';
|
||
|
|
|
||
|
|
// Fail fast on missing release prerequisites to avoid half-publishing.
|
||
|
|
// Strategy:
|
||
|
|
// - If RELEASE_USE_GH=true: require gh + auth
|
||
|
|
// - Else: prefer gh+auth; fallback to API token; else fail
|
||
|
|
let releaseMode = 'none';
|
||
|
|
if (releaseCreate && releaseTag) {
|
||
|
|
if (releaseUseGh) {
|
||
|
|
const ghOk = canUseGhForRelease();
|
||
|
|
if (!dryRun && !ghOk.ok) {
|
||
|
|
throw new Error(`Cannot create release via gh: ${ghOk.reason}`);
|
||
|
|
}
|
||
|
|
releaseMode = 'gh';
|
||
|
|
} else {
|
||
|
|
const ghOk = canUseGhForRelease();
|
||
|
|
if (ghOk.ok) {
|
||
|
|
releaseMode = 'gh';
|
||
|
|
} else {
|
||
|
|
const token = getGithubToken();
|
||
|
|
if (!dryRun && !token) {
|
||
|
|
throw new Error(
|
||
|
|
'Cannot create GitHub Release: neither gh (installed+authenticated) nor GITHUB_TOKEN (or GH_TOKEN/GITHUB_PAT) is available.'
|
||
|
|
);
|
||
|
|
}
|
||
|
|
releaseMode = 'api';
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// In release-only mode we do not push code or tags, only create a GitHub Release for an existing tag.
|
||
|
|
if (!releaseOnly) {
|
||
|
|
ensureClean(dryRun);
|
||
|
|
ensureBranch(sourceBranch, dryRun);
|
||
|
|
ensureTagAvailable(releaseTag, dryRun);
|
||
|
|
} else {
|
||
|
|
requireEnv('RELEASE_TAG', releaseTag);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!releaseOnly) {
|
||
|
|
if (!useBuildOutput) {
|
||
|
|
ensureRemote(publicRemote, dryRun);
|
||
|
|
run(`git push ${publicRemote} ${sourceBranch}:${publicBranch}`, { dryRun });
|
||
|
|
} else {
|
||
|
|
const tmpBase = path.join(os.tmpdir(), 'evolver-public-publish');
|
||
|
|
const tmpRepoDir = path.join(tmpBase, `repo_${Date.now()}`);
|
||
|
|
const buildAbs = path.resolve(process.cwd(), outDir);
|
||
|
|
|
||
|
|
rmDir(tmpRepoDir, dryRun);
|
||
|
|
ensureDir(tmpRepoDir, dryRun);
|
||
|
|
|
||
|
|
run(`git clone --depth 1 https://github.com/${publicRepo}.git "${tmpRepoDir}"`, { dryRun });
|
||
|
|
run(`git -C "${tmpRepoDir}" checkout -B ${publicBranch}`, { dryRun });
|
||
|
|
|
||
|
|
// Replace repo contents with build output (except .git)
|
||
|
|
if (!dryRun) {
|
||
|
|
const entries = fs.readdirSync(tmpRepoDir, { withFileTypes: true });
|
||
|
|
for (const ent of entries) {
|
||
|
|
if (ent.name === '.git') continue;
|
||
|
|
fs.rmSync(path.join(tmpRepoDir, ent.name), { recursive: true, force: true });
|
||
|
|
}
|
||
|
|
}
|
||
|
|
copyDir(buildAbs, tmpRepoDir, dryRun);
|
||
|
|
|
||
|
|
run(`git -C "${tmpRepoDir}" add -A`, { dryRun });
|
||
|
|
const msg = releaseTag ? `Release ${releaseTag}` : `Publish build output`;
|
||
|
|
|
||
|
|
// If build output is identical to current public branch, skip commit/push.
|
||
|
|
const pending = run(`git -C "${tmpRepoDir}" status --porcelain`, { dryRun });
|
||
|
|
if (!dryRun && !pending) {
|
||
|
|
process.stdout.write('Public repo already matches build output. Skipping commit/push.\n');
|
||
|
|
} else {
|
||
|
|
const contributors = getContributorsSinceLastRelease();
|
||
|
|
let commitMsg = msg.replace(/"/g, '\\"');
|
||
|
|
if (contributors.length > 0) {
|
||
|
|
const trailers = contributors.map(c => `Co-authored-by: ${c}`).join('\n');
|
||
|
|
commitMsg += `\n\n${trailers.replace(/"/g, '\\"')}`;
|
||
|
|
process.stdout.write(`Including ${contributors.length} contributor(s) in publish commit.\n`);
|
||
|
|
}
|
||
|
|
run(
|
||
|
|
`git -C "${tmpRepoDir}" -c user.name="evolver-publish" -c user.email="evolver-publish@local" commit -m "${commitMsg}"`,
|
||
|
|
{ dryRun }
|
||
|
|
);
|
||
|
|
run(`git -C "${tmpRepoDir}" push origin ${publicBranch}`, { dryRun });
|
||
|
|
}
|
||
|
|
|
||
|
|
if (releaseTag) {
|
||
|
|
const tagMsg = releaseTitle || `Release ${releaseTag}`;
|
||
|
|
// If tag already exists in the public repo, do not recreate it.
|
||
|
|
try {
|
||
|
|
run(`git -C "${tmpRepoDir}" fetch --tags`, { dryRun });
|
||
|
|
const exists = run(`git -C "${tmpRepoDir}" tag --list ${releaseTag}`, { dryRun });
|
||
|
|
if (!dryRun && exists) {
|
||
|
|
process.stdout.write(`Tag ${releaseTag} already exists in public repo. Skipping tag creation.\n`);
|
||
|
|
} else {
|
||
|
|
run(`git -C "${tmpRepoDir}" tag -a ${releaseTag} -m "${tagMsg.replace(/"/g, '\\"')}"`, { dryRun });
|
||
|
|
run(`git -C "${tmpRepoDir}" push origin ${releaseTag}`, { dryRun });
|
||
|
|
}
|
||
|
|
} catch (e) {
|
||
|
|
// If tag operations fail, rethrow to avoid publishing a release without a tag.
|
||
|
|
throw e;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (releaseTag) {
|
||
|
|
if (!useBuildOutput) {
|
||
|
|
const msg = releaseTitle || `Release ${releaseTag}`;
|
||
|
|
run(`git tag -a ${releaseTag} -m "${msg.replace(/"/g, '\\"')}"`, { dryRun });
|
||
|
|
run(`git push ${publicRemote} ${releaseTag}`, { dryRun });
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (releaseCreate) {
|
||
|
|
if (releaseMode === 'gh') {
|
||
|
|
createReleaseWithGh({
|
||
|
|
repo: publicRepo,
|
||
|
|
tag: releaseTag,
|
||
|
|
title: releaseTitle,
|
||
|
|
notes: releaseNotes,
|
||
|
|
notesFile: releaseNotesFile,
|
||
|
|
dryRun,
|
||
|
|
});
|
||
|
|
} else if (releaseMode === 'api') {
|
||
|
|
return ensureReleaseWithApi({
|
||
|
|
repo: publicRepo,
|
||
|
|
tag: releaseTag,
|
||
|
|
title: releaseTitle,
|
||
|
|
notes: releaseNotes,
|
||
|
|
notesFile: releaseNotesFile,
|
||
|
|
dryRun,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Publish to ClawHub after GitHub release succeeds (default enabled).
|
||
|
|
if (clawhubPublish && releaseVersion) {
|
||
|
|
process.env.CLAWHUB_REGISTRY = clawhubRegistry;
|
||
|
|
|
||
|
|
const skillDir = useBuildOutput ? path.resolve(process.cwd(), outDir) : process.cwd();
|
||
|
|
const changelog = releaseTitle ? `GitHub Release ${releaseTitle}` : `GitHub Release ${releaseTag}`;
|
||
|
|
|
||
|
|
publishToClawhub({
|
||
|
|
skillDir,
|
||
|
|
slug: 'evolver',
|
||
|
|
name: 'Evolver',
|
||
|
|
version: releaseVersion,
|
||
|
|
changelog,
|
||
|
|
tags: 'latest',
|
||
|
|
dryRun,
|
||
|
|
});
|
||
|
|
|
||
|
|
publishToClawhub({
|
||
|
|
skillDir,
|
||
|
|
slug: 'capability-evolver',
|
||
|
|
name: 'Evolver',
|
||
|
|
version: releaseVersion,
|
||
|
|
changelog,
|
||
|
|
tags: 'latest',
|
||
|
|
dryRun,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
const maybePromise = main();
|
||
|
|
if (maybePromise && typeof maybePromise.then === 'function') {
|
||
|
|
maybePromise.catch(e => {
|
||
|
|
process.stderr.write(`${e.message}\n`);
|
||
|
|
process.exit(1);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
} catch (e) {
|
||
|
|
process.stderr.write(`${e.message}\n`);
|
||
|
|
process.exit(1);
|
||
|
|
}
|
||
|
|
|