Instruction file imported from kurone-kito/builder-config (
.github/instructions/idd-ci.instructions.md). Copyright stays with the author.
IDD — CI Polling (Shared Helper)
Read this file when you need to wait for CI after a push. Callers define their own on-success target before invoking this algorithm.
The shared CI wait defaults are listed in
IDD policy constants. Resolve via
.github/idd/config.json ciWait.runningTimeout,
ciWait.generationTimeout, and ciWait.rerunPolicy when present and
valid; otherwise keep the distributed defaults (PT30M, PT10M,
rerun-once).
When helper support is installed, use the profile-selected ci-wait policy helper command as the canonical read-only policy resolver.
# source repo / vendored-node profile
node scripts/ci-wait-policy.mjs
# package-manager / ephemeral-npx profile
<profile-selected-ci-wait-policy-command>
Prefer --run-id <run-id> (derives the rerun-budget count from the
run's own run_attempt field, mirroring
rerun-advisory-convergence.mjs) when a run ID is available; append
--rerun-count <count> directly for the same deterministic
rerun-budget decision otherwise — this manual form remains valid and
is not being replaced. Resolve
<profile-selected-ci-wait-policy-command> from
docs/idd-helper-scripts.md. Do not hardcode
node scripts/ci-wait-policy.mjs for profiles that don't vendor
scripts/.
Shared policy keys
ciWait.runningTimeout: max time polling a running required check before stalled-run recovery begins. Default:PT30M(30 min).ciWait.generationTimeout: max time to wait for required checks to appear at all. Default:PT10M(10 min).ciWait.rerunPolicy: rerun budget for infra/stalled CI recovery. Default:rerun-once— the first eligible infra/stalled route reruns exactly once, the next recurrence holds.hold— never auto-rerun; post a hold at the first eligible route.
Inputs
Before polling, collect:
- PR number and current PR head SHA.
- The required-check set for the target base branch.
- Current check/run statuses for the same head SHA.
Use GitHub server timestamps and states only.
Required-check discovery
Determine required checks from branch protection or rulesets before
interpreting gh pr checks output.
-
Fetch ruleset summaries:
gh api repos/{owner}/{repo}/rulesets --paginate -
Fetch each ruleset detail:
gh api repos/{owner}/{repo}/rulesets/{ruleset-id}Use detail payload rules only from enforcing rulesets that apply to the PR base branch.
-
Fetch branch protection checks for the base branch too (do not treat this as mutually exclusive with rulesets). URL-encode branch names before calling this endpoint:
gh api repos/{owner}/{repo}/branches/{url-encoded-base-branch}/protection -
Distinguish a permission error from a genuine empty result on each of the three reads above. (Ruleset-detail, step 2, only runs once per ruleset ID step 1 already returned, so an empty step-1 list means step 2 is skipped, not called with an empty result.) A
403on any of the three reads means the read itself failed — the token lacks permission — not that no required checks exist; never substitute an empty array/object for it. Record it as unreadable.Treat every
404on these reads exactly like a403by default. None of the three endpoints documents403as a possible response at all, so a404is structurally ambiguous between "genuinely nothing configured" and "the token cannot read this" — see design rationale for the full GitHub-documentation citations behind this rule. A repository may opt out and restore the pre-#1377trusting behavior (a404on these reads is genuinely empty) by recordingciGate.trustEmptyProtectionReads: truein.github/idd/config.json— a git-committed, human-authorized policy decision, not a runtime check of the caller's token scope. Absent orfalsekeeps the fail-closed default.If any of the three reads is unreadable (a confirmed
403, or an untrusted404per above), fail closed: do not fall through to step 6 below. Post a hold comment stating "cannot determine required checks: protection/ruleset unreadable" and stop. This is distinct from the genuinenoRequiredChecksConfiguredcase in step 6, which requires every read to have returned a genuine, trusted result — a200, or a404trusted underciGate.trustEmptyProtectionReads— never an unreadable one. -
Build the required-check set as the union of enforcing-ruleset checks and branch-protection checks, using only the genuine (readable, not unreadable) results from step 4. Keep expected check source metadata (GitHub App/integration) when configured.
-
If neither source yields a required-check set, and step 4 found no unreadable result: not automatically a hold — it's the same
noRequiredChecksConfigured: truestate F2's CI gate already interprets (idd-pre-merge.instructions.md). Reusepre-merge-readiness'sci.presentRunConclusionwhen available; otherwise derive the equivalent from actual runs at the head SHA:all-passingmay proceed;pending→ wait/re-check;some-failingornone(no runs) → hold — never treat an empty required-check set as a vacuous pass. Full routing table: F2 — Pre-merge condition check.
When caller phases already provide a trusted required-check set, reuse that set instead of re-deriving it.
Polling algorithm
-
Fetch current checks for the PR:
gh pr checks {pr-number} --json name,state,bucket,startedAt,completedAt,linkDuplicate-name-safe, HEAD-pinned reads:
gh pr checkscan collapse same-named checks across workflows. When helper support is installed, read the profile-selectedci-wait-statesnapshot instead (keyed by(checkName, workflowName), liveheadRefOid); seedocs/idd-helper-scripts.md.# source repo / vendored-node profile node scripts/ci-wait-state.mjs --pr {pr-number} # package-manager / ephemeral-npx profile <profile-selected-ci-wait-state-command> --pr {pr-number} -
Normalize check states:
- treat
skipped,neutral, andnot_applicableas pass-equivalent - treat
pending,requested,waiting,queued,in_progress, and the Commit-Statusexpectedstate as running - keep
failure,cancelled,timed_out,action_required,startup_failure, andstaleas non-pass
- treat
-
Evaluate only checks in the required-check set, and match expected check source when the required definition includes an app/integration constraint.
-
Repeat at a reasonable interval until a terminal route in the table below is reached.
Measure each running check's ciWait.runningTimeout window from its
server startedAt. When absent (a queued check not yet started), the
running-timeout hasn't begun: keep polling, capped at
ciWait.generationTimeout. Some running states (e.g. a Commit-Status
expected context) never report startedAt — when
ciWait.generationTimeout elapses with still none, post a hold and
escalate rather than poll indefinitely. Never anchor the window to a
client clock.
Do not rely on gh pr checks command exit code as the gate decision.
The decision must be based on normalized required-check states.
Rerun mechanics
When the resolved ciWait.rerunPolicy says rerun, rerun the exact
failed or stalled run:
- rerun whole run:
gh run rerun <run-id> - rerun failed jobs only:
gh run rerun --failed <run-id>
Extract <run-id> from the failing check link field (for example:
https://github.com/{owner}/{repo}/actions/runs/<run-id>/job/<job-id>),
or query the Actions API for runs filtered to the current PR head SHA and
check name.
If GH CLI cannot resolve a run ID, use Actions REST endpoints directly for the same run before posting a hold.
idd-advisory-convergence specifically (when hosted as a required
check): workflow_dispatch does not reliably refresh the PR's
required-check rollup for current HEAD — a manually dispatched run has
no pull_request context to associate with the PR's HEAD SHA (full
investigation: this repo's dogfooded
.github/workflows/idd-advisory-convergence.yml
header comment — not present in the portable stub this template
ships). For a stuck or stale rollup entry, apply the rerun mechanic
above (gh run rerun <run-id> on the existing PR-linked run)
instead of workflow_dispatch.
A second cause: GitHub gates a bot-triggered run (e.g. Copilot's
pull_request_review/pull_request_review_comment event) to
action_required, and the bot event alone never refreshes the check.
Recover by rerunning the existing non-bot pull_request-triggered
run for this HEAD (subject to ciWait.rerunPolicy) — never the gated
bot run itself, which keeps the original actor's privileges and
re-enters action_required (approve via POST /repos/{owner}/{repo}/actions/runs/{run_id}/approve if it must run).
The check also self-heals on the next non-bot trigger — a push or a
review-thread reply, not a regular PR comment (no issue_comment
subscription).
If rerunning the passing non-bot instance alone does not clear the
rollup (#1745): a HEAD can carry several idd-advisory-convergence
check-run instances at once (the check fires on pull_request plus
pull_request_review/pull_request_review_comment, and
cancel-in-progress cancels most of them), and GitHub's own required-check
rollup can stay pinned to a bot-triggered instance whose conclusion is
CANCELLED — distinct from the action_required case above. Unlike
action_required, a CANCELLED-conclusion bot-triggered instance is
not gated: rerunning it completes normally and does not re-enter
action_required (confirmed by direct experiment, #1745). If the
non-bot rerun above does not clear the block, rerun every
CANCELLED-conclusion bot-triggered sibling instance for the same HEAD
next (gh run rerun <run-id> on each, one at a time, per the sequential
rule in the helper-first plan below) — only an action_required-conclusion
instance stays withheld from rerun.
Helper-first: prints this diagnosis and ordered rerun plan, read-only
by default; pass --apply to also execute it — the preferred one-shot
recovery path when a helper runtime is available. --apply reruns each
rerun-eligible instance in order (recovery-refresh first when one
applies), waits for each to reach a terminal state before starting the
next, and stops early as soon as the rollup resolves — never a
bot-gated-skip or rerun-budget-held instance.
# source repo / vendored-node profile
node scripts/rerun-advisory-convergence.mjs --pr <n> [--apply]
# package-manager / ephemeral-npx profile
<profile-selected-rerun-advisory-convergence-command> --pr <n> [--apply]
Resolve <profile-selected-rerun-advisory-convergence-command> from
docs/idd-helper-scripts.md; do not hardcode node scripts/... for
non-vendored profiles. On instructions-only (no helper runtime), fall
back to the manual sequence: run the diagnostic, then gh run rerun <run-id> on each plan entry one at a time, waiting for each to finish
before the next.
Terminal-waiver recheck (#1570): once a maintainer waives a proven
COPILOT_UNAVAILABLE state
(Terminal routing),
rerun this SAME existing run via the mechanic above — never
workflow_dispatch.
Shared rerun-once budget with comment-triggered refreshes (#171):
rerun-advisory-convergence.mjs's per-instance rerun eligibility (keyed
by GitHub's own run_attempt on that check-run instance) is the SAME
ciWait.rerunPolicy budget this section's own CI-wait infra-retry uses
— it is also spent by the idd-advisory-convergence-comment.yml
companion workflow (added by issue #163; see docs/idd-policy.md's
idd-advisory-convergence workflow section) when it reruns a check
instance in response to an IDD-originated review-thread comment. If one
caller has already rerun a given instance once, a later IDD-originated
comment wanting to refresh that same still-stuck instance is silently
withheld as rerun-budget-exhausted rather than granted an independent
budget.
This is accepted, intentional, bounded-recovery-by-design behavior, not
a bug — it mirrors ciWait.rerunPolicy's own escalate-after-one-rerun
philosophy rather than letting reruns multiply across trigger sources.
Self-healing recovery: if a same-HEAD comment-triggered refresh is
withheld this way, the next push creates a new run instance (its own
fresh run_attempt) with its own budget, clearing the stale-red state
with no further action needed. A maintainer's manual rerun (gh run rerun <run-id> or the Actions UI) can also force that same run to
execute again — a human isn't bound by this automation's own
once-only budget, though this only forces a fresh execution, not a
guaranteed pass. It reruns the same run and increments that run's
run_attempt, so this tooling's own automated rerun still treats
that instance as budget-exhausted afterward. A fresh bot review is
not a reliable recovery path on its own: its own bot-triggered run
can re-enter action_required (see the bot-gated cause above)
instead of clearing the rollup.
Queue-eviction of a queued comment-triggered refresh (#177):
idd-advisory-convergence-comment.yml's own concurrency group uses
cancel-in-progress: false, grouped by PR number, which queues at
most one pending run per PR (GitHub's default queue: single behavior
— neither workflow sets queue: max, the newer opt-in that allows up
to 100 pending runs). The required idd-advisory-convergence.yml
workflow's own group queues the same single-pending way — under this
default, GitHub evicts an older pending run in a concurrency group on
a new trigger regardless of cancel-in-progress — but that required
group additionally sets cancel-in-progress: true, which also cancels
its own currently-running instance; the companion workflow leaves an
already-running instance alone. If an IDD-originated comment's triggered
run is queued behind an already-executing run, and a later ORDINARY
(non-IDD) comment arrives before the first finishes, this pending-run
eviction replaces the queued IDD-originated run with the newer one. The
classify step (review-comment-origin.mjs) only inspects the triggering
event's own comment body — the current body, plus the pre-edit body on an
edited event — never full PR state, so the evicting run correctly —
from its own narrow per-event view — classifies itself non-IDD-originated
and takes no action; the evicted refresh is lost until another trigger
fires.
This is accepted as a residual of the workflow's own narrow, per-event
classify-step design, not a defect to silently patch — no fix was found
that clearly improves on this without its own tradeoffs: evaluating full
current PR state instead of just the triggering comment would be a real
architectural change, and even a narrower per-event alternative — keying
the concurrency group by comment id instead of PR number — would trade
away the shared per-PR serialization that prevents concurrent reruns of
the same required-check run; neither is an obviously-better alternative.
Self-healing recovery: a subsequent push creates a fresh run
instance and clears the stale state. A maintainer can also clear it by
forcing the required workflow's own existing run for current HEAD to
execute again — gh run rerun <run-id> targeting that run, or the
Actions UI — which reuses the existing run rather than creating a new
instance; never rerun the companion workflow's evicting run instead,
which just reclassifies the same ordinary comment and no-ops again. A
fresh bot review is not a reliable recovery path here either — the
same bot-gated action_required risk noted above (a
pull_request_review-triggered run for the required workflow can
re-enter action_required instead of completing) applies to this
residual too.
Interpretation
| State (required checks only, normalized) | Action |
|---|---|
| All required checks are generated and pass-equivalent | → on-success (caller-defined) |
Any required check is non-pass failure, action_required, startup_failure, or stale |
Inspect the log. Infra/flaky: apply ciWait.rerunPolicy (default rerun-once) — rerun the exact failed run once and resume polling, or hold and stop. Code-caused: fix, fix-validate, commit atomically, return to caller's pre-push step. action_required/startup_failure/stale rarely clear on a blind rerun — if it needs a maintainer action or fresh run, hold rather than loop reruns. Exception: idd-advisory-convergence stuck at action_required from a gated bot run recovers by rerunning the existing run per ciWait.rerunPolicy (see §Rerun mechanics). Exception 2: idd-advisory-convergence alone non-pass with pending: false and outstanding review reasons — D4/E15 exit to E1 (both carve out a just-posted maintainer waiver, which still needs the rerun — see D4); F2/F3 unaffected. |
Any required check is non-pass cancelled or timed_out |
Code-caused: fix, fix-validate, commit atomically, return to caller's pre-push step. Infra-caused: apply ciWait.rerunPolicy; rerun/re-push only within budget, otherwise hold and stop. |
Any required check is running (pending/requested/waiting/expected/...) |
Continue waiting. After ciWait.runningTimeout (from server startedAt; default 30 min) with no completion, apply ciWait.rerunPolicy — rerun once and resume, or hold and stop if the route recurs or policy is hold. |
Required checks are not generated after ciWait.generationTimeout |
Treat as running (default 10 min). If the workflow run doesn't exist at all when that window elapses, hold and escalate to a maintainer, then stop. |
Hold-and-report failure shapes
Recognize this shape in one pass; hold-and-report instead of the infra-vs-code triage above:
- Account-level Actions billing / spend-limit block: every job in
every workflow fails near-instantly with an identical platform banner
(the run starts but no steps execute, unlike a normal step failure).
Non-transient — a rerun reproduces it, no code change fixes it. Skip
ciWait.rerunPolicy; post a hold comment naming the block and stop for a maintainer.
Wake-up discipline
This advisory, tool-agnostic note keeps the wait itself cheap: the dominant cost is each re-invocation's context re-read (worse past the prompt-cache TTL), not the idle time.
Portability: under supervisor/worker topologies, a background wait's completion notification often reaches only the supervisor, so the worker's turn stalls until re-prompted — the topology-safety condition below accounts for this.
- No interim polling turns — schedule one wake at the expected
completion, or background only if the topology is confirmed to route
completion back to this turn; otherwise wait synchronously — block
with
gh pr checks <pr-number> --watch --required(works on a fine-grained PAT;gh run watch <run-id> --exit-statusdoes not). Both only block, never decide: required-only scoping, duplicate-name collapse, the no-required-checks route, and theciWait.runningTimeout/generationTimeoutbound all stay with the algorithm above — track elapsed time and apply its rerun-or-hold decision if a watch outlasts it. Issue that blocking call with an execution-timeout override set at or near the calling tool's own execution-timeout ceiling, not the tool's default, which can hard-kill the wait well short ofciWait.runningTimeout; a tool-timeout kill of the watch call is not a CI verdict — re-issue the same blocking watch, keep accumulating elapsed time against the bound above, and do not fall back torun_in_backgroundor another detached/backgrounded mechanism just because of the kill. Neither watches Copilot review state — seeidd-advisory-wait.instructions.md. A baresleepmay be sandboxed or blocked in some runtimes (preventive; no observed incident yet); arun_in_backgroundBash task or other detached/backgrounded mechanism must not be used for this wait unless the topology-safety condition above is confirmed. Never insert "is it done yet?" turns or end this turn assuming an unconfirmed background/async notification resumes it — that stalls silently under supervisor/worker topologies. - Batch post-wait actions into one turn once the wait resolves (disposition, replies, marker, next gate together).
- Scope post-fix re-validation to the changed surface when provably outside the full build/test suite, instead of re-running everything.
This trims only wasteful dimensions (context re-read, CI minutes) — review rounds stay full. Same discipline applies to the advisory-wait and review-fix wait points.
Known residual risk: workers can still stall here — expected and budgeted. Recovery: one message citing live state (PR number, check states, local worktree HEAD SHA).