From e6c7ba3a15b75adcee1ec7257a7c146c2b186609 Mon Sep 17 00:00:00 2001 From: bircni Date: Wed, 29 Jul 2026 19:16:24 +0000 Subject: [PATCH] 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 Reviewed-on: https://gitea.com/gitea/runner/pulls/1111 Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com> --- README.md | 10 ++ act/container/host_environment.go | 4 + act/runner/job_executor.go | 7 +- act/runner/job_hooks.go | 115 +++++++++++++++++ act/runner/job_hooks_test.go | 162 ++++++++++++++++++++++++ act/runner/runner.go | 2 + docs/job-hooks.md | 70 ++++++++++ docs/post-task-script.md | 1 + internal/app/run/runner.go | 2 + internal/pkg/config/config.example.yaml | 8 ++ internal/pkg/config/config.go | 7 + internal/pkg/config/config_test.go | 22 +++- 12 files changed, 406 insertions(+), 4 deletions(-) create mode 100644 act/runner/job_hooks.go create mode 100644 act/runner/job_hooks_test.go create mode 100644 docs/job-hooks.md diff --git a/README.md b/README.md index e2674f12..542c5fa0 100644 --- a/README.md +++ b/README.md @@ -270,6 +270,16 @@ On Windows, use `.exe`, `.bat`, or `.cmd` paths; **PowerShell (`.ps1`) is not su See **[docs/post-task-script.md](docs/post-task-script.md)** for lifecycle details, environment variables, timeout interaction, and platform notes. +#### Job hooks (`runner.hooks.job_started`, `runner.hooks.job_completed`) + +Optional scripts that run **inside the job environment** (the job container, or the host in host mode), before the job's first step and after its last one. They are the equivalent of GitHub's `ACTIONS_RUNNER_HOOK_JOB_STARTED` / `ACTIONS_RUNNER_HOOK_JOB_COMPLETED`, which are read when the settings are unset. + +Because they run where the steps run and see the job's environment, they are the place for per-job setup no workflow should have to carry: registry logins, mirror configuration, or masking runner-wide secrets with `::add-mask::`. Their output is part of the job log and is scanned for workflow commands, and they can export to the job through `$GITHUB_ENV` and `$GITHUB_PATH`. + +Both hooks are synchronous and block the job while they run. Either one exiting non-zero fails the job, and there is no per-hook timeout. + +See **[docs/job-hooks.md](docs/job-hooks.md)** for the execution order, environment, and platform notes. + ### Example Deployments Check out the [examples](examples) directory for sample deployment types. diff --git a/act/container/host_environment.go b/act/container/host_environment.go index 6937e39f..3ca2910c 100644 --- a/act/container/host_environment.go +++ b/act/container/host_environment.go @@ -330,6 +330,10 @@ func (e *HostEnvironment) exec(ctx context.Context, command []string, cmdline st } else { wd = e.Path } + // Flush any buffered, not-yet-newline-terminated trailing line, as the docker backend + // does in waitForCommand, so the final line of a command's output is not lost. + defer common.FlushWriter(e.StdOut) + f, err := lookupPathHost(command[0], env, e.StdOut) if err != nil { return err diff --git a/act/runner/job_executor.go b/act/runner/job_executor.go index 2177d96f..753a1a60 100644 --- a/act/runner/job_executor.go +++ b/act/runner/job_executor.go @@ -226,11 +226,16 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo } } - // The setup section of the job log: download the actions, run the pre steps, then name the job. + // The setup section of the job log. The started hook goes first, so what it sets up is + // in place for the first action download and the first step. + preSteps = append(preSteps, rc.runJobStartedHook) preSteps = append(preSteps, printPrepareActions(rc, preparers)) preSteps = append(preSteps, stepPreSteps...) preSteps = append(preSteps, printCompleteJobName(rc)) + // Ahead of the teardown below, while the job environment is still up. + postExecutor = postExecutor.Finally(rc.runJobCompletedHook) + postExecutor = postExecutor.Finally(func(ctx context.Context) error { jobError := common.JobError(ctx) var err error diff --git a/act/runner/job_hooks.go b/act/runner/job_hooks.go new file mode 100644 index 00000000..1ca4a8ce --- /dev/null +++ b/act/runner/job_hooks.go @@ -0,0 +1,115 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package runner + +import ( + "cmp" + "context" + "fmt" + "maps" + "path" + "strings" + + "gitea.com/gitea/runner/act/common" + "gitea.com/gitea/runner/act/container" +) + +// GitHub's job-hook variables, read as a fallback when the settings are unset. +const ( + jobStartedHookEnv = "ACTIONS_RUNNER_HOOK_JOB_STARTED" + jobCompletedHookEnv = "ACTIONS_RUNNER_HOOK_JOB_COMPLETED" +) + +// Kept apart from the per-step file-command files, which are truncated on every step. +const ( + hookEnvFileCommand = "workflow/hook-envs.txt" + hookPathFileCommand = "workflow/hook-path.txt" +) + +func (rc *RunContext) runJobStartedHook(ctx context.Context) error { + return rc.runJobHook(ctx, cmp.Or(rc.Config.JobStartedHook, rc.Config.Env[jobStartedHookEnv]), "job started") +} + +func (rc *RunContext) runJobCompletedHook(ctx context.Context) error { + return rc.runJobHook(ctx, cmp.Or(rc.Config.JobCompletedHook, rc.Config.Env[jobCompletedHookEnv]), "job completed") +} + +// runJobHook runs one hook in the job environment. Either hook failing fails the job, as +// on GitHub, where the operator is responsible for the hook's own resilience. +func (rc *RunContext) runJobHook(ctx context.Context, hookPath, name string) error { + if hookPath == "" { + return nil + } + + cmd, shell := hookCommand(hookPath) + rawLogger := common.Logger(ctx).WithField(rawOutputField, true) + defer rawLogger.Infof("::endgroup::") + rawLogger.Infof("::group::Run '%s'", escapeCommandData(hookPath)) + rawLogger.Infof("A %s hook has been configured by the runner administrator", name) + if shell != "" { + rawLogger.Infof("shell: %s", shell) + } + + env := maps.Clone(rc.GetEnv()) + if jobContainer := rc.Run.Job().Container(); jobContainer != nil { + maps.Copy(env, jobContainer.Env) + } + rc.withGithubEnv(ctx, rc.getGithubContext(ctx), env) + rc.ApplyExtraPath(ctx, &env) + + err := rc.setupHookFileCommands(ctx, env) + if err == nil { + err = rc.JobContainer.Exec(cmd, env, "", "")(ctx) + } + // Processed even on failure, so a hook that exports what it managed to set up before + // failing still hands it to the job. + err = cmp.Or(err, rc.processHookFileCommands(ctx)) + if err == nil { + return nil + } + + err = fmt.Errorf("the %s hook %q failed: %w", name, hookPath, err) + // Flip the job status the way a failing pre step does, so success()-default main steps + // skip and the task is reported failed. + reportStepError(ctx, rc, err) + return err +} + +// setupHookFileCommands points the hook at its GITHUB_ENV and GITHUB_PATH files, so it can +// export to the job's steps, and truncates them so the second hook does not re-read what +// the first one wrote. +func (rc *RunContext) setupHookFileCommands(ctx context.Context, env map[string]string) error { + actPath := rc.JobContainer.GetActPath() + env["GITHUB_ENV"] = path.Join(actPath, hookEnvFileCommand) + env["GITHUB_PATH"] = path.Join(actPath, hookPathFileCommand) + env["GITEA_ENV"] = env["GITHUB_ENV"] + env["GITEA_PATH"] = env["GITHUB_PATH"] + + return rc.JobContainer.Copy(actPath, + &container.FileEntry{Name: hookEnvFileCommand, Mode: 0o666}, + &container.FileEntry{Name: hookPathFileCommand, Mode: 0o666}, + )(ctx) +} + +func (rc *RunContext) processHookFileCommands(ctx context.Context) error { + if err := processRunnerEnvFileCommand(ctx, hookEnvFileCommand, rc, rc.setEnv); err != nil { + return err + } + return rc.UpdateExtraPath(ctx, path.Join(rc.JobContainer.GetActPath(), hookPathFileCommand)) +} + +// hookCommand mirrors actions/runner, which deliberately does not apply the shell flags it +// gives `run:` steps — a hook sets its own. See docs/adrs/1751-runner-job-hooks.md there. +// The second return value is how the invocation is shown in the log, empty when the file is +// executed directly. +func hookCommand(hookPath string) (cmd []string, shell string) { + switch strings.ToLower(path.Ext(hookPath)) { + case ".sh": + return []string{"bash", "-e", hookPath}, "bash -e {0}" + case ".ps1": + return []string{"pwsh", "-command", ". '" + hookPath + "'"}, `pwsh -command ". '{0}'"` + default: + return []string{hookPath}, "" + } +} diff --git a/act/runner/job_hooks_test.go b/act/runner/job_hooks_test.go new file mode 100644 index 00000000..51ec4fc8 --- /dev/null +++ b/act/runner/job_hooks_test.go @@ -0,0 +1,162 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package runner + +import ( + "bytes" + "context" + "errors" + "io" + "maps" + "testing" + + "gitea.com/gitea/runner/act/common" + "gitea.com/gitea/runner/act/model" + + "github.com/sirupsen/logrus/hooks/test" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// hookContainer records the command a hook was run with and answers with what the hook +// wrote to its GITHUB_ENV and GITHUB_PATH files. +type hookContainer struct { + fakeContainer + cmd []string + env map[string]string + err error + envFile map[string]string + pathTar []byte +} + +func (c *hookContainer) ToContainerPath(path string) string { return path } +func (c *hookContainer) IsEnvironmentCaseInsensitive() bool { return false } + +func (c *hookContainer) GetRunnerContext(context.Context) map[string]any { + return map[string]any{"os": "Linux"} +} + +func (c *hookContainer) Exec(command []string, env map[string]string, _, _ string) common.Executor { + return func(context.Context) error { + c.cmd, c.env = command, env + return c.err + } +} + +func (c *hookContainer) UpdateFromEnv(_ string, env *map[string]string) common.Executor { + return func(context.Context) error { + maps.Copy(*env, c.envFile) + return nil + } +} + +func (c *hookContainer) GetContainerArchive(context.Context, string) (io.ReadCloser, error) { + return io.NopCloser(bytes.NewReader(c.pathTar)), nil +} + +// newHookRunContext returns a RunContext and the context to run a hook with, whose logger is +// silenced so the hook's job-log output does not reach the test output. +func newHookRunContext(jobContainer *hookContainer, config *Config) (*RunContext, context.Context) { + // Env is left nil so that it is built from the config, as it is for a real job. + rc := &RunContext{ + Config: config, + Run: &model.Run{JobID: "job", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"job": {}}}}, + JobContainer: jobContainer, + } + logger, _ := test.NewNullLogger() + ctx := common.WithJobErrorContainer(common.WithLogger(context.Background(), logger.WithField("test", true))) + rc.ExprEval = rc.NewExpressionEvaluator(ctx) + return rc, ctx +} + +func TestRunJobHook(t *testing.T) { + t.Run("runs the hook with the job environment", func(t *testing.T) { + jobContainer := &hookContainer{} + rc, ctx := newHookRunContext(jobContainer, &Config{ + JobStartedHook: "/hooks/started.sh", + Env: map[string]string{"A_VAR": "value", jobStartedHookEnv: "/from/env.sh"}, + }) + + require.NoError(t, rc.runJobStartedHook(ctx)) + + // The setting wins over the environment variable. + assert.Equal(t, []string{"bash", "-e", "/hooks/started.sh"}, jobContainer.cmd) + assert.Equal(t, "value", jobContainer.env["A_VAR"]) + // The github environment is there too, so a hook can tell which job it runs for. + assert.Equal(t, "job", jobContainer.env["GITHUB_JOB"]) + assert.Equal(t, "/var/run/act/workflow/hook-envs.txt", jobContainer.env["GITHUB_ENV"]) + assert.Equal(t, "/var/run/act/workflow/hook-path.txt", jobContainer.env["GITHUB_PATH"]) + }) + + // Each hook reads its own variable, so a swapped constant cannot pass. + t.Run("falls back to the GitHub environment variables", func(t *testing.T) { + for name, hook := range map[string]struct { + env string + run func(*RunContext, context.Context) error + }{ + "started": {jobStartedHookEnv, (*RunContext).runJobStartedHook}, + "completed": {jobCompletedHookEnv, (*RunContext).runJobCompletedHook}, + } { + t.Run(name, func(t *testing.T) { + jobContainer := &hookContainer{} + rc, ctx := newHookRunContext(jobContainer, &Config{Env: map[string]string{hook.env: "/from/env.sh"}}) + + require.NoError(t, hook.run(rc, ctx)) + assert.Equal(t, []string{"bash", "-e", "/from/env.sh"}, jobContainer.cmd) + }) + } + }) + + t.Run("exports what the hook wrote to GITHUB_ENV and GITHUB_PATH", func(t *testing.T) { + jobContainer := &hookContainer{ + envFile: map[string]string{"FROM_HOOK": "1"}, + pathTar: tarArchive(t, tarEntry{name: "hook-path.txt", body: "/opt/tool/bin\n"}), + } + rc, ctx := newHookRunContext(jobContainer, &Config{JobStartedHook: "/hooks/started.sh"}) + + require.NoError(t, rc.runJobStartedHook(ctx)) + + assert.Equal(t, "1", rc.Env["FROM_HOOK"]) + assert.Equal(t, []string{"/opt/tool/bin"}, rc.ExtraPath) + }) + + t.Run("a failing hook fails the job", func(t *testing.T) { + rc, ctx := newHookRunContext(&hookContainer{err: errors.New("boom")}, &Config{JobStartedHook: "/hooks/started.sh"}) + + err := rc.runJobStartedHook(ctx) + + require.ErrorContains(t, err, `the job started hook "/hooks/started.sh" failed`) + require.ErrorContains(t, err, "boom") + // The failure has to flip the job status, or success()-default steps would still + // run and the task would be reported successful despite the missing setup. + assert.Equal(t, "failure", rc.getJobContext().Status) + require.ErrorContains(t, common.JobError(ctx), "boom") + }) + + t.Run("is a no-op without a hook", func(t *testing.T) { + jobContainer := &hookContainer{} + rc, ctx := newHookRunContext(jobContainer, &Config{}) + + require.NoError(t, rc.runJobStartedHook(ctx)) + require.NoError(t, rc.runJobCompletedHook(ctx)) + assert.Nil(t, jobContainer.cmd) + }) +} + +// actions/runner deliberately runs a hook without the flags it gives `run:` steps, and an +// executable without a known extension speaks for itself through its shebang. +func TestHookCommand(t *testing.T) { + for hookPath, want := range map[string]struct { + cmd []string + shell string + }{ + "/hooks/started.sh": {[]string{"bash", "-e", "/hooks/started.sh"}, "bash -e {0}"}, + "/hooks/started.PS1": {[]string{"pwsh", "-command", ". '/hooks/started.PS1'"}, `pwsh -command ". '{0}'"`}, + "/hooks/started": {[]string{"/hooks/started"}, ""}, + } { + cmd, shell := hookCommand(hookPath) + assert.Equal(t, want.cmd, cmd, hookPath) + assert.Equal(t, want.shell, shell, hookPath) + } +} diff --git a/act/runner/runner.go b/act/runner/runner.go index bb1c009b..45d05a7d 100644 --- a/act/runner/runner.go +++ b/act/runner/runner.go @@ -93,6 +93,8 @@ type Config struct { 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 RunnerName string // name this runner registered with, reported as `runner.name`, defaults to the hostname + JobStartedHook string // script run inside the job environment before the job's first step; ACTIONS_RUNNER_HOOK_JOB_STARTED is read from Env when empty + JobCompletedHook string // script run inside the job environment after the job's last step; ACTIONS_RUNNER_HOOK_JOB_COMPLETED is read from Env when empty } // RunnerDebug reports whether debug logging is on, exposed as `runner.debug` and diff --git a/docs/job-hooks.md b/docs/job-hooks.md new file mode 100644 index 00000000..c3f95501 --- /dev/null +++ b/docs/job-hooks.md @@ -0,0 +1,70 @@ +# Job hooks + +Job hooks are operator-provided scripts that run **inside the job environment**, before the job's first step and after its last one. They are the equivalent of GitHub's [job hooks](https://docs.github.com/en/actions/how-tos/manage-runners/self-hosted-runners/run-scripts) and are configured under `runner.hooks` in the runner YAML config (see [config.example.yaml](../internal/pkg/config/config.example.yaml)): + +```yaml +runner: + hooks: + job_started: /hooks/started.sh + job_completed: /hooks/completed.sh +``` + +| Setting | Runs | +| --- | --- | +| `runner.hooks.job_started` | Before the job's first step, before any action is downloaded | +| `runner.hooks.job_completed` | After the job's last post step, while the job environment is still up | + +`ACTIONS_RUNNER_HOOK_JOB_STARTED` and `ACTIONS_RUNNER_HOOK_JOB_COMPLETED` are read from the runner's environment (`runner.envs`, `runner.env_file`) when the settings are unset, so a configuration carried over from actions/runner keeps working. The settings take precedence. A workflow cannot point the runner at a different hook: the variables are only read from the runner's own environment, never from the job's. + +Both hooks are **synchronous** and block the job while they run, and a non-zero exit from either one fails the job. There is no `continue-on-error` and no per-hook timeout — the job's own `runner.timeout` is the only bound. The operator is responsible for the hook's resilience; run anything long in the background from within the hook. + +## Where they run + +The hooks run in the same place as the job's steps: inside the job container, or on the host in host mode. The paths are resolved *there*, so the script has to exist in the job image or on the host — a path that only exists on the runner host is not visible to a containerized job. For host-wide cleanup that runs after the job environment is gone, use the [post-task script](post-task-script.md) instead. + +> This is a deliberate difference from actions/runner, which runs its job hooks on the host, outside any container the job declares. Running them where the steps run is what lets a hook prepare the environment the steps actually see. + +The script is run according to its extension: + +| Extension | Command | +| --- | --- | +| `.sh` | `bash -e ` | +| `.ps1` | `pwsh -command . ''` | +| anything else | the file itself, which needs its own shebang and executable bit | + +As on GitHub, the shell flags applied to `run:` steps are **not** applied to a hook — set `pipefail` or anything else you want inside the script. + +### Docker-in-Docker and Docker-out-of-Docker + +The hook is executed and its files are exchanged over the Docker API, addressed by container ID, so no path is translated between the runner and the daemon. Both setups work unchanged, but they differ in where the hook file has to be: + +- **DinD** — the daemon has its own filesystem. Bake the hook into the job image; a path from the runner's filesystem is not visible to it. +- **DooD** — the job container is created by the host's daemon, so a bind mount in `container.options` is resolved against the **host**, not against the runner container. Either bake the hook into the job image, or mount a host directory and add it to `container.valid_volumes`. + +A hook path that does not exist inside the job environment fails the job with `No such file or directory`, naming the path. + +## Environment + +A hook sees the job's environment: the workflow, job and `container:` `env:`, the runner's `envs`, and the `GITHUB_*` context variables, with the same masking applied to its output as to a step's. The step-specific ones (`GITHUB_ACTION`, `GITHUB_OUTPUT`, `GITHUB_STATE`) are not set — a hook is not a step, so `::save-state::` and `::set-output::` have nowhere to go. + +Its stdout is part of the job log, inside a collapsible group, and is scanned for workflow commands. `::add-mask::` registers a value to be masked for the rest of the job, `::set-env::` and `::add-path::` apply to the steps that follow. + +`$GITHUB_ENV` and `$GITHUB_PATH` point at files that are read back after the hook exits, so the file-command form works too: + +```bash +#!/bin/bash +echo "REGISTRY_TOKEN=$(fetch-token)" >> "$GITHUB_ENV" +echo "/opt/tooling/bin" >> "$GITHUB_PATH" +``` + +Both files are the hook's own, separate from the per-step ones, so nothing a hook writes is truncated by the first step. + +## Recommendations + +- Keep hooks **fast** and return the right exit code: they are on the critical path of every job, and nothing bounds them. +- Use **idempotent** operations, and expect `job_completed` to run after success, failure, and cancellation alike. +- Mask anything secret the hook prints or exports with `::add-mask::`. + +## See also + +- [Post-task script](post-task-script.md) — host-side cleanup after the job environment is torn down. diff --git a/docs/post-task-script.md b/docs/post-task-script.md index aa279936..b632c0f5 100644 --- a/docs/post-task-script.md +++ b/docs/post-task-script.md @@ -150,6 +150,7 @@ powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "%~dp0po ## See also +- [Job hooks](job-hooks.md) — scripts running inside the job environment, around its steps - [Configuration](../README.md#configuration) — generating and loading `config.yaml` - [config.example.yaml](../internal/pkg/config/config.example.yaml) — all runner options - Bind-workdir idle cleanup (`runner.workdir_cleanup_age`) — separate from this hook; runs only when the runner is idle diff --git a/internal/app/run/runner.go b/internal/app/run/runner.go index fa5aff8f..b7fad1f3 100644 --- a/internal/app/run/runner.go +++ b/internal/app/run/runner.go @@ -471,6 +471,8 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report. DefaultActionInstance: r.getDefaultActionsURL(task), DefaultActionInstanceIsSelfHosted: r.isSelfHostedActionsURL(task), PlatformPicker: r.labels.PickPlatform, + JobStartedHook: r.cfg.Runner.Hooks.JobStarted, + JobCompletedHook: r.cfg.Runner.Hooks.JobCompleted, Vars: task.Vars, ValidVolumes: r.cfg.Container.ValidVolumes, InsecureSkipTLS: r.cfg.Runner.Insecure, diff --git a/internal/pkg/config/config.example.yaml b/internal/pkg/config/config.example.yaml index d463fed6..4a30c53e 100644 --- a/internal/pkg/config/config.example.yaml +++ b/internal/pkg/config/config.example.yaml @@ -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). diff --git a/internal/pkg/config/config.go b/internal/pkg/config/config.go index 77ac253e..b6295bda 100644 --- a/internal/pkg/config/config.go +++ b/internal/pkg/config/config.go @@ -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. diff --git a/internal/pkg/config/config_test.go b/internal/pkg/config/config_test.go index e3c75dcf..04a100ab 100644 --- a/internal/pkg/config/config_test.go +++ b/internal/pkg/config/config_test.go @@ -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")