mirror of
https://gitea.com/gitea/act_runner.git
synced 2026-08-07 17:34:22 +02:00
Compare commits
8 Commits
68547886a5
...
refactor/s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
39a54e0013 | ||
|
|
573e811492 | ||
|
|
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`;
|
||||||
|
|||||||
5
Makefile
5
Makefile
@@ -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
|
||||||
|
|||||||
44
README.md
44
README.md
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -1,60 +0,0 @@
|
|||||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
|
||||||
// Copyright 2020 The nektos/act Authors. All rights reserved.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package common
|
|
||||||
|
|
||||||
import "slices"
|
|
||||||
|
|
||||||
// CartesianProduct takes map of lists and returns list of unique tuples
|
|
||||||
func CartesianProduct(mapOfLists map[string][]any) []map[string]any {
|
|
||||||
listNames := make([]string, 0)
|
|
||||||
lists := make([][]any, 0)
|
|
||||||
for k, v := range mapOfLists {
|
|
||||||
listNames = append(listNames, k)
|
|
||||||
lists = append(lists, v)
|
|
||||||
}
|
|
||||||
|
|
||||||
listCart := cartN(lists...)
|
|
||||||
|
|
||||||
rtn := make([]map[string]any, 0)
|
|
||||||
for _, list := range listCart {
|
|
||||||
vMap := make(map[string]any)
|
|
||||||
for i, v := range list {
|
|
||||||
vMap[listNames[i]] = v
|
|
||||||
}
|
|
||||||
rtn = append(rtn, vMap)
|
|
||||||
}
|
|
||||||
return rtn
|
|
||||||
}
|
|
||||||
|
|
||||||
func cartN(a ...[]any) [][]any {
|
|
||||||
c := 1
|
|
||||||
for _, a := range a {
|
|
||||||
c *= len(a)
|
|
||||||
}
|
|
||||||
if c == 0 || len(a) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
p := make([][]any, c)
|
|
||||||
b := make([]any, c*len(a))
|
|
||||||
n := make([]int, len(a))
|
|
||||||
s := 0
|
|
||||||
for i := range p {
|
|
||||||
e := s + len(a)
|
|
||||||
pi := b[s:e]
|
|
||||||
p[i] = pi
|
|
||||||
s = e
|
|
||||||
for j, n := range n {
|
|
||||||
pi[j] = a[j][n]
|
|
||||||
}
|
|
||||||
for j := range slices.Backward(n) {
|
|
||||||
n[j]++
|
|
||||||
if n[j] < len(a[j]) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
n[j] = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return p
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
|
||||||
// Copyright 2020 The nektos/act Authors. All rights reserved.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package common
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestCartesianProduct(t *testing.T) {
|
|
||||||
assert := assert.New(t)
|
|
||||||
input := map[string][]any{
|
|
||||||
"foo": {1, 2, 3, 4},
|
|
||||||
"bar": {"a", "b", "c"},
|
|
||||||
"baz": {false, true},
|
|
||||||
}
|
|
||||||
|
|
||||||
output := CartesianProduct(input)
|
|
||||||
assert.Len(output, 24)
|
|
||||||
|
|
||||||
for _, v := range output {
|
|
||||||
assert.Len(v, 3)
|
|
||||||
|
|
||||||
assert.Contains(v, "foo")
|
|
||||||
assert.Contains(v, "bar")
|
|
||||||
assert.Contains(v, "baz")
|
|
||||||
}
|
|
||||||
|
|
||||||
input = map[string][]any{
|
|
||||||
"foo": {1, 2, 3, 4},
|
|
||||||
"bar": {},
|
|
||||||
"baz": {false, true},
|
|
||||||
}
|
|
||||||
output = CartesianProduct(input)
|
|
||||||
assert.Empty(output)
|
|
||||||
|
|
||||||
input = map[string][]any{}
|
|
||||||
output = CartesianProduct(input)
|
|
||||||
assert.Empty(output)
|
|
||||||
}
|
|
||||||
@@ -1,309 +0,0 @@
|
|||||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
|
||||||
// Copyright 2022 The nektos/act Authors. All rights reserved.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package exprparser
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/hex"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"io/fs"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"reflect"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/model"
|
|
||||||
|
|
||||||
"github.com/go-git/go-git/v5/plumbing/format/gitignore"
|
|
||||||
"github.com/rhysd/actionlint"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (impl *interperterImpl) contains(search, item reflect.Value) (bool, error) {
|
|
||||||
switch search.Kind() {
|
|
||||||
case reflect.String, reflect.Int, reflect.Float64, reflect.Bool, reflect.Invalid:
|
|
||||||
return strings.Contains(
|
|
||||||
strings.ToLower(CoerceToString(search)),
|
|
||||||
strings.ToLower(CoerceToString(item)),
|
|
||||||
), nil
|
|
||||||
|
|
||||||
case reflect.Slice:
|
|
||||||
for i := 0; i < search.Len(); i++ {
|
|
||||||
arrayItem := search.Index(i).Elem()
|
|
||||||
result, err := impl.compareValues(arrayItem, item, actionlint.CompareOpNodeKindEq)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if isEqual, ok := result.(bool); ok && isEqual {
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (impl *interperterImpl) startsWith(searchString, searchValue reflect.Value) (bool, error) { //nolint:unparam // pre-existing issue from nektos/act
|
|
||||||
return strings.HasPrefix(
|
|
||||||
strings.ToLower(CoerceToString(searchString)),
|
|
||||||
strings.ToLower(CoerceToString(searchValue)),
|
|
||||||
), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (impl *interperterImpl) endsWith(searchString, searchValue reflect.Value) (bool, error) { //nolint:unparam // pre-existing issue from nektos/act
|
|
||||||
return strings.HasSuffix(
|
|
||||||
strings.ToLower(CoerceToString(searchString)),
|
|
||||||
strings.ToLower(CoerceToString(searchValue)),
|
|
||||||
), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
const (
|
|
||||||
passThrough = iota
|
|
||||||
bracketOpen
|
|
||||||
bracketClose
|
|
||||||
)
|
|
||||||
|
|
||||||
func (impl *interperterImpl) format(str reflect.Value, replaceValue ...reflect.Value) (string, error) {
|
|
||||||
input := CoerceToString(str)
|
|
||||||
var output strings.Builder
|
|
||||||
replacementIndex := ""
|
|
||||||
|
|
||||||
state := passThrough
|
|
||||||
for _, character := range input {
|
|
||||||
switch state {
|
|
||||||
case passThrough: // normal buffer output
|
|
||||||
switch character {
|
|
||||||
case '{':
|
|
||||||
state = bracketOpen
|
|
||||||
|
|
||||||
case '}':
|
|
||||||
state = bracketClose
|
|
||||||
|
|
||||||
default:
|
|
||||||
output.WriteRune(character)
|
|
||||||
}
|
|
||||||
|
|
||||||
case bracketOpen: // found {
|
|
||||||
switch character {
|
|
||||||
case '{':
|
|
||||||
output.WriteString("{")
|
|
||||||
replacementIndex = ""
|
|
||||||
state = passThrough
|
|
||||||
|
|
||||||
case '}':
|
|
||||||
index, err := strconv.ParseInt(replacementIndex, 10, 32)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("The following format string is invalid: '%s'", input)
|
|
||||||
}
|
|
||||||
|
|
||||||
replacementIndex = ""
|
|
||||||
|
|
||||||
if len(replaceValue) <= int(index) {
|
|
||||||
return "", fmt.Errorf("The following format string references more arguments than were supplied: '%s'", input)
|
|
||||||
}
|
|
||||||
|
|
||||||
output.WriteString(CoerceToString(replaceValue[index]))
|
|
||||||
|
|
||||||
state = passThrough
|
|
||||||
|
|
||||||
default:
|
|
||||||
replacementIndex += string(character)
|
|
||||||
}
|
|
||||||
|
|
||||||
case bracketClose: // found }
|
|
||||||
switch character {
|
|
||||||
case '}':
|
|
||||||
output.WriteString("}")
|
|
||||||
replacementIndex = ""
|
|
||||||
state = passThrough
|
|
||||||
|
|
||||||
default:
|
|
||||||
return "", fmt.Errorf("Closing bracket without opening one. The following format string is invalid: '%s'", input)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if state != passThrough {
|
|
||||||
switch state {
|
|
||||||
case bracketOpen:
|
|
||||||
return "", fmt.Errorf("Unclosed brackets. The following format string is invalid: '%s'", input)
|
|
||||||
|
|
||||||
case bracketClose:
|
|
||||||
return "", fmt.Errorf("Closing bracket without opening one. The following format string is invalid: '%s'", input)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return output.String(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (impl *interperterImpl) join(array, sep reflect.Value) (string, error) { //nolint:unparam // pre-existing issue from nektos/act
|
|
||||||
separator := CoerceToString(sep)
|
|
||||||
switch array.Kind() {
|
|
||||||
case reflect.Slice:
|
|
||||||
var items []string
|
|
||||||
for i := 0; i < array.Len(); i++ {
|
|
||||||
items = append(items, CoerceToString(array.Index(i)))
|
|
||||||
}
|
|
||||||
|
|
||||||
return strings.Join(items, separator), nil
|
|
||||||
default:
|
|
||||||
return strings.Join([]string{CoerceToString(array)}, separator), nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (impl *interperterImpl) toJSON(value reflect.Value) (string, error) {
|
|
||||||
if value.Kind() == reflect.Invalid {
|
|
||||||
return "null", nil
|
|
||||||
}
|
|
||||||
|
|
||||||
json, err := json.MarshalIndent(value.Interface(), "", " ")
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("Cannot convert value to JSON. Cause: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return string(json), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (impl *interperterImpl) fromJSON(value reflect.Value) (any, error) {
|
|
||||||
if value.Kind() != reflect.String {
|
|
||||||
return nil, fmt.Errorf("Cannot parse non-string type %v as JSON", value.Kind())
|
|
||||||
}
|
|
||||||
|
|
||||||
var data any
|
|
||||||
|
|
||||||
err := json.Unmarshal([]byte(value.String()), &data)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("Invalid JSON: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return data, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (impl *interperterImpl) hashFiles(paths ...reflect.Value) (string, error) {
|
|
||||||
var ps []gitignore.Pattern
|
|
||||||
|
|
||||||
const cwdPrefix = "." + string(filepath.Separator)
|
|
||||||
const excludeCwdPrefix = "!" + cwdPrefix
|
|
||||||
for _, path := range paths {
|
|
||||||
if path.Kind() == reflect.String {
|
|
||||||
cleanPath := path.String()
|
|
||||||
if strings.HasPrefix(cleanPath, cwdPrefix) {
|
|
||||||
cleanPath = cleanPath[len(cwdPrefix):]
|
|
||||||
} else if strings.HasPrefix(cleanPath, excludeCwdPrefix) {
|
|
||||||
cleanPath = "!" + cleanPath[len(excludeCwdPrefix):]
|
|
||||||
}
|
|
||||||
ps = append(ps, gitignore.ParsePattern(cleanPath, nil))
|
|
||||||
} else {
|
|
||||||
return "", errors.New("Non-string path passed to hashFiles")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
matcher := gitignore.NewMatcher(ps)
|
|
||||||
|
|
||||||
var files []string
|
|
||||||
if err := filepath.Walk(impl.config.WorkingDir, func(path string, fi fs.FileInfo, err error) error {
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
sansPrefix := strings.TrimPrefix(path, impl.config.WorkingDir+string(filepath.Separator))
|
|
||||||
parts := strings.Split(sansPrefix, string(filepath.Separator))
|
|
||||||
if fi.IsDir() || !matcher.Match(parts, fi.IsDir()) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
files = append(files, path)
|
|
||||||
return nil
|
|
||||||
}); err != nil {
|
|
||||||
return "", fmt.Errorf("Unable to filepath.Walk: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(files) == 0 {
|
|
||||||
return "", nil
|
|
||||||
}
|
|
||||||
|
|
||||||
hasher := sha256.New()
|
|
||||||
|
|
||||||
for _, file := range files {
|
|
||||||
f, err := os.Open(file)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("Unable to os.Open: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := io.Copy(hasher, f); err != nil {
|
|
||||||
return "", fmt.Errorf("Unable to io.Copy: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := f.Close(); err != nil {
|
|
||||||
return "", fmt.Errorf("Unable to Close file: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return hex.EncodeToString(hasher.Sum(nil)), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (impl *interperterImpl) getNeedsTransitive(job *model.Job) []string {
|
|
||||||
needs := job.Needs()
|
|
||||||
|
|
||||||
for _, need := range needs {
|
|
||||||
parentNeeds := impl.getNeedsTransitive(impl.config.Run.Workflow.GetJob(need))
|
|
||||||
needs = append(needs, parentNeeds...)
|
|
||||||
}
|
|
||||||
|
|
||||||
return needs
|
|
||||||
}
|
|
||||||
|
|
||||||
func (impl *interperterImpl) always() (bool, error) {
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (impl *interperterImpl) jobSuccess() (bool, error) { //nolint:unparam // pre-existing issue from nektos/act
|
|
||||||
jobs := impl.config.Run.Workflow.Jobs
|
|
||||||
jobNeeds := impl.getNeedsTransitive(impl.config.Run.Job())
|
|
||||||
|
|
||||||
for _, needs := range jobNeeds {
|
|
||||||
if jobs[needs].NeedsResult() != "success" {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// jobStatus returns the current job status, treating a nil Job context as an
|
|
||||||
// empty status so status-check functions never panic on a nil dereference.
|
|
||||||
func (impl *interperterImpl) jobStatus() string {
|
|
||||||
if impl.env.Job == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return impl.env.Job.Status
|
|
||||||
}
|
|
||||||
|
|
||||||
func (impl *interperterImpl) stepSuccess() (bool, error) { //nolint:unparam // pre-existing issue from nektos/act
|
|
||||||
return impl.jobStatus() == "success", nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (impl *interperterImpl) jobFailure() (bool, error) { //nolint:unparam // pre-existing issue from nektos/act
|
|
||||||
jobs := impl.config.Run.Workflow.Jobs
|
|
||||||
jobNeeds := impl.getNeedsTransitive(impl.config.Run.Job())
|
|
||||||
|
|
||||||
for _, needs := range jobNeeds {
|
|
||||||
if jobs[needs].NeedsResult() == "failure" {
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (impl *interperterImpl) stepFailure() (bool, error) { //nolint:unparam // pre-existing issue from nektos/act
|
|
||||||
return impl.jobStatus() == "failure", nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (impl *interperterImpl) cancelled() (bool, error) { //nolint:unparam // pre-existing issue from nektos/act
|
|
||||||
return impl.jobStatus() == "cancelled", nil
|
|
||||||
}
|
|
||||||
@@ -1,283 +0,0 @@
|
|||||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
|
||||||
// Copyright 2022 The nektos/act Authors. All rights reserved.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package exprparser
|
|
||||||
|
|
||||||
import (
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/model"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestFunctionContains(t *testing.T) {
|
|
||||||
table := []struct {
|
|
||||||
input string
|
|
||||||
expected any
|
|
||||||
name string
|
|
||||||
}{
|
|
||||||
{"contains('search', 'item') }}", false, "contains-str-str"},
|
|
||||||
{`cOnTaInS('Hello', 'll') }}`, true, "contains-str-casing"},
|
|
||||||
{`contains('HELLO', 'll') }}`, true, "contains-str-casing"},
|
|
||||||
{`contains('3.141592', 3.14) }}`, true, "contains-str-number"},
|
|
||||||
{`contains(3.141592, '3.14') }}`, true, "contains-number-str"},
|
|
||||||
{`contains(3.141592, 3.14) }}`, true, "contains-number-number"},
|
|
||||||
{`contains(true, 'u') }}`, true, "contains-bool-str"},
|
|
||||||
{`contains(null, '') }}`, true, "contains-null-str"},
|
|
||||||
{`contains(fromJSON('["first","second"]'), 'first') }}`, true, "contains-item"},
|
|
||||||
{`contains(fromJSON('[null,"second"]'), '') }}`, true, "contains-item-null-empty-str"},
|
|
||||||
{`contains(fromJSON('["","second"]'), null) }}`, true, "contains-item-empty-str-null"},
|
|
||||||
{`contains(fromJSON('[true,"second"]'), 'true') }}`, false, "contains-item-bool-arr"},
|
|
||||||
{`contains(fromJSON('["true","second"]'), true) }}`, false, "contains-item-str-bool"},
|
|
||||||
{`contains(fromJSON('[3.14,"second"]'), '3.14') }}`, true, "contains-item-number-str"},
|
|
||||||
{`contains(fromJSON('[3.14,"second"]'), 3.14) }}`, true, "contains-item-number-number"},
|
|
||||||
{`contains(fromJSON('["","second"]'), fromJSON('[]')) }}`, false, "contains-item-str-arr"},
|
|
||||||
{`contains(fromJSON('["","second"]'), fromJSON('{}')) }}`, false, "contains-item-str-obj"},
|
|
||||||
}
|
|
||||||
|
|
||||||
env := &EvaluationEnvironment{}
|
|
||||||
|
|
||||||
for _, tt := range table {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
|
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
|
|
||||||
assert.Equal(t, tt.expected, output)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFunctionStartsWith(t *testing.T) {
|
|
||||||
table := []struct {
|
|
||||||
input string
|
|
||||||
expected any
|
|
||||||
name string
|
|
||||||
}{
|
|
||||||
{"startsWith('search', 'se') }}", true, "startswith-string"},
|
|
||||||
{"startsWith('search', 'sa') }}", false, "startswith-string"},
|
|
||||||
{"startsWith('123search', '123s') }}", true, "startswith-string"},
|
|
||||||
{"startsWith(123, 's') }}", false, "startswith-string"},
|
|
||||||
{"startsWith(123, '12') }}", true, "startswith-string"},
|
|
||||||
{"startsWith('123', 12) }}", true, "startswith-string"},
|
|
||||||
{"startsWith(null, '42') }}", false, "startswith-string"},
|
|
||||||
{"startsWith('null', null) }}", true, "startswith-string"},
|
|
||||||
{"startsWith('null', '') }}", true, "startswith-string"},
|
|
||||||
}
|
|
||||||
|
|
||||||
env := &EvaluationEnvironment{}
|
|
||||||
|
|
||||||
for _, tt := range table {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
|
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
|
|
||||||
assert.Equal(t, tt.expected, output)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFunctionEndsWith(t *testing.T) {
|
|
||||||
table := []struct {
|
|
||||||
input string
|
|
||||||
expected any
|
|
||||||
name string
|
|
||||||
}{
|
|
||||||
{"endsWith('search', 'ch') }}", true, "endsWith-string"},
|
|
||||||
{"endsWith('search', 'sa') }}", false, "endsWith-string"},
|
|
||||||
{"endsWith('search123s', '123s') }}", true, "endsWith-string"},
|
|
||||||
{"endsWith(123, 's') }}", false, "endsWith-string"},
|
|
||||||
{"endsWith(123, '23') }}", true, "endsWith-string"},
|
|
||||||
{"endsWith('123', 23) }}", true, "endsWith-string"},
|
|
||||||
{"endsWith(null, '42') }}", false, "endsWith-string"},
|
|
||||||
{"endsWith('null', null) }}", true, "endsWith-string"},
|
|
||||||
{"endsWith('null', '') }}", true, "endsWith-string"},
|
|
||||||
}
|
|
||||||
|
|
||||||
env := &EvaluationEnvironment{}
|
|
||||||
|
|
||||||
for _, tt := range table {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
|
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
|
|
||||||
assert.Equal(t, tt.expected, output)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFunctionJoin(t *testing.T) {
|
|
||||||
table := []struct {
|
|
||||||
input string
|
|
||||||
expected any
|
|
||||||
name string
|
|
||||||
}{
|
|
||||||
{"join(fromJSON('[\"a\", \"b\"]'), ',')", "a,b", "join-arr"},
|
|
||||||
{"join('string', ',')", "string", "join-str"},
|
|
||||||
{"join(1, ',')", "1", "join-number"},
|
|
||||||
{"join(null, ',')", "", "join-number"},
|
|
||||||
{"join(fromJSON('[\"a\", \"b\", null]'), null)", "ab", "join-number"},
|
|
||||||
{"join(fromJSON('[\"a\", \"b\"]'))", "a,b", "join-number"},
|
|
||||||
{"join(fromJSON('[\"a\", \"b\", null]'), 1)", "a1b1", "join-number"},
|
|
||||||
{"join(fromJSON('[1, true, null]'), '-')", "1-true-", "join-mixed-types"},
|
|
||||||
}
|
|
||||||
|
|
||||||
env := &EvaluationEnvironment{}
|
|
||||||
|
|
||||||
for _, tt := range table {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
|
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
|
|
||||||
assert.Equal(t, tt.expected, output)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFunctionToJSON(t *testing.T) {
|
|
||||||
table := []struct {
|
|
||||||
input string
|
|
||||||
expected any
|
|
||||||
name string
|
|
||||||
}{
|
|
||||||
{"toJSON(env) }}", "{\n \"key\": \"value\"\n}", "toJSON"},
|
|
||||||
{"toJSON(null)", "null", "toJSON-null"},
|
|
||||||
}
|
|
||||||
|
|
||||||
env := &EvaluationEnvironment{
|
|
||||||
Env: map[string]string{
|
|
||||||
"key": "value",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range table {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
|
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
|
|
||||||
assert.Equal(t, tt.expected, output)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFunctionFromJSON(t *testing.T) {
|
|
||||||
table := []struct {
|
|
||||||
input string
|
|
||||||
expected any
|
|
||||||
name string
|
|
||||||
}{
|
|
||||||
{"fromJSON('{\"foo\":\"bar\"}') }}", map[string]any{
|
|
||||||
"foo": "bar",
|
|
||||||
}, "fromJSON"},
|
|
||||||
}
|
|
||||||
|
|
||||||
env := &EvaluationEnvironment{}
|
|
||||||
|
|
||||||
for _, tt := range table {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
|
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
|
|
||||||
assert.Equal(t, tt.expected, output)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFunctionHashFiles(t *testing.T) {
|
|
||||||
table := []struct {
|
|
||||||
input string
|
|
||||||
expected any
|
|
||||||
name string
|
|
||||||
}{
|
|
||||||
{"hashFiles('**/non-extant-files') }}", "", "hash-non-existing-file"},
|
|
||||||
{"hashFiles('**/non-extant-files', '**/more-non-extant-files') }}", "", "hash-multiple-non-existing-files"},
|
|
||||||
{"hashFiles('./for-hashing-1.txt') }}", "66a045b452102c59d840ec097d59d9467e13a3f34f6494e539ffd32c1bb35f18", "hash-single-file"},
|
|
||||||
{"hashFiles('./for-hashing-*.txt') }}", "8e5935e7e13368cd9688fe8f48a0955293676a021562582c7e848dafe13fb046", "hash-multiple-files"},
|
|
||||||
{"hashFiles('./for-hashing-*.txt', '!./for-hashing-2.txt') }}", "66a045b452102c59d840ec097d59d9467e13a3f34f6494e539ffd32c1bb35f18", "hash-negative-pattern"},
|
|
||||||
{"hashFiles('./for-hashing-**') }}", "c418ba693753c84115ced0da77f876cddc662b9054f4b129b90f822597ee2f94", "hash-multiple-files-and-directories"},
|
|
||||||
{"hashFiles('./for-hashing-3/**') }}", "6f5696b546a7a9d6d42a449dc9a56bef244aaa826601ef27466168846139d2c2", "hash-nested-directories"},
|
|
||||||
{"hashFiles('./for-hashing-3/**/nested-data.txt') }}", "8ecadfb49f7f978d0a9f3a957e9c8da6cc9ab871f5203b5d9f9d1dc87d8af18c", "hash-nested-directories-2"},
|
|
||||||
}
|
|
||||||
|
|
||||||
env := &EvaluationEnvironment{}
|
|
||||||
|
|
||||||
for _, tt := range table {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
workdir, err := filepath.Abs("testdata")
|
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
output, err := NewInterpeter(env, Config{WorkingDir: workdir}).Evaluate(tt.input, DefaultStatusCheckNone)
|
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
|
|
||||||
assert.Equal(t, tt.expected, output)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFunctionFormat(t *testing.T) {
|
|
||||||
table := []struct {
|
|
||||||
input string
|
|
||||||
expected any
|
|
||||||
error any
|
|
||||||
name string
|
|
||||||
}{
|
|
||||||
{"format('text')", "text", nil, "format-plain-string"},
|
|
||||||
{"format('Hello {0} {1} {2}!', 'Mona', 'the', 'Octocat')", "Hello Mona the Octocat!", nil, "format-with-placeholders"},
|
|
||||||
{"format('{{Hello {0} {1} {2}!}}', 'Mona', 'the', 'Octocat')", "{Hello Mona the Octocat!}", nil, "format-with-escaped-braces"},
|
|
||||||
{"format('{{0}}', 'test')", "{0}", nil, "format-with-escaped-braces"},
|
|
||||||
{"format('{{{0}}}', 'test')", "{test}", nil, "format-with-escaped-braces-and-value"},
|
|
||||||
{"format('}}')", "}", nil, "format-output-closing-brace"},
|
|
||||||
{`format('Hello "{0}" {1} {2} {3} {4}', null, true, -3.14, NaN, Infinity)`, `Hello "" true -3.14 NaN Infinity`, nil, "format-with-primitives"},
|
|
||||||
{`format('Hello "{0}" {1} {2}', fromJSON('[0, true, "abc"]'), fromJSON('[{"a":1}]'), fromJSON('{"a":{"b":1}}'))`, `Hello "Array" Array Object`, nil, "format-with-complex-types"},
|
|
||||||
{"format(true)", "true", nil, "format-with-primitive-args"},
|
|
||||||
{"format('{0}', github)", "Object", nil, "format-with-context"},
|
|
||||||
{"format('echo Hello {0} ${{Test}}', github.undefined_property)", "echo Hello ${Test}", nil, "format-with-undefined-value"},
|
|
||||||
{"format('{0}}', '{1}', 'World')", nil, "Closing bracket without opening one. The following format string is invalid: '{0}}'", "format-invalid-format-string"},
|
|
||||||
{"format('a}b')", nil, "Closing bracket without opening one. The following format string is invalid: 'a}b'", "format-unmatched-closing-brace"},
|
|
||||||
{"format('{0', '{1}', 'World')", nil, "Unclosed brackets. The following format string is invalid: '{0'", "format-invalid-format-string"},
|
|
||||||
{"format('{2}', '{1}', 'World')", "", "The following format string references more arguments than were supplied: '{2}'", "format-invalid-replacement-reference"},
|
|
||||||
{"format('{2147483648}')", "", "The following format string is invalid: '{2147483648}'", "format-invalid-replacement-reference"},
|
|
||||||
{"format('{0} {1} {2} {3}', 1.0, 1.1, 1234567890.0, 12345678901234567890.0)", "1 1.1 1234567890 1.23456789012346E+19", nil, "format-floats"},
|
|
||||||
}
|
|
||||||
|
|
||||||
env := &EvaluationEnvironment{
|
|
||||||
Github: &model.GithubContext{},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range table {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
|
|
||||||
if tt.error != nil {
|
|
||||||
assert.Equal(t, tt.error, err.Error())
|
|
||||||
} else {
|
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
assert.Equal(t, tt.expected, output)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStatusFunctionsNilJob(t *testing.T) {
|
|
||||||
// A nil Job context must not panic: the status-check functions should treat
|
|
||||||
// it as an empty status and return false rather than dereferencing nil.
|
|
||||||
env := &EvaluationEnvironment{}
|
|
||||||
|
|
||||||
table := []struct {
|
|
||||||
input string
|
|
||||||
context string
|
|
||||||
name string
|
|
||||||
}{
|
|
||||||
{"cancelled()", "job", "cancelled-nil-job"},
|
|
||||||
{"success()", "step", "step-success-nil-job"},
|
|
||||||
{"failure()", "step", "step-failure-nil-job"},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range table {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
output, err := NewInterpeter(env, Config{Context: tt.context}).Evaluate(tt.input, DefaultStatusCheckNone)
|
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
assert.Equal(t, false, output)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,658 +0,0 @@
|
|||||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
|
||||||
// Copyright 2022 The nektos/act Authors. All rights reserved.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package exprparser
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"math"
|
|
||||||
"reflect"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/model"
|
|
||||||
|
|
||||||
"github.com/rhysd/actionlint"
|
|
||||||
)
|
|
||||||
|
|
||||||
type EvaluationEnvironment struct {
|
|
||||||
Github *model.GithubContext
|
|
||||||
Env map[string]string
|
|
||||||
Job *model.JobContext
|
|
||||||
Jobs *map[string]*model.WorkflowCallResult
|
|
||||||
Steps map[string]*model.StepResult
|
|
||||||
Runner map[string]any
|
|
||||||
Secrets map[string]string
|
|
||||||
Vars map[string]string
|
|
||||||
Strategy map[string]any
|
|
||||||
Matrix map[string]any
|
|
||||||
Needs map[string]Needs
|
|
||||||
Inputs map[string]any
|
|
||||||
HashFiles func([]reflect.Value) (any, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
type Needs struct {
|
|
||||||
Outputs map[string]string `json:"outputs"`
|
|
||||||
Result string `json:"result"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type Config struct {
|
|
||||||
Run *model.Run
|
|
||||||
WorkingDir string
|
|
||||||
Context string
|
|
||||||
}
|
|
||||||
|
|
||||||
type DefaultStatusCheck int
|
|
||||||
|
|
||||||
const (
|
|
||||||
DefaultStatusCheckNone DefaultStatusCheck = iota
|
|
||||||
DefaultStatusCheckSuccess
|
|
||||||
DefaultStatusCheckAlways
|
|
||||||
DefaultStatusCheckCanceled
|
|
||||||
DefaultStatusCheckFailure
|
|
||||||
)
|
|
||||||
|
|
||||||
func (dsc DefaultStatusCheck) String() string {
|
|
||||||
switch dsc {
|
|
||||||
case DefaultStatusCheckSuccess:
|
|
||||||
return "success"
|
|
||||||
case DefaultStatusCheckAlways:
|
|
||||||
return "always"
|
|
||||||
case DefaultStatusCheckCanceled:
|
|
||||||
return "cancelled"
|
|
||||||
case DefaultStatusCheckFailure:
|
|
||||||
return "failure"
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
type Interpreter interface {
|
|
||||||
Evaluate(input string, defaultStatusCheck DefaultStatusCheck) (any, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
type interperterImpl struct {
|
|
||||||
env *EvaluationEnvironment
|
|
||||||
config Config
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewInterpeter(env *EvaluationEnvironment, config Config) Interpreter {
|
|
||||||
return &interperterImpl{
|
|
||||||
env: env,
|
|
||||||
config: config,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (impl *interperterImpl) Evaluate(input string, defaultStatusCheck DefaultStatusCheck) (any, error) {
|
|
||||||
input = strings.TrimPrefix(input, "${{")
|
|
||||||
if defaultStatusCheck != DefaultStatusCheckNone && input == "" {
|
|
||||||
input = "success()"
|
|
||||||
}
|
|
||||||
parser := actionlint.NewExprParser()
|
|
||||||
exprNode, err := parser.Parse(actionlint.NewExprLexer(input + "}}"))
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("Failed to parse: %s", err.Message)
|
|
||||||
}
|
|
||||||
|
|
||||||
if defaultStatusCheck != DefaultStatusCheckNone {
|
|
||||||
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{
|
|
||||||
Kind: actionlint.LogicalOpNodeKindAnd,
|
|
||||||
Left: &actionlint.FuncCallNode{
|
|
||||||
Callee: defaultStatusCheck.String(),
|
|
||||||
Args: []actionlint.ExprNode{},
|
|
||||||
},
|
|
||||||
Right: exprNode,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err2 := impl.evaluateNode(exprNode)
|
|
||||||
|
|
||||||
return result, err2
|
|
||||||
}
|
|
||||||
|
|
||||||
func (impl *interperterImpl) evaluateNode(exprNode actionlint.ExprNode) (any, error) {
|
|
||||||
switch node := exprNode.(type) {
|
|
||||||
case *actionlint.VariableNode:
|
|
||||||
return impl.evaluateVariable(node)
|
|
||||||
case *actionlint.BoolNode:
|
|
||||||
return node.Value, nil
|
|
||||||
case *actionlint.NullNode:
|
|
||||||
return nil, nil //nolint:nilnil // pre-existing issue from nektos/act
|
|
||||||
case *actionlint.IntNode:
|
|
||||||
return node.Value, nil
|
|
||||||
case *actionlint.FloatNode:
|
|
||||||
return node.Value, nil
|
|
||||||
case *actionlint.StringNode:
|
|
||||||
return node.Value, nil
|
|
||||||
case *actionlint.IndexAccessNode:
|
|
||||||
return impl.evaluateIndexAccess(node)
|
|
||||||
case *actionlint.ObjectDerefNode:
|
|
||||||
return impl.evaluateObjectDeref(node)
|
|
||||||
case *actionlint.ArrayDerefNode:
|
|
||||||
return impl.evaluateArrayDeref(node)
|
|
||||||
case *actionlint.NotOpNode:
|
|
||||||
return impl.evaluateNot(node)
|
|
||||||
case *actionlint.CompareOpNode:
|
|
||||||
return impl.evaluateCompare(node)
|
|
||||||
case *actionlint.LogicalOpNode:
|
|
||||||
return impl.evaluateLogicalCompare(node)
|
|
||||||
case *actionlint.FuncCallNode:
|
|
||||||
return impl.evaluateFuncCall(node)
|
|
||||||
default:
|
|
||||||
return nil, fmt.Errorf("Fatal error! Unknown node type: %s node: %+v", reflect.TypeOf(exprNode), exprNode)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (impl *interperterImpl) evaluateVariable(variableNode *actionlint.VariableNode) (any, error) {
|
|
||||||
switch strings.ToLower(variableNode.Name) {
|
|
||||||
case "github":
|
|
||||||
return impl.env.Github, nil
|
|
||||||
case "gitea": // compatible with Gitea
|
|
||||||
return impl.env.Github, nil
|
|
||||||
case "env":
|
|
||||||
return impl.env.Env, nil
|
|
||||||
case "job":
|
|
||||||
return impl.env.Job, nil
|
|
||||||
case "jobs":
|
|
||||||
if impl.env.Jobs == nil {
|
|
||||||
return nil, errors.New("Unavailable context: jobs")
|
|
||||||
}
|
|
||||||
return impl.env.Jobs, nil
|
|
||||||
case "steps":
|
|
||||||
return impl.env.Steps, nil
|
|
||||||
case "runner":
|
|
||||||
return impl.env.Runner, nil
|
|
||||||
case "secrets":
|
|
||||||
return impl.env.Secrets, nil
|
|
||||||
case "vars":
|
|
||||||
return impl.env.Vars, nil
|
|
||||||
case "strategy":
|
|
||||||
return impl.env.Strategy, nil
|
|
||||||
case "matrix":
|
|
||||||
return impl.env.Matrix, nil
|
|
||||||
case "needs":
|
|
||||||
return impl.env.Needs, nil
|
|
||||||
case "inputs":
|
|
||||||
return impl.env.Inputs, nil
|
|
||||||
case "infinity":
|
|
||||||
return math.Inf(1), nil
|
|
||||||
case "nan":
|
|
||||||
return math.NaN(), nil
|
|
||||||
default:
|
|
||||||
return nil, fmt.Errorf("Unavailable context: %s", variableNode.Name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (impl *interperterImpl) evaluateIndexAccess(indexAccessNode *actionlint.IndexAccessNode) (any, error) {
|
|
||||||
left, err := impl.evaluateNode(indexAccessNode.Operand)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
leftValue := reflect.ValueOf(left)
|
|
||||||
|
|
||||||
right, err := impl.evaluateNode(indexAccessNode.Index)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
rightValue := reflect.ValueOf(right)
|
|
||||||
|
|
||||||
switch rightValue.Kind() {
|
|
||||||
case reflect.String:
|
|
||||||
return impl.getPropertyValue(leftValue, rightValue.String())
|
|
||||||
|
|
||||||
case reflect.Int:
|
|
||||||
switch leftValue.Kind() {
|
|
||||||
case reflect.Slice:
|
|
||||||
if rightValue.Int() < 0 || rightValue.Int() >= int64(leftValue.Len()) {
|
|
||||||
return nil, nil //nolint:nilnil // pre-existing issue from nektos/act
|
|
||||||
}
|
|
||||||
return leftValue.Index(int(rightValue.Int())).Interface(), nil
|
|
||||||
default:
|
|
||||||
return nil, nil //nolint:nilnil // pre-existing issue from nektos/act
|
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
|
||||||
return nil, nil //nolint:nilnil // pre-existing issue from nektos/act
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (impl *interperterImpl) evaluateObjectDeref(objectDerefNode *actionlint.ObjectDerefNode) (any, error) {
|
|
||||||
left, err := impl.evaluateNode(objectDerefNode.Receiver)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return impl.getPropertyValue(reflect.ValueOf(left), objectDerefNode.Property)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (impl *interperterImpl) evaluateArrayDeref(arrayDerefNode *actionlint.ArrayDerefNode) (any, error) {
|
|
||||||
left, err := impl.evaluateNode(arrayDerefNode.Receiver)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return impl.getSafeValue(reflect.ValueOf(left)), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (impl *interperterImpl) getPropertyValue(left reflect.Value, property string) (value any, err error) {
|
|
||||||
switch left.Kind() {
|
|
||||||
case reflect.Pointer:
|
|
||||||
return impl.getPropertyValue(left.Elem(), property)
|
|
||||||
|
|
||||||
case reflect.Struct:
|
|
||||||
leftType := left.Type()
|
|
||||||
for field := range leftType.Fields() {
|
|
||||||
jsonName := field.Tag.Get("json")
|
|
||||||
if jsonName == property {
|
|
||||||
property = field.Name
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fieldValue := left.FieldByNameFunc(func(name string) bool {
|
|
||||||
return strings.EqualFold(name, property)
|
|
||||||
})
|
|
||||||
|
|
||||||
if fieldValue.Kind() == reflect.Invalid {
|
|
||||||
return "", nil
|
|
||||||
}
|
|
||||||
|
|
||||||
i := fieldValue.Interface()
|
|
||||||
// The type stepStatus int is an integer, but should be treated as string
|
|
||||||
if m, ok := i.(encoding.TextMarshaler); ok {
|
|
||||||
text, err := m.MarshalText()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return string(text), nil
|
|
||||||
}
|
|
||||||
return i, nil
|
|
||||||
|
|
||||||
case reflect.Map:
|
|
||||||
iter := left.MapRange()
|
|
||||||
|
|
||||||
for iter.Next() {
|
|
||||||
key := iter.Key()
|
|
||||||
|
|
||||||
switch key.Kind() {
|
|
||||||
case reflect.String:
|
|
||||||
if strings.EqualFold(key.String(), property) {
|
|
||||||
return impl.getMapValue(iter.Value())
|
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
|
||||||
return nil, fmt.Errorf("'%s' in map key not implemented", key.Kind())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil, nil //nolint:nilnil // pre-existing issue from nektos/act
|
|
||||||
|
|
||||||
case reflect.Slice:
|
|
||||||
var values []any
|
|
||||||
|
|
||||||
for i := 0; i < left.Len(); i++ {
|
|
||||||
value, err := impl.getPropertyValue(left.Index(i).Elem(), property)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
values = append(values, value)
|
|
||||||
}
|
|
||||||
|
|
||||||
return values, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil, nil //nolint:nilnil // pre-existing issue from nektos/act
|
|
||||||
}
|
|
||||||
|
|
||||||
func (impl *interperterImpl) getMapValue(value reflect.Value) (any, error) {
|
|
||||||
if value.Kind() == reflect.Pointer {
|
|
||||||
return impl.getMapValue(value.Elem())
|
|
||||||
}
|
|
||||||
|
|
||||||
return value.Interface(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (impl *interperterImpl) evaluateNot(notNode *actionlint.NotOpNode) (any, error) {
|
|
||||||
operand, err := impl.evaluateNode(notNode.Operand)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return !IsTruthy(operand), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (impl *interperterImpl) evaluateCompare(compareNode *actionlint.CompareOpNode) (any, error) {
|
|
||||||
left, err := impl.evaluateNode(compareNode.Left)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
right, err := impl.evaluateNode(compareNode.Right)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
leftValue := reflect.ValueOf(left)
|
|
||||||
rightValue := reflect.ValueOf(right)
|
|
||||||
|
|
||||||
return impl.compareValues(leftValue, rightValue, compareNode.Kind)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (impl *interperterImpl) compareValues(leftValue, rightValue reflect.Value, kind actionlint.CompareOpNodeKind) (any, error) {
|
|
||||||
if leftValue.Kind() != rightValue.Kind() {
|
|
||||||
if !impl.isNumber(leftValue) {
|
|
||||||
leftValue = impl.coerceToNumber(leftValue)
|
|
||||||
}
|
|
||||||
if !impl.isNumber(rightValue) {
|
|
||||||
rightValue = impl.coerceToNumber(rightValue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
switch leftValue.Kind() {
|
|
||||||
case reflect.Bool:
|
|
||||||
return impl.compareNumber(float64(impl.coerceToNumber(leftValue).Int()), float64(impl.coerceToNumber(rightValue).Int()), kind)
|
|
||||||
case reflect.String:
|
|
||||||
return impl.compareString(strings.ToLower(leftValue.String()), strings.ToLower(rightValue.String()), kind)
|
|
||||||
|
|
||||||
case reflect.Int:
|
|
||||||
if rightValue.Kind() == reflect.Float64 {
|
|
||||||
return impl.compareNumber(float64(leftValue.Int()), rightValue.Float(), kind)
|
|
||||||
}
|
|
||||||
|
|
||||||
return impl.compareNumber(float64(leftValue.Int()), float64(rightValue.Int()), kind)
|
|
||||||
|
|
||||||
case reflect.Float64:
|
|
||||||
if rightValue.Kind() == reflect.Int {
|
|
||||||
return impl.compareNumber(leftValue.Float(), float64(rightValue.Int()), kind)
|
|
||||||
}
|
|
||||||
|
|
||||||
return impl.compareNumber(leftValue.Float(), rightValue.Float(), kind)
|
|
||||||
|
|
||||||
case reflect.Invalid:
|
|
||||||
if rightValue.Kind() == reflect.Invalid {
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// not possible situation - params are converted to the same type in code above
|
|
||||||
return nil, fmt.Errorf("Compare params of Invalid type: left: %+v, right: %+v", leftValue.Kind(), rightValue.Kind())
|
|
||||||
|
|
||||||
default:
|
|
||||||
return nil, fmt.Errorf("Compare not implemented for types: left: %+v, right: %+v", leftValue.Kind(), rightValue.Kind())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (impl *interperterImpl) coerceToNumber(value reflect.Value) reflect.Value {
|
|
||||||
switch value.Kind() {
|
|
||||||
case reflect.Invalid:
|
|
||||||
return reflect.ValueOf(0)
|
|
||||||
|
|
||||||
case reflect.Bool:
|
|
||||||
switch value.Bool() {
|
|
||||||
case true:
|
|
||||||
return reflect.ValueOf(1)
|
|
||||||
case false:
|
|
||||||
return reflect.ValueOf(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
case reflect.String:
|
|
||||||
if value.String() == "" {
|
|
||||||
return reflect.ValueOf(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// try to parse the string as a number
|
|
||||||
evaluated, err := impl.Evaluate(value.String(), DefaultStatusCheckNone)
|
|
||||||
if err != nil {
|
|
||||||
return reflect.ValueOf(math.NaN())
|
|
||||||
}
|
|
||||||
|
|
||||||
if value := reflect.ValueOf(evaluated); impl.isNumber(value) {
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return reflect.ValueOf(math.NaN())
|
|
||||||
}
|
|
||||||
|
|
||||||
// CoerceToString converts an evaluated expression value to a string the way GitHub does,
|
|
||||||
// see https://docs.github.com/en/actions/reference/workflows-and-actions/expressions#operators
|
|
||||||
// An already reflected value is accepted as-is, since Interface() would panic on an invalid one.
|
|
||||||
func CoerceToString(v any) string {
|
|
||||||
value, ok := v.(reflect.Value)
|
|
||||||
if !ok {
|
|
||||||
value = reflect.ValueOf(v)
|
|
||||||
}
|
|
||||||
|
|
||||||
switch value.Kind() {
|
|
||||||
case reflect.Invalid:
|
|
||||||
return ""
|
|
||||||
|
|
||||||
case reflect.Bool:
|
|
||||||
return strconv.FormatBool(value.Bool())
|
|
||||||
|
|
||||||
case reflect.String:
|
|
||||||
return value.String()
|
|
||||||
|
|
||||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
|
||||||
return strconv.FormatInt(value.Int(), 10)
|
|
||||||
|
|
||||||
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
|
|
||||||
return strconv.FormatUint(value.Uint(), 10)
|
|
||||||
|
|
||||||
case reflect.Float32, reflect.Float64:
|
|
||||||
if math.IsInf(value.Float(), 1) {
|
|
||||||
return "Infinity"
|
|
||||||
} else if math.IsInf(value.Float(), -1) {
|
|
||||||
return "-Infinity"
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("%.15G", value.Float())
|
|
||||||
|
|
||||||
case reflect.Slice, reflect.Array:
|
|
||||||
return "Array"
|
|
||||||
|
|
||||||
// contexts such as `github` are pointers to structs, so they stringify as objects too
|
|
||||||
case reflect.Map, reflect.Struct:
|
|
||||||
return "Object"
|
|
||||||
|
|
||||||
case reflect.Interface, reflect.Pointer:
|
|
||||||
if value.IsNil() {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return CoerceToString(value.Elem())
|
|
||||||
}
|
|
||||||
|
|
||||||
return fmt.Sprintf("%v", value)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (impl *interperterImpl) compareString(left, right string, kind actionlint.CompareOpNodeKind) (bool, error) {
|
|
||||||
switch kind {
|
|
||||||
case actionlint.CompareOpNodeKindLess:
|
|
||||||
return left < right, nil
|
|
||||||
case actionlint.CompareOpNodeKindLessEq:
|
|
||||||
return left <= right, nil
|
|
||||||
case actionlint.CompareOpNodeKindGreater:
|
|
||||||
return left > right, nil
|
|
||||||
case actionlint.CompareOpNodeKindGreaterEq:
|
|
||||||
return left >= right, nil
|
|
||||||
case actionlint.CompareOpNodeKindEq:
|
|
||||||
return left == right, nil
|
|
||||||
case actionlint.CompareOpNodeKindNotEq:
|
|
||||||
return left != right, nil
|
|
||||||
default:
|
|
||||||
return false, fmt.Errorf("TODO: not implemented to compare '%+v'", kind)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (impl *interperterImpl) compareNumber(left, right float64, kind actionlint.CompareOpNodeKind) (bool, error) {
|
|
||||||
switch kind {
|
|
||||||
case actionlint.CompareOpNodeKindLess:
|
|
||||||
return left < right, nil
|
|
||||||
case actionlint.CompareOpNodeKindLessEq:
|
|
||||||
return left <= right, nil
|
|
||||||
case actionlint.CompareOpNodeKindGreater:
|
|
||||||
return left > right, nil
|
|
||||||
case actionlint.CompareOpNodeKindGreaterEq:
|
|
||||||
return left >= right, nil
|
|
||||||
case actionlint.CompareOpNodeKindEq:
|
|
||||||
return left == right, nil
|
|
||||||
case actionlint.CompareOpNodeKindNotEq:
|
|
||||||
return left != right, nil
|
|
||||||
default:
|
|
||||||
return false, fmt.Errorf("TODO: not implemented to compare '%+v'", kind)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func IsTruthy(input any) bool {
|
|
||||||
value := reflect.ValueOf(input)
|
|
||||||
switch value.Kind() {
|
|
||||||
case reflect.Bool:
|
|
||||||
return value.Bool()
|
|
||||||
|
|
||||||
case reflect.String:
|
|
||||||
return value.String() != ""
|
|
||||||
|
|
||||||
case reflect.Int:
|
|
||||||
return value.Int() != 0
|
|
||||||
|
|
||||||
case reflect.Float64:
|
|
||||||
if math.IsNaN(value.Float()) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return value.Float() != 0
|
|
||||||
|
|
||||||
case reflect.Map, reflect.Slice:
|
|
||||||
return true
|
|
||||||
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (impl *interperterImpl) isNumber(value reflect.Value) bool {
|
|
||||||
switch value.Kind() {
|
|
||||||
case reflect.Int, reflect.Float64:
|
|
||||||
return true
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (impl *interperterImpl) getSafeValue(value reflect.Value) any {
|
|
||||||
switch value.Kind() {
|
|
||||||
case reflect.Invalid:
|
|
||||||
return nil
|
|
||||||
|
|
||||||
case reflect.Float64:
|
|
||||||
if value.Float() == 0 {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return value.Interface()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (impl *interperterImpl) evaluateLogicalCompare(compareNode *actionlint.LogicalOpNode) (any, error) {
|
|
||||||
left, err := impl.evaluateNode(compareNode.Left)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
leftValue := reflect.ValueOf(left)
|
|
||||||
|
|
||||||
if IsTruthy(left) == (compareNode.Kind == actionlint.LogicalOpNodeKindOr) {
|
|
||||||
return impl.getSafeValue(leftValue), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
right, err := impl.evaluateNode(compareNode.Right)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
rightValue := reflect.ValueOf(right)
|
|
||||||
|
|
||||||
switch compareNode.Kind {
|
|
||||||
case actionlint.LogicalOpNodeKindAnd:
|
|
||||||
return impl.getSafeValue(rightValue), nil
|
|
||||||
case actionlint.LogicalOpNodeKindOr:
|
|
||||||
return impl.getSafeValue(rightValue), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil, fmt.Errorf("Unable to compare incompatibles types '%s' and '%s'", leftValue.Kind(), rightValue.Kind())
|
|
||||||
}
|
|
||||||
|
|
||||||
func (impl *interperterImpl) evaluateFuncCall(funcCallNode *actionlint.FuncCallNode) (any, error) {
|
|
||||||
args := make([]reflect.Value, 0)
|
|
||||||
|
|
||||||
for _, arg := range funcCallNode.Args {
|
|
||||||
value, err := impl.evaluateNode(arg)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
args = append(args, reflect.ValueOf(value))
|
|
||||||
}
|
|
||||||
|
|
||||||
switch strings.ToLower(funcCallNode.Callee) {
|
|
||||||
case "contains":
|
|
||||||
return impl.contains(args[0], args[1])
|
|
||||||
case "startswith":
|
|
||||||
return impl.startsWith(args[0], args[1])
|
|
||||||
case "endswith":
|
|
||||||
return impl.endsWith(args[0], args[1])
|
|
||||||
case "format":
|
|
||||||
return impl.format(args[0], args[1:]...)
|
|
||||||
case "join":
|
|
||||||
if len(args) == 1 {
|
|
||||||
return impl.join(args[0], reflect.ValueOf(","))
|
|
||||||
}
|
|
||||||
return impl.join(args[0], args[1])
|
|
||||||
case "tojson":
|
|
||||||
return impl.toJSON(args[0])
|
|
||||||
case "fromjson":
|
|
||||||
return impl.fromJSON(args[0])
|
|
||||||
case "hashfiles":
|
|
||||||
if impl.env.HashFiles != nil {
|
|
||||||
return impl.env.HashFiles(args)
|
|
||||||
}
|
|
||||||
return impl.hashFiles(args...)
|
|
||||||
case "always":
|
|
||||||
return impl.always()
|
|
||||||
case "success":
|
|
||||||
if impl.config.Context == "job" {
|
|
||||||
return impl.jobSuccess()
|
|
||||||
}
|
|
||||||
if impl.config.Context == "step" {
|
|
||||||
return impl.stepSuccess()
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("Context '%s' must be one of 'job' or 'step'", impl.config.Context)
|
|
||||||
case "failure":
|
|
||||||
if impl.config.Context == "job" {
|
|
||||||
return impl.jobFailure()
|
|
||||||
}
|
|
||||||
if impl.config.Context == "step" {
|
|
||||||
return impl.stepFailure()
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("Context '%s' must be one of 'job' or 'step'", impl.config.Context)
|
|
||||||
case "cancelled":
|
|
||||||
return impl.cancelled()
|
|
||||||
default:
|
|
||||||
return nil, fmt.Errorf("TODO: '%s' not implemented", funcCallNode.Callee)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,684 +0,0 @@
|
|||||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
|
||||||
// Copyright 2022 The nektos/act Authors. All rights reserved.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package exprparser
|
|
||||||
|
|
||||||
import (
|
|
||||||
"math"
|
|
||||||
"reflect"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/model"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestLiterals(t *testing.T) {
|
|
||||||
table := []struct {
|
|
||||||
input string
|
|
||||||
expected any
|
|
||||||
name string
|
|
||||||
}{
|
|
||||||
{"true", true, "true"},
|
|
||||||
{"false", false, "false"},
|
|
||||||
{"null", nil, "null"},
|
|
||||||
{"123", 123, "integer"},
|
|
||||||
{"-9.7", -9.7, "float"},
|
|
||||||
{"0xff", 255, "hex"},
|
|
||||||
{"-2.99e-2", -2.99e-2, "exponential"},
|
|
||||||
{"'foo'", "foo", "string"},
|
|
||||||
{"'it''s foo'", "it's foo", "string"},
|
|
||||||
}
|
|
||||||
|
|
||||||
env := &EvaluationEnvironment{}
|
|
||||||
|
|
||||||
for _, tt := range table {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
|
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
|
|
||||||
assert.Equal(t, tt.expected, output)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestOperators(t *testing.T) {
|
|
||||||
table := []struct {
|
|
||||||
input string
|
|
||||||
expected any
|
|
||||||
name string
|
|
||||||
error string
|
|
||||||
}{
|
|
||||||
{"(false || (false || true))", true, "logical-grouping", ""},
|
|
||||||
{"github.action", "push", "property-dereference", ""},
|
|
||||||
{"github['action']", "push", "property-index", ""},
|
|
||||||
{"github.action[0]", nil, "string-index", ""},
|
|
||||||
{"github.action['0']", nil, "string-index", ""},
|
|
||||||
{"fromJSON('[0,1]')[1]", 1.0, "array-index", ""},
|
|
||||||
{"fromJSON('[0,1]')[1.1]", nil, "array-index", ""},
|
|
||||||
// Disabled weird things are happening
|
|
||||||
// {"fromJSON('[0,1]')['1.1']", nil, "array-index", ""},
|
|
||||||
{"(github.event.commits.*.author.username)[0]", "someone", "array-index-0", ""},
|
|
||||||
{"fromJSON('[0,1]')[2]", nil, "array-index-out-of-bounds-0", ""},
|
|
||||||
{"fromJSON('[0,1]')[34553]", nil, "array-index-out-of-bounds-1", ""},
|
|
||||||
{"fromJSON('[0,1]')[-1]", nil, "array-index-out-of-bounds-2", ""},
|
|
||||||
{"fromJSON('[0,1]')[-34553]", nil, "array-index-out-of-bounds-3", ""},
|
|
||||||
{"!true", false, "not", ""},
|
|
||||||
{"1 < 2", true, "less-than", ""},
|
|
||||||
{`'b' <= 'a'`, false, "less-than-or-equal", ""},
|
|
||||||
{"1 > 2", false, "greater-than", ""},
|
|
||||||
{`'b' >= 'a'`, true, "greater-than-or-equal", ""},
|
|
||||||
{`'a' == 'a'`, true, "equal", ""},
|
|
||||||
{`'a' != 'a'`, false, "not-equal", ""},
|
|
||||||
{`true && false`, false, "and", ""},
|
|
||||||
{`true || false`, true, "or", ""},
|
|
||||||
{`fromJSON('{}') && true`, true, "and-boolean-object", ""},
|
|
||||||
{`fromJSON('{}') || false`, make(map[string]any), "or-boolean-object", ""},
|
|
||||||
{"github.event.commits[0].author.username != github.event.commits[1].author.username", true, "property-comparison1", ""},
|
|
||||||
{"github.event.commits[0].author.username1 != github.event.commits[1].author.username", true, "property-comparison2", ""},
|
|
||||||
{"github.event.commits[0].author.username != github.event.commits[1].author.username1", true, "property-comparison3", ""},
|
|
||||||
{"github.event.commits[0].author.username1 != github.event.commits[1].author.username2", true, "property-comparison4", ""},
|
|
||||||
{"secrets != env", nil, "property-comparison5", "Compare not implemented for types: left: map, right: map"},
|
|
||||||
}
|
|
||||||
|
|
||||||
env := &EvaluationEnvironment{
|
|
||||||
Github: &model.GithubContext{
|
|
||||||
Action: "push",
|
|
||||||
Event: map[string]any{
|
|
||||||
"commits": []any{
|
|
||||||
map[string]any{
|
|
||||||
"author": map[string]any{
|
|
||||||
"username": "someone",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
map[string]any{
|
|
||||||
"author": map[string]any{
|
|
||||||
"username": "someone-else",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range table {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
|
|
||||||
if tt.error != "" {
|
|
||||||
assert.Error(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
assert.Equal(t, tt.error, err.Error())
|
|
||||||
} else {
|
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Equal(t, tt.expected, output)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestOperatorsCompare(t *testing.T) {
|
|
||||||
table := []struct {
|
|
||||||
input string
|
|
||||||
expected any
|
|
||||||
name string
|
|
||||||
}{
|
|
||||||
{"!null", true, "not-null"},
|
|
||||||
{"!-10", false, "not-neg-num"},
|
|
||||||
{"!0", true, "not-zero"},
|
|
||||||
{"!3.14", false, "not-pos-float"},
|
|
||||||
{"!''", true, "not-empty-str"},
|
|
||||||
{"!'abc'", false, "not-str"},
|
|
||||||
{"!fromJSON('{}')", false, "not-obj"},
|
|
||||||
{"!fromJSON('[]')", false, "not-arr"},
|
|
||||||
{`null == 0 }}`, true, "null-coercion"},
|
|
||||||
{`true == 1 }}`, true, "boolean-coercion"},
|
|
||||||
{`'' == 0 }}`, true, "string-0-coercion"},
|
|
||||||
{`'3' == 3 }}`, true, "string-3-coercion"},
|
|
||||||
{`0 == null }}`, true, "null-coercion-alt"},
|
|
||||||
{`1 == true }}`, true, "boolean-coercion-alt"},
|
|
||||||
{`0 == '' }}`, true, "string-0-coercion-alt"},
|
|
||||||
{`3 == '3' }}`, true, "string-3-coercion-alt"},
|
|
||||||
{`'TEST' == 'test' }}`, true, "string-casing"},
|
|
||||||
{"true > false }}", true, "bool-greater-than"},
|
|
||||||
{"true >= false }}", true, "bool-greater-than-eq"},
|
|
||||||
{"true >= true }}", true, "bool-greater-than-1"},
|
|
||||||
{"true != false }}", true, "bool-not-equal"},
|
|
||||||
{`fromJSON('{}') < 2 }}`, false, "object-with-less"},
|
|
||||||
{`fromJSON('{}') < fromJSON('[]') }}`, false, "object/arr-with-lt"},
|
|
||||||
{`fromJSON('{}') > fromJSON('[]') }}`, false, "object/arr-with-gt"},
|
|
||||||
}
|
|
||||||
|
|
||||||
env := &EvaluationEnvironment{
|
|
||||||
Github: &model.GithubContext{
|
|
||||||
Action: "push",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range table {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
|
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
|
|
||||||
assert.Equal(t, tt.expected, output)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestOperatorsBooleanEvaluation(t *testing.T) {
|
|
||||||
table := []struct {
|
|
||||||
input string
|
|
||||||
expected any
|
|
||||||
name string
|
|
||||||
}{
|
|
||||||
// true &&
|
|
||||||
{"true && true", true, "true-and"},
|
|
||||||
{"true && false", false, "true-and"},
|
|
||||||
{"true && null", nil, "true-and"},
|
|
||||||
{"true && -10", -10, "true-and"},
|
|
||||||
{"true && 0", 0, "true-and"},
|
|
||||||
{"true && 10", 10, "true-and"},
|
|
||||||
{"true && 3.14", 3.14, "true-and"},
|
|
||||||
{"true && 0.0", 0, "true-and"},
|
|
||||||
{"true && Infinity", math.Inf(1), "true-and"},
|
|
||||||
// {"true && -Infinity", math.Inf(-1), "true-and"},
|
|
||||||
{"true && NaN", math.NaN(), "true-and"},
|
|
||||||
{"true && ''", "", "true-and"},
|
|
||||||
{"true && 'abc'", "abc", "true-and"},
|
|
||||||
// false &&
|
|
||||||
{"false && true", false, "false-and"},
|
|
||||||
{"false && false", false, "false-and"},
|
|
||||||
{"false && null", false, "false-and"},
|
|
||||||
{"false && -10", false, "false-and"},
|
|
||||||
{"false && 0", false, "false-and"},
|
|
||||||
{"false && 10", false, "false-and"},
|
|
||||||
{"false && 3.14", false, "false-and"},
|
|
||||||
{"false && 0.0", false, "false-and"},
|
|
||||||
{"false && Infinity", false, "false-and"},
|
|
||||||
// {"false && -Infinity", false, "false-and"},
|
|
||||||
{"false && NaN", false, "false-and"},
|
|
||||||
{"false && ''", false, "false-and"},
|
|
||||||
{"false && 'abc'", false, "false-and"},
|
|
||||||
// true ||
|
|
||||||
{"true || true", true, "true-or"},
|
|
||||||
{"true || false", true, "true-or"},
|
|
||||||
{"true || null", true, "true-or"},
|
|
||||||
{"true || -10", true, "true-or"},
|
|
||||||
{"true || 0", true, "true-or"},
|
|
||||||
{"true || 10", true, "true-or"},
|
|
||||||
{"true || 3.14", true, "true-or"},
|
|
||||||
{"true || 0.0", true, "true-or"},
|
|
||||||
{"true || Infinity", true, "true-or"},
|
|
||||||
// {"true || -Infinity", true, "true-or"},
|
|
||||||
{"true || NaN", true, "true-or"},
|
|
||||||
{"true || ''", true, "true-or"},
|
|
||||||
{"true || 'abc'", true, "true-or"},
|
|
||||||
// false ||
|
|
||||||
{"false || true", true, "false-or"},
|
|
||||||
{"false || false", false, "false-or"},
|
|
||||||
{"false || null", nil, "false-or"},
|
|
||||||
{"false || -10", -10, "false-or"},
|
|
||||||
{"false || 0", 0, "false-or"},
|
|
||||||
{"false || 10", 10, "false-or"},
|
|
||||||
{"false || 3.14", 3.14, "false-or"},
|
|
||||||
{"false || 0.0", 0, "false-or"},
|
|
||||||
{"false || Infinity", math.Inf(1), "false-or"},
|
|
||||||
// {"false || -Infinity", math.Inf(-1), "false-or"},
|
|
||||||
{"false || NaN", math.NaN(), "false-or"},
|
|
||||||
{"false || ''", "", "false-or"},
|
|
||||||
{"false || 'abc'", "abc", "false-or"},
|
|
||||||
// null &&
|
|
||||||
{"null && true", nil, "null-and"},
|
|
||||||
{"null && false", nil, "null-and"},
|
|
||||||
{"null && null", nil, "null-and"},
|
|
||||||
{"null && -10", nil, "null-and"},
|
|
||||||
{"null && 0", nil, "null-and"},
|
|
||||||
{"null && 10", nil, "null-and"},
|
|
||||||
{"null && 3.14", nil, "null-and"},
|
|
||||||
{"null && 0.0", nil, "null-and"},
|
|
||||||
{"null && Infinity", nil, "null-and"},
|
|
||||||
// {"null && -Infinity", nil, "null-and"},
|
|
||||||
{"null && NaN", nil, "null-and"},
|
|
||||||
{"null && ''", nil, "null-and"},
|
|
||||||
{"null && 'abc'", nil, "null-and"},
|
|
||||||
// null ||
|
|
||||||
{"null || true", true, "null-or"},
|
|
||||||
{"null || false", false, "null-or"},
|
|
||||||
{"null || null", nil, "null-or"},
|
|
||||||
{"null || -10", -10, "null-or"},
|
|
||||||
{"null || 0", 0, "null-or"},
|
|
||||||
{"null || 10", 10, "null-or"},
|
|
||||||
{"null || 3.14", 3.14, "null-or"},
|
|
||||||
{"null || 0.0", 0, "null-or"},
|
|
||||||
{"null || Infinity", math.Inf(1), "null-or"},
|
|
||||||
// {"null || -Infinity", math.Inf(-1), "null-or"},
|
|
||||||
{"null || NaN", math.NaN(), "null-or"},
|
|
||||||
{"null || ''", "", "null-or"},
|
|
||||||
{"null || 'abc'", "abc", "null-or"},
|
|
||||||
// -10 &&
|
|
||||||
{"-10 && true", true, "neg-num-and"},
|
|
||||||
{"-10 && false", false, "neg-num-and"},
|
|
||||||
{"-10 && null", nil, "neg-num-and"},
|
|
||||||
{"-10 && -10", -10, "neg-num-and"},
|
|
||||||
{"-10 && 0", 0, "neg-num-and"},
|
|
||||||
{"-10 && 10", 10, "neg-num-and"},
|
|
||||||
{"-10 && 3.14", 3.14, "neg-num-and"},
|
|
||||||
{"-10 && 0.0", 0, "neg-num-and"},
|
|
||||||
{"-10 && Infinity", math.Inf(1), "neg-num-and"},
|
|
||||||
// {"-10 && -Infinity", math.Inf(-1), "neg-num-and"},
|
|
||||||
{"-10 && NaN", math.NaN(), "neg-num-and"},
|
|
||||||
{"-10 && ''", "", "neg-num-and"},
|
|
||||||
{"-10 && 'abc'", "abc", "neg-num-and"},
|
|
||||||
// -10 ||
|
|
||||||
{"-10 || true", -10, "neg-num-or"},
|
|
||||||
{"-10 || false", -10, "neg-num-or"},
|
|
||||||
{"-10 || null", -10, "neg-num-or"},
|
|
||||||
{"-10 || -10", -10, "neg-num-or"},
|
|
||||||
{"-10 || 0", -10, "neg-num-or"},
|
|
||||||
{"-10 || 10", -10, "neg-num-or"},
|
|
||||||
{"-10 || 3.14", -10, "neg-num-or"},
|
|
||||||
{"-10 || 0.0", -10, "neg-num-or"},
|
|
||||||
{"-10 || Infinity", -10, "neg-num-or"},
|
|
||||||
// {"-10 || -Infinity", -10, "neg-num-or"},
|
|
||||||
{"-10 || NaN", -10, "neg-num-or"},
|
|
||||||
{"-10 || ''", -10, "neg-num-or"},
|
|
||||||
{"-10 || 'abc'", -10, "neg-num-or"},
|
|
||||||
// 0 &&
|
|
||||||
{"0 && true", 0, "zero-and"},
|
|
||||||
{"0 && false", 0, "zero-and"},
|
|
||||||
{"0 && null", 0, "zero-and"},
|
|
||||||
{"0 && -10", 0, "zero-and"},
|
|
||||||
{"0 && 0", 0, "zero-and"},
|
|
||||||
{"0 && 10", 0, "zero-and"},
|
|
||||||
{"0 && 3.14", 0, "zero-and"},
|
|
||||||
{"0 && 0.0", 0, "zero-and"},
|
|
||||||
{"0 && Infinity", 0, "zero-and"},
|
|
||||||
// {"0 && -Infinity", 0, "zero-and"},
|
|
||||||
{"0 && NaN", 0, "zero-and"},
|
|
||||||
{"0 && ''", 0, "zero-and"},
|
|
||||||
{"0 && 'abc'", 0, "zero-and"},
|
|
||||||
// 0 ||
|
|
||||||
{"0 || true", true, "zero-or"},
|
|
||||||
{"0 || false", false, "zero-or"},
|
|
||||||
{"0 || null", nil, "zero-or"},
|
|
||||||
{"0 || -10", -10, "zero-or"},
|
|
||||||
{"0 || 0", 0, "zero-or"},
|
|
||||||
{"0 || 10", 10, "zero-or"},
|
|
||||||
{"0 || 3.14", 3.14, "zero-or"},
|
|
||||||
{"0 || 0.0", 0, "zero-or"},
|
|
||||||
{"0 || Infinity", math.Inf(1), "zero-or"},
|
|
||||||
// {"0 || -Infinity", math.Inf(-1), "zero-or"},
|
|
||||||
{"0 || NaN", math.NaN(), "zero-or"},
|
|
||||||
{"0 || ''", "", "zero-or"},
|
|
||||||
{"0 || 'abc'", "abc", "zero-or"},
|
|
||||||
// 10 &&
|
|
||||||
{"10 && true", true, "pos-num-and"},
|
|
||||||
{"10 && false", false, "pos-num-and"},
|
|
||||||
{"10 && null", nil, "pos-num-and"},
|
|
||||||
{"10 && -10", -10, "pos-num-and"},
|
|
||||||
{"10 && 0", 0, "pos-num-and"},
|
|
||||||
{"10 && 10", 10, "pos-num-and"},
|
|
||||||
{"10 && 3.14", 3.14, "pos-num-and"},
|
|
||||||
{"10 && 0.0", 0, "pos-num-and"},
|
|
||||||
{"10 && Infinity", math.Inf(1), "pos-num-and"},
|
|
||||||
// {"10 && -Infinity", math.Inf(-1), "pos-num-and"},
|
|
||||||
{"10 && NaN", math.NaN(), "pos-num-and"},
|
|
||||||
{"10 && ''", "", "pos-num-and"},
|
|
||||||
{"10 && 'abc'", "abc", "pos-num-and"},
|
|
||||||
// 10 ||
|
|
||||||
{"10 || true", 10, "pos-num-or"},
|
|
||||||
{"10 || false", 10, "pos-num-or"},
|
|
||||||
{"10 || null", 10, "pos-num-or"},
|
|
||||||
{"10 || -10", 10, "pos-num-or"},
|
|
||||||
{"10 || 0", 10, "pos-num-or"},
|
|
||||||
{"10 || 10", 10, "pos-num-or"},
|
|
||||||
{"10 || 3.14", 10, "pos-num-or"},
|
|
||||||
{"10 || 0.0", 10, "pos-num-or"},
|
|
||||||
{"10 || Infinity", 10, "pos-num-or"},
|
|
||||||
// {"10 || -Infinity", 10, "pos-num-or"},
|
|
||||||
{"10 || NaN", 10, "pos-num-or"},
|
|
||||||
{"10 || ''", 10, "pos-num-or"},
|
|
||||||
{"10 || 'abc'", 10, "pos-num-or"},
|
|
||||||
// 3.14 &&
|
|
||||||
{"3.14 && true", true, "pos-float-and"},
|
|
||||||
{"3.14 && false", false, "pos-float-and"},
|
|
||||||
{"3.14 && null", nil, "pos-float-and"},
|
|
||||||
{"3.14 && -10", -10, "pos-float-and"},
|
|
||||||
{"3.14 && 0", 0, "pos-float-and"},
|
|
||||||
{"3.14 && 10", 10, "pos-float-and"},
|
|
||||||
{"3.14 && 3.14", 3.14, "pos-float-and"},
|
|
||||||
{"3.14 && 0.0", 0, "pos-float-and"},
|
|
||||||
{"3.14 && Infinity", math.Inf(1), "pos-float-and"},
|
|
||||||
// {"3.14 && -Infinity", math.Inf(-1), "pos-float-and"},
|
|
||||||
{"3.14 && NaN", math.NaN(), "pos-float-and"},
|
|
||||||
{"3.14 && ''", "", "pos-float-and"},
|
|
||||||
{"3.14 && 'abc'", "abc", "pos-float-and"},
|
|
||||||
// 3.14 ||
|
|
||||||
{"3.14 || true", 3.14, "pos-float-or"},
|
|
||||||
{"3.14 || false", 3.14, "pos-float-or"},
|
|
||||||
{"3.14 || null", 3.14, "pos-float-or"},
|
|
||||||
{"3.14 || -10", 3.14, "pos-float-or"},
|
|
||||||
{"3.14 || 0", 3.14, "pos-float-or"},
|
|
||||||
{"3.14 || 10", 3.14, "pos-float-or"},
|
|
||||||
{"3.14 || 3.14", 3.14, "pos-float-or"},
|
|
||||||
{"3.14 || 0.0", 3.14, "pos-float-or"},
|
|
||||||
{"3.14 || Infinity", 3.14, "pos-float-or"},
|
|
||||||
// {"3.14 || -Infinity", 3.14, "pos-float-or"},
|
|
||||||
{"3.14 || NaN", 3.14, "pos-float-or"},
|
|
||||||
{"3.14 || ''", 3.14, "pos-float-or"},
|
|
||||||
{"3.14 || 'abc'", 3.14, "pos-float-or"},
|
|
||||||
// Infinity &&
|
|
||||||
{"Infinity && true", true, "pos-inf-and"},
|
|
||||||
{"Infinity && false", false, "pos-inf-and"},
|
|
||||||
{"Infinity && null", nil, "pos-inf-and"},
|
|
||||||
{"Infinity && -10", -10, "pos-inf-and"},
|
|
||||||
{"Infinity && 0", 0, "pos-inf-and"},
|
|
||||||
{"Infinity && 10", 10, "pos-inf-and"},
|
|
||||||
{"Infinity && 3.14", 3.14, "pos-inf-and"},
|
|
||||||
{"Infinity && 0.0", 0, "pos-inf-and"},
|
|
||||||
{"Infinity && Infinity", math.Inf(1), "pos-inf-and"},
|
|
||||||
// {"Infinity && -Infinity", math.Inf(-1), "pos-inf-and"},
|
|
||||||
{"Infinity && NaN", math.NaN(), "pos-inf-and"},
|
|
||||||
{"Infinity && ''", "", "pos-inf-and"},
|
|
||||||
{"Infinity && 'abc'", "abc", "pos-inf-and"},
|
|
||||||
// Infinity ||
|
|
||||||
{"Infinity || true", math.Inf(1), "pos-inf-or"},
|
|
||||||
{"Infinity || false", math.Inf(1), "pos-inf-or"},
|
|
||||||
{"Infinity || null", math.Inf(1), "pos-inf-or"},
|
|
||||||
{"Infinity || -10", math.Inf(1), "pos-inf-or"},
|
|
||||||
{"Infinity || 0", math.Inf(1), "pos-inf-or"},
|
|
||||||
{"Infinity || 10", math.Inf(1), "pos-inf-or"},
|
|
||||||
{"Infinity || 3.14", math.Inf(1), "pos-inf-or"},
|
|
||||||
{"Infinity || 0.0", math.Inf(1), "pos-inf-or"},
|
|
||||||
{"Infinity || Infinity", math.Inf(1), "pos-inf-or"},
|
|
||||||
// {"Infinity || -Infinity", math.Inf(1), "pos-inf-or"},
|
|
||||||
{"Infinity || NaN", math.Inf(1), "pos-inf-or"},
|
|
||||||
{"Infinity || ''", math.Inf(1), "pos-inf-or"},
|
|
||||||
{"Infinity || 'abc'", math.Inf(1), "pos-inf-or"},
|
|
||||||
// -Infinity &&
|
|
||||||
// {"-Infinity && true", true, "neg-inf-and"},
|
|
||||||
// {"-Infinity && false", false, "neg-inf-and"},
|
|
||||||
// {"-Infinity && null", nil, "neg-inf-and"},
|
|
||||||
// {"-Infinity && -10", -10, "neg-inf-and"},
|
|
||||||
// {"-Infinity && 0", 0, "neg-inf-and"},
|
|
||||||
// {"-Infinity && 10", 10, "neg-inf-and"},
|
|
||||||
// {"-Infinity && 3.14", 3.14, "neg-inf-and"},
|
|
||||||
// {"-Infinity && 0.0", 0, "neg-inf-and"},
|
|
||||||
// {"-Infinity && Infinity", math.Inf(1), "neg-inf-and"},
|
|
||||||
// {"-Infinity && -Infinity", math.Inf(-1), "neg-inf-and"},
|
|
||||||
// {"-Infinity && NaN", math.NaN(), "neg-inf-and"},
|
|
||||||
// {"-Infinity && ''", "", "neg-inf-and"},
|
|
||||||
// {"-Infinity && 'abc'", "abc", "neg-inf-and"},
|
|
||||||
// -Infinity ||
|
|
||||||
// {"-Infinity || true", math.Inf(-1), "neg-inf-or"},
|
|
||||||
// {"-Infinity || false", math.Inf(-1), "neg-inf-or"},
|
|
||||||
// {"-Infinity || null", math.Inf(-1), "neg-inf-or"},
|
|
||||||
// {"-Infinity || -10", math.Inf(-1), "neg-inf-or"},
|
|
||||||
// {"-Infinity || 0", math.Inf(-1), "neg-inf-or"},
|
|
||||||
// {"-Infinity || 10", math.Inf(-1), "neg-inf-or"},
|
|
||||||
// {"-Infinity || 3.14", math.Inf(-1), "neg-inf-or"},
|
|
||||||
// {"-Infinity || 0.0", math.Inf(-1), "neg-inf-or"},
|
|
||||||
// {"-Infinity || Infinity", math.Inf(-1), "neg-inf-or"},
|
|
||||||
// {"-Infinity || -Infinity", math.Inf(-1), "neg-inf-or"},
|
|
||||||
// {"-Infinity || NaN", math.Inf(-1), "neg-inf-or"},
|
|
||||||
// {"-Infinity || ''", math.Inf(-1), "neg-inf-or"},
|
|
||||||
// {"-Infinity || 'abc'", math.Inf(-1), "neg-inf-or"},
|
|
||||||
// NaN &&
|
|
||||||
{"NaN && true", math.NaN(), "nan-and"},
|
|
||||||
{"NaN && false", math.NaN(), "nan-and"},
|
|
||||||
{"NaN && null", math.NaN(), "nan-and"},
|
|
||||||
{"NaN && -10", math.NaN(), "nan-and"},
|
|
||||||
{"NaN && 0", math.NaN(), "nan-and"},
|
|
||||||
{"NaN && 10", math.NaN(), "nan-and"},
|
|
||||||
{"NaN && 3.14", math.NaN(), "nan-and"},
|
|
||||||
{"NaN && 0.0", math.NaN(), "nan-and"},
|
|
||||||
{"NaN && Infinity", math.NaN(), "nan-and"},
|
|
||||||
// {"NaN && -Infinity", math.NaN(), "nan-and"},
|
|
||||||
{"NaN && NaN", math.NaN(), "nan-and"},
|
|
||||||
{"NaN && ''", math.NaN(), "nan-and"},
|
|
||||||
{"NaN && 'abc'", math.NaN(), "nan-and"},
|
|
||||||
// NaN ||
|
|
||||||
{"NaN || true", true, "nan-or"},
|
|
||||||
{"NaN || false", false, "nan-or"},
|
|
||||||
{"NaN || null", nil, "nan-or"},
|
|
||||||
{"NaN || -10", -10, "nan-or"},
|
|
||||||
{"NaN || 0", 0, "nan-or"},
|
|
||||||
{"NaN || 10", 10, "nan-or"},
|
|
||||||
{"NaN || 3.14", 3.14, "nan-or"},
|
|
||||||
{"NaN || 0.0", 0, "nan-or"},
|
|
||||||
{"NaN || Infinity", math.Inf(1), "nan-or"},
|
|
||||||
// {"NaN || -Infinity", math.Inf(-1), "nan-or"},
|
|
||||||
{"NaN || NaN", math.NaN(), "nan-or"},
|
|
||||||
{"NaN || ''", "", "nan-or"},
|
|
||||||
{"NaN || 'abc'", "abc", "nan-or"},
|
|
||||||
// "" &&
|
|
||||||
{"'' && true", "", "empty-str-and"},
|
|
||||||
{"'' && false", "", "empty-str-and"},
|
|
||||||
{"'' && null", "", "empty-str-and"},
|
|
||||||
{"'' && -10", "", "empty-str-and"},
|
|
||||||
{"'' && 0", "", "empty-str-and"},
|
|
||||||
{"'' && 10", "", "empty-str-and"},
|
|
||||||
{"'' && 3.14", "", "empty-str-and"},
|
|
||||||
{"'' && 0.0", "", "empty-str-and"},
|
|
||||||
{"'' && Infinity", "", "empty-str-and"},
|
|
||||||
// {"'' && -Infinity", "", "empty-str-and"},
|
|
||||||
{"'' && NaN", "", "empty-str-and"},
|
|
||||||
{"'' && ''", "", "empty-str-and"},
|
|
||||||
{"'' && 'abc'", "", "empty-str-and"},
|
|
||||||
// "" ||
|
|
||||||
{"'' || true", true, "empty-str-or"},
|
|
||||||
{"'' || false", false, "empty-str-or"},
|
|
||||||
{"'' || null", nil, "empty-str-or"},
|
|
||||||
{"'' || -10", -10, "empty-str-or"},
|
|
||||||
{"'' || 0", 0, "empty-str-or"},
|
|
||||||
{"'' || 10", 10, "empty-str-or"},
|
|
||||||
{"'' || 3.14", 3.14, "empty-str-or"},
|
|
||||||
{"'' || 0.0", 0, "empty-str-or"},
|
|
||||||
{"'' || Infinity", math.Inf(1), "empty-str-or"},
|
|
||||||
// {"'' || -Infinity", math.Inf(-1), "empty-str-or"},
|
|
||||||
{"'' || NaN", math.NaN(), "empty-str-or"},
|
|
||||||
{"'' || ''", "", "empty-str-or"},
|
|
||||||
{"'' || 'abc'", "abc", "empty-str-or"},
|
|
||||||
// "abc" &&
|
|
||||||
{"'abc' && true", true, "str-and"},
|
|
||||||
{"'abc' && false", false, "str-and"},
|
|
||||||
{"'abc' && null", nil, "str-and"},
|
|
||||||
{"'abc' && -10", -10, "str-and"},
|
|
||||||
{"'abc' && 0", 0, "str-and"},
|
|
||||||
{"'abc' && 10", 10, "str-and"},
|
|
||||||
{"'abc' && 3.14", 3.14, "str-and"},
|
|
||||||
{"'abc' && 0.0", 0, "str-and"},
|
|
||||||
{"'abc' && Infinity", math.Inf(1), "str-and"},
|
|
||||||
// {"'abc' && -Infinity", math.Inf(-1), "str-and"},
|
|
||||||
{"'abc' && NaN", math.NaN(), "str-and"},
|
|
||||||
{"'abc' && ''", "", "str-and"},
|
|
||||||
{"'abc' && 'abc'", "abc", "str-and"},
|
|
||||||
// "abc" ||
|
|
||||||
{"'abc' || true", "abc", "str-or"},
|
|
||||||
{"'abc' || false", "abc", "str-or"},
|
|
||||||
{"'abc' || null", "abc", "str-or"},
|
|
||||||
{"'abc' || -10", "abc", "str-or"},
|
|
||||||
{"'abc' || 0", "abc", "str-or"},
|
|
||||||
{"'abc' || 10", "abc", "str-or"},
|
|
||||||
{"'abc' || 3.14", "abc", "str-or"},
|
|
||||||
{"'abc' || 0.0", "abc", "str-or"},
|
|
||||||
{"'abc' || Infinity", "abc", "str-or"},
|
|
||||||
// {"'abc' || -Infinity", "abc", "str-or"},
|
|
||||||
{"'abc' || NaN", "abc", "str-or"},
|
|
||||||
{"'abc' || ''", "abc", "str-or"},
|
|
||||||
{"'abc' || 'abc'", "abc", "str-or"},
|
|
||||||
// extra tests
|
|
||||||
{"0.0 && true", 0, "float-evaluation-0-alt"},
|
|
||||||
{"-1.5 && true", true, "float-evaluation-neg-alt"},
|
|
||||||
}
|
|
||||||
|
|
||||||
env := &EvaluationEnvironment{
|
|
||||||
Github: &model.GithubContext{
|
|
||||||
Action: "push",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range table {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
|
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
|
|
||||||
if expected, ok := tt.expected.(float64); ok && math.IsNaN(expected) {
|
|
||||||
number, ok := output.(float64)
|
|
||||||
require.True(t, ok, "want a number, got %T", output)
|
|
||||||
assert.True(t, math.IsNaN(number))
|
|
||||||
} else {
|
|
||||||
assert.Equal(t, tt.expected, output)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestContexts(t *testing.T) {
|
|
||||||
table := []struct {
|
|
||||||
input string
|
|
||||||
expected any
|
|
||||||
name string
|
|
||||||
}{
|
|
||||||
{"github.action", "push", "github-context"},
|
|
||||||
{"github.event.commits[0].message", nil, "github-context-noexist-prop"},
|
|
||||||
{"fromjson('{\"commits\":[]}').commits[0].message", nil, "github-context-noexist-prop"},
|
|
||||||
{"github.event.pull_request.labels.*.name", nil, "github-context-noexist-prop"},
|
|
||||||
{"env.TEST", "value", "env-context"},
|
|
||||||
{"job.status", "success", "job-context"},
|
|
||||||
{"steps.step-id.outputs.name", "value", "steps-context"},
|
|
||||||
{"steps.step-id.conclusion", "success", "steps-context-conclusion"},
|
|
||||||
{"steps.step-id.conclusion && true", true, "steps-context-conclusion"},
|
|
||||||
{"steps.step-id2.conclusion", "skipped", "steps-context-conclusion"},
|
|
||||||
{"steps.step-id2.conclusion && true", true, "steps-context-conclusion"},
|
|
||||||
{"steps.step-id.outcome", "success", "steps-context-outcome"},
|
|
||||||
{"steps.step-id['outcome']", "success", "steps-context-outcome"},
|
|
||||||
{"steps.step-id.outcome == 'success'", true, "steps-context-outcome"},
|
|
||||||
{"steps.step-id['outcome'] == 'success'", true, "steps-context-outcome"},
|
|
||||||
{"steps.step-id.outcome && true", true, "steps-context-outcome"},
|
|
||||||
{"steps['step-id']['outcome'] && true", true, "steps-context-outcome"},
|
|
||||||
{"steps.step-id2.outcome", "failure", "steps-context-outcome"},
|
|
||||||
{"steps.step-id2.outcome && true", true, "steps-context-outcome"},
|
|
||||||
// Disabled, since the interpreter is still too broken
|
|
||||||
// {"contains(steps.*.outcome, 'success')", true, "steps-context-array-outcome"},
|
|
||||||
// {"contains(steps.*.outcome, 'failure')", true, "steps-context-array-outcome"},
|
|
||||||
// {"contains(steps.*.outputs.name, 'value')", true, "steps-context-array-outputs"},
|
|
||||||
{"runner.os", "Linux", "runner-context"},
|
|
||||||
{"secrets.name", "value", "secrets-context"},
|
|
||||||
{"vars.name", "value", "vars-context"},
|
|
||||||
{"strategy.fail-fast", true, "strategy-context"},
|
|
||||||
{"matrix.os", "Linux", "matrix-context"},
|
|
||||||
{"needs.job-id.outputs.output-name", "value", "needs-context"},
|
|
||||||
{"needs.job-id.result", "success", "needs-context"},
|
|
||||||
{"inputs.name", "value", "inputs-context"},
|
|
||||||
}
|
|
||||||
|
|
||||||
env := &EvaluationEnvironment{
|
|
||||||
Github: &model.GithubContext{
|
|
||||||
Action: "push",
|
|
||||||
},
|
|
||||||
Env: map[string]string{
|
|
||||||
"TEST": "value",
|
|
||||||
},
|
|
||||||
Job: &model.JobContext{
|
|
||||||
Status: "success",
|
|
||||||
},
|
|
||||||
Steps: map[string]*model.StepResult{
|
|
||||||
"step-id": {
|
|
||||||
Outputs: map[string]string{
|
|
||||||
"name": "value",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"step-id2": {
|
|
||||||
Outcome: model.StepStatusFailure,
|
|
||||||
Conclusion: model.StepStatusSkipped,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Runner: map[string]any{
|
|
||||||
"os": "Linux",
|
|
||||||
"temp": "/tmp",
|
|
||||||
"tool_cache": "/opt/hostedtoolcache",
|
|
||||||
},
|
|
||||||
Secrets: map[string]string{
|
|
||||||
"name": "value",
|
|
||||||
},
|
|
||||||
Vars: map[string]string{
|
|
||||||
"name": "value",
|
|
||||||
},
|
|
||||||
Strategy: map[string]any{
|
|
||||||
"fail-fast": true,
|
|
||||||
},
|
|
||||||
Matrix: map[string]any{
|
|
||||||
"os": "Linux",
|
|
||||||
},
|
|
||||||
Needs: map[string]Needs{
|
|
||||||
"job-id": {
|
|
||||||
Outputs: map[string]string{
|
|
||||||
"output-name": "value",
|
|
||||||
},
|
|
||||||
Result: "success",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Inputs: map[string]any{
|
|
||||||
"name": "value",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range table {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
|
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
|
|
||||||
assert.Equal(t, tt.expected, output)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCoerceToString(t *testing.T) {
|
|
||||||
type object struct{ Name string }
|
|
||||||
obj := object{Name: "x"}
|
|
||||||
var nilPointer *object
|
|
||||||
var nilMap map[string]any
|
|
||||||
var nilSlice []any
|
|
||||||
|
|
||||||
table := []struct {
|
|
||||||
input any
|
|
||||||
expected string
|
|
||||||
name string
|
|
||||||
}{
|
|
||||||
{nil, "", "null"},
|
|
||||||
{true, "true", "true"},
|
|
||||||
{false, "false", "false"},
|
|
||||||
{"foo", "foo", "string"},
|
|
||||||
{"", "", "empty-string"},
|
|
||||||
{123, "123", "int"},
|
|
||||||
{int64(-9), "-9", "int64"},
|
|
||||||
{uint8(7), "7", "uint8"},
|
|
||||||
{1.0, "1", "float-integral"},
|
|
||||||
{-9.7, "-9.7", "float"},
|
|
||||||
{2.99e-2, "0.0299", "float-exponential"},
|
|
||||||
{1e21, "1E+21", "float-large"},
|
|
||||||
{float32(1.5), "1.5", "float32"},
|
|
||||||
{math.NaN(), "NaN", "nan"},
|
|
||||||
{math.Inf(1), "Infinity", "positive-infinity"},
|
|
||||||
{math.Inf(-1), "-Infinity", "negative-infinity"},
|
|
||||||
{[]any{1, 2}, "Array", "slice"},
|
|
||||||
{nilSlice, "Array", "nil-slice"},
|
|
||||||
{[2]int{1, 2}, "Array", "fixed-size-array"},
|
|
||||||
{map[string]any{"a": 1}, "Object", "map"},
|
|
||||||
{nilMap, "Object", "nil-map"},
|
|
||||||
{obj, "Object", "struct"},
|
|
||||||
{&obj, "Object", "pointer-to-struct"},
|
|
||||||
{nilPointer, "", "nil-pointer"},
|
|
||||||
{&model.GithubContext{Action: "push"}, "Object", "github-context"},
|
|
||||||
{reflect.ValueOf(42), "42", "reflected-value"},
|
|
||||||
{reflect.Value{}, "", "invalid-reflected-value"},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range table {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
assert.Equal(t, tt.expected, CoerceToString(tt.input))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
1
act/exprparser/testdata/for-hashing-1.txt
vendored
1
act/exprparser/testdata/for-hashing-1.txt
vendored
@@ -1 +0,0 @@
|
|||||||
Hello
|
|
||||||
1
act/exprparser/testdata/for-hashing-2.txt
vendored
1
act/exprparser/testdata/for-hashing-2.txt
vendored
@@ -1 +0,0 @@
|
|||||||
World!
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
Knock knock!
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
Anybody home?
|
|
||||||
138
act/ghcontext/github_context.go
Normal file
138
act/ghcontext/github_context.go
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2021 The nektos/act Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
// Package ghcontext fills a model.GithubContext from the local git checkout.
|
||||||
|
// The pure data parts of the context live in the shared
|
||||||
|
// gitea.dev/actionslib/pkg/model package, only the helpers that need a
|
||||||
|
// git repository on disk are kept here.
|
||||||
|
package ghcontext
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
"gitea.com/gitea/runner/act/common/git"
|
||||||
|
|
||||||
|
"gitea.dev/actionslib/pkg/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
findGitRef = git.FindGitRef
|
||||||
|
findGitRevision = git.FindGitRevision
|
||||||
|
findGithubRepo = git.FindGithubRepo
|
||||||
|
)
|
||||||
|
|
||||||
|
func withDefaultBranch(ctx context.Context, b string, event map[string]any) map[string]any {
|
||||||
|
repoI, ok := event["repository"]
|
||||||
|
if !ok {
|
||||||
|
repoI = make(map[string]any)
|
||||||
|
}
|
||||||
|
|
||||||
|
repo, ok := repoI.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
common.Logger(ctx).Warnf("unable to set default branch to %v", b)
|
||||||
|
return event
|
||||||
|
}
|
||||||
|
|
||||||
|
// if the branch is already there return with no changes
|
||||||
|
if _, ok = repo["default_branch"]; ok {
|
||||||
|
return event
|
||||||
|
}
|
||||||
|
|
||||||
|
repo["default_branch"] = b
|
||||||
|
event["repository"] = repo
|
||||||
|
|
||||||
|
return event
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetRef resolves the ref of the context from its event payload, falling back
|
||||||
|
// to the ref checked out in repoPath.
|
||||||
|
func SetRef(ctx context.Context, ghc *model.GithubContext, defaultBranch, repoPath string) {
|
||||||
|
logger := common.Logger(ctx)
|
||||||
|
|
||||||
|
// https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows
|
||||||
|
// https://docs.github.com/en/developers/webhooks-and-events/webhooks/webhook-events-and-payloads
|
||||||
|
switch ghc.EventName {
|
||||||
|
case "pull_request_target":
|
||||||
|
ghc.Ref = "refs/heads/" + ghc.BaseRef
|
||||||
|
case "pull_request", "pull_request_review", "pull_request_review_comment":
|
||||||
|
ghc.Ref = fmt.Sprintf("refs/pull/%.0f/merge", ghc.Event["number"])
|
||||||
|
case "deployment", "deployment_status":
|
||||||
|
ghc.Ref = model.AsString(model.NestedMapLookup(ghc.Event, "deployment", "ref"))
|
||||||
|
case "release":
|
||||||
|
ghc.Ref = "refs/tags/" + model.AsString(model.NestedMapLookup(ghc.Event, "release", "tag_name"))
|
||||||
|
case "push", "create", "workflow_dispatch":
|
||||||
|
ghc.Ref = model.AsString(ghc.Event["ref"])
|
||||||
|
default:
|
||||||
|
defaultBranch := model.AsString(model.NestedMapLookup(ghc.Event, "repository", "default_branch"))
|
||||||
|
if defaultBranch != "" {
|
||||||
|
ghc.Ref = "refs/heads/" + defaultBranch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ghc.Ref == "" {
|
||||||
|
ref, err := findGitRef(ctx, repoPath)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warningf("unable to get git ref: %v", err)
|
||||||
|
} else {
|
||||||
|
logger.Debugf("using github ref: %s", ref)
|
||||||
|
ghc.Ref = ref
|
||||||
|
}
|
||||||
|
|
||||||
|
// set the branch in the event data
|
||||||
|
if defaultBranch != "" {
|
||||||
|
ghc.Event = withDefaultBranch(ctx, defaultBranch, ghc.Event)
|
||||||
|
} else {
|
||||||
|
ghc.Event = withDefaultBranch(ctx, "master", ghc.Event)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ghc.Ref == "" {
|
||||||
|
ghc.Ref = "refs/heads/" + model.AsString(model.NestedMapLookup(ghc.Event, "repository", "default_branch"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetSha resolves the commit of the context from its event payload, falling
|
||||||
|
// back to the revision checked out in repoPath.
|
||||||
|
func SetSha(ctx context.Context, ghc *model.GithubContext, repoPath string) {
|
||||||
|
logger := common.Logger(ctx)
|
||||||
|
|
||||||
|
// https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows
|
||||||
|
// https://docs.github.com/en/developers/webhooks-and-events/webhooks/webhook-events-and-payloads
|
||||||
|
switch ghc.EventName {
|
||||||
|
case "pull_request_target":
|
||||||
|
ghc.Sha = model.AsString(model.NestedMapLookup(ghc.Event, "pull_request", "base", "sha"))
|
||||||
|
case "deployment", "deployment_status":
|
||||||
|
ghc.Sha = model.AsString(model.NestedMapLookup(ghc.Event, "deployment", "sha"))
|
||||||
|
case "push", "create", "workflow_dispatch":
|
||||||
|
if deleted, ok := ghc.Event["deleted"].(bool); ok && !deleted {
|
||||||
|
ghc.Sha = model.AsString(ghc.Event["after"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ghc.Sha == "" {
|
||||||
|
_, sha, err := findGitRevision(ctx, repoPath)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warningf("unable to get git revision: %v", err)
|
||||||
|
} else {
|
||||||
|
ghc.Sha = sha
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetRepositoryAndOwner resolves the repository of the context from the git
|
||||||
|
// remote in repoPath when it is not set yet, and derives its owner.
|
||||||
|
func SetRepositoryAndOwner(ctx context.Context, ghc *model.GithubContext, githubInstance, remoteName, repoPath string) {
|
||||||
|
if ghc.Repository == "" {
|
||||||
|
repo, err := findGithubRepo(ctx, repoPath, githubInstance, remoteName)
|
||||||
|
if err != nil {
|
||||||
|
common.Logger(ctx).Warningf("unable to get git repo (githubInstance: %v; remoteName: %v, repoPath: %v): %v", githubInstance, remoteName, repoPath, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ghc.Repository = repo
|
||||||
|
}
|
||||||
|
ghc.RepositoryOwner = strings.Split(ghc.Repository, "/")[0]
|
||||||
|
}
|
||||||
@@ -2,13 +2,14 @@
|
|||||||
// Copyright 2022 The nektos/act Authors. All rights reserved.
|
// Copyright 2022 The nektos/act Authors. All rights reserved.
|
||||||
// SPDX-License-Identifier: MIT
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
package model
|
package ghcontext
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"gitea.dev/actionslib/pkg/model"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
@@ -97,13 +98,13 @@ func TestSetRef(t *testing.T) {
|
|||||||
|
|
||||||
for _, table := range tables {
|
for _, table := range tables {
|
||||||
t.Run(table.eventName, func(t *testing.T) {
|
t.Run(table.eventName, func(t *testing.T) {
|
||||||
ghc := &GithubContext{
|
ghc := &model.GithubContext{
|
||||||
EventName: table.eventName,
|
EventName: table.eventName,
|
||||||
BaseRef: "master",
|
BaseRef: "master",
|
||||||
Event: table.event,
|
Event: table.event,
|
||||||
}
|
}
|
||||||
|
|
||||||
ghc.SetRef(context.Background(), "main", "/some/dir")
|
SetRef(context.Background(), ghc, "main", "/some/dir")
|
||||||
ghc.SetRefTypeAndName()
|
ghc.SetRefTypeAndName()
|
||||||
|
|
||||||
assert.Equal(t, table.ref, ghc.Ref)
|
assert.Equal(t, table.ref, ghc.Ref)
|
||||||
@@ -116,12 +117,12 @@ func TestSetRef(t *testing.T) {
|
|||||||
return "", errors.New("no default branch")
|
return "", errors.New("no default branch")
|
||||||
}
|
}
|
||||||
|
|
||||||
ghc := &GithubContext{
|
ghc := &model.GithubContext{
|
||||||
EventName: "no-default-branch",
|
EventName: "no-default-branch",
|
||||||
Event: map[string]any{},
|
Event: map[string]any{},
|
||||||
}
|
}
|
||||||
|
|
||||||
ghc.SetRef(context.Background(), "", "/some/dir")
|
SetRef(context.Background(), ghc, "", "/some/dir")
|
||||||
|
|
||||||
assert.Equal(t, "refs/heads/master", ghc.Ref)
|
assert.Equal(t, "refs/heads/master", ghc.Ref)
|
||||||
})
|
})
|
||||||
@@ -202,13 +203,13 @@ func TestSetSha(t *testing.T) {
|
|||||||
|
|
||||||
for _, table := range tables {
|
for _, table := range tables {
|
||||||
t.Run(table.eventName, func(t *testing.T) {
|
t.Run(table.eventName, func(t *testing.T) {
|
||||||
ghc := &GithubContext{
|
ghc := &model.GithubContext{
|
||||||
EventName: table.eventName,
|
EventName: table.eventName,
|
||||||
BaseRef: "master",
|
BaseRef: "master",
|
||||||
Event: table.event,
|
Event: table.event,
|
||||||
}
|
}
|
||||||
|
|
||||||
ghc.SetSha(context.Background(), "/some/dir")
|
SetSha(context.Background(), ghc, "/some/dir")
|
||||||
|
|
||||||
assert.Equal(t, table.sha, ghc.Sha)
|
assert.Equal(t, table.sha, ghc.Sha)
|
||||||
})
|
})
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
|
||||||
// Copyright 2020 The nektos/act Authors. All rights reserved.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package model
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"go.yaml.in/yaml/v4"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ActionRunsUsing is the type of runner for the action
|
|
||||||
type ActionRunsUsing string
|
|
||||||
|
|
||||||
func (a *ActionRunsUsing) UnmarshalYAML(unmarshal func(any) error) error {
|
|
||||||
var using string
|
|
||||||
if err := unmarshal(&using); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Force input to lowercase for case insensitive comparison
|
|
||||||
format := ActionRunsUsing(strings.ToLower(using))
|
|
||||||
switch format {
|
|
||||||
case ActionRunsUsingNode24, ActionRunsUsingNode20, ActionRunsUsingNode16, ActionRunsUsingNode12, ActionRunsUsingDocker, ActionRunsUsingComposite, ActionRunsUsingGo:
|
|
||||||
*a = format
|
|
||||||
default:
|
|
||||||
return fmt.Errorf("The runs.using key in action.yml must be one of: %v, got %s", []string{
|
|
||||||
ActionRunsUsingComposite,
|
|
||||||
ActionRunsUsingDocker,
|
|
||||||
ActionRunsUsingNode12,
|
|
||||||
ActionRunsUsingNode16,
|
|
||||||
ActionRunsUsingNode20,
|
|
||||||
ActionRunsUsingNode24,
|
|
||||||
ActionRunsUsingGo,
|
|
||||||
}, format)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
const (
|
|
||||||
// ActionRunsUsingNode12 for running with node12
|
|
||||||
ActionRunsUsingNode12 = "node12"
|
|
||||||
// ActionRunsUsingNode16 for running with node16
|
|
||||||
ActionRunsUsingNode16 = "node16"
|
|
||||||
// ActionRunsUsingNode20 for running with node20
|
|
||||||
ActionRunsUsingNode20 = "node20"
|
|
||||||
// ActionRunsUsingNode24 for running with node24
|
|
||||||
ActionRunsUsingNode24 = "node24"
|
|
||||||
// ActionRunsUsingDocker for running with docker
|
|
||||||
ActionRunsUsingDocker = "docker"
|
|
||||||
// ActionRunsUsingComposite for running composite
|
|
||||||
ActionRunsUsingComposite = "composite"
|
|
||||||
// ActionRunsUsingGo for running with go
|
|
||||||
ActionRunsUsingGo = "go"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (a ActionRunsUsing) IsNode() bool {
|
|
||||||
switch a {
|
|
||||||
case ActionRunsUsingNode12, ActionRunsUsingNode16, ActionRunsUsingNode20, ActionRunsUsingNode24:
|
|
||||||
return true
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a ActionRunsUsing) IsDocker() bool {
|
|
||||||
return a == ActionRunsUsingDocker
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a ActionRunsUsing) IsComposite() bool {
|
|
||||||
return a == ActionRunsUsingComposite
|
|
||||||
}
|
|
||||||
|
|
||||||
// ActionRuns are a field in Action
|
|
||||||
type ActionRuns struct {
|
|
||||||
Using ActionRunsUsing `yaml:"using"`
|
|
||||||
Env map[string]string `yaml:"env"`
|
|
||||||
Main string `yaml:"main"`
|
|
||||||
Pre string `yaml:"pre"`
|
|
||||||
PreIf string `yaml:"pre-if"`
|
|
||||||
Post string `yaml:"post"`
|
|
||||||
PostIf string `yaml:"post-if"`
|
|
||||||
Image string `yaml:"image"`
|
|
||||||
PreEntrypoint string `yaml:"pre-entrypoint"`
|
|
||||||
Entrypoint string `yaml:"entrypoint"`
|
|
||||||
PostEntrypoint string `yaml:"post-entrypoint"`
|
|
||||||
Args []string `yaml:"args"`
|
|
||||||
Steps []Step `yaml:"steps"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Action describes a metadata file for GitHub actions. The metadata filename must be either action.yml or action.yaml. The data in the metadata file defines the inputs, outputs and main entrypoint for your action.
|
|
||||||
type Action struct {
|
|
||||||
Name string `yaml:"name"`
|
|
||||||
Author string `yaml:"author"`
|
|
||||||
Description string `yaml:"description"`
|
|
||||||
Inputs map[string]Input `yaml:"inputs"`
|
|
||||||
Outputs map[string]Output `yaml:"outputs"`
|
|
||||||
Runs ActionRuns `yaml:"runs"`
|
|
||||||
Branding struct {
|
|
||||||
Color string `yaml:"color"`
|
|
||||||
Icon string `yaml:"icon"`
|
|
||||||
} `yaml:"branding"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Input parameters allow you to specify data that the action expects to use during runtime. GitHub stores input parameters as environment variables. Input ids with uppercase letters are converted to lowercase during runtime. We recommended using lowercase input ids.
|
|
||||||
type Input struct {
|
|
||||||
Description string `yaml:"description"`
|
|
||||||
Required bool `yaml:"required"`
|
|
||||||
Default string `yaml:"default"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Output parameters allow you to declare data that an action sets. Actions that run later in a workflow can use the output data set in previously run actions. For example, if you had an action that performed the addition of two inputs (x + y = z), the action could output the sum (z) for other actions to use as an input.
|
|
||||||
type Output struct {
|
|
||||||
Description string `yaml:"description"`
|
|
||||||
Value string `yaml:"value"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReadAction reads an action from a reader
|
|
||||||
func ReadAction(in io.Reader) (*Action, error) {
|
|
||||||
a := new(Action)
|
|
||||||
err := yaml.NewDecoder(in).Decode(a)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// set defaults
|
|
||||||
if a.Runs.PreIf == "" {
|
|
||||||
a.Runs.PreIf = "always()"
|
|
||||||
}
|
|
||||||
if a.Runs.PostIf == "" {
|
|
||||||
a.Runs.PostIf = "always()"
|
|
||||||
}
|
|
||||||
|
|
||||||
return a, nil
|
|
||||||
}
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package model
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestReadActionDefaultsAndCaseInsensitiveUsing(t *testing.T) {
|
|
||||||
action, err := ReadAction(strings.NewReader(`
|
|
||||||
name: example
|
|
||||||
runs:
|
|
||||||
using: NoDe24
|
|
||||||
main: dist/index.js
|
|
||||||
`))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if action.Runs.Using != ActionRunsUsingNode24 {
|
|
||||||
t.Fatalf("using = %q, want %q", action.Runs.Using, ActionRunsUsingNode24)
|
|
||||||
}
|
|
||||||
if action.Runs.PreIf != "always()" {
|
|
||||||
t.Fatalf("pre-if = %q, want always()", action.Runs.PreIf)
|
|
||||||
}
|
|
||||||
if action.Runs.PostIf != "always()" {
|
|
||||||
t.Fatalf("post-if = %q, want always()", action.Runs.PostIf)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestReadActionPreservesExplicitConditions(t *testing.T) {
|
|
||||||
action, err := ReadAction(strings.NewReader(`
|
|
||||||
runs:
|
|
||||||
using: composite
|
|
||||||
pre-if: success()
|
|
||||||
post-if: failure()
|
|
||||||
steps:
|
|
||||||
- run: echo hello
|
|
||||||
`))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if action.Runs.PreIf != "success()" || action.Runs.PostIf != "failure()" {
|
|
||||||
t.Fatalf("conditions = %q/%q, want explicit values", action.Runs.PreIf, action.Runs.PostIf)
|
|
||||||
}
|
|
||||||
if !action.Runs.Using.IsComposite() || action.Runs.Using.IsDocker() || action.Runs.Using.IsNode() {
|
|
||||||
t.Fatalf("unexpected using predicates for %q", action.Runs.Using)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestReadActionRejectsUnknownUsing(t *testing.T) {
|
|
||||||
_, err := ReadAction(strings.NewReader(`
|
|
||||||
runs:
|
|
||||||
using: node99
|
|
||||||
`))
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected unknown runs.using to fail")
|
|
||||||
}
|
|
||||||
if !strings.Contains(err.Error(), "node99") {
|
|
||||||
t.Fatalf("error = %q, want invalid value", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestReadActionDockerEntrypoints(t *testing.T) {
|
|
||||||
action, err := ReadAction(strings.NewReader(`
|
|
||||||
runs:
|
|
||||||
using: docker
|
|
||||||
image: Dockerfile
|
|
||||||
pre-entrypoint: pre.sh
|
|
||||||
post-entrypoint: post.sh
|
|
||||||
`))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if action.Runs.PreEntrypoint != "pre.sh" {
|
|
||||||
t.Fatalf("pre-entrypoint = %q, want pre.sh", action.Runs.PreEntrypoint)
|
|
||||||
}
|
|
||||||
if action.Runs.PostEntrypoint != "post.sh" {
|
|
||||||
t.Fatalf("post-entrypoint = %q, want post.sh", action.Runs.PostEntrypoint)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,222 +0,0 @@
|
|||||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
|
||||||
// Copyright 2021 The nektos/act Authors. All rights reserved.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package model
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
|
||||||
"gitea.com/gitea/runner/act/common/git"
|
|
||||||
)
|
|
||||||
|
|
||||||
type GithubContext struct {
|
|
||||||
Event map[string]any `json:"event"`
|
|
||||||
EventPath string `json:"event_path"`
|
|
||||||
Workflow string `json:"workflow"`
|
|
||||||
RunID string `json:"run_id"`
|
|
||||||
RunNumber string `json:"run_number"`
|
|
||||||
Actor string `json:"actor"`
|
|
||||||
Repository string `json:"repository"`
|
|
||||||
EventName string `json:"event_name"`
|
|
||||||
Sha string `json:"sha"`
|
|
||||||
Ref string `json:"ref"`
|
|
||||||
RefName string `json:"ref_name"`
|
|
||||||
RefType string `json:"ref_type"`
|
|
||||||
HeadRef string `json:"head_ref"`
|
|
||||||
BaseRef string `json:"base_ref"`
|
|
||||||
Token string `json:"token"`
|
|
||||||
Workspace string `json:"workspace"`
|
|
||||||
Action string `json:"action"`
|
|
||||||
ActionPath string `json:"action_path"`
|
|
||||||
ActionRef string `json:"action_ref"`
|
|
||||||
ActionRepository string `json:"action_repository"`
|
|
||||||
Job string `json:"job"`
|
|
||||||
JobName string `json:"job_name"`
|
|
||||||
RepositoryOwner string `json:"repository_owner"`
|
|
||||||
RetentionDays string `json:"retention_days"`
|
|
||||||
RunnerPerflog string `json:"runner_perflog"`
|
|
||||||
RunnerTrackingID string `json:"runner_tracking_id"`
|
|
||||||
ServerURL string `json:"server_url"`
|
|
||||||
APIURL string `json:"api_url"`
|
|
||||||
GraphQLURL string `json:"graphql_url"`
|
|
||||||
|
|
||||||
// For Gitea
|
|
||||||
RunAttempt string `json:"run_attempt"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func asString(v any) string {
|
|
||||||
if v == nil {
|
|
||||||
return ""
|
|
||||||
} else if s, ok := v.(string); ok {
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func nestedMapLookup(m map[string]any, ks ...string) (rval any) {
|
|
||||||
var ok bool
|
|
||||||
|
|
||||||
if len(ks) == 0 { // degenerate input
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if rval, ok = m[ks[0]]; !ok {
|
|
||||||
return nil
|
|
||||||
} else if len(ks) == 1 { // we've reached the final key
|
|
||||||
return rval
|
|
||||||
} else if m, ok = rval.(map[string]any); !ok {
|
|
||||||
return nil
|
|
||||||
} else { // 1+ more keys
|
|
||||||
return nestedMapLookup(m, ks[1:]...)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func withDefaultBranch(ctx context.Context, b string, event map[string]any) map[string]any {
|
|
||||||
repoI, ok := event["repository"]
|
|
||||||
if !ok {
|
|
||||||
repoI = make(map[string]any)
|
|
||||||
}
|
|
||||||
|
|
||||||
repo, ok := repoI.(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
common.Logger(ctx).Warnf("unable to set default branch to %v", b)
|
|
||||||
return event
|
|
||||||
}
|
|
||||||
|
|
||||||
// if the branch is already there return with no changes
|
|
||||||
if _, ok = repo["default_branch"]; ok {
|
|
||||||
return event
|
|
||||||
}
|
|
||||||
|
|
||||||
repo["default_branch"] = b
|
|
||||||
event["repository"] = repo
|
|
||||||
|
|
||||||
return event
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
|
||||||
findGitRef = git.FindGitRef
|
|
||||||
findGitRevision = git.FindGitRevision
|
|
||||||
)
|
|
||||||
|
|
||||||
func (ghc *GithubContext) SetRef(ctx context.Context, defaultBranch, repoPath string) {
|
|
||||||
logger := common.Logger(ctx)
|
|
||||||
|
|
||||||
// https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows
|
|
||||||
// https://docs.github.com/en/developers/webhooks-and-events/webhooks/webhook-events-and-payloads
|
|
||||||
switch ghc.EventName {
|
|
||||||
case "pull_request_target":
|
|
||||||
ghc.Ref = "refs/heads/" + ghc.BaseRef
|
|
||||||
case "pull_request", "pull_request_review", "pull_request_review_comment":
|
|
||||||
ghc.Ref = fmt.Sprintf("refs/pull/%.0f/merge", ghc.Event["number"])
|
|
||||||
case "deployment", "deployment_status":
|
|
||||||
ghc.Ref = asString(nestedMapLookup(ghc.Event, "deployment", "ref"))
|
|
||||||
case "release":
|
|
||||||
ghc.Ref = "refs/tags/" + asString(nestedMapLookup(ghc.Event, "release", "tag_name"))
|
|
||||||
case "push", "create", "workflow_dispatch":
|
|
||||||
ghc.Ref = asString(ghc.Event["ref"])
|
|
||||||
default:
|
|
||||||
defaultBranch := asString(nestedMapLookup(ghc.Event, "repository", "default_branch"))
|
|
||||||
if defaultBranch != "" {
|
|
||||||
ghc.Ref = "refs/heads/" + defaultBranch
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ghc.Ref == "" {
|
|
||||||
ref, err := findGitRef(ctx, repoPath)
|
|
||||||
if err != nil {
|
|
||||||
logger.Warningf("unable to get git ref: %v", err)
|
|
||||||
} else {
|
|
||||||
logger.Debugf("using github ref: %s", ref)
|
|
||||||
ghc.Ref = ref
|
|
||||||
}
|
|
||||||
|
|
||||||
// set the branch in the event data
|
|
||||||
if defaultBranch != "" {
|
|
||||||
ghc.Event = withDefaultBranch(ctx, defaultBranch, ghc.Event)
|
|
||||||
} else {
|
|
||||||
ghc.Event = withDefaultBranch(ctx, "master", ghc.Event)
|
|
||||||
}
|
|
||||||
|
|
||||||
if ghc.Ref == "" {
|
|
||||||
ghc.Ref = "refs/heads/" + asString(nestedMapLookup(ghc.Event, "repository", "default_branch"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ghc *GithubContext) SetSha(ctx context.Context, repoPath string) {
|
|
||||||
logger := common.Logger(ctx)
|
|
||||||
|
|
||||||
// https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows
|
|
||||||
// https://docs.github.com/en/developers/webhooks-and-events/webhooks/webhook-events-and-payloads
|
|
||||||
switch ghc.EventName {
|
|
||||||
case "pull_request_target":
|
|
||||||
ghc.Sha = asString(nestedMapLookup(ghc.Event, "pull_request", "base", "sha"))
|
|
||||||
case "deployment", "deployment_status":
|
|
||||||
ghc.Sha = asString(nestedMapLookup(ghc.Event, "deployment", "sha"))
|
|
||||||
case "push", "create", "workflow_dispatch":
|
|
||||||
if deleted, ok := ghc.Event["deleted"].(bool); ok && !deleted {
|
|
||||||
ghc.Sha = asString(ghc.Event["after"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ghc.Sha == "" {
|
|
||||||
_, sha, err := findGitRevision(ctx, repoPath)
|
|
||||||
if err != nil {
|
|
||||||
logger.Warningf("unable to get git revision: %v", err)
|
|
||||||
} else {
|
|
||||||
ghc.Sha = sha
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ghc *GithubContext) SetRepositoryAndOwner(ctx context.Context, githubInstance, remoteName, repoPath string) {
|
|
||||||
if ghc.Repository == "" {
|
|
||||||
repo, err := git.FindGithubRepo(ctx, repoPath, githubInstance, remoteName)
|
|
||||||
if err != nil {
|
|
||||||
common.Logger(ctx).Warningf("unable to get git repo (githubInstance: %v; remoteName: %v, repoPath: %v): %v", githubInstance, remoteName, repoPath, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ghc.Repository = repo
|
|
||||||
}
|
|
||||||
ghc.RepositoryOwner = strings.Split(ghc.Repository, "/")[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ghc *GithubContext) SetRefTypeAndName() {
|
|
||||||
var refType, refName string
|
|
||||||
|
|
||||||
// https://docs.github.com/en/actions/learn-github-actions/environment-variables
|
|
||||||
if strings.HasPrefix(ghc.Ref, "refs/tags/") {
|
|
||||||
refType = "tag"
|
|
||||||
refName = ghc.Ref[len("refs/tags/"):]
|
|
||||||
} else if strings.HasPrefix(ghc.Ref, "refs/heads/") {
|
|
||||||
refType = "branch"
|
|
||||||
refName = ghc.Ref[len("refs/heads/"):]
|
|
||||||
} else if strings.HasPrefix(ghc.Ref, "refs/pull/") {
|
|
||||||
refType = ""
|
|
||||||
refName = ghc.Ref[len("refs/pull/"):]
|
|
||||||
}
|
|
||||||
|
|
||||||
if ghc.RefType == "" {
|
|
||||||
ghc.RefType = refType
|
|
||||||
}
|
|
||||||
|
|
||||||
if ghc.RefName == "" {
|
|
||||||
ghc.RefName = refName
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ghc *GithubContext) SetBaseAndHeadRef() {
|
|
||||||
if ghc.EventName == "pull_request" || ghc.EventName == "pull_request_target" {
|
|
||||||
if ghc.BaseRef == "" {
|
|
||||||
ghc.BaseRef = asString(nestedMapLookup(ghc.Event, "pull_request", "base", "ref"))
|
|
||||||
}
|
|
||||||
|
|
||||||
if ghc.HeadRef == "" {
|
|
||||||
ghc.HeadRef = asString(nestedMapLookup(ghc.Event, "pull_request", "head", "ref"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
|
||||||
// Copyright 2021 The nektos/act Authors. All rights reserved.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package model
|
|
||||||
|
|
||||||
type JobContext struct {
|
|
||||||
Status string `json:"status"`
|
|
||||||
Container JobContainerContext `json:"container"`
|
|
||||||
Services map[string]JobService `json:"services"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type JobContainerContext struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Network string `json:"network"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type JobService struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Network string `json:"network"`
|
|
||||||
Ports map[string]string `json:"ports"` // container port to the published host port
|
|
||||||
}
|
|
||||||
@@ -1,410 +0,0 @@
|
|||||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
|
||||||
// Copyright 2020 The nektos/act Authors. All rights reserved.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package model
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"io/fs"
|
|
||||||
"math"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"regexp"
|
|
||||||
"slices"
|
|
||||||
|
|
||||||
log "github.com/sirupsen/logrus"
|
|
||||||
)
|
|
||||||
|
|
||||||
// WorkflowPlanner contains methods for creating plans
|
|
||||||
type WorkflowPlanner interface {
|
|
||||||
PlanEvent(eventName string) (*Plan, error)
|
|
||||||
PlanJob(jobName string) (*Plan, error)
|
|
||||||
PlanAll() (*Plan, error)
|
|
||||||
GetEvents() []string
|
|
||||||
}
|
|
||||||
|
|
||||||
// Plan contains a list of stages to run in series
|
|
||||||
type Plan struct {
|
|
||||||
Stages []*Stage
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stage contains a list of runs to execute in parallel
|
|
||||||
type Stage struct {
|
|
||||||
Runs []*Run
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run represents a job from a workflow that needs to be run
|
|
||||||
type Run struct {
|
|
||||||
Workflow *Workflow
|
|
||||||
JobID string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *Run) String() string {
|
|
||||||
jobName := r.Job().Name
|
|
||||||
if jobName == "" {
|
|
||||||
jobName = r.JobID
|
|
||||||
}
|
|
||||||
return jobName
|
|
||||||
}
|
|
||||||
|
|
||||||
// Job returns the job for this Run
|
|
||||||
func (r *Run) Job() *Job {
|
|
||||||
return r.Workflow.GetJob(r.JobID)
|
|
||||||
}
|
|
||||||
|
|
||||||
type WorkflowFiles struct {
|
|
||||||
workflowDirEntry os.DirEntry
|
|
||||||
dirPath string
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewWorkflowPlanner will load a specific workflow, all workflows from a directory or all workflows from a directory and its subdirectories
|
|
||||||
func NewWorkflowPlanner(path string, noWorkflowRecurse bool) (WorkflowPlanner, error) {
|
|
||||||
path, err := filepath.Abs(path)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
fi, err := os.Stat(path)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var workflows []WorkflowFiles
|
|
||||||
|
|
||||||
if fi.IsDir() {
|
|
||||||
log.Debugf("Loading workflows from '%s'", path)
|
|
||||||
if noWorkflowRecurse {
|
|
||||||
files, err := os.ReadDir(path)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, v := range files {
|
|
||||||
workflows = append(workflows, WorkflowFiles{
|
|
||||||
dirPath: path,
|
|
||||||
workflowDirEntry: v,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
log.Debug("Loading workflows recursively")
|
|
||||||
if err := filepath.Walk(path,
|
|
||||||
func(p string, f os.FileInfo, err error) error {
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if !f.IsDir() {
|
|
||||||
log.Debugf("Found workflow '%s' in '%s'", f.Name(), p)
|
|
||||||
workflows = append(workflows, WorkflowFiles{
|
|
||||||
dirPath: filepath.Dir(p),
|
|
||||||
workflowDirEntry: fs.FileInfoToDirEntry(f),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
log.Debugf("Loading workflow '%s'", path)
|
|
||||||
dirname := filepath.Dir(path)
|
|
||||||
|
|
||||||
workflows = append(workflows, WorkflowFiles{
|
|
||||||
dirPath: dirname,
|
|
||||||
workflowDirEntry: fs.FileInfoToDirEntry(fi),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
wp := new(workflowPlanner)
|
|
||||||
for _, wf := range workflows {
|
|
||||||
ext := filepath.Ext(wf.workflowDirEntry.Name())
|
|
||||||
if ext == ".yml" || ext == ".yaml" {
|
|
||||||
f, err := os.Open(filepath.Join(wf.dirPath, wf.workflowDirEntry.Name()))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Debugf("Reading workflow '%s'", f.Name())
|
|
||||||
workflow, err := ReadWorkflow(f)
|
|
||||||
if err != nil {
|
|
||||||
_ = f.Close()
|
|
||||||
if err == io.EOF {
|
|
||||||
return nil, fmt.Errorf("unable to read workflow '%s': file is empty: %w", wf.workflowDirEntry.Name(), err)
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("workflow is not valid. '%s': %w", wf.workflowDirEntry.Name(), err)
|
|
||||||
}
|
|
||||||
_, err = f.Seek(0, 0)
|
|
||||||
if err != nil {
|
|
||||||
_ = f.Close()
|
|
||||||
return nil, fmt.Errorf("error occurring when resetting io pointer in '%s': %w", wf.workflowDirEntry.Name(), err)
|
|
||||||
}
|
|
||||||
|
|
||||||
workflow.File = wf.workflowDirEntry.Name()
|
|
||||||
if workflow.Name == "" {
|
|
||||||
workflow.Name = wf.workflowDirEntry.Name()
|
|
||||||
}
|
|
||||||
|
|
||||||
err = validateJobName(workflow)
|
|
||||||
if err != nil {
|
|
||||||
_ = f.Close()
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
wp.workflows = append(wp.workflows, workflow)
|
|
||||||
_ = f.Close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return wp, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// CombineWorkflowPlanner combines workflows to a WorkflowPlanner
|
|
||||||
func CombineWorkflowPlanner(workflows ...*Workflow) WorkflowPlanner {
|
|
||||||
return &workflowPlanner{
|
|
||||||
workflows: workflows,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewSingleWorkflowPlanner(name string, f io.Reader) (WorkflowPlanner, error) {
|
|
||||||
wp := new(workflowPlanner)
|
|
||||||
|
|
||||||
log.Debugf("Reading workflow %s", name)
|
|
||||||
workflow, err := ReadWorkflow(f)
|
|
||||||
if err != nil {
|
|
||||||
if err == io.EOF {
|
|
||||||
return nil, fmt.Errorf("unable to read workflow '%s': file is empty: %w", name, err)
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("workflow is not valid. '%s': %w", name, err)
|
|
||||||
}
|
|
||||||
workflow.File = name
|
|
||||||
if workflow.Name == "" {
|
|
||||||
workflow.Name = name
|
|
||||||
}
|
|
||||||
|
|
||||||
err = validateJobName(workflow)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
wp.workflows = append(wp.workflows, workflow)
|
|
||||||
|
|
||||||
return wp, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateJobName(workflow *Workflow) error {
|
|
||||||
jobNameRegex := regexp.MustCompile(`^([[:alpha:]_][[:alnum:]_\-]*)$`)
|
|
||||||
for k := range workflow.Jobs {
|
|
||||||
if ok := jobNameRegex.MatchString(k); !ok {
|
|
||||||
return fmt.Errorf("workflow is not valid. '%s': Job name '%s' is invalid. Names must start with a letter or '_' and contain only alphanumeric characters, '-', or '_'", workflow.Name, k)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type workflowPlanner struct {
|
|
||||||
workflows []*Workflow
|
|
||||||
}
|
|
||||||
|
|
||||||
// PlanEvent builds a new list of runs to execute in parallel for an event name
|
|
||||||
func (wp *workflowPlanner) PlanEvent(eventName string) (*Plan, error) {
|
|
||||||
plan := new(Plan)
|
|
||||||
if len(wp.workflows) == 0 {
|
|
||||||
log.Debug("no workflows found by planner")
|
|
||||||
return plan, nil
|
|
||||||
}
|
|
||||||
var lastErr error
|
|
||||||
|
|
||||||
for _, w := range wp.workflows {
|
|
||||||
events := w.On()
|
|
||||||
if len(events) == 0 {
|
|
||||||
log.Debugf("no events found for workflow: %s", w.File)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, e := range events {
|
|
||||||
if e == eventName {
|
|
||||||
stages, err := createStages(w, w.GetJobIDs()...)
|
|
||||||
if err != nil {
|
|
||||||
log.Warn(err)
|
|
||||||
lastErr = err
|
|
||||||
} else {
|
|
||||||
plan.mergeStages(stages)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return plan, lastErr
|
|
||||||
}
|
|
||||||
|
|
||||||
// PlanJob builds a new run to execute in parallel for a job name
|
|
||||||
func (wp *workflowPlanner) PlanJob(jobName string) (*Plan, error) {
|
|
||||||
plan := new(Plan)
|
|
||||||
if len(wp.workflows) == 0 {
|
|
||||||
log.Debugf("no jobs found for workflow: %s", jobName)
|
|
||||||
}
|
|
||||||
var lastErr error
|
|
||||||
|
|
||||||
for _, w := range wp.workflows {
|
|
||||||
stages, err := createStages(w, jobName)
|
|
||||||
if err != nil {
|
|
||||||
log.Warn(err)
|
|
||||||
lastErr = err
|
|
||||||
} else {
|
|
||||||
plan.mergeStages(stages)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return plan, lastErr
|
|
||||||
}
|
|
||||||
|
|
||||||
// PlanAll builds a new run to execute in parallel all
|
|
||||||
func (wp *workflowPlanner) PlanAll() (*Plan, error) {
|
|
||||||
plan := new(Plan)
|
|
||||||
if len(wp.workflows) == 0 {
|
|
||||||
log.Debug("no workflows found by planner")
|
|
||||||
return plan, nil
|
|
||||||
}
|
|
||||||
var lastErr error
|
|
||||||
|
|
||||||
for _, w := range wp.workflows {
|
|
||||||
stages, err := createStages(w, w.GetJobIDs()...)
|
|
||||||
if err != nil {
|
|
||||||
log.Warn(err)
|
|
||||||
lastErr = err
|
|
||||||
} else {
|
|
||||||
plan.mergeStages(stages)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return plan, lastErr
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetEvents gets all the events in the workflows file
|
|
||||||
func (wp *workflowPlanner) GetEvents() []string {
|
|
||||||
events := make([]string, 0)
|
|
||||||
for _, w := range wp.workflows {
|
|
||||||
found := false
|
|
||||||
for _, e := range events {
|
|
||||||
if slices.Contains(w.On(), e) {
|
|
||||||
found = true
|
|
||||||
}
|
|
||||||
if found {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !found {
|
|
||||||
events = append(events, w.On()...)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// sort the list based on depth of dependencies
|
|
||||||
slices.Sort(events)
|
|
||||||
|
|
||||||
return events
|
|
||||||
}
|
|
||||||
|
|
||||||
// MaxRunNameLen determines the max name length of all jobs
|
|
||||||
func (p *Plan) MaxRunNameLen() int {
|
|
||||||
maxRunNameLen := 0
|
|
||||||
for _, stage := range p.Stages {
|
|
||||||
for _, run := range stage.Runs {
|
|
||||||
runNameLen := len(run.String())
|
|
||||||
if runNameLen > maxRunNameLen {
|
|
||||||
maxRunNameLen = runNameLen
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return maxRunNameLen
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetJobIDs will get all the job names in the stage
|
|
||||||
func (s *Stage) GetJobIDs() []string {
|
|
||||||
names := make([]string, 0)
|
|
||||||
for _, r := range s.Runs {
|
|
||||||
names = append(names, r.JobID)
|
|
||||||
}
|
|
||||||
return names
|
|
||||||
}
|
|
||||||
|
|
||||||
// Merge stages with existing stages in plan
|
|
||||||
func (p *Plan) mergeStages(stages []*Stage) {
|
|
||||||
newStages := make([]*Stage, int(math.Max(float64(len(p.Stages)), float64(len(stages)))))
|
|
||||||
for i := range newStages {
|
|
||||||
newStages[i] = new(Stage)
|
|
||||||
if i >= len(p.Stages) {
|
|
||||||
newStages[i].Runs = append(newStages[i].Runs, stages[i].Runs...)
|
|
||||||
} else if i >= len(stages) {
|
|
||||||
newStages[i].Runs = append(newStages[i].Runs, p.Stages[i].Runs...)
|
|
||||||
} else {
|
|
||||||
newStages[i].Runs = append(newStages[i].Runs, p.Stages[i].Runs...)
|
|
||||||
newStages[i].Runs = append(newStages[i].Runs, stages[i].Runs...)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
p.Stages = newStages
|
|
||||||
}
|
|
||||||
|
|
||||||
func createStages(w *Workflow, jobIDs ...string) ([]*Stage, error) {
|
|
||||||
// first, build a list of all the necessary jobs to run, and their dependencies
|
|
||||||
jobDependencies := make(map[string][]string)
|
|
||||||
for len(jobIDs) > 0 {
|
|
||||||
newJobIDs := make([]string, 0)
|
|
||||||
for _, jID := range jobIDs {
|
|
||||||
// make sure we haven't visited this job yet
|
|
||||||
if _, ok := jobDependencies[jID]; !ok {
|
|
||||||
if job := w.GetJob(jID); job != nil {
|
|
||||||
jobDependencies[jID] = job.Needs()
|
|
||||||
newJobIDs = append(newJobIDs, job.Needs()...)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
jobIDs = newJobIDs
|
|
||||||
}
|
|
||||||
|
|
||||||
// next, build an execution graph
|
|
||||||
stages := make([]*Stage, 0)
|
|
||||||
for len(jobDependencies) > 0 {
|
|
||||||
stage := new(Stage)
|
|
||||||
for jID, jDeps := range jobDependencies {
|
|
||||||
// make sure all deps are in the graph already
|
|
||||||
if listInStages(jDeps, stages...) {
|
|
||||||
stage.Runs = append(stage.Runs, &Run{
|
|
||||||
Workflow: w,
|
|
||||||
JobID: jID,
|
|
||||||
})
|
|
||||||
delete(jobDependencies, jID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(stage.Runs) == 0 {
|
|
||||||
return nil, fmt.Errorf("unable to build dependency graph for %s (%s)", w.Name, w.File)
|
|
||||||
}
|
|
||||||
stages = append(stages, stage)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(stages) == 0 {
|
|
||||||
return nil, errors.New("Could not find any stages to run. View the valid jobs with `act --list`. Use `act --help` to find how to filter by Job ID/Workflow/Event Name")
|
|
||||||
}
|
|
||||||
|
|
||||||
return stages, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// return true iff all strings in srcList exist in at least one of the stages
|
|
||||||
func listInStages(srcList []string, stages ...*Stage) bool {
|
|
||||||
for _, src := range srcList {
|
|
||||||
found := false
|
|
||||||
for _, stage := range stages {
|
|
||||||
for _, search := range stage.GetJobIDs() {
|
|
||||||
if src == search {
|
|
||||||
found = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !found {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
@@ -1,199 +0,0 @@
|
|||||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
|
||||||
// Copyright 2021 The nektos/act Authors. All rights reserved.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package model
|
|
||||||
|
|
||||||
import (
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
log "github.com/sirupsen/logrus"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
type WorkflowPlanTest struct {
|
|
||||||
workflowPath string
|
|
||||||
errorMessage string
|
|
||||||
noWorkflowRecurse bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPlanner(t *testing.T) {
|
|
||||||
log.SetLevel(log.DebugLevel)
|
|
||||||
|
|
||||||
tables := []WorkflowPlanTest{
|
|
||||||
{"invalid-job-name/invalid-1.yml", "workflow is not valid. 'invalid-job-name-1': Job name 'invalid-JOB-Name-v1.2.3-docker_hub' is invalid. Names must start with a letter or '_' and contain only alphanumeric characters, '-', or '_'", false},
|
|
||||||
{"invalid-job-name/invalid-2.yml", "workflow is not valid. 'invalid-job-name-2': Job name '1234invalid-JOB-Name-v123-docker_hub' is invalid. Names must start with a letter or '_' and contain only alphanumeric characters, '-', or '_'", false},
|
|
||||||
{"invalid-job-name/valid-1.yml", "", false},
|
|
||||||
{"invalid-job-name/valid-2.yml", "", false},
|
|
||||||
{"empty-workflow", "unable to read workflow 'push.yml': file is empty: EOF", false},
|
|
||||||
{"nested", "unable to read workflow 'fail.yml': file is empty: EOF", false},
|
|
||||||
{"nested", "", true},
|
|
||||||
}
|
|
||||||
|
|
||||||
workdir, err := filepath.Abs("testdata")
|
|
||||||
assert.NoError(t, err, workdir) //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
for _, table := range tables {
|
|
||||||
fullWorkflowPath := filepath.Join(workdir, table.workflowPath)
|
|
||||||
_, err = NewWorkflowPlanner(fullWorkflowPath, table.noWorkflowRecurse)
|
|
||||||
if table.errorMessage == "" {
|
|
||||||
assert.NoError(t, err, "WorkflowPlanner should exit without any error")
|
|
||||||
} else {
|
|
||||||
assert.EqualError(t, err, table.errorMessage)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWorkflow(t *testing.T) {
|
|
||||||
log.SetLevel(log.DebugLevel)
|
|
||||||
|
|
||||||
workflow := Workflow{
|
|
||||||
Jobs: map[string]*Job{
|
|
||||||
"valid_job": {
|
|
||||||
Name: "valid_job",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check that an invalid job id returns error
|
|
||||||
result, err := createStages(&workflow, "invalid_job_id")
|
|
||||||
assert.Error(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
assert.Nil(t, result)
|
|
||||||
|
|
||||||
// Check that an valid job id returns non-error
|
|
||||||
result, err = createStages(&workflow, "valid_job")
|
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
assert.NotNil(t, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNewSingleWorkflowPlannerAndPlanMethods(t *testing.T) {
|
|
||||||
planner, err := NewSingleWorkflowPlanner("ci.yml", strings.NewReader(`
|
|
||||||
name: CI
|
|
||||||
on: [push, pull_request]
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
name: Build project
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- run: make build
|
|
||||||
test:
|
|
||||||
needs: build
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- run: make test
|
|
||||||
`))
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
assert.Equal(t, []string{"pull_request", "push"}, planner.GetEvents())
|
|
||||||
|
|
||||||
eventPlan, err := planner.PlanEvent("push")
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Len(t, eventPlan.Stages, 2)
|
|
||||||
assert.Equal(t, []string{"build"}, eventPlan.Stages[0].GetJobIDs())
|
|
||||||
assert.Equal(t, []string{"test"}, eventPlan.Stages[1].GetJobIDs())
|
|
||||||
assert.Equal(t, len("Build project"), eventPlan.MaxRunNameLen())
|
|
||||||
assert.Equal(t, "Build project", eventPlan.Stages[0].Runs[0].String())
|
|
||||||
assert.Equal(t, "build", eventPlan.Stages[0].Runs[0].JobID)
|
|
||||||
assert.NotNil(t, eventPlan.Stages[0].Runs[0].Job())
|
|
||||||
|
|
||||||
jobPlan, err := planner.PlanJob("test")
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Len(t, jobPlan.Stages, 2)
|
|
||||||
assert.Equal(t, []string{"build"}, jobPlan.Stages[0].GetJobIDs())
|
|
||||||
assert.Equal(t, []string{"test"}, jobPlan.Stages[1].GetJobIDs())
|
|
||||||
|
|
||||||
allPlan, err := planner.PlanAll()
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Len(t, allPlan.Stages, 2)
|
|
||||||
assert.Equal(t, []string{"build"}, allPlan.Stages[0].GetJobIDs())
|
|
||||||
assert.Equal(t, []string{"test"}, allPlan.Stages[1].GetJobIDs())
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCombineWorkflowPlannerMergesWorkflowStages(t *testing.T) {
|
|
||||||
first := mustReadWorkflow(t, `
|
|
||||||
name: First
|
|
||||||
on: push
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- run: make build
|
|
||||||
`)
|
|
||||||
second := mustReadWorkflow(t, `
|
|
||||||
name: Second
|
|
||||||
on: push
|
|
||||||
jobs:
|
|
||||||
lint:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- run: make lint
|
|
||||||
test:
|
|
||||||
needs: lint
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- run: make test
|
|
||||||
`)
|
|
||||||
|
|
||||||
planner := CombineWorkflowPlanner(first, second)
|
|
||||||
plan, err := planner.PlanEvent("push")
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Len(t, plan.Stages, 2)
|
|
||||||
assert.ElementsMatch(t, []string{"build", "lint"}, plan.Stages[0].GetJobIDs())
|
|
||||||
assert.Equal(t, []string{"test"}, plan.Stages[1].GetJobIDs())
|
|
||||||
|
|
||||||
empty, err := planner.PlanEvent("schedule")
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Empty(t, empty.Stages)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPlannerErrorsForMissingAndCyclicJobs(t *testing.T) {
|
|
||||||
workflow := mustReadWorkflow(t, `
|
|
||||||
name: Cyclic
|
|
||||||
on: push
|
|
||||||
jobs:
|
|
||||||
a:
|
|
||||||
needs: b
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- run: echo a
|
|
||||||
b:
|
|
||||||
needs: a
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- run: echo b
|
|
||||||
`)
|
|
||||||
planner := CombineWorkflowPlanner(workflow)
|
|
||||||
|
|
||||||
plan, err := planner.PlanJob("missing")
|
|
||||||
require.Error(t, err)
|
|
||||||
assert.Empty(t, plan.Stages)
|
|
||||||
assert.Contains(t, err.Error(), "Could not find any stages")
|
|
||||||
|
|
||||||
plan, err = planner.PlanEvent("push")
|
|
||||||
require.Error(t, err)
|
|
||||||
assert.Empty(t, plan.Stages)
|
|
||||||
assert.Contains(t, err.Error(), "unable to build dependency graph")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNewSingleWorkflowPlannerErrors(t *testing.T) {
|
|
||||||
_, err := NewSingleWorkflowPlanner("empty.yml", strings.NewReader(""))
|
|
||||||
require.Error(t, err)
|
|
||||||
assert.Contains(t, err.Error(), "file is empty")
|
|
||||||
|
|
||||||
_, err = NewSingleWorkflowPlanner("invalid.yml", strings.NewReader("jobs: ["))
|
|
||||||
require.Error(t, err)
|
|
||||||
assert.Contains(t, err.Error(), "workflow is not valid")
|
|
||||||
}
|
|
||||||
|
|
||||||
func mustReadWorkflow(t *testing.T, content string) *Workflow {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
workflow, err := ReadWorkflow(strings.NewReader(content))
|
|
||||||
require.NoError(t, err)
|
|
||||||
if workflow.Name == "" {
|
|
||||||
workflow.Name = "workflow"
|
|
||||||
}
|
|
||||||
return workflow
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
|
||||||
// Copyright 2021 The nektos/act Authors. All rights reserved.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package model
|
|
||||||
|
|
||||||
import "fmt"
|
|
||||||
|
|
||||||
type stepStatus int
|
|
||||||
|
|
||||||
const (
|
|
||||||
StepStatusSuccess stepStatus = iota
|
|
||||||
StepStatusFailure
|
|
||||||
StepStatusSkipped
|
|
||||||
)
|
|
||||||
|
|
||||||
var stepStatusStrings = [...]string{
|
|
||||||
"success",
|
|
||||||
"failure",
|
|
||||||
"skipped",
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s stepStatus) MarshalText() ([]byte, error) {
|
|
||||||
return []byte(s.String()), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *stepStatus) UnmarshalText(b []byte) error {
|
|
||||||
str := string(b)
|
|
||||||
for i, name := range stepStatusStrings {
|
|
||||||
if name == str {
|
|
||||||
*s = stepStatus(i)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return fmt.Errorf("invalid step status %q", str)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s stepStatus) String() string {
|
|
||||||
if int(s) >= len(stepStatusStrings) {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return stepStatusStrings[s]
|
|
||||||
}
|
|
||||||
|
|
||||||
type StepResult struct {
|
|
||||||
Outputs map[string]string `json:"outputs"`
|
|
||||||
Conclusion stepStatus `json:"conclusion"`
|
|
||||||
Outcome stepStatus `json:"outcome"`
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
name: invalid-job-name-1
|
|
||||||
on: push
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
invalid-JOB-Name-v1.2.3-docker_hub:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- run: echo hi
|
|
||||||
valid-JOB-Name-v123-docker_hub:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- run: echo hi
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
name: invalid-job-name-2
|
|
||||||
on: push
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
1234invalid-JOB-Name-v123-docker_hub:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- run: echo hi
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
name: valid-job-name-1
|
|
||||||
on: push
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
valid-JOB-Name-v123-docker_hub:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- run: echo hi
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
name: valid-job-name-2
|
|
||||||
on: push
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
___valid-JOB-Name-v123-docker_hub:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- run: echo hi
|
|
||||||
9
act/model/testdata/nested/success.yml
vendored
9
act/model/testdata/nested/success.yml
vendored
@@ -1,9 +0,0 @@
|
|||||||
name: Hello World Workflow
|
|
||||||
on: push
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
hello-world:
|
|
||||||
name: Hello World Job
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- run: echo "Hello World!"
|
|
||||||
50
act/model/testdata/strategy/push.yml
vendored
50
act/model/testdata/strategy/push.yml
vendored
@@ -1,50 +0,0 @@
|
|||||||
---
|
|
||||||
jobs:
|
|
||||||
strategy-all:
|
|
||||||
name: ${{ matrix.node-version }} | ${{ matrix.site }} | ${{ matrix.datacenter }}
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- run: echo 'Hello!'
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
datacenter:
|
|
||||||
- site-c
|
|
||||||
- site-d
|
|
||||||
exclude:
|
|
||||||
- datacenter: site-d
|
|
||||||
node-version: 14.x
|
|
||||||
site: staging
|
|
||||||
include:
|
|
||||||
- php-version: 5.4
|
|
||||||
- datacenter: site-a
|
|
||||||
node-version: 10.x
|
|
||||||
site: prod
|
|
||||||
- datacenter: site-b
|
|
||||||
node-version: 12.x
|
|
||||||
site: dev
|
|
||||||
node-version: [14.x, 16.x]
|
|
||||||
site:
|
|
||||||
- staging
|
|
||||||
max-parallel: 2
|
|
||||||
strategy-no-matrix:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- run: echo 'Hello!'
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
max-parallel: 2
|
|
||||||
strategy-only-fail-fast:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- run: echo 'Hello!'
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
strategy-only-max-parallel:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- run: echo 'Hello!'
|
|
||||||
strategy:
|
|
||||||
max-parallel: 2
|
|
||||||
'on':
|
|
||||||
push: null
|
|
||||||
@@ -1,910 +0,0 @@
|
|||||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
|
||||||
// Copyright 2020 The nektos/act Authors. All rights reserved.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package model
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/sha256"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"maps"
|
|
||||||
"reflect"
|
|
||||||
"regexp"
|
|
||||||
"slices"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
|
||||||
|
|
||||||
log "github.com/sirupsen/logrus"
|
|
||||||
"go.yaml.in/yaml/v4"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Workflow is the structure of the files in .github/workflows
|
|
||||||
type Workflow struct {
|
|
||||||
File string
|
|
||||||
Name string `yaml:"name"`
|
|
||||||
RawOn yaml.Node `yaml:"on"`
|
|
||||||
Env map[string]string `yaml:"env"`
|
|
||||||
Jobs map[string]*Job `yaml:"jobs"`
|
|
||||||
Defaults Defaults `yaml:"defaults"`
|
|
||||||
RawConcurrency *RawConcurrency `yaml:"concurrency"`
|
|
||||||
RawPermissions yaml.Node `yaml:"permissions"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// On events for the workflow
|
|
||||||
func (w *Workflow) On() []string {
|
|
||||||
switch w.RawOn.Kind {
|
|
||||||
case yaml.ScalarNode:
|
|
||||||
var val string
|
|
||||||
err := w.RawOn.Decode(&val)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal(err)
|
|
||||||
}
|
|
||||||
return []string{val}
|
|
||||||
case yaml.SequenceNode:
|
|
||||||
var val []string
|
|
||||||
err := w.RawOn.Decode(&val)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal(err)
|
|
||||||
}
|
|
||||||
return val
|
|
||||||
case yaml.MappingNode:
|
|
||||||
var val map[string]any
|
|
||||||
err := w.RawOn.Decode(&val)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal(err)
|
|
||||||
}
|
|
||||||
var keys []string
|
|
||||||
for k := range val {
|
|
||||||
keys = append(keys, k)
|
|
||||||
}
|
|
||||||
return keys
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *Workflow) OnEvent(event string) any {
|
|
||||||
if w.RawOn.Kind == yaml.MappingNode {
|
|
||||||
var val map[string]any
|
|
||||||
if !decodeNode(w.RawOn, &val) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return val[event]
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *Workflow) OnSchedule() []string {
|
|
||||||
schedules := w.OnEvent("schedule")
|
|
||||||
if schedules == nil {
|
|
||||||
return []string{}
|
|
||||||
}
|
|
||||||
|
|
||||||
switch val := schedules.(type) {
|
|
||||||
case []any:
|
|
||||||
allSchedules := []string{}
|
|
||||||
for _, v := range val {
|
|
||||||
entry, ok := v.(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if cron, ok := entry["cron"].(string); ok {
|
|
||||||
allSchedules = append(allSchedules, cron)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return allSchedules
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
|
|
||||||
return []string{}
|
|
||||||
}
|
|
||||||
|
|
||||||
type WorkflowDispatchInput struct {
|
|
||||||
Description string `yaml:"description"`
|
|
||||||
Required bool `yaml:"required"`
|
|
||||||
Default string `yaml:"default"`
|
|
||||||
Type string `yaml:"type"`
|
|
||||||
Options []string `yaml:"options"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type WorkflowDispatch struct {
|
|
||||||
Inputs map[string]WorkflowDispatchInput `yaml:"inputs"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *Workflow) WorkflowDispatchConfig() *WorkflowDispatch {
|
|
||||||
switch w.RawOn.Kind {
|
|
||||||
case yaml.ScalarNode:
|
|
||||||
var val string
|
|
||||||
if !decodeNode(w.RawOn, &val) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if val == "workflow_dispatch" {
|
|
||||||
return &WorkflowDispatch{}
|
|
||||||
}
|
|
||||||
case yaml.SequenceNode:
|
|
||||||
var val []string
|
|
||||||
if !decodeNode(w.RawOn, &val) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if slices.Contains(val, "workflow_dispatch") {
|
|
||||||
return &WorkflowDispatch{}
|
|
||||||
}
|
|
||||||
case yaml.MappingNode:
|
|
||||||
var val map[string]yaml.Node
|
|
||||||
if !decodeNode(w.RawOn, &val) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
n, found := val["workflow_dispatch"]
|
|
||||||
var workflowDispatch WorkflowDispatch
|
|
||||||
if found && decodeNode(n, &workflowDispatch) {
|
|
||||||
return &workflowDispatch
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type WorkflowCallInput struct {
|
|
||||||
Description string `yaml:"description"`
|
|
||||||
Required bool `yaml:"required"`
|
|
||||||
Default string `yaml:"default"`
|
|
||||||
Type string `yaml:"type"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type WorkflowCallOutput struct {
|
|
||||||
Description string `yaml:"description"`
|
|
||||||
Value string `yaml:"value"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type WorkflowCall struct {
|
|
||||||
Inputs map[string]WorkflowCallInput `yaml:"inputs"`
|
|
||||||
Outputs map[string]WorkflowCallOutput `yaml:"outputs"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type WorkflowCallResult struct {
|
|
||||||
Outputs map[string]string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *Workflow) WorkflowCallConfig() *WorkflowCall {
|
|
||||||
if w.RawOn.Kind != yaml.MappingNode {
|
|
||||||
// The callers expect for "on: workflow_call" and "on: [ workflow_call ]" a non nil return value
|
|
||||||
return &WorkflowCall{}
|
|
||||||
}
|
|
||||||
|
|
||||||
var val map[string]yaml.Node
|
|
||||||
if !decodeNode(w.RawOn, &val) {
|
|
||||||
return &WorkflowCall{}
|
|
||||||
}
|
|
||||||
|
|
||||||
var config WorkflowCall
|
|
||||||
node := val["workflow_call"]
|
|
||||||
if !decodeNode(node, &config) {
|
|
||||||
return &WorkflowCall{}
|
|
||||||
}
|
|
||||||
|
|
||||||
return &config
|
|
||||||
}
|
|
||||||
|
|
||||||
// Job is the structure of one job in a workflow
|
|
||||||
type Job struct {
|
|
||||||
Name string `yaml:"name"`
|
|
||||||
RawNeeds yaml.Node `yaml:"needs"`
|
|
||||||
RawRunsOn yaml.Node `yaml:"runs-on"`
|
|
||||||
Env yaml.Node `yaml:"env"`
|
|
||||||
If yaml.Node `yaml:"if"`
|
|
||||||
Steps []*Step `yaml:"steps"`
|
|
||||||
TimeoutMinutes string `yaml:"timeout-minutes"`
|
|
||||||
RawContinueOnError string `yaml:"continue-on-error"`
|
|
||||||
Services map[string]*ContainerSpec `yaml:"services"`
|
|
||||||
Strategy *Strategy `yaml:"strategy"`
|
|
||||||
RawContainer yaml.Node `yaml:"container"`
|
|
||||||
Defaults Defaults `yaml:"defaults"`
|
|
||||||
Outputs map[string]string `yaml:"outputs"`
|
|
||||||
Uses string `yaml:"uses"`
|
|
||||||
With map[string]any `yaml:"with"`
|
|
||||||
RawSecrets yaml.Node `yaml:"secrets"`
|
|
||||||
RawPermissions yaml.Node `yaml:"permissions"`
|
|
||||||
Result string
|
|
||||||
// Runtime fields set during execution (not from YAML):
|
|
||||||
ContinueOnError bool // true when all failing matrix combinations had continue-on-error=true
|
|
||||||
hasFirmFailure bool // true once any combination failed without continue-on-error
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetContinueOnError records whether this combination's failure should not fail the workflow.
|
|
||||||
// Must be called under the job lock. Safe across parallel matrix combinations.
|
|
||||||
func (j *Job) SetContinueOnError(continueOnErr bool) {
|
|
||||||
if continueOnErr {
|
|
||||||
if !j.hasFirmFailure {
|
|
||||||
j.ContinueOnError = true
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
j.hasFirmFailure = true
|
|
||||||
j.ContinueOnError = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// NeedsResult returns the job result as seen by dependent jobs through the
|
|
||||||
// `needs` context. A job that failed but was tolerated via continue-on-error
|
|
||||||
// reports "success" to its dependents, matching GitHub: such a failure must not
|
|
||||||
// block jobs gated on the default `if: success()`, even though the overall
|
|
||||||
// workflow run is still marked as failed.
|
|
||||||
func (j *Job) NeedsResult() string {
|
|
||||||
if j.Result == "failure" && j.ContinueOnError {
|
|
||||||
return "success"
|
|
||||||
}
|
|
||||||
return j.Result
|
|
||||||
}
|
|
||||||
|
|
||||||
// Strategy for the job
|
|
||||||
type Strategy struct {
|
|
||||||
FailFast bool
|
|
||||||
MaxParallel int
|
|
||||||
FailFastString string `yaml:"fail-fast"`
|
|
||||||
MaxParallelString string `yaml:"max-parallel"`
|
|
||||||
RawMatrix yaml.Node `yaml:"matrix"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default settings that will apply to all steps in the job or workflow
|
|
||||||
type Defaults struct {
|
|
||||||
Run RunDefaults `yaml:"run"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Defaults for all run steps in the job or workflow
|
|
||||||
type RunDefaults struct {
|
|
||||||
Shell string `yaml:"shell"`
|
|
||||||
WorkingDirectory string `yaml:"working-directory"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetMaxParallel sets default and returns value for `max-parallel`
|
|
||||||
func (s Strategy) GetMaxParallel() int {
|
|
||||||
// MaxParallel default value is `GitHub will maximize the number of jobs run in parallel depending on the available runners on GitHub-hosted virtual machines`
|
|
||||||
// So I take the liberty to hardcode default limit to 4 and this is because:
|
|
||||||
// 1: tl;dr: self-hosted does only 1 parallel job - https://github.com/actions/runner/issues/639#issuecomment-825212735
|
|
||||||
// 2: GH has 20 parallel job limit (for free tier) - https://github.com/github/docs/blob/3ae84420bd10997bb5f35f629ebb7160fe776eae/content/actions/reference/usage-limits-billing-and-administration.md?plain=1#L45
|
|
||||||
// 3: I want to add support for MaxParallel to act and 20! parallel jobs is a bit overkill IMHO
|
|
||||||
maxParallel := 4
|
|
||||||
if s.MaxParallelString != "" {
|
|
||||||
var err error
|
|
||||||
if maxParallel, err = strconv.Atoi(s.MaxParallelString); err != nil {
|
|
||||||
log.Errorf("Failed to parse 'max-parallel' option: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return maxParallel
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetFailFast sets default and returns value for `fail-fast`
|
|
||||||
func (s Strategy) GetFailFast() bool {
|
|
||||||
// FailFast option is true by default: https://github.com/github/docs/blob/3ae84420bd10997bb5f35f629ebb7160fe776eae/content/actions/reference/workflow-syntax-for-github-actions.md?plain=1#L1107
|
|
||||||
failFast := true
|
|
||||||
log.Debug(s.FailFastString)
|
|
||||||
if s.FailFastString != "" {
|
|
||||||
var err error
|
|
||||||
if failFast, err = strconv.ParseBool(s.FailFastString); err != nil {
|
|
||||||
log.Errorf("Failed to parse 'fail-fast' option: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return failFast
|
|
||||||
}
|
|
||||||
|
|
||||||
func (j *Job) InheritSecrets() bool {
|
|
||||||
if j.RawSecrets.Kind != yaml.ScalarNode {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
var val string
|
|
||||||
if !decodeNode(j.RawSecrets, &val) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return val == "inherit"
|
|
||||||
}
|
|
||||||
|
|
||||||
func (j *Job) Secrets() map[string]string {
|
|
||||||
if j.RawSecrets.Kind != yaml.MappingNode {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var val map[string]string
|
|
||||||
if !decodeNode(j.RawSecrets, &val) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return val
|
|
||||||
}
|
|
||||||
|
|
||||||
// Container details for the job
|
|
||||||
func (j *Job) Container() *ContainerSpec {
|
|
||||||
var val *ContainerSpec
|
|
||||||
switch j.RawContainer.Kind {
|
|
||||||
case yaml.ScalarNode:
|
|
||||||
val = new(ContainerSpec)
|
|
||||||
if !decodeNode(j.RawContainer, &val.Image) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
case yaml.MappingNode:
|
|
||||||
val = new(ContainerSpec)
|
|
||||||
if !decodeNode(j.RawContainer, val) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return val
|
|
||||||
}
|
|
||||||
|
|
||||||
// Needs list for Job
|
|
||||||
func (j *Job) Needs() []string {
|
|
||||||
switch j.RawNeeds.Kind {
|
|
||||||
case yaml.ScalarNode:
|
|
||||||
var val string
|
|
||||||
if !decodeNode(j.RawNeeds, &val) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return []string{val}
|
|
||||||
case yaml.SequenceNode:
|
|
||||||
var val []string
|
|
||||||
if !decodeNode(j.RawNeeds, &val) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return val
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// RunsOn list for Job
|
|
||||||
func (j *Job) RunsOn() []string {
|
|
||||||
return RunsOnFromNode(j.RawRunsOn)
|
|
||||||
}
|
|
||||||
|
|
||||||
// RunsOnFromNode parses the runs-on labels from a raw runs-on node, so callers can evaluate a
|
|
||||||
// copy of the node (avoiding mutation of the shared Job) before reading the labels.
|
|
||||||
func RunsOnFromNode(rawRunsOn yaml.Node) []string {
|
|
||||||
switch rawRunsOn.Kind {
|
|
||||||
case yaml.MappingNode:
|
|
||||||
var val struct {
|
|
||||||
Group string
|
|
||||||
Labels yaml.Node
|
|
||||||
}
|
|
||||||
|
|
||||||
if !decodeNode(rawRunsOn, &val) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
labels := nodeAsStringSlice(val.Labels)
|
|
||||||
|
|
||||||
if val.Group != "" {
|
|
||||||
labels = append(labels, val.Group)
|
|
||||||
}
|
|
||||||
|
|
||||||
return labels
|
|
||||||
default:
|
|
||||||
return nodeAsStringSlice(rawRunsOn)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func nodeAsStringSlice(node yaml.Node) []string {
|
|
||||||
switch node.Kind {
|
|
||||||
case yaml.ScalarNode:
|
|
||||||
var val string
|
|
||||||
if !decodeNode(node, &val) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return []string{val}
|
|
||||||
case yaml.SequenceNode:
|
|
||||||
var val []string
|
|
||||||
if !decodeNode(node, &val) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return val
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func environment(yml yaml.Node) map[string]string {
|
|
||||||
env := make(map[string]string)
|
|
||||||
if yml.Kind == yaml.MappingNode {
|
|
||||||
if !decodeNode(yml, &env) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return env
|
|
||||||
}
|
|
||||||
|
|
||||||
// Environment returns string-based key=value map for a job
|
|
||||||
func (j *Job) Environment() map[string]string {
|
|
||||||
return environment(j.Env)
|
|
||||||
}
|
|
||||||
|
|
||||||
// normalizeMatrixValue converts a matrix value to []interface{}.
|
|
||||||
// Arrays pass through unchanged; scalars are wrapped in a single-element array.
|
|
||||||
// Unevaluated template expressions are wrapped as a fallback — proper resolution
|
|
||||||
// happens via EvaluateYamlNode before Matrix() is called. Nested maps are rejected.
|
|
||||||
func normalizeMatrixValue(key string, val any) ([]any, error) {
|
|
||||||
switch t := val.(type) {
|
|
||||||
case []any:
|
|
||||||
// Already an array - use as-is
|
|
||||||
return t, nil
|
|
||||||
case string, int, float64, bool, nil:
|
|
||||||
// Valid scalar types that can appear in YAML
|
|
||||||
// These can be unevaluated template expressions (strings) or literal values
|
|
||||||
return []any{t}, nil
|
|
||||||
case map[string]any:
|
|
||||||
// Nested map indicates misconfiguration - likely user error
|
|
||||||
return nil, fmt.Errorf("matrix key %q has invalid nested object value - expected scalar or array, got map", key)
|
|
||||||
default:
|
|
||||||
// Unknown types might indicate parsing issues
|
|
||||||
log.Warnf("matrix key %q has unexpected type %T, wrapping as single value", key, t)
|
|
||||||
return []any{t}, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Matrix decodes the RawMatrix YAML node into a map[string][]interface{}.
|
|
||||||
// Scalar values are wrapped into single-element arrays automatically.
|
|
||||||
// Template expressions are resolved by EvaluateYamlNode before this method is
|
|
||||||
// called; if unresolved, the literal string is wrapped as a one-element fallback.
|
|
||||||
func (j *Job) Matrix() (map[string][]any, error) {
|
|
||||||
if j.Strategy == nil || j.Strategy.RawMatrix.Kind != yaml.MappingNode {
|
|
||||||
return map[string][]any{}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Decode to flexible map first so that scalar values don't cause a type error.
|
|
||||||
var flexVal map[string]any
|
|
||||||
err := j.Strategy.RawMatrix.Decode(&flexVal)
|
|
||||||
if err != nil {
|
|
||||||
// Fall back to the strict array-only format for backward compatibility.
|
|
||||||
var val map[string][]any
|
|
||||||
if !decodeNode(j.Strategy.RawMatrix, &val) {
|
|
||||||
return map[string][]any{}, nil
|
|
||||||
}
|
|
||||||
return val, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert flexible format to expected format with validation
|
|
||||||
val := make(map[string][]any)
|
|
||||||
for k, v := range flexVal {
|
|
||||||
normalized, err := normalizeMatrixValue(k, v)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
val[k] = normalized
|
|
||||||
}
|
|
||||||
return val, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetMatrixes returns the matrix cross product
|
|
||||||
// It skips includes and hard fails excludes for non-existing keys
|
|
||||||
func (j *Job) GetMatrixes() ([]map[string]any, error) {
|
|
||||||
matrixes := make([]map[string]any, 0)
|
|
||||||
if j.Strategy != nil {
|
|
||||||
// Always set these values, even if there's an error later
|
|
||||||
j.Strategy.FailFast = j.Strategy.GetFailFast()
|
|
||||||
j.Strategy.MaxParallel = j.Strategy.GetMaxParallel()
|
|
||||||
|
|
||||||
m, err := j.Matrix()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if len(m) > 0 {
|
|
||||||
includes := make([]map[string]any, 0)
|
|
||||||
extraIncludes := make([]map[string]any, 0)
|
|
||||||
addInclude := func(raw any) error {
|
|
||||||
include, ok := raw.(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
return fmt.Errorf("the workflow is not valid. Matrix include %v is not a map of matrix keys to values", raw)
|
|
||||||
}
|
|
||||||
for k := range include {
|
|
||||||
if _, ok := m[k]; ok {
|
|
||||||
includes = append(includes, include)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
extraIncludes = append(extraIncludes, include)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
for _, v := range m["include"] {
|
|
||||||
switch t := v.(type) {
|
|
||||||
case []any:
|
|
||||||
for _, i := range t {
|
|
||||||
if err := addInclude(i); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case any:
|
|
||||||
if err := addInclude(t); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
delete(m, "include")
|
|
||||||
|
|
||||||
excludes := make([]map[string]any, 0)
|
|
||||||
for _, e := range m["exclude"] {
|
|
||||||
exclude, ok := e.(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
return nil, fmt.Errorf("the workflow is not valid. Matrix exclude %v is not a map of matrix keys to values", e)
|
|
||||||
}
|
|
||||||
for k := range exclude {
|
|
||||||
if _, ok := m[k]; ok {
|
|
||||||
excludes = append(excludes, exclude)
|
|
||||||
} else {
|
|
||||||
// We fail completely here because that's what GitHub does for non-existing matrix keys, fail on exclude, silent skip on include
|
|
||||||
return nil, fmt.Errorf("the workflow is not valid. Matrix exclude key %q does not match any key within the matrix", k)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
delete(m, "exclude")
|
|
||||||
|
|
||||||
matrixProduct := common.CartesianProduct(m)
|
|
||||||
MATRIX:
|
|
||||||
for _, matrix := range matrixProduct {
|
|
||||||
for _, exclude := range excludes {
|
|
||||||
if commonKeysMatch(matrix, exclude) {
|
|
||||||
log.Debugf("Skipping matrix '%v' due to exclude '%v'", matrix, exclude)
|
|
||||||
continue MATRIX
|
|
||||||
}
|
|
||||||
}
|
|
||||||
matrixes = append(matrixes, matrix)
|
|
||||||
}
|
|
||||||
for _, include := range includes {
|
|
||||||
matched := false
|
|
||||||
for _, matrix := range matrixes {
|
|
||||||
if commonKeysMatch2(matrix, include, m) {
|
|
||||||
matched = true
|
|
||||||
log.Debugf("Adding include values '%v' to existing entry", include)
|
|
||||||
maps.Copy(matrix, include)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !matched {
|
|
||||||
extraIncludes = append(extraIncludes, include)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, include := range extraIncludes {
|
|
||||||
log.Debugf("Adding include '%v'", include)
|
|
||||||
matrixes = append(matrixes, include)
|
|
||||||
}
|
|
||||||
if len(matrixes) == 0 {
|
|
||||||
matrixes = append(matrixes, make(map[string]any))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
matrixes = append(matrixes, make(map[string]any))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
matrixes = append(matrixes, make(map[string]any))
|
|
||||||
log.Debugf("Empty Strategy, matrixes=%v", matrixes)
|
|
||||||
}
|
|
||||||
return matrixes, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func commonKeysMatch(a, b map[string]any) bool {
|
|
||||||
for aKey, aVal := range a {
|
|
||||||
if bVal, ok := b[aKey]; ok && !reflect.DeepEqual(aVal, bVal) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func commonKeysMatch2(a, b map[string]any, m map[string][]any) bool {
|
|
||||||
for aKey, aVal := range a {
|
|
||||||
_, useKey := m[aKey]
|
|
||||||
if bVal, ok := b[aKey]; useKey && ok && !reflect.DeepEqual(aVal, bVal) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// JobType describes what type of job we are about to run
|
|
||||||
type JobType int
|
|
||||||
|
|
||||||
const (
|
|
||||||
// JobTypeDefault is all jobs that have a `run` attribute
|
|
||||||
JobTypeDefault JobType = iota
|
|
||||||
|
|
||||||
// JobTypeReusableWorkflowLocal is all jobs that have a `uses` that is a local workflow in the .github/workflows directory
|
|
||||||
JobTypeReusableWorkflowLocal
|
|
||||||
|
|
||||||
// JobTypeReusableWorkflowRemote is all jobs that have a `uses` that references a workflow file in a github repo
|
|
||||||
JobTypeReusableWorkflowRemote
|
|
||||||
|
|
||||||
// JobTypeInvalid represents a job which is not configured correctly
|
|
||||||
JobTypeInvalid
|
|
||||||
)
|
|
||||||
|
|
||||||
func (j JobType) String() string {
|
|
||||||
switch j {
|
|
||||||
case JobTypeDefault:
|
|
||||||
return "default"
|
|
||||||
case JobTypeReusableWorkflowLocal:
|
|
||||||
return "local-reusable-workflow"
|
|
||||||
case JobTypeReusableWorkflowRemote:
|
|
||||||
return "remote-reusable-workflow"
|
|
||||||
}
|
|
||||||
return "unknown"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Type returns the type of the job
|
|
||||||
func (j *Job) Type() (JobType, error) {
|
|
||||||
isReusable := j.Uses != ""
|
|
||||||
|
|
||||||
if isReusable {
|
|
||||||
isYaml, _ := regexp.MatchString(`\.(ya?ml)(?:$|@)`, j.Uses)
|
|
||||||
|
|
||||||
if isYaml {
|
|
||||||
isLocalPath := strings.HasPrefix(j.Uses, "./")
|
|
||||||
isRemotePath, _ := regexp.MatchString(`^[^.](.+?/){2,}.+\.ya?ml@`, j.Uses)
|
|
||||||
hasVersion, _ := regexp.MatchString(`\.ya?ml@`, j.Uses)
|
|
||||||
|
|
||||||
if isLocalPath {
|
|
||||||
return JobTypeReusableWorkflowLocal, nil
|
|
||||||
} else if isRemotePath && hasVersion {
|
|
||||||
return JobTypeReusableWorkflowRemote, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return JobTypeInvalid, fmt.Errorf("`uses` key references invalid workflow path '%s'. Must start with './' if it's a local workflow, or must start with '<org>/<repo>/' and include an '@' if it's a remote workflow", j.Uses)
|
|
||||||
}
|
|
||||||
|
|
||||||
return JobTypeDefault, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ContainerSpec is the specification of the container to use for the job
|
|
||||||
type ContainerSpec struct {
|
|
||||||
Image string `yaml:"image"`
|
|
||||||
Env map[string]string `yaml:"env"`
|
|
||||||
Ports []string `yaml:"ports"`
|
|
||||||
Volumes []string `yaml:"volumes"`
|
|
||||||
Options string `yaml:"options"`
|
|
||||||
Credentials map[string]string `yaml:"credentials"`
|
|
||||||
Entrypoint string
|
|
||||||
Args string
|
|
||||||
Name string
|
|
||||||
Reuse bool
|
|
||||||
|
|
||||||
// Gitea specific
|
|
||||||
Cmd []string `yaml:"cmd"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step is the structure of one step in a job
|
|
||||||
type Step struct {
|
|
||||||
Number int `yaml:"-"`
|
|
||||||
ID string `yaml:"id"`
|
|
||||||
If yaml.Node `yaml:"if"`
|
|
||||||
Name string `yaml:"name"`
|
|
||||||
Uses string `yaml:"uses"`
|
|
||||||
Run string `yaml:"run"`
|
|
||||||
WorkingDirectory string `yaml:"working-directory"`
|
|
||||||
Shell string `yaml:"shell"`
|
|
||||||
Env yaml.Node `yaml:"env"`
|
|
||||||
With map[string]string `yaml:"with"`
|
|
||||||
RawContinueOnError string `yaml:"continue-on-error"`
|
|
||||||
TimeoutMinutes string `yaml:"timeout-minutes"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clone returns a deep copy safe to mutate independently of s. Job steps are shared across
|
|
||||||
// parallel matrix runs, which mutate per-job fields (ID, Number, Shell) and evaluate the If/Env
|
|
||||||
// yaml.Nodes in place, so each job must own its copy.
|
|
||||||
func (s *Step) Clone() *Step {
|
|
||||||
clone := *s
|
|
||||||
clone.If = CloneYamlNode(s.If)
|
|
||||||
clone.Env = CloneYamlNode(s.Env)
|
|
||||||
clone.With = maps.Clone(s.With)
|
|
||||||
return &clone
|
|
||||||
}
|
|
||||||
|
|
||||||
// CloneYamlNode returns a deep copy of a yaml.Node so callers can evaluate it in place without
|
|
||||||
// mutating a node shared across parallel jobs.
|
|
||||||
func CloneYamlNode(n yaml.Node) yaml.Node {
|
|
||||||
clone := n
|
|
||||||
if n.Content != nil {
|
|
||||||
clone.Content = make([]*yaml.Node, len(n.Content))
|
|
||||||
for i, child := range n.Content {
|
|
||||||
if child != nil {
|
|
||||||
childClone := CloneYamlNode(*child)
|
|
||||||
clone.Content[i] = &childClone
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return clone
|
|
||||||
}
|
|
||||||
|
|
||||||
// String gets the name of step
|
|
||||||
func (s *Step) String() string {
|
|
||||||
if s.Name != "" {
|
|
||||||
return s.Name
|
|
||||||
} else if s.Uses != "" {
|
|
||||||
return s.Uses
|
|
||||||
} else if s.Run != "" {
|
|
||||||
return s.Run
|
|
||||||
}
|
|
||||||
return s.ID
|
|
||||||
}
|
|
||||||
|
|
||||||
// Environment returns string-based key=value map for a step
|
|
||||||
func (s *Step) Environment() map[string]string {
|
|
||||||
return environment(s.Env)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetEnv gets the env for a step
|
|
||||||
func (s *Step) GetEnv() map[string]string {
|
|
||||||
env := s.Environment()
|
|
||||||
|
|
||||||
for k, v := range s.With {
|
|
||||||
envKey := regexp.MustCompile("[^A-Z0-9-]").ReplaceAllString(strings.ToUpper(k), "_")
|
|
||||||
envKey = "INPUT_" + strings.ToUpper(envKey)
|
|
||||||
env[envKey] = v
|
|
||||||
}
|
|
||||||
return env
|
|
||||||
}
|
|
||||||
|
|
||||||
// ShellCommand returns the command for the shell
|
|
||||||
func (s *Step) ShellCommand() string {
|
|
||||||
var shellCommand string
|
|
||||||
|
|
||||||
// Reference: https://github.com/actions/runner/blob/8109c962f09d9acc473d92c595ff43afceddb347/src/Runner.Worker/Handlers/ScriptHandlerHelpers.cs#L9-L17
|
|
||||||
switch s.Shell {
|
|
||||||
case "", "bash":
|
|
||||||
shellCommand = "bash --noprofile --norc -e -o pipefail {0}"
|
|
||||||
case "pwsh":
|
|
||||||
shellCommand = "pwsh -command . '{0}'"
|
|
||||||
case "python":
|
|
||||||
shellCommand = "python {0}"
|
|
||||||
case "sh":
|
|
||||||
shellCommand = "sh -e {0}"
|
|
||||||
case "cmd":
|
|
||||||
shellCommand = "cmd /D /E:ON /V:OFF /S /C \"CALL \"{0}\"\""
|
|
||||||
case "powershell":
|
|
||||||
shellCommand = "powershell -command . '{0}'"
|
|
||||||
default:
|
|
||||||
shellCommand = s.Shell
|
|
||||||
}
|
|
||||||
return shellCommand
|
|
||||||
}
|
|
||||||
|
|
||||||
// StepType describes what type of step we are about to run
|
|
||||||
type StepType int
|
|
||||||
|
|
||||||
const (
|
|
||||||
// StepTypeRun is all steps that have a `run` attribute
|
|
||||||
StepTypeRun StepType = iota
|
|
||||||
|
|
||||||
// StepTypeUsesDockerURL is all steps that have a `uses` that is of the form `docker://...`
|
|
||||||
StepTypeUsesDockerURL
|
|
||||||
|
|
||||||
// StepTypeUsesActionLocal is all steps that have a `uses` that is a local action in a subdirectory
|
|
||||||
StepTypeUsesActionLocal
|
|
||||||
|
|
||||||
// StepTypeUsesActionRemote is all steps that have a `uses` that is a reference to a github repo
|
|
||||||
StepTypeUsesActionRemote
|
|
||||||
|
|
||||||
// StepTypeReusableWorkflowLocal is all steps that have a `uses` that is a local workflow in the .github/workflows directory
|
|
||||||
StepTypeReusableWorkflowLocal
|
|
||||||
|
|
||||||
// StepTypeReusableWorkflowRemote is all steps that have a `uses` that references a workflow file in a github repo
|
|
||||||
StepTypeReusableWorkflowRemote
|
|
||||||
|
|
||||||
// StepTypeInvalid is for steps that have invalid step action
|
|
||||||
StepTypeInvalid
|
|
||||||
)
|
|
||||||
|
|
||||||
func (s StepType) String() string {
|
|
||||||
switch s {
|
|
||||||
case StepTypeInvalid:
|
|
||||||
return "invalid"
|
|
||||||
case StepTypeRun:
|
|
||||||
return "run"
|
|
||||||
case StepTypeUsesActionLocal:
|
|
||||||
return "local-action"
|
|
||||||
case StepTypeUsesActionRemote:
|
|
||||||
return "remote-action"
|
|
||||||
case StepTypeUsesDockerURL:
|
|
||||||
return "docker"
|
|
||||||
case StepTypeReusableWorkflowLocal:
|
|
||||||
return "local-reusable-workflow"
|
|
||||||
case StepTypeReusableWorkflowRemote:
|
|
||||||
return "remote-reusable-workflow"
|
|
||||||
}
|
|
||||||
return "unknown"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Type returns the type of the step
|
|
||||||
func (s *Step) Type() StepType {
|
|
||||||
if s.Run == "" && s.Uses == "" {
|
|
||||||
return StepTypeInvalid
|
|
||||||
}
|
|
||||||
|
|
||||||
if s.Run != "" {
|
|
||||||
if s.Uses != "" {
|
|
||||||
return StepTypeInvalid
|
|
||||||
}
|
|
||||||
return StepTypeRun
|
|
||||||
} else if strings.HasPrefix(s.Uses, "docker://") {
|
|
||||||
return StepTypeUsesDockerURL
|
|
||||||
} else if strings.HasPrefix(s.Uses, "./.github/workflows") && (strings.HasSuffix(s.Uses, ".yml") || strings.HasSuffix(s.Uses, ".yaml")) {
|
|
||||||
return StepTypeReusableWorkflowLocal
|
|
||||||
} else if !strings.HasPrefix(s.Uses, "./") && strings.Contains(s.Uses, ".github/workflows") && (strings.Contains(s.Uses, ".yml@") || strings.Contains(s.Uses, ".yaml@")) {
|
|
||||||
return StepTypeReusableWorkflowRemote
|
|
||||||
} else if strings.HasPrefix(s.Uses, "./") {
|
|
||||||
return StepTypeUsesActionLocal
|
|
||||||
}
|
|
||||||
return StepTypeUsesActionRemote
|
|
||||||
}
|
|
||||||
|
|
||||||
// UsesHash returns a hash of the uses string.
|
|
||||||
// For Gitea.
|
|
||||||
func (s *Step) UsesHash() string {
|
|
||||||
return fmt.Sprintf("%x", sha256.Sum256([]byte(s.Uses)))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReadWorkflow returns a list of jobs for a given workflow file reader
|
|
||||||
func ReadWorkflow(in io.Reader) (*Workflow, error) {
|
|
||||||
w := new(Workflow)
|
|
||||||
err := yaml.NewDecoder(in).Decode(w)
|
|
||||||
return w, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetJob will get a job by name in the workflow
|
|
||||||
func (w *Workflow) GetJob(jobID string) *Job {
|
|
||||||
for id, j := range w.Jobs {
|
|
||||||
if jobID == id {
|
|
||||||
if j.Name == "" {
|
|
||||||
j.Name = id
|
|
||||||
}
|
|
||||||
if j.If.Value == "" {
|
|
||||||
j.If.Value = "success()"
|
|
||||||
}
|
|
||||||
return j
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetJobIDs will get all the job names in the workflow
|
|
||||||
func (w *Workflow) GetJobIDs() []string {
|
|
||||||
ids := make([]string, 0)
|
|
||||||
for id := range w.Jobs {
|
|
||||||
ids = append(ids, id)
|
|
||||||
}
|
|
||||||
return ids
|
|
||||||
}
|
|
||||||
|
|
||||||
var OnDecodeNodeError = func(node yaml.Node, out any, err error) {
|
|
||||||
log.Fatalf("Failed to decode node %v into %T: %v", node, out, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
func decodeNode(node yaml.Node, out any) bool {
|
|
||||||
if err := node.Decode(out); err != nil {
|
|
||||||
if OnDecodeNodeError != nil {
|
|
||||||
OnDecodeNodeError(node, out, err)
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// For Gitea
|
|
||||||
// RawConcurrency represents a workflow concurrency or a job concurrency with uninterpolated options
|
|
||||||
type RawConcurrency struct {
|
|
||||||
Group string `yaml:"group,omitempty"`
|
|
||||||
CancelInProgress string `yaml:"cancel-in-progress,omitempty"`
|
|
||||||
RawExpression string `yaml:"-,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type objectConcurrency RawConcurrency
|
|
||||||
|
|
||||||
func (r *RawConcurrency) UnmarshalYAML(n *yaml.Node) error {
|
|
||||||
if err := n.Decode(&r.RawExpression); err == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return n.Decode((*objectConcurrency)(r))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *RawConcurrency) MarshalYAML() (any, error) {
|
|
||||||
if r.RawExpression != "" {
|
|
||||||
return r.RawExpression, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return (*objectConcurrency)(r), nil
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -23,8 +23,8 @@ import (
|
|||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
"gitea.com/gitea/runner/act/common/git"
|
"gitea.com/gitea/runner/act/common/git"
|
||||||
"gitea.com/gitea/runner/act/container"
|
"gitea.com/gitea/runner/act/container"
|
||||||
"gitea.com/gitea/runner/act/model"
|
|
||||||
|
|
||||||
|
"gitea.dev/actionslib/pkg/model"
|
||||||
"github.com/kballard/go-shellquote"
|
"github.com/kballard/go-shellquote"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
"gitea.com/gitea/runner/act/model"
|
|
||||||
|
|
||||||
|
"gitea.dev/actionslib/pkg/model"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
"gitea.com/gitea/runner/act/model"
|
|
||||||
|
"gitea.dev/actionslib/pkg/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
func evaluateCompositeInputAndEnv(ctx context.Context, parent *RunContext, step actionStep) map[string]string {
|
func evaluateCompositeInputAndEnv(ctx context.Context, parent *RunContext, step actionStep) map[string]string {
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ import (
|
|||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
"gitea.com/gitea/runner/act/common/git"
|
"gitea.com/gitea/runner/act/common/git"
|
||||||
"gitea.com/gitea/runner/act/container"
|
"gitea.com/gitea/runner/act/container"
|
||||||
"gitea.com/gitea/runner/act/model"
|
|
||||||
|
|
||||||
|
"gitea.dev/actionslib/pkg/model"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/mock"
|
"github.com/stretchr/testify/mock"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
"gitea.com/gitea/runner/act/exprparser"
|
|
||||||
"gitea.com/gitea/runner/act/model"
|
|
||||||
|
|
||||||
|
"gitea.dev/actionslib/pkg/exprparser"
|
||||||
|
"gitea.dev/actionslib/pkg/model"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"go.yaml.in/yaml/v4"
|
"go.yaml.in/yaml/v4"
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
"gitea.com/gitea/runner/act/model"
|
|
||||||
|
|
||||||
|
"gitea.dev/actionslib/pkg/model"
|
||||||
"github.com/sirupsen/logrus/hooks/test"
|
"github.com/sirupsen/logrus/hooks/test"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|||||||
@@ -17,17 +17,19 @@ import (
|
|||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
"gitea.com/gitea/runner/act/container"
|
"gitea.com/gitea/runner/act/container"
|
||||||
"gitea.com/gitea/runner/act/exprparser"
|
|
||||||
"gitea.com/gitea/runner/act/model"
|
|
||||||
|
|
||||||
_ "embed"
|
_ "embed"
|
||||||
|
|
||||||
|
"gitea.dev/actionslib/pkg/expreval"
|
||||||
|
"gitea.dev/actionslib/pkg/exprparser"
|
||||||
|
"gitea.dev/actionslib/pkg/model"
|
||||||
"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
|
||||||
}
|
}
|
||||||
@@ -232,240 +234,37 @@ func (ee expressionEvaluator) evaluate(ctx context.Context, in string, defaultSt
|
|||||||
return evaluated, err
|
return evaluated, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ee expressionEvaluator) evaluateScalarYamlNode(ctx context.Context, node *yaml.Node) (*yaml.Node, error) {
|
// shared returns the evaluation layer of the shared library, bound to this context so the
|
||||||
var in string
|
// evaluation of every single expression is still logged and masked here.
|
||||||
if err := node.Decode(&in); err != nil {
|
func (ee expressionEvaluator) shared(ctx context.Context) expreval.Evaluator {
|
||||||
return nil, err
|
return expreval.New(func(in string, defaultStatusCheck exprparser.DefaultStatusCheck) (any, error) {
|
||||||
}
|
return ee.evaluate(ctx, in, defaultStatusCheck)
|
||||||
if !strings.Contains(in, "${{") || !strings.Contains(in, "}}") {
|
})
|
||||||
return nil, nil //nolint:nilnil // pre-existing issue from nektos/act
|
|
||||||
}
|
|
||||||
expr, _ := rewriteSubExpression(ctx, in, false)
|
|
||||||
res, err := ee.evaluate(ctx, expr, exprparser.DefaultStatusCheckNone)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
ret := &yaml.Node{}
|
|
||||||
if err := ret.Encode(res); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return ret, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ee expressionEvaluator) evaluateMappingYamlNode(ctx context.Context, node *yaml.Node) (*yaml.Node, error) {
|
|
||||||
var ret *yaml.Node
|
|
||||||
// GitHub has this undocumented feature to merge maps, called insert directive
|
|
||||||
insertDirective := regexp.MustCompile(`\${{\s*insert\s*}}`)
|
|
||||||
for i := 0; i < len(node.Content)/2; i++ {
|
|
||||||
changed := func() error {
|
|
||||||
if ret == nil {
|
|
||||||
ret = &yaml.Node{}
|
|
||||||
if err := ret.Encode(node); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
ret.Content = ret.Content[:i*2]
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
k := node.Content[i*2]
|
|
||||||
v := node.Content[i*2+1]
|
|
||||||
ev, err := ee.evaluateYamlNodeInternal(ctx, v)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if ev != nil {
|
|
||||||
if err := changed(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
ev = v
|
|
||||||
}
|
|
||||||
var sk string
|
|
||||||
// Merge the nested map of the insert directive
|
|
||||||
if k.Decode(&sk) == nil && insertDirective.MatchString(sk) {
|
|
||||||
if ev.Kind != yaml.MappingNode {
|
|
||||||
return nil, fmt.Errorf("failed to insert node %v into mapping %v unexpected type %v expected MappingNode", ev, node, ev.Kind)
|
|
||||||
}
|
|
||||||
if err := changed(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
ret.Content = append(ret.Content, ev.Content...)
|
|
||||||
} else {
|
|
||||||
ek, err := ee.evaluateYamlNodeInternal(ctx, k)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if ek != nil {
|
|
||||||
if err := changed(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
ek = k
|
|
||||||
}
|
|
||||||
if ret != nil {
|
|
||||||
ret.Content = append(ret.Content, ek, ev)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ret, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ee expressionEvaluator) evaluateSequenceYamlNode(ctx context.Context, node *yaml.Node) (*yaml.Node, error) {
|
|
||||||
var ret *yaml.Node
|
|
||||||
for i := 0; i < len(node.Content); i++ {
|
|
||||||
v := node.Content[i]
|
|
||||||
// Preserve nested sequences
|
|
||||||
wasseq := v.Kind == yaml.SequenceNode
|
|
||||||
ev, err := ee.evaluateYamlNodeInternal(ctx, v)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if ev != nil {
|
|
||||||
if ret == nil {
|
|
||||||
ret = &yaml.Node{}
|
|
||||||
if err := ret.Encode(node); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
ret.Content = ret.Content[:i]
|
|
||||||
}
|
|
||||||
// GitHub has this undocumented feature to merge sequences / arrays
|
|
||||||
// We have a nested sequence via evaluation, merge the arrays
|
|
||||||
if ev.Kind == yaml.SequenceNode && !wasseq {
|
|
||||||
ret.Content = append(ret.Content, ev.Content...)
|
|
||||||
} else {
|
|
||||||
ret.Content = append(ret.Content, ev)
|
|
||||||
}
|
|
||||||
} else if ret != nil {
|
|
||||||
ret.Content = append(ret.Content, v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ret, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ee expressionEvaluator) evaluateYamlNodeInternal(ctx context.Context, node *yaml.Node) (*yaml.Node, error) {
|
|
||||||
switch node.Kind {
|
|
||||||
case yaml.ScalarNode:
|
|
||||||
return ee.evaluateScalarYamlNode(ctx, node)
|
|
||||||
case yaml.MappingNode:
|
|
||||||
return ee.evaluateMappingYamlNode(ctx, node)
|
|
||||||
case yaml.SequenceNode:
|
|
||||||
return ee.evaluateSequenceYamlNode(ctx, node)
|
|
||||||
default:
|
|
||||||
return nil, nil //nolint:nilnil // pre-existing issue from nektos/act
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ee expressionEvaluator) EvaluateYamlNode(ctx context.Context, node *yaml.Node) error {
|
func (ee expressionEvaluator) EvaluateYamlNode(ctx context.Context, node *yaml.Node) error {
|
||||||
ret, err := ee.evaluateYamlNodeInternal(ctx, node)
|
return ee.shared(ctx).EvaluateYamlNode(node)
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if ret != nil {
|
|
||||||
return ret.Decode(node)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
func (ee expressionEvaluator) interpolate(ctx context.Context, in string) (string, error) {
|
||||||
|
return ee.shared(ctx).Interpolate(in)
|
||||||
}
|
}
|
||||||
|
|
||||||
// EvalBool evaluates an expression against given evaluator
|
// 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)
|
return expreval.New(func(in string, dsc exprparser.DefaultStatusCheck) (any, error) {
|
||||||
|
return evaluator.evaluate(ctx, in, dsc)
|
||||||
evaluated, err := evaluator.evaluate(ctx, nextExpr, defaultStatusCheck)
|
}).EvalBool(expr, defaultStatusCheck)
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return exprparser.IsTruthy(evaluated), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func escapeFormatString(in string) string {
|
|
||||||
return strings.ReplaceAll(strings.ReplaceAll(in, "{", "{{"), "}", "}}")
|
|
||||||
}
|
|
||||||
|
|
||||||
func rewriteSubExpression(ctx context.Context, in string, forceFormat bool) (string, error) { //nolint:unparam // pre-existing issue from nektos/act
|
|
||||||
if !strings.Contains(in, "${{") || !strings.Contains(in, "}}") {
|
|
||||||
return in, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
strPattern := regexp.MustCompile("(?:''|[^'])*'")
|
|
||||||
pos := 0
|
|
||||||
exprStart := -1
|
|
||||||
strStart := -1
|
|
||||||
var results []string
|
|
||||||
var formatOut strings.Builder
|
|
||||||
for pos < len(in) {
|
|
||||||
if strStart > -1 {
|
|
||||||
matches := strPattern.FindStringIndex(in[pos:])
|
|
||||||
if matches == nil {
|
|
||||||
panic("unclosed string.")
|
|
||||||
}
|
|
||||||
|
|
||||||
strStart = -1
|
|
||||||
pos += matches[1]
|
|
||||||
} else if exprStart > -1 {
|
|
||||||
exprEnd := strings.Index(in[pos:], "}}")
|
|
||||||
strStart = strings.Index(in[pos:], "'")
|
|
||||||
|
|
||||||
if exprEnd > -1 && strStart > -1 {
|
|
||||||
if exprEnd < strStart {
|
|
||||||
strStart = -1
|
|
||||||
} else {
|
|
||||||
exprEnd = -1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if exprEnd > -1 {
|
|
||||||
fmt.Fprintf(&formatOut, "{%d}", len(results))
|
|
||||||
results = append(results, strings.TrimSpace(in[exprStart:pos+exprEnd]))
|
|
||||||
pos += exprEnd + 2
|
|
||||||
exprStart = -1
|
|
||||||
} else if strStart > -1 {
|
|
||||||
pos += strStart + 1
|
|
||||||
} else {
|
|
||||||
panic("unclosed expression.")
|
|
||||||
}
|
|
||||||
} 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
||||||
|
|||||||
@@ -9,9 +9,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/exprparser"
|
"gitea.dev/actionslib/pkg/exprparser"
|
||||||
"gitea.com/gitea/runner/act/model"
|
"gitea.dev/actionslib/pkg/model"
|
||||||
|
|
||||||
assert "github.com/stretchr/testify/assert"
|
assert "github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
yaml "go.yaml.in/yaml/v4"
|
yaml "go.yaml.in/yaml/v4"
|
||||||
@@ -259,6 +258,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,60 +278,6 @@ func TestInterpolate(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRewriteSubExpression(t *testing.T) {
|
|
||||||
table := []struct {
|
|
||||||
in string
|
|
||||||
out string
|
|
||||||
}{
|
|
||||||
{in: "Hello World", out: "Hello World"},
|
|
||||||
{in: "${{ true }}", out: "${{ true }}"},
|
|
||||||
{in: "${{ true }} ${{ true }}", out: "format('{0} {1}', true, true)"},
|
|
||||||
{in: "${{ true || false }} ${{ true && true }}", out: "format('{0} {1}', true || false, true && true)"},
|
|
||||||
{in: "${{ '}}' }}", out: "${{ '}}' }}"},
|
|
||||||
{in: "${{ '''}}''' }}", out: "${{ '''}}''' }}"},
|
|
||||||
{in: "${{ '''' }}", out: "${{ '''' }}"},
|
|
||||||
{in: `${{ fromJSON('"}}"') }}`, out: `${{ fromJSON('"}}"') }}`},
|
|
||||||
{in: `${{ fromJSON('"\"}}\""') }}`, out: `${{ fromJSON('"\"}}\""') }}`},
|
|
||||||
{in: `${{ fromJSON('"''}}"') }}`, out: `${{ fromJSON('"''}}"') }}`},
|
|
||||||
{in: "Hello ${{ 'World' }}", out: "format('Hello {0}', 'World')"},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, table := range table {
|
|
||||||
t.Run("TestRewriteSubExpression", func(t *testing.T) {
|
|
||||||
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"},
|
|
||||||
{in: "${{ true }}", out: "format('{0}', true)"},
|
|
||||||
{in: "${{ '}}' }}", out: "format('{0}', '}}')"},
|
|
||||||
{in: `${{ fromJSON('"}}"') }}`, out: `format('{0}', fromJSON('"}}"'))`},
|
|
||||||
{in: "Hello ${{ 'World' }}", out: "format('Hello {0}', 'World')"},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, table := range table {
|
|
||||||
t.Run("TestRewriteSubExpressionForceFormat", func(t *testing.T) {
|
|
||||||
assertObject := assert.New(t)
|
|
||||||
out, err := rewriteSubExpression(context.Background(), table.in, true)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
assertObject.Equal(table.out, out, table.in)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetEvaluatorInputsBoolean(t *testing.T) {
|
func TestGetEvaluatorInputsBoolean(t *testing.T) {
|
||||||
workflows := map[string]string{
|
workflows := map[string]string{
|
||||||
"workflow_call": `
|
"workflow_call": `
|
||||||
|
|||||||
@@ -23,8 +23,9 @@ import (
|
|||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
"gitea.com/gitea/runner/act/container"
|
"gitea.com/gitea/runner/act/container"
|
||||||
"gitea.com/gitea/runner/act/exprparser"
|
|
||||||
"gitea.com/gitea/runner/act/model"
|
"gitea.dev/actionslib/pkg/exprparser"
|
||||||
|
"gitea.dev/actionslib/pkg/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
const maxJobSummaryBytes = 1024 * 1024
|
const maxJobSummaryBytes = 1024 * 1024
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ import (
|
|||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
"gitea.com/gitea/runner/act/container"
|
"gitea.com/gitea/runner/act/container"
|
||||||
"gitea.com/gitea/runner/act/model"
|
|
||||||
|
|
||||||
|
"gitea.dev/actionslib/pkg/model"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
logrustest "github.com/sirupsen/logrus/hooks/test"
|
logrustest "github.com/sirupsen/logrus/hooks/test"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
"gitea.com/gitea/runner/act/model"
|
|
||||||
|
|
||||||
|
"gitea.dev/actionslib/pkg/model"
|
||||||
"github.com/sirupsen/logrus/hooks/test"
|
"github.com/sirupsen/logrus/hooks/test"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|||||||
@@ -6,8 +6,7 @@ package runner
|
|||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/model"
|
"gitea.dev/actionslib/pkg/model"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"go.yaml.in/yaml/v4"
|
"go.yaml.in/yaml/v4"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ import (
|
|||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
"gitea.com/gitea/runner/act/common/git"
|
"gitea.com/gitea/runner/act/common/git"
|
||||||
"gitea.com/gitea/runner/act/model"
|
|
||||||
|
"gitea.dev/actionslib/pkg/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
func newLocalReusableWorkflowExecutor(rc *RunContext) common.Executor {
|
func newLocalReusableWorkflowExecutor(rc *RunContext) common.Executor {
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common/git"
|
"gitea.com/gitea/runner/act/common/git"
|
||||||
"gitea.com/gitea/runner/act/model"
|
|
||||||
|
|
||||||
|
"gitea.dev/actionslib/pkg/model"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -27,10 +27,11 @@ import (
|
|||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
"gitea.com/gitea/runner/act/container"
|
"gitea.com/gitea/runner/act/container"
|
||||||
"gitea.com/gitea/runner/act/exprparser"
|
"gitea.com/gitea/runner/act/ghcontext"
|
||||||
"gitea.com/gitea/runner/act/model"
|
|
||||||
"gitea.com/gitea/runner/internal/pkg/lock"
|
"gitea.com/gitea/runner/internal/pkg/lock"
|
||||||
|
|
||||||
|
"gitea.dev/actionslib/pkg/exprparser"
|
||||||
|
"gitea.dev/actionslib/pkg/model"
|
||||||
"github.com/docker/cli/cli/compose/loader"
|
"github.com/docker/cli/cli/compose/loader"
|
||||||
"github.com/docker/go-connections/nat"
|
"github.com/docker/go-connections/nat"
|
||||||
"github.com/moby/moby/api/types/mount"
|
"github.com/moby/moby/api/types/mount"
|
||||||
@@ -1355,12 +1356,12 @@ func (rc *RunContext) getGithubContext(ctx context.Context) *model.GithubContext
|
|||||||
|
|
||||||
ghc.SetBaseAndHeadRef()
|
ghc.SetBaseAndHeadRef()
|
||||||
repoPath := rc.Config.Workdir
|
repoPath := rc.Config.Workdir
|
||||||
ghc.SetRepositoryAndOwner(ctx, rc.Config.GitHubInstance, rc.Config.RemoteName, repoPath)
|
ghcontext.SetRepositoryAndOwner(ctx, ghc, rc.Config.GitHubInstance, rc.Config.RemoteName, repoPath)
|
||||||
if ghc.Ref == "" {
|
if ghc.Ref == "" {
|
||||||
ghc.SetRef(ctx, rc.Config.DefaultBranch, repoPath)
|
ghcontext.SetRef(ctx, ghc, rc.Config.DefaultBranch, repoPath)
|
||||||
}
|
}
|
||||||
if ghc.Sha == "" {
|
if ghc.Sha == "" {
|
||||||
ghc.SetSha(ctx, repoPath)
|
ghcontext.SetSha(ctx, ghc, repoPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
ghc.SetRefTypeAndName()
|
ghc.SetRefTypeAndName()
|
||||||
|
|||||||
@@ -17,9 +17,9 @@ import (
|
|||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
"gitea.com/gitea/runner/act/container"
|
"gitea.com/gitea/runner/act/container"
|
||||||
"gitea.com/gitea/runner/act/exprparser"
|
|
||||||
"gitea.com/gitea/runner/act/model"
|
|
||||||
|
|
||||||
|
"gitea.dev/actionslib/pkg/exprparser"
|
||||||
|
"gitea.dev/actionslib/pkg/model"
|
||||||
"github.com/docker/cli/cli/compose/loader"
|
"github.com/docker/cli/cli/compose/loader"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
assert "github.com/stretchr/testify/assert"
|
assert "github.com/stretchr/testify/assert"
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ import (
|
|||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
"gitea.com/gitea/runner/act/container"
|
"gitea.com/gitea/runner/act/container"
|
||||||
"gitea.com/gitea/runner/act/model"
|
|
||||||
|
|
||||||
|
"gitea.dev/actionslib/pkg/model"
|
||||||
docker_container "github.com/moby/moby/api/types/container"
|
docker_container "github.com/moby/moby/api/types/container"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
"gitea.com/gitea/runner/act/model"
|
|
||||||
|
|
||||||
|
"gitea.dev/actionslib/pkg/model"
|
||||||
"github.com/joho/godotenv"
|
"github.com/joho/godotenv"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
assert "github.com/stretchr/testify/assert"
|
assert "github.com/stretchr/testify/assert"
|
||||||
@@ -312,7 +312,7 @@ func TestRunEvent(t *testing.T) {
|
|||||||
{workdir, "workflow_dispatch-scalar", "workflow_dispatch", "", platforms, secrets},
|
{workdir, "workflow_dispatch-scalar", "workflow_dispatch", "", platforms, secrets},
|
||||||
{workdir, "workflow_dispatch-scalar-composite-action", "workflow_dispatch", "", platforms, secrets},
|
{workdir, "workflow_dispatch-scalar-composite-action", "workflow_dispatch", "", platforms, secrets},
|
||||||
{workdir, "job-needs-context-contains-result", "push", "", platforms, secrets},
|
{workdir, "job-needs-context-contains-result", "push", "", platforms, secrets},
|
||||||
{"../model/testdata", "container-volumes", "push", "", platforms, secrets},
|
{workdir, "container-volumes", "push", "", platforms, secrets},
|
||||||
{workdir, "path-handling", "push", "", platforms, secrets},
|
{workdir, "path-handling", "push", "", platforms, secrets},
|
||||||
{workdir, "do-not-leak-step-env-in-composite", "push", "", platforms, secrets},
|
{workdir, "do-not-leak-step-env-in-composite", "push", "", platforms, secrets},
|
||||||
{workdir, "set-env-step-env-override", "push", "", platforms, secrets},
|
{workdir, "set-env-step-env-override", "push", "", platforms, secrets},
|
||||||
|
|||||||
@@ -15,8 +15,9 @@ import (
|
|||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
"gitea.com/gitea/runner/act/container"
|
"gitea.com/gitea/runner/act/container"
|
||||||
"gitea.com/gitea/runner/act/exprparser"
|
|
||||||
"gitea.com/gitea/runner/act/model"
|
"gitea.dev/actionslib/pkg/exprparser"
|
||||||
|
"gitea.dev/actionslib/pkg/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
type step interface {
|
type step interface {
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
"gitea.com/gitea/runner/act/model"
|
|
||||||
|
"gitea.dev/actionslib/pkg/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
type stepActionLocal struct {
|
type stepActionLocal struct {
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
"gitea.com/gitea/runner/act/model"
|
|
||||||
|
|
||||||
|
"gitea.dev/actionslib/pkg/model"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/mock"
|
"github.com/stretchr/testify/mock"
|
||||||
"go.yaml.in/yaml/v4"
|
"go.yaml.in/yaml/v4"
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ import (
|
|||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
"gitea.com/gitea/runner/act/common/git"
|
"gitea.com/gitea/runner/act/common/git"
|
||||||
"gitea.com/gitea/runner/act/model"
|
|
||||||
|
|
||||||
|
"gitea.dev/actionslib/pkg/model"
|
||||||
gogit "github.com/go-git/go-git/v5"
|
gogit "github.com/go-git/go-git/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ import (
|
|||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
"gitea.com/gitea/runner/act/common/git"
|
"gitea.com/gitea/runner/act/common/git"
|
||||||
"gitea.com/gitea/runner/act/model"
|
|
||||||
|
|
||||||
|
"gitea.dev/actionslib/pkg/model"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/mock"
|
"github.com/stretchr/testify/mock"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ import (
|
|||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
"gitea.com/gitea/runner/act/container"
|
"gitea.com/gitea/runner/act/container"
|
||||||
"gitea.com/gitea/runner/act/model"
|
|
||||||
|
|
||||||
|
"gitea.dev/actionslib/pkg/model"
|
||||||
"github.com/kballard/go-shellquote"
|
"github.com/kballard/go-shellquote"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/container"
|
"gitea.com/gitea/runner/act/container"
|
||||||
"gitea.com/gitea/runner/act/model"
|
|
||||||
|
|
||||||
|
"gitea.dev/actionslib/pkg/model"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/mock"
|
"github.com/stretchr/testify/mock"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ package runner
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/model"
|
"gitea.dev/actionslib/pkg/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
type stepFactory interface {
|
type stepFactory interface {
|
||||||
|
|||||||
@@ -7,8 +7,7 @@ package runner
|
|||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/model"
|
"gitea.dev/actionslib/pkg/model"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ import (
|
|||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
"gitea.com/gitea/runner/act/container"
|
"gitea.com/gitea/runner/act/container"
|
||||||
"gitea.com/gitea/runner/act/lookpath"
|
"gitea.com/gitea/runner/act/lookpath"
|
||||||
"gitea.com/gitea/runner/act/model"
|
|
||||||
|
|
||||||
|
"gitea.dev/actionslib/pkg/model"
|
||||||
"github.com/kballard/go-shellquote"
|
"github.com/kballard/go-shellquote"
|
||||||
"github.com/sirupsen/logrus"
|
"github.com/sirupsen/logrus"
|
||||||
yaml "go.yaml.in/yaml/v4"
|
yaml "go.yaml.in/yaml/v4"
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
"gitea.com/gitea/runner/act/model"
|
|
||||||
|
|
||||||
|
"gitea.dev/actionslib/pkg/model"
|
||||||
"github.com/sirupsen/logrus"
|
"github.com/sirupsen/logrus"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/container"
|
"gitea.com/gitea/runner/act/container"
|
||||||
"gitea.com/gitea/runner/act/model"
|
|
||||||
|
|
||||||
|
"gitea.dev/actionslib/pkg/model"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/mock"
|
"github.com/stretchr/testify/mock"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
"gitea.com/gitea/runner/act/model"
|
|
||||||
|
|
||||||
|
"gitea.dev/actionslib/pkg/model"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/mock"
|
"github.com/stretchr/testify/mock"
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ import (
|
|||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
"gitea.com/gitea/runner/act/common/git"
|
"gitea.com/gitea/runner/act/common/git"
|
||||||
"gitea.com/gitea/runner/act/model"
|
|
||||||
|
"gitea.dev/actionslib/pkg/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Actions bundle the @actions toolkit into their own JavaScript, and two of its lines keep it
|
// Actions bundle the @actions toolkit into their own JavaScript, and two of its lines keep it
|
||||||
|
|||||||
@@ -12,8 +12,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/model"
|
"gitea.dev/actionslib/pkg/model"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
6
go.mod
6
go.mod
@@ -2,10 +2,12 @@ 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
|
||||||
gitea.dev/actions-proto-go v0.6.0
|
gitea.dev/actionslib v0.6.1-0.20260806221014-d5dbc3fc1f21
|
||||||
github.com/avast/retry-go/v5 v5.0.0
|
github.com/avast/retry-go/v5 v5.0.0
|
||||||
github.com/containerd/errdefs v1.0.0
|
github.com/containerd/errdefs v1.0.0
|
||||||
github.com/creack/pty v1.1.24
|
github.com/creack/pty v1.1.24
|
||||||
@@ -28,7 +30,6 @@ require (
|
|||||||
github.com/opencontainers/selinux v1.15.1
|
github.com/opencontainers/selinux v1.15.1
|
||||||
github.com/prometheus/client_golang v1.24.1
|
github.com/prometheus/client_golang v1.24.1
|
||||||
github.com/prometheus/client_model v0.6.2
|
github.com/prometheus/client_model v0.6.2
|
||||||
github.com/rhysd/actionlint v1.7.12
|
|
||||||
github.com/sirupsen/logrus v1.9.4
|
github.com/sirupsen/logrus v1.9.4
|
||||||
github.com/spf13/cobra v1.10.2
|
github.com/spf13/cobra v1.10.2
|
||||||
github.com/spf13/pflag v1.0.10
|
github.com/spf13/pflag v1.0.10
|
||||||
@@ -88,6 +89,7 @@ require (
|
|||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
github.com/prometheus/common v0.70.1 // indirect
|
github.com/prometheus/common v0.70.1 // indirect
|
||||||
github.com/prometheus/procfs v0.21.1 // indirect
|
github.com/prometheus/procfs v0.21.1 // indirect
|
||||||
|
github.com/rhysd/actionlint v1.7.12 // indirect
|
||||||
github.com/robfig/cron/v3 v3.0.1 // indirect
|
github.com/robfig/cron/v3 v3.0.1 // indirect
|
||||||
github.com/sergi/go-diff v1.4.0 // indirect
|
github.com/sergi/go-diff v1.4.0 // indirect
|
||||||
github.com/skeema/knownhosts v1.3.2 // indirect
|
github.com/skeema/knownhosts v1.3.2 // indirect
|
||||||
|
|||||||
4
go.sum
4
go.sum
@@ -4,8 +4,8 @@ cyphar.com/go-pathrs v0.2.3 h1:0pH8gep37wB0BgaXrEaN1OtZhUMeS7VvaejSr6i822o=
|
|||||||
cyphar.com/go-pathrs v0.2.3/go.mod h1:y8f1EMG7r+hCuFf/rXsKqMJrJAUoADZGNh5/vZPKcGc=
|
cyphar.com/go-pathrs v0.2.3/go.mod h1:y8f1EMG7r+hCuFf/rXsKqMJrJAUoADZGNh5/vZPKcGc=
|
||||||
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
|
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
|
||||||
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
|
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
|
||||||
gitea.dev/actions-proto-go v0.6.0 h1:gjllYQ5vmwlkqOeofTQu5qKTZpmf7kWsafoHvoPCSzY=
|
gitea.dev/actionslib v0.6.1-0.20260806221014-d5dbc3fc1f21 h1:1607w1GzOuYJ2cBQqsZwoI6M/dfd5NYcsReGDpCZizk=
|
||||||
gitea.dev/actions-proto-go v0.6.0/go.mod h1:p4RX+D9oqiEEzzkPMXscw2CmaGuYFPWFc6xIOmDNDqs=
|
gitea.dev/actionslib v0.6.1-0.20260806221014-d5dbc3fc1f21/go.mod h1:DI3Lqp+8TrycM7/semMdqsDQOHaBskjIAuMn+SXfZJ0=
|
||||||
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
|
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
|
||||||
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
|
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
|
||||||
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
|
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -20,10 +20,10 @@ import (
|
|||||||
"gitea.com/gitea/runner/act/artifactcache"
|
"gitea.com/gitea/runner/act/artifactcache"
|
||||||
"gitea.com/gitea/runner/act/artifacts"
|
"gitea.com/gitea/runner/act/artifacts"
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
"gitea.com/gitea/runner/act/model"
|
|
||||||
"gitea.com/gitea/runner/act/runner"
|
"gitea.com/gitea/runner/act/runner"
|
||||||
"gitea.com/gitea/runner/internal/app/run"
|
"gitea.com/gitea/runner/internal/app/run"
|
||||||
|
|
||||||
|
"gitea.dev/actionslib/pkg/model"
|
||||||
"github.com/joho/godotenv"
|
"github.com/joho/godotenv"
|
||||||
"github.com/moby/moby/api/types/container"
|
"github.com/moby/moby/api/types/container"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
|
|||||||
@@ -12,8 +12,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/model"
|
"gitea.dev/actionslib/pkg/model"
|
||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"go.yaml.in/yaml/v4"
|
"go.yaml.in/yaml/v4"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ import (
|
|||||||
"gitea.com/gitea/runner/internal/pkg/ver"
|
"gitea.com/gitea/runner/internal/pkg/ver"
|
||||||
|
|
||||||
"connectrpc.com/connect"
|
"connectrpc.com/connect"
|
||||||
pingv1 "gitea.dev/actions-proto-go/ping/v1"
|
pingv1 "gitea.dev/actionslib/ping/v1"
|
||||||
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
|
runnerv1 "gitea.dev/actionslib/runner/v1"
|
||||||
"github.com/mattn/go-isatty"
|
"github.com/mattn/go-isatty"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import (
|
|||||||
"gitea.com/gitea/runner/internal/pkg/metrics"
|
"gitea.com/gitea/runner/internal/pkg/metrics"
|
||||||
|
|
||||||
"connectrpc.com/connect"
|
"connectrpc.com/connect"
|
||||||
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
|
runnerv1 "gitea.dev/actionslib/runner/v1"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import (
|
|||||||
"gitea.com/gitea/runner/internal/pkg/config"
|
"gitea.com/gitea/runner/internal/pkg/config"
|
||||||
|
|
||||||
connect_go "connectrpc.com/connect"
|
connect_go "connectrpc.com/connect"
|
||||||
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
|
runnerv1 "gitea.dev/actionslib/runner/v1"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/mock"
|
"github.com/stretchr/testify/mock"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import (
|
|||||||
"gitea.com/gitea/runner/internal/pkg/process"
|
"gitea.com/gitea/runner/internal/pkg/process"
|
||||||
"gitea.com/gitea/runner/internal/pkg/report"
|
"gitea.com/gitea/runner/internal/pkg/report"
|
||||||
|
|
||||||
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
|
runnerv1 "gitea.dev/actionslib/runner/v1"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import (
|
|||||||
"gitea.com/gitea/runner/internal/pkg/metrics"
|
"gitea.com/gitea/runner/internal/pkg/metrics"
|
||||||
"gitea.com/gitea/runner/internal/pkg/report"
|
"gitea.com/gitea/runner/internal/pkg/report"
|
||||||
|
|
||||||
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
|
runnerv1 "gitea.dev/actionslib/runner/v1"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ import (
|
|||||||
"gitea.com/gitea/runner/act/artifactcache"
|
"gitea.com/gitea/runner/act/artifactcache"
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
"gitea.com/gitea/runner/act/container"
|
"gitea.com/gitea/runner/act/container"
|
||||||
"gitea.com/gitea/runner/act/model"
|
|
||||||
"gitea.com/gitea/runner/act/runner"
|
"gitea.com/gitea/runner/act/runner"
|
||||||
"gitea.com/gitea/runner/internal/pkg/client"
|
"gitea.com/gitea/runner/internal/pkg/client"
|
||||||
"gitea.com/gitea/runner/internal/pkg/config"
|
"gitea.com/gitea/runner/internal/pkg/config"
|
||||||
@@ -34,7 +33,8 @@ import (
|
|||||||
"gitea.com/gitea/runner/internal/pkg/ver"
|
"gitea.com/gitea/runner/internal/pkg/ver"
|
||||||
|
|
||||||
"connectrpc.com/connect"
|
"connectrpc.com/connect"
|
||||||
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
|
"gitea.dev/actionslib/pkg/model"
|
||||||
|
runnerv1 "gitea.dev/actionslib/runner/v1"
|
||||||
docker_container "github.com/moby/moby/api/types/container"
|
docker_container "github.com/moby/moby/api/types/container"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import (
|
|||||||
"gitea.com/gitea/runner/internal/pkg/ver"
|
"gitea.com/gitea/runner/internal/pkg/ver"
|
||||||
|
|
||||||
"connectrpc.com/connect"
|
"connectrpc.com/connect"
|
||||||
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
|
runnerv1 "gitea.dev/actionslib/runner/v1"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/mock"
|
"github.com/stretchr/testify/mock"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import (
|
|||||||
"gitea.com/gitea/runner/internal/pkg/report"
|
"gitea.com/gitea/runner/internal/pkg/report"
|
||||||
"gitea.com/gitea/runner/internal/pkg/ver"
|
"gitea.com/gitea/runner/internal/pkg/ver"
|
||||||
|
|
||||||
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
|
runnerv1 "gitea.dev/actionslib/runner/v1"
|
||||||
)
|
)
|
||||||
|
|
||||||
// osReleasePath describes the host distribution on Linux; absent elsewhere, where the platform
|
// osReleasePath describes the host distribution on Linux; absent elsewhere, where the platform
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import (
|
|||||||
"gitea.com/gitea/runner/internal/pkg/labels"
|
"gitea.com/gitea/runner/internal/pkg/labels"
|
||||||
"gitea.com/gitea/runner/internal/pkg/ver"
|
"gitea.com/gitea/runner/internal/pkg/ver"
|
||||||
|
|
||||||
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
|
runnerv1 "gitea.dev/actionslib/runner/v1"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"google.golang.org/protobuf/types/known/structpb"
|
"google.golang.org/protobuf/types/known/structpb"
|
||||||
|
|||||||
@@ -9,9 +9,8 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/model"
|
"gitea.dev/actionslib/pkg/model"
|
||||||
|
runnerv1 "gitea.dev/actionslib/runner/v1"
|
||||||
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
|
|
||||||
"go.yaml.in/yaml/v4"
|
"go.yaml.in/yaml/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -6,9 +6,8 @@ package run
|
|||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/model"
|
"gitea.dev/actionslib/pkg/model"
|
||||||
|
runnerv1 "gitea.dev/actionslib/runner/v1"
|
||||||
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"go.yaml.in/yaml/v4"
|
"go.yaml.in/yaml/v4"
|
||||||
"gotest.tools/v3/assert"
|
"gotest.tools/v3/assert"
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
package client
|
package client
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"gitea.dev/actions-proto-go/ping/v1/pingv1connect"
|
"gitea.dev/actionslib/ping/v1/pingv1connect"
|
||||||
"gitea.dev/actions-proto-go/runner/v1/runnerv1connect"
|
"gitea.dev/actionslib/runner/v1/runnerv1connect"
|
||||||
)
|
)
|
||||||
|
|
||||||
// A Client manages communication with the runner.
|
// A Client manages communication with the runner.
|
||||||
|
|||||||
@@ -3,7 +3,11 @@
|
|||||||
|
|
||||||
package client
|
package client
|
||||||
|
|
||||||
|
import "gitea.dev/actionslib/pkg/protocol"
|
||||||
|
|
||||||
|
// The headers are defined in the shared protocol package so that Gitea and the
|
||||||
|
// runner cannot drift apart.
|
||||||
const (
|
const (
|
||||||
UUIDHeader = "x-runner-uuid"
|
UUIDHeader = protocol.UUIDHeader
|
||||||
TokenHeader = "x-runner-token"
|
TokenHeader = protocol.TokenHeader
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ import (
|
|||||||
"gitea.com/gitea/runner/internal/pkg/ver"
|
"gitea.com/gitea/runner/internal/pkg/ver"
|
||||||
|
|
||||||
"connectrpc.com/connect"
|
"connectrpc.com/connect"
|
||||||
"gitea.dev/actions-proto-go/ping/v1/pingv1connect"
|
"gitea.dev/actionslib/ping/v1/pingv1connect"
|
||||||
"gitea.dev/actions-proto-go/runner/v1/runnerv1connect"
|
"gitea.dev/actionslib/runner/v1/runnerv1connect"
|
||||||
)
|
)
|
||||||
|
|
||||||
func getHTTPClient(endpoint string, insecure bool) *http.Client {
|
func getHTTPClient(endpoint string, insecure bool) *http.Client {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"connectrpc.com/connect"
|
"connectrpc.com/connect"
|
||||||
pingv1 "gitea.dev/actions-proto-go/ping/v1"
|
pingv1 "gitea.dev/actionslib/ping/v1"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ import (
|
|||||||
|
|
||||||
mock "github.com/stretchr/testify/mock"
|
mock "github.com/stretchr/testify/mock"
|
||||||
|
|
||||||
pingv1 "gitea.dev/actions-proto-go/ping/v1"
|
pingv1 "gitea.dev/actionslib/ping/v1"
|
||||||
|
|
||||||
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
|
runnerv1 "gitea.dev/actionslib/runner/v1"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Client is an autogenerated mock type for the Client type
|
// Client is an autogenerated mock type for the Client type
|
||||||
|
|||||||
@@ -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/cache@v4.2 and later. Those actions
|
# Serve the actions cache service v2 API, used by actions/cache@v4.2 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())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
|
runnerv1 "gitea.dev/actionslib/runner/v1"
|
||||||
"github.com/prometheus/client_golang/prometheus"
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
"github.com/prometheus/client_golang/prometheus/collectors"
|
"github.com/prometheus/client_golang/prometheus/collectors"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
|
runnerv1 "gitea.dev/actionslib/runner/v1"
|
||||||
"github.com/prometheus/client_golang/prometheus"
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
dto "github.com/prometheus/client_model/go"
|
dto "github.com/prometheus/client_model/go"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user