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
95 changes: 95 additions & 0 deletions e2e/harness/component_promote_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -196,3 +196,98 @@ func TestRunner_AssertStep_ComponentState_Unchanged(t *testing.T) {
assert.Len(t, errs, 1)
assert.Contains(t, errs[0].Error(), "components/web/dev")
}

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

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

// TestEnvBranchName verifies the integration-branch namespace: single-component
// stays flat env/<env> (byte-identical), a component nests env/<component>/<env>
// so two components' branches never collide.
func TestEnvBranchName(t *testing.T) {
assert.Equal(t, "env/prod", envBranchName("", "prod"))
assert.Equal(t, "env/api/prod", envBranchName("api", "prod"))
assert.Equal(t, "env/web/prod", envBranchName("web", "prod"))
assert.NotEqual(t, envBranchName("api", "prod"), envBranchName("web", "prod"))
}

// TestHotfixBranchName verifies the throwaway cherry-pick branch namespace:
// single-component stays hotfix/<env>/<short> (byte-identical), a component nests
// hotfix/<component>/<env>/<short> so two components hotfixing the same env at the
// same source commit push disjoint branches and never collide.
func TestHotfixBranchName(t *testing.T) {
assert.Equal(t, "hotfix/prod/abc12345", hotfixBranchName("", "prod", "abc12345"))
assert.Equal(t, "hotfix/api/prod/abc12345", hotfixBranchName("api", "prod", "abc12345"))
// The exact collision the generator fix closes: same env, same source short SHA,
// two components -> two distinct branches.
assert.NotEqual(t,
hotfixBranchName("api", "prod", "abc12345"),
hotfixBranchName("web", "prod", "abc12345"))
}

// TestParseComponentStates_Divergence confirms the component subtree parse reads
// the hotfix/rollback divergence fields (ref/base_sha/patches) so a per-component
// lifecycle scenario can assert a hotfixed component tracks its own env branch.
func TestParseComponentStates_Divergence(t *testing.T) {
manifest := `ci:
state:
components:
api:
prod:
sha: apiprodsha
version: api-v0.1.0
ref: env/api/prod
base_sha: apibasesha
patches:
- apipatchsha
web:
prod:
sha: webprodsha
version: web-v0.1.0
`
components, err := parseComponentStates(manifest, "ci")
require.NoError(t, err)
assert.Equal(t, "env/api/prod", components["api"]["prod"].Ref)
assert.Equal(t, "apibasesha", components["api"]["prod"].BaseSHA)
assert.Equal(t, []string{"apipatchsha"}, components["api"]["prod"].Patches)
// The undiverged sibling carries no divergence fields.
assert.Empty(t, components["web"]["prod"].Ref)
assert.Empty(t, components["web"]["prod"].Patches)
}
74 changes: 58 additions & 16 deletions e2e/harness/hotfix_actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,41 @@ import (
"time"
)

// hotfixWorkflowPath is the generated hotfix workflow's path inside the repo.
const hotfixWorkflowPath = ".github/workflows/cascade-hotfix.yaml"
// hotfixWorkflowPath returns the generated hotfix workflow's path inside the
// repo for a component. An empty component selects the repo-wide
// cascade-hotfix.yaml (byte-identical single-component behavior); a named
// component selects the fanned-out cascade-hotfix-<name>.yaml the generator emits
// for a manifest with a components: block. It mirrors promoteWorkflowPath.
func hotfixWorkflowPath(component string) string {
if component == "" {
return ".github/workflows/cascade-hotfix.yaml"
}
return ".github/workflows/cascade-hotfix-" + component + ".yaml"
}

// envBranchName returns the per-environment integration branch a hotfix operates
// on. The default (empty) component yields env/<env>, byte-identical to the
// historical single-component form; a named component yields env/<component>/<env>
// so each component's integration branches occupy a disjoint namespace. It
// mirrors hotfix.EnvBranchName in internal/hotfix/lifecycle.go.
func envBranchName(component, env string) string {
if component == "" {
return "env/" + env
}
return "env/" + component + "/" + env
}

// hotfixBranchName returns the throwaway cherry-pick branch a hotfix apply pushes.
// The default component yields hotfix/<env>/<short>, byte-identical to the
// historical form; a named component yields hotfix/<component>/<env>/<short> so
// two components cherry-picking the same env at the same source commit push
// disjoint branches. It mirrors the generator's hotfixBranchPrefix + envBranchRef.
func hotfixBranchName(component, env, short string) string {
if component == "" {
return "hotfix/" + env + "/" + short
}
return "hotfix/" + component + "/" + env + "/" + short
}

// resolveCommit resolves a commit reference to its SHA via the execution
// context, falling back to treating the reference as a literal SHA.
Expand Down Expand Up @@ -104,7 +137,7 @@ func (r *Runner) executeHotfixPlan(ctx context.Context, step *HotfixPlanStep) er
}

