mirror of
https://gitea.com/gitea/runner.git
synced 2026-11-05 14:55:44 +00:00
Removes dead code and redundant logic and consolidates tests, so there is less to read and maintain. No behavior change. 1. **act/runner**: inline single-use wrappers, compile regexes once instead of per call, embed the trampoline as a string, drop the unused legacy result of `parseActionCommand` 2. **act/container**: `sanitizeConfig` and `mergeContainerConfigs` stop returning the configs they already mutate in place, drop never-set option fields and the unused `user` parameter of `ExecWithCmdLine` 3. **act/common and cache server**: drop the unused `Trace`/`Tracef` loggers and the short SHA of `FindGitRevision`, `RegisterJob` releases through `RevokeJob` 4. **daemon, poller and reporter**: one shutdown wait for both poll modes, drop an unreachable fetch timeout branch, guard the reporter's last report time with its existing lock instead of an atomic 5. **build**: drop unused Makefile and release workflow variables, collapse the goreleaser ignore list to the same build targets 6. **tests**: fold near-duplicate tests into tables with shared setup Throughout, hand-rolled logic gives way to `cmp.Or`, `strings.Cut`, `slices` and `min`/`max`. Reviewed-on: https://gitea.com/gitea/runner/pulls/1335 Reviewed-by: bircni <[email protected]> Co-authored-by: silverwind <[email protected]>
489 lines
16 KiB
Go
489 lines
16 KiB
Go
// Copyright 2022 The Gitea Authors. All rights reserved.
|
|
// Copyright 2020 The nektos/act Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package runner
|
|
|
|
import (
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
|
|
"gitea.dev/actionslib/pkg/exprparser"
|
|
"gitea.dev/actionslib/pkg/model"
|
|
assert "github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
yaml "go.yaml.in/yaml/v4"
|
|
)
|
|
|
|
func createRunContext(t *testing.T) *RunContext {
|
|
var yml yaml.Node
|
|
err := yml.Encode(map[string][]any{
|
|
"os": {"Linux", "Windows"},
|
|
"foo": {"bar", "baz"},
|
|
})
|
|
assert.NoError(t, err)
|
|
|
|
return &RunContext{
|
|
Config: &Config{
|
|
Workdir: ".",
|
|
Secrets: map[string]string{
|
|
"CASE_INSENSITIVE_SECRET": "value",
|
|
},
|
|
Vars: map[string]string{
|
|
"CASE_INSENSITIVE_VAR": "value",
|
|
},
|
|
},
|
|
Env: map[string]string{
|
|
"key": "value",
|
|
},
|
|
Run: &model.Run{
|
|
JobID: "job1",
|
|
Workflow: &model.Workflow{
|
|
Name: "test-workflow",
|
|
Jobs: map[string]*model.Job{
|
|
"job1": {
|
|
Strategy: &model.Strategy{
|
|
RawMatrix: yml,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
Matrix: map[string]any{
|
|
"os": "Linux",
|
|
"foo": "bar",
|
|
},
|
|
StepResults: map[string]*model.StepResult{
|
|
"idwithnothing": {
|
|
Conclusion: model.StepStatusSuccess,
|
|
Outcome: model.StepStatusFailure,
|
|
Outputs: map[string]string{
|
|
"foowithnothing": "barwithnothing",
|
|
},
|
|
},
|
|
"id-with-hyphens": {
|
|
Conclusion: model.StepStatusSuccess,
|
|
Outcome: model.StepStatusFailure,
|
|
Outputs: map[string]string{
|
|
"foo-with-hyphens": "bar-with-hyphens",
|
|
},
|
|
},
|
|
"id_with_underscores": {
|
|
Conclusion: model.StepStatusSuccess,
|
|
Outcome: model.StepStatusFailure,
|
|
Outputs: map[string]string{
|
|
"foo_with_underscores": "bar_with_underscores",
|
|
},
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
func TestEvaluateRunContext(t *testing.T) {
|
|
rc := createRunContext(t)
|
|
ee := rc.NewExpressionEvaluator(t.Context())
|
|
|
|
tables := []struct {
|
|
in string
|
|
out any
|
|
}{
|
|
{" 1 ", 1.0},
|
|
{"'my text'", "my text"},
|
|
{"contains('my text', 'te')", true},
|
|
{"contains('my TEXT', 'te')", true},
|
|
{"contains(fromJSON('[\"my text\"]'), 'te')", false},
|
|
{"contains(fromJSON('[\"foo\",\"bar\"]'), 'bar')", true},
|
|
{"startsWith('hello world', 'He')", true},
|
|
{"endsWith('hello world', 'ld')", true},
|
|
{"format('0:{0} 2:{2} 1:{1}', 'zero', 'one', 'two')", "0:zero 2:two 1:one"},
|
|
{"join(fromJSON('[\"hello\"]'),'octocat')", "hello"},
|
|
{"join(fromJSON('[\"hello\",\"mona\",\"the\"]'),'octocat')", "hellooctocatmonaoctocatthe"},
|
|
{"join('hello','mona')", "hello"},
|
|
{"toJSON(env)", "{\n \"ACT\": \"true\",\n \"ACT_SKIP_CHECKOUT\": \"true\",\n \"key\": \"value\"\n}"},
|
|
{"toJson(env)", "{\n \"ACT\": \"true\",\n \"ACT_SKIP_CHECKOUT\": \"true\",\n \"key\": \"value\"\n}"},
|
|
{"(fromJSON('{\"foo\":\"bar\"}')).foo", "bar"},
|
|
{"(fromJson('{\"foo\":\"bar\"}')).foo", "bar"},
|
|
{"(fromJson('[\"foo\",\"bar\"]'))[1]", "bar"},
|
|
{"hashFiles('**/non-extant-files')", ""},
|
|
{"hashFiles('**/non-extant-files', '**/more-non-extant-files')", ""},
|
|
{"hashFiles('**/non.extant.files')", ""},
|
|
{"hashFiles('**/non''extant''files')", ""},
|
|
{"success()", true},
|
|
{"failure()", false},
|
|
{"always()", true},
|
|
{"cancelled()", false},
|
|
{"github.workflow", "test-workflow"},
|
|
{"github.actor", "nektos/act"},
|
|
{"github.run_id", "1"},
|
|
{"github.run_number", "1"},
|
|
{"job.status", "success"},
|
|
{"matrix.os", "Linux"},
|
|
{"matrix.foo", "bar"},
|
|
{"env.key", "value"},
|
|
{"secrets.CASE_INSENSITIVE_SECRET", "value"},
|
|
{"secrets.case_insensitive_secret", "value"},
|
|
{"vars.CASE_INSENSITIVE_VAR", "value"},
|
|
{"vars.case_insensitive_var", "value"},
|
|
{"format('{{0}}', 'test')", "{0}"},
|
|
{"format('{{{0}}}', 'test')", "{test}"},
|
|
{"format('}}')", "}"},
|
|
{"format('echo Hello {0} ${{Test}}', 'World')", "echo Hello World ${Test}"},
|
|
{"format('echo Hello {0} ${{Test}}', github.undefined_property)", "echo Hello ${Test}"},
|
|
{"format('echo Hello {0}{1} ${{Te{0}st}}', github.undefined_property, 'World')", "echo Hello World ${Test}"},
|
|
{"format('{0}', '{1}', 'World')", "{1}"},
|
|
{"format('{{{0}', '{1}', 'World')", "{{1}"},
|
|
}
|
|
|
|
for _, table := range tables {
|
|
t.Run(table.in, func(t *testing.T) {
|
|
out, err := ee.evaluate(t.Context(), table.in, exprparser.DefaultStatusCheckNone)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, table.out, out)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestEvaluateStep(t *testing.T) {
|
|
rc := createRunContext(t)
|
|
rc.Env["INPUT_FORGED"] = "leaked"
|
|
step := &stepRun{
|
|
RunContext: rc,
|
|
env: map[string]string{"INPUT_FORGED": "leaked"},
|
|
}
|
|
|
|
ee := rc.NewStepExpressionEvaluator(t.Context(), step)
|
|
|
|
tables := []struct {
|
|
in string
|
|
out any
|
|
}{
|
|
{"steps.idwithnothing.conclusion", model.StepStatusSuccess.String()},
|
|
{"steps.idwithnothing.outcome", model.StepStatusFailure.String()},
|
|
{"steps.idwithnothing.outputs.foowithnothing", "barwithnothing"},
|
|
{"steps.id-with-hyphens.conclusion", model.StepStatusSuccess.String()},
|
|
{"steps.id-with-hyphens.outcome", model.StepStatusFailure.String()},
|
|
{"steps.id-with-hyphens.outputs.foo-with-hyphens", "bar-with-hyphens"},
|
|
{"steps.id_with_underscores.conclusion", model.StepStatusSuccess.String()},
|
|
{"steps.id_with_underscores.outcome", model.StepStatusFailure.String()},
|
|
{"steps.id_with_underscores.outputs.foo_with_underscores", "bar_with_underscores"},
|
|
{"inputs.forged", nil},
|
|
}
|
|
|
|
for _, table := range tables {
|
|
t.Run(table.in, func(t *testing.T) {
|
|
out, err := ee.evaluate(t.Context(), table.in, exprparser.DefaultStatusCheckNone)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, table.out, out)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestInterpolate(t *testing.T) {
|
|
rc := &RunContext{
|
|
Config: &Config{
|
|
Workdir: ".",
|
|
Secrets: map[string]string{
|
|
"CASE_INSENSITIVE_SECRET": "value",
|
|
},
|
|
Vars: map[string]string{
|
|
"CASE_INSENSITIVE_VAR": "value",
|
|
},
|
|
},
|
|
Env: map[string]string{
|
|
"KEYWITHNOTHING": "valuewithnothing",
|
|
"KEY-WITH-HYPHENS": "value-with-hyphens",
|
|
"KEY_WITH_UNDERSCORES": "value_with_underscores",
|
|
"SOMETHING_TRUE": "true",
|
|
"SOMETHING_FALSE": "false",
|
|
},
|
|
Run: &model.Run{
|
|
JobID: "job1",
|
|
Workflow: &model.Workflow{
|
|
Name: "test-workflow",
|
|
Jobs: map[string]*model.Job{
|
|
"job1": {},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
ee := rc.NewExpressionEvaluator(t.Context())
|
|
tables := []struct {
|
|
in string
|
|
out string
|
|
}{
|
|
{" text ", " text "},
|
|
{" $text ", " $text "},
|
|
{" ${text} ", " ${text} "},
|
|
{" ${{ 1 }} to ${{2}} ", " 1 to 2 "},
|
|
{" ${{ (true || false) }} to ${{2}} ", " true to 2 "},
|
|
{" ${{ (false || '}}' ) }} to ${{2}} ", " }} to 2 "},
|
|
{" ${{ env.KEYWITHNOTHING }} ", " valuewithnothing "},
|
|
{" ${{ env.KEY-WITH-HYPHENS }} ", " value-with-hyphens "},
|
|
{" ${{ env.KEY_WITH_UNDERSCORES }} ", " value_with_underscores "},
|
|
{"${{ secrets.CASE_INSENSITIVE_SECRET }}", "value"},
|
|
{"${{ secrets.case_insensitive_secret }}", "value"},
|
|
{"${{ vars.CASE_INSENSITIVE_VAR }}", "value"},
|
|
{"${{ vars.case_insensitive_var }}", "value"},
|
|
{"${{ env.UNKNOWN }}", ""},
|
|
{"${{ env.SOMETHING_TRUE }}", "true"},
|
|
{"${{ env.SOMETHING_FALSE }}", "false"},
|
|
{"${{ !env.SOMETHING_TRUE }}", "false"},
|
|
{"${{ !env.SOMETHING_FALSE }}", "false"},
|
|
{"${{ !env.SOMETHING_TRUE && true }}", "false"},
|
|
{"${{ !env.SOMETHING_FALSE && true }}", "false"},
|
|
{"${{ env.SOMETHING_TRUE && true }}", "true"},
|
|
{"${{ env.SOMETHING_FALSE && true }}", "true"},
|
|
{"${{ !env.SOMETHING_TRUE || true }}", "true"},
|
|
{"${{ !env.SOMETHING_FALSE || true }}", "true"},
|
|
{"${{ !env.SOMETHING_TRUE && false }}", "false"},
|
|
{"${{ !env.SOMETHING_FALSE && false }}", "false"},
|
|
{"${{ !env.SOMETHING_TRUE || false }}", "false"},
|
|
{"${{ !env.SOMETHING_FALSE || false }}", "false"},
|
|
{"${{ env.SOMETHING_TRUE || false }}", "true"},
|
|
{"${{ env.SOMETHING_FALSE || false }}", "false"},
|
|
{"${{ env.SOMETHING_FALSE }} && ${{ env.SOMETHING_TRUE }}", "false && true"},
|
|
{"${{ fromJSON('{}') < 2 }}", "false"},
|
|
{"${{ 1 }}", "1"},
|
|
{"${{ 1.0 }}", "1"},
|
|
{"${{ null }}", ""},
|
|
{"${{ fromJSON('[1,2]') }}", "Array"},
|
|
{"${{ fromJSON('{\"a\":1}') }}", "Object"},
|
|
}
|
|
|
|
for _, table := range tables {
|
|
t.Run("interpolate", func(t *testing.T) {
|
|
out, err := ee.Interpolate(t.Context(), table.in)
|
|
require.NoError(t, err, table.in)
|
|
assert.Equal(t, table.out, out, table.in)
|
|
})
|
|
}
|
|
|
|
for _, in := range []string{"${{ 1) && (2 }}", "run ${{ 1) && (2 }} now", "${{ 1"} {
|
|
_, err := ee.Interpolate(t.Context(), in)
|
|
assert.Error(t, err, in)
|
|
}
|
|
}
|
|
|
|
func TestGetEvaluatorInputsBoolean(t *testing.T) {
|
|
workflow := func(event string) string {
|
|
return "on:\n " + event + `:
|
|
inputs:
|
|
flag:
|
|
type: boolean
|
|
default: true
|
|
name:
|
|
type: string
|
|
default: gitea
|
|
unset:
|
|
type: boolean
|
|
`
|
|
}
|
|
|
|
tables := []struct {
|
|
name string
|
|
event map[string]any
|
|
flag any
|
|
}{
|
|
{
|
|
name: "native bool true",
|
|
event: map[string]any{"inputs": map[string]any{"flag": true}},
|
|
flag: true,
|
|
},
|
|
{
|
|
name: "native bool false",
|
|
event: map[string]any{"inputs": map[string]any{"flag": false}},
|
|
flag: false,
|
|
},
|
|
{
|
|
name: "string true",
|
|
event: map[string]any{"inputs": map[string]any{"flag": "true"}},
|
|
flag: true,
|
|
},
|
|
{
|
|
name: "string false",
|
|
event: map[string]any{"inputs": map[string]any{"flag": "false"}},
|
|
flag: false,
|
|
},
|
|
{
|
|
name: "default is used when the event carries no inputs",
|
|
event: map[string]any{},
|
|
flag: true,
|
|
},
|
|
}
|
|
|
|
for _, eventName := range []string{"workflow_call", "workflow_dispatch"} {
|
|
for _, table := range tables {
|
|
t.Run(eventName+"/"+table.name, func(t *testing.T) {
|
|
wf, err := model.ReadWorkflow(strings.NewReader(workflow(eventName)))
|
|
require.NoError(t, err)
|
|
|
|
rc := &RunContext{
|
|
Config: &Config{Workdir: "."},
|
|
Run: &model.Run{JobID: "job1", Workflow: wf},
|
|
}
|
|
ghc := &model.GithubContext{EventName: eventName, Event: table.event}
|
|
|
|
inputs := getEvaluatorInputs(rc, nil, ghc)
|
|
assert.Equal(t, table.flag, inputs["flag"])
|
|
assert.Equal(t, "gitea", inputs["name"])
|
|
assert.Equal(t, false, inputs["unset"])
|
|
})
|
|
}
|
|
}
|
|
|
|
t.Run("deferred workflow call inputs", func(t *testing.T) {
|
|
workflow, err := model.ReadWorkflow(strings.NewReader(workflow("workflow_call") + `
|
|
jobs:
|
|
test: {}
|
|
call:
|
|
uses: ./reuse.yml
|
|
with: ${{ fromJSON(matrix.args) }}
|
|
`))
|
|
require.NoError(t, err)
|
|
workflow.GetJob("test")
|
|
job := workflow.GetJob("call")
|
|
rawWith := model.CloneYamlNode(job.RawWith)
|
|
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' }}"}`})
|
|
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' }}", "unset": false}, child.workflowCallInputs)
|
|
assert.Equal(t, rawWith, job.RawWith)
|
|
assert.Nil(t, job.With)
|
|
})
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestGetEvaluatorInputsResolvedByGitea(t *testing.T) {
|
|
wf, err := model.ReadWorkflow(strings.NewReader(`
|
|
on:
|
|
workflow_call:
|
|
inputs:
|
|
name: {type: string, default: callee}
|
|
workflow_dispatch:
|
|
inputs:
|
|
name: {type: string}
|
|
`))
|
|
require.NoError(t, err)
|
|
config := &Config{Workdir: ".", WorkflowCallInputs: map[string]any{"name": "caller", "target": "dispatch"}}
|
|
ghc := &model.GithubContext{EventName: "workflow_dispatch", Event: map[string]any{"inputs": map[string]any{"name": "dispatch"}}}
|
|
|
|
job := &RunContext{Config: config, Run: &model.Run{JobID: "job1", Workflow: wf}}
|
|
assert.Equal(t, map[string]any{"name": "caller", "target": "dispatch"}, getEvaluatorInputs(job, nil, ghc))
|
|
|
|
composite := &RunContext{Config: config, Run: &model.Run{JobID: "job1", Workflow: &model.Workflow{}}, Parent: job}
|
|
assert.Equal(t, map[string]any{"arg": "x"}, getEvaluatorInputs(composite, map[string]any{"arg": "x"}, ghc))
|
|
}
|
|
|
|
func TestActionInputsShadowDispatchInputs(t *testing.T) {
|
|
wf, err := model.ReadWorkflow(strings.NewReader(`
|
|
on:
|
|
workflow_dispatch:
|
|
inputs:
|
|
message: {type: string}
|
|
jobs:
|
|
job1: {}
|
|
`))
|
|
require.NoError(t, err)
|
|
rc := newTestRC(wf, nil)
|
|
rc.Config.EventName = "workflow_dispatch"
|
|
rc.EventJSON = `{"inputs":{"message":"workflow"}}`
|
|
step := &stepActionRemote{RunContext: rc, env: map[string]string{"INPUT_MESSAGE": "step"}}
|
|
assert.Equal(t, "step", rc.NewActionInputsExpressionEvaluator(t.Context(), step).InterpolateName(t.Context(), "${{ inputs.message }}"))
|
|
assert.Equal(t, "workflow", rc.NewStepExpressionEvaluator(t.Context(), step).InterpolateName(t.Context(), "${{ inputs.message }}"))
|
|
enabled, err := isStepEnabled(t.Context(), "inputs.message == 'workflow'", step, stepStageMain)
|
|
require.NoError(t, err)
|
|
assert.True(t, enabled)
|
|
step.Step, step.action = &model.Step{}, &model.Action{Inputs: map[string]model.Input{"fallback": {Default: "${{ inputs.message }}"}}}
|
|
env, err := evaluateCompositeInputAndEnv(t.Context(), rc, step)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "workflow", env["INPUT_FALLBACK"])
|
|
}
|
|
|
|
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:
|
|
a:
|
|
name: deploy ${{ secrets.A }}
|
|
b:
|
|
name: deploy ${{ secrets.B }}
|
|
`))
|
|
require.NoError(t, err)
|
|
|
|
runner := &runnerImpl{config: &Config{Secrets: map[string]string{"A": "s3cr3t-a", "B": "s3cr3t-b"}}}
|
|
containerName := func(jobID string) string {
|
|
rc, err := runner.newRunContext(t.Context(), &model.Run{JobID: jobID, Workflow: workflow}, nil)
|
|
require.NoError(t, err)
|
|
assert.NotContains(t, rc.Name, "s3cr3t")
|
|
return rc.jobContainerName()
|
|
}
|
|
|
|
a, b := containerName("a"), containerName("b")
|
|
assert.NotContains(t, a, "s3cr3t")
|
|
assert.NotEqual(t, a, b)
|
|
}
|
|
|
|
func TestNewExpressionEvaluatorReadsJobOutputsWhileCombinationsWriteThem(t *testing.T) {
|
|
rc := createRunContext(t)
|
|
rc.caller = &caller{runContext: createRunContext(t)}
|
|
rc.ExprEval = rc.NewExpressionEvaluator(t.Context())
|
|
job := rc.Run.Job()
|
|
var writes sync.WaitGroup
|
|
writes.Go(func() {
|
|
for range 100 {
|
|
unlock := lockJob(job)
|
|
job.Outputs = map[string]string{"out": "value"}
|
|
unlock()
|
|
}
|
|
})
|
|
for range 100 {
|
|
rc.NewExpressionEvaluator(t.Context())
|
|
}
|
|
writes.Wait()
|
|
}
|