mirror of
https://gitea.com/gitea/act_runner.git
synced 2026-08-06 00:44:22 +02:00
feat: gate set-env/add-path and render annotation locations (#1109)
`::set-env::` and `::add-path::` let a step rewrite the environment of every later step from its own output, which the runner honoured silently. They are now refused, as GitHub has done since 2020, and `ACTIONS_ALLOW_UNSECURE_COMMANDS` opts back in per step or job. Support for that variable is new here too, and is the only opt-in, matching GitHub rather than adding a runner config key on top. Annotations keep their source location: Gitea has no annotation store and its web UI strips command properties, so `::error file=main.go,line=12::msg` is rendered as `::error::main.go:12: msg`. `DEVELOPMENT.md` writes down the log line encoding rules this relies on. --------- Co-authored-by: silverwind <me@silverwind.io> Reviewed-on: https://gitea.com/gitea/runner/pulls/1109 Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com> Co-authored-by: bircni <bircni@icloud.com>
This commit is contained in:
@@ -186,10 +186,10 @@ func (rc *RunContext) compositeExecutor(action *model.Action) *compositeSteps {
|
||||
err := rc.newCompositeCommandExecutor(step.main())(ctx)
|
||||
|
||||
if err != nil {
|
||||
logger.Errorf("%v", err)
|
||||
logger.Errorf("##[error]%s", EscapeCommandData(err.Error()))
|
||||
common.SetJobError(ctx, err)
|
||||
} else if ctx.Err() != nil {
|
||||
logger.Errorf("%v", ctx.Err())
|
||||
logger.Errorf("##[error]%s", EscapeCommandData(ctx.Err().Error()))
|
||||
common.SetJobError(ctx, ctx.Err())
|
||||
}
|
||||
return nil
|
||||
@@ -248,10 +248,10 @@ func newCompositeStepLogExecutor(runStep common.Executor, stepID string) common.
|
||||
logger := common.Logger(ctx)
|
||||
err := runStep(ctx)
|
||||
if err != nil {
|
||||
logger.Errorf("%v", err)
|
||||
logger.Errorf("##[error]%s", EscapeCommandData(err.Error()))
|
||||
common.SetJobError(ctx, err)
|
||||
} else if ctx.Err() != nil {
|
||||
logger.Errorf("%v", ctx.Err())
|
||||
logger.Errorf("##[error]%s", EscapeCommandData(ctx.Err().Error()))
|
||||
common.SetJobError(ctx, ctx.Err())
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -6,6 +6,7 @@ package runner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
@@ -45,17 +46,24 @@ func (rc *RunContext) commandHandler(ctx context.Context) common.LineHandler {
|
||||
return true
|
||||
}
|
||||
|
||||
if resumeCommand != "" && command != resumeCommand {
|
||||
if resumeCommand != "" {
|
||||
// There should not be any emojis in the log output for Gitea.
|
||||
// The code in the switch statement is the same.
|
||||
// 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 command == resumeCommand {
|
||||
resumeCommand = ""
|
||||
}
|
||||
return true
|
||||
}
|
||||
arg = UnescapeCommandData(arg)
|
||||
kvPairs = unescapeKvPairs(kvPairs)
|
||||
if (command == "set-env" || command == "add-path") && rc.refuseUnsecureCommand(ctx, command) {
|
||||
return true
|
||||
}
|
||||
switch command {
|
||||
case "set-env":
|
||||
rc.setEnv(ctx, kvPairs, arg)
|
||||
@@ -63,27 +71,20 @@ func (rc *RunContext) commandHandler(ctx context.Context) common.LineHandler {
|
||||
rc.setOutput(ctx, kvPairs, arg)
|
||||
case "add-path":
|
||||
rc.addPath(ctx, arg)
|
||||
case "debug":
|
||||
logger.Infof("%s", line)
|
||||
case "warning":
|
||||
logger.Infof("%s", line)
|
||||
case "error":
|
||||
logger.Infof("%s", line)
|
||||
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.
|
||||
case "stop-commands":
|
||||
resumeCommand = arg
|
||||
logger.Infof("%s", line)
|
||||
case resumeCommand:
|
||||
resumeCommand = ""
|
||||
logger.Infof("%s", line)
|
||||
case "save-state":
|
||||
logger.Infof("%s", line)
|
||||
rc.saveState(ctx, kvPairs, arg)
|
||||
case "add-matcher":
|
||||
logger.Infof("%s", line)
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -92,6 +93,52 @@ func (rc *RunContext) commandHandler(ctx context.Context) common.LineHandler {
|
||||
}
|
||||
}
|
||||
|
||||
const allowUnsecureCommandsVar = "ACTIONS_ALLOW_UNSECURE_COMMANDS"
|
||||
|
||||
// refuseUnsecureCommand reports whether a deprecated ::set-env:: or ::add-path:: command must
|
||||
// not run, recording the error that fails the step. GitHub disabled both because a step that
|
||||
// echoes untrusted content can use them to set NODE_OPTIONS or PATH for every later step.
|
||||
func (rc *RunContext) refuseUnsecureCommand(ctx context.Context, command string) bool {
|
||||
if rc.allowUnsecureCommandsOptIn() {
|
||||
return false
|
||||
}
|
||||
|
||||
// The step executor logs the failure itself, so keep this line's wording distinct.
|
||||
common.Logger(ctx).WithField(rawOutputField, true).Errorf("##[error]%s", EscapeCommandData(fmt.Sprintf(
|
||||
"The `%s` command is disabled: it can set the environment of every later step from untrusted output. "+
|
||||
"Write to $GITHUB_ENV or $GITHUB_PATH instead, or set ACTIONS_ALLOW_UNSECURE_COMMANDS to allow it",
|
||||
command)))
|
||||
|
||||
rc.unsecureCommandMu.Lock()
|
||||
defer rc.unsecureCommandMu.Unlock()
|
||||
if rc.unsecureCommandErr == nil {
|
||||
rc.unsecureCommandErr = fmt.Errorf("the `%s` workflow command is disabled", command)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// allowUnsecureCommandsOptIn reports whether the workflow itself asked for the deprecated
|
||||
// commands, from any env scope, as it can on GitHub.
|
||||
func (rc *RunContext) allowUnsecureCommandsOptIn() bool {
|
||||
return isTruthyEnv(rc.currentStepEnv()[allowUnsecureCommandsVar]) ||
|
||||
isTruthyEnv(rc.Env[allowUnsecureCommandsVar]) ||
|
||||
isTruthyEnv(rc.GlobalEnv[allowUnsecureCommandsVar])
|
||||
}
|
||||
|
||||
// isTruthyEnv mirrors GitHub's bool.TryParse: only "true", in any casing.
|
||||
func isTruthyEnv(v string) bool {
|
||||
return strings.EqualFold(strings.TrimSpace(v), "true")
|
||||
}
|
||||
|
||||
// takeUnsecureCommandError returns and clears the error left by a refused command.
|
||||
func (rc *RunContext) takeUnsecureCommandError() error {
|
||||
rc.unsecureCommandMu.Lock()
|
||||
defer rc.unsecureCommandMu.Unlock()
|
||||
err := rc.unsecureCommandErr
|
||||
rc.unsecureCommandErr = nil
|
||||
return err
|
||||
}
|
||||
|
||||
func (rc *RunContext) setEnv(ctx context.Context, kvPairs map[string]string, arg string) {
|
||||
name := kvPairs["name"]
|
||||
common.Logger(ctx).Infof("::set-env:: %s=%s", name, arg)
|
||||
@@ -161,9 +208,9 @@ var (
|
||||
commandPropertyUnescaper = strings.NewReplacer("%25", "%", "%0D", "\r", "%0A", "\n", "%3A", ":", "%2C", ",")
|
||||
)
|
||||
|
||||
// escapeCommandData encodes the data part of a "::cmd::" or "##[cmd]" line the runner writes itself,
|
||||
// EscapeCommandData encodes the data part of a "::cmd::" or "##[cmd]" line the runner writes itself,
|
||||
// so the log renderer decodes it back. Lines forwarded from step output are already escaped.
|
||||
func escapeCommandData(arg string) string {
|
||||
func EscapeCommandData(arg string) string {
|
||||
return commandDataEscaper.Replace(arg)
|
||||
}
|
||||
|
||||
|
||||
@@ -16,12 +16,18 @@ import (
|
||||
|
||||
"github.com/sirupsen/logrus/hooks/test"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// unsecureRC opts into ::set-env:: and ::add-path::, which are refused without it.
|
||||
func unsecureRC() *RunContext {
|
||||
return &RunContext{Env: map[string]string{allowUnsecureCommandsVar: "true"}}
|
||||
}
|
||||
|
||||
func TestSetEnv(t *testing.T) {
|
||||
a := assert.New(t)
|
||||
ctx := context.Background()
|
||||
rc := new(RunContext)
|
||||
rc := unsecureRC()
|
||||
handler := rc.commandHandler(ctx)
|
||||
|
||||
handler("::set-env name=x::valz\n")
|
||||
@@ -31,7 +37,7 @@ func TestSetEnv(t *testing.T) {
|
||||
func TestStopCommandsKeepsSuppressedLinesInLog(t *testing.T) {
|
||||
a := assert.New(t)
|
||||
ctx := context.Background()
|
||||
rc := new(RunContext)
|
||||
rc := unsecureRC()
|
||||
handler := rc.commandHandler(ctx)
|
||||
|
||||
// Stop command processing until the matching end token is seen.
|
||||
@@ -84,7 +90,7 @@ func TestSetOutput(t *testing.T) {
|
||||
func TestAddpath(t *testing.T) {
|
||||
a := assert.New(t)
|
||||
ctx := context.Background()
|
||||
rc := new(RunContext)
|
||||
rc := unsecureRC()
|
||||
handler := rc.commandHandler(ctx)
|
||||
|
||||
handler("::add-path::/zoo\n")
|
||||
@@ -99,7 +105,7 @@ func TestStopCommands(t *testing.T) {
|
||||
|
||||
a := assert.New(t)
|
||||
ctx := common.WithLogger(context.Background(), logger)
|
||||
rc := new(RunContext)
|
||||
rc := unsecureRC()
|
||||
handler := rc.commandHandler(ctx)
|
||||
|
||||
handler("::set-env name=x::valz\n")
|
||||
@@ -119,10 +125,26 @@ func TestStopCommands(t *testing.T) {
|
||||
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(context.Background())
|
||||
|
||||
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 := context.Background()
|
||||
rc := new(RunContext)
|
||||
rc := unsecureRC()
|
||||
handler := rc.commandHandler(ctx)
|
||||
|
||||
handler("##[add-path]/zoo\n")
|
||||
@@ -218,6 +240,44 @@ func TestSaveState(t *testing.T) {
|
||||
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%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
|
||||
stepEnv map[string]string
|
||||
optedIn bool
|
||||
}{
|
||||
{name: "refused with no opt-in"},
|
||||
// GitHub reads the opt-in with bool.TryParse, so "1" is not one.
|
||||
{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},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
a := assert.New(t)
|
||||
rc := &RunContext{Env: tt.jobEnv}
|
||||
rc.setCurrentStepEnv(tt.stepEnv)
|
||||
handler := rc.commandHandler(context.Background())
|
||||
|
||||
handler("::set-env name=x::valz\n")
|
||||
handler("::add-path::/opt/bin\n")
|
||||
|
||||
if !tt.optedIn {
|
||||
a.Empty(rc.Env["x"])
|
||||
a.Empty(rc.ExtraPath)
|
||||
// The refusal fails the step that produced it, once.
|
||||
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())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ func reportStepError(ctx context.Context, rc *RunContext, err error) {
|
||||
rc.markInterrupted(ctx.Err())
|
||||
return
|
||||
}
|
||||
common.Logger(ctx).Errorf("##[error]%s", escapeCommandData(err.Error()))
|
||||
common.Logger(ctx).Errorf("##[error]%s", EscapeCommandData(err.Error()))
|
||||
common.SetJobError(ctx, err)
|
||||
rc.markFailed()
|
||||
}
|
||||
@@ -260,7 +260,7 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
|
||||
|
||||
logger.Infof("Cleaning up container for job %s", rc.JobName)
|
||||
if err = info.stopContainer()(ctx); err != nil {
|
||||
logger.Errorf("Error while stop job container: %v", err)
|
||||
logger.Errorf("##[error]%s", EscapeCommandData("Error while stop job container: "+err.Error()))
|
||||
}
|
||||
|
||||
// For Gitea
|
||||
|
||||
@@ -45,7 +45,7 @@ func (rc *RunContext) runJobHook(ctx context.Context, hookPath, name string) err
|
||||
cmd, shell := hookCommand(hookPath)
|
||||
rawLogger := common.Logger(ctx).WithField(rawOutputField, true)
|
||||
defer rawLogger.Infof("::endgroup::")
|
||||
rawLogger.Infof("::group::Run '%s'", escapeCommandData(hookPath))
|
||||
rawLogger.Infof("::group::Run '%s'", EscapeCommandData(hookPath))
|
||||
rawLogger.Infof("A %s hook has been configured by the runner administrator", name)
|
||||
if shell != "" {
|
||||
rawLogger.Infof("shell: %s", shell)
|
||||
|
||||
@@ -250,7 +250,7 @@ func AppendSecretMasker(oldnew []string, v string) []string {
|
||||
ret = append(ret, tm, "***")
|
||||
// command data reaches the log escaped, so "pass%word" also arrives as "pass%25word"
|
||||
if strings.ContainsAny(tm, "%\r\n") {
|
||||
ret = append(ret, escapeCommandData(tm), "***")
|
||||
ret = append(ret, EscapeCommandData(tm), "***")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
@@ -83,6 +84,25 @@ type RunContext struct {
|
||||
// failures. Those failures must still make success() false and failure() true for later
|
||||
// main-step if evaluation.
|
||||
jobFailed bool
|
||||
// stepEnv is a copy of the running step's environment, so that workflow commands parsed out
|
||||
// of the container's output can be judged against it. Written by runStepExecutor and read on
|
||||
// the log-writer goroutine, hence unsecureCommandMu, which also guards unsecureCommandErr.
|
||||
stepEnv map[string]string
|
||||
unsecureCommandErr error // refused ::set-env::/::add-path::, turned into a step failure
|
||||
unsecureCommandMu sync.Mutex
|
||||
}
|
||||
|
||||
// setCurrentStepEnv records the environment of the step about to run.
|
||||
func (rc *RunContext) setCurrentStepEnv(env map[string]string) {
|
||||
rc.unsecureCommandMu.Lock()
|
||||
defer rc.unsecureCommandMu.Unlock()
|
||||
rc.stepEnv = env
|
||||
}
|
||||
|
||||
func (rc *RunContext) currentStepEnv() map[string]string {
|
||||
rc.unsecureCommandMu.Lock()
|
||||
defer rc.unsecureCommandMu.Unlock()
|
||||
return rc.stepEnv
|
||||
}
|
||||
|
||||
// markCancelled flags the job as cancelled so subsequent step `if` evaluations and the
|
||||
|
||||
@@ -165,9 +165,22 @@ func runStepExecutor(step step, stage stepStage, executor common.Executor) commo
|
||||
}
|
||||
_ = rc.JobContainer.Copy(actPath, files...)(ctx)
|
||||
|
||||
// The command handler needs the step's env to judge ACTIONS_ALLOW_UNSECURE_COMMANDS.
|
||||
// Cloned: the step executor keeps writing to its own env map after this point, on a
|
||||
// different goroutine from the command handler that reads it.
|
||||
rc.setCurrentStepEnv(maps0.Clone(*step.getEnv()))
|
||||
defer rc.setCurrentStepEnv(nil)
|
||||
_ = rc.takeUnsecureCommandError() // a refusal from before any step belongs to no step
|
||||
|
||||
timeoutctx, cancelTimeOut := evaluateStepTimeout(ctx, rc.ExprEval, stepModel)
|
||||
defer cancelTimeOut()
|
||||
err = executor(timeoutctx)
|
||||
// Always take it, so the job-scoped error cannot leak onto a later step. A refusal
|
||||
// fails the step as it does on GitHub, but the executor's own error wins.
|
||||
insecureErr := rc.takeUnsecureCommandError()
|
||||
if err == nil {
|
||||
err = insecureErr
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
logger.WithField("stepResult", stepResult.Outcome).Infof("Success - %s %s", stage, stepString)
|
||||
@@ -181,7 +194,7 @@ func runStepExecutor(step step, stage stepStage, executor common.Executor) commo
|
||||
}
|
||||
|
||||
if continueOnError {
|
||||
logger.Errorf("##[error]%s", escapeCommandData(err.Error()))
|
||||
logger.Errorf("##[error]%s", EscapeCommandData(err.Error()))
|
||||
logger.Infof("Failed but continue next step")
|
||||
err = nil
|
||||
stepResult.Conclusion = model.StepStatusSuccess
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"gitea.com/gitea/runner/act/model"
|
||||
|
||||
"github.com/kballard/go-shellquote"
|
||||
"github.com/sirupsen/logrus"
|
||||
yaml "go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
@@ -63,7 +64,7 @@ func (sr *stepRun) printRunScriptActionDetails(ctx context.Context) {
|
||||
|
||||
normalized := strings.TrimRight(strings.ReplaceAll(sr.interpolatedScript, "\r\n", "\n"), "\n")
|
||||
|
||||
rawLogger.Infof("::group::Run %s", escapeCommandData(sr.runScriptGroupTitle(normalized)))
|
||||
rawLogger.Infof("::group::Run %s", EscapeCommandData(sr.runScriptGroupTitle(normalized)))
|
||||
|
||||
if normalized != "" {
|
||||
for line := range strings.SplitSeq(normalized, "\n") {
|
||||
@@ -90,12 +91,12 @@ func printRunActionHeader(ctx context.Context, step *model.Step, env map[string]
|
||||
if step.Name != "" {
|
||||
title = step.Name
|
||||
}
|
||||
rawLogger.Infof("::group::Run %s", escapeCommandData(title))
|
||||
rawLogger.Infof("::group::Run %s", EscapeCommandData(title))
|
||||
|
||||
if len(step.With) > 0 {
|
||||
rawLogger.Infof("with:")
|
||||
for _, k := range slices.Sorted(maps.Keys(step.With)) {
|
||||
rawLogger.Infof(" %s: %s", k, step.With[k])
|
||||
logKeyedValue(rawLogger, k, step.With[k])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,7 +130,17 @@ func printStepEnvBlock(ctx context.Context, step *model.Step, env map[string]str
|
||||
if caseInsensitive {
|
||||
lookupKey = strings.ToUpper(k)
|
||||
}
|
||||
rawLogger.Infof(" %s: %s", k, envLookup[lookupKey])
|
||||
logKeyedValue(rawLogger, k, envLookup[lookupKey])
|
||||
}
|
||||
}
|
||||
|
||||
// logKeyedValue prints one row per line of value: Gitea stores one log row per line, so an
|
||||
// embedded newline would reach the user as a literal "\n".
|
||||
func logKeyedValue(rawLogger *logrus.Entry, key, value string) {
|
||||
lines := strings.Split(strings.ReplaceAll(value, "\r\n", "\n"), "\n")
|
||||
rawLogger.Infof(" %s: %s", key, lines[0])
|
||||
for _, line := range lines[1:] {
|
||||
rawLogger.Infof(" %s", line)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ package runner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
@@ -14,6 +15,7 @@ import (
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
yaml "go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
@@ -354,3 +356,48 @@ func TestIsContinueOnError(t *testing.T) {
|
||||
assertObject.False(continueOnError)
|
||||
assertObject.Error(err)
|
||||
}
|
||||
|
||||
// A refused ::set-env::/::add-path:: records a job-scoped error. When the step that
|
||||
// produced it also fails on its own, the refusal must be cleared at the step boundary, so
|
||||
// it fails only that step and never leaks onto a later step that runs anyway (if: always()).
|
||||
func TestRunStepExecutorDoesNotLeakRefusalToNextStep(t *testing.T) {
|
||||
cm := &containerMock{}
|
||||
noop := func(context.Context) error { return nil }
|
||||
cm.On("Copy", mock.Anything, mock.Anything).Return(noop)
|
||||
cm.On("UpdateFromEnv", mock.Anything, mock.Anything).Return(noop)
|
||||
|
||||
rc := &RunContext{
|
||||
Config: &Config{Env: map[string]string{}},
|
||||
Run: &model.Run{
|
||||
JobID: "1",
|
||||
Workflow: &model.Workflow{Jobs: map[string]*model.Job{"1": {}}},
|
||||
},
|
||||
Env: map[string]string{},
|
||||
StepResults: map[string]*model.StepResult{},
|
||||
JobContainer: cm,
|
||||
}
|
||||
rc.ExprEval = rc.NewExpressionEvaluator(context.Background())
|
||||
// Dryrun skips reading the path file back from the (mocked) container.
|
||||
ctx := common.WithDryrun(context.Background(), true)
|
||||
|
||||
// A refusal parsed out of the job container's own output belongs to no step, so the
|
||||
// first step must not be failed by it.
|
||||
rc.commandHandler(ctx)("::set-env name=setup::y\n")
|
||||
stepSetup := &stepRun{RunContext: rc, Step: &model.Step{ID: "setup"}, env: map[string]string{}}
|
||||
require.NoError(t, runStepExecutor(stepSetup, stepStageMain, func(context.Context) error { return nil })(ctx))
|
||||
|
||||
// Step A refuses a ::set-env:: and then fails on its own.
|
||||
stepA := &stepRun{RunContext: rc, Step: &model.Step{ID: "a"}, env: map[string]string{}}
|
||||
errA := runStepExecutor(stepA, stepStageMain, func(context.Context) error {
|
||||
rc.commandHandler(ctx)("::set-env name=x::y\n")
|
||||
return errors.New("boom")
|
||||
})(ctx)
|
||||
// The step fails with its own error, not the refusal.
|
||||
require.ErrorContains(t, errA, "boom")
|
||||
|
||||
// Step B runs despite step A's failure (if: always()) and issues no unsecure command;
|
||||
// it must not inherit step A's refusal.
|
||||
stepB := &stepRun{RunContext: rc, Step: &model.Step{ID: "b", If: yaml.Node{Value: "always()"}}, env: map[string]string{}}
|
||||
errB := runStepExecutor(stepB, stepStageMain, func(context.Context) error { return nil })(ctx)
|
||||
require.NoError(t, errB)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ jobs:
|
||||
_:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ACTIONS_ALLOW_UNSECURE_COMMANDS: 'true'
|
||||
MYGLOBALENV3: myglobalval3
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
2
act/runner/testdata/commands/push.yml
vendored
2
act/runner/testdata/commands/push.yml
vendored
@@ -4,6 +4,8 @@ on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ACTIONS_ALLOW_UNSECURE_COMMANDS: 'true'
|
||||
steps:
|
||||
- name: TEST set-env
|
||||
run: echo "::set-env name=foo::bar"
|
||||
|
||||
Reference in New Issue
Block a user