diff --git a/act/container/container_types.go b/act/container/container_types.go index 16b9ed99..9277b6e0 100644 --- a/act/container/container_types.go +++ b/act/container/container_types.go @@ -47,6 +47,7 @@ type NewContainerInput struct { // Gitea specific AutoRemove bool ValidVolumes []string + AllocatePTY bool // allocate a pseudo-TTY for the container's exec processes } // FileEntry is a file to copy to a container diff --git a/act/container/docker_run.go b/act/container/docker_run.go index 1959ab24..15251721 100644 --- a/act/container/docker_run.go +++ b/act/container/docker_run.go @@ -41,7 +41,6 @@ import ( "github.com/moby/moby/client" specs "github.com/opencontainers/image-spec/specs-go/v1" "github.com/spf13/pflag" - "golang.org/x/term" ) // NewContainer creates a reference to a container @@ -450,7 +449,6 @@ func (cr *containerReference) create(capAdd, capDrop []string) common.Executor { return nil } logger := common.Logger(ctx) - isTerminal := term.IsTerminal(int(os.Stdout.Fd())) input := cr.input exposedPorts, err := convertPortSet(input.ExposedPorts) if err != nil { @@ -466,7 +464,7 @@ func (cr *containerReference) create(capAdd, capDrop []string) common.Executor { WorkingDir: input.WorkingDir, Env: input.Env, ExposedPorts: exposedPorts, - Tty: isTerminal, + Tty: input.AllocatePTY, } // For Gitea, reduce log noise // logger.Debugf("Common container.Config ==> %+v", config) @@ -604,7 +602,7 @@ func (cr *containerReference) exec(cmd []string, env map[string]string, user, wo } logger.Debugf("Exec command '%s'", cmd) - isTerminal := term.IsTerminal(int(os.Stdout.Fd())) + isTerminal := cr.input.AllocatePTY envList := make([]string, 0) for k, v := range env { envList = append(envList, fmt.Sprintf("%s=%s", k, v)) @@ -899,7 +897,7 @@ func (cr *containerReference) attach() common.Executor { if err != nil { return fmt.Errorf("failed to attach to container: %w", err) } - isTerminal := term.IsTerminal(int(os.Stdout.Fd())) + isTerminal := cr.input.AllocatePTY var outWriter io.Writer outWriter = cr.input.Stdout diff --git a/act/container/host_environment.go b/act/container/host_environment.go index 121266b8..497039bf 100644 --- a/act/container/host_environment.go +++ b/act/container/host_environment.go @@ -19,6 +19,7 @@ import ( "strconv" "strings" "sync" + "sync/atomic" "time" "gitea.com/gitea/runner/act/common" @@ -42,6 +43,7 @@ type HostEnvironment struct { ActPath string CleanUp func() StdOut io.Writer + AllocatePTY bool // allocate a pseudo-TTY for each step's process mu sync.Mutex runningPIDs map[int]struct{} @@ -200,12 +202,12 @@ func (e *HostEnvironment) Start(_ bool) common.Executor { type ptyWriter struct { Out io.Writer - AutoStop bool + AutoStop atomic.Bool dirtyLine bool } func (w *ptyWriter) Write(buf []byte) (int, error) { - if w.AutoStop && len(buf) > 0 && buf[len(buf)-1] == 4 { + if w.AutoStop.Load() && len(buf) > 0 && buf[len(buf)-1] == 4 { n, err := w.Out.Write(buf[:len(buf)-1]) if err != nil { return n, err @@ -335,21 +337,20 @@ func (e *HostEnvironment) exec(ctx context.Context, command []string, cmdline st tty.Close() } }() - if true /* allocate Terminal */ { + if e.AllocatePTY { var err error ppty, tty, err = setupPty(cmd, cmdline) if err != nil { common.Logger(ctx).Debugf("Failed to setup Pty %v\n", err.Error()) } } - writer := &ptyWriter{Out: e.StdOut} - logctx, finishLog := context.WithCancel(context.Background()) + var writer *ptyWriter + var logctx context.Context if ppty != nil { + writer = &ptyWriter{Out: e.StdOut} + var finishLog context.CancelFunc + logctx, finishLog = context.WithCancel(context.Background()) go copyPtyOutput(writer, ppty, finishLog) - } else { - finishLog() - } - if ppty != nil { go writeKeepAlive(ppty) } // Split Start/Wait so the PID can be registered before the process can exit; @@ -379,14 +380,11 @@ func (e *HostEnvironment) exec(ctx context.Context, command []string, cmdline st return err } if tty != nil { - writer.AutoStop = true + writer.AutoStop.Store(true) if _, err := tty.WriteString("\x04"); err != nil { common.Logger(ctx).Debug("Failed to write EOT") } - } - <-logctx.Done() - - if ppty != nil { + <-logctx.Done() ppty.Close() ppty = nil } diff --git a/act/container/host_environment_test.go b/act/container/host_environment_test.go index 4101e8ba..a9911d19 100644 --- a/act/container/host_environment_test.go +++ b/act/container/host_environment_test.go @@ -6,12 +6,14 @@ package container import ( "archive/tar" + "bytes" "context" "io" "os" "path" "path/filepath" "runtime" + "strings" "testing" "gitea.com/gitea/runner/act/common" @@ -100,6 +102,45 @@ func TestHostEnvironmentExecExitCode(t *testing.T) { assert.Equal(t, "Process completed with exit code 3.", err.Error()) } +func TestHostEnvironmentAllocatePTY(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("uses POSIX shell") + } + for _, tc := range []struct { + name string + allocPTY bool + expect string + }{ + {name: "off", allocPTY: false, expect: "NOTTY"}, + {name: "on", allocPTY: true, expect: "TTY"}, + } { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + buf := &bytes.Buffer{} + e := &HostEnvironment{ + Path: filepath.Join(dir, "path"), + TmpDir: filepath.Join(dir, "tmp"), + ToolCache: filepath.Join(dir, "tool_cache"), + ActPath: filepath.Join(dir, "act_path"), + StdOut: buf, + Workdir: filepath.Join(dir, "path"), + AllocatePTY: tc.allocPTY, + } + for _, p := range []string{e.Path, e.TmpDir, e.ToolCache, e.ActPath} { + require.NoError(t, os.MkdirAll(p, 0o700)) + } + + err := e.Exec( + []string{"sh", "-c", "[ -t 1 ] && printf TTY || printf NOTTY"}, + map[string]string{"PATH": os.Getenv("PATH")}, "", "", + )(context.Background()) + require.NoError(t, err) + got := strings.TrimSpace(strings.ReplaceAll(buf.String(), "\r", "")) + assert.Equal(t, tc.expect, got) + }) + } +} + func TestHostEnvironmentRemoveCleansWorkdir(t *testing.T) { logger := logrus.New() ctx := common.WithLogger(context.Background(), logrus.NewEntry(logger)) diff --git a/act/runner/action.go b/act/runner/action.go index 5a50c2c1..e07def75 100644 --- a/act/runner/action.go +++ b/act/runner/action.go @@ -456,6 +456,7 @@ func newStepContainer(ctx context.Context, step step, image string, cmd, entrypo Options: rc.Config.ContainerOptions, AutoRemove: rc.Config.AutoRemove, ValidVolumes: rc.Config.ValidVolumes, + AllocatePTY: rc.Config.AllocatePTY, }) return stepContainer } diff --git a/act/runner/run_context.go b/act/runner/run_context.go index dd21767c..23a0d67e 100644 --- a/act/runner/run_context.go +++ b/act/runner/run_context.go @@ -229,7 +229,8 @@ func (rc *RunContext) startHostEnvironment() common.Executor { CleanUp: func() { os.RemoveAll(miscpath) }, - StdOut: logWriter, + StdOut: logWriter, + AllocatePTY: rc.Config.AllocatePTY, } rc.cleanUpJobContainer = rc.JobContainer.Remove() for k, v := range rc.JobContainer.GetRunnerContext(ctx) { @@ -371,6 +372,7 @@ func (rc *RunContext) startJobContainer() common.Executor { NetworkAliases: []string{serviceID}, ExposedPorts: exposedPorts, PortBindings: portBindings, + AllocatePTY: rc.Config.AllocatePTY, }) rc.ServiceContainers = append(rc.ServiceContainers, c) } @@ -431,6 +433,7 @@ func (rc *RunContext) startJobContainer() common.Executor { Options: rc.options(ctx), AutoRemove: rc.Config.AutoRemove, ValidVolumes: rc.Config.ValidVolumes, + AllocatePTY: rc.Config.AllocatePTY, }) if rc.JobContainer == nil { return errors.New("Failed to create job container") diff --git a/act/runner/runner.go b/act/runner/runner.go index ba88ad4a..0da97e42 100644 --- a/act/runner/runner.go +++ b/act/runner/runner.go @@ -79,6 +79,7 @@ type Config struct { ValidVolumes []string // only volumes (and bind mounts) in this slice can be mounted on the job container or service containers InsecureSkipTLS bool // whether to skip verifying TLS certificate of the Gitea instance MaxParallel int // max parallel jobs to run across all workflows (0 = no limit, uses CPU count) + AllocatePTY bool // allocate a pseudo-TTY for each step's process } // GetToken: Adapt to Gitea diff --git a/act/runner/step_docker.go b/act/runner/step_docker.go index 909cc6ea..252de6ea 100644 --- a/act/runner/step_docker.go +++ b/act/runner/step_docker.go @@ -139,6 +139,7 @@ func (sd *stepDocker) newStepContainer(ctx context.Context, image string, cmd, e Platform: rc.Config.ContainerArchitecture, AutoRemove: rc.Config.AutoRemove, ValidVolumes: rc.Config.ValidVolumes, + AllocatePTY: rc.Config.AllocatePTY, }) return stepContainer } diff --git a/act/runner/step_docker_test.go b/act/runner/step_docker_test.go index 62aca42c..230fa6b4 100644 --- a/act/runner/step_docker_test.go +++ b/act/runner/step_docker_test.go @@ -109,6 +109,55 @@ func TestStepDockerMain(t *testing.T) { cm.AssertExpectations(t) } +func TestStepDockerNewStepContainerAllocatePTY(t *testing.T) { + for _, tc := range []struct { + name string + allocPTY bool + }{ + {name: "off", allocPTY: false}, + {name: "on", allocPTY: true}, + } { + t.Run(tc.name, func(t *testing.T) { + cm := &containerMock{} + + var captured *container.NewContainerInput + origContainerNewContainer := ContainerNewContainer + ContainerNewContainer = func(input *container.NewContainerInput) container.ExecutionsEnvironment { + captured = input + return cm + } + defer func() { + ContainerNewContainer = origContainerNewContainer + }() + + ctx := context.Background() + sd := &stepDocker{ + RunContext: &RunContext{ + StepResults: map[string]*model.StepResult{}, + Config: &Config{ + AllocatePTY: tc.allocPTY, + PlatformPicker: func(_ []string) string { + return "node:14" + }, + }, + Run: &model.Run{ + JobID: "1", + Workflow: &model.Workflow{ + Jobs: map[string]*model.Job{"1": {}}, + }, + }, + JobContainer: cm, + }, + Step: &model.Step{ID: "1", Uses: "docker://node:14"}, + } + sd.RunContext.ExprEval = sd.RunContext.NewExpressionEvaluator(ctx) + + _ = sd.newStepContainer(ctx, "node:14", []string{"echo", "hi"}, nil) + assert.Equal(t, tc.allocPTY, captured.AllocatePTY) + }) + } +} + func TestStepDockerPrePost(t *testing.T) { ctx := context.Background() sd := &stepDocker{} diff --git a/internal/app/run/runner.go b/internal/app/run/runner.go index e7edaef6..6f83e560 100644 --- a/internal/app/run/runner.go +++ b/internal/app/run/runner.go @@ -347,6 +347,7 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report. Workdir: workdir, BindWorkdir: r.cfg.Container.BindWorkdir, ActionCacheDir: filepath.FromSlash(r.cfg.Host.WorkdirParent), + AllocatePTY: r.cfg.Runner.AllocatePTY, ReuseContainers: false, ForcePull: r.cfg.Container.ForcePull, diff --git a/internal/pkg/config/config.example.yaml b/internal/pkg/config/config.example.yaml index eded51c3..24fc478d 100644 --- a/internal/pkg/config/config.example.yaml +++ b/internal/pkg/config/config.example.yaml @@ -74,6 +74,11 @@ runner: - "ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest" - "ubuntu-24.04:docker://docker.gitea.com/runner-images:ubuntu-24.04" - "ubuntu-22.04:docker://docker.gitea.com/runner-images:ubuntu-22.04" + # Allocate a pseudo-TTY for each step's process. Applies to both host and docker backends. + # Default false matches GitHub actions/runner. Enable only for jobs that need an interactive + # terminal; tools like `docker build` emit redrawing progress frames into the captured log + # when a TTY is present. + allocate_pty: false cache: # Enable cache server to use actions/cache. diff --git a/internal/pkg/config/config.go b/internal/pkg/config/config.go index a257a9dd..4f5b11d5 100644 --- a/internal/pkg/config/config.go +++ b/internal/pkg/config/config.go @@ -41,6 +41,7 @@ type Runner struct { StateReportInterval time.Duration `yaml:"state_report_interval"` // StateReportInterval specifies the interval for state reporting. Labels []string `yaml:"labels"` // Labels specify the labels of the runner. Labels are declared on each startup GithubMirror string `yaml:"github_mirror"` // GithubMirror defines what mirrors should be used when using github + AllocatePTY bool `yaml:"allocate_pty"` // AllocatePTY allocates a pseudo-TTY for each step's process. Default is false, matching GitHub's actions/runner. Enable only for jobs that need an interactive terminal; tools like docker build emit redrawing progress frames into the captured log when a TTY is present. Applies to both host and docker backends. } // Cache represents the configuration for caching.