|
1 | 1 | name: Auto-Merge PRs |
2 | 2 |
|
| 3 | +<<<<<<< HEAD |
3 | 4 | # Fires when a PR is opened/updated (to enable native auto-merge) and when a |
4 | 5 | # check suite completes (to directly merge if every check went green and the |
5 | 6 | # branch has no conflicts). |
@@ -125,5 +126,177 @@ jobs: |
125 | 126 | core.info(`✅ Auto-merged PR #${pr.number}: ${pull.title}`); |
126 | 127 | } catch (err) { |
127 | 128 | core.error(`Failed to merge PR #${pr.number}: ${err.message}`); |
| 129 | +======= |
| 130 | +# Merges open PRs once every check that ran for their head commit has gone green. |
| 131 | +# |
| 132 | +# Why `workflow_run` and not `check_suite`: |
| 133 | +# GitHub does not start a new workflow run from events generated by the |
| 134 | +# built-in GITHUB_TOKEN, so the `check_suite: completed` suites produced by |
| 135 | +# Actions never triggered this workflow (0 runs in ~40 attempts). `workflow_run` |
| 136 | +# is the supported way to react to another workflow finishing. |
| 137 | +# |
| 138 | +# Why not GitHub's native auto-merge (`gh pr merge --auto`): |
| 139 | +# `main` has no branch protection and therefore no *required* status checks. |
| 140 | +# Native auto-merge only waits for required checks, so with none configured it |
| 141 | +# merges as soon as the PR is mergeable — i.e. before CI has finished. This |
| 142 | +# workflow evaluates the checks itself instead. |
| 143 | +# |
| 144 | +# Security: jobs here run in the base-repo context with a writable token and |
| 145 | +# deliberately never check out or execute PR-authored code — they only call the |
| 146 | +# REST API. |
| 147 | + |
| 148 | +on: |
| 149 | + workflow_run: |
| 150 | + workflows: |
| 151 | + - "Frontend CI" |
| 152 | + - "Frontend Build Check" |
| 153 | + - "Smart Contract CI" |
| 154 | + - "Contract Tests" |
| 155 | + types: [completed] |
| 156 | + # Safety net: re-sweep periodically so a PR whose triggering event was missed |
| 157 | + # (or that went green while another PR was merging) still lands. |
| 158 | + schedule: |
| 159 | + - cron: "*/30 * * * *" |
| 160 | + workflow_dispatch: |
| 161 | + |
| 162 | +permissions: |
| 163 | + contents: write # needed to merge |
| 164 | + pull-requests: write # needed to comment / update PRs |
| 165 | + checks: read |
| 166 | + statuses: read |
| 167 | + |
| 168 | +concurrency: |
| 169 | + group: auto-merge |
| 170 | + cancel-in-progress: false |
| 171 | + |
| 172 | +jobs: |
| 173 | + merge-green-prs: |
| 174 | + name: Merge PRs with all checks passing |
| 175 | + runs-on: ubuntu-latest |
| 176 | + steps: |
| 177 | + - name: Evaluate and merge |
| 178 | + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 |
| 179 | + with: |
| 180 | + github-token: ${{ secrets.GITHUB_TOKEN }} |
| 181 | + script: | |
| 182 | + const { owner, repo } = context.repo; |
| 183 | + const TERMINAL_OK = ['success', 'skipped', 'neutral']; |
| 184 | +
|
| 185 | + // ── Work out which PRs to consider ──────────────────────────── |
| 186 | + // `workflow_run.pull_requests` is always empty for PRs from forks, |
| 187 | + // so resolve candidates by matching head SHA against open PRs. |
| 188 | + const openPrs = await github.paginate(github.rest.pulls.list, { |
| 189 | + owner, repo, state: 'open', per_page: 100, |
| 190 | + }); |
| 191 | +
|
| 192 | + let candidates = openPrs; |
| 193 | + if (context.eventName === 'workflow_run') { |
| 194 | + const run = context.payload.workflow_run; |
| 195 | + if (run.conclusion !== 'success') { |
| 196 | + core.info(`Upstream run "${run.name}" concluded "${run.conclusion}" — nothing to do.`); |
| 197 | + return; |
| 198 | + } |
| 199 | + candidates = openPrs.filter(pr => pr.head.sha === run.head_sha); |
| 200 | + if (candidates.length === 0) { |
| 201 | + core.info(`No open PR matches head SHA ${run.head_sha}.`); |
| 202 | + return; |
| 203 | + } |
| 204 | + } |
| 205 | +
|
| 206 | + core.info(`Evaluating ${candidates.length} PR(s).`); |
| 207 | +
|
| 208 | + for (const summary of candidates) { |
| 209 | + const number = summary.number; |
| 210 | +
|
| 211 | + // Re-fetch: the list payload omits `mergeable`/`mergeable_state`. |
| 212 | + let { data: pr } = await github.rest.pulls.get({ |
| 213 | + owner, repo, pull_number: number, |
| 214 | + }); |
| 215 | +
|
| 216 | + if (pr.draft) { core.info(`#${number}: draft — skipping.`); continue; } |
| 217 | + if (pr.state !== 'open') { core.info(`#${number}: not open — skipping.`); continue; } |
| 218 | +
|
| 219 | + // ── Checks must all be finished and green ─────────────────── |
| 220 | + const sha = pr.head.sha; |
| 221 | + const { data: { check_runs } } = await github.rest.checks.listForRef({ |
| 222 | + owner, repo, ref: sha, per_page: 100, |
| 223 | + }); |
| 224 | +
|
| 225 | + // Ignore this workflow's own check so it never waits on itself. |
| 226 | + const relevant = check_runs.filter(r => r.name !== 'Merge PRs with all checks passing'); |
| 227 | +
|
| 228 | + if (relevant.length === 0) { |
| 229 | + core.info(`#${number}: no checks reported for ${sha} — refusing to merge unchecked.`); |
| 230 | + continue; |
| 231 | + } |
| 232 | +
|
| 233 | + const pending = relevant.filter(r => r.status !== 'completed'); |
| 234 | + if (pending.length > 0) { |
| 235 | + core.info(`#${number}: still running (${pending.map(r => r.name).join(', ')}) — waiting.`); |
| 236 | + continue; |
| 237 | + } |
| 238 | +
|
| 239 | + const failing = relevant.filter(r => !TERMINAL_OK.includes(r.conclusion)); |
| 240 | + if (failing.length > 0) { |
| 241 | + core.info(`#${number}: failing checks (${failing.map(r => r.name).join(', ')}) — skipping.`); |
| 242 | + continue; |
| 243 | + } |
| 244 | +
|
| 245 | + // Legacy commit statuses (non-Actions integrations) count too. |
| 246 | + const { data: combined } = await github.rest.repos.getCombinedStatusForRef({ |
| 247 | + owner, repo, ref: sha, |
| 248 | + }); |
| 249 | + if (combined.total_count > 0 && combined.state !== 'success') { |
| 250 | + core.info(`#${number}: commit status is "${combined.state}" — skipping.`); |
| 251 | + continue; |
| 252 | + } |
| 253 | +
|
| 254 | + // ── Mergeability (computed asynchronously by GitHub) ──────── |
| 255 | + for (let attempt = 0; pr.mergeable === null && attempt < 3; attempt++) { |
| 256 | + core.info(`#${number}: mergeability not computed yet, retrying in 5s…`); |
| 257 | + await new Promise(r => setTimeout(r, 5000)); |
| 258 | + ({ data: pr } = await github.rest.pulls.get({ |
| 259 | + owner, repo, pull_number: number, |
| 260 | + })); |
| 261 | + } |
| 262 | +
|
| 263 | + if (pr.mergeable === false) { |
| 264 | + core.info(`#${number}: merge conflicts (${pr.mergeable_state}) — skipping.`); |
| 265 | + continue; |
| 266 | + } |
| 267 | + if (pr.mergeable === null) { |
| 268 | + core.info(`#${number}: mergeability still unknown — will retry next sweep.`); |
| 269 | + continue; |
| 270 | + } |
| 271 | + if (['blocked', 'behind', 'draft'].includes(pr.mergeable_state)) { |
| 272 | + core.info(`#${number}: mergeable_state="${pr.mergeable_state}" — skipping.`); |
| 273 | + continue; |
| 274 | + } |
| 275 | +
|
| 276 | + // ── Merge ────────────────────────────────────────────────── |
| 277 | + // Drop AI-assistant co-author trailers from the squash message. |
| 278 | + // Human co-authors are deliberately left intact — stripping those |
| 279 | + // would erase real contributor attribution. |
| 280 | + const body = (pr.body ?? '') |
| 281 | + .split('\n') |
| 282 | + .filter(line => !/^\s*co-authored-by:.*(claude|anthropic\.com)/i.test(line)) |
| 283 | + .join('\n') |
| 284 | + .trim(); |
| 285 | +
|
| 286 | + try { |
| 287 | + await github.rest.pulls.merge({ |
| 288 | + owner, repo, |
| 289 | + pull_number: number, |
| 290 | + merge_method: 'squash', |
| 291 | + commit_title: `${pr.title} (#${number})`, |
| 292 | + commit_message: body, |
| 293 | + sha, |
| 294 | + }); |
| 295 | + core.notice(`Auto-merged #${number}: ${pr.title}`); |
| 296 | + } catch (err) { |
| 297 | + // 405 = not mergeable right now, 409 = head moved. Both are |
| 298 | + // transient; the next sweep retries. |
| 299 | + core.warning(`#${number}: merge failed (${err.status}): ${err.message}`); |
| 300 | +>>>>>>> emwulrd/main |
128 | 301 | } |
129 | 302 | } |
0 commit comments