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
198 changes: 198 additions & 0 deletions e2e/harness/component_promote_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
package harness

import (
"context"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// TestPromoteWorkflowPath verifies the promote workflow path selection: an empty
// component keeps the repo-wide promote.yaml (byte-identical single-component
// behavior), while a named component targets its fanned-out promote-<name>.yaml.
func TestPromoteWorkflowPath(t *testing.T) {
cases := []struct {
name string
component string
want string
}{
{name: "single component keeps repo-wide file", component: "", want: ".github/workflows/promote.yaml"},
{name: "named component selects fanned-out file", component: "api", want: ".github/workflows/promote-api.yaml"},
{name: "second component selects its own file", component: "web", want: ".github/workflows/promote-web.yaml"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.want, promoteWorkflowPath(tc.component))
})
}
}

// TestOrchestrateWorkflowPath verifies the orchestrate workflow path selection
// mirrors promote: empty keeps orchestrate.yaml, a component selects its
// orchestrate-<name>.yaml.
func TestOrchestrateWorkflowPath(t *testing.T) {
cases := []struct {
name string
component string
want string
}{
{name: "single component keeps repo-wide file", component: "", want: ".github/workflows/orchestrate.yaml"},
{name: "named component selects fanned-out file", component: "api", want: ".github/workflows/orchestrate-api.yaml"},
{name: "second component selects its own file", component: "web", want: ".github/workflows/orchestrate-web.yaml"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.want, orchestrateWorkflowPath(tc.component))
})
}
}

// TestComponentStateKey verifies the composite key is distinct per (component, env)
// and never collides with a flat env key.
func TestComponentStateKey(t *testing.T) {
assert.Equal(t, "components/api/dev", componentStateKey("api", "dev"))
assert.Equal(t, "components/api/prod", componentStateKey("api", "prod"))
assert.Equal(t, "components/web/prod", componentStateKey("web", "prod"))
// Distinct components at the same env must not collide.
assert.NotEqual(t, componentStateKey("api", "prod"), componentStateKey("web", "prod"))
// The composite key is never a bare env name, so a component-scoped record can
// never overwrite a flat state.<env> record.
assert.NotEqual(t, "prod", componentStateKey("api", "prod"))
}

// TestParseComponentStates parses a manifest carrying both flat env rows and a
// per-component subtree, asserting only the component rows are returned and the
// flat rows are ignored (they are handled by the flat parse).
func TestParseComponentStates(t *testing.T) {
manifest := `ci:
config:
trunk_branch: main
state:
dev:
sha: flatdevsha
version: v0.1.0-rc.0
components:
api:
dev:
sha: apidevsha
version: api-v0.1.0-rc.0
prod:
sha: apiprodsha
version: api-v0.1.0
deploys:
app:
sha: apideploysha
web:
dev:
sha: webdevsha
version: web-v0.1.0-rc.0
`
components, err := parseComponentStates(manifest, "ci")
require.NoError(t, err)
require.Len(t, components, 2)

assert.Equal(t, "apidevsha", components["api"]["dev"].SHA)
assert.Equal(t, "api-v0.1.0-rc.0", components["api"]["dev"].Version)
assert.Equal(t, "apiprodsha", components["api"]["prod"].SHA)
assert.Equal(t, "api-v0.1.0", components["api"]["prod"].Version)
assert.Equal(t, "apideploysha", components["api"]["prod"].Deploys["app"].SHA)
assert.Equal(t, "webdevsha", components["web"]["dev"].SHA)

// The flat "dev" row is not surfaced as a component.
_, ok := components["dev"]
assert.False(t, ok, "flat env row must not be parsed as a component")
}

// TestParseComponentStates_NoComponents confirms a flat manifest yields an empty
// component map and no error, so a single-component scenario's readback is a no-op.
func TestParseComponentStates_NoComponents(t *testing.T) {
manifest := `ci:
state:
dev:
sha: devsha
version: v0.1.0-rc.0
prod:
sha: prodsha
version: v0.1.0
`
components, err := parseComponentStates(manifest, "ci")
require.NoError(t, err)
assert.Empty(t, components)
}

// TestParseComponentStates_MissingKey confirms a manifest without the requested
// top-level key yields an empty map rather than an error.
func TestParseComponentStates_MissingKey(t *testing.T) {
components, err := parseComponentStates("other:\n state: {}\n", "ci")
require.NoError(t, err)
assert.Empty(t, components)
}

// TestRunner_AssertStep_ComponentState_Isolation drives the component-scoped
// assertion branch: with only component A's prod subtree recorded, an expectation
// that A.prod advanced AND B.prod is absent (wiped) must pass, proving the
// component-scoped lookup targets the composite key rather than the flat env.
func TestRunner_AssertStep_ComponentState_Isolation(t *testing.T) {
runner := &Runner{ctx: NewExecutionContext(), t: t}
runner.ctx.RecordCommit("commit1", "abc123")
// Only component A advanced to prod; component B has no prod row.
runner.ctx.RecordState(componentStateKey("api", "prod"), "abc123", "api-v0.1.0")

step := &Step{
Name: "assert per-component isolation",
Action: "promote",
Expect: &StepExpect{
State: map[string]*StateExpect{
// A advanced at its own prod subtree.
"api-prod": {Component: "api", Env: "prod", SHA: "commit1", Version: "api-v0.1.0"},
// B's prod subtree is untouched (absent). A distinct map key plus an
// explicit env lets both components be asserted at the same env in one
// step, which an env-keyed map alone cannot express.
"web-prod": {Component: "web", Env: "prod", Wiped: true},
},
},
}

ctx := context.Background()
preState := runner.ctx.Clone()
errs := runner.assertStep(ctx, step, preState)
assert.Empty(t, errs)

// Recording B's prod subtree must now break the "wiped" expectation, proving
// the sibling assertion is really scoped to component B and not component A.
runner.ctx.RecordState(componentStateKey("web", "prod"), "def456", "web-v0.1.0")
errs = runner.assertStep(ctx, step, preState)
assert.Len(t, errs, 1)
assert.Contains(t, errs[0].Error(), "components/web/prod")
}

