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 <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1233
Reviewed-by: bircni <[email protected]>
Co-authored-by: silverwind <[email protected]>
This commit is contained in:
silverwindandbircni authored and bircni committed 2026-09-23 15:40:19 +00:00
1 parent ae9ce10f71
commit e0d00e543d
24 files changed
+454 -721

No files matched your search

+14 -36
View File
@@ -13,6 +13,7 @@ import (
"fmt" "fmt"
"io" "io"
"io/fs" "io/fs"
"maps"
"os" "os"
"path" "path"
"path/filepath" "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) logger.Debugf("image '%s' for architecture '%s' already exists", image, rc.Config.ContainerArchitecture)
} }
} }
eval := rc.NewActionInputsExpressionEvaluator(ctx, step) cmd, err := shellquote.Split(step.getStepModel().With["args"])
args, err := eval.Interpolate(ctx, step.getStepModel().With["args"])
if err != nil {
return fmt.Errorf("unable to interpolate with.args: %w", err)
}
cmd, err := shellquote.Split(args)
if err != nil { if err != nil {
return err return err
} }
@@ -375,14 +371,11 @@ func execAsDocker(ctx context.Context, step actionStep, actionName, actionDir, b
} }
} }
} }
entrypoint, err := dockerEntrypoint(ctx, step, eval, stage) entrypoint, err := dockerEntrypoint(step, stage)
if err != nil {
return err
}
stepContainer, err := newStepContainer(ctx, step, image, cmd, entrypoint, rc.Config.ContainerOptions)
if err != nil { if err != nil {
return err return err
} }
stepContainer := newStepContainer(ctx, step, image, cmd, entrypoint, rc.Config.ContainerOptions)
return common.NewPipelineExecutor( return common.NewPipelineExecutor(
prepImage, prepImage,
stepContainer.Pull(forcePull), 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 // dockerEntrypoint returns the entrypoint the action's image runs with for the given
// stage. Only the main stage honours the `entrypoint` input. // 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 runs := step.getActionModel().Runs
var entrypoint string var entrypoint string
@@ -406,11 +399,7 @@ func dockerEntrypoint(ctx context.Context, step actionStep, eval *expressionEval
default: default:
entrypoint = runs.Entrypoint entrypoint = runs.Entrypoint
if entrypoint == "" { if entrypoint == "" {
withEntrypoint, err := eval.Interpolate(ctx, step.getStepModel().With["entrypoint"]) if fields := strings.Fields(step.getStepModel().With["entrypoint"]); len(fields) > 0 {
if err != nil {
return nil, fmt.Errorf("unable to interpolate with.entrypoint: %w", err)
}
if fields := strings.Fields(withEntrypoint); len(fields) > 0 {
return fields, nil return fields, nil
} }
} }
@@ -425,7 +414,6 @@ func dockerEntrypoint(ctx context.Context, step actionStep, eval *expressionEval
// evalDockerEnv returns an evaluator bound to the environment it installed. // evalDockerEnv returns an evaluator bound to the environment it installed.
func evalDockerEnv(ctx context.Context, step step, action *model.Action) (*expressionEvaluator, error) { func evalDockerEnv(ctx context.Context, step step, action *model.Action) (*expressionEvaluator, error) {
rc := step.getRunContext() rc := step.getRunContext()
stepModel := step.getStepModel()
var err error var err error
inputs := make(map[string]string) 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) return nil, fmt.Errorf("unable to interpolate the default of input %s: %w", k, err)
} }
} }
for k, v := range stepModel.With { mergeIntoMap(step, step.getEnv(), inputs, step.getStepModel().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
ee := rc.NewActionInputsExpressionEvaluator(ctx, step) ee := rc.NewActionInputsExpressionEvaluator(ctx, step)
for k, v := range *step.getEnv() { runsEnv := make(map[string]string, len(action.Runs.Env))
if (*step.getEnv())[k], err = ee.Interpolate(ctx, v); err != nil { 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) return nil, fmt.Errorf("unable to interpolate env %s: %w", k, err)
} }
} }
mergeIntoMap(step, step.getEnv(), runsEnv, maps.Clone(*step.getEnv()))
return ee, nil 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() rc := step.getRunContext()
logWriter := rc.commandLogWriter(ctx) logWriter := rc.commandLogWriter(ctx)
envList := make([]string, 0) 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)...) envList = append(envList, rc.runnerEnv(ctx)...)
binds, mounts, err := rc.GetBindsAndMounts() binds, mounts := rc.GetBindsAndMounts()
if err != nil {
return nil, err
}
networkMode := "container:" + rc.jobContainerName() networkMode := "container:" + rc.jobContainerName()
if rc.IsHostEnv() { if rc.IsHostEnv() {
networkMode = "default" networkMode = "default"
@@ -493,7 +471,7 @@ func newStepContainer(ctx context.Context, step step, image string, cmd, entrypo
AutoRemove: true, AutoRemove: true,
ValidVolumes: rc.validVolumes(), ValidVolumes: rc.validVolumes(),
AllocatePTY: rc.Config.AllocatePTY, AllocatePTY: rc.Config.AllocatePTY,
}), nil })
} }
func populateEnvsFromSavedState(env *map[string]string, step actionStep, rc *RunContext) { func populateEnvsFromSavedState(env *map[string]string, step actionStep, rc *RunContext) {
+2
View File
@@ -104,6 +104,8 @@ func newCompositeRunContext(ctx context.Context, parent *RunContext, step action
Parent: parent, Parent: parent,
EventJSON: parent.EventJSON, EventJSON: parent.EventJSON,
platformImage: parent.platformImage, platformImage: parent.platformImage,
jobIndex: parent.jobIndex,
jobTotal: parent.jobTotal,
} }
compositerc.setCompositeActionEnv(env) compositerc.setCompositeActionEnv(env)
compositerc.ExprEval = compositerc.NewExpressionEvaluator(ctx) compositerc.ExprEval = compositerc.NewExpressionEvaluator(ctx)
+6 -3
View File
@@ -12,18 +12,21 @@ import (
"gitea.dev/actionslib/pkg/model" "gitea.dev/actionslib/pkg/model"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"go.yaml.in/yaml/v4"
) )
func TestCompositeActionParity(t *testing.T) { func TestCompositeActionParity(t *testing.T) {
t.Run("inherits contexts without leaking inputs", func(t *testing.T) { t.Run("inherits contexts without leaking inputs", func(t *testing.T) {
ctx := t.Context() ctx := t.Context()
strategy := &model.Strategy{MaxParallelString: "3"} strategy := &model.Strategy{MaxParallelString: "3", RawMatrix: yaml.Node{Kind: yaml.MappingNode}}
parent := &RunContext{ parent := &RunContext{
Config: &Config{}, Config: &Config{},
Matrix: map[string]any{"os": "linux"}, Matrix: map[string]any{"os": "linux"},
Run: &model.Run{JobID: "job", Workflow: &model.Workflow{Name: "workflow", Jobs: map[string]*model.Job{"job": {Strategy: strategy}}}}, Run: &model.Run{JobID: "job", Workflow: &model.Workflow{Name: "workflow", Jobs: map[string]*model.Job{"job": {Strategy: strategy}}}},
JobContainer: &jobContainerMock{}, JobContainer: &jobContainerMock{},
platformImage: "-self-hosted", platformImage: "-self-hosted",
jobIndex: 1,
jobTotal: 4,
} }
composite, err := newCompositeRunContext(ctx, parent, &stepActionRemote{ composite, err := newCompositeRunContext(ctx, parent, &stepActionRemote{
Step: &model.Step{With: map[string]string{"SHARED": "outer"}}, Step: &model.Step{With: map[string]string{"SHARED": "outer"}},
@@ -36,9 +39,9 @@ func TestCompositeActionParity(t *testing.T) {
assert.Same(t, strategy, composite.Run.Job().Strategy) assert.Same(t, strategy, composite.Run.Job().Strategy)
assert.True(t, composite.IsHostEnv()) assert.True(t, composite.IsHostEnv())
interpolated, err := composite.NewExpressionEvaluator(ctx).Interpolate(ctx, 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) 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") assert.NotContains(t, composite.Env, "INPUT_SHARED")
nestedEnv := composite.GetEnv() nestedEnv := composite.GetEnv()
+3 -10
View File
@@ -242,7 +242,7 @@ func TestActionRunner(t *testing.T) {
tt.step.getRunContext().JobContainer = cm 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 assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
cm.AssertExpectations(t) cm.AssertExpectations(t)
@@ -316,7 +316,7 @@ func TestNewStepContainerDoesNotUseDockerSecrets(t *testing.T) {
step.On("getStepModel").Return(&model.Step{ID: "action"}) step.On("getStepModel").Return(&model.Step{ID: "action"})
step.On("getEnv").Return(&env) step.On("getEnv").Return(&env)
_, _ = newStepContainer(ctx, step, "registry.example.com/action:tag", nil, nil, "") newStepContainer(ctx, step, "registry.example.com/action:tag", nil, nil, "")
// DOCKER_USERNAME/DOCKER_PASSWORD should not be injected as pull credentials for docker action containers. // DOCKER_USERNAME/DOCKER_PASSWORD should not be injected as pull credentials for docker action containers.
assert.Empty(t, captured.Username) assert.Empty(t, captured.Username)
@@ -461,18 +461,11 @@ func TestExecAsDockerHoldsCloneLockForRemoteUncached(t *testing.T) {
case <-innerEntered: case <-innerEntered:
case err := <-done: case err := <-done:
t.Fatalf("execAsDocker returned without entering inner: %v", err) 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() cancel()
releaseOnce() releaseOnce()
<-done
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("execAsDocker did not return after inner was released and ctx was canceled")
}
} }
func TestDockerActionImageTag(t *testing.T) { func TestDockerActionImageTag(t *testing.T) {
+38 -116
View File
@@ -13,7 +13,6 @@ import (
"reflect" "reflect"
"regexp" "regexp"
"strings" "strings"
"sync"
"time" "time"
"gitea.com/gitea/runner/act/common" "gitea.com/gitea/runner/act/common"
@@ -27,29 +26,6 @@ import (
"go.yaml.in/yaml/v4" "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 // NewExpressionEvaluator creates a new evaluator
func (rc *RunContext) NewExpressionEvaluator(ctx context.Context) *ExpressionEvaluator { func (rc *RunContext) NewExpressionEvaluator(ctx context.Context) *ExpressionEvaluator {
return rc.NewExpressionEvaluatorWithEnv(ctx, rc.GetEnv()) 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 var workflowCallResult map[string]*model.WorkflowCallResult
// todo: cleanup EvaluationEnvironment creation // todo: cleanup EvaluationEnvironment creation
using := make(map[string]exprparser.Needs)
strategy := make(map[string]any) strategy := make(map[string]any)
if rc.Run != nil { if rc.Run != nil {
job := rc.Run.Job() strategy = exprparser.StrategyContext(rc.Run.Job().Strategy, rc.jobIndex, rc.jobTotal)
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(),
}
}
// only setup jobs context in case of workflow_call // only setup jobs context in case of workflow_call
// and existing expression evaluator (this means, jobs are at // 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 { if rc.caller != nil && rc.ExprEval != nil {
workflowCallResult = map[string]*model.WorkflowCallResult{} workflowCallResult = map[string]*model.WorkflowCallResult{}
for jobName, job := range jobs { for jobName, job := range rc.Run.Workflow.Jobs {
result := model.WorkflowCallResult{ result := model.WorkflowCallResult{
Outputs: map[string]string{}, Outputs: map[string]string{},
} }
@@ -108,7 +70,7 @@ func (rc *RunContext) NewExpressionEvaluatorWithEnv(ctx context.Context, env map
Vars: getWorkflowVars(ctx, rc), Vars: getWorkflowVars(ctx, rc),
Strategy: strategy, Strategy: strategy,
Matrix: rc.Matrix, Matrix: rc.Matrix,
Needs: using, Needs: exprparser.NeedsContext(rc.Run),
Inputs: inputs, Inputs: inputs,
HashFiles: getHashFilesFunction(ctx, rc), 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 { func (rc *RunContext) newStepExpressionEvaluator(ctx context.Context, step step, stepInputs map[string]any, jobContext *model.JobContext) *ExpressionEvaluator {
// todo: cleanup EvaluationEnvironment creation // 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{ ee := &exprparser.EvaluationEnvironment{
Github: step.getGithubContext(ctx), Github: step.getGithubContext(ctx),
Env: *step.getEnv(), Env: *step.getEnv(),
@@ -161,9 +106,9 @@ func (rc *RunContext) newStepExpressionEvaluator(ctx context.Context, step step,
Steps: rc.getStepsContext(), Steps: rc.getStepsContext(),
Secrets: getWorkflowSecrets(rc), Secrets: getWorkflowSecrets(rc),
Vars: getWorkflowVars(ctx, rc), Vars: getWorkflowVars(ctx, rc),
Strategy: strategy, Strategy: exprparser.StrategyContext(rc.Run.Job().Strategy, rc.jobIndex, rc.jobTotal),
Matrix: rc.Matrix, Matrix: rc.Matrix,
Needs: using, Needs: exprparser.NeedsContext(rc.Run),
// todo: should be unavailable // todo: should be unavailable
// but required to interpolate/evaluate the inputs in actions/composite // but required to interpolate/evaluate the inputs in actions/composite
Inputs: getEvaluatorInputs(rc, stepInputs, rc.getGithubContext(ctx)), 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, rc.workflowCallInputs)
maps.Copy(inputs, stepInputs) maps.Copy(inputs, stepInputs)
if ghc.EventName == "workflow_dispatch" { switch ghc.EventName {
config := rc.Run.Workflow.WorkflowDispatchConfig() case "workflow_dispatch":
if config != nil && config.Inputs != nil { if config := rc.Run.Workflow.WorkflowDispatchConfig(); config != nil {
for k, v := range config.Inputs { for name, input := range config.Inputs {
value := nestedMapLookup(ghc.Event, "inputs", k) if input.Type == "number" {
if value == nil { input.Type = "string" // boolean is the only typed dispatch input, as in Gitea
value = v.Default
} }
inputs[k] = coerceInputValue(value, v.Type) setEvaluatorInput(inputs, ghc, name, model.WorkflowCallInput{Type: input.Type, Default: input.Default})
} }
} }
} case "workflow_call":
for name, input := range rc.Run.Workflow.WorkflowCallConfig().Inputs {
if ghc.EventName == "workflow_call" { setEvaluatorInput(inputs, ghc, name, input)
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)
}
} }
} }
return inputs return inputs
} }
// coerceInputValue converts an input value to the type declared by the workflow. func setEvaluatorInput(inputs map[string]any, ghc *model.GithubContext, name string, input model.WorkflowCallInput) {
// The event payload carries natively typed JSON values on newer Gitea versions, value := model.NestedMapLookup(ghc.Event, "inputs", name)
// while defaults and older servers provide strings. if value == nil {
func coerceInputValue(value any, inputType string) any { value, _ = input.DefaultValue()
if inputType != "boolean" {
return value
} }
if b, ok := value.(bool); ok { inputs[name] = model.CoerceInputValue(value, input.Type)
return b
}
return value == "true"
} }
// resolveWorkflowCall evaluates the caller's with: and secrets: once, as the server does before dispatching a called workflow. // 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 { if rc.caller == nil {
return nil return nil
} }
logger := common.Logger(ctx)
callerJob := rc.caller.runContext.Run.Job() callerJob := rc.caller.runContext.Run.Job()
callerEval := rc.caller.runContext.ExprEval callerEval := rc.caller.runContext.ExprEval.shared(ctx)
callerWith := callerJob.With var callerWith map[string]any
if err := decodeDeferred(ctx, callerEval, "workflow inputs", callerJob.RawWith, &callerWith); err != nil { if err := model.DecodeEvaluated("workflow inputs", callerJob.RawWith, callerEval.EvaluateYamlNode, &callerWith); err != nil {
return err 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() config := rc.Run.Workflow.WorkflowCallConfig()
var err error
rc.workflowCallInputs = make(map[string]any, len(config.Inputs)) if rc.workflowCallInputs, err = callerEval.ResolveWorkflowCallInputs(config, callerWith); err != nil {
for name, input := range config.Inputs { logger.Warn(err)
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)
} }
if callerJob.InheritSecrets() {
rc.workflowCallSecrets = getWorkflowSecrets(rc.caller.runContext)
return nil
}
secrets := callerJob.Secrets() secrets := callerJob.Secrets()
if secrets == nil && callerJob.InheritSecrets() { if err := config.ValidateSecrets(secrets); err != nil {
secrets = rc.caller.runContext.Config.Secrets logger.Warn(err)
} }
rc.workflowCallSecrets = make(map[string]string, len(secrets)) if rc.workflowCallSecrets, err = callerEval.EvaluateCallerSecrets(secrets); err != nil {
for k, v := range secrets { return fmt.Errorf("unable to interpolate %w", err)
var err error
if rc.workflowCallSecrets[k], err = callerEval.Interpolate(ctx, v); err != nil {
return fmt.Errorf("unable to interpolate secret %s: %w", k, err)
}
} }
return nil return nil
} }
+47 -2
View File
@@ -294,6 +294,8 @@ on:
name: name:
type: string type: string
default: gitea default: gitea
unset:
type: boolean
`, `,
"workflow_dispatch": ` "workflow_dispatch": `
on: on:
@@ -305,6 +307,8 @@ on:
name: name:
type: string type: string
default: gitea default: gitea
unset:
type: boolean
`, `,
} }
@@ -356,6 +360,7 @@ on:
inputs := getEvaluatorInputs(rc, nil, ghc) inputs := getEvaluatorInputs(rc, nil, ghc)
assert.Equal(t, table.flag, inputs["flag"]) assert.Equal(t, table.flag, inputs["flag"])
assert.Equal(t, "gitea", inputs["name"]) assert.Equal(t, "gitea", inputs["name"])
assert.Equal(t, false, inputs["unset"])
}) })
} }
} }
@@ -375,11 +380,11 @@ jobs:
for _, value := range []string{"true", "false"} { for _, value := range []string{"true", "false"} {
t.Run(value, func(t *testing.T) { t.Run(value, func(t *testing.T) {
t.Parallel() 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) require.NoError(t, err)
child, err := (&runnerImpl{config: &Config{}, caller: &caller{runContext: parent}}).newRunContext(t.Context(), &model.Run{Workflow: workflow, JobID: "test"}, nil) child, err := (&runnerImpl{config: &Config{}, caller: &caller{runContext: parent}}).newRunContext(t.Context(), &model.Run{Workflow: workflow, JobID: "test"}, nil)
require.NoError(t, err) 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.Equal(t, rawWith, job.RawWith)
assert.Nil(t, job.With) 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) { func TestJobNameMasksSecrets(t *testing.T) {
workflow, err := model.ReadWorkflow(strings.NewReader(` workflow, err := model.ReadWorkflow(strings.NewReader(`
jobs: jobs:
+20 -25
View File
@@ -18,7 +18,6 @@ import (
"slices" "slices"
"strconv" "strconv"
"strings" "strings"
"sync"
"time" "time"
"unicode" "unicode"
@@ -414,49 +413,45 @@ func setJobResult(ctx context.Context, info jobInfo, rc *RunContext, success boo
logger.WithField("jobResult", jobResult).Infof("Job %s", jobResultMessage) logger.WithField("jobResult", jobResult).Infof("Job %s", jobResultMessage)
} }
// 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 { func evaluateJobEnvAndDefaults(ctx context.Context, rc *RunContext) error {
rc.ExprEval = rc.NewExpressionEvaluator(ctx) rc.ExprEval = rc.NewExpressionEvaluator(ctx)
env := rc.GetEnv()
var workflowEnv map[string]string var workflowEnv map[string]string
if err := decodeDeferred(ctx, rc.ExprEval, "workflow env", rc.Run.Workflow.RawEnv, &workflowEnv); err != nil { if rc.Run.Workflow.Env == nil {
return err if err := model.DecodeEvaluated("workflow env", rc.Run.Workflow.RawEnv, rc.ExprEval.shared(ctx).EvaluateYamlNode, &workflowEnv); err != nil {
return err
}
} }
if workflowEnv != nil { if workflowEnv != nil {
rc.Env = mergeMaps(workflowEnv, rc.GetEnv()) rc.Env = mergeMaps(workflowEnv, env)
rc.ExprEval = rc.NewExpressionEvaluator(ctx) rc.ExprEval = rc.NewExpressionEvaluator(ctx)
} }
var err error 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 { if rc.Env[k], err = rc.ExprEval.Interpolate(ctx, v); err != nil {
return fmt.Errorf("unable to interpolate env %s: %w", k, err) return fmt.Errorf("unable to interpolate env %s: %w", k, err)
} }
} }
defaults := rc.Run.Job().Defaults.Run var defaults model.Defaults
if rc.jobRunDefaults.Shell, err = rc.ExprEval.Interpolate(ctx, defaults.Shell); err != nil { if err := model.DecodeEvaluated("defaults", rc.Run.Job().RawDefaults, rc.ExprEval.shared(ctx).EvaluateYamlNode, &defaults); err != nil {
return fmt.Errorf("unable to interpolate defaults.run.shell: %w", err) return err
} }
if rc.jobRunDefaults.WorkingDirectory, err = rc.ExprEval.Interpolate(ctx, defaults.WorkingDirectory); err != nil { rc.jobRunDefaults = defaults.Run
return fmt.Errorf("unable to interpolate defaults.run.working-directory: %w", err) 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 { func setJobOutputs(ctx context.Context, rc *RunContext) error {
if rc.caller == nil { if rc.caller == nil {
return nil return nil
} }
outputs := rc.Run.Workflow.WorkflowCallConfig().Outputs callerOutputs, err := rc.NewExpressionEvaluator(ctx).shared(ctx).EvaluateWorkflowCallOutputs(rc.Run.Workflow.WorkflowCallConfig())
callerOutputs := make(map[string]string, len(outputs)) if err != nil {
ee := sync.OnceValue(func() *expressionEvaluator { return rc.NewExpressionEvaluator(ctx) }) return fmt.Errorf("unable to interpolate workflow %w", err)
for k, v := range outputs {
value := v.Value
for range 2 { // two passes, the value resolves through a job output
var err error
if value, err = ee().Interpolate(ctx, value); err != nil {
return fmt.Errorf("unable to interpolate workflow output %s: %w", k, err)
}
}
callerOutputs[k] = value
} }
// Matrix combinations of a reusable-workflow caller share the caller's *model.Job; // Matrix combinations of a reusable-workflow caller share the caller's *model.Job;
@@ -474,7 +469,7 @@ func applyJobTimeout(ctx context.Context, rc *RunContext, job *model.Job) (conte
if err != nil { if err != nil {
common.Logger(ctx).Errorf("An error occurred when attempting to determine the job timeout: %s", err) common.Logger(ctx).Errorf("An error occurred when attempting to determine the job timeout: %s", err)
} else if timeout != "" { } 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) return context.WithTimeout(ctx, time.Duration(timeoutMinutes)*time.Minute)
} }
} }
+2 -2
View File
@@ -479,8 +479,7 @@ func TestNewJobExecutor(t *testing.T) {
Config: &Config{}, Config: &Config{},
} }
if tt.output != "" { if tt.output != "" {
rc.Run.Job().Outputs = map[string]string{"bad": tt.output} require.NoError(t, rc.Run.Job().RawOutputs.Encode(map[string]string{"bad": tt.output}))
rc.outputTemplate = map[string]string{"bad": tt.output}
} }
rc.ExprEval = rc.NewExpressionEvaluator(ctx) rc.ExprEval = rc.NewExpressionEvaluator(ctx)
executorOrder := make([]string, 0) executorOrder := make([]string, 0)
@@ -1082,6 +1081,7 @@ func TestApplyJobTimeout(t *testing.T) {
}{ }{
{"empty", "runs-on: ubuntu-latest", false}, {"empty", "runs-on: ubuntu-latest", false},
{"integer", "timeout-minutes: 5\nruns-on: ubuntu-latest", true}, {"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}, {"non-numeric ignored", "timeout-minutes: abc\nruns-on: ubuntu-latest", false},
} }
for _, tc := range cases { for _, tc := range cases {
+1 -6
View File
@@ -7,7 +7,6 @@ import (
"cmp" "cmp"
"context" "context"
"fmt" "fmt"
"maps"
"path" "path"
"strings" "strings"
@@ -51,11 +50,7 @@ func (rc *RunContext) runJobHook(ctx context.Context, hookPath, name string) err
rawLogger.Infof("shell: %s", shell) rawLogger.Infof("shell: %s", shell)
} }
env := map[string]string{} env := mergeMaps(rc.containerSpec.Env, rc.GetEnv())
if jobContainer := rc.Run.Job().Container(); jobContainer != nil {
maps.Copy(env, jobContainer.Env)
}
maps.Copy(env, rc.GetEnv())
rc.withGithubEnv(ctx, rc.getGithubContext(ctx), env) rc.withGithubEnv(ctx, rc.getGithubContext(ctx), env)
rc.ApplyExtraPath(ctx, &env) rc.ApplyExtraPath(ctx, &env)
+35 -82
View File
@@ -9,7 +9,6 @@ import (
"fmt" "fmt"
"net/url" "net/url"
"path" "path"
"path/filepath"
"regexp" "regexp"
"strings" "strings"
@@ -20,28 +19,22 @@ import (
) )
func newLocalReusableWorkflowExecutor(rc *RunContext) common.Executor { func newLocalReusableWorkflowExecutor(rc *RunContext) common.Executor {
if !rc.Config.NoSkipCheckout { localWorkflow, err := model.ParseReusableWorkflowUses(rc.Run.Job().Uses)
fullPath := rc.Run.Job().Uses if err != nil {
return common.NewErrorExecutor(err)
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),
)
} }
// ./.gitea/workflows/wf.yml -> .gitea/workflows/wf.yml if !rc.Config.NoSkipCheckout {
trimmedUses := strings.TrimPrefix(rc.Run.Job().Uses, "./") // resolve the local workflow against the workspace root, not the process
// uses string format is {owner}/{repo}/.{git_platform}/workflows/{filename}@{ref} // working directory, so it is found regardless of where the runner is invoked
uses := fmt.Sprintf("%s/%s@%s", rc.Config.PresetGitHubContext.Repository, trimmedUses, rc.Config.PresetGitHubContext.Sha) return newReusableWorkflowExecutor(rc, rc.Config.Workdir, localWorkflow.Path)
}
remoteReusableWorkflow := newRemoteReusableWorkflowWithPlat(rc.Config.GitHubInstance, uses) uses := fmt.Sprintf("%s/%s@%s", rc.Config.PresetGitHubContext.Repository, localWorkflow.Path, rc.Config.PresetGitHubContext.Sha)
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, uses)
if err != nil {
return common.NewErrorExecutor(err)
} }
workflowDir := fmt.Sprintf("%s/%s", rc.ActionCacheDir(), safeFilename(uses)) workflowDir := fmt.Sprintf("%s/%s", rc.ActionCacheDir(), safeFilename(uses))
@@ -51,37 +44,27 @@ func newLocalReusableWorkflowExecutor(rc *RunContext) common.Executor {
return common.NewPipelineExecutor( return common.NewPipelineExecutor(
cloneRemoteReusableWorkflow(rc, remoteReusableWorkflow.CloneURL(), remoteReusableWorkflow.Ref, workflowDir, token), cloneRemoteReusableWorkflow(rc, remoteReusableWorkflow.CloneURL(), remoteReusableWorkflow.Ref, workflowDir, token),
newReusableWorkflowExecutor(rc, workflowDir, remoteReusableWorkflow.FilePath()), newReusableWorkflowExecutor(rc, workflowDir, remoteReusableWorkflow.Path),
) )
} }
func newRemoteReusableWorkflowExecutor(rc *RunContext) common.Executor { func newRemoteReusableWorkflowExecutor(rc *RunContext) common.Executor {
uses := rc.Run.Job().Uses remoteReusableWorkflow, err := newRemoteReusableWorkflow(rc.Config.GitHubInstance, rc.Run.Job().Uses)
if err != nil {
var remoteReusableWorkflow *remoteReusableWorkflow return common.NewErrorExecutor(err)
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))
}
} }
// uses with safe filename makes the target directory look something like this {owner}-{repo}-.github-workflows-{filename}@{ref} // 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 // 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 // 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)) workflowDir := fmt.Sprintf("%s/%s", rc.ActionCacheDir(), safeFilename(filename))
token := getGitCloneToken(rc.Config, remoteReusableWorkflow.CloneURL()) token := getGitCloneToken(rc.Config, remoteReusableWorkflow.CloneURL())
return common.NewPipelineExecutor( return common.NewPipelineExecutor(
cloneRemoteReusableWorkflow(rc, remoteReusableWorkflow.CloneURL(), remoteReusableWorkflow.Ref, workflowDir, token), 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: // Callers must not change remoteReusableWorkflow.URL, because:
// 1. Gitea doesn't support specifying GithubContext.ServerURL by the GITHUB_SERVER_URL env // 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 // remoteReusableWorkflow.URL = rc.getGithubContext(ctx).ServerURL
func cloneRemoteReusableWorkflow(rc *RunContext, cloneURL, ref, targetDirectory, token string) common.Executor { func cloneRemoteReusableWorkflow(rc *RunContext, cloneURL, ref, targetDirectory, token string) common.Executor {
@@ -153,64 +136,34 @@ func newReusableWorkflowRunner(rc *RunContext) (*runnerImpl, error) {
} }
type remoteReusableWorkflow struct { type remoteReusableWorkflow struct {
URL string *model.ReusableWorkflowUses
Org string URL string
Repo string
Filename string
Ref string
GitPlatform string
} }
func (r *remoteReusableWorkflow) CloneURL() 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. // 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://") { 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 { var absoluteReusableWorkflowURLRegex = regexp.MustCompile(`^(https?://.*)/([^/]+/[^/]+/\.[^/]+/workflows/[^@]+@.*)$`)
return fmt.Sprintf("./.%s/workflows/%s", r.GitPlatform, r.Filename)
}
// For Gitea // For Gitea
// newRemoteReusableWorkflowWithPlat create a `remoteReusableWorkflow` // newRemoteReusableWorkflow parses a remote `uses`, an absolute URL up to `{owner}/{repo}/.{git_platform}/workflows/` replaces baseURL
// workflows from `.gitea/workflows` and `.github/workflows` are supported func newRemoteReusableWorkflow(baseURL, uses string) (*remoteReusableWorkflow, error) {
func newRemoteReusableWorkflowWithPlat(url, uses string) *remoteReusableWorkflow { if matches := absoluteReusableWorkflowURLRegex.FindStringSubmatch(uses); matches != nil {
// GitHub docs: baseURL, uses = matches[1], matches[2]
// 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
} }
return &remoteReusableWorkflow{ parsed, err := model.ParseReusableWorkflowUses(uses)
Org: matches[1], if err != nil {
Repo: matches[2], return nil, err
GitPlatform: matches[3],
Filename: matches[4],
Ref: matches[5],
URL: url,
} }
} if parsed.IsLocal() {
return nil, fmt.Errorf("expected a remote reusable workflow, got %q", uses)
// 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],
} }
return &remoteReusableWorkflow{ReusableWorkflowUses: parsed, URL: baseURL}, nil
} }
// For Gitea // For Gitea
+17
View File
@@ -5,6 +5,7 @@ package runner
import ( import (
"context" "context"
"io/fs"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "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) { func TestGetGitCloneTokenWithSchemalessGiteaInstance(t *testing.T) {
conf := &Config{ conf := &Config{
GitHubInstance: "gitea.example.net", GitHubInstance: "gitea.example.net",
+75 -197
View File
@@ -35,8 +35,10 @@ import (
"gitea.dev/actionslib/pkg/model" "gitea.dev/actionslib/pkg/model"
"github.com/docker/cli/cli/compose/loader" "github.com/docker/cli/cli/compose/loader"
"github.com/docker/go-connections/nat" "github.com/docker/go-connections/nat"
"github.com/kballard/go-shellquote"
"github.com/moby/moby/api/types/mount" "github.com/moby/moby/api/types/mount"
"github.com/opencontainers/selinux/go-selinux" "github.com/opencontainers/selinux/go-selinux"
"go.yaml.in/yaml/v4"
"golang.org/x/sync/errgroup" "golang.org/x/sync/errgroup"
"golang.org/x/text/encoding/unicode" "golang.org/x/text/encoding/unicode"
"golang.org/x/text/transform" "golang.org/x/text/transform"
@@ -62,6 +64,7 @@ type RunContext struct {
ExprEval *expressionEvaluator ExprEval *expressionEvaluator
JobContainer container.ExecutionsEnvironment JobContainer container.ExecutionsEnvironment
serviceContainers []*serviceContainer serviceContainers []*serviceContainer
containerSpec model.ContainerSpec // container:, resolved once with platformImage, zero without a job container
JobName string JobName string
ActionPath string ActionPath string
Parent *RunContext Parent *RunContext
@@ -74,6 +77,7 @@ type RunContext struct {
workflowCallSecrets map[string]string // the caller's secrets:, resolved once by resolveWorkflowCall workflowCallSecrets map[string]string // the caller's secrets:, resolved once by resolveWorkflowCall
jobRunDefaults model.RunDefaults // defaults.run, resolved once at job setup as GitHub does 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 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) // summaryFileInitialized tracks which per-step summary files (workflow/step-summary-N.md)
// have already been created on the JobContainer. The runner sets up file-command files // have already been created on the JobContainer. The runner sets up file-command files
// via JobContainer.Copy at the start of every phase, which truncates them — fine for // via JobContainer.Copy at the start of every phase, which truncates them — fine for
@@ -82,10 +86,6 @@ type RunContext struct {
// so writes from later phases and from composite sub-steps append to the same file. // 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. // Only populated on the top-level RunContext; child RCs walk Parent via topLevelRunContext.
summaryFileInitialized map[int]bool 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 // jobCancelled records that this job's run was cancelled (context.Canceled). It makes
// getJobContext report the "cancelled" status so cancelled()/always() evaluate the way // getJobContext report the "cancelled" status so cancelled()/always() evaluate the way
// GitHub Actions does, letting cleanup and always() steps run while normal steps skip. // 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 // 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() name := rc.jobContainerName()
ext := container.LinuxContainerEnvironmentExtensions{} 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 // 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"] { if rc.containerDaemonSocket() != "-" && !claimed["/var/run/docker.sock"] {
binds = append(binds, rc.jobDockerSocket()+":/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 { func (rc *RunContext) startHostEnvironment() common.Executor {
@@ -486,7 +472,7 @@ func (rc *RunContext) startJobContainer() common.Executor {
image := rc.platformImage image := rc.platformImage
logWriter := rc.commandLogWriter(ctx) logWriter := rc.commandLogWriter(ctx)
username, password, err := rc.handleCredentials(ctx) username, password, err := registryCredentials(rc.containerSpec.Credentials, "container.")
if err != nil { if err != nil {
return fmt.Errorf("failed to handle credentials: %s", err) return fmt.Errorf("failed to handle credentials: %s", err)
} }
@@ -507,78 +493,57 @@ func (rc *RunContext) startJobContainer() common.Executor {
networkName, createAndDeleteNetwork := rc.networkNameForGitea() networkName, createAndDeleteNetwork := rc.networkNameForGitea()
rc.cleanUpJobContainer = rc.cleanupJobResources(networkName, createAndDeleteNetwork, false) rc.cleanUpJobContainer = rc.cleanupJobResources(networkName, createAndDeleteNetwork, false)
services := rc.Run.Job().Services var services map[string]*model.ContainerSpec
if err := decodeDeferred(ctx, rc.ExprEval, "job services", rc.Run.Job().RawServices, &services); err != nil { if err := model.DecodeEvaluated("job services", rc.Run.Job().RawServices, rc.ExprEval.shared(ctx).EvaluateYamlNode, &services); err != nil {
return err return err
} }
for serviceID, spec := range services { 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 // GitHub compatibility: skip services whose image evaluates to an
// empty string, enabling conditional services via expressions // empty string, enabling conditional services via expressions
serviceImage, err := rc.ExprEval.Interpolate(ctx, spec.Image) if spec.Image == "" {
if err != nil {
return fmt.Errorf("unable to interpolate service %s image: %w", serviceID, err)
}
if serviceImage == "" {
logger.Infof("The service '%s' will not be started because the container definition has an empty image.", serviceID) logger.Infof("The service '%s' will not be started because the container definition has an empty image.", serviceID)
continue continue
} }
// interpolate env envs := make([]string, 0, len(spec.Env)+len(rc.Config.ProxyEnv))
interpolatedEnvs := make(map[string]string, len(spec.Env)+len(rc.Config.ProxyEnv))
// a service reaches the internet the way the job does; its own env still wins // a service reaches the internet the way the job does; its own env still wins
maps0.Copy(interpolatedEnvs, rc.Config.ProxyEnv) for k, v := range mergeMaps(rc.Config.ProxyEnv, spec.Env) {
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 {
envs = append(envs, fmt.Sprintf("%s=%s", k, v)) 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 // keep these local: reusing username/password would overwrite the
// credentials the job container is pulled with further down // credentials the job container is pulled with further down
serviceUsername, servicePassword, err := rc.interpolateCredentials(ctx, spec.Credentials, "") serviceUsername, servicePassword, err := registryCredentials(spec.Credentials, "")
if err != nil { if err != nil {
return fmt.Errorf("failed to handle service %s credentials: %w", serviceID, err) return fmt.Errorf("failed to handle service %s credentials: %w", serviceID, err)
} }
cmd := spec.Cmd
interpolatedVolumes := make([]string, len(spec.Volumes)) if len(cmd) == 0 && spec.Command != "" {
for i, volume := range spec.Volumes { if cmd, err = shellquote.Split(spec.Command); err != nil {
if interpolatedVolumes[i], err = rc.ExprEval.Interpolate(ctx, volume); err != nil { return fmt.Errorf("failed to parse service %s command: %w", serviceID, err)
return fmt.Errorf("unable to interpolate service %s volumes: %w", serviceID, err)
} }
} }
serviceBinds, serviceMounts, _ := splitVolumes(interpolatedVolumes) var entrypoint []string
if spec.Entrypoint != "" {
interpolatedPorts := make([]string, len(spec.Ports)) entrypoint = []string{spec.Entrypoint}
for i, port := range spec.Ports {
if interpolatedPorts[i], err = rc.ExprEval.Interpolate(ctx, port); err != nil {
return fmt.Errorf("unable to interpolate service %s ports: %w", serviceID, err)
}
} }
exposedPorts, portBindings, err := nat.ParsePortSpecs(interpolatedPorts)
serviceBinds, serviceMounts, _ := splitVolumes(spec.Volumes)
exposedPorts, portBindings, err := nat.ParsePortSpecs(spec.Ports)
if err != nil { if err != nil {
return fmt.Errorf("failed to parse service %s ports: %w", serviceID, err) return fmt.Errorf("failed to parse service %s ports: %w", serviceID, err)
} }
serviceOptions, err := rc.ExprEval.Interpolate(ctx, spec.Options)
if err != nil {
return fmt.Errorf("unable to interpolate service %s options: %w", serviceID, err)
}
serviceContainerName := createContainerName(rc.jobContainerName(), serviceID) serviceContainerName := createContainerName(rc.jobContainerName(), serviceID)
c := newContainer(&container.NewContainerInput{ c := newContainer(&container.NewContainerInput{
Name: serviceContainerName, Name: serviceContainerName,
Image: serviceImage, Image: spec.Image,
Username: serviceUsername, Username: serviceUsername,
Password: servicePassword, Password: servicePassword,
Cmd: interpolatedCmd, Entrypoint: entrypoint,
Cmd: cmd,
Env: envs, Env: envs,
Mounts: serviceMounts, Mounts: serviceMounts,
Binds: serviceBinds, Binds: serviceBinds,
@@ -588,7 +553,7 @@ func (rc *RunContext) startJobContainer() common.Executor {
UsernsMode: rc.Config.UsernsMode, UsernsMode: rc.Config.UsernsMode,
Platform: rc.Config.ContainerArchitecture, Platform: rc.Config.ContainerArchitecture,
AutoRemove: false, // so a dead service's log survives, cleanupJobResources removes it AutoRemove: false, // so a dead service's log survives, cleanupJobResources removes it
WorkflowOptions: serviceOptions, WorkflowOptions: spec.Options,
NetworkMode: networkName, NetworkMode: networkName,
NetworkAliases: []string{serviceID}, NetworkAliases: []string{serviceID},
ExposedPorts: exposedPorts, ExposedPorts: exposedPorts,
@@ -596,16 +561,12 @@ func (rc *RunContext) startJobContainer() common.Executor {
ValidVolumes: rc.Config.ValidVolumes, // not validVolumes(), a service gets no docker socket ValidVolumes: rc.Config.ValidVolumes, // not validVolumes(), a service gets no docker socket
AllocatePTY: rc.Config.AllocatePTY, 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` // For Gitea, `jobContainerNetwork` should be the same as `networkName`
jobContainerNetwork := networkName jobContainerNetwork := networkName
workflowOptions, err := rc.workflowOptions(ctx)
if err != nil {
return err
}
ext := container.LinuxContainerEnvironmentExtensions{} ext := container.LinuxContainerEnvironmentExtensions{}
containerInput := &container.NewContainerInput{ containerInput := &container.NewContainerInput{
Cmd: nil, Cmd: nil,
@@ -624,7 +585,7 @@ func (rc *RunContext) startJobContainer() common.Executor {
UsernsMode: rc.Config.UsernsMode, UsernsMode: rc.Config.UsernsMode,
Platform: rc.Config.ContainerArchitecture, Platform: rc.Config.ContainerArchitecture,
RunnerOptions: rc.Config.ContainerOptions, RunnerOptions: rc.Config.ContainerOptions,
WorkflowOptions: workflowOptions, WorkflowOptions: rc.containerSpec.Options,
AutoRemove: true, AutoRemove: true,
AllocatePTY: rc.Config.AllocatePTY, AllocatePTY: rc.Config.AllocatePTY,
} }
@@ -641,9 +602,7 @@ func (rc *RunContext) startJobContainer() common.Executor {
return err return err
} }
rc.startDockerProxy(ctx) rc.startDockerProxy(ctx)
if containerInput.Binds, containerInput.Mounts, err = rc.GetBindsAndMounts(); err != nil { containerInput.Binds, containerInput.Mounts = rc.GetBindsAndMounts()
return err
}
containerInput.ValidVolumes = rc.validVolumes() containerInput.ValidVolumes = rc.validVolumes()
rc.jobNetworkName = networkName rc.jobNetworkName = networkName
@@ -1033,39 +992,16 @@ func lockJob(job *model.Job) func() {
func (rc *RunContext) interpolateOutputs() common.Executor { func (rc *RunContext) interpolateOutputs() common.Executor {
return func(ctx context.Context) error { return func(ctx context.Context) error {
ee := rc.NewExpressionEvaluator(ctx)
job := rc.Run.Job() job := rc.Run.Job()
// Matrix combinations share this Job and its Outputs map. Interpolate from this combo's var outputs map[string]string
// pristine snapshot (outputTemplate) and write under the lock, so each combo overwrites if err := model.DecodeEvaluated("job outputs", job.RawOutputs, rc.NewExpressionEvaluator(ctx).shared(ctx).EvaluateYamlNode, &outputs); err != nil {
// 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 {
return err return err
} }
if deferredOutputs != nil { // Matrix combinations share the job, so as on GitHub a failure publishes nothing and the last non-empty value wins.
defer lockJob(job)() maps0.DeleteFunc(outputs, func(_, value string) bool { return value == "" })
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
}
}
defer lockJob(job)() defer lockJob(job)()
for k := range rc.outputTemplate { job.Outputs = mergeMaps(job.Outputs, outputs)
if err != nil { return nil
job.Outputs[k] = ""
continue
}
job.Outputs[k] = outputs[k]
}
return err
} }
} }
@@ -1184,18 +1120,6 @@ func (rc *RunContext) Executor() (common.Executor, error) {
}, nil }, 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) { func (rc *RunContext) runsOnImage(ctx context.Context) (string, error) {
if rc.Run.Job().RunsOn() == nil { if rc.Run.Job().RunsOn() == nil {
common.Logger(ctx).Errorf("'runs-on' key not defined in %s", rc.String()) common.Logger(ctx).Errorf("'runs-on' key not defined in %s", rc.String())
@@ -1233,26 +1157,37 @@ func (rc *RunContext) runsOnPlatformNames(ctx context.Context) []string {
return model.RunsOnFromNode(rawRunsOn) 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 { func (rc *RunContext) resolvePlatformImage(ctx context.Context) error {
image, err := rc.containerImage(ctx) withoutEnv, _ := splitContainerEnv(rc.Run.Job().RawContainer)
if err == nil && image == "" { var spec *model.ContainerSpec
image, err = rc.runsOnImage(ctx) 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 rc.platformImage = image
return err return err
} }
func (rc *RunContext) workflowOptions(ctx context.Context) (string, error) { // splitContainerEnv takes `env` out of a container mapping, since env reads the job env that resolves after the job environment starts.
c := rc.Run.Job().Container() func splitContainerEnv(container yaml.Node) (withoutEnv, env yaml.Node) {
if c == nil { if container.Kind != yaml.MappingNode {
return "", nil return container, env
} }
options, err := rc.ExprEval.Interpolate(ctx, c.Options) withoutEnv = container
if err != nil { withoutEnv.Content = nil
return "", fmt.Errorf("unable to interpolate container.options: %w", err) 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) { 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 { // Adapt to Gitea
if preset := rc.Config.PresetGitHubContext; preset != nil { if preset := rc.Config.PresetGitHubContext; preset != nil {
ghc.Event = preset.Event merged := *preset
ghc.RunID = preset.RunID merged.Workflow, merged.Job, merged.Workspace, merged.EventPath = ghc.Workflow, ghc.Job, ghc.Workspace, ghc.EventPath
ghc.RunNumber = preset.RunNumber merged.Action, merged.ActionPath, merged.ActionRepository, merged.ActionRef = ghc.Action, ghc.ActionPath, ghc.ActionRepository, ghc.ActionRef
ghc.RunAttempt = preset.RunAttempt merged.RunnerPerflog, merged.RunnerTrackingID = ghc.RunnerPerflog, ghc.RunnerTrackingID
ghc.Actor = preset.Actor *ghc = merged
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
instance := rc.Config.GitHubInstance instance := rc.Config.GitHubInstance
if !strings.HasPrefix(instance, "http://") && if !strings.HasPrefix(instance, "http://") &&
@@ -1563,12 +1487,7 @@ func isLocalCheckout(ghc *model.GithubContext, step *model.Step) bool {
if step.Type() != model.StepTypeUsesActionRemote { if step.Type() != model.StepTypeUsesActionRemote {
return false return false
} }
remoteAction := newRemoteAction(step.Uses) if uses, err := model.ParseActionUses(step.Uses); err != nil || uses.Owner != "actions" || uses.Repo != "checkout" {
if remoteAction == nil {
// IsCheckout() will nil panic if we dont bail out early
return false
}
if !remoteAction.IsCheckout() {
return false return false
} }
@@ -1581,23 +1500,6 @@ func isLocalCheckout(ghc *model.GithubContext, step *model.Step) bool {
return true 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) { func (rc *RunContext) withGithubEnv(ctx context.Context, github *model.GithubContext, env map[string]string) {
env["CI"] = "true" env["CI"] = "true"
env["GITHUB_WORKFLOW"] = github.Workflow env["GITHUB_WORKFLOW"] = github.Workflow
@@ -1721,41 +1623,17 @@ func setActionRuntimeVars(rc *RunContext, env map[string]string) {
env["ACTIONS_RUNTIME_TOKEN"] = actionsRuntimeToken env["ACTIONS_RUNTIME_TOKEN"] = actionsRuntimeToken
} }
func (rc *RunContext) handleCredentials(ctx context.Context) (string, string, error) { func registryCredentials(credentials map[string]string, prefix string) (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) {
if credentials == nil { if credentials == nil {
return "", "", nil return "", "", nil
} }
if len(credentials) != 2 { if len(credentials) != 2 {
return "", "", errors.New("invalid property count for key 'credentials:'") return "", "", errors.New("invalid property count for key 'credentials:'")
} }
for _, key := range []string{"username", "password"} {
ee := rc.NewExpressionEvaluator(ctx) if credentials[key] == "" {
interpolate := func(key string) (string, error) { return "", "", fmt.Errorf("failed to interpolate %scredentials.%s", prefix, key)
value, err := ee.Interpolate(ctx, credentials[key])
if err != nil {
return "", fmt.Errorf("failed to interpolate %scredentials.%s: %w", prefix, key, err)
} }
if value == "" {
return "", fmt.Errorf("failed to interpolate %scredentials.%s", prefix, key)
}
return value, nil
} }
username, err := interpolate("username") return credentials["username"], credentials["password"], nil
if err != nil {
return "", "", err
}
password, err := interpolate("password")
if err != nil {
return "", "", err
}
return username, password, nil
} }
+47 -71
View File
@@ -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 // 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 // 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. // 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.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.Binds) // the docker socket is the job container's alone
require.Empty(t, redis.WorkingDir) 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 TestStartJobContainerEvaluatesContainersOnce(t *testing.T) {
func TestStartJobContainerGivesServicesTheirVolumesFromExpression(t *testing.T) { inputs := startJobContainerInputs(t, `
redis := startJobContainerInputs(t, `
jobs: jobs:
job: job:
services: ${{ fromJSON('{"redis":{"image":"redis:latest","volumes":["data:/data"]}}') }} container: ${{ fromJSON('{"image":"node:20","options":"--label ${{ github.job }}","credentials":{"username":"user","password":"${{ vars.TITLE }}"}}') }}
`, &Config{ValidVolumes: []string{"data"}})[0] 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, "redis:latest", redis.Image)
require.Equal(t, []string{"data"}, redis.ValidVolumes)
require.Equal(t, map[string]string{"data": "/data"}, redis.Mounts) 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 // 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 := testcase.rc.Config
config.Workdir = testcase.name config.Workdir = testcase.name
config.BindWorkdir = bindWorkDir config.BindWorkdir = bindWorkDir
gotbind, gotmount, err := rctemplate.GetBindsAndMounts() gotbind, gotmount := rctemplate.GetBindsAndMounts()
require.NoError(t, err)
// Name binds/mounts are either/or // Name binds/mounts are either/or
if config.BindWorkdir { if config.BindWorkdir {
@@ -543,8 +519,9 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) {
t.Run("InterpolatedContainerVolumes", func(t *testing.T) { t.Run("InterpolatedContainerVolumes", func(t *testing.T) {
job := &model.Job{} job := &model.Job{}
err := job.RawContainer.Encode(map[string][]string{ err := job.RawContainer.Encode(map[string]any{
"volumes": {"${{ secrets.MAME }}:/root/.mame/roms:ro"}, "image": "node:20",
"volumes": []string{"${{ secrets.MAME }}:/root/.mame/roms:ro"},
}) })
require.NoError(t, err) require.NoError(t, err)
@@ -565,9 +542,9 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) {
rc.Run.JobID = "job1" rc.Run.JobID = "job1"
rc.Run.Workflow.Jobs = map[string]*model.Job{"job1": job} rc.Run.Workflow.Jobs = map[string]*model.Job{"job1": job}
rc.ExprEval = rc.NewExpressionEvaluator(context.Background()) rc.ExprEval = rc.NewExpressionEvaluator(context.Background())
require.NoError(t, rc.resolvePlatformImage(context.Background()))
gotbind, gotmount, err := rc.GetBindsAndMounts() gotbind, gotmount := rc.GetBindsAndMounts()
require.NoError(t, err)
assert.Contains(t, gotbind, "/host/mame/roms:/root/.mame/roms:ro") assert.Contains(t, gotbind, "/host/mame/roms:/root/.mame/roms:ro")
assert.NotContains(t, gotbind, "${{ secrets.MAME }}") assert.NotContains(t, gotbind, "${{ secrets.MAME }}")
assert.NotContains(t, gotmount, "${{ secrets.MAME }}") assert.NotContains(t, gotmount, "${{ secrets.MAME }}")
@@ -575,12 +552,6 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) {
for _, testcase := range tests { for _, testcase := range tests {
t.Run(testcase.name, func(t *testing.T) { 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{ rc := &RunContext{
Name: "TestRCName", Name: "TestRCName",
Run: &model.Run{ Run: &model.Run{
@@ -592,12 +563,11 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) {
BindWorkdir: false, BindWorkdir: false,
SharedToolCache: true, // so OverridesToolCache has a mount to displace SharedToolCache: true, // so OverridesToolCache has a mount to displace
}, },
containerSpec: model.ContainerSpec{Volumes: testcase.volumes},
} }
rc.Run.JobID = "job1" rc.Run.JobID = "job1"
rc.Run.Workflow.Jobs = map[string]*model.Job{"job1": job}
gotbind, gotmount, err := rc.GetBindsAndMounts() gotbind, gotmount := rc.GetBindsAndMounts()
require.NoError(t, err)
if len(testcase.wantbind) > 0 { if len(testcase.wantbind) > 0 {
assert.Contains(t, gotbind, testcase.wantbind) assert.Contains(t, gotbind, testcase.wantbind)
@@ -632,13 +602,11 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) {
Config: &Config{}, Config: &Config{},
} }
_, gotmount, err := rc.GetBindsAndMounts() _, gotmount := rc.GetBindsAndMounts()
require.NoError(t, err)
assert.NotContains(t, gotmount, sharedToolCacheVolume) assert.NotContains(t, gotmount, sharedToolCacheVolume)
rc.Config.SharedToolCache = true rc.Config.SharedToolCache = true
_, gotmount, err = rc.GetBindsAndMounts() _, gotmount = rc.GetBindsAndMounts()
require.NoError(t, err)
assert.Equal(t, container.DefaultToolCache, gotmount[sharedToolCacheVolume]) 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{}}, Config: &Config{BindWorkdir: true, Workdir: "/workspace/1/owner/repo", PresetGitHubContext: &model.GithubContext{}},
} }
gotbind, _, err := rc.GetBindsAndMounts() gotbind, _ := rc.GetBindsAndMounts()
require.NoError(t, err)
assert.True(t, slices.ContainsFunc(gotbind, func(bind string) bool { return strings.HasPrefix(bind, "/workspace/1:/workspace/1") }), gotbind) assert.True(t, slices.ContainsFunc(gotbind, func(bind string) bool { return strings.HasPrefix(bind, "/workspace/1:/workspace/1") }), gotbind)
rc.Config.BindWorkdir = false rc.Config.BindWorkdir = false
_, gotmount, err := rc.GetBindsAndMounts() _, gotmount := rc.GetBindsAndMounts()
require.NoError(t, err)
assert.Equal(t, "/workspace/1", gotmount[rc.jobContainerName()]) assert.Equal(t, "/workspace/1", gotmount[rc.jobContainerName()])
require.NoError(t, rc.Run.Job().RawContainer.Encode(map[string][]string{"volumes": {"claimed:/workspace/1"}})) rc.containerSpec.Volumes = []string{"claimed:/workspace/1"}
_, gotmount, err = rc.GetBindsAndMounts() _, gotmount = rc.GetBindsAndMounts()
require.NoError(t, err)
assert.Equal(t, "/workspace/1/owner/repo", gotmount[rc.jobContainerName()]) 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 func TestInterpolateOutputsIsPerMatrixComboKeepingNeedsOutputs(t *testing.T) {
// *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) {
job := &model.Job{Outputs: map[string]string{"o": "${{ matrix.v }}"}} 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{}} r := &runnerImpl{config: &Config{}}
ctx := context.Background() 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"}) rcA, err := r.newRunContext(ctx, run, map[string]any{"v": "a"})
require.NoError(t, err) require.NoError(t, err)
rcB, err := r.newRunContext(ctx, run, map[string]any{"v": "b"}) rcB, err := r.newRunContext(ctx, run, map[string]any{"v": "b"})
require.NoError(t, err) 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, rcA.interpolateOutputs()(ctx))
require.NoError(t, rcB.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, map[string]string{"o": "b"}, job.Outputs)
require.Equal(t, "b", job.Outputs["o"]) require.Equal(t, map[string]string{"o": "from-gitea"}, needed.Outputs)
} }
// A whole-value `outputs:` expression only reaches the typed field through DecodeRaw. // 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.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.Token, rc.Config.Secrets["GITHUB_TOKEN"])
assert.Equal(t, ghc.Job, "job1") //nolint:testifylint // pre-existing issue from nektos/act 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) { func TestGetGithubContextRef(t *testing.T) {
+26 -9
View File
@@ -7,7 +7,6 @@ package runner
import ( import (
"context" "context"
"fmt" "fmt"
"maps"
"os" "os"
"runtime" "runtime"
"sync" "sync"
@@ -159,9 +158,9 @@ func (runner *runnerImpl) configure() (*runnerImpl, error) {
func maxParallelFor(strategy *model.Strategy, combinations int) int { func maxParallelFor(strategy *model.Strategy, combinations int) int {
maxParallel := 4 // actionslib has no default for an undeclared max-parallel maxParallel := 4 // actionslib has no default for an undeclared max-parallel
if strategy != nil { 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) log.Errorf("Ignoring invalid max-parallel: %v", err)
} else if declared { } else if limit > 0 {
maxParallel = limit maxParallel = limit
} }
} }
@@ -210,7 +209,7 @@ func (runner *runnerImpl) NewPlanExecutor(plan *model.Plan) common.Executor {
return err return err
} }
if job.RawStrategy.Kind == yaml.ScalarNode { 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 return err
} }
} else { } 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 { if err := strategyRc.NewExpressionEvaluator(ctx).EvaluateYamlNode(ctx, &job.Strategy.RawMatrix); err != nil {
log.Errorf("Error while evaluating matrix: %v", err) 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)) log.Infof("Running job with maxParallel=%d for %d matrix combinations", maxParallel, len(matrixes))
for i, matrix := range matrixes { for i, matrix := range matrixes {
rc, 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 { if err != nil {
return err 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) { 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{ rc := &RunContext{
Config: runner.config, Config: runner.config,
Run: run, Run: run,
@@ -332,16 +349,16 @@ func (runner *runnerImpl) newRunContext(ctx context.Context, run *model.Run, mat
StepResults: make(map[string]*model.StepResult), StepResults: make(map[string]*model.StepResult),
Matrix: matrix, Matrix: matrix,
caller: runner.caller, caller: runner.caller,
jobIndex: index,
jobTotal: total,
} }
if err := rc.resolveWorkflowCall(ctx); err != nil { if err := rc.resolveWorkflowCall(ctx); err != nil {
return nil, err return nil, err
} }
rc.ExprEval = rc.NewExpressionEvaluator(ctx) rc.ExprEval = rc.NewExpressionEvaluator(ctx)
rc.Name = rc.maskSecrets(rc.ExprEval.InterpolateName(ctx, run.String())) rc.Name = rc.maskSecrets(rc.ExprEval.InterpolateName(ctx, run.String()))
// Snapshot the job's pristine output expressions now, before any matrix combo runs and if job := run.Job(); job != nil && job.RawOutputs.Kind != 0 {
// rewrites the shared Job.Outputs (see interpolateOutputs). job.Outputs = nil // a job no combination ran for reads "" as on GitHub, a Gitea needs placeholder has no RawOutputs and keeps its outputs
if job := run.Job(); job != nil {
rc.outputTemplate = maps.Clone(job.Outputs)
} }
return rc, nil return rc, nil
+4 -2
View File
@@ -7,6 +7,7 @@ package runner
import ( import (
"bytes" "bytes"
"context" "context"
"crypto/rand"
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
@@ -29,6 +30,7 @@ var (
logLevel = log.DebugLevel logLevel = log.DebugLevel
workdir = "testdata" workdir = "testdata"
secrets map[string]string 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 { 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, EventPath: cfg.EventPath,
PlatformPicker: mapPlatformPicker(j.platforms), PlatformPicker: mapPlatformPicker(j.platforms),
// fixtures reuse workflow and job names, so parallel tests would collide without this // 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 // 0 would run jobs runtime.NumCPU()-wide, making the network peak machine-dependent
MaxParallel: 2, MaxParallel: 2,
ForceRebuild: true, ForceRebuild: true,
@@ -346,7 +348,7 @@ func TestRunEvent(t *testing.T) {
config := &Config{ config := &Config{
Secrets: table.secrets, 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") eventFile := filepath.Join(workdir, table.workflowPath, "event.json")
+22 -33
View File
@@ -11,7 +11,6 @@ import (
"path" "path"
"strconv" "strconv"
"strings" "strings"
"sync"
"time" "time"
"gitea.com/gitea/runner/act/common" "gitea.com/gitea/runner/act/common"
@@ -19,7 +18,6 @@ import (
"gitea.dev/actionslib/pkg/exprparser" "gitea.dev/actionslib/pkg/exprparser"
"gitea.dev/actionslib/pkg/model" "gitea.dev/actionslib/pkg/model"
"go.yaml.in/yaml/v4"
) )
type step interface { type step interface {
@@ -89,6 +87,9 @@ func runStepExecutor(step step, stage stepStage, executor common.Executor) commo
if err == nil { if err == nil {
runStep, err = isStepEnabled(ctx, ifExpression, step, stage) runStep, err = isStepEnabled(ctx, ifExpression, step, stage)
} }
if err == nil && runStep {
err = setupInputs(ctx, step)
}
if err != nil { if err != nil {
stepResult.Conclusion = model.StepStatusFailure stepResult.Conclusion = model.StepStatusFailure
stepResult.Outcome = model.StepStatusFailure stepResult.Outcome = model.StepStatusFailure
@@ -240,51 +241,39 @@ func setupEnv(ctx context.Context, step step) error {
rc := step.getRunContext() rc := step.getRunContext()
mergeEnv(ctx, step) mergeEnv(ctx, step)
// merge step env last, since it should not be overwritten
mergeIntoMap(step, step.getEnv(), step.getStepModel().GetEnv())
var err error var err error
exprEval := rc.NewExpressionEvaluator(ctx) exprEval := rc.NewExpressionEvaluator(ctx)
for k, v := range *step.getEnv() { stepEnv := step.getStepModel().Environment()
if !strings.HasPrefix(k, "INPUT_") { for k, v := range stepEnv {
if (*step.getEnv())[k], err = exprEval.Interpolate(ctx, v); err != nil { if stepEnv[k], err = exprEval.Interpolate(ctx, v); err != nil {
return fmt.Errorf("unable to interpolate env %s: %w", k, err) 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 // merge step env last, since it should not be overwritten
// you can use step level env in the with property of a uses construct mergeIntoMap(step, step.getEnv(), stepEnv)
inputEval := sync.OnceValue(func() *expressionEvaluator { return rc.NewExpressionEvaluatorWithEnv(ctx, *step.getEnv()) }) return nil
for k, v := range *step.getEnv() { }
if strings.HasPrefix(k, "INPUT_") {
if (*step.getEnv())[k], err = inputEval().Interpolate(ctx, v); err != nil { // setupInputs evaluates `with` once the condition passed, as GitHub does, so every consumer reads the evaluated With.
return fmt.Errorf("unable to interpolate env %s: %w", k, err) 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 { if err := model.DecodeEvaluated("with", stepModel.RawWith, step.getRunContext().NewStepExpressionEvaluator(ctx, step).shared(ctx).EvaluateYamlNode, &stepModel.With); err != nil {
decoded := &model.Step{} return err
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())
} }
mergeIntoMap(step, step.getEnv(), (&model.Step{With: stepModel.With}).GetEnv())
return nil return nil
} }
func mergeEnv(ctx context.Context, step step) { func mergeEnv(ctx context.Context, step step) {
env := step.getEnv() env := step.getEnv()
rc := step.getRunContext() rc := step.getRunContext()
job := rc.Run.Job()
c := job.Container() // container env is the image's baseline, which job env and $GITHUB_ENV override
if c != nil { mergeIntoMap(step, env, rc.containerSpec.Env, rc.GetEnv())
// 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())
}
rc.withGithubEnv(ctx, step.getGithubContext(ctx), *env) rc.withGithubEnv(ctx, step.getGithubContext(ctx), *env)
} }
+28 -65
View File
@@ -12,7 +12,6 @@ import (
"os" "os"
"path" "path"
"path/filepath" "path/filepath"
"regexp"
"strings" "strings"
"gitea.com/gitea/runner/act/common" "gitea.com/gitea/runner/act/common"
@@ -58,13 +57,8 @@ func (sar *stepActionRemote) prepareActionExecutor() common.Executor {
sar.Step.Uses = uses sar.Step.Uses = uses
github := sar.getGithubContext(ctx) // read before remoteAction is set, so `$/` resolves against the enclosing action github := sar.getGithubContext(ctx) // read before remoteAction is set, so `$/` resolves against the enclosing action
if strings.HasPrefix(sar.Step.Uses, selfRepoPrefix) { if sar.remoteAction, err = newRemoteAction(sar.Step.Uses, github, sar.RunContext.Config.GitHubInstance); err != nil {
sar.remoteAction = newSelfRepoAction(sar.Step.Uses, github) return err
} 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.IsCheckout() && isLocalCheckout(github, sar.Step) && !sar.RunContext.Config.NoSkipCheckout { 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") common.Logger(ctx).Debugf("Skipping local actions/checkout because you bound your workspace")
return nil return nil
} }
checkoutPath, err := sar.RunContext.NewExpressionEvaluator(ctx).Interpolate(ctx, sar.Step.With["path"]) copyToPath := path.Join(sar.RunContext.JobContainer.ToContainerPath(sar.RunContext.Config.Workdir), sar.Step.With["path"])
if err != nil {
return fmt.Errorf("unable to interpolate with.path: %w", err)
}
copyToPath := path.Join(sar.RunContext.JobContainer.ToContainerPath(sar.RunContext.Config.Workdir), checkoutPath)
return sar.RunContext.JobContainer.CopyDir(copyToPath, sar.RunContext.Config.Workdir+string(filepath.Separator)+".", sar.RunContext.Config.UseGitIgnore, false)(ctx) 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 return false
} }
// newSelfRepoAction resolves `$/{path}` against the enclosing composite action, falling back to the workflow's own repo and commit. // newRemoteAction resolves `self:` on instanceURL and `$/` in the enclosing composite action, else the workflow's repo and commit.
func newSelfRepoAction(action string, github *model.GithubContext) *remoteAction { func newRemoteAction(action string, github *model.GithubContext, instanceURL string) (*remoteAction, error) {
subPath := strings.TrimLeft(strings.TrimPrefix(action, selfRepoPrefix), "/") uses, err := model.ParseActionUses(action)
if subPath == "" || strings.Contains(subPath, "@") || path.Clean("/"+subPath) != "/"+subPath { // rooted, so a leading ".." is rejected too if err != nil {
return nil return nil, err
} }
repo, ref := github.ActionRepository, github.ActionRef ra := &remoteAction{URL: uses.URL, Org: uses.Owner, Repo: uses.Repo, Path: uses.Path, Ref: uses.Ref}
if repo == "" || ref == "" { switch uses.Kind {
repo, ref = github.Repository, github.Sha case model.ActionUsesInstance:
} if instanceURL == "" {
org, name, _ := strings.Cut(repo, "/") return nil, fmt.Errorf("unable to resolve %q without a Gitea instance", action)
if org == "" || name == "" || ref == "" { }
return nil ra.URL = instanceURL
} if !strings.Contains(instanceURL, "://") {
return &remoteAction{URL: github.ServerURL, Org: org, Repo: name, Path: subPath, Ref: ref} ra.URL = "https://" + instanceURL
} }
case model.ActionUsesSelfRepo:
func newRemoteAction(action string) *remoteAction { repo, ref := github.ActionRepository, github.ActionRef
// support http(s)://host/owner/repo@v3 if repo == "" || ref == "" {
for _, schema := range []string{"https://", "http://", "ssh://"} { repo, ref = github.Repository, github.Sha
if after, ok := strings.CutPrefix(action, schema); ok { }
splits := strings.SplitN(after, "/", 2) ra.Org, ra.Repo, _ = strings.Cut(repo, "/")
if len(splits) != 2 { ra.URL, ra.Ref = github.ServerURL, ref
return nil if ra.Org == "" || ra.Repo == "" || ra.Ref == "" {
} return nil, fmt.Errorf("unable to resolve %q without a repository and commit", action)
ret := parseAction(splits[1])
if ret == nil {
return nil
}
ret.URL = schema + splits[0]
return ret
} }
} }
return ra, nil
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: "",
}
} }
func safeFilename(s string) string { func safeFilename(s string) string {
+30 -9
View File
@@ -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) sarm.On("readAction", sar.Step, actionDirSuffix(sar.Step.UsesHash()), "", mock.Anything, mock.Anything).Return(&model.Action{}, nil)
} }
if tt.mocks.run { 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) 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{}}, 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": {}}}, action: &model.Action{Inputs: map[string]model.Input{"shared": {}}},
env: map[string]string{}, env: map[string]string{},
remoteAction: newRemoteAction("org/composite@v1"), remoteAction: mustNewRemoteAction(t, "org/composite@v1"),
} }
for _, value := range []string{"first", "second"} { for _, value := range []string{"first", "second"} {
@@ -464,7 +464,7 @@ func TestStepActionRemotePost(t *testing.T) {
Step: tt.stepModel, Step: tt.stepModel,
action: tt.actionModel, action: tt.actionModel,
// post only ever runs after prepareActionExecutor resolved the action // 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) 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 action: "ssh://gitea.com/onlyonesegment@main", // missing org/repo after the host
want: nil, 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 { for _, tt := range tests {
t.Run(tt.action, func(t *testing.T) { 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) assert.Equalf(t, tt.want, got, "newRemoteAction(%v)", tt.action)
cloneURL := "" cloneURL := ""
if got != nil { if got != nil {
@@ -623,9 +634,18 @@ func Test_newRemoteAction(t *testing.T) {
assert.Equalf(t, tt.wantCloneURL, cloneURL, "newRemoteAction(%v).CloneURL()", tt.action) 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{ workflow := &model.GithubContext{
ServerURL: "https://gitea.example.com", ServerURL: "https://gitea.example.com",
Repository: "owner/workflow-repo", Repository: "owner/workflow-repo",
@@ -674,7 +694,8 @@ func Test_newSelfRepoAction(t *testing.T) {
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { 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 { for _, tt := range tests {
t.Run(tt.uses, func(t *testing.T) { 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) { func TestStepActionRemoteActionDownloadInfo(t *testing.T) {
t.Run("reports the action and its resolved commit", func(t *testing.T) { t.Run("reports the action and its resolved commit", func(t *testing.T) {
sar := &stepActionRemote{ sar := &stepActionRemote{
remoteAction: newRemoteAction("actions/checkout@v7"), remoteAction: mustNewRemoteAction(t, "actions/checkout@v7"),
action: &model.Action{}, action: &model.Action{},
resolvedSha: "9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0", resolvedSha: "9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0",
} }
@@ -775,7 +796,7 @@ func TestStepActionRemoteActionDownloadInfo(t *testing.T) {
t.Run("reports nothing when no action was downloaded", func(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. // 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() _, _, ok := sar.actionDownloadInfo()
+3 -16
View File
@@ -6,7 +6,6 @@ package runner
import ( import (
"context" "context"
"fmt"
"strings" "strings"
"gitea.com/gitea/runner/act/common" "gitea.com/gitea/runner/act/common"
@@ -58,29 +57,17 @@ func (sd *stepDocker) runUsesContainer() common.Executor {
return func(ctx context.Context) error { return func(ctx context.Context) error {
image := strings.TrimPrefix(step.Uses, "docker://") image := strings.TrimPrefix(step.Uses, "docker://")
eval := rc.NewExpressionEvaluator(ctx) cmd, err := shellquote.Split(step.With["args"])
args, err := eval.Interpolate(ctx, step.With["args"])
if err != nil {
return fmt.Errorf("unable to interpolate with.args: %w", err)
}
cmd, err := shellquote.Split(args)
if err != nil { if err != nil {
return err return err
} }
var entrypoint []string var entrypoint []string
entry, err := eval.Interpolate(ctx, step.With["entrypoint"]) if entry := step.With["entrypoint"]; entry != "" {
if err != nil {
return fmt.Errorf("unable to interpolate with.entrypoint: %w", err)
}
if entry != "" {
entrypoint = []string{entry} entrypoint = []string{entry}
} }
stepContainer, err := newStepContainer(ctx, sd, image, cmd, entrypoint, "") stepContainer := newStepContainer(ctx, sd, image, cmd, entrypoint, "")
if err != nil {
return err
}
return common.NewPipelineExecutor( return common.NewPipelineExecutor(
stepContainer.Pull(rc.Config.ForcePull), stepContainer.Pull(rc.Config.ForcePull),
+2 -2
View File
@@ -145,7 +145,7 @@ func TestStepDockerNewStepContainerAllocatePTY(t *testing.T) {
} }
sd.RunContext.ExprEval = sd.RunContext.NewExpressionEvaluator(ctx) sd.RunContext.ExprEval = sd.RunContext.NewExpressionEvaluator(ctx)
_, _ = newStepContainer(ctx, sd, "node:14", []string{"echo", "hi"}, nil, "") newStepContainer(ctx, sd, "node:14", []string{"echo", "hi"}, nil, "")
assert.Equal(t, tc.allocPTY, captured.AllocatePTY) assert.Equal(t, tc.allocPTY, captured.AllocatePTY)
}) })
} }
@@ -214,7 +214,7 @@ func TestStepDockerNewStepContainerNetworkMode(t *testing.T) {
require.NoError(t, sd.RunContext.resolvePlatformImage(ctx)) require.NoError(t, sd.RunContext.resolvePlatformImage(ctx))
assert.Equal(t, tc.expectDefault, sd.RunContext.IsHostEnv(), "IsHostEnv mismatch for platform %q", tc.platform) assert.Equal(t, tc.expectDefault, sd.RunContext.IsHostEnv(), "IsHostEnv mismatch for platform %q", tc.platform)
_, _ = newStepContainer(ctx, sd, "alpine:3.20", []string{"echo", "hello"}, nil, "") newStepContainer(ctx, sd, "alpine:3.20", []string{"echo", "hello"}, nil, "")
if tc.expectDefault { if tc.expectDefault {
assert.Equal(t, "default", captured.NetworkMode, assert.Equal(t, "default", captured.NetworkMode,
+24 -13
View File
@@ -149,11 +149,8 @@ func TestSetupEnv(t *testing.T) {
}, },
JobContainer: cm, JobContainer: cm,
} }
step := &model.Step{ step := &model.Step{}
With: map[string]string{ require.NoError(t, step.RawWith.Encode(map[string]string{"STEP_WITH": "with-value", "ID": "${{ fromJSON('1234567') }}"}))
"STEP_WITH": "with-value",
},
}
env := map[string]string{} env := map[string]string{}
sm.On("getRunContext").Return(rc) sm.On("getRunContext").Return(rc)
@@ -162,6 +159,7 @@ func TestSetupEnv(t *testing.T) {
sm.On("getEnv").Return(&env) sm.On("getEnv").Return(&env)
require.NoError(t, setupEnv(context.Background(), sm)) require.NoError(t, setupEnv(context.Background(), sm))
require.NoError(t, setupInputs(context.Background(), sm))
// These are commit or system specific // These are commit or system specific
delete(env, "GITHUB_REF") delete(env, "GITHUB_REF")
@@ -198,6 +196,7 @@ func TestSetupEnv(t *testing.T) {
"GITHUB_RUN_NUMBER": "1", "GITHUB_RUN_NUMBER": "1",
"GITHUB_SERVER_URL": "https://", "GITHUB_SERVER_URL": "https://",
"GITHUB_WORKFLOW": "", "GITHUB_WORKFLOW": "",
"INPUT_ID": "1234567",
"INPUT_STEP_WITH": "with-value", "INPUT_STEP_WITH": "with-value",
"RC_KEY": "rcvalue", "RC_KEY": "rcvalue",
"RUNNER_ENVIRONMENT": "self-hosted", "RUNNER_ENVIRONMENT": "self-hosted",
@@ -214,7 +213,13 @@ env: ${{ fromJSON(matrix.env) }}
jobs: jobs:
test: test:
env: env:
PRIORITY: job PRIORITY: ${{ matrix.priority }}
defaults:
run: ${{ fromJSON(matrix.defaults) }}
container:
image: node:20
env:
CONTAINER: ${{ env.PRIORITY }}
steps: steps:
- uses: ./act - uses: ./act
env: env:
@@ -228,16 +233,22 @@ jobs:
for _, value := range []string{"first", "second"} { for _, value := range []string{"first", "second"} {
t.Run(value, func(t *testing.T) { t.Run(value, func(t *testing.T) {
t.Parallel() 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) 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)) 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{}} step := &stepRun{RunContext: rc, Step: job.Steps[0].Clone(), env: map[string]string{}}
require.NoError(t, setupEnv(t.Context(), step)) 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()) require.NoError(t, setupInputs(t.Context(), step))
assert.Equal(t, value, step.env["INPUT_NAME"]) 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, step.env["WORKFLOW"]) assert.Equal(t, value+"${{ github.job }}", step.env["INPUT_NAME"])
assert.Equal(t, "job", step.env["PRIORITY"]) 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, rawEnv, workflow.RawEnv)
assert.Equal(t, rawWith, job.Steps[0].RawWith) assert.Equal(t, rawWith, job.Steps[0].RawWith)
assert.Nil(t, workflow.Env) 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) rc, err := (&runnerImpl{config: &Config{}}).newRunContext(t.Context(), &model.Run{Workflow: workflow, JobID: "test"}, nil)
require.NoError(t, err) require.NoError(t, err)
rc.workflowCallInputs = map[string]any{"args": "${{ inputs.unresolved }}"} 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:")
}) })
} }
+1 -1
View File
@@ -7,7 +7,7 @@ toolchain go1.27.1
require ( require (
connectrpc.com/connect v1.21.0 connectrpc.com/connect v1.21.0
dario.cat/mergo v1.0.2 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/avast/retry-go/v5 v5.0.0
github.com/containerd/errdefs v1.0.0 github.com/containerd/errdefs v1.0.0
github.com/creack/pty v1.1.24 github.com/creack/pty v1.1.24
+2 -2
View File
@@ -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= 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 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
gitea.dev/actionslib v1.0.0 h1:l0oFJP+P4Ds1rlCI5zk618dYkuBc2mU7Gz5wPeG0lZY= gitea.dev/actionslib v1.2.0 h1:R/PBu9DwD04tCd5rjAWT9dAa+S6GOk1XnXCC3T2Rrnw=
gitea.dev/actionslib v1.0.0/go.mod h1:6O8YHkqVTKSR0LL2e5VhIDePYzGTZCbfmSVqJWEhk9g= 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 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= 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= github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
+5 -19
View File
@@ -41,6 +41,7 @@ import (
runnerv1 "gitea.dev/actionslib/runner/v1" runnerv1 "gitea.dev/actionslib/runner/v1"
docker_container "github.com/moby/moby/api/types/container" docker_container "github.com/moby/moby/api/types/container"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"go.yaml.in/yaml/v4"
) )
// CapabilityCancelling tells the server this runner understands the // 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 // Added per task because this job's service containers must be reached directly, and
// act reaches them by their workflow key. // 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) maps.Copy(envs, proxyEnv)
if r.capabilities != "" { if r.capabilities != "" {
@@ -428,24 +431,7 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
r.getDefaultActionsURL(task), r.getDefaultActionsURL(task),
r.client.Address()) r.client.Address())
preset := &model.GithubContext{ preset := model.GithubContextFromMap(task.Context.AsMap())
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(),
}
if t := task.Secrets["GITEA_TOKEN"]; t != "" { if t := task.Secrets["GITEA_TOKEN"]; t != "" {
preset.Token = t preset.Token = t
} else if t := task.Secrets["GITHUB_TOKEN"]; t != "" { } else if t := task.Secrets["GITHUB_TOKEN"]; t != "" {