mirror of
https://gitea.com/gitea/act_runner.git
synced 2026-11-03 05:45:12 +08:00
fix: handle workflow commands like GitHub's runner (#1333)
Commands the runner applies itself, like the `::set-output::` older actions still print, showed up raw in the job log. The reporter also re-parsed every line with one job-wide stop-commands and echo state, while act keeps one command handler per step, stage, composite child and hook, as GitHub does. A `::stop-commands::` left active in one of them kept the reporter suppressed for the others, so their `::add-mask::` values were never registered, and post steps logged command lines twice. - act's command handler decides what reaches the log, as GitHub's runner does: applied commands only under `ACTIONS_STEP_DEBUG` or `::echo::on`, `::stop-commands::` and its resume line shown with long tokens masked - Masks go straight to the reporter, which no longer parses command lines - One parser follows GitHub's rules: leading whitespace, `##[...]` anywhere in a line, registered commands only - Service logs honour only `::add-mask::` --------- Co-authored-by: bircni <bircni@icloud.com> Reviewed-on: https://gitea.com/gitea/runner/pulls/1333 Reviewed-by: bircni <bircni@icloud.com> Co-authored-by: silverwind <me@silverwind.io>
This commit is contained in:
@@ -241,14 +241,7 @@ func (rc *RunContext) compositeExecutor(action *model.Action) *compositeSteps {
|
||||
|
||||
func (rc *RunContext) newCompositeCommandExecutor(executor common.Executor) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
ctx = WithCompositeLogger(ctx, &rc.Masks)
|
||||
|
||||
logWriter := rc.commandLogWriter(ctx)
|
||||
|
||||
oldout, olderr := rc.JobContainer.ReplaceLogWriter(logWriter, logWriter)
|
||||
defer rc.JobContainer.ReplaceLogWriter(oldout, olderr)
|
||||
|
||||
return executor(ctx)
|
||||
return rc.withCommandLogWriter(WithCompositeLogger(ctx, &rc.Masks), executor)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,61 +5,72 @@
|
||||
package runner
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
)
|
||||
|
||||
var commandPatternGA *regexp.Regexp
|
||||
|
||||
var commandPatternADO *regexp.Regexp
|
||||
|
||||
func init() {
|
||||
commandPatternGA = regexp.MustCompile("^::([^ ]+?)( (.+?))?::([^\r\n]*)[\r\n]*$")
|
||||
// excluding ']' ends the command info at the first bracket, as GitHub does
|
||||
commandPatternADO = regexp.MustCompile("^##\\[([^ \\]]+)( ([^\\]]*))?]([^\r\n]*)[\r\n]*$")
|
||||
}
|
||||
|
||||
func tryParseRawActionCommand(line string) (command string, kvPairs map[string]string, arg string, ok bool) {
|
||||
command, kvPairs, arg, _, ok = tryParseActionCommand(line)
|
||||
return command, kvPairs, arg, ok
|
||||
}
|
||||
|
||||
func tryParseActionCommand(line string) (command string, kvPairs map[string]string, arg string, legacy, ok bool) {
|
||||
if m := commandPatternGA.FindStringSubmatch(line); m != nil {
|
||||
command = m[1]
|
||||
kvPairs = parseKeyValuePairs(m[3], ",")
|
||||
arg = m[4]
|
||||
ok = true
|
||||
} else if m := commandPatternADO.FindStringSubmatch(line); m != nil {
|
||||
command = m[1]
|
||||
kvPairs = parseKeyValuePairs(m[3], ";")
|
||||
arg = m[4]
|
||||
legacy = true
|
||||
ok = true
|
||||
// parseActionCommand parses like GitHub's runner, but rejects a bare \r GitHub would split at and keeps a value's leading '=' so masks match.
|
||||
func parseActionCommand(line, resumeCommand string) (command string, properties map[string]string, data string, legacy, ok bool) {
|
||||
line = strings.TrimRight(line, "\r\n")
|
||||
if strings.ContainsAny(line, "\r\n") {
|
||||
return "", nil, "", false, false
|
||||
}
|
||||
return command, kvPairs, arg, legacy, ok
|
||||
trimmed := strings.TrimLeftFunc(line, unicode.IsSpace)
|
||||
for _, legacy := range []bool{false, true} {
|
||||
header, found := strings.CutPrefix(trimmed, "::")
|
||||
separator, propertySeparator, unescapeProperty, unescapeData := "::", ",", commandPropertyUnescaper.Replace, UnescapeCommandData
|
||||
if legacy {
|
||||
_, header, found = strings.Cut(line, "##[")
|
||||
separator, propertySeparator, unescapeProperty, unescapeData = "]", ";", UnescapeLegacyCommand, UnescapeLegacyCommand
|
||||
}
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
header, data, found := strings.Cut(header, separator)
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
command, propertyList, _ := strings.Cut(header, " ")
|
||||
command = strings.ToLower(command)
|
||||
switch command {
|
||||
case "stop-commands", "set-env", "set-output", "save-state", "add-path", "add-mask", "add-matcher",
|
||||
"remove-matcher", "warning", "error", "notice", "debug", "group", "endgroup", "echo":
|
||||
default:
|
||||
if resumeCommand == "" || !strings.EqualFold(command, resumeCommand) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if !legacy {
|
||||
propertyList = strings.TrimSpace(propertyList)
|
||||
}
|
||||
properties := map[string]string{}
|
||||
for property := range strings.SplitSeq(propertyList, propertySeparator) {
|
||||
if key, value, _ := strings.Cut(strings.TrimLeft(property, "="), "="); value != "" {
|
||||
properties[strings.ToLower(key)] = unescapeProperty(value)
|
||||
}
|
||||
}
|
||||
return command, properties, unescapeData(data), legacy, true
|
||||
}
|
||||
return "", nil, "", false, false
|
||||
}
|
||||
|
||||
// commandHandler reports whether a line is logged, keeping per-handler command state as GitHub does.
|
||||
func (rc *RunContext) commandHandler(ctx context.Context) common.LineHandler {
|
||||
logger := common.Logger(ctx)
|
||||
rawLogger := common.Logger(ctx).With(rawOutputField, true)
|
||||
debug := rc.topLevelRunContext().Config.RunnerDebug()
|
||||
echo := debug
|
||||
resumeCommand := ""
|
||||
return func(line string) bool {
|
||||
command, kvPairs, arg, legacy, ok := tryParseActionCommand(line)
|
||||
command, kvPairs, arg, _, ok := parseActionCommand(line, resumeCommand)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
command = strings.ToLower(command)
|
||||
|
||||
if resumeCommand != "" {
|
||||
// There should not be any emojis in the log output for Gitea.
|
||||
// Return true (not false) so the line still reaches the raw_output
|
||||
// log handler; otherwise everything between ::stop-commands:: and
|
||||
// its end token is silently dropped from the step log.
|
||||
logger.Infof("%s", line)
|
||||
// Resumed here rather than from the switch, because the end token is arbitrary
|
||||
// and a token naming a real command would otherwise never resume.
|
||||
if strings.EqualFold(command, resumeCommand) {
|
||||
@@ -67,15 +78,9 @@ func (rc *RunContext) commandHandler(ctx context.Context) common.LineHandler {
|
||||
}
|
||||
return true
|
||||
}
|
||||
if legacy {
|
||||
arg = UnescapeLegacyCommand(arg)
|
||||
kvPairs = unescapeKvPairs(kvPairs, UnescapeLegacyCommand)
|
||||
} else {
|
||||
arg = UnescapeCommandData(arg)
|
||||
kvPairs = unescapeKvPairs(kvPairs, unescapeCommandProperty)
|
||||
}
|
||||
echoed := echo
|
||||
if (command == "set-env" || command == "add-path") && rc.refuseUnsecureCommand(ctx, command) {
|
||||
return true
|
||||
return echoed
|
||||
}
|
||||
switch command {
|
||||
case "set-env":
|
||||
@@ -83,26 +88,67 @@ func (rc *RunContext) commandHandler(ctx context.Context) common.LineHandler {
|
||||
case "set-output":
|
||||
rc.setOutput(ctx, kvPairs, arg)
|
||||
case "add-path":
|
||||
rc.addPath(ctx, arg)
|
||||
rc.addPath(arg)
|
||||
case "save-state":
|
||||
rc.saveState(ctx, kvPairs, arg)
|
||||
case "echo":
|
||||
switch strings.ToLower(strings.TrimSpace(arg)) {
|
||||
case "on":
|
||||
echo = true
|
||||
case "off":
|
||||
echo = false
|
||||
}
|
||||
case "add-mask":
|
||||
rc.AddMask(arg)
|
||||
logger.Infof("%s", "***")
|
||||
// The raw line is still forwarded, carrying the secret: that is how the reporter
|
||||
// learns the mask, and it drops the row rather than writing it out.
|
||||
rc.AddMask(ctx, arg)
|
||||
if echoed {
|
||||
rawLogger.Infof("::add-mask::***")
|
||||
}
|
||||
return false
|
||||
case "stop-commands":
|
||||
resumeCommand = arg
|
||||
logger.Infof("%s", line)
|
||||
case "save-state":
|
||||
logger.Infof("%s", line)
|
||||
rc.saveState(ctx, kvPairs, arg)
|
||||
default:
|
||||
// ::debug::, ::error::, ::warning::, ::add-matcher:: and anything unrecognised are
|
||||
// passed through for the reporter and Gitea's web UI to render.
|
||||
logger.Infof("%s", line)
|
||||
if len(arg) > 6 { // as on GitHub, so the log does not give away the resume token
|
||||
rc.AddMask(ctx, arg)
|
||||
}
|
||||
return true
|
||||
case "debug":
|
||||
return debug
|
||||
case "notice", "warning", "error":
|
||||
rawLogger.Infof("%s", formatAnnotation(command, kvPairs, arg))
|
||||
return false
|
||||
case "group", "endgroup":
|
||||
return true
|
||||
}
|
||||
return echoed
|
||||
}
|
||||
}
|
||||
|
||||
// return true to let gitea's logger handle these special outputs also
|
||||
return true
|
||||
// formatAnnotation folds the location and title into the message, as Gitea's web UI drops command properties.
|
||||
func formatAnnotation(level string, properties map[string]string, message string) string {
|
||||
prefix := properties["file"]
|
||||
if prefix != "" {
|
||||
if line := properties["line"]; line != "" {
|
||||
prefix += ":" + line
|
||||
if col := cmp.Or(properties["col"], properties["column"]); col != "" {
|
||||
prefix += ":" + col
|
||||
}
|
||||
}
|
||||
prefix += ": "
|
||||
}
|
||||
if title := properties["title"]; title != "" {
|
||||
prefix += title + ": "
|
||||
}
|
||||
return "::" + level + "::" + EscapeCommandData(prefix+message)
|
||||
}
|
||||
|
||||
// serviceCommandHandler applies only ::add-mask::, so a service cannot set the job's env, outputs or state.
|
||||
func (rc *RunContext) serviceCommandHandler(ctx context.Context) common.LineHandler {
|
||||
return func(line string) bool {
|
||||
command, _, arg, _, ok := parseActionCommand(line, "")
|
||||
if !ok || command != "add-mask" {
|
||||
return true
|
||||
}
|
||||
rc.AddMask(ctx, arg)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,7 +208,6 @@ func (rc *RunContext) setEnv(ctx context.Context, kvPairs map[string]string, arg
|
||||
common.Logger(ctx).With(rawOutputField, true).Errorf("##[error]%s", EscapeCommandData(message))
|
||||
return
|
||||
}
|
||||
common.Logger(ctx).Infof("::set-env:: %s=%s", name, arg)
|
||||
if rc.Env == nil {
|
||||
rc.Env = make(map[string]string)
|
||||
}
|
||||
@@ -195,12 +240,10 @@ func (rc *RunContext) setOutput(ctx context.Context, kvPairs map[string]string,
|
||||
return
|
||||
}
|
||||
|
||||
logger.Infof("::set-output:: %s=%s", outputName, arg)
|
||||
result.Outputs[outputName] = arg
|
||||
}
|
||||
|
||||
func (rc *RunContext) addPath(ctx context.Context, arg string) {
|
||||
common.Logger(ctx).Infof("::add-path:: %s", arg)
|
||||
func (rc *RunContext) addPath(arg string) {
|
||||
extraPath := []string{arg}
|
||||
for _, v := range rc.ExtraPath {
|
||||
if v != arg {
|
||||
@@ -210,18 +253,6 @@ func (rc *RunContext) addPath(ctx context.Context, arg string) {
|
||||
rc.ExtraPath = extraPath
|
||||
}
|
||||
|
||||
func parseKeyValuePairs(kvPairs, separator string) map[string]string {
|
||||
rtn := make(map[string]string)
|
||||
kvPairList := strings.SplitSeq(kvPairs, separator)
|
||||
for kvPair := range kvPairList {
|
||||
kv := strings.SplitN(kvPair, "=", 2)
|
||||
if len(kv) == 2 {
|
||||
rtn[kv[0]] = kv[1]
|
||||
}
|
||||
}
|
||||
return rtn
|
||||
}
|
||||
|
||||
// A Replacer never rescans what it wrote, so "%250A" stays a literal "%0A".
|
||||
var (
|
||||
commandDataEscaper = strings.NewReplacer("%", "%25", "\r", "%0D", "\n", "%0A")
|
||||
@@ -240,22 +271,11 @@ func UnescapeCommandData(arg string) string {
|
||||
return commandDataUnescaper.Replace(arg)
|
||||
}
|
||||
|
||||
func unescapeCommandProperty(arg string) string {
|
||||
return commandPropertyUnescaper.Replace(arg)
|
||||
}
|
||||
|
||||
// UnescapeLegacyCommand decodes a "##[cmd]" line, which also spells ";" and "]" escaped.
|
||||
func UnescapeLegacyCommand(arg string) string {
|
||||
return legacyCommandUnescaper.Replace(arg)
|
||||
}
|
||||
|
||||
func unescapeKvPairs(kvPairs map[string]string, unescape func(string) string) map[string]string {
|
||||
for k, v := range kvPairs {
|
||||
kvPairs[k] = unescape(v)
|
||||
}
|
||||
return kvPairs
|
||||
}
|
||||
|
||||
func (rc *RunContext) saveState(_ context.Context, kvPairs map[string]string, arg string) {
|
||||
stepID := rc.CurrentStep
|
||||
if stepID != "" {
|
||||
|
||||
@@ -5,12 +5,13 @@
|
||||
package runner
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"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"
|
||||
@@ -18,9 +19,56 @@ import (
|
||||
"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
|
||||
legacy, ok bool
|
||||
}{
|
||||
{"plain", "plain output", "", "", nil, "", false, false},
|
||||
{"unknown v2", "::unknown::v", "", "", nil, "", false, false},
|
||||
{"missing v2 terminator", "::set-output name=x", "", "", nil, "", false, false},
|
||||
{"empty command", "::::", "", "", nil, "", false, false},
|
||||
{"text before v2", "debug ::set-output name=x::v", "", "", nil, "", false, false},
|
||||
{"missing legacy terminator", "##[debug", "", "", nil, "", false, false},
|
||||
{"tab is not a name separator", "::set-output\tname=x::v", "", "", nil, "", false, 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", false, true},
|
||||
{"legacy prefix and casing", "prefix ##[SET-OUTPUT NAME=mid;]v\r\n", "", "set-output", map[string]string{"name": "mid"}, "v", true, true},
|
||||
{"only first legacy prefix", "##[unknown]##[set-output name=x]v", "", "", nil, "", false, 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 ", false, true},
|
||||
{"legacy first closing bracket", "##[set-output name=x]v]tail", "", "set-output", map[string]string{"name": "x"}, "v]tail", true, true},
|
||||
{"v2 semicolons are literal", "::set-output name=x;y=z::v", "", "set-output", map[string]string{"name": "x;y=z"}, "v", false, true},
|
||||
{"legacy commas are literal", "##[set-output name=x,y=z]v", "", "set-output", map[string]string{"name": "x,y=z"}, "v", true, 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", false, 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", false, true},
|
||||
{"legacy preserves list whitespace", "##[set-output name=q ]v", "", "set-output", map[string]string{" name": "q "}, "v", true, true},
|
||||
{"case insensitive duplicate keys", "::SET-OUTPUT NAME=first,Name=last::v", "", "set-output", map[string]string{"name": "last"}, "v", false, true},
|
||||
{"keys stay escaped", "::set-output na%3Ame=x::v", "", "set-output", map[string]string{"na%3ame": "x"}, "v", false, 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", false, 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, true},
|
||||
{"unknown v2 falls back to legacy", "::unknown::prefix ##[set-output name=x]v", "", "set-output", map[string]string{"name": "x"}, "v", true, true},
|
||||
{"unterminated v2 falls back to legacy", "::debug ##[set-output name=x]v", "", "set-output", map[string]string{"name": "x"}, "v", true, true},
|
||||
{"v2 wins over legacy", "::debug::##[set-output name=x]v", "", "debug", map[string]string{}, "##[set-output name=x]v", false, true},
|
||||
{"internal line breaks are not commands", "::debug::hidden\rplain\r\n", "", "", nil, "", false, false},
|
||||
{"v2 resume token", "::ReSuMe-ToKeN::", "RESUME-TOKEN", "resume-token", map[string]string{}, "", false, true},
|
||||
{"legacy resume token", "prefix ##[RESUME-TOKEN]v", "resume-token", "resume-token", map[string]string{}, "v", true, true},
|
||||
{"different resume token", "::other-token::", "resume-token", "", nil, "", false, false},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
command, properties, data, legacy, 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.legacy, legacy)
|
||||
assert.Equal(t, test.ok, ok)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// unsecureRC opts into ::set-env:: and ::add-path::, which are refused without it.
|
||||
func unsecureRC() *RunContext {
|
||||
return &RunContext{Env: map[string]string{allowUnsecureCommandsVar: "true"}}
|
||||
return &RunContext{Config: &Config{}, Env: map[string]string{allowUnsecureCommandsVar: "true"}}
|
||||
}
|
||||
|
||||
func TestSetEnv(t *testing.T) {
|
||||
@@ -37,212 +85,37 @@ func TestSetEnv(t *testing.T) {
|
||||
a.NotContains(rc.Env, "NODE_OPTIONS")
|
||||
a.NotContains(rc.Env, "node_options")
|
||||
entries := hook.AllRecords()
|
||||
require.Len(t, entries, 3)
|
||||
a.Equal("##[error]Can't update NODE_OPTIONS environment variable using ::set-env:: command.", entries[1].Message)
|
||||
a.Equal("##[error]Can't store NODE_OPTIONS output parameter using '$GITHUB_ENV' command.", entries[2].Message)
|
||||
}
|
||||
|
||||
func TestStopCommandsKeepsSuppressedLinesInLog(t *testing.T) {
|
||||
a := assert.New(t)
|
||||
ctx := t.Context()
|
||||
rc := unsecureRC()
|
||||
handler := rc.commandHandler(ctx)
|
||||
|
||||
// Stop command processing until the matching end token is seen.
|
||||
a.True(handler("::stop-commands::my-end-token\n"))
|
||||
|
||||
// A command-shaped line while stopped must not be executed (env unchanged),
|
||||
// but must still return true so it reaches the raw_output log handler and is
|
||||
// not dropped from the step log.
|
||||
a.True(handler("::set-env name=x::valz\n"))
|
||||
a.NotContains(rc.Env, "x")
|
||||
|
||||
// The matching end token resumes command processing.
|
||||
a.True(handler("::my-end-token::\n"))
|
||||
|
||||
// Commands are processed again after resuming.
|
||||
a.True(handler("::set-env name=y::valy\n"))
|
||||
a.Equal("valy", rc.Env["y"])
|
||||
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) {
|
||||
a := assert.New(t)
|
||||
ctx := t.Context()
|
||||
rc := new(RunContext)
|
||||
rc.StepResults = make(map[string]*model.StepResult)
|
||||
handler := rc.commandHandler(ctx)
|
||||
rc := &RunContext{Config: &Config{}, CurrentStep: "my-step", StepResults: map[string]*model.StepResult{"my-step": {Outputs: map[string]string{}}}}
|
||||
handler := rc.commandHandler(t.Context())
|
||||
|
||||
rc.CurrentStep = "my-step"
|
||||
rc.StepResults[rc.CurrentStep] = &model.StepResult{
|
||||
Outputs: make(map[string]string),
|
||||
}
|
||||
handler("::set-output name=x::valz\n")
|
||||
a.Equal("valz", rc.StepResults["my-step"].Outputs["x"])
|
||||
|
||||
handler("::set-output name=x::percent2%25\n")
|
||||
a.Equal("percent2%", rc.StepResults["my-step"].Outputs["x"])
|
||||
|
||||
handler("::set-output name=x::percent2%25%0Atest\n")
|
||||
a.Equal("percent2%\ntest", rc.StepResults["my-step"].Outputs["x"])
|
||||
|
||||
handler("::set-output name=x::percent2%25%0Atest another3%25test\n")
|
||||
a.Equal("percent2%\ntest another3%test", rc.StepResults["my-step"].Outputs["x"])
|
||||
|
||||
handler("::set-output name=x%3A::percent2%25%0Atest\n")
|
||||
a.Equal("percent2%\ntest", rc.StepResults["my-step"].Outputs["x:"])
|
||||
|
||||
handler("::set-output name=x%3A%2C%0A%25%0D%3A::percent2%25%0Atest\n")
|
||||
a.Equal("percent2%\ntest", rc.StepResults["my-step"].Outputs["x:,\n%\r:"])
|
||||
handler("::set-output name=symbol::std::vector")
|
||||
a.Equal("std::vector", rc.StepResults["my-step"].Outputs["symbol"])
|
||||
handler("::set-output name=a=b::value\n")
|
||||
a.Equal("value", rc.StepResults["my-step"].Outputs["a=b"])
|
||||
handler("##[set-output name=legacy%3B%5D]value%3B%5D")
|
||||
a.Equal("value;]", rc.StepResults["my-step"].Outputs["legacy;]"])
|
||||
handler("##[set-output name=bracket]value]tail")
|
||||
a.Equal("value]tail", rc.StepResults["my-step"].Outputs["bracket"])
|
||||
handler("::set-output name=modern%3B%5D::value%3B%5D\n")
|
||||
a.Equal("value%3B%5D", rc.StepResults["my-step"].Outputs["modern%3B%5D"])
|
||||
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) {
|
||||
a := assert.New(t)
|
||||
ctx := t.Context()
|
||||
rc := unsecureRC()
|
||||
handler := rc.commandHandler(ctx)
|
||||
|
||||
handler("::add-path::/zoo\n")
|
||||
a.Equal("/zoo", rc.ExtraPath[0])
|
||||
|
||||
handler("::add-path::/boo\n")
|
||||
a.Equal("/boo", rc.ExtraPath[0])
|
||||
}
|
||||
|
||||
func TestStopCommands(t *testing.T) {
|
||||
logger, hook := logtest.NewLogger()
|
||||
|
||||
a := assert.New(t)
|
||||
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("::stop-commands::MY-END-TOKEN\n")
|
||||
handler("::set-env name=x::abcd\n")
|
||||
a.Equal("valz", rc.Env["x"])
|
||||
handler("::my-end-token::\n")
|
||||
handler("::set-env name=x::abcd\n")
|
||||
a.Equal("abcd", rc.Env["x"])
|
||||
|
||||
messages := make([]string, 0)
|
||||
for _, entry := range hook.AllRecords() {
|
||||
messages = append(messages, entry.Message)
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
a.Contains(messages, "::set-env name=x::abcd\n")
|
||||
}
|
||||
|
||||
// The end token is arbitrary, so one that happens to name a real command must still resume
|
||||
// rather than being swallowed by that command's case.
|
||||
func TestStopCommandsResumesOnCommandNamedToken(t *testing.T) {
|
||||
a := assert.New(t)
|
||||
rc := unsecureRC()
|
||||
handler := rc.commandHandler(t.Context())
|
||||
|
||||
handler("::stop-commands::add-mask\n")
|
||||
handler("::set-env name=x::suppressed\n")
|
||||
a.NotContains(rc.Env, "x")
|
||||
|
||||
handler("::add-mask::\n")
|
||||
handler("::set-env name=x::resumed\n")
|
||||
a.Equal("resumed", rc.Env["x"])
|
||||
}
|
||||
|
||||
func TestAddpathADO(t *testing.T) {
|
||||
a := assert.New(t)
|
||||
ctx := t.Context()
|
||||
rc := unsecureRC()
|
||||
handler := rc.commandHandler(ctx)
|
||||
|
||||
handler("##[add-path]/zoo\n")
|
||||
a.Equal("/zoo", rc.ExtraPath[0])
|
||||
|
||||
handler("##[add-path]/boo\n")
|
||||
a.Equal("/boo", rc.ExtraPath[0])
|
||||
}
|
||||
|
||||
func TestAddmask(t *testing.T) {
|
||||
logger, hook := logtest.NewLogger()
|
||||
|
||||
a := assert.New(t)
|
||||
ctx := t.Context()
|
||||
loggerCtx := common.WithLogger(ctx, logger)
|
||||
|
||||
rc := new(RunContext)
|
||||
handler := rc.commandHandler(loggerCtx)
|
||||
handler("::ADD-MASK::my::secret")
|
||||
|
||||
a.Equal("***", hook.LastRecord().Message)
|
||||
a.Equal([]string{"my::secret"}, rc.Masks)
|
||||
}
|
||||
|
||||
// based on https://stackoverflow.com/a/10476304
|
||||
func captureOutput(t *testing.T, f func()) string {
|
||||
old := os.Stdout
|
||||
r, w, _ := os.Pipe()
|
||||
os.Stdout = w
|
||||
|
||||
f()
|
||||
|
||||
outC := make(chan string)
|
||||
|
||||
go func() {
|
||||
var buf bytes.Buffer
|
||||
_, err := io.Copy(&buf, r)
|
||||
if err != nil {
|
||||
a := assert.New(t)
|
||||
a.Fail("io.Copy failed")
|
||||
}
|
||||
outC <- buf.String()
|
||||
}()
|
||||
|
||||
w.Close()
|
||||
os.Stdout = old
|
||||
out := <-outC
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func TestAddmaskUsemask(t *testing.T) {
|
||||
rc := new(RunContext)
|
||||
rc.StepResults = make(map[string]*model.StepResult)
|
||||
rc.CurrentStep = "my-step"
|
||||
rc.StepResults[rc.CurrentStep] = &model.StepResult{
|
||||
Outputs: make(map[string]string),
|
||||
}
|
||||
|
||||
a := assert.New(t)
|
||||
|
||||
config := &Config{
|
||||
Secrets: map[string]string{},
|
||||
InsecureSecrets: false,
|
||||
}
|
||||
|
||||
re := captureOutput(t, func() {
|
||||
ctx := t.Context()
|
||||
ctx = WithJobLogger(ctx, "0", "testjob", config, &rc.Masks, map[string]any{})
|
||||
|
||||
handler := rc.commandHandler(ctx)
|
||||
handler("::add-mask::secret\n")
|
||||
handler("::set-output:: token=secret\n")
|
||||
})
|
||||
|
||||
a.Equal("[testjob] ***\n[testjob] ::set-output:: = token=***\n", re)
|
||||
}
|
||||
|
||||
func TestSaveState(t *testing.T) {
|
||||
rc := &RunContext{
|
||||
Config: &Config{},
|
||||
CurrentStep: "step",
|
||||
StepResults: map[string]*model.StepResult{},
|
||||
}
|
||||
@@ -278,12 +151,12 @@ func TestUnsecureCommands(t *testing.T) {
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
a := assert.New(t)
|
||||
rc := &RunContext{Env: tt.jobEnv}
|
||||
rc := &RunContext{Config: &Config{}, Env: tt.jobEnv}
|
||||
rc.setCurrentStepEnv(tt.stepEnv)
|
||||
handler := rc.commandHandler(t.Context())
|
||||
|
||||
handler("::set-env name=x::valz\n")
|
||||
handler("::add-path::/opt/bin\n")
|
||||
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"])
|
||||
@@ -299,3 +172,109 @@ func TestUnsecureCommands(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -732,17 +732,6 @@ func useJobStageLogger(stage string, executor common.Executor) common.Executor {
|
||||
func useStepLogger(rc *RunContext, stepModel *model.Step, stage stepStage, executor common.Executor) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
ctx = withStepLogger(ctx, stepModel.Number, stepModel.ID, rc.ExprEval.InterpolateName(ctx, stepModel.String()), stage.String())
|
||||
|
||||
logWriter := rc.commandLogWriter(ctx)
|
||||
|
||||
oldout, olderr := rc.JobContainer.ReplaceLogWriter(logWriter, logWriter)
|
||||
defer rc.JobContainer.ReplaceLogWriter(oldout, olderr)
|
||||
|
||||
// Flush any buffered, not-yet-newline-terminated trailing line once the
|
||||
// step has finished, so the final line of the step's output is not lost
|
||||
// when it is not newline-terminated.
|
||||
defer common.FlushWriter(logWriter)
|
||||
|
||||
return executor(ctx)
|
||||
return rc.withCommandLogWriter(ctx, executor)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ func (rc *RunContext) runJobHook(ctx context.Context, hookPath, name string) err
|
||||
|
||||
err := rc.setupHookFileCommands(ctx, env)
|
||||
if err == nil {
|
||||
err = rc.JobContainer.Exec(cmd, env, "", "")(ctx)
|
||||
err = rc.withCommandLogWriter(ctx, rc.JobContainer.Exec(cmd, env, "", ""))
|
||||
}
|
||||
// Processed even on failure, so a hook that exports what it managed to set up before
|
||||
// failing still hands it to the job.
|
||||
|
||||
@@ -29,6 +29,8 @@ type hookContainer struct {
|
||||
err error
|
||||
envFile map[string]string
|
||||
pathTar []byte
|
||||
output string
|
||||
stdout io.Writer
|
||||
}
|
||||
|
||||
func (c *hookContainer) ToContainerPath(path string) string { return path }
|
||||
@@ -41,10 +43,21 @@ func (c *hookContainer) GetRunnerContext(context.Context) map[string]any {
|
||||
func (c *hookContainer) Exec(command []string, env map[string]string, _, _ string) common.Executor {
|
||||
return func(context.Context) error {
|
||||
c.cmd, c.env = command, env
|
||||
if c.output != "" {
|
||||
if _, err := io.WriteString(c.stdout, c.output); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return c.err
|
||||
}
|
||||
}
|
||||
|
||||
func (c *hookContainer) ReplaceLogWriter(stdout, _ io.Writer) (io.Writer, io.Writer) {
|
||||
old := c.stdout
|
||||
c.stdout = stdout
|
||||
return old, nil
|
||||
}
|
||||
|
||||
func (c *hookContainer) UpdateFromEnv(_ string, env *map[string]string) common.Executor {
|
||||
return func(context.Context) error {
|
||||
maps.Copy(*env, c.envFile)
|
||||
@@ -90,6 +103,19 @@ func TestRunJobHook(t *testing.T) {
|
||||
assert.Equal(t, "/var/run/act/workflow/hook-path.txt", jobContainer.env["GITHUB_PATH"])
|
||||
})
|
||||
|
||||
t.Run("each hook has its own command state", func(t *testing.T) {
|
||||
jobContainer := &hookContainer{output: "::stop-commands::token77"}
|
||||
rc, ctx := newHookRunContext(jobContainer, &Config{JobStartedHook: "/started.sh", JobCompletedHook: "/completed.sh"})
|
||||
jobWriter := rc.commandLogWriter(ctx)
|
||||
jobContainer.stdout = jobWriter
|
||||
|
||||
require.NoError(t, rc.runJobStartedHook(ctx))
|
||||
jobContainer.output = "::add-mask::s3cret"
|
||||
require.NoError(t, rc.runJobCompletedHook(ctx))
|
||||
assert.Equal(t, []string{"token77", "s3cret"}, rc.Masks)
|
||||
assert.Same(t, jobWriter, jobContainer.stdout)
|
||||
})
|
||||
|
||||
// Each hook reads its own variable, so a swapped constant cannot pass.
|
||||
t.Run("falls back to the GitHub environment variables", func(t *testing.T) {
|
||||
for name, hook := range map[string]struct {
|
||||
|
||||
@@ -407,7 +407,7 @@ func (f textFormatter) format(r logging.Record) ([]byte, error) {
|
||||
b := &bytes.Buffer{}
|
||||
|
||||
// the web renderer decodes command data, so this local view has to as well
|
||||
if _, _, _, ok := tryParseRawActionCommand(r.Message + "\n"); ok {
|
||||
if _, _, _, _, ok := parseActionCommand(r.Message, ""); ok && (strings.HasPrefix(r.Message, "::") || strings.HasPrefix(r.Message, "##[")) {
|
||||
r.Message = UnescapeCommandData(r.Message)
|
||||
}
|
||||
r.Message = strings.TrimSuffix(r.Message, "\n")
|
||||
|
||||
@@ -164,8 +164,12 @@ func (rc *RunContext) markInterrupted(err error) {
|
||||
}
|
||||
}
|
||||
|
||||
func (rc *RunContext) AddMask(mask string) {
|
||||
// AddMask also hands the mask to the job logger's extra handler, which receives records unmasked.
|
||||
func (rc *RunContext) AddMask(ctx context.Context, mask string) {
|
||||
rc.Masks = append(rc.Masks, mask)
|
||||
if masker, ok := common.LoggerHandler(ctx).(interface{ AddMask(string) }); ok {
|
||||
masker.AddMask(mask)
|
||||
}
|
||||
}
|
||||
|
||||
func (rc *RunContext) String() string {
|
||||
@@ -672,7 +676,7 @@ func (rc *RunContext) jobServices(ctx context.Context, networkName string) (map[
|
||||
return nil, fmt.Errorf("failed to parse service %s ports: %w", serviceID, err)
|
||||
}
|
||||
|
||||
serviceLog := common.NewLineWriter(rawLogLine(ctx)) // raw, so a service's output cannot set the job's env, outputs or state
|
||||
serviceLog := common.NewLineWriter(rc.serviceCommandHandler(ctx), rawLogLine(ctx))
|
||||
inputs[serviceID] = &container.NewContainerInput{
|
||||
Name: createContainerName(rc.jobContainerName(), serviceID),
|
||||
Image: spec.Image,
|
||||
@@ -705,6 +709,15 @@ func (rc *RunContext) commandLogWriter(ctx context.Context) io.Writer {
|
||||
return common.NewLineWriter(rc.commandHandler(ctx), rawLogLine(ctx))
|
||||
}
|
||||
|
||||
// withCommandLogWriter runs executor with a command handler of its own on the job environment's output.
|
||||
func (rc *RunContext) withCommandLogWriter(ctx context.Context, executor common.Executor) error {
|
||||
logWriter := rc.commandLogWriter(ctx)
|
||||
oldout, olderr := rc.JobContainer.ReplaceLogWriter(logWriter, logWriter)
|
||||
defer rc.JobContainer.ReplaceLogWriter(oldout, olderr)
|
||||
defer common.FlushWriter(logWriter) // the last line of output may lack its newline
|
||||
return executor(ctx)
|
||||
}
|
||||
|
||||
func rawLogLine(ctx context.Context) common.LineHandler {
|
||||
rawLogger := common.Logger(ctx).With(rawOutputField, true)
|
||||
return func(line string) bool {
|
||||
@@ -853,7 +866,7 @@ func (rc *RunContext) UpdateExtraPath(ctx context.Context, githubEnvPath string)
|
||||
for s.Scan() {
|
||||
line := s.Text()
|
||||
if len(line) > 0 {
|
||||
rc.addPath(ctx, line)
|
||||
rc.addPath(line)
|
||||
}
|
||||
}
|
||||
if err := s.Err(); err != nil {
|
||||
|
||||
@@ -1434,7 +1434,7 @@ func TestRunContext_cleanupFailedStart(t *testing.T) {
|
||||
slices.Sort(operations[:3])
|
||||
slices.Sort(operations[len(operations)-4 : len(operations)-1])
|
||||
assert.Equal(t, strings.Fields(want+" job.Remove service.Close service.Remove job.Close"), operations)
|
||||
assert.Empty(t, rc.Masks)
|
||||
assert.Equal(t, strings.Contains(want, "service.DumpLogs"), slices.Contains(rc.Masks, "leak"))
|
||||
assert.Equal(t, testcase.pullError == nil && testcase.health != container.HealthUnhealthy, slices.ContainsFunc(hook.AllRecords(), func(entry logging.Record) bool {
|
||||
return strings.HasPrefix(entry.Message, "::group::Print service container logs")
|
||||
}))
|
||||
|
||||
@@ -94,9 +94,7 @@ type Config struct {
|
||||
JobCompletedHook string // script run inside the job environment after the job's last step; ACTIONS_RUNNER_HOOK_JOB_COMPLETED is read from Env when empty
|
||||
}
|
||||
|
||||
// RunnerDebug reports whether debug logging is on, exposed as `runner.debug` and
|
||||
// RUNNER_DEBUG. Only the secret also makes the reporter keep ::debug:: output, the env
|
||||
// is accepted for `exec` and for runners configured with it.
|
||||
// RunnerDebug reports whether debug logging is on, exposed as `runner.debug` and RUNNER_DEBUG.
|
||||
func (c Config) RunnerDebug() bool {
|
||||
return c.Secrets["ACTIONS_STEP_DEBUG"] == "true" || c.Env["ACTIONS_STEP_DEBUG"] == "true"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user