From c57d6408009833544e8fdb8c0c005323fe32bb30 Mon Sep 17 00:00:00 2001 From: Propfend Date: Fri, 27 Mar 2026 12:40:17 +0100 Subject: [PATCH 1/4] add `--lenient-env-parsing` flag, so new lines are treated as continuation from the env var, and not discarded with error. --- cmd/input.go | 1 + cmd/root.go | 2 ++ pkg/container/container_types.go | 2 +- pkg/container/docker_run.go | 4 ++-- pkg/container/host_environment.go | 4 ++-- pkg/container/parse_env_file.go | 16 +++++++++++++--- pkg/runner/container_mock_test.go | 4 ++-- pkg/runner/runner.go | 1 + pkg/runner/step.go | 2 +- 9 files changed, 25 insertions(+), 11 deletions(-) diff --git a/cmd/input.go b/cmd/input.go index c348ef3acf5..7b2671979eb 100644 --- a/cmd/input.go +++ b/cmd/input.go @@ -65,6 +65,7 @@ type Input struct { validate bool strict bool concurrentJobs int + lenientEnvParsing bool } func (i *Input) resolve(path string) string { diff --git a/cmd/root.go b/cmd/root.go index 1fbb55bcfe1..20c9f7fe53e 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -130,6 +130,7 @@ func createRootCommand(ctx context.Context, input *Input, version string) *cobra rootCmd.PersistentFlags().StringArrayVarP(&input.localRepository, "local-repository", "", []string{}, "Replaces the specified repository and ref with a local folder (e.g. https://github.com/test/test@v0=/home/act/test or test/test@v0=/home/act/test, the latter matches any hosts or protocols)") rootCmd.PersistentFlags().BoolVar(&input.listOptions, "list-options", false, "Print a json structure of compatible options") rootCmd.PersistentFlags().IntVar(&input.concurrentJobs, "concurrent-jobs", 0, "Maximum number of concurrent jobs to run. Default is the number of CPUs available.") + rootCmd.PersistentFlags().BoolVar(&input.lenientEnvParsing, "lenient-env-parsing", false, "Parse GITHUB_ENV leniently, treating lines without '=' or '<<' as multiline value continuations instead of errors") rootCmd.SetArgs(args()) return rootCmd } @@ -645,6 +646,7 @@ func newRunCommand(ctx context.Context, input *Input) func(*cobra.Command, []str Matrix: matrixes, ContainerNetworkMode: docker_container.NetworkMode(input.networkName), ConcurrentJobs: input.concurrentJobs, + LenientEnvParsing: input.lenientEnvParsing, } if input.useNewActionCache || len(input.localRepository) > 0 { if input.actionOfflineMode { diff --git a/pkg/container/container_types.go b/pkg/container/container_types.go index 627e5d8eb1b..a6cbd465caa 100644 --- a/pkg/container/container_types.go +++ b/pkg/container/container_types.go @@ -49,7 +49,7 @@ type Container interface { Pull(forcePull bool) common.Executor Start(attach bool) common.Executor Exec(command []string, env map[string]string, user, workdir string) common.Executor - UpdateFromEnv(srcPath string, env *map[string]string) common.Executor + UpdateFromEnv(srcPath string, env *map[string]string, lenient bool) common.Executor UpdateFromImageEnv(env *map[string]string) common.Executor Remove() common.Executor Close() common.Executor diff --git a/pkg/container/docker_run.go b/pkg/container/docker_run.go index 985ba2f8d95..42f323dfdcb 100644 --- a/pkg/container/docker_run.go +++ b/pkg/container/docker_run.go @@ -145,8 +145,8 @@ func (cr *containerReference) GetContainerArchive(ctx context.Context, srcPath s return result.Content, err } -func (cr *containerReference) UpdateFromEnv(srcPath string, env *map[string]string) common.Executor { - return parseEnvFile(cr, srcPath, env).IfNot(common.Dryrun) +func (cr *containerReference) UpdateFromEnv(srcPath string, env *map[string]string, lenient bool) common.Executor { + return parseEnvFile(cr, srcPath, env, lenient).IfNot(common.Dryrun) } func (cr *containerReference) UpdateFromImageEnv(env *map[string]string) common.Executor { diff --git a/pkg/container/host_environment.go b/pkg/container/host_environment.go index b2dc67c8240..beb6c44759b 100644 --- a/pkg/container/host_environment.go +++ b/pkg/container/host_environment.go @@ -371,8 +371,8 @@ func (e *HostEnvironment) ExecWithCmdLine(command []string, cmdline string, env } } -func (e *HostEnvironment) UpdateFromEnv(srcPath string, env *map[string]string) common.Executor { - return parseEnvFile(e, srcPath, env) +func (e *HostEnvironment) UpdateFromEnv(srcPath string, env *map[string]string, lenient bool) common.Executor { + return parseEnvFile(e, srcPath, env, lenient) } func (e *HostEnvironment) Remove() common.Executor { diff --git a/pkg/container/parse_env_file.go b/pkg/container/parse_env_file.go index b1c21f51b9a..428e3abcf88 100644 --- a/pkg/container/parse_env_file.go +++ b/pkg/container/parse_env_file.go @@ -11,7 +11,7 @@ import ( "github.com/nektos/act/pkg/common" ) -func parseEnvFile(e Container, srcPath string, env *map[string]string) common.Executor { +func parseEnvFile(e Container, srcPath string, env *map[string]string, lenient bool) common.Executor { localEnv := *env return func(ctx context.Context) error { envTar, err := e.GetContainerArchive(ctx, srcPath) @@ -27,6 +27,7 @@ func parseEnvFile(e Container, srcPath string, env *map[string]string) common.Ex s := bufio.NewScanner(reader) s.Buffer(nil, 1024*1024*1024) // increase buffer to 1GB to avoid scanner buffer overflow firstLine := true + var lastKey string for s.Scan() { line := s.Text() if firstLine { @@ -39,7 +40,8 @@ func parseEnvFile(e Container, srcPath string, env *map[string]string) common.Ex singleLineEnv := strings.Index(line, "=") multiLineEnv := strings.Index(line, "<<") if singleLineEnv != -1 && (multiLineEnv == -1 || singleLineEnv < multiLineEnv) { - localEnv[line[:singleLineEnv]] = line[singleLineEnv+1:] + lastKey = line[:singleLineEnv] + localEnv[lastKey] = line[singleLineEnv+1:] } else if multiLineEnv != -1 { multiLineEnvContent := "" multiLineEnvDelimiter := line[multiLineEnv+2:] @@ -58,12 +60,20 @@ func parseEnvFile(e Container, srcPath string, env *map[string]string) common.Ex if !delimiterFound { return fmt.Errorf("invalid format delimiter '%v' not found before end of file", multiLineEnvDelimiter) } - localEnv[line[:multiLineEnv]] = multiLineEnvContent + lastKey = line[:multiLineEnv] + localEnv[lastKey] = multiLineEnvContent + } else if lenient { + // In lenient mode, treat unrecognized lines as continuations + // of the previous value (bare multiline from printenv output), just like GitHub Actions. + if lastKey != "" { + localEnv[lastKey] += "\n" + line + } } else { return fmt.Errorf("invalid format '%v', expected a line with '=' or '<<'", line) } } env = &localEnv + return s.Err() } } diff --git a/pkg/runner/container_mock_test.go b/pkg/runner/container_mock_test.go index 04d6261b167..bd6a684063e 100644 --- a/pkg/runner/container_mock_test.go +++ b/pkg/runner/container_mock_test.go @@ -40,8 +40,8 @@ func (cm *containerMock) Close() common.Executor { return args.Get(0).(func(context.Context) error) } -func (cm *containerMock) UpdateFromEnv(srcPath string, env *map[string]string) common.Executor { - args := cm.Called(srcPath, env) +func (cm *containerMock) UpdateFromEnv(srcPath string, env *map[string]string, lenient bool) common.Executor { + args := cm.Called(srcPath, env, lenient) return args.Get(0).(func(context.Context) error) } diff --git a/pkg/runner/runner.go b/pkg/runner/runner.go index b72bbc69746..ab439d41000 100644 --- a/pkg/runner/runner.go +++ b/pkg/runner/runner.go @@ -62,6 +62,7 @@ type Config struct { ContainerNetworkMode docker_container.NetworkMode // the network mode of job containers (the value of --network) ActionCache ActionCache // Use a custom ActionCache Implementation ConcurrentJobs int // Number of max concurrent jobs + LenientEnvParsing bool // Parse GITHUB_ENV leniently, treating unrecognized lines as multiline continuations } func (config *Config) GetConcurrentJobs() int { diff --git a/pkg/runner/step.go b/pkg/runner/step.go index 5ee0dbcf050..85acb8ca733 100644 --- a/pkg/runner/step.go +++ b/pkg/runner/step.go @@ -81,7 +81,7 @@ func processRunnerSummaryCommand(ctx context.Context, fileName string, rc *RunCo func processRunnerEnvFileCommand(ctx context.Context, fileName string, rc *RunContext, setter func(context.Context, map[string]string, string)) error { env := map[string]string{} - err := rc.JobContainer.UpdateFromEnv(path.Join(rc.JobContainer.GetActPath(), fileName), &env)(ctx) + err := rc.JobContainer.UpdateFromEnv(path.Join(rc.JobContainer.GetActPath(), fileName), &env, rc.Config.LenientEnvParsing)(ctx) if err != nil { return err } From 17e4c394b3b4d8c9eaf8050466ebc0cb8a23a8e3 Mon Sep 17 00:00:00 2001 From: Propfend Date: Mon, 30 Mar 2026 10:52:09 +0200 Subject: [PATCH 2/4] change how the vars are parsed so that is the default. --- cmd/input.go | 1 - cmd/root.go | 2 -- pkg/container/container_types.go | 2 +- pkg/container/docker_run.go | 4 ++-- pkg/container/host_environment.go | 4 ++-- pkg/container/parse_env_file.go | 12 +++--------- pkg/runner/container_mock_test.go | 4 ++-- pkg/runner/runner.go | 1 - pkg/runner/step.go | 2 +- 9 files changed, 11 insertions(+), 21 deletions(-) diff --git a/cmd/input.go b/cmd/input.go index 7b2671979eb..c348ef3acf5 100644 --- a/cmd/input.go +++ b/cmd/input.go @@ -65,7 +65,6 @@ type Input struct { validate bool strict bool concurrentJobs int - lenientEnvParsing bool } func (i *Input) resolve(path string) string { diff --git a/cmd/root.go b/cmd/root.go index 20c9f7fe53e..1fbb55bcfe1 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -130,7 +130,6 @@ func createRootCommand(ctx context.Context, input *Input, version string) *cobra rootCmd.PersistentFlags().StringArrayVarP(&input.localRepository, "local-repository", "", []string{}, "Replaces the specified repository and ref with a local folder (e.g. https://github.com/test/test@v0=/home/act/test or test/test@v0=/home/act/test, the latter matches any hosts or protocols)") rootCmd.PersistentFlags().BoolVar(&input.listOptions, "list-options", false, "Print a json structure of compatible options") rootCmd.PersistentFlags().IntVar(&input.concurrentJobs, "concurrent-jobs", 0, "Maximum number of concurrent jobs to run. Default is the number of CPUs available.") - rootCmd.PersistentFlags().BoolVar(&input.lenientEnvParsing, "lenient-env-parsing", false, "Parse GITHUB_ENV leniently, treating lines without '=' or '<<' as multiline value continuations instead of errors") rootCmd.SetArgs(args()) return rootCmd } @@ -646,7 +645,6 @@ func newRunCommand(ctx context.Context, input *Input) func(*cobra.Command, []str Matrix: matrixes, ContainerNetworkMode: docker_container.NetworkMode(input.networkName), ConcurrentJobs: input.concurrentJobs, - LenientEnvParsing: input.lenientEnvParsing, } if input.useNewActionCache || len(input.localRepository) > 0 { if input.actionOfflineMode { diff --git a/pkg/container/container_types.go b/pkg/container/container_types.go index a6cbd465caa..627e5d8eb1b 100644 --- a/pkg/container/container_types.go +++ b/pkg/container/container_types.go @@ -49,7 +49,7 @@ type Container interface { Pull(forcePull bool) common.Executor Start(attach bool) common.Executor Exec(command []string, env map[string]string, user, workdir string) common.Executor - UpdateFromEnv(srcPath string, env *map[string]string, lenient bool) common.Executor + UpdateFromEnv(srcPath string, env *map[string]string) common.Executor UpdateFromImageEnv(env *map[string]string) common.Executor Remove() common.Executor Close() common.Executor diff --git a/pkg/container/docker_run.go b/pkg/container/docker_run.go index 42f323dfdcb..985ba2f8d95 100644 --- a/pkg/container/docker_run.go +++ b/pkg/container/docker_run.go @@ -145,8 +145,8 @@ func (cr *containerReference) GetContainerArchive(ctx context.Context, srcPath s return result.Content, err } -func (cr *containerReference) UpdateFromEnv(srcPath string, env *map[string]string, lenient bool) common.Executor { - return parseEnvFile(cr, srcPath, env, lenient).IfNot(common.Dryrun) +func (cr *containerReference) UpdateFromEnv(srcPath string, env *map[string]string) common.Executor { + return parseEnvFile(cr, srcPath, env).IfNot(common.Dryrun) } func (cr *containerReference) UpdateFromImageEnv(env *map[string]string) common.Executor { diff --git a/pkg/container/host_environment.go b/pkg/container/host_environment.go index beb6c44759b..b2dc67c8240 100644 --- a/pkg/container/host_environment.go +++ b/pkg/container/host_environment.go @@ -371,8 +371,8 @@ func (e *HostEnvironment) ExecWithCmdLine(command []string, cmdline string, env } } -func (e *HostEnvironment) UpdateFromEnv(srcPath string, env *map[string]string, lenient bool) common.Executor { - return parseEnvFile(e, srcPath, env, lenient) +func (e *HostEnvironment) UpdateFromEnv(srcPath string, env *map[string]string) common.Executor { + return parseEnvFile(e, srcPath, env) } func (e *HostEnvironment) Remove() common.Executor { diff --git a/pkg/container/parse_env_file.go b/pkg/container/parse_env_file.go index 428e3abcf88..b73beb137da 100644 --- a/pkg/container/parse_env_file.go +++ b/pkg/container/parse_env_file.go @@ -11,7 +11,7 @@ import ( "github.com/nektos/act/pkg/common" ) -func parseEnvFile(e Container, srcPath string, env *map[string]string, lenient bool) common.Executor { +func parseEnvFile(e Container, srcPath string, env *map[string]string) common.Executor { localEnv := *env return func(ctx context.Context) error { envTar, err := e.GetContainerArchive(ctx, srcPath) @@ -62,14 +62,8 @@ func parseEnvFile(e Container, srcPath string, env *map[string]string, lenient b } lastKey = line[:multiLineEnv] localEnv[lastKey] = multiLineEnvContent - } else if lenient { - // In lenient mode, treat unrecognized lines as continuations - // of the previous value (bare multiline from printenv output), just like GitHub Actions. - if lastKey != "" { - localEnv[lastKey] += "\n" + line - } - } else { - return fmt.Errorf("invalid format '%v', expected a line with '=' or '<<'", line) + } else if lastKey != "" { + localEnv[lastKey] += "\n" + line } } env = &localEnv diff --git a/pkg/runner/container_mock_test.go b/pkg/runner/container_mock_test.go index bd6a684063e..04d6261b167 100644 --- a/pkg/runner/container_mock_test.go +++ b/pkg/runner/container_mock_test.go @@ -40,8 +40,8 @@ func (cm *containerMock) Close() common.Executor { return args.Get(0).(func(context.Context) error) } -func (cm *containerMock) UpdateFromEnv(srcPath string, env *map[string]string, lenient bool) common.Executor { - args := cm.Called(srcPath, env, lenient) +func (cm *containerMock) UpdateFromEnv(srcPath string, env *map[string]string) common.Executor { + args := cm.Called(srcPath, env) return args.Get(0).(func(context.Context) error) } diff --git a/pkg/runner/runner.go b/pkg/runner/runner.go index ab439d41000..b72bbc69746 100644 --- a/pkg/runner/runner.go +++ b/pkg/runner/runner.go @@ -62,7 +62,6 @@ type Config struct { ContainerNetworkMode docker_container.NetworkMode // the network mode of job containers (the value of --network) ActionCache ActionCache // Use a custom ActionCache Implementation ConcurrentJobs int // Number of max concurrent jobs - LenientEnvParsing bool // Parse GITHUB_ENV leniently, treating unrecognized lines as multiline continuations } func (config *Config) GetConcurrentJobs() int { diff --git a/pkg/runner/step.go b/pkg/runner/step.go index 85acb8ca733..5ee0dbcf050 100644 --- a/pkg/runner/step.go +++ b/pkg/runner/step.go @@ -81,7 +81,7 @@ func processRunnerSummaryCommand(ctx context.Context, fileName string, rc *RunCo func processRunnerEnvFileCommand(ctx context.Context, fileName string, rc *RunContext, setter func(context.Context, map[string]string, string)) error { env := map[string]string{} - err := rc.JobContainer.UpdateFromEnv(path.Join(rc.JobContainer.GetActPath(), fileName), &env, rc.Config.LenientEnvParsing)(ctx) + err := rc.JobContainer.UpdateFromEnv(path.Join(rc.JobContainer.GetActPath(), fileName), &env)(ctx) if err != nil { return err } From 5b17e0a3cfc60384f758f5458f21bf3d7ad85286 Mon Sep 17 00:00:00 2001 From: Propfend Date: Mon, 30 Mar 2026 22:32:05 +0200 Subject: [PATCH 3/4] add tests for env parsing. --- pkg/container/parse_env_file_test.go | 188 +++++++++++++++++++++++++++ 1 file changed, 188 insertions(+) create mode 100644 pkg/container/parse_env_file_test.go diff --git a/pkg/container/parse_env_file_test.go b/pkg/container/parse_env_file_test.go new file mode 100644 index 00000000000..13e4100c94f --- /dev/null +++ b/pkg/container/parse_env_file_test.go @@ -0,0 +1,188 @@ +package container + +import ( + "archive/tar" + "bytes" + "context" + "io" + "testing" + + "github.com/nektos/act/pkg/common" + assert "github.com/stretchr/testify/assert" +) + +type mockContainer struct { + content string + err error +} + +func (m *mockContainer) GetContainerArchive(_ context.Context, _ string) (io.ReadCloser, error) { + if m.err != nil { + return nil, m.err + } + + var buf bytes.Buffer + tw := tar.NewWriter(&buf) + hdr := &tar.Header{ + Name: "env", + Size: int64(len(m.content)), + } + _ = tw.WriteHeader(hdr) + _, _ = tw.Write([]byte(m.content)) + _ = tw.Close() + + return io.NopCloser(&buf), nil +} + +func (m *mockContainer) Create([]string, []string) common.Executor { return nil } +func (m *mockContainer) Copy(string, ...*FileEntry) common.Executor { return nil } +func (m *mockContainer) CopyTarStream(context.Context, string, io.Reader) error { return nil } +func (m *mockContainer) CopyDir(string, string, bool) common.Executor { return nil } +func (m *mockContainer) Pull(bool) common.Executor { return nil } +func (m *mockContainer) Start(bool) common.Executor { return nil } +func (m *mockContainer) Exec([]string, map[string]string, string, string) common.Executor { + return nil +} +func (m *mockContainer) UpdateFromEnv(string, *map[string]string) common.Executor { return nil } +func (m *mockContainer) UpdateFromImageEnv(*map[string]string) common.Executor { return nil } +func (m *mockContainer) Remove() common.Executor { return nil } +func (m *mockContainer) Close() common.Executor { return nil } +func (m *mockContainer) ReplaceLogWriter(io.Writer, io.Writer) (io.Writer, io.Writer) { return nil, nil } +func (m *mockContainer) GetHealth(context.Context) Health { return HealthHealthy } + +func TestParseEnvFile(t *testing.T) { + tests := []struct { + name string + content string + initial map[string]string + expected map[string]string + }{ + { + name: "single line env var", + content: "FOO=bar", + initial: map[string]string{}, + expected: map[string]string{ + "FOO": "bar", + }, + }, + { + name: "multiple single line env vars", + content: "FOO=bar\nBAZ=qux", + initial: map[string]string{}, + expected: map[string]string{ + "FOO": "bar", + "BAZ": "qux", + }, + }, + { + name: "value containing equals sign", + content: "FOO=bar=baz", + initial: map[string]string{}, + expected: map[string]string{ + "FOO": "bar=baz", + }, + }, + { + name: "multiline env var with heredoc delimiter", + content: "FOO< Date: Mon, 30 Mar 2026 23:25:13 +0200 Subject: [PATCH 4/4] fix linter. --- pkg/container/parse_env_file_test.go | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/pkg/container/parse_env_file_test.go b/pkg/container/parse_env_file_test.go index 13e4100c94f..418f5e17110 100644 --- a/pkg/container/parse_env_file_test.go +++ b/pkg/container/parse_env_file_test.go @@ -34,21 +34,23 @@ func (m *mockContainer) GetContainerArchive(_ context.Context, _ string) (io.Rea return io.NopCloser(&buf), nil } -func (m *mockContainer) Create([]string, []string) common.Executor { return nil } -func (m *mockContainer) Copy(string, ...*FileEntry) common.Executor { return nil } +func (m *mockContainer) Create([]string, []string) common.Executor { return nil } +func (m *mockContainer) Copy(string, ...*FileEntry) common.Executor { return nil } func (m *mockContainer) CopyTarStream(context.Context, string, io.Reader) error { return nil } -func (m *mockContainer) CopyDir(string, string, bool) common.Executor { return nil } -func (m *mockContainer) Pull(bool) common.Executor { return nil } -func (m *mockContainer) Start(bool) common.Executor { return nil } +func (m *mockContainer) CopyDir(string, string, bool) common.Executor { return nil } +func (m *mockContainer) Pull(bool) common.Executor { return nil } +func (m *mockContainer) Start(bool) common.Executor { return nil } func (m *mockContainer) Exec([]string, map[string]string, string, string) common.Executor { return nil } -func (m *mockContainer) UpdateFromEnv(string, *map[string]string) common.Executor { return nil } -func (m *mockContainer) UpdateFromImageEnv(*map[string]string) common.Executor { return nil } -func (m *mockContainer) Remove() common.Executor { return nil } -func (m *mockContainer) Close() common.Executor { return nil } -func (m *mockContainer) ReplaceLogWriter(io.Writer, io.Writer) (io.Writer, io.Writer) { return nil, nil } -func (m *mockContainer) GetHealth(context.Context) Health { return HealthHealthy } +func (m *mockContainer) UpdateFromEnv(string, *map[string]string) common.Executor { return nil } +func (m *mockContainer) UpdateFromImageEnv(*map[string]string) common.Executor { return nil } +func (m *mockContainer) Remove() common.Executor { return nil } +func (m *mockContainer) Close() common.Executor { return nil } +func (m *mockContainer) ReplaceLogWriter(io.Writer, io.Writer) (io.Writer, io.Writer) { + return nil, nil +} +func (m *mockContainer) GetHealth(context.Context) Health { return HealthHealthy } func TestParseEnvFile(t *testing.T) { tests := []struct {