diff --git a/act/runner/action.go b/act/runner/action.go index 928ade6a..d414407e 100644 --- a/act/runner/action.go +++ b/act/runner/action.go @@ -185,7 +185,7 @@ func runActionImpl(step actionStep, actionDir string, remoteAction *remoteAction if err := maybeCopyToActionDir(ctx, step, actionDir, actionPath, containerActionDir); err != nil { return err } - containerArgs := []string{"node", path.Join(containerActionDir, action.Runs.Main)} + containerArgs := nodeActionCommand(path.Join(containerActionDir, action.Runs.Main)) logger.Debugf("executing remote job container: %s", containerArgs) rc.ApplyExtraPath(ctx, step.getEnv()) @@ -232,6 +232,11 @@ func runActionImpl(step actionStep, actionDir string, remoteAction *remoteAction } } +// /var/run is a symlink, so without the flag node's import.meta.url differs from argv[1], which ESM actions compare. +func nodeActionCommand(script string) []string { + return []string{"node", "--preserve-symlinks-main", script} +} + // https://github.com/nektos/act/issues/228#issuecomment-629709055 // files in .gitignore are not copied in a Docker container // this causes issues with actions that ignore other important resources @@ -592,7 +597,7 @@ func runPreStep(step actionStep) common.Executor { return err } - containerArgs := []string{"node", path.Join(containerActionDir, action.Runs.Pre)} + containerArgs := nodeActionCommand(path.Join(containerActionDir, action.Runs.Pre)) logger.Debugf("executing remote job container: %s", containerArgs) rc.ApplyExtraPath(ctx, step.getEnv()) @@ -693,7 +698,7 @@ func runPostStep(step actionStep) common.Executor { populateEnvsFromSavedState(step.getEnv(), step, rc) - containerArgs := []string{"node", path.Join(containerActionDir, action.Runs.Post)} + containerArgs := nodeActionCommand(path.Join(containerActionDir, action.Runs.Post)) logger.Debugf("executing remote job container: %s", containerArgs) rc.ApplyExtraPath(ctx, step.getEnv()) diff --git a/act/runner/action_test.go b/act/runner/action_test.go index db4cc4b0..ff19b79c 100644 --- a/act/runner/action_test.go +++ b/act/runner/action_test.go @@ -8,6 +8,10 @@ import ( "context" "io" "io/fs" + "os" + "os/exec" + "path/filepath" + "runtime" "strings" "sync" "testing" @@ -234,7 +238,7 @@ func TestActionRunner(t *testing.T) { return true }) - cm.On("Exec", []string{"node", "/var/run/act/actions/dir/path"}, envMatcher, "", "").Return(func(ctx context.Context) error { return nil }) + cm.On("Exec", []string{"node", "--preserve-symlinks-main", "/var/run/act/actions/dir/path"}, envMatcher, "", "").Return(func(ctx context.Context) error { return nil }) tt.step.getRunContext().JobContainer = cm @@ -246,6 +250,32 @@ func TestActionRunner(t *testing.T) { } } +func TestNodeActionCommandPreservesSymlinkedEntrypoint(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink creation requires privileges on Windows") + } + requireHostTools(t, "node") + + actionDir := t.TempDir() + entrypoint := filepath.Join(actionDir, "index.mjs") + require.NoError(t, os.WriteFile(entrypoint, []byte(` +import {fileURLToPath} from "node:url"; +const self = fileURLToPath(import.meta.url); +if (process.argv[1] !== self) { + console.log("argv[1]:", process.argv[1], "import.meta.url:", self); + process.exitCode = 1; +} +`), 0o600)) + + symlinkedActionDir := filepath.Join(t.TempDir(), "action") + require.NoError(t, os.Symlink(actionDir, symlinkedActionDir)) + symlinkedEntrypoint := filepath.Join(symlinkedActionDir, "index.mjs") + + args := nodeActionCommand(symlinkedEntrypoint) + output, err := exec.CommandContext(t.Context(), args[0], args[1:]...).CombinedOutput() + require.NoError(t, err, "%s", output) +} + func TestNewStepContainerDoesNotUseDockerSecrets(t *testing.T) { cm := &containerMock{} diff --git a/act/runner/step_action_local_test.go b/act/runner/step_action_local_test.go index 1780d3d1..ccdd7bbc 100644 --- a/act/runner/step_action_local_test.go +++ b/act/runner/step_action_local_test.go @@ -254,7 +254,8 @@ func TestStepActionLocalPost(t *testing.T) { if tt.mocks.exec { suffixMatcher := func(suffix string) any { return mock.MatchedBy(func(array []string) bool { - return strings.HasSuffix(array[1], suffix) + return len(array) == 3 && array[0] == "node" && array[1] == "--preserve-symlinks-main" && + strings.HasSuffix(array[2], suffix) }) } cm.On("Exec", suffixMatcher("runner/local/action/post.js"), sal.env, "", "").Return(func(ctx context.Context) error { return tt.err }) diff --git a/act/runner/step_action_remote_test.go b/act/runner/step_action_remote_test.go index 3af74a94..1ac8dd2e 100644 --- a/act/runner/step_action_remote_test.go +++ b/act/runner/step_action_remote_test.go @@ -470,10 +470,10 @@ func TestStepActionRemotePost(t *testing.T) { if tt.mocks.exec { // Use mock.MatchedBy to match the exec command with hash-based path execMatcher := mock.MatchedBy(func(args []string) bool { - if len(args) != 2 { + if len(args) != 3 { return false } - return args[0] == "node" && strings.Contains(args[1], "post.js") + return args[0] == "node" && args[1] == "--preserve-symlinks-main" && strings.Contains(args[2], "post.js") }) cm.On("Exec", execMatcher, sar.env, "", "").Return(func(ctx context.Context) error { return tt.err })