c24cfc72f4
* refactor: remove debug error output Errors should always be logged with an error level and not debug level. Since the error is returned here, it will be logged later as an error. Presumably this was a leftover from debugging the executor chain in: PR: #971 * refactor: debug log wich expression is going to be evaluated * fix: handle nil in EvalBool We've seen this issue when the env map is not set-up properly, i.e. when the env map is nil, EvalBool might return nil, which should be handled as a falsy value. * fix: fail on error in if expression and return the evaluation error Stop running the workflow in case an expression cannot be evaluated. Fixes: #1008 * fix: remove quotes from inside expression syntax in test It looks like having an expression inside double quotes inside the expression syntax is not valid: https://github.com/ZauberNerd/act-test/actions/runs/1881986429 The workflow is not valid. .github/workflows/test.yml (Line: 10, Col: 13): Unexpected symbol: '"endsWith'. Located at position 1 within expression: "endsWith('Hello world', 'ld')" * refactor: export IsTruthy function from exprparser package * refactor: use IsTruthy function in EvalBool * refactor: move debug log for expression rewrite to rewrite function Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
310 lines
7.9 KiB
Go
310 lines
7.9 KiB
Go
package runner
|
|
|
|
import (
|
|
"fmt"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"github.com/nektos/act/pkg/exprparser"
|
|
log "github.com/sirupsen/logrus"
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
// ExpressionEvaluator is the interface for evaluating expressions
|
|
type ExpressionEvaluator interface {
|
|
evaluate(string, bool) (interface{}, error)
|
|
EvaluateYamlNode(node *yaml.Node) error
|
|
Interpolate(string) string
|
|
}
|
|
|
|
// NewExpressionEvaluator creates a new evaluator
|
|
func (rc *RunContext) NewExpressionEvaluator() ExpressionEvaluator {
|
|
// todo: cleanup EvaluationEnvironment creation
|
|
job := rc.Run.Job()
|
|
strategy := make(map[string]interface{})
|
|
if job.Strategy != nil {
|
|
strategy["fail-fast"] = job.Strategy.FailFast
|
|
strategy["max-parallel"] = job.Strategy.MaxParallel
|
|
}
|
|
|
|
jobs := rc.Run.Workflow.Jobs
|
|
jobNeeds := rc.Run.Job().Needs()
|
|
|
|
using := make(map[string]map[string]map[string]string)
|
|
for _, needs := range jobNeeds {
|
|
using[needs] = map[string]map[string]string{
|
|
"outputs": jobs[needs].Outputs,
|
|
}
|
|
}
|
|
|
|
secrets := rc.Config.Secrets
|
|
if rc.Composite != nil {
|
|
secrets = nil
|
|
}
|
|
|
|
ee := &exprparser.EvaluationEnvironment{
|
|
Github: rc.getGithubContext(),
|
|
Env: rc.GetEnv(),
|
|
Job: rc.getJobContext(),
|
|
// todo: should be unavailable
|
|
// but required to interpolate/evaluate the step outputs on the job
|
|
Steps: rc.getStepsContext(),
|
|
Runner: map[string]interface{}{
|
|
"os": "Linux",
|
|
"temp": "/tmp",
|
|
"tool_cache": "/opt/hostedtoolcache",
|
|
},
|
|
Secrets: secrets,
|
|
Strategy: strategy,
|
|
Matrix: rc.Matrix,
|
|
Needs: using,
|
|
Inputs: rc.Inputs,
|
|
}
|
|
return expressionEvaluator{
|
|
interpreter: exprparser.NewInterpeter(ee, exprparser.Config{
|
|
Run: rc.Run,
|
|
WorkingDir: rc.Config.Workdir,
|
|
Context: "job",
|
|
}),
|
|
}
|
|
}
|
|
|
|
// NewExpressionEvaluator creates a new evaluator
|
|
func (sc *StepContext) NewExpressionEvaluator() ExpressionEvaluator {
|
|
rc := sc.RunContext
|
|
// todo: cleanup EvaluationEnvironment creation
|
|
job := rc.Run.Job()
|
|
strategy := make(map[string]interface{})
|
|
if job.Strategy != nil {
|
|
strategy["fail-fast"] = job.Strategy.FailFast
|
|
strategy["max-parallel"] = job.Strategy.MaxParallel
|
|
}
|
|
|
|
jobs := rc.Run.Workflow.Jobs
|
|
jobNeeds := rc.Run.Job().Needs()
|
|
|
|
using := make(map[string]map[string]map[string]string)
|
|
for _, needs := range jobNeeds {
|
|
using[needs] = map[string]map[string]string{
|
|
"outputs": jobs[needs].Outputs,
|
|
}
|
|
}
|
|
|
|
secrets := rc.Config.Secrets
|
|
if rc.Composite != nil {
|
|
secrets = nil
|
|
}
|
|
|
|
ee := &exprparser.EvaluationEnvironment{
|
|
Github: rc.getGithubContext(),
|
|
Env: rc.GetEnv(),
|
|
Job: rc.getJobContext(),
|
|
Steps: rc.getStepsContext(),
|
|
Runner: map[string]interface{}{
|
|
"os": "Linux",
|
|
"temp": "/tmp",
|
|
"tool_cache": "/opt/hostedtoolcache",
|
|
},
|
|
Secrets: secrets,
|
|
Strategy: strategy,
|
|
Matrix: rc.Matrix,
|
|
Needs: using,
|
|
// todo: should be unavailable
|
|
// but required to interpolate/evaluate the inputs in actions/composite
|
|
Inputs: rc.Inputs,
|
|
}
|
|
return expressionEvaluator{
|
|
interpreter: exprparser.NewInterpeter(ee, exprparser.Config{
|
|
Run: rc.Run,
|
|
WorkingDir: rc.Config.Workdir,
|
|
Context: "step",
|
|
}),
|
|
}
|
|
}
|
|
|
|
type expressionEvaluator struct {
|
|
interpreter exprparser.Interpreter
|
|
}
|
|
|
|
func (ee expressionEvaluator) evaluate(in string, isIfExpression bool) (interface{}, error) {
|
|
log.Debugf("evaluating expression '%s'", in)
|
|
evaluated, err := ee.interpreter.Evaluate(in, isIfExpression)
|
|
log.Debugf("expression '%s' evaluated to '%t'", in, evaluated)
|
|
return evaluated, err
|
|
}
|
|
|
|
func (ee expressionEvaluator) evaluateScalarYamlNode(node *yaml.Node) error {
|
|
var in string
|
|
if err := node.Decode(&in); err != nil {
|
|
return err
|
|
}
|
|
if !strings.Contains(in, "${{") || !strings.Contains(in, "}}") {
|
|
return nil
|
|
}
|
|
expr, _ := rewriteSubExpression(in, false)
|
|
res, err := ee.evaluate(expr, false)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return node.Encode(res)
|
|
}
|
|
|
|
func (ee expressionEvaluator) evaluateMappingYamlNode(node *yaml.Node) error {
|
|
// 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; {
|
|
k := node.Content[i*2]
|
|
v := node.Content[i*2+1]
|
|
if err := ee.EvaluateYamlNode(v); err != nil {
|
|
return err
|
|
}
|
|
var sk string
|
|
// Merge the nested map of the insert directive
|
|
if k.Decode(&sk) == nil && insertDirective.MatchString(sk) {
|
|
node.Content = append(append(node.Content[:i*2], v.Content...), node.Content[(i+1)*2:]...)
|
|
i += len(v.Content) / 2
|
|
} else {
|
|
if err := ee.EvaluateYamlNode(k); err != nil {
|
|
return err
|
|
}
|
|
i++
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (ee expressionEvaluator) evaluateSequenceYamlNode(node *yaml.Node) error {
|
|
for i := 0; i < len(node.Content); {
|
|
v := node.Content[i]
|
|
// Preserve nested sequences
|
|
wasseq := v.Kind == yaml.SequenceNode
|
|
if err := ee.EvaluateYamlNode(v); err != nil {
|
|
return err
|
|
}
|
|
// GitHub has this undocumented feature to merge sequences / arrays
|
|
// We have a nested sequence via evaluation, merge the arrays
|
|
if v.Kind == yaml.SequenceNode && !wasseq {
|
|
node.Content = append(append(node.Content[:i], v.Content...), node.Content[i+1:]...)
|
|
i += len(v.Content)
|
|
} else {
|
|
i++
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (ee expressionEvaluator) EvaluateYamlNode(node *yaml.Node) error {
|
|
switch node.Kind {
|
|
case yaml.ScalarNode:
|
|
return ee.evaluateScalarYamlNode(node)
|
|
case yaml.MappingNode:
|
|
return ee.evaluateMappingYamlNode(node)
|
|
case yaml.SequenceNode:
|
|
return ee.evaluateSequenceYamlNode(node)
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func (ee expressionEvaluator) Interpolate(in string) string {
|
|
if !strings.Contains(in, "${{") || !strings.Contains(in, "}}") {
|
|
return in
|
|
}
|
|
|
|
expr, _ := rewriteSubExpression(in, true)
|
|
evaluated, err := ee.evaluate(expr, false)
|
|
if err != nil {
|
|
log.Errorf("Unable to interpolate expression '%s': %s", expr, err)
|
|
return ""
|
|
}
|
|
|
|
value, ok := evaluated.(string)
|
|
if !ok {
|
|
panic(fmt.Sprintf("Expression %s did not evaluate to a string", expr))
|
|
}
|
|
|
|
return value
|
|
}
|
|
|
|
// EvalBool evaluates an expression against given evaluator
|
|
func EvalBool(evaluator ExpressionEvaluator, expr string) (bool, error) {
|
|
nextExpr, _ := rewriteSubExpression(expr, false)
|
|
|
|
evaluated, err := evaluator.evaluate(nextExpr, true)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
return exprparser.IsTruthy(evaluated), nil
|
|
}
|
|
|
|
func escapeFormatString(in string) string {
|
|
return strings.ReplaceAll(strings.ReplaceAll(in, "{", "{{"), "}", "}}")
|
|
}
|
|
|
|
//nolint:gocyclo
|
|
func rewriteSubExpression(in string, forceFormat bool) (string, error) {
|
|
if !strings.Contains(in, "${{") || !strings.Contains(in, "}}") {
|
|
return in, nil
|
|
}
|
|
|
|
strPattern := regexp.MustCompile("(?:''|[^'])*'")
|
|
pos := 0
|
|
exprStart := -1
|
|
strStart := -1
|
|
var results []string
|
|
formatOut := ""
|
|
for pos < len(in) {
|
|
if strStart > -1 {
|
|
matches := strPattern.FindStringIndex(in[pos:])
|
|
if matches == nil {
|
|
panic("unclosed string.")
|
|
}
|
|
|
|
strStart = -1
|
|
pos += matches[1]
|
|
} else if exprStart > -1 {
|
|
exprEnd := strings.Index(in[pos:], "}}")
|
|
strStart = strings.Index(in[pos:], "'")
|
|
|
|
if exprEnd > -1 && strStart > -1 {
|
|
if exprEnd < strStart {
|
|
strStart = -1
|
|
} else {
|
|
exprEnd = -1
|
|
}
|
|
}
|
|
|
|
if exprEnd > -1 {
|
|
formatOut += fmt.Sprintf("{%d}", len(results))
|
|
results = append(results, strings.TrimSpace(in[exprStart:pos+exprEnd]))
|
|
pos += exprEnd + 2
|
|
exprStart = -1
|
|
} else if strStart > -1 {
|
|
pos += strStart + 1
|
|
} else {
|
|
panic("unclosed expression.")
|
|
}
|
|
} else {
|
|
exprStart = strings.Index(in[pos:], "${{")
|
|
if exprStart != -1 {
|
|
formatOut += escapeFormatString(in[pos : pos+exprStart])
|
|
exprStart = pos + exprStart + 3
|
|
pos = exprStart
|
|
} else {
|
|
formatOut += escapeFormatString(in[pos:])
|
|
pos = len(in)
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(results) == 1 && formatOut == "{0}" && !forceFormat {
|
|
return in, nil
|
|
}
|
|
|
|
out := fmt.Sprintf("format('%s', %s)", strings.ReplaceAll(formatOut, "'", "''"), strings.Join(results, ", "))
|
|
if in != out {
|
|
log.Debugf("expression '%s' rewritten to '%s'", in, out)
|
|
}
|
|
return out, nil
|
|
}
|