detached c8bd86b

feat(template): runner mode for the reader (depth, error limit, YAML restrictions)

Context gains MaxDepth, ErrorLimit and RunnerYAML, which package action sets
to read an action.yml the way the C# runner's loader does: the nesting limit
of TemplateMemory (skipped values count), the ten errors it keeps, and the
restrictions of its YAML reader (anchors, tags, 32-bit hex and octal integers,
a second document, an empty file, a collection used as a key). Numbers read as
strings show as .NET's G15. Error.Exception marks a .NET exception reported
without a position. DefinitionInfo.WithContexts adds contexts to a definition.

workflow gains HasStatusFunction and Step.EnvExpression/WithExpression, which
hold env and with when the whole value is an expression (accepted in a
composite action by the runner). Nothing changes without the options.
commit
c8bd86b39c31e117aab7ea87cf22a0d5b68cfa1c
author
brice · · 2026-10-13 13:38 UTC
committer
brice · · 2026-10-18 14:59 UTC
parent
28f9d6e
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
10 files · +503 -17
M template/PORTING.md +13 -0
@@ -65,6 +65,19 @@ parser omits, so the packages run unmodified. The last check, on a clean
6565 - Mapping keys that are collections (`? [a] : b`) are reported as
6666 `Unexpected value ''`; the reference reads `undefined` there.
6767
68+## Runner mode (package action)
69+
70+`Context` has four options that only package action sets, to read an
71+`action.yml` as the C# runner's loader does (`action/PORTING.md` lists what is
72+checked against the runner's own code): `MaxDepth` (`TemplateMemory`'s
73+`maxDepth`, counting skipped values too), `ErrorLimit` (the runner keeps ten),
74+`RunnerYAML` (anchors and aliases, tags other than `!!bool/!!float/!!int/!!null`,
75+32-bit limits of `0x`/`0o` integers, a second document, an empty file and a
76+collection used as a key all end the read with the runner's message; a number
77+read as a string is shown as .NET's `G15`, `DotNetNumberString`). Without them
78+nothing changes. `Error.Exception` marks an error that is a .NET exception the
79+runner reports without a position.
80+
6881 ## To check against the C# runner / recordings
6982
7083 - [ ] A string with `${{` inside a non-expression place (`name:` of a
@@ -65,6 +65,19 @@ parser omits, so the packages run unmodified. The last check, on a clean
65 - Mapping keys that are collections (`? [a] : b`) are reported as65 - Mapping keys that are collections (`? [a] : b`) are reported as
66 `Unexpected value ''`; the reference reads `undefined` there.66 `Unexpected value ''`; the reference reads `undefined` there.
67 67
68+## Runner mode (package action)
69+
70+`Context` has four options that only package action sets, to read an
71+`action.yml` as the C# runner's loader does (`action/PORTING.md` lists what is
72+checked against the runner's own code): `MaxDepth` (`TemplateMemory`'s
73+`maxDepth`, counting skipped values too), `ErrorLimit` (the runner keeps ten),
74+`RunnerYAML` (anchors and aliases, tags other than `!!bool/!!float/!!int/!!null`,
75+32-bit limits of `0x`/`0o` integers, a second document, an empty file and a
76+collection used as a key all end the read with the runner's message; a number
77+read as a string is shown as .NET's `G15`, `DotNetNumberString`). Without them
78+nothing changes. `Error.Exception` marks an error that is a .NET exception the
79+runner reports without a position.
80+
68 ## To check against the C# runner / recordings81 ## To check against the C# runner / recordings
69 82
70 - [ ] A string with `${{` inside a non-expression place (`name:` of a83 - [ ] A string with `${{` inside a non-expression place (`name:` of a
M template/context.go +46 -2
@@ -21,6 +21,14 @@ type Error struct {
2121 // Raw is the message without the "file (Line: l, Col: c): " prefix.
2222 Raw string
2323
24+ // Exception is the name of the .NET exception class when the error is an
25+ // exception that the C# runner reports without a position, such as
26+ // "System.ArgumentException"; Raw is then the message the exception was
27+ // built with. The runner prints Exception.ToString(), which starts with the
28+ // class and the message formatted by .NET (see [Error.Error]) and goes on
29+ // with a stack trace that is not reproduced.
30+ Exception string
31+
2432 // fileOnly makes the prefix the file name alone, for errors of the YAML
2533 // parser, which the reference reports without a position.
2634 fileOnly bool
@@ -34,6 +42,15 @@ func (e *Error) Unwrap() error { return e.cause }
3442 // Error returns the message with its prefix, as the reference does:
3543 // "file.yml (Line: 3, Col: 5): message".
3644 func (e *Error) Error() string {
45+ if e.Exception != "" {
46+ switch e.Exception {
47+ case "System.ArgumentNullException":
48+ return e.Exception + ": Value cannot be null. (Parameter '" + e.Raw + "')"
49+ case "System.ArgumentOutOfRangeException":
50+ return e.Exception + ": Specified argument was out of the range of valid values. (Parameter '" + e.Raw + "')"
51+ }
52+ return e.Exception + ": " + e.Raw
53+ }
3754 pos := e.Pos
3855 if e.fileOnly {
3956 pos = Pos{}
@@ -64,9 +81,25 @@ func (m message) Error() string { return string(m) }
6481
6582 // Context collects the errors of a read and carries its schema.
6683 type Context struct {
84+ // MaxDepth bounds the nesting of sequences and mappings that [ReadYAML]
85+ // accepts. A deeper document ends the read with "Maximum object depth
86+ // exceeded", as the runner's TemplateMemory does. Zero means no limit.
87+ MaxDepth int
88+ // ErrorLimit is the number of errors kept; later ones are dropped. Zero
89+ // means [MaxErrors]. The runner keeps ten when it loads an action.yml.
90+ ErrorLimit int
91+ // RunnerYAML makes the YAML reader as strict as the C# runner's when it
92+ // loads an action.yml: an anchor is an error ("Anchors are not currently
93+ // supported. Remove the anchor 'x'", and so is the alias that needs it),
94+ // and so is every explicit tag but !!bool, !!float, !!int and !!null
95+ // ("Unexpected tag 'tag:yaml.org,2002:str'": the runner knows no !!str).
96+ // Such an error is reported alone, without the schema errors that the
97+ // runner would have found in the part of the document before it.
98+ RunnerYAML bool
99+
67100 schema *Schema
68101 errors []*Error
69- // Dropped counts the errors beyond [MaxErrors].
102+ // Dropped counts the errors beyond the limit.
70103 dropped int
71104 }
72105
@@ -96,6 +129,13 @@ func (c *Context) ErrorAt(tok Token, msg string) {
96129 c.add(e)
97130 }
98131
132+// ErrorException records an exception the C# runner reports without a
133+// position: class is the .NET class name ("System.ArgumentException") and msg
134+// the message the exception was built with.
135+func (c *Context) ErrorException(class, msg string) {
136+ c.add(&Error{Raw: msg, Exception: class})
137+}
138+
99139 // ErrorInFile records an error that is about a whole file.
100140 func (c *Context) ErrorInFile(file, msg string) { c.add(&Error{File: file, Raw: msg}) }
101141
@@ -105,7 +145,11 @@ func (c *Context) ErrorAtPos(file string, p Pos, msg string) {
105145 }
106146
107147 func (c *Context) add(e *Error) {
108- if len(c.errors) >= MaxErrors {
148+ limit := c.ErrorLimit
149+ if limit <= 0 {
150+ limit = MaxErrors
151+ }
152+ if len(c.errors) >= limit {
109153 c.dropped++
110154 return
111155 }
@@ -21,6 +21,14 @@ type Error struct {
21 // Raw is the message without the "file (Line: l, Col: c): " prefix.21 // Raw is the message without the "file (Line: l, Col: c): " prefix.
22 Raw string22 Raw string
23 23
24+ // Exception is the name of the .NET exception class when the error is an
25+ // exception that the C# runner reports without a position, such as
26+ // "System.ArgumentException"; Raw is then the message the exception was
27+ // built with. The runner prints Exception.ToString(), which starts with the
28+ // class and the message formatted by .NET (see [Error.Error]) and goes on
29+ // with a stack trace that is not reproduced.
30+ Exception string
31+
24 // fileOnly makes the prefix the file name alone, for errors of the YAML32 // fileOnly makes the prefix the file name alone, for errors of the YAML
25 // parser, which the reference reports without a position.33 // parser, which the reference reports without a position.
26 fileOnly bool34 fileOnly bool
@@ -34,6 +42,15 @@ func (e *Error) Unwrap() error { return e.cause }
34 // Error returns the message with its prefix, as the reference does:42 // Error returns the message with its prefix, as the reference does:
35 // "file.yml (Line: 3, Col: 5): message".43 // "file.yml (Line: 3, Col: 5): message".
36 func (e *Error) Error() string {44 func (e *Error) Error() string {
45+ if e.Exception != "" {
46+ switch e.Exception {
47+ case "System.ArgumentNullException":
48+ return e.Exception + ": Value cannot be null. (Parameter '" + e.Raw + "')"
49+ case "System.ArgumentOutOfRangeException":
50+ return e.Exception + ": Specified argument was out of the range of valid values. (Parameter '" + e.Raw + "')"
51+ }
52+ return e.Exception + ": " + e.Raw
53+ }
37 pos := e.Pos54 pos := e.Pos
38 if e.fileOnly {55 if e.fileOnly {
39 pos = Pos{}56 pos = Pos{}
@@ -64,9 +81,25 @@ func (m message) Error() string { return string(m) }
64 81
65 // Context collects the errors of a read and carries its schema.82 // Context collects the errors of a read and carries its schema.
66 type Context struct {83 type Context struct {
84+ // MaxDepth bounds the nesting of sequences and mappings that [ReadYAML]
85+ // accepts. A deeper document ends the read with "Maximum object depth
86+ // exceeded", as the runner's TemplateMemory does. Zero means no limit.
87+ MaxDepth int
88+ // ErrorLimit is the number of errors kept; later ones are dropped. Zero
89+ // means [MaxErrors]. The runner keeps ten when it loads an action.yml.
90+ ErrorLimit int
91+ // RunnerYAML makes the YAML reader as strict as the C# runner's when it
92+ // loads an action.yml: an anchor is an error ("Anchors are not currently
93+ // supported. Remove the anchor 'x'", and so is the alias that needs it),
94+ // and so is every explicit tag but !!bool, !!float, !!int and !!null
95+ // ("Unexpected tag 'tag:yaml.org,2002:str'": the runner knows no !!str).
96+ // Such an error is reported alone, without the schema errors that the
97+ // runner would have found in the part of the document before it.
98+ RunnerYAML bool
99+
67 schema *Schema100 schema *Schema
68 errors []*Error101 errors []*Error
69- // Dropped counts the errors beyond [MaxErrors].102+ // Dropped counts the errors beyond the limit.
70 dropped int103 dropped int
71 }104 }
72 105
@@ -96,6 +129,13 @@ func (c *Context) ErrorAt(tok Token, msg string) {
96 c.add(e)129 c.add(e)
97 }130 }
98 131
132+// ErrorException records an exception the C# runner reports without a
133+// position: class is the .NET class name ("System.ArgumentException") and msg
134+// the message the exception was built with.
135+func (c *Context) ErrorException(class, msg string) {
136+ c.add(&Error{Raw: msg, Exception: class})
137+}
138+
99 // ErrorInFile records an error that is about a whole file.139 // ErrorInFile records an error that is about a whole file.
100 func (c *Context) ErrorInFile(file, msg string) { c.add(&Error{File: file, Raw: msg}) }140 func (c *Context) ErrorInFile(file, msg string) { c.add(&Error{File: file, Raw: msg}) }
101 141
@@ -105,7 +145,11 @@ func (c *Context) ErrorAtPos(file string, p Pos, msg string) {
105 }145 }
106 146
107 func (c *Context) add(e *Error) {147 func (c *Context) add(e *Error) {
108- if len(c.errors) >= MaxErrors {148+ limit := c.ErrorLimit
149+ if limit <= 0 {
150+ limit = MaxErrors
151+ }
152+ if len(c.errors) >= limit {
109 c.dropped++153 c.dropped++
110 return154 return
111 }155 }
M template/definfo.go +10 -0
@@ -51,6 +51,16 @@ func (di *DefinitionInfo) child(name string) *DefinitionInfo {
5151 return newDefinitionInfo(di.schema, di, di.schema.get(name))
5252 }
5353
54+// WithContexts returns a copy of di that also allows the named contexts and
55+// functions ("vars", "hashFiles(1,255)"). The runner checks the "if" of a
56+// composite step against a longer list than the manifest schema declares.
57+func (di *DefinitionInfo) WithContexts(entries ...string) *DefinitionInfo {
58+ cp := *di
59+ cp.allowed = append(append([]string(nil), di.allowed...), entries...)
60+ cp.named, cp.funcs = splitAllowedContext(cp.allowed)
61+ return &cp
62+}
63+
5464 // AllowedContext returns the raw entries: context names and function
5565 // signatures such as "hashFiles(1,255)".
5666 func (di *DefinitionInfo) AllowedContext() []string { return di.allowed }
@@ -51,6 +51,16 @@ func (di *DefinitionInfo) child(name string) *DefinitionInfo {
51 return newDefinitionInfo(di.schema, di, di.schema.get(name))51 return newDefinitionInfo(di.schema, di, di.schema.get(name))
52 }52 }
53 53
54+// WithContexts returns a copy of di that also allows the named contexts and
55+// functions ("vars", "hashFiles(1,255)"). The runner checks the "if" of a
56+// composite step against a longer list than the manifest schema declares.
57+func (di *DefinitionInfo) WithContexts(entries ...string) *DefinitionInfo {
58+ cp := *di
59+ cp.allowed = append(append([]string(nil), di.allowed...), entries...)
60+ cp.named, cp.funcs = splitAllowedContext(cp.allowed)
61+ return &cp
62+}
63+
54 // AllowedContext returns the raw entries: context names and function64 // AllowedContext returns the raw entries: context names and function
55 // signatures such as "hashFiles(1,255)".65 // signatures such as "hashFiles(1,255)".
56 func (di *DefinitionInfo) AllowedContext() []string { return di.allowed }66 func (di *DefinitionInfo) AllowedContext() []string { return di.allowed }
A template/limits_test.go +143 -0
@@ -0,0 +1,143 @@
1+// SPDX-License-Identifier: MIT
2+
3+package template
4+
5+import (
6+ "math"
7+ "strings"
8+ "testing"
9+)
10+
11+// The options of Context that the C# runner's loader needs (package action):
12+// nesting limit, error limit and the runner's YAML restrictions.
13+
14+func readWith(t *testing.T, set func(*Context), src string) []string {
15+ t.Helper()
16+ schema, err := WorkflowSchema()
17+ if err != nil {
18+ t.Fatal(err)
19+ }
20+ ctx := NewContext(schema)
21+ set(ctx)
22+ ReadYAML(ctx, "workflow-root-strict", "f.yml", []byte(src))
23+ return ctx.Messages()
24+}
25+
26+func TestMaxDepth(t *testing.T) {
27+ nest := func(n int) string { return "x: " + strings.Repeat("[", n) + strings.Repeat("]", n) + "\n" }
28+ set := func(c *Context) { c.MaxDepth = 10 }
29+ // Like TemplateMemory.IncrementDepth: the root mapping is level one, so
30+ // nine brackets fit and ten do not. The unexpected key "x" is skipped, and
31+ // skipping counts levels too.
32+ got := readWith(t, set, nest(9))
33+ for _, m := range got {
34+ if strings.Contains(m, "depth") {
35+ t.Errorf("9 brackets: %q", got)
36+ }
37+ }
38+ got = readWith(t, set, nest(10))
39+ if len(got) == 0 || got[len(got)-1] != "f.yml: Maximum object depth exceeded" {
40+ t.Errorf("10 brackets: %q", got)
41+ }
42+ // No limit by default.
43+ for _, m := range readWith(t, func(*Context) {}, nest(500)) {
44+ if strings.Contains(m, "depth") {
45+ t.Errorf("default limit: %q", m)
46+ }
47+ }
48+}
49+
50+func TestErrorLimit(t *testing.T) {
51+ var b strings.Builder
52+ for i := 0; i < 30; i++ {
53+ b.WriteString("k" + string(rune('a'+i%26)) + string(rune('a'+i/26)) + ": 1\n")
54+ }
55+ if got := readWith(t, func(c *Context) { c.ErrorLimit = 10 }, b.String()); len(got) != 10 {
56+ t.Errorf("%d errors with a limit of 10", len(got))
57+ }
58+ if got := readWith(t, func(*Context) {}, b.String()); len(got) < 30 {
59+ t.Errorf("%d errors without a limit", len(got))
60+ }
61+}
62+
63+func TestRunnerYAML(t *testing.T) {
64+ runner := func(c *Context) { c.RunnerYAML = true }
65+ tests := []struct {
66+ name, src, want string
67+ }{
68+ {"anchor", "on: &a push\n", "f.yml: Anchors are not currently supported. Remove the anchor 'a'"},
69+ {"alias", "a: &a [1]\nb: *a\n", "f.yml: Anchors are not currently supported. Remove the anchor 'a'"},
70+ {"!!str is unknown to the runner", "on: !!str push\n", "f.yml: Unexpected tag 'tag:yaml.org,2002:str'"},
71+ {"!!int on a string", "on: !!int push\n", "f.yml: The value 'push' on line 1 and column 5 is invalid for the type 'tag:yaml.org,2002:int'"},
72+ {"tag on a quoted scalar", "on: !!int '1'\n", "f.yml: The scalar style 'SingleQuoted' on line 1 and column 5 is not valid with the tag 'tag:yaml.org,2002:int'"},
73+ {"second document", "on: push\n---\non: pull_request\n", "f.yml: Expected stream end parse event"},
74+ {"empty", "", "f.yml: Expected document start parse event"},
75+ {"collection key", "? [a]\n: b\n", "f.yml: Expected mapping end"},
76+ }
77+ for _, tc := range tests {
78+ got := readWith(t, runner, tc.src)
79+ found := false
80+ for _, m := range got {
81+ found = found || m == tc.want
82+ }
83+ if !found {
84+ t.Errorf("%s: %q, want %q", tc.name, got, tc.want)
85+ }
86+ // None of this is an error without the option (except an empty file
87+ // and a collection key, which have other messages).
88+ }
89+ for _, src := range []string{"on: &a push\n", "on: !!str push\n", "on: !!int 1\n"} {
90+ for _, m := range readWith(t, func(*Context) {}, src) {
91+ if strings.Contains(m, "Anchors") || strings.Contains(m, "Unexpected tag") {
92+ t.Errorf("%q without RunnerYAML: %q", src, m)
93+ }
94+ }
95+ }
96+ // A valid tag passes.
97+ for _, src := range []string{"on: !!null\n", "on: !!int 12\n", "on: !!float 1.5\n", "on: !!bool true\n", "on: !!int 0x1F\n"} {
98+ for _, m := range readWith(t, runner, src) {
99+ if strings.Contains(m, "tag") || strings.Contains(m, "invalid for the type") {
100+ t.Errorf("%q: %q", src, m)
101+ }
102+ }
103+ }
104+}
105+
106+func TestExceptionError(t *testing.T) {
107+ schema, _ := WorkflowSchema()
108+ ctx := NewContext(schema)
109+ ctx.ErrorException("System.ArgumentNullException", "msg")
110+ ctx.ErrorException("System.ArgumentOutOfRangeException", "msg")
111+ ctx.ErrorException("System.NotSupportedException", "msg")
112+ want := []string{
113+ "System.ArgumentNullException: Value cannot be null. (Parameter 'msg')",
114+ "System.ArgumentOutOfRangeException: Specified argument was out of the range of valid values. (Parameter 'msg')",
115+ "System.NotSupportedException: msg",
116+ }
117+ got := ctx.Messages()
118+ if strings.Join(got, "\n") != strings.Join(want, "\n") {
119+ t.Errorf("got %q", got)
120+ }
121+}
122+
123+func TestDotNetNumberString(t *testing.T) {
124+ for _, tc := range []struct {
125+ in float64
126+ want string
127+ }{
128+ {0, "0"}, {1, "1"}, {1.5, "1.5"}, {-5, "-5"}, {0.1, "0.1"}, {0.30000000000000004, "0.3"},
129+ {100000, "100000"}, {1e14, "100000000000000"}, {1e15, "1E+15"}, {123456789012345678, "1.23456789012346E+17"},
130+ {1e20, "1E+20"}, {1e21, "1E+21"}, {0.0001, "0.0001"}, {0.00001, "1E-05"}, {1e-6, "1E-06"}, {1.5e-7, "1.5E-07"},
131+ {12345.6789, "12345.6789"}, {999999999999999, "999999999999999"}, {9999999999999999, "1E+16"},
132+ } {
133+ if got := DotNetNumberString(tc.in); got != tc.want {
134+ t.Errorf("DotNetNumberString(%v) = %q, want %q", tc.in, got, tc.want)
135+ }
136+ }
137+ if got := DotNetNumberString(math.Copysign(0, -1)); got != "-0" {
138+ t.Errorf("negative zero: %q", got)
139+ }
140+ if DotNetNumberString(math.Inf(1)) != "Infinity" || DotNetNumberString(math.Inf(-1)) != "-Infinity" || DotNetNumberString(math.NaN()) != "NaN" {
141+ t.Error("non-finite numbers")
142+ }
143+}
@@ -0,0 +1,143 @@
1+// SPDX-License-Identifier: MIT
2+
3+package template
4+
5+import (
6+ "math"
7+ "strings"
8+ "testing"
9+)
10+
11+// The options of Context that the C# runner's loader needs (package action):
12+// nesting limit, error limit and the runner's YAML restrictions.
13+
14+func readWith(t *testing.T, set func(*Context), src string) []string {
15+ t.Helper()
16+ schema, err := WorkflowSchema()
17+ if err != nil {
18+ t.Fatal(err)
19+ }
20+ ctx := NewContext(schema)
21+ set(ctx)
22+ ReadYAML(ctx, "workflow-root-strict", "f.yml", []byte(src))
23+ return ctx.Messages()
24+}
25+
26+func TestMaxDepth(t *testing.T) {
27+ nest := func(n int) string { return "x: " + strings.Repeat("[", n) + strings.Repeat("]", n) + "\n" }
28+ set := func(c *Context) { c.MaxDepth = 10 }
29+ // Like TemplateMemory.IncrementDepth: the root mapping is level one, so
30+ // nine brackets fit and ten do not. The unexpected key "x" is skipped, and
31+ // skipping counts levels too.
32+ got := readWith(t, set, nest(9))
33+ for _, m := range got {
34+ if strings.Contains(m, "depth") {
35+ t.Errorf("9 brackets: %q", got)
36+ }
37+ }
38+ got = readWith(t, set, nest(10))
39+ if len(got) == 0 || got[len(got)-1] != "f.yml: Maximum object depth exceeded" {
40+ t.Errorf("10 brackets: %q", got)
41+ }
42+ // No limit by default.
43+ for _, m := range readWith(t, func(*Context) {}, nest(500)) {
44+ if strings.Contains(m, "depth") {
45+ t.Errorf("default limit: %q", m)
46+ }
47+ }
48+}
49+
50+func TestErrorLimit(t *testing.T) {
51+ var b strings.Builder
52+ for i := 0; i < 30; i++ {
53+ b.WriteString("k" + string(rune('a'+i%26)) + string(rune('a'+i/26)) + ": 1\n")
54+ }
55+ if got := readWith(t, func(c *Context) { c.ErrorLimit = 10 }, b.String()); len(got) != 10 {
56+ t.Errorf("%d errors with a limit of 10", len(got))
57+ }
58+ if got := readWith(t, func(*Context) {}, b.String()); len(got) < 30 {
59+ t.Errorf("%d errors without a limit", len(got))
60+ }
61+}
62+
63+func TestRunnerYAML(t *testing.T) {
64+ runner := func(c *Context) { c.RunnerYAML = true }
65+ tests := []struct {
66+ name, src, want string
67+ }{
68+ {"anchor", "on: &a push\n", "f.yml: Anchors are not currently supported. Remove the anchor 'a'"},
69+ {"alias", "a: &a [1]\nb: *a\n", "f.yml: Anchors are not currently supported. Remove the anchor 'a'"},
70+ {"!!str is unknown to the runner", "on: !!str push\n", "f.yml: Unexpected tag 'tag:yaml.org,2002:str'"},
71+ {"!!int on a string", "on: !!int push\n", "f.yml: The value 'push' on line 1 and column 5 is invalid for the type 'tag:yaml.org,2002:int'"},
72+ {"tag on a quoted scalar", "on: !!int '1'\n", "f.yml: The scalar style 'SingleQuoted' on line 1 and column 5 is not valid with the tag 'tag:yaml.org,2002:int'"},
73+ {"second document", "on: push\n---\non: pull_request\n", "f.yml: Expected stream end parse event"},
74+ {"empty", "", "f.yml: Expected document start parse event"},
75+ {"collection key", "? [a]\n: b\n", "f.yml: Expected mapping end"},
76+ }
77+ for _, tc := range tests {
78+ got := readWith(t, runner, tc.src)
79+ found := false
80+ for _, m := range got {
81+ found = found || m == tc.want
82+ }
83+ if !found {
84+ t.Errorf("%s: %q, want %q", tc.name, got, tc.want)
85+ }
86+ // None of this is an error without the option (except an empty file
87+ // and a collection key, which have other messages).
88+ }
89+ for _, src := range []string{"on: &a push\n", "on: !!str push\n", "on: !!int 1\n"} {
90+ for _, m := range readWith(t, func(*Context) {}, src) {
91+ if strings.Contains(m, "Anchors") || strings.Contains(m, "Unexpected tag") {
92+ t.Errorf("%q without RunnerYAML: %q", src, m)
93+ }
94+ }
95+ }
96+ // A valid tag passes.
97+ for _, src := range []string{"on: !!null\n", "on: !!int 12\n", "on: !!float 1.5\n", "on: !!bool true\n", "on: !!int 0x1F\n"} {
98+ for _, m := range readWith(t, runner, src) {
99+ if strings.Contains(m, "tag") || strings.Contains(m, "invalid for the type") {
100+ t.Errorf("%q: %q", src, m)
101+ }
102+ }
103+ }
104+}
105+
106+func TestExceptionError(t *testing.T) {
107+ schema, _ := WorkflowSchema()
108+ ctx := NewContext(schema)
109+ ctx.ErrorException("System.ArgumentNullException", "msg")
110+ ctx.ErrorException("System.ArgumentOutOfRangeException", "msg")
111+ ctx.ErrorException("System.NotSupportedException", "msg")
112+ want := []string{
113+ "System.ArgumentNullException: Value cannot be null. (Parameter 'msg')",
114+ "System.ArgumentOutOfRangeException: Specified argument was out of the range of valid values. (Parameter 'msg')",
115+ "System.NotSupportedException: msg",
116+ }
117+ got := ctx.Messages()
118+ if strings.Join(got, "\n") != strings.Join(want, "\n") {
119+ t.Errorf("got %q", got)
120+ }
121+}
122+
123+func TestDotNetNumberString(t *testing.T) {
124+ for _, tc := range []struct {
125+ in float64
126+ want string
127+ }{
128+ {0, "0"}, {1, "1"}, {1.5, "1.5"}, {-5, "-5"}, {0.1, "0.1"}, {0.30000000000000004, "0.3"},
129+ {100000, "100000"}, {1e14, "100000000000000"}, {1e15, "1E+15"}, {123456789012345678, "1.23456789012346E+17"},
130+ {1e20, "1E+20"}, {1e21, "1E+21"}, {0.0001, "0.0001"}, {0.00001, "1E-05"}, {1e-6, "1E-06"}, {1.5e-7, "1.5E-07"},
131+ {12345.6789, "12345.6789"}, {999999999999999, "999999999999999"}, {9999999999999999, "1E+16"},
132+ } {
133+ if got := DotNetNumberString(tc.in); got != tc.want {
134+ t.Errorf("DotNetNumberString(%v) = %q, want %q", tc.in, got, tc.want)
135+ }
136+ }
137+ if got := DotNetNumberString(math.Copysign(0, -1)); got != "-0" {
138+ t.Errorf("negative zero: %q", got)
139+ }
140+ if DotNetNumberString(math.Inf(1)) != "Infinity" || DotNetNumberString(math.Inf(-1)) != "-Infinity" || DotNetNumberString(math.NaN()) != "NaN" {
141+ t.Error("non-finite numbers")
142+ }
143+}
M template/number.go +57 -0
@@ -53,3 +53,60 @@ func JSNumberString(x float64) string {
5353 }
5454 return sign + digits[:1] + "." + digits[1:] + "e" + esign + strconv.Itoa(e)
5555 }
56+
57+// DotNetNumberString formats x like .NET's Double.ToString("G15",
58+// CultureInfo.InvariantCulture), which is what the C# runner's NumberToken
59+// shows (and what a number becomes when it is read as a string): at most 15
60+// significant digits, positional when the decimal exponent is at least -4 and
61+// below 15, "E+XX"/"E-XX" with at least two exponent digits otherwise, "-0" for
62+// negative zero.
63+func DotNetNumberString(x float64) string {
64+ switch {
65+ case math.IsNaN(x):
66+ return "NaN"
67+ case math.IsInf(x, 1):
68+ return "Infinity"
69+ case math.IsInf(x, -1):
70+ return "-Infinity"
71+ case x == 0:
72+ if math.Signbit(x) {
73+ return "-0"
74+ }
75+ return "0"
76+ }
77+ sign := ""
78+ if x < 0 {
79+ sign = "-"
80+ x = -x
81+ }
82+ s := strconv.FormatFloat(x, 'e', 14, 64)
83+ mant, expStr, _ := strings.Cut(s, "e")
84+ exp, _ := strconv.Atoi(expStr)
85+ digits := strings.TrimRight(strings.Replace(mant, ".", "", 1), "0")
86+ if digits == "" {
87+ digits = "0"
88+ }
89+ if exp >= 15 || exp < -4 {
90+ out := digits[:1]
91+ if len(digits) > 1 {
92+ out += "." + digits[1:]
93+ }
94+ esign := "+"
95+ if exp < 0 {
96+ esign = "-"
97+ exp = -exp
98+ }
99+ e := strconv.Itoa(exp)
100+ if len(e) < 2 {
101+ e = "0" + e
102+ }
103+ return sign + out + "E" + esign + e
104+ }
105+ if exp < 0 {
106+ return sign + "0." + strings.Repeat("0", -exp-1) + digits
107+ }
108+ if len(digits) <= exp+1 {
109+ return sign + digits + strings.Repeat("0", exp+1-len(digits))
110+ }
111+ return sign + digits[:exp+1] + "." + digits[exp+1:]
112+}
@@ -53,3 +53,60 @@ func JSNumberString(x float64) string {
53 }53 }
54 return sign + digits[:1] + "." + digits[1:] + "e" + esign + strconv.Itoa(e)54 return sign + digits[:1] + "." + digits[1:] + "e" + esign + strconv.Itoa(e)
55 }55 }
56+
57+// DotNetNumberString formats x like .NET's Double.ToString("G15",
58+// CultureInfo.InvariantCulture), which is what the C# runner's NumberToken
59+// shows (and what a number becomes when it is read as a string): at most 15
60+// significant digits, positional when the decimal exponent is at least -4 and
61+// below 15, "E+XX"/"E-XX" with at least two exponent digits otherwise, "-0" for
62+// negative zero.
63+func DotNetNumberString(x float64) string {
64+ switch {
65+ case math.IsNaN(x):
66+ return "NaN"
67+ case math.IsInf(x, 1):
68+ return "Infinity"
69+ case math.IsInf(x, -1):
70+ return "-Infinity"
71+ case x == 0:
72+ if math.Signbit(x) {
73+ return "-0"
74+ }
75+ return "0"
76+ }
77+ sign := ""
78+ if x < 0 {
79+ sign = "-"
80+ x = -x
81+ }
82+ s := strconv.FormatFloat(x, 'e', 14, 64)
83+ mant, expStr, _ := strings.Cut(s, "e")
84+ exp, _ := strconv.Atoi(expStr)
85+ digits := strings.TrimRight(strings.Replace(mant, ".", "", 1), "0")
86+ if digits == "" {
87+ digits = "0"
88+ }
89+ if exp >= 15 || exp < -4 {
90+ out := digits[:1]
91+ if len(digits) > 1 {
92+ out += "." + digits[1:]
93+ }
94+ esign := "+"
95+ if exp < 0 {
96+ esign = "-"
97+ exp = -exp
98+ }
99+ e := strconv.Itoa(exp)
100+ if len(e) < 2 {
101+ e = "0" + e
102+ }
103+ return sign + out + "E" + esign + e
104+ }
105+ if exp < 0 {
106+ return sign + "0." + strings.Repeat("0", -exp-1) + digits
107+ }
108+ if len(digits) <= exp+1 {
109+ return sign + digits + strings.Repeat("0", exp+1-len(digits))
110+ }
111+ return sign + digits[:exp+1] + "." + digits[exp+1:]
112+}
M template/reader.go +103 -6
@@ -23,7 +23,7 @@ func ReadYAML(ctx *Context, rootType, file string, content []byte) (root Token)
2323 ctx.add(e)
2424 return nil
2525 }
26- res := readYAMLDocument(file, content)
26+ res := readYAMLDocument(file, content, ctx.RunnerYAML)
2727 for _, e := range res.errs {
2828 ctx.add(e)
2929 }
@@ -41,10 +41,18 @@ func ReadYAML(ctx *Context, rootType, file string, content []byte) (root Token)
4141 }()
4242 rd := &reader{ctx: ctx, schema: ctx.schema, file: file}
4343 if res.root == nil {
44+ if ctx.RunnerYAML {
45+ // YamlDotNet produces no DocumentStart event for an empty stream.
46+ fatalf("Expected document start parse event")
47+ }
4448 fatalf("Expected a scalar value, a sequence, or a mapping")
4549 }
4650 def := newDefinitionInfo(ctx.schema, nil, ctx.schema.get(rootType))
47- return rd.readValue(res.root, def)
51+ root = rd.readValue(res.root, def)
52+ for _, e := range res.after {
53+ ctx.add(e)
54+ }
55+ return root
4856 }
4957
5058 type reader struct {
@@ -54,16 +62,81 @@ type reader struct {
5462 // noExpressions is set when reading the result of an evaluation, whose
5563 // strings are text even if they contain ${{.
5664 noExpressions bool
65+ // depth is the nesting of the collections being read, see Context.MaxDepth.
66+ depth int
67+}
68+
69+// enter counts one more level of nesting. Like TemplateMemory.IncrementDepth
70+// it fails when the levels already entered reach the limit, so a limit of 100
71+// allows 100 nested collections.
72+func (r *reader) enter() {
73+ if r.ctx.MaxDepth <= 0 {
74+ return
75+ }
76+ if r.depth >= r.ctx.MaxDepth {
77+ fatalf("Maximum object depth exceeded")
78+ }
79+ r.depth++
80+}
81+
82+// text is the string a literal becomes when the schema wants a string, and
83+// the text of a key that is not a string: Token.String, except that a number
84+// is shown as the runner's NumberToken shows it in runner mode.
85+func (r *reader) text(t Token) string {
86+ if n, ok := t.(*Number); ok && r.ctx.RunnerYAML {
87+ return DotNetNumberString(n.Value)
88+ }
89+ return t.String()
90+}
91+
92+func (r *reader) leave() {
93+ if r.ctx.MaxDepth > 0 {
94+ r.depth--
95+ }
96+}
97+
98+// skip walks a value that is not kept (the value of an unexpected key, the
99+// items of a collection the schema does not allow) and counts its nesting,
100+// as the reference's SkipValue does.
101+func (r *reader) skip(n *rnode) {
102+ if n == nil {
103+ return
104+ }
105+ if n.fault != "" {
106+ fatalf("%s", n.fault)
107+ }
108+ if r.ctx.MaxDepth <= 0 {
109+ return
110+ }
111+ switch n.kind {
112+ case rSeq:
113+ r.enter()
114+ for _, it := range n.items {
115+ r.skip(it)
116+ }
117+ r.leave()
118+ case rMap:
119+ r.enter()
120+ for i := range n.keys {
121+ r.skip(n.keys[i])
122+ r.skip(n.vals[i])
123+ }
124+ r.leave()
125+ }
57126 }
58127
59128 func (r *reader) origin(n *rnode) Origin { return Origin{File: r.file, Pos: n.pos} }
60129
61130 func (r *reader) readValue(n *rnode, def *DefinitionInfo) Token {
131+ if n.fault != "" {
132+ fatalf("%s", n.fault)
133+ }
62134 switch n.kind {
63135 case rLiteral:
64136 scalar := r.parseScalar(n, n.lit, def)
65137 return r.validate(scalar, def)
66138 case rSeq:
139+ r.enter()
67140 seq := NewSequence(r.origin(n))
68141 defs := def.schema.definitionsOfType(def.Definition, DefSequence)
69142 if len(defs) > 0 {
@@ -73,10 +146,15 @@ func (r *reader) readValue(n *rnode, def *DefinitionInfo) Token {
73146 }
74147 } else {
75148 r.ctx.ErrorAt(seq, "A sequence was not expected")
149+ for _, it := range n.items {
150+ r.skip(it)
151+ }
76152 }
77153 seq.def = def
154+ r.leave()
78155 return seq
79156 }
157+ r.enter()
80158 m := NewMapping(r.origin(n))
81159 defs := def.schema.definitionsOfType(def.Definition, DefMapping)
82160 if len(defs) > 0 {
@@ -89,10 +167,15 @@ func (r *reader) readValue(n *rnode, def *DefinitionInfo) Token {
89167 }
90168 } else {
91169 r.ctx.ErrorAt(m, "A mapping was not expected")
170+ for i := range n.keys {
171+ r.skip(n.keys[i])
172+ r.skip(n.vals[i])
173+ }
92174 }
93175 if m.def == nil {
94176 m.def = def
95177 }
178+ r.leave()
96179 return m
97180 }
98181
@@ -100,7 +183,15 @@ func (r *reader) readValue(n *rnode, def *DefinitionInfo) Token {
100183 // error and reads as an empty string.
101184 func (r *reader) key(n *rnode, i int, def *DefinitionInfo) Token {
102185 k := n.keys[i]
186+ if k.fault != "" {
187+ fatalf("%s", k.fault)
188+ }
103189 if k.badKey {
190+ if r.ctx.RunnerYAML {
191+ // The runner's loop over the keys ends at the first one that is not
192+ // a literal, and then expects the end of the mapping.
193+ fatalf("Expected mapping end")
194+ }
104195 r.ctx.ErrorAt(NewString(r.origin(k), ""), "Unexpected value ''")
105196 }
106197 return r.parseScalar(k, k.lit, def)
@@ -123,17 +214,19 @@ func (r *reader) mappingWithProperties(n *rnode, m *Mapping, def *DefinitionInfo
123214 m.Add(keyScalar, r.readValue(val, def.child("any")))
124215 } else {
125216 r.ctx.ErrorAt(keyScalar, "A template expression is not allowed in this context")
217+ r.skip(val)
126218 }
127219 continue
128220 }
129221 nextKey, ok := keyScalar.(*String)
130222 if !ok {
131- nextKey = &String{tokenBase: *keyScalar.base(), Value: keyScalar.String()}
223+ nextKey = &String{tokenBase: *keyScalar.base(), Value: r.text(keyScalar)}
132224 }
133225 if nextKey.Value != "" {
134226 up := strings.ToUpper(nextKey.Value)
135227 if upperKeys[up] {
136228 r.ctx.Errorf(nextKey, "'%s' is already defined", nextKey.Value)
229+ r.skip(val)
137230 continue
138231 }
139232 upperKeys[up] = true
@@ -143,6 +236,7 @@ func (r *reader) mappingWithProperties(n *rnode, m *Mapping, def *DefinitionInfo
143236 if prop != nil {
144237 if backgroundStepProperties[nextKey.Value] {
145238 r.ctx.Errorf(nextKey, "Unexpected value '%s'", nextKey.Value)
239+ r.skip(val)
146240 continue
147241 }
148242 nd := def.child(prop.Type)
@@ -161,6 +255,7 @@ func (r *reader) mappingWithProperties(n *rnode, m *Mapping, def *DefinitionInfo
161255 continue
162256 }
163257 r.ctx.Errorf(nextKey, "Unexpected value '%s'", nextKey.Value)
258+ r.skip(val)
164259 }
165260 if len(defs) == 1 {
166261 m.def = newDefinitionInfo(def.schema, def, defs[0])
@@ -206,17 +301,19 @@ func (r *reader) mappingAllLoose(n *rnode, m *Mapping, def, keyDef, valDef *Defi
206301 m.Add(keyScalar, r.readValue(val, valDef))
207302 } else {
208303 r.ctx.ErrorAt(keyScalar, "A template expression is not allowed in this context")
304+ r.skip(val)
209305 }
210306 continue
211307 }
212308 nextKey, ok := keyScalar.(*String)
213309 if !ok {
214- nextKey = &String{tokenBase: *keyScalar.base(), Value: keyScalar.String()}
310+ nextKey = &String{tokenBase: *keyScalar.base(), Value: r.text(keyScalar)}
215311 }
216312 if nextKey.Value != "" {
217313 up := strings.ToUpper(nextKey.Value)
218314 if upperKeys[up] {
219315 r.ctx.Errorf(nextKey, "'%s' is already defined", nextKey.Value)
316+ r.skip(val)
220317 continue
221318 }
222319 upperKeys[up] = true
@@ -239,7 +336,7 @@ func (r *reader) validate(scalar Token, def *DefinitionInfo) Token {
239336 }
240337 }
241338 if scalar.Kind() != KindString {
242- sl := &String{tokenBase: *scalar.base(), Value: scalar.String()}
339+ sl := &String{tokenBase: *scalar.base(), Value: r.text(scalar)}
243340 for _, d := range def.schema.scalarDefinitions(def.Definition) {
244341 if d.isMatch(sl) {
245342 sl.def = newDefinitionInfo(def.schema, def, d)
@@ -247,7 +344,7 @@ func (r *reader) validate(scalar Token, def *DefinitionInfo) Token {
247344 }
248345 }
249346 }
250- r.ctx.ErrorAt(scalar, fmt.Sprintf("Unexpected value '%s'", scalar.String()))
347+ r.ctx.ErrorAt(scalar, fmt.Sprintf("Unexpected value '%s'", r.text(scalar)))
251348 case KindBasicExpression:
252349 if len(def.allowed) == 0 {
253350 r.ctx.ErrorAt(scalar, "A template expression is not allowed in this context")
@@ -23,7 +23,7 @@ func ReadYAML(ctx *Context, rootType, file string, content []byte) (root Token)
23 ctx.add(e)23 ctx.add(e)
24 return nil24 return nil
25 }25 }
26- res := readYAMLDocument(file, content)26+ res := readYAMLDocument(file, content, ctx.RunnerYAML)
27 for _, e := range res.errs {27 for _, e := range res.errs {
28 ctx.add(e)28 ctx.add(e)
29 }29 }
@@ -41,10 +41,18 @@ func ReadYAML(ctx *Context, rootType, file string, content []byte) (root Token)
41 }()41 }()
42 rd := &reader{ctx: ctx, schema: ctx.schema, file: file}42 rd := &reader{ctx: ctx, schema: ctx.schema, file: file}
43 if res.root == nil {43 if res.root == nil {
44+ if ctx.RunnerYAML {
45+ // YamlDotNet produces no DocumentStart event for an empty stream.
46+ fatalf("Expected document start parse event")
47+ }
44 fatalf("Expected a scalar value, a sequence, or a mapping")48 fatalf("Expected a scalar value, a sequence, or a mapping")
45 }49 }
46 def := newDefinitionInfo(ctx.schema, nil, ctx.schema.get(rootType))50 def := newDefinitionInfo(ctx.schema, nil, ctx.schema.get(rootType))
47- return rd.readValue(res.root, def)51+ root = rd.readValue(res.root, def)
52+ for _, e := range res.after {
53+ ctx.add(e)
54+ }
55+ return root
48 }56 }
49 57
50 type reader struct {58 type reader struct {
@@ -54,16 +62,81 @@ type reader struct {
54 // noExpressions is set when reading the result of an evaluation, whose62 // noExpressions is set when reading the result of an evaluation, whose
55 // strings are text even if they contain ${{.63 // strings are text even if they contain ${{.
56 noExpressions bool64 noExpressions bool
65+ // depth is the nesting of the collections being read, see Context.MaxDepth.
66+ depth int
67+}
68+
69+// enter counts one more level of nesting. Like TemplateMemory.IncrementDepth
70+// it fails when the levels already entered reach the limit, so a limit of 100
71+// allows 100 nested collections.
72+func (r *reader) enter() {
73+ if r.ctx.MaxDepth <= 0 {
74+ return
75+ }
76+ if r.depth >= r.ctx.MaxDepth {
77+ fatalf("Maximum object depth exceeded")
78+ }
79+ r.depth++
80+}
81+
82+// text is the string a literal becomes when the schema wants a string, and
83+// the text of a key that is not a string: Token.String, except that a number
84+// is shown as the runner's NumberToken shows it in runner mode.
85+func (r *reader) text(t Token) string {
86+ if n, ok := t.(*Number); ok && r.ctx.RunnerYAML {
87+ return DotNetNumberString(n.Value)
88+ }
89+ return t.String()
90+}
91+
92+func (r *reader) leave() {
93+ if r.ctx.MaxDepth > 0 {
94+ r.depth--
95+ }
96+}
97+
98+// skip walks a value that is not kept (the value of an unexpected key, the
99+// items of a collection the schema does not allow) and counts its nesting,
100+// as the reference's SkipValue does.
101+func (r *reader) skip(n *rnode) {
102+ if n == nil {
103+ return
104+ }
105+ if n.fault != "" {
106+ fatalf("%s", n.fault)
107+ }
108+ if r.ctx.MaxDepth <= 0 {
109+ return
110+ }
111+ switch n.kind {
112+ case rSeq:
113+ r.enter()
114+ for _, it := range n.items {
115+ r.skip(it)
116+ }
117+ r.leave()
118+ case rMap:
119+ r.enter()
120+ for i := range n.keys {
121+ r.skip(n.keys[i])
122+ r.skip(n.vals[i])
123+ }
124+ r.leave()
125+ }
57 }126 }
58 127
59 func (r *reader) origin(n *rnode) Origin { return Origin{File: r.file, Pos: n.pos} }128 func (r *reader) origin(n *rnode) Origin { return Origin{File: r.file, Pos: n.pos} }
60 129
61 func (r *reader) readValue(n *rnode, def *DefinitionInfo) Token {130 func (r *reader) readValue(n *rnode, def *DefinitionInfo) Token {
131+ if n.fault != "" {
132+ fatalf("%s", n.fault)
133+ }
62 switch n.kind {134 switch n.kind {
63 case rLiteral:135 case rLiteral:
64 scalar := r.parseScalar(n, n.lit, def)136 scalar := r.parseScalar(n, n.lit, def)
65 return r.validate(scalar, def)137 return r.validate(scalar, def)
66 case rSeq:138 case rSeq:
139+ r.enter()
67 seq := NewSequence(r.origin(n))140 seq := NewSequence(r.origin(n))
68 defs := def.schema.definitionsOfType(def.Definition, DefSequence)141 defs := def.schema.definitionsOfType(def.Definition, DefSequence)
69 if len(defs) > 0 {142 if len(defs) > 0 {
@@ -73,10 +146,15 @@ func (r *reader) readValue(n *rnode, def *DefinitionInfo) Token {
73 }146 }
74 } else {147 } else {
75 r.ctx.ErrorAt(seq, "A sequence was not expected")148 r.ctx.ErrorAt(seq, "A sequence was not expected")
149+ for _, it := range n.items {
150+ r.skip(it)
151+ }
76 }152 }
77 seq.def = def153 seq.def = def
154+ r.leave()
78 return seq155 return seq
79 }156 }
157+ r.enter()
80 m := NewMapping(r.origin(n))158 m := NewMapping(r.origin(n))
81 defs := def.schema.definitionsOfType(def.Definition, DefMapping)159 defs := def.schema.definitionsOfType(def.Definition, DefMapping)
82 if len(defs) > 0 {160 if len(defs) > 0 {
@@ -89,10 +167,15 @@ func (r *reader) readValue(n *rnode, def *DefinitionInfo) Token {
89 }167 }
90 } else {168 } else {
91 r.ctx.ErrorAt(m, "A mapping was not expected")169 r.ctx.ErrorAt(m, "A mapping was not expected")
170+ for i := range n.keys {
171+ r.skip(n.keys[i])
172+ r.skip(n.vals[i])
173+ }
92 }174 }
93 if m.def == nil {175 if m.def == nil {
94 m.def = def176 m.def = def
95 }177 }
178+ r.leave()
96 return m179 return m
97 }180 }
98 181
@@ -100,7 +183,15 @@ func (r *reader) readValue(n *rnode, def *DefinitionInfo) Token {
100 // error and reads as an empty string.183 // error and reads as an empty string.
101 func (r *reader) key(n *rnode, i int, def *DefinitionInfo) Token {184 func (r *reader) key(n *rnode, i int, def *DefinitionInfo) Token {
102 k := n.keys[i]185 k := n.keys[i]
186+ if k.fault != "" {
187+ fatalf("%s", k.fault)
188+ }
103 if k.badKey {189 if k.badKey {
190+ if r.ctx.RunnerYAML {
191+ // The runner's loop over the keys ends at the first one that is not
192+ // a literal, and then expects the end of the mapping.
193+ fatalf("Expected mapping end")
194+ }
104 r.ctx.ErrorAt(NewString(r.origin(k), ""), "Unexpected value ''")195 r.ctx.ErrorAt(NewString(r.origin(k), ""), "Unexpected value ''")
105 }196 }
106 return r.parseScalar(k, k.lit, def)197 return r.parseScalar(k, k.lit, def)
@@ -123,17 +214,19 @@ func (r *reader) mappingWithProperties(n *rnode, m *Mapping, def *DefinitionInfo
123 m.Add(keyScalar, r.readValue(val, def.child("any")))214 m.Add(keyScalar, r.readValue(val, def.child("any")))
124 } else {215 } else {
125 r.ctx.ErrorAt(keyScalar, "A template expression is not allowed in this context")216 r.ctx.ErrorAt(keyScalar, "A template expression is not allowed in this context")
217+ r.skip(val)
126 }218 }
127 continue219 continue
128 }220 }
129 nextKey, ok := keyScalar.(*String)221 nextKey, ok := keyScalar.(*String)
130 if !ok {222 if !ok {
131- nextKey = &String{tokenBase: *keyScalar.base(), Value: keyScalar.String()}223+ nextKey = &String{tokenBase: *keyScalar.base(), Value: r.text(keyScalar)}
132 }224 }
133 if nextKey.Value != "" {225 if nextKey.Value != "" {
134 up := strings.ToUpper(nextKey.Value)226 up := strings.ToUpper(nextKey.Value)
135 if upperKeys[up] {227 if upperKeys[up] {
136 r.ctx.Errorf(nextKey, "'%s' is already defined", nextKey.Value)228 r.ctx.Errorf(nextKey, "'%s' is already defined", nextKey.Value)
229+ r.skip(val)
137 continue230 continue
138 }231 }
139 upperKeys[up] = true232 upperKeys[up] = true
@@ -143,6 +236,7 @@ func (r *reader) mappingWithProperties(n *rnode, m *Mapping, def *DefinitionInfo
143 if prop != nil {236 if prop != nil {
144 if backgroundStepProperties[nextKey.Value] {237 if backgroundStepProperties[nextKey.Value] {
145 r.ctx.Errorf(nextKey, "Unexpected value '%s'", nextKey.Value)238 r.ctx.Errorf(nextKey, "Unexpected value '%s'", nextKey.Value)
239+ r.skip(val)
146 continue240 continue
147 }241 }
148 nd := def.child(prop.Type)242 nd := def.child(prop.Type)
@@ -161,6 +255,7 @@ func (r *reader) mappingWithProperties(n *rnode, m *Mapping, def *DefinitionInfo
161 continue255 continue
162 }256 }
163 r.ctx.Errorf(nextKey, "Unexpected value '%s'", nextKey.Value)257 r.ctx.Errorf(nextKey, "Unexpected value '%s'", nextKey.Value)
258+ r.skip(val)
164 }259 }
165 if len(defs) == 1 {260 if len(defs) == 1 {
166 m.def = newDefinitionInfo(def.schema, def, defs[0])261 m.def = newDefinitionInfo(def.schema, def, defs[0])
@@ -206,17 +301,19 @@ func (r *reader) mappingAllLoose(n *rnode, m *Mapping, def, keyDef, valDef *Defi
206 m.Add(keyScalar, r.readValue(val, valDef))301 m.Add(keyScalar, r.readValue(val, valDef))
207 } else {302 } else {
208 r.ctx.ErrorAt(keyScalar, "A template expression is not allowed in this context")303 r.ctx.ErrorAt(keyScalar, "A template expression is not allowed in this context")
304+ r.skip(val)
209 }305 }
210 continue306 continue
211 }307 }
212 nextKey, ok := keyScalar.(*String)308 nextKey, ok := keyScalar.(*String)
213 if !ok {309 if !ok {
214- nextKey = &String{tokenBase: *keyScalar.base(), Value: keyScalar.String()}310+ nextKey = &String{tokenBase: *keyScalar.base(), Value: r.text(keyScalar)}
215 }311 }
216 if nextKey.Value != "" {312 if nextKey.Value != "" {
217 up := strings.ToUpper(nextKey.Value)313 up := strings.ToUpper(nextKey.Value)
218 if upperKeys[up] {314 if upperKeys[up] {
219 r.ctx.Errorf(nextKey, "'%s' is already defined", nextKey.Value)315 r.ctx.Errorf(nextKey, "'%s' is already defined", nextKey.Value)
316+ r.skip(val)
220 continue317 continue
221 }318 }
222 upperKeys[up] = true319 upperKeys[up] = true
@@ -239,7 +336,7 @@ func (r *reader) validate(scalar Token, def *DefinitionInfo) Token {
239 }336 }
240 }337 }
241 if scalar.Kind() != KindString {338 if scalar.Kind() != KindString {
242- sl := &String{tokenBase: *scalar.base(), Value: scalar.String()}339+ sl := &String{tokenBase: *scalar.base(), Value: r.text(scalar)}
243 for _, d := range def.schema.scalarDefinitions(def.Definition) {340 for _, d := range def.schema.scalarDefinitions(def.Definition) {
244 if d.isMatch(sl) {341 if d.isMatch(sl) {
245 sl.def = newDefinitionInfo(def.schema, def, d)342 sl.def = newDefinitionInfo(def.schema, def, d)
@@ -247,7 +344,7 @@ func (r *reader) validate(scalar Token, def *DefinitionInfo) Token {
247 }344 }
248 }345 }
249 }346 }
250- r.ctx.ErrorAt(scalar, fmt.Sprintf("Unexpected value '%s'", scalar.String()))347+ r.ctx.ErrorAt(scalar, fmt.Sprintf("Unexpected value '%s'", r.text(scalar)))
251 case KindBasicExpression:348 case KindBasicExpression:
252 if len(def.allowed) == 0 {349 if len(def.allowed) == 0 {
253 r.ctx.ErrorAt(scalar, "A template expression is not allowed in this context")350 r.ctx.ErrorAt(scalar, "A template expression is not allowed in this context")
M template/scalar_ref_test.go +1 -1
@@ -23,7 +23,7 @@ func TestPlainScalarsAgainstReference(t *testing.T) {
2323 t.Fatal(err)
2424 }
2525 for src, w := range want {
26- res := readYAMLDocument("f.yml", []byte("k: "+src+"\n"))
26+ res := readYAMLDocument("f.yml", []byte("k: "+src+"\n"), false)
2727 if w == "ERR" {
2828 continue
2929 }
@@ -23,7 +23,7 @@ func TestPlainScalarsAgainstReference(t *testing.T) {
23 t.Fatal(err)23 t.Fatal(err)
24 }24 }
25 for src, w := range want {25 for src, w := range want {
26- res := readYAMLDocument("f.yml", []byte("k: "+src+"\n"))26+ res := readYAMLDocument("f.yml", []byte("k: "+src+"\n"), false)
27 if w == "ERR" {27 if w == "ERR" {
28 continue28 continue
29 }29 }
M template/yaml.go +120 -8
@@ -51,12 +51,19 @@ type rnode struct {
5151 block bool
5252 // badKey is set on a key that is a collection.
5353 badKey bool
54+ // fault is set in runner mode (Context.RunnerYAML) on a node the runner's
55+ // YAML reader refuses. The template reader stops with this message when it
56+ // reaches the node, so the errors found before it in the document are kept.
57+ fault string
5458 }
5559
5660 // yamlResult is the outcome of reading a YAML document.
5761 type yamlResult struct {
5862 root *rnode // nil for an empty document
5963 errs []*Error
64+ // after holds errors to record once the document is read (runner mode:
65+ // the runner notices a second document only after reading the first).
66+ after []*Error
6067 }
6168
6269 type yamlBuilder struct {
@@ -65,11 +72,13 @@ type yamlBuilder struct {
6572 errs []*Error
6673 stack map[*yaml.Node]bool
6774 over bool
75+ // runnerYAML selects the runner's restrictions, see Context.RunnerYAML.
76+ runnerYAML bool
6877 }
6978
7079 // readYAMLDocument parses content with yaml.v3 and builds the tree. Syntax
7180 // errors are returned in errs, with root nil.
72-func readYAMLDocument(file string, content []byte) (res yamlResult) {
81+func readYAMLDocument(file string, content []byte, runnerYAML bool) (res yamlResult) {
7382 defer func() {
7483 if r := recover(); r != nil {
7584 res = yamlResult{errs: []*Error{{File: file, Raw: fmt.Sprintf("%v", r)}}}
@@ -84,25 +93,32 @@ func readYAMLDocument(file string, content []byte) (res yamlResult) {
8493 if err != nil {
8594 return yamlResult{errs: []*Error{yamlError(file, err)}}
8695 }
96+ var after []*Error
8797 var second yaml.Node
8898 if err := dec.Decode(&second); err == nil {
89- e := &Error{File: file, Raw: "Source contains multiple documents; please use YAML.parseAllDocuments()"}
90- e.Pos = Pos{second.Line, second.Column}
91- e.fileOnly = true
92- e.Raw += fmt.Sprintf(" at line %d, column %d", second.Line, second.Column)
93- return yamlResult{errs: []*Error{e}}
99+ if runnerYAML {
100+ // The runner reads the first document, then fails on the event
101+ // that is not the end of the stream.
102+ after = []*Error{{File: file, fileOnly: true, Raw: "Expected stream end parse event"}}
103+ } else {
104+ e := &Error{File: file, Raw: "Source contains multiple documents; please use YAML.parseAllDocuments()"}
105+ e.Pos = Pos{second.Line, second.Column}
106+ e.fileOnly = true
107+ e.Raw += fmt.Sprintf(" at line %d, column %d", second.Line, second.Column)
108+ return yamlResult{errs: []*Error{e}}
109+ }
94110 } else if !errors.Is(err, io.EOF) {
95111 return yamlResult{errs: []*Error{yamlError(file, err)}}
96112 }
97113 if len(doc.Content) == 0 {
98114 return yamlResult{}
99115 }
100- b := &yamlBuilder{file: file, stack: map[*yaml.Node]bool{}}
116+ b := &yamlBuilder{file: file, stack: map[*yaml.Node]bool{}, runnerYAML: runnerYAML}
101117 root := b.build(doc.Content[0], false)
102118 if b.over {
103119 return yamlResult{errs: b.errs}
104120 }
105- return yamlResult{root: root, errs: b.errs}
121+ return yamlResult{root: root, errs: b.errs, after: after}
106122 }
107123
108124 var yamlLineRE = regexp.MustCompile(`^(?:yaml: )?(?:unmarshal errors:\n\s*)?(?:line (\d+): )?`)
@@ -139,6 +155,14 @@ func (b *yamlBuilder) count() bool {
139155 }
140156
141157 func (b *yamlBuilder) build(n *yaml.Node, asKey bool) *rnode {
158+ r := b.build1(n, asKey)
159+ if b.runnerYAML && r.fault == "" && n.Kind != yaml.AliasNode {
160+ r.fault = runnerYAMLError(n)
161+ }
162+ return r
163+}
164+
165+func (b *yamlBuilder) build1(n *yaml.Node, asKey bool) *rnode {
142166 if b.over || !b.count() {
143167 return &rnode{kind: rLiteral, lit: NewNull(Origin{})}
144168 }
@@ -193,6 +217,94 @@ func (b *yamlBuilder) build(n *yaml.Node, asKey bool) *rnode {
193217 return &rnode{kind: rLiteral, pos: pos, lit: NewNull(o)}
194218 }
195219
220+// runnerYAMLError returns the message of the runner's YAML reader
221+// (Sdk/DTPipelines/Pipelines/ObjectTemplating/YamlObjectReader.cs) for a node
222+// it refuses, or "". It checks anchors, and the tag and style of scalars.
223+func runnerYAMLError(n *yaml.Node) string {
224+ if n.Anchor != "" {
225+ return fmt.Sprintf("Anchors are not currently supported. Remove the anchor '%s'", n.Anchor)
226+ }
227+ if n.Kind == yaml.ScalarNode && n.Style&(yaml.TaggedStyle|yaml.DoubleQuotedStyle|yaml.SingleQuotedStyle|yaml.LiteralStyle|yaml.FoldedStyle) == 0 {
228+ if msg := runnerIntegerError(n); msg != "" {
229+ return msg
230+ }
231+ }
232+ if n.Kind != yaml.ScalarNode || n.Style&yaml.TaggedStyle == 0 {
233+ return ""
234+ }
235+ tag := n.LongTag()
236+ const yamlTag = "tag:yaml.org,2002:"
237+ // The runner's string tag is "tag:yaml.org,2002:string", which no YAML
238+ // document spells (!!str is "tag:yaml.org,2002:str").
239+ if tag == yamlTag+"string" {
240+ return ""
241+ }
242+ plain := n.Style&(yaml.DoubleQuotedStyle|yaml.SingleQuotedStyle|yaml.LiteralStyle|yaml.FoldedStyle) == 0
243+ if !plain {
244+ style := "SingleQuoted"
245+ switch {
246+ case n.Style&yaml.DoubleQuotedStyle != 0:
247+ style = "DoubleQuoted"
248+ case n.Style&yaml.LiteralStyle != 0:
249+ style = "Literal"
250+ case n.Style&yaml.FoldedStyle != 0:
251+ style = "Folded"
252+ }
253+ return fmt.Sprintf("The scalar style '%s' on line %d and column %d is not valid with the tag '%s'", style, n.Line, n.Column, tag)
254+ }
255+ switch tag {
256+ case yamlTag + "bool", yamlTag + "float", yamlTag + "int", yamlTag + "null":
257+ default:
258+ return fmt.Sprintf("Unexpected tag '%s'", tag)
259+ }
260+ t := resolvePlain(Origin{}, n.Value)
261+ ok := false
262+ switch tag {
263+ case yamlTag + "bool":
264+ ok = t.Kind() == KindBoolean
265+ case yamlTag + "null":
266+ ok = t.Kind() == KindNull
267+ case yamlTag + "float":
268+ ok = t.Kind() == KindNumber
269+ case yamlTag + "int":
270+ ok = t.Kind() == KindNumber && (reInt.MatchString(n.Value) || reOct.MatchString(n.Value) || reHex.MatchString(n.Value))
271+ }
272+ if !ok {
273+ return fmt.Sprintf("The value '%s' on line %d and column %d is invalid for the type '%s'", n.Value, n.Line, n.Column, tag)
274+ }
275+ return ""
276+}
277+
278+// runnerIntegerError is the runner's refusal of a plain 0x or 0o integer that
279+// does not fit 32 bits (Int32.TryParse with AllowHexSpecifier, which accepts up
280+// to FFFFFFFF as a negative number, and Convert.ToInt32(s, 8)). The tag is
281+// empty in the message, as the plain scalar has none.
282+func runnerIntegerError(n *yaml.Node) string {
283+ v := n.Value
284+ var limit uint64 = 1<<32 - 1
285+ var base int
286+ switch {
287+ case reHex.MatchString(v):
288+ base = 16
289+ case reOct.MatchString(v):
290+ base = 8
291+ default:
292+ return ""
293+ }
294+ var acc uint64
295+ for _, c := range strings.ToLower(v[2:]) {
296+ d := uint64(c - '0')
297+ if c >= 'a' {
298+ d = uint64(c-'a') + 10
299+ }
300+ acc = acc*uint64(base) + d
301+ if acc > limit {
302+ return fmt.Sprintf("The value '%s' on line %d and column %d is invalid for the type ''", v, n.Line, n.Column)
303+ }
304+ }
305+ return ""
306+}
307+
196308 // resolveScalar gives a scalar its type the way the YAML 1.2 core schema of
197309 // the JavaScript yaml library does: only plain scalars are resolved, and an
198310 // explicit !!tag overrides the guess.
@@ -51,12 +51,19 @@ type rnode struct {
51 block bool51 block bool
52 // badKey is set on a key that is a collection.52 // badKey is set on a key that is a collection.
53 badKey bool53 badKey bool
54+ // fault is set in runner mode (Context.RunnerYAML) on a node the runner's
55+ // YAML reader refuses. The template reader stops with this message when it
56+ // reaches the node, so the errors found before it in the document are kept.
57+ fault string
54 }58 }
55 59
56 // yamlResult is the outcome of reading a YAML document.60 // yamlResult is the outcome of reading a YAML document.
57 type yamlResult struct {61 type yamlResult struct {
58 root *rnode // nil for an empty document62 root *rnode // nil for an empty document
59 errs []*Error63 errs []*Error
64+ // after holds errors to record once the document is read (runner mode:
65+ // the runner notices a second document only after reading the first).
66+ after []*Error
60 }67 }
61 68
62 type yamlBuilder struct {69 type yamlBuilder struct {
@@ -65,11 +72,13 @@ type yamlBuilder struct {
65 errs []*Error72 errs []*Error
66 stack map[*yaml.Node]bool73 stack map[*yaml.Node]bool
67 over bool74 over bool
75+ // runnerYAML selects the runner's restrictions, see Context.RunnerYAML.
76+ runnerYAML bool
68 }77 }
69 78
70 // readYAMLDocument parses content with yaml.v3 and builds the tree. Syntax79 // readYAMLDocument parses content with yaml.v3 and builds the tree. Syntax
71 // errors are returned in errs, with root nil.80 // errors are returned in errs, with root nil.
72-func readYAMLDocument(file string, content []byte) (res yamlResult) {81+func readYAMLDocument(file string, content []byte, runnerYAML bool) (res yamlResult) {
73 defer func() {82 defer func() {
74 if r := recover(); r != nil {83 if r := recover(); r != nil {
75 res = yamlResult{errs: []*Error{{File: file, Raw: fmt.Sprintf("%v", r)}}}84 res = yamlResult{errs: []*Error{{File: file, Raw: fmt.Sprintf("%v", r)}}}
@@ -84,25 +93,32 @@ func readYAMLDocument(file string, content []byte) (res yamlResult) {
84 if err != nil {93 if err != nil {
85 return yamlResult{errs: []*Error{yamlError(file, err)}}94 return yamlResult{errs: []*Error{yamlError(file, err)}}
86 }95 }
96+ var after []*Error
87 var second yaml.Node97 var second yaml.Node
88 if err := dec.Decode(&second); err == nil {98 if err := dec.Decode(&second); err == nil {
89- e := &Error{File: file, Raw: "Source contains multiple documents; please use YAML.parseAllDocuments()"}99+ if runnerYAML {
90- e.Pos = Pos{second.Line, second.Column}100+ // The runner reads the first document, then fails on the event
91- e.fileOnly = true101+ // that is not the end of the stream.
92- e.Raw += fmt.Sprintf(" at line %d, column %d", second.Line, second.Column)102+ after = []*Error{{File: file, fileOnly: true, Raw: "Expected stream end parse event"}}
93- return yamlResult{errs: []*Error{e}}103+ } else {
104+ e := &Error{File: file, Raw: "Source contains multiple documents; please use YAML.parseAllDocuments()"}
105+ e.Pos = Pos{second.Line, second.Column}
106+ e.fileOnly = true
107+ e.Raw += fmt.Sprintf(" at line %d, column %d", second.Line, second.Column)
108+ return yamlResult{errs: []*Error{e}}
109+ }
94 } else if !errors.Is(err, io.EOF) {110 } else if !errors.Is(err, io.EOF) {
95 return yamlResult{errs: []*Error{yamlError(file, err)}}111 return yamlResult{errs: []*Error{yamlError(file, err)}}
96 }112 }
97 if len(doc.Content) == 0 {113 if len(doc.Content) == 0 {
98 return yamlResult{}114 return yamlResult{}
99 }115 }
100- b := &yamlBuilder{file: file, stack: map[*yaml.Node]bool{}}116+ b := &yamlBuilder{file: file, stack: map[*yaml.Node]bool{}, runnerYAML: runnerYAML}
101 root := b.build(doc.Content[0], false)117 root := b.build(doc.Content[0], false)
102 if b.over {118 if b.over {
103 return yamlResult{errs: b.errs}119 return yamlResult{errs: b.errs}
104 }120 }
105- return yamlResult{root: root, errs: b.errs}121+ return yamlResult{root: root, errs: b.errs, after: after}
106 }122 }
107 123
108 var yamlLineRE = regexp.MustCompile(`^(?:yaml: )?(?:unmarshal errors:\n\s*)?(?:line (\d+): )?`)124 var yamlLineRE = regexp.MustCompile(`^(?:yaml: )?(?:unmarshal errors:\n\s*)?(?:line (\d+): )?`)
@@ -139,6 +155,14 @@ func (b *yamlBuilder) count() bool {
139 }155 }
140 156
141 func (b *yamlBuilder) build(n *yaml.Node, asKey bool) *rnode {157 func (b *yamlBuilder) build(n *yaml.Node, asKey bool) *rnode {
158+ r := b.build1(n, asKey)
159+ if b.runnerYAML && r.fault == "" && n.Kind != yaml.AliasNode {
160+ r.fault = runnerYAMLError(n)
161+ }
162+ return r
163+}
164+
165+func (b *yamlBuilder) build1(n *yaml.Node, asKey bool) *rnode {
142 if b.over || !b.count() {166 if b.over || !b.count() {
143 return &rnode{kind: rLiteral, lit: NewNull(Origin{})}167 return &rnode{kind: rLiteral, lit: NewNull(Origin{})}
144 }168 }
@@ -193,6 +217,94 @@ func (b *yamlBuilder) build(n *yaml.Node, asKey bool) *rnode {
193 return &rnode{kind: rLiteral, pos: pos, lit: NewNull(o)}217 return &rnode{kind: rLiteral, pos: pos, lit: NewNull(o)}
194 }218 }
195 219
220+// runnerYAMLError returns the message of the runner's YAML reader
221+// (Sdk/DTPipelines/Pipelines/ObjectTemplating/YamlObjectReader.cs) for a node
222+// it refuses, or "". It checks anchors, and the tag and style of scalars.
223+func runnerYAMLError(n *yaml.Node) string {
224+ if n.Anchor != "" {
225+ return fmt.Sprintf("Anchors are not currently supported. Remove the anchor '%s'", n.Anchor)
226+ }
227+ if n.Kind == yaml.ScalarNode && n.Style&(yaml.TaggedStyle|yaml.DoubleQuotedStyle|yaml.SingleQuotedStyle|yaml.LiteralStyle|yaml.FoldedStyle) == 0 {
228+ if msg := runnerIntegerError(n); msg != "" {
229+ return msg
230+ }
231+ }
232+ if n.Kind != yaml.ScalarNode || n.Style&yaml.TaggedStyle == 0 {
233+ return ""
234+ }
235+ tag := n.LongTag()
236+ const yamlTag = "tag:yaml.org,2002:"
237+ // The runner's string tag is "tag:yaml.org,2002:string", which no YAML
238+ // document spells (!!str is "tag:yaml.org,2002:str").
239+ if tag == yamlTag+"string" {
240+ return ""
241+ }
242+ plain := n.Style&(yaml.DoubleQuotedStyle|yaml.SingleQuotedStyle|yaml.LiteralStyle|yaml.FoldedStyle) == 0
243+ if !plain {
244+ style := "SingleQuoted"
245+ switch {
246+ case n.Style&yaml.DoubleQuotedStyle != 0:
247+ style = "DoubleQuoted"
248+ case n.Style&yaml.LiteralStyle != 0:
249+ style = "Literal"
250+ case n.Style&yaml.FoldedStyle != 0:
251+ style = "Folded"
252+ }
253+ return fmt.Sprintf("The scalar style '%s' on line %d and column %d is not valid with the tag '%s'", style, n.Line, n.Column, tag)
254+ }
255+ switch tag {
256+ case yamlTag + "bool", yamlTag + "float", yamlTag + "int", yamlTag + "null":
257+ default:
258+ return fmt.Sprintf("Unexpected tag '%s'", tag)
259+ }
260+ t := resolvePlain(Origin{}, n.Value)
261+ ok := false
262+ switch tag {
263+ case yamlTag + "bool":
264+ ok = t.Kind() == KindBoolean
265+ case yamlTag + "null":
266+ ok = t.Kind() == KindNull
267+ case yamlTag + "float":
268+ ok = t.Kind() == KindNumber
269+ case yamlTag + "int":
270+ ok = t.Kind() == KindNumber && (reInt.MatchString(n.Value) || reOct.MatchString(n.Value) || reHex.MatchString(n.Value))
271+ }
272+ if !ok {
273+ return fmt.Sprintf("The value '%s' on line %d and column %d is invalid for the type '%s'", n.Value, n.Line, n.Column, tag)
274+ }
275+ return ""
276+}
277+
278+// runnerIntegerError is the runner's refusal of a plain 0x or 0o integer that
279+// does not fit 32 bits (Int32.TryParse with AllowHexSpecifier, which accepts up
280+// to FFFFFFFF as a negative number, and Convert.ToInt32(s, 8)). The tag is
281+// empty in the message, as the plain scalar has none.
282+func runnerIntegerError(n *yaml.Node) string {
283+ v := n.Value
284+ var limit uint64 = 1<<32 - 1
285+ var base int
286+ switch {
287+ case reHex.MatchString(v):
288+ base = 16
289+ case reOct.MatchString(v):
290+ base = 8
291+ default:
292+ return ""
293+ }
294+ var acc uint64
295+ for _, c := range strings.ToLower(v[2:]) {
296+ d := uint64(c - '0')
297+ if c >= 'a' {
298+ d = uint64(c-'a') + 10
299+ }
300+ acc = acc*uint64(base) + d
301+ if acc > limit {
302+ return fmt.Sprintf("The value '%s' on line %d and column %d is invalid for the type ''", v, n.Line, n.Column)
303+ }
304+ }
305+ return ""
306+}
307+
196 // resolveScalar gives a scalar its type the way the YAML 1.2 core schema of308 // resolveScalar gives a scalar its type the way the YAML 1.2 core schema of
197 // the JavaScript yaml library does: only plain scalars are resolved, and an309 // the JavaScript yaml library does: only plain scalars are resolved, and an
198 // explicit !!tag overrides the guess.310 // explicit !!tag overrides the guess.
M workflow/convert_misc.go +5 -0
@@ -106,6 +106,11 @@ func runsOnLabels(t template.Token) []string {
106106 // statusFunctions are the functions that make a condition a status check.
107107 var statusFunctions = map[string]bool{"success": true, "failure": true, "cancelled": true, "always": true}
108108
109+// HasStatusFunction reports whether an expression calls success, failure,
110+// cancelled or always anywhere in its syntax tree, which decides whether an
111+// "if" condition is wrapped as "success() && (cond)".
112+func HasStatusFunction(n expr.Node) bool { return hasStatusFunction(n) }
113+
109114 func hasStatusFunction(n expr.Node) bool {
110115 switch n := n.(type) {
111116 case *expr.FunctionCall:
@@ -106,6 +106,11 @@ func runsOnLabels(t template.Token) []string {
106 // statusFunctions are the functions that make a condition a status check.106 // statusFunctions are the functions that make a condition a status check.
107 var statusFunctions = map[string]bool{"success": true, "failure": true, "cancelled": true, "always": true}107 var statusFunctions = map[string]bool{"success": true, "failure": true, "cancelled": true, "always": true}
108 108
109+// HasStatusFunction reports whether an expression calls success, failure,
110+// cancelled or always anywhere in its syntax tree, which decides whether an
111+// "if" condition is wrapped as "success() && (cond)".
112+func HasStatusFunction(n expr.Node) bool { return hasStatusFunction(n) }
113+
109 func hasStatusFunction(n expr.Node) bool {114 func hasStatusFunction(n expr.Node) bool {
110 switch n := n.(type) {115 switch n := n.(type) {
111 case *expr.FunctionCall:116 case *expr.FunctionCall:
M workflow/model.go +5 -0
@@ -152,6 +152,11 @@ type Step struct {
152152 With *template.Mapping
153153 // Env is the step's "env" mapping, or nil.
154154 Env *template.Mapping
155+ // EnvExpression and WithExpression hold "env" and "with" when the whole
156+ // value is an expression (env: ${{ fromJSON(...) }}); Env and With are then
157+ // nil. Only package action fills them, for the steps of a composite action
158+ // (the C# runner accepts such a step there); Parse of a workflow does not.
159+ EnvExpression, WithExpression template.Token
155160 // ContinueOnError and TimeoutMinutes are tokens, or nil.
156161 ContinueOnError template.Token
157162 TimeoutMinutes template.Token
@@ -152,6 +152,11 @@ type Step struct {
152 With *template.Mapping152 With *template.Mapping
153 // Env is the step's "env" mapping, or nil.153 // Env is the step's "env" mapping, or nil.
154 Env *template.Mapping154 Env *template.Mapping
155+ // EnvExpression and WithExpression hold "env" and "with" when the whole
156+ // value is an expression (env: ${{ fromJSON(...) }}); Env and With are then
157+ // nil. Only package action fills them, for the steps of a composite action
158+ // (the C# runner accepts such a step there); Parse of a workflow does not.
159+ EnvExpression, WithExpression template.Token
155 // ContinueOnError and TimeoutMinutes are tokens, or nil.160 // ContinueOnError and TimeoutMinutes are tokens, or nil.
156 ContinueOnError template.Token161 ContinueOnError template.Token
157 TimeoutMinutes template.Token162 TimeoutMinutes template.Token

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