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
64 changes: 61 additions & 3 deletions e2e/harness/act.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"context"
"fmt"
"io"
"strings"

"github.com/moby/moby/api/types/container"
"github.com/moby/moby/api/types/mount"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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" +
Expand All @@ -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",
Expand Down Expand Up @@ -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
Expand All @@ -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
}

Expand Down
83 changes: 83 additions & 0 deletions e2e/harness/act_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <file>` 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()

Expand Down
22 changes: 21 additions & 1 deletion e2e/harness/harness.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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/<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
Expand Down
Loading