Skip to content

ci(cli): enforce canonical openapi.json in a hook and in CI - #573

Merged
dviejokfs merged 1 commit into
mainfrom
chore/openapi-canonical-guard
Aug 7, 2026
Merged

ci(cli): enforce canonical openapi.json in a hook and in CI#573
dviejokfs merged 1 commit into
mainfrom
chore/openapi-canonical-guard

Conversation

@dviejokfs

Copy link
Copy Markdown
Contributor

Problem

apps/temps-cli/src/api/ is generated from a committed copy of the spec at apps/temps-cli/openapi.json. The server serves that same document minified on one line; the committed file is ~92,000 lines of formatted JSON.

So curl .../openapi.json > openapi.json turns 92,000 lines into 1, and the pull request reports ~92,000 deletions. That happened on #564. Pretty-printing alone does not fix it either: key order comes from serde and is not stable between builds, so an unsorted dump reorders large unrelated blocks.

#564 established the canonical shape (keys sorted recursively, two-space indent, trailing newline) and documented the rule. This PR makes it enforced, because a documented rule does not help when the failure is silent and only visible as a diff nobody can read.

What this adds

  • scripts/openapi-canonical.ts — one definition of "canonical", shared by the writer and the checker so they cannot drift.
  • scripts/check-openapi.ts (bun run spec:check) — verifies the committed file. Reads only what is on disk: no server, no network, no bun install. --fix reformats in place; it deliberately never fetches, so it cannot mask an out-of-date spec.
  • Pre-commit hook openapi-canonical, scoped to ^apps/temps-cli/openapi\.json$.
  • CI job OpenAPI Spec Format in rust-tests.yml — runs in seconds on every PR.
  • update-openapi.ts rewired onto the shared module.
  • CLAUDE.md / AGENTS.md updated to point at the check and at --fix vs spec:update.

The messages name the failure rather than reporting a mismatch:

apps/temps-cli/openapi.json is not in canonical shape.

It is minified: 1 line(s) where canonical is 92714. This is what a raw server
response written straight to the file looks like, and it reports ~92714
deletions, burying the real change.

Fix it with either:
  cd apps/temps-cli && bun run spec:check --fix   # reformat what is committed
  cd apps/temps-cli && bun run spec:update        # refetch from a running server
then: bun run generate:api

Verification

Each case run against the real spec:

Case Result
Committed file as-is passes, 673 paths
Minified (the original bug) fails, names it as minified
Pretty-printed but reordered fails, explains canonical form
paths: {} fails — would otherwise delete the whole client
Invalid JSON fails with the parse error
--fix after minifying restores the file byte-for-byte (empty git diff)

Hook wiring confirmed with prek run openapi-canonical: fires on openapi.json, skipped for unrelated files, and blocks the commit when the spec is minified.

bun run typecheck in apps/temps-cli reports the same 4 pre-existing errors as main — none from these files.

Note

This does not check that src/api/ is regenerated from the spec — a different drift, not the one that costs 92,000 lines.

Documenting the rule was not enough — the failure is silent, and by the
time anyone notices, the pull request already reports ~92,000 deletions
and the real change is unreviewable.

spec:check verifies the committed spec against the same canonicalizer
spec:update writes with, so the writer and the gate cannot drift. It reads
only the file on disk — no server, no network, no bun install — so it runs
as a pre-commit hook on openapi.json and as a seconds-long CI job on every
pull request.

It names what went wrong rather than reporting a mismatch: minified,
reordered, unparseable or empty each get their own message and the command
that fixes it. --fix reformats what is committed; it deliberately does not
fetch, so it can never mask an out-of-date spec.
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown

📓 Changelog preview

This is what your commits will add to the generated CHANGELOG.md at release time (via git-cliff). Do not edit CHANGELOG.md by hand — it is generated from your Conventional Commit messages.

## [Unreleased]

### CI

- **cli:** Enforce canonical openapi.json in a hook and in CI

@dviejokfs

Copy link
Copy Markdown
Contributor Author

Evidence — runtime proof

1. The refactored spec:update still reproduces the file byte-for-byte

Served the spec back the way the real server does (JSON.stringify, minified, one line) and refetched it:

$ bun run spec:update --url http://localhost:8791/openapi.json
Wrote /…/apps/temps-cli/openapi.json (673 paths)
Now run: bun run generate:api

$ git diff --numstat -- apps/temps-cli/openapi.json
(no output)

Empty numstat: the minified input produced the committed file exactly. The writer and the gate agree.

2. The gate catches the failure it exists for

$ python3 -c "import json;json.dump(json.load(open('/tmp/good-spec.json')),open('apps/temps-cli/openapi.json','w'),separators=(',',':'))"
$ bun apps/temps-cli/scripts/check-openapi.ts
apps/temps-cli/openapi.json is not in canonical shape.

It is minified: 1 line(s) where canonical is 92714. This is what a raw server
response written straight to the file looks like, and it reports ~92714
deletions, burying the real change.
…
exit=1

Other cases: pretty-but-reordered → exit 1; paths: {} → exit 1; invalid JSON → exit 1; clean file → exit 0 (673 paths).

3. --fix restores it exactly

$ bun run spec:check --fix
Reformatted openapi.json (673 paths)
$ git diff --numstat -- apps/temps-cli/openapi.json
(no output)

4. Hook wiring

$ prek run openapi-canonical --files apps/temps-cli/openapi.json
openapi.json is canonical................................................Passed

$ prek run openapi-canonical --files CLAUDE.md
openapi.json is canonical............................(no files to check)Skipped

# with the file minified:
openapi.json is canonical................................................Failed
- hook id: openapi-canonical
- exit code: 1

prek stashes unstaged changes before running hooks, so the file the hook reads is the staged content.

5. Typecheck

bun run typecheck in apps/temps-cli reports the same 4 pre-existing errors as main (openapi-ts.config.ts, notifications, providers ×2) — none from these files.

6. CI

OpenAPI Spec Format passed on this PR.

@dviejokfs
dviejokfs merged commit 4d0a6d9 into main Aug 7, 2026
23 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant