diff --git a/.husky/commit-msg b/.husky/commit-msg index 88fd9b6..70bd3dd 100755 --- a/.husky/commit-msg +++ b/.husky/commit-msg @@ -1 +1 @@ -npx --no commitlint --edit "$1" +npx --no-install commitlint --edit "$1" diff --git a/README.md b/README.md index 5676aa8..706e754 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,7 @@ patternfly-cli [command] - **`save`**: Commit and push changes to the current branch. - **`load`**: Pull the latest updates from GitHub. - **`deploy`**: Build and deploy your app to GitHub Pages. +- **`bump-prerelease`**: Create and push the next prerelease tag for semantic-release. Finds the latest prerelease tag (e.g., `prerelease-v5.0.0-prerelease.0`), increments the minor version by default (e.g., `prerelease-v5.1.0-prerelease.0`), or the major version with `--major` (e.g., `prerelease-v6.0.0-prerelease.0`). This triggers semantic-release to start publishing new prerelease versions. Use `--dry-run` to preview what would happen without making changes. ### Custom templates diff --git a/src/__tests__/bump-prerelease.test.ts b/src/__tests__/bump-prerelease.test.ts new file mode 100644 index 0000000..156e2bb --- /dev/null +++ b/src/__tests__/bump-prerelease.test.ts @@ -0,0 +1,300 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { runBumpPrerelease } from '../bump-prerelease'; +import { execa } from 'execa'; + +jest.mock('execa'); + +const mockedExeca = execa as jest.MockedFunction; + +describe('runBumpPrerelease', () => { + const mockCwd = '/test/repo'; + + beforeEach(() => { + jest.clearAllMocks(); + console.log = jest.fn(); + }); + + it('should create initial prerelease tag if no tags exist', async () => { + mockedExeca.mockImplementation((command, args) => { + if (command === 'git' && args?.[0] === 'remote') { + return Promise.resolve({ stdout: 'origin' } as any); + } + if (command === 'git' && args?.[0] === 'tag' && args?.[1] === '-l') { + return Promise.resolve({ stdout: '' } as any); + } + if (command === 'git' && args?.[0] === 'rev-parse') { + return Promise.reject(new Error('not found')); + } + return Promise.resolve({} as any); + }); + + await runBumpPrerelease(mockCwd); + + expect(mockedExeca).toHaveBeenCalledWith('git', ['tag', 'prerelease-v1.0.0-prerelease.0'], { cwd: mockCwd }); + expect(mockedExeca).toHaveBeenCalledWith('git', ['push', 'origin', 'prerelease-v1.0.0-prerelease.0'], { cwd: mockCwd }); + }); + + it('should increment minor version from existing tag', async () => { + mockedExeca.mockImplementation((command, args) => { + if (command === 'git' && args?.[0] === 'remote') { + return Promise.resolve({ stdout: 'upstream\norigin' } as any); + } + if (command === 'git' && args?.[0] === 'tag' && args?.[1] === '-l') { + if (args.includes('--sort=version:refname')) { + return Promise.resolve({ stdout: 'prerelease-v5.0.0-prerelease.0' } as any); + } + return Promise.resolve({ stdout: 'prerelease-v5.0.0-prerelease.0' } as any); + } + if (command === 'git' && args?.[0] === 'rev-parse') { + return Promise.reject(new Error('not found')); + } + return Promise.resolve({} as any); + }); + + await runBumpPrerelease(mockCwd); + + expect(mockedExeca).toHaveBeenCalledWith('git', ['tag', 'prerelease-v5.1.0-prerelease.0'], { cwd: mockCwd }); + expect(mockedExeca).toHaveBeenCalledWith('git', ['push', 'upstream', 'prerelease-v5.1.0-prerelease.0'], { cwd: mockCwd }); + }); + + it('should use upstream remote if available', async () => { + mockedExeca.mockImplementation((command, args) => { + if (command === 'git' && args?.[0] === 'remote') { + return Promise.resolve({ stdout: 'upstream\norigin' } as any); + } + if (command === 'git' && args?.[0] === 'tag' && args?.[1] === '-l') { + if (args.includes('--sort=version:refname')) { + return Promise.resolve({ stdout: 'prerelease-v1.5.0-prerelease.0' } as any); + } + return Promise.resolve({ stdout: 'prerelease-v1.5.0-prerelease.0' } as any); + } + if (command === 'git' && args?.[0] === 'rev-parse') { + return Promise.reject(new Error('not found')); + } + return Promise.resolve({} as any); + }); + + await runBumpPrerelease(mockCwd); + + expect(mockedExeca).toHaveBeenCalledWith('git', ['fetch', 'upstream', '--tags'], { cwd: mockCwd }); + expect(mockedExeca).toHaveBeenCalledWith('git', ['push', 'upstream', 'prerelease-v1.6.0-prerelease.0'], { cwd: mockCwd }); + }); + + it('should use origin remote if upstream does not exist', async () => { + mockedExeca.mockImplementation((command, args) => { + if (command === 'git' && args?.[0] === 'remote') { + return Promise.resolve({ stdout: 'origin' } as any); + } + if (command === 'git' && args?.[0] === 'tag' && args?.[1] === '-l') { + if (args.includes('--sort=version:refname')) { + return Promise.resolve({ stdout: 'prerelease-v2.3.0-prerelease.0' } as any); + } + return Promise.resolve({ stdout: 'prerelease-v2.3.0-prerelease.0' } as any); + } + if (command === 'git' && args?.[0] === 'rev-parse') { + return Promise.reject(new Error('not found')); + } + return Promise.resolve({} as any); + }); + + await runBumpPrerelease(mockCwd); + + expect(mockedExeca).toHaveBeenCalledWith('git', ['fetch', 'origin', '--tags'], { cwd: mockCwd }); + expect(mockedExeca).toHaveBeenCalledWith('git', ['push', 'origin', 'prerelease-v2.4.0-prerelease.0'], { cwd: mockCwd }); + }); + + it('should throw error if tag already exists', async () => { + mockedExeca.mockImplementation((command, args) => { + if (command === 'git' && args?.[0] === 'remote') { + return Promise.resolve({ stdout: 'origin' } as any); + } + if (command === 'git' && args?.[0] === 'tag' && args?.[1] === '-l') { + if (args.includes('--sort=version:refname')) { + return Promise.resolve({ stdout: 'prerelease-v3.0.0-prerelease.0' } as any); + } + return Promise.resolve({ stdout: 'prerelease-v3.0.0-prerelease.0' } as any); + } + if (command === 'git' && args?.[0] === 'rev-parse') { + // Tag exists + return Promise.resolve({ stdout: 'abc123' } as any); + } + return Promise.resolve({} as any); + }); + + await expect(runBumpPrerelease(mockCwd)).rejects.toThrow( + 'Tag prerelease-v3.1.0-prerelease.0 already exists' + ); + }); + + it('should handle double-digit minor versions correctly', async () => { + mockedExeca.mockImplementation((command, args) => { + if (command === 'git' && args?.[0] === 'remote') { + return Promise.resolve({ stdout: 'origin' } as any); + } + if (command === 'git' && args?.[0] === 'tag' && args?.[1] === '-l') { + if (args.includes('--sort=version:refname')) { + return Promise.resolve({ stdout: 'prerelease-v5.99.0-prerelease.0' } as any); + } + return Promise.resolve({ stdout: 'prerelease-v5.99.0-prerelease.0' } as any); + } + if (command === 'git' && args?.[0] === 'rev-parse') { + return Promise.reject(new Error('not found')); + } + return Promise.resolve({} as any); + }); + + await runBumpPrerelease(mockCwd); + + expect(mockedExeca).toHaveBeenCalledWith('git', ['tag', 'prerelease-v5.100.0-prerelease.0'], { cwd: mockCwd }); + }); + + it('should select latest tag when multiple tags exist', async () => { + mockedExeca.mockImplementation((command, args) => { + if (command === 'git' && args?.[0] === 'remote') { + return Promise.resolve({ stdout: 'origin' } as any); + } + if (command === 'git' && args?.[0] === 'tag' && args?.[1] === '-l') { + if (args.includes('--sort=version:refname')) { + return Promise.resolve({ + stdout: 'prerelease-v1.0.0-prerelease.0\nprerelease-v2.0.0-prerelease.0\nprerelease-v2.1.0-prerelease.0', + } as any); + } + return Promise.resolve({ + stdout: 'prerelease-v1.0.0-prerelease.0\nprerelease-v2.0.0-prerelease.0\nprerelease-v2.1.0-prerelease.0', + } as any); + } + if (command === 'git' && args?.[0] === 'rev-parse') { + return Promise.reject(new Error('not found')); + } + return Promise.resolve({} as any); + }); + + await runBumpPrerelease(mockCwd); + + // Should use v2.1.0 as the latest and increment to v2.2.0 + expect(mockedExeca).toHaveBeenCalledWith('git', ['tag', 'prerelease-v2.2.0-prerelease.0'], { cwd: mockCwd }); + }); + + it('should not create or push tag in dry run mode', async () => { + mockedExeca.mockImplementation((command, args) => { + if (command === 'git' && args?.[0] === 'remote') { + return Promise.resolve({ stdout: 'origin' } as any); + } + if (command === 'git' && args?.[0] === 'tag' && args?.[1] === '-l') { + if (args.includes('--sort=version:refname')) { + return Promise.resolve({ stdout: 'prerelease-v4.0.0-prerelease.0' } as any); + } + return Promise.resolve({ stdout: 'prerelease-v4.0.0-prerelease.0' } as any); + } + if (command === 'git' && args?.[0] === 'rev-parse') { + return Promise.reject(new Error('not found')); + } + return Promise.resolve({} as any); + }); + + await runBumpPrerelease(mockCwd, { dryRun: true }); + + // Should NOT create tag + expect(mockedExeca).not.toHaveBeenCalledWith('git', ['tag', expect.any(String)], expect.any(Object)); + // Should NOT push tag + expect(mockedExeca).not.toHaveBeenCalledWith('git', ['push', expect.any(String), expect.any(String)], expect.any(Object)); + }); + + it('should still fetch and check tags in dry run mode', async () => { + mockedExeca.mockImplementation((command, args) => { + if (command === 'git' && args?.[0] === 'remote') { + return Promise.resolve({ stdout: 'upstream' } as any); + } + if (command === 'git' && args?.[0] === 'tag' && args?.[1] === '-l') { + if (args.includes('--sort=version:refname')) { + return Promise.resolve({ stdout: 'prerelease-v3.5.0-prerelease.0' } as any); + } + return Promise.resolve({ stdout: 'prerelease-v3.5.0-prerelease.0' } as any); + } + if (command === 'git' && args?.[0] === 'rev-parse') { + return Promise.reject(new Error('not found')); + } + return Promise.resolve({} as any); + }); + + await runBumpPrerelease(mockCwd, { dryRun: true }); + + // Should still fetch tags + expect(mockedExeca).toHaveBeenCalledWith('git', ['fetch', 'upstream', '--tags'], { cwd: mockCwd }); + // Should still check if tag exists + expect(mockedExeca).toHaveBeenCalledWith('git', ['rev-parse', 'prerelease-v3.6.0-prerelease.0'], { cwd: mockCwd }); + }); + + it('should bump major version when major option is true', async () => { + mockedExeca.mockImplementation((command, args) => { + if (command === 'git' && args?.[0] === 'remote') { + return Promise.resolve({ stdout: 'origin' } as any); + } + if (command === 'git' && args?.[0] === 'tag' && args?.[1] === '-l') { + if (args.includes('--sort=version:refname')) { + return Promise.resolve({ stdout: 'prerelease-v5.3.0-prerelease.0' } as any); + } + return Promise.resolve({ stdout: 'prerelease-v5.3.0-prerelease.0' } as any); + } + if (command === 'git' && args?.[0] === 'rev-parse') { + return Promise.reject(new Error('not found')); + } + return Promise.resolve({} as any); + }); + + await runBumpPrerelease(mockCwd, { major: true }); + + // Should bump major from 5 to 6, reset minor and patch to 0 + expect(mockedExeca).toHaveBeenCalledWith('git', ['tag', 'prerelease-v6.0.0-prerelease.0'], { cwd: mockCwd }); + expect(mockedExeca).toHaveBeenCalledWith('git', ['push', 'origin', 'prerelease-v6.0.0-prerelease.0'], { cwd: mockCwd }); + }); + + it('should bump minor version by default when major option is false', async () => { + mockedExeca.mockImplementation((command, args) => { + if (command === 'git' && args?.[0] === 'remote') { + return Promise.resolve({ stdout: 'origin' } as any); + } + if (command === 'git' && args?.[0] === 'tag' && args?.[1] === '-l') { + if (args.includes('--sort=version:refname')) { + return Promise.resolve({ stdout: 'prerelease-v5.3.0-prerelease.0' } as any); + } + return Promise.resolve({ stdout: 'prerelease-v5.3.0-prerelease.0' } as any); + } + if (command === 'git' && args?.[0] === 'rev-parse') { + return Promise.reject(new Error('not found')); + } + return Promise.resolve({} as any); + }); + + await runBumpPrerelease(mockCwd, { major: false }); + + // Should bump minor from 3 to 4, keep major at 5, reset patch to 0 + expect(mockedExeca).toHaveBeenCalledWith('git', ['tag', 'prerelease-v5.4.0-prerelease.0'], { cwd: mockCwd }); + expect(mockedExeca).toHaveBeenCalledWith('git', ['push', 'origin', 'prerelease-v5.4.0-prerelease.0'], { cwd: mockCwd }); + }); + + it('should work with major version bump in dry run mode', async () => { + mockedExeca.mockImplementation((command, args) => { + if (command === 'git' && args?.[0] === 'remote') { + return Promise.resolve({ stdout: 'origin' } as any); + } + if (command === 'git' && args?.[0] === 'tag' && args?.[1] === '-l') { + if (args.includes('--sort=version:refname')) { + return Promise.resolve({ stdout: 'prerelease-v7.15.0-prerelease.0' } as any); + } + return Promise.resolve({ stdout: 'prerelease-v7.15.0-prerelease.0' } as any); + } + if (command === 'git' && args?.[0] === 'rev-parse') { + return Promise.reject(new Error('not found')); + } + return Promise.resolve({} as any); + }); + + await runBumpPrerelease(mockCwd, { major: true, dryRun: true }); + + // Should check for v8.0.0 but not create or push + expect(mockedExeca).toHaveBeenCalledWith('git', ['rev-parse', 'prerelease-v8.0.0-prerelease.0'], { cwd: mockCwd }); + expect(mockedExeca).not.toHaveBeenCalledWith('git', ['tag', expect.any(String)], expect.any(Object)); + expect(mockedExeca).not.toHaveBeenCalledWith('git', ['push', expect.any(String), expect.any(String)], expect.any(Object)); + }); +}); diff --git a/src/bump-prerelease.ts b/src/bump-prerelease.ts new file mode 100644 index 0000000..8781ec0 --- /dev/null +++ b/src/bump-prerelease.ts @@ -0,0 +1,153 @@ +import { execa } from 'execa'; + +/** + * Parse a prerelease tag (e.g., "prerelease-v5.0.0-prerelease.0") + * Returns { major, minor, patch } + */ +function parsePrereleaseTag(tag: string): { major: number; minor: number; patch: number } { + const match = tag.match(/^prerelease-v(\d+)\.(\d+)\.(\d+)-prerelease\.\d+$/); + if (!match || !match[1] || !match[2] || !match[3]) { + throw new Error(`Tag "${tag}" is not in prerelease format (expected: prerelease-vX.Y.Z-prerelease.N)`); + } + return { + major: parseInt(match[1], 10), + minor: parseInt(match[2], 10), + patch: parseInt(match[3], 10), + }; +} + +/** + * Get the name of the upstream remote (could be 'upstream' or 'origin') + */ +async function getUpstreamRemote(cwd: string): Promise { + try { + const { stdout } = await execa('git', ['remote'], { cwd }); + const remotes = stdout.split('\n').map(r => r.trim()).filter(Boolean); + + if (remotes.includes('upstream')) { + return 'upstream'; + } + if (remotes.includes('origin')) { + return 'origin'; + } + throw new Error('No upstream or origin remote found'); + } catch (error) { + if (error instanceof Error) { + throw new Error(`Failed to determine upstream remote: ${error.message}`, { cause: error }); + } + throw error; + } +} + +/** + * Get the latest prerelease tag from git + */ +async function getLatestPrereleaseTag(cwd: string, remote: string): Promise { + try { + // Fetch tags from remote + await execa('git', ['fetch', remote, '--tags'], { cwd }); + + // Get all prerelease tags, sorted by version + const { stdout } = await execa('git', ['tag', '-l', 'prerelease-v*'], { cwd }); + const tags = stdout.split('\n').filter(Boolean); + + if (tags.length === 0) { + return null; + } + + // Sort tags by version (using git's version sort) + const { stdout: sortedTags } = await execa('git', ['tag', '-l', 'prerelease-v*', '--sort=version:refname'], { cwd }); + const sortedTagList = sortedTags.split('\n').filter(Boolean); + + return sortedTagList[sortedTagList.length - 1] || null; + } catch (error) { + if (error instanceof Error) { + throw new Error(`Failed to get latest prerelease tag: ${error.message}`, { cause: error }); + } + throw error; + } +} + +/** + * Check if a git tag already exists + */ +async function tagExists(tag: string, cwd: string): Promise { + try { + await execa('git', ['rev-parse', tag], { cwd }); + return true; + } catch { + return false; + } +} + +export interface BumpPrereleaseOptions { + dryRun?: boolean; + major?: boolean; +} + +export async function runBumpPrerelease(cwd: string, options: BumpPrereleaseOptions = {}): Promise { + const { dryRun = false, major = false } = options; + + if (dryRun) { + console.log('šŸ” DRY RUN MODE - No changes will be made\n'); + } + + console.log(`šŸš€ Bumping prerelease ${major ? 'MAJOR' : 'minor'} version...\n`); + + const remote = await getUpstreamRemote(cwd); + console.log(`šŸ“” Using remote: ${remote}`); + + console.log('šŸ“„ Fetching tags...'); + const latestTag = await getLatestPrereleaseTag(cwd, remote); + + let newTag: string; + + if (!latestTag) { + console.log('āš ļø No existing prerelease tags found.'); + console.log('Creating initial prerelease tag: prerelease-v1.0.0-prerelease.0\n'); + newTag = 'prerelease-v1.0.0-prerelease.0'; + } else { + console.log(`šŸ“Œ Latest prerelease tag: ${latestTag}`); + + const { major: currentMajor, minor: currentMinor } = parsePrereleaseTag(latestTag); + + let newVersion: string; + if (major) { + // Bump major version, reset minor and patch to 0 + const newMajor = currentMajor + 1; + newVersion = `${newMajor}.0.0`; + } else { + // Bump minor version, reset patch to 0 + const newMinor = currentMinor + 1; + newVersion = `${currentMajor}.${newMinor}.0`; + } + + newTag = `prerelease-v${newVersion}-prerelease.0`; + console.log(`šŸ·ļø Next prerelease tag: ${newTag}\n`); + } + + // Check if tag already exists + if (await tagExists(newTag, cwd)) { + throw new Error(`Tag ${newTag} already exists. Cannot create duplicate tag.`); + } + + if (dryRun) { + console.log(`\nšŸ“‹ Summary (DRY RUN):`); + console.log(` Would create tag: ${newTag}`); + console.log(` Would push tag to: ${remote}`); + console.log('\nšŸ’” Run without --dry-run to actually create and push the tag.\n'); + return; + } + + // Create the tag + console.log(`Creating tag: ${newTag}`); + await execa('git', ['tag', newTag], { cwd }); + console.log('āœ… Tag created'); + + // Push the tag + console.log(`šŸ“¤ Pushing tag to ${remote}...`); + await execa('git', ['push', remote, newTag], { cwd }); + console.log(`āœ… Tag pushed to ${remote}`); + + console.log('\nšŸŽ‰ Done! Semantic-release should now start releasing from this tag.\n'); +} diff --git a/src/cli.ts b/src/cli.ts index 6a49f03..38632d1 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -14,6 +14,7 @@ import { runLoad } from './load.js'; import { runDeployToGitHubPages } from './gh-pages.js'; import { readPackageVersion } from './read-package-version.js'; import { promptAndSetLocalGitUser } from './git-user-config.js'; +import { runBumpPrerelease } from './bump-prerelease.js'; const packageJsonPath = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'package.json'); const packageVersion = readPackageVersion(packageJsonPath); @@ -205,4 +206,25 @@ program } }); +/** Command to bump prerelease version for semantic-release */ +program + .command('bump-prerelease') + .description('Create and push next prerelease tag (increments minor version by default) for semantic-release') + .argument('[path]', 'Path to the repository (defaults to current directory)') + .option('--dry-run', 'Show what would be done without making any changes') + .option('--major', 'Bump major version instead of minor version') + .action(async (repoPath, options) => { + const cwd = repoPath ? path.resolve(repoPath) : process.cwd(); + try { + await runBumpPrerelease(cwd, { dryRun: options.dryRun, major: options.major }); + } catch (error) { + if (error instanceof Error) { + console.error(`\nāŒ ${error.message}\n`); + } else { + console.error(error); + } + process.exit(1); + } + }); + program.parse(process.argv); \ No newline at end of file