From e8f5c996216ed70d17d71670b0995ca7d34cb2ae Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Thu, 11 Jun 2026 09:51:36 -0400 Subject: [PATCH 1/2] feat(e2e): add hotfix divergence expectation surface and promote force Signed-off-by: Joshua Temple --- e2e/harness/assert.go | 66 +++++++++++++++++++++++ e2e/harness/context.go | 35 ++++++++++-- e2e/harness/multistep.go | 113 ++++++++++++++++++++++++++++++++++++++- e2e/harness/runner.go | 65 ++++++++++++++++++++++ 4 files changed, 275 insertions(+), 4 deletions(-) diff --git a/e2e/harness/assert.go b/e2e/harness/assert.go index 53e14ab..da631e9 100644 --- a/e2e/harness/assert.go +++ b/e2e/harness/assert.go @@ -238,6 +238,52 @@ func AssertState(ctx *ExecutionContext, env string, expect *StateExpect) []error env, expect.Version, actual.Version)) } + // Check integration-branch ref (exact match). + if expect.Ref != "" && actual.Ref != expect.Ref { + errs = append(errs, fmt.Errorf("state[%s].ref expected %s, got %s", + env, expect.Ref, actual.Ref)) + } + + // Check base SHA (resolve commit references, falling back to literal). + if expect.BaseSHA != "" { + expectedBase := ctx.ResolveSHA(expect.BaseSHA) + if expectedBase == "" { + expectedBase = expect.BaseSHA + } + if actual.BaseSHA != expectedBase { + errs = append(errs, fmt.Errorf("state[%s].base_sha expected %s, got %s", + env, expectedBase, actual.BaseSHA)) + } + } + + // Check patches: every listed patch (resolved via reference, falling back to + // literal) must be present in the recorded patches slice. + for _, p := range expect.Patches { + want := ctx.ResolveSHA(p) + if want == "" { + want = p + } + if !containsString(actual.Patches, want) { + errs = append(errs, fmt.Errorf("state[%s].patches missing %s, got %v", + env, want, actual.Patches)) + } + } + + // Check patches_contain: each entry must match at least one recorded patch + // by substring (no reference resolution). + for _, frag := range expect.PatchesContain { + if !anyContains(actual.Patches, frag) { + errs = append(errs, fmt.Errorf("state[%s].patches has no entry containing %q, got %v", + env, frag, actual.Patches)) + } + } + + // Check pre-divergence version (exact match). + if expect.PreviousVersion != "" && actual.PreviousVersion != expect.PreviousVersion { + errs = append(errs, fmt.Errorf("state[%s].previous_version expected %s, got %s", + env, expect.PreviousVersion, actual.PreviousVersion)) + } + // Check deploys for deployName, deployExpect := range expect.Deploys { actualDeploy := actual.Deploys[deployName] @@ -261,6 +307,26 @@ func AssertState(ctx *ExecutionContext, env string, expect *StateExpect) []error return errs } +// containsString reports whether s appears exactly in list. +func containsString(list []string, s string) bool { + for _, v := range list { + if v == s { + return true + } + } + return false +} + +// anyContains reports whether any element of list contains substr. +func anyContains(list []string, substr string) bool { + for _, v := range list { + if strings.Contains(v, substr) { + return true + } + } + return false +} + // AssertJobs validates job conclusions against expectations (used internally) // Returns errors instead of failing test directly for more flexible error handling func AssertJobs(actual map[string]string, expect map[string]string) []error { diff --git a/e2e/harness/context.go b/e2e/harness/context.go index d2fa4fb..1b31eed 100644 --- a/e2e/harness/context.go +++ b/e2e/harness/context.go @@ -19,6 +19,15 @@ type EnvState struct { SHA string Version string Deploys map[string]*DeployState + // Ref is the integration branch the environment tracks instead of trunk. + Ref string + // BaseSHA is the trunk anchor the integration branch diverged from. + BaseSHA string + // Patches lists the patch commit SHAs applied on top of BaseSHA. + Patches []string + // PreviousVersion is the version held before the latest divergence update. + // Harness-side only; not read back from the product manifest. + PreviousVersion string } // DeployState tracks per-deploy state @@ -163,6 +172,22 @@ func (c *ExecutionContext) RecordState(env, sha, version string) { c.state[env].Version = version } +// RecordStateDivergence records the integration-branch divergence fields for an +// environment (ref, base SHA, patch list, and pre-divergence version) without +// disturbing the recorded SHA/Version. Used by manifest sync and setup staging +// so divergence assertions can observe a hotfixed environment. +func (c *ExecutionContext) RecordStateDivergence(env, ref, baseSHA string, patches []string, previousVersion string) { + c.mu.Lock() + defer c.mu.Unlock() + if c.state[env] == nil { + c.state[env] = &EnvState{Deploys: make(map[string]*DeployState)} + } + c.state[env].Ref = ref + c.state[env].BaseSHA = baseSHA + c.state[env].Patches = append([]string(nil), patches...) + c.state[env].PreviousVersion = previousVersion +} + // ClearState removes all env state. Used by sync routines that need to // rebuild ctx from an authoritative source (the manifest), so deletions in // that source (e.g. finalize wiping state[prerelease] on publish) are @@ -250,9 +275,13 @@ func (c *ExecutionContext) Clone() *ExecutionContext { clone.commitSeq = append([]string{}, c.commitSeq...) for k, v := range c.state { clone.state[k] = &EnvState{ - SHA: v.SHA, - Version: v.Version, - Deploys: make(map[string]*DeployState), + SHA: v.SHA, + Version: v.Version, + Deploys: make(map[string]*DeployState), + Ref: v.Ref, + BaseSHA: v.BaseSHA, + Patches: append([]string(nil), v.Patches...), + PreviousVersion: v.PreviousVersion, } for dk, dv := range v.Deploys { clone.state[k].Deploys[dk] = &DeployState{SHA: dv.SHA} diff --git a/e2e/harness/multistep.go b/e2e/harness/multistep.go index f9c2eff..6e12392 100644 --- a/e2e/harness/multistep.go +++ b/e2e/harness/multistep.go @@ -28,15 +28,43 @@ type SetupState struct { type EnvStateSetup struct { SHA string `yaml:"sha,omitempty"` Version string `yaml:"version,omitempty"` + // Ref stages the environment on an integration branch (e.g. a hotfix branch) + // rather than trunk. Written into the manifest's per-env state so a diverged + // environment exists without running a full hotfix flow. + Ref string `yaml:"ref,omitempty"` + // BaseSHA is the trunk anchor the staged integration branch diverged from. + // May be a commit reference (resolved via the execution context) or a literal. + BaseSHA string `yaml:"base_sha,omitempty"` + // Patches stages the patch commit SHAs applied on top of BaseSHA. Each entry + // may be a commit reference or a literal SHA. + Patches []string `yaml:"patches,omitempty"` + // PreviousVersion stages the version held before divergence. Harness-side + // only (see StateExpect.PreviousVersion); not written to the manifest. + PreviousVersion string `yaml:"previous_version,omitempty"` } // Step represents a single action in the scenario type Step struct { Name string `yaml:"name"` - Action string `yaml:"action"` // commit, orchestrate, promote + Action string `yaml:"action"` // commit, orchestrate, promote, hotfix_plan, hotfix_apply, merge_pr, resolve_conflict, hotfix_merged Commit *CommitStep `yaml:"commit,omitempty"` Promote *PromoteStep `yaml:"promote,omitempty"` Expect *StepExpect `yaml:"expect,omitempty"` + // HotfixPlan configures a "hotfix_plan" action: a workflow_dispatch run of the + // hotfix workflow's plan job for a trunk commit and target environment. + HotfixPlan *HotfixPlanStep `yaml:"hotfix_plan,omitempty"` + // HotfixApply configures a "hotfix_apply" action: a harness-driven cherry-pick + // of a trunk commit onto the target env branch, opening a hotfix PR. + HotfixApply *HotfixApplyStep `yaml:"hotfix_apply,omitempty"` + // MergePR configures a "merge_pr" action: squash-merge an open PR identified + // by index or label. + MergePR *MergePRStep `yaml:"merge_pr,omitempty"` + // ResolveConflict configures a "resolve_conflict" action: push resolved file + // content to the last hotfix PR head branch and re-run the check job. + ResolveConflict *ResolveConflictStep `yaml:"resolve_conflict,omitempty"` + // 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"` // 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 @@ -45,6 +73,43 @@ type Step struct { ExpectFailure bool `yaml:"expect_failure,omitempty"` } +// HotfixPlanStep defines a hotfix_plan action: a workflow_dispatch of the hotfix +// workflow's plan job. CommitRef is the trunk commit to plan a hotfix for and is +// resolved via the execution context (falling back to a literal SHA). +type HotfixPlanStep struct { + CommitRef string `yaml:"commit_ref"` + TargetEnv string `yaml:"target_env"` + DryRun bool `yaml:"dry_run,omitempty"` + ExpectFailure bool `yaml:"expect_failure,omitempty"` +} + +// HotfixApplyStep defines a hotfix_apply action: a harness-driven cherry-pick of +// CommitRef onto env/, pushing a hotfix branch and opening a labeled +// PR. CommitRef is resolved via the execution context (falling back to literal). +type HotfixApplyStep struct { + TargetEnv string `yaml:"target_env"` + CommitRef string `yaml:"commit_ref"` +} + +// MergePRStep defines a merge_pr action. Index identifies the PR directly; if +// zero, the first open PR carrying Label is merged. +type MergePRStep struct { + Label string `yaml:"label,omitempty"` + Index int64 `yaml:"index,omitempty"` +} + +// ResolveConflictStep defines a resolve_conflict action. Files maps repository +// paths to their resolved content, committed onto the last hotfix PR head branch. +type ResolveConflictStep struct { + Files map[string]string `yaml:"files"` +} + +// HotfixMergedStep defines a hotfix_merged action: replay of the merged +// pull_request event for the recorded hotfix PR of TargetEnv. +type HotfixMergedStep struct { + TargetEnv string `yaml:"target_env"` +} + // CommitStep defines a commit action type CommitStep struct { Message string `yaml:"message"` @@ -57,6 +122,10 @@ type PromoteStep struct { Target string `yaml:"target,omitempty"` // for cascade: dev-to-prod AllowBreaking bool `yaml:"allow_breaking,omitempty"` ExpectFailure bool `yaml:"expect_failure,omitempty"` + // Force sets the promote workflow's "force" dispatch input to "true", + // bypassing the no-op promotion guard. Only meaningful for multi-env + // (default mode) promotions; single-env Release promotions ignore it. + Force bool `yaml:"force,omitempty"` } // StepExpect defines expected outcomes for a step @@ -67,6 +136,25 @@ type StepExpect struct { Tags *TagsExpect `yaml:"tags,omitempty"` Preflight *PreflightExpect `yaml:"preflight,omitempty"` WorkflowFiles []WorkflowFileExpect `yaml:"workflow_files,omitempty"` // Generated workflow file content checks + // Branches asserts presence/absence of branches in Gitea (live check). + Branches *BranchesExpect `yaml:"branches,omitempty"` + // PRs asserts open pull requests in Gitea (live check). + PRs *PRsExpect `yaml:"prs,omitempty"` +} + +// BranchesExpect asserts branch existence in Gitea. Exist entries must be +// present; Deleted entries must be absent. +type BranchesExpect struct { + Exist []string `yaml:"exist,omitempty"` + Deleted []string `yaml:"deleted,omitempty"` +} + +// PRsExpect asserts open pull requests in Gitea. OpenWithLabel filters open PRs +// by label; when OpenCount is set the count must match exactly, otherwise at +// least one matching PR must be open. +type PRsExpect struct { + OpenWithLabel string `yaml:"open_with_label,omitempty"` + OpenCount *int `yaml:"open_count,omitempty"` } // WorkflowFileExpect asserts a generated workflow file contains/excludes @@ -91,6 +179,29 @@ type StateExpect struct { Wiped bool `yaml:"wiped,omitempty"` // State should not exist Unchanged bool `yaml:"unchanged,omitempty"` // State should match previous Deploys map[string]*DeployExpect `yaml:"deploys,omitempty"` + // Ref is the integration branch the environment is expected to track instead + // of trunk (e.g. "env/prod" or a hotfix branch). Matched exactly against the + // state's recorded divergence ref. + Ref string `yaml:"ref,omitempty"` + // BaseSHA is the trunk anchor the integration branch is expected to have + // diverged from. Resolved via commit references (falling back to a literal) + // the same way SHA is. + BaseSHA string `yaml:"base_sha,omitempty"` + // Patches lists every patch commit the environment must have applied on top + // of BaseSHA. Treated as "must contain all listed": each entry is resolved + // via a commit reference (falling back to a literal) and must be present in + // the recorded patches slice. + Patches []string `yaml:"patches,omitempty"` + // PatchesContain lists substrings or exact members that must each match at + // least one recorded patch (membership/substring match, no reference + // resolution). Useful when only a fragment of a patch SHA is known. + PatchesContain []string `yaml:"patches_contain,omitempty"` + // PreviousVersion is the version the environment held before the most recent + // divergence update. Matched exactly. This is a harness-side expectation + // surface and is not read back from the product manifest (the product tracks + // 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"` } // DeployExpect defines expected deploy state diff --git a/e2e/harness/runner.go b/e2e/harness/runner.go index 9158452..6f6bcaa 100644 --- a/e2e/harness/runner.go +++ b/e2e/harness/runner.go @@ -116,6 +116,23 @@ func (r *Runner) applySetup(ctx context.Context, setup *SetupState) error { // Apply initial state for env, state := range setup.State { r.ctx.RecordState(env, state.SHA, state.Version) + // Stage integration-branch divergence when any divergence field is set. + // base_sha/patches may be commit references; resolve to literal SHAs. + if state.Ref != "" || state.BaseSHA != "" || len(state.Patches) > 0 || state.PreviousVersion != "" { + baseSHA := r.ctx.ResolveSHA(state.BaseSHA) + if baseSHA == "" { + baseSHA = state.BaseSHA + } + patches := make([]string, 0, len(state.Patches)) + for _, p := range state.Patches { + if resolved := r.ctx.ResolveSHA(p); resolved != "" { + patches = append(patches, resolved) + } else { + patches = append(patches, p) + } + } + r.ctx.RecordStateDivergence(env, state.Ref, baseSHA, patches, state.PreviousVersion) + } } // Apply initial tags @@ -193,6 +210,32 @@ func (r *Runner) materializeManifestState(ctx context.Context, state map[string] case defaultSHA != "": entry["sha"] = defaultSHA } + // Stage integration-branch divergence into the manifest using the + // product's exact yaml tags (ref/base_sha/patches), so a diverged env + // exists without a full hotfix run. base_sha/patches may be commit + // references; resolve to literal SHAs. There is no previous_version + // manifest key, so PreviousVersion is harness-side only. + if s.Ref != "" { + entry["ref"] = s.Ref + } + if s.BaseSHA != "" { + base := r.ctx.ResolveSHA(s.BaseSHA) + if base == "" { + base = s.BaseSHA + } + entry["base_sha"] = base + } + if len(s.Patches) > 0 { + patches := make([]string, 0, len(s.Patches)) + for _, p := range s.Patches { + if resolved := r.ctx.ResolveSHA(p); resolved != "" { + patches = append(patches, resolved) + } else { + patches = append(patches, p) + } + } + entry["patches"] = patches + } stateMap[env] = entry } @@ -416,6 +459,12 @@ func (r *Runner) executePromote(ctx context.Context, promote *PromoteStep, confi inputs = map[string]string{ "mode": mode, } + if promote.Force { + // Workflow input is named "force" (env PROMOTION_FORCE), forwarded to + // the CLI's --force flag. Only meaningful for default-mode promotions; + // it bypasses the no-op promotion guard. + inputs["force"] = "true" + } if promote.AllowBreaking { // Workflow input is named allow_breaking_changes (see internal/generate // promote.go); the workflow forwards it to the CLI's --allow-breaking @@ -550,6 +599,13 @@ func (r *Runner) syncStateFromGitea(ctx context.Context, config Config) error { Deploys map[string]struct { SHA string `yaml:"sha"` } `yaml:"deploys"` + // Divergence fields written by the real hotfix finalize step. Tags + // match the product's config.EnvState (ref/base_sha/patches). The + // product has no previous_version manifest key, so divergence's + // PreviousVersion is left unset on sync. + Ref string `yaml:"ref"` + BaseSHA string `yaml:"base_sha"` + Patches []string `yaml:"patches"` } `yaml:"state"` // LatestRelease is the single-environment Release workflow's published // pointer (ci.latest_release). Single-env repos publish via the Release @@ -581,6 +637,15 @@ func (r *Runner) syncStateFromGitea(ctx context.Context, config Config) error { r.ctx.RecordState(env, state.SHA, state.Version) r.t.Logf(" Synced state[%s] = %s @ %s", env, truncateSHA(state.SHA), state.Version) + // Record integration-branch divergence so divergence assertions can see + // a hotfixed environment. PreviousVersion has no manifest key (the + // product tracks prior versions separately), so it stays empty here. + if state.Ref != "" || state.BaseSHA != "" || len(state.Patches) > 0 { + r.ctx.RecordStateDivergence(env, state.Ref, state.BaseSHA, state.Patches, "") + r.t.Logf(" Synced state[%s] divergence ref=%s base=%s patches=%d", + env, state.Ref, truncateSHA(state.BaseSHA), len(state.Patches)) + } + // Also record per-deploy state for deployName, deployState := range state.Deploys { r.ctx.RecordDeployState(env, deployName, deployState.SHA) From 70932ede16334718339c99020c07a7b502371106 Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Thu, 11 Jun 2026 09:55:12 -0400 Subject: [PATCH 2/2] feat(e2e): add hotfix runner step actions and branch/PR expectations Signed-off-by: Joshua Temple --- e2e/harness/hotfix_actions.go | 416 +++++++++++++++++++++++++++++ e2e/harness/hotfix_actions_test.go | 332 +++++++++++++++++++++++ e2e/harness/runner.go | 120 +++++++++ 3 files changed, 868 insertions(+) create mode 100644 e2e/harness/hotfix_actions.go create mode 100644 e2e/harness/hotfix_actions_test.go diff --git a/e2e/harness/hotfix_actions.go b/e2e/harness/hotfix_actions.go new file mode 100644 index 0000000..4645713 --- /dev/null +++ b/e2e/harness/hotfix_actions.go @@ -0,0 +1,416 @@ +package harness + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "strings" + "time" +) + +// hotfixWorkflowPath is the generated hotfix workflow's path inside the repo. +const hotfixWorkflowPath = ".github/workflows/cascade-hotfix.yaml" + +// resolveCommit resolves a commit reference to its SHA via the execution +// context, falling back to treating the reference as a literal SHA. +func (r *Runner) resolveCommit(ref string) string { + if sha := r.ctx.ResolveSHA(ref); sha != "" { + return sha + } + return ref +} + +// 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 { + if len(sha) >= 8 { + return sha[:8] + } + return sha +} + +// execInRepo runs a bash script inside /tmp/repo in the act container and +// returns the exit code and combined output. A nil harness/act yields (0, "") +// so callers in unit-test mode are unaffected. +func (r *Runner) execInRepo(ctx context.Context, script string) (int, string, error) { + exitCode, reader, err := r.harness.act.Container().Exec(ctx, + []string{"bash", "-c", "cd /tmp/repo && " + script}) + if err != nil { + return exitCode, "", err + } + var out bytes.Buffer + if reader != nil { + _, _ = io.Copy(&out, reader) + } + return exitCode, out.String(), nil +} + +// repoEnv builds the standard workflow environment (GITHUB_REPOSITORY) shared by +// the orchestrate/promote runners. +func (r *Runner) repoEnv() map[string]string { + return map[string]string{ + "GITHUB_REPOSITORY": fmt.Sprintf("%s/%s", AdminUsername, r.harness.repo.Name), + } +} + +// executeHotfixPlan dispatches the hotfix workflow's plan job for a trunk commit +// and target environment. Plan output parsing is intentionally lenient: the +// branch/base_sha/version-candidate outputs are visible in the run logs, and +// Wave-D scenarios assert observable state rather than scraped step outputs. +func (r *Runner) executeHotfixPlan(ctx context.Context, step *HotfixPlanStep) error { + if r.harness == nil || r.harness.act == nil { + r.t.Log(" Would execute hotfix plan (no harness)") + return nil + } + + sha := r.resolveCommit(step.CommitRef) + dryRun := "false" + if step.DryRun { + dryRun = "true" + } + r.t.Logf(" HotfixPlan: commit=%s target=%s dry_run=%s", truncateSHA(sha), step.TargetEnv, dryRun) + + if err := r.harness.SyncRepoToActContainer(ctx); err != nil { + return fmt.Errorf("failed to sync repo: %w", err) + } + + result, err := r.harness.act.RunWorkflowFromRepo(ctx, RunOpts{ + WorkflowPath: hotfixWorkflowPath, + Event: "workflow_dispatch", + Inputs: map[string]string{ + "commit": sha, + "target_env": step.TargetEnv, + "dry_run": dryRun, + }, + Env: r.repoEnv(), + }) + if err != nil { + return fmt.Errorf("failed to run hotfix plan workflow: %w", err) + } + r.lastWorkflowResult = result + + if step.ExpectFailure { + if result.Conclusion == "failure" { + r.t.Log(" HotfixPlan: workflow failed as expected") + return nil + } + return fmt.Errorf("expected hotfix plan to fail but it succeeded") + } + + if result.Conclusion != "success" { + r.t.Logf(" HotfixPlan workflow logs:\n%s", result.Logs) + return fmt.Errorf("hotfix plan workflow failed: %s", result.Error) + } + + r.t.Logf(" HotfixPlan: parsed %d jobs", len(result.Jobs)) + for name, job := range result.Jobs { + r.t.Logf(" - Job '%s': conclusion=%s", name, job.Conclusion) + } + return nil +} + +// executeHotfixApply performs a harness-driven cherry-pick of a trunk commit onto +// env/, pushing a hotfix branch and opening a labeled PR. It mirrors the +// product workflow's apply recipe (internal/generate/hotfix.go) but runs the git +// mechanics directly so scenarios can exercise both clean and conflict paths. +func (r *Runner) executeHotfixApply(ctx context.Context, step *HotfixApplyStep) error { + if r.harness == nil || r.harness.act == nil { + r.t.Log(" Would execute hotfix apply (no harness)") + return nil + } + + commit := r.resolveCommit(step.CommitRef) + env := step.TargetEnv + envBranch := "env/" + env + short := shortSHA(commit) + hotfixBranch := "hotfix/" + env + "/" + short + r.t.Logf(" HotfixApply: commit=%s env=%s branch=%s", truncateSHA(commit), env, hotfixBranch) + + // Ensure env/ exists, anchored at the env's recorded state SHA (or HEAD). + branches, err := r.harness.gitea.ListBranches(ctx, r.harness.repo) + if err != nil { + return fmt.Errorf("list branches: %w", err) + } + if !containsString(branches, envBranch) { + anchor := r.ctx.GetState(env).SHA + if anchor == "" { + anchor, err = r.harness.gitea.getHeadSHA(ctx, r.harness.repo) + if err != nil { + return fmt.Errorf("get HEAD SHA for env branch anchor: %w", err) + } + } + if err := r.harness.gitea.CreateBranch(ctx, r.harness.repo, envBranch, anchor); err != nil { + return fmt.Errorf("create env branch %s: %w", envBranch, err) + } + r.t.Logf(" HotfixApply: created %s at %s", envBranch, truncateSHA(anchor)) + } + + baseSHA, err := r.harness.gitea.GetBranchSHA(ctx, r.harness.repo, envBranch) + if err != nil { + return fmt.Errorf("get base SHA for %s: %w", envBranch, err) + } + + if err := r.harness.SyncRepoToActContainer(ctx); err != nil { + return fmt.Errorf("failed to sync repo: %w", err) + } + + // Drive the cherry-pick in the act container. A CONFLICT_FILES sentinel line + // reports any conflicting paths so we can classify the outcome and build the + // conflict PR body. The push uses the admin-credentialed origin URL. + pushURL := r.authedRepoURL() + short8 := short + script := strings.Join([]string{ + "set +e", + "git fetch origin '+refs/heads/*:refs/remotes/origin/*' --tags >/dev/null 2>&1", + fmt.Sprintf("git switch -c %q %q", hotfixBranch, "origin/"+envBranch), + fmt.Sprintf("git cherry-pick -x %q", commit), + "CP_EXIT=$?", + "if [ \"$CP_EXIT\" -ne 0 ]; then", + " CONFLICTS=$(git diff --name-only --diff-filter=U | tr '\\n' ' ')", + " echo \"CONFLICT_FILES=$CONFLICTS\"", + " git add -A", + fmt.Sprintf(" git -c core.editor=true cherry-pick --continue || git commit -m %q", "hotfix: cherry-pick "+short8+" with conflicts"), + "else", + " echo \"CONFLICT_FILES=\"", + "fi", + fmt.Sprintf("git push %q %q", pushURL, hotfixBranch+":"+hotfixBranch), + "echo \"PUSH_EXIT=$?\"", + }, "\n") + + _, out, err := r.execInRepo(ctx, script) + if err != nil { + return fmt.Errorf("cherry-pick exec: %w", err) + } + conflictFiles := parseSentinel(out, "CONFLICT_FILES=") + conflict := strings.TrimSpace(conflictFiles) != "" + if strings.Contains(out, "PUSH_EXIT=") && !strings.Contains(out, "PUSH_EXIT=0") { + r.t.Logf(" HotfixApply push output:\n%s", out) + return fmt.Errorf("failed to push hotfix branch %s", hotfixBranch) + } + + // Wait out the post-push API staleness window (same class as the B1 flake) + // before opening the PR, polling until Gitea reports the pushed branch. + if err := r.waitForBranch(ctx, hotfixBranch, 30*time.Second); err != nil { + return fmt.Errorf("waiting for pushed branch %s: %w", hotfixBranch, err) + } + + // Build the PR body with the three product trailers; append the conflict file + // list on the conflict path. + body := fmt.Sprintf("Cascade-Hotfix-Target: %s\nCascade-Hotfix-Source: %s\nCascade-Hotfix-Base: %s\n", env, commit, baseSHA) + title := fmt.Sprintf("hotfix(%s): cherry-pick %s", env, short) + label := "cascade-hotfix" + if conflict { + title = fmt.Sprintf("hotfix(%s): cherry-pick %s (conflicts)", env, short) + label = "cascade-hotfix-conflict" + body += "\nConflicting files:\n" + strings.TrimSpace(conflictFiles) + "\n" + } + + index, err := r.harness.gitea.CreatePR(ctx, r.harness.repo, hotfixBranch, envBranch, title, body, []string{label}) + if err != nil { + return fmt.Errorf("create hotfix PR: %w", err) + } + + r.lastPRIndex = index + r.lastPRConflict = conflict + r.lastHotfixBranch = hotfixBranch + r.lastHotfixEnv = env + r.lastHotfixBody = body + if r.prByLabel == nil { + r.prByLabel = make(map[string]int64) + } + r.prByLabel[label] = index + r.t.Logf(" HotfixApply: opened PR #%d (label=%s, conflict=%v)", index, label, conflict) + return nil +} + +// executeMergePR squash-merges an open PR identified by index or label. +func (r *Runner) executeMergePR(ctx context.Context, step *MergePRStep) error { + if r.harness == nil || r.harness.act == nil { + r.t.Log(" Would execute merge_pr (no harness)") + return nil + } + + index := step.Index + if index <= 0 { + if idx, ok := r.prByLabel[step.Label]; ok { + index = idx + } else { + indices, err := r.harness.gitea.ListOpenPRs(ctx, r.harness.repo, "", step.Label) + if err != nil { + return fmt.Errorf("list open PRs for label %s: %w", step.Label, err) + } + if len(indices) == 0 { + return fmt.Errorf("merge_pr: no open PR found with label %q", step.Label) + } + index = indices[0] + } + } + + r.t.Logf(" MergePR: squash-merging PR #%d", index) + if err := r.harness.gitea.MergePR(ctx, r.harness.repo, index, "squash"); err != nil { + return fmt.Errorf("merge PR #%d: %w", index, err) + } + return nil +} + +// executeResolveConflict pushes resolved file content to the last hotfix PR head +// branch (the gitea contents-API equivalent of a force-push that advances the +// head), then replays a pull_request "synchronize" event so the check job runs. +func (r *Runner) executeResolveConflict(ctx context.Context, step *ResolveConflictStep) error { + if r.harness == nil || r.harness.act == nil { + r.t.Log(" Would execute resolve_conflict (no harness)") + return nil + } + if r.lastHotfixBranch == "" { + return fmt.Errorf("resolve_conflict: no prior hotfix_apply branch recorded") + } + + r.t.Logf(" ResolveConflict: committing %d file(s) to %s", len(step.Files), r.lastHotfixBranch) + if _, err := r.harness.gitea.CreateCommitOnBranch(ctx, r.harness.repo, r.lastHotfixBranch, + "hotfix: resolve conflicts", step.Files); err != nil { + return fmt.Errorf("commit conflict resolution: %w", err) + } + + if err := r.harness.SyncRepoToActContainer(ctx); err != nil { + return fmt.Errorf("failed to sync repo: %w", err) + } + + event := map[string]any{ + "action": "synchronize", + "pull_request": map[string]any{ + "number": r.lastPRIndex, + "merged": false, + "base": map[string]any{"ref": "env/" + r.lastHotfixEnv}, + "head": map[string]any{"ref": r.lastHotfixBranch}, + "labels": []map[string]any{{"name": "cascade-hotfix-conflict"}}, + }, + } + eventJSON, err := json.Marshal(event) + if err != nil { + return fmt.Errorf("marshal synchronize event: %w", err) + } + + result, err := r.harness.act.RunWorkflowFromRepo(ctx, RunOpts{ + WorkflowPath: hotfixWorkflowPath, + Event: "pull_request", + EventJSON: string(eventJSON), + Env: r.repoEnv(), + }) + if err != nil { + return fmt.Errorf("failed to run check workflow: %w", err) + } + r.lastWorkflowResult = result + + // Be lenient on conclusion: jobs gated off this event are skipped, which can + // surface as a non-success overall conclusion. Surface logs but do not fail + // the step on conclusion alone; Wave-D scenarios assert observable state. + if result.Conclusion != "success" { + r.t.Logf(" ResolveConflict: check run conclusion=%s (non-fatal)\n%s", result.Conclusion, result.Logs) + } + return nil +} + +// executeHotfixMerged replays the merged pull_request "closed" event for the +// recorded hotfix PR so the context/build/deploy/finalize jobs run. finalize +// invokes the real `cascade hotfix finalize`, writing the diverged state. +func (r *Runner) executeHotfixMerged(ctx context.Context, step *HotfixMergedStep, config Config) error { + if r.harness == nil || r.harness.act == nil { + r.t.Log(" Would execute hotfix_merged (no harness)") + return nil + } + + env := step.TargetEnv + envBranch := "env/" + env + + // The squash merge advanced env/; its tip is the merge commit SHA. + mergeSHA, err := r.harness.gitea.GetBranchSHA(ctx, r.harness.repo, envBranch) + if err != nil { + return fmt.Errorf("get merge SHA for %s: %w", envBranch, err) + } + + if err := r.harness.SyncRepoToActContainer(ctx); err != nil { + return fmt.Errorf("failed to sync repo: %w", err) + } + + event := map[string]any{ + "action": "closed", + "pull_request": map[string]any{ + "number": r.lastPRIndex, + "merged": true, + "merge_commit_sha": mergeSHA, + "base": map[string]any{"ref": envBranch}, + "head": map[string]any{"ref": r.lastHotfixBranch}, + "labels": []map[string]any{{"name": "cascade-hotfix"}}, + "body": r.lastHotfixBody, + }, + } + eventJSON, err := json.Marshal(event) + if err != nil { + return fmt.Errorf("marshal closed event: %w", err) + } + + r.t.Logf(" HotfixMerged: replaying merged PR #%d for %s (merge_sha=%s)", r.lastPRIndex, env, truncateSHA(mergeSHA)) + result, err := r.harness.act.RunWorkflowFromRepo(ctx, RunOpts{ + WorkflowPath: hotfixWorkflowPath, + Event: "pull_request", + EventJSON: string(eventJSON), + Env: r.repoEnv(), + }) + if err != nil { + return fmt.Errorf("failed to run hotfix merged workflow: %w", err) + } + r.lastWorkflowResult = result + + if result.Conclusion != "success" { + r.t.Logf(" HotfixMerged workflow logs:\n%s", result.Logs) + return fmt.Errorf("hotfix merged workflow failed: %s", result.Error) + } + + if err := r.syncStateFromGitea(ctx, config); err != nil { + r.t.Logf(" Warning: failed to sync state from Gitea: %v", err) + } + r.t.Logf(" HotfixMerged: workflow completed successfully") + return nil +} + +// authedRepoURL builds the admin-credentialed origin URL for the test repo, +// mirroring GenerateWorkflows's clone URL construction. +func (r *Runner) authedRepoURL() string { + externalURL := r.harness.act.GiteaURL() + host := strings.TrimPrefix(strings.TrimPrefix(externalURL, "http://"), "https://") + return fmt.Sprintf("http://%s:%s@%s/%s/%s.git", + AdminUsername, AdminPassword, host, AdminUsername, r.harness.repo.Name) +} + +// waitForBranch polls Gitea until it reports the given branch (closing the +// post-push API staleness window) or the timeout elapses. +func (r *Runner) waitForBranch(ctx context.Context, branch string, timeout time.Duration) error { + deadline := time.Now().Add(timeout) + for { + if _, err := r.harness.gitea.GetBranchSHA(ctx, r.harness.repo, branch); err == nil { + return nil + } + if time.Now().After(deadline) { + return fmt.Errorf("branch %s not visible before timeout", branch) + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(500 * time.Millisecond): + } + } +} + +// parseSentinel extracts the value following the first occurrence of prefix on +// its own line in out (e.g. "CONFLICT_FILES=a.txt b.txt"). +func parseSentinel(out, prefix string) string { + for _, line := range strings.Split(out, "\n") { + line = strings.TrimRight(line, "\r") + if strings.HasPrefix(line, prefix) { + return strings.TrimPrefix(line, prefix) + } + } + return "" +} diff --git a/e2e/harness/hotfix_actions_test.go b/e2e/harness/hotfix_actions_test.go new file mode 100644 index 0000000..84c400e --- /dev/null +++ b/e2e/harness/hotfix_actions_test.go @@ -0,0 +1,332 @@ +package harness + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestParseHotfixScenario verifies the new step actions, divergence expectation +// fields, diverged-env setup staging, promote force, and branches/prs +// expectations all unmarshal into the expected struct fields. +func TestParseHotfixScenario(t *testing.T) { + yamlDoc := ` +name: "Hotfix lifecycle" +config: + environments: [dev, prod] +setup: + state: + prod: + sha: base000 + version: v1.0.0 + ref: hotfix/prod/abc12345 + base_sha: commit1 + patches: [commit2] + previous_version: v0.9.0 +steps: + - name: "Plan hotfix" + action: hotfix_plan + hotfix_plan: + commit_ref: commit1 + target_env: prod + dry_run: true + - name: "Apply hotfix" + action: hotfix_apply + hotfix_apply: + commit_ref: commit1 + target_env: prod + - name: "Resolve conflict" + action: resolve_conflict + resolve_conflict: + files: + src/app.ts: "// resolved" + - name: "Merge PR" + action: merge_pr + merge_pr: + label: cascade-hotfix + - name: "Merged event" + action: hotfix_merged + hotfix_merged: + target_env: prod + expect: + state: + prod: + ref: hotfix/prod/abc12345 + base_sha: commit1 + patches: [commit2] + patches_contain: [commit] + previous_version: v0.9.0 + branches: + exist: [env/prod] + deleted: [hotfix/prod/abc12345] + prs: + open_with_label: cascade-hotfix + open_count: 0 + - name: "Force promote" + action: promote + promote: + mode: default + force: true +` + s, err := ParseMultiStepScenario([]byte(yamlDoc)) + require.NoError(t, err) + require.Len(t, s.Steps, 6) + + // Setup diverged env. + prodSetup := s.Setup.State["prod"] + require.NotNil(t, prodSetup) + assert.Equal(t, "hotfix/prod/abc12345", prodSetup.Ref) + assert.Equal(t, "commit1", prodSetup.BaseSHA) + assert.Equal(t, []string{"commit2"}, prodSetup.Patches) + assert.Equal(t, "v0.9.0", prodSetup.PreviousVersion) + + // hotfix_plan. + require.NotNil(t, s.Steps[0].HotfixPlan) + assert.Equal(t, "commit1", s.Steps[0].HotfixPlan.CommitRef) + assert.Equal(t, "prod", s.Steps[0].HotfixPlan.TargetEnv) + assert.True(t, s.Steps[0].HotfixPlan.DryRun) + + // hotfix_apply. + require.NotNil(t, s.Steps[1].HotfixApply) + assert.Equal(t, "commit1", s.Steps[1].HotfixApply.CommitRef) + assert.Equal(t, "prod", s.Steps[1].HotfixApply.TargetEnv) + + // resolve_conflict. + require.NotNil(t, s.Steps[2].ResolveConflict) + assert.Equal(t, "// resolved", s.Steps[2].ResolveConflict.Files["src/app.ts"]) + + // merge_pr. + require.NotNil(t, s.Steps[3].MergePR) + assert.Equal(t, "cascade-hotfix", s.Steps[3].MergePR.Label) + + // hotfix_merged + expectations. + require.NotNil(t, s.Steps[4].HotfixMerged) + assert.Equal(t, "prod", s.Steps[4].HotfixMerged.TargetEnv) + exp := s.Steps[4].Expect + require.NotNil(t, exp) + se := exp.State["prod"] + require.NotNil(t, se) + assert.Equal(t, "hotfix/prod/abc12345", se.Ref) + assert.Equal(t, "commit1", se.BaseSHA) + assert.Equal(t, []string{"commit2"}, se.Patches) + assert.Equal(t, []string{"commit"}, se.PatchesContain) + assert.Equal(t, "v0.9.0", se.PreviousVersion) + require.NotNil(t, exp.Branches) + assert.Equal(t, []string{"env/prod"}, exp.Branches.Exist) + assert.Equal(t, []string{"hotfix/prod/abc12345"}, exp.Branches.Deleted) + require.NotNil(t, exp.PRs) + assert.Equal(t, "cascade-hotfix", exp.PRs.OpenWithLabel) + require.NotNil(t, exp.PRs.OpenCount) + assert.Equal(t, 0, *exp.PRs.OpenCount) + + // promote force. + require.NotNil(t, s.Steps[5].Promote) + assert.True(t, s.Steps[5].Promote.Force) +} + +// TestValidateScenarioHotfixActions covers valid and invalid configurations for +// each new step action. +func TestValidateScenarioHotfixActions(t *testing.T) { + tests := []struct { + name string + step Step + wantErr string // substring; empty means no error expected + }{ + { + name: "hotfix_plan valid", + step: Step{Name: "p", Action: "hotfix_plan", HotfixPlan: &HotfixPlanStep{CommitRef: "c1", TargetEnv: "prod"}}, + }, + { + name: "hotfix_plan missing config", + step: Step{Name: "p", Action: "hotfix_plan"}, + wantErr: "requires hotfix_plan config", + }, + { + name: "hotfix_plan missing target_env", + step: Step{Name: "p", Action: "hotfix_plan", HotfixPlan: &HotfixPlanStep{CommitRef: "c1"}}, + wantErr: "requires target_env", + }, + { + name: "hotfix_plan missing commit_ref", + step: Step{Name: "p", Action: "hotfix_plan", HotfixPlan: &HotfixPlanStep{TargetEnv: "prod"}}, + wantErr: "requires commit_ref", + }, + { + name: "hotfix_apply valid", + step: Step{Name: "a", Action: "hotfix_apply", HotfixApply: &HotfixApplyStep{CommitRef: "c1", TargetEnv: "prod"}}, + }, + { + name: "hotfix_apply missing config", + step: Step{Name: "a", Action: "hotfix_apply"}, + wantErr: "requires hotfix_apply config", + }, + { + name: "hotfix_apply missing target_env", + step: Step{Name: "a", Action: "hotfix_apply", HotfixApply: &HotfixApplyStep{CommitRef: "c1"}}, + wantErr: "requires target_env", + }, + { + name: "hotfix_apply missing commit_ref", + step: Step{Name: "a", Action: "hotfix_apply", HotfixApply: &HotfixApplyStep{TargetEnv: "prod"}}, + wantErr: "requires commit_ref", + }, + { + name: "merge_pr valid by label", + step: Step{Name: "m", Action: "merge_pr", MergePR: &MergePRStep{Label: "cascade-hotfix"}}, + }, + { + name: "merge_pr valid by index", + step: Step{Name: "m", Action: "merge_pr", MergePR: &MergePRStep{Index: 3}}, + }, + { + name: "merge_pr missing config", + step: Step{Name: "m", Action: "merge_pr"}, + wantErr: "requires merge_pr config", + }, + { + name: "merge_pr missing label and index", + step: Step{Name: "m", Action: "merge_pr", MergePR: &MergePRStep{}}, + wantErr: "requires label or index", + }, + { + name: "resolve_conflict valid", + step: Step{Name: "r", Action: "resolve_conflict", ResolveConflict: &ResolveConflictStep{Files: map[string]string{"a": "b"}}}, + }, + { + name: "resolve_conflict missing config", + step: Step{Name: "r", Action: "resolve_conflict"}, + wantErr: "requires resolve_conflict config", + }, + { + name: "resolve_conflict no files", + step: Step{Name: "r", Action: "resolve_conflict", ResolveConflict: &ResolveConflictStep{Files: map[string]string{}}}, + wantErr: "requires at least one file", + }, + { + name: "hotfix_merged valid", + step: Step{Name: "h", Action: "hotfix_merged", HotfixMerged: &HotfixMergedStep{TargetEnv: "prod"}}, + }, + { + name: "hotfix_merged missing config", + step: Step{Name: "h", Action: "hotfix_merged"}, + wantErr: "requires hotfix_merged config", + }, + { + name: "hotfix_merged missing target_env", + step: Step{Name: "h", Action: "hotfix_merged", HotfixMerged: &HotfixMergedStep{}}, + wantErr: "requires target_env", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := NewRunner(t, nil) + err := r.ValidateScenario(&MultiStepScenario{Name: "s", Steps: []Step{tt.step}}) + if tt.wantErr == "" { + assert.NoError(t, err) + return + } + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + }) + } +} + +// TestAssertStateDivergence covers match and mismatch for each new StateExpect +// divergence field against a hand-built context populated via +// RecordStateDivergence. +func TestAssertStateDivergence(t *testing.T) { + newCtx := func() *ExecutionContext { + c := NewExecutionContext() + c.RecordCommit("commit1", "base0000abcd") + c.RecordCommit("commit2", "patch1111efgh") + c.RecordState("prod", "tip9999", "v1.1.0") + c.RecordStateDivergence("prod", "hotfix/prod/base0000", "base0000abcd", + []string{"patch1111efgh"}, "v1.0.0") + return c + } + + tests := []struct { + name string + expect *StateExpect + wantErr string // substring; empty means no errors + }{ + {name: "ref match", expect: &StateExpect{Ref: "hotfix/prod/base0000"}}, + {name: "ref mismatch", expect: &StateExpect{Ref: "hotfix/prod/other"}, wantErr: ".ref expected"}, + {name: "base_sha match by reference", expect: &StateExpect{BaseSHA: "commit1"}}, + {name: "base_sha match by literal", expect: &StateExpect{BaseSHA: "base0000abcd"}}, + {name: "base_sha mismatch", expect: &StateExpect{BaseSHA: "deadbeef"}, wantErr: ".base_sha expected"}, + {name: "patches match by reference", expect: &StateExpect{Patches: []string{"commit2"}}}, + {name: "patches match by literal", expect: &StateExpect{Patches: []string{"patch1111efgh"}}}, + {name: "patches mismatch", expect: &StateExpect{Patches: []string{"missingsha"}}, wantErr: ".patches missing"}, + {name: "patches_contain match", expect: &StateExpect{PatchesContain: []string{"patch1111"}}}, + {name: "patches_contain mismatch", expect: &StateExpect{PatchesContain: []string{"nope"}}, wantErr: "no entry containing"}, + {name: "previous_version match", expect: &StateExpect{PreviousVersion: "v1.0.0"}}, + {name: "previous_version mismatch", expect: &StateExpect{PreviousVersion: "v0.0.1"}, wantErr: ".previous_version expected"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + errs := AssertState(newCtx(), "prod", tt.expect) + if tt.wantErr == "" { + assert.Empty(t, errs) + return + } + require.NotEmpty(t, errs) + var joined string + for _, e := range errs { + joined += e.Error() + "\n" + } + assert.Contains(t, joined, tt.wantErr) + }) + } +} + +// TestRunnerHotfixActionsNoHarness proves the dispatch wiring for each new +// action returns nil (the no-harness guard path) without containers. +func TestRunnerHotfixActionsNoHarness(t *testing.T) { + ctx := context.Background() + steps := []Step{ + {Name: "plan", Action: "hotfix_plan", HotfixPlan: &HotfixPlanStep{CommitRef: "c1", TargetEnv: "prod"}}, + {Name: "apply", Action: "hotfix_apply", HotfixApply: &HotfixApplyStep{CommitRef: "c1", TargetEnv: "prod"}}, + {Name: "merge", Action: "merge_pr", MergePR: &MergePRStep{Label: "cascade-hotfix"}}, + {Name: "resolve", Action: "resolve_conflict", ResolveConflict: &ResolveConflictStep{Files: map[string]string{"a": "b"}}}, + {Name: "merged", Action: "hotfix_merged", HotfixMerged: &HotfixMergedStep{TargetEnv: "prod"}}, + } + for _, step := range steps { + t.Run(step.Action, func(t *testing.T) { + r := NewRunner(t, nil) + step := step + err := r.executeStep(ctx, &step, Config{Environments: []string{"dev", "prod"}}) + assert.NoError(t, err) + }) + } +} + +// TestRunnerStateDivergenceSetupNoHarness verifies applySetup records divergence +// into the execution context without a harness. +func TestRunnerStateDivergenceSetupNoHarness(t *testing.T) { + r := NewRunner(t, nil) + r.ctx.RecordCommit("commit1", "base0000") + err := r.applySetup(context.Background(), &SetupState{ + State: map[string]*EnvStateSetup{ + "prod": { + SHA: "tip000", + Version: "v1.1.0", + Ref: "hotfix/prod/x", + BaseSHA: "commit1", + Patches: []string{"patchsha"}, + PreviousVersion: "v1.0.0", + }, + }, + }) + require.NoError(t, err) + + st := r.ctx.GetState("prod") + assert.Equal(t, "hotfix/prod/x", st.Ref) + assert.Equal(t, "base0000", st.BaseSHA) // resolved from commit1 + assert.Equal(t, []string{"patchsha"}, st.Patches) + assert.Equal(t, "v1.0.0", st.PreviousVersion) +} diff --git a/e2e/harness/runner.go b/e2e/harness/runner.go index 6f6bcaa..2c87e99 100644 --- a/e2e/harness/runner.go +++ b/e2e/harness/runner.go @@ -17,6 +17,14 @@ type Runner struct { ctx *ExecutionContext harness *Harness // The existing harness for infra lastWorkflowResult *ExtendedWorkflowResult + // Hotfix apply bookkeeping, carried across steps so merge_pr, + // resolve_conflict, and hotfix_merged can act on the most recent apply. + lastPRIndex int64 // PR index opened by the most recent hotfix_apply + lastPRConflict bool // whether that apply hit a cherry-pick conflict + lastHotfixBranch string // head branch of that apply + lastHotfixEnv string // target env of that apply + lastHotfixBody string // PR body (with trailers) of that apply + prByLabel map[string]int64 // label -> most recent PR index opened with it } // NewRunner creates a new scenario runner @@ -58,6 +66,47 @@ func (r *Runner) ValidateScenario(scenario *MultiStepScenario) error { if step.Promote.Mode == "cascade" && step.Promote.Target == "" { return fmt.Errorf("step %d (%s): cascade promote requires target", i, step.Name) } + case "hotfix_plan": + if step.HotfixPlan == nil { + return fmt.Errorf("step %d (%s): hotfix_plan action requires hotfix_plan config", i, step.Name) + } + if step.HotfixPlan.TargetEnv == "" { + return fmt.Errorf("step %d (%s): hotfix_plan requires target_env", i, step.Name) + } + if step.HotfixPlan.CommitRef == "" { + return fmt.Errorf("step %d (%s): hotfix_plan requires commit_ref", i, step.Name) + } + case "hotfix_apply": + if step.HotfixApply == nil { + return fmt.Errorf("step %d (%s): hotfix_apply action requires hotfix_apply config", i, step.Name) + } + if step.HotfixApply.TargetEnv == "" { + return fmt.Errorf("step %d (%s): hotfix_apply requires target_env", i, step.Name) + } + if step.HotfixApply.CommitRef == "" { + return fmt.Errorf("step %d (%s): hotfix_apply requires commit_ref", i, step.Name) + } + case "merge_pr": + if step.MergePR == nil { + return fmt.Errorf("step %d (%s): merge_pr action requires merge_pr config", i, step.Name) + } + if step.MergePR.Label == "" && step.MergePR.Index <= 0 { + return fmt.Errorf("step %d (%s): merge_pr requires label or index", i, step.Name) + } + case "resolve_conflict": + if step.ResolveConflict == nil { + return fmt.Errorf("step %d (%s): resolve_conflict action requires resolve_conflict config", i, step.Name) + } + if len(step.ResolveConflict.Files) == 0 { + return fmt.Errorf("step %d (%s): resolve_conflict requires at least one file", i, step.Name) + } + case "hotfix_merged": + if step.HotfixMerged == nil { + return fmt.Errorf("step %d (%s): hotfix_merged action requires hotfix_merged config", i, step.Name) + } + if step.HotfixMerged.TargetEnv == "" { + return fmt.Errorf("step %d (%s): hotfix_merged requires target_env", i, step.Name) + } default: return fmt.Errorf("step %d (%s): unknown action %q", i, step.Name, step.Action) } @@ -278,6 +327,16 @@ func (r *Runner) executeStep(ctx context.Context, step *Step, config Config) err return r.executeOrchestrate(ctx, config, step.ExpectFailure) case "promote": return r.executePromote(ctx, step.Promote, config) + case "hotfix_plan": + return r.executeHotfixPlan(ctx, step.HotfixPlan) + case "hotfix_apply": + return r.executeHotfixApply(ctx, step.HotfixApply) + case "merge_pr": + return r.executeMergePR(ctx, step.MergePR) + case "resolve_conflict": + return r.executeResolveConflict(ctx, step.ResolveConflict) + case "hotfix_merged": + return r.executeHotfixMerged(ctx, step.HotfixMerged, config) default: return fmt.Errorf("unknown action: %s", step.Action) } @@ -766,9 +825,70 @@ func (r *Runner) assertStep(ctx context.Context, step *Step, preState *Execution allErrs = append(allErrs, errs...) } + // Assert branch presence/absence in Gitea (live). + if expect.Branches != nil { + errs := r.assertBranches(ctx, expect.Branches) + allErrs = append(allErrs, errs...) + } + + // Assert open pull requests in Gitea (live). + if expect.PRs != nil { + errs := r.assertPRs(ctx, expect.PRs) + allErrs = append(allErrs, errs...) + } + return allErrs } +// assertBranches checks branch existence in Gitea against the expectation. +// Returns nil in unit-test mode (no harness). +func (r *Runner) assertBranches(ctx context.Context, expect *BranchesExpect) []error { + if r.harness == nil || r.harness.gitea == nil || r.harness.repo == nil { + return nil + } + branches, err := r.harness.gitea.ListBranches(ctx, r.harness.repo) + if err != nil { + return []error{fmt.Errorf("list branches: %w", err)} + } + var errs []error + for _, want := range expect.Exist { + if !containsString(branches, want) { + errs = append(errs, fmt.Errorf("branch %s expected to exist but not found", want)) + } + } + for _, gone := range expect.Deleted { + if containsString(branches, gone) { + errs = append(errs, fmt.Errorf("branch %s expected to be deleted but exists", gone)) + } + } + return errs +} + +// assertPRs checks open pull requests in Gitea against the expectation. When +// OpenCount is set the count must match exactly; otherwise at least one matching +// PR must be open. Returns nil in unit-test mode (no harness). +func (r *Runner) assertPRs(ctx context.Context, expect *PRsExpect) []error { + if r.harness == nil || r.harness.gitea == nil || r.harness.repo == nil { + return nil + } + indices, err := r.harness.gitea.ListOpenPRs(ctx, r.harness.repo, "", expect.OpenWithLabel) + if err != nil { + return []error{fmt.Errorf("list open PRs: %w", err)} + } + var errs []error + if expect.OpenCount != nil { + if len(indices) != *expect.OpenCount { + errs = append(errs, fmt.Errorf("expected %d open PR(s) with label %q, got %d", + *expect.OpenCount, expect.OpenWithLabel, len(indices))) + } + return errs + } + if len(indices) == 0 { + errs = append(errs, fmt.Errorf("expected at least one open PR with label %q, got none", expect.OpenWithLabel)) + } + return errs +} + // assertWorkflowFile reads a workflow file from the test repo (in /tmp/repo // inside the act container) and checks its content against the expectation. // Returns errors for missing-substring or unexpected-substring matches.