Replace expressions engine (#133)

This commit is contained in:
ChristopherHX
2025-10-06 13:53:15 +02:00
committed by GitHub
parent 418c708bb0
commit 82dccc7820
40 changed files with 6876 additions and 1304 deletions

View File

@@ -1,245 +1,6 @@
package exprparser
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"reflect"
"strconv"
"strings"
"github.com/go-git/go-git/v5/plumbing/format/gitignore"
"github.com/actions-oss/act-cli/pkg/model"
"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(impl.coerceToString(search).String()),
strings.ToLower(impl.coerceToString(item).String()),
), 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) {
return strings.HasPrefix(
strings.ToLower(impl.coerceToString(searchString).String()),
strings.ToLower(impl.coerceToString(searchValue).String()),
), nil
}
func (impl *interperterImpl) endsWith(searchString, searchValue reflect.Value) (bool, error) {
return strings.HasSuffix(
strings.ToLower(impl.coerceToString(searchString).String()),
strings.ToLower(impl.coerceToString(searchValue).String()),
), nil
}
const (
passThrough = iota
bracketOpen
bracketClose
)
func (impl *interperterImpl) format(str reflect.Value, replaceValue ...reflect.Value) (string, error) {
input := impl.coerceToString(str).String()
output := ""
replacementIndex := ""
state := passThrough
for _, character := range input {
switch state {
case passThrough: // normal buffer output
switch character {
case '{':
state = bracketOpen
case '}':
state = bracketClose
default:
output += string(character)
}
case bracketOpen: // found {
switch character {
case '{':
output += "{"
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 += impl.coerceToString(replaceValue[index]).String()
state = passThrough
default:
replacementIndex += string(character)
}
case bracketClose: // found }
switch character {
case '}':
output += "}"
replacementIndex = ""
state = passThrough
default:
panic("Invalid format parser state")
}
}
}
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, nil
}
func (impl *interperterImpl) join(array reflect.Value, sep reflect.Value) (string, error) {
separator := impl.coerceToString(sep).String()
switch array.Kind() {
case reflect.Slice:
var items []string
for i := 0; i < array.Len(); i++ {
items = append(items, impl.coerceToString(array.Index(i).Elem()).String())
}
return strings.Join(items, separator), nil
default:
return strings.Join([]string{impl.coerceToString(array).String()}, 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) (interface{}, error) {
if value.Kind() != reflect.String {
return nil, fmt.Errorf("cannot parse non-string type %v as JSON", value.Kind())
}
var data interface{}
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 "", fmt.Errorf("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
}
import "github.com/actions-oss/act-cli/pkg/model"
func (impl *interperterImpl) getNeedsTransitive(job *model.Job) []string {
needs := job.Needs()
@@ -252,11 +13,11 @@ func (impl *interperterImpl) getNeedsTransitive(job *model.Job) []string {
return needs
}
func (impl *interperterImpl) always() (bool, error) {
func (impl *interperterImpl) always() (interface{}, error) {
return true, nil
}
func (impl *interperterImpl) jobSuccess() (bool, error) {
func (impl *interperterImpl) jobSuccess() (interface{}, error) {
jobs := impl.config.Run.Workflow.Jobs
jobNeeds := impl.getNeedsTransitive(impl.config.Run.Job())
@@ -269,11 +30,11 @@ func (impl *interperterImpl) jobSuccess() (bool, error) {
return true, nil
}
func (impl *interperterImpl) stepSuccess() (bool, error) {
func (impl *interperterImpl) stepSuccess() (interface{}, error) {
return impl.env.Job.Status == "success", nil
}
func (impl *interperterImpl) jobFailure() (bool, error) {
func (impl *interperterImpl) jobFailure() (interface{}, error) {
jobs := impl.config.Run.Workflow.Jobs
jobNeeds := impl.getNeedsTransitive(impl.config.Run.Job())
@@ -286,10 +47,10 @@ func (impl *interperterImpl) jobFailure() (bool, error) {
return false, nil
}
func (impl *interperterImpl) stepFailure() (bool, error) {
func (impl *interperterImpl) stepFailure() (interface{}, error) {
return impl.env.Job.Status == "failure", nil
}
func (impl *interperterImpl) cancelled() (bool, error) {
func (impl *interperterImpl) cancelled() (interface{}, error) {
return impl.env.Job.Status == "cancelled", nil
}

View File

@@ -1,278 +0,0 @@
package exprparser
import (
"path/filepath"
"testing"
"github.com/actions-oss/act-cli/pkg/model"
"github.com/stretchr/testify/assert"
)
func TestFunctionContains(t *testing.T) {
table := []struct {
input string
expected interface{}
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"},
{`contains(fromJSON('[{ "first": { "result": "success" }},{ "second": { "result": "success" }}]').first.result, 'success') }}`, true, "multiple-contains-item"},
{`contains(fromJSON('[{ "result": "success" },{ "result": "failure" }]').*.result, 'failure') }}`, true, "multiple-contains-dereferenced-failure-item"},
{`contains(fromJSON('[{ "result": "failure" },{ "result": "success" }]').*.result, 'success') }}`, true, "multiple-contains-dereferenced-success-item"},
{`contains(fromJSON('[{ "result": "failure" },{ "result": "success" }]').*.result, 'notthere') }}`, false, "multiple-contains-dereferenced-missing-item"},
{`contains(fromJSON('[{ "result": "failure", "outputs": { "key": "val1" } },{ "result": "success", "outputs": { "key": "val2" } }]').*.outputs.key, 'val1') }}`, true, "multiple-contains-dereferenced-output-item"},
{`contains(fromJSON('[{ "result": "failure", "outputs": { "key": "val1" } },{ "result": "success", "outputs": { "key": "val2" } }]').*.outputs.key, 'val2') }}`, true, "multiple-contains-dereferenced-output-item-2"},
{`contains(fromJSON('[{ "result": "failure", "outputs": { "key": "val1" } },{ "result": "success", "outputs": { "key": "val2" } }]').*.outputs.key, 'missing') }}`, false, "multiple-contains-dereferenced-output-misssing-item"},
}
env := &EvaluationEnvironment{}
for _, tt := range table {
t.Run(tt.name, func(t *testing.T) {
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
assert.Nil(t, err)
assert.Equal(t, tt.expected, output)
})
}
}
func TestFunctionStartsWith(t *testing.T) {
table := []struct {
input string
expected interface{}
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.Nil(t, err)
assert.Equal(t, tt.expected, output)
})
}
}
func TestFunctionEndsWith(t *testing.T) {
table := []struct {
input string
expected interface{}
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.Nil(t, err)
assert.Equal(t, tt.expected, output)
})
}
}
func TestFunctionJoin(t *testing.T) {
table := []struct {
input string
expected interface{}
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"},
}
env := &EvaluationEnvironment{}
for _, tt := range table {
t.Run(tt.name, func(t *testing.T) {
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
assert.Nil(t, err)
assert.Equal(t, tt.expected, output)
})
}
}
func TestFunctionToJSON(t *testing.T) {
table := []struct {
input string
expected interface{}
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.Nil(t, err)
assert.Equal(t, tt.expected, output)
})
}
}
func TestFunctionFromJSON(t *testing.T) {
table := []struct {
input string
expected interface{}
name string
}{
{"fromJSON('{\"foo\":\"bar\"}') }}", map[string]interface{}{
"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.Nil(t, err)
assert.Equal(t, tt.expected, output)
})
}
}
func TestFunctionHashFiles(t *testing.T) {
table := []struct {
input string
expected interface{}
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.Nil(t, err)
output, err := NewInterpeter(env, Config{WorkingDir: workdir}).Evaluate(tt.input, DefaultStatusCheckNone)
assert.Nil(t, err)
assert.Equal(t, tt.expected, output)
})
}
}
func TestFunctionFormat(t *testing.T) {
table := []struct {
input string
expected interface{}
error interface{}
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('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('{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.Nil(t, err)
assert.Equal(t, tt.expected, output)
}
})
}
}
func TestMapContains(t *testing.T) {
env := &EvaluationEnvironment{
Needs: map[string]Needs{
"first-job": {
Outputs: map[string]string{},
Result: "success",
},
"second-job": {
Outputs: map[string]string{},
Result: "failure",
},
},
}
output, err := NewInterpeter(env, Config{}).Evaluate("contains(needs.*.result, 'failure')", DefaultStatusCheckNone)
assert.Nil(t, err)
assert.Equal(t, true, output)
}

View File

@@ -2,14 +2,14 @@ package exprparser
import (
"encoding"
"encoding/json"
"fmt"
"math"
"reflect"
"strings"
eval "github.com/actions-oss/act-cli/internal/eval/v2"
exprparser "github.com/actions-oss/act-cli/internal/expr"
"github.com/actions-oss/act-cli/pkg/model"
"github.com/rhysd/actionlint"
)
type EvaluationEnvironment struct {
@@ -83,22 +83,109 @@ func NewInterpeter(env *EvaluationEnvironment, config Config) Interpreter {
}
}
func toRawObj(left reflect.Value) map[string]any {
res, _ := toRaw(left).(map[string]any)
return res
}
func toRaw(left reflect.Value) any {
if left.IsZero() {
return nil
}
switch left.Kind() {
case reflect.Pointer:
if left.IsNil() {
return nil
}
return toRaw(left.Elem())
case reflect.Map:
iter := left.MapRange()
m := map[string]any{}
for iter.Next() {
key := iter.Key()
if key.Kind() == reflect.String {
nv := toRaw(iter.Value())
if nv != nil {
m[key.String()] = nv
}
}
}
if len(m) == 0 {
return nil
}
return m
case reflect.Struct:
m := map[string]any{}
leftType := left.Type()
for i := 0; i < leftType.NumField(); i++ {
var name string
if jsonName := leftType.Field(i).Tag.Get("json"); jsonName != "" {
name, _, _ = strings.Cut(jsonName, ",")
}
if name == "" {
name = leftType.Field(i).Name
}
v := left.Field(i).Interface()
if t, ok := v.(encoding.TextMarshaler); ok {
text, _ := t.MarshalText()
if len(text) > 0 {
m[name] = string(text)
}
} else {
nv := toRaw(left.Field(i))
if nv != nil {
m[name] = nv
}
}
}
return m
}
return left.Interface()
}
// All values are evaluated as string, funcs that takes objects are implemented elsewhere
type externalFunc struct {
f func([]reflect.Value) (interface{}, error)
}
func (e externalFunc) Evaluate(ev *eval.Evaluator, args []exprparser.Node) (*eval.EvaluationResult, error) {
rargs := []reflect.Value{}
for _, arg := range args {
res, err := ev.Evaluate(arg)
if err != nil {
return nil, err
}
rargs = append(rargs, reflect.ValueOf(res.ConvertToString()))
}
res, err := e.f(rargs)
if err != nil {
return nil, err
}
return eval.CreateIntermediateResult(ev.Context(), res), nil
}
func (impl *interperterImpl) Evaluate(input string, defaultStatusCheck DefaultStatusCheck) (interface{}, error) {
input = strings.TrimPrefix(input, "${{")
input = strings.TrimSuffix(input, "}}")
if defaultStatusCheck != DefaultStatusCheckNone && input == "" {
input = "success()"
}
parser := actionlint.NewExprParser()
exprNode, err := parser.Parse(actionlint.NewExprLexer(input + "}}"))
exprNode, err := exprparser.Parse(input)
if err != nil {
return nil, fmt.Errorf("failed to parse: %s", err.Message)
return nil, fmt.Errorf("failed to parse: %s", err.Error())
}
if defaultStatusCheck != DefaultStatusCheckNone {
hasStatusCheckFunction := false
actionlint.VisitExprNode(exprNode, func(node, _ actionlint.ExprNode, entering bool) {
if funcCallNode, ok := node.(*actionlint.FuncCallNode); entering && ok {
switch strings.ToLower(funcCallNode.Callee) {
exprparser.VisitNode(exprNode, func(node exprparser.Node) {
if funcCallNode, ok := node.(*exprparser.FunctionNode); ok {
switch strings.ToLower(funcCallNode.Name) {
case "success", "always", "cancelled", "failure":
hasStatusCheckFunction = true
}
@@ -106,470 +193,103 @@ func (impl *interperterImpl) Evaluate(input string, defaultStatusCheck DefaultSt
})
if !hasStatusCheckFunction {
exprNode = &actionlint.LogicalOpNode{
Kind: actionlint.LogicalOpNodeKindAnd,
Left: &actionlint.FuncCallNode{
Callee: defaultStatusCheck.String(),
Args: []actionlint.ExprNode{},
exprNode = &exprparser.BinaryNode{
Op: "&&",
Left: &exprparser.FunctionNode{
Name: defaultStatusCheck.String(),
Args: []exprparser.Node{},
},
Right: exprNode,
}
}
}
result, err2 := impl.evaluateNode(exprNode)
functions := impl.GetFunctions()
return result, err2
vars := impl.GetVariables()
ctx := eval.EvaluationContext{
Functions: functions,
Variables: vars,
}
evaluator := eval.NewEvaluator(&ctx)
res, err := evaluator.Evaluate(exprNode)
if err != nil {
return nil, err
}
return evaluator.ToRaw(res)
}
func (impl *interperterImpl) evaluateNode(exprNode actionlint.ExprNode) (interface{}, 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
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) GetFunctions() eval.CaseInsensitiveObject[eval.Function] {
functions := eval.GetFunctions()
if impl.env.HashFiles != nil {
functions["hashfiles"] = &externalFunc{impl.env.HashFiles}
}
}
func (impl *interperterImpl) evaluateVariable(variableNode *actionlint.VariableNode) (interface{}, error) {
lowerName := strings.ToLower(variableNode.Name)
if result, err := impl.evaluateOverriddenVariable(lowerName); result != nil || err != nil {
return result, err
}
switch lowerName {
case "github":
return impl.env.Github, nil
case "env":
if impl.env.EnvCS {
return CaseSensitiveDict(impl.env.Env), nil
functions["always"] = &externalFunc{func(_ []reflect.Value) (interface{}, error) {
return impl.always()
}}
functions["success"] = &externalFunc{func(_ []reflect.Value) (interface{}, error) {
if impl.config.Context == "job" {
return impl.jobSuccess()
}
return impl.env.Env, nil
case "job":
return impl.env.Job, nil
case "jobs":
if impl.env.Jobs == nil {
return nil, fmt.Errorf("unavailable context: jobs")
if impl.config.Context == "step" {
return impl.stepSuccess()
}
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)
}
return nil, fmt.Errorf("context '%s' must be one of 'job' or 'step'", impl.config.Context)
}}
functions["failure"] = &externalFunc{func(_ []reflect.Value) (interface{}, error) {
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)
}}
functions["cancelled"] = &externalFunc{func(_ []reflect.Value) (interface{}, error) {
return impl.cancelled()
}}
return functions
}
func (impl *interperterImpl) evaluateOverriddenVariable(lowerName string) (interface{}, error) {
if cd, ok := impl.env.CtxData[lowerName]; ok {
func (impl *interperterImpl) GetVariables() eval.ReadOnlyObject[any] {
githubCtx := toRawObj(reflect.ValueOf(impl.env.Github))
var env any
if impl.env.EnvCS {
env = eval.CaseSensitiveObject[any](toRawObj(reflect.ValueOf(impl.env.Env)))
} else {
env = eval.CaseInsensitiveObject[any](toRawObj(reflect.ValueOf(impl.env.Env)))
}
vars := eval.CaseInsensitiveObject[any]{
"github": githubCtx,
"env": env,
"vars": toRawObj(reflect.ValueOf(impl.env.Vars)),
"steps": toRawObj(reflect.ValueOf(impl.env.Steps)),
"strategy": toRawObj(reflect.ValueOf(impl.env.Strategy)),
"matrix": toRawObj(reflect.ValueOf(impl.env.Matrix)),
"secrets": toRawObj(reflect.ValueOf(impl.env.Secrets)),
"job": toRawObj(reflect.ValueOf(impl.env.Job)),
"runner": toRawObj(reflect.ValueOf(impl.env.Runner)),
"needs": toRawObj(reflect.ValueOf(impl.env.Needs)),
"jobs": toRawObj(reflect.ValueOf(impl.env.Jobs)),
"inputs": toRawObj(reflect.ValueOf(impl.env.Inputs)),
}
for name, cd := range impl.env.CtxData {
lowerName := strings.ToLower(name)
if serverPayload, ok := cd.(map[string]interface{}); ok {
if lowerName == "github" {
var out map[string]interface{}
content, err := json.Marshal(impl.env.Github)
if err != nil {
return nil, err
}
err = json.Unmarshal(content, &out)
if err != nil {
return nil, err
}
for k, v := range serverPayload {
// skip empty values, because github.workspace was set by Gitea Actions to an empty string
if _, ok := out[k]; !ok || v != "" && v != nil {
out[k] = v
if _, ok := githubCtx[k]; !ok || v != "" && v != nil {
githubCtx[k] = v
}
}
return out, nil
continue
}
}
return cd, nil
}
return nil, nil
}
func (impl *interperterImpl) evaluateIndexAccess(indexAccessNode *actionlint.IndexAccessNode) (interface{}, 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
}
return leftValue.Index(int(rightValue.Int())).Interface(), nil
default:
return nil, nil
}
default:
return nil, nil
}
}
func (impl *interperterImpl) evaluateObjectDeref(objectDerefNode *actionlint.ObjectDerefNode) (interface{}, error) {
left, err := impl.evaluateNode(objectDerefNode.Receiver)
if err != nil {
return nil, err
}
_, receiverIsDeref := objectDerefNode.Receiver.(*actionlint.ArrayDerefNode)
if receiverIsDeref {
return impl.getPropertyValueDereferenced(reflect.ValueOf(left), objectDerefNode.Property)
}
return impl.getPropertyValue(reflect.ValueOf(left), objectDerefNode.Property)
}
func (impl *interperterImpl) evaluateArrayDeref(arrayDerefNode *actionlint.ArrayDerefNode) (interface{}, 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 interface{}, err error) {
switch left.Kind() {
case reflect.Ptr:
return impl.getPropertyValue(left.Elem(), property)
case reflect.Struct:
leftType := left.Type()
for i := 0; i < leftType.NumField(); i++ {
jsonName := leftType.Field(i).Tag.Get("json")
if jsonName == property {
property = leftType.Field(i).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:
cd, ok := left.Interface().(CaseSensitiveDict)
if ok {
return cd[property], nil
}
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
case reflect.Slice:
var values []interface{}
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
}
func (impl *interperterImpl) getPropertyValueDereferenced(left reflect.Value, property string) (value interface{}, err error) {
switch left.Kind() {
case reflect.Ptr:
return impl.getPropertyValue(left, property)
case reflect.Struct:
return impl.getPropertyValue(left, property)
case reflect.Map:
iter := left.MapRange()
var values []interface{}
for iter.Next() {
value, err := impl.getPropertyValue(iter.Value(), property)
if err != nil {
return nil, err
}
values = append(values, value)
}
return values, nil
case reflect.Slice:
return impl.getPropertyValue(left, property)
}
return nil, nil
}
func (impl *interperterImpl) getMapValue(value reflect.Value) (interface{}, error) {
if value.Kind() == reflect.Ptr {
return impl.getMapValue(value.Elem())
}
return value.Interface(), nil
}
func (impl *interperterImpl) evaluateNot(notNode *actionlint.NotOpNode) (interface{}, error) {
operand, err := impl.evaluateNode(notNode.Operand)
if err != nil {
return nil, err
}
return !IsTruthy(operand), nil
}
func (impl *interperterImpl) evaluateCompare(compareNode *actionlint.CompareOpNode) (interface{}, 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 reflect.Value, rightValue reflect.Value, kind actionlint.CompareOpNodeKind) (interface{}, 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())
}
func (impl *interperterImpl) coerceToString(value reflect.Value) reflect.Value {
switch value.Kind() {
case reflect.Invalid:
return reflect.ValueOf("")
case reflect.Bool:
switch value.Bool() {
case true:
return reflect.ValueOf("true")
case false:
return reflect.ValueOf("false")
}
case reflect.String:
return value
case reflect.Int:
return reflect.ValueOf(fmt.Sprint(value))
case reflect.Float64:
if math.IsInf(value.Float(), 1) {
return reflect.ValueOf("Infinity")
} else if math.IsInf(value.Float(), -1) {
return reflect.ValueOf("-Infinity")
}
return reflect.ValueOf(fmt.Sprintf("%.15G", value.Float()))
case reflect.Slice:
return reflect.ValueOf("Array")
case reflect.Map:
return reflect.ValueOf("Object")
}
return value
}
func (impl *interperterImpl) compareString(left string, 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 float64, 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)
vars[name] = cd
}
return vars
}
func IsTruthy(input interface{}) bool {
@@ -598,116 +318,3 @@ func IsTruthy(input interface{}) bool {
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) interface{} {
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) (interface{}, 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())
}
//nolint:gocyclo
func (impl *interperterImpl) evaluateFuncCall(funcCallNode *actionlint.FuncCallNode) (interface{}, 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)
}
}

View File

@@ -17,9 +17,9 @@ func TestLiterals(t *testing.T) {
{"true", true, "true"},
{"false", false, "false"},
{"null", nil, "null"},
{"123", 123, "integer"},
{"123", float64(123), "integer"},
{"-9.7", -9.7, "float"},
{"0xff", 255, "hex"},
{"0xff", float64(255), "hex"},
{"-2.99e-2", -2.99e-2, "exponential"},
{"'foo'", "foo", "string"},
{"'it''s foo'", "it's foo", "string"},
@@ -50,10 +50,11 @@ func TestOperators(t *testing.T) {
{"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]')[1.1]", 1.0, "array-index", ""},
{"fromJSON('[0,1]')['1.1']", nil, "array-index", ""},
// Invalid Test
// {"(github.event.commits.*.author.username)[0]", "someone", "array-index-0", ""},
{"fromjson(tojson(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", ""},
@@ -72,8 +73,9 @@ func TestOperators(t *testing.T) {
{"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"},
{"github.event.commits[0].author.username1 != github.event.commits[1].author.username2", false, "property-comparison4", ""},
{"secrets != env", true, "property-comparison5", ""},
{"job.container && 'failure' || 'ok'", "ok", "object-truth", ""},
}
env := &EvaluationEnvironment{
@@ -175,7 +177,7 @@ func TestOperatorsBooleanEvaluation(t *testing.T) {
{"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 && -Infinity", math.Inf(-1), "true-and"},
{"true && NaN", math.NaN(), "true-and"},
{"true && ''", "", "true-and"},
{"true && 'abc'", "abc", "true-and"},
@@ -189,7 +191,7 @@ func TestOperatorsBooleanEvaluation(t *testing.T) {
{"false && 3.14", false, "false-and"},
{"false && 0.0", false, "false-and"},
{"false && Infinity", 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"},
@@ -203,7 +205,7 @@ func TestOperatorsBooleanEvaluation(t *testing.T) {
{"true || 3.14", true, "true-or"},
{"true || 0.0", true, "true-or"},
{"true || Infinity", 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"},
@@ -217,7 +219,7 @@ func TestOperatorsBooleanEvaluation(t *testing.T) {
{"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 || -Infinity", math.Inf(-1), "false-or"},
{"false || NaN", math.NaN(), "false-or"},
{"false || ''", "", "false-or"},
{"false || 'abc'", "abc", "false-or"},
@@ -231,7 +233,7 @@ func TestOperatorsBooleanEvaluation(t *testing.T) {
{"null && 3.14", nil, "null-and"},
{"null && 0.0", nil, "null-and"},
{"null && Infinity", 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"},
@@ -245,7 +247,7 @@ func TestOperatorsBooleanEvaluation(t *testing.T) {
{"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 || -Infinity", math.Inf(-1), "null-or"},
{"null || NaN", math.NaN(), "null-or"},
{"null || ''", "", "null-or"},
{"null || 'abc'", "abc", "null-or"},
@@ -259,7 +261,7 @@ func TestOperatorsBooleanEvaluation(t *testing.T) {
{"-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 && -Infinity", math.Inf(-1), "neg-num-and"},
{"-10 && NaN", math.NaN(), "neg-num-and"},
{"-10 && ''", "", "neg-num-and"},
{"-10 && 'abc'", "abc", "neg-num-and"},
@@ -273,7 +275,7 @@ func TestOperatorsBooleanEvaluation(t *testing.T) {
{"-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 || -Infinity", -10, "neg-num-or"},
{"-10 || NaN", -10, "neg-num-or"},
{"-10 || ''", -10, "neg-num-or"},
{"-10 || 'abc'", -10, "neg-num-or"},
@@ -287,7 +289,7 @@ func TestOperatorsBooleanEvaluation(t *testing.T) {
{"0 && 3.14", 0, "zero-and"},
{"0 && 0.0", 0, "zero-and"},
{"0 && Infinity", 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"},
@@ -301,7 +303,7 @@ func TestOperatorsBooleanEvaluation(t *testing.T) {
{"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 || -Infinity", math.Inf(-1), "zero-or"},
{"0 || NaN", math.NaN(), "zero-or"},
{"0 || ''", "", "zero-or"},
{"0 || 'abc'", "abc", "zero-or"},
@@ -343,7 +345,7 @@ func TestOperatorsBooleanEvaluation(t *testing.T) {
{"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 && -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"},
@@ -357,7 +359,7 @@ func TestOperatorsBooleanEvaluation(t *testing.T) {
{"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 || -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"},
@@ -371,7 +373,7 @@ func TestOperatorsBooleanEvaluation(t *testing.T) {
{"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 && -Infinity", math.Inf(-1), "pos-inf-and"},
{"Infinity && NaN", math.NaN(), "pos-inf-and"},
{"Infinity && ''", "", "pos-inf-and"},
{"Infinity && 'abc'", "abc", "pos-inf-and"},
@@ -385,38 +387,38 @@ func TestOperatorsBooleanEvaluation(t *testing.T) {
{"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 || -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 && 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"},
{"-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"},
@@ -427,7 +429,7 @@ func TestOperatorsBooleanEvaluation(t *testing.T) {
{"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 && -Infinity", math.NaN(), "nan-and"},
{"NaN && NaN", math.NaN(), "nan-and"},
{"NaN && ''", math.NaN(), "nan-and"},
{"NaN && 'abc'", math.NaN(), "nan-and"},
@@ -441,7 +443,7 @@ func TestOperatorsBooleanEvaluation(t *testing.T) {
{"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 || -Infinity", math.Inf(-1), "nan-or"},
{"NaN || NaN", math.NaN(), "nan-or"},
{"NaN || ''", "", "nan-or"},
{"NaN || 'abc'", "abc", "nan-or"},
@@ -455,7 +457,7 @@ func TestOperatorsBooleanEvaluation(t *testing.T) {
{"'' && 3.14", "", "empty-str-and"},
{"'' && 0.0", "", "empty-str-and"},
{"'' && Infinity", "", "empty-str-and"},
// {"'' && -Infinity", "", "empty-str-and"},
{"'' && -Infinity", "", "empty-str-and"},
{"'' && NaN", "", "empty-str-and"},
{"'' && ''", "", "empty-str-and"},
{"'' && 'abc'", "", "empty-str-and"},
@@ -469,7 +471,7 @@ func TestOperatorsBooleanEvaluation(t *testing.T) {
{"'' || 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"},
{"'' || -Infinity", math.Inf(-1), "empty-str-or"},
{"'' || NaN", math.NaN(), "empty-str-or"},
{"'' || ''", "", "empty-str-or"},
{"'' || 'abc'", "abc", "empty-str-or"},
@@ -483,7 +485,7 @@ func TestOperatorsBooleanEvaluation(t *testing.T) {
{"'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' && -Infinity", math.Inf(-1), "str-and"},
{"'abc' && NaN", math.NaN(), "str-and"},
{"'abc' && ''", "", "str-and"},
{"'abc' && 'abc'", "abc", "str-and"},
@@ -497,7 +499,7 @@ func TestOperatorsBooleanEvaluation(t *testing.T) {
{"'abc' || 3.14", "abc", "str-or"},
{"'abc' || 0.0", "abc", "str-or"},
{"'abc' || Infinity", "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"},
@@ -517,6 +519,11 @@ func TestOperatorsBooleanEvaluation(t *testing.T) {
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
assert.Nil(t, err)
// Normalize int => float64
if i, ok := tt.expected.(int); ok {
tt.expected = (float64)(i)
}
if expected, ok := tt.expected.(float64); ok && math.IsNaN(expected) {
assert.True(t, math.IsNaN(output.(float64)))
} else {
@@ -543,9 +550,9 @@ func TestContexts(t *testing.T) {
{input: "github.event.pull_request.labels.*.name", expected: nil, name: "github-context-noexist-prop"},
{input: "env.TEST", expected: "value", name: "env-context"},
{input: "env.TEST", expected: "value", name: "env-context", caseSensitiveEnv: true},
{input: "env.test", expected: "", name: "env-context", caseSensitiveEnv: true},
{input: "env.test", expected: nil, name: "env-context", caseSensitiveEnv: true},
{input: "env['TEST']", expected: "value", name: "env-context", caseSensitiveEnv: true},
{input: "env['test']", expected: "", name: "env-context", caseSensitiveEnv: true},
{input: "env['test']", expected: nil, name: "env-context", caseSensitiveEnv: true},
{input: "env.test", expected: "value", name: "env-context"},
{input: "job.status", expected: "success", name: "job-context"},
{input: "steps.step-id.outputs.name", expected: "value", name: "steps-context"},
@@ -561,10 +568,9 @@ func TestContexts(t *testing.T) {
{input: "steps['step-id']['outcome'] && true", expected: true, name: "steps-context-outcome"},
{input: "steps.step-id2.outcome", expected: "failure", name: "steps-context-outcome"},
{input: "steps.step-id2.outcome && true", expected: true, name: "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"},
{input: "contains(steps.*.outcome, 'success')", expected: true, name: "steps-context-array-outcome"},
{input: "contains(steps.*.outcome, 'failure')", expected: true, name: "steps-context-array-outcome"},
{input: "contains(steps.*.outputs.name, 'value')", expected: true, name: "steps-context-array-outputs"},
{input: "runner.os", expected: "Linux", name: "runner-context"},
{input: "secrets.name", expected: "value", name: "secrets-context"},
{input: "vars.name", expected: "value", name: "vars-context"},

View File

@@ -1 +0,0 @@
Hello

View File

@@ -1 +0,0 @@
World!

View File

@@ -1 +0,0 @@
Knock knock!

View File

@@ -1 +0,0 @@
Anybody home?

View File

@@ -69,6 +69,9 @@ func (w *Workflow) OnEvent(event string) interface{} {
}
func (w *Workflow) UnmarshalYAML(node *yaml.Node) error {
if err := resolveAliases(node); err != nil {
return err
}
// Validate the schema before deserializing it into our model
if err := (&schema.Node{
Definition: "workflow-root",
@@ -76,9 +79,6 @@ func (w *Workflow) UnmarshalYAML(node *yaml.Node) error {
}).UnmarshalYAML(node); err != nil {
return errors.Join(err, fmt.Errorf("actions YAML Schema Validation Error detected:\nFor more information, see: https://actions-oss.github.io/act-docs/usage/schema.html"))
}
if err := resolveAliases(node); err != nil {
return err
}
type WorkflowDefault Workflow
return node.Decode((*WorkflowDefault)(w))
}
@@ -86,6 +86,9 @@ func (w *Workflow) UnmarshalYAML(node *yaml.Node) error {
type WorkflowStrict Workflow
func (w *WorkflowStrict) UnmarshalYAML(node *yaml.Node) error {
if err := resolveAliases(node); err != nil {
return err
}
// Validate the schema before deserializing it into our model
if err := (&schema.Node{
Definition: "workflow-root-strict",
@@ -93,9 +96,6 @@ func (w *WorkflowStrict) UnmarshalYAML(node *yaml.Node) error {
}).UnmarshalYAML(node); err != nil {
return errors.Join(err, fmt.Errorf("actions YAML Strict Schema Validation Error detected:\nFor more information, see: https://nektosact.com/usage/schema.html"))
}
if err := resolveAliases(node); err != nil {
return err
}
type WorkflowDefault Workflow
return node.Decode((*WorkflowDefault)(w))
}

View File

@@ -444,6 +444,12 @@ func TestStep_ShellCommand(t *testing.T) {
func TestReadWorkflow_WorkflowDispatchConfig(t *testing.T) {
yaml := `
name: local-action-docker-url
on: push
jobs:
test:
runs-on: ubuntu-latest
steps:
- run: echo Test
`
workflow, err := ReadWorkflow(strings.NewReader(yaml), false)
assert.NoError(t, err, "read workflow should succeed")
@@ -452,16 +458,12 @@ func TestReadWorkflow_WorkflowDispatchConfig(t *testing.T) {
yaml = `
name: local-action-docker-url
on: push
`
workflow, err = ReadWorkflow(strings.NewReader(yaml), false)
assert.NoError(t, err, "read workflow should succeed")
workflowDispatch = workflow.WorkflowDispatchConfig()
assert.Nil(t, workflowDispatch)
yaml = `
name: local-action-docker-url
on: workflow_dispatch
jobs:
test:
runs-on: ubuntu-latest
steps:
- run: echo Test
`
workflow, err = ReadWorkflow(strings.NewReader(yaml), false)
assert.NoError(t, err, "read workflow should succeed")
@@ -472,6 +474,11 @@ func TestReadWorkflow_WorkflowDispatchConfig(t *testing.T) {
yaml = `
name: local-action-docker-url
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- run: echo Test
`
workflow, err = ReadWorkflow(strings.NewReader(yaml), false)
assert.NoError(t, err, "read workflow should succeed")
@@ -481,6 +488,11 @@ func TestReadWorkflow_WorkflowDispatchConfig(t *testing.T) {
yaml = `
name: local-action-docker-url
on: [push, workflow_dispatch]
jobs:
test:
runs-on: ubuntu-latest
steps:
- run: echo Test
`
workflow, err = ReadWorkflow(strings.NewReader(yaml), false)
assert.NoError(t, err, "read workflow should succeed")
@@ -493,6 +505,11 @@ func TestReadWorkflow_WorkflowDispatchConfig(t *testing.T) {
on:
- push
- workflow_dispatch
jobs:
test:
runs-on: ubuntu-latest
steps:
- run: echo Test
`
workflow, err = ReadWorkflow(strings.NewReader(yaml), false)
assert.NoError(t, err, "read workflow should succeed")
@@ -505,6 +522,11 @@ func TestReadWorkflow_WorkflowDispatchConfig(t *testing.T) {
on:
push:
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- run: echo Test
`
workflow, err = ReadWorkflow(strings.NewReader(yaml), false)
assert.NoError(t, err, "read workflow should succeed")
@@ -527,6 +549,11 @@ func TestReadWorkflow_WorkflowDispatchConfig(t *testing.T) {
- info
- warning
- debug
jobs:
test:
runs-on: ubuntu-latest
steps:
- run: echo Test
`
workflow, err = ReadWorkflow(strings.NewReader(yaml), false)
assert.NoError(t, err, "read workflow should succeed")

View File

@@ -143,6 +143,9 @@ func TestEvaluateRunContext(t *testing.T) {
out, err := ee.evaluate(context.Background(), table.in, exprparser.DefaultStatusCheckNone)
if table.errMesg == "" {
assertObject.NoError(err, table.in)
if i, ok := table.out.(int); ok {
table.out = float64(i)
}
assertObject.Equal(table.out, out, table.in)
} else {
assertObject.Error(err, table.in)

View File

@@ -5,14 +5,17 @@ on:
inputs:
required:
description: a required input
type: string
required: true
with_default:
description: an input with default
required: false
type: string
default: default
with_default2:
description: an input with default
required: false
type: string
default: ${{ github.event_name }}
boolean:
description: an input of type boolean

View File

@@ -0,0 +1,31 @@
package schema
import "slices"
func GetGiteaWorkflowSchema() *Schema {
schema := GetWorkflowSchema()
in := schema.Definitions
schema.Definitions = map[string]Definition{}
for k, v := range in {
if v.Context != nil && slices.Contains(v.Context, "github") {
v.Context = append(v.Context, "gitea", "env")
}
if k == "step-if" || k == "job-if" || k == "string-strategy-context" {
v.Context = append(v.Context, "secrets")
}
schema.Definitions[k] = v
}
updateUses(schema.Definitions["workflow-job"].Mapping)
updateUses(schema.Definitions["regular-step"].Mapping)
schema.Definitions["container-mapping"].Mapping.Properties["cmd"] = MappingProperty{
Type: "sequence-of-non-empty-string",
}
return schema
}
func updateUses(mapping *MappingDefinition) {
uses := mapping.Properties["uses"]
uses.Type = "string-strategy-context"
mapping.Properties["uses"] = uses
}

View File

@@ -0,0 +1,19 @@
package schema
import (
"encoding/json"
"os"
"testing"
"github.com/stretchr/testify/assert"
)
func TestGiteaSchemaFactory(t *testing.T) {
schema := GetGiteaWorkflowSchema()
_ = schema
data, err := json.MarshalIndent(schema, "", " ")
assert.NoError(t, err)
err = os.WriteFile("gitea_workflow_schema.json", append(data, "\n"...), 0o600)
assert.NoError(t, err)
}

File diff suppressed because it is too large Load Diff

View File

@@ -10,7 +10,7 @@ import (
"strconv"
"strings"
"github.com/rhysd/actionlint"
exprparser "github.com/actions-oss/act-cli/internal/expr"
"gopkg.in/yaml.v3"
)
@@ -22,6 +22,87 @@ var actionSchema string
var functions = regexp.MustCompile(`^([a-zA-Z0-9_]+)\(([0-9]+),([0-9]+|MAX)\)$`)
type ValidationKind int
const (
ValidationKindFatal ValidationKind = iota
ValidationKindWarning
ValidationKindInvalidProperty
ValidationKindMismatched
ValidationKindMissingProperty
)
type Location struct {
Line int
Column int
}
type ValidationError struct {
Kind ValidationKind
Location
Message string
}
func (e ValidationError) Error() string {
return fmt.Sprintf("Line: %d Column %d: %s", e.Line, e.Column, e.Message)
}
type ValidationErrorCollection struct {
Errors []ValidationError
Collections []ValidationErrorCollection
}
func indent(builder *strings.Builder, in string) {
for _, v := range strings.Split(in, "\n") {
if v != "" {
builder.WriteString(" ")
builder.WriteString(v)
}
builder.WriteString("\n")
}
}
func (c ValidationErrorCollection) Error() string {
var builder strings.Builder
for _, e := range c.Errors {
if builder.Len() > 0 {
builder.WriteString("\n")
}
builder.WriteString(e.Error())
}
for _, e := range c.Collections {
if builder.Len() > 0 {
builder.WriteString("\n")
}
indent(&builder, e.Error())
}
return builder.String()
}
func (c *ValidationErrorCollection) AddError(err ValidationError) {
c.Errors = append(c.Errors, err)
}
func AsValidationErrorCollection(err error) *ValidationErrorCollection {
if col, ok := err.(ValidationErrorCollection); ok {
return &col
}
if col, ok := err.(*ValidationErrorCollection); ok {
return col
}
if e, ok := err.(ValidationError); ok {
return &ValidationErrorCollection{
Errors: []ValidationError{e},
}
}
if e, ok := err.(*ValidationError); ok {
return &ValidationErrorCollection{
Errors: []ValidationError{*e},
}
}
return nil
}
type Schema struct {
Definitions map[string]Definition
}
@@ -50,26 +131,26 @@ func (s *Schema) GetDefinition(name string) Definition {
}
type Definition struct {
Context []string
Mapping *MappingDefinition
Sequence *SequenceDefinition
OneOf *[]string `json:"one-of"`
AllowedValues *[]string `json:"allowed-values"`
String *StringDefinition
Number *NumberDefinition
Boolean *BooleanDefinition
Null *NullDefinition
Context []string `json:"context,omitempty"`
Mapping *MappingDefinition `json:"mapping,omitempty"`
Sequence *SequenceDefinition `json:"sequence,omitempty"`
OneOf *[]string `json:"one-of,omitempty"`
AllowedValues *[]string `json:"allowed-values,omitempty"`
String *StringDefinition `json:"string,omitempty"`
Number *NumberDefinition `json:"number,omitempty"`
Boolean *BooleanDefinition `json:"boolean,omitempty"`
Null *NullDefinition `json:"null,omitempty"`
}
type MappingDefinition struct {
Properties map[string]MappingProperty
LooseKeyType string `json:"loose-key-type"`
LooseValueType string `json:"loose-value-type"`
Properties map[string]MappingProperty `json:"properties,omitempty"`
LooseKeyType string `json:"loose-key-type,omitempty"`
LooseValueType string `json:"loose-value-type,omitempty"`
}
type MappingProperty struct {
Type string
Required bool
Type string `json:"type,omitempty"`
Required bool `json:"required,omitempty"`
}
func (s *MappingProperty) UnmarshalJSON(data []byte) error {
@@ -85,8 +166,8 @@ type SequenceDefinition struct {
}
type StringDefinition struct {
Constant string
IsExpression bool `json:"is-expression"`
Constant string `json:"constant,omitempty"`
IsExpression bool `json:"is-expression,omitempty"`
}
type NumberDefinition struct {
@@ -111,23 +192,22 @@ func GetActionSchema() *Schema {
}
type Node struct {
Definition string
Schema *Schema
Context []string
RestrictEval bool
Definition string
Schema *Schema
Context []string
}
type FunctionInfo struct {
name string
min int
max int
Name string
Min int
Max int
}
func (s *Node) checkSingleExpression(exprNode actionlint.ExprNode) error {
func (s *Node) checkSingleExpression(exprNode exprparser.Node) error {
if len(s.Context) == 0 {
switch exprNode.Token().Kind {
case actionlint.TokenKindInt:
case actionlint.TokenKindFloat:
case actionlint.TokenKindString:
switch exprNode.(type) {
case *exprparser.ValueNode:
return nil
default:
return fmt.Errorf("expressions are not allowed here")
@@ -137,42 +217,44 @@ func (s *Node) checkSingleExpression(exprNode actionlint.ExprNode) error {
funcs := s.GetFunctions()
var err error
actionlint.VisitExprNode(exprNode, func(node, _ actionlint.ExprNode, entering bool) {
if funcCallNode, ok := node.(*actionlint.FuncCallNode); entering && ok {
for _, v := range *funcs {
if strings.EqualFold(funcCallNode.Callee, v.name) {
if v.min > len(funcCallNode.Args) {
err = errors.Join(err, fmt.Errorf("missing parameters for %s expected >= %v got %v", funcCallNode.Callee, v.min, len(funcCallNode.Args)))
exprparser.VisitNode(exprNode, func(node exprparser.Node) {
if funcCallNode, ok := node.(*exprparser.FunctionNode); ok {
for _, v := range funcs {
if strings.EqualFold(funcCallNode.Name, v.Name) {
if v.Min > len(funcCallNode.Args) {
err = errors.Join(err, fmt.Errorf("missing parameters for %s expected >= %v got %v", funcCallNode.Name, v.Min, len(funcCallNode.Args)))
}
if v.max < len(funcCallNode.Args) {
err = errors.Join(err, fmt.Errorf("too many parameters for %s expected <= %v got %v", funcCallNode.Callee, v.max, len(funcCallNode.Args)))
if v.Max < len(funcCallNode.Args) {
err = errors.Join(err, fmt.Errorf("too many parameters for %s expected <= %v got %v", funcCallNode.Name, v.Max, len(funcCallNode.Args)))
}
return
}
}
err = errors.Join(err, fmt.Errorf("unknown Function Call %s", funcCallNode.Callee))
err = errors.Join(err, fmt.Errorf("unknown Function Call %s", funcCallNode.Name))
}
if varNode, ok := node.(*actionlint.VariableNode); entering && ok {
for _, v := range s.Context {
if strings.EqualFold(varNode.Name, v) {
return
if varNode, ok := node.(*exprparser.ValueNode); ok && varNode.Kind == exprparser.TokenKindNamedValue {
if str, ok := varNode.Value.(string); ok {
for _, v := range s.Context {
if strings.EqualFold(str, v) {
return
}
}
}
err = errors.Join(err, fmt.Errorf("unknown Variable Access %s", varNode.Name))
err = errors.Join(err, fmt.Errorf("unknown Variable Access %v", varNode.Value))
}
})
return err
}
func (s *Node) GetFunctions() *[]FunctionInfo {
funcs := &[]FunctionInfo{}
AddFunction(funcs, "contains", 2, 2)
AddFunction(funcs, "endsWith", 2, 2)
AddFunction(funcs, "format", 1, 255)
AddFunction(funcs, "join", 1, 2)
AddFunction(funcs, "startsWith", 2, 2)
AddFunction(funcs, "toJson", 1, 1)
AddFunction(funcs, "fromJson", 1, 1)
func (s *Node) GetFunctions() []FunctionInfo {
funcs := []FunctionInfo{}
AddFunction(&funcs, "contains", 2, 2)
AddFunction(&funcs, "endsWith", 2, 2)
AddFunction(&funcs, "format", 1, 255)
AddFunction(&funcs, "join", 1, 2)
AddFunction(&funcs, "startsWith", 2, 2)
AddFunction(&funcs, "toJson", 1, 1)
AddFunction(&funcs, "fromJson", 1, 1)
for _, v := range s.Context {
i := strings.Index(v, "(")
if i == -1 {
@@ -189,17 +271,32 @@ func (s *Node) GetFunctions() *[]FunctionInfo {
} else {
maxParameters, _ = strconv.ParseInt(maxParametersRaw, 10, 32)
}
*funcs = append(*funcs, FunctionInfo{
name: functionName,
min: int(minParameters),
max: int(maxParameters),
funcs = append(funcs, FunctionInfo{
Name: functionName,
Min: int(minParameters),
Max: int(maxParameters),
})
}
}
return funcs
}
func exprEnd(expr string) int {
var inQuotes bool
for i, v := range expr {
if v == '\'' {
inQuotes = !inQuotes
} else if !inQuotes && i+1 < len(expr) && expr[i:i+2] == "}}" {
return i
}
}
return -1
}
func (s *Node) checkExpression(node *yaml.Node) (bool, error) {
if s.RestrictEval {
return false, nil
}
val := node.Value
hadExpr := false
var err error
@@ -211,26 +308,32 @@ func (s *Node) checkExpression(node *yaml.Node) (bool, error) {
}
hadExpr = true
parser := actionlint.NewExprParser()
lexer := actionlint.NewExprLexer(val)
exprNode, parseErr := parser.Parse(lexer)
j := exprEnd(val)
exprNode, parseErr := exprparser.Parse(val[:j])
if parseErr != nil {
err = errors.Join(err, fmt.Errorf("%sFailed to parse: %s", formatLocation(node), parseErr.Message))
err = errors.Join(err, ValidationError{
Location: toLocation(node),
Message: fmt.Sprintf("failed to parse: %s", parseErr.Error()),
})
continue
}
val = val[lexer.Offset():]
val = val[j+2:]
cerr := s.checkSingleExpression(exprNode)
if cerr != nil {
err = errors.Join(err, fmt.Errorf("%s%w", formatLocation(node), cerr))
err = errors.Join(err, ValidationError{
Location: toLocation(node),
Message: cerr.Error(),
})
}
}
}
func AddFunction(funcs *[]FunctionInfo, s string, i1, i2 int) {
*funcs = append(*funcs, FunctionInfo{
name: s,
min: i1,
max: i2,
Name: s,
Min: i1,
Max: i2,
})
}
@@ -238,9 +341,6 @@ func (s *Node) UnmarshalYAML(node *yaml.Node) error {
if node != nil && node.Kind == yaml.DocumentNode {
return s.UnmarshalYAML(node.Content[0])
}
if node.Kind == yaml.AliasNode {
node = node.Alias
}
def := s.Schema.GetDefinition(s.Definition)
if s.Context == nil {
s.Context = def.Context
@@ -261,8 +361,8 @@ func (s *Node) UnmarshalYAML(node *yaml.Node) error {
return s.checkOneOf(def, node)
}
if node.Kind != yaml.ScalarNode {
return fmt.Errorf("%sExpected a scalar got %v", formatLocation(node), getStringKind(node.Kind))
if err := assertKind(node, yaml.ScalarNode); err != nil {
return err
}
if def.String != nil {
@@ -280,50 +380,99 @@ func (s *Node) UnmarshalYAML(node *yaml.Node) error {
return nil
}
}
return fmt.Errorf("%sExpected one of %s got %s", formatLocation(node), strings.Join(*def.AllowedValues, ","), s)
return ValidationError{
Location: toLocation(node),
Message: fmt.Sprintf("expected one of %s got %s", strings.Join(*def.AllowedValues, ","), s),
}
} else if def.Null != nil {
var myNull *byte
return node.Decode(&myNull)
if err := node.Decode(&myNull); err != nil {
return err
}
if myNull != nil {
return ValidationError{
Location: toLocation(node),
Message: "invalid Null",
}
}
return nil
}
return errors.ErrUnsupported
}
func (s *Node) checkString(node *yaml.Node, def Definition) error {
// caller checks node type
val := node.Value
if def.String.Constant != "" && def.String.Constant != val {
return fmt.Errorf("%sExpected %s got %s", formatLocation(node), def.String.Constant, val)
return ValidationError{
Location: toLocation(node),
Message: fmt.Sprintf("expected %s got %s", def.String.Constant, val),
}
}
if def.String.IsExpression {
parser := actionlint.NewExprParser()
lexer := actionlint.NewExprLexer(val + "}}")
exprNode, parseErr := parser.Parse(lexer)
if def.String.IsExpression && !s.RestrictEval {
exprNode, parseErr := exprparser.Parse(node.Value)
if parseErr != nil {
return fmt.Errorf("%sFailed to parse: %s", formatLocation(node), parseErr.Message)
return ValidationError{
Location: toLocation(node),
Message: fmt.Sprintf("failed to parse: %s", parseErr.Error()),
}
}
cerr := s.checkSingleExpression(exprNode)
if cerr != nil {
return fmt.Errorf("%s%w", formatLocation(node), cerr)
return ValidationError{
Location: toLocation(node),
Message: cerr.Error(),
}
}
}
return nil
}
func (s *Node) checkOneOf(def Definition, node *yaml.Node) error {
var allErrors error
var invalidProps = math.MaxInt
var bestMatches ValidationErrorCollection
for _, v := range *def.OneOf {
sub := &Node{
Definition: v,
Schema: s.Schema,
Context: append(append([]string{}, s.Context...), s.Schema.GetDefinition(v).Context...),
}
// Use helper to create child node
sub := s.childNode(v)
err := sub.UnmarshalYAML(node)
if err == nil {
return nil
}
allErrors = errors.Join(allErrors, fmt.Errorf("%sFailed to match %s: %w", formatLocation(node), v, err))
if col := AsValidationErrorCollection(err); col != nil {
var matched int
for _, e := range col.Errors {
if e.Kind == ValidationKindInvalidProperty {
matched++
}
if e.Kind == ValidationKindMismatched {
if math.MaxInt == invalidProps {
bestMatches.Collections = append(bestMatches.Collections, *col)
continue
}
}
}
if matched == 0 {
matched = math.MaxInt
}
if matched <= invalidProps {
if matched < invalidProps {
// clear, we have better matching ones
bestMatches.Collections = nil
}
bestMatches.Collections = append(bestMatches.Collections, *col)
invalidProps = matched
}
continue
}
bestMatches.Errors = append(bestMatches.Errors, ValidationError{
Location: toLocation(node),
Message: fmt.Sprintf("failed to match %s: %s", v, err.Error()),
})
}
return allErrors
if len(bestMatches.Errors) > 0 || len(bestMatches.Collections) > 0 {
return bestMatches
}
return nil
}
func getStringKind(k yaml.Kind) string {
@@ -344,65 +493,216 @@ func getStringKind(k yaml.Kind) string {
}
func (s *Node) checkSequence(node *yaml.Node, def Definition) error {
if node.Kind != yaml.SequenceNode {
return fmt.Errorf("%sExpected a sequence got %v", formatLocation(node), getStringKind(node.Kind))
if err := assertKind(node, yaml.SequenceNode); err != nil {
return err
}
var allErrors error
for _, v := range node.Content {
allErrors = errors.Join(allErrors, (&Node{
Definition: def.Sequence.ItemType,
Schema: s.Schema,
Context: append(append([]string{}, s.Context...), s.Schema.GetDefinition(def.Sequence.ItemType).Context...),
}).UnmarshalYAML(v))
// Use helper to create child node
child := s.childNode(def.Sequence.ItemType)
allErrors = errors.Join(allErrors, child.UnmarshalYAML(v))
}
return allErrors
}
func formatLocation(node *yaml.Node) string {
return fmt.Sprintf("Line: %v Column %v: ", node.Line, node.Column)
func toLocation(node *yaml.Node) Location {
return Location{Line: node.Line, Column: node.Column}
}
func assertKind(node *yaml.Node, kind yaml.Kind) error {
if node.Kind != kind {
return ValidationError{
Location: toLocation(node),
Kind: ValidationKindMismatched,
Message: fmt.Sprintf("expected a %s got %s", getStringKind(kind), getStringKind(node.Kind)),
}
}
return nil
}
func (s *Node) GetNestedNode(path ...string) *Node {
if len(path) == 0 {
return s
}
def := s.Schema.GetDefinition(s.Definition)
if def.Mapping != nil {
prop, ok := def.Mapping.Properties[path[0]]
if !ok {
if def.Mapping.LooseValueType == "" {
return nil
}
return s.childNode(def.Mapping.LooseValueType).GetNestedNode(path[1:]...)
}
return s.childNode(prop.Type).GetNestedNode(path[1:]...)
}
if def.Sequence != nil {
// OneOf Branching
if path[0] != "*" {
return nil
}
return s.childNode(def.Sequence.ItemType).GetNestedNode(path[1:]...)
}
if def.OneOf != nil {
for _, one := range *def.OneOf {
opt := s.childNode(one).GetNestedNode(path...)
if opt != nil {
return opt
}
}
return nil
}
return nil
}
func (s *Node) checkMapping(node *yaml.Node, def Definition) error {
if node.Kind != yaml.MappingNode {
return fmt.Errorf("%sExpected a mapping got %v", formatLocation(node), getStringKind(node.Kind))
if err := assertKind(node, yaml.MappingNode); err != nil {
return err
}
insertDirective := regexp.MustCompile(`\${{\s*insert\s*}}`)
var allErrors error
var allErrors ValidationErrorCollection
var hasKeyExpr bool
usedProperties := map[string]string{}
for i, k := range node.Content {
if i%2 == 0 {
if insertDirective.MatchString(k.Value) {
if len(s.Context) == 0 {
allErrors = errors.Join(allErrors, fmt.Errorf("%sinsert is not allowed here", formatLocation(k)))
allErrors.AddError(ValidationError{
Location: toLocation(node),
Message: "insert is not allowed here",
})
}
hasKeyExpr = true
continue
}
isExpr, err := s.checkExpression(k)
if err != nil {
allErrors = errors.Join(allErrors, err)
allErrors.AddError(ValidationError{
Location: toLocation(node),
Message: err.Error(),
})
hasKeyExpr = true
continue
}
if isExpr {
hasKeyExpr = true
continue
}
if org, ok := usedProperties[strings.ToLower(k.Value)]; !ok {
// duplicate check case insensitive
usedProperties[strings.ToLower(k.Value)] = k.Value
// schema check case sensitive
usedProperties[k.Value] = k.Value
} else {
allErrors.AddError(ValidationError{
// Kind: ValidationKindInvalidProperty,
Location: toLocation(node),
Message: fmt.Sprintf("duplicate property %v of %v", k.Value, org),
})
}
vdef, ok := def.Mapping.Properties[k.Value]
if !ok {
if def.Mapping.LooseValueType == "" {
allErrors = errors.Join(allErrors, fmt.Errorf("%sUnknown Property %v", formatLocation(k), k.Value))
allErrors.AddError(ValidationError{
Kind: ValidationKindInvalidProperty,
Location: toLocation(node),
Message: fmt.Sprintf("unknown property %v", k.Value),
})
continue
}
vdef = MappingProperty{Type: def.Mapping.LooseValueType}
}
if err := (&Node{
Definition: vdef.Type,
Schema: s.Schema,
Context: append(append([]string{}, s.Context...), s.Schema.GetDefinition(vdef.Type).Context...),
}).UnmarshalYAML(node.Content[i+1]); err != nil {
allErrors = errors.Join(allErrors, err)
// Use helper to create child node
child := s.childNode(vdef.Type)
if err := child.UnmarshalYAML(node.Content[i+1]); err != nil {
if col := AsValidationErrorCollection(err); col != nil {
allErrors.AddError(ValidationError{
Location: toLocation(node.Content[i+1]),
Message: fmt.Sprintf("error found in value of key %s", k.Value),
})
allErrors.Collections = append(allErrors.Collections, *col)
continue
}
allErrors.AddError(ValidationError{
Location: toLocation(node),
Message: err.Error(),
})
continue
}
}
}
if !hasKeyExpr {
for k, v := range def.Mapping.Properties {
if _, ok := usedProperties[k]; !ok && v.Required {
allErrors.AddError(ValidationError{
Location: toLocation(node),
Kind: ValidationKindMissingProperty,
Message: fmt.Sprintf("missing property %s", k),
})
}
}
}
if len(allErrors.Errors) == 0 && len(allErrors.Collections) == 0 {
return nil
}
return allErrors
}
func (s *Node) childNode(defName string) *Node {
return &Node{
RestrictEval: s.RestrictEval,
Definition: defName,
Schema: s.Schema,
Context: append(append([]string{}, s.Context...), s.Schema.GetDefinition(defName).Context...),
}
}
func (s *Node) GetVariables() []string {
// Return only variable names (exclude function signatures)
vars := []string{}
for _, v := range s.Context {
if !strings.Contains(v, "(") {
vars = append(vars, v)
}
}
return vars
}
// ValidateExpression checks whether all variables and functions used in the expressions
// inside the provided yaml.Node are present in the allowed sets. It returns false
// if any variable or function is missing.
func (s *Node) ValidateExpression(node *yaml.Node, allowedVars map[string]struct{}, allowedFuncs map[string]struct{}) bool {
val := node.Value
for {
i := strings.Index(val, "${{")
if i == -1 {
break
}
val = val[i+3:]
j := exprEnd(val)
exprNode, parseErr := exprparser.Parse(val[:j])
if parseErr != nil {
return false
}
val = val[j+2:]
// walk expression tree
exprparser.VisitNode(exprNode, func(n exprparser.Node) {
switch el := n.(type) {
case *exprparser.FunctionNode:
if _, ok := allowedFuncs[el.Name]; !ok {
// missing function
// use a panic to break out
panic("missing function")
}
case *exprparser.ValueNode:
if el.Kind == exprparser.TokenKindNamedValue {
if _, ok := allowedVars[el.Value.(string)]; !ok {
panic("missing variable")
}
}
}
})
}
return true
}

View File

@@ -91,22 +91,53 @@ jobs:
assert.NoError(t, err)
}
func TestYAMLAnchors(t *testing.T) {
func TestFailure(t *testing.T) {
var node yaml.Node
err := yaml.Unmarshal([]byte(`
on: push
jobs:
job-with-condition:
runs-on: &label
self-hosted
if: success() || success('joba', 'jobb') || failure() || failure('joba', 'jobb') || always() || cancelled()
steps: &steps
- run: exit 0
then:
runs-on: *label
runs-on: self-hosted
x: failure
`), &node)
if !assert.NoError(t, err) {
return
}
err = (&Node{
Definition: "workflow-root-strict",
Schema: GetWorkflowSchema(),
}).UnmarshalYAML(&node)
assert.Error(t, err)
}
func TestFailure2(t *testing.T) {
var node yaml.Node
err := yaml.Unmarshal([]byte(`
on: push
jobs:
job-with-condition:
runs-on: self-hosted
Runs-on: failure
`), &node)
if !assert.NoError(t, err) {
return
}
err = (&Node{
Definition: "workflow-root-strict",
Schema: GetWorkflowSchema(),
}).UnmarshalYAML(&node)
assert.Error(t, err)
}
func TestEscape(t *testing.T) {
var node yaml.Node
err := yaml.Unmarshal([]byte(`
${{ 'on' }}: push
jobs:
job-with-condition:
runs-on: self-hosted
steps:
- run: exit 0
`), &node)
if !assert.NoError(t, err) {
return