name: Merge Gatekeeper on: pull_request: branches: ["main", "feature*"] merge_group: branches: ["main"] concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true jobs: merge-gatekeeper: runs-on: ubuntu-latest permissions: checks: read statuses: read steps: - name: Wait for required checks if: github.event_name == 'pull_request' uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: TIMEOUT_SECONDS: "3600" INTERVAL_SECONDS: "30" SELF_JOB_NAME: ${{ github.job }} # "Cleanup artifacts", "Agent", "Prepare", and "Upload results" are check runs # created by an org-level GitHub App (MSDO), not by any workflow in this repo. # They are outside our control and their transient failures should not block merges. IGNORED_NAMES: "CodeQL,CodeQL analysis (csharp),Cleanup artifacts,Agent,Prepare,Upload results,review" with: script: | const timeoutSeconds = Number(process.env.TIMEOUT_SECONDS); const intervalSeconds = Number(process.env.INTERVAL_SECONDS); const selfName = process.env.SELF_JOB_NAME; const ignored = new Set( process.env.IGNORED_NAMES.split(',').map((s) => s.trim()).filter(Boolean), ); const sha = context.payload.pull_request.head.sha; const { owner, repo } = context.repo; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); // Mirrors upsidr/merge-gatekeeper: merge combined-statuses and check-runs // for the PR head SHA, with combined-statuses winning on name collision. async function collectChecks() { const merged = new Map(); const combined = await github.rest.repos.getCombinedStatusForRef({ owner, repo, ref: sha, per_page: 100, }); for (const s of combined.data.statuses ?? []) { if (!merged.has(s.context)) { // Combined-status states: success | pending | error | failure merged.set(s.context, { name: s.context, state: s.state }); } } const runs = await github.paginate(github.rest.checks.listForRef, { owner, repo, ref: sha, per_page: 100, }); for (const r of runs) { if (merged.has(r.name)) continue; let state; if (r.status !== 'completed') { state = 'pending'; } else if (r.conclusion === 'skipped') { continue; // Skipped runs are dropped, matching the original action. } else if (r.conclusion === 'success' || r.conclusion === 'neutral') { state = 'success'; } else { // cancelled | timed_out | action_required | stale | failure state = 'error'; } merged.set(r.name, { name: r.name, state }); } return [...merged.values()]; } function evaluate(entries) { const failed = []; const pending = []; const succeeded = []; for (const e of entries) { if (e.name === selfName || ignored.has(e.name)) continue; if (e.state === 'success') succeeded.push(e.name); else if (e.state === 'error' || e.state === 'failure') failed.push(e.name); else pending.push(e.name); } return { failed, pending, succeeded }; } const deadline = Date.now() + timeoutSeconds * 1000; for (;;) { const entries = await collectChecks(); const { failed, pending, succeeded } = evaluate(entries); core.info( `succeeded=${succeeded.length} pending=${pending.length} failed=${failed.length}`, ); if (failed.length) { core.setFailed(`Failing checks: ${failed.join(', ')}`); return; } if (pending.length === 0) { core.info(`All required checks passed: ${succeeded.join(', ') || '(none)'}`); return; } if (Date.now() > deadline) { core.setFailed(`Timed out waiting for: ${pending.join(', ')}`); return; } core.info(`Waiting on (${pending.length}): ${pending.slice(0, 10).join(', ')}${pending.length > 10 ? ', …' : ''}`); await sleep(intervalSeconds * 1000); }