Skip to content

Build/Test Tools: Batch timeout-annotation checks via GraphQL (alternative to #639) - #640

Draft
desrosj wants to merge 15 commits into
trunkfrom
claude/slack-webhook-timeout-graphql
Draft

Build/Test Tools: Batch timeout-annotation checks via GraphQL (alternative to #639)#640
desrosj wants to merge 15 commits into
trunkfrom
claude/slack-webhook-timeout-graphql

Conversation

@desrosj

@desrosj desrosj commented Aug 28, 2026

Copy link
Copy Markdown
Owner

This branches from claude/slack-webhook-empty-data-3r39gl (#639) and changes exactly one thing, for side-by-side comparison: how the "Determine whether the workflow timed out" step checks cancelled jobs' check-run annotations for the real timeout signal.

  • Claude/slack webhook empty data 3r39gl #639 (REST): loops cancelled jobs and calls checks.listAnnotations once per job (checks: read), checking the longest-running jobs first as a fast path. Worst case is still one request per cancelled job.
  • This branch (GraphQL): batches up to 100 cancelled jobs per request using the root nodes( ids: [ID!]! ) field and CheckRun.annotations, via each job's node_id (already present on the REST job objects, no extra lookup). Collapses what could be dozens of requests into a small constant number.

Everything else — the github.paginate() normalization fix for listJobsForWorkflowRunAttempt, the checks: read permission grant on prepare and all 17 calling workflows, and the notify-prepare-failure safety net — is identical between the two branches. checks: read is required either way; GraphQL reads from the same Actions GITHUB_TOKEN permissions as REST.

Trac ticket:

Use of AI Tools

AI assistance: Yes
Tool(s): Claude Code
Used for: Full investigation, implementation, and live validation (via a temporary forced job timeout on a draft PR) of both this approach and the REST-based #639. Reviewed by @desrosj throughout.


This Pull Request is for code review only. Please keep all other discussion in the Trac ticket. Do not merge this Pull Request. See GitHub Pull Requests for Code Review in the Core Handbook for more details.


Generated by Claude Code

claude and others added 15 commits August 28, 2026 13:03
…ils.

When the "Prepare notifications" job in the reusable Slack notifications
workflow throws an unhandled error, the "failure" and "cancelled" jobs
were still running because their `if` conditions included `|| failure()`
and `|| cancelled()`. Referencing those status-check functions disables
GitHub Actions' default behavior of skipping a job when a job it `needs`
did not succeed, so these jobs ran anyway and posted to Slack using
`needs.prepare.outputs.payload`, which is empty because `prepare` never
reached the step that sets it. This is why timed out (and some failed)
workflow runs have been posting empty-looking Slack messages.

This was most recently observed in a run where the "Determine whether the
workflow timed out" step threw `TypeError: Cannot read properties of
undefined (reading 'conclusion')` from `jobs.some(...)`, because
`github.paginate()` can merge in an undefined entry for `jobs` when a
page response is missing its `jobs` array.

- Remove `|| failure()` and `|| cancelled()` from the "failure" and
  "cancelled" jobs' conditions so they consistently rely on the default
  "skip if `needs` did not succeed" behavior, matching "success",
  "fixed", and "timeout" already do.
- Add a defensive `job &&` guard in the timeout-detection script so a
  malformed pagination page can't crash the job.
- Add a new "notify-prepare-failure" job that posts a distinct alert to
  Slack whenever `prepare` fails, so a broken notification pipeline is
  surfaced instead of failing silently.

See #65845.
…meout.

The previous `job &&` guard only prevented a crash when `github.paginate()`
merged in a missing entry for a page of `listJobsForWorkflowRunAttempt`
results; it didn't address the underlying under-detection risk. Treating a
missing page as "no timed out job here" can silently misclassify a run as
`cancelled` instead of `timeout` if the job that actually timed out was on
that page.

This job only runs once every job it `needs` has reached a terminal state,
but the REST API's view of job data can briefly lag behind that. Poll
`listJobsForWorkflowRunAttempt` (up to 5 attempts, 3s apart) until every job
in the list has a recorded conclusion and no entries are missing, instead of
trusting the first response. Also request `per_page: 100` to avoid needing
multiple pages for typical job counts in the first place.

See #65845.
- Inline the retry delay since it was only used once.
- Reduce the max attempts from 5 to 3.
- Back off exponentially starting at 1 second (1s, then 2s) between attempts
  instead of a fixed delay.

See #65845.
Root-caused with a reproduction against the real @octokit/plugin-paginate-rest
package: this endpoint's `{ total_count, jobs: [...] } ` response body is
unconditionally normalized by `github.paginate()` before any custom map
function sees it, replacing `response.data` with the `jobs` array itself.
Reading `response.data.jobs` off of that (as this script did) is therefore
always `undefined`, on every page, deterministically -- not an occasional
race tied to jobs still being cancelled, as previously assumed. That
`undefined` then gets merged into the results by `paginate()`, crashing
`.some()` when it's reached.

Because this was never a timing issue, the previous polling/backoff retry
loop couldn't fix it (it always saw the same undefined entry and always
fell through to 'false'). Since this script has been returning 'false' for
every timed-out run since it was introduced, timeouts have likely never
been correctly classified as 'timeout' -- only ever falling through as a
plain 'cancelled' notification instead.

Fix: stop passing a map function that reads `.jobs` and let `paginate()`
return the array it already normalized for us. Verified against the real
normalize-paginated-list-response.js logic for both a single-page and a
multi-page response.

See #65845.
…lusion.

Live-tested by forcing an actual job timeout (temporary 1-minute
timeout-minutes override) on a fork: every job that hit its own timeout was
reported with `conclusion: 'cancelled'` via listJobsForWorkflowRunAttempt,
identical to a job cancelled for any other reason (e.g. fail-fast cancelling
a sibling after another job failed). `timed_out` is a documented value for
the `conclusion` field, but GitHub Actions does not appear to ever set it.

The real signal survives elsewhere: the timed-out job's check-run
annotations still record "The job has exceeded the maximum execution time
of ...", confirmed against the live test run's actual API response. Switch
detection to check each cancelled job's annotations for that message via
`checks.listAnnotations`, short-circuiting on the first match. Only runs
for jobs already filtered to `conclusion === 'cancelled'`, so this adds no
extra API calls on a normal (non-cancelled) run.

Also:
- Add `checks: read` to the "prepare" job's permissions, required for the
  annotations call.
- Revert the temporary debugging changes used to force and inspect the live
  timeout (per-job 1-minute timeout override, always()-gated ifs to run on
  a fork, and the console.log of the raw jobs array).

See #65845.
Re-apply the same live-test setup as before (1-minute forced job timeout,
always()-gated ifs so the notification jobs run on a fork PR) to validate
the new annotation-based timeout detection end to end, plus logging for
each cancelled job's annotation check and the final result.

This is temporary and will be reverted once validated.
…ler.

The first live-test push failed with a startup_failure before any jobs
ran: "The nested job 'prepare' is requesting 'checks: read', but is only
allowed 'checks: none'." A reusable workflow's job can never request more
permissions than its caller grants, and the `checks: read` added to the
"prepare" job's permissions (needed for the annotations call) wasn't
granted by any of the 17 workflows that call slack-notifications.yml.

Add checks: read alongside the existing actions: read / contents: read at
each of those 17 call sites, matching the existing pattern exactly.

See #65845.
The annotation-based timeout detection and checks: read permission grant
were validated live on #639 (see run
https://github.com/desrosj/wordpress-develop/actions/runs/33181690594):
prepare completed successfully, correctly identified the forced timeout via
its check-run annotation ("The job has exceeded the maximum execution time
of 1m0s"), and the notify-prepare-failure job correctly stayed skipped
since prepare didn't fail.

Revert the 1-minute forced job timeout, the always()-gated ifs used to run
on this fork's PR, and the console.log debug output.

See #65845.
Neither approach considered for speeding up timeout detection touched
correctness the same way. Filtering to jobs whose runtime falls within some
window of the configured timeout-minutes was ruled out: that value isn't
exposed anywhere in the Jobs API, and in this repo it isn't even static
(`timeout-minutes: ${{ inputs.coverage-report && 120 || ... }}`), so
deriving it would mean reimplementing GitHub Actions expression evaluation
just to get a fuzzy matching window -- with real risk of a false negative
if that window is wrong for a given job's configured timeout.

Sorting cancelled jobs by actual runtime (completed_at - started_at)
descending has none of that risk: it only changes check order, not which
jobs get checked, so a real timeout can never be skipped -- worst case is
identical to before, but the annotations loop's early return now typically
fires after checking the single most-likely candidate instead of walking
through jobs cancelled early by an unrelated fail-fast cascade first.

See #65845.
Only used in the one .sort() call, so a standalone function was unnecessary.

See #65845.
Alternative to the REST-based approach on claude/slack-webhook-empty-data-3r39gl,
for comparison. Everything else is identical (the paginate() normalization fix,
the checks: read permission grants, the notify-prepare-failure safety net) --
only how cancelled jobs' annotations get checked differs.

REST's `checks.listAnnotations` is scoped to one check run per request, so
checking N cancelled jobs costs up to N requests (mitigated on the REST branch
by checking longest-running jobs first, but still worst-case O(N)). GraphQL's
`CheckRun.annotations` field, reached via the root `nodes( ids: [ID!]! )`
field, batches up to 100 check runs' annotations into a single request using
the `node_id` already present on each job object from the existing REST jobs
call -- collapsing what was up to N requests into a small constant number
regardless of how many jobs were cancelled.

checks: read is still required either way: GraphQL reads from the same
Actions GITHUB_TOKEN permissions as REST, there's no separate GraphQL-only
permission model.

See #65845.
Same live-test setup as used to validate #639 (1-minute forced job timeout,
always()-gated ifs to run on this fork's PR), plus logging of each GraphQL
batch's result, to validate this branch's approach end to end.

This is temporary and will be reverted once validated.
Validated live on #640 (run
https://github.com/desrosj/wordpress-develop/actions/runs/33198104148):
prepare completed successfully, and the GraphQL batch correctly identified
the forced timeout across all 7 cancelled jobs in a single request (versus
7 separate REST requests on the equivalent #639 run).

Revert the 1-minute forced job timeout, the always()-gated ifs used to run
on this fork's PR, and the console.log debug output.

See #65845.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants