From a31883e26892bb3d0de3d7ce115f61ccf089f9af Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Thu, 11 Jun 2026 10:18:49 -0400 Subject: [PATCH 01/11] test(e2e): add hotfix promote guards scenario Signed-off-by: Joshua Temple --- e2e/scenarios/hotfix/hotfix-guards.yaml | 126 ++++++++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 e2e/scenarios/hotfix/hotfix-guards.yaml diff --git a/e2e/scenarios/hotfix/hotfix-guards.yaml b/e2e/scenarios/hotfix/hotfix-guards.yaml new file mode 100644 index 0000000..d172d0e --- /dev/null +++ b/e2e/scenarios/hotfix/hotfix-guards.yaml @@ -0,0 +1,126 @@ +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 + + - 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 + + - name: "Advance trunk 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 + + # Force-override path. Because the recorded patch is the original trunk commit + # (commit2), every later trunk SHA (commit3 and beyond) already contains it as + # an ancestor, so the patch-containment "missing patch" failure case cannot be + # engineered with real linear git history here. This step instead exercises the + # force input: with force set, the promotion proceeds regardless of containment. + # The outcome state is left unasserted because a contained promotion also + # triggers rejoin, which clears divergence; that path is asserted in + # hotfix-rejoin.yaml. + - name: "Force override exercises the force input path" + action: promote + promote: + mode: default + force: true From 583c5abd37156dc9ee031499fa096ae775f4354d Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Thu, 11 Jun 2026 10:18:49 -0400 Subject: [PATCH 02/11] test(e2e): add hotfix rejoin scenario Signed-off-by: Joshua Temple --- e2e/scenarios/hotfix/hotfix-rejoin.yaml | 107 ++++++++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 e2e/scenarios/hotfix/hotfix-rejoin.yaml diff --git a/e2e/scenarios/hotfix/hotfix-rejoin.yaml b/e2e/scenarios/hotfix/hotfix-rejoin.yaml new file mode 100644 index 0000000..97d4978 --- /dev/null +++ b/e2e/scenarios/hotfix/hotfix-rejoin.yaml @@ -0,0 +1,107 @@ +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 + + - 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 and deletes env/test on the remote. + - name: "Promote a containing SHA into test triggers rejoin" + action: promote + promote: + mode: default + expect: + branches: + deleted: ["env/test"] From b43ddb747dbc17b66e82653b465e337ce139dc3c Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Thu, 11 Jun 2026 10:18:49 -0400 Subject: [PATCH 03/11] test(e2e): add stacked hotfix scenario Signed-off-by: Joshua Temple --- e2e/scenarios/hotfix/hotfix-stacked.yaml | 121 +++++++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 e2e/scenarios/hotfix/hotfix-stacked.yaml diff --git a/e2e/scenarios/hotfix/hotfix-stacked.yaml b/e2e/scenarios/hotfix/hotfix-stacked.yaml new file mode 100644 index 0000000..5f01e66 --- /dev/null +++ b/e2e/scenarios/hotfix/hotfix-stacked.yaml @@ -0,0 +1,121 @@ +name: "Hotfix Stacked" +description: | + Verifies that two sequential hotfixes applied to the same environment stack: + the second finalize appends to the existing patches slice rather than + replacing it, and carries the original base anchor forward. After both + hotfixes, test still tracks env/test and its patches slice contains both + recorded trunk commits. + + The single-flight ordering rule (merge_pr before hotfix_merged, each preceded + by its hotfix_apply) is respected by the step order below. + +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 + + - name: "Apply first hotfix onto env/test" + action: hotfix_apply + hotfix_apply: + target_env: test + commit_ref: commit2 + + - 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" + + - name: "Commit the second trunk fix" + action: commit + commit: + message: "fix: second hotfix" + files: + src/fix2.go: | + package main + func fix2() {} + + - name: "Plan second hotfix for test" + action: hotfix_plan + hotfix_plan: + target_env: test + commit_ref: commit3 + + - 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] From 0d47d45c43456b0e8f098208231615acfedd2bf8 Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Thu, 11 Jun 2026 10:18:49 -0400 Subject: [PATCH 04/11] test(e2e): add hotfix prod gate generation scenario Signed-off-by: Joshua Temple --- e2e/scenarios/hotfix/prod-gate.yaml | 49 +++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 e2e/scenarios/hotfix/prod-gate.yaml diff --git a/e2e/scenarios/hotfix/prod-gate.yaml b/e2e/scenarios/hotfix/prod-gate.yaml new file mode 100644 index 0000000..18f0f2d --- /dev/null +++ b/e2e/scenarios/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:" From ed0e00f202f74a355109c7e37f2c15eed9ce318e Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Thu, 11 Jun 2026 10:22:57 -0400 Subject: [PATCH 05/11] test(e2e): refine hotfix scenario assertions and naming Signed-off-by: Joshua Temple --- .../{prod-gate.yaml => hotfix-prod-gate.yaml} | 0 e2e/scenarios/hotfix/hotfix-rejoin.yaml | 14 +++++- e2e/scenarios/hotfix/hotfix-stacked.yaml | 47 ++++++++++++------- 3 files changed, 43 insertions(+), 18 deletions(-) rename e2e/scenarios/hotfix/{prod-gate.yaml => hotfix-prod-gate.yaml} (100%) diff --git a/e2e/scenarios/hotfix/prod-gate.yaml b/e2e/scenarios/hotfix/hotfix-prod-gate.yaml similarity index 100% rename from e2e/scenarios/hotfix/prod-gate.yaml rename to e2e/scenarios/hotfix/hotfix-prod-gate.yaml diff --git a/e2e/scenarios/hotfix/hotfix-rejoin.yaml b/e2e/scenarios/hotfix/hotfix-rejoin.yaml index 97d4978..7da4fe2 100644 --- a/e2e/scenarios/hotfix/hotfix-rejoin.yaml +++ b/e2e/scenarios/hotfix/hotfix-rejoin.yaml @@ -97,11 +97,23 @@ steps: # 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 and deletes env/test on the remote. + # divergence fields (ref/base_sha/patches) and deletes env/test on the remote. + # prod is untouched by this default-mode dev-to-test promotion. + # + # The harness does not expose a "ref must be absent" assertion (an empty-string + # ref in StateExpect is skipped per assert.go:242); the branch deletion is the + # observable side effect of rejoin that the harness CAN verify. The manifest- + # level clearing of ref/base_sha/patches is covered by the finalize unit tests + # in internal/promote. - name: "Promote a containing SHA into test triggers rejoin" action: promote promote: mode: default expect: + state: + 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 index 5f01e66..90f58c7 100644 --- a/e2e/scenarios/hotfix/hotfix-stacked.yaml +++ b/e2e/scenarios/hotfix/hotfix-stacked.yaml @@ -1,13 +1,14 @@ name: "Hotfix Stacked" description: | - Verifies that two sequential hotfixes applied to the same environment stack: - the second finalize appends to the existing patches slice rather than - replacing it, and carries the original base anchor forward. After both - hotfixes, test still tracks env/test and its patches slice contains both - recorded trunk commits. + Verifies two behaviours around successive hotfixes on the same environment: - The single-flight ordering rule (merge_pr before hotfix_merged, each preceded - by its hotfix_apply) is respected by the step order below. + 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 @@ -66,6 +67,24 @@ steps: 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: @@ -80,16 +99,10 @@ steps: test: ref: "env/test" - - name: "Commit the second trunk fix" - action: commit - commit: - message: "fix: second hotfix" - files: - src/fix2.go: | - package main - func fix2() {} - - - name: "Plan second hotfix for 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 From 2fa4843e125137a4e198b4c109b6e97c981f850e Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Thu, 11 Jun 2026 15:09:48 -0400 Subject: [PATCH 06/11] test(e2e): add cleared-field matcher and tighten hotfix guard scenarios Signed-off-by: Joshua Temple --- e2e/harness/assert.go | 26 +++++++++++++++++++++ e2e/harness/multistep.go | 6 +++++ e2e/scenarios/hotfix/hotfix-guards.yaml | 30 ++++++++++++++++--------- e2e/scenarios/hotfix/hotfix-rejoin.yaml | 13 ++++++----- 4 files changed, 60 insertions(+), 15 deletions(-) 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/multistep.go b/e2e/harness/multistep.go index 6e12392..4b8300a 100644 --- a/e2e/harness/multistep.go +++ b/e2e/harness/multistep.go @@ -202,6 +202,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/scenarios/hotfix/hotfix-guards.yaml b/e2e/scenarios/hotfix/hotfix-guards.yaml index d172d0e..accd3d1 100644 --- a/e2e/scenarios/hotfix/hotfix-guards.yaml +++ b/e2e/scenarios/hotfix/hotfix-guards.yaml @@ -111,16 +111,26 @@ steps: - name: "Orchestrate so dev carries the patch's trunk ancestor" action: orchestrate - # Force-override path. Because the recorded patch is the original trunk commit - # (commit2), every later trunk SHA (commit3 and beyond) already contains it as - # an ancestor, so the patch-containment "missing patch" failure case cannot be - # engineered with real linear git history here. This step instead exercises the - # force input: with force set, the promotion proceeds regardless of containment. - # The outcome state is left unasserted because a contained promotion also - # triggers rejoin, which clears divergence; that path is asserted in - # hotfix-rejoin.yaml. - - name: "Force override exercises the force input path" + # Guard 2 (patch-containment passing branch): a promote INTO a diverged test + # env checks that every recorded patch is an ancestor of the incoming SHA. + # The recorded patch is commit2 - an ancestor of the current dev SHA (which + # carries commit3 > commit2 > commit1). Containment passes, so the promote + # succeeds and divergence clears via rejoin. The full rejoin-side assertions + # (cleared fields, branch deletion) live in hotfix-rejoin.yaml. + # + # The guard's failure branch (recorded patch is not an ancestor of the + # incoming SHA) and the force-override branch are covered by internal/promote + # unit tests: + # TestPromote_IntoDivergedEnv_MissingPatch_Blocked + # TestPromote_IntoDivergedEnv_Force_OverridesWithWarning + # Engineering a real non-ancestor patch at the e2e level requires a + # side-branch commit step not present in the harness; those paths are + # exercised directly in the promote unit tests against the git ancestry logic. + - name: "Promote a containing SHA into diverged test passes the containment guard" action: promote promote: mode: default - force: true + expect: + state: + dev: + unchanged: true diff --git a/e2e/scenarios/hotfix/hotfix-rejoin.yaml b/e2e/scenarios/hotfix/hotfix-rejoin.yaml index 7da4fe2..dc6102c 100644 --- a/e2e/scenarios/hotfix/hotfix-rejoin.yaml +++ b/e2e/scenarios/hotfix/hotfix-rejoin.yaml @@ -100,17 +100,20 @@ steps: # divergence fields (ref/base_sha/patches) and deletes env/test on the remote. # prod is untouched by this default-mode dev-to-test promotion. # - # The harness does not expose a "ref must be absent" assertion (an empty-string - # ref in StateExpect is skipped per assert.go:242); the branch deletion is the - # observable side effect of rejoin that the harness CAN verify. The manifest- - # level clearing of ref/base_sha/patches is covered by the finalize unit tests - # in internal/promote. + # 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: default expect: state: + test: + cleared: [ref, base_sha, patches] dev: unchanged: true prod: From af21c4c202e61f53d983fdef2aa4c3738ad39198 Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Thu, 11 Jun 2026 15:23:02 -0400 Subject: [PATCH 07/11] test: add hotfix containment guard e2e scenario Record the post-merge env branch tip as hotfix_head in merge_pr so a scenario can reference an off-trunk commit as a patch. Add a stage_divergence step that rewrites an environment's divergence fields in the live manifest mid-scenario, and a new hotfix-containment scenario that exercises both the patch-containment failure path (expect_failure) and the force override at the e2e level. Signed-off-by: Joshua Temple --- e2e/harness/hotfix_actions.go | 12 ++ e2e/harness/hotfix_actions_test.go | 63 ++++++++++ e2e/harness/multistep.go | 19 +++ e2e/harness/runner.go | 52 ++++++++ e2e/scenarios/hotfix/hotfix-containment.yaml | 124 +++++++++++++++++++ 5 files changed, 270 insertions(+) create mode 100644 e2e/scenarios/hotfix/hotfix-containment.yaml diff --git a/e2e/harness/hotfix_actions.go b/e2e/harness/hotfix_actions.go index 6d207ca..22ce5a4 100644 --- a/e2e/harness/hotfix_actions.go +++ b/e2e/harness/hotfix_actions.go @@ -258,6 +258,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 4b8300a..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"` 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..4430cbd --- /dev/null +++ b/e2e/scenarios/hotfix/hotfix-containment.yaml @@ -0,0 +1,124 @@ +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 + + - 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), so the promote into diverged test is blocked. test is + # left untouched. The exact ancestry message ("does not contain patch") is + # asserted by internal/promote unit tests; the e2e contract is that the + # promotion is observably blocked. + - name: "Guard: promote blocked - dev SHA does not contain off-trunk patch" + action: promote + promote: + mode: default + expect_failure: true + expect: + state: + test: + unchanged: true + + # Force override: the same promotion with force: true bypasses the containment + # guard and proceeds. dev is the promotion source and stays unchanged. + - name: "Force: promote overrides containment guard with force=true" + action: promote + promote: + mode: default + force: true + expect: + state: + dev: + unchanged: true From 087674bb837445410e1d21930f8e2d20a6927991 Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Thu, 11 Jun 2026 15:40:10 -0400 Subject: [PATCH 08/11] fix(e2e): seed github.event.inputs from workflow_dispatch inputs in act When RunWorkflowFromRepo dispatches a workflow_dispatch event, act does not reliably populate github.event.inputs from --input flags alone when --detect-event is set and no event file carries an inputs key. Job-level if: conditions that inspect github.event.inputs (such as the hotfix apply job's dry_run guard) mis-evaluate and the job runs when it should be skipped. Add dispatchInputsEventJSON and resolveEventJSON helpers so RunWorkflowFromRepo automatically synthesizes a minimal {"inputs":{...}} event payload and writes it via the existing writeEventFile/-e path when the event is workflow_dispatch and inputs are present. Explicit opts.EventJSON always wins. Add dry_run: true to hotfix scenarios (guards, containment, rejoin, stacked) that were missing it so the apply job is gated correctly in each one. Signed-off-by: Joshua Temple --- e2e/harness/act.go | 57 ++++++++++-- e2e/harness/act_test.go | 93 ++++++++++++++++++++ e2e/scenarios/hotfix/hotfix-containment.yaml | 4 + e2e/scenarios/hotfix/hotfix-guards.yaml | 4 + e2e/scenarios/hotfix/hotfix-rejoin.yaml | 4 + e2e/scenarios/hotfix/hotfix-stacked.yaml | 7 ++ 6 files changed, 164 insertions(+), 5 deletions(-) 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/scenarios/hotfix/hotfix-containment.yaml b/e2e/scenarios/hotfix/hotfix-containment.yaml index 4430cbd..64cc8b1 100644 --- a/e2e/scenarios/hotfix/hotfix-containment.yaml +++ b/e2e/scenarios/hotfix/hotfix-containment.yaml @@ -60,6 +60,10 @@ steps: 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 diff --git a/e2e/scenarios/hotfix/hotfix-guards.yaml b/e2e/scenarios/hotfix/hotfix-guards.yaml index accd3d1..61bb3d9 100644 --- a/e2e/scenarios/hotfix/hotfix-guards.yaml +++ b/e2e/scenarios/hotfix/hotfix-guards.yaml @@ -62,6 +62,10 @@ steps: 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 diff --git a/e2e/scenarios/hotfix/hotfix-rejoin.yaml b/e2e/scenarios/hotfix/hotfix-rejoin.yaml index dc6102c..7e801e2 100644 --- a/e2e/scenarios/hotfix/hotfix-rejoin.yaml +++ b/e2e/scenarios/hotfix/hotfix-rejoin.yaml @@ -59,6 +59,10 @@ steps: 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 diff --git a/e2e/scenarios/hotfix/hotfix-stacked.yaml b/e2e/scenarios/hotfix/hotfix-stacked.yaml index 90f58c7..25cf872 100644 --- a/e2e/scenarios/hotfix/hotfix-stacked.yaml +++ b/e2e/scenarios/hotfix/hotfix-stacked.yaml @@ -60,6 +60,10 @@ steps: 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 @@ -107,6 +111,9 @@ steps: 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 From f72d343ed357650d59ab1de43a095d2d2efb8df5 Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Thu, 11 Jun 2026 15:58:58 -0400 Subject: [PATCH 09/11] test(e2e): isolate containment guard via targeted cascade promote Drive the patch-containment failure and force-override paths through a targeted dev-to-test cascade promote so the diverged-source guard does not mask the containment check, and point hotfix-guards at the dedicated containment scenario. Signed-off-by: Joshua Temple --- e2e/scenarios/hotfix/hotfix-containment.yaml | 19 +++++---- e2e/scenarios/hotfix/hotfix-guards.yaml | 44 +++++--------------- 2 files changed, 22 insertions(+), 41 deletions(-) diff --git a/e2e/scenarios/hotfix/hotfix-containment.yaml b/e2e/scenarios/hotfix/hotfix-containment.yaml index 64cc8b1..64407de 100644 --- a/e2e/scenarios/hotfix/hotfix-containment.yaml +++ b/e2e/scenarios/hotfix/hotfix-containment.yaml @@ -101,26 +101,29 @@ steps: action: orchestrate # Containment failure: dev's SHA does not contain hotfix_head (an env/test-only - # squash-merge commit), so the promote into diverged test is blocked. test is - # left untouched. The exact ancestry message ("does not contain patch") is - # asserted by internal/promote unit tests; the e2e contract is that the - # promotion is observably blocked. + # 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: default + mode: cascade + target: test expect_failure: true expect: state: test: unchanged: true - # Force override: the same promotion with force: true bypasses the containment - # guard and proceeds. dev is the promotion source and stays unchanged. + # 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: default + mode: cascade + target: test force: true expect: state: diff --git a/e2e/scenarios/hotfix/hotfix-guards.yaml b/e2e/scenarios/hotfix/hotfix-guards.yaml index 61bb3d9..6ff43b4 100644 --- a/e2e/scenarios/hotfix/hotfix-guards.yaml +++ b/e2e/scenarios/hotfix/hotfix-guards.yaml @@ -103,38 +103,16 @@ steps: test: unchanged: true - - name: "Advance trunk 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 - - # Guard 2 (patch-containment passing branch): a promote INTO a diverged test - # env checks that every recorded patch is an ancestor of the incoming SHA. - # The recorded patch is commit2 - an ancestor of the current dev SHA (which - # carries commit3 > commit2 > commit1). Containment passes, so the promote - # succeeds and divergence clears via rejoin. The full rejoin-side assertions - # (cleared fields, branch deletion) live in hotfix-rejoin.yaml. - # - # The guard's failure branch (recorded patch is not an ancestor of the - # incoming SHA) and the force-override branch are covered by internal/promote - # unit tests: + # 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 the e2e level requires a - # side-branch commit step not present in the harness; those paths are - # exercised directly in the promote unit tests against the git ancestry logic. - - name: "Promote a containing SHA into diverged test passes the containment guard" - action: promote - promote: - mode: default - expect: - state: - dev: - unchanged: true + # 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. From d09dec423fab0e6bf43caf2a402828b8bcb68e1f Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Thu, 11 Jun 2026 16:05:11 -0400 Subject: [PATCH 10/11] fix(e2e): make stacked hotfix apply re-anchor on advanced env branch A second hotfix_apply onto an env branch the first finalize already advanced via squash-merge branched from a stale env tip and replayed an already-merged change, so the push was rejected non-fast-forward. Abort any half-finished cherry-pick, force-refresh the env tracking ref, drop any stale local hotfix branch, and force-push the uniquely-named per-apply branch. Signed-off-by: Joshua Temple --- e2e/harness/hotfix_actions.go | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/e2e/harness/hotfix_actions.go b/e2e/harness/hotfix_actions.go index 22ce5a4..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") From 209a199a6db92731171a27f80e0c50b06f9a5a8e Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Thu, 11 Jun 2026 16:20:03 -0400 Subject: [PATCH 11/11] fix: make finalize git push detached-head safe with explicit trunk ref The finalize job checks out the triggering SHA, so on a workflow_dispatch run HEAD is detached. A bare git push then fails with exit 128 because no upstream tracking branch exists, which broke the rejoin leg of the hotfix-rejoin e2e scenario. Push HEAD explicitly to refs/heads/ resolved from GITHUB_REF, and capture combined output so the real git error surfaces in the workflow log. Real GitHub is unaffected: that path writes state through the Contents REST API, not plain git. Signed-off-by: Joshua Temple --- e2e/scenarios/hotfix/hotfix-rejoin.yaml | 10 +++- internal/promote/finalize.go | 13 +++-- internal/promote/finalize_test.go | 67 +++++++++++++++++++++++++ 3 files changed, 85 insertions(+), 5 deletions(-) diff --git a/e2e/scenarios/hotfix/hotfix-rejoin.yaml b/e2e/scenarios/hotfix/hotfix-rejoin.yaml index 7e801e2..62c2014 100644 --- a/e2e/scenarios/hotfix/hotfix-rejoin.yaml +++ b/e2e/scenarios/hotfix/hotfix-rejoin.yaml @@ -102,7 +102,12 @@ steps: # 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. - # prod is untouched by this default-mode dev-to-test promotion. + # + # 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 @@ -113,7 +118,8 @@ steps: - name: "Promote a containing SHA into test triggers rejoin" action: promote promote: - mode: default + mode: cascade + target: test expect: state: test: 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") +}