diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2e14fec..12eb883 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,18 +8,23 @@ on: jobs: test: - runs-on: ubuntu-latest + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + bun: [latest, 1.1.30] steps: - uses: actions/checkout@v4 - uses: oven-sh/setup-bun@v2 with: - bun-version: latest + bun-version: ${{ matrix.bun }} - name: Install dependencies working-directory: tui - run: bun install + run: bun install --frozen-lockfile - name: Lint working-directory: tui @@ -33,6 +38,9 @@ jobs: working-directory: tui run: bun test + - name: Snapshot guard + run: git diff --exit-code + - name: Build working-directory: tui run: bun run build @@ -59,3 +67,21 @@ jobs: run: | jq . .claude-plugin/plugin.json > /dev/null jq . hooks/hooks.json > /dev/null + + audit: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install dependencies + working-directory: tui + run: bun install --frozen-lockfile + + - name: Dependency audit + working-directory: tui + run: bun run audit diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..2ff9c8e --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,33 @@ +name: Release + +on: + push: + tags: + - 'v*.*.*' + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install dependencies + working-directory: tui + run: bun install --frozen-lockfile + + - name: Build TUI + working-directory: tui + run: bun run build + + - name: Package artifact + run: tar -czf claude-hud-tui.tar.gz tui/dist tui/package.json + + - name: Release artifact + uses: softprops/action-gh-release@v2 + with: + files: claude-hud-tui.tar.gz diff --git a/docs/RELEASING.md b/docs/RELEASING.md new file mode 100644 index 0000000..9801fbf --- /dev/null +++ b/docs/RELEASING.md @@ -0,0 +1,27 @@ +# Releasing Claude HUD + +## Versioning Strategy + +Claude HUD follows semantic versioning. Release tags are `vX.Y.Z`. Both +`tui/package.json` and `.claude-plugin/plugin.json` are kept in sync. + +## Release Steps + +1. Bump versions: + ```bash + node scripts/release.ts --bump patch + ``` +2. Generate changelog entry: + ```bash + node scripts/changelog.ts X.Y.Z + ``` +3. Review `docs/CHANGELOG.md`, commit, and tag: + ```bash + git commit -am "chore: release X.Y.Z" + git tag vX.Y.Z + git push --follow-tags + ``` + +## Artifacts + +GitHub Actions builds the TUI and uploads `claude-hud-tui.tar.gz` on release tags. diff --git a/package.json b/package.json index c88f990..2eb5cf5 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,12 @@ { "name": "claude-hud", - "version": "0.1.0", + "version": "2.0.11", "private": true, - "description": "Claude Code plugin HUD", + "description": "Claude Code HUD plugin", "scripts": { - "prepare": "husky" + "prepare": "husky", + "release:prepare": "node scripts/release.ts", + "release:changelog": "node scripts/changelog.ts" }, "devDependencies": { "husky": "^9.1.7" diff --git a/scripts/changelog.ts b/scripts/changelog.ts new file mode 100644 index 0000000..9e7d3b7 --- /dev/null +++ b/scripts/changelog.ts @@ -0,0 +1,86 @@ +import { execSync } from 'node:child_process'; +import { readFileSync, writeFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +function getLastTag(): string | null { + try { + return execSync('git describe --tags --abbrev=0', { stdio: ['ignore', 'pipe', 'ignore'] }) + .toString() + .trim(); + } catch { + return null; + } +} + +function getCommits(fromTag: string | null): string[] { + const range = fromTag ? `${fromTag}..HEAD` : 'HEAD'; + const output = execSync(`git log ${range} --pretty=%s`, { stdio: ['ignore', 'pipe', 'ignore'] }) + .toString() + .trim(); + if (!output) return []; + return output.split('\n'); +} + +function groupCommits(messages: string[]): Record { + const groups: Record = { + Added: [], + Fixed: [], + Changed: [], + }; + + for (const message of messages) { + const match = message.match(/^(\w+):\s*(.+)$/); + if (!match) { + groups.Changed.push(message); + continue; + } + const [, type, rest] = match; + if (type === 'feat') groups.Added.push(rest); + else if (type === 'fix') groups.Fixed.push(rest); + else groups.Changed.push(rest); + } + + return groups; +} + +function formatSection(title: string, items: string[]): string { + if (items.length === 0) return ''; + return `### ${title}\n${items.map((item) => `- ${item}`).join('\n')}\n\n`; +} + +function buildEntry(version: string, date: string, messages: string[]): string { + const groups = groupCommits(messages); + return ( + `## [${version}] - ${date}\n\n` + + formatSection('Added', groups.Added) + + formatSection('Fixed', groups.Fixed) + + formatSection('Changed', groups.Changed) + ); +} + +function insertEntry(changelogPath: string, entry: string): void { + const content = readFileSync(changelogPath, 'utf-8'); + const lines = content.split('\n'); + const headerIndex = lines.findIndex((line) => line.startsWith('# ')); + const insertAt = headerIndex === -1 ? 0 : headerIndex + 2; + const updated = [...lines.slice(0, insertAt), entry.trimEnd(), '', ...lines.slice(insertAt)].join( + '\n', + ); + writeFileSync(changelogPath, updated, 'utf-8'); +} + +const version = process.argv[2]; +if (!version) { + console.log('Usage: node scripts/changelog.ts x.y.z'); + process.exit(1); +} + +const lastTag = getLastTag(); +const commits = getCommits(lastTag); +const date = new Date().toISOString().slice(0, 10); +const entry = buildEntry(version, date, commits); + +const changelogPath = resolve('docs/CHANGELOG.md'); +insertEntry(changelogPath, entry); + +console.log(`Changelog updated for ${version}.`); diff --git a/scripts/release.ts b/scripts/release.ts new file mode 100644 index 0000000..ed1456c --- /dev/null +++ b/scripts/release.ts @@ -0,0 +1,54 @@ +import { readFileSync, writeFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +type Bump = 'major' | 'minor' | 'patch'; + +function parseArgs(argv: string[]) { + const args: { bump?: Bump; version?: string } = {}; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === '--bump') { + args.bump = argv[i + 1] as Bump; + i += 1; + } else if (arg === '--version') { + args.version = argv[i + 1]; + i += 1; + } + } + return args; +} + +function bumpVersion(current: string, bump: Bump): string { + const [major, minor, patch] = current.split('.').map(Number); + if ([major, minor, patch].some((n) => Number.isNaN(n))) { + throw new Error(`Invalid version: ${current}`); + } + if (bump === 'major') return `${major + 1}.0.0`; + if (bump === 'minor') return `${major}.${minor + 1}.0`; + return `${major}.${minor}.${patch + 1}`; +} + +function updateJsonVersion(filePath: string, nextVersion: string): void { + const raw = readFileSync(filePath, 'utf-8'); + const data = JSON.parse(raw) as Record; + data.version = nextVersion; + writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n', 'utf-8'); +} + +const args = parseArgs(process.argv.slice(2)); +if (!args.bump && !args.version) { + console.log('Usage: node scripts/release.ts --bump patch|minor|major'); + console.log(' or: node scripts/release.ts --version x.y.z'); + process.exit(1); +} + +const tuiPkgPath = resolve('tui/package.json'); +const pluginPath = resolve('.claude-plugin/plugin.json'); + +const tuiPkg = JSON.parse(readFileSync(tuiPkgPath, 'utf-8')) as { version: string }; +const nextVersion = args.version || bumpVersion(tuiPkg.version, args.bump as Bump); + +updateJsonVersion(tuiPkgPath, nextVersion); +updateJsonVersion(pluginPath, nextVersion); + +console.log(`Version bumped to ${nextVersion}. Next: run scripts/changelog.ts.`);