Skip to Content

Automated SDK preview and breaking change detection for spec PRs

This guide walks through setting up a workflow where every pull request that modifies the OpenAPI spec automatically:

  • Generates SDKs for each target language (TypeScript, Python, Go, etc.)
  • Opens a PR in each downstream SDK repository showing the exact code changes
  • Posts a status comment on the spec PR linking to each SDK PR
  • Reports any breaking changes introduced by the spec modification
  • Auto-merges or closes SDK PRs when the spec PR is merged or closed

The end result: the team reviews a spec change and immediately sees how it affects every SDK, including whether the change breaks existing consumers.


Architecture overview

This pattern uses a multi-repo layout with two component types:

  • Spec repo (one) — holds the OpenAPI spec, triggers downstream generation
  • SDK repos (one per language) — each receives a trigger and generates code
spec-repo ├── specs/openapi.yaml ├── .speakeasy/workflow.yaml (registry source config) └── .github/workflows/ ├── trigger-downstream-sdk-generation.yaml └── reconcile-sdk-prs.yaml sdk-repo-python ├── .speakeasy/workflow.yaml (target + registry source) └── .github/workflows/ └── generate-sdk-from-spec.yaml sdk-repo-typescript ├── .speakeasy/workflow.yaml └── .github/workflows/ └── generate-sdk-from-spec.yaml

Flow:

  1. A developer opens a PR in the spec repo modifying specs/openapi.yaml.
  2. The spec repo workflow tags the new spec version in the Speakeasy registry.
  3. It then triggers workflow_dispatch in each downstream SDK repo, passing the spec tag.
  4. Each SDK repo generates code from the tagged spec, opens a PR with the diff, and Speakeasy annotates it with a changelog and breaking change report.
  5. The spec repo polls each downstream workflow and posts a summary comment on the original spec PR.
  6. When the spec PR is merged, a reconciliation workflow auto-merges the SDK PRs. If the spec PR is closed without merging, the SDK PRs are closed.

Prerequisites

  • A Speakeasy  account with a valid API key (see core concepts for an overview of how Speakeasy SDK generation works)
  • The Speakeasy CLI (speakeasy) installed in CI (the workflows handle this automatically)
  • Separate GitHub repositories for each SDK target
  • A fine-grained Personal Access Token (PAT) or GitHub App token with permissions to trigger workflows and manage PRs in the downstream SDK repos (see GitHub setup for details)

Step 1: Configure the spec repo

1.1 Speakeasy workflow configuration

Create .speakeasy/workflow.yaml in the spec repo. This tells Speakeasy where the spec lives and where to publish it in the registry (see the workflow file reference for all available options):

workflowVersion: 1.0.0 speakeasyVersion: latest sources: my-api: inputs: - location: specs/openapi.yaml registry: location: registry.speakeasyapi.dev/<org>/<workspace>/my-api targets: {}

Key points:

  • Replace <org>/<workspace> with the appropriate Speakeasy organization and workspace identifiers.
  • targets: {} is intentionally empty. The spec repo only publishes to the registry; the downstream SDK repos define their own targets.
  • When speakeasy run executes, it validates the spec and pushes a tagged version to the registry.

1.2 Trigger downstream SDK generation workflow

Create .github/workflows/trigger-downstream-sdk-generation.yaml:

trigger-downstream-sdk-generation.yaml

name: Trigger Downstream SDK Generation permissions: checks: write contents: write pull-requests: write statuses: write id-token: write issues: write on: pull_request: types: [opened, synchronize, reopened] # To avoid triggering on unrelated changes, scope this to the spec path: # paths: # - "specs/**" workflow_dispatch: jobs: # ------------------------------------------------------------------ # Job 1: Tag the spec in the Speakeasy registry # ------------------------------------------------------------------ update_spec_and_tag_registry: runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@v4 with: token: ${{ secrets.GITHUB_TOKEN }} ref: ${{ github.head_ref }} - name: Install Speakeasy CLI run: | curl -fsSL https://raw.githubusercontent.com/speakeasy-api/speakeasy/main/install.sh | sh echo "$HOME/.speakeasy/bin" >> $GITHUB_PATH - name: Run Speakeasy workflow env: SPEAKEASY_API_KEY: ${{ secrets.SPEAKEASY_API_KEY }} run: speakeasy run # ------------------------------------------------------------------ # Job 2: Fan out to each downstream SDK repo # ------------------------------------------------------------------ trigger_sdk_workflows: needs: update_spec_and_tag_registry runs-on: ubuntu-latest strategy: fail-fast: false matrix: include: - language: typescript owner: your-org repo: your-sdk-typescript - language: python owner: your-org repo: your-sdk-python # Add more SDK targets as needed: # - language: go # owner: your-org # repo: your-sdk-go steps: - name: Prepare branch tag id: tag run: | echo "name=$(echo "${{ github.head_ref }}" | sed 's|/|-|g')" >> $GITHUB_OUTPUT - name: Initialize status comment env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | language="${{ matrix.language }}" repo="${{ matrix.repo }}" owner="${{ matrix.owner }}" marker="<!-- sdk-generation-status-$language -->" comment_body="$marker ### ${language} SDK Generation | Repository | Workflow Run | Status | SDK PR | | --- | --- | --- | --- | | [$repo](https://github.com/$owner/$repo) | Pending | :hourglass_flowing_sand: Pending | Pending |" existing_comment_id=$(gh api \ "repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/comments" \ --jq "map(select(.body | contains(\"$marker\"))) | last | .id // empty") if [[ -n "$existing_comment_id" ]]; then gh api --method PATCH \ "repos/${{ github.repository }}/issues/comments/$existing_comment_id" \ -f body="$comment_body" > /dev/null echo "SDK_COMMENT_ID=$existing_comment_id" >> $GITHUB_ENV else new_comment_id=$(gh api --method POST \ "repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/comments" \ -f body="$comment_body" --jq '.id') echo "SDK_COMMENT_ID=$new_comment_id" >> $GITHUB_ENV fi - name: Trigger ${{ matrix.language }} SDK generation env: GH_TOKEN: ${{ secrets.DOWNSTREAM_SDK_TOKEN }} run: | owner="${{ matrix.owner }}" repo="${{ matrix.repo }}" language="${{ matrix.language }}" branch="${{ github.head_ref }}" tag_name="${{ steps.tag.outputs.name }}" gh workflow run generate-sdk-from-spec.yaml \ --repo "$owner/$repo" --ref main \ --field force="true" \ --field feature_branch="$branch" \ --field environment="TAG=$tag_name" sleep 10 run_id=$(gh run list --repo "$owner/$repo" \ --workflow generate-sdk-from-spec.yaml \ --limit 1 --json databaseId --jq '.[0].databaseId') if [[ -z "$run_id" ]]; then echo "Failed to get run ID" exit 1 fi echo "WORKFLOW_RUN_ID=$run_id" >> $GITHUB_ENV run_url="https://github.com/$owner/$repo/actions/runs/$run_id" echo "WORKFLOW_RUN_URL=$run_url" >> $GITHUB_ENV marker="<!-- sdk-generation-status-$language -->" pending_body="$marker ### ${language} SDK Generation | Repository | Workflow Run | Status | SDK PR | | --- | --- | --- | --- | | [$repo](https://github.com/$owner/$repo) | [Workflow run]($run_url) | :hourglass_flowing_sand: In Progress | Pending |" GH_TOKEN="${{ secrets.GITHUB_TOKEN }}" gh api --method PATCH \ "repos/${{ github.repository }}/issues/comments/$SDK_COMMENT_ID" \ -f body="$pending_body" > /dev/null - name: Poll workflow status env: GH_TOKEN: ${{ secrets.DOWNSTREAM_SDK_TOKEN }} run: | owner="${{ matrix.owner }}" repo="${{ matrix.repo }}" run_id="$WORKFLOW_RUN_ID" while true; do resp=$(gh api "repos/$owner/$repo/actions/runs/$run_id") status=$(echo "$resp" | jq -r '.status') conclusion=$(echo "$resp" | jq -r '.conclusion') if [[ "$status" == "completed" ]]; then if [[ "$conclusion" == "success" ]]; then echo "WORKFLOW_SUCCESS=true" >> $GITHUB_ENV else echo "WORKFLOW_SUCCESS=false" >> $GITHUB_ENV fi break fi sleep 30 done - name: Finalize PR comment if: always() env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} SDK_TOKEN: ${{ secrets.DOWNSTREAM_SDK_TOKEN }} run: | language="${{ matrix.language }}" repo="${{ matrix.repo }}" owner="${{ matrix.owner }}" branch="${{ github.head_ref }}" marker="<!-- sdk-generation-status-$language -->" pr_url=$(GH_TOKEN="$SDK_TOKEN" gh pr list \ --repo "$owner/$repo" --head "$branch" \ --json url --jq '.[0].url' 2>/dev/null || echo "") if [[ "$WORKFLOW_SUCCESS" == "true" ]]; then status_cell=":white_check_mark: Success" else status_cell=":x: Failed" fi run_cell="[Workflow run]($WORKFLOW_RUN_URL)" if [[ -n "$pr_url" && "$pr_url" != "null" ]]; then pr_cell="[SDK PR]($pr_url)" else compare_url="https://github.com/$owner/$repo/compare/main...$branch" pr_cell="[Review Changes]($compare_url)" fi comment_body="$marker ### ${language} SDK Generation | Repository | Workflow Run | Status | SDK PR | | --- | --- | --- | --- | | [$repo](https://github.com/$owner/$repo) | $run_cell | $status_cell | $pr_cell |" gh api --method PATCH \ "repos/${{ github.repository }}/issues/comments/$SDK_COMMENT_ID" \ -f body="$comment_body" if [[ "$WORKFLOW_SUCCESS" != "true" ]]; then exit 1 fi

1.3 Reconcile SDK PRs workflow

Create .github/workflows/reconcile-sdk-prs.yaml. This handles the lifecycle of downstream SDK PRs when the spec PR is resolved:

reconcile-sdk-prs.yaml

name: Reconcile SDK PRs permissions: contents: read pull-requests: read on: pull_request: types: [closed] jobs: manage_sdk_prs: runs-on: ubuntu-latest strategy: fail-fast: false matrix: include: - language: typescript owner: your-org repo: your-sdk-typescript - language: python owner: your-org repo: your-sdk-python steps: - name: Manage ${{ matrix.language }} SDK PR env: GH_TOKEN: ${{ secrets.DOWNSTREAM_SDK_TOKEN }} run: | branch_name="${{ github.event.pull_request.head.ref }}" owner="${{ matrix.owner }}" repo="${{ matrix.repo }}" was_merged="${{ github.event.pull_request.merged }}" pr_data=$(gh pr list --repo "$owner/$repo" \ --head "$branch_name" \ --json number,title,url --jq '.[0]' 2>/dev/null || echo "null") if [[ "$pr_data" != "null" && -n "$pr_data" ]]; then pr_number=$(echo "$pr_data" | jq -r '.number') pr_title=$(echo "$pr_data" | jq -r '.title') if [[ "$was_merged" == "true" ]]; then echo "Merging SDK PR #$pr_number..." gh pr merge "$pr_number" --repo "$owner/$repo" \ --squash --delete-branch else echo "Closing SDK PR #$pr_number..." gh pr close "$pr_number" --repo "$owner/$repo" \ --comment "Closing: source spec PR was closed without merging." fi else echo "No matching PR found for branch '$branch_name'." fi

Step 2: Configure each downstream SDK repo

Each SDK repo needs two things: a Speakeasy workflow config and a GitHub Actions workflow that can be triggered remotely.

2.1 Speakeasy workflow configuration

Create .speakeasy/workflow.yaml in each SDK repo. This tells Speakeasy to pull the spec from the registry (tagged by the spec repo) and generate code for the appropriate language.

Python example:

workflowVersion: 1.0.0 speakeasyVersion: latest sources: my-api: inputs: - location: registry.speakeasyapi.dev/<org>/<workspace>/my-api:${TAG:-latest} registry: location: registry.speakeasyapi.dev/<org>/<workspace>/my-api targets: python: target: python source: my-api output: .

TypeScript example:

workflowVersion: 1.0.0 speakeasyVersion: latest sources: my-api: inputs: - location: registry.speakeasyapi.dev/<org>/<workspace>/my-api:${TAG:-latest} registry: location: registry.speakeasyapi.dev/<org>/<workspace>/my-api targets: typescript: target: typescript source: my-api output: .

The ${TAG:-latest} variable is the key mechanism. When the spec repo triggers generation, it passes the branch-specific tag (e.g., feature-add-users-endpoint), so the SDK is generated from exactly the spec version in the PR. When TAG is not set (e.g., on a daily schedule), it falls back to latest.

2.2 SDK generation workflow

Create .github/workflows/generate-sdk-from-spec.yaml in each SDK repo:

generate-sdk-from-spec.yaml

name: Generate SDK from Spec on: workflow_dispatch: inputs: force: description: "Force SDK regeneration" required: false default: "false" type: string feature_branch: description: "Branch for SDK changes" required: false type: string environment: description: "Environment variables (e.g., TAG=branch-name)" required: false type: string schedule: - cron: "0 0 * * *" permissions: contents: write pull-requests: write checks: write statuses: write jobs: generate: uses: speakeasy-api/sdk-generation-action/.github/workflows/workflow-executor.yaml@v15 with: mode: pr force: ${{ inputs.force || 'false' }} feature_branch: ${{ inputs.feature_branch }} environment: ${{ inputs.environment }} secrets: github_access_token: ${{ secrets.GITHUB_TOKEN }} speakeasy_api_key: ${{ secrets.SPEAKEASY_API_KEY }}

