Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 110 additions & 0 deletions .github/workflows/openapi-snapshot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
# Cross-stack OpenAPI contract snapshot check.
#
# Why this exists: 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 workflow makes the contract a committed
# artifact (api/openapi.snapshot.json) that the dashboard + instanode-web
# repos consume to generate typed clients. A drift here at PR time forces
# the engineer to address client-side regeneration in the same change.
#
# Gate logic (small + cheap, < 30s):
# 1. Build the openapi-snapshot tool.
# 2. Regenerate openapi.snapshot.json from internal/handlers/openapi.go.
# 3. Diff against the committed file. If different → fail with the exact
# `make openapi-snapshot` command the engineer must run.
#
# Observability (rule 25): a single structured log line per run so we can
# query CI artifacts for the rate at which contract drift is being caught:
# {"event":"cross_stack_contract_drift","detected":true|false,"repo":"api"}
# Captured by the GitHub Actions log forwarder into NR (instanode-reliability
# dashboard tile: "Contract drift caught at PR time, 30d").

name: openapi-snapshot

on:
push:
branches: [master]
paths:
- 'internal/handlers/openapi.go'
- 'cmd/openapi-snapshot/**'
- 'openapi.snapshot.json'
- '.github/workflows/openapi-snapshot.yml'
pull_request:
branches: [master]
paths:
- 'internal/handlers/openapi.go'
- 'cmd/openapi-snapshot/**'
- 'openapi.snapshot.json'
- '.github/workflows/openapi-snapshot.yml'
workflow_dispatch:

concurrency:
group: openapi-snapshot-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
snapshot-drift:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6

- name: Checkout proto sibling (for go.mod replace ../proto)
uses: actions/checkout@v6
with:
repository: ${{ vars.PROTO_REPO || format('{0}/proto', github.repository_owner) }}
token: ${{ secrets.REPO_ACCESS_TOKEN || secrets.GITHUB_TOKEN }}
path: _proto_ci

- name: Place ../proto for Go replace directive
run: mv _proto_ci ../proto

- name: Checkout common sibling (for go.mod replace ../common)
uses: actions/checkout@v6
with:
repository: ${{ vars.COMMON_REPO || format('{0}/common', github.repository_owner) }}
token: ${{ secrets.REPO_ACCESS_TOKEN || secrets.GITHUB_TOKEN }}
path: _common_ci

- name: Place ../common for Go replace directive
run: mv _common_ci ../common

- uses: actions/setup-go@v6
with:
go-version: '1.25'

- name: Regenerate openapi.snapshot.json
id: regen
run: |
go run ./cmd/openapi-snapshot/ -out /tmp/openapi.snapshot.regenerated.json
if diff -q openapi.snapshot.json /tmp/openapi.snapshot.regenerated.json >/dev/null; then
echo "drift=false" >> "$GITHUB_OUTPUT"
else
echo "drift=true" >> "$GITHUB_OUTPUT"
fi

- name: Emit observability line (rule 25)
# Single structured line so NR log forwarder can chart drift rate.
# Runs regardless of drift status so the absence of drift is also
# a data point ("we ran, we passed").
run: |
printf '{"event":"cross_stack_contract_drift","detected":%s,"repo":"api","pr":"%s","sha":"%s"}\n' \
"${{ steps.regen.outputs.drift }}" \
"${{ github.event.pull_request.number || 'none' }}" \
"${GITHUB_SHA:0:7}"

- name: Fail if snapshot is out of date
if: steps.regen.outputs.drift == 'true'
run: |
echo "::error::openapi.snapshot.json is out of date."
echo "::error::An edit to internal/handlers/openapi.go changed the production OpenAPI surface but the snapshot was not regenerated."
echo "::error::Run \`make openapi-snapshot\` and commit the updated file in this PR."
echo "::error::This is rule 22 (contract surface checklist): dashboard + instanode-web depend on this snapshot to generate typed clients."
echo ""
echo "Drift (committed → regenerated, first 200 lines):"
diff -u openapi.snapshot.json /tmp/openapi.snapshot.regenerated.json | head -200 || true
exit 1

- name: Snapshot is current
if: steps.regen.outputs.drift == 'false'
run: echo "openapi.snapshot.json matches handlers.OpenAPISpecProduction() — dashboards can regenerate clients deterministically."
35 changes: 35 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
docker-up docker-down docker-logs \
migrate migrate-platform migrate-customers \
docker-build smoke-buildinfo \
openapi-snapshot openapi-snapshot-check \
k8s-deploy k8s-delete k8s-status k8s-regen-migrations \
gen-secrets install-cli \
storage-verify-isolation \
Expand Down Expand Up @@ -184,6 +185,40 @@ smoke-buildinfo:
echo "smoke-buildinfo: OK ($$out)" && \
rm -rf $$tmpdir

# ── Cross-stack OpenAPI contract snapshot ─────────────────────────────────────
#
# api/openapi.snapshot.json is the source-of-truth artifact that the
# dashboard and instanode-web repos consume to generate their typed API
# clients (via openapi-typescript). It is the canonicalised JSON output of
# handlers.OpenAPISpecProduction() — the same spec served at GET /openapi.json
# in production.
#
# Workflow:
# - Edit internal/handlers/openapi.go (add a path, change a schema).
# - Run `make openapi-snapshot` — regenerates openapi.snapshot.json.
# - Commit both files in the same PR (rule 22: contract surface checklist).
# - CI runs `make openapi-snapshot-check` and fails the PR if the
# committed snapshot differs from a freshly regenerated one.
#
# The snapshot tool canonicalises (sorted keys, 2-space indent) so that
# whitespace-only edits to the const do not flip the snapshot — only real
# contract changes do.
openapi-snapshot:
@go run ./cmd/openapi-snapshot/

openapi-snapshot-check:
@go run ./cmd/openapi-snapshot/ -out /tmp/openapi.snapshot.regenerated.json
@if ! diff -q openapi.snapshot.json /tmp/openapi.snapshot.regenerated.json >/dev/null; then \
echo ""; \
echo "::error::openapi.snapshot.json is out of date."; \
echo "::error::Run \`make openapi-snapshot\` and commit the updated file."; \
echo ""; \
echo "Drift (committed → regenerated):"; \
diff -u openapi.snapshot.json /tmp/openapi.snapshot.regenerated.json | head -80 || true; \
exit 1; \
fi
@echo "openapi-snapshot-check: snapshot matches handlers.OpenAPISpecProduction()"

# Regen the SQL ConfigMap from the actual migration file (run after schema changes)
k8s-regen-migrations:
kubectl create configmap instant-migrations \
Expand Down
97 changes: 97 additions & 0 deletions cmd/openapi-snapshot/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
// Command openapi-snapshot writes the production-rendered OpenAPI 3.1 spec
// to api/openapi.snapshot.json (or to the path given by -out).
//
// This is the source-of-truth artifact for cross-stack contract testing. The
// dashboard and instanode-web repos consume the committed snapshot to generate
// their typed API clients via openapi-typescript. If the snapshot drifts from
// what handlers.OpenAPISpecProduction returns (i.e. someone edited the spec
// const without regenerating), `make openapi-snapshot-check` fails CI with a
// clear "regenerate the snapshot" message.
//
// The snapshot is canonicalised before writing:
// - parsed as JSON and re-marshalled with sorted map keys and 2-space indent
//
// so that whitespace-only edits to the const (re-flowed strings, added blank
// lines) do not falsely flip the snapshot. The only things that change the
// snapshot are real contract changes: new paths, changed schemas, renamed
// fields, removed responses.
//
// Usage:
//
// go run ./cmd/openapi-snapshot # writes ./openapi.snapshot.json
// go run ./cmd/openapi-snapshot -out /tmp/x # writes to /tmp/x
// go run ./cmd/openapi-snapshot -stdout # prints to stdout (CI diff)
package main

import (
"bytes"
"encoding/json"
"flag"
"fmt"
"io"
"os"

"instant.dev/internal/handlers"
)

const defaultOutPath = "openapi.snapshot.json"

// exitFn is os.Exit at runtime; tests swap it so the main() body becomes
// a measurable statement instead of an irreducible coverage hole.
var exitFn = os.Exit

func main() { exitFn(run(os.Args[1:], os.Stdout, os.Stderr, handlers.OpenAPISpecProduction)) }

// run is the testable body of main. Returns the exit code. specSource is
// injected so the test suite can drive both the happy path (real production
// spec) and the canonicalise-error path (a deliberately-malformed source).
func run(args []string, stdout, stderr io.Writer, specSource func() string) int {
fs := flag.NewFlagSet("openapi-snapshot", flag.ContinueOnError)
fs.SetOutput(stderr)
out := fs.String("out", defaultOutPath, "destination file for the canonical snapshot")
toStdout := fs.Bool("stdout", false, "write to stdout instead of -out (CI diff mode)")
if err := fs.Parse(args); err != nil {
return 2
}

canonical, err := canonicalise(specSource())
if err != nil {
_, _ = fmt.Fprintf(stderr, "openapi-snapshot: built-in spec is not valid JSON: %v\n", err)
return 2
}

if *toStdout {
if _, err := stdout.Write(canonical); err != nil {
_, _ = fmt.Fprintf(stderr, "openapi-snapshot: stdout: %v\n", err)
return 2
}
return 0
}

if err := os.WriteFile(*out, canonical, 0o644); err != nil {
_, _ = fmt.Fprintf(stderr, "openapi-snapshot: write %s: %v\n", *out, err)
return 2
}
_, _ = fmt.Fprintf(stderr, "openapi-snapshot: wrote %d bytes to %s\n", len(canonical), *out)
return 0
}

// canonicalise parses the spec as JSON and re-emits it with sorted keys and
// 2-space indent so that the on-disk snapshot is deterministic and friendly
// to diff. encoding/json sorts map keys alphabetically by default.
//
// Re-encoding a value produced by json.Unmarshal cannot fail (bytes.Buffer
// writes never error, and json.Marshal on interface{} produced from valid
// JSON is always defined), so we only surface the parse error.
func canonicalise(spec string) ([]byte, error) {
var v any
if err := json.Unmarshal([]byte(spec), &v); err != nil {
return nil, fmt.Errorf("parse spec: %w", err)
}
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetIndent("", " ")
enc.SetEscapeHTML(false)
_ = enc.Encode(v) // see godoc: re-encoding a valid-JSON-derived value cannot fail
return buf.Bytes(), nil
}
Loading
Loading