Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
158 changes: 152 additions & 6 deletions .github/workflows/sdk_generation.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,21 @@ 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:
# Prefer a PAT: PRs opened with GITHUB_TOKEN do not trigger
# pull_request workflows, so verify CI would never run on them.
token: ${{ secrets.SDK_BOT_PAT || secrets.GITHUB_TOKEN }}
# 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
Expand Down Expand Up @@ -95,11 +104,11 @@ jobs:
- name: Push branch and open PR
if: steps.diff.outputs.changed == 'true'
env:
GH_TOKEN: ${{ secrets.SDK_BOT_PAT || secrets.GITHUB_TOKEN }}
GH_TOKEN: ${{ steps.bot-token.outputs.token }}
BRANCH: ${{ steps.branch.outputs.name }}
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
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
Expand All @@ -118,3 +127,140 @@ jobs:
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
Comment thread
mekilis marked this conversation as resolved.
if: steps.diff.outputs.changed == 'true'
Comment thread
mekilis marked this conversation as resolved.
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