From 343282edc6480ef2454914ab7eb9544fb020c674 Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Tue, 30 Jun 2026 09:32:21 -0400 Subject: [PATCH] fix(rollback): guard the first environment against rollback The first environment tracks trunk and is never promoted into, so its deploy-history ring is structurally always empty. A no-target rollback there resolved its target from an empty or stale ring, producing a silent wrong target. Refuse a rollback aimed at the first environment in the preflight and apply paths with an actionable error that points at the trunk revert path. Exclude the first environment from the generated rollback workflow's choice dropdown and gate emission on at least two environments, mirroring the hotfix generator, so the manual path never offers an environment the guard refuses. Add unit coverage for the guard on the preflight, apply, no-target, and deployable-scoped paths, and an e2e scenario proving a first-environment rollback fails fast while a promoted environment still rolls back. Signed-off-by: Joshua Temple --- docs/src/content/docs/workflows.md | 4 +- e2e/harness/multistep.go | 6 +- e2e/harness/rollback_actions.go | 8 ++ .../rollback/rollback-first-env-guard.yaml | 123 ++++++++++++++++++ internal/generate/rollback.go | 24 +++- internal/generate/rollback_test.go | 31 ++++- internal/rollback/first_env_guard_test.go | 105 +++++++++++++++ internal/rollback/rollback.go | 25 ++++ 8 files changed, 316 insertions(+), 10 deletions(-) create mode 100644 e2e/scenarios/rollback/rollback-first-env-guard.yaml create mode 100644 internal/rollback/first_env_guard_test.go diff --git a/docs/src/content/docs/workflows.md b/docs/src/content/docs/workflows.md index 15b7c085..862aa5bb 100644 --- a/docs/src/content/docs/workflows.md +++ b/docs/src/content/docs/workflows.md @@ -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`). diff --git a/e2e/harness/multistep.go b/e2e/harness/multistep.go index 2afacd0c..a6eb92a8 100644 --- a/e2e/harness/multistep.go +++ b/e2e/harness/multistep.go @@ -212,7 +212,10 @@ 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"` @@ -220,6 +223,7 @@ type RollbackStep struct { 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 diff --git a/e2e/harness/rollback_actions.go b/e2e/harness/rollback_actions.go index 5f51c34a..6e17b978 100644 --- a/e2e/harness/rollback_actions.go +++ b/e2e/harness/rollback_actions.go @@ -3,6 +3,7 @@ package harness import ( "context" "fmt" + "strings" ) // rollbackWorkflowPath is the generated rollback workflow's path inside the repo. @@ -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 } diff --git a/e2e/scenarios/rollback/rollback-first-env-guard.yaml b/e2e/scenarios/rollback/rollback-first-env-guard.yaml new file mode 100644 index 00000000..c57daa79 --- /dev/null +++ b/e2e/scenarios/rollback/rollback-first-env-guard.yaml @@ -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 diff --git a/internal/generate/rollback.go b/internal/generate/rollback.go index 095f6130..06a46d77 100644 --- a/internal/generate/rollback.go +++ b/internal/generate/rollback.go @@ -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 @@ -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) } diff --git a/internal/generate/rollback_test.go b/internal/generate/rollback_test.go index 659137e5..f7c02842 100644 --- a/internal/generate/rollback_test.go +++ b/internal/generate/rollback_test.go @@ -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()) } @@ -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() diff --git a/internal/rollback/first_env_guard_test.go b/internal/rollback/first_env_guard_test.go new file mode 100644 index 00000000..03935727 --- /dev/null +++ b/internal/rollback/first_env_guard_test.go @@ -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) + } +} diff --git a/internal/rollback/rollback.go b/internal/rollback/rollback.go index ebfc2d96..d0436360 100644 --- a/internal/rollback/rollback.go +++ b/internal/rollback/rollback.go @@ -173,6 +173,10 @@ func (r *Rollbacker) Plan(env, to, deployable string) (*Plan, error) { return nil, fmt.Errorf("unknown environment %q (not declared in config.environments and has no recorded state)", env) } + if err := r.firstEnvErr(env); err != nil { + return nil, err + } + current := r.cicdFile.State[env] plan := &Plan{ Environment: env, @@ -390,6 +394,9 @@ func (r *Rollbacker) Apply(plan *Plan) error { if plan == nil { return fmt.Errorf("nil plan") } + if err := r.firstEnvErr(plan.Environment); err != nil { + return err + } if plan.NoOp { return nil } @@ -484,6 +491,24 @@ func (r *Rollbacker) writeConfig() error { return nil } +// firstEnvErr returns a guard error when env is the first (build target) +// environment and nil otherwise. The first environment tracks trunk and is +// never promoted into, so its deploy-history ring is structurally always empty: +// a rollback there has no recorded prior target and would resolve a silent wrong +// one from an empty or stale ring. The trunk-native undo for the first +// environment is a revert merge to the trunk branch, not a rollback. The guard +// is inert when no parsed config is available to identify the first environment, +// so a state-only manifest still resolves through the normal path. +func (r *Rollbacker) firstEnvErr(env string) error { + if r.cicdFile == nil || r.cicdFile.Config == nil { + return nil + } + if r.cicdFile.Config.IsFirstEnvironment(env) { + return fmt.Errorf("environment %q is the first environment; it tracks trunk and is never promoted into, so it has no rollback history. Revert it with a merge to the trunk branch instead of a rollback", env) + } + return nil +} + // knownEnvironment reports whether env is declared in config.environments or // has recorded state. func (r *Rollbacker) knownEnvironment(env string) bool {