Skip to content

Commit f099dca

Browse files
os-steveclaude
andauthored
ci(docs-drift): tolerate transient API failures when delivering the advisory, loudly (#9423)
The `Flag docs affected by code changes` job died four consecutive times on PR #9370 (2026-08-17, 17:17Z-18:24Z) with a 503 from `/repos/.../issues/9370/comments`. actions/github-script routes any throw from the inline script to `main().catch(handleError)` -> `core.setFailed(...)`, so a degraded GitHub API turned an advisory-only check red and cost four re-runs that no local change could have fixed. Delivery of the advisory comment now retries a bounded, narrow transient class (5xx, 429, 403 carrying a secondary-rate-limit signature, and network-level codes) and, once the retries are spent, degrades VISIBLY instead of failing: a warning annotation plus a job summary that names the failed call, states that the run's verdict is NOT on the pull request, warns that any advisory comment shown there is from an earlier push, and reproduces the verdict it did compute. The tolerance is scoped to delivery only. A malformed `affected.json`, a 422 over-long body, a plain 403 permission denial and any non-HTTP error still fail the job — swallowing those would let "could not tell" render as "no drift", which is the same defect wearing the opposite mask. Fixes #9373 Claude-Session: https://claude.ai/code/session_01XqDQYVU5smx29ts9pAErja Co-authored-by: claude <noreply@anthropic.com>
1 parent a433122 commit f099dca

1 file changed

Lines changed: 166 additions & 10 deletions

File tree

.github/workflows/docs-drift-check.yml

Lines changed: 166 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,13 @@ name: Docs Drift Check
33
# When a PR changes packages/** code, flag the hand-written docs that NAME something the
44
# change touched — a symbol, a wire route, or the SDK method a route ledger binds to it —
55
# so they can be re-verified for implementation accuracy before the drift lands on main.
6-
# Advisory only: posts a PR comment, never fails the build. The actual LLM audit is run
7-
# on-demand / on a schedule via the `docs-accuracy-audit` workflow, scoped to exactly the
8-
# docs this check lists.
6+
# Advisory only: posts a PR comment, and its VERDICT never fails the build. Since #9373 a
7+
# transient GitHub API failure while DELIVERING that comment does not fail it either — but
8+
# it is never swallowed: the run then states, in its own job summary and a warning
9+
# annotation, that the advisory could not be delivered, so "could not tell" can never
10+
# render as "no drift". Scan and derivation errors DO still fail the job. The actual LLM
11+
# audit is run on-demand / on a schedule via the `docs-accuracy-audit` workflow, scoped to
12+
# exactly the docs this check lists.
913
#
1014
# It used to list pages by PACKAGE DEPENDENCY ("which docs mention @objectstack/x"), and
1115
# #9192 measured that wrong in both directions on a real PR: 2 of 3 listed pages were
@@ -192,12 +196,164 @@ jobs:
192196
);
193197
body = body.join('\n');
194198
}
195-
const { data: comments } = await github.rest.issues.listComments({
196-
owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number,
197-
});
199+
// ── Delivery, and ONLY delivery, tolerates platform weather (#9373) ────
200+
//
201+
// Everything above is the VERDICT. Everything below merely carries it to the
202+
// PR conversation. Those are two different failures and must not share one
203+
// outcome:
204+
//
205+
// the scan ran wrong / the body could not be built → red job (unchanged)
206+
// the computed advisory could not be POSTED → green job, said aloud
207+
//
208+
// Measured: this job died four consecutive times on PR #9370 (17:17Z-18:24Z,
209+
// 2026-08-17), every time with
210+
// HttpError: No server is currently available to service your request.
211+
// response: { url: '.../issues/9370/comments', status: 503 }
212+
// github-script hands any throw from this script to `main().catch(handleError)`
213+
// -> `core.setFailed('Unhandled error: ...')`, so an advisory-only check went
214+
// red on GitHub's weather and cost four re-runs that no local change could fix.
215+
// That URL is the endpoint of BOTH `listComments` (GET) and `createComment`
216+
// (POST), so the recorded log cannot say which call was rejected — both are
217+
// covered below.
218+
//
219+
// ⛔ Deliberately NOT `continue-on-error: true`, and NOT a bare catch:
220+
// - this step also parses `affected.json` and builds `body`, so blanket
221+
// tolerance would let a malformed scan result or an over-long (422) comment
222+
// read as a clean run — real breakage wearing a green tick;
223+
// - a swallowed failure leaves the run saying NOTHING, and then "could not
224+
// tell" renders exactly like "no drift". That is the same defect in the
225+
// opposite mask, and it is precisely what this file's #9192 posture — say
226+
// what the run could not see — exists to prevent.
227+
// So: a narrow transient class, bounded retries, and, when they are spent, a
228+
// loud statement in the run's own output of exactly what was lost.
229+
230+
// Transient = the request never received a considered answer.
231+
// 5xx the server declined to serve it. octokit also normalises
232+
// network-layer failures into a 500-shaped RequestError; the explicit
233+
// code set covers any that arrive unnormalised.
234+
// 429 rate limited.
235+
// 403 + a rate-limit signature — GitHub answers a SECONDARY rate limit with
236+
// 403 as well as 429, so the signature, never the status alone, is what
237+
// separates it from a genuine permission denial.
238+
// Everything else stays fatal on purpose: 401 / plain 403 (the `permissions:`
239+
// block above is wrong), 404 (wrong target), 422 (the body this workflow built
240+
// is not postable — e.g. past GitHub's 65536-character comment limit), and any
241+
// non-HTTP error such as a TypeError in the code above. Those are this repo's
242+
// own bugs and must keep failing the job.
243+
const TRANSIENT_NETWORK_CODES = new Set([
244+
'ECONNRESET', 'ECONNREFUSED', 'ETIMEDOUT', 'EAI_AGAIN', 'ENOTFOUND', 'EPIPE',
245+
'EHOSTUNREACH', 'ENETUNREACH', 'UND_ERR_SOCKET', 'UND_ERR_CONNECT_TIMEOUT',
246+
]);
247+
const isTransient = (error) => {
248+
if (error && TRANSIENT_NETWORK_CODES.has(error.code)) return true;
249+
const status = error && typeof error.status === 'number' ? error.status : null;
250+
if (status === null) return false;
251+
if (status >= 500 || status === 429) return true;
252+
if (status === 403) {
253+
const remaining = error.response?.headers?.['x-ratelimit-remaining'];
254+
return String(remaining) === '0'
255+
|| /secondary rate limit|abuse detection/i.test(String(error.message || ''));
256+
}
257+
return false;
258+
};
259+
260+
// Bounded, and deliberately short. The measured incident ran over an hour — no
261+
// retry budget rides that out, and pretending otherwise only burns runner
262+
// minutes before degrading anyway. The retries are for a BLIP; the visible
263+
// degradation below is what handles an incident. The delays are a judgement,
264+
// not a measurement.
265+
const RETRY_DELAYS_MS = [3000, 9000];
266+
const ATTEMPTS = RETRY_DELAYS_MS.length + 1;
267+
const deliver = async (label, call) => {
268+
for (let attempt = 0; ; attempt++) {
269+
try {
270+
return await call();
271+
} catch (error) {
272+
if (!isTransient(error) || attempt >= RETRY_DELAYS_MS.length) throw error;
273+
const wait = RETRY_DELAYS_MS[attempt];
274+
core.info(`${label}: transient ${error.status ?? error.code} — retrying in ${wait}ms (attempt ${attempt + 2}/${ATTEMPTS})`);
275+
await new Promise((resolve) => setTimeout(resolve, wait));
276+
}
277+
}
278+
};
279+
280+
// Degrade VISIBLY — the house pattern (check-links.yml's "the link check did
281+
// not run", cross-repo-issue-closer.yml's missing-token notice). A reader looks
282+
// for this advisory's verdict on the PR; when it cannot be put there, the run
283+
// page must say so in full: what failed, what the reader must NOT infer from
284+
// whatever is on the PR, and the verdict itself — degraded but delivered,
285+
// never lost.
286+
const degrade = async (stage, error, staleNote) => {
287+
const detail = typeof error.status === 'number' ? `HTTP ${error.status}` : (error.code || 'error');
288+
// Octokit messages usually end in a full stop; ours supplies its own.
289+
const reason = `${detail}: ${String(error.message || '').replace(/\s*\.\s*$/, '')}`;
290+
const note = [
291+
'## ⚠️ Docs Drift Check — advisory computed, but NOT posted to this PR',
292+
'',
293+
`The scan completed; this is a **delivery** failure only. \`${stage}\` was rejected on all`,
294+
`${ATTEMPTS} attempts: \`${reason}\`.`,
295+
'',
296+
`- ${staleNote}`,
297+
'- The verdict this run computed is reproduced below. It is **not** on the pull request.',
298+
'- This job is **green on purpose**: its conclusion reflects the scan, not the',
299+
' deliverability of its courtesy comment (#9373). The check is advisory either way.',
300+
'- Re-run this job to retry delivery once the API recovers.',
301+
'',
302+
'---',
303+
'',
304+
body,
305+
'',
306+
].join('\n');
307+
try {
308+
await core.summary.addRaw(note).write();
309+
} catch (summaryError) {
310+
// The summary is the richer channel, the annotation the reliable one.
311+
// Losing the richer one must not restore the silence this exists to prevent.
312+
core.info(`Could not write the job summary: ${summaryError.message}`);
313+
}
314+
core.warning(
315+
`The docs-drift advisory was computed but could not be posted to this PR: `
316+
+ `${stage} failed ${ATTEMPTS}x with ${reason}. ${staleNote} `
317+
+ `The verdict is in this run's job summary. Transient GitHub API failure (#9373) — `
318+
+ `the job stays green because the scan itself is unaffected.`,
319+
{ title: 'Docs drift advisory not delivered' },
320+
);
321+
};
322+
323+
let comments;
324+
try {
325+
({ data: comments } = await deliver('issues.listComments', () => github.rest.issues.listComments({
326+
owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number,
327+
})));
328+
} catch (error) {
329+
if (!isTransient(error)) throw error;
330+
// Without the listing there is no way to tell an update from a create.
331+
// Posting blind would strand a SECOND advisory comment that the marker dedup
332+
// then updates forever alongside the first, and every comment here is relayed
333+
// into subscribed agent sessions (#9037). Saying so costs less than
334+
// duplicating.
335+
await degrade(
336+
'issues.listComments',
337+
error,
338+
'This run could not even determine whether an advisory comment exists on this PR; if one is shown there, it is from an earlier push.',
339+
);
340+
return;
341+
}
342+
198343
const existing = comments.find(c => c.body && c.body.includes(marker));
199-
if (existing) {
200-
await github.rest.issues.updateComment({ owner: context.repo.owner, repo: context.repo.repo, comment_id: existing.id, body });
201-
} else {
202-
await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, body });
344+
try {
345+
if (existing) {
346+
await deliver('issues.updateComment', () => github.rest.issues.updateComment({ owner: context.repo.owner, repo: context.repo.repo, comment_id: existing.id, body }));
347+
} else {
348+
await deliver('issues.createComment', () => github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, body }));
349+
}
350+
} catch (error) {
351+
if (!isTransient(error)) throw error;
352+
await degrade(
353+
existing ? 'issues.updateComment' : 'issues.createComment',
354+
error,
355+
existing
356+
? `The \`docs-drift-check\` comment on this PR still shows an EARLIER run's verdict — this run could not refresh it.`
357+
: 'No advisory comment was posted on this PR for this run.',
358+
);
203359
}

0 commit comments

Comments
 (0)