Skip to content

SDK Generation

SDK Generation #20

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