diff --git a/e2e/harness/act.go b/e2e/harness/act.go index 4eca800..27c9a68 100644 --- a/e2e/harness/act.go +++ b/e2e/harness/act.go @@ -3,6 +3,7 @@ package harness import ( "bytes" "context" + "encoding/json" "fmt" "io" "strings" @@ -129,6 +130,49 @@ func (a *ActRunner) Terminate(ctx context.Context) error { // github.event context (e.g. a merged pull_request payload). const eventFilePath = "/tmp/cascade-event.json" +// dispatchInputsEventJSON builds a minimal workflow_dispatch event payload that +// embeds the run's inputs under the top-level "inputs" key, e.g. +// +// {"inputs":{"commit":"","dry_run":"true","target_env":"test"}} +// +// act seeds the github.event.inputs context from the -e event file, but does NOT +// reliably populate it from --input flags alone when --detect-event is set and no +// event file carries an inputs key. A job gated on +// github.event.inputs.dry_run (e.g. the hotfix apply job) therefore evaluates its +// if: against an empty value and runs when it should be skipped. Writing this +// payload as the event file makes github.event.inputs authoritative regardless of +// act's --input handling. +// +// It returns "" when inputs is empty (no payload needed) or when marshaling fails +// (caller falls back to the --input-only behavior). The map is encoded by +// encoding/json, which emits keys in sorted order, so the output is deterministic. +func dispatchInputsEventJSON(inputs map[string]string) string { + if len(inputs) == 0 { + return "" + } + payload := map[string]map[string]string{"inputs": inputs} + encoded, err := json.Marshal(payload) + if err != nil { + return "" + } + return string(encoded) +} + +// resolveEventJSON picks the event payload act should be pointed at for a run. An +// explicitly supplied opts.EventJSON always wins (e.g. a merged pull_request +// payload). Otherwise, for a workflow_dispatch carrying inputs, it synthesizes a +// payload via dispatchInputsEventJSON so github.event.inputs is seeded. All other +// runs get "" (no event file). +func resolveEventJSON(opts RunOpts) string { + if opts.EventJSON != "" { + return opts.EventJSON + } + if opts.Event == "workflow_dispatch" { + return dispatchInputsEventJSON(opts.Inputs) + } + return "" +} + // writeEventFile writes the run's EventJSON payload to eventFilePath inside the // act container via CopyToContainer. It is a no-op (returning "" with no error) // when EventJSON is empty, so callers can unconditionally invoke it and only @@ -271,11 +315,14 @@ func (a *ActRunner) RunWorkflowFromRepo(ctx context.Context, opts RunOpts) (*Ext if a.networkName != "" { network = a.networkName } - // When an event payload is supplied, write it into the container so - // buildActArgs can append the act -e flag pointing at it. This seeds the - // github.event context (e.g. the merged pull_request payload that drives the - // hotfix context/build/deploy/finalize jobs). - eventPath, err := a.writeEventFile(ctx, opts.EventJSON) + // Resolve the event payload to point act at. An explicit opts.EventJSON wins + // (e.g. the merged pull_request payload that drives the hotfix + // context/build/deploy/finalize jobs). For a workflow_dispatch carrying + // inputs, we synthesize a payload embedding those inputs so + // github.event.inputs is seeded; act does not reliably populate that context + // from --input flags alone, so a job gated on github.event.inputs (e.g. the + // hotfix apply job's dry_run check) would otherwise mis-evaluate its if:. + eventPath, err := a.writeEventFile(ctx, resolveEventJSON(opts)) if err != nil { return nil, err } diff --git a/e2e/harness/act_test.go b/e2e/harness/act_test.go index d42e829..145f125 100644 --- a/e2e/harness/act_test.go +++ b/e2e/harness/act_test.go @@ -140,6 +140,99 @@ func TestBuildActArgs_EventFlag(t *testing.T) { } } +// TestDispatchInputsEventJSON verifies the synthesized workflow_dispatch payload +// embeds the run's inputs under a top-level "inputs" key (the shape act reads to +// seed github.event.inputs) and is empty when there are no inputs. +func TestDispatchInputsEventJSON(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + inputs map[string]string + want string + }{ + { + name: "no inputs yields empty payload", + inputs: nil, + want: "", + }, + { + name: "empty map yields empty payload", + inputs: map[string]string{}, + want: "", + }, + { + name: "hotfix dry-run inputs are embedded under inputs key (keys sorted)", + inputs: map[string]string{ + "commit": "abc123", + "target_env": "test", + "dry_run": "true", + }, + want: `{"inputs":{"commit":"abc123","dry_run":"true","target_env":"test"}}`, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.want, dispatchInputsEventJSON(tt.inputs)) + }) + } +} + +// TestResolveEventJSON verifies an explicit EventJSON always wins, a +// workflow_dispatch with inputs synthesizes the inputs payload, and all other +// runs resolve to no event file. +func TestResolveEventJSON(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + opts RunOpts + want string + }{ + { + name: "explicit event json wins over synthesized payload", + opts: RunOpts{ + Event: "workflow_dispatch", + EventJSON: `{"action":"closed"}`, + Inputs: map[string]string{"dry_run": "true"}, + }, + want: `{"action":"closed"}`, + }, + { + name: "workflow_dispatch with inputs synthesizes inputs payload", + opts: RunOpts{ + Event: "workflow_dispatch", + Inputs: map[string]string{"dry_run": "true", "target_env": "test"}, + }, + want: `{"inputs":{"dry_run":"true","target_env":"test"}}`, + }, + { + name: "workflow_dispatch without inputs yields no event file", + opts: RunOpts{Event: "workflow_dispatch"}, + want: "", + }, + { + name: "non-dispatch event without explicit json yields no event file", + opts: RunOpts{ + Event: "push", + Inputs: map[string]string{"dry_run": "true"}, + }, + want: "", + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.want, resolveEventJSON(tt.opts)) + }) + } +} + func TestNormalizeWorkflowResult(t *testing.T) { t.Parallel() diff --git a/e2e/harness/assert.go b/e2e/harness/assert.go index da631e9..6a19af6 100644 --- a/e2e/harness/assert.go +++ b/e2e/harness/assert.go @@ -284,6 +284,32 @@ func AssertState(ctx *ExecutionContext, env string, expect *StateExpect) []error env, expect.PreviousVersion, actual.PreviousVersion)) } + // Check cleared divergence fields: each named field must read back empty. + // Expresses the rejoin contract, which an empty expectation value alone + // cannot assert (empty Ref/BaseSHA/Patches expectations are skipped above). + for _, field := range expect.Cleared { + switch field { + case "ref": + if actual.Ref != "" { + errs = append(errs, fmt.Errorf("state[%s].ref expected cleared but got %s", + env, actual.Ref)) + } + case "base_sha": + if actual.BaseSHA != "" { + errs = append(errs, fmt.Errorf("state[%s].base_sha expected cleared but got %s", + env, actual.BaseSHA)) + } + case "patches": + if len(actual.Patches) > 0 { + errs = append(errs, fmt.Errorf("state[%s].patches expected cleared but got %v", + env, actual.Patches)) + } + default: + errs = append(errs, fmt.Errorf("state[%s].cleared lists unsupported field %q (want ref|base_sha|patches)", + env, field)) + } + } + // Check deploys for deployName, deployExpect := range expect.Deploys { actualDeploy := actual.Deploys[deployName] diff --git a/e2e/harness/hotfix_actions.go b/e2e/harness/hotfix_actions.go index 6d207ca..5b51fa0 100644 --- a/e2e/harness/hotfix_actions.go +++ b/e2e/harness/hotfix_actions.go @@ -169,7 +169,17 @@ func (r *Runner) executeHotfixApply(ctx context.Context, step *HotfixApplyStep) short8 := short script := strings.Join([]string{ "set +e", + // Abort any half-finished cherry-pick left in the shared /tmp/repo by a + // prior apply in this scenario, then drop any stale local hotfix branch so + // the re-create below always anchors on the freshly-fetched remote tip. + "git cherry-pick --abort >/dev/null 2>&1 || true", + // Force-update the env tracking ref so a second apply onto an env branch + // the first finalize already advanced (squash-merge) anchors on the + // current tip rather than a stale ref, otherwise the cherry-pick replays + // an already-merged change and the push is rejected non-fast-forward. + fmt.Sprintf("git fetch origin %q --tags >/dev/null 2>&1", "+refs/heads/"+envBranch+":refs/remotes/origin/"+envBranch), "git fetch origin '+refs/heads/*:refs/remotes/origin/*' --tags >/dev/null 2>&1", + fmt.Sprintf("git branch -D %q >/dev/null 2>&1 || true", hotfixBranch), fmt.Sprintf("git switch -c %q %q", hotfixBranch, "origin/"+envBranch), fmt.Sprintf("git cherry-pick -x %q", commit), "CP_EXIT=$?", @@ -181,7 +191,11 @@ func (r *Runner) executeHotfixApply(ctx context.Context, step *HotfixApplyStep) "else", " echo \"CONFLICT_FILES=\"", "fi", - fmt.Sprintf("git push %q %q", pushURL, hotfixBranch+":"+hotfixBranch), + // Force-push the uniquely-named, per-apply throwaway hotfix branch. The + // branch name embeds the source short SHA, so a force-push only ever + // overwrites this apply's own prior attempt (e.g. a retried sync), never a + // shared branch. + fmt.Sprintf("git push --force %q %q", pushURL, hotfixBranch+":"+hotfixBranch), "echo \"PUSH_EXIT=$?\"", }, "\n") @@ -258,6 +272,18 @@ func (r *Runner) executeMergePR(ctx context.Context, step *MergePRStep) error { if err := r.harness.gitea.MergePR(ctx, r.harness.repo, index, "squash"); err != nil { return fmt.Errorf("merge PR #%d: %w", index, err) } + + // Record the post-merge env branch tip as "hotfix_head". The squash merge + // produces a commit that lives only on env/; trunk never merges that + // 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 + 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) + } + } return nil } diff --git a/e2e/harness/hotfix_actions_test.go b/e2e/harness/hotfix_actions_test.go index 84c400e..1455ae8 100644 --- a/e2e/harness/hotfix_actions_test.go +++ b/e2e/harness/hotfix_actions_test.go @@ -218,6 +218,20 @@ func TestValidateScenarioHotfixActions(t *testing.T) { step: Step{Name: "h", Action: "hotfix_merged", HotfixMerged: &HotfixMergedStep{}}, wantErr: "requires target_env", }, + { + name: "stage_divergence valid", + step: Step{Name: "s", Action: "stage_divergence", StageDivergence: &StageDivergenceStep{Env: "test", Patches: []string{"hotfix_head"}}}, + }, + { + name: "stage_divergence missing config", + step: Step{Name: "s", Action: "stage_divergence"}, + wantErr: "requires stage_divergence config", + }, + { + name: "stage_divergence missing env", + step: Step{Name: "s", Action: "stage_divergence", StageDivergence: &StageDivergenceStep{Patches: []string{"x"}}}, + wantErr: "requires env", + }, } for _, tt := range tests { @@ -305,6 +319,55 @@ func TestRunnerHotfixActionsNoHarness(t *testing.T) { } } +// TestRunnerStageDivergenceNoHarness verifies stage_divergence records the +// divergence into the execution context (resolving commit references to literal +// SHAs) without a harness, and that the manifest write is skipped in that mode. +func TestRunnerStageDivergenceNoHarness(t *testing.T) { + r := NewRunner(t, nil) + r.ctx.RecordCommit("commit1", "base0000") + r.ctx.RecordCommit("hotfix_head", "offtrunk9999") + + err := r.executeStageDivergence(context.Background(), &StageDivergenceStep{ + Env: "test", + Ref: "env/test", + BaseSHA: "commit1", + Patches: []string{"hotfix_head", "literalsha"}, + }) + require.NoError(t, err) + + st := r.ctx.GetState("test") + assert.Equal(t, "env/test", st.Ref) + assert.Equal(t, "base0000", st.BaseSHA) // resolved from commit1 + assert.Equal(t, []string{"offtrunk9999", "literalsha"}, st.Patches) // hotfix_head resolved, literal kept +} + +// TestParseStageDivergenceStep verifies the stage_divergence step unmarshals +// into the expected struct fields. +func TestParseStageDivergenceStep(t *testing.T) { + yamlDoc := ` +name: "Stage divergence" +config: + environments: [dev, test] +steps: + - name: "Stage" + action: stage_divergence + stage_divergence: + env: test + ref: "env/test" + base_sha: commit1 + patches: [hotfix_head] +` + s, err := ParseMultiStepScenario([]byte(yamlDoc)) + require.NoError(t, err) + require.Len(t, s.Steps, 1) + sd := s.Steps[0].StageDivergence + require.NotNil(t, sd) + assert.Equal(t, "test", sd.Env) + assert.Equal(t, "env/test", sd.Ref) + assert.Equal(t, "commit1", sd.BaseSHA) + assert.Equal(t, []string{"hotfix_head"}, sd.Patches) +} + // TestRunnerStateDivergenceSetupNoHarness verifies applySetup records divergence // into the execution context without a harness. func TestRunnerStateDivergenceSetupNoHarness(t *testing.T) { diff --git a/e2e/harness/multistep.go b/e2e/harness/multistep.go index 6e12392..42f9509 100644 --- a/e2e/harness/multistep.go +++ b/e2e/harness/multistep.go @@ -65,6 +65,10 @@ type Step struct { // HotfixMerged configures a "hotfix_merged" action: replay the merged // pull_request event so the context/build/deploy/finalize jobs run. HotfixMerged *HotfixMergedStep `yaml:"hotfix_merged,omitempty"` + // StageDivergence configures a "stage_divergence" action: overwrite an + // environment's divergence fields in the live manifest mid-scenario without + // running any workflow. + StageDivergence *StageDivergenceStep `yaml:"stage_divergence,omitempty"` // ExpectFailure marks a step whose workflow is expected to conclude in // failure (for example an orchestrate run whose build exits non-zero). When // set, a failure conclusion is the success path and a success conclusion is @@ -110,6 +114,21 @@ type HotfixMergedStep struct { TargetEnv string `yaml:"target_env"` } +// StageDivergenceStep defines a stage_divergence action: it rewrites the +// divergence fields (ref/base_sha/patches) for Env directly in the live +// manifest, then records the same divergence in the execution context. No +// workflow runs. Ref/BaseSHA/Patches entries may be commit references (resolved +// via the execution context) or literal SHAs. Used to re-wire a diverged env's +// patch set to an off-trunk SHA so a later promote exercises the +// patch-containment guard. +type StageDivergenceStep struct { + Env string `yaml:"env"` + Ref string `yaml:"ref,omitempty"` + BaseSHA string `yaml:"base_sha,omitempty"` + Patches []string `yaml:"patches,omitempty"` + PreviousVersion string `yaml:"previous_version,omitempty"` +} + // CommitStep defines a commit action type CommitStep struct { Message string `yaml:"message"` @@ -202,6 +221,12 @@ type StateExpect struct { // prior versions in a separate "previous" ring), so it is only populated by // setup staging or by an explicit divergence record. PreviousVersion string `yaml:"previous_version,omitempty"` + // Cleared names divergence fields that must now be empty on the recorded + // state. Supported members: "ref", "base_sha", "patches". This expresses the + // rejoin contract (divergence fields are cleared once an env rejoins trunk), + // which an empty Ref/BaseSHA/Patches value alone cannot assert because empty + // expectation values are skipped. Each named field must read back empty. + Cleared []string `yaml:"cleared,omitempty"` } // DeployExpect defines expected deploy state diff --git a/e2e/harness/runner.go b/e2e/harness/runner.go index 2c87e99..d27b320 100644 --- a/e2e/harness/runner.go +++ b/e2e/harness/runner.go @@ -107,6 +107,13 @@ func (r *Runner) ValidateScenario(scenario *MultiStepScenario) error { if step.HotfixMerged.TargetEnv == "" { return fmt.Errorf("step %d (%s): hotfix_merged requires target_env", i, step.Name) } + case "stage_divergence": + if step.StageDivergence == nil { + return fmt.Errorf("step %d (%s): stage_divergence action requires stage_divergence config", i, step.Name) + } + if step.StageDivergence.Env == "" { + return fmt.Errorf("step %d (%s): stage_divergence requires env", i, step.Name) + } default: return fmt.Errorf("step %d (%s): unknown action %q", i, step.Name, step.Action) } @@ -337,6 +344,8 @@ func (r *Runner) executeStep(ctx context.Context, step *Step, config Config) err return r.executeResolveConflict(ctx, step.ResolveConflict) case "hotfix_merged": return r.executeHotfixMerged(ctx, step.HotfixMerged, config) + case "stage_divergence": + return r.executeStageDivergence(ctx, step.StageDivergence) default: return fmt.Errorf("unknown action: %s", step.Action) } @@ -367,6 +376,49 @@ func (r *Runner) executeCommit(ctx context.Context, commit *CommitStep) error { return nil } +// executeStageDivergence rewrites an environment's divergence fields directly in +// the live manifest (via materializeManifestState) and records the same +// divergence in the execution context. No workflow runs. Ref/BaseSHA/Patches +// entries may be commit references (resolved via the execution context) or +// literal SHAs. This lets a scenario re-wire a diverged env's patch set to an +// off-trunk SHA so a later promote exercises the patch-containment guard at the +// e2e level. +func (r *Runner) executeStageDivergence(ctx context.Context, step *StageDivergenceStep) error { + setup := map[string]*EnvStateSetup{ + step.Env: { + Ref: step.Ref, + BaseSHA: step.BaseSHA, + Patches: step.Patches, + PreviousVersion: step.PreviousVersion, + }, + } + + if r.harness != nil && r.harness.gitea != nil && r.harness.repo != nil { + if err := r.materializeManifestState(ctx, setup); err != nil { + return fmt.Errorf("stage_divergence: %w", err) + } + } + + // Mirror the staged divergence into the execution context, resolving any + // commit references to literal SHAs the same way applySetup does. + baseSHA := r.ctx.ResolveSHA(step.BaseSHA) + if baseSHA == "" { + baseSHA = step.BaseSHA + } + patches := make([]string, 0, len(step.Patches)) + for _, p := range step.Patches { + if resolved := r.ctx.ResolveSHA(p); resolved != "" { + patches = append(patches, resolved) + } else { + patches = append(patches, p) + } + } + r.ctx.RecordStateDivergence(step.Env, step.Ref, baseSHA, patches, step.PreviousVersion) + r.t.Logf(" StageDivergence: env=%s ref=%s base=%s patches=%d", + step.Env, step.Ref, truncateSHA(baseSHA), len(patches)) + return nil +} + // executeOrchestrate runs the orchestrate workflow via ActRunner. When // expectFailure is set, a failure conclusion is the success path (mirrors // executePromote's ExpectFailure handling) and a success conclusion is an error. diff --git a/e2e/scenarios/hotfix/hotfix-containment.yaml b/e2e/scenarios/hotfix/hotfix-containment.yaml new file mode 100644 index 0000000..64407de --- /dev/null +++ b/e2e/scenarios/hotfix/hotfix-containment.yaml @@ -0,0 +1,131 @@ +name: "Hotfix Containment Guard" +description: | + Exercises the patch-containment guard at e2e level using a real off-trunk SHA. + + After a hotfix is squash-merged onto env/test, the merge-commit SHA is recorded + as hotfix_head. That commit lives only on env/test; trunk never merges it back, + so it is not an ancestor of any trunk commit. A mid-scenario stage_divergence + step re-wires test's patches to [hotfix_head]. Promoting the current dev SHA + (which only knows trunk) into test then finds hotfix_head is not an ancestor of + the incoming SHA, so the guard fires and blocks the promotion. A subsequent + force: true promote overrides the guard and proceeds. + +config: + trunk_branch: main + environments: [dev, test, prod] + builds: + - name: app + workflow: build.yaml + triggers: ["src/**"] + deploys: + - name: deploy-dev + workflow: deploy.yaml + triggers: ["src/**"] + - name: deploy-test + workflow: deploy.yaml + triggers: ["src/**"] + - name: deploy-prod + workflow: deploy.yaml + triggers: ["src/**"] + +steps: + - name: "Initial commit" + action: commit + commit: + message: "feat: add app" + files: + src/app.go: | + package main + func main() {} + + - name: "Orchestrate trunk into dev" + action: orchestrate + + - name: "Promote to establish a test/prod baseline" + action: promote + promote: + mode: default + + - name: "Commit a trunk fix to cherry-pick" + action: commit + commit: + message: "fix: patch to cherry-pick" + files: + src/fix.go: | + package main + func patch() {} + + - name: "Plan hotfix for test" + action: hotfix_plan + hotfix_plan: + target_env: test + commit_ref: commit2 + # Plan only: the harness's hotfix_apply step performs the real cherry-pick + # and PR via the gitea API. Running the workflow's apply job here would call + # the GitHub gh CLI, which is absent from the act runner image. + dry_run: true + + - name: "Apply hotfix onto env/test" + action: hotfix_apply + hotfix_apply: + target_env: test + commit_ref: commit2 + + - name: "Merge the hotfix pull request (records hotfix_head)" + action: merge_pr + merge_pr: + label: cascade-hotfix + + # hotfix_head is now the squash-merge commit on env/test. It is not an ancestor + # of trunk. Re-stage test's divergence with patches=[hotfix_head] so the next + # promote sees a patch the incoming dev SHA cannot contain. + - name: "Stage test divergence with off-trunk patch (hotfix_head)" + action: stage_divergence + stage_divergence: + env: test + ref: "env/test" + base_sha: "commit1" + patches: + - "hotfix_head" + + - name: "Advance trunk so dev has a new SHA to promote" + action: commit + commit: + message: "chore: trunk advance after hotfix" + files: + src/advance.go: | + package main + func advance() {} + + - name: "Orchestrate so dev carries the advance" + action: orchestrate + + # Containment failure: dev's SHA does not contain hotfix_head (an env/test-only + # squash-merge commit). The targeted dev-to-test promote hits the + # patch-containment guard directly (dev is not diverged, so the diverged-source + # guard does not interfere). test is left untouched. + # The exact ancestry message ("does not contain patch") is asserted by + # internal/promote unit tests; the e2e contract is that promotion is blocked. + - name: "Guard: promote blocked - dev SHA does not contain off-trunk patch" + action: promote + promote: + mode: cascade + target: test + expect_failure: true + expect: + state: + test: + unchanged: true + + # Force override: the same targeted promote with force: true bypasses the + # patch-containment guard and proceeds. dev is the source and stays unchanged. + - name: "Force: promote overrides containment guard with force=true" + action: promote + promote: + mode: cascade + target: test + force: true + expect: + state: + dev: + unchanged: true diff --git a/e2e/scenarios/hotfix/hotfix-guards.yaml b/e2e/scenarios/hotfix/hotfix-guards.yaml new file mode 100644 index 0000000..6ff43b4 --- /dev/null +++ b/e2e/scenarios/hotfix/hotfix-guards.yaml @@ -0,0 +1,118 @@ +name: "Hotfix Promote Guards" +description: | + Exercises the promote-time guards that protect a diverged environment after a + hotfix has been applied to it. + + The centerpiece is the diverged-source guard: once test carries a hotfix and + diverges from trunk, the default-mode cascade's test-to-prod leg sources from + test and must refuse to run, leaving test unchanged. That guard does not call + git ancestry, so it is deterministic. + + Runtime protection-rule enforcement (environment approvals on prod) is a + real-GitHub-only concern and is not exercised by this harness; only the + promote-time guards that the generator and CLI enforce are covered here. + +config: + trunk_branch: main + environments: [dev, test, prod] + builds: + - name: app + workflow: build.yaml + triggers: ["src/**"] + deploys: + - name: deploy-dev + workflow: deploy.yaml + triggers: ["src/**"] + - name: deploy-test + workflow: deploy.yaml + triggers: ["src/**"] + - name: deploy-prod + workflow: deploy.yaml + triggers: ["src/**"] + +steps: + - name: "Initial commit" + action: commit + commit: + message: "feat: add app" + files: + src/app.go: | + package main + func main() {} + + - name: "Orchestrate trunk into dev/test" + action: orchestrate + + - name: "Promote to establish a test/prod baseline" + action: promote + promote: + mode: default + + - name: "Commit a trunk fix to hotfix into test" + action: commit + commit: + message: "fix: patch for test env" + files: + src/fix.go: | + package main + func patch() {} + + - name: "Plan hotfix for test" + action: hotfix_plan + hotfix_plan: + target_env: test + commit_ref: commit2 + # Plan only: the harness's hotfix_apply step performs the real cherry-pick + # and PR via the gitea API. Running the workflow's apply job here would call + # the GitHub gh CLI, which is absent from the act runner image. + dry_run: true + + - name: "Apply hotfix onto env/test" + action: hotfix_apply + hotfix_apply: + target_env: test + commit_ref: commit2 + + - name: "Merge the hotfix pull request" + action: merge_pr + merge_pr: + label: cascade-hotfix + + - name: "Finalize hotfix; test diverges onto env/test" + action: hotfix_merged + hotfix_merged: + target_env: test + expect: + # Robust minimal assertion: test now tracks its integration branch. The + # recorded patch is the original trunk commit SHA (commit2), not the + # cherry-pick SHA, so we assert the ref rather than a fragile patch SHA. + state: + test: + ref: "env/test" + + # Guard 1 (deterministic centerpiece): a default-mode cascade now has a + # test-to-prod leg that would source from the diverged test env. The + # diverged-source guard fires and the promotion fails. test is untouched. + - name: "Guard: promote refuses to source from diverged test" + action: promote + promote: + mode: default + expect_failure: true + expect: + state: + test: + unchanged: true + + # Guard 2 (patch-containment passing branch) is covered in hotfix-rejoin.yaml: + # that scenario promotes a dev SHA that contains the recorded patch (commit2) + # into the diverged test env and observes that containment passes, rejoin fires, + # and the divergence fields clear. Repeating it here adds no new contract and + # would require a deploy-leg that can be unstable in the act environment after + # the hotfix workflow chain above. The failure branch and force-override of the + # patch-containment guard are covered by internal/promote unit tests: + # TestPromote_IntoDivergedEnv_MissingPatch_Blocked + # TestPromote_IntoDivergedEnv_Force_OverridesWithWarning + # Engineering a real non-ancestor patch at e2e level is done in + # hotfix-containment.yaml, which records the env/test squash-merge commit as + # hotfix_head (not a trunk ancestor) and exercises both the blocked path + # (expect_failure) and the force-override path. diff --git a/e2e/scenarios/hotfix/hotfix-prod-gate.yaml b/e2e/scenarios/hotfix/hotfix-prod-gate.yaml new file mode 100644 index 0000000..18f0f2d --- /dev/null +++ b/e2e/scenarios/hotfix/hotfix-prod-gate.yaml @@ -0,0 +1,49 @@ +name: "Hotfix Prod Gate" +description: | + Verifies that the generated cascade-hotfix.yaml binds each hotfix deploy and + rollback job to the target environment via + "environment: ${{ needs.context.outputs.target_env }}", so that an + environment's configured protection rules apply to the hotfix deploy. + + Runtime protection-rule (environment approval) enforcement is a + real-GitHub-only concern and is not exercised by the act-based harness; this + scenario verifies only that the environment binding is emitted. + + Generator-output verification only. + +config: + trunk_branch: main + environments: [dev, test, prod] + builds: + - name: app + workflow: build.yaml + triggers: ["src/**"] + deploys: + - name: deploy-dev + workflow: deploy.yaml + triggers: ["src/**"] + - name: deploy-test + workflow: deploy.yaml + triggers: ["src/**"] + - name: deploy-prod + workflow: deploy.yaml + triggers: ["src/**"] + +steps: + - name: "Initial commit; assert hotfix deploy/rollback bind the target env" + action: commit + commit: + message: "feat: add app" + files: + src/app.go: | + package main + func main() {} + expect: + workflow_files: + - path: ".github/workflows/cascade-hotfix.yaml" + contains: + - "environment: ${{ needs.context.outputs.target_env }}" + - "deploy-deploy-prod:" + - "rollback-deploy-prod:" + - "deploy-deploy-test:" + - "rollback-deploy-test:" diff --git a/e2e/scenarios/hotfix/hotfix-rejoin.yaml b/e2e/scenarios/hotfix/hotfix-rejoin.yaml new file mode 100644 index 0000000..62c2014 --- /dev/null +++ b/e2e/scenarios/hotfix/hotfix-rejoin.yaml @@ -0,0 +1,132 @@ +name: "Hotfix Rejoin" +description: | + Verifies the rejoin path: once a diverged environment is promoted a trunk SHA + that contains every recorded patch, the divergence ends. The product clears + the env's divergence fields and deletes its env/ integration branch on the + remote. + + Because the recorded patch is the original trunk commit SHA, landing that same + trunk ancestor on dev and cascading it into test satisfies containment and + triggers the rejoin. + +config: + trunk_branch: main + environments: [dev, test, prod] + builds: + - name: app + workflow: build.yaml + triggers: ["src/**"] + deploys: + - name: deploy-dev + workflow: deploy.yaml + triggers: ["src/**"] + - name: deploy-test + workflow: deploy.yaml + triggers: ["src/**"] + - name: deploy-prod + workflow: deploy.yaml + triggers: ["src/**"] + +steps: + - name: "Initial commit" + action: commit + commit: + message: "feat: add app" + files: + src/app.go: | + package main + func main() {} + + - name: "Orchestrate trunk into dev/test" + action: orchestrate + + - name: "Promote to establish a test/prod baseline" + action: promote + promote: + mode: default + + - name: "Commit a trunk fix to hotfix into test" + action: commit + commit: + message: "fix: patch for test env" + files: + src/fix.go: | + package main + func patch() {} + + - name: "Plan hotfix for test" + action: hotfix_plan + hotfix_plan: + target_env: test + commit_ref: commit2 + # Plan only: the harness's hotfix_apply step performs the real cherry-pick + # and PR via the gitea API. Running the workflow's apply job here would call + # the GitHub gh CLI, which is absent from the act runner image. + dry_run: true + + - name: "Apply hotfix onto env/test" + action: hotfix_apply + hotfix_apply: + target_env: test + commit_ref: commit2 + + - name: "Merge the hotfix pull request" + action: merge_pr + merge_pr: + label: cascade-hotfix + + - name: "Finalize hotfix; test diverges onto env/test" + action: hotfix_merged + hotfix_merged: + target_env: test + expect: + state: + test: + ref: "env/test" + + # Land a normal trunk commit. Trunk now contains commit2 (the recorded patch) + # as an ancestor, so a promotion of any later trunk SHA into test will satisfy + # patch containment. + - name: "Advance trunk normally past the patch" + action: commit + commit: + message: "chore: trunk advance" + files: + src/advance.go: | + package main + func advance() {} + + - name: "Orchestrate so dev carries the patch's trunk ancestor" + action: orchestrate + + # Rejoin: promoting dev into the diverged test env, where the incoming SHA + # contains every recorded patch, ends the divergence. The product clears the + # divergence fields (ref/base_sha/patches) and deletes env/test on the remote. + # + # A targeted dev-to-test cascade is used (not default mode) so only the + # rejoin leg runs. Default mode would also queue the test-to-prod leg, which + # sources FROM the still-diverged test env and trips the diverged-source guard + # before the rejoin leg can clear the divergence. The targeted promote isolates + # the incoming dev SHA -> test containment-and-rejoin path. prod is untouched. + # + # The cleared matcher asserts ref/base_sha/patches read back EMPTY on test + # after rejoin (an empty StateExpect value alone is skipped, so the matcher + # exists to express the clearing contract). The branch deletion is the second + # observable side effect of rejoin. Together they cover the full rejoin + # boundary that gitea can observe; promote-side unit tests in internal/promote + # cover the manifest-write internals. + - name: "Promote a containing SHA into test triggers rejoin" + action: promote + promote: + mode: cascade + target: test + expect: + state: + test: + cleared: [ref, base_sha, patches] + dev: + unchanged: true + prod: + unchanged: true + branches: + deleted: ["env/test"] diff --git a/e2e/scenarios/hotfix/hotfix-stacked.yaml b/e2e/scenarios/hotfix/hotfix-stacked.yaml new file mode 100644 index 0000000..25cf872 --- /dev/null +++ b/e2e/scenarios/hotfix/hotfix-stacked.yaml @@ -0,0 +1,141 @@ +name: "Hotfix Stacked" +description: | + Verifies two behaviours around successive hotfixes on the same environment: + + 1. Single-flight guard: a second hotfix_plan dispatched while the first + hotfix PR is still open fails with re-dispatch guidance (resolved + decision Q4). + + 2. Sequential stacking: after the first hotfix finishes, a second hotfix + goes through a full plan-apply-merge-merged cycle and appends its patch + to the existing patches slice, carrying the original base anchor forward. + +config: + trunk_branch: main + environments: [dev, test, prod] + builds: + - name: app + workflow: build.yaml + triggers: ["src/**"] + deploys: + - name: deploy-dev + workflow: deploy.yaml + triggers: ["src/**"] + - name: deploy-test + workflow: deploy.yaml + triggers: ["src/**"] + - name: deploy-prod + workflow: deploy.yaml + triggers: ["src/**"] + +steps: + - name: "Initial commit" + action: commit + commit: + message: "feat: add app" + files: + src/app.go: | + package main + func main() {} + + - name: "Orchestrate trunk into dev/test" + action: orchestrate + + - name: "Promote to establish a test/prod baseline" + action: promote + promote: + mode: default + + - name: "Commit the first trunk fix" + action: commit + commit: + message: "fix: first hotfix" + files: + src/fix1.go: | + package main + func fix1() {} + + - name: "Plan first hotfix for test" + action: hotfix_plan + hotfix_plan: + target_env: test + commit_ref: commit2 + # Plan only: the harness's hotfix_apply step performs the real cherry-pick + # and PR via the gitea API. Running the workflow's apply job here would call + # the GitHub gh CLI, which is absent from the act runner image. + dry_run: true + + - name: "Apply first hotfix onto env/test" + action: hotfix_apply + hotfix_apply: + target_env: test + commit_ref: commit2 + + # Single-flight guard: dispatching a second hotfix_plan while the first + # hotfix PR is still open must fail with re-dispatch guidance (Q4). + - name: "Commit a second trunk fix (staged for later)" + action: commit + commit: + message: "fix: second hotfix" + files: + src/fix2.go: | + package main + func fix2() {} + + - name: "Single-flight: second hotfix_plan while first PR is open fails" + action: hotfix_plan + hotfix_plan: + target_env: test + commit_ref: commit3 + expect_failure: true + + - name: "Merge the first hotfix pull request" + action: merge_pr + merge_pr: + label: cascade-hotfix + + - name: "Finalize first hotfix; test diverges onto env/test" + action: hotfix_merged + hotfix_merged: + target_env: test + expect: + state: + test: + ref: "env/test" + + # commit3 (the second fix) was already committed above for the single-flight + # test. Now that the first hotfix is finalized, dispatch the second hotfix + # using commit3 - the single-flight guard no longer blocks it. + - name: "Plan second hotfix for test (first PR now closed)" + action: hotfix_plan + hotfix_plan: + target_env: test + commit_ref: commit3 + # Plan only (see first plan step): the apply job's gh CLI is absent from + # the act image; the harness applies via the gitea API in the next step. + dry_run: true + + - name: "Apply second hotfix onto env/test" + action: hotfix_apply + hotfix_apply: + target_env: test + commit_ref: commit3 + + - name: "Merge the second hotfix pull request" + action: merge_pr + merge_pr: + label: cascade-hotfix + + # The second finalize appends commit3 to the existing patches slice (which + # already holds commit2) and carries the original base anchor forward. The + # recorded fix_sha for each hotfix is the original trunk commit SHA, so both + # entries resolve via the execution context to commit2 and commit3. + - name: "Finalize second hotfix; patches stack on test" + action: hotfix_merged + hotfix_merged: + target_env: test + expect: + state: + test: + ref: "env/test" + patches: [commit2, commit3] diff --git a/internal/promote/finalize.go b/internal/promote/finalize.go index 9ba1ad8..e7553cb 100644 --- a/internal/promote/finalize.go +++ b/internal/promote/finalize.go @@ -413,9 +413,16 @@ func (f *Finalizer) commitAndPushGit(message string) error { return fmt.Errorf("git commit failed: %w", err) } - cmd = exec.Command("git", "push") - if err := cmd.Run(); err != nil { - return fmt.Errorf("git push failed: %w", err) + // Push HEAD explicitly to the trunk branch. The finalize job checks out the + // triggering SHA, so on a workflow_dispatch run HEAD is detached and a bare + // "git push" fails (exit 128) with no upstream tracking branch. Pushing + // HEAD:refs/heads/ works regardless of detached state and targets the + // branch the state belongs on. Capture combined output so the real git error + // surfaces in the workflow log rather than just the exit status. + branch := trunkBranchFromEnv() + cmd = exec.Command("git", "push", "origin", "HEAD:refs/heads/"+branch) + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("git push failed: %s: %w", strings.TrimSpace(string(out)), err) } return nil diff --git a/internal/promote/finalize_test.go b/internal/promote/finalize_test.go index 71ab61f..d722b08 100644 --- a/internal/promote/finalize_test.go +++ b/internal/promote/finalize_test.go @@ -2,6 +2,7 @@ package promote import ( "os" + "os/exec" "path/filepath" "testing" @@ -685,3 +686,69 @@ func TestFinalize_RealWorldManifest(t *testing.T) { require.NotNil(t, cicdFile.State["test"]) require.Equal(t, "5cb540d63e94b158f798a3b5af3caee80e6a1290", cicdFile.State["test"].SHA) } + +// gitRun runs a git command in dir and fails the test on error, surfacing the +// combined output for diagnosis. +func gitRun(t *testing.T, dir string, args ...string) { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v failed: %v\n%s", args, err, out) + } +} + +// TestCommitAndPushGit_DetachedHeadPushesToTrunk reproduces the finalize-job +// environment: the working tree is checked out at the triggering SHA, so HEAD is +// detached. A bare "git push" fails there with exit 128 (no upstream tracking +// branch). This verifies commitAndPushGit instead pushes HEAD explicitly to the +// trunk branch resolved from GITHUB_REF, so the state commit lands on trunk. +func TestCommitAndPushGit_DetachedHeadPushesToTrunk(t *testing.T) { + tmp := t.TempDir() + bare := filepath.Join(tmp, "remote.git") + work := filepath.Join(tmp, "work") + + // Bare remote acts as origin's trunk host. + gitRun(t, tmp, "init", "--bare", "--initial-branch=main", bare) + + // Working clone with an initial commit on main, then push to seed origin/main. + gitRun(t, tmp, "clone", bare, work) + gitRun(t, work, "config", "user.name", "seed") + gitRun(t, work, "config", "user.email", "seed@example.com") + manifest := filepath.Join(work, "manifest.yaml") + require.NoError(t, os.WriteFile(manifest, []byte("ci:\n state: {}\n"), 0644)) + gitRun(t, work, "add", "manifest.yaml") + gitRun(t, work, "commit", "-m", "seed") + gitRun(t, work, "push", "origin", "main") + + // Detach HEAD onto the seed commit, mirroring the finalize checkout. + gitRun(t, work, "checkout", "--detach", "HEAD") + + // Sanity: a bare push from detached HEAD must fail, proving the scenario. + bareDetached := exec.Command("git", "push") + bareDetached.Dir = work + require.Error(t, bareDetached.Run(), + "bare git push from detached HEAD is expected to fail; the explicit ref is what fixes it") + + // Mutate the manifest so commitAndPushGit has something to commit. + require.NoError(t, os.WriteFile(manifest, []byte("ci:\n state:\n test: {}\n"), 0644)) + + t.Setenv("GITHUB_SERVER_URL", "http://gitea:3000") // force the plain-git path + t.Setenv("GITHUB_REF", "refs/heads/main") + + f := &Finalizer{configPath: manifest, targetEnv: "test"} + + // Run from the working tree so the relative configPath resolves. + restore, err := os.Getwd() + require.NoError(t, err) + require.NoError(t, os.Chdir(work)) + defer func() { _ = os.Chdir(restore) }() + + require.NoError(t, f.commitAndPushGit("chore: update state after promotion to test [skip ci]")) + + // origin/main on the bare remote must now point at the new state commit. + logCmd := exec.Command("git", "--git-dir", bare, "log", "-1", "--pretty=%s", "main") + out, err := logCmd.CombinedOutput() + require.NoError(t, err, "reading remote main log: %s", out) + require.Contains(t, string(out), "update state after promotion to test") +}