ReviewOS

also looking at this

pantry-pm/pantry

fix(ci): bot commits land through a checked pull request, not a bypass

#274
Merged glennmichael123 wants to merge fix/bot-pushes-via-pr into main
10 files +621 -23
.github/actions/merge-via-pr/action.ymladded+154-0
Changes to .github/actions/merge-via-pr/action.yml
@@ -0,0 +1,154 @@
1name: Merge via PR
2description: >-
3 Land an already-committed change on the default branch through a pull request
4 that actually runs the required checks.
5
6inputs:
7 token:
8 description: "GitHub token with contents, pull-requests and actions write."
9 required: true
10 branch-prefix:
11 description: "Branch namespace for the pull request's head."
12 required: false
13 default: 'chore/automated'
14 title:
15 description: "Pull request title. Defaults to the commit subject."
16 required: false
17 default: ''
18 body:
19 description: "Pull request body."
20 required: false
21 default: ''
22 checks:
23 description: "Comma-separated required check names to wait for."
24 required: false
25 default: 'lint,typecheck,test,zig-tests'
26 workflow:
27 description: "Workflow dispatched to produce those checks."
28 required: false
29 default: 'ci.yml'
30 timeout-minutes:
31 description: "How long to wait for the checks before giving up."
32 required: false
33 default: '40'
34
35outputs:
36 merged:
37 description: "true when the pull request was merged."
38 value: ${{ steps.land.outputs.merged }}
39 pr-number:
40 description: "Number of the pull request that was opened."
41 value: ${{ steps.land.outputs.pr-number }}
42 pr-url:
43 description: "URL of the pull request that was opened."
44 value: ${{ steps.land.outputs.pr-url }}
45
46runs:
47 using: composite
48 steps:
49 # A bot cannot push to the default branch: the branch ruleset requires the
50 # CI contexts, and `github-actions[bot]` holds no repository role, so its
51 # push is refused with GH013. Going through a pull request is the honest
52 # answer — the generated catalog gets checked like anything else — but it
53 # needs one piece of care. GitHub suppresses events caused by GITHUB_TOKEN,
54 # which covers a pull request opened with it just as it covers a push, so
55 # the `pull_request` trigger never fires and nothing would ever report
56 # against the required contexts. Dispatching CI at the head branch does
57 # report: a check run is attached to a commit, and that commit is the one
58 # the pull request is gated on.
59 - name: Land the commit through a pull request
60 id: land
61 shell: bash
62 env:
63 GH_TOKEN: ${{ inputs.token }}
64 REPO: ${{ github.repository }}
65 BASE: ${{ github.event.repository.default_branch || 'main' }}
66 PREFIX: ${{ inputs.branch-prefix }}
67 TITLE_IN: ${{ inputs.title }}
68 BODY_IN: ${{ inputs.body }}
69 CHECKS: ${{ inputs.checks }}
70 WORKFLOW: ${{ inputs.workflow }}
71 TIMEOUT_MIN: ${{ inputs.timeout-minutes }}
72 RUN_ID: ${{ github.run_id }}
73 RUN_ATTEMPT: ${{ github.run_attempt }}
74 run: |
75 set -uo pipefail
76
77 BRANCH="${PREFIX}/${RUN_ID}-${RUN_ATTEMPT}"
78 SHA="$(git rev-parse HEAD)"
79 TITLE="${TITLE_IN:-$(git log -1 --pretty=%s)}"
80 echo "merged=false" >> "$GITHUB_OUTPUT"
81
82 # Each run's commit regenerates the whole of what the last one wrote, so
83 # an older open pull request from this same prefix is superseded rather
84 # than merely stale. Closing it keeps a red or slow run from piling up a
85 # queue of pull requests that all say the same thing.
86 gh pr list --repo "$REPO" --state open --json number,headRefName \
87 --jq ".[] | select(.headRefName | startswith(\"${PREFIX}/\")) | .number" 2>/dev/null \
88 | while read -r stale; do
89 [ -n "$stale" ] || continue
90 echo "Superseding #${stale}"
91 gh pr close "$stale" --repo "$REPO" --delete-branch \
92 --comment "Superseded by a newer automated run." || true
93 done
94
95 if ! git push origin "HEAD:refs/heads/${BRANCH}"; then
96 echo "::error::could not push ${BRANCH}"
97 exit 1
98 fi
99
100 PR_URL="$(gh pr create --repo "$REPO" --base "$BASE" --head "$BRANCH" \
101 --title "$TITLE" --body "${BODY_IN:-Opened automatically so the required checks run on generated output.}")"
102 if [ -z "$PR_URL" ]; then
103 echo "::error::could not open a pull request for ${BRANCH}"
104 git push origin --delete "$BRANCH" || true
105 exit 1
106 fi
107 PR_NUMBER="${PR_URL##*/}"
108 echo "pr-number=${PR_NUMBER}" >> "$GITHUB_OUTPUT"
109 echo "pr-url=${PR_URL}" >> "$GITHUB_OUTPUT"
110 echo "Opened ${PR_URL}"
111
112 if ! gh workflow run "$WORKFLOW" --repo "$REPO" --ref "$BRANCH"; then
113 echo "::error::could not dispatch ${WORKFLOW} at ${BRANCH}; nothing would report on ${SHA}"
114 exit 1
115 fi
116
117 IFS=',' read -ra WANTED <<< "$CHECKS"
118 DEADLINE=$(( $(date +%s) + TIMEOUT_MIN * 60 ))
119 while :; do
120 if [ "$(date +%s)" -ge "$DEADLINE" ]; then
121 echo "::error::${CHECKS} did not all report on ${SHA} within ${TIMEOUT_MIN}m; ${PR_URL} is left open"
122 exit 1
123 fi
124
125 RUNS="$(gh api "repos/${REPO}/commits/${SHA}/check-runs?per_page=100" \
126 --jq '.check_runs[] | "\(.name)\t\(.status)\t\(.conclusion // "")"' 2>/dev/null)"
127
128 PASSED=0
129 FAILED=''
130 for want in "${WANTED[@]}"; do
131 line="$(printf '%s\n' "$RUNS" | awk -F'\t' -v n="$want" '$1 == n { print; exit }')"
132 [ -n "$line" ] || continue
133 status="$(printf '%s' "$line" | cut -f2)"
134 [ "$status" = "completed" ] || continue
135 case "$(printf '%s' "$line" | cut -f3)" in
136 success|skipped|neutral) PASSED=$((PASSED + 1)) ;;
137 *) FAILED="${FAILED:+$FAILED, }${want}" ;;
138 esac
139 done
140
141 if [ -n "$FAILED" ]; then
142 echo "::error::required checks failed on generated output: ${FAILED}. ${PR_URL} is left open to show why."
143 exit 1
144 fi
145 [ "$PASSED" -eq "${#WANTED[@]}" ] && break
146 sleep 20
147 done
148
149 if ! gh pr merge "$PR_NUMBER" --repo "$REPO" --squash --delete-branch; then
150 echo "::error:__stx_checks="eyJzdGF0ZSI6Im5ldXRyYWwiLCJjb3VudHMiOnsiZmFpbHVyZSI6MCwicGVuZGluZyI6MCwic3VjY2VzcyI6MCwibmV1dHJhbCI6MH0sImVudHJpZXMiOltdLCJyZXF1aXJlZCI6eyJzYXRpc2ZpZWQiOnRydWUsIm1pc3NpbmciOltdLCJmYWlsaW5nIjpbXSwicGVuZGluZyI6W119LCJhbm5vdGF0aW9ucyI6W119" passed but ${PR_URL} would not merge; it is left open"
151 exit 1
152 fi
153 echo "merged=true" >> "$GITHUB_OUTPUT"
154 echo "Merged ${PR_URL}"