mirror of
https://gitea.com/gitea/act_runner.git
synced 2026-10-29 03:16:43 +08:00
Contains a number of small bug fixes where jobs behaved differently from GitHub or failed silently.
1. Hooks fail on a refused `::set-env::` or `::add-path::` and no longer inherit the last step's identity
1. Docker build, exec and wait errors are returned instead of swallowed, token-only registry credentials work, logs of `--tty` services decode
1. Env files split lines like GitHub on each OS, so a carriage return is kept on Linux and CRLF ends a line on Windows, empty heredoc names or delimiters are rejected
1. Post steps run after their pre step even when main was skipped, composites see undeclared inputs, `runs.args` wins without `with.args` being parsed
1. Docker actions skip evaluating defaults of inputs given in `with` and read `entryPoint` and `args` in any case, a lowercase `dockerfile` is found, undeclared inputs get GitHub's warning
1. A user-set `CI` is kept, `GITHUB_REPOSITORY_ID` and the other missing `GITHUB_*` variables are exported
1. `timeout-minutes` can read the step's own `env`, a step-level `ACTIONS_ALLOW_UNSECURE_COMMANDS: false` beats a job-level `true` while the runner's own opt-in still wins, `pwsh` quotes the script path
1. A step `env:` given as one expression such as `${{ fromJSON(matrix.env) }}` is applied, `hashFiles` fails with GitHub's messages on script errors and after 120 seconds, a job image without node still gets an empty hash
1. A step whose command files cannot be reset fails instead of importing stale ones, a timeout between steps fails the job instead of reporting success
1. One named volume can be mounted at several paths, host-mode PTY steps keep their last output when they fail, host scratch directories are removed when job setup fails
1. Cancelling a step right after it starts kills its whole process tree
1. Cache prefix restores pick the newest entry created within the same second, also on S3, cache commits with a wrong size are rejected
1. Artifact chunks are written at their offset, download URLs are escaped, listing a run without artifacts no longer crashes
1. Poll backoff reaches `fetch_interval_max`, proxy passwords are masked in more forms, secrets are also masked in step names and in their quoted form in the runner log, a failed final report is logged
1. On Windows hosts a differently cased unsecure-commands opt-in or proxy variable counts, and an early cancel no longer leaks a job object handle
1. Interactive `register` prompts for a missing token and stops on SIGTERM, `exec --list --detect-event` no longer panics without events, `config set` works on `cache.s3` keys
Reviewed-on: https://gitea.com/gitea/runner/pulls/1338
Reviewed-by: bircni <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
286 lines
13 KiB
Go
286 lines
13 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 (
|
|
"io"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
|
|
"gitea.com/gitea/runner/act/common"
|
|
"gitea.com/gitea/runner/act/common/logging"
|
|
"gitea.com/gitea/runner/act/common/logging/logtest"
|
|
|
|
"gitea.dev/actionslib/pkg/model"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func TestParseActionCommand(t *testing.T) {
|
|
for _, test := range []struct {
|
|
name, line, resumeCommand, command string
|
|
properties map[string]string
|
|
data string
|
|
ok bool
|
|
}{
|
|
{"plain", "plain output", "", "", nil, "", false},
|
|
{"unknown v2", "::unknown::v", "", "", nil, "", false},
|
|
{"missing v2 terminator", "::set-output name=x", "", "", nil, "", false},
|
|
{"empty command", "::::", "", "", nil, "", false},
|
|
{"text before v2", "debug ::set-output name=x::v", "", "", nil, "", false},
|
|
{"missing legacy terminator", "##[debug", "", "", nil, "", false},
|
|
{"tab is not a name separator", "::set-output\tname=x::v", "", "", nil, "", false},
|
|
{"v2 leading unicode whitespace and line endings", " \t\u2003::set-output name=tab::v\r\n\r\n", "", "set-output", map[string]string{"name": "tab"}, "v", true},
|
|
{"legacy prefix and casing", "prefix ##[SET-OUTPUT NAME=mid;]v\r\n", "", "set-output", map[string]string{"name": "mid"}, "v", true},
|
|
{"only first legacy prefix", "##[unknown]##[set-output name=x]v", "", "", nil, "", false},
|
|
{"v2 delimiter boundaries", "::set-output name=a:b,other=c:d:: a::b ", "", "set-output", map[string]string{"name": "a:b", "other": "c:d"}, " a::b ", true},
|
|
{"legacy first closing bracket", "##[set-output name=x]v]tail", "", "set-output", map[string]string{"name": "x"}, "v]tail", true},
|
|
{"v2 semicolons are literal", "::set-output name=x;y=z::v", "", "set-output", map[string]string{"name": "x;y=z"}, "v", true},
|
|
{"legacy commas are literal", "##[set-output name=x,y=z]v", "", "set-output", map[string]string{"name": "x,y=z"}, "v", true},
|
|
{"values keep their leading equals", "::set-output name==x=y,=key=v,empty=,=x,spaced= =x::v", "", "set-output", map[string]string{"name": "=x=y", "key": "v", "spaced": " =x"}, "v", true},
|
|
{"v2 trims whole list only", "::set-output \tfile=x, line=2,name = value \t::v", "", "set-output", map[string]string{"file": "x", " line": "2", "name ": " value"}, "v", true},
|
|
{"legacy preserves list whitespace", "##[set-output name=q ]v", "", "set-output", map[string]string{" name": "q "}, "v", true},
|
|
{"case insensitive duplicate keys", "::SET-OUTPUT NAME=first,Name=last::v", "", "set-output", map[string]string{"name": "last"}, "v", true},
|
|
{"keys stay escaped", "::set-output na%3Ame=x::v", "", "set-output", map[string]string{"na%3ame": "x"}, "v", true},
|
|
{"v2 decode table", "::set-output name=%0D%0A%3A%2C%25%253A%252C%250A%2525%3a%2c%0d%0a%3B%5D::%0D%0A%25%3A%2C%3B%5D%250A", "", "set-output", map[string]string{"name": "\r\n:,%%3A%2C%0A%25%3a%2c%0d%0a%3B%5D"}, "\r\n%%3A%2C%3B%5D%0A", true},
|
|
{"legacy decode table", "##[set-output name=%3B%0D%0A%5D%25%253B%255D%250A%2525%3b%5d%0d%0a%3A%2C]v%5Dw%3Bx", "", "set-output", map[string]string{"name": ";\r\n]%%3B%5D%0A%25%3b%5d%0d%0a%3A%2C"}, "v]w;x", true},
|
|
{"unknown v2 falls back to legacy", "::unknown::prefix ##[set-output name=x]v", "", "set-output", map[string]string{"name": "x"}, "v", true},
|
|
{"unterminated v2 falls back to legacy", "::debug ##[set-output name=x]v", "", "set-output", map[string]string{"name": "x"}, "v", true},
|
|
{"v2 wins over legacy", "::debug::##[set-output name=x]v", "", "debug", map[string]string{}, "##[set-output name=x]v", true},
|
|
{"internal line breaks are not commands", "::debug::hidden\rplain\r\n", "", "", nil, "", false},
|
|
{"v2 resume token", "::ReSuMe-ToKeN::", "RESUME-TOKEN", "resume-token", map[string]string{}, "", true},
|
|
{"legacy resume token", "prefix ##[RESUME-TOKEN]v", "resume-token", "resume-token", map[string]string{}, "v", true},
|
|
{"different resume token", "::other-token::", "resume-token", "", nil, "", false},
|
|
} {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
command, properties, data, ok := parseActionCommand(test.line, test.resumeCommand)
|
|
assert.Equal(t, test.command, command)
|
|
assert.Equal(t, test.properties, properties)
|
|
assert.Equal(t, test.data, data)
|
|
assert.Equal(t, test.ok, ok)
|
|
})
|
|
}
|
|
}
|
|
|
|
// unsecureRC opts into ::set-env:: and ::add-path::, which are refused without it.
|
|
func unsecureRC() *RunContext {
|
|
return &RunContext{Config: &Config{}, Env: map[string]string{allowUnsecureCommandsVar: "true"}}
|
|
}
|
|
|
|
func TestSetEnv(t *testing.T) {
|
|
a := assert.New(t)
|
|
logger, hook := logtest.NewLogger()
|
|
ctx := common.WithLogger(t.Context(), logger)
|
|
rc := unsecureRC()
|
|
handler := rc.commandHandler(ctx)
|
|
|
|
handler("::set-env name=x::valz\n")
|
|
a.Equal("valz", rc.Env["x"])
|
|
handler("::SET-ENV name=NODE_OPTIONS::--require command.js\n")
|
|
rc.setEnvFile(ctx, map[string]string{"name": "node_options"}, "--require env.js")
|
|
a.NotContains(rc.Env, "NODE_OPTIONS")
|
|
a.NotContains(rc.Env, "node_options")
|
|
entries := hook.AllRecords()
|
|
require.Len(t, entries, 2)
|
|
a.Equal("##[error]Can't update NODE_OPTIONS environment variable using ::set-env:: command.", entries[0].Message)
|
|
a.Equal("##[error]Can't store NODE_OPTIONS output parameter using '$GITHUB_ENV' command.", entries[1].Message)
|
|
}
|
|
|
|
func TestSetOutput(t *testing.T) {
|
|
rc := &RunContext{Config: &Config{}, CurrentStep: "my-step", StepResults: map[string]*model.StepResult{"my-step": {Outputs: map[string]string{}}}}
|
|
handler := rc.commandHandler(t.Context())
|
|
|
|
handler("::set-output NAME=modern%3A::v%0D%0A%25%3B%5D%250A\n")
|
|
handler("##[set-output name=legacy%3B%5D]v%0D%0A%25%3B%5D%250A\n")
|
|
assert.Equal(t, map[string]string{"modern:": "v\r\n%%3B%5D%0A", "legacy;]": "v\r\n%;]%0A"}, rc.StepResults["my-step"].Outputs)
|
|
}
|
|
|
|
func TestAddpath(t *testing.T) {
|
|
for _, prefix := range []string{"::add-path::", "##[add-path]"} {
|
|
t.Run(prefix, func(t *testing.T) {
|
|
rc := unsecureRC()
|
|
handler := rc.commandHandler(t.Context())
|
|
handler(prefix + "/zoo\n")
|
|
handler(prefix + "/boo\n")
|
|
assert.Equal(t, []string{"/boo", "/zoo"}, rc.ExtraPath)
|
|
handler(prefix + "/zoo\n")
|
|
assert.Equal(t, []string{"/zoo", "/boo"}, rc.ExtraPath)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSaveState(t *testing.T) {
|
|
rc := &RunContext{
|
|
Config: &Config{},
|
|
CurrentStep: "step",
|
|
StepResults: map[string]*model.StepResult{},
|
|
}
|
|
|
|
ctx := t.Context()
|
|
|
|
handler := rc.commandHandler(ctx)
|
|
handler("::save-state name=state-name::state-value\n")
|
|
|
|
assert.Equal(t, "state-value", rc.IntraActionState["step"]["state-name"])
|
|
}
|
|
|
|
func TestEscapeCommandData(t *testing.T) {
|
|
a := assert.New(t)
|
|
|
|
a.Equal("a%25b%0Dc%0Ad%250A", EscapeCommandData("a%b\rc\nd%0A"))
|
|
a.Equal("a%b\rc\nd%0A", UnescapeCommandData("a%25b%0Dc%0Ad%250A"))
|
|
}
|
|
|
|
func TestUnsecureCommands(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
jobEnv map[string]string
|
|
globalEnv map[string]string
|
|
stepEnv map[string]string
|
|
processEnv string
|
|
caseInsensitive bool
|
|
optedIn bool
|
|
}{
|
|
{name: "refused with no opt-in"},
|
|
{name: "refused for a value bool.TryParse rejects", jobEnv: map[string]string{allowUnsecureCommandsVar: "1"}},
|
|
{name: "opted in through the step environment", stepEnv: map[string]string{allowUnsecureCommandsVar: "true"}, optedIn: true},
|
|
{name: "opted in through the job environment", jobEnv: map[string]string{allowUnsecureCommandsVar: "TRUE"}, optedIn: true},
|
|
{name: "step opt-out overrides the job and global environment", jobEnv: map[string]string{allowUnsecureCommandsVar: "true"}, globalEnv: map[string]string{allowUnsecureCommandsVar: "true"}, stepEnv: map[string]string{allowUnsecureCommandsVar: "false"}},
|
|
{name: "the runner's own opt-in overrides a step opt-out", stepEnv: map[string]string{allowUnsecureCommandsVar: "false"}, processEnv: "true", optedIn: true},
|
|
{name: "a differently cased opt-in counts where env names are case-insensitive", jobEnv: map[string]string{"Actions_Allow_Unsecure_Commands": "true"}, caseInsensitive: true, optedIn: true},
|
|
{name: "a differently cased opt-in is ignored where env names are case-sensitive", jobEnv: map[string]string{"Actions_Allow_Unsecure_Commands": "true"}},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Setenv(allowUnsecureCommandsVar, tt.processEnv)
|
|
a := assert.New(t)
|
|
rc := &RunContext{Config: &Config{}, Env: tt.jobEnv, GlobalEnv: tt.globalEnv, JobContainer: fakeContainer{caseInsensitive: tt.caseInsensitive}}
|
|
rc.setCurrentStepEnv(tt.stepEnv)
|
|
handler := rc.commandHandler(t.Context())
|
|
|
|
a.False(handler("::set-env name=x::valz\n"))
|
|
a.False(handler("::add-path::/opt/bin\n"))
|
|
|
|
if !tt.optedIn {
|
|
a.Empty(rc.Env["x"])
|
|
a.Empty(rc.ExtraPath)
|
|
require.ErrorContains(t, rc.takeUnsecureCommandError(), "set-env")
|
|
a.NoError(rc.takeUnsecureCommandError())
|
|
return
|
|
}
|
|
a.Equal("valz", rc.Env["x"])
|
|
a.Equal([]string{"/opt/bin"}, rc.ExtraPath)
|
|
a.NoError(rc.takeUnsecureCommandError())
|
|
})
|
|
}
|
|
}
|
|
|
|
type maskRecorder struct {
|
|
logging.Handler
|
|
masks []string
|
|
}
|
|
|
|
func (m *maskRecorder) AddMask(mask string) { m.masks = append(m.masks, mask) }
|
|
|
|
func TestCommandLogWriter(t *testing.T) {
|
|
for _, tt := range []struct {
|
|
name string
|
|
debug bool
|
|
steps [][]string
|
|
log []string
|
|
masks []string
|
|
}{
|
|
{
|
|
name: "applied commands are hidden unless echoed",
|
|
steps: [][]string{{
|
|
"::set-env name=x::y", "::add-path::/bin", "::set-output name=a::b", "##[save-state name=a]b", "::add-matcher::m.json", "::remove-matcher owner=m::",
|
|
"::echo::On%0A",
|
|
"::set-env name=x::y", "::add-path::/bin", "::set-output name=a::b", "##[save-state name=a]b", "::add-matcher::m.json", "::remove-matcher owner=m::",
|
|
"::ADD-MASK::my::secret", "::echo::bogus", "::echo::off", "::add-mask ::empty-properties", "::unknown::v", "plain",
|
|
}},
|
|
log: []string{
|
|
"::set-env name=x::y", "::add-path::/bin", "::set-output name=a::b", "##[save-state name=a]b", "::add-matcher::m.json", "::remove-matcher owner=m::",
|
|
"::add-mask::***", "::echo::bogus", "::echo::off", "::unknown::v", "plain",
|
|
},
|
|
masks: []string{"my::secret", "empty-properties"},
|
|
},
|
|
{
|
|
name: "step debug shows debug lines and echoes commands until echo is off",
|
|
debug: true,
|
|
steps: [][]string{{"::debug::first%0Asecond", "::set-output name=a::b", "::echo::off", "::debug::again", "::set-output name=a::b"}},
|
|
log: []string{"::debug::first%0Asecond", "::set-output name=a::b", "::echo::off", "::debug::again"},
|
|
},
|
|
{
|
|
name: "debug lines are hidden without step debug and an invalid echo changes nothing",
|
|
steps: [][]string{{"::echo::bogus", "::set-output name=a::b", "::echo::on", "::debug::d"}},
|
|
},
|
|
{
|
|
name: "annotations fold their location once whether echoed or not",
|
|
steps: [][]string{{
|
|
"::echo::on",
|
|
"::error file=main.go,line=12,col=5,title=vet::undefined: x",
|
|
"::warning File=a%3Ab.go,Line=1,column=2,title=100%252C::50%25%0Anext",
|
|
"::warning file=x,line=1,col=2,column=3::m",
|
|
"::notice line=9,col=4,title=title::m",
|
|
"::error file=x,col=2::m",
|
|
"::error file=x,line=1::m",
|
|
"::notice::std::vector",
|
|
"##[error file=x%3By]m%5D",
|
|
}},
|
|
log: []string{
|
|
"::error::main.go:12:5: vet: undefined: x", "::warning::a:b.go:1:2: 100%252C: 50%25%0Anext", "::warning::x:1:2: m",
|
|
"::notice::title: m", "::error::x: m", "::error::x:1: m", "::notice::std::vector", "::error::x;y: m]",
|
|
},
|
|
},
|
|
{
|
|
name: "stop-commands keeps lines verbatim until resumed",
|
|
steps: [][]string{{"::stop-commands::resume-token", "::add-mask::kept", "::set-output name=a::b", "::RESUME-TOKEN::", "::add-mask::masked"}},
|
|
log: []string{"::stop-commands::resume-token", "::add-mask::kept", "::set-output name=a::b", "::RESUME-TOKEN::"},
|
|
masks: []string{"resume-token", "masked"},
|
|
},
|
|
{
|
|
name: "a short stop token naming a command stays unmasked and resumes in any case",
|
|
steps: [][]string{{"::stop-commands::NOTICE", "::notice::", "::add-mask::masked"}},
|
|
log: []string{"::stop-commands::NOTICE", "::notice::"},
|
|
masks: []string{"masked"},
|
|
},
|
|
{
|
|
name: "state does not carry over to the next step",
|
|
steps: [][]string{{"::echo::on", "::stop-commands::token77"}, {"::set-output name=a::b", "::add-mask::masked"}},
|
|
log: []string{"::stop-commands::token77"},
|
|
masks: []string{"token77", "masked"},
|
|
},
|
|
} {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
logger, hook := logtest.NewLogger()
|
|
recorder := &maskRecorder{}
|
|
ctx := common.WithLoggerHandler(common.WithLogger(t.Context(), logger), recorder)
|
|
rc := &RunContext{
|
|
Config: &Config{},
|
|
Parent: &RunContext{Config: &Config{Secrets: map[string]string{"ACTIONS_STEP_DEBUG": strconv.FormatBool(tt.debug)}}},
|
|
Env: map[string]string{allowUnsecureCommandsVar: "true"},
|
|
CurrentStep: "step",
|
|
StepResults: map[string]*model.StepResult{"step": {Outputs: map[string]string{}}},
|
|
}
|
|
for _, lines := range tt.steps {
|
|
writer := rc.commandLogWriter(ctx)
|
|
for _, line := range lines {
|
|
_, err := io.WriteString(writer, line+"\n")
|
|
require.NoError(t, err)
|
|
}
|
|
}
|
|
|
|
var log []string
|
|
for _, record := range hook.AllRecords() {
|
|
log = append(log, strings.TrimSuffix(record.Message, "\n"))
|
|
}
|
|
assert.Equal(t, tt.log, log)
|
|
assert.Equal(t, tt.masks, recorder.masks)
|
|
assert.Equal(t, tt.masks, rc.Masks)
|
|
})
|
|
}
|
|
}
|