mirror of
https://gitea.com/gitea/act_runner
synced 2026-11-03 10:57:22 +01:00
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>
794 lines
26 KiB
Go
794 lines
26 KiB
Go
// Copyright 2022 The Gitea Authors. All rights reserved.
|
|
// Copyright 2022 The nektos/act Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package runner
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"maps"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"regexp"
|
|
"runtime"
|
|
"slices"
|
|
"strings"
|
|
|
|
"gitea.com/gitea/runner/act/common"
|
|
"gitea.com/gitea/runner/act/common/git"
|
|
"gitea.com/gitea/runner/act/container"
|
|
|
|
_ "embed"
|
|
|
|
"gitea.dev/actionslib/pkg/model"
|
|
"github.com/kballard/go-shellquote"
|
|
)
|
|
|
|
type actionStep interface {
|
|
step
|
|
|
|
getActionModel() *model.Action
|
|
getCompositeRunContext(context.Context) (*RunContext, error)
|
|
getCompositeSteps() *compositeSteps
|
|
}
|
|
|
|
type readAction func(ctx context.Context, step *model.Step, actionDir, actionPath string, readFile actionYamlReader, writeFile fileWriter) (*model.Action, error)
|
|
|
|
type actionYamlReader func(filename string) (io.Reader, io.Closer, error)
|
|
|
|
type fileWriter func(filename string, data []byte, perm fs.FileMode) error
|
|
|
|
type runAction func(step actionStep, actionDir string, remoteAction *remoteAction) common.Executor
|
|
|
|
//go:embed res/trampoline.js
|
|
var trampoline string
|
|
|
|
var (
|
|
ContainerImageExistsLocally = container.ImageExistsLocally
|
|
ContainerNewDockerBuildExecutor = container.NewDockerBuildExecutor
|
|
dockerActionNamePattern = regexp.MustCompile("[^a-zA-Z0-9]")
|
|
inputEnvPattern = regexp.MustCompile("[^A-Z0-9-]")
|
|
)
|
|
|
|
func readActionImpl(ctx context.Context, step *model.Step, actionDir, actionPath string, readFile actionYamlReader, writeFile fileWriter) (*model.Action, error) {
|
|
logger := common.Logger(ctx)
|
|
allErrors := []error{}
|
|
addError := func(fileName string, err error) {
|
|
if err != nil {
|
|
allErrors = append(allErrors, fmt.Errorf("failed to read '%s' from action '%s' with path '%s' of step %w", fileName, step.String(), actionPath, err))
|
|
} else {
|
|
// One successful read, clear error state
|
|
allErrors = nil
|
|
}
|
|
}
|
|
reader, closer, err := readFile("action.yml")
|
|
addError("action.yml", err)
|
|
if os.IsNotExist(err) {
|
|
reader, closer, err = readFile("action.yaml")
|
|
addError("action.yaml", err)
|
|
if os.IsNotExist(err) {
|
|
dockerfile := "Dockerfile"
|
|
_, closer, err := readFile(dockerfile)
|
|
addError(dockerfile, err)
|
|
if os.IsNotExist(err) {
|
|
dockerfile = "dockerfile"
|
|
_, closer, err = readFile(dockerfile)
|
|
addError(dockerfile, err)
|
|
}
|
|
if err == nil {
|
|
closer.Close()
|
|
action := &model.Action{
|
|
Name: "(Synthetic)",
|
|
Runs: model.ActionRuns{
|
|
Using: "docker",
|
|
Image: dockerfile,
|
|
},
|
|
}
|
|
logger.Debugf("Using synthetic action %v for %s", action, dockerfile)
|
|
return action, nil
|
|
}
|
|
if val, ok := step.With["args"]; ok {
|
|
if err := writeFile(filepath.Join(actionDir, actionPath, "trampoline.js"), []byte(trampoline), 0o644); err != nil {
|
|
return nil, err
|
|
}
|
|
action := &model.Action{
|
|
Name: "(Synthetic)",
|
|
Inputs: map[string]model.Input{
|
|
"args": {},
|
|
"cwd": {
|
|
Description: "(Actual working directory)",
|
|
Required: false,
|
|
Default: filepath.Join(actionDir, actionPath),
|
|
},
|
|
"command": {
|
|
Description: "(Actual program)",
|
|
Required: false,
|
|
Default: val,
|
|
},
|
|
},
|
|
Runs: model.ActionRuns{
|
|
Using: "node12",
|
|
Main: "trampoline.js",
|
|
},
|
|
}
|
|
logger.Debugf("Using synthetic action %v", action)
|
|
return action, nil
|
|
}
|
|
}
|
|
}
|
|
if allErrors != nil {
|
|
return nil, errors.Join(allErrors...)
|
|
}
|
|
defer closer.Close()
|
|
|
|
return model.ReadAction(reader)
|
|
}
|
|
|
|
func maybeCopyToActionDir(ctx context.Context, step actionStep, actionDir, actionPath, containerActionDir string) error {
|
|
logger := common.Logger(ctx)
|
|
rc := step.getRunContext()
|
|
stepModel := step.getStepModel()
|
|
|
|
if stepModel.Type() != model.StepTypeUsesActionRemote {
|
|
return nil
|
|
}
|
|
|
|
containerActionDirCopy := strings.TrimSuffix(containerActionDir, actionPath)
|
|
logger.Debug(containerActionDirCopy)
|
|
|
|
if !strings.HasSuffix(containerActionDirCopy, `/`) {
|
|
containerActionDirCopy += `/`
|
|
}
|
|
|
|
unlock, err := git.AcquireCloneLock(ctx, actionDir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer unlock()
|
|
|
|
if err := rc.JobContainer.CopyDir(containerActionDirCopy, actionDir+"/", false, true)(ctx); err != nil { // an exported commit has no .git to apply gitignore with
|
|
return err
|
|
}
|
|
if rc.Config.NoActionPatch {
|
|
return nil
|
|
}
|
|
patched := patchedScripts(ctx, actionDir, actionScriptPaths(actionDir, filepath.Join(actionDir, actionPath), step.getActionModel()))
|
|
if len(patched) == 0 {
|
|
return nil
|
|
}
|
|
return rc.JobContainer.Copy(containerActionDirCopy, patched...)(ctx)
|
|
}
|
|
|
|
func runActionImpl(step actionStep, actionDir string, remoteAction *remoteAction) common.Executor {
|
|
rc := step.getRunContext()
|
|
stepModel := step.getStepModel()
|
|
|
|
return func(ctx context.Context) error {
|
|
logger := common.Logger(ctx)
|
|
actionPath := ""
|
|
if remoteAction != nil {
|
|
actionPath = remoteAction.Path
|
|
}
|
|
|
|
action := step.getActionModel()
|
|
|
|
rc.withGithubEnv(ctx, step.getGithubContext(ctx), *step.getEnv())
|
|
populateEnvsFromSavedState(step.getEnv(), step, rc)
|
|
if err := populateEnvsFromInput(ctx, step.getEnv(), action, rc); err != nil {
|
|
return err
|
|
}
|
|
warnUnexpectedInputs(ctx, action, stepModel.With)
|
|
|
|
actionName, containerActionDir := getContainerActionPaths(step)
|
|
|
|
logger.Debugf("type=%v actionDir=%s actionPath=%s workdir=%s actionCacheDir=%s actionName=%s containerActionDir=%s", stepModel.Type(), actionDir, actionPath, rc.Config.Workdir, rc.ActionCacheDir(), actionName, containerActionDir)
|
|
|
|
x := action.Runs.Using
|
|
switch {
|
|
case x.IsNode():
|
|
if err := maybeCopyToActionDir(ctx, step, actionDir, actionPath, containerActionDir); err != nil {
|
|
return err
|
|
}
|
|
containerArgs := nodeActionCommand(path.Join(containerActionDir, action.Runs.Main))
|
|
logger.Debugf("executing remote job container: %s", containerArgs)
|
|
|
|
rc.ApplyExtraPath(ctx, step.getEnv())
|
|
|
|
return rc.JobContainer.Exec(containerArgs, *step.getEnv(), "", "")(ctx)
|
|
case x.IsDocker():
|
|
location := path.Join(actionDir, actionPath)
|
|
if remoteAction == nil {
|
|
location = containerActionDir
|
|
}
|
|
return execAsDocker(ctx, step, actionName, actionDir, location, remoteAction == nil, stepStageMain)
|
|
case x.IsComposite():
|
|
if err := maybeCopyToActionDir(ctx, step, actionDir, actionPath, containerActionDir); err != nil {
|
|
return err
|
|
}
|
|
|
|
return execAsComposite(step)(ctx)
|
|
case x == model.ActionRunsUsingGo:
|
|
if err := maybeCopyToActionDir(ctx, step, actionDir, actionPath, containerActionDir); err != nil {
|
|
return err
|
|
}
|
|
|
|
rc.ApplyExtraPath(ctx, step.getEnv())
|
|
|
|
execFileName := action.Runs.Main + ".out"
|
|
return common.NewPipelineExecutor(
|
|
rc.JobContainer.Exec([]string{"go", "build", "-o", execFileName, action.Runs.Main}, *step.getEnv(), "", containerActionDir),
|
|
rc.JobContainer.Exec([]string{filepath.Join(containerActionDir, execFileName)}, *step.getEnv(), "", ""),
|
|
)(ctx)
|
|
default:
|
|
return fmt.Errorf("the runs.using key must be one of: %v, got %s", []string{
|
|
model.ActionRunsUsingDocker,
|
|
model.ActionRunsUsingNode12,
|
|
model.ActionRunsUsingNode16,
|
|
model.ActionRunsUsingNode20,
|
|
model.ActionRunsUsingNode24,
|
|
model.ActionRunsUsingComposite,
|
|
model.ActionRunsUsingGo,
|
|
}, action.Runs.Using)
|
|
}
|
|
}
|
|
}
|
|
|
|
// /var/run is a symlink, so without the flag node's import.meta.url differs from argv[1], which ESM actions compare.
|
|
func nodeActionCommand(script string) []string {
|
|
return []string{"node", "--preserve-symlinks-main", script}
|
|
}
|
|
|
|
// dockerActionImageTag derives the local docker image tag used when an action
|
|
// is built from a Dockerfile.
|
|
//
|
|
// For Gitea: a local action (`uses: ./` or `uses: ./path`) has an actionName
|
|
// that is the workspace-relative path of the action. That path is identical
|
|
// across repositories (e.g. "./" for a self-referencing action), so without
|
|
// namespacing, every repository's local docker action would build and reuse the
|
|
// same `act-dockeraction:latest` image on a shared docker daemon. A subsequent
|
|
// repository would then silently run the image built for an earlier one.
|
|
// Including the repository keeps the tag stable for caching within a repository
|
|
// while preventing cross-repository collisions.
|
|
// See https://gitea.com/gitea/runner/issues/1039.
|
|
func dockerActionImageTag(repository, actionName string, localAction bool) string {
|
|
name := actionName
|
|
if localAction {
|
|
name = path.Join(repository, actionName)
|
|
}
|
|
// The human-readable name is sanitized by collapsing every non-alphanumeric character to "-".
|
|
sanitized := dockerActionNamePattern.ReplaceAllString(name, "-")
|
|
if localAction {
|
|
// For local actions a short hash of the raw repository and action path is appended so the tag stays unique per repository.
|
|
sum := sha256.Sum256([]byte(repository + "\x00" + actionName))
|
|
sanitized += "-" + hex.EncodeToString(sum[:])[:12]
|
|
}
|
|
// "-dockeraction" ensures that "./", "./test " won't get converted to "act-:latest", "act-test-:latest" which are invalid docker image names
|
|
return strings.ToLower("act-" + strings.TrimLeft(sanitized+"-dockeraction:latest", "-"))
|
|
}
|
|
|
|
// 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)
|
|
rc := step.getRunContext()
|
|
action := step.getActionModel()
|
|
if rc.kubernetes {
|
|
return errKubernetesDocker
|
|
}
|
|
|
|
var prepImage common.Executor
|
|
var image string
|
|
forcePull := false
|
|
if after, ok := strings.CutPrefix(action.Runs.Image, "docker://"); ok {
|
|
image = after
|
|
// Apply forcePull only for prebuild docker images
|
|
forcePull = rc.Config.ForcePull
|
|
} else {
|
|
image = rc.dockerActionImage(ctx, step, actionName, localAction)
|
|
contextDir, fileName := filepath.Split(filepath.Join(basedir, action.Runs.Image))
|
|
|
|
anyArchExists, err := ContainerImageExistsLocally(ctx, image, "any")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
correctArchExists := anyArchExists
|
|
if anyArchExists && rc.Config.ContainerArchitecture != "" && rc.Config.ContainerArchitecture != "any" {
|
|
correctArchExists, err = ContainerImageExistsLocally(ctx, image, rc.Config.ContainerArchitecture)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if anyArchExists && !correctArchExists {
|
|
wasRemoved, err := container.RemoveImage(ctx, image, true, true)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !wasRemoved {
|
|
return fmt.Errorf("failed to remove image '%s'", image)
|
|
}
|
|
}
|
|
|
|
if !correctArchExists || rc.Config.ForceRebuild {
|
|
logger.Debugf("image '%s' for architecture '%s' will be built from context '%s", image, rc.Config.ContainerArchitecture, contextDir)
|
|
var buildContext io.ReadCloser
|
|
if localAction {
|
|
buildContext, err = rc.JobContainer.GetContainerArchive(ctx, contextDir+"/.")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer buildContext.Close()
|
|
}
|
|
prepImage = ContainerNewDockerBuildExecutor(container.NewDockerBuildExecutorInput{
|
|
ContextDir: contextDir,
|
|
Dockerfile: fileName,
|
|
ImageTag: image,
|
|
BuildContext: buildContext,
|
|
Platform: rc.Config.ContainerArchitecture,
|
|
BuildArgs: rc.proxyBuildArgs(),
|
|
})
|
|
if buildContext == nil {
|
|
// Held across the whole build: the daemon drains contextDir lazily.
|
|
inner := prepImage
|
|
prepImage = func(ctx context.Context) error {
|
|
unlock, err := git.AcquireCloneLock(ctx, actionDir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer unlock()
|
|
return inner(ctx)
|
|
}
|
|
}
|
|
} else {
|
|
logger.Debugf("image '%s' for architecture '%s' already exists", image, rc.Config.ContainerArchitecture)
|
|
}
|
|
}
|
|
ee, err := evalDockerEnv(ctx, step, action)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var cmd []string
|
|
if action.Runs.Args != nil {
|
|
// a fresh slice, the manifest is evaluated again for every stage
|
|
cmd = make([]string, len(action.Runs.Args))
|
|
for i, v := range action.Runs.Args {
|
|
if cmd[i], err = ee.Interpolate(ctx, v); err != nil {
|
|
return fmt.Errorf("unable to interpolate runs.args: %w", err)
|
|
}
|
|
}
|
|
} else {
|
|
cmd, err = shellquote.Split(stepInput(step.getStepModel(), "args"))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
entrypoint, err := dockerEntrypoint(step, stage)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
stepContainer := newStepContainer(ctx, step, image, cmd, entrypoint, rc.Config.ContainerOptions)
|
|
return common.NewPipelineExecutor(
|
|
prepImage,
|
|
stepContainer.Pull(forcePull),
|
|
stepContainer.Remove(),
|
|
stepContainer.Create(rc.Config.ContainerCapAdd, rc.Config.ContainerCapDrop),
|
|
stepContainer.Start(true),
|
|
).Finally(stepContainer.Close())(ctx)
|
|
}
|
|
|
|
// dockerEntrypoint returns the entrypoint the action's image runs with for the given
|
|
// stage. Only the main stage honours the `entrypoint` input.
|
|
func dockerEntrypoint(step actionStep, stage stepStage) ([]string, error) {
|
|
runs := step.getActionModel().Runs
|
|
|
|
var entrypoint string
|
|
switch stage {
|
|
case stepStagePre:
|
|
entrypoint = runs.PreEntrypoint
|
|
case stepStagePost:
|
|
entrypoint = runs.PostEntrypoint
|
|
default:
|
|
entrypoint = runs.Entrypoint
|
|
if entrypoint == "" {
|
|
if fields := strings.Fields(stepInput(step.getStepModel(), "entrypoint")); len(fields) > 0 {
|
|
return fields, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
if entrypoint == "" {
|
|
return nil, nil
|
|
}
|
|
return shellquote.Split(entrypoint)
|
|
}
|
|
|
|
// evalDockerEnv returns an evaluator bound to the environment it installed.
|
|
func evalDockerEnv(ctx context.Context, step step, action *model.Action) (*expressionEvaluator, error) {
|
|
rc := step.getRunContext()
|
|
|
|
var err error
|
|
inputs := make(map[string]string)
|
|
providedInputs := make(map[string]bool, len(step.getStepModel().With))
|
|
for key := range step.getStepModel().With {
|
|
providedInputs[strings.ToLower(key)] = true
|
|
}
|
|
eval := rc.NewExpressionEvaluator(ctx)
|
|
for k, input := range action.Inputs {
|
|
if providedInputs[strings.ToLower(k)] {
|
|
continue
|
|
}
|
|
if inputs[k], err = eval.Interpolate(ctx, input.Default); err != nil {
|
|
return nil, fmt.Errorf("unable to interpolate the default of input %s: %w", k, err)
|
|
}
|
|
}
|
|
mergeIntoMap(step, step.getEnv(), inputs, step.getStepModel().With)
|
|
|
|
ee := rc.NewActionInputsExpressionEvaluator(ctx, step)
|
|
runsEnv := make(map[string]string, len(action.Runs.Env))
|
|
for k, v := range action.Runs.Env {
|
|
if runsEnv[k], err = ee.Interpolate(ctx, v); err != nil {
|
|
return nil, fmt.Errorf("unable to interpolate env %s: %w", k, err)
|
|
}
|
|
}
|
|
mergeIntoMap(step, step.getEnv(), runsEnv, maps.Clone(*step.getEnv()))
|
|
return ee, nil
|
|
}
|
|
|
|
func newStepContainer(ctx context.Context, step step, image string, cmd, entrypoint []string, runnerOptions string) container.Container {
|
|
rc := step.getRunContext()
|
|
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))
|
|
}
|
|
|
|
envList = append(envList, rc.runnerEnv(ctx)...)
|
|
|
|
binds, mounts := rc.GetBindsAndMounts(ctx)
|
|
networkMode := "container:" + rc.jobContainerName()
|
|
if rc.IsHostEnv() {
|
|
networkMode = "default"
|
|
}
|
|
return ContainerNewContainer(&container.NewContainerInput{
|
|
Cmd: cmd,
|
|
Entrypoint: entrypoint,
|
|
WorkingDir: rc.JobContainer.ToContainerPath(rc.Config.Workdir),
|
|
Image: image,
|
|
Name: createContainerName(rc.jobContainerName(), "STEP-"+step.getStepModel().ID),
|
|
Env: envList,
|
|
Mounts: mounts,
|
|
NetworkMode: networkMode,
|
|
Binds: binds,
|
|
Stdout: logWriter,
|
|
Stderr: logWriter,
|
|
Privileged: rc.Config.Privileged,
|
|
UsernsMode: rc.Config.UsernsMode,
|
|
Platform: rc.Config.ContainerArchitecture,
|
|
RunnerOptions: runnerOptions,
|
|
AutoRemove: true,
|
|
ValidVolumes: rc.validVolumes(),
|
|
AllocatePTY: rc.Config.AllocatePTY,
|
|
})
|
|
}
|
|
|
|
func populateEnvsFromSavedState(env *map[string]string, step actionStep, rc *RunContext) {
|
|
for name, value := range rc.IntraActionState[step.getStepModel().ID] {
|
|
(*env)["STATE_"+name] = value
|
|
}
|
|
}
|
|
|
|
func populateEnvsFromInput(ctx context.Context, env *map[string]string, action *model.Action, rc *RunContext) error {
|
|
eval := rc.NewExpressionEvaluator(ctx)
|
|
for inputID, input := range action.Inputs {
|
|
envKey := "INPUT_" + inputEnvPattern.ReplaceAllString(strings.ToUpper(inputID), "_")
|
|
if _, ok := (*env)[envKey]; !ok {
|
|
var err error
|
|
if (*env)[envKey], err = eval.Interpolate(ctx, input.Default); err != nil {
|
|
return fmt.Errorf("unable to interpolate the default of input %s: %w", inputID, err)
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// lookupFold finds name in values, ignoring case where the environment does, as GitHub's inputs always do.
|
|
func lookupFold[V any](values map[string]V, name string, caseInsensitive bool) (V, bool) {
|
|
if value, ok := values[name]; ok || !caseInsensitive {
|
|
return value, ok
|
|
}
|
|
for key, value := range values {
|
|
if strings.EqualFold(key, name) {
|
|
return value, true
|
|
}
|
|
}
|
|
var zero V
|
|
return zero, false
|
|
}
|
|
|
|
func stepInput(step *model.Step, name string) string {
|
|
value, _ := lookupFold(step.With, name, true)
|
|
return value
|
|
}
|
|
|
|
func warnUnexpectedInputs(ctx context.Context, action *model.Action, inputs map[string]string) {
|
|
docker := action.Runs.Using.IsDocker()
|
|
var unexpectedInputs []string
|
|
for input := range inputs {
|
|
_, declared := lookupFold(action.Inputs, input, true)
|
|
containerInput := docker && (strings.EqualFold(input, "entryPoint") || strings.EqualFold(input, "args"))
|
|
if !declared && !containerInput {
|
|
unexpectedInputs = append(unexpectedInputs, input)
|
|
}
|
|
}
|
|
if len(unexpectedInputs) == 0 {
|
|
return
|
|
}
|
|
validInputs := slices.Sorted(maps.Keys(action.Inputs))
|
|
if docker {
|
|
validInputs = append([]string{"entryPoint", "args"}, validInputs...)
|
|
}
|
|
slices.Sort(unexpectedInputs)
|
|
common.Logger(ctx).With(rawOutputField, true).Warnf("##[warning]%s", EscapeCommandData(fmt.Sprintf(
|
|
"Unexpected input(s) '%s', valid inputs are ['%s']", strings.Join(unexpectedInputs, "', '"), strings.Join(validInputs, "', '"))))
|
|
}
|
|
|
|
func getContainerActionPaths(step actionStep) (string, string) {
|
|
rc := step.getRunContext()
|
|
if sar, ok := step.(*stepActionRemote); ok { // a copy per action, as its repository's directory is shared
|
|
actionName := path.Join(sar.containerKey(), sar.remoteAction.Path)
|
|
return actionName, rc.JobContainer.GetActPath() + "/actions/" + actionName
|
|
}
|
|
actionName := getOsSafeRelativePath(filepath.Join(rc.Config.Workdir, step.getStepModel().Uses), rc.Config.Workdir)
|
|
return "./" + actionName, rc.JobContainer.ToContainerPath(rc.Config.Workdir) + "/" + actionName
|
|
}
|
|
|
|
func getOsSafeRelativePath(s, prefix string) string {
|
|
actionName := strings.TrimPrefix(s, prefix)
|
|
if runtime.GOOS == "windows" {
|
|
actionName = strings.ReplaceAll(actionName, "\\", "/")
|
|
}
|
|
actionName = strings.TrimPrefix(actionName, "/")
|
|
|
|
return actionName
|
|
}
|
|
|
|
func shouldRunPreStep(step actionStep) common.Conditional {
|
|
return func(ctx context.Context) bool {
|
|
log := common.Logger(ctx)
|
|
|
|
if step.getActionModel() == nil {
|
|
log.Debugf("skip pre step for '%s': no action model available", step.getStepModel())
|
|
return false
|
|
}
|
|
|
|
return true
|
|
}
|
|
}
|
|
|
|
func hasPreStep(step actionStep) common.Conditional {
|
|
return func(ctx context.Context) bool {
|
|
action := step.getActionModel()
|
|
return action.Runs.Using.IsComposite() ||
|
|
(action.Runs.Using.IsNode() &&
|
|
action.Runs.Pre != "") ||
|
|
(action.Runs.Using.IsDocker() &&
|
|
action.Runs.PreEntrypoint != "") ||
|
|
(action.Runs.Using == model.ActionRunsUsingGo &&
|
|
action.Runs.Pre != "")
|
|
}
|
|
}
|
|
|
|
// actionStagePaths resolves where a step's action lives and where the job container sees
|
|
// it, for the pre and post stage.
|
|
func actionStagePaths(step actionStep) (actionDir, actionPath, actionName, containerActionDir string) {
|
|
rc := step.getRunContext()
|
|
stepModel := step.getStepModel()
|
|
|
|
if sar, ok := step.(*stepActionRemote); ok {
|
|
actionDir = sar.actionDir
|
|
actionPath = sar.remoteAction.Path
|
|
} else {
|
|
actionDir = filepath.Join(rc.Config.Workdir, stepModel.Uses)
|
|
}
|
|
|
|
actionName, containerActionDir = getContainerActionPaths(step)
|
|
return actionDir, actionPath, actionName, containerActionDir
|
|
}
|
|
|
|
// execDockerActionStage runs a docker action's image for its pre or post stage.
|
|
func execDockerActionStage(ctx context.Context, step actionStep, stage stepStage) error {
|
|
actionDir, actionPath, actionName, containerActionDir := actionStagePaths(step)
|
|
|
|
_, remote := step.(*stepActionRemote)
|
|
location := containerActionDir
|
|
if remote {
|
|
location = path.Join(actionDir, actionPath)
|
|
}
|
|
return execAsDocker(ctx, step, actionName, actionDir, location, !remote, stage)
|
|
}
|
|
|
|
func runPreStep(step actionStep) common.Executor {
|
|
return func(ctx context.Context) error {
|
|
logger := common.Logger(ctx)
|
|
logger.Debugf("run pre step for '%s'", step.getStepModel())
|
|
|
|
rc := step.getRunContext()
|
|
action := step.getActionModel()
|
|
|
|
actionDir, actionPath, _, containerActionDir := actionStagePaths(step)
|
|
|
|
x := action.Runs.Using
|
|
if !x.IsComposite() {
|
|
// defaults in pre steps were missing, however provided inputs are available
|
|
if err := populateEnvsFromInput(ctx, step.getEnv(), action, rc); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
switch {
|
|
case x.IsNode():
|
|
if err := maybeCopyToActionDir(ctx, step, actionDir, actionPath, containerActionDir); err != nil {
|
|
return err
|
|
}
|
|
|
|
containerArgs := nodeActionCommand(path.Join(containerActionDir, action.Runs.Pre))
|
|
logger.Debugf("executing remote job container: %s", containerArgs)
|
|
|
|
rc.ApplyExtraPath(ctx, step.getEnv())
|
|
|
|
return rc.JobContainer.Exec(containerArgs, *step.getEnv(), "", "")(ctx)
|
|
|
|
case x.IsDocker():
|
|
return execDockerActionStage(ctx, step, stepStagePre)
|
|
|
|
case x.IsComposite():
|
|
if step.getCompositeSteps() == nil {
|
|
if _, err := step.getCompositeRunContext(ctx); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if steps := step.getCompositeSteps(); steps != nil && steps.pre != nil {
|
|
return steps.pre(ctx)
|
|
}
|
|
return errors.New("missing steps in composite action")
|
|
|
|
case x == model.ActionRunsUsingGo:
|
|
if err := maybeCopyToActionDir(ctx, step, actionDir, actionPath, containerActionDir); err != nil {
|
|
return err
|
|
}
|
|
|
|
rc.ApplyExtraPath(ctx, step.getEnv())
|
|
|
|
execFileName := action.Runs.Pre + ".out"
|
|
return common.NewPipelineExecutor(
|
|
rc.JobContainer.Exec([]string{"go", "build", "-o", execFileName, action.Runs.Pre}, *step.getEnv(), "", containerActionDir),
|
|
rc.JobContainer.Exec([]string{filepath.Join(containerActionDir, execFileName)}, *step.getEnv(), "", ""),
|
|
)(ctx)
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
|
|
func shouldRunPostStep(step actionStep) common.Conditional {
|
|
return func(ctx context.Context) bool {
|
|
if remote, ok := step.(*stepActionRemote); ok && remote.preStepStarted && !remote.action.Runs.Using.IsComposite() {
|
|
return true
|
|
}
|
|
log := common.Logger(ctx)
|
|
stepResults := step.getRunContext().StepResults
|
|
stepResult := stepResults[step.getStepModel().ID]
|
|
|
|
if stepResult == nil {
|
|
log.With("stepResult", model.StepStatusSkipped).Debugf("skipping post step for '%s'; step was not executed", step.getStepModel())
|
|
return false
|
|
}
|
|
|
|
if stepResult.Conclusion == model.StepStatusSkipped {
|
|
log.With("stepResult", model.StepStatusSkipped).Debugf("skipping post step for '%s'; main step was skipped", step.getStepModel())
|
|
return false
|
|
}
|
|
|
|
if step.getActionModel() == nil {
|
|
log.With("stepResult", model.StepStatusSkipped).Debugf("skipping post step for '%s': no action model available", step.getStepModel())
|
|
return false
|
|
}
|
|
|
|
return true
|
|
}
|
|
}
|
|
|
|
func hasPostStep(step actionStep) common.Conditional {
|
|
return func(ctx context.Context) bool {
|
|
action := step.getActionModel()
|
|
return action.Runs.Using.IsComposite() ||
|
|
(action.Runs.Using.IsNode() &&
|
|
action.Runs.Post != "") ||
|
|
(action.Runs.Using.IsDocker() &&
|
|
action.Runs.PostEntrypoint != "") ||
|
|
(action.Runs.Using == model.ActionRunsUsingGo &&
|
|
action.Runs.Post != "")
|
|
}
|
|
}
|
|
|
|
func runPostStep(step actionStep) common.Executor {
|
|
return func(ctx context.Context) error {
|
|
logger := common.Logger(ctx)
|
|
logger.Debugf("run post step for '%s'", step.getStepModel())
|
|
|
|
rc := step.getRunContext()
|
|
action := step.getActionModel()
|
|
|
|
actionDir, actionPath, _, containerActionDir := actionStagePaths(step)
|
|
|
|
x := action.Runs.Using
|
|
switch {
|
|
case x.IsNode():
|
|
|
|
populateEnvsFromSavedState(step.getEnv(), step, rc)
|
|
|
|
containerArgs := nodeActionCommand(path.Join(containerActionDir, action.Runs.Post))
|
|
logger.Debugf("executing remote job container: %s", containerArgs)
|
|
|
|
rc.ApplyExtraPath(ctx, step.getEnv())
|
|
|
|
return rc.JobContainer.Exec(containerArgs, *step.getEnv(), "", "")(ctx)
|
|
|
|
case x.IsDocker():
|
|
populateEnvsFromSavedState(step.getEnv(), step, rc)
|
|
|
|
return execDockerActionStage(ctx, step, stepStagePost)
|
|
|
|
case x.IsComposite():
|
|
if err := maybeCopyToActionDir(ctx, step, actionDir, actionPath, containerActionDir); err != nil {
|
|
return err
|
|
}
|
|
|
|
if steps := step.getCompositeSteps(); steps != nil && steps.post != nil {
|
|
return steps.post(ctx)
|
|
}
|
|
return errors.New("missing steps in composite action")
|
|
|
|
case x == model.ActionRunsUsingGo:
|
|
populateEnvsFromSavedState(step.getEnv(), step, rc)
|
|
rc.ApplyExtraPath(ctx, step.getEnv())
|
|
|
|
execFileName := action.Runs.Post + ".out"
|
|
return common.NewPipelineExecutor(
|
|
rc.JobContainer.Exec([]string{"go", "build", "-o", execFileName, action.Runs.Post}, *step.getEnv(), "", containerActionDir),
|
|
rc.JobContainer.Exec([]string{filepath.Join(containerActionDir, execFileName)}, *step.getEnv(), "", ""),
|
|
)(ctx)
|
|
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
}
|