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
18 changes: 18 additions & 0 deletions e2e/harness/assert.go
Original file line number Diff line number Diff line change
Expand Up @@ -483,3 +483,21 @@ func findJob(jobs map[string]*JobResultExtended, name string) *JobResultExtended

return nil
}

// assertRollbackSource checks that the workflow logs contain the expected
// resolved-source marker emitted by the preflight "Report Resolved Source"
// step. The marker has the form "rollback resolved from <source>", where
// <source> is one of "state", "previous-ring", or "git-history".
//
// It is called from executeRollback when RollbackStep.ExpectSource is set, so
// scenarios can assert WHICH precedence path the resolver chose, not just the
// resulting SHA.
func assertRollbackSource(t testingT, logs string, wantSource string) error {
t.Helper()
marker := fmt.Sprintf("rollback resolved from %s", wantSource)
if !strings.Contains(logs, marker) {
t.Errorf("rollback logs did not contain resolved-source marker %q", marker)
return fmt.Errorf("resolved-source marker %q not found in rollback logs", marker)
}
return nil
}
44 changes: 44 additions & 0 deletions e2e/harness/assert_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -406,3 +406,47 @@ func (m *mockT) Helper() {}

var _ testingT = (*testing.T)(nil)
var _ testingT = (*mockT)(nil)

func TestAssertRollbackSource(t *testing.T) {
t.Run("marker present passes", func(t *testing.T) {
logs := "some output\nrollback resolved from previous-ring\nmore output\n"
mt := &mockT{}
err := assertRollbackSource(mt, logs, "previous-ring")
assert.NoError(t, err)
assert.False(t, mt.failed)
})

t.Run("marker absent fails", func(t *testing.T) {
logs := "some output\nrollback resolved from state\nmore output\n"
mt := &mockT{}
err := assertRollbackSource(mt, logs, "previous-ring")
assert.Error(t, err)
assert.True(t, mt.failed)
assert.Contains(t, mt.errors[0], "previous-ring")
})

t.Run("state source marker", func(t *testing.T) {
logs := "pre\nrollback resolved from state\npost\n"
mt := &mockT{}
err := assertRollbackSource(mt, logs, "state")
assert.NoError(t, err)
assert.False(t, mt.failed)
})

t.Run("git-history source marker", func(t *testing.T) {
logs := "pre\nrollback resolved from git-history\npost\n"
mt := &mockT{}
err := assertRollbackSource(mt, logs, "git-history")
assert.NoError(t, err)
assert.False(t, mt.failed)
})

t.Run("wrong source in logs fails", func(t *testing.T) {
logs := "rollback resolved from git-history\n"
mt := &mockT{}
err := assertRollbackSource(mt, logs, "state")
assert.Error(t, err)
assert.True(t, mt.failed)
assert.Contains(t, mt.errors[0], "state")
})
}
5 changes: 4 additions & 1 deletion e2e/harness/multistep.go
Original file line number Diff line number Diff line change
Expand Up @@ -201,13 +201,16 @@ type PromoteStep struct {
// 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.
// mirroring PromoteStep.ExpectFailure. ExpectSource, when non-empty, asserts the
// resolved-target source label that the preflight job echoes to its job log
// (one of "state", "previous-ring", or "git-history").
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"`
ExpectSource string `yaml:"expect_source,omitempty"`
}

// VerifyStep defines a verify action: a read-only `cascade verify` run in the
Expand Down
7 changes: 7 additions & 0 deletions e2e/harness/rollback_actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,5 +104,12 @@ func (r *Runner) executeRollback(ctx context.Context, rollback *RollbackStep, co
}

r.t.Logf(" Rollback: workflow completed successfully")

if rollback.ExpectSource != "" {
if err := assertRollbackSource(r.t, result.Logs, rollback.ExpectSource); err != nil {
return err
}
}

return nil
}
139 changes: 139 additions & 0 deletions e2e/scenarios/rollback/rollback-source-git-history.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
name: "Rollback preflight resolves target from git history (deployable-scoped)"
description: |
Isolates the Source="git-history" resolution path: proves that when the
rollback is scoped to a single deployable AND the --to value is not in the
current live state, the preflight job reports target_source=git-history in
its log output.

The git-history path fires deterministically for a deployable-scoped rollback
because resolveTarget skips the deploy-history ring entirely when a deployable
is given (the ring is env-scoped only: its snapshots carry no per-deployable
data). With the ring skipped, resolution falls straight to step 3: manifest git
history, where it scans prior manifest commits for a matching per-deployable
SHA/version.

prod is advanced through two published versions with a single deployable (api),
so the first version's per-deployable SHA lives only in an earlier manifest
commit - not in current live state (which is commit2) and not in the ring
(which the deployable-scoped path never consults). A dry-run rollback with
--deployable api --to commit1 forces this path: step 1 fails (live api sha =
commit2), step 2 is skipped (deployable-scoped), step 3 finds commit1 in the
manifest git log as api's recorded per-deployable SHA, resolves
Source="git-history".

