Automating Multi-Language API Clients from OpenAPI with GitHub Actions
At this point in the series, we’ve cleared the hardest hurdle: we can reliably generate a real-world OpenAPI specification from an ASP.NET REST API — even one that actually does things like write logs, persist data, participate in distributed workflows, or call large language models like OpenAI as part of backend logic.
Now it’s time to cash in on that work.
The whole reason we cared about build-time OpenAPI generation in the first place is that we want to generate client SDKs automatically whenever the backend changes. No more hand-maintained TypeScript plumbing in React. No more custom HttpClient wrappers in MAUI. Just merge backend changes into main, and updated clients are produced from the API contract.
This article is the final step: wiring up a GitHub Action that triggers on merge to main (and can be run manually too), generates the OpenAPI document, generates client SDKs in C# and TypeScript, and then opens a PR with the updated code.
The Workflow Trigger
We want this to run automatically when backend code changes land in main, but we also want the ability to run it manually if we’re testing changes or debugging generation. So we use both push with a paths filter and workflow_dispatch.
name: Generate API Clients
on:
push:
branches: ["main"]
paths:
- "src/dotnet/**"
- ".github/workflows/generate-clients.yml"
workflow_dispatch:
That paths filter matters: it prevents the workflow from running on unrelated changes, and also ensures edits to the workflow itself get exercised.
Three Codebases, One Pipeline
The reality is this automation isn’t operating on just one project. We have three distinct codebases involved:
- C# .NET REST API
- C# .NET Client SDK
- TypeScript Client SDK
So our GitHub Action needs to be able to locate all three, generate the OpenAPI spec from the API project, and then generate code into the correct client folders.
To keep things sane — and to make this workflow portable to other repos — I set up environment variables for all the naming choices Kiota cares about.
env:
SERVICE_NAME: Dinkline
SERVICE_NAMESPACE: Dinkline.Backend
DOTNET_CLASS_NAME: BackendApiClient
DOTNET_PACKAGE_NAME: Dinkline.Backend.Client
TYPESCRIPT_CLASS_NAME: BackendApiClient
TYPESCRIPT_PACKAGE_NAME: dinkline-backend-client
These names become your SDK namespace/package identity. Changing them here makes the workflow reusable without rewriting every job.
Job 1: Build the API and Produce the OpenAPI Artifact
Because we already set up build-time OpenAPI generation, the first job is basically: check out the repo, restore, and build the API solution. That build emits the OpenAPI JSON into a predictable folder.
We put this in its own job so the output can be shared by multiple downstream jobs.
jobs:
build-openapi:
runs-on: ubuntu-latest
environment: ci
permissions:
id-token: write
contents: read
steps:
- name: Checkout
uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Setup .NET
uses: actions/setup-dotnet@v5
with:
dotnet-version: "9.0.x"
- name: Restore
run: dotnet restore ./src/dotnet/$.Api.sln
- name: Build API (Release) + generate OpenAPI JSON
env:
OPENAPI_EXPORT: "true"
run: dotnet build ./src/dotnet/$.Api.sln --configuration Release --no-restore
Next we verify the spec exists and upload it as an artifact. I don’t like committing generated specs to the repo because it creates churn and conflicts, but as an artifact it’s perfect: you can download it from a run, verify it, and troubleshoot generation.
- name: Verify OpenAPI JSON exists
run: |
SPEC="src/dotnet/$.Api/openapi/$.Api.json"
test -f "$SPEC"
echo "OpenAPI spec found at: $SPEC"
- name: Upload OpenAPI spec artifact
uses: actions/upload-artifact@v4
with:
name: openapi-spec
path: src/dotnet/$.Api/openapi/$.Api.json
if-no-files-found: error
retention-days: 14
Now downstream jobs don’t need to rebuild the API or guess where the spec is. They just download the artifact.
Job 2: Generate the C# Client SDK
For code generation, I’m using Kiota, which can generate SDKs directly from an OpenAPI specification.
This job depends on build-openapi, downloads the spec artifact, then generates the C# client into src/client/dotnet.
generate-csharp:
runs-on: ubuntu-latest
needs: build-openapi
environment: ci
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Setup Kiota
uses: microsoft/setup-kiota@v0.5.0
with:
version: latest
- name: Kiota version
run: kiota --version
- name: Download OpenAPI spec artifact
uses: actions/download-artifact@v4
with:
name: openapi-spec
path: artifacts/openapi
- name: Generate C# client
run: |
SPEC="artifacts/openapi/$.Api.json"
OUT="src/client/dotnet"
rm -rf "$OUT"
mkdir -p "$OUT"
kiota generate \
-d "$SPEC" \
-l CSharp \
-c $ \
-n $ \
-o "$OUT"
- name: Upload generated C# client artifact
uses: actions/upload-artifact@v4
with:
name: generated-client-csharp
path: src/client/dotnet
if-no-files-found: error
retention-days: 14
At this point you have a generated .NET client SDK that can be used in a client-side MCP server, a MAUI app, or any other C# consumer.
Job 3: Generate the TypeScript Client SDK
The TypeScript version is almost identical: same spec, different output folder, and -l TypeScript.
generate-typescript:
runs-on: ubuntu-latest
needs: build-openapi
environment: ci
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Setup Kiota
uses: microsoft/setup-kiota@v0.5.0
with:
version: latest
- name: Kiota version
run: kiota --version
- name: Download OpenAPI spec artifact
uses: actions/download-artifact@v4
with:
name: openapi-spec
path: artifacts/openapi
- name: Generate TypeScript client
run: |
SPEC="artifacts/openapi/$.Api.json"
OUT="src/client/ts"
rm -rf "$OUT"
mkdir -p "$OUT"
kiota generate \
-d "$SPEC" \
-l TypeScript \
-c $ \
-n $ \
-o "$OUT"
- name: Upload generated TypeScript client artifact
uses: actions/upload-artifact@v4
with:
name: generated-client-typescript
path: src/client/ts
if-no-files-found: error
retention-days: 14
Now we have two artifacts representing the generated SDKs.
Job 4: Commit the Generated Clients and Open a PR
The last step is the part that makes this feel “real”: we take the generated artifacts, commit them to a bot branch, and open a pull request back to main.
That gives you a clean review surface for changes, and it keeps the generation process transparent. If the API change caused a breaking client change, it shows up as a diff you can inspect before it lands.
commit-and-pr:
runs-on: ubuntu-latest
needs: [generate-csharp, generate-typescript]
environment: ci
permissions:
id-token: write
contents: write
pull-requests: write
steps:
- name: Checkout
uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Download generated C# client artifact
uses: actions/download-artifact@v4
with:
name: generated-client-csharp
path: src/client/dotnet
- name: Download generated TypeScript client artifact
uses: actions/download-artifact@v4
with:
name: generated-client-typescript
path: src/client/ts
- name: Debug generated output
run: |
echo "=== dotnet client ==="
ls -la src/client/dotnet || true
echo "=== ts client ==="
ls -la src/client/ts || true
echo "=== git status ==="
git status --porcelain
- name: Configure git author
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
- name: Commit to bot branch (if changes)
run: |
git checkout -B bot/regenerate-api-clients
git add -A src/client/dotnet src/client/ts
if git diff --cached --quiet; then
echo "No generated changes to commit."
exit 0
fi
git commit -m "chore(client): regenerate API clients"
- name: Push bot branch (use PAT)
env:
GITHUB_PAT: $
run: |
git push -u "https://x-access-token:${GITHUB_PAT}@github.com/${GITHUB_REPOSITORY}.git" \
bot/regenerate-api-clients --force
- name: Create or update PR (use PAT)
env:
GH_TOKEN: $
run: |
gh pr view bot/regenerate-api-clients --repo "${GITHUB_REPOSITORY}" --json number --jq .number \
|| gh pr create \
--repo "${GITHUB_REPOSITORY}" \
--title "chore(client): regenerate API clients" \
--body "Automated regeneration of clients from OpenAPI." \
--base main \
--head bot/regenerate-api-clients
This is the part where you need elevated permissions. The default GITHUB_TOKEN often isn’t enough for the exact behavior you want (especially depending on branch protections and whether you need PR creation from a workflow). So we use a dedicated Personal Access Token.
That means creating a PAT with sufficient permissions to write to the repo and open PRs, saving it as a repo secret, and referencing it from the workflow as:
OPENAPI_GENERATOR_PAT
Conclusion
This is the payoff: once your API can emit OpenAPI documents on build — even in a messy real-world service with auth, telemetry, storage, databases, and background workers — you can automate SDK generation like it’s just another part of CI.
Merge to main produces an updated OpenAPI artifact. That artifact feeds Kiota. Kiota generates SDKs for C# and TypeScript. The workflow commits the results to a bot branch and opens a PR for review.
At that point, client plumbing stops being a chore and becomes a byproduct of your backend contract — and that’s exactly where it belongs.