From 35805249769ec081d1a7776859de0b788f08c48f Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Sun, 14 Jun 2026 09:13:56 -0400 Subject: [PATCH 1/6] feat: manual rollback that deploys a prior version and stays divergence-safe Resolve rollback targets ring-first (live state, then the deploy-history ring newest-first, then git history) and default --to to the previous version (N-1) when omitted. Mark a rolled-back environment diverged with a rollback/ ref so upward promotion from it is guarded, and skip the integration-branch and tag cleanup on rejoin for rollback refs since no such branch exists. Generate a cascade-rollback workflow (preflight resolves the target, deploy jobs redeploy the resolved SHA, finalize writes state) when the manifest declares at least one environment. Finalize gates the state write on deploy results so a failed deploy leaves the environment unchanged. Signed-off-by: Joshua Temple --- .github/manifest.yaml | 6 +- e2e/harness/multistep.go | 23 +- e2e/harness/rollback_actions.go | 100 +++++ e2e/harness/runner.go | 9 + ...rollback-deployable-scoped-leaves-env.yaml | 124 ++++++ .../rollback/rollback-deploys-prior.yaml | 96 +++++ ...ollback-failed-deploy-no-state-change.yaml | 105 +++++ ...ollback-marks-diverged-blocks-promote.yaml | 97 +++++ .../rollforward-rejoins-rolledback.yaml | 129 ++++++ internal/generate/command.go | 20 + internal/generate/command_test.go | 4 + internal/generate/pr_preview_test.go | 4 + internal/generate/rollback.go | 349 +++++++++++++++ internal/generate/rollback_test.go | 161 +++++++ internal/promote/finalize.go | 16 +- internal/promote/rejoin.go | 5 + internal/promote/rollback_ref.go | 19 + internal/promote/rollback_ref_test.go | 23 + internal/promote/rollback_rejoin_test.go | 95 +++++ internal/rollback/command.go | 17 +- internal/rollback/command_subcommands.go | 390 +++++++++++++++++ internal/rollback/command_subcommands_test.go | 398 ++++++++++++++++++ internal/rollback/ring_test.go | 271 ++++++++++++ internal/rollback/rollback.go | 156 ++++++- 24 files changed, 2593 insertions(+), 24 deletions(-) create mode 100644 e2e/harness/rollback_actions.go create mode 100644 e2e/scenarios/rollback/rollback-deployable-scoped-leaves-env.yaml create mode 100644 e2e/scenarios/rollback/rollback-deploys-prior.yaml create mode 100644 e2e/scenarios/rollback/rollback-failed-deploy-no-state-change.yaml create mode 100644 e2e/scenarios/rollback/rollback-marks-diverged-blocks-promote.yaml create mode 100644 e2e/scenarios/rollback/rollforward-rejoins-rolledback.yaml create mode 100644 internal/generate/rollback.go create mode 100644 internal/generate/rollback_test.go create mode 100644 internal/promote/rollback_ref.go create mode 100644 internal/promote/rollback_ref_test.go create mode 100644 internal/promote/rollback_rejoin_test.go create mode 100644 internal/rollback/command_subcommands.go create mode 100644 internal/rollback/command_subcommands_test.go create mode 100644 internal/rollback/ring_test.go diff --git a/.github/manifest.yaml b/.github/manifest.yaml index 1e74dbd6..9115e85b 100644 --- a/.github/manifest.yaml +++ b/.github/manifest.yaml @@ -21,7 +21,7 @@ ci: contributors: true state: prerelease: - sha: 8e7f9308e093256dca4ebfe66ec4e5ceca350bec - version: v0.2.0-rc.51 - committed_at: "2026-06-14T05:12:21Z" + sha: df50416540667ebd8dea4579281c8de4444b3fed + version: v0.2.0-rc.52 + committed_at: "2026-06-14T11:15:08Z" committed_by: joshua-temple diff --git a/e2e/harness/multistep.go b/e2e/harness/multistep.go index 6c46c516..5b2f6fec 100644 --- a/e2e/harness/multistep.go +++ b/e2e/harness/multistep.go @@ -49,7 +49,12 @@ type Step struct { Action string `yaml:"action"` // commit, orchestrate, promote, hotfix_plan, hotfix_apply, merge_pr, resolve_conflict, hotfix_merged Commit *CommitStep `yaml:"commit,omitempty"` Promote *PromoteStep `yaml:"promote,omitempty"` - Expect *StepExpect `yaml:"expect,omitempty"` + // Rollback configures a "rollback" action: a workflow_dispatch run of the + // cascade-rollback workflow that re-points an environment at a prior version + // or SHA, re-runs its deploys at that target, and marks the environment + // diverged until a forward promotion rejoins it. + Rollback *RollbackStep `yaml:"rollback,omitempty"` + Expect *StepExpect `yaml:"expect,omitempty"` // HotfixPlan configures a "hotfix_plan" action: a workflow_dispatch run of the // hotfix workflow's plan job for a trunk commit and target environment. HotfixPlan *HotfixPlanStep `yaml:"hotfix_plan,omitempty"` @@ -152,6 +157,22 @@ type PromoteStep struct { RollbackOnFailure bool `yaml:"rollback_on_failure,omitempty"` } +// RollbackStep defines a rollback action: a workflow_dispatch of the +// cascade-rollback workflow. Environment is the env to roll back. Target is the +// prior version or SHA to roll back to; when empty the workflow defaults to the +// previous version (N-1). Deployable, when set, limits the rollback to a single +// deployable. DryRun sets the dry_run input, which suppresses the deploy and +// finalize jobs. ExpectFailure marks a run that is expected to conclude in +// failure (for example a rollback whose preflight cannot resolve a target), +// mirroring PromoteStep.ExpectFailure. +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"` +} + // StepExpect defines expected outcomes for a step type StepExpect struct { State map[string]*StateExpect `yaml:"state,omitempty"` diff --git a/e2e/harness/rollback_actions.go b/e2e/harness/rollback_actions.go new file mode 100644 index 00000000..a242cd4f --- /dev/null +++ b/e2e/harness/rollback_actions.go @@ -0,0 +1,100 @@ +package harness + +import ( + "context" + "fmt" +) + +// rollbackWorkflowPath is the generated rollback workflow's path inside the repo. +const rollbackWorkflowPath = ".github/workflows/cascade-rollback.yaml" + +// executeRollback dispatches the cascade-rollback workflow for an environment. +// It mirrors executePromote: it builds the workflow_dispatch inputs (omitting +// empty optional values the same way promote does), runs the workflow via +// ActRunner, honors ExpectFailure, and syncs the resulting manifest state from +// Gitea so the step's state/divergence assertions observe the rolled-back env. +// +// The rollback workflow re-points the environment at a prior version or SHA +// (resolved by the preflight job from the live state, the previous-deploy ring, +// or manifest history), re-runs the env's deploy jobs at that target SHA, then +// marks the environment diverged (Ref="rollback/") until a forward +// promotion rejoins it. +func (r *Runner) executeRollback(ctx context.Context, rollback *RollbackStep, config Config) error { + if r.harness == nil || r.harness.act == nil { + r.t.Log(" Would execute rollback workflow (no harness)") + return nil + } + + dryRun := "false" + if rollback.DryRun { + dryRun = "true" + } + r.t.Logf(" Rollback: running workflow (env=%s, target=%s, deployable=%s, dry_run=%s)", + rollback.Environment, rollback.Target, rollback.Deployable, dryRun) + + // Sync the repo to act container before running workflow. + if err := r.harness.SyncRepoToActContainer(ctx); err != nil { + return fmt.Errorf("failed to sync repo: %w", err) + } + + // Build workflow_dispatch inputs. environment is required; target and + // deployable are optional and omitted when empty so the workflow falls back + // to its defaults (target -> previous version, deployable -> all deploys), + // mirroring how executePromote omits empty optional inputs. + inputs := map[string]string{ + "environment": rollback.Environment, + "dry_run": dryRun, + } + if rollback.Target != "" { + // Target may be a commit reference recorded in earlier steps; resolve to + // a literal SHA when possible, falling back to the literal (e.g. a version + // string like "v0.1.0") otherwise. + inputs["target"] = r.resolveCommit(rollback.Target) + } + if rollback.Deployable != "" { + inputs["deployable"] = rollback.Deployable + } + + branch := config.TrunkBranch + if branch == "" { + branch = "main" + } + + result, err := r.harness.act.RunWorkflowFromRepo(ctx, RunOpts{ + WorkflowPath: rollbackWorkflowPath, + Event: "workflow_dispatch", + Inputs: inputs, + Env: map[string]string{ + "GITHUB_REF": fmt.Sprintf("refs/heads/%s", branch), + "GITHUB_REPOSITORY": fmt.Sprintf("%s/%s", AdminUsername, r.harness.repo.Name), + }, + }) + if err != nil { + return fmt.Errorf("failed to run rollback workflow: %w", err) + } + + r.lastWorkflowResult = result + + // Handle expected failures (mirrors executePromote's ExpectFailure path). + if rollback.ExpectFailure { + if result.Conclusion == "failure" { + r.t.Log(" Rollback: workflow failed as expected") + return nil + } + return fmt.Errorf("expected rollback to fail but it succeeded") + } + + if result.Conclusion != "success" { + r.t.Logf(" Rollback workflow logs:\n%s", result.Logs) + return workflowFailureError("rollback", result) + } + + // Sync state from Gitea so divergence/sha/version assertions see the + // rolled-back env (the finalize job wrote Ref="rollback/"). + if err := r.syncStateFromGitea(ctx, config); err != nil { + r.t.Logf(" Warning: failed to sync state from Gitea: %v", err) + } + + r.t.Logf(" Rollback: workflow completed successfully") + return nil +} diff --git a/e2e/harness/runner.go b/e2e/harness/runner.go index d6c391eb..6ad90504 100644 --- a/e2e/harness/runner.go +++ b/e2e/harness/runner.go @@ -114,6 +114,13 @@ func (r *Runner) ValidateScenario(scenario *MultiStepScenario) error { if step.StageDivergence.Env == "" { return fmt.Errorf("step %d (%s): stage_divergence requires env", i, step.Name) } + case "rollback": + if step.Rollback == nil { + return fmt.Errorf("step %d (%s): rollback action requires rollback config", i, step.Name) + } + if step.Rollback.Environment == "" { + return fmt.Errorf("step %d (%s): rollback requires environment", i, step.Name) + } default: return fmt.Errorf("step %d (%s): unknown action %q", i, step.Name, step.Action) } @@ -346,6 +353,8 @@ func (r *Runner) executeStep(ctx context.Context, step *Step, config Config) err return r.executeHotfixMerged(ctx, step.HotfixMerged, config) case "stage_divergence": return r.executeStageDivergence(ctx, step.StageDivergence) + case "rollback": + return r.executeRollback(ctx, step.Rollback, config) default: return fmt.Errorf("unknown action: %s", step.Action) } diff --git a/e2e/scenarios/rollback/rollback-deployable-scoped-leaves-env.yaml b/e2e/scenarios/rollback/rollback-deployable-scoped-leaves-env.yaml new file mode 100644 index 00000000..ade7af9d --- /dev/null +++ b/e2e/scenarios/rollback/rollback-deployable-scoped-leaves-env.yaml @@ -0,0 +1,124 @@ +name: "Deployable-scoped rollback moves one deployable and leaves the env" +description: | + Proves a rollback scoped to a single deployable touches only that deployable's + per-deployable state, leaving the env-level pointer, sibling deployables, and + env divergence untouched. + + prod runs two deployables (api, web) and is advanced through two published + versions, so both deployables record commit2 per-deployable state. The rollback + is dispatched with deployable=api and an explicit target of the first version, + so preflight resolves api's prior target and only deploy-api re-runs. + + finalize runs deployable-scoped (the generated workflow threads the dispatch + deployable input through to the CLI), so it re-applies only api's recorded + per-deployable SHA. It does NOT move env.sha, does NOT mirror onto web, and + does NOT mark prod diverged. The assertions lock that scope: prod's env-level + sha stays at commit2, its divergence ref stays cleared, api lands on commit1, + and web stays on commit2. + + Both deploys are inline run: jobs (no actions/checkout, which act cannot + resolve for a reusable callback against the per-scenario gitea), so each deploy + job runs observably under act; only deploy-api runs for a scoped dispatch. + +config: + trunk_branch: main + environments: [dev, prod] + builds: + - name: app + workflow: build.yaml + triggers: ["src/**"] + deploys: + - name: api + run: | + echo "deployed env=${ENVIRONMENT} sha=${SHA}" + triggers: ["**"] + - name: web + run: | + echo "deployed env=${ENVIRONMENT} sha=${SHA}" + triggers: ["**"] + +steps: + - name: "Commit the first version source" + action: commit + commit: + message: "feat: first version" + files: + src/app.go: | + package main + func main() {} + + - name: "Orchestrate the first commit into dev" + action: orchestrate + expect: + state: + dev: + sha: commit1 + + - name: "Promote the first version to prod (records per-deployable commit1)" + action: promote + promote: + mode: default + expect: + state: + prod: + sha: commit1 + deploys: + api: + sha: commit1 + web: + 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 to prod (advances both deployables)" + action: promote + promote: + mode: default + expect: + state: + prod: + sha: commit2 + deploys: + api: + sha: commit2 + web: + sha: commit2 + + # Rollback scoped to the api deployable, targeting the first version. Only + # deploy-api re-runs. finalize applies api's per-deployable SHA and nothing + # else: env.sha stays commit2, prod is NOT marked diverged (ref stays cleared), + # and the web sibling stays on commit2. + - name: "Rollback only the api deployable to the prior version" + action: rollback + rollback: + environment: prod + deployable: api + target: commit1 + expect: + state: + prod: + sha: commit2 + cleared: [ref] + deploys: + api: + sha: commit1 + web: + sha: commit2 + jobs: + preflight: success + deploy-api: success + finalize: success diff --git a/e2e/scenarios/rollback/rollback-deploys-prior.yaml b/e2e/scenarios/rollback/rollback-deploys-prior.yaml new file mode 100644 index 00000000..21d3a570 --- /dev/null +++ b/e2e/scenarios/rollback/rollback-deploys-prior.yaml @@ -0,0 +1,96 @@ +name: "Rollback re-deploys the prior version" +description: | + Proves a manual rollback DEPLOYS the prior target, not merely rewrites state. + prod is advanced through two published versions, then rolled back to the + previous one. The cascade-rollback workflow's preflight resolves the prior + target from the previous-deploy ring (no explicit target input), the deploy + job re-runs at that target SHA, and prod state lands back on the first commit's + version. + + The deploy is an inline run: job (no actions/checkout, which act cannot resolve + for a reusable callback against the per-scenario gitea), so the deploy job runs + observably under act and is asserted as deploy-app: success. + +config: + trunk_branch: main + environments: [dev, test, prod] + builds: + - name: app + workflow: build.yaml + triggers: ["src/**"] + deploys: + # Inline run: deploy. It echoes the resolved env/sha the rollback workflow + # threads in, so the deploy job runs under act without a checkout step. Its + # caller job id is deploy-app, which the harness's findJob matches directly. + - name: app + run: | + echo "deployed env=${ENVIRONMENT} sha=${SHA}" + triggers: ["**"] + +steps: + - name: "Commit the first version source" + action: commit + commit: + message: "feat: first version" + files: + src/app.go: | + package main + func main() {} + + - name: "Orchestrate the first commit into dev" + action: orchestrate + expect: + state: + dev: + sha: commit1 + + - name: "Promote the first version to prod (establishes the prior target)" + action: promote + promote: + mode: default + 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 to prod (advances past the prior target)" + action: promote + promote: + mode: default + expect: + state: + prod: + sha: commit2 + + # Rollback prod with no explicit target: the preflight resolves N-1 from the + # previous-deploy ring (the first commit). The deploy job re-runs at that SHA, + # and prod state lands back on commit1. The deploy job running is what proves + # this is a deploy, not just a state rewrite. + - name: "Rollback prod to the prior version" + action: rollback + rollback: + environment: prod + expect: + state: + prod: + sha: commit1 + jobs: + preflight: success + deploy-app: success + finalize: success diff --git a/e2e/scenarios/rollback/rollback-failed-deploy-no-state-change.yaml b/e2e/scenarios/rollback/rollback-failed-deploy-no-state-change.yaml new file mode 100644 index 00000000..4666ba57 --- /dev/null +++ b/e2e/scenarios/rollback/rollback-failed-deploy-no-state-change.yaml @@ -0,0 +1,105 @@ +name: "Rollback leaves trunk state unchanged when the deploy fails" +description: | + Proves the rollback gate: when the re-deploy of the prior target fails, the + finalize job must NOT record the environment as rolled back. The cascade- + rollback workflow's finalize job runs with always() so it observes the deploy + result, but the CLI aborts the state write when an in-scope deploy did not + succeed, leaving trunk state exactly where it was. + + prod is advanced through two published versions, then a rollback is requested. + The inline deploy job exits non-zero on purpose, so finalize sees + DEPLOY_RESULT_APP=failure, aborts before writing, and the run concludes in + failure. prod state therefore stays on the second version: no rolled-back SHA, + no rollback ref. This is the safety property that distinguishes a real + re-deploy from a blind state rewrite. + + The deploy is an inline run: job (no actions/checkout, which act cannot resolve + for a reusable callback against the per-scenario gitea), so the deploy job runs + observably under act and is asserted as deploy-app: failure. + +config: + trunk_branch: main + environments: [dev, test, prod] + builds: + - name: app + workflow: build.yaml + triggers: ["src/**"] + deploys: + # Inline run: deploy that always fails. The non-zero exit forces + # deploy-app to fail, so finalize sees DEPLOY_RESULT_APP=failure and aborts + # the state write. Its caller job id is deploy-app, which the harness's + # findJob matches directly. + - name: app + run: | + echo "rollback deploy of env=${ENVIRONMENT} sha=${SHA} failing on purpose" + exit 1 + triggers: ["**"] + +steps: + - name: "Commit the first version source" + action: commit + commit: + message: "feat: first version" + files: + src/app.go: | + package main + func main() {} + + - name: "Orchestrate the first commit into dev" + action: orchestrate + expect: + state: + dev: + sha: commit1 + + - name: "Promote the first version to prod (establishes the prior target)" + action: promote + promote: + mode: default + 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 to prod (advances past the prior target)" + action: promote + promote: + mode: default + expect: + state: + prod: + sha: commit2 + + # Rollback prod with no explicit target: the preflight resolves N-1 from the + # previous-deploy ring (the first commit) and the deploy job re-runs at that + # SHA, but the deploy exits non-zero. finalize observes the failure and aborts + # the state write, so the run concludes in failure and prod state stays on + # commit2 with no rollback ref. + - name: "Rollback prod with a failing deploy leaves state on commit2" + action: rollback + rollback: + environment: prod + expect_failure: true + expect: + state: + prod: + unchanged: true + jobs: + preflight: success + deploy-app: failure diff --git a/e2e/scenarios/rollback/rollback-marks-diverged-blocks-promote.yaml b/e2e/scenarios/rollback/rollback-marks-diverged-blocks-promote.yaml new file mode 100644 index 00000000..48d82aa3 --- /dev/null +++ b/e2e/scenarios/rollback/rollback-marks-diverged-blocks-promote.yaml @@ -0,0 +1,97 @@ +name: "Rollback marks the env diverged and blocks an onward promote" +description: | + A rollback marks the rolled-back environment diverged with a rollback ref, and + that divergence guards an onward promotion sourced from the rolled-back env. + + test is advanced through two versions, then rolled back. The rollback workflow's + finalize step records test's divergence as ref=rollback/test (exact match). A + subsequent dev-to-test cascade is irrelevant; the guarded leg is test-to-prod, + which sources FROM the diverged test env, so its preflight trips the + diverged-source guard and the promote concludes in failure. + +config: + trunk_branch: main + environments: [dev, test, prod] + builds: + - name: app + workflow: build.yaml + triggers: ["src/**"] + deploys: + - name: app + run: | + echo "deployed env=${ENVIRONMENT} sha=${SHA}" + triggers: ["**"] + +steps: + - name: "Commit the first version source" + action: commit + commit: + message: "feat: first version" + files: + src/app.go: | + package main + func main() {} + + - name: "Orchestrate the first commit into dev" + action: orchestrate + expect: + state: + dev: + sha: commit1 + + - name: "Promote the first version into test and prod" + action: promote + promote: + mode: default + expect: + state: + test: + 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 into test (advances past the prior target)" + action: promote + promote: + mode: cascade + target: test + expect: + state: + test: + sha: commit2 + + # Roll test back to the prior version. finalize records test as diverged with + # the rollback ref. The ref is matched exactly against "rollback/test". + - name: "Rollback test to the prior version marks it diverged" + action: rollback + rollback: + environment: test + expect: + state: + test: + sha: commit1 + ref: "rollback/test" + + # An onward promote sourced FROM the rolled-back test env is guarded: the + # test-to-prod leg's preflight sees test diverged and trips the diverged-source + # guard, so the promote concludes in failure. + - name: "Promote from the rolled-back test env is blocked" + action: promote + promote: + mode: cascade + target: prod + expect_failure: true diff --git a/e2e/scenarios/rollback/rollforward-rejoins-rolledback.yaml b/e2e/scenarios/rollback/rollforward-rejoins-rolledback.yaml new file mode 100644 index 00000000..1406228d --- /dev/null +++ b/e2e/scenarios/rollback/rollforward-rejoins-rolledback.yaml @@ -0,0 +1,129 @@ +name: "Roll-forward rejoins a rolled-back env without a cleaner error" +description: | + A forward promotion onto a rolled-back environment ends the divergence. Unlike + a hotfix rejoin, a rollback never creates an env/ integration branch, so + the rejoin path must clear the divergence WITHOUT attempting (and failing) a + branch deletion. + + test is rolled back (finalize records ref=rollback/test), then trunk advances + and a dev-to-test cascade promotes a containing SHA into test. The promote + succeeds, the divergence fields read back empty, and there is no env/test branch + to delete. The targeted dev-to-test cascade isolates the rejoin leg so the + diverged test env is never used as a promote source. + +config: + trunk_branch: main + environments: [dev, test, prod] + builds: + - name: app + workflow: build.yaml + triggers: ["src/**"] + deploys: + - name: app + run: | + echo "deployed env=${ENVIRONMENT} sha=${SHA}" + triggers: ["**"] + +steps: + - name: "Commit the first version source" + action: commit + commit: + message: "feat: first version" + files: + src/app.go: | + package main + func main() {} + + - name: "Orchestrate the first commit into dev" + action: orchestrate + expect: + state: + dev: + sha: commit1 + + - name: "Promote the first version into test" + action: promote + promote: + mode: cascade + target: test + expect: + state: + test: + 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 into test" + action: promote + promote: + mode: cascade + target: test + expect: + state: + test: + sha: commit2 + + # Roll test back to the prior version. finalize records test diverged with the + # rollback ref. No env/test branch is created by a rollback. + - name: "Rollback test to the prior version" + action: rollback + rollback: + environment: test + expect: + state: + test: + sha: commit1 + ref: "rollback/test" + branches: + deleted: ["env/test"] + + - name: "Advance trunk past the rolled-back point" + action: commit + commit: + message: "chore: trunk advance" + files: + src/advance.go: | + package main + func advance() {} + + - name: "Orchestrate so dev carries the advanced trunk" + action: orchestrate + expect: + state: + dev: + sha: commit3 + + # Roll-forward: a dev-to-test cascade promotes a containing SHA into the + # rolled-back test env. The rejoin clears the divergence fields. Because the + # rollback ref carries no env/test branch, the cleaner skips the branch deletion + # and the promote SUCCEEDS (no DeleteEnvBranch error). cleared asserts ref and + # base_sha read back empty; the env/test branch never existed. + - name: "Roll-forward promote into the rolled-back test env" + action: promote + promote: + mode: cascade + target: test + expect: + state: + test: + cleared: [ref, base_sha] + dev: + unchanged: true + prod: + unchanged: true + branches: + deleted: ["env/test"] diff --git a/internal/generate/command.go b/internal/generate/command.go index 3fd79f24..93d4695a 100644 --- a/internal/generate/command.go +++ b/internal/generate/command.go @@ -304,6 +304,26 @@ func runGenerateWorkflow(opts generateOptions) error { } } + // Generate the rollback workflow when at least one environment is configured. + rollbackGen := NewRollbackGenerator(cfg, baseDir) + if rollbackGen.Enabled() { + content, err := rollbackGen.Generate() + if err != nil { + return fmt.Errorf("generating rollback workflow: %w", err) + } + outPath := ".github/workflows/cascade-rollback.yaml" + if opts.dryRun { + fmt.Println("\n=== cascade-rollback.yaml ===") + fmt.Print(content) + } else { + if err := writeWorkflow(outPath, content, opts.force); err != nil { + return err + } + generatedFiles = append(generatedFiles, outPath) + fmt.Printf("Generated workflow: %s\n", outPath) + } + } + // Generate the opt-in read-only PR plan-preview workflow (#40). Absent or // disabled pr_preview emits nothing, so existing manifests are unaffected. if cfg.PRPreview != nil && cfg.PRPreview.Enabled { diff --git a/internal/generate/command_test.go b/internal/generate/command_test.go index 0bb698fe..a9d1cd4a 100644 --- a/internal/generate/command_test.go +++ b/internal/generate/command_test.go @@ -447,6 +447,10 @@ func TestRunGenerateWorkflow_ExtraTriggers(t *testing.T) { outputPath := filepath.Join(tmpDir, "orchestrate.yaml") opts := defaultOpts(configPath, outputPath) + // The rollback workflow lands at a hardcoded repo-relative path shared across + // tests; force overwrite plus cleanup keeps this run order-independent. + opts.force = true + t.Cleanup(func() { _ = os.Remove(filepath.Join(".github/workflows", "cascade-rollback.yaml")) }) require.NoError(t, runGenerateWorkflow(opts)) raw, err := os.ReadFile(outputPath) diff --git a/internal/generate/pr_preview_test.go b/internal/generate/pr_preview_test.go index 3192a2cb..586c3b45 100644 --- a/internal/generate/pr_preview_test.go +++ b/internal/generate/pr_preview_test.go @@ -201,6 +201,8 @@ func TestRunGenerateWorkflow_PRPreview(t *testing.T) { outputPath := filepath.Join(workflowDir, "orchestrate.yaml") opts := defaultOpts(configPath, outputPath) + opts.force = true + t.Cleanup(func() { _ = os.Remove(filepath.Join(".github/workflows", "cascade-rollback.yaml")) }) require.NoError(t, runGenerateWorkflow(opts)) previewPath := filepath.Join(".github/workflows", "cascade-pr-preview.yaml") @@ -229,6 +231,8 @@ func TestRunGenerateWorkflow_NoPRPreviewWhenAbsent(t *testing.T) { outputPath := filepath.Join(workflowDir, "orchestrate.yaml") opts := defaultOpts(configPath, outputPath) + opts.force = true + t.Cleanup(func() { _ = os.Remove(filepath.Join(".github/workflows", "cascade-rollback.yaml")) }) require.NoError(t, runGenerateWorkflow(opts)) previewPath := filepath.Join(".github/workflows", "cascade-pr-preview.yaml") diff --git a/internal/generate/rollback.go b/internal/generate/rollback.go new file mode 100644 index 00000000..6c1b896e --- /dev/null +++ b/internal/generate/rollback.go @@ -0,0 +1,349 @@ +package generate + +import ( + "fmt" + "path/filepath" + "strings" + + "github.com/stablekernel/cascade/internal/config" +) + +// RollbackGenerator emits the cascade-rollback workflow. The workflow re-deploys +// a prior version or SHA to an environment: a read-only preflight job resolves +// the target, one deploy job per configured deployable re-runs the deploy keyed +// on the resolved SHA, and a finalize job applies the state write back to trunk +// (marking the environment diverged until a forward promotion rejoins it). +// +// The deploy stage reuses the same deploy callbacks (reusable workflow, inline +// run, or matrix) the promote workflow drives; there is no separate rollback +// deploy path. The generator is gated on the configured environment count: it +// emits only when at least one environment is declared. +type RollbackGenerator struct { + config *config.TrunkConfig + baseDir string +} + +// NewRollbackGenerator creates a rollback-workflow generator bound to the given +// trunk config and repository base directory. +func NewRollbackGenerator(cfg *config.TrunkConfig, baseDir string) *RollbackGenerator { + return &RollbackGenerator{ + config: cfg, + baseDir: baseDir, + } +} + +// 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. +func (g *RollbackGenerator) Enabled() bool { + return g.config != nil && len(g.config.Environments) >= 1 +} + +// getCLIRef mirrors the ref-resolution used by the other generators so the +// emitted setup-cli ref tracks config.cli_version. "beta" is the explicit opt-in +// escape hatch to the "master" branch; everything else resolves through +// GetCLIVersion (which pins "" / "latest" to the immutable default). +func (g *RollbackGenerator) getCLIRef() string { + if g.config.CLIVersion == "beta" { + return "master" // Explicit opt-in escape hatch to trunk. + } + return g.config.GetCLIVersion() +} + +// getReleaseTokenRef returns the token expression for deploy/release operations. +func (g *RollbackGenerator) getReleaseTokenRef() string { + return g.config.GetReleaseToken() +} + +// getStateTokenRef returns the token expression used to write manifest state to +// the trunk branch. +func (g *RollbackGenerator) getStateTokenRef() string { + return g.config.GetStateToken() +} + +// getManifestFilePath returns the repo-relative manifest path for use in the +// generated workflow, matching the other generators' resolution. +func (g *RollbackGenerator) getManifestFilePath() string { + manifestPath := g.config.GetManifestFile() + if !filepath.IsAbs(manifestPath) { + return manifestPath + } + if g.baseDir != "" { + if rel, err := filepath.Rel(g.baseDir, manifestPath); err == nil { + return rel + } + } + return ".github/manifest.yaml" +} + +// Generate renders the cascade-rollback workflow. +func (g *RollbackGenerator) Generate() (string, error) { + var sb strings.Builder + + g.writeHeader(&sb) + g.writeTriggers(&sb) + g.writeConcurrency(&sb) + g.writeJobs(&sb) + + return sb.String(), nil +} + +func (g *RollbackGenerator) writeHeader(sb *strings.Builder) { + sb.WriteString("# AUTO-GENERATED by cascade - DO NOT EDIT MANUALLY\n") + fmt.Fprintf(sb, "# Regenerate with: cascade generate-workflow --config %s\n", g.getManifestFilePath()) + sb.WriteString("#\n") + sb.WriteString("# Manual rollback: re-deploy a prior version or SHA to an environment.\n") + sb.WriteString("#\n") + sb.WriteString("# A read-only preflight resolves the target (from live state, the\n") + sb.WriteString("# deploy-history ring, or manifest history), the deploy stage re-runs the\n") + sb.WriteString("# configured deploy callbacks keyed on the resolved SHA, and finalize writes\n") + sb.WriteString("# the rolled-back state back to trunk, marking the environment diverged until\n") + sb.WriteString("# a forward promotion rejoins it.\n") + sb.WriteString("\n") +} + +func (g *RollbackGenerator) writeTriggers(sb *strings.Builder) { + sb.WriteString("name: Rollback\n\n") + sb.WriteString("on:\n") + 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. + 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 { + fmt.Fprintf(sb, " - %s\n", env) + } + + sb.WriteString(" target:\n") + sb.WriteString(" description: 'Prior version or SHA (optional; defaults to the previous version)'\n") + sb.WriteString(" required: false\n") + sb.WriteString(" type: string\n") + sb.WriteString(" default: ''\n") + + sb.WriteString(" deployable:\n") + sb.WriteString(" description: 'Limit rollback to one deployable (optional)'\n") + sb.WriteString(" required: false\n") + sb.WriteString(" type: string\n") + sb.WriteString(" default: ''\n") + + sb.WriteString(" dry_run:\n") + sb.WriteString(" description: 'Resolve and print without deploying'\n") + sb.WriteString(" required: false\n") + sb.WriteString(" type: boolean\n") + sb.WriteString(" default: false\n") + sb.WriteString("\n") + + // contents:write to commit the rolled-back state; actions:write for parity + // with the promote workflow's release/dispatch surface. + sb.WriteString("permissions:\n") + sb.WriteString(" contents: write\n") + sb.WriteString(" actions: write\n") + sb.WriteString("\n") +} + +// writeConcurrency serializes rollback runs so concurrent state writes cannot +// interleave. The default group keys on the workflow; an explicit config group +// overrides it, mirroring the promote generator. +func (g *RollbackGenerator) writeConcurrency(sb *strings.Builder) { + sb.WriteString("concurrency:\n") + if g.config.Concurrency != nil && g.config.Concurrency.Group != "" { + fmt.Fprintf(sb, " group: %s\n", g.config.Concurrency.Group) + } else { + sb.WriteString(" group: \"${{ github.workflow }}\"\n") + } + if g.config.Concurrency != nil { + fmt.Fprintf(sb, " cancel-in-progress: %t\n", g.config.Concurrency.CancelInProgress) + } else { + sb.WriteString(" cancel-in-progress: false\n") + } + sb.WriteString("\n") +} + +func (g *RollbackGenerator) writeJobs(sb *strings.Builder) { + sb.WriteString("jobs:\n") + g.writePreflightJob(sb) + g.writeDeployJobs(sb) + g.writeFinalizeJob(sb) +} + +// writeSetupCLI emits the checkout + setup-cli steps shared by the rollback jobs. +func (g *RollbackGenerator) writeSetupCLI(sb *strings.Builder) { + writeActionStep(sb, g.config, " ", actionCheckout) + sb.WriteString(" with:\n") + sb.WriteString(" fetch-depth: 0\n") + sb.WriteString(" - name: Setup CLI\n") + fmt.Fprintf(sb, " uses: stablekernel/cascade/.github/actions/setup-cli@%s\n", g.getCLIRef()) + sb.WriteString(" with:\n") + fmt.Fprintf(sb, " token: %s\n", g.getReleaseTokenRef()) + fmt.Fprintf(sb, " version: %s\n", g.config.GetCLIVersion()) +} + +// writePreflightJob emits the read-only target-resolution job. It exposes the +// resolved environment, SHA, version, and can_proceed gate as outputs for the +// deploy and finalize jobs, and fails fast when resolution reports it cannot +// proceed. +func (g *RollbackGenerator) writePreflightJob(sb *strings.Builder) { + sb.WriteString(" preflight:\n") + sb.WriteString(" name: Pre-flight Check\n") + sb.WriteString(" runs-on: ubuntu-latest\n") + sb.WriteString(" outputs:\n") + sb.WriteString(" target_env: ${{ steps.preflight.outputs.target_env }}\n") + sb.WriteString(" target_sha: ${{ steps.preflight.outputs.target_sha }}\n") + sb.WriteString(" target_version: ${{ steps.preflight.outputs.target_version }}\n") + sb.WriteString(" can_proceed: ${{ steps.preflight.outputs.can_proceed }}\n") + sb.WriteString(" steps:\n") + g.writeSetupCLI(sb) + + sb.WriteString(" - name: Resolve Target\n") + sb.WriteString(" id: preflight\n") + sb.WriteString(" env:\n") + sb.WriteString(" ENVIRONMENT: ${{ github.event.inputs.environment }}\n") + sb.WriteString(" TARGET: ${{ github.event.inputs.target }}\n") + sb.WriteString(" DEPLOYABLE: ${{ github.event.inputs.deployable }}\n") + sb.WriteString(" run: |\n") + sb.WriteString(" cascade rollback preflight \\\n") + fmt.Fprintf(sb, " --config %s \\\n", g.getManifestFilePath()) + sb.WriteString(" --env \"$ENVIRONMENT\" \\\n") + sb.WriteString(" --to \"$TARGET\" \\\n") + sb.WriteString(" --deployable \"$DEPLOYABLE\" \\\n") + sb.WriteString(" --gha-output\n") + + sb.WriteString(" - name: Fail if Cannot Proceed\n") + sb.WriteString(" if: steps.preflight.outputs.can_proceed == 'false'\n") + sb.WriteString(" run: exit 1\n") + sb.WriteString("\n") +} + +// rollbackDeployGuard is the if-condition gating a rollback deploy job: not a +// dry run, and either no deployable filter or a filter naming this deployable. +func rollbackDeployGuard(deployName string) string { + return fmt.Sprintf("${{ github.event.inputs.dry_run != 'true' && (github.event.inputs.deployable == '' || github.event.inputs.deployable == '%s') }}", deployName) +} + +// writeDeployJobs emits one deploy job per configured deploy, re-running the same +// callback the promote workflow uses but sourced from the resolved rollback +// target SHA. Inline run: deploys carry the job-level environment gate when GHA +// environment protection is configured; reusable (uses:) deploys thread the env +// via the with: input. With no environments configured, no deploy jobs emit. +func (g *RollbackGenerator) writeDeployJobs(sb *strings.Builder) { + if len(g.config.Environments) == 0 { + return + } + + for _, d := range g.config.Deploys { + fmt.Fprintf(sb, " deploy-%s:\n", d.Name) + fmt.Fprintf(sb, " name: Deploy %s\n", d.Name) + sb.WriteString(" needs: [preflight]\n") + fmt.Fprintf(sb, " if: %s\n", rollbackDeployGuard(d.Name)) + + if d.Run != "" { + // Inline run: deploy callback. The environment gate is valid only on a + // steps job, so it is emitted here (not on a reusable caller job). + if anyEnvHasGHAConfig(g.config) { + sb.WriteString(" environment: ${{ needs.preflight.outputs.target_env }}\n") + } + g.writeInlineDeployBody(sb, d) + continue + } + + // Reusable (uses:) deploy: thread the resolved env and SHA via with:. The + // environment name is carried as an input; GitHub Environment protection + // must be declared inside the reusable workflow's own job. + fmt.Fprintf(sb, " uses: %s\n", normalizeWorkflowPath(d.Workflow)) + sb.WriteString(" with:\n") + sb.WriteString(" environment: ${{ needs.preflight.outputs.target_env }}\n") + sb.WriteString(" sha: ${{ needs.preflight.outputs.target_sha }}\n") + writeSecretsBlock(sb, d.Secrets) + } +} + +// writeInlineDeployBody emits the runs-on / steps body of an inline run: deploy +// callback, surfacing the resolved environment and SHA as env: variables. +func (g *RollbackGenerator) writeInlineDeployBody(sb *strings.Builder, d config.DeployConfig) { + writeRunsOn(sb, " ", d.RunsOn, g.config.RunsOn) + writeJobPermissions(sb, " ", d.Permissions) + writeJobConcurrency(sb, " ", d.Concurrency) + sb.WriteString(" steps:\n") + fmt.Fprintf(sb, " - name: Deploy %s\n", d.Name) + sb.WriteString(" env:\n") + sb.WriteString(" ENVIRONMENT: ${{ needs.preflight.outputs.target_env }}\n") + sb.WriteString(" SHA: ${{ needs.preflight.outputs.target_sha }}\n") + + shell := d.Shell + if shell == "" { + shell = "bash" + } + fmt.Fprintf(sb, " shell: %s\n", shell) + sb.WriteString(" run: |\n") + for _, line := range strings.Split(strings.TrimRight(d.Run, "\n"), "\n") { + fmt.Fprintf(sb, " %s\n", line) + } + sb.WriteString("\n") +} + +// deployJobNames returns the deploy job identifiers so finalize can declare +// correct needs: references. +func (g *RollbackGenerator) deployJobNames() []string { + names := make([]string, 0, len(g.config.Deploys)) + for _, d := range g.config.Deploys { + names = append(names, "deploy-"+d.Name) + } + return names +} + +// writeFinalizeJob emits the state-write job. It runs after preflight succeeds +// (and after every deploy job), skipping on a dry run, and re-resolves the target +// deterministically via the passed-through SHA before applying and committing. +// +// The job condition uses always() so finalize still runs when a deploy job +// fails or is skipped: finalize must observe every deploy result to decide +// whether the state write is safe. Each deploy's conclusion is threaded in as a +// DEPLOY_RESULT_ env var, and the CLI aborts the state write (leaving +// trunk unchanged) when an in-scope deploy did not succeed. always() guarantees +// finalize reaches that gate rather than being skipped by a failed dependency. +func (g *RollbackGenerator) writeFinalizeJob(sb *strings.Builder) { + needsList := append([]string{"preflight"}, g.deployJobNames()...) + needsStr := "[" + strings.Join(needsList, ", ") + "]" + + sb.WriteString(" finalize:\n") + sb.WriteString(" name: Finalize\n") + fmt.Fprintf(sb, " needs: %s\n", needsStr) + sb.WriteString(" if: always() && needs.preflight.result == 'success' && github.event.inputs.dry_run != 'true'\n") + sb.WriteString(" runs-on: ubuntu-latest\n") + sb.WriteString(" steps:\n") + g.writeSetupCLI(sb) + + sb.WriteString(" - name: Finalize Rollback\n") + sb.WriteString(" env:\n") + fmt.Fprintf(sb, " GH_TOKEN: %s\n", g.getStateTokenRef()) + fmt.Fprintf(sb, " GITHUB_TOKEN: %s\n", g.getReleaseTokenRef()) + sb.WriteString(" GITHUB_REPOSITORY: ${{ github.repository }}\n") + // Thread the dispatch deployable scope through so finalize applies and gates + // at the same scope the deploy jobs ran at. An empty value is env-scope, the + // same default the CLI flag carries, so a full-env rollback still mirrors and + // marks the env diverged while a deployable-scoped rollback touches only that + // deployable. Without this, a deployable-scoped dispatch would resolve and + // deploy one deployable but finalize the whole environment. + sb.WriteString(" DEPLOYABLE: ${{ github.event.inputs.deployable }}\n") + // Thread each deploy job's conclusion in as DEPLOY_RESULT_ so the CLI + // can gate the state write on actual deploy success. Deploy jobs only exist + // when at least one environment is configured. + if len(g.config.Environments) > 0 { + for _, d := range g.config.Deploys { + envKey := "DEPLOY_RESULT_" + strings.ToUpper(strings.ReplaceAll(d.Name, "-", "_")) + fmt.Fprintf(sb, " %s: ${{ needs.deploy-%s.result }}\n", envKey, d.Name) + } + } + sb.WriteString(" run: |\n") + sb.WriteString(" cascade rollback finalize \\\n") + fmt.Fprintf(sb, " --config %s \\\n", g.getManifestFilePath()) + sb.WriteString(" --env \"${{ needs.preflight.outputs.target_env }}\" \\\n") + sb.WriteString(" --to \"${{ needs.preflight.outputs.target_sha }}\" \\\n") + sb.WriteString(" --deployable \"$DEPLOYABLE\" \\\n") + sb.WriteString(" --commit-push\n") +} diff --git a/internal/generate/rollback_test.go b/internal/generate/rollback_test.go new file mode 100644 index 00000000..28b03415 --- /dev/null +++ b/internal/generate/rollback_test.go @@ -0,0 +1,161 @@ +package generate + +import ( + "strings" + "testing" + + "github.com/stablekernel/cascade/internal/config" + "github.com/stretchr/testify/assert" +) + +// rollbackTestConfig builds a multi-env config with a single deploy that has a +// reusable deploy workflow, for asserting on the generated rollback workflow. +func rollbackTestConfig() *config.TrunkConfig { + return &config.TrunkConfig{ + TrunkBranch: "main", + Environments: []string{"dev", "prod"}, + Deploys: []config.DeployConfig{ + { + Name: "services", + Workflow: ".github/workflows/deploy.yaml", + }, + }, + } +} + +func TestRollbackGenerator_Enabled_TrueWithOneEnv(t *testing.T) { + cfg := &config.TrunkConfig{Environments: []string{"prod"}} + g := NewRollbackGenerator(cfg, "") + assert.True(t, g.Enabled()) +} + +func TestRollbackGenerator_Enabled_FalseWithZeroEnv(t *testing.T) { + cfg := &config.TrunkConfig{} + g := NewRollbackGenerator(cfg, "") + assert.False(t, g.Enabled()) +} + +func TestRollbackGenerator_DispatchInputs(t *testing.T) { + g := NewRollbackGenerator(rollbackTestConfig(), "") + content, err := g.Generate() + assert.NoError(t, err) + + assert.Contains(t, content, "name: Rollback") + assert.Contains(t, content, "workflow_dispatch:") + assert.Contains(t, content, " environment:") + assert.Contains(t, content, " target:") + assert.Contains(t, content, " deployable:") + assert.Contains(t, content, " dry_run:") + assert.Contains(t, content, "permissions:") + assert.Contains(t, content, " contents: write") +} + +func TestRollbackGenerator_PreflightResolves(t *testing.T) { + g := NewRollbackGenerator(rollbackTestConfig(), "") + content, err := g.Generate() + assert.NoError(t, err) + + assert.Contains(t, content, "cascade rollback preflight") + assert.Contains(t, content, "--gha-output") + assert.Contains(t, content, "target_sha: ${{ steps.preflight.outputs.target_sha }}") + assert.Contains(t, content, "target_env: ${{ steps.preflight.outputs.target_env }}") + assert.Contains(t, content, "can_proceed: ${{ steps.preflight.outputs.can_proceed }}") +} + +func TestRollbackGenerator_DeployJobsKeyedOnTargetSha(t *testing.T) { + g := NewRollbackGenerator(rollbackTestConfig(), "") + content, err := g.Generate() + assert.NoError(t, err) + + assert.Contains(t, content, " deploy-services:") + assert.Contains(t, content, "needs: [preflight]") + assert.Contains(t, content, "needs.preflight.outputs.target_sha") +} + +func TestRollbackGenerator_FinalizeNeedsWiring(t *testing.T) { + g := NewRollbackGenerator(rollbackTestConfig(), "") + content, err := g.Generate() + assert.NoError(t, err) + + assert.Contains(t, content, " finalize:") + assert.Contains(t, content, "cascade rollback finalize") + assert.Contains(t, content, "--commit-push") + + // finalize must need both preflight and the deploy job. + finalizeNeeds := finalizeNeedsLine(t, content) + assert.Contains(t, finalizeNeeds, "preflight") + assert.Contains(t, finalizeNeeds, "deploy-services") +} + +func TestRollbackGenerator_FinalizeThreadsDeployResults(t *testing.T) { + cfg := &config.TrunkConfig{ + TrunkBranch: "main", + Environments: []string{"dev", "prod"}, + Deploys: []config.DeployConfig{ + {Name: "services", Workflow: ".github/workflows/deploy.yaml"}, + {Name: "web-api", Workflow: ".github/workflows/deploy-web-api.yaml"}, + }, + } + g := NewRollbackGenerator(cfg, "") + content, err := g.Generate() + assert.NoError(t, err) + + // Each deploy job's result must be threaded into finalize as a + // DEPLOY_RESULT_ env var so the CLI can gate the state write. + assert.Contains(t, content, "DEPLOY_RESULT_SERVICES: ${{ needs.deploy-services.result }}") + assert.Contains(t, content, "DEPLOY_RESULT_WEB_API: ${{ needs.deploy-web-api.result }}") +} + +func TestRollbackGenerator_FinalizeThreadsDeployableScope(t *testing.T) { + g := NewRollbackGenerator(rollbackTestConfig(), "") + content, err := g.Generate() + assert.NoError(t, err) + + // The dispatch deployable input must reach finalize so the state write is + // scoped the same way the deploy jobs were. Without this, a deployable-scoped + // rollback would deploy one deployable but finalize the whole environment, + // marking it diverged and mirroring onto siblings that never redeployed. + assert.Contains(t, content, "DEPLOYABLE: ${{ github.event.inputs.deployable }}") + assert.Contains(t, content, "--deployable \"$DEPLOYABLE\"") + + // The deployable flag must sit on the finalize invocation, not only preflight. + finalizeIdx := strings.Index(content, " finalize:") + assert.Greater(t, finalizeIdx, -1) + assert.Contains(t, content[finalizeIdx:], "--deployable \"$DEPLOYABLE\"") +} + +func TestRollbackGenerator_PreflightBeforeDeployBeforeFinalize(t *testing.T) { + g := NewRollbackGenerator(rollbackTestConfig(), "") + content, err := g.Generate() + assert.NoError(t, err) + + preflightIdx := strings.Index(content, " preflight:") + deployIdx := strings.Index(content, " deploy-services:") + finalizeIdx := strings.Index(content, " finalize:") + + assert.Greater(t, preflightIdx, -1) + assert.Greater(t, deployIdx, preflightIdx) + assert.Greater(t, finalizeIdx, deployIdx) +} + +// finalizeNeedsLine returns the needs: line of the finalize job. +func finalizeNeedsLine(t *testing.T, content string) string { + t.Helper() + lines := strings.Split(content, "\n") + for i, line := range lines { + if line == " finalize:" { + for j := i + 1; j < len(lines); j++ { + trimmed := strings.TrimSpace(lines[j]) + if strings.HasPrefix(trimmed, "needs:") { + return trimmed + } + // Stop if we leave the job before finding needs. + if len(lines[j]) > 0 && lines[j][0] != ' ' { + break + } + } + } + } + t.Fatalf("finalize job needs: line not found") + return "" +} diff --git a/internal/promote/finalize.go b/internal/promote/finalize.go index 04daecf7..f4d44742 100644 --- a/internal/promote/finalize.go +++ b/internal/promote/finalize.go @@ -129,6 +129,12 @@ func (f *Finalizer) Run() error { // this is a no-op and the injected cleaner is never called. func (f *Finalizer) runLifecycleCleanup() error { for _, ev := range f.pendingRejoins { + if ev.rollbackOrigin { + // A manual rollback creates no integration branch, hotfix tags, or + // release drafts. The divergence fields were already cleared above, + // so the rejoin is complete with no side effects to undo. + continue + } if err := f.cleaner.DeleteEnvBranch(ev.env); err != nil { return fmt.Errorf("rejoin cleanup for %s: %w", ev.env, err) } @@ -191,12 +197,18 @@ func (f *Finalizer) updateState() { // on the env having been diverged, so a normal promotion into a // non-diverged env touches none of the lifecycle logic. if wasDiverged { + // Capture the divergence origin before clearing the ref: a + // rollback-origin rejoin clears the same fields but skips the + // integration-branch and hotfix-release cleanup, which apply + // only to hotfix divergences. + rollbackOrigin := IsRollbackRef(state.Ref) state.Ref = "" state.BaseSHA = "" state.Patches = nil f.pendingRejoins = append(f.pendingRejoins, rejoinEvent{ - env: promo.Environment, - baseVersion: priorVersion, + env: promo.Environment, + baseVersion: priorVersion, + rollbackOrigin: rollbackOrigin, }) } diff --git a/internal/promote/rejoin.go b/internal/promote/rejoin.go index 34c3086d..81818b98 100644 --- a/internal/promote/rejoin.go +++ b/internal/promote/rejoin.go @@ -63,6 +63,11 @@ func WithLifecycleCleaner(c LifecycleCleaner) FinalizeOption { type rejoinEvent struct { env string baseVersion string + // rollbackOrigin is true when the env diverged via a manual rollback rather + // than a hotfix integration branch. The rejoin cleanup skips integration + // branch and hotfix release deletion in that case, since a rollback creates + // no such objects; the divergence fields are still cleared unconditionally. + rollbackOrigin bool } // gitReleaseCleaner is the production LifecycleCleaner. It deletes the remote diff --git a/internal/promote/rollback_ref.go b/internal/promote/rollback_ref.go new file mode 100644 index 00000000..723af94f --- /dev/null +++ b/internal/promote/rollback_ref.go @@ -0,0 +1,19 @@ +package promote + +import "strings" + +// RollbackRefPrefix marks an environment whose state was set by a manual +// rollback rather than a hotfix integration branch. A rollback points an +// environment back at a prior known-good SHA and records the prior pointer in +// state..ref using this prefix, so the divergence guards treat the env as +// off-trunk (blocking forward promotion until it rejoins) without implying a +// hotfix integration branch, tags, or release drafts exist. +const RollbackRefPrefix = "rollback/" + +// IsRollbackRef reports whether ref is a rollback-divergence ref (set by a +// manual rollback) rather than a hotfix integration ref. The rejoin cleanup +// uses this to skip integration-branch and hotfix-release deletion for an env +// that diverged via rollback, since no such objects were created. +func IsRollbackRef(ref string) bool { + return strings.HasPrefix(ref, RollbackRefPrefix) +} diff --git a/internal/promote/rollback_ref_test.go b/internal/promote/rollback_ref_test.go new file mode 100644 index 00000000..0bafc03d --- /dev/null +++ b/internal/promote/rollback_ref_test.go @@ -0,0 +1,23 @@ +package promote + +import "testing" + +func TestIsRollbackRef(t *testing.T) { + cases := []struct { + name string + ref string + want bool + }{ + {name: "RollbackPrefixTrue", ref: RollbackRefPrefix + "prod", want: true}, + {name: "HotfixPrefixFalse", ref: "hotfix/login-fix", want: false}, + {name: "EmptyFalse", ref: "", want: false}, + {name: "PlainEnvBranchFalse", ref: "env/prod", want: false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := IsRollbackRef(c.ref); got != c.want { + t.Errorf("IsRollbackRef(%q) = %v, want %v", c.ref, got, c.want) + } + }) + } +} diff --git a/internal/promote/rollback_rejoin_test.go b/internal/promote/rollback_rejoin_test.go new file mode 100644 index 00000000..62f06138 --- /dev/null +++ b/internal/promote/rollback_rejoin_test.go @@ -0,0 +1,95 @@ +package promote + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stablekernel/cascade/internal/config" + "github.com/stretchr/testify/require" +) + +// rollbackDivergedManifest writes a manifest where "prod" is diverged via a +// manual rollback (ref carries the RollbackRefPrefix and no patches), so a +// forward promotion into it rejoins trunk without any integration branch or +// hotfix release objects to clean up. +func rollbackDivergedManifest(t *testing.T) string { + t.Helper() + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "manifest.yaml") + initialConfig := `ci: + config: + environments: [dev, prod] + state: + dev: + sha: trunkhead + version: v2.0.0 + prod: + sha: oldgoodsha + version: v1.9.0 + ref: rollback/prod + base_sha: priorprodsha +` + require.NoError(t, os.WriteFile(configPath, []byte(initialConfig), 0644)) + return configPath +} + +func TestFinalize_RollbackRejoin_SkipsBranchDeletion(t *testing.T) { + configPath := rollbackDivergedManifest(t) + cleaner := &recordingCleaner{} + + fin, err := NewFinalizer(configPath, "prod", WithLifecycleCleaner(cleaner)) + require.NoError(t, err) + + // Forward promotion into the rollback-diverged "prod" env. + fin.SetPromotionResult(&PromotionResult{ + Promotions: []EnvPromotion{{ + Environment: "prod", + SourceEnv: "dev", + SHA: "trunkhead", + Version: "v2.0.0", + }}, + }) + + require.NoError(t, fin.Run()) + + // A rollback-origin rejoin must not touch any integration branch or + // hotfix release objects (none exist for a manual rollback). + require.Empty(t, cleaner.deletedBranches, "rollback rejoin must delete no env branch") + require.Empty(t, cleaner.cleanedReleases, "rollback rejoin must clean no hotfix releases") + + cicd, err := config.ParseManifestFile(configPath, config.DefaultManifestKey) + require.NoError(t, err) + st := cicd.State["prod"] + require.NotNil(t, st) + require.Empty(t, st.Ref, "ref must be cleared on rejoin") + require.Empty(t, st.BaseSHA, "base_sha must be cleared on rejoin") + require.Empty(t, st.Patches, "patches must be cleared on rejoin") + require.False(t, st.IsDiverged(), "env must no longer be diverged") +} + +func TestFinalize_HotfixRejoin_DeletesBranch(t *testing.T) { + configPath := divergedManifest(t) + cleaner := &recordingCleaner{} + + fin, err := NewFinalizer(configPath, "test", WithLifecycleCleaner(cleaner)) + require.NoError(t, err) + + fin.SetPromotionResult(&PromotionResult{ + Promotions: []EnvPromotion{{ + Environment: "test", + SourceEnv: "dev", + SHA: "trunkhead", + Version: "v1.4.0-rc.3", + }}, + }) + + require.NoError(t, fin.Run()) + + // A hotfix-origin rejoin still deletes the integration branch and cleans + // its hotfix releases exactly as before. + require.Equal(t, []string{"test"}, cleaner.deletedBranches, + "hotfix rejoin must delete the integration branch exactly once") + require.Len(t, cleaner.cleanedReleases, 1, + "hotfix rejoin must clean its hotfix releases") +} diff --git a/internal/rollback/command.go b/internal/rollback/command.go index dd5b3dc5..ec10149a 100644 --- a/internal/rollback/command.go +++ b/internal/rollback/command.go @@ -35,9 +35,14 @@ separate deploy code path. Resolution order for --to : 1. The environment's current recorded state (and per-deployable state). - 2. The manifest's git history, newest first (recovers a deployment the + 2. The environment's deploy-history ring, newest first. + 3. The manifest's git history, newest first (recovers a deployment the manifest has already moved past). +When --to is omitted, rollback resolves the previous version: the newest +deploy-history ring entry that differs from the current state, falling back to +the newest distinct prior state from manifest history. + A SHA may be given in full or as a short (>=7 char) prefix. Use --deployable to scope the rollback to a single deployable's recorded version (requires per-deployable version tracking in state). @@ -67,14 +72,20 @@ Examples: cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to manifest file (default: .github/manifest.yaml)") cmd.Flags().StringVar(&manifestKey, "key", config.DefaultManifestKey, "Top-level manifest key") cmd.Flags().StringVar(&env, "env", "", "Target environment to roll back (required)") - cmd.Flags().StringVar(&to, "to", "", "Prior version or SHA to re-promote (required)") + cmd.Flags().StringVar(&to, "to", "", "Prior version or SHA to re-promote (optional; defaults to the previous version)") cmd.Flags().StringVar(&deployable, "deployable", "", "Scope the rollback to a single deployable") cmd.Flags().StringVar(&actor, "actor", "", "Actor recorded on the rollback (default: $GITHUB_ACTOR)") cmd.Flags().BoolVar(&dryRun, "dry-run", false, "Resolve and print the plan without modifying the manifest") cmd.Flags().BoolVar(&jsonOutput, "json", false, "Output the resolved plan as JSON") _ = cmd.MarkFlagRequired("env") - _ = cmd.MarkFlagRequired("to") + + // The flat root run remains the standalone operator path. The workflow the + // generator emits drives rollback through these two subcommands instead: a + // read-only preflight that resolves the target and a finalize that applies the + // state write and pushes it back to trunk. + cmd.AddCommand(newPreflightCommand()) + cmd.AddCommand(newFinalizeCommand()) return cmd } diff --git a/internal/rollback/command_subcommands.go b/internal/rollback/command_subcommands.go new file mode 100644 index 00000000..29cec3d9 --- /dev/null +++ b/internal/rollback/command_subcommands.go @@ -0,0 +1,390 @@ +package rollback + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "os" + "os/exec" + "strings" + + "github.com/spf13/cobra" + + "github.com/stablekernel/cascade/internal/config" + "github.com/stablekernel/cascade/internal/ghaoutput" +) + +// newPreflightCommand creates the `cascade rollback preflight` subcommand. It +// resolves the rollback target read-only and, with --gha-output, writes the +// resolved environment, SHA, and version to $GITHUB_OUTPUT for the deploy and +// finalize jobs to consume. It never mutates state. +func newPreflightCommand() *cobra.Command { + var ( + configPath string + manifestKey string + env string + to string + deployable string + ghaOutput bool + jsonOutput bool + ) + + cmd := &cobra.Command{ + Use: "preflight", + Short: "Resolve a rollback target without modifying state", + Long: `Resolve the rollback target for an environment and report it. + +This is the read-only first stage of the generated rollback workflow. It +resolves the target SHA/version (from live state, the deploy-history ring, or +manifest history) and, with --gha-output, writes target_env, target_sha, +target_version, and can_proceed to $GITHUB_OUTPUT. It writes no manifest state.`, + RunE: func(cmd *cobra.Command, args []string) error { + return runPreflight(preflightOptions{ + configPath: configPath, + manifestKey: manifestKey, + env: env, + to: to, + deployable: deployable, + ghaOutput: ghaOutput, + jsonOutput: jsonOutput, + }) + }, + } + + cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to manifest file (default: .github/manifest.yaml)") + cmd.Flags().StringVar(&manifestKey, "key", config.DefaultManifestKey, "Top-level manifest key") + cmd.Flags().StringVar(&env, "env", "", "Target environment to roll back (required)") + cmd.Flags().StringVar(&to, "to", "", "Prior version or SHA to re-promote (optional; defaults to the previous version)") + cmd.Flags().StringVar(&deployable, "deployable", "", "Scope the rollback to a single deployable") + cmd.Flags().BoolVar(&ghaOutput, "gha-output", false, "Write resolved target to $GITHUB_OUTPUT") + cmd.Flags().BoolVar(&jsonOutput, "json", false, "Print the resolved plan as JSON") + + _ = cmd.MarkFlagRequired("env") + + return cmd +} + +type preflightOptions struct { + configPath string + manifestKey string + env string + to string + deployable string + ghaOutput bool + jsonOutput bool +} + +func runPreflight(opts preflightOptions) error { + rb, err := New(Options{ + ConfigPath: opts.configPath, + ManifestKey: opts.manifestKey, + }) + if err != nil { + if opts.ghaOutput { + writeCannotProceed() + } + return err + } + + plan, err := rb.Plan(opts.env, opts.to, opts.deployable) + if err != nil { + if opts.ghaOutput { + writeCannotProceed() + } + return err + } + + if opts.ghaOutput { + w := ghaoutput.New() + w.Set("target_env", plan.Environment) + w.Set("target_sha", plan.Target.SHA) + w.Set("target_version", plan.Target.Version) + w.SetBool("can_proceed", true) + return w.Flush() + } + + if opts.jsonOutput { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(plan) + } + + fmt.Printf("environment %s would roll back to %s (%s) [resolved from %s]\n", + plan.Environment, orDash(plan.Target.Version), truncate(plan.Target.SHA), plan.Target.Source) + return nil +} + +// writeCannotProceed emits can_proceed=false so the workflow's fail step trips +// when resolution fails. A best-effort flush: the returned resolution error is +// the authoritative signal, so a flush failure here is intentionally ignored. +func writeCannotProceed() { + w := ghaoutput.New() + w.SetBool("can_proceed", false) + _ = w.Flush() +} + +// newFinalizeCommand creates the `cascade rollback finalize` subcommand. It +// resolves the target, applies the rollback to state (marking the environment +// diverged), and, with --commit-push, writes the manifest back to the trunk +// branch using the same state-write mechanism as a promotion finalize. +func newFinalizeCommand() *cobra.Command { + var ( + configPath string + manifestKey string + env string + to string + deployable string + actor string + commitPush bool + ) + + cmd := &cobra.Command{ + Use: "finalize", + Short: "Apply a rollback and persist state to trunk", + Long: `Apply a resolved rollback and persist the updated manifest. + +This is the final stage of the generated rollback workflow. It resolves the +target, applies it to state (marking the environment diverged so forward- +promotion guards treat it as off-trunk until a promotion rejoins it), and, with +--commit-push, commits the manifest back to the trunk branch.`, + RunE: func(cmd *cobra.Command, args []string) error { + return runFinalize(finalizeOptions{ + configPath: configPath, + manifestKey: manifestKey, + env: env, + to: to, + deployable: deployable, + actor: actor, + commitPush: commitPush, + }) + }, + } + + cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to manifest file (default: .github/manifest.yaml)") + cmd.Flags().StringVar(&manifestKey, "key", config.DefaultManifestKey, "Top-level manifest key") + cmd.Flags().StringVar(&env, "env", "", "Target environment to roll back (required)") + cmd.Flags().StringVar(&to, "to", "", "Prior version or SHA to re-promote (optional; defaults to the previous version)") + cmd.Flags().StringVar(&deployable, "deployable", "", "Scope the rollback to a single deployable") + cmd.Flags().StringVar(&actor, "actor", "", "Actor recorded on the rollback (default: $GITHUB_ACTOR)") + cmd.Flags().BoolVar(&commitPush, "commit-push", false, "Commit and push the updated manifest to the trunk branch") + + _ = cmd.MarkFlagRequired("env") + + return cmd +} + +type finalizeOptions struct { + configPath string + manifestKey string + env string + to string + deployable string + actor string + commitPush bool +} + +func runFinalize(opts finalizeOptions) error { + rb, err := New(Options{ + ConfigPath: opts.configPath, + ManifestKey: opts.manifestKey, + Actor: opts.actor, + }) + if err != nil { + return err + } + + plan, err := rb.Plan(opts.env, opts.to, opts.deployable) + if err != nil { + return err + } + + // Gate the state write on the actual deploy results. A rollback re-deploys a + // prior SHA; finalize must not record the environment as rolled back unless + // that deploy actually succeeded. Otherwise a failed deploy would still mark + // the environment diverged at the prior SHA, asserting a rollback that never + // landed. See gateOnDeployResults for the in-scope rules. + if err := gateOnDeployResults(rb.DeployNames(), opts.deployable); err != nil { + return err + } + + if err := rb.Apply(plan); err != nil { + return fmt.Errorf("applying rollback: %w", err) + } + + if opts.commitPush { + if err := commitAndPush(rb.ConfigPath(), plan.Environment); err != nil { + return fmt.Errorf("failed to commit and push: %w", err) + } + fmt.Printf("State updated and committed for %s\n", plan.Environment) + return nil + } + + fmt.Printf("State updated for %s (not committed)\n", plan.Environment) + return nil +} + +// gateOnDeployResults decides whether the rollback state write may proceed, +// based on the reported result of each configured deploy job. The generated +// finalize job runs with always() so it observes every deploy result, including +// failures; this is where that observation becomes a gate. +// +// Rules: +// - No deploys configured: a state-only, deploy-less rollback. There is no +// deploy to gate on, so it always proceeds (returns nil). +// - In scope: when deployable is set, only that deploy is in scope; otherwise +// every configured deploy is in scope. A deploy excluded by the --deployable +// filter reports "skipped", which is never treated as a failure. +// - Any in-scope deploy reporting "failure" or "cancelled" aborts the write +// with an error naming the deploy, leaving trunk state unchanged. +// - If deploys are in scope but none of them succeeded (all skipped or +// unreported), nothing was actually deployed, so the write is also aborted. +// - Otherwise (at least one in-scope deploy succeeded and none failed) the +// write proceeds. +func gateOnDeployResults(deployNames []string, deployable string) error { + if len(deployNames) == 0 { + return nil + } + + results := readDeployResultsFromEnv(deployNames) + + anySucceeded := false + for _, name := range deployNames { + if deployable != "" && name != deployable { + continue // Out of scope: excluded by the --deployable filter. + } + result := results[name] + switch result { + case "failure", "cancelled": + return fmt.Errorf("rollback aborted: deploy %q did not succeed (result=%s); environment state left unchanged", name, result) + case "success": + anySucceeded = true + } + } + + if !anySucceeded { + return fmt.Errorf("rollback aborted: no in-scope deploy succeeded; environment state left unchanged") + } + return nil +} + +// readDeployResultsFromEnv reads DEPLOY_RESULT_ env vars and returns a map +// of deploy name to its reported conclusion for each deploy with a non-empty +// value. Hyphens in deploy names become underscores and the name is uppercased, +// matching the keys the generated finalize job writes. +func readDeployResultsFromEnv(deployNames []string) map[string]string { + results := make(map[string]string, len(deployNames)) + for _, name := range deployNames { + key := "DEPLOY_RESULT_" + strings.ToUpper(strings.ReplaceAll(name, "-", "_")) + if val := os.Getenv(key); val != "" { + results[name] = val + } + } + return results +} + +// commitAndPush persists the manifest at path back to the trunk branch. +// +// On real GitHub the write goes through the Contents REST API (via the gh CLI): +// API-created commits are signed by GitHub and, with a bypass-capable token, can +// update a protected trunk. In the act/gitea environment there is no GitHub API, +// so the change is committed and pushed with plain git. The environment is +// detected exactly as the promote finalize path does, by GITHUB_SERVER_URL. +func commitAndPush(path, env string) error { + status, err := exec.Command("git", "status", "--porcelain", path).Output() + if err != nil { + return fmt.Errorf("git status failed: %w", err) + } + if len(status) == 0 { + return nil // No changes + } + + message := fmt.Sprintf("chore: update state after rollback of %s [skip ci]", env) + + if isRealGitHub() { + return writeStateViaAPI(path, message) + } + return commitAndPushGit(path, message) +} + +// isRealGitHub reports whether the workflow runs on github.com rather than an +// act/gitea environment, mirroring the promote finalize detection. +func isRealGitHub() bool { + server := os.Getenv("GITHUB_SERVER_URL") + return server == "" || server == "https://github.com" +} + +// writeStateViaAPI writes the manifest to the trunk branch through the GitHub +// Contents REST API using the gh CLI, producing a signed commit that can update +// a protected branch when the token is bypass-capable. +func writeStateViaAPI(path, message string) error { + repo := os.Getenv("GITHUB_REPOSITORY") + if repo == "" { + return fmt.Errorf("GITHUB_REPOSITORY is not set; cannot write state via API") + } + branch := trunkBranchFromEnv() + + data, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("read manifest failed: %w", err) + } + contentB64 := base64.StdEncoding.EncodeToString(data) + + apiPath := fmt.Sprintf("repos/%s/contents/%s", repo, path) + + shaOut, _ := exec.Command("gh", "api", fmt.Sprintf("%s?ref=%s", apiPath, branch), "--jq", ".sha").Output() + currentSHA := strings.TrimSpace(string(shaOut)) + + args := []string{ + "api", apiPath, "-X", "PUT", + "-f", "message=" + message, + "-f", "content=" + contentB64, + "-f", "branch=" + branch, + } + if currentSHA != "" { + args = append(args, "-f", "sha="+currentSHA) + } + + if out, err := exec.Command("gh", args...).CombinedOutput(); err != nil { + return fmt.Errorf("state write via API failed: %s: %w", strings.TrimSpace(string(out)), err) + } + return nil +} + +// commitAndPushGit commits the manifest and pushes with plain git, used in the +// act/gitea environment which enforces neither branch protection nor signatures. +func commitAndPushGit(path, message string) error { + if err := exec.Command("git", "config", "user.name", "github-actions[bot]").Run(); err != nil { + return fmt.Errorf("git config user.name failed: %w", err) + } + if err := exec.Command("git", "config", "user.email", "github-actions[bot]@users.noreply.github.com").Run(); err != nil { + return fmt.Errorf("git config user.email failed: %w", err) + } + if err := exec.Command("git", "add", path).Run(); err != nil { + return fmt.Errorf("git add failed: %w", err) + } + if err := exec.Command("git", "commit", "-m", message).Run(); err != nil { + return fmt.Errorf("git commit failed: %w", err) + } + + // Push HEAD to the trunk branch explicitly: a workflow_dispatch run checks out + // a detached HEAD, so a bare push has no upstream. HEAD:refs/heads/ + // works regardless of detached state and targets the branch state belongs on. + branch := trunkBranchFromEnv() + if out, err := exec.Command("git", "push", "origin", "HEAD:refs/heads/"+branch).CombinedOutput(); err != nil { + return fmt.Errorf("git push failed: %s: %w", strings.TrimSpace(string(out)), err) + } + return nil +} + +// trunkBranchFromEnv resolves the branch to write state to, taking it from +// GITHUB_REF when present and falling back to "main". +func trunkBranchFromEnv() string { + ref := os.Getenv("GITHUB_REF") + if strings.HasPrefix(ref, "refs/heads/") { + return strings.TrimPrefix(ref, "refs/heads/") + } + if ref != "" { + return ref + } + return "main" +} diff --git a/internal/rollback/command_subcommands_test.go b/internal/rollback/command_subcommands_test.go new file mode 100644 index 00000000..b913666d --- /dev/null +++ b/internal/rollback/command_subcommands_test.go @@ -0,0 +1,398 @@ +package rollback + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stablekernel/cascade/internal/config" + "github.com/stablekernel/cascade/internal/promote" +) + +// ringManifest writes a manifest whose prod env carries a deploy-history ring +// with a distinct N-1 entry, so default-target (N-1) resolution and explicit +// resolution both have something to find without needing git history. +func ringManifest(t *testing.T) string { + t.Helper() + dir := t.TempDir() + rel := filepath.Join(dir, "manifest.yaml") + content := `ci: + config: + trunk_branch: main + environments: + - dev + - prod + deploys: + - name: services + workflow: .github/workflows/deploy.yaml + state: + prod: + sha: prodnew7654321 + version: v2.0.0 + committed_at: "2026-03-01T11:00:00Z" + committed_by: alice + previous: + - sha: prodold1112223 + version: v1.9.0 + committed_at: "2026-02-15T11:00:00Z" + committed_by: alice +` + if err := os.WriteFile(rel, []byte(content), 0o644); err != nil { + t.Fatalf("write manifest: %v", err) + } + return rel +} + +func TestRollbackPreflight_GHAOutput_EmitsTargetEnvShaVersion(t *testing.T) { + path := ringManifest(t) + outFile := filepath.Join(t.TempDir(), "gha_output") + t.Setenv("GITHUB_OUTPUT", outFile) + + cmd := NewCommand() + cmd.SilenceUsage = true + cmd.SilenceErrors = true + cmd.SetArgs([]string{ + "preflight", + "--config", path, + "--env", "prod", + "--to", "v1.9.0", + "--gha-output", + }) + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute: %v", err) + } + + data, err := os.ReadFile(outFile) + if err != nil { + t.Fatalf("read gha output: %v", err) + } + got := string(data) + for _, want := range []string{ + "target_env=prod", + "target_sha=prodold1112223", + "target_version=v1.9.0", + "can_proceed=true", + } { + if !strings.Contains(got, want) { + t.Errorf("gha output missing %q\n%s", want, got) + } + } +} + +func TestRollbackPreflight_DefaultTarget_UsesRing(t *testing.T) { + path := ringManifest(t) + outFile := filepath.Join(t.TempDir(), "gha_output") + t.Setenv("GITHUB_OUTPUT", outFile) + + cmd := NewCommand() + cmd.SilenceUsage = true + cmd.SilenceErrors = true + cmd.SetArgs([]string{ + "preflight", + "--config", path, + "--env", "prod", + "--gha-output", + }) + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute: %v", err) + } + + data, err := os.ReadFile(outFile) + if err != nil { + t.Fatalf("read gha output: %v", err) + } + got := string(data) + if !strings.Contains(got, "target_sha=prodold1112223") { + t.Errorf("default target did not resolve N-1 from ring\n%s", got) + } + if !strings.Contains(got, "can_proceed=true") { + t.Errorf("can_proceed not true\n%s", got) + } +} + +func TestRollbackPreflight_UnresolvableEmitsCannotProceed(t *testing.T) { + path := ringManifest(t) + outFile := filepath.Join(t.TempDir(), "gha_output") + t.Setenv("GITHUB_OUTPUT", outFile) + + cmd := NewCommand() + cmd.SilenceUsage = true + cmd.SilenceErrors = true + cmd.SetArgs([]string{ + "preflight", + "--config", path, + "--env", "prod", + "--to", "v0.0.0-nope", + "--gha-output", + }) + if err := cmd.Execute(); err == nil { + t.Fatal("expected error for unresolvable target") + } + + data, err := os.ReadFile(outFile) + if err != nil { + t.Fatalf("read gha output: %v", err) + } + if !strings.Contains(string(data), "can_proceed=false") { + t.Errorf("expected can_proceed=false on failure\n%s", string(data)) + } +} + +// noDeployManifest writes a manifest whose prod env has NO configured deploys, +// so a rollback is state-only and has no deploy job to gate on. Used to assert +// the deploy-less rollback path still applies. +func noDeployManifest(t *testing.T) string { + t.Helper() + dir := t.TempDir() + rel := filepath.Join(dir, "manifest.yaml") + content := `ci: + config: + trunk_branch: main + environments: + - dev + - prod + state: + prod: + sha: prodnew7654321 + version: v2.0.0 + committed_at: "2026-03-01T11:00:00Z" + committed_by: alice + previous: + - sha: prodold1112223 + version: v1.9.0 + committed_at: "2026-02-15T11:00:00Z" + committed_by: alice +` + if err := os.WriteFile(rel, []byte(content), 0o644); err != nil { + t.Fatalf("write manifest: %v", err) + } + return rel +} + +// twoDeployManifest writes a manifest whose prod env declares two deploys +// (services, web-api), used to assert deployable-scoped finalize only gates on +// the in-scope deploy and ignores the excluded (skipped) one. +func twoDeployManifest(t *testing.T) string { + t.Helper() + dir := t.TempDir() + rel := filepath.Join(dir, "manifest.yaml") + content := `ci: + config: + trunk_branch: main + environments: + - dev + - prod + deploys: + - name: services + workflow: .github/workflows/deploy-services.yaml + - name: web-api + workflow: .github/workflows/deploy-web-api.yaml + state: + prod: + sha: prodnew7654321 + version: v2.0.0 + committed_at: "2026-03-01T11:00:00Z" + committed_by: alice + deploys: + services: + sha: prodold1112223 + version: v1.9.0 + deployed_at: "2026-02-15T11:00:00Z" + deployed_by: alice +` + if err := os.WriteFile(rel, []byte(content), 0o644); err != nil { + t.Fatalf("write manifest: %v", err) + } + return rel +} + +func TestRollbackFinalize_SkippedDeployableNotCounted(t *testing.T) { + path := twoDeployManifest(t) + + // The rollback is scoped to "services" (--deployable services), which + // succeeded. "web-api" is excluded by the filter and its job reports + // "skipped"; that must not abort the in-scope rollback. + t.Setenv("DEPLOY_RESULT_SERVICES", "success") + t.Setenv("DEPLOY_RESULT_WEB_API", "skipped") + + cmd := NewCommand() + cmd.SilenceUsage = true + cmd.SilenceErrors = true + cmd.SetArgs([]string{ + "finalize", + "--config", path, + "--env", "prod", + "--to", "prodold1112223", + "--deployable", "services", + "--actor", "oncall", + }) + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute: %v", err) + } + + file, err := config.ParseManifestFile(path, config.DefaultManifestKey) + if err != nil { + t.Fatalf("re-parse: %v", err) + } + prod := file.State["prod"] + if prod == nil { + t.Fatal("prod state missing after finalize") + } + // A deployable-scoped rollback re-applies the per-deployable SHA without + // touching the env-level pointer. With "services" succeeding and "web-api" + // skipped, the gate must let the write through and the deployable state is + // re-applied at the prior SHA. + ds := prod.Deploys["services"] + if ds == nil { + t.Fatal("services deploy state missing after finalize") + } + if ds.SHA != "prodold1112223" { + t.Errorf("services sha = %q, want prodold1112223", ds.SHA) + } +} + +func TestRollbackFinalize_AppliesWhenDeploySucceeded(t *testing.T) { + path := ringManifest(t) + + // The manifest declares a "services" deploy, so finalize gates on its + // result. A successful deploy must let the state write through. + t.Setenv("DEPLOY_RESULT_SERVICES", "success") + + cmd := NewCommand() + cmd.SilenceUsage = true + cmd.SilenceErrors = true + cmd.SetArgs([]string{ + "finalize", + "--config", path, + "--env", "prod", + "--to", "prodold1112223", + "--actor", "oncall", + }) + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute: %v", err) + } + + file, err := config.ParseManifestFile(path, config.DefaultManifestKey) + if err != nil { + t.Fatalf("re-parse: %v", err) + } + prod := file.State["prod"] + if prod == nil { + t.Fatal("prod state missing after finalize") + } + if prod.SHA != "prodold1112223" { + t.Errorf("env sha = %q, want prodold1112223", prod.SHA) + } + if !prod.IsDiverged() { + t.Error("env should be diverged after rollback finalize") + } + if !promote.IsRollbackRef(prod.Ref) { + t.Errorf("ref %q is not a rollback ref", prod.Ref) + } +} + +func TestRollbackFinalize_AbortsWhenDeployFailed(t *testing.T) { + path := ringManifest(t) + + // Capture the on-disk manifest before finalize so we can prove it is + // untouched when the deploy failed. + before, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read manifest before: %v", err) + } + + // The "services" deploy job failed: finalize must abort and not write state. + t.Setenv("DEPLOY_RESULT_SERVICES", "failure") + + cmd := NewCommand() + cmd.SilenceUsage = true + cmd.SilenceErrors = true + cmd.SetArgs([]string{ + "finalize", + "--config", path, + "--env", "prod", + "--to", "prodold1112223", + "--actor", "oncall", + }) + err = cmd.Execute() + if err == nil { + t.Fatal("expected error when a deploy failed, got nil") + } + if !strings.Contains(err.Error(), "services") || !strings.Contains(err.Error(), "did not succeed") { + t.Errorf("error %q should name the failed deploy and say it did not succeed", err.Error()) + } + + after, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read manifest after: %v", err) + } + if string(before) != string(after) { + t.Errorf("manifest changed after aborted finalize:\nbefore:\n%s\nafter:\n%s", before, after) + } +} + +func TestRollbackFinalize_AbortsWhenNoInScopeDeploySucceeded(t *testing.T) { + path := ringManifest(t) + before, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read manifest before: %v", err) + } + + // No DEPLOY_RESULT_* is set: the deploy reports no success at all (treated + // as skipped/empty). Nothing deployed, so finalize must not mark rolled-back. + cmd := NewCommand() + cmd.SilenceUsage = true + cmd.SilenceErrors = true + cmd.SetArgs([]string{ + "finalize", + "--config", path, + "--env", "prod", + "--to", "prodold1112223", + "--actor", "oncall", + }) + if err := cmd.Execute(); err == nil { + t.Fatal("expected error when no in-scope deploy succeeded, got nil") + } + + after, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read manifest after: %v", err) + } + if string(before) != string(after) { + t.Errorf("manifest changed after aborted finalize:\nbefore:\n%s\nafter:\n%s", before, after) + } +} + +func TestRollbackFinalize_NoDeploysConfigured_StillApplies(t *testing.T) { + path := noDeployManifest(t) + + cmd := NewCommand() + cmd.SilenceUsage = true + cmd.SilenceErrors = true + cmd.SetArgs([]string{ + "finalize", + "--config", path, + "--env", "prod", + "--to", "prodold1112223", + "--actor", "oncall", + }) + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute: %v", err) + } + + file, err := config.ParseManifestFile(path, config.DefaultManifestKey) + if err != nil { + t.Fatalf("re-parse: %v", err) + } + prod := file.State["prod"] + if prod == nil { + t.Fatal("prod state missing after finalize") + } + if prod.SHA != "prodold1112223" { + t.Errorf("env sha = %q, want prodold1112223", prod.SHA) + } + if !promote.IsRollbackRef(prod.Ref) { + t.Errorf("ref %q is not a rollback ref", prod.Ref) + } +} diff --git a/internal/rollback/ring_test.go b/internal/rollback/ring_test.go new file mode 100644 index 00000000..21397f77 --- /dev/null +++ b/internal/rollback/ring_test.go @@ -0,0 +1,271 @@ +package rollback + +import ( + "strings" + "testing" + + "github.com/stablekernel/cascade/internal/config" + "github.com/stablekernel/cascade/internal/promote" +) + +// seedRing sets the deploy-history ring on the env's live state, newest first. +// Tests use it to populate the Previous ring that resolveTarget consults +// between live state and git history. +func seedRing(t *testing.T, rb *Rollbacker, env string, ring []config.EnvStateSnapshot) { + t.Helper() + st := rb.cicdFile.State[env] + if st == nil { + t.Fatalf("no live state for env %q to seed ring", env) + } + st.Previous = ring +} + +func TestResolveTarget_RingDepth1(t *testing.T) { + dir := t.TempDir() + path := writeManifest(t, dir, "currentsha12345", "v3.0.0") + rb := newRollbacker(t, path, fakeHistory{}) + seedRing(t, rb, "prod", []config.EnvStateSnapshot{ + {SHA: "ringsha0000001", Version: "v2.0.0"}, + }) + + plan, err := rb.Plan("prod", "v2.0.0", "") + if err != nil { + t.Fatalf("Plan: %v", err) + } + if plan.Target.SHA != "ringsha0000001" { + t.Errorf("target sha = %q, want ringsha0000001", plan.Target.SHA) + } + if plan.Target.Source != "previous-ring" { + t.Errorf("source = %q, want previous-ring", plan.Target.Source) + } +} + +func TestResolveTarget_RingDepthN(t *testing.T) { + dir := t.TempDir() + path := writeManifest(t, dir, "currentsha12345", "v4.0.0") + rb := newRollbacker(t, path, fakeHistory{}) + seedRing(t, rb, "prod", []config.EnvStateSnapshot{ + {SHA: "ringsha0000003", Version: "v3.0.0"}, + {SHA: "ringsha0000002", Version: "v2.0.0"}, + {SHA: "ringsha0000001", Version: "v1.0.0"}, + }) + + // Match the deepest entry (an N-3) by version. + plan, err := rb.Plan("prod", "v1.0.0", "") + if err != nil { + t.Fatalf("Plan: %v", err) + } + if plan.Target.SHA != "ringsha0000001" { + t.Errorf("target sha = %q, want ringsha0000001", plan.Target.SHA) + } + if plan.Target.Source != "previous-ring" { + t.Errorf("source = %q, want previous-ring", plan.Target.Source) + } +} + +func TestResolveTarget_ExplicitVersionStillWorks(t *testing.T) { + dir := t.TempDir() + // Live state itself carries the requested version; live state wins. + path := writeManifest(t, dir, "currentsha12345", "v3.0.0") + rb := newRollbacker(t, path, fakeHistory{}) + seedRing(t, rb, "prod", []config.EnvStateSnapshot{ + {SHA: "ringsha0000001", Version: "v2.0.0"}, + }) + + plan, err := rb.Plan("prod", "v3.0.0", "") + if err != nil { + t.Fatalf("Plan: %v", err) + } + if plan.Target.Source != "state" { + t.Errorf("source = %q, want state (live state wins over ring)", plan.Target.Source) + } + if plan.Target.SHA != "currentsha12345" { + t.Errorf("target sha = %q, want currentsha12345", plan.Target.SHA) + } +} + +func TestResolveTarget_ExplicitShaPrefixStillWorks(t *testing.T) { + dir := t.TempDir() + path := writeManifest(t, dir, "currentsha12345", "v4.0.0") + rb := newRollbacker(t, path, fakeHistory{}) + seedRing(t, rb, "prod", []config.EnvStateSnapshot{ + {SHA: "ringsha0000001", Version: "v1.0.0"}, + }) + + // Short (>=7 char) SHA prefix resolves the full ring SHA. + plan, err := rb.Plan("prod", "ringsha", "") + if err != nil { + t.Fatalf("Plan: %v", err) + } + if plan.Target.SHA != "ringsha0000001" { + t.Errorf("target sha = %q, want ringsha0000001", plan.Target.SHA) + } + if plan.Target.Source != "previous-ring" { + t.Errorf("source = %q, want previous-ring", plan.Target.Source) + } +} + +func TestResolveTarget_GitFallbackWhenRingMissing(t *testing.T) { + dir := t.TempDir() + path := writeManifest(t, dir, "currentsha12345", "v4.0.0") + hist := fakeHistory{states: map[string][]*config.EnvState{ + "prod": { + {SHA: "gitsha00000001", Version: "v1.0.0"}, + }, + }} + rb := newRollbacker(t, path, hist) + // Ring holds an unrelated version; the requested one lives only in git. + seedRing(t, rb, "prod", []config.EnvStateSnapshot{ + {SHA: "ringsha0000002", Version: "v2.0.0"}, + }) + + plan, err := rb.Plan("prod", "v1.0.0", "") + if err != nil { + t.Fatalf("Plan: %v", err) + } + if plan.Target.SHA != "gitsha00000001" { + t.Errorf("target sha = %q, want gitsha00000001", plan.Target.SHA) + } + if plan.Target.Source != "git-history" { + t.Errorf("source = %q, want git-history", plan.Target.Source) + } +} + +func TestResolveTarget_DefaultPicksN1FromRing(t *testing.T) { + dir := t.TempDir() + path := writeManifest(t, dir, "currentsha12345", "v3.0.0") + rb := newRollbacker(t, path, fakeHistory{}) + seedRing(t, rb, "prod", []config.EnvStateSnapshot{ + {SHA: "ringsha0000002", Version: "v2.0.0"}, + {SHA: "ringsha0000001", Version: "v1.0.0"}, + }) + + // Empty --to: default to the newest distinct ring entry (the N-1). + plan, err := rb.Plan("prod", "", "") + if err != nil { + t.Fatalf("Plan: %v", err) + } + if plan.Target.SHA != "ringsha0000002" { + t.Errorf("default target sha = %q, want ringsha0000002 (N-1)", plan.Target.SHA) + } + if plan.Target.Source != "previous-ring" { + t.Errorf("source = %q, want previous-ring", plan.Target.Source) + } +} + +func TestResolveTarget_DefaultFallsBackToGitWhenRingEmpty(t *testing.T) { + dir := t.TempDir() + path := writeManifest(t, dir, "currentsha12345", "v3.0.0") + hist := fakeHistory{states: map[string][]*config.EnvState{ + "prod": { + {SHA: "gitsha00000001", Version: "v2.0.0"}, + }, + }} + rb := newRollbacker(t, path, hist) + // No ring: default falls back to the newest distinct git-history entry. + + plan, err := rb.Plan("prod", "", "") + if err != nil { + t.Fatalf("Plan: %v", err) + } + if plan.Target.SHA != "gitsha00000001" { + t.Errorf("default target sha = %q, want gitsha00000001", plan.Target.SHA) + } + if plan.Target.Source != "git-history" { + t.Errorf("source = %q, want git-history", plan.Target.Source) + } +} + +func TestResolveTarget_UnresolvableReturnsError(t *testing.T) { + dir := t.TempDir() + path := writeManifest(t, dir, "currentsha12345", "v3.0.0") + rb := newRollbacker(t, path, fakeHistory{}) + // Empty ring, empty git history. + + _, err := rb.Plan("prod", "", "") + if err == nil { + t.Fatal("expected error when no prior version to roll back to, got nil") + } + if !strings.Contains(err.Error(), "no prior version to roll back to") { + t.Errorf("error = %q, want it to mention no prior version to roll back to", err.Error()) + } +} + +func TestApply_MarksEnvDivergedWithRollbackRef(t *testing.T) { + dir := t.TempDir() + path := writeManifest(t, dir, "currentsha12345", "v3.0.0") + hist := fakeHistory{states: map[string][]*config.EnvState{ + "prod": { + {SHA: "priorgoodsha01", Version: "v2.0.0", + Deploys: map[string]*config.DeployState{ + "services": {SHA: "priorgoodsha01", Version: "v2.0.0"}, + }}, + }, + }} + rb := newRollbacker(t, path, hist) + + plan, err := rb.Plan("prod", "v2.0.0", "") + if err != nil { + t.Fatalf("Plan: %v", err) + } + if err := rb.Apply(plan); err != nil { + t.Fatalf("Apply: %v", err) + } + + file, err := config.ParseManifestFile(path, config.DefaultManifestKey) + if err != nil { + t.Fatalf("re-parse: %v", err) + } + prod := file.State["prod"] + if !prod.IsDiverged() { + t.Errorf("expected env to be diverged after rollback Apply") + } + if !strings.HasPrefix(prod.Ref, promote.RollbackRefPrefix) { + t.Errorf("ref = %q, want prefix %q", prod.Ref, promote.RollbackRefPrefix) + } + if prod.Ref != "rollback/prod" { + t.Errorf("ref = %q, want rollback/prod", prod.Ref) + } + // BaseSHA records the pre-rollback (outgoing) current SHA. + if prod.BaseSHA != "currentsha12345" { + t.Errorf("base_sha = %q, want currentsha12345 (pre-rollback SHA)", prod.BaseSHA) + } + if len(prod.Patches) != 0 { + t.Errorf("patches = %v, want empty (rollback sets no patches)", prod.Patches) + } +} + +func TestApply_DeployableScopedDoesNotMarkEnvDiverged(t *testing.T) { + dir := t.TempDir() + path := writeManifest(t, dir, "currentsha12345", "v3.0.0") + hist := fakeHistory{states: map[string][]*config.EnvState{ + "prod": { + {SHA: "priorgoodsha01", Version: "v2.0.0", + Deploys: map[string]*config.DeployState{ + "services": {SHA: "svcsha111", Version: "v2.0.0-svc"}, + }}, + }, + }} + rb := newRollbacker(t, path, hist) + + plan, err := rb.Plan("prod", "v2.0.0-svc", "services") + if err != nil { + t.Fatalf("Plan: %v", err) + } + if err := rb.Apply(plan); err != nil { + t.Fatalf("Apply: %v", err) + } + + file, err := config.ParseManifestFile(path, config.DefaultManifestKey) + if err != nil { + t.Fatalf("re-parse: %v", err) + } + prod := file.State["prod"] + // A deployable-scoped rollback must not mark the env-level state diverged. + if prod.Ref != "" { + t.Errorf("env ref = %q, want empty (deployable scope must not touch env-level divergence)", prod.Ref) + } + if prod.IsDiverged() { + t.Errorf("env unexpectedly diverged after deployable-scoped rollback") + } +} diff --git a/internal/rollback/rollback.go b/internal/rollback/rollback.go index 2d565aa4..45f3c6c4 100644 --- a/internal/rollback/rollback.go +++ b/internal/rollback/rollback.go @@ -2,11 +2,11 @@ // re-promotion of a prior version or SHA to a target environment. // // Rollback does not introduce a new deploy code path. It resolves a prior -// deployment target from existing state (and, when needed, the git history of -// the manifest) and then re-applies that target's SHA/version to the -// environment using the same state-write machinery the promote/finalize flow -// uses. The reserved `state..previous` ring is intentionally not consulted -// here; target resolution walks current state and manifest git history. +// deployment target from existing state and then re-applies that target's +// SHA/version to the environment using the same state-write machinery the +// promote/finalize flow uses. Target resolution walks the environment's live +// state, then its deploy-history ring (state..previous, newest first), +// then the git history of the manifest. package rollback import ( @@ -16,6 +16,7 @@ import ( "time" "github.com/stablekernel/cascade/internal/config" + "github.com/stablekernel/cascade/internal/promote" "gopkg.in/yaml.v3" ) @@ -118,21 +119,41 @@ func New(opts Options) (*Rollbacker, error) { }, nil } +// ConfigPath returns the resolved manifest path the Rollbacker reads and writes. +// The finalize subcommand uses it to commit the post-rollback state back to the +// trunk branch. +func (r *Rollbacker) ConfigPath() string { + return r.configPath +} + +// DeployNames returns the names of the deploys declared in the manifest, in +// declaration order. The finalize subcommand uses it to gate the state write on +// each deploy job's reported result. It returns nil when no deploys are +// configured (a state-only, deploy-less rollback). +func (r *Rollbacker) DeployNames() []string { + if r.cicdFile.Config == nil { + return nil + } + names := make([]string, 0, len(r.cicdFile.Config.Deploys)) + for _, d := range r.cicdFile.Config.Deploys { + names = append(names, d.Name) + } + return names +} + // Plan resolves the rollback target for env and (optionally) a single // deployable, without mutating any state. It returns a clear error when the -// environment is unknown or the requested target cannot be resolved from -// state or manifest history. +// environment is unknown or the requested target cannot be resolved. // // to is matched against both SHA and version. Matching is exact for full // values and prefix-based for SHAs (so a short SHA resolves to the recorded -// full SHA). +// full SHA). When to is empty, Plan resolves the previous version (the N-1 +// entry in the deploy-history ring, or the newest distinct prior state from +// manifest history when the ring has no distinct entry). func (r *Rollbacker) Plan(env, to, deployable string) (*Plan, error) { if env == "" { return nil, fmt.Errorf("rollback requires --env") } - if to == "" { - return nil, fmt.Errorf("rollback requires --to ") - } if !r.knownEnvironment(env) { return nil, fmt.Errorf("unknown environment %q (not declared in config.environments and has no recorded state)", env) @@ -158,7 +179,13 @@ func (r *Rollbacker) Plan(env, to, deployable string) (*Plan, error) { return nil, fmt.Errorf("unknown deployable %q (not declared in config.deploys and has no recorded state in %q)", deployable, env) } - target, err := r.resolveTarget(env, to, deployable) + var target *Target + var err error + if to == "" { + target, err = r.resolveDefaultTarget(env, deployable) + } else { + target, err = r.resolveTarget(env, to, deployable) + } if err != nil { return nil, err } @@ -171,8 +198,10 @@ func (r *Rollbacker) Plan(env, to, deployable string) (*Plan, error) { } // resolveTarget finds a prior SHA/version matching `to`, searching live state -// first and manifest git history second (per the locked git/state-history -// approach). It never consults the reserved previous-state ring. +// first, the environment's deploy-history ring (state..previous) next, and +// manifest git history last. The ring is env-scoped only: snapshots carry no +// per-deployable data, so a deployable-scoped resolution skips it and falls +// straight through to git history. func (r *Rollbacker) resolveTarget(env, to, deployable string) (*Target, error) { // 1. Live state for the environment (and the scoped deployable). if cur := r.cicdFile.State[env]; cur != nil { @@ -187,7 +216,19 @@ func (r *Rollbacker) resolveTarget(env, to, deployable string) (*Target, error) } } - // 2. Manifest git history, newest first. This recovers a prior deployment + // 2. Deploy-history ring, newest first. Env-scoped only: snapshots have no + // per-deployable data, so a deployable-scoped rollback skips the ring. + if deployable == "" { + if cur := r.cicdFile.State[env]; cur != nil { + for i := range cur.Previous { + if t := matchSnapshot(cur.Previous[i], to); t != nil { + return t, nil + } + } + } + } + + // 3. Manifest git history, newest first. This recovers a prior deployment // the live manifest has already advanced past (the core rollback case). priors, err := r.history.PriorStates(env) if err != nil { @@ -241,6 +282,78 @@ func matchDeploy(ds *config.DeployState, to, deployable, source string) *Target return nil } +// matchSnapshot returns a Target when `to` matches a deploy-history ring +// snapshot's SHA (full or >=7-char prefix) or exact version. The ring is +// env-scoped, so the resulting Target carries no Deployable. +func matchSnapshot(snap config.EnvStateSnapshot, to string) *Target { + if shaMatches(snap.SHA, to) || (snap.Version != "" && snap.Version == to) { + return &Target{SHA: snap.SHA, Version: snap.Version, Source: "previous-ring"} + } + return nil +} + +// resolveDefaultTarget resolves the implicit "previous version" target used when +// no --to is given. Env-scoped: it picks the newest deploy-history ring entry +// whose SHA differs from the current state (the N-1), falling back to the newest +// distinct git-history entry. Deployable-scoped: the ring is env-only, so it +// uses the newest git-history entry carrying a distinct per-deployable SHA. +func (r *Rollbacker) resolveDefaultTarget(env, deployable string) (*Target, error) { + cur := r.cicdFile.State[env] + currentSHA := "" + if cur != nil { + currentSHA = cur.SHA + if deployable != "" { + if ds := cur.Deploys[deployable]; ds != nil { + currentSHA = ds.SHA + } + } + } + + if deployable == "" { + if cur != nil { + for i := range cur.Previous { + snap := cur.Previous[i] + if snap.SHA != "" && snap.SHA != currentSHA { + return &Target{SHA: snap.SHA, Version: snap.Version, Source: "previous-ring"}, nil + } + } + } + + priors, err := r.history.PriorStates(env) + if err != nil { + return nil, fmt.Errorf("reading manifest history for %q: %w", env, err) + } + for _, prior := range priors { + if prior == nil { + continue + } + if prior.SHA != "" && prior.SHA != currentSHA { + return &Target{SHA: prior.SHA, Version: prior.Version, Source: "git-history"}, nil + } + } + + return nil, fmt.Errorf("no prior version to roll back to for environment %q (deploy-history ring and manifest history are empty)", env) + } + + // Deployable-scoped default: the ring carries no per-deployable data, so the + // only source of a distinct prior per-deployable SHA is git history. + priors, err := r.history.PriorStates(env) + if err != nil { + return nil, fmt.Errorf("reading manifest history for %q: %w", env, err) + } + for _, prior := range priors { + if prior == nil { + continue + } + ds := prior.Deploys[deployable] + if ds != nil && ds.SHA != "" && ds.SHA != currentSHA { + return &Target{SHA: ds.SHA, Version: ds.Version, Source: "git-history", Deployable: deployable}, nil + } + } + + return nil, fmt.Errorf("no prior version to roll back to for deployable %q in environment %q (deploy-history ring and manifest history are empty)", deployable, env) +} + // shaMatches reports whether candidate matches the requested value exactly or // as a SHA prefix (min 7 chars, the conventional short-SHA length). func shaMatches(candidate, requested string) bool { @@ -296,6 +409,10 @@ func (r *Rollbacker) Apply(plan *Plan) error { // SHA onto every recorded deployable so change-detection compares // against the rolled-back base. // + // Capture the outgoing (pre-rollback) SHA before any field is mutated; + // it becomes the divergence base recorded below. + prevSHA := env.SHA + // Record the outgoing state in the deploy-history ring before the env // pointer advances. No-op when there is no prior SHA or the rollback // target equals the current SHA. @@ -313,6 +430,15 @@ func (r *Rollbacker) Apply(plan *Plan) error { ds.DeployedAt = timestamp ds.DeployedBy = r.actor } + + // Mark the environment diverged so forward-promotion guards treat it as + // off-trunk until a promotion rejoins it. The rollback ref distinguishes + // this from a hotfix divergence (no integration branch, tags, or drafts), + // so the rejoin cleanup can skip the hotfix-specific teardown. No patches + // are recorded: a rollback re-points at a prior SHA, it does not stack + // commits on a base. + env.Ref = promote.RollbackRefPrefix + plan.Environment + env.BaseSHA = prevSHA } return r.writeConfig() From 8569639e5224eac09fd39d4c1f86e671deaa5592 Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Sun, 14 Jun 2026 13:05:49 -0400 Subject: [PATCH 2/6] test: cascade-promote to prod so rollback scenarios populate the target env The rollback scenarios with environments [dev, test, prod] used a single default-mode promote per version. With the release-tier topology (top env published as a release), reaching prod from dev takes a full cascade, so prod was never populated and the setup assertion that prod equals the prior commit failed before the rollback step ran. Use a single cascade promote targeting prod (mode dev-to-prod) for each version, which advances through every hop and publishes prod in one atomic step, mirroring the cascade-promotion scenario. Rollback assertions are unchanged. Signed-off-by: Joshua Temple --- e2e/scenarios/rollback/rollback-deploys-prior.yaml | 10 ++++++---- .../rollback-failed-deploy-no-state-change.yaml | 10 ++++++---- .../rollback-marks-diverged-blocks-promote.yaml | 2 +- 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/e2e/scenarios/rollback/rollback-deploys-prior.yaml b/e2e/scenarios/rollback/rollback-deploys-prior.yaml index 21d3a570..6f2ecfd5 100644 --- a/e2e/scenarios/rollback/rollback-deploys-prior.yaml +++ b/e2e/scenarios/rollback/rollback-deploys-prior.yaml @@ -44,10 +44,11 @@ steps: dev: sha: commit1 - - name: "Promote the first version to prod (establishes the prior target)" + - name: "Promote the first version from dev to prod (establishes the prior target)" action: promote promote: - mode: default + mode: cascade + target: prod expect: state: prod: @@ -69,10 +70,11 @@ steps: dev: sha: commit2 - - name: "Promote the second version to prod (advances past the prior target)" + - name: "Promote the second version from dev to prod (advances past the prior target)" action: promote promote: - mode: default + mode: cascade + target: prod expect: state: prod: diff --git a/e2e/scenarios/rollback/rollback-failed-deploy-no-state-change.yaml b/e2e/scenarios/rollback/rollback-failed-deploy-no-state-change.yaml index 4666ba57..7c84b268 100644 --- a/e2e/scenarios/rollback/rollback-failed-deploy-no-state-change.yaml +++ b/e2e/scenarios/rollback/rollback-failed-deploy-no-state-change.yaml @@ -52,10 +52,11 @@ steps: dev: sha: commit1 - - name: "Promote the first version to prod (establishes the prior target)" + - name: "Promote the first version from dev to prod (establishes the prior target)" action: promote promote: - mode: default + mode: cascade + target: prod expect: state: prod: @@ -77,10 +78,11 @@ steps: dev: sha: commit2 - - name: "Promote the second version to prod (advances past the prior target)" + - name: "Promote the second version from dev to prod (advances past the prior target)" action: promote promote: - mode: default + mode: cascade + target: prod expect: state: prod: diff --git a/e2e/scenarios/rollback/rollback-marks-diverged-blocks-promote.yaml b/e2e/scenarios/rollback/rollback-marks-diverged-blocks-promote.yaml index 48d82aa3..3d82956c 100644 --- a/e2e/scenarios/rollback/rollback-marks-diverged-blocks-promote.yaml +++ b/e2e/scenarios/rollback/rollback-marks-diverged-blocks-promote.yaml @@ -39,7 +39,7 @@ steps: dev: sha: commit1 - - name: "Promote the first version into test and prod" + - name: "Promote the first version from dev to test" action: promote promote: mode: default From e1d058a04c326d0f8a12337f31f04965a666b20f Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Sun, 14 Jun 2026 13:15:19 -0400 Subject: [PATCH 3/6] test: target deployable rollback by published version and reach prod The two-env deployable-scoped scenario also needs a cascade promote to reach prod (top env publishes a release), and the deployable-scoped rollback must target the per-deployable published version, not a raw commit SHA: the preflight resolves a scoped target by matching the per-deployable Version recorded in manifest history. Promote to prod via cascade and target v0.1.0. Signed-off-by: Joshua Temple --- .../rollback/rollback-deployable-scoped-leaves-env.yaml | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/e2e/scenarios/rollback/rollback-deployable-scoped-leaves-env.yaml b/e2e/scenarios/rollback/rollback-deployable-scoped-leaves-env.yaml index ade7af9d..66092b3f 100644 --- a/e2e/scenarios/rollback/rollback-deployable-scoped-leaves-env.yaml +++ b/e2e/scenarios/rollback/rollback-deployable-scoped-leaves-env.yaml @@ -57,7 +57,8 @@ steps: - name: "Promote the first version to prod (records per-deployable commit1)" action: promote promote: - mode: default + mode: cascade + target: prod expect: state: prod: @@ -87,7 +88,8 @@ steps: - name: "Promote the second version to prod (advances both deployables)" action: promote promote: - mode: default + mode: cascade + target: prod expect: state: prod: @@ -107,7 +109,7 @@ steps: rollback: environment: prod deployable: api - target: commit1 + target: "v0.1.0" expect: state: prod: From e1e0150b792eb11b1daff4685e0af7b2f1f6a08d Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Sun, 14 Jun 2026 15:38:35 -0400 Subject: [PATCH 4/6] fix(rollback): resolve manifest path relative to repo root in git history reader Signed-off-by: Joshua Temple --- internal/rollback/history.go | 20 +++++++++-- internal/rollback/history_test.go | 60 +++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 3 deletions(-) diff --git a/internal/rollback/history.go b/internal/rollback/history.go index 45000c28..cf7d2b64 100644 --- a/internal/rollback/history.go +++ b/internal/rollback/history.go @@ -29,11 +29,25 @@ func newGitHistoryReader(configPath, manifestKey string) *gitHistoryReader { // (or a non-repo) yields an empty slice, not an error, so callers degrade to // state-only resolution gracefully. func (g *gitHistoryReader) PriorStates(env string) ([]*config.EnvState, error) { - repoDir := filepath.Dir(g.configPath) + manifestDir := filepath.Dir(g.configPath) relPath := filepath.Base(g.configPath) + // `git show :` always interprets relative to the repo + // root, so a manifest in a subdirectory (e.g. .github/manifest.yaml) must be + // addressed by its repo-root-relative path, not the basename combined with + // `-C `. Ask git for the subdir's prefix relative to the root and + // join it with the basename, so both flat and nested layouts resolve. + showPath := relPath + prefixCmd := exec.Command("git", "-C", manifestDir, "rev-parse", "--show-prefix") + if prefixOut, prefixErr := prefixCmd.Output(); prefixErr == nil { + prefix := strings.TrimSpace(string(prefixOut)) + if prefix != "" { + showPath = strings.TrimSuffix(prefix, "/") + "/" + relPath + } + } + // List commits that touched the manifest, newest first. - logCmd := exec.Command("git", "-C", repoDir, "log", "--format=%H", "--", relPath) + logCmd := exec.Command("git", "-C", manifestDir, "log", "--format=%H", "--", relPath) out, err := logCmd.Output() if err != nil { // Not a git repo, or git unavailable; degrade to no history. @@ -45,7 +59,7 @@ func (g *gitHistoryReader) PriorStates(env string) ([]*config.EnvState, error) { seen := make(map[string]bool) // dedupe identical sha|version snapshots for _, sha := range commits { - showCmd := exec.Command("git", "-C", repoDir, "show", sha+":"+relPath) + showCmd := exec.Command("git", "-C", manifestDir, "show", sha+":"+showPath) blob, err := showCmd.Output() if err != nil { continue // file may not exist at that revision diff --git a/internal/rollback/history_test.go b/internal/rollback/history_test.go index d269f9f3..64640640 100644 --- a/internal/rollback/history_test.go +++ b/internal/rollback/history_test.go @@ -100,3 +100,63 @@ func TestGitHistoryReader_RecoversPriorVersion(t *testing.T) { t.Errorf("prod not rolled back: %q", file.State["prod"].SHA) } } + +// TestGitHistoryReader_RecoversPriorVersion_Subdir proves the reader recovers +// prior states when the manifest lives in a subdirectory (e.g. .github/) rather +// than at the repo root. `git show :` resolves relative to the +// repo root, so a subdir manifest must be addressed by its repo-root-relative +// path, not a basename combined with `-C `. +func TestGitHistoryReader_RecoversPriorVersion_Subdir(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not available") + } + dir := t.TempDir() + gitInit(t, dir) + + if err := os.MkdirAll(filepath.Join(dir, ".github"), 0755); err != nil { + t.Fatalf("mkdir .github: %v", err) + } + rel := filepath.Join(".github", "manifest.yaml") + gitCommitFile(t, dir, rel, manifestAt("oldsha1112223", "v1.5.0"), "prod v1.5.0") + gitCommitFile(t, dir, rel, manifestAt("newsha4445556", "v2.0.0"), "prod v2.0.0") + + path := filepath.Join(dir, rel) + + reader := newGitHistoryReader(path, config.DefaultManifestKey) + states, err := reader.PriorStates("prod") + if err != nil { + t.Fatalf("PriorStates: %v", err) + } + if len(states) == 0 { + t.Fatalf("PriorStates returned empty; subdir manifest history not recovered") + } + // Newest first: v2.0.0 then v1.5.0. + if states[0].Version != "v2.0.0" { + t.Errorf("states[0].Version = %q, want v2.0.0", states[0].Version) + } + var foundPrior bool + for _, s := range states { + if s.SHA == "oldsha1112223" && s.Version == "v1.5.0" { + foundPrior = true + } + } + if !foundPrior { + t.Errorf("prior state v1.5.0/oldsha1112223 not found in %+v", states) + } + + // End-to-end: the historical target must be resolvable and applicable. + rb, err := New(Options{ConfigPath: path, Actor: "oncall"}) + if err != nil { + t.Fatalf("New: %v", err) + } + plan, err := rb.Plan("prod", "v1.5.0", "") + if err != nil { + t.Fatalf("Plan: %v", err) + } + if plan.Target.SHA != "oldsha1112223" { + t.Errorf("target sha = %q, want oldsha1112223", plan.Target.SHA) + } + if plan.Target.Source != "git-history" { + t.Errorf("source = %q, want git-history", plan.Target.Source) + } +} From 2bad3069626f9c25c6718741c82ae7310e5a94f9 Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Sun, 14 Jun 2026 15:49:39 -0400 Subject: [PATCH 5/6] test(rollback): exercise deploy-result gate and diverged-source guard The failed-deploy scenario stubbed the deploy as exit 1, which aborted the setup promotions before the rollback re-deploy ran, so the deploy-result gate was never exercised. Key the conditional failure on the invoking workflow name so the Promote setup succeeds and only the Rollback re-deploy fails. The diverged-blocks-promote scenario sourced its onward cascade from Environments[0] (on-trunk dev), so the test-to-prod leg from the diverged env was never the source and the guard was not hit. Add an optional source field to the harness promote step, threaded into the source-to-target mode string (defaulting to the existing behavior), and set the blocked step to source from the rolled-back test env. Signed-off-by: Joshua Temple --- e2e/harness/multistep.go | 12 +++++-- e2e/harness/runner.go | 9 +++++- ...ollback-failed-deploy-no-state-change.yaml | 31 ++++++++++++++----- ...ollback-marks-diverged-blocks-promote.yaml | 5 ++- 4 files changed, 45 insertions(+), 12 deletions(-) diff --git a/e2e/harness/multistep.go b/e2e/harness/multistep.go index 5b2f6fec..e25f7307 100644 --- a/e2e/harness/multistep.go +++ b/e2e/harness/multistep.go @@ -142,8 +142,16 @@ type CommitStep struct { // PromoteStep defines a promote action type PromoteStep struct { - Mode string `yaml:"mode"` // default, cascade - Target string `yaml:"target,omitempty"` // for cascade: dev-to-prod + Mode string `yaml:"mode"` // default, cascade + // Target is the destination env for a cascade promote; the harness builds the + // "-to-" mode string from Source and Target. + Target string `yaml:"target,omitempty"` + // Source overrides the cascade source env. When unset the harness defaults to + // Environments[0] (the trunk-rooted leg), matching the generator's dev-rooted + // cascade options. Set it to drive a non-default leg, e.g. source: test with + // target: prod runs the test-to-prod hop so a promote sourced from a diverged + // env exercises the diverged-source guard. + Source string `yaml:"source,omitempty"` AllowBreaking bool `yaml:"allow_breaking,omitempty"` ExpectFailure bool `yaml:"expect_failure,omitempty"` // Force sets the promote workflow's "force" dispatch input to "true", diff --git a/e2e/harness/runner.go b/e2e/harness/runner.go index 6ad90504..fc2d7b31 100644 --- a/e2e/harness/runner.go +++ b/e2e/harness/runner.go @@ -549,7 +549,7 @@ func (r *Runner) executePromote(ctx context.Context, promote *PromoteStep, confi // the literal "cascade". Translate scenarios that use the cascade+target // pair into the "-to-" form. Source defaults to the first // env (typically dev) since the workflow generator only emits dev-rooted - // cascade options. + // cascade options, but a step may set Source to drive a non-default leg. var inputs map[string]string if len(config.Environments) == 1 { // Single-environment repos generate a Release workflow (see @@ -570,10 +570,17 @@ func (r *Runner) executePromote(ctx context.Context, promote *PromoteStep, confi } else { mode := promote.Mode if mode == "cascade" { + // Source defaults to the first env (typically dev, the trunk-rooted + // leg the generator emits cascade options for). A scenario can override + // it to drive a non-default leg, e.g. test-to-prod sourced from a + // diverged env to exercise the diverged-source guard. source := "dev" if len(config.Environments) > 0 { source = config.Environments[0] } + if promote.Source != "" { + source = promote.Source + } mode = fmt.Sprintf("%s-to-%s", source, promote.Target) } inputs = map[string]string{ diff --git a/e2e/scenarios/rollback/rollback-failed-deploy-no-state-change.yaml b/e2e/scenarios/rollback/rollback-failed-deploy-no-state-change.yaml index 7c84b268..df5d3cbe 100644 --- a/e2e/scenarios/rollback/rollback-failed-deploy-no-state-change.yaml +++ b/e2e/scenarios/rollback/rollback-failed-deploy-no-state-change.yaml @@ -7,7 +7,9 @@ description: | succeed, leaving trunk state exactly where it was. prod is advanced through two published versions, then a rollback is requested. - The inline deploy job exits non-zero on purpose, so finalize sees + The inline deploy succeeds while it is invoked by the Promote workflow (so the + two setup promotions land prod on commit1 then commit2), and exits non-zero + only when invoked by the Rollback workflow's re-deploy. finalize then sees DEPLOY_RESULT_APP=failure, aborts before writing, and the run concludes in failure. prod state therefore stays on the second version: no rolled-back SHA, no rollback ref. This is the safety property that distinguishes a real @@ -15,7 +17,10 @@ description: | The deploy is an inline run: job (no actions/checkout, which act cannot resolve for a reusable callback against the per-scenario gitea), so the deploy job runs - observably under act and is asserted as deploy-app: failure. + observably under act and is asserted as deploy-app: failure. The deploy keys + its conditional failure on $GITHUB_WORKFLOW (the invoking workflow's name): the + Promote workflow succeeds, only the Rollback re-deploy fails, exercising the + gateOnDeployResults guard rather than aborting during setup. config: trunk_branch: main @@ -25,14 +30,24 @@ config: workflow: build.yaml triggers: ["src/**"] deploys: - # Inline run: deploy that always fails. The non-zero exit forces - # deploy-app to fail, so finalize sees DEPLOY_RESULT_APP=failure and aborts - # the state write. Its caller job id is deploy-app, which the harness's - # findJob matches directly. + # Inline run: deploy that succeeds under the Promote workflow but fails under + # the Rollback workflow's re-deploy. Both workflows surface the same env vars + # (ENVIRONMENT, SHA) to this inline step, and the rollback target SHA equals + # an earlier promote's SHA, so the SHA alone cannot tell the two apart. The + # invoking workflow name ($GITHUB_WORKFLOW) does: it is "Promote" during the + # two setup promotions and "Rollback" during the re-deploy under test. Failing + # only on Rollback forces deploy-app to fail there, so finalize sees + # DEPLOY_RESULT_APP=failure and aborts the state write, while setup lands prod + # on commit1 then commit2 cleanly. Its caller job id is deploy-app, which the + # harness's findJob matches directly. - name: app run: | - echo "rollback deploy of env=${ENVIRONMENT} sha=${SHA} failing on purpose" - exit 1 + echo "deploy of env=${ENVIRONMENT} sha=${SHA} via workflow=${GITHUB_WORKFLOW}" + if [ "${GITHUB_WORKFLOW}" = "Rollback" ]; then + echo "failing the rollback re-deploy on purpose" + exit 1 + fi + echo "promote deploy succeeded" triggers: ["**"] steps: diff --git a/e2e/scenarios/rollback/rollback-marks-diverged-blocks-promote.yaml b/e2e/scenarios/rollback/rollback-marks-diverged-blocks-promote.yaml index 3d82956c..11338f73 100644 --- a/e2e/scenarios/rollback/rollback-marks-diverged-blocks-promote.yaml +++ b/e2e/scenarios/rollback/rollback-marks-diverged-blocks-promote.yaml @@ -88,10 +88,13 @@ steps: # An onward promote sourced FROM the rolled-back test env is guarded: the # test-to-prod leg's preflight sees test diverged and trips the diverged-source - # guard, so the promote concludes in failure. + # guard, so the promote concludes in failure. source: test drives the + # test-to-prod cascade leg directly (the harness default of Environments[0] + # would source from on-trunk dev and never exercise the guard). - name: "Promote from the rolled-back test env is blocked" action: promote promote: mode: cascade + source: test target: prod expect_failure: true From 1a96096f8fb23aa2eeece5ffd2f1fb8d76ffbb15 Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Sun, 14 Jun 2026 15:52:58 -0400 Subject: [PATCH 6/6] test(rollback): match Rollback workflow prefix in failing deploy The harness suffixes every workflow name with [scenario-], so the rollback re-deploy sees GITHUB_WORKFLOW as 'Rollback [scenario-...]' rather than the bare 'Rollback'. Match the prefix so only the rollback re-deploy fails and the deploy-result gate is exercised. Signed-off-by: Joshua Temple --- ...ollback-failed-deploy-no-state-change.yaml | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/e2e/scenarios/rollback/rollback-failed-deploy-no-state-change.yaml b/e2e/scenarios/rollback/rollback-failed-deploy-no-state-change.yaml index df5d3cbe..12d094fc 100644 --- a/e2e/scenarios/rollback/rollback-failed-deploy-no-state-change.yaml +++ b/e2e/scenarios/rollback/rollback-failed-deploy-no-state-change.yaml @@ -18,9 +18,11 @@ description: | The deploy is an inline run: job (no actions/checkout, which act cannot resolve for a reusable callback against the per-scenario gitea), so the deploy job runs observably under act and is asserted as deploy-app: failure. The deploy keys - its conditional failure on $GITHUB_WORKFLOW (the invoking workflow's name): the - Promote workflow succeeds, only the Rollback re-deploy fails, exercising the - gateOnDeployResults guard rather than aborting during setup. + its conditional failure on $GITHUB_WORKFLOW (the invoking workflow's name, + which the harness suffixes with [scenario-...], so the deploy matches the + Rollback prefix): the Promote workflow succeeds, only the Rollback re-deploy + fails, exercising the gateOnDeployResults guard rather than aborting during + setup. config: trunk_branch: main @@ -43,10 +45,15 @@ config: - name: app run: | echo "deploy of env=${ENVIRONMENT} sha=${SHA} via workflow=${GITHUB_WORKFLOW}" - if [ "${GITHUB_WORKFLOW}" = "Rollback" ]; then - echo "failing the rollback re-deploy on purpose" - exit 1 - fi + # The harness suffixes each workflow name with [scenario-], so the + # Rollback workflow surfaces GITHUB_WORKFLOW as "Rollback [scenario-...]". + # Match the Rollback prefix rather than an exact string. + case "${GITHUB_WORKFLOW}" in + Rollback*) + echo "failing the rollback re-deploy on purpose" + exit 1 + ;; + esac echo "promote deploy succeeded" triggers: ["**"]