Skip to content

feat(seidroid-review)!: remove the cross-repository target grammar - #83

Merged
bdchatham merged 3 commits into
feat/seidroid-reviewfrom
fix/drop-cross-repo-target
Sep 6, 2026
Merged

feat(seidroid-review)!: remove the cross-repository target grammar#83
bdchatham merged 3 commits into
feat/seidroid-reviewfrom
fix/drop-cross-repo-target

Conversation

@bdchatham

Copy link
Copy Markdown
Contributor

Implements PLT-1148. seidroid review is now only invokable on the repository
the pull request is on.

@seidroid review owner/name#123 reviewed a pull request in another repository.
ai-review.yml never supported this — every call there uses context.repo — so
this is net-new capability the rewrite introduced, withdrawn by decision.

What went

31 expression occurrences of review_repo / review_repo_name / review_pr on
27 lines, across 11 steps plus the guard's outputs: block and the review job's
concurrency.group. Plus target_re, the guard's target-parse block, the dead
REPO_OWNER env entry, the Place findings on the code identity-or-same-repo
condition (a tautology once there is no target), and 17 prose sites.

No workflow_call input changes, so neither caller breaks.

What replaced it

An explicit refusal. ai-assistant.yml reserves only the exact body
@seidroid review, so the withdrawn form would otherwise fall through and get a
conversational answer — a worse signal than silence, because it looks like the
system worked.

A bug found and fixed inside this change

The refusal's first cut read a grep -q exit status. Under set -o pipefail
that is wrong: -q exits at the first match, printf is then killed by SIGPIPE
writing into a closed pipe, and pipefail propagates 141 — so the refusal is
silently not written
. Measured: at 232 kB the pipeline returned 141 and the
refusal was missed.

The threshold is a race between printf's write and grep's read-then-exit, so
it passes every small-body test and fails nondeterministically on a long
comment. GitHub's comment cap is 65,536 characters, so the input is reachable.

Fixed by capturing through grep -m1 … || true and testing for emptiness — the
same idiom the cmdline= line three lines above already uses. Re-measured
through the extracted script: the refusal fires at 1 kB, 65 kB and 200 kB, one
::error:: and one should_run key each time.

Two traps avoided

repositories: stays on both App-token mints. With owner set and
repositories absent, the token covers every repository the installation
reaches — a privilege widening disguised as cleanup.

The regex target group and the parse block go in the same commit as the
expression collapse. A half-removal parses a target, admits it, and reviews the
local pull request while the requester believes otherwise.

Verification

grep -nE 'review_repo|review_pr|target_re|REPO_OWNER'     empty
grep -niE 'cross-repositor|asking repositor|elsewhere'    empty
actionlint       5 findings → 4; SC2129 gone (it sat on a deleted line),
                 4×SC2102 unchanged at identical script offsets, nothing new
shellcheck       clean on the extracted parse script
step bodies      2 changed (parse, Post the verdict), 11 byte-identical

20 primary behavioural rows plus 12 supplementary, driven through the extracted
parse script. Every row required to stay identical is byte-identical to the
base — including a CRLF body, tab separators, uppercase, backtick-quoted
mentions, and both malformed-target shapes.

One drift row beyond the four expected: a body carrying both a target line
and a valid bare command line. The base matched the target; this matches the
bare line and runs an ordinary local review with no annotation. That is the
right precedence and the honest signal.

Not changed, worth a look

The refusal annotates but does not fail. ::error:: does not fail a step, so
the guard still concludes success and the requester may never open the run —
while ai-assistant.yml still answers the target form conversationally.
Widening that reservation would put the refusal where the request was written.
Out of scope here.

github.repository in the concurrency.group is now redundant — a reusable
workflow's group is already scoped to the calling repository. Kept, because
dropping it would change a live group key for any in-flight run.

Pre-merge check

The driver keys a session on the reviewed (repo, pr) pair, so a session created
by a cross-repo review becomes unreclaimable through this workflow — and the
launcher sets no lifetime cap while the server runs no sweep. Enumerate live
sessions for a key naming anything other than a caller's own repository, or
search run history for the guard's ::notice::reviewing <target> annotation, and
close any found before a caller's pin moves.

🤖 Generated with Claude Code

`@seidroid review owner/name#123` reviewed a pull request in another
repository. `ai-review.yml` never offered this. A review must only ever be
invokable on the repository the pull request is on, so a maintainer decision
withdraws the grammar.

