From e0d00e543d609b73fd688586ea022f18eace531b Mon Sep 17 00:00:00 2001 From: silverwind <2021+silverwind@noreply.gitea.com> Date: Wed, 23 Sep 2026 15:40:19 +0000 Subject: [PATCH] feat: update to actionslib v1.2.0, support `self:` (#1233) Update to [actionslib v1.2.0](https://gitea.com/gitea/actionslib/releases/tag/v1.2.0) and move logic to it. 1. `self:{owner}/{repo}[/{path}]@{ref}` in step `uses` resolves on the runner's Gitea instance, for https://github.com/go-gitea/gitea/issues/38990. 2. `strategy.job-index` and `strategy.job-total` come from Gitea when it sends them. 3. Container, services and `with` are evaluated once instead of on every use. 4. Fixes a flaky clone-lock test. Gitea PR: https://github.com/go-gitea/gitea/pull/39358 Docs PR: https://gitea.com/gitea/docs/pulls/553 --------- Co-authored-by: bircni Reviewed-on: https://gitea.com/gitea/runner/pulls/1233 Reviewed-by: bircni Co-authored-by: silverwind <2021+silverwind@noreply.gitea.com> --- act/runner/action.go | 50 ++--- act/runner/action_composite.go | 2 + act/runner/action_composite_test.go | 9 +- act/runner/action_test.go | 13 +- act/runner/expression.go | 154 ++++----------- act/runner/expression_test.go | 49 ++++- act/runner/job_executor.go | 45 ++--- act/runner/job_executor_test.go | 4 +- act/runner/job_hooks.go | 7 +- act/runner/reusable_workflow.go | 117 ++++------- act/runner/reusable_workflow_test.go | 17 ++ act/runner/run_context.go | 272 +++++++------------------- act/runner/run_context_test.go | 118 +++++------ act/runner/runner.go | 35 +++- act/runner/runner_test.go | 6 +- act/runner/step.go | 55 +++--- act/runner/step_action_remote.go | 93 +++------ act/runner/step_action_remote_test.go | 39 +++- act/runner/step_docker.go | 19 +- act/runner/step_docker_test.go | 4 +- act/runner/step_test.go | 37 ++-- go.mod | 2 +- go.sum | 4 +- internal/app/run/runner.go | 24 +-- 24 files changed, 454 insertions(+), 721 deletions(-) diff --git a/act/runner/action.go b/act/runner/action.go index 874e137e..c31952fb 100644 --- a/act/runner/action.go +++ b/act/runner/action.go @@ -13,6 +13,7 @@ import ( "fmt" "io" "io/fs" + "maps" "os" "path" "path/filepath" @@ -353,12 +354,7 @@ func execAsDocker(ctx context.Context, step actionStep, actionName, actionDir, b logger.Debugf("image '%s' for architecture '%s' already exists", image, rc.Config.ContainerArchitecture) } } - eval := rc.NewActionInputsExpressionEvaluator(ctx, step) - 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) + cmd, err := shellquote.Split(step.getStepModel().With["args"]) if err != nil { return err } @@ -375,14 +371,11 @@ func execAsDocker(ctx context.Context, step actionStep, actionName, actionDir, b } } } - entrypoint, err := dockerEntrypoint(ctx, step, eval, stage) - if err != nil { - return err - } - stepContainer, err := newStepContainer(ctx, step, image, cmd, entrypoint, rc.Config.ContainerOptions) + entrypoint, err := dockerEntrypoint(step, stage) if err != nil { return err } + stepContainer := newStepContainer(ctx, step, image, cmd, entrypoint, rc.Config.ContainerOptions) return common.NewPipelineExecutor( prepImage, stepContainer.Pull(forcePull), @@ -394,7 +387,7 @@ func execAsDocker(ctx context.Context, step actionStep, actionName, actionDir, b // dockerEntrypoint returns the entrypoint the action's image runs with for the given // stage. Only the main stage honours the `entrypoint` input. -func dockerEntrypoint(ctx context.Context, step actionStep, eval *expressionEvaluator, stage stepStage) ([]string, error) { +func dockerEntrypoint(step actionStep, stage stepStage) ([]string, error) { runs := step.getActionModel().Runs var entrypoint string @@ -406,11 +399,7 @@ func dockerEntrypoint(ctx context.Context, step actionStep, eval *expressionEval default: entrypoint = runs.Entrypoint if entrypoint == "" { - 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 { + if fields := strings.Fields(step.getStepModel().With["entrypoint"]); len(fields) > 0 { return fields, nil } } @@ -425,7 +414,6 @@ 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, error) { rc := step.getRunContext() - stepModel := step.getStepModel() var err error inputs := make(map[string]string) @@ -436,27 +424,20 @@ func evalDockerEnv(ctx context.Context, step step, action *model.Action) (*expre return nil, fmt.Errorf("unable to interpolate the default of input %s: %w", k, err) } } - 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) - - env := make(map[string]string, len(action.Runs.Env)+len(*step.getEnv())) - mergeIntoMap(step, &env, action.Runs.Env, *step.getEnv()) - *step.getEnv() = env + mergeIntoMap(step, step.getEnv(), inputs, step.getStepModel().With) ee := rc.NewActionInputsExpressionEvaluator(ctx, step) - for k, v := range *step.getEnv() { - if (*step.getEnv())[k], err = ee.Interpolate(ctx, v); err != nil { + runsEnv := make(map[string]string, len(action.Runs.Env)) + for k, v := range action.Runs.Env { + if runsEnv[k], err = ee.Interpolate(ctx, v); err != nil { return nil, fmt.Errorf("unable to interpolate env %s: %w", k, err) } } + mergeIntoMap(step, step.getEnv(), runsEnv, maps.Clone(*step.getEnv())) return ee, nil } -func newStepContainer(ctx context.Context, step step, image string, cmd, entrypoint []string, runnerOptions string) (container.Container, error) { +func newStepContainer(ctx context.Context, step step, image string, cmd, entrypoint []string, runnerOptions string) container.Container { rc := step.getRunContext() logWriter := rc.commandLogWriter(ctx) envList := make([]string, 0) @@ -466,10 +447,7 @@ func newStepContainer(ctx context.Context, step step, image string, cmd, entrypo envList = append(envList, rc.runnerEnv(ctx)...) - binds, mounts, err := rc.GetBindsAndMounts() - if err != nil { - return nil, err - } + binds, mounts := rc.GetBindsAndMounts() networkMode := "container:" + rc.jobContainerName() if rc.IsHostEnv() { networkMode = "default" @@ -493,7 +471,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) { diff --git a/act/runner/action_composite.go b/act/runner/action_composite.go index 444c3845..609325db 100644 --- a/act/runner/action_composite.go +++ b/act/runner/action_composite.go @@ -104,6 +104,8 @@ func newCompositeRunContext(ctx context.Context, parent *RunContext, step action Parent: parent, EventJSON: parent.EventJSON, platformImage: parent.platformImage, + jobIndex: parent.jobIndex, + jobTotal: parent.jobTotal, } compositerc.setCompositeActionEnv(env) compositerc.ExprEval = compositerc.NewExpressionEvaluator(ctx) diff --git a/act/runner/action_composite_test.go b/act/runner/action_composite_test.go index a88bc0fb..948f5e5c 100644 --- a/act/runner/action_composite_test.go +++ b/act/runner/action_composite_test.go @@ -12,18 +12,21 @@ import ( "gitea.dev/actionslib/pkg/model" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "go.yaml.in/yaml/v4" ) func TestCompositeActionParity(t *testing.T) { t.Run("inherits contexts without leaking inputs", func(t *testing.T) { ctx := t.Context() - strategy := &model.Strategy{MaxParallelString: "3"} + strategy := &model.Strategy{MaxParallelString: "3", RawMatrix: yaml.Node{Kind: yaml.MappingNode}} 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{}, platformImage: "-self-hosted", + jobIndex: 1, + jobTotal: 4, } composite, err := newCompositeRunContext(ctx, parent, &stepActionRemote{ Step: &model.Step{With: map[string]string{"SHARED": "outer"}}, @@ -36,9 +39,9 @@ func TestCompositeActionParity(t *testing.T) { assert.Same(t, strategy, composite.Run.Job().Strategy) assert.True(t, composite.IsHostEnv()) interpolated, err := composite.NewExpressionEvaluator(ctx).Interpolate(ctx, - "${{ matrix.os }}|${{ strategy.max-parallel }}|${{ inputs.shared }}") + "${{ matrix.os }}|${{ strategy.max-parallel }}|${{ strategy.job-index }}|${{ inputs.shared }}") require.NoError(t, err) - assert.Equal(t, "linux|3|outer", interpolated) + assert.Equal(t, "linux|3|1|outer", interpolated) assert.NotContains(t, composite.Env, "INPUT_SHARED") nestedEnv := composite.GetEnv() diff --git a/act/runner/action_test.go b/act/runner/action_test.go index 31cf3d17..33035682 100644 --- a/act/runner/action_test.go +++ b/act/runner/action_test.go @@ -242,7 +242,7 @@ func TestActionRunner(t *testing.T) { tt.step.getRunContext().JobContainer = cm - err := runActionImpl(tt.step, "dir", newRemoteAction("org/repo/path@ref"))(ctx) + err := runActionImpl(tt.step, "dir", mustNewRemoteAction(t, "org/repo/path@ref"))(ctx) assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act cm.AssertExpectations(t) @@ -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) @@ -461,18 +461,11 @@ func TestExecAsDockerHoldsCloneLockForRemoteUncached(t *testing.T) { case <-innerEntered: case err := <-done: t.Fatalf("execAsDocker returned without entering inner: %v", err) - case <-time.After(time.Second): - t.Fatal("inner build executor not entered after lock released") } cancel() releaseOnce() - - select { - case <-done: - case <-time.After(time.Second): - t.Fatal("execAsDocker did not return after inner was released and ctx was canceled") - } + <-done } func TestDockerActionImageTag(t *testing.T) { diff --git a/act/runner/expression.go b/act/runner/expression.go index 573211e5..aa8566d5 100644 --- a/act/runner/expression.go +++ b/act/runner/expression.go @@ -13,7 +13,6 @@ import ( "reflect" "regexp" "strings" - "sync" "time" "gitea.com/gitea/runner/act/common" @@ -27,29 +26,6 @@ import ( "go.yaml.in/yaml/v4" ) -// setStrategyContext leaves `max-parallel` unset when the job declares none, as GitHub does. -func setStrategyContext(strategy map[string]any, jobStrategy *model.Strategy) { - strategy["fail-fast"] = jobStrategy.GetFailFast() - if limit, declared, err := jobStrategy.ParseMaxParallel(); declared && err == nil { - strategy["max-parallel"] = limit - } -} - -// decodeDeferred decodes a whole-value `${{ }}` parked in a Raw* field, evaluating a clone since the node is shared across matrix combinations. -func decodeDeferred[T any](ctx context.Context, eval *expressionEvaluator, name string, raw yaml.Node, out *T) error { - if raw.Kind != yaml.ScalarNode { - return nil - } - node := model.CloneYamlNode(raw) - if err := eval.EvaluateYamlNode(ctx, &node); err != nil { - return fmt.Errorf("unable to evaluate %s: %w", name, err) - } - if err := node.Decode(out); err != nil { - return fmt.Errorf("unable to decode %s: %w", name, err) - } - return nil -} - // NewExpressionEvaluator creates a new evaluator func (rc *RunContext) NewExpressionEvaluator(ctx context.Context) *ExpressionEvaluator { return rc.NewExpressionEvaluatorWithEnv(ctx, rc.GetEnv()) @@ -59,23 +35,9 @@ func (rc *RunContext) NewExpressionEvaluatorWithEnv(ctx context.Context, env map var workflowCallResult map[string]*model.WorkflowCallResult // todo: cleanup EvaluationEnvironment creation - using := make(map[string]exprparser.Needs) strategy := make(map[string]any) if rc.Run != nil { - job := rc.Run.Job() - if job != nil && job.Strategy != nil { - setStrategyContext(strategy, job.Strategy) - } - - jobs := rc.Run.Workflow.Jobs - jobNeeds := rc.Run.Job().Needs() - - for _, needs := range jobNeeds { - using[needs] = exprparser.Needs{ - Outputs: jobs[needs].Outputs, - Result: jobs[needs].NeedsResult(), - } - } + strategy = exprparser.StrategyContext(rc.Run.Job().Strategy, rc.jobIndex, rc.jobTotal) // only setup jobs context in case of workflow_call // and existing expression evaluator (this means, jobs are at @@ -83,7 +45,7 @@ func (rc *RunContext) NewExpressionEvaluatorWithEnv(ctx context.Context, env map if rc.caller != nil && rc.ExprEval != nil { workflowCallResult = map[string]*model.WorkflowCallResult{} - for jobName, job := range jobs { + for jobName, job := range rc.Run.Workflow.Jobs { result := model.WorkflowCallResult{ Outputs: map[string]string{}, } @@ -108,7 +70,7 @@ func (rc *RunContext) NewExpressionEvaluatorWithEnv(ctx context.Context, env map Vars: getWorkflowVars(ctx, rc), Strategy: strategy, Matrix: rc.Matrix, - Needs: using, + Needs: exprparser.NeedsContext(rc.Run), Inputs: inputs, HashFiles: getHashFilesFunction(ctx, rc), } @@ -137,23 +99,6 @@ func (rc *RunContext) NewActionInputsExpressionEvaluator(ctx context.Context, st func (rc *RunContext) newStepExpressionEvaluator(ctx context.Context, step step, stepInputs map[string]any, jobContext *model.JobContext) *ExpressionEvaluator { // todo: cleanup EvaluationEnvironment creation - job := rc.Run.Job() - strategy := make(map[string]any) - if job.Strategy != nil { - setStrategyContext(strategy, job.Strategy) - } - - jobs := rc.Run.Workflow.Jobs - jobNeeds := rc.Run.Job().Needs() - - using := make(map[string]exprparser.Needs) - for _, needs := range jobNeeds { - using[needs] = exprparser.Needs{ - Outputs: jobs[needs].Outputs, - Result: jobs[needs].NeedsResult(), - } - } - ee := &exprparser.EvaluationEnvironment{ Github: step.getGithubContext(ctx), Env: *step.getEnv(), @@ -161,9 +106,9 @@ func (rc *RunContext) newStepExpressionEvaluator(ctx context.Context, step step, Steps: rc.getStepsContext(), Secrets: getWorkflowSecrets(rc), Vars: getWorkflowVars(ctx, rc), - Strategy: strategy, + Strategy: exprparser.StrategyContext(rc.Run.Job().Strategy, rc.jobIndex, rc.jobTotal), Matrix: rc.Matrix, - Needs: using, + Needs: exprparser.NeedsContext(rc.Run), // todo: should be unavailable // but required to interpolate/evaluate the inputs in actions/composite Inputs: getEvaluatorInputs(rc, stepInputs, rc.getGithubContext(ctx)), @@ -306,45 +251,30 @@ func getEvaluatorInputs(rc *RunContext, stepInputs map[string]any, ghc *model.Gi maps.Copy(inputs, rc.workflowCallInputs) maps.Copy(inputs, stepInputs) - if ghc.EventName == "workflow_dispatch" { - config := rc.Run.Workflow.WorkflowDispatchConfig() - if config != nil && config.Inputs != nil { - for k, v := range config.Inputs { - value := nestedMapLookup(ghc.Event, "inputs", k) - if value == nil { - value = v.Default + switch ghc.EventName { + case "workflow_dispatch": + if config := rc.Run.Workflow.WorkflowDispatchConfig(); config != nil { + for name, input := range config.Inputs { + if input.Type == "number" { + input.Type = "string" // boolean is the only typed dispatch input, as in Gitea } - inputs[k] = coerceInputValue(value, v.Type) + setEvaluatorInput(inputs, ghc, name, model.WorkflowCallInput{Type: input.Type, Default: input.Default}) } } - } - - if ghc.EventName == "workflow_call" { - config := rc.Run.Workflow.WorkflowCallConfig() - if config != nil && config.Inputs != nil { - for k, v := range config.Inputs { - value := nestedMapLookup(ghc.Event, "inputs", k) - if value == nil { - value = v.Default - } - inputs[k] = coerceInputValue(value, v.Type) - } + case "workflow_call": + for name, input := range rc.Run.Workflow.WorkflowCallConfig().Inputs { + setEvaluatorInput(inputs, ghc, name, input) } } return inputs } -// coerceInputValue converts an input value to the type declared by the workflow. -// The event payload carries natively typed JSON values on newer Gitea versions, -// while defaults and older servers provide strings. -func coerceInputValue(value any, inputType string) any { - if inputType != "boolean" { - return value +func setEvaluatorInput(inputs map[string]any, ghc *model.GithubContext, name string, input model.WorkflowCallInput) { + value := model.NestedMapLookup(ghc.Event, "inputs", name) + if value == nil { + value, _ = input.DefaultValue() } - if b, ok := value.(bool); ok { - return b - } - return value == "true" + inputs[name] = model.CoerceInputValue(value, input.Type) } // resolveWorkflowCall evaluates the caller's with: and secrets: once, as the server does before dispatching a called workflow. @@ -352,40 +282,32 @@ func (rc *RunContext) resolveWorkflowCall(ctx context.Context) error { if rc.caller == nil { return nil } + logger := common.Logger(ctx) callerJob := rc.caller.runContext.Run.Job() - callerEval := rc.caller.runContext.ExprEval - callerWith := callerJob.With - if err := decodeDeferred(ctx, callerEval, "workflow inputs", callerJob.RawWith, &callerWith); err != nil { + callerEval := rc.caller.runContext.ExprEval.shared(ctx) + var callerWith map[string]any + if err := model.DecodeEvaluated("workflow inputs", callerJob.RawWith, callerEval.EvaluateYamlNode, &callerWith); err != nil { return err } - calleeEval := sync.OnceValue(func() *expressionEvaluator { return rc.NewExpressionEvaluator(ctx) }) + if !rc.Run.Workflow.IsWorkflowCall() { + logger.Warn("workflow_call key is not defined in the referenced workflow") + } config := rc.Run.Workflow.WorkflowCallConfig() - - rc.workflowCallInputs = make(map[string]any, len(config.Inputs)) - for name, input := range config.Inputs { - value, eval, label := callerWith[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) + var err error + if rc.workflowCallInputs, err = callerEval.ResolveWorkflowCallInputs(config, callerWith); err != nil { + logger.Warn(err) } + if callerJob.InheritSecrets() { + rc.workflowCallSecrets = getWorkflowSecrets(rc.caller.runContext) + return nil + } secrets := callerJob.Secrets() - if secrets == nil && callerJob.InheritSecrets() { - secrets = rc.caller.runContext.Config.Secrets + if err := config.ValidateSecrets(secrets); err != nil { + logger.Warn(err) } - 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) - } + if rc.workflowCallSecrets, err = callerEval.EvaluateCallerSecrets(secrets); err != nil { + return fmt.Errorf("unable to interpolate %w", err) } return nil } diff --git a/act/runner/expression_test.go b/act/runner/expression_test.go index 31f003a0..6ec57dd7 100644 --- a/act/runner/expression_test.go +++ b/act/runner/expression_test.go @@ -294,6 +294,8 @@ on: name: type: string default: gitea + unset: + type: boolean `, "workflow_dispatch": ` on: @@ -305,6 +307,8 @@ on: name: type: string default: gitea + unset: + type: boolean `, } @@ -356,6 +360,7 @@ on: inputs := getEvaluatorInputs(rc, nil, ghc) assert.Equal(t, table.flag, inputs["flag"]) assert.Equal(t, "gitea", inputs["name"]) + assert.Equal(t, false, inputs["unset"]) }) } } @@ -375,11 +380,11 @@ jobs: for _, value := range []string{"true", "false"} { t.Run(value, func(t *testing.T) { t.Parallel() - parent, err := (&runnerImpl{config: &Config{}}).newRunContext(t.Context(), &model.Run{Workflow: workflow, JobID: "call"}, map[string]any{"args": `{"flag":` + value + `,"name":"runner"}`}) + parent, err := (&runnerImpl{config: &Config{}}).newRunContext(t.Context(), &model.Run{Workflow: workflow, JobID: "call"}, map[string]any{"args": `{"flag":` + value + `,"name":"${{ 'runner' }}"}`}) require.NoError(t, err) child, err := (&runnerImpl{config: &Config{}, caller: &caller{runContext: parent}}).newRunContext(t.Context(), &model.Run{Workflow: workflow, JobID: "test"}, nil) require.NoError(t, err) - assert.Equal(t, map[string]any{"flag": value == "true", "name": "runner"}, child.workflowCallInputs) + assert.Equal(t, map[string]any{"flag": value == "true", "name": "${{ 'runner' }}", "unset": false}, child.workflowCallInputs) assert.Equal(t, rawWith, job.RawWith) assert.Nil(t, job.With) }) @@ -387,6 +392,46 @@ jobs: }) } +func TestResolveWorkflowCallToleratesMismatchesAndPassesInheritedSecretsVerbatim(t *testing.T) { + callerWorkflow, err := model.ReadWorkflow(strings.NewReader(` +jobs: + call: + uses: ./reuse.yml + with: {flag: not-a-boolean} + secrets: {undeclared: "${{ secrets.token }}"} + inherit: + uses: ./reuse.yml + secrets: inherit +`)) + require.NoError(t, err) + callee, err := model.ReadWorkflow(strings.NewReader(` +on: + workflow_call: + inputs: + flag: {type: boolean, default: true} +jobs: + test: {} +`)) + require.NoError(t, err) + notCallable, err := model.ReadWorkflow(strings.NewReader("on: push\njobs:\n test: {}\n")) + require.NoError(t, err) + config := &Config{Secrets: map[string]string{"token": "${{ github.token }}"}} + resolve := func(callerJobID string, workflow *model.Workflow) *RunContext { + parent, err := (&runnerImpl{config: config}).newRunContext(t.Context(), &model.Run{Workflow: callerWorkflow, JobID: callerJobID}, nil) + require.NoError(t, err) + child, err := (&runnerImpl{config: config, caller: &caller{runContext: parent}}).newRunContext(t.Context(), &model.Run{Workflow: workflow, JobID: "test"}, nil) + require.NoError(t, err) + return child + } + + called := resolve("call", callee) + assert.Equal(t, map[string]any{"flag": true}, called.workflowCallInputs) + assert.Equal(t, map[string]string{"undeclared": "${{ github.token }}"}, called.workflowCallSecrets) + inherited := resolve("inherit", notCallable) + assert.Empty(t, inherited.workflowCallInputs) + assert.Equal(t, config.Secrets, inherited.workflowCallSecrets) +} + func TestJobNameMasksSecrets(t *testing.T) { workflow, err := model.ReadWorkflow(strings.NewReader(` jobs: diff --git a/act/runner/job_executor.go b/act/runner/job_executor.go index 78a5cd2e..fcc7f13f 100644 --- a/act/runner/job_executor.go +++ b/act/runner/job_executor.go @@ -18,7 +18,6 @@ import ( "slices" "strconv" "strings" - "sync" "time" "unicode" @@ -414,49 +413,45 @@ func setJobResult(ctx context.Context, info jobInfo, rc *RunContext, success boo logger.WithField("jobResult", jobResult).Infof("Job %s", jobResultMessage) } -// evaluateJobEnvAndDefaults resolves the job's env and defaults.run once, as GitHub does at job setup. +// evaluateJobEnvAndDefaults resolves the job's env, defaults.run and container env once, as GitHub does at job setup. func evaluateJobEnvAndDefaults(ctx context.Context, rc *RunContext) error { rc.ExprEval = rc.NewExpressionEvaluator(ctx) + env := rc.GetEnv() var workflowEnv map[string]string - if err := decodeDeferred(ctx, rc.ExprEval, "workflow env", rc.Run.Workflow.RawEnv, &workflowEnv); err != nil { - return err + if rc.Run.Workflow.Env == nil { + if err := model.DecodeEvaluated("workflow env", rc.Run.Workflow.RawEnv, rc.ExprEval.shared(ctx).EvaluateYamlNode, &workflowEnv); err != nil { + return err + } } if workflowEnv != nil { - rc.Env = mergeMaps(workflowEnv, rc.GetEnv()) + rc.Env = mergeMaps(workflowEnv, env) rc.ExprEval = rc.NewExpressionEvaluator(ctx) } var err error - for k, v := range rc.GetEnv() { + for k, v := range env { 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) + var defaults model.Defaults + if err := model.DecodeEvaluated("defaults", rc.Run.Job().RawDefaults, rc.ExprEval.shared(ctx).EvaluateYamlNode, &defaults); err != nil { + return 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) + rc.jobRunDefaults = defaults.Run + if rc.containerSpec.Image == "" { + return nil } - return nil + _, containerEnv := splitContainerEnv(rc.Run.Job().RawContainer) + return model.DecodeEvaluated("container env", containerEnv, rc.ExprEval.shared(ctx).EvaluateYamlNode, &rc.containerSpec.Env) } 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 + callerOutputs, err := rc.NewExpressionEvaluator(ctx).shared(ctx).EvaluateWorkflowCallOutputs(rc.Run.Workflow.WorkflowCallConfig()) + if err != nil { + return fmt.Errorf("unable to interpolate workflow %w", err) } // Matrix combinations of a reusable-workflow caller share the caller's *model.Job; @@ -474,7 +469,7 @@ func applyJobTimeout(ctx context.Context, rc *RunContext, job *model.Job) (conte 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 && timeoutMinutes > 0 { return context.WithTimeout(ctx, time.Duration(timeoutMinutes)*time.Minute) } } diff --git a/act/runner/job_executor_test.go b/act/runner/job_executor_test.go index 6c818235..1384f6e6 100644 --- a/act/runner/job_executor_test.go +++ b/act/runner/job_executor_test.go @@ -479,8 +479,7 @@ func TestNewJobExecutor(t *testing.T) { Config: &Config{}, } if tt.output != "" { - rc.Run.Job().Outputs = map[string]string{"bad": tt.output} - rc.outputTemplate = map[string]string{"bad": tt.output} + require.NoError(t, rc.Run.Job().RawOutputs.Encode(map[string]string{"bad": tt.output})) } rc.ExprEval = rc.NewExpressionEvaluator(ctx) executorOrder := make([]string, 0) @@ -1082,6 +1081,7 @@ func TestApplyJobTimeout(t *testing.T) { }{ {"empty", "runs-on: ubuntu-latest", false}, {"integer", "timeout-minutes: 5\nruns-on: ubuntu-latest", true}, + {"zero ignored", "timeout-minutes: 0\nruns-on: ubuntu-latest", false}, {"non-numeric ignored", "timeout-minutes: abc\nruns-on: ubuntu-latest", false}, } for _, tc := range cases { diff --git a/act/runner/job_hooks.go b/act/runner/job_hooks.go index 2fa78c82..ab6f6ea6 100644 --- a/act/runner/job_hooks.go +++ b/act/runner/job_hooks.go @@ -7,7 +7,6 @@ import ( "cmp" "context" "fmt" - "maps" "path" "strings" @@ -51,11 +50,7 @@ func (rc *RunContext) runJobHook(ctx context.Context, hookPath, name string) err rawLogger.Infof("shell: %s", shell) } - env := map[string]string{} - if jobContainer := rc.Run.Job().Container(); jobContainer != nil { - maps.Copy(env, jobContainer.Env) - } - maps.Copy(env, rc.GetEnv()) + env := mergeMaps(rc.containerSpec.Env, rc.GetEnv()) rc.withGithubEnv(ctx, rc.getGithubContext(ctx), env) rc.ApplyExtraPath(ctx, &env) diff --git a/act/runner/reusable_workflow.go b/act/runner/reusable_workflow.go index 095a8c6f..49d10bc7 100644 --- a/act/runner/reusable_workflow.go +++ b/act/runner/reusable_workflow.go @@ -9,7 +9,6 @@ import ( "fmt" "net/url" "path" - "path/filepath" "regexp" "strings" @@ -20,28 +19,22 @@ import ( ) func newLocalReusableWorkflowExecutor(rc *RunContext) common.Executor { - if !rc.Config.NoSkipCheckout { - fullPath := rc.Run.Job().Uses - - fileName := path.Base(fullPath) - workflowDir := strings.TrimSuffix(fullPath, path.Join("/", fileName)) - workflowDir = strings.TrimPrefix(workflowDir, "./") - - return common.NewPipelineExecutor( - // resolve the local workflow against the workspace root, not the process - // working directory, so it is found regardless of where the runner is invoked - newReusableWorkflowExecutor(rc, filepath.Join(rc.Config.Workdir, workflowDir), fileName), - ) + localWorkflow, err := model.ParseReusableWorkflowUses(rc.Run.Job().Uses) + if err != nil { + return common.NewErrorExecutor(err) } - // ./.gitea/workflows/wf.yml -> .gitea/workflows/wf.yml - trimmedUses := strings.TrimPrefix(rc.Run.Job().Uses, "./") - // uses string format is {owner}/{repo}/.{git_platform}/workflows/{filename}@{ref} - uses := fmt.Sprintf("%s/%s@%s", rc.Config.PresetGitHubContext.Repository, trimmedUses, rc.Config.PresetGitHubContext.Sha) + if !rc.Config.NoSkipCheckout { + // resolve the local workflow against the workspace root, not the process + // working directory, so it is found regardless of where the runner is invoked + return newReusableWorkflowExecutor(rc, rc.Config.Workdir, localWorkflow.Path) + } - remoteReusableWorkflow := newRemoteReusableWorkflowWithPlat(rc.Config.GitHubInstance, uses) - if remoteReusableWorkflow == nil { - return common.NewErrorExecutor(fmt.Errorf("expected format {owner}/{repo}/.{git_platform}/workflows/{filename}@{ref}. Actual '%s' Input string was not in a correct format", uses)) + uses := fmt.Sprintf("%s/%s@%s", rc.Config.PresetGitHubContext.Repository, localWorkflow.Path, rc.Config.PresetGitHubContext.Sha) + + remoteReusableWorkflow, err := newRemoteReusableWorkflow(rc.Config.GitHubInstance, uses) + if err != nil { + return common.NewErrorExecutor(err) } workflowDir := fmt.Sprintf("%s/%s", rc.ActionCacheDir(), safeFilename(uses)) @@ -51,37 +44,27 @@ func newLocalReusableWorkflowExecutor(rc *RunContext) common.Executor { return common.NewPipelineExecutor( cloneRemoteReusableWorkflow(rc, remoteReusableWorkflow.CloneURL(), remoteReusableWorkflow.Ref, workflowDir, token), - newReusableWorkflowExecutor(rc, workflowDir, remoteReusableWorkflow.FilePath()), + newReusableWorkflowExecutor(rc, workflowDir, remoteReusableWorkflow.Path), ) } func newRemoteReusableWorkflowExecutor(rc *RunContext) common.Executor { - uses := rc.Run.Job().Uses - - var remoteReusableWorkflow *remoteReusableWorkflow - if strings.HasPrefix(uses, "http://") || strings.HasPrefix(uses, "https://") { - remoteReusableWorkflow = newRemoteReusableWorkflowFromAbsoluteURL(uses) - if remoteReusableWorkflow == nil { - return common.NewErrorExecutor(fmt.Errorf("expected format http(s)://{domain}/{owner}/{repo}/.{git_platform}/workflows/{filename}@{ref}. Actual '%s' Input string was not in a correct format", uses)) - } - } else { - remoteReusableWorkflow = newRemoteReusableWorkflowWithPlat(rc.Config.GitHubInstance, uses) - if remoteReusableWorkflow == nil { - return common.NewErrorExecutor(fmt.Errorf("expected format {owner}/{repo}/.{git_platform}/workflows/{filename}@{ref}. Actual '%s' Input string was not in a correct format", uses)) - } + remoteReusableWorkflow, err := newRemoteReusableWorkflow(rc.Config.GitHubInstance, rc.Run.Job().Uses) + if err != nil { + return common.NewErrorExecutor(err) } // uses with safe filename makes the target directory look something like this {owner}-{repo}-.github-workflows-{filename}@{ref} // instead we will just use {owner}-{repo}@{ref} as our target directory. This should also improve performance when we are using // multiple reusable workflows from the same repository and ref since for each workflow we won't have to clone it again - filename := fmt.Sprintf("%s/%s@%s", remoteReusableWorkflow.Org, remoteReusableWorkflow.Repo, remoteReusableWorkflow.Ref) + filename := fmt.Sprintf("%s/%s@%s", remoteReusableWorkflow.Owner, remoteReusableWorkflow.Repo, remoteReusableWorkflow.Ref) workflowDir := fmt.Sprintf("%s/%s", rc.ActionCacheDir(), safeFilename(filename)) token := getGitCloneToken(rc.Config, remoteReusableWorkflow.CloneURL()) return common.NewPipelineExecutor( cloneRemoteReusableWorkflow(rc, remoteReusableWorkflow.CloneURL(), remoteReusableWorkflow.Ref, workflowDir, token), - newReusableWorkflowExecutor(rc, workflowDir, remoteReusableWorkflow.FilePath()), + newReusableWorkflowExecutor(rc, workflowDir, remoteReusableWorkflow.Path), ) } @@ -90,7 +73,7 @@ func newRemoteReusableWorkflowExecutor(rc *RunContext) common.Executor { // // Callers must not change remoteReusableWorkflow.URL, because: // 1. Gitea doesn't support specifying GithubContext.ServerURL by the GITHUB_SERVER_URL env -// 2. Gitea has already full URL with rc.Config.GitHubInstance when calling newRemoteReusableWorkflowWithPlat +// 2. Gitea has already full URL with rc.Config.GitHubInstance when calling newRemoteReusableWorkflow // // remoteReusableWorkflow.URL = rc.getGithubContext(ctx).ServerURL func cloneRemoteReusableWorkflow(rc *RunContext, cloneURL, ref, targetDirectory, token string) common.Executor { @@ -153,64 +136,34 @@ func newReusableWorkflowRunner(rc *RunContext) (*runnerImpl, error) { } type remoteReusableWorkflow struct { - URL string - Org string - Repo string - Filename string - Ref string - - GitPlatform string + *model.ReusableWorkflowUses + URL string } func (r *remoteReusableWorkflow) CloneURL() string { // In Gitea, r.URL always has the protocol prefix, we don't need to add extra prefix in this case. if strings.HasPrefix(r.URL, "http://") || strings.HasPrefix(r.URL, "https://") { - return fmt.Sprintf("%s/%s/%s", r.URL, r.Org, r.Repo) + return fmt.Sprintf("%s/%s/%s", r.URL, r.Owner, r.Repo) } - return fmt.Sprintf("https://%s/%s/%s", r.URL, r.Org, r.Repo) + return fmt.Sprintf("https://%s/%s/%s", r.URL, r.Owner, r.Repo) } -func (r *remoteReusableWorkflow) FilePath() string { - return fmt.Sprintf("./.%s/workflows/%s", r.GitPlatform, r.Filename) -} +var absoluteReusableWorkflowURLRegex = regexp.MustCompile(`^(https?://.*)/([^/]+/[^/]+/\.[^/]+/workflows/[^@]+@.*)$`) // For Gitea -// newRemoteReusableWorkflowWithPlat create a `remoteReusableWorkflow` -// workflows from `.gitea/workflows` and `.github/workflows` are supported -func newRemoteReusableWorkflowWithPlat(url, uses string) *remoteReusableWorkflow { - // GitHub docs: - // https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_iduses - r := regexp.MustCompile(`^([^/]+)/([^/]+)/\.([^/]+)/workflows/([^@]+)@(.*)$`) - matches := r.FindStringSubmatch(uses) - if len(matches) != 6 { - return nil +// newRemoteReusableWorkflow parses a remote `uses`, an absolute URL up to `{owner}/{repo}/.{git_platform}/workflows/` replaces baseURL +func newRemoteReusableWorkflow(baseURL, uses string) (*remoteReusableWorkflow, error) { + if matches := absoluteReusableWorkflowURLRegex.FindStringSubmatch(uses); matches != nil { + baseURL, uses = matches[1], matches[2] } - return &remoteReusableWorkflow{ - Org: matches[1], - Repo: matches[2], - GitPlatform: matches[3], - Filename: matches[4], - Ref: matches[5], - URL: url, + parsed, err := model.ParseReusableWorkflowUses(uses) + if err != nil { + return nil, err } -} - -// For Gitea -// newRemoteReusableWorkflowWithPlat create a `remoteReusableWorkflow` from an absolute url -func newRemoteReusableWorkflowFromAbsoluteURL(uses string) *remoteReusableWorkflow { - r := regexp.MustCompile(`^(https?://.*)/([^/]+)/([^/]+)/\.([^/]+)/workflows/([^@]+)@(.*)$`) - matches := r.FindStringSubmatch(uses) - if len(matches) != 7 { - return nil - } - return &remoteReusableWorkflow{ - URL: matches[1], - Org: matches[2], - Repo: matches[3], - GitPlatform: matches[4], - Filename: matches[5], - Ref: matches[6], + if parsed.IsLocal() { + return nil, fmt.Errorf("expected a remote reusable workflow, got %q", uses) } + return &remoteReusableWorkflow{ReusableWorkflowUses: parsed, URL: baseURL}, nil } // For Gitea diff --git a/act/runner/reusable_workflow_test.go b/act/runner/reusable_workflow_test.go index 82661a09..6e4665e3 100644 --- a/act/runner/reusable_workflow_test.go +++ b/act/runner/reusable_workflow_test.go @@ -5,6 +5,7 @@ package runner import ( "context" + "io/fs" "os" "os/exec" "path/filepath" @@ -106,6 +107,22 @@ func TestNewReusableWorkflowExecutorHoldsCloneLock(t *testing.T) { } } +func TestNewLocalReusableWorkflowExecutorFindsSameRepositoryPaths(t *testing.T) { + workdir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(workdir, ".gitea", "workflows"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(workdir, ".gitea", "workflows", "reusable.yml"), []byte(":"), 0o644)) + + for _, uses := range []string{"./.gitea/workflows/reusable.yml", "$/.gitea/workflows/reusable.yml"} { + rc := &RunContext{ + Config: &Config{Workdir: workdir}, + Run: &model.Run{JobID: "job", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"job": {Uses: uses}}}}, + } + err := newLocalReusableWorkflowExecutor(rc)(t.Context()) + require.Error(t, err) + require.NotErrorIs(t, err, fs.ErrNotExist) + } +} + func TestGetGitCloneTokenWithSchemalessGiteaInstance(t *testing.T) { conf := &Config{ GitHubInstance: "gitea.example.net", diff --git a/act/runner/run_context.go b/act/runner/run_context.go index f293ced2..b2204d3e 100644 --- a/act/runner/run_context.go +++ b/act/runner/run_context.go @@ -35,8 +35,10 @@ import ( "gitea.dev/actionslib/pkg/model" "github.com/docker/cli/cli/compose/loader" "github.com/docker/go-connections/nat" + "github.com/kballard/go-shellquote" "github.com/moby/moby/api/types/mount" "github.com/opencontainers/selinux/go-selinux" + "go.yaml.in/yaml/v4" "golang.org/x/sync/errgroup" "golang.org/x/text/encoding/unicode" "golang.org/x/text/transform" @@ -62,6 +64,7 @@ type RunContext struct { ExprEval *expressionEvaluator JobContainer container.ExecutionsEnvironment serviceContainers []*serviceContainer + containerSpec model.ContainerSpec // container:, resolved once with platformImage, zero without a job container JobName string ActionPath string Parent *RunContext @@ -74,6 +77,7 @@ type RunContext struct { 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 + jobIndex, jobTotal int // strategy.job-index and job-total, total 0 when unknown // 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 @@ -82,10 +86,6 @@ type RunContext struct { // so writes from later phases and from composite sub-steps append to the same file. // Only populated on the top-level RunContext; child RCs walk Parent via topLevelRunContext. summaryFileInitialized map[int]bool - // outputTemplate is this combination's pristine snapshot of the job's output expressions, - // captured before execution so each matrix combo interpolates from the originals rather - // than from a sibling's already-resolved values written into the shared Job.Outputs. - outputTemplate map[string]string // jobCancelled records that this job's run was cancelled (context.Canceled). It makes // getJobContext report the "cancelled" status so cancelled()/always() evaluate the way // GitHub Actions does, letting cleanup and always() steps run while normal steps skip. @@ -334,26 +334,12 @@ 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, error) { +func (rc *RunContext) GetBindsAndMounts() ([]string, map[string]string) { name := rc.jobContainerName() ext := container.LinuxContainerEnvironmentExtensions{} - var volumes []string - if job := rc.Run.Job(); job != nil { - if container := job.Container(); container != nil { - for _, v := range container.Volumes { - if rc.ExprEval != nil { - 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) - } - } - } // the runner's own mounts below yield to the targets the job claims - binds, mounts, claimed := splitVolumes(volumes) + binds, mounts, claimed := splitVolumes(rc.containerSpec.Volumes) if rc.containerDaemonSocket() != "-" && !claimed["/var/run/docker.sock"] { binds = append(binds, rc.jobDockerSocket()+":/var/run/docker.sock") @@ -385,7 +371,7 @@ func (rc *RunContext) GetBindsAndMounts() ([]string, map[string]string, error) { } } - return binds, mounts, nil + return binds, mounts } func (rc *RunContext) startHostEnvironment() common.Executor { @@ -486,7 +472,7 @@ func (rc *RunContext) startJobContainer() common.Executor { image := rc.platformImage logWriter := rc.commandLogWriter(ctx) - username, password, err := rc.handleCredentials(ctx) + username, password, err := registryCredentials(rc.containerSpec.Credentials, "container.") if err != nil { return fmt.Errorf("failed to handle credentials: %s", err) } @@ -507,78 +493,57 @@ func (rc *RunContext) startJobContainer() common.Executor { networkName, createAndDeleteNetwork := rc.networkNameForGitea() rc.cleanUpJobContainer = rc.cleanupJobResources(networkName, createAndDeleteNetwork, false) - services := rc.Run.Job().Services - if err := decodeDeferred(ctx, rc.ExprEval, "job services", rc.Run.Job().RawServices, &services); err != nil { + var services map[string]*model.ContainerSpec + if err := model.DecodeEvaluated("job services", rc.Run.Job().RawServices, rc.ExprEval.shared(ctx).EvaluateYamlNode, &services); err != nil { return err } for serviceID, spec := range services { + if spec == nil { + return fmt.Errorf("service %s has no container definition", serviceID) + } // GitHub compatibility: skip services whose image evaluates to an // empty string, enabling conditional services via expressions - 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 spec.Image == "" { logger.Infof("The service '%s' will not be started because the container definition has an empty image.", serviceID) continue } - // interpolate env - interpolatedEnvs := make(map[string]string, len(spec.Env)+len(rc.Config.ProxyEnv)) + envs := make([]string, 0, len(spec.Env)+len(rc.Config.ProxyEnv)) // 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 { - 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 { + for k, v := range mergeMaps(rc.Config.ProxyEnv, spec.Env) { envs = append(envs, fmt.Sprintf("%s=%s", k, v)) } - // interpolate cmd - 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 - serviceUsername, servicePassword, err := rc.interpolateCredentials(ctx, spec.Credentials, "") + serviceUsername, servicePassword, err := registryCredentials(spec.Credentials, "") if err != nil { return fmt.Errorf("failed to handle service %s credentials: %w", serviceID, err) } - - 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) + cmd := spec.Cmd + if len(cmd) == 0 && spec.Command != "" { + if cmd, err = shellquote.Split(spec.Command); err != nil { + return fmt.Errorf("failed to parse service %s command: %w", serviceID, err) } } - serviceBinds, serviceMounts, _ := splitVolumes(interpolatedVolumes) - - 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) - } + var entrypoint []string + if spec.Entrypoint != "" { + entrypoint = []string{spec.Entrypoint} } - exposedPorts, portBindings, err := nat.ParsePortSpecs(interpolatedPorts) + + serviceBinds, serviceMounts, _ := splitVolumes(spec.Volumes) + + exposedPorts, portBindings, err := nat.ParsePortSpecs(spec.Ports) 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{ Name: serviceContainerName, - Image: serviceImage, + Image: spec.Image, Username: serviceUsername, Password: servicePassword, - Cmd: interpolatedCmd, + Entrypoint: entrypoint, + Cmd: cmd, Env: envs, Mounts: serviceMounts, Binds: serviceBinds, @@ -588,7 +553,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: serviceOptions, + WorkflowOptions: spec.Options, NetworkMode: networkName, NetworkAliases: []string{serviceID}, ExposedPorts: exposedPorts, @@ -596,16 +561,12 @@ func (rc *RunContext) startJobContainer() common.Executor { ValidVolumes: rc.Config.ValidVolumes, // not validVolumes(), a service gets no docker socket AllocatePTY: rc.Config.AllocatePTY, }) - rc.serviceContainers = append(rc.serviceContainers, &serviceContainer{name: serviceID, image: serviceImage, container: c}) + rc.serviceContainers = append(rc.serviceContainers, &serviceContainer{name: serviceID, image: spec.Image, container: c}) } // For Gitea, `jobContainerNetwork` should be the same as `networkName` jobContainerNetwork := networkName - workflowOptions, err := rc.workflowOptions(ctx) - if err != nil { - return err - } ext := container.LinuxContainerEnvironmentExtensions{} containerInput := &container.NewContainerInput{ Cmd: nil, @@ -624,7 +585,7 @@ func (rc *RunContext) startJobContainer() common.Executor { UsernsMode: rc.Config.UsernsMode, Platform: rc.Config.ContainerArchitecture, RunnerOptions: rc.Config.ContainerOptions, - WorkflowOptions: workflowOptions, + WorkflowOptions: rc.containerSpec.Options, AutoRemove: true, AllocatePTY: rc.Config.AllocatePTY, } @@ -641,9 +602,7 @@ func (rc *RunContext) startJobContainer() common.Executor { return err } rc.startDockerProxy(ctx) - if containerInput.Binds, containerInput.Mounts, err = rc.GetBindsAndMounts(); err != nil { - return err - } + containerInput.Binds, containerInput.Mounts = rc.GetBindsAndMounts() containerInput.ValidVolumes = rc.validVolumes() rc.jobNetworkName = networkName @@ -1033,39 +992,16 @@ func lockJob(job *model.Job) func() { func (rc *RunContext) interpolateOutputs() common.Executor { return func(ctx context.Context) error { - ee := rc.NewExpressionEvaluator(ctx) job := rc.Run.Job() - // Matrix combinations share this Job and its Outputs map. Interpolate from this combo's - // pristine snapshot (outputTemplate) and write under the lock, so each combo overwrites - // with its own resolved values (last wins, as on GitHub) instead of the first combo's - // resolved values freezing the shared template against later combos. - // Resolved up front so one failure publishes none of them, as GitHub does. - var deferredOutputs map[string]string - if err := decodeDeferred(ctx, ee, "job outputs", job.RawOutputs, &deferredOutputs); err != nil { + var outputs map[string]string + if err := model.DecodeEvaluated("job outputs", job.RawOutputs, rc.NewExpressionEvaluator(ctx).shared(ctx).EvaluateYamlNode, &outputs); err != nil { return err } - if deferredOutputs != nil { - defer lockJob(job)() - job.Outputs = deferredOutputs - return nil - } - 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 { - err = fmt.Errorf("failed to evaluate job output %q: %w", k, err) - break - } - } + // Matrix combinations share the job, so as on GitHub a failure publishes nothing and the last non-empty value wins. + maps0.DeleteFunc(outputs, func(_, value string) bool { return value == "" }) defer lockJob(job)() - for k := range rc.outputTemplate { - if err != nil { - job.Outputs[k] = "" - continue - } - job.Outputs[k] = outputs[k] - } - return err + job.Outputs = mergeMaps(job.Outputs, outputs) + return nil } } @@ -1184,18 +1120,6 @@ func (rc *RunContext) Executor() (common.Executor, error) { }, nil } -func (rc *RunContext) containerImage(ctx context.Context) (string, error) { - c := rc.Run.Job().Container() - if c == nil { - return "", nil - } - 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, error) { if rc.Run.Job().RunsOn() == nil { common.Logger(ctx).Errorf("'runs-on' key not defined in %s", rc.String()) @@ -1233,26 +1157,37 @@ func (rc *RunContext) runsOnPlatformNames(ctx context.Context) []string { return model.RunsOnFromNode(rawRunsOn) } -// resolvePlatformImage evaluates the job's image once, so every consumer sees the image the job started with. +// resolvePlatformImage evaluates the job's container once for every consumer, ignoring one without an image as GitHub does. func (rc *RunContext) resolvePlatformImage(ctx context.Context) error { - image, err := rc.containerImage(ctx) - if err == nil && image == "" { - image, err = rc.runsOnImage(ctx) + withoutEnv, _ := splitContainerEnv(rc.Run.Job().RawContainer) + var spec *model.ContainerSpec + if err := model.DecodeEvaluated("container", withoutEnv, rc.ExprEval.shared(ctx).EvaluateYamlNode, &spec); err != nil { + return err } + if spec != nil && spec.Image != "" { + rc.containerSpec, rc.platformImage = *spec, spec.Image + return nil + } + image, err := rc.runsOnImage(ctx) rc.platformImage = image return err } -func (rc *RunContext) workflowOptions(ctx context.Context) (string, error) { - c := rc.Run.Job().Container() - if c == nil { - return "", nil +// splitContainerEnv takes `env` out of a container mapping, since env reads the job env that resolves after the job environment starts. +func splitContainerEnv(container yaml.Node) (withoutEnv, env yaml.Node) { + if container.Kind != yaml.MappingNode { + return container, env } - options, err := rc.ExprEval.Interpolate(ctx, c.Options) - if err != nil { - return "", fmt.Errorf("unable to interpolate container.options: %w", err) + withoutEnv = container + withoutEnv.Content = nil + for i := 0; i+1 < len(container.Content); i += 2 { + if container.Content[i].Value == "env" { + env = *container.Content[i+1] + } else { + withoutEnv.Content = append(withoutEnv.Content, container.Content[i:i+2]...) + } } - return options, nil + return withoutEnv, env } func (rc *RunContext) isEnabled(ctx context.Context) (bool, error) { @@ -1471,22 +1406,11 @@ func (rc *RunContext) getGithubContext(ctx context.Context) *model.GithubContext { // Adapt to Gitea if preset := rc.Config.PresetGitHubContext; preset != nil { - ghc.Event = preset.Event - ghc.RunID = preset.RunID - ghc.RunNumber = preset.RunNumber - ghc.RunAttempt = preset.RunAttempt - ghc.Actor = preset.Actor - ghc.Repository = preset.Repository - ghc.EventName = preset.EventName - ghc.Sha = preset.Sha - ghc.Ref = preset.Ref - ghc.RefName = preset.RefName - ghc.RefType = preset.RefType - ghc.HeadRef = preset.HeadRef - ghc.BaseRef = preset.BaseRef - ghc.Token = preset.Token - ghc.RepositoryOwner = preset.RepositoryOwner - ghc.RetentionDays = preset.RetentionDays + merged := *preset + merged.Workflow, merged.Job, merged.Workspace, merged.EventPath = ghc.Workflow, ghc.Job, ghc.Workspace, ghc.EventPath + merged.Action, merged.ActionPath, merged.ActionRepository, merged.ActionRef = ghc.Action, ghc.ActionPath, ghc.ActionRepository, ghc.ActionRef + merged.RunnerPerflog, merged.RunnerTrackingID = ghc.RunnerPerflog, ghc.RunnerTrackingID + *ghc = merged instance := rc.Config.GitHubInstance if !strings.HasPrefix(instance, "http://") && @@ -1563,12 +1487,7 @@ func isLocalCheckout(ghc *model.GithubContext, step *model.Step) bool { if step.Type() != model.StepTypeUsesActionRemote { return false } - remoteAction := newRemoteAction(step.Uses) - if remoteAction == nil { - // IsCheckout() will nil panic if we dont bail out early - return false - } - if !remoteAction.IsCheckout() { + if uses, err := model.ParseActionUses(step.Uses); err != nil || uses.Owner != "actions" || uses.Repo != "checkout" { return false } @@ -1581,23 +1500,6 @@ func isLocalCheckout(ghc *model.GithubContext, step *model.Step) bool { return true } -func nestedMapLookup(m map[string]any, ks ...string) (rval any) { - var ok bool - - if len(ks) == 0 { // degenerate input - return nil - } - if rval, ok = m[ks[0]]; !ok { - return nil - } else if len(ks) == 1 { // we've reached the final key - return rval - } else if m, ok = rval.(map[string]any); !ok { - return nil - } else { // 1+ more keys - return nestedMapLookup(m, ks[1:]...) - } -} - func (rc *RunContext) withGithubEnv(ctx context.Context, github *model.GithubContext, env map[string]string) { env["CI"] = "true" env["GITHUB_WORKFLOW"] = github.Workflow @@ -1721,41 +1623,17 @@ func setActionRuntimeVars(rc *RunContext, env map[string]string) { env["ACTIONS_RUNTIME_TOKEN"] = actionsRuntimeToken } -func (rc *RunContext) handleCredentials(ctx context.Context) (string, string, error) { - container := rc.Run.Job().Container() - if container == nil || container.Credentials == nil { - return "", "", nil - } - - return rc.interpolateCredentials(ctx, container.Credentials, "container.") -} - -func (rc *RunContext) interpolateCredentials(ctx context.Context, credentials map[string]string, prefix string) (string, string, error) { +func registryCredentials(credentials map[string]string, prefix string) (string, string, error) { if credentials == nil { return "", "", nil } if len(credentials) != 2 { return "", "", errors.New("invalid property count for key 'credentials:'") } - - ee := rc.NewExpressionEvaluator(ctx) - 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) + for _, key := range []string{"username", "password"} { + if credentials[key] == "" { + return "", "", fmt.Errorf("failed to interpolate %scredentials.%s", prefix, key) } - if value == "" { - return "", fmt.Errorf("failed to interpolate %scredentials.%s", prefix, key) - } - 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 credentials["username"], credentials["password"], nil } diff --git a/act/runner/run_context_test.go b/act/runner/run_context_test.go index d0bcfe38..233c0867 100644 --- a/act/runner/run_context_test.go +++ b/act/runner/run_context_test.go @@ -185,38 +185,6 @@ func TestRunContext_EvalBool(t *testing.T) { } } -func TestRunContextHandleCredentialsDoesNotUseDockerSecrets(t *testing.T) { - workflow, err := model.ReadWorkflow(strings.NewReader(` -name: test -on: push -jobs: - job: - runs-on: ubuntu-latest - steps: [] -`)) - require.NoError(t, err) - - rc := &RunContext{ - Config: &Config{ - Secrets: map[string]string{ - "DOCKER_USERNAME": "docker-user", - "DOCKER_PASSWORD": "docker-password", - }, - Env: map[string]string{}, - }, - Run: &model.Run{ - JobID: "job", - Workflow: workflow, - }, - } - - // DOCKER_USERNAME/DOCKER_PASSWORD secrets should not be used as implicit job container pull credentials. - username, password, err := rc.handleCredentials(t.Context()) - require.NoError(t, err) - assert.Empty(t, username) - assert.Empty(t, password) -} - // fakeContainer turns every container operation into a no-op, so startJobContainer // runs without a Docker daemon. The embedded interface is nil, so any method the // test does not exercise panics rather than silently doing the wrong thing. @@ -370,19 +338,28 @@ jobs: require.Equal(t, map[string]string{"data": "/data"}, redis.Mounts) require.Empty(t, redis.Binds) // the docker socket is the job container's alone require.Empty(t, redis.WorkingDir) + require.Nil(t, redis.Entrypoint) + require.Nil(t, redis.Cmd) } -// A whole-value `services:` expression only reaches the typed field through DecodeRaw. -func TestStartJobContainerGivesServicesTheirVolumesFromExpression(t *testing.T) { - redis := startJobContainerInputs(t, ` +func TestStartJobContainerEvaluatesContainersOnce(t *testing.T) { + inputs := startJobContainerInputs(t, ` jobs: job: - services: ${{ fromJSON('{"redis":{"image":"redis:latest","volumes":["data:/data"]}}') }} -`, &Config{ValidVolumes: []string{"data"}})[0] + container: ${{ fromJSON('{"image":"node:20","options":"--label ${{ github.job }}","credentials":{"username":"user","password":"${{ vars.TITLE }}"}}') }} + services: ${{ fromJSON('{"redis":{"image":"redis:latest","volumes":["data:/data"],"env":{"TITLE":"${{ github.job }}"},"credentials":{"username":"user","password":"${{ github.job }}"},"entrypoint":"/entry.sh","command":"redis-server --port 6380"}}') }} +`, &Config{}) + redis, job := inputs[0], inputs[1] require.Equal(t, "redis:latest", redis.Image) - require.Equal(t, []string{"data"}, redis.ValidVolumes) require.Equal(t, map[string]string{"data": "/data"}, redis.Mounts) + require.Equal(t, []string{"TITLE=${{ github.job }}"}, redis.Env) + require.Equal(t, "${{ github.job }}", redis.Password) + require.Equal(t, []string{"/entry.sh"}, redis.Entrypoint) + require.Equal(t, []string{"redis-server", "--port", "6380"}, redis.Cmd) + require.Equal(t, "node:20", job.Image) + require.Equal(t, "--label ${{ github.job }}", job.WorkflowOptions) + require.Equal(t, "${{ vars.TITLE }}", job.Password) } // Only the workflow's options may be stripped later, so the two sources have to reach the @@ -506,8 +483,7 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) { config := testcase.rc.Config config.Workdir = testcase.name config.BindWorkdir = bindWorkDir - gotbind, gotmount, err := rctemplate.GetBindsAndMounts() - require.NoError(t, err) + gotbind, gotmount := rctemplate.GetBindsAndMounts() // Name binds/mounts are either/or if config.BindWorkdir { @@ -543,8 +519,9 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) { t.Run("InterpolatedContainerVolumes", func(t *testing.T) { job := &model.Job{} - err := job.RawContainer.Encode(map[string][]string{ - "volumes": {"${{ secrets.MAME }}:/root/.mame/roms:ro"}, + err := job.RawContainer.Encode(map[string]any{ + "image": "node:20", + "volumes": []string{"${{ secrets.MAME }}:/root/.mame/roms:ro"}, }) require.NoError(t, err) @@ -565,9 +542,9 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) { rc.Run.JobID = "job1" rc.Run.Workflow.Jobs = map[string]*model.Job{"job1": job} rc.ExprEval = rc.NewExpressionEvaluator(context.Background()) + require.NoError(t, rc.resolvePlatformImage(context.Background())) - gotbind, gotmount, err := rc.GetBindsAndMounts() - require.NoError(t, err) + gotbind, gotmount := rc.GetBindsAndMounts() assert.Contains(t, gotbind, "/host/mame/roms:/root/.mame/roms:ro") assert.NotContains(t, gotbind, "${{ secrets.MAME }}") assert.NotContains(t, gotmount, "${{ secrets.MAME }}") @@ -575,12 +552,6 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) { for _, testcase := range tests { t.Run(testcase.name, func(t *testing.T) { - job := &model.Job{} - err := job.RawContainer.Encode(map[string][]string{ - "volumes": testcase.volumes, - }) - assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act - rc := &RunContext{ Name: "TestRCName", Run: &model.Run{ @@ -592,12 +563,11 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) { BindWorkdir: false, SharedToolCache: true, // so OverridesToolCache has a mount to displace }, + containerSpec: model.ContainerSpec{Volumes: testcase.volumes}, } rc.Run.JobID = "job1" - rc.Run.Workflow.Jobs = map[string]*model.Job{"job1": job} - gotbind, gotmount, err := rc.GetBindsAndMounts() - require.NoError(t, err) + gotbind, gotmount := rc.GetBindsAndMounts() if len(testcase.wantbind) > 0 { assert.Contains(t, gotbind, testcase.wantbind) @@ -632,13 +602,11 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) { Config: &Config{}, } - _, gotmount, err := rc.GetBindsAndMounts() - require.NoError(t, err) + _, gotmount := rc.GetBindsAndMounts() assert.NotContains(t, gotmount, sharedToolCacheVolume) rc.Config.SharedToolCache = true - _, gotmount, err = rc.GetBindsAndMounts() - require.NoError(t, err) + _, gotmount = rc.GetBindsAndMounts() assert.Equal(t, container.DefaultToolCache, gotmount[sharedToolCacheVolume]) }) @@ -651,18 +619,15 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) { Config: &Config{BindWorkdir: true, Workdir: "/workspace/1/owner/repo", PresetGitHubContext: &model.GithubContext{}}, } - gotbind, _, err := rc.GetBindsAndMounts() - require.NoError(t, err) + gotbind, _ := rc.GetBindsAndMounts() assert.True(t, slices.ContainsFunc(gotbind, func(bind string) bool { return strings.HasPrefix(bind, "/workspace/1:/workspace/1") }), gotbind) rc.Config.BindWorkdir = false - _, gotmount, err := rc.GetBindsAndMounts() - require.NoError(t, err) + _, gotmount := rc.GetBindsAndMounts() assert.Equal(t, "/workspace/1", gotmount[rc.jobContainerName()]) - require.NoError(t, rc.Run.Job().RawContainer.Encode(map[string][]string{"volumes": {"claimed:/workspace/1"}})) - _, gotmount, err = rc.GetBindsAndMounts() - require.NoError(t, err) + rc.containerSpec.Volumes = []string{"claimed:/workspace/1"} + _, gotmount = rc.GetBindsAndMounts() assert.Equal(t, "/workspace/1/owner/repo", gotmount[rc.jobContainerName()]) }) } @@ -845,25 +810,30 @@ func TestCleanupJobResourcesContinuesAfterFailure(t *testing.T) { } } -// TestInterpolateOutputsIsPerMatrixCombo guards the matrix-output fix: combinations share one -// *model.Job, so each must interpolate from its own pristine snapshot. Otherwise the first -// combo's resolved value freezes the shared template and later combos can't resolve their own. -func TestInterpolateOutputsIsPerMatrixCombo(t *testing.T) { +func TestInterpolateOutputsIsPerMatrixComboKeepingNeedsOutputs(t *testing.T) { job := &model.Job{Outputs: map[string]string{"o": "${{ matrix.v }}"}} - run := &model.Run{JobID: "j", Workflow: &model.Workflow{Name: "w", Jobs: map[string]*model.Job{"j": job}}} + require.NoError(t, job.RawOutputs.Encode(job.Outputs)) + needed := &model.Job{Outputs: map[string]string{"o": "from-gitea"}} + run := &model.Run{JobID: "j", Workflow: &model.Workflow{Name: "w", Jobs: map[string]*model.Job{"j": job, "needed": needed}}} r := &runnerImpl{config: &Config{}} ctx := context.Background() + _, err := r.newRunContext(ctx, &model.Run{JobID: "needed", Workflow: run.Workflow}, nil) + require.NoError(t, err) 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) + rcEmpty, err := r.newRunContext(ctx, run, map[string]any{"v": ""}) + require.NoError(t, err) + require.Empty(t, job.Outputs) require.NoError(t, rcA.interpolateOutputs()(ctx)) require.NoError(t, rcB.interpolateOutputs()(ctx)) + require.NoError(t, rcEmpty.interpolateOutputs()(ctx)) - // Last combo wins (matching GitHub) instead of being frozen to combo A's "a". - require.Equal(t, "b", job.Outputs["o"]) + require.Equal(t, map[string]string{"o": "b"}, job.Outputs) + require.Equal(t, map[string]string{"o": "from-gitea"}, needed.Outputs) } // A whole-value `outputs:` expression only reaches the typed field through DecodeRaw. @@ -936,6 +906,12 @@ func TestGetGitHubContext(t *testing.T) { assert.Equal(t, ghc.RunnerPerflog, "/dev/null") //nolint:testifylint // pre-existing issue from nektos/act assert.Equal(t, ghc.Token, rc.Config.Secrets["GITHUB_TOKEN"]) assert.Equal(t, ghc.Job, "job1") //nolint:testifylint // pre-existing issue from nektos/act + + rc.Config.PresetGitHubContext = &model.GithubContext{Actor: "preset-actor", TriggeringActor: "triggerer", Job: "preset-job"} + ghc = rc.getGithubContext(context.Background()) + assert.Equal(t, "preset-actor", ghc.Actor) + assert.Equal(t, "triggerer", ghc.TriggeringActor) + assert.Equal(t, "job1", ghc.Job) } func TestGetGithubContextRef(t *testing.T) { diff --git a/act/runner/runner.go b/act/runner/runner.go index aaec6a93..10311ed2 100644 --- a/act/runner/runner.go +++ b/act/runner/runner.go @@ -7,7 +7,6 @@ package runner import ( "context" "fmt" - "maps" "os" "runtime" "sync" @@ -159,9 +158,9 @@ func (runner *runnerImpl) configure() (*runnerImpl, error) { func maxParallelFor(strategy *model.Strategy, combinations int) int { maxParallel := 4 // actionslib has no default for an undeclared max-parallel if strategy != nil { - if limit, declared, err := strategy.ParseMaxParallel(); err != nil { + if limit, err := strategy.MaxParallel(); err != nil { log.Errorf("Ignoring invalid max-parallel: %v", err) - } else if declared { + } else if limit > 0 { maxParallel = limit } } @@ -210,7 +209,7 @@ func (runner *runnerImpl) NewPlanExecutor(plan *model.Plan) common.Executor { return err } if job.RawStrategy.Kind == yaml.ScalarNode { - if err := decodeDeferred(ctx, strategyRc.ExprEval, "job strategy", job.RawStrategy, &job.Strategy); err != nil { + if err := model.DecodeEvaluated("job strategy", job.RawStrategy, strategyRc.ExprEval.shared(ctx).EvaluateYamlNode, &job.Strategy); err != nil { return err } } else { @@ -222,6 +221,11 @@ func (runner *runnerImpl) NewPlanExecutor(plan *model.Plan) common.Executor { if err := strategyRc.NewExpressionEvaluator(ctx).EvaluateYamlNode(ctx, &job.Strategy.RawMatrix); err != nil { log.Errorf("Error while evaluating matrix: %v", err) } + for _, value := range []*string{&job.Strategy.FailFastString, &job.Strategy.MaxParallelString} { + if evaluated, err := strategyRc.ExprEval.Interpolate(ctx, *value); err == nil { + *value = evaluated + } + } } } @@ -236,7 +240,16 @@ 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, err := runner.newRunContext(ctx, run, matrix) + index, total := i, len(matrixes) + if runner.caller == nil && runner.config.PresetGitHubContext != nil && len(matrix) > 0 { + var expanded struct { + Index int `yaml:"job-index"` + Total int `yaml:"job-total"` + } + _ = job.RawStrategy.Decode(&expanded) // Gitea expanded the combination, a version writing neither leaves them unknown + index, total = expanded.Index, expanded.Total + } + rc, err := runner.newCombinationRunContext(ctx, run, matrix, index, total) if err != nil { return err } @@ -325,6 +338,10 @@ func handleFailure(plan *model.Plan) common.Executor { } func (runner *runnerImpl) newRunContext(ctx context.Context, run *model.Run, matrix map[string]any) (*RunContext, error) { + return runner.newCombinationRunContext(ctx, run, matrix, 0, 1) +} + +func (runner *runnerImpl) newCombinationRunContext(ctx context.Context, run *model.Run, matrix map[string]any, index, total int) (*RunContext, error) { rc := &RunContext{ Config: runner.config, Run: run, @@ -332,16 +349,16 @@ func (runner *runnerImpl) newRunContext(ctx context.Context, run *model.Run, mat StepResults: make(map[string]*model.StepResult), Matrix: matrix, caller: runner.caller, + jobIndex: index, + jobTotal: total, } if err := rc.resolveWorkflowCall(ctx); err != nil { return nil, err } rc.ExprEval = rc.NewExpressionEvaluator(ctx) 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) + if job := run.Job(); job != nil && job.RawOutputs.Kind != 0 { + job.Outputs = nil // a job no combination ran for reads "" as on GitHub, a Gitea needs placeholder has no RawOutputs and keeps its outputs } return rc, nil diff --git a/act/runner/runner_test.go b/act/runner/runner_test.go index 2cfc7b33..4db76adb 100644 --- a/act/runner/runner_test.go +++ b/act/runner/runner_test.go @@ -7,6 +7,7 @@ package runner import ( "bytes" "context" + "crypto/rand" "fmt" "os" "path/filepath" @@ -29,6 +30,7 @@ var ( logLevel = log.DebugLevel workdir = "testdata" secrets map[string]string + testRunID = strings.ToLower(rand.Text()[:8]) // keeps docker names apart from concurrent test processes on one daemon ) func mapPlatformPicker(platforms map[string]string) func([]string) string { @@ -216,7 +218,7 @@ func (j *TestJobFileInfo) runTest(ctx context.Context, t *testing.T, cfg *Config EventPath: cfg.EventPath, PlatformPicker: mapPlatformPicker(j.platforms), // fixtures reuse workflow and job names, so parallel tests would collide without this - ContainerNamePrefix: strings.ReplaceAll(t.Name(), "/", "-"), + ContainerNamePrefix: strings.ReplaceAll(t.Name(), "/", "-") + "-" + testRunID, // 0 would run jobs runtime.NumCPU()-wide, making the network peak machine-dependent MaxParallel: 2, ForceRebuild: true, @@ -346,7 +348,7 @@ func TestRunEvent(t *testing.T) { config := &Config{ Secrets: table.secrets, - Env: map[string]string{"GITHUB_REPOSITORY": t.Name()}, + Env: map[string]string{"GITHUB_REPOSITORY": t.Name() + "-" + testRunID}, } eventFile := filepath.Join(workdir, table.workflowPath, "event.json") diff --git a/act/runner/step.go b/act/runner/step.go index e42fd4c2..c459ff86 100644 --- a/act/runner/step.go +++ b/act/runner/step.go @@ -11,7 +11,6 @@ import ( "path" "strconv" "strings" - "sync" "time" "gitea.com/gitea/runner/act/common" @@ -19,7 +18,6 @@ import ( "gitea.dev/actionslib/pkg/exprparser" "gitea.dev/actionslib/pkg/model" - "go.yaml.in/yaml/v4" ) type step interface { @@ -89,6 +87,9 @@ func runStepExecutor(step step, stage stepStage, executor common.Executor) commo if err == nil { runStep, err = isStepEnabled(ctx, ifExpression, step, stage) } + if err == nil && runStep { + err = setupInputs(ctx, step) + } if err != nil { stepResult.Conclusion = model.StepStatusFailure stepResult.Outcome = model.StepStatusFailure @@ -240,51 +241,39 @@ 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_") { - if (*step.getEnv())[k], err = exprEval.Interpolate(ctx, v); err != nil { - return fmt.Errorf("unable to interpolate env %s: %w", k, err) - } + stepEnv := step.getStepModel().Environment() + for k, v := range stepEnv { + if stepEnv[k], err = exprEval.Interpolate(ctx, v); err != nil { + return fmt.Errorf("unable to interpolate env %s: %w", k, err) } } - // 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 - inputEval := sync.OnceValue(func() *expressionEvaluator { return rc.NewExpressionEvaluatorWithEnv(ctx, *step.getEnv()) }) - for k, v := range *step.getEnv() { - if strings.HasPrefix(k, "INPUT_") { - if (*step.getEnv())[k], err = inputEval().Interpolate(ctx, v); err != nil { - return fmt.Errorf("unable to interpolate env %s: %w", k, err) - } - } + // merge step env last, since it should not be overwritten + mergeIntoMap(step, step.getEnv(), stepEnv) + return nil +} + +// setupInputs evaluates `with` once the condition passed, as GitHub does, so every consumer reads the evaluated With. +func setupInputs(ctx context.Context, step step) error { + stepModel := step.getStepModel() + if stepModel.RawWith.Kind == 0 { + return nil } - if step.getStepModel().RawWith.Kind == yaml.ScalarNode { - decoded := &model.Step{} - if err := decodeDeferred(ctx, inputEval(), "with", step.getStepModel().RawWith, &decoded.With); err != nil { - return err - } - step.getStepModel().With = decoded.With - mergeIntoMap(step, step.getEnv(), decoded.GetEnv()) + if err := model.DecodeEvaluated("with", stepModel.RawWith, step.getRunContext().NewStepExpressionEvaluator(ctx, step).shared(ctx).EvaluateYamlNode, &stepModel.With); err != nil { + return err } + mergeIntoMap(step, step.getEnv(), (&model.Step{With: stepModel.With}).GetEnv()) return nil } func mergeEnv(ctx context.Context, step step) { env := step.getEnv() rc := step.getRunContext() - job := rc.Run.Job() - c := job.Container() - if c != nil { - // container env is the image's baseline, which job env and $GITHUB_ENV override - mergeIntoMap(step, env, c.Env, rc.GetEnv()) - } else { - mergeIntoMap(step, env, rc.GetEnv()) - } + // container env is the image's baseline, which job env and $GITHUB_ENV override + mergeIntoMap(step, env, rc.containerSpec.Env, rc.GetEnv()) rc.withGithubEnv(ctx, step.getGithubContext(ctx), *env) } diff --git a/act/runner/step_action_remote.go b/act/runner/step_action_remote.go index 8429fb6c..ebd86aa9 100644 --- a/act/runner/step_action_remote.go +++ b/act/runner/step_action_remote.go @@ -12,7 +12,6 @@ import ( "os" "path" "path/filepath" - "regexp" "strings" "gitea.com/gitea/runner/act/common" @@ -58,13 +57,8 @@ func (sar *stepActionRemote) prepareActionExecutor() common.Executor { 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) { - sar.remoteAction = newSelfRepoAction(sar.Step.Uses, github) - } else { - sar.remoteAction = newRemoteAction(sar.Step.Uses) - } - if sar.remoteAction == nil { - return fmt.Errorf("expected format {org}/{repo}[/path]@ref or %s{path}. Actual '%s' Input string was not in a correct format", selfRepoPrefix, sar.Step.Uses) + if sar.remoteAction, err = newRemoteAction(sar.Step.Uses, github, sar.RunContext.Config.GitHubInstance); err != nil { + return err } if sar.remoteAction.IsCheckout() && isLocalCheckout(github, sar.Step) && !sar.RunContext.Config.NoSkipCheckout { @@ -164,11 +158,7 @@ func (sar *stepActionRemote) main() common.Executor { common.Logger(ctx).Debugf("Skipping local actions/checkout because you bound your workspace") return nil } - 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) + copyToPath := path.Join(sar.RunContext.JobContainer.ToContainerPath(sar.RunContext.Config.Workdir), sar.Step.With["path"]) return sar.RunContext.JobContainer.CopyDir(copyToPath, sar.RunContext.Config.Workdir+string(filepath.Separator)+".", sar.RunContext.Config.UseGitIgnore, false)(ctx) } @@ -324,61 +314,34 @@ func (ra *remoteAction) IsCheckout() bool { return false } -// newSelfRepoAction resolves `$/{path}` against the enclosing composite action, falling back to the workflow's own repo and commit. -func newSelfRepoAction(action string, github *model.GithubContext) *remoteAction { - subPath := strings.TrimLeft(strings.TrimPrefix(action, selfRepoPrefix), "/") - if subPath == "" || strings.Contains(subPath, "@") || path.Clean("/"+subPath) != "/"+subPath { // rooted, so a leading ".." is rejected too - return nil +// newRemoteAction resolves `self:` on instanceURL and `$/` in the enclosing composite action, else the workflow's repo and commit. +func newRemoteAction(action string, github *model.GithubContext, instanceURL string) (*remoteAction, error) { + uses, err := model.ParseActionUses(action) + if err != nil { + return nil, err } - repo, ref := github.ActionRepository, github.ActionRef - if repo == "" || ref == "" { - repo, ref = github.Repository, github.Sha - } - org, name, _ := strings.Cut(repo, "/") - if org == "" || name == "" || ref == "" { - return nil - } - return &remoteAction{URL: github.ServerURL, Org: org, Repo: name, Path: subPath, Ref: ref} -} - -func newRemoteAction(action string) *remoteAction { - // support http(s)://host/owner/repo@v3 - for _, schema := range []string{"https://", "http://", "ssh://"} { - if after, ok := strings.CutPrefix(action, schema); ok { - splits := strings.SplitN(after, "/", 2) - if len(splits) != 2 { - return nil - } - ret := parseAction(splits[1]) - if ret == nil { - return nil - } - ret.URL = schema + splits[0] - return ret + ra := &remoteAction{URL: uses.URL, Org: uses.Owner, Repo: uses.Repo, Path: uses.Path, Ref: uses.Ref} + switch uses.Kind { + case model.ActionUsesInstance: + if instanceURL == "" { + return nil, fmt.Errorf("unable to resolve %q without a Gitea instance", action) + } + ra.URL = instanceURL + if !strings.Contains(instanceURL, "://") { + ra.URL = "https://" + instanceURL + } + case model.ActionUsesSelfRepo: + repo, ref := github.ActionRepository, github.ActionRef + if repo == "" || ref == "" { + repo, ref = github.Repository, github.Sha + } + ra.Org, ra.Repo, _ = strings.Cut(repo, "/") + ra.URL, ra.Ref = github.ServerURL, ref + if ra.Org == "" || ra.Repo == "" || ra.Ref == "" { + return nil, fmt.Errorf("unable to resolve %q without a repository and commit", action) } } - - return parseAction(action) -} - -func parseAction(action string) *remoteAction { - // GitHub's document[^] describes: - // > We strongly recommend that you include the version of - // > the action you are using by specifying a Git ref, SHA, or Docker tag number. - // Actually, the workflow stops if there is the uses directive that hasn't @ref. - // [^]: https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions - r := regexp.MustCompile(`^([^/@]+)/([^/@]+)(/([^@]*))?(@(.*))?$`) - matches := r.FindStringSubmatch(action) - if len(matches) < 7 || matches[6] == "" { - return nil - } - return &remoteAction{ - Org: matches[1], - Repo: matches[2], - Path: matches[4], - Ref: matches[6], - URL: "", - } + return ra, nil } func safeFilename(s string) string { diff --git a/act/runner/step_action_remote_test.go b/act/runner/step_action_remote_test.go index bc8f2891..30b4ccc1 100644 --- a/act/runner/step_action_remote_test.go +++ b/act/runner/step_action_remote_test.go @@ -216,7 +216,7 @@ func TestStepActionRemote(t *testing.T) { sarm.On("readAction", sar.Step, actionDirSuffix(sar.Step.UsesHash()), "", mock.Anything, mock.Anything).Return(&model.Action{}, nil) } if tt.mocks.run { - sarm.On("runAction", sar, actionDirSuffix(sar.Step.UsesHash()), newRemoteAction(sar.Step.Uses)).Return(func(ctx context.Context) error { return tt.runError }) + sarm.On("runAction", sar, actionDirSuffix(sar.Step.UsesHash()), mustNewRemoteAction(t, sar.Step.Uses)).Return(func(ctx context.Context) error { return tt.runError }) cm.On("Copy", "/var/run/act", mock.AnythingOfType("[]*container.FileEntry")).Return(noopExecutor) @@ -249,7 +249,7 @@ func TestStepActionRemote(t *testing.T) { RunContext: &RunContext{Config: &Config{ActionCacheDir: t.TempDir()}, Run: &model.Run{JobID: "job", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"job": {}}}}, JobContainer: &jobContainerMock{}}, action: &model.Action{Inputs: map[string]model.Input{"shared": {}}}, env: map[string]string{}, - remoteAction: newRemoteAction("org/composite@v1"), + remoteAction: mustNewRemoteAction(t, "org/composite@v1"), } for _, value := range []string{"first", "second"} { @@ -464,7 +464,7 @@ func TestStepActionRemotePost(t *testing.T) { Step: tt.stepModel, action: tt.actionModel, // post only ever runs after prepareActionExecutor resolved the action - remoteAction: newRemoteAction(tt.stepModel.Uses), + remoteAction: mustNewRemoteAction(t, tt.stepModel.Uses), } sar.RunContext.ExprEval = sar.RunContext.NewExpressionEvaluator(ctx) @@ -611,10 +611,21 @@ func Test_newRemoteAction(t *testing.T) { action: "ssh://gitea.com/onlyonesegment@main", // missing org/repo after the host want: nil, }, + { + action: "self:owner/repo/sub@v1", + want: &remoteAction{ + URL: "https://gitea.example.com", + Org: "owner", + Repo: "repo", + Path: "sub", + Ref: "v1", + }, + wantCloneURL: "https://gitea.example.com/owner/repo", + }, } for _, tt := range tests { t.Run(tt.action, func(t *testing.T) { - got := newRemoteAction(tt.action) + got, _ := newRemoteAction(tt.action, nil, "gitea.example.com") assert.Equalf(t, tt.want, got, "newRemoteAction(%v)", tt.action) cloneURL := "" if got != nil { @@ -623,9 +634,18 @@ func Test_newRemoteAction(t *testing.T) { assert.Equalf(t, tt.wantCloneURL, cloneURL, "newRemoteAction(%v).CloneURL()", tt.action) }) } + _, err := newRemoteAction("self:owner/repo@v1", nil, "") + require.ErrorContains(t, err, "without a Gitea instance") } -func Test_newSelfRepoAction(t *testing.T) { +func mustNewRemoteAction(t *testing.T, uses string) *remoteAction { + t.Helper() + action, err := newRemoteAction(uses, nil, "") + require.NoError(t, err) + return action +} + +func Test_newRemoteActionSelfRepo(t *testing.T) { workflow := &model.GithubContext{ ServerURL: "https://gitea.example.com", Repository: "owner/workflow-repo", @@ -674,7 +694,8 @@ func Test_newSelfRepoAction(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - assert.Equal(t, tt.want, newSelfRepoAction(tt.action, tt.github)) + got, _ := newRemoteAction(tt.action, tt.github, "") + assert.Equal(t, tt.want, got) }) } } @@ -704,7 +725,7 @@ func Test_remoteActionReference(t *testing.T) { } for _, tt := range tests { t.Run(tt.uses, func(t *testing.T) { - assert.Equal(t, tt.want, newRemoteAction(tt.uses).Reference()) + assert.Equal(t, tt.want, mustNewRemoteAction(t, tt.uses).Reference()) }) } } @@ -761,7 +782,7 @@ func TestStepActionRemotePreResolvesDownloadedCommit(t *testing.T) { func TestStepActionRemoteActionDownloadInfo(t *testing.T) { t.Run("reports the action and its resolved commit", func(t *testing.T) { sar := &stepActionRemote{ - remoteAction: newRemoteAction("actions/checkout@v7"), + remoteAction: mustNewRemoteAction(t, "actions/checkout@v7"), action: &model.Action{}, resolvedSha: "9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0", } @@ -775,7 +796,7 @@ func TestStepActionRemoteActionDownloadInfo(t *testing.T) { t.Run("reports nothing when no action was downloaded", func(t *testing.T) { // The local checkout of the workflow's own repository resolves no action. - sar := &stepActionRemote{remoteAction: newRemoteAction("actions/checkout@v7")} + sar := &stepActionRemote{remoteAction: mustNewRemoteAction(t, "actions/checkout@v7")} _, _, ok := sar.actionDownloadInfo() diff --git a/act/runner/step_docker.go b/act/runner/step_docker.go index 1923a6a6..2a764696 100644 --- a/act/runner/step_docker.go +++ b/act/runner/step_docker.go @@ -6,7 +6,6 @@ package runner import ( "context" - "fmt" "strings" "gitea.com/gitea/runner/act/common" @@ -58,29 +57,17 @@ func (sd *stepDocker) runUsesContainer() common.Executor { return func(ctx context.Context) error { image := strings.TrimPrefix(step.Uses, "docker://") - eval := rc.NewExpressionEvaluator(ctx) - 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) + cmd, err := shellquote.Split(step.With["args"]) if err != nil { return err } var entrypoint []string - entry, err := eval.Interpolate(ctx, step.With["entrypoint"]) - if err != nil { - return fmt.Errorf("unable to interpolate with.entrypoint: %w", err) - } - if entry != "" { + if entry := step.With["entrypoint"]; entry != "" { entrypoint = []string{entry} } - stepContainer, err := newStepContainer(ctx, sd, image, cmd, entrypoint, "") - if err != nil { - return err - } + stepContainer := newStepContainer(ctx, sd, image, cmd, entrypoint, "") 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 0f90c493..b3133f00 100644 --- a/act/runner/step_docker_test.go +++ b/act/runner/step_docker_test.go @@ -145,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) }) } @@ -214,7 +214,7 @@ func TestStepDockerNewStepContainerNetworkMode(t *testing.T) { 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_test.go b/act/runner/step_test.go index 1d9c4a27..8c0b4dc0 100644 --- a/act/runner/step_test.go +++ b/act/runner/step_test.go @@ -149,11 +149,8 @@ func TestSetupEnv(t *testing.T) { }, JobContainer: cm, } - step := &model.Step{ - With: map[string]string{ - "STEP_WITH": "with-value", - }, - } + step := &model.Step{} + require.NoError(t, step.RawWith.Encode(map[string]string{"STEP_WITH": "with-value", "ID": "${{ fromJSON('1234567') }}"})) env := map[string]string{} sm.On("getRunContext").Return(rc) @@ -162,6 +159,7 @@ func TestSetupEnv(t *testing.T) { sm.On("getEnv").Return(&env) require.NoError(t, setupEnv(context.Background(), sm)) + require.NoError(t, setupInputs(context.Background(), sm)) // These are commit or system specific delete(env, "GITHUB_REF") @@ -198,6 +196,7 @@ func TestSetupEnv(t *testing.T) { "GITHUB_RUN_NUMBER": "1", "GITHUB_SERVER_URL": "https://", "GITHUB_WORKFLOW": "", + "INPUT_ID": "1234567", "INPUT_STEP_WITH": "with-value", "RC_KEY": "rcvalue", "RUNNER_ENVIRONMENT": "self-hosted", @@ -214,7 +213,13 @@ env: ${{ fromJSON(matrix.env) }} jobs: test: env: - PRIORITY: job + PRIORITY: ${{ matrix.priority }} + defaults: + run: ${{ fromJSON(matrix.defaults) }} + container: + image: node:20 + env: + CONTAINER: ${{ env.PRIORITY }} steps: - uses: ./act env: @@ -228,16 +233,22 @@ jobs: for _, value := range []string{"first", "second"} { t.Run(value, func(t *testing.T) { t.Parallel() - rc, err := (&runnerImpl{config: &Config{}}).newRunContext(t.Context(), &model.Run{Workflow: workflow, JobID: "test"}, map[string]any{"env": `{"WORKFLOW":"` + value + `","PRIORITY":"workflow"}`}) + rc, err := (&runnerImpl{config: &Config{}}).newRunContext(t.Context(), &model.Run{Workflow: workflow, JobID: "test"}, map[string]any{"env": `{"WORKFLOW":"` + value + `${{ github.job }}","PRIORITY":"workflow"}`, "priority": "${{ github.job }}", "defaults": `{"shell":"` + value + `"}`}) require.NoError(t, err) - rc.workflowCallInputs = map[string]any{"args": `{"name":"` + value + `","fetch-depth":2}`} + rc.workflowCallInputs = map[string]any{"args": `{"name":"` + value + `${{ github.job }}","fetch-depth":1234567}`} + enabled, err := rc.isEnabled(t.Context()) + require.NoError(t, err) + require.True(t, enabled) require.NoError(t, evaluateJobEnvAndDefaults(t.Context(), rc)) + assert.Equal(t, model.RunDefaults{Shell: value}, rc.jobRunDefaults) step := &stepRun{RunContext: rc, Step: job.Steps[0].Clone(), env: map[string]string{}} require.NoError(t, setupEnv(t.Context(), step)) - assert.Equal(t, map[string]string{"ARGS": `${{ inputs.args }}`, "INPUT_NAME": value, "INPUT_FETCH-DEPTH": "2"}, step.Step.GetEnv()) - assert.Equal(t, value, step.env["INPUT_NAME"]) - assert.Equal(t, value, step.env["WORKFLOW"]) - assert.Equal(t, "job", step.env["PRIORITY"]) + require.NoError(t, setupInputs(t.Context(), step)) + assert.Equal(t, map[string]string{"ARGS": `${{ inputs.args }}`, "INPUT_NAME": value + "${{ github.job }}", "INPUT_FETCH-DEPTH": "1234567"}, step.Step.GetEnv()) + assert.Equal(t, value+"${{ github.job }}", step.env["INPUT_NAME"]) + assert.Equal(t, value+"${{ github.job }}", step.env["WORKFLOW"]) + assert.Equal(t, "${{ github.job }}", step.env["PRIORITY"]) + assert.Equal(t, "${{ github.job }}", step.env["CONTAINER"]) assert.Equal(t, rawEnv, workflow.RawEnv) assert.Equal(t, rawWith, job.Steps[0].RawWith) assert.Nil(t, workflow.Env) @@ -253,7 +264,7 @@ jobs: rc, err := (&runnerImpl{config: &Config{}}).newRunContext(t.Context(), &model.Run{Workflow: workflow, JobID: "test"}, nil) require.NoError(t, err) rc.workflowCallInputs = map[string]any{"args": "${{ inputs.unresolved }}"} - require.ErrorContains(t, setupEnv(t.Context(), &stepRun{RunContext: rc, Step: workflow.Jobs["test"].Steps[0].Clone(), env: map[string]string{}}), "with:") + require.ErrorContains(t, setupInputs(t.Context(), &stepRun{RunContext: rc, Step: workflow.Jobs["test"].Steps[0].Clone(), env: map[string]string{}}), "with:") }) } diff --git a/go.mod b/go.mod index 322c8a6f..b17ebe8d 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,7 @@ toolchain go1.27.1 require ( connectrpc.com/connect v1.21.0 dario.cat/mergo v1.0.2 - gitea.dev/actionslib v1.0.0 + gitea.dev/actionslib v1.2.0 github.com/avast/retry-go/v5 v5.0.0 github.com/containerd/errdefs v1.0.0 github.com/creack/pty v1.1.24 diff --git a/go.sum b/go.sum index 618d0abe..7dd7a955 100644 --- a/go.sum +++ b/go.sum @@ -4,8 +4,8 @@ cyphar.com/go-pathrs v0.2.5 h1:SnX9FBvnoyn3lUs1dkMgZ52bAETpirNu3FTRh5HlRik= cyphar.com/go-pathrs v0.2.5/go.mod h1:y8f1EMG7r+hCuFf/rXsKqMJrJAUoADZGNh5/vZPKcGc= dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= -gitea.dev/actionslib v1.0.0 h1:l0oFJP+P4Ds1rlCI5zk618dYkuBc2mU7Gz5wPeG0lZY= -gitea.dev/actionslib v1.0.0/go.mod h1:6O8YHkqVTKSR0LL2e5VhIDePYzGTZCbfmSVqJWEhk9g= +gitea.dev/actionslib v1.2.0 h1:R/PBu9DwD04tCd5rjAWT9dAa+S6GOk1XnXCC3T2Rrnw= +gitea.dev/actionslib v1.2.0/go.mod h1:1+gqOKGSEPn2IFHgY8S3GC5Ld+Hn8jF3OxiFHQ/fpx4= github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= diff --git a/internal/app/run/runner.go b/internal/app/run/runner.go index 7400d202..6f1b35bd 100644 --- a/internal/app/run/runner.go +++ b/internal/app/run/runner.go @@ -41,6 +41,7 @@ import ( runnerv1 "gitea.dev/actionslib/runner/v1" docker_container "github.com/moby/moby/api/types/container" log "github.com/sirupsen/logrus" + "go.yaml.in/yaml/v4" ) // CapabilityCancelling tells the server this runner understands the @@ -414,7 +415,9 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report. // Added per task because this job's service containers must be reached directly, and // act reaches them by their workflow key. - proxyEnv := JobProxyEnv(envs, r.builtInCacheURL(), slices.Sorted(maps.Keys(job.Services))) + var services map[string]yaml.Node + _ = job.RawServices.Decode(&services) // a whole-value expression names no service before evaluation + proxyEnv := JobProxyEnv(envs, r.builtInCacheURL(), slices.Sorted(maps.Keys(services))) maps.Copy(envs, proxyEnv) if r.capabilities != "" { @@ -428,24 +431,7 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report. r.getDefaultActionsURL(task), r.client.Address()) - preset := &model.GithubContext{ - Event: taskContext["event"].GetStructValue().AsMap(), - RunID: taskContext["run_id"].GetStringValue(), - RunNumber: taskContext["run_number"].GetStringValue(), - RunAttempt: taskContext["run_attempt"].GetStringValue(), - Actor: taskContext["actor"].GetStringValue(), - Repository: taskContext["repository"].GetStringValue(), - EventName: taskContext["event_name"].GetStringValue(), - Sha: taskContext["sha"].GetStringValue(), - Ref: taskContext["ref"].GetStringValue(), - RefName: taskContext["ref_name"].GetStringValue(), - RefType: taskContext["ref_type"].GetStringValue(), - HeadRef: taskContext["head_ref"].GetStringValue(), - BaseRef: taskContext["base_ref"].GetStringValue(), - Token: taskContext["token"].GetStringValue(), - RepositoryOwner: taskContext["repository_owner"].GetStringValue(), - RetentionDays: taskContext["retention_days"].GetStringValue(), - } + preset := model.GithubContextFromMap(task.Context.AsMap()) if t := task.Secrets["GITEA_TOKEN"]; t != "" { preset.Token = t } else if t := task.Secrets["GITHUB_TOKEN"]; t != "" {