Skip to main content

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.

Two places generated code goes stale Inside the provider repository the spec can change without the committed types being regenerated. Across repositories a consumer can hold a copy of the spec from three releases ago and never learn that it moved. inside one repository openapi.yaml edited in this PR not run src/api.d.ts unchanged, now wrong gate 1: regenerate and diff fails in this same PR across repositories published 2.15.0 provider ships it never fetched consumer pinned 2.12.0 types from three releases ago gate 2: scheduled version check opens a PR when they differ

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.

Turning silent drift into a reviewable pull request A weekday job compares the vendored spec version with the published latest; when they match nothing happens, and when they differ it regenerates and opens a pull request carrying the type diff. scheduled job weekday mornings pinned == latest? info.version same — do nothing no noise on a quiet week differ — regenerate open a PR with the type diff a human decides when to merge

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.

Two commits, two kinds of change The first commit contains only regenerated output and can be skimmed; the second contains the hand-written response to it and deserves real review. One combined commit hides the second inside the first. one combined commit 4,100 generated lines + 12 hand-written ones the 12 are unreviewable commit 1: regenerate types 4,100 lines, skim and move on commit 2: handle the new field 12 lines, read every one
# .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.autocrlf produces a whole-file diff. Add * text=auto eol=lf to .gitattributes before 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.