From 66bb0dae15c8ae8a4946c0fa90efdeb859f1b039 Mon Sep 17 00:00:00 2001 From: Gunju Kim Date: Wed, 12 Aug 2026 13:14:41 +0000 Subject: [PATCH] Support matrix include and exclude expansions --- api/v1alpha1/workflowjob_types.go | 5 +- docs/reference.md | 63 ++- .../controller/workflowrun_controller_test.go | 6 +- .../crds/actions.kelos.dev_workflowjobs.yaml | 5 +- internal/workflow/workflow.go | 378 ++++++++++++++++-- internal/workflow/workflow_test.go | 113 ++++++ 6 files changed, 507 insertions(+), 63 deletions(-) diff --git a/api/v1alpha1/workflowjob_types.go b/api/v1alpha1/workflowjob_types.go index 4b52315..6e11fcf 100644 --- a/api/v1alpha1/workflowjob_types.go +++ b/api/v1alpha1/workflowjob_types.go @@ -65,8 +65,9 @@ type WorkflowJobMatrix struct { // +required LogicalJobID string `json:"logicalJobID"` - // Values contains the scalar matrix axis values for this combination. - // Values are represented using their workflow string form. + // Values contains the scalar matrix values for this combination, including + // values added by an include transformation. Values are represented using + // their workflow string form. // +kubebuilder:validation:MinProperties=1 // +kubebuilder:validation:MaxProperties=100 // +kubebuilder:validation:XValidation:rule="self.all(k, size(k) > 0 && size(k) <= 256 && size(self[k]) <= 1024)",message="matrix keys must contain 1 to 256 characters and values must contain at most 1024 characters" diff --git a/docs/reference.md b/docs/reference.md index 598ffac..c5f89cf 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -148,14 +148,41 @@ trusted. See [`config/samples/actions_v1alpha1_docker_runner.yaml`](../config/samples/actions_v1alpha1_docker_runner.yaml) for a Docker-enabled Runner. -A job strategy may define scalar matrix axes and an optional positive -`max-parallel`. The controller creates one `WorkflowJob` per Cartesian-product -combination in deterministic order. Each child has a unique `spec.jobID`, while -`spec.matrix.logicalJobID`, `values`, and `maxParallel` preserve its logical -identity and scheduling group. `max-parallel` limits active children in that -group independently of the number of matching Runners. A failed matrix child -makes the completed WorkflowRun fail. Strategy `fail-fast` is not supported; -remaining combinations continue to completion after a child fails. +A job strategy may define scalar matrix axes, `include` and `exclude` +transformations, and an optional positive `max-parallel`. `exclude` mappings +remove every Cartesian-product combination that partially matches their values. +The controller then applies `include` mappings in declaration order. An include +augments every compatible original combination and may overwrite values added by +an earlier include, but it does not overwrite original axis values. An include +that matches no original combination is appended as a standalone combination. +A matrix may consist only of `include` mappings. + +```yaml +strategy: + max-parallel: 2 + matrix: + os: [linux, darwin] + version: [1, 2] + exclude: + - os: darwin + version: 1 + include: + - os: linux + coverage: true + - os: windows + version: 2 +``` + +The controller creates one `WorkflowJob` per transformed combination. Axis and +value declaration order determines the order of Cartesian-product combinations, +followed by standalone includes in declaration order. Each child has a unique +`spec.jobID`, while `spec.matrix.logicalJobID`, `values`, and `maxParallel` +preserve its logical identity and scheduling group. Every scalar axis or include +value is available through the `matrix` expression context and persisted in +`spec.matrix.values`. `max-parallel` limits active children in that group +independently of the number of matching Runners. A failed matrix child makes the +completed WorkflowRun fail. Strategy `fail-fast` is not supported; remaining +combinations continue to completion after a child fails. ### Conditions @@ -424,10 +451,13 @@ Workflow definitions must satisfy these limits: 65,535 characters. - A `schedule` trigger may contain at most 20 cron expressions, each at most 256 characters. -- A matrix may define at most 100 axes and expand one logical job into at most - 256 jobs. A workflow may expand to at most 1,000 jobs in total. -- Matrix axis names contain at most 256 characters, and scalar matrix values - contain at most 1,024 characters. +- A matrix may define at most 100 axes. Its `include` and `exclude` lists may + each contain at most 256 mappings, with at most 100 values per mapping. The + final transformed matrix must contain 1 to 256 jobs, and a workflow may expand + to at most 1,000 jobs in total. +- Matrix keys contain at most 256 characters, scalar matrix values contain at + most 1,024 characters, and each transformed combination contains at most 100 + values. - A job may contain at most 100 steps and 100,000 bytes of aggregate planned content. - A completed job result may contain at most 100 outputs and 4 KiB of encoded @@ -470,11 +500,10 @@ than one plan version must emit the result version assigned to that plan, not always the latest result version supported by the runner binary. Docker and local actions, private cross-repository action authentication, job -dependencies, matrix `include` and `exclude`, strategy `fail-fast`, service -containers, repository secret and variable sources, caches, and artifacts are -not supported. Expressions outside the documented fields and runtime contexts -are rejected during planning or execution and are never interpreted as literal -values. +dependencies, strategy `fail-fast`, service containers, repository secret and +variable sources, caches, and artifacts are not supported. Expressions outside +the documented fields and runtime contexts are rejected during planning or +execution and are never interpreted as literal values. `WorkflowJob` resources are not retried or reassigned when a Runner is removed. Native Jobs and their Pod logs are deleted one hour after completion. Completed WorkflowRuns are retained indefinitely unless `spec.ttlSecondsAfterFinished` is diff --git a/internal/controller/workflowrun_controller_test.go b/internal/controller/workflowrun_controller_test.go index a9283e1..f37f636 100644 --- a/internal/controller/workflowrun_controller_test.go +++ b/internal/controller/workflowrun_controller_test.go @@ -246,7 +246,7 @@ func TestPlanWorkflowJobsExpandsArchitectureMatrix(t *testing.T) { Revision: actionsv1alpha1.GitRevision{SHA: strings.Repeat("a", 40), Ref: "refs/heads/main"}, }, }}} - definition, err := workflow.Parse([]byte("name: Release\non: push\njobs:\n build-images:\n strategy:\n max-parallel: 1\n matrix:\n arch: [amd64, arm64]\n runs-on: ${{ matrix.arch == 'arm64' && 'ubuntu-24.04-arm' || 'ubuntu-latest' }}\n outputs:\n image: ${{ matrix.arch }}-${{ steps.build.outputs.image }}\n steps:\n - id: build\n run: make image IMAGE_PLATFORMS=linux/${{ matrix.arch }}\n")) + definition, err := workflow.Parse([]byte("name: Release\non: push\njobs:\n build-images:\n strategy:\n max-parallel: 1\n matrix:\n arch: [amd64, arm64]\n include:\n - variant: release\n runs-on: ${{ matrix.arch == 'arm64' && 'ubuntu-24.04-arm' || 'ubuntu-latest' }}\n outputs:\n image: ${{ matrix.arch }}-${{ matrix.variant }}-${{ steps.build.outputs.image }}\n steps:\n - id: build\n run: make image IMAGE_PLATFORMS=linux/${{ matrix.arch }}\n")) if err != nil { t.Fatal(err) } @@ -260,7 +260,7 @@ func TestPlanWorkflowJobsExpandsArchitectureMatrix(t *testing.T) { } for index, arch := range []string{"amd64", "arm64"} { job := planned[index] - if job.id != fmt.Sprintf("build-images-matrix-%d", index+1) || job.matrix == nil || job.matrix.LogicalJobID != "build-images" || job.matrix.Values["arch"] != arch || job.matrix.MaxParallel != 1 || job.resultVersion != jobResultVersion { + if job.id != fmt.Sprintf("build-images-matrix-%d", index+1) || job.matrix == nil || job.matrix.LogicalJobID != "build-images" || job.matrix.Values["arch"] != arch || job.matrix.Values["variant"] != "release" || job.matrix.MaxParallel != 1 || job.resultVersion != jobResultVersion { t.Errorf("planned job %d = %#v", index, job) } wantRunner := "ubuntu-latest" @@ -274,7 +274,7 @@ func TestPlanWorkflowJobsExpandsArchitectureMatrix(t *testing.T) { if err := json.Unmarshal([]byte(job.plan), plan); err != nil { t.Fatal(err) } - if plan.JobID != "build-images" || plan.Matrix["arch"] != arch || plan.Outputs["image"] != "${{ matrix.arch }}-${{ steps.build.outputs.image }}" { + if plan.JobID != "build-images" || plan.Matrix["arch"] != arch || plan.Matrix["variant"] != "release" || plan.Outputs["image"] != "${{ matrix.arch }}-${{ matrix.variant }}-${{ steps.build.outputs.image }}" { t.Errorf("plan for %s = %#v", arch, plan) } } diff --git a/internal/manifests/charts/open-actions/templates/crds/actions.kelos.dev_workflowjobs.yaml b/internal/manifests/charts/open-actions/templates/crds/actions.kelos.dev_workflowjobs.yaml index 57f6c2f..d01a9bd 100644 --- a/internal/manifests/charts/open-actions/templates/crds/actions.kelos.dev_workflowjobs.yaml +++ b/internal/manifests/charts/open-actions/templates/crds/actions.kelos.dev_workflowjobs.yaml @@ -102,8 +102,9 @@ spec: additionalProperties: type: string description: |- - Values contains the scalar matrix axis values for this combination. - Values are represented using their workflow string form. + Values contains the scalar matrix values for this combination, including + values added by an include transformation. Values are represented using + their workflow string form. maxProperties: 100 minProperties: 1 type: object diff --git a/internal/workflow/workflow.go b/internal/workflow/workflow.go index d5283a1..371e071 100644 --- a/internal/workflow/workflow.go +++ b/internal/workflow/workflow.go @@ -134,10 +134,13 @@ type Job struct { } type Strategy struct { - Matrix map[string][]any - MaxParallel int32 - configured bool - maxParallelSet bool + Matrix map[string][]any + Include []map[string]any + Exclude []map[string]any + MaxParallel int32 + matrixAxisOrder []string + configured bool + maxParallelSet bool } type Step struct { @@ -425,13 +428,12 @@ func validateStrategy(id string, strategy Strategy) error { if !strategy.configured { return nil } - if len(strategy.Matrix) == 0 { - return fmt.Errorf("job %q strategy must define a matrix", id) + if len(strategy.Matrix) == 0 && len(strategy.Include) == 0 { + return fmt.Errorf("job %q strategy matrix must define at least one axis or include combination", id) } if len(strategy.Matrix) > maxMapEntries { return fmt.Errorf("job %q matrix defines %d axes; maximum is %d", id, len(strategy.Matrix), maxMapEntries) } - combinations := 1 for name, values := range strategy.Matrix { if name == "" || utf8.RuneCountInString(name) > maxMapKeyLength { return fmt.Errorf("job %q matrix axis %q must contain 1 to %d characters", id, name, maxMapKeyLength) @@ -439,53 +441,306 @@ func validateStrategy(id string, strategy Strategy) error { if len(values) == 0 { return fmt.Errorf("job %q matrix axis %q must define at least one value", id, name) } - if len(values) > maxMatrixJobs || combinations > maxMatrixJobs/len(values) { - return fmt.Errorf("job %q matrix expands to more than %d jobs", id, maxMatrixJobs) - } - combinations *= len(values) for _, value := range values { - scalar, ok := scalarString(value) - if !ok { - return fmt.Errorf("job %q matrix axis %q values must be scalars", id, name) - } - if utf8.RuneCountInString(scalar) > maxMatrixValueLength { - return fmt.Errorf("job %q matrix axis %q value exceeds %d characters", id, name, maxMatrixValueLength) + if err := validateMatrixValue(fmt.Sprintf("job %q matrix axis %q", id, name), value); err != nil { + return err } } } + if err := validateMatrixMappings(id, "include", strategy.Include); err != nil { + return err + } + if err := validateMatrixMappings(id, "exclude", strategy.Exclude); err != nil { + return err + } if strategy.maxParallelSet && strategy.MaxParallel < 1 { return fmt.Errorf("job %q strategy max-parallel must be greater than zero", id) } + combinations, exceeded := matrixCombinations(strategy, maxMatrixJobs) + if exceeded { + return fmt.Errorf("job %q matrix expands to more than %d jobs", id, maxMatrixJobs) + } + if len(combinations) == 0 { + return fmt.Errorf("job %q matrix must expand to at least one job", id) + } + for _, combination := range combinations { + if len(combination) > maxMapEntries { + return fmt.Errorf("job %q matrix combination defines %d values; maximum is %d", id, len(combination), maxMapEntries) + } + } return nil } -// MatrixCombinations returns matrix values in stable axis and value order. +func validateMatrixMappings(id, transformation string, mappings []map[string]any) error { + if len(mappings) > maxMatrixJobs { + return fmt.Errorf("job %q matrix %s defines %d combinations; maximum is %d", id, transformation, len(mappings), maxMatrixJobs) + } + for index, mapping := range mappings { + field := fmt.Sprintf("job %q matrix %s combination %d", id, transformation, index+1) + if len(mapping) == 0 { + return fmt.Errorf("%s must define at least one value", field) + } + if len(mapping) > maxMapEntries { + return fmt.Errorf("%s defines %d values; maximum is %d", field, len(mapping), maxMapEntries) + } + for name, value := range mapping { + if name == "" || utf8.RuneCountInString(name) > maxMapKeyLength { + return fmt.Errorf("%s key %q must contain 1 to %d characters", field, name, maxMapKeyLength) + } + if err := validateMatrixValue(fmt.Sprintf("%s value %q", field, name), value); err != nil { + return err + } + } + } + return nil +} + +func validateMatrixValue(field string, value any) error { + scalar, ok := scalarString(value) + if !ok { + return fmt.Errorf("%s must be a scalar", field) + } + if utf8.RuneCountInString(scalar) > maxMatrixValueLength { + return fmt.Errorf("%s exceeds %d characters", field, maxMatrixValueLength) + } + return nil +} + +// MatrixCombinations returns transformed matrix values in deterministic order. func MatrixCombinations(strategy Strategy) []map[string]any { + combinations, _ := matrixCombinations(strategy, maxMatrixJobs) + return combinations +} + +func matrixCombinations(strategy Strategy, limit int) ([]map[string]any, bool) { if len(strategy.Matrix) == 0 { - return nil + if len(strategy.Include) > limit { + return nil, true + } + combinations := make([]map[string]any, 0, len(strategy.Include)) + for _, included := range strategy.Include { + combinations = append(combinations, cloneMatrixValues(included)) + } + return combinations, false } - axisNames := make([]string, 0, len(strategy.Matrix)) - for name := range strategy.Matrix { - axisNames = append(axisNames, name) - } - sort.Strings(axisNames) - combinations := []map[string]any{{}} - for _, name := range axisNames { - values := strategy.Matrix[name] - next := make([]map[string]any, 0, len(combinations)*len(values)) - for _, combination := range combinations { - for _, value := range values { - item := make(map[string]any, len(combination)+1) - for existingName, existingValue := range combination { - item[existingName] = existingValue + + base, exceeded := baseMatrixCombinations(strategy, limit) + if exceeded { + return nil, true + } + combinations := make([]map[string]any, len(base)) + for index := range base { + combinations[index] = cloneMatrixValues(base[index].values) + } + for _, included := range strategy.Include { + applied := false + for index := range base { + if matrixIncludeMatches(base[index].values, included) { + for name, value := range included { + combinations[index][name] = value } - item[name] = value - next = append(next, item) + applied = true + } + } + if !applied { + if len(combinations) == limit { + return nil, true } + combinations = append(combinations, cloneMatrixValues(included)) } - combinations = next } - return combinations + return combinations, false +} + +type matrixCombination struct { + values map[string]any + indices []int +} + +func baseMatrixCombinations(strategy Strategy, limit int) ([]matrixCombination, bool) { + axisNames := matrixAxisNames(strategy) + axisPositions := make(map[string]int, len(axisNames)) + for index, name := range axisNames { + axisPositions[name] = index + } + + exclusions := make([]map[string]any, 0, len(strategy.Exclude)) + for _, excluded := range strategy.Exclude { + applicable := true + for name := range excluded { + if _, found := axisPositions[name]; !found { + applicable = false + break + } + } + if applicable { + exclusions = append(exclusions, excluded) + } + } + expansionOrder := matrixExpansionOrder(axisNames, exclusions) + values := make(map[string]any, len(axisNames)) + indices := make([]int, len(axisNames)) + combinations := make([]matrixCombination, 0, min(limit, maxMatrixJobs)) + exceeded := false + + var expandRemaining func(int) + expandRemaining = func(depth int) { + if exceeded { + return + } + if depth == len(expansionOrder) { + if len(combinations) == limit { + exceeded = true + return + } + combinations = append(combinations, matrixCombination{values: cloneMatrixValues(values), indices: append([]int(nil), indices...)}) + return + } + name := expansionOrder[depth] + for valueIndex, value := range strategy.Matrix[name] { + values[name] = value + indices[axisPositions[name]] = valueIndex + expandRemaining(depth + 1) + if exceeded { + return + } + } + delete(values, name) + } + + var expand func(int, []map[string]any) + expand = func(depth int, possibleExclusions []map[string]any) { + if exceeded { + return + } + if len(possibleExclusions) == 0 { + expandRemaining(depth) + return + } + if depth == len(expansionOrder) { + return + } + name := expansionOrder[depth] + for valueIndex, value := range strategy.Matrix[name] { + values[name] = value + indices[axisPositions[name]] = valueIndex + nextExclusions := make([]map[string]any, 0, len(possibleExclusions)) + excluded := false + for _, exclusion := range possibleExclusions { + expected, constrainsAxis := exclusion[name] + if constrainsAxis && !matrixValuesEqual(expected, value) { + continue + } + complete := true + for excludedName := range exclusion { + if _, assigned := values[excludedName]; !assigned { + complete = false + break + } + } + if complete { + excluded = true + break + } + nextExclusions = append(nextExclusions, exclusion) + } + if !excluded { + expand(depth+1, nextExclusions) + } + if exceeded { + return + } + } + delete(values, name) + } + + expand(0, exclusions) + if exceeded { + return nil, true + } + sort.SliceStable(combinations, func(left, right int) bool { + for index := range combinations[left].indices { + if combinations[left].indices[index] != combinations[right].indices[index] { + return combinations[left].indices[index] < combinations[right].indices[index] + } + } + return false + }) + return combinations, false +} + +func matrixAxisNames(strategy Strategy) []string { + if len(strategy.matrixAxisOrder) == len(strategy.Matrix) { + return append([]string(nil), strategy.matrixAxisOrder...) + } + names := make([]string, 0, len(strategy.Matrix)) + for name := range strategy.Matrix { + names = append(names, name) + } + sort.Strings(names) + return names +} + +func matrixExpansionOrder(axisNames []string, exclusions []map[string]any) []string { + order := append([]string(nil), axisNames...) + frequency := make(map[string]int, len(axisNames)) + for _, exclusion := range exclusions { + for name := range exclusion { + frequency[name]++ + } + } + positions := make(map[string]int, len(axisNames)) + for index, name := range axisNames { + positions[name] = index + } + sort.SliceStable(order, func(left, right int) bool { + if frequency[order[left]] != frequency[order[right]] { + return frequency[order[left]] > frequency[order[right]] + } + return positions[order[left]] < positions[order[right]] + }) + return order +} + +func matrixIncludeMatches(original, included map[string]any) bool { + for name, includedValue := range included { + if originalValue, found := original[name]; found && !matrixValuesEqual(originalValue, includedValue) { + return false + } + } + return true +} + +func matrixValuesEqual(left, right any) bool { + leftNumber, leftIsNumber := matrixNumber(left) + rightNumber, rightIsNumber := matrixNumber(right) + if leftIsNumber || rightIsNumber { + return leftIsNumber && rightIsNumber && leftNumber == rightNumber + } + return left == right +} + +func matrixNumber(value any) (float64, bool) { + switch typed := value.(type) { + case int: + return float64(typed), true + case int64: + return float64(typed), true + case uint64: + return float64(typed), true + case float64: + return typed, true + default: + return 0, false + } +} + +func cloneMatrixValues(values map[string]any) map[string]any { + cloned := make(map[string]any, len(values)) + for name, value := range values { + cloned[name] = value + } + return cloned } func validateJobOutputs(jobID string, outputs map[string]any) (int, error) { @@ -1004,8 +1259,31 @@ func (s *Strategy) UnmarshalYAML(node *yaml.Node) error { if err := rejectDuplicateMappingKeys(value, "job strategy matrix"); err != nil { return err } - if err := value.Decode(&s.Matrix); err != nil { - return err + s.Matrix = make(map[string][]any, len(value.Content)/2) + for matrixIndex := 0; matrixIndex < len(value.Content); matrixIndex += 2 { + matrixName := value.Content[matrixIndex].Value + matrixValue := value.Content[matrixIndex+1] + switch matrixName { + case "include": + mappings, err := decodeMatrixMappings(matrixValue, "include") + if err != nil { + return err + } + s.Include = mappings + case "exclude": + mappings, err := decodeMatrixMappings(matrixValue, "exclude") + if err != nil { + return err + } + s.Exclude = mappings + default: + values := []any{} + if err := matrixValue.Decode(&values); err != nil { + return fmt.Errorf("decode matrix axis %q: %w", matrixName, err) + } + s.Matrix[matrixName] = values + s.matrixAxisOrder = append(s.matrixAxisOrder, matrixName) + } } case "max-parallel": s.maxParallelSet = true @@ -1019,6 +1297,28 @@ func (s *Strategy) UnmarshalYAML(node *yaml.Node) error { return nil } +func decodeMatrixMappings(node *yaml.Node, transformation string) ([]map[string]any, error) { + if node.Kind != yaml.SequenceNode { + return nil, fmt.Errorf("job strategy matrix %s must be a list", transformation) + } + mappings := make([]map[string]any, 0, len(node.Content)) + for index, child := range node.Content { + field := fmt.Sprintf("job strategy matrix %s combination %d", transformation, index+1) + if child.Kind != yaml.MappingNode { + return nil, fmt.Errorf("%s must be a mapping", field) + } + if err := rejectDuplicateMappingKeys(child, field); err != nil { + return nil, err + } + mapping := map[string]any{} + if err := child.Decode(&mapping); err != nil { + return nil, fmt.Errorf("decode %s: %w", field, err) + } + mappings = append(mappings, mapping) + } + return mappings, nil +} + func (c *Concurrency) UnmarshalYAML(node *yaml.Node) error { c.configured = true if node.Kind == yaml.ScalarNode { diff --git a/internal/workflow/workflow_test.go b/internal/workflow/workflow_test.go index ca57d0b..b372bb6 100644 --- a/internal/workflow/workflow_test.go +++ b/internal/workflow/workflow_test.go @@ -568,12 +568,125 @@ func TestParseAndExpandMatrixStrategy(t *testing.T) { } } +func TestParseAndExpandIncludeOnlyMatrix(t *testing.T) { + definition, err := Parse([]byte("name: Deploy\non: push\njobs:\n deploy:\n strategy:\n max-parallel: 1\n matrix:\n include:\n - site: production\n datacenter: site-a\n - site: staging\n datacenter: site-b\n runs-on: ubuntu-latest\n steps:\n - run: deploy '${{ matrix.site }}' '${{ matrix.datacenter }}'\n")) + if err != nil { + t.Fatal(err) + } + combinations := MatrixCombinations(definition.Jobs["deploy"].Strategy) + want := []map[string]any{ + {"site": "production", "datacenter": "site-a"}, + {"site": "staging", "datacenter": "site-b"}, + } + if len(combinations) != len(want) { + t.Fatalf("matrix combinations = %d, want %d", len(combinations), len(want)) + } + for index := range want { + if !maps.Equal(combinations[index], want[index]) { + t.Errorf("combination %d = %v, want %v", index, combinations[index], want[index]) + } + } +} + +func TestMatrixIncludesAugmentOriginalCombinationsInOrder(t *testing.T) { + definition, err := Parse([]byte("name: Test\non: push\njobs:\n test:\n strategy:\n matrix:\n fruit: [apple, pear]\n animal: [cat, dog]\n include:\n - color: green\n - color: pink\n animal: cat\n - fruit: apple\n shape: circle\n - fruit: banana\n - fruit: banana\n animal: cat\n runs-on: ubuntu-latest\n steps:\n - run: make test\n")) + if err != nil { + t.Fatal(err) + } + combinations := MatrixCombinations(definition.Jobs["test"].Strategy) + want := []map[string]any{ + {"fruit": "apple", "animal": "cat", "color": "pink", "shape": "circle"}, + {"fruit": "apple", "animal": "dog", "color": "green", "shape": "circle"}, + {"fruit": "pear", "animal": "cat", "color": "pink"}, + {"fruit": "pear", "animal": "dog", "color": "green"}, + {"fruit": "banana"}, + {"fruit": "banana", "animal": "cat"}, + } + if len(combinations) != len(want) { + t.Fatalf("matrix combinations = %d, want %d", len(combinations), len(want)) + } + for index := range want { + if !maps.Equal(combinations[index], want[index]) { + t.Errorf("combination %d = %v, want %v", index, combinations[index], want[index]) + } + } +} + +func TestMatrixExcludesPartialAndFullMatchesBeforeIncludes(t *testing.T) { + definition, err := Parse([]byte("name: Test\non: push\njobs:\n test:\n strategy:\n matrix:\n os: [macos, windows]\n version: [12, 14, 16]\n environment: [staging, production]\n exclude:\n - os: macos\n version: 12\n environment: production\n - os: windows\n version: 16\n include:\n - os: windows\n version: 16\n environment: production\n restored: true\n runs-on: ubuntu-latest\n steps:\n - run: make test\n")) + if err != nil { + t.Fatal(err) + } + combinations := MatrixCombinations(definition.Jobs["test"].Strategy) + if len(combinations) != 10 { + t.Fatalf("matrix combinations = %d, want 10", len(combinations)) + } + for _, combination := range combinations[:len(combinations)-1] { + if combination["os"] == "windows" && combination["version"] == 16 { + t.Errorf("partial exclusion retained %v", combination) + } + if combination["os"] == "macos" && combination["version"] == 12 && combination["environment"] == "production" { + t.Errorf("full exclusion retained %v", combination) + } + } + wantRestored := map[string]any{"os": "windows", "version": 16, "environment": "production", "restored": true} + if !maps.Equal(combinations[len(combinations)-1], wantRestored) { + t.Errorf("restored combination = %v, want %v", combinations[len(combinations)-1], wantRestored) + } +} + +func TestMatrixExpansionUsesAxisDeclarationOrder(t *testing.T) { + definition, err := Parse([]byte("name: Test\non: push\njobs:\n test:\n strategy:\n matrix:\n version: [1, 2]\n os: [linux, darwin]\n runs-on: ubuntu-latest\n steps:\n - run: make test\n")) + if err != nil { + t.Fatal(err) + } + combinations := MatrixCombinations(definition.Jobs["test"].Strategy) + want := []string{"1/linux", "1/darwin", "2/linux", "2/darwin"} + for index, combination := range combinations { + got := fmt.Sprintf("%v/%v", combination["version"], combination["os"]) + if got != want[index] { + t.Errorf("combination %d = %q, want %q", index, got, want[index]) + } + } +} + +func TestMatrixExpansionLimitAppliesAfterExclusionsAndIncludes(t *testing.T) { + values := make([]string, maxMatrixJobs+1) + for index := range values { + values[index] = fmt.Sprint(index) + } + base := "name: Test\non: push\njobs:\n test:\n strategy:\n matrix:\n item: [" + strings.Join(values, ", ") + "]\n" + job := " runs-on: ubuntu-latest\n steps:\n - run: make test\n" + + definition, err := Parse([]byte(base + " exclude:\n - item: 256\n" + job)) + if err != nil { + t.Fatalf("Parse() rejected matrix reduced to the limit: %v", err) + } + if got := len(MatrixCombinations(definition.Jobs["test"].Strategy)); got != maxMatrixJobs { + t.Fatalf("matrix combinations = %d, want %d", got, maxMatrixJobs) + } + + if _, err := Parse([]byte(base + job)); err == nil || !strings.Contains(err.Error(), "more than 256 jobs") { + t.Fatalf("Parse() oversized base matrix error = %v", err) + } + values = values[:maxMatrixJobs] + base = "name: Test\non: push\njobs:\n test:\n strategy:\n matrix:\n item: [" + strings.Join(values, ", ") + "]\n" + if _, err := Parse([]byte(base + " include:\n - item: 256\n" + job)); err == nil || !strings.Contains(err.Error(), "more than 256 jobs") { + t.Fatalf("Parse() oversized transformed matrix error = %v", err) + } +} + func TestParseRejectsInvalidMatrixStrategy(t *testing.T) { strategies := []string{ "strategy: {}", "strategy: {matrix: {}}", "strategy: {matrix: {arch: []}}", "strategy: {matrix: {arch: [{name: arm64}]}}", + "strategy: {matrix: {include: value}}", + "strategy: {matrix: {include: [value]}}", + "strategy: {matrix: {include: [{}]}}", + "strategy: {matrix: {include: [{arch: {name: arm64}}]}}", + "strategy: {matrix: {arch: [amd64], exclude: [{arch: amd64}]}}", "strategy: {matrix: {arch: [" + strings.Repeat("a", maxMatrixValueLength+1) + "]}}", "strategy: {max-parallel: 0, matrix: {arch: [amd64]}}", "strategy: {fail-fast: false, matrix: {arch: [amd64]}}",