fix(ci): allow the github-actions bot actor, stop quarantining on systemic failure (#2948)

* fix(ci): allow the github-actions bot actor, stop quarantining on systemic failure

Follow-up to #2947, from watching it run in production. The catch-up dispatched
for the first time (the 403 is gone), but all five dispatched runs then failed:

  Actor type: Bot
  ##[error]Workflow initiated by non-human actor: github-actions (type: Bot).

checkHumanActor (src/github/validation/actor.ts) is a SEPARATE gate from the
write-permission one, and rejects any actor whose account type is not User.
allowed_non_write_users does not cover it — that is only consulted for User
accounts. Switching the catch-up to GITHUB_TOKEN in #2947 made those runs
arrive as github-actions[bot], so it traded the 403 for this.

Fixed with `allowed_bots: "github-actions"` — named rather than "*", since only
this repo's own workflows dispatch as that actor. Scheduled runs are unaffected
either way: they arrive as actor=alexbelgium, a User, which is also why the
tier-2 sweep never hit this.

The same run exposed a design error in #2947's bounded retry. It quarantined an
issue with ai:needs-human when the ACTION failed — but an action failure is
systemic, hitting every issue identically, so a workflow-level fault silently
buried a batch a day. It is the opposite case that is issue-specific: the action
ran fine and the model still produced no usable verdict. Inverted:

  * action failed        -> fail red, touch no labels, let the catch-up retry
  * ran but no verdict   -> one retry, then ai:needs-human on the second look

Five issues (#2847 #2850 #2852 #2896 #2918) were quarantined by the old rule and
need their ai:needs-human removed once this lands, so they re-enter the queue.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(ci): allow the coderabbitai bot actor on the PR follow-up tier

Same gate, latent instance. on_pr_coderabbit.yml fires on a review submitted by
coderabbitai[bot], so github.actor is a Bot-type account and checkHumanActor
rejects it. Every run so far skipped on the `ai-fix/*` branch guard before ever
reaching the action, so this has never surfaced — it would have failed on the
first genuine invocation, taking the whole CodeRabbit follow-up tier with it.

Note this is NOT covered by the write-permission check returning early for
[bot] actors: checkHumanActor is a separate gate consulted independently.

Audited all five claude-code-action call sites. The other three need nothing:
on_claude_mention and on_issue_approved are gated to alexbelgium, and the
daily_ai_fix schedule runs as actor=alexbelgium — confirmed from run metadata,
not assumed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(ci): clear retry triggers when escalating to a human; correct a comment

CodeRabbit: the no-verdict escalation added ai:needs-human but left ai-triage
and ai:needs-info in place. The catch-up search excludes both, so the automated
path never reaches it — but a MANUAL re-triage of an already-queued issue does,
and there it matters: ai-triage would keep an issue we just handed to a human
sitting in tier 2's unattended fix queue, and ai:needs-info would let a reporter
reply silently re-trigger classification behind the human's back. The normal
verdict path already clears stale control labels; this makes the escalation
path consistent with it.

Copilot: the action-failure comment claimed "touch no labels", but the
ai:needs-info restore above may already have run on the issue_comment path.
Reworded to say it adds no labels of its own, and why the restore still stands.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(ci): check the action outcome before trusting its structured output

CodeRabbit caught the silent-failure mode sneaking back in. The shape check ran
first, so if the action failed AFTER writing a valid structured output, the
object sailed through, labels and a comment were applied, and the step exited 0
— a green run on a failed action, which is the precise thing this workflow was
rebuilt to eliminate. Reproduced: valid verdict + CLASSIFY_OUTCOME=failure
applied bug/ai-triage/ai:classified and posted the comment at exit 0.

A failed action means its output is not trustworthy, full stop, so the outcome
check now runs before the payload is read at all. That also reads better as two
sequential gates rather than nested branches:

  gate 1  action failed        -> restore ai:needs-info, fail red, add nothing
  gate 2  payload unusable     -> restore ai:needs-info, warn, escalate on 2nd look
          otherwise            -> normal verdict handling

The ai:needs-info restore is now a function rather than being repeated at each
exit, since both gates need it.

Re-tested all 12 paths: the two newly-corrected cases plus a full regression
sweep over empty/array/valid payloads across issues, issue_comment and
workflow_dispatch, and the owned / low-confidence / label-grab branches.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Alexandre
2026-08-09 20:08:11 +02:00
committed by GitHub
parent e8f01387d3
commit 409de579da
2 changed files with 86 additions and 35 deletions

View File

@@ -175,6 +175,20 @@ jobs:
# contents:read + issues:write, the model gets no credentials and no
# Bash, and every value it produces is validated in Apply verdict.
allowed_non_write_users: "*"
# Separate gate from the one above, and it bit the catch-up path in
# production: checkHumanActor (src/github/validation/actor.ts)
# rejects any actor whose account type is not User. The catch-up
# dispatches with GITHUB_TOKEN, so those runs arrive as
# github-actions[bot] and died with "Workflow initiated by non-human
# actor". allowed_non_write_users does NOT cover this — it is only
# consulted for User accounts.
# Named rather than "*": only this repo's own workflows can dispatch
# as github-actions, whereas "*" would also admit any other App that
# can reach a trigger. The matcher lowercases and strips a trailing
# [bot], so this entry matches the github-actions[bot] actor.
# Scheduled runs are unaffected either way — they arrive as
# actor=alexbelgium, a User.
allowed_bots: "github-actions"
show_full_output: true
prompt: |
Read /tmp/ai-triage/context.md, then follow the instructions in
@@ -220,46 +234,72 @@ jobs:
set -euo pipefail
mkdir -p /tmp/ai-triage
F=/tmp/ai-triage/verdict.json
# A reporter reply consumed ai:needs-info in the claim step above, so
# every early exit below has to restore it or the next reply could
# never re-trigger. Defined once here rather than repeated per exit.
restore_needs_info() {
[ "${EVENT_NAME:-}" = "issue_comment" ] || return 0
gh issue edit "$ISSUE" --repo "$REPO" --add-label ai:needs-info >/dev/null 2>&1 || true
}
# GATE 1 — did the action itself run? This is checked BEFORE looking
# at the payload, because the action can fail *after* having written
# a valid structured output: the object would sail through the shape
# check below, labels and a comment would be applied, and the step
# would exit 0 — a green run on a failed action, which is the exact
# silent-failure mode this workflow was rebuilt to eliminate.
# A failed action means its output is not trustworthy, full stop.
#
# This branch is also deliberately label-neutral. An action failure
# (auth, config, an outage) is systemic — it hits every issue the
# same way — so quarantining here would silently bury a batch a day
# while the real fault sits in the workflow. Fail red, add nothing,
# let the catch-up retry once it's fixed. Classify carries
# continue-on-error so this step still runs at all; without the
# explicit exit 1 the job would report success.
if [ "${CLASSIFY_OUTCOME:-}" = "failure" ]; then
restore_needs_info
echo "::error::the Classify action failed for #$ISSUE — this is usually a workflow-level fault affecting every issue, so the issue is left untouched for a retry. See the Classify step."
exit 1
fi
# The verdict arrives as the action's schema-validated structured
# output rather than a file the model wrote — see the Classify step.
# Materialise it here so the validation below is unchanged.
printf '%s' "${STRUCTURED:-}" > "$F"
# `jq -e .` alone accepts any truthy JSON, so a verdict of `[1,2]` or
# `"hi"` passes here and then dies on `.verdict` below with "Cannot
# index array with string" — set -e kills the step before the
# ai:needs-info restore below, stranding the issue so no later reply
# can ever re-trigger it. Require an object.
# GATE 2 — the action ran, but is the payload usable? `jq -e .` alone
# accepts any truthy JSON, so a verdict of `[1,2]` or `"hi"` would
# pass and then die on `.verdict` below with "Cannot index array with
# string", killing the step under set -e before the restore. Require
# an object.
#
# Reaching here means the failure is specific to THIS issue — the
# model looked at it and produced nothing usable — so a retry is
# worth exactly one attempt. A workflow_dispatch is the catch-up or
# a manual re-triage, i.e. the second look, so hand it to a human
# rather than re-dispatching the same issue every day forever;
# ai:needs-human is in the catch-up exclusion search, so it drops out
# of the queue instead of starving newer issues behind it.
if [ ! -s "$F" ] || ! jq -e 'type == "object"' "$F" >/dev/null 2>&1; then
# A reporter reply consumed ai:needs-info in the claim step above.
# With no verdict we would otherwise leave the issue with the flag
# gone, so the next reply could never re-trigger — restore it.
# Do this FIRST, before any exit path below.
if [ "${EVENT_NAME:-}" = "issue_comment" ]; then
gh issue edit "$ISSUE" --repo "$REPO" --add-label ai:needs-info >/dev/null 2>&1 || true
restore_needs_info
echo "::warning::no usable verdict produced for #$ISSUE"
if [ "${EVENT_NAME:-}" = "workflow_dispatch" ]; then
echo "::warning::second attempt produced no verdict, handing #$ISSUE to a human"
gh label create ai:needs-human --repo "$REPO" --color ededed >/dev/null 2>&1 || true
# Drop the retry triggers in the same call. The catch-up search
# already excludes both, so this only bites on a MANUAL
# re-triage of an issue that still carries them — but there it
# matters: leaving ai-triage would keep an issue we just handed
# to a human sitting in tier 2's unattended fix queue, and
# leaving ai:needs-info would let a reporter reply silently
# re-trigger classification behind the human's back. The normal
# verdict path below already clears stale control labels; this
# keeps the escalation path consistent with it.
gh issue edit "$ISSUE" --repo "$REPO" \
--add-label ai:needs-human \
--remove-label ai-triage --remove-label ai:needs-info >/dev/null 2>&1 || true
fi
# Classify carries continue-on-error so the restore above always
# gets to run. That also means a hard failure inside the action
# reports the job green — which is exactly how tier 1 stayed dead
# for weeks while every run showed success. Re-raise it here.
if [ "${CLASSIFY_OUTCOME:-}" = "failure" ]; then
echo "::error::classification failed (see the Classify step); no verdict produced for #$ISSUE"
# A workflow_dispatch here is the daily catch-up (or a manual
# re-triage) — i.e. this issue has already had at least one shot
# and failed again. Hand it to a human rather than letting the
# catch-up re-dispatch the same issue every day forever; the
# label is in catchup's exclusion search, so it drops out of the
# queue instead of starving the newer issues behind it.
if [ "${EVENT_NAME:-}" = "workflow_dispatch" ]; then
gh label create ai:needs-human --repo "$REPO" --color ededed >/dev/null 2>&1 || true
gh issue edit "$ISSUE" --repo "$REPO" --add-label ai:needs-human >/dev/null 2>&1 || true
fi
exit 1
fi
# Action succeeded but produced nothing usable: leave the issue
# untriaged so tomorrow's catch-up picks it up, and stay green.
echo "::warning::no usable verdict produced, leaving issue untouched"
exit 0
fi

View File

@@ -87,6 +87,17 @@ jobs:
# coderabbitai[bot], the review submitter. AI_PR_TOKEN, not
# GITHUB_TOKEN, so the pushed fixes re-trigger CI on the PR.
github_token: ${{ secrets.AI_PR_TOKEN }}
# Latent until now only because this job has never reached the action:
# every run so far skipped on the `ai-fix/*` branch guard. On the
# first real firing github.actor is coderabbitai[bot], and
# checkHumanActor (src/github/validation/actor.ts) rejects any actor
# whose account type is not User — a different gate from the write
# check above, which does return early for a [bot] actor. Without
# this the whole CodeRabbit follow-up tier would fail on its first
# genuine invocation. Named, not "*": the job `if` already requires
# the review to come from coderabbitai[bot], so nothing else can get
# here anyway, and "*" would only widen it if that guard changed.
allowed_bots: "coderabbitai"
prompt: |
CodeRabbit has reviewed pull request #${{ github.event.pull_request.number }}
on ${{ github.repository }}. You are on that PR's branch. Follow