From 18b05a07cb7785ecfdb6a58082eeb75d11f66111 Mon Sep 17 00:00:00 2001 From: John Ford Date: Tue, 3 Mar 2026 00:24:41 -0500 Subject: [PATCH 1/2] feat: Add publish flag --- cli/src/__tests__/changelog.test.ts | 7 ++- cli/src/__tests__/channels.test.ts | 2 +- cli/src/__tests__/plugins.test.ts | 38 +++++------ cli/src/__tests__/release.test.ts | 83 ++++++++++++++++++++++++ cli/src/channels.ts | 12 ++-- cli/src/index.ts | 13 +++- cli/src/release.ts | 97 ++++++++++++++++++++--------- tsconfig.json | 4 +- 8 files changed, 195 insertions(+), 61 deletions(-) create mode 100644 cli/src/__tests__/release.test.ts diff --git a/cli/src/__tests__/changelog.test.ts b/cli/src/__tests__/changelog.test.ts index 8ccb6fe..6aedbe8 100644 --- a/cli/src/__tests__/changelog.test.ts +++ b/cli/src/__tests__/changelog.test.ts @@ -1,10 +1,10 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('fs') -import fs from 'fs' -import { ChangelogPlugin } from '../plugins/changelog' +import fs from 'node:fs' import type { PluginContext } from '../plugins' +import { ChangelogPlugin } from '../plugins/changelog' const plugin = new ChangelogPlugin() @@ -164,6 +164,7 @@ function makeCtx(commits: string[]): PluginContext { tag: 'v1.4.0', commitCount: commits.length, dryRun: false, + published: false, }, options: {}, commits, diff --git a/cli/src/__tests__/channels.test.ts b/cli/src/__tests__/channels.test.ts index 13c23ea..f4d41d6 100644 --- a/cli/src/__tests__/channels.test.ts +++ b/cli/src/__tests__/channels.test.ts @@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' // Must be declared before importing the module under test so vitest can hoist it vi.mock('child_process') -import { execSync } from 'child_process' +import { execSync } from 'node:child_process' import { applyChannel, detectChannel } from '../channels' const mockExecSync = vi.mocked(execSync) diff --git a/cli/src/__tests__/plugins.test.ts b/cli/src/__tests__/plugins.test.ts index ec5dadd..39b6e0f 100644 --- a/cli/src/__tests__/plugins.test.ts +++ b/cli/src/__tests__/plugins.test.ts @@ -1,8 +1,8 @@ -import { describe, it, expect, vi } from 'vitest' +import { execSync } from 'node:child_process' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { Plugin } from '../plugins' import { createRelease } from '../release' import * as tags from '../tags' -import type { Plugin } from '../plugins' -import { execSync } from 'child_process' vi.mock('../tags', () => ({ resolveLatestTag: vi.fn(), @@ -13,16 +13,25 @@ vi.mock('child_process', () => ({ execSync: vi.fn(), })) +const mockExecSync = vi.mocked(execSync) + +function setupMocks() { + ;(tags.resolveLatestTag as ReturnType).mockReturnValue({ + latest: '1.0.0', + tag: 'v1.0.0', + isInitial: false, + }) + ;(tags.getCommitsSinceTag as ReturnType).mockReturnValue(['feat: new feature']) + mockExecSync.mockReturnValue('main' as any) // getCurrentBranch +} + describe('Plugin System', () => { - it('executes onResolved and onSuccess hooks', async () => { - ;(tags.resolveLatestTag as any).mockReturnValue({ - latest: '1.0.0', - tag: 'v1.0.0', - isInitial: false, - }) - ;(tags.getCommitsSinceTag as any).mockReturnValue(['feat: new feature']) - ;(execSync as any).mockReturnValue('main') // for getCurrentBranch + beforeEach(() => { + vi.resetAllMocks() + setupMocks() + }) + it('executes onResolved and onSuccess hooks', async () => { const plugin: Plugin = { name: 'test-plugin', onResolved: vi.fn(), @@ -36,13 +45,6 @@ describe('Plugin System', () => { }) it('does not execute onSuccess in dryRun mode', async () => { - ;(tags.resolveLatestTag as any).mockReturnValue({ - latest: '1.0.0', - tag: 'v1.0.0', - isInitial: false, - }) - ;(tags.getCommitsSinceTag as any).mockReturnValue(['feat: new feature']) - const plugin: Plugin = { name: 'test-plugin', onResolved: vi.fn(), diff --git a/cli/src/__tests__/release.test.ts b/cli/src/__tests__/release.test.ts new file mode 100644 index 0000000..5853c45 --- /dev/null +++ b/cli/src/__tests__/release.test.ts @@ -0,0 +1,83 @@ +import { execSync } from 'node:child_process' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { Plugin } from '../plugins' +import { createRelease } from '../release' +import * as tags from '../tags' + +vi.mock('../tags', () => ({ + resolveLatestTag: vi.fn(), + getCommitsSinceTag: vi.fn(), +})) + +vi.mock('child_process', () => ({ + execSync: vi.fn(), +})) + +const mockExecSync = vi.mocked(execSync) + +function setupMocks() { + ;(tags.resolveLatestTag as ReturnType).mockReturnValue({ + latest: '1.0.0', + tag: 'v1.0.0', + isInitial: false, + }) + ;(tags.getCommitsSinceTag as ReturnType).mockReturnValue(['feat: new feature']) + mockExecSync.mockReturnValue('main' as any) // getCurrentBranch +} + +describe('Publish', () => { + beforeEach(() => { + vi.resetAllMocks() + setupMocks() + }) + + it('creates and pushes the tag when publish is true', async () => { + const result = await createRelease({ publish: true }) + + expect(mockExecSync).toHaveBeenCalledWith( + expect.stringContaining('git tag v1.1.0'), + expect.any(Object) + ) + expect(mockExecSync).toHaveBeenCalledWith( + expect.stringContaining('git push origin v1.1.0'), + expect.any(Object) + ) + expect(result.published).toBe(true) + }) + + it('does not create a tag when publish is false', async () => { + await createRelease({ publish: false }) + + expect(mockExecSync).not.toHaveBeenCalledWith( + expect.stringContaining('git tag'), + expect.any(Object) + ) + }) + + it('does not publish in dry run mode', async () => { + const result = await createRelease({ publish: true, dryRun: true }) + + expect(mockExecSync).not.toHaveBeenCalledWith( + expect.stringContaining('git tag'), + expect.any(Object) + ) + expect(result.published).toBe(false) + }) + + it('calls onFailure and rethrows when pushTag fails', async () => { + mockExecSync.mockImplementation((cmd: string) => { + if (cmd.includes('git tag')) throw new Error('git tag failed') + return 'main' as any + }) + + const plugin: Plugin = { + name: 'test-plugin', + onFailure: vi.fn(), + } + + await expect(createRelease({ publish: true, plugins: [plugin] })).rejects.toThrow( + 'Failed to create or push tag' + ) + expect(plugin.onFailure).toHaveBeenCalled() + }) +}) diff --git a/cli/src/channels.ts b/cli/src/channels.ts index 52b7fe1..5214710 100644 --- a/cli/src/channels.ts +++ b/cli/src/channels.ts @@ -1,4 +1,4 @@ -import { execSync } from 'child_process' +import { execSync } from 'node:child_process' export type Channel = 'stable' | 'alpha' | 'beta' | 'rc' | 'feature' @@ -6,7 +6,7 @@ export type Channel = 'stable' | 'alpha' | 'beta' | 'rc' | 'feature' export function detectChannel(branch: string): Channel { if (branch === 'main' || branch === 'master') return 'stable' if (branch === 'develop') return 'alpha' - if (/^release\//.test(branch)) return 'rc' + if (branch.startsWith('release')) return 'rc' if (/^feat(ure)?\//.test(branch)) return 'feature' return 'alpha' } @@ -39,9 +39,9 @@ export function applyChannel( if (channel === 'feature') { const slug = branch .replace(/(^feature|^feat)\//, '') - .replace(/[^a-zA-Z0-9]/g, '-') - .replace(/-+/g, '-') - .replace(/^-|-$/g, '') + .replaceAll(/[^a-zA-Z0-9]/g, '-') + .replaceAll(/-+/g, '-') + .replaceAll(/^-|-$/g, '') .toLowerCase() return `${version}-${slug}` } @@ -65,7 +65,7 @@ function getNextPreReleaseIndex(baseVersion: string, channel: string, prefix: st .filter(Boolean) .map((tag) => { const match = tag.match(/\.(\d+)$/) - return match ? parseInt(match[1], 10) : -1 + return match ? Number.parseInt(match[1], 10) : -1 }) .filter((n) => n >= 0) diff --git a/cli/src/index.ts b/cli/src/index.ts index b3e6cf4..3d98048 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -1,15 +1,21 @@ #!/usr/bin/env node import { Command } from 'commander' -import { createRelease } from './release' +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' import type { Channel } from './channels' import { loadConfig, resolvePlugins } from './config' +import { createRelease } from './release' + +const { version } = JSON.parse(readFileSync(resolve(__dirname, '../../package.json'), 'utf-8')) as { + version: string +} const program = new Command() program .name('gitcraft') .description('Craft production-ready releases from your Git history.') - .version('0.1.0') + .version(version) program .command('release') @@ -17,6 +23,7 @@ program .option('-c, --channel ', 'Release channel: stable, alpha, beta, rc, feature') .option('-p, --tag-prefix ', 'Git tag prefix', 'v') .option('-n, --dry-run', 'Preview the release without running plugins or writing files') + .option('--publish', 'Create and push the git tag to the remote') .option('--path ', 'Only analyze commits touching this path (monorepo support)') .action(async (opts) => { try { @@ -27,6 +34,7 @@ program channel: opts.channel as Channel | undefined, tagPrefix: opts.tagPrefix, dryRun: opts.dryRun ?? false, + publish: opts.publish ?? false, plugins, filterPath: opts.path, }) @@ -36,6 +44,7 @@ program console.log(` ${result.previousVersion} → ${result.nextVersion} (${result.bumpType})`) console.log(` Tag: ${result.tag}`) console.log(` Commits: ${result.commitCount}`) + if (result.published) console.log(` Published: ${result.tag} pushed to origin`) if (result.dryRun) { console.log('\nDry run — no plugins ran, no files were written.') diff --git a/cli/src/release.ts b/cli/src/release.ts index 7a899a7..8221923 100644 --- a/cli/src/release.ts +++ b/cli/src/release.ts @@ -1,4 +1,4 @@ -import { execSync } from 'child_process' +import { execSync } from 'node:child_process' import { determineBump } from './analyzer' import type { Channel } from './channels' import { applyChannel, detectChannel } from './channels' @@ -10,6 +10,7 @@ export interface ReleaseOptions { channel?: Channel tagPrefix?: string dryRun?: boolean + publish?: boolean plugins?: Plugin[] filterPath?: string } @@ -21,6 +22,7 @@ export interface ReleaseResult { tag: string commitCount: number dryRun: boolean + published: boolean /** Populated by the changelog plugin — markdown for this release entry. */ releaseNotes?: string } @@ -29,7 +31,6 @@ export async function createRelease(opts: ReleaseOptions = {}): Promise { + if (!plugins) return + for (const plugin of plugins) { + if (plugin.onResolved) await plugin.onResolved(context) + } +} + +async function runPluginSuccess( + plugins: Plugin[] | undefined, + context: PluginContext +): Promise { + if (!plugins) return + for (const plugin of plugins) { + if (plugin.onSuccess) await plugin.onSuccess(context) + } +} + +async function runPluginFailure(plugins: Plugin[] | undefined, error: Error): Promise { + if (!plugins) return + for (const plugin of plugins) { + if (plugin.onFailure) await plugin.onFailure(error) + } +} + +async function craft( + result: ReleaseResult, + options: ReleaseOptions, + commits: string[] +): Promise { + const context: PluginContext = { result, options, commits } + + await runPluginResolved(options.plugins, context) + + if (!options.dryRun) { + try { + if (options.publish) { + pushTag(result.tag) + result.published = true + } + + await runPluginSuccess(options.plugins, context) + } catch (err) { + await runPluginFailure(options.plugins, err as Error) + + throw err + } + } + + return result +} diff --git a/tsconfig.json b/tsconfig.json index 7f1168d..11c80c1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,8 +1,8 @@ { "compilerOptions": { - "target": "ES2020", + "target": "es2024", "module": "commonjs", - "lib": ["ES2020"], + "lib": ["ES2024"], "strict": true, "esModuleInterop": true, "skipLibCheck": true, From 1424c585a842521e5f01085f066555f9d7ba712b Mon Sep 17 00:00:00 2001 From: John Ford Date: Tue, 3 Mar 2026 00:56:00 -0500 Subject: [PATCH 2/2] fix: Add package.json to dist to grab version tag --- cli/src/index.ts | 2 +- package.json | 2 +- tsconfig.cli.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cli/src/index.ts b/cli/src/index.ts index 3d98048..5142951 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -6,7 +6,7 @@ import type { Channel } from './channels' import { loadConfig, resolvePlugins } from './config' import { createRelease } from './release' -const { version } = JSON.parse(readFileSync(resolve(__dirname, '../../package.json'), 'utf-8')) as { +const { version } = JSON.parse(readFileSync(resolve(__dirname, './package.json'), 'utf-8')) as { version: string } diff --git a/package.json b/package.json index 04fe489..ce39d39 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ "scripts": { "build": "npm run build:cli && npm run build:extension", "prepublishOnly": "npm run build:cli", - "build:cli": "tsc -p tsconfig.cli.json", + "build:cli": "tsc -p tsconfig.cli.json && node -e \"require('fs').copyFileSync('package.json','dist/cli/package.json')\"", "build:extension": "esbuild extension/task/index.ts --bundle --platform=node --target=node20 --outfile=dist/extension/task/index.js && node -e \"require('fs').copyFileSync('extension/task/task.json','dist/extension/task/task.json')\"", "build:icon": "resvg-js-cli extension/icon.svg extension/icon.png", "dev": "ts-node cli/src/index.ts", diff --git a/tsconfig.cli.json b/tsconfig.cli.json index 885cff9..66c6714 100644 --- a/tsconfig.cli.json +++ b/tsconfig.cli.json @@ -4,6 +4,6 @@ "outDir": "./dist/cli", "rootDir": "./cli/src" }, - "include": ["cli/src/**/*"], + "include": ["cli/src/**/*", "package.json"], "exclude": ["node_modules", "dist", "cli/src/__tests__/**/*"] }