Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions .github/workflows/ci-gate.yml
Original file line number Diff line number Diff line change
@@ -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).');
Loading