From 3583fc2c4e3b2f1f9263b52091dfcbb389d032dd Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Fri, 12 Jun 2026 02:14:25 -0400 Subject: [PATCH] fix: skip rollback job for inline-run deploys Signed-off-by: Joshua Temple --- .../13-inline-run-deploy-no-rollback.yaml | 39 +++++++++++ internal/generate/promote.go | 15 ++++ internal/generate/promote_test.go | 69 +++++++++++++++++++ 3 files changed, 123 insertions(+) create mode 100644 e2e/scenarios/13-inline-run-deploy-no-rollback.yaml diff --git a/e2e/scenarios/13-inline-run-deploy-no-rollback.yaml b/e2e/scenarios/13-inline-run-deploy-no-rollback.yaml new file mode 100644 index 0000000..862369d --- /dev/null +++ b/e2e/scenarios/13-inline-run-deploy-no-rollback.yaml @@ -0,0 +1,39 @@ +name: "Inline Run Deploy Skips Rollback Job" +description: | + Verifies that a deploy declaring run: (an inline-run callback with no reusable + workflow) does not produce a rollback- job in the generated promote.yaml. + A rollback job is a reusable-workflow call, so an inline-run deploy has nothing + to call: emitting one would write an empty `uses:` value and invalid workflow + YAML. The deploy- job itself must still be emitted. + + This is a generator-output verification scenario. Assertion runs on the staged + repo after StageRepoFromConfig generates workflows, before any run. + +config: + trunk_branch: main + environments: [test, staging, prod] + deploys: + - name: app + run: | + echo "deploy $ENVIRONMENT" + shell: bash + triggers: ["src/**"] + +steps: + - name: "Initial commit generates workflows; inline-run deploy has no rollback job and no empty uses" + action: commit + commit: + message: "feat: add inline-run deploy" + files: + src/app.go: | + package main + func main() {} + expect: + workflow_files: + - path: ".github/workflows/promote.yaml" + contains: + - "deploy-app:" + - "deploy-app-prod:" + not_contains: + - "rollback-app:" + - "uses: \n" diff --git a/internal/generate/promote.go b/internal/generate/promote.go index 7a1fe06..9a9a80e 100644 --- a/internal/generate/promote.go +++ b/internal/generate/promote.go @@ -1032,6 +1032,14 @@ func (g *PromoteGenerator) writeRollbackJobs(sb *strings.Builder) { // Write rollback jobs for local deploys for _, d := range g.config.Deploys { + // A rollback job is, by contract, a reusable-workflow call that reverts a + // deploy. An inline run: deploy callback has no reusable workflow to call, + // so there is nothing to roll back through: skip it. Emitting a rollback + // job here would write an empty `uses:` value and invalid workflow YAML. + if d.Run != "" { + continue + } + jobName := fmt.Sprintf("deploy-%s", d.Name) fmt.Fprintf(sb, " rollback-%s:\n", d.Name) @@ -1057,6 +1065,13 @@ func (g *PromoteGenerator) writeRollbackJobs(sb *strings.Builder) { // Write rollback jobs for external deploys for _, ext := range g.config.External { for _, d := range ext.Deploys { + // Inline run: external deploys (Run set, Workflow empty) have no + // reusable workflow to call, so they cannot have a rollback job for + // the same reason as local inline-run deploys above. + if d.Run != "" { + continue + } + jobName := fmt.Sprintf("deploy-%s", d.Name) fmt.Fprintf(sb, " rollback-%s:\n", d.Name) diff --git a/internal/generate/promote_test.go b/internal/generate/promote_test.go index 3a553fc..8ac08c8 100644 --- a/internal/generate/promote_test.go +++ b/internal/generate/promote_test.go @@ -9,6 +9,7 @@ import ( "github.com/stablekernel/cascade/internal/config" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" ) // concurrencyGroupLine extracts the " group: ..." line from the top-level @@ -1247,6 +1248,74 @@ func TestPromoteGenerator_RollbackJobs(t *testing.T) { assert.Contains(t, content, "rollback_on_failure: ${{ steps.preflight.outputs.rollback_on_failure }}") } +func TestPromoteGenerator_RollbackJobs_InlineRunDeploy(t *testing.T) { + tests := []struct { + name string + deploys []config.DeployConfig + wantRollback []string // rollback job names that must be present + noRollback []string // rollback job names that must be absent + wantDeployJob []string // deploy job names that must still be present + }{ + { + name: "single inline-run deploy emits no rollback job", + deploys: []config.DeployConfig{ + {Name: "app", Run: "echo deploying $ENVIRONMENT", Shell: "bash"}, + }, + noRollback: []string{"rollback-app:"}, + wantDeployJob: []string{"deploy-app:", "deploy-app-prod:"}, + }, + { + name: "mixed inline-run and reusable-workflow deploys", + deploys: []config.DeployConfig{ + {Name: "app", Run: "echo deploying $ENVIRONMENT", Shell: "bash"}, + {Name: "infra", Workflow: ".github/workflows/deploy-infra.yaml"}, + }, + wantRollback: []string{"rollback-infra:"}, + noRollback: []string{"rollback-app:"}, + wantDeployJob: []string{"deploy-app:", "deploy-infra:"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := &config.TrunkConfig{ + TrunkBranch: "main", + Environments: []string{"test", "staging", "prod"}, + Deploys: tt.deploys, + } + + gen := NewPromoteGenerator(cfg, "") + content, err := gen.Generate() + require.NoError(t, err) + + // An inline-run deploy has no reusable workflow to call, so the + // generator must never emit an empty `uses:` line. + for _, line := range strings.Split(content, "\n") { + assert.NotEqual(t, " uses:", strings.TrimRight(line, " "), + "generated promote workflow must not contain an empty uses: value") + } + + for _, want := range tt.wantRollback { + assert.Contains(t, content, want, + "reusable-workflow deploy should keep its rollback job") + } + for _, absent := range tt.noRollback { + assert.NotContains(t, content, absent, + "inline-run deploy must not produce a rollback job") + } + for _, want := range tt.wantDeployJob { + assert.Contains(t, content, want, + "deploy job must still be emitted regardless of rollback skip") + } + + // The emitted workflow must remain structurally valid YAML. + var parsed map[string]any + require.NoError(t, yaml.Unmarshal([]byte(content), &parsed), + "emitted promote workflow must be valid YAML") + }) + } +} + func TestPromoteGenerator_RollbackOnFailureInput(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main",