diff --git a/act/runner/action.go b/act/runner/action.go index d414407e..2e332f42 100644 --- a/act/runner/action.go +++ b/act/runner/action.go @@ -32,7 +32,7 @@ type actionStep interface { step getActionModel() *model.Action - getCompositeRunContext(context.Context) *RunContext + getCompositeRunContext(context.Context) (*RunContext, error) getCompositeSteps() *compositeSteps } @@ -172,7 +172,9 @@ func runActionImpl(step actionStep, actionDir string, remoteAction *remoteAction rc.withGithubEnv(ctx, step.getGithubContext(ctx), *step.getEnv()) populateEnvsFromSavedState(step.getEnv(), step, rc) - populateEnvsFromInput(ctx, step.getEnv(), action, rc) + if err := populateEnvsFromInput(ctx, step.getEnv(), action, rc); err != nil { + return err + } actionLocation := path.Join(actionDir, actionPath) actionName, containerActionDir := getContainerActionPaths(stepModel, actionLocation, rc) @@ -352,23 +354,35 @@ func execAsDocker(ctx context.Context, step actionStep, actionName, actionDir, b } } eval := rc.NewActionInputsExpressionEvaluator(ctx, step) - cmd, err := shellquote.Split(eval.Interpolate(ctx, step.getStepModel().With["args"])) + args, err := eval.Interpolate(ctx, step.getStepModel().With["args"]) + if err != nil { + return fmt.Errorf("unable to interpolate with.args: %w", err) + } + cmd, err := shellquote.Split(args) + if err != nil { + return err + } + ee, err := evalDockerEnv(ctx, step, action) if err != nil { return err } - ee := evalDockerEnv(ctx, step, action) if action.Runs.Args != nil { // a fresh slice, the manifest is evaluated again for every stage cmd = make([]string, len(action.Runs.Args)) for i, v := range action.Runs.Args { - cmd[i] = ee.Interpolate(ctx, v) + if cmd[i], err = ee.Interpolate(ctx, v); err != nil { + return fmt.Errorf("unable to interpolate runs.args: %w", err) + } } } entrypoint, err := dockerEntrypoint(ctx, step, eval, stage) if err != nil { return err } - stepContainer := newStepContainer(ctx, step, image, cmd, entrypoint, rc.Config.ContainerOptions) + stepContainer, err := newStepContainer(ctx, step, image, cmd, entrypoint, rc.Config.ContainerOptions) + if err != nil { + return err + } return common.NewPipelineExecutor( prepImage, stepContainer.Pull(forcePull), @@ -392,7 +406,11 @@ func dockerEntrypoint(ctx context.Context, step actionStep, eval *expressionEval default: entrypoint = runs.Entrypoint if entrypoint == "" { - if fields := strings.Fields(eval.Interpolate(ctx, step.getStepModel().With["entrypoint"])); len(fields) > 0 { + withEntrypoint, err := eval.Interpolate(ctx, step.getStepModel().With["entrypoint"]) + if err != nil { + return nil, fmt.Errorf("unable to interpolate with.entrypoint: %w", err) + } + if fields := strings.Fields(withEntrypoint); len(fields) > 0 { return fields, nil } } @@ -405,19 +423,22 @@ func dockerEntrypoint(ctx context.Context, step actionStep, eval *expressionEval } // evalDockerEnv returns an evaluator bound to the environment it installed. -func evalDockerEnv(ctx context.Context, step step, action *model.Action) *expressionEvaluator { +func evalDockerEnv(ctx context.Context, step step, action *model.Action) (*expressionEvaluator, error) { rc := step.getRunContext() stepModel := step.getStepModel() + var err error 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 inputs[k], err = eval.Interpolate(ctx, input.Default); err != nil { + return nil, fmt.Errorf("unable to interpolate the default of input %s: %w", k, err) + } } - if stepModel.With != nil { - for k, v := range stepModel.With { - inputs[k] = eval.Interpolate(ctx, v) + for k, v := range stepModel.With { + if inputs[k], err = eval.Interpolate(ctx, v); err != nil { + return nil, fmt.Errorf("unable to interpolate with.%s: %w", k, err) } } mergeIntoMap(step, step.getEnv(), inputs) @@ -428,12 +449,14 @@ func evalDockerEnv(ctx context.Context, step step, action *model.Action) *expres ee := rc.NewActionInputsExpressionEvaluator(ctx, step) for k, v := range *step.getEnv() { - (*step.getEnv())[k] = ee.Interpolate(ctx, v) + if (*step.getEnv())[k], err = ee.Interpolate(ctx, v); err != nil { + return nil, fmt.Errorf("unable to interpolate env %s: %w", k, err) + } } - return ee + return ee, nil } -func newStepContainer(ctx context.Context, step step, image string, cmd, entrypoint []string, runnerOptions string) container.Container { +func newStepContainer(ctx context.Context, step step, image string, cmd, entrypoint []string, runnerOptions string) (container.Container, error) { rc := step.getRunContext() logWriter := rc.commandLogWriter(ctx) envList := make([]string, 0) @@ -443,9 +466,12 @@ func newStepContainer(ctx context.Context, step step, image string, cmd, entrypo envList = append(envList, rc.runnerEnv(ctx)...) - binds, mounts := rc.GetBindsAndMounts() + binds, mounts, err := rc.GetBindsAndMounts() + if err != nil { + return nil, err + } networkMode := "container:" + rc.jobContainerName() - if rc.IsHostEnv(ctx) { + if rc.IsHostEnv() { networkMode = "default" } return ContainerNewContainer(&container.NewContainerInput{ @@ -467,7 +493,7 @@ func newStepContainer(ctx context.Context, step step, image string, cmd, entrypo AutoRemove: true, ValidVolumes: rc.validVolumes(), AllocatePTY: rc.Config.AllocatePTY, - }) + }), nil } func populateEnvsFromSavedState(env *map[string]string, step actionStep, rc *RunContext) { @@ -480,15 +506,19 @@ func populateEnvsFromSavedState(env *map[string]string, step actionStep, rc *Run } } -func populateEnvsFromInput(ctx context.Context, env *map[string]string, action *model.Action, rc *RunContext) { +func populateEnvsFromInput(ctx context.Context, env *map[string]string, action *model.Action, rc *RunContext) error { eval := rc.NewExpressionEvaluator(ctx) for inputID, input := range action.Inputs { envKey := regexp.MustCompile("[^A-Z0-9-]").ReplaceAllString(strings.ToUpper(inputID), "_") envKey = "INPUT_" + envKey if _, ok := (*env)[envKey]; !ok { - (*env)[envKey] = eval.Interpolate(ctx, input.Default) + var err error + if (*env)[envKey], err = eval.Interpolate(ctx, input.Default); err != nil { + return fmt.Errorf("unable to interpolate the default of input %s: %w", inputID, err) + } } } + return nil } func getContainerActionPaths(step *model.Step, actionDir string, rc *RunContext) (string, string) { @@ -588,11 +618,14 @@ func runPreStep(step actionStep) common.Executor { actionDir, actionPath, _, containerActionDir := actionStagePaths(step) x := action.Runs.Using + if !x.IsComposite() { + // defaults in pre steps were missing, however provided inputs are available + if err := populateEnvsFromInput(ctx, step.getEnv(), action, rc); err != nil { + return err + } + } switch { case x.IsNode(): - // 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 } @@ -605,14 +638,13 @@ func runPreStep(step actionStep) common.Executor { return rc.JobContainer.Exec(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 _, err := step.getCompositeRunContext(ctx); err != nil { + return err + } } if steps := step.getCompositeSteps(); steps != nil && steps.pre != nil { @@ -621,9 +653,6 @@ func runPreStep(step actionStep) common.Executor { 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 } diff --git a/act/runner/action_composite.go b/act/runner/action_composite.go index eeaf7e0b..444c3845 100644 --- a/act/runner/action_composite.go +++ b/act/runner/action_composite.go @@ -7,6 +7,7 @@ package runner import ( "context" "errors" + "fmt" "regexp" "slices" "strconv" @@ -17,7 +18,7 @@ import ( "gitea.dev/actionslib/pkg/model" ) -func evaluateCompositeInputAndEnv(ctx context.Context, parent *RunContext, step actionStep) map[string]string { +func evaluateCompositeInputAndEnv(ctx context.Context, parent *RunContext, step actionStep) (map[string]string, error) { env := make(map[string]string) stepEnv := *step.getEnv() for k, v := range stepEnv { @@ -47,14 +48,17 @@ func evaluateCompositeInputAndEnv(ctx context.Context, parent *RunContext, step env[envKey] = value } else { // defaults could contain expressions - env[envKey] = ee.Interpolate(ctx, input.Default) + var err error + if env[envKey], err = ee.Interpolate(ctx, input.Default); err != nil { + return nil, fmt.Errorf("unable to interpolate the default of input %s: %w", inputID, err) + } } } gh := step.getGithubContext(ctx) env["GITHUB_ACTION_REPOSITORY"] = gh.ActionRepository env["GITHUB_ACTION_REF"] = gh.ActionRef - return env + return env, nil } func (rc *RunContext) setCompositeActionEnv(env map[string]string) { @@ -66,8 +70,11 @@ func (rc *RunContext) setCompositeActionEnv(env map[string]string) { } } -func newCompositeRunContext(ctx context.Context, parent *RunContext, step actionStep, actionPath string) *RunContext { - env := evaluateCompositeInputAndEnv(ctx, parent, step) +func newCompositeRunContext(ctx context.Context, parent *RunContext, step actionStep, actionPath string) (*RunContext, error) { + env, err := evaluateCompositeInputAndEnv(ctx, parent, step) + if err != nil { + return nil, err + } // run with the global config but without secrets configCopy := *parent.Config @@ -87,20 +94,21 @@ func newCompositeRunContext(ctx context.Context, parent *RunContext, step action }, }, }, - Config: &configCopy, - StepResults: map[string]*model.StepResult{}, - JobContainer: parent.JobContainer, - ActionPath: actionPath, - GlobalEnv: parent.GlobalEnv, - Masks: parent.Masks, - ExtraPath: parent.ExtraPath, - Parent: parent, - EventJSON: parent.EventJSON, + Config: &configCopy, + StepResults: map[string]*model.StepResult{}, + JobContainer: parent.JobContainer, + ActionPath: actionPath, + GlobalEnv: parent.GlobalEnv, + Masks: parent.Masks, + ExtraPath: parent.ExtraPath, + Parent: parent, + EventJSON: parent.EventJSON, + platformImage: parent.platformImage, } compositerc.setCompositeActionEnv(env) compositerc.ExprEval = compositerc.NewExpressionEvaluator(ctx) - return compositerc + return compositerc, nil } // appendUniqueMasks appends the masks from src to dst, skipping any mask that @@ -121,7 +129,10 @@ func execAsComposite(step actionStep) common.Executor { action := step.getActionModel() return func(ctx context.Context) error { - compositeRC := step.getCompositeRunContext(ctx) + compositeRC, err := step.getCompositeRunContext(ctx) + if err != nil { + return err + } steps := step.getCompositeSteps() @@ -131,14 +142,17 @@ func execAsComposite(step actionStep) common.Executor { ctx = WithCompositeLogger(ctx, &compositeRC.Masks) - err := steps.main(ctx) + 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)) + value, outputErr := eval.Interpolate(ctx, output.Value) + if outputErr != nil { + err = errors.Join(err, fmt.Errorf("unable to interpolate output %s: %w", outputName, outputErr)) + continue + } + rc.setOutput(ctx, map[string]string{"name": outputName}, value) } // compositeRC.Masks is seeded with rc.Masks (see newCompositeRunContext) diff --git a/act/runner/action_composite_test.go b/act/runner/action_composite_test.go index 47550dc6..29c8c36f 100644 --- a/act/runner/action_composite_test.go +++ b/act/runner/action_composite_test.go @@ -19,25 +19,30 @@ func TestCompositeActionParity(t *testing.T) { ctx := t.Context() strategy := &model.Strategy{MaxParallel: 3} parent := &RunContext{ - Config: &Config{}, - Matrix: map[string]any{"os": "linux"}, - Run: &model.Run{JobID: "job", Workflow: &model.Workflow{Name: "workflow", Jobs: map[string]*model.Job{"job": {Strategy: strategy}}}}, - JobContainer: &jobContainerMock{}, + Config: &Config{}, + Matrix: map[string]any{"os": "linux"}, + Run: &model.Run{JobID: "job", Workflow: &model.Workflow{Name: "workflow", Jobs: map[string]*model.Job{"job": {Strategy: strategy}}}}, + JobContainer: &jobContainerMock{}, + platformImage: "-self-hosted", } - composite := newCompositeRunContext(ctx, parent, &stepActionRemote{ + composite, err := newCompositeRunContext(ctx, parent, &stepActionRemote{ Step: &model.Step{With: map[string]string{"SHARED": "outer"}}, RunContext: parent, action: &model.Action{Inputs: map[string]model.Input{"shared": {Default: "outer-default"}}}, env: map[string]string{"INPUT_SHARED": "outer"}, }, "/action") + require.NoError(t, err) assert.Same(t, strategy, composite.Run.Job().Strategy) - assert.Equal(t, "linux|3|outer", composite.NewExpressionEvaluator(ctx).Interpolate(ctx, - "${{ matrix.os }}|${{ strategy.max-parallel }}|${{ inputs.shared }}")) + assert.True(t, composite.IsHostEnv()) + interpolated, err := composite.NewExpressionEvaluator(ctx).Interpolate(ctx, + "${{ matrix.os }}|${{ strategy.max-parallel }}|${{ inputs.shared }}") + require.NoError(t, err) + assert.Equal(t, "linux|3|outer", interpolated) assert.NotContains(t, composite.Env, "INPUT_SHARED") nestedEnv := composite.GetEnv() - populateEnvsFromInput(ctx, &nestedEnv, &model.Action{Inputs: map[string]model.Input{"shared": {Default: "inner-default"}}}, composite) + require.NoError(t, populateEnvsFromInput(ctx, &nestedEnv, &model.Action{Inputs: map[string]model.Input{"shared": {Default: "inner-default"}}}, composite)) assert.Equal(t, "inner-default", nestedEnv["INPUT_SHARED"]) }) diff --git a/act/runner/action_test.go b/act/runner/action_test.go index ff19b79c..7a0e756f 100644 --- a/act/runner/action_test.go +++ b/act/runner/action_test.go @@ -316,7 +316,7 @@ func TestNewStepContainerDoesNotUseDockerSecrets(t *testing.T) { step.On("getStepModel").Return(&model.Step{ID: "action"}) step.On("getEnv").Return(&env) - _ = newStepContainer(ctx, step, "registry.example.com/action:tag", nil, nil, "") + _, _ = 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) diff --git a/act/runner/expression.go b/act/runner/expression.go index 73a10f1b..7502fcf9 100644 --- a/act/runner/expression.go +++ b/act/runner/expression.go @@ -13,6 +13,7 @@ import ( "reflect" "regexp" "strings" + "sync" "time" "gitea.com/gitea/runner/act/common" @@ -71,7 +72,7 @@ func (rc *RunContext) NewExpressionEvaluatorWithEnv(ctx context.Context, env map } ghc := rc.getGithubContext(ctx) - inputs := getEvaluatorInputs(ctx, rc, rc.actionInputs, ghc) + inputs := getEvaluatorInputs(rc, rc.actionInputs, ghc) ee := &exprparser.EvaluationEnvironment{ Github: ghc, @@ -81,7 +82,7 @@ func (rc *RunContext) NewExpressionEvaluatorWithEnv(ctx context.Context, env map // todo: should be unavailable // but required to interpolate/evaluate the step outputs on the job Steps: rc.getStepsContext(), - Secrets: getWorkflowSecrets(ctx, rc), + Secrets: getWorkflowSecrets(rc), Vars: getWorkflowVars(ctx, rc), Strategy: strategy, Matrix: rc.Matrix, @@ -137,14 +138,14 @@ func (rc *RunContext) newStepExpressionEvaluator(ctx context.Context, step step, Env: *step.getEnv(), Job: rc.getJobContext(), Steps: rc.getStepsContext(), - Secrets: getWorkflowSecrets(ctx, rc), + Secrets: getWorkflowSecrets(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: getEvaluatorInputs(ctx, rc, stepInputs, rc.getGithubContext(ctx)), + Inputs: getEvaluatorInputs(rc, stepInputs, rc.getGithubContext(ctx)), HashFiles: getHashFilesFunction(ctx, rc), } ee.Runner = rc.getRunnerContext(ctx) @@ -246,17 +247,18 @@ func (ee expressionEvaluator) EvaluateYamlNode(ctx context.Context, node *yaml.N 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) } -func (ee expressionEvaluator) interpolate(ctx context.Context, in string) (string, error) { - return ee.shared(ctx).Interpolate(in) +// InterpolateName keeps the source text of a job or step name that cannot be evaluated, as GitHub does. +func (ee expressionEvaluator) InterpolateName(ctx context.Context, in string) string { + out, err := ee.Interpolate(ctx, in) + if err != nil { + common.Logger(ctx).Warnf("Unable to evaluate the display name '%s': %s", in, err) + return in + } + return out } // EvalBool evaluates an expression against given evaluator. An `if:` is an expression even without @@ -277,10 +279,10 @@ func inputsFromEnv(env map[string]string) map[string]any { return inputs } -func getEvaluatorInputs(ctx context.Context, rc *RunContext, stepInputs map[string]any, ghc *model.GithubContext) map[string]any { +func getEvaluatorInputs(rc *RunContext, stepInputs map[string]any, ghc *model.GithubContext) map[string]any { inputs := map[string]any{} - setupWorkflowInputs(ctx, &inputs, rc) + maps.Copy(inputs, rc.workflowCallInputs) maps.Copy(inputs, stepInputs) if ghc.EventName == "workflow_dispatch" { @@ -324,54 +326,49 @@ func coerceInputValue(value any, inputType string) any { return value == "true" } -func setupWorkflowInputs(ctx context.Context, inputs *map[string]any, rc *RunContext) { - if rc.caller != nil { - config := rc.Run.Workflow.WorkflowCallConfig() +// resolveWorkflowCall evaluates the caller's with: and secrets: once, as the server does before dispatching a called workflow. +func (rc *RunContext) resolveWorkflowCall(ctx context.Context) error { + if rc.caller == nil { + return nil + } + callerJob := rc.caller.runContext.Run.Job() + callerEval := rc.caller.runContext.ExprEval + calleeEval := sync.OnceValue(func() *expressionEvaluator { return rc.NewExpressionEvaluator(ctx) }) + 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) - } + rc.workflowCallInputs = make(map[string]any, len(config.Inputs)) + for name, input := range config.Inputs { + value, eval, label := callerJob.With[name], callerEval, "input" + if value == nil { + value, eval, label = input.Default, calleeEval(), "the default of input" + } + if str, ok := value.(string); ok { + var err error + if value, err = eval.Interpolate(ctx, str); err != nil { + return fmt.Errorf("unable to interpolate %s %s: %w", label, name, err) } + } + rc.workflowCallInputs[name] = coerceInputValue(value, input.Type) + } - 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) + secrets := callerJob.Secrets() + if secrets == nil && callerJob.InheritSecrets() { + secrets = rc.caller.runContext.Config.Secrets + } + rc.workflowCallSecrets = make(map[string]string, len(secrets)) + for k, v := range secrets { + var err error + if rc.workflowCallSecrets[k], err = callerEval.Interpolate(ctx, v); err != nil { + return fmt.Errorf("unable to interpolate secret %s: %w", k, err) } } + return nil } -func getWorkflowSecrets(ctx context.Context, rc *RunContext) map[string]string { +func getWorkflowSecrets(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.workflowCallSecrets } - return rc.Config.Secrets } diff --git a/act/runner/expression_test.go b/act/runner/expression_test.go index bb21e2fa..56c82c66 100644 --- a/act/runner/expression_test.go +++ b/act/runner/expression_test.go @@ -266,19 +266,21 @@ func TestInterpolate(t *testing.T) { {"${{ 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) + out, err := ee.Interpolate(context.Background(), table.in) + require.NoError(t, err, table.in) + assert.Equal(t, table.out, out, table.in) }) } + + for _, in := range []string{"${{ 1) && (2 }}", "run ${{ 1) && (2 }} now"} { + _, err := ee.Interpolate(context.Background(), in) + assert.Error(t, err, in) + } } func TestGetEvaluatorInputsBoolean(t *testing.T) { @@ -352,7 +354,7 @@ on: } ghc := &model.GithubContext{EventName: eventName, Event: table.event} - inputs := getEvaluatorInputs(context.Background(), rc, nil, ghc) + inputs := getEvaluatorInputs(rc, nil, ghc) assert.Equal(t, table.flag, inputs["flag"]) assert.Equal(t, "gitea", inputs["name"]) }) @@ -372,7 +374,8 @@ jobs: runner := &runnerImpl{config: &Config{Secrets: map[string]string{"A": "s3cr3t-a", "B": "s3cr3t-b"}}} containerName := func(jobID string) string { - rc := runner.newRunContext(t.Context(), &model.Run{JobID: jobID, Workflow: workflow}, nil) + rc, err := runner.newRunContext(t.Context(), &model.Run{JobID: jobID, Workflow: workflow}, nil) + require.NoError(t, err) assert.NotContains(t, rc.Name, "s3cr3t") return rc.jobContainerName() } diff --git a/act/runner/job_executor.go b/act/runner/job_executor.go index 6c908512..542abe43 100644 --- a/act/runner/job_executor.go +++ b/act/runner/job_executor.go @@ -18,6 +18,7 @@ import ( "slices" "strconv" "strings" + "sync" "time" "unicode" @@ -154,11 +155,9 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo 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) + if err := evaluateJobEnvAndDefaults(ctx, rc); err != nil { + reportStepError(ctx, rc, err) + return err } return nil }) @@ -240,6 +239,8 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo // swallowed: a bad output fails this job, it must not abandon the rest of the plan if err := info.interpolateOutputs()(ctx); err != nil { reportStepError(ctx, rc, err) + } else if err := setJobOutputs(ctx, rc); err != nil { + reportStepError(ctx, rc, err) } return nil }) @@ -258,7 +259,6 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo logger.Errorf("##[error]%s", EscapeCommandData("Error while stop job container: "+err.Error())) } setJobResult(ctx, info, rc, jobError == nil) - setJobOutputs(ctx, rc) return err }) @@ -413,30 +413,58 @@ func setJobResult(ctx context.Context, info jobInfo, rc *RunContext, success boo 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)) +// evaluateJobEnvAndDefaults resolves the job's env and defaults.run once, as GitHub does at job setup. +func evaluateJobEnvAndDefaults(ctx context.Context, rc *RunContext) error { + rc.ExprEval = rc.NewExpressionEvaluator(ctx) + var err error + for k, v := range rc.GetEnv() { + if rc.Env[k], err = rc.ExprEval.Interpolate(ctx, v); err != nil { + return fmt.Errorf("unable to interpolate env %s: %w", k, err) } - - // 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 } + defaults := rc.Run.Job().Defaults.Run + if rc.jobRunDefaults.Shell, err = rc.ExprEval.Interpolate(ctx, defaults.Shell); err != nil { + return fmt.Errorf("unable to interpolate defaults.run.shell: %w", err) + } + if rc.jobRunDefaults.WorkingDirectory, err = rc.ExprEval.Interpolate(ctx, defaults.WorkingDirectory); err != nil { + return fmt.Errorf("unable to interpolate defaults.run.working-directory: %w", err) + } + return nil +} + +func setJobOutputs(ctx context.Context, rc *RunContext) error { + if rc.caller == nil { + return nil + } + outputs := rc.Run.Workflow.WorkflowCallConfig().Outputs + callerOutputs := make(map[string]string, len(outputs)) + ee := sync.OnceValue(func() *expressionEvaluator { return rc.NewExpressionEvaluator(ctx) }) + for k, v := range outputs { + value := v.Value + for range 2 { // two passes, the value resolves through a job output + var err error + if value, err = ee().Interpolate(ctx, value); err != nil { + return fmt.Errorf("unable to interpolate workflow output %s: %w", k, err) + } + } + callerOutputs[k] = 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 + return nil } // 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 != "" { + timeout, err := rc.ExprEval.Interpolate(ctx, job.TimeoutMinutes) + if err != nil { + common.Logger(ctx).Errorf("An error occurred when attempting to determine the job timeout: %s", err) + } else if timeout != "" { if timeoutMinutes, err := strconv.ParseInt(timeout, 10, 64); err == nil { return context.WithTimeout(ctx, time.Duration(timeoutMinutes)*time.Minute) } @@ -635,7 +663,7 @@ func archiveEntryMatchesPath(entryName, requestedPath string) bool { 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()) + ctx = withStepLogger(ctx, stepModel.Number, stepModel.ID, rc.ExprEval.InterpolateName(ctx, stepModel.String()), stage.String()) logWriter := rc.commandLogWriter(ctx) diff --git a/act/runner/reusable_workflow.go b/act/runner/reusable_workflow.go index 6d46571d..095a8c6f 100644 --- a/act/runner/reusable_workflow.go +++ b/act/runner/reusable_workflow.go @@ -95,9 +95,12 @@ func newRemoteReusableWorkflowExecutor(rc *RunContext) common.Executor { // 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) + interpolatedURL, err := rc.NewExpressionEvaluator(ctx).Interpolate(ctx, cloneURL) + if err != nil { + return fmt.Errorf("unable to interpolate the workflow clone URL: %w", err) + } return git.NewGitCloneExecutor(git.NewGitCloneExecutorInput{ - URL: cloneURL, + URL: interpolatedURL, Ref: ref, Dir: targetDirectory, Token: token, diff --git a/act/runner/run_context.go b/act/runner/run_context.go index 0f17095b..8c3efa89 100644 --- a/act/runner/run_context.go +++ b/act/runner/run_context.go @@ -67,7 +67,11 @@ type RunContext struct { actionInputs map[string]any // inputs of the composite action this runs, nil for a job Masks []string cleanUpJobContainer common.Executor - caller *caller // job calling this RunContext (reusable workflows) + caller *caller // job calling this RunContext (reusable workflows) + workflowCallInputs map[string]any // the caller's with:, resolved once by resolveWorkflowCall + workflowCallSecrets map[string]string // the caller's secrets:, resolved once by resolveWorkflowCall + jobRunDefaults model.RunDefaults // defaults.run, resolved once at job setup as GitHub does + platformImage string // container.image or the runs-on pick, resolved once by isEnabled // summaryFileInitialized tracks which per-step summary files (workflow/step-summary-N.md) // have already been created on the JobContainer. The runner sets up file-command files // via JobContainer.Copy at the start of every phase, which truncates them — fine for @@ -302,7 +306,7 @@ func splitVolumes(specs []string) ([]string, map[string]string, map[string]bool) } // Returns the binds and mounts for the container, resolving paths as appopriate -func (rc *RunContext) GetBindsAndMounts() ([]string, map[string]string) { +func (rc *RunContext) GetBindsAndMounts() ([]string, map[string]string, error) { name := rc.jobContainerName() ext := container.LinuxContainerEnvironmentExtensions{} @@ -311,7 +315,10 @@ func (rc *RunContext) GetBindsAndMounts() ([]string, map[string]string) { if container := job.Container(); container != nil { for _, v := range container.Volumes { if rc.ExprEval != nil { - v = rc.ExprEval.Interpolate(context.Background(), v) + var err error + if v, err = rc.ExprEval.Interpolate(context.Background(), v); err != nil { + return nil, nil, fmt.Errorf("unable to interpolate container.volumes: %w", err) + } } volumes = append(volumes, v) } @@ -345,7 +352,7 @@ func (rc *RunContext) GetBindsAndMounts() ([]string, map[string]string) { } } - return binds, mounts + return binds, mounts, nil } func (rc *RunContext) startHostEnvironment() common.Executor { @@ -436,7 +443,7 @@ var newContainer = container.NewContainer func (rc *RunContext) startJobContainer() common.Executor { return func(ctx context.Context) error { logger := common.Logger(ctx) - image := rc.platformImage(ctx) + image := rc.platformImage logWriter := rc.commandLogWriter(ctx) username, password, err := rc.handleCredentials(ctx) @@ -455,7 +462,10 @@ func (rc *RunContext) startJobContainer() common.Executor { envList = append(envList, fmt.Sprintf("%s=%s", "LANG", "C.UTF-8")) // Use same locale as GitHub Actions ext := container.LinuxContainerEnvironmentExtensions{} - binds, mounts := rc.GetBindsAndMounts() + binds, mounts, err := rc.GetBindsAndMounts() + if err != nil { + return err + } // specify the network to which the container will connect when `docker create` stage. (like execute command line: docker create --network ) // if using service containers, will create a new network for the containers. @@ -466,7 +476,10 @@ func (rc *RunContext) startJobContainer() common.Executor { for serviceID, spec := range rc.Run.Job().Services { // GitHub compatibility: skip services whose image evaluates to an // empty string, enabling conditional services via expressions - serviceImage := rc.ExprEval.Interpolate(ctx, spec.Image) + serviceImage, err := rc.ExprEval.Interpolate(ctx, spec.Image) + if err != nil { + return fmt.Errorf("unable to interpolate service %s image: %w", serviceID, err) + } if serviceImage == "" { logger.Infof("The service '%s' will not be started because the container definition has an empty image.", serviceID) continue @@ -476,16 +489,20 @@ func (rc *RunContext) startJobContainer() common.Executor { // a service reaches the internet the way the job does; its own env still wins maps0.Copy(interpolatedEnvs, rc.Config.ProxyEnv) for k, v := range spec.Env { - interpolatedEnvs[k] = rc.ExprEval.Interpolate(ctx, v) + if interpolatedEnvs[k], err = rc.ExprEval.Interpolate(ctx, v); err != nil { + return fmt.Errorf("unable to interpolate service %s env %s: %w", serviceID, k, err) + } } envs := make([]string, 0, len(interpolatedEnvs)) for k, v := range interpolatedEnvs { envs = append(envs, fmt.Sprintf("%s=%s", k, v)) } // interpolate cmd - interpolatedCmd := make([]string, 0, len(spec.Cmd)) - for _, v := range spec.Cmd { - interpolatedCmd = append(interpolatedCmd, rc.ExprEval.Interpolate(ctx, v)) + interpolatedCmd := make([]string, len(spec.Cmd)) + for i, v := range spec.Cmd { + if interpolatedCmd[i], err = rc.ExprEval.Interpolate(ctx, v); err != nil { + return fmt.Errorf("unable to interpolate service %s command: %w", serviceID, err) + } } // keep these local: reusing username/password would overwrite the // credentials the job container is pulled with further down @@ -494,20 +511,28 @@ func (rc *RunContext) startJobContainer() common.Executor { return fmt.Errorf("failed to handle service %s credentials: %w", serviceID, err) } - interpolatedVolumes := make([]string, 0, len(spec.Volumes)) - for _, volume := range spec.Volumes { - interpolatedVolumes = append(interpolatedVolumes, rc.ExprEval.Interpolate(ctx, volume)) + interpolatedVolumes := make([]string, len(spec.Volumes)) + for i, volume := range spec.Volumes { + if interpolatedVolumes[i], err = rc.ExprEval.Interpolate(ctx, volume); err != nil { + return fmt.Errorf("unable to interpolate service %s volumes: %w", serviceID, err) + } } serviceBinds, serviceMounts, _ := splitVolumes(interpolatedVolumes) - interpolatedPorts := make([]string, 0, len(spec.Ports)) - for _, port := range spec.Ports { - interpolatedPorts = append(interpolatedPorts, rc.ExprEval.Interpolate(ctx, port)) + interpolatedPorts := make([]string, len(spec.Ports)) + for i, port := range spec.Ports { + if interpolatedPorts[i], err = rc.ExprEval.Interpolate(ctx, port); err != nil { + return fmt.Errorf("unable to interpolate service %s ports: %w", serviceID, err) + } } exposedPorts, portBindings, err := nat.ParsePortSpecs(interpolatedPorts) if err != nil { return fmt.Errorf("failed to parse service %s ports: %w", serviceID, err) } + serviceOptions, err := rc.ExprEval.Interpolate(ctx, spec.Options) + if err != nil { + return fmt.Errorf("unable to interpolate service %s options: %w", serviceID, err) + } serviceContainerName := createContainerName(rc.jobContainerName(), serviceID) c := newContainer(&container.NewContainerInput{ @@ -525,7 +550,7 @@ func (rc *RunContext) startJobContainer() common.Executor { UsernsMode: rc.Config.UsernsMode, Platform: rc.Config.ContainerArchitecture, AutoRemove: false, // so a dead service's log survives, cleanupJobResources removes it - WorkflowOptions: rc.ExprEval.Interpolate(ctx, spec.Options), + WorkflowOptions: serviceOptions, NetworkMode: networkName, NetworkAliases: []string{serviceID}, ExposedPorts: exposedPorts, @@ -541,6 +566,10 @@ func (rc *RunContext) startJobContainer() common.Executor { // For Gitea, `jobContainerNetwork` should be the same as `networkName` jobContainerNetwork := networkName + workflowOptions, err := rc.workflowOptions(ctx) + if err != nil { + return err + } rc.JobContainer = newContainer(&container.NewContainerInput{ Cmd: nil, Entrypoint: []string{"/bin/sleep", fmt.Sprint(rc.Config.ContainerMaxLifetime.Round(time.Second).Seconds())}, @@ -560,7 +589,7 @@ func (rc *RunContext) startJobContainer() common.Executor { UsernsMode: rc.Config.UsernsMode, Platform: rc.Config.ContainerArchitecture, RunnerOptions: rc.Config.ContainerOptions, - WorkflowOptions: rc.workflowOptions(ctx), + WorkflowOptions: workflowOptions, AutoRemove: true, ValidVolumes: rc.validVolumes(), AllocatePTY: rc.Config.AllocatePTY, @@ -931,7 +960,7 @@ func (rc *RunContext) interpolateOutputs() common.Executor { outputs := make(map[string]string, len(rc.outputTemplate)) var err error for k, v := range rc.outputTemplate { - if outputs[k], err = ee.interpolate(ctx, v); err != nil { + if outputs[k], err = ee.Interpolate(ctx, v); err != nil { err = fmt.Errorf("failed to evaluate job output %q: %w", k, err) break } @@ -951,7 +980,7 @@ func (rc *RunContext) interpolateOutputs() common.Executor { func (rc *RunContext) startContainer() common.Executor { return func(ctx context.Context) error { var err error - if rc.IsHostEnv(ctx) { + if rc.IsHostEnv() { err = rc.startHostEnvironment()(ctx) } else { err = rc.startJobContainer()(ctx) @@ -981,10 +1010,8 @@ func (rc *RunContext) cleanupFailedStart(ctx context.Context) { } } -func (rc *RunContext) IsHostEnv(ctx context.Context) bool { - platform := rc.runsOnImage(ctx) - image := rc.containerImage(ctx) - return image == "" && strings.EqualFold(platform, "-self-hosted") +func (rc *RunContext) IsHostEnv() bool { + return strings.EqualFold(rc.platformImage, "-self-hosted") } func (rc *RunContext) stopContainer() common.Executor { @@ -1069,32 +1096,35 @@ func (rc *RunContext) Executor() (common.Executor, error) { }, nil } -func (rc *RunContext) containerImage(ctx context.Context) string { - job := rc.Run.Job() - - c := job.Container() - if c != nil { - return rc.ExprEval.Interpolate(ctx, c.Image) +func (rc *RunContext) containerImage(ctx context.Context) (string, error) { + c := rc.Run.Job().Container() + if c == nil { + return "", nil } - - return "" + image, err := rc.ExprEval.Interpolate(ctx, c.Image) + if err != nil { + return "", fmt.Errorf("unable to interpolate container.image: %w", err) + } + return image, nil } -func (rc *RunContext) runsOnImage(ctx context.Context) string { +func (rc *RunContext) runsOnImage(ctx context.Context) (string, error) { if rc.Run.Job().RunsOn() == nil { common.Logger(ctx).Errorf("'runs-on' key not defined in %s", rc.String()) } - job := rc.Run.Job() - runsOn := job.RunsOn() + runsOn := rc.Run.Job().RunsOn() for i, v := range runsOn { - runsOn[i] = rc.ExprEval.Interpolate(ctx, v) + var err error + if runsOn[i], err = rc.ExprEval.Interpolate(ctx, v); err != nil { + return "", fmt.Errorf("unable to interpolate runs-on: %w", err) + } } if rc.Config.PlatformPicker != nil { - return rc.Config.PlatformPicker(runsOn) + return rc.Config.PlatformPicker(runsOn), nil } - return "" + return "", nil } func (rc *RunContext) runsOnPlatformNames(ctx context.Context) []string { @@ -1115,21 +1145,26 @@ func (rc *RunContext) runsOnPlatformNames(ctx context.Context) []string { return model.RunsOnFromNode(rawRunsOn) } -func (rc *RunContext) platformImage(ctx context.Context) string { - if containerImage := rc.containerImage(ctx); containerImage != "" { - return containerImage +// resolvePlatformImage evaluates the job's image once, so every consumer sees the image the job started with. +func (rc *RunContext) resolvePlatformImage(ctx context.Context) error { + image, err := rc.containerImage(ctx) + if err == nil && image == "" { + image, err = rc.runsOnImage(ctx) } - - return rc.runsOnImage(ctx) + rc.platformImage = image + return err } -func (rc *RunContext) workflowOptions(ctx context.Context) string { +func (rc *RunContext) workflowOptions(ctx context.Context) (string, error) { c := rc.Run.Job().Container() if c == nil { - return "" + return "", nil } - - return rc.ExprEval.Interpolate(ctx, c.Options) + options, err := rc.ExprEval.Interpolate(ctx, c.Options) + if err != nil { + return "", fmt.Errorf("unable to interpolate container.options: %w", err) + } + return options, nil } func (rc *RunContext) isEnabled(ctx context.Context) (bool, error) { @@ -1159,8 +1194,10 @@ func (rc *RunContext) isEnabled(ctx context.Context) (bool, error) { return true, nil } - img := rc.platformImage(ctx) - if img == "" { + if err := rc.resolvePlatformImage(ctx); err != nil { + return false, err + } + if rc.platformImage == "" { for _, platformName := range rc.runsOnPlatformNames(ctx) { l.Infof("Skipping unsupported platform -- Try running with `-P %+v=...`", platformName) } @@ -1547,7 +1584,7 @@ func (rc *RunContext) imageOS(ctx context.Context) string { // log that runs-on is missing. return "" } - if imageOS := imageOSFromImage(rc.platformImage(ctx)); imageOS != "" { + if imageOS := imageOSFromImage(rc.platformImage); imageOS != "" { return imageOS } @@ -1609,14 +1646,23 @@ func (rc *RunContext) interpolateCredentials(ctx context.Context, credentials ma } ee := rc.NewExpressionEvaluator(ctx) - username := ee.Interpolate(ctx, credentials["username"]) - if username == "" { - return "", "", errors.New("failed to interpolate " + prefix + "credentials.username") + interpolate := func(key string) (string, error) { + value, err := ee.Interpolate(ctx, credentials[key]) + if err != nil { + return "", fmt.Errorf("failed to interpolate %scredentials.%s: %w", prefix, key, err) + } + if value == "" { + return "", fmt.Errorf("failed to interpolate %scredentials.%s", prefix, key) + } + return value, nil } - password := ee.Interpolate(ctx, credentials["password"]) - if password == "" { - return "", "", errors.New("failed to interpolate " + prefix + "credentials.password") + username, err := interpolate("username") + if err != nil { + return "", "", err + } + password, err := interpolate("password") + if err != nil { + return "", "", err } - return username, password, nil } diff --git a/act/runner/run_context_test.go b/act/runner/run_context_test.go index cded68b3..08dc936b 100644 --- a/act/runner/run_context_test.go +++ b/act/runner/run_context_test.go @@ -267,6 +267,7 @@ func startJobContainerInputs(t *testing.T, workflowYAML string, cfg *Config) []* }, } rc.ExprEval = rc.NewExpressionEvaluator(t.Context()) + require.NoError(t, rc.resolvePlatformImage(t.Context())) // the inputs are built before the missing daemon fails the first call t.Setenv("DOCKER_HOST", "unix:///nonexistent.sock") @@ -451,7 +452,8 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) { config := testcase.rc.Config config.Workdir = testcase.name config.BindWorkdir = bindWorkDir - gotbind, gotmount := rctemplate.GetBindsAndMounts() + gotbind, gotmount, err := rctemplate.GetBindsAndMounts() + require.NoError(t, err) // Name binds/mounts are either/or if config.BindWorkdir { @@ -510,7 +512,8 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) { rc.Run.Workflow.Jobs = map[string]*model.Job{"job1": job} rc.ExprEval = rc.NewExpressionEvaluator(context.Background()) - gotbind, gotmount := rc.GetBindsAndMounts() + gotbind, gotmount, err := rc.GetBindsAndMounts() + require.NoError(t, err) assert.Contains(t, gotbind, "/host/mame/roms:/root/.mame/roms:ro") assert.NotContains(t, gotbind, "${{ secrets.MAME }}") assert.NotContains(t, gotmount, "${{ secrets.MAME }}") @@ -539,7 +542,8 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) { rc.Run.JobID = "job1" rc.Run.Workflow.Jobs = map[string]*model.Job{"job1": job} - gotbind, gotmount := rc.GetBindsAndMounts() + gotbind, gotmount, err := rc.GetBindsAndMounts() + require.NoError(t, err) if len(testcase.wantbind) > 0 { assert.Contains(t, gotbind, testcase.wantbind) @@ -574,11 +578,13 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) { Config: &Config{}, } - _, gotmount := rc.GetBindsAndMounts() + _, gotmount, err := rc.GetBindsAndMounts() + require.NoError(t, err) assert.NotContains(t, gotmount, sharedToolCacheVolume) rc.Config.SharedToolCache = true - _, gotmount = rc.GetBindsAndMounts() + _, gotmount, err = rc.GetBindsAndMounts() + require.NoError(t, err) assert.Equal(t, container.DefaultToolCache, gotmount[sharedToolCacheVolume]) }) } @@ -660,8 +666,10 @@ func TestInterpolateOutputsIsPerMatrixCombo(t *testing.T) { r := &runnerImpl{config: &Config{}} ctx := context.Background() - rcA := r.newRunContext(ctx, run, map[string]any{"v": "a"}) - rcB := r.newRunContext(ctx, run, map[string]any{"v": "b"}) + rcA, err := r.newRunContext(ctx, run, map[string]any{"v": "a"}) + require.NoError(t, err) + rcB, err := r.newRunContext(ctx, run, map[string]any{"v": "b"}) + require.NoError(t, err) require.NoError(t, rcA.interpolateOutputs()(ctx)) require.NoError(t, rcB.interpolateOutputs()(ctx)) @@ -1334,12 +1342,14 @@ func TestRunContextImageOS(t *testing.T) { t.Run("prefers the release in the resolved image tag", func(t *testing.T) { rc := createRunsOnRunContext(t, "ubuntu-latest") rc.Config.PlatformPicker = func([]string) string { return "docker.gitea.com/runner-images:ubuntu-24.04" } + require.NoError(t, rc.resolvePlatformImage(ctx)) assert.Equal(t, "ubuntu24", rc.imageOS(ctx)) }) t.Run("falls back to the runs-on label", func(t *testing.T) { rc := createRunsOnRunContext(t, "ubuntu-22.04") rc.Config.PlatformPicker = func([]string) string { return "some-image" } + require.NoError(t, rc.resolvePlatformImage(ctx)) assert.Equal(t, "ubuntu22", rc.imageOS(ctx)) }) diff --git a/act/runner/runner.go b/act/runner/runner.go index 47567217..91102930 100644 --- a/act/runner/runner.go +++ b/act/runner/runner.go @@ -198,7 +198,10 @@ func (runner *runnerImpl) NewPlanExecutor(plan *model.Plan) common.Executor { log.Debugf("Job.Strategy.MaxParallelString: %v", job.Strategy.MaxParallelString) log.Debugf("Job.Strategy.RawMatrix: %v", job.Strategy.RawMatrix) - strategyRc := runner.newRunContext(ctx, run, nil) + strategyRc, err := runner.newRunContext(ctx, run, nil) + if err != nil { + return err + } // 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 { @@ -230,7 +233,10 @@ func (runner *runnerImpl) NewPlanExecutor(plan *model.Plan) common.Executor { 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, err := runner.newRunContext(ctx, run, matrix) + if err != nil { + return err + } rc.JobName = rc.Name if len(matrixes) > 1 { rc.Name = fmt.Sprintf("%s-%d", rc.Name, i+1) @@ -315,7 +321,7 @@ func handleFailure(plan *model.Plan) common.Executor { } } -func (runner *runnerImpl) newRunContext(ctx context.Context, run *model.Run, matrix map[string]any) *RunContext { +func (runner *runnerImpl) newRunContext(ctx context.Context, run *model.Run, matrix map[string]any) (*RunContext, error) { rc := &RunContext{ Config: runner.config, Run: run, @@ -324,15 +330,18 @@ func (runner *runnerImpl) newRunContext(ctx context.Context, run *model.Run, mat Matrix: matrix, caller: runner.caller, } + if err := rc.resolveWorkflowCall(ctx); err != nil { + return nil, err + } rc.ExprEval = rc.NewExpressionEvaluator(ctx) - rc.Name = rc.maskSecrets(rc.ExprEval.Interpolate(ctx, run.String())) + rc.Name = rc.maskSecrets(rc.ExprEval.InterpolateName(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 + return rc, nil } // For Gitea diff --git a/act/runner/step.go b/act/runner/step.go index cdc9d56d..962679e0 100644 --- a/act/runner/step.go +++ b/act/runner/step.go @@ -11,6 +11,7 @@ import ( "path" "strconv" "strings" + "sync" "time" "gitea.com/gitea/runner/act/common" @@ -82,9 +83,11 @@ func runStepExecutor(step step, stage stepStage, executor common.Executor) commo rc.StepResults[rc.CurrentStep] = stepResult } - setupEnv(ctx, step) - - runStep, err := isStepEnabled(ctx, ifExpression, step, stage) + err := setupEnv(ctx, step) + var runStep bool + if err == nil { + runStep, err = isStepEnabled(ctx, ifExpression, step, stage) + } if err != nil { stepResult.Conclusion = model.StepStatusFailure stepResult.Outcome = model.StepStatusFailure @@ -99,7 +102,7 @@ func runStepExecutor(step step, stage stepStage, executor common.Executor) commo return nil } - stepString := rc.ExprEval.Interpolate(ctx, stepModel.String()) + stepString := rc.ExprEval.InterpolateName(ctx, stepModel.String()) if strings.Contains(stepString, "::add-mask::") { stepString = "add-mask command" } @@ -221,8 +224,10 @@ func runStepExecutor(step step, stage stepStage, executor common.Executor) commo } func evaluateStepTimeout(ctx context.Context, exprEval *expressionEvaluator, stepModel *model.Step) (context.Context, context.CancelFunc) { - timeout := exprEval.Interpolate(ctx, stepModel.TimeoutMinutes) - if timeout != "" { + timeout, err := exprEval.Interpolate(ctx, stepModel.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) } @@ -230,27 +235,33 @@ func evaluateStepTimeout(ctx context.Context, exprEval *expressionEvaluator, ste return ctx, func() {} } -func setupEnv(ctx context.Context, step step) { +func setupEnv(ctx context.Context, step step) error { rc := step.getRunContext() mergeEnv(ctx, step) // merge step env last, since it should not be overwritten mergeIntoMap(step, step.getEnv(), step.getStepModel().GetEnv()) + var err error exprEval := rc.NewExpressionEvaluator(ctx) for k, v := range *step.getEnv() { if !strings.HasPrefix(k, "INPUT_") { - (*step.getEnv())[k] = exprEval.Interpolate(ctx, v) + if (*step.getEnv())[k], err = exprEval.Interpolate(ctx, v); err != nil { + return fmt.Errorf("unable to interpolate env %s: %w", k, err) + } } } // 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()) + inputEval := sync.OnceValue(func() *expressionEvaluator { return rc.NewExpressionEvaluatorWithEnv(ctx, *step.getEnv()) }) for k, v := range *step.getEnv() { if strings.HasPrefix(k, "INPUT_") { - (*step.getEnv())[k] = exprEval.Interpolate(ctx, v) + if (*step.getEnv())[k], err = inputEval().Interpolate(ctx, v); err != nil { + return fmt.Errorf("unable to interpolate env %s: %w", k, err) + } } } + return nil } func mergeEnv(ctx context.Context, step step) { diff --git a/act/runner/step_action_local.go b/act/runner/step_action_local.go index 49c7dfec..ec78772e 100644 --- a/act/runner/step_action_local.go +++ b/act/runner/step_action_local.go @@ -122,15 +122,19 @@ func (sal *stepActionLocal) getActionModel() *model.Action { return sal.action } -func (sal *stepActionLocal) getCompositeRunContext(ctx context.Context) *RunContext { +func (sal *stepActionLocal) getCompositeRunContext(ctx context.Context) (*RunContext, error) { 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) + compositeRunContext, err := newCompositeRunContext(ctx, sal.RunContext, sal, containerActionDir) + if err != nil { + return nil, err + } + sal.compositeRunContext = compositeRunContext + sal.compositeSteps = compositeRunContext.compositeExecutor(sal.action) } - return sal.compositeRunContext + return sal.compositeRunContext, nil } func (sal *stepActionLocal) getCompositeSteps() *compositeSteps { diff --git a/act/runner/step_action_remote.go b/act/runner/step_action_remote.go index d4ad2c37..11037e97 100644 --- a/act/runner/step_action_remote.go +++ b/act/runner/step_action_remote.go @@ -51,7 +51,11 @@ func (sar *stepActionRemote) prepareActionExecutor() common.Executor { // 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) + uses, err := sar.RunContext.NewExpressionEvaluator(ctx).Interpolate(ctx, sar.Step.Uses) + if err != nil { + return fmt.Errorf("unable to interpolate uses: %w", err) + } + sar.Step.Uses = uses github := sar.getGithubContext(ctx) // read before remoteAction is set, so `$/` resolves against the enclosing action if strings.HasPrefix(sar.Step.Uses, selfRepoPrefix) { @@ -160,8 +164,11 @@ func (sar *stepActionRemote) main() common.Executor { 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"])) + checkoutPath, err := sar.RunContext.NewExpressionEvaluator(ctx).Interpolate(ctx, sar.Step.With["path"]) + if err != nil { + return fmt.Errorf("unable to interpolate with.path: %w", err) + } + copyToPath := path.Join(sar.RunContext.JobContainer.ToContainerPath(sar.RunContext.Config.Workdir), checkoutPath) return sar.RunContext.JobContainer.CopyDir(copyToPath, sar.RunContext.Config.Workdir+string(filepath.Separator)+".", sar.RunContext.Config.UseGitIgnore)(ctx) } @@ -229,14 +236,18 @@ func (sar *stepActionRemote) getActionModel() *model.Action { return sar.action } -func (sar *stepActionRemote) getCompositeRunContext(ctx context.Context) *RunContext { +func (sar *stepActionRemote) getCompositeRunContext(ctx context.Context) (*RunContext, error) { 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) + compositeRunContext, err := newCompositeRunContext(ctx, sar.RunContext, sar, containerActionDir) + if err != nil { + return nil, err + } + sar.compositeRunContext = compositeRunContext + sar.compositeSteps = 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 @@ -244,11 +255,14 @@ func (sar *stepActionRemote) getCompositeRunContext(ctx context.Context) *RunCon // 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) + env, err := evaluateCompositeInputAndEnv(ctx, sar.RunContext, sar) + if err != nil { + return nil, err + } sar.compositeRunContext.setCompositeActionEnv(env) sar.compositeRunContext.ExtraPath = sar.RunContext.ExtraPath } - return sar.compositeRunContext + return sar.compositeRunContext, nil } func (sar *stepActionRemote) getCompositeSteps() *compositeSteps { diff --git a/act/runner/step_action_remote_test.go b/act/runner/step_action_remote_test.go index 1ac8dd2e..bc8f2891 100644 --- a/act/runner/step_action_remote_test.go +++ b/act/runner/step_action_remote_test.go @@ -254,7 +254,8 @@ func TestStepActionRemote(t *testing.T) { for _, value := range []string{"first", "second"} { step.env["INPUT_SHARED"] = value - composite := step.getCompositeRunContext(t.Context()) + composite, err := step.getCompositeRunContext(t.Context()) + require.NoError(t, err) assert.Equal(t, map[string]any{"shared": value}, composite.actionInputs) assert.NotContains(t, composite.Env, "INPUT_SHARED") } diff --git a/act/runner/step_docker.go b/act/runner/step_docker.go index f61c44a0..1923a6a6 100644 --- a/act/runner/step_docker.go +++ b/act/runner/step_docker.go @@ -6,6 +6,7 @@ package runner import ( "context" + "fmt" "strings" "gitea.com/gitea/runner/act/common" @@ -58,17 +59,28 @@ func (sd *stepDocker) runUsesContainer() common.Executor { 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"])) + args, err := eval.Interpolate(ctx, step.With["args"]) + if err != nil { + return fmt.Errorf("unable to interpolate with.args: %w", err) + } + cmd, err := shellquote.Split(args) if err != nil { return err } var entrypoint []string - if entry := eval.Interpolate(ctx, step.With["entrypoint"]); entry != "" { + entry, err := eval.Interpolate(ctx, step.With["entrypoint"]) + if err != nil { + return fmt.Errorf("unable to interpolate with.entrypoint: %w", err) + } + if entry != "" { entrypoint = []string{entry} } - stepContainer := newStepContainer(ctx, sd, image, cmd, entrypoint, "") + stepContainer, err := newStepContainer(ctx, sd, image, cmd, entrypoint, "") + if err != nil { + return err + } return common.NewPipelineExecutor( stepContainer.Pull(rc.Config.ForcePull), diff --git a/act/runner/step_docker_test.go b/act/runner/step_docker_test.go index ff159f7e..0f90c493 100644 --- a/act/runner/step_docker_test.go +++ b/act/runner/step_docker_test.go @@ -16,6 +16,7 @@ import ( "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) { @@ -144,7 +145,7 @@ func TestStepDockerNewStepContainerAllocatePTY(t *testing.T) { } sd.RunContext.ExprEval = sd.RunContext.NewExpressionEvaluator(ctx) - _ = newStepContainer(ctx, sd, "node:14", []string{"echo", "hi"}, nil, "") + _, _ = newStepContainer(ctx, sd, "node:14", []string{"echo", "hi"}, nil, "") assert.Equal(t, tc.allocPTY, captured.AllocatePTY) }) } @@ -210,10 +211,10 @@ func TestStepDockerNewStepContainerNetworkMode(t *testing.T) { } sd.RunContext.ExprEval = sd.RunContext.NewExpressionEvaluator(ctx) - assert.Equal(t, tc.expectDefault, sd.RunContext.IsHostEnv(ctx), - "IsHostEnv mismatch for platform %q", tc.platform) + require.NoError(t, sd.RunContext.resolvePlatformImage(ctx)) + assert.Equal(t, tc.expectDefault, sd.RunContext.IsHostEnv(), "IsHostEnv mismatch for platform %q", tc.platform) - _ = newStepContainer(ctx, sd, "alpine:3.20", []string{"echo", "hello"}, nil, "") + _, _ = newStepContainer(ctx, sd, "alpine:3.20", []string{"echo", "hello"}, nil, "") if tc.expectDefault { assert.Equal(t, "default", captured.NetworkMode, diff --git a/act/runner/step_run.go b/act/runner/step_run.go index 7e4aba33..c0f5f6a8 100644 --- a/act/runner/step_run.go +++ b/act/runner/step_run.go @@ -254,12 +254,21 @@ func getScriptName(rc *RunContext, step *model.Step) string { // 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) - implicitShell := sr.setupShell(ctx) - sr.setupWorkingDirectory(ctx) + eval := sr.RunContext.NewStepExpressionEvaluator(ctx, sr) + implicitShell, err := sr.setupShell(ctx, eval) + if err != nil { + return "", "", err + } + if err := sr.setupWorkingDirectory(ctx, eval); err != nil { + return "", "", err + } step := sr.Step - script = sr.RunContext.NewStepExpressionEvaluator(ctx, sr).Interpolate(ctx, step.Run) + script, err = eval.Interpolate(ctx, step.Run) + if err != nil { + return "", "", fmt.Errorf("unable to interpolate the run script: %w", err) + } sr.interpolatedScript = script // GitHub matches the built-in names case-insensitively, so `shell: PWSH` is valid @@ -313,19 +322,21 @@ func (sr *stepRun) setupShellCommand(ctx context.Context) (name, script string, return name, script, err } -func (sr *stepRun) setupShell(ctx context.Context) bool { +func (sr *stepRun) setupShell(ctx context.Context, eval *expressionEvaluator) (bool, error) { rc := sr.RunContext step := sr.Step - if step.Shell == "" { - step.Shell = rc.Run.Job().Defaults.Run.Shell + shell, err := eval.Interpolate(ctx, step.Shell) + if err != nil { + return false, fmt.Errorf("unable to interpolate the shell: %w", err) } - - step.Shell = rc.NewStepExpressionEvaluator(ctx, sr).Interpolate(ctx, step.Shell) - - if step.Shell == "" { - step.Shell = rc.Run.Workflow.Defaults.Run.Shell + if shell == "" { + shell = rc.jobRunDefaults.Shell } + if shell == "" { + shell = rc.Run.Workflow.Defaults.Run.Shell + } + step.Shell = shell implicitShell := step.Shell == "" if implicitShell { @@ -349,7 +360,7 @@ func (sr *stepRun) setupShell(ctx context.Context) bool { } } } - return implicitShell + return implicitShell, nil } // containerHasBash probes once per job, else every implicit-shell step pays for an exec. @@ -364,23 +375,19 @@ func (rc *RunContext) containerHasBash(ctx context.Context, env map[string]strin return *top.hasBash } -func (sr *stepRun) setupWorkingDirectory(ctx context.Context) { +func (sr *stepRun) setupWorkingDirectory(ctx context.Context, eval *expressionEvaluator) error { rc := sr.RunContext - step := sr.Step - var workingdirectory string - - if step.WorkingDirectory == "" { - workingdirectory = rc.Run.Job().Defaults.Run.WorkingDirectory - } else { - workingdirectory = step.WorkingDirectory + workingdirectory, err := eval.Interpolate(ctx, sr.Step.WorkingDirectory) + if err != nil { + return fmt.Errorf("unable to interpolate the working directory: %w", err) } - - // jobs can receive context values, so we interpolate - workingdirectory = rc.NewStepExpressionEvaluator(ctx, sr).Interpolate(ctx, workingdirectory) - - // but top level keys in workflow file like `defaults` or `env` can't + if workingdirectory == "" { + workingdirectory = rc.jobRunDefaults.WorkingDirectory + } + // top level keys in workflow file like `defaults` or `env` can't hold expressions if workingdirectory == "" { workingdirectory = rc.Run.Workflow.Defaults.Run.WorkingDirectory } sr.WorkingDirectory = workingdirectory + return nil } diff --git a/act/runner/step_run_test.go b/act/runner/step_run_test.go index f844881e..2c170699 100644 --- a/act/runner/step_run_test.go +++ b/act/runner/step_run_test.go @@ -6,6 +6,7 @@ package runner import ( "bytes" + "cmp" "context" "io" "os" @@ -42,17 +43,12 @@ func TestStepRun(t *testing.T) { JobID: "1", Workflow: &model.Workflow{ Jobs: map[string]*model.Job{ - "1": { - Defaults: model.Defaults{ - Run: model.RunDefaults{ - Shell: "bash", - }, - }, - }, + "1": {}, }, }, }, - JobContainer: cm, + JobContainer: cm, + jobRunDefaults: model.RunDefaults{Shell: "bash"}, }, Step: &model.Step{ ID: "1", @@ -84,13 +80,13 @@ func TestStepRun(t *testing.T) { func TestStepRunShellParity(t *testing.T) { tests := []struct { - name, shell, workingDir string - env map[string]string - host bool - probeErr error - wantExt string - wantCmd []string - wantErr string + name, run, shell, workingDir string + env map[string]string + host bool + probeErr error + wantExt string + wantCmd []string + wantErr string }{ { name: "implicit host bash", @@ -130,6 +126,21 @@ func TestStepRunShellParity(t *testing.T) { wantExt: ".py", wantCmd: []string{"python", "/var/run/act/workflow/1.py"}, }, + { + name: "run expression without a context", + run: "echo ${{ COMMIT_SHA }}", + wantErr: "unable to interpolate the run script:", + }, + { + name: "shell expression without a context", + shell: "${{ SHELL }}", + wantErr: "unable to interpolate the shell:", + }, + { + name: "working directory expression without a context", + workingDir: "${{ DIR }}", + wantErr: "unable to interpolate the working directory:", + }, } for _, test := range tests { @@ -156,13 +167,13 @@ func TestStepRunShellParity(t *testing.T) { Run: &model.Run{JobID: "1", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"1": {}}}}, JobContainer: jobContainer, }, - Step: &model.Step{ID: "1", Run: "echo hi", Shell: test.shell, WorkingDirectory: test.workingDir}, + Step: &model.Step{ID: "1", Run: cmp.Or(test.run, "echo hi"), Shell: test.shell, WorkingDirectory: test.workingDir}, env: test.env, } name, script, err := sr.setupShellCommand(t.Context()) if test.wantErr != "" { - require.EqualError(t, err, test.wantErr) + require.ErrorContains(t, err, test.wantErr) return } require.NoError(t, err) diff --git a/act/runner/step_test.go b/act/runner/step_test.go index f9a0a617..0659ac19 100644 --- a/act/runner/step_test.go +++ b/act/runner/step_test.go @@ -160,7 +160,7 @@ func TestSetupEnv(t *testing.T) { sm.On("getStepModel").Return(step) sm.On("getEnv").Return(&env) - setupEnv(context.Background(), sm) + require.NoError(t, setupEnv(context.Background(), sm)) // These are commit or system specific delete(env, "GITHUB_REF")