The OpenAPI → npm publishing workflow is one of the most common tasks API teams do, and one of the least standardised. Most teams stitch together codegen + a build step + a manual npm publish, then forget how it works between releases. This post is the workflow that holds up: deterministic, dry-runnable, versioned, with breaking-change detection in the loop.
The five-step workflow
- Source of truth:
openapi.yamlin your API repo. One spec, owned by the team that owns the API. - Generate: spec → TypeScript SDK output in a separate SDK repo (or a subdirectory if you're confident).
- Diff: compare the new SDK's public surface against the last published version. Bump major if breaking.
- Build + dry-run publish: emit dist/, run
npm pack --dry-run, review the file list, runnpm publish --dry-run. - Tag + publish: tag the spec version that produced this SDK, then
npm publish.
Each step is cheap individually. The discipline is doing all five every release.
What "deterministic" actually means
A second run of the same workflow on the same spec should produce a byte-identical npm tarball. If it doesn't, you have hidden state somewhere — usually a generator version drift, a date stamp in a header, or a timestamp-based filename.
Things that break determinism:
- Pinning the generator with
^instead of an exact version. Pin to1.2.3, not^1.2.3. - Embedding
new Date().toISOString()in generated code. - Sorting maps in different orders across runs (Map iteration order can be insertion-order-dependent).
- Pulling templates from a network source at generate time.
Lock all of these and you can npm pack --dry-run twice in CI to assert the shasum matches before publishing.
A concrete CI pipeline
# .github/workflows/release.yml
name: Release SDK
on:
push:
branches: [main]
paths: ["openapi.yaml"]
jobs:
release:
runs-on: ubuntu-latest
permissions:
contents: write
id-token: write
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- name: Detect breaking changes
uses: oasdiff/oasdiff-action/breaking@main
with:
base: ${{ github.event.before }}:openapi.yaml
revision: openapi.yaml
fail-on: ERR
- name: Generate SDK
run: npx @openapitools/openapi-generator-cli@2.34.0 generate \
-i openapi.yaml -g typescript-fetch -o ./sdk
- name: Build
working-directory: ./sdk
run: |
npm ci
npm run build
- name: Dry-run publish
working-directory: ./sdk
run: npm publish --dry-run
- name: Publish
working-directory: ./sdk
run: npm publish --provenance --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Tag spec version
run: |
git tag "spec-$(jq -r .version sdk/package.json)"
git push --tags
The shape:
paths: ["openapi.yaml"]— only run when the spec changes.- Breaking change check before generation. Failing here costs nothing; failing after npm publish costs a deprecation.
- Pin the generator to an exact version (
@2.34.0shown — pin to whatever you tested against). Reproducibility matters more than getting the newest features for free. provenance: truesigns the package with the GitHub Actions OIDC identity. Customers in regulated environments increasingly require this.- Tag the spec version on the API repo so anyone can recover the exact OpenAPI that produced any published SDK.
Versioning the spec, the SDK, and the changelog together
A common confusion: which version do you bump? The SDK has its own semver (independent of the API). The spec has its own version (often matching the API).
Practical rule:
- The SDK version follows SDK-surface semver: breaking changes in method names, parameters, response types, error types. Bump major.
- The spec version follows API semver: breaking changes on the wire (removed endpoints, removed fields). Bump major.
- The API URL version (
/v1,/v2) follows the API-surface major.
These don't move together. A non-breaking spec change can produce a breaking SDK change if you renamed an operationId. A breaking spec change can produce a non-breaking SDK change if the SDK never exposed the affected surface.
Pin SDK ↔ spec by tagging the spec at publish time (spec-1.4.0) and including the spec sha in the SDK's package.json (a custom field, or a comment in the readme).
Common failure modes
Forgetting to bump. CI publishes with the previous version and 409s. Add a step that fails early if package.json version equals the last published version on npm.
Accidentally publishing test fixtures. Use files allowlist in package.json. Run npm pack --dry-run and review the file list manually for the first three releases until you trust the allowlist.
Provenance fails. Provenance requires OIDC tokens from a public CI. If you're on a private GitHub Actions runner, drop --provenance or move the publish step to a public runner.
Customer can't npm install. Almost always a missing exports map or a wrong main/module/types triple. See Publishing a TypeScript SDK to npm for the correct shape.
When to outsource the whole pipeline
If you're shipping more than 2 SDKs across more than 1 language, the value of running this in-house starts to evaporate. Hosted pipelines (Bloom, Stainless, Speakeasy) cover spec → SDK → diff → publish as one workflow and surface the diff visually in a dashboard.
With Bloom specifically: upload spec → preview SDK in the dashboard → see compatibility report against the last published version → publish to npm from the same workflow. No CI pipeline to maintain.
Want this end-to-end without writing CI?
Try Bloom free for 30 days — Bloom runs the spec → SDK → compatibility-report → publish flow on every spec push, with the SDK preview and breaking-change report visible in the dashboard before any npm release. Completely free for 30 days. No credit card required.