## Problem
Several runner code paths could drop the **tail** of a step's log output, so a
failing (or cancelled) step would show output that is missing its last line(s).
This was observed in practice and traced to four independent issues.
## Root causes & fixes
### 1. Trailing line without a newline was never flushed
`common.lineWriter` buffers output until it sees a `\n`. A final line **without**
a trailing newline (e.g. an error message printed right before a process exits,
a panic, `printf` without `\n`) stayed in the internal buffer and was never
emitted — the writer exposed no flush at all.
- Added `lineWriter.Flush()` (idempotent), a `Flusher` interface, and a
`FlushWriter(io.Writer)` helper.
- Flush at every stream EOF: the exec copy goroutine, the container `attach()`
streaming goroutine, and at step end (`useStepLogger`).
### 2. Cancellation/timeout truncated output
`waitForCommand` returned immediately on `ctx.Done()` and abandoned the
output-copy goroutine, losing output the command had already produced. It now
drains with a bounded grace period before returning. The response channel is
buffered so the goroutine can't leak if the drain times out.
### 3. `attach()` raced the final bytes
Container output was streamed in a fire-and-forget goroutine that `wait()` did
not synchronize with, so the step could proceed before the last bytes were
written. `wait()` now blocks on the streaming goroutine (bounded) so output is
fully drained and flushed first.
### 4. `::stop-commands::` silently dropped lines from the step log
Lines between `::stop-commands::<token>` and its end token were echoed without
the `raw_output` field **and** short-circuited the handler chain (`return false`),
so they never reached the step log (non-raw entries aren't appended while a step
is running). Now returns `true` so they are still captured.
Reviewed-on: https://gitea.com/gitea/runner/pulls/1028
Reviewed-by: Zettat123 <[email protected]>
Fixes#1023.
## Problem
In Windows host mode, a single stalled delete syscall (AV/EDR filter driver, unresponsive mount, dying disk) wedged the job forever at `Cleaning up container`. `HostEnvironment.Remove()` bounds every teardown phase (`terminateRunningProcesses`, both `removePathWithRetry` calls) except the `CleanUp` callback — an unbounded `os.RemoveAll(miscpath)` assigned in `startHostEnvironment`. The runner then held its capacity slot indefinitely, the task was reaped as a zombie, and there were no diagnostics.
## Fix
- **Bound the cleanup (availability):** `Remove()` now runs `CleanUp` under `hostCleanupTimeout` (30s) via `runWithTimeout`; on timeout it logs a warning and continues job completion. The stuck goroutine is left to finish (a delete syscall can't be interrupted). Added debug logs around the phase.
- **Reclaim the leak (disk hygiene):** a timed-out cleanup can leave a scratch dir behind, so the existing idle stale-dir sweep is extended to also remove orphaned host-mode scratch dirs (16-hex names) under `Host.WorkdirParent`, leaving the shared `tool_cache` and operator data untouched. The `bind_workdir` gate is dropped from `shouldRunIdleCleanup` so host-mode runners run the sweep.
Reviewed-on: https://gitea.com/gitea/runner/pulls/1024
Reviewed-by: Lunny Xiao <[email protected]>
Updates `golang.org/x/crypto` from `v0.50.0` to `v0.52.0` (and `golang.org/x/net` from `v0.53.0` to `v0.54.0` as a transitive bump).
## Why
`make security-check` (govulncheck) reported **7 vulnerabilities**, all in `golang.org/x/crypto/ssh` at `v0.50.0`, reachable through the git action cache fetch path (`act/runner/action_cache.go` → `git.Remote.FetchContext`):
| ID | Issue |
| --- | --- |
| GO-2026-5013 | Byte arithmetic underflow/panic in `ssh` |
| GO-2026-5015 | Server panic during `CheckHostKey`/`Authenticate` |
| GO-2026-5017 | Client can cause server deadlock on unexpected responses |
| GO-2026-5018 | Pathological RSA/DSA parameters may cause DoS |
| GO-2026-5019 | Bypass of FIDO/U2F physical interaction |
| GO-2026-5020 | Infinite loop on large channel writes |
| GO-2026-5021 | Auth bypass via unenforced `@revoked` status in `knownhosts` |
All are fixed in `v0.52.0`.
Reviewed-on: https://gitea.com/gitea/runner/pulls/1027
Reviewed-by: techknowlogick <[email protected]>
Completes the runner side of the cancellation flow, superseding #825. Two parts:
### 1. Report cancellations correctly (`fix`)
When `Reporter.Close` ran with the state still `UNSPECIFIED` and the reporter's
context had been cancelled, the synthesised final state attributed the job to
`RESULT_FAILURE` with an "Early termination" log row — misreporting a
cancellation as a generic failure. `Close` now detects the cancelled context
and finalizes the task as `RESULT_CANCELLED`.
### 2. Advertise the `cancelling` capability (`feat`)
[actions-proto-go v0.6.0](https://gitea.com/gitea/actions-proto-go) adds a
`capabilities` field to `RegisterRequest`/`DeclareRequest`, so the runner can
now tell the server it understands the transitional cancelling state:
- Bumps `gitea.dev/actions-proto-go` to `v0.6.0`.
- Adds a single `RunnerCapabilities()` source of truth exposing
`CapabilityCancelling`.
- Sends `Capabilities` on both register and declare.
With this the server records `HasCancellingSupport` and can rely on the runner
running post-step cleanup before a task is finalized as `RESULT_CANCELLED`.
## Compatibility
Wire-compatible against older servers: the new field uses a previously unused
field number (8 on `RegisterRequest`, 3 on `DeclareRequest`) and the client uses
the binary protobuf codec, so a server predating the field silently ignores it —
registration and declaration succeed and the feature simply stays off. It
activates only once both runner and server are on v0.6.0.
## Server side
The matching Gitea change (read `GetCapabilities()`, persist
`HasCancellingSupport`) is a separate PR against `gitea/gitea`.
Supersedes #825.
Reviewed-on: https://gitea.com/gitea/runner/pulls/1016
Reviewed-by: Zettat123 <[email protected]>
Reviewed-by: wxiaoguang <[email protected]>
- Add GitHub-style Actions **job summaries** support (writes to `GITHUB_STEP_SUMMARY` / `workflow/SUMMARY.md`) and render them in the run UI.
- Gitea stores summaries internally (DB) and serves them in the run view payload.
- `act_runner` uploads the summary **only when Gitea advertises support** (`X-Gitea-Actions-Capabilities: job-summary`), and warns on upload failures without failing the job.
## Compatibility
- New Gitea + old runner: no upload → no summary shown (no behavior change)
- New runner + old Gitea: capability not advertised → runner skips upload (no behavior change)
## Issue
- Fixesgo-gitea/gitea#23721
Reviewed-on: https://gitea.com/gitea/runner/pulls/917
Reviewed-by: Lunny Xiao <[email protected]>
Reviewed-by: Zettat123 <[email protected]>
## Problem
Cancelling a job on a Windows host runner can leave the spawned process
tree running and hang the runner. When a step launches a shell that
starts a child which in turn spawns further GUI/background processes,
cancelling the job kills only the direct child (the default
`exec.CommandContext` behaviour). The surviving descendants inherited
the step's stdout/stderr pipe, so the read end never hit EOF and
`cmd.Wait()` blocked forever.
Because the step executor never returned:
- the orphaned processes kept running (the cancelled work was not
actually stopped), and
- end-of-job cleanup (`Remove` → `terminateRunningProcesses`) was never
reached, so the runner appeared to go offline / stop picking up jobs.
`CREATE_NEW_PROCESS_GROUP` does not help here — it affects Ctrl-C signal
delivery, not handle inheritance or tree termination.
## Fix
- Assign each Windows step process to a **Job Object** immediately after
`cmd.Start()`. Descendants created afterwards are automatically part
of the job.
- Override `cmd.Cancel` to `TerminateJobObject`, so cancellation kills
the **entire descendant tree** atomically. This also closes the
inherited pipe handles, so `cmd.Wait()` can return.
- Set `cmd.WaitDelay` (10s) as a safety net: once the process has
exited, Wait force-closes the pipes and returns rather than blocking
forever — covering the case where the job-object setup fails (e.g.
nested-job restrictions), in which we fall back to the previous
single-process kill.
- The Job Object is created **without** `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`,
so closing the handle on normal completion does not kill legitimate
background processes; the tree is only torn down on explicit cancel.
Implemented behind `runtime.GOOS == "windows"` with a Windows-only
`processKiller` (Job Object) and no-op stubs elsewhere, so non-Windows
behaviour (default cancellation + `Setpgid`) is unchanged.
## Changes
- `act/container/process_windows.go` — Job Object `processKiller`
(create / assign / terminate).
- `act/container/process_other.go` — no-op stubs (`//go:build !windows`).
- `act/container/host_environment.go` — wire `cmd.Cancel` (tree kill)
and `cmd.WaitDelay` into `exec()`.
- `go.mod` / `go.sum` — promote `golang.org/x/sys` to a direct
dependency.
## Testing
I fully tested it already
## Notes
Follow-up to the Windows leftover-process reaping in #996: that sweep
now actually runs on cancellation because the step no longer hangs
before reaching it.
Reviewed-on: https://gitea.com/gitea/runner/pulls/1011
Reviewed-by: techknowlogick <[email protected]>
## Summary
- When Gitea cancels a job, the reporter cancels its own task context; the final Close() flush then aborted on that same cancelled context and Gitea never received the runner's acknowledgement (missing tail logs and final state).
- On Windows the cancelled context also neutralised terminateRunningProcesses, leaving step grandchildren alive in the workspace, holding file handles, so the runner could no longer clean up and pick up new work.
- Reporter.Close() now flushes on a detached, bounded context via a new rpcCtx() helper and configurable Runner.ReportCloseTimeout (default 10s).
- terminateRunningProcesses now PowerShell-enumerates Win32_Process and taskkill /T /F's every process whose ExecutablePath or CommandLine references the job's workspace directories, on a detached context.
- The daemon heartbeat loop still exits on <-r.ctx.Done(): the runner is intentionally seen as offline by Gitea during cleanup so it isn't handed a new task overlapping the in-progress teardown.
## Test plan
- [x] go test ./internal/pkg/report/... ./act/container/ -run 'TestReporter_ServerCancelStillFlushesFinal|TestBuildWindowsWorkspaceKillScript'
- [x] make fmt && make lint-go - 0 issues
- [x] GOOS=windows go build ./... - clean
- [x] Manual on a Windows runner: trigger a long-running workflow, cancel from Gitea UI; verify (a) the job ends with tail logs + cancelled state in Gitea, (b) workspace cleans up, (c) the runner picks up a new job without restart.
Authored-by: bircni
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: silverwind <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/996
Reviewed-by: silverwind <[email protected]>
## Summary
- Non-raw_output log entries above the globally configured `log.level` are no longer forwarded to the Gitea job log output
- Step output (`raw_output=true`) is always forwarded regardless of level — it is actual job stdout/stderr, not runner internals
- State-machine fields (`stepResult`, `jobResult`) are always processed regardless of level, preserving correct tracking for skipped steps (whose `stepResult` is emitted at `DebugLevel` in `step.go`)
- Extracts a `shouldAppendLogRow` helper to avoid repeating the combined `!duringSteps() && entry.Level <= log.GetLevel()` guard in three places
## Why not the approach in #677
PR #677 adds `if entry.Level != log.GetLevel() { return nil }` at the top of `Fire()`. That has two bugs:
1. Uses `!=` instead of `>`, so `Error`/`Fatal` entries are dropped when the configured level is `Warn`
2. Returns early before processing `stepResult`/`jobResult` state fields — skipped steps (whose `stepResult` is logged at `DebugLevel`) would never be marked complete
This fix instead applies the level guard only at the `r.logRows` append sites, leaving state tracking unconditional.
Relates to #409.
Reviewed-on: https://gitea.com/gitea/runner/pulls/989
Reviewed-by: Lunny Xiao <[email protected]>
## Summary
- `CopyCollector.WriteFile` now removes any existing destination file
before writing, handling read-only modes (e.g. git pack files at
`0444`) that cause `EACCES`/`ERROR_ACCESS_DENIED` on macOS and Windows.
- Added `O_TRUNC` to the `OpenFile` flags as a safety net.
## Root cause
When a composite action with a post step runs on a host runner,
`runPostStep` calls `maybeCopyToActionDir`, which re-copies the action
into `miscpath/act/actions/<name>/`. The first copy (main step) writes
`.git/objects/pack/*.idx` at the destination with mode `0444` (as set
by go-git). The second copy (post step) calls
`os.OpenFile(dest, O_CREATE|O_WRONLY, …)` on that existing `0444` file,
which fails immediately:
- macOS: `open <path>: permission denied`
- Windows: `open <path>: Access is denied`
Fixes: https://gitea.com/gitea/runner/issues/941
Fixes: https://gitea.com/gitea/runner/issues/876
---------
Co-authored-by: silverwind <[email protected]>
Co-authored-by: silverwind <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/942
Reviewed-by: silverwind <[email protected]>
Co-authored-by: Nicolas <[email protected]>
Co-committed-by: Nicolas <[email protected]>
- Add idle-time cleanup for stale bind-workdir task directories instead of cleaning them on the task execution path.
- Make cleanup behavior configurable with `runner.startup_cleanup_age` as the stale-age threshold (default: `24h`) and `runner.idle_cleanup_interval` as the idle cleanup cadence (default: `10m`).
- Restrict cleanup scope to numeric task directory names only, to avoid touching operator-managed folders.
- Document the cleanup settings in `config.example.yaml` and `README.md`.
- Add tests for stale-directory cleanup, idle cleanup throttling, and config default/override parsing.
## Why
When a runner or host crashes, normal per-task cleanup may not run, leaving stale task directories under the bind-workdir root. Running this cleanup only while the runner is idle recovers that disk space without adding overhead to active job execution.
If you want, I can also tighten the wording around `startup_cleanup_age`, since the key name now reads a bit misleadingly relative to the actual behavior.
---------
Co-authored-by: silverwind <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/870
Reviewed-by: silverwind <[email protected]>
## Summary
- Fix host-mode cleanup to remove the job **workspace** directory after a run (instead of leaving checkouts behind).
- On Windows, track step process PIDs and terminate remaining process trees during teardown before attempting workspace deletion (prevents file-lock failures).
- Skip workspace deletion when `bind_workdir` is enabled to avoid conflicting with runner-level task directory cleanup.
## Implementation details
- `HostEnvironment` now records PIDs for started commands and best-effort terminates them on Windows during `Remove()`.
- Workspace removal uses a small retry loop on Windows to handle transient locks.
- `BindWorkdir` is propagated into `HostEnvironment` so cleanup behavior matches runner configuration.
---------
Co-authored-by: silverwind <[email protected]>
Co-authored-by: silverwind <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/883
Reviewed-by: silverwind <[email protected]>
## Summary
Mirrors the GitHub Actions runner behaviour where each `run:` step shows a collapsible **"Run \<command\>"** section containing the script, shell command, and environment variables before the actual step output.
### What changes
- **`pkg/runner/step_run.go`**: In `stepRun.main()`, two new executors are added to the pipeline:
1. `logRunGroupHeader()` — runs after `setupShellCommandExecutor()` (so `sr.cmdline` is already resolved). Emits a `::group::Run <step>` log entry followed by the interpolated script, the full shell command line, and the step's env vars (sorted, internal vars filtered out).
2. The existing execution function now has `defer rawLogger.Infof("::endgroup::")` so the group is closed after the step finishes, regardless of success or failure.
### Env var filtering
Internal runner vars are hidden (`GITHUB_*`, `GITEA_*`, `RUNNER_*`, `INPUT_*`, `PATH`, `HOME`) — only user-relevant vars are shown, matching what GitHub Actions displays.
### Example output
```
▼ Run cargo build
cargo build
shell: bash --noprofile --norc -e -o pipefail {0}
env:
CARGO_HOME: /home/runner/.cargo
CARGO_INCREMENTAL: 0
CARGO_TERM_COLOR: always
<actual build output>
```
---------
Co-authored-by: silverwind <[email protected]>
Co-authored-by: silverwind <[email protected]>
Reviewed-on: https://gitea.com/gitea/act_runner/pulls/847
Reviewed-by: silverwind <[email protected]>
Reviewed-by: ChristopherHX <[email protected]>