ReviewOS

also looking at this

stacks/bumpx

fix: stop releases when a required command fails

#2014
Merged chris wants to merge codex/fail-fast-release-execute into main
3 files +40 -34
packages/bumpx/src/version-bump.tsmodified+17-20
Changes to packages/bumpx/src/version-bump.ts
@@ -684,6 +684,8 @@ export async function versionBump(options: VersionBumpOptions): Promise<void> {
684684
685685 // Create a backup of the file content before modification
686686 const originalContent = readFileSync(filePath, 'utf-8')
687 fileBackups.set(filePath, { content: originalContent, version: fileCurrentVersion })
688 hasStartedUpdates = true
687689 // Always call updateVersionInFile to ensure mocks are triggered in tests
688690 const fileInfo = updateVersionInFile(filePath, fileCurrentVersion, fileNewVersion, forceUpdate)
689691 // If in dry run mode, restore the original content after the operation
@@ -773,27 +775,22 @@ export async function versionBump(options: VersionBumpOptions): Promise<void> {
773775
774776 // Execute custom commands before git operations
775777 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 })
793792 }
794 }
795 catch (error) {
796 console.warn(`Warning: Command execution failed: ${error}`)
793 executeCommandWithOutput(command, effectiveCwd)
797794 }
798795 }
799796 else if (execute && dryRun) {
packages/bumpx/test/git-operations.test.tsmodified+6-13
Changes to packages/bumpx/test/git-operations.test.ts
@@ -389,7 +389,7 @@ describe('Git Operations (Integration)', () => {
389389 expect(mockExecSyncWithOutput).toHaveBeenCalledWith('echo "third"', expect.any(String))
390390 })
391391
392 it('should handle command execution failures gracefully', async () => {
392 it('rejects failed release commands and rolls back the version', async () => {
393393 const packagePath = join(tempDir, 'package.json')
394394 writeFileSync(packagePath, JSON.stringify({ name: 'test', version: '1.0.0' }, null, 2))
395395
@@ -400,27 +400,20 @@ describe('Git Operations (Integration)', () => {
400400 }
401401 })
402402
403 const consoleSpy = spyOn(console, 'warn').mockImplementation(() => {})
404
405 await versionBump({
403 await expect(versionBump({
406404 release: 'patch',
407405 files: [packagePath],
408 execute: 'failing-command',
406 execute: ['failing-command', 'echo "must not run"'],
409407 commit: false,
410408 tag: false,
411409 push: false,
412410 quiet: true,
413411 noGitCheck: true,
414 })
412 })).rejects.toThrow('Command failed')
415413
416 // Version should still be updated despite command failure
417414 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))
424417 })
425418
426419 it('should not execute commands in dry run mode', async () => {
packages/bumpx/test/git-push.integration.test.tsmodified+17-1
Changes to packages/bumpx/test/git-push.integration.test.ts
@@ -1,5 +1,5 @@
11import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
2import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
2import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
33import { tmpdir } from 'node:os'
44import { join } from 'node:path'
55
@@ -121,6 +121,22 @@ describe('Git push & tag integration (local bare remote)', () => {
121121 })
122122 }
123123
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
124140 it('pushes commit and tag to local bare remote (single package)', async () => {
125141 writeFileSync(join(workDir, 'settings.local.json'), '{"local":true}\n')
126142