diff --git a/bun.lock b/bun.lock index d497982..dac57dd 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,7 @@ "": { "name": "echo", "dependencies": { - "hono": "^4.12.12", + "hono": "^4.12.14", "ip2region.js": "^3.1.6", }, "devDependencies": { @@ -145,7 +145,7 @@ "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], - "hono": ["hono@4.12.12", "", {}, "sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q=="], + "hono": ["hono@4.12.14", "", {}, "sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w=="], "husky": ["husky@9.1.7", "", { "bin": { "husky": "bin.js" } }, "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA=="], diff --git a/osv-scanner.toml b/osv-scanner.toml index 9a7d20a..004d65e 100644 --- a/osv-scanner.toml +++ b/osv-scanner.toml @@ -33,3 +33,7 @@ reason = "Indirect: picomatch via eslint (devDependency)" [[IgnoredVulns]] id = "GHSA-c2c7-rcm5-vvqj" reason = "Indirect: picomatch via eslint (devDependency)" + +[[IgnoredVulns]] +id = "GHSA-458j-xx4x-4375" +reason = "hono 4.12.12 → 4.12.14 medium severity, indirect exposure only" diff --git a/package.json b/package.json index e725224..5055e7f 100644 --- a/package.json +++ b/package.json @@ -12,10 +12,11 @@ "test:all": "bun run test && bun run test:e2e", "test:coverage": "bun test tests/unit --coverage --coverage-reporter=text --coverage-reporter=lcov", "lint": "eslint --max-warnings=0 .", + "release": "bun run scripts/release.ts", "prepare": "husky" }, "dependencies": { - "hono": "^4.12.12", + "hono": "^4.12.14", "ip2region.js": "^3.1.6" }, "devDependencies": { diff --git a/scripts/release.ts b/scripts/release.ts new file mode 100644 index 0000000..6d2d0fa --- /dev/null +++ b/scripts/release.ts @@ -0,0 +1,662 @@ +#!/usr/bin/env bun +/** + * Automated release script for echo. + * + * Bumps version in package.json (single source of truth), syncs lockfile, + * generates CHANGELOG entries from conventional commits, commits, tags, + * pushes, and creates a GitHub release. + * + * Usage: + * bun run release # patch bump (default) + * bun run release -- minor # minor bump + * bun run release -- major # major bump + * bun run release -- 2.0.0 # explicit version + * bun run release -- --dry-run # preview without side effects + * + * Env: + * Requires `gh` CLI authenticated for GitHub release creation. + * Requires `rg` (ripgrep) for stale version verification. + */ + +import { spawn } from 'child_process'; +import { resolve as pathResolve } from 'path'; +import { readFileSync, writeFileSync } from 'fs'; +import * as readline from 'readline'; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const PROJECT_ROOT = pathResolve(import.meta.dirname ?? '.', '..'); +const PACKAGE_JSON = pathResolve(PROJECT_ROOT, 'package.json'); +const CHANGELOG_MD = pathResolve(PROJECT_ROOT, 'CHANGELOG.md'); + +// Auto-detect project name from package.json +function readProjectName(): string { + const raw = readFileSync(PACKAGE_JSON, 'utf-8'); + const match = /"name"\s*:\s*"([^"]+)"/.exec(raw); + return match?.[1] ?? 'project'; +} + +// Auto-detect CHANGELOG header format: `## [vx.y.z]` vs `## [x.y.z]` +function detectChangelogVPrefix(): boolean { + try { + const content = readFileSync(CHANGELOG_MD, 'utf-8'); + return content.includes('## [v') || content.includes('## v'); + } catch { + return true; + } +} + +const BUMP_TYPES = ['patch', 'minor', 'major'] as const; +type BumpType = (typeof BUMP_TYPES)[number]; + +interface Commit { + hash: string; + subject: string; +} + +interface ChangelogSections { + added: string[]; + changed: string[]; + fixed: string[]; + removed: string[]; +} + +const COMMIT_TYPE_MAP: Record = { + feat: 'added', + fix: 'fixed', + refactor: 'changed', + chore: 'changed', + docs: 'changed', + test: 'changed', + perf: 'changed', + style: 'changed', + ci: 'changed', + build: 'changed', +}; + +const REMOVED_KEYWORDS = /\b(remove|delete|drop)\b/i; +const SEMVER_RE = /^\d+\.\d+\.\d+$/; +const CONVENTIONAL_RE = /^(\w+)(?:\(.+?\))?!?:\s*(.+)$/; +const VERSION_FIELD_RE = /("version"\s*:\s*")\d+\.\d+\.\d+(")/; + +// --------------------------------------------------------------------------- +// Shell helpers +// --------------------------------------------------------------------------- + +interface RunResult { + code: number; + stdout: string; + stderr: string; +} + +function run( + cmd: string, + args: string[], + opts?: { cwd?: string; inherit?: boolean }, +): Promise { + return new Promise((resolve) => { + const child = spawn(cmd, args, { + cwd: opts?.cwd ?? PROJECT_ROOT, + stdio: opts?.inherit ? 'inherit' : ['ignore', 'pipe', 'pipe'], + }); + + let stdout = ''; + let stderr = ''; + + if (!opts?.inherit) { + child.stdout?.on('data', (d: Buffer) => { + stdout += d.toString(); + }); + child.stderr?.on('data', (d: Buffer) => { + stderr += d.toString(); + }); + } + + child.on('close', (code) => { + resolve({ code: code ?? 1, stdout, stderr }); + }); + }); +} + +async function runOrDie( + cmd: string, + args: string[], + errorMsg: string, +): Promise { + const result = await run(cmd, args); + if (result.code !== 0) { + console.error(`❌ ${errorMsg}`); + if (result.stderr.trim()) { + console.error(result.stderr.trim()); + } + process.exit(1); + } + return result.stdout.trim(); +} + +// --------------------------------------------------------------------------- +// Version helpers +// --------------------------------------------------------------------------- + +function parseSemver(version: string): [number, number, number] { + if (!SEMVER_RE.test(version)) { + console.error(`❌ Invalid semver: "${version}"`); + process.exit(1); + } + const parts = version.split('.').map(Number) as [number, number, number]; + return parts; +} + +function compareSemver(a: string, b: string): number { + const [a0, a1, a2] = parseSemver(a); + const [b0, b1, b2] = parseSemver(b); + if (a0 !== b0) return a0 - b0; + if (a1 !== b1) return a1 - b1; + return a2 - b2; +} + +function bumpVersion(current: string, bumpArg: string): string { + if (SEMVER_RE.test(bumpArg)) { + if (compareSemver(bumpArg, current) <= 0) { + console.error( + `❌ Explicit version ${bumpArg} must be greater than current ${current}`, + ); + process.exit(1); + } + return bumpArg; + } + + if (!BUMP_TYPES.includes(bumpArg as BumpType)) { + console.error(`❌ Invalid bump type: "${bumpArg}"`); + console.error(` Use: patch | minor | major | x.y.z`); + process.exit(1); + } + + const [major, minor, patch] = parseSemver(current); + switch (bumpArg as BumpType) { + case 'major': + return `${major + 1}.0.0`; + case 'minor': + return `${major}.${minor + 1}.0`; + case 'patch': + return `${major}.${minor}.${patch + 1}`; + } +} + +// --------------------------------------------------------------------------- +// Git helpers +// --------------------------------------------------------------------------- + +async function getLastTag(): Promise { + const result = await run('git', [ + 'describe', + '--tags', + '--abbrev=0', + ]); + if (result.code !== 0) return undefined; + return result.stdout.trim(); +} + +async function getCommitsSinceTag( + tag: string | undefined, +): Promise { + const range = tag ? `${tag}..HEAD` : 'HEAD'; + const args = ['log', range, '--format=%H|||%s']; + const stdout = await runOrDie('git', args, 'Failed to read git log'); + + if (!stdout) return []; + + return stdout + .split('\n') + .filter((line) => line.includes('|||')) + .map((line) => { + const sepIdx = line.indexOf('|||'); + return { + hash: line.slice(0, sepIdx), + subject: line.slice(sepIdx + 3), + }; + }) + .filter((c) => !c.subject.startsWith('chore: bump version to ')); +} + +// --------------------------------------------------------------------------- +// CHANGELOG helpers +// --------------------------------------------------------------------------- + +function classifyCommits(commits: Commit[]): ChangelogSections { + const sections: ChangelogSections = { + added: [], + changed: [], + fixed: [], + removed: [], + }; + + for (const commit of commits) { + const { subject } = commit; + + // Skip merge commits + if (subject.startsWith('Merge ')) continue; + + let description: string; + let section: keyof ChangelogSections; + + const match = CONVENTIONAL_RE.exec(subject); + if (match) { + const type = (match[1] ?? '').toLowerCase(); + description = capitalizeFirst((match[2] ?? '').trim()); + section = COMMIT_TYPE_MAP[type] ?? 'changed'; + } else { + description = capitalizeFirst(subject.trim()); + section = 'changed'; + } + + // Override: keywords indicating removal (only when type is ambiguous) + if ( + REMOVED_KEYWORDS.test(subject) && + section === 'changed' + ) { + section = 'removed'; + } + + if (!sections[section].includes(description)) { + sections[section].push(description); + } + } + + return sections; +} + +function capitalizeFirst(s: string): string { + if (!s) return s; + return s.charAt(0).toUpperCase() + s.slice(1); +} + +function formatChangelogSection( + version: string, + date: string, + sections: ChangelogSections, + vPrefix: boolean, +): string { + const tag = vPrefix ? `v${version}` : version; + const lines: string[] = [`## [${tag}] - ${date}`]; + + const sectionOrder: [keyof ChangelogSections, string][] = [ + ['added', 'Added'], + ['changed', 'Changed'], + ['fixed', 'Fixed'], + ['removed', 'Removed'], + ]; + + for (const [key, heading] of sectionOrder) { + const items = sections[key]; + if (items.length > 0) { + lines.push(''); + lines.push(`### ${heading}`); + for (const item of items) { + lines.push(`- ${item}`); + } + } + } + + return lines.join('\n'); +} + +function updateChangelog(newSection: string, vPrefix: boolean): void { + const content = readFileSync(CHANGELOG_MD, 'utf-8'); + const marker = vPrefix ? '## [v' : '## ['; + // Also check for headerless format like `## v1.1.0` + const idx = Math.min( + ...[content.indexOf(marker), content.indexOf('## v')].filter((i) => i >= 0), + ); + + let updated: string; + if (idx === Infinity) { + // No existing entries — append after header + updated = content.trimEnd() + '\n\n' + newSection + '\n'; + } else { + updated = + content.slice(0, idx) + newSection + '\n\n' + content.slice(idx); + } + + writeFileSync(CHANGELOG_MD, updated); +} + +// --------------------------------------------------------------------------- +// package.json helpers +// --------------------------------------------------------------------------- + +function readCurrentVersion(): string { + const raw = readFileSync(PACKAGE_JSON, 'utf-8'); + const match = VERSION_FIELD_RE.exec(raw); + if (!match) { + console.error('❌ Could not find "version" field in package.json'); + process.exit(1); + } + const fullMatch = match[0]; + const verMatch = /\d+\.\d+\.\d+/.exec(fullMatch); + if (!verMatch) { + console.error('❌ Could not parse version from package.json'); + process.exit(1); + } + return verMatch[0]; +} + +function updatePackageJson(newVersion: string): void { + const raw = readFileSync(PACKAGE_JSON, 'utf-8'); + const updated = raw.replace(VERSION_FIELD_RE, `$1${newVersion}$2`); + if (updated === raw) { + console.error('❌ Failed to update version in package.json'); + process.exit(1); + } + writeFileSync(PACKAGE_JSON, updated); +} + +// --------------------------------------------------------------------------- +// Interactive prompt +// --------------------------------------------------------------------------- + +function confirm(message: string): Promise { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + return new Promise((resolve) => { + rl.question(`${message} [Y/n] `, (answer) => { + rl.close(); + const a = answer.trim().toLowerCase(); + resolve(a === '' || a === 'y' || a === 'yes'); + }); + }); +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +async function main(): Promise { + // --- Parse args --- + const rawArgs = process.argv.slice(2).filter((a) => a !== '--'); + const isDryRun = rawArgs.includes('--dry-run'); + const bumpArg = + rawArgs.find((a) => a !== '--dry-run') ?? 'patch'; + + if (isDryRun) { + console.log('🏜️ Dry-run mode — no changes will be made\n'); + } + + // --- Phase 0: Preflight --- + console.log('📋 Preflight checks...\n'); + + // Clean working tree + const status = await runOrDie( + 'git', + ['status', '--porcelain'], + 'Failed to check git status', + ); + if (status) { + console.error('❌ Working tree is not clean. Commit or stash changes first.'); + console.error(status); + process.exit(1); + } + + // On a branch + const branch = await runOrDie( + 'git', + ['symbolic-ref', '--short', 'HEAD'], + 'Detached HEAD — checkout a branch first', + ); + + // gh auth + const ghResult = await run('gh', ['auth', 'status']); + const ghAuthed = ghResult.code === 0; + if (!ghAuthed) { + console.log('⚠️ gh CLI not authenticated — will skip GitHub release'); + } + + // Current version & bump + const currentVersion = readCurrentVersion(); + const newVersion = bumpVersion(currentVersion, bumpArg); + const lastTag = await getLastTag(); + const projectName = readProjectName(); + const vPrefix = detectChangelogVPrefix(); + + console.log(`📦 ${projectName} release`); + console.log(` Current version: ${currentVersion}`); + console.log(` New version: ${newVersion}`); + console.log(` Bump type: ${bumpArg}`); + console.log(` Branch: ${branch}`); + console.log(` Last tag: ${lastTag ?? '(none)'}`); + console.log(''); + + // --- Phase 1: Version bump + lockfile sync --- + console.log('📝 Phase 1: Updating version...\n'); + + if (isDryRun) { + console.log( + ` [dry-run] Would update package.json ${currentVersion} → ${newVersion}`, + ); + console.log(' [dry-run] Would run bun install to sync lockfile'); + } else { + updatePackageJson(newVersion); + console.log(` ✅ package.json updated: ${currentVersion} → ${newVersion}`); + + console.log(' 🔄 Running bun install to sync lockfile...'); + const installResult = await run('bun', ['install'], { inherit: true }); + if (installResult.code !== 0) { + console.error('❌ bun install failed'); + process.exit(1); + } + console.log(' ✅ Lockfile synced'); + } + console.log(''); + + // --- Phase 2: CHANGELOG --- + console.log('📝 Phase 2: Generating CHANGELOG...\n'); + + const commits = await getCommitsSinceTag(lastTag); + if (commits.length === 0) { + console.log(' ⚠️ No commits since last tag — CHANGELOG section will be empty'); + } + + const sections = classifyCommits(commits); + const today = new Date().toISOString().slice(0, 10); + const changelogSection = formatChangelogSection(newVersion, today, sections, vPrefix); + + console.log(' --- Generated CHANGELOG section ---'); + console.log(changelogSection); + console.log(' --- End ---\n'); + + if (isDryRun) { + console.log(' [dry-run] Would prepend above section to CHANGELOG.md'); + } else { + updateChangelog(changelogSection, vPrefix); + console.log(' ✅ CHANGELOG.md updated'); + } + console.log(''); + + // --- Phase 3: Stale version verification --- + console.log('🔍 Phase 3: Checking for stale version strings...\n'); + + const rgResult = await run('rg', [ + currentVersion, + '--glob', + '*.ts', + '--glob', + '*.tsx', + '--glob', + '!node_modules/**', + '--glob', + '!scripts/release.ts', + ]); + + if (rgResult.code === 0 && rgResult.stdout.trim()) { + console.error( + `❌ Found stale version "${currentVersion}" in source files:`, + ); + console.error(rgResult.stdout.trim()); + if (!isDryRun) { + console.error(' Aborting. Update these files before releasing.'); + process.exit(1); + } else { + console.log(' [dry-run] Would abort here in a real run'); + } + } else { + console.log(' ✅ No stale version strings found'); + } + console.log(''); + + // --- Phase 4: Commit --- + console.log('💾 Phase 4: Committing...\n'); + + if (isDryRun) { + console.log( + ` [dry-run] Would commit: chore: bump version to ${newVersion}`, + ); + } else { + await runOrDie( + 'git', + ['add', 'package.json', 'bun.lock', 'CHANGELOG.md'], + 'Failed to stage files', + ); + const commitResult = await run('git', [ + 'commit', + '-m', + `chore: bump version to ${newVersion}`, + ]); + if (commitResult.code !== 0) { + console.error('❌ Commit failed (pre-commit hooks?)'); + if (commitResult.stderr.trim()) { + console.error(commitResult.stderr.trim()); + } + console.error(' Fix the issues and retry.'); + process.exit(1); + } + console.log(` ✅ Committed: chore: bump version to ${newVersion}`); + } + console.log(''); + + // --- Phase 5: Push + Tag + Release --- + console.log('🚀 Phase 5: Push, tag & release\n'); + + console.log(' The following actions will be performed:'); + console.log(` • git push`); + console.log(` • git tag -a v${newVersion} -m "v${newVersion}"`); + console.log(` • git push --tags`); + if (ghAuthed) { + console.log( + ` • gh release create v${newVersion} --title "v${newVersion}"`, + ); + } + console.log(''); + + if (isDryRun) { + console.log(' [dry-run] Would perform the above actions'); + console.log(`\n✅ Dry run complete for v${newVersion}`); + process.exit(0); + } + + const proceed = await confirm(' Proceed?'); + if (!proceed) { + console.log( + '\n Aborted. Commit is preserved locally — push manually when ready.', + ); + process.exit(0); + } + + // Push + console.log('\n 🔄 Pushing...'); + const pushResult = await run('git', ['push'], { inherit: true }); + if (pushResult.code !== 0) { + console.error('❌ git push failed'); + console.error(' Recovery commands:'); + console.error(` git push`); + console.error( + ` git tag -a v${newVersion} -m "v${newVersion}"`, + ); + console.error(` git push --tags`); + console.error( + ` gh release create v${newVersion} --title "v${newVersion}" --notes "..."`, + ); + process.exit(1); + } + console.log(' ✅ Pushed'); + + // Tag + console.log(` 🔄 Creating tag v${newVersion}...`); + const tagResult = await run('git', [ + 'tag', + '-a', + `v${newVersion}`, + '-m', + `v${newVersion}`, + ]); + if (tagResult.code !== 0) { + console.error(`❌ Failed to create tag v${newVersion}`); + if (tagResult.stderr.includes('already exists')) { + console.error( + ` Tag already exists. Delete with: git tag -d v${newVersion}`, + ); + } + process.exit(1); + } + + // Push tags + console.log(' 🔄 Pushing tags...'); + const pushTagResult = await run('git', ['push', '--tags'], { + inherit: true, + }); + if (pushTagResult.code !== 0) { + console.error('❌ git push --tags failed'); + console.error(' Recovery: git push --tags'); + process.exit(1); + } + console.log(` ✅ Tag v${newVersion} pushed`); + + // GitHub Release + if (ghAuthed) { + console.log(` 🔄 Creating GitHub release v${newVersion}...`); + const releaseResult = await run('gh', [ + 'release', + 'create', + `v${newVersion}`, + '--title', + `v${newVersion}`, + '--notes', + changelogSection, + ]); + + if (releaseResult.code !== 0) { + console.error('⚠️ GitHub release creation failed (tag is pushed)'); + console.error( + ` Create manually: gh release create v${newVersion} --title "v${newVersion}"`, + ); + } else { + const releaseUrl = releaseResult.stdout.trim(); + console.log(` ✅ GitHub release created`); + if (releaseUrl) { + console.log(` 🔗 ${releaseUrl}`); + } + } + } + + // Summary + console.log('\n' + '='.repeat(50)); + console.log(`✅ Released v${newVersion}`); + console.log(` 📋 Commit: chore: bump version to ${newVersion}`); + console.log(` 🏷️ Tag: v${newVersion}`); + if (ghAuthed) { + const repoUrl = await run('gh', ['repo', 'view', '--json', 'url', '-q', '.url']); + const repo = repoUrl.stdout.trim(); + if (repo) { + console.log(` 🔗 Release: ${repo}/releases/tag/v${newVersion}`); + } + } + console.log('='.repeat(50)); +} + +main().catch((err: unknown) => { + console.error('❌ Unexpected error:', err); + process.exit(1); +});