11name : 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+
318on : pull_request_target
419
520permissions :
@@ -9,6 +24,7 @@ permissions:
924jobs :
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