mirror of
https://gitea.com/gitea/act_runner.git
synced 2026-08-07 01:14:22 +02:00
refactor: use the shared expression evaluation layer
Gitea carries a copy of this evaluation layer, as modules/actions/jobparser/evaluator.go says itself: "copied from runner.expressionEvaluator, to avoid unnecessary dependencies". It now lives in gitea.dev/actionslib/pkg/expreval, so both sides split sub-expressions, interpolate, keep the type of a lone expression and apply the default status check the same way. expressionEvaluator keeps the logging and the mask of every single evaluation, which is what the shared Evaluator takes a function for. Signed-off-by: Lunny Xiao <xiaolunwen@gmail.com>
This commit is contained in:
@@ -7,7 +7,6 @@ package runner
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"maps"
|
"maps"
|
||||||
"path"
|
"path"
|
||||||
@@ -21,9 +20,9 @@ import (
|
|||||||
|
|
||||||
_ "embed"
|
_ "embed"
|
||||||
|
|
||||||
|
"gitea.dev/actionslib/pkg/expreval"
|
||||||
"gitea.dev/actionslib/pkg/exprparser"
|
"gitea.dev/actionslib/pkg/exprparser"
|
||||||
"gitea.dev/actionslib/pkg/model"
|
"gitea.dev/actionslib/pkg/model"
|
||||||
"github.com/rhysd/actionlint"
|
|
||||||
"go.yaml.in/yaml/v4"
|
"go.yaml.in/yaml/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -235,137 +234,16 @@ func (ee expressionEvaluator) evaluate(ctx context.Context, in string, defaultSt
|
|||||||
return evaluated, err
|
return evaluated, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ee expressionEvaluator) evaluateScalarYamlNode(ctx context.Context, node *yaml.Node) (*yaml.Node, error) {
|
// shared returns the evaluation layer of the shared library, bound to this context so the
|
||||||
var in string
|
// evaluation of every single expression is still logged and masked here.
|
||||||
if err := node.Decode(&in); err != nil {
|
func (ee expressionEvaluator) shared(ctx context.Context) expreval.Evaluator {
|
||||||
return nil, err
|
return expreval.New(func(in string, defaultStatusCheck exprparser.DefaultStatusCheck) (any, error) {
|
||||||
}
|
return ee.evaluate(ctx, in, defaultStatusCheck)
|
||||||
if !strings.Contains(in, "${{") || !strings.Contains(in, "}}") {
|
})
|
||||||
return nil, nil //nolint:nilnil // pre-existing issue from nektos/act
|
|
||||||
}
|
|
||||||
res, err := ee.evaluateScalar(ctx, in)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
ret := &yaml.Node{}
|
|
||||||
if err := ret.Encode(res); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return ret, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ee expressionEvaluator) evaluateMappingYamlNode(ctx context.Context, node *yaml.Node) (*yaml.Node, error) {
|
|
||||||
var ret *yaml.Node
|
|
||||||
// GitHub has this undocumented feature to merge maps, called insert directive
|
|
||||||
insertDirective := regexp.MustCompile(`\${{\s*insert\s*}}`)
|
|
||||||
for i := 0; i < len(node.Content)/2; i++ {
|
|
||||||
changed := func() error {
|
|
||||||
if ret == nil {
|
|
||||||
ret = &yaml.Node{}
|
|
||||||
if err := ret.Encode(node); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
ret.Content = ret.Content[:i*2]
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
k := node.Content[i*2]
|
|
||||||
v := node.Content[i*2+1]
|
|
||||||
ev, err := ee.evaluateYamlNodeInternal(ctx, v)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if ev != nil {
|
|
||||||
if err := changed(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
ev = v
|
|
||||||
}
|
|
||||||
var sk string
|
|
||||||
// Merge the nested map of the insert directive
|
|
||||||
if k.Decode(&sk) == nil && insertDirective.MatchString(sk) {
|
|
||||||
if ev.Kind != yaml.MappingNode {
|
|
||||||
return nil, fmt.Errorf("failed to insert node %v into mapping %v unexpected type %v expected MappingNode", ev, node, ev.Kind)
|
|
||||||
}
|
|
||||||
if err := changed(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
ret.Content = append(ret.Content, ev.Content...)
|
|
||||||
} else {
|
|
||||||
ek, err := ee.evaluateYamlNodeInternal(ctx, k)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if ek != nil {
|
|
||||||
if err := changed(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
ek = k
|
|
||||||
}
|
|
||||||
if ret != nil {
|
|
||||||
ret.Content = append(ret.Content, ek, ev)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ret, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ee expressionEvaluator) evaluateSequenceYamlNode(ctx context.Context, node *yaml.Node) (*yaml.Node, error) {
|
|
||||||
var ret *yaml.Node
|
|
||||||
for i := 0; i < len(node.Content); i++ {
|
|
||||||
v := node.Content[i]
|
|
||||||
// Preserve nested sequences
|
|
||||||
wasseq := v.Kind == yaml.SequenceNode
|
|
||||||
ev, err := ee.evaluateYamlNodeInternal(ctx, v)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if ev != nil {
|
|
||||||
if ret == nil {
|
|
||||||
ret = &yaml.Node{}
|
|
||||||
if err := ret.Encode(node); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
ret.Content = ret.Content[:i]
|
|
||||||
}
|
|
||||||
// GitHub has this undocumented feature to merge sequences / arrays
|
|
||||||
// We have a nested sequence via evaluation, merge the arrays
|
|
||||||
if ev.Kind == yaml.SequenceNode && !wasseq {
|
|
||||||
ret.Content = append(ret.Content, ev.Content...)
|
|
||||||
} else {
|
|
||||||
ret.Content = append(ret.Content, ev)
|
|
||||||
}
|
|
||||||
} else if ret != nil {
|
|
||||||
ret.Content = append(ret.Content, v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ret, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ee expressionEvaluator) evaluateYamlNodeInternal(ctx context.Context, node *yaml.Node) (*yaml.Node, error) {
|
|
||||||
switch node.Kind {
|
|
||||||
case yaml.ScalarNode:
|
|
||||||
return ee.evaluateScalarYamlNode(ctx, node)
|
|
||||||
case yaml.MappingNode:
|
|
||||||
return ee.evaluateMappingYamlNode(ctx, node)
|
|
||||||
case yaml.SequenceNode:
|
|
||||||
return ee.evaluateSequenceYamlNode(ctx, node)
|
|
||||||
default:
|
|
||||||
return nil, nil //nolint:nilnil // pre-existing issue from nektos/act
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ee expressionEvaluator) EvaluateYamlNode(ctx context.Context, node *yaml.Node) error {
|
func (ee expressionEvaluator) EvaluateYamlNode(ctx context.Context, node *yaml.Node) error {
|
||||||
ret, err := ee.evaluateYamlNodeInternal(ctx, node)
|
return ee.shared(ctx).EvaluateYamlNode(node)
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if ret != nil {
|
|
||||||
return ret.Decode(node)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ee expressionEvaluator) Interpolate(ctx context.Context, in string) string {
|
func (ee expressionEvaluator) Interpolate(ctx context.Context, in string) string {
|
||||||
@@ -377,138 +255,16 @@ func (ee expressionEvaluator) Interpolate(ctx context.Context, in string) string
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// interpolate evaluates every part on its own, so a malformed one cannot restructure its neighbours
|
|
||||||
func (ee expressionEvaluator) interpolate(ctx context.Context, in string) (string, error) {
|
func (ee expressionEvaluator) interpolate(ctx context.Context, in string) (string, error) {
|
||||||
parts, err := splitSubExpressions(in)
|
return ee.shared(ctx).Interpolate(in)
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
if len(parts) == 1 && !parts[0].isExpr {
|
|
||||||
return in, nil
|
|
||||||
}
|
|
||||||
var out strings.Builder
|
|
||||||
out.Grow(len(in))
|
|
||||||
for _, part := range parts {
|
|
||||||
if !part.isExpr {
|
|
||||||
out.WriteString(part.text)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
evaluated, err := ee.evaluate(ctx, part.text, exprparser.DefaultStatusCheckNone)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
out.WriteString(exprparser.CoerceToString(evaluated))
|
|
||||||
}
|
|
||||||
return out.String(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// evaluateScalar keeps the type of a lone expression, so `${{ fromJSON('[1,2]') }}` stays an array
|
|
||||||
func (ee expressionEvaluator) evaluateScalar(ctx context.Context, in string) (any, error) {
|
|
||||||
parts, err := splitSubExpressions(in)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if len(parts) == 1 && parts[0].isExpr {
|
|
||||||
return ee.evaluate(ctx, parts[0].text, exprparser.DefaultStatusCheckNone)
|
|
||||||
}
|
|
||||||
return ee.interpolate(ctx, in)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// EvalBool evaluates an expression against given evaluator. An `if:` is an expression even without
|
// EvalBool evaluates an expression against given evaluator. An `if:` is an expression even without
|
||||||
// `${{ }}`, while literal text around one makes the whole value a string.
|
// `${{ }}`, while literal text around one makes the whole value a string.
|
||||||
func EvalBool(ctx context.Context, evaluator ExpressionEvaluator, expr string, defaultStatusCheck exprparser.DefaultStatusCheck) (bool, error) {
|
func EvalBool(ctx context.Context, evaluator ExpressionEvaluator, expr string, defaultStatusCheck exprparser.DefaultStatusCheck) (bool, error) {
|
||||||
parts, err := splitSubExpressions(expr)
|
return expreval.New(func(in string, dsc exprparser.DefaultStatusCheck) (any, error) {
|
||||||
if err != nil {
|
return evaluator.evaluate(ctx, in, dsc)
|
||||||
return false, err
|
}).EvalBool(expr, defaultStatusCheck)
|
||||||
}
|
|
||||||
if len(parts) == 1 {
|
|
||||||
evaluated, err := evaluator.evaluate(ctx, parts[0].text, defaultStatusCheck)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
return exprparser.IsTruthy(evaluated), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// mixed content is a string, so the status check applies to it separately
|
|
||||||
if defaultStatusCheck != exprparser.DefaultStatusCheckNone && !callsStatusFunction(parts) {
|
|
||||||
status, err := evaluator.evaluate(ctx, "", defaultStatusCheck)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
if !exprparser.IsTruthy(status) {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
interpolated, err := evaluator.interpolate(ctx, expr)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
return exprparser.IsTruthy(interpolated), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// callsStatusFunction reports whether any part calls a status function. A part that does not parse
|
|
||||||
// counts as one, so the evaluation reports it against the real values.
|
|
||||||
func callsStatusFunction(parts []exprPart) bool {
|
|
||||||
for _, part := range parts {
|
|
||||||
if !part.isExpr {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// The lexer needs the closing `}}` that the scanner strips.
|
|
||||||
exprNode, err := actionlint.NewExprParser().Parse(actionlint.NewExprLexer(part.text + "}}"))
|
|
||||||
if err != nil || exprparser.CallsStatusFunction(exprNode) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
type exprPart struct {
|
|
||||||
text string
|
|
||||||
isExpr bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// splitSubExpressions splits in the way GitHub's template reader does, leaving a value without a
|
|
||||||
// complete expression literal.
|
|
||||||
func splitSubExpressions(in string) ([]exprPart, error) {
|
|
||||||
if !strings.Contains(in, "${{") || !strings.Contains(in, "}}") {
|
|
||||||
return []exprPart{{text: in}}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
parts := make([]exprPart, 0, 2*strings.Count(in, "${{")+1)
|
|
||||||
for {
|
|
||||||
start := strings.Index(in, "${{")
|
|
||||||
if start < 0 {
|
|
||||||
if in != "" {
|
|
||||||
parts = append(parts, exprPart{text: in})
|
|
||||||
}
|
|
||||||
return parts, nil
|
|
||||||
}
|
|
||||||
if start > 0 {
|
|
||||||
parts = append(parts, exprPart{text: in[:start]})
|
|
||||||
}
|
|
||||||
rest := in[start+len("${{"):]
|
|
||||||
end := indexExprEnd(rest)
|
|
||||||
if end < 0 {
|
|
||||||
return nil, errors.New("unclosed expression")
|
|
||||||
}
|
|
||||||
parts = append(parts, exprPart{text: strings.TrimSpace(rest[:end]), isExpr: true})
|
|
||||||
in = rest[end+len("}}"):]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// indexExprEnd returns the offset of the `}}` ending an expression, or -1. A quote toggles string
|
|
||||||
// state, so a `}}` inside a string does not end it.
|
|
||||||
func indexExprEnd(in string) int {
|
|
||||||
inString := false
|
|
||||||
for i := range len(in) {
|
|
||||||
switch {
|
|
||||||
case in[i] == '\'':
|
|
||||||
inString = !inString
|
|
||||||
case !inString && in[i] == '}' && i+1 < len(in) && in[i+1] == '}':
|
|
||||||
return i
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return -1
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func getEvaluatorInputs(ctx context.Context, rc *RunContext, step step, ghc *model.GithubContext) map[string]any {
|
func getEvaluatorInputs(ctx context.Context, rc *RunContext, step step, ghc *model.GithubContext) map[string]any {
|
||||||
|
|||||||
@@ -278,41 +278,6 @@ func TestInterpolate(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSplitSubExpressions(t *testing.T) {
|
|
||||||
expr := func(text string) exprPart { return exprPart{text: text, isExpr: true} }
|
|
||||||
literal := func(text string) exprPart { return exprPart{text: text} }
|
|
||||||
|
|
||||||
for _, tt := range []struct {
|
|
||||||
in string
|
|
||||||
want []exprPart
|
|
||||||
}{
|
|
||||||
{"Hello World", []exprPart{literal("Hello World")}},
|
|
||||||
{"${{ true }}", []exprPart{expr("true")}},
|
|
||||||
{"${{ true }} ${{ false }}", []exprPart{expr("true"), literal(" "), expr("false")}},
|
|
||||||
{"Hello ${{ 'World' }}", []exprPart{literal("Hello "), expr("'World'")}},
|
|
||||||
// a quote toggles string state, so a `}}` inside a string does not end the expression
|
|
||||||
{"${{ '}}' }}", []exprPart{expr("'}}'")}},
|
|
||||||
{"${{ '''}}''' }}", []exprPart{expr("'''}}'''")}},
|
|
||||||
{"${{ '''' }}", []exprPart{expr("''''")}},
|
|
||||||
{`${{ fromJSON('"}}"') }}`, []exprPart{expr(`fromJSON('"}}"')`)}},
|
|
||||||
{`${{ fromJSON('"\"}}\""') }}`, []exprPart{expr(`fromJSON('"\"}}\""')`)}},
|
|
||||||
{`${{ fromJSON('"''}}"') }}`, []exprPart{expr(`fromJSON('"''}}"')`)}},
|
|
||||||
// without a complete literal the value stays text, as GitHub's template reader leaves it
|
|
||||||
{"${{ 1", []exprPart{literal("${{ 1")}},
|
|
||||||
// a malformed part stays one part, so it cannot restructure its neighbours
|
|
||||||
{"${{ 1) && (2 }}", []exprPart{expr("1) && (2")}},
|
|
||||||
} {
|
|
||||||
got, err := splitSubExpressions(tt.in)
|
|
||||||
require.NoError(t, err, tt.in)
|
|
||||||
assert.Equal(t, tt.want, got, tt.in)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, in := range []string{"${{ 'a' }} ${{ b", "${{ 'a }}"} {
|
|
||||||
_, err := splitSubExpressions(in)
|
|
||||||
assert.ErrorContains(t, err, "unclosed expression", in)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetEvaluatorInputsBoolean(t *testing.T) {
|
func TestGetEvaluatorInputsBoolean(t *testing.T) {
|
||||||
workflows := map[string]string{
|
workflows := map[string]string{
|
||||||
"workflow_call": `
|
"workflow_call": `
|
||||||
|
|||||||
Reference in New Issue
Block a user