diff --git a/e2e/harness/hotfix_actions.go b/e2e/harness/hotfix_actions.go index 91a8fe2..10570e0 100644 --- a/e2e/harness/hotfix_actions.go +++ b/e2e/harness/hotfix_actions.go @@ -22,6 +22,26 @@ func (r *Runner) resolveCommit(ref string) string { return ref } +// resolveCommits resolves a comma-delimited commit-ref list, resolving each +// entry to its SHA via the execution context and rejoining with commas. It +// mirrors how the plan job forwards a multi-commit dispatch input to +// `cascade hotfix plan --commits`: each ref must be a real SHA the planner can +// rev-parse, so a scenario reference like "commit2,commit3" must resolve to +// "," before reaching the workflow. A single ref with no comma +// resolves identically to resolveCommit, keeping single-commit callers stable. +func (r *Runner) resolveCommits(refList string) string { + parts := strings.Split(refList, ",") + resolved := make([]string, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + continue + } + resolved = append(resolved, r.resolveCommit(part)) + } + return strings.Join(resolved, ",") +} + // shortSHA returns the first 8 characters of a commit SHA, mirroring the hotfix // workflow's SHORT_SHA computation (internal/generate/hotfix.go). func shortSHA(sha string) string { @@ -65,7 +85,11 @@ func (r *Runner) executeHotfixPlan(ctx context.Context, step *HotfixPlanStep) er return nil } - sha := r.resolveCommit(step.CommitRef) + // CommitRef may be a comma-delimited list (the multi-commit chain dispatch + // input). Resolve each entry to a real SHA so the plan job's + // `cascade hotfix plan --commits` can rev-parse every commit; a single ref + // resolves identically to the single-commit path. + sha := r.resolveCommits(step.CommitRef) dryRun := "false" if step.DryRun { dryRun = "true" diff --git a/e2e/scenarios/hotfix/hotfix-multi-env-clean.yaml b/e2e/scenarios/hotfix/hotfix-multi-env-clean.yaml new file mode 100644 index 0000000..5ef5c5a --- /dev/null +++ b/e2e/scenarios/hotfix/hotfix-multi-env-clean.yaml @@ -0,0 +1,223 @@ +name: "Hotfix Multi-Env Clean Chain" +description: | + Verifies a clean multi-environment hotfix chain. The dispatch targets staging, + so the planner's bottom-up chain is [test, staging]: the fix must land on every + intermediate environment between the build target and the requested target, not + just the requested one. + + Three trunk fixes (commit2, commit3, commit4) are cherry-picked onto both env/test + and env/staging. Each (commit, env) lands via a discrete plan-apply-merge-merged + cycle - the e2e backend is gitea, whose cherry-picks apply cleanly and whose + hotfix_apply step drives the real API while the workflow's apply job stays a + dry-run plan (the act image lacks the gh CLI). The final state shows both test and + staging carrying all three patches. + + The generated apply job's env_sequence loop, per-env case lookup, and conflict + halt contract are asserted in the generator unit tests; this scenario covers the + observable outcome that the fix reaches every chain environment. + +config: + trunk_branch: main + environments: [dev, test, staging, 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-staging + 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 test/staging/prod baselines" + action: promote + promote: + mode: default + + - name: "Promote again to carry the baseline down the chain" + action: promote + promote: + mode: default + + - name: "Promote once more so every env shares the baseline" + action: promote + promote: + mode: default + + - name: "First trunk fix" + action: commit + commit: + message: "fix: first patch" + files: + src/fix1.go: | + package main + func fix1() {} + + - name: "Second trunk fix" + action: commit + commit: + message: "fix: second patch" + files: + src/fix2.go: | + package main + func fix2() {} + + - name: "Third trunk fix" + action: commit + commit: + message: "fix: third patch" + files: + src/fix3.go: | + package main + func fix3() {} + + # Plan the chain once. Targeting staging exercises the bottom-up [test, staging] + # sequence the apply loop walks. dry_run keeps it to validation: the act image + # has no gh CLI, so the discrete hotfix_apply steps below drive the real + # cherry-picks via the gitea API. + - name: "Plan hotfix chain for staging" + action: hotfix_plan + hotfix_plan: + target_env: staging + commit_ref: commit2 + dry_run: true + + # env/test receives all three fixes, one discrete cycle each. + - name: "Apply fix1 onto env/test" + action: hotfix_apply + hotfix_apply: + target_env: test + commit_ref: commit2 + + - name: "Merge fix1 test PR" + action: merge_pr + merge_pr: + label: cascade-hotfix + + - name: "Finalize fix1 on test" + action: hotfix_merged + hotfix_merged: + target_env: test + expect: + state: + test: + ref: "env/test" + + - name: "Apply fix2 onto env/test" + action: hotfix_apply + hotfix_apply: + target_env: test + commit_ref: commit3 + + - name: "Merge fix2 test PR" + action: merge_pr + merge_pr: + label: cascade-hotfix + + - name: "Finalize fix2 on test" + action: hotfix_merged + hotfix_merged: + target_env: test + + - name: "Apply fix3 onto env/test" + action: hotfix_apply + hotfix_apply: + target_env: test + commit_ref: commit4 + + - name: "Merge fix3 test PR" + action: merge_pr + merge_pr: + label: cascade-hotfix + + - name: "Finalize fix3 on test; test carries all three patches" + action: hotfix_merged + hotfix_merged: + target_env: test + expect: + state: + test: + ref: "env/test" + patches: [commit2, commit3, commit4] + + # env/staging receives the same three fixes - the chain target. + - name: "Apply fix1 onto env/staging" + action: hotfix_apply + hotfix_apply: + target_env: staging + commit_ref: commit2 + + - name: "Merge fix1 staging PR" + action: merge_pr + merge_pr: + label: cascade-hotfix + + - name: "Finalize fix1 on staging" + action: hotfix_merged + hotfix_merged: + target_env: staging + expect: + state: + staging: + ref: "env/staging" + + - name: "Apply fix2 onto env/staging" + action: hotfix_apply + hotfix_apply: + target_env: staging + commit_ref: commit3 + + - name: "Merge fix2 staging PR" + action: merge_pr + merge_pr: + label: cascade-hotfix + + - name: "Finalize fix2 on staging" + action: hotfix_merged + hotfix_merged: + target_env: staging + + - name: "Apply fix3 onto env/staging" + action: hotfix_apply + hotfix_apply: + target_env: staging + commit_ref: commit4 + + - name: "Merge fix3 staging PR" + action: merge_pr + merge_pr: + label: cascade-hotfix + + - name: "Finalize fix3 on staging; staging carries all three patches" + action: hotfix_merged + hotfix_merged: + target_env: staging + expect: + state: + test: + ref: "env/test" + patches: [commit2, commit3, commit4] + staging: + ref: "env/staging" + patches: [commit2, commit3, commit4] diff --git a/e2e/scenarios/hotfix/hotfix-multi-env-conflict-halt.yaml b/e2e/scenarios/hotfix/hotfix-multi-env-conflict-halt.yaml new file mode 100644 index 0000000..23083d7 --- /dev/null +++ b/e2e/scenarios/hotfix/hotfix-multi-env-conflict-halt.yaml @@ -0,0 +1,162 @@ +name: "Hotfix Multi-Env Conflict Halt" +description: | + Verifies that a cherry-pick conflict on an intermediate environment halts the + multi-environment hotfix chain: the conflicting env gets a resolution PR and every + later env in the chain is left untouched until the operator resolves and re-engages. + + Two trunk fixes touch the same line of src/conflict.go: + - commit2 rewrites the line one way + - commit3 rewrites the same original line a different way + + commit2 lands clean on env/test. Cherry-picking commit3 onto env/test (which now + carries commit2's version, not the original) is the conflict the apply loop trips + on, halting before staging. + + The e2e backend is gitea, whose server-side cherry-pick applies the overlapping + edit cleanly and labels the PR cascade-hotfix; the conflict-labeled PR path and the + halt-then-resume PR body are GitHub-only behaviors exercised by the real-GitHub + validation fleet. This scenario asserts the gitea-observable boundary - staging is + never hotfixed (no env/staging ref, no patches) - and asserts against the + materialized workflow that the apply loop carries the conflict-halt contract: + it opens a cascade-hotfix-conflict PR and breaks the chain rather than continuing. + +config: + trunk_branch: main + environments: [dev, test, staging, 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-staging + workflow: deploy.yaml + triggers: ["src/**"] + - name: deploy-prod + workflow: deploy.yaml + triggers: ["src/**"] + +steps: + - name: "Initial commit with a conflict-prone line" + action: commit + commit: + message: "feat: add conflict-prone file" + files: + src/conflict.go: | + package main + const Marker = "placeholder" + + - name: "Orchestrate trunk into dev" + action: orchestrate + + - name: "Promote to establish test/staging/prod baselines" + action: promote + promote: + mode: default + + - name: "Promote again to carry the baseline down the chain" + action: promote + promote: + mode: default + + - name: "Promote once more so every env shares the baseline" + action: promote + promote: + mode: default + + - name: "First trunk fix rewrites the marker line" + action: commit + commit: + message: "fix: marker fix1" + files: + src/conflict.go: | + package main + const Marker = "fix1-change" + + - name: "Second trunk fix rewrites the same line differently" + action: commit + commit: + message: "fix: marker fix2" + files: + src/conflict.go: | + package main + const Marker = "fix2-change" + + # Plan the chain targeting staging: bottom-up [test, staging]. The conflict halts + # the chain on test, so staging is never reached. dry_run keeps it to validation + # (the act image has no gh CLI); the discrete hotfix_apply steps drive the real + # cherry-picks via the gitea API, and the workflow_files assertion below proves the + # materialized apply loop carries the conflict-halt contract real GitHub runs. + - name: "Plan hotfix chain for staging" + action: hotfix_plan + hotfix_plan: + target_env: staging + commit_ref: commit2 + dry_run: true + expect: + workflow_files: + - path: ".github/workflows/cascade-hotfix.yaml" + contains: + # The apply loop walks the planner's env_sequence bottom-up. + - "ENV_SEQUENCE: ${{ needs.plan.outputs.env_sequence }}" + - "for env in $(echo" + # On conflict it opens a conflict-labeled PR and breaks the chain. + - "gh label create cascade-hotfix-conflict " + - "--label cascade-hotfix-conflict" + - "Environments still pending:" + - "After merge, re-engage the hotfix workflow targeting" + + - name: "Apply fix1 onto env/test (clean)" + action: hotfix_apply + hotfix_apply: + target_env: test + commit_ref: commit2 + + - name: "Merge fix1 test PR" + action: merge_pr + merge_pr: + label: cascade-hotfix + + - name: "Finalize fix1 on test" + action: hotfix_merged + hotfix_merged: + target_env: test + expect: + state: + test: + ref: "env/test" + patches: [commit2] + + # Cherry-picking commit3 onto env/test (which now carries fix1-change, not the + # original placeholder) is the conflict that halts the chain. On the gitea backend + # this lands clean and opens a cascade-hotfix PR; the conflict-labeled path is + # asserted against the materialized workflow above and exercised live by the + # real-GitHub fleet. Either way, the chain never advances to staging here because + # the harness applies strictly the single requested env. + - name: "Apply fix2 onto env/test (conflict on real GitHub; clean on gitea)" + action: hotfix_apply + hotfix_apply: + target_env: test + commit_ref: commit3 + expect: + branches: + exist: ["env/test"] + prs: + open_with_label: "cascade-hotfix" + + # The chain halted on test: staging was never hotfixed. Its env branch and patch + # set must be absent. staging still shares the promoted baseline, so it tracks + # trunk state (no divergence ref), which is what "no env/staging ref" asserts. + - name: "Staging is untouched: no hotfix ref, no patches" + action: hotfix_merged + hotfix_merged: + target_env: test + expect: + state: + staging: + unchanged: true diff --git a/e2e/scenarios/hotfix/hotfix-multi-env-idempotent.yaml b/e2e/scenarios/hotfix/hotfix-multi-env-idempotent.yaml new file mode 100644 index 0000000..0f11d79 --- /dev/null +++ b/e2e/scenarios/hotfix/hotfix-multi-env-idempotent.yaml @@ -0,0 +1,192 @@ +name: "Hotfix Multi-Env Idempotent Chain" +description: | + Verifies per-(commit, env) idempotency across a multi-environment hotfix chain. + commit2 is pre-applied to env/test before the chain runs, so when the operator + later requests a {commit2, commit3} chain targeting staging, the planner reports + commit2 as already-present on test (no-op skip) and only commit3 needs to land + there. staging, never hotfixed, must receive both commit2 and commit3. + + The apply job's per-env skip-when-empty behavior (the loop's `if [ -z "$COMMITS" ]` + guard) is what lets test skip the already-present commit while still cherry-picking + the new one, and lets staging take the full set. That branch is asserted in the + generator unit tests; this scenario covers the observable end state. + + The e2e backend is gitea, whose hotfix_apply step drives the real cherry-pick via + the API while the workflow's apply job stays a dry-run plan (the act image lacks + the gh CLI). Each (commit, env) lands via a discrete plan-apply-merge-merged cycle. + +config: + trunk_branch: main + environments: [dev, test, staging, 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-staging + 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 test/staging/prod baselines" + action: promote + promote: + mode: default + + - name: "Promote again to carry the baseline down the chain" + action: promote + promote: + mode: default + + - name: "Promote once more so every env shares the baseline" + action: promote + promote: + mode: default + + # Both trunk fixes are committed up front, before any merge_pr records a + # post-merge env tip into the execution context. The harness numbers commit + # references off every recorded SHA, so committing both fixes here keeps them + # deterministically commit2 and commit3; interleaving a commit after a merge_pr + # would shift the numbering. + - name: "First trunk fix" + action: commit + commit: + message: "fix: first patch" + files: + src/fix1.go: | + package main + func fix1() {} + + - name: "Second trunk fix" + action: commit + commit: + message: "fix: second patch" + files: + src/fix2.go: | + package main + func fix2() {} + + # Pre-apply commit2 to env/test only. After this, test already contains the fix + # so a later chain that includes commit2 must skip it on test (idempotent no-op). + - name: "Plan pre-apply of fix1 for test" + action: hotfix_plan + hotfix_plan: + target_env: test + commit_ref: commit2 + dry_run: true + + - name: "Pre-apply fix1 onto env/test" + action: hotfix_apply + hotfix_apply: + target_env: test + commit_ref: commit2 + + - name: "Merge pre-apply test PR" + action: merge_pr + merge_pr: + label: cascade-hotfix + + - name: "Finalize pre-apply; test carries fix1" + action: hotfix_merged + hotfix_merged: + target_env: test + expect: + state: + test: + ref: "env/test" + patches: [commit2] + + # Plan the {commit2, commit3} chain targeting staging. The planner sees commit2 + # already on env/test and reports it no-op for test, so only commit3 applies + # there; staging takes both. dry_run keeps it to validation (the act image has no + # gh CLI), so the discrete hotfix_apply steps below drive the real cherry-picks. + - name: "Plan idempotent chain {fix1, fix2} for staging" + action: hotfix_plan + hotfix_plan: + target_env: staging + commit_ref: commit2,commit3 + dry_run: true + + # test: commit2 is already present (skip); only commit3 lands. + - name: "Apply only fix2 onto env/test (fix1 already present)" + action: hotfix_apply + hotfix_apply: + target_env: test + commit_ref: commit3 + + - name: "Merge fix2 test PR" + action: merge_pr + merge_pr: + label: cascade-hotfix + + - name: "Finalize fix2 on test; test carries both patches" + action: hotfix_merged + hotfix_merged: + target_env: test + expect: + state: + test: + ref: "env/test" + patches: [commit2, commit3] + + # staging: never hotfixed, so it receives the full requested set. + - name: "Apply fix1 onto env/staging" + action: hotfix_apply + hotfix_apply: + target_env: staging + commit_ref: commit2 + + - name: "Merge fix1 staging PR" + action: merge_pr + merge_pr: + label: cascade-hotfix + + - name: "Finalize fix1 on staging" + action: hotfix_merged + hotfix_merged: + target_env: staging + + - name: "Apply fix2 onto env/staging" + action: hotfix_apply + hotfix_apply: + target_env: staging + commit_ref: commit3 + + - name: "Merge fix2 staging PR" + action: merge_pr + merge_pr: + label: cascade-hotfix + + - name: "Finalize fix2 on staging; staging carries both patches" + action: hotfix_merged + hotfix_merged: + target_env: staging + expect: + state: + test: + ref: "env/test" + patches: [commit2, commit3] + staging: + ref: "env/staging" + patches: [commit2, commit3] diff --git a/internal/generate/hotfix.go b/internal/generate/hotfix.go index ba3e72f..7054370 100644 --- a/internal/generate/hotfix.go +++ b/internal/generate/hotfix.go @@ -139,7 +139,7 @@ func (g *HotfixGenerator) writeTriggers(sb *strings.Builder) { sb.WriteString(" workflow_dispatch:\n") sb.WriteString(" inputs:\n") sb.WriteString(" commit:\n") - sb.WriteString(" description: 'Trunk commit SHA to hotfix (must be on trunk)'\n") + sb.WriteString(" description: 'Trunk commit SHA(s) to hotfix, comma-delimited (must be on trunk)'\n") sb.WriteString(" required: true\n") sb.WriteString(" type: string\n") sb.WriteString(" target_env:\n") @@ -219,6 +219,12 @@ func (g *HotfixGenerator) writePlanJob(sb *strings.Builder) { sb.WriteString(" hotfix_version_candidate: ${{ steps.plan.outputs.hotfix_version_candidate }}\n") sb.WriteString(" conflict_expected: ${{ steps.plan.outputs.conflict_expected }}\n") sb.WriteString(" no_op: ${{ steps.plan.outputs.no_op }}\n") + sb.WriteString(" env_sequence: ${{ steps.plan.outputs.env_sequence }}\n") + for _, env := range g.targetEnvs() { + fmt.Fprintf(sb, " commits_%s: ${{ steps.plan.outputs.commits_%s }}\n", env, env) + fmt.Fprintf(sb, " no_op_%s: ${{ steps.plan.outputs.no_op_%s }}\n", env, env) + fmt.Fprintf(sb, " base_%s: ${{ steps.plan.outputs.base_%s }}\n", env, env) + } sb.WriteString(" steps:\n") writeActionStep(sb, g.config, " ", actionCheckout) sb.WriteString(" with:\n") @@ -236,12 +242,15 @@ func (g *HotfixGenerator) writePlanJob(sb *strings.Builder) { sb.WriteString(" run: |\n") sb.WriteString(" cascade hotfix plan \\\n") fmt.Fprintf(sb, " --config %s \\\n", g.getManifestFilePath()) - sb.WriteString(" --commit \"$HOTFIX_COMMIT\" \\\n") + sb.WriteString(" --commits \"$HOTFIX_COMMIT\" \\\n") sb.WriteString(" --target-env \"$HOTFIX_TARGET_ENV\" \\\n") sb.WriteString(" --dry-run=\"$HOTFIX_DRY_RUN\" \\\n") sb.WriteString(" --gha-output\n") // Q6: surface the planner's ready-to-run branch-protection commands. + // On the --commits (chain) path, protection_suggestions is not emitted by + // chainGHAOutputs, so this step's if: guard skips it silently. That is the + // correct behavior: protection suggestions are single-env-plan output only. sb.WriteString(" - name: Surface protection suggestions\n") sb.WriteString(" if: steps.plan.outputs.protection_suggestions != ''\n") sb.WriteString(" env:\n") @@ -254,43 +263,45 @@ func (g *HotfixGenerator) writePlanJob(sb *strings.Builder) { } // writeApplyJob emits the apply job, run on dispatch when not a dry-run. It -// cherry-picks the commit onto a hotfix branch and opens a resolution PR via gh -// pr create. The job-level GH_TOKEN is the configured state token so the PR is -// authored by a trigger-capable actor: this fires on: pull_request, which lets a -// protected env branch's required check post on PR open rather than only after -// this run finishes. A clean cherry-pick is then merged by the dedicated merge -// step (also as the state token), which polls until the PR is mergeable so the -// required check still gates the merge. A conflicting cherry-pick opens a labeled -// PR for local resolution and is merged by a human via the UI. +// walks the bottom-up env_sequence the planner produced, cherry-picking each +// env's still-to-apply commits onto a hotfix// branch, opening a +// resolution PR, and merging it before moving to the next env. Each env's commit +// list and base SHA are resolved from statically baked per-env outputs the plan +// job exposes. The job-level GH_TOKEN is the configured state token so every PR +// is authored by a trigger-capable actor: this fires on: pull_request, which lets +// a protected env branch's required check post on PR open rather than only after +// this run finishes. A clean cherry-pick is merged inline (polling until the PR +// is mergeable so the required check still gates the merge) and the loop proceeds +// to the next env. A conflicting cherry-pick opens a labeled PR for local +// resolution and halts the chain: later envs are left untouched until the +// operator resolves the conflict and re-engages the workflow. func (g *HotfixGenerator) writeApplyJob(sb *strings.Builder) { sb.WriteString(" apply:\n") sb.WriteString(" name: Apply Hotfix Cherry-Pick\n") sb.WriteString(" needs: plan\n") - // Skip the cherry-pick on a dry-run and on a no-op plan: when the fix is - // already contained in the target state SHA the planner reports no_op and - // there is nothing to cherry-pick, so attempting one would fail. - sb.WriteString(" if: github.event_name == 'workflow_dispatch' && github.event.inputs.dry_run != 'true' && needs.plan.outputs.no_op != 'true'\n") + // Gate on env_sequence being non-empty: if the planner emitted no envs to + // process the apply job is a no-op. Per-env idempotency (all commits already + // present for a given env) is handled inside the loop where COMMITS is empty. + sb.WriteString(" if: github.event_name == 'workflow_dispatch' && github.event.inputs.dry_run != 'true' && needs.plan.outputs.env_sequence != ''\n") sb.WriteString(" runs-on: ubuntu-latest\n") sb.WriteString(" env:\n") - // Author the resolution PR with the configured state token so gh pr create + // Author every resolution PR with the configured state token so gh pr create // runs as a trigger-capable actor. A PR opened under the default GITHUB_TOKEN // is authored by github-actions[bot], and a bot-authored PR does not fire // on: pull_request workflows; the env-branch required check would then post // only via on: workflow_run after this run finishes, deadlocking against the - // merge step that waits for that check. A PAT-authored PR fires on: - // pull_request so the check posts on PR open, independent of this job. The - // merge step (writeCleanMergeStep) inherits this same job-level token. When - // no state token is configured this degrades to GITHUB_TOKEN, in which case + // inline merge poll that waits for that check. A PAT-authored PR fires on: + // pull_request so the check posts on PR open, independent of this job. When no + // state token is configured this degrades to GITHUB_TOKEN, in which case // post-hotfix automation (early check + finalize) requires the operator to - // supply a trigger-capable state_token, matching the merge step's caveat. - // This is a job-level env, where the steps.* context is not available, so it - // always binds the static state token. When a state-token App is configured - // the per-step consumers (the merge step below) carry the minted-token - // fallback themselves; this job-level default stays on the static token. + // supply a trigger-capable state_token. fmt.Fprintf(sb, " GH_TOKEN: %s\n", g.config.GetStateToken()) - sb.WriteString(" COMMIT: ${{ github.event.inputs.commit }}\n") - sb.WriteString(" TARGET_ENV: ${{ github.event.inputs.target_env }}\n") - sb.WriteString(" BASE_SHA: ${{ needs.plan.outputs.base_sha }}\n") + // HOTFIX_COMMIT and HOTFIX_TARGET_ENV carry the operator's original dispatch + // inputs for human-facing messaging (the conflict PR body's re-engage hint). + // ENV_SEQUENCE is the planner's bottom-up env chain the loop walks. + sb.WriteString(" HOTFIX_COMMIT: ${{ github.event.inputs.commit }}\n") + sb.WriteString(" HOTFIX_TARGET_ENV: ${{ github.event.inputs.target_env }}\n") + sb.WriteString(" ENV_SEQUENCE: ${{ needs.plan.outputs.env_sequence }}\n") sb.WriteString(" steps:\n") writeMintSteps(sb, g.config, " ", seamState) writeActionStep(sb, g.config, " ", actionCheckout) @@ -304,18 +315,21 @@ func (g *HotfixGenerator) writeApplyJob(sb *strings.Builder) { sb.WriteString(" run: |\n") writeGitConfigSteps(sb, g.config, " ") - // Q2: warn (do not fail) when the env branch lacks required-status-check - // protection, and print the exact command to configure it. + // Q2: warn (do not fail) when an env branch lacks required-status-check + // protection, and print the exact command to configure it. The check loops + // over the whole chain so every env the apply step may touch is reported. sb.WriteString(" - name: Check branch protection on env branch\n") sb.WriteString(" continue-on-error: true\n") sb.WriteString(" run: |\n") - sb.WriteString(" PROT_PATH=\"repos/${{ github.repository }}/branches/env%2F${TARGET_ENV}/protection\"\n") - sb.WriteString(" PROT=$(gh api \"$PROT_PATH\" 2>/dev/null || echo '')\n") - sb.WriteString(" CHECKS=$(echo \"$PROT\" | jq -r '.required_status_checks.contexts[]? // empty' 2>/dev/null || echo '')\n") - sb.WriteString(" if [ -z \"$PROT\" ] || [ -z \"$CHECKS\" ]; then\n") - sb.WriteString(" echo \"::warning::Branch env/${TARGET_ENV} has no required status checks; hotfix auto-merge will NOT be gated by required checks.\"\n") - sb.WriteString(" echo \"::warning::Configure protection: gh api \\\"$PROT_PATH\\\" -X PUT -f required_status_checks.strict=true -F required_status_checks.contexts[]=hotfix-check\"\n") - sb.WriteString(" fi\n") + sb.WriteString(" for env in $(echo \"$ENV_SEQUENCE\" | tr ',' '\\n'); do\n") + sb.WriteString(" PROT_PATH=\"repos/${{ github.repository }}/branches/env%2F${env}/protection\"\n") + sb.WriteString(" PROT=$(gh api \"$PROT_PATH\" 2>/dev/null || echo '')\n") + sb.WriteString(" CHECKS=$(echo \"$PROT\" | jq -r '.required_status_checks.contexts[]? // empty' 2>/dev/null || echo '')\n") + sb.WriteString(" if [ -z \"$PROT\" ] || [ -z \"$CHECKS\" ]; then\n") + sb.WriteString(" echo \"::warning::Branch env/${env} has no required status checks; hotfix auto-merge will NOT be gated by required checks.\"\n") + sb.WriteString(" echo \"::warning::Configure protection: gh api \\\"$PROT_PATH\\\" -X PUT -f required_status_checks.strict=true -F required_status_checks.contexts[]=hotfix-check\"\n") + sb.WriteString(" fi\n") + sb.WriteString(" done\n") // Seed both resolution-PR labels before any PR is opened. gh pr create // --label fails hard on a missing label; seeding here guarantees both the @@ -327,106 +341,113 @@ func (g *HotfixGenerator) writeApplyJob(sb *strings.Builder) { fmt.Fprintf(sb, " gh label create %s --color B60205 --description \"Cascade hotfix resolution PR\" || true\n", hotfixLabel) fmt.Fprintf(sb, " gh label create %s --color D93F0B --description \"Cascade hotfix resolution PR with cherry-pick conflicts\" || true\n", hotfixConflictLabel) - // Cherry-pick. Clean and conflict paths diverge after the cherry-pick result. - sb.WriteString(" - name: Cherry-pick and open resolution PR\n") - sb.WriteString(" run: |\n") - sb.WriteString(" SHORT_SHA=$(echo \"$COMMIT\" | cut -c1-8)\n") - sb.WriteString(" BRANCH=\"hotfix/${TARGET_ENV}/${SHORT_SHA}\"\n") - // The first hotfix into an environment runs before env/ has ever been - // pushed: the plan verb creates it locally at the recorded state SHA but does - // not push, so origin/env/ may not exist yet. Materialize it at BASE_SHA - // (the plan's validated base) and push so the resolution PR has a base branch, - // then branch the hotfix from BASE_SHA. When the env branch already exists its - // tip equals BASE_SHA (the plan enforces this), so this is a no-op create. - sb.WriteString(" if ! git rev-parse --verify --quiet \"refs/remotes/origin/env/${TARGET_ENV}\" >/dev/null; then\n") - sb.WriteString(" git push origin \"${BASE_SHA}:refs/heads/env/${TARGET_ENV}\"\n") - sb.WriteString(" git fetch origin \"+refs/heads/env/${TARGET_ENV}:refs/remotes/origin/env/${TARGET_ENV}\"\n") - sb.WriteString(" fi\n") - sb.WriteString(" git switch -c \"$BRANCH\" \"$BASE_SHA\"\n") - sb.WriteString(" BODY=$(printf 'Cascade-Hotfix-Target: %s\\nCascade-Hotfix-Source: %s\\nCascade-Hotfix-Base: %s\\n' \"$TARGET_ENV\" \"$COMMIT\" \"$BASE_SHA\")\n") - sb.WriteString(" if git cherry-pick -x \"$COMMIT\"; then\n") - sb.WriteString(" echo \"clean cherry-pick\"\n") - sb.WriteString(" git push origin \"$BRANCH\"\n") - sb.WriteString(" gh pr create \\\n") - sb.WriteString(" --base \"env/${TARGET_ENV}\" \\\n") - sb.WriteString(" --head \"$BRANCH\" \\\n") - fmt.Fprintf(sb, " --label %s \\\n", hotfixLabel) - sb.WriteString(" --title \"hotfix(${TARGET_ENV}): cherry-pick ${SHORT_SHA}\" \\\n") - sb.WriteString(" --body \"$BODY\"\n") - // Hand the resolution branch to the dedicated merge step. Both gh pr create - // above and the merge step run as the job-level GH_TOKEN (the configured - // state token), so the resolution PR is authored by a trigger-capable actor - // and the merge is too. The clean path is the only one that auto-merges; the - // conflict path leaves the merge to a human via the UI. - sb.WriteString(" {\n") - sb.WriteString(" echo \"HOTFIX_BRANCH=$BRANCH\"\n") - sb.WriteString(" echo \"HOTFIX_CLEAN_MERGE=true\"\n") - sb.WriteString(" } >> \"$GITHUB_ENV\"\n") - sb.WriteString(" else\n") - sb.WriteString(" echo \"::warning::Cherry-pick conflicted; opening resolution PR for manual resolve\"\n") - sb.WriteString(" CONFLICTS=$(git diff --name-only --diff-filter=U)\n") - sb.WriteString(" git add -A\n") - sb.WriteString(" git -c core.editor=true cherry-pick --continue || git commit -m \"hotfix: cherry-pick ${SHORT_SHA} with conflicts\"\n") - sb.WriteString(" git push origin \"$BRANCH\"\n") - sb.WriteString(" CONFLICT_BODY=$(printf '%s\\n\\nConflicting files:\\n%s\\n\\nResolve locally:\\n git fetch && git switch %s\\n # resolve conflicts, then\\n git push --force-with-lease\\n' \"$BODY\" \"$CONFLICTS\" \"$BRANCH\")\n") - sb.WriteString(" gh pr create \\\n") - sb.WriteString(" --base \"env/${TARGET_ENV}\" \\\n") - sb.WriteString(" --head \"$BRANCH\" \\\n") - fmt.Fprintf(sb, " --label %s \\\n", hotfixConflictLabel) - sb.WriteString(" --title \"hotfix(${TARGET_ENV}): cherry-pick ${SHORT_SHA} (conflicts)\" \\\n") - sb.WriteString(" --body \"$CONFLICT_BODY\"\n") - sb.WriteString(" fi\n") - - g.writeCleanMergeStep(sb) -} - -// writeCleanMergeStep emits the clean-path merge step. It runs only after a -// clean cherry-pick (signalled by HOTFIX_CLEAN_MERGE) and merges the resolution -// PR as the configured state token. The merge must be authored by a -// trigger-capable actor: a merge authored by the default GITHUB_TOKEN does not -// emit a pull_request(closed) event, so the build, deploy, and finalize chain -// would never run and the target environment's state would never be recorded. -// -// The step polls PR mergeability before merging, so a protected env branch with -// a required status check still gates the merge until that check is green. An -// unprotected branch reports mergeable on the first poll, so the same loop -// merges immediately. On timeout it fails loudly so the operator sees the stuck -// resolution PR rather than a silently skipped finalize. -func (g *HotfixGenerator) writeCleanMergeStep(sb *strings.Builder) { - sb.WriteString(" - name: Merge clean resolution PR\n") - sb.WriteString(" if: env.HOTFIX_CLEAN_MERGE == 'true'\n") + // Cherry-pick step: loop bottom-up over env_sequence, resolve per-env commit + // lists and base SHAs from statically baked env vars, cherry-pick all commits + // for each env, merge the clean PR, then proceed to the next env. On conflict, + // open a resolution PR and break out of the loop - later envs are NOT touched. + sb.WriteString(" - name: Cherry-pick and open resolution PRs\n") sb.WriteString(" env:\n") - // Merge as the configured state token so the merge is trigger capable and - // reaches finalize. Defaults to GITHUB_TOKEN when unset; that default does - // not emit the pull_request event, so operators that need finalize after a - // hotfix must configure a trigger-capable state_token. - fmt.Fprintf(sb, " GH_TOKEN: %s\n", g.getStateTokenRef()) - sb.WriteString(" BRANCH: ${{ env.HOTFIX_BRANCH }}\n") + for _, env := range g.targetEnvs() { + upper := strings.ToUpper(env) + fmt.Fprintf(sb, " COMMITS_%s: ${{ needs.plan.outputs.commits_%s }}\n", upper, env) + fmt.Fprintf(sb, " BASE_%s: ${{ needs.plan.outputs.base_%s }}\n", upper, env) + } sb.WriteString(" run: |\n") - // Poll mergeability for up to ~5 minutes (20 attempts, 15s apart) so a - // required status check on a protected env branch has time to report. The - // `if` guard keeps an individual non-fatal gh call from aborting the loop; - // only the timeout path exits non-zero. - sb.WriteString(" ATTEMPTS=20\n") - sb.WriteString(" SLEEP=15\n") - sb.WriteString(" MERGED=false\n") - sb.WriteString(" for i in $(seq 1 \"$ATTEMPTS\"); do\n") - sb.WriteString(" STATE=$(gh pr view \"$BRANCH\" --json mergeable,mergeStateStatus -q '.mergeable + \" \" + .mergeStateStatus' 2>/dev/null || echo \"UNKNOWN UNKNOWN\")\n") - sb.WriteString(" MERGEABLE=$(echo \"$STATE\" | cut -d' ' -f1)\n") - sb.WriteString(" STATUS=$(echo \"$STATE\" | cut -d' ' -f2)\n") - sb.WriteString(" echo \"::notice::resolution PR mergeable=$MERGEABLE state=$STATUS (attempt $i/$ATTEMPTS)\"\n") - sb.WriteString(" if [ \"$MERGEABLE\" = \"MERGEABLE\" ] && [ \"$STATUS\" != \"BLOCKED\" ]; then\n") - sb.WriteString(" if gh pr merge --squash --delete-branch \"$BRANCH\"; then\n") - sb.WriteString(" MERGED=true\n") + // REMAINING tracks the envs still to process AFTER the current one. The loop + // strips the current env (always at the front of REMAINING) before the body + // runs, so on a conflict REMAINING names exactly the envs left untouched. + sb.WriteString(" REMAINING=\"$ENV_SEQUENCE\"\n") + sb.WriteString(" for env in $(echo \"$ENV_SEQUENCE\" | tr ',' '\\n'); do\n") + sb.WriteString(" REMAINING=\"${REMAINING#\"$env\"}\"\n") + sb.WriteString(" REMAINING=\"${REMAINING#,}\"\n") + sb.WriteString(" case \"$env\" in\n") + for _, env := range g.targetEnvs() { + upper := strings.ToUpper(env) + fmt.Fprintf(sb, " %s) COMMITS=\"$COMMITS_%s\"; BASE=\"$BASE_%s\" ;;\n", env, upper, upper) + } + sb.WriteString(" esac\n") + // A no-op env (all requested commits already present) has an empty commit + // list; skip it and continue the chain to the next env. + sb.WriteString(" if [ -z \"$COMMITS\" ]; then\n") + sb.WriteString(" echo \"::notice::env/${env}: all commits already present, skipping\"\n") + sb.WriteString(" continue\n") + sb.WriteString(" fi\n") + sb.WriteString(" FIRST_COMMIT=$(echo \"$COMMITS\" | cut -d',' -f1)\n") + sb.WriteString(" SHORT_SHA=$(echo \"$FIRST_COMMIT\" | cut -c1-8)\n") + sb.WriteString(" BRANCH=\"hotfix/${env}/${SHORT_SHA}\"\n") + // Materialize env/ at the planner's validated base if origin lacks it, + // so the resolution PR has a base branch; the plan enforces tip == BASE when + // the branch already exists, so this is a no-op create in that case. + sb.WriteString(" if ! git rev-parse --verify --quiet \"refs/remotes/origin/env/${env}\" >/dev/null; then\n") + sb.WriteString(" git push origin \"${BASE}:refs/heads/env/${env}\"\n") + sb.WriteString(" git fetch origin \"+refs/heads/env/${env}:refs/remotes/origin/env/${env}\"\n") + sb.WriteString(" fi\n") + sb.WriteString(" git switch -c \"$BRANCH\" \"$BASE\"\n") + // The PR-body trailers carry the first applied commit and the base anchor so + // the post-merge context job can recover the fix and base SHAs. + sb.WriteString(" BODY=$(printf 'Cascade-Hotfix-Target: %s\\nCascade-Hotfix-Source: %s\\nCascade-Hotfix-Base: %s\\n' \"$env\" \"$FIRST_COMMIT\" \"$BASE\")\n") + sb.WriteString(" CLEAN=true\n") + sb.WriteString(" CONFLICT_COMMIT=\"\"\n") + sb.WriteString(" CONFLICTS=\"\"\n") + sb.WriteString(" for commit in $(echo \"$COMMITS\" | tr ',' '\\n'); do\n") + sb.WriteString(" if ! git cherry-pick -x \"$commit\"; then\n") + sb.WriteString(" CLEAN=false\n") + sb.WriteString(" CONFLICT_COMMIT=\"$commit\"\n") + sb.WriteString(" CONFLICTS=$(git diff --name-only --diff-filter=U)\n") + sb.WriteString(" git add -A\n") + sb.WriteString(" git -c core.editor=true cherry-pick --continue || git commit -m \"hotfix: cherry-pick $(echo \"$commit\" | cut -c1-8) with conflicts\"\n") sb.WriteString(" break\n") sb.WriteString(" fi\n") + sb.WriteString(" done\n") + sb.WriteString(" if $CLEAN; then\n") + sb.WriteString(" git push origin \"$BRANCH\"\n") + sb.WriteString(" gh pr create \\\n") + sb.WriteString(" --base \"env/${env}\" \\\n") + sb.WriteString(" --head \"$BRANCH\" \\\n") + fmt.Fprintf(sb, " --label %s \\\n", hotfixLabel) + sb.WriteString(" --title \"hotfix(${env}): cherry-pick ${SHORT_SHA}\" \\\n") + sb.WriteString(" --body \"$BODY\"\n") + // Poll mergeability for up to ~5 minutes (20 attempts, 15s apart) so a + // required status check on a protected env branch has time to report, then + // merge as the state token so the merge is trigger capable and reaches + // finalize. The merge must complete before the loop advances so each env's + // state lands before the next env cherry-picks onto it. + sb.WriteString(" ATTEMPTS=20\n") + sb.WriteString(" SLEEP=15\n") + sb.WriteString(" MERGED=false\n") + sb.WriteString(" for i in $(seq 1 \"$ATTEMPTS\"); do\n") + sb.WriteString(" STATE=$(gh pr view \"$BRANCH\" --json mergeable,mergeStateStatus -q '.mergeable + \" \" + .mergeStateStatus' 2>/dev/null || echo \"UNKNOWN UNKNOWN\")\n") + sb.WriteString(" MERGEABLE=$(echo \"$STATE\" | cut -d' ' -f1)\n") + sb.WriteString(" STATUS=$(echo \"$STATE\" | cut -d' ' -f2)\n") + sb.WriteString(" echo \"::notice::resolution PR mergeable=$MERGEABLE state=$STATUS (attempt $i/$ATTEMPTS)\"\n") + sb.WriteString(" if [ \"$MERGEABLE\" = \"MERGEABLE\" ] && [ \"$STATUS\" != \"BLOCKED\" ]; then\n") + sb.WriteString(" if gh pr merge --squash --delete-branch \"$BRANCH\"; then\n") + sb.WriteString(" MERGED=true\n") + sb.WriteString(" break\n") + sb.WriteString(" fi\n") + sb.WriteString(" fi\n") + sb.WriteString(" sleep \"$SLEEP\"\n") + sb.WriteString(" done\n") + sb.WriteString(" if [ \"$MERGED\" != \"true\" ]; then\n") + sb.WriteString(" echo \"::error::Resolution PR for $BRANCH did not become mergeable within the timeout; merge it manually to run the hotfix finalize chain\"\n") + sb.WriteString(" exit 1\n") + sb.WriteString(" fi\n") + // Re-fetch the env branches so the next env in the chain cherry-picks onto the + // just-merged tip. + sb.WriteString(" git fetch origin '+refs/heads/env/*:refs/remotes/origin/env/*' --tags\n") + sb.WriteString(" else\n") + sb.WriteString(" echo \"::warning::Cherry-pick conflicted on env/${env}; opening resolution PR and halting chain\"\n") + sb.WriteString(" git push origin \"$BRANCH\"\n") + sb.WriteString(" CONFLICT_BODY=$(printf '%s\\n\\nConflicting files:\\n%s\\n\\nThis resolves %s.\\n\\nEnvironments still pending: %s.\\n\\nAfter merge, re-engage the hotfix workflow targeting %s.\\n\\nResolve locally:\\n git fetch && git switch %s\\n # resolve conflicts, then\\n git push --force-with-lease\\n' \"$BODY\" \"$CONFLICTS\" \"$env\" \"$REMAINING\" \"$HOTFIX_TARGET_ENV\" \"$BRANCH\")\n") + sb.WriteString(" gh pr create \\\n") + sb.WriteString(" --base \"env/${env}\" \\\n") + sb.WriteString(" --head \"$BRANCH\" \\\n") + fmt.Fprintf(sb, " --label %s \\\n", hotfixConflictLabel) + sb.WriteString(" --title \"hotfix(${env}): cherry-pick $(echo \"$CONFLICT_COMMIT\" | cut -c1-8) (conflicts)\" \\\n") + sb.WriteString(" --body \"$CONFLICT_BODY\"\n") + sb.WriteString(" break\n") sb.WriteString(" fi\n") - sb.WriteString(" sleep \"$SLEEP\"\n") sb.WriteString(" done\n") - sb.WriteString(" if [ \"$MERGED\" != \"true\" ]; then\n") - sb.WriteString(" echo \"::error::Resolution PR for $BRANCH did not become mergeable within the timeout; merge it manually to run the hotfix finalize chain\"\n") - sb.WriteString(" exit 1\n") - sb.WriteString(" fi\n") } // writeCheckJob emits the parse-config validity gate that runs while a hotfix PR diff --git a/internal/generate/hotfix_test.go b/internal/generate/hotfix_test.go index 90dbbc9..23a5182 100644 --- a/internal/generate/hotfix_test.go +++ b/internal/generate/hotfix_test.go @@ -89,6 +89,85 @@ func TestHotfixGenerator_Triggers(t *testing.T) { assert.NotContains(t, content, "- dev") } +// TestHotfixGenerator_CommitInputAcceptsMultiple guards that the dispatch +// `commit` input documents comma-delimited multi-commit hotfixes, so an operator +// can hand the workflow a stack of trunk fixes to cherry-pick as one chain. +func TestHotfixGenerator_CommitInputAcceptsMultiple(t *testing.T) { + gen := NewHotfixGenerator(threeEnvHotfixConfig(), "") + content, err := gen.Generate() + require.NoError(t, err) + + assert.Contains(t, content, "comma", + "the commit input description must advertise comma-delimited multi-commit input") + assert.Contains(t, content, + "description: 'Trunk commit SHA(s) to hotfix, comma-delimited (must be on trunk)'", + "the commit input must carry the multi-commit description verbatim") +} + +// TestHotfixGenerator_PlanJobChainOutputs guards that the plan job exposes the +// per-env chain outputs the apply job consumes: the bottom-up env_sequence plus +// each target env's commit list, no-op flag, and base SHA. +func TestHotfixGenerator_PlanJobChainOutputs(t *testing.T) { + gen := NewHotfixGenerator(threeEnvHotfixConfig(), "") + content, err := gen.Generate() + require.NoError(t, err) + + planJob := extractJobSection(t, content, "plan:") + require.NotEmpty(t, planJob, "plan job section should be present") + + assert.Contains(t, planJob, "env_sequence: ${{ steps.plan.outputs.env_sequence }}", + "plan job must expose the env_sequence chain order") + for _, key := range []string{ + "commits_test: ${{ steps.plan.outputs.commits_test }}", + "no_op_test: ${{ steps.plan.outputs.no_op_test }}", + "base_test: ${{ steps.plan.outputs.base_test }}", + "commits_prod: ${{ steps.plan.outputs.commits_prod }}", + "no_op_prod: ${{ steps.plan.outputs.no_op_prod }}", + "base_prod: ${{ steps.plan.outputs.base_prod }}", + } { + assert.Contains(t, planJob, key, "plan job must expose per-env chain output %q", key) + } +} + +// TestHotfixGenerator_ApplyLoopsEnvSequence guards that the apply job walks the +// planner's env_sequence, cherry-picking each env's commits from the statically +// baked per-env outputs. +func TestHotfixGenerator_ApplyLoopsEnvSequence(t *testing.T) { + gen := NewHotfixGenerator(threeEnvHotfixConfig(), "") + content, err := gen.Generate() + require.NoError(t, err) + + applyJob := extractJobSection(t, content, "apply:") + require.NotEmpty(t, applyJob, "apply job section should be present") + + assert.Contains(t, applyJob, "ENV_SEQUENCE: ${{ needs.plan.outputs.env_sequence }}", + "apply job must consume the planner's env_sequence") + assert.Contains(t, applyJob, `for env in $(echo "$ENV_SEQUENCE" | tr ',' '\n')`, + "apply job must loop over the env_sequence") + assert.Contains(t, applyJob, "git cherry-pick -x", + "apply job must cherry-pick each commit with -x") + assert.Contains(t, applyJob, "COMMITS_TEST: ${{ needs.plan.outputs.commits_test }}", + "apply job must wire the per-env commit list into the cherry-pick step env") + assert.Contains(t, applyJob, `test) COMMITS="$COMMITS_TEST"`, + "apply job must resolve the per-env commit list via a case statement") +} + +// TestHotfixGenerator_ConflictHaltsChain guards that the conflict path's +// resolution PR body tells the operator which env was resolved, which envs still +// pending, and how to re-engage the chain after merge. +func TestHotfixGenerator_ConflictHaltsChain(t *testing.T) { + gen := NewHotfixGenerator(threeEnvHotfixConfig(), "") + content, err := gen.Generate() + require.NoError(t, err) + + assert.Contains(t, content, "This resolves %s.", + "conflict PR body must state which env the PR resolves") + assert.Contains(t, content, "Environments still pending: %s.", + "conflict PR body must list the envs the halted chain has not reached") + assert.Contains(t, content, "After merge, re-engage the hotfix workflow targeting %s.", + "conflict PR body must tell the operator how to resume the chain") +} + func TestHotfixGenerator_Concurrency(t *testing.T) { gen := NewHotfixGenerator(threeEnvHotfixConfig(), "") content, err := gen.Generate() @@ -593,6 +672,10 @@ func TestHotfixGenerator_PlanJobOutputsMatchPlannerKeys(t *testing.T) { "no_op": true, "branch_created": true, "hotfix_version_candidate": true, "conflict_expected": true, "dry_run": true, "protection_suggestions": true, "protection_suggestions_text": true, + // Chain keys emitted by chainGHAOutputs for the target envs (test, prod). + "env_sequence": true, "env_count": true, + "commits_test": true, "no_op_test": true, "conflict_expected_test": true, "base_test": true, + "commits_prod": true, "no_op_prod": true, "conflict_expected_prod": true, "base_prod": true, } for _, m := range refRe.FindAllStringSubmatch(content, -1) { assert.Truef(t, plannerKeys[m[1]], @@ -728,19 +811,19 @@ func TestHotfixGenerator_ApplyMaterializesAbsentEnvBranch(t *testing.T) { applyJob := extractJobSection(t, content, "apply:") require.NotEmpty(t, applyJob, "apply job section should be present") - // The hotfix branch must be cut from the plan's validated base SHA, not from + // The hotfix branch must be cut from the per-env validated base SHA, not from // a remote-tracking ref that may not exist on a first hotfix. - assert.Contains(t, applyJob, `git switch -c "$BRANCH" "$BASE_SHA"`, - "apply job must branch the hotfix from the validated BASE_SHA") - assert.NotContains(t, applyJob, `git switch -c "$BRANCH" "origin/env/${TARGET_ENV}"`, + assert.Contains(t, applyJob, `git switch -c "$BRANCH" "$BASE"`, + "apply job must branch the hotfix from the per-env validated BASE") + assert.NotContains(t, applyJob, `git switch -c "$BRANCH" "origin/env/${env}"`, "apply job must not branch from origin/env/, which is absent on a first hotfix") // When the remote env branch is absent the apply job must create and push it - // at BASE_SHA so the resolution PR has a base to target. - assert.Contains(t, applyJob, `refs/remotes/origin/env/${TARGET_ENV}`, + // at the per-env base so the resolution PR has a base to target. + assert.Contains(t, applyJob, `refs/remotes/origin/env/${env}`, "apply job must probe for the remote env branch before relying on it") - assert.Contains(t, applyJob, `git push origin "${BASE_SHA}:refs/heads/env/${TARGET_ENV}"`, - "apply job must push env/ at BASE_SHA when it is absent") + assert.Contains(t, applyJob, `git push origin "${BASE}:refs/heads/env/${env}"`, + "apply job must push env/ at the per-env base when it is absent") } // TestHotfixGenerator_ApplySkippedOnNoOp guards that the apply job does not run @@ -755,12 +838,14 @@ func TestHotfixGenerator_ApplySkippedOnNoOp(t *testing.T) { planJob := extractJobSection(t, content, "plan:") require.NotEmpty(t, planJob, "plan job section should be present") assert.Contains(t, planJob, "no_op: ${{ steps.plan.outputs.no_op }}", - "plan job must expose no_op so the apply job can gate on it") + "plan job must expose no_op so the single-env plan path stays available") + // no_op is now per-env; the job-level gate checks env_sequence non-empty, and + // per-env no-ops are skipped inside the loop when that env's COMMITS is empty. applyJob := extractJobSection(t, content, "apply:") require.NotEmpty(t, applyJob, "apply job section should be present") - assert.Contains(t, applyJob, "needs.plan.outputs.no_op != 'true'", - "apply job must skip when the plan reports a no-op") + assert.Contains(t, applyJob, "needs.plan.outputs.env_sequence != ''", + "apply job must skip when the plan reports no envs to process") } // TestHotfixGenerator_FinalizeWritesStateWithStateToken guards the trunk diff --git a/internal/hotfix/command.go b/internal/hotfix/command.go index e5d62d9..3dd828b 100644 --- a/internal/hotfix/command.go +++ b/internal/hotfix/command.go @@ -298,6 +298,7 @@ func chainGHAOutputs(result *PlanChainResult) (simple map[string]string, multili simple["commits_"+ep.Env] = strings.Join(ep.Commits, ",") simple["no_op_"+ep.Env] = fmt.Sprintf("%v", ep.NoOp) simple["conflict_expected_"+ep.Env] = fmt.Sprintf("%v", ep.ConflictExpected) + simple["base_"+ep.Env] = ep.BaseSHA } simple["env_sequence"] = strings.Join(envNames, ",") simple["env_count"] = fmt.Sprintf("%d", len(envNames))