-
Notifications
You must be signed in to change notification settings - Fork 0
ci: add catalog api contract drift check #52
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,41 @@ | ||
| name: Catalog API Contract Drift | ||
|
|
||
| on: | ||
| push: | ||
| branches: [main] | ||
| paths: | ||
| - "src/daemon/NKS.WebDevConsole.Daemon/Binaries/CatalogClient.cs" | ||
| - "scripts/check-catalog-drift.mjs" | ||
| - ".github/workflows/catalog-contract.yml" | ||
| pull_request: | ||
| paths: | ||
| - "src/daemon/NKS.WebDevConsole.Daemon/Binaries/CatalogClient.cs" | ||
| - "scripts/check-catalog-drift.mjs" | ||
| - ".github/workflows/catalog-contract.yml" | ||
| workflow_dispatch: | ||
| inputs: | ||
| catalog_version: | ||
| description: "Pinned catalog-api version (e.g. 0.2.0)" | ||
| required: false | ||
|
|
||
| concurrency: | ||
| group: catalog-contract-${{ github.ref }} | ||
| cancel-in-progress: true | ||
|
|
||
| jobs: | ||
| check: | ||
| runs-on: ubuntu-latest | ||
| steps: | ||
| - uses: actions/checkout@v4 | ||
|
|
||
| - uses: actions/setup-node@v4 | ||
| with: | ||
| node-version: "20" | ||
|
|
||
| - name: Run drift check | ||
| env: | ||
| # Pin the catalog-api release whose openapi.json we validate | ||
| # the C# CatalogClient.cs against. Bump this whenever the | ||
| # catalog service ships a new contract version. | ||
| CATALOG_API_VERSION: ${{ inputs.catalog_version || '0.2.0' }} | ||
| run: node scripts/check-catalog-drift.mjs |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,139 @@ | ||
| #!/usr/bin/env node | ||
| /** | ||
| * Catalog API contract drift checker. | ||
| * | ||
| * Fetches ``openapi.json`` from a pinned ``wdc-catalog-api`` GitHub | ||
| * release (or a local file via --spec) and verifies the endpoints / | ||
| * required fields the C# ``CatalogClient.cs`` depends on are still | ||
| * present with the expected shape. Emits a non-zero exit when the | ||
| * contract drifts so CI can block a breaking merge. | ||
| * | ||
| * Usage (CI): | ||
| * CATALOG_API_VERSION=0.2.0 node scripts/check-catalog-drift.mjs | ||
| * | ||
| * Usage (local): | ||
| * node scripts/check-catalog-drift.mjs --spec /path/to/openapi.json | ||
| */ | ||
|
|
||
| import fs from "node:fs"; | ||
| import path from "node:path"; | ||
| import { fileURLToPath } from "node:url"; | ||
|
|
||
| const __dirname = path.dirname(fileURLToPath(import.meta.url)); | ||
| const REPO_ROOT = path.resolve(__dirname, ".."); | ||
|
|
||
| // Endpoints + DTOs the C# daemon consumes. Add to this list when | ||
| // ``CatalogClient.cs`` grows a new dependency. | ||
| const REQUIRED_PATHS = [ | ||
| "/healthz", | ||
| "/api/v1/catalog", | ||
| "/api/v1/catalog/{app_name}", | ||
| "/api/v1/sync/config", | ||
| "/api/v1/sync/config/{device_id}", | ||
| ]; | ||
|
|
||
| // Wire-format fields on ``CatalogDocument`` / related DTOs. Values are | ||
| // snake_case because ``CatalogClient.cs`` serializes with | ||
| // ``JsonNamingPolicy.SnakeCaseLower``. | ||
| const REQUIRED_FIELDS_BY_SCHEMA = { | ||
| TokenResponse: ["token", "email"], | ||
| ConfigSyncEntry: ["device_id", "updated_at", "payload"], | ||
| ConfigSyncUploadRequest: ["device_id", "payload"], | ||
|
Comment on lines
+38
to
+41
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This schema map does not include the catalog document types consumed by Useful? React with 👍 / 👎. |
||
| }; | ||
|
|
||
| function fail(msg) { | ||
| console.error(`[drift-check] ${msg}`); | ||
| process.exitCode = 1; | ||
| } | ||
|
|
||
| function ok(msg) { | ||
| console.log(`[drift-check] ${msg}`); | ||
| } | ||
|
|
||
| async function loadSpec(args) { | ||
| const explicit = args.indexOf("--spec"); | ||
| if (explicit !== -1 && args[explicit + 1]) { | ||
| const file = args[explicit + 1]; | ||
| ok(`Loading spec from ${file}`); | ||
| return JSON.parse(fs.readFileSync(file, "utf-8")); | ||
| } | ||
| const version = process.env.CATALOG_API_VERSION; | ||
| if (!version) { | ||
| fail("CATALOG_API_VERSION not set (and no --spec passed)."); | ||
| fail("Either export CATALOG_API_VERSION=x.y.z or pass --spec path."); | ||
| process.exit(2); | ||
| } | ||
| const tag = version.startsWith("v") ? version : `v${version}`; | ||
| const url = | ||
| `https://github.com/nks-hub/wdc-catalog-api/releases/download/` + | ||
| `${tag}/openapi.json`; | ||
| ok(`Fetching spec: ${url}`); | ||
| const res = await fetch(url); | ||
| if (!res.ok) { | ||
| fail(`Failed to fetch ${url}: ${res.status} ${res.statusText}`); | ||
| process.exit(2); | ||
| } | ||
| return await res.json(); | ||
| } | ||
|
|
||
| function checkPaths(spec) { | ||
| const present = spec.paths ?? {}; | ||
| for (const p of REQUIRED_PATHS) { | ||
| if (!(p in present)) { | ||
| fail(`Missing required path: ${p}`); | ||
| } else { | ||
| ok(`Path present: ${p}`); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| function checkSchemas(spec) { | ||
| const schemas = spec.components?.schemas ?? {}; | ||
| for (const [name, fields] of Object.entries(REQUIRED_FIELDS_BY_SCHEMA)) { | ||
| const schema = schemas[name]; | ||
| if (!schema) { | ||
| fail(`Missing schema: ${name}`); | ||
| continue; | ||
| } | ||
| const props = schema.properties ?? {}; | ||
| for (const field of fields) { | ||
| if (!(field in props)) { | ||
| fail(`Schema ${name} is missing field '${field}'`); | ||
| } | ||
| } | ||
| ok(`Schema ${name} has required fields [${fields.join(", ")}]`); | ||
| } | ||
| } | ||
|
|
||
| function checkCSharpStillCompiles() { | ||
| // Light sanity check: the C# client file still exists and mentions | ||
| // the critical DTO class names. Full type-level check needs dotnet | ||
| // build, which the CI's downstream job already runs. | ||
| const clientPath = path.join( | ||
| REPO_ROOT, | ||
| "src/daemon/NKS.WebDevConsole.Daemon/Binaries/CatalogClient.cs", | ||
| ); | ||
| if (!fs.existsSync(clientPath)) { | ||
| fail(`Missing CatalogClient.cs at ${clientPath}`); | ||
| return; | ||
| } | ||
| const source = fs.readFileSync(clientPath, "utf-8"); | ||
| for (const token of ["CatalogDocument", "ReleaseDoc", "DownloadDoc"]) { | ||
| if (!source.includes(token)) { | ||
| fail(`CatalogClient.cs no longer references '${token}'`); | ||
| } | ||
| } | ||
| ok("CatalogClient.cs references CatalogDocument/ReleaseDoc/DownloadDoc"); | ||
| } | ||
|
|
||
| const args = process.argv.slice(2); | ||
| const spec = await loadSpec(args); | ||
| checkPaths(spec); | ||
| checkSchemas(spec); | ||
| checkCSharpStillCompiles(); | ||
|
|
||
| if (process.exitCode) { | ||
| console.error("[drift-check] FAILED — catalog-api contract has drifted."); | ||
| process.exit(1); | ||
| } | ||
| ok("All contract checks passed."); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,34 @@ | ||
| # catalog-api has moved | ||
|
|
||
| This service was extracted into its own public repository: | ||
|
|
||
| **https://github.com/nks-hub/wdc-catalog-api** | ||
|
|
||
| ## Why | ||
|
|
||
| - Zero code coupling with the rest of the monorepo — only a JSON wire contract | ||
| - Separate tech stack (Python/FastAPI/Docker vs C#/.NET/Electron) | ||
| - Independent release cadence — catalog scraping iterates weekly, desktop app monthly | ||
| - Dedicated CI pipeline (ruff + mypy + pytest + pip-audit) instead of cross-stack overhead | ||
| - Cleaner contributor surface — backend contributors don't need to clone .NET + Electron | ||
|
|
||
| ## Contract | ||
|
|
||
| The desktop daemon consumes the public JSON at `https://wdc.nks-hub.cz/api/v1/catalog`. | ||
| The new repo publishes `openapi.json` as a GitHub Release asset on every | ||
| tagged version — this repo's CI pins a catalog schema version via | ||
| `CATALOG_API_VERSION` and regenerates C# DTOs, failing the build if the | ||
| hand-maintained `CatalogClient.cs` drifts from the spec. | ||
|
|
||
| ## History | ||
|
|
||
| The pre-split monorepo state is preserved under tag | ||
| [`catalog-api-pre-split`](https://github.com/nks-hub/webdev-console/releases/tag/catalog-api-pre-split). | ||
| The new repo was created via `git filter-repo --subdirectory-filter services/catalog-api` | ||
| so all 700+ commits retain their original authors and dates. | ||
|
|
||
| ## Deployment | ||
|
|
||
| The public instance `wdc.nks-hub.cz` is deployed from | ||
| `ghcr.io/nks-hub/wdc-catalog-api:latest`. See the new repo's README for | ||
| operational runbooks. |
This file was deleted.
This file was deleted.
This file was deleted.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The drift check omits
"/api/v1/sync/config/{device_id}/exists"fromREQUIRED_PATHS, even though the daemon calls that contract inProgram.cs(/api/sync/existsproxy). If the catalog API drops or renames only that endpoint, this CI check will still pass and the sync-exists flow will regress at runtime without early detection.Useful? React with 👍 / 👎.