Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions e2e/scenarios/13-inline-run-deploy-no-rollback.yaml
Original file line number Diff line number Diff line change
@@ -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-<name> 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-<name> 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"
15 changes: 15 additions & 0 deletions internal/generate/promote.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down
69 changes: 69 additions & 0 deletions internal/generate/promote_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand Down
Loading