// TestRunner_AssertStep_ComponentState_Unchanged verifies the "unchanged"
// expectation resolves the component composite key: component B's dev subtree must
// read as unchanged across a step that only advanced component A.
func TestRunner_AssertStep_ComponentState_Unchanged(t *testing.T) {
runner := &Runner{ctx: NewExecutionContext(), t: t}
runner.ctx.RecordState(componentStateKey("web", "dev"), "webdev", "web-v0.1.0-rc.0")

preState := runner.ctx.Clone()

step := &Step{
Name: "assert sibling unchanged",
Action: "promote",
Expect: &StepExpect{
State: map[string]*StateExpect{
"dev": {Component: "web", Unchanged: true},
},
},
}

ctx := context.Background()
errs := runner.assertStep(ctx, step, preState)
assert.Empty(t, errs)

// Mutating component B's dev subtree must now trip the unchanged assertion.
runner.ctx.RecordState(componentStateKey("web", "dev"), "webdev2", "web-v0.1.0-rc.1")
errs = runner.assertStep(ctx, step, preState)
assert.Len(t, errs, 1)
assert.Contains(t, errs[0].Error(), "components/web/dev")
}
41 changes: 37 additions & 4 deletions e2e/harness/multistep.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,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"`
// Orchestrate optionally configures an "orchestrate" action. It is not
// required: an orchestrate step with no config runs the repo-wide
// orchestrate.yaml exactly as before. It exists so a component: manifest,
// whose orchestrate lane is fanned out into one orchestrate-<name>.yaml per
// component, can seed a specific component's version line independently.
Orchestrate *OrchestrateStep `yaml:"orchestrate,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
Expand Down Expand Up @@ -125,10 +131,10 @@ type Step struct {
// workflow's plan job. CommitRef is the trunk commit to plan a hotfix for and is
// resolved via the execution context (falling back to a literal SHA).
type HotfixPlanStep struct {
CommitRef string `yaml:"commit_ref"`
TargetEnv string `yaml:"target_env"`
DryRun bool `yaml:"dry_run,omitempty"`
ExpectFailure bool `yaml:"expect_failure,omitempty"`
CommitRef string `yaml:"commit_ref"`
TargetEnv string `yaml:"target_env"`
DryRun bool `yaml:"dry_run,omitempty"`
ExpectFailure bool `yaml:"expect_failure,omitempty"`
// AssertBranchReset, when true, asserts that the plan workflow logged the
// orphan self-heal diagnostic line (branch_reset=true), confirming the heal
// fired rather than the plan merely succeeding for another reason.
Expand Down Expand Up @@ -192,9 +198,25 @@ type CommitStep struct {
Files map[string]string `yaml:"files"`
}

// OrchestrateStep configures an "orchestrate" action. Component, when set,
// targets the per-component orchestrate workflow
// .github/workflows/orchestrate-<Component>.yaml (emitted for a manifest with a
// components: block) instead of the repo-wide orchestrate.yaml, so a scenario can
// seed one component's version line without touching a sibling. Empty selects the
// repo-wide orchestrate.yaml, byte-identical to an orchestrate step with no config.
type OrchestrateStep struct {
Component string `yaml:"component,omitempty"`
}

// PromoteStep defines a promote action
type PromoteStep struct {
Mode string `yaml:"mode"` // default, cascade
// Component, when set, targets the per-component promote workflow
// .github/workflows/promote-<Component>.yaml (emitted for a manifest with a
// components: block) instead of the repo-wide promote.yaml, and that
// component's promotion records state under state.components.<Component>.<env>.
// Empty selects the single-component promote.yaml, byte-identical to today.
Component string `yaml:"component,omitempty"`
// 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"`
Expand Down Expand Up @@ -379,6 +401,17 @@ type WorkflowFileExpect struct {

// StateExpect defines expected state for an environment
type StateExpect struct {
// Component, when set, scopes this expectation to a declared component's state
// subtree at state.components.<Component>.<Env> rather than the flat
// state.<env>. It is how a scenario asserts per-component promotion isolation:
// component A advancing must leave component B's subtree byte-intact.
Component string `yaml:"component,omitempty"`
// Env names the environment within the component subtree when Component is set.
// When empty it defaults to the map key this expectation is filed under, so a
// component-free scenario (the common case) never sets it and is unaffected.
// It exists only to let a single step assert two components at the SAME env,
// which the env-keyed map alone cannot express.
Env string `yaml:"env,omitempty"`
SHA string `yaml:"sha,omitempty"` // Can be "commit1", "commit2", etc.
Version string `yaml:"version,omitempty"`
Wiped bool `yaml:"wiped,omitempty"` // State should not exist
Expand Down
Loading