Skip to content

Commit e3929e5

Browse files
mevdscheeclaude
andcommitted
ci: add GitHub Actions workflow and OpenAPI spec validation tests
- .github/workflows/ci.yml: vet, test, cgo-free static build, and gofmt check on push/PR to main and v2 (matches the plan's CGO_ENABLED=0 CI requirement). - api/spec_test.go: validate the embedded OpenAPI 3.1 document with the existing yaml.v3 dependency (no new deps) — asserts version/info, that every path has an operation with responses, and that the documented mirror paths are present so the spec cannot silently drift from the handlers. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent f47edb1 commit e3929e5

2 files changed

Lines changed: 126 additions & 0 deletions

File tree

.github/workflows/ci.yml

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main, v2]
6+
pull_request:
7+
8+
jobs:
9+
build-test:
10+
runs-on: ubuntu-latest
11+
steps:
12+
- uses: actions/checkout@v4
13+
14+
- uses: actions/setup-go@v5
15+
with:
16+
go-version: '1.25'
17+
check-latest: true
18+
19+
- name: Vet
20+
run: go vet ./...
21+
22+
- name: Test
23+
run: go test ./...
24+
25+
- name: Build (cgo-free, static single binary)
26+
env:
27+
CGO_ENABLED: 0
28+
run: go build -o github-export .
29+
30+
- name: gofmt
31+
run: |
32+
unformatted=$(gofmt -l .)
33+
if [ -n "$unformatted" ]; then
34+
echo "These files need gofmt:"; echo "$unformatted"; exit 1
35+
fi

api/spec_test.go

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
package api
2+
3+
import (
4+
"strings"
5+
"testing"
6+
7+
"gopkg.in/yaml.v3"
8+
)
9+
10+
// openapiDoc is a minimal view of the structure we assert on. It intentionally
11+
// avoids a heavyweight OpenAPI library to keep the project's dependency set
12+
// small; it validates the invariants that matter for our hand-written spec.
13+
type openapiDoc struct {
14+
OpenAPI string `yaml:"openapi"`
15+
Info struct {
16+
Title string `yaml:"title"`
17+
Version string `yaml:"version"`
18+
} `yaml:"info"`
19+
Paths map[string]map[string]struct {
20+
Responses map[string]any `yaml:"responses"`
21+
} `yaml:"paths"`
22+
}
23+
24+
func loadSpec(t *testing.T) openapiDoc {
25+
t.Helper()
26+
var doc openapiDoc
27+
if err := yaml.Unmarshal(OpenAPISpec, &doc); err != nil {
28+
t.Fatalf("openapi.yaml is not valid YAML: %v", err)
29+
}
30+
return doc
31+
}
32+
33+
func TestSpecIsOpenAPI31(t *testing.T) {
34+
doc := loadSpec(t)
35+
if !strings.HasPrefix(doc.OpenAPI, "3.1") {
36+
t.Errorf("openapi version = %q, want 3.1.x", doc.OpenAPI)
37+
}
38+
if doc.Info.Title == "" || doc.Info.Version == "" {
39+
t.Errorf("info.title/version must be set: %+v", doc.Info)
40+
}
41+
if len(doc.Paths) == 0 {
42+
t.Fatal("spec declares no paths")
43+
}
44+
}
45+
46+
func TestEveryPathHasAnOperationWithResponses(t *testing.T) {
47+
doc := loadSpec(t)
48+
methods := map[string]bool{"get": true, "post": true, "put": true, "patch": true, "delete": true}
49+
for path, item := range doc.Paths {
50+
ops := 0
51+
for method, op := range item {
52+
if !methods[method] {
53+
continue
54+
}
55+
ops++
56+
if len(op.Responses) == 0 {
57+
t.Errorf("%s %s declares no responses", strings.ToUpper(method), path)
58+
}
59+
}
60+
if ops == 0 {
61+
t.Errorf("path %s has no HTTP operations", path)
62+
}
63+
}
64+
}
65+
66+
// TestDocumentedMirrorPathsPresent guards against the spec drifting away from
67+
// the endpoints the server actually mirrors. If a handler path is renamed, this
68+
// list and the spec must be updated together.
69+
func TestDocumentedMirrorPathsPresent(t *testing.T) {
70+
doc := loadSpec(t)
71+
want := []string{
72+
"/repos/{owner}/{repo}",
73+
"/repos/{owner}/{repo}/issues",
74+
"/repos/{owner}/{repo}/issues/{number}",
75+
"/repos/{owner}/{repo}/issues/{number}/comments",
76+
"/repos/{owner}/{repo}/pulls",
77+
"/repos/{owner}/{repo}/pulls/{number}",
78+
"/repos/{owner}/{repo}/pulls/{number}/reviews",
79+
"/repos/{owner}/{repo}/labels",
80+
"/repos/{owner}/{repo}/releases",
81+
"/repos/{owner}/{repo}/commits",
82+
"/repos/{owner}/{repo}/contents/{path}",
83+
"/search/issues",
84+
"/status",
85+
}
86+
for _, p := range want {
87+
if _, ok := doc.Paths[p]; !ok {
88+
t.Errorf("OpenAPI spec is missing documented path %q", p)
89+
}
90+
}
91+
}

0 commit comments

Comments
 (0)