Compare commits

...

2 Commits

Author SHA1 Message Date
bircni
fc0e03e5a9 feat: run pre-entrypoint and post-entrypoint of docker actions (#1106)
A docker action can declare `runs.pre-entrypoint` and `runs.post-entrypoint` next to `runs.entrypoint` — the docker equivalent of a javascript action's `runs.pre`/`runs.post`. Neither key existed on `ActionRuns`, so the YAML decoder dropped them and docker actions silently skipped their setup and cleanup stages.

Only the entrypoint is stage specific. [`ContainerActionHandler`](https://github.com/actions/runner/blob/main/src/Runner.Worker/Handlers/ContainerActionHandler.cs) selects `Data.Pre`/`Data.Post` per stage but evaluates `runs.args` and `runs.env` unconditionally, so `docker create` receives the action's args and env on every stage. The `entrypoint` input stays main only, because it is read inside the main branch.

The stage is carried as the existing `stepStage` value rather than a separate parameter, and the pre and post stages reuse the action path resolution that `runPreStep`/`runPostStep` already open-coded three times, so the diff also drops those copies.

Known gap: `stepActionLocal.pre()` is a no-op for every `using`, so `pre-entrypoint` does not run for `uses: ./local-action` while `post-entrypoint` does. Closing it requires reading the action model before the main stage and adding a pre case to `getIfExpression` for `pre-if`, which also changes local node, go and composite actions — better as its own change.

---------

Co-authored-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: silverwind <me@silverwind.io>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1106
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: bircni <bircni@icloud.com>
2026-07-28 05:36:35 +00:00
bircni
333eb17d19 feat: report runner name, environment, workspace and debug to jobs (#1105)
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 <me@silverwind.io>
Co-authored-by: bircni <me@silverwind.io>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1105
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: bircni <bircni@icloud.com>
2026-07-28 05:29:34 +00:00
10 changed files with 403 additions and 103 deletions

View File

@@ -84,7 +84,9 @@ type ActionRuns struct {
Post string `yaml:"post"` Post string `yaml:"post"`
PostIf string `yaml:"post-if"` PostIf string `yaml:"post-if"`
Image string `yaml:"image"` Image string `yaml:"image"`
PreEntrypoint string `yaml:"pre-entrypoint"`
Entrypoint string `yaml:"entrypoint"` Entrypoint string `yaml:"entrypoint"`
PostEntrypoint string `yaml:"post-entrypoint"`
Args []string `yaml:"args"` Args []string `yaml:"args"`
Steps []Step `yaml:"steps"` Steps []Step `yaml:"steps"`
} }

View File

@@ -61,3 +61,22 @@ runs:
t.Fatalf("error = %q, want invalid value", err) t.Fatalf("error = %q, want invalid value", err)
} }
} }
func TestReadActionDockerEntrypoints(t *testing.T) {
action, err := ReadAction(strings.NewReader(`
runs:
using: docker
image: Dockerfile
pre-entrypoint: pre.sh
post-entrypoint: post.sh
`))
if err != nil {
t.Fatal(err)
}
if action.Runs.PreEntrypoint != "pre.sh" {
t.Fatalf("pre-entrypoint = %q, want pre.sh", action.Runs.PreEntrypoint)
}
if action.Runs.PostEntrypoint != "post.sh" {
t.Fatalf("post-entrypoint = %q, want post.sh", action.Runs.PostEntrypoint)
}
}

View File

