mirror of
https://gitea.com/gitea/act_runner.git
synced 2026-08-02 12:53:08 +00:00
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>
163 lines
5.8 KiB
Go
163 lines
5.8 KiB
Go
// 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)
|
|
}
|
|
}
|