Skip to content

Commit eb44991

Browse files
chitcommitclaude
andauthored
fix(ci): gate Dependabot auto-merge on checks actually passing (#136)
gh pr merge --auto does not wait when branch protection requires zero status checks -- it merges immediately. Proven on chittyregistry PR #184, which merged 14s BEFORE its build-and-test job completed. Branch protection is unavailable as a uniform fix: private repos on this plan 403 on protection. Replaces --auto with a poll loop that fails closed if no checks register, excludes its own check run (by run id, or by name while not completed) so it cannot deadlock on itself, reads both check runs and commit statuses, treats pending statuses as pending, and merges with --match-head-commit to close the race between the gate passing and the merge. Claude-Session: https://claude.ai/code/session_01AKKxbsxh4tozUMzBKADSTd Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent a60d0e1 commit eb44991

1 file changed

Lines changed: 89 additions & 8 deletions

File tree

Lines changed: 89 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,20 @@
11
name: Dependabot auto-merge
22

3+
# Merges only AFTER checks have registered and passed.
4+
#
5+
# The previous version called `gh pr merge --auto`. With zero required status
6+
# checks configured in branch protection, `--auto` merges immediately rather
7+
# than waiting -- proven on chittyregistry PR #184, which merged 14 seconds
8+
# BEFORE its build-and-test job completed. Branch protection cannot be the fix
9+
# here: private repos on this plan cannot have it at all, so the gate has to
10+
# live in the workflow to be uniform across the fleet.
11+
#
12+
# Two traps this works around, both real:
13+
# 1. A watch started before any check registers sees an empty set and passes
14+
# vacuously. Hence the explicit "no checks ever appeared" -> fail closed.
15+
# 2. This job is itself a check run on the PR, so `gh pr checks --watch`
16+
# would wait on itself forever. Hence the self-exclusion by run id.
17+
318
on: pull_request_target
419

520
permissions:
@@ -9,6 +24,7 @@ permissions:
924
jobs:
1025
dependabot:
1126
runs-on: ubuntu-latest
27+
timeout-minutes: 30
1228
if: github.actor == 'dependabot[bot]'
1329
steps:
1430
- name: Fetch Dependabot metadata
@@ -17,20 +33,85 @@ jobs:
1733
with:
1834
github-token: "${{ secrets.GITHUB_TOKEN }}"
1935

20-
- name: Approve minor and patch updates
36+
- name: Wait for checks to register and pass
37+
id: gate
2138
if: >-
2239
steps.metadata.outputs.update-type == 'version-update:semver-minor' ||
2340
steps.metadata.outputs.update-type == 'version-update:semver-patch'
24-
run: gh pr review --approve "$PR_URL"
41+
run: |
42+
set -euo pipefail
43+
44+
deadline=$(( SECONDS + 1500 ))
45+
saw_any=0
46+
47+
while [ "$SECONDS" -lt "$deadline" ]; do
48+
# Exclude this job's own check run. Belt and braces: by run id
49+
# (details_url is .../actions/runs/<id>/job/<id>, verified) and by
50+
# job name, so a change to either shape cannot deadlock the gate.
51+
# `gh api` has no --arg, so jq is invoked separately.
52+
runs=$(gh api --paginate --slurp \
53+
"repos/${GITHUB_REPOSITORY}/commits/${HEAD_SHA}/check-runs" \
54+
| jq -r --arg runid "${GITHUB_RUN_ID}" --arg self "${GITHUB_JOB}" '
55+
.[].check_runs[]
56+
| select(((((.details_url // "") | contains("/runs/" + $runid + "/"))
57+
or (.name == $self and .status != "completed"))) | not)
58+
| [.name, .status, (.conclusion // "")] | @tsv')
59+
60+
# Legacy commit statuses (some systems report here, not as check runs).
61+
# A status in state "pending" is still running -- hardcoding
62+
# "completed" here would let the gate merge mid-build.
63+
statuses=$(gh api \
64+
"repos/${GITHUB_REPOSITORY}/commits/${HEAD_SHA}/status" \
65+
--jq '.statuses[]
66+
| [.context,
67+
(if .state == "pending" then "in_progress" else "completed" end),
68+
.state] | @tsv')
69+
70+
all=$(printf '%s\n%s\n' "$runs" "$statuses" | sed '/^$/d')
71+
72+
if [ -n "$all" ]; then
73+
saw_any=1
74+
75+
# Any hard failure -> stop now, leave the PR open for a human.
76+
if failed=$(printf '%s\n' "$all" | awk -F'\t' '$3=="failure"||$3=="cancelled"||$3=="timed_out"||$3=="error"' | grep .); then
77+
echo "::error::Checks failed, refusing to merge:"
78+
printf '%s\n' "$failed"
79+
exit 1
80+
fi
81+
82+
# Still-running checks -> keep waiting.
83+
pending=$(printf '%s\n' "$all" | awk -F'\t' '$2!="completed"' || true)
84+
if [ -z "$pending" ]; then
85+
echo "All checks complete and non-failing:"
86+
printf '%s\n' "$all"
87+
exit 0
88+
fi
89+
fi
90+
91+
sleep 20
92+
done
93+
94+
if [ "$saw_any" -eq 0 ]; then
95+
echo "::error::No checks ever registered on ${HEAD_SHA} -- refusing to merge."
96+
else
97+
echo "::error::Checks did not settle before the deadline -- refusing to merge."
98+
fi
99+
exit 1
25100
env:
26-
PR_URL: ${{ github.event.pull_request.html_url }}
101+
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
27102
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
28103

29-
- name: Enable auto-merge for minor and patch updates
30-
if: >-
31-
steps.metadata.outputs.update-type == 'version-update:semver-minor' ||
32-
steps.metadata.outputs.update-type == 'version-update:semver-patch'
33-
run: gh pr merge --auto --squash "$PR_URL"
104+
- name: Approve and merge
105+
if: steps.gate.outcome == 'success'
106+
run: |
107+
set -euo pipefail
108+
# --match-head-commit closes the window between the gate passing and
109+
# the merge: pull_request_target does re-trigger on push (verified --
110+
# PR 186 has two runs, one per pushed commit), so a race here would
111+
# merge a commit this run never gated. Refuse instead.
112+
gh pr review --approve "$PR_URL"
113+
gh pr merge --squash --match-head-commit "$HEAD_SHA" "$PR_URL"
34114
env:
35115
PR_URL: ${{ github.event.pull_request.html_url }}
116+
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
36117
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

0 commit comments

Comments
 (0)