From 3746ef60231f9ce922b14202d003ff569e7d1263 Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Thu, 11 Jun 2026 16:48:24 -0400 Subject: [PATCH] test: add promote force and rollback runtime e2e scenarios Signed-off-by: Joshua Temple --- e2e/harness/multistep.go | 5 + e2e/harness/runner.go | 6 + e2e/scenarios/promote/promote-force.yaml | 61 ++++++++ .../promote/promote-rollback-runtime.yaml | 148 ++++++++++++++++++ 4 files changed, 220 insertions(+) create mode 100644 e2e/scenarios/promote/promote-force.yaml create mode 100644 e2e/scenarios/promote/promote-rollback-runtime.yaml diff --git a/e2e/harness/multistep.go b/e2e/harness/multistep.go index 42f9509..6c46c51 100644 --- a/e2e/harness/multistep.go +++ b/e2e/harness/multistep.go @@ -145,6 +145,11 @@ type PromoteStep struct { // bypassing the no-op promotion guard. Only meaningful for multi-env // (default mode) promotions; single-env Release promotions ignore it. Force bool `yaml:"force,omitempty"` + // RollbackOnFailure sets the promote workflow's "rollback_on_failure" + // dispatch input to "true", enabling the atomic rollback path: when a deploy + // fails, every deploy that already succeeded is rolled back to the SHA + // previously deployed in the target env (preflight's rollback_sha). + RollbackOnFailure bool `yaml:"rollback_on_failure,omitempty"` } // StepExpect defines expected outcomes for a step diff --git a/e2e/harness/runner.go b/e2e/harness/runner.go index d27b320..119bb0d 100644 --- a/e2e/harness/runner.go +++ b/e2e/harness/runner.go @@ -576,6 +576,12 @@ func (r *Runner) executePromote(ctx context.Context, promote *PromoteStep, confi // it bypasses the no-op promotion guard. inputs["force"] = "true" } + if promote.RollbackOnFailure { + // Workflow input is named "rollback_on_failure"; preflight surfaces it + // to the rollback- jobs, which re-deploy each successful deploy at + // the target env's previously deployed SHA when any deploy fails. + inputs["rollback_on_failure"] = "true" + } if promote.AllowBreaking { // Workflow input is named allow_breaking_changes (see internal/generate // promote.go); the workflow forwards it to the CLI's --allow-breaking diff --git a/e2e/scenarios/promote/promote-force.yaml b/e2e/scenarios/promote/promote-force.yaml new file mode 100644 index 0000000..348a176 --- /dev/null +++ b/e2e/scenarios/promote/promote-force.yaml @@ -0,0 +1,61 @@ +name: "Promote force bypasses the no-op guard" +description: | + Proves the promote workflow's force input bypasses the no-op promotion guard. + A commit is orchestrated into dev, then promoted dev-to-test (success). A + second promote of the same dev SHA is rejected by the no-op guard (test + already holds that SHA), so it fails. A third promote of the same SHA with + force=true bypasses the guard and succeeds, leaving test on the dev SHA. + +config: + trunk_branch: main + environments: [dev, test, prod] + builds: + - name: app + workflow: build.yaml + triggers: ["src/**"] + +steps: + - name: "Commit app source on trunk" + action: commit + commit: + message: "feat: add app source" + files: + src/app.go: | + package main + + func main() {} + + - name: "Orchestrate to populate dev state" + action: orchestrate + expect: + state: + dev: + sha: commit1 + + - name: "Promote dev to test succeeds" + action: promote + promote: + mode: cascade + target: test + expect: + state: + test: + sha: commit1 + + - name: "Re-promote same SHA is rejected by the no-op guard" + action: promote + promote: + mode: cascade + target: test + expect_failure: true + + - name: "Force re-promote of the same SHA bypasses the guard" + action: promote + promote: + mode: cascade + target: test + force: true + expect: + state: + test: + sha: commit1 diff --git a/e2e/scenarios/promote/promote-rollback-runtime.yaml b/e2e/scenarios/promote/promote-rollback-runtime.yaml new file mode 100644 index 0000000..91ec12e --- /dev/null +++ b/e2e/scenarios/promote/promote-rollback-runtime.yaml @@ -0,0 +1,148 @@ +name: "Promote rolls back a successful deploy when a sibling deploy fails" +description: | + Exercises the rollback_on_failure path in the generated promote workflow. + Two reusable deploys run during a dev-to-test promote: infra always succeeds, + app fails when a marker file is present on the promoted SHA. The first promote + populates test state (so preflight later resolves a non-empty rollback_sha). + A marker commit is then orchestrated into dev and promoted again with + rollback_on_failure=true. app's deploy fails, so the rollback-infra job runs + and re-deploys infra at the SHA test held before this promote. + + Observable boundary: the assertions verify the rollback-infra JOB ran with + conclusion=success (infra deploy succeeded, app deploy failed). The exact + rollback_sha value handed to the infra deploy callback comes from + needs.preflight.outputs.rollback_sha; on real GitHub the deployed SHA is + observable through deployment objects, which gitea/act do not model, so the + SHA-value claim is trimmed to the job-conclusion claim here. + +config: + trunk_branch: main + environments: [dev, test, prod] + builds: + - name: app + workflow: build.yaml + triggers: ["src/**"] + deploys: + # infra is a reusable workflow so its rollback-infra job emits a valid + # uses: call. The deploy itself always succeeds. + - name: infra + workflow: .github/workflows/deploy-infra.yaml + triggers: ["**"] + # app fails its deploy when the marker file is present on the promoted SHA, + # which forces the rollback path for the sibling infra deploy. + - name: app + workflow: .github/workflows/deploy-app.yaml + triggers: ["**"] + +steps: + - name: "Commit app source and custom deploy workflows" + action: commit + commit: + message: "feat: add app source and deploy callbacks" + files: + src/app.go: | + package main + + func main() {} + # infra deploy: always succeeds. Declares the environment/sha inputs the + # generated promote workflow passes through its uses: with: block. + .github/workflows/deploy-infra.yaml: | + name: deploy-infra + on: + workflow_call: + inputs: + environment: + required: false + type: string + sha: + required: false + type: string + jobs: + deploy: + runs-on: ubuntu-latest + steps: + - run: echo "infra deployed env=${{ inputs.environment }} sha=${{ inputs.sha }}" + # app deploy: checks out the promoted sha and fails if the marker file + # exists at that commit. The marker is committed later, so the first + # promote (no marker) succeeds and the second (marker present) fails. + .github/workflows/deploy-app.yaml: | + name: deploy-app + on: + workflow_call: + inputs: + environment: + required: false + type: string + sha: + required: false + type: string + jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.sha }} + - name: Fail when marker present + run: | + if [ -f deploy/fail-marker ]; then + echo "marker present: deploy-app failing on purpose" + exit 1 + fi + echo "no marker: app deployed env=${{ inputs.environment }} sha=${{ inputs.sha }}" + + - name: "Orchestrate the clean commit into dev" + action: orchestrate + expect: + state: + dev: + sha: commit1 + + - name: "First promote dev to test succeeds and populates test state" + action: promote + promote: + mode: cascade + target: test + expect: + state: + test: + sha: commit1 + jobs: + preflight: success + promote: success + deploy-infra: success + deploy-app: success + + - name: "Commit the fail marker so the next app deploy fails" + action: commit + commit: + message: "chore: arm deploy-app failure marker" + files: + deploy/fail-marker: | + fail the app deploy on the next promote + + - name: "Orchestrate the marker commit into dev" + action: orchestrate + expect: + state: + dev: + sha: commit2 + + - name: "Promote with rollback_on_failure: app fails, infra rolls back" + action: promote + promote: + mode: cascade + target: test + rollback_on_failure: true + expect_failure: true + expect: + jobs: + preflight: success + promote: success + deploy-infra: success + deploy-app: failure + # rollback-infra rolls back the successful infra deploy to the SHA test + # held before this promote (preflight rollback_sha = prior test state). + rollback-infra: success + # rollback-app does not run: a deploy that failed is never rolled back. + rollback-app: skipped