Repository navigation
fix(ci): use terraform exit code as the source of truth for CI status #2382
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Erik Osterman (Cloud Posse) (osterman)
merged 9 commits into
main
from
osterman/ci-summary-error-status
May 3, 2026
Merged
Changes from all commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
9be492e
fix(ci): show "Apply Failed" summary when terraform deploy errors bef…
osterman 07d0af8
[autocommit] formatting fixes
atmos-pro[bot] 9a2d655
refactor(ci): make terraform exit code authoritative for failure/chan…
osterman b280f38
fix(ci): preserve plan exit-2 "changes detected" when CommandError is…
osterman a7531f5
test(ci): boost patch coverage for RunCIHooksOptions plumbing
osterman 64aa290
Merge remote-tracking branch 'origin/main' into osterman/ci-summary-e…
osterman 21bd649
[autocommit] formatting fixes
atmos-pro[bot] 19358e5
[autocommit] formatting fixes
atmos-pro[bot] 228608c
Merge branch 'main' into osterman/ci-summary-error-status
goruha File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| package terraform | ||
|
|
||
| import ( | ||
| "os" | ||
| "testing" | ||
| ) | ||
|
|
||
| // TestMain is the entry point for the cmd/terraform test binary. | ||
| // It intercepts subprocess-helper env vars before any test runs, enabling | ||
| // tests to use the test binary itself as a portable cross-platform subprocess | ||
| // (no Unix-only binaries required). | ||
| // | ||
| // Supported env vars: | ||
| // | ||
| // _ATMOS_TEST_EXIT_ONE=1 — if set, exit 1 immediately so the parent process | ||
| // observes a non-zero exit code without invoking | ||
| // any actual test. Used by the ExitCodeError | ||
| // wrapping-contract test in | ||
| // utils_exit_wrapping_test.go. | ||
| func TestMain(m *testing.M) { | ||
| if os.Getenv("_ATMOS_TEST_EXIT_ONE") == "1" { | ||
| os.Exit(1) | ||
| } | ||
| os.Exit(m.Run()) | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,108 @@ | ||
| package terraform | ||
|
|
||
| // utils_exit_wrapping_test.go is a regression test for the ExitCodeError | ||
| // wrapping contract at the cmd/terraform → internal/exec boundary. | ||
| // | ||
| // runHooksOnErrorWithOutput (cmd/terraform/utils.go) extracts the exit code | ||
| // from the command error via errUtils.GetExitCode(cmdErr). The CI hook | ||
| // plumbing (RunCIHooksOptions.ExitCode + CommandError) depends on the wrapper | ||
| // being intact: a future refactor that swaps the error type or unwraps | ||
| // ExitCodeError before it reaches this layer would silently degrade CI | ||
| // summaries and check runs without tripping any of the existing tests | ||
| // downstream (those tests start *after* GetExitCode has already flattened the | ||
| // chain to an int). | ||
| // | ||
| // Cross-platform approach: uses the test binary (os.Executable) with | ||
| // _ATMOS_TEST_EXIT_ONE=1 — TestMain in testmain_test.go intercepts that env | ||
| // var and calls os.Exit(1). No Unix-only binaries are required. | ||
|
|
||
| import ( | ||
| "errors" | ||
| "os" | ||
| "testing" | ||
|
|
||
| "github.com/stretchr/testify/assert" | ||
| "github.com/stretchr/testify/require" | ||
|
|
||
| errUtils "github.com/cloudposse/atmos/errors" | ||
| e "github.com/cloudposse/atmos/internal/exec" | ||
| h "github.com/cloudposse/atmos/pkg/hooks" | ||
| "github.com/cloudposse/atmos/pkg/schema" | ||
| ) | ||
|
|
||
| // TestExecuteShellCommand_ErrorWrapsExitCodeError_AtCmdTerraformBoundary verifies | ||
| // that the cmdErr passed into runHooksOnErrorWithOutput preserves the | ||
| // ExitCodeError wrapper. This protects the contract: | ||
| // | ||
| // ExecuteShellCommand → returns errUtils.ExitCodeError (typed) | ||
| // → ExecuteTerraform/executeSingleComponent | ||
| // → terraform RunE catches as runErr | ||
| // → runHooksOnErrorWithOutput (this layer) calls | ||
| // errUtils.GetExitCode(cmdErr) — depends on the wrapper. | ||
| // | ||
| // We assert both ends: errors.As recovers the typed error AND | ||
| // errUtils.GetExitCode extracts the wrapped code, which is exactly what | ||
| // runHooksOnErrorWithOutput does in production. | ||
| func TestExecuteShellCommand_ErrorWrapsExitCodeError_AtCmdTerraformBoundary(t *testing.T) { | ||
| exePath, err := os.Executable() | ||
| require.NoError(t, err, "os.Executable() must succeed") | ||
|
|
||
| atmosConfig := schema.AtmosConfiguration{} | ||
| cmdErr := e.ExecuteShellCommand( | ||
| atmosConfig, | ||
| exePath, | ||
| []string{"-test.run=^$"}, // no test matches; TestMain exits before any test runs. | ||
| "", // dir: current working directory. | ||
| []string{"_ATMOS_TEST_EXIT_ONE=1"}, // env: makes TestMain call os.Exit(1). | ||
| false, // dryRun: false — actually run the subprocess. | ||
| "", // redirectStdErr. | ||
| ) | ||
|
|
||
| require.Error(t, cmdErr, "subprocess exit 1 must surface as a non-nil error") | ||
|
|
||
| // The error reaching runHooksOnErrorWithOutput must remain wrapped as | ||
| // ExitCodeError — this is the contract the CI hook plumbing depends on. | ||
| var exitCodeErr errUtils.ExitCodeError | ||
| require.True(t, | ||
| errors.As(cmdErr, &exitCodeErr), | ||
| "cmdErr passed into runHooksOnErrorWithOutput must satisfy errors.As(err, &errUtils.ExitCodeError{}); got %T: %v", cmdErr, cmdErr, | ||
| ) | ||
| assert.Equal(t, 1, exitCodeErr.Code, "ExitCodeError.Code must equal the subprocess exit code") | ||
|
|
||
| // Mirror what runHooksOnErrorWithOutput does in production: extract the | ||
| // exit code via errUtils.GetExitCode. This is the consumed half of the | ||
| // contract — if GetExitCode ever stops finding ExitCodeError in the | ||
| // chain, the CI summary/check-run flow regresses to ExitCode=1 by | ||
| // default, masking real exit codes (e.g., 2 for plan -detailed-exitcode). | ||
| assert.Equal(t, 1, errUtils.GetExitCode(cmdErr), | ||
| "errUtils.GetExitCode must extract the wrapped code — runHooksOnErrorWithOutput depends on this") | ||
|
|
||
| // End-to-end: feed the cmdErr into the same RunCIHooks plumbing that | ||
| // runHooksOnErrorWithOutput uses, and verify the wrapper survives in | ||
| // the options struct that plugins observe. ci.enabled=false short-circuits | ||
| // before any plugin runs, so this exercises only the option construction | ||
| // + extraction path that this PR introduced. | ||
| opts := &h.RunCIHooksOptions{ | ||
| Event: h.AfterTerraformPlan, | ||
| AtmosConfig: &atmosConfig, // ci.enabled is the zero value (false) → short-circuits. | ||
| Info: &schema.ConfigAndStacksInfo{Stack: "dev", ComponentFromArg: "vpc"}, | ||
| Output: "", | ||
| ForceCIMode: true, | ||
| CommandError: cmdErr, | ||
| ExitCode: errUtils.GetExitCode(cmdErr), | ||
| } | ||
|
|
||
| // The wrapper contract must hold AT the boundary plugins see, not just at | ||
| // the cmd/terraform layer. A future refactor that copies/wraps cmdErr | ||
| // before placing it in the options would lose this property. | ||
| require.True(t, | ||
| errors.As(opts.CommandError, &exitCodeErr), | ||
| "options.CommandError must still satisfy errors.As(err, &errUtils.ExitCodeError{}); got %T", opts.CommandError, | ||
| ) | ||
| assert.Equal(t, 1, opts.ExitCode, "options.ExitCode must equal the wrapped subprocess exit code") | ||
| assert.Equal(t, 1, exitCodeErr.Code) | ||
|
|
||
| // Confirm the round-trip is harmless when CI is disabled. | ||
| require.NoError(t, h.RunCIHooks(opts), | ||
| "RunCIHooks must short-circuit cleanly when ci.enabled=false even with a non-nil CommandError") | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,128 @@ | ||
| package terraform | ||
|
|
||
| // utils_hooks_test.go covers the hook-wrapper functions in utils.go that | ||
| // build RunCIHooksOptions and forward CommandError + ExitCode into the CI | ||
| // hook plumbing. These wrappers are thin glue (ProcessCommandLineArgs → | ||
| // InitCliConfig → RunCIHooks) but contain the new CommandError/ExitCode | ||
| // forwarding lines added in this PR. The demo-stacks fixture has no | ||
| // ci.enabled config, so RunCIHooks short-circuits cleanly — these tests | ||
| // exercise option construction without invoking real plugin handlers. | ||
|
|
||
| import ( | ||
| "testing" | ||
|
|
||
| "github.com/spf13/cobra" | ||
| "github.com/stretchr/testify/assert" | ||
|
|
||
| errUtils "github.com/cloudposse/atmos/errors" | ||
| "github.com/cloudposse/atmos/pkg/hooks" | ||
| "github.com/cloudposse/atmos/pkg/schema" | ||
| ) | ||
|
|
||
| // newHookTestCmd constructs a cobra.Command with all the flags | ||
| // ProcessCommandLineArgs reads (base-path, config, config-path, profile, | ||
| // stack, ci, verify-plan). This lets the wrapper functions in utils.go | ||
| // progress past argument parsing and into the option-construction code | ||
| // that this PR added. | ||
| func newHookTestCmd() *cobra.Command { | ||
| cmd := &cobra.Command{Use: "plan"} | ||
| cmd.Flags().String("base-path", "", "base path") | ||
| cmd.Flags().StringSlice("config", nil, "config") | ||
| cmd.Flags().StringSlice("config-path", nil, "config path") | ||
| cmd.Flags().StringSlice("profile", nil, "profile") | ||
| cmd.Flags().String("stack", "", "stack flag") | ||
| cmd.Flags().Bool("ci", false, "ci flag") | ||
| cmd.Flags().Bool("verify-plan", false, "verify-plan flag") | ||
| return cmd | ||
| } | ||
|
|
||
| // TestRunHooksOnError_PreservesCommandError verifies the failure-path | ||
| // wrapper (runHooksOnError → runHooksOnErrorWithOutput) accepts a non-nil | ||
| // cmdErr and forwards it into RunCIHooksOptions without mutating it. This | ||
| // exercises the new errUtils.GetExitCode(cmdErr) call and the | ||
| // CommandError/ExitCode field assignments added in this PR. | ||
| func TestRunHooksOnError_PreservesCommandError(t *testing.T) { | ||
| t.Chdir("../../examples/demo-stacks") | ||
|
|
||
| cmd := newHookTestCmd() | ||
|
|
||
| tests := []struct { | ||
| name string | ||
| cmdErr error | ||
| wantExt int | ||
| }{ | ||
| { | ||
| name: "wrapped ExitCodeError code 1", | ||
| cmdErr: errUtils.ExitCodeError{Code: 1}, | ||
| wantExt: 1, | ||
| }, | ||
| { | ||
| name: "wrapped ExitCodeError code 2 (plan changes detected)", | ||
| cmdErr: errUtils.ExitCodeError{Code: 2}, | ||
| wantExt: 2, | ||
| }, | ||
| } | ||
|
|
||
| for _, tc := range tests { | ||
| t.Run(tc.name, func(t *testing.T) { | ||
| // Pre-condition: GetExitCode must extract the wrapped code. | ||
| // runHooksOnErrorWithOutput depends on this — if it ever stops | ||
| // working, CI summaries silently regress to ExitCode=1 by default. | ||
| assert.Equal(t, tc.wantExt, errUtils.GetExitCode(tc.cmdErr), | ||
| "GetExitCode must extract the wrapped exit code") | ||
|
|
||
| // runHooksOnErrorWithOutput returns void; the test asserts no | ||
| // panic, no fatal error from the option construction or the | ||
| // ci-disabled short-circuit path. | ||
| runHooksOnError(hooks.AfterTerraformPlan, cmd, []string{"--stack", "dev", "myapp"}, tc.cmdErr) | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| // TestRunHooksOnErrorWithOutput_NilCmdErr verifies the wrapper handles a | ||
| // nil cmdErr cleanly (defensive: callers should always pass a non-nil error | ||
| // on the failure path, but the wrapper must not panic if they don't). | ||
| func TestRunHooksOnErrorWithOutput_NilCmdErr(t *testing.T) { | ||
| t.Chdir("../../examples/demo-stacks") | ||
|
|
||
| cmd := newHookTestCmd() | ||
|
|
||
| // errUtils.GetExitCode(nil) returns 0 — ExitCode forwarded to plugins | ||
| // will be 0, which RunCIHooks short-circuits cleanly via the | ||
| // ci.enabled=false demo-stacks fixture. | ||
| assert.Equal(t, 0, errUtils.GetExitCode(nil)) | ||
|
|
||
| runHooksOnErrorWithOutput(hooks.AfterTerraformPlan, cmd, []string{"--stack", "dev", "myapp"}, nil, "captured output") | ||
| } | ||
|
|
||
| // TestRunHooks_DemoStacks exercises the success-path wrapper (runHooks → | ||
| // runHooksWithOutput). The demo-stacks fixture has ci.enabled=false so | ||
| // RunCIHooks short-circuits cleanly inside the wrapper — the test asserts | ||
| // the wrapper completes without error. | ||
| func TestRunHooks_DemoStacks(t *testing.T) { | ||
| t.Chdir("../../examples/demo-stacks") | ||
|
|
||
| cmd := newHookTestCmd() | ||
| err := runHooks(hooks.BeforeTerraformPlan, cmd, []string{"--stack", "dev", "myapp"}) | ||
| assert.NoError(t, err) | ||
| } | ||
|
|
||
| // TestRunCIHooksForDeploy_DemoStacks exercises the deploy-specific wrapper | ||
| // (runCIHooksForDeploy). Unlike the other wrappers, this one takes a | ||
| // pre-resolved info struct and skips ProcessCommandLineArgs to avoid eager | ||
| // !store YAML function resolution. The demo-stacks fixture's | ||
| // ci.enabled=false makes RunCIHooks short-circuit cleanly. | ||
| func TestRunCIHooksForDeploy_DemoStacks(t *testing.T) { | ||
| t.Chdir("../../examples/demo-stacks") | ||
|
|
||
| cmd := newHookTestCmd() | ||
| info := &schema.ConfigAndStacksInfo{ | ||
| Stack: "dev", | ||
| ComponentFromArg: "myapp", | ||
| ComponentType: "terraform", | ||
| } | ||
|
|
||
| // Function returns void — the test verifies no panic on the option | ||
| // construction path with a wired info struct. | ||
| runCIHooksForDeploy(hooks.BeforeTerraformDeploy, cmd, []string{"myapp"}, info, "") | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.