diff --git a/act/common/outbound_ip.go b/act/common/outbound_ip.go index 974b1ccc..55a29c44 100644 --- a/act/common/outbound_ip.go +++ b/act/common/outbound_ip.go @@ -10,6 +10,11 @@ import ( "strings" ) +type interfaceIP struct { + net.IP + net.Interface +} + // GetOutboundIP returns an outbound IP address of this machine. // It tries to access the internet and returns the local IP address of the connection. // If the machine cannot access the internet, it returns a preferred IP address from network interfaces. @@ -26,11 +31,7 @@ func GetOutboundIP() net.IP { // So the machine cannot access the internet. Pick an IP address from network interfaces. if ifs, err := net.Interfaces(); err == nil { - type IP struct { - net.IP - net.Interface - } - var ips []IP + var ips []interfaceIP for _, i := range ifs { if addrs, err := i.Addrs(); err == nil { for _, addr := range addrs { @@ -42,40 +43,45 @@ func GetOutboundIP() net.IP { ip = v.IP } if ip.IsGlobalUnicast() { - ips = append(ips, IP{ip, i}) + ips = append(ips, interfaceIP{ip, i}) } } } } - if len(ips) > 1 { - sort.Slice(ips, func(i, j int) bool { - ifi := ips[i].Interface - ifj := ips[j].Interface - - // ethernet is preferred - if vi, vj := strings.HasPrefix(ifi.Name, "e"), strings.HasPrefix(ifj.Name, "e"); vi != vj { - return vi - } - - ipi := ips[i].IP - ipj := ips[j].IP - - // IPv4 is preferred - if vi, vj := ipi.To4() != nil, ipj.To4() != nil; vi != vj { - return vi - } - - // en0 is preferred to en1 - if ifi.Name != ifj.Name { - return ifi.Name < ifj.Name - } - - // fallback - return ipi.String() < ipj.String() - }) - return ips[0].IP - } + return preferredIP(ips) } return nil } + +func preferredIP(ips []interfaceIP) net.IP { + if len(ips) == 0 { + return nil + } + sort.Slice(ips, func(i, j int) bool { + ifi := ips[i].Interface + ifj := ips[j].Interface + + // ethernet is preferred + if vi, vj := strings.HasPrefix(ifi.Name, "e"), strings.HasPrefix(ifj.Name, "e"); vi != vj { + return vi + } + + ipi := ips[i].IP + ipj := ips[j].IP + + // IPv4 is preferred + if vi, vj := ipi.To4() != nil, ipj.To4() != nil; vi != vj { + return vi + } + + // en0 is preferred to en1 + if ifi.Name != ifj.Name { + return ifi.Name < ifj.Name + } + + // fallback + return ipi.String() < ipj.String() + }) + return ips[0].IP +} diff --git a/act/common/outbound_ip_test.go b/act/common/outbound_ip_test.go new file mode 100644 index 00000000..2ba1eaed --- /dev/null +++ b/act/common/outbound_ip_test.go @@ -0,0 +1,20 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package common + +import ( + "net" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestPreferredIP(t *testing.T) { + assert.Nil(t, preferredIP(nil)) + assert.Equal(t, net.ParseIP("2001:db8::1"), preferredIP([]interfaceIP{{IP: net.ParseIP("2001:db8::1")}})) + assert.Equal(t, net.ParseIP("2001:db8::1"), preferredIP([]interfaceIP{ + {IP: net.ParseIP("192.0.2.1"), Name: "wlan0"}, + {IP: net.ParseIP("2001:db8::1"), Name: "eth0"}, + })) +} diff --git a/act/container/docker_images.go b/act/container/docker_images.go index bcd46e64..69618e1d 100644 --- a/act/container/docker_images.go +++ b/act/container/docker_images.go @@ -46,14 +46,13 @@ func RemoveImage(ctx context.Context, imageName string, force, pruneChildren boo } defer cli.Close() - inspectImage, err := cli.ImageInspect(ctx, imageName) - if cerrdefs.IsNotFound(err) { + if _, err := cli.ImageInspect(ctx, imageName); cerrdefs.IsNotFound(err) { return false, nil } else if err != nil { return false, err } - if _, err = cli.ImageRemove(ctx, inspectImage.ID, client.ImageRemoveOptions{ + if _, err = cli.ImageRemove(ctx, imageName, client.ImageRemoveOptions{ Force: force, PruneChildren: pruneChildren, }); err != nil { diff --git a/act/runner/action.go b/act/runner/action.go index 683dec7c..d99b0e2b 100644 --- a/act/runner/action.go +++ b/act/runner/action.go @@ -6,6 +6,7 @@ package runner import ( "context" + "crypto/rand" "crypto/sha256" "embed" "encoding/hex" @@ -287,6 +288,22 @@ func dockerActionImageTag(repository, actionName string, localAction bool) strin return strings.ToLower(image) } +// One image per step and job: its stages share a build, and concurrent jobs never run each other's. +func (rc *RunContext) dockerActionImage(ctx context.Context, step actionStep, actionName string, localAction bool) string { + job := rc.topLevelRunContext() + if job.dockerActionImages == nil { + job.dockerActionImages = map[*model.Step]string{} + } + image, ok := job.dockerActionImages[step.getStepModel()] + if !ok { + image = dockerActionImageTag(step.getGithubContext(ctx).Repository, actionName, localAction) + "-" + strings.ToLower(rand.Text()) + if !common.Dryrun(ctx) { // a dry run builds nothing to remove + job.dockerActionImages[step.getStepModel()] = image + } + } + return image +} + // TODO: break out parts of function to reduce complexicity func execAsDocker(ctx context.Context, step actionStep, actionName, actionDir, basedir string, localAction bool, stage stepStage) error { logger := common.Logger(ctx) @@ -304,7 +321,7 @@ func execAsDocker(ctx context.Context, step actionStep, actionName, actionDir, b // Apply forcePull only for prebuild docker images forcePull = rc.Config.ForcePull } else { - image = dockerActionImageTag(step.getGithubContext(ctx).Repository, actionName, localAction) + image = rc.dockerActionImage(ctx, step, actionName, localAction) contextDir, fileName := filepath.Split(filepath.Join(basedir, action.Runs.Image)) anyArchExists, err := ContainerImageExistsLocally(ctx, image, "any") @@ -445,6 +462,9 @@ func newStepContainer(ctx context.Context, step step, image string, cmd, entrypo logWriter := rc.commandLogWriter(ctx) envList := make([]string, 0) for k, v := range *step.getEnv() { + if rc.IsHostEnv() && strings.EqualFold(k, rc.JobContainer.GetPathVariableName()) && v == rc.JobContainer.DefaultPathVariable() { // the runner's own PATH stays out, as on GitHub + continue + } envList = append(envList, fmt.Sprintf("%s=%s", k, v)) } diff --git a/act/runner/action_composite.go b/act/runner/action_composite.go index 299dbf48..69ba5352 100644 --- a/act/runner/action_composite.go +++ b/act/runner/action_composite.go @@ -29,7 +29,7 @@ func evaluateCompositeInputAndEnv(ctx context.Context, parent *RunContext, step } } - ee := parent.NewActionInputsExpressionEvaluator(ctx, step) + ee := parent.NewStepExpressionEvaluator(ctx, step) for inputID, input := range step.getActionModel().Inputs { envKey := regexp.MustCompile("[^A-Z0-9-]").ReplaceAllString(strings.ToUpper(inputID), "_") diff --git a/act/runner/action_test.go b/act/runner/action_test.go index 33035682..96951fbd 100644 --- a/act/runner/action_test.go +++ b/act/runner/action_test.go @@ -513,6 +513,17 @@ func TestDockerActionImageTag(t *testing.T) { dockerActionImageTag("owner/repo", "./", true), dockerActionImageTag("owner/repo", "./sub", true), ) + + workflow := &model.Workflow{Jobs: map[string]*model.Job{"job1": {}}} + job := newTestRC(workflow, nil) + step := &stepActionRemote{Step: &model.Step{}, RunContext: job} + image := job.dockerActionImage(t.Context(), step, "abc123", false) + assert.True(t, strings.HasPrefix(image, "act-abc123-dockeraction:latest-")) + assert.Equal(t, image, (&RunContext{Parent: job}).dockerActionImage(t.Context(), step, "abc123", false)) + assert.NotEqual(t, image, job.dockerActionImage(t.Context(), &stepActionRemote{Step: &model.Step{}, RunContext: job}, "abc123", false)) + assert.NotEqual(t, image, newTestRC(workflow, nil).dockerActionImage(t.Context(), step, "abc123", false)) + job.dockerActionImage(common.WithDryrun(t.Context(), true), &stepActionRemote{Step: &model.Step{}, RunContext: job}, "abc123", false) + assert.Len(t, job.dockerActionImages, 2) } func TestExecAsDockerStageEntrypoint(t *testing.T) { diff --git a/act/runner/expression.go b/act/runner/expression.go index 8b0adee6..2b6d0125 100644 --- a/act/runner/expression.go +++ b/act/runner/expression.go @@ -246,18 +246,19 @@ func inputsFromEnv(env map[string]string) map[string]any { } func getEvaluatorInputs(rc *RunContext, stepInputs map[string]any, ghc *model.GithubContext) map[string]any { + if stepInputs != nil { // an action's own inputs, which workflow inputs must not shadow + return maps.Clone(stepInputs) + } inputs := map[string]any{} // only a called workflow's job itself takes the inputs Gitea resolved, // composite actions copy its Config but have their own `inputs` if rc.Config.WorkflowCallInputs != nil && rc.Parent == nil { maps.Copy(inputs, rc.Config.WorkflowCallInputs) - maps.Copy(inputs, stepInputs) return inputs } maps.Copy(inputs, rc.workflowCallInputs) - maps.Copy(inputs, stepInputs) switch ghc.EventName { case "workflow_dispatch": diff --git a/act/runner/expression_test.go b/act/runner/expression_test.go index 81c5615b..82b50695 100644 --- a/act/runner/expression_test.go +++ b/act/runner/expression_test.go @@ -414,6 +414,31 @@ on: assert.Equal(t, map[string]any{"arg": "x"}, getEvaluatorInputs(composite, map[string]any{"arg": "x"}, ghc)) } +func TestActionInputsShadowDispatchInputs(t *testing.T) { + wf, err := model.ReadWorkflow(strings.NewReader(` +on: + workflow_dispatch: + inputs: + message: {type: string} +jobs: + job1: {} +`)) + require.NoError(t, err) + rc := newTestRC(wf, nil) + rc.Config.EventName = "workflow_dispatch" + rc.EventJSON = `{"inputs":{"message":"workflow"}}` + step := &stepActionRemote{RunContext: rc, env: map[string]string{"INPUT_MESSAGE": "step"}} + assert.Equal(t, "step", rc.NewActionInputsExpressionEvaluator(t.Context(), step).InterpolateName(t.Context(), "${{ inputs.message }}")) + assert.Equal(t, "workflow", rc.NewStepExpressionEvaluator(t.Context(), step).InterpolateName(t.Context(), "${{ inputs.message }}")) + enabled, err := isStepEnabled(t.Context(), "inputs.message == 'workflow'", step, stepStageMain) + require.NoError(t, err) + assert.True(t, enabled) + step.Step, step.action = &model.Step{}, &model.Action{Inputs: map[string]model.Input{"fallback": {Default: "${{ inputs.message }}"}}} + env, err := evaluateCompositeInputAndEnv(t.Context(), rc, step) + require.NoError(t, err) + assert.Equal(t, "workflow", env["INPUT_FALLBACK"]) +} + func TestResolveWorkflowCallToleratesMismatchesAndPassesInheritedSecretsVerbatim(t *testing.T) { callerWorkflow, err := model.ReadWorkflow(strings.NewReader(` jobs: diff --git a/act/runner/helpers_test.go b/act/runner/helpers_test.go index 40434dfa..b1b2dd16 100644 --- a/act/runner/helpers_test.go +++ b/act/runner/helpers_test.go @@ -5,6 +5,7 @@ package runner import ( "context" + "os" "os/exec" "runtime" "testing" @@ -40,6 +41,16 @@ func requireDocker(t *testing.T) { } } +func requireDockerSharingFiles(t *testing.T) { + t.Helper() + requireDocker(t) + for _, marker := range []string{"/.dockerenv", "/run/.containerenv"} { + if _, err := os.Stat(marker); err == nil { + t.Skip("skipping: inside a container, the docker daemon cannot bind-mount this process's files") + } + } +} + // requireHostTools skips the test unless every named executable is on PATH. Used by the // self-hosted (host environment) suite, which runs steps directly on the host. func requireHostTools(t *testing.T, tools ...string) { diff --git a/act/runner/job_executor.go b/act/runner/job_executor.go index 57b80b4c..9cba245b 100644 --- a/act/runner/job_executor.go +++ b/act/runner/job_executor.go @@ -13,6 +13,7 @@ import ( "errors" "fmt" "io" + "maps" "net/http" "path" "slices" @@ -258,6 +259,7 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo logger.Errorf("##[error]%s", EscapeCommandData("Error while stop job container: "+err.Error())) } setJobResult(ctx, info, rc, jobError == nil) + rc.removeDockerActionImages(ctx) return err }) @@ -410,26 +412,38 @@ func setJobResult(ctx context.Context, info jobInfo, rc *RunContext, success boo logger.WithField("jobResult", jobResult).Infof("Job %s", jobResultMessage) } -// evaluateJobEnvAndDefaults resolves the job's env, defaults.run and container env once, as GitHub does at job setup. -func evaluateJobEnvAndDefaults(ctx context.Context, rc *RunContext) error { - rc.ExprEval = rc.NewExpressionEvaluator(ctx) +// evaluateJobEnv returns the workflow and job env evaluated, as far as it got on error, leaving rc.Env raw. +func evaluateJobEnv(ctx context.Context, rc *RunContext) (map[string]string, error) { env := rc.GetEnv() + evaluated := maps.Clone(env) + evaluator := rc.NewExpressionEvaluatorWithEnv(ctx, evaluated) var workflowEnv map[string]string if rc.Run.Workflow.Env == nil { - if err := model.DecodeEvaluated("workflow env", rc.Run.Workflow.RawEnv, rc.ExprEval.shared(ctx).EvaluateYamlNode, &workflowEnv); err != nil { - return err + if err := model.DecodeEvaluated("workflow env", rc.Run.Workflow.RawEnv, evaluator.shared(ctx).EvaluateYamlNode, &workflowEnv); err != nil { + return evaluated, err } } if workflowEnv != nil { - rc.Env = mergeMaps(workflowEnv, env) - rc.ExprEval = rc.NewExpressionEvaluator(ctx) + evaluated = mergeMaps(workflowEnv, env) + evaluator = rc.NewExpressionEvaluatorWithEnv(ctx, evaluated) } var err error for k, v := range env { - if rc.Env[k], err = rc.ExprEval.Interpolate(ctx, v); err != nil { - return fmt.Errorf("unable to interpolate env %s: %w", k, err) + if evaluated[k], err = evaluator.Interpolate(ctx, v); err != nil { + return evaluated, fmt.Errorf("unable to interpolate env %s: %w", k, err) } } + return evaluated, nil +} + +// evaluateJobEnvAndDefaults resolves the job's env, defaults.run and container env once, as GitHub does at job setup. +func evaluateJobEnvAndDefaults(ctx context.Context, rc *RunContext) error { + env, err := evaluateJobEnv(ctx, rc) + rc.Env = env + rc.ExprEval = rc.NewExpressionEvaluator(ctx) + if err != nil { + return err + } var defaults model.Defaults if err := model.DecodeEvaluated("defaults", rc.Run.Job().RawDefaults, rc.ExprEval.shared(ctx).EvaluateYamlNode, &defaults); err != nil { return err diff --git a/act/runner/run_context.go b/act/runner/run_context.go index 9f2c1797..5baeedef 100644 --- a/act/runner/run_context.go +++ b/act/runner/run_context.go @@ -82,6 +82,10 @@ type RunContext struct { jobRunDefaults model.RunDefaults // defaults.run, resolved once at job setup as GitHub does platformImage string // container.image or the runs-on pick, resolved once by isEnabled jobIndex, jobTotal int // strategy.job-index and job-total, total 0 when unknown + + jobEnv map[string]string // workflow and job env as evaluated for the container and service specs + dockerActionImages map[*model.Step]string // Dockerfile action images built for this job, removed when it ends + // summaryFileInitialized tracks which per-step summary files (workflow/step-summary-N.md) // have already been created on the JobContainer. The runner sets up file-command files // via JobContainer.Copy at the start of every phase, which truncates them — fine for @@ -256,6 +260,9 @@ const sharedToolCacheVolume = "act-toolcache" // mounted only when the tool cach func (rc *RunContext) validVolumes() []string { name := rc.jobContainerName() volumes := slices.Clone(rc.Config.ValidVolumes) + if hostEnv, ok := rc.JobContainer.(*container.HostEnvironment); ok { + volumes = append(volumes, hostEnv.Path, hostEnv.GetActPath()) + } if rc.Config.SharedToolCache { volumes = append(volumes, sharedToolCacheVolume) } @@ -354,6 +361,9 @@ func (rc *RunContext) GetBindsAndMounts(ctx context.Context) ([]string, map[stri mounts[sharedToolCacheVolume] = toolCache } } + if hostEnv, ok := rc.JobContainer.(*container.HostEnvironment); ok { + binds = append(binds, hostEnv.Path+":"+hostEnv.Path, hostEnv.GetActPath()+":"+hostEnv.GetActPath()) + } mounts[name+"-env"] = ext.GetActPath() // runner-internal, never overridable if workdir := ext.ToContainerPath(rc.Config.Workdir); !claimed[workdir] { @@ -535,6 +545,7 @@ func (rc *RunContext) startJobContainer() common.Executor { // For gitea, to support --volumes-from in options. // We need to set the container name to the environment variable. rc.Env["JOB_CONTAINER_NAME"] = name + delete(rc.jobEnv, "JOB_CONTAINER_NAME") // the runtime name wins over a workflow env of the same name envList := make([]string, 0) @@ -618,7 +629,7 @@ func (rc *RunContext) startJobContainer() common.Executor { func (rc *RunContext) jobServices(ctx context.Context, networkName string) (map[string]*container.NewContainerInput, error) { var services map[string]*model.ContainerSpec - if err := model.DecodeEvaluated("job services", rc.Run.Job().RawServices, rc.ExprEval.shared(ctx).EvaluateYamlNode, &services); err != nil { + if err := model.DecodeEvaluated("job services", rc.Run.Job().RawServices, rc.NewExpressionEvaluatorWithEnv(ctx, mergeMaps(rc.GetEnv(), rc.jobEnv)).shared(ctx).EvaluateYamlNode, &services); err != nil { return nil, err } inputs := map[string]*container.NewContainerInput{} @@ -836,6 +847,14 @@ func (rc *RunContext) stopJobContainer() common.Executor { } } +func (rc *RunContext) removeDockerActionImages(ctx context.Context) { + for _, image := range rc.dockerActionImages { + if _, err := container.RemoveImage(ctx, image, true, false); err != nil { + common.Logger(ctx).Warnf("failed to remove action image %s: %v", image, err) + } + } +} + func (rc *RunContext) pullServicesImages(forcePull bool) common.Executor { return func(ctx context.Context) error { execs := []common.Executor{} @@ -1255,9 +1274,10 @@ func (rc *RunContext) runsOnPlatformNames(ctx context.Context) []string { // resolvePlatformImage evaluates the job's container once for every consumer, skipping one whose image is empty as GitHub does. func (rc *RunContext) resolvePlatformImage(ctx context.Context) error { + rc.jobEnv, _ = evaluateJobEnv(ctx, rc) // a broken env fails the job at setup instead withoutEnv, env := splitContainerEnv(rc.Run.Job().RawContainer) var spec model.ContainerSpec - if err := model.DecodeEvaluated("container", withoutEnv, rc.ExprEval.shared(ctx).EvaluateYamlNode, &spec); err != nil { + if err := model.DecodeEvaluated("container", withoutEnv, rc.NewExpressionEvaluatorWithEnv(ctx, rc.jobEnv).shared(ctx).EvaluateYamlNode, &spec); err != nil { return err } spec.Image = strings.TrimPrefix(spec.Image, "docker://") diff --git a/act/runner/run_context_test.go b/act/runner/run_context_test.go index 12182170..bf10cf3c 100644 --- a/act/runner/run_context_test.go +++ b/act/runner/run_context_test.go @@ -251,7 +251,6 @@ func startJobContainerInputs(t *testing.T, workflowYAML string, cfg *Config) []* rc := &RunContext{ Name: "test", Config: cfg, - Env: map[string]string{}, Run: &model.Run{ JobID: "job", Workflow: workflow, @@ -362,6 +361,29 @@ jobs: require.Equal(t, "sleep", job.Entrypoint[0]) require.Equal(t, "--label ${{ github.job }}", job.WorkflowOptions) require.Equal(t, "${{ vars.TITLE }}", job.Password) + + inputs = startJobContainerInputs(t, ` +env: + REG_USER: ${{ vars.REG_USER }} + TEXT: ${{ vars.TEXT }} + JOB_CONTAINER_NAME: from-workflow +jobs: + job: + container: + image: node:20 + credentials: {username: "${{ env.REG_USER }}", password: pass} + services: + redis: + image: redis:latest + credentials: {username: "${{ env.REG_USER }}", password: pass} + env: {TEXT: "${{ env.TEXT }}"} + options: --volumes-from ${{ env.JOB_CONTAINER_NAME }} +`, &Config{Vars: map[string]string{"REG_USER": "user", "TEXT": "${{ 'literal' }}"}}) + redis, job = inputs[0], inputs[1] + require.Equal(t, "user", job.Username) + require.Equal(t, "user", redis.Username) + require.Equal(t, []string{"TEXT=${{ 'literal' }}"}, redis.Env) + require.Equal(t, "--volumes-from "+job.Name, redis.WorkflowOptions) } // Only the workflow's options may be stripped later, so the two sources have to reach the @@ -662,6 +684,15 @@ func TestRunContextValidVolumes(t *testing.T) { assert.Contains(t, rc.validVolumes(), rc.Config.Workdir) rc.Config.PresetGitHubContext = &model.GithubContext{} assert.Contains(t, rc.validVolumes(), filepath.FromSlash("/workspace/1")) + + hostEnv := &container.HostEnvironment{Path: t.TempDir(), ActPath: t.TempDir()} + rc.JobContainer = hostEnv + rc.Config.BindWorkdir = false + rc.Config.SharedToolCache = true + binds, mounts := rc.GetBindsAndMounts(t.Context()) + assert.Subset(t, binds, []string{hostEnv.Path + ":" + hostEnv.Path, hostEnv.GetActPath() + ":" + hostEnv.GetActPath()}) + assert.Contains(t, mounts, sharedToolCacheVolume) + assert.Equal(t, binds, container.SanitizeBinds(t.Context(), rc.validVolumes(), binds)) } func TestCleanupJobResourcesCleansServicesWithoutJobContainer(t *testing.T) { @@ -730,6 +761,20 @@ func TestCleanupJobVolumesReapsAbandonedDeferredCleanup(t *testing.T) { assert.ElementsMatch(t, []string{"became-active", "remove-failed", "foreign", "fresh", "unknown-age"}, slices.Collect(maps.Keys(volumes))) } +func TestRemoveDockerActionImagesByTag(t *testing.T) { + var removed []string + fakeDockerDaemon(t, func(writer http.ResponseWriter, request *http.Request) { + if request.Method == http.MethodDelete { + removed = append(removed, strings.TrimPrefix(request.URL.Path, "/v1.47/images/")) + _, _ = io.WriteString(writer, "[]") + return + } + _, _ = io.WriteString(writer, `{"Id":"sha256:shared"}`) + }) + (&RunContext{dockerActionImages: map[*model.Step]string{{}: "act-action-dockeraction:latest-job"}}).removeDockerActionImages(t.Context()) + assert.Equal(t, []string{"act-action-dockeraction:latest-job"}, removed) +} + func TestCleanupJobResourcesContinuesAfterFailure(t *testing.T) { t.Setenv("TMPDIR", "/tmp") proxyDir := t.TempDir() @@ -960,7 +1005,6 @@ func TestGetGithubContextRef(t *testing.T) { func createIfTestRunContext(jobs map[string]*model.Job) *RunContext { rc := &RunContext{ Config: &Config{Workdir: ".", PlatformPicker: func([]string) string { return "ubuntu-latest" }}, - Env: map[string]string{}, Run: &model.Run{ JobID: "job1", Workflow: &model.Workflow{ @@ -1037,9 +1081,12 @@ func TestRunContextIsEnabled(t *testing.T) { // success() rc := createIfTestRunContext(map[string]*model.Job{ "job1": createJob(t, `runs-on: ubuntu-latest +env: + BROKEN: ${{ fromJSON('invalid') }} if: success()`, ""), }) assertObject.True(rc.isEnabled(context.Background())) + require.Error(t, evaluateJobEnvAndDefaults(context.Background(), rc)) rc = createIfTestRunContext(map[string]*model.Job{ "job1": createJob(t, `runs-on: ubuntu-latest`, "failure"), diff --git a/act/runner/runner_test.go b/act/runner/runner_test.go index e1790ced..c44a88af 100644 --- a/act/runner/runner_test.go +++ b/act/runner/runner_test.go @@ -221,7 +221,6 @@ func (j *TestJobFileInfo) runTest(ctx context.Context, t *testing.T, cfg *Config ContainerNamePrefix: strings.ReplaceAll(t.Name(), "/", "-") + "-" + testRunID, // 0 would run jobs runtime.NumCPU()-wide, making the network peak machine-dependent MaxParallel: 2, - ForceRebuild: true, Env: cfg.Env, Secrets: cfg.Secrets, GitHubInstance: "github.com", @@ -434,6 +433,8 @@ func TestRunEventHostEnvironment(t *testing.T) { tables = append(tables, []TestJobFileInfo{ {workdir, "nix-prepend-path", "push", "", platforms, secrets}, + {workdir, "uses-docker-url", "push", "", platforms, secrets}, + {workdir, "local-action-dockerfile", "push", "", platforms, secrets}, {workdir, "builtin-checkout", "push", "", platforms, secrets}, {workdir, "inputs-via-env-context", "push", "", platforms, secrets}, {workdir, "do-not-leak-step-env-in-composite", "push", "", platforms, secrets}, @@ -451,6 +452,8 @@ func TestRunEventHostEnvironment(t *testing.T) { requireHostTools(t, "pwsh") case "nix-prepend-path": requireHostTools(t, "nix") + case "uses-docker-url", "local-action-dockerfile": + requireDockerSharingFiles(t) } t.Parallel() hostPlanSlots <- struct{}{} diff --git a/act/runner/step_docker_test.go b/act/runner/step_docker_test.go index b3133f00..7d9b7892 100644 --- a/act/runner/step_docker_test.go +++ b/act/runner/step_docker_test.go @@ -8,6 +8,7 @@ import ( "bytes" "context" "io" + "slices" "strings" "testing" @@ -151,22 +152,35 @@ func TestStepDockerNewStepContainerAllocatePTY(t *testing.T) { } } -func TestStepDockerNewStepContainerNetworkMode(t *testing.T) { +func TestStepDockerNewStepContainerNetworkModeAndPath(t *testing.T) { + inheritedPath := (&container.LinuxContainerEnvironmentExtensions{}).DefaultPathVariable() cases := []struct { name string platform string + path string expectDefault bool + keepsPath bool }{ { name: "docker mode attaches to job container network", platform: "node:14", + path: inheritedPath, expectDefault: false, + keepsPath: true, }, { - name: "host mode uses default network", + name: "host mode uses default network and the image's PATH", platform: "-self-hosted", + path: inheritedPath, expectDefault: true, }, + { + name: "host mode keeps a PATH that is not the runner's", + platform: "-self-hosted", + path: "/custom/bin", + expectDefault: true, + keepsPath: true, + }, } for _, tc := range cases { @@ -208,6 +222,7 @@ func TestStepDockerNewStepContainerNetworkMode(t *testing.T) { ID: "1", Uses: "docker://alpine:3.20", }, + env: map[string]string{"PATH": tc.path}, } sd.RunContext.ExprEval = sd.RunContext.NewExpressionEvaluator(ctx) @@ -215,6 +230,7 @@ func TestStepDockerNewStepContainerNetworkMode(t *testing.T) { assert.Equal(t, tc.expectDefault, sd.RunContext.IsHostEnv(), "IsHostEnv mismatch for platform %q", tc.platform) newStepContainer(ctx, sd, "alpine:3.20", []string{"echo", "hello"}, nil, "") + assert.Equal(t, tc.keepsPath, slices.Contains(captured.Env, "PATH="+tc.path)) if tc.expectDefault { assert.Equal(t, "default", captured.NetworkMode, diff --git a/act/runner/testdata/uses-docker-url/push.yml b/act/runner/testdata/uses-docker-url/push.yml index 660f107d..6d846958 100644 --- a/act/runner/testdata/uses-docker-url/push.yml +++ b/act/runner/testdata/uses-docker-url/push.yml @@ -5,6 +5,7 @@ jobs: test: runs-on: ubuntu-latest steps: + - run: printf 'workspace=from-host-step\n' > workspace-marker - uses: docker://node:24-bookworm-slim with: somekey: somevalue @@ -16,8 +17,10 @@ jobs: with: entrypoint: /bin/sh args: -c -- uname -a - - uses: docker://node:24-bookworm-slim + - id: workspace + uses: docker://node:24-bookworm-slim with: entrypoint: /bin/sh - args: -c 'uname -a' + args: -c 'uname -a && cat "$GITHUB_WORKSPACE/workspace-marker" >> "$GITHUB_OUTPUT"' + - run: test '${{ steps.workspace.outputs.workspace }}' = 'from-host-step'