Skip to content

Build Stainless SDK from OpenAPI spec changes #1660

Build Stainless SDK from OpenAPI spec changes

Build Stainless SDK from OpenAPI spec changes #1660

name: Stainless SDK Builds
run-name: Build Stainless SDK from OpenAPI spec changes
# SECURITY NOTE: This workflow uses pull_request_target, which runs with access to
# secrets and a privileged GITHUB_TOKEN even for fork PRs.
#
# Security measures in place:
# 1. The preview and merge jobs only use Stainless actions that read OAS/config files
# without executing arbitrary code from the PR.
#
# 2. The integration tests are called with security flags:
# - matrix_json: Pre-defined matrix to skip generate_ci_matrix.py execution
# - disable_cache: Prevents cache poisoning
# - Composite actions in integration-tests.yml use full repo paths with pinned SHA
# so they're loaded from a trusted commit, not from PR checkout
#
# References:
# - https://securitylab.github.com/research/github-actions-preventing-pwn-requests/
on:
pull_request_target:
types:
- opened
- synchronize
- reopened
- closed
paths:
- "client-sdks/stainless/**"
- ".github/workflows/stainless-builds.yml" # this workflow
workflow_dispatch:
inputs:
pr_number:
description: 'PR number to run Stainless build for. Leave empty to force-upload the current OpenAPI spec from main.'
required: false
type: number
sdk_install_url:
description: 'Python SDK install URL (optional, for testing specific builds)'
required: false
type: string
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || inputs.pr_number || github.run_id }}
cancel-in-progress: true
env:
# Stainless organization name.
STAINLESS_ORG: llamastack
# Stainless project name.
STAINLESS_PROJECT: llama-stack-client
# Path to your OpenAPI spec.
OAS_PATH: ./client-sdks/stainless/openapi.yml
# Path to your Stainless config. Optional; only provide this if you prefer
# to maintain the ground truth Stainless config in your own repo.
CONFIG_PATH: ./client-sdks/stainless/config.yml
# When to fail the job based on build conclusion.
# Options: "never" | "note" | "warning" | "error" | "fatal".
FAIL_ON: error
# In your repo secrets, configure:
# - STAINLESS_API_KEY: a Stainless API key, which you can generate on the
# Stainless organization dashboard
jobs:
force-upload:
# Push the OpenAPI spec from main to Stainless without a PR. Triggered
# manually via workflow_dispatch with no pr_number. Skips preview and
# integration tests; just uploads the spec on the main branch.
if: github.event_name == 'workflow_dispatch' && inputs.pr_number == ''
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout main
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: main
- name: Upload OpenAPI spec to Stainless
uses: stainless-api/upload-openapi-spec-action/build@020053e7fbf853281174bd3029eb3bfa7a54c039 # 1.13.0
with:
stainless_api_key: ${{ secrets.STAINLESS_API_KEY }}
org: ${{ env.STAINLESS_ORG }}
project: ${{ env.STAINLESS_PROJECT }}
oas_path: ${{ env.OAS_PATH }}
config_path: ${{ env.CONFIG_PATH }}
fail_on: ${{ env.FAIL_ON }}
branch: main
make_comment: false
compute-branch:
if: github.event_name == 'pull_request_target' || (github.event_name == 'workflow_dispatch' && inputs.pr_number != '')
runs-on: ubuntu-latest
outputs:
preview_branch: ${{ steps.compute.outputs.preview_branch }}
base_branch: ${{ steps.compute.outputs.base_branch }}
merge_branch: ${{ steps.compute.outputs.merge_branch }}
pr_head_repo: ${{ steps.compute.outputs.pr_head_repo }}
pr_head_ref: ${{ steps.compute.outputs.pr_head_ref }}
pr_head_sha: ${{ steps.compute.outputs.pr_head_sha }}
pr_base_sha: ${{ steps.compute.outputs.pr_base_sha }}
pr_base_ref: ${{ steps.compute.outputs.pr_base_ref }}
pr_title: ${{ steps.compute.outputs.pr_title }}
is_fork_pr: ${{ steps.compute.outputs.is_fork_pr }}
steps:
- name: Fetch PR details for workflow_dispatch
if: github.event_name == 'workflow_dispatch'
id: fetch-pr
env:
GH_TOKEN: ${{ github.token }}
run: |
PR_DATA=$(gh pr view ${{ inputs.pr_number }} --repo ${{ github.repository }} --json headRefName,headRepository,headRefOid,baseRefName,baseRefOid,headRepositoryOwner,title)
echo "pr_data=$PR_DATA" >> "$GITHUB_OUTPUT"
- name: Compute branch names
id: compute
# Pass PR title via environment variable to prevent shell injection.
# Direct interpolation of ${{ github.event.pull_request.title }} into bash
# allows command substitution via backticks or $() in PR titles.
env:
PR_TITLE_FROM_EVENT: ${{ github.event.pull_request.title }}
HEAD_REF_FROM_EVENT: ${{ github.event.pull_request.head.ref }}
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
# Extract from fetched PR data (jq -r safely handles special characters)
PR_DATA='${{ steps.fetch-pr.outputs.pr_data }}'
FORK_OWNER=$(echo "$PR_DATA" | jq -r '.headRepositoryOwner.login')
REPO_NAME=$(echo "$PR_DATA" | jq -r '.headRepository.name')
HEAD_REPO="${FORK_OWNER}/${REPO_NAME}"
BRANCH_NAME=$(echo "$PR_DATA" | jq -r '.headRefName')
HEAD_SHA=$(echo "$PR_DATA" | jq -r '.headRefOid')
BASE_SHA=$(echo "$PR_DATA" | jq -r '.baseRefOid')
BASE_REF=$(echo "$PR_DATA" | jq -r '.baseRefName')
PR_TITLE=$(echo "$PR_DATA" | jq -r '.title')
else
# Use pull_request_target event data
HEAD_REPO="${{ github.event.pull_request.head.repo.full_name }}"
BRANCH_NAME="$HEAD_REF_FROM_EVENT"
FORK_OWNER="${{ github.event.pull_request.head.repo.owner.login }}"
HEAD_SHA="${{ github.event.pull_request.head.sha }}"
BASE_SHA="${{ github.event.pull_request.base.sha }}"
BASE_REF="${{ github.event.pull_request.base.ref }}"
# Use environment variable to prevent shell injection from PR titles
PR_TITLE="$PR_TITLE_FROM_EVENT"
fi
BASE_REPO="${{ github.repository }}"
if [ "$HEAD_REPO" != "$BASE_REPO" ]; then
# Fork PR: prefix with fork owner for isolation
if [ -z "$FORK_OWNER" ]; then
echo "Error: Fork PR detected but fork owner is empty" >&2
exit 1
fi
PREVIEW_BRANCH="preview/${FORK_OWNER}/${BRANCH_NAME}"
BASE_BRANCH="preview/base/${FORK_OWNER}/${BRANCH_NAME}"
IS_FORK_PR="true"
else
# Same-repo PR
PREVIEW_BRANCH="preview/${BRANCH_NAME}"
BASE_BRANCH="preview/base/${BRANCH_NAME}"
IS_FORK_PR="false"
fi
{
echo "preview_branch=${PREVIEW_BRANCH}"
echo "base_branch=${BASE_BRANCH}"
echo "merge_branch=${PREVIEW_BRANCH}"
echo "pr_head_repo=${HEAD_REPO}"
echo "pr_head_ref=${BRANCH_NAME}"
echo "pr_head_sha=${HEAD_SHA}"
echo "pr_base_sha=${BASE_SHA}"
echo "pr_base_ref=${BASE_REF}"
echo "pr_title=${PR_TITLE}"
echo "is_fork_pr=${IS_FORK_PR}"
} >> "$GITHUB_OUTPUT"
preview:
needs: compute-branch
# Skip preview if workflow_dispatch provides sdk_install_url, or if PR is being closed
if: |
(github.event_name == 'workflow_dispatch' && inputs.sdk_install_url == '') ||
(github.event_name == 'pull_request_target' && github.event.action != 'closed')
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
outputs:
sdk_install_url: ${{ fromJSON(steps.run-preview.outputs.outcomes || '{}').python.install_url || '' }}
steps:
# Checkout the PR's code to access the OpenAPI spec and config files.
# This is necessary to read the spec/config from the PR (including from forks).
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
repository: ${{ needs.compute-branch.outputs.pr_head_repo }}
ref: ${{ needs.compute-branch.outputs.pr_head_sha }}
fetch-depth: 2
- name: Run preview builds
id: run-preview
uses: stainless-api/upload-openapi-spec-action/preview@020053e7fbf853281174bd3029eb3bfa7a54c039 # 1.13.0
env:
PR_NUMBER: ${{ inputs.pr_number || github.event.pull_request.number }}
with:
stainless_api_key: ${{ secrets.STAINLESS_API_KEY }}
org: ${{ env.STAINLESS_ORG }}
project: ${{ env.STAINLESS_PROJECT }}
oas_path: ${{ env.OAS_PATH }}
config_path: ${{ env.CONFIG_PATH }}
fail_on: ${{ env.FAIL_ON }}
base_sha: ${{ needs.compute-branch.outputs.pr_base_sha }}
base_ref: ${{ needs.compute-branch.outputs.pr_base_ref }}
head_sha: ${{ needs.compute-branch.outputs.pr_head_sha }}
branch: ${{ needs.compute-branch.outputs.preview_branch }}
base_branch: ${{ needs.compute-branch.outputs.base_branch }}
commit_message: ${{ needs.compute-branch.outputs.pr_title }}
make_comment: true
run-integration-tests:
needs: [compute-branch, preview]
if: |
always() &&
(needs.preview.result == 'success' || needs.preview.result == 'skipped') &&
(github.event_name == 'workflow_dispatch' || github.event.action != 'closed')
uses: ./.github/workflows/integration-tests.yml
with:
# Use provided sdk_install_url from workflow_dispatch, or from preview build
sdk_install_url: ${{ inputs.sdk_install_url || needs.preview.outputs.sdk_install_url }}
# Hardcoded matrix avoids running generate_ci_matrix.py from the PR checkout
matrix_json: '{"include":[{"suite":"base","setup":"ollama","inference_mode":"record-if-missing"}]}'
# Disable caching to prevent cache poisoning from fork PRs
disable_cache: true
test-all-client-versions: false
pr_head_sha: ${{ needs.compute-branch.outputs.pr_head_sha }}
pr_head_ref: ${{ needs.compute-branch.outputs.pr_head_ref }}
is_fork_pr: ${{ needs.compute-branch.outputs.is_fork_pr == 'true' }}
merge:
needs: compute-branch
if: github.event_name == 'pull_request_target' && github.event.action == 'closed' && github.event.pull_request.merged == true
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps:
# Checkout the PR's code to access the OpenAPI spec and config files.
# This is necessary to read the spec/config from the PR (including from forks).
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
repository: ${{ needs.compute-branch.outputs.pr_head_repo }}
ref: ${{ needs.compute-branch.outputs.pr_head_sha }}
fetch-depth: 2
# Note that this only merges in changes that happened on the last build on
# the computed preview branch. It's possible that there are OAS/config
# changes that haven't been built, if the preview job didn't finish
# before this step starts. In theory we want to wait for all builds
# against the preview branch to complete, but assuming that
# the preview job happens before the PR merge, it should be fine.
- name: Run merge build
uses: stainless-api/upload-openapi-spec-action/merge@020053e7fbf853281174bd3029eb3bfa7a54c039 # 1.13.0
with:
stainless_api_key: ${{ secrets.STAINLESS_API_KEY }}
org: ${{ env.STAINLESS_ORG }}
project: ${{ env.STAINLESS_PROJECT }}
oas_path: ${{ env.OAS_PATH }}
config_path: ${{ env.CONFIG_PATH }}
fail_on: ${{ env.FAIL_ON }}
base_sha: ${{ needs.compute-branch.outputs.pr_base_sha }}
base_ref: ${{ needs.compute-branch.outputs.pr_base_ref }}
head_sha: ${{ needs.compute-branch.outputs.pr_head_sha }}
merge_branch: ${{ needs.compute-branch.outputs.merge_branch }}