Skip to content

Commit b029767

Browse files
committed
feat: initial contract — JSON schemas, fixtures, golden path test
Defines the shared API contracts between openboot CLI and openboot.dev: - remote-config.json: GET /:user/:slug/config response shape - snapshot.json: POST /api/configs/from-snapshot body shape - packages.json: GET /api/packages response shape - auth.json: CLI device auth flow request/response - Fixtures with example payloads - Golden path test script for round-trip integrity validation - CI workflow to validate schemas and notify consumer repos
0 parents  commit b029767

File tree

9 files changed

+620
-0
lines changed

9 files changed

+620
-0
lines changed

.github/workflows/validate.yml

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
name: Validate Contract
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
9+
jobs:
10+
validate-schemas:
11+
runs-on: ubuntu-latest
12+
steps:
13+
- uses: actions/checkout@v4
14+
15+
- uses: actions/setup-python@v5
16+
with:
17+
python-version: '3.12'
18+
19+
- name: Install jsonschema
20+
run: pip install jsonschema
21+
22+
- name: Validate fixtures against schemas
23+
run: |
24+
python3 -c "
25+
import json, jsonschema, sys
26+
27+
checks = [
28+
('schemas/remote-config.json', 'fixtures/config-v1.json'),
29+
('schemas/snapshot.json', 'fixtures/snapshot-v1.json'),
30+
]
31+
32+
failed = 0
33+
for schema_path, fixture_path in checks:
34+
schema = json.load(open(schema_path))
35+
data = json.load(open(fixture_path))
36+
try:
37+
jsonschema.validate(data, schema)
38+
print(f' ✓ {fixture_path} matches {schema_path}')
39+
except jsonschema.ValidationError as e:
40+
print(f' ✗ {fixture_path}: {e.message}')
41+
failed += 1
42+
43+
sys.exit(1 if failed else 0)
44+
"
45+
46+
- name: Validate schema files are valid JSON Schema
47+
run: |
48+
python3 -c "
49+
import json, glob, sys
50+
failed = 0
51+
for f in glob.glob('schemas/*.json'):
52+
try:
53+
schema = json.load(open(f))
54+
assert '\$schema' in schema, 'missing \$schema'
55+
print(f' ✓ {f} is valid')
56+
except Exception as e:
57+
print(f' ✗ {f}: {e}')
58+
failed += 1
59+
sys.exit(1 if failed else 0)
60+
"
61+
62+
notify-consumers:
63+
needs: validate-schemas
64+
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
65+
runs-on: ubuntu-latest
66+
strategy:
67+
matrix:
68+
repo: [openboot, openboot.dev]
69+
steps:
70+
- name: Trigger consumer CI
71+
uses: peter-evans/repository-dispatch@v3
72+
with:
73+
token: ${{ secrets.CONTRACT_DISPATCH_TOKEN }}
74+
repository: openbootdotdev/${{ matrix.repo }}
75+
event-type: contract-updated
76+
client-payload: '{"ref": "${{ github.sha }}"}'

README.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# openboot-contract
2+
3+
Shared API contracts between [openboot](https://github.com/openbootdotdev/openboot) (Go CLI) and [openboot.dev](https://github.com/openbootdotdev/openboot.dev) (SvelteKit server).
4+
5+
## Why
6+
7+
The CLI is a binary on users' machines — it can't be updated instantly when the server changes. This repo defines the data shapes both sides agree on, and CI enforces they stay in sync.
8+
9+
## Structure
10+
11+
```
12+
schemas/ JSON Schema definitions (source of truth)
13+
remote-config.json GET /:user/:slug/config response
14+
snapshot.json POST /api/configs/from-snapshot body
15+
packages.json GET /api/packages response
16+
auth.json CLI device auth flow
17+
18+
fixtures/ Example payloads that match the schemas
19+
config-v1.json
20+
snapshot-v1.json
21+
22+
golden-path/ End-to-end validation scripts
23+
test.sh Round-trip integrity + live API checks
24+
```
25+
26+
## How it works
27+
28+
1. **Contract changes** → PR to this repo → CI validates schemas + fixtures
29+
2. **On merge** → CI triggers both consumer repos via `repository_dispatch`
30+
3. **Consumer CI** → clones this repo, validates their responses against schemas
31+
4. **Any mismatch** → CI fails → change must be coordinated across repos
32+
33+
## Local usage
34+
35+
```bash
36+
# Validate fixtures against schemas (needs python3 + jsonschema)
37+
pip install jsonschema
38+
./golden-path/test.sh
39+
40+
# With a running server
41+
SERVER_URL=http://localhost:5173 ./golden-path/test.sh
42+
```
43+
44+
## Rules
45+
46+
- **Only add fields, never remove** — CLI binaries in the wild depend on existing fields
47+
- **Schema changes require a PR** — no direct pushes to main
48+
- **Fixtures must always pass** — if you change a schema, update fixtures to match

fixtures/config-v1.json

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
{
2+
"username": "testuser",
3+
"slug": "dev-setup",
4+
"name": "Developer Setup",
5+
"preset": "developer",
6+
"packages": [
7+
{ "name": "git", "desc": "Distributed version control system" },
8+
{ "name": "curl", "desc": "Transfer data with URLs" },
9+
{ "name": "jq", "desc": "JSON processor for command line" },
10+
{ "name": "homebrew/cask-fonts/font-fira-code", "desc": "Fira Code font" }
11+
],
12+
"casks": [
13+
{ "name": "visual-studio-code", "desc": "Code editor with extensions and debugging" },
14+
{ "name": "docker", "desc": "Platform for building and running containers" }
15+
],
16+
"taps": [
17+
"homebrew/cask-fonts"
18+
],
19+
"npm": [
20+
{ "name": "typescript", "desc": "Typed superset of JavaScript" },
21+
{ "name": "eslint", "desc": "Linter for JavaScript and TypeScript" }
22+
],
23+
"dotfiles_repo": "https://github.com/testuser/dotfiles",
24+
"post_install": [
25+
"echo 'Setup complete!'",
26+
"mkdir -p ~/projects"
27+
],
28+
"shell": {
29+
"oh_my_zsh": true,
30+
"theme": "robbyrussell",
31+
"plugins": ["git", "zsh-autosuggestions"]
32+
},
33+
"macos_prefs": [
34+
{
35+
"domain": "com.apple.dock",
36+
"key": "autohide",
37+
"type": "bool",
38+
"value": "true",
39+
"desc": "Auto-hide the Dock"
40+
}
41+
]
42+
}

fixtures/snapshot-v1.json

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
{
2+
"version": 1,
3+
"captured_at": "2026-03-25T10:00:00Z",
4+
"hostname": "test-mac",
5+
"packages": {
6+
"formulae": ["git", "curl", "jq", "node", "go"],
7+
"casks": ["visual-studio-code", "docker", "warp"],
8+
"taps": ["homebrew/cask-fonts", "openbootdotdev/tap"],
9+
"npm": ["typescript", "eslint", "prettier"]
10+
},
11+
"macos_prefs": [
12+
{
13+
"domain": "com.apple.dock",
14+
"key": "autohide",
15+
"type": "bool",
16+
"value": "true",
17+
"desc": "Auto-hide the Dock"
18+
},
19+
{
20+
"domain": "NSGlobalDomain",
21+
"key": "AppleShowScrollBars",
22+
"type": "string",
23+
"value": "Always",
24+
"desc": ""
25+
}
26+
],
27+
"git": {
28+
"user_name": "Test User",
29+
"user_email": "test@example.com"
30+
},
31+
"dotfiles": {
32+
"repo_url": "https://github.com/testuser/dotfiles"
33+
},
34+
"dev_tools": [
35+
{ "name": "go", "version": "1.24.0" },
36+
{ "name": "node", "version": "22.0.0" }
37+
],
38+
"matched_preset": "developer",
39+
"catalog_match": {
40+
"matched": ["git", "curl", "jq"],
41+
"unmatched": ["custom-tool"],
42+
"match_rate": 0.75
43+
},
44+
"health": {
45+
"failed_steps": [],
46+
"partial": false
47+
}
48+
}

golden-path/test.sh

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
#!/bin/bash
2+
# Golden path end-to-end test.
3+
# Validates that data survives the full CLI → Server → CLI round-trip.
4+
#
5+
# Prerequisites:
6+
# - Server running at $SERVER_URL (default: http://localhost:5173)
7+
# - CLI binary built at $CLI_BIN (default: openboot in PATH)
8+
# - jq installed
9+
#
10+
# Usage:
11+
# ./golden-path/test.sh
12+
# SERVER_URL=https://openboot.dev ./golden-path/test.sh
13+
#
14+
set -euo pipefail
15+
16+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
17+
CONTRACT_DIR="$(dirname "$SCRIPT_DIR")"
18+
SERVER_URL="${SERVER_URL:-http://localhost:5173}"
19+
PASS=0
20+
FAIL=0
21+
22+
pass() { PASS=$((PASS + 1)); echo "$1"; }
23+
fail() { FAIL=$((FAIL + 1)); echo "$1: $2"; }
24+
25+
echo "Golden Path Test"
26+
echo " Server: $SERVER_URL"
27+
echo " Schemas: $CONTRACT_DIR/schemas/"
28+
echo ""
29+
30+
# --- 1. Validate fixtures against schemas ---
31+
echo "=== Schema Validation ==="
32+
33+
validate_schema() {
34+
local schema="$1" fixture="$2" label="$3"
35+
# Use ajv-cli if available, otherwise python jsonschema
36+
if command -v ajv &>/dev/null; then
37+
if ajv validate -s "$schema" -d "$fixture" --spec=draft2020 2>/dev/null; then
38+
pass "$label"
39+
else
40+
fail "$label" "schema validation failed"
41+
fi
42+
elif python3 -c "import jsonschema" 2>/dev/null; then
43+
if python3 -c "
44+
import json, jsonschema
45+
schema = json.load(open('$schema'))
46+
data = json.load(open('$fixture'))
47+
jsonschema.validate(data, schema)
48+
" 2>/dev/null; then
49+
pass "$label"
50+
else
51+
fail "$label" "schema validation failed"
52+
fi
53+
else
54+
echo " - skipped $label (install ajv-cli or python3 jsonschema)"
55+
fi
56+
}
57+
58+
validate_schema \
59+
"$CONTRACT_DIR/schemas/remote-config.json" \
60+
"$CONTRACT_DIR/fixtures/config-v1.json" \
61+
"config fixture matches schema"
62+
63+
validate_schema \
64+
"$CONTRACT_DIR/schemas/snapshot.json" \
65+
"$CONTRACT_DIR/fixtures/snapshot-v1.json" \
66+
"snapshot fixture matches schema"
67+
68+
# --- 2. Live API checks (if server is reachable) ---
69+
echo ""
70+
echo "=== Live API Checks ==="
71+
72+
if ! curl -sf "$SERVER_URL/api/health" >/dev/null 2>&1; then
73+
echo " - Server not reachable at $SERVER_URL, skipping live checks"
74+
else
75+
# /api/packages schema check
76+
PKGS_RESPONSE=$(curl -sf "$SERVER_URL/api/packages")
77+
PKGS_TMP=$(mktemp)
78+
echo "$PKGS_RESPONSE" > "$PKGS_TMP"
79+
validate_schema \
80+
"$CONTRACT_DIR/schemas/packages.json" \
81+
"$PKGS_TMP" \
82+
"/api/packages matches schema"
83+
rm -f "$PKGS_TMP"
84+
85+
# /api/packages field spot-checks
86+
if echo "$PKGS_RESPONSE" | python3 -c "
87+
import sys, json
88+
d = json.load(sys.stdin)
89+
p = d['packages']
90+
assert len(p) > 50, f'only {len(p)} packages'
91+
installers = set(x['installer'] for x in p)
92+
assert installers == {'formula','cask','npm'}, f'missing installer types: {installers}'
93+
" 2>/dev/null; then
94+
pass "/api/packages has 50+ packages with all installer types"
95+
else
96+
fail "/api/packages" "insufficient packages or missing types"
97+
fi
98+
99+
# Config endpoint check (try known public config)
100+
CONFIG_RESPONSE=$(curl -sf "$SERVER_URL/openboot/developer/config" 2>/dev/null || echo '')
101+
if [ -n "$CONFIG_RESPONSE" ] && [ "$CONFIG_RESPONSE" != "{}" ]; then
102+
CONFIG_TMP=$(mktemp)
103+
echo "$CONFIG_RESPONSE" > "$CONFIG_TMP"
104+
validate_schema \
105+
"$CONTRACT_DIR/schemas/remote-config.json" \
106+
"$CONFIG_TMP" \
107+
"live config response matches schema"
108+
rm -f "$CONFIG_TMP"
109+
else
110+
echo " - skipped config check (no public config at /openboot/developer)"
111+
fi
112+
fi
113+
114+
# --- 3. Data round-trip integrity (snapshot → config) ---
115+
echo ""
116+
echo "=== Data Round-Trip Integrity ==="
117+
118+
# The critical invariant: fields present in snapshot must survive through
119+
# server storage and config retrieval. Test with fixture data.
120+
if python3 -c "
121+
import json
122+
123+
snapshot = json.load(open('$CONTRACT_DIR/fixtures/snapshot-v1.json'))
124+
config = json.load(open('$CONTRACT_DIR/fixtures/config-v1.json'))
125+
126+
# Every formulae in snapshot should have a corresponding entry in config.packages
127+
snap_formulae = set(snapshot['packages']['formulae'])
128+
config_pkg_names = set(p['name'] for p in config['packages'])
129+
130+
# Every cask in snapshot should appear in config.casks
131+
snap_casks = set(snapshot['packages']['casks'])
132+
config_cask_names = set(p['name'] for p in config['casks'])
133+
134+
# Every npm in snapshot should appear in config.npm
135+
snap_npm = set(snapshot['packages']['npm'])
136+
config_npm_names = set(p['name'] for p in config['npm'])
137+
138+
# Config entries must have desc field (not empty for known packages)
139+
for entry in config['packages'] + config['casks'] + config['npm']:
140+
assert 'name' in entry, f'missing name in {entry}'
141+
assert 'desc' in entry, f'missing desc in {entry}'
142+
143+
# macos_prefs must survive
144+
assert len(config.get('macos_prefs', [])) > 0, 'macos_prefs lost'
145+
assert config['macos_prefs'][0]['domain'] == snapshot['macos_prefs'][0]['domain'], 'macos_prefs domain mismatch'
146+
147+
print('All round-trip invariants hold')
148+
" 2>/dev/null; then
149+
pass "fixture data round-trip invariants"
150+
else
151+
fail "round-trip" "data integrity check failed"
152+
fi
153+
154+
# --- Summary ---
155+
echo ""
156+
TOTAL=$((PASS + FAIL))
157+
echo "Results: $PASS/$TOTAL passed"
158+
if [ "$FAIL" -gt 0 ]; then
159+
echo "FAILED"
160+
exit 1
161+
else
162+
echo "ALL PASSED"
163+
fi

0 commit comments

Comments
 (0)