fix: align common behaviour with GitHub's runner (#1413)

Closes differences from GitHub's runner that common workflows hit:

- `ACTIONS_STEP_DEBUG` set as a repository variable enables debug output as on GitHub, where a secret of the same name still takes precedence. `runner.debug` and `RUNNER_DEBUG` are now also set inside composite actions, which lost the secret.
- `github.env` and `github.path` hold the file-command paths instead of being empty, so `echo "A=1" >> "${{ github.env }}"` works.
- Remote composite actions only get a pre stage when they nest actions. That stage evaluated `with:` before earlier steps ran, so an input like `${{ fromJSON(steps.meta.outputs.json).v }}` failed the job.
- `ports` on a job container are published, they were ignored while service ports worked.
- Dockerfile action build output reaches the job log. It was logged at debug and dropped, so a failing `RUN` showed only the final error.
- Local Docker actions honor their `.dockerignore`, as `docker build` does.
- Cancelling or timing out a host-mode step sends SIGINT, then SIGTERM after 7.5 seconds and SIGKILL after 2.5 more, as GitHub's runner does, so `trap` handlers and shutdown hooks run.
- `.sh` job hooks fall back to `sh` where bash is missing, and `.ps1` hooks to `powershell` where pwsh is missing, as on GitHub.

_Written by Claude (Opus 5.5)._

Reviewed-on: https://gitea.com/gitea/runner/pulls/1413
Reviewed-by: bircni <[email protected]>
Co-authored-by: silverwind <[email protected]>
This commit is contained in:
silverwind authored and bircni committed 2026-11-12 15:28:47 +00:00
1 parent 0dfec377d2
commit 645b17de72
25 files changed
+340 -91

No files matched your search

+2
View File
@@ -26,4 +26,6 @@ func (k *Killer) Kill() error {
return k.p.Kill()
}
func (k *Killer) cancel(_process *os.Process, _graceful bool) error { return k.Kill() }
func (k *Killer) Close() error { return nil }
+33
View File
@@ -9,6 +9,7 @@ import (
"errors"
"os"
"syscall"
"time"
)
// Killer terminates a started process together with its whole process group,
@@ -52,5 +53,37 @@ func (k *Killer) Kill() error {
return nil
}
func (k *Killer) cancel(process *os.Process, graceful bool) error {
if graceful && k.pgid > 0 {
for _, stage := range []struct {
signal syscall.Signal
delay time.Duration
}{
{syscall.SIGINT, 7500 * time.Millisecond},
{syscall.SIGTERM, 2500 * time.Millisecond},
} {
if process.Signal(syscall.Signal(0)) != nil || syscall.Kill(-k.pgid, stage.signal) != nil || waitForExit(process, stage.delay) {
break
}
}
}
return k.Kill()
}
func waitForExit(process *os.Process, delay time.Duration) bool {
timer := time.NewTimer(delay)
defer timer.Stop()
ticker := time.NewTicker(10 * time.Millisecond)
defer ticker.Stop()
for !errors.Is(process.Signal(syscall.Signal(0)), os.ErrProcessDone) {
select {
case <-timer.C:
return false
case <-ticker.C:
}
}
return true
}
// Close is a no-op on Unix; there is no job handle to release.
func (k *Killer) Close() error { return nil }
+33
View File
@@ -6,9 +6,11 @@
package process
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"syscall"
"testing"
@@ -95,3 +97,34 @@ func TestTreeKillCancelBeforeCaptureKillsTree(t *testing.T) {
require.Error(t, cmd.Wait())
require.Eventually(t, func() bool { return !processAlive(childPID) }, 5*time.Second, 10*time.Millisecond)
}
func TestTreeKillCancelRunsInterruptTrap(t *testing.T) {
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
defer cancel()
marker := filepath.Join(t.TempDir(), "interrupted")
cmd := exec.CommandContext(ctx, "/bin/sh", "-c", `trap 'echo interrupted > "$1"; exit 0' INT; echo ready; read value`, "sh", marker)
cmd.SysProcAttr = SysProcAttr("", false)
input, err := cmd.StdinPipe()
require.NoError(t, err)
defer input.Close()
output, err := cmd.StdoutPipe()
require.NoError(t, err)
treeKill := NewTreeKill(cmd)
treeKill.GracefulCancel = true
require.NoError(t, cmd.Start())
killer, err := treeKill.Capture(cmd.Process)
require.NoError(t, err)
defer killer.Close()
t.Cleanup(func() {
_ = killer.Kill()
_ = cmd.Wait()
})
var ready string
_, err = fmt.Fscanln(output, &ready)
require.NoError(t, err)
require.Equal(t, "ready", ready)
cancel()
require.ErrorIs(t, treeKill.Wait(ctx, cmd), context.Canceled)
require.FileExists(t, marker)
}
+2
View File
@@ -68,6 +68,8 @@ func (k *Killer) Kill() error {
return windows.TerminateJobObject(k.job, 1)
}
func (k *Killer) cancel(_process *os.Process, _graceful bool) error { return k.Kill() }
// Close releases the job handle. It does not terminate the processes.
func (k *Killer) Close() error {
if k == nil {
+5 -4
View File
@@ -32,8 +32,9 @@ const treeKillWaitDelay = 10 * time.Second
// Callers still set cmd.SysProcAttr (via SysProcAttr) themselves, because the
// value differs between the plain and PTY execution paths.
type TreeKill struct {
killer atomic.Pointer[Killer]
KeepLeftovers bool
killer atomic.Pointer[Killer]
KeepLeftovers bool
GracefulCancel bool
}
// NewTreeKill sets cmd.Cancel, cmd.WaitDelay and CREATE_SUSPENDED. Call it after setting cmd.SysProcAttr, and Capture once after a successful Start.
@@ -42,12 +43,12 @@ func NewTreeKill(cmd *exec.Cmd) *TreeKill {
t := &TreeKill{}
cmd.Cancel = func() error {
if k := t.killer.Load(); k != nil {
return k.Kill()
return k.cancel(cmd.Process, t.GracefulCancel)
}
if cmd.Process != nil {
if killer, err := NewKiller(cmd.Process); err == nil {
defer killer.Close()
return killer.Kill()
return killer.cancel(cmd.Process, t.GracefulCancel)
}
return cmd.Process.Kill()
}
+1
View File
@@ -45,6 +45,7 @@ func TestTreeKillWaitWithLeftoverHoldingOutput(t *testing.T) {
cmd.Stdout = io.Discard
cmd.SysProcAttr = SysProcAttr("", false)
tk := NewTreeKill(cmd)
tk.GracefulCancel = true
cmd.WaitDelay = 50 * time.Millisecond
require.NoError(t, cmd.Start())
killer, err := tk.Capture(cmd.Process)