fix(seidroid-review): fall back to GITHUB_TOKEN for the position and the dismissal - #88
Conversation
PR SummaryMedium Risk Overview When GitHub refuses the requested review event (detected via 422 on the error body), the workflow retries as App identity is treated as all-or-nothing: Reviewed by Cursor Bugbot for commit e7e326a. Bugbot is set up for automated code reviews on this repo. Configure here. |
There was a problem hiding this comment.
The fallback to github.token is correct — the job already declares pull-requests: write, every sibling publisher uses the same steps.identity.outputs.token || github.token form, and the new post_position helper is sound bash. Two non-blocking notes: the COMMENT rung retries on any failure rather than only on a refusal, and repositories without an App now execute the withdrawal path that was previously skipped.
Findings: 0 blocking | 2 non-blocking | 1 posted inline
Blockers
- None at the file/PR level.
Non-blocking
- Dropping the identity gate means a repository with no
SEIDROID_APP_IDnow runs the withdrawal block that was previously skipped along with the whole step, and that pathexit 1s on a dismissal the API refuses. The narrow-but-real case is a repository that had the App, accumulatedseidroid[bot]CHANGES_REQUESTED reviews, then dropped the credential:github-actions[bot]may not be permitted to dismiss those (protected branch with dismissal restrictions), and every subsequent clean run then fails the job persistently. The PR description names this as the intended loud failure; it is worth recording in theSEIDROID_APP_IDsecret description too (line 257), since that is where an operator removing the credential will look. - 1 suggestion(s)/nit(s) flagged inline on specific lines.
| # clears no approval requirement, and a REQUEST_CHANGES blocks nothing. | ||
| if post_position "$event"; then | ||
| echo "recorded $event on $REPO#$PR" | ||
| elif post_position COMMENT; then |
There was a problem hiding this comment.
[suggestion] The rung fires on any non-zero exit from the first POST, not just on the refusal it is written for. Two consequences:
- A transient 5xx, or a connection reset after the write lands server-side, produces two reviews on the pull request (the description acknowledges this;
ai-review.ymlcarries it too). - A 422 caused by a stale
commit_idretries with the samecommit_id, so the COMMENT attempt fails identically and only costs a second API call.
Scoping the downgrade to the statuses that actually mean "this identity may not hold this opinion" avoids both. Capturing the status inside post_position and gating on it is cheap, e.g. have it return a distinct code when stderr shows HTTP 403/HTTP 422 and retry as COMMENT only on that code, letting anything else fall straight through to the existing warning.
Non-blocking — the current behaviour is never worse than one extra review — but it would make the fallback mean what the comment above it says it means.
There was a problem hiding this comment.
Both taken, in the commit above, and the change is rebased onto 19cb2fb (#85).
Scoping the rung. Right, and the comment above it claimed the narrower meaning it did not have. post_position now returns 2 for a 403 or a 422 and 1 for everything else, and only 2 earns the downgrade. Exercised against a stub:
first call succeeds -> recorded APPROVE 1 call
422, COMMENT accepted -> downgraded to COMMENT 2 calls
502 transient -> could not record 1 call
403 on both -> could not record 2 calls
The transient row is the one that changed: it used to spend a second call and could post a second review when the first write landed before the connection dropped.
Your second consequence stands and I did not close it: a 422 from a stale commit_id still downgrades and meets the same answer, because the status does not distinguish it from a refusal. That costs one call and no review, so it is no worse than today. Said in the code rather than left for the next reader to find.
The dropped-credential hazard. Recorded on SEIDROID_APP_ID, which is exactly where an operator removing it will look — the PR body was the wrong place for it. The description now says the workflow's own identity may not clear a block the App left, that every later clean run then fails on the dismissal, and to dismiss those reviews by hand when removing the credential.
Verification
actionlint base 4 SC2102 / head 4 SC2102 unchanged
yaml.safe_load parses
downgrade matrix 4/4 as above
Still not exercised on a runner. I have never seen the API refuse a position from either identity here, so the rung itself is stub-proven only.
a39e796 to
cb17813
Compare
There was a problem hiding this comment.
The github.token fallback for the position step is correct and matches the pattern already used elsewhere in this file (e.g. the Read the threads step at line 710); the new post_position helper is sound bash and its 403/422-only retry gate genuinely avoids the duplicate-review hazard that ai-review.yml carries. One non-blocking note on the 403 classification, plus a pre-existing gap in how the optional-App gate is computed.
Findings: 0 blocking | 3 non-blocking | 1 posted inline
Blockers
- None at the file/PR level.
Non-blocking
- The change is well-scoped and internally consistent: a repo with no App now posts the position as
github-actions[bot]and, because the same identity later runs the withdrawal, the block/clear loop stays self-consistent.approve-on-successremains opt-in and off by default, so the fallback does not silently start casting approving votes anywhere. The newly-exposed failure mode (App removed after it recorded CHANGES_REQUESTED) is documented on theSEIDROID_APP_IDsecret description. - 1 suggestion(s)/nit(s) flagged inline on specific lines.
- 1 non-blocking pre-existing issue(s) listed below under pre-existing issues.
Pre-existing issues
- [suggestion]
HAS_REVIEWER_IDENTITY(lines 295 and 593) is derived fromsecrets.SEIDROID_APP_ID != ''alone. A caller that configures the app id but notSEIDROID_APP_PRIVATE_KEYmakes theactions/create-github-app-tokenstep run and fail; that step has nocontinue-on-error, so the whole job aborts rather than falling back togithub.token. Gating on both secrets would make the "App is optional" contract hold for a half-configured caller too.
| fi | ||
| err="$(gh api "${args[@]}" 2>&1 >/dev/null)" && return 0 | ||
| rc=1 | ||
| case "$err" in (*"HTTP 403"*|*"HTTP 422"*) rc=2 ;; esac |
There was a problem hiding this comment.
[suggestion] The comment above this line reads a 403 as "the API saying this identity may not hold this opinion," but 403 is also what GitHub returns for a secondary rate limit / abuse-detection trip, and for a token that simply lacks pull-requests: write. In the rate-limit case the immediate COMMENT retry can land where a short backoff on the original event would have succeeded, silently downgrading a REQUEST_CHANGES or APPROVE to a non-voting comment behind only a ::warning::. (The permission case is harmless — the retry meets the same 403 and falls through.)
422 is the code GitHub actually returns for both refusals the rung is built for ("GitHub Actions is not permitted to approve pull requests" and self-approval), so narrowing the rung to 422, or keeping 403 but excluding messages matching rate limit/abuse, would keep the vote in the one case where retrying the original event still had a chance.
There was a problem hiding this comment.
Both taken in 9d0bb47. I narrowed the rung to 422 alone rather than filtering 403 by message.
Finding 1 — the 403 classification. You are right that 403 is overloaded here, and I verified the rate-limit half two ways. GitHub's rate-limit page states it outright: "If you exceed a secondary rate limit, you will receive a 403 or 429 response and an error message that indicates that you exceeded a secondary rate limit." And this endpoint's own OpenAPI description names itself as a trigger: "Creating content too quickly using this endpoint may result in secondary rate limiting." So the concern is not hypothetical for pulls/create-review specifically.
I measured the self-review half live against this API, on this PR, which I authored:
$ gh api -X POST repos/sei-protocol/uci/pulls/88/reviews -f event=APPROVE -f body=probe
gh: Unprocessable Entity (HTTP 422)
{"message":"Unprocessable Entity","errors":["Review Can not approve your own pull request"],"status":"422"}
$ gh api -X POST repos/sei-protocol/uci/pulls/88/reviews -f event=REQUEST_CHANGES -f body=probe
gh: Unprocessable Entity (HTTP 422)
Review count on #88 was 3 before and 3 after, so the probe created nothing.
Why 422 alone and not a message filter. The two errors are not symmetric. Excluding rate limit/abuse by message leaves a block hostage to GitHub's wording, which no contract fixes — the docs promise only "an error message that indicates" the limit. A miss downgrades a REQUEST_CHANGES to a comment that blocks nothing, and rate limits fire exactly when the tool is busy. The narrow rule's cost is the reverse: a policy refusal arriving as 403 gets no comment review, only the ::warning::. That loses an entry in the reviewers list while the verdict comment and the check run still carry everything. A vote is worth more than a list entry, so the rule protects the vote. 422-only is also immune to the 429 form, which a 403 filter would miss entirely.
What I could not verify. Your claim that 422 covers both refusals is confirmed for self-review and unconfirmed for "GitHub Actions is not permitted to approve pull requests". I cannot produce that error: GET /repos/sei-protocol/uci/actions/permissions/workflow returns 403 for my token, and the setting is evidently on here anyway — github-actions[bot] has landed APPROVE reviews on #84, #83, #75, #74, #73, #72, #71, #70 and #61. GitHub's Actions settings docs describe the toggle without stating a status code. If that refusal is a 403, this rung will not answer it; that is the cost I chose, and it is named in the code. I have still never seen the API refuse a position on a real run, so the rung remains stub-proven.
Finding 2 — the half-configured caller. Confirmed and fixed. HAS_REVIEWER_IDENTITY is derived at exactly two sites, jobs.guard.env and jobs.review.env, and consumed at exactly two, the guard's mint and the review's mint. There are no others; grep -n "HAS_REVIEWER_IDENTITY\|secrets.SEIDROID" returns those four plus the two app-id/private-key pairs. Neither mint carries continue-on-error, so your read is right: an id with no key aborted the job. Both sites now require both secrets.
Verification. actionlint 1.7.12 against origin/feat/seidroid-review (19cb2fb): 4 findings before, 4 after, all SC2102:info, rule set diff empty. YAML parses; shellcheck -s bash on the extracted step is clean.
Stub matrix, blocking conclusion, counting review POSTs — one POST means the rung did not fire:
MODE POSTs OUTCOME
ok 1 recorded REQUEST_CHANGES
422-self 2 would not take REQUEST_CHANGES -> COMMENT
403-rate 1 could not record REQUEST_CHANGES
403-perm 1 could not record REQUEST_CHANGES
429-rate 1 could not record REQUEST_CHANGES
500-flake 1 could not record REQUEST_CHANGES
timeout 1 could not record REQUEST_CHANGES
comment-fails 2 could not record REQUEST_CHANGES
Diffing the full 18-path matrix against cb17813 shows three changed lines, all three the 403 cases. Every 422 path, both loud failures and all four withdrawal interlocks are byte-identical.
There was a problem hiding this comment.
The fallback to github.token for the position step is correct and consistent with every sibling publisher in this file, and the post_position helper is sound bash (redirection order, local array, errexit-safe call sites all check out). Three non-blocking observations: the COMMENT rung's 422-only trigger may not cover the Actions-policy refusal the step header cites as its motivation, the widened if newly exposes App-less repositories to a job-failing dismissal, and requiring both credential halves converts a loud misconfiguration into a silent one.
Findings: 0 blocking | 2 non-blocking | 1 posted inline
Blockers
- None at the file/PR level.
Non-blocking
- The
COMMENTrung is exercised only against a stub per the PR description — no run has seen the API actually refuse a position. That is acceptable given this repository has no workflow test harness, but the rung's trigger condition (422) is the part that is unverified, and it is also the part that decides whether the rung fires at all. - 1 suggestion(s)/nit(s) flagged inline on specific lines.
Comments that couldn't be anchored to the diff
.github/workflows/seidroid-review.yml:1299-- [suggestion] The rung is gated on 422 only, but the step header at lines 1199-1205 names "a repository whose Actions policy forbids an approval refuses the event outright" as the case "the COMMENT rung below answers" — and the comment at 1282-1284 then concedes that same refusal may arrive as 403 and earn no rung. If it does arrive as 403, a no-App repository running withapprove-on-successgets only a warning and never appears in the reviewers list, which is the gap this PR set out to close.
Worth noting too that ai-review.yml:979-989, cited as the precedent, retries as COMMENT on any error from the body-only post, not on a status code — so this is narrower than the thing it mirrors.
The asymmetry the surrounding comment argues for is real, but it only applies to REQUEST_CHANGES: downgrading a rate-limited block to a comment loses a gate, whereas an APPROVE that the API just refused was never going to cast a vote. That points at a rung keyed on the event rather than on the status alone:
case "$err" in
(*"HTTP 422"*) rc=2 ;;
(*"HTTP 403"*) if [ "$1" = APPROVE ]; then rc=2; fi ;;
esacThat covers the policy refusal whichever status carries it, and keeps REQUEST_CHANGES on the 422-only rule that protects the block.
.github/workflows/seidroid-review.yml:310-- [suggestion] Requiring both halves is the right fix for the crash this describes, but it changes a loud misconfiguration into a silent one. WithSEIDROID_APP_IDset andSEIDROID_APP_PRIVATE_KEYempty, the previous behaviour was a red guard job fromcreate-github-app-token. Now the mint is skipped,GH_TOKENinAdmit the requestis empty, and — for any caller that also setsallowed-team— line 491 takes thedeny "no identity to check ... denying"branch, so every comment-triggered review is refused with a::notice::and no signal on the pull request at all.
A half-configured App is a typo, not a supported configuration. Consider naming it where the machine-client check already names its own missing secret (line 517), e.g. an ::error:: when exactly one of the two is set, so the operator learns it from a failing job rather than from a review that quietly stops happening.
| printf '%s\n' "$err" >&2 | ||
| return "$rc" | ||
| } | ||
|
|
There was a problem hiding this comment.
[suggestion] Dropping the identity gate also means the withdrawal at the end of this step now runs in repositories that have no App — where it previously never ran — and a refused dismissal exits 1 (line ~1414), reddening every subsequent clean run until a human clears the block. The PR names this as intended, and the loud failure is the right default.
The mitigation, though, lives only in the SEIDROID_APP_ID secret description, which is the one place an operator staring at a red job will not look. The ::error:: in the dismissal loop currently says only "could not withdraw review $id ... it still blocks the merge on a finding this run did not reproduce". Adding the likely cause and the remedy there — a block recorded under an identity this run no longer holds, dismiss it by hand — would put the answer in the log next to the failure.
There was a problem hiding this comment.
Taken in 80bb314, rebased onto bf0fd23 (#87 merged). The exit and the interlocks are untouched — only the message changed.
::error::could not withdraw review $id on $REPO#$PR; it still blocks the merge on a
finding this run did not reproduce. This run may not hold the identity that recorded
it: removing SEIDROID_APP_ID leaves the App's blocks behind. Dismiss review $id by
hand to clear it
The secret description stays as well. One is for the operator configuring, the other for the operator debugging, and neither reaches the other's reader.
Also folded in: the half-configured caller. Requiring both credential halves did convert a loud misconfiguration into a silent one, so the guard now warns and names the half that is missing. I broadened it past what was raised — either half alone earns the warning, not just a missing key. A diagnostic with a blind spot sends the reader to inspect the half that is already set, which costs more time than the four lines it saves.
both secrets set -> (silent)
id set, key missing -> ::warning::SEIDROID_APP_PRIVATE_KEY is not set beside the
other half of the App credential, so this review posts as
github-actions[bot] rather than as the App. ...
key set, id missing -> ::warning::SEIDROID_APP_ID is not set beside the other half ...
neither set -> (silent)
On the rung's 422 trigger, restating the gap plainly. You raised this on the previous thread and my own measurement is the honest answer: 422 is confirmed for self-review, and unconfirmed for the Actions-policy refusal. I measured the first live against this API on this PR, which I authored — gh: Unprocessable Entity (HTTP 422), {"errors":["Review Can not approve your own pull request"]}, with the review count on #88 at 3 before and 3 after, so the probe created nothing. I cannot produce the second. GET /repos/sei-protocol/uci/actions/permissions/workflow returns 403 for my token, the setting is evidently on in this org anyway, and GitHub's Actions settings docs describe the toggle without naming a status code. If that refusal arrives as 403 this rung will not answer it, and an approval the policy forbids gets no comment review — only the ::warning::. That is the cost I chose over leaving a block hostage to error-message wording, and it is stated in the code. I have still never seen the API refuse a position on a real run, so the rung stays stub-proven.
Verification. actionlint 1.7.12 against origin/feat/seidroid-review (bf0fd23): 4 findings before, 4 after, all SC2102:info, rule-set diff empty. YAML parses; shellcheck -s bash clean on both extracted steps.
The 18-path stub matrix differs from cfafeb0 in exactly two lines, both the new error text, one per stuck review id:
### clean, dismissals refused [mode=stuck] expect: LOUD failure rc=1
recorded APPROVE on owner/name#7
gh: Forbidden (HTTP 403)
::error::could not withdraw review 901 ... Dismiss review 901 by hand to clear it
gh: Forbidden (HTTP 403)
::error::could not withdraw review 902 ... Dismiss review 902 by hand to clear it
superseded blocks: 0 withdrawn, 2 still standing
rc=1
Every exit code across all 18 paths is identical to the previous commit — four rc=1 before, four after — and the review-list failure, the four withdrawal interlocks and every 422/403 classification path are byte-identical.
…the dismissal The step that records the review's position required the App identity, so a repository with no SEIDROID_APP_ID got a verdict comment and no entry in the reviewers list. It could not approve, it could not request changes, and it could not clear a CHANGES_REQUESTED review it had left before. Only a human could then lift that block. The step now uses the App token where the caller configured one and the workflow's own token otherwise, which is what every sibling publisher in this file already does and what ai-review.yml does for the review, the dismissal and the check. Add the COMMENT rung ai-review.yml takes at ai-review.yml:983-986. The API refuses an opinion the identity may not hold: github-actions approving where the repository's Actions policy forbids it, or either identity taking a side on a pull request it opened. A comment review still records the run in the reviewers list, and the check run still carries the gate. The withdrawal interlocks and the loud failure on a stuck block are unchanged. A behaviour battery over twelve paths shows one difference against the base: the new COMMENT rung. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…s it The COMMENT rung fired on any non-zero exit from the first post. A write that landed and then lost its connection would post a second review, and a 422 from a stale commit_id would retry with the same commit_id and meet the same answer. post_position now returns 2 for a 403 or a 422 -- the API saying this identity may not hold this opinion -- and 1 for everything else. Only 2 earns the rung. The SEIDROID_APP_ID description carries the cost of removing the credential: the workflow's own identity may not clear a block the App left, so a repository that removes it after the App recorded CHANGES_REQUESTED leaves every later clean run failing on a dismissal it cannot perform. That is where an operator removing the credential looks. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…h App secrets Two fixes on the review's position. The rung fired on a 403 as well as a 422. A 403 on this endpoint is also how GitHub reports a secondary rate limit, and the API names this endpoint as one that trips that limit. So a rate-limited REQUEST_CHANGES was retried at once as a COMMENT, and a block became a note behind a warning. It now fires on 422 alone. GitHub answers a self-review with 422, measured against this API as "Review Can not approve your own pull request". The narrow rule costs the opposite case: a refusal that arrives as 403 earns no comment review. Losing the vote is the worse half. HAS_REVIEWER_IDENTITY read the app id alone. A caller that set the id and not the private key ran the mint step, failed it, and lost the job -- where the App is optional and the answer is to fall back to GITHUB_TOKEN. Both derivation sites now require both secrets. A stub matrix over eighteen paths counts one review POST for a 403, a 429, a 500 and a client timeout, and two for a 422. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…me a half credential The error on a refused dismissal said only that the block still stands. An operator reading a red job had nowhere to go from there. It now names the likely cause and the remedy: this run may not hold the identity that recorded the block, removing SEIDROID_APP_ID leaves the App's blocks behind, and a person has to dismiss the review by hand. The exit and the interlocks are unchanged. Requiring both halves of the App credential turned a failed job into a silent fallback. The guard now warns when one half is set without the other, and names the half that is missing. Either half alone earns the warning: a diagnostic with a blind spot sends the reader looking at the half that is already set. A stub matrix over eighteen paths differs from the last commit in two lines, both the new error text. Every exit code is identical. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
9d0bb47 to
80bb314
Compare
There was a problem hiding this comment.
The fallback from the App token to github.token for the position step is sound and consistent with the sibling publishers in this file, and the 422→COMMENT rung mirrors ai-review.yml. No blockers; a few notes on operator-facing accuracy and one behaviour change the tightened HAS_REVIEWER_IDENTITY introduces for half-configured repositories.
Findings: 0 blocking | 5 non-blocking | 3 posted inline
Blockers
- None at the file/PR level.
Non-blocking
- The withdrawal now runs for repositories that never configured an App, where the whole step was previously skipped. That is the stated intent, but it means a repository whose
mainrestricts who may dismiss reviews will getexit 1from this step on every subsequent clean run oncegithub-actions[bot]has recorded one CHANGES_REQUESTED — a permanently red job until a human dismisses. Worth a line in the caller-facing docs alongside theSEIDROID_APP_IDdescription, since the failure surfaces on a run that found nothing wrong. - The 422→COMMENT rung is only exercised against a stub (per the PR description). Since the whole rung hinges on GitHub answering a refused position with 422 and not 403, a single recorded real refusal — even pasted into the step comment — would be worth more than the reasoning currently there.
- 3 suggestion(s)/nit(s) flagged inline on specific lines.
| else | ||
| stuck=$((stuck+1)) | ||
| echo "::error::could not withdraw review $id on $REPO#$PR; it still blocks the merge on a finding this run did not reproduce" | ||
| echo "::error::could not withdraw review $id on $REPO#$PR; it still blocks the merge on a finding this run did not reproduce. This run may not hold the identity that recorded it: removing SEIDROID_APP_ID leaves the App's blocks behind. Dismiss review $id by hand to clear it" |
There was a problem hiding this comment.
[suggestion] This names App removal as the cause of a stuck dismissal, but that is usually not it. PUT .../reviews/{id}/dismissals does not require the review's author — any token with write access on the pull request can dismiss another actor's review. What it does require, per the API docs, is that on a protected branch the caller be a repository admin or be listed under "restrict who can dismiss pull request reviews". So the likely causes here are a dismissal restriction on the protected branch, or a read-only token, not the identity that authored the review.
Sending an operator to check SEIDROID_APP_ID during a permanently-red pull request costs them the first thing they look at. Consider naming both causes, or dropping the attribution and keeping the actionable half ("Dismiss review $id by hand to clear it"). The same claim in the new SEIDROID_APP_ID description ("That identity also may not clear a block the App left") reads on the same assumption.
There was a problem hiding this comment.
You are right, and the earlier text named the wrong cause. That is on the record: 80bb314 told an operator that a stuck dismissal came from removing SEIDROID_APP_ID, and the API does not work that way. Fixed in e7e326a.
I checked it against GitHub's own OpenAPI description rather than taking it on trust. PUT /repos/{owner}/{repo}/pulls/{pull_number}/reviews/{review_id}/dismissals carries exactly one note:
To dismiss a pull request review on a protected branch, you must be a repository administrator or be included in the list of people or teams who can dismiss pull request reviews.
Nothing conditions it on having authored the review, and the endpoint declares only 200, 404 and 422 — no 403. The ruleset schema agrees: dismissal_restriction: {enabled, allowed_actors} is the list the note means, and this repository's own ruleset carries it with enabled: false.
The error now names the causes that are true:
::error::could not withdraw review $id on $REPO#$PR; it still blocks the merge on a
finding this run did not reproduce. On a protected branch the API takes a dismissal
only from a repository admin or an actor named in the branch's dismissal restriction,
so check that this run's identity is one of them. A token without pull-requests: write
is refused as well. Dismiss review $id by hand to clear it
Identity survives only as a sub-question of the restriction list — that list names actors, so which identity the run holds decides whether it is on it. It no longer leads.
The SEIDROID_APP_ID description carried the same wrong assumption and is rewritten on the same evidence: changing the credential changes who withdraws a block because the restriction list names actors, not because the recorder has a claim on its own review.
| env: | ||
| HAS_REVIEWER_IDENTITY: ${{ secrets.SEIDROID_APP_ID != '' }} | ||
| HAS_REVIEWER_IDENTITY: >- | ||
| ${{ secrets.SEIDROID_APP_ID != '' && secrets.SEIDROID_APP_PRIVATE_KEY != '' }} |
There was a problem hiding this comment.
[suggestion] Requiring both halves is right for the mint, but it silently changes what a half-configured repository does with skip-review-label. Before: SEIDROID_APP_ID set with no private key made HAS_REVIEWER_IDENTITY true, the mint ran, failed, and took the guard job with it — no review. Now the mint is skipped, so Admit the request (line 447) runs with an empty GH_TOKEN, and its label check is gated on [ -n "${GH_TOKEN:-}" ] and fails open. A pull request carrying the skip label gets reviewed, where it previously could not.
The team check is unaffected — it still fails closed at line 490. The new warning step announces the half-credential, but it runs after admit, so the label is already ignored by then.
If you want the label gate to survive the fallback, Admit the request would need GH_TOKEN: ${{ steps.identity.outputs.token || github.token }} plus pull-requests: read on the guard job, which currently declares permissions: {}. Otherwise it is worth stating in the label input's description that the gate needs the App identity.
There was a problem hiding this comment.
Confirmed and closed in e7e326a. Your trace is exact, including that the warning step runs after admit and so cannot help.
How I closed it. The label check now fails open only for a caller that configured no App, and closed for one that configured half of one. Admit the request reads both secret-presence values as step-level env and denies when it holds no token but a half-credential says one was intended.
I did not take the github.token + pull-requests: read route. That fixes the gate for every no-App caller, which is a user-visible change for repositories that deliberately run without the App and rely on the label failing open today — and it is already filed as PLT-1169. Widening the guard from permissions: {} is that ticket's call, not this PR's. What I closed is the regression this change introduced, no more.
Measured, both revisions, same stub:
80bb314 e7e326a
App configured, PR carries the label false false
App configured, PR clean true true
no App at all, PR carries the label true true <- PLT-1169, untouched
HALF configured (id, no key), PR carries the label true FALSE <- the regression
HALF configured, PR clean true FALSE
HALF configured (key, no id), PR carries the label true FALSE
HALF configured, teardown true true <- sandbox reclaim survives
HALF configured, caller disabled the label true true <- no control, no denial
The denial says why: half of the App credential is set, so ai: skip-review cannot be read on owner/name#7; not reviewing. Pass both secrets, or unset the half that is set.
On the net effect for a half-configured caller. Before this PR that configuration reviewed nothing — the mint failed and took the guard with it. It still reviews nothing, but now says which secret is missing instead of dying inside create-github-app-token. The improvement this PR set out to make was a legible failure, not a review under an identity the caller did not choose, and the label invariant holds either way.
| fi | ||
| err="$(gh api "${args[@]}" 2>&1 >/dev/null)" && return 0 | ||
| rc=1 | ||
| case "$err" in (*"HTTP 422"*) rc=2 ;; esac |
There was a problem hiding this comment.
[nit] This couples the rung to gh's human-readable stderr. Current gh formats a REST error as gh: <message> (HTTP <code>), so this matches today, but that string is not part of gh's compatibility surface and nothing else in this file depends on it — a reformat upstream turns the rung into a silent no-op that degrades to the pre-change behaviour, with no test to catch it.
gh api -i puts the status line on stdout, which this already discards; capturing stdout instead of dropping it would let you read the code directly. Not worth reworking if you would rather keep the shape simple — but the fragility is worth a word in the comment block above, which currently reads as though 422 detection is guaranteed.
There was a problem hiding this comment.
Taken in e7e326a, and a machine-readable status does exist — I did not need -i. gh api already writes the response body to stdout on a failure, and that body is GitHub's error object with the code in a status field. The old code threw that away with 2>&1 >/dev/null.
Measured against this API, on this PR, which I authored (review count 7 before and 7 after, so it created nothing):
$ gh api -X POST repos/sei-protocol/uci/pulls/88/reviews -f event=APPROVE -f body=probe
stdout: {"message":"Unprocessable Entity","errors":["Review Can not approve your own
pull request"],"documentation_url":"...","status":"422"}
$ ... | jq -r '.status // empty'
422
So the rung now reads:
body="$(gh api "${args[@]}")" && return 0
status="$(printf '%s' "$body" | jq -r '.status // empty' 2>/dev/null || true)"
if [ "$status" = "422" ]; then
return 2
fi
return 1gh's own line is no longer parsed; it still reaches the log unredirected, where it says why.
The fragility that remains, stated in the comment block as you asked. status is documented on GitHub's Basic Error schema but the 422's own schema — validation-error-simple — declares only message, documentation_url and errors. So the field is observed and documented in general, not contract-guaranteed for this exact response. An absent status returns 1: no rung, no downgrade. The vote survives a shape the code cannot read, which is the same direction the 422-over-403 rule already chose.
Proof the coupling is gone. I added a stub mode that keeps the error body intact and reformats only gh's line to error: 422 Unprocessable Entity [reformatted upstream] — your exact scenario:
POSTs (2 = rung fired, 1 = silent no-op)
80bb314 string match 1 <- the silent no-op you predicted
e7e326a status field 2 <- rung still fires
And the fail-safe, with a body that omits status:
422-nostatus 1 <- no rung, vote kept
Full 18-path matrix against 80bb314: two changed lines, both the corrected dismissal error text. Every exit code identical, all four withdrawal interlocks and both loud failures byte-identical. actionlint 4 findings before and after against bf0fd23, all SC2102:info, rule-set diff empty.
…abel, read the API's status Three fixes. The dismissal error named the wrong cause. The API takes a dismissal from any caller with write access, whoever recorded the review; what it requires, on a protected branch, is a repository admin or an actor named in the branch's dismissal restriction. The error and the SEIDROID_APP_ID description now say that, and name a read-only token as the other cause. Requiring both credential halves let a skip-labelled pull request through. Half a credential mints no token, the label read is gated on holding one, and it fails open. The label check now fails open only for a caller that configured no App, and closed for one that configured half of one. A teardown still passes, and a caller that disabled the label is untouched. Whether the check should read GITHUB_TOKEN for every no-App caller is PLT-1169 and is not folded in here. The rung matched gh's human stderr, which no compatibility promise covers. It now reads `status` from GitHub's error object on stdout. An absent status returns 1, so an unreadable shape keeps the vote rather than downgrading it. A stub matrix over eighteen paths differs from the last commit in two lines, both the corrected error text, and every exit code is identical. A gh reformat that silently disabled the old rung leaves the new one firing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
The core change is sound — the position step's steps.identity.outputs.token || github.token fallback now matches every sibling publisher in this file, and the review job already carries pull-requests: write, so both the create and the dismissal paths work under GITHUB_TOKEN. The undescribed half-credential handling added alongside it is where the problems are: a caller with one of the two App secrets set now has every review silently denied while a new step simultaneously warns that the review will post as github-actions[bot], and the new COMMENT rung fires on a JSON field the 422 schema does not declare.
Findings: 0 blocking | 7 non-blocking | 5 posted inline
Blockers
- None at the file/PR level.
Non-blocking
- The PR description covers only the position-step fallback and the COMMENT rung, but the diff also changes
HAS_REVIEWER_IDENTITYin both jobs, adds a fail-closed deny path to the skip-label check, and adds a whole new guard step. The twelve-path behaviour battery described in the body exercises the position step only, so the guard changes — the ones that can stop a review entirely — arrive unverified. Worth extending both the description and the battery to cover them. - The inline comment at line 1340 says
statusis "measured on this endpoint's 422", while the PR description says "I never saw the API refuse a position, so the rung is exercised against a stub, not against GitHub." One of the two is wrong, and the comment is the one a future reader will trust. - 5 suggestion(s)/nit(s) flagged inline on specific lines.
| if gh api "repos/$REPO/pulls/$PR" --jq '.labels[].name' 2>/dev/null | grep -qxF "$SKIP_LABEL"; then | ||
| deny "$REPO#$PR carries $SKIP_LABEL; not reviewing" | ||
| fi | ||
| elif [ "$APP_ID_PRESENT" = "true" ] || [ "$APP_KEY_PRESENT" = "true" ]; then |
There was a problem hiding this comment.
[suggestion] skip-review-label defaults to 'ai: skip-review' (line 129), so SKIP_LABEL is non-empty unless a caller deliberately clears it. That means this branch denies every review — automatic and comment-triggered alike — for any repository that has exactly one of SEIDROID_APP_ID / SEIDROID_APP_PRIVATE_KEY set, since HAS_REVIEWER_IDENTITY (line 313) now requires both halves and GH_TOKEN is therefore empty.
The visibility is the sharper half. Before this PR that configuration ran the mint step and failed it, taking the guard job red — loud and impossible to miss. Now deny() writes a ::notice:: and exits 0, so the guard is green, the review job is skipped, and on the comment path the requester gets no reaction and no reply at all. A workflow that silently reviews nothing is a worse failure mode than one that goes red.
If the fail-closed policy is intended, consider making this path ::error:: with a non-zero exit rather than a notice, so the misconfiguration stays as visible as it was.
| missing=SEIDROID_APP_ID | ||
| fi | ||
| if [ -n "$missing" ]; then | ||
| echo "::warning::$missing is not set beside the other half of the App credential, so this review posts as github-actions[bot] rather than as the App. Pass both secrets for the App identity, or unset the half that is set" |
There was a problem hiding this comment.
[suggestion] This warning contradicts the deny at line 522 in exactly the configuration it fires in. Both steps are gated on steps.parse.outputs.should_run == 'true' — not on steps.admit.outputs.admit — so with a half-set credential and the default skip-label the log carries ::notice::half of the App credential is set ... not reviewing and then ::warning::... so this review posts as github-actions[bot] rather than as the App. No review posts at all.
Either gate this step on steps.admit.outputs.admit == 'true', or reword it so it does not promise a review the step above already refused.
| args+=(-f commit_id="$head_sha") | ||
| fi | ||
| body="$(gh api "${args[@]}")" && return 0 | ||
| status="$(printf '%s' "$body" | jq -r '.status // empty' 2>/dev/null || true)" |
There was a problem hiding this comment.
[suggestion] The whole COMMENT rung hinges on .status being present in the error body, and GitHub's Validation Error schema — the one a 422 on this endpoint returns — declares only message, documentation_url and errors. status is on the Basic Error schema. The comment above acknowledges this and chooses return 1 on an absent code, which is safe, but the consequence is that the rung may simply never fire: the step falls back to the same could not record $event warning it emitted before this PR, and the new code is dead.
Since the PR description says the rung was only ever exercised against a stub, this is untested against a real refusal. A reading that does not depend on an undeclared body field: capture the response line with gh api -i and match on 422, which is part of the HTTP contract rather than the payload schema — and unlike gh's gh: ... (HTTP 422) stderr string, it is not a human-facing message a reformat could silently break.
| # that forbids an approval, earns no comment review and only the warning | ||
| # below. Losing the vote is the worse half, so the rule protects it. | ||
| # | ||
| # A 422 also covers a stale commit_id, which the rung retries with the same |
There was a problem hiding this comment.
[nit] When the 422 came from a stale commit_id, retrying COMMENT with the same commit_id costs a call and lands nothing — the pull request ends up with no review at all, which is the case the rung exists to avoid. Dropping commit_id on the retry would let the comment land on the current head, which is already the announced fallback at line 1299 when the SHA was never recorded. E.g. give post_position a second argument that suppresses the commit_id append, and pass it on the COMMENT call.
| # identity that reads it. Failing open there would review a pull request | ||
| # carrying the label, on the one configuration that cannot notice. | ||
| if [ "$COMMAND" != "close" ] && [ -n "$SKIP_LABEL" ]; then | ||
| if [ -n "${GH_TOKEN:-}" ]; then |
There was a problem hiding this comment.
[suggestion] This PR applies steps.identity.outputs.token || github.token to every publisher in the review job, but the guard's GH_TOKEN at line 451 keeps the App-only form — which is what forces the fail-open / fail-closed split being added here. Giving the guard job permissions: { pull-requests: read } (it currently declares permissions: {}) and using the same || github.token fallback would make the skip-label readable in every configuration, including the no-App one that still fails open today, and would let this branch collapse back to the single gh api ... | grep -qxF check. Worth considering as the simpler shape, consistent with what the rest of the diff does.
…ed pull request (#89) A review clones the pull request's code into a sandbox that holds a live App credential and a shell. Where that code comes from a fork, someone outside the organisation wrote it. The guard's `Admit the request` step now compares the head and base repository ids, and refuses when they differ. It refuses on both paths. ## What changed `.github/workflows/seidroid-review.yml`, +105/-33. Rebased onto `5f5fd78`, which is `#86` and `#91`. **The check**, in `Admit the request`, between the team-membership check and the skip-label check: ```sh if [ "$MODE" != "close" ]; then if [ "$EVENT_NAME" = "pull_request" ]; then if [ -z "$BASE_REPO_ID" ]; then origin=unreadable elif [ "$HEAD_REPO_ID" = "$BASE_REPO_ID" ]; then origin=same else origin=fork fi refusal="$REPO#$PR is fork-originated; not reviewing it" else origin="$(GH_TOKEN="$GATE_TOKEN" gh api "repos/$REPO/pulls/$PR" \ --jq 'if .head.repo.id != null and .head.repo.id == .base.repo.id then "same" else "fork" end' \ || true)" refusal="explicit re-reviews are disabled for fork-originated pull requests; not reviewing $REPO#$PR" fi case "$origin" in same) ;; fork) deny "$refusal" ;; *) deny "could not read where $REPO#$PR comes from, so a fork cannot be ruled out; not reviewing it" ;; esac fi ``` **The signals.** `MODE: ${{ inputs.mode }}`, `HEAD_REPO_ID` and `BASE_REPO_ID` in the step's `env:`. The gate keys on the caller's routing, not on this guard's own re-parse of the comment body. **The token.** `GATE_TOKEN`, which `#86` added to the same step and this check now shares. An `issue_comment` payload carries no `pull_request.head.repo`, so the API answers there, and the read must work whether or not a caller configures an App. **The permission.** `#86` already grants the guard `pull-requests: read` and `issues: read`. This check adds no grant. It rewrites the comment on that block, because three reads now share it and they do not fail the same way. **The verdict gate.** `Require the machine-client secret` now reads `steps.admit.outputs.admit == 'true'` as well as the parse. See below. **Three comments** that stated something this change makes false. Listed at the end. ## Two sources, one rule A `pull_request` payload already carries both ids, so that path spends no API call. An `issue_comment` payload carries no head repository, so the API answers there. The payload branch tests the base id first. Comparing two absent ids makes them equal, which would admit a fork on a payload that carried no signal at all. A present base id with an absent head id, the deleted-fork case, falls to `fork`. ## How the incumbent words its refusal `.github/workflows/ai-review.yml` lines 250-257: ```js const isFork = pr.head.repo?.id !== pr.base.repo?.id; if (!isAutomatic && isFork) { core.notice( "Explicit re-reviews are disabled for fork-originated pull requests." ); ``` Two things carry over. First the **sentence**: `explicit re-reviews are disabled for fork-originated pull requests`, word for word, in lower case, with `; not reviewing $REPO#$PR` after it. The draft and skip-label denies in this file already use that shape. Second the **comparison**: repository ids, not names, so a rename does not read as a fork. A null head repository reads as a fork. The refusal wording differs by path on purpose. The incumbent's sentence is accurate on the comment path. Nobody asked explicitly on the automatic path, so that path says `$REPO#$PR is fork-originated; not reviewing it`. ## Divergence from the incumbent, recorded on purpose `ai-review.yml` refuses forks on the explicit path only (`!isAutomatic && isFork`) and still reviews a fork pull request automatically. This refuses both paths. That is a deliberate posture change, not a port, and it is worth being exact about what it costs. Under the default, it costs nothing. A fork `pull_request` run receives no secrets, so `Require the machine-client secret` fails it today. This change turns that hard failure into a clean refusal. **Under one configuration it does remove a working behaviour, and that removal is the objective.** A private or internal repository can enable one Actions setting: "Send secrets and variables to workflows from fork pull requests". A repository owner or an organisation policy sets it. There, a fork `pull_request` run receives `OMNIGENT_MACHINE_CLIENT_SECRET`, passes the machine-client check, and drives an agent over fork code. That is the exposure this ticket exists to close. Calling it lost coverage would be reading it backwards: it is outside code beside a live credential, and the incumbent has the same gap. ## Fail closed, unlike the neighbours The step runs under `set -uo pipefail` with no `-e`. A failed command substitution does not stop the step; it leaves the variable empty and carries on. The `case` therefore admits on `same` alone. Every other value refuses, including empty. That is the opposite of the checks around it: | check | on an unreadable signal | why | |---|---|---| | skip-review label | admits | a convenience, not a control. Being unable to read it must not stop every review. | | once-per-PR (#86) | admits | same reasoning: one extra review, corrected by the next push. | | **fork origin (this)** | **refuses** | a refused review costs one retry a person can make. Admitting on a signal nobody could read costs the sandbox. | The code states that reason, directly above the check. The refusal names a cause. `gh` writes its own error to the step log, so a 403 or a 404 appears immediately above the notice: ``` gh: Resource not accessible by integration (HTTP 403) ::notice::could not read where #42 comes from, so a fork cannot be ruled out; not reviewing it ``` ## A refused fork ends green, not red `deny` exits 0, so every step after `Admit the request` still runs. `Require the machine-client secret` read the parse alone. A refused fork `pull_request` run therefore reached it, found no secret, and ended the guard red. That pointed at a caller misconfiguration that does not exist, and contradicted the notice the gate had just written. The step now reads the verdict too. Evaluated against both revisions, with the step's own script run when the condition holds: | revision | condition | step | guard | |---|---|---|---| | before | `should_run == 'true'` | runs, exit 1 | RED, `::error::OMNIGENT_MACHINE_CLIENT_SECRET is not set…` | | after | `should_run == 'true' && admit == 'true'` | skipped | GREEN | The fail-fast survives where it belongs: | scenario | machine-client step | guard | `guard.should_run` | |---|---|---|---| | fork `pull_request`, no secrets, refused | skipped | GREEN | false | | admitted review, secret missing | runs, exit 1 | RED | true | | admitted review, secret present | runs, exit 0 | GREEN | true | | comment parsed to nothing | skipped | GREEN | false | The review job skips either way, so only the guard's colour changes. **Audit of the other steps.** `Report a half-configured reviewer identity` also keys on the parse alone. It stays that way deliberately. #88 made `Admit the request` deny when a caller sets half an App credential. That warning is what explains the deny. Gating it on `admit` would suppress the diagnostic exactly where a reader needs it. No other guard step keys off `should_run`. ## The App stays optional `GATE_TOKEN` prefers the App identity and falls back to `github.token`, so a caller that configures no `SEIDROID_APP_ID` still reaches the read. A same-repository pull request admits there, and a fork refuses. The secret's `required: false` contract holds. The name sits apart from `GH_TOKEN` on purpose. The team check has no such fallback: reading an organisation's teams needs an identity that can see them, and `GITHUB_TOKEN` cannot. ## The close path still runs `@seidroid review close` on a fork pull request still reclaims its sandbox. A close is the only thing that reclaims one: no lifetime cap and no sweep does it instead. The comment-path close depends on the guard's verdict — the review job requires `needs.guard.outputs.should_run == 'true'` for every `issue_comment` run — so a deny would block the reclaim. The gate keys on `$MODE`, not on `$COMMAND`. Two readers derive those two from one comment body, and they can disagree. This guard's grammar accepts a bare `seidroid review close` with no `@`. A caller matching the documented `@seidroid review close` form routes that same comment as `mode: review`. Keying on `$COMMAND` therefore skipped the fork check on a comment the caller had routed as a real review. That is the bypass this PR exists to close. `$MODE` decides what the review job does. Nothing risky runs when the mode is close, so the exemption stays safe both ways. ## Three comments this change corrects Two of them asserted that GitHub withholds secrets from a fork `pull_request` run. Both rested a safety argument on it. That holds by default, not by guarantee. - The check's own comment said the automatic path never reaches it. It now names the default, names the setting that disables it, and states that the check does not rest on it. - The guard job's comment justified having no author-association check on the `pull_request` branch, partly on the same withholding. It now points at this gate. - The header block enumerates the gates. It now names the fork refusal, and says `Both paths`. That sentence already ran to 35 words, so I split it into four rather than adding a clause. ## Verification Rebased onto `feat/seidroid-review` at `5f5fd78` (#91). `actionlint` 1.7.12 against that base and against this branch: ``` before: 4 findings, all SC2102 after: 4 findings, all SC2102 diff of the two, normalised for line numbers: identical ``` YAML parses: `python3 -c "import yaml; yaml.safe_load(open('.github/workflows/seidroid-review.yml'))"` returns clean. `yaml.safe_load` extracts the step's script from the YAML. A harness runs it under `bash`, with a `gh` stub on `PATH` that refuses when it receives no token, the way `gh` itself does. | case | verdict | |---|---| | fork PR + review comment | DENY, fork refusal | | same-repo PR + review comment | ADMIT | | fork PR + `close` | ADMIT | | fork + body `close`, caller sent `mode: review` | DENY, fork refusal | | fork + body review, caller sent `mode: close` | ADMIT | | same-repo + body `close`, caller `mode: review` | ADMIT | | `pull_request` + fork payload | DENY, `#42 is fork-originated` | | `pull_request` + same-repo payload | ADMIT | | `pull_request` + null head repo id | DENY, fork | | `pull_request` + no ids at all | DENY, could not read | | `pull_request` + fork + draft | DENY, draft, unchanged | | `pull_request` + `mode: close` | ADMIT | | read fails 404 | DENY, could not read | | read fails 403 | DENY, could not read | | read returns nothing | DENY, could not read | | deleted fork, null head repo | DENY, fork refusal | | no App, same-repo PR + review comment | ADMIT | | no App, fork PR + review comment | DENY, fork refusal | | no App, fork PR + `close` | ADMIT | | no token at all | DENY, could not read | | same-repo + `close` | ADMIT | | automatic, same-repo, not draft | ADMIT | | automatic, same-repo, draft | DENY, draft, unchanged | | same-repo + skip label | DENY, label, unchanged | | half an App credential (#88) | DENY, half-credential, unchanged | | fork PR + team member | DENY, fork refusal | | same-repo + non-member | DENY, membership, unchanged | | parse said no | DENY, unchanged | Real `jq` answered five payload shapes: same ids to `same`; different ids to `fork`; `head.repo: null` to `fork`; `head.repo` absent to `fork`; an error body to `fork`. API cost, counted by the stub: one read on the comment-review path, zero on every other path. ## Not verified from here Nothing here has run in a GitHub runner. The harness proves three things: the shell logic, the jq mapping, and the step conditions evaluated the way GitHub would for this expression shape. It does not prove that `github.token` with `pull-requests: read` answers `repos/{repo}/pulls/{n}` in a real run. No repository of mine enables that Actions setting. I have therefore not observed a fork `pull_request` run receiving secrets. That setting's existence and effect come from review, not from measurement. Not depending on the default is sound either way. ## Where this check meets the once-per-PR gate `#86`'s gate sits after this one, so the ordering matters and the harness covers it. | case | verdict | |---|---| | automatic, same-repo, verdict already posted | DENY, the gate's own refusal | | automatic, fork, verdict already posted | DENY, fork refusal — the gate is never reached | | `synchronize`, same-repo, standing block | ADMIT, the gate's withdrawal path intact | | `synchronize`, same-repo, block read fails | ADMIT, the gate still fails open | | `synchronize`, fork, standing block | DENY, fork refusal | The last row is a consequence worth stating. `#86` runs a review on a pull request carrying a standing `CHANGES_REQUESTED` from this workflow, because the withdrawal lives inside a review. On a fork this check refuses that review, so such a block stays until a maintainer dismisses it by hand. That is the right way round. The alternative is running an agent over fork code to retract a review. The case is also narrow. It needs a block this workflow left on a fork pull request, and only a review that already ran could have created one. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
The step that records the review's position required the App identity, so a repository with no
SEIDROID_APP_IDgot a verdict comment and never appeared in the reviewers list. It now falls back togithub.token, which is what every sibling publisher in this file already does.What changed
State the review's position on the pull requestdropssteps.identity.outputs.token != ''from its condition and readssteps.identity.outputs.token || github.token. The App still authors the position wherever a caller configured one.COMMENTrung, mirroring.github/workflows/ai-review.yml:983-986. When the API refusesAPPROVEorREQUEST_CHANGES, the step retries the same body asCOMMENTand warns.Answer the requeststep names why it stands apart from the position step.The withdrawal, its interlocks and its loud failure on a stuck block are untouched.
What a GITHUB_TOKEN review can and cannot do
Measured in this repository.
github-actions[bot]— the loginGITHUB_TOKENcarries — has createdAPPROVEreviews here throughai-review.yml's identical fallback: PRs #84, #83, #75, #74, #73, #72, #71, #70 and #61 all carry agithub-actions[bot]APPROVEDreview whose body opens with<!-- ai-combined-review -->. It has also createdREQUEST_CHANGESreviews and dismissed them: the timeline of #75 records tworeview_dismissedevents withactor=github-actions[bot],state=changes_requested, messageSuperseded: latest AI review found no blocking issues.So the create path and the dismissal path both work under this repository's settings.That the approvals land at all means sei-protocol allows Actions to approve a pull request. GitHub refuses the call outright when that setting is off, and none of these calls was refused.
Cannot. A
COMMENTreview casts no vote. Where the rung fires, anAPPROVEclears no approval requirement and aREQUEST_CHANGESblocks no merge. The check run remains the gate in both cases.The branch-protection question
Neither identity clears the review requirement on
sei-chaintoday. sei-chain PRs #4094 and #4100 each carry exactly one review —seidroid[bot]APPROVED, from the App — and GraphQLreviewDecisionon both readsREVIEW_REQUIRED. sei-chain'smainis protected and carries.github/CODEOWNERS. So a bot approval, App-authored or not, does not satisfy that gate now. The fallback costs nothing there that the App identity was buying.uciitself has no gate to satisfy.mainreportsprotected: false, and the one ruleset (id 7955617,~DEFAULT_BRANCH) isenforcement: disabled. Were it enabled it would ask forrequired_approving_review_count: 1,required_reviewers: [],require_code_owner_review: false,dismissal_restriction.enabled: false— a rule that names no reviewer and restricts no dismissal.What I could not verify
github-actions[bot]approval satisfies a required-approval rule.GET /repos/sei-protocol/uci/actions/permissions/workflowand the org equivalent both returned 403 for my token, so I could not readcan_approve_pull_request_reviewsdirectly — the successful approvals above are the inference. No repository in reach has a merge gate that an Actions approval has ever been put to.GET /repos/sei-protocol/sei-chain/branches/main/protectionreturns 404 for my token. CODEOWNERS is the probable reasonreviewDecisionstaysREVIEW_REQUIRED, not a measured one.COMMENTrung under a real refusal. I never saw the API refuse a position, so the rung is exercised against a stub, not against GitHub.Two consequences worth naming
COMMENTretry writes a second review.ai-review.yml:968-991carries the same hazard; the cost is one extra review, never a changed gate.Verification
actionlint1.7.12, against base543f11f:Both findings pre-date this change and sit in steps it does not touch.
Behaviour battery. I extracted the step's script from the YAML at both revisions, put a stubbed
ghonPATH, and ran twelve paths against each: event accepted, event refused, every event refused, approve off, blocking, blocking with the event refused, dismissals refused, review list unreadable, neutral with a blocker, neutral with nothing written down, neutral with no counts, and no check file. The base and the new step produce byte-identical output on every path but the two where the newCOMMENTrung fires:Both loud failures still exit 1: the stuck dismissal and the unreadable review list. All four withdrawal interlocks are unchanged — a blocker beside a soft conclusion stops the withdrawal, a soft conclusion with zero counts stops it, absent counts let it proceed with a warning, and a
failureconclusion exits before it.One thing the ticket got wrong
The ticket's phrasing — "drop the identity requirement from the step's condition" — was written against a base that still carried the cross-repository target grammar.
bf507f3removed that grammar, so the step now only ever posts togithub.repositoryand dropping the requirement outright is safe. Against the older base it was not:github.tokenreaches no other repository, the withdrawal's review listing would have 404'd, and the step would have failed the job reporting a block it could not see.🤖 Generated with Claude Code