The whole surface goes in one commit, because a half-removal is worse than
either end state. A parse that still admits a target reviews the local pull
request. The requester then reads a verdict on code the review never read.
This commit deletes the regex target group, the parse block, the guard's three
target outputs, and every expression that read them. That is 31 occurrences
over 27 lines, in 11 steps, the guard `outputs:` block, and the review
`concurrency.group`.

Two fallbacks stay. The review job's `concurrency.group` and the drive step's
`PR:` keep `github.event.pull_request.number`. A close that arrives as a
`pull_request` event skips the guard, so the event is the only source of a
number there. Collapsed, every reclaim run shares one group. Two closes for
different pull requests then cancel each other, and close is the only thing
that reclaims a sandbox.

Both App-token mints keep `owner:` and `repositories:`. `owner` on its own
mints a token that reaches every repository the installation is on. So
`repositories` narrows to `github.event.repository.name` rather than going
away.

The guard refuses the withdrawn form by name. `ai-assistant.yml` reserves the
bare `@seidroid review` body only, so a request that carries a target reaches
the conversational assistant. A chatty answer to it reads as a review that ran
somewhere.

The refusal captures the match through `grep -m1 ... || true` rather than
reading a `grep -q` exit status. `-q` stops at the first match. On a body
larger than the pipe buffer that leaves `printf` writing into a closed pipe,
and `pipefail` reads the SIGPIPE as a failed pipeline. Measured at 232 kB, the
`-q` form reports status 141 and writes no refusal.

Verified. actionlint drops from 5 findings to 4. The lone SC2129 sat on a
deleted line, and the four SC2102 hold at the same script offsets. Nothing new
appears. `shellcheck -s bash` is clean on the extracted parse script.

The extracted script also answers the ordinary grammar byte-identically to the
base. That covers the bare and `@`-prefixed forms, leading, trailing and
doubled whitespace, a command line inside a longer comment, `close`, and the
`pull_request` path. Every run body in the file except `parse` and `Post the
verdict` hashes identical to the base.

BREAKING CHANGE: `@seidroid review owner/name#123` and `@seidroid review close
owner/name#123` earn an error annotation instead of a review of the named pull
request. No `workflow_call` input or secret changes, so no caller needs an
edit.

Refs: #82

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@cursor

cursor Bot commented Sep 6, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Workflow behavior change for anyone using cross-repo review syntax; guard and token scoping changes affect when reviews run and where they post, though callers and inputs are unchanged.

Overview
Breaking: @seidroid review no longer accepts owner/repo#N — reviews always run on the pull request in the repository where the comment or pull_request event fired.

The guard drops review_repo / review_pr outputs and the target regex; valid commands are whole-line @?seidroid review with optional close only. Comments that still name another repo get a ::notice refusal (so they do not fall through to the assistant) and should_run=false, with detection fixed to use grep -m1 + emptiness instead of grep -q under pipefail (which could SIGPIPE and skip the notice on large bodies).

Downstream steps always use github.repository and the event PR number for admit, driver session, thread recall, head SHA, inline placement, check runs, review position, and verdict posting. App installation tokens stay minted with owner + repositories pinned to the calling repo. Concurrency groups on that repo and PR, not a remote target. Inline placement no longer skips when the workflow token cannot reach another repository.

Reviewed by Cursor Bugbot for commit da6b7ca. 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.

A clean, complete withdrawal of the cross-repository target grammar — all 31 expression sites, the guard's parse block, the outputs, the concurrency key and the now-tautological Place findings condition are removed consistently, with no dangling references and no workflow_call input changes. One non-blocking concern: the explicit refusal that replaced the grammar is emitted as an ::error:: annotation on a run that isn't linked to the PR, and it doesn't stop ai-assistant.yml from posting the conversational reply it was written to pre-empt.

Findings: 0 blocking | 4 non-blocking | 1 posted inline

Blockers

  • None at the file/PR level.

Non-blocking

  • Verified the removal is complete and self-consistent: no review_repo / review_repo_name / review_pr / REPO_OWNER / target_re references remain anywhere in .github/workflows/, no in-repo caller or doc (.github/seidroid/ai-review/README.md) documented the withdrawn target grammar, and the workflow_call input surface is unchanged so no caller breaks.
  • The named_repo capture correctly reuses the grep -m1 … || true idiom rather than grep -q; with set -o pipefail the || true inside the command substitution absorbs the SIGPIPE-derived 141, so the fix described in the PR body holds.
  • Keeping repositories: alongside owner: on both create-github-app-token mints is the right call — dropping it would have widened the token to every repository the installation reaches.
  • 1 suggestion(s)/nit(s) flagged inline on specific lines.

