mirror of
https://gitea.com/gitea/act_runner.git
synced 2026-08-13 22:41:54 +02:00
refactor: move act under internal
The runner is an application, not a library. `act/model` and `act/exprparser` were the last packages anything outside this repository consumed and they now live in actionslib, so nothing needs the rest of `act` to be importable, and keeping it importable invites the coupling that was just removed. Import paths only, the files are unchanged. Assisted-by: Codet:GPT-5.1-Codex
This commit is contained in:
@@ -0,0 +1,781 @@
|
||||
// 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/sha256"
|
||||
"embed"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"gitea.com/gitea/runner/internal/act/common"
|
||||
"gitea.com/gitea/runner/internal/act/common/git"
|
||||
"gitea.com/gitea/runner/internal/act/container"
|
||||
|
||||
"gitea.dev/actionslib/pkg/model"
|
||||
"github.com/kballard/go-shellquote"
|
||||
)
|
||||
|
||||
type actionStep interface {
|
||||
step
|
||||
|
||||
getActionModel() *model.Action
|
||||
getCompositeRunContext(context.Context) *RunContext
|
||||
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 embed.FS
|
||||
|
||||
var (
|
||||
ContainerImageExistsLocally = container.ImageExistsLocally
|
||||
ContainerNewDockerBuildExecutor = container.NewDockerBuildExecutor
|
||||
)
|
||||
|
||||
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) {
|
||||
_, 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 Dockerfile", action)
|
||||
return action, nil
|
||||
}
|
||||
if step.With != nil {
|
||||
if val, ok := step.With["args"]; ok {
|
||||
var b []byte
|
||||
if b, err = trampoline.ReadFile("res/trampoline.js"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err2 := writeFile(filepath.Join(actionDir, actionPath, "trampoline.js"), b, 0o400)
|
||||
if err2 != nil {
|
||||
return nil, err2
|
||||
}
|
||||
action := &model.Action{
|
||||
Name: "(Synthetic)",
|
||||
Inputs: map[string]model.Input{
|
||||
"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()
|
||||
|
||||
action, err := model.ReadAction(reader)
|
||||
// For Gitea, reduce log noise
|
||||
// logger.Debugf("Read action %v from '%s'", action, "Unknown")
|
||||
return action, err
|
||||
}
|
||||
|
||||
// cachedActionTar returns the action's tree from the action cache, which only a remote action
|
||||
// has an entry in.
|
||||
func cachedActionTar(ctx context.Context, step actionStep, name, includePrefix string) (io.ReadCloser, error) {
|
||||
remote, ok := step.(*stepActionRemote)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("action %q is a remote action but runs as %T", name, step)
|
||||
}
|
||||
return step.getRunContext().Config.ActionCache.GetTarArchive(ctx, remote.cacheDir, remote.resolvedSha, includePrefix)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
var containerActionDirCopy string
|
||||
containerActionDirCopy = strings.TrimSuffix(containerActionDir, actionPath)
|
||||
logger.Debug(containerActionDirCopy)
|
||||
|
||||
if !strings.HasSuffix(containerActionDirCopy, `/`) {
|
||||
containerActionDirCopy += `/`
|
||||
}
|
||||
|
||||
if rc.Config != nil && rc.Config.ActionCache != nil {
|
||||
ta, err := cachedActionTar(ctx, step, stepModel.Uses, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer ta.Close()
|
||||
return rc.JobContainer.CopyTarStream(ctx, containerActionDirCopy, ta)
|
||||
}
|
||||
|
||||
defer git.AcquireCloneLock(actionDir)()
|
||||
|
||||
if err := removeGitIgnore(ctx, actionDir); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return rc.JobContainer.CopyDir(containerActionDirCopy, actionDir+"/", rc.Config.UseGitIgnore)(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 && remoteAction.Path != "" {
|
||||
actionPath = remoteAction.Path
|
||||
}
|
||||
|
||||
action := step.getActionModel()
|
||||
// For Gitea, reduce log noise
|
||||
// logger.Debugf("About to run action %v", action)
|
||||
|
||||
err := setupActionEnv(ctx, step, remoteAction)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
actionLocation := path.Join(actionDir, actionPath)
|
||||
actionName, containerActionDir := getContainerActionPaths(stepModel, actionLocation, rc)
|
||||
|
||||
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 := []string{"node", path.Join(containerActionDir, action.Runs.Main)}
|
||||
logger.Debugf("executing remote job container: %s", containerArgs)
|
||||
|
||||
rc.ApplyExtraPath(ctx, step.getEnv())
|
||||
|
||||
return rc.execJobContainer(containerArgs, *step.getEnv(), "", "")(ctx)
|
||||
case x.IsDocker():
|
||||
location := actionLocation
|
||||
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"
|
||||
buildArgs := []string{"go", "build", "-o", execFileName, action.Runs.Main}
|
||||
execArgs := []string{filepath.Join(containerActionDir, execFileName)}
|
||||
|
||||
return common.NewPipelineExecutor(
|
||||
rc.execJobContainer(buildArgs, *step.getEnv(), "", containerActionDir),
|
||||
rc.execJobContainer(execArgs, *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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func setupActionEnv(ctx context.Context, step actionStep, _ *remoteAction) error {
|
||||
rc := step.getRunContext()
|
||||
|
||||
// A few fields in the environment (e.g. GITHUB_ACTION_REPOSITORY)
|
||||
// are dependent on the action. That means we can complete the
|
||||
// setup only after resolving the whole action model and cloning
|
||||
// the action
|
||||
rc.withGithubEnv(ctx, step.getGithubContext(ctx), *step.getEnv())
|
||||
populateEnvsFromSavedState(step.getEnv(), step, rc)
|
||||
populateEnvsFromInput(ctx, step.getEnv(), step.getActionModel(), rc)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// https://github.com/nektos/act/issues/228#issuecomment-629709055
|
||||
// files in .gitignore are not copied in a Docker container
|
||||
// this causes issues with actions that ignore other important resources
|
||||
// such as `node_modules` for example
|
||||
func removeGitIgnore(ctx context.Context, directory string) error {
|
||||
gitIgnorePath := path.Join(directory, ".gitignore")
|
||||
if _, err := os.Stat(gitIgnorePath); err == nil {
|
||||
// .gitignore exists
|
||||
common.Logger(ctx).Debugf("Removing %s before docker cp", gitIgnorePath)
|
||||
err := os.Remove(gitIgnorePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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 := regexp.MustCompile("[^a-zA-Z0-9]").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
|
||||
image := fmt.Sprintf("%s-dockeraction:%s", sanitized, "latest")
|
||||
image = "act-" + strings.TrimLeft(image, "-")
|
||||
return strings.ToLower(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()
|
||||
|
||||
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 = dockerActionImageTag(step.getGithubContext(ctx).Repository, actionName, localAction)
|
||||
contextDir, fileName := filepath.Split(filepath.Join(basedir, action.Runs.Image))
|
||||
|
||||
anyArchExists, err := ContainerImageExistsLocally(ctx, image, "any")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
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()
|
||||
} else if rc.Config.ActionCache != nil {
|
||||
buildContext, err = cachedActionTar(ctx, step, actionName, 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 {
|
||||
defer git.AcquireCloneLock(actionDir)()
|
||||
return inner(ctx)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logger.Debugf("image '%s' for architecture '%s' already exists", image, rc.Config.ContainerArchitecture)
|
||||
}
|
||||
}
|
||||
eval := rc.NewStepExpressionEvaluator(ctx, step)
|
||||
cmd, err := shellquote.Split(eval.Interpolate(ctx, step.getStepModel().With["args"]))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(cmd) == 0 {
|
||||
cmd = action.Runs.Args
|
||||
evalDockerArgs(ctx, step, action, &cmd)
|
||||
}
|
||||
entrypoint, err := dockerEntrypoint(ctx, step, eval, stage)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stepContainer := newStepContainer(ctx, step, image, cmd, entrypoint)
|
||||
return common.NewPipelineExecutor(
|
||||
prepImage,
|
||||
stepContainer.Pull(forcePull),
|
||||
stepContainer.Remove().IfBool(!rc.Config.ReuseContainers),
|
||||
stepContainer.Create(rc.Config.ContainerCapAdd, rc.Config.ContainerCapDrop),
|
||||
stepContainer.Start(true),
|
||||
).Finally(
|
||||
stepContainer.Remove().IfBool(!rc.Config.ReuseContainers && !rc.Config.AutoRemove),
|
||||
).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(ctx context.Context, step actionStep, eval ExpressionEvaluator, stage stepStage) ([]string, error) {
|
||||
runs := step.getActionModel().Runs
|
||||
|
||||
var entrypoint string
|
||||
switch stage {
|
||||
case stepStagePre:
|
||||
entrypoint = runs.PreEntrypoint
|
||||
case stepStagePost:
|
||||
entrypoint = runs.PostEntrypoint
|
||||
default:
|
||||
if fields := strings.Fields(eval.Interpolate(ctx, step.getStepModel().With["entrypoint"])); len(fields) > 0 {
|
||||
return fields, nil
|
||||
}
|
||||
entrypoint = runs.Entrypoint
|
||||
}
|
||||
|
||||
if entrypoint == "" {
|
||||
return nil, nil
|
||||
}
|
||||
return shellquote.Split(entrypoint)
|
||||
}
|
||||
|
||||
func evalDockerArgs(ctx context.Context, step step, action *model.Action, cmd *[]string) {
|
||||
rc := step.getRunContext()
|
||||
stepModel := step.getStepModel()
|
||||
|
||||
inputs := make(map[string]string)
|
||||
eval := rc.NewExpressionEvaluator(ctx)
|
||||
// Set Defaults
|
||||
for k, input := range action.Inputs {
|
||||
inputs[k] = eval.Interpolate(ctx, input.Default)
|
||||
}
|
||||
if stepModel.With != nil {
|
||||
for k, v := range stepModel.With {
|
||||
inputs[k] = eval.Interpolate(ctx, v)
|
||||
}
|
||||
}
|
||||
mergeIntoMap(step, step.getEnv(), inputs)
|
||||
|
||||
stepEE := rc.NewStepExpressionEvaluator(ctx, step)
|
||||
for i, v := range *cmd {
|
||||
(*cmd)[i] = stepEE.Interpolate(ctx, v)
|
||||
}
|
||||
mergeIntoMap(step, step.getEnv(), action.Runs.Env)
|
||||
|
||||
ee := rc.NewStepExpressionEvaluator(ctx, step)
|
||||
for k, v := range *step.getEnv() {
|
||||
(*step.getEnv())[k] = ee.Interpolate(ctx, v)
|
||||
}
|
||||
}
|
||||
|
||||
func newStepContainer(ctx context.Context, step step, image string, cmd, entrypoint []string) container.Container {
|
||||
rc := step.getRunContext()
|
||||
stepModel := step.getStepModel()
|
||||
rawLogger := common.Logger(ctx).WithField("raw_output", true)
|
||||
logWriter := common.NewLineWriter(rc.commandHandler(ctx), func(s string) bool {
|
||||
if rc.Config.LogOutput {
|
||||
rawLogger.Infof("%s", s)
|
||||
} else {
|
||||
rawLogger.Debugf("%s", s)
|
||||
}
|
||||
return true
|
||||
})
|
||||
envList := make([]string, 0)
|
||||
for k, v := range *step.getEnv() {
|
||||
envList = append(envList, fmt.Sprintf("%s=%s", k, v))
|
||||
}
|
||||
|
||||
envList = append(envList, rc.runnerEnv(ctx)...)
|
||||
|
||||
binds, mounts := rc.GetBindsAndMounts()
|
||||
networkMode := "container:" + rc.jobContainerName()
|
||||
if rc.IsHostEnv(ctx) {
|
||||
networkMode = "default"
|
||||
}
|
||||
stepContainer := ContainerNewContainer(&container.NewContainerInput{
|
||||
Cmd: cmd,
|
||||
Entrypoint: entrypoint,
|
||||
WorkingDir: rc.JobContainer.ToContainerPath(rc.Config.Workdir),
|
||||
Image: image,
|
||||
Name: createContainerName(rc.jobContainerName(), "STEP-"+stepModel.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,
|
||||
Options: rc.Config.ContainerOptions,
|
||||
AutoRemove: rc.Config.AutoRemove,
|
||||
ValidVolumes: rc.validVolumes(),
|
||||
AllocatePTY: rc.Config.AllocatePTY,
|
||||
})
|
||||
return stepContainer
|
||||
}
|
||||
|
||||
func populateEnvsFromSavedState(env *map[string]string, step actionStep, rc *RunContext) {
|
||||
state, ok := rc.IntraActionState[step.getStepModel().ID]
|
||||
if ok {
|
||||
for name, value := range state {
|
||||
envName := "STATE_" + name
|
||||
(*env)[envName] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func populateEnvsFromInput(ctx context.Context, env *map[string]string, action *model.Action, rc *RunContext) {
|
||||
eval := rc.NewExpressionEvaluator(ctx)
|
||||
for inputID, input := range action.Inputs {
|
||||
envKey := regexp.MustCompile("[^A-Z0-9-]").ReplaceAllString(strings.ToUpper(inputID), "_")
|
||||
envKey = "INPUT_" + envKey
|
||||
if _, ok := (*env)[envKey]; !ok {
|
||||
(*env)[envKey] = eval.Interpolate(ctx, input.Default)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func getContainerActionPaths(step *model.Step, actionDir string, rc *RunContext) (string, string) {
|
||||
actionName := ""
|
||||
containerActionDir := "."
|
||||
if step.Type() != model.StepTypeUsesActionRemote {
|
||||
actionName = getOsSafeRelativePath(actionDir, rc.Config.Workdir)
|
||||
containerActionDir = rc.JobContainer.ToContainerPath(rc.Config.Workdir) + "/" + actionName
|
||||
actionName = "./" + actionName
|
||||
} else if step.Type() == model.StepTypeUsesActionRemote {
|
||||
actionName = getOsSafeRelativePath(actionDir, rc.ActionCacheDir())
|
||||
containerActionDir = rc.JobContainer.GetActPath() + "/actions/" + actionName
|
||||
}
|
||||
|
||||
if actionName == "" {
|
||||
actionName = filepath.Base(actionDir)
|
||||
if runtime.GOOS == "windows" {
|
||||
actionName = strings.ReplaceAll(actionName, "\\", "/")
|
||||
}
|
||||
}
|
||||
return actionName, containerActionDir
|
||||
}
|
||||
|
||||
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(stepModel, path.Join(actionDir, actionPath), rc)
|
||||
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
|
||||
switch {
|
||||
case x.IsNode():
|
||||
// defaults in pre steps were missing, however provided inputs are available
|
||||
populateEnvsFromInput(ctx, step.getEnv(), action, rc)
|
||||
|
||||
if err := maybeCopyToActionDir(ctx, step, actionDir, actionPath, containerActionDir); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
containerArgs := []string{"node", path.Join(containerActionDir, action.Runs.Pre)}
|
||||
logger.Debugf("executing remote job container: %s", containerArgs)
|
||||
|
||||
rc.ApplyExtraPath(ctx, step.getEnv())
|
||||
|
||||
return rc.execJobContainer(containerArgs, *step.getEnv(), "", "")(ctx)
|
||||
|
||||
case x.IsDocker():
|
||||
// defaults in pre steps were missing, however provided inputs are available
|
||||
populateEnvsFromInput(ctx, step.getEnv(), action, rc)
|
||||
|
||||
return execDockerActionStage(ctx, step, stepStagePre)
|
||||
|
||||
case x.IsComposite():
|
||||
if step.getCompositeSteps() == nil {
|
||||
step.getCompositeRunContext(ctx)
|
||||
}
|
||||
|
||||
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:
|
||||
// defaults in pre steps were missing, however provided inputs are available
|
||||
populateEnvsFromInput(ctx, step.getEnv(), action, rc)
|
||||
|
||||
if err := maybeCopyToActionDir(ctx, step, actionDir, actionPath, containerActionDir); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rc.ApplyExtraPath(ctx, step.getEnv())
|
||||
|
||||
execFileName := action.Runs.Pre + ".out"
|
||||
buildArgs := []string{"go", "build", "-o", execFileName, action.Runs.Pre}
|
||||
execArgs := []string{filepath.Join(containerActionDir, execFileName)}
|
||||
|
||||
return common.NewPipelineExecutor(
|
||||
rc.execJobContainer(buildArgs, *step.getEnv(), "", containerActionDir),
|
||||
rc.execJobContainer(execArgs, *step.getEnv(), "", ""),
|
||||
)(ctx)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func shouldRunPostStep(step actionStep) common.Conditional {
|
||||
return func(ctx context.Context) bool {
|
||||
log := common.Logger(ctx)
|
||||
stepResults := step.getRunContext().getStepsContext()
|
||||
stepResult := stepResults[step.getStepModel().ID]
|
||||
|
||||
if stepResult == nil {
|
||||
log.WithField("stepResult", model.StepStatusSkipped).Debugf("skipping post step for '%s'; step was not executed", step.getStepModel())
|
||||
return false
|
||||
}
|
||||
|
||||
if stepResult.Conclusion == model.StepStatusSkipped {
|
||||
log.WithField("stepResult", model.StepStatusSkipped).Debugf("skipping post step for '%s'; main step was skipped", step.getStepModel())
|
||||
return false
|
||||
}
|
||||
|
||||
if step.getActionModel() == nil {
|
||||
log.WithField("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 := []string{"node", path.Join(containerActionDir, action.Runs.Post)}
|
||||
logger.Debugf("executing remote job container: %s", containerArgs)
|
||||
|
||||
rc.ApplyExtraPath(ctx, step.getEnv())
|
||||
|
||||
return rc.execJobContainer(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"
|
||||
buildArgs := []string{"go", "build", "-o", execFileName, action.Runs.Post}
|
||||
execArgs := []string{filepath.Join(containerActionDir, execFileName)}
|
||||
|
||||
return common.NewPipelineExecutor(
|
||||
rc.execJobContainer(buildArgs, *step.getEnv(), "", containerActionDir),
|
||||
rc.execJobContainer(execArgs, *step.getEnv(), "", ""),
|
||||
)(ctx)
|
||||
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2023 The nektos/act Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package runner
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"io"
|
||||
"io/fs"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
git "github.com/go-git/go-git/v5"
|
||||
config "github.com/go-git/go-git/v5/config"
|
||||
"github.com/go-git/go-git/v5/plumbing"
|
||||
"github.com/go-git/go-git/v5/plumbing/object"
|
||||
"github.com/go-git/go-git/v5/plumbing/transport"
|
||||
"github.com/go-git/go-git/v5/plumbing/transport/http"
|
||||
)
|
||||
|
||||
type ActionCache interface {
|
||||
Fetch(ctx context.Context, cacheDir, url, ref, token string) (string, error)
|
||||
GetTarArchive(ctx context.Context, cacheDir, sha, includePrefix string) (io.ReadCloser, error)
|
||||
}
|
||||
|
||||
type GoGitActionCache struct {
|
||||
Path string
|
||||
}
|
||||
|
||||
func (c GoGitActionCache) Fetch(ctx context.Context, cacheDir, url, ref, token string) (string, error) {
|
||||
gitPath := path.Join(c.Path, safeFilename(cacheDir)+".git")
|
||||
gogitrepo, err := git.PlainInit(gitPath, true)
|
||||
if errors.Is(err, git.ErrRepositoryAlreadyExists) {
|
||||
gogitrepo, err = git.PlainOpen(gitPath)
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
tmpBranch := make([]byte, 12)
|
||||
if _, err := rand.Read(tmpBranch); err != nil {
|
||||
return "", err
|
||||
}
|
||||
branchName := hex.EncodeToString(tmpBranch)
|
||||
|
||||
var auth transport.AuthMethod
|
||||
if token != "" {
|
||||
auth = &http.BasicAuth{
|
||||
Username: "token",
|
||||
Password: token,
|
||||
}
|
||||
}
|
||||
remote, err := gogitrepo.CreateRemoteAnonymous(&config.RemoteConfig{
|
||||
Name: "anonymous",
|
||||
URLs: []string{
|
||||
url,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer func() {
|
||||
_ = gogitrepo.DeleteBranch(branchName)
|
||||
}()
|
||||
if err := remote.FetchContext(ctx, &git.FetchOptions{
|
||||
RefSpecs: []config.RefSpec{
|
||||
config.RefSpec(ref + ":" + branchName),
|
||||
},
|
||||
Auth: auth,
|
||||
Force: true,
|
||||
}); err != nil {
|
||||
return "", err
|
||||
}
|
||||
hash, err := gogitrepo.ResolveRevision(plumbing.Revision(branchName))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hash.String(), nil
|
||||
}
|
||||
|
||||
func (c GoGitActionCache) GetTarArchive(ctx context.Context, cacheDir, sha, includePrefix string) (io.ReadCloser, error) {
|
||||
gitPath := path.Join(c.Path, safeFilename(cacheDir)+".git")
|
||||
gogitrepo, err := git.PlainOpen(gitPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
commit, err := gogitrepo.CommitObject(plumbing.NewHash(sha))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
files, err := commit.Files()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rpipe, wpipe := io.Pipe()
|
||||
// Interrupt io.Copy using ctx
|
||||
ch := make(chan int, 1)
|
||||
go func() {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
wpipe.CloseWithError(ctx.Err())
|
||||
case <-ch:
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
defer wpipe.Close()
|
||||
defer close(ch)
|
||||
tw := tar.NewWriter(wpipe)
|
||||
cleanIncludePrefix := path.Clean(includePrefix)
|
||||
wpipe.CloseWithError(files.ForEach(func(f *object.File) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
name := f.Name
|
||||
if strings.HasPrefix(name, cleanIncludePrefix+"/") {
|
||||
name = name[len(cleanIncludePrefix)+1:]
|
||||
} else if cleanIncludePrefix != "." && name != cleanIncludePrefix {
|
||||
return nil
|
||||
}
|
||||
fmode, err := f.Mode.ToOSFileMode()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if fmode&fs.ModeSymlink == fs.ModeSymlink {
|
||||
content, err := f.Contents()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return tw.WriteHeader(&tar.Header{
|
||||
Name: name,
|
||||
Mode: int64(fmode),
|
||||
Linkname: content,
|
||||
})
|
||||
}
|
||||
err = tw.WriteHeader(&tar.Header{
|
||||
Name: name,
|
||||
Mode: int64(fmode),
|
||||
Size: f.Size,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reader, err := f.Reader()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = io.Copy(tw, reader)
|
||||
return err
|
||||
}))
|
||||
}()
|
||||
return rpipe, err
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2023 The nektos/act Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package runner
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.com/gitea/runner/internal/act/common"
|
||||
|
||||
"gitea.dev/actionslib/pkg/model"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func runGit(t *testing.T, dir string, args ...string) {
|
||||
t.Helper()
|
||||
if dir != "" {
|
||||
args = append([]string{"-C", dir}, args...)
|
||||
}
|
||||
cmd := exec.Command("git", args...)
|
||||
// Fixed identity and host-config isolation so commits succeed offline regardless of the
|
||||
// host's git config (mirrors gitCmd in act/common/git).
|
||||
cmd.Env = append(os.Environ(),
|
||||
"GIT_AUTHOR_NAME=test", "[email protected]",
|
||||
"GIT_COMMITTER_NAME=test", "[email protected]",
|
||||
"GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null",
|
||||
)
|
||||
out, err := cmd.CombinedOutput()
|
||||
require.NoError(t, err, string(out))
|
||||
}
|
||||
|
||||
// TestShortShaActionRejected verifies a `uses` ref that is a shortened commit SHA is rejected
|
||||
// with a clear error. The action is resolved from a local repo (via DefaultActionInstance) so
|
||||
// this runs offline.
|
||||
func TestShortShaActionRejected(t *testing.T) {
|
||||
// a local "remote" action repo at <root>/actions/hello-world-docker-action
|
||||
actionRoot := t.TempDir()
|
||||
repo := filepath.Join(actionRoot, "actions", "hello-world-docker-action")
|
||||
require.NoError(t, os.MkdirAll(repo, 0o755))
|
||||
runGit(t, "", "init", "--initial-branch=main", repo)
|
||||
require.NoError(t, os.WriteFile(filepath.Join(repo, "action.yml"),
|
||||
[]byte("name: hello\nruns:\n using: node24\n main: index.js\n"), 0o644))
|
||||
runGit(t, repo, "add", ".")
|
||||
runGit(t, repo, "commit", "-m", "initial")
|
||||
out, err := exec.Command("git", "-C", repo, "rev-parse", "HEAD").Output()
|
||||
require.NoError(t, err)
|
||||
shortSha := strings.TrimSpace(string(out))[:7]
|
||||
|
||||
// a workflow that uses the action at the short SHA
|
||||
wfDir := filepath.Join(t.TempDir(), "wf")
|
||||
require.NoError(t, os.MkdirAll(wfDir, 0o755))
|
||||
wf := fmt.Sprintf("on: push\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/hello-world-docker-action@%s\n", shortSha)
|
||||
require.NoError(t, os.WriteFile(filepath.Join(wfDir, "push.yml"), []byte(wf), 0o644))
|
||||
|
||||
runner, err := New(&Config{
|
||||
Workdir: wfDir,
|
||||
EventName: "push",
|
||||
Platforms: map[string]string{"ubuntu-latest": baseImage},
|
||||
GitHubInstance: "github.com",
|
||||
DefaultActionInstance: actionRoot,
|
||||
ContainerMaxLifetime: time.Hour,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
planner, err := model.NewWorkflowPlanner(wfDir, true)
|
||||
require.NoError(t, err)
|
||||
plan, err := planner.PlanEvent("push")
|
||||
require.NoError(t, err)
|
||||
|
||||
err = runner.NewPlanExecutor(plan)(common.WithDryrun(context.Background(), true))
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "shortened version of a commit SHA")
|
||||
}
|
||||
|
||||
func TestActionCache(t *testing.T) {
|
||||
a := assert.New(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Build a local bare repo with a `js` action dir so this runs offline (formerly cloned
|
||||
// github.com/nektos/act-test-actions over the network). allowAnySHA1InWant lets the
|
||||
// "Fetch Sha" case fetch a commit hash directly.
|
||||
remoteDir := t.TempDir()
|
||||
runGit(t, "", "init", "--bare", "--initial-branch=main", remoteDir)
|
||||
runGit(t, remoteDir, "config", "uploadpack.allowAnySHA1InWant", "true")
|
||||
|
||||
workDir := t.TempDir()
|
||||
runGit(t, "", "clone", remoteDir, workDir)
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(workDir, "js"), 0o755))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(workDir, "js", "action.yml"),
|
||||
[]byte("name: js\nruns:\n using: node24\n main: index.js\n"), 0o644))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(workDir, "js", "index.js"),
|
||||
[]byte("console.log('hello');\n"), 0o644))
|
||||
runGit(t, workDir, "add", ".")
|
||||
runGit(t, workDir, "commit", "-m", "initial")
|
||||
runGit(t, workDir, "push", "-u", "origin", "main")
|
||||
|
||||
out, err := exec.Command("git", "-C", workDir, "rev-parse", "main").Output()
|
||||
require.NoError(t, err)
|
||||
fullSha := strings.TrimSpace(string(out))
|
||||
|
||||
cache := &GoGitActionCache{
|
||||
Path: t.TempDir(),
|
||||
}
|
||||
cacheDir := "local/act-test-actions"
|
||||
refs := []struct {
|
||||
Name string
|
||||
Ref string
|
||||
}{
|
||||
{Name: "Fetch Branch Name", Ref: "main"},
|
||||
{Name: "Fetch Branch Name Absolutely", Ref: "refs/heads/main"},
|
||||
{Name: "Fetch HEAD", Ref: "HEAD"},
|
||||
{Name: "Fetch Sha", Ref: fullSha},
|
||||
}
|
||||
for _, c := range refs {
|
||||
t.Run(c.Name, func(t *testing.T) {
|
||||
sha, err := cache.Fetch(ctx, cacheDir, remoteDir, c.Ref, "")
|
||||
if !a.NoError(err) || !a.NotEmpty(sha) { //nolint:testifylint // pre-existing issue from nektos/act
|
||||
return
|
||||
}
|
||||
atar, err := cache.GetTarArchive(ctx, cacheDir, sha, "js")
|
||||
// NotNil, not NotEmpty: atar is a live io.PipeReader whose producer goroutine is
|
||||
// writing concurrently; NotEmpty deep-reflects over its internals and races.
|
||||
if !a.NoError(err) || !a.NotNil(atar) { //nolint:testifylint // pre-existing issue from nektos/act
|
||||
return
|
||||
}
|
||||
// GetTarArchive streams from a background goroutine walking the shared repo.
|
||||
// Drain and close so it finishes before the next subtest fetches into the same
|
||||
// repo; otherwise the lingering walk races with that fetch.
|
||||
defer func() {
|
||||
_, _ = io.Copy(io.Discard, atar)
|
||||
_ = atar.Close()
|
||||
}()
|
||||
mytar := tar.NewReader(atar)
|
||||
th, err := mytar.Next()
|
||||
if !a.NoError(err) || !a.NotEqual(0, th.Size) { //nolint:testifylint // pre-existing issue from nektos/act
|
||||
return
|
||||
}
|
||||
buf := &bytes.Buffer{}
|
||||
// G110: Potential DoS vulnerability via decompression bomb (gosec)
|
||||
_, err = io.Copy(buf, mytar)
|
||||
a.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
str := buf.String()
|
||||
a.NotEmpty(str)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
// 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"
|
||||
"errors"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gitea.com/gitea/runner/internal/act/common"
|
||||
|
||||
"gitea.dev/actionslib/pkg/model"
|
||||
)
|
||||
|
||||
func evaluateCompositeInputAndEnv(ctx context.Context, parent *RunContext, step actionStep) map[string]string {
|
||||
env := make(map[string]string)
|
||||
stepEnv := *step.getEnv()
|
||||
for k, v := range stepEnv {
|
||||
// do not set current inputs into composite action
|
||||
// the required inputs are added in the second loop
|
||||
if !strings.HasPrefix(k, "INPUT_") {
|
||||
env[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
ee := parent.NewStepExpressionEvaluator(ctx, step)
|
||||
|
||||
for inputID, input := range step.getActionModel().Inputs {
|
||||
envKey := regexp.MustCompile("[^A-Z0-9-]").ReplaceAllString(strings.ToUpper(inputID), "_")
|
||||
envKey = "INPUT_" + strings.ToUpper(envKey)
|
||||
|
||||
// lookup if key is defined in the step but the already
|
||||
// evaluated value from the environment
|
||||
_, defined := step.getStepModel().With[inputID]
|
||||
if value, ok := stepEnv[envKey]; defined && ok {
|
||||
env[envKey] = value
|
||||
} else {
|
||||
// defaults could contain expressions
|
||||
env[envKey] = ee.Interpolate(ctx, input.Default)
|
||||
}
|
||||
}
|
||||
gh := step.getGithubContext(ctx)
|
||||
env["GITHUB_ACTION_REPOSITORY"] = gh.ActionRepository
|
||||
env["GITHUB_ACTION_REF"] = gh.ActionRef
|
||||
|
||||
return env
|
||||
}
|
||||
|
||||
func newCompositeRunContext(ctx context.Context, parent *RunContext, step actionStep, actionPath string) *RunContext {
|
||||
env := evaluateCompositeInputAndEnv(ctx, parent, step)
|
||||
|
||||
// run with the global config but without secrets
|
||||
configCopy := *(parent.Config)
|
||||
configCopy.Secrets = nil
|
||||
|
||||
// create a run context for the composite action to run in
|
||||
compositerc := &RunContext{
|
||||
Name: parent.Name,
|
||||
JobName: parent.JobName,
|
||||
Run: &model.Run{
|
||||
JobID: parent.Run.JobID,
|
||||
Workflow: &model.Workflow{
|
||||
Name: parent.Run.Workflow.Name,
|
||||
Jobs: map[string]*model.Job{
|
||||
parent.Run.JobID: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
Config: &configCopy,
|
||||
StepResults: map[string]*model.StepResult{},
|
||||
JobContainer: parent.JobContainer,
|
||||
ActionPath: actionPath,
|
||||
Env: env,
|
||||
GlobalEnv: parent.GlobalEnv,
|
||||
Masks: parent.Masks,
|
||||
ExtraPath: parent.ExtraPath,
|
||||
Parent: parent,
|
||||
EventJSON: parent.EventJSON,
|
||||
}
|
||||
compositerc.ExprEval = compositerc.NewExpressionEvaluator(ctx)
|
||||
|
||||
return compositerc
|
||||
}
|
||||
|
||||
// appendUniqueMasks appends the masks from src to dst, skipping any mask that
|
||||
// is already present in dst. This prevents the parent RunContext's Masks slice
|
||||
// from growing exponentially when composite actions are nested or repeated,
|
||||
// since each composite RunContext is seeded with its parent's masks.
|
||||
func appendUniqueMasks(dst, src []string) []string {
|
||||
for _, m := range src {
|
||||
if !slices.Contains(dst, m) {
|
||||
dst = append(dst, m)
|
||||
}
|
||||
}
|
||||
return dst
|
||||
}
|
||||
|
||||
func execAsComposite(step actionStep) common.Executor {
|
||||
rc := step.getRunContext()
|
||||
action := step.getActionModel()
|
||||
|
||||
return func(ctx context.Context) error {
|
||||
compositeRC := step.getCompositeRunContext(ctx)
|
||||
|
||||
steps := step.getCompositeSteps()
|
||||
|
||||
if steps == nil || steps.main == nil {
|
||||
return errors.New("missing steps in composite action")
|
||||
}
|
||||
|
||||
ctx = WithCompositeLogger(ctx, &compositeRC.Masks)
|
||||
|
||||
err := steps.main(ctx)
|
||||
|
||||
// Map outputs from composite RunContext to job RunContext
|
||||
eval := compositeRC.NewExpressionEvaluator(ctx)
|
||||
for outputName, output := range action.Outputs {
|
||||
rc.setOutput(ctx, map[string]string{
|
||||
"name": outputName,
|
||||
}, eval.Interpolate(ctx, output.Value))
|
||||
}
|
||||
|
||||
// compositeRC.Masks is seeded with rc.Masks (see newCompositeRunContext)
|
||||
// and may have additional masks appended while the composite action runs.
|
||||
// Only append masks that are not already present, otherwise nested or
|
||||
// repeated composite actions grow rc.Masks exponentially.
|
||||
rc.Masks = appendUniqueMasks(rc.Masks, compositeRC.Masks)
|
||||
rc.ExtraPath = compositeRC.ExtraPath
|
||||
// compositeRC.Env is dirty, contains INPUT_ and merged step env, only rely on compositeRC.GlobalEnv
|
||||
mergeIntoMap := mergeIntoMapCaseSensitive
|
||||
if rc.JobContainer.IsEnvironmentCaseInsensitive() {
|
||||
mergeIntoMap = mergeIntoMapCaseInsensitive
|
||||
}
|
||||
if rc.GlobalEnv == nil {
|
||||
rc.GlobalEnv = map[string]string{}
|
||||
}
|
||||
mergeIntoMap(rc.GlobalEnv, compositeRC.GlobalEnv)
|
||||
mergeIntoMap(rc.Env, compositeRC.GlobalEnv)
|
||||
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
type compositeSteps struct {
|
||||
pre common.Executor
|
||||
main common.Executor
|
||||
post common.Executor
|
||||
}
|
||||
|
||||
// Executor returns a pipeline executor for all the steps in the job
|
||||
func (rc *RunContext) compositeExecutor(action *model.Action) *compositeSteps {
|
||||
steps := make([]common.Executor, 0)
|
||||
preSteps := make([]common.Executor, 0)
|
||||
var postExecutor common.Executor
|
||||
|
||||
sf := &stepFactoryImpl{}
|
||||
|
||||
for i, step := range action.Runs.Steps {
|
||||
if step.ID == "" {
|
||||
step.ID = strconv.Itoa(i)
|
||||
}
|
||||
step.Number = i
|
||||
|
||||
// create a copy of the step, since this composite action could
|
||||
// run multiple times and we might modify the instance
|
||||
stepcopy := step
|
||||
|
||||
step, err := sf.newStep(&stepcopy, rc)
|
||||
if err != nil {
|
||||
return &compositeSteps{
|
||||
main: common.NewErrorExecutor(err),
|
||||
}
|
||||
}
|
||||
|
||||
stepID := step.getStepModel().ID
|
||||
stepPre := rc.newCompositeCommandExecutor(step.pre())
|
||||
preSteps = append(preSteps, newCompositeStepLogExecutor(stepPre, stepID))
|
||||
|
||||
steps = append(steps, func(ctx context.Context) error {
|
||||
ctx = WithCompositeStepLogger(ctx, stepID)
|
||||
logger := common.Logger(ctx)
|
||||
err := rc.newCompositeCommandExecutor(step.main())(ctx)
|
||||
|
||||
if err != nil {
|
||||
logger.Errorf("##[error]%s", EscapeCommandData(err.Error()))
|
||||
common.SetJobError(ctx, err)
|
||||
} else if ctx.Err() != nil {
|
||||
logger.Errorf("##[error]%s", EscapeCommandData(ctx.Err().Error()))
|
||||
common.SetJobError(ctx, ctx.Err())
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
// run the post executor in reverse order
|
||||
if postExecutor != nil {
|
||||
stepPost := rc.newCompositeCommandExecutor(step.post())
|
||||
postExecutor = newCompositeStepLogExecutor(stepPost.Finally(postExecutor), stepID)
|
||||
} else {
|
||||
stepPost := rc.newCompositeCommandExecutor(step.post())
|
||||
postExecutor = newCompositeStepLogExecutor(stepPost, stepID)
|
||||
}
|
||||
}
|
||||
|
||||
steps = append(steps, common.JobError)
|
||||
return &compositeSteps{
|
||||
pre: func(ctx context.Context) error {
|
||||
return common.NewPipelineExecutor(preSteps...)(common.WithJobErrorContainer(ctx))
|
||||
},
|
||||
main: func(ctx context.Context) error {
|
||||
return common.NewPipelineExecutor(steps...)(common.WithJobErrorContainer(ctx))
|
||||
},
|
||||
post: postExecutor,
|
||||
}
|
||||
}
|
||||
|
||||
func (rc *RunContext) newCompositeCommandExecutor(executor common.Executor) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
ctx = WithCompositeLogger(ctx, &rc.Masks)
|
||||
|
||||
// We need to inject a composite RunContext related command
|
||||
// handler into the current running job container
|
||||
// We need this, to support scoping commands to the composite action
|
||||
// executing.
|
||||
rawLogger := common.Logger(ctx).WithField("raw_output", true)
|
||||
logWriter := common.NewLineWriter(rc.commandHandler(ctx), func(s string) bool {
|
||||
if rc.Config.LogOutput {
|
||||
rawLogger.Infof("%s", s)
|
||||
} else {
|
||||
rawLogger.Debugf("%s", s)
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
oldout, olderr := rc.JobContainer.ReplaceLogWriter(logWriter, logWriter)
|
||||
defer rc.JobContainer.ReplaceLogWriter(oldout, olderr)
|
||||
|
||||
return executor(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
func newCompositeStepLogExecutor(runStep common.Executor, stepID string) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
ctx = WithCompositeStepLogger(ctx, stepID)
|
||||
logger := common.Logger(ctx)
|
||||
err := runStep(ctx)
|
||||
if err != nil {
|
||||
logger.Errorf("##[error]%s", EscapeCommandData(err.Error()))
|
||||
common.SetJobError(ctx, err)
|
||||
} else if ctx.Err() != nil {
|
||||
logger.Errorf("##[error]%s", EscapeCommandData(ctx.Err().Error()))
|
||||
common.SetJobError(ctx, ctx.Err())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package runner
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestAppendUniqueMasks(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
dst []string
|
||||
src []string
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "appends new masks",
|
||||
dst: []string{"a"},
|
||||
src: []string{"b", "c"},
|
||||
want: []string{"a", "b", "c"},
|
||||
},
|
||||
{
|
||||
name: "skips masks already present",
|
||||
dst: []string{"a", "b"},
|
||||
src: []string{"a", "b"},
|
||||
want: []string{"a", "b"},
|
||||
},
|
||||
{
|
||||
name: "deduplicates within src",
|
||||
dst: []string{"a"},
|
||||
src: []string{"b", "b", "a"},
|
||||
want: []string{"a", "b"},
|
||||
},
|
||||
{
|
||||
name: "empty src leaves dst unchanged",
|
||||
dst: []string{"a"},
|
||||
src: nil,
|
||||
want: []string{"a"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.want, appendUniqueMasks(tt.dst, tt.src))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppendUniqueMasksNoExponentialGrowth reproduces the exponential growth of
|
||||
// the parent's Masks slice observed with nested/repeated composite actions. A
|
||||
// composite RunContext is seeded with its parent's masks and the whole seeded
|
||||
// slice was previously appended back into the parent, doubling its length on
|
||||
// every composite action.
|
||||
func TestAppendUniqueMasksNoExponentialGrowth(t *testing.T) {
|
||||
parentMasks := []string{"secret"}
|
||||
|
||||
for range 20 {
|
||||
// compositeRC.Masks starts as a copy of the parent's masks (it is
|
||||
// seeded with parent.Masks in newCompositeRunContext).
|
||||
compositeMasks := make([]string, len(parentMasks))
|
||||
copy(compositeMasks, parentMasks)
|
||||
|
||||
parentMasks = appendUniqueMasks(parentMasks, compositeMasks)
|
||||
}
|
||||
|
||||
assert.Equal(t, []string{"secret"}, parentMasks)
|
||||
}
|
||||
@@ -0,0 +1,626 @@
|
||||
// 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"
|
||||
"io"
|
||||
"io/fs"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.com/gitea/runner/internal/act/common"
|
||||
"gitea.com/gitea/runner/internal/act/common/git"
|
||||
"gitea.com/gitea/runner/internal/act/container"
|
||||
|
||||
"gitea.dev/actionslib/pkg/model"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type closerMock struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *closerMock) Close() error {
|
||||
m.Called()
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestActionReader(t *testing.T) {
|
||||
yaml := strings.ReplaceAll(`
|
||||
name: 'name'
|
||||
runs:
|
||||
using: 'node16'
|
||||
main: 'main.js'
|
||||
`, "\t", " ")
|
||||
|
||||
table := []struct {
|
||||
name string
|
||||
step *model.Step
|
||||
filename string
|
||||
fileContent string
|
||||
expected *model.Action
|
||||
}{
|
||||
{
|
||||
name: "readActionYml",
|
||||
step: &model.Step{},
|
||||
filename: "action.yml",
|
||||
fileContent: yaml,
|
||||
expected: &model.Action{
|
||||
Name: "name",
|
||||
Runs: model.ActionRuns{
|
||||
Using: "node16",
|
||||
Main: "main.js",
|
||||
PreIf: "always()",
|
||||
PostIf: "always()",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "readActionYaml",
|
||||
step: &model.Step{},
|
||||
filename: "action.yaml",
|
||||
fileContent: yaml,
|
||||
expected: &model.Action{
|
||||
Name: "name",
|
||||
Runs: model.ActionRuns{
|
||||
Using: "node16",
|
||||
Main: "main.js",
|
||||
PreIf: "always()",
|
||||
PostIf: "always()",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "readDockerfile",
|
||||
step: &model.Step{},
|
||||
filename: "Dockerfile",
|
||||
fileContent: "FROM ubuntu:20.04",
|
||||
expected: &model.Action{
|
||||
Name: "(Synthetic)",
|
||||
Runs: model.ActionRuns{
|
||||
Using: "docker",
|
||||
Image: "Dockerfile",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "readWithArgs",
|
||||
step: &model.Step{
|
||||
With: map[string]string{
|
||||
"args": "cmd",
|
||||
},
|
||||
},
|
||||
expected: &model.Action{
|
||||
Name: "(Synthetic)",
|
||||
Inputs: map[string]model.Input{
|
||||
"cwd": {
|
||||
Description: "(Actual working directory)",
|
||||
Required: false,
|
||||
Default: "actionDir/actionPath",
|
||||
},
|
||||
"command": {
|
||||
Description: "(Actual program)",
|
||||
Required: false,
|
||||
Default: "cmd",
|
||||
},
|
||||
},
|
||||
Runs: model.ActionRuns{
|
||||
Using: "node12",
|
||||
Main: "trampoline.js",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range table {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
closerMock := &closerMock{}
|
||||
|
||||
readFile := func(filename string) (io.Reader, io.Closer, error) {
|
||||
if tt.filename != filename {
|
||||
return nil, nil, fs.ErrNotExist
|
||||
}
|
||||
|
||||
return strings.NewReader(tt.fileContent), closerMock, nil
|
||||
}
|
||||
|
||||
writeFile := func(filename string, data []byte, perm fs.FileMode) error {
|
||||
assert.Equal(t, "actionDir/actionPath/trampoline.js", filename)
|
||||
assert.Equal(t, fs.FileMode(0o400), perm)
|
||||
return nil
|
||||
}
|
||||
|
||||
if tt.filename != "" {
|
||||
closerMock.On("Close")
|
||||
}
|
||||
|
||||
action, err := readActionImpl(context.Background(), tt.step, "actionDir", "actionPath", readFile, writeFile)
|
||||
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.Equal(t, tt.expected, action)
|
||||
|
||||
closerMock.AssertExpectations(t)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// With AutoRemove the daemon reaps the container on exit, so act must not remove it afterwards.
|
||||
func TestExecAsDockerAutoRemove(t *testing.T) {
|
||||
orig := ContainerNewContainer
|
||||
defer func() { ContainerNewContainer = orig }()
|
||||
|
||||
for _, tc := range []struct {
|
||||
autoRemove bool
|
||||
removes int
|
||||
}{
|
||||
{false, 2}, // stale + post-run
|
||||
{true, 1}, // post-run skipped
|
||||
} {
|
||||
cm := &containerMock{}
|
||||
ContainerNewContainer = func(*container.NewContainerInput) container.ExecutionsEnvironment { return cm }
|
||||
|
||||
step := &stepActionRemote{
|
||||
Step: &model.Step{ID: "1", Uses: "org/action@v1"},
|
||||
RunContext: &RunContext{
|
||||
Config: &Config{AutoRemove: tc.autoRemove},
|
||||
Run: &model.Run{JobID: "1", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"1": {}}}},
|
||||
JobContainer: cm,
|
||||
},
|
||||
action: &model.Action{Runs: model.ActionRuns{Using: "docker", Image: "docker://node:14"}},
|
||||
}
|
||||
|
||||
removes := 0
|
||||
cm.On("Pull", false).Return(func(context.Context) error { return nil })
|
||||
cm.On("Remove").Return(func(context.Context) error { removes++; return nil })
|
||||
cm.On("Create", []string(nil), []string(nil)).Return(func(context.Context) error { return nil })
|
||||
cm.On("Start", true).Return(func(context.Context) error { return nil })
|
||||
cm.On("Close").Return(func(context.Context) error { return nil })
|
||||
|
||||
require.NoError(t, execAsDocker(context.Background(), step, "action", t.TempDir(), t.TempDir(), false, stepStageMain))
|
||||
cm.AssertExpectations(t)
|
||||
assert.Equal(t, tc.removes, removes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionRunner(t *testing.T) {
|
||||
table := []struct {
|
||||
name string
|
||||
step actionStep
|
||||
expectedEnv map[string]string
|
||||
}{
|
||||
{
|
||||
name: "with-input",
|
||||
step: &stepActionRemote{
|
||||
Step: &model.Step{
|
||||
Uses: "org/repo/path@ref",
|
||||
},
|
||||
RunContext: &RunContext{
|
||||
Config: &Config{},
|
||||
Run: &model.Run{
|
||||
JobID: "job",
|
||||
Workflow: &model.Workflow{
|
||||
Jobs: map[string]*model.Job{
|
||||
"job": {
|
||||
Name: "job",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
action: &model.Action{
|
||||
Inputs: map[string]model.Input{
|
||||
"key": {
|
||||
Default: "default value",
|
||||
},
|
||||
},
|
||||
Runs: model.ActionRuns{
|
||||
Using: "node16",
|
||||
},
|
||||
},
|
||||
env: map[string]string{},
|
||||
},
|
||||
expectedEnv: map[string]string{"INPUT_KEY": "default value"},
|
||||
},
|
||||
{
|
||||
name: "restore-saved-state",
|
||||
step: &stepActionRemote{
|
||||
Step: &model.Step{
|
||||
ID: "step",
|
||||
Uses: "org/repo/path@ref",
|
||||
},
|
||||
RunContext: &RunContext{
|
||||
ActionPath: "path",
|
||||
Config: &Config{},
|
||||
Run: &model.Run{
|
||||
JobID: "job",
|
||||
Workflow: &model.Workflow{
|
||||
Jobs: map[string]*model.Job{
|
||||
"job": {
|
||||
Name: "job",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
CurrentStep: "post-step",
|
||||
StepResults: map[string]*model.StepResult{
|
||||
"step": {},
|
||||
},
|
||||
IntraActionState: map[string]map[string]string{
|
||||
"step": {
|
||||
"name": "state value",
|
||||
},
|
||||
},
|
||||
},
|
||||
action: &model.Action{
|
||||
Runs: model.ActionRuns{
|
||||
Using: "node16",
|
||||
},
|
||||
},
|
||||
env: map[string]string{},
|
||||
},
|
||||
expectedEnv: map[string]string{"STATE_name": "state value"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range table {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
cm := &containerMock{}
|
||||
cm.On("CopyDir", "/var/run/act/actions/dir/", "dir/", false).Return(func(ctx context.Context) error { return nil })
|
||||
|
||||
envMatcher := mock.MatchedBy(func(env map[string]string) bool {
|
||||
for k, v := range tt.expectedEnv {
|
||||
if env[k] != v {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
cm.On("Exec", []string{"node", "/var/run/act/actions/dir/path"}, envMatcher, "", "").Return(func(ctx context.Context) error { return nil })
|
||||
|
||||
tt.step.getRunContext().JobContainer = cm
|
||||
|
||||
err := runActionImpl(tt.step, "dir", newRemoteAction("org/repo/path@ref"))(ctx)
|
||||
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
cm.AssertExpectations(t)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewStepContainerDoesNotUseDockerSecrets(t *testing.T) {
|
||||
cm := &containerMock{}
|
||||
|
||||
var captured *container.NewContainerInput
|
||||
origContainerNewContainer := ContainerNewContainer
|
||||
ContainerNewContainer = func(input *container.NewContainerInput) container.ExecutionsEnvironment {
|
||||
captured = input
|
||||
return cm
|
||||
}
|
||||
defer func() {
|
||||
ContainerNewContainer = origContainerNewContainer
|
||||
}()
|
||||
|
||||
ctx := context.Background()
|
||||
rc := &RunContext{
|
||||
Name: "job",
|
||||
Config: &Config{
|
||||
Secrets: map[string]string{
|
||||
"DOCKER_USERNAME": "docker-user",
|
||||
"DOCKER_PASSWORD": "docker-password",
|
||||
},
|
||||
},
|
||||
Run: &model.Run{
|
||||
JobID: "job",
|
||||
Workflow: &model.Workflow{
|
||||
Name: "test",
|
||||
Jobs: map[string]*model.Job{
|
||||
"job": {},
|
||||
},
|
||||
},
|
||||
},
|
||||
JobContainer: cm,
|
||||
StepResults: map[string]*model.StepResult{},
|
||||
}
|
||||
env := map[string]string{}
|
||||
step := &stepMock{}
|
||||
step.On("getRunContext").Return(rc)
|
||||
step.On("getStepModel").Return(&model.Step{ID: "action"})
|
||||
step.On("getEnv").Return(&env)
|
||||
|
||||
_ = newStepContainer(ctx, step, "registry.example.com/action:tag", nil, nil)
|
||||
|
||||
// DOCKER_USERNAME/DOCKER_PASSWORD should not be injected as pull credentials for docker action containers.
|
||||
assert.Empty(t, captured.Username)
|
||||
assert.Empty(t, captured.Password)
|
||||
step.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestMaybeCopyToActionDirHoldsCloneLock(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
actionDir := t.TempDir()
|
||||
|
||||
releaseCopy := make(chan struct{})
|
||||
release := sync.OnceFunc(func() { close(releaseCopy) })
|
||||
defer release()
|
||||
|
||||
copyEntered := make(chan struct{})
|
||||
|
||||
cm := &containerMock{}
|
||||
cm.On("CopyDir", "/var/run/act/actions/", actionDir+"/", false).Return(func(ctx context.Context) error {
|
||||
close(copyEntered)
|
||||
<-releaseCopy
|
||||
return nil
|
||||
})
|
||||
|
||||
step := &stepActionRemote{
|
||||
Step: &model.Step{Uses: "remote/action@v1"},
|
||||
RunContext: &RunContext{
|
||||
Config: &Config{},
|
||||
JobContainer: cm,
|
||||
},
|
||||
}
|
||||
|
||||
copyDone := make(chan error, 1)
|
||||
go func() {
|
||||
copyDone <- maybeCopyToActionDir(ctx, step, actionDir, "", "/var/run/act/actions/")
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-copyEntered:
|
||||
case err := <-copyDone:
|
||||
t.Fatalf("maybeCopyToActionDir returned before CopyDir was entered: %v", err)
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("CopyDir was not entered within 1 second")
|
||||
}
|
||||
|
||||
peerAcquired := make(chan struct{})
|
||||
go func() {
|
||||
unlock := git.AcquireCloneLock(actionDir)
|
||||
close(peerAcquired)
|
||||
unlock()
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-peerAcquired:
|
||||
t.Fatal("peer AcquireCloneLock returned while CopyDir was running")
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
}
|
||||
|
||||
release()
|
||||
|
||||
select {
|
||||
case err := <-copyDone:
|
||||
if err != nil {
|
||||
t.Fatalf("maybeCopyToActionDir returned error: %v", err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("maybeCopyToActionDir did not return after CopyDir was unblocked")
|
||||
}
|
||||
|
||||
select {
|
||||
case <-peerAcquired:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("peer AcquireCloneLock did not proceed after lock released")
|
||||
}
|
||||
|
||||
cm.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestExecAsDockerHoldsCloneLockForRemoteUncached(t *testing.T) {
|
||||
actionDir := t.TempDir()
|
||||
|
||||
unlockOnce := sync.OnceFunc(git.AcquireCloneLock(actionDir))
|
||||
defer unlockOnce()
|
||||
|
||||
innerEntered := make(chan struct{})
|
||||
releaseInner := make(chan struct{})
|
||||
releaseOnce := sync.OnceFunc(func() { close(releaseInner) })
|
||||
defer releaseOnce()
|
||||
|
||||
origImageExists := ContainerImageExistsLocally
|
||||
ContainerImageExistsLocally = func(_ context.Context, _, _ string) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
defer func() { ContainerImageExistsLocally = origImageExists }()
|
||||
|
||||
origBuildExec := ContainerNewDockerBuildExecutor
|
||||
ContainerNewDockerBuildExecutor = func(_ container.NewDockerBuildExecutorInput) common.Executor {
|
||||
return func(_ context.Context) error {
|
||||
close(innerEntered)
|
||||
<-releaseInner
|
||||
return nil
|
||||
}
|
||||
}
|
||||
defer func() { ContainerNewDockerBuildExecutor = origBuildExec }()
|
||||
|
||||
step := &stepActionRemote{
|
||||
Step: &model.Step{ID: "1", Uses: "remote/action@v1", With: map[string]string{}},
|
||||
RunContext: &RunContext{
|
||||
Config: &Config{},
|
||||
Run: &model.Run{
|
||||
JobID: "1",
|
||||
Workflow: &model.Workflow{
|
||||
Name: "wf",
|
||||
Jobs: map[string]*model.Job{"1": {}},
|
||||
},
|
||||
},
|
||||
JobContainer: &containerMock{},
|
||||
},
|
||||
action: &model.Action{Runs: model.ActionRuns{Using: "docker", Image: "Dockerfile"}},
|
||||
env: map[string]string{},
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- execAsDocker(ctx, step, "test-action", actionDir, actionDir, false, stepStageMain) }()
|
||||
|
||||
select {
|
||||
case <-innerEntered:
|
||||
t.Fatal("inner build executor ran before clone lock was released")
|
||||
case err := <-done:
|
||||
t.Fatalf("execAsDocker returned before inner was entered: %v", err)
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
}
|
||||
|
||||
unlockOnce()
|
||||
|
||||
select {
|
||||
case <-innerEntered:
|
||||
case err := <-done:
|
||||
t.Fatalf("execAsDocker returned without entering inner: %v", err)
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("inner build executor not entered after lock released")
|
||||
}
|
||||
|
||||
cancel()
|
||||
releaseOnce()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("execAsDocker did not return after inner was released and ctx was canceled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDockerActionImageTag(t *testing.T) {
|
||||
// Remote actions already carry a unique, ref-scoped actionName (the uses
|
||||
// hash), so the tag must be left untouched for backwards compatibility.
|
||||
assert.Equal(t,
|
||||
"act-abc123-dockeraction:latest",
|
||||
dockerActionImageTag("owner/repo", "abc123", false),
|
||||
)
|
||||
|
||||
// Local actions keep a human-readable, repository-namespaced prefix and gain a short hash suffix that makes the tag unique per (repository, actionName).
|
||||
// See https://gitea.com/gitea/runner/issues/1039.
|
||||
assert.Equal(t,
|
||||
"act-owner-repo-baca2daaa2fe-dockeraction:latest",
|
||||
dockerActionImageTag("owner/repo", "./", true),
|
||||
)
|
||||
assert.Equal(t,
|
||||
"act-owner-repo-sub-e847b61255a8-dockeraction:latest",
|
||||
dockerActionImageTag("owner/repo", "./sub", true),
|
||||
)
|
||||
|
||||
// Sanitizing every non-alphanumeric character to "-" is lossy, so distinct inputs can collapse to the same readable prefix.
|
||||
// The hash suffix must keep such cases apart, otherwise an image built for one repository is reused for another.
|
||||
collisions := [][2]struct {
|
||||
repoName string
|
||||
actionName string
|
||||
}{
|
||||
// Two different repositories, both `uses: ./`: "a/b-c" and "a-b/c" both sanitize to "a-b-c".
|
||||
{{"a/b-c", "./"}, {"a-b/c", "./"}},
|
||||
// A repository's root action vs another repository's sub-path action:
|
||||
// "owner/repo-a" + "./" and "owner/repo" + "./a" both sanitize to "owner-repo-a".
|
||||
{{"owner/repo-a", "./"}, {"owner/repo", "./a"}},
|
||||
}
|
||||
for _, c := range collisions {
|
||||
assert.NotEqual(t,
|
||||
dockerActionImageTag(c[0].repoName, c[0].actionName, true),
|
||||
dockerActionImageTag(c[1].repoName, c[1].actionName, true),
|
||||
"local docker action tags must differ for %q/%q vs %q/%q",
|
||||
c[0].repoName, c[0].actionName, c[1].repoName, c[1].actionName,
|
||||
)
|
||||
}
|
||||
|
||||
// Distinct local actions within the same repository keep distinct tags.
|
||||
assert.NotEqual(t,
|
||||
dockerActionImageTag("owner/repo", "./", true),
|
||||
dockerActionImageTag("owner/repo", "./sub", true),
|
||||
)
|
||||
}
|
||||
|
||||
// Only the entrypoint is stage specific: every stage of a docker action receives runs.args
|
||||
// and runs.env, and the `entrypoint` input applies to the main stage alone.
|
||||
func TestExecAsDockerStageEntrypoint(t *testing.T) {
|
||||
orig := ContainerNewContainer
|
||||
defer func() { ContainerNewContainer = orig }()
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
stage stepStage
|
||||
wantEntrypoint []string
|
||||
}{
|
||||
{
|
||||
name: "main stage prefers the entrypoint input",
|
||||
stage: stepStageMain,
|
||||
wantEntrypoint: []string{"input.sh"},
|
||||
},
|
||||
{
|
||||
name: "pre stage uses runs.pre-entrypoint",
|
||||
stage: stepStagePre,
|
||||
wantEntrypoint: []string{"pre.sh", "--verbose"},
|
||||
},
|
||||
{
|
||||
name: "post stage uses runs.post-entrypoint",
|
||||
stage: stepStagePost,
|
||||
wantEntrypoint: []string{"post.sh"},
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cm := &containerMock{}
|
||||
var input *container.NewContainerInput
|
||||
ContainerNewContainer = func(in *container.NewContainerInput) container.ExecutionsEnvironment {
|
||||
input = in
|
||||
return cm
|
||||
}
|
||||
|
||||
step := &stepActionRemote{
|
||||
Step: &model.Step{ID: "1", Uses: "org/action@v1", With: map[string]string{"entrypoint": "input.sh"}},
|
||||
RunContext: &RunContext{
|
||||
Config: &Config{},
|
||||
Run: &model.Run{JobID: "1", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"1": {}}}},
|
||||
JobContainer: cm,
|
||||
},
|
||||
action: &model.Action{Runs: model.ActionRuns{
|
||||
Using: "docker",
|
||||
Image: "docker://node:14",
|
||||
PreEntrypoint: "pre.sh --verbose",
|
||||
Entrypoint: "main.sh",
|
||||
PostEntrypoint: "post.sh",
|
||||
Args: []string{"hello"},
|
||||
Env: map[string]string{"MY_VAR": "world"},
|
||||
}},
|
||||
env: map[string]string{},
|
||||
}
|
||||
|
||||
cm.On("Pull", false).Return(func(context.Context) error { return nil })
|
||||
cm.On("Remove").Return(func(context.Context) error { return nil })
|
||||
cm.On("Create", []string(nil), []string(nil)).Return(func(context.Context) error { return nil })
|
||||
cm.On("Start", true).Return(func(context.Context) error { return nil })
|
||||
cm.On("Close").Return(func(context.Context) error { return nil })
|
||||
|
||||
require.NoError(t, execAsDocker(context.Background(), step, "action", t.TempDir(), t.TempDir(), false, tc.stage))
|
||||
require.NotNil(t, input)
|
||||
assert.Equal(t, tc.wantEntrypoint, input.Entrypoint)
|
||||
assert.Equal(t, []string{"hello"}, input.Cmd)
|
||||
assert.Contains(t, input.Env, "MY_VAR=world")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDockerActionHasPreAndPostStep(t *testing.T) {
|
||||
newStep := func(runs model.ActionRuns) actionStep {
|
||||
return &stepActionRemote{action: &model.Action{Runs: runs}}
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
assert.False(t, hasPreStep(newStep(model.ActionRuns{Using: "docker", Image: "Dockerfile"}))(ctx))
|
||||
assert.False(t, hasPostStep(newStep(model.ActionRuns{Using: "docker", Image: "Dockerfile"}))(ctx))
|
||||
|
||||
withStages := model.ActionRuns{Using: "docker", Image: "Dockerfile", PreEntrypoint: "pre.sh", PostEntrypoint: "post.sh"}
|
||||
assert.True(t, hasPreStep(newStep(withStages))(ctx))
|
||||
assert.True(t, hasPostStep(newStep(withStages))(ctx))
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package runner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.com/gitea/runner/internal/act/common"
|
||||
|
||||
"gitea.dev/actionslib/pkg/exprparser"
|
||||
"gitea.dev/actionslib/pkg/model"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
// TestCancelledJobStatusEnablesAlwaysAndCancelledSteps verifies that once a job is
|
||||
// cancelled, getJobContext reports the "cancelled" status so the step `if` functions
|
||||
// evaluate the way GitHub Actions does: cancelled()/always() are true, success()/failure()
|
||||
// are false. A step that defaults to success() is therefore skipped while an always() step
|
||||
// still runs. Before the fix the status could only ever be success/failure, so cancelled()
|
||||
// was structurally impossible and cancel-only cleanup steps never ran.
|
||||
func TestCancelledJobStatusEnablesAlwaysAndCancelledSteps(t *testing.T) {
|
||||
rc := createIfTestRunContext(map[string]*model.Job{
|
||||
"job1": createJob(t, `runs-on: ubuntu-latest`, ""),
|
||||
})
|
||||
rc.markCancelled()
|
||||
|
||||
// The core fix: the job status context now reports "cancelled" instead of being
|
||||
// pinned to success/failure.
|
||||
jobCtx := rc.getJobContext()
|
||||
require.Equal(t, "cancelled", jobCtx.Status)
|
||||
|
||||
// Feed that status through the step-context expression functions, which is what a
|
||||
// step `if` evaluates. On a cancelled job only always()/cancelled() are true.
|
||||
interp := exprparser.NewInterpeter(
|
||||
&exprparser.EvaluationEnvironment{Job: jobCtx},
|
||||
exprparser.Config{Context: "step"},
|
||||
)
|
||||
for expr, want := range map[string]bool{
|
||||
"cancelled()": true,
|
||||
"always()": true,
|
||||
"success()": false,
|
||||
"failure()": false,
|
||||
"!cancelled()": false,
|
||||
} {
|
||||
got, err := interp.Evaluate(expr, exprparser.DefaultStatusCheckNone)
|
||||
require.NoErrorf(t, err, "Evaluate(%q)", expr)
|
||||
assert.Equalf(t, want, got, "Evaluate(%q) on a cancelled job", expr)
|
||||
}
|
||||
|
||||
// A step without an `if` defaults to success() and must be skipped on cancel,
|
||||
// while an `if: always()` step must still run.
|
||||
disabled, err := interp.Evaluate("", exprparser.DefaultStatusCheckSuccess)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, false, disabled, "default-success step must be skipped on a cancelled job")
|
||||
|
||||
enabled, err := interp.Evaluate("always()", exprparser.DefaultStatusCheckSuccess)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, true, enabled, "`if: always()` step must run on a cancelled job")
|
||||
}
|
||||
|
||||
// TestMainStepsExecutorRunsAlwaysStepsAfterCancel verifies that newMainStepsExecutor does
|
||||
// not abandon the remaining steps when the run is cancelled mid-pipeline. The later step
|
||||
// still runs (so a main-stage always() step is reached), it runs under a fresh,
|
||||
// non-cancelled context, and the job is marked cancelled. The interrupt error is still
|
||||
// propagated so callers up the chain see the cancellation.
|
||||
func TestMainStepsExecutorRunsAlwaysStepsAfterCancel(t *testing.T) {
|
||||
rc := createIfTestRunContext(map[string]*model.Job{
|
||||
"job1": createJob(t, `runs-on: ubuntu-latest`, ""),
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
var ran []string
|
||||
var laterStepCtxErr error
|
||||
steps := []common.Executor{
|
||||
func(_ context.Context) error {
|
||||
ran = append(ran, "step1")
|
||||
cancel() // server cancellation lands while step1 runs
|
||||
return nil
|
||||
},
|
||||
func(c context.Context) error {
|
||||
ran = append(ran, "always-step")
|
||||
laterStepCtxErr = c.Err()
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
err := newMainStepsExecutor(rc, steps)(ctx)
|
||||
|
||||
require.ErrorIs(t, err, context.Canceled, "interrupt error is propagated")
|
||||
assert.Equal(t, []string{"step1", "always-step"}, ran, "the always() step still runs after cancel")
|
||||
require.NoError(t, laterStepCtxErr, "remaining steps run under a fresh, non-cancelled context")
|
||||
assert.True(t, rc.jobCancelled, "the job is marked cancelled")
|
||||
}
|
||||
|
||||
// TestMainStepsExecutorMarksFailedOnTimeoutBetweenSteps guards the timeout path's symmetry with the cancel path.
|
||||
// When the job deadline (timeout-minutes) lands in the gap between two steps, the job must be marked as failed (not cancelled),
|
||||
// so always()/failure() cleanup steps run while default success() steps skip, and so the timed-out job is not reported as success.
|
||||
func TestMainStepsExecutorMarksFailedOnTimeoutBetweenSteps(t *testing.T) {
|
||||
rc := createIfTestRunContext(map[string]*model.Job{
|
||||
"job1": createJob(t, `runs-on: ubuntu-latest`, ""),
|
||||
})
|
||||
|
||||
// A short deadline that we let elapse between steps, so no step records the error itself.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
var ran []string
|
||||
var laterStepCtxErr error
|
||||
steps := []common.Executor{
|
||||
func(c context.Context) error {
|
||||
ran = append(ran, "step1")
|
||||
// Block until the job deadline elapses, then return cleanly: the interrupt lands in the loop's between-steps check, not inside a step.
|
||||
<-c.Done()
|
||||
return nil
|
||||
},
|
||||
func(c context.Context) error {
|
||||
ran = append(ran, "always-step")
|
||||
laterStepCtxErr = c.Err()
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
err := newMainStepsExecutor(rc, steps)(ctx)
|
||||
|
||||
require.ErrorIs(t, err, context.DeadlineExceeded, "the timeout error is propagated")
|
||||
assert.Equal(t, []string{"step1", "always-step"}, ran, "the always() step still runs after a timeout")
|
||||
require.NoError(t, laterStepCtxErr, "remaining steps run under a fresh, non-expired context")
|
||||
assert.True(t, rc.jobFailed, "a job timeout marks the job failed")
|
||||
assert.False(t, rc.jobCancelled, "a timeout is not a cancellation")
|
||||
|
||||
// The status the real main-step `if` evaluation sees: "failure", so default success() steps skip while always()/failure() steps run.
|
||||
assert.Equal(t, "failure", rc.getJobContext().Status)
|
||||
}
|
||||
|
||||
// TestStepsExecutorRunsMainStepsAfterPreCancel verifies that a cancellation landing during the
|
||||
// pre phase does not abandon the main steps: newStepsExecutor still runs the main-steps executor,
|
||||
// so a main-stage always()/cancelled() step is reached (under a fresh, non-cancelled context),
|
||||
// the job is marked cancelled, and the cancellation is propagated. Before the fix the `.Then(...)`
|
||||
// short-circuit skipped the main steps entirely when a pre step was cancelled.
|
||||
func TestStepsExecutorRunsMainStepsAfterPreCancel(t *testing.T) {
|
||||
rc := createIfTestRunContext(map[string]*model.Job{
|
||||
"job1": createJob(t, `runs-on: ubuntu-latest`, ""),
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
var ran []string
|
||||
var mainStepCtxErr error
|
||||
preSteps := []common.Executor{
|
||||
func(_ context.Context) error {
|
||||
ran = append(ran, "pre1")
|
||||
cancel() // server cancellation lands during the pre phase
|
||||
return nil
|
||||
},
|
||||
}
|
||||
steps := []common.Executor{
|
||||
func(c context.Context) error {
|
||||
ran = append(ran, "always-step")
|
||||
mainStepCtxErr = c.Err()
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
err := newStepsExecutor(rc, preSteps, steps)(ctx)
|
||||
|
||||
require.ErrorIs(t, err, context.Canceled, "the cancellation is propagated")
|
||||
assert.Equal(t, []string{"pre1", "always-step"}, ran, "the main always() step runs after a pre-phase cancel")
|
||||
require.NoError(t, mainStepCtxErr, "the main step runs under a fresh, non-cancelled context")
|
||||
assert.True(t, rc.jobCancelled, "the job is marked cancelled")
|
||||
}
|
||||
|
||||
// TestStepsExecutorRunsMainStepsAfterPreFailure verifies that a failing pre step does not abandon
|
||||
// the main steps: they still run (so a main-stage always()/failure() step is reached), and the
|
||||
// pre-step error is propagated so the job is reported as failed. The main steps' own `if`
|
||||
// evaluation is what skips success()-default steps, so running them here is safe.
|
||||
func TestStepsExecutorRunsMainStepsAfterPreFailure(t *testing.T) {
|
||||
rc := createIfTestRunContext(map[string]*model.Job{
|
||||
"job1": createJob(t, `runs-on: ubuntu-latest`, ""),
|
||||
})
|
||||
|
||||
var ran []string
|
||||
preSteps := []common.Executor{
|
||||
func(_ context.Context) error {
|
||||
ran = append(ran, "pre1")
|
||||
return assert.AnError
|
||||
},
|
||||
}
|
||||
steps := []common.Executor{
|
||||
func(_ context.Context) error {
|
||||
ran = append(ran, "always-step")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
err := newStepsExecutor(rc, preSteps, steps)(context.Background())
|
||||
|
||||
require.ErrorIs(t, err, assert.AnError, "the pre-step error is propagated")
|
||||
assert.Equal(t, []string{"pre1", "always-step"}, ran, "the main always() step runs after a pre-step failure")
|
||||
assert.False(t, rc.jobCancelled, "a pre-step failure is not a cancellation")
|
||||
}
|
||||
|
||||
// TestPreStepFailureAffectsMainStepIfStatus verifies the status path used by real
|
||||
// main-step `if` evaluation. A pre-step failure is not present in StepResults, so
|
||||
// recording only the context job error is not enough: getJobContext must also report
|
||||
// failure so success()-default main steps skip and failure() steps run.
|
||||
func TestPreStepFailureAffectsMainStepIfStatus(t *testing.T) {
|
||||
rc := createIfTestRunContext(map[string]*model.Job{
|
||||
"job1": createJob(t, `runs-on: ubuntu-latest`, ""),
|
||||
})
|
||||
ctx := common.WithJobErrorContainer(context.Background())
|
||||
|
||||
reportStepError(ctx, rc, assert.AnError)
|
||||
|
||||
assert.Equal(t, "failure", rc.getJobContext().Status)
|
||||
require.ErrorIs(t, common.JobError(ctx), assert.AnError)
|
||||
|
||||
defaultStep := &stepRun{
|
||||
RunContext: rc,
|
||||
Step: &model.Step{ID: "default-step"},
|
||||
env: map[string]string{},
|
||||
}
|
||||
defaultEnabled, err := isStepEnabled(ctx, defaultStep.getIfExpression(ctx, stepStageMain), defaultStep, stepStageMain)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, defaultEnabled, "default success() main step must skip after a pre-step failure")
|
||||
|
||||
failureStep := &stepRun{
|
||||
RunContext: rc,
|
||||
Step: &model.Step{
|
||||
ID: "failure-step",
|
||||
If: yaml.Node{Value: "failure()"},
|
||||
},
|
||||
env: map[string]string{},
|
||||
}
|
||||
failureEnabled, err := isStepEnabled(ctx, failureStep.getIfExpression(ctx, stepStageMain), failureStep, stepStageMain)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, failureEnabled, "failure() main step must run after a pre-step failure")
|
||||
}
|
||||
|
||||
// TestPostStepsContextCancelledIsUsableForFailingStep guards against a panic: post/cleanup
|
||||
// steps run on a context derived from the cancelled job context, and a failing post step
|
||||
// records its error via common.SetJobError. If that derived context lacks a job-error container,
|
||||
// SetJobError dereferences a nil map and panics. The post context must therefore be detached
|
||||
// from cancellation (so the steps run) yet still carry a usable error container.
|
||||
func TestPostStepsContextCancelledIsUsableForFailingStep(t *testing.T) {
|
||||
cancelled, cancel := context.WithCancel(common.WithJobErrorContainer(context.Background()))
|
||||
cancel()
|
||||
require.ErrorIs(t, cancelled.Err(), context.Canceled)
|
||||
|
||||
postCtx, done := postStepsContext(cancelled)
|
||||
defer done()
|
||||
|
||||
// Detached from cancellation, so the post steps actually run.
|
||||
require.NoError(t, postCtx.Err(), "post context must not be cancelled")
|
||||
|
||||
// A failing post step records its error instead of panicking.
|
||||
require.NotPanics(t, func() {
|
||||
common.SetJobError(postCtx, assert.AnError)
|
||||
}, "a failing post step must not panic on the cancel path")
|
||||
assert.ErrorIs(t, common.JobError(postCtx), assert.AnError)
|
||||
}
|
||||
|
||||
// TestPostStepsContextDeadlinePreservesJobError verifies the job-timeout path keeps the original
|
||||
// job-error container (via context.WithoutCancel), so the timeout failure and any post-step error
|
||||
// survive into the post phase and the job is still reported as failed.
|
||||
func TestPostStepsContextDeadlinePreservesJobError(t *testing.T) {
|
||||
base := common.WithJobErrorContainer(context.Background())
|
||||
common.SetJobError(base, assert.AnError)
|
||||
expired, cancel := context.WithDeadline(base, time.Now().Add(-time.Hour))
|
||||
defer cancel()
|
||||
require.ErrorIs(t, expired.Err(), context.DeadlineExceeded)
|
||||
|
||||
postCtx, done := postStepsContext(expired)
|
||||
defer done()
|
||||
|
||||
require.NoError(t, postCtx.Err(), "post context must not carry the expired deadline")
|
||||
assert.ErrorIs(t, common.JobError(postCtx), assert.AnError, "the timeout job error must be preserved")
|
||||
}
|
||||
|
||||
// reportStepError must treat a context.Canceled (e.g. a teardown-cancelled read) as an
|
||||
// interruption, never a job failure.
|
||||
func TestReportStepErrorTreatsCancelAsInterruption(t *testing.T) {
|
||||
rc := &RunContext{}
|
||||
|
||||
// stray read cancellation while the job context is live: ignored, not a failure
|
||||
live := common.WithJobErrorContainer(context.Background())
|
||||
reportStepError(live, rc, context.Canceled)
|
||||
require.NoError(t, common.JobError(live))
|
||||
assert.False(t, rc.jobFailed)
|
||||
assert.False(t, rc.jobCancelled)
|
||||
|
||||
// genuine job cancellation: recorded as cancelled, still not a failure
|
||||
cancelled, cancel := context.WithCancel(common.WithJobErrorContainer(context.Background()))
|
||||
cancel()
|
||||
reportStepError(cancelled, rc, context.Canceled)
|
||||
require.NoError(t, common.JobError(cancelled))
|
||||
assert.False(t, rc.jobFailed)
|
||||
assert.True(t, rc.jobCancelled)
|
||||
|
||||
// a real error still fails the job
|
||||
failed := common.WithJobErrorContainer(context.Background())
|
||||
reportStepError(failed, rc, assert.AnError)
|
||||
require.ErrorIs(t, common.JobError(failed), assert.AnError)
|
||||
assert.True(t, rc.jobFailed)
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2020 The nektos/act Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package runner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"gitea.com/gitea/runner/internal/act/common"
|
||||
)
|
||||
|
||||
var commandPatternGA *regexp.Regexp
|
||||
|
||||
var commandPatternADO *regexp.Regexp
|
||||
|
||||
func init() {
|
||||
commandPatternGA = regexp.MustCompile("^::([^ ]+)( (.+))?::([^\r\n]*)[\r\n]+$")
|
||||
commandPatternADO = regexp.MustCompile("^##\\[([^ ]+)( (.+))?]([^\r\n]*)[\r\n]+$")
|
||||
}
|
||||
|
||||
func tryParseRawActionCommand(line string) (command string, kvPairs map[string]string, arg string, ok bool) {
|
||||
if m := commandPatternGA.FindStringSubmatch(line); m != nil {
|
||||
command = m[1]
|
||||
kvPairs = parseKeyValuePairs(m[3], ",")
|
||||
arg = m[4]
|
||||
ok = true
|
||||
} else if m := commandPatternADO.FindStringSubmatch(line); m != nil {
|
||||
command = m[1]
|
||||
kvPairs = parseKeyValuePairs(m[3], ";")
|
||||
arg = m[4]
|
||||
ok = true
|
||||
}
|
||||
return command, kvPairs, arg, ok
|
||||
}
|
||||
|
||||
func (rc *RunContext) commandHandler(ctx context.Context) common.LineHandler {
|
||||
logger := common.Logger(ctx)
|
||||
resumeCommand := ""
|
||||
return func(line string) bool {
|
||||
command, kvPairs, arg, ok := tryParseRawActionCommand(line)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
|
||||
if resumeCommand != "" {
|
||||
// There should not be any emojis in the log output for Gitea.
|
||||
// Return true (not false) so the line still reaches the raw_output
|
||||
// log handler; otherwise everything between ::stop-commands:: and
|
||||
// its end token is silently dropped from the step log.
|
||||
logger.Infof("%s", line)
|
||||
// Resumed here rather than from the switch, because the end token is arbitrary
|
||||
// and a token naming a real command would otherwise never resume.
|
||||
if command == resumeCommand {
|
||||
resumeCommand = ""
|
||||
}
|
||||
return true
|
||||
}
|
||||
arg = UnescapeCommandData(arg)
|
||||
kvPairs = unescapeKvPairs(kvPairs)
|
||||
if (command == "set-env" || command == "add-path") && rc.refuseUnsecureCommand(ctx, command) {
|
||||
return true
|
||||
}
|
||||
switch command {
|
||||
case "set-env":
|
||||
rc.setEnv(ctx, kvPairs, arg)
|
||||
case "set-output":
|
||||
rc.setOutput(ctx, kvPairs, arg)
|
||||
case "add-path":
|
||||
rc.addPath(ctx, arg)
|
||||
case "add-mask":
|
||||
rc.AddMask(arg)
|
||||
logger.Infof("%s", "***")
|
||||
// The raw line is still forwarded, carrying the secret: that is how the reporter
|
||||
// learns the mask, and it drops the row rather than writing it out.
|
||||
case "stop-commands":
|
||||
resumeCommand = arg
|
||||
logger.Infof("%s", line)
|
||||
case "save-state":
|
||||
logger.Infof("%s", line)
|
||||
rc.saveState(ctx, kvPairs, arg)
|
||||
default:
|
||||
// ::debug::, ::error::, ::warning::, ::add-matcher:: and anything unrecognised are
|
||||
// passed through for the reporter and Gitea's web UI to render.
|
||||
logger.Infof("%s", line)
|
||||
}
|
||||
|
||||
// return true to let gitea's logger handle these special outputs also
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
const allowUnsecureCommandsVar = "ACTIONS_ALLOW_UNSECURE_COMMANDS"
|
||||
|
||||
// refuseUnsecureCommand reports whether a deprecated ::set-env:: or ::add-path:: command must
|
||||
// not run, recording the error that fails the step. GitHub disabled both because a step that
|
||||
// echoes untrusted content can use them to set NODE_OPTIONS or PATH for every later step.
|
||||
func (rc *RunContext) refuseUnsecureCommand(ctx context.Context, command string) bool {
|
||||
if rc.allowUnsecureCommandsOptIn() {
|
||||
return false
|
||||
}
|
||||
|
||||
// The step executor logs the failure itself, so keep this line's wording distinct.
|
||||
common.Logger(ctx).WithField(rawOutputField, true).Errorf("##[error]%s", EscapeCommandData(fmt.Sprintf(
|
||||
"The `%s` command is disabled: it can set the environment of every later step from untrusted output. "+
|
||||
"Write to $GITHUB_ENV or $GITHUB_PATH instead, or set ACTIONS_ALLOW_UNSECURE_COMMANDS to allow it",
|
||||
command)))
|
||||
|
||||
rc.unsecureCommandMu.Lock()
|
||||
defer rc.unsecureCommandMu.Unlock()
|
||||
if rc.unsecureCommandErr == nil {
|
||||
rc.unsecureCommandErr = fmt.Errorf("the `%s` workflow command is disabled", command)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// allowUnsecureCommandsOptIn reports whether the workflow itself asked for the deprecated
|
||||
// commands, from any env scope, as it can on GitHub.
|
||||
func (rc *RunContext) allowUnsecureCommandsOptIn() bool {
|
||||
return isTruthyEnv(rc.currentStepEnv()[allowUnsecureCommandsVar]) ||
|
||||
isTruthyEnv(rc.Env[allowUnsecureCommandsVar]) ||
|
||||
isTruthyEnv(rc.GlobalEnv[allowUnsecureCommandsVar])
|
||||
}
|
||||
|
||||
// isTruthyEnv mirrors GitHub's bool.TryParse: only "true", in any casing.
|
||||
func isTruthyEnv(v string) bool {
|
||||
return strings.EqualFold(strings.TrimSpace(v), "true")
|
||||
}
|
||||
|
||||
// takeUnsecureCommandError returns and clears the error left by a refused command.
|
||||
func (rc *RunContext) takeUnsecureCommandError() error {
|
||||
rc.unsecureCommandMu.Lock()
|
||||
defer rc.unsecureCommandMu.Unlock()
|
||||
err := rc.unsecureCommandErr
|
||||
rc.unsecureCommandErr = nil
|
||||
return err
|
||||
}
|
||||
|
||||
func (rc *RunContext) setEnv(ctx context.Context, kvPairs map[string]string, arg string) {
|
||||
name := kvPairs["name"]
|
||||
common.Logger(ctx).Infof("::set-env:: %s=%s", name, arg)
|
||||
if rc.Env == nil {
|
||||
rc.Env = make(map[string]string)
|
||||
}
|
||||
if rc.GlobalEnv == nil {
|
||||
rc.GlobalEnv = map[string]string{}
|
||||
}
|
||||
newenv := map[string]string{
|
||||
name: arg,
|
||||
}
|
||||
mergeIntoMap := mergeIntoMapCaseSensitive
|
||||
if rc.JobContainer != nil && rc.JobContainer.IsEnvironmentCaseInsensitive() {
|
||||
mergeIntoMap = mergeIntoMapCaseInsensitive
|
||||
}
|
||||
mergeIntoMap(rc.Env, newenv)
|
||||
mergeIntoMap(rc.GlobalEnv, newenv)
|
||||
}
|
||||
|
||||
func (rc *RunContext) setOutput(ctx context.Context, kvPairs map[string]string, arg string) {
|
||||
logger := common.Logger(ctx)
|
||||
stepID := rc.CurrentStep
|
||||
outputName := kvPairs["name"]
|
||||
if outputMapping, ok := rc.OutputMappings[MappableOutput{StepID: stepID, OutputName: outputName}]; ok {
|
||||
stepID = outputMapping.StepID
|
||||
outputName = outputMapping.OutputName
|
||||
}
|
||||
|
||||
result, ok := rc.StepResults[stepID]
|
||||
if !ok {
|
||||
logger.Infof("No outputs registered for step '%s'", stepID)
|
||||
return
|
||||
}
|
||||
|
||||
logger.Infof("::set-output:: %s=%s", outputName, arg)
|
||||
result.Outputs[outputName] = arg
|
||||
}
|
||||
|
||||
func (rc *RunContext) addPath(ctx context.Context, arg string) {
|
||||
common.Logger(ctx).Infof("::add-path:: %s", arg)
|
||||
extraPath := []string{arg}
|
||||
for _, v := range rc.ExtraPath {
|
||||
if v != arg {
|
||||
extraPath = append(extraPath, v)
|
||||
}
|
||||
}
|
||||
rc.ExtraPath = extraPath
|
||||
}
|
||||
|
||||
func parseKeyValuePairs(kvPairs, separator string) map[string]string {
|
||||
rtn := make(map[string]string)
|
||||
kvPairList := strings.SplitSeq(kvPairs, separator)
|
||||
for kvPair := range kvPairList {
|
||||
kv := strings.Split(kvPair, "=")
|
||||
if len(kv) == 2 {
|
||||
rtn[kv[0]] = kv[1]
|
||||
}
|
||||
}
|
||||
return rtn
|
||||
}
|
||||
|
||||
// A Replacer never rescans what it wrote, so "%250A" stays a literal "%0A".
|
||||
var (
|
||||
commandDataEscaper = strings.NewReplacer("%", "%25", "\r", "%0D", "\n", "%0A")
|
||||
commandDataUnescaper = strings.NewReplacer("%25", "%", "%0D", "\r", "%0A", "\n")
|
||||
commandPropertyUnescaper = strings.NewReplacer("%25", "%", "%0D", "\r", "%0A", "\n", "%3A", ":", "%2C", ",")
|
||||
)
|
||||
|
||||
// EscapeCommandData encodes the data part of a "::cmd::" or "##[cmd]" line the runner writes itself,
|
||||
// so the log renderer decodes it back. Lines forwarded from step output are already escaped.
|
||||
func EscapeCommandData(arg string) string {
|
||||
return commandDataEscaper.Replace(arg)
|
||||
}
|
||||
|
||||
func UnescapeCommandData(arg string) string {
|
||||
return commandDataUnescaper.Replace(arg)
|
||||
}
|
||||
|
||||
func unescapeCommandProperty(arg string) string {
|
||||
return commandPropertyUnescaper.Replace(arg)
|
||||
}
|
||||
|
||||
func unescapeKvPairs(kvPairs map[string]string) map[string]string {
|
||||
for k, v := range kvPairs {
|
||||
kvPairs[k] = unescapeCommandProperty(v)
|
||||
}
|
||||
return kvPairs
|
||||
}
|
||||
|
||||
func (rc *RunContext) saveState(_ context.Context, kvPairs map[string]string, arg string) {
|
||||
stepID := rc.CurrentStep
|
||||
if stepID != "" {
|
||||
if rc.IntraActionState == nil {
|
||||
rc.IntraActionState = map[string]map[string]string{}
|
||||
}
|
||||
state, ok := rc.IntraActionState[stepID]
|
||||
if !ok {
|
||||
state = map[string]string{}
|
||||
rc.IntraActionState[stepID] = state
|
||||
}
|
||||
state[kvPairs["name"]] = arg
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2020 The nektos/act Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package runner
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"gitea.com/gitea/runner/internal/act/common"
|
||||
|
||||
"gitea.dev/actionslib/pkg/model"
|
||||
"github.com/sirupsen/logrus/hooks/test"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// unsecureRC opts into ::set-env:: and ::add-path::, which are refused without it.
|
||||
func unsecureRC() *RunContext {
|
||||
return &RunContext{Env: map[string]string{allowUnsecureCommandsVar: "true"}}
|
||||
}
|
||||
|
||||
func TestSetEnv(t *testing.T) {
|
||||
a := assert.New(t)
|
||||
ctx := context.Background()
|
||||
rc := unsecureRC()
|
||||
handler := rc.commandHandler(ctx)
|
||||
|
||||
handler("::set-env name=x::valz\n")
|
||||
a.Equal("valz", rc.Env["x"])
|
||||
}
|
||||
|
||||
func TestStopCommandsKeepsSuppressedLinesInLog(t *testing.T) {
|
||||
a := assert.New(t)
|
||||
ctx := context.Background()
|
||||
rc := unsecureRC()
|
||||
handler := rc.commandHandler(ctx)
|
||||
|
||||
// Stop command processing until the matching end token is seen.
|
||||
a.True(handler("::stop-commands::my-end-token\n"))
|
||||
|
||||
// A command-shaped line while stopped must not be executed (env unchanged),
|
||||
// but must still return true so it reaches the raw_output log handler and is
|
||||
// not dropped from the step log.
|
||||
a.True(handler("::set-env name=x::valz\n"))
|
||||
a.NotContains(rc.Env, "x")
|
||||
|
||||
// The matching end token resumes command processing.
|
||||
a.True(handler("::my-end-token::\n"))
|
||||
|
||||
// Commands are processed again after resuming.
|
||||
a.True(handler("::set-env name=y::valy\n"))
|
||||
a.Equal("valy", rc.Env["y"])
|
||||
}
|
||||
|
||||
func TestSetOutput(t *testing.T) {
|
||||
a := assert.New(t)
|
||||
ctx := context.Background()
|
||||
rc := new(RunContext)
|
||||
rc.StepResults = make(map[string]*model.StepResult)
|
||||
handler := rc.commandHandler(ctx)
|
||||
|
||||
rc.CurrentStep = "my-step"
|
||||
rc.StepResults[rc.CurrentStep] = &model.StepResult{
|
||||
Outputs: make(map[string]string),
|
||||
}
|
||||
handler("::set-output name=x::valz\n")
|
||||
a.Equal("valz", rc.StepResults["my-step"].Outputs["x"])
|
||||
|
||||
handler("::set-output name=x::percent2%25\n")
|
||||
a.Equal("percent2%", rc.StepResults["my-step"].Outputs["x"])
|
||||
|
||||
handler("::set-output name=x::percent2%25%0Atest\n")
|
||||
a.Equal("percent2%\ntest", rc.StepResults["my-step"].Outputs["x"])
|
||||
|
||||
handler("::set-output name=x::percent2%25%0Atest another3%25test\n")
|
||||
a.Equal("percent2%\ntest another3%test", rc.StepResults["my-step"].Outputs["x"])
|
||||
|
||||
handler("::set-output name=x%3A::percent2%25%0Atest\n")
|
||||
a.Equal("percent2%\ntest", rc.StepResults["my-step"].Outputs["x:"])
|
||||
|
||||
handler("::set-output name=x%3A%2C%0A%25%0D%3A::percent2%25%0Atest\n")
|
||||
a.Equal("percent2%\ntest", rc.StepResults["my-step"].Outputs["x:,\n%\r:"])
|
||||
}
|
||||
|
||||
func TestAddpath(t *testing.T) {
|
||||
a := assert.New(t)
|
||||
ctx := context.Background()
|
||||
rc := unsecureRC()
|
||||
handler := rc.commandHandler(ctx)
|
||||
|
||||
handler("::add-path::/zoo\n")
|
||||
a.Equal("/zoo", rc.ExtraPath[0])
|
||||
|
||||
handler("::add-path::/boo\n")
|
||||
a.Equal("/boo", rc.ExtraPath[0])
|
||||
}
|
||||
|
||||
func TestStopCommands(t *testing.T) {
|
||||
logger, hook := test.NewNullLogger()
|
||||
|
||||
a := assert.New(t)
|
||||
ctx := common.WithLogger(context.Background(), logger)
|
||||
rc := unsecureRC()
|
||||
handler := rc.commandHandler(ctx)
|
||||
|
||||
handler("::set-env name=x::valz\n")
|
||||
a.Equal("valz", rc.Env["x"])
|
||||
handler("::stop-commands::my-end-token\n")
|
||||
handler("::set-env name=x::abcd\n")
|
||||
a.Equal("valz", rc.Env["x"])
|
||||
handler("::my-end-token::\n")
|
||||
handler("::set-env name=x::abcd\n")
|
||||
a.Equal("abcd", rc.Env["x"])
|
||||
|
||||
messages := make([]string, 0)
|
||||
for _, entry := range hook.AllEntries() {
|
||||
messages = append(messages, entry.Message)
|
||||
}
|
||||
|
||||
a.Contains(messages, "::set-env name=x::abcd\n")
|
||||
}
|
||||
|
||||
// The end token is arbitrary, so one that happens to name a real command must still resume
|
||||
// rather than being swallowed by that command's case.
|
||||
func TestStopCommandsResumesOnCommandNamedToken(t *testing.T) {
|
||||
a := assert.New(t)
|
||||
rc := unsecureRC()
|
||||
handler := rc.commandHandler(context.Background())
|
||||
|
||||
handler("::stop-commands::add-mask\n")
|
||||
handler("::set-env name=x::suppressed\n")
|
||||
a.NotContains(rc.Env, "x")
|
||||
|
||||
handler("::add-mask::\n")
|
||||
handler("::set-env name=x::resumed\n")
|
||||
a.Equal("resumed", rc.Env["x"])
|
||||
}
|
||||
|
||||
func TestAddpathADO(t *testing.T) {
|
||||
a := assert.New(t)
|
||||
ctx := context.Background()
|
||||
rc := unsecureRC()
|
||||
handler := rc.commandHandler(ctx)
|
||||
|
||||
handler("##[add-path]/zoo\n")
|
||||
a.Equal("/zoo", rc.ExtraPath[0])
|
||||
|
||||
handler("##[add-path]/boo\n")
|
||||
a.Equal("/boo", rc.ExtraPath[0])
|
||||
}
|
||||
|
||||
func TestAddmask(t *testing.T) {
|
||||
logger, hook := test.NewNullLogger()
|
||||
|
||||
a := assert.New(t)
|
||||
ctx := context.Background()
|
||||
loggerCtx := common.WithLogger(ctx, logger)
|
||||
|
||||
rc := new(RunContext)
|
||||
handler := rc.commandHandler(loggerCtx)
|
||||
handler("::add-mask::my-secret-value\n")
|
||||
|
||||
a.Equal("***", hook.LastEntry().Message)
|
||||
a.NotEqual("*my-secret-value", hook.LastEntry().Message)
|
||||
}
|
||||
|
||||
// based on https://stackoverflow.com/a/10476304
|
||||
func captureOutput(t *testing.T, f func()) string {
|
||||
old := os.Stdout
|
||||
r, w, _ := os.Pipe()
|
||||
os.Stdout = w
|
||||
|
||||
f()
|
||||
|
||||
outC := make(chan string)
|
||||
|
||||
go func() {
|
||||
var buf bytes.Buffer
|
||||
_, err := io.Copy(&buf, r)
|
||||
if err != nil {
|
||||
a := assert.New(t)
|
||||
a.Fail("io.Copy failed")
|
||||
}
|
||||
outC <- buf.String()
|
||||
}()
|
||||
|
||||
w.Close()
|
||||
os.Stdout = old
|
||||
out := <-outC
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func TestAddmaskUsemask(t *testing.T) {
|
||||
rc := new(RunContext)
|
||||
rc.StepResults = make(map[string]*model.StepResult)
|
||||
rc.CurrentStep = "my-step"
|
||||
rc.StepResults[rc.CurrentStep] = &model.StepResult{
|
||||
Outputs: make(map[string]string),
|
||||
}
|
||||
|
||||
a := assert.New(t)
|
||||
|
||||
config := &Config{
|
||||
Secrets: map[string]string{},
|
||||
InsecureSecrets: false,
|
||||
}
|
||||
|
||||
re := captureOutput(t, func() {
|
||||
ctx := context.Background()
|
||||
ctx = WithJobLogger(ctx, "0", "testjob", config, &rc.Masks, map[string]any{})
|
||||
|
||||
handler := rc.commandHandler(ctx)
|
||||
handler("::add-mask::secret\n")
|
||||
handler("::set-output:: token=secret\n")
|
||||
})
|
||||
|
||||
a.Equal("[testjob] ***\n[testjob] ::set-output:: = token=***\n", re)
|
||||
}
|
||||
|
||||
func TestSaveState(t *testing.T) {
|
||||
rc := &RunContext{
|
||||
CurrentStep: "step",
|
||||
StepResults: map[string]*model.StepResult{},
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
handler := rc.commandHandler(ctx)
|
||||
handler("::save-state name=state-name::state-value\n")
|
||||
|
||||
assert.Equal(t, "state-value", rc.IntraActionState["step"]["state-name"])
|
||||
}
|
||||
|
||||
func TestEscapeCommandData(t *testing.T) {
|
||||
a := assert.New(t)
|
||||
|
||||
a.Equal("a%25b%0Dc%0Ad%250A", EscapeCommandData("a%b\rc\nd%0A"))
|
||||
a.Equal("a%b\rc\nd%0A", UnescapeCommandData("a%25b%0Dc%0Ad%250A"))
|
||||
}
|
||||
|
||||
func TestUnsecureCommands(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
jobEnv map[string]string
|
||||
stepEnv map[string]string
|
||||
optedIn bool
|
||||
}{
|
||||
{name: "refused with no opt-in"},
|
||||
// GitHub reads the opt-in with bool.TryParse, so "1" is not one.
|
||||
{name: "refused for a value bool.TryParse rejects", jobEnv: map[string]string{allowUnsecureCommandsVar: "1"}},
|
||||
{name: "opted in through the step environment", stepEnv: map[string]string{allowUnsecureCommandsVar: "true"}, optedIn: true},
|
||||
{name: "opted in through the job environment", jobEnv: map[string]string{allowUnsecureCommandsVar: "TRUE"}, optedIn: true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
a := assert.New(t)
|
||||
rc := &RunContext{Env: tt.jobEnv}
|
||||
rc.setCurrentStepEnv(tt.stepEnv)
|
||||
handler := rc.commandHandler(context.Background())
|
||||
|
||||
handler("::set-env name=x::valz\n")
|
||||
handler("::add-path::/opt/bin\n")
|
||||
|
||||
if !tt.optedIn {
|
||||
a.Empty(rc.Env["x"])
|
||||
a.Empty(rc.ExtraPath)
|
||||
// The refusal fails the step that produced it, once.
|
||||
require.ErrorContains(t, rc.takeUnsecureCommandError(), "set-env")
|
||||
a.NoError(rc.takeUnsecureCommandError())
|
||||
return
|
||||
}
|
||||
a.Equal("valz", rc.Env["x"])
|
||||
a.Equal([]string{"/opt/bin"}, rc.ExtraPath)
|
||||
a.NoError(rc.takeUnsecureCommandError())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
// 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"
|
||||
"io"
|
||||
|
||||
"gitea.com/gitea/runner/internal/act/common"
|
||||
"gitea.com/gitea/runner/internal/act/container"
|
||||
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
type containerMock struct {
|
||||
mock.Mock
|
||||
container.Container
|
||||
container.LinuxContainerEnvironmentExtensions
|
||||
}
|
||||
|
||||
func (cm *containerMock) Create(capAdd, capDrop []string) common.Executor {
|
||||
args := cm.Called(capAdd, capDrop)
|
||||
return args.Get(0).(func(context.Context) error)
|
||||
}
|
||||
|
||||
func (cm *containerMock) Pull(forcePull bool) common.Executor {
|
||||
args := cm.Called(forcePull)
|
||||
return args.Get(0).(func(context.Context) error)
|
||||
}
|
||||
|
||||
func (cm *containerMock) Start(attach bool) common.Executor {
|
||||
args := cm.Called(attach)
|
||||
return args.Get(0).(func(context.Context) error)
|
||||
}
|
||||
|
||||
func (cm *containerMock) Remove() common.Executor {
|
||||
args := cm.Called()
|
||||
return args.Get(0).(func(context.Context) error)
|
||||
}
|
||||
|
||||
func (cm *containerMock) Close() common.Executor {
|
||||
args := cm.Called()
|
||||
return args.Get(0).(func(context.Context) error)
|
||||
}
|
||||
|
||||
func (cm *containerMock) UpdateFromEnv(srcPath string, env *map[string]string) common.Executor {
|
||||
args := cm.Called(srcPath, env)
|
||||
return args.Get(0).(func(context.Context) error)
|
||||
}
|
||||
|
||||
func (cm *containerMock) UpdateFromImageEnv(env *map[string]string) common.Executor {
|
||||
args := cm.Called(env)
|
||||
return args.Get(0).(func(context.Context) error)
|
||||
}
|
||||
|
||||
func (cm *containerMock) Copy(destPath string, files ...*container.FileEntry) common.Executor {
|
||||
args := cm.Called(destPath, files)
|
||||
return args.Get(0).(func(context.Context) error)
|
||||
}
|
||||
|
||||
func (cm *containerMock) CopyDir(destPath, srcPath string, useGitIgnore bool) common.Executor {
|
||||
args := cm.Called(destPath, srcPath, useGitIgnore)
|
||||
return args.Get(0).(func(context.Context) error)
|
||||
}
|
||||
|
||||
func (cm *containerMock) Exec(command []string, env map[string]string, user, workdir string) common.Executor {
|
||||
args := cm.Called(command, env, user, workdir)
|
||||
return args.Get(0).(func(context.Context) error)
|
||||
}
|
||||
|
||||
func (cm *containerMock) GetContainerArchive(ctx context.Context, srcPath string) (io.ReadCloser, error) {
|
||||
args := cm.Called(ctx, srcPath)
|
||||
err, hasErr := args.Get(1).(error)
|
||||
if !hasErr {
|
||||
err = nil
|
||||
}
|
||||
return args.Get(0).(io.ReadCloser), err
|
||||
}
|
||||
|
||||
func (cm *containerMock) DumpLogs(ctx context.Context) error {
|
||||
return cm.Called(ctx).Error(0)
|
||||
}
|
||||
|
||||
func (cm *containerMock) Inspect(ctx context.Context) (*container.Info, error) {
|
||||
args := cm.Called(ctx)
|
||||
info, _ := args.Get(0).(*container.Info)
|
||||
err, _ := args.Get(1).(error)
|
||||
return info, err
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2020 The nektos/act Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package runner
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"maps"
|
||||
"path"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.com/gitea/runner/internal/act/common"
|
||||
"gitea.com/gitea/runner/internal/act/container"
|
||||
|
||||
_ "embed"
|
||||
|
||||
"gitea.dev/actionslib/pkg/expreval"
|
||||
"gitea.dev/actionslib/pkg/exprparser"
|
||||
"gitea.dev/actionslib/pkg/model"
|
||||
"go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
// ExpressionEvaluator is the interface for evaluating expressions
|
||||
type ExpressionEvaluator interface {
|
||||
evaluate(context.Context, string, exprparser.DefaultStatusCheck) (any, error)
|
||||
interpolate(context.Context, string) (string, error)
|
||||
EvaluateYamlNode(context.Context, *yaml.Node) error
|
||||
Interpolate(context.Context, string) string
|
||||
}
|
||||
|
||||
// NewExpressionEvaluator creates a new evaluator
|
||||
func (rc *RunContext) NewExpressionEvaluator(ctx context.Context) ExpressionEvaluator {
|
||||
return rc.NewExpressionEvaluatorWithEnv(ctx, rc.GetEnv())
|
||||
}
|
||||
|
||||
func (rc *RunContext) NewExpressionEvaluatorWithEnv(ctx context.Context, env map[string]string) ExpressionEvaluator {
|
||||
var workflowCallResult map[string]*model.WorkflowCallResult
|
||||
|
||||
// todo: cleanup EvaluationEnvironment creation
|
||||
using := make(map[string]exprparser.Needs)
|
||||
strategy := make(map[string]any)
|
||||
if rc.Run != nil {
|
||||
job := rc.Run.Job()
|
||||
if job != nil && job.Strategy != nil {
|
||||
strategy["fail-fast"] = job.Strategy.FailFast
|
||||
strategy["max-parallel"] = job.Strategy.MaxParallel
|
||||
}
|
||||
|
||||
jobs := rc.Run.Workflow.Jobs
|
||||
jobNeeds := rc.Run.Job().Needs()
|
||||
|
||||
for _, needs := range jobNeeds {
|
||||
using[needs] = exprparser.Needs{
|
||||
Outputs: jobs[needs].Outputs,
|
||||
Result: jobs[needs].NeedsResult(),
|
||||
}
|
||||
}
|
||||
|
||||
// only setup jobs context in case of workflow_call
|
||||
// and existing expression evaluator (this means, jobs are at
|
||||
// least ready to run)
|
||||
if rc.caller != nil && rc.ExprEval != nil {
|
||||
workflowCallResult = map[string]*model.WorkflowCallResult{}
|
||||
|
||||
for jobName, job := range jobs {
|
||||
result := model.WorkflowCallResult{
|
||||
Outputs: map[string]string{},
|
||||
}
|
||||
maps.Copy(result.Outputs, job.Outputs)
|
||||
workflowCallResult[jobName] = &result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ghc := rc.getGithubContext(ctx)
|
||||
inputs := getEvaluatorInputs(ctx, rc, nil, ghc)
|
||||
|
||||
ee := &exprparser.EvaluationEnvironment{
|
||||
Github: ghc,
|
||||
Env: env,
|
||||
Job: rc.getJobContext(),
|
||||
Jobs: &workflowCallResult,
|
||||
// todo: should be unavailable
|
||||
// but required to interpolate/evaluate the step outputs on the job
|
||||
Steps: rc.getStepsContext(),
|
||||
Secrets: getWorkflowSecrets(ctx, rc),
|
||||
Vars: getWorkflowVars(ctx, rc),
|
||||
Strategy: strategy,
|
||||
Matrix: rc.Matrix,
|
||||
Needs: using,
|
||||
Inputs: inputs,
|
||||
HashFiles: getHashFilesFunction(ctx, rc),
|
||||
}
|
||||
ee.Runner = rc.getRunnerContext(ctx)
|
||||
return expressionEvaluator{
|
||||
interpreter: exprparser.NewInterpeter(ee, exprparser.Config{
|
||||
Run: rc.Run,
|
||||
WorkingDir: rc.Config.Workdir,
|
||||
Context: "job",
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
//go:embed hashfiles/index.js
|
||||
var hashfiles string
|
||||
|
||||
// NewStepExpressionEvaluator creates a new evaluator
|
||||
func (rc *RunContext) NewStepExpressionEvaluator(ctx context.Context, step step) ExpressionEvaluator {
|
||||
// todo: cleanup EvaluationEnvironment creation
|
||||
job := rc.Run.Job()
|
||||
strategy := make(map[string]any)
|
||||
if job.Strategy != nil {
|
||||
strategy["fail-fast"] = job.Strategy.FailFast
|
||||
strategy["max-parallel"] = job.Strategy.MaxParallel
|
||||
}
|
||||
|
||||
jobs := rc.Run.Workflow.Jobs
|
||||
jobNeeds := rc.Run.Job().Needs()
|
||||
|
||||
using := make(map[string]exprparser.Needs)
|
||||
for _, needs := range jobNeeds {
|
||||
using[needs] = exprparser.Needs{
|
||||
Outputs: jobs[needs].Outputs,
|
||||
Result: jobs[needs].NeedsResult(),
|
||||
}
|
||||
}
|
||||
|
||||
ghc := rc.getGithubContext(ctx)
|
||||
inputs := getEvaluatorInputs(ctx, rc, step, ghc)
|
||||
|
||||
ee := &exprparser.EvaluationEnvironment{
|
||||
Github: step.getGithubContext(ctx),
|
||||
Env: *step.getEnv(),
|
||||
Job: rc.getJobContext(),
|
||||
Steps: rc.getStepsContext(),
|
||||
Secrets: getWorkflowSecrets(ctx, rc),
|
||||
Vars: getWorkflowVars(ctx, rc),
|
||||
Strategy: strategy,
|
||||
Matrix: rc.Matrix,
|
||||
Needs: using,
|
||||
// todo: should be unavailable
|
||||
// but required to interpolate/evaluate the inputs in actions/composite
|
||||
Inputs: inputs,
|
||||
HashFiles: getHashFilesFunction(ctx, rc),
|
||||
}
|
||||
ee.Runner = rc.getRunnerContext(ctx)
|
||||
return expressionEvaluator{
|
||||
interpreter: exprparser.NewInterpeter(ee, exprparser.Config{
|
||||
Run: rc.Run,
|
||||
WorkingDir: rc.Config.Workdir,
|
||||
Context: "step",
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
func getHashFilesFunction(ctx context.Context, rc *RunContext) func(v []reflect.Value) (any, error) {
|
||||
hashFiles := func(v []reflect.Value) (any, error) {
|
||||
if rc.JobContainer != nil {
|
||||
timeed, cancel := context.WithTimeout(ctx, time.Minute)
|
||||
defer cancel()
|
||||
name := "workflow/hashfiles/index.js"
|
||||
hout := &bytes.Buffer{}
|
||||
herr := &bytes.Buffer{}
|
||||
patterns := []string{}
|
||||
followSymlink := false
|
||||
|
||||
for i, p := range v {
|
||||
s := p.String()
|
||||
if i == 0 {
|
||||
if strings.HasPrefix(s, "--") {
|
||||
if strings.EqualFold(s, "--follow-symbolic-links") {
|
||||
followSymlink = true
|
||||
continue
|
||||
}
|
||||
return "", fmt.Errorf("Invalid glob option %s, available option: '--follow-symbolic-links'", s)
|
||||
}
|
||||
}
|
||||
patterns = append(patterns, s)
|
||||
}
|
||||
env := map[string]string{}
|
||||
maps.Copy(env, rc.Env)
|
||||
env["patterns"] = strings.Join(patterns, "\n")
|
||||
if followSymlink {
|
||||
env["followSymbolicLinks"] = "true"
|
||||
}
|
||||
|
||||
stdout, stderr := rc.JobContainer.ReplaceLogWriter(hout, herr)
|
||||
_ = rc.JobContainer.Copy(rc.JobContainer.GetActPath(), &container.FileEntry{
|
||||
Name: name,
|
||||
Mode: 0o644,
|
||||
Body: hashfiles,
|
||||
}).
|
||||
Then(rc.execJobContainer([]string{"node", path.Join(rc.JobContainer.GetActPath(), name)},
|
||||
env, "", "")).
|
||||
Finally(func(context.Context) error {
|
||||
rc.JobContainer.ReplaceLogWriter(stdout, stderr)
|
||||
return nil
|
||||
})(timeed)
|
||||
output := hout.String() + "\n" + herr.String()
|
||||
guard := "__OUTPUT__"
|
||||
outstart := strings.Index(output, guard)
|
||||
if outstart != -1 {
|
||||
outstart += len(guard)
|
||||
outend := strings.Index(output[outstart:], guard)
|
||||
if outend != -1 {
|
||||
return output[outstart : outstart+outend], nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
return hashFiles
|
||||
}
|
||||
|
||||
type expressionEvaluator struct {
|
||||
interpreter exprparser.Interpreter
|
||||
}
|
||||
|
||||
func (ee expressionEvaluator) evaluate(ctx context.Context, in string, defaultStatusCheck exprparser.DefaultStatusCheck) (any, error) {
|
||||
logger := common.Logger(ctx)
|
||||
logger.Debugf("evaluating expression '%s'", in)
|
||||
evaluated, err := ee.interpreter.Evaluate(in, defaultStatusCheck)
|
||||
|
||||
// evaluated is an any: %t renders everything but a bool as "%!t(string=...)"
|
||||
printable := regexp.MustCompile(`::add-mask::.*`).ReplaceAllString(fmt.Sprintf("%v", evaluated), "::add-mask::***)")
|
||||
logger.Debugf("expression '%s' evaluated to '%s'", in, printable)
|
||||
|
||||
return evaluated, err
|
||||
}
|
||||
|
||||
// shared returns the evaluation layer of the shared library, bound to this context so the
|
||||
// evaluation of every single expression is still logged and masked here.
|
||||
func (ee expressionEvaluator) shared(ctx context.Context) expreval.Evaluator {
|
||||
return expreval.New(func(in string, defaultStatusCheck exprparser.DefaultStatusCheck) (any, error) {
|
||||
return ee.evaluate(ctx, in, defaultStatusCheck)
|
||||
})
|
||||
}
|
||||
|
||||
func (ee expressionEvaluator) EvaluateYamlNode(ctx context.Context, node *yaml.Node) error {
|
||||
return ee.shared(ctx).EvaluateYamlNode(node)
|
||||
}
|
||||
|
||||
func (ee expressionEvaluator) Interpolate(ctx context.Context, in string) string {
|
||||
out, err := ee.interpolate(ctx, in)
|
||||
if err != nil {
|
||||
common.Logger(ctx).Errorf("Unable to interpolate expression '%s': %s", in, err)
|
||||
return ""
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (ee expressionEvaluator) interpolate(ctx context.Context, in string) (string, error) {
|
||||
return ee.shared(ctx).Interpolate(in)
|
||||
}
|
||||
|
||||
// EvalBool evaluates an expression against given evaluator. An `if:` is an expression even without
|
||||
// `${{ }}`, while literal text around one makes the whole value a string.
|
||||
func EvalBool(ctx context.Context, evaluator ExpressionEvaluator, expr string, defaultStatusCheck exprparser.DefaultStatusCheck) (bool, error) {
|
||||
return expreval.New(func(in string, dsc exprparser.DefaultStatusCheck) (any, error) {
|
||||
return evaluator.evaluate(ctx, in, dsc)
|
||||
}).EvalBool(expr, defaultStatusCheck)
|
||||
}
|
||||
|
||||
func getEvaluatorInputs(ctx context.Context, rc *RunContext, step step, ghc *model.GithubContext) map[string]any {
|
||||
inputs := map[string]any{}
|
||||
|
||||
setupWorkflowInputs(ctx, &inputs, rc)
|
||||
|
||||
var env map[string]string
|
||||
if step != nil {
|
||||
env = *step.getEnv()
|
||||
} else {
|
||||
env = rc.GetEnv()
|
||||
}
|
||||
|
||||
for k, v := range env {
|
||||
if after, ok := strings.CutPrefix(k, "INPUT_"); ok {
|
||||
inputs[strings.ToLower(after)] = v
|
||||
}
|
||||
}
|
||||
|
||||
if ghc.EventName == "workflow_dispatch" {
|
||||
config := rc.Run.Workflow.WorkflowDispatchConfig()
|
||||
if config != nil && config.Inputs != nil {
|
||||
for k, v := range config.Inputs {
|
||||
value := nestedMapLookup(ghc.Event, "inputs", k)
|
||||
if value == nil {
|
||||
value = v.Default
|
||||
}
|
||||
inputs[k] = coerceInputValue(value, v.Type)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ghc.EventName == "workflow_call" {
|
||||
config := rc.Run.Workflow.WorkflowCallConfig()
|
||||
if config != nil && config.Inputs != nil {
|
||||
for k, v := range config.Inputs {
|
||||
value := nestedMapLookup(ghc.Event, "inputs", k)
|
||||
if value == nil {
|
||||
value = v.Default
|
||||
}
|
||||
inputs[k] = coerceInputValue(value, v.Type)
|
||||
}
|
||||
}
|
||||
}
|
||||
return inputs
|
||||
}
|
||||
|
||||
// coerceInputValue converts an input value to the type declared by the workflow.
|
||||
// The event payload carries natively typed JSON values on newer Gitea versions,
|
||||
// while defaults and older servers provide strings.
|
||||
func coerceInputValue(value any, inputType string) any {
|
||||
if inputType != "boolean" {
|
||||
return value
|
||||
}
|
||||
if b, ok := value.(bool); ok {
|
||||
return b
|
||||
}
|
||||
return value == "true"
|
||||
}
|
||||
|
||||
func setupWorkflowInputs(ctx context.Context, inputs *map[string]any, rc *RunContext) {
|
||||
if rc.caller != nil {
|
||||
config := rc.Run.Workflow.WorkflowCallConfig()
|
||||
|
||||
for name, input := range config.Inputs {
|
||||
value := rc.caller.runContext.Run.Job().With[name]
|
||||
if value != nil {
|
||||
if str, ok := value.(string); ok {
|
||||
// evaluate using the calling RunContext (outside)
|
||||
value = rc.caller.runContext.ExprEval.Interpolate(ctx, str)
|
||||
}
|
||||
}
|
||||
|
||||
if value == nil && config != nil && config.Inputs != nil {
|
||||
value = input.Default
|
||||
if rc.ExprEval != nil {
|
||||
if str, ok := value.(string); ok {
|
||||
// evaluate using the called RunContext (inside)
|
||||
value = rc.ExprEval.Interpolate(ctx, str)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(*inputs)[name] = coerceInputValue(value, input.Type)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func getWorkflowSecrets(ctx context.Context, rc *RunContext) map[string]string {
|
||||
if rc.caller != nil {
|
||||
job := rc.caller.runContext.Run.Job()
|
||||
secrets := job.Secrets()
|
||||
|
||||
if secrets == nil && job.InheritSecrets() {
|
||||
secrets = rc.caller.runContext.Config.Secrets
|
||||
}
|
||||
|
||||
// Interpolate into a new map. secrets may be the shared Config.Secrets (or the job's
|
||||
// map), which other parallel jobs read concurrently (e.g. log masking), so mutating it
|
||||
// in place is a data race.
|
||||
interpolated := make(map[string]string, len(secrets))
|
||||
for k, v := range secrets {
|
||||
interpolated[k] = rc.caller.runContext.ExprEval.Interpolate(ctx, v)
|
||||
}
|
||||
|
||||
return interpolated
|
||||
}
|
||||
|
||||
return rc.Config.Secrets
|
||||
}
|
||||
|
||||
func getWorkflowVars(_ context.Context, rc *RunContext) map[string]string {
|
||||
return rc.Config.Vars
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2020 The nektos/act Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package runner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dev/actionslib/pkg/exprparser"
|
||||
"gitea.dev/actionslib/pkg/model"
|
||||
assert "github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
yaml "go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
func createRunContext(t *testing.T) *RunContext {
|
||||
var yml yaml.Node
|
||||
err := yml.Encode(map[string][]any{
|
||||
"os": {"Linux", "Windows"},
|
||||
"foo": {"bar", "baz"},
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
return &RunContext{
|
||||
Config: &Config{
|
||||
Workdir: ".",
|
||||
Secrets: map[string]string{
|
||||
"CASE_INSENSITIVE_SECRET": "value",
|
||||
},
|
||||
Vars: map[string]string{
|
||||
"CASE_INSENSITIVE_VAR": "value",
|
||||
},
|
||||
},
|
||||
Env: map[string]string{
|
||||
"key": "value",
|
||||
},
|
||||
Run: &model.Run{
|
||||
JobID: "job1",
|
||||
Workflow: &model.Workflow{
|
||||
Name: "test-workflow",
|
||||
Jobs: map[string]*model.Job{
|
||||
"job1": {
|
||||
Strategy: &model.Strategy{
|
||||
RawMatrix: yml,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Matrix: map[string]any{
|
||||
"os": "Linux",
|
||||
"foo": "bar",
|
||||
},
|
||||
StepResults: map[string]*model.StepResult{
|
||||
"idwithnothing": {
|
||||
Conclusion: model.StepStatusSuccess,
|
||||
Outcome: model.StepStatusFailure,
|
||||
Outputs: map[string]string{
|
||||
"foowithnothing": "barwithnothing",
|
||||
},
|
||||
},
|
||||
"id-with-hyphens": {
|
||||
Conclusion: model.StepStatusSuccess,
|
||||
Outcome: model.StepStatusFailure,
|
||||
Outputs: map[string]string{
|
||||
"foo-with-hyphens": "bar-with-hyphens",
|
||||
},
|
||||
},
|
||||
"id_with_underscores": {
|
||||
Conclusion: model.StepStatusSuccess,
|
||||
Outcome: model.StepStatusFailure,
|
||||
Outputs: map[string]string{
|
||||
"foo_with_underscores": "bar_with_underscores",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateRunContext(t *testing.T) {
|
||||
rc := createRunContext(t)
|
||||
ee := rc.NewExpressionEvaluator(context.Background())
|
||||
|
||||
tables := []struct {
|
||||
in string
|
||||
out any
|
||||
errMesg string
|
||||
}{
|
||||
{" 1 ", 1, ""},
|
||||
// {"1 + 3", "4", ""},
|
||||
// {"(1 + 3) * -2", "-8", ""},
|
||||
{"'my text'", "my text", ""},
|
||||
{"contains('my text', 'te')", true, ""},
|
||||
{"contains('my TEXT', 'te')", true, ""},
|
||||
{"contains(fromJSON('[\"my text\"]'), 'te')", false, ""},
|
||||
{"contains(fromJSON('[\"foo\",\"bar\"]'), 'bar')", true, ""},
|
||||
{"startsWith('hello world', 'He')", true, ""},
|
||||
{"endsWith('hello world', 'ld')", true, ""},
|
||||
{"format('0:{0} 2:{2} 1:{1}', 'zero', 'one', 'two')", "0:zero 2:two 1:one", ""},
|
||||
{"join(fromJSON('[\"hello\"]'),'octocat')", "hello", ""},
|
||||
{"join(fromJSON('[\"hello\",\"mona\",\"the\"]'),'octocat')", "hellooctocatmonaoctocatthe", ""},
|
||||
{"join('hello','mona')", "hello", ""},
|
||||
{"toJSON(env)", "{\n \"ACT\": \"true\",\n \"ACT_SKIP_CHECKOUT\": \"true\",\n \"key\": \"value\"\n}", ""},
|
||||
{"toJson(env)", "{\n \"ACT\": \"true\",\n \"ACT_SKIP_CHECKOUT\": \"true\",\n \"key\": \"value\"\n}", ""},
|
||||
{"(fromJSON('{\"foo\":\"bar\"}')).foo", "bar", ""},
|
||||
{"(fromJson('{\"foo\":\"bar\"}')).foo", "bar", ""},
|
||||
{"(fromJson('[\"foo\",\"bar\"]'))[1]", "bar", ""},
|
||||
// github does return an empty string for non-existent files
|
||||
{"hashFiles('**/non-extant-files')", "", ""},
|
||||
{"hashFiles('**/non-extant-files', '**/more-non-extant-files')", "", ""},
|
||||
{"hashFiles('**/non.extant.files')", "", ""},
|
||||
{"hashFiles('**/non''extant''files')", "", ""},
|
||||
{"success()", true, ""},
|
||||
{"failure()", false, ""},
|
||||
{"always()", true, ""},
|
||||
{"cancelled()", false, ""},
|
||||
{"github.workflow", "test-workflow", ""},
|
||||
{"github.actor", "nektos/act", ""},
|
||||
{"github.run_id", "1", ""},
|
||||
{"github.run_number", "1", ""},
|
||||
{"job.status", "success", ""},
|
||||
{"matrix.os", "Linux", ""},
|
||||
{"matrix.foo", "bar", ""},
|
||||
{"env.key", "value", ""},
|
||||
{"secrets.CASE_INSENSITIVE_SECRET", "value", ""},
|
||||
{"secrets.case_insensitive_secret", "value", ""},
|
||||
{"vars.CASE_INSENSITIVE_VAR", "value", ""},
|
||||
{"vars.case_insensitive_var", "value", ""},
|
||||
{"format('{{0}}', 'test')", "{0}", ""},
|
||||
{"format('{{{0}}}', 'test')", "{test}", ""},
|
||||
{"format('}}')", "}", ""},
|
||||
{"format('echo Hello {0} ${{Test}}', 'World')", "echo Hello World ${Test}", ""},
|
||||
{"format('echo Hello {0} ${{Test}}', github.undefined_property)", "echo Hello ${Test}", ""},
|
||||
{"format('echo Hello {0}{1} ${{Te{0}st}}', github.undefined_property, 'World')", "echo Hello World ${Test}", ""},
|
||||
{"format('{0}', '{1}', 'World')", "{1}", ""},
|
||||
{"format('{{{0}', '{1}', 'World')", "{{1}", ""},
|
||||
}
|
||||
|
||||
for _, table := range tables {
|
||||
t.Run(table.in, func(t *testing.T) {
|
||||
assertObject := assert.New(t)
|
||||
out, err := ee.evaluate(context.Background(), table.in, exprparser.DefaultStatusCheckNone)
|
||||
if table.errMesg == "" {
|
||||
assertObject.NoError(err, table.in) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assertObject.Equal(table.out, out, table.in)
|
||||
} else {
|
||||
assertObject.Error(err, table.in) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assertObject.Equal(table.errMesg, err.Error(), table.in)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateStep(t *testing.T) {
|
||||
rc := createRunContext(t)
|
||||
step := &stepRun{
|
||||
RunContext: rc,
|
||||
}
|
||||
|
||||
ee := rc.NewStepExpressionEvaluator(context.Background(), step)
|
||||
|
||||
tables := []struct {
|
||||
in string
|
||||
out any
|
||||
errMesg string
|
||||
}{
|
||||
{"steps.idwithnothing.conclusion", model.StepStatusSuccess.String(), ""},
|
||||
{"steps.idwithnothing.outcome", model.StepStatusFailure.String(), ""},
|
||||
{"steps.idwithnothing.outputs.foowithnothing", "barwithnothing", ""},
|
||||
{"steps.id-with-hyphens.conclusion", model.StepStatusSuccess.String(), ""},
|
||||
{"steps.id-with-hyphens.outcome", model.StepStatusFailure.String(), ""},
|
||||
{"steps.id-with-hyphens.outputs.foo-with-hyphens", "bar-with-hyphens", ""},
|
||||
{"steps.id_with_underscores.conclusion", model.StepStatusSuccess.String(), ""},
|
||||
{"steps.id_with_underscores.outcome", model.StepStatusFailure.String(), ""},
|
||||
{"steps.id_with_underscores.outputs.foo_with_underscores", "bar_with_underscores", ""},
|
||||
}
|
||||
|
||||
for _, table := range tables {
|
||||
t.Run(table.in, func(t *testing.T) {
|
||||
assertObject := assert.New(t)
|
||||
out, err := ee.evaluate(context.Background(), table.in, exprparser.DefaultStatusCheckNone)
|
||||
if table.errMesg == "" {
|
||||
assertObject.NoError(err, table.in) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assertObject.Equal(table.out, out, table.in)
|
||||
} else {
|
||||
assertObject.Error(err, table.in) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assertObject.Equal(table.errMesg, err.Error(), table.in)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInterpolate(t *testing.T) {
|
||||
rc := &RunContext{
|
||||
Config: &Config{
|
||||
Workdir: ".",
|
||||
Secrets: map[string]string{
|
||||
"CASE_INSENSITIVE_SECRET": "value",
|
||||
},
|
||||
Vars: map[string]string{
|
||||
"CASE_INSENSITIVE_VAR": "value",
|
||||
},
|
||||
},
|
||||
Env: map[string]string{
|
||||
"KEYWITHNOTHING": "valuewithnothing",
|
||||
"KEY-WITH-HYPHENS": "value-with-hyphens",
|
||||
"KEY_WITH_UNDERSCORES": "value_with_underscores",
|
||||
"SOMETHING_TRUE": "true",
|
||||
"SOMETHING_FALSE": "false",
|
||||
},
|
||||
Run: &model.Run{
|
||||
JobID: "job1",
|
||||
Workflow: &model.Workflow{
|
||||
Name: "test-workflow",
|
||||
Jobs: map[string]*model.Job{
|
||||
"job1": {},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
ee := rc.NewExpressionEvaluator(context.Background())
|
||||
tables := []struct {
|
||||
in string
|
||||
out string
|
||||
}{
|
||||
{" text ", " text "},
|
||||
{" $text ", " $text "},
|
||||
{" ${text} ", " ${text} "},
|
||||
{" ${{ 1 }} to ${{2}} ", " 1 to 2 "},
|
||||
{" ${{ (true || false) }} to ${{2}} ", " true to 2 "},
|
||||
{" ${{ (false || '}}' ) }} to ${{2}} ", " }} to 2 "},
|
||||
{" ${{ env.KEYWITHNOTHING }} ", " valuewithnothing "},
|
||||
{" ${{ env.KEY-WITH-HYPHENS }} ", " value-with-hyphens "},
|
||||
{" ${{ env.KEY_WITH_UNDERSCORES }} ", " value_with_underscores "},
|
||||
{"${{ secrets.CASE_INSENSITIVE_SECRET }}", "value"},
|
||||
{"${{ secrets.case_insensitive_secret }}", "value"},
|
||||
{"${{ vars.CASE_INSENSITIVE_VAR }}", "value"},
|
||||
{"${{ vars.case_insensitive_var }}", "value"},
|
||||
{"${{ env.UNKNOWN }}", ""},
|
||||
{"${{ env.SOMETHING_TRUE }}", "true"},
|
||||
{"${{ env.SOMETHING_FALSE }}", "false"},
|
||||
{"${{ !env.SOMETHING_TRUE }}", "false"},
|
||||
{"${{ !env.SOMETHING_FALSE }}", "false"},
|
||||
{"${{ !env.SOMETHING_TRUE && true }}", "false"},
|
||||
{"${{ !env.SOMETHING_FALSE && true }}", "false"},
|
||||
{"${{ env.SOMETHING_TRUE && true }}", "true"},
|
||||
{"${{ env.SOMETHING_FALSE && true }}", "true"},
|
||||
{"${{ !env.SOMETHING_TRUE || true }}", "true"},
|
||||
{"${{ !env.SOMETHING_FALSE || true }}", "true"},
|
||||
{"${{ !env.SOMETHING_TRUE && false }}", "false"},
|
||||
{"${{ !env.SOMETHING_FALSE && false }}", "false"},
|
||||
{"${{ !env.SOMETHING_TRUE || false }}", "false"},
|
||||
{"${{ !env.SOMETHING_FALSE || false }}", "false"},
|
||||
{"${{ env.SOMETHING_TRUE || false }}", "true"},
|
||||
{"${{ env.SOMETHING_FALSE || false }}", "false"},
|
||||
{"${{ env.SOMETHING_FALSE }} && ${{ env.SOMETHING_TRUE }}", "false && true"},
|
||||
{"${{ fromJSON('{}') < 2 }}", "false"},
|
||||
{"${{ 1 }}", "1"},
|
||||
{"${{ 1.0 }}", "1"},
|
||||
{"${{ null }}", ""},
|
||||
{"${{ fromJSON('[1,2]') }}", "Array"},
|
||||
{"${{ fromJSON('{\"a\":1}') }}", "Object"},
|
||||
// a malformed part must not restructure its neighbours, and it interpolates to nothing
|
||||
{"${{ 1) && (2 }}", ""},
|
||||
{"run ${{ 1) && (2 }} now", ""},
|
||||
{"${{ 1", "${{ 1"},
|
||||
}
|
||||
|
||||
for _, table := range tables {
|
||||
t.Run("interpolate", func(t *testing.T) {
|
||||
assertObject := assert.New(t)
|
||||
out := ee.Interpolate(context.Background(), table.in)
|
||||
assertObject.Equal(table.out, out, table.in)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetEvaluatorInputsBoolean(t *testing.T) {
|
||||
workflows := map[string]string{
|
||||
"workflow_call": `
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
flag:
|
||||
type: boolean
|
||||
default: true
|
||||
name:
|
||||
type: string
|
||||
default: gitea
|
||||
`,
|
||||
"workflow_dispatch": `
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
flag:
|
||||
type: boolean
|
||||
default: true
|
||||
name:
|
||||
type: string
|
||||
default: gitea
|
||||
`,
|
||||
}
|
||||
|
||||
tables := []struct {
|
||||
name string
|
||||
event map[string]any
|
||||
flag any
|
||||
}{
|
||||
{
|
||||
// Gitea >= 1.27 resolves the inputs server-side and sends native JSON types
|
||||
name: "native bool true",
|
||||
event: map[string]any{"inputs": map[string]any{"flag": true}},
|
||||
flag: true,
|
||||
},
|
||||
{
|
||||
name: "native bool false",
|
||||
event: map[string]any{"inputs": map[string]any{"flag": false}},
|
||||
flag: false,
|
||||
},
|
||||
{
|
||||
name: "string true",
|
||||
event: map[string]any{"inputs": map[string]any{"flag": "true"}},
|
||||
flag: true,
|
||||
},
|
||||
{
|
||||
name: "string false",
|
||||
event: map[string]any{"inputs": map[string]any{"flag": "false"}},
|
||||
flag: false,
|
||||
},
|
||||
{
|
||||
name: "default is used when the event carries no inputs",
|
||||
event: map[string]any{},
|
||||
flag: true,
|
||||
},
|
||||
}
|
||||
|
||||
for eventName, workflow := range workflows {
|
||||
for _, table := range tables {
|
||||
t.Run(eventName+"/"+table.name, func(t *testing.T) {
|
||||
wf, err := model.ReadWorkflow(strings.NewReader(workflow))
|
||||
require.NoError(t, err)
|
||||
|
||||
rc := &RunContext{
|
||||
Config: &Config{Workdir: "."},
|
||||
Run: &model.Run{JobID: "job1", Workflow: wf},
|
||||
}
|
||||
ghc := &model.GithubContext{EventName: eventName, Event: table.event}
|
||||
|
||||
inputs := getEvaluatorInputs(context.Background(), rc, nil, ghc)
|
||||
assert.Equal(t, table.flag, inputs["flag"])
|
||||
assert.Equal(t, "gitea", inputs["name"])
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,52 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package runner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"gitea.com/gitea/runner/internal/act/container"
|
||||
|
||||
mobyclient "github.com/moby/moby/client"
|
||||
)
|
||||
|
||||
// requireLinuxDocker skips on non-Linux hosts. Some integration workflows need Docker features
|
||||
// that only a Linux daemon provides (host networking, host /proc bind mounts); Docker Desktop
|
||||
// on macOS/Windows does not, so those tests can only run on Linux.
|
||||
func requireLinuxDocker(t *testing.T) {
|
||||
t.Helper()
|
||||
if runtime.GOOS != "linux" {
|
||||
t.Skip("skipping: requires a Linux Docker host")
|
||||
}
|
||||
}
|
||||
|
||||
// requireDocker skips the test unless a reachable docker daemon is available.
|
||||
// GetDockerClient succeeds even without a running daemon (its ping is best-effort),
|
||||
// so the daemon has to be pinged explicitly here to decide whether to skip.
|
||||
func requireDocker(t *testing.T) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
cli, err := container.GetDockerClient(ctx)
|
||||
if err != nil {
|
||||
t.Skipf("skipping: docker client unavailable: %v", err)
|
||||
}
|
||||
defer cli.Close()
|
||||
if _, err := cli.Ping(ctx, mobyclient.PingOptions{}); err != nil {
|
||||
t.Skipf("skipping: docker daemon unreachable: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
t.Helper()
|
||||
for _, tool := range tools {
|
||||
if _, err := exec.LookPath(tool); err != nil {
|
||||
t.Skipf("skipping: required host tool %q not found: %v", tool, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,674 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2022 The nektos/act Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package runner
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"path"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"gitea.com/gitea/runner/internal/act/common"
|
||||
"gitea.com/gitea/runner/internal/act/container"
|
||||
|
||||
"gitea.dev/actionslib/pkg/exprparser"
|
||||
"gitea.dev/actionslib/pkg/model"
|
||||
)
|
||||
|
||||
const maxJobSummaryBytes = 1024 * 1024
|
||||
|
||||
// jobSummaryTruncationMarker is appended to a summary that exceeded the size limit
|
||||
// so the rendered output makes the truncation visible instead of silently cutting off.
|
||||
const jobSummaryTruncationMarker = "\n\n---\n\n*Job summary truncated: it exceeded the maximum allowed size.*\n"
|
||||
|
||||
var (
|
||||
jobSummaryUploadRetryDelay = time.Second
|
||||
// jobSummaryUploadRequestTimeout bounds a single step upload request. It is kept
|
||||
// below jobSummaryUploadPhaseTimeout so one slow or unreachable request times out
|
||||
// and lets the remaining steps still upload within the phase budget, instead of a
|
||||
// single stuck request consuming the whole phase.
|
||||
jobSummaryUploadRequestTimeout = 5 * time.Second
|
||||
// jobSummaryUploadPhaseTimeout bounds the total time spent uploading all step
|
||||
// summaries. The uploads run inside the job cleanup budget that is also used to
|
||||
// stop and remove the container, so a slow or unreachable endpoint must not be
|
||||
// allowed to consume it; this keeps the remaining budget available for teardown.
|
||||
jobSummaryUploadPhaseTimeout = 15 * time.Second
|
||||
)
|
||||
|
||||
type jobInfo interface {
|
||||
matrix() map[string]any
|
||||
steps() []*model.Step
|
||||
startContainer() common.Executor
|
||||
stopContainer() common.Executor
|
||||
closeContainer() common.Executor
|
||||
interpolateOutputs() common.Executor
|
||||
result(result string)
|
||||
}
|
||||
|
||||
// reportStepError records a step error so the job is reported failed — except a
|
||||
// cancellation, which is an interruption, not a failure.
|
||||
func reportStepError(ctx context.Context, rc *RunContext, err error) {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
// Defer to the job context: a genuine cancel reports cancelled, a stray teardown
|
||||
// cancellation on a live ctx is ignored — never a step FAILURE.
|
||||
rc.markInterrupted(ctx.Err())
|
||||
return
|
||||
}
|
||||
common.Logger(ctx).Errorf("##[error]%s", EscapeCommandData(err.Error()))
|
||||
common.SetJobError(ctx, err)
|
||||
rc.markFailed()
|
||||
}
|
||||
|
||||
// actionPreparer is implemented by steps that download an action before they run, so the job
|
||||
// executor can fetch all of them up front.
|
||||
type actionPreparer interface {
|
||||
prepareActionExecutor() common.Executor
|
||||
actionDownloadInfo() (reference, sha string, ok bool)
|
||||
}
|
||||
|
||||
// printPrepareActions downloads every action the job uses before its first step runs and reports
|
||||
// them as actions/runner's "Prepare all required actions" section does. The steps still call
|
||||
// prepareActionExecutor themselves; it is a no-op once the action is resolved here.
|
||||
func printPrepareActions(rc *RunContext, preparers []actionPreparer) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
if len(preparers) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
rawLogger := common.Logger(ctx).WithField(rawOutputField, true)
|
||||
rawLogger.Infof("Prepare all required actions")
|
||||
|
||||
for _, preparer := range preparers {
|
||||
if err := preparer.prepareActionExecutor()(ctx); err != nil {
|
||||
// No step has run yet, so the failure belongs to the job.
|
||||
reportStepError(ctx, rc, err)
|
||||
return err
|
||||
}
|
||||
reference, sha, ok := preparer.actionDownloadInfo()
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if sha == "" {
|
||||
rawLogger.Infof("Download action repository '%s'", reference)
|
||||
} else {
|
||||
rawLogger.Infof("Download action repository '%s' (SHA:%s)", reference, sha)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// printCompleteJobName closes the setup section the way actions/runner ends its "Set up job" step.
|
||||
func printCompleteJobName(rc *RunContext) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
// Name holds a matrix combination; JobName is the shared name GitHub reports.
|
||||
name := rc.JobName
|
||||
if name == "" {
|
||||
name = rc.Name
|
||||
}
|
||||
if name == "" && rc.Run != nil {
|
||||
name = rc.Run.JobID
|
||||
}
|
||||
common.Logger(ctx).WithField(rawOutputField, true).Infof("Complete job name: %s", name)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executor {
|
||||
steps := make([]common.Executor, 0)
|
||||
preSteps := make([]common.Executor, 0)
|
||||
// Collected separately: every action is downloaded before the first pre step runs.
|
||||
stepPreSteps := make([]common.Executor, 0)
|
||||
preparers := make([]actionPreparer, 0)
|
||||
var postExecutor common.Executor
|
||||
|
||||
steps = append(steps, func(ctx context.Context) error {
|
||||
logger := common.Logger(ctx)
|
||||
if len(info.matrix()) > 0 {
|
||||
logger.Infof("Matrix: %v", info.matrix())
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
infoSteps := info.steps()
|
||||
|
||||
if len(infoSteps) == 0 {
|
||||
return common.NewDebugExecutor("No steps found")
|
||||
}
|
||||
|
||||
preSteps = append(preSteps, func(ctx context.Context) error {
|
||||
// Have to be skipped for some Tests
|
||||
if rc.Run == nil {
|
||||
return nil
|
||||
}
|
||||
rc.ExprEval = rc.NewExpressionEvaluator(ctx)
|
||||
// evaluate environment variables since they can contain
|
||||
// GitHub's special environment variables.
|
||||
for k, v := range rc.GetEnv() {
|
||||
rc.Env[k] = rc.ExprEval.Interpolate(ctx, v)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
for i, stepModel := range infoSteps {
|
||||
if stepModel == nil {
|
||||
return func(ctx context.Context) error {
|
||||
return fmt.Errorf("invalid Step %v: missing run or uses key", i)
|
||||
}
|
||||
}
|
||||
if stepModel.ID == "" {
|
||||
stepModel.ID = strconv.Itoa(i)
|
||||
}
|
||||
stepModel.Number = i
|
||||
|
||||
step, err := sf.newStep(stepModel, rc)
|
||||
if err != nil {
|
||||
return common.NewErrorExecutor(err)
|
||||
}
|
||||
|
||||
if preparer, ok := step.(actionPreparer); ok {
|
||||
preparers = append(preparers, preparer)
|
||||
}
|
||||
|
||||
stepIdx := stepModel.Number
|
||||
preExec := step.pre()
|
||||
stepPreSteps = append(stepPreSteps, useStepLogger(rc, stepModel, stepStagePre, func(ctx context.Context) error {
|
||||
rc.CurrentStepIndex = stepIdx
|
||||
preErr := preExec(ctx)
|
||||
if preErr != nil {
|
||||
reportStepError(ctx, rc, preErr)
|
||||
} else if ctx.Err() != nil {
|
||||
reportStepError(ctx, rc, ctx.Err())
|
||||
}
|
||||
return preErr
|
||||
}))
|
||||
|
||||
stepExec := step.main()
|
||||
steps = append(steps, useStepLogger(rc, stepModel, stepStageMain, func(ctx context.Context) error {
|
||||
rc.CurrentStepIndex = stepIdx
|
||||
err := stepExec(ctx)
|
||||
if err != nil {
|
||||
reportStepError(ctx, rc, err)
|
||||
} else if ctx.Err() != nil {
|
||||
reportStepError(ctx, rc, ctx.Err())
|
||||
}
|
||||
return nil
|
||||
}))
|
||||
|
||||
postFn := step.post()
|
||||
postExec := useStepLogger(rc, stepModel, stepStagePost, func(ctx context.Context) error {
|
||||
rc.CurrentStepIndex = stepIdx
|
||||
err := postFn(ctx)
|
||||
if err != nil {
|
||||
reportStepError(ctx, rc, err)
|
||||
} else if ctx.Err() != nil {
|
||||
reportStepError(ctx, rc, ctx.Err())
|
||||
}
|
||||
return err
|
||||
})
|
||||
if postExecutor != nil {
|
||||
// run the post executor in reverse order
|
||||
postExecutor = postExec.Finally(postExecutor)
|
||||
} else {
|
||||
postExecutor = postExec
|
||||
}
|
||||
}
|
||||
|
||||
// The setup section of the job log. The started hook goes first, so what it sets up is
|
||||
// in place for the first action download and the first step.
|
||||
preSteps = append(preSteps, rc.runJobStartedHook)
|
||||
preSteps = append(preSteps, printPrepareActions(rc, preparers))
|
||||
preSteps = append(preSteps, stepPreSteps...)
|
||||
preSteps = append(preSteps, printCompleteJobName(rc))
|
||||
|
||||
// Ahead of the teardown below, while the job environment is still up.
|
||||
postExecutor = postExecutor.Finally(rc.runJobCompletedHook)
|
||||
|
||||
postExecutor = postExecutor.Finally(func(ctx context.Context) error {
|
||||
jobError := common.JobError(ctx)
|
||||
var err error
|
||||
// jobError == nil keeps a failed job's container alive for post-mortem debugging when
|
||||
// AutoRemove is off (the act-CLI --rm behavior; the shipped runner always sets
|
||||
// AutoRemove). A cancelled run is not a failure to inspect, and the cancel-path post
|
||||
// context now carries its own error container so a failing post step makes jobError
|
||||
// non-nil — OR in rc.jobCancelled so cancellation still always tears the container down.
|
||||
if rc.Config.AutoRemove || jobError == nil || rc.jobCancelled {
|
||||
// always allow 1 min for stopping and removing the runner, even if we were cancelled
|
||||
ctx, cancel := context.WithTimeout(common.WithLogger(context.Background(), common.Logger(ctx)), time.Minute)
|
||||
defer cancel()
|
||||
|
||||
logger := common.Logger(ctx)
|
||||
tryUploadJobSummary(ctx, rc)
|
||||
// For Gitea
|
||||
// We don't need to call `stopServiceContainers` here since it will be called by following `info.stopContainer`
|
||||
// logger.Infof("Cleaning up services for job %s", rc.JobName)
|
||||
// if err := rc.stopServiceContainers()(ctx); err != nil {
|
||||
// logger.Errorf("Error while cleaning services: %v", err)
|
||||
// }
|
||||
|
||||
logger.Infof("Cleaning up container for job %s", rc.JobName)
|
||||
if err = info.stopContainer()(ctx); err != nil {
|
||||
logger.Errorf("##[error]%s", EscapeCommandData("Error while stop job container: "+err.Error()))
|
||||
}
|
||||
|
||||
// For Gitea
|
||||
// We don't need to call `NewDockerNetworkRemoveExecutor` here since it is called by above `info.stopContainer`
|
||||
// if !rc.IsHostEnv(ctx) && rc.Config.ContainerNetworkMode == "" {
|
||||
// // clean network in docker mode only
|
||||
// // if the value of `ContainerNetworkMode` is empty string,
|
||||
// // it means that the network to which containers are connecting is created by `runner`,
|
||||
// // so, we should remove the network at last.
|
||||
// networkName, _ := rc.networkName()
|
||||
// logger.Infof("Cleaning up network for job %s, and network name is: %s", rc.JobName, networkName)
|
||||
// if err := container.NewDockerNetworkRemoveExecutor(networkName)(ctx); err != nil {
|
||||
// logger.Errorf("Error while cleaning network: %v", err)
|
||||
// }
|
||||
// }
|
||||
}
|
||||
setJobResult(ctx, info, rc, jobError == nil)
|
||||
setJobOutputs(ctx, rc)
|
||||
|
||||
return err
|
||||
})
|
||||
|
||||
stepsExecutor := newStepsExecutor(rc, preSteps, steps)
|
||||
|
||||
return common.NewPipelineExecutor(info.startContainer(), stepsExecutor.
|
||||
Finally(func(ctx context.Context) error {
|
||||
// Record an interrupt (backstop for interrupts that land outside the main
|
||||
// step loop) so the post steps observe the cancelled/failed job status.
|
||||
rc.markInterrupted(ctx.Err())
|
||||
postCtx, cancel := postStepsContext(ctx)
|
||||
defer cancel()
|
||||
return postExecutor(postCtx)
|
||||
}).
|
||||
Finally(info.interpolateOutputs()).
|
||||
Finally(info.closeContainer()))
|
||||
}
|
||||
|
||||
// postStepsContext derives the context used to run the job's post/cleanup steps from the
|
||||
// finished main-pipeline context. Cleanup has to run even when the run was interrupted, so the
|
||||
// returned context always carries a fresh bounded deadline and is never itself cancelled.
|
||||
//
|
||||
// - context.Canceled (server cancel): detach from the cancelled context via a fresh root so
|
||||
// the post steps can run.
|
||||
// - context.DeadlineExceeded (job timeout): detach the deadline with WithoutCancel, which
|
||||
// keeps the original values — including the job-error container — so the timeout failure and
|
||||
// any post-step error are preserved and the job is still reported as failed.
|
||||
// - otherwise: run on the live context unchanged.
|
||||
func postStepsContext(ctx context.Context) (context.Context, context.CancelFunc) {
|
||||
switch ctx.Err() {
|
||||
case context.Canceled:
|
||||
// The cancelled context is abandoned for a fresh root, which drops the job-error
|
||||
// container installed at the job root. Re-attach a fresh one so a failing post step
|
||||
// records its error via SetJobError instead of panicking on a nil container.
|
||||
return context.WithTimeout(common.WithJobErrorContainer(common.WithLogger(context.Background(), common.Logger(ctx))), 5*time.Minute)
|
||||
case context.DeadlineExceeded:
|
||||
return context.WithTimeout(context.WithoutCancel(ctx), 5*time.Minute)
|
||||
default:
|
||||
return ctx, func() {}
|
||||
}
|
||||
}
|
||||
|
||||
// newStepsExecutor sequences the job's pre steps and main steps.
|
||||
//
|
||||
// The pre steps run as a normal pipeline that short-circuits on the first failure or
|
||||
// cancellation. The main-steps executor then runs unconditionally — even if a pre step failed
|
||||
// or the job was interrupted — so always()/cancelled()/failure() main steps still run, mirroring
|
||||
// GitHub Actions. This is safe because each main step re-evaluates its own `if` (a pre-step
|
||||
// failure flips the expression job status to failure, so success()-default steps skip) and
|
||||
// newMainStepsExecutor detaches from an interrupted context before running the remaining steps.
|
||||
//
|
||||
// A pre-step failure or interrupt is still propagated so the job is reported with the correct
|
||||
// conclusion; the pre error takes precedence since it happened first.
|
||||
func newStepsExecutor(rc *RunContext, preSteps, steps []common.Executor) common.Executor {
|
||||
preExecutor := common.NewPipelineExecutor(preSteps...)
|
||||
mainExecutor := newMainStepsExecutor(rc, steps)
|
||||
return func(ctx context.Context) error {
|
||||
preErr := preExecutor(ctx)
|
||||
mainErr := mainExecutor(ctx)
|
||||
if preErr != nil {
|
||||
return preErr
|
||||
}
|
||||
return mainErr
|
||||
}
|
||||
}
|
||||
|
||||
// newMainStepsExecutor runs the job's main-stage step executors in order. Unlike a plain
|
||||
// pipeline, an interruption (context.Canceled from a server cancel, or context.DeadlineExceeded
|
||||
// from the job timeout) does not abandon the remaining steps: it marks the job cancelled when
|
||||
// appropriate and keeps iterating under a fresh, bounded context so steps whose `if` still
|
||||
// evaluates true — always() and cancelled() — run for cleanup, mirroring GitHub Actions. Steps
|
||||
// that default to success() skip themselves because success() is false once the job is no longer
|
||||
// successful. The main-step wrappers report their own errors and return nil, so the loop drives
|
||||
// step ordering off the context, not return values.
|
||||
func newMainStepsExecutor(rc *RunContext, steps []common.Executor) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
for i, step := range steps {
|
||||
if ctx.Err() != nil {
|
||||
return runMainStepsAfterInterrupt(ctx, rc, steps[i:])
|
||||
}
|
||||
_ = step(ctx)
|
||||
}
|
||||
// An interrupt can land during the final step, after the loop's last context
|
||||
// check; record it so the post steps still observe the cancelled/failed status.
|
||||
rc.markInterrupted(ctx.Err())
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// runMainStepsAfterInterrupt runs the remaining main steps after the job context was cancelled or
|
||||
// timed out. It detaches from the interrupted context (keeping its values: logger and job error)
|
||||
// and applies a fresh deadline so always()/cancelled() steps run to completion. The original
|
||||
// interrupt error is returned so callers up the chain still see the job as cancelled/timed out.
|
||||
func runMainStepsAfterInterrupt(ctx context.Context, rc *RunContext, steps []common.Executor) error {
|
||||
interruptErr := ctx.Err()
|
||||
rc.markInterrupted(interruptErr)
|
||||
freshCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Minute)
|
||||
defer cancel()
|
||||
for _, step := range steps {
|
||||
_ = step(freshCtx)
|
||||
}
|
||||
return interruptErr
|
||||
}
|
||||
|
||||
func setJobResult(ctx context.Context, info jobInfo, rc *RunContext, success bool) {
|
||||
logger := common.Logger(ctx)
|
||||
|
||||
// Matrix combinations share one *model.Job and run in parallel; serialize the
|
||||
// read-modify-write of the job result so a failing combination is not lost-updated by a
|
||||
// concurrent succeeding one.
|
||||
job := rc.Run.Job()
|
||||
var continueOnError bool
|
||||
if !success {
|
||||
// Use a fresh context so an expired job timeout cannot block expression evaluation.
|
||||
evalCtx := common.WithLogger(context.Background(), common.Logger(ctx))
|
||||
continueOnError = evaluateJobContinueOnError(evalCtx, rc, job)
|
||||
}
|
||||
jobResult := func() string {
|
||||
defer lockJob(job)()
|
||||
result := "success"
|
||||
// we have only one result for a whole matrix build, so we need
|
||||
// to keep an existing result state if we run a matrix
|
||||
if len(info.matrix()) > 0 && job.Result != "" {
|
||||
result = job.Result
|
||||
}
|
||||
if !success {
|
||||
result = "failure"
|
||||
job.SetContinueOnError(continueOnError)
|
||||
}
|
||||
info.result(result)
|
||||
return result
|
||||
}()
|
||||
|
||||
if rc.caller != nil {
|
||||
// set reusable workflow job result
|
||||
rc.caller.setReusedWorkflowJobResult(rc.JobName, jobResult) // For Gitea
|
||||
return
|
||||
}
|
||||
|
||||
jobResultMessage := "succeeded"
|
||||
if jobResult != "success" {
|
||||
jobResultMessage = "failed"
|
||||
}
|
||||
|
||||
logger.WithField("jobResult", jobResult).Infof("Job %s", jobResultMessage)
|
||||
}
|
||||
|
||||
func setJobOutputs(ctx context.Context, rc *RunContext) {
|
||||
if rc.caller != nil {
|
||||
// map outputs for reusable workflows
|
||||
callerOutputs := make(map[string]string)
|
||||
|
||||
ee := rc.NewExpressionEvaluator(ctx)
|
||||
|
||||
for k, v := range rc.Run.Workflow.WorkflowCallConfig().Outputs {
|
||||
callerOutputs[k] = ee.Interpolate(ctx, ee.Interpolate(ctx, v.Value))
|
||||
}
|
||||
|
||||
// Matrix combinations of a reusable-workflow caller share the caller's *model.Job;
|
||||
// serialize the write so parallel combos don't race on its Outputs field.
|
||||
callerJob := rc.caller.runContext.Run.Job()
|
||||
defer lockJob(callerJob)()
|
||||
callerJob.Outputs = callerOutputs
|
||||
}
|
||||
}
|
||||
|
||||
// applyJobTimeout applies the job-level timeout-minutes to ctx, mirroring the
|
||||
// step-level evaluateStepTimeout in step.go.
|
||||
func applyJobTimeout(ctx context.Context, rc *RunContext, job *model.Job) (context.Context, context.CancelFunc) {
|
||||
timeout := rc.ExprEval.Interpolate(ctx, job.TimeoutMinutes)
|
||||
if timeout != "" {
|
||||
if timeoutMinutes, err := strconv.ParseInt(timeout, 10, 64); err == nil {
|
||||
return context.WithTimeout(ctx, time.Duration(timeoutMinutes)*time.Minute)
|
||||
}
|
||||
}
|
||||
return ctx, func() {}
|
||||
}
|
||||
|
||||
// evaluateJobContinueOnError evaluates the job-level continue-on-error expression.
|
||||
func evaluateJobContinueOnError(ctx context.Context, rc *RunContext, job *model.Job) bool {
|
||||
expr := strings.TrimSpace(job.RawContinueOnError)
|
||||
if expr == "" {
|
||||
return false
|
||||
}
|
||||
continueOnError, err := EvalBool(ctx, rc.NewExpressionEvaluator(ctx), expr, exprparser.DefaultStatusCheckNone)
|
||||
if err != nil {
|
||||
common.Logger(ctx).Warnf("continue-on-error expression %q evaluation failed: %v", expr, err)
|
||||
return false
|
||||
}
|
||||
return continueOnError
|
||||
}
|
||||
|
||||
func tryUploadJobSummary(ctx context.Context, rc *RunContext) {
|
||||
if rc == nil || rc.JobContainer == nil || rc.Config == nil {
|
||||
return
|
||||
}
|
||||
// Bound the whole upload phase so a slow or unreachable endpoint cannot consume
|
||||
// the job cleanup budget reserved for stopping and removing the container.
|
||||
ctx, cancel := context.WithTimeout(ctx, jobSummaryUploadPhaseTimeout)
|
||||
defer cancel()
|
||||
env := rc.GetEnv()
|
||||
caps := strings.TrimSpace(env["GITEA_ACTIONS_CAPABILITIES"])
|
||||
if !hasJobSummaryCapability(caps) {
|
||||
// Server did not advertise support. Do not attempt upload.
|
||||
return
|
||||
}
|
||||
runtimeURL := strings.TrimSpace(env["ACTIONS_RUNTIME_URL"])
|
||||
runtimeToken := strings.TrimSpace(env["ACTIONS_RUNTIME_TOKEN"])
|
||||
runID := strings.TrimSpace(env["GITEA_RUN_ID"])
|
||||
if runtimeURL == "" || runtimeToken == "" || runID == "" {
|
||||
return
|
||||
}
|
||||
if rc.Run == nil || rc.Run.Job() == nil {
|
||||
return
|
||||
}
|
||||
// The numeric ActionRunJob ID is not exposed in the proto Task message or task context,
|
||||
// but the server signs it into the ACTIONS_RUNTIME_TOKEN JWT claims. We decode the
|
||||
// unverified claims to retrieve it; the server re-verifies the token on the request.
|
||||
jobID := extractJobIDFromRuntimeToken(runtimeToken)
|
||||
if jobID <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
base := strings.TrimRight(runtimeURL, "/") + "/_apis/pipelines/workflows/" + runID +
|
||||
"/jobs/" + strconv.FormatInt(jobID, 10) + "/steps/"
|
||||
actPath := rc.JobContainer.GetActPath()
|
||||
// Reuse a single client across all step uploads so connections can be pooled.
|
||||
client := &http.Client{Timeout: jobSummaryUploadRequestTimeout}
|
||||
for i := range rc.Run.Job().Steps {
|
||||
summaryPath := path.Join(actPath, "workflow", "step-summary-"+strconv.Itoa(i)+".md")
|
||||
body, ok := readSingleFileFromContainerArchive(ctx, rc.JobContainer, summaryPath, maxJobSummaryBytes)
|
||||
if !ok || len(body) == 0 {
|
||||
continue
|
||||
}
|
||||
uploadJobSummary(ctx, client, base+strconv.Itoa(i)+"/summary", runtimeToken, body)
|
||||
}
|
||||
}
|
||||
|
||||
// extractJobIDFromRuntimeToken returns the JobID claim from an ACTIONS_RUNTIME_TOKEN JWT
|
||||
// without verifying its signature. Returns 0 if the token is unparseable or has no JobID.
|
||||
func extractJobIDFromRuntimeToken(token string) int64 {
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) != 3 {
|
||||
return 0
|
||||
}
|
||||
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
var claims struct {
|
||||
JobID int64 `json:"JobID"`
|
||||
}
|
||||
if err := json.Unmarshal(payload, &claims); err != nil {
|
||||
return 0
|
||||
}
|
||||
return claims.JobID
|
||||
}
|
||||
|
||||
func hasJobSummaryCapability(caps string) bool {
|
||||
return slices.Contains(strings.FieldsFunc(caps, func(r rune) bool {
|
||||
return r == ',' || unicode.IsSpace(r)
|
||||
}), "job-summary")
|
||||
}
|
||||
|
||||
func uploadJobSummary(ctx context.Context, client *http.Client, url, runtimeToken string, body []byte) {
|
||||
logger := common.Logger(ctx)
|
||||
|
||||
var lastStatus int
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < 2; attempt++ {
|
||||
status, err := putJobSummary(ctx, client, url, runtimeToken, body)
|
||||
if err == nil && status/100 == 2 {
|
||||
return
|
||||
}
|
||||
lastStatus = status
|
||||
lastErr = err
|
||||
if attempt == 1 || !isTransientJobSummaryUploadFailure(status, err) {
|
||||
break
|
||||
}
|
||||
timer := time.NewTimer(jobSummaryUploadRetryDelay)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
lastErr = ctx.Err()
|
||||
attempt = 1
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
|
||||
// Best-effort only; do not fail job, but log because capability was advertised.
|
||||
if lastErr != nil {
|
||||
logger.WithError(lastErr).Warn("job summary upload failed")
|
||||
return
|
||||
}
|
||||
logger.Warnf("job summary upload failed: status=%d", lastStatus)
|
||||
}
|
||||
|
||||
func putJobSummary(ctx context.Context, client *http.Client, url, runtimeToken string, body []byte) (int, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+runtimeToken)
|
||||
req.Header.Set("Content-Type", "text/markdown; charset=utf-8")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
return resp.StatusCode, nil
|
||||
}
|
||||
|
||||
func isTransientJobSummaryUploadFailure(status int, err error) bool {
|
||||
return err != nil || status == http.StatusRequestTimeout || status == http.StatusTooManyRequests || status/100 == 5
|
||||
}
|
||||
|
||||
func readSingleFileFromContainerArchive(ctx context.Context, env container.ExecutionsEnvironment, p string, maxBytes int64) ([]byte, bool) {
|
||||
rc, err := env.GetContainerArchive(ctx, p)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
defer rc.Close()
|
||||
|
||||
tr := tar.NewReader(rc)
|
||||
for {
|
||||
header, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
return nil, false
|
||||
}
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
if header.Typeflag != tar.TypeReg {
|
||||
continue
|
||||
}
|
||||
if !archiveEntryMatchesPath(header.Name, p) {
|
||||
continue
|
||||
}
|
||||
// Summaries larger than the limit are truncated rather than dropped, so the
|
||||
// user still gets the leading content (mirroring how GitHub caps oversized
|
||||
// step summaries instead of discarding them). Read one extra byte so an
|
||||
// over-limit file is detected from the actual stream rather than trusting
|
||||
// header.Size, then cap the returned content at maxBytes.
|
||||
b, err := io.ReadAll(io.LimitReader(tr, maxBytes+1))
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
if int64(len(b)) > maxBytes {
|
||||
// Reserve room for the marker so the marked-up result still fits in maxBytes.
|
||||
marker := []byte(jobSummaryTruncationMarker)
|
||||
keep := max(maxBytes-int64(len(marker)), 0)
|
||||
b = append(b[:keep], marker...)
|
||||
common.Logger(ctx).Warnf("job summary truncated: path=%s max=%d", p, maxBytes)
|
||||
}
|
||||
return b, true
|
||||
}
|
||||
}
|
||||
|
||||
func archiveEntryMatchesPath(entryName, requestedPath string) bool {
|
||||
entryName = path.Clean(strings.TrimPrefix(entryName, "/"))
|
||||
requestedPath = path.Clean(strings.TrimPrefix(requestedPath, "/"))
|
||||
return entryName == requestedPath || entryName == path.Base(requestedPath)
|
||||
}
|
||||
|
||||
func useStepLogger(rc *RunContext, stepModel *model.Step, stage stepStage, executor common.Executor) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
ctx = withStepLogger(ctx, stepModel.Number, stepModel.ID, rc.ExprEval.Interpolate(ctx, stepModel.String()), stage.String())
|
||||
|
||||
rawLogger := common.Logger(ctx).WithField("raw_output", true)
|
||||
logWriter := common.NewLineWriter(rc.commandHandler(ctx), func(s string) bool {
|
||||
if rc.Config.LogOutput {
|
||||
rawLogger.Infof("%s", s)
|
||||
} else {
|
||||
rawLogger.Debugf("%s", s)
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
oldout, olderr := rc.JobContainer.ReplaceLogWriter(logWriter, logWriter)
|
||||
defer rc.JobContainer.ReplaceLogWriter(oldout, olderr)
|
||||
|
||||
// Flush any buffered, not-yet-newline-terminated trailing line once the
|
||||
// step has finished, so the final line of the step's output is not lost
|
||||
// when it is not newline-terminated.
|
||||
defer common.FlushWriter(logWriter)
|
||||
|
||||
return executor(ctx)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,115 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package runner
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"fmt"
|
||||
"maps"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"gitea.com/gitea/runner/internal/act/common"
|
||||
"gitea.com/gitea/runner/internal/act/container"
|
||||
)
|
||||
|
||||
// GitHub's job-hook variables, read as a fallback when the settings are unset.
|
||||
const (
|
||||
jobStartedHookEnv = "ACTIONS_RUNNER_HOOK_JOB_STARTED"
|
||||
jobCompletedHookEnv = "ACTIONS_RUNNER_HOOK_JOB_COMPLETED"
|
||||
)
|
||||
|
||||
// Kept apart from the per-step file-command files, which are truncated on every step.
|
||||
const (
|
||||
hookEnvFileCommand = "workflow/hook-envs.txt"
|
||||
hookPathFileCommand = "workflow/hook-path.txt"
|
||||
)
|
||||
|
||||
func (rc *RunContext) runJobStartedHook(ctx context.Context) error {
|
||||
return rc.runJobHook(ctx, cmp.Or(rc.Config.JobStartedHook, rc.Config.Env[jobStartedHookEnv]), "job started")
|
||||
}
|
||||
|
||||
func (rc *RunContext) runJobCompletedHook(ctx context.Context) error {
|
||||
return rc.runJobHook(ctx, cmp.Or(rc.Config.JobCompletedHook, rc.Config.Env[jobCompletedHookEnv]), "job completed")
|
||||
}
|
||||
|
||||
// runJobHook runs one hook in the job environment. Either hook failing fails the job, as
|
||||
// on GitHub, where the operator is responsible for the hook's own resilience.
|
||||
func (rc *RunContext) runJobHook(ctx context.Context, hookPath, name string) error {
|
||||
if hookPath == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
cmd, shell := hookCommand(hookPath)
|
||||
rawLogger := common.Logger(ctx).WithField(rawOutputField, true)
|
||||
defer rawLogger.Infof("::endgroup::")
|
||||
rawLogger.Infof("::group::Run '%s'", EscapeCommandData(hookPath))
|
||||
rawLogger.Infof("A %s hook has been configured by the runner administrator", name)
|
||||
if shell != "" {
|
||||
rawLogger.Infof("shell: %s", shell)
|
||||
}
|
||||
|
||||
env := maps.Clone(rc.GetEnv())
|
||||
if jobContainer := rc.Run.Job().Container(); jobContainer != nil {
|
||||
maps.Copy(env, jobContainer.Env)
|
||||
}
|
||||
rc.withGithubEnv(ctx, rc.getGithubContext(ctx), env)
|
||||
rc.ApplyExtraPath(ctx, &env)
|
||||
|
||||
err := rc.setupHookFileCommands(ctx, env)
|
||||
if err == nil {
|
||||
err = rc.JobContainer.Exec(cmd, env, "", "")(ctx)
|
||||
}
|
||||
// Processed even on failure, so a hook that exports what it managed to set up before
|
||||
// failing still hands it to the job.
|
||||
err = cmp.Or(err, rc.processHookFileCommands(ctx))
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
err = fmt.Errorf("the %s hook %q failed: %w", name, hookPath, err)
|
||||
// Flip the job status the way a failing pre step does, so success()-default main steps
|
||||
// skip and the task is reported failed.
|
||||
reportStepError(ctx, rc, err)
|
||||
return err
|
||||
}
|
||||
|
||||
// setupHookFileCommands points the hook at its GITHUB_ENV and GITHUB_PATH files, so it can
|
||||
// export to the job's steps, and truncates them so the second hook does not re-read what
|
||||
// the first one wrote.
|
||||
func (rc *RunContext) setupHookFileCommands(ctx context.Context, env map[string]string) error {
|
||||
actPath := rc.JobContainer.GetActPath()
|
||||
env["GITHUB_ENV"] = path.Join(actPath, hookEnvFileCommand)
|
||||
env["GITHUB_PATH"] = path.Join(actPath, hookPathFileCommand)
|
||||
env["GITEA_ENV"] = env["GITHUB_ENV"]
|
||||
env["GITEA_PATH"] = env["GITHUB_PATH"]
|
||||
|
||||
return rc.JobContainer.Copy(actPath,
|
||||
&container.FileEntry{Name: hookEnvFileCommand, Mode: 0o666},
|
||||
&container.FileEntry{Name: hookPathFileCommand, Mode: 0o666},
|
||||
)(ctx)
|
||||
}
|
||||
|
||||
func (rc *RunContext) processHookFileCommands(ctx context.Context) error {
|
||||
if err := processRunnerEnvFileCommand(ctx, hookEnvFileCommand, rc, rc.setEnv); err != nil {
|
||||
return err
|
||||
}
|
||||
return rc.UpdateExtraPath(ctx, path.Join(rc.JobContainer.GetActPath(), hookPathFileCommand))
|
||||
}
|
||||
|
||||
// hookCommand mirrors actions/runner, which deliberately does not apply the shell flags it
|
||||
// gives `run:` steps — a hook sets its own. See docs/adrs/1751-runner-job-hooks.md there.
|
||||
// The second return value is how the invocation is shown in the log, empty when the file is
|
||||
// executed directly.
|
||||
func hookCommand(hookPath string) (cmd []string, shell string) {
|
||||
switch strings.ToLower(path.Ext(hookPath)) {
|
||||
case ".sh":
|
||||
return []string{"bash", "-e", hookPath}, "bash -e {0}"
|
||||
case ".ps1":
|
||||
return []string{"pwsh", "-command", ". '" + hookPath + "'"}, `pwsh -command ". '{0}'"`
|
||||
default:
|
||||
return []string{hookPath}, ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package runner
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"maps"
|
||||
"testing"
|
||||
|
||||
"gitea.com/gitea/runner/internal/act/common"
|
||||
|
||||
"gitea.dev/actionslib/pkg/model"
|
||||
"github.com/sirupsen/logrus/hooks/test"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// hookContainer records the command a hook was run with and answers with what the hook
|
||||
// wrote to its GITHUB_ENV and GITHUB_PATH files.
|
||||
type hookContainer struct {
|
||||
fakeContainer
|
||||
cmd []string
|
||||
env map[string]string
|
||||
err error
|
||||
envFile map[string]string
|
||||
pathTar []byte
|
||||
}
|
||||
|
||||
func (c *hookContainer) ToContainerPath(path string) string { return path }
|
||||
func (c *hookContainer) IsEnvironmentCaseInsensitive() bool { return false }
|
||||
|
||||
func (c *hookContainer) GetRunnerContext(context.Context) map[string]any {
|
||||
return map[string]any{"os": "Linux"}
|
||||
}
|
||||
|
||||
func (c *hookContainer) Exec(command []string, env map[string]string, _, _ string) common.Executor {
|
||||
return func(context.Context) error {
|
||||
c.cmd, c.env = command, env
|
||||
return c.err
|
||||
}
|
||||
}
|
||||
|
||||
func (c *hookContainer) UpdateFromEnv(_ string, env *map[string]string) common.Executor {
|
||||
return func(context.Context) error {
|
||||
maps.Copy(*env, c.envFile)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (c *hookContainer) GetContainerArchive(context.Context, string) (io.ReadCloser, error) {
|
||||
return io.NopCloser(bytes.NewReader(c.pathTar)), nil
|
||||
}
|
||||
|
||||
// newHookRunContext returns a RunContext and the context to run a hook with, whose logger is
|
||||
// silenced so the hook's job-log output does not reach the test output.
|
||||
func newHookRunContext(jobContainer *hookContainer, config *Config) (*RunContext, context.Context) {
|
||||
// Env is left nil so that it is built from the config, as it is for a real job.
|
||||
rc := &RunContext{
|
||||
Config: config,
|
||||
Run: &model.Run{JobID: "job", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"job": {}}}},
|
||||
JobContainer: jobContainer,
|
||||
}
|
||||
logger, _ := test.NewNullLogger()
|
||||
ctx := common.WithJobErrorContainer(common.WithLogger(context.Background(), logger.WithField("test", true)))
|
||||
rc.ExprEval = rc.NewExpressionEvaluator(ctx)
|
||||
return rc, ctx
|
||||
}
|
||||
|
||||
func TestRunJobHook(t *testing.T) {
|
||||
t.Run("runs the hook with the job environment", func(t *testing.T) {
|
||||
jobContainer := &hookContainer{}
|
||||
rc, ctx := newHookRunContext(jobContainer, &Config{
|
||||
JobStartedHook: "/hooks/started.sh",
|
||||
Env: map[string]string{"A_VAR": "value", jobStartedHookEnv: "/from/env.sh"},
|
||||
})
|
||||
|
||||
require.NoError(t, rc.runJobStartedHook(ctx))
|
||||
|
||||
// The setting wins over the environment variable.
|
||||
assert.Equal(t, []string{"bash", "-e", "/hooks/started.sh"}, jobContainer.cmd)
|
||||
assert.Equal(t, "value", jobContainer.env["A_VAR"])
|
||||
// The github environment is there too, so a hook can tell which job it runs for.
|
||||
assert.Equal(t, "job", jobContainer.env["GITHUB_JOB"])
|
||||
assert.Equal(t, "/var/run/act/workflow/hook-envs.txt", jobContainer.env["GITHUB_ENV"])
|
||||
assert.Equal(t, "/var/run/act/workflow/hook-path.txt", jobContainer.env["GITHUB_PATH"])
|
||||
})
|
||||
|
||||
// Each hook reads its own variable, so a swapped constant cannot pass.
|
||||
t.Run("falls back to the GitHub environment variables", func(t *testing.T) {
|
||||
for name, hook := range map[string]struct {
|
||||
env string
|
||||
run func(*RunContext, context.Context) error
|
||||
}{
|
||||
"started": {jobStartedHookEnv, (*RunContext).runJobStartedHook},
|
||||
"completed": {jobCompletedHookEnv, (*RunContext).runJobCompletedHook},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
jobContainer := &hookContainer{}
|
||||
rc, ctx := newHookRunContext(jobContainer, &Config{Env: map[string]string{hook.env: "/from/env.sh"}})
|
||||
|
||||
require.NoError(t, hook.run(rc, ctx))
|
||||
assert.Equal(t, []string{"bash", "-e", "/from/env.sh"}, jobContainer.cmd)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("exports what the hook wrote to GITHUB_ENV and GITHUB_PATH", func(t *testing.T) {
|
||||
jobContainer := &hookContainer{
|
||||
envFile: map[string]string{"FROM_HOOK": "1"},
|
||||
pathTar: tarArchive(t, tarEntry{name: "hook-path.txt", body: "/opt/tool/bin\n"}),
|
||||
}
|
||||
rc, ctx := newHookRunContext(jobContainer, &Config{JobStartedHook: "/hooks/started.sh"})
|
||||
|
||||
require.NoError(t, rc.runJobStartedHook(ctx))
|
||||
|
||||
assert.Equal(t, "1", rc.Env["FROM_HOOK"])
|
||||
assert.Equal(t, []string{"/opt/tool/bin"}, rc.ExtraPath)
|
||||
})
|
||||
|
||||
t.Run("a failing hook fails the job", func(t *testing.T) {
|
||||
rc, ctx := newHookRunContext(&hookContainer{err: errors.New("boom")}, &Config{JobStartedHook: "/hooks/started.sh"})
|
||||
|
||||
err := rc.runJobStartedHook(ctx)
|
||||
|
||||
require.ErrorContains(t, err, `the job started hook "/hooks/started.sh" failed`)
|
||||
require.ErrorContains(t, err, "boom")
|
||||
// The failure has to flip the job status, or success()-default steps would still
|
||||
// run and the task would be reported successful despite the missing setup.
|
||||
assert.Equal(t, "failure", rc.getJobContext().Status)
|
||||
require.ErrorContains(t, common.JobError(ctx), "boom")
|
||||
})
|
||||
|
||||
t.Run("is a no-op without a hook", func(t *testing.T) {
|
||||
jobContainer := &hookContainer{}
|
||||
rc, ctx := newHookRunContext(jobContainer, &Config{})
|
||||
|
||||
require.NoError(t, rc.runJobStartedHook(ctx))
|
||||
require.NoError(t, rc.runJobCompletedHook(ctx))
|
||||
assert.Nil(t, jobContainer.cmd)
|
||||
})
|
||||
}
|
||||
|
||||
// actions/runner deliberately runs a hook without the flags it gives `run:` steps, and an
|
||||
// executable without a known extension speaks for itself through its shebang.
|
||||
func TestHookCommand(t *testing.T) {
|
||||
for hookPath, want := range map[string]struct {
|
||||
cmd []string
|
||||
shell string
|
||||
}{
|
||||
"/hooks/started.sh": {[]string{"bash", "-e", "/hooks/started.sh"}, "bash -e {0}"},
|
||||
"/hooks/started.PS1": {[]string{"pwsh", "-command", ". '/hooks/started.PS1'"}, `pwsh -command ". '{0}'"`},
|
||||
"/hooks/started": {[]string{"/hooks/started"}, ""},
|
||||
} {
|
||||
cmd, shell := hookCommand(hookPath)
|
||||
assert.Equal(t, want.cmd, cmd, hookPath)
|
||||
assert.Equal(t, want.shell, shell, hookPath)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2024 The nektos/act Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package runner
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
goURL "net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gitea.com/gitea/runner/internal/act/filecollector"
|
||||
)
|
||||
|
||||
type LocalRepositoryCache struct {
|
||||
Parent ActionCache
|
||||
LocalRepositories map[string]string
|
||||
CacheDirCache map[string]string
|
||||
}
|
||||
|
||||
func (l *LocalRepositoryCache) Fetch(ctx context.Context, cacheDir, url, ref, token string) (string, error) {
|
||||
if dest, ok := l.LocalRepositories[fmt.Sprintf("%s@%s", url, ref)]; ok {
|
||||
l.CacheDirCache[fmt.Sprintf("%s@%s", cacheDir, ref)] = dest
|
||||
return ref, nil
|
||||
}
|
||||
if purl, err := goURL.Parse(url); err == nil {
|
||||
if dest, ok := l.LocalRepositories[fmt.Sprintf("%s@%s", strings.TrimPrefix(purl.Path, "/"), ref)]; ok {
|
||||
l.CacheDirCache[fmt.Sprintf("%s@%s", cacheDir, ref)] = dest
|
||||
return ref, nil
|
||||
}
|
||||
}
|
||||
return l.Parent.Fetch(ctx, cacheDir, url, ref, token)
|
||||
}
|
||||
|
||||
func (l *LocalRepositoryCache) GetTarArchive(ctx context.Context, cacheDir, sha, includePrefix string) (io.ReadCloser, error) {
|
||||
// sha is mapped to ref in fetch if there is a local override
|
||||
if dest, ok := l.CacheDirCache[fmt.Sprintf("%s@%s", cacheDir, sha)]; ok {
|
||||
srcPath := filepath.Join(dest, includePrefix)
|
||||
buf := &bytes.Buffer{}
|
||||
tw := tar.NewWriter(buf)
|
||||
defer tw.Close()
|
||||
srcPath = filepath.Clean(srcPath)
|
||||
fi, err := os.Lstat(srcPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tc := &filecollector.TarCollector{
|
||||
TarWriter: tw,
|
||||
}
|
||||
if fi.IsDir() {
|
||||
srcPrefix := srcPath
|
||||
if !strings.HasSuffix(srcPrefix, string(filepath.Separator)) {
|
||||
srcPrefix += string(filepath.Separator)
|
||||
}
|
||||
fc := &filecollector.FileCollector{
|
||||
Fs: &filecollector.DefaultFs{},
|
||||
SrcPath: srcPath,
|
||||
SrcPrefix: srcPrefix,
|
||||
Handler: tc,
|
||||
}
|
||||
err = filepath.Walk(srcPath, fc.CollectFiles(ctx, []string{}))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
var f io.ReadCloser
|
||||
var linkname string
|
||||
if fi.Mode()&fs.ModeSymlink != 0 {
|
||||
linkname, err = os.Readlink(srcPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
f, err = os.Open(srcPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
}
|
||||
err := tc.WriteFile(fi.Name(), fi, linkname, f)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return io.NopCloser(buf), nil
|
||||
}
|
||||
return l.Parent.GetTarArchive(ctx, cacheDir, sha, includePrefix)
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2020 The nektos/act Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package runner
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"gitea.com/gitea/runner/internal/act/common"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
const (
|
||||
// nocolor = 0
|
||||
red = 31
|
||||
green = 32
|
||||
yellow = 33
|
||||
blue = 34
|
||||
magenta = 35
|
||||
cyan = 36
|
||||
gray = 37
|
||||
)
|
||||
|
||||
const (
|
||||
rawOutputField = "raw_output"
|
||||
scriptLineCyanField = "script_line_cyan"
|
||||
)
|
||||
|
||||
var (
|
||||
colors []int
|
||||
nextColor int
|
||||
mux sync.Mutex
|
||||
)
|
||||
|
||||
func init() {
|
||||
nextColor = 0
|
||||
colors = []int{
|
||||
blue, yellow, green, magenta, red, gray, cyan,
|
||||
}
|
||||
}
|
||||
|
||||
type masksContextKey string
|
||||
|
||||
const masksContextKeyVal = masksContextKey("logrus.FieldLogger")
|
||||
|
||||
// Logger returns the appropriate logger for current context
|
||||
func Masks(ctx context.Context) *[]string {
|
||||
val := ctx.Value(masksContextKeyVal)
|
||||
if val != nil {
|
||||
if masks, ok := val.(*[]string); ok {
|
||||
return masks
|
||||
}
|
||||
}
|
||||
return &[]string{}
|
||||
}
|
||||
|
||||
// WithMasks adds a value to the context for the logger
|
||||
func WithMasks(ctx context.Context, masks *[]string) context.Context {
|
||||
return context.WithValue(ctx, masksContextKeyVal, masks)
|
||||
}
|
||||
|
||||
type JobLoggerFactory interface {
|
||||
WithJobLogger() *logrus.Logger
|
||||
}
|
||||
|
||||
type jobLoggerFactoryContextKey string
|
||||
|
||||
var jobLoggerFactoryContextKeyVal = (jobLoggerFactoryContextKey)("jobloggerkey")
|
||||
|
||||
func WithJobLoggerFactory(ctx context.Context, factory JobLoggerFactory) context.Context {
|
||||
return context.WithValue(ctx, jobLoggerFactoryContextKeyVal, factory)
|
||||
}
|
||||
|
||||
// WithJobLogger attaches a new logger to context that is aware of steps
|
||||
func WithJobLogger(ctx context.Context, jobID, jobName string, config *Config, masks *[]string, matrix map[string]any) context.Context {
|
||||
ctx = WithMasks(ctx, masks)
|
||||
|
||||
var logger *logrus.Logger
|
||||
if jobLoggerFactory, ok := ctx.Value(jobLoggerFactoryContextKeyVal).(JobLoggerFactory); ok && jobLoggerFactory != nil {
|
||||
logger = jobLoggerFactory.WithJobLogger()
|
||||
} else {
|
||||
var formatter logrus.Formatter
|
||||
if config.JSONLogger {
|
||||
formatter = &logrus.JSONFormatter{}
|
||||
} else {
|
||||
mux.Lock()
|
||||
defer mux.Unlock()
|
||||
nextColor++
|
||||
formatter = &jobLogFormatter{
|
||||
color: colors[nextColor%len(colors)],
|
||||
logPrefixJobID: config.LogPrefixJobID,
|
||||
}
|
||||
}
|
||||
|
||||
logger = logrus.New()
|
||||
logger.SetOutput(os.Stdout)
|
||||
logger.SetLevel(logrus.GetLevel())
|
||||
logger.SetFormatter(formatter)
|
||||
}
|
||||
|
||||
{ // Adapt to Gitea
|
||||
if hook := common.LoggerHook(ctx); hook != nil {
|
||||
logger.AddHook(hook)
|
||||
}
|
||||
if config.JobLoggerLevel != nil {
|
||||
logger.SetLevel(*config.JobLoggerLevel)
|
||||
} else {
|
||||
logger.SetLevel(logrus.TraceLevel)
|
||||
}
|
||||
}
|
||||
|
||||
logger.SetFormatter(&maskedFormatter{
|
||||
Formatter: logger.Formatter,
|
||||
masker: valueMasker(config.InsecureSecrets, config.Secrets),
|
||||
})
|
||||
rtn := logger.WithFields(logrus.Fields{
|
||||
"job": jobName,
|
||||
"jobID": jobID,
|
||||
"dryrun": common.Dryrun(ctx),
|
||||
"matrix": matrix,
|
||||
}).WithContext(ctx)
|
||||
|
||||
return common.WithLogger(ctx, rtn)
|
||||
}
|
||||
|
||||
func WithCompositeLogger(ctx context.Context, masks *[]string) context.Context {
|
||||
ctx = WithMasks(ctx, masks)
|
||||
return common.WithLogger(ctx, common.Logger(ctx).WithFields(logrus.Fields{}).WithContext(ctx))
|
||||
}
|
||||
|
||||
func WithCompositeStepLogger(ctx context.Context, stepID string) context.Context {
|
||||
val := common.Logger(ctx)
|
||||
stepIDs := make([]string, 0)
|
||||
|
||||
if logger, ok := val.(*logrus.Entry); ok {
|
||||
if oldStepIDs, ok := logger.Data["stepID"].([]string); ok {
|
||||
stepIDs = append(stepIDs, oldStepIDs...)
|
||||
}
|
||||
}
|
||||
|
||||
stepIDs = append(stepIDs, stepID)
|
||||
|
||||
return common.WithLogger(ctx, common.Logger(ctx).WithFields(logrus.Fields{
|
||||
"stepID": stepIDs,
|
||||
}).WithContext(ctx))
|
||||
}
|
||||
|
||||
func withStepLogger(ctx context.Context, stepNumber int, stepID, stepName, stageName string) context.Context {
|
||||
rtn := common.Logger(ctx).WithFields(logrus.Fields{
|
||||
"stepNumber": stepNumber,
|
||||
"step": stepName,
|
||||
"stepID": []string{stepID},
|
||||
"stage": stageName,
|
||||
})
|
||||
return common.WithLogger(ctx, rtn)
|
||||
}
|
||||
|
||||
type entryProcessor func(entry *logrus.Entry) *logrus.Entry
|
||||
|
||||
// secretValueEncoders are the shapes a secret takes on its way into a log: a base64
|
||||
// payload, a JSON string, or a URL component. An action that serializes a secret leaks
|
||||
// it in one of these forms, which a mask of the verbatim value alone does not catch, so
|
||||
// every form is masked as well. This mirrors the value encoders of GitHub's runner.
|
||||
var secretValueEncoders = []func(string) string{
|
||||
func(v string) string { return base64.StdEncoding.EncodeToString([]byte(v)) },
|
||||
base64ShiftEncoder(1),
|
||||
base64ShiftEncoder(2),
|
||||
jsonStringEscape,
|
||||
jsonStringEscapeNoHTML,
|
||||
url.QueryEscape,
|
||||
url.PathEscape,
|
||||
}
|
||||
|
||||
// minShiftedBase64Len is the shortest shifted base64 fragment worth masking. A shorter
|
||||
// one carries too few bytes of the secret to identify it and would mask unrelated output.
|
||||
const minShiftedBase64Len = 8
|
||||
|
||||
// base64ShiftEncoder returns the part of a secret's base64 form that survives when the
|
||||
// secret does not start on a 3-byte boundary of the payload it is embedded in. base64
|
||||
// encodes three bytes at a time, so `Authorization: Basic base64("user:token")` contains
|
||||
// the base64 of the token alone only when the prefix length happens to be a multiple of
|
||||
// three; at the other two alignments the encoding of the whole value differs. Encoding
|
||||
// the secret behind shift filler bytes reproduces those alignments, which is what the
|
||||
// Base64StringEscapeShift1/2 encoders of GitHub's runner do.
|
||||
//
|
||||
// The leading group (filler mixed with the secret's first bytes) and the trailing group
|
||||
// (padded here, but continuing into whatever follows the secret) are dropped, leaving the
|
||||
// group-aligned middle that does appear verbatim in the log.
|
||||
func base64ShiftEncoder(shift int) func(string) string {
|
||||
return func(v string) string {
|
||||
buf := make([]byte, shift+len(v))
|
||||
copy(buf[shift:], v)
|
||||
encoded := base64.StdEncoding.EncodeToString(buf)
|
||||
// Keep only the aligned middle, and only when enough of it is left to be a
|
||||
// distinctive pattern rather than a fragment that matches unrelated output.
|
||||
if len(encoded) < 8+minShiftedBase64Len {
|
||||
return ""
|
||||
}
|
||||
return encoded[4 : len(encoded)-4]
|
||||
}
|
||||
}
|
||||
|
||||
// jsonStringEscape returns v as it appears inside a JSON string, without the quotes,
|
||||
// which is what `toJSON(secrets)` or any action logging a JSON body produces. Go's encoder
|
||||
// escapes <, >, & (as act's own toJSON does); the non-HTML variant below covers the runtimes
|
||||
// that do not. When v has none of those characters both forms are equal and deduplicated.
|
||||
func jsonStringEscape(v string) string {
|
||||
encoded, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return v
|
||||
}
|
||||
return string(encoded[1 : len(encoded)-1])
|
||||
}
|
||||
|
||||
// jsonStringEscapeNoHTML is jsonStringEscape without HTML escaping, matching the JSON a
|
||||
// JavaScript (JSON.stringify) or .NET action emits, so a secret containing < > or & is
|
||||
// masked in that form too.
|
||||
func jsonStringEscapeNoHTML(v string) string {
|
||||
var buf bytes.Buffer
|
||||
enc := json.NewEncoder(&buf)
|
||||
enc.SetEscapeHTML(false)
|
||||
if err := enc.Encode(v); err != nil {
|
||||
return v
|
||||
}
|
||||
// Encode appends a newline; drop it along with the surrounding quotes.
|
||||
encoded := strings.TrimRight(buf.String(), "\n")
|
||||
return encoded[1 : len(encoded)-1]
|
||||
}
|
||||
|
||||
func AppendSecretMasker(oldnew []string, v string) []string {
|
||||
ret := oldnew
|
||||
|
||||
for l := range strings.SplitSeq(v, "\n") {
|
||||
tm := strings.TrimSpace(l)
|
||||
// formatted JSON secrets could otherwise mask {,[,],} everywhere
|
||||
if len(tm) > 1 {
|
||||
ret = append(ret, tm, "***")
|
||||
// command data reaches the log escaped, so "pass%word" also arrives as "pass%25word"
|
||||
if strings.ContainsAny(tm, "%\r\n") {
|
||||
ret = append(ret, EscapeCommandData(tm), "***")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The encoded forms are derived from the whole value: a multi-line secret is
|
||||
// encoded as one string, not line by line.
|
||||
trimmed := strings.TrimSpace(v)
|
||||
if len(trimmed) <= 1 {
|
||||
return ret
|
||||
}
|
||||
for _, encode := range secretValueEncoders {
|
||||
encoded := encode(trimmed)
|
||||
// An encoding that leaves the value unchanged is already masked above.
|
||||
if encoded == trimmed || len(encoded) <= 1 || slices.Contains(ret, encoded) {
|
||||
continue
|
||||
}
|
||||
ret = append(ret, encoded, "***")
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
// valueMasker applies secrets and ::add-mask:: patterns to every log entry, including
|
||||
// raw_output (command/stream) lines; there is no bypass by field.
|
||||
func valueMasker(insecureSecrets bool, secrets map[string]string) entryProcessor {
|
||||
var oldnew []string
|
||||
for _, v := range secrets {
|
||||
oldnew = AppendSecretMasker(oldnew, v)
|
||||
}
|
||||
oldnew = slices.Clip(oldnew)
|
||||
defReplacer := strings.NewReplacer(oldnew...)
|
||||
|
||||
// A ::add-mask:: only ever appends to the job's mask slice, so the replacer built for
|
||||
// it stays valid until the slice grows. Cache it, keyed by the slice itself and its
|
||||
// length, instead of encoding every secret and mask again for each log line.
|
||||
var (
|
||||
mu sync.Mutex
|
||||
masksRef *[]string
|
||||
pairs []string
|
||||
masked int
|
||||
replacer *strings.Replacer
|
||||
)
|
||||
|
||||
return func(entry *logrus.Entry) *logrus.Entry {
|
||||
if insecureSecrets {
|
||||
return entry
|
||||
}
|
||||
|
||||
masks := Masks(entry.Context)
|
||||
|
||||
if len(*masks) == 0 {
|
||||
entry.Message = defReplacer.Replace(entry.Message)
|
||||
return entry
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
// A composite action logs through the same masker with its own mask slice, so a
|
||||
// different slice starts the cache over.
|
||||
if masksRef != masks {
|
||||
masksRef, pairs, masked, replacer = masks, oldnew, 0, nil
|
||||
}
|
||||
if replacer == nil || masked != len(*masks) {
|
||||
for _, v := range (*masks)[masked:] {
|
||||
pairs = AppendSecretMasker(pairs, v)
|
||||
}
|
||||
masked = len(*masks)
|
||||
replacer = strings.NewReplacer(pairs...)
|
||||
}
|
||||
cmasker := replacer
|
||||
mu.Unlock()
|
||||
|
||||
entry.Message = cmasker.Replace(entry.Message)
|
||||
|
||||
return entry
|
||||
}
|
||||
}
|
||||
|
||||
type maskedFormatter struct {
|
||||
logrus.Formatter
|
||||
masker entryProcessor
|
||||
}
|
||||
|
||||
func (f *maskedFormatter) Format(entry *logrus.Entry) ([]byte, error) {
|
||||
return f.Formatter.Format(f.masker(entry))
|
||||
}
|
||||
|
||||
type jobLogFormatter struct {
|
||||
color int
|
||||
logPrefixJobID bool
|
||||
}
|
||||
|
||||
func (f *jobLogFormatter) Format(entry *logrus.Entry) ([]byte, error) {
|
||||
b := &bytes.Buffer{}
|
||||
|
||||
// the web renderer decodes command data, so this local view has to as well
|
||||
if _, _, _, ok := tryParseRawActionCommand(entry.Message + "\n"); ok {
|
||||
entry.Message = UnescapeCommandData(entry.Message)
|
||||
}
|
||||
|
||||
if f.isColored(entry) {
|
||||
f.printColored(b, entry)
|
||||
} else {
|
||||
f.print(b, entry)
|
||||
}
|
||||
|
||||
b.WriteByte('\n')
|
||||
return b.Bytes(), nil
|
||||
}
|
||||
|
||||
func (f *jobLogFormatter) printColored(b *bytes.Buffer, entry *logrus.Entry) {
|
||||
entry.Message = strings.TrimSuffix(entry.Message, "\n")
|
||||
|
||||
var job any
|
||||
if f.logPrefixJobID {
|
||||
job = entry.Data["jobID"]
|
||||
} else {
|
||||
job = entry.Data["job"]
|
||||
}
|
||||
|
||||
debugFlag := ""
|
||||
if entry.Level == logrus.DebugLevel {
|
||||
debugFlag = "[DEBUG] "
|
||||
}
|
||||
|
||||
if entry.Data[rawOutputField] == true {
|
||||
if entry.Data[scriptLineCyanField] == true {
|
||||
fmt.Fprintf(b, "\x1b[%dm|\x1b[0m \x1b[36;1m%s\x1b[0m", f.color, entry.Message)
|
||||
} else {
|
||||
fmt.Fprintf(b, "\x1b[%dm|\x1b[0m %s", f.color, entry.Message)
|
||||
}
|
||||
} else if entry.Data["dryrun"] == true {
|
||||
fmt.Fprintf(b, "\x1b[1m\x1b[%dm\x1b[7m*DRYRUN*\x1b[0m \x1b[%dm[%s] \x1b[0m%s%s", gray, f.color, job, debugFlag, entry.Message)
|
||||
} else {
|
||||
fmt.Fprintf(b, "\x1b[%dm[%s] \x1b[0m%s%s", f.color, job, debugFlag, entry.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func (f *jobLogFormatter) print(b *bytes.Buffer, entry *logrus.Entry) {
|
||||
entry.Message = strings.TrimSuffix(entry.Message, "\n")
|
||||
|
||||
var job any
|
||||
if f.logPrefixJobID {
|
||||
job = entry.Data["jobID"]
|
||||
} else {
|
||||
job = entry.Data["job"]
|
||||
}
|
||||
|
||||
debugFlag := ""
|
||||
if entry.Level == logrus.DebugLevel {
|
||||
debugFlag = "[DEBUG] "
|
||||
}
|
||||
|
||||
if entry.Data[rawOutputField] == true {
|
||||
fmt.Fprintf(b, "[%s] | %s", job, entry.Message)
|
||||
} else if entry.Data["dryrun"] == true {
|
||||
fmt.Fprintf(b, "*DRYRUN* [%s] %s%s", job, debugFlag, entry.Message)
|
||||
} else {
|
||||
fmt.Fprintf(b, "[%s] %s%s", job, debugFlag, entry.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func (f *jobLogFormatter) isColored(entry *logrus.Entry) bool {
|
||||
isColored := checkIfTerminal(entry.Logger.Out)
|
||||
|
||||
if force, ok := os.LookupEnv("CLICOLOR_FORCE"); ok && force != "0" {
|
||||
isColored = true
|
||||
} else if ok && force == "0" {
|
||||
isColored = false
|
||||
} else if os.Getenv("CLICOLOR") == "0" {
|
||||
isColored = false
|
||||
}
|
||||
|
||||
return isColored
|
||||
}
|
||||
|
||||
func checkIfTerminal(w io.Writer) bool {
|
||||
switch v := w.(type) {
|
||||
case *os.File:
|
||||
return term.IsTerminal(int(v.Fd()))
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package runner
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"io"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestValueMasker(t *testing.T) {
|
||||
table := []struct {
|
||||
name string
|
||||
lines string
|
||||
secrets map[string]string
|
||||
masks []string
|
||||
disallowed []string
|
||||
}{
|
||||
{
|
||||
name: "Multiline Private Key",
|
||||
lines: "cat << EOF > private.key\nPRIVATE_KEY_BEGIN\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\nPRIVATE_KEY_END\nEOF",
|
||||
secrets: map[string]string{
|
||||
"PRIVATE_KEY": "PRIVATE_KEY_BEGIN\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\nPRIVATE_KEY_END",
|
||||
},
|
||||
disallowed: []string{"KEY", "dsdfseffefsefes", "PRIVATE_KEY_END"},
|
||||
},
|
||||
{
|
||||
name: "Multiline Private Key in masks",
|
||||
lines: "cat << EOF > private.key\nPRIVATE_KEY_BEGIN\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\nPRIVATE_KEY_END\nEOF",
|
||||
masks: []string{"PRIVATE_KEY_BEGIN\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\nPRIVATE_KEY_END"},
|
||||
disallowed: []string{"KEY", "dsdfseffefsefes", "PRIVATE_KEY_END"},
|
||||
},
|
||||
{
|
||||
name: "Secret containing a percent sign",
|
||||
lines: "##[error]login failed for pass%25word",
|
||||
secrets: map[string]string{"TOKEN": "pass%word"},
|
||||
disallowed: []string{"pass%25word"},
|
||||
},
|
||||
}
|
||||
for _, entry := range table {
|
||||
t.Run(entry.name, func(t *testing.T) {
|
||||
ctx := WithMasks(t.Context(), &entry.masks)
|
||||
masker := valueMasker(false, entry.secrets)
|
||||
for line := range strings.SplitSeq(entry.lines, "\n") {
|
||||
lentry := masker(&logrus.Entry{
|
||||
Context: ctx,
|
||||
Message: line,
|
||||
})
|
||||
for _, line := range entry.disallowed {
|
||||
assert.NotContains(t, lentry.Message, line)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A secret that reaches the log through an encoding — a base64 payload, a JSON body, a
|
||||
// URL — must be masked as well: masking only the verbatim value leaks it.
|
||||
func TestValueMaskerEncodedSecrets(t *testing.T) {
|
||||
secret := `p@ss w"rd/1`
|
||||
masker := valueMasker(false, map[string]string{"TOKEN": secret})
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
line string
|
||||
}{
|
||||
{"verbatim", "the token is " + secret},
|
||||
{"base64", "Authorization: Basic " + base64.StdEncoding.EncodeToString([]byte(secret))},
|
||||
{"json", `{"token":"` + jsonStringEscape(secret) + `"}`},
|
||||
{"query escaped", "https://example.com/?token=" + url.QueryEscape(secret)},
|
||||
{"path escaped", "https://example.com/" + url.PathEscape(secret) + "/x"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
entry := masker(&logrus.Entry{Context: t.Context(), Message: tc.line})
|
||||
|
||||
assert.Contains(t, entry.Message, "***")
|
||||
assert.NotContains(t, entry.Message, secret)
|
||||
assert.NotContains(t, entry.Message, base64.StdEncoding.EncodeToString([]byte(secret)))
|
||||
assert.NotContains(t, entry.Message, url.QueryEscape(secret))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A secret containing " together with <, > or & serializes to JSON differently depending
|
||||
// on the runtime: act's own toJSON (and Go) HTML-escape <>&, while a JavaScript
|
||||
// (JSON.stringify) or .NET action leaves them literal. The secret must be masked in either
|
||||
// form, so a JS-serialized JSON body does not leak it.
|
||||
func TestValueMaskerJSONEscapesBothWays(t *testing.T) {
|
||||
secret := `a"<b>&c`
|
||||
masker := valueMasker(false, map[string]string{"TOKEN": secret})
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
form string
|
||||
}{
|
||||
{"html escaped (act toJSON / Go)", jsonStringEscape(secret)},
|
||||
{"literal (JS JSON.stringify / .NET)", jsonStringEscapeNoHTML(secret)},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
entry := masker(&logrus.Entry{Context: t.Context(), Message: `{"t":"` + tc.form + `"}`})
|
||||
|
||||
assert.Contains(t, entry.Message, "***")
|
||||
assert.NotContains(t, entry.Message, tc.form)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ::add-mask:: values go through the same masker, so they get the same treatment.
|
||||
func TestValueMaskerEncodedMasks(t *testing.T) {
|
||||
masks := []string{"s3cr3t value"}
|
||||
masker := valueMasker(false, nil)
|
||||
|
||||
entry := masker(&logrus.Entry{
|
||||
Context: WithMasks(t.Context(), &masks),
|
||||
Message: "encoded: " + base64.StdEncoding.EncodeToString([]byte("s3cr3t value")),
|
||||
})
|
||||
|
||||
assert.Equal(t, "encoded: ***", entry.Message)
|
||||
}
|
||||
|
||||
// A token in a Basic auth header is base64'd together with the user name, so the token's
|
||||
// own base64 only appears when the prefix length is a multiple of three. The other two
|
||||
// alignments must be masked as well, or `Authorization: Basic base64("user:token")` leaks
|
||||
// the token to anyone who can decode the log.
|
||||
func TestValueMaskerBase64Alignments(t *testing.T) {
|
||||
secret := "s3cr3t-token-value"
|
||||
masker := valueMasker(false, map[string]string{"TOKEN": secret})
|
||||
|
||||
// One prefix per alignment: len%3 of 0, 1 and 2.
|
||||
for _, prefix := range []string{"x-access-token:", "user:", "ab:"} {
|
||||
t.Run(prefix, func(t *testing.T) {
|
||||
encoded := base64.StdEncoding.EncodeToString([]byte(prefix + secret))
|
||||
entry := masker(&logrus.Entry{Context: t.Context(), Message: "Authorization: Basic " + encoded})
|
||||
|
||||
assert.Contains(t, entry.Message, "***")
|
||||
// The aligned middle of the secret must be gone, so the payload can no longer be
|
||||
// decoded back into the token.
|
||||
assert.NotEqual(t, "Authorization: Basic "+encoded, entry.Message)
|
||||
decodable := strings.TrimPrefix(entry.Message, "Authorization: Basic ")
|
||||
decoded, err := base64.StdEncoding.DecodeString(decodable)
|
||||
if err == nil {
|
||||
assert.NotContains(t, string(decoded), secret)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The masker caches its replacer, so it has to notice both a mask appended to the same
|
||||
// slice and a composite action logging with a slice of its own.
|
||||
func TestValueMaskerCachedReplacerSeesNewMasks(t *testing.T) {
|
||||
masker := valueMasker(false, map[string]string{"TOKEN": "secret-token"})
|
||||
mask := func(masks *[]string, message string) string {
|
||||
return masker(&logrus.Entry{Context: WithMasks(t.Context(), masks), Message: message}).Message
|
||||
}
|
||||
|
||||
job := []string{"first mask"}
|
||||
assert.Equal(t, "a *** and ***", mask(&job, "a first mask and secret-token"))
|
||||
|
||||
// ::add-mask:: appends to the same slice
|
||||
job = append(job, "second mask")
|
||||
assert.Equal(t, "*** and ***", mask(&job, "first mask and second mask"))
|
||||
|
||||
// a composite action brings its own slice
|
||||
composite := []string{"composite mask"}
|
||||
assert.Equal(t, "*** but first mask", mask(&composite, "composite mask but first mask"))
|
||||
|
||||
// and the job's masks still apply once it is back
|
||||
assert.Equal(t, "*** and *** but composite mask", mask(&job, "first mask and second mask but composite mask"))
|
||||
}
|
||||
|
||||
func TestAppendSecretMaskerSkipsUselessEncodings(t *testing.T) {
|
||||
// A token with no character an escape would touch only gains its base64 forms:
|
||||
// JSON, query and path escaping all leave it unchanged.
|
||||
pairs := AppendSecretMasker(nil, "plaintoken")
|
||||
assert.Equal(t, []string{
|
||||
"plaintoken", "***",
|
||||
base64.StdEncoding.EncodeToString([]byte("plaintoken")), "***",
|
||||
// The two shifted alignments, each without its leading and trailing group.
|
||||
"YWludG9r", "***",
|
||||
"bGFpbnRv", "***",
|
||||
}, pairs)
|
||||
|
||||
// Too short to mask.
|
||||
assert.Empty(t, AppendSecretMasker(nil, "x"))
|
||||
}
|
||||
|
||||
func TestJobLogFormatterDecodesCommandData(t *testing.T) {
|
||||
logger := logrus.New()
|
||||
logger.Out = io.Discard
|
||||
format := func(message string) string {
|
||||
out, err := (&jobLogFormatter{}).Format(&logrus.Entry{Logger: logger, Message: message, Data: logrus.Fields{rawOutputField: true}})
|
||||
require.NoError(t, err)
|
||||
return string(out)
|
||||
}
|
||||
|
||||
assert.Contains(t, format("##[error]deploy 50%25 traffic"), "##[error]deploy 50% traffic")
|
||||
// a plain line is not command data and keeps its literal escapes
|
||||
assert.Contains(t, format("progress 50%25 done"), "progress 50%25 done")
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package runner
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.dev/actionslib/pkg/model"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
func TestMaxParallelStrategy(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
maxParallelString string
|
||||
expectedMaxParallel int
|
||||
}{
|
||||
{
|
||||
name: "max-parallel-1",
|
||||
maxParallelString: "1",
|
||||
expectedMaxParallel: 1,
|
||||
},
|
||||
{
|
||||
name: "max-parallel-2",
|
||||
maxParallelString: "2",
|
||||
expectedMaxParallel: 2,
|
||||
},
|
||||
{
|
||||
name: "max-parallel-default",
|
||||
maxParallelString: "",
|
||||
expectedMaxParallel: 4,
|
||||
},
|
||||
{
|
||||
name: "max-parallel-10",
|
||||
maxParallelString: "10",
|
||||
expectedMaxParallel: 10,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
matrix := map[string][]any{
|
||||
"version": {1, 2, 3, 4, 5},
|
||||
}
|
||||
|
||||
var rawMatrix yaml.Node
|
||||
err := rawMatrix.Encode(matrix)
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
job := &model.Job{
|
||||
Strategy: &model.Strategy{
|
||||
MaxParallelString: tt.maxParallelString,
|
||||
RawMatrix: rawMatrix,
|
||||
},
|
||||
}
|
||||
|
||||
matrixes, err := job.GetMatrixes()
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.NotNil(t, matrixes)
|
||||
assert.Len(t, matrixes, 5)
|
||||
assert.Equal(t, tt.expectedMaxParallel, job.Strategy.MaxParallel)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
const { spawnSync } = require('child_process')
|
||||
const spawnArguments = {
|
||||
cwd: process.env.INPUT_CWD,
|
||||
stdio: [
|
||||
process.stdin,
|
||||
process.stdout,
|
||||
process.stderr
|
||||
]
|
||||
}
|
||||
const child = spawnSync(
|
||||
'/bin/sh',
|
||||
['-c'].concat(process.env.INPUT_COMMAND),
|
||||
spawnArguments)
|
||||
process.exit(child.status)
|
||||
@@ -0,0 +1,350 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2022 The nektos/act Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package runner
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"gitea.com/gitea/runner/internal/act/common"
|
||||
"gitea.com/gitea/runner/internal/act/common/git"
|
||||
|
||||
"gitea.dev/actionslib/pkg/model"
|
||||
)
|
||||
|
||||
func newLocalReusableWorkflowExecutor(rc *RunContext) common.Executor {
|
||||
if !rc.Config.NoSkipCheckout {
|
||||
fullPath := rc.Run.Job().Uses
|
||||
|
||||
fileName := path.Base(fullPath)
|
||||
workflowDir := strings.TrimSuffix(fullPath, path.Join("/", fileName))
|
||||
workflowDir = strings.TrimPrefix(workflowDir, "./")
|
||||
|
||||
return common.NewPipelineExecutor(
|
||||
// resolve the local workflow against the workspace root, not the process
|
||||
// working directory, so it is found regardless of where the runner is invoked
|
||||
newReusableWorkflowExecutor(rc, filepath.Join(rc.Config.Workdir, workflowDir), fileName),
|
||||
)
|
||||
}
|
||||
|
||||
// ./.gitea/workflows/wf.yml -> .gitea/workflows/wf.yml
|
||||
trimmedUses := strings.TrimPrefix(rc.Run.Job().Uses, "./")
|
||||
// uses string format is {owner}/{repo}/.{git_platform}/workflows/{filename}@{ref}
|
||||
uses := fmt.Sprintf("%s/%s@%s", rc.Config.PresetGitHubContext.Repository, trimmedUses, rc.Config.PresetGitHubContext.Sha)
|
||||
|
||||
remoteReusableWorkflow := newRemoteReusableWorkflowWithPlat(rc.Config.GitHubInstance, uses)
|
||||
if remoteReusableWorkflow == nil {
|
||||
return common.NewErrorExecutor(fmt.Errorf("expected format {owner}/{repo}/.{git_platform}/workflows/{filename}@{ref}. Actual '%s' Input string was not in a correct format", uses))
|
||||
}
|
||||
|
||||
workflowDir := fmt.Sprintf("%s/%s", rc.ActionCacheDir(), safeFilename(uses))
|
||||
|
||||
// If the repository is private, we need a token to clone it
|
||||
token := rc.Config.GetToken()
|
||||
|
||||
return common.NewPipelineExecutor(
|
||||
cloneRemoteReusableWorkflow(rc, remoteReusableWorkflow.CloneURL(), remoteReusableWorkflow.Ref, workflowDir, token),
|
||||
newReusableWorkflowExecutor(rc, workflowDir, remoteReusableWorkflow.FilePath()),
|
||||
)
|
||||
}
|
||||
|
||||
func newRemoteReusableWorkflowExecutor(rc *RunContext) common.Executor {
|
||||
uses := rc.Run.Job().Uses
|
||||
|
||||
var remoteReusableWorkflow *remoteReusableWorkflow
|
||||
if strings.HasPrefix(uses, "http://") || strings.HasPrefix(uses, "https://") {
|
||||
remoteReusableWorkflow = newRemoteReusableWorkflowFromAbsoluteURL(uses)
|
||||
if remoteReusableWorkflow == nil {
|
||||
return common.NewErrorExecutor(fmt.Errorf("expected format http(s)://{domain}/{owner}/{repo}/.{git_platform}/workflows/{filename}@{ref}. Actual '%s' Input string was not in a correct format", uses))
|
||||
}
|
||||
} else {
|
||||
remoteReusableWorkflow = newRemoteReusableWorkflowWithPlat(rc.Config.GitHubInstance, uses)
|
||||
if remoteReusableWorkflow == nil {
|
||||
return common.NewErrorExecutor(fmt.Errorf("expected format {owner}/{repo}/.{git_platform}/workflows/{filename}@{ref}. Actual '%s' Input string was not in a correct format", uses))
|
||||
}
|
||||
}
|
||||
|
||||
// uses with safe filename makes the target directory look something like this {owner}-{repo}-.github-workflows-{filename}@{ref}
|
||||
// instead we will just use {owner}-{repo}@{ref} as our target directory. This should also improve performance when we are using
|
||||
// multiple reusable workflows from the same repository and ref since for each workflow we won't have to clone it again
|
||||
filename := fmt.Sprintf("%s/%s@%s", remoteReusableWorkflow.Org, remoteReusableWorkflow.Repo, remoteReusableWorkflow.Ref)
|
||||
workflowDir := fmt.Sprintf("%s/%s", rc.ActionCacheDir(), safeFilename(filename))
|
||||
|
||||
if rc.Config.ActionCache != nil {
|
||||
return newActionCacheReusableWorkflowExecutor(rc, filename, remoteReusableWorkflow)
|
||||
}
|
||||
|
||||
token := getGitCloneToken(rc.Config, remoteReusableWorkflow.CloneURL())
|
||||
|
||||
return common.NewPipelineExecutor(
|
||||
cloneRemoteReusableWorkflow(rc, remoteReusableWorkflow.CloneURL(), remoteReusableWorkflow.Ref, workflowDir, token),
|
||||
newReusableWorkflowExecutor(rc, workflowDir, remoteReusableWorkflow.FilePath()),
|
||||
)
|
||||
}
|
||||
|
||||
func newActionCacheReusableWorkflowExecutor(rc *RunContext, filename string, remoteReusableWorkflow *remoteReusableWorkflow) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
ghctx := rc.getGithubContext(ctx)
|
||||
remoteReusableWorkflow.URL = ghctx.ServerURL
|
||||
sha, err := rc.Config.ActionCache.Fetch(ctx, filename, remoteReusableWorkflow.CloneURL(), remoteReusableWorkflow.Ref, ghctx.Token)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
archive, err := rc.Config.ActionCache.GetTarArchive(ctx, filename, sha, ".github/workflows/"+remoteReusableWorkflow.Filename)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer archive.Close()
|
||||
treader := tar.NewReader(archive)
|
||||
if _, err = treader.Next(); err != nil {
|
||||
return err
|
||||
}
|
||||
planner, err := model.NewSingleWorkflowPlanner(remoteReusableWorkflow.Filename, treader)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
plan, err := planner.PlanEvent("workflow_call")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
runner, err := NewReusableWorkflowRunner(rc)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return runner.NewPlanExecutor(plan)(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// cloneRemoteReusableWorkflow always invokes the clone executor — moving refs
|
||||
// (branches, tags) must be re-resolved each run, matching GitHub Actions.
|
||||
//
|
||||
// Callers must not change remoteReusableWorkflow.URL, because:
|
||||
// 1. Gitea doesn't support specifying GithubContext.ServerURL by the GITHUB_SERVER_URL env
|
||||
// 2. Gitea has already full URL with rc.Config.GitHubInstance when calling newRemoteReusableWorkflowWithPlat
|
||||
//
|
||||
// remoteReusableWorkflow.URL = rc.getGithubContext(ctx).ServerURL
|
||||
func cloneRemoteReusableWorkflow(rc *RunContext, cloneURL, ref, targetDirectory, token string) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
cloneURL = rc.NewExpressionEvaluator(ctx).Interpolate(ctx, cloneURL)
|
||||
return git.NewGitCloneExecutor(git.NewGitCloneExecutorInput{
|
||||
URL: cloneURL,
|
||||
Ref: ref,
|
||||
Dir: targetDirectory,
|
||||
Token: token,
|
||||
OfflineMode: rc.Config.ActionOfflineMode,
|
||||
Depth: rc.Config.ActionCloneDepth,
|
||||
})(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
var modelNewWorkflowPlanner = model.NewWorkflowPlanner
|
||||
|
||||
func newReusableWorkflowExecutor(rc *RunContext, directory, workflow string) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
// Scoped to the yaml read so concurrent invocations don't serialize
|
||||
// on the whole job run.
|
||||
planner, err := func() (model.WorkflowPlanner, error) {
|
||||
defer git.AcquireCloneLock(directory)()
|
||||
return modelNewWorkflowPlanner(path.Join(directory, workflow), true)
|
||||
}()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
plan, err := planner.PlanEvent("workflow_call")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
runner, err := NewReusableWorkflowRunner(rc)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// return runner.NewPlanExecutor(plan)(ctx)
|
||||
return common.NewPipelineExecutor( // For Gitea
|
||||
runner.NewPlanExecutor(plan),
|
||||
setReusedWorkflowCallerResult(rc, runner),
|
||||
)(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
func NewReusableWorkflowRunner(rc *RunContext) (Runner, error) {
|
||||
runner := &runnerImpl{
|
||||
config: rc.Config,
|
||||
eventJSON: rc.EventJSON,
|
||||
caller: &caller{
|
||||
runContext: rc,
|
||||
|
||||
reusedWorkflowJobResults: map[string]string{}, // For Gitea
|
||||
},
|
||||
}
|
||||
|
||||
return runner.configure()
|
||||
}
|
||||
|
||||
type remoteReusableWorkflow struct {
|
||||
URL string
|
||||
Org string
|
||||
Repo string
|
||||
Filename string
|
||||
Ref string
|
||||
|
||||
GitPlatform string
|
||||
}
|
||||
|
||||
func (r *remoteReusableWorkflow) CloneURL() string {
|
||||
// In Gitea, r.URL always has the protocol prefix, we don't need to add extra prefix in this case.
|
||||
if strings.HasPrefix(r.URL, "http://") || strings.HasPrefix(r.URL, "https://") {
|
||||
return fmt.Sprintf("%s/%s/%s", r.URL, r.Org, r.Repo)
|
||||
}
|
||||
return fmt.Sprintf("https://%s/%s/%s", r.URL, r.Org, r.Repo)
|
||||
}
|
||||
|
||||
func (r *remoteReusableWorkflow) FilePath() string {
|
||||
return fmt.Sprintf("./.%s/workflows/%s", r.GitPlatform, r.Filename)
|
||||
}
|
||||
|
||||
// For Gitea
|
||||
// newRemoteReusableWorkflowWithPlat create a `remoteReusableWorkflow`
|
||||
// workflows from `.gitea/workflows` and `.github/workflows` are supported
|
||||
func newRemoteReusableWorkflowWithPlat(url, uses string) *remoteReusableWorkflow {
|
||||
// GitHub docs:
|
||||
// https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_iduses
|
||||
r := regexp.MustCompile(`^([^/]+)/([^/]+)/\.([^/]+)/workflows/([^@]+)@(.*)$`)
|
||||
matches := r.FindStringSubmatch(uses)
|
||||
if len(matches) != 6 {
|
||||
return nil
|
||||
}
|
||||
return &remoteReusableWorkflow{
|
||||
Org: matches[1],
|
||||
Repo: matches[2],
|
||||
GitPlatform: matches[3],
|
||||
Filename: matches[4],
|
||||
Ref: matches[5],
|
||||
URL: url,
|
||||
}
|
||||
}
|
||||
|
||||
// For Gitea
|
||||
// newRemoteReusableWorkflowWithPlat create a `remoteReusableWorkflow` from an absolute url
|
||||
func newRemoteReusableWorkflowFromAbsoluteURL(uses string) *remoteReusableWorkflow {
|
||||
r := regexp.MustCompile(`^(https?://.*)/([^/]+)/([^/]+)/\.([^/]+)/workflows/([^@]+)@(.*)$`)
|
||||
matches := r.FindStringSubmatch(uses)
|
||||
if len(matches) != 7 {
|
||||
return nil
|
||||
}
|
||||
return &remoteReusableWorkflow{
|
||||
URL: matches[1],
|
||||
Org: matches[2],
|
||||
Repo: matches[3],
|
||||
GitPlatform: matches[4],
|
||||
Filename: matches[5],
|
||||
Ref: matches[6],
|
||||
}
|
||||
}
|
||||
|
||||
// For Gitea
|
||||
func setReusedWorkflowCallerResult(rc *RunContext, runner Runner) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
logger := common.Logger(ctx)
|
||||
|
||||
runnerImpl, ok := runner.(*runnerImpl)
|
||||
if !ok {
|
||||
logger.Warn("Failed to get caller from runner")
|
||||
return nil
|
||||
}
|
||||
caller := runnerImpl.caller
|
||||
|
||||
allJobDone := true
|
||||
hasFailure := false
|
||||
for _, result := range caller.reusedWorkflowJobResults {
|
||||
if result == "pending" {
|
||||
allJobDone = false
|
||||
break
|
||||
}
|
||||
if result == "failure" {
|
||||
hasFailure = true
|
||||
}
|
||||
}
|
||||
|
||||
if allJobDone {
|
||||
reusedWorkflowJobResult := "success"
|
||||
reusedWorkflowJobResultMessage := "succeeded"
|
||||
if hasFailure {
|
||||
reusedWorkflowJobResult = "failure"
|
||||
reusedWorkflowJobResultMessage = "failed"
|
||||
}
|
||||
|
||||
if rc.caller != nil {
|
||||
rc.caller.setReusedWorkflowJobResult(rc.JobName, reusedWorkflowJobResult)
|
||||
} else {
|
||||
// Serialize this shared Job.Result write against the other matrix combos
|
||||
// and setJobResult (same lockJob key).
|
||||
unlock := lockJob(rc.Run.Job())
|
||||
rc.result(reusedWorkflowJobResult)
|
||||
unlock()
|
||||
logger.WithField("jobResult", reusedWorkflowJobResult).Infof("Job %s", reusedWorkflowJobResultMessage)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// For Gitea
|
||||
// getGitCloneToken returns GITEA_TOKEN when shouldCloneURLUseToken returns true,
|
||||
// otherwise returns an empty string
|
||||
func getGitCloneToken(conf *Config, cloneURL string) string {
|
||||
if !shouldCloneURLUseToken(conf.GitHubInstance, conf.trustedActionInstance(), cloneURL) {
|
||||
return ""
|
||||
}
|
||||
return conf.GetToken()
|
||||
}
|
||||
|
||||
// For Gitea
|
||||
// trustedActionInstance returns the self-hosted DEFAULT_ACTIONS_URL host that may carry the
|
||||
// task token, or "" when actions resolve to github.com / a GithubMirror (never trusted).
|
||||
func (c Config) trustedActionInstance() string {
|
||||
if c.DefaultActionInstanceIsSelfHosted {
|
||||
return c.DefaultActionInstance
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// For Gitea
|
||||
// shouldCloneURLUseToken returns true when the following conditions are met:
|
||||
// 1. cloneURL's host matches this Gitea instance: either the registered instance
|
||||
// (instanceURL) or, for DEFAULT_ACTIONS_URL=self on a different hostname, the
|
||||
// self-hosted action instance (trustedActionInstance, "" when not trusted)
|
||||
// 2. the cloneURL does not have basic auth embedded
|
||||
func shouldCloneURLUseToken(instanceURL, trustedActionInstance, cloneURL string) bool {
|
||||
u2, err := url.Parse(cloneURL)
|
||||
if err != nil || u2.User != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, candidate := range []string{instanceURL, trustedActionInstance} {
|
||||
if candidate == "" {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(candidate, "http://") &&
|
||||
!strings.HasPrefix(candidate, "https://") {
|
||||
candidate = "https://" + candidate
|
||||
}
|
||||
if u1, err := url.Parse(candidate); err == nil && u1.Host == u2.Host {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package runner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.com/gitea/runner/internal/act/common/git"
|
||||
|
||||
"gitea.dev/actionslib/pkg/model"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// Regression test for go-gitea/gitea#37483: a remote reusable workflow at a moving
|
||||
// ref (branch/tag) must reflect the new tip on every invocation, not stay pinned
|
||||
// to the cache populated on the first run.
|
||||
func TestReusableWorkflowCachedBranchRefRefreshes(t *testing.T) {
|
||||
if _, err := exec.LookPath("git"); err != nil {
|
||||
t.Skip("git not available in PATH")
|
||||
}
|
||||
|
||||
remoteDir := t.TempDir()
|
||||
gitMust(t, "", "init", "--bare", "--initial-branch=master", remoteDir)
|
||||
|
||||
workDir := t.TempDir()
|
||||
gitMust(t, "", "clone", remoteDir, workDir)
|
||||
gitMust(t, workDir, "config", "user.email", "test@test")
|
||||
gitMust(t, workDir, "config", "user.name", "test")
|
||||
gitMust(t, workDir, "checkout", "-b", "master")
|
||||
|
||||
const workflowPath = ".gitea/workflows/reusable.yml"
|
||||
tmpl := func(tag string) string {
|
||||
return "name: reusable\non:\n workflow_call:\njobs:\n build:\n runs-on: ubuntu-latest\n steps:\n - run: echo " + tag + "\n"
|
||||
}
|
||||
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(workDir, ".gitea/workflows"), 0o755))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(workDir, workflowPath), []byte(tmpl("v1")), 0o644))
|
||||
gitMust(t, workDir, "add", workflowPath)
|
||||
gitMust(t, workDir, "commit", "-m", "v1")
|
||||
gitMust(t, workDir, "push", "-u", "origin", "master")
|
||||
|
||||
rc := &RunContext{
|
||||
Config: &Config{},
|
||||
Run: &model.Run{
|
||||
JobID: "j1",
|
||||
Workflow: &model.Workflow{
|
||||
Name: "wf",
|
||||
Jobs: map[string]*model.Job{"j1": {}},
|
||||
},
|
||||
},
|
||||
}
|
||||
cacheDir := t.TempDir()
|
||||
|
||||
require.NoError(t, cloneRemoteReusableWorkflow(rc, remoteDir, "master", cacheDir, "")(context.Background()))
|
||||
got, err := os.ReadFile(filepath.Join(cacheDir, workflowPath))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tmpl("v1"), string(got))
|
||||
|
||||
// Branch tip moves; cache key (cacheDir) does not.
|
||||
require.NoError(t, os.WriteFile(filepath.Join(workDir, workflowPath), []byte(tmpl("v2")), 0o644))
|
||||
gitMust(t, workDir, "commit", "-am", "v2")
|
||||
gitMust(t, workDir, "push", "origin", "master")
|
||||
|
||||
require.NoError(t, cloneRemoteReusableWorkflow(rc, remoteDir, "master", cacheDir, "")(context.Background()))
|
||||
got, err = os.ReadFile(filepath.Join(cacheDir, workflowPath))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tmpl("v2"), string(got), "cached workflow file must reflect the updated branch tip")
|
||||
}
|
||||
|
||||
func TestNewReusableWorkflowExecutorHoldsCloneLock(t *testing.T) {
|
||||
workflowDir := t.TempDir()
|
||||
|
||||
unlockOnce := sync.OnceFunc(git.AcquireCloneLock(workflowDir))
|
||||
defer unlockOnce()
|
||||
|
||||
plannerCalled := make(chan struct{})
|
||||
|
||||
origPlanner := modelNewWorkflowPlanner
|
||||
modelNewWorkflowPlanner = func(string, bool) (model.WorkflowPlanner, error) {
|
||||
close(plannerCalled)
|
||||
return nil, errors.New("stop")
|
||||
}
|
||||
defer func() { modelNewWorkflowPlanner = origPlanner }()
|
||||
|
||||
rc := &RunContext{
|
||||
Config: &Config{},
|
||||
Run: &model.Run{Workflow: &model.Workflow{Jobs: map[string]*model.Job{}}},
|
||||
}
|
||||
exec := newReusableWorkflowExecutor(rc, workflowDir, "reusable.yml")
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- exec(context.Background()) }()
|
||||
|
||||
select {
|
||||
case <-plannerCalled:
|
||||
t.Fatal("planner ran while clone lock was held")
|
||||
case err := <-done:
|
||||
t.Fatalf("executor returned before planner was reached: %v", err)
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
}
|
||||
|
||||
unlockOnce()
|
||||
|
||||
select {
|
||||
case <-plannerCalled:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("planner not called after lock was released")
|
||||
}
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
require.Error(t, err)
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("executor did not return after planner ran")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetGitCloneTokenWithSchemalessGiteaInstance(t *testing.T) {
|
||||
conf := &Config{
|
||||
GitHubInstance: "gitea.example.net",
|
||||
Secrets: map[string]string{
|
||||
"GITEA_TOKEN": "token-value",
|
||||
},
|
||||
}
|
||||
|
||||
token := getGitCloneToken(conf, "https://gitea.example.net/actions/tools")
|
||||
|
||||
require.Equal(t, "token-value", token)
|
||||
}
|
||||
|
||||
func TestGetGitCloneTokenSelfHostedActionsDifferentHost(t *testing.T) {
|
||||
// The runner registered with one hostname while DEFAULT_ACTIONS_URL=self resolves
|
||||
// actions against AppURL on a different hostname for the same instance.
|
||||
conf := &Config{
|
||||
GitHubInstance: "gitea.local",
|
||||
DefaultActionInstance: "https://gitea.my-nas.lan",
|
||||
DefaultActionInstanceIsSelfHosted: true,
|
||||
Secrets: map[string]string{
|
||||
"GITEA_TOKEN": "token-value",
|
||||
},
|
||||
}
|
||||
|
||||
token := getGitCloneToken(conf, "https://gitea.my-nas.lan/owner/action")
|
||||
|
||||
require.Equal(t, "token-value", token)
|
||||
}
|
||||
|
||||
func TestShouldCloneURLUseToken(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
instanceURL string
|
||||
trustedActionInstance string
|
||||
cloneURL string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "same host with schemaless instance",
|
||||
instanceURL: "gitea.example.net",
|
||||
cloneURL: "https://gitea.example.net/actions/tools",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "same host with schemaless instance and port",
|
||||
instanceURL: "gitea.example.net:3000",
|
||||
cloneURL: "https://gitea.example.net:3000/actions/tools",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "different host",
|
||||
instanceURL: "gitea.example.net",
|
||||
cloneURL: "https://github.com/actions/tools",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "embedded basic auth",
|
||||
instanceURL: "gitea.example.net",
|
||||
cloneURL: "https://user:[email protected]/actions/tools",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "invalid clone URL",
|
||||
instanceURL: "gitea.example.net",
|
||||
cloneURL: "://gitea.example.net/actions/tools",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
// self-hosted DEFAULT_ACTIONS_URL on a different hostname than the
|
||||
// registered instance: the token must still be attached.
|
||||
name: "self-hosted action instance on different host",
|
||||
instanceURL: "gitea.local",
|
||||
trustedActionInstance: "https://gitea.my-nas.lan",
|
||||
cloneURL: "https://gitea.my-nas.lan/owner/action",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
// embedded basic auth must still be rejected even when the host matches
|
||||
// the trusted action instance.
|
||||
name: "self-hosted action instance with embedded basic auth",
|
||||
instanceURL: "gitea.local",
|
||||
trustedActionInstance: "https://gitea.my-nas.lan",
|
||||
cloneURL: "https://user:[email protected]/owner/action",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
// github.com / mirror hosts are never trusted: trustedActionInstance is
|
||||
// empty in github mode, so an off-instance clone URL gets no token.
|
||||
name: "github mode does not trust mirror host",
|
||||
instanceURL: "gitea.local",
|
||||
cloneURL: "https://mirror.example.com/owner/action",
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
require.Equal(t, tt.want, shouldCloneURLUseToken(tt.instanceURL, tt.trustedActionInstance, tt.cloneURL))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func gitMust(t *testing.T, dir string, args ...string) {
|
||||
t.Helper()
|
||||
cmd := exec.Command("git", args...)
|
||||
if dir != "" {
|
||||
cmd.Dir = dir
|
||||
}
|
||||
out, err := cmd.CombinedOutput()
|
||||
require.NoError(t, err, "git %v: %s", args, string(out))
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,390 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2020 The nektos/act Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package runner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"maps"
|
||||
"os"
|
||||
"runtime"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.com/gitea/runner/internal/act/common"
|
||||
"gitea.com/gitea/runner/internal/act/container"
|
||||
|
||||
"gitea.dev/actionslib/pkg/model"
|
||||
docker_container "github.com/moby/moby/api/types/container"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// Runner provides capabilities to run GitHub actions
|
||||
type Runner interface {
|
||||
NewPlanExecutor(plan *model.Plan) common.Executor
|
||||
}
|
||||
|
||||
// Config contains the config for a new runner
|
||||
type Config struct {
|
||||
Actor string // the user that triggered the event
|
||||
Workdir string // path to working directory
|
||||
ActionCacheDir string // path used for caching action contents
|
||||
ActionOfflineMode bool // when offline, use cached action contents
|
||||
ActionCloneDepth int // limit history when cloning an action repo; 0 clones every branch in full
|
||||
BindWorkdir bool // bind the workdir to the job container
|
||||
EventName string // name of event to run
|
||||
EventPath string // path to JSON file to use for event.json in containers
|
||||
DefaultBranch string // name of the main branch for this repository
|
||||
ReuseContainers bool // reuse containers to maintain state
|
||||
ForcePull bool // force pulling of the image, even if already present
|
||||
ForceRebuild bool // force rebuilding local docker image action
|
||||
LogOutput bool // log the output from docker run
|
||||
JSONLogger bool // use json or text logger
|
||||
LogPrefixJobID bool // switches from the full job name to the job id
|
||||
Env map[string]string // env for containers
|
||||
Inputs map[string]string // manually passed action inputs
|
||||
Secrets map[string]string // list of secrets
|
||||
Vars map[string]string // list of vars
|
||||
Token string // GitHub token
|
||||
InsecureSecrets bool // switch hiding output when printing to terminal
|
||||
Platforms map[string]string // list of platforms
|
||||
Privileged bool // use privileged mode
|
||||
UsernsMode string // user namespace to use
|
||||
ContainerArchitecture string // Desired OS/architecture platform for running containers
|
||||
ContainerDaemonSocket string // Path to Docker daemon socket
|
||||
ContainerOptions string // Options for the job container
|
||||
UseGitIgnore bool // controls if paths in .gitignore should not be copied into container, default true
|
||||
GitHubInstance string // GitHub instance to use, default "github.com"
|
||||
ContainerCapAdd []string // list of kernel capabilities to add to the containers
|
||||
ContainerCapDrop []string // list of kernel capabilities to remove from the containers
|
||||
AutoRemove bool // controls if the container is automatically removed upon workflow completion
|
||||
ArtifactServerPath string // the path where the artifact server stores uploads
|
||||
ArtifactServerAddr string // the address the artifact server binds to
|
||||
ArtifactServerPort string // the port the artifact server binds to
|
||||
NoSkipCheckout bool // do not skip actions/checkout
|
||||
DisableActEnv bool // do not inject the ACT=true environment variable into jobs
|
||||
RemoteName string // remote name in local git repo config
|
||||
ReplaceGheActionWithGithubCom []string // Use actions from GitHub Enterprise instance to GitHub
|
||||
ReplaceGheActionTokenWithGithubCom string // Token of private action repo on GitHub.
|
||||
Matrix map[string]map[string]bool // Matrix config to run
|
||||
ContainerNetworkMode docker_container.NetworkMode // the network mode of job containers (the value of --network)
|
||||
ContainerNetworkCreateOptions container.NewDockerNetworkCreateExecutorInput // the default network create options
|
||||
ActionCache ActionCache // Use a custom ActionCache Implementation
|
||||
ProxyEnv map[string]string // the proxy variables the job runs with, also given to service containers and image builds
|
||||
PatchToolkit bool // edit the @actions toolkit bundled into an action so it works against Gitea, see toolkit_patch.go
|
||||
|
||||
PresetGitHubContext *model.GithubContext // the preset github context, overrides some fields like DefaultBranch, Env, Secrets etc.
|
||||
EventJSON string // the content of JSON file to use for event.json in containers, overrides EventPath
|
||||
ContainerNamePrefix string // the prefix of container name
|
||||
ContainerMaxLifetime time.Duration // the max lifetime of job containers
|
||||
CleanWorkdir bool // remove host executor workdir on teardown
|
||||
DefaultActionInstance string // the default actions web site
|
||||
// DefaultActionInstanceIsSelfHosted reports whether DefaultActionInstance is this
|
||||
// self-hosted Gitea (DEFAULT_ACTIONS_URL=self). It gates token trust: only then may the
|
||||
// task token be attached to action clone URLs on DefaultActionInstance's host, which can
|
||||
// differ from GitHubInstance when the runner registered with a different hostname than
|
||||
// AppURL. It is never set for github.com or a GithubMirror, so the token stays on-instance.
|
||||
DefaultActionInstanceIsSelfHosted bool
|
||||
PlatformPicker func(labels []string) string // platform picker, it will take precedence over Platforms if isn't nil
|
||||
JobLoggerLevel *log.Level // the level of job logger
|
||||
ValidVolumes []string // only volumes (and bind mounts) in this slice can be mounted on the job container or service containers
|
||||
InsecureSkipTLS bool // whether to skip verifying TLS certificate of the Gitea instance
|
||||
MaxParallel int // max parallel jobs to run across all workflows (0 = no limit, uses CPU count)
|
||||
AllocatePTY bool // allocate a pseudo-TTY for each step's process
|
||||
ServiceReadyTimeout time.Duration // how long a job waits for its service containers to report healthy (0 uses the default)
|
||||
RunnerName string // name this runner registered with, reported as `runner.name`, defaults to the hostname
|
||||
JobStartedHook string // script run inside the job environment before the job's first step; ACTIONS_RUNNER_HOOK_JOB_STARTED is read from Env when empty
|
||||
JobCompletedHook string // script run inside the job environment after the job's last step; ACTIONS_RUNNER_HOOK_JOB_COMPLETED is read from Env when empty
|
||||
}
|
||||
|
||||
// RunnerDebug reports whether debug logging is on, exposed as `runner.debug` and
|
||||
// RUNNER_DEBUG. Only the secret also makes the reporter keep ::debug:: output, the env
|
||||
// is accepted for `exec` and for runners configured with it.
|
||||
func (c Config) RunnerDebug() bool {
|
||||
return c.Secrets["ACTIONS_STEP_DEBUG"] == "true" || c.Env["ACTIONS_STEP_DEBUG"] == "true"
|
||||
}
|
||||
|
||||
// GetToken: Adapt to Gitea
|
||||
func (c Config) GetToken() string {
|
||||
token := c.Secrets["GITHUB_TOKEN"]
|
||||
if c.Secrets["GITEA_TOKEN"] != "" {
|
||||
token = c.Secrets["GITEA_TOKEN"]
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
// DefaultActionURL returns the host used for implicit remote actions.
|
||||
func (c Config) DefaultActionURL() string {
|
||||
if c.DefaultActionInstance != "" {
|
||||
return c.DefaultActionInstance
|
||||
}
|
||||
if c.GitHubInstance != "" {
|
||||
return c.GitHubInstance
|
||||
}
|
||||
return "github.com"
|
||||
}
|
||||
|
||||
type caller struct {
|
||||
runContext *RunContext
|
||||
|
||||
updateResultLock sync.Mutex // For Gitea
|
||||
reusedWorkflowJobResults map[string]string // For Gitea
|
||||
}
|
||||
|
||||
type runnerImpl struct {
|
||||
config *Config
|
||||
eventJSON string
|
||||
caller *caller // the job calling this runner (caller of a reusable workflow)
|
||||
}
|
||||
|
||||
// New Creates a new Runner
|
||||
func New(runnerConfig *Config) (Runner, error) {
|
||||
runner := &runnerImpl{
|
||||
config: runnerConfig,
|
||||
}
|
||||
|
||||
return runner.configure()
|
||||
}
|
||||
|
||||
func (runner *runnerImpl) configure() (Runner, error) {
|
||||
if runner.config.RunnerName == "" {
|
||||
// Callers that do not register, such as `exec`, still get a `runner.name`.
|
||||
runner.config.RunnerName, _ = os.Hostname()
|
||||
}
|
||||
|
||||
runner.eventJSON = "{}"
|
||||
if runner.config.EventJSON != "" {
|
||||
runner.eventJSON = runner.config.EventJSON
|
||||
} else if runner.config.EventPath != "" {
|
||||
log.Debugf("Reading event.json from %s", runner.config.EventPath)
|
||||
eventJSONBytes, err := os.ReadFile(runner.config.EventPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
runner.eventJSON = string(eventJSONBytes)
|
||||
} else if len(runner.config.Inputs) != 0 {
|
||||
eventMap := map[string]map[string]string{
|
||||
"inputs": runner.config.Inputs,
|
||||
}
|
||||
eventJSON, err := json.Marshal(eventMap)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
runner.eventJSON = string(eventJSON)
|
||||
}
|
||||
return runner, nil
|
||||
}
|
||||
|
||||
// NewPlanExecutor ...
|
||||
func (runner *runnerImpl) NewPlanExecutor(plan *model.Plan) common.Executor {
|
||||
maxJobNameLen := 0
|
||||
|
||||
stagePipeline := make([]common.Executor, 0)
|
||||
log.Debugf("Plan Stages: %v", plan.Stages)
|
||||
|
||||
for i := range plan.Stages {
|
||||
stage := plan.Stages[i]
|
||||
stagePipeline = append(stagePipeline, func(ctx context.Context) error {
|
||||
pipeline := make([]common.Executor, 0)
|
||||
for _, run := range stage.Runs {
|
||||
log.Debugf("Stages Runs: %v", stage.Runs)
|
||||
stageExecutor := make([]common.Executor, 0)
|
||||
job := run.Job()
|
||||
log.Debugf("Job.Name: %v", job.Name)
|
||||
log.Debugf("Job.RawNeeds: %v", job.RawNeeds)
|
||||
log.Debugf("Job.RawRunsOn: %v", job.RawRunsOn)
|
||||
log.Debugf("Job.Env: %v", job.Env)
|
||||
log.Debugf("Job.If: %v", job.If)
|
||||
for step := range job.Steps {
|
||||
if nil != job.Steps[step] {
|
||||
log.Debugf("Job.Steps: %v", job.Steps[step].String())
|
||||
}
|
||||
}
|
||||
log.Debugf("Job.TimeoutMinutes: %v", job.TimeoutMinutes)
|
||||
log.Debugf("Job.Services: %v", job.Services)
|
||||
log.Debugf("Job.Strategy: %v", job.Strategy)
|
||||
log.Debugf("Job.RawContainer: %v", job.RawContainer)
|
||||
log.Debugf("Job.Defaults.Run.Shell: %v", job.Defaults.Run.Shell)
|
||||
log.Debugf("Job.Defaults.Run.WorkingDirectory: %v", job.Defaults.Run.WorkingDirectory)
|
||||
log.Debugf("Job.Outputs: %v", job.Outputs)
|
||||
log.Debugf("Job.Uses: %v", job.Uses)
|
||||
log.Debugf("Job.With: %v", job.With)
|
||||
// log.Debugf("Job.RawSecrets: %v", job.RawSecrets)
|
||||
log.Debugf("Job.Result: %v", job.Result)
|
||||
|
||||
if job.Strategy != nil {
|
||||
log.Debugf("Job.Strategy.FailFast: %v", job.Strategy.FailFast)
|
||||
log.Debugf("Job.Strategy.MaxParallel: %v", job.Strategy.MaxParallel)
|
||||
log.Debugf("Job.Strategy.FailFastString: %v", job.Strategy.FailFastString)
|
||||
log.Debugf("Job.Strategy.MaxParallelString: %v", job.Strategy.MaxParallelString)
|
||||
log.Debugf("Job.Strategy.RawMatrix: %v", job.Strategy.RawMatrix)
|
||||
|
||||
strategyRc := runner.newRunContext(ctx, run, nil)
|
||||
// Resolve template expressions in the matrix node before Matrix() is called.
|
||||
// On failure the literal string is kept and normalizeMatrixValue wraps it as a fallback.
|
||||
if err := strategyRc.NewExpressionEvaluator(ctx).EvaluateYamlNode(ctx, &job.Strategy.RawMatrix); err != nil {
|
||||
log.Errorf("Error while evaluating matrix: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
var matrixes []map[string]any
|
||||
if m, err := job.GetMatrixes(); err != nil {
|
||||
log.Errorf("Error while get job's matrix: %v", err)
|
||||
} else {
|
||||
log.Debugf("Job Matrices: %v", m)
|
||||
log.Debugf("Runner Matrices: %v", runner.config.Matrix)
|
||||
matrixes = selectMatrixes(m, runner.config.Matrix)
|
||||
}
|
||||
log.Debugf("Final matrix after applying user inclusions '%v'", matrixes)
|
||||
|
||||
maxParallel := 4
|
||||
if job.Strategy != nil {
|
||||
// Ensure GetMaxParallel() is called if MaxParallel is still 0
|
||||
if job.Strategy.MaxParallel == 0 {
|
||||
job.Strategy.MaxParallel = job.Strategy.GetMaxParallel()
|
||||
}
|
||||
maxParallel = job.Strategy.MaxParallel
|
||||
log.Debugf("Using job.Strategy.MaxParallel: %d", maxParallel)
|
||||
}
|
||||
|
||||
if len(matrixes) < maxParallel {
|
||||
log.Debugf("Adjusting maxParallel from %d to %d (number of matrix combinations)", maxParallel, len(matrixes))
|
||||
maxParallel = len(matrixes)
|
||||
}
|
||||
|
||||
log.Infof("Running job with maxParallel=%d for %d matrix combinations", maxParallel, len(matrixes))
|
||||
|
||||
for i, matrix := range matrixes {
|
||||
rc := runner.newRunContext(ctx, run, matrix)
|
||||
rc.JobName = rc.Name
|
||||
if len(matrixes) > 1 {
|
||||
rc.Name = fmt.Sprintf("%s-%d", rc.Name, i+1)
|
||||
}
|
||||
if len(rc.String()) > maxJobNameLen {
|
||||
maxJobNameLen = len(rc.String())
|
||||
}
|
||||
if rc.caller != nil { // For Gitea
|
||||
rc.caller.setReusedWorkflowJobResult(rc.JobName, "pending")
|
||||
}
|
||||
stageExecutor = append(stageExecutor, func(ctx context.Context) error {
|
||||
jobName := fmt.Sprintf("%-*s", maxJobNameLen, rc.String())
|
||||
executor, err := rc.Executor()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
jobCtx := common.WithJobErrorContainer(WithJobLogger(ctx, rc.Run.JobID, jobName, rc.Config, &rc.Masks, matrix))
|
||||
jobCtx, cancelTimeout := applyJobTimeout(jobCtx, rc, job)
|
||||
defer cancelTimeout()
|
||||
return executor(jobCtx)
|
||||
})
|
||||
}
|
||||
// Run all matrix combinations of this job, then drop its aggregation mutex: the
|
||||
// combos are the only users of it, so once they finish the jobMutexes entry can be
|
||||
// released, keeping the map from growing unbounded over a long-lived runner.
|
||||
stageParallel := common.NewParallelExecutor(maxParallel, stageExecutor...)
|
||||
pipeline = append(pipeline, func(ctx context.Context) error {
|
||||
defer jobMutexes.Delete(job)
|
||||
return stageParallel(ctx)
|
||||
})
|
||||
}
|
||||
|
||||
// For pipeline execution:
|
||||
// - If only 1 element: run it directly (no need for additional parallelization)
|
||||
// - If multiple elements: run them in parallel up to maxParallel or ncpu
|
||||
if len(pipeline) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if len(pipeline) == 1 {
|
||||
// Single run/job: execute directly without additional parallelization wrapper
|
||||
// This ensures max-parallel is the only limiting factor
|
||||
log.Debugf("Single pipeline element, executing directly")
|
||||
return pipeline[0](ctx)
|
||||
}
|
||||
|
||||
// Multiple runs/jobs: execute in parallel up to maxParallel (if set) or ncpu
|
||||
parallelism := runtime.NumCPU()
|
||||
|
||||
// If MaxParallel is set in config, use it
|
||||
if runner.config.MaxParallel > 0 {
|
||||
parallelism = runner.config.MaxParallel
|
||||
log.Debugf("Using configured max-parallel: %d", parallelism)
|
||||
} else {
|
||||
log.Debugf("Using CPU count for parallelism: %d", parallelism)
|
||||
}
|
||||
|
||||
// Don't exceed the number of pipeline elements
|
||||
if parallelism > len(pipeline) {
|
||||
parallelism = len(pipeline)
|
||||
}
|
||||
|
||||
log.Infof("Executing %d pipeline elements with parallelism %d", len(pipeline), parallelism)
|
||||
return common.NewParallelExecutor(parallelism, pipeline...)(ctx)
|
||||
})
|
||||
}
|
||||
|
||||
return common.NewPipelineExecutor(stagePipeline...).Then(handleFailure(plan))
|
||||
}
|
||||
|
||||
func handleFailure(plan *model.Plan) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
for _, stage := range plan.Stages {
|
||||
for _, run := range stage.Runs {
|
||||
if run.Job().Result == "failure" && !run.Job().ContinueOnError {
|
||||
return fmt.Errorf("Job '%s' failed", run.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func selectMatrixes(originalMatrixes []map[string]any, targetMatrixValues map[string]map[string]bool) []map[string]any {
|
||||
matrixes := make([]map[string]any, 0)
|
||||
for _, original := range originalMatrixes {
|
||||
flag := true
|
||||
for key, val := range original {
|
||||
if allowedVals, ok := targetMatrixValues[key]; ok {
|
||||
valToString := fmt.Sprintf("%v", val)
|
||||
if _, ok := allowedVals[valToString]; !ok {
|
||||
flag = false
|
||||
}
|
||||
}
|
||||
}
|
||||
if flag {
|
||||
matrixes = append(matrixes, original)
|
||||
}
|
||||
}
|
||||
return matrixes
|
||||
}
|
||||
|
||||
func (runner *runnerImpl) newRunContext(ctx context.Context, run *model.Run, matrix map[string]any) *RunContext {
|
||||
rc := &RunContext{
|
||||
Config: runner.config,
|
||||
Run: run,
|
||||
EventJSON: runner.eventJSON,
|
||||
StepResults: make(map[string]*model.StepResult),
|
||||
Matrix: matrix,
|
||||
caller: runner.caller,
|
||||
}
|
||||
rc.ExprEval = rc.NewExpressionEvaluator(ctx)
|
||||
rc.Name = rc.ExprEval.Interpolate(ctx, run.String())
|
||||
// Snapshot the job's pristine output expressions now, before any matrix combo runs and
|
||||
// rewrites the shared Job.Outputs (see interpolateOutputs).
|
||||
if job := run.Job(); job != nil {
|
||||
rc.outputTemplate = maps.Clone(job.Outputs)
|
||||
}
|
||||
|
||||
return rc
|
||||
}
|
||||
|
||||
// For Gitea
|
||||
func (c *caller) setReusedWorkflowJobResult(jobName, result string) {
|
||||
c.updateResultLock.Lock()
|
||||
defer c.updateResultLock.Unlock()
|
||||
c.reusedWorkflowJobResults[jobName] = result
|
||||
}
|
||||
@@ -0,0 +1,665 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2020 The nektos/act Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package runner
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.com/gitea/runner/internal/act/common"
|
||||
|
||||
"gitea.dev/actionslib/pkg/model"
|
||||
"github.com/joho/godotenv"
|
||||
log "github.com/sirupsen/logrus"
|
||||
assert "github.com/stretchr/testify/assert"
|
||||
"go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
var (
|
||||
baseImage = "node:24-bookworm-slim"
|
||||
platforms map[string]string
|
||||
logLevel = log.DebugLevel
|
||||
workdir = "testdata"
|
||||
secrets map[string]string
|
||||
)
|
||||
|
||||
func init() {
|
||||
if p := os.Getenv("ACT_TEST_IMAGE"); p != "" {
|
||||
baseImage = p
|
||||
}
|
||||
|
||||
platforms = map[string]string{
|
||||
"ubuntu-latest": baseImage,
|
||||
}
|
||||
|
||||
if l := os.Getenv("ACT_TEST_LOG_LEVEL"); l != "" {
|
||||
if lvl, err := log.ParseLevel(l); err == nil {
|
||||
logLevel = lvl
|
||||
}
|
||||
}
|
||||
|
||||
if wd, err := filepath.Abs(workdir); err == nil {
|
||||
workdir = wd
|
||||
}
|
||||
|
||||
secrets = map[string]string{}
|
||||
}
|
||||
|
||||
func TestNoWorkflowsFoundByPlanner(t *testing.T) {
|
||||
planner, err := model.NewWorkflowPlanner("res", true)
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
out := log.StandardLogger().Out
|
||||
var buf bytes.Buffer
|
||||
log.SetOutput(&buf)
|
||||
log.SetLevel(log.DebugLevel)
|
||||
plan, err := planner.PlanEvent("pull_request")
|
||||
assert.NotNil(t, plan)
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.Contains(t, buf.String(), "no workflows found by planner")
|
||||
buf.Reset()
|
||||
plan, err = planner.PlanAll()
|
||||
assert.NotNil(t, plan)
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.Contains(t, buf.String(), "no workflows found by planner")
|
||||
log.SetOutput(out)
|
||||
}
|
||||
|
||||
func TestGraphMissingEvent(t *testing.T) {
|
||||
planner, err := model.NewWorkflowPlanner("testdata/issue-1595/no-event.yml", true)
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
out := log.StandardLogger().Out
|
||||
var buf bytes.Buffer
|
||||
log.SetOutput(&buf)
|
||||
log.SetLevel(log.DebugLevel)
|
||||
|
||||
plan, err := planner.PlanEvent("push")
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.NotNil(t, plan)
|
||||
assert.Empty(t, plan.Stages)
|
||||
|
||||
assert.Contains(t, buf.String(), "no events found for workflow: no-event.yml")
|
||||
log.SetOutput(out)
|
||||
}
|
||||
|
||||
func TestGraphMissingFirst(t *testing.T) {
|
||||
planner, err := model.NewWorkflowPlanner("testdata/issue-1595/no-first.yml", true)
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
plan, err := planner.PlanEvent("push")
|
||||
assert.EqualError(t, err, "unable to build dependency graph for no first (no-first.yml)") //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.NotNil(t, plan)
|
||||
assert.Empty(t, plan.Stages)
|
||||
}
|
||||
|
||||
func TestGraphWithMissing(t *testing.T) {
|
||||
planner, err := model.NewWorkflowPlanner("testdata/issue-1595/missing.yml", true)
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
out := log.StandardLogger().Out
|
||||
var buf bytes.Buffer
|
||||
log.SetOutput(&buf)
|
||||
log.SetLevel(log.DebugLevel)
|
||||
|
||||
plan, err := planner.PlanEvent("push")
|
||||
assert.NotNil(t, plan)
|
||||
assert.Empty(t, plan.Stages)
|
||||
assert.EqualError(t, err, "unable to build dependency graph for missing (missing.yml)") //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.Contains(t, buf.String(), "unable to build dependency graph for missing (missing.yml)")
|
||||
log.SetOutput(out)
|
||||
}
|
||||
|
||||
func TestGraphWithSomeMissing(t *testing.T) {
|
||||
log.SetLevel(log.DebugLevel)
|
||||
|
||||
planner, err := model.NewWorkflowPlanner("testdata/issue-1595/", true)
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
out := log.StandardLogger().Out
|
||||
var buf bytes.Buffer
|
||||
log.SetOutput(&buf)
|
||||
log.SetLevel(log.DebugLevel)
|
||||
|
||||
plan, err := planner.PlanAll()
|
||||
assert.Error(t, err, "unable to build dependency graph for no first (no-first.yml)") //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.NotNil(t, plan)
|
||||
assert.Len(t, plan.Stages, 1)
|
||||
assert.Contains(t, buf.String(), "unable to build dependency graph for missing (missing.yml)")
|
||||
assert.Contains(t, buf.String(), "unable to build dependency graph for no first (no-first.yml)")
|
||||
log.SetOutput(out)
|
||||
}
|
||||
|
||||
func TestGraphEvent(t *testing.T) {
|
||||
planner, err := model.NewWorkflowPlanner("testdata/basic", true)
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
plan, err := planner.PlanEvent("push")
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.NotNil(t, plan)
|
||||
assert.NotNil(t, plan.Stages)
|
||||
assert.Equal(t, len(plan.Stages), 3, "stages") //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.Equal(t, len(plan.Stages[0].Runs), 1, "stage0.runs") //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.Equal(t, len(plan.Stages[1].Runs), 1, "stage1.runs") //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.Equal(t, len(plan.Stages[2].Runs), 1, "stage2.runs") //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.Equal(t, plan.Stages[0].Runs[0].JobID, "check", "jobid") //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.Equal(t, plan.Stages[1].Runs[0].JobID, "build", "jobid") //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.Equal(t, plan.Stages[2].Runs[0].JobID, "test", "jobid") //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
plan, err = planner.PlanEvent("release")
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.NotNil(t, plan)
|
||||
assert.Empty(t, plan.Stages)
|
||||
}
|
||||
|
||||
// these two build the same action Dockerfiles into one image tag, so they cannot overlap
|
||||
var sharedImageWorkflows = []string{"local-action-dockerfile", "local-action-via-composite-dockerfile"}
|
||||
|
||||
// bounds concurrent plans: each job holds a network, and the daemon's address pool is finite
|
||||
var planSlots = make(chan struct{}, 4)
|
||||
|
||||
type TestJobFileInfo struct {
|
||||
workdir string
|
||||
workflowPath string
|
||||
eventName string
|
||||
errorMessage string
|
||||
platforms map[string]string
|
||||
secrets map[string]string
|
||||
}
|
||||
|
||||
func (j *TestJobFileInfo) runTest(ctx context.Context, t *testing.T, cfg *Config) {
|
||||
fmt.Printf("::group::%s\n", j.workflowPath) //nolint:forbidigo // pre-existing issue from nektos/act
|
||||
|
||||
log.SetLevel(logLevel)
|
||||
|
||||
workdir, err := filepath.Abs(j.workdir)
|
||||
assert.NoError(t, err, workdir) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
fullWorkflowPath := filepath.Join(workdir, j.workflowPath)
|
||||
runnerConfig := &Config{
|
||||
Workdir: workdir,
|
||||
BindWorkdir: false,
|
||||
EventName: j.eventName,
|
||||
EventPath: cfg.EventPath,
|
||||
Platforms: j.platforms,
|
||||
// fixtures reuse workflow and job names, so parallel tests would collide without this
|
||||
ContainerNamePrefix: strings.ReplaceAll(t.Name(), "/", "-"),
|
||||
ReuseContainers: false,
|
||||
// as the shipped runner does, else a fixture asserting a job failure keeps its
|
||||
// container, and its network, on the daemon forever
|
||||
AutoRemove: true,
|
||||
// 0 would run jobs runtime.NumCPU()-wide, making the network peak machine-dependent
|
||||
MaxParallel: 2,
|
||||
ForceRebuild: true,
|
||||
Env: cfg.Env,
|
||||
Secrets: cfg.Secrets,
|
||||
Inputs: cfg.Inputs,
|
||||
GitHubInstance: "github.com",
|
||||
DefaultActionInstance: cfg.DefaultActionInstance,
|
||||
ContainerArchitecture: cfg.ContainerArchitecture,
|
||||
ContainerMaxLifetime: time.Hour,
|
||||
Matrix: cfg.Matrix,
|
||||
ActionCache: cfg.ActionCache,
|
||||
ValidVolumes: []string{"**"}, // allow workflow-declared volumes (e.g. container-volumes)
|
||||
}
|
||||
|
||||
runner, err := New(runnerConfig)
|
||||
assert.NoError(t, err, j.workflowPath) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
planner, err := model.NewWorkflowPlanner(fullWorkflowPath, true)
|
||||
assert.NoError(t, err, fullWorkflowPath) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
plan, err := planner.PlanEvent(j.eventName)
|
||||
assert.True(t, (err == nil) != (plan == nil), "PlanEvent should return either a plan or an error") //nolint:testifylint // pre-existing issue from nektos/act
|
||||
if err == nil && plan != nil {
|
||||
err = func() error {
|
||||
planSlots <- struct{}{}
|
||||
defer func() { <-planSlots }()
|
||||
return runner.NewPlanExecutor(plan)(ctx)
|
||||
}()
|
||||
if j.errorMessage == "" {
|
||||
assert.NoError(t, err, fullWorkflowPath) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
} else {
|
||||
assert.Error(t, err, j.errorMessage) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("::endgroup::") //nolint:forbidigo // pre-existing issue from nektos/act
|
||||
}
|
||||
|
||||
type TestConfig struct {
|
||||
LocalRepositories map[string]string `yaml:"local-repositories"`
|
||||
}
|
||||
|
||||
func TestRunEvent(t *testing.T) {
|
||||
requireDocker(t)
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
tables := []TestJobFileInfo{
|
||||
// Shells
|
||||
{workdir, "shells/defaults", "push", "", platforms, secrets},
|
||||
{workdir, "shells/bash", "push", "", platforms, secrets},
|
||||
{workdir, "shells/sh", "push", "", platforms, secrets},
|
||||
|
||||
// Local action
|
||||
{workdir, "local-action-docker-url", "push", "", platforms, secrets},
|
||||
{workdir, "local-action-dockerfile", "push", "", platforms, secrets},
|
||||
{workdir, "local-action-via-composite-dockerfile", "push", "", platforms, secrets},
|
||||
{workdir, "local-action-js", "push", "", platforms, secrets},
|
||||
|
||||
// Uses
|
||||
{workdir, "uses-composite", "push", "", platforms, secrets},
|
||||
{workdir, "uses-composite-with-error", "push", "Job 'failing-composite-action' failed", platforms, secrets},
|
||||
{workdir, "uses-docker-url", "push", "", platforms, secrets},
|
||||
{workdir, "act-composite-env-test", "push", "", platforms, secrets},
|
||||
|
||||
// Eval
|
||||
{workdir, "evalmatrix", "push", "", platforms, secrets},
|
||||
{workdir, "evalmatrixneeds", "push", "", platforms, secrets},
|
||||
{workdir, "evalmatrixneeds2", "push", "", platforms, secrets},
|
||||
{workdir, "evalmatrix-merge-map", "push", "", platforms, secrets},
|
||||
{workdir, "evalmatrix-merge-array", "push", "", platforms, secrets},
|
||||
|
||||
{workdir, "basic", "push", "", platforms, secrets},
|
||||
{workdir, "fail", "push", "exit with `FAILURE`: 1", platforms, secrets},
|
||||
{workdir, "checkout", "push", "", platforms, secrets},
|
||||
{workdir, "job-container", "push", "", platforms, secrets},
|
||||
{workdir, "job-container-non-root", "push", "", platforms, secrets},
|
||||
{workdir, "job-container-invalid-credentials", "push", "failed to handle credentials: failed to interpolate container.credentials.password", platforms, secrets},
|
||||
{workdir, "container-hostname", "push", "", platforms, secrets},
|
||||
{workdir, "matrix", "push", "", platforms, secrets},
|
||||
{workdir, "matrix-exitcode", "push", "Job 'test' failed", platforms, secrets},
|
||||
{workdir, "commands", "push", "", platforms, secrets},
|
||||
{workdir, "workdir", "push", "", platforms, secrets},
|
||||
{workdir, "defaults-run", "push", "", platforms, secrets},
|
||||
{workdir, "composite-fail-with-output", "push", "", platforms, secrets},
|
||||
{workdir, "issue-597", "push", "", platforms, secrets},
|
||||
{workdir, "issue-598", "push", "", platforms, secrets},
|
||||
{workdir, "if-env-act", "push", "", platforms, secrets},
|
||||
{workdir, "env-and-path", "push", "", platforms, secrets},
|
||||
{workdir, "environment-files", "push", "", platforms, secrets},
|
||||
{workdir, "GITHUB_STATE", "push", "", platforms, secrets},
|
||||
{workdir, "environment-files-parser-bug", "push", "", platforms, secrets},
|
||||
{workdir, "non-existent-action", "push", "Job 'nopanic' failed", platforms, secrets},
|
||||
{workdir, "outputs", "push", "", platforms, secrets},
|
||||
{workdir, "networking", "push", "", platforms, secrets},
|
||||
{workdir, "steps-context/conclusion", "push", "", platforms, secrets},
|
||||
{workdir, "steps-context/outcome", "push", "", platforms, secrets},
|
||||
{workdir, "job-status-check", "push", "job 'fail' failed", platforms, secrets},
|
||||
{workdir, "if-expressions", "push", "Job 'mytest' failed", platforms, secrets},
|
||||
{workdir, "actions-environment-and-context-tests", "push", "", platforms, secrets},
|
||||
{workdir, "evalenv", "push", "", platforms, secrets},
|
||||
{workdir, "docker-action-custom-path", "push", "", platforms, secrets},
|
||||
{workdir, "GITHUB_ENV-use-in-env-ctx", "push", "", platforms, secrets},
|
||||
{workdir, "ensure-post-steps", "push", "Job 'second-post-step-should-fail' failed", platforms, secrets},
|
||||
{workdir, "workflow_call_inputs", "workflow_call", "", platforms, secrets},
|
||||
{workdir, "workflow_dispatch", "workflow_dispatch", "", platforms, secrets},
|
||||
{workdir, "workflow_dispatch_no_inputs_mapping", "workflow_dispatch", "", platforms, secrets},
|
||||
{workdir, "workflow_dispatch-scalar", "workflow_dispatch", "", platforms, secrets},
|
||||
{workdir, "workflow_dispatch-scalar-composite-action", "workflow_dispatch", "", platforms, secrets},
|
||||
{workdir, "job-needs-context-contains-result", "push", "", platforms, secrets},
|
||||
{workdir, "container-volumes", "push", "", platforms, secrets},
|
||||
{workdir, "path-handling", "push", "", platforms, secrets},
|
||||
{workdir, "do-not-leak-step-env-in-composite", "push", "", platforms, secrets},
|
||||
{workdir, "set-env-step-env-override", "push", "", platforms, secrets},
|
||||
{workdir, "set-env-new-env-file-per-step", "push", "", platforms, secrets},
|
||||
{workdir, "no-panic-on-invalid-composite-action", "push", "jobs failed due to invalid action", platforms, secrets},
|
||||
|
||||
// services
|
||||
{workdir, "services", "push", "", platforms, secrets},
|
||||
{workdir, "services-with-container", "push", "", platforms, secrets},
|
||||
{workdir, "services-empty-image", "push", "", platforms, secrets},
|
||||
|
||||
// local remote action overrides
|
||||
{workdir, "local-remote-action-overrides", "push", "", platforms, secrets},
|
||||
}
|
||||
|
||||
for _, table := range tables {
|
||||
t.Run(table.workflowPath, func(t *testing.T) {
|
||||
if table.workflowPath == "container-volumes" {
|
||||
// host /proc bind mounts are Linux-Docker-only
|
||||
requireLinuxDocker(t)
|
||||
}
|
||||
if !slices.Contains(sharedImageWorkflows, table.workflowPath) {
|
||||
t.Parallel()
|
||||
}
|
||||
|
||||
config := &Config{
|
||||
Secrets: table.secrets,
|
||||
}
|
||||
|
||||
eventFile := filepath.Join(workdir, table.workflowPath, "event.json")
|
||||
if _, err := os.Stat(eventFile); err == nil {
|
||||
config.EventPath = eventFile
|
||||
}
|
||||
|
||||
testConfigFile := filepath.Join(workdir, table.workflowPath, "config.yml")
|
||||
if file, err := os.ReadFile(testConfigFile); err == nil {
|
||||
testConfig := &TestConfig{}
|
||||
if yaml.Unmarshal(file, testConfig) == nil {
|
||||
if testConfig.LocalRepositories != nil {
|
||||
config.ActionCache = &LocalRepositoryCache{
|
||||
Parent: GoGitActionCache{
|
||||
path.Clean(path.Join(workdir, "cache")),
|
||||
},
|
||||
LocalRepositories: testConfig.LocalRepositories,
|
||||
CacheDirCache: map[string]string{},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
table.runTest(ctx, t, config)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunEventHostEnvironment(t *testing.T) {
|
||||
// Runs steps directly on the host (the "-self-hosted" platform), so it needs the shells
|
||||
// and tools the workflows invoke. No network gate: every action these workflows reference
|
||||
// is a local `./` fixture or the skipped actions/checkout, so the suite runs offline (same
|
||||
// as TestRunEvent). Only the broadly-used interpreters are required up front; the pwsh- and
|
||||
// nix-specific cases gate on their own tool below so a missing pwsh/nix skips just those.
|
||||
requireHostTools(t, "bash", "node")
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
tables := []TestJobFileInfo{}
|
||||
|
||||
if runtime.GOOS == "linux" {
|
||||
platforms := map[string]string{
|
||||
"ubuntu-latest": "-self-hosted",
|
||||
}
|
||||
|
||||
tables = append(tables, []TestJobFileInfo{
|
||||
// Shells
|
||||
{workdir, "shells/defaults", "push", "", platforms, secrets},
|
||||
{workdir, "shells/pwsh", "push", "", platforms, secrets},
|
||||
{workdir, "shells/bash", "push", "", platforms, secrets},
|
||||
{workdir, "shells/sh", "push", "", platforms, secrets},
|
||||
|
||||
// Local action
|
||||
{workdir, "local-action-js", "push", "", platforms, secrets},
|
||||
|
||||
// Uses
|
||||
{workdir, "uses-composite", "push", "", platforms, secrets},
|
||||
{workdir, "uses-composite-with-error", "push", "Job 'failing-composite-action' failed", platforms, secrets},
|
||||
{workdir, "act-composite-env-test", "push", "", platforms, secrets},
|
||||
|
||||
// Eval
|
||||
{workdir, "evalmatrix", "push", "", platforms, secrets},
|
||||
{workdir, "evalmatrixneeds", "push", "", platforms, secrets},
|
||||
{workdir, "evalmatrixneeds2", "push", "", platforms, secrets},
|
||||
{workdir, "evalmatrix-merge-map", "push", "", platforms, secrets},
|
||||
{workdir, "evalmatrix-merge-array", "push", "", platforms, secrets},
|
||||
|
||||
{workdir, "fail", "push", "exit with `FAILURE`: 1", platforms, secrets},
|
||||
{workdir, "checkout", "push", "", platforms, secrets},
|
||||
{workdir, "matrix", "push", "", platforms, secrets},
|
||||
{workdir, "commands", "push", "", platforms, secrets},
|
||||
{workdir, "defaults-run", "push", "", platforms, secrets},
|
||||
{workdir, "composite-fail-with-output", "push", "", platforms, secrets},
|
||||
{workdir, "issue-597", "push", "", platforms, secrets},
|
||||
{workdir, "issue-598", "push", "", platforms, secrets},
|
||||
{workdir, "if-env-act", "push", "", platforms, secrets},
|
||||
{workdir, "env-and-path", "push", "", platforms, secrets},
|
||||
{workdir, "non-existent-action", "push", "Job 'nopanic' failed", platforms, secrets},
|
||||
{workdir, "outputs", "push", "", platforms, secrets},
|
||||
{workdir, "steps-context/conclusion", "push", "", platforms, secrets},
|
||||
{workdir, "steps-context/outcome", "push", "", platforms, secrets},
|
||||
{workdir, "job-status-check", "push", "job 'fail' failed", platforms, secrets},
|
||||
{workdir, "if-expressions", "push", "Job 'mytest' failed", platforms, secrets},
|
||||
{workdir, "evalenv", "push", "", platforms, secrets},
|
||||
{workdir, "ensure-post-steps", "push", "Job 'second-post-step-should-fail' failed", platforms, secrets},
|
||||
}...)
|
||||
}
|
||||
if runtime.GOOS == "windows" {
|
||||
platforms := map[string]string{
|
||||
"windows-latest": "-self-hosted",
|
||||
}
|
||||
|
||||
tables = append(tables, []TestJobFileInfo{
|
||||
{workdir, "windows-prepend-path", "push", "", platforms, secrets},
|
||||
{workdir, "windows-add-env", "push", "", platforms, secrets},
|
||||
{workdir, "windows-shell-cmd", "push", "", platforms, secrets},
|
||||
}...)
|
||||
} else {
|
||||
platforms := map[string]string{
|
||||
"self-hosted": "-self-hosted",
|
||||
"ubuntu-latest": "-self-hosted",
|
||||
}
|
||||
|
||||
tables = append(tables, []TestJobFileInfo{
|
||||
{workdir, "nix-prepend-path", "push", "", platforms, secrets},
|
||||
{workdir, "inputs-via-env-context", "push", "", platforms, secrets},
|
||||
{workdir, "do-not-leak-step-env-in-composite", "push", "", platforms, secrets},
|
||||
{workdir, "set-env-step-env-override", "push", "", platforms, secrets},
|
||||
{workdir, "set-env-new-env-file-per-step", "push", "", platforms, secrets},
|
||||
{workdir, "no-panic-on-invalid-composite-action", "push", "jobs failed due to invalid action", platforms, secrets},
|
||||
}...)
|
||||
}
|
||||
|
||||
for _, table := range tables {
|
||||
t.Run(table.workflowPath, func(t *testing.T) {
|
||||
switch table.workflowPath {
|
||||
case "shells/pwsh":
|
||||
requireHostTools(t, "pwsh")
|
||||
case "nix-prepend-path":
|
||||
requireHostTools(t, "nix")
|
||||
}
|
||||
table.runTest(ctx, t, &Config{})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDryrunEvent(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Dryrun plans without containers or network (shells and local actions only).
|
||||
ctx := common.WithDryrun(context.Background(), true)
|
||||
|
||||
tables := []TestJobFileInfo{
|
||||
// Shells
|
||||
{workdir, "shells/defaults", "push", "", platforms, secrets},
|
||||
{workdir, "shells/pwsh", "push", "", platforms, secrets},
|
||||
{workdir, "shells/bash", "push", "", platforms, secrets},
|
||||
{workdir, "shells/sh", "push", "", platforms, secrets},
|
||||
|
||||
// Local action
|
||||
{workdir, "local-action-docker-url", "push", "", platforms, secrets},
|
||||
{workdir, "local-action-dockerfile", "push", "", platforms, secrets},
|
||||
{workdir, "local-action-via-composite-dockerfile", "push", "", platforms, secrets},
|
||||
{workdir, "local-action-js", "push", "", platforms, secrets},
|
||||
}
|
||||
|
||||
for _, table := range tables {
|
||||
t.Run(table.workflowPath, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
table.runTest(ctx, t, &Config{})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestReusableWorkflowCaller exercises the reusable-workflow caller path against a local
|
||||
// reusable workflow (typed inputs, secrets as both a map and `inherit`, and reading the called
|
||||
// workflow's outputs via `needs`).
|
||||
func TestReusableWorkflowCaller(t *testing.T) {
|
||||
requireDocker(t)
|
||||
t.Parallel()
|
||||
table := TestJobFileInfo{workdir, "uses-workflow", "push", "", platforms, map[string]string{"secret": "keep_it_private"}}
|
||||
table.runTest(context.Background(), t, &Config{Secrets: table.secrets})
|
||||
}
|
||||
|
||||
type maskJobLoggerFactory struct {
|
||||
Output bytes.Buffer
|
||||
}
|
||||
|
||||
func (f *maskJobLoggerFactory) WithJobLogger() *log.Logger {
|
||||
logger := log.New()
|
||||
logger.SetOutput(io.MultiWriter(&f.Output, os.Stdout))
|
||||
logger.SetLevel(log.DebugLevel)
|
||||
return logger
|
||||
}
|
||||
|
||||
func TestMaskValues(t *testing.T) {
|
||||
t.Parallel()
|
||||
assertNoSecret := func(text, secret string) { //nolint:unparam // pre-existing issue from nektos/act
|
||||
found := strings.Contains(text, "composite secret")
|
||||
if found {
|
||||
fmt.Printf("\nFound Secret in the given text:\n%s\n", text) //nolint:forbidigo // pre-existing issue from nektos/act
|
||||
}
|
||||
assert.False(t, strings.Contains(text, "composite secret")) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
}
|
||||
|
||||
requireDocker(t)
|
||||
|
||||
log.SetLevel(log.DebugLevel)
|
||||
|
||||
tjfi := TestJobFileInfo{
|
||||
workdir: workdir,
|
||||
workflowPath: "mask-values",
|
||||
eventName: "push",
|
||||
errorMessage: "",
|
||||
platforms: platforms,
|
||||
}
|
||||
|
||||
logger := &maskJobLoggerFactory{}
|
||||
tjfi.runTest(WithJobLoggerFactory(common.WithLogger(context.Background(), logger.WithJobLogger()), logger), t, &Config{})
|
||||
output := logger.Output.String()
|
||||
|
||||
assertNoSecret(output, "secret value")
|
||||
assertNoSecret(output, "YWJjCg==")
|
||||
}
|
||||
|
||||
func TestRunEventSecrets(t *testing.T) {
|
||||
requireDocker(t)
|
||||
t.Parallel()
|
||||
workflowPath := "secrets"
|
||||
|
||||
tjfi := TestJobFileInfo{
|
||||
workdir: workdir,
|
||||
workflowPath: workflowPath,
|
||||
eventName: "push",
|
||||
errorMessage: "",
|
||||
platforms: platforms,
|
||||
}
|
||||
|
||||
env, err := godotenv.Read(filepath.Join(workdir, workflowPath, ".env"))
|
||||
assert.NoError(t, err, "Failed to read .env") //nolint:testifylint // pre-existing issue from nektos/act
|
||||
secrets, _ := godotenv.Read(filepath.Join(workdir, workflowPath, ".secrets"))
|
||||
assert.NoError(t, err, "Failed to read .secrets") //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
tjfi.runTest(context.Background(), t, &Config{Secrets: secrets, Env: env})
|
||||
}
|
||||
|
||||
func TestRunWithService(t *testing.T) {
|
||||
requireDocker(t)
|
||||
|
||||
log.SetLevel(log.DebugLevel)
|
||||
ctx := context.Background()
|
||||
|
||||
platforms := map[string]string{
|
||||
"ubuntu-latest": "node:24-bookworm-slim",
|
||||
}
|
||||
|
||||
workflowPath := "services"
|
||||
eventName := "push"
|
||||
|
||||
workdir, err := filepath.Abs("testdata")
|
||||
assert.NoError(t, err, workflowPath) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
runnerConfig := &Config{
|
||||
Workdir: workdir,
|
||||
EventName: eventName,
|
||||
Platforms: platforms,
|
||||
ReuseContainers: false,
|
||||
ContainerMaxLifetime: time.Hour, // otherwise the job container is `sleep 0` and exits at once
|
||||
}
|
||||
runner, err := New(runnerConfig)
|
||||
assert.NoError(t, err, workflowPath) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
planner, err := model.NewWorkflowPlanner("testdata/"+workflowPath, true)
|
||||
assert.NoError(t, err, workflowPath) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
plan, err := planner.PlanEvent(eventName)
|
||||
assert.NoError(t, err, workflowPath) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
err = runner.NewPlanExecutor(plan)(ctx)
|
||||
assert.NoError(t, err, workflowPath)
|
||||
}
|
||||
|
||||
func TestRunActionInputs(t *testing.T) {
|
||||
t.Parallel()
|
||||
requireDocker(t)
|
||||
workflowPath := "input-from-cli"
|
||||
|
||||
tjfi := TestJobFileInfo{
|
||||
workdir: workdir,
|
||||
workflowPath: workflowPath,
|
||||
eventName: "workflow_dispatch",
|
||||
errorMessage: "",
|
||||
platforms: platforms,
|
||||
}
|
||||
|
||||
inputs := map[string]string{
|
||||
"SOME_INPUT": "input",
|
||||
}
|
||||
|
||||
tjfi.runTest(context.Background(), t, &Config{Inputs: inputs})
|
||||
}
|
||||
|
||||
func TestRunEventPullRequest(t *testing.T) {
|
||||
t.Parallel()
|
||||
requireDocker(t)
|
||||
|
||||
workflowPath := "pull-request"
|
||||
|
||||
tjfi := TestJobFileInfo{
|
||||
workdir: workdir,
|
||||
workflowPath: workflowPath,
|
||||
eventName: "pull_request",
|
||||
errorMessage: "",
|
||||
platforms: platforms,
|
||||
}
|
||||
|
||||
tjfi.runTest(context.Background(), t, &Config{EventPath: filepath.Join(workdir, workflowPath, "event.json")})
|
||||
}
|
||||
|
||||
func TestRunMatrixWithUserDefinedInclusions(t *testing.T) {
|
||||
t.Parallel()
|
||||
requireDocker(t)
|
||||
workflowPath := "matrix-with-user-inclusions"
|
||||
|
||||
tjfi := TestJobFileInfo{
|
||||
workdir: workdir,
|
||||
workflowPath: workflowPath,
|
||||
eventName: "push",
|
||||
errorMessage: "",
|
||||
platforms: platforms,
|
||||
}
|
||||
|
||||
matrix := map[string]map[string]bool{
|
||||
"node": {
|
||||
"8": true,
|
||||
"8.x": true,
|
||||
},
|
||||
"os": {
|
||||
"ubuntu-18.04": true,
|
||||
},
|
||||
}
|
||||
|
||||
tjfi.runTest(context.Background(), t, &Config{Matrix: matrix})
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2020 The nektos/act Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package runner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
maps0 "maps"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.com/gitea/runner/internal/act/common"
|
||||
"gitea.com/gitea/runner/internal/act/container"
|
||||
|
||||
"gitea.dev/actionslib/pkg/exprparser"
|
||||
"gitea.dev/actionslib/pkg/model"
|
||||
)
|
||||
|
||||
type step interface {
|
||||
pre() common.Executor
|
||||
main() common.Executor
|
||||
post() common.Executor
|
||||
|
||||
getRunContext() *RunContext
|
||||
getGithubContext(ctx context.Context) *model.GithubContext
|
||||
getStepModel() *model.Step
|
||||
getEnv() *map[string]string
|
||||
getIfExpression(context context.Context, stage stepStage) string
|
||||
}
|
||||
|
||||
type stepStage int
|
||||
|
||||
const (
|
||||
stepStagePre stepStage = iota
|
||||
stepStageMain
|
||||
stepStagePost
|
||||
)
|
||||
|
||||
// Controls how many symlinks are resolved for local and remote Actions
|
||||
const maxSymlinkDepth = 10
|
||||
|
||||
func (s stepStage) String() string {
|
||||
switch s {
|
||||
case stepStagePre:
|
||||
return "Pre"
|
||||
case stepStageMain:
|
||||
return "Main"
|
||||
case stepStagePost:
|
||||
return "Post"
|
||||
}
|
||||
return "Unknown"
|
||||
}
|
||||
|
||||
func processRunnerEnvFileCommand(ctx context.Context, fileName string, rc *RunContext, setter func(context.Context, map[string]string, string)) error {
|
||||
env := map[string]string{}
|
||||
err := rc.JobContainer.UpdateFromEnv(path.Join(rc.JobContainer.GetActPath(), fileName), &env)(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for k, v := range env {
|
||||
setter(ctx, map[string]string{"name": k}, v)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runStepExecutor(step step, stage stepStage, executor common.Executor) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
logger := common.Logger(ctx)
|
||||
rc := step.getRunContext()
|
||||
stepModel := step.getStepModel()
|
||||
|
||||
ifExpression := step.getIfExpression(ctx, stage)
|
||||
rc.CurrentStep = stepModel.ID
|
||||
|
||||
stepResult := &model.StepResult{
|
||||
Outcome: model.StepStatusSuccess,
|
||||
Conclusion: model.StepStatusSuccess,
|
||||
Outputs: make(map[string]string),
|
||||
}
|
||||
if stage == stepStageMain {
|
||||
rc.StepResults[rc.CurrentStep] = stepResult
|
||||
}
|
||||
|
||||
err := setupEnv(ctx, step)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
runStep, err := isStepEnabled(ctx, ifExpression, step, stage)
|
||||
if err != nil {
|
||||
stepResult.Conclusion = model.StepStatusFailure
|
||||
stepResult.Outcome = model.StepStatusFailure
|
||||
return err
|
||||
}
|
||||
|
||||
if !runStep {
|
||||
stepResult.Conclusion = model.StepStatusSkipped
|
||||
stepResult.Outcome = model.StepStatusSkipped
|
||||
logger.WithField("stepResult", stepResult.Outcome).Debugf("Skipping step '%s' due to '%s'", stepModel, ifExpression)
|
||||
return nil
|
||||
}
|
||||
|
||||
stepString := rc.ExprEval.Interpolate(ctx, stepModel.String())
|
||||
if strings.Contains(stepString, "::add-mask::") {
|
||||
stepString = "add-mask command"
|
||||
}
|
||||
if stage == stepStageMain {
|
||||
// Main steps print their own raw "Run <title>" header, so this line is redundant and
|
||||
// only leaks into the "Set up job" section for the first step; keep it as a debug trace.
|
||||
logger.Debugf("Run %s %s", stage, stepString)
|
||||
} else {
|
||||
logger.Infof("Run %s %s", stage, stepString)
|
||||
}
|
||||
|
||||
// Prepare and clean Runner File Commands
|
||||
actPath := rc.JobContainer.GetActPath()
|
||||
|
||||
outputFileCommand := path.Join("workflow", "outputcmd.txt")
|
||||
(*step.getEnv())["GITHUB_OUTPUT"] = path.Join(actPath, outputFileCommand)
|
||||
|
||||
stateFileCommand := path.Join("workflow", "statecmd.txt")
|
||||
(*step.getEnv())["GITHUB_STATE"] = path.Join(actPath, stateFileCommand)
|
||||
|
||||
pathFileCommand := path.Join("workflow", "pathcmd.txt")
|
||||
(*step.getEnv())["GITHUB_PATH"] = path.Join(actPath, pathFileCommand)
|
||||
|
||||
envFileCommand := path.Join("workflow", "envs.txt")
|
||||
(*step.getEnv())["GITHUB_ENV"] = path.Join(actPath, envFileCommand)
|
||||
|
||||
// Per-step summary file. Composite sub-steps share the outer job step's index
|
||||
// via the Parent chain so all writes from within a composite action accumulate
|
||||
// in the same file and upload under the outer step_index.
|
||||
topRC := rc.topLevelRunContext()
|
||||
stepSummaryIndex := topRC.CurrentStepIndex
|
||||
summaryFileCommand := path.Join("workflow", "step-summary-"+strconv.Itoa(stepSummaryIndex)+".md")
|
||||
(*step.getEnv())["GITHUB_STEP_SUMMARY"] = path.Join(actPath, summaryFileCommand)
|
||||
|
||||
{
|
||||
// For Gitea
|
||||
(*step.getEnv())["GITEA_OUTPUT"] = (*step.getEnv())["GITHUB_OUTPUT"]
|
||||
(*step.getEnv())["GITEA_STATE"] = (*step.getEnv())["GITHUB_STATE"]
|
||||
(*step.getEnv())["GITEA_PATH"] = (*step.getEnv())["GITHUB_PATH"]
|
||||
(*step.getEnv())["GITEA_ENV"] = (*step.getEnv())["GITHUB_ENV"]
|
||||
(*step.getEnv())["GITEA_STEP_SUMMARY"] = (*step.getEnv())["GITHUB_STEP_SUMMARY"]
|
||||
}
|
||||
|
||||
// Reset the per-phase file-command files. GITHUB_STEP_SUMMARY is intentionally
|
||||
// excluded here and initialized below at most once per step so writes from later
|
||||
// phases and from composite sub-steps accumulate instead of being truncated.
|
||||
files := []*container.FileEntry{
|
||||
{Name: outputFileCommand, Mode: 0o666},
|
||||
{Name: stateFileCommand, Mode: 0o666},
|
||||
{Name: pathFileCommand, Mode: 0o666},
|
||||
{Name: envFileCommand, Mode: 0o666},
|
||||
}
|
||||
if topRC.summaryFileInitialized == nil {
|
||||
topRC.summaryFileInitialized = map[int]bool{}
|
||||
}
|
||||
if !topRC.summaryFileInitialized[stepSummaryIndex] {
|
||||
files = append(files, &container.FileEntry{Name: summaryFileCommand, Mode: 0o666})
|
||||
topRC.summaryFileInitialized[stepSummaryIndex] = true
|
||||
}
|
||||
_ = rc.JobContainer.Copy(actPath, files...)(ctx)
|
||||
|
||||
// The command handler needs the step's env to judge ACTIONS_ALLOW_UNSECURE_COMMANDS.
|
||||
// Cloned: the step executor keeps writing to its own env map after this point, on a
|
||||
// different goroutine from the command handler that reads it.
|
||||
rc.setCurrentStepEnv(maps0.Clone(*step.getEnv()))
|
||||
defer rc.setCurrentStepEnv(nil)
|
||||
_ = rc.takeUnsecureCommandError() // a refusal from before any step belongs to no step
|
||||
|
||||
timeoutctx, cancelTimeOut := evaluateStepTimeout(ctx, rc.ExprEval, stepModel)
|
||||
defer cancelTimeOut()
|
||||
err = executor(timeoutctx)
|
||||
// Always take it, so the job-scoped error cannot leak onto a later step. A refusal
|
||||
// fails the step as it does on GitHub, but the executor's own error wins.
|
||||
insecureErr := rc.takeUnsecureCommandError()
|
||||
if err == nil {
|
||||
err = insecureErr
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
logger.WithField("stepResult", stepResult.Outcome).Infof("Success - %s %s", stage, stepString)
|
||||
} else {
|
||||
stepResult.Outcome = model.StepStatusFailure
|
||||
|
||||
continueOnError, parseErr := isContinueOnError(ctx, stepModel.RawContinueOnError, step, stage)
|
||||
if parseErr != nil {
|
||||
stepResult.Conclusion = model.StepStatusFailure
|
||||
return parseErr
|
||||
}
|
||||
|
||||
if continueOnError {
|
||||
logger.Errorf("##[error]%s", EscapeCommandData(err.Error()))
|
||||
logger.Infof("Failed but continue next step")
|
||||
err = nil
|
||||
stepResult.Conclusion = model.StepStatusSuccess
|
||||
} else {
|
||||
stepResult.Conclusion = model.StepStatusFailure
|
||||
}
|
||||
|
||||
// Infof: Errorf entries are promoted to the user log by the reporter,
|
||||
// which would duplicate the ##[error] annotation emitted elsewhere.
|
||||
logger.WithField("stepResult", stepResult.Outcome).Infof("Failure - %s %s", stage, stepString)
|
||||
}
|
||||
// Process Runner File Commands
|
||||
orgerr := err
|
||||
err = processRunnerEnvFileCommand(ctx, envFileCommand, rc, rc.setEnv)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = processRunnerEnvFileCommand(ctx, stateFileCommand, rc, rc.saveState)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = processRunnerEnvFileCommand(ctx, outputFileCommand, rc, rc.setOutput)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = rc.UpdateExtraPath(ctx, path.Join(actPath, pathFileCommand))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if orgerr != nil {
|
||||
return orgerr
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
func evaluateStepTimeout(ctx context.Context, exprEval ExpressionEvaluator, stepModel *model.Step) (context.Context, context.CancelFunc) {
|
||||
timeout := exprEval.Interpolate(ctx, stepModel.TimeoutMinutes)
|
||||
if timeout != "" {
|
||||
if timeOutMinutes, err := strconv.ParseInt(timeout, 10, 64); err == nil {
|
||||
return context.WithTimeout(ctx, time.Duration(timeOutMinutes)*time.Minute)
|
||||
}
|
||||
}
|
||||
return ctx, func() {}
|
||||
}
|
||||
|
||||
func setupEnv(ctx context.Context, step step) error { //nolint:unparam // pre-existing issue from nektos/act
|
||||
rc := step.getRunContext()
|
||||
|
||||
mergeEnv(ctx, step)
|
||||
// merge step env last, since it should not be overwritten
|
||||
mergeIntoMap(step, step.getEnv(), step.getStepModel().GetEnv())
|
||||
|
||||
exprEval := rc.NewExpressionEvaluator(ctx)
|
||||
for k, v := range *step.getEnv() {
|
||||
if !strings.HasPrefix(k, "INPUT_") {
|
||||
(*step.getEnv())[k] = exprEval.Interpolate(ctx, v)
|
||||
}
|
||||
}
|
||||
// after we have an evaluated step context, update the expressions evaluator with a new env context
|
||||
// you can use step level env in the with property of a uses construct
|
||||
exprEval = rc.NewExpressionEvaluatorWithEnv(ctx, *step.getEnv())
|
||||
for k, v := range *step.getEnv() {
|
||||
if strings.HasPrefix(k, "INPUT_") {
|
||||
(*step.getEnv())[k] = exprEval.Interpolate(ctx, v)
|
||||
}
|
||||
}
|
||||
|
||||
// For Gitea, reduce log noise
|
||||
// common.Logger(ctx).Debugf("setupEnv => %v", *step.getEnv())
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func mergeEnv(ctx context.Context, step step) {
|
||||
env := step.getEnv()
|
||||
rc := step.getRunContext()
|
||||
job := rc.Run.Job()
|
||||
|
||||
c := job.Container()
|
||||
if c != nil {
|
||||
mergeIntoMap(step, env, rc.GetEnv(), c.Env)
|
||||
} else {
|
||||
mergeIntoMap(step, env, rc.GetEnv())
|
||||
}
|
||||
|
||||
rc.withGithubEnv(ctx, step.getGithubContext(ctx), *env)
|
||||
}
|
||||
|
||||
func isStepEnabled(ctx context.Context, expr string, step step, stage stepStage) (bool, error) {
|
||||
rc := step.getRunContext()
|
||||
|
||||
var defaultStatusCheck exprparser.DefaultStatusCheck
|
||||
if stage == stepStagePost {
|
||||
defaultStatusCheck = exprparser.DefaultStatusCheckAlways
|
||||
} else {
|
||||
defaultStatusCheck = exprparser.DefaultStatusCheckSuccess
|
||||
}
|
||||
|
||||
runStep, err := EvalBool(ctx, rc.NewStepExpressionEvaluator(ctx, step), expr, defaultStatusCheck)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("if-expression %q evaluation failed: %s", expr, err)
|
||||
}
|
||||
|
||||
return runStep, nil
|
||||
}
|
||||
|
||||
func isContinueOnError(ctx context.Context, expr string, step step, _ stepStage) (bool, error) {
|
||||
// https://github.com/github/docs/blob/3ae84420bd10997bb5f35f629ebb7160fe776eae/content/actions/reference/workflow-syntax-for-github-actions.md?plain=true#L962
|
||||
if len(strings.TrimSpace(expr)) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
rc := step.getRunContext()
|
||||
|
||||
continueOnError, err := EvalBool(ctx, rc.NewStepExpressionEvaluator(ctx, step), expr, exprparser.DefaultStatusCheckNone)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("continue-on-error expression %q evaluation failed: %s", expr, err)
|
||||
}
|
||||
|
||||
return continueOnError, nil
|
||||
}
|
||||
|
||||
func mergeIntoMap(step step, target *map[string]string, maps ...map[string]string) {
|
||||
if rc := step.getRunContext(); rc != nil && rc.JobContainer != nil && rc.JobContainer.IsEnvironmentCaseInsensitive() {
|
||||
mergeIntoMapCaseInsensitive(*target, maps...)
|
||||
} else {
|
||||
mergeIntoMapCaseSensitive(*target, maps...)
|
||||
}
|
||||
}
|
||||
|
||||
func mergeIntoMapCaseSensitive(target map[string]string, maps ...map[string]string) {
|
||||
for _, m := range maps {
|
||||
maps0.Copy(target, m)
|
||||
}
|
||||
}
|
||||
|
||||
func mergeIntoMapCaseInsensitive(target map[string]string, maps ...map[string]string) {
|
||||
foldKeys := make(map[string]string, len(target))
|
||||
for k := range target {
|
||||
foldKeys[strings.ToLower(k)] = k
|
||||
}
|
||||
toKey := func(s string) string {
|
||||
foldKey := strings.ToLower(s)
|
||||
if k, ok := foldKeys[foldKey]; ok {
|
||||
return k
|
||||
}
|
||||
foldKeys[strings.ToLower(foldKey)] = s
|
||||
return s
|
||||
}
|
||||
for _, m := range maps {
|
||||
for k, v := range m {
|
||||
target[toKey(k)] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func symlinkJoin(filename, sym, parent string) (string, error) {
|
||||
dir := path.Dir(filename)
|
||||
dest := path.Join(dir, sym)
|
||||
prefix := path.Clean(parent) + "/"
|
||||
if strings.HasPrefix(dest, prefix) || prefix == "./" {
|
||||
return dest, nil
|
||||
}
|
||||
return "", fmt.Errorf("symlink tries to access file '%s' outside of '%s'", strings.ReplaceAll(dest, "'", "''"), strings.ReplaceAll(parent, "'", "''"))
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2022 The nektos/act Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package runner
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
|
||||
"gitea.com/gitea/runner/internal/act/common"
|
||||
|
||||
"gitea.dev/actionslib/pkg/model"
|
||||
)
|
||||
|
||||
type stepActionLocal struct {
|
||||
Step *model.Step
|
||||
RunContext *RunContext
|
||||
compositeRunContext *RunContext
|
||||
compositeSteps *compositeSteps
|
||||
runAction runAction
|
||||
readAction readAction
|
||||
env map[string]string
|
||||
action *model.Action
|
||||
}
|
||||
|
||||
func (sal *stepActionLocal) pre() common.Executor {
|
||||
sal.env = map[string]string{}
|
||||
|
||||
return func(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (sal *stepActionLocal) main() common.Executor {
|
||||
return runStepExecutor(sal, stepStageMain, func(ctx context.Context) error {
|
||||
if common.Dryrun(ctx) {
|
||||
return nil
|
||||
}
|
||||
|
||||
printRunActionHeader(ctx, sal.Step, sal.env, sal.getRunContext())
|
||||
rawLogger := common.Logger(ctx).WithField(rawOutputField, true)
|
||||
defer rawLogger.Infof("::endgroup::")
|
||||
|
||||
actionDir := filepath.Join(sal.getRunContext().Config.Workdir, sal.Step.Uses)
|
||||
|
||||
localReader := func(ctx context.Context) actionYamlReader {
|
||||
_, cpath := getContainerActionPaths(sal.Step, path.Join(actionDir, ""), sal.RunContext)
|
||||
return func(filename string) (io.Reader, io.Closer, error) {
|
||||
spath := path.Join(cpath, filename)
|
||||
for range maxSymlinkDepth {
|
||||
tars, err := sal.RunContext.JobContainer.GetContainerArchive(ctx, spath)
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return nil, nil, err
|
||||
} else if err != nil {
|
||||
return nil, nil, fs.ErrNotExist
|
||||
}
|
||||
treader := tar.NewReader(tars)
|
||||
header, err := treader.Next()
|
||||
if errors.Is(err, io.EOF) {
|
||||
return nil, nil, os.ErrNotExist
|
||||
} else if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if header.FileInfo().Mode()&os.ModeSymlink == os.ModeSymlink {
|
||||
spath, err = symlinkJoin(spath, header.Linkname, cpath)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
} else {
|
||||
return treader, tars, nil
|
||||
}
|
||||
}
|
||||
return nil, nil, fmt.Errorf("max depth %d of symlinks exceeded while reading %s", maxSymlinkDepth, spath)
|
||||
}
|
||||
}
|
||||
|
||||
actionModel, err := sal.readAction(ctx, sal.Step, actionDir, "", localReader(ctx), os.WriteFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sal.action = actionModel
|
||||
|
||||
return sal.runAction(sal, actionDir, nil)(ctx)
|
||||
})
|
||||
}
|
||||
|
||||
func (sal *stepActionLocal) post() common.Executor {
|
||||
return runStepExecutor(sal, stepStagePost, runPostStep(sal)).If(hasPostStep(sal)).If(shouldRunPostStep(sal))
|
||||
}
|
||||
|
||||
func (sal *stepActionLocal) getRunContext() *RunContext {
|
||||
return sal.RunContext
|
||||
}
|
||||
|
||||
func (sal *stepActionLocal) getGithubContext(ctx context.Context) *model.GithubContext {
|
||||
return sal.getRunContext().getGithubContext(ctx)
|
||||
}
|
||||
|
||||
func (sal *stepActionLocal) getStepModel() *model.Step {
|
||||
return sal.Step
|
||||
}
|
||||
|
||||
func (sal *stepActionLocal) getEnv() *map[string]string {
|
||||
return &sal.env
|
||||
}
|
||||
|
||||
func (sal *stepActionLocal) getIfExpression(_ context.Context, stage stepStage) string {
|
||||
switch stage {
|
||||
case stepStageMain:
|
||||
return sal.Step.If.Value
|
||||
case stepStagePost:
|
||||
return sal.action.Runs.PostIf
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (sal *stepActionLocal) getActionModel() *model.Action {
|
||||
return sal.action
|
||||
}
|
||||
|
||||
func (sal *stepActionLocal) getCompositeRunContext(ctx context.Context) *RunContext {
|
||||
if sal.compositeRunContext == nil {
|
||||
actionDir := filepath.Join(sal.RunContext.Config.Workdir, sal.Step.Uses)
|
||||
_, containerActionDir := getContainerActionPaths(sal.getStepModel(), actionDir, sal.RunContext)
|
||||
|
||||
sal.compositeRunContext = newCompositeRunContext(ctx, sal.RunContext, sal, containerActionDir)
|
||||
sal.compositeSteps = sal.compositeRunContext.compositeExecutor(sal.action)
|
||||
}
|
||||
return sal.compositeRunContext
|
||||
}
|
||||
|
||||
func (sal *stepActionLocal) getCompositeSteps() *compositeSteps {
|
||||
return sal.compositeSteps
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2022 The nektos/act Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package runner
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.com/gitea/runner/internal/act/common"
|
||||
|
||||
"gitea.dev/actionslib/pkg/model"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
type stepActionLocalMocks struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (salm *stepActionLocalMocks) runAction(step actionStep, actionDir string, remoteAction *remoteAction) common.Executor {
|
||||
args := salm.Called(step, actionDir, remoteAction)
|
||||
return args.Get(0).(func(context.Context) error)
|
||||
}
|
||||
|
||||
func (salm *stepActionLocalMocks) readAction(_ context.Context, step *model.Step, actionDir, actionPath string, readFile actionYamlReader, writeFile fileWriter) (*model.Action, error) {
|
||||
args := salm.Called(step, actionDir, actionPath, readFile, writeFile)
|
||||
return args.Get(0).(*model.Action), args.Error(1)
|
||||
}
|
||||
|
||||
func TestStepActionLocalTest(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
cm := &containerMock{}
|
||||
salm := &stepActionLocalMocks{}
|
||||
|
||||
sal := &stepActionLocal{
|
||||
readAction: salm.readAction,
|
||||
runAction: salm.runAction,
|
||||
RunContext: &RunContext{
|
||||
StepResults: map[string]*model.StepResult{},
|
||||
ExprEval: &expressionEvaluator{},
|
||||
Config: &Config{
|
||||
Workdir: "/tmp",
|
||||
},
|
||||
Run: &model.Run{
|
||||
JobID: "1",
|
||||
Workflow: &model.Workflow{
|
||||
Jobs: map[string]*model.Job{
|
||||
"1": {
|
||||
Defaults: model.Defaults{
|
||||
Run: model.RunDefaults{
|
||||
Shell: "bash",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
JobContainer: cm,
|
||||
},
|
||||
Step: &model.Step{
|
||||
ID: "1",
|
||||
Uses: "./path/to/action",
|
||||
},
|
||||
}
|
||||
|
||||
salm.On("readAction", sal.Step, filepath.Clean("/tmp/path/to/action"), "", mock.Anything, mock.Anything).
|
||||
Return(&model.Action{}, nil)
|
||||
|
||||
cm.On("Copy", "/var/run/act", mock.AnythingOfType("[]*container.FileEntry")).Return(func(ctx context.Context) error {
|
||||
return nil
|
||||
})
|
||||
|
||||
cm.On("UpdateFromEnv", "/var/run/act/workflow/envs.txt", mock.AnythingOfType("*map[string]string")).Return(func(ctx context.Context) error {
|
||||
return nil
|
||||
})
|
||||
|
||||
cm.On("UpdateFromEnv", "/var/run/act/workflow/statecmd.txt", mock.AnythingOfType("*map[string]string")).Return(func(ctx context.Context) error {
|
||||
return nil
|
||||
})
|
||||
|
||||
cm.On("UpdateFromEnv", "/var/run/act/workflow/outputcmd.txt", mock.AnythingOfType("*map[string]string")).Return(func(ctx context.Context) error {
|
||||
return nil
|
||||
})
|
||||
|
||||
cm.On("GetContainerArchive", ctx, "/var/run/act/workflow/pathcmd.txt").Return(io.NopCloser(&bytes.Buffer{}), nil)
|
||||
|
||||
salm.On("runAction", sal, filepath.Clean("/tmp/path/to/action"), (*remoteAction)(nil)).Return(func(ctx context.Context) error {
|
||||
return nil
|
||||
})
|
||||
|
||||
err := sal.pre()(ctx)
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
err = sal.main()(ctx)
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
cm.AssertExpectations(t)
|
||||
salm.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestStepActionLocalPost(t *testing.T) {
|
||||
table := []struct {
|
||||
name string
|
||||
stepModel *model.Step
|
||||
actionModel *model.Action
|
||||
initialStepResults map[string]*model.StepResult
|
||||
err error
|
||||
mocks struct {
|
||||
env bool
|
||||
exec bool
|
||||
}
|
||||
}{
|
||||
{
|
||||
name: "main-success",
|
||||
stepModel: &model.Step{
|
||||
ID: "step",
|
||||
Uses: "./local/action",
|
||||
},
|
||||
actionModel: &model.Action{
|
||||
Runs: model.ActionRuns{
|
||||
Using: "node16",
|
||||
Post: "post.js",
|
||||
PostIf: "always()",
|
||||
},
|
||||
},
|
||||
initialStepResults: map[string]*model.StepResult{
|
||||
"step": {
|
||||
Conclusion: model.StepStatusSuccess,
|
||||
Outcome: model.StepStatusSuccess,
|
||||
Outputs: map[string]string{},
|
||||
},
|
||||
},
|
||||
mocks: struct {
|
||||
env bool
|
||||
exec bool
|
||||
}{
|
||||
env: true,
|
||||
exec: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "main-failed",
|
||||
stepModel: &model.Step{
|
||||
ID: "step",
|
||||
Uses: "./local/action",
|
||||
},
|
||||
actionModel: &model.Action{
|
||||
Runs: model.ActionRuns{
|
||||
Using: "node16",
|
||||
Post: "post.js",
|
||||
PostIf: "always()",
|
||||
},
|
||||
},
|
||||
initialStepResults: map[string]*model.StepResult{
|
||||
"step": {
|
||||
Conclusion: model.StepStatusFailure,
|
||||
Outcome: model.StepStatusFailure,
|
||||
Outputs: map[string]string{},
|
||||
},
|
||||
},
|
||||
mocks: struct {
|
||||
env bool
|
||||
exec bool
|
||||
}{
|
||||
env: true,
|
||||
exec: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "skip-if-failed",
|
||||
stepModel: &model.Step{
|
||||
ID: "step",
|
||||
Uses: "./local/action",
|
||||
},
|
||||
actionModel: &model.Action{
|
||||
Runs: model.ActionRuns{
|
||||
Using: "node16",
|
||||
Post: "post.js",
|
||||
PostIf: "success()",
|
||||
},
|
||||
},
|
||||
initialStepResults: map[string]*model.StepResult{
|
||||
"step": {
|
||||
Conclusion: model.StepStatusFailure,
|
||||
Outcome: model.StepStatusFailure,
|
||||
Outputs: map[string]string{},
|
||||
},
|
||||
},
|
||||
mocks: struct {
|
||||
env bool
|
||||
exec bool
|
||||
}{
|
||||
env: false,
|
||||
exec: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "skip-if-main-skipped",
|
||||
stepModel: &model.Step{
|
||||
ID: "step",
|
||||
If: yaml.Node{Value: "failure()"},
|
||||
Uses: "./local/action",
|
||||
},
|
||||
actionModel: &model.Action{
|
||||
Runs: model.ActionRuns{
|
||||
Using: "node16",
|
||||
Post: "post.js",
|
||||
PostIf: "always()",
|
||||
},
|
||||
},
|
||||
initialStepResults: map[string]*model.StepResult{
|
||||
"step": {
|
||||
Conclusion: model.StepStatusSkipped,
|
||||
Outcome: model.StepStatusSkipped,
|
||||
Outputs: map[string]string{},
|
||||
},
|
||||
},
|
||||
mocks: struct {
|
||||
env bool
|
||||
exec bool
|
||||
}{
|
||||
env: false,
|
||||
exec: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range table {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
cm := &containerMock{}
|
||||
|
||||
sal := &stepActionLocal{
|
||||
env: map[string]string{},
|
||||
RunContext: &RunContext{
|
||||
Config: &Config{
|
||||
GitHubInstance: "https://github.com",
|
||||
},
|
||||
JobContainer: cm,
|
||||
Run: &model.Run{
|
||||
JobID: "1",
|
||||
Workflow: &model.Workflow{
|
||||
Jobs: map[string]*model.Job{
|
||||
"1": {},
|
||||
},
|
||||
},
|
||||
},
|
||||
StepResults: tt.initialStepResults,
|
||||
},
|
||||
Step: tt.stepModel,
|
||||
action: tt.actionModel,
|
||||
}
|
||||
sal.RunContext.ExprEval = sal.RunContext.NewExpressionEvaluator(ctx)
|
||||
|
||||
if tt.mocks.exec {
|
||||
suffixMatcher := func(suffix string) any {
|
||||
return mock.MatchedBy(func(array []string) bool {
|
||||
return strings.HasSuffix(array[1], suffix)
|
||||
})
|
||||
}
|
||||
cm.On("Exec", suffixMatcher("runner/local/action/post.js"), sal.env, "", "").Return(func(ctx context.Context) error { return tt.err })
|
||||
|
||||
cm.On("Copy", "/var/run/act", mock.AnythingOfType("[]*container.FileEntry")).Return(func(ctx context.Context) error {
|
||||
return nil
|
||||
})
|
||||
|
||||
cm.On("UpdateFromEnv", "/var/run/act/workflow/envs.txt", mock.AnythingOfType("*map[string]string")).Return(func(ctx context.Context) error {
|
||||
return nil
|
||||
})
|
||||
|
||||
cm.On("UpdateFromEnv", "/var/run/act/workflow/statecmd.txt", mock.AnythingOfType("*map[string]string")).Return(func(ctx context.Context) error {
|
||||
return nil
|
||||
})
|
||||
|
||||
cm.On("UpdateFromEnv", "/var/run/act/workflow/outputcmd.txt", mock.AnythingOfType("*map[string]string")).Return(func(ctx context.Context) error {
|
||||
return nil
|
||||
})
|
||||
|
||||
cm.On("GetContainerArchive", ctx, "/var/run/act/workflow/pathcmd.txt").Return(io.NopCloser(&bytes.Buffer{}), nil)
|
||||
}
|
||||
|
||||
err := sal.post()(ctx)
|
||||
|
||||
assert.Equal(t, tt.err, err)
|
||||
assert.Equal(t, sal.RunContext.StepResults["post-step"], (*model.StepResult)(nil)) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
cm.AssertExpectations(t)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,474 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2022 The nektos/act Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package runner
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"gitea.com/gitea/runner/internal/act/common"
|
||||
"gitea.com/gitea/runner/internal/act/common/git"
|
||||
|
||||
"gitea.dev/actionslib/pkg/model"
|
||||
gogit "github.com/go-git/go-git/v5"
|
||||
)
|
||||
|
||||
type stepActionRemote struct {
|
||||
Step *model.Step
|
||||
RunContext *RunContext
|
||||
compositeRunContext *RunContext
|
||||
compositeSteps *compositeSteps
|
||||
readAction readAction
|
||||
runAction runAction
|
||||
action *model.Action
|
||||
env map[string]string
|
||||
remoteAction *remoteAction
|
||||
cacheDir string
|
||||
resolvedSha string
|
||||
}
|
||||
|
||||
var stepActionRemoteNewCloneExecutor = git.NewGitCloneExecutor
|
||||
|
||||
// selfRepoPrefix introduces a self-repository reference: the action lives in the repo holding the file that wrote the `uses:`.
|
||||
const selfRepoPrefix = "$/"
|
||||
|
||||
func (sar *stepActionRemote) prepareActionExecutor() common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
if sar.remoteAction != nil && sar.action != nil {
|
||||
// we are already good to run
|
||||
return nil
|
||||
}
|
||||
|
||||
// For gitea:
|
||||
// Since actions can specify the download source via a url prefix.
|
||||
// The prefix may contain some sensitive information that needs to be stored in secrets,
|
||||
// so we need to interpolate the expression value for uses first.
|
||||
sar.Step.Uses = sar.RunContext.NewExpressionEvaluator(ctx).Interpolate(ctx, sar.Step.Uses)
|
||||
|
||||
github := sar.getGithubContext(ctx) // read before remoteAction is set, so `$/` resolves against the enclosing action
|
||||
if strings.HasPrefix(sar.Step.Uses, selfRepoPrefix) {
|
||||
sar.remoteAction = newSelfRepoAction(sar.Step.Uses, github)
|
||||
} else {
|
||||
sar.remoteAction = newRemoteAction(sar.Step.Uses)
|
||||
}
|
||||
if sar.remoteAction == nil {
|
||||
return fmt.Errorf("Expected format {org}/{repo}[/path]@ref or %s{path}. Actual '%s' Input string was not in a correct format", selfRepoPrefix, sar.Step.Uses)
|
||||
}
|
||||
|
||||
if sar.remoteAction.IsCheckout() && isLocalCheckout(github, sar.Step) && !sar.RunContext.Config.NoSkipCheckout {
|
||||
common.Logger(ctx).Debugf("Skipping local actions/checkout because workdir was already copied")
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, action := range sar.RunContext.Config.ReplaceGheActionWithGithubCom {
|
||||
if strings.EqualFold(fmt.Sprintf("%s/%s", sar.remoteAction.Org, sar.remoteAction.Repo), action) {
|
||||
sar.remoteAction.URL = "https://github.com"
|
||||
github.Token = sar.RunContext.Config.ReplaceGheActionTokenWithGithubCom
|
||||
}
|
||||
}
|
||||
// Actions served from the action cache are read out of a git object store rather than a
|
||||
// directory, so they never reach the bundle patch below and keep to the v1 cache API.
|
||||
if sar.RunContext.Config.ActionCache != nil {
|
||||
cache := sar.RunContext.Config.ActionCache
|
||||
|
||||
var err error
|
||||
sar.cacheDir = fmt.Sprintf("%s/%s", sar.remoteAction.Org, sar.remoteAction.Repo)
|
||||
repoURL := sar.remoteAction.URL + "/" + sar.cacheDir
|
||||
repoRef := sar.remoteAction.Ref
|
||||
sar.resolvedSha, err = cache.Fetch(ctx, sar.cacheDir, repoURL, repoRef, github.Token)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to fetch \"%s\" version \"%s\": %w", repoURL, repoRef, err)
|
||||
}
|
||||
|
||||
remoteReader := func(ctx context.Context) actionYamlReader {
|
||||
return func(filename string) (io.Reader, io.Closer, error) {
|
||||
spath := path.Join(sar.remoteAction.Path, filename)
|
||||
for range maxSymlinkDepth {
|
||||
tars, err := cache.GetTarArchive(ctx, sar.cacheDir, sar.resolvedSha, spath)
|
||||
if err != nil {
|
||||
return nil, nil, os.ErrNotExist
|
||||
}
|
||||
treader := tar.NewReader(tars)
|
||||
header, err := treader.Next()
|
||||
if err != nil {
|
||||
return nil, nil, os.ErrNotExist
|
||||
}
|
||||
if header.FileInfo().Mode()&os.ModeSymlink == os.ModeSymlink {
|
||||
spath, err = symlinkJoin(spath, header.Linkname, ".")
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
} else {
|
||||
return treader, tars, nil
|
||||
}
|
||||
}
|
||||
return nil, nil, fmt.Errorf("max depth %d of symlinks exceeded while reading %s", maxSymlinkDepth, spath)
|
||||
}
|
||||
}
|
||||
|
||||
actionModel, err := sar.readAction(ctx, sar.Step, sar.resolvedSha, sar.remoteAction.Path, remoteReader(ctx), os.WriteFile)
|
||||
sar.action = actionModel
|
||||
return err
|
||||
}
|
||||
|
||||
actionDir := sar.actionDir()
|
||||
defaultActionURL := sar.RunContext.Config.DefaultActionURL()
|
||||
// For Gitea
|
||||
// A composite RunContext nils Config.Secrets, so getGitCloneToken would yield an
|
||||
// empty token and clone the action anonymously (401 against the authenticated
|
||||
// instance). github.Token survives the composite config copy and matches the
|
||||
// top-level token; keep the shouldCloneURLUseToken host gate to avoid leaking it.
|
||||
cloneURL := sar.remoteAction.CloneURL(defaultActionURL)
|
||||
token := ""
|
||||
if shouldCloneURLUseToken(sar.RunContext.Config.GitHubInstance, sar.RunContext.Config.trustedActionInstance(), cloneURL) {
|
||||
token = github.Token
|
||||
}
|
||||
gitClone := stepActionRemoteNewCloneExecutor(git.NewGitCloneExecutorInput{
|
||||
URL: cloneURL,
|
||||
Ref: sar.remoteAction.Ref,
|
||||
Dir: actionDir,
|
||||
Token: token,
|
||||
OfflineMode: sar.RunContext.Config.ActionOfflineMode,
|
||||
Depth: sar.RunContext.Config.ActionCloneDepth,
|
||||
// printPrepareActions reports the download with its resolved commit.
|
||||
Quiet: true,
|
||||
|
||||
InsecureSkipTLS: sar.cloneSkipTLS(), // For Gitea
|
||||
})
|
||||
var ntErr common.Executor
|
||||
if err := gitClone(ctx); err != nil {
|
||||
var refErr *git.Error
|
||||
if errors.As(err, &refErr) && errors.Is(err, git.ErrShortRef) {
|
||||
return fmt.Errorf("Unable to resolve action `%s`, the provided ref `%s` is the shortened version of a commit SHA, which is not supported. Please use the full commit SHA `%s` instead",
|
||||
sar.Step.Uses, sar.remoteAction.Ref, refErr.Commit())
|
||||
} else if errors.Is(err, gogit.ErrForceNeeded) { // TODO: figure out if it will be easy to shadow/alias go-git err's
|
||||
ntErr = common.NewInfoExecutor("Non-terminating error while running 'git clone': %v", err)
|
||||
} else {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Best effort: the download report falls back to the ref alone when the commit is unknown.
|
||||
if _, sha, err := git.FindGitRevision(ctx, actionDir); err != nil {
|
||||
common.Logger(ctx).Debugf("unable to resolve the commit of %s: %v", sar.remoteAction.Reference(), err)
|
||||
} else {
|
||||
sar.resolvedSha = sha
|
||||
}
|
||||
|
||||
remoteReader := func(ctx context.Context) actionYamlReader { //nolint:unparam // pre-existing issue from nektos/act
|
||||
return func(filename string) (io.Reader, io.Closer, error) {
|
||||
f, err := os.Open(filepath.Join(actionDir, sar.remoteAction.Path, filename))
|
||||
return f, f, err
|
||||
}
|
||||
}
|
||||
|
||||
return common.NewPipelineExecutor(
|
||||
ntErr,
|
||||
func(ctx context.Context) error {
|
||||
defer git.AcquireCloneLock(actionDir)()
|
||||
actionModel, err := sar.readAction(ctx, sar.Step, actionDir, sar.remoteAction.Path, remoteReader(ctx), os.WriteFile)
|
||||
sar.action = actionModel
|
||||
return err
|
||||
},
|
||||
// A stage of its own: it takes the same clone lock, and it has to land before
|
||||
// runAction copies the action into the job container.
|
||||
sar.patchActionToolkit,
|
||||
)(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// actionDownloadInfo reports the action this step downloaded and the commit it resolved to. ok is
|
||||
// false when nothing was fetched, as for the local checkout of the workflow's own repository.
|
||||
func (sar *stepActionRemote) actionDownloadInfo() (reference, sha string, ok bool) {
|
||||
if sar.remoteAction == nil || sar.action == nil {
|
||||
return "", "", false
|
||||
}
|
||||
return sar.remoteAction.Reference(), sar.resolvedSha, true
|
||||
}
|
||||
|
||||
func (sar *stepActionRemote) pre() common.Executor {
|
||||
sar.env = map[string]string{}
|
||||
|
||||
return common.NewPipelineExecutor(
|
||||
sar.prepareActionExecutor(),
|
||||
runStepExecutor(sar, stepStagePre, sar.revertToolkitOnFailure(runPreStep(sar))).If(hasPreStep(sar)).If(shouldRunPreStep(sar)))
|
||||
}
|
||||
|
||||
func (sar *stepActionRemote) main() common.Executor {
|
||||
return common.NewPipelineExecutor(
|
||||
sar.prepareActionExecutor(),
|
||||
runStepExecutor(sar, stepStageMain, func(ctx context.Context) error {
|
||||
printRunActionHeader(ctx, sar.Step, sar.env, sar.RunContext)
|
||||
rawLogger := common.Logger(ctx).WithField(rawOutputField, true)
|
||||
defer rawLogger.Infof("::endgroup::")
|
||||
|
||||
github := sar.getGithubContext(ctx)
|
||||
if sar.remoteAction.IsCheckout() && isLocalCheckout(github, sar.Step) && !sar.RunContext.Config.NoSkipCheckout {
|
||||
if sar.RunContext.Config.BindWorkdir {
|
||||
common.Logger(ctx).Debugf("Skipping local actions/checkout because you bound your workspace")
|
||||
return nil
|
||||
}
|
||||
eval := sar.RunContext.NewExpressionEvaluator(ctx)
|
||||
copyToPath := path.Join(sar.RunContext.JobContainer.ToContainerPath(sar.RunContext.Config.Workdir), eval.Interpolate(ctx, sar.Step.With["path"]))
|
||||
return sar.RunContext.JobContainer.CopyDir(copyToPath, sar.RunContext.Config.Workdir+string(filepath.Separator)+".", sar.RunContext.Config.UseGitIgnore)(ctx)
|
||||
}
|
||||
|
||||
actionDir := sar.actionDir()
|
||||
|
||||
return sar.revertToolkitOnFailure(sar.runAction(sar, actionDir, sar.remoteAction))(ctx)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
func (sar *stepActionRemote) post() common.Executor {
|
||||
return runStepExecutor(sar, stepStagePost, sar.revertToolkitOnFailure(runPostStep(sar))).If(hasPostStep(sar)).If(shouldRunPostStep(sar))
|
||||
}
|
||||
|
||||
// toolkitBundles is the action directory and the entrypoints the toolkit may live in.
|
||||
func (sar *stepActionRemote) toolkitBundles() (string, []string) {
|
||||
if sar.remoteAction == nil {
|
||||
return "", nil
|
||||
}
|
||||
dir := sar.actionDir()
|
||||
return dir, actionScriptPaths(filepath.Join(dir, sar.remoteAction.Path), sar.action)
|
||||
}
|
||||
|
||||
// patchActionToolkit edits the bundled toolkit so it works against Gitea: the artifact actions
|
||||
// stop refusing, and the cache client keeps to the cache server whichever API version it picks.
|
||||
func (sar *stepActionRemote) patchActionToolkit(ctx context.Context) error {
|
||||
if sar.RunContext.Config.PatchToolkit {
|
||||
dir, scripts := sar.toolkitBundles()
|
||||
patchToolkit(ctx, dir, scripts)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// revertToolkitOnFailure restores the untouched bundles when the action fails, so a later job
|
||||
// runs it as shipped rather than repeating a failure the patch may have caused.
|
||||
func (sar *stepActionRemote) revertToolkitOnFailure(exec common.Executor) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
err := exec(ctx)
|
||||
if err != nil {
|
||||
dir, scripts := sar.toolkitBundles()
|
||||
revertToolkit(ctx, dir, scripts)
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
func (sar *stepActionRemote) actionDir() string {
|
||||
uses := sar.Step.Uses
|
||||
if strings.HasPrefix(uses, selfRepoPrefix) {
|
||||
// The same `$/x` names a different action per enclosing repo, so key the cache on what it resolved to.
|
||||
uses = sar.remoteAction.URL + "/" + sar.remoteAction.Reference()
|
||||
}
|
||||
return fmt.Sprintf("%s/%s", sar.RunContext.ActionCacheDir(), model.UsesHash(uses))
|
||||
}
|
||||
|
||||
func (sar *stepActionRemote) getRunContext() *RunContext {
|
||||
return sar.RunContext
|
||||
}
|
||||
|
||||
func (sar *stepActionRemote) getGithubContext(ctx context.Context) *model.GithubContext {
|
||||
ghc := sar.getRunContext().getGithubContext(ctx)
|
||||
|
||||
// extend github context if we already have an initialized remoteAction
|
||||
remoteAction := sar.remoteAction
|
||||
if remoteAction != nil {
|
||||
ghc.ActionRepository = fmt.Sprintf("%s/%s", remoteAction.Org, remoteAction.Repo)
|
||||
ghc.ActionRef = remoteAction.Ref
|
||||
}
|
||||
|
||||
return ghc
|
||||
}
|
||||
|
||||
func (sar *stepActionRemote) getStepModel() *model.Step {
|
||||
return sar.Step
|
||||
}
|
||||
|
||||
func (sar *stepActionRemote) getEnv() *map[string]string {
|
||||
return &sar.env
|
||||
}
|
||||
|
||||
func (sar *stepActionRemote) getIfExpression(ctx context.Context, stage stepStage) string {
|
||||
switch stage {
|
||||
case stepStagePre:
|
||||
github := sar.getGithubContext(ctx)
|
||||
if sar.remoteAction.IsCheckout() && isLocalCheckout(github, sar.Step) && !sar.RunContext.Config.NoSkipCheckout {
|
||||
// skip local checkout pre step
|
||||
return "false"
|
||||
}
|
||||
return sar.action.Runs.PreIf
|
||||
case stepStageMain:
|
||||
return sar.Step.If.Value
|
||||
case stepStagePost:
|
||||
return sar.action.Runs.PostIf
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (sar *stepActionRemote) getActionModel() *model.Action {
|
||||
return sar.action
|
||||
}
|
||||
|
||||
func (sar *stepActionRemote) getCompositeRunContext(ctx context.Context) *RunContext {
|
||||
if sar.compositeRunContext == nil {
|
||||
actionDir := sar.actionDir()
|
||||
actionLocation := path.Join(actionDir, sar.remoteAction.Path)
|
||||
_, containerActionDir := getContainerActionPaths(sar.getStepModel(), actionLocation, sar.RunContext)
|
||||
|
||||
sar.compositeRunContext = newCompositeRunContext(ctx, sar.RunContext, sar, containerActionDir)
|
||||
sar.compositeSteps = sar.compositeRunContext.compositeExecutor(sar.action)
|
||||
} else {
|
||||
// Re-evaluate environment here. For remote actions the environment
|
||||
// need to be re-created for every stage (pre, main, post) as there
|
||||
// might be required context changes (inputs/outputs) while the action
|
||||
// stages are executed. (e.g. the output of another action is the
|
||||
// input for this action during the main stage, but the env
|
||||
// was already created during the pre stage)
|
||||
env := evaluateCompositeInputAndEnv(ctx, sar.RunContext, sar)
|
||||
sar.compositeRunContext.Env = env
|
||||
sar.compositeRunContext.ExtraPath = sar.RunContext.ExtraPath
|
||||
}
|
||||
return sar.compositeRunContext
|
||||
}
|
||||
|
||||
func (sar *stepActionRemote) getCompositeSteps() *compositeSteps {
|
||||
return sar.compositeSteps
|
||||
}
|
||||
|
||||
// For Gitea
|
||||
// cloneSkipTLS returns true if the runner can clone an action from the Gitea instance
|
||||
func (sar *stepActionRemote) cloneSkipTLS() bool {
|
||||
if !sar.RunContext.Config.InsecureSkipTLS {
|
||||
// Return false if the Gitea instance is not an insecure instance
|
||||
return false
|
||||
}
|
||||
if sar.remoteAction.URL == "" {
|
||||
// Empty URL means the default action instance should be used
|
||||
// Return true if the URL of the Gitea instance is the same as the URL of the default action instance
|
||||
return sar.RunContext.Config.DefaultActionURL() == sar.RunContext.Config.GitHubInstance
|
||||
}
|
||||
// Return true if the URL of the remote action is the same as the URL of the Gitea instance
|
||||
return sar.remoteAction.URL == sar.RunContext.Config.GitHubInstance
|
||||
}
|
||||
|
||||
type remoteAction struct {
|
||||
URL string
|
||||
Org string
|
||||
Repo string
|
||||
Path string
|
||||
Ref string
|
||||
}
|
||||
|
||||
func (ra *remoteAction) CloneURL(u string) string {
|
||||
if ra.URL == "" {
|
||||
// keep an absolute local path as-is (used by tests to resolve actions from a local
|
||||
// repo); only bare host names get the https:// scheme prepended
|
||||
if !strings.HasPrefix(u, "http://") && !strings.HasPrefix(u, "https://") && !filepath.IsAbs(u) {
|
||||
u = "https://" + u
|
||||
}
|
||||
} else {
|
||||
u = ra.URL
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s/%s/%s", u, ra.Org, ra.Repo)
|
||||
}
|
||||
|
||||
// Reference renders the action as {org}/{repo}[/path]@{ref}, omitting the download source, which
|
||||
// can be interpolated from a secret.
|
||||
func (ra *remoteAction) Reference() string {
|
||||
repo := fmt.Sprintf("%s/%s", ra.Org, ra.Repo)
|
||||
if ra.Path != "" {
|
||||
repo = fmt.Sprintf("%s/%s", repo, ra.Path)
|
||||
}
|
||||
return fmt.Sprintf("%s@%s", repo, ra.Ref)
|
||||
}
|
||||
|
||||
func (ra *remoteAction) IsCheckout() bool {
|
||||
if ra.Org == "actions" && ra.Repo == "checkout" {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// newSelfRepoAction resolves `$/{path}` against the enclosing composite action, falling back to the workflow's own repo and commit.
|
||||
func newSelfRepoAction(action string, github *model.GithubContext) *remoteAction {
|
||||
subPath := strings.TrimLeft(strings.TrimPrefix(action, selfRepoPrefix), "/")
|
||||
if subPath == "" || strings.Contains(subPath, "@") || path.Clean("/"+subPath) != "/"+subPath { // rooted, so a leading ".." is rejected too
|
||||
return nil
|
||||
}
|
||||
repo, ref := github.ActionRepository, github.ActionRef
|
||||
if repo == "" || ref == "" {
|
||||
repo, ref = github.Repository, github.Sha
|
||||
}
|
||||
org, name, _ := strings.Cut(repo, "/")
|
||||
if org == "" || name == "" || ref == "" {
|
||||
return nil
|
||||
}
|
||||
return &remoteAction{URL: github.ServerURL, Org: org, Repo: name, Path: subPath, Ref: ref}
|
||||
}
|
||||
|
||||
func newRemoteAction(action string) *remoteAction {
|
||||
// support http(s)://host/owner/repo@v3
|
||||
for _, schema := range []string{"https://", "http://", "ssh://"} {
|
||||
if after, ok := strings.CutPrefix(action, schema); ok {
|
||||
splits := strings.SplitN(after, "/", 2)
|
||||
if len(splits) != 2 {
|
||||
return nil
|
||||
}
|
||||
ret := parseAction(splits[1])
|
||||
if ret == nil {
|
||||
return nil
|
||||
}
|
||||
ret.URL = schema + splits[0]
|
||||
return ret
|
||||
}
|
||||
}
|
||||
|
||||
return parseAction(action)
|
||||
}
|
||||
|
||||
func parseAction(action string) *remoteAction {
|
||||
// GitHub's document[^] describes:
|
||||
// > We strongly recommend that you include the version of
|
||||
// > the action you are using by specifying a Git ref, SHA, or Docker tag number.
|
||||
// Actually, the workflow stops if there is the uses directive that hasn't @ref.
|
||||
// [^]: https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions
|
||||
r := regexp.MustCompile(`^([^/@]+)/([^/@]+)(/([^@]*))?(@(.*))?$`)
|
||||
matches := r.FindStringSubmatch(action)
|
||||
if len(matches) < 7 || matches[6] == "" {
|
||||
return nil
|
||||
}
|
||||
return &remoteAction{
|
||||
Org: matches[1],
|
||||
Repo: matches[2],
|
||||
Path: matches[4],
|
||||
Ref: matches[6],
|
||||
URL: "",
|
||||
}
|
||||
}
|
||||
|
||||
func safeFilename(s string) string {
|
||||
return strings.NewReplacer(
|
||||
`<`, "-",
|
||||
`>`, "-",
|
||||
`:`, "-",
|
||||
`"`, "-",
|
||||
`/`, "-",
|
||||
`\`, "-",
|
||||
`|`, "-",
|
||||
`?`, "-",
|
||||
`*`, "-",
|
||||
).Replace(s)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,140 @@
|
||||
// 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"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gitea.com/gitea/runner/internal/act/common"
|
||||
"gitea.com/gitea/runner/internal/act/container"
|
||||
|
||||
"gitea.dev/actionslib/pkg/model"
|
||||
"github.com/kballard/go-shellquote"
|
||||
)
|
||||
|
||||
type stepDocker struct {
|
||||
Step *model.Step
|
||||
RunContext *RunContext
|
||||
env map[string]string
|
||||
}
|
||||
|
||||
func (sd *stepDocker) pre() common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (sd *stepDocker) main() common.Executor {
|
||||
sd.env = map[string]string{}
|
||||
|
||||
return runStepExecutor(sd, stepStageMain, sd.runUsesContainer())
|
||||
}
|
||||
|
||||
func (sd *stepDocker) post() common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (sd *stepDocker) getRunContext() *RunContext {
|
||||
return sd.RunContext
|
||||
}
|
||||
|
||||
func (sd *stepDocker) getGithubContext(ctx context.Context) *model.GithubContext {
|
||||
return sd.getRunContext().getGithubContext(ctx)
|
||||
}
|
||||
|
||||
func (sd *stepDocker) getStepModel() *model.Step {
|
||||
return sd.Step
|
||||
}
|
||||
|
||||
func (sd *stepDocker) getEnv() *map[string]string {
|
||||
return &sd.env
|
||||
}
|
||||
|
||||
func (sd *stepDocker) getIfExpression(_ context.Context, _ stepStage) string {
|
||||
return sd.Step.If.Value
|
||||
}
|
||||
|
||||
func (sd *stepDocker) runUsesContainer() common.Executor {
|
||||
rc := sd.RunContext
|
||||
step := sd.Step
|
||||
|
||||
return func(ctx context.Context) error {
|
||||
image := strings.TrimPrefix(step.Uses, "docker://")
|
||||
eval := rc.NewExpressionEvaluator(ctx)
|
||||
cmd, err := shellquote.Split(eval.Interpolate(ctx, step.With["args"]))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var entrypoint []string
|
||||
if entry := eval.Interpolate(ctx, step.With["entrypoint"]); entry != "" {
|
||||
entrypoint = []string{entry}
|
||||
}
|
||||
|
||||
stepContainer := sd.newStepContainer(ctx, image, cmd, entrypoint)
|
||||
|
||||
return common.NewPipelineExecutor(
|
||||
stepContainer.Pull(rc.Config.ForcePull),
|
||||
stepContainer.Remove().IfBool(!rc.Config.ReuseContainers),
|
||||
stepContainer.Create(rc.Config.ContainerCapAdd, rc.Config.ContainerCapDrop),
|
||||
stepContainer.Start(true),
|
||||
).Finally(
|
||||
stepContainer.Remove().IfBool(!rc.Config.ReuseContainers && !rc.Config.AutoRemove),
|
||||
).Finally(stepContainer.Close())(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
var ContainerNewContainer = container.NewContainer
|
||||
|
||||
func (sd *stepDocker) newStepContainer(ctx context.Context, image string, cmd, entrypoint []string) container.Container {
|
||||
rc := sd.RunContext
|
||||
step := sd.Step
|
||||
|
||||
rawLogger := common.Logger(ctx).WithField("raw_output", true)
|
||||
logWriter := common.NewLineWriter(rc.commandHandler(ctx), func(s string) bool {
|
||||
if rc.Config.LogOutput {
|
||||
rawLogger.Infof("%s", s)
|
||||
} else {
|
||||
rawLogger.Debugf("%s", s)
|
||||
}
|
||||
return true
|
||||
})
|
||||
envList := make([]string, 0)
|
||||
for k, v := range sd.env {
|
||||
envList = append(envList, fmt.Sprintf("%s=%s", k, v))
|
||||
}
|
||||
|
||||
envList = append(envList, rc.runnerEnv(ctx)...)
|
||||
|
||||
binds, mounts := rc.GetBindsAndMounts()
|
||||
networkMode := "container:" + rc.jobContainerName()
|
||||
if rc.IsHostEnv(ctx) {
|
||||
networkMode = "default"
|
||||
}
|
||||
stepContainer := ContainerNewContainer(&container.NewContainerInput{
|
||||
Cmd: cmd,
|
||||
Entrypoint: entrypoint,
|
||||
WorkingDir: rc.JobContainer.ToContainerPath(rc.Config.Workdir),
|
||||
Image: image,
|
||||
Name: createContainerName(rc.jobContainerName(), "STEP-"+step.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,
|
||||
AutoRemove: rc.Config.AutoRemove,
|
||||
ValidVolumes: rc.validVolumes(),
|
||||
AllocatePTY: rc.Config.AllocatePTY,
|
||||
})
|
||||
return stepContainer
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2022 The nektos/act Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package runner
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.com/gitea/runner/internal/act/container"
|
||||
|
||||
"gitea.dev/actionslib/pkg/model"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestStepDockerMain(t *testing.T) {
|
||||
cm := &containerMock{}
|
||||
|
||||
var input *container.NewContainerInput
|
||||
|
||||
// mock the new container call
|
||||
origContainerNewContainer := ContainerNewContainer
|
||||
ContainerNewContainer = func(containerInput *container.NewContainerInput) container.ExecutionsEnvironment {
|
||||
input = containerInput
|
||||
return cm
|
||||
}
|
||||
defer (func() {
|
||||
ContainerNewContainer = origContainerNewContainer
|
||||
})()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
sd := &stepDocker{
|
||||
RunContext: &RunContext{
|
||||
StepResults: map[string]*model.StepResult{},
|
||||
Config: &Config{
|
||||
Secrets: map[string]string{
|
||||
"DOCKER_USERNAME": "docker-user",
|
||||
"DOCKER_PASSWORD": "docker-password",
|
||||
},
|
||||
},
|
||||
Run: &model.Run{
|
||||
JobID: "1",
|
||||
Workflow: &model.Workflow{
|
||||
Jobs: map[string]*model.Job{
|
||||
"1": {
|
||||
Defaults: model.Defaults{
|
||||
Run: model.RunDefaults{
|
||||
Shell: "bash",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
JobContainer: cm,
|
||||
},
|
||||
Step: &model.Step{
|
||||
ID: "1",
|
||||
Uses: "docker://node:14",
|
||||
WorkingDirectory: "workdir",
|
||||
},
|
||||
}
|
||||
sd.RunContext.ExprEval = sd.RunContext.NewExpressionEvaluator(ctx)
|
||||
|
||||
cm.On("Pull", false).Return(func(ctx context.Context) error {
|
||||
return nil
|
||||
})
|
||||
|
||||
cm.On("Remove").Return(func(ctx context.Context) error {
|
||||
return nil
|
||||
})
|
||||
|
||||
cm.On("Create", []string(nil), []string(nil)).Return(func(ctx context.Context) error {
|
||||
return nil
|
||||
})
|
||||
|
||||
cm.On("Start", true).Return(func(ctx context.Context) error {
|
||||
return nil
|
||||
})
|
||||
|
||||
cm.On("Close").Return(func(ctx context.Context) error {
|
||||
return nil
|
||||
})
|
||||
|
||||
cm.On("Copy", "/var/run/act", mock.AnythingOfType("[]*container.FileEntry")).Return(func(ctx context.Context) error {
|
||||
return nil
|
||||
})
|
||||
|
||||
cm.On("UpdateFromEnv", "/var/run/act/workflow/envs.txt", mock.AnythingOfType("*map[string]string")).Return(func(ctx context.Context) error {
|
||||
return nil
|
||||
})
|
||||
|
||||
cm.On("UpdateFromEnv", "/var/run/act/workflow/statecmd.txt", mock.AnythingOfType("*map[string]string")).Return(func(ctx context.Context) error {
|
||||
return nil
|
||||
})
|
||||
|
||||
cm.On("UpdateFromEnv", "/var/run/act/workflow/outputcmd.txt", mock.AnythingOfType("*map[string]string")).Return(func(ctx context.Context) error {
|
||||
return nil
|
||||
})
|
||||
|
||||
cm.On("GetContainerArchive", ctx, "/var/run/act/workflow/pathcmd.txt").Return(io.NopCloser(&bytes.Buffer{}), nil)
|
||||
|
||||
err := sd.main()(ctx)
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
assert.Equal(t, "node:14", input.Image)
|
||||
|
||||
// DOCKER_USERNAME/DOCKER_PASSWORD secrets should not be used as implicit pull credentials for docker:// action containers.
|
||||
assert.Empty(t, input.Username)
|
||||
assert.Empty(t, input.Password)
|
||||
|
||||
cm.AssertExpectations(t)
|
||||
}
|
||||
|
||||
// With AutoRemove the daemon reaps the container on exit, so act must not remove it afterwards.
|
||||
func TestStepDockerAutoRemove(t *testing.T) {
|
||||
orig := ContainerNewContainer
|
||||
defer func() { ContainerNewContainer = orig }()
|
||||
|
||||
for _, tc := range []struct {
|
||||
autoRemove bool
|
||||
removes int
|
||||
}{
|
||||
{false, 2}, // stale + post-run
|
||||
{true, 1}, // post-run skipped
|
||||
} {
|
||||
cm := &containerMock{}
|
||||
ContainerNewContainer = func(*container.NewContainerInput) container.ExecutionsEnvironment { return cm }
|
||||
|
||||
sd := &stepDocker{
|
||||
RunContext: &RunContext{
|
||||
Config: &Config{AutoRemove: tc.autoRemove},
|
||||
Run: &model.Run{JobID: "1", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"1": {}}}},
|
||||
JobContainer: cm,
|
||||
},
|
||||
Step: &model.Step{ID: "1", Uses: "docker://node:14"},
|
||||
}
|
||||
|
||||
removes := 0
|
||||
cm.On("Pull", false).Return(func(context.Context) error { return nil })
|
||||
cm.On("Remove").Return(func(context.Context) error { removes++; return nil })
|
||||
cm.On("Create", []string(nil), []string(nil)).Return(func(context.Context) error { return nil })
|
||||
cm.On("Start", true).Return(func(context.Context) error { return nil })
|
||||
cm.On("Close").Return(func(context.Context) error { return nil })
|
||||
|
||||
require.NoError(t, sd.runUsesContainer()(context.Background()))
|
||||
cm.AssertExpectations(t)
|
||||
assert.Equal(t, tc.removes, removes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStepDockerNewStepContainerAllocatePTY(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
allocPTY bool
|
||||
}{
|
||||
{name: "off", allocPTY: false},
|
||||
{name: "on", allocPTY: true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cm := &containerMock{}
|
||||
|
||||
var captured *container.NewContainerInput
|
||||
origContainerNewContainer := ContainerNewContainer
|
||||
ContainerNewContainer = func(input *container.NewContainerInput) container.ExecutionsEnvironment {
|
||||
captured = input
|
||||
return cm
|
||||
}
|
||||
defer func() {
|
||||
ContainerNewContainer = origContainerNewContainer
|
||||
}()
|
||||
|
||||
ctx := context.Background()
|
||||
sd := &stepDocker{
|
||||
RunContext: &RunContext{
|
||||
StepResults: map[string]*model.StepResult{},
|
||||
Config: &Config{
|
||||
AllocatePTY: tc.allocPTY,
|
||||
PlatformPicker: func(_ []string) string {
|
||||
return "node:14"
|
||||
},
|
||||
},
|
||||
Run: &model.Run{
|
||||
JobID: "1",
|
||||
Workflow: &model.Workflow{
|
||||
Jobs: map[string]*model.Job{"1": {}},
|
||||
},
|
||||
},
|
||||
JobContainer: cm,
|
||||
},
|
||||
Step: &model.Step{ID: "1", Uses: "docker://node:14"},
|
||||
}
|
||||
sd.RunContext.ExprEval = sd.RunContext.NewExpressionEvaluator(ctx)
|
||||
|
||||
_ = sd.newStepContainer(ctx, "node:14", []string{"echo", "hi"}, nil)
|
||||
assert.Equal(t, tc.allocPTY, captured.AllocatePTY)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStepDockerPrePost(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
sd := &stepDocker{}
|
||||
|
||||
err := sd.pre()(ctx)
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
err = sd.post()(ctx)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestStepDockerNewStepContainerNetworkMode(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
platform string
|
||||
expectDefault bool
|
||||
}{
|
||||
{
|
||||
name: "docker mode attaches to job container network",
|
||||
platform: "node:14",
|
||||
expectDefault: false,
|
||||
},
|
||||
{
|
||||
name: "host mode uses default network",
|
||||
platform: "-self-hosted",
|
||||
expectDefault: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cm := &containerMock{}
|
||||
|
||||
var captured *container.NewContainerInput
|
||||
origContainerNewContainer := ContainerNewContainer
|
||||
ContainerNewContainer = func(input *container.NewContainerInput) container.ExecutionsEnvironment {
|
||||
captured = input
|
||||
return cm
|
||||
}
|
||||
defer func() {
|
||||
ContainerNewContainer = origContainerNewContainer
|
||||
}()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
platform := tc.platform
|
||||
sd := &stepDocker{
|
||||
RunContext: &RunContext{
|
||||
StepResults: map[string]*model.StepResult{},
|
||||
Config: &Config{
|
||||
PlatformPicker: func(_ []string) string {
|
||||
return platform
|
||||
},
|
||||
},
|
||||
Run: &model.Run{
|
||||
JobID: "1",
|
||||
Workflow: &model.Workflow{
|
||||
Jobs: map[string]*model.Job{
|
||||
"1": {},
|
||||
},
|
||||
},
|
||||
},
|
||||
JobContainer: cm,
|
||||
},
|
||||
Step: &model.Step{
|
||||
ID: "1",
|
||||
Uses: "docker://alpine:3.20",
|
||||
},
|
||||
}
|
||||
sd.RunContext.ExprEval = sd.RunContext.NewExpressionEvaluator(ctx)
|
||||
|
||||
assert.Equal(t, tc.expectDefault, sd.RunContext.IsHostEnv(ctx),
|
||||
"IsHostEnv mismatch for platform %q", tc.platform)
|
||||
|
||||
_ = sd.newStepContainer(ctx, "alpine:3.20", []string{"echo", "hello"}, nil)
|
||||
|
||||
if tc.expectDefault {
|
||||
assert.Equal(t, "default", captured.NetworkMode,
|
||||
"host-mode step container must use 'default' network, got %q",
|
||||
captured.NetworkMode)
|
||||
} else {
|
||||
assert.True(t, strings.HasPrefix(captured.NetworkMode, "container:"),
|
||||
"docker-mode step container must attach to job container network, got %q",
|
||||
captured.NetworkMode)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2022 The nektos/act Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package runner
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"gitea.dev/actionslib/pkg/model"
|
||||
)
|
||||
|
||||
type stepFactory interface {
|
||||
newStep(step *model.Step, rc *RunContext) (step, error)
|
||||
}
|
||||
|
||||
type stepFactoryImpl struct{}
|
||||
|
||||
func (sf *stepFactoryImpl) newStep(stepModel *model.Step, rc *RunContext) (step, error) {
|
||||
switch stepModel.Type() {
|
||||
case model.StepTypeInvalid:
|
||||
return nil, fmt.Errorf("Invalid run/uses syntax for job:%s step:%+v", rc.Run, stepModel)
|
||||
case model.StepTypeRun:
|
||||
return &stepRun{
|
||||
Step: stepModel,
|
||||
RunContext: rc,
|
||||
}, nil
|
||||
case model.StepTypeUsesActionLocal:
|
||||
return &stepActionLocal{
|
||||
Step: stepModel,
|
||||
RunContext: rc,
|
||||
readAction: readActionImpl,
|
||||
runAction: runActionImpl,
|
||||
}, nil
|
||||
case model.StepTypeUsesActionRemote:
|
||||
return &stepActionRemote{
|
||||
Step: stepModel,
|
||||
RunContext: rc,
|
||||
readAction: readActionImpl,
|
||||
runAction: runActionImpl,
|
||||
}, nil
|
||||
case model.StepTypeUsesDockerURL:
|
||||
return &stepDocker{
|
||||
Step: stepModel,
|
||||
RunContext: rc,
|
||||
}, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("Unable to determine how to run job:%s step:%+v", rc.Run, stepModel)
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2022 The nektos/act Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package runner
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.dev/actionslib/pkg/model"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestStepFactoryNewStep(t *testing.T) {
|
||||
table := []struct {
|
||||
name string
|
||||
model *model.Step
|
||||
check func(s step) bool
|
||||
}{
|
||||
{
|
||||
name: "StepRemoteAction",
|
||||
model: &model.Step{
|
||||
Uses: "remote/action@v1",
|
||||
},
|
||||
check: func(s step) bool {
|
||||
_, ok := s.(*stepActionRemote)
|
||||
return ok
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "StepLocalAction",
|
||||
model: &model.Step{
|
||||
Uses: "./action@v1",
|
||||
},
|
||||
check: func(s step) bool {
|
||||
_, ok := s.(*stepActionLocal)
|
||||
return ok
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "StepDocker",
|
||||
model: &model.Step{
|
||||
Uses: "docker://image:tag",
|
||||
},
|
||||
check: func(s step) bool {
|
||||
_, ok := s.(*stepDocker)
|
||||
return ok
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "StepRun",
|
||||
model: &model.Step{
|
||||
Run: "cmd",
|
||||
},
|
||||
check: func(s step) bool {
|
||||
_, ok := s.(*stepRun)
|
||||
return ok
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range table {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
sf := &stepFactoryImpl{}
|
||||
|
||||
step, err := sf.newStep(tt.model, &RunContext{})
|
||||
|
||||
assert.True(t, tt.check((step)))
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStepFactoryInvalidStep(t *testing.T) {
|
||||
model := &model.Step{
|
||||
Uses: "remote/action@v1",
|
||||
Run: "cmd",
|
||||
}
|
||||
|
||||
sf := &stepFactoryImpl{}
|
||||
|
||||
_, err := sf.newStep(model, &RunContext{})
|
||||
|
||||
assert.Error(t, err)
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
// 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"
|
||||
"fmt"
|
||||
"maps"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"gitea.com/gitea/runner/internal/act/common"
|
||||
"gitea.com/gitea/runner/internal/act/container"
|
||||
"gitea.com/gitea/runner/internal/act/lookpath"
|
||||
|
||||
"gitea.dev/actionslib/pkg/model"
|
||||
"github.com/kballard/go-shellquote"
|
||||
"github.com/sirupsen/logrus"
|
||||
yaml "go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
type stepRun struct {
|
||||
Step *model.Step
|
||||
RunContext *RunContext
|
||||
cmd []string
|
||||
cmdline string
|
||||
env map[string]string
|
||||
WorkingDirectory string
|
||||
interpolatedScript string
|
||||
shellCommand string
|
||||
}
|
||||
|
||||
func (sr *stepRun) pre() common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (sr *stepRun) main() common.Executor {
|
||||
sr.env = map[string]string{}
|
||||
return runStepExecutor(sr, stepStageMain, common.NewPipelineExecutor(
|
||||
sr.setupShellCommandExecutor(),
|
||||
func(ctx context.Context) error {
|
||||
rc := sr.getRunContext()
|
||||
// Apply ::add-path:: effects before printing so PATH is accurate in the env: block.
|
||||
rc.ApplyExtraPath(ctx, &sr.env)
|
||||
sr.printRunScriptActionDetails(ctx)
|
||||
if he, ok := rc.JobContainer.(*container.HostEnvironment); ok && he != nil {
|
||||
return he.ExecWithCmdLine(sr.cmd, sr.cmdline, sr.env, "", sr.WorkingDirectory)(ctx)
|
||||
}
|
||||
return rc.JobContainer.Exec(sr.cmd, sr.env, "", sr.WorkingDirectory)(ctx)
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
// printRunScriptActionDetails mirrors actions/runner ScriptHandler.PrintActionDetails
|
||||
// for script steps.
|
||||
func (sr *stepRun) printRunScriptActionDetails(ctx context.Context) {
|
||||
rawLogger := common.Logger(ctx).WithField(rawOutputField, true)
|
||||
scriptLineLogger := rawLogger.WithField(scriptLineCyanField, true)
|
||||
|
||||
normalized := strings.TrimRight(strings.ReplaceAll(sr.interpolatedScript, "\r\n", "\n"), "\n")
|
||||
|
||||
rawLogger.Infof("::group::Run %s", EscapeCommandData(sr.runScriptGroupTitle(normalized)))
|
||||
|
||||
if normalized != "" {
|
||||
for line := range strings.SplitSeq(normalized, "\n") {
|
||||
scriptLineLogger.Info(line)
|
||||
}
|
||||
}
|
||||
|
||||
rawLogger.Infof("shell: %s", sr.shellCommand)
|
||||
|
||||
printStepEnvBlock(ctx, sr.Step, sr.env, sr.getRunContext())
|
||||
rawLogger.Infof("::endgroup::")
|
||||
}
|
||||
|
||||
// printRunActionHeader mirrors actions/runner's "Run <action>" header for `uses:` steps,
|
||||
// including the with: inputs and the step-level env: block. The caller is responsible
|
||||
// for emitting ::endgroup:: after the action finishes.
|
||||
func printRunActionHeader(ctx context.Context, step *model.Step, env map[string]string, rc *RunContext) {
|
||||
if step == nil {
|
||||
return
|
||||
}
|
||||
rawLogger := common.Logger(ctx).WithField(rawOutputField, true)
|
||||
|
||||
title := step.Uses
|
||||
if step.Name != "" {
|
||||
title = step.Name
|
||||
}
|
||||
rawLogger.Infof("::group::Run %s", EscapeCommandData(title))
|
||||
|
||||
if len(step.With) > 0 {
|
||||
rawLogger.Infof("with:")
|
||||
for _, k := range slices.Sorted(maps.Keys(step.With)) {
|
||||
logKeyedValue(rawLogger, k, step.With[k])
|
||||
}
|
||||
}
|
||||
|
||||
printStepEnvBlock(ctx, step, env, rc)
|
||||
}
|
||||
|
||||
// printStepEnvBlock emits the declared-env block (YAML order, internal vars filtered)
|
||||
// shared by the run: and uses: "Run" headers.
|
||||
func printStepEnvBlock(ctx context.Context, step *model.Step, env map[string]string, rc *RunContext) {
|
||||
rawLogger := common.Logger(ctx).WithField(rawOutputField, true)
|
||||
caseInsensitive := rc != nil && rc.JobContainer != nil && rc.JobContainer.IsEnvironmentCaseInsensitive()
|
||||
var visible []string
|
||||
for _, k := range stepDeclaredEnvKeysInOrder(step) {
|
||||
if !isInternalEnvKey(k, caseInsensitive) {
|
||||
visible = append(visible, k)
|
||||
}
|
||||
}
|
||||
if len(visible) == 0 {
|
||||
return
|
||||
}
|
||||
rawLogger.Infof("env:")
|
||||
envLookup := env
|
||||
if caseInsensitive {
|
||||
envLookup = make(map[string]string, len(env))
|
||||
for k, v := range env {
|
||||
envLookup[strings.ToUpper(k)] = v
|
||||
}
|
||||
}
|
||||
for _, k := range visible {
|
||||
lookupKey := k
|
||||
if caseInsensitive {
|
||||
lookupKey = strings.ToUpper(k)
|
||||
}
|
||||
logKeyedValue(rawLogger, k, envLookup[lookupKey])
|
||||
}
|
||||
}
|
||||
|
||||
// logKeyedValue prints one row per line of value: Gitea stores one log row per line, so an
|
||||
// embedded newline would reach the user as a literal "\n".
|
||||
func logKeyedValue(rawLogger *logrus.Entry, key, value string) {
|
||||
lines := strings.Split(strings.ReplaceAll(value, "\r\n", "\n"), "\n")
|
||||
rawLogger.Infof(" %s: %s", key, lines[0])
|
||||
for _, line := range lines[1:] {
|
||||
rawLogger.Infof(" %s", line)
|
||||
}
|
||||
}
|
||||
|
||||
// isInternalEnvKey matches actions/runner's filtered set of vars that are hidden
|
||||
// from the "Run" header's env: block because they are injected by the runner itself.
|
||||
func isInternalEnvKey(k string, caseInsensitive bool) bool {
|
||||
upper := k
|
||||
if caseInsensitive {
|
||||
upper = strings.ToUpper(k)
|
||||
}
|
||||
switch upper {
|
||||
case "PATH", "HOME", "CI":
|
||||
return true
|
||||
}
|
||||
return strings.HasPrefix(upper, "GITHUB_") ||
|
||||
strings.HasPrefix(upper, "GITEA_") ||
|
||||
strings.HasPrefix(upper, "RUNNER_") ||
|
||||
strings.HasPrefix(upper, "INPUT_")
|
||||
}
|
||||
|
||||
func (sr *stepRun) runScriptGroupTitle(normalizedScript string) string {
|
||||
trimmed := strings.TrimLeft(normalizedScript, " \t\r\n")
|
||||
if idx := strings.IndexAny(trimmed, "\r\n"); idx >= 0 {
|
||||
trimmed = trimmed[:idx]
|
||||
}
|
||||
if trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
if sr.Step != nil {
|
||||
if sr.Step.Name != "" {
|
||||
return sr.Step.Name
|
||||
}
|
||||
return sr.Step.ID
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// stepDeclaredEnvKeysInOrder walks the raw YAML Env mapping so keys are emitted in
|
||||
// the order the workflow author wrote them; step.Environment() decodes into a Go map
|
||||
// and loses ordering.
|
||||
func stepDeclaredEnvKeysInOrder(step *model.Step) []string {
|
||||
if step == nil || step.Env.Kind != yaml.MappingNode {
|
||||
return nil
|
||||
}
|
||||
content := step.Env.Content
|
||||
keys := make([]string, 0, len(content)/2)
|
||||
seen := make(map[string]struct{}, len(content)/2)
|
||||
for i := 0; i+1 < len(content); i += 2 {
|
||||
k := content[i]
|
||||
if k.Kind != yaml.ScalarNode || k.Tag == "!!merge" || k.Value == "<<" {
|
||||
continue
|
||||
}
|
||||
if _, dup := seen[k.Value]; dup {
|
||||
continue
|
||||
}
|
||||
seen[k.Value] = struct{}{}
|
||||
keys = append(keys, k.Value)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
func (sr *stepRun) post() common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (sr *stepRun) getRunContext() *RunContext {
|
||||
return sr.RunContext
|
||||
}
|
||||
|
||||
func (sr *stepRun) getGithubContext(ctx context.Context) *model.GithubContext {
|
||||
return sr.getRunContext().getGithubContext(ctx)
|
||||
}
|
||||
|
||||
func (sr *stepRun) getStepModel() *model.Step {
|
||||
return sr.Step
|
||||
}
|
||||
|
||||
func (sr *stepRun) getEnv() *map[string]string {
|
||||
return &sr.env
|
||||
}
|
||||
|
||||
func (sr *stepRun) getIfExpression(_ context.Context, _ stepStage) string {
|
||||
return sr.Step.If.Value
|
||||
}
|
||||
|
||||
func (sr *stepRun) setupShellCommandExecutor() common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
scriptName, script, err := sr.setupShellCommand(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rc := sr.getRunContext()
|
||||
return rc.JobContainer.Copy(rc.JobContainer.GetActPath(), &container.FileEntry{
|
||||
Name: scriptName,
|
||||
Mode: 0o755,
|
||||
Body: script,
|
||||
})(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
func getScriptName(rc *RunContext, step *model.Step) string {
|
||||
scriptName := step.ID
|
||||
for rcs := rc; rcs.Parent != nil; rcs = rcs.Parent {
|
||||
scriptName = fmt.Sprintf("%s-composite-%s", rcs.Parent.CurrentStep, scriptName)
|
||||
}
|
||||
return "workflow/" + scriptName
|
||||
}
|
||||
|
||||
// TODO: Currently we just ignore top level keys, BUT we should return proper error on them
|
||||
// BUTx2 I leave this for when we rewrite act to use actionlint for workflow validation
|
||||
// so we return proper errors before any execution or spawning containers
|
||||
// it will error anyway with:
|
||||
// OCI runtime exec failed: exec failed: container_linux.go:380: starting container process caused: exec: "${{": executable file not found in $PATH: unknown
|
||||
func (sr *stepRun) setupShellCommand(ctx context.Context) (name, script string, err error) {
|
||||
logger := common.Logger(ctx)
|
||||
sr.setupShell(ctx)
|
||||
sr.setupWorkingDirectory(ctx)
|
||||
|
||||
step := sr.Step
|
||||
|
||||
script = sr.RunContext.NewStepExpressionEvaluator(ctx, sr).Interpolate(ctx, step.Run)
|
||||
sr.interpolatedScript = script
|
||||
|
||||
scCmd := step.ShellCommand()
|
||||
sr.shellCommand = scCmd
|
||||
|
||||
name = getScriptName(sr.RunContext, step)
|
||||
|
||||
// Reference: https://github.com/actions/runner/blob/8109c962f09d9acc473d92c595ff43afceddb347/src/Runner.Worker/Handlers/ScriptHandlerHelpers.cs#L47-L64
|
||||
// Reference: https://github.com/actions/runner/blob/8109c962f09d9acc473d92c595ff43afceddb347/src/Runner.Worker/Handlers/ScriptHandlerHelpers.cs#L19-L27
|
||||
runPrepend := ""
|
||||
runAppend := ""
|
||||
switch step.Shell {
|
||||
case "bash", "sh":
|
||||
name += ".sh"
|
||||
case "pwsh", "powershell":
|
||||
name += ".ps1"
|
||||
runPrepend = "$ErrorActionPreference = 'stop'"
|
||||
runAppend = "if ((Test-Path -LiteralPath variable:/LASTEXITCODE)) { exit $LASTEXITCODE }"
|
||||
case "cmd":
|
||||
name += ".cmd"
|
||||
runPrepend = "@echo off"
|
||||
case "python":
|
||||
name += ".py"
|
||||
}
|
||||
|
||||
script = fmt.Sprintf("%s\n%s\n%s", runPrepend, script, runAppend)
|
||||
|
||||
if !strings.Contains(script, "::add-mask::") && !sr.RunContext.Config.InsecureSecrets {
|
||||
logger.Debugf("Wrote command \n%s\n to '%s'", script, name)
|
||||
} else {
|
||||
logger.Debugf("Wrote add-mask command to '%s'", name)
|
||||
}
|
||||
|
||||
rc := sr.getRunContext()
|
||||
scriptPath := fmt.Sprintf("%s/%s", rc.JobContainer.GetActPath(), name)
|
||||
sr.cmdline = strings.Replace(scCmd, `{0}`, scriptPath, 1)
|
||||
sr.cmd, err = shellquote.Split(sr.cmdline)
|
||||
|
||||
return name, script, err
|
||||
}
|
||||
|
||||
type localEnv struct {
|
||||
env map[string]string
|
||||
}
|
||||
|
||||
func (l *localEnv) Getenv(name string) string {
|
||||
if runtime.GOOS == "windows" {
|
||||
for k, v := range l.env {
|
||||
if strings.EqualFold(name, k) {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
return l.env[name]
|
||||
}
|
||||
|
||||
func (sr *stepRun) setupShell(ctx context.Context) {
|
||||
rc := sr.RunContext
|
||||
step := sr.Step
|
||||
|
||||
if step.Shell == "" {
|
||||
step.Shell = rc.Run.Job().Defaults.Run.Shell
|
||||
}
|
||||
|
||||
step.Shell = rc.NewExpressionEvaluator(ctx).Interpolate(ctx, step.Shell)
|
||||
|
||||
if step.Shell == "" {
|
||||
step.Shell = rc.Run.Workflow.Defaults.Run.Shell
|
||||
}
|
||||
|
||||
if step.Shell == "" {
|
||||
if _, ok := rc.JobContainer.(*container.HostEnvironment); ok {
|
||||
shellWithFallback := []string{"bash", "sh"}
|
||||
// Don't use bash on windows by default, if not using a docker container
|
||||
if runtime.GOOS == "windows" {
|
||||
shellWithFallback = []string{"pwsh", "powershell"}
|
||||
}
|
||||
step.Shell = shellWithFallback[0]
|
||||
lenv := &localEnv{env: map[string]string{}}
|
||||
maps.Copy(lenv.env, sr.env)
|
||||
sr.getRunContext().ApplyExtraPath(ctx, &lenv.env)
|
||||
_, err := lookpath.LookPath2(shellWithFallback[0], lenv)
|
||||
if err != nil {
|
||||
step.Shell = shellWithFallback[1]
|
||||
}
|
||||
} else if containerImage := rc.containerImage(ctx); containerImage != "" {
|
||||
// Currently only linux containers are supported, use sh by default like actions/runner
|
||||
step.Shell = "sh"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (sr *stepRun) setupWorkingDirectory(ctx context.Context) {
|
||||
rc := sr.RunContext
|
||||
step := sr.Step
|
||||
var workingdirectory string
|
||||
|
||||
if step.WorkingDirectory == "" {
|
||||
workingdirectory = rc.Run.Job().Defaults.Run.WorkingDirectory
|
||||
} else {
|
||||
workingdirectory = step.WorkingDirectory
|
||||
}
|
||||
|
||||
// jobs can receive context values, so we interpolate
|
||||
workingdirectory = rc.NewExpressionEvaluator(ctx).Interpolate(ctx, workingdirectory)
|
||||
|
||||
// but top level keys in workflow file like `defaults` or `env` can't
|
||||
if workingdirectory == "" {
|
||||
workingdirectory = rc.Run.Workflow.Defaults.Run.WorkingDirectory
|
||||
}
|
||||
sr.WorkingDirectory = workingdirectory
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2026 The nektos/act Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package runner
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.com/gitea/runner/internal/act/common"
|
||||
|
||||
"gitea.dev/actionslib/pkg/model"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
yaml "go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
func TestRunScriptGroupTitle(t *testing.T) {
|
||||
sr := &stepRun{Step: &model.Step{Name: "Build"}}
|
||||
assert.Equal(t, "make build", sr.runScriptGroupTitle("make build"))
|
||||
assert.Equal(t, "echo one", sr.runScriptGroupTitle(" \techo one\necho two"))
|
||||
assert.Equal(t, "Build", sr.runScriptGroupTitle(""))
|
||||
|
||||
sr = &stepRun{Step: &model.Step{ID: "s1"}}
|
||||
assert.Equal(t, "s1", sr.runScriptGroupTitle("\n \n"))
|
||||
}
|
||||
|
||||
func TestStepDeclaredEnvOrderPreservesYAML(t *testing.T) {
|
||||
raw := `id: s1
|
||||
run: "echo 1"
|
||||
env:
|
||||
GITHUB_TOKEN: tok
|
||||
PATH: /custom/bin
|
||||
MY_VAR: hello
|
||||
`
|
||||
var step model.Step
|
||||
require.NoError(t, yaml.Unmarshal([]byte(raw), &step))
|
||||
assert.Equal(t, []string{"GITHUB_TOKEN", "PATH", "MY_VAR"}, stepDeclaredEnvKeysInOrder(&step))
|
||||
}
|
||||
|
||||
func TestStepDeclaredEnvKeysInOrderEmpty(t *testing.T) {
|
||||
assert.Nil(t, stepDeclaredEnvKeysInOrder(nil))
|
||||
assert.Empty(t, stepDeclaredEnvKeysInOrder(&model.Step{}))
|
||||
}
|
||||
|
||||
func TestStepDeclaredEnvKeysIgnoreYAMLMergeKey(t *testing.T) {
|
||||
doc := `
|
||||
common: &common
|
||||
COMMON_A: a
|
||||
COMMON_B: b
|
||||
step:
|
||||
env:
|
||||
LOCAL_BEFORE: before
|
||||
<<: *common
|
||||
COMMON_B: overridden
|
||||
LOCAL_AFTER: after
|
||||
`
|
||||
var root struct {
|
||||
Step model.Step `yaml:"step"`
|
||||
}
|
||||
require.NoError(t, yaml.Unmarshal([]byte(doc), &root))
|
||||
|
||||
keys := stepDeclaredEnvKeysInOrder(&root.Step)
|
||||
assert.Equal(t, []string{"LOCAL_BEFORE", "COMMON_B", "LOCAL_AFTER"}, keys)
|
||||
}
|
||||
|
||||
func TestPrintRunScriptActionDetailsGolden(t *testing.T) {
|
||||
raw := `id: s1
|
||||
name: Build
|
||||
run: |
|
||||
echo one
|
||||
echo two
|
||||
shell: pwsh
|
||||
env:
|
||||
PATH_PREFIX: /custom/bin
|
||||
GITHUB_TOKEN: tok
|
||||
GREETING: hello
|
||||
`
|
||||
var step model.Step
|
||||
require.NoError(t, yaml.Unmarshal([]byte(raw), &step))
|
||||
|
||||
buf := &bytes.Buffer{}
|
||||
logger := logrus.New()
|
||||
logger.SetOutput(buf)
|
||||
logger.SetLevel(logrus.InfoLevel)
|
||||
logger.SetFormatter(&jobLogFormatter{color: cyan})
|
||||
entry := logger.WithFields(logrus.Fields{"job": "j1"})
|
||||
ctx := common.WithLogger(context.Background(), entry)
|
||||
|
||||
sr := &stepRun{
|
||||
Step: &step,
|
||||
RunContext: &RunContext{},
|
||||
shellCommand: "pwsh -command . '{0}'",
|
||||
interpolatedScript: "echo one\necho two\n",
|
||||
env: map[string]string{
|
||||
"PATH_PREFIX": "/custom/bin",
|
||||
"GITHUB_TOKEN": "tok",
|
||||
"GREETING": "hello",
|
||||
},
|
||||
}
|
||||
|
||||
sr.printRunScriptActionDetails(ctx)
|
||||
|
||||
want := strings.Join([]string{
|
||||
"[j1] | ::group::Run echo one",
|
||||
"[j1] | echo one",
|
||||
"[j1] | echo two",
|
||||
"[j1] | shell: pwsh -command . '{0}'",
|
||||
"[j1] | env:",
|
||||
"[j1] | PATH_PREFIX: /custom/bin",
|
||||
"[j1] | GREETING: hello",
|
||||
"[j1] | ::endgroup::",
|
||||
"",
|
||||
}, "\n")
|
||||
assert.Equal(t, want, buf.String())
|
||||
}
|
||||
|
||||
func TestPrintRunActionHeaderGolden(t *testing.T) {
|
||||
raw := `id: s1
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: "0"
|
||||
token: secret
|
||||
env:
|
||||
CUSTOM: value
|
||||
GITHUB_TOKEN: tok
|
||||
`
|
||||
var step model.Step
|
||||
require.NoError(t, yaml.Unmarshal([]byte(raw), &step))
|
||||
|
||||
buf := &bytes.Buffer{}
|
||||
logger := logrus.New()
|
||||
logger.SetOutput(buf)
|
||||
logger.SetLevel(logrus.InfoLevel)
|
||||
logger.SetFormatter(&jobLogFormatter{color: cyan})
|
||||
entry := logger.WithFields(logrus.Fields{"job": "j1"})
|
||||
ctx := common.WithLogger(context.Background(), entry)
|
||||
|
||||
printRunActionHeader(ctx, &step, map[string]string{"CUSTOM": "value", "GITHUB_TOKEN": "tok"}, &RunContext{})
|
||||
|
||||
want := strings.Join([]string{
|
||||
"[j1] | ::group::Run actions/checkout@v4",
|
||||
"[j1] | with:",
|
||||
"[j1] | fetch-depth: 0",
|
||||
"[j1] | token: secret",
|
||||
"[j1] | env:",
|
||||
"[j1] | CUSTOM: value",
|
||||
"",
|
||||
}, "\n")
|
||||
assert.Equal(t, want, buf.String())
|
||||
}
|
||||
|
||||
func TestIsInternalEnvKey(t *testing.T) {
|
||||
for _, k := range []string{"PATH", "HOME", "CI", "GITHUB_TOKEN", "GITEA_ACTIONS", "RUNNER_OS", "INPUT_FOO"} {
|
||||
assert.True(t, isInternalEnvKey(k, false), k)
|
||||
}
|
||||
for _, k := range []string{"PATH_PREFIX", "MY_VAR", "GREETING", "HOMEPAGE"} {
|
||||
assert.False(t, isInternalEnvKey(k, false), k)
|
||||
}
|
||||
assert.True(t, isInternalEnvKey("path", true))
|
||||
assert.False(t, isInternalEnvKey("path", false))
|
||||
}
|
||||
|
||||
func TestPrintColoredScriptLineCyan(t *testing.T) {
|
||||
f := &jobLogFormatter{color: cyan}
|
||||
entry := &logrus.Entry{
|
||||
Level: logrus.InfoLevel,
|
||||
Message: "echo one",
|
||||
Data: logrus.Fields{
|
||||
"job": "j1",
|
||||
rawOutputField: true,
|
||||
scriptLineCyanField: true,
|
||||
},
|
||||
}
|
||||
buf := &bytes.Buffer{}
|
||||
f.printColored(buf, entry)
|
||||
assert.Equal(t, "\x1b[36m|\x1b[0m \x1b[36;1mecho one\x1b[0m", buf.String())
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2022 The nektos/act Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package runner
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"gitea.com/gitea/runner/internal/act/container"
|
||||
|
||||
"gitea.dev/actionslib/pkg/model"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
func TestStepRun(t *testing.T) {
|
||||
cm := &containerMock{}
|
||||
fileEntry := &container.FileEntry{
|
||||
Name: "workflow/1.sh",
|
||||
Mode: 0o755,
|
||||
Body: "\ncmd\n",
|
||||
}
|
||||
|
||||
sr := &stepRun{
|
||||
RunContext: &RunContext{
|
||||
StepResults: map[string]*model.StepResult{},
|
||||
ExprEval: &expressionEvaluator{},
|
||||
Config: &Config{},
|
||||
Run: &model.Run{
|
||||
JobID: "1",
|
||||
Workflow: &model.Workflow{
|
||||
Jobs: map[string]*model.Job{
|
||||
"1": {
|
||||
Defaults: model.Defaults{
|
||||
Run: model.RunDefaults{
|
||||
Shell: "bash",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
JobContainer: cm,
|
||||
},
|
||||
Step: &model.Step{
|
||||
ID: "1",
|
||||
Run: "cmd",
|
||||
WorkingDirectory: "workdir",
|
||||
},
|
||||
}
|
||||
|
||||
cm.On("Copy", "/var/run/act", []*container.FileEntry{fileEntry}).Return(func(ctx context.Context) error {
|
||||
return nil
|
||||
})
|
||||
cm.On("Exec", []string{"bash", "--noprofile", "--norc", "-e", "-o", "pipefail", "/var/run/act/workflow/1.sh"}, mock.AnythingOfType("map[string]string"), "", "workdir").Return(func(ctx context.Context) error {
|
||||
return nil
|
||||
})
|
||||
|
||||
cm.On("Copy", "/var/run/act", mock.AnythingOfType("[]*container.FileEntry")).Return(func(ctx context.Context) error {
|
||||
return nil
|
||||
})
|
||||
|
||||
cm.On("UpdateFromEnv", "/var/run/act/workflow/envs.txt", mock.AnythingOfType("*map[string]string")).Return(func(ctx context.Context) error {
|
||||
return nil
|
||||
})
|
||||
|
||||
cm.On("UpdateFromEnv", "/var/run/act/workflow/statecmd.txt", mock.AnythingOfType("*map[string]string")).Return(func(ctx context.Context) error {
|
||||
return nil
|
||||
})
|
||||
|
||||
cm.On("UpdateFromEnv", "/var/run/act/workflow/outputcmd.txt", mock.AnythingOfType("*map[string]string")).Return(func(ctx context.Context) error {
|
||||
return nil
|
||||
})
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
cm.On("GetContainerArchive", ctx, "/var/run/act/workflow/pathcmd.txt").Return(io.NopCloser(&bytes.Buffer{}), nil)
|
||||
|
||||
err := sr.main()(ctx)
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
cm.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestStepRunPrePost(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
sr := &stepRun{}
|
||||
|
||||
err := sr.pre()(ctx)
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
err = sr.post()(ctx)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
@@ -0,0 +1,403 @@
|
||||
// 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"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"gitea.com/gitea/runner/internal/act/common"
|
||||
|
||||
"gitea.dev/actionslib/pkg/model"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
yaml "go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
func TestMergeIntoMap(t *testing.T) {
|
||||
table := []struct {
|
||||
name string
|
||||
target map[string]string
|
||||
maps []map[string]string
|
||||
expected map[string]string
|
||||
}{
|
||||
{
|
||||
name: "testEmptyMap",
|
||||
target: map[string]string{},
|
||||
maps: []map[string]string{},
|
||||
expected: map[string]string{},
|
||||
},
|
||||
{
|
||||
name: "testMergeIntoEmptyMap",
|
||||
target: map[string]string{},
|
||||
maps: []map[string]string{
|
||||
{
|
||||
"key1": "value1",
|
||||
"key2": "value2",
|
||||
}, {
|
||||
"key2": "overridden",
|
||||
"key3": "value3",
|
||||
},
|
||||
},
|
||||
expected: map[string]string{
|
||||
"key1": "value1",
|
||||
"key2": "overridden",
|
||||
"key3": "value3",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "testMergeIntoExistingMap",
|
||||
target: map[string]string{
|
||||
"key1": "value1",
|
||||
"key2": "value2",
|
||||
},
|
||||
maps: []map[string]string{
|
||||
{
|
||||
"key1": "overridden",
|
||||
},
|
||||
},
|
||||
expected: map[string]string{
|
||||
"key1": "overridden",
|
||||
"key2": "value2",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range table {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
mergeIntoMapCaseSensitive(tt.target, tt.maps...)
|
||||
assert.Equal(t, tt.expected, tt.target)
|
||||
mergeIntoMapCaseInsensitive(tt.target, tt.maps...)
|
||||
assert.Equal(t, tt.expected, tt.target)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type stepMock struct {
|
||||
mock.Mock
|
||||
step
|
||||
}
|
||||
|
||||
func (sm *stepMock) pre() common.Executor {
|
||||
args := sm.Called()
|
||||
return args.Get(0).(func(context.Context) error)
|
||||
}
|
||||
|
||||
func (sm *stepMock) main() common.Executor {
|
||||
args := sm.Called()
|
||||
return args.Get(0).(func(context.Context) error)
|
||||
}
|
||||
|
||||
func (sm *stepMock) post() common.Executor {
|
||||
args := sm.Called()
|
||||
return args.Get(0).(func(context.Context) error)
|
||||
}
|
||||
|
||||
func (sm *stepMock) getRunContext() *RunContext {
|
||||
args := sm.Called()
|
||||
return args.Get(0).(*RunContext)
|
||||
}
|
||||
|
||||
func (sm *stepMock) getGithubContext(ctx context.Context) *model.GithubContext {
|
||||
args := sm.Called()
|
||||
return args.Get(0).(*RunContext).getGithubContext(ctx)
|
||||
}
|
||||
|
||||
func (sm *stepMock) getStepModel() *model.Step {
|
||||
args := sm.Called()
|
||||
return args.Get(0).(*model.Step)
|
||||
}
|
||||
|
||||
func (sm *stepMock) getEnv() *map[string]string {
|
||||
args := sm.Called()
|
||||
return args.Get(0).(*map[string]string)
|
||||
}
|
||||
|
||||
func TestSetupEnv(t *testing.T) {
|
||||
cm := &containerMock{}
|
||||
sm := &stepMock{}
|
||||
|
||||
rc := &RunContext{
|
||||
Config: &Config{
|
||||
Env: map[string]string{
|
||||
"GITHUB_RUN_ID": "runId",
|
||||
},
|
||||
},
|
||||
Run: &model.Run{
|
||||
JobID: "1",
|
||||
Workflow: &model.Workflow{
|
||||
Jobs: map[string]*model.Job{
|
||||
"1": {
|
||||
Env: yaml.Node{
|
||||
Value: "JOB_KEY: jobvalue",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Env: map[string]string{
|
||||
"RC_KEY": "rcvalue",
|
||||
},
|
||||
JobContainer: cm,
|
||||
}
|
||||
step := &model.Step{
|
||||
With: map[string]string{
|
||||
"STEP_WITH": "with-value",
|
||||
},
|
||||
}
|
||||
env := map[string]string{}
|
||||
|
||||
sm.On("getRunContext").Return(rc)
|
||||
sm.On("getGithubContext").Return(rc)
|
||||
sm.On("getStepModel").Return(step)
|
||||
sm.On("getEnv").Return(&env)
|
||||
|
||||
err := setupEnv(context.Background(), sm)
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
// These are commit or system specific
|
||||
delete((env), "GITHUB_REF")
|
||||
delete((env), "GITHUB_REF_NAME")
|
||||
delete((env), "GITHUB_REF_TYPE")
|
||||
delete((env), "GITHUB_SHA")
|
||||
delete((env), "GITHUB_WORKSPACE")
|
||||
delete((env), "GITHUB_REPOSITORY")
|
||||
delete((env), "GITHUB_REPOSITORY_OWNER")
|
||||
delete((env), "GITHUB_ACTOR")
|
||||
// Host-dependent, asserted in TestRunContextWithGithubEnvRunnerValues instead.
|
||||
delete((env), "RUNNER_NAME")
|
||||
delete((env), "RUNNER_WORKSPACE")
|
||||
|
||||
assert.Equal(t, map[string]string{
|
||||
"ACT": "true",
|
||||
"ACT_SKIP_CHECKOUT": "true",
|
||||
"CI": "true",
|
||||
"GITHUB_ACTION": "",
|
||||
"GITHUB_ACTIONS": "true",
|
||||
"GITHUB_ACTION_PATH": "",
|
||||
"GITHUB_ACTION_REF": "",
|
||||
"GITHUB_ACTION_REPOSITORY": "",
|
||||
"GITHUB_API_URL": "https:///api/v1", // Gitea uses api/v1 (upstream GitHub: api/v3)
|
||||
"GITHUB_BASE_REF": "",
|
||||
"GITHUB_EVENT_NAME": "",
|
||||
"GITHUB_EVENT_PATH": "/var/run/act/workflow/event.json",
|
||||
"GITHUB_GRAPHQL_URL": "",
|
||||
"GITHUB_HEAD_REF": "",
|
||||
"GITHUB_JOB": "1",
|
||||
"GITHUB_RETENTION_DAYS": "0",
|
||||
"GITHUB_RUN_ATTEMPT": "",
|
||||
"GITHUB_RUN_ID": "runId",
|
||||
"GITHUB_RUN_NUMBER": "1",
|
||||
"GITHUB_SERVER_URL": "https://",
|
||||
"GITHUB_WORKFLOW": "",
|
||||
"INPUT_STEP_WITH": "with-value",
|
||||
"RC_KEY": "rcvalue",
|
||||
"RUNNER_ENVIRONMENT": "self-hosted",
|
||||
"RUNNER_PERFLOG": "/dev/null",
|
||||
"RUNNER_TRACKING_ID": "",
|
||||
}, env)
|
||||
|
||||
cm.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestIsStepEnabled(t *testing.T) {
|
||||
createTestStep := func(t *testing.T, input string) step {
|
||||
var step *model.Step
|
||||
err := yaml.Unmarshal([]byte(input), &step)
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
return &stepRun{
|
||||
RunContext: &RunContext{
|
||||
Config: &Config{
|
||||
Workdir: ".",
|
||||
Platforms: map[string]string{
|
||||
"ubuntu-latest": "ubuntu-latest",
|
||||
},
|
||||
},
|
||||
StepResults: map[string]*model.StepResult{},
|
||||
Env: map[string]string{},
|
||||
Run: &model.Run{
|
||||
JobID: "job1",
|
||||
Workflow: &model.Workflow{
|
||||
Name: "workflow1",
|
||||
Jobs: map[string]*model.Job{
|
||||
"job1": createJob(t, `runs-on: ubuntu-latest`, ""),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Step: step,
|
||||
}
|
||||
}
|
||||
|
||||
log.SetLevel(log.DebugLevel)
|
||||
assertObject := assert.New(t)
|
||||
|
||||
// success()
|
||||
step := createTestStep(t, "if: success()")
|
||||
assertObject.True(isStepEnabled(context.Background(), step.getIfExpression(context.Background(), stepStageMain), step, stepStageMain))
|
||||
|
||||
step = createTestStep(t, "if: success()")
|
||||
step.getRunContext().StepResults["a"] = &model.StepResult{
|
||||
Conclusion: model.StepStatusSuccess,
|
||||
}
|
||||
assertObject.True(isStepEnabled(context.Background(), step.getStepModel().If.Value, step, stepStageMain))
|
||||
|
||||
step = createTestStep(t, "if: success()")
|
||||
step.getRunContext().StepResults["a"] = &model.StepResult{
|
||||
Conclusion: model.StepStatusFailure,
|
||||
}
|
||||
assertObject.False(isStepEnabled(context.Background(), step.getStepModel().If.Value, step, stepStageMain))
|
||||
|
||||
// failure()
|
||||
step = createTestStep(t, "if: failure()")
|
||||
assertObject.False(isStepEnabled(context.Background(), step.getStepModel().If.Value, step, stepStageMain))
|
||||
|
||||
step = createTestStep(t, "if: failure()")
|
||||
step.getRunContext().StepResults["a"] = &model.StepResult{
|
||||
Conclusion: model.StepStatusSuccess,
|
||||
}
|
||||
assertObject.False(isStepEnabled(context.Background(), step.getStepModel().If.Value, step, stepStageMain))
|
||||
|
||||
step = createTestStep(t, "if: failure()")
|
||||
step.getRunContext().StepResults["a"] = &model.StepResult{
|
||||
Conclusion: model.StepStatusFailure,
|
||||
}
|
||||
assertObject.True(isStepEnabled(context.Background(), step.getStepModel().If.Value, step, stepStageMain))
|
||||
|
||||
// always()
|
||||
step = createTestStep(t, "if: always()")
|
||||
assertObject.True(isStepEnabled(context.Background(), step.getStepModel().If.Value, step, stepStageMain))
|
||||
|
||||
step = createTestStep(t, "if: always()")
|
||||
step.getRunContext().StepResults["a"] = &model.StepResult{
|
||||
Conclusion: model.StepStatusSuccess,
|
||||
}
|
||||
assertObject.True(isStepEnabled(context.Background(), step.getStepModel().If.Value, step, stepStageMain))
|
||||
|
||||
step = createTestStep(t, "if: always()")
|
||||
step.getRunContext().StepResults["a"] = &model.StepResult{
|
||||
Conclusion: model.StepStatusFailure,
|
||||
}
|
||||
assertObject.True(isStepEnabled(context.Background(), step.getStepModel().If.Value, step, stepStageMain))
|
||||
}
|
||||
|
||||
func TestIsContinueOnError(t *testing.T) {
|
||||
createTestStep := func(t *testing.T, input string) step {
|
||||
var step *model.Step
|
||||
err := yaml.Unmarshal([]byte(input), &step)
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
return &stepRun{
|
||||
RunContext: &RunContext{
|
||||
Config: &Config{
|
||||
Workdir: ".",
|
||||
Platforms: map[string]string{
|
||||
"ubuntu-latest": "ubuntu-latest",
|
||||
},
|
||||
},
|
||||
StepResults: map[string]*model.StepResult{},
|
||||
Env: map[string]string{},
|
||||
Run: &model.Run{
|
||||
JobID: "job1",
|
||||
Workflow: &model.Workflow{
|
||||
Name: "workflow1",
|
||||
Jobs: map[string]*model.Job{
|
||||
"job1": createJob(t, `runs-on: ubuntu-latest`, ""),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Step: step,
|
||||
}
|
||||
}
|
||||
|
||||
log.SetLevel(log.DebugLevel)
|
||||
assertObject := assert.New(t)
|
||||
|
||||
// absent
|
||||
step := createTestStep(t, "name: test")
|
||||
continueOnError, err := isContinueOnError(context.Background(), step.getStepModel().RawContinueOnError, step, stepStageMain)
|
||||
assertObject.False(continueOnError)
|
||||
assertObject.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
// explcit true
|
||||
step = createTestStep(t, "continue-on-error: true")
|
||||
continueOnError, err = isContinueOnError(context.Background(), step.getStepModel().RawContinueOnError, step, stepStageMain)
|
||||
assertObject.True(continueOnError)
|
||||
assertObject.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
// explicit false
|
||||
step = createTestStep(t, "continue-on-error: false")
|
||||
continueOnError, err = isContinueOnError(context.Background(), step.getStepModel().RawContinueOnError, step, stepStageMain)
|
||||
assertObject.False(continueOnError)
|
||||
assertObject.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
// expression true
|
||||
step = createTestStep(t, "continue-on-error: ${{ 'test' == 'test' }}")
|
||||
continueOnError, err = isContinueOnError(context.Background(), step.getStepModel().RawContinueOnError, step, stepStageMain)
|
||||
assertObject.True(continueOnError)
|
||||
assertObject.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
// expression false
|
||||
step = createTestStep(t, "continue-on-error: ${{ 'test' != 'test' }}")
|
||||
continueOnError, err = isContinueOnError(context.Background(), step.getStepModel().RawContinueOnError, step, stepStageMain)
|
||||
assertObject.False(continueOnError)
|
||||
assertObject.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
// expression parse error
|
||||
step = createTestStep(t, "continue-on-error: ${{ 'test' != test }}")
|
||||
continueOnError, err = isContinueOnError(context.Background(), step.getStepModel().RawContinueOnError, step, stepStageMain)
|
||||
assertObject.False(continueOnError)
|
||||
assertObject.Error(err)
|
||||
}
|
||||
|
||||
// A refused ::set-env::/::add-path:: records a job-scoped error. When the step that
|
||||
// produced it also fails on its own, the refusal must be cleared at the step boundary, so
|
||||
// it fails only that step and never leaks onto a later step that runs anyway (if: always()).
|
||||
func TestRunStepExecutorDoesNotLeakRefusalToNextStep(t *testing.T) {
|
||||
cm := &containerMock{}
|
||||
noop := func(context.Context) error { return nil }
|
||||
cm.On("Copy", mock.Anything, mock.Anything).Return(noop)
|
||||
cm.On("UpdateFromEnv", mock.Anything, mock.Anything).Return(noop)
|
||||
|
||||
rc := &RunContext{
|
||||
Config: &Config{Env: map[string]string{}},
|
||||
Run: &model.Run{
|
||||
JobID: "1",
|
||||
Workflow: &model.Workflow{Jobs: map[string]*model.Job{"1": {}}},
|
||||
},
|
||||
Env: map[string]string{},
|
||||
StepResults: map[string]*model.StepResult{},
|
||||
JobContainer: cm,
|
||||
}
|
||||
rc.ExprEval = rc.NewExpressionEvaluator(context.Background())
|
||||
// Dryrun skips reading the path file back from the (mocked) container.
|
||||
ctx := common.WithDryrun(context.Background(), true)
|
||||
|
||||
// A refusal parsed out of the job container's own output belongs to no step, so the
|
||||
// first step must not be failed by it.
|
||||
rc.commandHandler(ctx)("::set-env name=setup::y\n")
|
||||
stepSetup := &stepRun{RunContext: rc, Step: &model.Step{ID: "setup"}, env: map[string]string{}}
|
||||
require.NoError(t, runStepExecutor(stepSetup, stepStageMain, func(context.Context) error { return nil })(ctx))
|
||||
|
||||
// Step A refuses a ::set-env:: and then fails on its own.
|
||||
stepA := &stepRun{RunContext: rc, Step: &model.Step{ID: "a"}, env: map[string]string{}}
|
||||
errA := runStepExecutor(stepA, stepStageMain, func(context.Context) error {
|
||||
rc.commandHandler(ctx)("::set-env name=x::y\n")
|
||||
return errors.New("boom")
|
||||
})(ctx)
|
||||
// The step fails with its own error, not the refusal.
|
||||
require.ErrorContains(t, errA, "boom")
|
||||
|
||||
// Step B runs despite step A's failure (if: always()) and issues no unsecure command;
|
||||
// it must not inherit step A's refusal.
|
||||
stepB := &stepRun{RunContext: rc, Step: &model.Step{ID: "b", If: yaml.Node{Value: "always()"}}, env: map[string]string{}}
|
||||
errB := runStepExecutor(stepB, stepStageMain, func(context.Context) error { return nil })(ctx)
|
||||
require.NoError(t, errB)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
name: local-reusable-workflow
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
string_required:
|
||||
required: true
|
||||
type: string
|
||||
bool_required:
|
||||
required: true
|
||||
type: boolean
|
||||
number_required:
|
||||
required: true
|
||||
type: number
|
||||
secrets:
|
||||
secret:
|
||||
required: true
|
||||
outputs:
|
||||
output:
|
||||
value: ${{ jobs.reusable.outputs.output }}
|
||||
|
||||
jobs:
|
||||
reusable:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
output: ${{ steps.gen.outputs.output }}
|
||||
steps:
|
||||
- name: check inputs and secret arrived
|
||||
run: |
|
||||
[ "${{ inputs.string_required }}" = "string" ]
|
||||
[ "${{ inputs.bool_required }}" = "true" ]
|
||||
[ "${{ inputs.number_required }}" = "1" ]
|
||||
[ "${{ secrets.secret }}" = "keep_it_private" ]
|
||||
- id: gen
|
||||
run: echo "output=${{ inputs.string_required }}" >> $GITHUB_OUTPUT
|
||||
@@ -0,0 +1,29 @@
|
||||
on: push
|
||||
jobs:
|
||||
_:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ACTIONS_ALLOW_UNSECURE_COMMANDS: 'true'
|
||||
MYGLOBALENV3: myglobalval3
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: |
|
||||
echo MYGLOBALENV1=myglobalval1 > $GITHUB_ENV
|
||||
echo "::set-env name=MYGLOBALENV2::myglobalval2"
|
||||
- uses: ./actions/script
|
||||
with:
|
||||
main: |
|
||||
env
|
||||
[[ "$MYGLOBALENV1" = "${{ env.MYGLOBALENV1 }}" ]]
|
||||
[[ "$MYGLOBALENV1" = "${{ env.MYGLOBALENV1ALIAS }}" ]]
|
||||
[[ "$MYGLOBALENV1" = "$MYGLOBALENV1ALIAS" ]]
|
||||
[[ "$MYGLOBALENV2" = "${{ env.MYGLOBALENV2 }}" ]]
|
||||
[[ "$MYGLOBALENV2" = "${{ env.MYGLOBALENV2ALIAS }}" ]]
|
||||
[[ "$MYGLOBALENV2" = "$MYGLOBALENV2ALIAS" ]]
|
||||
[[ "$MYGLOBALENV3" = "${{ env.MYGLOBALENV3 }}" ]]
|
||||
[[ "$MYGLOBALENV3" = "${{ env.MYGLOBALENV3ALIAS }}" ]]
|
||||
[[ "$MYGLOBALENV3" = "$MYGLOBALENV3ALIAS" ]]
|
||||
env:
|
||||
MYGLOBALENV1ALIAS: ${{ env.MYGLOBALENV1 }}
|
||||
MYGLOBALENV2ALIAS: ${{ env.MYGLOBALENV2 }}
|
||||
MYGLOBALENV3ALIAS: ${{ env.MYGLOBALENV3 }}
|
||||
@@ -0,0 +1,31 @@
|
||||
on: push
|
||||
jobs:
|
||||
# State saved in main (via the $GITHUB_STATE file and the ::save-state command) must surface
|
||||
# as $STATE_* in the action's post step.
|
||||
_:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ./actions/script
|
||||
with:
|
||||
main: |
|
||||
echo mystate2=mystateval > $GITHUB_STATE
|
||||
echo "::save-state name=mystate3::mystateval"
|
||||
post: |
|
||||
[ "$STATE_mystate2" = "mystateval" ]
|
||||
[ "$STATE_mystate3" = "mystateval" ]
|
||||
# State must be isolated per action instance even when two steps use the same action.
|
||||
test-id-collision-bug:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ./actions/script
|
||||
id: script
|
||||
with:
|
||||
main: echo mystate=val1 > $GITHUB_STATE
|
||||
post: '[ "$STATE_mystate" = "val1" ]'
|
||||
- uses: ./actions/script
|
||||
id: pre-script
|
||||
with:
|
||||
main: echo mystate=val2 > $GITHUB_STATE
|
||||
post: '[ "$STATE_mystate" = "val2" ]'
|
||||
@@ -0,0 +1,21 @@
|
||||
name: action1
|
||||
description: action1
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: env.COMPOSITE_OVERRIDE != '1'
|
||||
run: exit 1
|
||||
if: env.COMPOSITE_OVERRIDE != '1'
|
||||
shell: bash
|
||||
- name: env.JOB != '1'
|
||||
run: exit 1
|
||||
if: env.JOB != '1'
|
||||
shell: bash
|
||||
- name: env.GLOBAL != '1'
|
||||
run: exit 1
|
||||
if: env.GLOBAL != '1'
|
||||
shell: bash
|
||||
- uses: ./act-composite-env-test/action2
|
||||
env:
|
||||
COMPOSITE_OVERRIDE: "2"
|
||||
COMPOSITE: "1"
|
||||
@@ -0,0 +1,21 @@
|
||||
name: action2
|
||||
description: actions2
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: env.COMPOSITE_OVERRIDE != '2'
|
||||
run: exit 1
|
||||
if: env.COMPOSITE_OVERRIDE != '2'
|
||||
shell: bash
|
||||
- name: env.COMPOSITE != '1'
|
||||
run: exit 1
|
||||
if: env.COMPOSITE != '1'
|
||||
shell: bash
|
||||
- name: env.JOB != '1'
|
||||
run: exit 1
|
||||
if: env.JOB != '1'
|
||||
shell: bash
|
||||
- name: env.GLOBAL != '1'
|
||||
run: exit 1
|
||||
if: env.GLOBAL != '1'
|
||||
shell: bash
|
||||
@@ -0,0 +1,13 @@
|
||||
on: push
|
||||
env:
|
||||
GLOBAL: "1"
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
JOB: "1"
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: ./act-composite-env-test/action1
|
||||
env:
|
||||
COMPOSITE_OVERRIDE: "1"
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
FROM alpine:3.24
|
||||
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
|
||||
ENTRYPOINT [ "/entrypoint.sh" ]
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
name: 'Test'
|
||||
description: 'Test'
|
||||
runs:
|
||||
using: 'docker'
|
||||
image: 'Dockerfile'
|
||||
Vendored
Executable
+26
@@ -0,0 +1,26 @@
|
||||
#!/bin/sh
|
||||
|
||||
checkEnvVar() {
|
||||
name=$1
|
||||
value=$2
|
||||
allowEmpty=$3
|
||||
|
||||
if [ -z "$value" ]; then
|
||||
echo "Missing environment variable: $name"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$allowEmpty" != "true" ]; then
|
||||
test=$(echo "$value" |cut -f 2 -d "=")
|
||||
if [ -z "$test" ]; then
|
||||
echo "Environment variable is empty: $name"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "$value"
|
||||
}
|
||||
|
||||
checkEnvVar "GITHUB_ACTION" "$(env |grep "GITHUB_ACTION=")" false
|
||||
checkEnvVar "GITHUB_ACTION_REPOSITORY" "$(env |grep "GITHUB_ACTION_REPOSITORY=")" true
|
||||
checkEnvVar "GITHUB_ACTION_REF" "$(env |grep "GITHUB_ACTION_REF=")" true
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
name: 'Test'
|
||||
description: 'Test'
|
||||
runs:
|
||||
using: 'node24'
|
||||
main: 'index.js'
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
function checkEnvVar({ name, allowEmpty }) {
|
||||
if (
|
||||
process.env[name] === undefined ||
|
||||
(allowEmpty === false && process.env[name] === "")
|
||||
) {
|
||||
throw new Error(
|
||||
`${name} is undefined` + (allowEmpty === false ? " or empty" : "")
|
||||
);
|
||||
}
|
||||
console.log(`${name}=${process.env[name]}`);
|
||||
}
|
||||
|
||||
checkEnvVar({ name: "GITHUB_ACTION", allowEmpty: false });
|
||||
checkEnvVar({ name: "GITHUB_ACTION_REPOSITORY", allowEmpty: true /* allows to be empty for local actions */ });
|
||||
checkEnvVar({ name: "GITHUB_ACTION_REF", allowEmpty: true /* allows to be empty for local actions */ });
|
||||
@@ -0,0 +1,11 @@
|
||||
name: actions-with-environment-and-context-tests
|
||||
description: "Actions with environment (env vars) and context (expression) tests"
|
||||
on: push
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: './actions-environment-and-context-tests/js'
|
||||
- uses: './actions-environment-and-context-tests/docker'
|
||||
@@ -0,0 +1 @@
|
||||
FROM ubuntu:26.04
|
||||
@@ -0,0 +1,4 @@
|
||||
name: 'action1'
|
||||
runs:
|
||||
using: 'docker'
|
||||
image: 'Dockerfile'
|
||||
@@ -0,0 +1,13 @@
|
||||
outputs:
|
||||
customoutput:
|
||||
value: my-customoutput-${{ steps.random-color-generator.outputs.SELECTED_COLOR }}
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Set selected color
|
||||
run: echo '::set-output name=SELECTED_COLOR::green'
|
||||
id: random-color-generator
|
||||
shell: bash
|
||||
- name: fail
|
||||
run: exit 1
|
||||
shell: bash
|
||||
@@ -0,0 +1,8 @@
|
||||
# Container image that runs your code
|
||||
FROM node:24-bookworm-slim
|
||||
|
||||
# Copies your code file from your action repository to the filesystem path `/` of the container
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
|
||||
# Code file to execute when the docker container starts up (`entrypoint.sh`)
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
@@ -0,0 +1,15 @@
|
||||
name: 'Hello World'
|
||||
description: 'Greet someone and record the time'
|
||||
inputs:
|
||||
who-to-greet: # id of input
|
||||
description: 'Who to greet'
|
||||
required: true
|
||||
default: 'World'
|
||||
outputs:
|
||||
time: # id of output
|
||||
description: 'The time we greeted you'
|
||||
runs:
|
||||
using: 'docker'
|
||||
image: 'Dockerfile'
|
||||
env:
|
||||
WHOAMI: ${{ inputs.who-to-greet }}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
#!/bin/sh -l
|
||||
|
||||
echo "Hello $1"
|
||||
time=$(date)
|
||||
echo ::set-output name=time::$time
|
||||
echo ::set-output name=whoami::$WHOAMI
|
||||
|
||||
echo "SOMEVAR=$1" >>$GITHUB_ENV
|
||||
@@ -0,0 +1,8 @@
|
||||
# Container image that runs your code
|
||||
FROM node:24-bookworm-slim
|
||||
|
||||
# Copies your code file from your action repository to the filesystem path `/` of the container
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
|
||||
# Code file to execute when the docker container starts up (`entrypoint.sh`)
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
@@ -0,0 +1,17 @@
|
||||
name: 'Hello World'
|
||||
description: 'Greet someone and record the time'
|
||||
inputs:
|
||||
who-to-greet: # id of input
|
||||
description: 'Who to greet'
|
||||
required: true
|
||||
default: 'World'
|
||||
outputs:
|
||||
time: # id of output
|
||||
description: 'The time we greeted you'
|
||||
runs:
|
||||
using: 'docker'
|
||||
image: 'Dockerfile'
|
||||
env:
|
||||
WHOAMI: ${{ inputs.who-to-greet }}
|
||||
args:
|
||||
- ${{ inputs.who-to-greet }}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
#!/bin/sh -l
|
||||
|
||||
echo "Hello $1"
|
||||
time=$(date)
|
||||
echo ::set-output name=time::$time
|
||||
echo ::set-output name=whoami::$WHOAMI
|
||||
|
||||
echo "SOMEVAR=$1" >>$GITHUB_ENV
|
||||
@@ -0,0 +1,16 @@
|
||||
name: docker-url
|
||||
author: nektos
|
||||
description: testing
|
||||
inputs:
|
||||
who-to-greet:
|
||||
description: who to greet
|
||||
required: true
|
||||
default: World
|
||||
runs:
|
||||
using: docker
|
||||
image: docker://node:24-bookworm-slim
|
||||
entrypoint: /bin/sh -c
|
||||
env:
|
||||
TEST: enabled
|
||||
args:
|
||||
- env
|
||||
@@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
package-lock.json
|
||||
@@ -0,0 +1,13 @@
|
||||
name: 'Hello World'
|
||||
description: 'Greet someone and record the time'
|
||||
inputs:
|
||||
who-to-greet: # id of input
|
||||
description: 'Who to greet'
|
||||
required: true
|
||||
default: 'World'
|
||||
outputs:
|
||||
time: # id of output
|
||||
description: 'The time we greeted you'
|
||||
runs:
|
||||
using: 'node24'
|
||||
main: 'index.js'
|
||||
@@ -0,0 +1,14 @@
|
||||
import {appendFileSync, readFileSync} from 'node:fs';
|
||||
|
||||
const nameToGreet = process.env['INPUT_WHO-TO-GREET'] || 'World';
|
||||
console.log(`Hello ${nameToGreet}!`);
|
||||
|
||||
if (process.env.GITHUB_OUTPUT) {
|
||||
appendFileSync(process.env.GITHUB_OUTPUT, `time=${new Date().toTimeString()}\n`);
|
||||
}
|
||||
|
||||
let payload = {};
|
||||
if (process.env.GITHUB_EVENT_PATH) {
|
||||
payload = JSON.parse(readFileSync(process.env.GITHUB_EVENT_PATH, 'utf8'));
|
||||
}
|
||||
console.log(`The event payload: ${JSON.stringify(payload, undefined, 2)}`);
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "node24",
|
||||
"private": true,
|
||||
"type": "module"
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
name: 'script'
|
||||
description: 'Run the shell scripts passed as inputs across the pre/main/post lifecycle'
|
||||
inputs:
|
||||
main:
|
||||
description: 'shell script to run in the main step'
|
||||
required: false
|
||||
default: ''
|
||||
post:
|
||||
description: 'shell script to run in the post step'
|
||||
required: false
|
||||
default: ''
|
||||
runs:
|
||||
using: 'node24'
|
||||
main: 'index.js'
|
||||
post: 'post.js'
|
||||
@@ -0,0 +1,9 @@
|
||||
import {execFileSync} from 'node:child_process';
|
||||
|
||||
// Run the `main` input as a bash script; its stdout (workflow commands like
|
||||
// ::set-output / ::save-state) and $GITHUB_ENV / $GITHUB_STATE writes are
|
||||
// processed by the runner, exactly like the remote script action this replaces.
|
||||
const script = process.env.INPUT_MAIN;
|
||||
if (script) {
|
||||
execFileSync('bash', ['-eo', 'pipefail', '-c', script], {stdio: 'inherit'});
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "script",
|
||||
"private": true,
|
||||
"type": "module"
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import {execFileSync} from 'node:child_process';
|
||||
|
||||
const script = process.env.INPUT_POST;
|
||||
if (script) {
|
||||
execFileSync('bash', ['-eo', 'pipefail', '-c', script], {stdio: 'inherit'});
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
name: basic
|
||||
on: push
|
||||
|
||||
env:
|
||||
TEST: value
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: '[[ "$(pwd)" == "${GITHUB_WORKSPACE}" ]]'
|
||||
- run: echo ${{ env.TEST }} | grep value
|
||||
- run: env
|
||||
- uses: docker://node:24-bookworm-slim
|
||||
with:
|
||||
somekey: ${{ env.TEST }}
|
||||
args: echo ${INPUT_SOMEKEY} | grep somevalue
|
||||
- run: ls
|
||||
- run: echo 'hello world'
|
||||
- run: echo ${GITHUB_SHA} >> $(dirname "${GITHUB_WORKSPACE}")/sha.txt
|
||||
- run: cat $(dirname "${GITHUB_WORKSPACE}")/sha.txt | grep ${GITHUB_SHA}
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [check]
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: ./actions/action1
|
||||
with:
|
||||
args: echo 'build'
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build]
|
||||
steps:
|
||||
- uses: docker://node:24-bookworm-slim
|
||||
with:
|
||||
args: env
|
||||
- uses: docker://node:24-bookworm-slim
|
||||
with:
|
||||
entrypoint: /bin/echo
|
||||
args: ${{github.event_name}}
|
||||
@@ -0,0 +1 @@
|
||||
ref: refs/heads/master
|
||||
@@ -0,0 +1,2 @@
|
||||
[core]
|
||||
bare = true
|
||||
@@ -0,0 +1,8 @@
|
||||
name: checkout
|
||||
on: push
|
||||
|
||||
jobs:
|
||||
checkout:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
name: basic
|
||||
on: push
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ACTIONS_ALLOW_UNSECURE_COMMANDS: 'true'
|
||||
steps:
|
||||
- name: TEST set-env
|
||||
run: echo "::set-env name=foo::bar"
|
||||
- name: TEST set-env (cont.)
|
||||
run: echo $foo | grep bar
|
||||
|
||||
- name: TEST set-output
|
||||
id: set_output
|
||||
run: echo "::set-output name=zoo::zar"
|
||||
|
||||
- run: echo "::add-path::/zip"
|
||||
- run: echo $PATH | grep /zip
|
||||
|
||||
- name: TEST conditional
|
||||
if: steps.set_output.outputs.zoo
|
||||
run: echo "::set-env name=cond_env::foo"
|
||||
- name: TEST conditional (cont.)
|
||||
run: echo $cond_env | grep foo
|
||||
|
||||
- name: TEST debug, warning, error
|
||||
run: |
|
||||
echo "::debug file=app.js,line=100,col=20::Hello debug!"
|
||||
echo "::warning file=app.js,line=100,col=20::Hello warning!"
|
||||
echo "::error file=app.js,line=100,col=30::Hello error!"
|
||||
|
||||
- name: TEST stop-commands
|
||||
run: |
|
||||
echo "::stop-commands::my-end-token"
|
||||
echo "::set-env name=foo::baz"
|
||||
echo $foo | grep bar
|
||||
echo "::my-end-token::"
|
||||
echo "::set-env name=foo::baz"
|
||||
- name: TEST stop-commands (cont.)
|
||||
run: echo $foo | grep baz
|
||||
@@ -0,0 +1,14 @@
|
||||
name: composite-fail-with-output
|
||||
on: push
|
||||
|
||||
jobs:
|
||||
test-for-output:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: ./actions/composite-fail-with-output
|
||||
id: composite-fail-with-output
|
||||
continue-on-error: true
|
||||
- run: |
|
||||
echo ${{steps.composite-fail-with-output.outputs.customoutput}}
|
||||
exit ${{steps.composite-fail-with-output.outputs.customoutput == 'my-customoutput-green' && '0' || '1'}}
|
||||
@@ -0,0 +1,30 @@
|
||||
name: container-hostname
|
||||
on: push
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
jobs:
|
||||
with-hostname:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: node:24-bookworm-slim
|
||||
options: "--hostname my.host.local --user 100:101"
|
||||
steps:
|
||||
- run: |
|
||||
echo "UID: $(id -u)"
|
||||
echo "GID: $(id -g)"
|
||||
echo "HOST: $(uname -n)"
|
||||
[[ "$(id -u)" == "100" ]] && [[ "$(id -g)" == "101" ]] && [[ "$(uname -n)" == "my.host.local" ]]
|
||||
|
||||
default-hostname:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: node:24-bookworm-slim
|
||||
steps:
|
||||
- run: |
|
||||
echo "UID: $(id -u)"
|
||||
echo "GID: $(id -g)"
|
||||
echo "HOST: $(uname -n)"
|
||||
[[ "$(id -u)" == "0" ]] && [[ "$(id -g)" == "0" ]] && [[ $(uname -n) ]] && [[ "$(uname -n)" != "my.host.local" ]]
|
||||
@@ -0,0 +1,19 @@
|
||||
name: Job Container
|
||||
on: push
|
||||
|
||||
jobs:
|
||||
with-volumes:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: node:24-bookworm-slim
|
||||
volumes:
|
||||
- my_docker_volume:/path/to/volume
|
||||
- /path/to/nonexist/directory
|
||||
- /proc/sys/kernel/random/boot_id:/current/boot_id
|
||||
steps:
|
||||
- run: |
|
||||
set -e
|
||||
test -d /path/to/volume
|
||||
test "$(cat /proc/sys/kernel/random/boot_id)" = "$(cat /current/boot_id)"
|
||||
test -d /path/to/nonexist/directory
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
name: defaults-run
|
||||
on:
|
||||
- push
|
||||
defaults:
|
||||
run:
|
||||
shell: sh
|
||||
jobs:
|
||||
without-defaults:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo $SHELL | grep -v bash || exit 1
|
||||
with-defaults:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
working-directory: /tmp
|
||||
steps:
|
||||
- run: |
|
||||
echo $SHELL | grep bash || exit 1
|
||||
[ $(pwd) = /tmp ] || exit 2
|
||||
override-in-step:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
steps:
|
||||
- run: echo $SHELL | grep -v bash || exit 1
|
||||
shell: sh
|
||||
@@ -0,0 +1,18 @@
|
||||
on: push
|
||||
jobs:
|
||||
_:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: |
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- run: exit 1
|
||||
shell: bash
|
||||
if: env.LEAK_ENV != 'val'
|
||||
shell: cp {0} action.yml
|
||||
- uses: ./
|
||||
env:
|
||||
LEAK_ENV: val
|
||||
- run: exit 1
|
||||
if: env.LEAK_ENV == 'val'
|
||||
@@ -0,0 +1,12 @@
|
||||
on: push
|
||||
jobs:
|
||||
_:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: |
|
||||
FROM node:24-bookworm-slim
|
||||
ENV PATH="/opt/texlive/texdir/bin/x86_64-linuxmusl:${PATH}"
|
||||
ENV ORG_PATH="${PATH}"
|
||||
ENTRYPOINT [ "bash", "-c", "echo \"PATH=$PATH\" && echo \"ORG_PATH=$ORG_PATH\" && [[ \"$PATH\" = \"$ORG_PATH\" ]]" ]
|
||||
shell: mv {0} Dockerfile
|
||||
- uses: ./
|
||||
@@ -0,0 +1,10 @@
|
||||
name: "action composite"
|
||||
description: "action composite"
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
# second post action should fail if executed (we do check on the exit code)
|
||||
- uses: ./ensure-post-steps/action-post/
|
||||
with:
|
||||
fail: "true"
|
||||
- uses: ./ensure-post-steps/action-post/
|
||||
@@ -0,0 +1,11 @@
|
||||
name: "action post"
|
||||
description: "action post"
|
||||
inputs:
|
||||
fail:
|
||||
description: "true if this should fail"
|
||||
required: false
|
||||
default: "false"
|
||||
runs:
|
||||
using: node24
|
||||
main: "./main.js"
|
||||
post: "./post.js"
|
||||
@@ -0,0 +1,3 @@
|
||||
if (process.env["INPUT_FAIL"] === "true") {
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
name: test
|
||||
on: push
|
||||
jobs:
|
||||
second-post-step-should-fail:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: ./ensure-post-steps/action-composite/
|
||||
@@ -0,0 +1,79 @@
|
||||
name: env-and-path
|
||||
on: push
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: "Append to $GITHUB_PATH"
|
||||
run: |
|
||||
echo "$HOME/someFolder" >> $GITHUB_PATH
|
||||
- name: "Append some more to $GITHUB_PATH"
|
||||
run: |
|
||||
echo "$HOME/someOtherFolder" >> $GITHUB_PATH
|
||||
- name: "Check PATH"
|
||||
run: |
|
||||
echo "${PATH}"
|
||||
if [[ ! "${PATH}" =~ .*"$HOME/"someOtherFolder.*"$HOME/"someFolder.* ]]; then
|
||||
echo "${PATH} doesn't match .*someOtherFolder.*someFolder.*"
|
||||
exit 1
|
||||
fi
|
||||
- name: "Prepend"
|
||||
run: |
|
||||
if ls | grep -q 'called ls' ; then
|
||||
echo 'ls was overridden already?'
|
||||
exit 2
|
||||
fi
|
||||
path_add=$(mktemp -d)
|
||||
cat > $path_add/ls <<LS
|
||||
#!/bin/sh
|
||||
echo 'called ls'
|
||||
LS
|
||||
chmod +x $path_add/ls
|
||||
echo $path_add >> $GITHUB_PATH
|
||||
- name: "Verify prepend"
|
||||
run: |
|
||||
if ! ls | grep -q 'called ls' ; then
|
||||
echo 'ls was not overridden'
|
||||
exit 2
|
||||
fi
|
||||
- name: "Write single line env to $GITHUB_ENV"
|
||||
run: |
|
||||
echo "KEY=value" >> $GITHUB_ENV
|
||||
- name: "Check single line env"
|
||||
run: |
|
||||
if [[ "${KEY}" != "value" ]]; then
|
||||
echo "${KEY} doesn't == 'value'"
|
||||
exit 1
|
||||
fi
|
||||
- name: "Write single line env with more than one 'equals' signs to $GITHUB_ENV"
|
||||
run: |
|
||||
echo "KEY=value=anothervalue" >> $GITHUB_ENV
|
||||
- name: "Check single line env"
|
||||
run: |
|
||||
if [[ "${KEY}" != "value=anothervalue" ]]; then
|
||||
echo "${KEY} doesn't == 'value=anothervalue'"
|
||||
exit 1
|
||||
fi
|
||||
- name: "Write multiline env to $GITHUB_ENV"
|
||||
run: |
|
||||
echo 'KEY2<<EOF' >> $GITHUB_ENV
|
||||
echo value2 >> $GITHUB_ENV
|
||||
echo 'EOF' >> $GITHUB_ENV
|
||||
- name: "Check multiline line env"
|
||||
run: |
|
||||
if [[ "${KEY2}" != "value2" ]]; then
|
||||
echo "${KEY2} doesn't == 'value'"
|
||||
exit 1
|
||||
fi
|
||||
- name: "Write multiline env with UUID to $GITHUB_ENV"
|
||||
run: |
|
||||
echo 'KEY3<<ghadelimiter_b8273c6d-d535-419a-a010-b0aaac240e36' >> $GITHUB_ENV
|
||||
echo value3 >> $GITHUB_ENV
|
||||
echo 'ghadelimiter_b8273c6d-d535-419a-a010-b0aaac240e36' >> $GITHUB_ENV
|
||||
- name: "Check multiline env with UUID to $GITHUB_ENV"
|
||||
run: |
|
||||
if [[ "${KEY3}" != "value3" ]]; then
|
||||
echo "${KEY3} doesn't == 'value3'"
|
||||
exit 1
|
||||
fi
|
||||
@@ -0,0 +1,13 @@
|
||||
on: push
|
||||
jobs:
|
||||
_:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: |
|
||||
echo "test<<World" > $GITHUB_ENV
|
||||
echo "x=Thats really Weird" >> $GITHUB_ENV
|
||||
echo "World" >> $GITHUB_ENV
|
||||
- if: env.test != 'x=Thats really Weird'
|
||||
run: exit 1
|
||||
- if: env.x == 'Thats really Weird' # This assert is triggered by the broken impl of act
|
||||
run: exit 1
|
||||
@@ -0,0 +1,101 @@
|
||||
name: environment-files
|
||||
on: push
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: "Append to $GITHUB_PATH"
|
||||
run: |
|
||||
echo "$HOME/someFolder" >> $GITHUB_PATH
|
||||
- name: "Append some more to $GITHUB_PATH"
|
||||
run: |
|
||||
echo "$HOME/someOtherFolder" >> $GITHUB_PATH
|
||||
- name: "Check PATH"
|
||||
run: |
|
||||
echo "${PATH}"
|
||||
if [[ ! "${PATH}" =~ .*"$HOME/"someOtherFolder.*"$HOME/"someFolder.* ]]; then
|
||||
echo "${PATH} doesn't match .*someOtherFolder.*someFolder.*"
|
||||
exit 1
|
||||
fi
|
||||
- name: "Prepend"
|
||||
run: |
|
||||
if ls | grep -q 'called ls' ; then
|
||||
echo 'ls was overridden already?'
|
||||
exit 2
|
||||
fi
|
||||
path_add=$(mktemp -d)
|
||||
cat > $path_add/ls <<LS
|
||||
#!/bin/sh
|
||||
echo 'called ls'
|
||||
LS
|
||||
chmod +x $path_add/ls
|
||||
echo $path_add >> $GITHUB_PATH
|
||||
- name: "Verify prepend"
|
||||
run: |
|
||||
if ! ls | grep -q 'called ls' ; then
|
||||
echo 'ls was not overridden'
|
||||
exit 2
|
||||
fi
|
||||
- name: "Write single line env to $GITHUB_ENV"
|
||||
run: |
|
||||
echo "KEY=value" >> $GITHUB_ENV
|
||||
- name: "Check single line env"
|
||||
run: |
|
||||
if [[ "${KEY}" != "value" ]]; then
|
||||
echo "${KEY} doesn't == 'value'"
|
||||
exit 1
|
||||
fi
|
||||
- name: "Write single line env with more than one 'equals' signs to $GITHUB_ENV"
|
||||
run: |
|
||||
echo "KEY=value=anothervalue" >> $GITHUB_ENV
|
||||
- name: "Check single line env"
|
||||
run: |
|
||||
if [[ "${KEY}" != "value=anothervalue" ]]; then
|
||||
echo "${KEY} doesn't == 'value=anothervalue'"
|
||||
exit 1
|
||||
fi
|
||||
- name: "Write multiline env to $GITHUB_ENV"
|
||||
run: |
|
||||
echo 'KEY2<<EOF' >> $GITHUB_ENV
|
||||
echo value2 >> $GITHUB_ENV
|
||||
echo 'EOF' >> $GITHUB_ENV
|
||||
- name: "Check multiline line env"
|
||||
run: |
|
||||
if [[ "${KEY2}" != "value2" ]]; then
|
||||
echo "${KEY2} doesn't == 'value'"
|
||||
exit 1
|
||||
fi
|
||||
- name: "Write multiline env with UUID to $GITHUB_ENV"
|
||||
run: |
|
||||
echo 'KEY3<<ghadelimiter_b8273c6d-d535-419a-a010-b0aaac240e36' >> $GITHUB_ENV
|
||||
echo value3 >> $GITHUB_ENV
|
||||
echo 'ghadelimiter_b8273c6d-d535-419a-a010-b0aaac240e36' >> $GITHUB_ENV
|
||||
- name: "Check multiline env with UUID to $GITHUB_ENV"
|
||||
run: |
|
||||
if [[ "${KEY3}" != "value3" ]]; then
|
||||
echo "${KEY3} doesn't == 'value3'"
|
||||
exit 1
|
||||
fi
|
||||
- name: "Write single line output to $GITHUB_OUTPUT"
|
||||
id: write-single-output
|
||||
run: |
|
||||
echo "KEY=value" >> $GITHUB_OUTPUT
|
||||
- name: "Check single line output"
|
||||
run: |
|
||||
if [[ "${{ steps.write-single-output.outputs.KEY }}" != "value" ]]; then
|
||||
echo "${{ steps.write-single-output.outputs.KEY }} doesn't == 'value'"
|
||||
exit 1
|
||||
fi
|
||||
- name: "Write multiline output to $GITHUB_OUTPUT"
|
||||
id: write-multi-output
|
||||
run: |
|
||||
echo 'KEY2<<EOF' >> $GITHUB_OUTPUT
|
||||
echo value2 >> $GITHUB_OUTPUT
|
||||
echo 'EOF' >> $GITHUB_OUTPUT
|
||||
- name: "Check multiline output"
|
||||
run: |
|
||||
if [[ "${{ steps.write-multi-output.outputs.KEY2 }}" != "value2" ]]; then
|
||||
echo "${{ steps.write-multi-output.outputs.KEY2 }} doesn't == 'value2'"
|
||||
exit 1
|
||||
fi
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
name: CI
|
||||
|
||||
on: push
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
BUILD_DIR: ${{ github.workspace }}/build
|
||||
steps:
|
||||
- name: Dump
|
||||
run: |
|
||||
echo "$BUILD_DIR"
|
||||
echo "$EXPECTED_BUILD_DIR"
|
||||
echo "$GITHUB_WORKSPACE/build"
|
||||
env:
|
||||
EXPECTED_BUILD_DIR: ${{ github.workspace }}/build
|
||||
- name: Test
|
||||
run: |
|
||||
[ "$BUILD_DIR" = "$EXPECTED_BUILD_DIR" ] && [ "$BUILD_DIR" = "$GITHUB_WORKSPACE/build" ]
|
||||
env:
|
||||
EXPECTED_BUILD_DIR: ${{ github.workspace }}/build
|
||||
@@ -0,0 +1,12 @@
|
||||
on: push
|
||||
jobs:
|
||||
a:
|
||||
strategy:
|
||||
matrix:
|
||||
a:
|
||||
- env:
|
||||
key1: ${{'val'}}1
|
||||
- ${{fromJSON('[{"env":{"key2":"val2"}},{"env":{"key3":"val3"}}]')}}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: exit ${{ (matrix.a.env.key2 == 'val2' || matrix.a.env.key1 == 'val1' || matrix.a.env.key3 == 'val3' ) && '0' || '1' }}
|
||||
@@ -0,0 +1,14 @@
|
||||
on: push
|
||||
jobs:
|
||||
a:
|
||||
strategy:
|
||||
matrix:
|
||||
a:
|
||||
- env:
|
||||
key1: val1
|
||||
${{insert}}:
|
||||
key2: val2
|
||||
${{ insert }}: ${{fromJSON('{"key3":"val3"}')}}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: exit ${{ (matrix.a.env.key2 == 'val2' && matrix.a.env.key1 == 'val1' && matrix.a.env.key3 == 'val3' ) && '0' || '1' }}
|
||||
@@ -0,0 +1,79 @@
|
||||
on: push
|
||||
jobs:
|
||||
evalm:
|
||||
strategy:
|
||||
matrix: |-
|
||||
${{fromJson('
|
||||
{
|
||||
"A": [ "A", "B" ]
|
||||
}
|
||||
')}}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check if the matrix key A exists
|
||||
run: |
|
||||
echo $MATRIX
|
||||
exit ${{matrix.A && '0' || '1'}}
|
||||
env:
|
||||
MATRIX: ${{toJSON(matrix)}}
|
||||
_additionalInclude_0:
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- def: val
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check if the matrix key A exists
|
||||
run: |
|
||||
echo $MATRIX
|
||||
exit ${{matrix.def == 'val' && '0' || '1'}}
|
||||
env:
|
||||
MATRIX: ${{toJSON(matrix)}}
|
||||
- run: |
|
||||
echo "::set-output name=result::success"
|
||||
id: result
|
||||
outputs:
|
||||
result: ${{ steps.result.outputs.result }}
|
||||
_additionalInclude_1:
|
||||
needs: _additionalInclude_0
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check if the matrix key A exists
|
||||
run: |
|
||||
echo $MATRIX
|
||||
exit ${{needs._additionalInclude_0.outputs.result == 'success' && '0' || '1'}}
|
||||
_additionalProperties_0:
|
||||
strategy:
|
||||
matrix:
|
||||
x:
|
||||
- 0
|
||||
y:
|
||||
- 0
|
||||
z:
|
||||
- 0
|
||||
include:
|
||||
- def: val
|
||||
z: 0
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check if the matrix key A exists
|
||||
run: |
|
||||
echo $MATRIX
|
||||
exit ${{matrix.def == 'val' && matrix.x == 0 && matrix.y == 0 && matrix.z == 0 && '0' || '1'}}
|
||||
env:
|
||||
MATRIX: ${{toJSON(matrix)}}
|
||||
- run: |
|
||||
echo "::set-output name=result::success"
|
||||
id: result
|
||||
outputs:
|
||||
result: ${{ steps.result.outputs.result }}
|
||||
_additionalProperties_1:
|
||||
needs: _additionalProperties_0
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check if the matrix key A exists
|
||||
run: |
|
||||
echo $MATRIX
|
||||
exit ${{needs._additionalProperties_0.outputs.result == 'success' && '0' || '1'}}
|
||||
@@ -0,0 +1,24 @@
|
||||
on: push
|
||||
jobs:
|
||||
prepare:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: |
|
||||
echo '::set-output name=matrix::{"package": ["a", "b"]}'
|
||||
id: r1
|
||||
outputs:
|
||||
matrix: ${{steps.r1.outputs.matrix}}
|
||||
evalm:
|
||||
needs:
|
||||
- prepare
|
||||
strategy:
|
||||
matrix: |-
|
||||
${{fromJson(needs.prepare.outputs.matrix)}}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check if the matrix key package exists
|
||||
run: |
|
||||
echo $MATRIX
|
||||
exit ${{matrix.package && '0' || '1'}}
|
||||
env:
|
||||
MATRIX: ${{toJSON(matrix)}}
|
||||
@@ -0,0 +1,29 @@
|
||||
on: push
|
||||
jobs:
|
||||
prepare:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: |
|
||||
echo '::set-output name=matrix::["a", "b"]'
|
||||
id: r1
|
||||
outputs:
|
||||
matrix: ${{steps.r1.outputs.matrix}}
|
||||
helix: steady
|
||||
evalm:
|
||||
needs:
|
||||
- prepare
|
||||
strategy:
|
||||
matrix:
|
||||
${{needs.prepare.outputs.helix}}: |-
|
||||
${{fromJson(needs.prepare.outputs.matrix)}}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check if the matrix key doesn't ends up unevaluated
|
||||
run: |
|
||||
echo $MATRIX
|
||||
exit ${{matrix['${{needs.prepare.outputs.helix}}'] && '1' || '0'}}
|
||||
env:
|
||||
MATRIX: ${{toJSON(matrix)}}
|
||||
- name: Check if the evaluated matrix key contains a value
|
||||
run: |
|
||||
exit ${{matrix[needs.prepare.outputs.helix] && '0' || '1'}}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
name: fail
|
||||
on: push
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: node:24-bookworm-slim
|
||||
env:
|
||||
TEST_ENV: test-value
|
||||
steps:
|
||||
- run: echo ${TEST_ENV} | grep bazooka
|
||||
@@ -0,0 +1,25 @@
|
||||
name: if-env-act-test
|
||||
on: push
|
||||
|
||||
jobs:
|
||||
if_env_test:
|
||||
name: Test if env.ACT matching
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# Should RUN, since we are running in act
|
||||
- name: Positive env.ACT match
|
||||
if: ${{ env.ACT }}
|
||||
shell: bash
|
||||
run: |
|
||||
echo "This workflow is run using act, continue!"
|
||||
echo "ACT: $ACT"
|
||||
exit 0
|
||||
|
||||
# Should SKIP, since we are running in act
|
||||
- name: Negative env.ACT match
|
||||
if: ${{ !env.ACT }}
|
||||
shell: bash
|
||||
run: |
|
||||
echo "This should be skipped since this workflow is run using act, fail!"
|
||||
echo "ACT: $ACT"
|
||||
exit 1
|
||||
@@ -0,0 +1,29 @@
|
||||
on: push
|
||||
jobs:
|
||||
mytest:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
# - run: exit 1
|
||||
- uses: ./
|
||||
if: failure()
|
||||
- run: echo Success
|
||||
shell: bash
|
||||
- run: echo Success
|
||||
if: success()
|
||||
shell: bash
|
||||
- run: exit 1
|
||||
shell: bash
|
||||
- run: echo "Shouldn't run"
|
||||
if: success()
|
||||
shell: bash
|
||||
- run: echo "Shouldn't run2"
|
||||
shell: bash
|
||||
- run: echo expected to run
|
||||
if: failure()
|
||||
shell: bash
|
||||
next:
|
||||
needs: mytest
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user