fix: align Docker actions, job env and host mode with GitHub (#1268)

- Docker actions evaluate `runs.args` and `runs.env` with their own `inputs`, so a same-named `workflow_dispatch` input no longer replaces the step's `with:` value
- Dockerfile action images are tagged per step and job and removed afterwards, so changed Dockerfiles rebuild and concurrent jobs never share an image
- Workflow and job `env` are evaluated before job and service container credentials and service `env` use them
- Docker actions in host jobs get the host workspace and file-command directory mounted, and no longer inherit the runner's own `PATH`
- Cache server address detection accepts a single interface address

Fixes: https://gitea.com/gitea/runner/issues/390
Fixes: https://gitea.com/gitea/runner/issues/406
Fixes: https://gitea.com/gitea/runner/issues/578
Related: https://gitea.com/gitea/runner/issues/64
Reviewed-on: https://gitea.com/gitea/runner/pulls/1268
Reviewed-by: bircni <[email protected]>
Co-authored-by: silverwind <[email protected]>
This commit is contained in:
silverwind
2026-10-01 19:45:13 +00:00
committed by bircni
parent 629e062f86
commit ddee2e054a
15 changed files with 255 additions and 59 deletions
+40 -34
View File
@@ -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
}
+20
View File
@@ -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"},
}))
}
+2 -3
View File
@@ -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 {
+21 -1
View File
@@ -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))
}
+1 -1
View File
@@ -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), "_")
+11
View File
@@ -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) {
+3 -2
View File
@@ -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":
+25
View File
@@ -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:
+11
View File
@@ -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) {
+23 -9
View File
@@ -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
+22 -2
View File
@@ -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 <container_name_or_id> 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://")
+49 -2
View File
@@ -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"),
+4 -1
View File
@@ -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{}{}
+18 -2
View File
@@ -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,
+5 -2
View File
@@ -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'