Comment thread .github/workflows/seidroid-review.yml Outdated
named_repo="$(printf '%s\n' "$cmd" \
| grep -m1 -E '^[[:space:]]*@?seidroid[[:space:]]+review([[:space:]]+close)?[[:space:]]+[A-Za-z0-9][A-Za-z0-9._-]*/[A-Za-z0-9][A-Za-z0-9._-]*#[0-9]+[[:space:]]*$' || true)"
if [ -n "$named_repo" ]; then
echo "::error::seidroid review takes no repository target; a review runs only on the repository the pull request is on"

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 refusal doesn't reach the person who asked, and it doesn't achieve the goal the comment above it states.

::error:: writes an annotation onto this workflow run's summary. An issue_comment-triggered run is not attached to the pull request, so it appears in no check list and on no timeline — the requester would have to go find the Actions run to see it.

Meanwhile the outcome this block is meant to prevent still happens. ai-assistant.yml reserves only the body that normalizes to exactly @seidroid review (.github/workflows/ai-assistant.yml:107-110), so @seidroid review owner/name#123 is not reserved, is_review_command is false, and the conversational assistant posts its generic reply exactly as before. The net effect of this change is the chatty answer the comment calls "a worse signal than silence", plus an invisible annotation.

If the refusal is meant to be seen, it has to be a PR comment (the guard has permissions: {}, so this would need a scoped grant or to be deferred to a step that already holds a token). If an annotation is genuinely all that's wanted, the comment above should say that it's a log-only record rather than a user-facing refusal, since the stated justification for existing at all is the signal to the requester.

Separately, the pattern only matches the exact whole-line owner/name#N shape, so @seidroid review owner/name#123 please or @seidroid review other-repo#5 fall through with no annotation at all — worth noting in the comment if that narrowness is deliberate.

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 correct, and the second one names the sharper version: the comment justified the refusal with a benefit it does not deliver. Fixed in the latest commit.

You are right on both mechanisms. An issue_comment run attaches to no pull request, so the annotation reaches whoever opens the run and nobody else. And ai-assistant.yml reserves the body that normalises to exactly @seidroid review, so the target form was never reserved and still reaches the conversational assistant.

I kept the refusal and rewrote the justification rather than escalating it, because both of those match ai-review: there the target form was never a review command either, so an unreserved body reaching the assistant is the incumbent's behaviour, not a regression this branch introduces. Parity is the standard for this work, so matching it is the answer rather than exceeding it.

What the line now claims is only what it does: it names the reason in the one place a person debugging "why did my request do nothing" will look. If we later want the refusal to reach the requester, the change is to ai-assistant.yml's reservation, not here — and that is a behaviour change past this ticket.

ai-review denies at notice throughout: an unauthorised actor, an unlisted bot,
a draft, an empty team. error there is reserved for a caller that wired the
workflow wrongly, which this is not -- the request is well-formed and refused.

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.

A faithful, mechanical withdrawal of the cross-repository target grammar: every review_repo/review_pr/target_re/REPO_OWNER reference is gone, each removed expression fallback collapses to exactly the value it produced when no target was named (concurrency group, Place findings condition, Post the verdict REPO/PR ternaries all verified identity-preserving), and both App-token mints keep repositories: so token scope is not widened. The only net-new logic is the refusal notice, which is correctly written (captured through grep -m1 … || true rather than a pipefail-sensitive grep -q, and it no longer interpolates comment-derived text into a workflow command); my remaining notes are about whether that refusal ever reaches the person who wrote the request.

Findings: 0 blocking | 4 non-blocking | 1 posted inline

Blockers

  • None at the file/PR level.

