Files
act_runner/act/runner/run_context.go
T
silverwindandbircni dafe8d8d0c fix: keep dry runs off the docker daemon and speed up the tests (#1349)
Without a docker daemon six `act/runner` tests failed instead of passing. A dry run still created and removed job networks, the job teardown dropped the dry-run flag along with every other context value, and the container mocks asked the daemon for the runner arch.

This also speeds up the tests and CI without dropping assertions. Most of the time went to git and docker round trips, which are now pinned, batched or coalesced, and the dind and e2e steps cache or overlap more of their setup. On CI, `act/runner` went from 77s to 55s and `test-dind` from 108s to 55s. Locally, `act/runner` without docker went from 53s to 7s.

Split out of https://gitea.com/gitea/runner/pulls/1348, which adds Windows CI on top. The go cache changes moved to https://gitea.com/gitea/runner/pulls/1350.

Written by Claude Code.

---------

Co-authored-by: bircni <bircni@icloud.com>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1349
Reviewed-by: bircni <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-10-26 17:25:17 +00:00

1734 lines
60 KiB
Go

// Copyright 2022 The Gitea Authors. All rights reserved.
// Copyright 2020 The nektos/act Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package runner
import (
"archive/tar"
"bufio"
"cmp"
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json/v2"
"errors"
"fmt"
"io"
maps0 "maps"
"net"
"os"
"path/filepath"
"regexp"
"runtime"
"runtime/debug"
"slices"
"strconv"
"strings"
"sync"
"time"
"gitea.com/gitea/runner/act/common"
"gitea.com/gitea/runner/act/container"
"gitea.com/gitea/runner/act/ghcontext"
"gitea.com/gitea/runner/internal/pkg/lock"
"gitea.dev/actionslib/pkg/exprparser"
"gitea.dev/actionslib/pkg/model"
"github.com/docker/go-connections/nat"
"github.com/kballard/go-shellquote"
"github.com/moby/moby/api/types/mount"
"go.yaml.in/yaml/v4"
"golang.org/x/sync/errgroup"
"golang.org/x/text/encoding/unicode"
"golang.org/x/text/transform"
)
// RunContext contains info about current job
type RunContext struct {
Name string
Config *Config
Matrix map[string]any
Run *model.Run
EventJSON string
Env map[string]string
GlobalEnv map[string]string // to pass env changes of GITHUB_ENV and set-env correctly, due to dirty Env field
ExtraPath []string
CurrentStep string
// CurrentStepIndex is the index of the top-level job step currently executing
// (model.Step.Number). Composite sub-steps inherit the outer step's index by
// walking the Parent chain; see topLevelRunContext.
CurrentStepIndex int
StepResults map[string]*model.StepResult
IntraActionState map[string]map[string]string
ExprEval *expressionEvaluator
JobContainer container.ExecutionsEnvironment
serviceContainers []*serviceContainer
kubernetes bool
containerSpec model.ContainerSpec // container:, resolved once with platformImage
containerEnv yaml.Node // container.env of the kept containerSpec, decoded once the job env resolves
JobName string
ActionPath string
Parent *RunContext
actionInputs map[string]any // inputs of the composite action this runs, nil for a job
Masks []string
cleanUpJobContainer common.Executor
deferVolumeCleanup func(common.Executor)
caller *caller // job calling this RunContext (reusable workflows)
workflowCallInputs map[string]any // the caller's with:, resolved once by resolveWorkflowCall
workflowCallSecrets map[string]string // the caller's secrets:, resolved once by resolveWorkflowCall
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
actionDownloads map[string]*actionDownload // remote action repositories by clone URL and ref, fetched once per job
// 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
// GITHUB_ENV/OUTPUT/STATE/PATH (consumed per phase) but wrong for GITHUB_STEP_SUMMARY,
// which has accumulating semantics. We initialize each step's summary file exactly once
// so writes from later phases and from composite sub-steps append to the same file.
// Only populated on the top-level RunContext; child RCs walk Parent via topLevelRunContext.
summaryFileInitialized map[int]bool
// jobCancelled records that this job's run was cancelled (context.Canceled). It makes
// getJobContext report the "cancelled" status so cancelled()/always() evaluate the way
// GitHub Actions does, letting cleanup and always() steps run while normal steps skip.
jobCancelled bool
// jobFailed records failures outside normal main-step results, such as action pre-step
// failures. Those failures must still make success() false and failure() true for later
// main-step if evaluation.
jobFailed bool
// empty for a host-mode job, which starts no container
jobContainerID string
hasBash *bool // memoized implicit-shell probe, only set on the top-level RunContext
jobNetworkName string
dockerProxy *container.DockerProxy
hadDockerProxy bool
// stepEnv is a copy of the running step's environment, so that workflow commands parsed out
// of the container's output can be judged against it. Written by runStepExecutor and read on
// the log-writer goroutine, hence unsecureCommandMu, which also guards unsecureCommandErr.
stepEnv map[string]string
unsecureCommandErr error // refused ::set-env::/::add-path::, turned into a step failure
unsecureCommandMu sync.Mutex
checkoutPins sync.Map // git lookups of contexts no run has pinned
}
// serviceContainer pairs a service container with the workflow id that keys job.services.
type serviceContainer struct {
name string
image string
container container.ExecutionsEnvironment
created bool
logsDumped bool
info *container.Info // last poll, the source of the `job.services` entry
}
// setCurrentStepEnv records the environment of the step about to run.
func (rc *RunContext) setCurrentStepEnv(env map[string]string) {
rc.unsecureCommandMu.Lock()
defer rc.unsecureCommandMu.Unlock()
rc.stepEnv = env
}
func (rc *RunContext) currentStepEnv() map[string]string {
rc.unsecureCommandMu.Lock()
defer rc.unsecureCommandMu.Unlock()
return rc.stepEnv
}
// markCancelled flags the job as cancelled so subsequent step `if` evaluations and the
// job status context observe the "cancelled" state.
func (rc *RunContext) markCancelled() {
rc.jobCancelled = true
}
// markFailed flags the job as failed so subsequent step `if` evaluations observe
// failure even when the error happened outside a main step result.
func (rc *RunContext) markFailed() {
rc.jobFailed = true
}
// markInterrupted records the job's interruption status from a context error so later step `if` evaluations and the job result observe it,
// keeping the timeout path symmetric with the cancel path:
// - context.Canceled (server cancel) marks the job cancelled, matching GitHub's "only always()/cancelled() run on cancel".
// - context.DeadlineExceeded (job timeout-minutes) marks the job failed, matching the "Timeout -> FAILURE" reporting semantics.
func (rc *RunContext) markInterrupted(err error) {
switch {
case errors.Is(err, context.Canceled):
rc.markCancelled()
case errors.Is(err, context.DeadlineExceeded):
rc.markFailed()
}
}
// AddMask also hands the mask to the job logger's extra handler, which receives records unmasked.
func (rc *RunContext) AddMask(ctx context.Context, mask string) {
rc.Masks = append(rc.Masks, mask)
if masker, ok := common.LoggerHandler(ctx).(interface{ AddMask(string) }); ok {
masker.AddMask(mask)
}
}
func (rc *RunContext) String() string {
name := fmt.Sprintf("%s/%s", rc.Run.Workflow.Name, rc.Name)
if rc.caller != nil {
// prefix the reusable workflow with the caller job
// this is required to create unique container names
name = fmt.Sprintf("%s/%s", rc.caller.runContext.Name, name)
}
return name
}
// GetEnv returns the env for the context
func (rc *RunContext) GetEnv() map[string]string {
if rc.Env == nil {
rc.Env = map[string]string{}
if rc.Run != nil && rc.Run.Workflow != nil && rc.Config != nil {
job := rc.Run.Job()
if job != nil {
rc.Env = mergeMaps(rc.Run.Workflow.Env, job.Environment(), rc.Config.Env)
}
}
}
if !rc.Config.DisableActEnv {
rc.Env["ACT"] = "true"
}
if !rc.Config.NoSkipCheckout {
rc.Env["ACT_SKIP_CHECKOUT"] = "true"
}
return rc.Env
}
// setActionEnv sets a composite action's env, keeping the `inputs` context it derives from
// in sync. Remote actions re-evaluate it per stage, so inputs may change between them.
func (rc *RunContext) setActionEnv(env map[string]string) {
rc.Env = env
rc.actionInputs = inputsFromEnv(env)
}
func (rc *RunContext) jobContainerName() string {
// The job id, never evaluated, keeps two jobs apart when masking collapses their names.
nameParts := []string{rc.Config.ContainerNamePrefix, "WORKFLOW-" + rc.Run.Workflow.Name, "JOB-" + rc.Run.JobID, rc.Name}
if rc.caller != nil {
nameParts = append(nameParts, "CALLED-BY-"+rc.caller.runContext.JobName)
}
return createContainerName(nameParts...) // For Gitea
}
// networkNameForGitea return the name of the network
func (rc *RunContext) networkNameForGitea() (string, bool) {
if rc.Config.ContainerNetworkMode != "" {
return string(rc.Config.ContainerNetworkMode), false
}
return fmt.Sprintf("%s-%s-network", rc.jobContainerName(), rc.Run.JobID), true
}
func getDockerDaemonSocketMountPath(daemonPath string) string {
if scheme, after, ok := strings.Cut(daemonPath, "://"); ok {
switch {
case strings.EqualFold(scheme, "npipe"):
// linux container mount on windows, use the default socket path of the VM / wsl2
return "/var/run/docker.sock"
case strings.EqualFold(scheme, "unix"):
return after
case strings.IndexFunc(scheme, func(r rune) bool {
return (r < 'a' || r > 'z') && (r < 'A' || r > 'Z')
}) == -1:
// unknown protocol use default
return "/var/run/docker.sock"
}
}
return daemonPath
}
// containerDaemonSocket returns the configured Docker daemon socket, applying the default
// without mutating the shared Config. Parallel jobs in a plan share one *Config, so a job
// must never write to it.
func (rc *RunContext) containerDaemonSocket() string {
return cmp.Or(rc.Config.ContainerDaemonSocket, "/var/run/docker.sock")
}
const sharedToolCacheVolume = "act-toolcache" // mounted only when the tool cache is shared
// validVolumes returns what the job and action containers may mount, the configured base plus
// the runner's own volumes. Fresh slice per call, the shared Config is never mutated.
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)
}
if rc.Config.BindWorkdir {
volumes = append(volumes, rc.Config.Workdir, rc.workdirMountRoot())
}
if rc.dockerProxy != nil {
volumes = append(volumes, rc.dockerProxy.Socket)
}
// TODO: add a new configuration to control whether the docker daemon can be mounted
return append(volumes, name, name+"-env",
getDockerDaemonSocketMountPath(rc.containerDaemonSocket()))
}
func (rc *RunContext) workdirMountRoot() string {
if rc.Config.PresetGitHubContext != nil {
return filepath.Dir(filepath.Dir(rc.Config.Workdir)) // only the daemon presets, its workdir is <parent>/<owner>/<repo>
}
return rc.Config.Workdir
}
func (rc *RunContext) jobDockerSocket() string {
if rc.dockerProxy != nil {
return rc.dockerProxy.Socket
}
return getDockerDaemonSocketMountPath(rc.containerDaemonSocket())
}
func (rc *RunContext) startDockerProxy(ctx context.Context) {
if !filepath.IsAbs(strings.TrimPrefix(rc.containerDaemonSocket(), "unix://")) || common.Dryrun(ctx) {
return
}
rc.dockerProxy = container.NewDockerProxy(ctx, rc.jobContainerName())
}
// toolCache returns the tool cache path the job sees, relocatable through RUNNER_TOOL_CACHE.
func (rc *RunContext) toolCache(fallback string) string {
return cmp.Or(rc.GetEnv()["RUNNER_TOOL_CACHE"], fallback)
}
// runnerEnv returns a container's RUNNER_* variables, derived from the values runner.tool_cache
// and friends report so the two cannot drift apart.
func (rc *RunContext) runnerEnv(ctx context.Context) []string {
ext := container.LinuxContainerEnvironmentExtensions{}
runnerContext := ext.GetRunnerContext(ctx)
runnerContext["tool_cache"] = rc.toolCache(container.DefaultToolCache)
env := make([]string, 0, len(runnerContext))
for key, value := range runnerContext {
env = append(env, fmt.Sprintf("RUNNER_%s=%s", strings.ToUpper(key), value))
}
slices.Sort(env)
return env
}
// splitVolumes routes volume specs into binds and a source:target mount map, and returns the
// container paths they mount onto. Only a plain source:target volume fits the map, everything
// else (anonymous volumes, host binds, mount options) stays a bind.
func splitVolumes(specs []string) ([]string, map[string]string, map[string]bool) {
binds := []string{}
mounts := map[string]string{}
targets := map[string]bool{}
for _, spec := range specs {
parsed, err := container.ParseVolume(spec)
if err != nil {
binds = append(binds, spec) // unclassifiable, sanitizeConfig warns and drops it
continue
}
targets[parsed.Target] = true
switch {
case parsed.Type != mount.TypeVolume || parsed.Source == "" || parsed.ReadOnly:
binds = append(binds, spec)
case mounts[parsed.Source] == "":
mounts[parsed.Source] = parsed.Target
case mounts[parsed.Source] != parsed.Target: // a repeat of the same mount is dropped
binds = append(binds, spec)
}
}
return binds, mounts, targets
}
// Returns the binds and mounts for the container, resolving paths as appopriate
func (rc *RunContext) GetBindsAndMounts(ctx context.Context) ([]string, map[string]string) {
name := rc.jobContainerName()
ext := container.LinuxContainerEnvironmentExtensions{}
// the runner's own mounts below yield to the targets the job claims
binds, mounts, claimed := splitVolumes(container.SanitizeBinds(ctx, rc.validVolumes(), rc.containerSpec.Volumes))
if rc.containerDaemonSocket() != "-" && !claimed["/var/run/docker.sock"] {
binds = append(binds, rc.jobDockerSocket()+":/var/run/docker.sock")
}
if rc.Config.SharedToolCache {
if toolCache := rc.toolCache(container.DefaultToolCache); !claimed[toolCache] {
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] {
source := rc.workdirMountRoot()
target := ext.ToContainerPath(source)
if claimed[target] {
source, target = rc.Config.Workdir, workdir
}
if rc.Config.BindWorkdir {
bindModifiers := ""
if runtime.GOOS == "darwin" {
bindModifiers = ":delegated"
}
if selinuxEnabled() {
bindModifiers = ":z"
}
binds = append(binds, fmt.Sprintf("%s:%s%s", source, target, bindModifiers))
} else {
mounts[name] = target
}
}
return binds, mounts
}
func (rc *RunContext) startHostEnvironment() common.Executor {
return func(ctx context.Context) error {
logWriter := rc.commandLogWriter(ctx)
cacheDir := rc.ActionCacheDir()
randBytes := make([]byte, 8)
_, _ = rand.Read(randBytes)
miscpath := filepath.Join(cacheDir, hex.EncodeToString(randBytes))
rc.cleanUpJobContainer = func(_ctx context.Context) error { return os.RemoveAll(miscpath) }
actPath := filepath.Join(miscpath, "act")
if err := os.MkdirAll(actPath, 0o777); err != nil {
return err
}
path := filepath.Join(miscpath, "hostexecutor")
if err := os.MkdirAll(path, 0o777); err != nil {
return err
}
runnerTmp := filepath.Join(miscpath, "tmp")
if err := os.MkdirAll(runnerTmp, 0o777); err != nil {
return err
}
toolCacheParent := miscpath // per job, so cleanup removes it with the job
if rc.Config.SharedToolCache {
toolCacheParent = cacheDir
}
toolCache := rc.toolCache(filepath.Join(toolCacheParent, "tool_cache"))
if err := os.MkdirAll(toolCache, 0o777); err != nil {
return err
}
rc.JobContainer = &container.HostEnvironment{
Path: path,
TmpDir: runnerTmp,
ToolCache: toolCache,
Workdir: rc.Config.Workdir,
CleanWorkdir: rc.Config.CleanWorkdir,
ActPath: actPath,
CleanUp: func() {
os.RemoveAll(miscpath)
},
StdOut: logWriter,
AllocatePTY: rc.Config.AllocatePTY,
}
rc.cleanUpJobContainer = rc.JobContainer.Remove()
rc.setRunnerContextEnv(ctx)
for _, env := range os.Environ() {
if k, v, ok := strings.Cut(env, "="); ok {
// don't override
if _, ok := rc.Env[k]; !ok {
rc.Env[k] = v
}
}
}
return rc.copyWorkflowFiles()(ctx)
}
}
func (rc *RunContext) setRunnerContextEnv(ctx context.Context) {
for k, v := range rc.getRunnerContext(ctx) {
if v, ok := v.(string); ok {
rc.Env["RUNNER_"+strings.ToUpper(k)] = v
}
}
}
var errKubernetesDocker = errors.New("docker actions and docker:// steps are not supported on Kubernetes")
func (rc *RunContext) startKubernetesPod() common.Executor {
return func(ctx context.Context) error {
if rc.containerSpec.Options != "" || len(rc.containerSpec.Volumes) > 0 || rc.containerSpec.Credentials != nil {
return errors.New("container options, volumes and credentials are not supported on Kubernetes")
}
var options container.KubernetesOptions
if rc.Config.KubernetesPicker != nil {
options = rc.Config.KubernetesPicker(rc.runsOnPlatformNames(ctx))
}
options.MaxLifetime, options.ForcePull = rc.Config.ContainerMaxLifetime, rc.Config.ForcePull
pod, err := container.NewKubernetesPod(options)
if err != nil {
return err
}
services, err := rc.jobServices(ctx, "")
if err != nil {
return err
}
for _, serviceID := range slices.Sorted(maps0.Keys(services)) {
input := services[serviceID]
if input.Username != "" || len(input.Binds) > 0 || len(input.Mounts) > 0 {
return fmt.Errorf("service %s: volumes and credentials are not supported on Kubernetes", serviceID)
}
rc.serviceContainers = append(rc.serviceContainers, &serviceContainer{name: serviceID, image: input.Image, container: pod.ServiceContainer(serviceID, input)})
}
logWriter := rc.commandLogWriter(ctx)
rc.JobContainer = pod.JobContainer(&container.NewContainerInput{
Entrypoint: []string{"tail", "-f", "/dev/null"},
WorkingDir: (&container.LinuxContainerEnvironmentExtensions{}).ToContainerPath(rc.Config.Workdir),
Image: rc.platformImage,
Name: rc.jobContainerName(),
Env: []string{"LANG=C.UTF-8"},
Stdout: logWriter,
Stderr: logWriter,
})
rc.cleanUpJobContainer = func(ctx context.Context) error {
rc.printServiceLogs(ctx)
return rc.JobContainer.Remove()(ctx)
}
defer printStartJobContainerGroup(ctx, rc.platformImage, rc.jobContainerName(), "")()
return common.NewPipelineExecutor(
rc.JobContainer.Create(nil, nil),
rc.startServiceContainers(),
rc.JobContainer.Start(false),
func(ctx context.Context) error { rc.setRunnerContextEnv(ctx); return nil },
rc.captureJobContainerInfo(),
rc.copyWorkflowFiles(),
rc.reportUnstartedServices(),
rc.waitForServiceContainers(),
)(ctx)
}
}
// printStartJobContainerGroup mirrors actions/runner's "Starting job container"
// section: emit the group header and summary, return a closer for ::endgroup::.
func printStartJobContainerGroup(ctx context.Context, image, name, network string) func() {
rawLogger := common.Logger(ctx).With(rawOutputField, true)
rawLogger.Infof("::group::Starting job container")
rawLogger.Infof("image: %s", image)
rawLogger.Infof("name: %s", name)
rawLogger.Infof("network: %s", network)
return func() {
rawLogger.Infof("::endgroup::")
}
}
// newContainer is a variable so tests can substitute a container that needs no Docker daemon.
var newContainer = container.NewContainer
type jobVolumeCleanupKey struct{}
func WithJobVolumeCleanup(ctx context.Context, deferCleanup func(common.Executor)) context.Context {
return context.WithValue(ctx, jobVolumeCleanupKey{}, deferCleanup)
}
func (rc *RunContext) startJobContainer() common.Executor {
return func(ctx context.Context) error {
rc.deferVolumeCleanup, _ = ctx.Value(jobVolumeCleanupKey{}).(func(common.Executor))
image := rc.platformImage
logWriter := rc.commandLogWriter(ctx)
username, password, err := registryCredentials(rc.containerSpec.Credentials, "container.")
if err != nil {
return fmt.Errorf("failed to handle credentials: %s", err)
}
name := rc.jobContainerName()
// 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 := append(rc.runnerEnv(ctx), "LANG=C.UTF-8") // Use same locale as GitHub Actions
// specify the network to which the container will connect when `docker create` stage. (like execute command line: docker create --network <networkName> <image>)
// if using service containers, will create a new network for the containers.
// and it will be removed after at last.
networkName, createAndDeleteNetwork := rc.networkNameForGitea()
rc.cleanUpJobContainer = rc.cleanupJobResources(networkName, createAndDeleteNetwork, false)
services, err := rc.jobServices(ctx, networkName)
if err != nil {
return err
}
for _, serviceID := range slices.Sorted(maps0.Keys(services)) {
input := services[serviceID]
rc.serviceContainers = append(rc.serviceContainers, &serviceContainer{name: serviceID, image: input.Image, container: newContainer(input)})
}
ext := container.LinuxContainerEnvironmentExtensions{}
containerInput := &container.NewContainerInput{
Entrypoint: []string{"sleep", strconv.FormatInt(int64(rc.Config.ContainerMaxLifetime.Round(time.Second).Seconds()), 10)},
WorkingDir: ext.ToContainerPath(rc.Config.Workdir),
Image: image,
Username: username,
Password: password,
Name: name,
Env: envList,
NetworkMode: networkName,
NetworkAliases: []string{rc.Name},
Stdout: logWriter,
Stderr: logWriter,
Privileged: rc.Config.Privileged,
UsernsMode: rc.Config.UsernsMode,
Platform: rc.Config.ContainerArchitecture,
RunnerOptions: rc.Config.ContainerOptions,
WorkflowOptions: rc.containerSpec.Options,
AutoRemove: true,
AllocatePTY: rc.Config.AllocatePTY,
}
rc.JobContainer = newContainer(containerInput)
if rc.JobContainer == nil {
return errors.New("failed to create job container")
}
defer printStartJobContainerGroup(ctx, image, name, networkName)()
if err := common.NewPipelineExecutor(
rc.cleanupJobResources(networkName, createAndDeleteNetwork, true),
rc.pullServicesImages(rc.Config.ForcePull),
rc.JobContainer.Pull(rc.Config.ForcePull),
)(ctx); err != nil {
return err
}
rc.startDockerProxy(ctx)
containerInput.Binds, containerInput.Mounts = rc.GetBindsAndMounts(ctx)
containerInput.ValidVolumes = rc.validVolumes()
rc.jobNetworkName = networkName
return common.NewPipelineExecutor(
container.NewDockerNetworkCreateExecutor(networkName, rc.Config.ContainerNetworkCreateOptions).
IfBool(createAndDeleteNetwork),
container.NewDockerNetworkConnectExecutor(networkName, rc.Config.CacheContainer).
IfBool(rc.Config.CacheContainer != ""),
rc.startServiceContainers(),
rc.reportUnstartedServices(),
func(ctx context.Context) error { return rc.createJobVolumes(ctx, containerInput.Mounts) },
rc.JobContainer.Create(rc.Config.ContainerCapAdd, rc.Config.ContainerCapDrop),
rc.JobContainer.Start(false),
rc.captureJobContainerInfo(),
rc.copyWorkflowFiles(),
rc.waitForServiceContainers(),
)(ctx)
}
}
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.NewExpressionEvaluatorWithEnv(ctx, mergeMaps(rc.GetEnv(), rc.jobEnv)).shared(ctx).EvaluateYamlNode, &services); err != nil {
return nil, err
}
inputs := map[string]*container.NewContainerInput{}
for _, serviceID := range slices.Sorted(maps0.Keys(services)) {
spec := services[serviceID]
if spec == nil {
return nil, fmt.Errorf("service %s has no container definition", serviceID)
}
spec.Image = strings.TrimPrefix(spec.Image, "docker://")
// GitHub compatibility: skip services whose image evaluates to an
// empty string, enabling conditional services via expressions
if spec.Image == "" {
common.Logger(ctx).Infof("The service '%s' will not be started because the container definition has an empty image.", serviceID)
continue
}
envs := make([]string, 0, len(spec.Env)+len(rc.Config.ProxyEnv))
// a service reaches the internet the way the job does; its own env still wins
for k, v := range mergeMaps(rc.Config.ProxyEnv, spec.Env) {
envs = append(envs, fmt.Sprintf("%s=%s", k, v))
}
serviceUsername, servicePassword, err := registryCredentials(spec.Credentials, "")
if err != nil {
return nil, fmt.Errorf("failed to handle service %s credentials: %w", serviceID, err)
}
cmd := spec.Cmd
if len(cmd) == 0 && spec.Command != "" {
if cmd, err = shellquote.Split(spec.Command); err != nil {
return nil, fmt.Errorf("failed to parse service %s command: %w", serviceID, err)
}
}
var entrypoint []string
if spec.Entrypoint != "" {
entrypoint = []string{spec.Entrypoint}
}
serviceBinds, serviceMounts, _ := splitVolumes(spec.Volumes)
exposedPorts, portBindings, err := nat.ParsePortSpecs(spec.Ports)
if err != nil {
return nil, fmt.Errorf("failed to parse service %s ports: %w", serviceID, err)
}
serviceLog := common.NewLineWriter(rc.serviceCommandHandler(ctx), rawLogLine(ctx))
inputs[serviceID] = &container.NewContainerInput{
Name: createContainerName(rc.jobContainerName(), serviceID),
Image: spec.Image,
Username: serviceUsername,
Password: servicePassword,
Entrypoint: entrypoint,
Cmd: cmd,
Env: envs,
Mounts: serviceMounts,
Binds: serviceBinds,
Stdout: serviceLog,
Stderr: serviceLog,
Privileged: rc.Config.Privileged,
UsernsMode: rc.Config.UsernsMode,
Platform: rc.Config.ContainerArchitecture,
AutoRemove: false, // so a dead service's log survives, cleanupJobResources removes it
WorkflowOptions: spec.Options,
NetworkMode: networkName,
NetworkAliases: []string{serviceID},
ExposedPorts: exposedPorts,
PortBindings: portBindings,
ValidVolumes: rc.Config.ValidVolumes, // not validVolumes(), a service gets no docker socket
AllocatePTY: rc.Config.AllocatePTY,
}
}
return inputs, nil
}
func (rc *RunContext) commandLogWriter(ctx context.Context) io.Writer {
return common.NewLineWriter(rc.commandHandler(ctx), rawLogLine(ctx))
}
// withCommandLogWriter runs executor with a command handler of its own on the job environment's output.
func (rc *RunContext) withCommandLogWriter(ctx context.Context, executor common.Executor) error {
logWriter := rc.commandLogWriter(ctx)
oldout, olderr := rc.JobContainer.ReplaceLogWriter(logWriter, logWriter)
defer rc.JobContainer.ReplaceLogWriter(oldout, olderr)
defer common.FlushWriter(logWriter) // the last line of output may lack its newline
return executor(ctx)
}
func rawLogLine(ctx context.Context) common.LineHandler {
rawLogger := common.Logger(ctx).With(rawOutputField, true)
return func(line string) bool {
rawLogger.Infof("%s", line)
return true
}
}
func (rc *RunContext) cleanupJobResources(networkName string, createAndDeleteNetwork, preclean bool) common.Executor {
return func(ctx context.Context) error {
logger := common.Logger(ctx)
rc.printServiceLogs(ctx)
errs := []error{rc.closeDockerProxy(ctx)}
var containers []common.Executor
if rc.JobContainer != nil {
containers = append(containers, rc.JobContainer.Remove())
}
if len(rc.serviceContainers) > 0 {
logger.Infof("Cleaning up services for job %s", rc.JobName)
containers = append(containers, rc.stopServiceContainers())
}
errs = append(errs, runAll(ctx, containers))
ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), jobCleanupTimeout)
defer cancel()
if !common.Dryrun(ctx) && !rc.Config.NoSweep && (preclean || rc.hadDockerProxy) {
errs = append(errs, container.RemoveDockerJobResources(ctx, rc.jobContainerName())) // after the containers, whose endpoints would block removing the job networks
}
var rest []common.Executor
if preclean || rc.deferVolumeCleanup == nil {
rest = append(rest, rc.cleanupJobVolumes)
} else {
rc.deferVolumeCleanup(rc.cleanupJobVolumes)
}
if createAndDeleteNetwork {
logger.Infof("Cleaning up network for job %s, and network name is: %s", rc.JobName, networkName)
rest = append(rest, func(ctx context.Context) error {
return errors.Join(
container.NewDockerNetworkDisconnectExecutor(networkName, rc.Config.CacheContainer).IfBool(rc.Config.CacheContainer != "")(ctx),
container.NewDockerNetworkRemoveExecutor(networkName)(ctx))
})
}
return errors.Join(append(errs, runAll(ctx, rest))...)
}
}
func runAll(ctx context.Context, executors []common.Executor) error {
errs := make([]error, len(executors))
var wg sync.WaitGroup
for i, executor := range executors {
wg.Go(func() {
defer func() {
if recovered := recover(); recovered != nil {
errs[i] = fmt.Errorf("panic: %v\n%s", recovered, debug.Stack())
}
}()
errs[i] = executor(ctx)
})
}
wg.Wait()
return errors.Join(errs...)
}
func (rc *RunContext) createJobVolumes(ctx context.Context, mounts map[string]string) error {
runnerUUID := rc.Config.ContainerNetworkCreateOptions.RunnerUUID
if runnerUUID == "" || common.Dryrun(ctx) {
return nil
}
name := rc.jobContainerName()
volumeNames := []string{name, name + "-env"}
if _, ok := mounts[name]; !ok { // the workspace is a bind mount, the env volume is always ours
volumeNames = volumeNames[1:]
}
return container.CreateJobVolumes(ctx, runnerUUID, volumeNames)
}
func (rc *RunContext) cleanupJobVolumes(ctx context.Context) error {
if rc.JobContainer == nil {
return nil
}
name := rc.jobContainerName()
return errors.Join(
container.NewDockerVolumeRemoveExecutor(name, false)(ctx),
container.NewDockerVolumeRemoveExecutor(name+"-env", false)(ctx))
}
func (rc *RunContext) closeDockerProxy(ctx context.Context) error {
if rc.dockerProxy == nil {
return nil
}
rc.hadDockerProxy = true
err := rc.dockerProxy.Close(ctx)
rc.dockerProxy = nil
if err != nil {
return fmt.Errorf("close docker proxy: %w", err)
}
return nil
}
func (rc *RunContext) ApplyExtraPath(ctx context.Context, env *map[string]string) {
if len(rc.ExtraPath) > 0 {
path := rc.JobContainer.GetPathVariableName()
if rc.JobContainer.IsEnvironmentCaseInsensitive() {
// On windows system Path and PATH could also be in the map
for k := range *env {
if strings.EqualFold(path, k) {
path = k
break
}
}
}
if (*env)[path] == "" {
cenv := map[string]string{}
var cpath string
if err := rc.JobContainer.UpdateFromImageEnv(&cenv)(ctx); err == nil {
cpath = cenv[path]
}
if len(cpath) == 0 {
cpath = rc.JobContainer.DefaultPathVariable()
}
(*env)[path] = cpath
}
(*env)[path] = rc.JobContainer.JoinPathVariable(append(rc.ExtraPath, (*env)[path])...)
}
}
func (rc *RunContext) UpdateExtraPath(ctx context.Context, githubEnvPath string) error {
if common.Dryrun(ctx) {
return nil
}
pathTar, err := rc.JobContainer.GetContainerArchive(ctx, githubEnvPath)
if err != nil {
return err
}
defer pathTar.Close()
reader := tar.NewReader(pathTar)
_, err = reader.Next()
if err != nil && err != io.EOF {
return err
}
decoded := transform.NewReader(reader, unicode.BOMOverride(unicode.UTF8.NewDecoder()))
s := bufio.NewScanner(decoded)
s.Buffer(make([]byte, 0, 64*1024), 16*1024*1024)
for s.Scan() {
line := s.Text()
if len(line) > 0 {
rc.addPath(line)
}
}
if err := s.Err(); err != nil {
return fmt.Errorf("reading path file: %w", err)
}
return nil
}
func (rc *RunContext) stopContainer() common.Executor {
return func(ctx context.Context) error {
if rc.cleanUpJobContainer != nil {
return rc.cleanUpJobContainer(ctx)
}
return nil
}
}
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{}
for _, svc := range rc.serviceContainers {
execs = append(execs, svc.container.Pull(forcePull))
}
return common.NewParallelExecutor(len(execs), execs...)(ctx)
}
}
func (rc *RunContext) startServiceContainers() common.Executor {
return func(ctx context.Context) error {
execs := []common.Executor{}
for _, svc := range rc.serviceContainers {
execs = append(execs, common.NewPipelineExecutor(
svc.container.Create(rc.Config.ContainerCapAdd, rc.Config.ContainerCapDrop),
func(context.Context) error { svc.created = true; return nil },
svc.container.Start(false),
))
}
return common.NewParallelExecutor(len(execs), execs...)(ctx)
}
}
func (rc *RunContext) stopServiceContainers() common.Executor {
return func(ctx context.Context) error {
execs := []common.Executor{}
errs := make([]error, len(rc.serviceContainers))
for index, svc := range rc.serviceContainers {
execs = append(execs, func(ctx context.Context) error {
if err := errors.Join(svc.container.Remove()(ctx), svc.container.Close()(ctx)); err != nil {
errs[index] = fmt.Errorf("clean service %s: %w", svc.name, err)
}
return nil
})
}
errs = append(errs, common.NewParallelExecutor(len(execs), execs...)(ctx))
return errors.Join(errs...)
}
}
const defaultServiceReadyTimeout = 5 * time.Minute
const serviceReadyPollInterval = 250 * time.Millisecond
// reportUnstartedServices logs a service that did not start. The steps that need it
// report it better than the runner can, so the job carries on.
func (rc *RunContext) reportUnstartedServices() common.Executor {
return func(ctx context.Context) error {
logger := common.Logger(ctx)
for _, svc := range rc.serviceContainers {
info, err := svc.inspect(ctx)
if err != nil {
logger.Debugf("unable to inspect service '%s': %v", svc.name, err)
continue
}
if info.State == container.StateRunning {
continue
}
svc.dumpLogs(ctx)
logger.Warnf("Docker container %s is not in running state: %s (%d)", info.ID, info.State, info.ExitCode)
}
return nil
}
}
// waitForServiceContainers blocks until every service that declares a healthcheck reports
// healthy, as GitHub does, so a first step cannot connect before the service listens.
func (rc *RunContext) waitForServiceContainers() common.Executor {
return func(ctx context.Context) error {
if len(rc.serviceContainers) == 0 {
return nil
}
timeout := rc.Config.ServiceReadyTimeout
switch {
case timeout < 0:
// disabled, but still describe the containers for `job.services`
for _, svc := range rc.serviceContainers {
if _, err := svc.inspect(ctx); err != nil && !errors.Is(err, container.ErrContainerNotFound) {
return err
}
}
return nil
case timeout == 0:
timeout = defaultServiceReadyTimeout
}
// the first error cancels the rest, so a failure does not wait out a sibling's timeout
group, groupCtx := errgroup.WithContext(ctx)
for _, svc := range rc.serviceContainers {
group.Go(func() error {
return svc.waitUntilHealthy(groupCtx, timeout)
})
}
return group.Wait()
}
}
// waitUntilHealthy waits on the healthcheck alone, so a container that declares none is
// ready at once and one that exited is left to the steps that need it.
func (svc *serviceContainer) waitUntilHealthy(ctx context.Context, timeout time.Duration) error {
rawLogger := common.Logger(ctx).With(rawOutputField, true)
loggedStarting := false
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
for {
info, err := svc.inspect(ctx)
if ctxErr := ctx.Err(); ctxErr != nil { // the wait ended, an inspect error only noticed it
if errors.Is(ctxErr, context.DeadlineExceeded) {
return fmt.Errorf("the service '%s' did not become healthy within %s%s", svc.name, timeout, svc.healthOutputSuffix())
}
return ctxErr
}
switch {
case errors.Is(err, container.ErrContainerNotFound):
return nil // gone, so there is no health left to wait on
case err != nil:
return err
}
switch {
case info.Health == container.HealthUnhealthy:
svc.dumpLogs(ctx)
common.Logger(ctx).Errorf("Failed to initialize container %s", svc.image)
return fmt.Errorf("the service '%s' is unhealthy%s", svc.name, svc.healthOutputSuffix())
case info.Health != container.HealthStarting:
rawLogger.Infof("%s service is healthy.", svc.name)
return nil
}
if !loggedStarting {
rawLogger.Infof("%s service is starting.", svc.name)
loggedStarting = true
}
select {
case <-ctx.Done(): // reported at the top of the loop
case <-time.After(serviceReadyPollInterval):
}
}
}
// dumpLogs writes the container's log to the job log once, however often it is reported.
func (svc *serviceContainer) dumpLogs(ctx context.Context) {
if svc.logsDumped {
return
}
svc.logsDumped = true
if err := svc.container.DumpLogs(ctx); err != nil {
common.Logger(ctx).Debugf("unable to read the log of service '%s': %v", svc.name, err)
}
}
const serviceLogsTimeout = 15 * time.Second // cleanup gets one minute in total
func (rc *RunContext) printServiceLogs(ctx context.Context) {
ctx, cancel := context.WithTimeout(ctx, serviceLogsTimeout)
defer cancel()
rawLogger := common.Logger(ctx).With(rawOutputField, true)
for _, svc := range rc.serviceContainers {
if !svc.created || svc.logsDumped {
continue
}
rawLogger.Infof("::group::Print service container logs: %s", EscapeCommandData(svc.name))
svc.dumpLogs(ctx)
rawLogger.Infof("::endgroup::")
if ctx.Err() != nil {
common.Logger(ctx).Warnf("Timed out printing the service logs after %s", serviceLogsTimeout)
return
}
}
}
// inspect also records the state for the `job.services` context.
func (svc *serviceContainer) inspect(ctx context.Context) (*container.Info, error) {
info, err := svc.container.Inspect(ctx)
if err != nil {
return nil, fmt.Errorf("failed to inspect service '%s': %w", svc.name, err)
}
svc.info = info
return info, nil
}
func (svc *serviceContainer) healthOutputSuffix() string {
if svc.info == nil || svc.info.HealthOutput == "" {
return ""
}
return ": " + svc.info.HealthOutput
}
func (rc *RunContext) copyWorkflowFiles() common.Executor {
return rc.JobContainer.Copy(rc.JobContainer.GetActPath()+"/",
&container.FileEntry{Name: "workflow/event.json", Mode: 0o644, Body: rc.EventJSON},
&container.FileEntry{Name: "workflow/envs.txt", Mode: 0o666},
)
}
// captureJobContainerInfo is a convenience: failing to describe the container must not
// fail the job.
func (rc *RunContext) captureJobContainerInfo() common.Executor {
return func(ctx context.Context) error {
info, err := rc.JobContainer.Inspect(ctx)
if err != nil {
common.Logger(ctx).Debugf("unable to inspect the job container: %v", err)
return nil
}
rc.jobContainerID = info.ID
if rc.dockerProxy != nil {
rc.dockerProxy.SetMounts(info.Mounts)
}
if source := info.DaemonPath(rc.githubWorkspace()); source != "" {
rc.Env["GITEA_DOCKER_WORKSPACE"] = source
}
return nil
}
}
// Prepare the mounts and binds for the worker
// ActionCacheDir is for rc
func (rc *RunContext) ActionCacheDir() string {
if rc.Config.ActionCacheDir != "" {
return rc.Config.ActionCacheDir
}
xdgCache := os.Getenv("XDG_CACHE_HOME")
if xdgCache == "" {
if home, err := os.UserHomeDir(); err == nil {
xdgCache = filepath.Join(home, ".cache")
} else if xdgCache, err = filepath.Abs("."); err != nil {
// It's almost impossible to get here, so the temp dir is a good fallback
xdgCache = os.TempDir()
}
}
return filepath.Join(xdgCache, "act")
}
// Interpolate outputs after a job is done
// jobMutexes serializes per-job result/output aggregation across the matrix combinations that
// share one *model.Job and run in parallel. Keyed by the shared *model.Job (mirrors the
// per-directory AcquireCloneLock pattern).
var jobMutexes lock.Keyed[*model.Job]
func lockJob(job *model.Job) func() {
return jobMutexes.Lock(job)
}
func (rc *RunContext) interpolateOutputs() common.Executor {
return func(ctx context.Context) error {
job := rc.Run.Job()
var outputs map[string]string
if err := model.DecodeEvaluated("job outputs", job.RawOutputs, rc.NewExpressionEvaluator(ctx).shared(ctx).EvaluateYamlNode, &outputs); err != nil {
return err
}
// Matrix combinations share the job, so as on GitHub a failure publishes nothing and the last non-empty value wins.
maps0.DeleteFunc(outputs, func(_, value string) bool { return value == "" })
defer lockJob(job)()
job.Outputs = mergeMaps(job.Outputs, outputs)
return nil
}
}
func (rc *RunContext) startContainer() common.Executor {
return func(ctx context.Context) error {
var err error
switch {
case rc.kubernetes:
err = rc.startKubernetesPod()(ctx)
case rc.IsHostEnv():
err = rc.startHostEnvironment()(ctx)
default:
err = rc.startJobContainer()(ctx)
}
if err != nil {
// The job executor's teardown only runs after a successful start, so a failed
// start would otherwise leak the per-job network and container.
rc.cleanupFailedStart(ctx)
}
return err
}
}
func (rc *RunContext) cleanupFailedStart(ctx context.Context) {
cleanCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), time.Minute)
defer cancel()
cleanup := rc.cleanUpJobContainer
if cleanup == nil {
cleanup = rc.closeDockerProxy
}
if err := cleanup(cleanCtx); err != nil {
common.Logger(ctx).Errorf("Error while cleaning up after failed container start for job %s: %v", rc.JobName, err)
}
}
func (rc *RunContext) IsHostEnv() bool {
return rc.containerSpec.Image == "" && strings.EqualFold(rc.platformImage, "-self-hosted")
}
func (rc *RunContext) closeContainer() common.Executor {
return func(ctx context.Context) error {
if rc.JobContainer != nil {
return rc.JobContainer.Close()(ctx)
}
return nil
}
}
func (rc *RunContext) matrix() map[string]any {
return rc.Matrix
}
func (rc *RunContext) result(result string) {
rc.Run.Job().Result = result
}
func (rc *RunContext) steps() []*model.Step {
// Return per-job copies of the steps. Matrix combinations run in parallel and share the
// workflow model, but step execution mutates per-job fields and evaluates the If/Env nodes
// in place, so the *model.Step instances must not be shared across jobs (see Step.Clone).
shared := rc.Run.Job().Steps
steps := make([]*model.Step, len(shared))
for i, step := range shared {
if step == nil {
continue
}
steps[i] = step.Clone()
}
return steps
}
// topLevelRunContext walks the Parent chain to the outermost RunContext. Composite
// actions create child RunContexts whose sub-steps need to share the outer job step's
// summary file path so that nested writes accumulate under the right step_index.
func (rc *RunContext) topLevelRunContext() *RunContext {
top := rc
for top.Parent != nil {
top = top.Parent
}
return top
}
// Executor returns a pipeline executor for all the steps in the job
func (rc *RunContext) Executor() (common.Executor, error) {
var executor common.Executor
jobType, err := rc.Run.Job().Type()
switch jobType {
case model.JobTypeDefault:
executor = newJobExecutor(rc, &stepFactoryImpl{}, rc)
case model.JobTypeReusableWorkflowLocal:
executor = newLocalReusableWorkflowExecutor(rc)
case model.JobTypeReusableWorkflowRemote:
executor = newRemoteReusableWorkflowExecutor(rc)
case model.JobTypeInvalid:
return nil, err
}
return func(ctx context.Context) error {
res, err := rc.isEnabled(ctx)
if err != nil {
// Record the failure so a job that cannot start gets a stop time. rc.caller is only set for reusable workflows.
rc.result("failure")
if rc.caller != nil { // For Gitea
rc.caller.setReusedWorkflowJobResult(rc.Run.JobID, "failure")
}
return err
}
if res {
return executor(ctx)
}
return nil
}, nil
}
func (rc *RunContext) runsOnImage(ctx context.Context) (string, error) {
runsOn := rc.Run.Job().RunsOn()
for i, v := range runsOn {
var err error
if runsOn[i], err = rc.ExprEval.Interpolate(ctx, v); err != nil {
return "", fmt.Errorf("unable to interpolate runs-on: %w", err)
}
}
if rc.Config.PlatformPicker != nil {
return rc.Config.PlatformPicker(runsOn), nil
}
return "", nil
}
func (rc *RunContext) runsOnPlatformNames(ctx context.Context) []string {
job := rc.Run.Job()
if job.RunsOn() == nil {
return []string{}
}
// Evaluate a copy: RawRunsOn is shared across parallel matrix jobs, so interpolating it in
// place would race and leak one matrix combination's runs-on into the others.
rawRunsOn := model.CloneYamlNode(job.RawRunsOn)
if err := rc.ExprEval.EvaluateYamlNode(ctx, &rawRunsOn); err != nil {
common.Logger(ctx).Errorf("Error while evaluating runs-on: %v", err)
return []string{}
}
return model.RunsOnFromNode(rawRunsOn)
}
// 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.NewExpressionEvaluatorWithEnv(ctx, rc.jobEnv).shared(ctx).EvaluateYamlNode, &spec); err != nil {
return err
}
spec.Image = strings.TrimPrefix(spec.Image, "docker://")
if spec.Image != "" || imagelessContainer(withoutEnv) {
rc.containerSpec, rc.containerEnv = spec, env
}
image, err := rc.runsOnImage(ctx)
if err != nil {
return err
}
image, rc.kubernetes = strings.CutPrefix(image, "kubernetes://")
rc.platformImage = cmp.Or(rc.containerSpec.Image, image)
return nil
}
// imagelessContainer reports a container: mapping without an image key, which configures the runs-on image.
func imagelessContainer(container yaml.Node) bool {
if container.Kind != yaml.MappingNode {
return false
}
for i := 0; i < len(container.Content); i += 2 {
if container.Content[i].Value == "image" {
return false
}
}
return true
}
// splitContainerEnv takes `env` out of a container mapping, since env reads the job env that resolves after the job environment starts.
func splitContainerEnv(container yaml.Node) (withoutEnv, env yaml.Node) {
if container.Kind != yaml.MappingNode {
return container, env
}
withoutEnv = container
withoutEnv.Content = nil
for i := 0; i+1 < len(container.Content); i += 2 {
if container.Content[i].Value == "env" {
env = *container.Content[i+1]
} else {
withoutEnv.Content = append(withoutEnv.Content, container.Content[i:i+2]...)
}
}
return withoutEnv, env
}
func (rc *RunContext) isEnabled(ctx context.Context) (bool, error) {
job := rc.Run.Job()
l := common.Logger(ctx)
runJob, runJobErr := EvalBool(ctx, rc.ExprEval, job.If.Value, exprparser.DefaultStatusCheckSuccess)
jobType, jobTypeErr := job.Type()
if runJobErr != nil {
l.Errorf("Error when evaluating 'if' for job '%s'. %v", rc.Run.JobID, runJobErr)
}
if jobType == model.JobTypeInvalid {
return false, jobTypeErr
}
if !runJob {
if rc.caller != nil { // For Gitea
rc.caller.setReusedWorkflowJobResult(rc.Run.JobID, "skipped")
return false, nil
}
l.With("jobResult", "skipped").Debugf("Skipping job '%s' due to '%s'", job.Name, job.If.Value)
return false, nil
}
if jobType != model.JobTypeDefault {
return true, nil
}
if err := rc.resolvePlatformImage(ctx); err != nil {
return false, err
}
if rc.platformImage == "" {
for _, platformName := range rc.runsOnPlatformNames(ctx) {
l.Infof("Skipping unsupported platform -- Try running with `-P %+v=...`", platformName)
}
return false, nil
}
return true, nil
}
// proxyBuildArgs returns the job's proxy variables as docker build args. The docker CLI
// pre-populates these from its own client configuration, but act builds through the API,
// so without them a Dockerfile action's RUN steps have no network behind a proxy.
func (rc *RunContext) proxyBuildArgs() map[string]*string {
if len(rc.Config.ProxyEnv) == 0 {
return nil
}
args := make(map[string]*string, len(rc.Config.ProxyEnv))
for name, value := range rc.Config.ProxyEnv {
args[name] = &value
}
return args
}
func mergeMaps(maps ...map[string]string) map[string]string {
rtnMap := make(map[string]string)
for _, m := range maps {
maps0.Copy(rtnMap, m)
}
return rtnMap
}
var containerNamePattern = regexp.MustCompile("[^a-zA-Z0-9]")
func createContainerName(parts ...string) string {
name := strings.Join(parts, "-")
name = containerNamePattern.ReplaceAllString(name, "-")
name = strings.ReplaceAll(name, "--", "-")
hash := sha256.Sum256([]byte(name))
// SHA256 is 64 hex characters. So trim name to 63 characters to make room for the hash and separator
trimmedName := strings.Trim(name[:min(len(name), 63)], "-")
return fmt.Sprintf("%s-%x", trimmedName, hash)
}
// ownStatus is the result of this context's own steps, which for a composite action is its action status
func (rc *RunContext) ownStatus() string {
if rc.jobFailed {
return "failure"
}
for _, stepStatus := range rc.StepResults {
if stepStatus.Conclusion == model.StepStatusFailure {
return "failure"
}
}
return "success"
}
func (rc *RunContext) getJobContext() *model.JobContext {
job := rc.topLevelRunContext()
jobStatus := job.ownStatus()
// A cancelled run takes precedence over success/failure so cancelled() is true and
// success()/failure() are false, matching GitHub Actions: on cancellation only
// always() and cancelled() steps run.
if job.jobCancelled {
jobStatus = "cancelled"
}
jobContext := &model.JobContext{
Status: jobStatus,
Services: map[string]model.JobService{}, // an empty map, never null
}
if job.jobContainerID != "" {
jobContext.Container.ID = job.jobContainerID
jobContext.Container.Network = job.jobNetworkName
}
for _, svc := range job.serviceContainers {
if svc.info == nil {
continue
}
jobContext.Services[svc.name] = model.JobService{
ID: svc.info.ID,
Network: job.jobNetworkName,
Ports: svc.info.Ports,
}
}
return jobContext
}
// getRunnerContext returns the `runner` context: what the execution environment knows
// (os, arch, temp, tool_cache) plus what only the runner process knows.
func (rc *RunContext) getRunnerContext(ctx context.Context) map[string]any {
runnerContext := map[string]any{}
if rc.JobContainer != nil {
maps0.Copy(runnerContext, rc.JobContainer.GetRunnerContext(ctx))
defaultToolCache, _ := runnerContext["tool_cache"].(string)
runnerContext["tool_cache"] = rc.toolCache(defaultToolCache)
}
runnerContext["name"] = rc.Config.RunnerName
runnerContext["environment"] = "self-hosted"
runnerContext["workspace"] = parentDir(rc.githubWorkspace())
if rc.Config.RunnerDebug() {
runnerContext["debug"] = "1"
}
return runnerContext
}
func (rc *RunContext) githubWorkspace() string {
if rc.JobContainer != nil {
return rc.JobContainer.ToContainerPath(rc.Config.Workdir)
}
if workspace := rc.Config.Env["GITHUB_WORKSPACE"]; workspace != "" {
return workspace
}
return rc.Config.Workdir
}
func (rc *RunContext) getGithubContext(ctx context.Context) *model.GithubContext {
logger := common.Logger(ctx)
ghc := &model.GithubContext{
Event: make(map[string]any),
Workflow: rc.Run.Workflow.Name,
RunID: cmp.Or(rc.Config.Env["GITHUB_RUN_ID"], "1"),
RunNumber: cmp.Or(rc.Config.Env["GITHUB_RUN_NUMBER"], "1"),
Actor: cmp.Or(rc.Config.Actor, "nektos/act"), // for configs that require a default rather than being run as a cmd
EventName: rc.Config.EventName,
Action: rc.CurrentStep,
Token: rc.Config.Token,
Job: rc.Run.JobID,
ActionPath: rc.ActionPath,
ActionRepository: rc.Env["GITHUB_ACTION_REPOSITORY"],
ActionRef: rc.Env["GITHUB_ACTION_REF"],
RepositoryOwner: rc.Config.Env["GITHUB_REPOSITORY_OWNER"],
RetentionDays: cmp.Or(rc.Config.Env["GITHUB_RETENTION_DAYS"], "0"),
RunnerPerflog: cmp.Or(rc.Config.Env["RUNNER_PERFLOG"], "/dev/null"),
RunnerTrackingID: rc.Config.Env["RUNNER_TRACKING_ID"],
Repository: rc.Config.Env["GITHUB_REPOSITORY"],
Ref: rc.Config.Env["GITHUB_REF"],
Sha: rc.Config.Env["SHA_REF"],
RefName: rc.Config.Env["GITHUB_REF_NAME"],
RefType: rc.Config.Env["GITHUB_REF_TYPE"],
BaseRef: rc.Config.Env["GITHUB_BASE_REF"],
HeadRef: rc.Config.Env["GITHUB_HEAD_REF"],
Workspace: rc.githubWorkspace(),
}
if rc.JobContainer != nil {
ghc.EventPath = rc.JobContainer.GetActPath() + "/workflow/event.json"
}
if preset := rc.Config.PresetGitHubContext; preset != nil {
merged := *preset
merged.Workflow, merged.Job, merged.Workspace, merged.EventPath = ghc.Workflow, ghc.Job, ghc.Workspace, ghc.EventPath
merged.Action, merged.ActionPath, merged.ActionRepository, merged.ActionRef = ghc.Action, ghc.ActionPath, ghc.ActionRepository, ghc.ActionRef
merged.RunnerPerflog, merged.RunnerTrackingID = ghc.RunnerPerflog, ghc.RunnerTrackingID
*ghc = merged
rc.setInstanceURLs(ghc)
ghc.GraphQLURL = "" // Gitea doesn't support graphql
return ghc
}
if rc.EventJSON != "" {
err := json.Unmarshal([]byte(rc.EventJSON), &ghc.Event)
if err != nil {
logger.Errorf("Unable to Unmarshal event '%s': %v", rc.EventJSON, err)
}
}
ghc.SetBaseAndHeadRef()
repoPath := rc.Config.Workdir
ctx = ghcontext.WithPinnedCheckout(ctx, &rc.checkoutPins)
ghcontext.SetRepositoryAndOwner(ctx, ghc, repoPath)
if ghc.Ref == "" {
ghcontext.SetRef(ctx, ghc, repoPath)
}
if ghc.Sha == "" {
ghcontext.SetSha(ctx, ghc, repoPath)
}
ghc.SetRefTypeAndName()
rc.setInstanceURLs(ghc)
// allow to be overridden by user
if rc.Config.Env["GITHUB_SERVER_URL"] != "" {
ghc.ServerURL = rc.Config.Env["GITHUB_SERVER_URL"]
}
if rc.Config.Env["GITHUB_API_URL"] != "" {
ghc.APIURL = rc.Config.Env["GITHUB_API_URL"]
}
if rc.Config.Env["GITHUB_GRAPHQL_URL"] != "" {
ghc.GraphQLURL = rc.Config.Env["GITHUB_GRAPHQL_URL"]
}
return ghc
}
func (rc *RunContext) setInstanceURLs(ghc *model.GithubContext) {
if instance := strings.TrimRight(rc.Config.GitHubInstance, "/"); instance != "" {
ghc.ServerURL = withHTTPS(instance)
ghc.APIURL = ghc.ServerURL + "/api/v1"
}
}
func withHTTPS(u string) string {
if strings.HasPrefix(u, "http://") || strings.HasPrefix(u, "https://") {
return u
}
return "https://" + u
}
func isLocalCheckout(ghc *model.GithubContext, step *model.Step) bool {
if step.Type() != model.StepTypeUsesActionRemote {
return false
}
if uses, err := model.ParseActionUses(step.Uses); err != nil || uses.Owner != "actions" || uses.Repo != "checkout" {
return false
}
if repository, ok := step.With["repository"]; ok && repository != ghc.Repository {
return false
}
if repository, ok := step.With["ref"]; ok && repository != ghc.Ref {
return false
}
return true
}
func (rc *RunContext) withGithubEnv(ctx context.Context, github *model.GithubContext, env map[string]string) {
if _, ok := env["CI"]; !ok {
env["CI"] = "true"
}
env["GITHUB_WORKFLOW"] = github.Workflow
env["GITHUB_RUN_ID"] = github.RunID
env["GITHUB_RUN_NUMBER"] = github.RunNumber
env["GITHUB_ACTION"] = github.Action
env["GITHUB_ACTION_PATH"] = github.ActionPath
env["GITHUB_ACTION_REPOSITORY"] = github.ActionRepository
env["GITHUB_ACTION_REF"] = github.ActionRef
env["GITHUB_ACTIONS"] = "true"
env["GITHUB_ACTOR"] = github.Actor
env["GITHUB_REPOSITORY"] = github.Repository
env["GITHUB_EVENT_NAME"] = github.EventName
env["GITHUB_EVENT_PATH"] = github.EventPath
env["GITHUB_WORKSPACE"] = github.Workspace
env["GITHUB_SHA"] = github.Sha
env["GITHUB_REF"] = github.Ref
env["GITHUB_REF_NAME"] = github.RefName
env["GITHUB_REF_TYPE"] = github.RefType
env["GITHUB_REF_PROTECTED"] = strconv.FormatBool(github.RefProtected)
env["GITHUB_JOB"] = github.Job
env["GITHUB_REPOSITORY_OWNER"] = github.RepositoryOwner
env["GITHUB_RETENTION_DAYS"] = github.RetentionDays
env["RUNNER_PERFLOG"] = github.RunnerPerflog
env["RUNNER_TRACKING_ID"] = github.RunnerTrackingID
env["GITHUB_BASE_REF"] = github.BaseRef
env["GITHUB_HEAD_REF"] = github.HeadRef
env["GITHUB_SERVER_URL"] = github.ServerURL
env["GITHUB_API_URL"] = github.APIURL
for name, value := range map[string]string{
"GITHUB_WORKFLOW_REF": github.WorkflowRef,
"GITHUB_WORKFLOW_SHA": github.WorkflowSha,
"GITHUB_ACTOR_ID": github.ActorID,
"GITHUB_TRIGGERING_ACTOR": github.TriggeringActor,
"GITHUB_REPOSITORY_ID": github.RepositoryID,
"GITHUB_REPOSITORY_OWNER_ID": github.RepositoryOwnerID,
} {
if value != "" { // a context without them keeps what the environment set
env[name] = value
}
}
for _, name := range []string{"GITHUB_SERVER_URL", "GITHUB_API_URL"} {
if env[name] == "" {
delete(env, name)
}
}
env["GITHUB_GRAPHQL_URL"] = ""
env["GITHUB_RUN_ATTEMPT"] = github.RunAttempt
env["RUNNER_NAME"] = rc.Config.RunnerName
env["RUNNER_ENVIRONMENT"] = "self-hosted"
if workspace := parentDir(github.Workspace); workspace != "" {
env["RUNNER_WORKSPACE"] = workspace
}
if rc.Config.RunnerDebug() {
env["RUNNER_DEBUG"] = "1"
}
if rc.Config.ArtifactServerPath != "" {
env["ACTIONS_RUNTIME_URL"] = cmp.Or(os.Getenv("ACTIONS_RUNTIME_URL"), "http://"+net.JoinHostPort(rc.Config.ArtifactServerAddr, rc.Config.ArtifactServerPort)+"/")
env["ACTIONS_RUNTIME_TOKEN"] = cmp.Or(os.Getenv("ACTIONS_RUNTIME_TOKEN"), "token")
}
if imageOS := rc.imageOS(ctx); imageOS != "" {
env["ImageOS"] = imageOS
}
}
// parentDir returns the directory containing p, or "" when p names no parent. Both
// separators are accepted rather than filepath's, as p may describe a container while
// the runner itself runs on Windows, or the other way round.
func parentDir(p string) string {
if slash := strings.LastIndexAny(p, `/\`); slash > 0 {
return p[:slash]
}
return ""
}
// imageOS returns ImageOS, which setup-* actions use to tell one runner image release
// from another. The resolved image tag is preferred over the runs-on label because it
// still names a release when the label is a rolling one such as ubuntu-latest.
func (rc *RunContext) imageOS(ctx context.Context) string {
if rc.Run.Job().RunsOn() == nil {
return "" // a composite action runs on a synthetic job without runs-on
}
if imageOS := imageOSFromImage(rc.platformImage); imageOS != "" {
return imageOS
}
for _, platformName := range slices.Backward(rc.runsOnPlatformNames(ctx)) {
if platformName == "ubuntu-latest" {
// Rolling label whose image names no release either, so keep the historical value.
return "ubuntu20"
} else if platformName != "" {
return strings.SplitN(strings.Replace(platformName, `-`, ``, 1), `.`, 2)[0]
}
}
return ""
}
// imageOSTag matches an image reference tagged with an OS family ImageOS can report plus
// its release, such as "docker.gitea.com/runner-images:ubuntu-24.04". Anything else
// ("ubuntu-latest", "app:22.04", "catthehacker/ubuntu:act-22.04", or a registry port) is
// left to the runs-on label rather than turned into a bogus OS.
var imageOSTag = regexp.MustCompile(`:(ubuntu|win|macos)-?([0-9]+)[^/]*$`)
// imageOSFromImage derives ImageOS from an image reference, e.g.
// "docker.gitea.com/runner-images:ubuntu-24.04" yields "ubuntu24".
func imageOSFromImage(image string) string {
if match := imageOSTag.FindStringSubmatch(image); match != nil {
return match[1] + match[2]
}
return ""
}
func registryCredentials(credentials map[string]string, prefix string) (string, string, error) {
if credentials == nil {
return "", "", nil
}
if len(credentials) != 2 {
return "", "", errors.New("invalid property count for key 'credentials:'")
}
for _, key := range []string{"username", "password"} {
if credentials[key] == "" {
return "", "", fmt.Errorf("failed to interpolate %scredentials.%s", prefix, key)
}
}
return credentials["username"], credentials["password"], nil
}