fix(actions): evaluate each ${{ }} part on its own (#38754) (#38797)

Backport of https://github.com/go-gitea/gitea/pull/38754

Every `${{ }}` part was spliced as raw text into a synthesized
`format('...', <raw>)` call and re-parsed, so unbalanced parentheses
restructured the whole expression:

```yaml
run-name: ${{ 1) && (2 }}          # panicked, aborting workflow parsing for the push
if: ${{ 1 }} ${{ 0) && (0 }}       # silently skipped the job
runs-on: ${{ nosuchcontext.x }}    # silently queued the job against the label ""
```

One scanner shaped like GitHub's template reader now splits every value
and each part is evaluated on its own, so nothing builds an expression
out of text. A part that fails is an error instead of an empty string.

`expressionCallsFunction` is self-contained here, since this branch has
no `expressionsMatch` to build it on. That makes
`github.com/rhysd/actionlint` a direct dependency, which it already is
on `main`.
This commit is contained in:
silverwind
2026-08-07 01:28:48 +02:00
committed by GitHub
parent 4e64b3a65d
commit 00a637295e
7 changed files with 254 additions and 129 deletions
+1 -1
View File
@@ -87,6 +87,7 @@ require (
github.com/prometheus/client_golang v1.23.2 github.com/prometheus/client_golang v1.23.2
github.com/quasoft/websspi v1.1.2 github.com/quasoft/websspi v1.1.2
github.com/redis/go-redis/v9 v9.21.0 github.com/redis/go-redis/v9 v9.21.0
github.com/rhysd/actionlint v1.7.12
github.com/robfig/cron/v3 v3.0.1 github.com/robfig/cron/v3 v3.0.1
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 github.com/santhosh-tekuri/jsonschema/v6 v6.0.2
github.com/sassoftware/go-rpmutils v0.4.0 github.com/sassoftware/go-rpmutils v0.4.0
@@ -244,7 +245,6 @@ require (
github.com/prometheus/common v0.68.1 // indirect github.com/prometheus/common v0.68.1 // indirect
github.com/prometheus/procfs v0.20.1 // indirect github.com/prometheus/procfs v0.20.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rhysd/actionlint v1.7.12 // indirect
github.com/rs/xid v1.6.0 // indirect github.com/rs/xid v1.6.0 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/shopspring/decimal v1.4.0 // indirect github.com/shopspring/decimal v1.4.0 // indirect
+161 -82
View File
@@ -6,7 +6,10 @@ package jobparser
import ( import (
"errors" "errors"
"fmt" "fmt"
"math"
"reflect"
"regexp" "regexp"
"strconv"
"strings" "strings"
"gitea.com/gitea/runner/act/exprparser" "gitea.com/gitea/runner/act/exprparser"
@@ -23,12 +26,6 @@ func NewExpressionEvaluator(interpreter exprparser.Interpreter) *ExpressionEvalu
return &ExpressionEvaluator{interpreter: interpreter} return &ExpressionEvaluator{interpreter: interpreter}
} }
func (ee ExpressionEvaluator) evaluate(in string, defaultStatusCheck exprparser.DefaultStatusCheck) (any, error) {
evaluated, err := ee.interpreter.Evaluate(in, defaultStatusCheck)
return evaluated, err
}
func (ee ExpressionEvaluator) evaluateScalarYamlNode(node *yaml.Node) error { func (ee ExpressionEvaluator) evaluateScalarYamlNode(node *yaml.Node) error {
var in string var in string
if err := node.Decode(&in); err != nil { if err := node.Decode(&in); err != nil {
@@ -37,17 +34,17 @@ func (ee ExpressionEvaluator) evaluateScalarYamlNode(node *yaml.Node) error {
if !strings.Contains(in, "${{") || !strings.Contains(in, "}}") { if !strings.Contains(in, "${{") || !strings.Contains(in, "}}") {
return nil return nil
} }
expr, _ := rewriteSubExpression(in, false) res, err := ee.evaluateScalar(in)
res, err := ee.evaluate(expr, exprparser.DefaultStatusCheckNone)
if err != nil { if err != nil {
return err return err
} }
return node.Encode(res) 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 { 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; { for i := 0; i < len(node.Content)/2; {
k := node.Content[i*2] k := node.Content[i*2]
v := node.Content[i*2+1] v := node.Content[i*2+1]
@@ -102,88 +99,170 @@ func (ee ExpressionEvaluator) EvaluateYamlNode(node *yaml.Node) error {
} }
} }
func (ee ExpressionEvaluator) Interpolate(in string) string { // interpolate evaluates every part on its own, so a malformed one cannot restructure its neighbours
if !strings.Contains(in, "${{") || !strings.Contains(in, "}}") { func (ee ExpressionEvaluator) interpolate(in string) (string, error) {
return in parts, err := splitSubExpressions(in)
}
expr, _ := rewriteSubExpression(in, true)
evaluated, err := ee.evaluate(expr, exprparser.DefaultStatusCheckNone)
if err != nil { if err != nil {
return "" return "", err
} }
if len(parts) == 1 && !parts[0].isExpr {
value, ok := evaluated.(string)
if !ok {
panic(fmt.Sprintf("Expression %s did not evaluate to a string", expr))
}
return value
}
func escapeFormatString(in string) string {
return strings.ReplaceAll(strings.ReplaceAll(in, "{", "{{"), "}", "}}")
}
func rewriteSubExpression(in string, forceFormat bool) (string, error) {
if !strings.Contains(in, "${{") || !strings.Contains(in, "}}") {
return in, nil 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
}
strPattern := regexp.MustCompile("(?:''|[^'])*'") // evaluateScalar keeps the type of a lone expression, so `${{ fromJSON('[1,2]') }}` stays an array
pos := 0 func (ee ExpressionEvaluator) evaluateScalar(in string) (any, error) {
exprStart := -1 parts, err := splitSubExpressions(in)
strStart := -1 if err != nil {
var results []string return nil, err
var formatOut strings.Builder }
for pos < len(in) { if len(parts) == 1 && parts[0].isExpr {
if strStart > -1 { return ee.interpreter.Evaluate(parts[0].text, exprparser.DefaultStatusCheckNone)
matches := strPattern.FindStringIndex(in[pos:]) }
if matches == nil { return ee.interpolate(in)
return "", errors.New("unclosed string") }
}
strStart = -1 // evaluateCondition evaluates an `if:`, an expression even without `${{ }}`. Mixed content
pos += matches[1] // interpolates to a string, so the success() default applies to it separately.
} else if exprStart > -1 { func (ee ExpressionEvaluator) evaluateCondition(in string) (bool, error) {
exprEnd := strings.Index(in[pos:], "}}") parts, err := splitSubExpressions(in)
strStart = strings.Index(in[pos:], "'") 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
}
if exprEnd > -1 && strStart > -1 { // mixed content is a string, so the success() default applies to it separately
if exprEnd < strStart { if !expressionCallsFunction(in, "success", "always", "failure", "cancelled") {
strStart = -1 status, err := ee.interpreter.Evaluate("success()", exprparser.DefaultStatusCheckNone)
} else { if err != nil {
exprEnd = -1 return false, err
} }
} if !exprparser.IsTruthy(status) {
return false, nil
if exprEnd > -1 {
fmt.Fprintf(&formatOut, "{%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.WriteString(escapeFormatString(in[pos : pos+exprStart]))
exprStart = pos + exprStart + 3
pos = exprStart
} else {
formatOut.WriteString(escapeFormatString(in[pos:]))
pos = len(in)
}
} }
} }
interpolated, err := ee.interpolate(in)
if err != nil {
return false, err
}
return exprparser.IsTruthy(interpolated), nil
}
if len(results) == 1 && formatOut.String() == "{0}" && !forceFormat { // coerceToString converts an evaluated expression value to a string the way GitHub does,
return in, nil // 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)
} }
out := fmt.Sprintf("format('%s', %s)", strings.ReplaceAll(formatOut.String(), "'", "''"), strings.Join(results, ", ")) switch value.Kind() {
return out, nil 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
} }
+44 -7
View File
@@ -6,11 +6,13 @@ package jobparser
import ( import (
"bytes" "bytes"
"fmt" "fmt"
"slices"
"sort" "sort"
"strings" "strings"
"gitea.com/gitea/runner/act/exprparser" "gitea.com/gitea/runner/act/exprparser"
"gitea.com/gitea/runner/act/model" "gitea.com/gitea/runner/act/model"
"github.com/rhysd/actionlint"
"go.yaml.in/yaml/v4" "go.yaml.in/yaml/v4"
) )
@@ -48,7 +50,9 @@ func Parse(content []byte, options ...ParseOption) ([]*SingleWorkflow, error) {
} }
evaluator := NewExpressionEvaluator(exprparser.NewInterpeter(&exprparser.EvaluationEnvironment{Github: pc.gitContext, Vars: pc.vars, Inputs: pc.inputs}, exprparser.Config{})) evaluator := NewExpressionEvaluator(exprparser.NewInterpeter(&exprparser.EvaluationEnvironment{Github: pc.gitContext, Vars: pc.vars, Inputs: pc.inputs}, exprparser.Config{}))
workflow.RunName = evaluator.Interpolate(workflow.RunName) if workflow.RunName, err = evaluator.interpolate(workflow.RunName); err != nil {
return nil, fmt.Errorf("interpolate run-name: %w", err)
}
for i, id := range ids { for i, id := range ids {
job := jobs[i] job := jobs[i]
@@ -63,10 +67,14 @@ func Parse(content []byte, options ...ParseOption) ([]*SingleWorkflow, error) {
} }
job.Strategy.RawMatrix = encodeMatrix(matrix) job.Strategy.RawMatrix = encodeMatrix(matrix)
evaluator := NewExpressionEvaluator(NewInterpeter(id, origin.GetJob(id), matrix, pc.gitContext, results, pc.vars, pc.inputs)) evaluator := NewExpressionEvaluator(NewInterpeter(id, origin.GetJob(id), matrix, pc.gitContext, results, pc.vars, pc.inputs))
job.Name = nameWithMatrix(job.Name, matrix, evaluator) if job.Name, err = nameWithMatrix(job.Name, matrix, evaluator); err != nil {
return nil, fmt.Errorf("interpolate name for job %q: %w", id, err)
}
runsOn := origin.GetJob(id).RunsOn() runsOn := origin.GetJob(id).RunsOn()
for i, v := range runsOn { for i, v := range runsOn {
runsOn[i] = evaluator.Interpolate(v) if runsOn[i], err = evaluator.interpolate(v); err != nil {
return nil, fmt.Errorf("interpolate runs-on for job %q: %w", id, err)
}
} }
job.RawRunsOn = encodeRunsOn(runsOn) job.RawRunsOn = encodeRunsOn(runsOn)
if err := evaluator.EvaluateYamlNode(&job.RawContinueOnError); err != nil { if err := evaluator.EvaluateYamlNode(&job.RawContinueOnError); err != nil {
@@ -150,16 +158,45 @@ func encodeRunsOn(runsOn []string) yaml.Node {
return node return node
} }
func nameWithMatrix(name string, m map[string]any, evaluator *ExpressionEvaluator) string { func nameWithMatrix(name string, m map[string]any, evaluator *ExpressionEvaluator) (string, error) {
if len(m) == 0 { if len(m) == 0 {
return name return name, nil
} }
if !strings.Contains(name, "${{") || !strings.Contains(name, "}}") { if !strings.Contains(name, "${{") || !strings.Contains(name, "}}") {
return name + " " + matrixName(m) return name + " " + matrixName(m), nil
} }
return evaluator.Interpolate(name) return evaluator.interpolate(name)
}
// expressionCallsFunction reports whether any ${{ }} expression in value calls one of the functions.
func expressionCallsFunction(value string, names ...string) bool {
parts, err := splitSubExpressions(value)
if err != nil {
return true // unparseable here, let the expansion report it against the real values
}
for _, part := range parts {
if !part.isExpr {
continue
}
// The lexer needs the closing `}}` that the scanner strips.
expr, err := actionlint.NewExprParser().Parse(actionlint.NewExprLexer(part.text + "}}"))
if err != nil {
return true // unparseable here, let the expansion report it against the real values
}
found := false
actionlint.VisitExprNode(expr, func(node, _ actionlint.ExprNode, entering bool) {
call, ok := node.(*actionlint.FuncCallNode)
if entering && ok && slices.Contains(names, strings.ToLower(call.Callee)) {
found = true
}
})
if found {
return true
}
}
return false
} }
func matrixName(m map[string]any) string { func matrixName(m map[string]any) string {
@@ -7,6 +7,7 @@ import (
"strings" "strings"
"testing" "testing"
"gitea.com/gitea/runner/act/model"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"go.yaml.in/yaml/v4" "go.yaml.in/yaml/v4"
@@ -107,3 +108,42 @@ func TestParse(t *testing.T) {
}) })
} }
} }
func TestParseInterpolatesRunName(t *testing.T) {
workflow := func(runName string) []byte {
return []byte("name: t\nrun-name: \"" + runName + "\"\non: push\njobs:\n build:\n runs-on: ubuntu-latest\n steps: [{run: echo}]\n")
}
for _, tt := range []struct{ name, runName, want string }{
{"bool", "${{ true }}", "true"},
{"int", "${{ 1 }}", "1"},
{"float", "${{ 1.0 }}", "1"},
{"null", "${{ null }}", ""},
{"object", `${{ fromJSON('{\"a\":1}') }}`, "Object"},
{"array", "${{ fromJSON('[1,2]') }}", "Array"},
{"context", "${{ github }}", "Object"},
{"surrounding literals", "run ${{ 1 }} now", "run 1 now"},
{"two expressions", "${{ 1 }}-${{ true }}", "1-true"},
{"closing brace inside a string", "${{ 'a}}b' }}", "a}}b"},
{"incomplete expression stays literal", "${{ 1", "${{ 1"},
} {
t.Run(tt.name, func(t *testing.T) {
result, err := Parse(workflow(tt.runName), WithGitContext(&model.GithubContext{EventName: "push"}))
require.NoError(t, err)
require.Len(t, result, 1)
assert.Equal(t, tt.want, result[0].RunName)
})
}
// a malformed part must not restructure the surrounding expression
for _, runName := range []string{"${{ 1) && (2 }}", "run ${{ 1) && (2 }} now", "${{ 'a' }} ${{ b", "${{ 'a }}"} {
_, err := Parse(workflow(runName), WithGitContext(&model.GithubContext{EventName: "push"}))
assert.ErrorContains(t, err, "interpolate run-name")
}
// callers such as commit status parse without a git context, leaving `github` a nil pointer
result, err := Parse(workflow("${{ github }}"))
require.NoError(t, err)
require.Len(t, result, 1)
assert.Empty(t, result[0].RunName)
}
+1 -10
View File
@@ -8,7 +8,6 @@ import (
"errors" "errors"
"fmt" "fmt"
"gitea.com/gitea/runner/act/exprparser"
"gitea.com/gitea/runner/act/model" "gitea.com/gitea/runner/act/model"
"go.yaml.in/yaml/v4" "go.yaml.in/yaml/v4"
) )
@@ -518,15 +517,7 @@ func EvaluateJobIfExpression(jobID string, job *Job, gitCtx map[string]any, resu
matrix = matrixes[0] matrix = matrixes[0]
} }
evaluator := NewExpressionEvaluator(NewInterpeter(jobID, actJob, matrix, toGitContext(gitCtx), results, vars, inputs)) evaluator := NewExpressionEvaluator(NewInterpeter(jobID, actJob, matrix, toGitContext(gitCtx), results, vars, inputs))
expr, err := rewriteSubExpression(job.If.Value, false) return evaluator.evaluateCondition(job.If.Value)
if err != nil {
return false, err
}
result, err := evaluator.evaluate(expr, exprparser.DefaultStatusCheckSuccess)
if err != nil {
return false, err
}
return exprparser.IsTruthy(result), nil
} }
// parseMappingNode parse a mapping node and preserve order. // parseMappingNode parse a mapping node and preserve order.
+4
View File
@@ -527,6 +527,10 @@ func TestEvaluateJobIfExpression(t *testing.T) {
{name: "cancelled", ifCond: "${{ cancelled() }}", needResult: "success", expected: false}, {name: "cancelled", ifCond: "${{ cancelled() }}", needResult: "success", expected: false},
{name: "not cancelled or failure", ifCond: "${{ !(cancelled() || failure()) }}", needResult: "success", expected: true}, {name: "not cancelled or failure", ifCond: "${{ !(cancelled() || failure()) }}", needResult: "success", expected: true},
{name: "not cancelled or failure, need failed", ifCond: "${{ !(cancelled() || failure()) }}", needResult: "failure", expected: false}, {name: "not cancelled or failure, need failed", ifCond: "${{ !(cancelled() || failure()) }}", needResult: "failure", expected: false},
// a condition is an expression with or without `${{ }}`, literal text around one makes it a string
{name: "bare expression", ifCond: "always()", needResult: "failure", expected: true},
{name: "literal text keeps the success() default", ifCond: "x ${{ 1 }}", needResult: "failure", expected: false},
{name: "literal text around a status function drops it", ifCond: "x ${{ always() }}", needResult: "failure", expected: true},
} }
for _, kase := range kases { for _, kase := range kases {
t.Run(kase.name, func(t *testing.T) { t.Run(kase.name, func(t *testing.T) {
+3 -29
View File
@@ -260,7 +260,7 @@ func MatchCallerInputsAgainstSpec(spec *WorkflowCallSpec, evaluated map[string]a
func parseWorkflowCallInput(name string, typ InputType, v any) (any, error) { func parseWorkflowCallInput(name string, typ InputType, v any) (any, error) {
switch typ { switch typ {
case InputTypeString: case InputTypeString:
return toString(v), nil return coerceToString(v), nil
case InputTypeBoolean: case InputTypeBoolean:
// strict type matching: a boolean input only accepts a native bool, not a "true"/"false" string // strict type matching: a boolean input only accepts a native bool, not a "true"/"false" string
if b, ok := v.(bool); ok { if b, ok := v.(bool); ok {
@@ -361,11 +361,11 @@ func EvaluateWorkflowCallOutputs(spec *WorkflowCallSpec, gitCtx *model.GithubCon
Vars: vars, Vars: vars,
Inputs: inputs, Inputs: inputs,
} }
interpreter := exprparser.NewInterpeter(env, exprparser.Config{}) evaluator := NewExpressionEvaluator(exprparser.NewInterpeter(env, exprparser.Config{}))
out := make(map[string]string, len(spec.Outputs)) out := make(map[string]string, len(spec.Outputs))
for name, o := range spec.Outputs { for name, o := range spec.Outputs {
v, err := evaluateWorkflowCallOutputValue(interpreter, o.Value) v, err := evaluator.interpolate(o.Value)
if err != nil { if err != nil {
return nil, fmt.Errorf("workflow_call output %q: %w", name, err) return nil, fmt.Errorf("workflow_call output %q: %w", name, err)
} }
@@ -373,29 +373,3 @@ func EvaluateWorkflowCallOutputs(spec *WorkflowCallSpec, gitCtx *model.GithubCon
} }
return out, nil return out, nil
} }
func evaluateWorkflowCallOutputValue(interpreter exprparser.Interpreter, value string) (string, error) {
if !strings.Contains(value, "${{") || !strings.Contains(value, "}}") {
return value, nil
}
expr, err := rewriteSubExpression(value, true)
if err != nil {
return "", err
}
evaluated, err := interpreter.Evaluate(expr, exprparser.DefaultStatusCheckNone)
if err != nil {
return "", err
}
return toString(evaluated), nil
}
func toString(v any) string {
switch s := v.(type) {
case string:
return s
case nil:
return ""
default:
return fmt.Sprintf("%v", s)
}
}