diff --git a/e2e/harness/component_promote_test.go b/e2e/harness/component_promote_test.go index f4ab58e3..02cbe4ac 100644 --- a/e2e/harness/component_promote_test.go +++ b/e2e/harness/component_promote_test.go @@ -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-.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-.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/ (byte-identical), a component nests 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// (byte-identical), a component nests +// hotfix/// 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) +} diff --git a/e2e/harness/hotfix_actions.go b/e2e/harness/hotfix_actions.go index 785bd4e8..c6faf8df 100644 --- a/e2e/harness/hotfix_actions.go +++ b/e2e/harness/hotfix_actions.go @@ -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-.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/, byte-identical to the +// historical single-component form; a named component yields 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//, byte-identical to the +// historical form; a named component yields hotfix/// 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. @@ -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, @@ -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//; 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 @@ -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/ already exists so we know whether to seed it. branches, err := r.harness.gitea.ListBranches(ctx, r.harness.repo) @@ -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 } @@ -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) @@ -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 @@ -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"}}, }, @@ -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(), @@ -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/; its tip is the merge commit SHA. mergeSHA, err := r.harness.gitea.GetBranchSHA(ctx, r.harness.repo, envBranch) @@ -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(), diff --git a/e2e/harness/hotfix_actions_test.go b/e2e/harness/hotfix_actions_test.go index 09f3c0ae..6a609e1e 100644 --- a/e2e/harness/hotfix_actions_test.go +++ b/e2e/harness/hotfix_actions_test.go @@ -309,14 +309,14 @@ 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) }) @@ -324,14 +324,25 @@ func TestResolveEnvAnchor(t *testing.T) { 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// 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") diff --git a/e2e/harness/multistep.go b/e2e/harness/multistep.go index ca5ab9b6..8475d63e 100644 --- a/e2e/harness/multistep.go +++ b/e2e/harness/multistep.go @@ -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-.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"` @@ -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// + // and pushes a hotfix/// branch, so two components + // hotfixing the same env at the same source commit never collide. Empty selects + // the flat env/ and hotfix// 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"` @@ -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-.yaml) with the merged PR's base + // on env//, so finalize records the diverged state under + // state.components... 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"` } @@ -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-.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"` diff --git a/e2e/harness/rollback_actions.go b/e2e/harness/rollback_actions.go index 6e17b978..87e1b9c4 100644 --- a/e2e/harness/rollback_actions.go +++ b/e2e/harness/rollback_actions.go @@ -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-.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 @@ -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 { @@ -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{ diff --git a/e2e/harness/runner.go b/e2e/harness/runner.go index efc895da..486126d7 100644 --- a/e2e/harness/runner.go +++ b/e2e/harness/runner.go @@ -20,12 +20,13 @@ type Runner struct { lastWorkflowResult *ExtendedWorkflowResult // Hotfix apply bookkeeping, carried across steps so merge_pr, // resolve_conflict, and hotfix_merged can act on the most recent apply. - lastPRIndex int64 // PR index opened by the most recent hotfix_apply - lastPRConflict bool // whether that apply hit a cherry-pick conflict - lastHotfixBranch string // head branch of that apply - lastHotfixEnv string // target env of that apply - lastHotfixBody string // PR body (with trailers) of that apply - prByLabel map[string]int64 // label -> most recent PR index opened with it + lastPRIndex int64 // PR index opened by the most recent hotfix_apply + lastPRConflict bool // whether that apply hit a cherry-pick conflict + lastHotfixBranch string // head branch of that apply + lastHotfixEnv string // target env of that apply + lastHotfixComponent string // component of that apply ("" for single-component) + lastHotfixBody string // PR body (with trailers) of that apply + prByLabel map[string]int64 // label -> most recent PR index opened with it } // NewRunner creates a new scenario runner @@ -714,6 +715,13 @@ type componentEnvStateYAML struct { Deploys map[string]struct { SHA string `yaml:"sha"` } `yaml:"deploys"` + // Divergence fields written by the per-component hotfix and rollback finalize + // steps, mirroring the flat parse's ref/base_sha/patches. Component-scoped + // divergence nests under state.components.., so a scenario can assert + // a hotfixed component tracks env// while a sibling stays undiverged. + Ref string `yaml:"ref"` + BaseSHA string `yaml:"base_sha"` + Patches []string `yaml:"patches"` } // parseComponentStates extracts ci.state.components.. rows from a @@ -1380,6 +1388,15 @@ func (r *Runner) syncStateFromGitea(ctx context.Context, config Config) error { r.ctx.RecordState(key, st.SHA, st.Version) r.t.Logf(" Synced state.components[%s][%s] = %s @ %s", comp, env, truncateSHA(st.SHA), st.Version) + // Record component-scoped divergence so a per-component hotfix or + // rollback assertion (ref/base_sha/patches under the composite key) can + // observe the diverged component while a sibling's subtree stays + // undiverged. Mirrors the flat parse above. + if st.Ref != "" || st.BaseSHA != "" || len(st.Patches) > 0 { + r.ctx.RecordStateDivergence(key, st.Ref, st.BaseSHA, st.Patches, "") + r.t.Logf(" Synced state.components[%s][%s] divergence ref=%s base=%s patches=%d", + comp, env, st.Ref, truncateSHA(st.BaseSHA), len(st.Patches)) + } for deployName, deployState := range st.Deploys { r.ctx.RecordDeployState(key, deployName, deployState.SHA) r.t.Logf(" Synced state.components[%s][%s].deploys[%s] = %s", diff --git a/e2e/scenarios/54-component-hotfix-rollback-isolation.yaml b/e2e/scenarios/54-component-hotfix-rollback-isolation.yaml new file mode 100644 index 00000000..8ce9f3af --- /dev/null +++ b/e2e/scenarios/54-component-hotfix-rollback-isolation.yaml @@ -0,0 +1,289 @@ +name: "Per-Component Hotfix and Rollback Isolation" +description: | + Proves per-component hotfix and rollback execute end to end against only their + own namespace and never disturb a sibling (#296). The manifest declares two + components (api, web), each owning a path subtree with its own strict tag + namespace, promoting through the same dev to prod ladder on its own version line. + Generation fans the hotfix lane out to one cascade-hotfix-.yaml per + component and the rollback lane out to one cascade-rollback-.yaml per + component. This scenario executes a specific component's rollback and hotfix + workflows through act, which the generation-only component scenarios could not + yet do. + + Each component is seeded in its own commit and advanced to prod on its own + version line, so each component's prod state is anchored on a known commit. Then + api is exercised through a rollback and a full hotfix while web is asserted + byte-intact, and web is exercised through the mirror while api is asserted + byte-intact. The proof is that each component's rollback resolves its target from + its own recorded state subtree, each component's hotfix cherry-picks onto its own + env// integration branch and records divergence under only + state.components.., and neither lifecycle rebuilds, moves, or drops the + other's state.components. subtree or touches the sibling's integration + branch. + +config: + trunk_branch: main + environments: [dev, prod] + builds: + - name: app + workflow: build.yaml + triggers: ["services/**"] + deploys: + - name: app + workflow: deploy.yaml + triggers: ["services/**"] + components: + api: + path: services/api + tag_prefix: api- + web: + path: services/web + tag_prefix: web- + +steps: + - name: "Seed the api subtree" + action: commit + commit: + message: "feat: seed api source" + files: + services/api/main.go: | + package main + + func main() {} + + # Confirm the multi-component generate then verify roundtrip is drift-free, so + # the per-component workflows executed below are the pristine generated output. + - name: "Regenerate the per-component set and confirm no drift" + action: verify + verify: + regenerate: true + expect_exit: 0 + + # Advance api to prod on its own version line via its own orchestrate and promote + # workflows. api orchestrates at the seed commit, so its prod state is anchored on + # commit1. + - name: "Orchestrate api to cut its dev prerelease" + action: orchestrate + orchestrate: + component: api + + - name: "Promote api from dev to prod" + action: promote + promote: + mode: cascade + target: prod + component: api + expect: + state: + api-prod: + component: api + env: prod + sha: commit1 + version: "api-0.1.0" + + # Seed web in its own commit so web orchestrates at a known commit (commit2) + # rather than at a state-write commit left behind by api's promotion. + - name: "Seed the web subtree" + action: commit + commit: + message: "feat: seed web source" + files: + services/web/main.go: | + package main + + func main() {} + + - name: "Orchestrate web to cut its dev prerelease" + action: orchestrate + orchestrate: + component: web + + - name: "Promote web from dev to prod" + action: promote + promote: + mode: cascade + target: prod + component: web + expect: + state: + web-prod: + component: web + env: prod + sha: commit2 + version: "web-0.1.0" + api-prod: + component: api + env: prod + unchanged: true + + # --- Component api lifecycle; web must stay byte-intact throughout. --- + + # Dry-run rollback of api prod targeting its own live-state SHA (commit1). The + # per-component cascade-rollback-api.yaml preflight reads only + # state.components.api.prod, so it resolves the target from that component's own + # state (target_source=state). The dry_run flag suppresses the deploy and finalize + # jobs, so web's subtree cannot be touched; the assertion proves the rollback ran + # against api's namespace alone. + - name: "Rollback api prod resolves target from its own state" + action: rollback + rollback: + component: api + environment: prod + target: commit1 + dry_run: true + expect_source: state + expect: + state: + web-prod: + component: web + env: prod + unchanged: true + + - name: "Commit an api-only hotfix source" + action: commit + commit: + message: "fix: patch api" + files: + services/api/fix.go: | + package main + + // api patch applied + + - name: "Plan the api hotfix for prod" + action: hotfix_plan + hotfix_plan: + component: api + commit_ref: commit3 + target_env: prod + dry_run: true + + # Cherry-pick the api patch onto env/api/prod (seeded at api's own prod anchor, + # commit1) and open a cascade-hotfix PR. The sibling's env/web/prod integration + # branch must not exist: the hotfix branch namespaces are disjoint per component. + - name: "Apply the api hotfix to prod" + action: hotfix_apply + hotfix_apply: + component: api + target_env: prod + commit_ref: commit3 + base_ref: commit1 + expect: + branches: + exist: ["env/api/prod"] + deleted: ["env/web/prod"] + prs: + open_with_label: "cascade-hotfix" + + - name: "Merge the api hotfix PR" + action: merge_pr + merge_pr: + label: "cascade-hotfix" + + # Finalize the api hotfix. finalize --component api records the divergence under + # only state.components.api.prod (tracking env/api/prod, anchored on commit1, with + # the api patch applied). web's prod subtree must read byte-unchanged across the + # entire api hotfix. + - name: "Finalize the api hotfix for prod" + action: hotfix_merged + hotfix_merged: + component: api + target_env: prod + expect: + state: + api-prod: + component: api + env: prod + ref: "env/api/prod" + base_sha: commit1 + patches: [commit3] + web-prod: + component: web + env: prod + unchanged: true + + # --- Component web lifecycle (mirror); api must stay byte-intact throughout. --- + + # Dry-run rollback of web prod targeting its own live-state SHA (commit2). api's + # prod subtree is diverged from its hotfix above and must read byte-unchanged + # across web's rollback, proving the rollback touched only web's namespace. + - name: "Rollback web prod resolves target from its own state" + action: rollback + rollback: + component: web + environment: prod + target: commit2 + dry_run: true + expect_source: state + expect: + state: + api-prod: + component: api + env: prod + unchanged: true + + - name: "Commit a web-only hotfix source" + action: commit + commit: + message: "fix: patch web" + files: + services/web/fix.go: | + package main + + // web patch applied + + - name: "Plan the web hotfix for prod" + action: hotfix_plan + hotfix_plan: + component: web + commit_ref: commit5 + target_env: prod + dry_run: true + + - name: "Apply the web hotfix to prod" + action: hotfix_apply + hotfix_apply: + component: web + target_env: prod + commit_ref: commit5 + base_ref: commit2 + expect: + branches: + exist: ["env/web/prod", "env/api/prod"] + prs: + open_with_label: "cascade-hotfix" + + - name: "Merge the web hotfix PR" + action: merge_pr + merge_pr: + label: "cascade-hotfix" + + # Finalize the web hotfix. finalize --component web records divergence under only + # state.components.web.prod (tracking env/web/prod). api's prod subtree, already + # diverged from its own hotfix, must survive byte-unchanged: web's finalize can + # neither clobber nor rejoin it. + - name: "Finalize the web hotfix for prod" + action: hotfix_merged + hotfix_merged: + component: web + target_env: prod + expect: + state: + web-prod: + component: web + env: prod + ref: "env/web/prod" + base_sha: commit2 + patches: [commit5] + # api's sha/version are byte-unchanged across web's finalize. + api-prod: + component: api + env: prod + unchanged: true + # api's own divergence (from its earlier hotfix) also survives intact: + # web's finalize neither clobbers nor rejoins api's env/api/prod tracking. + api-prod-divergence: + component: api + env: prod + ref: "env/api/prod" + base_sha: commit1 + patches: [commit3] diff --git a/internal/generate/hotfix.go b/internal/generate/hotfix.go index ca7bd729..811c262b 100644 --- a/internal/generate/hotfix.go +++ b/internal/generate/hotfix.go @@ -112,6 +112,20 @@ func (g *HotfixGenerator) envBranchRef() string { return g.envBranchPrefix() + "${env}" } +// hotfixBranchPrefix returns the throwaway cherry-pick branch name prefix the +// apply lane pushes to: single-component yields "hotfix/" (byte-identical to the +// historical form), a component yields "hotfix//" so two components +// cherry-picking the same env at the same source commit push disjoint branches +// and never collide on a shared hotfix// ref. It mirrors +// envBranchPrefix so the apply branch namespace tracks the integration-branch +// namespace. +func (g *HotfixGenerator) hotfixBranchPrefix() string { + if g.componentName != "" { + return "hotfix/" + g.componentName + "/" + } + return "hotfix/" +} + // getStateTokenRef returns the token expression used to merge the clean-path // resolution PR. It mirrors the release and promote generators: users configure // the full expression via the state_token config option, and it defaults to the @@ -480,7 +494,7 @@ func (g *HotfixGenerator) writeApplyJob(sb *strings.Builder) { sb.WriteString(" fi\n") sb.WriteString(" FIRST_COMMIT=$(echo \"$COMMITS\" | cut -d',' -f1)\n") sb.WriteString(" SHORT_SHA=$(echo \"$FIRST_COMMIT\" | cut -c1-8)\n") - sb.WriteString(" BRANCH=\"hotfix/${env}/${SHORT_SHA}\"\n") + fmt.Fprintf(sb, " BRANCH=\"%s${env}/${SHORT_SHA}\"\n", g.hotfixBranchPrefix()) // Materialize env/ at the planner's validated base if origin lacks it, // so the resolution PR has a base branch; the plan enforces tip == BASE when // the branch already exists, so this is a no-op create in that case. diff --git a/internal/hotfix/plan.go b/internal/hotfix/plan.go index ad8dffb5..d6d6ff27 100644 --- a/internal/hotfix/plan.go +++ b/internal/hotfix/plan.go @@ -8,6 +8,7 @@ package hotfix import ( "fmt" + "os" "os/exec" "strings" @@ -220,10 +221,20 @@ func NewPlanner(opts PlannerOptions, options ...Option) (*Planner, error) { key = config.DefaultManifestKey } - cicd, err := config.ParseManifestFile(opts.ConfigPath, key) + // Read raw bytes rather than delegating straight to config.ParseManifestFile + // so a component-scoped planner can overlay state.components.. + // below; ParseManifestFile alone discards the bytes needed for that read. + raw, err := os.ReadFile(opts.ConfigPath) + if err != nil { + return nil, fmt.Errorf("reading manifest file: %w", err) + } + cicd, err := config.ParseManifestBytes(raw, key) if err != nil { return nil, fmt.Errorf("failed to parse config: %w", err) } + if cicd.Config != nil { + cicd.Config.ManifestFile = opts.ConfigPath + } actor := opts.Actor if actor == "" { @@ -240,6 +251,20 @@ func NewPlanner(opts PlannerOptions, options ...Option) (*Planner, error) { for _, o := range options { o(p) } + + // A component-scoped plan reads its prior env state from + // state.components.., not the flat state. node (which a + // multi-component manifest never populates for a declared component). Overlay + // it into the flat map so Plan and PlanChain's State[env] lookups transparently + // see the component's recorded row, mirroring the finalize path's + // overlayComponentState. A no-op for the single-component default. + if p.cicd.State == nil { + p.cicd.State = make(map[string]*config.EnvState) + } + if err := overlayComponentState(p.cicd, raw, key, p.component); err != nil { + return nil, err + } + return p, nil } diff --git a/internal/hotfix/plan_component_test.go b/internal/hotfix/plan_component_test.go index b23f011d..c90b1bb0 100644 --- a/internal/hotfix/plan_component_test.go +++ b/internal/hotfix/plan_component_test.go @@ -1,10 +1,48 @@ package hotfix import ( + "os" "os/exec" + "path/filepath" + "strings" "testing" ) +// writeComponentOnlyManifest writes a manifest whose ONLY recorded state lives +// under state.components.. for two declared components (api, web); it +// never populates the flat state. node a real multi-component manifest never +// writes to for a declared component (orchestrate and promote always record via +// WriteScopedState). It reproduces the exact shape a per-component hotfix plans +// against on a real repo, so a planner that reads only the flat map fails closed +// with "no recorded state SHA" here just as it did on the fleet. +func writeComponentOnlyManifest(t *testing.T, envs []string, componentState map[string]map[string]string) string { + t.Helper() + + var b strings.Builder + b.WriteString("ci:\n") + b.WriteString(" config:\n") + b.WriteString(" environments:\n") + for _, e := range envs { + b.WriteString(" - " + e + "\n") + } + b.WriteString(" state:\n") + b.WriteString(" components:\n") + for comp, state := range componentState { + b.WriteString(" " + comp + ":\n") + for env, sha := range state { + b.WriteString(" " + env + ":\n") + b.WriteString(" sha: " + sha + "\n") + b.WriteString(" version: v1.0.0-rc.1\n") + } + } + + path := filepath.Join(".", "manifest.yaml") + if err := os.WriteFile(path, []byte(b.String()), 0o600); err != nil { + t.Fatalf("write manifest: %v", err) + } + return path +} + // TestPlan_Component_UsesComponentScopedEnvBranch proves a planner scoped to a // component names the integration branch env// and creates it at // the recorded state SHA, so a per-component hotfix operates in its own branch @@ -95,3 +133,57 @@ func TestPlan_Component_SingleFlightQueriesComponentBranch(t *testing.T) { t.Errorf("single-flight queried %q, want env/web/test", stub.calledWith) } } + +// TestPlan_Component_ReadsComponentScopedStateOnly is the regression test for the +// bug the isolation e2e scenario caught: a component-scoped plan must resolve its +// base SHA from state.components.., not the flat state. node, +// which a multi-component manifest never populates for a declared component. The +// manifest here carries ONLY the components subtree (api and web, both at "test"), +// reproducing the real generated shape; a planner that reads the flat map alone +// fails with "no recorded state SHA" even though the component's row exists. +func TestPlan_Component_ReadsComponentScopedStateOnly(t *testing.T) { + newScratchRepo(t) + base := commitFile(t, "a.txt", "one", "first") + fix := commitFile(t, "b.txt", "two", "fix") + + manifest := writeComponentOnlyManifest(t, []string{"dev", "test", "prod"}, map[string]map[string]string{ + "api": {"test": base, "prod": base}, + "web": {"test": base, "prod": base}, + }) + + p := newPlanner(t, manifest, WithPlanComponent("api")) + res, err := p.Plan(fix, "test") + if err != nil { + t.Fatalf("Plan: %v (component-scoped state must be readable with no flat state. node)", err) + } + if res.BaseSHA != base { + t.Errorf("base_sha = %q, want %q (api's own state.components.api.test.sha)", res.BaseSHA, base) + } + if res.Branch != "env/api/test" { + t.Errorf("branch = %q, want env/api/test", res.Branch) + } +} + +// TestPlan_Component_IgnoresSiblingComponentState proves the overlay reads only +// the named component's subtree: api and web are seeded at DIFFERENT base SHAs, so +// a plan scoped to api must resolve api's own base, never web's. +func TestPlan_Component_IgnoresSiblingComponentState(t *testing.T) { + newScratchRepo(t) + apiBase := commitFile(t, "a.txt", "one", "api base") + webBase := commitFile(t, "b.txt", "two", "web base") + fix := commitFile(t, "c.txt", "three", "fix") + + manifest := writeComponentOnlyManifest(t, []string{"dev", "test", "prod"}, map[string]map[string]string{ + "api": {"test": apiBase}, + "web": {"test": webBase}, + }) + + p := newPlanner(t, manifest, WithPlanComponent("api")) + res, err := p.Plan(fix, "test") + if err != nil { + t.Fatalf("Plan: %v", err) + } + if res.BaseSHA != apiBase { + t.Errorf("base_sha = %q, want api's own base %q (must not read web's %q)", res.BaseSHA, apiBase, webBase) + } +}