|
| 1 | +# Speakeasy SDK Generation Workflow |
| 2 | +# |
| 3 | +# This workflow regenerates the Python SDK code using Speakeasy. |
| 4 | +# It can create a new PR, update an existing PR branch, or run in dry-run mode for validation. |
| 5 | +# |
| 6 | +# Triggers: |
| 7 | +# - On push to main: Auto-generates after every merge to ensure SDK stays up-to-date (auto-merge enabled) |
| 8 | +# - Daily schedule (6 AM UTC): Catches upstream API spec changes (auto-merge enabled) |
| 9 | +# - Manual workflow_dispatch: For on-demand generation |
| 10 | +# - Slash command (/generate): Regenerates and pushes results back to the PR branch |
| 11 | +# - workflow_call: For validation from other workflows (e.g., PR checks) |
| 12 | +# |
| 13 | +# Generation Process: |
| 14 | +# 1. Install Speakeasy CLI from pinned Docker image |
| 15 | +# 2. Run Speakeasy to generate the Python SDK code |
| 16 | +# 3. Run post-generation patches (currently no-op) |
| 17 | +# 4. (If PR context) Commit and push regenerated code back to the PR branch |
| 18 | +# 5. (If no PR context and not dry_run) Create a new PR with the regenerated code |
| 19 | +# 6. (If dry_run) Verify the generated code is valid |
| 20 | +# |
| 21 | +# How to use: |
| 22 | +# - From a PR: Comment `/generate` to regenerate and push to the PR branch |
| 23 | +# - From Actions: Go to Actions > Generate > Run workflow (creates a new PR) |
| 24 | +# - Optionally check "Dry run" to validate generation without committing |
| 25 | + |
| 26 | +name: Generate SDK |
| 27 | + |
| 28 | +"on": |
| 29 | + push: |
| 30 | + branches: |
| 31 | + - main |
| 32 | + schedule: |
| 33 | + - cron: '0 6 * * *' |
| 34 | + workflow_dispatch: |
| 35 | + inputs: |
| 36 | + dry_run: |
| 37 | + description: Validate generation without creating a PR |
| 38 | + type: boolean |
| 39 | + default: false |
| 40 | + pr: |
| 41 | + description: 'PR number (if set, pushes results to the PR branch instead of creating a new PR)' |
| 42 | + type: string |
| 43 | + required: false |
| 44 | + comment-id: |
| 45 | + description: 'Comment ID (for slash command triggers)' |
| 46 | + type: string |
| 47 | + required: false |
| 48 | + workflow_call: |
| 49 | + inputs: |
| 50 | + dry_run: |
| 51 | + description: Validate generation without creating a PR |
| 52 | + type: boolean |
| 53 | + default: false |
| 54 | + |
| 55 | +concurrency: |
| 56 | + group: ${{ (github.event_name == 'push' || github.event_name == 'schedule') && 'generate-new-pr' || format('generate-{0}', github.run_id) }} |
| 57 | + cancel-in-progress: true |
| 58 | + |
| 59 | +jobs: |
| 60 | + generate: |
| 61 | + name: Generate SDK |
| 62 | + runs-on: ubuntu-latest |
| 63 | + timeout-minutes: 30 |
| 64 | + permissions: |
| 65 | + contents: write |
| 66 | + pull-requests: write |
| 67 | + steps: |
| 68 | + - name: Authenticate as GitHub App |
| 69 | + if: ${{ !inputs.dry_run && github.event.inputs.pr != '' }} |
| 70 | + uses: actions/create-github-app-token@v3 |
| 71 | + id: get-app-token |
| 72 | + with: |
| 73 | + app-id: ${{ secrets.OCTAVIA_BOT_APP_ID }} |
| 74 | + private-key: ${{ secrets.OCTAVIA_BOT_PRIVATE_KEY }} |
| 75 | + |
| 76 | + - name: Post or append starting comment |
| 77 | + if: ${{ !inputs.dry_run && github.event.inputs.pr != '' }} |
| 78 | + id: start-comment |
| 79 | + uses: peter-evans/create-or-update-comment@v5 |
| 80 | + with: |
| 81 | + token: ${{ steps.get-app-token.outputs.token }} |
| 82 | + issue-number: ${{ github.event.inputs.pr }} |
| 83 | + comment-id: ${{ github.event.inputs.comment-id || '' }} |
| 84 | + body: | |
| 85 | + > **Generate SDK Job Info** |
| 86 | + > |
| 87 | + > Running Speakeasy SDK generation. |
| 88 | +
|
| 89 | + > Job started... [Check job output.](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) |
| 90 | +
|
| 91 | + - name: Resolve PR head branch |
| 92 | + if: ${{ !inputs.dry_run && github.event.inputs.pr != '' }} |
| 93 | + id: pr-branch |
| 94 | + env: |
| 95 | + GH_TOKEN: ${{ steps.get-app-token.outputs.token }} |
| 96 | + PR_NUMBER: ${{ github.event.inputs.pr }} |
| 97 | + run: | |
| 98 | + PR_JSON=$(gh api repos/${{ github.repository }}/pulls/${PR_NUMBER}) |
| 99 | + HEAD_REF=$(echo "$PR_JSON" | jq -r '.head.ref') |
| 100 | + IS_FORK=$(echo "$PR_JSON" | jq -r '.head.repo.fork') |
| 101 | + if [ "$IS_FORK" = "true" ]; then |
| 102 | + echo "::error::Cannot run /generate on fork PRs. Please regenerate locally." |
| 103 | + exit 1 |
| 104 | + fi |
| 105 | + echo "head_ref=${HEAD_REF}" >> $GITHUB_OUTPUT |
| 106 | +
|
| 107 | + - name: Checkout repository |
| 108 | + uses: actions/checkout@v4 |
| 109 | + with: |
| 110 | + fetch-depth: 0 |
| 111 | + ref: ${{ steps.pr-branch.outputs.head_ref || '' }} |
| 112 | + token: ${{ steps.get-app-token.outputs.token || github.token }} |
| 113 | + |
| 114 | + - name: Install uv |
| 115 | + uses: astral-sh/setup-uv@v5 |
| 116 | + |
| 117 | + - name: Set up Python |
| 118 | + uses: actions/setup-python@v5 |
| 119 | + with: |
| 120 | + python-version: '3.12' |
| 121 | + |
| 122 | + - name: Get next version from release drafter |
| 123 | + id: get-version |
| 124 | + uses: aaronsteers/semantic-pr-release-drafter@v1.1.0 |
| 125 | + with: |
| 126 | + dry-run: true |
| 127 | + env: |
| 128 | + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} |
| 129 | + |
| 130 | + - name: Install Speakeasy CLI |
| 131 | + run: | |
| 132 | + SPEAKEASY_IMAGE=$(yq '.services.speakeasy.image' .github/speakeasy/dummy-compose.yml) |
| 133 | + echo "Pinned Speakeasy image: $SPEAKEASY_IMAGE" |
| 134 | + docker pull "$SPEAKEASY_IMAGE" |
| 135 | + CONTAINER_ID=$(docker create "$SPEAKEASY_IMAGE") |
| 136 | + sudo docker cp "$CONTAINER_ID:/usr/local/bin/speakeasy" /usr/local/bin/speakeasy |
| 137 | + docker rm "$CONTAINER_ID" >/dev/null |
| 138 | + speakeasy --version |
| 139 | +
|
| 140 | + - name: Generate SDK with Speakeasy |
| 141 | + env: |
| 142 | + SPEAKEASY_API_KEY: ${{ secrets.SPEAKEASY_API_KEY }} |
| 143 | + VERSION: ${{ steps.get-version.outputs.resolved-version }} |
| 144 | + run: | |
| 145 | + if [ -z "$VERSION" ]; then |
| 146 | + echo "::error::Version resolution returned empty. Cannot proceed without an explicit version." |
| 147 | + exit 1 |
| 148 | + fi |
| 149 | + echo "Using version from release drafter: $VERSION" |
| 150 | + uvx --from=poethepoet poe generate-code |
| 151 | +
|
| 152 | + - name: Post-generation patching |
| 153 | + run: python3 scripts/post_generate.py |
| 154 | + |
| 155 | + - name: Verify generated code |
| 156 | + run: | |
| 157 | + if [ -f "pyproject.toml" ]; then |
| 158 | + echo "pyproject.toml found (v2 generator confirmed)" |
| 159 | + uv sync --no-install-project 2>/dev/null || true |
| 160 | + uv run python -c "import airbyte_api; print(f'SDK import OK: {airbyte_api.__name__}')" || echo "::warning::SDK import check failed" |
| 161 | + else |
| 162 | + echo "::warning::No pyproject.toml found. Generation may not have produced v2 output." |
| 163 | + fi |
| 164 | +
|
| 165 | + - name: Upload generated SDK as artifact |
| 166 | + if: ${{ inputs.dry_run }} |
| 167 | + uses: actions/upload-artifact@v4 |
| 168 | + with: |
| 169 | + name: generated_sdk_code |
| 170 | + path: | |
| 171 | + src/ |
| 172 | + pyproject.toml |
| 173 | + py.typed |
| 174 | + retention-days: 7 |
| 175 | + |
| 176 | + - name: Generation Summary |
| 177 | + run: | |
| 178 | + echo "=== Generation Summary ===" |
| 179 | + echo "Source files: $(find src/ -name '*.py' 2>/dev/null | wc -l)" |
| 180 | + echo "Model files: $(find src/ -path '*/models/*' -name '*.py' 2>/dev/null | wc -l)" |
| 181 | + if [ -f "pyproject.toml" ]; then |
| 182 | + echo "Package version: $(grep 'version' pyproject.toml | head -1)" |
| 183 | + fi |
| 184 | +
|
| 185 | + - name: Check for changes |
| 186 | + if: ${{ !inputs.dry_run }} |
| 187 | + id: changes |
| 188 | + run: | |
| 189 | + if [ -n "$(git status --porcelain)" ]; then |
| 190 | + echo "has_changes=true" >> $GITHUB_OUTPUT |
| 191 | + else |
| 192 | + echo "has_changes=false" >> $GITHUB_OUTPUT |
| 193 | + fi |
| 194 | +
|
| 195 | + # --- PR branch mode: commit and push to the existing PR branch --- |
| 196 | + - name: Push regenerated code to PR branch |
| 197 | + if: ${{ !inputs.dry_run && github.event.inputs.pr != '' && steps.changes.outputs.has_changes == 'true' }} |
| 198 | + run: | |
| 199 | + git config user.name "octavia-bot[bot]" |
| 200 | + git config user.email "octavia-bot[bot]@users.noreply.github.com" |
| 201 | + git add -A |
| 202 | + git commit -m "chore: regenerate SDK with Speakeasy" |
| 203 | + git push |
| 204 | +
|
| 205 | + # --- New PR mode: create a PR to main --- |
| 206 | + - name: Authenticate as GitHub App for PR creation |
| 207 | + if: ${{ !inputs.dry_run && steps.changes.outputs.has_changes == 'true' && github.event.inputs.pr == '' }} |
| 208 | + uses: actions/create-github-app-token@v3 |
| 209 | + id: get-pr-token |
| 210 | + with: |
| 211 | + app-id: ${{ secrets.OCTAVIA_BOT_APP_ID }} |
| 212 | + private-key: ${{ secrets.OCTAVIA_BOT_PRIVATE_KEY }} |
| 213 | + |
| 214 | + - name: Create Pull Request |
| 215 | + if: ${{ !inputs.dry_run && steps.changes.outputs.has_changes == 'true' && github.event.inputs.pr == '' }} |
| 216 | + id: create-pr |
| 217 | + uses: peter-evans/create-pull-request@v6 |
| 218 | + with: |
| 219 | + token: ${{ steps.get-pr-token.outputs.token }} |
| 220 | + commit-message: "chore: regenerate SDK with Speakeasy" |
| 221 | + title: "chore: regenerate SDK with Speakeasy" |
| 222 | + body: | |
| 223 | + This PR was automatically generated by the Speakeasy SDK generation workflow. |
| 224 | +
|
| 225 | + Please review the changes and merge if they look correct. |
| 226 | + branch: speakeasy-sdk-regen |
| 227 | + base: main |
| 228 | + delete-branch: true |
| 229 | + |
| 230 | + - name: Enable auto-merge (new PR only) |
| 231 | + if: | |
| 232 | + (github.event_name == 'push' |
| 233 | + || github.event_name == 'schedule' |
| 234 | + ) && steps.create-pr.outputs.pull-request-operation == 'created' |
| 235 | + env: |
| 236 | + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} |
| 237 | + run: gh pr merge ${{ steps.create-pr.outputs.pull-request-number }} --auto --squash |
| 238 | + |
| 239 | + - name: Append success comment |
| 240 | + if: ${{ success() && !inputs.dry_run && github.event.inputs.pr != '' }} |
| 241 | + uses: peter-evans/create-or-update-comment@v5 |
| 242 | + with: |
| 243 | + token: ${{ steps.get-app-token.outputs.token }} |
| 244 | + comment-id: ${{ steps.start-comment.outputs.comment-id }} |
| 245 | + reactions: hooray |
| 246 | + body: | |
| 247 | + > SDK generation completed successfully. |
| 248 | +
|
| 249 | + - name: Append failure comment |
| 250 | + if: ${{ failure() && !inputs.dry_run && github.event.inputs.pr != '' }} |
| 251 | + uses: peter-evans/create-or-update-comment@v5 |
| 252 | + with: |
| 253 | + token: ${{ steps.get-app-token.outputs.token }} |
| 254 | + comment-id: ${{ steps.start-comment.outputs.comment-id }} |
| 255 | + reactions: confused |
| 256 | + body: | |
| 257 | + > SDK generation failed. Check the [job output](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) for details. |
0 commit comments