Non-blocking

  • The refusal's reachability depends on caller wiring that lives outside this repo. The header states routing is the caller's ("it reads the body and passes the mode"), so a caller whose dispatch condition matches only the exact @seidroid review body never invokes this workflow for @seidroid review owner/name#12 — and the new named_repo branch is then unreachable dead code. Worth confirming against at least one real caller, or noting in the input docs that callers must dispatch on a looser body match for the refusal to fire.
  • The PR description and the code disagree on the annotation level: the Verification block says "one ::error:: and one should_run key each time" and the "Not changed, worth a look" section reasons about ::error:: not failing a step, while the merged code emits ::notice:: (correctly, per its own inline rationale). Worth correcting the description so a later reader does not go looking for an error annotation that is not emitted.
  • The refusal regex restates the command grammar's ^[[:space:]]*@?seidroid[[:space:]]+review([[:space:]]+close)? prefix verbatim three lines below the grammar itself. The comment notes they are deliberately matched on the same anchor, but nothing enforces that; a future change to the accepted command shape has to be made in both places or the refusal silently stops covering the fall-through set.
  • 1 suggestion(s)/nit(s) flagged inline on specific lines.

named_repo="$(printf '%s\n' "$cmd" \
| grep -m1 -E '^[[:space:]]*@?seidroid[[:space:]]+review([[:space:]]+close)?[[:space:]]+[A-Za-z0-9][A-Za-z0-9._-]*/[A-Za-z0-9][A-Za-z0-9._-]*#[0-9]+[[:space:]]*$' || true)"
if [ -n "$named_repo" ]; then
echo "::notice::seidroid review takes no repository target; a review runs only on the repository the pull request is on"

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 refusal is written only to the guard job's log. should_run=false follows, the review job is skipped, and the workflow concludes success — so the requester sees a green check and has to open the run and read annotations to find out anything was refused.

The comment above justifies the refusal by saying silence is not available, because ai-assistant.yml reserves only the exact bare body and "a chatty answer to this reads as a review that ran somewhere." But this code does not stop the chatty answer: ai-assistant.yml's route step compares the normalized body against exactly @seidroid review, so @seidroid review owner/name#12 still fails that check and still reaches the conversational assistant. Net effect on the PR page is unchanged from before this branch — a generic reply — with the refusal recorded where nobody looks.

Since the stated goal is that the requester learns their request was declined, the refusal needs a channel they will actually see: a reaction or a short comment on the triggering comment (the workflow already has that pattern in the "Post the verdict" / trigger-reaction steps), or widening the ai-assistant.yml reservation so the refusal lands where the request was written. The PR description flags this as out of scope, which is a fair call — but as merged, the block does not achieve the outcome its own comment claims for it.

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 correct, and the second one names the sharper version: the comment justified the refusal with a benefit it does not deliver. Fixed in the latest commit.

You are right on both mechanisms. An issue_comment run attaches to no pull request, so the annotation reaches whoever opens the run and nobody else. And ai-assistant.yml reserves the body that normalises to exactly @seidroid review, so the target form was never reserved and still reaches the conversational assistant.

I kept the refusal and rewrote the justification rather than escalating it, because both of those match ai-review: there the target form was never a review command either, so an unreserved body reaching the assistant is the incumbent's behaviour, not a regression this branch introduces. Parity is the standard for this work, so matching it is the answer rather than exceeding it.

What the line now claims is only what it does: it names the reason in the one place a person debugging "why did my request do nothing" will look. If we later want the refusal to reach the requester, the change is to ai-assistant.yml's reservation, not here — and that is a behaviour change past this ticket.

The justification claimed the refusal stops a chatty answer from the assistant.
It does not: ai-assistant.yml reserves the bare command alone, so the target
form reaches it either way. It also implied an audience the annotation does not
have, because an issue_comment run attaches to no pull request.

Both match ai-review, where the target form was never a review command. The
refusal earns its line by naming the reason where a person debugging the silence
will look, and the comment says that now rather than a benefit it does not
deliver.

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.

Clean, complete removal of the cross-repository target grammar from seidroid-review.yml: all 3 guard outputs, the parse block, the dead REPO_OWNER env, the now-tautological placement condition and every consumer expression are gone, workflow_call inputs are untouched so no caller breaks, and both App-token mints keep repositories: alongside owner: so the token scope is not widened. No blocking issues; two minor notes.

Findings: 0 blocking | 2 non-blocking | 1 posted inline

Blockers

  • None at the file/PR level.

Non-blocking

  • The refusal for a repository-targeted request is a ::notice:: on an issue_comment run, which is only visible to someone who opens the Actions run. Meanwhile ai-assistant.yml still answers that body conversationally (its reservation is the exact @seidroid review alone), so the person who typed the withdrawn form sees a normal assistant reply and no indication the target was refused. The PR body acknowledges this tradeoff; worth revisiting if users start reporting "my review request did nothing", e.g. by teaching the assistant prompt about the withdrawn form so the reply the user actually sees says so.
  • 1 suggestion(s)/nit(s) flagged inline on specific lines.

