Skip to content

Commit d8e626f

Browse files
Merge pull request #5 from OneBusAway/deploy
Always exit 0 on a completed run; verdict lives in summary.verdict
2 parents 95237f7 + 20da9f8 commit d8e626f

8 files changed

Lines changed: 81 additions & 26 deletions

File tree

CLAUDE.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ go run ./cmd/oba-validator [flags] <config.json | raw-json-string>
2323
OBA_VALIDATOR_LIVE=1 go test ./validator/ -run TestLiveKingCountyMetro -v
2424
```
2525

26-
Exit codes (also returned by `Report.ExitCode()`): `0` = no failures, `1` = ≥1 failure, `2` = config/usage error. Warnings and skips never affect the exit code.
26+
Exit codes: `0` = the validator produced a report (PASS *or* FAIL verdict); `2` = the validator could not run (config/usage error, or `validator.Run` returned an error). The verdict is deliberately *not* in the exit code — it lives in the JSON report's `summary.verdict` and the result-sink row's `result_data`, so a Render cron that surfaces real server bugs still completes as "succeeded" and the caller learns the verdict from the sink. `Report.ExitCode()` (and `summary.exitCode`) is always `0`.
2727

2828
## Architecture
2929

@@ -49,7 +49,7 @@ Each check is a small struct in its own `check_*.go` file, returns `[]Result`, a
4949

5050
This is the core design discipline. Severity is **evidence-based** — see `docs/superpowers/specs/2026-05-24-oba-validator-design.md`:
5151

52-
- **`Fail`** only when the feed *has* an entity but the API contradicts or is missing it (genuine server breakage). Failures set exit code 1.
52+
- **`Fail`** only when the feed *has* an entity but the API contradicts or is missing it (genuine server breakage). A `Fail` drives the report's `summary.verdict` to `"FAIL"` but does **not** change the process exit code (see exit-code policy above).
5353
- **`Warn`** for valid-but-empty / unsamplable / unconfirmed conditions: empty feed, vehicle that moved, or an ID that didn't match on shape alone.
5454
- **`Skip`** when a prerequisite failed earlier in a dependent chain.
5555

README.md

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,10 @@ its REST API against the authoritative static GTFS and GTFS-realtime feeds
1111
Flags: `--json`, `--sample-size`, `--freshness`, `--timeout`, `--cache-dir`,
1212
`--no-cache`, `--refresh`.
1313

14-
Exit codes: `0` = no failures, `1` = at least one failure, `2` = config/usage
15-
error. Warnings and skips do not affect the exit code.
14+
Exit codes: `0` = the validator produced a report (read `summary.verdict` for
15+
PASS/FAIL); `2` = the validator could not run (config/usage error). The process
16+
exit code is intentionally not used to convey a FAIL verdict — that's what the
17+
JSON report's `summary.verdict` and the result-sink row are for.
1618

1719
## Config
1820

@@ -119,8 +121,10 @@ From Ruby (e.g. an obacloud job), this mirrors the existing pattern:
119121
start_command = "/app/entrypoint.sh #{encoded}"
120122

121123
Validator flags go after the token (`/app/entrypoint.sh <base64> --json`). The
122-
job's exit status is the validator's exit code (`0` no failures, `1` ≥1 failure,
123-
`2` config/usage error), so a failed validation shows as a failed run.
124+
job's exit status is the validator's exit code — `0` when a report was produced
125+
(PASS *or* FAIL verdict), `2` only when the validator couldn't run (config/usage
126+
error). A FAIL verdict therefore shows as a *succeeded* Render run; the caller
127+
reads the verdict from `summary.verdict` in the JSON report or the sink row.
124128

125129
See `docs/superpowers/specs/2026-05-24-oba-validator-design.md` for the validator
126130
design and `docs/superpowers/specs/2026-05-25-render-deployment-design.md` for the

cmd/oba-validator/main_test.go

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,38 @@ func TestRunJSONOutputShape(t *testing.T) {
130130
}
131131
}
132132

133+
// A FAIL verdict must come back through run() as process exit code 0; the
134+
// PASS/FAIL signal lives in summary.verdict, not the exit code. This pins the
135+
// CLAUDE.md exit-code policy end-to-end (newStubOBA returns an empty agencies
136+
// list, which makes the endpoints check emit at least one Fail).
137+
func TestRunFailVerdictReturnsZero(t *testing.T) {
138+
t.Setenv("ONEBUSAWAY_API_KEY", "")
139+
obaSrv := newStubOBA(t)
140+
feedSrv := newStubFeed(t)
141+
cfg := `{"obaServerURL":"` + obaSrv.URL + `","apiKey":"k","dataSources":[{"staticGtfsFeedURL":"` + feedSrv.URL + `/gtfs.zip"}]}`
142+
var stdout, stderr bytes.Buffer
143+
code := run([]string{"oba-validator", "--json", "--no-cache", cfg}, &stdout, &stderr)
144+
145+
if code != 0 {
146+
t.Fatalf("FAIL verdict must produce exit code 0, got %d\nstderr: %s", code, stderr.String())
147+
}
148+
var doc struct {
149+
Summary struct {
150+
Verdict string `json:"verdict"`
151+
ExitCode int `json:"exitCode"`
152+
} `json:"summary"`
153+
}
154+
if err := json.Unmarshal(stdout.Bytes(), &doc); err != nil {
155+
t.Fatalf("stdout not JSON: %v\n%s", err, stdout.String())
156+
}
157+
if doc.Summary.Verdict != "FAIL" {
158+
t.Errorf("summary.verdict = %q, want %q (stub returns empty agencies list)", doc.Summary.Verdict, "FAIL")
159+
}
160+
if doc.Summary.ExitCode != 0 {
161+
t.Errorf("summary.exitCode = %d, want 0", doc.Summary.ExitCode)
162+
}
163+
}
164+
133165
func TestRunJSONConfigErrorRedactsInlineAPIKey(t *testing.T) {
134166
t.Setenv("ONEBUSAWAY_API_KEY", "")
135167
var stdout, stderr bytes.Buffer
@@ -235,9 +267,10 @@ func TestRunSinkErrorDoesNotAlterExitCode(t *testing.T) {
235267
var stdout, stderr bytes.Buffer
236268
code := run([]string{"oba-validator", "--json", "--no-cache", cfg}, &stdout, &stderr)
237269

238-
// Sink failure must not change the validator exit code (0 or 1, never 2).
239-
if code == 2 {
240-
t.Errorf("sink failure should not produce exit code 2, got %d", code)
270+
// Sink failure must not change the validator exit code (always 0 on a
271+
// completed run; 2 is reserved for config/usage errors).
272+
if code != 0 {
273+
t.Errorf("sink failure should not change exit code from 0, got %d", code)
241274
}
242275
if !strings.Contains(stderr.String(), "result sink write failed") {
243276
t.Errorf("stderr should log the sink failure: %s", stderr.String())

report/document.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,11 @@ type MetaSource struct {
4242

4343
// Summary is the run-wide verdict and tallies.
4444
type Summary struct {
45-
Verdict string `json:"verdict"` // PASS | FAIL
45+
Verdict string `json:"verdict"` // PASS | FAIL
46+
// ExitCode mirrors validator.Report.ExitCode() and is always 0 for any
47+
// completed run. The PASS/FAIL verdict lives in Verdict above; the process
48+
// exit code is reserved for "the validator could not run" (exit 2 on
49+
// config error). Retained for schema stability — see CLAUDE.md.
4650
ExitCode int `json:"exitCode"`
4751
Total int `json:"total"`
4852
Counts Counts `json:"counts"`

report/document_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,8 +105,8 @@ func TestBuildDocument_CountsVerdictExit(t *testing.T) {
105105
if doc.Summary.Total != 4 {
106106
t.Errorf("total=%d want 4", doc.Summary.Total)
107107
}
108-
if doc.Summary.Verdict != "FAIL" || doc.Summary.ExitCode != 1 {
109-
t.Errorf("verdict/exit = %q/%d want FAIL/1", doc.Summary.Verdict, doc.Summary.ExitCode)
108+
if doc.Summary.Verdict != "FAIL" || doc.Summary.ExitCode != 0 {
109+
t.Errorf("verdict/exit = %q/%d want FAIL/0", doc.Summary.Verdict, doc.Summary.ExitCode)
110110
}
111111
if doc.SchemaVersion != SchemaVersion {
112112
t.Errorf("schemaVersion=%q", doc.SchemaVersion)

schema/oba-validator-report.schema.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,11 @@
8181
"required": ["verdict", "exitCode", "total", "counts"],
8282
"properties": {
8383
"verdict": { "type": "string", "enum": ["PASS", "FAIL"] },
84-
"exitCode": { "type": "integer", "enum": [0, 1] },
84+
"exitCode": {
85+
"type": "integer",
86+
"const": 0,
87+
"description": "Always 0 for a completed run. The PASS/FAIL verdict is conveyed by 'verdict' above, not by this field; the process exit code is reserved for 'the validator could not run' (exit 2 on config error). Field is retained for schema stability."
88+
},
8589
"total": { "type": "integer", "minimum": 0 },
8690
"counts": { "$ref": "#/$defs/counts" }
8791
}

validator/result.go

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,12 @@ func (r Report) Worst() Status {
3131
return worst
3232
}
3333

34-
// ExitCode is 1 if any result failed, else 0.
34+
// ExitCode is always 0 once a report has been produced. A FAIL verdict is
35+
// reported via Worst() and the JSON document's summary.verdict; the process
36+
// exit code is reserved for "the validator could not run" (config error in
37+
// main.go returns 2). This keeps the Render cron status green when the
38+
// validator successfully evaluated the OBA server, even if checks failed —
39+
// callers read the verdict from the result-sink row.
3540
func (r Report) ExitCode() int {
36-
if r.Worst() == Fail {
37-
return 1
38-
}
3941
return 0
4042
}

validator/result_test.go

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,16 @@ package validator
22

33
import "testing"
44

5-
func TestReportWorstAndExitCode(t *testing.T) {
5+
func TestReportWorst(t *testing.T) {
66
cases := []struct {
77
name string
88
statuses []Status
99
worst Status
10-
exit int
1110
}{
12-
{"all pass", []Status{Pass, Pass}, Pass, 0},
13-
{"warn only", []Status{Pass, Warn, Skip}, Warn, 0},
14-
{"any fail", []Status{Pass, Warn, Fail}, Fail, 1},
15-
{"empty", nil, Pass, 0},
11+
{"all pass", []Status{Pass, Pass}, Pass},
12+
{"warn only", []Status{Pass, Warn, Skip}, Warn},
13+
{"any fail", []Status{Pass, Warn, Fail}, Fail},
14+
{"empty", nil, Pass},
1615
}
1716
for _, c := range cases {
1817
t.Run(c.name, func(t *testing.T) {
@@ -23,9 +22,18 @@ func TestReportWorstAndExitCode(t *testing.T) {
2322
if got := r.Worst(); got != c.worst {
2423
t.Errorf("Worst()=%v want %v", got, c.worst)
2524
}
26-
if got := r.ExitCode(); got != c.exit {
27-
t.Errorf("ExitCode()=%d want %d", got, c.exit)
28-
}
2925
})
3026
}
3127
}
28+
29+
// ExitCode is intentionally constant: a completed run always returns 0,
30+
// including when checks failed. The FAIL verdict is conveyed via Worst() and
31+
// the JSON summary.verdict, not the process exit code.
32+
func TestReportExitCodeAlwaysZero(t *testing.T) {
33+
for _, s := range []Status{Pass, Warn, Skip, Fail} {
34+
r := Report{Results: []Result{{Status: s}}}
35+
if got := r.ExitCode(); got != 0 {
36+
t.Errorf("ExitCode() with %v result = %d, want 0", s, got)
37+
}
38+
}
39+
}

0 commit comments

Comments
 (0)