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
57 changes: 56 additions & 1 deletion e2e/harness/harness.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,15 @@ func (h *Harness) SetupInfra(ctx context.Context) error {
}

// StageRepoFromConfig creates a repo with the given config for multi-step scenarios
func (h *Harness) StageRepoFromConfig(ctx context.Context, config Config) error {
// StageRepoFromConfig creates the test repo, writes the manifest and the stub
// callback workflows derived from config, then runs workflow generation. The
// optional setupWorkflows map seeds additional reusable callback workflow files
// (keyed by repository path) into the same setup commit before generation, and
// overrides any auto-generated stub at the same path. Scenarios use it to supply
// a callback body the generic stub cannot express (for example a deploy that
// fails only under the Rollback workflow); files staged via a later step's
// commit.files would land after generation and never be read by the generator.
func (h *Harness) StageRepoFromConfig(ctx context.Context, config Config, setupWorkflows map[string]string) error {
var err error

// Create repo
Expand Down Expand Up @@ -159,6 +167,25 @@ func (h *Harness) StageRepoFromConfig(ctx context.Context, config Config) error
files[p] = generateChangelogStubWorkflow(scenarioTag)
}
}
// A top-level validate callback is a reusable workflow the generated
// orchestrate.yaml invokes as a job-level uses:. Stub it so the generator
// can read the referenced workflow at generation time and emit the validate
// gate. Without a seeded stub the generator fails reading validate.yaml,
// since the file would otherwise only arrive via a later step commit.
if wf, ok := config.Validate["workflow"].(string); ok && wf != "" {
if p := normalizeCallbackStubPath(wf); p != "" {
files[p] = generateValidateStubWorkflow(scenarioTag)
}
}

// Seed scenario-supplied reusable callback workflows last so they override
// any auto-generated stub at the same path. These bodies express behavior
// the generic stub cannot (for example a deploy that exits non-zero only
// under the Rollback workflow) and must be present before generation reads
// the referenced workflows.
for path, body := range setupWorkflows {
files[path] = body
}

