diff --git a/README.md b/README.md index 32e6a7da..7356eb78 100644 --- a/README.md +++ b/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). + --- ![act-logo](https://raw.githubusercontent.com/wiki/nektos/act/img/logo-150.png) diff --git a/pkg/model/workflow.go b/pkg/model/workflow.go index 62326702..cbcb0f29 100644 --- a/pkg/model/workflow.go +++ b/pkg/model/workflow.go @@ -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 diff --git a/pkg/model/workflow_test.go b/pkg/model/workflow_test.go index ca4212c7..c57859e3 100644 --- a/pkg/model/workflow_test.go +++ b/pkg/model/workflow_test.go @@ -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") + }) +} diff --git a/pkg/runner/runner.go b/pkg/runner/runner.go index 53cd9c97..2a4bfd0a 100644 --- a/pkg/runner/runner.go +++ b/pkg/runner/runner.go @@ -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) }