result, err := r.harness.act.RunWorkflowFromRepo(ctx, RunOpts{
WorkflowPath: hotfixWorkflowPath,
WorkflowPath: hotfixWorkflowPath(step.Component),
Event: "workflow_dispatch",
Inputs: map[string]string{
"commit": sha,
Expand Down Expand Up @@ -165,16 +198,23 @@ func (r *Runner) executeHotfixPlan(ctx context.Context, step *HotfixPlanStep) er
// conflict into an empty (clean) cherry-pick and flipping the resulting PR label
// run-to-run. An empty resolution returns an error so the race surfaces loudly
// instead of being masked by a non-deterministic anchor.
func (r *Runner) resolveEnvAnchor(env, baseRef string) (string, error) {
func (r *Runner) resolveEnvAnchor(component, env, baseRef string) (string, error) {
if baseRef != "" {
if anchor := r.resolveCommit(baseRef); anchor != "" {
return anchor, nil
}
}
if anchor := r.ctx.GetState(env).SHA; anchor != "" {
// For a component the recorded state lives under the composite key
// components/<component>/<env>; the single-component path keeps the flat env
// key, so the fallback tracks whichever namespace the hotfix targets.
stateKey := env
if component != "" {
stateKey = componentStateKey(component, env)
}
if anchor := r.ctx.GetState(stateKey).SHA; anchor != "" {
return anchor, nil
}
return "", fmt.Errorf("hotfix_apply: cannot anchor env/%s: no base_ref given and recorded state SHA for %q is empty (likely a gitea state sync race); pin the scenario step's base_ref to make the cherry-pick outcome deterministic", env, env)
return "", fmt.Errorf("hotfix_apply: cannot anchor %s: no base_ref given and recorded state SHA for %q is empty (likely a gitea state sync race); pin the scenario step's base_ref to make the cherry-pick outcome deterministic", envBranchName(component, env), stateKey)
}

// executeHotfixApply performs a harness-driven cherry-pick of a trunk commit onto
Expand All @@ -196,10 +236,11 @@ func (r *Runner) executeHotfixApply(ctx context.Context, step *HotfixApplyStep)
commits := strings.Split(commitList, ",")
commit := commits[0]
env := step.TargetEnv
envBranch := "env/" + env
component := step.Component
envBranch := envBranchName(component, env)
short := shortSHA(commit)
hotfixBranch := "hotfix/" + env + "/" + short
r.t.Logf(" HotfixApply: commits=%s env=%s branch=%s", commitList, env, hotfixBranch)
hotfixBranch := hotfixBranchName(component, env, short)
r.t.Logf(" HotfixApply: component=%q commits=%s env=%s branch=%s", component, commitList, env, hotfixBranch)

// Determine whether env/<env> already exists so we know whether to seed it.
branches, err := r.harness.gitea.ListBranches(ctx, r.harness.repo)
Expand All @@ -214,7 +255,7 @@ func (r *Runner) executeHotfixApply(ctx context.Context, step *HotfixApplyStep)
// anchor is unresolvable, surfacing sync races loudly.
var anchorSHA string
if needsSeedEnvBranch {
anchorSHA, err = r.resolveEnvAnchor(env, step.BaseRef)
anchorSHA, err = r.resolveEnvAnchor(component, env, step.BaseRef)
if err != nil {
return err
}
Expand Down Expand Up @@ -347,6 +388,7 @@ func (r *Runner) executeHotfixApply(ctx context.Context, step *HotfixApplyStep)
r.lastPRConflict = conflict
r.lastHotfixBranch = hotfixBranch
r.lastHotfixEnv = env
r.lastHotfixComponent = component
r.lastHotfixBody = body
if r.prByLabel == nil {
r.prByLabel = make(map[string]int64)
Expand Down Expand Up @@ -389,10 +431,10 @@ func (r *Runner) executeMergePR(ctx context.Context, step *MergePRStep) error {
// branch back, so this SHA is not an ancestor of any trunk commit. Scenarios
// reference it as an off-trunk patch to exercise the patch-containment guard.
if r.lastHotfixEnv != "" {
envBranch := "env/" + r.lastHotfixEnv
envBranch := envBranchName(r.lastHotfixComponent, r.lastHotfixEnv)
if branchSHA, err := r.harness.gitea.GetBranchSHA(ctx, r.harness.repo, envBranch); err == nil {
r.ctx.RecordCommit("hotfix_head", branchSHA)
r.t.Logf(" MergePR: recorded hotfix_head=%s (post-merge env/%s tip)", truncateSHA(branchSHA), r.lastHotfixEnv)
r.t.Logf(" MergePR: recorded hotfix_head=%s (post-merge %s tip)", truncateSHA(branchSHA), envBranch)
}
}
return nil
Expand Down Expand Up @@ -425,7 +467,7 @@ func (r *Runner) executeResolveConflict(ctx context.Context, step *ResolveConfli
"pull_request": map[string]any{
"number": r.lastPRIndex,
"merged": false,
"base": map[string]any{"ref": "env/" + r.lastHotfixEnv},
"base": map[string]any{"ref": envBranchName(r.lastHotfixComponent, r.lastHotfixEnv)},
"head": map[string]any{"ref": r.lastHotfixBranch},
"labels": []map[string]any{{"name": "cascade-hotfix-conflict"}},
},
Expand All @@ -436,7 +478,7 @@ func (r *Runner) executeResolveConflict(ctx context.Context, step *ResolveConfli
}

result, err := r.harness.act.RunWorkflowFromRepo(ctx, RunOpts{
WorkflowPath: hotfixWorkflowPath,
WorkflowPath: hotfixWorkflowPath(r.lastHotfixComponent),
Event: "pull_request",
EventJSON: string(eventJSON),
Env: r.repoEnv(),
Expand Down Expand Up @@ -465,7 +507,7 @@ func (r *Runner) executeHotfixMerged(ctx context.Context, step *HotfixMergedStep
}

env := step.TargetEnv
envBranch := "env/" + env
envBranch := envBranchName(step.Component, env)

// The squash merge advanced env/<env>; its tip is the merge commit SHA.
mergeSHA, err := r.harness.gitea.GetBranchSHA(ctx, r.harness.repo, envBranch)
Expand Down Expand Up @@ -496,7 +538,7 @@ func (r *Runner) executeHotfixMerged(ctx context.Context, step *HotfixMergedStep

r.t.Logf(" HotfixMerged: replaying merged PR #%d for %s (merge_sha=%s)", r.lastPRIndex, env, truncateSHA(mergeSHA))
result, err := r.harness.act.RunWorkflowFromRepo(ctx, RunOpts{
WorkflowPath: hotfixWorkflowPath,
WorkflowPath: hotfixWorkflowPath(step.Component),
Event: "pull_request",
EventJSON: string(eventJSON),
Env: r.repoEnv(),
Expand Down
19 changes: 15 additions & 4 deletions e2e/harness/hotfix_actions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -309,29 +309,40 @@ func TestResolveEnvAnchor(t *testing.T) {
r.ctx.RecordCommit("commit1", "base1111aaaa")
r.ctx.RecordState("test", "tip2222bbbb", "v0.1.0")

anchor, err := r.resolveEnvAnchor("test", "commit1")
anchor, err := r.resolveEnvAnchor("", "test", "commit1")
require.NoError(t, err)
assert.Equal(t, "base1111aaaa", anchor, "base_ref must take precedence over the recorded state SHA")
})

t.Run("base_ref literal SHA when not a known reference", func(t *testing.T) {
r := NewRunner(t, nil)
anchor, err := r.resolveEnvAnchor("test", "literalsha9999")
anchor, err := r.resolveEnvAnchor("", "test", "literalsha9999")
require.NoError(t, err)
assert.Equal(t, "literalsha9999", anchor)
})

t.Run("falls back to recorded state SHA when no base_ref", func(t *testing.T) {
r := NewRunner(t, nil)
r.ctx.RecordState("test", "tip2222bbbb", "v0.1.0")
anchor, err := r.resolveEnvAnchor("test", "")
anchor, err := r.resolveEnvAnchor("", "test", "")
require.NoError(t, err)
assert.Equal(t, "tip2222bbbb", anchor)
})

t.Run("component falls back to its composite-key state SHA", func(t *testing.T) {
r := NewRunner(t, nil)
// The flat env key must be ignored for a component apply; only the
// components/<name>/<env> composite key seeds the anchor.
r.ctx.RecordState("prod", "flataaaa0000", "v0.1.0")
r.ctx.RecordState(componentStateKey("api", "prod"), "apitip111", "api-0.1.0")
anchor, err := r.resolveEnvAnchor("api", "prod", "")
require.NoError(t, err)
assert.Equal(t, "apitip111", anchor, "a component apply must anchor on its own subtree, not the flat env row")
})

t.Run("errors when no base_ref and state SHA empty (no trunk-HEAD fallback)", func(t *testing.T) {
r := NewRunner(t, nil)
anchor, err := r.resolveEnvAnchor("test", "")
anchor, err := r.resolveEnvAnchor("", "test", "")
require.Error(t, err)
assert.Empty(t, anchor)
assert.Contains(t, err.Error(), "base_ref")
Expand Down
24 changes: 24 additions & 0 deletions e2e/harness/multistep.go
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,12 @@ 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 {
// Component, when set, targets the per-component hotfix workflow
// .github/workflows/cascade-hotfix-<Component>.yaml (emitted for a manifest
// with a components: block) instead of the repo-wide cascade-hotfix.yaml, so a
// scenario can drive one component's hotfix plan without touching a sibling.
// Empty selects the repo-wide file, byte-identical to today.
Component string `yaml:"component,omitempty"`
CommitRef string `yaml:"commit_ref"`
TargetEnv string `yaml:"target_env"`
DryRun bool `yaml:"dry_run,omitempty"`
Expand All @@ -153,6 +159,12 @@ type HotfixPlanStep struct {
// the anchor rather than depend on the synced state SHA, which a gitea
// state-propagation race can momentarily report empty.
type HotfixApplyStep struct {
// Component, when set, scopes the cherry-pick to the component's own
// integration-branch namespace: the apply seeds and targets env/<Component>/<env>
// and pushes a hotfix/<Component>/<env>/<short> branch, so two components
// hotfixing the same env at the same source commit never collide. Empty selects
// the flat env/<env> and hotfix/<env>/<short> forms, byte-identical to today.
Component string `yaml:"component,omitempty"`
TargetEnv string `yaml:"target_env"`
CommitRef string `yaml:"commit_ref"`
BaseRef string `yaml:"base_ref,omitempty"`
Expand All @@ -174,6 +186,12 @@ type ResolveConflictStep struct {
// HotfixMergedStep defines a hotfix_merged action: replay of the merged
// pull_request event for the recorded hotfix PR of TargetEnv.
type HotfixMergedStep struct {
// Component, when set, replays the merged event against the per-component
// hotfix workflow (cascade-hotfix-<Component>.yaml) with the merged PR's base
// on env/<Component>/<TargetEnv>, so finalize records the diverged state under
// state.components.<Component>.<TargetEnv>. Empty selects the repo-wide file and
// the flat env branch, byte-identical to today.
Component string `yaml:"component,omitempty"`
TargetEnv string `yaml:"target_env"`
}

Expand Down Expand Up @@ -253,6 +271,12 @@ type PromoteStep struct {
// substring, so a scenario can prove the run failed for the expected reason (for
// example the first-environment guard message) rather than an unrelated fault.
type RollbackStep struct {
// Component, when set, targets the per-component rollback workflow
// .github/workflows/cascade-rollback-<Component>.yaml (emitted for a manifest
// with a components: block) instead of the repo-wide cascade-rollback.yaml, so a
// scenario can roll back one component reading and writing only its own state
// subtree. Empty selects the repo-wide file, byte-identical to today.
Component string `yaml:"component,omitempty"`
Environment string `yaml:"environment"`
Target string `yaml:"target,omitempty"`
Deployable string `yaml:"deployable,omitempty"`
Expand Down
19 changes: 14 additions & 5 deletions e2e/harness/rollback_actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,17 @@ import (
"strings"
)

// rollbackWorkflowPath is the generated rollback workflow's path inside the repo.
const rollbackWorkflowPath = ".github/workflows/cascade-rollback.yaml"
// rollbackWorkflowPath returns the generated rollback workflow's path inside the
// repo for a component. An empty component selects the repo-wide
// cascade-rollback.yaml (byte-identical single-component behavior); a named
// component selects the fanned-out cascade-rollback-<name>.yaml the generator
// emits for a manifest with a components: block. It mirrors promoteWorkflowPath.
func rollbackWorkflowPath(component string) string {
if component == "" {
return ".github/workflows/cascade-rollback.yaml"
}
return ".github/workflows/cascade-rollback-" + component + ".yaml"
}

// executeRollback dispatches the cascade-rollback workflow for an environment.
// It mirrors executePromote: it builds the workflow_dispatch inputs (omitting
Expand All @@ -30,8 +39,8 @@ func (r *Runner) executeRollback(ctx context.Context, rollback *RollbackStep, co
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)
r.t.Logf(" Rollback: running %s (env=%s, target=%s, deployable=%s, dry_run=%s)",
rollbackWorkflowPath(rollback.Component), rollback.Environment, rollback.Target, rollback.Deployable, dryRun)

// Sync the repo to act container before running workflow.
if err := r.harness.SyncRepoToActContainer(ctx); err != nil {
Expand Down Expand Up @@ -62,7 +71,7 @@ func (r *Runner) executeRollback(ctx context.Context, rollback *RollbackStep, co
}

result, err := r.harness.act.RunWorkflowFromRepo(ctx, RunOpts{
WorkflowPath: rollbackWorkflowPath,
WorkflowPath: rollbackWorkflowPath(rollback.Component),
Event: "workflow_dispatch",
Inputs: inputs,
Env: map[string]string{
Expand Down
Loading