detached b5f3fab

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
20 files · +716 -45
M THIRD_PARTY.md +1 -1
@@ -1,6 +1,6 @@
11 # Third-party material
22
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.
44
55 ## actions/languageservices
66
@@ -1,6 +1,6 @@
1 # Third-party material1 # 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/languageservices5 ## 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 {
5656 return ""
5757 }
5858
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+
5965 // Context collects the errors of a read and carries its schema.
6066 type Context struct {
6167 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 *Schema67 schema *Schema
M template/definfo.go +2 -2
@@ -85,12 +85,12 @@ func parseFuncEntry(s string) (expr.FuncInfo, bool) {
8585 }
8686 name := s[:open]
8787 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') {
8989 return expr.FuncInfo{}, false
9090 }
9191 }
9292 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") {
9494 return expr.FuncInfo{}, false
9595 }
9696 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{}, false89 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{}, false94 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 @@
33 package template
44
55 import (
6- "errors"
76 "fmt"
87 "strings"
98 "unicode"
@@ -140,7 +139,7 @@ func (r *reader) parseIntoExpressionToken(token Token, rawExpression string, def
140139 func (r *reader) parseExpression(token Token, value string, def *DefinitionInfo) (Token, error) {
141140 trimmed := strings.TrimSpace(value)
142141 if trimmed == "" {
143- return nil, errors.New("An expression was expected")
142+ return nil, message("An expression was expected")
144143 }
145144 isMatch, err := matchDirective(trimmed, insertDirective, 0)
146145 if err != nil {
@@ -214,7 +213,7 @@ func matchDirective(trimmed, directive string, expectedParameters int) (bool, er
214213 parameters = 1
215214 }
216215 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))
218217 }
219218 return true, nil
220219 }
@@ -3,7 +3,6 @@
3 package template3 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 = 1213 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, nil218 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()) {
3636 fn()
3737 }
3838
39-func throw(t template.Token, format string, a ...any) {
40- panic(&template.TokenError{Token: t, Message: sprintf(format, a...)})
41-}
42-
4339 func ck[T any](v T, err error) T {
4440 if err != nil {
4541 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 {
9393 }
9494 j := &Job{ID: key.Value, IDToken: key}
9595 var name template.Token
96- needsSeen := false
9796 for _, p := range m.Pairs {
9897 prop := asString(p.Key, "job property name")
9998 v := p.Value
@@ -114,7 +113,6 @@ func (c *converter) convertJob(key *template.String, m *template.Mapping) *Job {
114113 case "name":
115114 name = asScalar(v, "job name")
116115 case "needs":
117- needsSeen = true
118116 j.NeedTokens = nil
119117 if s, ok := v.(*template.String); ok {
120118 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 {
160158 j.Permissions = convertPermissions(v)
161159 }
162160 }
163- _ = needsSeen
164161 for _, n := range j.NeedTokens {
165162 j.Needs = append(j.Needs, n.Value)
166163 }
@@ -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.Token95 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.Value98 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 = nil116 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 @@
33 package workflow
44
55 import (
6- "fmt"
76 "strings"
87
98 "bnema.dev/ermite-actions/expr"
109 "bnema.dev/ermite-actions/template"
1110 )
1211
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-
2012 // convertConcurrency validates a concurrency token. Expressions are skipped.
2113 func (c *converter) convertConcurrency(t template.Token) {
2214 if template.IsExpression(t) {
@@ -3,20 +3,12 @@
3 package workflow3 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 {
115115 if len(segments) != 2 {
116116 return ""
117117 }
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 == '\\' })
122119 if len(path) >= 2 && path[0] != "" && path[1] != "" && segments[1] != "" {
123120 return path[0] + "/" + path[1]
124121 }
@@ -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 []string118+ 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) {
121121 if j.ID != wj.ID || j.Name.String() != wj.Name || j.If.Expression != wj.If {
122122 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)
123123 }
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) {
125125 t.Errorf("job %s needs: got %v, reference %v", j.ID, j.Needs, wj.Needs)
126126 }
127127 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
249249 if def != nil {
250250 in.HasDefault = true
251251 in.Default = def.String()
252- if !(call && template.IsExpression(def)) {
252+ if !call || !template.IsExpression(def) {
253253 c.handle(def, func() {
254254 switch in.Type {
255255 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 = true250 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) {
100100 case "fail-fast":
101101 b, err := strategyBool(p.Value)
102102 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)
104104 }
105105 s.FailFast = b
106106 case "max-parallel":
107107 n, err := strategyInt(p.Value)
108108 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)
110110 }
111111 s.MaxParallel = n
112112 case "matrix":
@@ -114,7 +114,7 @@ func ParseStrategy(v expr.Value) (*Strategy, error) {
114114 }
115115 }
116116 default:
117- return nil, errors.New("The strategy must be a mapping")
117+ return nil, errors.New("the strategy must be a mapping")
118118 }
119119 combos, has, err := buildMatrix(matrix)
120120 if err != nil {
@@ -187,7 +187,7 @@ func buildMatrix(m expr.Value) ([]MatrixCombo, bool, error) {
187187 }
188188 md, ok := m.(*expr.Dictionary)
189189 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")
191191 }
192192 if md == nil {
193193 return []MatrixCombo{{Values: expr.NewDictionary()}}, false, nil
@@ -212,10 +212,10 @@ func buildMatrix(m expr.Value) ([]MatrixCombo, bool, error) {
212212 default:
213213 arr, ok := p.Value.(*expr.Array)
214214 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)
216216 }
217217 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)
219219 }
220220 keys = append(keys, p.Key)
221221 vectors[p.Key] = arr.Items()
@@ -227,13 +227,13 @@ func buildMatrix(m expr.Value) ([]MatrixCombo, bool, error) {
227227 func matrixEntries(name string, v expr.Value) ([]*expr.Dictionary, error) {
228228 arr, ok := v.(*expr.Array)
229229 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)
231231 }
232232 var out []*expr.Dictionary
233233 for _, it := range arr.Items() {
234234 d, ok := it.(*expr.Dictionary)
235235 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)
237237 }
238238 out = append(out, d)
239239 }
@@ -272,7 +272,7 @@ func (b *comboBuilder) combo() MatrixCombo {
272272 func expand(keys []string, vectors map[string][]expr.Value, includes, excludes []*expr.Dictionary) ([]MatrixCombo, bool, error) {
273273 var combos []MatrixCombo
274274 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)
276276 if len(keys) > 0 {
277277 product := 1
278278 for _, k := range keys {
@@ -329,7 +329,7 @@ func expand(keys []string, vectors map[string][]expr.Value, includes, excludes [
329329 }
330330 // TODO(verify with recording): GitHub's behaviour when every
331331 // 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")
333333 }
334334 return combos, true, nil
335335 }
@@ -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 = b105 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 = n111 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, nil193 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.Dictionary232 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 []MatrixCombo273 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 := 1277 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 every330 // 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, nil334 return combos, true, nil
335 }335 }
M workflow/matrix_test.go +16 -8
@@ -167,13 +167,13 @@ func TestExpandMatrix(t *testing.T) {
167167
168168 func TestExpandMatrixErrors(t *testing.T) {
169169 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"},
177177 {"all excluded", `{"matrix": {"a": [1], "exclude": [{"a": 1}]}}`, "all of them are excluded"},
178178 {"fail-fast", `{"fail-fast": "maybe"}`, "fail-fast"},
179179 {"max-parallel", `{"max-parallel": 0}`, "max-parallel"},
@@ -201,7 +201,7 @@ func TestMatrixLimit(t *testing.T) {
201201 t.Errorf("256 jobs: %d, %v", len(c), err)
202202 }
203203 _, 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") {
205205 t.Errorf("257 jobs: %v", err)
206206 }
207207 _, err = ExpandMatrix(jsonValue(t, `{"matrix": {"a": `+vec(17)+`, "b": `+vec(16)+`}}`))
@@ -239,3 +239,11 @@ func TestStrategyOptions(t *testing.T) {
239239 t.Errorf("default context = %s", got)
240240 }
241241 }
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.

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 3

↑↓ moveenter openesc close