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
4 changes: 3 additions & 1 deletion docs/src/content/docs/workflows.md
Original file line number Diff line number Diff line change
Expand Up @@ -354,7 +354,9 @@ For the trunk branch, `cascade branch-protection` emits the full JSON body to PU

## Rollback

cascade generates a standalone `cascade-rollback.yaml` workflow whenever the manifest declares at least one environment. It re-deploys a prior version or SHA to a target environment, defaulting to the previous version (N-1). A read-only preflight resolves the target, the deploy stage re-runs the configured deploy callbacks keyed on the resolved SHA, and finalize writes the rolled-back state back to trunk.
cascade generates a standalone `cascade-rollback.yaml` workflow whenever the manifest declares at least two environments. It re-deploys a prior version or SHA to a target environment, defaulting to the previous version (N-1). A read-only preflight resolves the target, the deploy stage re-runs the configured deploy callbacks keyed on the resolved SHA, and finalize writes the rolled-back state back to trunk.

Rollback covers the promoted environments only. The first environment tracks trunk and is never promoted into, so it keeps no deploy history to roll back to: roll it forward by reverting the offending change on the trunk branch instead. The workflow dropdown offers only the promoted environments, and a rollback aimed at the first environment fails fast with that guidance.

By default the workflow is triggered by manual dispatch only (`workflow_dispatch`).