This workflow:

  • Accepts a feature_branch input so the SDK PR is created on a branch that matches the spec PR branch name (enabling the reconciliation workflow to find it later).
  • Accepts an environment input to pass the TAG variable, which the Speakeasy workflow config uses to pull the correct spec version.
  • Uses mode: pr so changes are opened as a pull request rather than pushed directly.
  • Also runs on a daily schedule to catch any registry updates outside of the PR flow.

Step 3: Set up secrets

Spec repo secrets

SecretPurpose
SPEAKEASY_API_KEYAuthenticates with the Speakeasy platform for registry tagging
DOWNSTREAM_SDK_TOKENFine-grained PAT (or GitHub App token) that can trigger workflow_dispatch and manage PRs in the downstream SDK repos

Downstream SDK repo secrets

SecretPurpose
SPEAKEASY_API_KEYAuthenticates with the Speakeasy platform for SDK generation

The DOWNSTREAM_SDK_TOKEN needs the following permissions on each downstream SDK repo:

  • Actions: Read and write (to trigger workflows and poll status)
  • Contents: Read and write (for branch creation)
  • Pull requests: Read and write (to create, merge, and close PRs)

Step 4: Team visibility

On the spec PR

When a developer opens a PR that modifies the OpenAPI spec, the workflow posts one comment per SDK target:

### typescript SDK Generation | Repository | Workflow Run | Status | SDK PR | | --- | --- | --- | --- | | your-sdk-typescript | Workflow run | ✅ Success | SDK PR |

Each “SDK PR” link takes the reviewer directly to the downstream SDK repo’s pull request, where they can inspect the generated code diff.

On each SDK PR

Speakeasy automatically annotates each SDK PR with:

  • OpenAPI change report — A summary of what changed in the spec (new endpoints, modified schemas, removed fields, etc.) with a link to a detailed report on the Speakeasy dashboard.
  • Breaking change detection — Explicit callout of any changes that would break existing SDK consumers (removed fields, type changes, required parameter additions, etc.). See breaking changes for more on how Speakeasy handles these.
  • Linting report — Spec quality and style issues surfaced during generation. See linting for configuration options.
  • Version bump recommendation — Whether the change warrants a patch, minor, or major version bump based on the nature of the modifications. See versioning for how Speakeasy determines version bumps.
  • Generated changelog — A human-readable summary of what changed in the SDK code. See SDK changelogs for details.

Reviewers can also use the “Files changed” tab on the SDK PR to see the exact diff of every generated file, giving full visibility into how a spec change translates to SDK code.


Step 5: Adding more SDK targets

To add a new language (e.g., Go, Java, Ruby):

  1. Create the downstream SDK repo with the same structure: .speakeasy/workflow.yaml targeting the new language and .github/workflows/generate-sdk-from-spec.yaml.

  2. Add the target to the matrix in both spec repo workflows:

    In trigger-downstream-sdk-generation.yaml:

    matrix: include: # ... existing targets ... - language: go owner: your-org repo: your-sdk-go

    In reconcile-sdk-prs.yaml:

    matrix: include: # ... existing targets ... - language: go owner: your-org repo: your-sdk-go
  3. Add SPEAKEASY_API_KEY as a secret in the new repo.

  4. Ensure DOWNSTREAM_SDK_TOKEN has permissions for the new repo.


How breaking change detection works

Speakeasy tracks breaking changes at two levels:

  1. OpenAPI spec level — When the spec is tagged in the registry, Speakeasy compares it against the previous version and identifies breaking changes according to OpenAPI compatibility rules:

    • Removed endpoints or operations
    • Required parameters added to existing operations
    • Response schema fields removed or type-changed
    • Authentication requirements changed
    • Enum values removed
  2. SDK level — The generated SDK PR reflects these as concrete code changes: removed methods, changed function signatures, modified type definitions. Reviewers see the actual impact on the public API surface of each SDK.

The breaking change report appears in the SDK PR description and is also accessible via the Speakeasy dashboard link. This gives both a high-level summary (“1 breaking change detected”) and a detailed breakdown of what changed and why it’s breaking. For a deeper look at how Speakeasy classifies and surfaces breaking changes, see the breaking changes documentation.

Last updated on