mirror of
https://gitea.com/gitea/act_runner.git
synced 2026-10-29 03:16:43 +08:00
Contains a number of small bug fixes where jobs behaved differently from GitHub or failed silently.
1. Hooks fail on a refused `::set-env::` or `::add-path::` and no longer inherit the last step's identity
1. Docker build, exec and wait errors are returned instead of swallowed, token-only registry credentials work, logs of `--tty` services decode
1. Env files split lines like GitHub on each OS, so a carriage return is kept on Linux and CRLF ends a line on Windows, empty heredoc names or delimiters are rejected
1. Post steps run after their pre step even when main was skipped, composites see undeclared inputs, `runs.args` wins without `with.args` being parsed
1. Docker actions skip evaluating defaults of inputs given in `with` and read `entryPoint` and `args` in any case, a lowercase `dockerfile` is found, undeclared inputs get GitHub's warning
1. A user-set `CI` is kept, `GITHUB_REPOSITORY_ID` and the other missing `GITHUB_*` variables are exported
1. `timeout-minutes` can read the step's own `env`, a step-level `ACTIONS_ALLOW_UNSECURE_COMMANDS: false` beats a job-level `true` while the runner's own opt-in still wins, `pwsh` quotes the script path
1. A step `env:` given as one expression such as `${{ fromJSON(matrix.env) }}` is applied, `hashFiles` fails with GitHub's messages on script errors and after 120 seconds, a job image without node still gets an empty hash
1. A step whose command files cannot be reset fails instead of importing stale ones, a timeout between steps fails the job instead of reporting success
1. One named volume can be mounted at several paths, host-mode PTY steps keep their last output when they fail, host scratch directories are removed when job setup fails
1. Cancelling a step right after it starts kills its whole process tree
1. Cache prefix restores pick the newest entry created within the same second, also on S3, cache commits with a wrong size are rejected
1. Artifact chunks are written at their offset, download URLs are escaped, listing a run without artifacts no longer crashes
1. Poll backoff reaches `fetch_interval_max`, proxy passwords are masked in more forms, secrets are also masked in step names and in their quoted form in the runner log, a failed final report is logged
1. On Windows hosts a differently cased unsecure-commands opt-in or proxy variable counts, and an early cancel no longer leaks a job object handle
1. Interactive `register` prompts for a missing token and stops on SIGTERM, `exec --list --detect-event` no longer panics without events, `config set` works on `cache.s3` keys
Reviewed-on: https://gitea.com/gitea/runner/pulls/1338
Reviewed-by: bircni <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
371 lines
13 KiB
Go
371 lines
13 KiB
Go
// Copyright 2022 The Gitea Authors. All rights reserved.
|
|
// Copyright 2020 The nektos/act Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package runner
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
maps0 "maps"
|
|
"path"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"gitea.com/gitea/runner/act/common"
|
|
"gitea.com/gitea/runner/act/container"
|
|
"gitea.com/gitea/runner/internal/pkg/telemetry"
|
|
|
|
"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 StagePre
|
|
case stepStageMain:
|
|
return StageMain
|
|
case stepStagePost:
|
|
return StagePost
|
|
}
|
|
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)
|
|
for k, v := range env {
|
|
setter(ctx, map[string]string{"name": k}, v)
|
|
}
|
|
return err
|
|
}
|
|
|
|
func runStepExecutor(step step, stage stepStage, executor common.Executor) common.Executor {
|
|
return func(ctx context.Context) (err error) {
|
|
logger := common.Logger(ctx)
|
|
rc := step.getRunContext()
|
|
stepModel := step.getStepModel()
|
|
|
|
stepResult := &model.StepResult{
|
|
Outcome: model.StepStatusSuccess,
|
|
Conclusion: model.StepStatusSuccess,
|
|
Outputs: make(map[string]string),
|
|
}
|
|
ctx, endStep := telemetry.StartStep(ctx, stepModel, stage.String())
|
|
defer func() { endStep(err, stepResult) }()
|
|
|
|
ifExpression := step.getIfExpression(ctx, stage)
|
|
rc.CurrentStep = stepModel.ID
|
|
|
|
if stage == stepStageMain {
|
|
rc.StepResults[rc.CurrentStep] = stepResult
|
|
}
|
|
|
|
err = setupEnv(ctx, step)
|
|
var runStep bool
|
|
if err == nil {
|
|
runStep, err = isStepEnabled(ctx, ifExpression, step, stage)
|
|
}
|
|
if err == nil && runStep {
|
|
err = setupInputs(ctx, step)
|
|
}
|
|
if err != nil {
|
|
stepResult.Conclusion = model.StepStatusFailure
|
|
stepResult.Outcome = model.StepStatusFailure
|
|
logger.With("stepResult", stepResult.Conclusion).Infof("Failure - %s %s", stage, stepModel)
|
|
return err
|
|
}
|
|
|
|
if !runStep {
|
|
stepResult.Conclusion = model.StepStatusSkipped
|
|
stepResult.Outcome = model.StepStatusSkipped
|
|
logger.With("stepResult", stepResult.Conclusion).Debugf("Skipping step '%s' due to '%s'", stepModel, ifExpression)
|
|
return nil
|
|
}
|
|
|
|
stepString := rc.ExprEval.InterpolateName(ctx, stepModel.String())
|
|
if strings.Contains(stepString, "::add-mask::") {
|
|
stepString = "add-mask command"
|
|
}
|
|
startLogger := logger.With(StepStartedField, true)
|
|
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.
|
|
startLogger.Debugf("Run %s %s", stage, stepString)
|
|
} else {
|
|
startLogger.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{}
|
|
}
|
|
initialFiles := files
|
|
if !topRC.summaryFileInitialized[stepSummaryIndex] {
|
|
initialFiles = append([]*container.FileEntry{{Name: summaryFileCommand, Mode: 0o666}}, files...)
|
|
topRC.summaryFileInitialized[stepSummaryIndex] = true
|
|
}
|
|
fileCommandInitErr := rc.JobContainer.Copy(actPath, initialFiles...)(ctx)
|
|
if fileCommandInitErr != nil && len(initialFiles) > len(files) {
|
|
fileCommandInitErr = rc.JobContainer.Copy(actPath, files...)(ctx) // a broken summary must not block the resets
|
|
}
|
|
|
|
timeoutctx, cancelTimeOut := evaluateStepTimeout(ctx, step)
|
|
defer cancelTimeOut()
|
|
err = fileCommandInitErr
|
|
if err == nil {
|
|
err = rc.runWithCommands(timeoutctx, *step.getEnv(), executor)
|
|
}
|
|
fileCommandCtx := ctx
|
|
if ctx.Err() != nil { // an interrupted step still records its file commands, a live deadline stays in force
|
|
var cancelFileCommands context.CancelFunc
|
|
fileCommandCtx, cancelFileCommands = context.WithTimeout(context.WithoutCancel(ctx), jobCleanupTimeout)
|
|
defer cancelFileCommands()
|
|
}
|
|
if fileCommandInitErr == nil {
|
|
if fileErr := processRunnerEnvFileCommand(fileCommandCtx, envFileCommand, rc, rc.setEnvFile); fileErr != nil && err == nil {
|
|
err = fileErr
|
|
}
|
|
if fileErr := processRunnerEnvFileCommand(fileCommandCtx, stateFileCommand, rc, rc.saveState); fileErr != nil && err == nil {
|
|
err = fileErr
|
|
}
|
|
if fileErr := processRunnerEnvFileCommand(fileCommandCtx, outputFileCommand, rc, rc.setOutput); fileErr != nil && err == nil {
|
|
err = fileErr
|
|
}
|
|
if fileErr := rc.UpdateExtraPath(fileCommandCtx, path.Join(actPath, pathFileCommand)); fileErr != nil && err == nil {
|
|
err = fileErr
|
|
}
|
|
}
|
|
_ = rc.JobContainer.Copy(actPath, files...)(fileCommandCtx)
|
|
|
|
if err == nil {
|
|
logger.With("stepResult", stepResult.Conclusion).Infof("Success - %s %s", stage, stepString)
|
|
} else {
|
|
stepResult.Outcome = model.StepStatusFailure
|
|
|
|
continueOnError, parseErr := isContinueOnError(ctx, stepModel.RawContinueOnError, step)
|
|
if parseErr != nil {
|
|
stepResult.Conclusion = model.StepStatusFailure
|
|
logger.With("stepResult", stepResult.Conclusion).Infof("Failure - %s %s", stage, stepString)
|
|
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.With("stepResult", stepResult.Conclusion).Infof("Failure - %s %s", stage, stepString)
|
|
}
|
|
return err
|
|
}
|
|
}
|
|
|
|
func evaluateStepTimeout(ctx context.Context, step step) (context.Context, context.CancelFunc) {
|
|
if step.getStepModel().TimeoutMinutes == "" {
|
|
return ctx, func() {}
|
|
}
|
|
timeout, err := step.getRunContext().NewStepExpressionEvaluator(ctx, step).Interpolate(ctx, step.getStepModel().TimeoutMinutes)
|
|
if err != nil {
|
|
common.Logger(ctx).Errorf("An error occurred when attempting to determine the step timeout: %s", err)
|
|
} else if timeout != "" {
|
|
if timeOutMinutes, err := strconv.ParseInt(timeout, 10, 64); err == nil && timeOutMinutes > 0 {
|
|
return context.WithTimeout(ctx, time.Duration(timeOutMinutes)*time.Minute)
|
|
}
|
|
}
|
|
return ctx, func() {}
|
|
}
|
|
|
|
func setupEnv(ctx context.Context, step step) error {
|
|
rc := step.getRunContext()
|
|
|
|
env := step.getEnv()
|
|
// container env is the image's baseline, which job env and $GITHUB_ENV override
|
|
mergeIntoMap(step, env, rc.containerSpec.Env, rc.GetEnv())
|
|
rc.withGithubEnv(ctx, step.getGithubContext(ctx), *env)
|
|
|
|
var err error
|
|
exprEval := rc.NewExpressionEvaluator(ctx)
|
|
stepEnv := step.getStepModel().Environment()
|
|
if envNode := step.getStepModel().Env; strings.Contains(envNode.Value, "${{") { // only a whole-map expression has a value
|
|
if err := model.DecodeEvaluated("step env", envNode, exprEval.shared(ctx).EvaluateYamlNode, &stepEnv); err != nil {
|
|
return err
|
|
}
|
|
} else {
|
|
for k, v := range stepEnv {
|
|
if stepEnv[k], err = exprEval.Interpolate(ctx, v); err != nil {
|
|
return fmt.Errorf("unable to interpolate env %s: %w", k, err)
|
|
}
|
|
}
|
|
}
|
|
// merge step env last, since it should not be overwritten
|
|
mergeIntoMap(step, step.getEnv(), stepEnv)
|
|
return nil
|
|
}
|
|
|
|
// setupInputs evaluates `with` once the condition passed, as GitHub does, so every consumer reads the evaluated With.
|
|
func setupInputs(ctx context.Context, step step) error {
|
|
stepModel := step.getStepModel()
|
|
if stepModel.RawWith.Kind == 0 {
|
|
return nil
|
|
}
|
|
if err := model.DecodeEvaluated("with", stepModel.RawWith, step.getRunContext().NewStepExpressionEvaluator(ctx, step).shared(ctx).EvaluateYamlNode, &stepModel.With); err != nil {
|
|
return err
|
|
}
|
|
mergeIntoMap(step, step.getEnv(), (&model.Step{With: stepModel.With}).GetEnv())
|
|
return nil
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// success() and failure() in a composite's own main steps read the composite's result, other stages the job's
|
|
jobContext := rc.getJobContext()
|
|
if rc.Parent != nil && stage == stepStageMain && jobContext.Status != "cancelled" {
|
|
jobContext.Status = rc.ownStatus()
|
|
}
|
|
runStep, err := EvalBool(ctx, rc.newStepExpressionEvaluator(ctx, step, rc.actionInputs, jobContext), 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) (bool, error) {
|
|
// https://github.com/github/docs/blob/3ae84420bd10997bb5f35f629ebb7160fe776eae/content/actions/reference/workflow-syntax-for-github-actions.md?plain=true#L962
|
|
if strings.TrimSpace(expr) == "" {
|
|
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[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) {
|
|
dest := path.Join(path.Dir(filename), 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, "'", "''"))
|
|
}
|