mirror of
https://gitea.com/gitea/act_runner.git
synced 2026-10-30 03:46:33 +08:00
Contains a number of small bug fixes where jobs behaved differently from GitHub or failed silently.
1. Hooks fail on a refused `::set-env::` or `::add-path::` and no longer inherit the last step's identity
1. Docker build, exec and wait errors are returned instead of swallowed, token-only registry credentials work, logs of `--tty` services decode
1. Env files split lines like GitHub on each OS, so a carriage return is kept on Linux and CRLF ends a line on Windows, empty heredoc names or delimiters are rejected
1. Post steps run after their pre step even when main was skipped, composites see undeclared inputs, `runs.args` wins without `with.args` being parsed
1. Docker actions skip evaluating defaults of inputs given in `with` and read `entryPoint` and `args` in any case, a lowercase `dockerfile` is found, undeclared inputs get GitHub's warning
1. A user-set `CI` is kept, `GITHUB_REPOSITORY_ID` and the other missing `GITHUB_*` variables are exported
1. `timeout-minutes` can read the step's own `env`, a step-level `ACTIONS_ALLOW_UNSECURE_COMMANDS: false` beats a job-level `true` while the runner's own opt-in still wins, `pwsh` quotes the script path
1. A step `env:` given as one expression such as `${{ fromJSON(matrix.env) }}` is applied, `hashFiles` fails with GitHub's messages on script errors and after 120 seconds, a job image without node still gets an empty hash
1. A step whose command files cannot be reset fails instead of importing stale ones, a timeout between steps fails the job instead of reporting success
1. One named volume can be mounted at several paths, host-mode PTY steps keep their last output when they fail, host scratch directories are removed when job setup fails
1. Cancelling a step right after it starts kills its whole process tree
1. Cache prefix restores pick the newest entry created within the same second, also on S3, cache commits with a wrong size are rejected
1. Artifact chunks are written at their offset, download URLs are escaped, listing a run without artifacts no longer crashes
1. Poll backoff reaches `fetch_interval_max`, proxy passwords are masked in more forms, secrets are also masked in step names and in their quoted form in the runner log, a failed final report is logged
1. On Windows hosts a differently cased unsecure-commands opt-in or proxy variable counts, and an early cancel no longer leaks a job object handle
1. Interactive `register` prompts for a missing token and stops on SIGTERM, `exec --list --detect-event` no longer panics without events, `config set` works on `cache.s3` keys
Reviewed-on: https://gitea.com/gitea/runner/pulls/1338
Reviewed-by: bircni <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
309 lines
9.5 KiB
Go
309 lines
9.5 KiB
Go
// Copyright 2026 The Gitea Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package config
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
const editFixture = `# A leading comment.
|
|
log:
|
|
# The logging level.
|
|
level: info
|
|
|
|
runner:
|
|
capacity: 1
|
|
envs:
|
|
EXISTING: value
|
|
timeout: 3h
|
|
labels:
|
|
- ubuntu-latest:docker://node:20
|
|
- self-hosted
|
|
`
|
|
|
|
func writeEditFixture(t *testing.T) string {
|
|
t.Helper()
|
|
path := filepath.Join(t.TempDir(), "config.yaml")
|
|
require.NoError(t, os.WriteFile(path, []byte(editFixture), 0o600))
|
|
return path
|
|
}
|
|
|
|
func TestEditValues(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
edit func(file string) error
|
|
assert func(t *testing.T, cfg *Config, content string)
|
|
}{
|
|
{
|
|
name: "set scalar",
|
|
edit: func(file string) error { return SetValue(file, "runner.capacity", "4") },
|
|
assert: func(t *testing.T, cfg *Config, _ string) {
|
|
assert.Equal(t, 4, cfg.Runner.Capacity)
|
|
},
|
|
},
|
|
{
|
|
name: "set duration",
|
|
edit: func(file string) error { return SetValue(file, "runner.timeout", "90m") },
|
|
assert: func(t *testing.T, cfg *Config, content string) {
|
|
assert.Equal(t, 90*time.Minute, cfg.Runner.Timeout)
|
|
assert.Contains(t, content, "timeout: 1h30m0s")
|
|
},
|
|
},
|
|
{
|
|
name: "set a pointer field in a missing section",
|
|
edit: func(file string) error {
|
|
return SetValue(file, "container.network_create_options.enable_ipv4", "false")
|
|
},
|
|
assert: func(t *testing.T, cfg *Config, _ string) {
|
|
require.NotNil(t, cfg.Container.NetworkCreateOptions.EnableIPv4)
|
|
assert.False(t, *cfg.Container.NetworkCreateOptions.EnableIPv4)
|
|
},
|
|
},
|
|
{
|
|
name: "set map entry",
|
|
edit: func(file string) error { return SetValue(file, "runner.envs.ADDED", "yes") },
|
|
assert: func(t *testing.T, cfg *Config, _ string) {
|
|
assert.Equal(t, map[string]string{"EXISTING": "value", "ADDED": "yes"}, cfg.Runner.Envs)
|
|
},
|
|
},
|
|
{
|
|
name: "set map entry starting with a newline before an indented line",
|
|
edit: func(file string) error { return SetValue(file, "runner.envs.ADDED", "\n indented\nplain\n") },
|
|
assert: func(t *testing.T, cfg *Config, _ string) {
|
|
assert.Equal(t, "\n indented\nplain\n", cfg.Runner.Envs["ADDED"])
|
|
},
|
|
},
|
|
{
|
|
name: "set replaces a list",
|
|
edit: func(file string) error { return SetValue(file, "runner.labels", "one", "two") },
|
|
assert: func(t *testing.T, cfg *Config, _ string) {
|
|
assert.Equal(t, []string{"one", "two"}, cfg.Runner.Labels)
|
|
},
|
|
},
|
|
{
|
|
name: "add appends to a list",
|
|
edit: func(file string) error { return AddValue(file, "runner.labels", "ubuntu:docker://node:22") },
|
|
assert: func(t *testing.T, cfg *Config, _ string) {
|
|
assert.Equal(t, []string{"ubuntu-latest:docker://node:20", "self-hosted", "ubuntu:docker://node:22"}, cfg.Runner.Labels)
|
|
},
|
|
},
|
|
{
|
|
name: "remove drops a list entry",
|
|
edit: func(file string) error { return RemoveValue(file, "runner.labels", "self-hosted") },
|
|
assert: func(t *testing.T, cfg *Config, _ string) {
|
|
assert.Equal(t, []string{"ubuntu-latest:docker://node:20"}, cfg.Runner.Labels)
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
file := writeEditFixture(t)
|
|
require.NoError(t, tt.edit(file))
|
|
|
|
raw, err := os.ReadFile(file)
|
|
require.NoError(t, err)
|
|
content := string(raw)
|
|
cfg, err := LoadDefault(file)
|
|
require.NoError(t, err)
|
|
|
|
tt.assert(t, cfg, content)
|
|
|
|
assert.Contains(t, content, "# A leading comment.")
|
|
assert.Contains(t, content, " # The logging level.")
|
|
assert.Contains(t, content, "\n\nrunner:")
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestEditValuesRejectsBadInput(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
edit func(file string) error
|
|
wantErr string
|
|
}{
|
|
{
|
|
name: "unknown key",
|
|
edit: func(file string) error { return SetValue(file, "runner.labl", "x") },
|
|
wantErr: `unknown config key "runner.labl"`,
|
|
},
|
|
{
|
|
name: "value is not a number",
|
|
edit: func(file string) error { return SetValue(file, "runner.capacity", "many") },
|
|
wantErr: `"many" is not a valid int`,
|
|
},
|
|
{
|
|
name: "value is not a duration",
|
|
edit: func(file string) error { return SetValue(file, "runner.timeout", "soon") },
|
|
wantErr: `"soon" is not a duration`,
|
|
},
|
|
{
|
|
name: "value is not a boolean",
|
|
edit: func(file string) error { return SetValue(file, "runner.insecure", "maybe") },
|
|
wantErr: `"maybe" is not a boolean`,
|
|
},
|
|
{
|
|
name: "set needs a single value",
|
|
edit: func(file string) error { return SetValue(file, "runner.capacity", "1", "2") },
|
|
wantErr: "takes exactly one value",
|
|
},
|
|
{
|
|
name: "set on a section",
|
|
edit: func(file string) error { return SetValue(file, "runner", "x") },
|
|
wantErr: "is a section",
|
|
},
|
|
{
|
|
name: "add on a scalar",
|
|
edit: func(file string) error { return AddValue(file, "runner.capacity", "4") },
|
|
wantErr: "is not a list",
|
|
},
|
|
{
|
|
name: "add a duplicate",
|
|
edit: func(file string) error { return AddValue(file, "runner.labels", "self-hosted") },
|
|
wantErr: `already contains "self-hosted"`,
|
|
},
|
|
{
|
|
name: "remove a missing entry",
|
|
edit: func(file string) error { return RemoveValue(file, "runner.labels", "absent") },
|
|
wantErr: `does not contain "absent"`,
|
|
},
|
|
{
|
|
name: "sub-key of a free-form map entry",
|
|
edit: func(file string) error { return SetValue(file, "runner.envs.A.B", "x") },
|
|
wantErr: "has no sub-keys",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
file := writeEditFixture(t)
|
|
err := tt.edit(file)
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), tt.wantErr)
|
|
|
|
content, err := os.ReadFile(file)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, editFixture, string(content), "a rejected edit must leave the file untouched")
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGetValue(t *testing.T) {
|
|
file := writeEditFixture(t)
|
|
|
|
value, err := GetValue(file, "runner.capacity")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "1", value)
|
|
|
|
value, err = GetValue(file, "runner.labels")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "ubuntu-latest:docker://node:20\nself-hosted", value)
|
|
|
|
value, err = GetValue(file, "runner.envs")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "EXISTING=value", value)
|
|
|
|
// A section has no single-line rendering.
|
|
value, err = GetValue(file, "runner")
|
|
require.NoError(t, err)
|
|
assert.Contains(t, value, "labels:\n - ubuntu-latest:docker://node:20")
|
|
|
|
_, err = GetValue(file, "metrics.addr")
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "is not set")
|
|
|
|
require.NoError(t, SetValue(file, "cache.s3.endpoint", "https://cache.example"))
|
|
value, err = GetValue(file, "cache.s3.endpoint")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "https://cache.example", value)
|
|
}
|
|
|
|
func TestEditValuesFileHandling(t *testing.T) {
|
|
t.Run("reports a missing file", func(t *testing.T) {
|
|
err := SetValue(filepath.Join(t.TempDir(), "absent.yaml"), "runner.capacity", "4")
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "does not exist")
|
|
})
|
|
|
|
t.Run("writes through a symlink", func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
target := filepath.Join(dir, "real.yaml")
|
|
link := filepath.Join(dir, "config.yaml")
|
|
require.NoError(t, os.WriteFile(target, []byte(editFixture), 0o600))
|
|
require.NoError(t, os.Symlink(target, link))
|
|
|
|
require.NoError(t, SetValue(link, "runner.capacity", "4"))
|
|
|
|
info, err := os.Lstat(link)
|
|
require.NoError(t, err)
|
|
assert.NotZero(t, info.Mode()&os.ModeSymlink, "the symlink must not be replaced by a regular file")
|
|
|
|
content, err := os.ReadFile(target)
|
|
require.NoError(t, err)
|
|
assert.Contains(t, string(content), "capacity: 4")
|
|
})
|
|
|
|
t.Run("keeps CRLF line endings", func(t *testing.T) {
|
|
file := filepath.Join(t.TempDir(), "config.yaml")
|
|
require.NoError(t, os.WriteFile(file, []byte(strings.ReplaceAll(editFixture, "\n", "\r\n")), 0o600))
|
|
|
|
require.NoError(t, SetValue(file, "runner.capacity", "4"))
|
|
|
|
content, err := os.ReadFile(file)
|
|
require.NoError(t, err)
|
|
assert.Contains(t, string(content), "capacity: 4\r\n")
|
|
assert.NotContains(t, strings.ReplaceAll(string(content), "\r\n", ""), "\n")
|
|
})
|
|
}
|
|
|
|
// An edit has to give the file back unchanged around it, down to the indentation of
|
|
// a commented-out option, as that documentation is what the user reads and edits.
|
|
func TestEditValuesPreservesFileText(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
content []byte
|
|
edit func(file string) error
|
|
added string // the only text the edit may add
|
|
}{
|
|
{
|
|
name: "example config",
|
|
content: Example,
|
|
edit: func(file string) error { return AddValue(file, "runner.labels", "ubuntu:docker://node:22") },
|
|
added: " labels:\n - ubuntu:docker://node:22\n",
|
|
},
|
|
{
|
|
name: "minimal config",
|
|
content: []byte(Minimal),
|
|
edit: func(file string) error { return SetValue(file, "runner.capacity", "4") },
|
|
added: "runner:\n capacity: 4\n",
|
|
},
|
|
{
|
|
name: "folded scalar with a long more-indented line",
|
|
content: []byte("runner:\n envs:\n FOLDED: >\n begin\n a more-indented line that is long enough for an encoder wrapping at 80 columns to break\n end\n"),
|
|
edit: func(file string) error { return SetValue(file, "runner.capacity", "4") },
|
|
added: " capacity: 4\n",
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
file := filepath.Join(t.TempDir(), "config.yaml")
|
|
require.NoError(t, os.WriteFile(file, tc.content, 0o600))
|
|
|
|
require.NoError(t, tc.edit(file))
|
|
|
|
content, err := os.ReadFile(file)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, string(tc.content), strings.Replace(string(content), tc.added, "", 1))
|
|
})
|
|
}
|
|
}
|