feat(workflow): evaluate strategies, add fuzz targets, notes and lint fixes
- commit
- b5f3fabaa316a725488d09411bac2aaa1dd8d69b
- author
- brice · · 2026-10-12 15:59 UTC
- committer
- brice · · 2026-10-12 16:17 UTC
- parent
- fd4d905
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
20 files · +716 -45
M THIRD_PARTY.md +1 -1
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | # Third-party material | |
| 2 | 2 | ||
| 3 | - | Test fixtures and schemas under `testdata/` and `template/schema/` are copied or ported from the projects below. Each copied file names its source in its first lines or in a `SOURCE` file next to it. | |
| 3 | + | Test fixtures and schemas under `testdata/`, `workflow/testdata/` and `template/schema/` are copied or ported from the projects below. Each copied file names its source in its first lines or in a `SOURCE` file next to it. | |
| 4 | 4 | ||
| 5 | 5 | ## actions/languageservices | |
| 6 | 6 | ||
| @@ -1,6 +1,6 @@ | |||||
| 1 | # Third-party material | 1 | # Third-party material | ||
| 2 | 2 | ||||
| 3 | - | Test fixtures and schemas under `testdata/` and `template/schema/` are copied or ported from the projects below. Each copied file names its source in its first lines or in a `SOURCE` file next to it. | 3 | + | Test fixtures and schemas under `testdata/`, `workflow/testdata/` and `template/schema/` are copied or ported from the projects below. Each copied file names its source in its first lines or in a `SOURCE` file next to it. |
| 4 | 4 | ||||
| 5 | ## actions/languageservices | 5 | ## actions/languageservices | ||
| 6 | 6 | ||||
A template/PORTING.md +58 -0
| @@ -0,0 +1,58 @@ | |||
| 1 | + | # Porting notes for package template | |
| 2 | + | ||
| 3 | + | The package is a port of the template layer of `@actions/workflow-parser` | |
| 4 | + | 0.3.61 (MIT, GitHub, actions/languageservices): `templates/` (tokens, reader, | |
| 5 | + | schema) and `workflows/yaml-object-reader`. The schema | |
| 6 | + | `schema/workflow-v1.0.min.json` is copied unmodified. The reference's own | |
| 7 | + | output is the specification: `../workflow/testdata/convert.json` holds what it | |
| 8 | + | answers for 160 workflow files and `workflow.TestConvertAgainstReference` | |
| 9 | + | replays them (messages, line and column included). | |
| 10 | + | ||
| 11 | + | The C# runner (`ObjectTemplating`) is what runs on github.com. It was not | |
| 12 | + | available. Nothing here has been checked against it. | |
| 13 | + | ||
| 14 | + | ## Differences from the reference | |
| 15 | + | ||
| 16 | + | - **YAML parser.** The reference uses the JavaScript `yaml` library (YAML 1.2, | |
| 17 | + | core schema); this port uses `go.yaml.in/yaml/v3` (a YAML 1.1/1.2 hybrid) and | |
| 18 | + | re-resolves plain scalars itself (`yaml.go`, `resolveScalar`) with the 1.2 | |
| 19 | + | core schema: `yes`/`no`/`on`/`off` are strings, `0o17` and `0x1F` are | |
| 20 | + | numbers, `.inf` and `.nan` are numbers, `0b11`, `1_000` and sexagesimal | |
| 21 | + | numbers are strings. Quoted, block and `!!str` scalars are strings. | |
| 22 | + | Verify: octal `017` (1.2 reads 17), timestamps, `!!binary`, merge keys `<<` | |
| 23 | + | (yaml.v3 resolves them when decoding but not in the Node API; they are kept | |
| 24 | + | as an ordinary key `<<`, as in the reference). | |
| 25 | + | - **YAML syntax errors** have yaml.v3's wording and only a line number, and are | |
| 26 | + | reported as `file.yml: message at line N`. The reference's are | |
| 27 | + | `file.yml: <message> at line N, column M:\n\n<excerpt>`. Only the shape | |
| 28 | + | (one error, prefixed by the file name) is tested. | |
| 29 | + | - **Multiple documents** are an error, with the reference's message text. | |
| 30 | + | - **Anchors and aliases** are expanded like the reference does. Added: a limit | |
| 31 | + | of `MaxNodes` (200 000) nodes after expansion, because the reference lets a | |
| 32 | + | few kilobytes of nested aliases allocate gigabytes; a circular alias is an | |
| 33 | + | error (the reference silently drops it). | |
| 34 | + | - **Positions of expressions in multi-line scalars.** The reference computes a | |
| 35 | + | precise range for the expression inside a multi-line string; this port | |
| 36 | + | reports the position of the scalar, which is what the reference's error | |
| 37 | + | messages use too (checked on `multiline_*`, `expr_multi`). | |
| 38 | + | `BasicExpression.Source` is the value of the scalar, not the raw source text | |
| 39 | + | (so a block scalar's indentation is not in it). | |
| 40 | + | - **Error limit.** `Context` keeps at most `MaxErrors` (1 000) errors. | |
| 41 | + | - **Limits on evaluation.** `MaxEvalDepth` (100) nested tokens; the result | |
| 42 | + | budget of package expr applies per expression. | |
| 43 | + | - **No `featureFlags`.** Background steps (`background`, `wait`, `wait-all`, | |
| 44 | + | `cancel`, `parallel`) are always disabled, which is the reference's default. | |
| 45 | + | - **Duplicate keys** are compared case-insensitively with ASCII/Unicode upper | |
| 46 | + | casing (`strings.ToUpper`), the reference uses `toUpperCase`. | |
| 47 | + | - Mapping keys that are collections (`? [a] : b`) are reported as | |
| 48 | + | `Unexpected value ''`; the reference reads `undefined` there. | |
| 49 | + | ||
| 50 | + | ## To check against the C# runner / recordings | |
| 51 | + | ||
| 52 | + | - [ ] A string with `${{` inside a non-expression place (`name:` of a | |
| 53 | + | reusable workflow...) and the `Exactly 0 parameter(s)...` wording. | |
| 54 | + | - [ ] Which contexts and functions each key may use: taken from the schema, | |
| 55 | + | which is the language service's, not necessarily the server's. | |
| 56 | + | - [ ] `EvaluateBool`/`EvaluateNumber` accept the strings `"true"`/`"12"`. The | |
| 57 | + | C# `TemplateEvaluator` converts scalars the same way (assumed). | |
| 58 | + | - [ ] A mapping key expression that produces a non-scalar. | |
| @@ -0,0 +1,58 @@ | |||||
| 1 | + | # Porting notes for package template | |||
| 2 | + | ||||
| 3 | + | The package is a port of the template layer of `@actions/workflow-parser` | |||
| 4 | + | 0.3.61 (MIT, GitHub, actions/languageservices): `templates/` (tokens, reader, | |||
| 5 | + | schema) and `workflows/yaml-object-reader`. The schema | |||
| 6 | + | `schema/workflow-v1.0.min.json` is copied unmodified. The reference's own | |||
| 7 | + | output is the specification: `../workflow/testdata/convert.json` holds what it | |||
| 8 | + | answers for 160 workflow files and `workflow.TestConvertAgainstReference` | |||
| 9 | + | replays them (messages, line and column included). | |||
| 10 | + | ||||
| 11 | + | The C# runner (`ObjectTemplating`) is what runs on github.com. It was not | |||
| 12 | + | available. Nothing here has been checked against it. | |||
| 13 | + | ||||
| 14 | + | ## Differences from the reference | |||
| 15 | + | ||||
| 16 | + | - **YAML parser.** The reference uses the JavaScript `yaml` library (YAML 1.2, | |||
| 17 | + | core schema); this port uses `go.yaml.in/yaml/v3` (a YAML 1.1/1.2 hybrid) and | |||
| 18 | + | re-resolves plain scalars itself (`yaml.go`, `resolveScalar`) with the 1.2 | |||
| 19 | + | core schema: `yes`/`no`/`on`/`off` are strings, `0o17` and `0x1F` are | |||
| 20 | + | numbers, `.inf` and `.nan` are numbers, `0b11`, `1_000` and sexagesimal | |||
| 21 | + | numbers are strings. Quoted, block and `!!str` scalars are strings. | |||
| 22 | + | Verify: octal `017` (1.2 reads 17), timestamps, `!!binary`, merge keys `<<` | |||
| 23 | + | (yaml.v3 resolves them when decoding but not in the Node API; they are kept | |||
| 24 | + | as an ordinary key `<<`, as in the reference). | |||
| 25 | + | - **YAML syntax errors** have yaml.v3's wording and only a line number, and are | |||
| 26 | + | reported as `file.yml: message at line N`. The reference's are | |||
| 27 | + | `file.yml: <message> at line N, column M:\n\n<excerpt>`. Only the shape | |||
| 28 | + | (one error, prefixed by the file name) is tested. | |||
| 29 | + | - **Multiple documents** are an error, with the reference's message text. | |||
| 30 | + | - **Anchors and aliases** are expanded like the reference does. Added: a limit | |||
| 31 | + | of `MaxNodes` (200 000) nodes after expansion, because the reference lets a | |||
| 32 | + | few kilobytes of nested aliases allocate gigabytes; a circular alias is an | |||
| 33 | + | error (the reference silently drops it). | |||
| 34 | + | - **Positions of expressions in multi-line scalars.** The reference computes a | |||
| 35 | + | precise range for the expression inside a multi-line string; this port | |||
| 36 | + | reports the position of the scalar, which is what the reference's error | |||
| 37 | + | messages use too (checked on `multiline_*`, `expr_multi`). | |||
| 38 | + | `BasicExpression.Source` is the value of the scalar, not the raw source text | |||
| 39 | + | (so a block scalar's indentation is not in it). | |||
| 40 | + | - **Error limit.** `Context` keeps at most `MaxErrors` (1 000) errors. | |||
| 41 | + | - **Limits on evaluation.** `MaxEvalDepth` (100) nested tokens; the result | |||
| 42 | + | budget of package expr applies per expression. | |||
| 43 | + | - **No `featureFlags`.** Background steps (`background`, `wait`, `wait-all`, | |||
| 44 | + | `cancel`, `parallel`) are always disabled, which is the reference's default. | |||
| 45 | + | - **Duplicate keys** are compared case-insensitively with ASCII/Unicode upper | |||
| 46 | + | casing (`strings.ToUpper`), the reference uses `toUpperCase`. | |||
| 47 | + | - Mapping keys that are collections (`? [a] : b`) are reported as | |||
| 48 | + | `Unexpected value ''`; the reference reads `undefined` there. | |||
| 49 | + | ||||
| 50 | + | ## To check against the C# runner / recordings | |||
| 51 | + | ||||
| 52 | + | - [ ] A string with `${{` inside a non-expression place (`name:` of a | |||
| 53 | + | reusable workflow...) and the `Exactly 0 parameter(s)...` wording. | |||
| 54 | + | - [ ] Which contexts and functions each key may use: taken from the schema, | |||
| 55 | + | which is the language service's, not necessarily the server's. | |||
| 56 | + | - [ ] `EvaluateBool`/`EvaluateNumber` accept the strings `"true"`/`"12"`. The | |||
| 57 | + | C# `TemplateEvaluator` converts scalars the same way (assumed). | |||
| 58 | + | - [ ] A mapping key expression that produces a non-scalar. | |||
M template/context.go +6 -0
| @@ -56,6 +56,12 @@ func errorPrefix(file string, p Pos) string { | |||
| 56 | 56 | return "" | |
| 57 | 57 | } | |
| 58 | 58 | ||
| 59 | + | // message is an error whose text is one of the reference's messages, which | |
| 60 | + | // start with a capital letter. | |
| 61 | + | type message string | |
| 62 | + | ||
| 63 | + | func (m message) Error() string { return string(m) } | |
| 64 | + | ||
| 59 | 65 | // Context collects the errors of a read and carries its schema. | |
| 60 | 66 | type Context struct { | |
| 61 | 67 | schema *Schema | |
| @@ -56,6 +56,12 @@ func errorPrefix(file string, p Pos) string { | |||||
| 56 | return "" | 56 | return "" | ||
| 57 | } | 57 | } | ||
| 58 | 58 | ||||
| 59 | + | // message is an error whose text is one of the reference's messages, which | |||
| 60 | + | // start with a capital letter. | |||
| 61 | + | type message string | |||
| 62 | + | ||||
| 63 | + | func (m message) Error() string { return string(m) } | |||
| 64 | + | ||||
| 59 | // Context collects the errors of a read and carries its schema. | 65 | // Context collects the errors of a read and carries its schema. | ||
| 60 | type Context struct { | 66 | type Context struct { | ||
| 61 | schema *Schema | 67 | schema *Schema | ||
M template/definfo.go +2 -2
| @@ -85,12 +85,12 @@ func parseFuncEntry(s string) (expr.FuncInfo, bool) { | |||
| 85 | 85 | } | |
| 86 | 86 | name := s[:open] | |
| 87 | 87 | for _, r := range name { | |
| 88 | - | if !(r == '_' || r >= '0' && r <= '9' || r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z') { | |
| 88 | + | if r != '_' && (r < '0' || r > '9') && (r < 'a' || r > 'z') && (r < 'A' || r > 'Z') { | |
| 89 | 89 | return expr.FuncInfo{}, false | |
| 90 | 90 | } | |
| 91 | 91 | } | |
| 92 | 92 | minS, maxS, ok := strings.Cut(s[open+1:len(s)-1], ",") | |
| 93 | - | if !ok || !isDigits(minS) || !(isDigits(maxS) || maxS == "MAX") { | |
| 93 | + | if !ok || !isDigits(minS) || (!isDigits(maxS) && maxS != "MAX") { | |
| 94 | 94 | return expr.FuncInfo{}, false | |
| 95 | 95 | } | |
| 96 | 96 | lo, err := strconv.Atoi(minS) | |
| @@ -85,12 +85,12 @@ func parseFuncEntry(s string) (expr.FuncInfo, bool) { | |||||
| 85 | } | 85 | } | ||
| 86 | name := s[:open] | 86 | name := s[:open] | ||
| 87 | for _, r := range name { | 87 | for _, r := range name { | ||
| 88 | - | if !(r == '_' || r >= '0' && r <= '9' || r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z') { | 88 | + | if r != '_' && (r < '0' || r > '9') && (r < 'a' || r > 'z') && (r < 'A' || r > 'Z') { |
| 89 | return expr.FuncInfo{}, false | 89 | return expr.FuncInfo{}, false | ||
| 90 | } | 90 | } | ||
| 91 | } | 91 | } | ||
| 92 | minS, maxS, ok := strings.Cut(s[open+1:len(s)-1], ",") | 92 | minS, maxS, ok := strings.Cut(s[open+1:len(s)-1], ",") | ||
| 93 | - | if !ok || !isDigits(minS) || !(isDigits(maxS) || maxS == "MAX") { | 93 | + | if !ok || !isDigits(minS) || (!isDigits(maxS) && maxS != "MAX") { |
| 94 | return expr.FuncInfo{}, false | 94 | return expr.FuncInfo{}, false | ||
| 95 | } | 95 | } | ||
| 96 | lo, err := strconv.Atoi(minS) | 96 | lo, err := strconv.Atoi(minS) | ||
A template/eval_test.go +212 -0
| @@ -0,0 +1,212 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package template | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "errors" | |
| 8 | + | "strings" | |
| 9 | + | "testing" | |
| 10 | + | ||
| 11 | + | "bnema.dev/ermite-actions/expr" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | func testEnv(t testing.TB) Env { | |
| 15 | + | t.Helper() | |
| 16 | + | gh, err := expr.FromJSON([]byte(`{"ref": "refs/heads/main", "n": 3, "flag": true}`)) | |
| 17 | + | if err != nil { | |
| 18 | + | t.Fatal(err) | |
| 19 | + | } | |
| 20 | + | m, _ := expr.FromJSON([]byte(`{"os": "linux", "extra": {"a": "1", "b": "2"}}`)) | |
| 21 | + | return Env{Contexts: map[string]expr.Value{"github": gh, "matrix": m}} | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | func stepOf(t testing.TB, src string) *Mapping { | |
| 25 | + | t.Helper() | |
| 26 | + | tok, errs := readWorkflow(t, head+src) | |
| 27 | + | if len(errs) > 0 { | |
| 28 | + | t.Fatalf("read: %q", errs) | |
| 29 | + | } | |
| 30 | + | return tok.(*Mapping).Find("jobs").(*Mapping).Find("a").(*Mapping).Find("steps").(*Sequence).Items[0].(*Mapping) | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | func TestEvaluateTokens(t *testing.T) { | |
| 34 | + | step := stepOf(t, " - name: n-${{ github.ref }}-${{ matrix.os }}\n run: echo\n env:\n A: ${{ github.n }}\n B: ${{ github.flag }}\n C: x\n") | |
| 35 | + | env := testEnv(t) | |
| 36 | + | ctx := context.Background() | |
| 37 | + | name, err := EvaluateString(ctx, step.Find("name"), env) | |
| 38 | + | if err != nil || name != "n-refs/heads/main-linux" { | |
| 39 | + | t.Errorf("name = %q, %v", name, err) | |
| 40 | + | } | |
| 41 | + | v, err := Evaluate(ctx, step.Find("env"), env) | |
| 42 | + | if err != nil { | |
| 43 | + | t.Fatal(err) | |
| 44 | + | } | |
| 45 | + | if got := expr.ToJSON(v); got != "{\n \"A\": 3,\n \"B\": true,\n \"C\": \"x\"\n}" { | |
| 46 | + | t.Errorf("env = %s", got) | |
| 47 | + | } | |
| 48 | + | s, err := EvaluateString(ctx, step.Find("env").(*Mapping).Find("A"), env) | |
| 49 | + | if err != nil || s != "3" { | |
| 50 | + | t.Errorf("A = %q, %v", s, err) | |
| 51 | + | } | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | func TestInsertAndExpressionKeys(t *testing.T) { | |
| 55 | + | tok, errs := readWorkflow(t, head+" - run: x\n env:\n ${{ insert }}: ${{ matrix.extra }}\n ${{ github.ref }}: v\n K: w\n") | |
| 56 | + | if len(errs) > 0 { | |
| 57 | + | t.Fatal(errs) | |
| 58 | + | } | |
| 59 | + | env := tok.(*Mapping).Find("jobs").(*Mapping).Find("a").(*Mapping).Find("steps").(*Sequence).Items[0].(*Mapping).Find("env") | |
| 60 | + | v, err := Evaluate(context.Background(), env, testEnv(t)) | |
| 61 | + | if err != nil { | |
| 62 | + | t.Fatal(err) | |
| 63 | + | } | |
| 64 | + | if got := strings.ReplaceAll(strings.ReplaceAll(expr.ToJSON(v), "\n", ""), " ", ""); got != `{"a": "1","b": "2","refs/heads/main": "v","K": "w"}` { | |
| 65 | + | t.Errorf("env = %s", got) | |
| 66 | + | } | |
| 67 | + | // insert needs a mapping. | |
| 68 | + | bad := NewMapping(Origin{}) | |
| 69 | + | bad.Add(&InsertExpression{}, NewString(Origin{}, "x")) | |
| 70 | + | if _, err := Evaluate(context.Background(), bad, testEnv(t)); err == nil || !strings.Contains(err.Error(), "insert") { | |
| 71 | + | t.Errorf("err = %v", err) | |
| 72 | + | } | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | func TestEvaluateTypedHelpers(t *testing.T) { | |
| 76 | + | ctx := context.Background() | |
| 77 | + | env := testEnv(t) | |
| 78 | + | ex := func(s string) *BasicExpression { | |
| 79 | + | return NewExpression(Origin{File: "f.yml", Pos: Pos{4, 2}}, s, mustInfo(t, "step-env")) | |
| 80 | + | } | |
| 81 | + | if b, err := EvaluateBool(ctx, ex("github.flag"), env); err != nil || !b { | |
| 82 | + | t.Errorf("bool = %v, %v", b, err) | |
| 83 | + | } | |
| 84 | + | if b, err := EvaluateBool(ctx, ex("'FALSE'"), env); err != nil || b { | |
| 85 | + | t.Errorf("bool string = %v, %v", b, err) | |
| 86 | + | } | |
| 87 | + | if _, err := EvaluateBool(ctx, ex("github.n"), env); err == nil || !strings.Contains(err.Error(), "f.yml (Line: 4, Col: 2): Unexpected type 'NumberToken'") { | |
| 88 | + | t.Errorf("bool number: %v", err) | |
| 89 | + | } | |
| 90 | + | if n, err := EvaluateNumber(ctx, ex("github.n"), env); err != nil || n != 3 { | |
| 91 | + | t.Errorf("number = %v, %v", n, err) | |
| 92 | + | } | |
| 93 | + | if n, err := EvaluateNumber(ctx, ex("'12'"), env); err != nil || n != 12 { | |
| 94 | + | t.Errorf("number string = %v, %v", n, err) | |
| 95 | + | } | |
| 96 | + | if _, err := EvaluateNumber(ctx, ex("'x'"), env); err == nil { | |
| 97 | + | t.Error("number x: no error") | |
| 98 | + | } | |
| 99 | + | if _, err := EvaluateString(ctx, ex("matrix.extra"), env); err == nil || !strings.Contains(err.Error(), "MappingToken") { | |
| 100 | + | t.Errorf("string of mapping: %v", err) | |
| 101 | + | } | |
| 102 | + | if s, err := EvaluateString(ctx, ex("null"), env); err != nil || s != "" { | |
| 103 | + | t.Errorf("string of null = %q, %v", s, err) | |
| 104 | + | } | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | func mustInfo(t testing.TB, name string) *DefinitionInfo { | |
| 108 | + | t.Helper() | |
| 109 | + | s, err := WorkflowSchema() | |
| 110 | + | if err != nil { | |
| 111 | + | t.Fatal(err) | |
| 112 | + | } | |
| 113 | + | return s.Info(name) | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | func TestExpressionErrors(t *testing.T) { | |
| 117 | + | ctx := context.Background() | |
| 118 | + | bad := NewExpression(Origin{File: "f.yml", Pos: Pos{1, 1}}, "github.", mustInfo(t, "step-env")) | |
| 119 | + | if bad.Err() == nil { | |
| 120 | + | t.Fatal("parse error expected") | |
| 121 | + | } | |
| 122 | + | _, err := Evaluate(ctx, bad, testEnv(t)) | |
| 123 | + | var te *Error | |
| 124 | + | if !errors.As(err, &te) || !strings.HasPrefix(err.Error(), "f.yml (Line: 1, Col: 1): ") { | |
| 125 | + | t.Errorf("err = %v", err) | |
| 126 | + | } | |
| 127 | + | // Not allowed context: secrets in job-if. | |
| 128 | + | e := NewExpression(Origin{}, "secrets.x", mustInfo(t, "job-if")) | |
| 129 | + | if e.Err() == nil || !strings.Contains(e.Err().Error(), "Unrecognized named-value: 'secrets'") { | |
| 130 | + | t.Errorf("err = %v", e.Err()) | |
| 131 | + | } | |
| 132 | + | // Evaluation errors wrap the expr error. | |
| 133 | + | ev := NewExpression(Origin{}, "fromJSON('x')", mustInfo(t, "step-env")) | |
| 134 | + | _, err = Evaluate(ctx, ev, testEnv(t)) | |
| 135 | + | var ee *expr.EvalError | |
| 136 | + | if !errors.As(err, &ee) { | |
| 137 | + | t.Errorf("err = %v (%T)", err, err) | |
| 138 | + | } | |
| 139 | + | // Cancellation. | |
| 140 | + | cctx, cancel := context.WithCancel(ctx) | |
| 141 | + | cancel() | |
| 142 | + | if _, err := Evaluate(cctx, NewExpression(Origin{}, "github.ref", mustInfo(t, "step-env")), testEnv(t)); err == nil { | |
| 143 | + | t.Error("cancelled: no error") | |
| 144 | + | } | |
| 145 | + | // Stray insert. | |
| 146 | + | if _, err := Evaluate(ctx, &InsertExpression{}, testEnv(t)); err == nil { | |
| 147 | + | t.Error("insert alone: no error") | |
| 148 | + | } | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | func TestEvaluateDepthLimit(t *testing.T) { | |
| 152 | + | var tok Token = NewString(Origin{}, "x") | |
| 153 | + | for i := 0; i < MaxEvalDepth+5; i++ { | |
| 154 | + | tok = NewSequence(Origin{}, tok) | |
| 155 | + | } | |
| 156 | + | if _, err := Evaluate(context.Background(), tok, Env{}); err == nil { | |
| 157 | + | t.Error("deep token: no error") | |
| 158 | + | } | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | func TestResultBudget(t *testing.T) { | |
| 162 | + | text := "'xxxxxxxxxxxxxxxx'" | |
| 163 | + | for i := 0; i < 7; i++ { | |
| 164 | + | text = "format('{0}{0}{0}{0}', " + text + ")" | |
| 165 | + | } | |
| 166 | + | e := NewExpression(Origin{}, text, mustInfo(t, "step-env")) | |
| 167 | + | if e.Err() != nil { | |
| 168 | + | t.Fatal(e.Err()) | |
| 169 | + | } | |
| 170 | + | env := testEnv(t) | |
| 171 | + | env.MaxResultBytes = 1000 | |
| 172 | + | _, err := Evaluate(context.Background(), e, env) | |
| 173 | + | var ee *expr.EvalError | |
| 174 | + | if !errors.As(err, &ee) { | |
| 175 | + | t.Fatalf("err = %v", err) | |
| 176 | + | } | |
| 177 | + | env.MaxResultBytes = -1 | |
| 178 | + | if _, err := Evaluate(context.Background(), e, env); err != nil { | |
| 179 | + | t.Errorf("no limit: %v", err) | |
| 180 | + | } | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | func TestMatchDirective(t *testing.T) { | |
| 184 | + | tests := []struct { | |
| 185 | + | in string | |
| 186 | + | match bool | |
| 187 | + | wantErr string | |
| 188 | + | }{ | |
| 189 | + | {"insert", true, ""}, | |
| 190 | + | {"insert ", true, ""}, | |
| 191 | + | {"inserted", false, ""}, | |
| 192 | + | {"insert x", false, "Exactly 0 parameter(s) were expected following the directive 'insert'. Actual parameter count: 1"}, | |
| 193 | + | {"insert x y", false, "Actual parameter count: 1"}, | |
| 194 | + | {"github.ref", false, ""}, | |
| 195 | + | } | |
| 196 | + | for _, tc := range tests { | |
| 197 | + | got, err := matchDirective(tc.in, "insert", 0) | |
| 198 | + | if got != tc.match || (tc.wantErr == "") != (err == nil) || (err != nil && !strings.Contains(err.Error(), tc.wantErr)) { | |
| 199 | + | t.Errorf("matchDirective(%q) = %v, %v", tc.in, got, err) | |
| 200 | + | } | |
| 201 | + | } | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | func TestJSNumberString(t *testing.T) { | |
| 205 | + | for in, want := range map[float64]string{ | |
| 206 | + | 0: "0", 1: "1", -1: "-1", 1.5: "1.5", 100: "100", 1e21: "1e+21", 1e-7: "1e-7", 123456789012: "123456789012", 0.000001: "0.000001", | |
| 207 | + | } { | |
| 208 | + | if got := JSNumberString(in); got != want { | |
| 209 | + | t.Errorf("JSNumberString(%v) = %s, want %s", in, got, want) | |
| 210 | + | } | |
| 211 | + | } | |
| 212 | + | } | |
| @@ -0,0 +1,212 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package template | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "errors" | |||
| 8 | + | "strings" | |||
| 9 | + | "testing" | |||
| 10 | + | ||||
| 11 | + | "bnema.dev/ermite-actions/expr" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | func testEnv(t testing.TB) Env { | |||
| 15 | + | t.Helper() | |||
| 16 | + | gh, err := expr.FromJSON([]byte(`{"ref": "refs/heads/main", "n": 3, "flag": true}`)) | |||
| 17 | + | if err != nil { | |||
| 18 | + | t.Fatal(err) | |||
| 19 | + | } | |||
| 20 | + | m, _ := expr.FromJSON([]byte(`{"os": "linux", "extra": {"a": "1", "b": "2"}}`)) | |||
| 21 | + | return Env{Contexts: map[string]expr.Value{"github": gh, "matrix": m}} | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | func stepOf(t testing.TB, src string) *Mapping { | |||
| 25 | + | t.Helper() | |||
| 26 | + | tok, errs := readWorkflow(t, head+src) | |||
| 27 | + | if len(errs) > 0 { | |||
| 28 | + | t.Fatalf("read: %q", errs) | |||
| 29 | + | } | |||
| 30 | + | return tok.(*Mapping).Find("jobs").(*Mapping).Find("a").(*Mapping).Find("steps").(*Sequence).Items[0].(*Mapping) | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | func TestEvaluateTokens(t *testing.T) { | |||
| 34 | + | step := stepOf(t, " - name: n-${{ github.ref }}-${{ matrix.os }}\n run: echo\n env:\n A: ${{ github.n }}\n B: ${{ github.flag }}\n C: x\n") | |||
| 35 | + | env := testEnv(t) | |||
| 36 | + | ctx := context.Background() | |||
| 37 | + | name, err := EvaluateString(ctx, step.Find("name"), env) | |||
| 38 | + | if err != nil || name != "n-refs/heads/main-linux" { | |||
| 39 | + | t.Errorf("name = %q, %v", name, err) | |||
| 40 | + | } | |||
| 41 | + | v, err := Evaluate(ctx, step.Find("env"), env) | |||
| 42 | + | if err != nil { | |||
| 43 | + | t.Fatal(err) | |||
| 44 | + | } | |||
| 45 | + | if got := expr.ToJSON(v); got != "{\n \"A\": 3,\n \"B\": true,\n \"C\": \"x\"\n}" { | |||
| 46 | + | t.Errorf("env = %s", got) | |||
| 47 | + | } | |||
| 48 | + | s, err := EvaluateString(ctx, step.Find("env").(*Mapping).Find("A"), env) | |||
| 49 | + | if err != nil || s != "3" { | |||
| 50 | + | t.Errorf("A = %q, %v", s, err) | |||
| 51 | + | } | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | func TestInsertAndExpressionKeys(t *testing.T) { | |||
| 55 | + | tok, errs := readWorkflow(t, head+" - run: x\n env:\n ${{ insert }}: ${{ matrix.extra }}\n ${{ github.ref }}: v\n K: w\n") | |||
| 56 | + | if len(errs) > 0 { | |||
| 57 | + | t.Fatal(errs) | |||
| 58 | + | } | |||
| 59 | + | env := tok.(*Mapping).Find("jobs").(*Mapping).Find("a").(*Mapping).Find("steps").(*Sequence).Items[0].(*Mapping).Find("env") | |||
| 60 | + | v, err := Evaluate(context.Background(), env, testEnv(t)) | |||
| 61 | + | if err != nil { | |||
| 62 | + | t.Fatal(err) | |||
| 63 | + | } | |||
| 64 | + | if got := strings.ReplaceAll(strings.ReplaceAll(expr.ToJSON(v), "\n", ""), " ", ""); got != `{"a": "1","b": "2","refs/heads/main": "v","K": "w"}` { | |||
| 65 | + | t.Errorf("env = %s", got) | |||
| 66 | + | } | |||
| 67 | + | // insert needs a mapping. | |||
| 68 | + | bad := NewMapping(Origin{}) | |||
| 69 | + | bad.Add(&InsertExpression{}, NewString(Origin{}, "x")) | |||
| 70 | + | if _, err := Evaluate(context.Background(), bad, testEnv(t)); err == nil || !strings.Contains(err.Error(), "insert") { | |||
| 71 | + | t.Errorf("err = %v", err) | |||
| 72 | + | } | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | func TestEvaluateTypedHelpers(t *testing.T) { | |||
| 76 | + | ctx := context.Background() | |||
| 77 | + | env := testEnv(t) | |||
| 78 | + | ex := func(s string) *BasicExpression { | |||
| 79 | + | return NewExpression(Origin{File: "f.yml", Pos: Pos{4, 2}}, s, mustInfo(t, "step-env")) | |||
| 80 | + | } | |||
| 81 | + | if b, err := EvaluateBool(ctx, ex("github.flag"), env); err != nil || !b { | |||
| 82 | + | t.Errorf("bool = %v, %v", b, err) | |||
| 83 | + | } | |||
| 84 | + | if b, err := EvaluateBool(ctx, ex("'FALSE'"), env); err != nil || b { | |||
| 85 | + | t.Errorf("bool string = %v, %v", b, err) | |||
| 86 | + | } | |||
| 87 | + | if _, err := EvaluateBool(ctx, ex("github.n"), env); err == nil || !strings.Contains(err.Error(), "f.yml (Line: 4, Col: 2): Unexpected type 'NumberToken'") { | |||
| 88 | + | t.Errorf("bool number: %v", err) | |||
| 89 | + | } | |||
| 90 | + | if n, err := EvaluateNumber(ctx, ex("github.n"), env); err != nil || n != 3 { | |||
| 91 | + | t.Errorf("number = %v, %v", n, err) | |||
| 92 | + | } | |||
| 93 | + | if n, err := EvaluateNumber(ctx, ex("'12'"), env); err != nil || n != 12 { | |||
| 94 | + | t.Errorf("number string = %v, %v", n, err) | |||
| 95 | + | } | |||
| 96 | + | if _, err := EvaluateNumber(ctx, ex("'x'"), env); err == nil { | |||
| 97 | + | t.Error("number x: no error") | |||
| 98 | + | } | |||
| 99 | + | if _, err := EvaluateString(ctx, ex("matrix.extra"), env); err == nil || !strings.Contains(err.Error(), "MappingToken") { | |||
| 100 | + | t.Errorf("string of mapping: %v", err) | |||
| 101 | + | } | |||
| 102 | + | if s, err := EvaluateString(ctx, ex("null"), env); err != nil || s != "" { | |||
| 103 | + | t.Errorf("string of null = %q, %v", s, err) | |||
| 104 | + | } | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | func mustInfo(t testing.TB, name string) *DefinitionInfo { | |||
| 108 | + | t.Helper() | |||
| 109 | + | s, err := WorkflowSchema() | |||
| 110 | + | if err != nil { | |||
| 111 | + | t.Fatal(err) | |||
| 112 | + | } | |||
| 113 | + | return s.Info(name) | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | func TestExpressionErrors(t *testing.T) { | |||
| 117 | + | ctx := context.Background() | |||
| 118 | + | bad := NewExpression(Origin{File: "f.yml", Pos: Pos{1, 1}}, "github.", mustInfo(t, "step-env")) | |||
| 119 | + | if bad.Err() == nil { | |||
| 120 | + | t.Fatal("parse error expected") | |||
| 121 | + | } | |||
| 122 | + | _, err := Evaluate(ctx, bad, testEnv(t)) | |||
| 123 | + | var te *Error | |||
| 124 | + | if !errors.As(err, &te) || !strings.HasPrefix(err.Error(), "f.yml (Line: 1, Col: 1): ") { | |||
| 125 | + | t.Errorf("err = %v", err) | |||
| 126 | + | } | |||
| 127 | + | // Not allowed context: secrets in job-if. | |||
| 128 | + | e := NewExpression(Origin{}, "secrets.x", mustInfo(t, "job-if")) | |||
| 129 | + | if e.Err() == nil || !strings.Contains(e.Err().Error(), "Unrecognized named-value: 'secrets'") { | |||
| 130 | + | t.Errorf("err = %v", e.Err()) | |||
| 131 | + | } | |||
| 132 | + | // Evaluation errors wrap the expr error. | |||
| 133 | + | ev := NewExpression(Origin{}, "fromJSON('x')", mustInfo(t, "step-env")) | |||
| 134 | + | _, err = Evaluate(ctx, ev, testEnv(t)) | |||
| 135 | + | var ee *expr.EvalError | |||
| 136 | + | if !errors.As(err, &ee) { | |||
| 137 | + | t.Errorf("err = %v (%T)", err, err) | |||
| 138 | + | } | |||
| 139 | + | // Cancellation. | |||
| 140 | + | cctx, cancel := context.WithCancel(ctx) | |||
| 141 | + | cancel() | |||
| 142 | + | if _, err := Evaluate(cctx, NewExpression(Origin{}, "github.ref", mustInfo(t, "step-env")), testEnv(t)); err == nil { | |||
| 143 | + | t.Error("cancelled: no error") | |||
| 144 | + | } | |||
| 145 | + | // Stray insert. | |||
| 146 | + | if _, err := Evaluate(ctx, &InsertExpression{}, testEnv(t)); err == nil { | |||
| 147 | + | t.Error("insert alone: no error") | |||
| 148 | + | } | |||
| 149 | + | } | |||
| 150 | + | ||||
| 151 | + | func TestEvaluateDepthLimit(t *testing.T) { | |||
| 152 | + | var tok Token = NewString(Origin{}, "x") | |||
| 153 | + | for i := 0; i < MaxEvalDepth+5; i++ { | |||
| 154 | + | tok = NewSequence(Origin{}, tok) | |||
| 155 | + | } | |||
| 156 | + | if _, err := Evaluate(context.Background(), tok, Env{}); err == nil { | |||
| 157 | + | t.Error("deep token: no error") | |||
| 158 | + | } | |||
| 159 | + | } | |||
| 160 | + | ||||
| 161 | + | func TestResultBudget(t *testing.T) { | |||
| 162 | + | text := "'xxxxxxxxxxxxxxxx'" | |||
| 163 | + | for i := 0; i < 7; i++ { | |||
| 164 | + | text = "format('{0}{0}{0}{0}', " + text + ")" | |||
| 165 | + | } | |||
| 166 | + | e := NewExpression(Origin{}, text, mustInfo(t, "step-env")) | |||
| 167 | + | if e.Err() != nil { | |||
| 168 | + | t.Fatal(e.Err()) | |||
| 169 | + | } | |||
| 170 | + | env := testEnv(t) | |||
| 171 | + | env.MaxResultBytes = 1000 | |||
| 172 | + | _, err := Evaluate(context.Background(), e, env) | |||
| 173 | + | var ee *expr.EvalError | |||
| 174 | + | if !errors.As(err, &ee) { | |||
| 175 | + | t.Fatalf("err = %v", err) | |||
| 176 | + | } | |||
| 177 | + | env.MaxResultBytes = -1 | |||
| 178 | + | if _, err := Evaluate(context.Background(), e, env); err != nil { | |||
| 179 | + | t.Errorf("no limit: %v", err) | |||
| 180 | + | } | |||
| 181 | + | } | |||
| 182 | + | ||||
| 183 | + | func TestMatchDirective(t *testing.T) { | |||
| 184 | + | tests := []struct { | |||
| 185 | + | in string | |||
| 186 | + | match bool | |||
| 187 | + | wantErr string | |||
| 188 | + | }{ | |||
| 189 | + | {"insert", true, ""}, | |||
| 190 | + | {"insert ", true, ""}, | |||
| 191 | + | {"inserted", false, ""}, | |||
| 192 | + | {"insert x", false, "Exactly 0 parameter(s) were expected following the directive 'insert'. Actual parameter count: 1"}, | |||
| 193 | + | {"insert x y", false, "Actual parameter count: 1"}, | |||
| 194 | + | {"github.ref", false, ""}, | |||
| 195 | + | } | |||
| 196 | + | for _, tc := range tests { | |||
| 197 | + | got, err := matchDirective(tc.in, "insert", 0) | |||
| 198 | + | if got != tc.match || (tc.wantErr == "") != (err == nil) || (err != nil && !strings.Contains(err.Error(), tc.wantErr)) { | |||
| 199 | + | t.Errorf("matchDirective(%q) = %v, %v", tc.in, got, err) | |||
| 200 | + | } | |||
| 201 | + | } | |||
| 202 | + | } | |||
| 203 | + | ||||
| 204 | + | func TestJSNumberString(t *testing.T) { | |||
| 205 | + | for in, want := range map[float64]string{ | |||
| 206 | + | 0: "0", 1: "1", -1: "-1", 1.5: "1.5", 100: "100", 1e21: "1e+21", 1e-7: "1e-7", 123456789012: "123456789012", 0.000001: "0.000001", | |||
| 207 | + | } { | |||
| 208 | + | if got := JSNumberString(in); got != want { | |||
| 209 | + | t.Errorf("JSNumberString(%v) = %s, want %s", in, got, want) | |||
| 210 | + | } | |||
| 211 | + | } | |||
| 212 | + | } | |||
A template/fuzz_test.go +32 -0
| @@ -0,0 +1,32 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package template | |
| 4 | + | ||
| 5 | + | import "testing" | |
| 6 | + | ||
| 7 | + | func FuzzReadYAML(f *testing.F) { | |
| 8 | + | for _, s := range []string{ | |
| 9 | + | "on: push\njobs:\n a:\n runs-on: x\n steps:\n - run: echo ${{ github.ref }}\n", | |
| 10 | + | "a: &x [1, *x]\n", | |
| 11 | + | "on: [push\n", | |
| 12 | + | "${{ insert }}: 1\n", | |
| 13 | + | "x: ${{ '${{' }}\n", | |
| 14 | + | "\x00", | |
| 15 | + | "a: !!str 1\nb: !!int x\n", | |
| 16 | + | "? [a]\n: b\n", | |
| 17 | + | "on: push\njobs:\n a:\n runs-on: ${{ matrix.os }}\n strategy:\n matrix:\n ${{ insert }}: ${{ fromJSON(needs.x.outputs.y) }}\n", | |
| 18 | + | } { | |
| 19 | + | f.Add(s) | |
| 20 | + | } | |
| 21 | + | schema, err := WorkflowSchema() | |
| 22 | + | if err != nil { | |
| 23 | + | f.Fatal(err) | |
| 24 | + | } | |
| 25 | + | f.Fuzz(func(t *testing.T, src string) { | |
| 26 | + | ctx := NewContext(schema) | |
| 27 | + | ReadYAML(ctx, "workflow-root-strict", "f.yml", []byte(src)) | |
| 28 | + | for _, e := range ctx.Errors() { | |
| 29 | + | _ = e.Error() | |
| 30 | + | } | |
| 31 | + | }) | |
| 32 | + | } | |
| @@ -0,0 +1,32 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package template | |||
| 4 | + | ||||
| 5 | + | import "testing" | |||
| 6 | + | ||||
| 7 | + | func FuzzReadYAML(f *testing.F) { | |||
| 8 | + | for _, s := range []string{ | |||
| 9 | + | "on: push\njobs:\n a:\n runs-on: x\n steps:\n - run: echo ${{ github.ref }}\n", | |||
| 10 | + | "a: &x [1, *x]\n", | |||
| 11 | + | "on: [push\n", | |||
| 12 | + | "${{ insert }}: 1\n", | |||
| 13 | + | "x: ${{ '${{' }}\n", | |||
| 14 | + | "\x00", | |||
| 15 | + | "a: !!str 1\nb: !!int x\n", | |||
| 16 | + | "? [a]\n: b\n", | |||
| 17 | + | "on: push\njobs:\n a:\n runs-on: ${{ matrix.os }}\n strategy:\n matrix:\n ${{ insert }}: ${{ fromJSON(needs.x.outputs.y) }}\n", | |||
| 18 | + | } { | |||
| 19 | + | f.Add(s) | |||
| 20 | + | } | |||
| 21 | + | schema, err := WorkflowSchema() | |||
| 22 | + | if err != nil { | |||
| 23 | + | f.Fatal(err) | |||
| 24 | + | } | |||
| 25 | + | f.Fuzz(func(t *testing.T, src string) { | |||
| 26 | + | ctx := NewContext(schema) | |||
| 27 | + | ReadYAML(ctx, "workflow-root-strict", "f.yml", []byte(src)) | |||
| 28 | + | for _, e := range ctx.Errors() { | |||
| 29 | + | _ = e.Error() | |||
| 30 | + | } | |||
| 31 | + | }) | |||
| 32 | + | } | |||
M template/scalar.go +2 -3
| @@ -3,7 +3,6 @@ | |||
| 3 | 3 | package template | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | - | "errors" | |
| 7 | 6 | "fmt" | |
| 8 | 7 | "strings" | |
| 9 | 8 | "unicode" | |
| @@ -140,7 +139,7 @@ func (r *reader) parseIntoExpressionToken(token Token, rawExpression string, def | |||
| 140 | 139 | func (r *reader) parseExpression(token Token, value string, def *DefinitionInfo) (Token, error) { | |
| 141 | 140 | trimmed := strings.TrimSpace(value) | |
| 142 | 141 | if trimmed == "" { | |
| 143 | - | return nil, errors.New("An expression was expected") | |
| 142 | + | return nil, message("An expression was expected") | |
| 144 | 143 | } | |
| 145 | 144 | isMatch, err := matchDirective(trimmed, insertDirective, 0) | |
| 146 | 145 | if err != nil { | |
| @@ -214,7 +213,7 @@ func matchDirective(trimmed, directive string, expectedParameters int) (bool, er | |||
| 214 | 213 | parameters = 1 | |
| 215 | 214 | } | |
| 216 | 215 | if expectedParameters != parameters { | |
| 217 | - | return false, fmt.Errorf("Exactly %d parameter(s) were expected following the directive '%s'. Actual parameter count: %d", expectedParameters, directive, parameters) | |
| 216 | + | return false, message(fmt.Sprintf("Exactly %d parameter(s) were expected following the directive '%s'. Actual parameter count: %d", expectedParameters, directive, parameters)) | |
| 218 | 217 | } | |
| 219 | 218 | return true, nil | |
| 220 | 219 | } | |
| @@ -3,7 +3,6 @@ | |||||
| 3 | package template | 3 | package template | ||
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | - | "errors" | |||
| 7 | "fmt" | 6 | "fmt" | ||
| 8 | "strings" | 7 | "strings" | ||
| 9 | "unicode" | 8 | "unicode" | ||
| @@ -140,7 +139,7 @@ func (r *reader) parseIntoExpressionToken(token Token, rawExpression string, def | |||||
| 140 | func (r *reader) parseExpression(token Token, value string, def *DefinitionInfo) (Token, error) { | 139 | func (r *reader) parseExpression(token Token, value string, def *DefinitionInfo) (Token, error) { | ||
| 141 | trimmed := strings.TrimSpace(value) | 140 | trimmed := strings.TrimSpace(value) | ||
| 142 | if trimmed == "" { | 141 | if trimmed == "" { | ||
| 143 | - | return nil, errors.New("An expression was expected") | 142 | + | return nil, message("An expression was expected") |
| 144 | } | 143 | } | ||
| 145 | isMatch, err := matchDirective(trimmed, insertDirective, 0) | 144 | isMatch, err := matchDirective(trimmed, insertDirective, 0) | ||
| 146 | if err != nil { | 145 | if err != nil { | ||
| @@ -214,7 +213,7 @@ func matchDirective(trimmed, directive string, expectedParameters int) (bool, er | |||||
| 214 | parameters = 1 | 213 | parameters = 1 | ||
| 215 | } | 214 | } | ||
| 216 | if expectedParameters != parameters { | 215 | if expectedParameters != parameters { | ||
| 217 | - | return false, fmt.Errorf("Exactly %d parameter(s) were expected following the directive '%s'. Actual parameter count: %d", expectedParameters, directive, parameters) | 216 | + | return false, message(fmt.Sprintf("Exactly %d parameter(s) were expected following the directive '%s'. Actual parameter count: %d", expectedParameters, directive, parameters)) |
| 218 | } | 217 | } | ||
| 219 | return true, nil | 218 | return true, nil | ||
| 220 | } | 219 | } | ||
A workflow/PORTING.md +61 -0
| @@ -0,0 +1,61 @@ | |||
| 1 | + | # Porting notes for package workflow | |
| 2 | + | ||
| 3 | + | `Parse` is a port of `model/convert` and `model/converter/*` of | |
| 4 | + | `@actions/workflow-parser` 0.3.61 on top of package `template`. The reference's | |
| 5 | + | errors are replayed in `TestConvertAgainstReference` (160 files in | |
| 6 | + | `testdata/convert`, answers in `testdata/convert.json`, generated by | |
| 7 | + | `gen/convert.mjs` from the unmodified npm package). | |
| 8 | + | ||
| 9 | + | The parts below are **not** in that package. They are written from GitHub's | |
| 10 | + | documentation and from what is known of the official runner, and each needs a | |
| 11 | + | recording of github.com (`ermite-conformance`) or a look at the C# runner | |
| 12 | + | before being trusted. Every one carries a `TODO(verify with recording)` in the | |
| 13 | + | code. | |
| 14 | + | ||
| 15 | + | ## Differences from the reference | |
| 16 | + | ||
| 17 | + | - **`branches` with `branches-ignore`** (and `tags`, `paths`): the reference | |
| 18 | + | accepts them; GitHub refuses the file. `Parse` reports | |
| 19 | + | `Event 'push' cannot use both 'branches' and 'branches-ignore'. Use only one | |
| 20 | + | of them.` (our wording, to replace with GitHub's). Applied to every event. | |
| 21 | + | - **Reusable workflows** are parsed (`uses`, `with`, `secrets`, `inherit`) but | |
| 22 | + | not followed, and the called workflow's inputs and secrets are not checked. | |
| 23 | + | - Unknown event types are reported by the schema (`Unexpected value 'x'`). | |
| 24 | + | - `permissions` are modelled but not validated beyond the schema. | |
| 25 | + | - `Job.If`, `Step.If`: the status-function wrapping is the reference's | |
| 26 | + | (`success() && (cond)` when the condition calls none of `success`, | |
| 27 | + | `failure`, `cancelled`, `always`, looking through nested calls). | |
| 28 | + | ||
| 29 | + | ## To verify (recordings) | |
| 30 | + | ||
| 31 | + | - [ ] **Glob syntax** (`glob.go`): `*`, `**`, `?`, `+`, `[]`, `\`, `!`. The | |
| 32 | + | cheat sheet's examples pass; `**/` matching nothing, `?` and `+` after a | |
| 33 | + | `]`, `*` or an escaped character, `[!x]`, an unclosed `[` (literal here) | |
| 34 | + | and `/`-prefixed patterns are assumed. | |
| 35 | + | - [ ] **`MatchPush`**: deletes never run; more than 3 tags run nothing for | |
| 36 | + | tags (not for the branch); branches-only filter and a tag push (no run) | |
| 37 | + | and the reverse; with both filters each ref uses its own; `paths` only | |
| 38 | + | for branches; first 300 files (also for `paths-ignore`); truncated list | |
| 39 | + | passes; a push whose diff is empty never matches a `paths` filter and | |
| 40 | + | always matches a `paths-ignore` one; unknown ref kinds never match. | |
| 41 | + | - [ ] **`PushInfo.Created`** is not used: a new branch's file list is the | |
| 42 | + | caller's job (diff against the default branch merge-base). | |
| 43 | + | - [ ] **`SkipCI`**: marker case, trailer rules (see its doc comment); whether | |
| 44 | + | only the head commit counts; `[skip ci]` in the subject vs the body. | |
| 45 | + | - [ ] **`ExpandMatrix`**: order of the product (first key slowest), include | |
| 46 | + | semantics (added to every matching combination, never overwriting | |
| 47 | + | original values, later includes overwrite earlier added values, a | |
| 48 | + | non-matching include is a new combination, match by vector keys only), | |
| 49 | + | exclude (partial match, applied before include), the 256 limit counting | |
| 50 | + | combinations after include, key case-insensitivity, value comparison | |
| 51 | + | (same kind; objects by containment), the error when all combinations are | |
| 52 | + | excluded (GitHub may fail the run differently), empty vector error. | |
| 53 | + | - [ ] **Job display names** (`MatrixCombo.DisplayName`, `Job.DisplayName`): | |
| 54 | + | `name (v1, v2)` listing every scalar value of the combination in key | |
| 55 | + | order, then keys added by include; a name that refers to `matrix.` is | |
| 56 | + | used as is. Booleans/numbers/null are shown with `CoerceString`. | |
| 57 | + | - [ ] **`strategy` context** (`Strategy.Context`): `fail-fast`, `job-index`, | |
| 58 | + | `job-total`, `max-parallel` (the job count when unlimited). | |
| 59 | + | - [ ] `fail-fast` and `max-parallel` accept strings (`"false"`, `"2"`) after | |
| 60 | + | evaluation; `max-parallel` must be a whole number ≥ 1. | |
| 61 | + | - [ ] The 1 000-commit and 300-file limits themselves are the caller's. | |
| @@ -0,0 +1,61 @@ | |||||
| 1 | + | # Porting notes for package workflow | |||
| 2 | + | ||||
| 3 | + | `Parse` is a port of `model/convert` and `model/converter/*` of | |||
| 4 | + | `@actions/workflow-parser` 0.3.61 on top of package `template`. The reference's | |||
| 5 | + | errors are replayed in `TestConvertAgainstReference` (160 files in | |||
| 6 | + | `testdata/convert`, answers in `testdata/convert.json`, generated by | |||
| 7 | + | `gen/convert.mjs` from the unmodified npm package). | |||
| 8 | + | ||||
| 9 | + | The parts below are **not** in that package. They are written from GitHub's | |||
| 10 | + | documentation and from what is known of the official runner, and each needs a | |||
| 11 | + | recording of github.com (`ermite-conformance`) or a look at the C# runner | |||
| 12 | + | before being trusted. Every one carries a `TODO(verify with recording)` in the | |||
| 13 | + | code. | |||
| 14 | + | ||||
| 15 | + | ## Differences from the reference | |||
| 16 | + | ||||
| 17 | + | - **`branches` with `branches-ignore`** (and `tags`, `paths`): the reference | |||
| 18 | + | accepts them; GitHub refuses the file. `Parse` reports | |||
| 19 | + | `Event 'push' cannot use both 'branches' and 'branches-ignore'. Use only one | |||
| 20 | + | of them.` (our wording, to replace with GitHub's). Applied to every event. | |||
| 21 | + | - **Reusable workflows** are parsed (`uses`, `with`, `secrets`, `inherit`) but | |||
| 22 | + | not followed, and the called workflow's inputs and secrets are not checked. | |||
| 23 | + | - Unknown event types are reported by the schema (`Unexpected value 'x'`). | |||
| 24 | + | - `permissions` are modelled but not validated beyond the schema. | |||
| 25 | + | - `Job.If`, `Step.If`: the status-function wrapping is the reference's | |||
| 26 | + | (`success() && (cond)` when the condition calls none of `success`, | |||
| 27 | + | `failure`, `cancelled`, `always`, looking through nested calls). | |||
| 28 | + | ||||
| 29 | + | ## To verify (recordings) | |||
| 30 | + | ||||
| 31 | + | - [ ] **Glob syntax** (`glob.go`): `*`, `**`, `?`, `+`, `[]`, `\`, `!`. The | |||
| 32 | + | cheat sheet's examples pass; `**/` matching nothing, `?` and `+` after a | |||
| 33 | + | `]`, `*` or an escaped character, `[!x]`, an unclosed `[` (literal here) | |||
| 34 | + | and `/`-prefixed patterns are assumed. | |||
| 35 | + | - [ ] **`MatchPush`**: deletes never run; more than 3 tags run nothing for | |||
| 36 | + | tags (not for the branch); branches-only filter and a tag push (no run) | |||
| 37 | + | and the reverse; with both filters each ref uses its own; `paths` only | |||
| 38 | + | for branches; first 300 files (also for `paths-ignore`); truncated list | |||
| 39 | + | passes; a push whose diff is empty never matches a `paths` filter and | |||
| 40 | + | always matches a `paths-ignore` one; unknown ref kinds never match. | |||
| 41 | + | - [ ] **`PushInfo.Created`** is not used: a new branch's file list is the | |||
| 42 | + | caller's job (diff against the default branch merge-base). | |||
| 43 | + | - [ ] **`SkipCI`**: marker case, trailer rules (see its doc comment); whether | |||
| 44 | + | only the head commit counts; `[skip ci]` in the subject vs the body. | |||
| 45 | + | - [ ] **`ExpandMatrix`**: order of the product (first key slowest), include | |||
| 46 | + | semantics (added to every matching combination, never overwriting | |||
| 47 | + | original values, later includes overwrite earlier added values, a | |||
| 48 | + | non-matching include is a new combination, match by vector keys only), | |||
| 49 | + | exclude (partial match, applied before include), the 256 limit counting | |||
| 50 | + | combinations after include, key case-insensitivity, value comparison | |||
| 51 | + | (same kind; objects by containment), the error when all combinations are | |||
| 52 | + | excluded (GitHub may fail the run differently), empty vector error. | |||
| 53 | + | - [ ] **Job display names** (`MatrixCombo.DisplayName`, `Job.DisplayName`): | |||
| 54 | + | `name (v1, v2)` listing every scalar value of the combination in key | |||
| 55 | + | order, then keys added by include; a name that refers to `matrix.` is | |||
| 56 | + | used as is. Booleans/numbers/null are shown with `CoerceString`. | |||
| 57 | + | - [ ] **`strategy` context** (`Strategy.Context`): `fail-fast`, `job-index`, | |||
| 58 | + | `job-total`, `max-parallel` (the job count when unlimited). | |||
| 59 | + | - [ ] `fail-fast` and `max-parallel` accept strings (`"false"`, `"2"`) after | |||
| 60 | + | evaluation; `max-parallel` must be a whole number ≥ 1. | |||
| 61 | + | - [ ] The 1 000-commit and 300-file limits themselves are the caller's. | |||
M workflow/convert.go +0 -4
| @@ -36,10 +36,6 @@ func (c *converter) handle(root template.Token, fn func()) { | |||
| 36 | 36 | fn() | |
| 37 | 37 | } | |
| 38 | 38 | ||
| 39 | - | func throw(t template.Token, format string, a ...any) { | |
| 40 | - | panic(&template.TokenError{Token: t, Message: sprintf(format, a...)}) | |
| 41 | - | } | |
| 42 | - | ||
| 43 | 39 | func ck[T any](v T, err error) T { | |
| 44 | 40 | if err != nil { | |
| 45 | 41 | panic(err) | |
| @@ -36,10 +36,6 @@ func (c *converter) handle(root template.Token, fn func()) { | |||||
| 36 | fn() | 36 | fn() | ||
| 37 | } | 37 | } | ||
| 38 | 38 | ||||
| 39 | - | func throw(t template.Token, format string, a ...any) { | |||
| 40 | - | panic(&template.TokenError{Token: t, Message: sprintf(format, a...)}) | |||
| 41 | - | } | |||
| 42 | - | ||||
| 43 | func ck[T any](v T, err error) T { | 39 | func ck[T any](v T, err error) T { | ||
| 44 | if err != nil { | 40 | if err != nil { | ||
| 45 | panic(err) | 41 | panic(err) | ||
M workflow/convert_jobs.go +0 -3
| @@ -93,7 +93,6 @@ func (c *converter) convertJob(key *template.String, m *template.Mapping) *Job { | |||
| 93 | 93 | } | |
| 94 | 94 | j := &Job{ID: key.Value, IDToken: key} | |
| 95 | 95 | var name template.Token | |
| 96 | - | needsSeen := false | |
| 97 | 96 | for _, p := range m.Pairs { | |
| 98 | 97 | prop := asString(p.Key, "job property name") | |
| 99 | 98 | v := p.Value | |
| @@ -114,7 +113,6 @@ func (c *converter) convertJob(key *template.String, m *template.Mapping) *Job { | |||
| 114 | 113 | case "name": | |
| 115 | 114 | name = asScalar(v, "job name") | |
| 116 | 115 | case "needs": | |
| 117 | - | needsSeen = true | |
| 118 | 116 | j.NeedTokens = nil | |
| 119 | 117 | if s, ok := v.(*template.String); ok { | |
| 120 | 118 | j.NeedTokens = append(j.NeedTokens, asString(s, "job needs id")) | |
| @@ -160,7 +158,6 @@ func (c *converter) convertJob(key *template.String, m *template.Mapping) *Job { | |||
| 160 | 158 | j.Permissions = convertPermissions(v) | |
| 161 | 159 | } | |
| 162 | 160 | } | |
| 163 | - | _ = needsSeen | |
| 164 | 161 | for _, n := range j.NeedTokens { | |
| 165 | 162 | j.Needs = append(j.Needs, n.Value) | |
| 166 | 163 | } | |
| @@ -93,7 +93,6 @@ func (c *converter) convertJob(key *template.String, m *template.Mapping) *Job { | |||||
| 93 | } | 93 | } | ||
| 94 | j := &Job{ID: key.Value, IDToken: key} | 94 | j := &Job{ID: key.Value, IDToken: key} | ||
| 95 | var name template.Token | 95 | var name template.Token | ||
| 96 | - | needsSeen := false | |||
| 97 | for _, p := range m.Pairs { | 96 | for _, p := range m.Pairs { | ||
| 98 | prop := asString(p.Key, "job property name") | 97 | prop := asString(p.Key, "job property name") | ||
| 99 | v := p.Value | 98 | v := p.Value | ||
| @@ -114,7 +113,6 @@ func (c *converter) convertJob(key *template.String, m *template.Mapping) *Job { | |||||
| 114 | case "name": | 113 | case "name": | ||
| 115 | name = asScalar(v, "job name") | 114 | name = asScalar(v, "job name") | ||
| 116 | case "needs": | 115 | case "needs": | ||
| 117 | - | needsSeen = true | |||
| 118 | j.NeedTokens = nil | 116 | j.NeedTokens = nil | ||
| 119 | if s, ok := v.(*template.String); ok { | 117 | if s, ok := v.(*template.String); ok { | ||
| 120 | j.NeedTokens = append(j.NeedTokens, asString(s, "job needs id")) | 118 | j.NeedTokens = append(j.NeedTokens, asString(s, "job needs id")) | ||
| @@ -160,7 +158,6 @@ func (c *converter) convertJob(key *template.String, m *template.Mapping) *Job { | |||||
| 160 | j.Permissions = convertPermissions(v) | 158 | j.Permissions = convertPermissions(v) | ||
| 161 | } | 159 | } | ||
| 162 | } | 160 | } | ||
| 163 | - | _ = needsSeen | |||
| 164 | for _, n := range j.NeedTokens { | 161 | for _, n := range j.NeedTokens { | ||
| 165 | j.Needs = append(j.Needs, n.Value) | 162 | j.Needs = append(j.Needs, n.Value) | ||
| 166 | } | 163 | } | ||
M workflow/convert_misc.go +0 -8
| @@ -3,20 +3,12 @@ | |||
| 3 | 3 | package workflow | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | - | "fmt" | |
| 7 | 6 | "strings" | |
| 8 | 7 | ||
| 9 | 8 | "bnema.dev/ermite-actions/expr" | |
| 10 | 9 | "bnema.dev/ermite-actions/template" | |
| 11 | 10 | ) | |
| 12 | 11 | ||
| 13 | - | func sprintf(format string, a ...any) string { | |
| 14 | - | if len(a) == 0 { | |
| 15 | - | return format | |
| 16 | - | } | |
| 17 | - | return fmt.Sprintf(format, a...) | |
| 18 | - | } | |
| 19 | - | ||
| 20 | 12 | // convertConcurrency validates a concurrency token. Expressions are skipped. | |
| 21 | 13 | func (c *converter) convertConcurrency(t template.Token) { | |
| 22 | 14 | if template.IsExpression(t) { | |
| @@ -3,20 +3,12 @@ | |||||
| 3 | package workflow | 3 | package workflow | ||
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | - | "fmt" | |||
| 7 | "strings" | 6 | "strings" | ||
| 8 | 7 | ||||
| 9 | "bnema.dev/ermite-actions/expr" | 8 | "bnema.dev/ermite-actions/expr" | ||
| 10 | "bnema.dev/ermite-actions/template" | 9 | "bnema.dev/ermite-actions/template" | ||
| 11 | ) | 10 | ) | ||
| 12 | 11 | ||||
| 13 | - | func sprintf(format string, a ...any) string { | |||
| 14 | - | if len(a) == 0 { | |||
| 15 | - | return format | |||
| 16 | - | } | |||
| 17 | - | return fmt.Sprintf(format, a...) | |||
| 18 | - | } | |||
| 19 | - | ||||
| 20 | // convertConcurrency validates a concurrency token. Expressions are skipped. | 12 | // convertConcurrency validates a concurrency token. Expressions are skipped. | ||
| 21 | func (c *converter) convertConcurrency(t template.Token) { | 13 | func (c *converter) convertConcurrency(t template.Token) { | ||
| 22 | if template.IsExpression(t) { | 14 | if template.IsExpression(t) { | ||
M workflow/convert_steps.go +1 -4
| @@ -115,10 +115,7 @@ func actionStepID(uses string) string { | |||
| 115 | 115 | if len(segments) != 2 { | |
| 116 | 116 | return "" | |
| 117 | 117 | } | |
| 118 | - | var path []string | |
| 119 | - | for _, p := range strings.FieldsFunc(segments[0], func(r rune) bool { return r == '/' || r == '\\' }) { | |
| 120 | - | path = append(path, p) | |
| 121 | - | } | |
| 118 | + | path := strings.FieldsFunc(segments[0], func(r rune) bool { return r == '/' || r == '\\' }) | |
| 122 | 119 | if len(path) >= 2 && path[0] != "" && path[1] != "" && segments[1] != "" { | |
| 123 | 120 | return path[0] + "/" + path[1] | |
| 124 | 121 | } | |
| @@ -115,10 +115,7 @@ func actionStepID(uses string) string { | |||||
| 115 | if len(segments) != 2 { | 115 | if len(segments) != 2 { | ||
| 116 | return "" | 116 | return "" | ||
| 117 | } | 117 | } | ||
| 118 | - | var path []string | 118 | + | path := strings.FieldsFunc(segments[0], func(r rune) bool { return r == '/' || r == '\\' }) |
| 119 | - | for _, p := range strings.FieldsFunc(segments[0], func(r rune) bool { return r == '/' || r == '\\' }) { | |||
| 120 | - | path = append(path, p) | |||
| 121 | - | } | |||
| 122 | if len(path) >= 2 && path[0] != "" && path[1] != "" && segments[1] != "" { | 119 | if len(path) >= 2 && path[0] != "" && path[1] != "" && segments[1] != "" { | ||
| 123 | return path[0] + "/" + path[1] | 120 | return path[0] + "/" + path[1] | ||
| 124 | } | 121 | } | ||
M workflow/convert_test.go +1 -1
| @@ -121,7 +121,7 @@ func TestConvertAgainstReference(t *testing.T) { | |||
| 121 | 121 | if j.ID != wj.ID || j.Name.String() != wj.Name || j.If.Expression != wj.If { | |
| 122 | 122 | t.Errorf("job %d: got id=%q name=%q if=%q, reference id=%q name=%q if=%q", i, j.ID, j.Name, j.If.Expression, wj.ID, wj.Name, wj.If) | |
| 123 | 123 | } | |
| 124 | - | if !reflect.DeepEqual(nonNil(j.Needs), nonNil(wj.Needs)) && !(j.IsReusable() && len(wj.Needs) == 0) { | |
| 124 | + | if !reflect.DeepEqual(nonNil(j.Needs), nonNil(wj.Needs)) && (!j.IsReusable() || len(wj.Needs) != 0) { | |
| 125 | 125 | t.Errorf("job %s needs: got %v, reference %v", j.ID, j.Needs, wj.Needs) | |
| 126 | 126 | } | |
| 127 | 127 | if len(j.Steps) != len(wj.Steps) { | |
| @@ -121,7 +121,7 @@ func TestConvertAgainstReference(t *testing.T) { | |||||
| 121 | if j.ID != wj.ID || j.Name.String() != wj.Name || j.If.Expression != wj.If { | 121 | if j.ID != wj.ID || j.Name.String() != wj.Name || j.If.Expression != wj.If { | ||
| 122 | t.Errorf("job %d: got id=%q name=%q if=%q, reference id=%q name=%q if=%q", i, j.ID, j.Name, j.If.Expression, wj.ID, wj.Name, wj.If) | 122 | t.Errorf("job %d: got id=%q name=%q if=%q, reference id=%q name=%q if=%q", i, j.ID, j.Name, j.If.Expression, wj.ID, wj.Name, wj.If) | ||
| 123 | } | 123 | } | ||
| 124 | - | if !reflect.DeepEqual(nonNil(j.Needs), nonNil(wj.Needs)) && !(j.IsReusable() && len(wj.Needs) == 0) { | 124 | + | if !reflect.DeepEqual(nonNil(j.Needs), nonNil(wj.Needs)) && (!j.IsReusable() || len(wj.Needs) != 0) { |
| 125 | t.Errorf("job %s needs: got %v, reference %v", j.ID, j.Needs, wj.Needs) | 125 | t.Errorf("job %s needs: got %v, reference %v", j.ID, j.Needs, wj.Needs) | ||
| 126 | } | 126 | } | ||
| 127 | if len(j.Steps) != len(wj.Steps) { | 127 | if len(j.Steps) != len(wj.Steps) { | ||
A workflow/eval.go +77 -0
| @@ -0,0 +1,77 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package workflow | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "strings" | |
| 8 | + | ||
| 9 | + | "bnema.dev/ermite-actions/expr" | |
| 10 | + | "bnema.dev/ermite-actions/template" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // EvaluateStrategy evaluates the job's strategy token against env and | |
| 14 | + | // expands its matrix. env must provide the contexts the schema allows in | |
| 15 | + | // "strategy": github, inputs, vars and needs. A job without a strategy gives | |
| 16 | + | // a Strategy with one empty combination. | |
| 17 | + | func (j *Job) EvaluateStrategy(ctx context.Context, env template.Env) (*Strategy, error) { | |
| 18 | + | if j.Strategy == nil { | |
| 19 | + | return ParseStrategy(nil) | |
| 20 | + | } | |
| 21 | + | v, err := template.Evaluate(ctx, j.Strategy, env) | |
| 22 | + | if err != nil { | |
| 23 | + | return nil, err | |
| 24 | + | } | |
| 25 | + | return ParseStrategy(v) | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | // MatrixEnv returns env with the "matrix" and "strategy" contexts of one | |
| 29 | + | // combination added, for evaluating the parts of a job that may use them: | |
| 30 | + | // runs-on, name, env, container, services, steps... | |
| 31 | + | func (s *Strategy) MatrixEnv(env template.Env, index int) template.Env { | |
| 32 | + | out := env | |
| 33 | + | out.Contexts = make(map[string]expr.Value, len(env.Contexts)+2) | |
| 34 | + | for k, v := range env.Contexts { | |
| 35 | + | out.Contexts[k] = v | |
| 36 | + | } | |
| 37 | + | out.Contexts["matrix"] = s.Combos[index].Values | |
| 38 | + | out.Contexts["strategy"] = s.Context(index) | |
| 39 | + | return out | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | // DisplayName evaluates the job's name for combination index of its | |
| 43 | + | // strategy: the name token (or the job id) with the matrix values added in | |
| 44 | + | // parentheses when the name does not use the matrix context. env must | |
| 45 | + | // already include the matrix and strategy contexts, see [Strategy.MatrixEnv]. | |
| 46 | + | // | |
| 47 | + | // TODO(verify with recording): GitHub adds the values to the name unless the | |
| 48 | + | // name is set and refers to the matrix; here a name is considered to refer to | |
| 49 | + | // the matrix when its expression text contains "matrix.". | |
| 50 | + | func (j *Job) DisplayName(ctx context.Context, env template.Env, s *Strategy, index int) (string, error) { | |
| 51 | + | name, err := template.EvaluateString(ctx, j.Name, env) | |
| 52 | + | if err != nil { | |
| 53 | + | return "", err | |
| 54 | + | } | |
| 55 | + | if !s.HasMatrix { | |
| 56 | + | return name, nil | |
| 57 | + | } | |
| 58 | + | if j.HasName && usesMatrix(j.Name) { | |
| 59 | + | return name, nil | |
| 60 | + | } | |
| 61 | + | return s.Combos[index].DisplayName(name), nil | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | func usesMatrix(t template.Token) bool { | |
| 65 | + | found := false | |
| 66 | + | template.Walk(t, func(x template.Token) bool { | |
| 67 | + | if be, ok := x.(*template.BasicExpression); ok && containsFold(be.Expression, "matrix.") { | |
| 68 | + | found = true | |
| 69 | + | } | |
| 70 | + | return !found | |
| 71 | + | }) | |
| 72 | + | return found | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | func containsFold(s, sub string) bool { | |
| 76 | + | return strings.Contains(strings.ToLower(s), sub) | |
| 77 | + | } | |
| @@ -0,0 +1,77 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package workflow | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "strings" | |||
| 8 | + | ||||
| 9 | + | "bnema.dev/ermite-actions/expr" | |||
| 10 | + | "bnema.dev/ermite-actions/template" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | // EvaluateStrategy evaluates the job's strategy token against env and | |||
| 14 | + | // expands its matrix. env must provide the contexts the schema allows in | |||
| 15 | + | // "strategy": github, inputs, vars and needs. A job without a strategy gives | |||
| 16 | + | // a Strategy with one empty combination. | |||
| 17 | + | func (j *Job) EvaluateStrategy(ctx context.Context, env template.Env) (*Strategy, error) { | |||
| 18 | + | if j.Strategy == nil { | |||
| 19 | + | return ParseStrategy(nil) | |||
| 20 | + | } | |||
| 21 | + | v, err := template.Evaluate(ctx, j.Strategy, env) | |||
| 22 | + | if err != nil { | |||
| 23 | + | return nil, err | |||
| 24 | + | } | |||
| 25 | + | return ParseStrategy(v) | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | // MatrixEnv returns env with the "matrix" and "strategy" contexts of one | |||
| 29 | + | // combination added, for evaluating the parts of a job that may use them: | |||
| 30 | + | // runs-on, name, env, container, services, steps... | |||
| 31 | + | func (s *Strategy) MatrixEnv(env template.Env, index int) template.Env { | |||
| 32 | + | out := env | |||
| 33 | + | out.Contexts = make(map[string]expr.Value, len(env.Contexts)+2) | |||
| 34 | + | for k, v := range env.Contexts { | |||
| 35 | + | out.Contexts[k] = v | |||
| 36 | + | } | |||
| 37 | + | out.Contexts["matrix"] = s.Combos[index].Values | |||
| 38 | + | out.Contexts["strategy"] = s.Context(index) | |||
| 39 | + | return out | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | // DisplayName evaluates the job's name for combination index of its | |||
| 43 | + | // strategy: the name token (or the job id) with the matrix values added in | |||
| 44 | + | // parentheses when the name does not use the matrix context. env must | |||
| 45 | + | // already include the matrix and strategy contexts, see [Strategy.MatrixEnv]. | |||
| 46 | + | // | |||
| 47 | + | // TODO(verify with recording): GitHub adds the values to the name unless the | |||
| 48 | + | // name is set and refers to the matrix; here a name is considered to refer to | |||
| 49 | + | // the matrix when its expression text contains "matrix.". | |||
| 50 | + | func (j *Job) DisplayName(ctx context.Context, env template.Env, s *Strategy, index int) (string, error) { | |||
| 51 | + | name, err := template.EvaluateString(ctx, j.Name, env) | |||
| 52 | + | if err != nil { | |||
| 53 | + | return "", err | |||
| 54 | + | } | |||
| 55 | + | if !s.HasMatrix { | |||
| 56 | + | return name, nil | |||
| 57 | + | } | |||
| 58 | + | if j.HasName && usesMatrix(j.Name) { | |||
| 59 | + | return name, nil | |||
| 60 | + | } | |||
| 61 | + | return s.Combos[index].DisplayName(name), nil | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | func usesMatrix(t template.Token) bool { | |||
| 65 | + | found := false | |||
| 66 | + | template.Walk(t, func(x template.Token) bool { | |||
| 67 | + | if be, ok := x.(*template.BasicExpression); ok && containsFold(be.Expression, "matrix.") { | |||
| 68 | + | found = true | |||
| 69 | + | } | |||
| 70 | + | return !found | |||
| 71 | + | }) | |||
| 72 | + | return found | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | func containsFold(s, sub string) bool { | |||
| 76 | + | return strings.Contains(strings.ToLower(s), sub) | |||
| 77 | + | } | |||
A workflow/eval_test.go +181 -0
| @@ -0,0 +1,181 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package workflow | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "strings" | |
| 8 | + | "testing" | |
| 9 | + | ||
| 10 | + | "bnema.dev/ermite-actions/expr" | |
| 11 | + | "bnema.dev/ermite-actions/template" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | func envWith(t testing.TB, contexts map[string]string) template.Env { | |
| 15 | + | t.Helper() | |
| 16 | + | env := template.Env{Contexts: map[string]expr.Value{}} | |
| 17 | + | for k, v := range contexts { | |
| 18 | + | env.Contexts[k] = jsonValue(t, v) | |
| 19 | + | } | |
| 20 | + | return env | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | func mustParse(t testing.TB, src string) *Workflow { | |
| 24 | + | t.Helper() | |
| 25 | + | w, errs := Parse("w.yml", []byte(src)) | |
| 26 | + | if len(errs) > 0 { | |
| 27 | + | t.Fatalf("Parse: %v", errs) | |
| 28 | + | } | |
| 29 | + | return w | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | func TestEvaluateStrategyEndToEnd(t *testing.T) { | |
| 33 | + | w := mustParse(t, `on: push | |
| 34 | + | jobs: | |
| 35 | + | build: | |
| 36 | + | name: Build | |
| 37 | + | runs-on: ${{ matrix.os }} | |
| 38 | + | strategy: | |
| 39 | + | fail-fast: false | |
| 40 | + | max-parallel: 2 | |
| 41 | + | matrix: | |
| 42 | + | os: [ubuntu-latest, macos-latest] | |
| 43 | + | node: ${{ fromJSON(needs.setup.outputs.versions) }} | |
| 44 | + | include: | |
| 45 | + | - os: ubuntu-latest | |
| 46 | + | extra: ${{ github.ref_name }} | |
| 47 | + | steps: | |
| 48 | + | - run: echo | |
| 49 | + | setup: | |
| 50 | + | runs-on: x | |
| 51 | + | steps: [{run: a}] | |
| 52 | + | `) | |
| 53 | + | env := envWith(t, map[string]string{ | |
| 54 | + | "github": `{"ref_name": "main"}`, | |
| 55 | + | "needs": `{"setup": {"outputs": {"versions": "[18, 20]"}}}`, | |
| 56 | + | }) | |
| 57 | + | job := w.Job("build") | |
| 58 | + | s, err := job.EvaluateStrategy(context.Background(), env) | |
| 59 | + | if err != nil { | |
| 60 | + | t.Fatal(err) | |
| 61 | + | } | |
| 62 | + | if s.FailFast || s.MaxParallel != 2 || len(s.Combos) != 4 { | |
| 63 | + | t.Fatalf("strategy = %+v", s) | |
| 64 | + | } | |
| 65 | + | names := make([]string, len(s.Combos)) | |
| 66 | + | runsOn := make([]string, len(s.Combos)) | |
| 67 | + | for i := range s.Combos { | |
| 68 | + | e := s.MatrixEnv(env, i) | |
| 69 | + | names[i], err = job.DisplayName(context.Background(), e, s, i) | |
| 70 | + | if err != nil { | |
| 71 | + | t.Fatal(err) | |
| 72 | + | } | |
| 73 | + | runsOn[i], err = template.EvaluateString(context.Background(), job.RunsOn, e) | |
| 74 | + | if err != nil { | |
| 75 | + | t.Fatal(err) | |
| 76 | + | } | |
| 77 | + | } | |
| 78 | + | wantNames := []string{ | |
| 79 | + | "Build (ubuntu-latest, 18, main)", "Build (ubuntu-latest, 20, main)", | |
| 80 | + | "Build (macos-latest, 18)", "Build (macos-latest, 20)", | |
| 81 | + | } | |
| 82 | + | if strings.Join(names, "|") != strings.Join(wantNames, "|") { | |
| 83 | + | t.Errorf("names = %q, want %q", names, wantNames) | |
| 84 | + | } | |
| 85 | + | if runsOn[0] != "ubuntu-latest" || runsOn[3] != "macos-latest" { | |
| 86 | + | t.Errorf("runs-on = %q", runsOn) | |
| 87 | + | } | |
| 88 | + | // The strategy context. | |
| 89 | + | schema, _ := template.WorkflowSchema() | |
| 90 | + | e := template.NewExpression(template.Origin{}, "format('{0}/{1}', strategy.job-index, strategy.job-total)", schema.Info("runs-on")) | |
| 91 | + | if got, err := template.EvaluateString(context.Background(), e, s.MatrixEnv(env, 2)); err != nil || got != "2/4" { | |
| 92 | + | t.Errorf("strategy context = %q, %v", got, err) | |
| 93 | + | } | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | func TestDisplayNameUsingMatrix(t *testing.T) { | |
| 97 | + | w := mustParse(t, `on: push | |
| 98 | + | jobs: | |
| 99 | + | a: | |
| 100 | + | name: build-${{ matrix.os }} | |
| 101 | + | runs-on: x | |
| 102 | + | strategy: | |
| 103 | + | matrix: | |
| 104 | + | os: [l, m] | |
| 105 | + | steps: [{run: a}] | |
| 106 | + | b: | |
| 107 | + | runs-on: x | |
| 108 | + | steps: [{run: a}] | |
| 109 | + | `) | |
| 110 | + | env := envWith(t, nil) | |
| 111 | + | j := w.Job("a") | |
| 112 | + | s, err := j.EvaluateStrategy(context.Background(), env) | |
| 113 | + | if err != nil { | |
| 114 | + | t.Fatal(err) | |
| 115 | + | } | |
| 116 | + | if got, _ := j.DisplayName(context.Background(), s.MatrixEnv(env, 1), s, 1); got != "build-m" { | |
| 117 | + | t.Errorf("name = %q", got) | |
| 118 | + | } | |
| 119 | + | b := w.Job("b") | |
| 120 | + | sb, _ := b.EvaluateStrategy(context.Background(), env) | |
| 121 | + | if got, _ := b.DisplayName(context.Background(), env, sb, 0); got != "b" { | |
| 122 | + | t.Errorf("name = %q", got) | |
| 123 | + | } | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | func TestEvaluateStrategyErrors(t *testing.T) { | |
| 127 | + | w := mustParse(t, `on: push | |
| 128 | + | jobs: | |
| 129 | + | a: | |
| 130 | + | runs-on: x | |
| 131 | + | strategy: | |
| 132 | + | matrix: ${{ fromJSON(inputs.m) }} | |
| 133 | + | steps: [{run: a}] | |
| 134 | + | `) | |
| 135 | + | env := envWith(t, map[string]string{"inputs": `{"m": "not json"}`}) | |
| 136 | + | _, err := w.Job("a").EvaluateStrategy(context.Background(), env) | |
| 137 | + | if err == nil || !strings.Contains(err.Error(), "w.yml (Line: 6, Col: 15)") { | |
| 138 | + | t.Errorf("err = %v", err) | |
| 139 | + | } | |
| 140 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 141 | + | cancel() | |
| 142 | + | if _, err := w.Job("a").EvaluateStrategy(ctx, envWith(t, map[string]string{"inputs": `{"m": "{}"}`})); err == nil { | |
| 143 | + | t.Error("cancelled context: no error") | |
| 144 | + | } | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | func TestJobIfAndStatusFunctions(t *testing.T) { | |
| 148 | + | w := mustParse(t, `on: push | |
| 149 | + | jobs: | |
| 150 | + | a: | |
| 151 | + | runs-on: x | |
| 152 | + | steps: [{run: a}] | |
| 153 | + | b: | |
| 154 | + | needs: a | |
| 155 | + | if: needs.a.result == 'failure' | |
| 156 | + | runs-on: x | |
| 157 | + | steps: | |
| 158 | + | - run: a | |
| 159 | + | if: always() | |
| 160 | + | - run: b | |
| 161 | + | `) | |
| 162 | + | b := w.Job("b") | |
| 163 | + | env := envWith(t, map[string]string{"needs": `{"a": {"result": "failure"}}`}) | |
| 164 | + | funcs := map[string]expr.Func{ | |
| 165 | + | "success": func(context.Context, []expr.Value) (expr.Value, error) { return expr.Boolean(true), nil }, | |
| 166 | + | "always": func(context.Context, []expr.Value) (expr.Value, error) { return expr.Boolean(true), nil }, | |
| 167 | + | "failure": func(context.Context, []expr.Value) (expr.Value, error) { return expr.Boolean(false), nil }, | |
| 168 | + | "cancelled": func(context.Context, []expr.Value) (expr.Value, error) { return expr.Boolean(false), nil }, | |
| 169 | + | } | |
| 170 | + | env.Funcs = funcs | |
| 171 | + | if b.If.Expression != "success() && (needs.a.result == 'failure')" { | |
| 172 | + | t.Fatalf("if = %s", b.If.Expression) | |
| 173 | + | } | |
| 174 | + | ok, err := template.EvaluateBool(context.Background(), b.If, env) | |
| 175 | + | if err != nil || !ok { | |
| 176 | + | t.Errorf("job if = %v, %v", ok, err) | |
| 177 | + | } | |
| 178 | + | if b.Steps[0].If.Expression != "always()" || b.Steps[1].If.Expression != "success()" { | |
| 179 | + | t.Errorf("step ifs = %s, %s", b.Steps[0].If.Expression, b.Steps[1].If.Expression) | |
| 180 | + | } | |
| 181 | + | } | |
| @@ -0,0 +1,181 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package workflow | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "strings" | |||
| 8 | + | "testing" | |||
| 9 | + | ||||
| 10 | + | "bnema.dev/ermite-actions/expr" | |||
| 11 | + | "bnema.dev/ermite-actions/template" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | func envWith(t testing.TB, contexts map[string]string) template.Env { | |||
| 15 | + | t.Helper() | |||
| 16 | + | env := template.Env{Contexts: map[string]expr.Value{}} | |||
| 17 | + | for k, v := range contexts { | |||
| 18 | + | env.Contexts[k] = jsonValue(t, v) | |||
| 19 | + | } | |||
| 20 | + | return env | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | func mustParse(t testing.TB, src string) *Workflow { | |||
| 24 | + | t.Helper() | |||
| 25 | + | w, errs := Parse("w.yml", []byte(src)) | |||
| 26 | + | if len(errs) > 0 { | |||
| 27 | + | t.Fatalf("Parse: %v", errs) | |||
| 28 | + | } | |||
| 29 | + | return w | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | func TestEvaluateStrategyEndToEnd(t *testing.T) { | |||
| 33 | + | w := mustParse(t, `on: push | |||
| 34 | + | jobs: | |||
| 35 | + | build: | |||
| 36 | + | name: Build | |||
| 37 | + | runs-on: ${{ matrix.os }} | |||
| 38 | + | strategy: | |||
| 39 | + | fail-fast: false | |||
| 40 | + | max-parallel: 2 | |||
| 41 | + | matrix: | |||
| 42 | + | os: [ubuntu-latest, macos-latest] | |||
| 43 | + | node: ${{ fromJSON(needs.setup.outputs.versions) }} | |||
| 44 | + | include: | |||
| 45 | + | - os: ubuntu-latest | |||
| 46 | + | extra: ${{ github.ref_name }} | |||
| 47 | + | steps: | |||
| 48 | + | - run: echo | |||
| 49 | + | setup: | |||
| 50 | + | runs-on: x | |||
| 51 | + | steps: [{run: a}] | |||
| 52 | + | `) | |||
| 53 | + | env := envWith(t, map[string]string{ | |||
| 54 | + | "github": `{"ref_name": "main"}`, | |||
| 55 | + | "needs": `{"setup": {"outputs": {"versions": "[18, 20]"}}}`, | |||
| 56 | + | }) | |||
| 57 | + | job := w.Job("build") | |||
| 58 | + | s, err := job.EvaluateStrategy(context.Background(), env) | |||
| 59 | + | if err != nil { | |||
| 60 | + | t.Fatal(err) | |||
| 61 | + | } | |||
| 62 | + | if s.FailFast || s.MaxParallel != 2 || len(s.Combos) != 4 { | |||
| 63 | + | t.Fatalf("strategy = %+v", s) | |||
| 64 | + | } | |||
| 65 | + | names := make([]string, len(s.Combos)) | |||
| 66 | + | runsOn := make([]string, len(s.Combos)) | |||
| 67 | + | for i := range s.Combos { | |||
| 68 | + | e := s.MatrixEnv(env, i) | |||
| 69 | + | names[i], err = job.DisplayName(context.Background(), e, s, i) | |||
| 70 | + | if err != nil { | |||
| 71 | + | t.Fatal(err) | |||
| 72 | + | } | |||
| 73 | + | runsOn[i], err = template.EvaluateString(context.Background(), job.RunsOn, e) | |||
| 74 | + | if err != nil { | |||
| 75 | + | t.Fatal(err) | |||
| 76 | + | } | |||
| 77 | + | } | |||
| 78 | + | wantNames := []string{ | |||
| 79 | + | "Build (ubuntu-latest, 18, main)", "Build (ubuntu-latest, 20, main)", | |||
| 80 | + | "Build (macos-latest, 18)", "Build (macos-latest, 20)", | |||
| 81 | + | } | |||
| 82 | + | if strings.Join(names, "|") != strings.Join(wantNames, "|") { | |||
| 83 | + | t.Errorf("names = %q, want %q", names, wantNames) | |||
| 84 | + | } | |||
| 85 | + | if runsOn[0] != "ubuntu-latest" || runsOn[3] != "macos-latest" { | |||
| 86 | + | t.Errorf("runs-on = %q", runsOn) | |||
| 87 | + | } | |||
| 88 | + | // The strategy context. | |||
| 89 | + | schema, _ := template.WorkflowSchema() | |||
| 90 | + | e := template.NewExpression(template.Origin{}, "format('{0}/{1}', strategy.job-index, strategy.job-total)", schema.Info("runs-on")) | |||
| 91 | + | if got, err := template.EvaluateString(context.Background(), e, s.MatrixEnv(env, 2)); err != nil || got != "2/4" { | |||
| 92 | + | t.Errorf("strategy context = %q, %v", got, err) | |||
| 93 | + | } | |||
| 94 | + | } | |||
| 95 | + | ||||
| 96 | + | func TestDisplayNameUsingMatrix(t *testing.T) { | |||
| 97 | + | w := mustParse(t, `on: push | |||
| 98 | + | jobs: | |||
| 99 | + | a: | |||
| 100 | + | name: build-${{ matrix.os }} | |||
| 101 | + | runs-on: x | |||
| 102 | + | strategy: | |||
| 103 | + | matrix: | |||
| 104 | + | os: [l, m] | |||
| 105 | + | steps: [{run: a}] | |||
| 106 | + | b: | |||
| 107 | + | runs-on: x | |||
| 108 | + | steps: [{run: a}] | |||
| 109 | + | `) | |||
| 110 | + | env := envWith(t, nil) | |||
| 111 | + | j := w.Job("a") | |||
| 112 | + | s, err := j.EvaluateStrategy(context.Background(), env) | |||
| 113 | + | if err != nil { | |||
| 114 | + | t.Fatal(err) | |||
| 115 | + | } | |||
| 116 | + | if got, _ := j.DisplayName(context.Background(), s.MatrixEnv(env, 1), s, 1); got != "build-m" { | |||
| 117 | + | t.Errorf("name = %q", got) | |||
| 118 | + | } | |||
| 119 | + | b := w.Job("b") | |||
| 120 | + | sb, _ := b.EvaluateStrategy(context.Background(), env) | |||
| 121 | + | if got, _ := b.DisplayName(context.Background(), env, sb, 0); got != "b" { | |||
| 122 | + | t.Errorf("name = %q", got) | |||
| 123 | + | } | |||
| 124 | + | } | |||
| 125 | + | ||||
| 126 | + | func TestEvaluateStrategyErrors(t *testing.T) { | |||
| 127 | + | w := mustParse(t, `on: push | |||
| 128 | + | jobs: | |||
| 129 | + | a: | |||
| 130 | + | runs-on: x | |||
| 131 | + | strategy: | |||
| 132 | + | matrix: ${{ fromJSON(inputs.m) }} | |||
| 133 | + | steps: [{run: a}] | |||
| 134 | + | `) | |||
| 135 | + | env := envWith(t, map[string]string{"inputs": `{"m": "not json"}`}) | |||
| 136 | + | _, err := w.Job("a").EvaluateStrategy(context.Background(), env) | |||
| 137 | + | if err == nil || !strings.Contains(err.Error(), "w.yml (Line: 6, Col: 15)") { | |||
| 138 | + | t.Errorf("err = %v", err) | |||
| 139 | + | } | |||
| 140 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 141 | + | cancel() | |||
| 142 | + | if _, err := w.Job("a").EvaluateStrategy(ctx, envWith(t, map[string]string{"inputs": `{"m": "{}"}`})); err == nil { | |||
| 143 | + | t.Error("cancelled context: no error") | |||
| 144 | + | } | |||
| 145 | + | } | |||
| 146 | + | ||||
| 147 | + | func TestJobIfAndStatusFunctions(t *testing.T) { | |||
| 148 | + | w := mustParse(t, `on: push | |||
| 149 | + | jobs: | |||
| 150 | + | a: | |||
| 151 | + | runs-on: x | |||
| 152 | + | steps: [{run: a}] | |||
| 153 | + | b: | |||
| 154 | + | needs: a | |||
| 155 | + | if: needs.a.result == 'failure' | |||
| 156 | + | runs-on: x | |||
| 157 | + | steps: | |||
| 158 | + | - run: a | |||
| 159 | + | if: always() | |||
| 160 | + | - run: b | |||
| 161 | + | `) | |||
| 162 | + | b := w.Job("b") | |||
| 163 | + | env := envWith(t, map[string]string{"needs": `{"a": {"result": "failure"}}`}) | |||
| 164 | + | funcs := map[string]expr.Func{ | |||
| 165 | + | "success": func(context.Context, []expr.Value) (expr.Value, error) { return expr.Boolean(true), nil }, | |||
| 166 | + | "always": func(context.Context, []expr.Value) (expr.Value, error) { return expr.Boolean(true), nil }, | |||
| 167 | + | "failure": func(context.Context, []expr.Value) (expr.Value, error) { return expr.Boolean(false), nil }, | |||
| 168 | + | "cancelled": func(context.Context, []expr.Value) (expr.Value, error) { return expr.Boolean(false), nil }, | |||
| 169 | + | } | |||
| 170 | + | env.Funcs = funcs | |||
| 171 | + | if b.If.Expression != "success() && (needs.a.result == 'failure')" { | |||
| 172 | + | t.Fatalf("if = %s", b.If.Expression) | |||
| 173 | + | } | |||
| 174 | + | ok, err := template.EvaluateBool(context.Background(), b.If, env) | |||
| 175 | + | if err != nil || !ok { | |||
| 176 | + | t.Errorf("job if = %v, %v", ok, err) | |||
| 177 | + | } | |||
| 178 | + | if b.Steps[0].If.Expression != "always()" || b.Steps[1].If.Expression != "success()" { | |||
| 179 | + | t.Errorf("step ifs = %s, %s", b.Steps[0].If.Expression, b.Steps[1].If.Expression) | |||
| 180 | + | } | |||
| 181 | + | } | |||
M workflow/events.go +1 -1
| @@ -249,7 +249,7 @@ func (c *converter) convertInput(name string, m *template.Mapping, call bool) In | |||
| 249 | 249 | if def != nil { | |
| 250 | 250 | in.HasDefault = true | |
| 251 | 251 | in.Default = def.String() | |
| 252 | - | if !(call && template.IsExpression(def)) { | |
| 252 | + | if !call || !template.IsExpression(def) { | |
| 253 | 253 | c.handle(def, func() { | |
| 254 | 254 | switch in.Type { | |
| 255 | 255 | case "boolean": | |
| @@ -249,7 +249,7 @@ func (c *converter) convertInput(name string, m *template.Mapping, call bool) In | |||||
| 249 | if def != nil { | 249 | if def != nil { | ||
| 250 | in.HasDefault = true | 250 | in.HasDefault = true | ||
| 251 | in.Default = def.String() | 251 | in.Default = def.String() | ||
| 252 | - | if !(call && template.IsExpression(def)) { | 252 | + | if !call || !template.IsExpression(def) { |
| 253 | c.handle(def, func() { | 253 | c.handle(def, func() { | ||
| 254 | switch in.Type { | 254 | switch in.Type { | ||
| 255 | case "boolean": | 255 | case "boolean": | ||
A workflow/fuzz_test.go +51 -0
| @@ -0,0 +1,51 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package workflow | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "os" | |
| 7 | + | "path/filepath" | |
| 8 | + | "testing" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func FuzzParseWorkflow(f *testing.F) { | |
| 12 | + | files, _ := filepath.Glob("testdata/convert/*.yml") | |
| 13 | + | for _, name := range files { | |
| 14 | + | if data, err := os.ReadFile(name); err == nil { | |
| 15 | + | f.Add(data) | |
| 16 | + | } | |
| 17 | + | } | |
| 18 | + | f.Add([]byte("on: push\njobs:\n a:\n runs-on: x\n steps: [{run: a}]\n")) | |
| 19 | + | f.Add([]byte("")) | |
| 20 | + | f.Add([]byte("\x00\xff")) | |
| 21 | + | f.Fuzz(func(t *testing.T, data []byte) { | |
| 22 | + | w, errs := Parse("f.yml", data) | |
| 23 | + | if (w == nil) == (len(errs) == 0) { | |
| 24 | + | t.Fatalf("workflow nil = %v with %d errors", w == nil, len(errs)) | |
| 25 | + | } | |
| 26 | + | for _, e := range errs { | |
| 27 | + | _ = e.Error() | |
| 28 | + | } | |
| 29 | + | if w != nil { | |
| 30 | + | w.MatchPush(PushInfo{Ref: "refs/heads/main", ChangedFiles: []string{"a/b"}}) | |
| 31 | + | w.MatchPush(PushInfo{Ref: "refs/tags/v1", TagCount: 1}) | |
| 32 | + | } | |
| 33 | + | }) | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | func FuzzExpandMatrix(f *testing.F) { | |
| 37 | + | f.Add(`{"matrix": {"a": [1, 2], "include": [{"a": 1, "b": 2}], "exclude": [{"a": 2}]}}`) | |
| 38 | + | f.Add(`{"matrix": {"a": [1], "b": [[1], {"c": 2}]}}`) | |
| 39 | + | f.Fuzz(func(t *testing.T, s string) { | |
| 40 | + | v := jsonValue2(s) | |
| 41 | + | if v == nil { | |
| 42 | + | return | |
| 43 | + | } | |
| 44 | + | _, _ = ExpandMatrix(v) | |
| 45 | + | }) | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | func FuzzSkipCI(f *testing.F) { | |
| 49 | + | f.Add("fix\n\nskip-checks: true") | |
| 50 | + | f.Fuzz(func(t *testing.T, s string) { SkipCI(s) }) | |
| 51 | + | } | |
| @@ -0,0 +1,51 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package workflow | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "os" | |||
| 7 | + | "path/filepath" | |||
| 8 | + | "testing" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func FuzzParseWorkflow(f *testing.F) { | |||
| 12 | + | files, _ := filepath.Glob("testdata/convert/*.yml") | |||
| 13 | + | for _, name := range files { | |||
| 14 | + | if data, err := os.ReadFile(name); err == nil { | |||
| 15 | + | f.Add(data) | |||
| 16 | + | } | |||
| 17 | + | } | |||
| 18 | + | f.Add([]byte("on: push\njobs:\n a:\n runs-on: x\n steps: [{run: a}]\n")) | |||
| 19 | + | f.Add([]byte("")) | |||
| 20 | + | f.Add([]byte("\x00\xff")) | |||
| 21 | + | f.Fuzz(func(t *testing.T, data []byte) { | |||
| 22 | + | w, errs := Parse("f.yml", data) | |||
| 23 | + | if (w == nil) == (len(errs) == 0) { | |||
| 24 | + | t.Fatalf("workflow nil = %v with %d errors", w == nil, len(errs)) | |||
| 25 | + | } | |||
| 26 | + | for _, e := range errs { | |||
| 27 | + | _ = e.Error() | |||
| 28 | + | } | |||
| 29 | + | if w != nil { | |||
| 30 | + | w.MatchPush(PushInfo{Ref: "refs/heads/main", ChangedFiles: []string{"a/b"}}) | |||
| 31 | + | w.MatchPush(PushInfo{Ref: "refs/tags/v1", TagCount: 1}) | |||
| 32 | + | } | |||
| 33 | + | }) | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | func FuzzExpandMatrix(f *testing.F) { | |||
| 37 | + | f.Add(`{"matrix": {"a": [1, 2], "include": [{"a": 1, "b": 2}], "exclude": [{"a": 2}]}}`) | |||
| 38 | + | f.Add(`{"matrix": {"a": [1], "b": [[1], {"c": 2}]}}`) | |||
| 39 | + | f.Fuzz(func(t *testing.T, s string) { | |||
| 40 | + | v := jsonValue2(s) | |||
| 41 | + | if v == nil { | |||
| 42 | + | return | |||
| 43 | + | } | |||
| 44 | + | _, _ = ExpandMatrix(v) | |||
| 45 | + | }) | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | func FuzzSkipCI(f *testing.F) { | |||
| 49 | + | f.Add("fix\n\nskip-checks: true") | |||
| 50 | + | f.Fuzz(func(t *testing.T, s string) { SkipCI(s) }) | |||
| 51 | + | } | |||
M workflow/matrix.go +10 -10
| @@ -100,13 +100,13 @@ func ParseStrategy(v expr.Value) (*Strategy, error) { | |||
| 100 | 100 | case "fail-fast": | |
| 101 | 101 | b, err := strategyBool(p.Value) | |
| 102 | 102 | if err != nil { | |
| 103 | - | return nil, fmt.Errorf("Unexpected value for 'fail-fast': %w", err) | |
| 103 | + | return nil, fmt.Errorf("unexpected value for 'fail-fast': %w", err) | |
| 104 | 104 | } | |
| 105 | 105 | s.FailFast = b | |
| 106 | 106 | case "max-parallel": | |
| 107 | 107 | n, err := strategyInt(p.Value) | |
| 108 | 108 | if err != nil { | |
| 109 | - | return nil, fmt.Errorf("Unexpected value for 'max-parallel': %w", err) | |
| 109 | + | return nil, fmt.Errorf("unexpected value for 'max-parallel': %w", err) | |
| 110 | 110 | } | |
| 111 | 111 | s.MaxParallel = n | |
| 112 | 112 | case "matrix": | |
| @@ -114,7 +114,7 @@ func ParseStrategy(v expr.Value) (*Strategy, error) { | |||
| 114 | 114 | } | |
| 115 | 115 | } | |
| 116 | 116 | default: | |
| 117 | - | return nil, errors.New("The strategy must be a mapping") | |
| 117 | + | return nil, errors.New("the strategy must be a mapping") | |
| 118 | 118 | } | |
| 119 | 119 | combos, has, err := buildMatrix(matrix) | |
| 120 | 120 | if err != nil { | |
| @@ -187,7 +187,7 @@ func buildMatrix(m expr.Value) ([]MatrixCombo, bool, error) { | |||
| 187 | 187 | } | |
| 188 | 188 | md, ok := m.(*expr.Dictionary) | |
| 189 | 189 | if !ok { | |
| 190 | - | return nil, false, errors.New("The matrix must be a mapping") | |
| 190 | + | return nil, false, errors.New("the matrix must be a mapping") | |
| 191 | 191 | } | |
| 192 | 192 | if md == nil { | |
| 193 | 193 | return []MatrixCombo{{Values: expr.NewDictionary()}}, false, nil | |
| @@ -212,10 +212,10 @@ func buildMatrix(m expr.Value) ([]MatrixCombo, bool, error) { | |||
| 212 | 212 | default: | |
| 213 | 213 | arr, ok := p.Value.(*expr.Array) | |
| 214 | 214 | if !ok || arr == nil { | |
| 215 | - | return nil, false, fmt.Errorf("Matrix vector '%s' does not contain any values", p.Key) | |
| 215 | + | return nil, false, fmt.Errorf("matrix vector '%s' does not contain any values", p.Key) | |
| 216 | 216 | } | |
| 217 | 217 | if arr.Len() == 0 { | |
| 218 | - | return nil, false, fmt.Errorf("Matrix vector '%s' does not contain any values", p.Key) | |
| 218 | + | return nil, false, fmt.Errorf("matrix vector '%s' does not contain any values", p.Key) | |
| 219 | 219 | } | |
| 220 | 220 | keys = append(keys, p.Key) | |
| 221 | 221 | vectors[p.Key] = arr.Items() | |
| @@ -227,13 +227,13 @@ func buildMatrix(m expr.Value) ([]MatrixCombo, bool, error) { | |||
| 227 | 227 | func matrixEntries(name string, v expr.Value) ([]*expr.Dictionary, error) { | |
| 228 | 228 | arr, ok := v.(*expr.Array) | |
| 229 | 229 | if !ok || arr == nil { | |
| 230 | - | return nil, fmt.Errorf("Matrix %s must be a sequence of mappings", name) | |
| 230 | + | return nil, fmt.Errorf("matrix %s must be a sequence of mappings", name) | |
| 231 | 231 | } | |
| 232 | 232 | var out []*expr.Dictionary | |
| 233 | 233 | for _, it := range arr.Items() { | |
| 234 | 234 | d, ok := it.(*expr.Dictionary) | |
| 235 | 235 | if !ok || d == nil { | |
| 236 | - | return nil, fmt.Errorf("Matrix %s must be a sequence of mappings", name) | |
| 236 | + | return nil, fmt.Errorf("matrix %s must be a sequence of mappings", name) | |
| 237 | 237 | } | |
| 238 | 238 | out = append(out, d) | |
| 239 | 239 | } | |
| @@ -272,7 +272,7 @@ func (b *comboBuilder) combo() MatrixCombo { | |||
| 272 | 272 | func expand(keys []string, vectors map[string][]expr.Value, includes, excludes []*expr.Dictionary) ([]MatrixCombo, bool, error) { | |
| 273 | 273 | var combos []MatrixCombo | |
| 274 | 274 | matched := make([]bool, len(includes)) | |
| 275 | - | tooBig := fmt.Errorf("Matrix size limit of %d exceeded", MaxMatrixJobs) | |
| 275 | + | tooBig := fmt.Errorf("matrix size limit of %d exceeded", MaxMatrixJobs) | |
| 276 | 276 | if len(keys) > 0 { | |
| 277 | 277 | product := 1 | |
| 278 | 278 | for _, k := range keys { | |
| @@ -329,7 +329,7 @@ func expand(keys []string, vectors map[string][]expr.Value, includes, excludes [ | |||
| 329 | 329 | } | |
| 330 | 330 | // TODO(verify with recording): GitHub's behaviour when every | |
| 331 | 331 | // combination is excluded. | |
| 332 | - | return nil, false, errors.New("The matrix has no combinations: all of them are excluded") | |
| 332 | + | return nil, false, errors.New("the matrix has no combinations: all of them are excluded") | |
| 333 | 333 | } | |
| 334 | 334 | return combos, true, nil | |
| 335 | 335 | } | |
| @@ -100,13 +100,13 @@ func ParseStrategy(v expr.Value) (*Strategy, error) { | |||||
| 100 | case "fail-fast": | 100 | case "fail-fast": | ||
| 101 | b, err := strategyBool(p.Value) | 101 | b, err := strategyBool(p.Value) | ||
| 102 | if err != nil { | 102 | if err != nil { | ||
| 103 | - | return nil, fmt.Errorf("Unexpected value for 'fail-fast': %w", err) | 103 | + | return nil, fmt.Errorf("unexpected value for 'fail-fast': %w", err) |
| 104 | } | 104 | } | ||
| 105 | s.FailFast = b | 105 | s.FailFast = b | ||
| 106 | case "max-parallel": | 106 | case "max-parallel": | ||
| 107 | n, err := strategyInt(p.Value) | 107 | n, err := strategyInt(p.Value) | ||
| 108 | if err != nil { | 108 | if err != nil { | ||
| 109 | - | return nil, fmt.Errorf("Unexpected value for 'max-parallel': %w", err) | 109 | + | return nil, fmt.Errorf("unexpected value for 'max-parallel': %w", err) |
| 110 | } | 110 | } | ||
| 111 | s.MaxParallel = n | 111 | s.MaxParallel = n | ||
| 112 | case "matrix": | 112 | case "matrix": | ||
| @@ -114,7 +114,7 @@ func ParseStrategy(v expr.Value) (*Strategy, error) { | |||||
| 114 | } | 114 | } | ||
| 115 | } | 115 | } | ||
| 116 | default: | 116 | default: | ||
| 117 | - | return nil, errors.New("The strategy must be a mapping") | 117 | + | return nil, errors.New("the strategy must be a mapping") |
| 118 | } | 118 | } | ||
| 119 | combos, has, err := buildMatrix(matrix) | 119 | combos, has, err := buildMatrix(matrix) | ||
| 120 | if err != nil { | 120 | if err != nil { | ||
| @@ -187,7 +187,7 @@ func buildMatrix(m expr.Value) ([]MatrixCombo, bool, error) { | |||||
| 187 | } | 187 | } | ||
| 188 | md, ok := m.(*expr.Dictionary) | 188 | md, ok := m.(*expr.Dictionary) | ||
| 189 | if !ok { | 189 | if !ok { | ||
| 190 | - | return nil, false, errors.New("The matrix must be a mapping") | 190 | + | return nil, false, errors.New("the matrix must be a mapping") |
| 191 | } | 191 | } | ||
| 192 | if md == nil { | 192 | if md == nil { | ||
| 193 | return []MatrixCombo{{Values: expr.NewDictionary()}}, false, nil | 193 | return []MatrixCombo{{Values: expr.NewDictionary()}}, false, nil | ||
| @@ -212,10 +212,10 @@ func buildMatrix(m expr.Value) ([]MatrixCombo, bool, error) { | |||||
| 212 | default: | 212 | default: | ||
| 213 | arr, ok := p.Value.(*expr.Array) | 213 | arr, ok := p.Value.(*expr.Array) | ||
| 214 | if !ok || arr == nil { | 214 | if !ok || arr == nil { | ||
| 215 | - | return nil, false, fmt.Errorf("Matrix vector '%s' does not contain any values", p.Key) | 215 | + | return nil, false, fmt.Errorf("matrix vector '%s' does not contain any values", p.Key) |
| 216 | } | 216 | } | ||
| 217 | if arr.Len() == 0 { | 217 | if arr.Len() == 0 { | ||
| 218 | - | return nil, false, fmt.Errorf("Matrix vector '%s' does not contain any values", p.Key) | 218 | + | return nil, false, fmt.Errorf("matrix vector '%s' does not contain any values", p.Key) |
| 219 | } | 219 | } | ||
| 220 | keys = append(keys, p.Key) | 220 | keys = append(keys, p.Key) | ||
| 221 | vectors[p.Key] = arr.Items() | 221 | vectors[p.Key] = arr.Items() | ||
| @@ -227,13 +227,13 @@ func buildMatrix(m expr.Value) ([]MatrixCombo, bool, error) { | |||||
| 227 | func matrixEntries(name string, v expr.Value) ([]*expr.Dictionary, error) { | 227 | func matrixEntries(name string, v expr.Value) ([]*expr.Dictionary, error) { | ||
| 228 | arr, ok := v.(*expr.Array) | 228 | arr, ok := v.(*expr.Array) | ||
| 229 | if !ok || arr == nil { | 229 | if !ok || arr == nil { | ||
| 230 | - | return nil, fmt.Errorf("Matrix %s must be a sequence of mappings", name) | 230 | + | return nil, fmt.Errorf("matrix %s must be a sequence of mappings", name) |
| 231 | } | 231 | } | ||
| 232 | var out []*expr.Dictionary | 232 | var out []*expr.Dictionary | ||
| 233 | for _, it := range arr.Items() { | 233 | for _, it := range arr.Items() { | ||
| 234 | d, ok := it.(*expr.Dictionary) | 234 | d, ok := it.(*expr.Dictionary) | ||
| 235 | if !ok || d == nil { | 235 | if !ok || d == nil { | ||
| 236 | - | return nil, fmt.Errorf("Matrix %s must be a sequence of mappings", name) | 236 | + | return nil, fmt.Errorf("matrix %s must be a sequence of mappings", name) |
| 237 | } | 237 | } | ||
| 238 | out = append(out, d) | 238 | out = append(out, d) | ||
| 239 | } | 239 | } | ||
| @@ -272,7 +272,7 @@ func (b *comboBuilder) combo() MatrixCombo { | |||||
| 272 | func expand(keys []string, vectors map[string][]expr.Value, includes, excludes []*expr.Dictionary) ([]MatrixCombo, bool, error) { | 272 | func expand(keys []string, vectors map[string][]expr.Value, includes, excludes []*expr.Dictionary) ([]MatrixCombo, bool, error) { | ||
| 273 | var combos []MatrixCombo | 273 | var combos []MatrixCombo | ||
| 274 | matched := make([]bool, len(includes)) | 274 | matched := make([]bool, len(includes)) | ||
| 275 | - | tooBig := fmt.Errorf("Matrix size limit of %d exceeded", MaxMatrixJobs) | 275 | + | tooBig := fmt.Errorf("matrix size limit of %d exceeded", MaxMatrixJobs) |
| 276 | if len(keys) > 0 { | 276 | if len(keys) > 0 { | ||
| 277 | product := 1 | 277 | product := 1 | ||
| 278 | for _, k := range keys { | 278 | for _, k := range keys { | ||
| @@ -329,7 +329,7 @@ func expand(keys []string, vectors map[string][]expr.Value, includes, excludes [ | |||||
| 329 | } | 329 | } | ||
| 330 | // TODO(verify with recording): GitHub's behaviour when every | 330 | // TODO(verify with recording): GitHub's behaviour when every | ||
| 331 | // combination is excluded. | 331 | // combination is excluded. | ||
| 332 | - | return nil, false, errors.New("The matrix has no combinations: all of them are excluded") | 332 | + | return nil, false, errors.New("the matrix has no combinations: all of them are excluded") |
| 333 | } | 333 | } | ||
| 334 | return combos, true, nil | 334 | return combos, true, nil | ||
| 335 | } | 335 | } | ||
M workflow/matrix_test.go +16 -8
| @@ -167,13 +167,13 @@ func TestExpandMatrix(t *testing.T) { | |||
| 167 | 167 | ||
| 168 | 168 | func TestExpandMatrixErrors(t *testing.T) { | |
| 169 | 169 | tests := []struct{ name, strategy, want string }{ | |
| 170 | - | {"strategy not a mapping", `[1]`, "The strategy must be a mapping"}, | |
| 171 | - | {"matrix not a mapping", `{"matrix": [1]}`, "The matrix must be a mapping"}, | |
| 172 | - | {"vector empty", `{"matrix": {"a": []}}`, "Matrix vector 'a' does not contain any values"}, | |
| 173 | - | {"vector scalar", `{"matrix": {"a": 1}}`, "Matrix vector 'a' does not contain any values"}, | |
| 174 | - | {"include not a list", `{"matrix": {"include": 1}}`, "Matrix include must be a sequence of mappings"}, | |
| 175 | - | {"include item", `{"matrix": {"include": [1]}}`, "Matrix include must be a sequence of mappings"}, | |
| 176 | - | {"exclude item", `{"matrix": {"a": [1], "exclude": ["x"]}}`, "Matrix exclude must be a sequence of mappings"}, | |
| 170 | + | {"strategy not a mapping", `[1]`, "the strategy must be a mapping"}, | |
| 171 | + | {"matrix not a mapping", `{"matrix": [1]}`, "the matrix must be a mapping"}, | |
| 172 | + | {"vector empty", `{"matrix": {"a": []}}`, "matrix vector 'a' does not contain any values"}, | |
| 173 | + | {"vector scalar", `{"matrix": {"a": 1}}`, "matrix vector 'a' does not contain any values"}, | |
| 174 | + | {"include not a list", `{"matrix": {"include": 1}}`, "matrix include must be a sequence of mappings"}, | |
| 175 | + | {"include item", `{"matrix": {"include": [1]}}`, "matrix include must be a sequence of mappings"}, | |
| 176 | + | {"exclude item", `{"matrix": {"a": [1], "exclude": ["x"]}}`, "matrix exclude must be a sequence of mappings"}, | |
| 177 | 177 | {"all excluded", `{"matrix": {"a": [1], "exclude": [{"a": 1}]}}`, "all of them are excluded"}, | |
| 178 | 178 | {"fail-fast", `{"fail-fast": "maybe"}`, "fail-fast"}, | |
| 179 | 179 | {"max-parallel", `{"max-parallel": 0}`, "max-parallel"}, | |
| @@ -201,7 +201,7 @@ func TestMatrixLimit(t *testing.T) { | |||
| 201 | 201 | t.Errorf("256 jobs: %d, %v", len(c), err) | |
| 202 | 202 | } | |
| 203 | 203 | _, err := ExpandMatrix(jsonValue(t, `{"matrix": {"a": `+vec(16)+`, "b": `+vec(16)+`, "include": [{"a": 99}]}}`)) | |
| 204 | - | if err == nil || !strings.Contains(err.Error(), "Matrix size limit of 256 exceeded") { | |
| 204 | + | if err == nil || !strings.Contains(err.Error(), "matrix size limit of 256 exceeded") { | |
| 205 | 205 | t.Errorf("257 jobs: %v", err) | |
| 206 | 206 | } | |
| 207 | 207 | _, err = ExpandMatrix(jsonValue(t, `{"matrix": {"a": `+vec(17)+`, "b": `+vec(16)+`}}`)) | |
| @@ -239,3 +239,11 @@ func TestStrategyOptions(t *testing.T) { | |||
| 239 | 239 | t.Errorf("default context = %s", got) | |
| 240 | 240 | } | |
| 241 | 241 | } | |
| 242 | + | ||
| 243 | + | func jsonValue2(s string) expr.Value { | |
| 244 | + | v, err := expr.FromJSON([]byte(s)) | |
| 245 | + | if err != nil { | |
| 246 | + | return nil | |
| 247 | + | } | |
| 248 | + | return v | |
| 249 | + | } | |
| @@ -167,13 +167,13 @@ func TestExpandMatrix(t *testing.T) { | |||||
| 167 | 167 | ||||
| 168 | func TestExpandMatrixErrors(t *testing.T) { | 168 | func TestExpandMatrixErrors(t *testing.T) { | ||
| 169 | tests := []struct{ name, strategy, want string }{ | 169 | tests := []struct{ name, strategy, want string }{ | ||
| 170 | - | {"strategy not a mapping", `[1]`, "The strategy must be a mapping"}, | 170 | + | {"strategy not a mapping", `[1]`, "the strategy must be a mapping"}, |
| 171 | - | {"matrix not a mapping", `{"matrix": [1]}`, "The matrix must be a mapping"}, | 171 | + | {"matrix not a mapping", `{"matrix": [1]}`, "the matrix must be a mapping"}, |
| 172 | - | {"vector empty", `{"matrix": {"a": []}}`, "Matrix vector 'a' does not contain any values"}, | 172 | + | {"vector empty", `{"matrix": {"a": []}}`, "matrix vector 'a' does not contain any values"}, |
| 173 | - | {"vector scalar", `{"matrix": {"a": 1}}`, "Matrix vector 'a' does not contain any values"}, | 173 | + | {"vector scalar", `{"matrix": {"a": 1}}`, "matrix vector 'a' does not contain any values"}, |
| 174 | - | {"include not a list", `{"matrix": {"include": 1}}`, "Matrix include must be a sequence of mappings"}, | 174 | + | {"include not a list", `{"matrix": {"include": 1}}`, "matrix include must be a sequence of mappings"}, |
| 175 | - | {"include item", `{"matrix": {"include": [1]}}`, "Matrix include must be a sequence of mappings"}, | 175 | + | {"include item", `{"matrix": {"include": [1]}}`, "matrix include must be a sequence of mappings"}, |
| 176 | - | {"exclude item", `{"matrix": {"a": [1], "exclude": ["x"]}}`, "Matrix exclude must be a sequence of mappings"}, | 176 | + | {"exclude item", `{"matrix": {"a": [1], "exclude": ["x"]}}`, "matrix exclude must be a sequence of mappings"}, |
| 177 | {"all excluded", `{"matrix": {"a": [1], "exclude": [{"a": 1}]}}`, "all of them are excluded"}, | 177 | {"all excluded", `{"matrix": {"a": [1], "exclude": [{"a": 1}]}}`, "all of them are excluded"}, | ||
| 178 | {"fail-fast", `{"fail-fast": "maybe"}`, "fail-fast"}, | 178 | {"fail-fast", `{"fail-fast": "maybe"}`, "fail-fast"}, | ||
| 179 | {"max-parallel", `{"max-parallel": 0}`, "max-parallel"}, | 179 | {"max-parallel", `{"max-parallel": 0}`, "max-parallel"}, | ||
| @@ -201,7 +201,7 @@ func TestMatrixLimit(t *testing.T) { | |||||
| 201 | t.Errorf("256 jobs: %d, %v", len(c), err) | 201 | t.Errorf("256 jobs: %d, %v", len(c), err) | ||
| 202 | } | 202 | } | ||
| 203 | _, err := ExpandMatrix(jsonValue(t, `{"matrix": {"a": `+vec(16)+`, "b": `+vec(16)+`, "include": [{"a": 99}]}}`)) | 203 | _, err := ExpandMatrix(jsonValue(t, `{"matrix": {"a": `+vec(16)+`, "b": `+vec(16)+`, "include": [{"a": 99}]}}`)) | ||
| 204 | - | if err == nil || !strings.Contains(err.Error(), "Matrix size limit of 256 exceeded") { | 204 | + | if err == nil || !strings.Contains(err.Error(), "matrix size limit of 256 exceeded") { |
| 205 | t.Errorf("257 jobs: %v", err) | 205 | t.Errorf("257 jobs: %v", err) | ||
| 206 | } | 206 | } | ||
| 207 | _, err = ExpandMatrix(jsonValue(t, `{"matrix": {"a": `+vec(17)+`, "b": `+vec(16)+`}}`)) | 207 | _, err = ExpandMatrix(jsonValue(t, `{"matrix": {"a": `+vec(17)+`, "b": `+vec(16)+`}}`)) | ||
| @@ -239,3 +239,11 @@ func TestStrategyOptions(t *testing.T) { | |||||
| 239 | t.Errorf("default context = %s", got) | 239 | t.Errorf("default context = %s", got) | ||
| 240 | } | 240 | } | ||
| 241 | } | 241 | } | ||
| 242 | + | ||||
| 243 | + | func jsonValue2(s string) expr.Value { | |||
| 244 | + | v, err := expr.FromJSON([]byte(s)) | |||
| 245 | + | if err != nil { | |||
| 246 | + | return nil | |||
| 247 | + | } | |||
| 248 | + | return v | |||
| 249 | + | } | |||
A workflow/testdata/SOURCE +4 -0
| @@ -0,0 +1,4 @@ | |||
| 1 | + | convert.json is the output of @actions/workflow-parser 0.3.61 | |
| 2 | + | (https://github.com/actions/languageservices, MIT, Copyright GitHub, Inc.) on | |
| 3 | + | the workflow files in convert/, which are written for this repository. It is | |
| 4 | + | generated by ../gen/convert.mjs. See ../../THIRD_PARTY.md. | |
| @@ -0,0 +1,4 @@ | |||||
| 1 | + | convert.json is the output of @actions/workflow-parser 0.3.61 | |||
| 2 | + | (https://github.com/actions/languageservices, MIT, Copyright GitHub, Inc.) on | |||
| 3 | + | the workflow files in convert/, which are written for this repository. It is | |||
| 4 | + | generated by ../gen/convert.mjs. See ../../THIRD_PARTY.md. | |||