@@ -207,7 +207,7 @@ func runActionImpl(step actionStep, actionDir string, remoteAction *remoteAction
if remoteAction == nil { if remoteAction == nil {
location = containerActionDir location = containerActionDir
} }
return execAsDocker(ctx, step, actionName, actionDir, location, remoteAction == nil) return execAsDocker(ctx, step, actionName, actionDir, location, remoteAction == nil, stepStageMain)
case x.IsComposite(): case x.IsComposite():
if err := maybeCopyToActionDir(ctx, step, actionDir, actionPath, containerActionDir); err != nil { if err := maybeCopyToActionDir(ctx, step, actionDir, actionPath, containerActionDir); err != nil {
return err return err
@@ -305,7 +305,7 @@ func dockerActionImageTag(repository, actionName string, localAction bool) strin
} }
// TODO: break out parts of function to reduce complexicity // TODO: break out parts of function to reduce complexicity
func execAsDocker(ctx context.Context, step actionStep, actionName, actionDir, basedir string, localAction bool) error { func execAsDocker(ctx context.Context, step actionStep, actionName, actionDir, basedir string, localAction bool, stage stepStage) error {
logger := common.Logger(ctx) logger := common.Logger(ctx)
rc := step.getRunContext() rc := step.getRunContext()
action := step.getActionModel() action := step.getActionModel()
@@ -386,17 +386,10 @@ func execAsDocker(ctx context.Context, step actionStep, actionName, actionDir, b
cmd = action.Runs.Args cmd = action.Runs.Args
evalDockerArgs(ctx, step, action, &cmd) evalDockerArgs(ctx, step, action, &cmd)
} }
entrypoint := strings.Fields(eval.Interpolate(ctx, step.getStepModel().With["entrypoint"])) entrypoint, err := dockerEntrypoint(ctx, step, eval, stage)
if len(entrypoint) == 0 {
if action.Runs.Entrypoint != "" {
entrypoint, err = shellquote.Split(action.Runs.Entrypoint)
if err != nil { if err != nil {
return err return err
} }
} else {
entrypoint = nil
}
}
stepContainer := newStepContainer(ctx, step, image, cmd, entrypoint) stepContainer := newStepContainer(ctx, step, image, cmd, entrypoint)
return common.NewPipelineExecutor( return common.NewPipelineExecutor(
prepImage, prepImage,
@@ -409,6 +402,30 @@ func execAsDocker(ctx context.Context, step actionStep, actionName, actionDir, b
).Finally(stepContainer.Close())(ctx) ).Finally(stepContainer.Close())(ctx)
} }
// dockerEntrypoint returns the entrypoint the action's image runs with for the given
// stage. Only the main stage honours the `entrypoint` input.
func dockerEntrypoint(ctx context.Context, step actionStep, eval ExpressionEvaluator, stage stepStage) ([]string, error) {
runs := step.getActionModel().Runs
var entrypoint string
switch stage {
case stepStagePre:
entrypoint = runs.PreEntrypoint
case stepStagePost:
entrypoint = runs.PostEntrypoint
default:
if fields := strings.Fields(eval.Interpolate(ctx, step.getStepModel().With["entrypoint"])); len(fields) > 0 {
return fields, nil
}
entrypoint = runs.Entrypoint
}
if entrypoint == "" {
return nil, nil
}
return shellquote.Split(entrypoint)
}
func evalDockerArgs(ctx context.Context, step step, action *model.Action, cmd *[]string) { func evalDockerArgs(ctx context.Context, step step, action *model.Action, cmd *[]string) {
rc := step.getRunContext() rc := step.getRunContext()
stepModel := step.getStepModel() stepModel := step.getStepModel()
@@ -559,44 +576,57 @@ func hasPreStep(step actionStep) common.Conditional {
return action.Runs.Using.IsComposite() || return action.Runs.Using.IsComposite() ||
(action.Runs.Using.IsNode() && (action.Runs.Using.IsNode() &&
action.Runs.Pre != "") || action.Runs.Pre != "") ||
(action.Runs.Using.IsDocker() &&
action.Runs.PreEntrypoint != "") ||
(action.Runs.Using == model.ActionRunsUsingGo && (action.Runs.Using == model.ActionRunsUsingGo &&
action.Runs.Pre != "") action.Runs.Pre != "")
} }
} }
// actionStagePaths resolves where a step's action lives and where the job container sees
// it, for the pre and post stage.
func actionStagePaths(step actionStep) (actionDir, actionPath, actionName, containerActionDir string) {
rc := step.getRunContext()
stepModel := step.getStepModel()
if _, ok := step.(*stepActionRemote); ok {
actionDir = fmt.Sprintf("%s/%s", rc.ActionCacheDir(), stepModel.UsesHash())
actionPath = newRemoteAction(stepModel.Uses).Path
} else {
actionDir = filepath.Join(rc.Config.Workdir, stepModel.Uses)
}
actionName, containerActionDir = getContainerActionPaths(stepModel, path.Join(actionDir, actionPath), rc)
return actionDir, actionPath, actionName, containerActionDir
}
// execDockerActionStage runs a docker action's image for its pre or post stage.
func execDockerActionStage(ctx context.Context, step actionStep, stage stepStage) error {
actionDir, actionPath, actionName, containerActionDir := actionStagePaths(step)
_, remote := step.(*stepActionRemote)
location := containerActionDir
if remote {
location = path.Join(actionDir, actionPath)
}
return execAsDocker(ctx, step, actionName, actionDir, location, !remote, stage)
}
func runPreStep(step actionStep) common.Executor { func runPreStep(step actionStep) common.Executor {
return func(ctx context.Context) error { return func(ctx context.Context) error {
logger := common.Logger(ctx) logger := common.Logger(ctx)
logger.Debugf("run pre step for '%s'", step.getStepModel()) logger.Debugf("run pre step for '%s'", step.getStepModel())
rc := step.getRunContext() rc := step.getRunContext()
stepModel := step.getStepModel()
action := step.getActionModel() action := step.getActionModel()
actionDir, actionPath, _, containerActionDir := actionStagePaths(step)
x := action.Runs.Using x := action.Runs.Using
switch { switch {
case x.IsNode(): case x.IsNode():
// defaults in pre steps were missing, however provided inputs are available // defaults in pre steps were missing, however provided inputs are available
populateEnvsFromInput(ctx, step.getEnv(), action, rc) populateEnvsFromInput(ctx, step.getEnv(), action, rc)
// todo: refactor into step
var actionDir string
var actionPath string
if _, ok := step.(*stepActionRemote); ok {
actionPath = newRemoteAction(stepModel.Uses).Path
actionDir = fmt.Sprintf("%s/%s", rc.ActionCacheDir(), stepModel.UsesHash())
} else {
actionDir = filepath.Join(rc.Config.Workdir, stepModel.Uses)
actionPath = ""
}
var actionLocation string
if actionPath != "" {
actionLocation = path.Join(actionDir, actionPath)
} else {
actionLocation = actionDir
}
_, containerActionDir := getContainerActionPaths(stepModel, actionLocation, rc)
if err := maybeCopyToActionDir(ctx, step, actionDir, actionPath, containerActionDir); err != nil { if err := maybeCopyToActionDir(ctx, step, actionDir, actionPath, containerActionDir); err != nil {
return err return err
@@ -609,6 +639,12 @@ func runPreStep(step actionStep) common.Executor {
return rc.execJobContainer(containerArgs, *step.getEnv(), "", "")(ctx) return rc.execJobContainer(containerArgs, *step.getEnv(), "", "")(ctx)
case x.IsDocker():
// defaults in pre steps were missing, however provided inputs are available
populateEnvsFromInput(ctx, step.getEnv(), action, rc)
return execDockerActionStage(ctx, step, stepStagePre)
case x.IsComposite(): case x.IsComposite():
if step.getCompositeSteps() == nil { if step.getCompositeSteps() == nil {
step.getCompositeRunContext(ctx) step.getCompositeRunContext(ctx)
@@ -622,25 +658,6 @@ func runPreStep(step actionStep) common.Executor {
case x == model.ActionRunsUsingGo: case x == model.ActionRunsUsingGo:
// defaults in pre steps were missing, however provided inputs are available // defaults in pre steps were missing, however provided inputs are available
populateEnvsFromInput(ctx, step.getEnv(), action, rc) populateEnvsFromInput(ctx, step.getEnv(), action, rc)
// todo: refactor into step
var actionDir string
var actionPath string
if _, ok := step.(*stepActionRemote); ok {
actionPath = newRemoteAction(stepModel.Uses).Path
actionDir = fmt.Sprintf("%s/%s", rc.ActionCacheDir(), stepModel.UsesHash())
} else {
actionDir = filepath.Join(rc.Config.Workdir, stepModel.Uses)
actionPath = ""
}
var actionLocation string
if actionPath != "" {
actionLocation = path.Join(actionDir, actionPath)
} else {
actionLocation = actionDir
}
_, containerActionDir := getContainerActionPaths(stepModel, actionLocation, rc)
if err := maybeCopyToActionDir(ctx, step, actionDir, actionPath, containerActionDir); err != nil { if err := maybeCopyToActionDir(ctx, step, actionDir, actionPath, containerActionDir); err != nil {
return err return err
@@ -693,6 +710,8 @@ func hasPostStep(step actionStep) common.Conditional {
return action.Runs.Using.IsComposite() || return action.Runs.Using.IsComposite() ||
(action.Runs.Using.IsNode() && (action.Runs.Using.IsNode() &&
action.Runs.Post != "") || action.Runs.Post != "") ||
(action.Runs.Using.IsDocker() &&
action.Runs.PostEntrypoint != "") ||
(action.Runs.Using == model.ActionRunsUsingGo && (action.Runs.Using == model.ActionRunsUsingGo &&
action.Runs.Post != "") action.Runs.Post != "")
} }
@@ -704,28 +723,9 @@ func runPostStep(step actionStep) common.Executor {
logger.Debugf("run post step for '%s'", step.getStepModel()) logger.Debugf("run post step for '%s'", step.getStepModel())
rc := step.getRunContext() rc := step.getRunContext()
stepModel := step.getStepModel()
action := step.getActionModel() action := step.getActionModel()
// todo: refactor into step actionDir, actionPath, _, containerActionDir := actionStagePaths(step)
var actionDir string
var actionPath string
if _, ok := step.(*stepActionRemote); ok {
actionPath = newRemoteAction(stepModel.Uses).Path
actionDir = fmt.Sprintf("%s/%s", rc.ActionCacheDir(), stepModel.UsesHash())
} else {
actionDir = filepath.Join(rc.Config.Workdir, stepModel.Uses)
actionPath = ""
}
var actionLocation string
if actionPath != "" {
actionLocation = path.Join(actionDir, actionPath)
} else {
actionLocation = actionDir
}
_, containerActionDir := getContainerActionPaths(stepModel, actionLocation, rc)
x := action.Runs.Using x := action.Runs.Using
switch { switch {
@@ -740,6 +740,11 @@ func runPostStep(step actionStep) common.Executor {
return rc.execJobContainer(containerArgs, *step.getEnv(), "", "")(ctx) return rc.execJobContainer(containerArgs, *step.getEnv(), "", "")(ctx)
case x.IsDocker():
populateEnvsFromSavedState(step.getEnv(), step, rc)
return execDockerActionStage(ctx, step, stepStagePost)
case x.IsComposite(): case x.IsComposite():
if err := maybeCopyToActionDir(ctx, step, actionDir, actionPath, containerActionDir); err != nil { if err := maybeCopyToActionDir(ctx, step, actionDir, actionPath, containerActionDir); err != nil {
return err return err

View File

@@ -183,7 +183,7 @@ func TestExecAsDockerAutoRemove(t *testing.T) {
cm.On("Start", true).Return(func(context.Context) error { return nil }) cm.On("Start", true).Return(func(context.Context) error { return nil })
cm.On("Close").Return(func(context.Context) error { return nil }) cm.On("Close").Return(func(context.Context) error { return nil })
require.NoError(t, execAsDocker(context.Background(), step, "action", t.TempDir(), t.TempDir(), false)) require.NoError(t, execAsDocker(context.Background(), step, "action", t.TempDir(), t.TempDir(), false, stepStageMain))
cm.AssertExpectations(t) cm.AssertExpectations(t)
assert.Equal(t, tc.removes, removes) assert.Equal(t, tc.removes, removes)
} }
@@ -465,7 +465,7 @@ func TestExecAsDockerHoldsCloneLockForRemoteUncached(t *testing.T) {
defer cancel() defer cancel()
done := make(chan error, 1) done := make(chan error, 1)
go func() { done <- execAsDocker(ctx, step, "test-action", actionDir, actionDir, false) }() go func() { done <- execAsDocker(ctx, step, "test-action", actionDir, actionDir, false, stepStageMain) }()
select { select {
case <-innerEntered: case <-innerEntered:
@@ -541,3 +541,86 @@ func TestDockerActionImageTag(t *testing.T) {
dockerActionImageTag("owner/repo", "./sub", true), dockerActionImageTag("owner/repo", "./sub", true),
) )
} }
// Only the entrypoint is stage specific: every stage of a docker action receives runs.args
// and runs.env, and the `entrypoint` input applies to the main stage alone.
func TestExecAsDockerStageEntrypoint(t *testing.T) {
orig := ContainerNewContainer
defer func() { ContainerNewContainer = orig }()
for _, tc := range []struct {
name string
stage stepStage
wantEntrypoint []string
}{
{
name: "main stage prefers the entrypoint input",
stage: stepStageMain,
wantEntrypoint: []string{"input.sh"},
},
{
name: "pre stage uses runs.pre-entrypoint",
stage: stepStagePre,
wantEntrypoint: []string{"pre.sh", "--verbose"},
},
{
name: "post stage uses runs.post-entrypoint",
stage: stepStagePost,
wantEntrypoint: []string{"post.sh"},
},
} {
t.Run(tc.name, func(t *testing.T) {
cm := &containerMock{}
var input *container.NewContainerInput
ContainerNewContainer = func(in *container.NewContainerInput) container.ExecutionsEnvironment {
input = in
return cm
}
step := &stepActionRemote{
Step: &model.Step{ID: "1", Uses: "org/action@v1", With: map[string]string{"entrypoint": "input.sh"}},
RunContext: &RunContext{
Config: &Config{},
Run: &model.Run{JobID: "1", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"1": {}}}},
JobContainer: cm,
},
action: &model.Action{Runs: model.ActionRuns{
Using: "docker",
Image: "docker://node:14",
PreEntrypoint: "pre.sh --verbose",
Entrypoint: "main.sh",
PostEntrypoint: "post.sh",
Args: []string{"hello"},
Env: map[string]string{"MY_VAR": "world"},
}},
env: map[string]string{},
}
cm.On("Pull", false).Return(func(context.Context) error { return nil })
cm.On("Remove").Return(func(context.Context) error { return nil })
cm.On("Create", []string(nil), []string(nil)).Return(func(context.Context) error { return nil })
cm.On("Start", true).Return(func(context.Context) error { return nil })
cm.On("Close").Return(func(context.Context) error { return nil })
require.NoError(t, execAsDocker(context.Background(), step, "action", t.TempDir(), t.TempDir(), false, tc.stage))
require.NotNil(t, input)
assert.Equal(t, tc.wantEntrypoint, input.Entrypoint)
assert.Equal(t, []string{"hello"}, input.Cmd)
assert.Contains(t, input.Env, "MY_VAR=world")
})
}
}
func TestDockerActionHasPreAndPostStep(t *testing.T) {
newStep := func(runs model.ActionRuns) actionStep {
return &stepActionRemote{action: &model.Action{Runs: runs}}
}
ctx := context.Background()
assert.False(t, hasPreStep(newStep(model.ActionRuns{Using: "docker", Image: "Dockerfile"}))(ctx))
assert.False(t, hasPostStep(newStep(model.ActionRuns{Using: "docker", Image: "Dockerfile"}))(ctx))
withStages := model.ActionRuns{Using: "docker", Image: "Dockerfile", PreEntrypoint: "pre.sh", PostEntrypoint: "post.sh"}
assert.True(t, hasPreStep(newStep(withStages))(ctx))
assert.True(t, hasPostStep(newStep(withStages))(ctx))
}

View File

@@ -95,9 +95,7 @@ func (rc *RunContext) NewExpressionEvaluatorWithEnv(ctx context.Context, env map
Inputs: inputs, Inputs: inputs,
HashFiles: getHashFilesFunction(ctx, rc), HashFiles: getHashFilesFunction(ctx, rc),
} }
if rc.JobContainer != nil { ee.Runner = rc.getRunnerContext(ctx)
ee.Runner = rc.JobContainer.GetRunnerContext(ctx)
}
return expressionEvaluator{ return expressionEvaluator{
interpreter: exprparser.NewInterpeter(ee, exprparser.Config{ interpreter: exprparser.NewInterpeter(ee, exprparser.Config{
Run: rc.Run, Run: rc.Run,
@@ -149,9 +147,7 @@ func (rc *RunContext) NewStepExpressionEvaluator(ctx context.Context, step step)
Inputs: inputs, Inputs: inputs,
HashFiles: getHashFilesFunction(ctx, rc), HashFiles: getHashFilesFunction(ctx, rc),
} }
if rc.JobContainer != nil { ee.Runner = rc.getRunnerContext(ctx)
ee.Runner = rc.JobContainer.GetRunnerContext(ctx)
}
return expressionEvaluator{ return expressionEvaluator{
interpreter: exprparser.NewInterpeter(ee, exprparser.Config{ interpreter: exprparser.NewInterpeter(ee, exprparser.Config{
Run: rc.Run, Run: rc.Run,

View File

@@ -298,7 +298,7 @@ func (rc *RunContext) startHostEnvironment() common.Executor {
AllocatePTY: rc.Config.AllocatePTY, AllocatePTY: rc.Config.AllocatePTY,
} }
rc.cleanUpJobContainer = rc.JobContainer.Remove() 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 { if v, ok := v.(string); ok {
rc.Env["RUNNER_"+strings.ToUpper(k)] = v rc.Env["RUNNER_"+strings.ToUpper(k)] = v
} }
@@ -986,6 +986,21 @@ func (rc *RunContext) getStepsContext() map[string]*model.StepResult {
return rc.StepResults 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 { func (rc *RunContext) getGithubContext(ctx context.Context) *model.GithubContext {
logger := common.Logger(ctx) logger := common.Logger(ctx)
ghc := &model.GithubContext{ 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["CI"] = "true"
env["GITHUB_WORKFLOW"] = github.Workflow env["GITHUB_WORKFLOW"] = github.Workflow
env["GITHUB_RUN_ID"] = github.RunID 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["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 != "" { if rc.Config.ArtifactServerPath != "" {
setActionRuntimeVars(rc, env) setActionRuntimeVars(rc, env)
} }
for _, platformName := range rc.runsOnPlatformNames(ctx) { if imageOS := rc.imageOS(ctx); imageOS != "" {
if platformName != "" { env["ImageOS"] = imageOS
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
}
} }
} }
return env // 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
}
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) { func setActionRuntimeVars(rc *RunContext, env map[string]string) {

View File

@@ -940,3 +940,117 @@ func TestRunContext_cleanupFailedStart(t *testing.T) {
assert.NotPanics(t, func() { (&RunContext{}).cleanupFailedStart(context.Background()) }) 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"])
}

View File

@@ -92,6 +92,14 @@ type Config struct {
InsecureSkipTLS bool // whether to skip verifying TLS certificate of the Gitea instance 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) 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 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 // GetToken: Adapt to Gitea
@@ -137,6 +145,11 @@ func New(runnerConfig *Config) (Runner, error) {
} }
func (runner *runnerImpl) configure() (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 = "{}" runner.eventJSON = "{}"
if runner.config.EventJSON != "" { if runner.config.EventJSON != "" {
runner.eventJSON = runner.config.EventJSON runner.eventJSON = runner.config.EventJSON

View File

@@ -167,6 +167,9 @@ func TestSetupEnv(t *testing.T) {
delete((env), "GITHUB_REPOSITORY") delete((env), "GITHUB_REPOSITORY")
delete((env), "GITHUB_REPOSITORY_OWNER") delete((env), "GITHUB_REPOSITORY_OWNER")
delete((env), "GITHUB_ACTOR") delete((env), "GITHUB_ACTOR")
// Host-dependent, asserted in TestRunContextWithGithubEnvRunnerValues instead.
delete((env), "RUNNER_NAME")
delete((env), "RUNNER_WORKSPACE")
assert.Equal(t, map[string]string{ assert.Equal(t, map[string]string{
"ACT": "true", "ACT": "true",
@@ -192,6 +195,7 @@ func TestSetupEnv(t *testing.T) {
"GITHUB_WORKFLOW": "", "GITHUB_WORKFLOW": "",
"INPUT_STEP_WITH": "with-value", "INPUT_STEP_WITH": "with-value",
"RC_KEY": "rcvalue", "RC_KEY": "rcvalue",
"RUNNER_ENVIRONMENT": "self-hosted",
"RUNNER_PERFLOG": "/dev/null", "RUNNER_PERFLOG": "/dev/null",
"RUNNER_TRACKING_ID": "", "RUNNER_TRACKING_ID": "",
}, env) }, env)

View File

@@ -474,6 +474,7 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
Vars: task.Vars, Vars: task.Vars,
ValidVolumes: r.cfg.Container.ValidVolumes, ValidVolumes: r.cfg.Container.ValidVolumes,
InsecureSkipTLS: r.cfg.Runner.Insecure, InsecureSkipTLS: r.cfg.Runner.Insecure,
RunnerName: r.name,
} }
rr, err := runner.New(runnerConfig) rr, err := runner.New(runnerConfig)