Expand Down
6 changes: 5 additions & 1 deletion e2e/harness/multistep.go
Original file line number Diff line number Diff line change
Expand Up @@ -212,14 +212,18 @@ type PromoteStep struct {
// failure (for example a rollback whose preflight cannot resolve a target),
// mirroring PromoteStep.ExpectFailure. ExpectSource, when non-empty, asserts the
// resolved-target source label that the preflight job echoes to its job log
// (one of "state", "previous-ring", or "git-history").
// (one of "state", "previous-ring", or "git-history"). ExpectLog, when set
// alongside ExpectFailure, asserts the failing run's logs contain the given
// substring, so a scenario can prove the run failed for the expected reason (for
// example the first-environment guard message) rather than an unrelated fault.
type RollbackStep struct {
Environment string `yaml:"environment"`
Target string `yaml:"target,omitempty"`
Deployable string `yaml:"deployable,omitempty"`
DryRun bool `yaml:"dry_run,omitempty"`
ExpectFailure bool `yaml:"expect_failure,omitempty"`
ExpectSource string `yaml:"expect_source,omitempty"`
ExpectLog string `yaml:"expect_log,omitempty"`
}

// VerifyStep defines a verify action: a read-only `cascade verify` run in the
Expand Down
8 changes: 8 additions & 0 deletions e2e/harness/rollback_actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package harness
import (
"context"
"fmt"
"strings"
)

// rollbackWorkflowPath is the generated rollback workflow's path inside the repo.
Expand Down Expand Up @@ -86,6 +87,13 @@ func (r *Runner) executeRollback(ctx context.Context, rollback *RollbackStep, co
// Handle expected failures (mirrors executePromote's ExpectFailure path).
if rollback.ExpectFailure {
if result.Conclusion == "failure" {
// When ExpectLog is set, assert the failure logs carry the expected
// marker so the scenario proves the run failed for the intended reason
// (for example the first-environment guard) and not an unrelated fault.
if rollback.ExpectLog != "" && !strings.Contains(result.Logs, rollback.ExpectLog) {
r.t.Logf(" Rollback workflow logs:\n%s", result.Logs)
return fmt.Errorf("rollback failed as expected but logs did not contain %q", rollback.ExpectLog)
}
r.t.Log(" Rollback: workflow failed as expected")
return nil
}
Expand Down
123 changes: 123 additions & 0 deletions e2e/scenarios/rollback/rollback-first-env-guard.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
name: "Rollback refuses the first environment, allows a promoted one"
description: |
Proves the first-environment rollback guard end to end. The first environment
tracks trunk and is never promoted into, so it keeps no deploy-history ring to
resolve a prior target from. A rollback there must fail fast with an actionable
message rather than silently re-point at an empty or stale ring. A promoted
environment still rolls back normally.

dev is the first environment. prod is advanced through two published versions
so it has a real prior target. The scenario then dispatches two rollbacks:

1. Rollback dev (the first env): the preflight refuses it. The run concludes
in failure and its logs carry the guard message, asserted via expect_log.
2. Rollback prod (a promoted env): resolves N-1 from the previous-deploy ring,
re-deploys at that SHA, and lands prod back on the first version.

config:
trunk_branch: main
environments: [dev, prod]
builds:
- name: app
workflow: build.yaml
triggers: ["src/**"]
deploys:
# Reusable deploy whose inner job echoes the resolved env/sha, so the deploy
# job runs observably under act without a checkout step. act keys it by the
# inner job id appdeploy, which the harness assertion targets.
- name: app
workflow: .github/workflows/deploy-app.yaml
triggers: ["**"]

steps:
- name: "Commit the first version source"
action: commit
commit:
message: "feat: first version"
files:
src/app.go: |
package main
func main() {}
.github/workflows/deploy-app.yaml: |
name: deploy-app
on:
workflow_call:
inputs:
environment:
required: false
type: string
sha:
required: false
type: string
jobs:
appdeploy:
runs-on: ubuntu-latest
steps:
- run: echo "deployed env=${{ inputs.environment }} sha=${{ inputs.sha }}"

- name: "Orchestrate the first commit into dev"
action: orchestrate
expect:
state:
dev:
sha: commit1

- name: "Promote the first version from dev to prod (establishes the prior target)"
action: promote
promote:
mode: cascade
target: prod
expect:
state:
prod:
sha: commit1

- name: "Commit a second version source"
action: commit
commit:
message: "feat: second version"
files:
src/app.go: |
package main
func main() { _ = 2 }

- name: "Orchestrate the second commit into dev"
action: orchestrate
expect:
state:
dev:
sha: commit2

- name: "Promote the second version from dev to prod (advances past the prior target)"
action: promote
promote:
mode: cascade
target: prod
expect:
state:
prod:
sha: commit2

# Rollback the first environment: the preflight refuses it before resolving any
# target. The run concludes in failure and its logs carry the guard message.
- name: "Rollback dev (the first environment) fails fast with the guard message"
action: rollback
rollback:
environment: dev
expect_failure: true
expect_log: "is the first environment"

# Rollback a promoted environment: resolves N-1 from the previous-deploy ring,
# re-deploys at that SHA, and prod state lands back on commit1.
- name: "Rollback prod (a promoted environment) succeeds"
action: rollback
rollback:
environment: prod
expect:
state:
prod:
sha: commit1
jobs:
preflight: success
appdeploy: success
finalize: success
24 changes: 17 additions & 7 deletions internal/generate/rollback.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,14 @@ func NewRollbackGenerator(cfg *config.TrunkConfig, baseDir string) *RollbackGene
}
}

// Enabled reports whether the rollback workflow should be emitted. It is emitted
// when the manifest declares at least one environment, since a rollback re-points
// an environment at a prior deployment.
// Enabled reports whether the rollback workflow should be emitted. It requires
// at least two environments: a rollback re-points a promoted environment at a
// prior deployment, and the first environment tracks trunk (it is never promoted
// into, so it has no rollback history and reverts via a merge to trunk instead).
// A single-environment project therefore has no rollbackable environment, so the
// workflow is not emitted, mirroring the hotfix generator.
func (g *RollbackGenerator) Enabled() bool {
return g.config != nil && len(g.config.Environments) >= 1
return g.config != nil && len(g.config.Environments) >= 2
}

// dispatchTrigger returns the configured opt-in repository_dispatch trigger, or
Expand Down Expand Up @@ -128,14 +131,21 @@ func (g *RollbackGenerator) writeTriggers(sb *strings.Builder) {
sb.WriteString(" workflow_dispatch:\n")
sb.WriteString(" inputs:\n")

// environment: enumerate the configured environments as a choice so the
// operator picks from the declared set rather than free-typing.
// environment: enumerate the promoted environments as a choice so the operator
// picks from the declared set rather than free-typing. The first environment
// is excluded: it tracks trunk and is refused by the rollback runtime guard, so
// offering it in the dropdown would only surface a guaranteed failure. Enabled
// gates emission on at least two environments, so Environments[1:] is non-empty.
sb.WriteString(" environment:\n")
sb.WriteString(" description: 'Environment to roll back'\n")
sb.WriteString(" required: true\n")
sb.WriteString(" type: choice\n")
sb.WriteString(" options:\n")
for _, env := range g.config.Environments {
promoted := g.config.Environments
if len(promoted) > 0 {
promoted = promoted[1:]
}
for _, env := range promoted {
fmt.Fprintf(sb, " - %s\n", env)
}

Expand Down
31 changes: 30 additions & 1 deletion internal/generate/rollback_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,19 @@ func rollbackTestConfig() *config.TrunkConfig {
}
}

func TestRollbackGenerator_Enabled_TrueWithOneEnv(t *testing.T) {
func TestRollbackGenerator_Enabled_FalseWithOneEnv(t *testing.T) {
// A single-environment project's only env is the first (trunk-tracking)
// environment, which reverts via a merge to trunk, not a rollback. With no
// promoted environment to roll back, the workflow is not emitted, mirroring
// the hotfix generator.
cfg := &config.TrunkConfig{Environments: []string{"prod"}}
g := NewRollbackGenerator(cfg, "")
assert.False(t, g.Enabled())
}

func TestRollbackGenerator_Enabled_TrueWithTwoEnvs(t *testing.T) {
cfg := &config.TrunkConfig{Environments: []string{"dev", "prod"}}
g := NewRollbackGenerator(cfg, "")
assert.True(t, g.Enabled())
}

Expand All @@ -39,6 +49,25 @@ func TestRollbackGenerator_Enabled_FalseWithZeroEnv(t *testing.T) {
assert.False(t, g.Enabled())
}

func TestRollbackGenerator_EnvironmentChoices_ExcludeFirstEnv(t *testing.T) {
cfg := &config.TrunkConfig{
TrunkBranch: "main",
Environments: []string{"dev", "staging", "prod"},
Deploys: []config.DeployConfig{
{Name: "services", Workflow: ".github/workflows/deploy.yaml"},
},
}
content, err := NewRollbackGenerator(cfg, "").Generate()
assert.NoError(t, err)

// The first env tracks trunk and is refused by the runtime guard, so the
// dropdown must not offer it. The promoted envs remain selectable.
assert.NotContains(t, content, " - dev\n",
"first environment must not be a rollback choice")
assert.Contains(t, content, " - staging\n")
assert.Contains(t, content, " - prod\n")
}

func TestRollbackGenerator_DispatchInputs(t *testing.T) {
g := NewRollbackGenerator(rollbackTestConfig(), "")
content, err := g.Generate()
Expand Down
105 changes: 105 additions & 0 deletions internal/rollback/first_env_guard_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package rollback

import (
"strings"
"testing"

"github.com/stablekernel/cascade/internal/config"
)

// The first environment tracks trunk and is never promoted into, so its
// deploy-history ring is structurally always empty. A rollback there would
// resolve a target from an empty or stale ring, which is a silent wrong target.
// The guard makes that case fail fast with an actionable error. dev is the first
// environment in the manifest writeManifest builds; prod is a promoted env.

func TestPlan_FirstEnvironment_NoTarget_Guarded(t *testing.T) {
dir := t.TempDir()
path := writeManifest(t, dir, "prodsha9999999", "v1.9.0")
rb := newRollbacker(t, path, fakeHistory{})

_, err := rb.Plan("dev", "", "")
if err == nil {
t.Fatalf("expected guard error rolling back the first environment, got nil")
}
if !strings.Contains(err.Error(), "first environment") {
t.Errorf("error = %q, want it to name the first environment", err.Error())
}
if !strings.Contains(err.Error(), "dev") {
t.Errorf("error = %q, want it to name the env (dev)", err.Error())
}
if !strings.Contains(err.Error(), "trunk") {
t.Errorf("error = %q, want it to point at the trunk revert path", err.Error())
}
}

func TestPlan_FirstEnvironment_WithTarget_Guarded(t *testing.T) {
dir := t.TempDir()
path := writeManifest(t, dir, "prodsha9999999", "v1.9.0")
rb := newRollbacker(t, path, fakeHistory{})

// Even an explicit --to value that matches the first env's live state must be
// refused: the trunk-tracking env reverts via a merge, not a ring rollback.
_, err := rb.Plan("dev", "devsha1234567", "")
if err == nil {
t.Fatalf("expected guard error rolling back the first environment with --to, got nil")
}
if !strings.Contains(err.Error(), "first environment") {
t.Errorf("error = %q, want it to name the first environment", err.Error())
}
}

func TestPlan_FirstEnvironment_Deployable_Guarded(t *testing.T) {
dir := t.TempDir()
path := writeManifest(t, dir, "prodsha9999999", "v1.9.0")
rb := newRollbacker(t, path, fakeHistory{})

_, err := rb.Plan("dev", "", "services")
if err == nil {
t.Fatalf("expected guard error for a deployable-scoped first-env rollback, got nil")
}
if !strings.Contains(err.Error(), "first environment") {
t.Errorf("error = %q, want it to name the first environment", err.Error())
}
}

func TestPlan_PromotedEnvironment_NotGuarded(t *testing.T) {
dir := t.TempDir()
path := writeManifest(t, dir, "prodsha9999999", "v1.9.0")
rb := newRollbacker(t, path, fakeHistory{})

// prod is a promoted (non-first) env: the guard must not fire and the
// no-target default path must still resolve through the normal sources.
if _, err := rb.Plan("prod", "v1.9.0", ""); err != nil {
t.Fatalf("Plan on a promoted env should not be guarded: %v", err)
}
}

func TestApply_FirstEnvironment_Guarded(t *testing.T) {
dir := t.TempDir()
path := writeManifest(t, dir, "prodsha9999999", "v1.9.0")
rb := newRollbacker(t, path, fakeHistory{})

// A plan constructed directly for the first env (bypassing Plan) must still be
// refused by Apply: the guard is defense in depth on the only mutating path.
plan := &Plan{
Environment: "dev",
Target: Target{SHA: "devsha1234567", Version: "v2.0.0-rc.1", Source: "state"},
}
err := rb.Apply(plan)
if err == nil {
t.Fatalf("expected Apply to refuse a first-environment rollback, got nil")
}
if !strings.Contains(err.Error(), "first environment") {
t.Errorf("error = %q, want it to name the first environment", err.Error())
}
}

// Guards must be inert when there is no parsed config to identify the first
// environment, so a state-only manifest still resolves through the normal path.
func TestFirstEnvErr_NoConfig_Inert(t *testing.T) {
rb := &Rollbacker{cicdFile: &config.CICDFile{}}
if err := rb.firstEnvErr("dev"); err != nil {
t.Errorf("firstEnvErr with no config should be nil, got %v", err)
}
}
Loading
Loading