jinwon-int

gh-ci-wait

Use when you pushed or opened/updated a PR and would say "I'll continue once CI is green/failed" — register a durable CI wait; the promise is real only with a wait_id. Also use to triage a SessionStart `⚠ 미완 약속` block (never re-register active `⏳` waits; dropped ones branch on skip_reason).

jinwon-int 0 Updated 2w ago

Resources

1
GitHub

Install

npx skillscat add jinwon-int/ccc-node/gh-ci-wait

Install via the SkillsCat registry.

SKILL.md

gh-ci-wait — durable GitHub CI wait (#740)

A natural-language "I'll continue once CI finishes" creates nothing —
the turn ends and nobody watches GitHub. The promise only becomes real when
you register a durable wait and receive a wait_id.

When to use

  • You pushed or opened/updated a PR and the next step depends on the CI
    outcome (merge, address failures, re-review).
  • You are about to tell the user "I'll continue when CI finishes".

Do NOT use it for sleeps/heartbeats inside the current turn, and never for
merge/deploy/release actions themselves — CI green never auto-approves
those; the continuation turn must ask for the usual approvals.

Register (inside the turn)

python -m telegram_bot.core.external_wait_cli register \
  --repo owner/name --pr <number> --head-sha <exact-head-sha> \
  --summary "<one-line next step>"
  • Pin the exact head SHA you mean (git rev-parse HEAD, or
    gh pr view <n> --json headRefOid --jq .headRefOid). A newer push supersedes
    the wait — it will never report stale CI as your result.
  • Never reconstruct the SHA from an abbreviated prefix you saw in output.
    Read the full 40 hex from the command above. A wait registered against an
    invented SHA is accepted and then never fires, so the promise looks kept and
    silently is not.
  • After every push or PR head update, re-read headRefOid and register a
    new wait for that SHA before promising another continuation. A wait is
    one-shot and exact-head-bound: the old wait_id never follows the new head,
    even when the PR number and promised next step are unchanged.
  • --summary is the one-line, body-free next step (no secrets, no logs).
    It becomes the continuation prompt: write it so future-you can act on it
    (e.g. "squash-merge PR #123 when green" or "inspect failing checks and fix").
  • Optional --timeout-seconds (default 6h).
  • After enqueueing into a merge queue, add --source merge-queue. A queue
    that drops a PR (its speculative group run failed) leaves it OPEN, approved,
    and with unchanged checks, so the default check-rollup wait never wakes on
    it. The merge-queue wait ends merged, evicted (the wake names the failed
    merge-group run id — gh run view <id> it, classify the failing job, and
    re-enqueue an unchanged approved head only for an unrelated flake), or
    closed. Register right after the enqueue mutation; a PR never seen in the
    queue is reported evicted with reason=never-enqueued after a short grace.

On success the CLI prints {"ok": true, "wait_id": "..."}.

Re-verify these before relying on them. Every value above is pinned to the
CLI's source, not to memory — the CLI is a separate component and may move:

Claim Check
--timeout-seconds default 6h grep -n 'DEFAULT_TIMEOUT_SECONDS' bridge/core/external_wait_monitor.py → 6 * 60 * 60
success shape {"ok": true, "wait_id": ...} grep -n '"wait_id": wait_id' bridge/core/external_wait_cli.py (the _emit block)
one-shot, exact-head-bound grep -n 'TERMINAL_SUPERSEDED|head_sha' bridge/core/external_wait_cli.py — a newer head finishes the old wait as superseded
route-unavailable token grep -n 'route-unavailable' bridge/core/external_wait_cli.py
--source merge-queue and its merged/evicted/closed outcomes grep -n '_SOURCE_ALIASES|_poll_merge_queue' bridge/core/external_wait_cli.py bridge/core/external_wait_monitor.py

If a check disagrees with this table, the CLI is authoritative — fix this file,
do not work around it.

After registering

  • Tell the user the wait_id and timeout in one short line, then end the
    turn normally. The bridge watches GitHub, notifies the conversation on
    terminal state, and continues with your summary as a bridge-owned
    external_event turn.

After the terminal wake

  • The wait resumes exactly one CI-result turn; it does not chain unrelated or
    subsequent work bundles by itself.
  • Handle the exact-head result named in the event. If more already-authorized
    work remains afterward, use $bridge-yield-continue to register the next
    bundle before ending the wake turn.

If registration fails (rc != 0)

Never claim auto-resume anyway. Either:

  1. run the deterministic watcher in the background and act on its exit code
    — never a hand-rolled until … grep loop:

    # ci-watch.sh ships with the gh-pr-flow skill; <skills-dir> is the directory
    # this skill was installed into (the runtime's skills root).
    bash "<skills-dir>/gh-pr-flow/ci-watch.sh" --repo <owner/repo> --pr <n> --head <exact-head-sha>
    # 0 green · 10 failed (names the checks) · 11 merged · 12 closed · 13 head moved · 20 timeout

    Two ad-hoc loops on 2026-10-02 never reported "green": their exit test
    grepped '"pending":0,"bad":0,"total":N' in gh --jq output, but gh
    sorts object keys alphabetically, so the pattern could not occur and the
    loops reacted to FAILED/MERGED only (#2113, #2120 — the operator noticed
    first). ci-watch.sh computes one verdict word inside jq and pins the
    head, so a newer push ends the watch as superseded instead of reporting
    a stale rollup. It reports green only once every required context of the
    base branch is present and successful, not merely when nothing is pending
    (#2200). Or

  2. say plainly that auto-resume is unavailable and the user should ping you
    when CI ends.

route-unavailable means the bridge could not bind this conversation —
do not retry blindly more than once. Source:
grep -n 'route-unavailable' bridge/core/external_wait_cli.py (emitted when
resolve_active_route returns None, i.e. there is no single active route).

When you fall back to a foreground watch, you own the tracking that the wait
would have done for you. Re-read the PR's live state (gh pr view <n> --json state,mergeStateStatus) before any irreversible step — a long watch can end
after the PR already moved, and acting on a stale in-head picture repeats work
that is already done.

Resuming a dropped promise at SessionStart

The SessionStart hook injects a ⚠ 미완 약속 block after a session timeout,
bridge restart, Telegram reconnect, or multi-bundle work that ended early. It
has two halves, and they need opposite actions:

⏳ 아직 대기 중 — registrations that are still live (no skip_reason).

  • Do NOT re-register. The wait is alive; a second registration just creates
    duplicate polling against the same head. This is the counter-default move —
    the instinct on seeing a pending list is to re-arm everything.
  • Confirm the condition is genuinely still pending (CI still running).
  • If a wait has outlived its plausible window (say >4h), investigate whether the
    external system is stuck rather than registering another one.
  • Otherwise leave it alone; it self-resolves on its next poll.

⚠ 알림은 갔으나 이어가지 못한 — the notification fired but the
continuation never ran. Each entry carries a skip_reason. Branch on it:

  • session_moved — the session ended before the condition completed. This says
    nothing about whether the external event succeeded. Open the PR, read the
    current CI status, and act on what you find now: if it is green and
    mergeable, proceed manually (subject to the usual approvals); if it is still
    pending, register a fresh wait for the current head SHA.
  • any other skip_reason (route/provider/user mismatch) — do not re-register
    blindly. Inspect the live registry with ... external_wait_cli list and
    confirm the route metadata is restorable first. Re-registering onto a broken
    route reproduces the same drop.

Before acting on any of these entries, remember they are inherited claims
written before the session boundary — see handoff-premise-freshness-verification
for the write-time vs merge-time check.

Verification: the next SessionStart should not re-surface the same promises.
If one does, check whether its skip_reason changed or the registry entry
leaked, and audit external_wait_cli list against what SessionStart printed.

User controls

  • The user sees waits with /waits and cancels with /cancelwait <wait_id>.
  • You can inspect with ... external_wait_cli list and cancel with
    ... external_wait_cli cancel <wait_id> when a wait you own is obsolete.