fix: fail the step or job whose expression cannot be interpolated (#1199)

An expression that failed to evaluate was logged and replaced by an empty string, so a `run:` step executed an empty script and reported success. The same swallow covered `shell:`, `working-directory:`, step `env:`, `with:`, `uses:`, the job's `env:`, `container:`, `services:`, `runs-on:` and outputs, and a called workflow's `with:` and `secrets:`. Every interpolation now propagates its error as actions/runner does: step-level values fail the step, job-level values fail the job at setup, `timeout-minutes` logs the error and runs unbounded, and a job or step name keeps its source text.

`defaults.run` and a called workflow's inputs and secrets are resolved once at job setup with the job context rather than per step, and the job's image is resolved once, so host mode and `ImageOS` derive from the image the job started with.

Closes https://gitea.com/gitea/runner/issues/392
Closes https://gitea.com/gitea/runner/issues/555

---------

Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: silverwind <2021+silverwind@noreply.gitea.com>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1199
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: bircni <bircni@icloud.com>
This commit is contained in:
bircni
2026-09-03 10:43:04 +00:00
committed by silverwind
co-authored by silverwind silverwind
parent 7f53eca716
commit 1745c7c841
20 changed files with 495 additions and 290 deletions
+58 -29
View File
@@ -32,7 +32,7 @@ type actionStep interface {
step step
getActionModel() *model.Action getActionModel() *model.Action
getCompositeRunContext(context.Context) *RunContext getCompositeRunContext(context.Context) (*RunContext, error)
getCompositeSteps() *compositeSteps getCompositeSteps() *compositeSteps
} }
@@ -172,7 +172,9 @@ func runActionImpl(step actionStep, actionDir string, remoteAction *remoteAction
rc.withGithubEnv(ctx, step.getGithubContext(ctx), *step.getEnv()) rc.withGithubEnv(ctx, step.getGithubContext(ctx), *step.getEnv())
populateEnvsFromSavedState(step.getEnv(), step, rc) 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) actionLocation := path.Join(actionDir, actionPath)
actionName, containerActionDir := getContainerActionPaths(stepModel, actionLocation, rc) 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) 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 { if err != nil {
return err return err
} }
ee := evalDockerEnv(ctx, step, action)
if action.Runs.Args != nil { if action.Runs.Args != nil {
// a fresh slice, the manifest is evaluated again for every stage // a fresh slice, the manifest is evaluated again for every stage
cmd = make([]string, len(action.Runs.Args)) cmd = make([]string, len(action.Runs.Args))
for i, v := range 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) entrypoint, err := dockerEntrypoint(ctx, step, eval, stage)
if err != nil { if err != nil {
return err 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( return common.NewPipelineExecutor(
prepImage, prepImage,
stepContainer.Pull(forcePull), stepContainer.Pull(forcePull),
@@ -392,7 +406,11 @@ func dockerEntrypoint(ctx context.Context, step actionStep, eval *expressionEval
default: default:
entrypoint = runs.Entrypoint entrypoint = runs.Entrypoint
if 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 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. // 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() rc := step.getRunContext()
stepModel := step.getStepModel() stepModel := step.getStepModel()
var err error
inputs := make(map[string]string) inputs := make(map[string]string)
eval := rc.NewExpressionEvaluator(ctx) eval := rc.NewExpressionEvaluator(ctx)
// Set Defaults // Set Defaults
for k, input := range action.Inputs { 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 { for k, v := range stepModel.With {
inputs[k] = eval.Interpolate(ctx, v) 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) 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) ee := rc.NewActionInputsExpressionEvaluator(ctx, step)
for k, v := range *step.getEnv() { 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() rc := step.getRunContext()
logWriter := rc.commandLogWriter(ctx) logWriter := rc.commandLogWriter(ctx)
envList := make([]string, 0) 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)...) 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() networkMode := "container:" + rc.jobContainerName()
if rc.IsHostEnv(ctx) { if rc.IsHostEnv() {
networkMode = "default" networkMode = "default"
} }
return ContainerNewContainer(&container.NewContainerInput{ return ContainerNewContainer(&container.NewContainerInput{
@@ -467,7 +493,7 @@ func newStepContainer(ctx context.Context, step step, image string, cmd, entrypo
AutoRemove: true, AutoRemove: true,
ValidVolumes: rc.validVolumes(), ValidVolumes: rc.validVolumes(),
AllocatePTY: rc.Config.AllocatePTY, AllocatePTY: rc.Config.AllocatePTY,
}) }), nil
} }
func populateEnvsFromSavedState(env *map[string]string, step actionStep, rc *RunContext) { 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) eval := rc.NewExpressionEvaluator(ctx)
for inputID, input := range action.Inputs { for inputID, input := range action.Inputs {
envKey := regexp.MustCompile("[^A-Z0-9-]").ReplaceAllString(strings.ToUpper(inputID), "_") envKey := regexp.MustCompile("[^A-Z0-9-]").ReplaceAllString(strings.ToUpper(inputID), "_")
envKey = "INPUT_" + envKey envKey = "INPUT_" + envKey
if _, ok := (*env)[envKey]; !ok { 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) { 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) actionDir, actionPath, _, containerActionDir := actionStagePaths(step)
x := action.Runs.Using 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 { switch {
case x.IsNode(): 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 { if err := maybeCopyToActionDir(ctx, step, actionDir, actionPath, containerActionDir); err != nil {
return err return err
} }
@@ -605,14 +638,13 @@ func runPreStep(step actionStep) common.Executor {
return rc.JobContainer.Exec(containerArgs, *step.getEnv(), "", "")(ctx) return rc.JobContainer.Exec(containerArgs, *step.getEnv(), "", "")(ctx)
case x.IsDocker(): 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) return execDockerActionStage(ctx, step, stepStagePre)
case x.IsComposite(): case x.IsComposite():
if step.getCompositeSteps() == nil { 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 { 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") return errors.New("missing steps in composite action")
case x == model.ActionRunsUsingGo: 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 { if err := maybeCopyToActionDir(ctx, step, actionDir, actionPath, containerActionDir); err != nil {
return err return err
} }
+25 -11
View File
@@ -7,6 +7,7 @@ package runner
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"regexp" "regexp"
"slices" "slices"
"strconv" "strconv"
@@ -17,7 +18,7 @@ import (
"gitea.dev/actionslib/pkg/model" "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) env := make(map[string]string)
stepEnv := *step.getEnv() stepEnv := *step.getEnv()
for k, v := range stepEnv { for k, v := range stepEnv {
@@ -47,14 +48,17 @@ func evaluateCompositeInputAndEnv(ctx context.Context, parent *RunContext, step
env[envKey] = value env[envKey] = value
} else { } else {
// defaults could contain expressions // 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) gh := step.getGithubContext(ctx)
env["GITHUB_ACTION_REPOSITORY"] = gh.ActionRepository env["GITHUB_ACTION_REPOSITORY"] = gh.ActionRepository
env["GITHUB_ACTION_REF"] = gh.ActionRef env["GITHUB_ACTION_REF"] = gh.ActionRef
return env return env, nil
} }
func (rc *RunContext) setCompositeActionEnv(env map[string]string) { 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 { func newCompositeRunContext(ctx context.Context, parent *RunContext, step actionStep, actionPath string) (*RunContext, error) {
env := evaluateCompositeInputAndEnv(ctx, parent, step) env, err := evaluateCompositeInputAndEnv(ctx, parent, step)
if err != nil {
return nil, err
}
// run with the global config but without secrets // run with the global config but without secrets
configCopy := *parent.Config configCopy := *parent.Config
@@ -96,11 +103,12 @@ func newCompositeRunContext(ctx context.Context, parent *RunContext, step action
ExtraPath: parent.ExtraPath, ExtraPath: parent.ExtraPath,
Parent: parent, Parent: parent,
EventJSON: parent.EventJSON, EventJSON: parent.EventJSON,
platformImage: parent.platformImage,
} }
compositerc.setCompositeActionEnv(env) compositerc.setCompositeActionEnv(env)
compositerc.ExprEval = compositerc.NewExpressionEvaluator(ctx) compositerc.ExprEval = compositerc.NewExpressionEvaluator(ctx)
return compositerc return compositerc, nil
} }
// appendUniqueMasks appends the masks from src to dst, skipping any mask that // 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() action := step.getActionModel()
return func(ctx context.Context) error { return func(ctx context.Context) error {
compositeRC := step.getCompositeRunContext(ctx) compositeRC, err := step.getCompositeRunContext(ctx)
if err != nil {
return err
}
steps := step.getCompositeSteps() steps := step.getCompositeSteps()
@@ -131,14 +142,17 @@ func execAsComposite(step actionStep) common.Executor {
ctx = WithCompositeLogger(ctx, &compositeRC.Masks) ctx = WithCompositeLogger(ctx, &compositeRC.Masks)
err := steps.main(ctx) err = steps.main(ctx)
// Map outputs from composite RunContext to job RunContext // Map outputs from composite RunContext to job RunContext
eval := compositeRC.NewExpressionEvaluator(ctx) eval := compositeRC.NewExpressionEvaluator(ctx)
for outputName, output := range action.Outputs { for outputName, output := range action.Outputs {
rc.setOutput(ctx, map[string]string{ value, outputErr := eval.Interpolate(ctx, output.Value)
"name": outputName, if outputErr != nil {
}, eval.Interpolate(ctx, output.Value)) 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) // compositeRC.Masks is seeded with rc.Masks (see newCompositeRunContext)
+9 -4
View File
@@ -23,21 +23,26 @@ func TestCompositeActionParity(t *testing.T) {
Matrix: map[string]any{"os": "linux"}, Matrix: map[string]any{"os": "linux"},
Run: &model.Run{JobID: "job", Workflow: &model.Workflow{Name: "workflow", Jobs: map[string]*model.Job{"job": {Strategy: strategy}}}}, Run: &model.Run{JobID: "job", Workflow: &model.Workflow{Name: "workflow", Jobs: map[string]*model.Job{"job": {Strategy: strategy}}}},
JobContainer: &jobContainerMock{}, 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"}}, Step: &model.Step{With: map[string]string{"SHARED": "outer"}},
RunContext: parent, RunContext: parent,
action: &model.Action{Inputs: map[string]model.Input{"shared": {Default: "outer-default"}}}, action: &model.Action{Inputs: map[string]model.Input{"shared": {Default: "outer-default"}}},
env: map[string]string{"INPUT_SHARED": "outer"}, env: map[string]string{"INPUT_SHARED": "outer"},
}, "/action") }, "/action")
require.NoError(t, err)
assert.Same(t, strategy, composite.Run.Job().Strategy) assert.Same(t, strategy, composite.Run.Job().Strategy)
assert.Equal(t, "linux|3|outer", composite.NewExpressionEvaluator(ctx).Interpolate(ctx, assert.True(t, composite.IsHostEnv())
"${{ matrix.os }}|${{ strategy.max-parallel }}|${{ inputs.shared }}")) 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") assert.NotContains(t, composite.Env, "INPUT_SHARED")
nestedEnv := composite.GetEnv() 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"]) assert.Equal(t, "inner-default", nestedEnv["INPUT_SHARED"])
}) })
+1 -1
View File
@@ -316,7 +316,7 @@ func TestNewStepContainerDoesNotUseDockerSecrets(t *testing.T) {
step.On("getStepModel").Return(&model.Step{ID: "action"}) step.On("getStepModel").Return(&model.Step{ID: "action"})
step.On("getEnv").Return(&env) 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. // DOCKER_USERNAME/DOCKER_PASSWORD should not be injected as pull credentials for docker action containers.
assert.Empty(t, captured.Username) assert.Empty(t, captured.Username)
+47 -50
View File
@@ -13,6 +13,7 @@ import (
"reflect" "reflect"
"regexp" "regexp"
"strings" "strings"
"sync"
"time" "time"
"gitea.com/gitea/runner/act/common" "gitea.com/gitea/runner/act/common"
@@ -71,7 +72,7 @@ func (rc *RunContext) NewExpressionEvaluatorWithEnv(ctx context.Context, env map
} }
ghc := rc.getGithubContext(ctx) ghc := rc.getGithubContext(ctx)
inputs := getEvaluatorInputs(ctx, rc, rc.actionInputs, ghc) inputs := getEvaluatorInputs(rc, rc.actionInputs, ghc)
ee := &exprparser.EvaluationEnvironment{ ee := &exprparser.EvaluationEnvironment{
Github: ghc, Github: ghc,
@@ -81,7 +82,7 @@ func (rc *RunContext) NewExpressionEvaluatorWithEnv(ctx context.Context, env map
// todo: should be unavailable // todo: should be unavailable
// but required to interpolate/evaluate the step outputs on the job // but required to interpolate/evaluate the step outputs on the job
Steps: rc.getStepsContext(), Steps: rc.getStepsContext(),
Secrets: getWorkflowSecrets(ctx, rc), Secrets: getWorkflowSecrets(rc),
Vars: getWorkflowVars(ctx, rc), Vars: getWorkflowVars(ctx, rc),
Strategy: strategy, Strategy: strategy,
Matrix: rc.Matrix, Matrix: rc.Matrix,
@@ -137,14 +138,14 @@ func (rc *RunContext) newStepExpressionEvaluator(ctx context.Context, step step,
Env: *step.getEnv(), Env: *step.getEnv(),
Job: rc.getJobContext(), Job: rc.getJobContext(),
Steps: rc.getStepsContext(), Steps: rc.getStepsContext(),
Secrets: getWorkflowSecrets(ctx, rc), Secrets: getWorkflowSecrets(rc),
Vars: getWorkflowVars(ctx, rc), Vars: getWorkflowVars(ctx, rc),
Strategy: strategy, Strategy: strategy,
Matrix: rc.Matrix, Matrix: rc.Matrix,
Needs: using, Needs: using,
// todo: should be unavailable // todo: should be unavailable
// but required to interpolate/evaluate the inputs in actions/composite // 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), HashFiles: getHashFilesFunction(ctx, rc),
} }
ee.Runner = rc.getRunnerContext(ctx) 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) return ee.shared(ctx).EvaluateYamlNode(node)
} }
func (ee expressionEvaluator) Interpolate(ctx context.Context, in string) string { func (ee expressionEvaluator) Interpolate(ctx context.Context, in string) (string, error) {
out, err := ee.interpolate(ctx, in) return ee.shared(ctx).Interpolate(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) { // InterpolateName keeps the source text of a job or step name that cannot be evaluated, as GitHub does.
return ee.shared(ctx).Interpolate(in) 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 // 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 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{} inputs := map[string]any{}
setupWorkflowInputs(ctx, &inputs, rc) maps.Copy(inputs, rc.workflowCallInputs)
maps.Copy(inputs, stepInputs) maps.Copy(inputs, stepInputs)
if ghc.EventName == "workflow_dispatch" { if ghc.EventName == "workflow_dispatch" {
@@ -324,54 +326,49 @@ func coerceInputValue(value any, inputType string) any {
return value == "true" return value == "true"
} }
func setupWorkflowInputs(ctx context.Context, inputs *map[string]any, rc *RunContext) { // resolveWorkflowCall evaluates the caller's with: and secrets: once, as the server does before dispatching a called workflow.
if rc.caller != nil { 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() config := rc.Run.Workflow.WorkflowCallConfig()
rc.workflowCallInputs = make(map[string]any, len(config.Inputs))
for name, input := range config.Inputs { for name, input := range config.Inputs {
value := rc.caller.runContext.Run.Job().With[name] value, eval, label := callerJob.With[name], callerEval, "input"
if value != nil { if value == nil {
value, eval, label = input.Default, calleeEval(), "the default of input"
}
if str, ok := value.(string); ok { if str, ok := value.(string); ok {
// evaluate using the calling RunContext (outside) var err error
value = rc.caller.runContext.ExprEval.Interpolate(ctx, str) 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 { secrets := callerJob.Secrets()
value = input.Default if secrets == nil && callerJob.InheritSecrets() {
if rc.ExprEval != nil {
if str, ok := value.(string); ok {
// evaluate using the called RunContext (inside)
value = rc.ExprEval.Interpolate(ctx, str)
}
}
}
(*inputs)[name] = coerceInputValue(value, input.Type)
}
}
}
func getWorkflowSecrets(ctx context.Context, rc *RunContext) map[string]string {
if rc.caller != nil {
job := rc.caller.runContext.Run.Job()
secrets := job.Secrets()
if secrets == nil && job.InheritSecrets() {
secrets = rc.caller.runContext.Config.Secrets secrets = rc.caller.runContext.Config.Secrets
} }
rc.workflowCallSecrets = make(map[string]string, len(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 { for k, v := range secrets {
interpolated[k] = rc.caller.runContext.ExprEval.Interpolate(ctx, v) 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 interpolated
} }
return nil
}
func getWorkflowSecrets(rc *RunContext) map[string]string {
if rc.caller != nil {
return rc.workflowCallSecrets
}
return rc.Config.Secrets return rc.Config.Secrets
} }
+11 -8
View File
@@ -266,19 +266,21 @@ func TestInterpolate(t *testing.T) {
{"${{ null }}", ""}, {"${{ null }}", ""},
{"${{ fromJSON('[1,2]') }}", "Array"}, {"${{ fromJSON('[1,2]') }}", "Array"},
{"${{ fromJSON('{\"a\":1}') }}", "Object"}, {"${{ 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"}, {"${{ 1", "${{ 1"},
} }
for _, table := range tables { for _, table := range tables {
t.Run("interpolate", func(t *testing.T) { t.Run("interpolate", func(t *testing.T) {
assertObject := assert.New(t) out, err := ee.Interpolate(context.Background(), table.in)
out := ee.Interpolate(context.Background(), table.in) require.NoError(t, err, table.in)
assertObject.Equal(table.out, out, 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) { func TestGetEvaluatorInputsBoolean(t *testing.T) {
@@ -352,7 +354,7 @@ on:
} }
ghc := &model.GithubContext{EventName: eventName, Event: table.event} 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, table.flag, inputs["flag"])
assert.Equal(t, "gitea", inputs["name"]) 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"}}} runner := &runnerImpl{config: &Config{Secrets: map[string]string{"A": "s3cr3t-a", "B": "s3cr3t-b"}}}
containerName := func(jobID string) string { 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") assert.NotContains(t, rc.Name, "s3cr3t")
return rc.jobContainerName() return rc.jobContainerName()
} }
+46 -18
View File
@@ -18,6 +18,7 @@ import (
"slices" "slices"
"strconv" "strconv"
"strings" "strings"
"sync"
"time" "time"
"unicode" "unicode"
@@ -154,11 +155,9 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
if rc.Run == nil { if rc.Run == nil {
return nil return nil
} }
rc.ExprEval = rc.NewExpressionEvaluator(ctx) if err := evaluateJobEnvAndDefaults(ctx, rc); err != nil {
// evaluate environment variables since they can contain reportStepError(ctx, rc, err)
// GitHub's special environment variables. return err
for k, v := range rc.GetEnv() {
rc.Env[k] = rc.ExprEval.Interpolate(ctx, v)
} }
return nil 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 // swallowed: a bad output fails this job, it must not abandon the rest of the plan
if err := info.interpolateOutputs()(ctx); err != nil { if err := info.interpolateOutputs()(ctx); err != nil {
reportStepError(ctx, rc, err) reportStepError(ctx, rc, err)
} else if err := setJobOutputs(ctx, rc); err != nil {
reportStepError(ctx, rc, err)
} }
return nil 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())) logger.Errorf("##[error]%s", EscapeCommandData("Error while stop job container: "+err.Error()))
} }
setJobResult(ctx, info, rc, jobError == nil) setJobResult(ctx, info, rc, jobError == nil)
setJobOutputs(ctx, rc)
return err return err
}) })
@@ -413,15 +413,41 @@ func setJobResult(ctx context.Context, info jobInfo, rc *RunContext, success boo
logger.WithField("jobResult", jobResult).Infof("Job %s", jobResultMessage) logger.WithField("jobResult", jobResult).Infof("Job %s", jobResultMessage)
} }
func setJobOutputs(ctx context.Context, rc *RunContext) { // evaluateJobEnvAndDefaults resolves the job's env and defaults.run once, as GitHub does at job setup.
if rc.caller != nil { func evaluateJobEnvAndDefaults(ctx context.Context, rc *RunContext) error {
// map outputs for reusable workflows rc.ExprEval = rc.NewExpressionEvaluator(ctx)
callerOutputs := make(map[string]string) 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)
}
}
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
}
ee := rc.NewExpressionEvaluator(ctx) func setJobOutputs(ctx context.Context, rc *RunContext) error {
if rc.caller == nil {
for k, v := range rc.Run.Workflow.WorkflowCallConfig().Outputs { return nil
callerOutputs[k] = ee.Interpolate(ctx, ee.Interpolate(ctx, v.Value)) }
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; // Matrix combinations of a reusable-workflow caller share the caller's *model.Job;
@@ -429,14 +455,16 @@ func setJobOutputs(ctx context.Context, rc *RunContext) {
callerJob := rc.caller.runContext.Run.Job() callerJob := rc.caller.runContext.Run.Job()
defer lockJob(callerJob)() defer lockJob(callerJob)()
callerJob.Outputs = callerOutputs callerJob.Outputs = callerOutputs
} return nil
} }
// applyJobTimeout applies the job-level timeout-minutes to ctx, mirroring the // applyJobTimeout applies the job-level timeout-minutes to ctx, mirroring the
// step-level evaluateStepTimeout in step.go. // step-level evaluateStepTimeout in step.go.
func applyJobTimeout(ctx context.Context, rc *RunContext, job *model.Job) (context.Context, context.CancelFunc) { func applyJobTimeout(ctx context.Context, rc *RunContext, job *model.Job) (context.Context, context.CancelFunc) {
timeout := rc.ExprEval.Interpolate(ctx, job.TimeoutMinutes) timeout, err := rc.ExprEval.Interpolate(ctx, job.TimeoutMinutes)
if timeout != "" { 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 { if timeoutMinutes, err := strconv.ParseInt(timeout, 10, 64); err == nil {
return context.WithTimeout(ctx, time.Duration(timeoutMinutes)*time.Minute) 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 { func useStepLogger(rc *RunContext, stepModel *model.Step, stage stepStage, executor common.Executor) common.Executor {
return func(ctx context.Context) error { 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) logWriter := rc.commandLogWriter(ctx)
+5 -2
View File
@@ -95,9 +95,12 @@ func newRemoteReusableWorkflowExecutor(rc *RunContext) common.Executor {
// remoteReusableWorkflow.URL = rc.getGithubContext(ctx).ServerURL // remoteReusableWorkflow.URL = rc.getGithubContext(ctx).ServerURL
func cloneRemoteReusableWorkflow(rc *RunContext, cloneURL, ref, targetDirectory, token string) common.Executor { func cloneRemoteReusableWorkflow(rc *RunContext, cloneURL, ref, targetDirectory, token string) common.Executor {
return func(ctx context.Context) error { 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{ return git.NewGitCloneExecutor(git.NewGitCloneExecutorInput{
URL: cloneURL, URL: interpolatedURL,
Ref: ref, Ref: ref,
Dir: targetDirectory, Dir: targetDirectory,
Token: token, Token: token,
+103 -57
View File
@@ -68,6 +68,10 @@ type RunContext struct {
Masks []string Masks []string
cleanUpJobContainer common.Executor 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) // 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 // 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 // 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 // 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() name := rc.jobContainerName()
ext := container.LinuxContainerEnvironmentExtensions{} ext := container.LinuxContainerEnvironmentExtensions{}
@@ -311,7 +315,10 @@ func (rc *RunContext) GetBindsAndMounts() ([]string, map[string]string) {
if container := job.Container(); container != nil { if container := job.Container(); container != nil {
for _, v := range container.Volumes { for _, v := range container.Volumes {
if rc.ExprEval != nil { 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) 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 { func (rc *RunContext) startHostEnvironment() common.Executor {
@@ -436,7 +443,7 @@ var newContainer = container.NewContainer
func (rc *RunContext) startJobContainer() common.Executor { func (rc *RunContext) startJobContainer() common.Executor {
return func(ctx context.Context) error { return func(ctx context.Context) error {
logger := common.Logger(ctx) logger := common.Logger(ctx)
image := rc.platformImage(ctx) image := rc.platformImage
logWriter := rc.commandLogWriter(ctx) logWriter := rc.commandLogWriter(ctx)
username, password, err := rc.handleCredentials(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 envList = append(envList, fmt.Sprintf("%s=%s", "LANG", "C.UTF-8")) // Use same locale as GitHub Actions
ext := container.LinuxContainerEnvironmentExtensions{} 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 <networkName> <image>) // specify the network to which the container will connect when `docker create` stage. (like execute command line: docker create --network <networkName> <image>)
// if using service containers, will create a new network for the containers. // 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 { for serviceID, spec := range rc.Run.Job().Services {
// GitHub compatibility: skip services whose image evaluates to an // GitHub compatibility: skip services whose image evaluates to an
// empty string, enabling conditional services via expressions // 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 == "" { if serviceImage == "" {
logger.Infof("The service '%s' will not be started because the container definition has an empty image.", serviceID) logger.Infof("The service '%s' will not be started because the container definition has an empty image.", serviceID)
continue 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 // a service reaches the internet the way the job does; its own env still wins
maps0.Copy(interpolatedEnvs, rc.Config.ProxyEnv) maps0.Copy(interpolatedEnvs, rc.Config.ProxyEnv)
for k, v := range spec.Env { 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)) envs := make([]string, 0, len(interpolatedEnvs))
for k, v := range interpolatedEnvs { for k, v := range interpolatedEnvs {
envs = append(envs, fmt.Sprintf("%s=%s", k, v)) envs = append(envs, fmt.Sprintf("%s=%s", k, v))
} }
// interpolate cmd // interpolate cmd
interpolatedCmd := make([]string, 0, len(spec.Cmd)) interpolatedCmd := make([]string, len(spec.Cmd))
for _, v := range spec.Cmd { for i, v := range spec.Cmd {
interpolatedCmd = append(interpolatedCmd, rc.ExprEval.Interpolate(ctx, v)) 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 // keep these local: reusing username/password would overwrite the
// credentials the job container is pulled with further down // 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) return fmt.Errorf("failed to handle service %s credentials: %w", serviceID, err)
} }
interpolatedVolumes := make([]string, 0, len(spec.Volumes)) interpolatedVolumes := make([]string, len(spec.Volumes))
for _, volume := range spec.Volumes { for i, volume := range spec.Volumes {
interpolatedVolumes = append(interpolatedVolumes, rc.ExprEval.Interpolate(ctx, volume)) 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) serviceBinds, serviceMounts, _ := splitVolumes(interpolatedVolumes)
interpolatedPorts := make([]string, 0, len(spec.Ports)) interpolatedPorts := make([]string, len(spec.Ports))
for _, port := range spec.Ports { for i, port := range spec.Ports {
interpolatedPorts = append(interpolatedPorts, rc.ExprEval.Interpolate(ctx, port)) 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) exposedPorts, portBindings, err := nat.ParsePortSpecs(interpolatedPorts)
if err != nil { if err != nil {
return fmt.Errorf("failed to parse service %s ports: %w", serviceID, err) 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) serviceContainerName := createContainerName(rc.jobContainerName(), serviceID)
c := newContainer(&container.NewContainerInput{ c := newContainer(&container.NewContainerInput{
@@ -525,7 +550,7 @@ func (rc *RunContext) startJobContainer() common.Executor {
UsernsMode: rc.Config.UsernsMode, UsernsMode: rc.Config.UsernsMode,
Platform: rc.Config.ContainerArchitecture, Platform: rc.Config.ContainerArchitecture,
AutoRemove: false, // so a dead service's log survives, cleanupJobResources removes it AutoRemove: false, // so a dead service's log survives, cleanupJobResources removes it
WorkflowOptions: rc.ExprEval.Interpolate(ctx, spec.Options), WorkflowOptions: serviceOptions,
NetworkMode: networkName, NetworkMode: networkName,
NetworkAliases: []string{serviceID}, NetworkAliases: []string{serviceID},
ExposedPorts: exposedPorts, ExposedPorts: exposedPorts,
@@ -541,6 +566,10 @@ func (rc *RunContext) startJobContainer() common.Executor {
// For Gitea, `jobContainerNetwork` should be the same as `networkName` // For Gitea, `jobContainerNetwork` should be the same as `networkName`
jobContainerNetwork := networkName jobContainerNetwork := networkName
workflowOptions, err := rc.workflowOptions(ctx)
if err != nil {
return err
}
rc.JobContainer = newContainer(&container.NewContainerInput{ rc.JobContainer = newContainer(&container.NewContainerInput{
Cmd: nil, Cmd: nil,
Entrypoint: []string{"/bin/sleep", fmt.Sprint(rc.Config.ContainerMaxLifetime.Round(time.Second).Seconds())}, 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, UsernsMode: rc.Config.UsernsMode,
Platform: rc.Config.ContainerArchitecture, Platform: rc.Config.ContainerArchitecture,
RunnerOptions: rc.Config.ContainerOptions, RunnerOptions: rc.Config.ContainerOptions,
WorkflowOptions: rc.workflowOptions(ctx), WorkflowOptions: workflowOptions,
AutoRemove: true, AutoRemove: true,
ValidVolumes: rc.validVolumes(), ValidVolumes: rc.validVolumes(),
AllocatePTY: rc.Config.AllocatePTY, AllocatePTY: rc.Config.AllocatePTY,
@@ -931,7 +960,7 @@ func (rc *RunContext) interpolateOutputs() common.Executor {
outputs := make(map[string]string, len(rc.outputTemplate)) outputs := make(map[string]string, len(rc.outputTemplate))
var err error var err error
for k, v := range rc.outputTemplate { 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) err = fmt.Errorf("failed to evaluate job output %q: %w", k, err)
break break
} }
@@ -951,7 +980,7 @@ func (rc *RunContext) interpolateOutputs() common.Executor {
func (rc *RunContext) startContainer() common.Executor { func (rc *RunContext) startContainer() common.Executor {
return func(ctx context.Context) error { return func(ctx context.Context) error {
var err error var err error
if rc.IsHostEnv(ctx) { if rc.IsHostEnv() {
err = rc.startHostEnvironment()(ctx) err = rc.startHostEnvironment()(ctx)
} else { } else {
err = rc.startJobContainer()(ctx) err = rc.startJobContainer()(ctx)
@@ -981,10 +1010,8 @@ func (rc *RunContext) cleanupFailedStart(ctx context.Context) {
} }
} }
func (rc *RunContext) IsHostEnv(ctx context.Context) bool { func (rc *RunContext) IsHostEnv() bool {
platform := rc.runsOnImage(ctx) return strings.EqualFold(rc.platformImage, "-self-hosted")
image := rc.containerImage(ctx)
return image == "" && strings.EqualFold(platform, "-self-hosted")
} }
func (rc *RunContext) stopContainer() common.Executor { func (rc *RunContext) stopContainer() common.Executor {
@@ -1069,32 +1096,35 @@ func (rc *RunContext) Executor() (common.Executor, error) {
}, nil }, nil
} }
func (rc *RunContext) containerImage(ctx context.Context) string { func (rc *RunContext) containerImage(ctx context.Context) (string, error) {
job := rc.Run.Job() c := rc.Run.Job().Container()
if c == nil {
c := job.Container() return "", nil
if c != nil {
return rc.ExprEval.Interpolate(ctx, c.Image)
} }
image, err := rc.ExprEval.Interpolate(ctx, c.Image)
return "" 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 { if rc.Run.Job().RunsOn() == nil {
common.Logger(ctx).Errorf("'runs-on' key not defined in %s", rc.String()) common.Logger(ctx).Errorf("'runs-on' key not defined in %s", rc.String())
} }
job := rc.Run.Job() runsOn := rc.Run.Job().RunsOn()
runsOn := job.RunsOn()
for i, v := range 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 { 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 { func (rc *RunContext) runsOnPlatformNames(ctx context.Context) []string {
@@ -1115,21 +1145,26 @@ func (rc *RunContext) runsOnPlatformNames(ctx context.Context) []string {
return model.RunsOnFromNode(rawRunsOn) return model.RunsOnFromNode(rawRunsOn)
} }
func (rc *RunContext) platformImage(ctx context.Context) string { // resolvePlatformImage evaluates the job's image once, so every consumer sees the image the job started with.
if containerImage := rc.containerImage(ctx); containerImage != "" { func (rc *RunContext) resolvePlatformImage(ctx context.Context) error {
return containerImage image, err := rc.containerImage(ctx)
if err == nil && image == "" {
image, err = rc.runsOnImage(ctx)
} }
rc.platformImage = image
return rc.runsOnImage(ctx) return err
} }
func (rc *RunContext) workflowOptions(ctx context.Context) string { func (rc *RunContext) workflowOptions(ctx context.Context) (string, error) {
c := rc.Run.Job().Container() c := rc.Run.Job().Container()
if c == nil { if c == nil {
return "" return "", nil
} }
options, err := rc.ExprEval.Interpolate(ctx, c.Options)
return 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) { 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 return true, nil
} }
img := rc.platformImage(ctx) if err := rc.resolvePlatformImage(ctx); err != nil {
if img == "" { return false, err
}
if rc.platformImage == "" {
for _, platformName := range rc.runsOnPlatformNames(ctx) { for _, platformName := range rc.runsOnPlatformNames(ctx) {
l.Infof("Skipping unsupported platform -- Try running with `-P %+v=...`", platformName) 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. // log that runs-on is missing.
return "" return ""
} }
if imageOS := imageOSFromImage(rc.platformImage(ctx)); imageOS != "" { if imageOS := imageOSFromImage(rc.platformImage); imageOS != "" {
return imageOS return imageOS
} }
@@ -1609,14 +1646,23 @@ func (rc *RunContext) interpolateCredentials(ctx context.Context, credentials ma
} }
ee := rc.NewExpressionEvaluator(ctx) ee := rc.NewExpressionEvaluator(ctx)
username := ee.Interpolate(ctx, credentials["username"]) interpolate := func(key string) (string, error) {
if username == "" { value, err := ee.Interpolate(ctx, credentials[key])
return "", "", errors.New("failed to interpolate " + prefix + "credentials.username") if err != nil {
return "", fmt.Errorf("failed to interpolate %scredentials.%s: %w", prefix, key, err)
} }
password := ee.Interpolate(ctx, credentials["password"]) if value == "" {
if password == "" { return "", fmt.Errorf("failed to interpolate %scredentials.%s", prefix, key)
return "", "", errors.New("failed to interpolate " + prefix + "credentials.password") }
return value, nil
}
username, err := interpolate("username")
if err != nil {
return "", "", err
}
password, err := interpolate("password")
if err != nil {
return "", "", err
} }
return username, password, nil return username, password, nil
} }
+17 -7
View File
@@ -267,6 +267,7 @@ func startJobContainerInputs(t *testing.T, workflowYAML string, cfg *Config) []*
}, },
} }
rc.ExprEval = rc.NewExpressionEvaluator(t.Context()) 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 // the inputs are built before the missing daemon fails the first call
t.Setenv("DOCKER_HOST", "unix:///nonexistent.sock") t.Setenv("DOCKER_HOST", "unix:///nonexistent.sock")
@@ -451,7 +452,8 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) {
config := testcase.rc.Config config := testcase.rc.Config
config.Workdir = testcase.name config.Workdir = testcase.name
config.BindWorkdir = bindWorkDir config.BindWorkdir = bindWorkDir
gotbind, gotmount := rctemplate.GetBindsAndMounts() gotbind, gotmount, err := rctemplate.GetBindsAndMounts()
require.NoError(t, err)
// Name binds/mounts are either/or // Name binds/mounts are either/or
if config.BindWorkdir { if config.BindWorkdir {
@@ -510,7 +512,8 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) {
rc.Run.Workflow.Jobs = map[string]*model.Job{"job1": job} rc.Run.Workflow.Jobs = map[string]*model.Job{"job1": job}
rc.ExprEval = rc.NewExpressionEvaluator(context.Background()) 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.Contains(t, gotbind, "/host/mame/roms:/root/.mame/roms:ro")
assert.NotContains(t, gotbind, "${{ secrets.MAME }}") assert.NotContains(t, gotbind, "${{ secrets.MAME }}")
assert.NotContains(t, gotmount, "${{ secrets.MAME }}") assert.NotContains(t, gotmount, "${{ secrets.MAME }}")
@@ -539,7 +542,8 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) {
rc.Run.JobID = "job1" rc.Run.JobID = "job1"
rc.Run.Workflow.Jobs = map[string]*model.Job{"job1": job} 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 { if len(testcase.wantbind) > 0 {
assert.Contains(t, gotbind, testcase.wantbind) assert.Contains(t, gotbind, testcase.wantbind)
@@ -574,11 +578,13 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) {
Config: &Config{}, Config: &Config{},
} }
_, gotmount := rc.GetBindsAndMounts() _, gotmount, err := rc.GetBindsAndMounts()
require.NoError(t, err)
assert.NotContains(t, gotmount, sharedToolCacheVolume) assert.NotContains(t, gotmount, sharedToolCacheVolume)
rc.Config.SharedToolCache = true rc.Config.SharedToolCache = true
_, gotmount = rc.GetBindsAndMounts() _, gotmount, err = rc.GetBindsAndMounts()
require.NoError(t, err)
assert.Equal(t, container.DefaultToolCache, gotmount[sharedToolCacheVolume]) assert.Equal(t, container.DefaultToolCache, gotmount[sharedToolCacheVolume])
}) })
} }
@@ -660,8 +666,10 @@ func TestInterpolateOutputsIsPerMatrixCombo(t *testing.T) {
r := &runnerImpl{config: &Config{}} r := &runnerImpl{config: &Config{}}
ctx := context.Background() ctx := context.Background()
rcA := r.newRunContext(ctx, run, map[string]any{"v": "a"}) rcA, err := r.newRunContext(ctx, run, map[string]any{"v": "a"})
rcB := r.newRunContext(ctx, run, map[string]any{"v": "b"}) 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, rcA.interpolateOutputs()(ctx))
require.NoError(t, rcB.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) { t.Run("prefers the release in the resolved image tag", func(t *testing.T) {
rc := createRunsOnRunContext(t, "ubuntu-latest") rc := createRunsOnRunContext(t, "ubuntu-latest")
rc.Config.PlatformPicker = func([]string) string { return "docker.gitea.com/runner-images:ubuntu-24.04" } 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)) assert.Equal(t, "ubuntu24", rc.imageOS(ctx))
}) })
t.Run("falls back to the runs-on label", func(t *testing.T) { t.Run("falls back to the runs-on label", func(t *testing.T) {
rc := createRunsOnRunContext(t, "ubuntu-22.04") rc := createRunsOnRunContext(t, "ubuntu-22.04")
rc.Config.PlatformPicker = func([]string) string { return "some-image" } rc.Config.PlatformPicker = func([]string) string { return "some-image" }
require.NoError(t, rc.resolvePlatformImage(ctx))
assert.Equal(t, "ubuntu22", rc.imageOS(ctx)) assert.Equal(t, "ubuntu22", rc.imageOS(ctx))
}) })
+14 -5
View File
@@ -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.MaxParallelString: %v", job.Strategy.MaxParallelString)
log.Debugf("Job.Strategy.RawMatrix: %v", job.Strategy.RawMatrix) 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. // 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. // 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 { 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)) log.Infof("Running job with maxParallel=%d for %d matrix combinations", maxParallel, len(matrixes))
for i, matrix := range 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 rc.JobName = rc.Name
if len(matrixes) > 1 { if len(matrixes) > 1 {
rc.Name = fmt.Sprintf("%s-%d", rc.Name, i+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{ rc := &RunContext{
Config: runner.config, Config: runner.config,
Run: run, Run: run,
@@ -324,15 +330,18 @@ func (runner *runnerImpl) newRunContext(ctx context.Context, run *model.Run, mat
Matrix: matrix, Matrix: matrix,
caller: runner.caller, caller: runner.caller,
} }
if err := rc.resolveWorkflowCall(ctx); err != nil {
return nil, err
}
rc.ExprEval = rc.NewExpressionEvaluator(ctx) 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 // Snapshot the job's pristine output expressions now, before any matrix combo runs and
// rewrites the shared Job.Outputs (see interpolateOutputs). // rewrites the shared Job.Outputs (see interpolateOutputs).
if job := run.Job(); job != nil { if job := run.Job(); job != nil {
rc.outputTemplate = maps.Clone(job.Outputs) rc.outputTemplate = maps.Clone(job.Outputs)
} }
return rc return rc, nil
} }
// For Gitea // For Gitea
+21 -10
View File
@@ -11,6 +11,7 @@ import (
"path" "path"
"strconv" "strconv"
"strings" "strings"
"sync"
"time" "time"
"gitea.com/gitea/runner/act/common" "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 rc.StepResults[rc.CurrentStep] = stepResult
} }
setupEnv(ctx, step) err := setupEnv(ctx, step)
var runStep bool
runStep, err := isStepEnabled(ctx, ifExpression, step, stage) if err == nil {
runStep, err = isStepEnabled(ctx, ifExpression, step, stage)
}
if err != nil { if err != nil {
stepResult.Conclusion = model.StepStatusFailure stepResult.Conclusion = model.StepStatusFailure
stepResult.Outcome = model.StepStatusFailure stepResult.Outcome = model.StepStatusFailure
@@ -99,7 +102,7 @@ func runStepExecutor(step step, stage stepStage, executor common.Executor) commo
return nil return nil
} }
stepString := rc.ExprEval.Interpolate(ctx, stepModel.String()) stepString := rc.ExprEval.InterpolateName(ctx, stepModel.String())
if strings.Contains(stepString, "::add-mask::") { if strings.Contains(stepString, "::add-mask::") {
stepString = "add-mask command" 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) { func evaluateStepTimeout(ctx context.Context, exprEval *expressionEvaluator, stepModel *model.Step) (context.Context, context.CancelFunc) {
timeout := exprEval.Interpolate(ctx, stepModel.TimeoutMinutes) timeout, err := exprEval.Interpolate(ctx, stepModel.TimeoutMinutes)
if timeout != "" { 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 { if timeOutMinutes, err := strconv.ParseInt(timeout, 10, 64); err == nil && timeOutMinutes > 0 {
return context.WithTimeout(ctx, time.Duration(timeOutMinutes)*time.Minute) return context.WithTimeout(ctx, time.Duration(timeOutMinutes)*time.Minute)
} }
@@ -230,27 +235,33 @@ func evaluateStepTimeout(ctx context.Context, exprEval *expressionEvaluator, ste
return ctx, func() {} return ctx, func() {}
} }
func setupEnv(ctx context.Context, step step) { func setupEnv(ctx context.Context, step step) error {
rc := step.getRunContext() rc := step.getRunContext()
mergeEnv(ctx, step) mergeEnv(ctx, step)
// merge step env last, since it should not be overwritten // merge step env last, since it should not be overwritten
mergeIntoMap(step, step.getEnv(), step.getStepModel().GetEnv()) mergeIntoMap(step, step.getEnv(), step.getStepModel().GetEnv())
var err error
exprEval := rc.NewExpressionEvaluator(ctx) exprEval := rc.NewExpressionEvaluator(ctx)
for k, v := range *step.getEnv() { for k, v := range *step.getEnv() {
if !strings.HasPrefix(k, "INPUT_") { 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 // 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 // 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() { for k, v := range *step.getEnv() {
if strings.HasPrefix(k, "INPUT_") { 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) { func mergeEnv(ctx context.Context, step step) {
+8 -4
View File
@@ -122,15 +122,19 @@ func (sal *stepActionLocal) getActionModel() *model.Action {
return sal.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 { if sal.compositeRunContext == nil {
actionDir := filepath.Join(sal.RunContext.Config.Workdir, sal.Step.Uses) actionDir := filepath.Join(sal.RunContext.Config.Workdir, sal.Step.Uses)
_, containerActionDir := getContainerActionPaths(sal.getStepModel(), actionDir, sal.RunContext) _, containerActionDir := getContainerActionPaths(sal.getStepModel(), actionDir, sal.RunContext)
sal.compositeRunContext = newCompositeRunContext(ctx, sal.RunContext, sal, containerActionDir) compositeRunContext, err := newCompositeRunContext(ctx, sal.RunContext, sal, containerActionDir)
sal.compositeSteps = sal.compositeRunContext.compositeExecutor(sal.action) if err != nil {
return nil, err
} }
return sal.compositeRunContext sal.compositeRunContext = compositeRunContext
sal.compositeSteps = compositeRunContext.compositeExecutor(sal.action)
}
return sal.compositeRunContext, nil
} }
func (sal *stepActionLocal) getCompositeSteps() *compositeSteps { func (sal *stepActionLocal) getCompositeSteps() *compositeSteps {
+22 -8
View File
@@ -51,7 +51,11 @@ func (sar *stepActionRemote) prepareActionExecutor() common.Executor {
// Since actions can specify the download source via a url prefix. // 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, // 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. // 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 github := sar.getGithubContext(ctx) // read before remoteAction is set, so `$/` resolves against the enclosing action
if strings.HasPrefix(sar.Step.Uses, selfRepoPrefix) { 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") common.Logger(ctx).Debugf("Skipping local actions/checkout because you bound your workspace")
return nil return nil
} }
eval := sar.RunContext.NewExpressionEvaluator(ctx) checkoutPath, err := sar.RunContext.NewExpressionEvaluator(ctx).Interpolate(ctx, sar.Step.With["path"])
copyToPath := path.Join(sar.RunContext.JobContainer.ToContainerPath(sar.RunContext.Config.Workdir), eval.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) 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 return sar.action
} }
func (sar *stepActionRemote) getCompositeRunContext(ctx context.Context) *RunContext { func (sar *stepActionRemote) getCompositeRunContext(ctx context.Context) (*RunContext, error) {
if sar.compositeRunContext == nil { if sar.compositeRunContext == nil {
actionDir := sar.actionDir() actionDir := sar.actionDir()
actionLocation := path.Join(actionDir, sar.remoteAction.Path) actionLocation := path.Join(actionDir, sar.remoteAction.Path)
_, containerActionDir := getContainerActionPaths(sar.getStepModel(), actionLocation, sar.RunContext) _, containerActionDir := getContainerActionPaths(sar.getStepModel(), actionLocation, sar.RunContext)
sar.compositeRunContext = newCompositeRunContext(ctx, sar.RunContext, sar, containerActionDir) compositeRunContext, err := newCompositeRunContext(ctx, sar.RunContext, sar, containerActionDir)
sar.compositeSteps = sar.compositeRunContext.compositeExecutor(sar.action) if err != nil {
return nil, err
}
sar.compositeRunContext = compositeRunContext
sar.compositeSteps = compositeRunContext.compositeExecutor(sar.action)
} else { } else {
// Re-evaluate environment here. For remote actions the environment // Re-evaluate environment here. For remote actions the environment
// need to be re-created for every stage (pre, main, post) as there // 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 // stages are executed. (e.g. the output of another action is the
// input for this action during the main stage, but the env // input for this action during the main stage, but the env
// was already created during the pre stage) // 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.setCompositeActionEnv(env)
sar.compositeRunContext.ExtraPath = sar.RunContext.ExtraPath sar.compositeRunContext.ExtraPath = sar.RunContext.ExtraPath
} }
return sar.compositeRunContext return sar.compositeRunContext, nil
} }
func (sar *stepActionRemote) getCompositeSteps() *compositeSteps { func (sar *stepActionRemote) getCompositeSteps() *compositeSteps {
+2 -1
View File
@@ -254,7 +254,8 @@ func TestStepActionRemote(t *testing.T) {
for _, value := range []string{"first", "second"} { for _, value := range []string{"first", "second"} {
step.env["INPUT_SHARED"] = value 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.Equal(t, map[string]any{"shared": value}, composite.actionInputs)
assert.NotContains(t, composite.Env, "INPUT_SHARED") assert.NotContains(t, composite.Env, "INPUT_SHARED")
} }
+15 -3
View File
@@ -6,6 +6,7 @@ package runner
import ( import (
"context" "context"
"fmt"
"strings" "strings"
"gitea.com/gitea/runner/act/common" "gitea.com/gitea/runner/act/common"
@@ -58,17 +59,28 @@ func (sd *stepDocker) runUsesContainer() common.Executor {
return func(ctx context.Context) error { return func(ctx context.Context) error {
image := strings.TrimPrefix(step.Uses, "docker://") image := strings.TrimPrefix(step.Uses, "docker://")
eval := rc.NewExpressionEvaluator(ctx) 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 { if err != nil {
return err return err
} }
var entrypoint []string 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} 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( return common.NewPipelineExecutor(
stepContainer.Pull(rc.Config.ForcePull), stepContainer.Pull(rc.Config.ForcePull),
+5 -4
View File
@@ -16,6 +16,7 @@ import (
"gitea.dev/actionslib/pkg/model" "gitea.dev/actionslib/pkg/model"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock" "github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
) )
func TestStepDockerMain(t *testing.T) { func TestStepDockerMain(t *testing.T) {
@@ -144,7 +145,7 @@ func TestStepDockerNewStepContainerAllocatePTY(t *testing.T) {
} }
sd.RunContext.ExprEval = sd.RunContext.NewExpressionEvaluator(ctx) 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) assert.Equal(t, tc.allocPTY, captured.AllocatePTY)
}) })
} }
@@ -210,10 +211,10 @@ func TestStepDockerNewStepContainerNetworkMode(t *testing.T) {
} }
sd.RunContext.ExprEval = sd.RunContext.NewExpressionEvaluator(ctx) sd.RunContext.ExprEval = sd.RunContext.NewExpressionEvaluator(ctx)
assert.Equal(t, tc.expectDefault, sd.RunContext.IsHostEnv(ctx), require.NoError(t, sd.RunContext.resolvePlatformImage(ctx))
"IsHostEnv mismatch for platform %q", tc.platform) 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 { if tc.expectDefault {
assert.Equal(t, "default", captured.NetworkMode, assert.Equal(t, "default", captured.NetworkMode,
+32 -25
View File
@@ -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 // 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) { func (sr *stepRun) setupShellCommand(ctx context.Context) (name, script string, err error) {
logger := common.Logger(ctx) logger := common.Logger(ctx)
implicitShell := sr.setupShell(ctx) eval := sr.RunContext.NewStepExpressionEvaluator(ctx, sr)
sr.setupWorkingDirectory(ctx) implicitShell, err := sr.setupShell(ctx, eval)
if err != nil {
return "", "", err
}
if err := sr.setupWorkingDirectory(ctx, eval); err != nil {
return "", "", err
}
step := sr.Step 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 sr.interpolatedScript = script
// GitHub matches the built-in names case-insensitively, so `shell: PWSH` is valid // 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 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 rc := sr.RunContext
step := sr.Step step := sr.Step
if step.Shell == "" { shell, err := eval.Interpolate(ctx, step.Shell)
step.Shell = rc.Run.Job().Defaults.Run.Shell if err != nil {
return false, fmt.Errorf("unable to interpolate the shell: %w", err)
} }
if shell == "" {
step.Shell = rc.NewStepExpressionEvaluator(ctx, sr).Interpolate(ctx, step.Shell) shell = rc.jobRunDefaults.Shell
if step.Shell == "" {
step.Shell = rc.Run.Workflow.Defaults.Run.Shell
} }
if shell == "" {
shell = rc.Run.Workflow.Defaults.Run.Shell
}
step.Shell = shell
implicitShell := step.Shell == "" implicitShell := step.Shell == ""
if implicitShell { 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. // 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 return *top.hasBash
} }
func (sr *stepRun) setupWorkingDirectory(ctx context.Context) { func (sr *stepRun) setupWorkingDirectory(ctx context.Context, eval *expressionEvaluator) error {
rc := sr.RunContext rc := sr.RunContext
step := sr.Step workingdirectory, err := eval.Interpolate(ctx, sr.Step.WorkingDirectory)
var workingdirectory string if err != nil {
return fmt.Errorf("unable to interpolate the working directory: %w", err)
if step.WorkingDirectory == "" {
workingdirectory = rc.Run.Job().Defaults.Run.WorkingDirectory
} else {
workingdirectory = step.WorkingDirectory
} }
if workingdirectory == "" {
// jobs can receive context values, so we interpolate workingdirectory = rc.jobRunDefaults.WorkingDirectory
workingdirectory = rc.NewStepExpressionEvaluator(ctx, sr).Interpolate(ctx, workingdirectory) }
// top level keys in workflow file like `defaults` or `env` can't hold expressions
// but top level keys in workflow file like `defaults` or `env` can't
if workingdirectory == "" { if workingdirectory == "" {
workingdirectory = rc.Run.Workflow.Defaults.Run.WorkingDirectory workingdirectory = rc.Run.Workflow.Defaults.Run.WorkingDirectory
} }
sr.WorkingDirectory = workingdirectory sr.WorkingDirectory = workingdirectory
return nil
} }
+21 -10
View File
@@ -6,6 +6,7 @@ package runner
import ( import (
"bytes" "bytes"
"cmp"
"context" "context"
"io" "io"
"os" "os"
@@ -42,17 +43,12 @@ func TestStepRun(t *testing.T) {
JobID: "1", JobID: "1",
Workflow: &model.Workflow{ Workflow: &model.Workflow{
Jobs: map[string]*model.Job{ Jobs: map[string]*model.Job{
"1": { "1": {},
Defaults: model.Defaults{
Run: model.RunDefaults{
Shell: "bash",
},
},
},
}, },
}, },
}, },
JobContainer: cm, JobContainer: cm,
jobRunDefaults: model.RunDefaults{Shell: "bash"},
}, },
Step: &model.Step{ Step: &model.Step{
ID: "1", ID: "1",
@@ -84,7 +80,7 @@ func TestStepRun(t *testing.T) {
func TestStepRunShellParity(t *testing.T) { func TestStepRunShellParity(t *testing.T) {
tests := []struct { tests := []struct {
name, shell, workingDir string name, run, shell, workingDir string
env map[string]string env map[string]string
host bool host bool
probeErr error probeErr error
@@ -130,6 +126,21 @@ func TestStepRunShellParity(t *testing.T) {
wantExt: ".py", wantExt: ".py",
wantCmd: []string{"python", "/var/run/act/workflow/1.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 { 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": {}}}}, Run: &model.Run{JobID: "1", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"1": {}}}},
JobContainer: jobContainer, 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, env: test.env,
} }
name, script, err := sr.setupShellCommand(t.Context()) name, script, err := sr.setupShellCommand(t.Context())
if test.wantErr != "" { if test.wantErr != "" {
require.EqualError(t, err, test.wantErr) require.ErrorContains(t, err, test.wantErr)
return return
} }
require.NoError(t, err) require.NoError(t, err)
+1 -1
View File
@@ -160,7 +160,7 @@ func TestSetupEnv(t *testing.T) {
sm.On("getStepModel").Return(step) sm.On("getStepModel").Return(step)
sm.On("getEnv").Return(&env) sm.On("getEnv").Return(&env)
setupEnv(context.Background(), sm) require.NoError(t, setupEnv(context.Background(), sm))
// These are commit or system specific // These are commit or system specific
delete(env, "GITHUB_REF") delete(env, "GITHUB_REF")