// Copyright 2026 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT package jobparser import ( "errors" "fmt" "math" "reflect" "regexp" "strconv" "strings" "gitea.com/gitea/runner/act/exprparser" "go.yaml.in/yaml/v4" ) // ExpressionEvaluator is copied from runner.expressionEvaluator, // to avoid unnecessary dependencies type ExpressionEvaluator struct { interpreter exprparser.Interpreter } func NewExpressionEvaluator(interpreter exprparser.Interpreter) *ExpressionEvaluator { return &ExpressionEvaluator{interpreter: interpreter} } 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 } res, err := ee.evaluateScalar(in) if err != nil { return err } return node.Encode(res) } // GitHub has this undocumented feature to merge maps, called insert directive var insertDirective = regexp.MustCompile(`\${{\s*insert\s*}}`) func (ee ExpressionEvaluator) evaluateMappingYamlNode(node *yaml.Node) error { 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 } } // interpolate evaluates every part on its own, so a malformed one cannot restructure its neighbours func (ee ExpressionEvaluator) interpolate(in string) (string, error) { parts, err := splitSubExpressions(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.interpreter.Evaluate(part.text, exprparser.DefaultStatusCheckNone) if err != nil { return "", err } out.WriteString(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(in string) (any, error) { parts, err := splitSubExpressions(in) if err != nil { return nil, err } if len(parts) == 1 && parts[0].isExpr { return ee.interpreter.Evaluate(parts[0].text, exprparser.DefaultStatusCheckNone) } return ee.interpolate(in) } // evaluateCondition evaluates an `if:`, an expression even without `${{ }}`. Mixed content // interpolates to a string, so the success() default applies to it separately. func (ee ExpressionEvaluator) evaluateCondition(in string) (bool, error) { parts, err := splitSubExpressions(in) if err != nil { return false, err } if len(parts) == 1 { evaluated, err := ee.interpreter.Evaluate(parts[0].text, exprparser.DefaultStatusCheckSuccess) if err != nil { return false, err } return exprparser.IsTruthy(evaluated), nil } // mixed content is a string, so the success() default applies to it separately if !expressionCallsFunction(in, "success", "always", "failure", "cancelled") { status, err := ee.interpreter.Evaluate("success()", exprparser.DefaultStatusCheckNone) if err != nil { return false, err } if !exprparser.IsTruthy(status) { return false, nil } } interpolated, err := ee.interpolate(in) if err != nil { return false, err } return exprparser.IsTruthy(interpolated), nil } // coerceToString converts an evaluated expression value to a string the way GitHub does, // see https://docs.github.com/en/actions/reference/workflows-and-actions/expressions#operators // An already reflected value is accepted as-is, since Interface() would panic on an invalid one. func coerceToString(v any) string { value, ok := v.(reflect.Value) if !ok { value = reflect.ValueOf(v) } switch value.Kind() { case reflect.Invalid: return "" case reflect.Bool: return strconv.FormatBool(value.Bool()) case reflect.String: return value.String() case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: return strconv.FormatInt(value.Int(), 10) case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: return strconv.FormatUint(value.Uint(), 10) case reflect.Float32, reflect.Float64: if math.IsInf(value.Float(), 1) { return "Infinity" } else if math.IsInf(value.Float(), -1) { return "-Infinity" } return fmt.Sprintf("%.15G", value.Float()) case reflect.Slice, reflect.Array: return "Array" // contexts such as `github` are pointers to structs, so they stringify as objects too case reflect.Map, reflect.Struct: return "Object" case reflect.Interface, reflect.Pointer: if value.IsNil() { return "" } return coerceToString(value.Elem()) } return fmt.Sprintf("%v", value) } 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 }