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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | |||
| 3 | 3 | go 1.27.1 | |
| 4 | 4 | ||
| 5 | 5 | require ( | |
| 6 | - | bnema.dev/ermite-actions v0.0.0-20261012192445-fcc31accb5a4 | |
| 6 | + | bnema.dev/ermite-actions v0.0.0-20261013033425-b9f61ec64bf7 | |
| 7 | 7 | bnema.dev/ermite-api v0.0.0-20261012155218-f8f64413e63c | |
| 8 | 8 | connectrpc.com/connect v1.21.0 | |
| 9 | 9 | github.com/bnema/zerowrap v1.4.1 | |
| @@ -3,7 +3,7 @@ module bnema.dev/ermite-runner | |||||
| 3 | go 1.27.1 | 3 | go 1.27.1 | ||
| 4 | 4 | ||||
| 5 | require ( | 5 | require ( | ||
| 6 | - | bnema.dev/ermite-actions v0.0.0-20261012192445-fcc31accb5a4 | 6 | + | bnema.dev/ermite-actions v0.0.0-20261013033425-b9f61ec64bf7 |
| 7 | bnema.dev/ermite-api v0.0.0-20261012155218-f8f64413e63c | 7 | bnema.dev/ermite-api v0.0.0-20261012155218-f8f64413e63c | ||
| 8 | connectrpc.com/connect v1.21.0 | 8 | connectrpc.com/connect v1.21.0 | ||
| 9 | github.com/bnema/zerowrap v1.4.1 | 9 | 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 { | |||
| 22 | 22 | return resFailed | |
| 23 | 23 | } | |
| 24 | 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 | 30 | if err := j.prepareFileCommands(st); err != nil { | |
| 27 | 31 | return fail(err) | |
| @@ -31,7 +35,7 @@ func (j *job) execute(s *stepRun, stepCancel <-chan struct{}) stepResult { | |||
| 31 | 35 | ||
| 32 | 36 | in, err := j.scriptInputs(s) | |
| 33 | 37 | if err != nil { | |
| 34 | - | return fail(err) | |
| 38 | + | return failTemplate(err) | |
| 35 | 39 | } | |
| 36 | 40 | plan, err := j.printHeader(s, in) | |
| 37 | 41 | if err != nil { | |
| @@ -22,6 +22,10 @@ func (j *job) execute(s *stepRun, stepCancel <-chan struct{}) stepResult { | |||||
| 22 | return resFailed | 22 | 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 | |||
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "context" | |
| 7 | + | "errors" | |
| 7 | 8 | "fmt" | |
| 8 | 9 | "strings" | |
| 9 | 10 | "sync" | |
| @@ -159,6 +160,32 @@ func nodeServerEvaluable(n expr.Node) bool { | |||
| 159 | 160 | 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 | 189 | func tokenSource(t template.Token) string { | |
| 163 | 190 | if be, ok := t.(*template.BasicExpression); ok && be.Source != "" { | |
| 164 | 191 | return be.Source | |
| @@ -234,7 +261,7 @@ func (j *job) runStep(num int, st *workflow.Step) { | |||
| 234 | 261 | } | |
| 235 | 262 | ||
| 236 | 263 | if envErr != nil { | |
| 237 | - | j.errorf(num, "%s", envErr.Error()) | |
| 264 | + | j.errorf(num, "%s", templateNotValid(envErr, true)) | |
| 238 | 265 | complete(resFailed) | |
| 239 | 266 | return | |
| 240 | 267 | } | |
| @@ -264,7 +291,7 @@ func (j *job) runStep(num int, st *workflow.Step) { | |||
| 264 | 291 | ok, err := cond() | |
| 265 | 292 | switch { | |
| 266 | 293 | case err != nil: | |
| 267 | - | j.errorf(num, "%s", err.Error()) | |
| 294 | + | j.errorf(num, "%s", templateNotValid(err, false)) | |
| 268 | 295 | complete(resFailed) | |
| 269 | 296 | return | |
| 270 | 297 | 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 true | 160 | 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.Source | 191 | 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 | return | 266 | 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 | return | 296 | return | ||
| 270 | case !ok: | 297 | case !ok: | ||