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.

From merged spec change to published packages A merged specification change is diffed against the last release to produce a classification, which derives per-language versions. An annotated tag triggers three publish jobs for npm, PyPI and the Go proxy, followed by a verification job. spec merged to main diff vs last major/minor/patch derive versions per language tag sdk-v3.8.0 npm PyPI Go proxy no publish without a tag — the tag is the rollback point

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.

Long-lived token versus OIDC trusted publishing On the left a stored registry token lives indefinitely in repository secrets and can publish from anywhere it leaks. On the right the workflow requests a short-lived identity token that the registry exchanges for a credential valid only for this run. Stored registry token OIDC trusted publishing repo secret workflow run registry lifetime: until rotated leak = publish from anywhere workflow identity short-lived token scoped to this run registry lifetime: minutes bound to repo + workflow

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.

Release stages and the guarantee each one adds Classification derives the version, the tag freezes the input, publishing attaches provenance, and verification installs the published artifact before the release is announced. each stage adds a guarantee the next one depends on classify diff vs last release major or minor, derived tag annotated, immutable the rollback point publish OIDC, no stored token provenance attached verify install from theregistry smoke test in a cleanjob announcing before verification is how packaging failures reach consumers first

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.