diff --git a/e2e/harness/act.go b/e2e/harness/act.go index 8c843f3..4eca800 100644 --- a/e2e/harness/act.go +++ b/e2e/harness/act.go @@ -5,6 +5,7 @@ import ( "context" "fmt" "io" + "strings" "github.com/moby/moby/api/types/container" "github.com/moby/moby/api/types/mount" @@ -123,6 +124,38 @@ func (a *ActRunner) Terminate(ctx context.Context) error { return a.container.Terminate(ctx) } +// eventFilePath is the in-container path the event payload is written to when +// RunOpts.EventJSON is set. act reads it via its -e flag to seed the +// github.event context (e.g. a merged pull_request payload). +const eventFilePath = "/tmp/cascade-event.json" + +// 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 +// append the -e flag when a path comes back. CopyToContainer transfers the raw +// bytes, so arbitrary JSON (quotes, newlines, shell metacharacters) round-trips +// intact without heredoc or shell-escaping hazards. +func (a *ActRunner) writeEventFile(ctx context.Context, eventJSON string) (string, error) { + if eventJSON == "" { + return "", nil + } + if err := a.container.CopyToContainer(ctx, []byte(eventJSON), eventFilePath, 0644); err != nil { + return "", fmt.Errorf("failed to write event payload: %w", err) + } + return eventFilePath, nil +} + +// eventFileArgs returns the act flag fragment that points act at the event +// payload file. eventPath is the path returned by writeEventFile (empty when no +// EventJSON was supplied), so the result is empty in that case. Kept pure so the +// command construction is unit-testable without a container. +func eventFileArgs(eventPath string) []string { + if eventPath == "" { + return nil + } + return []string{"-e", eventPath} +} + // RunWorkflow executes a GitHub Actions workflow using act func (a *ActRunner) RunWorkflow(ctx context.Context, opts RunOpts) (*ExtendedWorkflowResult, error) { // Create temp directory for workflow @@ -158,6 +191,15 @@ func (a *ActRunner) RunWorkflow(ctx context.Context, opts RunOpts) (*ExtendedWor "--network=" + network, } + // When an event payload is supplied, write it into the container and point + // act at it with -e so the github.event context is seeded (e.g. a merged + // pull_request payload for the hotfix post-merge path). + eventPath, evErr := a.writeEventFile(ctx, opts.EventJSON) + if evErr != nil { + return nil, evErr + } + cmd = append(cmd, eventFileArgs(eventPath)...) + // Add Gitea environment variables if a.giteaURL != "" { cmd = append(cmd, "--env", fmt.Sprintf("GITHUB_SERVER_URL=%s", a.giteaURL)) @@ -229,6 +271,15 @@ 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) + if err != nil { + return nil, err + } + actCmd := "cd /tmp/repo && act " + opts.Event + " -W " + workflowsPath + " --detect-event" + @@ -241,7 +292,7 @@ func (a *ActRunner) RunWorkflowFromRepo(ctx context.Context, opts RunOpts) (*Ext // Provide token for Gitea authentication " --secret GITHUB_TOKEN=" + a.giteaToken - actCmd += a.buildActArgs(opts) + actCmd += a.buildActArgs(opts, eventPath) cmd := []string{ "bash", "-c", @@ -296,8 +347,10 @@ func normalizeWorkflowResult(result *ExtendedWorkflowResult, workflowPath string } } -// buildActArgs builds additional act command arguments -func (a *ActRunner) buildActArgs(opts RunOpts) string { +// buildActArgs builds additional act command arguments. eventPath is the +// in-container event-payload file written by writeEventFile (empty when no +// EventJSON was supplied); when set it is appended as the act -e flag. +func (a *ActRunner) buildActArgs(opts RunOpts, eventPath string) string { var args string // Add provided env vars @@ -310,6 +363,11 @@ func (a *ActRunner) buildActArgs(opts RunOpts) string { args += fmt.Sprintf(" --input %s=%s", k, v) } + // Point act at the event payload file when one was written. + if flags := eventFileArgs(eventPath); len(flags) > 0 { + args += " " + strings.Join(flags, " ") + } + return args } diff --git a/e2e/harness/act_test.go b/e2e/harness/act_test.go index 4059fb2..d42e829 100644 --- a/e2e/harness/act_test.go +++ b/e2e/harness/act_test.go @@ -57,6 +57,89 @@ jobs: assert.Contains(t, result.Logs, "Hello from act") } +func TestEventFileArgs(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + eventPath string + want []string + }{ + { + name: "no event path yields no flag", + eventPath: "", + want: nil, + }, + { + name: "event path yields -e flag", + eventPath: eventFilePath, + want: []string{"-e", eventFilePath}, + }, + { + name: "custom event path is passed through", + eventPath: "/tmp/other-event.json", + want: []string{"-e", "/tmp/other-event.json"}, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.want, eventFileArgs(tt.eventPath)) + }) + } +} + +// TestBuildActArgs_EventFlag verifies the act command picks up `-e ` when +// an event payload was written, and omits it otherwise, without requiring a +// real act run or container. +func TestBuildActArgs_EventFlag(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + opts RunOpts + eventPath string + wantContains []string + wantOmits []string + }{ + { + name: "event payload adds -e flag", + opts: RunOpts{}, + eventPath: eventFilePath, + wantContains: []string{"-e " + eventFilePath}, + }, + { + name: "no event payload omits -e flag", + opts: RunOpts{}, + eventPath: "", + wantOmits: []string{"-e"}, + }, + { + name: "event flag coexists with env and inputs", + opts: RunOpts{Env: map[string]string{"FOO": "bar"}, Inputs: map[string]string{"k": "v"}}, + eventPath: eventFilePath, + wantContains: []string{"-e " + eventFilePath, "--env FOO=bar", "--input k=v"}, + }, + } + + a := &ActRunner{} + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + args := a.buildActArgs(tt.opts, tt.eventPath) + for _, want := range tt.wantContains { + assert.Contains(t, args, want) + } + for _, omit := range tt.wantOmits { + assert.NotContains(t, args, omit) + } + }) + } +} + func TestNormalizeWorkflowResult(t *testing.T) { t.Parallel() diff --git a/e2e/harness/harness.go b/e2e/harness/harness.go index 22e0f10..dda8032 100644 --- a/e2e/harness/harness.go +++ b/e2e/harness/harness.go @@ -704,6 +704,14 @@ func (h *Harness) ensureCLIBinary(ctx context.Context) (string, error) { // `./` prefix act needs for local resolution. Each pass is checked for // success and verified by grepping for any remaining `stablekernel/...` ref; // the operation retries on transient failure. +// +// The `.github/workflows/*.yaml` glob is intentionally broad: it covers every +// generated workflow, including cascade-hotfix.yaml (whose plan/apply/finalize +// jobs reference the same setup-cli action ref). No hotfix-specific localization +// is required beyond this glob. The companion `name:` namespacing in +// GenerateWorkflows only appends a per-scenario suffix to the workflow-level +// `name:` line, so workflow_files assertions over hotfix job/trigger content +// remain stable as long as they do not assert on the top-level name line. func (h *Harness) localizeWorkflows(ctx context.Context) error { const maxAttempts = 3 const retryDelay = 200 * time.Millisecond @@ -766,9 +774,21 @@ func (h *Harness) SyncRepoToActContainer(ctx context.Context) error { // push converged. Re-fetching resolves those; a persistent miss after the // bound is a real lost-commit and is reported with this call site's own // message rather than the generation-phase one. + // + // After the load-bearing `git fetch origin main && git reset --hard + // origin/main` (which still gates the lost-commit detection and bounded + // retry below), pull the env/* integration branches and tags too, mirroring + // the generated hotfix workflow's fetch step (internal/generate/hotfix.go, + // writeFetchEnvBranches). Hotfix plan/apply/finalize jobs inspect + // env/ branches and hotfix tags, so they must be present in /tmp/repo. + // This extra fetch is chained with `|| true`: env/* branches and tags do not + // exist in most scenarios, and their absence must never fail the sync or + // regress the main-branch convergence guarantee. syncCmd := []string{ "bash", "-c", - "cd /tmp/repo && git fetch origin main && git reset --hard origin/main && (git branch -f master HEAD 2>/dev/null || true)", + "cd /tmp/repo && git fetch origin main && git reset --hard origin/main && " + + "(git fetch origin '+refs/heads/env/*:refs/remotes/origin/env/*' --tags 2>/dev/null || true) && " + + "(git branch -f master HEAD 2>/dev/null || true)", } const maxAttempts = 5