Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions e2e/harness/multistep.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions e2e/harness/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -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-<name> 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
Expand Down
61 changes: 61 additions & 0 deletions e2e/scenarios/promote/promote-force.yaml
Original file line number Diff line number Diff line change
@@ -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
148 changes: 148 additions & 0 deletions e2e/scenarios/promote/promote-rollback-runtime.yaml
Original file line number Diff line number Diff line change
@@ -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
Loading