Skip to content

feat: add OpenAPI contract drift check to CI - #192

Merged
preshotta merged 11 commits into
TRELLIS-STELLAR:mainfrom
Prinzolumide:cloud-fixer/148-openapi-drift
Oct 1, 2026
Merged

preshotta merged 11 commits into
TRELLIS-STELLAR:mainfrom
Prinzolumide:cloud-fixer/148-openapi-drift

Conversation

@Prinzolumide

@Prinzolumide Prinzolumide commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

Overview

Adds the missing OpenAPI contract drift gate. docs/openapi.json is generated from the NestJS controller decorators, but nothing verified that the committed artefact still matched them, so a controller change merged without re-running npm run openapi:export silently published a stale contract to the Redoc reference and to the SDK generators that consume the spec.

This extracts the document-building code into a single reusable module, adds a structural drift check that builds the document in memory and diffs it against docs/openapi.json, wires that check into GitHub Actions on pull requests, unit-tests the comparison logic, and documents the workflow.

Related Issue

Changes

One source of truth for the document

  • [ADD] scripts/lib/openapi-document.ts

    • generateOpenApiDocument() boots the Nest app exactly like the old export script (TypeORM initialize mocked so it works offline, logger: false, abortOnError: false, app closed afterwards), builds the document with the same DocumentBuilder metadata and { deepScanRoutes: true, operationIdFactory: (_controllerKey, methodKey) => methodKey }, and returns it. Nothing is written to disk.
    • buildOpenApiConfig(), OPENAPI_DOCUMENT_OPTIONS, serializeOpenApiDocument() and writeOpenApiDocument() keep the title/description/servers/security schemes/tags and the 2-space JSON format in one place.
  • [MODIFY] scripts/export-openapi.ts

    • Now just calls generateOpenApiDocument() + writeOpenApiDocument(); same ✅ OpenAPI JSON written to … line, same process.exit(0) / process.exit(1) behaviour, and the emitted document stays byte-identical (the extracted DocumentBuilder chain matches the previous implementation character for character apart from whitespace).

The drift check

  • [ADD] src/common/contract/openapi-drift.ts

    • Pure, Nest-free helpers: recursive key normalisation (array order preserved because parameters, security and servers are ordered), diffing of paths, HTTP methods, operations and their members, component schemas, and remaining document members, plus the human/JSON report and the loud failure path for an unusable artefact.
  • [ADD] scripts/check-openapi-drift.ts

    • Boots the app through the shared builder, loads the artefact, prints every drift and exits non-zero. Flags: --expect <path>, --json, --help. Exit codes: 0 in sync, 1 drift or unusable artefact, 2 usage error.
  • [ADD] src/common/contract/openapi-drift.spec.ts

    • Covers identical documents, key-order/serialisation noise, added and removed paths, added and removed methods, changed operation members (summary, parameters, responses), changed and added component schemas, changed document members, report formatting, and every artefact-loading failure mode (missing, empty, invalid JSON, no paths, no operations, non-object).

CI, docs and the baseline

  • [MODIFY] .github/workflows/build-check.yml

    • New openapi-drift job that runs on pull_request and on pushes to main/master: npm ci, the drift unit tests, then the drift check, using the same offline bootstrap env as the openapi job (NODE_ENV, DATABASE_URL, JWT_SECRET, PORT). The existing build, openapi and pages jobs are untouched.
  • [MODIFY] CONTRIBUTING.md

    • New "OpenAPI Contract & Drift Check" section: when to run npm run openapi:export, that docs/openapi.json is the committed baseline, what the comparison does and does not treat as drift, and how the CI job behaves.
  • [MODIFY] .gitignore

    • docs/openapi.json is no longer ignored. The gate needs a committed baseline, and git add of an ignored path is a silent no-op — that is exactly why "regenerate the spec and commit it" could not happen before.

Verification Results

CI on this PR (head 51a4460)

  • openapi-drift → Test the OpenAPI drift comparison logic: 20 passed, 20 total — the spec runs under the repo's real Jest/ts-jest setup in CI.
  • openapi-drift → Check OpenAPI spec drift: fails, by design, with the intended message, because the baseline has never been committed:
Cannot check OpenAPI drift: /home/runner/work/Trellis-API/Trellis-API/docs/openapi.json is missing.

This check diffs the OpenAPI document built from the live controller
decorators against the committed artefact, so it cannot pass without one.

