Skip to content

fix(seidroid-review): fall back to GITHUB_TOKEN for the position and the dismissal - #88

Merged
bdchatham merged 5 commits into
feat/seidroid-reviewfrom
fix/fall-back-to-github-token-for-the-position
Sep 6, 2026
Merged

fix(seidroid-review): fall back to GITHUB_TOKEN for the position and the dismissal#88
bdchatham merged 5 commits into
feat/seidroid-reviewfrom
fix/fall-back-to-github-token-for-the-position

Conversation

@bdchatham

Copy link
Copy Markdown
Contributor

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 never appeared in the reviewers list. It now falls back to github.token, which is what every sibling publisher in this file already does.

What changed

  • State the review's position on the pull request drops steps.identity.outputs.token != '' from its condition and reads steps.identity.outputs.token || github.token. The App still authors the position wherever a caller configured one.
  • The position post gains a COMMENT rung, mirroring .github/workflows/ai-review.yml:983-986. When the API refuses APPROVE or REQUEST_CHANGES, the step retries the same body as COMMENT and warns.
  • Two comments now state the present: the position step names what the fallback identity costs, and the Answer the request step 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 login GITHUB_TOKEN carries — has created APPROVE reviews here through ai-review.yml's identical fallback: PRs #84, #83, #75, #74, #73, #72, #71, #70 and #61 all carry a github-actions[bot] APPROVED review whose body opens with <!-- ai-combined-review -->. It has also created REQUEST_CHANGES reviews and dismissed them: the timeline of #75 records two review_dismissed events with actor=github-actions[bot], state=changes_requested, message Superseded: 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 COMMENT review casts no vote. Where the rung fires, an APPROVE clears no approval requirement and a REQUEST_CHANGES blocks no merge. The check run remains the gate in both cases.

The branch-protection question

Neither identity clears the review requirement on sei-chain today. sei-chain PRs #4094 and #4100 each carry exactly one review — seidroid[bot] APPROVED, from the App — and GraphQL reviewDecision on both reads REVIEW_REQUIRED. sei-chain's main is 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.

uci itself has no gate to satisfy. main reports protected: false, and the one ruleset (id 7955617, ~DEFAULT_BRANCH) is enforcement: disabled. Were it enabled it would ask for required_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

  • Whether a github-actions[bot] approval satisfies a required-approval rule. GET /repos/sei-protocol/uci/actions/permissions/workflow and the org equivalent both returned 403 for my token, so I could not read can_approve_pull_request_reviews directly — the successful approvals above are the inference. No repository in reach has a merge gate that an Actions approval has ever been put to.
  • sei-chain's protection detail. GET /repos/sei-protocol/sei-chain/branches/main/protection returns 404 for my token. CODEOWNERS is the probable reason reviewDecision stays REVIEW_REQUIRED, not a measured one.
  • A live run. Nothing here ran on a runner. The evidence is the incumbent's history, the API state above and the local battery below.
  • The COMMENT rung 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

  1. A repository with no App now runs the withdrawal where it previously skipped the whole step. A dismissal the API refuses fails the job, by the design this step already states. That is the intended loud failure, and it is new exposure for those repositories.
  2. If the first post lands server-side but the client reports a failure, the COMMENT retry writes a second review. ai-review.yml:968-991 carries the same hazard; the cost is one extra review, never a changed gate.

Verification

actionlint 1.7.12, against base 543f11f:

base: 4 findings, exit 1  -> 4 SC2102:info
new:  4 findings, exit 1  -> 4 SC2102:info
rule set diff: identical

Both findings pre-date this change and sit in steps it does not touch.

$ python3 -c "import yaml; yaml.safe_load(open('.github/workflows/seidroid-review.yml'))"
yaml ok
$ shellcheck -s bash <the position step's script>
(clean)

Behaviour battery. I extracted the step's script from the YAML at both revisions, put a stubbed gh on PATH, 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 new COMMENT rung fires:

--- clean, approve on, event refused
-::warning::could not record APPROVE ...; the verdict comment stands
+::warning::... would not take APPROVE, so this review is recorded as a comment;
+  the check run carries the success conclusion
--- blocking, request changes refused
-::warning::could not record REQUEST_CHANGES ...; the verdict comment stands
+::warning::... would not take REQUEST_CHANGES, so this review is recorded as a
+  comment; the check run carries the failure conclusion

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 failure conclusion 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. bf507f3 removed that grammar, so the step now only ever posts to github.repository and dropping the requirement outright is safe. Against the older base it was not: github.token reaches 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

@cursor

cursor Bot commented Sep 6, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Changes merge-adjacent GitHub behavior (review votes, dismissals, skip-label gating) and expands loud job failure when dismissals are refused under the fallback identity.

Overview
Review position and dismissals no longer require a minted App token. The step that records APPROVE / REQUEST_CHANGES always runs when there is a verdict, using steps.identity.outputs.token || github.token, so repos without SEIDROID_APP_* still show up in the reviewers list and can run block withdrawal under github-actions[bot].

When GitHub refuses the requested review event (detected via 422 on the error body), the workflow retries as COMMENT and warns—matching the ai-review fallback—so policy refusals (e.g. Actions cannot approve) still leave a review thread while the check run stays the merge gate.

App identity is treated as all-or-nothing: HAS_REVIEWER_IDENTITY and the guard mint step require both SEIDROID_APP_ID and SEIDROID_APP_PRIVATE_KEY. Half-configured secrets get a workflow warning, and the skip-review label check denies review (fail closed) instead of silently ignoring the label. Docs and dismissal error text call out branch-protection dismissal restrictions when the posting identity changes.

Reviewed by Cursor Bugbot for commit e7e326a. Bugbot is set up for automated code reviews on this repo. Configure here.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_ID now runs the withdrawal block that was previously skipped along with the whole step, and that path exit 1s on a dismissal the API refuses. The narrow-but-real case is a repository that had the App, accumulated seidroid[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 the SEIDROID_APP_ID secret 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.

Comment thread .github/workflows/seidroid-review.yml Outdated
# 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.yml carries it too).
  • A 422 caused by a stale commit_id retries with the same commit_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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@bdchatham
bdchatham force-pushed the fix/fall-back-to-github-token-for-the-position branch from a39e796 to cb17813 Compare September 6, 2026 20:17

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-success remains 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 the SEIDROID_APP_ID secret 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 from secrets.SEIDROID_APP_ID != '' alone. A caller that configures the app id but not SEIDROID_APP_PRIVATE_KEY makes the actions/create-github-app-token step run and fail; that step has no continue-on-error, so the whole job aborts rather than falling back to github.token. Gating on both secrets would make the "App is optional" contract hold for a half-configured caller too.

Comment thread .github/workflows/seidroid-review.yml Outdated
fi
err="$(gh api "${args[@]}" 2>&1 >/dev/null)" && return 0
rc=1
case "$err" in (*"HTTP 403"*|*"HTTP 422"*) rc=2 ;; esac

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 COMMENT rung 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 with approve-on-success gets 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 ;;
esac

That 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. With SEIDROID_APP_ID set and SEIDROID_APP_PRIVATE_KEY empty, the previous behaviour was a red guard job from create-github-app-token. Now the mint is skipped, GH_TOKEN in Admit the request is empty, and — for any caller that also sets allowed-team — line 491 takes the deny "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"
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

bdchatham and others added 3 commits September 6, 2026 13:41
…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>
@bdchatham
bdchatham force-pushed the fix/fall-back-to-github-token-for-the-position branch from 9d0bb47 to 80bb314 Compare September 6, 2026 20:44

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 main restricts who may dismiss reviews will get exit 1 from this step on every subsequent clean run once github-actions[bot] has recorded one CHANGES_REQUESTED — a permanently red job until a human dismisses. Worth a line in the caller-facing docs alongside the SEIDROID_APP_ID description, 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.

Comment thread .github/workflows/seidroid-review.yml Outdated
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"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 != '' }}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread .github/workflows/seidroid-review.yml Outdated
fi
err="$(gh api "${args[@]}" 2>&1 >/dev/null)" && return 0
rc=1
case "$err" in (*"HTTP 422"*) rc=2 ;; esac

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 1

gh'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>

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_IDENTITY in 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 status is "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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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)"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

@bdchatham
bdchatham merged commit d477b7d into feat/seidroid-review Sep 6, 2026
16 of 21 checks passed
@bdchatham
bdchatham deleted the fix/fall-back-to-github-token-for-the-position branch September 6, 2026 21:06
bdchatham added a commit that referenced this pull request Sep 6, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant