diff --git a/.github/workflows/openapi-version-check.yml b/.github/workflows/openapi-version-check.yml new file mode 100644 index 0000000..592ffe3 --- /dev/null +++ b/.github/workflows/openapi-version-check.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..e32cde0 --- /dev/null +++ b/CHANGELOG.md @@ -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.) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b9640cb..a640813 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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. diff --git a/public/openapi-version.json b/public/openapi-version.json new file mode 100644 index 0000000..c11f84f --- /dev/null +++ b/public/openapi-version.json @@ -0,0 +1,5 @@ +{ + "version": "2.1.0", + "x-generated-at": "2026-05-20T23:30:42-04:00", + "x-commit-sha": "13f97e3" +} diff --git a/scripts/check-openapi-version.mjs b/scripts/check-openapi-version.mjs new file mode 100644 index 0000000..0d50896 --- /dev/null +++ b/scripts/check-openapi-version.mjs @@ -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 + +import { readFileSync, existsSync } from 'node:fs' + +const [, , basePath, headPath] = process.argv +if (!basePath || !headPath) { + console.error('usage: check-openapi-version.mjs ') + 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) diff --git a/scripts/stamp-openapi.mjs b/scripts/stamp-openapi.mjs index b8bf8f1..83578a2 100644 --- a/scripts/stamp-openapi.mjs +++ b/scripts/stamp-openapi.mjs @@ -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 { @@ -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}`)