Skip to content

Commit 3ca2ecc

Browse files
paperclip-resolver[bot]Codex
andauthored
ci(openapi): enforce info.version bump on spec changes + version sidecar (#246)
* ci(openapi): enforce info.version bump on spec changes + version sidecar Adds a pull_request CI check that fails when public/openapi.json paths or response schemas change without an info.version bump (and when a bump lacks a CHANGELOG.md entry). Emits public/openapi-version.json as a lightweight pollable consumer signal, documents the SemVer policy in CONTRIBUTING.md, and seeds CHANGELOG.md. Note: .github/workflows/openapi-version-check.yml is not included here because the paperclip-resolver[bot] App installation on the Sharp-API org lacks the workflows permission. The workflow YAML is in the PR description for an operator with workflows-write to commit separately. Fixes #233 * ci(openapi): add version-check workflow --------- Co-authored-by: paperclip-resolver[bot] <285242166+paperclip-resolver[bot]@users.noreply.github.com> Co-authored-by: Codex <codex@sharpapi.local>
1 parent 0c599b8 commit 3ca2ecc

6 files changed

Lines changed: 199 additions & 0 deletions

File tree

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
name: OpenAPI Version Check
2+
3+
on:
4+
pull_request:
5+
paths:
6+
- public/openapi.json
7+
- CHANGELOG.md
8+
- scripts/check-openapi-version.mjs
9+
- .github/workflows/openapi-version-check.yml
10+
11+
permissions:
12+
contents: read
13+
14+
jobs:
15+
openapi-version:
16+
name: OpenAPI version bump
17+
runs-on: ubuntu-latest
18+
timeout-minutes: 5
19+
steps:
20+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
21+
with:
22+
fetch-depth: 0
23+
24+
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
25+
with:
26+
node-version: '22'
27+
28+
- name: Load base OpenAPI spec
29+
run: |
30+
git fetch --no-tags --depth=1 origin "${{ github.base_ref }}"
31+
git show "origin/${{ github.base_ref }}:public/openapi.json" > /tmp/openapi.base.json || echo '{}' > /tmp/openapi.base.json
32+
33+
- name: Check OpenAPI version policy
34+
run: node scripts/check-openapi-version.mjs /tmp/openapi.base.json public/openapi.json

CHANGELOG.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# OpenAPI Spec Changelog
2+
3+
History of `public/openapi.json` `info.version` bumps. See the
4+
[spec-versioning policy](CONTRIBUTING.md#openapi-spec-versioning) for when and how
5+
to bump. Every change to API paths or response schemas gets a one-line entry here;
6+
the [OpenAPI Version Check](.github/workflows/openapi-version-check.yml) CI job
7+
enforces that a bump has a matching entry.
8+
9+
## 2.1.0 — 2026-05-21
10+
11+
- Align `/account` response schema with the live flat shape (#230).
12+
13+
## 2.0.0
14+
15+
- Baseline versioned spec. (Note: `/sports/{sportId}` and `/sportsbooks/{bookId}`
16+
were removed under this version in #218 without a bump — the gap that motivated
17+
the versioning policy in #233.)

CONTRIBUTING.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,39 @@ Before opening a PR:
2727

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

30+
## OpenAPI spec versioning
31+
32+
`public/openapi.json` is the published API contract. Some consumers pin against it
33+
and run CI-graded contract testing, so spec changes must be programmatically
34+
detectable.
35+
36+
**Bump `info.version` (SemVer) whenever you change `paths` or response schemas:**
37+
38+
| Bump | When |
39+
|------|------|
40+
| MAJOR (`x.0.0`) | Backward-incompatible redesign; removed or renamed response field; breaking schema change |
41+
| 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.** |
42+
| PATCH (`2.1.x`) | Description-only edits, examples, doc clarifications |
43+
44+
**Enforcement.** [`.github/workflows/openapi-version-check.yml`](.github/workflows/openapi-version-check.yml)
45+
runs on every PR that touches `public/openapi.json`. It fails if paths/schemas
46+
changed without an `info.version` bump, and if a bump has no matching
47+
[`CHANGELOG.md`](CHANGELOG.md) entry. Run it locally before pushing:
48+
49+
```bash
50+
git show "origin/main:public/openapi.json" > /tmp/openapi.base.json
51+
node scripts/check-openapi-version.mjs /tmp/openapi.base.json public/openapi.json
52+
```
53+
54+
**Consumer signal.** Clients can poll the lightweight sidecar at
55+
[`https://docs.sharpapi.io/openapi-version.json`](https://docs.sharpapi.io/openapi-version.json)
56+
(`{ "version", "x-generated-at", "x-commit-sha" }`) to detect changes without
57+
downloading the full spec. The same provenance is also stamped into
58+
`info["x-generated-at"]` / `info["x-commit-sha"]` inside `openapi.json` at build time.
59+
60+
**History.** Each bump gets a one-line entry in [`CHANGELOG.md`](CHANGELOG.md);
61+
deploys additionally cut a dated GitHub Release.
62+
3063
## Style
3164

3265
- **Tone**: terse and concrete. Show the request, the response, and the interesting details. Skip filler.

public/openapi-version.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"version": "2.1.0",
3+
"x-generated-at": "2026-05-20T23:30:42-04:00",
4+
"x-commit-sha": "13f97e3"
5+
}

scripts/check-openapi-version.mjs

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
#!/usr/bin/env node
2+
// Enforces the OpenAPI spec-versioning policy (CONTRIBUTING.md, issue #233):
3+
// fails CI when public/openapi.json's paths or response schemas change without
4+
// an info.version bump, and when a version bump lacks a CHANGELOG.md entry.
5+
// Compares info only by version — the build stamps info["x-generated-at"] /
6+
// info["x-commit-sha"], which must not count as a semantic change.
7+
//
8+
// Usage: node scripts/check-openapi-version.mjs <base-spec.json> <head-spec.json>
9+
10+
import { readFileSync, existsSync } from 'node:fs'
11+
12+
const [, , basePath, headPath] = process.argv
13+
if (!basePath || !headPath) {
14+
console.error('usage: check-openapi-version.mjs <base-spec> <head-spec>')
15+
process.exit(2)
16+
}
17+
18+
function load (p) {
19+
try {
20+
const raw = readFileSync(p, 'utf8').trim()
21+
return raw ? JSON.parse(raw) : null
22+
} catch (err) {
23+
console.error(`[openapi-version] cannot parse ${p}: ${err.message}`)
24+
process.exit(2)
25+
}
26+
}
27+
28+
// Stable stringify: recursively sort object keys so key-order churn isn't
29+
// mistaken for a semantic change.
30+
function canonical (value) {
31+
if (Array.isArray(value)) return value.map(canonical)
32+
if (value && typeof value === 'object') {
33+
return Object.keys(value).sort().reduce((acc, k) => {
34+
acc[k] = canonical(value[k])
35+
return acc
36+
}, {})
37+
}
38+
return value
39+
}
40+
41+
function semantic (spec) {
42+
return JSON.stringify({
43+
paths: canonical(spec?.paths ?? {}),
44+
schemas: canonical(spec?.components?.schemas ?? {})
45+
})
46+
}
47+
48+
const base = load(basePath)
49+
const head = load(headPath)
50+
51+
if (!head) {
52+
console.error('[openapi-version] head spec missing or empty')
53+
process.exit(2)
54+
}
55+
56+
// New spec (no base on the target branch) — nothing to compare against.
57+
if (!base || Object.keys(base).length === 0) {
58+
console.log('[openapi-version] no base spec to compare; skipping.')
59+
process.exit(0)
60+
}
61+
62+
const baseVersion = base?.info?.version
63+
const headVersion = head?.info?.version
64+
65+
if (semantic(base) === semantic(head)) {
66+
console.log('[openapi-version] no path/schema changes detected; OK.')
67+
process.exit(0)
68+
}
69+
70+
if (baseVersion === headVersion) {
71+
console.error(
72+
`[openapi-version] paths or schemas changed but info.version is still ${headVersion}.\n` +
73+
' Bump info.version per the SemVer policy in CONTRIBUTING.md:\n' +
74+
' MAJOR (x.0.0) backward-incompatible redesign / removed-renamed field\n' +
75+
' MINOR (2.x.0) new path or field, or a shape fix aligning the spec to the live response\n' +
76+
' PATCH (2.1.x) description-only edits\n' +
77+
' Removed paths and renamed fields bump the MINOR at minimum.'
78+
)
79+
process.exit(1)
80+
}
81+
82+
// Version bumped — require a CHANGELOG.md entry for the new version.
83+
const CHANGELOG = 'CHANGELOG.md'
84+
if (existsSync(CHANGELOG)) {
85+
const log = readFileSync(CHANGELOG, 'utf8')
86+
const escaped = headVersion.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
87+
const hasEntry = new RegExp(`^##\\s.*${escaped}(\\s|$)`, 'm').test(log)
88+
if (!hasEntry) {
89+
console.error(
90+
`[openapi-version] info.version bumped ${baseVersion} -> ${headVersion} but ` +
91+
`CHANGELOG.md has no "## ... ${headVersion}" entry. Add one describing the change.`
92+
)
93+
process.exit(1)
94+
}
95+
}
96+
97+
console.log(`[openapi-version] info.version ${baseVersion} -> ${headVersion}; OK.`)
98+
process.exit(0)

scripts/stamp-openapi.mjs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,15 @@
1010
//
1111
// Both fields are advisory — they don't affect the OpenAPI semantics. Tools
1212
// that don't recognise the `x-` extensions ignore them.
13+
//
14+
// It also emits public/openapi-version.json — a tiny sidecar consumers can poll
15+
// to detect spec changes without downloading the full spec (issue #233).
1316

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

1720
const SPEC_PATH = 'public/openapi.json'
21+
const VERSION_SIDECAR_PATH = 'public/openapi-version.json'
1822

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

4044
writeFileSync(SPEC_PATH, JSON.stringify(spec, null, 2) + '\n')
4145
console.log(`[stamp-openapi] ${SPEC_PATH} → x-generated-at=${commitISO} x-commit-sha=${commitSHA}`)
46+
47+
const versionSidecar = {
48+
version: spec.info.version,
49+
'x-generated-at': commitISO,
50+
'x-commit-sha': commitSHA
51+
}
52+
writeFileSync(VERSION_SIDECAR_PATH, JSON.stringify(versionSidecar, null, 2) + '\n')
53+
console.log(`[stamp-openapi] ${VERSION_SIDECAR_PATH} → version=${spec.info.version}`)

0 commit comments

Comments
 (0)