Replace expressions engine (#133)

This commit is contained in:
ChristopherHX
2025-10-06 13:53:15 +02:00
committed by GitHub
parent 418c708bb0
commit 82dccc7820
40 changed files with 6876 additions and 1304 deletions

30
internal/model/anchors.go Normal file
View File

@@ -0,0 +1,30 @@
package model
import (
"errors"
"gopkg.in/yaml.v3"
)
// Assumes there is no cycle ensured via test TestVerifyCycleIsInvalid
func resolveAliases(node *yaml.Node) error {
switch node.Kind {
case yaml.AliasNode:
aliasTarget := node.Alias
if aliasTarget == nil {
return errors.New("unresolved alias node")
}
*node = *aliasTarget
if err := resolveAliases(node); err != nil {
return err
}
case yaml.DocumentNode, yaml.MappingNode, yaml.SequenceNode:
for _, child := range node.Content {
if err := resolveAliases(child); err != nil {
return err
}
}
}
return nil
}

View File

@@ -0,0 +1,242 @@
package model
import (
"errors"
"fmt"
"strings"
"gopkg.in/yaml.v3"
)
// TraceWriter is an interface for logging trace information.
// Implementations can write to console, file, or any other sink.
type TraceWriter interface {
Info(format string, args ...interface{})
}
// StrategyResult holds the result of expanding a strategy.
// FlatMatrix contains the expanded matrix entries.
// IncludeMatrix contains entries that were added via include.
// FailFast indicates whether the job should fail fast.
// MaxParallel is the maximum parallelism allowed.
// MatrixKeys is the set of keys present in the matrix.
type StrategyResult struct {
FlatMatrix []map[string]yaml.Node
IncludeMatrix []map[string]yaml.Node
FailFast bool
MaxParallel *float64
MatrixKeys map[string]struct{}
}
type strategyContext struct {
jobTraceWriter TraceWriter
failFast bool
maxParallel float64
matrix map[string][]yaml.Node
flatMatrix []map[string]yaml.Node
includeMatrix []map[string]yaml.Node
include []yaml.Node
exclude []yaml.Node
}
func (strategyContext *strategyContext) handleInclude() error {
// Handle include logic
if len(strategyContext.include) > 0 {
for _, incNode := range strategyContext.include {
if incNode.Kind != yaml.MappingNode {
return fmt.Errorf("include entry is not a mapping node")
}
incMap := make(map[string]yaml.Node)
for i := 0; i < len(incNode.Content); i += 2 {
keyNode := incNode.Content[i]
valNode := incNode.Content[i+1]
if keyNode.Kind != yaml.ScalarNode {
return fmt.Errorf("include key is not scalar")
}
incMap[keyNode.Value] = *valNode
}
matched := false
for _, row := range strategyContext.flatMatrix {
match := true
for k, v := range incMap {
if rv, ok := row[k]; ok && !nodesEqual(rv, v) {
match = false
break
}
}
if match {
matched = true
// Add missing keys
strategyContext.jobTraceWriter.Info("Add missing keys %v", incMap)
for k, v := range incMap {
if _, ok := row[k]; !ok {
row[k] = v
}
}
}
}
if !matched {
if strategyContext.jobTraceWriter != nil {
strategyContext.jobTraceWriter.Info("Append include entry %v", incMap)
}
strategyContext.includeMatrix = append(strategyContext.includeMatrix, incMap)
}
}
}
return nil
}
func (strategyContext *strategyContext) handleExclude() error {
// Handle exclude logic
if len(strategyContext.exclude) > 0 {
for _, exNode := range strategyContext.exclude {
// exNode is expected to be a mapping node
if exNode.Kind != yaml.MappingNode {
return fmt.Errorf("exclude entry is not a mapping node")
}
// Convert mapping to map[string]yaml.Node
exMap := make(map[string]yaml.Node)
for i := 0; i < len(exNode.Content); i += 2 {
keyNode := exNode.Content[i]
valNode := exNode.Content[i+1]
if keyNode.Kind != yaml.ScalarNode {
return fmt.Errorf("exclude key is not scalar")
}
exMap[keyNode.Value] = *valNode
}
// Remove matching rows
filtered := []map[string]yaml.Node{}
for _, row := range strategyContext.flatMatrix {
match := true
for k, v := range exMap {
if rv, ok := row[k]; !ok || !nodesEqual(rv, v) {
match = false
break
}
}
if !match {
filtered = append(filtered, row)
} else if strategyContext.jobTraceWriter != nil {
strategyContext.jobTraceWriter.Info("Removing %v from matrix due to exclude entry %v", row, exMap)
}
}
strategyContext.flatMatrix = filtered
}
}
return nil
}
// ExpandStrategy expands the given strategy into a flat matrix and include matrix.
// It mimics the behavior of the C# StrategyUtils. The strategy parameter is expected
// to be populated from a YAML mapping that follows the GitHub Actions strategy schema.
func ExpandStrategy(strategy *Strategy, jobTraceWriter TraceWriter) (*StrategyResult, error) {
if strategy == nil {
return &StrategyResult{FlatMatrix: []map[string]yaml.Node{{}}, IncludeMatrix: []map[string]yaml.Node{}, FailFast: true}, nil
}
// Initialize defaults
strategyContext := &strategyContext{
jobTraceWriter: jobTraceWriter,
failFast: strategy.FailFast,
maxParallel: strategy.MaxParallel,
matrix: strategy.Matrix,
flatMatrix: []map[string]yaml.Node{{}},
}
// Process matrix entries
for key, values := range strategyContext.matrix {
switch key {
case "include":
strategyContext.include = values
case "exclude":
strategyContext.exclude = values
default:
// Other keys are treated as matrix dimensions
// Expand each existing row with the new key/value pairs
next := []map[string]yaml.Node{}
for _, row := range strategyContext.flatMatrix {
for _, val := range values {
newRow := make(map[string]yaml.Node)
for k, v := range row {
newRow[k] = v
}
newRow[key] = val
next = append(next, newRow)
}
}
strategyContext.flatMatrix = next
}
}
if err := strategyContext.handleExclude(); err != nil {
return nil, err
}
if len(strategyContext.flatMatrix) == 0 {
if jobTraceWriter != nil {
jobTraceWriter.Info("Matrix is empty, adding an empty entry")
}
strategyContext.flatMatrix = []map[string]yaml.Node{{}}
}
// Enforce job matrix limit of github
if len(strategyContext.flatMatrix) > 256 {
if jobTraceWriter != nil {
jobTraceWriter.Info("Failure: Matrix contains more than 256 entries after exclude")
}
return nil, errors.New("matrix contains more than 256 entries")
}
// Build matrix keys set
matrixKeys := make(map[string]struct{})
if len(strategyContext.flatMatrix) > 0 {
for k := range strategyContext.flatMatrix[0] {
matrixKeys[k] = struct{}{}
}
}
if err := strategyContext.handleInclude(); err != nil {
return nil, err
}
return &StrategyResult{
FlatMatrix: strategyContext.flatMatrix,
IncludeMatrix: strategyContext.includeMatrix,
FailFast: strategyContext.failFast,
MaxParallel: &strategyContext.maxParallel,
MatrixKeys: matrixKeys,
}, nil
}
// nodesEqual compares two yaml.Node values for equality.
func nodesEqual(a, b yaml.Node) bool {
return DeepEquals(a, b, true)
}
// GetDefaultDisplaySuffix returns a string like "(foo, bar, baz)".
// Empty items are ignored. If all items are empty the result is "".
func GetDefaultDisplaySuffix(items []string) string {
var b strings.Builder // efficient string concatenation
first := true // true until we write the first nonempty item
for _, mk := range items {
if mk == "" { // Go has no null string, so we only need to check for empty
continue
}
if first {
b.WriteString("(")
first = false
} else {
b.WriteString(", ")
}
b.WriteString(mk)
}
if !first { // we wrote at least one item
b.WriteString(")")
}
return b.String()
}

View File

@@ -0,0 +1,68 @@
package model
import (
"testing"
"github.com/stretchr/testify/require"
"gopkg.in/yaml.v3"
)
type EmptyTraceWriter struct {
}
func (e *EmptyTraceWriter) Info(_ string, _ ...interface{}) {
}
func TestStrategy(t *testing.T) {
table := []struct {
content string
flatmatrix int
includematrix int
}{
{`
matrix:
label:
- a
- b
fields:
- a
- b
`, 4, 0},
{`
matrix:
label:
- a
- b
include:
- label: a
x: self`, 2, 0,
},
{`
matrix:
label:
- a
- b
include:
- label: c
x: self`, 2, 1,
},
{`
matrix:
label:
- a
- b
exclude:
- label: a`, 1, 0,
},
}
for _, tc := range table {
var strategy Strategy
err := yaml.Unmarshal([]byte(tc.content), &strategy)
require.NoError(t, err)
res, err := ExpandStrategy(&strategy, &EmptyTraceWriter{})
require.NoError(t, err)
require.Len(t, res.FlatMatrix, tc.flatmatrix)
require.Len(t, res.IncludeMatrix, tc.includematrix)
}
}

View File

@@ -0,0 +1,148 @@
package model
import (
"strings"
v2 "github.com/actions-oss/act-cli/internal/eval/v2"
"gopkg.in/yaml.v3"
)
// DeepEquals compares two yaml.Node values recursively.
// It supports scalar, mapping and sequence nodes and allows
// an optional partial match for mappings and sequences.
func DeepEquals(a, b yaml.Node, partialMatch bool) bool {
// Scalar comparison
if a.Kind == yaml.ScalarNode && b.Kind == yaml.ScalarNode {
return scalarEquals(a, b)
}
// Mapping comparison
if a.Kind == yaml.MappingNode && b.Kind == yaml.MappingNode {
return deepMapEquals(a, b, partialMatch)
}
// Sequence comparison
if a.Kind == yaml.SequenceNode && b.Kind == yaml.SequenceNode {
return deepSequenceEquals(a, b, partialMatch)
}
// Different kinds are not equal
return false
}
func scalarEquals(a, b yaml.Node) bool {
var left, right any
return a.Decode(&left) == nil && b.Decode(&right) == nil && v2.CreateIntermediateResult(v2.NewEvaluationContext(), left).AbstractEqual(v2.CreateIntermediateResult(v2.NewEvaluationContext(), right))
}
func deepMapEquals(a, b yaml.Node, partialMatch bool) bool {
mapA := make(map[string]yaml.Node)
for i := 0; i < len(a.Content); i += 2 {
keyNode := a.Content[i]
valNode := a.Content[i+1]
if keyNode.Kind != yaml.ScalarNode {
return false
}
mapA[strings.ToLower(keyNode.Value)] = *valNode
}
mapB := make(map[string]yaml.Node)
for i := 0; i < len(b.Content); i += 2 {
keyNode := b.Content[i]
valNode := b.Content[i+1]
if keyNode.Kind != yaml.ScalarNode {
return false
}
mapB[strings.ToLower(keyNode.Value)] = *valNode
}
if partialMatch {
if len(mapA) < len(mapB) {
return false
}
} else {
if len(mapA) != len(mapB) {
return false
}
}
for k, vB := range mapB {
vA, ok := mapA[k]
if !ok || !DeepEquals(vA, vB, partialMatch) {
return false
}
}
return true
}
func deepSequenceEquals(a, b yaml.Node, partialMatch bool) bool {
if partialMatch {
if len(a.Content) < len(b.Content) {
return false
}
} else {
if len(a.Content) != len(b.Content) {
return false
}
}
limit := len(b.Content)
if !partialMatch {
limit = len(a.Content)
}
for i := 0; i < limit; i++ {
if !DeepEquals(*a.Content[i], *b.Content[i], partialMatch) {
return false
}
}
return true
}
// traverse walks a YAML node recursively.
func traverse(node *yaml.Node, omitKeys bool, result *[]*yaml.Node) {
if node == nil {
return
}
*result = append(*result, node)
switch node.Kind {
case yaml.MappingNode:
if omitKeys {
// node.Content: key0, val0, key1, val1, …
for i := 1; i < len(node.Content); i += 2 { // only the values
traverse(node.Content[i], omitKeys, result)
}
} else {
for _, child := range node.Content {
traverse(child, omitKeys, result)
}
}
case yaml.SequenceNode:
// For all other node kinds (Scalar, Sequence, Alias, etc.)
for _, child := range node.Content {
traverse(child, omitKeys, result)
}
}
}
// GetDisplayStrings implements the LINQ expression:
//
// from displayitem in keys.SelectMany(key => item[key].Traverse(true))
// where !(displayitem is SequenceToken || displayitem is MappingToken)
// select displayitem.ToString()
func GetDisplayStrings(keys []string, item map[string]*yaml.Node) []string {
var res []string
for _, k := range keys {
if node, ok := item[k]; ok {
var all []*yaml.Node
traverse(node, true, &all) // include the parent node itself
for _, n := range all {
// Keep only scalars everything else is dropped
if n.Kind == yaml.ScalarNode {
res = append(res, n.Value)
}
}
}
}
return res
}

View File

@@ -0,0 +1,277 @@
package model
import "gopkg.in/yaml.v3"
type JobStatus int
const (
JobStatusPending JobStatus = iota
JobStatusDependenciesReady
JobStatusBlocked
JobStatusCompleted
)
type JobState struct {
JobID string // Workflow path to job, incl matrix and parent jobids
Result string // Actions Job Result
Outputs map[string]string // Returned Outputs
State JobStatus
Strategy []MatrixJobState
}
type MatrixJobState struct {
Matrix map[string]any
Name string
Result string
Outputs map[string]string // Returned Outputs
State JobStatus
}
type WorkflowStatus int
const (
WorkflowStatusPending WorkflowStatus = iota
WorkflowStatusDependenciesReady
WorkflowStatusBlocked
WorkflowStatusCompleted
)
type WorkflowState struct {
Name string
RunName string
Jobs JobState
StateWorkflowStatus WorkflowStatus
}
type Workflow struct {
On *On `yaml:"on,omitempty"`
Name string `yaml:"name,omitempty"`
Description string `yaml:"description,omitempty"`
RunName yaml.Node `yaml:"run-name,omitempty"`
Permissions *Permissions `yaml:"permissions,omitempty"`
Env yaml.Node `yaml:"env,omitempty"`
Defaults yaml.Node `yaml:"defaults,omitempty"`
Concurrency yaml.Node `yaml:"concurrency,omitempty"` // Two layouts
Jobs map[string]Job `yaml:"jobs,omitempty"`
}
type On struct {
Data map[string]yaml.Node `yaml:"-"`
WorkflowDispatch *WorkflowDispatch `yaml:"workflow_dispatch,omitempty"`
WorkflowCall *WorkflowCall `yaml:"workflow_call,omitempty"`
Schedule []Cron `yaml:"schedule,omitempty"`
}
type Cron struct {
Cron string `yaml:"cron,omitempty"`
}
func (a *On) UnmarshalYAML(node *yaml.Node) error {
switch node.Kind {
case yaml.ScalarNode:
var s string
if err := node.Decode(&s); err != nil {
return err
}
a.Data = map[string]yaml.Node{}
a.Data[s] = yaml.Node{}
case yaml.SequenceNode:
var s []string
if err := node.Decode(&s); err != nil {
return err
}
a.Data = map[string]yaml.Node{}
for _, v := range s {
a.Data[v] = yaml.Node{}
}
default:
if err := node.Decode(&a.Data); err != nil {
return err
}
type OnObj On
if err := node.Decode((*OnObj)(a)); err != nil {
return err
}
}
return nil
}
func (a *On) MarshalYAML() (interface{}, error) {
return a.Data, nil
}
var (
_ yaml.Unmarshaler = &On{}
_ yaml.Marshaler = &On{}
_ yaml.Unmarshaler = &Concurrency{}
_ yaml.Unmarshaler = &RunsOn{}
_ yaml.Unmarshaler = &ImplicitStringArray{}
_ yaml.Unmarshaler = &Environment{}
)
type WorkflowDispatch struct {
Inputs map[string]Input `yaml:"inputs,omitempty"`
}
type Input struct {
Description string `yaml:"description,omitempty"`
Type string `yaml:"type,omitempty"`
Default string `yaml:"default,omitempty"`
Required bool `yaml:"required,omitempty"`
}
type WorkflowCall struct {
Inputs map[string]Input `yaml:"inputs,omitempty"`
Secrets map[string]Secret `yaml:"secrets,omitempty"`
Outputs map[string]Output `yaml:"outputs,omitempty"`
}
type Secret struct {
Description string `yaml:"description,omitempty"`
Required bool `yaml:"required,omitempty"`
}
type Output struct {
Description string `yaml:"description,omitempty"`
Value yaml.Node `yaml:"value,omitempty"`
}
type Job struct {
Needs ImplicitStringArray `yaml:"needs,omitempty"`
Permissions *Permissions `yaml:"permissions,omitempty"`
Strategy yaml.Node `yaml:"strategy,omitempty"`
Name yaml.Node `yaml:"name,omitempty"`
Concurrency yaml.Node `yaml:"concurrency,omitempty"`
// Reusable Workflow
Uses yaml.Node `yaml:"uses,omitempty"`
With yaml.Node `yaml:"with,omitempty"`
Secrets yaml.Node `yaml:"secrets,omitempty"`
// Runner Job
RunsOn yaml.Node `yaml:"runs-on,omitempty"`
Defaults yaml.Node `yaml:"defaults,omitempty"`
TimeoutMinutes yaml.Node `yaml:"timeout-minutes,omitempty"`
Container yaml.Node `yaml:"container,omitempty"`
Services yaml.Node `yaml:"services,omitempty"`
Env yaml.Node `yaml:"env,omitempty"`
Steps []yaml.Node `yaml:"steps,omitempty"`
Outputs yaml.Node `yaml:"outputs,omitempty"`
}
type ImplicitStringArray []string
func (a *ImplicitStringArray) UnmarshalYAML(node *yaml.Node) error {
if node.Kind == yaml.ScalarNode {
var s string
if err := node.Decode(&s); err != nil {
return err
}
*a = []string{s}
return nil
}
return node.Decode((*[]string)(a))
}
type Permissions map[string]string
func (p *Permissions) UnmarshalYAML(node *yaml.Node) error {
if node.Kind == yaml.ScalarNode {
var s string
if err := node.Decode(&s); err != nil {
return err
}
var perm string
switch s {
case "read-all":
perm = "read"
case "write-all":
perm = "write"
default:
return nil
}
(*p)["actions"] = perm
(*p)["attestations"] = perm
(*p)["contents"] = perm
(*p)["checks"] = perm
(*p)["deployments"] = perm
(*p)["discussions"] = perm
(*p)["id-token"] = perm
(*p)["issues"] = perm
(*p)["models"] = perm
(*p)["packages"] = perm
(*p)["pages"] = perm
(*p)["pull-requests"] = perm
(*p)["repository-projects"] = perm
(*p)["security-events"] = perm
(*p)["statuses"] = perm
return nil
}
return node.Decode((*map[string]string)(p))
}
type Strategy struct {
Matrix map[string][]yaml.Node `yaml:"matrix"`
MaxParallel float64 `yaml:"max-parallel"`
FailFast bool `yaml:"fail-fast"`
}
type Concurrency struct {
Group string `yaml:"group"`
CancelInProgress bool `yaml:"cancel-in-progress"`
}
func (c *Concurrency) UnmarshalYAML(node *yaml.Node) error {
if node.Kind == yaml.ScalarNode {
var s string
if err := node.Decode(&s); err != nil {
return err
}
c.Group = s
return nil
}
type ConcurrencyObj Concurrency
return node.Decode((*ConcurrencyObj)(c))
}
type Environment struct {
Name string `yaml:"name"`
URL yaml.Node `yaml:"url"`
}
func (e *Environment) UnmarshalYAML(node *yaml.Node) error {
if node.Kind == yaml.ScalarNode {
var s string
if err := node.Decode(&s); err != nil {
return err
}
e.Name = s
return nil
}
type EnvironmentObj Environment
return node.Decode((*EnvironmentObj)(e))
}
type RunsOn struct {
Labels []string `yaml:"labels"`
Group string `yaml:"group,omitempty"`
}
func (a *RunsOn) UnmarshalYAML(node *yaml.Node) error {
if node.Kind == yaml.ScalarNode {
var s string
if err := node.Decode(&s); err != nil {
return err
}
a.Labels = []string{s}
return nil
}
if node.Kind == yaml.SequenceNode {
var s []string
if err := node.Decode(&s); err != nil {
return err
}
a.Labels = s
return nil
}
type RunsOnObj RunsOn
return node.Decode((*RunsOnObj)(a))
}

View File

@@ -0,0 +1,141 @@
package model
import (
"context"
"testing"
v2 "github.com/actions-oss/act-cli/internal/eval/v2"
"github.com/actions-oss/act-cli/internal/templateeval"
"github.com/actions-oss/act-cli/pkg/schema"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gopkg.in/yaml.v3"
)
func TestParseWorkflow(t *testing.T) {
ee := &templateeval.ExpressionEvaluator{
EvaluationContext: v2.EvaluationContext{
Variables: v2.CaseInsensitiveObject[any]{},
Functions: v2.GetFunctions(),
},
}
var node yaml.Node
err := yaml.Unmarshal([]byte(`
on: push
run-name: ${{ fromjson('{}') }}
jobs:
_:
name: ${{ github.ref_name }}
steps:
- run: echo Hello World
env:
TAG: ${{ env.global }}
`), &node)
require.NoError(t, err)
err = ee.EvaluateYamlNode(context.Background(), node.Content[0], &schema.Node{
Definition: "workflow-root",
Schema: schema.GetWorkflowSchema(),
})
require.NoError(t, err)
ee.RestrictEval = true
ee.EvaluationContext.Variables = v2.CaseInsensitiveObject[any]{
"github": v2.CaseInsensitiveObject[any]{
"ref_name": "self",
},
"vars": v2.CaseInsensitiveObject[any]{},
"inputs": v2.CaseInsensitiveObject[any]{},
}
err = ee.EvaluateYamlNode(context.Background(), node.Content[0], &schema.Node{
Definition: "workflow-root",
Schema: schema.GetWorkflowSchema(),
})
require.Error(t, err)
var myw Workflow
require.NoError(t, node.Decode(&myw))
}
func TestParseWorkflowCall(t *testing.T) {
ee := &templateeval.ExpressionEvaluator{
EvaluationContext: v2.EvaluationContext{
Variables: v2.CaseInsensitiveObject[any]{},
Functions: v2.GetFunctions(),
},
}
var node yaml.Node
// jobs.test.outputs.test
err := yaml.Unmarshal([]byte(`
on:
workflow_call:
outputs:
test:
value: ${{ jobs.test.outputs.test }} # tojson(vars.raw)
run-name: ${{ github.ref_name }}
jobs:
_:
runs-on: ubuntu-latest
name: ${{ github.ref_name }}
steps:
- run: echo Hello World
env:
TAG: ${{ env.global }}
`), &node)
require.NoError(t, err)
require.NoError(t, resolveAliases(node.Content[0]))
require.NoError(t, (&schema.Node{
Definition: "workflow-root",
Schema: schema.GetWorkflowSchema(),
}).UnmarshalYAML(node.Content[0]))
err = ee.EvaluateYamlNode(context.Background(), node.Content[0], &schema.Node{
Definition: "workflow-root",
Schema: schema.GetWorkflowSchema(),
})
require.NoError(t, err)
var raw any
err = node.Content[0].Decode(&raw)
assert.NoError(t, err)
ee.RestrictEval = true
ee.EvaluationContext.Variables = v2.CaseInsensitiveObject[any]{
"github": v2.CaseInsensitiveObject[any]{
"ref_name": "self",
},
"vars": v2.CaseInsensitiveObject[any]{
"raw": raw,
},
"inputs": v2.CaseInsensitiveObject[any]{},
"jobs": v2.CaseInsensitiveObject[any]{
"test": v2.CaseInsensitiveObject[any]{
"outputs": v2.CaseInsensitiveObject[any]{
"test": "Hello World",
},
},
},
}
err = ee.EvaluateYamlNode(context.Background(), node.Content[0], &schema.Node{
RestrictEval: true,
Definition: "workflow-root",
Schema: schema.GetWorkflowSchema(),
})
require.NoError(t, err)
var myw Workflow
require.NoError(t, node.Decode(&myw))
workflowCall := myw.On.WorkflowCall
if workflowCall != nil {
for _, out := range workflowCall.Outputs {
err = ee.EvaluateYamlNode(context.Background(), &out.Value, &schema.Node{
RestrictEval: true,
Definition: "workflow-output-context",
Schema: schema.GetWorkflowSchema(),
})
require.NoError(t, err)
require.Equal(t, "Hello World", out.Value.Value)
}
}
out, err := yaml.Marshal(&myw)
assert.NoError(t, err)
assert.NotEmpty(t, out)
}