detached e88c60f

feat(executor): report template errors like GitHub, with the Json.NET message

A fromJSON that fails in a step's env or run reads 'The template is not valid.
<file> (Line: l, Col: c): <message>'; in a step's if, which has no location,
'The template is not valid. Newtonsoft.Json.JsonReaderException: <message>'
(recorded: expr-runtime-errors).

The Line 80 of the recording against the Line 90 of ermite is not a runner
difference: the harness injected a shorter dump step when GitHub ran the
scenario (the working-directory and token jq lines came later), so the
expression sat on line 80 of the file GitHub read. The executor reports the
line of the file it is given.

Uses bnema.dev/ermite-actions@b9f61ec (draft PR bnema/ermite-actions#7), to be
replaced by the merged commit.
commit
e88c60fb533c0c5457ec1794606f50ac88a3f2e8
author
brice · · 2026-10-13 03:36 UTC
parent
305254e
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +101 -4
M go.mod +1 -1
@@ -3,7 +3,7 @@ module bnema.dev/ermite-runner
33 go 1.27.1
44
55 require (
6- bnema.dev/ermite-actions v0.0.0-20261012192445-fcc31accb5a4
6+ bnema.dev/ermite-actions v0.0.0-20261013033425-b9f61ec64bf7
77 bnema.dev/ermite-api v0.0.0-20261012155218-f8f64413e63c
88 connectrpc.com/connect v1.21.0
99 github.com/bnema/zerowrap v1.4.1
@@ -3,7 +3,7 @@ module bnema.dev/ermite-runner
3 go 1.27.13 go 1.27.1
4 4
5 require (5 require (
6- bnema.dev/ermite-actions v0.0.0-20261012192445-fcc31accb5a46+ bnema.dev/ermite-actions v0.0.0-20261013033425-b9f61ec64bf7
7 bnema.dev/ermite-api v0.0.0-20261012155218-f8f64413e63c7 bnema.dev/ermite-api v0.0.0-20261012155218-f8f64413e63c
8 connectrpc.com/connect v1.21.08 connectrpc.com/connect v1.21.0
9 github.com/bnema/zerowrap v1.4.19 github.com/bnema/zerowrap v1.4.1
M go.sum +2 -0 generated file: collapsed
M internal/executor/exec_step.go +5 -1
@@ -22,6 +22,10 @@ func (j *job) execute(s *stepRun, stepCancel <-chan struct{}) stepResult {
2222 return resFailed
2323 }
2424 fail := func(err error) stepResult { j.errorf(num, "%s", err.Error()); return resFailed }
25+ failTemplate := func(err error) stepResult {
26+ j.errorf(num, "%s", templateNotValid(err, true))
27+ return resFailed
28+ }
2529
2630 if err := j.prepareFileCommands(st); err != nil {
2731 return fail(err)
@@ -31,7 +35,7 @@ func (j *job) execute(s *stepRun, stepCancel <-chan struct{}) stepResult {
3135
3236 in, err := j.scriptInputs(s)
3337 if err != nil {
34- return fail(err)
38+ return failTemplate(err)
3539 }
3640 plan, err := j.printHeader(s, in)
3741 if err != nil {
@@ -22,6 +22,10 @@ func (j *job) execute(s *stepRun, stepCancel <-chan struct{}) stepResult {
22 return resFailed22 return resFailed
23 }23 }
24 fail := func(err error) stepResult { j.errorf(num, "%s", err.Error()); return resFailed }24 fail := func(err error) stepResult { j.errorf(num, "%s", err.Error()); return resFailed }
25+ failTemplate := func(err error) stepResult {
26+ j.errorf(num, "%s", templateNotValid(err, true))
27+ return resFailed
28+ }
25 29
26 if err := j.prepareFileCommands(st); err != nil {30 if err := j.prepareFileCommands(st); err != nil {
27 return fail(err)31 return fail(err)
@@ -31,7 +35,7 @@ func (j *job) execute(s *stepRun, stepCancel <-chan struct{}) stepResult {
31 35
32 in, err := j.scriptInputs(s)36 in, err := j.scriptInputs(s)
33 if err != nil {37 if err != nil {
34- return fail(err)38+ return failTemplate(err)
35 }39 }
36 plan, err := j.printHeader(s, in)40 plan, err := j.printHeader(s, in)
37 if err != nil {41 if err != nil {
A internal/executor/fromjson_test.go +64 -0
@@ -0,0 +1,64 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "strings"
7+ "testing"
8+)
9+
10+// Recorded on github.com (expr-runtime-errors): a fromJSON that fails in a
11+// step's env or run is "The template is not valid. <file> (Line: l, Col: c):
12+// <Json.NET message>"; in a step's if there is no location and the exception
13+// class comes first. The line is the line of the expression in the workflow
14+// file the executor was given (GitHub's Line 80 and the 90 of a current
15+// recording differ only because the harness injected a shorter dump step then).
16+func TestFromJSONErrorsAreTemplateErrors(t *testing.T) {
17+ needTools(t, "bash")
18+ const msg = "Unexpected character encountered while parsing value: n. Path '', line 0, position 0."
19+ yaml := `on: push
20+env:
21+ NOT_JSON: not json
22+jobs:
23+ bad-env:
24+ runs-on: ubuntu-24.04
25+ steps:
26+ - name: bad env expression
27+ env:
28+ BAD: ${{ fromJSON(env.NOT_JSON) }}
29+ run: echo SHOULD-NOT-RUN
30+ - name: after
31+ if: always()
32+ run: echo after-ran
33+ bad-if:
34+ runs-on: ubuntu-24.04
35+ steps:
36+ - name: bad if expression
37+ if: fromJSON(env.NOT_JSON)
38+ run: echo SHOULD-NOT-RUN
39+ bad-run:
40+ runs-on: ubuntu-24.04
41+ steps:
42+ - name: bad run expression
43+ run: echo ${{ fromJSON(env.NOT_JSON) }}
44+`
45+ for _, tc := range []struct{ job, step, want string }{
46+ {"bad-env", "bad env expression", "##[error]The template is not valid. .github/workflows/ci.yml (Line: 10, Col: 16): " + msg},
47+ {"bad-if", "bad if expression", "##[error]The template is not valid. Newtonsoft.Json.JsonReaderException: " + msg},
48+ {"bad-run", "bad run expression", "##[error]The template is not valid. .github/workflows/ci.yml (Line: 25, Col: 14): " + msg},
49+ } {
50+ t.Run(tc.job, func(t *testing.T) {
51+ rec, res := runLocal(t, yaml, tc.job, nil)
52+ if res.Conclusion != ConclusionFailure {
53+ t.Fatalf("%+v", res)
54+ }
55+ s := rec.step(t, tc.step)
56+ if s.Conclusion != ConclusionFailure || len(s.Log) != 1 || s.Log[0] != tc.want {
57+ t.Errorf("%s: %s\nwant %s", s.Conclusion, strings.Join(s.Log, "\n"), tc.want)
58+ }
59+ if tc.job == "bad-env" && rec.step(t, "after").Conclusion != ConclusionSuccess {
60+ t.Error("the always() step must run")
61+ }
62+ })
63+ }
64+}
@@ -0,0 +1,64 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "strings"
7+ "testing"
8+)
9+
10+// Recorded on github.com (expr-runtime-errors): a fromJSON that fails in a
11+// step's env or run is "The template is not valid. <file> (Line: l, Col: c):
12+// <Json.NET message>"; in a step's if there is no location and the exception
13+// class comes first. The line is the line of the expression in the workflow
14+// file the executor was given (GitHub's Line 80 and the 90 of a current
15+// recording differ only because the harness injected a shorter dump step then).
16+func TestFromJSONErrorsAreTemplateErrors(t *testing.T) {
17+ needTools(t, "bash")
18+ const msg = "Unexpected character encountered while parsing value: n. Path '', line 0, position 0."
19+ yaml := `on: push
20+env:
21+ NOT_JSON: not json
22+jobs:
23+ bad-env:
24+ runs-on: ubuntu-24.04
25+ steps:
26+ - name: bad env expression
27+ env:
28+ BAD: ${{ fromJSON(env.NOT_JSON) }}
29+ run: echo SHOULD-NOT-RUN
30+ - name: after
31+ if: always()
32+ run: echo after-ran
33+ bad-if:
34+ runs-on: ubuntu-24.04
35+ steps:
36+ - name: bad if expression
37+ if: fromJSON(env.NOT_JSON)
38+ run: echo SHOULD-NOT-RUN
39+ bad-run:
40+ runs-on: ubuntu-24.04
41+ steps:
42+ - name: bad run expression
43+ run: echo ${{ fromJSON(env.NOT_JSON) }}
44+`
45+ for _, tc := range []struct{ job, step, want string }{
46+ {"bad-env", "bad env expression", "##[error]The template is not valid. .github/workflows/ci.yml (Line: 10, Col: 16): " + msg},
47+ {"bad-if", "bad if expression", "##[error]The template is not valid. Newtonsoft.Json.JsonReaderException: " + msg},
48+ {"bad-run", "bad run expression", "##[error]The template is not valid. .github/workflows/ci.yml (Line: 25, Col: 14): " + msg},
49+ } {
50+ t.Run(tc.job, func(t *testing.T) {
51+ rec, res := runLocal(t, yaml, tc.job, nil)
52+ if res.Conclusion != ConclusionFailure {
53+ t.Fatalf("%+v", res)
54+ }
55+ s := rec.step(t, tc.step)
56+ if s.Conclusion != ConclusionFailure || len(s.Log) != 1 || s.Log[0] != tc.want {
57+ t.Errorf("%s: %s\nwant %s", s.Conclusion, strings.Join(s.Log, "\n"), tc.want)
58+ }
59+ if tc.job == "bad-env" && rec.step(t, "after").Conclusion != ConclusionSuccess {
60+ t.Error("the always() step must run")
61+ }
62+ })
63+ }
64+}
M internal/executor/step.go +29 -2
@@ -4,6 +4,7 @@ package executor
44
55 import (
66 "context"
7+ "errors"
78 "fmt"
89 "strings"
910 "sync"
@@ -159,6 +160,32 @@ func nodeServerEvaluable(n expr.Node) bool {
159160 return true
160161 }
161162
163+// exceptionClasser is implemented by the errors of package expr that stand for
164+// a .NET exception (expr.JSONError).
165+type exceptionClasser interface{ ExceptionClass() string }
166+
167+// templateNotValid is the message of the TemplateValidationException the
168+// runner throws when the evaluation of a template fails
169+// (TemplateStrings.TemplateNotValidWithErrors): "The template is not valid. "
170+// and the errors. With a position the error reads "file (Line: l, Col: c):
171+// message" (recorded: expr-runtime-errors, step env and run). A step condition
172+// has no position: the error is the exception itself, "Class: message"
173+// (withLocation is false).
174+func templateNotValid(err error, withLocation bool) string {
175+ var te *template.Error
176+ if !errors.As(err, &te) {
177+ return err.Error()
178+ }
179+ if withLocation {
180+ return "The template is not valid. " + te.Error()
181+ }
182+ var c exceptionClasser
183+ if errors.As(err, &c) {
184+ return "The template is not valid. " + c.ExceptionClass() + ": " + te.Raw
185+ }
186+ return te.Error()
187+}
188+
162189 func tokenSource(t template.Token) string {
163190 if be, ok := t.(*template.BasicExpression); ok && be.Source != "" {
164191 return be.Source
@@ -234,7 +261,7 @@ func (j *job) runStep(num int, st *workflow.Step) {
234261 }
235262
236263 if envErr != nil {
237- j.errorf(num, "%s", envErr.Error())
264+ j.errorf(num, "%s", templateNotValid(envErr, true))
238265 complete(resFailed)
239266 return
240267 }
@@ -264,7 +291,7 @@ func (j *job) runStep(num int, st *workflow.Step) {
264291 ok, err := cond()
265292 switch {
266293 case err != nil:
267- j.errorf(num, "%s", err.Error())
294+ j.errorf(num, "%s", templateNotValid(err, false))
268295 complete(resFailed)
269296 return
270297 case !ok:
@@ -4,6 +4,7 @@ package executor
4 4
5 import (5 import (
6 "context"6 "context"
7+ "errors"
7 "fmt"8 "fmt"
8 "strings"9 "strings"
9 "sync"10 "sync"
@@ -159,6 +160,32 @@ func nodeServerEvaluable(n expr.Node) bool {
159 return true160 return true
160 }161 }
161 162
163+// exceptionClasser is implemented by the errors of package expr that stand for
164+// a .NET exception (expr.JSONError).
165+type exceptionClasser interface{ ExceptionClass() string }
166+
167+// templateNotValid is the message of the TemplateValidationException the
168+// runner throws when the evaluation of a template fails
169+// (TemplateStrings.TemplateNotValidWithErrors): "The template is not valid. "
170+// and the errors. With a position the error reads "file (Line: l, Col: c):
171+// message" (recorded: expr-runtime-errors, step env and run). A step condition
172+// has no position: the error is the exception itself, "Class: message"
173+// (withLocation is false).
174+func templateNotValid(err error, withLocation bool) string {
175+ var te *template.Error
176+ if !errors.As(err, &te) {
177+ return err.Error()
178+ }
179+ if withLocation {
180+ return "The template is not valid. " + te.Error()
181+ }
182+ var c exceptionClasser
183+ if errors.As(err, &c) {
184+ return "The template is not valid. " + c.ExceptionClass() + ": " + te.Raw
185+ }
186+ return te.Error()
187+}
188+
162 func tokenSource(t template.Token) string {189 func tokenSource(t template.Token) string {
163 if be, ok := t.(*template.BasicExpression); ok && be.Source != "" {190 if be, ok := t.(*template.BasicExpression); ok && be.Source != "" {
164 return be.Source191 return be.Source
@@ -234,7 +261,7 @@ func (j *job) runStep(num int, st *workflow.Step) {
234 }261 }
235 262
236 if envErr != nil {263 if envErr != nil {
237- j.errorf(num, "%s", envErr.Error())264+ j.errorf(num, "%s", templateNotValid(envErr, true))
238 complete(resFailed)265 complete(resFailed)
239 return266 return
240 }267 }
@@ -264,7 +291,7 @@ func (j *job) runStep(num int, st *workflow.Step) {
264 ok, err := cond()291 ok, err := cond()
265 switch {292 switch {
266 case err != nil:293 case err != nil:
267- j.errorf(num, "%s", err.Error())294+ j.errorf(num, "%s", templateNotValid(err, false))
268 complete(resFailed)295 complete(resFailed)
269 return296 return
270 case !ok:297 case !ok:

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 4

Tags 11

↑↓ moveenter openesc close