Skip to content

Add per operator withdrawal limit #654

Add per operator withdrawal limit

Add per operator withdrawal limit #654

Workflow file for this run

name: Auto-Merge PRs
<<<<<<< HEAD

Check failure on line 3 in .github/workflows/auto-merge.yml

View workflow run for this annotation

GitHub Actions / .github/workflows/auto-merge.yml

Invalid workflow file

You have an error in your yaml syntax on line 3
# Fires when a PR is opened/updated (to enable native auto-merge) and when a
# check suite completes (to directly merge if every check went green and the
# branch has no conflicts).
on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
branches: [main]
check_suite:
types: [completed]
permissions:
contents: write # needed to merge
pull-requests: write # needed to update PR auto-merge flag
jobs:
# ── 1. Enable GitHub's built-in auto-merge the moment a PR is opened / updated
enable-auto-merge:
name: Enable auto-merge on PR
if: |
github.event_name == 'pull_request' &&
github.event.pull_request.draft == false
runs-on: ubuntu-latest
steps:
- name: Enable squash auto-merge
# continue-on-error so a repo without branch-protection rules doesn't
# block the workflow; the merge-when-green job is the primary mechanism.
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh pr merge --auto --squash \
--repo "${{ github.repository }}" \
"${{ github.event.pull_request.number }}"
# ── 2. Directly merge when ALL check runs on the suite pass ──────────────
merge-when-green:
name: Merge PR when all checks pass
if: |
github.event_name == 'check_suite' &&
github.event.check_suite.conclusion == 'success'
runs-on: ubuntu-latest
steps:
- name: Merge passing, conflict-free PRs
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const headSha = context.payload.check_suite.head_sha;
const prs = context.payload.check_suite.pull_requests ?? [];
if (prs.length === 0) {
core.info('No open PRs linked to this check suite — nothing to do.');
return;
}
for (const pr of prs) {
// Fetch the full PR object (includes mergeable + draft fields)
const { data: pull } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: pr.number,
});
if (pull.draft) {
core.info(`PR #${pr.number} is a draft — skipping.`);
continue;
}
if (pull.state !== 'open') {
core.info(`PR #${pr.number} is already closed — skipping.`);
continue;
}
// GitHub computes mergeability asynchronously; poll once if null.
let mergeable = pull.mergeable;
if (mergeable === null) {
core.info(`PR #${pr.number}: mergeability not yet computed, waiting 6 s…`);
await new Promise(r => setTimeout(r, 6000));
const { data: refreshed } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: pr.number,
});
mergeable = refreshed.mergeable;
}
if (mergeable === false) {
core.info(`PR #${pr.number} has merge conflicts — skipping.`);
continue;
}
// Confirm every completed check run passed (or was neutral/skipped).
const { data: { check_runs } } = await github.rest.checks.listForRef({
owner: context.repo.owner,
repo: context.repo.repo,
ref: headSha,
per_page: 100,
});
const failing = check_runs.filter(r =>
r.status === 'completed' &&
!['success', 'skipped', 'neutral'].includes(r.conclusion)
);
if (failing.length > 0) {
core.info(
`PR #${pr.number} has failing checks ` +
`(${failing.map(r => r.name).join(', ')}) — skipping.`
);
continue;
}
// Everything is green and conflict-free — merge!
try {
await github.rest.pulls.merge({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: pr.number,
merge_method: 'squash',
commit_title: `${pull.title} (#${pr.number})`,
commit_message: pull.body ?? '',
});
core.info(`✅ Auto-merged PR #${pr.number}: ${pull.title}`);
} catch (err) {
core.error(`Failed to merge PR #${pr.number}: ${err.message}`);
=======
# Merges open PRs once every check that ran for their head commit has gone green.
#
# Why `workflow_run` and not `check_suite`:
# GitHub does not start a new workflow run from events generated by the
# built-in GITHUB_TOKEN, so the `check_suite: completed` suites produced by
# Actions never triggered this workflow (0 runs in ~40 attempts). `workflow_run`
# is the supported way to react to another workflow finishing.
#
# Why not GitHub's native auto-merge (`gh pr merge --auto`):
# `main` has no branch protection and therefore no *required* status checks.
# Native auto-merge only waits for required checks, so with none configured it
# merges as soon as the PR is mergeable — i.e. before CI has finished. This
# workflow evaluates the checks itself instead.
#
# Security: jobs here run in the base-repo context with a writable token and
# deliberately never check out or execute PR-authored code — they only call the
# REST API.
on:
workflow_run:
workflows:
- "Frontend CI"
- "Frontend Build Check"
- "Smart Contract CI"
- "Contract Tests"
types: [completed]
# Safety net: re-sweep periodically so a PR whose triggering event was missed
# (or that went green while another PR was merging) still lands.
schedule:
- cron: "*/30 * * * *"
workflow_dispatch:
permissions:
contents: write # needed to merge
pull-requests: write # needed to comment / update PRs
checks: read
statuses: read
concurrency:
group: auto-merge
cancel-in-progress: false
jobs:
merge-green-prs:
name: Merge PRs with all checks passing
runs-on: ubuntu-latest
steps:
- name: Evaluate and merge
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const { owner, repo } = context.repo;
const TERMINAL_OK = ['success', 'skipped', 'neutral'];
// ── Work out which PRs to consider ────────────────────────────
// `workflow_run.pull_requests` is always empty for PRs from forks,
// so resolve candidates by matching head SHA against open PRs.
const openPrs = await github.paginate(github.rest.pulls.list, {
owner, repo, state: 'open', per_page: 100,
});
let candidates = openPrs;
if (context.eventName === 'workflow_run') {
const run = context.payload.workflow_run;
if (run.conclusion !== 'success') {
core.info(`Upstream run "${run.name}" concluded "${run.conclusion}" — nothing to do.`);
return;
}
candidates = openPrs.filter(pr => pr.head.sha === run.head_sha);
if (candidates.length === 0) {
core.info(`No open PR matches head SHA ${run.head_sha}.`);
return;
}
}
core.info(`Evaluating ${candidates.length} PR(s).`);
for (const summary of candidates) {
const number = summary.number;
// Re-fetch: the list payload omits `mergeable`/`mergeable_state`.
let { data: pr } = await github.rest.pulls.get({
owner, repo, pull_number: number,
});
if (pr.draft) { core.info(`#${number}: draft — skipping.`); continue; }
if (pr.state !== 'open') { core.info(`#${number}: not open — skipping.`); continue; }
// ── Checks must all be finished and green ───────────────────
const sha = pr.head.sha;
const { data: { check_runs } } = await github.rest.checks.listForRef({
owner, repo, ref: sha, per_page: 100,
});
// Ignore this workflow's own check so it never waits on itself.
const relevant = check_runs.filter(r => r.name !== 'Merge PRs with all checks passing');
if (relevant.length === 0) {
core.info(`#${number}: no checks reported for ${sha} — refusing to merge unchecked.`);
continue;
}
const pending = relevant.filter(r => r.status !== 'completed');
if (pending.length > 0) {
core.info(`#${number}: still running (${pending.map(r => r.name).join(', ')}) — waiting.`);
continue;
}
const failing = relevant.filter(r => !TERMINAL_OK.includes(r.conclusion));
if (failing.length > 0) {
core.info(`#${number}: failing checks (${failing.map(r => r.name).join(', ')}) — skipping.`);
continue;
}
// Legacy commit statuses (non-Actions integrations) count too.
const { data: combined } = await github.rest.repos.getCombinedStatusForRef({
owner, repo, ref: sha,
});
if (combined.total_count > 0 && combined.state !== 'success') {
core.info(`#${number}: commit status is "${combined.state}" — skipping.`);
continue;
}
// ── Mergeability (computed asynchronously by GitHub) ────────
for (let attempt = 0; pr.mergeable === null && attempt < 3; attempt++) {
core.info(`#${number}: mergeability not computed yet, retrying in 5s…`);
await new Promise(r => setTimeout(r, 5000));
({ data: pr } = await github.rest.pulls.get({
owner, repo, pull_number: number,
}));
}
if (pr.mergeable === false) {
core.info(`#${number}: merge conflicts (${pr.mergeable_state}) — skipping.`);
continue;
}
if (pr.mergeable === null) {
core.info(`#${number}: mergeability still unknown — will retry next sweep.`);
continue;
}
if (['blocked', 'behind', 'draft'].includes(pr.mergeable_state)) {
core.info(`#${number}: mergeable_state="${pr.mergeable_state}" — skipping.`);
continue;
}
// ── Merge ──────────────────────────────────────────────────
// Drop AI-assistant co-author trailers from the squash message.
// Human co-authors are deliberately left intact — stripping those
// would erase real contributor attribution.
const body = (pr.body ?? '')
.split('\n')
.filter(line => !/^\s*co-authored-by:.*(claude|anthropic\.com)/i.test(line))
.join('\n')
.trim();
try {
await github.rest.pulls.merge({
owner, repo,
pull_number: number,
merge_method: 'squash',
commit_title: `${pr.title} (#${number})`,
commit_message: body,
sha,
});
core.notice(`Auto-merged #${number}: ${pr.title}`);
} catch (err) {
// 405 = not mergeable right now, 409 = head moved. Both are
// transient; the next sweep retries.
core.warning(`#${number}: merge failed (${err.status}): ${err.message}`);
>>>>>>> emwulrd/main
}
}