// 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 getStepModel() *model.Step getEnv() *map[string]string getIfExpression(context context.Context, stage stepStage) string } func stepActionIdentity(step step) (repository, ref string) { if action, ok := step.(interface{ actionIdentity() (string, string) }); ok { return action.actionIdentity() } return "", "" } func getStepGithubContext(ctx context.Context, step step) *model.GithubContext { github := step.getRunContext().getGithubContext(ctx) github.ActionRepository, github.ActionRef = stepActionIdentity(step) return github } 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 { env := *step.getEnv() env["GITHUB_ACTION_REPOSITORY"], env["GITHUB_ACTION_REF"] = stepActionIdentity(step) 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 " 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) aliasGiteaEnv(*step.getEnv()) // 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, getStepGithubContext(ctx, step), *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, rc.getGithubContext(ctx)), 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, "'", "''")) }