Skip to content

Commit c6d1871

Browse files
committed
ci: add a watchdog for required checks stuck past a threshold
Directly motivated by today's incident: "Superagent Security Scan" (a required, third-party check this repo has zero control over) hung in_progress for 90+ minutes, silently stalling the entire auto-merge pipeline -- this repo's gate merges based on GitHub's own mergeable_state, which only goes "clean" once every required check resolves, so one stuck required check blocks ALL open PRs, not just the one it's running on. Finding it required manually pulling check-run data PR-by-PR; nothing surfaced it proactively. This can't fix a stuck check -- nothing on this repo's side can, for a third-party check it doesn't control -- it just makes the situation visible fast: a scheduled job (every 15 minutes) flags any open, non-draft PR where a required check has been running past a threshold (default 20 minutes, comfortably above the ~1-2.5 minutes these checks normally take) via a single PR comment, idempotent (checks for an existing marker before posting again) so it doesn't spam the same stuck check repeatedly. Required-check names are hardcoded (validate, Superagent Security Scan) rather than read live from branch protection: GET .../protection/ required_status_checks needs "Administration" repository read permission, which the default GITHUB_TOKEN doesn't get even with an elevated permissions: block -- confirmed against GitHub's own docs that scope isn't in the grantable set for the ephemeral per-run token at all. Hardcoding avoids a dependency that would otherwise 403 on every scheduled run. Verified against the real repo (dry-run, both default and threshold-minutes=0): correctly found zero stuck checks with the incident resolved, and confirmed the threshold=0 dry-run correctly excluded a real currently-queued-but-not-yet-started validate job on a live open PR rather than misflagging it -- a job whose check-run has no started_at yet is legitimately queued, not stuck, and the script treats those differently on purpose.
1 parent 218a5ae commit c6d1871

2 files changed

Lines changed: 168 additions & 0 deletions

