Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 52 additions & 5 deletions e2e/harness/act.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package harness
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"strings"
Expand Down Expand Up @@ -129,6 +130,49 @@ func (a *ActRunner) Terminate(ctx context.Context) error {
// github.event context (e.g. a merged pull_request payload).
const eventFilePath = "/tmp/cascade-event.json"

// dispatchInputsEventJSON builds a minimal workflow_dispatch event payload that
// embeds the run's inputs under the top-level "inputs" key, e.g.
//
// {"inputs":{"commit":"<sha>","dry_run":"true","target_env":"test"}}
//
// act seeds the github.event.inputs context from the -e event file, but does NOT
// reliably populate it from --input flags alone when --detect-event is set and no
// event file carries an inputs key. A job gated on
// github.event.inputs.dry_run (e.g. the hotfix apply job) therefore evaluates its
// if: against an empty value and runs when it should be skipped. Writing this
// payload as the event file makes github.event.inputs authoritative regardless of
// act's --input handling.
//
// It returns "" when inputs is empty (no payload needed) or when marshaling fails
// (caller falls back to the --input-only behavior). The map is encoded by
// encoding/json, which emits keys in sorted order, so the output is deterministic.
func dispatchInputsEventJSON(inputs map[string]string) string {
if len(inputs) == 0 {
return ""
}
payload := map[string]map[string]string{"inputs": inputs}
encoded, err := json.Marshal(payload)
if err != nil {
return ""
}
return string(encoded)
}

// resolveEventJSON picks the event payload act should be pointed at for a run. An
// explicitly supplied opts.EventJSON always wins (e.g. a merged pull_request
// payload). Otherwise, for a workflow_dispatch carrying inputs, it synthesizes a
// payload via dispatchInputsEventJSON so github.event.inputs is seeded. All other
// runs get "" (no event file).
func resolveEventJSON(opts RunOpts) string {
if opts.EventJSON != "" {
return opts.EventJSON
}
if opts.Event == "workflow_dispatch" {
return dispatchInputsEventJSON(opts.Inputs)
}
return ""
}

// writeEventFile writes the run's EventJSON payload to eventFilePath inside the
// act container via CopyToContainer. It is a no-op (returning "" with no error)
// when EventJSON is empty, so callers can unconditionally invoke it and only
Expand Down Expand Up @@ -271,11 +315,14 @@ func (a *ActRunner) RunWorkflowFromRepo(ctx context.Context, opts RunOpts) (*Ext
if a.networkName != "" {
network = a.networkName
}
// When an event payload is supplied, write it into the container so
// buildActArgs can append the act -e flag pointing at it. This seeds the
// github.event context (e.g. the merged pull_request payload that drives the
// hotfix context/build/deploy/finalize jobs).
eventPath, err := a.writeEventFile(ctx, opts.EventJSON)
// Resolve the event payload to point act at. An explicit opts.EventJSON wins
// (e.g. the merged pull_request payload that drives the hotfix
// context/build/deploy/finalize jobs). For a workflow_dispatch carrying
// inputs, we synthesize a payload embedding those inputs so
// github.event.inputs is seeded; act does not reliably populate that context
// from --input flags alone, so a job gated on github.event.inputs (e.g. the
// hotfix apply job's dry_run check) would otherwise mis-evaluate its if:.
eventPath, err := a.writeEventFile(ctx, resolveEventJSON(opts))
if err != nil {
return nil, err
}
Expand Down
93 changes: 93 additions & 0 deletions e2e/harness/act_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,99 @@ func TestBuildActArgs_EventFlag(t *testing.T) {
}
}

// TestDispatchInputsEventJSON verifies the synthesized workflow_dispatch payload
// embeds the run's inputs under a top-level "inputs" key (the shape act reads to
// seed github.event.inputs) and is empty when there are no inputs.
func TestDispatchInputsEventJSON(t *testing.T) {
t.Parallel()

tests := []struct {
name string
inputs map[string]string
want string
}{
{
name: "no inputs yields empty payload",
inputs: nil,
want: "",
},
{
name: "empty map yields empty payload",
inputs: map[string]string{},
want: "",
},
{
name: "hotfix dry-run inputs are embedded under inputs key (keys sorted)",
inputs: map[string]string{
"commit": "abc123",
"target_env": "test",
"dry_run": "true",
},
want: `{"inputs":{"commit":"abc123","dry_run":"true","target_env":"test"}}`,
},
}

for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
assert.Equal(t, tt.want, dispatchInputsEventJSON(tt.inputs))
})
}
}

// TestResolveEventJSON verifies an explicit EventJSON always wins, a
// workflow_dispatch with inputs synthesizes the inputs payload, and all other
// runs resolve to no event file.
func TestResolveEventJSON(t *testing.T) {
t.Parallel()

tests := []struct {
name string
opts RunOpts
want string
}{
{
name: "explicit event json wins over synthesized payload",
opts: RunOpts{
Event: "workflow_dispatch",
EventJSON: `{"action":"closed"}`,
Inputs: map[string]string{"dry_run": "true"},
},
want: `{"action":"closed"}`,
},
{
name: "workflow_dispatch with inputs synthesizes inputs payload",
opts: RunOpts{
Event: "workflow_dispatch",
Inputs: map[string]string{"dry_run": "true", "target_env": "test"},
},
want: `{"inputs":{"dry_run":"true","target_env":"test"}}`,
},
{
name: "workflow_dispatch without inputs yields no event file",
opts: RunOpts{Event: "workflow_dispatch"},
want: "",
},
{
name: "non-dispatch event without explicit json yields no event file",
opts: RunOpts{
Event: "push",
Inputs: map[string]string{"dry_run": "true"},
},
want: "",
},
}

for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
assert.Equal(t, tt.want, resolveEventJSON(tt.opts))
})
}
}

func TestNormalizeWorkflowResult(t *testing.T) {
t.Parallel()

Expand Down
26 changes: 26 additions & 0 deletions e2e/harness/assert.go
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,32 @@ func AssertState(ctx *ExecutionContext, env string, expect *StateExpect) []error
env, expect.PreviousVersion, actual.PreviousVersion))
}

// Check cleared divergence fields: each named field must read back empty.
// Expresses the rejoin contract, which an empty expectation value alone
// cannot assert (empty Ref/BaseSHA/Patches expectations are skipped above).
for _, field := range expect.Cleared {
switch field {
case "ref":
if actual.Ref != "" {
errs = append(errs, fmt.Errorf("state[%s].ref expected cleared but got %s",
env, actual.Ref))
}
case "base_sha":
if actual.BaseSHA != "" {
errs = append(errs, fmt.Errorf("state[%s].base_sha expected cleared but got %s",
env, actual.BaseSHA))
}
case "patches":
if len(actual.Patches) > 0 {
errs = append(errs, fmt.Errorf("state[%s].patches expected cleared but got %v",
env, actual.Patches))
}
default:
errs = append(errs, fmt.Errorf("state[%s].cleared lists unsupported field %q (want ref|base_sha|patches)",
env, field))
}
}

// Check deploys
for deployName, deployExpect := range expect.Deploys {
actualDeploy := actual.Deploys[deployName]
Expand Down
28 changes: 27 additions & 1 deletion e2e/harness/hotfix_actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,17 @@ func (r *Runner) executeHotfixApply(ctx context.Context, step *HotfixApplyStep)
short8 := short
script := strings.Join([]string{
"set +e",
// Abort any half-finished cherry-pick left in the shared /tmp/repo by a
// prior apply in this scenario, then drop any stale local hotfix branch so
// the re-create below always anchors on the freshly-fetched remote tip.
"git cherry-pick --abort >/dev/null 2>&1 || true",
// Force-update the env tracking ref so a second apply onto an env branch
// the first finalize already advanced (squash-merge) anchors on the
// current tip rather than a stale ref, otherwise the cherry-pick replays
// an already-merged change and the push is rejected non-fast-forward.
fmt.Sprintf("git fetch origin %q --tags >/dev/null 2>&1", "+refs/heads/"+envBranch+":refs/remotes/origin/"+envBranch),
"git fetch origin '+refs/heads/*:refs/remotes/origin/*' --tags >/dev/null 2>&1",
fmt.Sprintf("git branch -D %q >/dev/null 2>&1 || true", hotfixBranch),
fmt.Sprintf("git switch -c %q %q", hotfixBranch, "origin/"+envBranch),
fmt.Sprintf("git cherry-pick -x %q", commit),
"CP_EXIT=$?",
Expand All @@ -181,7 +191,11 @@ func (r *Runner) executeHotfixApply(ctx context.Context, step *HotfixApplyStep)
"else",
" echo \"CONFLICT_FILES=\"",
"fi",
fmt.Sprintf("git push %q %q", pushURL, hotfixBranch+":"+hotfixBranch),
// Force-push the uniquely-named, per-apply throwaway hotfix branch. The
// branch name embeds the source short SHA, so a force-push only ever
// overwrites this apply's own prior attempt (e.g. a retried sync), never a
// shared branch.
fmt.Sprintf("git push --force %q %q", pushURL, hotfixBranch+":"+hotfixBranch),
"echo \"PUSH_EXIT=$?\"",
}, "\n")

Expand Down Expand Up @@ -258,6 +272,18 @@ func (r *Runner) executeMergePR(ctx context.Context, step *MergePRStep) error {
if err := r.harness.gitea.MergePR(ctx, r.harness.repo, index, "squash"); err != nil {
return fmt.Errorf("merge PR #%d: %w", index, err)
}

// Record the post-merge env branch tip as "hotfix_head". The squash merge
// produces a commit that lives only on env/<env>; trunk never merges that
// branch back, so this SHA is not an ancestor of any trunk commit. Scenarios
// reference it as an off-trunk patch to exercise the patch-containment guard.
if r.lastHotfixEnv != "" {
envBranch := "env/" + r.lastHotfixEnv
if branchSHA, err := r.harness.gitea.GetBranchSHA(ctx, r.harness.repo, envBranch); err == nil {
r.ctx.RecordCommit("hotfix_head", branchSHA)
r.t.Logf(" MergePR: recorded hotfix_head=%s (post-merge env/%s tip)", truncateSHA(branchSHA), r.lastHotfixEnv)
}
}
return nil
}

Expand Down
63 changes: 63 additions & 0 deletions e2e/harness/hotfix_actions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,20 @@ func TestValidateScenarioHotfixActions(t *testing.T) {
step: Step{Name: "h", Action: "hotfix_merged", HotfixMerged: &HotfixMergedStep{}},
wantErr: "requires target_env",
},
{
name: "stage_divergence valid",
step: Step{Name: "s", Action: "stage_divergence", StageDivergence: &StageDivergenceStep{Env: "test", Patches: []string{"hotfix_head"}}},
},
{
name: "stage_divergence missing config",
step: Step{Name: "s", Action: "stage_divergence"},
wantErr: "requires stage_divergence config",
},
{
name: "stage_divergence missing env",
step: Step{Name: "s", Action: "stage_divergence", StageDivergence: &StageDivergenceStep{Patches: []string{"x"}}},
wantErr: "requires env",
},
}

for _, tt := range tests {
Expand Down Expand Up @@ -305,6 +319,55 @@ func TestRunnerHotfixActionsNoHarness(t *testing.T) {
}
}

// TestRunnerStageDivergenceNoHarness verifies stage_divergence records the
// divergence into the execution context (resolving commit references to literal
// SHAs) without a harness, and that the manifest write is skipped in that mode.
func TestRunnerStageDivergenceNoHarness(t *testing.T) {
r := NewRunner(t, nil)
r.ctx.RecordCommit("commit1", "base0000")
r.ctx.RecordCommit("hotfix_head", "offtrunk9999")

err := r.executeStageDivergence(context.Background(), &StageDivergenceStep{
Env: "test",
Ref: "env/test",
BaseSHA: "commit1",
Patches: []string{"hotfix_head", "literalsha"},
})
require.NoError(t, err)

st := r.ctx.GetState("test")
assert.Equal(t, "env/test", st.Ref)
assert.Equal(t, "base0000", st.BaseSHA) // resolved from commit1
assert.Equal(t, []string{"offtrunk9999", "literalsha"}, st.Patches) // hotfix_head resolved, literal kept
}

