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
6 changes: 3 additions & 3 deletions .github/manifest.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
35 changes: 32 additions & 3 deletions e2e/harness/multistep.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -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
// "<source>-to-<target>" 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",
Expand All @@ -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"`
Expand Down
100 changes: 100 additions & 0 deletions e2e/harness/rollback_actions.go
Original file line number Diff line number Diff line change
@@ -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/<env>") 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/<env>").
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
}
18 changes: 17 additions & 1 deletion e2e/harness/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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 "<source>-to-<target>" 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
Expand All @@ -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{
Expand Down
126 changes: 126 additions & 0 deletions e2e/scenarios/rollback/rollback-deployable-scoped-leaves-env.yaml
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading