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
34 changes: 34 additions & 0 deletions .github/workflows/openapi-version-check.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
name: OpenAPI Version Check

on:
pull_request:
paths:
- public/openapi.json
- CHANGELOG.md
- scripts/check-openapi-version.mjs
- .github/workflows/openapi-version-check.yml

permissions:
contents: read

jobs:
openapi-version:
name: OpenAPI version bump
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0

- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '22'

- name: Load base OpenAPI spec
run: |
git fetch --no-tags --depth=1 origin "${{ github.base_ref }}"
git show "origin/${{ github.base_ref }}:public/openapi.json" > /tmp/openapi.base.json || echo '{}' > /tmp/openapi.base.json

- name: Check OpenAPI version policy
run: node scripts/check-openapi-version.mjs /tmp/openapi.base.json public/openapi.json
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# OpenAPI Spec Changelog

History of `public/openapi.json` `info.version` bumps. See the
[spec-versioning policy](CONTRIBUTING.md#openapi-spec-versioning) for when and how
to bump. Every change to API paths or response schemas gets a one-line entry here;
the [OpenAPI Version Check](.github/workflows/openapi-version-check.yml) CI job
enforces that a bump has a matching entry.

## 2.1.0 — 2026-05-21

- Align `/account` response schema with the live flat shape (#230).

## 2.0.0

- Baseline versioned spec. (Note: `/sports/{sportId}` and `/sportsbooks/{bookId}`
were removed under this version in #218 without a bump — the gap that motivated
the versioning policy in #233.)
33 changes: 33 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,39 @@ Before opening a PR:

PRs that fix a single issue are easier to review than batched ones.

## OpenAPI spec versioning

`public/openapi.json` is the published API contract. Some consumers pin against it
and run CI-graded contract testing, so spec changes must be programmatically
detectable.

**Bump `info.version` (SemVer) whenever you change `paths` or response schemas:**

| Bump | When |
|------|------|
| MAJOR (`x.0.0`) | Backward-incompatible redesign; removed or renamed response field; breaking schema change |
| MINOR (`2.x.0`) | New path or field; a shape fix that aligns the spec to the live response. **Removed paths bump the MINOR at minimum.** |
| PATCH (`2.1.x`) | Description-only edits, examples, doc clarifications |

**Enforcement.** [`.github/workflows/openapi-version-check.yml`](.github/workflows/openapi-version-check.yml)
runs on every PR that touches `public/openapi.json`. It fails if paths/schemas
changed without an `info.version` bump, and if a bump has no matching
[`CHANGELOG.md`](CHANGELOG.md) entry. Run it locally before pushing:

```bash
git show "origin/main:public/openapi.json" > /tmp/openapi.base.json
node scripts/check-openapi-version.mjs /tmp/openapi.base.json public/openapi.json
```

**Consumer signal.** Clients can poll the lightweight sidecar at
[`https://docs.sharpapi.io/openapi-version.json`](https://docs.sharpapi.io/openapi-version.json)
(`{ "version", "x-generated-at", "x-commit-sha" }`) to detect changes without
downloading the full spec. The same provenance is also stamped into
`info["x-generated-at"]` / `info["x-commit-sha"]` inside `openapi.json` at build time.

**History.** Each bump gets a one-line entry in [`CHANGELOG.md`](CHANGELOG.md);
deploys additionally cut a dated GitHub Release.

## Style

- **Tone**: terse and concrete. Show the request, the response, and the interesting details. Skip filler.
Expand Down
5 changes: 5 additions & 0 deletions public/openapi-version.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"version": "2.1.0",
"x-generated-at": "2026-05-20T23:30:42-04:00",
"x-commit-sha": "13f97e3"
}
98 changes: 98 additions & 0 deletions scripts/check-openapi-version.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
#!/usr/bin/env node
// Enforces the OpenAPI spec-versioning policy (CONTRIBUTING.md, issue #233):
// fails CI when public/openapi.json's paths or response schemas change without
// an info.version bump, and when a version bump lacks a CHANGELOG.md entry.
// Compares info only by version — the build stamps info["x-generated-at"] /
// info["x-commit-sha"], which must not count as a semantic change.
//
// Usage: node scripts/check-openapi-version.mjs <base-spec.json> <head-spec.json>

import { readFileSync, existsSync } from 'node:fs'

const [, , basePath, headPath] = process.argv
if (!basePath || !headPath) {
console.error('usage: check-openapi-version.mjs <base-spec> <head-spec>')
process.exit(2)
}

function load (p) {
try {
const raw = readFileSync(p, 'utf8').trim()
return raw ? JSON.parse(raw) : null
} catch (err) {
console.error(`[openapi-version] cannot parse ${p}: ${err.message}`)
process.exit(2)
}
}

// Stable stringify: recursively sort object keys so key-order churn isn't
// mistaken for a semantic change.
function canonical (value) {
if (Array.isArray(value)) return value.map(canonical)
if (value && typeof value === 'object') {
return Object.keys(value).sort().reduce((acc, k) => {
acc[k] = canonical(value[k])
return acc
}, {})
}
return value
}

function semantic (spec) {
return JSON.stringify({
paths: canonical(spec?.paths ?? {}),
schemas: canonical(spec?.components?.schemas ?? {})
})
}

const base = load(basePath)
const head = load(headPath)

if (!head) {
console.error('[openapi-version] head spec missing or empty')
process.exit(2)
}

// New spec (no base on the target branch) — nothing to compare against.
if (!base || Object.keys(base).length === 0) {
console.log('[openapi-version] no base spec to compare; skipping.')
process.exit(0)
}

const baseVersion = base?.info?.version
const headVersion = head?.info?.version

if (semantic(base) === semantic(head)) {
console.log('[openapi-version] no path/schema changes detected; OK.')
process.exit(0)
}

if (baseVersion === headVersion) {
console.error(
`[openapi-version] paths or schemas changed but info.version is still ${headVersion}.\n` +
' Bump info.version per the SemVer policy in CONTRIBUTING.md:\n' +
' MAJOR (x.0.0) backward-incompatible redesign / removed-renamed field\n' +
' MINOR (2.x.0) new path or field, or a shape fix aligning the spec to the live response\n' +
' PATCH (2.1.x) description-only edits\n' +
' Removed paths and renamed fields bump the MINOR at minimum.'
)
process.exit(1)
}

// Version bumped — require a CHANGELOG.md entry for the new version.
const CHANGELOG = 'CHANGELOG.md'
if (existsSync(CHANGELOG)) {
const log = readFileSync(CHANGELOG, 'utf8')
const escaped = headVersion.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const hasEntry = new RegExp(`^##\\s.*${escaped}(\\s|$)`, 'm').test(log)
if (!hasEntry) {
console.error(
`[openapi-version] info.version bumped ${baseVersion} -> ${headVersion} but ` +
`CHANGELOG.md has no "## ... ${headVersion}" entry. Add one describing the change.`
)
process.exit(1)
}
}

console.log(`[openapi-version] info.version ${baseVersion} -> ${headVersion}; OK.`)
process.exit(0)
12 changes: 12 additions & 0 deletions scripts/stamp-openapi.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,15 @@
//
// Both fields are advisory — they don't affect the OpenAPI semantics. Tools
// that don't recognise the `x-` extensions ignore them.
//
// It also emits public/openapi-version.json — a tiny sidecar consumers can poll
// to detect spec changes without downloading the full spec (issue #233).

import { execSync } from 'node:child_process'
import { readFileSync, writeFileSync } from 'node:fs'

const SPEC_PATH = 'public/openapi.json'
const VERSION_SIDECAR_PATH = 'public/openapi-version.json'

function git (args) {
try {
Expand All @@ -39,3 +43,11 @@ spec.info['x-commit-sha'] = commitSHA

writeFileSync(SPEC_PATH, JSON.stringify(spec, null, 2) + '\n')
console.log(`[stamp-openapi] ${SPEC_PATH} → x-generated-at=${commitISO} x-commit-sha=${commitSHA}`)

const versionSidecar = {
version: spec.info.version,
'x-generated-at': commitISO,
'x-commit-sha': commitSHA
}
writeFileSync(VERSION_SIDECAR_PATH, JSON.stringify(versionSidecar, null, 2) + '\n')
console.log(`[stamp-openapi] ${VERSION_SIDECAR_PATH} → version=${spec.info.version}`)