diff --git a/.github/workflows/ci-gate.yml b/.github/workflows/ci-gate.yml new file mode 100644 index 0000000..1011837 --- /dev/null +++ b/.github/workflows/ci-gate.yml @@ -0,0 +1,97 @@ +# CI Gate — watches every other check on a PR and blocks merge if any failed. +# Synced to all repos via syncWorkflows.yml. The org ruleset requires exactly +# this check, which transitively requires every other check to pass. +name: CI Gate + +on: + pull_request: + merge_group: + +concurrency: + group: ci-gate-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + checks: read + statuses: read + +jobs: + ci-gate: + runs-on: ubuntu-latest + timeout-minutes: 90 + steps: + - name: Wait for all checks and gate merge + uses: actions/github-script@v7 + with: + script: | + const sha = context.payload.pull_request?.head?.sha + ?? context.payload.merge_group?.head_sha + ?? context.sha; + const { owner, repo } = context.repo; + const SELF = 'ci-gate'; + const GRACE_MS = 15_000; // let other workflows register + const POLL_MS = 30_000; // check every 30 s + const TIMEOUT_MS = 80 * 60_000; // 80 min (under job timeout) + + const PASS_CONCLUSIONS = new Set(['success', 'neutral', 'skipped']); + + core.info(`Gating on commit ${sha}`); + await new Promise(r => setTimeout(r, GRACE_MS)); + + const start = Date.now(); + while (Date.now() - start < TIMEOUT_MS) { + // ── Check Runs (GitHub Actions, third-party apps) ── + const runs = await github.paginate( + github.rest.checks.listForRef, + { owner, repo, ref: sha, per_page: 100, filter: 'latest' } + ); + const others = runs.filter(r => r.name !== SELF); + + // ── Commit Statuses (legacy status API) ── + const rawStatuses = await github.paginate( + github.rest.repos.listCommitStatusesForRef, + { owner, repo, ref: sha, per_page: 100 } + ); + const latest = new Map(); + for (const s of rawStatuses) { + const prev = latest.get(s.context); + if (!prev || s.id > prev.id) latest.set(s.context, s); + } + const statuses = [...latest.values()]; + + // ── Are any still running? ── + const pendingRuns = others.filter(r => r.status !== 'completed'); + const pendingStatuses = statuses.filter(s => s.state === 'pending'); + + if (pendingRuns.length > 0 || pendingStatuses.length > 0) { + const names = [ + ...pendingRuns.map(r => r.name), + ...pendingStatuses.map(s => s.context), + ]; + core.info(`Waiting on ${names.length}: ${names.join(', ')}`); + await new Promise(r => setTimeout(r, POLL_MS)); + continue; + } + + // ── All done — evaluate ── + const failed = [ + ...others.filter(r => !PASS_CONCLUSIONS.has(r.conclusion)) + .map(r => `${r.name} (${r.conclusion})`), + ...statuses.filter(s => s.state !== 'success') + .map(s => `${s.context} (${s.state})`), + ]; + + if (failed.length > 0) { + core.setFailed( + `Blocked — ${failed.length} check(s) failed:\n` + + failed.map(f => ` ✗ ${f}`).join('\n') + ); + return; + } + + const total = others.length + statuses.length; + core.info(`All ${total} check(s) passed.`); + return; + } + + core.setFailed('Timed out waiting for checks to complete (80 min).');