Files
wehub-resource-sync adc62957a7
CI / Multi-repo Path Gate (AST-grep) (windows-latest) (push) Has been cancelled
CI / Version Consistency Check (push) Has been cancelled
CI / npm pack + install test (push) Has been cancelled
CI / Lint & Type Check (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Test (Windows path suite) (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / No Committed Build Artifacts (push) Has been cancelled
CI / Multi-repo Path Gate (AST-grep) (ubuntu-latest) (push) Has been cancelled
Upgrade Test / omc update + session-start hook (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:36:54 +08:00

439 lines
16 KiB
TypeScript

#!/usr/bin/env tsx
/**
* Release Automation Script
*
* Automates version bumping, changelog generation, and release notes creation.
* Uses merged PR metadata when available so changelog content, PR counts,
* and contributors all reflect the same release dataset.
*
* Usage:
* npm run release -- patch # Bump patch version
* npm run release -- minor # Bump minor version
* npm run release -- major # Bump major version
* npm run release -- 4.9.0 # Set explicit version
* npm run release -- patch --dry-run # Preview without writing
*/
import { readFileSync, writeFileSync, existsSync } from 'fs';
import { join, resolve } from 'path';
import { execSync } from 'child_process';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
import {
type ReleasePullRequest,
type ReleaseNoteEntry,
extractPullRequestNumbers,
isReleasePullRequest,
deriveContributorLogins,
buildReleaseNoteEntriesFromPullRequests,
categorizeReleaseNoteEntries,
generateChangelog,
generateReleaseBody,
getLatestTag,
} from '../src/lib/release-generation.ts';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const ROOT = resolve(__dirname, '..');
const DEFAULT_REPO_SLUG = 'Yeachan-Heo/oh-my-claudecode';
const REPO_SLUG = process.env.GITHUB_REPOSITORY || DEFAULT_REPO_SLUG;
const REPO_URL = `https://github.com/${REPO_SLUG}`;
const GITHUB_API_URL = process.env.GITHUB_API_URL || 'https://api.github.com';
// ── Colors ──────────────────────────────────────────────────────────────────
const c = {
reset: '\x1b[0m',
bold: '\x1b[1m',
red: '\x1b[31m',
green: '\x1b[32m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
cyan: '\x1b[36m',
dim: '\x1b[2m',
};
function clr(text: string, code: string): string {
return `${code}${text}${c.reset}`;
}
// ── Types ───────────────────────────────────────────────────────────────────
interface ParsedCommit {
hash: string;
type: string;
scope: string;
description: string;
prNumber: string | null;
raw: string;
}
interface GitHubPullRequestResponse {
title: string;
user?: { login?: string | null } | null;
head?: { ref?: string | null } | null;
}
interface GitHubCompareResponse {
commits?: Array<{
author?: { login?: string | null } | null;
}>;
}
// ── Version helpers ─────────────────────────────────────────────────────────
function getCurrentVersion(): string {
const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf-8'));
return pkg.version;
}
function bumpVersion(current: string, bump: string): string {
if (/^\d+\.\d+\.\d+$/.test(bump)) return bump;
const [major, minor, patch] = current.split('.').map(Number);
switch (bump) {
case 'major': return `${major + 1}.0.0`;
case 'minor': return `${major}.${minor + 1}.0`;
case 'patch': return `${major}.${minor}.${patch + 1}`;
default: throw new Error(`Invalid bump type: ${bump}. Use patch, minor, major, or X.Y.Z`);
}
}
// ── Git helpers ─────────────────────────────────────────────────────────────
function getGitLog(tag: string, format: string, flags: string[] = []): string[] {
const range = tag ? `${tag}..HEAD` : 'HEAD';
const cmd = ['git', 'log', range, `--format=${JSON.stringify(format)}`, ...flags].join(' ');
const raw = execSync(cmd, { cwd: ROOT, encoding: 'utf-8' }).trim();
return raw ? raw.split('\n') : [];
}
function getCommitLinesSinceTag(tag: string): string[] {
return getGitLog(tag, '%H|%s');
}
function getNonMergeCommitLinesSinceTag(tag: string): string[] {
return getGitLog(tag, '%H|%s', ['--no-merges']);
}
function getHeadSha(): string {
return execSync('git rev-parse HEAD', { cwd: ROOT, encoding: 'utf-8' }).trim();
}
// ── Commit / PR parsing ─────────────────────────────────────────────────────
function parseCommit(line: string): ParsedCommit | null {
const [hash, ...rest] = line.split('|');
const raw = rest.join('|');
if (!raw) return null;
if (raw.startsWith('Merge ')) return null;
if (raw.match(/^chore\(release\)/i)) return null;
const conventionalMatch = raw.match(/^(?<type>[a-z]+)(?:\((?<scope>[^)]*)\))?:\s*(?<desc>.+)$/);
if (!conventionalMatch?.groups) return null;
const prMatch = raw.match(/\(#(\d+)\)/);
return {
hash: hash.trim(),
type: conventionalMatch.groups.type,
scope: conventionalMatch.groups.scope || '',
description: conventionalMatch.groups.desc.replace(/\s*\(#\d+\)$/, '').trim(),
prNumber: prMatch ? prMatch[1] : null,
raw,
};
}
function toReleaseNoteEntryFromCommit(commit: ParsedCommit): ReleaseNoteEntry {
return {
type: commit.type,
scope: commit.scope,
description: commit.description,
prNumber: commit.prNumber,
};
}
// ── GitHub metadata helpers ─────────────────────────────────────────────────
function getGitHubApiHeaders(): Record<string, string> {
const headers: Record<string, string> = {
Accept: 'application/vnd.github+json',
'User-Agent': 'oh-my-claudecode-release-script',
};
const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN;
if (token) headers.Authorization = `Bearer ${token}`;
return headers;
}
async function fetchJson<T>(url: string): Promise<T | null> {
try {
const response = await fetch(url, { headers: getGitHubApiHeaders() });
if (!response.ok) return null;
return await response.json() as T;
} catch {
return null;
}
}
function getRepoApiPath(): string {
return REPO_SLUG
.split('/')
.map(part => encodeURIComponent(part))
.join('/');
}
async function fetchPullRequestMetadata(prNumbers: string[]): Promise<ReleasePullRequest[]> {
const repoPath = getRepoApiPath();
const records = await Promise.all(prNumbers.map(async number => {
const data = await fetchJson<GitHubPullRequestResponse>(
`${GITHUB_API_URL}/repos/${repoPath}/pulls/${encodeURIComponent(number)}`
);
if (!data) return null;
return {
number,
title: data.title,
author: data.user?.login ?? null,
headRefName: data.head?.ref ?? null,
} satisfies ReleasePullRequest;
}));
return records.filter((record): record is ReleasePullRequest => record !== null);
}
async function fetchCompareCommitAuthors(prevTag: string): Promise<string[]> {
if (!prevTag) return [];
const repoPath = getRepoApiPath();
const headRef = process.env.GITHUB_SHA || getHeadSha();
const data = await fetchJson<GitHubCompareResponse>(
`${GITHUB_API_URL}/repos/${repoPath}/compare/${encodeURIComponent(prevTag)}...${encodeURIComponent(headRef)}`
);
return (data?.commits ?? [])
.map(commit => commit.author?.login ?? null)
.filter((author): author is string => Boolean(author));
}
// ── Version file bumping ────────────────────────────────────────────────────
function bumpVersionFiles(newVersion: string, dryRun: boolean): string[] {
const changes: string[] = [];
const pkgPath = join(ROOT, 'package.json');
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
if (pkg.version !== newVersion) {
pkg.version = newVersion;
if (!dryRun) writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n', 'utf-8');
changes.push(`package.json: ${pkg.version}${newVersion}`);
}
const pluginPath = join(ROOT, '.claude-plugin/plugin.json');
if (existsSync(pluginPath)) {
const content = readFileSync(pluginPath, 'utf-8');
const updated = content.replace(/"version":\s*"[^"]*"/, `"version": "${newVersion}"`);
if (content !== updated) {
if (!dryRun) writeFileSync(pluginPath, updated, 'utf-8');
changes.push(`plugin.json: bumped to ${newVersion}`);
}
}
const marketPath = join(ROOT, '.claude-plugin/marketplace.json');
if (existsSync(marketPath)) {
const content = readFileSync(marketPath, 'utf-8');
const updated = content.replace(/"version":\s*"[^"]*"/g, `"version": "${newVersion}"`);
if (content !== updated) {
if (!dryRun) writeFileSync(marketPath, updated, 'utf-8');
changes.push(`marketplace.json: bumped to ${newVersion}`);
}
}
const claudeMdPath = join(ROOT, 'docs/CLAUDE.md');
if (existsSync(claudeMdPath)) {
const content = readFileSync(claudeMdPath, 'utf-8');
const updated = content.replace(/<!-- OMC:VERSION:[^\s]*? -->/, `<!-- OMC:VERSION:${newVersion} -->`);
if (content !== updated) {
if (!dryRun) writeFileSync(claudeMdPath, updated, 'utf-8');
changes.push(`docs/CLAUDE.md: version marker → ${newVersion}`);
}
}
if (!dryRun) {
try {
execSync('npm install --package-lock-only --ignore-scripts 2>/dev/null', { cwd: ROOT });
changes.push('package-lock.json: regenerated');
} catch {
changes.push('package-lock.json: FAILED to regenerate');
}
} else {
changes.push('package-lock.json: would regenerate');
}
return changes;
}
function buildFallbackPullRequests(prNumbers: string[], subjects: string[]): ReleasePullRequest[] {
return prNumbers.map(number => {
const subject = subjects.find(entry => entry.includes(`(#${number})`));
const mergeSubject = subjects.find(entry => entry.startsWith(`Merge pull request #${number} `));
const headRefMatch = mergeSubject?.match(/from\s+[^/]+\/(.+)$/);
return {
number,
title: subject ? subject.replace(/\s*\(#\d+\)$/, '').trim() : `PR #${number}`,
author: null,
headRefName: headRefMatch?.[1] ?? null,
} satisfies ReleasePullRequest;
});
}
function isMainModule(): boolean {
return process.argv[1] ? resolve(process.argv[1]) === __filename : false;
}
// ── Main ────────────────────────────────────────────────────────────────────
async function main(): Promise<void> {
const args = process.argv.slice(2);
const dryRun = args.includes('--dry-run');
const help = args.includes('--help') || args.includes('-h');
const bumpArg = args.find(a => !a.startsWith('-'));
if (help || !bumpArg) {
console.log(`
${clr('Release Automation', c.bold)}
${clr('Usage:', c.cyan)}
npm run release -- <patch|minor|major|X.Y.Z> [--dry-run]
${clr('Examples:', c.cyan)}
npm run release -- patch # 4.8.1 → 4.8.2
npm run release -- minor # 4.8.1 → 4.9.0
npm run release -- 5.0.0 # Set explicit version
npm run release -- patch --dry-run # Preview without writing
${clr('What it does:', c.cyan)}
1. Bumps version in all 5 files (package.json, plugin.json, marketplace.json, docs/CLAUDE.md, lockfile)
2. Generates CHANGELOG.md from the merged PR set when metadata is available
3. Generates .github/release-body.md with contributor @mentions
4. Runs sync-metadata to update doc badges
${clr('After running:', c.cyan)}
git add -A && git commit -m "chore(release): bump version to vX.Y.Z"
git push origin dev
# Wait for CI green, then:
git checkout main && git merge dev && git push origin main
git tag -a vX.Y.Z -m "vX.Y.Z" && git push origin vX.Y.Z
# release.yml handles npm publish + GitHub release
`);
return;
}
const currentVersion = getCurrentVersion();
const newVersion = bumpVersion(currentVersion, bumpArg);
const prevTag = getLatestTag({ excludeTag: `v${newVersion}` });
console.log(clr('\n🚀 Release Automation', c.bold));
console.log(clr('═══════════════════════\n', c.dim));
console.log(` Current version: ${clr(currentVersion, c.yellow)}`);
console.log(` New version: ${clr(newVersion, c.green)}`);
console.log(` Previous tag: ${clr(prevTag || '(none)', c.dim)}`);
if (dryRun) console.log(clr('\n DRY RUN — no files will be modified\n', c.yellow));
const allCommitLines = getCommitLinesSinceTag(prevTag);
const allSubjects = allCommitLines.map(line => line.split('|').slice(1).join('|'));
const fallbackCommits = getNonMergeCommitLinesSinceTag(prevTag)
.map(parseCommit)
.filter((commit): commit is ParsedCommit => commit !== null);
const extractedPrNumbers = extractPullRequestNumbers(allSubjects);
const fetchedPullRequests = await fetchPullRequestMetadata(extractedPrNumbers);
const pullRequests = fetchedPullRequests.length > 0
? fetchedPullRequests
: buildFallbackPullRequests(extractedPrNumbers, allSubjects);
const userFacingPullRequests = pullRequests.filter(pr => !isReleasePullRequest(pr));
const compareCommitAuthors = fetchedPullRequests.length > 0 ? await fetchCompareCommitAuthors(prevTag) : [];
const contributors = deriveContributorLogins(userFacingPullRequests, compareCommitAuthors);
const usingPullRequests = fetchedPullRequests.length > 0;
const releaseEntries = usingPullRequests
? buildReleaseNoteEntriesFromPullRequests(userFacingPullRequests)
: fallbackCommits.map(toReleaseNoteEntryFromCommit);
const categories = categorizeReleaseNoteEntries(releaseEntries);
const prCount = userFacingPullRequests.length > 0 ? userFacingPullRequests.length : extractedPrNumbers.length;
console.log(clr('\n📊 Release Analysis', c.cyan));
console.log(` Total commits: ${allCommitLines.length}`);
console.log(` Extracted PRs: ${extractedPrNumbers.length}`);
console.log(` User-facing PRs: ${prCount}`);
console.log(` Metadata source: ${usingPullRequests ? 'GitHub PR metadata' : 'git fallback'}`);
console.log(` Contributors: ${contributors.join(', ') || '(none)'}`);
for (const [cat, entries] of categories) {
console.log(` ${cat}: ${entries.length}`);
}
console.log(clr('\n📦 Version Bump', c.cyan));
const versionChanges = bumpVersionFiles(newVersion, dryRun);
for (const change of versionChanges) {
console.log(` ${clr('✓', c.green)} ${change}`);
}
console.log(clr('\n📝 Changelog', c.cyan));
const changelog = generateChangelog(newVersion, categories, prCount);
if (!dryRun) {
writeFileSync(join(ROOT, 'CHANGELOG.md'), changelog, 'utf-8');
console.log(` ${clr('✓', c.green)} Written to CHANGELOG.md`);
} else {
console.log(` ${clr('→', c.yellow)} Would write CHANGELOG.md`);
console.log(clr('\n--- CHANGELOG Preview ---\n', c.dim));
console.log(changelog);
console.log(clr('--- End Preview ---\n', c.dim));
}
console.log(clr('\n📋 Release Body', c.cyan));
const releaseBody = generateReleaseBody(newVersion, changelog, contributors, prevTag, REPO_URL);
const releaseBodyPath = join(ROOT, '.github/release-body.md');
if (!dryRun) {
writeFileSync(releaseBodyPath, releaseBody, 'utf-8');
console.log(` ${clr('✓', c.green)} Written to .github/release-body.md`);
} else {
console.log(` ${clr('→', c.yellow)} Would write .github/release-body.md`);
}
console.log(clr('\n🔄 Sync Metadata', c.cyan));
if (!dryRun) {
try {
execSync('npx tsx scripts/sync-metadata.ts', { cwd: ROOT, stdio: 'inherit' });
} catch {
console.log(` ${clr('⚠', c.yellow)} sync-metadata had warnings (non-fatal)`);
}
} else {
console.log(` ${clr('→', c.yellow)} Would run sync-metadata`);
}
console.log(clr('\n✅ Done!', c.green));
if (!dryRun) {
console.log(clr('\nNext steps:', c.bold));
console.log(` 1. ${clr(`git add -A && git commit -m "chore(release): bump version to v${newVersion}"`, c.cyan)}`);
console.log(` 2. ${clr('git push origin dev', c.cyan)}`);
console.log(' 3. Wait for CI green');
console.log(` 4. ${clr('git checkout main && git merge dev && git push origin main', c.cyan)}`);
console.log(` 5. ${clr(`git tag -a v${newVersion} -m "v${newVersion}" && git push origin v${newVersion}`, c.cyan)}`);
console.log(' 6. release.yml handles npm publish + GitHub release automatically');
}
}
if (isMainModule()) {
void main().catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
console.error(clr(`\n✖ ${message}`, c.red));
process.exit(1);
});
}