diff --git a/act/runner/logger.go b/act/runner/logger.go index 2140ee85..08492bae 100644 --- a/act/runner/logger.go +++ b/act/runner/logger.go @@ -30,6 +30,11 @@ const ( gray = 37 ) +const ( + rawOutputField = "raw_output" + scriptLineCyanField = "script_line_cyan" +) + var ( colors []int nextColor int @@ -161,6 +166,8 @@ func withStepLogger(ctx context.Context, stepNumber int, stepID, stepName, stage type entryProcessor func(entry *logrus.Entry) *logrus.Entry +// valueMasker applies secrets and ::add-mask:: patterns to every log entry, including +// raw_output (command/stream) lines; there is no bypass by field. func valueMasker(insecureSecrets bool, secrets map[string]string) entryProcessor { return func(entry *logrus.Entry) *logrus.Entry { if insecureSecrets { @@ -227,8 +234,12 @@ func (f *jobLogFormatter) printColored(b *bytes.Buffer, entry *logrus.Entry) { debugFlag = "[DEBUG] " } - if entry.Data["raw_output"] == true { - fmt.Fprintf(b, "\x1b[%dm|\x1b[0m %s", f.color, entry.Message) + if entry.Data[rawOutputField] == true { + if entry.Data[scriptLineCyanField] == true { + fmt.Fprintf(b, "\x1b[%dm|\x1b[0m \x1b[36;1m%s\x1b[0m", f.color, entry.Message) + } else { + fmt.Fprintf(b, "\x1b[%dm|\x1b[0m %s", f.color, entry.Message) + } } else if entry.Data["dryrun"] == true { fmt.Fprintf(b, "\x1b[1m\x1b[%dm\x1b[7m*DRYRUN*\x1b[0m \x1b[%dm[%s] \x1b[0m%s%s", gray, f.color, job, debugFlag, entry.Message) } else { @@ -251,7 +262,7 @@ func (f *jobLogFormatter) print(b *bytes.Buffer, entry *logrus.Entry) { debugFlag = "[DEBUG] " } - if entry.Data["raw_output"] == true { + if entry.Data[rawOutputField] == true { fmt.Fprintf(b, "[%s] | %s", job, entry.Message) } else if entry.Data["dryrun"] == true { fmt.Fprintf(b, "*DRYRUN* [%s] %s%s", job, debugFlag, entry.Message) diff --git a/act/runner/step_action_local.go b/act/runner/step_action_local.go index bcd255f4..f68f72ca 100644 --- a/act/runner/step_action_local.go +++ b/act/runner/step_action_local.go @@ -44,6 +44,10 @@ func (sal *stepActionLocal) main() common.Executor { return nil } + printRunActionHeader(ctx, sal.Step, sal.env, sal.getRunContext()) + rawLogger := common.Logger(ctx).WithField(rawOutputField, true) + defer rawLogger.Infof("::endgroup::") + actionDir := filepath.Join(sal.getRunContext().Config.Workdir, sal.Step.Uses) localReader := func(ctx context.Context) actionYamlReader { diff --git a/act/runner/step_action_remote.go b/act/runner/step_action_remote.go index 30025ec9..26410361 100644 --- a/act/runner/step_action_remote.go +++ b/act/runner/step_action_remote.go @@ -166,6 +166,10 @@ func (sar *stepActionRemote) main() common.Executor { return common.NewPipelineExecutor( sar.prepareActionExecutor(), runStepExecutor(sar, stepStageMain, func(ctx context.Context) error { + printRunActionHeader(ctx, sar.Step, sar.env, sar.RunContext) + rawLogger := common.Logger(ctx).WithField(rawOutputField, true) + defer rawLogger.Infof("::endgroup::") + github := sar.getGithubContext(ctx) if sar.remoteAction.IsCheckout() && isLocalCheckout(github, sar.Step) && !sar.RunContext.Config.NoSkipCheckout { if sar.RunContext.Config.BindWorkdir { diff --git a/act/runner/step_run.go b/act/runner/step_run.go index 076f1759..e7b1024a 100644 --- a/act/runner/step_run.go +++ b/act/runner/step_run.go @@ -9,6 +9,7 @@ import ( "fmt" "maps" "runtime" + "slices" "strings" "gitea.com/gitea/act_runner/act/common" @@ -17,15 +18,18 @@ import ( "gitea.com/gitea/act_runner/act/model" "github.com/kballard/go-shellquote" + yaml "go.yaml.in/yaml/v4" ) type stepRun struct { - Step *model.Step - RunContext *RunContext - cmd []string - cmdline string - env map[string]string - WorkingDirectory string + Step *model.Step + RunContext *RunContext + cmd []string + cmdline string + env map[string]string + WorkingDirectory string + interpolatedScript string + shellCommand string } func (sr *stepRun) pre() common.Executor { @@ -39,15 +43,154 @@ func (sr *stepRun) main() common.Executor { return runStepExecutor(sr, stepStageMain, common.NewPipelineExecutor( sr.setupShellCommandExecutor(), func(ctx context.Context) error { - sr.getRunContext().ApplyExtraPath(ctx, &sr.env) - if he, ok := sr.getRunContext().JobContainer.(*container.HostEnvironment); ok && he != nil { + rc := sr.getRunContext() + // Apply ::add-path:: effects before printing so PATH is accurate in the env: block. + rc.ApplyExtraPath(ctx, &sr.env) + sr.printRunScriptActionDetails(ctx) + if he, ok := rc.JobContainer.(*container.HostEnvironment); ok && he != nil { return he.ExecWithCmdLine(sr.cmd, sr.cmdline, sr.env, "", sr.WorkingDirectory)(ctx) } - return sr.getRunContext().JobContainer.Exec(sr.cmd, sr.env, "", sr.WorkingDirectory)(ctx) + return rc.JobContainer.Exec(sr.cmd, sr.env, "", sr.WorkingDirectory)(ctx) }, )) } +// printRunScriptActionDetails mirrors actions/runner ScriptHandler.PrintActionDetails +// for script steps. +func (sr *stepRun) printRunScriptActionDetails(ctx context.Context) { + rawLogger := common.Logger(ctx).WithField(rawOutputField, true) + scriptLineLogger := rawLogger.WithField(scriptLineCyanField, true) + + normalized := strings.TrimRight(strings.ReplaceAll(sr.interpolatedScript, "\r\n", "\n"), "\n") + + rawLogger.Infof("::group::Run %s", sr.runScriptGroupTitle(normalized)) + + if normalized != "" { + for line := range strings.SplitSeq(normalized, "\n") { + scriptLineLogger.Info(line) + } + } + + rawLogger.Infof("shell: %s", sr.shellCommand) + + printStepEnvBlock(ctx, sr.Step, sr.env, sr.getRunContext()) + rawLogger.Infof("::endgroup::") +} + +// printRunActionHeader mirrors actions/runner's "Run " header for `uses:` steps, +// including the with: inputs and the step-level env: block. The caller is responsible +// for emitting ::endgroup:: after the action finishes. +func printRunActionHeader(ctx context.Context, step *model.Step, env map[string]string, rc *RunContext) { + if step == nil { + return + } + rawLogger := common.Logger(ctx).WithField(rawOutputField, true) + + title := step.Uses + if step.Name != "" { + title = step.Name + } + rawLogger.Infof("::group::Run %s", 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]) + } + } + + printStepEnvBlock(ctx, step, env, rc) +} + +// printStepEnvBlock emits the declared-env block (YAML order, internal vars filtered) +// shared by the run: and uses: "Run" headers. +func printStepEnvBlock(ctx context.Context, step *model.Step, env map[string]string, rc *RunContext) { + rawLogger := common.Logger(ctx).WithField(rawOutputField, true) + caseInsensitive := rc != nil && rc.JobContainer != nil && rc.JobContainer.IsEnvironmentCaseInsensitive() + var visible []string + for _, k := range stepDeclaredEnvKeysInOrder(step) { + if !isInternalEnvKey(k, caseInsensitive) { + visible = append(visible, k) + } + } + if len(visible) == 0 { + return + } + rawLogger.Infof("env:") + envLookup := env + if caseInsensitive { + envLookup = make(map[string]string, len(env)) + for k, v := range env { + envLookup[strings.ToUpper(k)] = v + } + } + for _, k := range visible { + lookupKey := k + if caseInsensitive { + lookupKey = strings.ToUpper(k) + } + rawLogger.Infof(" %s: %s", k, envLookup[lookupKey]) + } +} + +// isInternalEnvKey matches actions/runner's filtered set of vars that are hidden +// from the "Run" header's env: block because they are injected by the runner itself. +func isInternalEnvKey(k string, caseInsensitive bool) bool { + upper := k + if caseInsensitive { + upper = strings.ToUpper(k) + } + switch upper { + case "PATH", "HOME", "CI": + return true + } + return strings.HasPrefix(upper, "GITHUB_") || + strings.HasPrefix(upper, "GITEA_") || + strings.HasPrefix(upper, "RUNNER_") || + strings.HasPrefix(upper, "INPUT_") +} + +func (sr *stepRun) runScriptGroupTitle(normalizedScript string) string { + trimmed := strings.TrimLeft(normalizedScript, " \t\r\n") + if idx := strings.IndexAny(trimmed, "\r\n"); idx >= 0 { + trimmed = trimmed[:idx] + } + if trimmed != "" { + return trimmed + } + if sr.Step != nil { + if sr.Step.Name != "" { + return sr.Step.Name + } + return sr.Step.ID + } + return "" +} + +// stepDeclaredEnvKeysInOrder walks the raw YAML Env mapping so keys are emitted in +// the order the workflow author wrote them; step.Environment() decodes into a Go map +// and loses ordering. +func stepDeclaredEnvKeysInOrder(step *model.Step) []string { + if step == nil || step.Env.Kind != yaml.MappingNode { + return nil + } + content := step.Env.Content + keys := make([]string, 0, len(content)/2) + seen := make(map[string]struct{}, len(content)/2) + for i := 0; i+1 < len(content); i += 2 { + k := content[i] + if k.Kind != yaml.ScalarNode || k.Tag == "!!merge" || k.Value == "<<" { + continue + } + if _, dup := seen[k.Value]; dup { + continue + } + seen[k.Value] = struct{}{} + keys = append(keys, k.Value) + } + return keys +} + func (sr *stepRun) post() common.Executor { return func(ctx context.Context) error { return nil @@ -111,8 +254,10 @@ func (sr *stepRun) setupShellCommand(ctx context.Context) (name, script string, step := sr.Step script = sr.RunContext.NewStepExpressionEvaluator(ctx, sr).Interpolate(ctx, step.Run) + sr.interpolatedScript = script scCmd := step.ShellCommand() + sr.shellCommand = scCmd name = getScriptName(sr.RunContext, step) diff --git a/act/runner/step_run_print_test.go b/act/runner/step_run_print_test.go new file mode 100644 index 00000000..7a93925e --- /dev/null +++ b/act/runner/step_run_print_test.go @@ -0,0 +1,182 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// Copyright 2026 The nektos/act Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package runner + +import ( + "bytes" + "context" + "strings" + "testing" + + "gitea.com/gitea/act_runner/act/common" + "gitea.com/gitea/act_runner/act/model" + + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + yaml "go.yaml.in/yaml/v4" +) + +func TestRunScriptGroupTitle(t *testing.T) { + sr := &stepRun{Step: &model.Step{Name: "Build"}} + assert.Equal(t, "make build", sr.runScriptGroupTitle("make build")) + assert.Equal(t, "echo one", sr.runScriptGroupTitle(" \techo one\necho two")) + assert.Equal(t, "Build", sr.runScriptGroupTitle("")) + + sr = &stepRun{Step: &model.Step{ID: "s1"}} + assert.Equal(t, "s1", sr.runScriptGroupTitle("\n \n")) +} + +func TestStepDeclaredEnvOrderPreservesYAML(t *testing.T) { + raw := `id: s1 +run: "echo 1" +env: + GITHUB_TOKEN: tok + PATH: /custom/bin + MY_VAR: hello +` + var step model.Step + require.NoError(t, yaml.Unmarshal([]byte(raw), &step)) + assert.Equal(t, []string{"GITHUB_TOKEN", "PATH", "MY_VAR"}, stepDeclaredEnvKeysInOrder(&step)) +} + +func TestStepDeclaredEnvKeysInOrderEmpty(t *testing.T) { + assert.Nil(t, stepDeclaredEnvKeysInOrder(nil)) + assert.Empty(t, stepDeclaredEnvKeysInOrder(&model.Step{})) +} + +func TestStepDeclaredEnvKeysIgnoreYAMLMergeKey(t *testing.T) { + doc := ` +common: &common + COMMON_A: a + COMMON_B: b +step: + env: + LOCAL_BEFORE: before + <<: *common + COMMON_B: overridden + LOCAL_AFTER: after +` + var root struct { + Step model.Step `yaml:"step"` + } + require.NoError(t, yaml.Unmarshal([]byte(doc), &root)) + + keys := stepDeclaredEnvKeysInOrder(&root.Step) + assert.Equal(t, []string{"LOCAL_BEFORE", "COMMON_B", "LOCAL_AFTER"}, keys) +} + +func TestPrintRunScriptActionDetailsGolden(t *testing.T) { + raw := `id: s1 +name: Build +run: | + echo one + echo two +shell: pwsh +env: + PATH_PREFIX: /custom/bin + GITHUB_TOKEN: tok + GREETING: hello +` + var step model.Step + require.NoError(t, yaml.Unmarshal([]byte(raw), &step)) + + buf := &bytes.Buffer{} + logger := logrus.New() + logger.SetOutput(buf) + logger.SetLevel(logrus.InfoLevel) + logger.SetFormatter(&jobLogFormatter{color: cyan}) + entry := logger.WithFields(logrus.Fields{"job": "j1"}) + ctx := common.WithLogger(context.Background(), entry) + + sr := &stepRun{ + Step: &step, + RunContext: &RunContext{}, + shellCommand: "pwsh -command . '{0}'", + interpolatedScript: "echo one\necho two\n", + env: map[string]string{ + "PATH_PREFIX": "/custom/bin", + "GITHUB_TOKEN": "tok", + "GREETING": "hello", + }, + } + + sr.printRunScriptActionDetails(ctx) + + want := strings.Join([]string{ + "[j1] | ::group::Run echo one", + "[j1] | echo one", + "[j1] | echo two", + "[j1] | shell: pwsh -command . '{0}'", + "[j1] | env:", + "[j1] | PATH_PREFIX: /custom/bin", + "[j1] | GREETING: hello", + "[j1] | ::endgroup::", + "", + }, "\n") + assert.Equal(t, want, buf.String()) +} + +func TestPrintRunActionHeaderGolden(t *testing.T) { + raw := `id: s1 +uses: actions/checkout@v4 +with: + fetch-depth: "0" + token: secret +env: + CUSTOM: value + GITHUB_TOKEN: tok +` + var step model.Step + require.NoError(t, yaml.Unmarshal([]byte(raw), &step)) + + buf := &bytes.Buffer{} + logger := logrus.New() + logger.SetOutput(buf) + logger.SetLevel(logrus.InfoLevel) + logger.SetFormatter(&jobLogFormatter{color: cyan}) + entry := logger.WithFields(logrus.Fields{"job": "j1"}) + ctx := common.WithLogger(context.Background(), entry) + + printRunActionHeader(ctx, &step, map[string]string{"CUSTOM": "value", "GITHUB_TOKEN": "tok"}, &RunContext{}) + + want := strings.Join([]string{ + "[j1] | ::group::Run actions/checkout@v4", + "[j1] | with:", + "[j1] | fetch-depth: 0", + "[j1] | token: secret", + "[j1] | env:", + "[j1] | CUSTOM: value", + "", + }, "\n") + assert.Equal(t, want, buf.String()) +} + +func TestIsInternalEnvKey(t *testing.T) { + for _, k := range []string{"PATH", "HOME", "CI", "GITHUB_TOKEN", "GITEA_ACTIONS", "RUNNER_OS", "INPUT_FOO"} { + assert.True(t, isInternalEnvKey(k, false), k) + } + for _, k := range []string{"PATH_PREFIX", "MY_VAR", "GREETING", "HOMEPAGE"} { + assert.False(t, isInternalEnvKey(k, false), k) + } + assert.True(t, isInternalEnvKey("path", true)) + assert.False(t, isInternalEnvKey("path", false)) +} + +func TestPrintColoredScriptLineCyan(t *testing.T) { + f := &jobLogFormatter{color: cyan} + entry := &logrus.Entry{ + Level: logrus.InfoLevel, + Message: "echo one", + Data: logrus.Fields{ + "job": "j1", + rawOutputField: true, + scriptLineCyanField: true, + }, + } + buf := &bytes.Buffer{} + f.printColored(buf, entry) + assert.Equal(t, "\x1b[36m|\x1b[0m \x1b[36;1mecho one\x1b[0m", buf.String()) +}