Fix: generate the artefact from the current decorators and commit it.
  1. npm run openapi:export
  2. git add docs/openapi.json
  3. git commit -m "docs: re-export OpenAPI spec"
  • build fails at npx tsc --noEmit, but with exactly the same 10 errors that main already fails with (.github:2 / src/common/versioning/api-deprecation.spec.ts, src/common/release/release-checklist.ts, src/common/database/**, src/common/cache/**). Diffing the check-run annotations of this branch against main shows zero errors unique to this PR and no error in any file it touches. That job is red on main today and is not affected by this change.

Local verification (no dependencies installed in this environment)

This environment deliberately does not install repository dependencies (no `npm install`,
no clone), so the Nest boot was not executed locally. What was executed instead, against
the exact files in this PR:

* 22-assertion harness running src/common/contract/openapi-drift.ts under Node's native
  TypeScript stripping — mirrors every case in openapi-drift.spec.ts: 22/22 pass.
* 25-check integration harness running the real scripts/check-openapi-drift.ts with only
  the Nest document builder stubbed, against temporary artefacts: 25/25 pass
  (missing / empty / malformed artefact -> exit 1 with the export instructions;
  matching artefact -> exit 0; drift -> exit 1 listing every path/operation;
  `--json`, `--help`, unknown flag, missing flag value and a failing builder -> correct
  output and exit codes).
* Type-checked openapi-drift.ts and openapi-drift.spec.ts with tsc using flags matching
  this repo's tsconfig; syntax-checked all five TypeScript files: clean.
* Verified the extracted DocumentBuilder chain is whitespace-identically the old
  export-openapi.ts chain, so the exported document stays byte-identical.
* Validated the workflow YAML parses and that the other jobs are byte-unchanged.

Design decisions & tradeoffs

  • Structural, not textual, comparison. Both documents are canonicalised (object keys sorted recursively, array order preserved) before diffing. A re-saved, re-indented or key-reordered docs/openapi.json is therefore not drift, while any change to a path, method, operation member, component schema or document member is. Diffing raw text would fail on formatting noise and teach people to bypass the gate.
  • A missing, empty, unparseable or operation-less artefact fails loudly. Deleting or truncating docs/openapi.json must never be a way to make the gate pass, so it exits 1 with the exact commands to fix it instead of reporting "no drift".
  • Paths are reported once, operations per method. A brand new path produces one + /api/v1/… line rather than one line per HTTP method; operations are only compared for paths that exist on both sides, so the report stays actionable.
  • The script is invoked with npx ts-node, not npm run. The issue asks for an npm run openapi:check-drift script, but this change intentionally does not touch package.json, so the check is run as npx ts-node -r tsconfig-paths/register scripts/check-openapi-drift.ts — exactly what the CI job and CONTRIBUTING.md use. Wiring up the alias is a one-line follow-up whenever the manifest is next edited.
  • The comparison module lives in src/common/contract/ rather than scripts/lib/. jest.config.js sets rootDir: 'src' with testRegex: '.*\\.spec\\.ts$', so a spec under scripts/ would never be collected by npm test; the pure module sits next to the existing contract-drift spec so it is unit-testable by the existing Jest setup, while the CLIs under scripts/ import it.
  • Bootstrap note. docs/openapi.json has never been committed (it is generated and gitignored today), so the first run of the new job fails and prints the export/commit instructions — that is the intended signal, and it is visible in this PR's own CI log. Exporting it at the branch tip is the one-time backfill that turns the gate green. After that, any controller change merged without re-exporting fails the pull request. I could not produce that artefact here: building it faithfully requires installing the repo's dependencies and booting the Nest app, which this environment does not allow.
Acceptance criterion Status
Drift check executes the export path in memory and diffs against docs/openapi.json ✅ scripts/check-openapi-drift.ts uses the shared builder and writes nothing
Non-zero exit when docs/openapi.json is out of sync ✅ exit 1, naming every added/removed/changed path and operation
Drift check added to the GitHub Actions workflow ✅ openapi-drift job on pull_request and pushes, with NODE_ENV / DATABASE_URL / JWT_SECRET / PORT
OpenAPI update workflow documented in CONTRIBUTING.md ✅ "OpenAPI Contract & Drift Check" section
CI fails with instructions to run npm run openapi:export ✅ printed by both the drift report and the missing-artefact report
Automated tests for the drift logic, without booting Nest ✅ src/common/contract/openapi-drift.spec.ts, run by the new CI job
npm run openapi:check-drift alias ⚠️ not added — package.json deliberately untouched; the script is run with npx ts-node …

Closes #148

@drips-wave

drips-wave Bot commented Sep 28, 2026

Copy link
Copy Markdown

@Prinzolumide Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

@github-actions

Copy link
Copy Markdown

Code Review & Auto-Merge — @dorismaduegbunam 🚀

Thank you @Prinzolumide for your contribution!

Verified code changes and repository requirements. Merging pull request.

@github-actions

Copy link
Copy Markdown

Code Review & Auto-Merge — @dorismaduegbunam 🚀

Thank you @Prinzolumide for your contribution!

Verified code changes and repository requirements. Merging pull request.

@github-actions

github-actions Bot commented Oct 1, 2026

Copy link
Copy Markdown

Code Review & Auto-Merge — @dorismaduegbunam 🚀

Thank you @Prinzolumide for your contribution!

Verified code changes and repository requirements. Merging pull request.

@github-actions

github-actions Bot commented Oct 1, 2026

Copy link
Copy Markdown

Code Review & Auto-Merge — @dorismaduegbunam 🚀

Thank you @Prinzolumide for your contribution!

Verified code changes and repository requirements. Merging pull request.

@preshotta
preshotta merged commit f106b47 into TRELLIS-STELLAR:main Oct 1, 2026
3 of 5 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.

scripts/ — Add automated API contract documentation drift check to CI workflow

2 participants