Publishing Generated SDKs from GitHub Actions
This walkthrough belongs to Client SDK Generation Pipelines, part of the API Design Fundamentals & Architecture reference. Generation gives you code; publishing is what makes it an SDK. This page covers the release half — deriving versions from the spec diff, tagging, publishing to three ecosystems with no long-lived secrets, and verifying the artifact before anyone depends on it.
The decision trigger
| Symptom | Cause |
|---|---|
| Consumers on an SDK version that predates a required field | no automated publish; releases are manual and forgotten |
| A patch release contained a breaking method rename | version derived by hand, not from the spec diff |
| npm token leaked from a workflow log | long-lived registry secret instead of OIDC |
| The Go module resolves to an old commit | no semver tag pushed for the module path |
| Published package fails on install in a clean environment | published without a post-publish smoke test |
| Two languages published, one failed, both announced | no atomic gate before the announcement step |
Spec snippet: the release inputs
The publish job needs exactly three inputs, all derivable in CI:
# .github/release-inputs.yaml — conceptual, resolved by the workflow below
api_version: "4.2.0" # from info.version in the bundled spec
diff_class: "minor" # major | minor | patch, from the spec diff
sdk_versions: # each language's next version
typescript: "3.8.0"
python: "2.5.0"
go: "v1.9.0"
diff_class comes from comparing the merged spec with the one the last release was generated from — the mechanics are in Detecting Breaking Changes with oasdiff. Deriving the bump instead of typing it is what stops a breaking change from shipping as a patch.
Step-by-step
Step 1 — Derive the version bump from the diff
# .github/workflows/sdk-release.yml (part 1)
name: SDK release
on:
push:
tags: ["sdk-v*"]
permissions:
contents: read
id-token: write # required for OIDC trusted publishing
jobs:
classify:
runs-on: ubuntu-latest
outputs:
bump: ${{ steps.diff.outputs.bump }}
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- name: Diff the spec against the previous SDK tag
id: diff
run: |
PREV=$(git describe --tags --abbrev=0 --match 'sdk-v*' HEAD^ 2>/dev/null || echo "")
if [ -z "$PREV" ]; then echo "bump=minor" >> "$GITHUB_OUTPUT"; exit 0; fi
git show "$PREV:dist/openapi.yaml" > /tmp/previous.yaml
docker run --rm -v /tmp:/specs -v "$PWD/dist:/current" tufin/oasdiff:latest \
breaking /specs/previous.yaml /current/openapi.yaml --format json > /tmp/breaking.json || true
if [ "$(jq 'length' /tmp/breaking.json)" -gt 0 ]; then
echo "bump=major" >> "$GITHUB_OUTPUT"
else
echo "bump=minor" >> "$GITHUB_OUTPUT"
fi
Step 2 — Publish to npm with provenance
npm’s trusted publishing exchanges the workflow’s OIDC token for a short-lived credential, so no NPM_TOKEN is stored anywhere:
publish-npm:
needs: classify
runs-on: ubuntu-latest
permissions: { contents: read, id-token: write }
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
registry-url: "https://registry.npmjs.org"
- name: Set the derived version
working-directory: sdk/typescript
run: npm version "${{ needs.classify.outputs.bump }}" --no-git-tag-version
- name: Build
working-directory: sdk/typescript
run: npm ci && npm run build
- name: Publish
working-directory: sdk/typescript
run: npm publish --provenance --access public
--provenance attaches a signed statement linking the published tarball to this workflow run and commit, which is what lets a consumer verify the package was built from the source it claims.
Step 3 — Publish to PyPI without an API token
publish-pypi:
needs: classify
runs-on: ubuntu-latest
permissions: { contents: read, id-token: write }
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: { python-version: "3.12" }
- name: Build the distribution
working-directory: sdk/python
run: |
python -m pip install --upgrade build
python -m build
# Trusted publishing: configure the publisher once in the PyPI project settings.
- uses: pypa/gh-action-pypi-publish@release/v1
with:
packages-dir: sdk/python/dist
Step 4 — “Publish” the Go module
Go has no upload step: the module proxy fetches tags from the repository. A module in a subdirectory needs the path as a tag prefix:
# Module declared as module github.com/example/billing-sdk/go in sdk/go/go.mod
git tag -a "sdk/go/v1.9.0" -m "Billing SDK for Go 1.9.0 (API 4.2.0)"
git push origin "sdk/go/v1.9.0"
# Warm the proxy and confirm the version resolves
GOPROXY=proxy.golang.org go list -m github.com/example/billing-sdk/[email protected]
Step 5 — Verify before announcing
The most valuable job in the workflow installs what was actually published, in a clean environment, and exercises it:
verify:
needs: [publish-npm, publish-pypi]
runs-on: ubuntu-latest
steps:
- uses: actions/setup-node@v4
with: { node-version: "22" }
- name: Install the published package and smoke-test it
run: |
mkdir -p /tmp/smoke && cd /tmp/smoke && npm init -y >/dev/null
npm install "@example/billing@latest"
node -e '
const { Billing } = require("@example/billing");
const c = new Billing({ bearerAuth: "test" });
if (typeof c.invoices.getInvoice !== "function") {
throw new Error("published package is missing invoices.getInvoice");
}
console.log("smoke test passed");
'
This catches the classic packaging failures — files excluded from the tarball, a missing main/exports entry, a broken type declaration path — none of which show up when you test the local build directory.
Standard compliance
| Concern | Standard / rule | Note |
|---|---|---|
| Version numbering | Semantic Versioning 2.0.0 | Major for breaking client surface changes |
| Go module versions | Go Modules reference | Tag vX.Y.Z, prefixed with the module subdirectory |
| Python version strings | PEP 440 | 1.4.0, not v1.4.0 |
| npm scope + access | npm registry rules | Scoped packages need --access public on first publish |
| Package provenance | SLSA build provenance | --provenance requires id-token: write |
The PEP 440 row bites teams that reuse one version string across ecosystems: v1.4.0 is a valid git tag and a valid Go version but an invalid Python version, so derive each language’s string from the same numbers rather than copying one literal.
Read as one sequence, the workflow has a single property worth protecting: nothing reaches a registry that has not been classified, tagged and verified.
Common mistakes
| Mistake | Correct approach |
|---|---|
| Publishing on every push to the main branch | Publish on annotated tags only |
| Typing the version bump by hand | Derive it from the spec diff classification |
| Storing a long-lived registry token | Use OIDC trusted publishing with id-token: write |
| Announcing before verifying | Install the published artifact in a clean job and smoke-test it |
| One version string reused across ecosystems | Derive per-ecosystem strings (PEP 440 vs vX.Y.Z) |
| Tagging a Go submodule without the path prefix | Tag sdk/go/v1.9.0 so the proxy resolves it |
FAQ
How should the SDK version relate to the API version?
Keep them separate and cross-referenced. The API version identifies the wire contract and changes when that contract changes; the SDK version is a package version that also moves for packaging fixes, dependency bumps and generator upgrades that touch no endpoint. Tying them together forces meaningless releases in one dimension every time the other moves. Instead, record the API version each SDK release was generated from in the changelog and the package metadata, so a consumer debugging a mismatch can map between them in one step. See Semver Ranges for Generated SDK Clients for how consumers should then depend on those numbers.
Should SDKs publish on every merge to the main branch?
Publish on tags. A tag is immutable, names a specific commit, gives you a rollback target, and provides a natural approval gate before anything reaches a public registry. Publishing on merge means a typo fix in a description produces a version bump that every consumer’s dependency bot proposes, burning attention that should be reserved for real changes. If you want continuous availability for internal consumers, publish pre-release versions from the main branch to a private registry and keep tagged releases for the public one.
How do I publish a Go SDK, which has no registry to push to?
You publish by tagging. The Go module proxy fetches source from the repository on first request for a version, so a v1.9.0 tag on a public repo is the release. Two details matter: the tag must be a valid semver tag with the v prefix, and if the module lives in a subdirectory the tag must be prefixed with that path (sdk/go/v1.9.0) or the proxy will not associate it with the module. After tagging, request the version once through go list -m to warm the proxy so the first consumer does not pay the fetch latency.
Related
- Client SDK Generation Pipelines — up-link: the generation half of this pipeline
- API Design Fundamentals & Architecture — up-link: the parent reference for contract-first design
- OpenAPI Generator vs Kiota vs Speakeasy — choosing the generator whose output this workflow ships
- Detecting Breaking Changes with oasdiff — the diff that derives the version bump
- Lockfile Strategies for Generated Clients — how consumers pin what you publish