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