// TestParseStageDivergenceStep verifies the stage_divergence step unmarshals
// into the expected struct fields.
func TestParseStageDivergenceStep(t *testing.T) {
yamlDoc := `
name: "Stage divergence"
config:
environments: [dev, test]
steps:
- name: "Stage"
action: stage_divergence
stage_divergence:
env: test
ref: "env/test"
base_sha: commit1
patches: [hotfix_head]
`
s, err := ParseMultiStepScenario([]byte(yamlDoc))
require.NoError(t, err)
require.Len(t, s.Steps, 1)
sd := s.Steps[0].StageDivergence
require.NotNil(t, sd)
assert.Equal(t, "test", sd.Env)
assert.Equal(t, "env/test", sd.Ref)
assert.Equal(t, "commit1", sd.BaseSHA)
assert.Equal(t, []string{"hotfix_head"}, sd.Patches)
}

// TestRunnerStateDivergenceSetupNoHarness verifies applySetup records divergence
// into the execution context without a harness.
func TestRunnerStateDivergenceSetupNoHarness(t *testing.T) {
Expand Down
25 changes: 25 additions & 0 deletions e2e/harness/multistep.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,10 @@ type Step struct {
// HotfixMerged configures a "hotfix_merged" action: replay the merged
// pull_request event so the context/build/deploy/finalize jobs run.
HotfixMerged *HotfixMergedStep `yaml:"hotfix_merged,omitempty"`
// StageDivergence configures a "stage_divergence" action: overwrite an
// environment's divergence fields in the live manifest mid-scenario without
// running any workflow.
StageDivergence *StageDivergenceStep `yaml:"stage_divergence,omitempty"`
// ExpectFailure marks a step whose workflow is expected to conclude in
// failure (for example an orchestrate run whose build exits non-zero). When
// set, a failure conclusion is the success path and a success conclusion is
Expand Down Expand Up @@ -110,6 +114,21 @@ type HotfixMergedStep struct {
TargetEnv string `yaml:"target_env"`
}

// StageDivergenceStep defines a stage_divergence action: it rewrites the
// divergence fields (ref/base_sha/patches) for Env directly in the live
// manifest, then records the same divergence in the execution context. No
// workflow runs. Ref/BaseSHA/Patches entries may be commit references (resolved
// via the execution context) or literal SHAs. Used to re-wire a diverged env's
// patch set to an off-trunk SHA so a later promote exercises the
// patch-containment guard.
type StageDivergenceStep struct {
Env string `yaml:"env"`
Ref string `yaml:"ref,omitempty"`
BaseSHA string `yaml:"base_sha,omitempty"`
Patches []string `yaml:"patches,omitempty"`
PreviousVersion string `yaml:"previous_version,omitempty"`
}

// CommitStep defines a commit action
type CommitStep struct {
Message string `yaml:"message"`
Expand Down Expand Up @@ -202,6 +221,12 @@ type StateExpect struct {
// prior versions in a separate "previous" ring), so it is only populated by
// setup staging or by an explicit divergence record.
PreviousVersion string `yaml:"previous_version,omitempty"`
// Cleared names divergence fields that must now be empty on the recorded
// state. Supported members: "ref", "base_sha", "patches". This expresses the
// rejoin contract (divergence fields are cleared once an env rejoins trunk),
// which an empty Ref/BaseSHA/Patches value alone cannot assert because empty
// expectation values are skipped. Each named field must read back empty.
Cleared []string `yaml:"cleared,omitempty"`
}

// DeployExpect defines expected deploy state
Expand Down
Loading
Loading