Files
act_runner/act/runner/job_hooks_test.go
KenanZhu ca8e9c3c64 enhance: add GITEA_* aliases for built-in workflow environment variables (#1358)
Closes https://gitea.com/gitea/runner/issues/1317, which makes https://github.com/go-gitea/gitea/issues/39813 redundant as the runner sets these variables.

Every built-in `GITHUB_*` environment variable the runner sets is now also available as `GITEA_*` with the same value, so Gitea-native workflows don't have to rely on GitHub naming. The `GITEA_*` names mirror the `GITHUB_*` ones after their last write, so they stay equal and are unset together.

Workflow, job and runner `envs` values for these `GITEA_*` names are now overwritten, the same as for `GITHUB_*`.

---------

Co-authored-by: silverwind <me@silverwind.io>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1358
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: KenanZhu <imnanoki@qq.com>
2026-10-30 21:21:02 +00:00

258 lines
9.5 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/common/logging"
"gitea.com/gitea/runner/act/common/logging/logtest"
"gitea.com/gitea/runner/act/container"
"gitea.dev/actionslib/pkg/model"
"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
copyErr error
envFile map[string]string
pathTar []byte
output string
stdout io.Writer
}
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
if c.output != "" {
if _, err := io.WriteString(c.stdout, c.output); err != nil {
return err
}
}
return c.err
}
}
func (c *hookContainer) ReplaceLogWriter(stdout, _ io.Writer) (io.Writer, io.Writer) {
old := c.stdout
c.stdout = stdout
return old, nil
}
func (c *hookContainer) Copy(_destPath string, _files ...*container.FileEntry) common.Executor {
return common.NewErrorExecutor(c.copyErr)
}
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) {
config.PresetGitHubContext = &model.GithubContext{}
rc := &RunContext{
Config: config,
Run: &model.Run{JobID: "job", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"job": {}}}},
JobContainer: jobContainer,
}
logger := logging.New(logtest.New())
ctx := common.WithJobErrorContainer(common.WithLogger(context.Background(), logger.With("test", true)))
rc.ExprEval = rc.NewExpressionEvaluator(ctx)
return rc, ctx
}
func TestRunJobHook(t *testing.T) {
t.Run("runs the hook with the job environment after its closed header group", func(t *testing.T) {
jobContainer := &hookContainer{output: "from the hook\n"}
rc, ctx := newHookRunContext(jobContainer, &Config{
JobStartedHook: "/hooks/started.sh",
Env: map[string]string{"A_VAR": "value", jobStartedHookEnv: "/from/env.sh"},
})
records := logtest.New()
ctx = common.WithLogger(ctx, logging.New(records))
require.NoError(t, rc.runJobStartedHook(ctx))
var lines []string
for _, record := range records.AllRecords() {
lines = append(lines, record.Message)
}
assert.Equal(t, []string{
"A job started hook has been configured by the runner administrator",
"::group::Run '/hooks/started.sh'",
"shell: bash -e {0}",
"::endgroup::",
"from the hook\n",
}, lines)
// 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"])
assert.Equal(t, jobContainer.env["GITHUB_PATH"], jobContainer.env["GITEA_PATH"])
})
t.Run("each hook has its own command state", func(t *testing.T) {
jobContainer := &hookContainer{output: "::stop-commands::token77"}
rc, ctx := newHookRunContext(jobContainer, &Config{JobStartedHook: "/started.sh", JobCompletedHook: "/completed.sh"})
jobWriter := rc.commandLogWriter(ctx)
jobContainer.stdout = jobWriter
require.NoError(t, rc.runJobStartedHook(ctx))
jobContainer.output = "::add-mask::s3cret"
require.NoError(t, rc.runJobCompletedHook(ctx))
assert.Equal(t, []string{"token77", "s3cret"}, rc.Masks)
assert.Same(t, jobWriter, jobContainer.stdout)
})
t.Run("fails on refused commands and honors the container opt-in", func(t *testing.T) {
for _, allow := range []bool{false, true} {
jobContainer := &hookContainer{output: "::set-env name=FROM_HOOK::1\n::add-path::/opt/hook/bin"}
rc, ctx := newHookRunContext(jobContainer, &Config{JobStartedHook: "/started.sh"})
if allow {
rc.containerSpec.Env = map[string]string{allowUnsecureCommandsVar: "true"}
}
err := rc.runJobStartedHook(ctx)
if allow {
require.NoError(t, err)
assert.Equal(t, "1", rc.Env["FROM_HOOK"])
assert.Equal(t, []string{"/opt/hook/bin"}, rc.ExtraPath)
} else {
require.ErrorContains(t, err, "workflow command is disabled")
assert.Equal(t, "failure", rc.getJobContext().Status)
require.ErrorContains(t, common.JobError(ctx), "workflow command is disabled")
assert.NotContains(t, rc.Env, "FROM_HOOK")
assert.Empty(t, rc.ExtraPath)
}
assert.Nil(t, rc.currentStepEnv())
require.NoError(t, rc.takeUnsecureCommandError())
}
})
t.Run("the completed hook has no preceding step identity", func(t *testing.T) {
jobContainer := &hookContainer{output: "::set-output name=value::after\n::save-state name=value::after"}
rc, ctx := newHookRunContext(jobContainer, &Config{JobCompletedHook: "/completed.sh"})
rc.CurrentStep = "last-step"
rc.StepResults = map[string]*model.StepResult{"last-step": {Outputs: map[string]string{"value": "before"}}}
require.NoError(t, rc.runJobCompletedHook(ctx))
assert.Empty(t, jobContainer.env["GITHUB_ACTION"])
assert.Equal(t, "before", rc.StepResults["last-step"].Outputs["value"])
assert.Empty(t, rc.IntraActionState)
assert.Equal(t, "last-step", rc.CurrentStep)
})
// 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("a hook whose file commands cannot be reset neither runs nor exports stale ones", func(t *testing.T) {
jobContainer := &hookContainer{copyErr: errors.New("boom"), envFile: map[string]string{"STALE": "1"}}
rc, ctx := newHookRunContext(jobContainer, &Config{JobStartedHook: "/hooks/started.sh"})
require.ErrorContains(t, rc.runJobStartedHook(ctx), "boom")
assert.Nil(t, jobContainer.cmd)
assert.NotContains(t, rc.Env, "STALE")
})
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)
}
}