@@ -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