File tree

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
name: Stuck required-check watchdog
2+
3+
# This repo's auto-merge gate merges based on GitHub's own mergeable_state, which only goes "clean"
4+
# once every required status check resolves (see .claude/skills/contributing-to-loopover/reference.md
5+
# section 3). A required check that hangs forever -- this repo hit exactly this with the third-party
6+
# "Superagent Security Scan" check, stuck in_progress for 90+ minutes with zero visibility beyond
7+
# manually pulling data PR-by-PR -- silently stalls the ENTIRE merge pipeline, not just one PR, with
8+
# nothing surfacing that fact anywhere. This can't fix a stuck check (nothing on this repo's side can,
9+
# for a third-party check it doesn't control), it just makes the situation visible fast: flags any open
10+
# PR where a required check has been running past a threshold, via a single idempotent PR comment (see
11+
# scripts/check-stuck-required-checks.mjs for the detection logic and why the required-check list is
12+
# hardcoded rather than read live from branch protection).
13+
14+
on:
15+
schedule:
16+
- cron: "*/15 * * * *"
17+
workflow_dispatch:
18+
inputs:
19+
dry_run:
20+
description: "Dry run (log what would be flagged, don't post any comments)"
21+
type: boolean
22+
default: false
23+
24+
permissions:
25+
contents: read
26+
pull-requests: write
27+
28+
concurrency:
29+
group: stuck-check-watchdog
30+
cancel-in-progress: true
31+
32+
jobs:
33+
watchdog:
34+
name: watchdog
35+
runs-on: ubuntu-latest
36+
timeout-minutes: 5
37+
steps:
38+
- name: Checkout
39+
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
40+
- name: Setup Node
41+
uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
42+
with:
43+
node-version-file: .nvmrc
44+
- name: Check for stuck required checks
45+
env:
46+
GITHUB_TOKEN: ${{ github.token }}
47+
run: node scripts/check-stuck-required-checks.mjs${{ inputs.dry_run && ' --dry-run' || '' }}
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
#!/usr/bin/env node
2+
// Flags a required status check that's been pending/in_progress on an open PR for longer than a
3+
// threshold, and posts a comment on the affected PR if one isn't already there.
4+
//
5+
// Motivation: this repo hit a real incident where "Superagent Security Scan" -- a required,
6+
// third-party GitHub App check this repo has zero control over -- hung for over 90 minutes with no
7+
// visibility beyond manually pulling data PR-by-PR. Since a required check must resolve before
8+
// mergeable_state can go clean, a single stuck required check silently stalls the ENTIRE auto-merge
9+
// pipeline (this repo's gate merges based on mergeable_state, see .claude/skills/contributing-to-
10+
// loopover/reference.md section 3) -- with nothing surfacing that fact anywhere. This doesn't fix a
11+
// stuck check (nothing on this repo's side can -- it's someone else's service), it just makes the
12+
// situation visible fast instead of requiring another manual multi-PR investigation like the one that
13+
// found the original incident.
14+
15+
const STUCK_THRESHOLD_MINUTES = Number(process.argv.find((a) => a.startsWith("--threshold-minutes="))?.split("=")[1] ?? 20);
16+
const DRY_RUN = process.argv.includes("--dry-run");
17+
const MARKER = "<!-- stuck-required-check-watchdog -->";
18+
19+
const repo = process.env.GITHUB_REPOSITORY;
20+
if (!repo) throw new Error("GITHUB_REPOSITORY is required");
21+
const [owner, repoName] = repo.split("/");
22+
const token = process.env.GITHUB_TOKEN;
23+
if (!token) throw new Error("GITHUB_TOKEN is required");
24+
25+
async function githubApi(path, options = {}) {
26+
const response = await fetch(`https://api.github.com${path}`, {
27+
...options,
28+
headers: {
29+
Authorization: `Bearer ${token}`,
30+
Accept: "application/vnd.github+json",
31+
"X-GitHub-Api-Version": "2022-11-28",
32+
...options.headers,
33+
},
34+
});
35+
if (!response.ok) {
36+
throw new Error(`GitHub API error ${response.status} on ${path}: ${await response.text()}`);
37+
}
38+
return response.status === 204 ? null : response.json();
39+
}
40+
41+
// Hardcoded, not read live from branch protection: GET /branches/main/protection/required_status_checks
42+
// needs "Administration" repository read permission, which the default GITHUB_TOKEN does not get even
43+
// with an elevated `permissions:` block in the workflow (confirmed against GitHub's own docs -- that
44+
// scope isn't in the grantable set for the ephemeral per-run token at all, deliberately, since branch
45+
// protection is considered too privileged). Update this list by hand if the required checks on `main`
46+
// ever change (`gh api repos/{owner}/{repo}/branches/main/protection/required_status_checks` locally,
47+
// with a real user token, to check the current list).
48+
const REQUIRED_CONTEXTS = new Set(["validate", "Superagent Security Scan"]);
49+
50+
async function getOpenPRs() {
51+
const prs = await githubApi(`/repos/${owner}/${repoName}/pulls?state=open&per_page=100`);
52+
return prs.filter((pr) => !pr.draft);
53+
}
54+
55+
function minutesSince(isoString) {
56+
return (Date.now() - new Date(isoString).getTime()) / 60000;
57+
}
58+
59+
async function findStuckChecksForPr(pr, requiredContexts) {
60+
const checkRuns = await githubApi(`/repos/${owner}/${repoName}/commits/${pr.head.sha}/check-runs?per_page=100`);
61+
const stuck = [];
62+
for (const run of checkRuns.check_runs ?? []) {
63+
if (!requiredContexts.has(run.name)) continue;
64+
if (run.status === "completed") continue;
65+
const elapsedMinutes = run.started_at ? minutesSince(run.started_at) : null;
66+
if (elapsedMinutes !== null && elapsedMinutes >= STUCK_THRESHOLD_MINUTES) {
67+
stuck.push({ name: run.name, status: run.status, startedAt: run.started_at, elapsedMinutes: Math.round(elapsedMinutes), htmlUrl: run.html_url });
68+
}
69+
}
70+
return stuck;
71+
}
72+
73+
async function hasExistingWatchdogComment(prNumber) {
74+
const comments = await githubApi(`/repos/${owner}/${repoName}/issues/${prNumber}/comments?per_page=100`);
75+
return comments.some((comment) => comment.body?.includes(MARKER));
76+
}
77+
78+
async function postComment(prNumber, stuckChecks) {
79+
const lines = [
80+
MARKER,
81+
"## ⚠️ A required check looks stuck",
82+
"",
83+
"The following required status check(s) have been pending for longer than expected. Since a required check has to resolve before this PR can be merged, this may be blocking not just this PR but the whole auto-merge pipeline:",
84+
"",
85+
...stuckChecks.map((check) => `- **${check.name}** — pending for ~${check.elapsedMinutes} min (started ${check.startedAt})${check.htmlUrl ? ` — [details](${check.htmlUrl})` : ""}`),
86+
"",
87+
"This is very likely an issue with the check's own service, not this PR's content. If it doesn't resolve on its own, it may be worth checking that service's status directly.",
88+
"",
89+
"_This comment was posted automatically by a scheduled check. It won't repeat for the same stuck check on this PR._",
90+
];
91+
await githubApi(`/repos/${owner}/${repoName}/issues/${prNumber}/comments`, {
92+
method: "POST",
93+
body: JSON.stringify({ body: lines.join("\n") }),
94+
});
95+
}
96+
97+
const prs = await getOpenPRs();
98+
let flaggedCount = 0;
99+
100+
for (const pr of prs) {
101+
const stuckChecks = await findStuckChecksForPr(pr, REQUIRED_CONTEXTS);
102+
if (stuckChecks.length === 0) continue;
103+
104+
console.log(`PR #${pr.number}: ${stuckChecks.length} required check(s) stuck past ${STUCK_THRESHOLD_MINUTES}min: ${stuckChecks.map((c) => `${c.name} (~${c.elapsedMinutes}min)`).join(", ")}`);
105+
106+
if (await hasExistingWatchdogComment(pr.number)) {
107+
console.log(` Already flagged this PR -- skipping (idempotent).`);
108+
continue;
109+
}
110+
111+
if (DRY_RUN) {
112+
console.log(` --dry-run: would post a comment on PR #${pr.number}, skipping the actual POST.`);
113+
continue;
114+
}
115+
116+
await postComment(pr.number, stuckChecks);
117+
flaggedCount += 1;
118+
console.log(` Posted a new comment on PR #${pr.number}.`);
119+
}
120+
121+
console.log(`Checked ${prs.length} open PR(s); flagged ${flaggedCount} new stuck-check comment(s).`);

0 commit comments

Comments
 (0)