Keeping Generated Clients in Sync with CI Checks
Problem: src/api.d.ts is generated from the OpenAPI document and committed. Nobody remembers whether it was regenerated after the last spec change, and the only way to find out is to run the generator and look. Meanwhile a consumer in another repository is generating from a copy of the spec that is three releases old. This guide is part of Compile-Time Type Generation from OpenAPI and covers proving that generated code is current — inside the repository and across repositories.
Root Cause: Generated Code Has No Owner
Hand-written code has an author who keeps it correct. Generated code has a command, and a command that nobody runs produces an artifact that silently ages. There are exactly two failure modes, and they need different gates.
Step 1: Make Generation Deterministic
The gate compares bytes, so byte-identical output on every machine is a precondition. Three things break it.
{
"devDependencies": {
"openapi-typescript": "7.4.2", // exact — never a caret range
"@redocly/cli": "1.25.11"
},
"scripts": {
"spec:bundle": "redocly bundle api/openapi.yaml -o dist/openapi.yaml",
"gen:types": "openapi-typescript dist/openapi.yaml -o src/api.d.ts --enum",
"gen": "npm run spec:bundle && npm run gen:types",
"gen:check": "npm run gen && git diff --exit-code dist/openapi.yaml src/api.d.ts"
}
}
Pin the generator exactly. A caret range means the first person to install after an upstream release generates different formatting, and the diff lands on whoever builds next.
Generate from the bundle, not the split source. Reference-resolution order can vary between tools; generating from the single bundled file removes the variable.
Strip anything time- or locale-dependent. Generators that stamp a generation date make every run differ. Disable the stamp if the tool allows it, or post-process it away:
# Remove a generated-on line if the generator insists on one.
sed -i '/^\/\/ Generated on /d' src/api.d.ts
Step 2: Gate It in CI
# .github/workflows/generated.yml
name: Generated artifacts
on: [pull_request]
jobs:
check:
runs-on: ubuntu-latest
env:
TZ: UTC # a generator that formats a date must not vary by runner
LC_ALL: C.UTF-8 # nor by locale-dependent sorting
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 22, cache: npm }
- run: npm ci # ci, not install — the lockfile is the pinning
- name: Regenerate
run: npm run gen
- name: Fail if the committed artifacts are stale
run: |
if ! git diff --exit-code dist/openapi.yaml src/api.d.ts; then
echo "::error::generated files are out of date — run 'npm run gen' and commit"
exit 1
fi
- name: Type-check against the regenerated types
run: npx tsc --noEmit
The type-check is not redundant. The diff proves the artifacts match the spec; tsc proves the application still compiles against them — which is where a removed field actually surfaces.
$ npm run gen:check
diff --git a/src/api.d.ts b/src/api.d.ts
@@ -142,7 +142,6 @@ export interface components {
status: "draft" | "paid" | "cancelled";
total: components["schemas"]["Money"];
- settled_at?: string;
};
Error: generated files are out of date — run 'npm run gen' and commit
That diff is the reviewable artifact the whole arrangement exists to produce: a reviewer sees settled_at disappearing from the client surface, in the pull request that removed it.
Step 3: Check the Upstream Spec on a Schedule
The in-repository gate cannot see a provider that changed. Consumers need a second check, and it runs on a timer rather than on a pull request.
# .github/workflows/spec-freshness.yml — in the CONSUMER repository
name: Upstream spec freshness
on:
schedule: [{ cron: "0 6 * * 1-5" }]
workflow_dispatch:
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Fetch the published contract
run: curl -sfL https://specs.example.com/orders/openapi-latest.yaml -o /tmp/upstream.yaml
- name: Compare versions
id: compare
run: |
PINNED=$(yq '.info.version' vendor/orders-openapi.yaml)
LATEST=$(yq '.info.version' /tmp/upstream.yaml)
echo "pinned=$PINNED" >> "$GITHUB_OUTPUT"
echo "latest=$LATEST" >> "$GITHUB_OUTPUT"
[ "$PINNED" = "$LATEST" ] && echo "stale=false" >> "$GITHUB_OUTPUT" \
|| echo "stale=true" >> "$GITHUB_OUTPUT"
- name: Adopt the new contract
if: steps.compare.outputs.stale == 'true'
run: |
cp /tmp/upstream.yaml vendor/orders-openapi.yaml
npm ci && npm run gen
- name: Open a pull request
if: steps.compare.outputs.stale == 'true'
uses: peter-evans/create-pull-request@v7
with:
branch: chore/orders-spec-${{ steps.compare.outputs.latest }}
title: "Adopt orders-api ${{ steps.compare.outputs.latest }}"
body: |
The orders API published ${{ steps.compare.outputs.latest }};
we generate from ${{ steps.compare.outputs.pinned }}.
Generated types in this branch are rebuilt from the new contract —
review the diff for anything that changes our call sites.
Vendoring the spec and pinning its version is what makes this work. A consumer generating directly from a live URL gets a build whose output changes without any commit, which is the same staleness problem wearing a different hat.
Step 4: Make the Regeneration Obvious to Reviewers
A pull request containing four thousand lines of regenerated output buries the twelve that matter. Two conventions help.
# .gitattributes — collapse generated files in review, keep them diffable in the terminal
src/api.d.ts linguist-generated=true
dist/openapi.yaml linguist-generated=true
And keep generation in its own commit. A branch whose first commit is “regenerate types” and whose second is “handle the new field” is far easier to review than one commit containing both.
Verification
# Deterministic: two runs produce identical bytes.
npm run gen && sha256sum src/api.d.ts > /tmp/a
npm run gen && sha256sum src/api.d.ts > /tmp/b
diff /tmp/a /tmp/b && echo "deterministic"
# deterministic
# The gate catches a spec change with no regeneration.
yq -i '.components.schemas.Order.properties.new_field.type = "string"' api/openapi.yaml
npm run gen:check || echo "gate fired as expected"
# gate fired as expected
# The gate stays quiet when nothing changed.
git checkout api/openapi.yaml && npm run gen:check && echo "clean"
# clean
# The consumer's pinned version is the one it generated from.
diff <(yq '.info.version' vendor/orders-openapi.yaml) <(grep -o '2\.[0-9]*\.[0-9]*' src/api.d.ts | head -1)
Edge Cases and Caveats
- Line endings. A Windows checkout with
core.autocrlfproduces a whole-file diff. Add* text=auto eol=lfto.gitattributesbefore adopting the gate, or the first Windows contributor sees a four-thousand-line change. - Monorepos with several consumers. Each package generating its own client needs its own gate. A single top-level check that regenerates everything is simpler and slower; per-package checks are worth it once the generation step exceeds a minute.
- Generators that emit runtime code. Types are safe to regenerate blindly; a generated client with hand-edited retry logic is not. Keep customisation outside the generated file — wrap it rather than editing it — or the gate becomes an argument every sprint.
- Air-gapped builds. Vendoring the spec is what lets the build work without network access. A generator fetching a live URL at build time fails the first time the network does.
- Version alignment. The version the consumer pinned should match the one recorded in its generated artifacts, which is why semantic versioning of the document is a prerequisite rather than a nicety.
Deciding Not to Commit the Generated Code
Committing generated artifacts is the default recommended here, and there is a coherent alternative worth knowing: generate at build time and commit nothing.
It wins when the generated output is large enough to dominate every diff, when no human ever reads it, and when the build reliably has network access and a pinned toolchain. In that arrangement the gate changes shape — instead of “regenerate and diff”, the check becomes “the build compiles against freshly generated types”, which catches the same class of drift at the moment it matters.
It loses on two counts. A reviewer cannot see that a field disappeared, because the artifact is not in the diff; the signal arrives as a compile error somewhere else, with no direct link to the contract change that caused it. And the build acquires a dependency on the generator and its inputs being available, which is a real constraint for air-gapped or long-lived release branches.
Whichever you choose, choose it deliberately and apply it consistently. The failure mode is the middle ground: a generated file committed once, never regenerated, and treated as hand-written by everyone who touches it afterwards.
Frequently Asked Questions
Should generated code be committed?
Yes, when consumers read it in review or the build must work without network access. Commit it and gate it: a CI step that regenerates and fails on any difference gives you the reviewable diff without the staleness risk.
Why does the gate fail on a machine where nothing changed?
Almost always an unpinned generator. A caret range picks up a new minor version whose output differs by formatting, and the diff appears on whoever builds next. Pin the exact version and the lockfile.
How does a consumer in another repository know the spec changed?
By pinning the spec version it generated from and checking it against the published latest on a schedule. A nightly job that opens a pull request when they differ turns a silent drift into a reviewable change.
Is a git hook enough instead of a CI check?
No. Hooks are local, skippable and not installed on every machine. Use a hook for speed if you like, but the authoritative check has to run where nobody can bypass it.
What if the generator output is not deterministic?
Fix that first — the gate is unusable otherwise. Sort keys before generation, pin the generator, and set an explicit locale and timezone in CI. Anything time-dependent in the output, such as a generation timestamp, has to be stripped or disabled.