Compare commits

...
6 Commits
Author SHA1 Message Date
bircniandsilverwind 4a8ee4ce57 fix: resolve container sleep through PATH (#1255)
Resolve the job container's sleep command through PATH so images with nonstandard binary locations, including Nix, can run jobs.

Closes https://gitea.com/gitea/runner/issues/808

---------

Co-authored-by: silverwind <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1255
Reviewed-by: silverwind <[email protected]>
2026-09-29 18:44:57 +00:00
bircniandsilverwind bf03f36d5a fix: honor configured Docker registration file (#1259)
The Docker entrypoint always checked `.runner` in `/data` and ignored `runner.file` from `CONFIG_FILE`. When `runner.file` pointed elsewhere, it re-registered on every start, or skipped registration because of a stale `.runner`. It now reads the path from a new `config runner-file` subcommand, which resolves `runner.file` the same way as `register` and `daemon`.

Closes https://gitea.com/gitea/runner/issues/585

---------

Co-authored-by: silverwind <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1259
Reviewed-by: silverwind <[email protected]>
2026-09-29 18:38:35 +00:00
Renovate Botandsilverwind 5a38a3d0c4 chore(deps): update dependencies (#1254)
This PR contains the following updates:

| Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) | Type | Update |
|---|---|---|---|---|---|
| [github.com/bmatcuk/doublestar/v4](https://github.com/bmatcuk/doublestar) | `v4.10.0` → `v4.10.2` | ![age](https://developer.mend.io/api/mc/badges/age/go/github.com%2fbmatcuk%2fdoublestar%2fv4/v4.10.2?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/go/github.com%2fbmatcuk%2fdoublestar%2fv4/v4.10.0/v4.10.2?slim=true) | require | patch |
| [github.com/ulikunitz/xz](https://github.com/ulikunitz/xz) | `v0.5.16` → `v0.5.17` | ![age](https://developer.mend.io/api/mc/badges/age/go/github.com%2fulikunitz%2fxz/v0.5.17?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/go/github.com%2fulikunitz%2fxz/v0.5.16/v0.5.17?slim=true) |  | patch |
| nginx | `62ff208` → `df221db` |  |  |  | digest |

---

### Release Notes

<details>
<summary>bmatcuk/doublestar (github.com/bmatcuk/doublestar/v4)</summary>

### [`v4.10.2`](https://github.com/bmatcuk/doublestar/releases/tag/v4.10.2): Better support for Windows "Junctions"

[Compare Source](https://github.com/bmatcuk/doublestar/compare/v4.10.1...v4.10.2)

Has improved support for reading "junctions" in Windows (ie, Windows' version of symlinks).

Thanks [@&#8203;x0Lazarus](https://github.com/x0Lazarus) for the PR!

#### What's Changed

- Fix Windows junction traversal with Go 1.23 and later by [@&#8203;x0Lazarus](https://github.com/x0Lazarus) in [#&#8203;117](https://github.com/bmatcuk/doublestar/pull/117)

#### New Contributors

- [@&#8203;x0Lazarus](https://github.com/x0Lazarus) made their first contribution in [#&#8203;117](https://github.com/bmatcuk/doublestar/pull/117)

**Full Changelog**: <https://github.com/bmatcuk/doublestar/compare/v4.10.1...v4.10.2>

### [`v4.10.1`](https://github.com/bmatcuk/doublestar/releases/tag/v4.10.1): Fixed issue with alternatives and character classes

[Compare Source](https://github.com/bmatcuk/doublestar/compare/v4.10.0...v4.10.1)

There was an issue where commas or ending curly brackets inside a character class would cause code looking for the next alternative (commas) or the end of alternatives (ending curly brackets) to erroneously match those characters inside the character classes.

Thanks to [@&#8203;sidsri14](https://github.com/sidsri14) for the fix!

#### What's Changed

- fix brace alternation scanning with character classes by [@&#8203;sidsri14](https://github.com/sidsri14) in [#&#8203;115](https://github.com/bmatcuk/doublestar/pull/115)

#### New Contributors

- [@&#8203;sidsri14](https://github.com/sidsri14) made their first contribution in [#&#8203;115](https://github.com/bmatcuk/doublestar/pull/115)

**Full Changelog**: <https://github.com/bmatcuk/doublestar/compare/v4.10.0...v4.10.1>

</details>

<details>
<summary>ulikunitz/xz (github.com/ulikunitz/xz)</summary>

### [`v0.5.17`](https://github.com/ulikunitz/xz/compare/v0.5.16...v0.5.17)

[Compare Source](https://github.com/ulikunitz/xz/compare/v0.5.16...v0.5.17)

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - Between 12:00 AM and 03:59 AM, only on Monday (`* 0-3 * * 1`)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

👻 **Immortal**: This PR will be recreated if closed unmerged. Get [config help](https://github.com/renovatebot/renovate/discussions) if that's undesired.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Mend Renovate](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xOTEuMiIsInVwZGF0ZWRJblZlciI6IjQzLjE5MS4yIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->

---------

Co-authored-by: silverwind <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1254
Reviewed-by: silverwind <[email protected]>
Co-authored-by: Renovate Bot <[email protected]>
2026-09-28 09:49:36 +00:00
silverwindandbircni 6618e67645 fix: apply a job container without image to the runs-on image (#1252)
Fixes https://gitea.com/gitea/runner/issues/1251, a regression from https://gitea.com/gitea/runner/pulls/1233.

A job `container:` without an `image` key configures the runs-on image again, as in 3.x, so its `volumes`, `options`, `credentials` and `env` apply. An image that evaluates to empty still skips the container, as on GitHub.

---------

Co-authored-by: bircni <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1252
Reviewed-by: Lunny Xiao <[email protected]>
Reviewed-by: bircni <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-09-28 09:43:48 +00:00
silverwind 2b599fae7f fix: misc bug fixed and improvements (#1253)
1. Host mode is selected only through `runs-on` again, as in upstream act.
1. A workflow volume that `valid_volumes` drops no longer displaces the runner's own socket, workspace, tool cache or `/var/run/act` mount, so `/var/run/docker.sock:/var/run/docker.sock` works on hosts whose daemon socket lives elsewhere.
1. `docker://` is stripped from job and service images, as on GitHub.
1. Each created service's log is printed at job end, and service log dumps no longer run workflow commands, as on GitHub.
1. A `--privileged` dropped because privileged mode is off now logs a warning.
1. README: the proxy variables change per step through `env:` or for the rest of a job through `$GITHUB_ENV`, `container.env` does not override them.

Reviewed-on: https://gitea.com/gitea/runner/pulls/1253
Reviewed-by: Zettat123 <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-09-28 05:53:41 +00:00
Zettat123 990ebaa2ac fix: restore the caller's event and inputs in reusable workflow jobs (#1250)
Fixes https://github.com/go-gitea/gitea/issues/38705

Gitea side: https://github.com/go-gitea/gitea/pull/39452

Jobs of a called workflow currently see `gitea.event_name == "workflow_call"`, and their `gitea.event.inputs` is replaced by the caller's `with:`. On GitHub they keep the caller's event, and their `inputs` are the run's `workflow_dispatch` inputs overlaid with the caller's `with:`.

For compatibility with older runners, Gitea keeps sending that form and adds a `gitea_workflow_call` key with the original trigger event name and inputs of the run, and the `inputs` Gitea resolved for the job. When the key is present, the runner restores `gitea.event_name` and `gitea.event.inputs` from it, and uses the resolved `inputs` for the job's expressions and the setup log.

Without that key (older Gitea, or jobs outside called workflows), nothing changes.

Reviewed-on: https://gitea.com/gitea/runner/pulls/1250
Reviewed-by: bircni <[email protected]>
Reviewed-by: silverwind <[email protected]>
Co-authored-by: Zettat123 <[email protected]>
2026-09-27 16:13:14 +00:00
23 changed files with 300 additions and 67 deletions

No files matched your search

+1 -1
View File
@@ -31,7 +31,7 @@ jobs:
- name: pre-pull test images - name: pre-pull test images
env: env:
TEST_JOB_IMAGE: node:24-bookworm-slim@sha256:0e0ff40c39bc087845bfb27465a0df4ea419520094bc35842ff83dd8cbe6f9b6 # renovate: datasource=docker TEST_JOB_IMAGE: node:24-bookworm-slim@sha256:0e0ff40c39bc087845bfb27465a0df4ea419520094bc35842ff83dd8cbe6f9b6 # renovate: datasource=docker
TEST_SERVICE_IMAGE: nginx:alpine@sha256:62ff2089abf5a9ed33bd232895bef5e22f7bb4b200675cec49a5ebc48e3d4ac8 # renovate: datasource=docker TEST_SERVICE_IMAGE: nginx:alpine@sha256:df221db836e1754089190208cee7eeda94f233197056426eda74a43ab1abeac2 # renovate: datasource=docker
run: | run: |
for image in "$TEST_JOB_IMAGE" "$TEST_SERVICE_IMAGE"; do for image in "$TEST_JOB_IMAGE" "$TEST_SERVICE_IMAGE"; do
for attempt in 1 2 3; do for attempt in 1 2 3; do
+1 -1
View File
@@ -6,7 +6,7 @@ SHASUM ?= shasum -a 256
HAS_GO = $(shell hash $(GO) > /dev/null 2>&1 && echo "GO" || echo "NOGO" ) HAS_GO = $(shell hash $(GO) > /dev/null 2>&1 && echo "GO" || echo "NOGO" )
XGO_PACKAGE ?= src.techknowlogick.com/[email protected] # renovate: datasource=go XGO_PACKAGE ?= src.techknowlogick.com/[email protected] # renovate: datasource=go
XGO_VERSION := go-1.27.x XGO_VERSION := go-1.27.x
GXZ_PACKAGE ?= github.com/ulikunitz/xz/cmd/[email protected]6 # renovate: datasource=go GXZ_PACKAGE ?= github.com/ulikunitz/xz/cmd/[email protected]7 # renovate: datasource=go
PODMAN_TEST_IMAGE ?= quay.io/containers/podman:v5.8.4-immutable@sha256:90405e3c6a86f33c5a6f8dbdfa8e02a98143a8ba4337b84bdaec5336d2ef008a # renovate: datasource=docker PODMAN_TEST_IMAGE ?= quay.io/containers/podman:v5.8.4-immutable@sha256:90405e3c6a86f33c5a6f8dbdfa8e02a98143a8ba4337b84bdaec5336d2ef008a # renovate: datasource=docker
E2E_JOB_IMAGE ?= node:24-bookworm@sha256:64af3819f9275802414d7cdc38c27e9d82bd564dec4d4da87d008255d36c63b4 # renovate: datasource=docker E2E_JOB_IMAGE ?= node:24-bookworm@sha256:64af3819f9275802414d7cdc38c27e9d82bd564dec4d4da87d008255d36c63b4 # renovate: datasource=docker
+1 -1
View File
@@ -298,7 +298,7 @@ These hosts are added to `no_proxy` for jobs, so they are always reached directl
Gitea is not added. Add it to `no_proxy` yourself if it should be reached directly. Gitea is not added. Add it to `no_proxy` yourself if it should be reached directly.
To change a value for one job, set it in a step's `env:` or in the job's `container.env`. Setting it at workflow or job level has no effect. To change it for the whole runner, set it in `runner.envs`. A `no_proxy` set there is added to the list above instead of replacing it. For one step, set it in the step's `env:`, for the rest of a job, write it to `$GITHUB_ENV`. Setting it at workflow or job level or in `container.env` has no effect. To change it for the whole runner, set it in `runner.envs`. A `no_proxy` set there is added to the list above instead of replacing it.
Images are pulled by the Docker daemon, which needs its own proxy setting. In the `dind` images the daemon runs in the same container and reads the variables above. For any other daemon, see [the Docker documentation](https://docs.docker.com/engine/daemon/proxy/). The runner logs a warning at startup if it has a proxy and the daemon does not. Images are pulled by the Docker daemon, which needs its own proxy setting. In the `dind` images the daemon runs in the same container and reads the variables above. For any other daemon, see [the Docker documentation](https://docs.docker.com/engine/daemon/proxy/). The runner logs a warning at startup if it has a proxy and the daemon does not.
+9 -1
View File
@@ -593,7 +593,8 @@ func (cr *containerReference) mergeContainerConfigs(ctx context.Context, config
// If the `privileged` config has been disabled, `copts.privileged` need to be forced to false, // If the `privileged` config has been disabled, `copts.privileged` need to be forced to false,
// even if the user specifies `--privileged` in the options string. // even if the user specifies `--privileged` in the options string.
if !hostConfig.Privileged { if !hostConfig.Privileged && copts.privileged {
logger.Warnf("container option %q is not allowed when privileged mode is disabled and will be ignored", "--privileged")
copts.privileged = false copts.privileged = false
} }
@@ -629,6 +630,7 @@ func (cr *containerReference) mergeContainerConfigs(ctx context.Context, config
logger.Debugf("Custom container.HostConfig from options ==> %+v", containerConfig.HostConfig) logger.Debugf("Custom container.HostConfig from options ==> %+v", containerConfig.HostConfig)
cr.sanitizeConfig(ctx, nil, containerConfig.HostConfig) // a dropped volume must not displace the runner's own
overlayVolumes(hostConfig, containerConfig.HostConfig) overlayVolumes(hostConfig, containerConfig.HostConfig)
binds := hostConfig.Binds binds := hostConfig.Binds
mounts := hostConfig.Mounts mounts := hostConfig.Mounts
@@ -1261,6 +1263,12 @@ func (cr *containerReference) sanitizeConfig(ctx context.Context, config *contai
return config, hostConfig return config, hostConfig
} }
func SanitizeBinds(ctx context.Context, validVolumes, binds []string) []string {
cr := &containerReference{input: &NewContainerInput{ValidVolumes: validVolumes}}
_, hostConfig := cr.sanitizeConfig(ctx, nil, &container.HostConfig{Binds: binds})
return hostConfig.Binds
}
// bindTarget returns the container path a bind mounts onto, empty if it cannot be parsed. // bindTarget returns the container path a bind mounts onto, empty if it cannot be parsed.
func bindTarget(bind string) string { func bindTarget(bind string) string {
parsed, err := loader.ParseVolume(bind) parsed, err := loader.ParseVolume(bind)
+6 -2
View File
@@ -682,6 +682,7 @@ func mergeOptions(t *testing.T, runnerOptions, workflowOptions string, privilege
WorkflowOptions: workflowOptions, WorkflowOptions: workflowOptions,
NetworkMode: "bridge", NetworkMode: "bridge",
UsernsMode: "private", UsernsMode: "private",
ValidVolumes: []string{"**"},
}} }}
_, hostConfig, err := cr.mergeContainerConfigs(common.WithLogger(context.Background(), logger), &container.Config{}, &container.HostConfig{ _, hostConfig, err := cr.mergeContainerConfigs(common.WithLogger(context.Background(), logger), &container.Config{}, &container.HostConfig{
@@ -844,8 +845,10 @@ func TestMergeContainerConfigsVolumesReplaceRunnerMounts(t *testing.T) {
ctx := common.WithLogger(context.Background(), logger) ctx := common.WithLogger(context.Background(), logger)
cr := &containerReference{ cr := &containerReference{
input: &NewContainerInput{ input: &NewContainerInput{
NetworkMode: "bridge", NetworkMode: "bridge",
RunnerOptions: "--volume /host/tools:/opt/hostedtoolcache", RunnerOptions: "--volume /host/tools:/opt/hostedtoolcache",
WorkflowOptions: "--volume /unapproved:/var/run/docker.sock",
ValidVolumes: []string{"/host/tools"},
}, },
} }
@@ -867,6 +870,7 @@ func TestMergeContainerConfigsWarnsOnlyAboutOptionsThatWereGiven(t *testing.T) {
assert.Zero(t, warnings("--volume /host/tools:/opt/hostedtoolcache", "")) assert.Zero(t, warnings("--volume /host/tools:/opt/hostedtoolcache", ""))
assert.Zero(t, warnings("", "--shm-size 1g")) assert.Zero(t, warnings("", "--shm-size 1g"))
assert.Equal(t, 1, warnings("--network host", "")) assert.Equal(t, 1, warnings("--network host", ""))
assert.Equal(t, 1, warnings("", "--privileged"))
} }
func TestMergeContainerConfigsKeepsNetworkAliasesFromOptions(t *testing.T) { func TestMergeContainerConfigsKeepsNetworkAliasesFromOptions(t *testing.T) {
+4
View File
@@ -107,6 +107,10 @@ func RemoveOrphanNetworks(_ context.Context, _, _ string, _ time.Time) error {
return nil return nil
} }
func SanitizeBinds(_ context.Context, _, binds []string) []string {
return binds
}
func CreateJobVolumes(ctx context.Context, runnerUUID string, volumeNames []string) error { func CreateJobVolumes(ctx context.Context, runnerUUID string, volumeNames []string) error {
return nil return nil
} }
+1 -1
View File
@@ -447,7 +447,7 @@ func newStepContainer(ctx context.Context, step step, image string, cmd, entrypo
envList = append(envList, rc.runnerEnv(ctx)...) envList = append(envList, rc.runnerEnv(ctx)...)
binds, mounts := rc.GetBindsAndMounts() binds, mounts := rc.GetBindsAndMounts(ctx)
networkMode := "container:" + rc.jobContainerName() networkMode := "container:" + rc.jobContainerName()
if rc.IsHostEnv() { if rc.IsHostEnv() {
networkMode = "default" networkMode = "default"
+8
View File
@@ -248,6 +248,14 @@ func inputsFromEnv(env map[string]string) map[string]any {
func getEvaluatorInputs(rc *RunContext, stepInputs map[string]any, ghc *model.GithubContext) map[string]any { func getEvaluatorInputs(rc *RunContext, stepInputs map[string]any, ghc *model.GithubContext) map[string]any {
inputs := map[string]any{} inputs := map[string]any{}
// only a called workflow's job itself takes the inputs Gitea resolved,
// composite actions copy its Config but have their own `inputs`
if rc.Config.WorkflowCallInputs != nil && rc.Parent == nil {
maps.Copy(inputs, rc.Config.WorkflowCallInputs)
maps.Copy(inputs, stepInputs)
return inputs
}
maps.Copy(inputs, rc.workflowCallInputs) maps.Copy(inputs, rc.workflowCallInputs)
maps.Copy(inputs, stepInputs) maps.Copy(inputs, stepInputs)
+22
View File
@@ -392,6 +392,28 @@ jobs:
}) })
} }
func TestGetEvaluatorInputsResolvedByGitea(t *testing.T) {
wf, err := model.ReadWorkflow(strings.NewReader(`
on:
workflow_call:
inputs:
name: {type: string, default: callee}
workflow_dispatch:
inputs:
name: {type: string}
`))
require.NoError(t, err)
config := &Config{Workdir: ".", WorkflowCallInputs: map[string]any{"name": "caller", "target": "dispatch"}}
ghc := &model.GithubContext{EventName: "workflow_dispatch", Event: map[string]any{"inputs": map[string]any{"name": "dispatch"}}}
// the event's own inputs must not override those Gitea resolved
job := &RunContext{Config: config, Run: &model.Run{JobID: "job1", Workflow: wf}}
assert.Equal(t, map[string]any{"name": "caller", "target": "dispatch"}, getEvaluatorInputs(job, nil, ghc))
composite := &RunContext{Config: config, Run: &model.Run{JobID: "job1", Workflow: &model.Workflow{}}, Parent: job}
assert.Equal(t, map[string]any{"arg": "x"}, getEvaluatorInputs(composite, map[string]any{"arg": "x"}, ghc))
}
func TestResolveWorkflowCallToleratesMismatchesAndPassesInheritedSecretsVerbatim(t *testing.T) { func TestResolveWorkflowCallToleratesMismatchesAndPassesInheritedSecretsVerbatim(t *testing.T) {
callerWorkflow, err := model.ReadWorkflow(strings.NewReader(` callerWorkflow, err := model.ReadWorkflow(strings.NewReader(`
jobs: jobs:
+1 -5
View File
@@ -435,11 +435,7 @@ func evaluateJobEnvAndDefaults(ctx context.Context, rc *RunContext) error {
return err return err
} }
rc.jobRunDefaults = defaults.Run rc.jobRunDefaults = defaults.Run
if rc.containerSpec.Image == "" { return model.DecodeEvaluated("container env", rc.containerEnv, rc.ExprEval.shared(ctx).EvaluateYamlNode, &rc.containerSpec.Env)
return nil
}
_, containerEnv := splitContainerEnv(rc.Run.Job().RawContainer)
return model.DecodeEvaluated("container env", containerEnv, rc.ExprEval.shared(ctx).EvaluateYamlNode, &rc.containerSpec.Env)
} }
func setJobOutputs(ctx context.Context, rc *RunContext) error { func setJobOutputs(ctx context.Context, rc *RunContext) error {
+64 -16
View File
@@ -64,7 +64,8 @@ type RunContext struct {
ExprEval *expressionEvaluator ExprEval *expressionEvaluator
JobContainer container.ExecutionsEnvironment JobContainer container.ExecutionsEnvironment
serviceContainers []*serviceContainer serviceContainers []*serviceContainer
containerSpec model.ContainerSpec // container:, resolved once with platformImage, zero without a job container containerSpec model.ContainerSpec // container:, resolved once with platformImage
containerEnv yaml.Node // container.env of the kept containerSpec, decoded once the job env resolves
JobName string JobName string
ActionPath string ActionPath string
Parent *RunContext Parent *RunContext
@@ -113,6 +114,7 @@ type serviceContainer struct {
name string name string
image string image string
container container.ExecutionsEnvironment container container.ExecutionsEnvironment
created bool
logsDumped bool logsDumped bool
info *container.Info // last poll, the source of the `job.services` entry info *container.Info // last poll, the source of the `job.services` entry
} }
@@ -334,12 +336,12 @@ func splitVolumes(specs []string) ([]string, map[string]string, map[string]bool)
} }
// Returns the binds and mounts for the container, resolving paths as appopriate // Returns the binds and mounts for the container, resolving paths as appopriate
func (rc *RunContext) GetBindsAndMounts() ([]string, map[string]string) { func (rc *RunContext) GetBindsAndMounts(ctx context.Context) ([]string, map[string]string) {
name := rc.jobContainerName() name := rc.jobContainerName()
ext := container.LinuxContainerEnvironmentExtensions{} ext := container.LinuxContainerEnvironmentExtensions{}
// the runner's own mounts below yield to the targets the job claims // the runner's own mounts below yield to the targets the job claims
binds, mounts, claimed := splitVolumes(rc.containerSpec.Volumes) binds, mounts, claimed := splitVolumes(container.SanitizeBinds(ctx, rc.validVolumes(), rc.containerSpec.Volumes))
if rc.containerDaemonSocket() != "-" && !claimed["/var/run/docker.sock"] { if rc.containerDaemonSocket() != "-" && !claimed["/var/run/docker.sock"] {
binds = append(binds, rc.jobDockerSocket()+":/var/run/docker.sock") binds = append(binds, rc.jobDockerSocket()+":/var/run/docker.sock")
@@ -497,10 +499,12 @@ func (rc *RunContext) startJobContainer() common.Executor {
if err := model.DecodeEvaluated("job services", rc.Run.Job().RawServices, rc.ExprEval.shared(ctx).EvaluateYamlNode, &services); err != nil { if err := model.DecodeEvaluated("job services", rc.Run.Job().RawServices, rc.ExprEval.shared(ctx).EvaluateYamlNode, &services); err != nil {
return err return err
} }
for serviceID, spec := range services { for _, serviceID := range slices.Sorted(maps0.Keys(services)) {
spec := services[serviceID]
if spec == nil { if spec == nil {
return fmt.Errorf("service %s has no container definition", serviceID) return fmt.Errorf("service %s has no container definition", serviceID)
} }
spec.Image = strings.TrimPrefix(spec.Image, "docker://")
// GitHub compatibility: skip services whose image evaluates to an // GitHub compatibility: skip services whose image evaluates to an
// empty string, enabling conditional services via expressions // empty string, enabling conditional services via expressions
if spec.Image == "" { if spec.Image == "" {
@@ -537,6 +541,7 @@ func (rc *RunContext) startJobContainer() common.Executor {
} }
serviceContainerName := createContainerName(rc.jobContainerName(), serviceID) serviceContainerName := createContainerName(rc.jobContainerName(), serviceID)
serviceLog := common.NewLineWriter(rawLogLine(ctx)) // raw, so a service's output cannot set the job's env, outputs or state
c := newContainer(&container.NewContainerInput{ c := newContainer(&container.NewContainerInput{
Name: serviceContainerName, Name: serviceContainerName,
Image: spec.Image, Image: spec.Image,
@@ -547,8 +552,8 @@ func (rc *RunContext) startJobContainer() common.Executor {
Env: envs, Env: envs,
Mounts: serviceMounts, Mounts: serviceMounts,
Binds: serviceBinds, Binds: serviceBinds,
Stdout: logWriter, Stdout: serviceLog,
Stderr: logWriter, Stderr: serviceLog,
Privileged: rc.Config.Privileged, Privileged: rc.Config.Privileged,
UsernsMode: rc.Config.UsernsMode, UsernsMode: rc.Config.UsernsMode,
Platform: rc.Config.ContainerArchitecture, Platform: rc.Config.ContainerArchitecture,
@@ -570,7 +575,7 @@ func (rc *RunContext) startJobContainer() common.Executor {
ext := container.LinuxContainerEnvironmentExtensions{} ext := container.LinuxContainerEnvironmentExtensions{}
containerInput := &container.NewContainerInput{ containerInput := &container.NewContainerInput{
Cmd: nil, Cmd: nil,
Entrypoint: []string{"/bin/sleep", fmt.Sprint(rc.Config.ContainerMaxLifetime.Round(time.Second).Seconds())}, Entrypoint: []string{"sleep", fmt.Sprint(rc.Config.ContainerMaxLifetime.Round(time.Second).Seconds())},
WorkingDir: ext.ToContainerPath(rc.Config.Workdir), WorkingDir: ext.ToContainerPath(rc.Config.Workdir),
Image: image, Image: image,
Username: username, Username: username,
@@ -602,7 +607,7 @@ func (rc *RunContext) startJobContainer() common.Executor {
return err return err
} }
rc.startDockerProxy(ctx) rc.startDockerProxy(ctx)
containerInput.Binds, containerInput.Mounts = rc.GetBindsAndMounts() containerInput.Binds, containerInput.Mounts = rc.GetBindsAndMounts(ctx)
containerInput.ValidVolumes = rc.validVolumes() containerInput.ValidVolumes = rc.validVolumes()
rc.jobNetworkName = networkName rc.jobNetworkName = networkName
@@ -633,16 +638,21 @@ func (rc *RunContext) startJobContainer() common.Executor {
} }
func (rc *RunContext) commandLogWriter(ctx context.Context) io.Writer { func (rc *RunContext) commandLogWriter(ctx context.Context) io.Writer {
return common.NewLineWriter(rc.commandHandler(ctx), rawLogLine(ctx))
}
func rawLogLine(ctx context.Context) common.LineHandler {
rawLogger := common.Logger(ctx).WithField(rawOutputField, true) rawLogger := common.Logger(ctx).WithField(rawOutputField, true)
return common.NewLineWriter(rc.commandHandler(ctx), func(line string) bool { return func(line string) bool {
rawLogger.Infof("%s", line) rawLogger.Infof("%s", line)
return true return true
}) }
} }
func (rc *RunContext) cleanupJobResources(networkName string, createAndDeleteNetwork, preclean bool) common.Executor { func (rc *RunContext) cleanupJobResources(networkName string, createAndDeleteNetwork, preclean bool) common.Executor {
return func(ctx context.Context) error { return func(ctx context.Context) error {
logger := common.Logger(ctx) logger := common.Logger(ctx)
rc.printServiceLogs(ctx)
errs := []error{rc.closeDockerProxy(ctx)} errs := []error{rc.closeDockerProxy(ctx)}
if rc.JobContainer != nil { if rc.JobContainer != nil {
errs = append(errs, rc.JobContainer.Remove()(ctx)) errs = append(errs, rc.JobContainer.Remove()(ctx))
@@ -789,6 +799,7 @@ func (rc *RunContext) startServiceContainers() common.Executor {
for _, svc := range rc.serviceContainers { for _, svc := range rc.serviceContainers {
execs = append(execs, common.NewPipelineExecutor( execs = append(execs, common.NewPipelineExecutor(
svc.container.Create(rc.Config.ContainerCapAdd, rc.Config.ContainerCapDrop), svc.container.Create(rc.Config.ContainerCapAdd, rc.Config.ContainerCapDrop),
func(context.Context) error { svc.created = true; return nil },
svc.container.Start(false), svc.container.Start(false),
)) ))
} }
@@ -927,6 +938,26 @@ func (svc *serviceContainer) dumpLogs(ctx context.Context) {
} }
} }
const serviceLogsTimeout = 15 * time.Second // cleanup gets one minute in total
func (rc *RunContext) printServiceLogs(ctx context.Context) {
ctx, cancel := context.WithTimeout(ctx, serviceLogsTimeout)
defer cancel()
rawLogger := common.Logger(ctx).WithField(rawOutputField, true)
for _, svc := range rc.serviceContainers {
if !svc.created || svc.logsDumped {
continue
}
rawLogger.Infof("::group::Print service container logs: %s", EscapeCommandData(svc.name))
svc.dumpLogs(ctx)
rawLogger.Infof("::endgroup::")
if ctx.Err() != nil {
common.Logger(ctx).Warnf("Timed out printing the service logs after %s", serviceLogsTimeout)
return
}
}
}
// inspect also records the state for the `job.services` context. // inspect also records the state for the `job.services` context.
func (svc *serviceContainer) inspect(ctx context.Context) (*container.Info, error) { func (svc *serviceContainer) inspect(ctx context.Context) (*container.Info, error) {
info, err := svc.container.Inspect(ctx) info, err := svc.container.Inspect(ctx)
@@ -1039,7 +1070,7 @@ func (rc *RunContext) cleanupFailedStart(ctx context.Context) {
} }
func (rc *RunContext) IsHostEnv() bool { func (rc *RunContext) IsHostEnv() bool {
return strings.EqualFold(rc.platformImage, "-self-hosted") return rc.containerSpec.Image == "" && strings.EqualFold(rc.platformImage, "-self-hosted")
} }
func (rc *RunContext) stopContainer() common.Executor { func (rc *RunContext) stopContainer() common.Executor {
@@ -1159,15 +1190,19 @@ func (rc *RunContext) runsOnPlatformNames(ctx context.Context) []string {
return model.RunsOnFromNode(rawRunsOn) return model.RunsOnFromNode(rawRunsOn)
} }
// resolvePlatformImage evaluates the job's container once for every consumer, ignoring one without an image as GitHub does. // resolvePlatformImage evaluates the job's container once for every consumer, skipping one whose image is empty as GitHub does.
func (rc *RunContext) resolvePlatformImage(ctx context.Context) error { func (rc *RunContext) resolvePlatformImage(ctx context.Context) error {
withoutEnv, _ := splitContainerEnv(rc.Run.Job().RawContainer) withoutEnv, env := splitContainerEnv(rc.Run.Job().RawContainer)
var spec *model.ContainerSpec var spec model.ContainerSpec
if err := model.DecodeEvaluated("container", withoutEnv, rc.ExprEval.shared(ctx).EvaluateYamlNode, &spec); err != nil { if err := model.DecodeEvaluated("container", withoutEnv, rc.ExprEval.shared(ctx).EvaluateYamlNode, &spec); err != nil {
return err return err
} }
if spec != nil && spec.Image != "" { spec.Image = strings.TrimPrefix(spec.Image, "docker://")
rc.containerSpec, rc.platformImage = *spec, spec.Image if spec.Image != "" || imagelessContainer(withoutEnv) {
rc.containerSpec, rc.containerEnv = spec, env
}
if rc.containerSpec.Image != "" {
rc.platformImage = rc.containerSpec.Image
return nil return nil
} }
image, err := rc.runsOnImage(ctx) image, err := rc.runsOnImage(ctx)
@@ -1175,6 +1210,19 @@ func (rc *RunContext) resolvePlatformImage(ctx context.Context) error {
return err return err
} }
// imagelessContainer reports a container: mapping without an image key, which configures the runs-on image.
func imagelessContainer(container yaml.Node) bool {
if container.Kind != yaml.MappingNode {
return false
}
for i := 0; i < len(container.Content); i += 2 {
if container.Content[i].Value == "image" {
return false
}
}
return true
}
// splitContainerEnv takes `env` out of a container mapping, since env reads the job env that resolves after the job environment starts. // splitContainerEnv takes `env` out of a container mapping, since env reads the job env that resolves after the job environment starts.
func splitContainerEnv(container yaml.Node) (withoutEnv, env yaml.Node) { func splitContainerEnv(container yaml.Node) (withoutEnv, env yaml.Node) {
if container.Kind != yaml.MappingNode { if container.Kind != yaml.MappingNode {
+74 -32
View File
@@ -32,6 +32,7 @@ import (
"github.com/docker/cli/cli/compose/loader" "github.com/docker/cli/cli/compose/loader"
"github.com/moby/moby/api/types/volume" "github.com/moby/moby/api/types/volume"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
logrustest "github.com/sirupsen/logrus/hooks/test"
assert "github.com/stretchr/testify/assert" assert "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock" "github.com/stretchr/testify/mock"
require "github.com/stretchr/testify/require" require "github.com/stretchr/testify/require"
@@ -342,12 +343,12 @@ jobs:
require.Nil(t, redis.Cmd) require.Nil(t, redis.Cmd)
} }
func TestStartJobContainerEvaluatesContainersOnce(t *testing.T) { func TestStartJobContainerBuildsEvaluatedJobAndServiceInputs(t *testing.T) {
inputs := startJobContainerInputs(t, ` inputs := startJobContainerInputs(t, `
jobs: jobs:
job: job:
container: ${{ fromJSON('{"image":"node:20","options":"--label ${{ github.job }}","credentials":{"username":"user","password":"${{ vars.TITLE }}"}}') }} container: ${{ fromJSON('{"image":"docker://node:20","options":"--label ${{ github.job }}","credentials":{"username":"user","password":"${{ vars.TITLE }}"}}') }}
services: ${{ fromJSON('{"redis":{"image":"redis:latest","volumes":["data:/data"],"env":{"TITLE":"${{ github.job }}"},"credentials":{"username":"user","password":"${{ github.job }}"},"entrypoint":"/entry.sh","command":"redis-server --port 6380"}}') }} services: ${{ fromJSON('{"redis":{"image":"docker://redis:latest","volumes":["data:/data"],"env":{"TITLE":"${{ github.job }}"},"credentials":{"username":"user","password":"${{ github.job }}"},"entrypoint":"/entry.sh","command":"redis-server --port 6380"}}') }}
`, &Config{}) `, &Config{})
redis, job := inputs[0], inputs[1] redis, job := inputs[0], inputs[1]
@@ -358,6 +359,7 @@ jobs:
require.Equal(t, []string{"/entry.sh"}, redis.Entrypoint) require.Equal(t, []string{"/entry.sh"}, redis.Entrypoint)
require.Equal(t, []string{"redis-server", "--port", "6380"}, redis.Cmd) require.Equal(t, []string{"redis-server", "--port", "6380"}, redis.Cmd)
require.Equal(t, "node:20", job.Image) require.Equal(t, "node:20", job.Image)
require.Equal(t, "sleep", job.Entrypoint[0])
require.Equal(t, "--label ${{ github.job }}", job.WorkflowOptions) require.Equal(t, "--label ${{ github.job }}", job.WorkflowOptions)
require.Equal(t, "${{ vars.TITLE }}", job.Password) require.Equal(t, "${{ vars.TITLE }}", job.Password)
} }
@@ -412,17 +414,14 @@ jobs:
steps: [] steps: []
`, &Config{ProxyEnv: map[string]string{"http_proxy": "http://proxy:3128", "no_proxy": "internal.example"}}) `, &Config{ProxyEnv: map[string]string{"http_proxy": "http://proxy:3128", "no_proxy": "internal.example"}})
env := map[string][]string{} db, redis := inputs[0], inputs[1]
for _, in := range inputs {
env[in.Image] = in.Env
}
require.Contains(t, env["redis:latest"], "http_proxy=http://proxy:3128") require.Contains(t, redis.Env, "http_proxy=http://proxy:3128")
require.Contains(t, env["redis:latest"], "no_proxy=internal.example") require.Contains(t, redis.Env, "no_proxy=internal.example")
// the service's own env wins over what the runner injected, without dropping the rest // the service's own env wins over what the runner injected, without dropping the rest
require.Contains(t, env["postgres:latest"], "no_proxy=db-only.example") require.Contains(t, db.Env, "no_proxy=db-only.example")
require.NotContains(t, env["postgres:latest"], "no_proxy=internal.example") require.NotContains(t, db.Env, "no_proxy=internal.example")
require.Contains(t, env["postgres:latest"], "http_proxy=http://proxy:3128") require.Contains(t, db.Env, "http_proxy=http://proxy:3128")
} }
// act builds Dockerfile actions through the API, which does not pre-populate the proxy // act builds Dockerfile actions through the API, which does not pre-populate the proxy
@@ -483,7 +482,7 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) {
config := testcase.rc.Config config := testcase.rc.Config
config.Workdir = testcase.name config.Workdir = testcase.name
config.BindWorkdir = bindWorkDir config.BindWorkdir = bindWorkDir
gotbind, gotmount := rctemplate.GetBindsAndMounts() gotbind, gotmount := rctemplate.GetBindsAndMounts(t.Context())
// Name binds/mounts are either/or // Name binds/mounts are either/or
if config.BindWorkdir { if config.BindWorkdir {
@@ -533,7 +532,8 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) {
}, },
}, },
Config: &Config{ Config: &Config{
BindWorkdir: false, BindWorkdir: false,
ValidVolumes: []string{"**"},
Secrets: map[string]string{ Secrets: map[string]string{
"MAME": "/host/mame/roms", "MAME": "/host/mame/roms",
}, },
@@ -544,7 +544,7 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) {
rc.ExprEval = rc.NewExpressionEvaluator(context.Background()) rc.ExprEval = rc.NewExpressionEvaluator(context.Background())
require.NoError(t, rc.resolvePlatformImage(context.Background())) require.NoError(t, rc.resolvePlatformImage(context.Background()))
gotbind, gotmount := rc.GetBindsAndMounts() gotbind, gotmount := rc.GetBindsAndMounts(t.Context())
assert.Contains(t, gotbind, "/host/mame/roms:/root/.mame/roms:ro") assert.Contains(t, gotbind, "/host/mame/roms:/root/.mame/roms:ro")
assert.NotContains(t, gotbind, "${{ secrets.MAME }}") assert.NotContains(t, gotbind, "${{ secrets.MAME }}")
assert.NotContains(t, gotmount, "${{ secrets.MAME }}") assert.NotContains(t, gotmount, "${{ secrets.MAME }}")
@@ -562,12 +562,13 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) {
Config: &Config{ Config: &Config{
BindWorkdir: false, BindWorkdir: false,
SharedToolCache: true, // so OverridesToolCache has a mount to displace SharedToolCache: true, // so OverridesToolCache has a mount to displace
ValidVolumes: []string{"**"},
}, },
containerSpec: model.ContainerSpec{Volumes: testcase.volumes}, containerSpec: model.ContainerSpec{Volumes: testcase.volumes},
} }
rc.Run.JobID = "job1" rc.Run.JobID = "job1"
gotbind, gotmount := rc.GetBindsAndMounts() gotbind, gotmount := rc.GetBindsAndMounts(t.Context())
if len(testcase.wantbind) > 0 { if len(testcase.wantbind) > 0 {
assert.Contains(t, gotbind, testcase.wantbind) assert.Contains(t, gotbind, testcase.wantbind)
@@ -602,11 +603,12 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) {
Config: &Config{}, Config: &Config{},
} }
_, gotmount := rc.GetBindsAndMounts() _, gotmount := rc.GetBindsAndMounts(t.Context())
assert.NotContains(t, gotmount, sharedToolCacheVolume) assert.NotContains(t, gotmount, sharedToolCacheVolume)
rc.Config.SharedToolCache = true rc.Config.SharedToolCache = true
_, gotmount = rc.GetBindsAndMounts() rc.containerSpec.Volumes = []string{"/unapproved:" + container.DefaultToolCache}
_, gotmount = rc.GetBindsAndMounts(t.Context())
assert.Equal(t, container.DefaultToolCache, gotmount[sharedToolCacheVolume]) assert.Equal(t, container.DefaultToolCache, gotmount[sharedToolCacheVolume])
}) })
@@ -616,18 +618,18 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) {
} }
rc := &RunContext{ rc := &RunContext{
Run: &model.Run{JobID: "job1", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"job1": {}}}}, Run: &model.Run{JobID: "job1", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"job1": {}}}},
Config: &Config{BindWorkdir: true, Workdir: "/workspace/1/owner/repo", PresetGitHubContext: &model.GithubContext{}}, Config: &Config{BindWorkdir: true, Workdir: "/workspace/1/owner/repo", PresetGitHubContext: &model.GithubContext{}, ValidVolumes: []string{"claimed"}},
} }
gotbind, _ := rc.GetBindsAndMounts() gotbind, _ := rc.GetBindsAndMounts(t.Context())
assert.True(t, slices.ContainsFunc(gotbind, func(bind string) bool { return strings.HasPrefix(bind, "/workspace/1:/workspace/1") }), gotbind) assert.True(t, slices.ContainsFunc(gotbind, func(bind string) bool { return strings.HasPrefix(bind, "/workspace/1:/workspace/1") }), gotbind)
rc.Config.BindWorkdir = false rc.Config.BindWorkdir = false
_, gotmount := rc.GetBindsAndMounts() _, gotmount := rc.GetBindsAndMounts(t.Context())
assert.Equal(t, "/workspace/1", gotmount[rc.jobContainerName()]) assert.Equal(t, "/workspace/1", gotmount[rc.jobContainerName()])
rc.containerSpec.Volumes = []string{"claimed:/workspace/1"} rc.containerSpec.Volumes = []string{"claimed:/workspace/1"}
_, gotmount = rc.GetBindsAndMounts() _, gotmount = rc.GetBindsAndMounts(t.Context())
assert.Equal(t, "/workspace/1/owner/repo", gotmount[rc.jobContainerName()]) assert.Equal(t, "/workspace/1/owner/repo", gotmount[rc.jobContainerName()])
}) })
} }
@@ -1272,12 +1274,13 @@ func TestRunContext_cleanupFailedStart(t *testing.T) {
} }
for _, testcase := range []struct { for _, testcase := range []struct {
name, health string name, health string
pullError error pullError, startError error
}{ }{
{name: "healthy", health: container.HealthHealthy}, {name: "healthy", health: container.HealthHealthy},
{name: "unhealthy", health: container.HealthUnhealthy}, {name: "unhealthy", health: container.HealthUnhealthy},
{name: "pull failure", pullError: errors.New("pull failed")}, {name: "pull failure", pullError: errors.New("pull failed")},
{name: "start failure", startError: errors.New("start failed")},
} { } {
t.Run(testcase.name, func(t *testing.T) { t.Run(testcase.name, func(t *testing.T) {
var operations []string var operations []string
@@ -1291,7 +1294,7 @@ func TestRunContext_cleanupFailedStart(t *testing.T) {
for name, instance := range map[string]*containerMock{"job": job, "service": service} { for name, instance := range map[string]*containerMock{"job": job, "service": service} {
instance.On("Pull", true).Return(record(name+".Pull", map[string]error{"job": testcase.pullError}[name])) instance.On("Pull", true).Return(record(name+".Pull", map[string]error{"job": testcase.pullError}[name]))
instance.On("Create", mock.Anything, mock.Anything).Return(record(name+".Create", nil)) instance.On("Create", mock.Anything, mock.Anything).Return(record(name+".Create", nil))
instance.On("Start", false).Return(record(name+".Start", nil)) instance.On("Start", false).Return(record(name+".Start", map[string]error{"service": testcase.startError}[name]))
instance.On("Remove").Return(record(name+".Remove", nil)) instance.On("Remove").Return(record(name+".Remove", nil))
instance.On("Close").Return(record(name+".Close", nil)) instance.On("Close").Return(record(name+".Close", nil))
} }
@@ -1302,10 +1305,14 @@ func TestRunContext_cleanupFailedStart(t *testing.T) {
service.On("Inspect", mock.Anything).Return(&container.Info{State: "running", Health: health}, nil). service.On("Inspect", mock.Anything).Return(&container.Info{State: "running", Health: health}, nil).
Run(func(mock.Arguments) { operations = append(operations, "service.Inspect") }).Once() Run(func(mock.Arguments) { operations = append(operations, "service.Inspect") }).Once()
} }
service.On("DumpLogs", mock.Anything).Return(nil). stdouts := map[string]io.Writer{}
Run(func(mock.Arguments) { operations = append(operations, "service.DumpLogs") }) service.On("DumpLogs", mock.Anything).Return(nil).Run(func(mock.Arguments) {
operations = append(operations, "service.DumpLogs")
fmt.Fprintln(stdouts["postgres:latest"], "::add-mask::leak")
})
origNewContainer := newContainer origNewContainer := newContainer
newContainer = func(input *container.NewContainerInput) container.ExecutionsEnvironment { newContainer = func(input *container.NewContainerInput) container.ExecutionsEnvironment {
stdouts[input.Image] = input.Stdout
return map[string]*containerMock{"postgres:latest": service, "node:20": job}[input.Image] return map[string]*containerMock{"postgres:latest": service, "node:20": job}[input.Image]
} }
t.Cleanup(func() { newContainer = origNewContainer }) t.Cleanup(func() { newContainer = origNewContainer })
@@ -1316,22 +1323,30 @@ func TestRunContext_cleanupFailedStart(t *testing.T) {
Run: &model.Run{JobID: "job", Workflow: workflow}, Run: &model.Run{JobID: "job", Workflow: workflow},
platformImage: "node:20", platformImage: "node:20",
} }
ctx := common.WithDryrun(t.Context(), true) logger, hook := logrustest.NewNullLogger()
ctx := common.WithLogger(common.WithDryrun(t.Context(), true), logger)
rc.ExprEval = rc.NewExpressionEvaluator(ctx) rc.ExprEval = rc.NewExpressionEvaluator(ctx)
err = rc.startContainer().Then(rc.stopContainer()).Finally(rc.closeContainer())(ctx) err = rc.startContainer().Then(rc.stopContainer()).Finally(rc.closeContainer())(ctx)
want := "job.Remove service.Remove service.Close service.Pull job.Pull" want := "job.Remove service.Remove service.Close service.Pull job.Pull"
if testcase.pullError != nil { switch {
case testcase.pullError != nil:
require.ErrorIs(t, err, testcase.pullError) require.ErrorIs(t, err, testcase.pullError)
} else { case testcase.startError != nil:
want += " service.Create service.Start service.Inspect job.Create job.Start job.Inspect job.Copy service.Inspect" require.ErrorIs(t, err, testcase.startError)
want += " service.Create service.Start service.DumpLogs"
default:
want += " service.Create service.Start service.Inspect job.Create job.Start job.Inspect job.Copy service.Inspect service.DumpLogs"
if testcase.health == container.HealthUnhealthy { if testcase.health == container.HealthUnhealthy {
require.ErrorContains(t, err, "the service 'postgres' is unhealthy") require.ErrorContains(t, err, "the service 'postgres' is unhealthy")
want += " service.DumpLogs"
} else { } else {
require.NoError(t, err) require.NoError(t, err)
} }
} }
assert.Equal(t, strings.Fields(want+" job.Remove service.Remove service.Close job.Close"), operations) assert.Equal(t, strings.Fields(want+" job.Remove service.Remove service.Close job.Close"), operations)
assert.Empty(t, rc.Masks)
assert.Equal(t, testcase.pullError == nil && testcase.health != container.HealthUnhealthy, slices.ContainsFunc(hook.AllEntries(), func(entry *log.Entry) bool {
return strings.HasPrefix(entry.Message, "::group::Print service container logs")
}))
}) })
} }
@@ -1587,12 +1602,39 @@ func TestImageOSFromImage(t *testing.T) {
} }
} }
func TestResolvePlatformImageAppliesImagelessContainerToRunsOnImage(t *testing.T) {
for _, tc := range []struct {
name, image string
want model.ContainerSpec
}{
{"without image key", "", model.ContainerSpec{Volumes: []string{"/host/data:/data"}, Env: map[string]string{"KEY": "value"}}},
{"with empty image", "image: '', ", model.ContainerSpec{}},
} {
t.Run(tc.name, func(t *testing.T) {
rc := createIfTestRunContext(map[string]*model.Job{
"job1": createJob(t, "runs-on: toolkit\ncontainer: {"+tc.image+"volumes: [/host/data:/data], env: {KEY: value}}", ""),
})
require.NoError(t, rc.resolvePlatformImage(t.Context()))
require.NoError(t, evaluateJobEnvAndDefaults(t.Context(), rc))
assert.Equal(t, "ubuntu-latest", rc.platformImage)
assert.Equal(t, tc.want, rc.containerSpec)
})
}
}
func createRunsOnRunContext(t *testing.T, runsOn string) *RunContext { func createRunsOnRunContext(t *testing.T, runsOn string) *RunContext {
return createIfTestRunContext(map[string]*model.Job{ return createIfTestRunContext(map[string]*model.Job{
"job1": createJob(t, "runs-on: "+runsOn, ""), "job1": createJob(t, "runs-on: "+runsOn, ""),
}) })
} }
func TestRunContextContainerImageCannotSelectHost(t *testing.T) {
rc := createIfTestRunContext(map[string]*model.Job{"job1": createJob(t, "container: -self-hosted", "")})
require.NoError(t, rc.resolvePlatformImage(t.Context()))
assert.False(t, rc.IsHostEnv())
}
func TestRunContextImageOS(t *testing.T) { func TestRunContextImageOS(t *testing.T) {
ctx := context.Background() ctx := context.Background()
+1
View File
@@ -62,6 +62,7 @@ type Config struct {
NoActionPatch bool // run actions exactly as published, applying no compatibility patches, see patch_actions.go NoActionPatch bool // run actions exactly as published, applying no compatibility patches, see patch_actions.go
PresetGitHubContext *model.GithubContext // overrides actor, ref, repository, token and related context fields PresetGitHubContext *model.GithubContext // overrides actor, ref, repository, token and related context fields
WorkflowCallInputs map[string]any // the `inputs` Gitea resolved for a called workflow's job, nil for other jobs
EventJSON string // the content of JSON file to use for event.json in containers, overrides EventPath EventJSON string // the content of JSON file to use for event.json in containers, overrides EventPath
ContainerNamePrefix string // the prefix of container name ContainerNamePrefix string // the prefix of container name
ContainerMaxLifetime time.Duration // the max lifetime of job containers ContainerMaxLifetime time.Duration // the max lifetime of job containers
+1
View File
@@ -69,6 +69,7 @@ func testPayloads(t *testing.T) {
"cell-a-2", "cell-a-2",
"cell-b-1", "cell-b-1",
"cell-b-2", "cell-b-2",
"imageless env=imageless-env-value host=imageless-host",
} { } {
if !strings.Contains(logs, want) { if !strings.Contains(logs, want) {
t.Errorf("stored logs are missing %q", want) t.Errorf("stored logs are missing %q", want)
+5
View File
@@ -3,6 +3,10 @@ on: push
jobs: jobs:
verify: verify:
runs-on: ubuntu-latest runs-on: ubuntu-latest
container:
env:
IMAGELESS_ENV: imageless-env-value
options: --hostname imageless-host
services: services:
web: web:
image: ${{ vars.E2E_SERVICE_IMAGE }} image: ${{ vars.E2E_SERVICE_IMAGE }}
@@ -19,6 +23,7 @@ jobs:
echo '::notice::encoded-first%0Aencoded-second' echo '::notice::encoded-first%0Aencoded-second'
echo "the variable is ${{ vars.GREETING }}" echo "the variable is ${{ vars.GREETING }}"
echo "the secret is ${{ secrets.FOO }}" echo "the secret is ${{ secrets.FOO }}"
echo "imageless env=$IMAGELESS_ENV host=$(hostname)"
curl --connect-timeout 1 --max-time 2 --retry 30 --retry-delay 1 --retry-max-time 30 --retry-all-errors -fsS -o /dev/null http://web/ curl --connect-timeout 1 --max-time 2 --retry 30 --retry-delay 1 --retry-max-time 30 --retry-all-errors -fsS -o /dev/null http://web/
produce: produce:
runs-on: ubuntu-latest runs-on: ubuntu-latest
+1 -1
View File
@@ -9,7 +9,7 @@ require (
dario.cat/mergo v1.0.2 dario.cat/mergo v1.0.2
gitea.dev/actionslib v1.2.1 gitea.dev/actionslib v1.2.1
github.com/avast/retry-go/v5 v5.0.0 github.com/avast/retry-go/v5 v5.0.0
github.com/bmatcuk/doublestar/v4 v4.10.0 github.com/bmatcuk/doublestar/v4 v4.10.2
github.com/containerd/errdefs v1.0.0 github.com/containerd/errdefs v1.0.0
github.com/creack/pty v1.1.24 github.com/creack/pty v1.1.24
github.com/distribution/reference v0.6.0 github.com/distribution/reference v0.6.0
+2 -2
View File
@@ -47,8 +47,8 @@ github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs= github.com/bmatcuk/doublestar/v4 v4.10.2 h1:eF7W7HWKg3z9NrWV9pTLnNeoXaqq3Tq9DNKXVMfoCnw=
github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/bmatcuk/doublestar/v4 v4.10.2/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cevatbarisyilmaz/ara v0.0.4 h1:SGH10hXpBJhhTlObuZzTuFn1rrdmjQImITXnZVPSodc= github.com/cevatbarisyilmaz/ara v0.0.4 h1:SGH10hXpBJhhTlObuZzTuFn1rrdmjQImITXnZVPSodc=
+13
View File
@@ -27,6 +27,19 @@ func loadConfigCmd(configFile *string) *cobra.Command {
configCmd.AddCommand(loadGenerateConfigCmd("generate")) configCmd.AddCommand(loadGenerateConfigCmd("generate"))
configCmd.AddCommand(loadInitConfigCmd(configFile)) configCmd.AddCommand(loadInitConfigCmd(configFile))
configCmd.AddCommand(&cobra.Command{
Use: "runner-file",
Short: "Print the registration file path used by the runner",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
cfg, err := config.LoadDefault(*configFile)
if err != nil {
return err
}
fmt.Fprintln(cmd.OutOrStdout(), cfg.Runner.File)
return nil
},
})
configCmd.AddCommand(&cobra.Command{ configCmd.AddCommand(&cobra.Command{
Use: "get <key>", Use: "get <key>",
+11
View File
@@ -97,3 +97,14 @@ func TestConfigCmdResolvesTheConfigFile(t *testing.T) {
assert.Contains(t, err.Error(), "--config") assert.Contains(t, err.Error(), "--config")
}) })
} }
func TestConfigCmdRunnerFileUsesConfiguredPathOrDefault(t *testing.T) {
out, _, err := runConfigCmd(t, "", "runner-file")
require.NoError(t, err)
assert.Equal(t, ".runner\n", out)
file := filepath.Join(t.TempDir(), "config.yaml")
require.NoError(t, os.WriteFile(file, []byte("runner:\n file: 'state dir/registration.json'\n"), 0o600))
out, _, err = runConfigCmd(t, file, "runner-file")
require.NoError(t, err)
assert.Equal(t, "state dir/registration.json\n", out)
}
+21
View File
@@ -350,6 +350,25 @@ func (r *Runner) cloneEnvs() map[string]string {
return envs return envs
} }
// restoreCalledWorkflowContext undoes Gitea's rewrite of `event_name` and `event.inputs` for older runners,
// and returns the job's `inputs` from `gitea_workflow_call`, nil if it is not a called workflow's job.
// See https://github.com/go-gitea/gitea/pull/39452
func restoreCalledWorkflowContext(task *runnerv1.Task) map[string]any {
workflowCall := task.GetContext().GetFields()["gitea_workflow_call"].GetStructValue().GetFields()
if workflowCall == nil {
return nil
}
task.Context.Fields["event_name"] = workflowCall["original_event_name"]
if event := task.Context.Fields["event"].GetStructValue(); event != nil {
if inputs, ok := workflowCall["original_event_inputs"]; ok {
event.Fields["inputs"] = inputs
} else {
delete(event.Fields, "inputs")
}
}
return workflowCall["inputs"].GetStructValue().AsMap()
}
// getDefaultActionsURL // getDefaultActionsURL
// when DEFAULT_ACTIONS_URL == "https://github.com" and GithubMirror is not blank, // when DEFAULT_ACTIONS_URL == "https://github.com" and GithubMirror is not blank,
// it should be set to GithubMirror first. // it should be set to GithubMirror first.
@@ -398,6 +417,7 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
} }
}() }()
workflowCallInputs := restoreCalledWorkflowContext(task)
r.reportSetup(reporter, task) r.reportSetup(reporter, task)
workflow, jobID, err := generateWorkflow(task) workflow, jobID, err := generateWorkflow(task)
@@ -536,6 +556,7 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
NoSkipCheckout: true, NoSkipCheckout: true,
DisableActEnv: r.cfg.Runner.SetActEnv != nil && !*r.cfg.Runner.SetActEnv, DisableActEnv: r.cfg.Runner.SetActEnv != nil && !*r.cfg.Runner.SetActEnv,
PresetGitHubContext: preset, PresetGitHubContext: preset,
WorkflowCallInputs: workflowCallInputs,
EventJSON: string(eventJSON), EventJSON: string(eventJSON),
ContainerNamePrefix: fmt.Sprintf("GITEA-ACTIONS-TASK-%d", task.Id), ContainerNamePrefix: fmt.Sprintf("GITEA-ACTIONS-TASK-%d", task.Id),
ContainerMaxLifetime: maxLifetime, ContainerMaxLifetime: maxLifetime,
+45
View File
@@ -358,3 +358,48 @@ func TestNewRunnerCacheServiceV2(t *testing.T) {
} }
} }
} }
func TestRestoreCalledWorkflowContext(t *testing.T) {
newTask := func(workflowCall map[string]any) *runnerv1.Task {
values := map[string]any{
"event_name": "workflow_call",
"event": map[string]any{"ref": "refs/heads/main", "inputs": map[string]any{"env": "leaf"}},
}
if workflowCall != nil {
values["gitea_workflow_call"] = workflowCall
}
taskCtx, err := structpb.NewStruct(values)
require.NoError(t, err)
return &runnerv1.Task{Context: taskCtx}
}
t.Run("dispatched run", func(t *testing.T) {
task := newTask(map[string]any{
"original_event_name": "workflow_dispatch",
"original_event_inputs": map[string]any{"target": "prod"},
"inputs": map[string]any{"target": "prod", "env": "leaf"},
})
assert.Equal(t, map[string]any{"target": "prod", "env": "leaf"}, restoreCalledWorkflowContext(task))
taskCtx := task.Context.AsMap()
assert.Equal(t, "workflow_dispatch", taskCtx["event_name"])
assert.Equal(t, map[string]any{"ref": "refs/heads/main", "inputs": map[string]any{"target": "prod"}}, taskCtx["event"])
assert.Equal(t, []string{"::group::Inputs", "env: leaf", "target: prod", "::endgroup::"}, inputLines(task.Context.Fields))
})
t.Run("event without inputs", func(t *testing.T) {
task := newTask(map[string]any{
"original_event_name": "push",
"inputs": map[string]any{"env": "leaf"},
})
assert.Equal(t, map[string]any{"env": "leaf"}, restoreCalledWorkflowContext(task))
taskCtx := task.Context.AsMap()
assert.Equal(t, "push", taskCtx["event_name"])
assert.Equal(t, map[string]any{"ref": "refs/heads/main"}, taskCtx["event"])
})
t.Run("not a called workflow's job", func(t *testing.T) {
task := newTask(nil)
assert.Nil(t, restoreCalledWorkflowContext(task))
assert.Equal(t, "workflow_call", task.Context.AsMap()["event_name"])
})
}
+7 -2
View File
@@ -56,10 +56,15 @@ func (r *Runner) setupLines(task *runnerv1.Task) []string {
return append(lines, "::endgroup::") return append(lines, "::endgroup::")
} }
// inputLines lists the inputs the run was triggered with, as workflow_dispatch and workflow_call // inputLines lists the inputs of the job: those Gitea resolved for a called workflow's job,
// carry them in the event payload. Empty when the event has none. // or the ones a workflow_dispatch event carries in its payload. Empty when there are none.
func inputLines(fields map[string]*structpb.Value) []string { func inputLines(fields map[string]*structpb.Value) []string {
inputs := fields["event"].GetStructValue().GetFields()["inputs"].GetStructValue().GetFields() inputs := fields["event"].GetStructValue().GetFields()["inputs"].GetStructValue().GetFields()
// For a called workflow's job, restore its inputs from `gitea_workflow_call`.
// See https://github.com/go-gitea/gitea/pull/39452.
if workflowCall := fields["gitea_workflow_call"].GetStructValue(); workflowCall != nil {
inputs = workflowCall.GetFields()["inputs"].GetStructValue().GetFields()
}
if len(inputs) == 0 { if len(inputs) == 0 {
return nil return nil
} }
+1 -2
View File
@@ -6,12 +6,11 @@ fi
cd /data cd /data
RUNNER_STATE_FILE=${RUNNER_STATE_FILE:-'.runner'}
CONFIG_ARG="" CONFIG_ARG=""
if [[ ! -z "${CONFIG_FILE}" ]]; then if [[ ! -z "${CONFIG_FILE}" ]]; then
CONFIG_ARG="--config ${CONFIG_FILE}" CONFIG_ARG="--config ${CONFIG_FILE}"
fi fi
RUNNER_STATE_FILE=${RUNNER_STATE_FILE:-$(gitea-runner ${CONFIG_ARG} config runner-file)}
LABEL_ARGS="" LABEL_ARGS=""
if [[ ! -z "${GITEA_RUNNER_LABELS}" ]]; then if [[ ! -z "${GITEA_RUNNER_LABELS}" ]]; then
LABEL_ARGS="--labels ${GITEA_RUNNER_LABELS}" LABEL_ARGS="--labels ${GITEA_RUNNER_LABELS}"