feat: add job hooks (#1111)

Adds `runner.hooks.job_started` and `runner.hooks.job_completed`: operator scripts that run inside the job environment, before the job's first step and after its last one.

```yaml
runner:
  hooks:
    job_started: /hooks/started.sh
    job_completed: /hooks/completed.sh
```

Equivalent to GitHub's `ACTIONS_RUNNER_HOOK_JOB_STARTED` / `ACTIONS_RUNNER_HOOK_JOB_COMPLETED`, which are read when unset: output is scanned for workflow commands, `$GITHUB_ENV` and `$GITHUB_PATH` are read back, and a non-zero exit fails the job.

Fixes: https://gitea.com/gitea/runner/issues/779
Co-authored-by: silverwind <me@silverwind.io>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1111
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
This commit is contained in:
bircni
2026-07-29 19:16:24 +00:00
parent 61f0cfa951
commit e6c7ba3a15
12 changed files with 406 additions and 4 deletions

View File

@@ -104,6 +104,14 @@ runner:
post_task_script: ''
# Hard limit on post_task_script runtime. Default if omitted: 5m.
post_task_script_timeout: 5m
# Scripts run inside the job environment before the job's first step and after its last
# one, the equivalent of GitHub's ACTIONS_RUNNER_HOOK_JOB_STARTED and
# ACTIONS_RUNNER_HOOK_JOB_COMPLETED, which are read when these are unset. The paths are
# resolved inside the job environment. Either one failing fails the job.
# Full guide: docs/job-hooks.md
hooks:
job_started: ''
job_completed: ''
cache:
# Enable the built-in cache server (used by actions/cache and similar actions).

View File

@@ -54,6 +54,13 @@ type Runner struct {
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.
PostTaskScript string `yaml:"post_task_script"` // PostTaskScript is the path to an executable script run on the host after each task's cleanup completes. Empty disables the hook. On Windows use .exe/.bat/.cmd; PowerShell (.ps1) is not supported yet as the configured path.
PostTaskScriptTimeout time.Duration `yaml:"post_task_script_timeout"` // PostTaskScriptTimeout caps how long the post-task script may run. Default is 5m when post_task_script is set.
Hooks RunnerHooks `yaml:"hooks"` // Hooks are scripts run inside the job environment around the job's steps.
}
// RunnerHooks represents the scripts run inside the job environment around the job's steps.
type RunnerHooks struct {
JobStarted string `yaml:"job_started"` // JobStarted is the path of a script run before the job's first step. Falls back to ACTIONS_RUNNER_HOOK_JOB_STARTED; a failure fails the job.
JobCompleted string `yaml:"job_completed"` // JobCompleted is the path of a script run after the job's last step, while the job environment is still up. Falls back to ACTIONS_RUNNER_HOOK_JOB_COMPLETED; a failure fails the job.
}
// Cache represents the configuration for caching.

View File

@@ -139,9 +139,6 @@ runner:
assert.Equal(t, -1*time.Second, cfg.Runner.IdleCleanupInterval)
}
// TestLoadDefault_MalformedYAMLReturnsParseError pins the error surfaced for
// invalid YAML to the canonical "parse config file" message rather than the
// "for defaults metadata" variant — i.e. the main yaml.Unmarshal runs first.
func TestLoadDefault_LoadsPostTaskScript(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
@@ -170,6 +167,25 @@ runner:
assert.Equal(t, 5*time.Minute, cfg.Runner.PostTaskScriptTimeout)
}
func TestLoadDefault_LoadsJobHooks(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
require.NoError(t, os.WriteFile(path, []byte(`
runner:
hooks:
job_started: /hooks/started.sh
job_completed: /hooks/completed.sh
`), 0o600))
cfg, err := LoadDefault(path)
require.NoError(t, err)
assert.Equal(t, "/hooks/started.sh", cfg.Runner.Hooks.JobStarted)
assert.Equal(t, "/hooks/completed.sh", cfg.Runner.Hooks.JobCompleted)
}
// TestLoadDefault_MalformedYAMLReturnsParseError pins the error surfaced for
// invalid YAML to the canonical "parse config file" message rather than the
// "for defaults metadata" variant — i.e. the main yaml.Unmarshal runs first.
func TestLoadDefault_MalformedYAMLReturnsParseError(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")