Skip to content

Commit 7cdf95f

Browse files
ci: snapshot OpenAPI spec for cross-stack contract testing
The bug class: today's prod login broke for ~24h because /auth/exchange (AUTH-004) shipped server-side without the dashboard's client being updated. Per-repo unit tests on both sides stayed green because each repo only saw its own half of the contract. This PR makes the contract a committed artifact (openapi.snapshot.json) so the dashboard + instanode-web repos can generate typed clients from it deterministically. When the api spec changes, this CI gate fails the PR until the snapshot is regenerated — surfacing the contract change so the engineer can also update the client repos. What ships: - handlers.OpenAPISpecProduction() — exported accessor for the production-rendered spec (with the dev-only /internal/set-tier path stripped). Source of truth for downstream snapshotting. - cmd/openapi-snapshot/ — small Go tool that canonicalises the spec (sorted keys, 2-space indent) so whitespace-only edits to the const do not flip the snapshot. - openapi.snapshot.json — the committed snapshot (9931 lines, 384KB). - make openapi-snapshot / openapi-snapshot-check — local regen + drift check. - .github/workflows/openapi-snapshot.yml — fast (<30s) PR gate that fails with a clear "run make openapi-snapshot" message when the committed file drifts from a fresh regeneration. Emits a structured log line per run (rule 25 observability) so we can track contract drift rate over time. Coverage block (rule 17): Symptom: Contract drift between api spec and TS clients goes undetected at PR time → runtime login break. Enumeration: rg -F 'openAPISpec' --include='*.go' Sites found: Single const + ServeOpenAPI handler in internal/handlers/openapi.go. Sites touched: Added OpenAPISpecProduction() accessor next to ServeOpenAPI; both go through the same stripInternalSetTierPath path so what the snapshot emits == what production serves. Coverage test: make openapi-snapshot-check (CI job snapshot-drift) regenerates and diffs on every PR touching openapi.go / cmd/openapi-snapshot/ / openapi.snapshot.json. Live verified: make openapi-snapshot wrote 383616 bytes; make openapi-snapshot-check returns "snapshot matches handlers.OpenAPISpecProduction()". The /internal/set-tier dev-only path is correctly stripped (grep -c returns 0). Follow-ups (separate PRs, scoped): - dashboard PR: openapi-typescript dev dep + npm run generate:api-types fetches this snapshot from origin/master and regenerates src/lib/api.generated.ts; CI diffs the regenerated file vs committed. Plus a Playwright AUTH-004 smoke test (POST /auth/email/start, OPTIONS /auth/exchange CORS preflight, POST /auth/exchange with credentials). - instanode-web PR: same pattern. - infra PR: NR dashboard tile + alert on cross_stack_contract_drift CI log events (rule 25). Note: /auth/exchange is currently NOT in the OpenAPI spec — that gap is the proximate cause of today's outage. This PR builds the infrastructure to catch the next gap; adding /auth/exchange to the spec is a separate fix that should follow this PR (it will trigger this gate and force the regeneration discipline from day one). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent fe6d391 commit 7cdf95f

5 files changed

Lines changed: 10176 additions & 0 deletions

File tree

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
# Cross-stack OpenAPI contract snapshot check.
2+
#
3+
# Why this exists: today's prod login broke for ~24h because /auth/exchange
4+
# (AUTH-004) shipped server-side without the dashboard's client being updated.
5+
# Per-repo unit tests on both sides stayed green because each repo only saw
6+
# its own half of the contract. This workflow makes the contract a committed
7+
# artifact (api/openapi.snapshot.json) that the dashboard + instanode-web
8+
# repos consume to generate typed clients. A drift here at PR time forces
9+
# the engineer to address client-side regeneration in the same change.
10+
#
11+
# Gate logic (small + cheap, < 30s):
12+
# 1. Build the openapi-snapshot tool.
13+
# 2. Regenerate openapi.snapshot.json from internal/handlers/openapi.go.
14+
# 3. Diff against the committed file. If different → fail with the exact
15+
# `make openapi-snapshot` command the engineer must run.
16+
#
17+
# Observability (rule 25): a single structured log line per run so we can
18+
# query CI artifacts for the rate at which contract drift is being caught:
19+
# {"event":"cross_stack_contract_drift","detected":true|false,"repo":"api"}
20+
# Captured by the GitHub Actions log forwarder into NR (instanode-reliability
21+
# dashboard tile: "Contract drift caught at PR time, 30d").
22+
23+
name: openapi-snapshot
24+
25+
on:
26+
push:
27+
branches: [master]
28+
paths:
29+
- 'internal/handlers/openapi.go'
30+
- 'cmd/openapi-snapshot/**'
31+
- 'openapi.snapshot.json'
32+
- '.github/workflows/openapi-snapshot.yml'
33+
pull_request:
34+
branches: [master]
35+
paths:
36+
- 'internal/handlers/openapi.go'
37+
- 'cmd/openapi-snapshot/**'
38+
- 'openapi.snapshot.json'
39+
- '.github/workflows/openapi-snapshot.yml'
40+
workflow_dispatch:
41+
42+
concurrency:
43+
group: openapi-snapshot-${{ github.workflow }}-${{ github.ref }}
44+
cancel-in-progress: true
45+
46+
jobs:
47+
snapshot-drift:
48+
runs-on: ubuntu-latest
49+
steps:
50+
- uses: actions/checkout@v6
51+
52+
- name: Checkout proto sibling (for go.mod replace ../proto)
53+
uses: actions/checkout@v6
54+
with:
55+
repository: ${{ vars.PROTO_REPO || format('{0}/proto', github.repository_owner) }}
56+
token: ${{ secrets.REPO_ACCESS_TOKEN || secrets.GITHUB_TOKEN }}
57+
path: _proto_ci
58+
59+
- name: Place ../proto for Go replace directive
60+
run: mv _proto_ci ../proto
61+
62+
- name: Checkout common sibling (for go.mod replace ../common)
63+
uses: actions/checkout@v6
64+
with:
65+
repository: ${{ vars.COMMON_REPO || format('{0}/common', github.repository_owner) }}
66+
token: ${{ secrets.REPO_ACCESS_TOKEN || secrets.GITHUB_TOKEN }}
67+
path: _common_ci
68+
69+
- name: Place ../common for Go replace directive
70+
run: mv _common_ci ../common
71+
72+
- uses: actions/setup-go@v6
73+
with:
74+
go-version: '1.25'
75+
76+
- name: Regenerate openapi.snapshot.json
77+
id: regen
78+
run: |
79+
go run ./cmd/openapi-snapshot/ -out /tmp/openapi.snapshot.regenerated.json
80+
if diff -q openapi.snapshot.json /tmp/openapi.snapshot.regenerated.json >/dev/null; then
81+
echo "drift=false" >> "$GITHUB_OUTPUT"
82+
else
83+
echo "drift=true" >> "$GITHUB_OUTPUT"
84+
fi
85+
86+
- name: Emit observability line (rule 25)
87+
# Single structured line so NR log forwarder can chart drift rate.
88+
# Runs regardless of drift status so the absence of drift is also
89+
# a data point ("we ran, we passed").
90+
run: |
91+
printf '{"event":"cross_stack_contract_drift","detected":%s,"repo":"api","pr":"%s","sha":"%s"}\n' \
92+
"${{ steps.regen.outputs.drift }}" \
93+
"${{ github.event.pull_request.number || 'none' }}" \
94+
"${GITHUB_SHA:0:7}"
95+
96+
- name: Fail if snapshot is out of date
97+
if: steps.regen.outputs.drift == 'true'
98+
run: |
99+
echo "::error::openapi.snapshot.json is out of date."
100+
echo "::error::An edit to internal/handlers/openapi.go changed the production OpenAPI surface but the snapshot was not regenerated."
101+
echo "::error::Run \`make openapi-snapshot\` and commit the updated file in this PR."
102+
echo "::error::This is rule 22 (contract surface checklist): dashboard + instanode-web depend on this snapshot to generate typed clients."
103+
echo ""
104+
echo "Drift (committed → regenerated, first 200 lines):"
105+
diff -u openapi.snapshot.json /tmp/openapi.snapshot.regenerated.json | head -200 || true
106+
exit 1
107+
108+
- name: Snapshot is current
109+
if: steps.regen.outputs.drift == 'false'
110+
run: echo "openapi.snapshot.json matches handlers.OpenAPISpecProduction() — dashboards can regenerate clients deterministically."

Makefile

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
docker-up docker-down docker-logs \
33
migrate migrate-platform migrate-customers \
44
docker-build smoke-buildinfo \
5+
openapi-snapshot openapi-snapshot-check \
56
k8s-deploy k8s-delete k8s-status k8s-regen-migrations \
67
gen-secrets install-cli \
78
storage-verify-isolation \
@@ -184,6 +185,40 @@ smoke-buildinfo:
184185
echo "smoke-buildinfo: OK ($$out)" && \
185186
rm -rf $$tmpdir
186187

188+
# ── Cross-stack OpenAPI contract snapshot ─────────────────────────────────────
189+
#
190+
# api/openapi.snapshot.json is the source-of-truth artifact that the
191+
# dashboard and instanode-web repos consume to generate their typed API
192+
# clients (via openapi-typescript). It is the canonicalised JSON output of
193+
# handlers.OpenAPISpecProduction() — the same spec served at GET /openapi.json
194+
# in production.
195+
#
196+
# Workflow:
197+
# - Edit internal/handlers/openapi.go (add a path, change a schema).
198+
# - Run `make openapi-snapshot` — regenerates openapi.snapshot.json.
199+
# - Commit both files in the same PR (rule 22: contract surface checklist).
200+
# - CI runs `make openapi-snapshot-check` and fails the PR if the
201+
# committed snapshot differs from a freshly regenerated one.
202+
#
203+
# The snapshot tool canonicalises (sorted keys, 2-space indent) so that
204+
# whitespace-only edits to the const do not flip the snapshot — only real
205+
# contract changes do.
206+
openapi-snapshot:
207+
@go run ./cmd/openapi-snapshot/
208+
209+
openapi-snapshot-check:
210+
@go run ./cmd/openapi-snapshot/ -out /tmp/openapi.snapshot.regenerated.json
211+
@if ! diff -q openapi.snapshot.json /tmp/openapi.snapshot.regenerated.json >/dev/null; then \
212+
echo ""; \
213+
echo "::error::openapi.snapshot.json is out of date."; \
214+
echo "::error::Run \`make openapi-snapshot\` and commit the updated file."; \
215+
echo ""; \
216+
echo "Drift (committed → regenerated):"; \
217+
diff -u openapi.snapshot.json /tmp/openapi.snapshot.regenerated.json | head -80 || true; \
218+
exit 1; \
219+
fi
220+
@echo "openapi-snapshot-check: snapshot matches handlers.OpenAPISpecProduction()"
221+
187222
# Regen the SQL ConfigMap from the actual migration file (run after schema changes)
188223
k8s-regen-migrations:
189224
kubectl create configmap instant-migrations \

cmd/openapi-snapshot/main.go

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
// Command openapi-snapshot writes the production-rendered OpenAPI 3.1 spec
2+
// to api/openapi.snapshot.json (or to the path given by -out).
3+
//
4+
// This is the source-of-truth artifact for cross-stack contract testing. The
5+
// dashboard and instanode-web repos consume the committed snapshot to generate
6+
// their typed API clients via openapi-typescript. If the snapshot drifts from
7+
// what handlers.OpenAPISpecProduction returns (i.e. someone edited the spec
8+
// const without regenerating), `make openapi-snapshot-check` fails CI with a
9+
// clear "regenerate the snapshot" message.
10+
//
11+
// The snapshot is canonicalised before writing:
12+
// - parsed as JSON and re-marshalled with sorted map keys and 2-space indent
13+
//
14+
// so that whitespace-only edits to the const (re-flowed strings, added blank
15+
// lines) do not falsely flip the snapshot. The only things that change the
16+
// snapshot are real contract changes: new paths, changed schemas, renamed
17+
// fields, removed responses.
18+
//
19+
// Usage:
20+
//
21+
// go run ./cmd/openapi-snapshot # writes ./openapi.snapshot.json
22+
// go run ./cmd/openapi-snapshot -out /tmp/x # writes to /tmp/x
23+
// go run ./cmd/openapi-snapshot -stdout # prints to stdout (CI diff)
24+
package main
25+
26+
import (
27+
"bytes"
28+
"encoding/json"
29+
"flag"
30+
"fmt"
31+
"os"
32+
33+
"instant.dev/internal/handlers"
34+
)
35+
36+
const defaultOutPath = "openapi.snapshot.json"
37+
38+
func main() {
39+
out := flag.String("out", defaultOutPath, "destination file for the canonical snapshot")
40+
toStdout := flag.Bool("stdout", false, "write to stdout instead of -out (CI diff mode)")
41+
flag.Parse()
42+
43+
canonical, err := canonicalise(handlers.OpenAPISpecProduction())
44+
if err != nil {
45+
fmt.Fprintf(os.Stderr, "openapi-snapshot: canonicalise: %v\n", err)
46+
os.Exit(2)
47+
}
48+
49+
if *toStdout {
50+
if _, err := os.Stdout.Write(canonical); err != nil {
51+
fmt.Fprintf(os.Stderr, "openapi-snapshot: stdout: %v\n", err)
52+
os.Exit(2)
53+
}
54+
return
55+
}
56+
57+
if err := os.WriteFile(*out, canonical, 0o644); err != nil {
58+
fmt.Fprintf(os.Stderr, "openapi-snapshot: write %s: %v\n", *out, err)
59+
os.Exit(2)
60+
}
61+
fmt.Fprintf(os.Stderr, "openapi-snapshot: wrote %d bytes to %s\n", len(canonical), *out)
62+
}
63+
64+
// canonicalise parses the spec as JSON and re-emits it with sorted keys and
65+
// 2-space indent so that the on-disk snapshot is deterministic and friendly
66+
// to diff. encoding/json sorts map keys alphabetically by default.
67+
func canonicalise(spec string) ([]byte, error) {
68+
var v any
69+
if err := json.Unmarshal([]byte(spec), &v); err != nil {
70+
return nil, fmt.Errorf("parse spec: %w", err)
71+
}
72+
var buf bytes.Buffer
73+
enc := json.NewEncoder(&buf)
74+
enc.SetIndent("", " ")
75+
enc.SetEscapeHTML(false)
76+
if err := enc.Encode(v); err != nil {
77+
return nil, fmt.Errorf("re-emit spec: %w", err)
78+
}
79+
return buf.Bytes(), nil
80+
}

internal/handlers/openapi.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,26 @@ func ServeOpenAPI(c *fiber.Ctx) error {
139139
return c.SendString(openAPISpecProd)
140140
}
141141

142+
// OpenAPISpecProduction returns the production-rendered OpenAPI 3.1 spec
143+
// (with the dev-only /internal/set-tier path stripped) as a JSON string.
144+
//
145+
// This is the canonical accessor for cross-stack contract snapshotting —
146+
// the cmd/openapi-snapshot tool writes its output to api/openapi.snapshot.json,
147+
// which dashboard and instanode-web consume to generate typed clients.
148+
//
149+
// Why an accessor (rather than exporting the const): the production spec is
150+
// the const minus the dev-only path entry. Callers that snapshot the raw const
151+
// would ship a spec that lies about the prod surface — every consumer would
152+
// generate a typed client for an endpoint that 404s in production. Routing
153+
// the snapshot through this function keeps "what the snapshot says" == "what
154+
// production serves".
155+
func OpenAPISpecProduction() string {
156+
openAPISpecOnce.Do(func() {
157+
openAPISpecProd = stripInternalSetTierPath(openAPISpec)
158+
})
159+
return openAPISpecProd
160+
}
161+
142162
// openAPISpec is embedded at build time. It covers all stable, agent-facing endpoints.
143163
// Generated credentials and tier limits are documented here so AI agents can
144164
// consume instant.dev programmatically without reading the source code.

0 commit comments

Comments
 (0)