From 333eb17d19340a7619b5a83ef01e1a31fa03d584 Mon Sep 17 00:00:00 2001 From: bircni Date: Tue, 28 Jul 2026 05:29:34 +0000 Subject: [PATCH] feat: report runner name, environment, workspace and debug to jobs (#1105) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Passes the `runner` context values act already knew but never reported to jobs: 1. `runner.name` / `RUNNER_NAME` — registered runner name, hostname for `exec` 2. `runner.environment` / `RUNNER_ENVIRONMENT` — `self-hosted` 3. `RUNNER_WORKSPACE` — parent of `GITHUB_WORKSPACE` 4. `runner.debug` / `RUNNER_DEBUG` — `1` when `ACTIONS_STEP_DEBUG` is set `ImageOS` now prefers the release named in the resolved image tag, so `ubuntu-latest` mapped to `runner-images:ubuntu-24.04` reports `ubuntu24` instead of the hardcoded `ubuntu20`. The `runs-on` label stays the fallback. --------- Co-authored-by: silverwind <2021+silverwind@noreply.gitea.com> Co-authored-by: silverwind Co-authored-by: bircni Reviewed-on: https://gitea.com/gitea/runner/pulls/1105 Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com> Co-authored-by: bircni --- act/runner/expression.go | 8 +-- act/runner/run_context.go | 89 +++++++++++++++++++++---- act/runner/run_context_test.go | 114 +++++++++++++++++++++++++++++++++ act/runner/runner.go | 13 ++++ act/runner/step_test.go | 4 ++ internal/app/run/runner.go | 1 + 6 files changed, 210 insertions(+), 19 deletions(-) diff --git a/act/runner/expression.go b/act/runner/expression.go index 3dc9a108..6a2c94a9 100644 --- a/act/runner/expression.go +++ b/act/runner/expression.go @@ -95,9 +95,7 @@ func (rc *RunContext) NewExpressionEvaluatorWithEnv(ctx context.Context, env map Inputs: inputs, HashFiles: getHashFilesFunction(ctx, rc), } - if rc.JobContainer != nil { - ee.Runner = rc.JobContainer.GetRunnerContext(ctx) - } + ee.Runner = rc.getRunnerContext(ctx) return expressionEvaluator{ interpreter: exprparser.NewInterpeter(ee, exprparser.Config{ Run: rc.Run, @@ -149,9 +147,7 @@ func (rc *RunContext) NewStepExpressionEvaluator(ctx context.Context, step step) Inputs: inputs, HashFiles: getHashFilesFunction(ctx, rc), } - if rc.JobContainer != nil { - ee.Runner = rc.JobContainer.GetRunnerContext(ctx) - } + ee.Runner = rc.getRunnerContext(ctx) return expressionEvaluator{ interpreter: exprparser.NewInterpeter(ee, exprparser.Config{ Run: rc.Run, diff --git a/act/runner/run_context.go b/act/runner/run_context.go index 9b00c287..6cfea8ab 100644 --- a/act/runner/run_context.go +++ b/act/runner/run_context.go @@ -298,7 +298,7 @@ func (rc *RunContext) startHostEnvironment() common.Executor { AllocatePTY: rc.Config.AllocatePTY, } rc.cleanUpJobContainer = rc.JobContainer.Remove() - for k, v := range rc.JobContainer.GetRunnerContext(ctx) { + for k, v := range rc.getRunnerContext(ctx) { if v, ok := v.(string); ok { rc.Env["RUNNER_"+strings.ToUpper(k)] = v } @@ -986,6 +986,21 @@ func (rc *RunContext) getStepsContext() map[string]*model.StepResult { return rc.StepResults } +// getRunnerContext returns the `runner` context: what the execution environment knows +// (os, arch, temp, tool_cache) plus what only the runner process knows. +func (rc *RunContext) getRunnerContext(ctx context.Context) map[string]any { + runnerContext := map[string]any{} + if rc.JobContainer != nil { + maps0.Copy(runnerContext, rc.JobContainer.GetRunnerContext(ctx)) + } + runnerContext["name"] = rc.Config.RunnerName + runnerContext["environment"] = "self-hosted" + if rc.Config.RunnerDebug() { + runnerContext["debug"] = "1" + } + return runnerContext +} + func (rc *RunContext) getGithubContext(ctx context.Context) *model.GithubContext { logger := common.Logger(ctx) ghc := &model.GithubContext{ @@ -1170,7 +1185,7 @@ func nestedMapLookup(m map[string]any, ks ...string) (rval any) { } } -func (rc *RunContext) withGithubEnv(ctx context.Context, github *model.GithubContext, env map[string]string) map[string]string { //nolint:unparam // pre-existing issue from nektos/act +func (rc *RunContext) withGithubEnv(ctx context.Context, github *model.GithubContext, env map[string]string) { env["CI"] = "true" env["GITHUB_WORKFLOW"] = github.Workflow env["GITHUB_RUN_ID"] = github.RunID @@ -1212,23 +1227,71 @@ func (rc *RunContext) withGithubEnv(ctx context.Context, github *model.GithubCon env["GITHUB_RUN_ATTEMPT"] = github.RunAttempt } + env["RUNNER_NAME"] = rc.Config.RunnerName + env["RUNNER_ENVIRONMENT"] = "self-hosted" + if workspace := parentDir(github.Workspace); workspace != "" { + env["RUNNER_WORKSPACE"] = workspace + } + if rc.Config.RunnerDebug() { + env["RUNNER_DEBUG"] = "1" + } + if rc.Config.ArtifactServerPath != "" { setActionRuntimeVars(rc, env) } - for _, platformName := range rc.runsOnPlatformNames(ctx) { - if platformName != "" { - if platformName == "ubuntu-latest" { - // hardcode current ubuntu-latest since we have no way to check that 'on the fly' - env["ImageOS"] = "ubuntu20" - } else { - platformName = strings.SplitN(strings.Replace(platformName, `-`, ``, 1), `.`, 2)[0] - env["ImageOS"] = platformName - } - } + if imageOS := rc.imageOS(ctx); imageOS != "" { + env["ImageOS"] = imageOS + } +} + +// parentDir returns the directory containing p, or "" when p names no parent. Both +// separators are accepted rather than filepath's, as p may describe a container while +// the runner itself runs on Windows, or the other way round. +func parentDir(p string) string { + if slash := strings.LastIndexAny(p, `/\`); slash > 0 { + return p[:slash] + } + return "" +} + +// imageOS returns ImageOS, which setup-* actions use to tell one runner image release +// from another. The resolved image tag is preferred over the runs-on label because it +// still names a release when the label is a rolling one such as ubuntu-latest. +func (rc *RunContext) imageOS(ctx context.Context) string { + if rc.Run.Job().RunsOn() == nil { + // A composite action runs on a synthetic job, and resolving its image would only + // log that runs-on is missing. + return "" + } + if imageOS := imageOSFromImage(rc.platformImage(ctx)); imageOS != "" { + return imageOS } - return env + for _, platformName := range slices.Backward(rc.runsOnPlatformNames(ctx)) { + if platformName == "ubuntu-latest" { + // Rolling label whose image names no release either, so keep the historical value. + return "ubuntu20" + } else if platformName != "" { + return strings.SplitN(strings.Replace(platformName, `-`, ``, 1), `.`, 2)[0] + } + } + return "" +} + +// imageOSTag matches an image reference tagged with an OS family ImageOS can report plus +// its release, such as "docker.gitea.com/runner-images:ubuntu-24.04". Anything else +// ("ubuntu-latest", "app:22.04", "catthehacker/ubuntu:act-22.04", or a registry port) is +// left to the runs-on label rather than turned into a bogus OS. +var imageOSTag = regexp.MustCompile(`:(ubuntu|win|macos)-?([0-9]+)[^/]*$`) + +// imageOSFromImage derives ImageOS from an image reference, e.g. +// "docker.gitea.com/runner-images:ubuntu-24.04" yields "ubuntu24". +func imageOSFromImage(image string) string { + if match := imageOSTag.FindStringSubmatch(image); match != nil { + return match[1] + match[2] + } + return "" } func setActionRuntimeVars(rc *RunContext, env map[string]string) { diff --git a/act/runner/run_context_test.go b/act/runner/run_context_test.go index 5cbb0d0a..078d62bc 100644 --- a/act/runner/run_context_test.go +++ b/act/runner/run_context_test.go @@ -940,3 +940,117 @@ func TestRunContext_cleanupFailedStart(t *testing.T) { assert.NotPanics(t, func() { (&RunContext{}).cleanupFailedStart(context.Background()) }) }) } + +func TestImageOSFromImage(t *testing.T) { + for _, tc := range []struct { + image string + want string + }{ + {"", ""}, + {"docker.gitea.com/runner-images:ubuntu-24.04", "ubuntu24"}, + {"docker.gitea.com/runner-images:ubuntu-latest", ""}, + {"runner-images:ubuntu22.04", "ubuntu22"}, + {"node:20", ""}, + {"ubuntu:22.04", ""}, + {"ubuntu", ""}, + {"catthehacker/ubuntu:act-22.04", ""}, + {"myco/ubuntu:v2.1", ""}, + {"myco/ubuntu:v22.04", ""}, + {"app:release-1", ""}, + {"app:1.2.3", ""}, + {"app:build-2.1", ""}, + {"registry.example.com:5000/runner-images", ""}, + {"registry.example.com:5000/runner-images:ubuntu-24.04", "ubuntu24"}, + } { + t.Run(tc.image, func(t *testing.T) { + assert.Equal(t, tc.want, imageOSFromImage(tc.image)) + }) + } +} + +func createRunsOnRunContext(t *testing.T, runsOn string) *RunContext { + return createIfTestRunContext(map[string]*model.Job{ + "job1": createJob(t, "runs-on: "+runsOn, ""), + }) +} + +func TestRunContextImageOS(t *testing.T) { + ctx := context.Background() + + t.Run("prefers the release in the resolved image tag", func(t *testing.T) { + rc := createRunsOnRunContext(t, "ubuntu-latest") + rc.Config.Platforms = map[string]string{ + "ubuntu-latest": "docker.gitea.com/runner-images:ubuntu-24.04", + } + assert.Equal(t, "ubuntu24", rc.imageOS(ctx)) + }) + + t.Run("falls back to the runs-on label", func(t *testing.T) { + rc := createRunsOnRunContext(t, "ubuntu-22.04") + rc.Config.Platforms = map[string]string{"ubuntu-22.04": "some-image"} + assert.Equal(t, "ubuntu22", rc.imageOS(ctx)) + }) + + t.Run("keeps the historical value for a rolling label with no release", func(t *testing.T) { + assert.Equal(t, "ubuntu20", createRunsOnRunContext(t, "ubuntu-latest").imageOS(ctx)) + }) + + t.Run("is empty for the synthetic job of a composite action", func(t *testing.T) { + rc := createIfTestRunContext(map[string]*model.Job{"job1": {}}) + assert.Empty(t, rc.imageOS(ctx)) + }) +} + +func TestRunContextGetRunnerContext(t *testing.T) { + ctx := context.Background() + + t.Run("adds the runner values the container cannot know", func(t *testing.T) { + rc := createRunsOnRunContext(t, "ubuntu-latest") + rc.Config.RunnerName = "runner-1" + + runnerContext := rc.getRunnerContext(ctx) + assert.Equal(t, "runner-1", runnerContext["name"]) + assert.Equal(t, "self-hosted", runnerContext["environment"]) + assert.NotContains(t, runnerContext, "debug") + }) + + t.Run("reports debug when step debugging is on", func(t *testing.T) { + rc := createRunsOnRunContext(t, "ubuntu-latest") + rc.Config.Secrets = map[string]string{"ACTIONS_STEP_DEBUG": "true"} + + assert.Equal(t, "1", rc.getRunnerContext(ctx)["debug"]) + }) + + t.Run("keeps the execution environment values", func(t *testing.T) { + rc := createRunsOnRunContext(t, "ubuntu-latest") + rc.JobContainer = &container.HostEnvironment{TmpDir: "/tmp/act", ToolCache: "/tmp/tool_cache"} + + runnerContext := rc.getRunnerContext(ctx) + assert.Equal(t, "/tmp/act", runnerContext["temp"]) + assert.Equal(t, "/tmp/tool_cache", runnerContext["tool_cache"]) + assert.NotEmpty(t, runnerContext["os"]) + }) +} + +func TestParentDir(t *testing.T) { + assert.Empty(t, parentDir("")) + assert.Empty(t, parentDir("repo")) + assert.Empty(t, parentDir("/repo")) + assert.Equal(t, "/workspace/owner", parentDir("/workspace/owner/repo")) + assert.Equal(t, `C:\workspace\owner`, parentDir(`C:\workspace\owner\repo`)) +} + +func TestRunContextWithGithubEnvRunnerValues(t *testing.T) { + ctx := context.Background() + rc := createRunsOnRunContext(t, "ubuntu-latest") + rc.Config.RunnerName = "runner-1" + rc.Config.Secrets = map[string]string{"ACTIONS_STEP_DEBUG": "true"} + + env := map[string]string{} + rc.withGithubEnv(ctx, &model.GithubContext{Workspace: "/workspace/owner/repo"}, env) + + assert.Equal(t, "runner-1", env["RUNNER_NAME"]) + assert.Equal(t, "self-hosted", env["RUNNER_ENVIRONMENT"]) + assert.Equal(t, "/workspace/owner", env["RUNNER_WORKSPACE"]) + assert.Equal(t, "1", env["RUNNER_DEBUG"]) +} diff --git a/act/runner/runner.go b/act/runner/runner.go index 51191a98..bb1c009b 100644 --- a/act/runner/runner.go +++ b/act/runner/runner.go @@ -92,6 +92,14 @@ type Config struct { InsecureSkipTLS bool // whether to skip verifying TLS certificate of the Gitea instance 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 +} + +// RunnerDebug reports whether debug logging is on, exposed as `runner.debug` and +// RUNNER_DEBUG. Only the secret also makes the reporter keep ::debug:: output, the env +// is accepted for `exec` and for runners configured with it. +func (c Config) RunnerDebug() bool { + return c.Secrets["ACTIONS_STEP_DEBUG"] == "true" || c.Env["ACTIONS_STEP_DEBUG"] == "true" } // GetToken: Adapt to Gitea @@ -137,6 +145,11 @@ func New(runnerConfig *Config) (Runner, error) { } func (runner *runnerImpl) configure() (Runner, error) { + if runner.config.RunnerName == "" { + // Callers that do not register, such as `exec`, still get a `runner.name`. + runner.config.RunnerName, _ = os.Hostname() + } + runner.eventJSON = "{}" if runner.config.EventJSON != "" { runner.eventJSON = runner.config.EventJSON diff --git a/act/runner/step_test.go b/act/runner/step_test.go index b741a32e..fa9d95c5 100644 --- a/act/runner/step_test.go +++ b/act/runner/step_test.go @@ -167,6 +167,9 @@ func TestSetupEnv(t *testing.T) { delete((env), "GITHUB_REPOSITORY") delete((env), "GITHUB_REPOSITORY_OWNER") delete((env), "GITHUB_ACTOR") + // Host-dependent, asserted in TestRunContextWithGithubEnvRunnerValues instead. + delete((env), "RUNNER_NAME") + delete((env), "RUNNER_WORKSPACE") assert.Equal(t, map[string]string{ "ACT": "true", @@ -192,6 +195,7 @@ func TestSetupEnv(t *testing.T) { "GITHUB_WORKFLOW": "", "INPUT_STEP_WITH": "with-value", "RC_KEY": "rcvalue", + "RUNNER_ENVIRONMENT": "self-hosted", "RUNNER_PERFLOG": "/dev/null", "RUNNER_TRACKING_ID": "", }, env) diff --git a/internal/app/run/runner.go b/internal/app/run/runner.go index 04a5484a..fa5aff8f 100644 --- a/internal/app/run/runner.go +++ b/internal/app/run/runner.go @@ -474,6 +474,7 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report. Vars: task.Vars, ValidVolumes: r.cfg.Container.ValidVolumes, InsecureSkipTLS: r.cfg.Runner.Insecure, + RunnerName: r.name, } rr, err := runner.New(runnerConfig)