The dry_run flag suppresses the deploy and finalize jobs so the scenario is
fast and deterministic. The preflight job and its "Report Resolved Source" step
run unconditionally (they are never gated by dry_run), so the source marker
appears in logs for all three resolution paths under dry-run.

The expect_source assertion on the rollback step is the distinguishing check:
it asserts the resolved-source marker in the workflow logs, proving that this
specific resolution path (Source="git-history") was taken end-to-end.

config:
trunk_branch: main
environments: [dev, prod]
builds:
- name: app
workflow: build.yaml
triggers: ["src/**"]
deploys:
# Single deployable so the rollback scope is unambiguous. Inner job id
# apideploy lets act key it distinctly; no actions/checkout needed because
# the step only echoes inputs (act cannot resolve checkout refs against the
# per-scenario gitea).
- name: api
workflow: .github/workflows/deploy-api.yaml
triggers: ["**"]

steps:
- name: "Commit the first version source"
action: commit
commit:
message: "feat: first version"
files:
src/app.go: |
package main
func main() {}
.github/workflows/deploy-api.yaml: |
name: deploy-api
on:
workflow_call:
inputs:
environment:
required: false
type: string
sha:
required: false
type: string
jobs:
apideploy:
runs-on: ubuntu-latest
steps:
- run: echo "deployed env=${{ inputs.environment }} sha=${{ inputs.sha }}"

- name: "Orchestrate the first commit into dev"
action: orchestrate
expect:
state:
dev:
sha: commit1

- name: "Promote the first version to prod (records api's per-deployable commit1 in manifest)"
action: promote
promote:
mode: cascade
target: prod
expect:
state:
prod:
sha: commit1
deploys:
api:
sha: commit1

- name: "Commit the 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 api past commit1)"
action: promote
promote:
mode: cascade
target: prod
expect:
state:
prod:
sha: commit2
deploys:
api:
sha: commit2

# Dry-run rollback scoped to the api deployable, targeting commit1. Resolution:
# step 1 - live state: api.sha = commit2, no match.
# step 2 - deploy-history ring: SKIPPED because deployable != "" (ring is
# env-scoped only; per-deployable data is never captured there).
# step 3 - manifest git history: finds a prior manifest commit where
# api.sha = commit1_sha, returns Source="git-history".
# The "Report Resolved Source" step in the preflight job echoes
# "rollback resolved from git-history" unconditionally (not gated by dry_run).
- name: "Dry-run deployable-scoped rollback resolves from git history"
action: rollback
rollback:
environment: prod
deployable: api
target: commit1
dry_run: true
expect_source: git-history
111 changes: 111 additions & 0 deletions e2e/scenarios/rollback/rollback-source-previous-ring.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
name: "Rollback preflight resolves target from previous-deploy ring"
description: |
Isolates the Source="previous-ring" resolution path: proves that when the
--to value is NOT the current live state but IS present in the deploy-history
ring (state.<env>.previous), the preflight job reports
target_source=previous-ring in its log output.

prod is advanced through two published versions so the ring captures the first
commit as the N-1 entry. A dry-run rollback is then dispatched with --to set
to the first commit's SHA. The resolver checks live state first (no match:
live state is commit2), then finds a match in the ring, so the "Report
Resolved Source" step echoes "rollback resolved from previous-ring". The
dry_run flag suppresses the deploy and finalize jobs.

The expect_source assertion on the rollback step is the distinguishing check:
it asserts the resolved-source marker in the workflow logs, proving that this
specific resolution path (Source="previous-ring") was taken end-to-end.

config:
trunk_branch: main
environments: [dev, prod]
builds:
- name: app
workflow: build.yaml
triggers: ["src/**"]
deploys:
- name: app
workflow: .github/workflows/deploy-app.yaml
triggers: ["**"]

steps:
- name: "Commit the first version source"
action: commit
commit:
message: "feat: first version"
files:
src/app.go: |
package main
func main() {}
.github/workflows/deploy-app.yaml: |
name: deploy-app
on:
workflow_call:
inputs:
environment:
required: false
type: string
sha:
required: false
type: string
jobs:
appdeploy:
runs-on: ubuntu-latest
steps:
- run: echo "deployed env=${{ inputs.environment }} sha=${{ inputs.sha }}"

- name: "Orchestrate the first commit into dev"
action: orchestrate
expect:
state:
dev:
sha: commit1

- name: "Promote the first version to prod (establishes the ring entry)"
action: promote
promote:
mode: cascade
target: prod
expect:
state:
prod:
sha: commit1

- name: "Commit the 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 state past commit1)"
action: promote
promote:
mode: cascade
target: prod
expect:
state:
prod:
sha: commit2

# Dry-run rollback targeting commit1 which is now in the deploy-history ring
# (recorded when the second promotion advanced prod past it) but NOT the live
# state (commit2). The resolver skips live state, finds commit1 in the ring,
# and reports Source="previous-ring". The expect_source assertion verifies this
# path end-to-end.
- name: "Dry-run rollback targeting ring entry resolves from previous-ring"
action: rollback
rollback:
environment: prod
target: commit1
dry_run: true
expect_source: previous-ring
Loading
Loading