SDK Generation & Changelog Automation
Hand-written client libraries rot the moment an endpoint changes. A field gets renamed in the API, the OpenAPI spec is updated, the changelog is forgotten, and three language SDKs silently drift out of sync until a consumer files a bug. This guide shows how to make the machine-readable spec the single source of truth for every client SDK and every published changelog, so that a merge to the default branch regenerates SDKs, detects breaking changes, tags a release, and updates the changelog without anyone editing client code by hand.
This is for API engineers and platform teams who ship SDKs in more than one language, developer advocates who maintain quickstarts that must match the current API surface, and technical writers who own changelogs. It assumes you already author specs using a contract-first workflow — if you do not yet have clean, linted specs, start with OpenAPI & AsyncAPI Schema Authoring and come back here to automate what happens after the spec is valid. The output of this pipeline also feeds the reference docs you build in Developer Portal Frameworks & UI Setup, so the same spec drives docs and SDKs from one commit.
What breaks without this automation is predictable: spec-to-SDK drift where the published client no longer matches the live API; manual changelogs that omit the breaking change a customer just hit; and breaking-change surprises that ship to npm before anyone notices an enum was narrowed. The fix is a deterministic pipeline — spec in, SDKs and a changelog out — gated by an automated diff.
Quick-reference: SDK toolchain comparison
Pick a generator before you write any workflow YAML — the choice determines how idiomatic the output is, how publishing works, and what it costs. The table below compares the three toolchains this guide covers in depth. Each has a dedicated section: OpenAPI Generator, Fern, and Speakeasy.
| Criterion | OpenAPI Generator (v7.x) | Fern | Speakeasy |
|---|---|---|---|
| Languages | 50+ generators (TS, Python, Go, Java, C#, Rust, PHP, Ruby, Kotlin, Swift…) | TS, Python, Go, Java, C#, Ruby, PHP, Swift | TS, Python, Go, Java, C#, PHP, Ruby, Swift, Unity |
| OpenAPI 3.1 support | Yes (3.1 parser since v7.0) | Yes (3.0/3.1; also Fern Definition) | Yes (3.0/3.1) |
| Idiomatic output | Template-driven; usable, less hand-tuned | Highly idiomatic, hand-curated per language | Highly idiomatic, hand-curated per language |
| Publishing / CI | Bring-your-own scripts; CLI only | fern generate + managed publish to npm/PyPI/etc. |
GitHub Action generates PRs + publishes |
| License / cost | Apache-2.0, free, self-hosted | Free tier + paid plans for managed SDKs | Free tier + paid plans per generated SDK |
| Customization | Mustache templates (-t), ignore file, properties |
Generator config + custom code snippets | gen.yaml overrides, custom code regions |
Use OpenAPI Generator when you need the widest language matrix, full control, and zero per-SDK cost — it runs entirely in your own CI. Choose Fern or Speakeasy when the priority is idiomatic, review-grade output in a handful of mainstream languages and you accept a managed pipeline. Many teams run OpenAPI Generator for less common languages and a managed tool for their flagship TypeScript and Python SDKs.
Choosing a generator
Start from the consumer, not the tool. List the languages your customers actually integrate in, then weight three properties: idiomatic feel, OpenAPI 3.1 coverage, and how the SDK gets published.
Idiomatic feel matters most for adoption. A TypeScript SDK that returns Promise<AxiosResponse<T>> instead of Promise<T> and ships any-typed error branches will get wrapped or replaced by every serious consumer. The managed tools win here because their templates are maintained per language by people who write that language daily. OpenAPI Generator closes the gap with custom Mustache templates, covered in customizing OpenAPI Generator templates, but that is ongoing maintenance you own.
OpenAPI 3.1 coverage is non-negotiable if your specs use webhooks, null types via type: [string, "null"], or JSON Schema 2020-12 keywords. All three toolchains parse 3.1, but generator-by-generator support for newer constructs varies. The most common failure is polymorphism: a oneOf with a discriminator must map cleanly to a tagged union or a base class. Validate this against a real fixture early, as shown in handling oneOf discriminators in generated SDKs.
Publishing model decides how much pipeline you build. OpenAPI Generator is a CLI: it emits source and you write the npm/PyPI/Go publish steps. Fern and Speakeasy ship managed publish paths and, in Speakeasy’s case, a GitHub Action that opens SDK update PRs automatically. If you want to own the entire pipeline in your repo, the CLI route is more transparent; if you want SDKs maintained for you, the managed route is faster to stand up.
A practical decision rule: if you ship more than four languages or any niche one, default to OpenAPI Generator. If you ship two or three mainstream languages and care more about polish than control, use a managed tool. Either way, keep the spec as the source of truth so you can switch generators later without rewriting clients by hand.
Cost is the last axis and it is easy to underweight at the prototype stage. OpenAPI Generator is Apache-2.0 with no usage ceiling, so the only cost is the CI minutes and the engineering time to maintain templates. Fern and Speakeasy bill per generated SDK above a free tier, which is cheap relative to the engineering hours a hand-maintained TypeScript client consumes, but it does mean your SDK pipeline has a vendor dependency. Quantify both: a self-hosted generator that needs a half-day of template maintenance per quarter is not obviously cheaper than a managed tool that produces review-grade output on day one. Decide deliberately rather than defaulting to “free.”
Finally, audit the generator’s track record on your specific spec, not on a toy example. Run all three candidates against your real openapi.yaml during evaluation and diff the outputs. Generators that look equivalent on a three-endpoint demo diverge sharply on a spec with deep $ref chains, request body variants, and nullable fields. The fifteen minutes it takes to generate a throwaway SDK from each tool saves weeks of discovering, after launch, that your chosen generator mangles the one schema your busiest endpoint depends on.
The spec → SDK → publish pipeline
The pipeline has four deterministic stages, and each stage must fail closed. Lint the spec; diff it against the previously released version; generate code per language; publish to the registry. Nothing downstream runs if an upstream stage fails, which is what prevents a broken SDK from reaching consumers.
Stage one is linting. Reuse the same ruleset your authoring pipeline already enforces so the generator never sees a spec the docs would reject. A minimal gate:
# Fail the pipeline before generation if the spec is invalid.
npx @redocly/cli@1 lint openapi.yaml --max-problems 0
Stage two is the breaking-change diff, covered in detail below. Stage three is generation. With OpenAPI Generator, pin the generator version and read configuration from openapitools.json so CI and local runs are identical:
# Pin the generator so output is reproducible across machines and CI.
npx @openapitools/[email protected] version-manager set 7.7.0
npx @openapitools/openapi-generator-cli generate \
-i openapi.yaml \
-g typescript-axios \
-o ./sdks/typescript \
--additional-properties=npmName=@acme/api-client,supportsES6=true,withInterfaces=true
Stage four is publishing. Build the package, then publish from CI using a registry token stored as a secret. For npm:
cd ./sdks/typescript
npm version "$SDK_VERSION" --no-git-tag-version # SDK_VERSION computed from the spec + diff
npm ci && npm run build
npm publish --access public --provenance
The --provenance flag attaches a signed attestation linking the published package back to the exact CI run and source commit, which is the supply-chain audit trail consumers increasingly check. Keep each language’s generated output in its own directory so a failure in the Go build never blocks the TypeScript publish.
Make every stage idempotent and side-effect-free until the final publish. Linting, diffing, and generation can run on every pull request without consequence; only the publish step mutates the outside world, and it should run exclusively on the default branch after a merge. This separation lets contributors see the full effect of a spec change — the SDK diff and the changelog fragment — in the pull request itself, while guaranteeing nothing reaches a registry until the change is reviewed and merged. A pull request that regenerates the SDK and posts the diff as a comment is the single highest-leverage addition to this pipeline, because it turns “trust me, the SDK still works” into a concrete, reviewable artifact.
Treat the bundled spec as the unit of reproducibility. Before generation, resolve every external $ref into one self-contained file and hash it; record that hash in the build metadata. If two pipeline runs produce different SDK output from the same spec hash, the cause is an unpinned generator or template, not the spec — which immediately narrows the investigation. Bundling once and fanning out also means a flaky network fetch of a remote $ref fails fast in one place rather than intermittently across every language job.
Multi-language strategy
Generating five languages from one spec sounds free until you hit naming. order_id is correct in Python, orderId in TypeScript, and OrderID (exported) in Go. The generator handles casing per language, but only if you let it — do not pre-mangle names in the spec to suit one language, because that corrupts the output for the others. Keep snake_case field names in the spec and let each generator apply its idiom.
Package identity is the second cross-language concern. Each SDK needs a distinct, registry-correct name and namespace set through generator properties, never edited into the output by hand:
# TypeScript: scoped npm package
--additional-properties=npmName=@acme/api-client,npmVersion=2.4.0
# Python: PyPI distribution + import package
--additional-properties=packageName=acme_api_client,projectName=acme-api-client
# Go: module path drives the import path
--additional-properties=packageName=acmeclient,enumClassPrefix=true
Run every language in parallel CI jobs from the same spec artifact. Bundle the spec once into a single self-contained file, upload it as a build artifact, then fan out one job per language that downloads that exact artifact. This guarantees all SDKs are generated from byte-identical input. For the managed-tool equivalents, see configuring Fern for multiple languages and publishing SDKs to npm and PyPI with Fern.
Decide early whether SDKs share a repository or each gets its own. A single repository keeps generation atomic — one PR updates all languages — but couples release cadences. Per-language repositories let each SDK version independently at the cost of orchestration. For most teams, a monorepo of generated SDKs published as independent packages is the pragmatic middle ground.
Documentation snippets are the third multi-language surface that drifts. A quickstart that shows client.orders.list() in TypeScript and client.orders.list() in Python must stay accurate as method names change. Generate those snippets from the same spec rather than hand-writing them, and embed them into the portal you build with Developer Portal Frameworks & UI Setup. When the spec adds a parameter, the regenerated snippet updates in lockstep with the regenerated SDK, so the docs never demonstrate a call signature the published client does not support.
Be deliberate about error handling, because it is where idiomatic output diverges most across languages. A TypeScript SDK should reject with a typed error; a Go SDK returns an error as the last value; a Python SDK raises an exception subclass. Generators expose properties to shape this — for example whether HTTP error responses throw or are returned — and the wrong default produces a client that feels foreign in its target language. Set these per language during evaluation, not after consumers complain.
Changelog & diff automation
A changelog readers trust is generated from the same spec diff that gates the pipeline, not hand-curated after release. Two outputs matter: a human-readable changelog and a machine-readable breaking-change verdict. Generate both from oasdiff, which compares two OpenAPI specs and emits a structured report.
# Human-readable changelog between the last released spec and HEAD.
oasdiff changelog spec-released.yaml openapi.yaml --format markdown > CHANGELOG-fragment.md
# Machine-readable summary for the pipeline to parse.
oasdiff diff spec-released.yaml openapi.yaml --format json > diff.json
The changelog subcommand classifies every change by severity and renders prose like “Added optional request property cursor to GET /orders” — exactly what a consumer needs. Prepend the fragment to a kept CHANGELOG.md so history accumulates. For the full pattern including stable version anchoring and per-endpoint grouping, see generating API changelogs from OpenAPI diff and the broader automated changelog tools section.
Store the previously released spec as a build artifact or a git tag (for example spec-v2.3.0) so the diff always compares against what actually shipped, not against whatever happens to be on the default branch. Comparing against the wrong baseline produces a changelog that lies — the single most common automation failure here.
Group the generated entries by audience, not just by endpoint. A changelog that buries “removed the deprecated /v1/users endpoint” in a flat list of forty additions does not communicate the one thing a consumer must act on. oasdiff tags each change with a severity, so split the rendered output into a “Breaking changes” section, a “New features” section, and a “Other” section. Consumers scan the first section, confirm it is empty or expected, and move on — which is exactly the reading pattern a changelog should serve.
Keep the changelog in version control alongside the spec, and write it in the same pull request that changes the spec. When the changelog fragment is generated in CI and committed back, the spec change and its description travel together through review. A reviewer can object to wording or flag a change that should have been a major bump before anything ships, which is impossible when the changelog is written days later from memory.
Breaking-change detection with oasdiff
Breaking-change detection is the gate that turns “we hope this is compatible” into “CI proved it is.” oasdiff breaking exits non-zero when it finds a breaking change, which makes it a drop-in pull-request gate.
# Exit 1 on any ERR-level breaking change; warnings do not fail the build.
oasdiff breaking spec-released.yaml openapi.yaml --fail-on ERR
What counts as breaking: removing an endpoint, removing a response property, adding a required request property, narrowing an enum, tightening a type (string → integer), or making an optional parameter required. oasdiff knows these rules so you do not have to encode them yourself. Run it on every pull request that touches the spec, and require it to pass before merge.
When a breaking change is intentional — a deliberate v3 — do not bypass the gate silently. Bump the spec’s info.version major, point the diff at the new released-spec baseline, and let the gate pass because the comparison is now major-to-major. This keeps the audit trail honest: a breaking change is always paired with a major version bump.
For event-driven APIs the same discipline applies with AsyncAPI tooling instead of oasdiff; the surrounding gate is identical. Understanding which protocol you are diffing starts with AsyncAPI vs OpenAPI for event-driven architectures.
Not every breaking change is obvious from the wire format, so calibrate the gate to your own conventions. oasdiff ships a default rule set, but you can disable specific checks or change their severity when your API contract treats a change differently — for instance, if your clients are documented to tolerate new enum values, you may downgrade enum additions from a warning. Configure this with a checks file rather than by sprinkling exceptions through CI:
# Run with a custom severity map and emit machine-readable results for the gate.
oasdiff breaking spec-released.yaml openapi.yaml \
--severity-levels severity.txt --fail-on ERR --format json > breaking.json
Resist the urge to relax the gate to make a red build go green. Every downgraded check is a promise to your consumers that the change is safe; document why in the checks file so the next engineer understands the intent. A breaking-change gate that is quietly weakened over time is worse than no gate, because it grants false confidence to everyone who trusts the green check mark.
Versioning & release tagging
SDK versions should be derived, not chosen by hand. Feed the oasdiff verdict into a semantic-version decision: any breaking change forces a major bump, any new optional field or endpoint is a minor, and a docs-only or example change is a patch. Compute the next version in the pipeline and use it for the package version, the git tag, and the changelog header so all three agree.
# Derive the bump from the diff verdict, then tag deterministically.
if oasdiff breaking spec-released.yaml openapi.yaml --fail-on ERR; then
BUMP=$(oasdiff diff spec-released.yaml openapi.yaml --format json \
| jq -r 'if (.paths.added // {} | length) > 0 then "minor" else "patch" end')
else
BUMP="major" # breaking changes detected
fi
echo "Selected bump: $BUMP"
Tag the release once per spec version, not once per language, and reuse that tag as the next diff baseline. A tag like v2.4.0 becomes the baseline for the v2.5.0 diff. This closes the loop: each release is the reference point for measuring the next one, so the breaking-change gate and the changelog always compare against ground truth.
Complete release workflow
The following GitHub Actions workflow runs the whole pipeline on every push to the default branch: it lints, diffs, fails on breaking changes, generates a TypeScript SDK, writes a changelog fragment, and publishes to npm with a matching git tag. It is copy-paste ready; replace the package name and registry secret with your own.
# .github/workflows/sdk-release.yml
name: Generate, diff, and publish SDK
on:
push:
branches: [main]
paths: ['openapi.yaml']
permissions:
contents: write # create release tags
id-token: write # npm provenance
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # need tags for the baseline diff
- uses: actions/setup-node@v4
with:
node-version: '20'
registry-url: 'https://registry.npmjs.org'
- name: Lint spec
run: npx @redocly/cli@1 lint openapi.yaml --max-problems 0
- name: Resolve last released spec
run: |
LAST_TAG=$(git tag --sort=-v:refname | head -n1)
git show "${LAST_TAG}:openapi.yaml" > spec-released.yaml || cp openapi.yaml spec-released.yaml
- name: Install oasdiff
run: |
curl -sSfL https://raw.githubusercontent.com/oasdiff/oasdiff/main/install.sh \
| sh -s -- -b "$RUNNER_TEMP" v1.11.7
echo "$RUNNER_TEMP" >> "$GITHUB_PATH"
- name: Fail on breaking changes
run: oasdiff breaking spec-released.yaml openapi.yaml --fail-on ERR
- name: Compute version bump
id: bump
run: |
if oasdiff breaking spec-released.yaml openapi.yaml --fail-on ERR; then
ADDED=$(oasdiff diff spec-released.yaml openapi.yaml --format json | jq '.paths.added // {} | length')
[ "$ADDED" -gt 0 ] && echo "level=minor" >> "$GITHUB_OUTPUT" || echo "level=patch" >> "$GITHUB_OUTPUT"
else
echo "level=major" >> "$GITHUB_OUTPUT"
fi
- name: Generate changelog fragment
run: oasdiff changelog spec-released.yaml openapi.yaml --format markdown > CHANGELOG-fragment.md
- name: Generate TypeScript SDK
run: |
npx @openapitools/[email protected] version-manager set 7.7.0
npx @openapitools/openapi-generator-cli generate \
-i openapi.yaml -g typescript-axios -o ./sdks/typescript \
--additional-properties=npmName=@acme/api-client,supportsES6=true,withInterfaces=true
- name: Build, version, and publish
working-directory: ./sdks/typescript
run: |
npm version "${{ steps.bump.outputs.level }}" --no-git-tag-version
NEW_VERSION=$(node -p "require('./package.json').version")
echo "NEW_VERSION=$NEW_VERSION" >> "$GITHUB_ENV"
npm ci && npm run build
npm publish --access public --provenance
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Tag release
run: |
git tag "v${NEW_VERSION}"
git push origin "v${NEW_VERSION}"
The fetch-depth: 0 line is load-bearing: without full history the baseline-tag lookup returns nothing and the diff silently compares the spec against itself. Pin every action and CLI version so a generator update never changes output without a deliberate bump.
Common Pitfalls
Diffing against the wrong baseline. Comparing the spec to the default branch tip instead of the last released tag produces a changelog and a version bump that describe changes nobody shipped. Always resolve the baseline from a release tag — git show "${LAST_TAG}:openapi.yaml" — never from HEAD~1.
Unpinned generator versions. Running openapi-generator-cli without pinning the generator means a transitive update silently changes method signatures and breaks consumers. Pin it: npx @openapitools/openapi-generator-cli version-manager set 7.7.0 and commit openapitools.json.
Hand-editing generated SDKs. Patching a bug directly in the generated client guarantees the fix vanishes on the next regeneration. Fix it at the source — the spec, a custom template, or an .openapi-generator-ignore exclusion — so the change survives, as detailed in customizing OpenAPI Generator templates.
Treating new required request fields as non-breaking. Adding a required property to a request body breaks every existing caller, but it is easy to wave through as “just a new field.” Let oasdiff breaking classify it; it flags new required request properties as ERR by default.
Publishing all languages from one job. When the Go build fails in a single combined job, it aborts the TypeScript publish that was already green. Fan out one publish job per language so failures are isolated.
FAQ
Should I commit generated SDKs to the repository or build them in CI?
Commit the generated SDK source when you publish from a dedicated SDK repository so reviewers can diff the exact code that ships and consumers can browse it. Build ephemeral SDKs in CI only for compatibility checks that get discarded after the job, since storing throwaway output just adds noise.
How do I detect breaking changes between two OpenAPI versions automatically?
Run oasdiff breaking against the old and new specs in a pull-request job and fail the build on any ERR-level change. This catches removed endpoints, narrowed enums, and new required request fields before they reach published SDKs, and it pairs every intentional breaking change with a major version bump.
Can one toolchain generate SDKs for every language I need?
OpenAPI Generator covers the widest language matrix from a single CLI, while Fern and Speakeasy focus on a smaller set of idiomatic, hand-tuned outputs. Pick OpenAPI Generator for breadth and the managed tools for polish in TypeScript, Python, Go, and Java; many teams run both.
How should SDK versions relate to the API version?
Tag SDK releases with their own semantic version that tracks the spec’s info.version, bumping major when oasdiff reports a breaking change. Keep a per-language CHANGELOG so consumers can map an SDK release back to the API change that produced it.
Do I need a separate pipeline for AsyncAPI SDKs?
Event-driven SDKs use AsyncAPI generators rather than the OpenAPI toolchain, but the surrounding pipeline is identical. You still lint the spec, diff it for breaking changes, generate per-language code, and publish to the same registries.
Related
- OpenAPI Generator — the free, self-hosted CLI for the widest language matrix
- Fern — managed, idiomatic SDKs with built-in publishing
- Speakeasy — GitHub Action that opens SDK update PRs automatically
- Automated changelog tools — generate trustworthy changelogs from spec diffs
- OpenAPI & AsyncAPI Schema Authoring — produce the clean specs this pipeline consumes
- Developer Portal Frameworks & UI Setup — render the same spec into reference docs