mirror of
https://code.forgejo.org/forgejo/runner.git
synced 2026-10-24 16:59:19 +03:00
Fixes #1501. As a jobparser upgrade, it requires Runner be released and Forgejo be upgraded to the new version. When a reusable workflow caller has an `if:` condition on the job, it is placed on the expanded jobs that come from it. If an inner job of a reusable workflow also has an `if:` condition, the two conditions are `&&`'d together. I originally planned to evaluate the `if` condition during job parsing, as described in [a comment on #1501](https://code.forgejo.org/forgejo/runner/issues/1501#issuecomment-85822). However, I realized that a common usage for `if` is job-status checks like `if: failed()`, and that cannot be evaluated at expansion time. Manually tested with these workflows: Caller: ``` on: pull_request: jobs: top-true-inner-true: if: true uses: ./.forgejo/workflows/shared/child.yml with: value: first top-true-inner-false: if: true uses: ./.forgejo/workflows/shared/child.yml with: value: not first top-false: if: false uses: ./.forgejo/workflows/shared/child.yml with: value: would be second ``` Child: ``` name: Child workflow on: workflow_call: inputs: value: type: string required: true jobs: child-workflow: runs-on: docker if: ${{ inputs.value == 'first' }} steps: - name: Echo run: echo ${{ inputs.value }} ``` <!--start release-notes-assistant--> <!--URL:https://code.forgejo.org/forgejo/runner--> - features - [PR](https://code.forgejo.org/forgejo/runner/pulls/1509): <!--number 1509 --><!--line 0 --><!--description ZmVhdDogbWVyZ2UgcmV1c2FibGUgZXhwYW5zaW9uIGNhbGxlcidzICdpZicgaW50byBleHBhbmRlZCBqb2Jz-->feat: merge reusable expansion caller's 'if' into expanded jobs<!--description--> <!--end release-notes-assistant--> Reviewed-on: https://code.forgejo.org/forgejo/runner/pulls/1509 Reviewed-by: Andreas Ahlenstorf <aahlenst@noreply.code.forgejo.org>
364 lines
12 KiB
Go
364 lines
12 KiB
Go
package exprparser
|
|
|
|
import (
|
|
"fmt"
|
|
"reflect"
|
|
"strings"
|
|
|
|
"github.com/rhysd/actionlint"
|
|
"go.yaml.in/yaml/v3"
|
|
)
|
|
|
|
// Given a workflow string which may contain an expression, apply an expression `Mutation` to the string and return a
|
|
// new one. Mutations have the capability to replace parts of the abstract-syntax-tree (AST) for the expression with
|
|
// new contents, allowing for sophisticated changes to the expression.
|
|
//
|
|
// As the expressions within the string are parsed into an AST, mutated, and then re-rendered from the new AST to text,
|
|
// arbitrary formatting changes may occur, particularly with whitespace. For example, a function call like
|
|
// `format('id={0}',123)` will be returned with a space between function parameters, even if a mutator didn't change
|
|
// this function.
|
|
func Mutate(input string, mutations ...Mutation) (string, error) {
|
|
// Skip raw strings with no expression evaluation.
|
|
if !strings.Contains(input, "${{") || !strings.Contains(input, "}}") {
|
|
return input, nil
|
|
}
|
|
|
|
// Parse expression into an AST.
|
|
expr := RewriteSubExpression(input, false)
|
|
expr = strings.TrimPrefix(expr, "${{")
|
|
parser := actionlint.NewExprParser()
|
|
exprNode, exprErr := parser.Parse(actionlint.NewExprLexer(expr + "}}"))
|
|
if exprErr != nil {
|
|
return "", fmt.Errorf("failed to parse: %w", exprErr)
|
|
}
|
|
|
|
// Mutate the AST.
|
|
mutator := &mutator{mutations}
|
|
newNode, mutationHasEffect, err := mutator.mutate(exprNode)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to mutate: %w", err)
|
|
}
|
|
if !mutationHasEffect {
|
|
// Rewriting the expression and re-rendering it can cause minor syntax changes throughout that could be
|
|
// confusing. If none of the mutations had any effect on the string, then abort and return the original.
|
|
return input, nil
|
|
}
|
|
|
|
// Render the new expression.
|
|
var builder strings.Builder
|
|
builder.WriteString("${{ ")
|
|
err = render(&builder, newNode)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to render: %w", err)
|
|
}
|
|
builder.WriteString(" }}")
|
|
|
|
return builder.String(), nil
|
|
}
|
|
|
|
// Where `Mutate` operates on a single expression string, `MutateYamlNode` will iterate through an entire yaml tree and
|
|
// perform the mutations on all string values found within.
|
|
func MutateYamlNode(node *yaml.Node, mutations ...Mutation) error {
|
|
return innerMutateYamlNode(node, false, mutations...)
|
|
}
|
|
|
|
func innerMutateYamlNode(node *yaml.Node, forceWrapScalar bool, mutations ...Mutation) error {
|
|
switch node.Kind {
|
|
case yaml.ScalarNode:
|
|
return mutateScalarYamlNode(node, forceWrapScalar, mutations...)
|
|
case yaml.MappingNode:
|
|
return mutateMappingYamlNode(node, mutations...)
|
|
case yaml.SequenceNode:
|
|
return mutateSequenceYamlNode(node, mutations...)
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func mutateScalarYamlNode(node *yaml.Node, forceWrapScalar bool, mutations ...Mutation) error {
|
|
if node.ShortTag() != "!!str" {
|
|
return nil
|
|
}
|
|
var in string
|
|
if err := node.Decode(&in); err != nil {
|
|
return err
|
|
}
|
|
hasExpr := strings.Contains(in, "${{") && strings.Contains(in, "}}")
|
|
if forceWrapScalar && !hasExpr {
|
|
in = fmt.Sprintf("${{ %s }}", in)
|
|
} else if !hasExpr {
|
|
return nil
|
|
}
|
|
res, err := Mutate(in, mutations...)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return node.Encode(res)
|
|
}
|
|
|
|
func mutateMappingYamlNode(node *yaml.Node, mutations ...Mutation) error {
|
|
for i := 0; i < len(node.Content)/2; {
|
|
k := node.Content[i*2]
|
|
// `jobs.<job_id>.if` and `jobs.<job_id>.steps[*].if` in workflows have a special behaviour -- the `${{ ... }}`
|
|
// wrapper can be omitted. `MutateYamlNode` implements this by automatically wrapping nodes with a key `if`.
|
|
forceWrapScalar := k.Value == "if"
|
|
v := node.Content[i*2+1]
|
|
if err := innerMutateYamlNode(v, forceWrapScalar, mutations...); err != nil {
|
|
return err
|
|
}
|
|
i++
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func mutateSequenceYamlNode(node *yaml.Node, mutations ...Mutation) error {
|
|
for i := range node.Content {
|
|
v := node.Content[i]
|
|
if err := MutateYamlNode(v, mutations...); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Mutations are provided to `Mutate` to change an expression.
|
|
type Mutation interface {
|
|
// Given a single AST node, return either `nil` to allow the original node to be used, or a new `ExprNode` to
|
|
// replace it in the AST.
|
|
SingleNodeMutation(exprNode actionlint.ExprNode) (actionlint.ExprNode, error)
|
|
}
|
|
|
|
type mutator struct {
|
|
mutations []Mutation
|
|
}
|
|
|
|
// Recursively rewrite the AST expression tree, applying the mutator's `mutations` as it is processed. The new node, a
|
|
// flag as to whether the mutation made any change, and possibly an error are returned.
|
|
func (m *mutator) mutate(exprNode actionlint.ExprNode) (actionlint.ExprNode, bool, error) {
|
|
mutationHasEffect := false
|
|
newNode, innerMutationHasEffect, err := m.noopRewrite(exprNode)
|
|
for _, mutation := range m.mutations {
|
|
replacement, err := mutation.SingleNodeMutation(exprNode)
|
|
if err != nil {
|
|
return nil, false, err
|
|
} else if replacement != nil {
|
|
newNode = replacement
|
|
mutationHasEffect = true
|
|
}
|
|
}
|
|
return newNode, mutationHasEffect || innerMutationHasEffect, err
|
|
}
|
|
|
|
// Create a copy of `exprNode` with the same data as the original node, applying mutations to all child nodes (function
|
|
// arguments, etc.). The new node, a flag as to whether the mutation made any change, and possibly an error are
|
|
// returned.
|
|
func (m *mutator) noopRewrite(exprNode actionlint.ExprNode) (actionlint.ExprNode, bool, error) {
|
|
switch node := exprNode.(type) {
|
|
case *actionlint.VariableNode:
|
|
return &actionlint.VariableNode{Name: node.Name}, false, nil
|
|
case *actionlint.BoolNode:
|
|
return &actionlint.BoolNode{Value: node.Value}, false, nil
|
|
case *actionlint.NullNode:
|
|
return &actionlint.NullNode{}, false, nil
|
|
case *actionlint.IntNode:
|
|
return &actionlint.IntNode{Value: node.Value}, false, nil
|
|
case *actionlint.FloatNode:
|
|
return &actionlint.FloatNode{Value: node.Value}, false, nil
|
|
case *actionlint.StringNode:
|
|
return &actionlint.StringNode{Value: node.Value}, false, nil
|
|
case *actionlint.IndexAccessNode:
|
|
newOperand, mutationHasEffect, err := m.mutate(node.Operand)
|
|
if err != nil {
|
|
return nil, false, fmt.Errorf("failure mutating IndexAccessNode operand: %w", err)
|
|
}
|
|
return &actionlint.IndexAccessNode{
|
|
Operand: newOperand,
|
|
Index: node.Index,
|
|
}, mutationHasEffect, nil
|
|
case *actionlint.ObjectDerefNode:
|
|
newReceiver, mutationHasEffect, err := m.mutate(node.Receiver)
|
|
if err != nil {
|
|
return nil, false, fmt.Errorf("failure mutating ObjectDerefNode receiver: %w", err)
|
|
}
|
|
return &actionlint.ObjectDerefNode{
|
|
Receiver: newReceiver,
|
|
Property: node.Property,
|
|
}, mutationHasEffect, nil
|
|
case *actionlint.ArrayDerefNode:
|
|
newReceiver, mutationHasEffect, err := m.mutate(node.Receiver)
|
|
if err != nil {
|
|
return nil, false, fmt.Errorf("failure mutating ArrayDerefNode receiver: %w", err)
|
|
}
|
|
return &actionlint.ArrayDerefNode{
|
|
Receiver: newReceiver,
|
|
}, mutationHasEffect, nil
|
|
case *actionlint.NotOpNode:
|
|
newOperand, mutationHasEffect, err := m.mutate(node.Operand)
|
|
if err != nil {
|
|
return nil, false, fmt.Errorf("failure mutating NotOpNode operand: %w", err)
|
|
}
|
|
return &actionlint.NotOpNode{
|
|
Operand: newOperand,
|
|
}, mutationHasEffect, nil
|
|
case *actionlint.CompareOpNode:
|
|
newLeft, mutationHasEffectLeft, err := m.mutate(node.Left)
|
|
if err != nil {
|
|
return nil, false, fmt.Errorf("failure mutating CompareOpNode left: %w", err)
|
|
}
|
|
newRight, mutationHasEffectRight, err := m.mutate(node.Right)
|
|
if err != nil {
|
|
return nil, false, fmt.Errorf("failure mutating CompareOpNode right: %w", err)
|
|
}
|
|
return &actionlint.CompareOpNode{
|
|
Left: newLeft,
|
|
Right: newRight,
|
|
Kind: node.Kind,
|
|
}, mutationHasEffectLeft || mutationHasEffectRight, nil
|
|
case *actionlint.LogicalOpNode:
|
|
newLeft, mutationHasEffectLeft, err := m.mutate(node.Left)
|
|
if err != nil {
|
|
return nil, false, fmt.Errorf("failure mutating CompareOpNode left: %w", err)
|
|
}
|
|
newRight, mutationHasEffectRight, err := m.mutate(node.Right)
|
|
if err != nil {
|
|
return nil, false, fmt.Errorf("failure mutating CompareOpNode right: %w", err)
|
|
}
|
|
return &actionlint.LogicalOpNode{
|
|
Left: newLeft,
|
|
Right: newRight,
|
|
Kind: node.Kind,
|
|
}, mutationHasEffectLeft || mutationHasEffectRight, nil
|
|
case *actionlint.FuncCallNode:
|
|
newArgs := make([]actionlint.ExprNode, len(node.Args))
|
|
mutationHasEffect := false
|
|
for i, arg := range node.Args {
|
|
newArg, argMutated, err := m.mutate(arg)
|
|
mutationHasEffect = mutationHasEffect || argMutated
|
|
if err != nil {
|
|
return nil, false, fmt.Errorf("failure mutating FuncCallNode arg %d: %w", i, err)
|
|
}
|
|
newArgs[i] = newArg
|
|
}
|
|
return &actionlint.FuncCallNode{
|
|
Callee: node.Callee,
|
|
Args: newArgs,
|
|
}, mutationHasEffect, nil
|
|
default:
|
|
return nil, false, fmt.Errorf("unknown node type: %s node: %+v", reflect.TypeOf(exprNode), exprNode)
|
|
}
|
|
}
|
|
|
|
// Converts an AST back to a text expression.
|
|
func render(builder *strings.Builder, exprNode actionlint.ExprNode) error {
|
|
switch node := exprNode.(type) {
|
|
case *actionlint.VariableNode:
|
|
builder.WriteString(node.Name)
|
|
case *actionlint.BoolNode:
|
|
if node.Value {
|
|
builder.WriteString("true")
|
|
} else {
|
|
builder.WriteString("false")
|
|
}
|
|
case *actionlint.NullNode:
|
|
builder.WriteString("null")
|
|
case *actionlint.IntNode:
|
|
fmt.Fprintf(builder, "%d", node.Value)
|
|
case *actionlint.FloatNode:
|
|
fmt.Fprintf(builder, "%#v", node.Value)
|
|
case *actionlint.StringNode:
|
|
builder.WriteString("'")
|
|
builder.WriteString(strings.ReplaceAll(node.Value, "'", "''"))
|
|
builder.WriteString("'")
|
|
case *actionlint.IndexAccessNode:
|
|
if err := render(builder, node.Operand); err != nil {
|
|
return nil
|
|
}
|
|
builder.WriteString("[")
|
|
if err := render(builder, node.Index); err != nil {
|
|
return nil
|
|
}
|
|
builder.WriteString("]")
|
|
case *actionlint.ObjectDerefNode:
|
|
if err := render(builder, node.Receiver); err != nil {
|
|
return nil
|
|
}
|
|
builder.WriteString(".")
|
|
builder.WriteString(node.Property)
|
|
case *actionlint.ArrayDerefNode:
|
|
if err := render(builder, node.Receiver); err != nil {
|
|
return nil
|
|
}
|
|
builder.WriteString(".*")
|
|
case *actionlint.NotOpNode:
|
|
builder.WriteString("!")
|
|
if err := render(builder, node.Operand); err != nil {
|
|
return nil
|
|
}
|
|
case *actionlint.CompareOpNode:
|
|
if err := render(builder, node.Left); err != nil {
|
|
return nil
|
|
}
|
|
builder.WriteString(" ")
|
|
builder.WriteString(node.Kind.String())
|
|
builder.WriteString(" ")
|
|
if err := render(builder, node.Right); err != nil {
|
|
return nil
|
|
}
|
|
case *actionlint.LogicalOpNode:
|
|
if err := render(builder, node.Left); err != nil {
|
|
return nil
|
|
}
|
|
builder.WriteString(" ")
|
|
builder.WriteString(node.Kind.String())
|
|
builder.WriteString(" ")
|
|
if err := render(builder, node.Right); err != nil {
|
|
return nil
|
|
}
|
|
case *actionlint.FuncCallNode:
|
|
builder.WriteString(node.Callee)
|
|
builder.WriteString("(")
|
|
for i, arg := range node.Args {
|
|
if err := render(builder, arg); err != nil {
|
|
return nil
|
|
}
|
|
if i != len(node.Args)-1 {
|
|
builder.WriteString(", ")
|
|
}
|
|
}
|
|
builder.WriteString(")")
|
|
default:
|
|
return fmt.Errorf("unknown node type: %s node: %+v", reflect.TypeOf(exprNode), exprNode)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// VariableAccessMutator is used to rewrite access to top-level variables (contexts, in the runner terminology). When a
|
|
// variable matching the name `Variable` is accessed, invoke the Rewriter function to get a replacement `ExprNode`. Both
|
|
// access to a variable with an object dereference (eg. `var.foo`) and an index-style access (`var.['foo']`) is
|
|
// supported for mutation. Note that because index-style access is supported, the mutator does not know the specific
|
|
// value of the property name being accessed -- it could be computed or derived from another property
|
|
// (`var[other-var.something]`).
|
|
type VariableAccessMutator struct {
|
|
// Variable to trigger the mutator upon. If set to `needs`, for example, then `needs.hello` will trigger
|
|
// `Rewriter`.
|
|
Variable string
|
|
// Rewrite routine to use. `property` will be an AST node representing the property accessed. Typically you would
|
|
// not want to inspect inside the contents of the `property` as it could be any type of AST from a simple string
|
|
// literal to a function call.
|
|
Rewriter func(property actionlint.ExprNode) actionlint.ExprNode
|
|
}
|
|
|
|
func (vam *VariableAccessMutator) SingleNodeMutation(exprNode actionlint.ExprNode) (actionlint.ExprNode, error) {
|
|
switch node := exprNode.(type) {
|
|
case *actionlint.ObjectDerefNode:
|
|
if varAccess, ok := node.Receiver.(*actionlint.VariableNode); ok && varAccess.Name == vam.Variable {
|
|
return vam.Rewriter(&actionlint.StringNode{Value: node.Property}), nil
|
|
}
|
|
case *actionlint.IndexAccessNode:
|
|
if varAccess, ok := node.Operand.(*actionlint.VariableNode); ok && varAccess.Name == vam.Variable {
|
|
return vam.Rewriter(node.Index), nil
|
|
}
|
|
}
|
|
return nil, nil
|
|
}
|