Build Stainless SDK from OpenAPI spec changes #1660
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 }} |