# reads the SIGPIPE as a failed pipeline and the refusal is not written.
# Measured: at 232 kB the -q form reports status 141 and stays silent.
named_repo="$(printf '%s\n' "$cmd" \
| grep -m1 -E '^[[:space:]]*@?seidroid[[:space:]]+review([[:space:]]+close)?[[:space:]]+[A-Za-z0-9][A-Za-z0-9._-]*/[A-Za-z0-9][A-Za-z0-9._-]*#[0-9]+[[:space:]]*$' || 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.

[nit] The command prefix ^[[:space:]]*@?seidroid[[:space:]]+review([[:space:]]+close)? is now written out three times in this step (the cmdline= grep at 347, this refusal grep, and the close-detection grep at 391). The removed target_re variable existed to avoid exactly this; hoisting the shared prefix into a local (e.g. cmd_re='^[[:space:]]*@?seidroid[[:space:]]+review') would keep the refusal from silently diverging from the grammar it is supposed to mirror when the trigger word or an option is next changed. The comment above already promises they are "matched on the same whole-line anchor" — a shared variable is what makes that structurally true rather than true by inspection.

The SIGPIPE fix itself is right: grep -m1 … || true inside a command substitution with a -z/-n test matches the cmdline= idiom above and is not exposed to pipefail propagating 141.

@bdchatham
bdchatham merged commit bf507f3 into feat/seidroid-review Sep 6, 2026
16 of 20 checks passed
@bdchatham
bdchatham deleted the fix/drop-cross-repo-target branch September 6, 2026 19:01
bdchatham added a commit that referenced this pull request Sep 6, 2026
… reaches no verdict (#85)

Closes PLT-1143 (workflow half). The driver half shipped in
sei-protocol/sei-internal-skills#405 and releases as `v0.14.0`.

## Problem

A review that reaches no verdict publishes nothing. The pull request
carries no comment, no check run, and no reaction that says anything
happened. A reviewer sees a red job and an untouched pull request, and
cannot tell a review that ran and could not be read from one that never
ran. Only one of the two is a reason to look.

## What changes

**The check run publishes whichever check the driver wrote.** The step
dropped its `verdict_produced` gate; the file's presence is the gate
instead. A no-verdict run writes a check under the title `no verdict`
concluding `failure`, carrying the reason and no `counts` key. A run
that never reached the driver still publishes nothing.

Read the **title**, not the conclusion, to tell the two apart — a
decided review carrying blockers concludes `failure` too.

**A new step reports the run in a comment.** It is the exact complement
of `Answer the request`, on one output: `== 'true'` there, `!= 'true'`
here. So a review run that was not cancelled posts one comment or the
other, never both and never neither — including a run that died before
the driver, where `verdict_produced` is unset and this is the only
record left.

The comment quotes the check run's own summary, so the two cannot
disagree. When the run stopped before the review started there is no
reason to quote, and the body says that rather than implying the review
ran.

**The position step keeps its gate.** `verdict_produced` is load-bearing
there twice over: the withdrawal at the end reads the driver's finding
counts, the no-verdict check carries none, and the no-counts branch
proceeds by design. An ungated no-verdict run would clear a standing
block on the strength of a review that produced nothing. The check run
and the comment publish without the gate; anything that clears a merge
gate keeps it.

## Parity

`ai-review.yml` answers the same case the same way, and forces `failure`
on unreadable output for the same reason.

## Requires

Driver `v0.14.0`. On an older pin the check file is absent on a
no-verdict run, the publish step self-gates on `[ ! -s "$CHECK" ]`, and
the comment step still reports — so this degrades rather than breaks.

## Verification

```
python3 -c "import yaml; yaml.safe_load(...)"   parses
actionlint base   4 [shellcheck]
actionlint head   4 [shellcheck]   unchanged
grep review_repo\|review_pr        0    (the cross-repo grammar #83 removed stays removed)
```

Not verified from here: whether the new step's condition fires as
written in GitHub's runner. That needs a live no-verdict run, which the
next `@seidroid review` after the pin bump will produce.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
bdchatham added a commit that referenced this pull request Sep 6, 2026
…the dismissal (#88)

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](https://claude.com/claude-code)

---------

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