Compare commits

..
Author SHA1 Message Date
Lunny Xiao 3ea5134ac6 chore: inject the version into main instead of a module path
The `-X` target carried the full module path in both the Makefile and
.goreleaser.yaml, so any change to the module path silently turned the
injection into a no-op and shipped a binary reporting "dev". Target
`main.version` instead, which no longer names the module, and fail
`make checks` when the injection stops taking effect.

Assisted-by: Codet:GPT-5.1-Codex
2026-08-07 11:14:30 -07:00
317 changed files with 5987 additions and 445 deletions
+2 -2
View File
@@ -24,7 +24,7 @@ jobs:
check-latest: true
- name: prepare anonymous docker config
run: mkdir -p "$DOCKER_CONFIG" && echo '{}' > "$DOCKER_CONFIG/config.json"
# Pre-pull internal/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`;
# the rest (alpine/ubuntu) pull on demand, absorbed by the make-test -timeout. The host
# daemon retains them between runs, so this is usually a fast manifest re-check.
- name: pre-pull test images
@@ -48,4 +48,4 @@ jobs:
- name: coverage report
run: |
make coverage-report
cat .tmp/coverage.md >> "$GITHUB_STEP_SUMMARY"
cat .tmp/coverage.md >> "$GITHUB_STEP_SUMMARY"
+2 -2
View File
@@ -1,6 +1,6 @@
/gitea-runner
.env
!/internal/act/runner/testdata/secrets/.env
!/act/runner/testdata/secrets/.env
.runner
.runner.lock
coverage.txt
@@ -14,4 +14,4 @@ coverage.txt
__debug_bin
# gorelease binary folder
/dist
.DS_Store
.DS_Store
+1 -1
View File
@@ -65,7 +65,7 @@ builds:
flags:
- -trimpath
ldflags:
- -s -w -X gitea.com/gitea/runner/internal/pkg/ver.version={{ .Summary }}
- -s -w -X main.version={{ .Summary }}
binary: >-
{{ .ProjectName }}-
{{- .Version }}-
+15 -2
View File
@@ -72,7 +72,8 @@ else
endif
TAGS ?=
LDFLAGS ?= -X "gitea.com/gitea/runner/internal/pkg/ver.version=v$(RELASE_VERSION)"
LDFLAGS ?= -X "main.version=v$(RELASE_VERSION)"
VERSION_CHECK_BIN := $(DIST)/version-check$(suffix $(EXECUTABLE))
.PHONY: all
all: build
@@ -113,7 +114,19 @@ deps-tools: ## install tool dependencies
wait
.PHONY: checks
checks: tidy-check fmt-check security-check ## run the non-lint source checks
checks: tidy-check fmt-check security-check version-check ## run the non-lint source checks
.PHONY: version-check
version-check: ## verify the version is injected into the binary
@mkdir -p $(DIST)
@$(GO) build -tags '$(TAGS)' -ldflags '-s -w $(EXTLDFLAGS) -X "main.version=v0.0.0-injected"' -o $(VERSION_CHECK_BIN) .
@case "$$($(VERSION_CHECK_BIN) --version)" in \
*v0.0.0-injected*) ;; \
*) echo "version injection is broken, the Makefile -X target no longer matches a variable" >&2; exit 1;; \
esac
@rm -f $(VERSION_CHECK_BIN)
@# goreleaser builds releases from its own ldflags, so a stale -X target there ships an unversioned binary
@grep -q -- '-X main.version=' .goreleaser.yaml || { echo ".goreleaser.yaml no longer injects main.version" >&2; exit 1; }
.PHONY: lint
lint: lint-go lint-go-windows ## lint everything
@@ -26,7 +26,7 @@ import (
"sync/atomic"
"time"
"gitea.com/gitea/runner/internal/act/common"
"gitea.com/gitea/runner/act/common"
"github.com/julienschmidt/httprouter"
"github.com/sirupsen/logrus"
@@ -17,7 +17,7 @@ import (
"strings"
"time"
"gitea.com/gitea/runner/internal/act/common"
"gitea.com/gitea/runner/act/common"
"github.com/julienschmidt/httprouter"
)
+60
View File
@@ -0,0 +1,60 @@
// 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
}
+43
View File
@@ -0,0 +1,43 @@
// 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)
}
@@ -15,7 +15,7 @@ import (
"strings"
"sync"
"gitea.com/gitea/runner/internal/act/common"
"gitea.com/gitea/runner/act/common"
"gitea.com/gitea/runner/internal/pkg/lock"
"github.com/go-git/go-git/v5"
@@ -16,7 +16,7 @@ import (
"testing"
"time"
"gitea.com/gitea/runner/internal/act/common"
"gitea.com/gitea/runner/act/common"
log "github.com/sirupsen/logrus"
logrustest "github.com/sirupsen/logrus/hooks/test"
@@ -10,7 +10,7 @@ import (
"fmt"
"io"
"gitea.com/gitea/runner/internal/act/common"
"gitea.com/gitea/runner/act/common"
"github.com/docker/go-connections/nat"
"github.com/moby/moby/api/types/container"
@@ -9,7 +9,7 @@ package container
import (
"context"
"gitea.com/gitea/runner/internal/act/common"
"gitea.com/gitea/runner/act/common"
"github.com/distribution/reference"
"github.com/docker/cli/cli/config"
@@ -12,7 +12,7 @@ import (
"os"
"path/filepath"
"gitea.com/gitea/runner/internal/act/common"
"gitea.com/gitea/runner/act/common"
"github.com/moby/go-archive"
"github.com/moby/go-archive/compression"
@@ -13,7 +13,7 @@ import (
"strings"
"time"
"gitea.com/gitea/runner/internal/act/common"
"gitea.com/gitea/runner/act/common"
"github.com/moby/moby/client"
)
@@ -11,7 +11,7 @@ import (
"fmt"
"strings"
"gitea.com/gitea/runner/internal/act/common"
"gitea.com/gitea/runner/act/common"
"github.com/distribution/reference"
"github.com/moby/moby/api/pkg/authconfig"
@@ -14,6 +14,7 @@ import (
"fmt"
"io"
"os"
"path"
"path/filepath"
"regexp"
"runtime"
@@ -22,8 +23,8 @@ import (
"strings"
"time"
"gitea.com/gitea/runner/internal/act/common"
"gitea.com/gitea/runner/internal/act/filecollector"
"gitea.com/gitea/runner/act/common"
"gitea.com/gitea/runner/act/filecollector"
"dario.cat/mergo"
cerrdefs "github.com/containerd/errdefs"
@@ -938,24 +939,59 @@ func (cr *containerReference) waitForCommand(ctx context.Context, resp client.Hi
}
}
// mkdirInContainer creates containerPath and returns it with the symlinked components
// replaced by the targets the daemon reports for them. Docker 29.7 rejects tar entries
// traversing a symlink to an absolute target, like the "/var/run" of most images, with
// "path escapes from parent", and not every daemon creates the implied parents of a
// directory entry, so one entry per missing component is extracted at the deepest
// existing ancestor.
// WORKAROUND: https://github.com/moby/moby/issues/53258
func (cr *containerReference) mkdirInContainer(ctx context.Context, containerPath string) (string, error) {
parts := strings.Split(strings.Trim(path.Clean(containerPath), "/"), "/")
existing := "/"
for i, part := range parts {
if part == "" {
return existing, nil
}
stat, err := cr.cli.ContainerStatPath(ctx, cr.id, client.ContainerStatPathOptions{Path: path.Join(existing, part)})
if err != nil {
// nothing below exists either, so create the remaining components
return path.Join(existing, path.Join(parts[i:]...)), cr.mkdirEntries(ctx, existing, parts[i:])
}
existing = path.Join(existing, part)
if target := stat.Stat.LinkTarget; target != "" {
if !path.IsAbs(target) {
target = path.Join(path.Dir(existing), target)
}
existing = target
}
}
return existing, nil
}
func (cr *containerReference) mkdirEntries(ctx context.Context, destPath string, missing []string) error {
buf := &bytes.Buffer{}
tw := tar.NewWriter(buf)
for i := range missing {
_ = tw.WriteHeader(&tar.Header{
Name: path.Join(missing[:i+1]...),
Mode: 0o777,
Typeflag: tar.TypeDir,
})
}
tw.Close()
_, err := cr.cli.CopyToContainer(ctx, cr.id, client.CopyToContainerOptions{
DestinationPath: destPath,
Content: buf,
})
return err
}
func (cr *containerReference) CopyTarStream(ctx context.Context, destPath string, tarStream io.Reader) error {
if cr.id == "" {
return cr.missingContainerError("copy to %s", destPath)
}
// Mkdir, with a path relative to the DestinationPath ("/") below. Docker 29.5+
// rejects absolute tar entry names with "path escapes from parent".
buf := &bytes.Buffer{}
tw := tar.NewWriter(buf)
_ = tw.WriteHeader(&tar.Header{
Name: strings.TrimPrefix(destPath, "/"),
Mode: 0o777,
Typeflag: tar.TypeDir,
})
tw.Close()
_, err := cr.cli.CopyToContainer(ctx, cr.id, client.CopyToContainerOptions{
DestinationPath: "/",
Content: buf,
})
destPath, err := cr.mkdirInContainer(ctx, destPath)
if err != nil {
return fmt.Errorf("failed to mkdir to copy content to container: %w", err)
}
@@ -980,6 +1016,10 @@ func (cr *containerReference) copyDir(dstPath, srcPath string, useGitIgnore bool
return cr.missingContainerError("copy directory to %s", dstPath)
}
logger := common.Logger(ctx)
dstPath, err := cr.mkdirInContainer(ctx, dstPath)
if err != nil {
return fmt.Errorf("failed to mkdir to copy directory to container: %w", err)
}
tarFile, err := os.CreateTemp("", "act")
if err != nil {
return err
@@ -19,7 +19,7 @@ import (
"testing"
"time"
"gitea.com/gitea/runner/internal/act/common"
"gitea.com/gitea/runner/act/common"
cerrdefs "github.com/containerd/errdefs"
"github.com/moby/moby/api/pkg/stdcopy"
@@ -94,6 +94,11 @@ func (m *mockDockerClient) ExecInspect(ctx context.Context, execID string, opts
return args.Get(0).(mobyclient.ExecInspectResult), args.Error(1)
}
func (m *mockDockerClient) ContainerStatPath(ctx context.Context, containerID string, opts mobyclient.ContainerStatPathOptions) (mobyclient.ContainerStatPathResult, error) {
args := m.Called(ctx, containerID, opts)
return args.Get(0).(mobyclient.ContainerStatPathResult), args.Error(1)
}
func (m *mockDockerClient) ContainerAttach(ctx context.Context, containerID string, opts mobyclient.ContainerAttachOptions) (mobyclient.ContainerAttachResult, error) {
args := m.Called(ctx, containerID, opts)
return args.Get(0).(mobyclient.ContainerAttachResult), args.Error(1)
@@ -337,52 +342,37 @@ func TestDockerWaitFailure(t *testing.T) {
client.AssertExpectations(t)
}
func TestDockerCopyTarStream(t *testing.T) {
ctx := context.Background()
client := &mockDockerClient{}
client.On("CopyToContainer", ctx, "123", mock.MatchedBy(func(opts mobyclient.CopyToContainerOptions) bool {
return opts.DestinationPath == "/" && opts.Content != nil
})).Return(mobyclient.CopyToContainerResult{}, nil)
client.On("CopyToContainer", ctx, "123", mock.MatchedBy(func(opts mobyclient.CopyToContainerOptions) bool {
return opts.DestinationPath == "/var/run/act" && opts.Content != nil
})).Return(mobyclient.CopyToContainerResult{}, nil)
cr := &containerReference{
id: "123",
cli: client,
input: &NewContainerInput{
Image: "image",
},
// stubStatPath answers path resolution: the given paths exist, mapped to their target
// when they are a symlink, everything else does not exist.
func stubStatPath(client *mockDockerClient, existing map[string]string) {
for containerPath, target := range existing {
client.On("ContainerStatPath", mock.Anything, "123", mobyclient.ContainerStatPathOptions{Path: containerPath}).
Return(mobyclient.ContainerStatPathResult{Stat: container.PathStat{LinkTarget: target}}, nil).Maybe()
}
_ = cr.CopyTarStream(ctx, "/var/run/act", &bytes.Buffer{})
client.AssertExpectations(t)
client.On("ContainerStatPath", mock.Anything, "123", mock.Anything).
Return(mobyclient.ContainerStatPathResult{}, cerrdefs.ErrNotFound).Maybe()
}
// Docker 29.5+ rejects absolute names in the mkdir tarball with
// "path escapes from parent", since it is extracted relative to "/".
func TestDockerCopyTarStreamMkdirEntryIsRelative(t *testing.T) {
// The mkdir tarball is extracted at the deepest existing ancestor, with entries relative
// to it that never traverse the "/var/run" symlink, see moby/moby#53258.
func TestDockerCopyTarStream(t *testing.T) {
ctx := context.Background()
var mkdirNames []string
client := &mockDockerClient{}
stubStatPath(client, map[string]string{"/var": "", "/var/run": "/run", "/run": ""})
client.On("CopyToContainer", ctx, "123", mock.MatchedBy(func(opts mobyclient.CopyToContainerOptions) bool {
if opts.DestinationPath != "/" || opts.Content == nil {
if opts.DestinationPath != "/run" || opts.Content == nil {
return false
}
tr := tar.NewReader(opts.Content)
for {
hdr, err := tr.Next()
if err != nil {
break
}
for hdr, err := tr.Next(); err == nil; hdr, err = tr.Next() {
mkdirNames = append(mkdirNames, hdr.Name)
}
return true
})).Return(mobyclient.CopyToContainerResult{}, nil)
client.On("CopyToContainer", ctx, "123", mock.MatchedBy(func(opts mobyclient.CopyToContainerOptions) bool {
return opts.DestinationPath == "/var/run/act" && opts.Content != nil
return opts.DestinationPath == "/run/act" && opts.Content != nil
})).Return(mobyclient.CopyToContainerResult{}, nil)
cr := &containerReference{
id: "123",
@@ -393,58 +383,45 @@ func TestDockerCopyTarStreamMkdirEntryIsRelative(t *testing.T) {
}
require.NoError(t, cr.CopyTarStream(ctx, "/var/run/act", &bytes.Buffer{}))
assert.Equal(t, []string{"var/run/act"}, mkdirNames)
assert.Equal(t, []string{"act"}, mkdirNames)
client.AssertExpectations(t)
}
func TestDockerCopyTarStreamErrorInCopyFiles(t *testing.T) {
ctx := context.Background()
func TestDockerCopyTarStreamErrors(t *testing.T) {
merr := errors.New("Failure")
for _, testCase := range []struct {
name string
mkdirErr error
copyErr error
}{
{"mkdir", merr, nil},
{"copy content", nil, merr},
} {
t.Run(testCase.name, func(t *testing.T) {
ctx := context.Background()
client := &mockDockerClient{}
client.On("CopyToContainer", ctx, "123", mock.MatchedBy(func(opts mobyclient.CopyToContainerOptions) bool {
return opts.DestinationPath == "/" && opts.Content != nil
})).Return(mobyclient.CopyToContainerResult{}, merr)
cr := &containerReference{
id: "123",
cli: client,
input: &NewContainerInput{
Image: "image",
},
client := &mockDockerClient{}
stubStatPath(client, map[string]string{"/var": "", "/var/run": ""})
client.On("CopyToContainer", ctx, "123", mock.MatchedBy(func(opts mobyclient.CopyToContainerOptions) bool {
return opts.DestinationPath == "/var/run" && opts.Content != nil
})).Return(mobyclient.CopyToContainerResult{}, testCase.mkdirErr)
client.On("CopyToContainer", ctx, "123", mock.MatchedBy(func(opts mobyclient.CopyToContainerOptions) bool {
return opts.DestinationPath == "/var/run/act" && opts.Content != nil
})).Return(mobyclient.CopyToContainerResult{}, testCase.copyErr).Maybe()
cr := &containerReference{
id: "123",
cli: client,
input: &NewContainerInput{
Image: "image",
},
}
require.ErrorIs(t, cr.CopyTarStream(ctx, "/var/run/act", &bytes.Buffer{}), merr)
client.AssertExpectations(t)
})
}
err := cr.CopyTarStream(ctx, "/var/run/act", &bytes.Buffer{})
assert.ErrorIs(t, err, merr) //nolint:testifylint // pre-existing issue from nektos/act
client.AssertExpectations(t)
}
func TestDockerCopyTarStreamErrorInMkdir(t *testing.T) {
ctx := context.Background()
merr := errors.New("Failure")
client := &mockDockerClient{}
client.On("CopyToContainer", ctx, "123", mock.MatchedBy(func(opts mobyclient.CopyToContainerOptions) bool {
return opts.DestinationPath == "/" && opts.Content != nil
})).Return(mobyclient.CopyToContainerResult{}, nil)
client.On("CopyToContainer", ctx, "123", mock.MatchedBy(func(opts mobyclient.CopyToContainerOptions) bool {
return opts.DestinationPath == "/var/run/act" && opts.Content != nil
})).Return(mobyclient.CopyToContainerResult{}, merr)
cr := &containerReference{
id: "123",
cli: client,
input: &NewContainerInput{
Image: "image",
},
}
err := cr.CopyTarStream(ctx, "/var/run/act", &bytes.Buffer{})
assert.ErrorIs(t, err, merr) //nolint:testifylint // pre-existing issue from nektos/act
client.AssertExpectations(t)
}
// A remove that raced the daemon's AutoRemove teardown is not a failure and must not
@@ -635,10 +612,9 @@ func TestDockerCopyToSymlinkPath(t *testing.T) {
_ = rc.Close()(ctx)
})
// CopyTarStream first creates the destination directory by extracting a tar at "/",
// which makes the daemon mkdir var, then var/run (the symlink), then act — the exact
// step that fails on the broken daemon.
err := rc.CopyTarStream(ctx, "/var/run/act/actions/", &bytes.Buffer{})
// CopyTarStream resolves the var/run symlink and creates act below its target, the
// exact step that fails on a broken daemon.
err := rc.CopyTarStream(ctx, "/var/run/act", &bytes.Buffer{})
require.NoError(t, err)
}
@@ -12,7 +12,7 @@ import (
"runtime"
"time"
"gitea.com/gitea/runner/internal/act/common"
"gitea.com/gitea/runner/act/common"
"github.com/moby/moby/api/types/system"
)
@@ -9,7 +9,7 @@ package container
import (
"context"
"gitea.com/gitea/runner/internal/act/common"
"gitea.com/gitea/runner/act/common"
"github.com/moby/moby/client"
)
@@ -21,9 +21,9 @@ import (
"sync/atomic"
"time"
"gitea.com/gitea/runner/internal/act/common"
"gitea.com/gitea/runner/internal/act/filecollector"
"gitea.com/gitea/runner/internal/act/lookpath"
"gitea.com/gitea/runner/act/common"
"gitea.com/gitea/runner/act/filecollector"
"gitea.com/gitea/runner/act/lookpath"
"gitea.com/gitea/runner/internal/pkg/process"
"github.com/go-git/go-billy/v5/helper/polyfill"
@@ -17,7 +17,7 @@ import (
"testing"
"time"
"gitea.com/gitea/runner/internal/act/common"
"gitea.com/gitea/runner/act/common"
"github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
@@ -12,7 +12,7 @@ import (
"io"
"strings"
"gitea.com/gitea/runner/internal/act/common"
"gitea.com/gitea/runner/act/common"
"golang.org/x/text/encoding/unicode"
"golang.org/x/text/transform"
+309
View File
@@ -0,0 +1,309 @@
// 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
}
+283
View File
@@ -0,0 +1,283 @@
// 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)
})
}
}
+666
View File
@@ -0,0 +1,666 @@
// 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,
}
}
// Evaluate evaluates one expression. An empty input asks defaultStatusCheck on its own, which is
// what a value that carries no expression of its own runs under.
func (impl *interperterImpl) Evaluate(input string, defaultStatusCheck DefaultStatusCheck) (any, error) {
input = strings.TrimPrefix(input, "${{")
if input == "" && defaultStatusCheck != DefaultStatusCheckNone {
return impl.evaluateNode(statusCheckNode(defaultStatusCheck))
}
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 && !CallsStatusFunction(exprNode) {
exprNode = &actionlint.LogicalOpNode{
Kind: actionlint.LogicalOpNodeKindAnd,
Left: statusCheckNode(defaultStatusCheck),
Right: exprNode,
}
}
result, err2 := impl.evaluateNode(exprNode)
return result, err2
}
func statusCheckNode(defaultStatusCheck DefaultStatusCheck) *actionlint.FuncCallNode {
return &actionlint.FuncCallNode{Callee: defaultStatusCheck.String(), Args: []actionlint.ExprNode{}}
}
// CallsStatusFunction reports whether the expression calls a status function, which counts as the
// expression asking its own status question instead of the default one.
func CallsStatusFunction(exprNode actionlint.ExprNode) bool {
found := false
actionlint.VisitExprNode(exprNode, func(node, _ actionlint.ExprNode, entering bool) {
if funcCallNode, ok := node.(*actionlint.FuncCallNode); entering && ok {
switch strings.ToLower(funcCallNode.Callee) {
case "success", "always", "cancelled", "failure":
found = true
}
}
})
return found
}
func (impl *interperterImpl) evaluateNode(exprNode actionlint.ExprNode) (any, error) {
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)
}
}
+691
View File
@@ -0,0 +1,691 @@
// 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))
})
}
}
func TestEvaluateEmptyInputAsksItsOwnStatusCheck(t *testing.T) {
// always() needs no job or step context, so it shows which function an empty input asks for
output, err := NewInterpeter(&EvaluationEnvironment{}, Config{}).Evaluate("", DefaultStatusCheckAlways)
require.NoError(t, err)
assert.Equal(t, true, output)
}
+1
View File
@@ -0,0 +1 @@
Hello
+1
View File
@@ -0,0 +1 @@
World!
+1
View File
@@ -0,0 +1 @@
Knock knock!
@@ -0,0 +1 @@
Anybody home?
+138
View File
@@ -0,0 +1,138 @@
// 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
}
+82
View File
@@ -0,0 +1,82 @@
// 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)
}
}
+222
View File
@@ -0,0 +1,222 @@
// 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"))
}
}
}
@@ -2,14 +2,13 @@
// Copyright 2022 The nektos/act Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package ghcontext
package model
import (
"context"
"errors"
"testing"
"gitea.dev/actionslib/pkg/model"
log "github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
)
@@ -98,13 +97,13 @@ func TestSetRef(t *testing.T) {
for _, table := range tables {
t.Run(table.eventName, func(t *testing.T) {
ghc := &model.GithubContext{
ghc := &GithubContext{
EventName: table.eventName,
BaseRef: "master",
Event: table.event,
}
SetRef(context.Background(), ghc, "main", "/some/dir")
ghc.SetRef(context.Background(), "main", "/some/dir")
ghc.SetRefTypeAndName()
assert.Equal(t, table.ref, ghc.Ref)
@@ -117,12 +116,12 @@ func TestSetRef(t *testing.T) {
return "", errors.New("no default branch")
}
ghc := &model.GithubContext{
ghc := &GithubContext{
EventName: "no-default-branch",
Event: map[string]any{},
}
SetRef(context.Background(), ghc, "", "/some/dir")
ghc.SetRef(context.Background(), "", "/some/dir")
assert.Equal(t, "refs/heads/master", ghc.Ref)
})
@@ -203,13 +202,13 @@ func TestSetSha(t *testing.T) {
for _, table := range tables {
t.Run(table.eventName, func(t *testing.T) {
ghc := &model.GithubContext{
ghc := &GithubContext{
EventName: table.eventName,
BaseRef: "master",
Event: table.event,
}
SetSha(context.Background(), ghc, "/some/dir")
ghc.SetSha(context.Background(), "/some/dir")
assert.Equal(t, table.sha, ghc.Sha)
})
+22
View File
@@ -0,0 +1,22 @@
// 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
}
+410
View File
@@ -0,0 +1,410 @@
// 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
}
+199
View File
@@ -0,0 +1,199 @@
// 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
}
+49
View File
@@ -0,0 +1,49 @@
// 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"`
}

Some files were not shown because too many files have changed in this diff Show More