// Create mock setup-cli action that installs CLI from repo
// The generated workflows reference stablekernel/cascade/.github/actions/setup-cli
Expand Down Expand Up @@ -418,6 +445,34 @@ jobs:
`, displayName)
}

// generateValidateStubWorkflow returns a reusable workflow_call stub for a
// top-level validate callback. The generated orchestrate.yaml invokes it as a
// job-level uses: and gates the build jobs on needs.validate.result, so the stub
// declares the environment/sha inputs the generator threads and an inner job
// that always succeeds, giving the gate a real job to wait on.
func generateValidateStubWorkflow(scenarioTag string) string {
displayName := "validate"
if scenarioTag != "" {
displayName = fmt.Sprintf("validate [scenario-%s]", scenarioTag)
}
return fmt.Sprintf(`name: %s
on:
workflow_call:
inputs:
environment:
required: false
type: string
sha:
required: false
type: string
jobs:
runvalidate:
runs-on: ubuntu-latest
steps:
- run: echo "validate"
`, displayName)
}

// GenerateWorkflows generates GitHub Actions workflows from cicd-config.yaml
func (h *Harness) GenerateWorkflows(ctx context.Context) error {
if h.repo == nil {
Expand Down
12 changes: 12 additions & 0 deletions e2e/harness/multistep.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,18 @@ type MultiStepScenario struct {
Config Config `yaml:"config"`
Setup *SetupState `yaml:"setup,omitempty"` // Optional initial state
Steps []Step `yaml:"steps"`
// SetupWorkflows seeds reusable callback workflow files into the setup commit
// BEFORE workflow generation runs, keyed by repository path (for example
// ".github/workflows/deploy-app.yaml"). The harness generates a generic
// non-failing stub for every build/deploy callback, which is sufficient for
// most scenarios. A scenario that needs a callback to behave differently (for
// example a deploy that exits non-zero only under the Rollback workflow) sets
// the exact reusable-workflow body here so it is present on disk when the
// generator reads the referenced workflow. A staged file overrides any
// auto-generated stub at the same path. These files are harness-side only and
// are never written into the generated manifest. Staging them via a step's
// commit.files would land after generation, so they would be invisible to it.
SetupWorkflows map[string]string `yaml:"setup_workflows,omitempty"`
}

// SetupState defines optional initial state for the scenario
Expand Down
8 changes: 8 additions & 0 deletions e2e/harness/rollback_actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,14 @@ func (r *Runner) executeRollback(ctx context.Context, rollback *RollbackStep, co
Env: map[string]string{
"GITHUB_REF": fmt.Sprintf("refs/heads/%s", branch),
"GITHUB_REPOSITORY": fmt.Sprintf("%s/%s", AdminUsername, r.harness.repo.Name),
// Mark this run as a rollback so a deploy callback can distinguish the
// rollback re-deploy from a setup promote. Both are dispatched via
// workflow_dispatch and a reusable callback sees its own name in
// $GITHUB_WORKFLOW (the callee's, not the caller's), so the caller's
// workflow name is not a usable signal. act passes top-level --env into
// every job, including reusable callees, so this is observable in the
// deploy step. Only the rollback path sets it; promote never does.
"CASCADE_E2E_ROLLBACK": "1",
},
})
if err != nil {
Expand Down
2 changes: 1 addition & 1 deletion e2e/harness/scenario_retry.go
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@ func RunMultiStepScenario(ctx context.Context, t *testing.T, scenario *MultiStep
if err := h.SetupInfra(ctx); err != nil {
return fmt.Errorf("failed to setup infrastructure: %w", err)
}
if err := h.StageRepoFromConfig(ctx, scenario.Config); err != nil {
if err := h.StageRepoFromConfig(ctx, scenario.Config, scenario.SetupWorkflows); err != nil {
return fmt.Errorf("failed to stage repo: %w", err)
}

Expand Down
24 changes: 5 additions & 19 deletions e2e/scenarios/17-validate-callback.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ description: |

Generator-output verification only.

The validate callback (validate.yaml) is a reusable workflow the generator
reads at generation time to discover its inputs and emit the gate. The harness
seeds a workflow_call stub for it from config.validate.workflow before
generation runs, so the referenced file is on disk when the generator reads it.

config:
trunk_branch: main
environments: [dev]
Expand All @@ -29,25 +34,6 @@ steps:
src/app.go: |
package main
func main() {}
# Reusable validate callback the generated orchestrate.yaml invokes as a
# uses: job. Its inner job echoes "validate" so the gate has a real job to
# wait on; the orchestrate build jobs gate on needs.validate.result.
.github/workflows/validate.yaml: |
name: validate
on:
workflow_call:
inputs:
environment:
required: false
type: string
sha:
required: false
type: string
jobs:
runvalidate:
runs-on: ubuntu-latest
steps:
- run: echo "validate"
expect:
workflow_files:
- path: ".github/workflows/orchestrate.yaml"
Expand Down
106 changes: 59 additions & 47 deletions e2e/scenarios/rollback/rollback-failed-deploy-no-state-change.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,23 +7,24 @@ description: |
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
The 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 a reusable workflow whose inner job runs under act (no actions/
checkout, which act cannot resolve for a reusable callback against the
per-scenario gitea), and is asserted as appdeploy: failure. The deploy keys its
conditional failure on $GITHUB_WORKFLOW, which inside a reusable callback is the
invoking (caller) workflow's name: the generated promote workflow sets
name: Promote and the rollback workflow sets name: Rollback, and the harness
suffixes each 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.
per-scenario gitea), and is asserted as appdeploy: failure. Inside a reusable
workflow_call callback, $GITHUB_WORKFLOW is the callee's own name, not the
caller's, on act and on real GitHub alike, so the caller workflow name cannot
tell the rollback re-deploy from a setup promote (both also run under
workflow_dispatch). The harness instead sets CASCADE_E2E_ROLLBACK=1 only on the
rollback dispatch, and act passes that top-level env into the reusable callee,
so the deploy fails only on the rollback re-deploy. The two setup promotes
succeed, exercising the gateOnDeployResults guard rather than aborting setup.

config:
trunk_branch: main
Expand All @@ -37,16 +38,58 @@ config:
# Rollback workflow's re-deploy. Both workflows thread the same with: inputs
# (environment, sha) into this callback, 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, the caller's name inside a reusable
# callback) does: it is "Promote" during the two setup promotions and
# "Rollback" during the re-deploy under test. Failing only on Rollback forces
# appdeploy to fail there, so finalize sees DEPLOY_RESULT_APP=failure and
# aborts the state write, while setup lands prod on commit1 then commit2
# cleanly. act keys it by the inner job id appdeploy.
# callee cannot read the caller's workflow name ($GITHUB_WORKFLOW resolves to
# the callee inside a reusable callback), so the harness sets the
# CASCADE_E2E_ROLLBACK env only on the rollback dispatch. Failing only when it
# is set forces appdeploy to fail on the rollback re-deploy, so finalize sees
# DEPLOY_RESULT_APP=failure and aborts the state write, while setup lands prod
# on commit1 then commit2 cleanly. act keys it by the inner job id appdeploy.
- name: app
workflow: .github/workflows/deploy-app.yaml
triggers: ["**"]

# Seed the failing deploy callback into the setup commit BEFORE generation, so
# the generator reads this body (not the generic harness stub) when it resolves
# deploy-app.yaml. Staging it via step 1's commit.files would land after
# generation and the generator would never see it, leaving the generic stub that
# always succeeds in place and the rollback re-deploy would not fail. The inner
# job is appdeploy (matched by expect.jobs below). It succeeds while the caller
# workflow is Promote and exits non-zero only when the caller is Rollback, keyed
# on $GITHUB_WORKFLOW, which inside a reusable callback is the caller's name.
setup_workflows:
.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:
- env:
DEPLOY_ENV: ${{ inputs.environment }}
DEPLOY_SHA: ${{ inputs.sha }}
run: |
echo "deploy of env=$DEPLOY_ENV sha=$DEPLOY_SHA rollback=${CASCADE_E2E_ROLLBACK:-0}"
# Inside a reusable workflow_call callback, $GITHUB_WORKFLOW is the
# callee's own name (deploy-app), not the caller's, on act and on
# real GitHub alike, so the caller name cannot tell rollback from
# promote. The harness instead sets CASCADE_E2E_ROLLBACK=1 only on
# the rollback dispatch (act passes top-level --env into reusable
# callees), so the rollback re-deploy fails while the two setup
# promotes succeed.
if [ "${CASCADE_E2E_ROLLBACK:-0}" = "1" ]; then
echo "failing the rollback re-deploy on purpose"
exit 1
fi
echo "promote deploy succeeded"

steps:
- name: "Commit the first version source"
action: commit
Expand All @@ -56,37 +99,6 @@ steps:
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:
- env:
DEPLOY_ENV: ${{ inputs.environment }}
DEPLOY_SHA: ${{ inputs.sha }}
run: |
echo "deploy of env=$DEPLOY_ENV sha=$DEPLOY_SHA via workflow=$GITHUB_WORKFLOW"
# GITHUB_WORKFLOW inside a reusable callback is the caller
# workflow's name. The harness suffixes each with
# [scenario-<tag>], so the Rollback workflow surfaces it as
# "Rollback [scenario-...]". Match the Rollback prefix.
case "$GITHUB_WORKFLOW" in
Rollback*)
echo "failing the rollback re-deploy on purpose"
exit 1
;;
esac
echo "promote deploy succeeded"

- name: "Orchestrate the first commit into dev"
action: orchestrate
Expand Down
Loading