SDK Generation #20
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: SDK Generation | |
| # Active generator: openapi-python-client (OSS). The Speakeasy pipeline is | |
| # dormant in speakeasy_generation.yaml (free tier allows one generated SDK | |
| # per workspace; convoy.js holds that slot). | |
| # | |
| # Keeps the same workflow filename and dispatch inputs as the Speakeasy | |
| # version so the frain-dev/convoy dispatcher (speakeasy-sdk.yml) works | |
| # unchanged. | |
| on: | |
| workflow_dispatch: | |
| inputs: | |
| force: | |
| # Accepted for dispatcher compatibility. Generation is deterministic | |
| # from the spec, so there is nothing to force: no diff means no PR. | |
| description: Accepted for compatibility; regeneration is always run | |
| required: false | |
| default: "false" | |
| type: string | |
| feature_branch: | |
| description: Branch for SDK changes | |
| required: false | |
| type: string | |
| schedule: | |
| - cron: "0 6 * * 1" | |
| # Serialize generations: overlapping cron/dispatch runs race on the same | |
| # branch/PR. Queue instead of cancel so a triggered regen is never dropped. | |
| concurrency: | |
| group: sdk-generation | |
| cancel-in-progress: false | |
| permissions: | |
| contents: write | |
| pull-requests: write | |
| jobs: | |
| generate: | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Mint bot app token | |
| id: bot-token | |
| # Pin to commit SHA for v1 (mutable tags can be retargeted). | |
| uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1 | |
| with: | |
| app-id: ${{ secrets.SDK_BOT_APP_ID }} | |
| private-key: ${{ secrets.SDK_BOT_APP_KEY }} | |
| - name: Checkout Code | |
| uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 | |
| with: | |
| # App token, not GITHUB_TOKEN: PRs opened with GITHUB_TOKEN do not | |
| # trigger pull_request workflows, so verify CI would never run on | |
| # them. The convoy-sdk-bot app token triggers CI like a PAT. | |
| token: ${{ steps.bot-token.outputs.token }} | |
| - name: Setup Python | |
| uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 | |
| with: | |
| python-version: "3.11" | |
| - name: Install generator | |
| # Pin both so regeneration output is reproducible; ruff is the | |
| # generator's post-processing formatter. | |
| run: pip install openapi-python-client==0.29.0 ruff==0.15.22 | |
| - name: Regenerate client | |
| run: ./scripts/generate.sh | |
| - name: Detect changes | |
| id: diff | |
| run: | | |
| if git diff --quiet && [ -z "$(git status --porcelain)" ]; then | |
| echo "changed=false" >> "$GITHUB_OUTPUT" | |
| echo "No client changes; skipping PR." >> "$GITHUB_STEP_SUMMARY" | |
| else | |
| echo "changed=true" >> "$GITHUB_OUTPUT" | |
| fi | |
| - name: Prepare feature branch | |
| if: steps.diff.outputs.changed == 'true' | |
| id: branch | |
| env: | |
| # Never interpolate free-form dispatch inputs into run: directly. | |
| FEATURE_BRANCH_INPUT: ${{ inputs.feature_branch }} | |
| run: | | |
| if [ -n "$FEATURE_BRANCH_INPUT" ]; then | |
| # SDK PRs must come from a reviewable feature branch, never a | |
| # protected ref or an option-looking / metacharacter name. | |
| # refs/* is blocked too: "refs/heads/main" would bypass the | |
| # literal main check and force-push the default branch. | |
| case "$FEATURE_BRANCH_INPUT" in | |
| main|master|release/*|refs/*|-*|*[!a-zA-Z0-9._/-]*) | |
| echo "::error::Invalid feature_branch '$FEATURE_BRANCH_INPUT'" | |
| exit 1 | |
| ;; | |
| esac | |
| echo "name=$FEATURE_BRANCH_INPUT" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "name=sdk-regen-$(date -u +%Y%m%d)" >> "$GITHUB_OUTPUT" | |
| fi | |
| - name: Push branch and open PR | |
| if: steps.diff.outputs.changed == 'true' | |
| env: | |
| GH_TOKEN: ${{ steps.bot-token.outputs.token }} | |
| BRANCH: ${{ steps.branch.outputs.name }} | |
| run: | | |
| git config user.name "convoy-sdk-bot[bot]" | |
| git config user.email "307218117+convoy-sdk-bot[bot]@users.noreply.github.com" | |
| git checkout -B "$BRANCH" | |
| git add -A | |
| git commit -m "feat: regenerate API client from OpenAPI spec" | |
| # Force push is safe: the regen branch is fully derived from main | |
| # plus this deterministic generation; any previous content is stale. | |
| git push --force origin "$BRANCH" | |
| existing=$(gh pr list --head "$BRANCH" --state open --json number --jq '.[0].number // empty') | |
| if [ -z "$existing" ]; then | |
| gh pr create \ | |
| --head "$BRANCH" \ | |
| --title "feat: regenerate API client from OpenAPI spec" \ | |
| --body "Automated regeneration via openapi-python-client from \`docs/v3/openapi3.yaml\` on frain-dev/convoy main. Hand-written webhook verify (\`src/convoy/utils/\`) is untouched by the sync script." | |
| echo "Opened PR for $BRANCH" >> "$GITHUB_STEP_SUMMARY" | |
| else | |
| echo "Updated existing PR #$existing" >> "$GITHUB_STEP_SUMMARY" | |
| fi | |
| - name: Mint reviewer app token | |
| if: steps.diff.outputs.changed == 'true' | |
| id: app-token | |
| # Pin to commit SHA for v1 (mutable tags can be retargeted). | |
| uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1 | |
| with: | |
| app-id: ${{ secrets.SDK_REVIEWER_APP_ID }} | |
| private-key: ${{ secrets.SDK_REVIEWER_APP_KEY }} | |
| - name: Approve and enable auto-merge | |
| if: steps.diff.outputs.changed == 'true' | |
| env: | |
| APP_TOKEN: ${{ steps.app-token.outputs.token }} | |
| # Merge via the bot app token, not GITHUB_TOKEN: merges performed | |
| # by GITHUB_TOKEN do not trigger the downstream publish workflows. | |
| MERGE_TOKEN: ${{ steps.bot-token.outputs.token }} | |
| GH_REPO: ${{ github.repository }} | |
| BRANCH: ${{ steps.branch.outputs.name }} | |
| run: | | |
| # Failure policy: fail open to human review, never to merge. Any | |
| # gate miss below (wrong base, stale head, non-generated paths) | |
| # leaves the PR unapproved for a human. Clearing a stale auto-merge | |
| # is the one hard failure: if --disable-auto errors, this step goes | |
| # red instead of leaving a rejected head silently mergeable. | |
| set -euo pipefail | |
| deny() { | |
| # Auto-merge is cleared before commenting: under set -e a comment | |
| # failure must not skip the disable, and a disable failure must | |
| # fail the step, not be swallowed. | |
| enabled=$(GH_TOKEN="$APP_TOKEN" gh pr view "$pr" --json autoMergeRequest --jq '.autoMergeRequest != null') | |
| if [ "$enabled" = "true" ]; then | |
| GH_TOKEN="$MERGE_TOKEN" gh pr merge "$pr" --disable-auto | |
| fi | |
| printf '%s\n' "$1" | GH_TOKEN="$APP_TOKEN" gh pr comment "$pr" --body-file - || true | |
| exit 0 | |
| } | |
| pr_json=$(GH_TOKEN="$APP_TOKEN" gh pr list --head "$BRANCH" --state open \ | |
| --json number,baseRefName,headRefOid --jq '.[0] // empty') | |
| if [ -z "$pr_json" ]; then | |
| echo "No open PR for $BRANCH; nothing to approve." | |
| exit 0 | |
| fi | |
| pr=$(echo "$pr_json" | jq -r .number) | |
| base=$(echo "$pr_json" | jq -r .baseRefName) | |
| head_sha=$(echo "$pr_json" | jq -r .headRefOid) | |
| if [ "$base" != "main" ]; then | |
| echo "Not approving PR #$pr: base is $base, not main." | |
| deny "SDK reviewer app: not auto-approving; the PR base is \`$base\`, not \`main\`. Left for human review (policy: fail open to human review, never to merge)." | |
| fi | |
| # Only regen PRs authored by the bot app qualify for auto-review. | |
| # REST is used because it returns the stable "convoy-sdk-bot[bot]" | |
| # login for app-authored PRs. | |
| author=$(GH_TOKEN="$APP_TOKEN" gh api "repos/$GH_REPO/pulls/$pr" --jq '.user.login') | |
| if [ "$author" != "convoy-sdk-bot[bot]" ]; then | |
| echo "Not approving PR #$pr: author is $author, not convoy-sdk-bot[bot]." | |
| deny "SDK reviewer app: not auto-approving; PR author is not convoy-sdk-bot[bot]. Left for human review (policy: fail open to human review, never to merge)." | |
| fi | |
| # Bind the approval to the commit this run pushed; a concurrent | |
| # push to the regen branch means the diff is no longer this run's | |
| # generated output. | |
| pushed_sha=$(git rev-parse HEAD) | |
| if [ "$head_sha" != "$pushed_sha" ]; then | |
| echo "Not approving PR #$pr: head $head_sha is not the commit this run pushed." | |
| deny "SDK reviewer app: not auto-approving; the PR head is not the commit this generation run pushed. Left for human review (policy: fail open to human review, never to merge)." | |
| fi | |
| # This run committed a real diff, so an empty changed-files listing | |
| # is an API anomaly, not a clean PR; it must not count as an | |
| # allowlist pass. | |
| files=$(GH_TOKEN="$APP_TOKEN" gh api "repos/$GH_REPO/pulls/$pr/files" --paginate \ | |
| --jq '.[] | .filename, (.previous_filename // empty)') | |
| if [ -z "$files" ]; then | |
| echo "Not approving PR #$pr: changed-files listing came back empty." | |
| deny "SDK reviewer app: not auto-approving; the changed-files listing came back empty for a non-empty regen commit. Left for human review (policy: fail open to human review, never to merge)." | |
| fi | |
| # Allowlist mirrors scripts/generate.sh: the generator mirrors into | |
| # src/convoy/ but never touches the hand-written utils/ or py.typed. | |
| # Renames are checked on both sides so a file cannot be moved into | |
| # the generated tree from outside it. | |
| bad=$(printf '%s\n' "$files" \ | |
| | while read -r f; do | |
| case "$f" in | |
| src/convoy/utils/*|src/convoy/py.typed) echo "$f" ;; | |
| src/convoy/*) ;; | |
| *) echo "$f" ;; | |
| esac | |
| done) | |
| if [ -n "$bad" ]; then | |
| echo "Not approving PR #$pr: diff touches non-generated paths:" | |
| echo "$bad" | |
| deny "$(printf '%s\n' \ | |
| "SDK reviewer app: not auto-approving; the diff touches paths outside the generated allowlist:" \ | |
| "" '```' "$bad" '```' "" \ | |
| "Left for human review (policy: fail open to human review, never to merge).")" | |
| fi | |
| review_id=$(GH_TOKEN="$APP_TOKEN" gh api -X POST "repos/$GH_REPO/pulls/$pr/reviews" \ | |
| -f event=APPROVE \ | |
| -f commit_id="$head_sha" \ | |
| -f body="SDK reviewer app: approving a generated-paths-only diff on the regen branch. Auto-merge completes only after required status checks pass." \ | |
| --jq '.id') | |
| # Close the validate-then-approve race: a push landing between the | |
| # allowlist check and the approval is not covered by | |
| # dismiss_stale_reviews (that only dismisses on pushes after the | |
| # review), so re-read the head and dismiss our own approval if it | |
| # moved off the validated commit. | |
| now_sha=$(GH_TOKEN="$APP_TOKEN" gh pr view "$pr" --json headRefOid --jq '.headRefOid') | |
| if [ "$now_sha" != "$head_sha" ]; then | |
| echo "Head moved from $head_sha to $now_sha during approval; dismissing review." | |
| # Best-effort: dismiss_stale_reviews normally dismissed this | |
| # approval already when the head moved. The hard gate is deny | |
| # below, which clears auto-merge or fails the step; a dismiss | |
| # error must not skip it. | |
| GH_TOKEN="$APP_TOKEN" gh api -X PUT "repos/$GH_REPO/pulls/$pr/reviews/$review_id/dismissals" \ | |
| -f message="Head moved during approval; the approved commit is no longer the PR head." \ | |
| -f event=DISMISS || true | |
| deny "SDK reviewer app: approval dismissed; the PR head changed while the review was being submitted. Left for human review (policy: fail open to human review, never to merge)." | |
| fi | |
| # Post-validation head drift is closed by branch protection, not | |
| # here: the approval is pinned to the validated commit_id and | |
| # dismiss_stale_reviews dismisses it on any later push, so | |
| # auto-merge fail-closes back to human review. | |
| enabled=$(GH_TOKEN="$APP_TOKEN" gh pr view "$pr" --json autoMergeRequest --jq '.autoMergeRequest != null') | |
| if [ "$enabled" = "true" ]; then | |
| echo "Auto-merge already enabled on PR #$pr." | |
| else | |
| GH_TOKEN="$MERGE_TOKEN" gh pr merge "$pr" --auto --squash | |
| fi |