mirror of
https://gitea.com/gitea/act_runner.git
synced 2026-08-08 19:55:03 +02:00
Compare commits
6
Commits
68547886a5
...
v3.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
24c13a1fd0 | ||
|
|
4c2ab943a8 | ||
|
|
20497aaf4f | ||
|
|
09b643bc14 | ||
|
|
1d6c6ffef9 | ||
|
|
70387cca44 |
@@ -35,6 +35,7 @@ jobs:
|
|||||||
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
|
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
|
||||||
with:
|
with:
|
||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
|
check-latest: true
|
||||||
- name: goreleaser
|
- name: goreleaser
|
||||||
uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7
|
uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ jobs:
|
|||||||
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
|
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
|
||||||
with:
|
with:
|
||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
|
check-latest: true
|
||||||
- name: Import GPG key
|
- name: Import GPG key
|
||||||
id: import_gpg
|
id: import_gpg
|
||||||
uses: crazy-max/ghaction-import-gpg@2dc316deee8e90f13e1a351ab510b4d5bc0c82cd # v7
|
uses: crazy-max/ghaction-import-gpg@2dc316deee8e90f13e1a351ab510b4d5bc0c82cd # v7
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ jobs:
|
|||||||
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
|
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
|
||||||
with:
|
with:
|
||||||
go-version-file: 'go.mod'
|
go-version-file: 'go.mod'
|
||||||
|
check-latest: true
|
||||||
- name: prepare anonymous docker config
|
- name: prepare anonymous docker config
|
||||||
run: mkdir -p "$DOCKER_CONFIG" && echo '{}' > "$DOCKER_CONFIG/config.json"
|
run: mkdir -p "$DOCKER_CONFIG" && echo '{}' > "$DOCKER_CONFIG/config.json"
|
||||||
# Pre-pull act/runner's two largest base images so a slow pull can't dominate `make test`;
|
# Pre-pull act/runner's two largest base images so a slow pull can't dominate `make test`;
|
||||||
|
|||||||
@@ -141,7 +141,12 @@ security-check:
|
|||||||
|
|
||||||
.PHONY: tidy
|
.PHONY: tidy
|
||||||
tidy: ## run go mod tidy
|
tidy: ## run go mod tidy
|
||||||
|
$(eval GO_TOOLCHAIN := $(shell grep -Eo '^toolchain\s+go[0-9.]+' go.mod | cut -d' ' -f2))
|
||||||
$(GO) mod tidy
|
$(GO) mod tidy
|
||||||
|
@# workaround https://github.com/golang/go/issues/75331: restore toolchain if tidy dropped it
|
||||||
|
@if [ -n "$(GO_TOOLCHAIN)" ] && ! grep -qE '^toolchain\s' go.mod; then \
|
||||||
|
$(GO) mod edit -toolchain=$(GO_TOOLCHAIN); \
|
||||||
|
fi
|
||||||
|
|
||||||
.PHONY: tidy-check
|
.PHONY: tidy-check
|
||||||
tidy-check: tidy
|
tidy-check: tidy
|
||||||
|
|||||||
@@ -129,48 +129,38 @@ Same idea as `dind`, but built on `docker:dind-rootless` so the bundled daemon a
|
|||||||
|
|
||||||
### Configuration
|
### Configuration
|
||||||
|
|
||||||
The runner is configured with a YAML file. Generate a starting point (this matches what ships in the tree):
|
The runner reads a YAML file. Without one, every option keeps its default.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./gitea-runner config generate > config.yaml
|
./gitea-runner config init # write config.yaml, with no option set
|
||||||
|
./gitea-runner config generate | less # read what the options do
|
||||||
|
./gitea-runner -c config.yaml daemon # -c also works on register and cache-server
|
||||||
```
|
```
|
||||||
|
|
||||||
> The top-level `generate-config` command still does the same thing, but is deprecated in favour of `config generate`.
|
`config generate` prints [config.example.yaml](internal/pkg/config/config.example.yaml). Every value in it is commented out, so copy the lines you want to change into your own file and uncomment them.
|
||||||
|
|
||||||
Pass it with `-c` / `--config` on any command that loads configuration (`register`, `daemon`, `cache-server`):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./gitea-runner -c config.yaml register
|
|
||||||
./gitea-runner -c config.yaml daemon
|
|
||||||
./gitea-runner -c config.yaml cache-server
|
|
||||||
```
|
|
||||||
|
|
||||||
Every option is described in [config.example.yaml](internal/pkg/config/config.example.yaml) (the same content `config generate` prints).
|
|
||||||
|
|
||||||
#### Editing a config file
|
#### Editing a config file
|
||||||
|
|
||||||
`config` changes an existing file in place, keeping its comments and key order, which is handy in provisioning scripts:
|
`config` edits a file in place, which is handy in provisioning scripts:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./gitea-runner -c config.yaml config set runner.capacity 4
|
./gitea-runner config set runner.capacity 4
|
||||||
./gitea-runner -c config.yaml config set runner.timeout 90m # written as 1h30m0s
|
./gitea-runner config set runner.timeout 90m # written as 1h30m0s
|
||||||
./gitea-runner -c config.yaml config set runner.envs.MY_VAR value
|
./gitea-runner config set runner.envs.MY_VAR value
|
||||||
./gitea-runner -c config.yaml config add runner.labels 'ubuntu:docker://node:22'
|
./gitea-runner config add runner.labels 'ubuntu:docker://node:22'
|
||||||
./gitea-runner -c config.yaml config remove runner.labels 'ubuntu:docker://node:22'
|
./gitea-runner config remove runner.labels 'ubuntu:docker://node:22'
|
||||||
./gitea-runner -c config.yaml config get runner.labels
|
./gitea-runner config get runner.labels
|
||||||
```
|
```
|
||||||
|
|
||||||
`-c` is optional for these subcommands: without it they use `config.yaml` (or `config.yml`) from the working directory, falling back to the directory of the `gitea-runner` binary, and print which file they picked to stderr.
|
A key is its dotted YAML path. An unknown key, a value of the wrong type, or `add`/`remove` on anything but a list is refused before the file is touched. `set` replaces a whole list when you give it several values.
|
||||||
|
|
||||||
Keys are the dotted YAML path and are validated against the known options, so a typo is rejected instead of being written. `add` and `remove` only work on list options such as `runner.labels` and `container.valid_volumes`, and fail if the value is already present or missing. `set` replaces the whole list when given several values.
|
An edit keeps the comments and the key order of the file. Indentation becomes two spaces, and a blank line between two values is dropped.
|
||||||
|
|
||||||
The file is re-encoded on every edit, so indentation is normalised to two spaces and blank lines inside a section are dropped.
|
`config get`, `set`, `add` and `remove` use `config.yaml` (or `config.yml`) from the working directory, then from the directory of the binary, and print their choice to stderr. `config init` writes `config.yaml` in the working directory, and refuses to overwrite an existing config without `--force`. Pass `-c` for another path.
|
||||||
|
|
||||||
#### Without a config file
|
#### Environment variables
|
||||||
|
|
||||||
If you omit `-c`, built-in defaults apply (same as an empty YAML document).
|
Earlier releases let a few environment variables (`GITEA_DEBUG`, `GITEA_TRACE`, `GITEA_RUNNER_CAPACITY`, `GITEA_RUNNER_FILE`, `GITEA_RUNNER_ENVIRON`, `GITEA_RUNNER_ENV_FILE`) override parts of the config. They are gone, use the YAML file for all settings. The Docker images still read their own variables, such as `RUNNER_STATE_FILE`, see [scripts/run.sh](scripts/run.sh) and the container documentation below.
|
||||||
|
|
||||||
Earlier releases let a small set of environment variables (`GITEA_DEBUG`, `GITEA_TRACE`, `GITEA_RUNNER_CAPACITY`, `GITEA_RUNNER_FILE`, `GITEA_RUNNER_ENVIRON`, `GITEA_RUNNER_ENV_FILE`) override parts of the default config. Those overrides have been removed — use a YAML config file for all settings instead. For the Docker images, the entrypoint still understands a separate set of variables (such as `RUNNER_STATE_FILE`); see [scripts/run.sh](scripts/run.sh) and the container documentation below.
|
|
||||||
|
|
||||||
### Labels
|
### Labels
|
||||||
|
|
||||||
|
|||||||
@@ -85,45 +85,53 @@ func NewInterpeter(env *EvaluationEnvironment, config Config) Interpreter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Evaluate evaluates one expression. An empty input asks defaultStatusCheck on its own, which is
|
||||||
|
// what a value that carries no expression of its own runs under.
|
||||||
func (impl *interperterImpl) Evaluate(input string, defaultStatusCheck DefaultStatusCheck) (any, error) {
|
func (impl *interperterImpl) Evaluate(input string, defaultStatusCheck DefaultStatusCheck) (any, error) {
|
||||||
input = strings.TrimPrefix(input, "${{")
|
input = strings.TrimPrefix(input, "${{")
|
||||||
if defaultStatusCheck != DefaultStatusCheckNone && input == "" {
|
|
||||||
input = "success()"
|
if input == "" && defaultStatusCheck != DefaultStatusCheckNone {
|
||||||
|
return impl.evaluateNode(statusCheckNode(defaultStatusCheck))
|
||||||
}
|
}
|
||||||
|
|
||||||
parser := actionlint.NewExprParser()
|
parser := actionlint.NewExprParser()
|
||||||
exprNode, err := parser.Parse(actionlint.NewExprLexer(input + "}}"))
|
exprNode, err := parser.Parse(actionlint.NewExprLexer(input + "}}"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("Failed to parse: %s", err.Message)
|
return nil, fmt.Errorf("Failed to parse: %s", err.Message)
|
||||||
}
|
}
|
||||||
|
|
||||||
if defaultStatusCheck != DefaultStatusCheckNone {
|
if defaultStatusCheck != DefaultStatusCheckNone && !CallsStatusFunction(exprNode) {
|
||||||
hasStatusCheckFunction := false
|
|
||||||
actionlint.VisitExprNode(exprNode, func(node, _ actionlint.ExprNode, entering bool) {
|
|
||||||
if funcCallNode, ok := node.(*actionlint.FuncCallNode); entering && ok {
|
|
||||||
switch strings.ToLower(funcCallNode.Callee) {
|
|
||||||
case "success", "always", "cancelled", "failure":
|
|
||||||
hasStatusCheckFunction = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
if !hasStatusCheckFunction {
|
|
||||||
exprNode = &actionlint.LogicalOpNode{
|
exprNode = &actionlint.LogicalOpNode{
|
||||||
Kind: actionlint.LogicalOpNodeKindAnd,
|
Kind: actionlint.LogicalOpNodeKindAnd,
|
||||||
Left: &actionlint.FuncCallNode{
|
Left: statusCheckNode(defaultStatusCheck),
|
||||||
Callee: defaultStatusCheck.String(),
|
|
||||||
Args: []actionlint.ExprNode{},
|
|
||||||
},
|
|
||||||
Right: exprNode,
|
Right: exprNode,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
result, err2 := impl.evaluateNode(exprNode)
|
result, err2 := impl.evaluateNode(exprNode)
|
||||||
|
|
||||||
return result, err2
|
return result, err2
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func statusCheckNode(defaultStatusCheck DefaultStatusCheck) *actionlint.FuncCallNode {
|
||||||
|
return &actionlint.FuncCallNode{Callee: defaultStatusCheck.String(), Args: []actionlint.ExprNode{}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CallsStatusFunction reports whether the expression calls a status function, which counts as the
|
||||||
|
// expression asking its own status question instead of the default one.
|
||||||
|
func CallsStatusFunction(exprNode actionlint.ExprNode) bool {
|
||||||
|
found := false
|
||||||
|
actionlint.VisitExprNode(exprNode, func(node, _ actionlint.ExprNode, entering bool) {
|
||||||
|
if funcCallNode, ok := node.(*actionlint.FuncCallNode); entering && ok {
|
||||||
|
switch strings.ToLower(funcCallNode.Callee) {
|
||||||
|
case "success", "always", "cancelled", "failure":
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
|
||||||
func (impl *interperterImpl) evaluateNode(exprNode actionlint.ExprNode) (any, error) {
|
func (impl *interperterImpl) evaluateNode(exprNode actionlint.ExprNode) (any, error) {
|
||||||
switch node := exprNode.(type) {
|
switch node := exprNode.(type) {
|
||||||
case *actionlint.VariableNode:
|
case *actionlint.VariableNode:
|
||||||
|
|||||||
@@ -682,3 +682,10 @@ func TestCoerceToString(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestEvaluateEmptyInputAsksItsOwnStatusCheck(t *testing.T) {
|
||||||
|
// always() needs no job or step context, so it shows which function an empty input asks for
|
||||||
|
output, err := NewInterpeter(&EvaluationEnvironment{}, Config{}).Evaluate("", DefaultStatusCheckAlways)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, true, output)
|
||||||
|
}
|
||||||
|
|||||||
+119
-76
@@ -7,6 +7,7 @@ package runner
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"maps"
|
"maps"
|
||||||
"path"
|
"path"
|
||||||
@@ -22,12 +23,14 @@ import (
|
|||||||
|
|
||||||
_ "embed"
|
_ "embed"
|
||||||
|
|
||||||
|
"github.com/rhysd/actionlint"
|
||||||
"go.yaml.in/yaml/v4"
|
"go.yaml.in/yaml/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ExpressionEvaluator is the interface for evaluating expressions
|
// ExpressionEvaluator is the interface for evaluating expressions
|
||||||
type ExpressionEvaluator interface {
|
type ExpressionEvaluator interface {
|
||||||
evaluate(context.Context, string, exprparser.DefaultStatusCheck) (any, error)
|
evaluate(context.Context, string, exprparser.DefaultStatusCheck) (any, error)
|
||||||
|
interpolate(context.Context, string) (string, error)
|
||||||
EvaluateYamlNode(context.Context, *yaml.Node) error
|
EvaluateYamlNode(context.Context, *yaml.Node) error
|
||||||
Interpolate(context.Context, string) string
|
Interpolate(context.Context, string) string
|
||||||
}
|
}
|
||||||
@@ -240,8 +243,7 @@ func (ee expressionEvaluator) evaluateScalarYamlNode(ctx context.Context, node *
|
|||||||
if !strings.Contains(in, "${{") || !strings.Contains(in, "}}") {
|
if !strings.Contains(in, "${{") || !strings.Contains(in, "}}") {
|
||||||
return nil, nil //nolint:nilnil // pre-existing issue from nektos/act
|
return nil, nil //nolint:nilnil // pre-existing issue from nektos/act
|
||||||
}
|
}
|
||||||
expr, _ := rewriteSubExpression(ctx, in, false)
|
res, err := ee.evaluateScalar(ctx, in)
|
||||||
res, err := ee.evaluate(ctx, expr, exprparser.DefaultStatusCheckNone)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -367,105 +369,146 @@ func (ee expressionEvaluator) EvaluateYamlNode(ctx context.Context, node *yaml.N
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (ee expressionEvaluator) Interpolate(ctx context.Context, in string) string {
|
func (ee expressionEvaluator) Interpolate(ctx context.Context, in string) string {
|
||||||
if !strings.Contains(in, "${{") || !strings.Contains(in, "}}") {
|
out, err := ee.interpolate(ctx, in)
|
||||||
return in
|
|
||||||
}
|
|
||||||
|
|
||||||
expr, _ := rewriteSubExpression(ctx, in, true)
|
|
||||||
evaluated, err := ee.evaluate(ctx, expr, exprparser.DefaultStatusCheckNone)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
common.Logger(ctx).Errorf("Unable to interpolate expression '%s': %s", expr, err)
|
common.Logger(ctx).Errorf("Unable to interpolate expression '%s': %s", in, err)
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
return out
|
||||||
value, ok := evaluated.(string)
|
|
||||||
if !ok {
|
|
||||||
panic(fmt.Sprintf("Expression %s did not evaluate to a string", expr))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return value
|
// interpolate evaluates every part on its own, so a malformed one cannot restructure its neighbours
|
||||||
|
func (ee expressionEvaluator) interpolate(ctx context.Context, in string) (string, error) {
|
||||||
|
parts, err := splitSubExpressions(in)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if len(parts) == 1 && !parts[0].isExpr {
|
||||||
|
return in, nil
|
||||||
|
}
|
||||||
|
var out strings.Builder
|
||||||
|
out.Grow(len(in))
|
||||||
|
for _, part := range parts {
|
||||||
|
if !part.isExpr {
|
||||||
|
out.WriteString(part.text)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
evaluated, err := ee.evaluate(ctx, part.text, exprparser.DefaultStatusCheckNone)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
out.WriteString(exprparser.CoerceToString(evaluated))
|
||||||
|
}
|
||||||
|
return out.String(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// EvalBool evaluates an expression against given evaluator
|
// evaluateScalar keeps the type of a lone expression, so `${{ fromJSON('[1,2]') }}` stays an array
|
||||||
|
func (ee expressionEvaluator) evaluateScalar(ctx context.Context, in string) (any, error) {
|
||||||
|
parts, err := splitSubExpressions(in)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(parts) == 1 && parts[0].isExpr {
|
||||||
|
return ee.evaluate(ctx, parts[0].text, exprparser.DefaultStatusCheckNone)
|
||||||
|
}
|
||||||
|
return ee.interpolate(ctx, in)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EvalBool evaluates an expression against given evaluator. An `if:` is an expression even without
|
||||||
|
// `${{ }}`, while literal text around one makes the whole value a string.
|
||||||
func EvalBool(ctx context.Context, evaluator ExpressionEvaluator, expr string, defaultStatusCheck exprparser.DefaultStatusCheck) (bool, error) {
|
func EvalBool(ctx context.Context, evaluator ExpressionEvaluator, expr string, defaultStatusCheck exprparser.DefaultStatusCheck) (bool, error) {
|
||||||
nextExpr, _ := rewriteSubExpression(ctx, expr, false)
|
parts, err := splitSubExpressions(expr)
|
||||||
|
if err != nil {
|
||||||
evaluated, err := evaluator.evaluate(ctx, nextExpr, defaultStatusCheck)
|
return false, err
|
||||||
|
}
|
||||||
|
if len(parts) == 1 {
|
||||||
|
evaluated, err := evaluator.evaluate(ctx, parts[0].text, defaultStatusCheck)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return exprparser.IsTruthy(evaluated), nil
|
return exprparser.IsTruthy(evaluated), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func escapeFormatString(in string) string {
|
// mixed content is a string, so the status check applies to it separately
|
||||||
return strings.ReplaceAll(strings.ReplaceAll(in, "{", "{{"), "}", "}}")
|
if defaultStatusCheck != exprparser.DefaultStatusCheckNone && !callsStatusFunction(parts) {
|
||||||
|
status, err := evaluator.evaluate(ctx, "", defaultStatusCheck)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if !exprparser.IsTruthy(status) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
interpolated, err := evaluator.interpolate(ctx, expr)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return exprparser.IsTruthy(interpolated), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func rewriteSubExpression(ctx context.Context, in string, forceFormat bool) (string, error) { //nolint:unparam // pre-existing issue from nektos/act
|
// callsStatusFunction reports whether any part calls a status function. A part that does not parse
|
||||||
|
// counts as one, so the evaluation reports it against the real values.
|
||||||
|
func callsStatusFunction(parts []exprPart) bool {
|
||||||
|
for _, part := range parts {
|
||||||
|
if !part.isExpr {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// The lexer needs the closing `}}` that the scanner strips.
|
||||||
|
exprNode, err := actionlint.NewExprParser().Parse(actionlint.NewExprLexer(part.text + "}}"))
|
||||||
|
if err != nil || exprparser.CallsStatusFunction(exprNode) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
type exprPart struct {
|
||||||
|
text string
|
||||||
|
isExpr bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitSubExpressions splits in the way GitHub's template reader does, leaving a value without a
|
||||||
|
// complete expression literal.
|
||||||
|
func splitSubExpressions(in string) ([]exprPart, error) {
|
||||||
if !strings.Contains(in, "${{") || !strings.Contains(in, "}}") {
|
if !strings.Contains(in, "${{") || !strings.Contains(in, "}}") {
|
||||||
return in, nil
|
return []exprPart{{text: in}}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
strPattern := regexp.MustCompile("(?:''|[^'])*'")
|
parts := make([]exprPart, 0, 2*strings.Count(in, "${{")+1)
|
||||||
pos := 0
|
for {
|
||||||
exprStart := -1
|
start := strings.Index(in, "${{")
|
||||||
strStart := -1
|
if start < 0 {
|
||||||
var results []string
|
if in != "" {
|
||||||
var formatOut strings.Builder
|
parts = append(parts, exprPart{text: in})
|
||||||
for pos < len(in) {
|
|
||||||
if strStart > -1 {
|
|
||||||
matches := strPattern.FindStringIndex(in[pos:])
|
|
||||||
if matches == nil {
|
|
||||||
panic("unclosed string.")
|
|
||||||
}
|
}
|
||||||
|
return parts, nil
|
||||||
strStart = -1
|
}
|
||||||
pos += matches[1]
|
if start > 0 {
|
||||||
} else if exprStart > -1 {
|
parts = append(parts, exprPart{text: in[:start]})
|
||||||
exprEnd := strings.Index(in[pos:], "}}")
|
}
|
||||||
strStart = strings.Index(in[pos:], "'")
|
rest := in[start+len("${{"):]
|
||||||
|
end := indexExprEnd(rest)
|
||||||
if exprEnd > -1 && strStart > -1 {
|
if end < 0 {
|
||||||
if exprEnd < strStart {
|
return nil, errors.New("unclosed expression")
|
||||||
strStart = -1
|
}
|
||||||
} else {
|
parts = append(parts, exprPart{text: strings.TrimSpace(rest[:end]), isExpr: true})
|
||||||
exprEnd = -1
|
in = rest[end+len("}}"):]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if exprEnd > -1 {
|
// indexExprEnd returns the offset of the `}}` ending an expression, or -1. A quote toggles string
|
||||||
fmt.Fprintf(&formatOut, "{%d}", len(results))
|
// state, so a `}}` inside a string does not end it.
|
||||||
results = append(results, strings.TrimSpace(in[exprStart:pos+exprEnd]))
|
func indexExprEnd(in string) int {
|
||||||
pos += exprEnd + 2
|
inString := false
|
||||||
exprStart = -1
|
for i := range len(in) {
|
||||||
} else if strStart > -1 {
|
switch {
|
||||||
pos += strStart + 1
|
case in[i] == '\'':
|
||||||
} else {
|
inString = !inString
|
||||||
panic("unclosed expression.")
|
case !inString && in[i] == '}' && i+1 < len(in) && in[i+1] == '}':
|
||||||
}
|
return i
|
||||||
} else {
|
|
||||||
exprStart = strings.Index(in[pos:], "${{")
|
|
||||||
if exprStart != -1 {
|
|
||||||
formatOut.WriteString(escapeFormatString(in[pos : pos+exprStart]))
|
|
||||||
exprStart = pos + exprStart + 3
|
|
||||||
pos = exprStart
|
|
||||||
} else {
|
|
||||||
formatOut.WriteString(escapeFormatString(in[pos:]))
|
|
||||||
pos = len(in)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
return -1
|
||||||
|
|
||||||
if len(results) == 1 && formatOut.String() == "{0}" && !forceFormat {
|
|
||||||
return in, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
out := fmt.Sprintf("format('%s', %s)", strings.ReplaceAll(formatOut.String(), "'", "''"), strings.Join(results, ", "))
|
|
||||||
if in != out {
|
|
||||||
common.Logger(ctx).Debugf("expression '%s' rewritten to '%s'", in, out)
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func getEvaluatorInputs(ctx context.Context, rc *RunContext, step step, ghc *model.GithubContext) map[string]any {
|
func getEvaluatorInputs(ctx context.Context, rc *RunContext, step step, ghc *model.GithubContext) map[string]any {
|
||||||
|
|||||||
@@ -259,6 +259,15 @@ func TestInterpolate(t *testing.T) {
|
|||||||
{"${{ env.SOMETHING_FALSE || false }}", "false"},
|
{"${{ env.SOMETHING_FALSE || false }}", "false"},
|
||||||
{"${{ env.SOMETHING_FALSE }} && ${{ env.SOMETHING_TRUE }}", "false && true"},
|
{"${{ env.SOMETHING_FALSE }} && ${{ env.SOMETHING_TRUE }}", "false && true"},
|
||||||
{"${{ fromJSON('{}') < 2 }}", "false"},
|
{"${{ fromJSON('{}') < 2 }}", "false"},
|
||||||
|
{"${{ 1 }}", "1"},
|
||||||
|
{"${{ 1.0 }}", "1"},
|
||||||
|
{"${{ null }}", ""},
|
||||||
|
{"${{ fromJSON('[1,2]') }}", "Array"},
|
||||||
|
{"${{ fromJSON('{\"a\":1}') }}", "Object"},
|
||||||
|
// a malformed part must not restructure its neighbours, and it interpolates to nothing
|
||||||
|
{"${{ 1) && (2 }}", ""},
|
||||||
|
{"run ${{ 1) && (2 }} now", ""},
|
||||||
|
{"${{ 1", "${{ 1"},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, table := range tables {
|
for _, table := range tables {
|
||||||
@@ -270,57 +279,38 @@ func TestInterpolate(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRewriteSubExpression(t *testing.T) {
|
func TestSplitSubExpressions(t *testing.T) {
|
||||||
table := []struct {
|
expr := func(text string) exprPart { return exprPart{text: text, isExpr: true} }
|
||||||
|
literal := func(text string) exprPart { return exprPart{text: text} }
|
||||||
|
|
||||||
|
for _, tt := range []struct {
|
||||||
in string
|
in string
|
||||||
out string
|
want []exprPart
|
||||||
}{
|
}{
|
||||||
{in: "Hello World", out: "Hello World"},
|
{"Hello World", []exprPart{literal("Hello World")}},
|
||||||
{in: "${{ true }}", out: "${{ true }}"},
|
{"${{ true }}", []exprPart{expr("true")}},
|
||||||
{in: "${{ true }} ${{ true }}", out: "format('{0} {1}', true, true)"},
|
{"${{ true }} ${{ false }}", []exprPart{expr("true"), literal(" "), expr("false")}},
|
||||||
{in: "${{ true || false }} ${{ true && true }}", out: "format('{0} {1}', true || false, true && true)"},
|
{"Hello ${{ 'World' }}", []exprPart{literal("Hello "), expr("'World'")}},
|
||||||
{in: "${{ '}}' }}", out: "${{ '}}' }}"},
|
// a quote toggles string state, so a `}}` inside a string does not end the expression
|
||||||
{in: "${{ '''}}''' }}", out: "${{ '''}}''' }}"},
|
{"${{ '}}' }}", []exprPart{expr("'}}'")}},
|
||||||
{in: "${{ '''' }}", out: "${{ '''' }}"},
|
{"${{ '''}}''' }}", []exprPart{expr("'''}}'''")}},
|
||||||
{in: `${{ fromJSON('"}}"') }}`, out: `${{ fromJSON('"}}"') }}`},
|
{"${{ '''' }}", []exprPart{expr("''''")}},
|
||||||
{in: `${{ fromJSON('"\"}}\""') }}`, out: `${{ fromJSON('"\"}}\""') }}`},
|
{`${{ fromJSON('"}}"') }}`, []exprPart{expr(`fromJSON('"}}"')`)}},
|
||||||
{in: `${{ fromJSON('"''}}"') }}`, out: `${{ fromJSON('"''}}"') }}`},
|
{`${{ fromJSON('"\"}}\""') }}`, []exprPart{expr(`fromJSON('"\"}}\""')`)}},
|
||||||
{in: "Hello ${{ 'World' }}", out: "format('Hello {0}', 'World')"},
|
{`${{ fromJSON('"''}}"') }}`, []exprPart{expr(`fromJSON('"''}}"')`)}},
|
||||||
}
|
// without a complete literal the value stays text, as GitHub's template reader leaves it
|
||||||
|
{"${{ 1", []exprPart{literal("${{ 1")}},
|
||||||
for _, table := range table {
|
// a malformed part stays one part, so it cannot restructure its neighbours
|
||||||
t.Run("TestRewriteSubExpression", func(t *testing.T) {
|
{"${{ 1) && (2 }}", []exprPart{expr("1) && (2")}},
|
||||||
assertObject := assert.New(t)
|
|
||||||
out, err := rewriteSubExpression(context.Background(), table.in, false)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
assertObject.Equal(table.out, out, table.in)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRewriteSubExpressionForceFormat(t *testing.T) {
|
|
||||||
table := []struct {
|
|
||||||
in string
|
|
||||||
out string
|
|
||||||
} {
|
} {
|
||||||
{in: "Hello World", out: "Hello World"},
|
got, err := splitSubExpressions(tt.in)
|
||||||
{in: "${{ true }}", out: "format('{0}', true)"},
|
require.NoError(t, err, tt.in)
|
||||||
{in: "${{ '}}' }}", out: "format('{0}', '}}')"},
|
assert.Equal(t, tt.want, got, tt.in)
|
||||||
{in: `${{ fromJSON('"}}"') }}`, out: `format('{0}', fromJSON('"}}"'))`},
|
|
||||||
{in: "Hello ${{ 'World' }}", out: "format('Hello {0}', 'World')"},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, table := range table {
|
for _, in := range []string{"${{ 'a' }} ${{ b", "${{ 'a }}"} {
|
||||||
t.Run("TestRewriteSubExpressionForceFormat", func(t *testing.T) {
|
_, err := splitSubExpressions(in)
|
||||||
assertObject := assert.New(t)
|
assert.ErrorContains(t, err, "unclosed expression", in)
|
||||||
out, err := rewriteSubExpression(context.Background(), table.in, true)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
assertObject.Equal(table.out, out, table.in)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,11 +12,13 @@ the runner as a background service on a systemd host.
|
|||||||
sudo useradd --system --home-dir /var/lib/gitea-runner --create-home gitea-runner
|
sudo useradd --system --home-dir /var/lib/gitea-runner --create-home gitea-runner
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Generate a config and register the runner (as the service user), so the
|
3. Write a config, hand it to the service user, and register as that user so the
|
||||||
`.runner` file ends up in the working directory:
|
`.runner` file ends up in the working directory:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo -u gitea-runner gitea-runner config generate > /etc/gitea-runner/config.yaml
|
sudo mkdir -p /etc/gitea-runner
|
||||||
|
sudo gitea-runner config init --config /etc/gitea-runner/config.yaml
|
||||||
|
sudo chown gitea-runner /etc/gitea-runner/config.yaml
|
||||||
cd /var/lib/gitea-runner
|
cd /var/lib/gitea-runner
|
||||||
sudo -u gitea-runner gitea-runner register --config /etc/gitea-runner/config.yaml
|
sudo -u gitea-runner gitea-runner register --config /etc/gitea-runner/config.yaml
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -49,10 +49,10 @@ export DOCKER_HOST=unix:///run/user/$(id -u)/docker.sock
|
|||||||
gitea-runner register
|
gitea-runner register
|
||||||
```
|
```
|
||||||
|
|
||||||
- Generate a `gitea-runner` configuration file in the data directory. Edit the file to adjust for the system.
|
- Write a `gitea-runner` configuration file in the data directory. Edit the file to adjust for the system, `gitea-runner config generate` documents every option.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
gitea-runner config generate >/home/rootless/gitea-runner/config
|
gitea-runner config init --config /home/rootless/gitea-runner/config
|
||||||
```
|
```
|
||||||
|
|
||||||
- Create a new user-level`systemd` unit file as `/home/rootless/.config/systemd/user/gitea-runner.service` with the following contents:
|
- Create a new user-level`systemd` unit file as `/home/rootless/.config/systemd/user/gitea-runner.service` with the following contents:
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ module gitea.com/gitea/runner
|
|||||||
|
|
||||||
go 1.26.0
|
go 1.26.0
|
||||||
|
|
||||||
|
toolchain go1.26.5
|
||||||
|
|
||||||
require (
|
require (
|
||||||
connectrpc.com/connect v1.20.0
|
connectrpc.com/connect v1.20.0
|
||||||
dario.cat/mergo v1.0.2
|
dario.cat/mergo v1.0.2
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ func loadConfigCmd(configFile *string) *cobra.Command {
|
|||||||
}
|
}
|
||||||
|
|
||||||
configCmd.AddCommand(loadGenerateConfigCmd("generate"))
|
configCmd.AddCommand(loadGenerateConfigCmd("generate"))
|
||||||
|
configCmd.AddCommand(loadInitConfigCmd(configFile))
|
||||||
|
|
||||||
configCmd.AddCommand(&cobra.Command{
|
configCmd.AddCommand(&cobra.Command{
|
||||||
Use: "get <key>",
|
Use: "get <key>",
|
||||||
@@ -73,10 +74,38 @@ func loadConfigCmd(configFile *string) *cobra.Command {
|
|||||||
return configCmd
|
return configCmd
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func loadInitConfigCmd(configFile *string) *cobra.Command {
|
||||||
|
var force bool
|
||||||
|
initCmd := &cobra.Command{
|
||||||
|
Use: "init",
|
||||||
|
Short: "Write a minimal config file",
|
||||||
|
Long: "Write a minimal config file, leaving every option at its default.\nWithout --config it writes config.yaml in the working directory.",
|
||||||
|
Args: cobra.MaximumNArgs(0),
|
||||||
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
|
file, taken := *configFile, []string{*configFile}
|
||||||
|
if file == "" {
|
||||||
|
file, taken = defaultConfigFileNames[0], defaultConfigFileNames // any of them would shadow the new file
|
||||||
|
}
|
||||||
|
for _, name := range taken {
|
||||||
|
if _, err := os.Stat(name); err == nil && !force {
|
||||||
|
return fmt.Errorf("config file %q already exists, pass --force to overwrite it", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := config.WriteFile(file, []byte(config.Minimal)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Fprintf(cmd.OutOrStdout(), "wrote config file %q\n", file)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
initCmd.Flags().BoolVarP(&force, "force", "f", false, "overwrite an existing config file")
|
||||||
|
return initCmd
|
||||||
|
}
|
||||||
|
|
||||||
func loadGenerateConfigCmd(use string) *cobra.Command {
|
func loadGenerateConfigCmd(use string) *cobra.Command {
|
||||||
return &cobra.Command{
|
return &cobra.Command{
|
||||||
Use: use,
|
Use: use,
|
||||||
Short: "Generate an example config file",
|
Short: "Print the example config, which documents every option",
|
||||||
Args: cobra.MaximumNArgs(0),
|
Args: cobra.MaximumNArgs(0),
|
||||||
Run: func(cmd *cobra.Command, _ []string) {
|
Run: func(cmd *cobra.Command, _ []string) {
|
||||||
fmt.Fprintf(cmd.OutOrStdout(), "%s", config.Example)
|
fmt.Fprintf(cmd.OutOrStdout(), "%s", config.Example)
|
||||||
|
|||||||
@@ -32,6 +32,30 @@ func TestConfigCmdGeneratePrintsTheExample(t *testing.T) {
|
|||||||
assert.Equal(t, string(config.Example), out)
|
assert.Equal(t, string(config.Example), out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestConfigCmdInitWritesTheMinimalConfig(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
file := filepath.Join(dir, "config.yaml")
|
||||||
|
|
||||||
|
out, _, err := runConfigCmd(t, file, "init")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, out, file)
|
||||||
|
content, err := os.ReadFile(file)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, config.Minimal, string(content))
|
||||||
|
|
||||||
|
_, _, err = runConfigCmd(t, file, "init")
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "--force")
|
||||||
|
|
||||||
|
_, _, err = runConfigCmd(t, file, "init", "--force")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
t.Chdir(t.TempDir())
|
||||||
|
_, _, err = runConfigCmd(t, "", "init")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.FileExists(t, defaultConfigFileNames[0])
|
||||||
|
}
|
||||||
|
|
||||||
// The subcommands only wire arguments through, so one pass over all of them is enough.
|
// The subcommands only wire arguments through, so one pass over all of them is enough.
|
||||||
func TestConfigCmdEditsTheFile(t *testing.T) {
|
func TestConfigCmdEditsTheFile(t *testing.T) {
|
||||||
file := filepath.Join(t.TempDir(), "config.yaml")
|
file := filepath.Join(t.TempDir(), "config.yaml")
|
||||||
|
|||||||
@@ -230,9 +230,9 @@ func initInputs(regArgs *registerArgs) (*registerInputs, error) {
|
|||||||
token = envToken
|
token = envToken
|
||||||
}
|
}
|
||||||
inputs := ®isterInputs{
|
inputs := ®isterInputs{
|
||||||
InstanceAddr: regArgs.InstanceAddr,
|
InstanceAddr: strings.TrimSpace(regArgs.InstanceAddr),
|
||||||
Token: token,
|
Token: strings.TrimSpace(token),
|
||||||
RunnerName: regArgs.RunnerName,
|
RunnerName: strings.TrimSpace(regArgs.RunnerName),
|
||||||
Ephemeral: regArgs.Ephemeral,
|
Ephemeral: regArgs.Ephemeral,
|
||||||
}
|
}
|
||||||
regArgs.Labels = strings.TrimSpace(regArgs.Labels)
|
regArgs.Labels = strings.TrimSpace(regArgs.Labels)
|
||||||
|
|||||||
@@ -185,8 +185,8 @@ func TestInitInputs(t *testing.T) {
|
|||||||
t.Setenv(registerTokenEnvVar, "from-env")
|
t.Setenv(registerTokenEnvVar, "from-env")
|
||||||
inputs, err := initInputs(®isterArgs{
|
inputs, err := initInputs(®isterArgs{
|
||||||
InstanceAddr: " http://localhost:3000 ",
|
InstanceAddr: " http://localhost:3000 ",
|
||||||
Token: "from-plain-arg",
|
Token: "from-plain-arg\n",
|
||||||
RunnerName: "runner",
|
RunnerName: "runner\n",
|
||||||
Ephemeral: true,
|
Ephemeral: true,
|
||||||
Labels: " ubuntu:host , ubuntu:docker://node:18 ",
|
Labels: " ubuntu:host , ubuntu:docker://node:18 ",
|
||||||
})
|
})
|
||||||
@@ -203,7 +203,7 @@ func TestInitInputs(t *testing.T) {
|
|||||||
tokenFile, createErr := os.CreateTemp(t.TempDir(), "from-file")
|
tokenFile, createErr := os.CreateTemp(t.TempDir(), "from-file")
|
||||||
require.NoError(t, createErr)
|
require.NoError(t, createErr)
|
||||||
defer tokenFile.Close()
|
defer tokenFile.Close()
|
||||||
_, writeErr := tokenFile.WriteString("from-file")
|
_, writeErr := tokenFile.WriteString("from-file\n")
|
||||||
require.NoError(t, writeErr)
|
require.NoError(t, writeErr)
|
||||||
_ = tokenFile.Sync()
|
_ = tokenFile.Sync()
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
# Example configuration file, it's safe to copy this as the default config file without any modification.
|
# Every option with its default value, all commented out. Read this file, do not copy it.
|
||||||
|
# `./gitea-runner config init` writes a config file to copy the lines you change into.
|
||||||
# You don't have to copy this file to your instance,
|
|
||||||
# just run `./gitea-runner config generate > config.yaml` to generate a config file.
|
|
||||||
|
|
||||||
# Logging for the runner process itself (messages printed to stderr).
|
# Logging for the runner process itself (messages printed to stderr).
|
||||||
# This does not control how workflow step output is streamed to the Gitea UI;
|
# This does not control how workflow step output is streamed to the Gitea UI;
|
||||||
@@ -9,92 +7,92 @@
|
|||||||
log:
|
log:
|
||||||
# logrus severity: trace, debug, info, warn, error, fatal, panic.
|
# logrus severity: trace, debug, info, warn, error, fatal, panic.
|
||||||
# trace and debug turn on caller/file:line in log lines. Default if omitted: info.
|
# trace and debug turn on caller/file:line in log lines. Default if omitted: info.
|
||||||
level: info
|
#level: info
|
||||||
|
|
||||||
runner:
|
runner:
|
||||||
# Where to store the registration result.
|
# Where to store the registration result.
|
||||||
file: .runner
|
#file: .runner
|
||||||
# Execute how many tasks concurrently at the same time.
|
# Execute how many tasks concurrently at the same time.
|
||||||
# With `container.network` empty, each concurrent docker job takes a subnet from the
|
# With `container.network` empty, each concurrent docker job takes a subnet from the
|
||||||
# daemon's address pool, so a high capacity can exhaust it. See `default-address-pools`
|
# daemon's address pool, so a high capacity can exhaust it. See `default-address-pools`
|
||||||
# in the docker daemon config.
|
# in the docker daemon config.
|
||||||
capacity: 1
|
#capacity: 1
|
||||||
# Extra environment variables to run jobs.
|
# Extra environment variables to run jobs.
|
||||||
envs:
|
#envs:
|
||||||
A_TEST_ENV_NAME_1: a_test_env_value_1
|
# A_TEST_ENV_NAME_1: a_test_env_value_1
|
||||||
A_TEST_ENV_NAME_2: a_test_env_value_2
|
# A_TEST_ENV_NAME_2: a_test_env_value_2
|
||||||
# Extra environment variables to run jobs from a file.
|
# Extra environment variables to run jobs from a file.
|
||||||
# It will be ignored if it's empty or the file doesn't exist.
|
# It will be ignored if it's empty or the file doesn't exist.
|
||||||
env_file: .env
|
#env_file: .env
|
||||||
# The timeout for a job to be finished.
|
# The timeout for a job to be finished.
|
||||||
# Please note that the Gitea instance also has a timeout (3h by default) for the job.
|
# Please note that the Gitea instance also has a timeout (3h by default) for the job.
|
||||||
# So the job could be stopped by the Gitea instance if its timeout is shorter than this.
|
# So the job could be stopped by the Gitea instance if its timeout is shorter than this.
|
||||||
timeout: 3h
|
#timeout: 3h
|
||||||
# The timeout for the runner to wait for running jobs to finish when shutting down.
|
# The timeout for the runner to wait for running jobs to finish when shutting down.
|
||||||
# Any running jobs that haven't finished after this timeout will be cancelled.
|
# Any running jobs that haven't finished after this timeout will be cancelled.
|
||||||
shutdown_timeout: 0s
|
#shutdown_timeout: 0s
|
||||||
# Whether skip verifying the TLS certificate of the Gitea instance.
|
# Whether skip verifying the TLS certificate of the Gitea instance.
|
||||||
insecure: false
|
#insecure: false
|
||||||
# The timeout for fetching the job from the Gitea instance.
|
# The timeout for fetching the job from the Gitea instance.
|
||||||
fetch_timeout: 5s
|
#fetch_timeout: 5s
|
||||||
# The interval for fetching the job from the Gitea instance.
|
# The interval for fetching the job from the Gitea instance.
|
||||||
fetch_interval: 2s
|
#fetch_interval: 2s
|
||||||
# The maximum interval for fetching the job from the Gitea instance.
|
# The maximum interval for fetching the job from the Gitea instance.
|
||||||
# The runner uses exponential backoff when idle, increasing the interval up to this maximum.
|
# The runner uses exponential backoff when idle, increasing the interval up to this maximum.
|
||||||
# Set to 0 or same as fetch_interval to disable backoff.
|
# Set to 0 or same as fetch_interval to disable backoff.
|
||||||
fetch_interval_max: 5s
|
#fetch_interval_max: 5s
|
||||||
# While idle, remove stale bind-workdir task directories and orphaned host-mode
|
# While idle, remove stale bind-workdir task directories and orphaned host-mode
|
||||||
# scratch directories (left behind when a host cleanup delete stalls) older than
|
# scratch directories (left behind when a host cleanup delete stalls) older than
|
||||||
# this duration. Setting either workdir_cleanup_age or idle_cleanup_interval to 0
|
# this duration. Setting either workdir_cleanup_age or idle_cleanup_interval to 0
|
||||||
# (or any non-positive value) disables stale-directory cleanup entirely, along with
|
# (or any non-positive value) disables stale-directory cleanup entirely, along with
|
||||||
# the docker network cleanup below.
|
# the docker network cleanup below.
|
||||||
workdir_cleanup_age: 24h
|
#workdir_cleanup_age: 24h
|
||||||
# Cadence for the idle cleanup pass. Besides the directories above, on runners that use
|
# Cadence for the idle cleanup pass. Besides the directories above, on runners that use
|
||||||
# docker it removes the per-job networks of jobs this runner did not live to tear down,
|
# docker it removes the per-job networks of jobs this runner did not live to tear down,
|
||||||
# which would otherwise hold a subnet of the daemon address pool until the host is rebuilt.
|
# which would otherwise hold a subnet of the daemon address pool until the host is rebuilt.
|
||||||
idle_cleanup_interval: 10m
|
#idle_cleanup_interval: 10m
|
||||||
# The base interval for periodic log flush to the Gitea instance.
|
# The base interval for periodic log flush to the Gitea instance.
|
||||||
# Logs may be sent earlier if the buffer reaches log_report_batch_size
|
# Logs may be sent earlier if the buffer reaches log_report_batch_size
|
||||||
# or if log_report_max_latency expires after the first buffered row.
|
# or if log_report_max_latency expires after the first buffered row.
|
||||||
log_report_interval: 5s
|
#log_report_interval: 5s
|
||||||
# The maximum time a log row can wait before being sent.
|
# The maximum time a log row can wait before being sent.
|
||||||
# This ensures even a single log line appears on the frontend within this duration.
|
# This ensures even a single log line appears on the frontend within this duration.
|
||||||
# Must be less than log_report_interval to have any effect.
|
# Must be less than log_report_interval to have any effect.
|
||||||
log_report_max_latency: 3s
|
#log_report_max_latency: 3s
|
||||||
# Flush logs immediately when the buffer reaches this many rows.
|
# Flush logs immediately when the buffer reaches this many rows.
|
||||||
# This ensures bursty output (e.g., npm install) is delivered promptly.
|
# This ensures bursty output (e.g., npm install) is delivered promptly.
|
||||||
log_report_batch_size: 100
|
#log_report_batch_size: 100
|
||||||
# The interval for reporting task state (step status, timing) to the Gitea instance.
|
# The interval for reporting task state (step status, timing) to the Gitea instance.
|
||||||
# State is also reported immediately on step transitions (start/stop).
|
# State is also reported immediately on step transitions (start/stop).
|
||||||
state_report_interval: 5s
|
#state_report_interval: 5s
|
||||||
# Per-attempt deadline for flushing the final logs and task state when a job
|
# Per-attempt deadline for flushing the final logs and task state when a job
|
||||||
# finishes, on a detached context so a server cancel can't block the acknowledgement.
|
# finishes, on a detached context so a server cancel can't block the acknowledgement.
|
||||||
report_close_timeout: 10s
|
#report_close_timeout: 10s
|
||||||
# The github_mirror of a runner is used to specify the mirror address of the github that pulls the action repository.
|
# The github_mirror of a runner is used to specify the mirror address of the github that pulls the action repository.
|
||||||
# It works when something like `uses: actions/checkout@v4` is used and DEFAULT_ACTIONS_URL is set to github,
|
# It works when something like `uses: actions/checkout@v4` is used and DEFAULT_ACTIONS_URL is set to github,
|
||||||
# and github_mirror is not empty. In this case,
|
# and github_mirror is not empty. In this case,
|
||||||
# it replaces https://github.com with the value here, which is useful for some special network environments.
|
# it replaces https://github.com with the value here, which is useful for some special network environments.
|
||||||
github_mirror: ''
|
#github_mirror: ''
|
||||||
# When true (the default), fetch only the requested ref of an action repository (e.g. actions/checkout@v4) at depth 1 instead of cloning every branch's full history.
|
# When true (the default), fetch only the requested ref of an action repository (e.g. actions/checkout@v4) at depth 1 instead of cloning every branch's full history.
|
||||||
# Set to false to clone the full history.
|
# Set to false to clone the full history.
|
||||||
action_shallow_clone: true
|
#action_shallow_clone: true
|
||||||
# When true (the default), inject the ACT=true environment variable into jobs.
|
# When true (the default), inject the ACT=true environment variable into jobs.
|
||||||
# Set to false so workflows gated on `if: ${{ !env.ACT }}` behave like they do on GitHub.
|
# Set to false so workflows gated on `if: ${{ !env.ACT }}` behave like they do on GitHub.
|
||||||
set_act_env: true
|
#set_act_env: true
|
||||||
# The labels of a runner are used to determine which jobs the runner can run, and how to run them.
|
# The labels of a runner are used to determine which jobs the runner can run, and how to run them.
|
||||||
# Like: "macos-arm64:host" or "ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest"
|
# Like: "macos-arm64:host" or "ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest"
|
||||||
# Find more images provided by Gitea at https://gitea.com/gitea/runner-images .
|
# Find more images provided by Gitea at https://gitea.com/gitea/runner-images .
|
||||||
# If it's empty when registering, it will ask for inputting labels.
|
# If it's empty when registering, it will ask for inputting labels.
|
||||||
# If it's empty when execute `daemon`, will use labels in `.runner` file.
|
# If it's empty when execute `daemon`, will use labels in `.runner` file.
|
||||||
labels:
|
#labels:
|
||||||
- "ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest"
|
# - "ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest"
|
||||||
- "ubuntu-24.04:docker://docker.gitea.com/runner-images:ubuntu-24.04"
|
# - "ubuntu-24.04:docker://docker.gitea.com/runner-images:ubuntu-24.04"
|
||||||
- "ubuntu-22.04:docker://docker.gitea.com/runner-images:ubuntu-22.04"
|
# - "ubuntu-22.04:docker://docker.gitea.com/runner-images:ubuntu-22.04"
|
||||||
# Allocate a pseudo-TTY for each step's process. Applies to both host and docker backends.
|
# Allocate a pseudo-TTY for each step's process. Applies to both host and docker backends.
|
||||||
# Default false matches GitHub actions/runner. Enable only for jobs that need an interactive
|
# Default false matches GitHub actions/runner. Enable only for jobs that need an interactive
|
||||||
# terminal; tools like `docker build` emit redrawing progress frames into the captured log
|
# terminal; tools like `docker build` emit redrawing progress frames into the captured log
|
||||||
# when a TTY is present.
|
# when a TTY is present.
|
||||||
allocate_pty: false
|
#allocate_pty: false
|
||||||
# Optional executable on the host, run once after each task's built-in cleanup
|
# Optional executable on the host, run once after each task's built-in cleanup
|
||||||
# (post-steps, container teardown, bind-workdir removal). Additive only.
|
# (post-steps, container teardown, bind-workdir removal). Additive only.
|
||||||
#
|
#
|
||||||
@@ -107,24 +105,24 @@ runner:
|
|||||||
# Windows: use .exe, .bat, or .cmd. PowerShell (.ps1) is not supported yet as
|
# Windows: use .exe, .bat, or .cmd. PowerShell (.ps1) is not supported yet as
|
||||||
# the configured path; wrap PowerShell commands in a .cmd file instead.
|
# the configured path; wrap PowerShell commands in a .cmd file instead.
|
||||||
# Full guide: docs/post-task-script.md
|
# Full guide: docs/post-task-script.md
|
||||||
post_task_script: ''
|
#post_task_script: ''
|
||||||
# Hard limit on post_task_script runtime. Default if omitted: 5m.
|
# Hard limit on post_task_script runtime. Default if omitted: 5m.
|
||||||
post_task_script_timeout: 5m
|
#post_task_script_timeout: 5m
|
||||||
# Scripts run inside the job environment before the job's first step and after its last
|
# Scripts run inside the job environment before the job's first step and after its last
|
||||||
# one, the equivalent of GitHub's ACTIONS_RUNNER_HOOK_JOB_STARTED and
|
# one, the equivalent of GitHub's ACTIONS_RUNNER_HOOK_JOB_STARTED and
|
||||||
# ACTIONS_RUNNER_HOOK_JOB_COMPLETED, which are read when these are unset. The paths are
|
# ACTIONS_RUNNER_HOOK_JOB_COMPLETED, which are read when these are unset. The paths are
|
||||||
# resolved inside the job environment. Either one failing fails the job.
|
# resolved inside the job environment. Either one failing fails the job.
|
||||||
# Full guide: docs/job-hooks.md
|
# Full guide: docs/job-hooks.md
|
||||||
hooks:
|
#hooks:
|
||||||
job_started: ''
|
# job_started: ''
|
||||||
job_completed: ''
|
# job_completed: ''
|
||||||
|
|
||||||
cache:
|
cache:
|
||||||
# Enable the built-in cache server (used by actions/cache and similar actions).
|
# Enable the built-in cache server (used by actions/cache and similar actions).
|
||||||
enabled: true
|
#enabled: true
|
||||||
# Directory where cache blobs are stored on disk. Default: $HOME/.cache/actcache
|
# Directory where cache blobs are stored on disk. Default: $HOME/.cache/actcache
|
||||||
# Ignored when external_server is set.
|
# Ignored when external_server is set.
|
||||||
dir: ""
|
#dir: ""
|
||||||
# Outbound IP or hostname that job containers use to reach this runner's cache server.
|
# Outbound IP or hostname that job containers use to reach this runner's cache server.
|
||||||
# Leave empty to detect automatically. 0.0.0.0 is not valid here.
|
# Leave empty to detect automatically. 0.0.0.0 is not valid here.
|
||||||
# If the runner itself runs in Docker, automatic detection can choose an
|
# If the runner itself runs in Docker, automatic detection can choose an
|
||||||
@@ -133,33 +131,33 @@ cache:
|
|||||||
# to a hostname/IP reachable from job containers, and set port to a fixed
|
# to a hostname/IP reachable from job containers, and set port to a fixed
|
||||||
# published port or put the job containers on a shared Docker network.
|
# published port or put the job containers on a shared Docker network.
|
||||||
# Ignored when external_server is set.
|
# Ignored when external_server is set.
|
||||||
host: ""
|
#host: ""
|
||||||
# Port for the built-in cache server. 0 picks a random free port.
|
# Port for the built-in cache server. 0 picks a random free port.
|
||||||
# Ignored when external_server is set.
|
# Ignored when external_server is set.
|
||||||
port: 0
|
#port: 0
|
||||||
# URL of a shared `gitea-runner cache-server` to use instead of starting a local one.
|
# URL of a shared `gitea-runner cache-server` to use instead of starting a local one.
|
||||||
# Set on every runner that should share a cache pool. A trailing slash is optional.
|
# Set on every runner that should share a cache pool. A trailing slash is optional.
|
||||||
# Example: "http://cache-host:8088/"
|
# Example: "http://cache-host:8088/"
|
||||||
# Requires external_secret (below) to match the value on the cache-server.
|
# Requires external_secret (below) to match the value on the cache-server.
|
||||||
external_server: ""
|
#external_server: ""
|
||||||
# Shared secret between this runner and the external cache-server.
|
# Shared secret between this runner and the external cache-server.
|
||||||
# Required when external_server is set. Must be identical on every runner and the cache-server.
|
# Required when external_server is set. Must be identical on every runner and the cache-server.
|
||||||
# Generate with: openssl rand -hex 32
|
# Generate with: openssl rand -hex 32
|
||||||
external_secret: ""
|
#external_secret: ""
|
||||||
# Path to a file containing the shared secret, as an alternative to external_secret.
|
# Path to a file containing the shared secret, as an alternative to external_secret.
|
||||||
# Use this to keep the secret out of this file.
|
# Use this to keep the secret out of this file.
|
||||||
# Surrounding whitespace is trimmed, so a trailing newline in the file is fine.
|
# Surrounding whitespace is trimmed, so a trailing newline in the file is fine.
|
||||||
# Setting both external_secret and external_secret_file is an error.
|
# Setting both external_secret and external_secret_file is an error.
|
||||||
external_secret_file: ""
|
#external_secret_file: ""
|
||||||
# When true, reuse a cached action instead of fetching from the remote on every job.
|
# When true, reuse a cached action instead of fetching from the remote on every job.
|
||||||
# A moved tag (e.g. a re-tagged "v6") or an updated branch stays at the cached commit
|
# A moved tag (e.g. a re-tagged "v6") or an updated branch stays at the cached commit
|
||||||
# until its cache entry expires or is manually removed.
|
# until its cache entry expires or is manually removed.
|
||||||
offline_mode: false
|
#offline_mode: false
|
||||||
# Serve the actions cache service v2 API, used by actions/[email protected] and later. Those actions
|
# Serve the actions cache service v2 API, used by actions/[email protected] and later. Those actions
|
||||||
# refuse any host they do not take for GitHub, so reaching it means editing that check out of
|
# refuse any host they do not take for GitHub, so reaching it means editing that check out of
|
||||||
# the action's own bundle, keeping the untouched copy beside it. The same edit lets the stock
|
# the action's own bundle, keeping the untouched copy beside it. The same edit lets the stock
|
||||||
# upload-artifact and download-artifact work here. A bundle that does not match is left alone.
|
# upload-artifact and download-artifact work here. A bundle that does not match is left alone.
|
||||||
v2: true
|
#v2: true
|
||||||
|
|
||||||
container:
|
container:
|
||||||
# Specifies the network to which the container will connect.
|
# Specifies the network to which the container will connect.
|
||||||
@@ -168,31 +166,31 @@ container:
|
|||||||
# For dockerized runners using the built-in cache server, a custom shared
|
# For dockerized runners using the built-in cache server, a custom shared
|
||||||
# network can be required so job containers can reach cache.host/cache.port.
|
# network can be required so job containers can reach cache.host/cache.port.
|
||||||
# Deprecated: `network_mode` is still accepted for old configs; use `network` instead.
|
# Deprecated: `network_mode` is still accepted for old configs; use `network` instead.
|
||||||
network: ""
|
#network: ""
|
||||||
# network_create_options only apply when `network` is left empty and the runner
|
# network_create_options only apply when `network` is left empty and the runner
|
||||||
# auto-creates a per-job network that does not already exist. They have no effect
|
# auto-creates a per-job network that does not already exist. They have no effect
|
||||||
# when a custom `network` name is set, because that network is used as-is and never
|
# when a custom `network` name is set, because that network is used as-is and never
|
||||||
# created by the runner. Omit the entire block to use Docker's defaults. An auto-created
|
# created by the runner. Omit the entire block to use Docker's defaults. An auto-created
|
||||||
# network is labelled com.gitea.runner.uuid=<this runner's uuid>, which is how the idle
|
# network is labelled com.gitea.runner.uuid=<this runner's uuid>, which is how the idle
|
||||||
# cleanup tells its own leftovers apart from those of other runners on the same daemon.
|
# cleanup tells its own leftovers apart from those of other runners on the same daemon.
|
||||||
network_create_options:
|
#network_create_options:
|
||||||
enable_ipv4: true # Omit to use Docker's default (IPv4 enabled). Set false to disable IPv4.
|
# enable_ipv4: true # Omit to use Docker's default (IPv4 enabled). Set false to disable IPv4.
|
||||||
enable_ipv6: false # Omit to use Docker's default (IPv6 disabled). Enabling it requires dockerd started with --ipv6.
|
# enable_ipv6: false # Omit to use Docker's default (IPv6 disabled). Enabling it requires dockerd started with --ipv6.
|
||||||
# Whether to use privileged mode or not when launching task containers (privileged mode is required for Docker-in-Docker).
|
# Whether to use privileged mode or not when launching task containers (privileged mode is required for Docker-in-Docker).
|
||||||
privileged: false
|
#privileged: false
|
||||||
# Any other options to be used when the container is started, for example:
|
# Any other options to be used when the container is started, for example:
|
||||||
# options: --add-host=my.gitea.url:host-gateway
|
# options: --add-host=my.gitea.url:host-gateway
|
||||||
# A volume declared here replaces the one the runner mounts on the same container path, so the
|
# A volume declared here replaces the one the runner mounts on the same container path, so the
|
||||||
# tool cache can be kept on the host. Its source must also be allowed by valid_volumes below:
|
# tool cache can be kept on the host. Its source must also be allowed by valid_volumes below:
|
||||||
# options: --volume /host/toolcache:/opt/hostedtoolcache
|
# options: --volume /host/toolcache:/opt/hostedtoolcache
|
||||||
options:
|
#options:
|
||||||
# The parent directory of a job's working directory.
|
# The parent directory of a job's working directory.
|
||||||
# NOTE: There is no need to add the first '/' of the path as runner will add it automatically.
|
# NOTE: There is no need to add the first '/' of the path as runner will add it automatically.
|
||||||
# If the path starts with '/', the '/' will be trimmed.
|
# If the path starts with '/', the '/' will be trimmed.
|
||||||
# For example, if the parent directory is /path/to/my/dir, workdir_parent should be path/to/my/dir
|
# For example, if the parent directory is /path/to/my/dir, workdir_parent should be path/to/my/dir
|
||||||
# If it's empty, /workspace will be used.
|
# If it's empty, /workspace will be used.
|
||||||
# Purely numeric subdirectories under this path are reserved for task workspaces and may be removed by idle cleanup.
|
# Purely numeric subdirectories under this path are reserved for task workspaces and may be removed by idle cleanup.
|
||||||
workdir_parent:
|
#workdir_parent:
|
||||||
# Volumes (including bind mounts) can be mounted to containers. Glob syntax is supported, see https://github.com/gobwas/glob
|
# Volumes (including bind mounts) can be mounted to containers. Glob syntax is supported, see https://github.com/gobwas/glob
|
||||||
# You can specify multiple volumes. If the sequence is empty, no volumes can be mounted.
|
# You can specify multiple volumes. If the sequence is empty, no volumes can be mounted.
|
||||||
# For example, if you only allow containers to mount the `data` volume and all the json files in `/src`, you should change the config to:
|
# For example, if you only allow containers to mount the `data` volume and all the json files in `/src`, you should change the config to:
|
||||||
@@ -202,62 +200,62 @@ container:
|
|||||||
# If you want to allow any volume, please use the following configuration:
|
# If you want to allow any volume, please use the following configuration:
|
||||||
# valid_volumes:
|
# valid_volumes:
|
||||||
# - '**'
|
# - '**'
|
||||||
valid_volumes: []
|
#valid_volumes: []
|
||||||
# Overrides the docker client host with the specified one.
|
# Overrides the docker client host with the specified one.
|
||||||
# If it's empty, runner will find an available docker host automatically.
|
# If it's empty, runner will find an available docker host automatically.
|
||||||
# If it's "-", runner will find an available docker host automatically, but the docker host won't be mounted to the job containers and service containers.
|
# If it's "-", runner will find an available docker host automatically, but the docker host won't be mounted to the job containers and service containers.
|
||||||
# If it's not empty or "-", the specified docker host will be used. An error will be returned if it doesn't work.
|
# If it's not empty or "-", the specified docker host will be used. An error will be returned if it doesn't work.
|
||||||
docker_host: ""
|
#docker_host: ""
|
||||||
# Pull docker image(s) even if already present.
|
# Pull docker image(s) even if already present.
|
||||||
# Defaults to false when the key is omitted.
|
# Defaults to false when the key is omitted.
|
||||||
# Two exceptions: an image pinned by digest (image@sha256:...) cannot change, so it is never
|
# Two exceptions: an image pinned by digest (image@sha256:...) cannot change, so it is never
|
||||||
# re-pulled, and a pull that fails while a copy is already on the host does not fail the job,
|
# re-pulled, and a pull that fails while a copy is already on the host does not fail the job,
|
||||||
# which runs on that copy with a warning in its log.
|
# which runs on that copy with a warning in its log.
|
||||||
force_pull: false
|
#force_pull: false
|
||||||
# Rebuild docker image(s) even if already present
|
# Rebuild docker image(s) even if already present
|
||||||
force_rebuild: false
|
#force_rebuild: false
|
||||||
# Always require a reachable docker daemon, even if not required by runner
|
# Always require a reachable docker daemon, even if not required by runner
|
||||||
require_docker: false
|
#require_docker: false
|
||||||
# Timeout to wait for the docker daemon to be reachable, if docker is required by require_docker or runner
|
# Timeout to wait for the docker daemon to be reachable, if docker is required by require_docker or runner
|
||||||
docker_timeout: 0s
|
#docker_timeout: 0s
|
||||||
# Bind the workspace to the host filesystem instead of using Docker volumes.
|
# Bind the workspace to the host filesystem instead of using Docker volumes.
|
||||||
# This is required for Docker-in-Docker (DinD) setups when jobs use docker compose
|
# This is required for Docker-in-Docker (DinD) setups when jobs use docker compose
|
||||||
# with bind mounts (e.g., ".:/app"), as volume-based workspaces are not accessible
|
# with bind mounts (e.g., ".:/app"), as volume-based workspaces are not accessible
|
||||||
# from the DinD daemon's filesystem. When enabled, ensure the workspace parent
|
# from the DinD daemon's filesystem. When enabled, ensure the workspace parent
|
||||||
# directory is also mounted into the runner container and listed in valid_volumes.
|
# directory is also mounted into the runner container and listed in valid_volumes.
|
||||||
bind_workdir: false
|
#bind_workdir: false
|
||||||
# How long a job waits for a service container that declares a healthcheck to become
|
# How long a job waits for a service container that declares a healthcheck to become
|
||||||
# healthy. A negative value (e.g. -1s) starts the steps without waiting.
|
# healthy. A negative value (e.g. -1s) starts the steps without waiting.
|
||||||
service_ready_timeout: 5m
|
#service_ready_timeout: 5m
|
||||||
|
|
||||||
host:
|
host:
|
||||||
# The parent directory of a job's working directory.
|
# The parent directory of a job's working directory.
|
||||||
# If it's empty, $HOME/.cache/act/ will be used.
|
# If it's empty, $HOME/.cache/act/ will be used.
|
||||||
workdir_parent:
|
#workdir_parent:
|
||||||
|
|
||||||
# Optional local task-admission checks. Disabled by default. When enabled, low
|
# Optional local task-admission checks. Disabled by default. When enabled, low
|
||||||
# disk space or a failing script pauses new task fetching; existing jobs continue.
|
# disk space or a failing script pauses new task fetching; existing jobs continue.
|
||||||
# No health checks run while any job is active; the last result is reused until idle.
|
# No health checks run while any job is active; the last result is reused until idle.
|
||||||
health_check:
|
health_check:
|
||||||
enabled: false
|
#enabled: false
|
||||||
# Minimum free space required on the filesystem holding runner workspaces.
|
# Minimum free space required on the filesystem holding runner workspaces.
|
||||||
# Defaults to 1024 MiB when omitted or set to zero.
|
# Defaults to 1024 MiB when omitted or set to zero.
|
||||||
min_free_disk_space_mb: 1024
|
#min_free_disk_space_mb: 1024
|
||||||
# Optional additional executable. A non-zero exit, timeout, or startup failure
|
# Optional additional executable. A non-zero exit, timeout, or startup failure
|
||||||
# marks the runner unavailable.
|
# marks the runner unavailable.
|
||||||
script: ''
|
#script: ''
|
||||||
# How long a script result is cached and its maximum execution time.
|
# How long a script result is cached and its maximum execution time.
|
||||||
interval: 30s
|
#interval: 30s
|
||||||
timeout: 10s
|
#timeout: 10s
|
||||||
|
|
||||||
metrics:
|
metrics:
|
||||||
# Enable the Prometheus metrics endpoint.
|
# Enable the Prometheus metrics endpoint.
|
||||||
# When enabled, metrics are served at /metrics, liveness at /healthz, and
|
# When enabled, metrics are served at /metrics, liveness at /healthz, and
|
||||||
# task-admission readiness at /readyz.
|
# task-admission readiness at /readyz.
|
||||||
enabled: false
|
#enabled: false
|
||||||
# The address for the metrics HTTP server to listen on.
|
# The address for the metrics HTTP server to listen on.
|
||||||
# Defaults to localhost only. Set to ":9101" to allow external access,
|
# Defaults to localhost only. Set to ":9101" to allow external access,
|
||||||
# but ensure the port is firewall-protected as there is no authentication.
|
# but ensure the port is firewall-protected as there is no authentication.
|
||||||
addr: "127.0.0.1:9101"
|
#addr: "127.0.0.1:9101"
|
||||||
# Consecutive polling failures may last this long before /readyz returns 503.
|
# Consecutive polling failures may last this long before /readyz returns 503.
|
||||||
readiness_grace: 30s
|
#readiness_grace: 30s
|
||||||
|
|||||||
@@ -24,6 +24,12 @@ import (
|
|||||||
// (so a programmatically built config still gets a sane bound).
|
// (so a programmatically built config still gets a sane bound).
|
||||||
const DefaultPostTaskScriptTimeout = 5 * time.Minute
|
const DefaultPostTaskScriptTimeout = 5 * time.Minute
|
||||||
|
|
||||||
|
// Minimal is the smallest config file that runs the runner: options it does not
|
||||||
|
// name keep their default, and it names none.
|
||||||
|
const Minimal = `# Minimal config file. Every option it does not set keeps its default.
|
||||||
|
# "gitea-runner config generate" prints all options, "config set <key> <value>" sets one here.
|
||||||
|
`
|
||||||
|
|
||||||
// Log represents the configuration for logging.
|
// Log represents the configuration for logging.
|
||||||
type Log struct {
|
type Log struct {
|
||||||
Level string `yaml:"level"` // Level indicates the logging level.
|
Level string `yaml:"level"` // Level indicates the logging level.
|
||||||
|
|||||||
@@ -348,12 +348,23 @@ cache:
|
|||||||
assert.Contains(t, err.Error(), "contains no secret")
|
assert.Contains(t, err.Error(), "contains no secret")
|
||||||
}
|
}
|
||||||
|
|
||||||
// The shipped example must parse, and every key in it must be one the config knows.
|
// The shipped configs must parse, hold no key the config does not know, and leave
|
||||||
func TestLoadDefault_ExampleConfigParses(t *testing.T) {
|
// every option at its default, as all of their values are commented out.
|
||||||
|
func TestLoadDefault_ShippedConfigsChangeNothing(t *testing.T) {
|
||||||
hook := test.NewGlobal()
|
hook := test.NewGlobal()
|
||||||
defer hook.Reset()
|
defer hook.Reset()
|
||||||
|
|
||||||
_, err := LoadDefault("config.example.yaml")
|
defaults, err := LoadDefault("")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
for name, content := range map[string][]byte{"config.example.yaml": Example, "minimal.yaml": []byte(Minimal)} {
|
||||||
|
file := filepath.Join(dir, name)
|
||||||
|
require.NoError(t, os.WriteFile(file, content, 0o600))
|
||||||
|
|
||||||
|
cfg, err := LoadDefault(file)
|
||||||
|
require.NoError(t, err, name)
|
||||||
|
assert.Equal(t, defaults, cfg, name)
|
||||||
|
}
|
||||||
assert.Empty(t, hook.AllEntries())
|
assert.Empty(t, hook.AllEntries())
|
||||||
}
|
}
|
||||||
|
|||||||
+48
-14
@@ -160,6 +160,7 @@ type editSession struct {
|
|||||||
root *yaml.Node
|
root *yaml.Node
|
||||||
field *fieldInfo
|
field *fieldInfo
|
||||||
segments []string
|
segments []string
|
||||||
|
preamble []byte // text of a file that holds no YAML node, which the encoder cannot give back
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadForEdit validates the path and parses the file, so every caller fails before anything is written.
|
// loadForEdit validates the path and parses the file, so every caller fails before anything is written.
|
||||||
@@ -176,7 +177,7 @@ func loadForEdit(file, path string) (*editSession, error) {
|
|||||||
content, err := os.ReadFile(file)
|
content, err := os.ReadFile(file)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, os.ErrNotExist) {
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
return nil, fmt.Errorf("config file %q does not exist, create one with `config generate`", file)
|
return nil, fmt.Errorf("config file %q does not exist, create one with `config init`", file)
|
||||||
}
|
}
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -185,7 +186,9 @@ func loadForEdit(file, path string) (*editSession, error) {
|
|||||||
if err := yaml.Unmarshal(content, &root); err != nil {
|
if err := yaml.Unmarshal(content, &root); err != nil {
|
||||||
return nil, fmt.Errorf("parse config file %q: %w", file, err)
|
return nil, fmt.Errorf("parse config file %q: %w", file, err)
|
||||||
}
|
}
|
||||||
|
var preamble []byte
|
||||||
if root.Kind == 0 || len(root.Content) == 0 {
|
if root.Kind == 0 || len(root.Content) == 0 {
|
||||||
|
preamble = bytes.TrimSpace(content) // all the file has is comments
|
||||||
root = yaml.Node{
|
root = yaml.Node{
|
||||||
Kind: yaml.DocumentNode,
|
Kind: yaml.DocumentNode,
|
||||||
Content: []*yaml.Node{{Kind: yaml.MappingNode, Tag: "!!map"}},
|
Content: []*yaml.Node{{Kind: yaml.MappingNode, Tag: "!!map"}},
|
||||||
@@ -195,7 +198,7 @@ func loadForEdit(file, path string) (*editSession, error) {
|
|||||||
return nil, fmt.Errorf("config file %q is not a YAML mapping", file)
|
return nil, fmt.Errorf("config file %q is not a YAML mapping", file)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &editSession{file: file, path: path, original: content, root: &root, field: field, segments: segments}, nil
|
return &editSession{file: file, path: path, original: content, root: &root, field: field, segments: segments, preamble: preamble}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadSequenceEdit(file, path string, values []string) (*editSession, error) {
|
func loadSequenceEdit(file, path string, values []string) (*editSession, error) {
|
||||||
@@ -319,16 +322,28 @@ func encodeYAML(node *yaml.Node) ([]byte, error) {
|
|||||||
return buf.Bytes(), nil
|
return buf.Bytes(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// restoreBlankLines re-inserts the blank lines between top-level sections that the encoder drops.
|
// restoreLayout re-applies the spacing the encoder drops: the blank lines between
|
||||||
func restoreBlankLines(original, generated []byte) []byte {
|
// top-level sections, and the indentation of comments, which the encoder emits at the
|
||||||
|
// indentation of the node it attached them to rather than the one they were written at.
|
||||||
|
func restoreLayout(original, generated []byte) []byte {
|
||||||
|
type comment struct {
|
||||||
|
line string // as written, indentation included
|
||||||
|
trimmed string
|
||||||
|
blankBefore bool
|
||||||
|
}
|
||||||
|
|
||||||
|
var comments []comment
|
||||||
spaced := map[string]bool{}
|
spaced := map[string]bool{}
|
||||||
blank := false
|
blank := false
|
||||||
for line := range strings.Lines(string(original)) {
|
for line := range strings.Lines(string(original)) {
|
||||||
line = strings.TrimRight(line, "\r\n")
|
line = strings.TrimRight(line, "\r\n")
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
switch {
|
switch {
|
||||||
case strings.TrimSpace(line) == "":
|
case trimmed == "":
|
||||||
blank = true
|
blank = true
|
||||||
case strings.HasPrefix(line, "#"): // the block belongs to the key below it
|
case strings.HasPrefix(trimmed, "#"):
|
||||||
|
comments = append(comments, comment{line: line, trimmed: trimmed, blankBefore: blank})
|
||||||
|
blank = false
|
||||||
default:
|
default:
|
||||||
if key, ok := topLevelKey(line); ok && blank {
|
if key, ok := topLevelKey(line); ok && blank {
|
||||||
spaced[key] = true
|
spaced[key] = true
|
||||||
@@ -338,16 +353,26 @@ func restoreBlankLines(original, generated []byte) []byte {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var out []string
|
var out []string
|
||||||
|
appendBlank := func() {
|
||||||
|
if len(out) > 0 && strings.TrimSpace(out[len(out)-1]) != "" {
|
||||||
|
out = append(out, "")
|
||||||
|
}
|
||||||
|
}
|
||||||
for line := range strings.Lines(string(generated)) {
|
for line := range strings.Lines(string(generated)) {
|
||||||
line = strings.TrimRight(line, "\r\n")
|
line = strings.TrimRight(line, "\r\n")
|
||||||
if key, ok := topLevelKey(line); ok && spaced[key] {
|
trimmed := strings.TrimSpace(line)
|
||||||
start := len(out)
|
if strings.HasPrefix(trimmed, "#") {
|
||||||
for start > 0 && strings.HasPrefix(out[start-1], "#") {
|
if i := slices.IndexFunc(comments, func(c comment) bool { return c.trimmed == trimmed }); i >= 0 {
|
||||||
start--
|
if comments[i].blankBefore {
|
||||||
|
appendBlank()
|
||||||
|
} else if len(out) > 0 && strings.TrimSpace(out[len(out)-1]) == "" {
|
||||||
|
out = out[:len(out)-1] // the encoder separates a comment block it moved
|
||||||
}
|
}
|
||||||
if start > 0 && strings.TrimSpace(out[start-1]) != "" {
|
line = comments[i].line
|
||||||
out = slices.Insert(out, start, "")
|
comments = comments[i+1:] // the encoder keeps their order, so earlier ones cannot match again
|
||||||
}
|
}
|
||||||
|
} else if key, ok := topLevelKey(line); ok && spaced[key] {
|
||||||
|
appendBlank()
|
||||||
}
|
}
|
||||||
out = append(out, line)
|
out = append(out, line)
|
||||||
}
|
}
|
||||||
@@ -377,12 +402,21 @@ func (s *editSession) write() error {
|
|||||||
return fmt.Errorf("the edit would produce a config the runner cannot load: %w", err)
|
return fmt.Errorf("the edit would produce a config the runner cannot load: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
content := restoreBlankLines(s.original, generated)
|
if len(s.preamble) > 0 { // before restoreLayout, so that it spaces the preamble too
|
||||||
|
generated = slices.Concat(s.preamble, []byte("\n"), generated)
|
||||||
|
}
|
||||||
|
|
||||||
|
content := restoreLayout(s.original, generated)
|
||||||
if bytes.Contains(s.original, []byte("\r\n")) { // the encoder only ever emits LF
|
if bytes.Contains(s.original, []byte("\r\n")) { // the encoder only ever emits LF
|
||||||
content = bytes.ReplaceAll(content, []byte("\n"), []byte("\r\n"))
|
content = bytes.ReplaceAll(content, []byte("\n"), []byte("\r\n"))
|
||||||
}
|
}
|
||||||
|
|
||||||
file := s.file
|
return WriteFile(s.file, content)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteFile replaces the config file in one step, keeping the mode and owner of the
|
||||||
|
// file it replaces, so a half-written config never reaches a runner reading it.
|
||||||
|
func WriteFile(file string, content []byte) error {
|
||||||
if resolved, err := filepath.EvalSymlinks(file); err == nil {
|
if resolved, err := filepath.EvalSymlinks(file); err == nil {
|
||||||
file = resolved // keeps a config linked in from elsewhere intact
|
file = resolved // keeps a config linked in from elsewhere intact
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -252,16 +252,39 @@ func TestEditValuesFileHandling(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// The example config is the file users edit, so it has to stay written the way
|
// An edit has to give the file back unchanged around it, down to the indentation of
|
||||||
// the encoder emits it, down to the single space before a trailing comment.
|
// a commented-out option, as that documentation is what the user reads and edits.
|
||||||
func TestEditValuesKeepsExampleConfigIntact(t *testing.T) {
|
func TestEditValuesPreservesFileText(t *testing.T) {
|
||||||
file := filepath.Join(t.TempDir(), "config.yaml")
|
tests := []struct {
|
||||||
require.NoError(t, os.WriteFile(file, Example, 0o600))
|
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",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
require.NoError(t, AddValue(file, "runner.labels", "ubuntu:docker://node:22"))
|
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)
|
content, err := os.ReadFile(file)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
withoutAdded := strings.Replace(string(content), " - ubuntu:docker://node:22\n", "", 1)
|
assert.Equal(t, string(tc.content), strings.Replace(string(content), tc.added, "", 1))
|
||||||
assert.Equal(t, string(Example), withoutAdded, "only the appended label may differ")
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user