Skip to content

Commit cc96ff9

Browse files
authored
fix: harden sdk auto-review gate (#7)
* fix: harden sdk auto-review gate deny paths now clear stale auto-merge before commenting and fail the step if the disable errors, instead of best-effort || true. an empty changed-files listing no longer counts as an allowlist pass. renames are checked on both sides everywhere. approvals are pinned to the head commit. * fix: close validate-then-approve race in auto-review pin the approval to the head captured before the allowlist run, then re-read the head after submitting; if it moved, dismiss the approval and deny. dismiss_stale_reviews (now enabled) covers pushes after the review; this covers the window before it. * fix: make review dismissal best-effort ahead of deny a dismiss failure under set -e must not skip deny, which is the hard gate that clears stale auto-merge. dismiss_stale_reviews already covers the dismissal in the normal case.
1 parent 363d5b2 commit cc96ff9

1 file changed

Lines changed: 84 additions & 24 deletions

File tree

.github/workflows/sdk_generation.yaml

Lines changed: 84 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -150,31 +150,73 @@ jobs:
150150
GH_REPO: ${{ github.repository }}
151151
BRANCH: ${{ steps.branch.outputs.name }}
152152
run: |
153-
# Failure policy: fail open to human review, never to merge. If the
154-
# diff touches anything outside the generated paths the PR is left
155-
# unapproved with the offending paths printed. Auto-merge completes
156-
# only after the required status checks pass.
157-
pr=$(GH_TOKEN="$APP_TOKEN" gh pr list --head "$BRANCH" --state open --json number --jq '.[0].number // empty')
158-
if [ -z "$pr" ]; then
153+
# Failure policy: fail open to human review, never to merge. Any
154+
# gate miss below (wrong base, stale head, non-generated paths)
155+
# leaves the PR unapproved for a human. Clearing a stale auto-merge
156+
# is the one hard failure: if --disable-auto errors, this step goes
157+
# red instead of leaving a rejected head silently mergeable.
158+
set -euo pipefail
159+
160+
deny() {
161+
# Auto-merge is cleared before commenting: under set -e a comment
162+
# failure must not skip the disable, and a disable failure must
163+
# fail the step, not be swallowed.
164+
enabled=$(GH_TOKEN="$APP_TOKEN" gh pr view "$pr" --json autoMergeRequest --jq '.autoMergeRequest != null')
165+
if [ "$enabled" = "true" ]; then
166+
GH_TOKEN="$MERGE_TOKEN" gh pr merge "$pr" --disable-auto
167+
fi
168+
printf '%s\n' "$1" | GH_TOKEN="$APP_TOKEN" gh pr comment "$pr" --body-file - || true
169+
exit 0
170+
}
171+
172+
pr_json=$(GH_TOKEN="$APP_TOKEN" gh pr list --head "$BRANCH" --state open \
173+
--json number,baseRefName,headRefOid --jq '.[0] // empty')
174+
if [ -z "$pr_json" ]; then
159175
echo "No open PR for $BRANCH; nothing to approve."
160176
exit 0
161177
fi
178+
pr=$(echo "$pr_json" | jq -r .number)
179+
base=$(echo "$pr_json" | jq -r .baseRefName)
180+
head_sha=$(echo "$pr_json" | jq -r .headRefOid)
181+
182+
if [ "$base" != "main" ]; then
183+
echo "Not approving PR #$pr: base is $base, not main."
184+
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)."
185+
fi
162186
163187
# Only regen PRs authored by the bot app qualify for auto-review.
164188
# REST is used because it returns the stable "convoy-sdk-bot[bot]"
165189
# login for app-authored PRs.
166190
author=$(GH_TOKEN="$APP_TOKEN" gh api "repos/$GH_REPO/pulls/$pr" --jq '.user.login')
167191
if [ "$author" != "convoy-sdk-bot[bot]" ]; then
168192
echo "Not approving PR #$pr: author is $author, not convoy-sdk-bot[bot]."
169-
GH_TOKEN="$APP_TOKEN" gh pr comment "$pr" --body "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)."
170-
exit 0
193+
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)."
194+
fi
195+
196+
# Bind the approval to the commit this run pushed; a concurrent
197+
# push to the regen branch means the diff is no longer this run's
198+
# generated output.
199+
pushed_sha=$(git rev-parse HEAD)
200+
if [ "$head_sha" != "$pushed_sha" ]; then
201+
echo "Not approving PR #$pr: head $head_sha is not the commit this run pushed."
202+
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)."
203+
fi
204+
205+
# This run committed a real diff, so an empty changed-files listing
206+
# is an API anomaly, not a clean PR; it must not count as an
207+
# allowlist pass.
208+
files=$(GH_TOKEN="$APP_TOKEN" gh api "repos/$GH_REPO/pulls/$pr/files" --paginate \
209+
--jq '.[] | .filename, (.previous_filename // empty)')
210+
if [ -z "$files" ]; then
211+
echo "Not approving PR #$pr: changed-files listing came back empty."
212+
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)."
171213
fi
172214
173-
# The head branch is the regen branch this run pushed, so the
174-
# branch condition holds by construction of the lookup above.
175215
# Allowlist mirrors scripts/generate.sh: only the api, client, and
176-
# models packages are generated; webhook/ is hand-written.
177-
bad=$(GH_TOKEN="$APP_TOKEN" gh api "repos/$GH_REPO/pulls/$pr/files" --paginate --jq '.[].filename' \
216+
# models packages are generated; webhook/ is hand-written. Renames
217+
# are checked on both sides so a file cannot be moved into the
218+
# generated tree from outside it.
219+
bad=$(printf '%s\n' "$files" \
178220
| while read -r f; do
179221
case "$f" in
180222
src/main/java/com/getconvoy/api/*) ;;
@@ -186,22 +228,40 @@ jobs:
186228
if [ -n "$bad" ]; then
187229
echo "Not approving PR #$pr: diff touches non-generated paths:"
188230
echo "$bad"
189-
{
190-
echo "SDK reviewer app: not auto-approving; the diff touches paths outside the generated allowlist:"
191-
echo
192-
echo '```'
193-
echo "$bad"
194-
echo '```'
195-
echo
196-
echo "Left for human review (policy: fail open to human review, never to merge)."
197-
} | GH_TOKEN="$APP_TOKEN" gh pr comment "$pr" --body-file -
198-
exit 0
231+
deny "$(printf '%s\n' \
232+
"SDK reviewer app: not auto-approving; the diff touches paths outside the generated allowlist:" \
233+
"" '```' "$bad" '```' "" \
234+
"Left for human review (policy: fail open to human review, never to merge).")"
199235
fi
200236
201-
GH_TOKEN="$APP_TOKEN" gh api -X POST "repos/$GH_REPO/pulls/$pr/reviews" \
237+
review_id=$(GH_TOKEN="$APP_TOKEN" gh api -X POST "repos/$GH_REPO/pulls/$pr/reviews" \
202238
-f event=APPROVE \
203-
-f body="SDK reviewer app: approving a generated-paths-only diff on the regen branch. Auto-merge completes only after required status checks pass."
239+
-f commit_id="$head_sha" \
240+
-f body="SDK reviewer app: approving a generated-paths-only diff on the regen branch. Auto-merge completes only after required status checks pass." \
241+
--jq '.id')
242+
243+
# Close the validate-then-approve race: a push landing between the
244+
# allowlist check and the approval is not covered by
245+
# dismiss_stale_reviews (that only dismisses on pushes after the
246+
# review), so re-read the head and dismiss our own approval if it
247+
# moved off the validated commit.
248+
now_sha=$(GH_TOKEN="$APP_TOKEN" gh pr view "$pr" --json headRefOid --jq '.headRefOid')
249+
if [ "$now_sha" != "$head_sha" ]; then
250+
echo "Head moved from $head_sha to $now_sha during approval; dismissing review."
251+
# Best-effort: dismiss_stale_reviews normally dismissed this
252+
# approval already when the head moved. The hard gate is deny
253+
# below, which clears auto-merge or fails the step; a dismiss
254+
# error must not skip it.
255+
GH_TOKEN="$APP_TOKEN" gh api -X PUT "repos/$GH_REPO/pulls/$pr/reviews/$review_id/dismissals" \
256+
-f message="Head moved during approval; the approved commit is no longer the PR head." \
257+
-f event=DISMISS || true
258+
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)."
259+
fi
204260
261+
# Post-validation head drift is closed by branch protection, not
262+
# here: the approval is pinned to the validated commit_id and
263+
# dismiss_stale_reviews dismisses it on any later push, so
264+
# auto-merge fail-closes back to human review.
205265
enabled=$(GH_TOKEN="$APP_TOKEN" gh pr view "$pr" --json autoMergeRequest --jq '.autoMergeRequest != null')
206266
if [ "$enabled" = "true" ]; then
207267
echo "Auto-merge already enabled on PR #$pr."

0 commit comments

Comments
 (0)