1
0
mirror of https://gitea.com/gitea/act synced 2026-05-01 01:27:48 +02:00

Merge branch 'main' into ci-enhancements

This commit is contained in:
silverwind
2026-04-20 21:27:40 +00:00
4 changed files with 351 additions and 3 deletions

View File

@@ -21,6 +21,42 @@ Tags:
- `nektos/v0.10.1` -> `v0.1001.*`, not ~~`v0.101.*`~~
- `nektos/v0.3.100` -> not ~~`v0.3100.*`~~, I don't think it's really going to happen, if it does, we can find a way to handle it.
## Gitea-specific changes
### Matrix strategy: scalar values and template expressions
This fork extends the matrix strategy parser [workflow.go](pkg/model/workflow.go) to accept
bare scalar YAML values in addition to arrays, and to handle unevaluated template
expressions gracefully.
**Scalar wrapping**
A matrix key written without brackets is automatically promoted to a
single-element array:
```yaml
strategy:
matrix:
go-version: 1.21 # treated as [1.21]
os: ubuntu-latest # treated as ["ubuntu-latest"]
```
> [!NOTE]
> Previously such a value caused the matrix decoding to fail and the job ran *without*
> a matrix context (`matrix.*` variables were undefined). Now the job runs *one* matrix iteration with the scalar as the
> value. Existing workflows that used scalars by accident may see a difference in which matrix variables are populated.
**Template expression support (`${{ fromJSON(...) }}`)**
Template expressions in the matrix are resolved by `EvaluateYamlNode`
(`pkg/runner/runner.go`) *before* `Matrix()` is called. When successful, the
expression is replaced by a proper YAML sequence and the matrix expands
normally.
If the expression cannot be resolved (e.g., the necessary context is not yet
available), the literal string is wrapped as a one-element array, and the job
runs once with the unexpanded string as the matrix value (graceful degradation).
---
![act-logo](https://raw.githubusercontent.com/wiki/nektos/act/img/logo-150.png)

View File

@@ -377,16 +377,61 @@ func (j *Job) Environment() map[string]string {
return environment(j.Env)
}
// Matrix decodes RawMatrix YAML node
// normalizeMatrixValue converts a matrix value to []interface{}.
// Arrays pass through unchanged; scalars are wrapped in a single-element array.
// Unevaluated template expressions are wrapped as a fallback — proper resolution
// happens via EvaluateYamlNode before Matrix() is called. Nested maps are rejected.
func normalizeMatrixValue(key string, val any) ([]any, error) {
switch t := val.(type) {
case []any:
// Already an array - use as-is
return t, nil
case string, int, float64, bool, nil:
// Valid scalar types that can appear in YAML
// These can be unevaluated template expressions (strings) or literal values
return []any{t}, nil
case map[string]any:
// Nested map indicates misconfiguration - likely user error
return nil, fmt.Errorf("matrix key %q has invalid nested object value - expected scalar or array, got map", key)
default:
// Unknown types might indicate parsing issues
log.Warnf("matrix key %q has unexpected type %T, wrapping as single value", key, t)
return []any{t}, nil
}
}
// Matrix decodes the RawMatrix YAML node into a map[string][]interface{}.
// Scalar values are wrapped into single-element arrays automatically.
// Template expressions are resolved by EvaluateYamlNode before this method is
// called; if unresolved, the literal string is wrapped as a one-element fallback.
func (j *Job) Matrix() map[string][]any {
if j.Strategy.RawMatrix.Kind == yaml.MappingNode {
if j.Strategy == nil || j.Strategy.RawMatrix.Kind != yaml.MappingNode {
return nil
}
// Decode to flexible map first so that scalar values don't cause a type error.
var flexVal map[string]any
err := j.Strategy.RawMatrix.Decode(&flexVal)
if err != nil {
// Fall back to the strict array-only format for backward compatibility.
var val map[string][]any
if !decodeNode(j.Strategy.RawMatrix, &val) {
return nil
}
return val
}
return nil
// Convert flexible format to expected format with validation
val := make(map[string][]any)
for k, v := range flexVal {
normalized, err := normalizeMatrixValue(k, v)
if err != nil {
log.Errorf("matrix validation error: %v", err)
return nil
}
val[k] = normalized
}
return val
}
// GetMatrixes returns the matrix cross product

View File

@@ -5,6 +5,7 @@ import (
"testing"
"github.com/stretchr/testify/assert"
"go.yaml.in/yaml/v4"
)
func TestReadWorkflow_ScheduleEvent(t *testing.T) {
@@ -637,3 +638,267 @@ func TestStep_UsesHash(t *testing.T) {
})
}
}
func TestNormalizeMatrixValue(t *testing.T) {
tests := []struct {
name string
key string
value interface{}
wantResult []interface{}
wantErr bool
errMsg string
}{
{
name: "array_values_pass_through",
key: "version",
value: []interface{}{"1.0", "2.0", "3.0"},
wantResult: []interface{}{"1.0", "2.0", "3.0"},
wantErr: false,
},
{
name: "string_scalar_wrapped",
key: "os",
value: "ubuntu-latest",
wantResult: []interface{}{"ubuntu-latest"},
wantErr: false,
},
{
name: "template_expression_wrapped",
key: "version",
value: "${{ fromJson(needs.setup.outputs.versions) }}",
wantResult: []interface{}{"${{ fromJson(needs.setup.outputs.versions) }}"},
wantErr: false,
},
{
name: "integer_scalar_wrapped",
key: "count",
value: 42,
wantResult: []interface{}{42},
wantErr: false,
},
{
name: "float_scalar_wrapped",
key: "factor",
value: 3.14,
wantResult: []interface{}{3.14},
wantErr: false,
},
{
name: "bool_scalar_wrapped",
key: "enabled",
value: true,
wantResult: []interface{}{true},
wantErr: false,
},
{
name: "nil_scalar_wrapped",
key: "optional",
value: nil,
wantResult: []interface{}{nil},
wantErr: false,
},
{
name: "nested_map_returns_error",
key: "config",
value: map[string]interface{}{"nested": "value"},
wantErr: true,
errMsg: "has invalid nested object value",
},
{
name: "empty_array_passes_through",
key: "empty",
value: []interface{}{},
wantResult: []interface{}{},
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := normalizeMatrixValue(tt.key, tt.value)
if tt.wantErr {
assert.Error(t, err, "should return error")
if tt.errMsg != "" {
assert.Contains(t, err.Error(), tt.errMsg)
}
} else {
assert.NoError(t, err, "should not return error")
assert.Equal(t, tt.wantResult, result, "result should match expected")
}
})
}
}
func TestJobMatrix(t *testing.T) {
tests := []struct {
name string
yaml string
wantErr bool
wantLen int
checkFn func(*testing.T, map[string][]interface{})
}{
{
name: "matrix_with_arrays",
yaml: `
name: test
on: push
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
os: [ubuntu-latest, windows-latest]
version: [1.18, 1.19]
steps:
- run: echo test
`,
wantErr: false,
wantLen: 2,
checkFn: func(t *testing.T, m map[string][]interface{}) {
assert.Equal(t, []interface{}{"ubuntu-latest", "windows-latest"}, m["os"])
assert.Equal(t, []interface{}{1.18, 1.19}, m["version"])
},
},
{
name: "matrix_with_scalar_values",
yaml: `
name: test
on: push
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
os: ubuntu-latest
version: 1.19
steps:
- run: echo test
`,
wantErr: false,
wantLen: 2,
checkFn: func(t *testing.T, m map[string][]interface{}) {
assert.Equal(t, []interface{}{"ubuntu-latest"}, m["os"])
assert.Equal(t, []interface{}{1.19}, m["version"])
},
},
{
name: "matrix_with_template_expression",
yaml: `
name: test
on: push
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
versions: ${{ fromJson(needs.setup.outputs.versions) }}
steps:
- run: echo test
`,
wantErr: false,
wantLen: 1,
checkFn: func(t *testing.T, m map[string][]interface{}) {
assert.Equal(t, []interface{}{"${{ fromJson(needs.setup.outputs.versions) }}"}, m["versions"])
},
},
{
name: "matrix_mixed_arrays_and_scalars",
yaml: `
name: test
on: push
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
os: [ubuntu-latest, windows-latest]
version: 1.19
node: [14, 16]
steps:
- run: echo test
`,
wantErr: false,
wantLen: 3,
checkFn: func(t *testing.T, m map[string][]interface{}) {
assert.Equal(t, []interface{}{"ubuntu-latest", "windows-latest"}, m["os"])
assert.Equal(t, []interface{}{1.19}, m["version"])
assert.Equal(t, []interface{}{14, 16}, m["node"])
},
},
{
name: "empty_matrix",
yaml: `
name: test
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo test
`,
wantErr: false,
wantLen: 0,
checkFn: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
workflow, err := ReadWorkflow(strings.NewReader(tt.yaml))
assert.NoError(t, err, "reading workflow should succeed")
job := workflow.GetJob("build")
if job == nil {
// For empty matrix test
if tt.wantLen == 0 {
return
}
t.Fatal("job not found")
}
matrix := job.Matrix()
if tt.wantErr {
assert.Nil(t, matrix, "matrix should be nil on error")
} else {
if tt.wantLen == 0 {
assert.Nil(t, matrix, "matrix should be nil for jobs without strategy")
} else {
assert.NotNil(t, matrix, "matrix should not be nil")
assert.Equal(t, tt.wantLen, len(matrix), "matrix should have expected number of keys")
if tt.checkFn != nil {
tt.checkFn(t, matrix)
}
}
}
})
}
}
func TestJobMatrixValidation(t *testing.T) {
// This test verifies that invalid nested map values are caught
t.Run("matrix_with_nested_map_fails", func(t *testing.T) {
// Manually construct a job with a problematic matrix containing a nested map
job := &Job{
Strategy: &Strategy{
RawMatrix: yaml.Node{
Kind: yaml.MappingNode,
Content: []*yaml.Node{
{Kind: yaml.ScalarNode, Tag: "!!str", Value: "config"},
{Kind: yaml.MappingNode, Tag: "!!map", Content: []*yaml.Node{
{Kind: yaml.ScalarNode, Tag: "!!str", Value: "nested"},
{Kind: yaml.ScalarNode, Tag: "!!str", Value: "value"},
}},
},
},
},
}
// Attempt to get matrix
matrix := job.Matrix()
// Should return nil due to validation error
assert.Nil(t, matrix, "matrix with nested map should return nil")
})
}

View File

@@ -179,6 +179,8 @@ func (runner *runnerImpl) NewPlanExecutor(plan *model.Plan) common.Executor {
log.Debugf("Job.Strategy.RawMatrix: %v", job.Strategy.RawMatrix)
strategyRc := runner.newRunContext(ctx, run, nil)
// Resolve template expressions in the matrix node before Matrix() is called.
// On failure the literal string is kept and normalizeMatrixValue wraps it as a fallback.
if err := strategyRc.NewExpressionEvaluator(ctx).EvaluateYamlNode(ctx, &job.Strategy.RawMatrix); err != nil {
log.Errorf("Error while evaluating matrix: %v", err)
}