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:
36
README.md
36
README.md
@@ -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).
|
||||
|
||||
---
|
||||
|
||||

|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user