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..e25f7307 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"` @@ -137,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", @@ -152,6 +165,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..fc2d7b31 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) } @@ -540,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 @@ -561,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-deployable-scoped-leaves-env.yaml b/e2e/scenarios/rollback/rollback-deployable-scoped-leaves-env.yaml new file mode 100644 index 00000000..66092b3f --- /dev/null +++ b/e2e/scenarios/rollback/rollback-deployable-scoped-leaves-env.yaml @@ -0,0 +1,126 @@ +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: cascade + target: prod + 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: cascade + target: prod + 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: "v0.1.0" + 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..6f2ecfd5 --- /dev/null +++ b/e2e/scenarios/rollback/rollback-deploys-prior.yaml @@ -0,0 +1,98 @@ +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 from dev to prod (establishes the prior target)" + action: promote + promote: + mode: cascade + target: prod + expect: + state: + prod: + sha: commit1 + + - name: "Commit a second version source" + action: commit + commit: + message: "feat: second version" + files: + src/app.go: | + package main + func main() { _ = 2 } + + - name: "Orchestrate the second commit into dev" + action: orchestrate + expect: + state: + dev: + sha: commit2 + + - name: "Promote the second version from dev to prod (advances past the prior target)" + action: promote + promote: + mode: cascade + target: prod + expect: + state: + prod: + sha: commit2 + + # Rollback 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..12d094fc --- /dev/null +++ b/e2e/scenarios/rollback/rollback-failed-deploy-no-state-change.yaml @@ -0,0 +1,129 @@ +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 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 + 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. The deploy keys + 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 + environments: [dev, test, prod] + builds: + - name: app + workflow: build.yaml + triggers: ["src/**"] + deploys: + # 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 "deploy of env=${ENVIRONMENT} sha=${SHA} via workflow=${GITHUB_WORKFLOW}" + # 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: ["**"] + +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 from dev to prod (establishes the prior target)" + action: promote + promote: + mode: cascade + target: prod + expect: + state: + prod: + sha: commit1 + + - name: "Commit a second version source" + action: commit + commit: + message: "feat: second version" + files: + src/app.go: | + package main + func main() { _ = 2 } + + - name: "Orchestrate the second commit into dev" + action: orchestrate + expect: + state: + dev: + sha: commit2 + + - name: "Promote the second version from dev to prod (advances past the prior target)" + action: promote + promote: + mode: cascade + target: prod + expect: + state: + prod: + sha: commit2 + + # Rollback 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..11338f73 --- /dev/null +++ b/e2e/scenarios/rollback/rollback-marks-diverged-blocks-promote.yaml @@ -0,0 +1,100 @@ +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 from dev to test" + 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. 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 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/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) + } +} 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()