also looking at this
fix: stop releases when a required command fails
#2014
3 files
+40
-34
| @@ -684,6 +684,8 @@ export async function versionBump(options: VersionBumpOptions): Promise<void> { | ||
| 684 | 684 | |
| 685 | 685 | // Create a backup of the file content before modification |
| 686 | 686 | const originalContent = readFileSync(filePath, 'utf-8') |
| 687 | fileBackups.set(filePath, { content: originalContent, version: fileCurrentVersion }) | |
| 688 | hasStartedUpdates = true | |
| 687 | 689 | // Always call updateVersionInFile to ensure mocks are triggered in tests |
| 688 | 690 | const fileInfo = updateVersionInFile(filePath, fileCurrentVersion, fileNewVersion, forceUpdate) |
| 689 | 691 | // If in dry run mode, restore the original content after the operation |
| @@ -773,27 +775,22 @@ export async function versionBump(options: VersionBumpOptions): Promise<void> { | ||
| 773 | 775 | |
| 774 | 776 | // Execute custom commands before git operations |
| 775 | 777 | if (execute && !dryRun) { |
| 776 | try { | |
| 777 | const commands = Array.isArray(execute) ? execute : [execute] | |
| 778 | for (const command of commands) { | |
| 779 | // Check for interruption before each command | |
| 780 | checkInterruption() | |
| 781 | if (progress) { | |
| 782 | // Provide full payload to satisfy VersionBumpProgress typing | |
| 783 | progress({ | |
| 784 | event: ProgressEvent.Execute, | |
| 785 | script: command, | |
| 786 | updatedFiles, | |
| 787 | skippedFiles, | |
| 788 | newVersion: lastNewVersion || '', | |
| 789 | oldVersion: _lastOldVersion, | |
| 790 | }) | |
| 791 | } | |
| 792 | executeCommandWithOutput(command, effectiveCwd) | |
| 778 | const commands = Array.isArray(execute) ? execute : [execute] | |
| 779 | for (const command of commands) { | |
| 780 | // A failed release prerequisite must reach the outer rollback handler | |
| 781 | // before any commit, tag or push can publish incomplete artifacts. | |
| 782 | checkInterruption() | |
| 783 | if (progress) { | |
| 784 | progress({ | |
| 785 | event: ProgressEvent.Execute, | |
| 786 | script: command, | |
| 787 | updatedFiles, | |
| 788 | skippedFiles, | |
| 789 | newVersion: lastNewVersion || '', | |
| 790 | oldVersion: _lastOldVersion, | |
| 791 | }) | |
| 793 | 792 | } |
| 794 | } | |
| 795 | catch (error) { | |
| 796 | console.warn(`Warning: Command execution failed: ${error}`) | |
| 793 | executeCommandWithOutput(command, effectiveCwd) | |
| 797 | 794 | } |
| 798 | 795 | } |
| 799 | 796 | else if (execute && dryRun) { |
| @@ -389,7 +389,7 @@ describe('Git Operations (Integration)', () => { | ||
| 389 | 389 | expect(mockExecSyncWithOutput).toHaveBeenCalledWith('echo "third"', expect.any(String)) |
| 390 | 390 | }) |
| 391 | 391 | |
| 392 | it('should handle command execution failures gracefully', async () => { | |
| 392 | it('rejects failed release commands and rolls back the version', async () => { | |
| 393 | 393 | const packagePath = join(tempDir, 'package.json') |
| 394 | 394 | writeFileSync(packagePath, JSON.stringify({ name: 'test', version: '1.0.0' }, null, 2)) |
| 395 | 395 | |
| @@ -400,27 +400,20 @@ describe('Git Operations (Integration)', () => { | ||
| 400 | 400 | } |
| 401 | 401 | }) |
| 402 | 402 | |
| 403 | const consoleSpy = spyOn(console, 'warn').mockImplementation(() => {}) | |
| 404 | ||
| 405 | await versionBump({ | |
| 403 | await expect(versionBump({ | |
| 406 | 404 | release: 'patch', |
| 407 | 405 | files: [packagePath], |
| 408 | execute: 'failing-command', | |
| 406 | execute: ['failing-command', 'echo "must not run"'], | |
| 409 | 407 | commit: false, |
| 410 | 408 | tag: false, |
| 411 | 409 | push: false, |
| 412 | 410 | quiet: true, |
| 413 | 411 | noGitCheck: true, |
| 414 | }) | |
| 412 | })).rejects.toThrow('Command failed') | |
| 415 | 413 | |
| 416 | // Version should still be updated despite command failure | |
| 417 | 414 | const updatedContent = JSON.parse(readFileSync(packagePath, 'utf-8')) |
| 418 | expect(updatedContent.version).toBe('1.0.1') | |
| 419 | ||
| 420 | // Warning should be shown | |
| 421 | expect(consoleSpy).toHaveBeenCalledWith('Warning: Command execution failed: Error: Command failed') | |
| 422 | ||
| 423 | consoleSpy.mockRestore() | |
| 415 | expect(updatedContent.version).toBe('1.0.0') | |
| 416 | expect(mockExecSyncWithOutput).not.toHaveBeenCalledWith('echo "must not run"', expect.any(String)) | |
| 424 | 417 | }) |
| 425 | 418 | |
| 426 | 419 | it('should not execute commands in dry run mode', async () => { |
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | import { afterEach, beforeEach, describe, expect, it } from 'bun:test' |
| 2 | import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' | |
| 2 | import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' | |
| 3 | 3 | import { tmpdir } from 'node:os' |
| 4 | 4 | import { join } from 'node:path' |
| 5 | 5 | |
| @@ -121,6 +121,22 @@ describe('Git push & tag integration (local bare remote)', () => { | ||
| 121 | 121 | }) |
| 122 | 122 | } |
| 123 | 123 | |
| 124 | it('does not commit, tag or push when a required release command fails', async () => { | |
| 125 | const localHead = runGit(['rev-parse', 'HEAD'], workDir).trim() | |
| 126 | const remoteHead = runGit(['--git-dir', bareDir, 'rev-parse', 'refs/heads/main'], tempDir).trim() | |
| 127 | const manifest = readFileSync(join(workDir, 'package.json'), 'utf8') | |
| 128 | const res = await runCLI(['patch', '--commit', '--tag', '--push', '--yes', '--no-changelog', '--execute', 'bun -e "process.exit(23)"'], workDir) | |
| 129 | ||
| 130 | expect({ | |
| 131 | succeeded: res.code === 0, | |
| 132 | manifest: readFileSync(join(workDir, 'package.json'), 'utf8'), | |
| 133 | localHead: runGit(['rev-parse', 'HEAD'], workDir).trim(), | |
| 134 | remoteHead: runGit(['--git-dir', bareDir, 'rev-parse', 'refs/heads/main'], tempDir).trim(), | |
| 135 | localTags: runGit(['tag', '--list'], workDir).trim(), | |
| 136 | remoteTags: runGit(['--git-dir', bareDir, 'tag', '--list'], tempDir).trim(), | |
| 137 | }).toEqual({ succeeded: false, manifest, localHead, remoteHead, localTags: '', remoteTags: '' }) | |
| 138 | }) | |
| 139 | ||
| 124 | 140 | it('pushes commit and tag to local bare remote (single package)', async () => { |
| 125 | 141 | writeFileSync(join(workDir, 'settings.local.json'), '{"local":true}\n') |
| 126 | 142 | |