fix(seidroid-review): withdraw the reactions on a cancelled run, from a step that cannot post - #100
Conversation
PR SummaryMedium Risk Overview
Workflow comments document why CI: Reviewed by Cursor Bugbot for commit 0d925ab. Bugbot is set up for automated code reviews on this repo. Configure here. |
There was a problem hiding this comment.
The fix correctly identifies a real defect (a superseded run abandoning 👀 on its own trigger comment) and always() does compose correctly inside the larger &&, but the reasoning that always() only admits a run with no check file is wrong: a cancellation arriving after drive completes leaves the verdict outputs populated, so this step can post a 👍/👎 while every publishing step skips. Gating the conclusion read on cancelled() preserves the fix without that outcome.
Findings: 1 blocking | 1 non-blocking | 1 posted inline
Blockers
- None at the file/PR level.
- 1 blocking issue(s) flagged inline on specific lines.
Non-blocking
- The workflow's header block (around line 61) still says the surfacing steps use
!cancelled()"rather thansuccess()... while still skipping a run superseded by a newer trigger." That is now false for one of them. A half-line there pointing atAnswer the requestas the documented exception would keep the file's own map accurate.
| # replays the trigger comment id, so a run reaching no verdict still has to clear | ||
| # the eyes, and a thumb an earlier attempt left there. | ||
| if: ${{ inputs.mode == 'review' && !cancelled() | ||
| if: ${{ inputs.mode == 'review' && always() |
There was a problem hiding this comment.
[blocker] always() admits more than the no-check-file case, so this can post a verdict reaction for a review that published nothing.
The justification above ("What always() admits is a run with no check file") assumes cancellation always kills drive before it sets its outputs. Cancellation timing is arbitrary. drive ends at line ~1599; Place findings on the code (1601) is the next step and is the slow one — it can spend a while posting inline comments. A cancellation arriving anywhere from there onward leaves steps.drive.outputs.check_path populated and verdict_produced == 'true', because step outputs persist once the step completes.
In that window every publisher skips on !cancelled() — Place findings (1611), Publish the review check run (1733), State the review's position (1854), Post the verdict (2215), Resolve the threads (2530) — while this step now runs and takes the failure/success arm at 2149-2150. The trigger comment ends up wearing 👎 or 👍 with no verdict comment, no check run, and no findings on the diff. A 👎 there is precisely the outcome the VERDICT_PRODUCED gate and the comment at 2119-2124 exist to prevent: a thumb down on a review nobody can read. Worse, if the superseding run then concludes success, the two comments carry contradictory thumbs.
The withdrawal this PR is after does not need the conclusion at all — it needs the missing-conclusion arm. Routing a cancelled run there unconditionally keeps the fix and closes the gap:
env:
# A cancelled run publishes nothing, so it has no conclusion to react
# to however far `drive` got. It still owes the withdrawal.
CANCELLED: ${{ cancelled() }}and in the script:
conclusion=""
if [ "${CANCELLED:-}" != "true" ] && [ -s "$CHECK" ] && [ "${VERDICT_PRODUCED:-}" = "true" ]; thenThat leaves the cancelled run doing exactly what the comment at 2090-2095 describes — clear all three reactions, post no thumb — for every cancellation, not just the early ones.
There was a problem hiding this comment.
Correct, and fixed in df981aa. Answer the request keeps !cancelled(); the withdrawal is now its own step, last in the job, under cancelled(), reading no check file and no conclusion.
The suggested env: CANCELLED: ${{ cancelled() }} cannot ship. cancelled() is readable only in a step or job if — PipelineTemplateEvaluator.EvaluateStepEnvironment builds its context without the expression state CancelledFunction asserts on, so the step fails before its condition runs. actionlint refuses it too: "calling function cancelled is not allowed here."
Last in the job, not beside this step: a condition is evaluated when its step is reached, so a clear placed here is already skipped by the time a cancellation lands on Post the verdict. From the end it also withdraws a thumb this step had just posted.
|
Non-blocking finding — the header map: fixed in df981aa, naming a different exception than you expected.
Blocking finding — replied in thread. You were right, and the suggested Regression cover, since an uncommitted harness is how this survived. Nothing ran on a GitHub runner. |
There was a problem hiding this comment.
Adds a final cancelled()-gated step that withdraws this bot's reactions from the trigger comment of a superseded review run, plus a shell harness and a step-condition model as tests. The mechanism is sound and well tested; two narrower points are worth addressing — the unconditional thumb withdrawal can erase a reaction for a verdict that was actually published, and the new invariant check only inspects step env.
Findings: 0 blocking | 4 non-blocking | 2 posted inline
Blockers
- None at the file/PR level.
Non-blocking
- The PR description documents a different fix than the one implemented: it presents
if: ${{ inputs.mode == 'review' && always() && needs.guard.outputs.comment_id != '' }}onAnswer the requestand argues at length whyalways()is safe there, while the code instead adds a separate last step gated oncancelled()and its in-file comments argue thatalways()onAnswer the requestwould be wrong. Worth updating the body so the rationale on record matches the change. - The list-and-delete block (hardcoded
me="github-actions[bot]", paginated--jqlist, delete loop, warning strings) is now duplicated verbatim betweenAnswer the requestand the new withdrawal step. The two must stay in sync if the reacting identity or the reaction set ever changes; a shared snippet or a small script undertest/-adjacent tooling would remove one copy. - 2 suggestion(s)/nit(s) flagged inline on specific lines.
| # step here chooses belongs to whatever put it there. | ||
| while read -r content rid; do | ||
| [ -n "$rid" ] || continue | ||
| case " +1 -1 eyes " in *" $content "*) ;; *) continue ;; esac |
There was a problem hiding this comment.
[suggestion] Withdrawing +1/-1 unconditionally can strip a thumb that correctly answers a published verdict.
Answer the request (line 2338) and Post the verdict (line 2465) both run before Resolve the threads this review closed (line 2746) and Report a review that reached no verdict (line 2960). A cancellation arriving after Post the verdict has already upserted the verdict comment and published the check run — i.e. during thread resolution — still routes here, and this loop deletes the 👍/👎 that Answer the request just posted. The trigger comment then carries no reaction at all even though the review is fully on the pull request, which reads as "never answered" — the same failure mode this PR sets out to fix, in a different window.
The comment at lines 3101-3102 ("a cancellation arriving during or after Answer the request heals rather than leaving a verdict nobody published") only holds when the verdict was in fact not published.
Two ways out, both local: give Post the verdict an id: and pass steps.<id>.outcome in this step's env so the thumb is kept when the verdict landed, or narrow this case to eyes only — a cancellation before Answer the request leaves no thumb from this run anyway, and a thumb from an earlier attempt on a replayed comment id corresponds to a verdict that was published. Either way, reactions.sh would want a case for "cancelled after the verdict was posted".
There was a problem hiding this comment.
Right, and fixed in 1ba2010. Post the verdict gains id: verdict, and the condition now also names steps.verdict.outcome != 'success'.
The step still cannot state an outcome, and the reason is stronger than an absent input: it contains no POST, so no code path in it adds a reaction whatever it is told. The gained fact is four words about another step — success, failure, cancelled, skipped — none of which names a conclusion. It says whether an answer already stands, never which answer. verdict_produced would not do: that is true whenever the driver reached a verdict, including when nothing published.
Anything but success withdraws, so an outcome the step cannot read clears rather than leaving a thumb standing for a verdict that may not be there.
New case cancelled-after-publish (success>cancelled, verdict success): the answer step posts the thumb, the withdrawal is skipped, the thumb survives. Dropping the term fails it plus three more; inverting it fails 16. Two more mutations now caught: deleting id: verdict, and moving the withdrawal ahead of Post the verdict — both silently empty the read forever.
| # unmodelled. | ||
| runs = verdict is not False | ||
| env = " ".join(str(v) for v in (step.get("env") or {}).values()) | ||
| reads = [k for k in CONCLUSION_INPUTS if k in env] |
There was a problem hiding this comment.
[suggestion] The invariant sweep only inspects step.get("env"), so it misses a step that interpolates ${{ steps.drive.outputs.check_path }} (or verdict_produced) directly into run:, with:, or its own if:. The README claims the check is "stated over the file as well as over a table -- so a step added later is covered"; a later step using inline interpolation would pass silently, which is exactly the case the invariant exists to catch.
Cheap fix: build the haystack from the whole step, e.g. haystack = yaml.safe_dump(step) (or join env, run, with, and if) before the CONCLUSION_INPUTS membership test.
There was a problem hiding this comment.
Correct, and fixed in 1ba2010. The haystack is now yaml.safe_dump(step), so run:, with:, if: and env: are all covered.
Proved with the exact case you name — echo "check was ${{ steps.drive.outputs.check_path }}" added to the withdrawal step's run:, touching no env: key:
env-only haystack contains check_path: False
whole-step haystack contains check_path: True
FAIL Withdraw the reactions on a cancelled run runs on a cancelled run and reads check_path
The README claim now holds. A second file-level check went in beside it: every steps.<id> a reaction condition reads must be a real id on an earlier step, because deleting the id or reordering the step empties the read forever with no error.
Superseded: latest AI review found no blocking issues.
|
All three findings fixed in 1ba2010. Both inline ones answered in thread. On the body note. The Also recorded, per the non-blocking note: a section on the cost. The list-and-delete block is duplicated between the two steps, so Harnesses: On Nothing ran on a GitHub runner. |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 1ba2010. Configure here.
There was a problem hiding this comment.
The new Withdraw the reactions on a cancelled run step is correctly placed last, correctly gated on cancelled() + steps.verdict.outcome != 'success', uses the same github.token identity (github-actions[bot]) as the steps that post the reactions, and reads nothing that could let it state an outcome — the core design holds up. The two new harnesses (reactions.sh, conditions.py) are wired into CI and meaningfully cover the behaviour; only minor gaps and a stale comment remain.
Findings: 0 blocking | 4 non-blocking | 3 posted inline
Blockers
- None at the file/PR level.
Non-blocking
- Note: I could not execute
test/seidroid-review/reactions.shorconditions.pyin this environment (sandbox deniedpython3), so the harnesses were reviewed statically rather than run. The condition model, theghstub's argument parsing, and each case's expected reaction set line up with the real step scripts on inspection. - 3 suggestion(s)/nit(s) flagged inline on specific lines.
| # a conclusion. It says whether an answer already stands, never which answer it | ||
| # would be, so reading it cannot give this step an outcome to state. | ||
| # | ||
| # Anything but success withdraws. An outcome this step cannot read therefore |
There was a problem hiding this comment.
[suggestion] steps.verdict.outcome only speaks for this run, but the reactions on the trigger comment can outlive a run. Reachable sequence: run 1 on comment A completes and leaves +1 on A plus a verdict comment on the PR; someone re-runs the workflow (the comments elsewhere note a re-run replays the same trigger comment id); a newer @seidroid review (comment B) cancels the re-run before it reaches Post the verdict, so steps.verdict.outcome is skipped/empty; this step then deletes run 1's +1 along with the eyes. Comment A ends bare even though the verdict it asked for is still upserted on the pull request — exactly the "reads as never answered" failure the != 'success' gate was added to prevent, one run earlier.
Narrow, and arguably an acceptable trade for the "clear on uncertainty" rule this step is built on — but the surrounding comment states the invariant unconditionally ("a thumb that answers a verdict already on the pull request survives a later cancellation"), which is only true within a single run. Worth either narrowing the withdrawal to eyes when this run posted no thumb, or recording the cross-run limit in the comment so the next reader does not rely on the stronger claim.
There was a problem hiding this comment.
Fixed in 6e7c8c9, and I narrowed rather than documented. Answer the request gains id: answer, and the withdrawal reads its outcome to decide what this run may take:
steps.answer.outcome |
withdrawn |
|---|---|
skipped — never touched the comment, so a thumb there is an earlier run's |
eyes only |
success — took the stale thumb, posted its own, published nothing |
+1 -1 eyes |
failure, cancelled, unreadable |
+1 -1 eyes |
Narrowed because the fix is cheap, keeps the structural property (still no POST; an outcome is four words with no conclusion in it), and a cancellation lands during the driver far more often than after step 13 — so the sequence you describe is the common one, not the rare one.
One case is left over, and the comment now states it instead of claiming the invariant. A run that answers — withdrawing an earlier thumb and posting its own — then cancelled before publishing leaves the comment bare. The answer step already took the earlier thumb, so nothing at the end of the job can restore it, and knowing it happened needs a read of the pull request this step deliberately does not make.
And one tension I am flagging rather than reading loosely. "Given a run cancelled by a newer request, its trigger comment carries no reaction from this bot" now fails on one path: a cancelled re-run leaves an earlier run's thumb. The intent holds — the comment is not wearing 👀 with no answer coming, because the answer is on the pull request. Satisfying the literal wording restores this defect.
Cases: rerun-cancelled-before-answer (earlier thumb survives), rerun-cancelled-human, rerun-answered-then-cancelled, and the failure/cancelled/unreported arms. Mutation: the skipped arm taking all three fails EARLIER THUMB SURVIVES plus three more.
| return select(sys.argv[2:]) | ||
| with open(sys.argv[1], encoding="utf-8") as handle: | ||
| doc = yaml.safe_load(handle) | ||
| steps = {s["name"]: s for s in doc["jobs"]["review"]["steps"] if "name" in s} |
There was a problem hiding this comment.
[suggestion] steps drops any step without a name, so the sweep below — the one advertised in the README as "stated over the file rather than over a table, so a step added later is covered" — silently skips unnamed steps. Every step in the review job is named today, so nothing is missed now; but a later - uses: actions/checkout@v7 with no name: (the usual shape) would be invisible to the check_path/verdict_produced invariant that is the whole point of the check. Keying the sweep off the raw doc["jobs"]["review"]["steps"] list (falling back to uses/index for the label) keeps the guarantee the README claims.
There was a problem hiding this comment.
Fixed in 6e7c8c9. Both file-level checks now walk every job's raw steps list and search the whole step, so neither needs telling where to look.
Your prediction was already true, not just hypothetical: the guard job carries an unnamed step today. My sweep only covered review, so nothing was missed — but it now covers both jobs.
Proved with the shape you name:
raw steps: 18 reachable by name: 17 invisible: 1
FAIL step 17 runs on a cancelled run and reads verdict_produced
The label falls back to uses and then to the index.
On your broader point — I found two more. The id check reached only if, so the withdrawal's new env read of steps.answer.outcome would have gone unchecked; it now searches the whole step too. And run_case exported each per-case knob without clearing it, so an ANSWERED_AS override leaked forward and silently disarmed a later case — my own harness caught that one only because the leak made a case fail. Knobs are unset at the top of every case now.
That is five checks narrower than their own claim on this PR. The pattern is the same each time: the check named a property but sampled one key, one name, or one argument. Each fix reads the file instead.
|
|
||
| import yaml | ||
|
|
||
| # step -> job state -> does it run? mode is 'review' with a comment id unless said. |
There was a problem hiding this comment.
[nit] Stale duplicate: lines 31-36 are an earlier draft of the block at 37-46, and their key description is now wrong — EXPECTED's keys are 4-tuples (state, mode, comment id, verdict outcome), not job state. Deleting 31-36 leaves the correct header.
There was a problem hiding this comment.
Deleted in 6e7c8c9. You were right that the key description was wrong — EXPECTED's keys were already 4-tuples, and they are now (job state, mode, comment id, posted).
dc7ea1c to
2ffa24a
Compare
|
All four findings fixed. Finding 1 — narrowed, not documented. Finding 2 — and two more of the same class. Both file-level checks now walk every job's raw steps list and search the whole step. Your prediction was already live: the Finding 3 — deleted. Finding 4 — Bugbot, and it was right. Five checks on this PR ran narrower than their own claim —
actionlint identical base vs branch (37 whole-dir, 4× Base moved twice mid-review and neither move was trusted: harnesses and actionlint re-ran on each new history, and I checked the rebase by reading back the step ids, the gate and the withdrawal step's POST count rather than its exit status. Nothing ran on a GitHub runner. |
Two `@seidroid review` comments in quick succession cancel the first run. The answering step took `!cancelled()`, so the concurrency group skipped it, and the acknowledgement 👀 was already on the older comment. The newer run answers its own comment id, so nothing ever read the older one again and it wore the eyes for good. `always()` on that step, which is safe because the arm it admits is the one that already handles an absent conclusion: a cancelled run reaches the step with no check file, and that arm withdraws all three of this bot's reactions and posts no thumb. It spends four API calls, takes no position on the pull request, and starts none of the work a cancellation stops. `issues: write` stands. PLT-1159 asked to drop it and react through GraphQL `addReaction` under `pull-requests: write`. The incumbent evidence does not support that: ai-review's reactions are posted by the App installation token, not by GITHUB_TOKEN, so its `permissions:` block never governed them. GitHub publishes no permission data for any GraphQL mutation. The permission comment now records both facts. PLT-1166 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three steps in the review job carry no `if:` at all, so the runner wraps them as `success()`. Naming `!cancelled()` as what every other step takes was wrong. All fifteen skip a cancelled run, whichever route they take there, and that is the fact the reader needs. PLT-1166 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…n cannot post from
A cancellation arriving after `drive` finishes leaves check_path and
verdict_produced populated. `always()` on `Answer the request` therefore read a
real conclusion and thumbed the request, while `Post the verdict` skipped and the
verdict never reached the pull request. A thumb reads as an answer, so that is
worse than the stale eyes it was meant to fix.
`Answer the request` keeps `!cancelled()`. The withdrawal moves to a step of its
own, last in the job, under `cancelled()`. That step reads no check file, no
verdict_produced and no conclusion, so no value a cancellation leaves populated
can make it state an outcome.
Last in the job rather than beside the step above, because a condition is
evaluated when its step is reached: a clear placed earlier is already skipped by
the time a cancellation lands on a later step. From the end it also withdraws a
thumb `Answer the request` had just posted, so a cancellation arriving during or
after that step heals.
The named fix -- gate the conclusion read on `cancelled()` inside the script --
is not available. `cancelled()` is readable only in a step or job `if`;
PipelineTemplateEvaluator.EvaluateStepEnvironment creates its context without
the expression state CancelledFunction asserts on, so `env: X: ${{ cancelled() }}`
fails the step before its condition runs, and actionlint rejects the workflow.
Harness: reactions.sh runs all three reaction steps against a gh stub, 38
assertions over 22 cases, including a cancelled run with the verdict outputs
populated. conditions.py covers what a shell harness cannot see, 33 assertions,
and states the invariant over the file as well as a table -- no step reading
check_path or verdict_produced may run on a cancelled job.
PLT-1166
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A cancellation can arrive once `Post the verdict` has upserted the comment and
published the check -- during thread resolution, say. The withdrawal step then
deleted the thumb `Answer the request` had posted, leaving a fully published
review with no reaction on the request that asked for it. That reads as never
answered: this step's own defect, one window later.
The step's condition now also names `steps.verdict.outcome != 'success'`. It
still cannot state an outcome, and the reason is stronger than an absent input:
the step contains no POST, so no code path in it adds a reaction whatever it is
told. The one fact it gains is the posting step's outcome -- success, failure,
cancelled or skipped -- and none of those four words names a conclusion. It says
whether an answer already stands, never which answer it would be.
Anything but success withdraws, so an outcome the step cannot read clears rather
than leaving a thumb that stands for a verdict which may not be there.
The invariant sweep now reads the whole step rather than its `env` block. An
inline ${{ steps.drive.outputs.check_path }} in `run:`, `with:` or `if:` reaches
the same value and passed unseen before.
Two harness gaps closed. Each reaction case now derives which step runs from
`conditions.py --select` instead of naming it, so the shell layer and the
condition layer cannot drift. And a case declares the moment the cancellation
arrived, as two job states, so a late-cancellation case proves its thumb through
the answer step rather than seeding one by hand.
`conditions.py` also checks that every `steps.<id>` a reaction condition reads is
a real id on an earlier step. Deleting the id, or moving the withdrawal ahead of
`Post the verdict`, otherwise empties the read forever with no error.
reactions.sh: 49 assertions over 26 cases. conditions.py: 39 assertions.
PLT-1166
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…still stands A re-run replays the trigger comment id, so the comment can already carry a thumb from an earlier run whose verdict IS on the pull request. A re-run cancelled before it answered took that thumb along with its own eyes, leaving the comment bare while the verdict it asked for still stood. That is the "reads as never answered" failure, one run earlier. `Answer the request` gains `id: answer`, and the withdrawal reads its outcome to decide what this run may take. skipped is the only value meaning "never touched the comment", and there the withdrawal takes the eyes alone. Once the answer step has run, every reaction on the comment is this run's own, and this run published nothing, so all three go. failure, cancelled and an unreadable value all clear: a thumb standing for a verdict nobody published is the worse of the two wrongs. The structural property holds. The step still has no POST, and an outcome is four words about another step with no conclusion among them. One case is left over and the comment now records it rather than claiming the invariant outright. A run that answers, withdrawing an earlier thumb and posting its own, and is then cancelled before publishing, leaves the comment bare. The earlier thumb is already gone by then, so nothing here can restore it. Both file-level checks now walk every job's RAW steps list and search the whole step. Keyed off a name they dropped an unnamed step, and `- uses: ...` with no `name:` is the usual shape -- the step most likely to arrive later was the one they could not see. The id check reached only `if`, so the withdrawal's new `env` read would have gone unchecked. Also removes a stale duplicate of the EXPECTED header whose key description no longer matched, and clears each per-case harness knob at the top of run_case: an ANSWERED_AS override leaked into every later case and silently disarmed one. reactions.sh: 60 assertions over 32 cases. conditions.py: 77 assertions. PLT-1166 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ed, not on an exit code `steps.verdict.outcome` cannot carry the fact the gate needs. `Post the verdict` tolerates a refused comment POST and ends on a call whose failure it swallows, so it exits 0 and its outcome reads `success` whether the verdict landed or not. A later cancellation then skipped the withdrawal and left the thumb `Answer the request` posted, standing for a review nobody can see. That step already tracks the POST's result in a shell variable. It now writes it as a `posted` output, and the withdrawal reads `steps.verdict.outputs.posted != 'true'`. One boolean about another step, written from the POST's own result, with no conclusion in it -- so the structural property holds: the withdrawal step still has no POST. `conditions.py` models `steps.verdict.outcome` beside `posted`, derived rather than passed: `success` whenever that step reported at all, which is what the runner sees. A condition that goes back to reading the outcome therefore fails an assertion instead of crashing the model. New case `cancelled-publish-failed`: the answer step posts its thumb, the POST of the verdict is refused, and the withdrawal takes the thumb. Reverting the gate to the outcome fails it, and fails the matching condition row. Reported by Cursor Bugbot on 1ba2010. PLT-1166 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
The fix is sound: the withdrawal step is genuinely last in the job, reads only two non-conclusion booleans (steps.verdict.outputs.posted, steps.answer.outcome), contains no POST path, and Post the verdict writes its posted output on both branches before its early exit 0. Two non-blocking notes, both about the new test harnesses rather than the workflow behaviour.
Findings: 0 blocking | 3 non-blocking | 2 posted inline
Blockers
- None at the file/PR level.
Non-blocking
bin-reactions/ghandbin/ghare now two independentghstubs with overlapping surface (-X,--jq,--paginate,-f). Not worth unifying today, but a third harness would be the point to factor out the argument parser.- 2 suggestion(s)/nit(s) flagged inline on specific lines.
| TRIGGER_ID: ${{ needs.guard.outputs.comment_id }} | ||
| # Whether this run ever reached the step that answers. See the withdrawal set | ||
| # below. Four words about another step; none of them a conclusion. | ||
| ANSWERED: ${{ steps.answer.outcome }} |
There was a problem hiding this comment.
[suggestion] This env: key is the single link between the two steps, and neither new harness asserts it exists.
extract.py copies only a step's run: block, and reactions.sh exports ANSWERED itself (line 127). conditions.py's steps.<id> check searches the dumped step for steps.answer., so it catches a dangling reference — but not a renamed or deleted key.
Concretely: rename this to ANSWER_OUTCOME and leave ${ANSWERED:-} in the run: block. steps.answer. is still present under the new key, so conditions.py passes; reactions.sh exports ANSWERED itself, so all cases pass. In production ${ANSWERED:-} is empty, which falls to the * arm and takes +1 -1 eyes — exactly the case rerun-cancelled-before-answer exists to prevent, silently stripping an earlier run's thumb whose verdict is on the pull request.
The same holds for Answer the request's CHECK / VERDICT_PRODUCED. Cheapest fix: assert the env mapping in conditions.py (e.g. that the withdrawal step's env.ANSWERED is ${{ steps.answer.outcome }}), or have extract.py emit the step's static env keys so the harness inherits the names rather than restating them.
| for ref in sorted(set(re.findall(r"steps\.([A-Za-z0-9_-]+)\.", dump(st)))): | ||
| if ref not in ids: | ||
| check(f"{where} reads steps.{ref}, which is no step's id", True, False) | ||
| elif ids[ref] > i: |
There was a problem hiding this comment.
[nit] ids[ref] > i lets a step read its own id pass. A step can never usefully read steps.<its own id> from any key — if:, env: and with: are all evaluated before the step runs, and run: sees the value the step has not written yet — so the read is empty in every case, which is the same silent failure the check exists to catch. ids[ref] >= i covers it.
The withdrawal being last is load-bearing, and nothing checked it. The runner evaluates a condition when it reaches the step, so any step placed after the withdrawal is a step during which a cancellation leaves the eyes standing: the withdrawal was already evaluated and skipped by then. `conditions.py` now checks the position rather than any one ordering. Moving the step ahead of `Post the verdict`, ahead of the resolve step, ahead of the no-verdict report, or appending a step after it all fail. The id-ordering check caught only the first of those, because `Post the verdict` still ran earlier in the other three. The README said "Two checks" where there are three, and described the withdrawal gate as `Post the verdict` having "completed" rather than having landed its comment. Both now match what the file does. PLT-1166 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2ffa24a to
0d925ab
Compare
|
Rebased on I closed the ordering class instead of adding the mutation.
The id-ordering check passes the middle two, because One conflict, in the harness README, resolved keeping both sides.
actionlint identical base vs branch (37 whole-dir, 4× For #103. It was written against two reaction steps; this branch leaves three, with six hardcoded Nothing ran on a GitHub runner. Third base move, third full re-run rather than a carried result. |
There was a problem hiding this comment.
The withdrawal step, the new answer/verdict step ids and the two harnesses are coherent, and the reaction logic reads correctly across the cancellation windows the description enumerates. Two non-blocking issues: a backtick command substitution in the new shell harness, and no test coverage for the posted output the whole gate depends on.
Findings: 0 blocking | 4 non-blocking | 2 posted inline
Blockers
- None at the file/PR level.
Non-blocking
- The "cancelled mid-publish" row of the PR's table says the withdrawal takes a thumb that "would stand for nothing". That is not guaranteed:
Post the verdictwritesposted=only after the comment POST returns, so a cancellation that kills the step between a successful POST and that line leavespostedempty and the withdrawal strips a thumb that does answer a published verdict. It is a strictly narrower instance of the gap already documented for the answer step, but the table currently reads as if the row is safe rather than best-effort. run_caseintest/seidroid-review/reactions.shaccepts aSKIP_ACKknob that no case ever sets, so the acknowledgement always runs. Either exercise it or drop it.- 2 suggestion(s)/nit(s) flagged inline on specific lines.
| # failure path below ends on a call whose failure it swallows, so the step | ||
| # exits 0 whether the comment landed or not, and its outcome reads success | ||
| # either way. | ||
| echo "posted=$posted" >> "$GITHUB_OUTPUT" |
There was a problem hiding this comment.
[suggestion] This output is the single fact the whole withdrawal gate rests on, and nothing tests that it is produced. reactions.sh never runs Post the verdict — it injects posted as a fixture argument to run_case — and conditions.py models the value in Ctx.values. So both harnesses stay green if this line is deleted, moved above the POST, or made unreachable by an early exit added higher in the script; the failure would only show up as a silently withdrawn thumb on a live run.
Given that the mutation table deliberately covers id: verdict being deleted and the gate regressing to steps.verdict.outcome, the producing side deserves the same treatment. Either extract this step into reactions.sh against the gh stub and assert posted=true / posted=false land in $GITHUB_OUTPUT on both POST outcomes, or add a conditions.py check that the step whose id the withdrawal reads writes posted= to $GITHUB_OUTPUT at all.
| check "withdraw ran" 1 "$(ran 'Withdraw the reactions on a cancelled run')" | ||
| check "left" "" "$(left)" | ||
|
|
||
| echo "== THE VERDICT COMMENT WAS REFUSED. `Post the verdict` tolerates that and exits 0," |
There was a problem hiding this comment.
[nit] The backticks are inside double quotes, so bash treats them as command substitution: it executes Post with args the verdict, prints Post: command not found to stderr, and substitutes empty — the banner comes out as == THE VERDICT COMMENT WAS REFUSED. tolerates that and exits 0,. set -e is not on, so the harness still passes, which is why it slipped through; shellcheck's SC2006 is style, below the -S warning threshold you ran at.
Use single quotes for this banner, or escape the backticks. The other multi-line banners in this file (e.g. lines 156-157, 187-188) avoid backticks and are fine.
…let a caller name it (#103) Four tickets, one region: the guard's job condition, its `parse` and `Admit the request` steps, and the two `workflow_call` inputs they read. **PLT-1147 — accept `pull_request_review_comment` and `pull_request_review`.** Both events are admitted. Every read in `parse` and `Admit the request` takes the comment key or the review key, whichever the event populated: a review body names its author under `review.user` and its id under `review.id`, and a step reading `comment.*` alone saw an empty body there and refused in silence. The two diff-side events are held to their creating action, because a `dismissed` review replays the body of the review it dismisses — a caller wiring that type would re-review on every dismissal. **PLT-1153 — restore `allowed-bots`.** A JSON array of exact logins, default `[]`. Checked in the guard's job condition, so an unlisted bot starts no runner, and again in `Admit the request`. Exact and case-insensitive both times, as ai-review.yml checks it. A listed bot skips the team read — a bot is not a team member — and is held to the fork check, the skip label and the command grammar. The job condition now admits a person on `author_association` and a bot only by login, because association does not discriminate a bot: one with write access carries MEMBER like anyone else. **PLT-1161 — refuse an unsupported event.** A first step names the event that arrived and the four this workflow handles, and exits 1. It runs before the identity mint and before the secret check, so a mis-wired caller spends no credential. The guard's condition gained a clause admitting an unsupported event for exactly that step: without it the job is skipped, every job after it is skipped, and the run reports success having done nothing. `pull_request` is excluded from that clause — a `pull_request` close skips the guard deliberately, and the review job reads that skip as its own trigger. `pull_request_target` is refused apart, with its reason: it runs with the base repository's secrets and a writable token over a head this workflow did not check out. Nothing here checks anything out today; the refusal is the control that does not depend on that staying true. **PLT-1164 — restore `trigger-phrase`.** Default `@seidroid`, and the pattern is built from it rather than hardcoded — in the command grammar and in the repository-target refusal beside it. ## The two decisions the tickets asked for **The optional `@` stays.** A person who types the phrase without the mention still means it, and the wider form costs nothing here. Whole-line anchoring is what makes it safe, and it is intact. The non-overlap with `ai-assistant.yml` is now measured rather than argued: that workflow's reply condition requires `contains(body, '@seidroid')`, so a bare `seidroid review` reaches this workflow alone. Group 16 of the harness evaluates the assistant's own condition beside the parse for five bodies and records which tool answers each. Two bodies both tools answer today, and both predate this change: `@seidroid review close`, and a body carrying the command on its own line amid prose. The assistant reserves the exact body only, and neither of those is it. Whole-line anchoring is what admits the second — and it is also what keeps `Do we need @seidroid review here?` from starting a review, so the overlap is the price of the property the ticket told me not to lose. The harness asserts the present, so a later change that closes either overlap fails a case and has to re-read it. **The phrase's shape is constrained, not escaped.** After stripping one leading `@`, the phrase must be letters, digits, `_` and `-`. None of those is an ERE metacharacter, so the pattern carries the phrase verbatim with no escaping. Anything else falls back to `@seidroid` with a warning, which is what `guidelines-file` does with a name it cannot trust. Escaping would have to cover every ERE metacharacter correctly forever; a character class is one thing to read. Two harness cases show what the constraint buys: with `@my.bot`, `@myXbot review` does not match; with `@a|b`, the line `a note about the diff` does not match. Unconstrained, the `|` would split the pattern into `^[[:space:]]*@?a` — which every line starting with `a` matches. ## One deliberate step outside the stated region Three reaction steps build their reactions URL from a new guard output, `comment_api`, instead of a hardcoded `issues/comments`: `Acknowledge the trigger` (one path), `Answer the request` (three) and, since #100, `Withdraw the reactions on a cancelled run` (two). Six paths, three `env:` keys. The endpoint differs per event — `issues/comments/{id}/reactions` for a conversation comment, `pulls/comments/{id}/reactions` for a diff-thread one — and without this PLT-1147's acknowledgement would post to a path that holds no object, and the two steps that withdraw it would look for it somewhere else again. That is the trap the ticket names, and it cannot be fixed from inside the guard alone. The review job already holds both scopes: GitHub grants the first to Issues and the second to Pull requests. Two properties of #100 survive the edit, and both are asserted rather than argued. The withdrawal step is still the **last** step of the review job — index 16 of 17, and `conditions.py` checks the position rather than one ordering. And it still contains **zero POSTs**: `-X POST`, `--method POST` and `-f content` each appear 0 times in it, `DELETE` is the only verb it names, and `reactions.sh` asserts the POST count. Adding an `env:` key changes neither. `repos/{owner}/{repo}/issues/comments/{id}` in two other steps is untouched: those delete comments this workflow posted on the conversation, not the trigger. ## One acceptance criterion that REST cannot meet GitHub publishes no reactions endpoint for a pull request **review**. Only the GraphQL schema makes a review reactable, and PLT-1159 already proposed that route and was declined. So a command in a review body starts a review, `comment_api` and `comment_id` both go out empty, both reacting steps skip on their existing condition, and a `::notice::` in the run log says the review started and why no reaction landed. The review, the verdict comment and the inline findings all still arrive. A diff-thread comment gets the full acknowledgement. That is a read claim, not a measured one — see below. ## The review round Seven findings taken. **An unset `allowed-bots` no longer takes the run down.** A `workflow_call` default applies only to an input the caller OMITS, so `allowed-bots: ${{ vars.SOMETHING }}` with that variable unset arrives as `''`, and `fromJSON('')` is not `[]`. The condition reads `fromJSON(inputs.allowed-bots || '[]')`, so empty takes the documented default and denies every bot, while a non-empty non-JSON value still fails loudly. The fix holds under either evaluation order, which turned out to matter — see below. **`gha.py` short-circuits, because the runner does.** Or and And return on the first truthy or falsy operand and never evaluate the rest. My model evaluated eagerly, and one shipped assertion therefore stated the opposite of what a real event does: with a malformed list and a human MEMBER, the person branch is already true, so `fromJSON` is never reached and the guard admits. Re-derived per requester — a person yields `true`, a bot yields `error` (the requester whose admission depends on parsing the list), an automatic review yields `true`. This corrects a claim in my own earlier report, where I had listed eager evaluation as read-not-measured and had it backwards. **Group 16 now measures the overlap on all three events.** `claims()` was keyed to `issue_comment`, so it measured the division of labour on the one path that already had it and inferred the two this branch adds. It takes an event now, and every body runs on all three plus an empty review body. The overlap is identical on all three — measured, not reasoned. 11 assertions to 33. **`ai-assistant.yml` is in `workflow-test-self.yml`'s `paths:`.** Group 16 states an invariant about that file, so an edit there could break it and surface later as a red `Guard the request` on an unrelated change. I audited every file the three harnesses read: it was the only one outside the filter, and `conditions.py` takes its target from the CI command line, which names a watched file. No other cross-file assertion has this shape. **The log id and the reactable id are two facts.** `comment_id` is the reactable object and goes out empty where nothing can react; the driver's `--trigger-id` was reading it, so a review-body dispatch had silently stopped carrying a label. The guard emits `trigger_id` beside it, always populated, and only `Drive session + collect verdict` moved to it — no step condition changed, so `conditions.py`'s context model needed nothing. A new group asserts which of the four outputs each consumer reads. **A comment claimed something false about the payload.** "No comment event carries a head repository" holds for `issue_comment` alone; `pull_request_review_comment` and `pull_request_review` both carry `pull_request.head.repo.id` and `.base.repo.id`. I corrected the sentence rather than widening the branch, because the label check twelve lines below reads the same `GET /repos/{owner}/{repo}/pulls/{n}` endpoint unconditionally on all three comment paths: reading the payload here would drop one of two identical round trips and neither the failure mode nor the dependency. The comment now names the one event that needs the API and records what a payload-keyed branch would have to preserve. The bigger saving is collapsing those two reads of one endpoint into one, available on all four paths — that belongs in a ticket, because it moves the fork check. **The permissions comment explains both scopes**, one per collection, and names what pruning either costs: the reaction fails on the path that scope serves, and all three reacting steps treat a lost reaction as a courtesy and only warn. ## Verification Everything below ran on this machine. Nothing ran on a GitHub runner. `test/seidroid-review/run-guard.sh` is new, beside the placement harness. It reads five steps out of the shipped YAML by name or id, runs them under `bash` against a `gh` stub of its own, and evaluates the two job conditions, the per-event `env:` mappings and the declared input defaults with a new `gha.py`. ``` $ test/seidroid-review/run-guard.sh assertions: 241 passed, 0 failed $ test/seidroid-review/run.sh # placement and resolution, unchanged assertions: 271 passed, 0 failed $ test/seidroid-review/reactions.sh # 62 before, +15 for the collection assertions: 77 passed, 0 failed $ python3 test/seidroid-review/conditions.py .github/workflows/seidroid-review.yml assertions: 77 passed, 0 failed ``` `reactions.sh` needed the change, not just the extra cases. Its `run_case` did not export `COMMENT_API`, so every extracted step died on an unset variable under `set -u` and 25 of its 62 assertions failed with every API count at zero. The default is set there now, and a group varies it: three steps, six paths, and a `pulls/comments` case asserting nothing reached `issues/comments`. `conditions.py` went from 76 to 77 on its own. Two of its checks walk every job's raw steps list, so the refusal step this branch adds to the guard job earns one more assertion without anything being written for it. `gha.py` models four GitHub expression semantics the conditions rest on: case-insensitive string comparison, `||` and `&&` yielding one operand each, **both short-circuiting**, and `contains` over an array testing membership rather than substring. `--selftest` checks all eighteen readings, and group 0 of the run fails if any is wrong. The model is read from GitHub's published semantics; it is not measured against a runner. **Mutation check.** 41 mutations of the shipped workflow, applied one at a time, each killed at least one assertion. **0 alive, 0 skipped.** The sweep runs all four harnesses per mutation, because one edit spans steps three of them cover — a mutation only `reactions.sh` or `conditions.py` can see would have survived a sweep that ran the guard harness alone. Four of the 41 cover this review round: dropping the empty-input fallback, holding `trigger_id` back with the reactable id, and pointing either the driver or the acknowledgement at the other's id. Among them: dropping either new event from the condition, dropping the creating-action gates, reading `allowed-bots` as a string rather than JSON, dropping the `pull_request_target` arm, emitting the id where no endpoint reaches it, dropping the phrase's shape check, hardcoding the phrase in either pattern, dropping the whole-line anchors, requiring the `@`, reading only the `comment.*` payload keys in either step, matching a listed bot by substring or case-sensitively, letting the once-per-PR gate reach a comment, and applying the requester check to a teardown. Six mutations cover the six reaction paths — one in the acknowledgement, three in the answer, two in the withdrawal — and each is killed by at least two assertions. Two more cover #100's properties: appending a step after the withdrawal is killed by `conditions.py`'s position check, and adding a POST to the withdrawal step is killed 21 times by `reactions.sh`. **actionlint, before and after.** Base `2f7efad`, all workflows: ``` 6 [action] 30 [shellcheck] 1 [syntax-check] ``` This branch, all workflows: the identical set, finding for finding — compared as `rule + code`, not just as a count, and identical per file too. `seidroid-review.yml`'s own four are the pre-existing `SC2102:info`. The rest are in `ai-assistant.yml` (3), `ai-review.yml` (4), `release-check.yml` (22) and `release-publish.yml` (4), all untouched. `workflow-test-self.yml` lints clean. Both files parse under PyYAML. ## Rebase note Written against `3544bf5`; rebased three times as the base moved, to `b1b51f8` (#97), `98c2619` (#101, #102) and `2f7efad` (#100). Head is `a31efa6`. The third rebase conflicted in four files: - **`seidroid-review.yml`** — one hunk, in `Acknowledge the trigger`: #100 rewrote the comment above the POST while this branch rewrote the URL below it. Union. - **`workflow-test-self.yml`** — a three-way union. Three jobs now, under distinct names: `Place findings and resolve threads`, `The reaction steps`, `Guard the request`. - **`README.md`** — two hunks; one document with a section per harness. - **`.gitignore`** — union of three extractor lists. One collision the conflict markers did not show: `reactions.sh` and `run-guard.sh` both extract `Acknowledge the trigger` and `Answer the request`, and both wrote them to `ack.sh` and `answer.sh`. Running both would have one overwrite the other's extraction. This branch is the newcomer, so it moved: `guard-ack.sh` and `guard-answer.sh`. The README now says which harness asks what of those two steps. Everything was re-run on the rebased history rather than carried forward: all four harnesses, the full mutation sweep, the actionlint comparison against the new base, and the group counts, recounted from the shipped file (unchanged this time — 204 over the same seventeen groups). **The sweep caught itself.** Its first pass on this base reported 36 killed and **one SKIP**: `M28`, which hardcodes the issue collection in the answering step's read. After this branch routed the withdrawal step through `COMMENT_API`, that step's read became byte-identical to the answering step's, so `M28`'s anchor matched twice and stopped applying. A sweep that only counted kills would have read 36/36 and looked clean. `M28` now carries the comment line above the call, which the two steps do not share, and is killed by four assertions across two harnesses. ## What rests on reading rather than measurement - That GitHub sends `pull_request_review_comment` as `created` and `pull_request_review` as `submitted` for a new request, and that a dismissal replays the dismissed review's body. - That the REST API carries no reactions endpoint for a pull request review. - That `fromJSON` over a non-JSON input fails the expression rather than evaluating false, and that GitHub evaluates both operands of `||`. - Every semantic `gha.py` models. A case here can only be as right as that model. - That a step with an explicit `if:` still requires the steps before it to have succeeded, which is what makes the refusal skip the identity mint. The file's own comments already rest on this. Nothing in this branch has been exercised by a real event on a runner. ## Case table `Guard the request`: 241 assertions — 95 runs of an extracted step script, and 71 call sites evaluating a shipped condition, `env:` mapping or declared input. Recounted from the shipped file. | Group | Assertions | What it holds | |---|---|---| | 0 | 1 | the expression model `gha.py` uses | | 1 | 11 | which requests reach a runner, on all three comment events | | 2 | 14 | `allowed-bots` in the job condition, malformed and unset | | 3 | 5 | the events the workflow does not handle | | 4 | 8 | the review job's condition | | 5 | 17 | the refusal, by event | | 6 | 28 | the parse: which body is a command, and what it resolves to | | 7 | 18 | a caller's own trigger phrase, including regex metacharacters | | 8 | 20 | who may ask, on every comment path | | 9 | 20 | a bot held to `allowed-bots` | | 10 | 17 | fork, label and once-per-PR, on the new paths | | 11 | 11 | draft, first review, re-review and teardown | | 12 | 16 | the payload field each step reads, per event | | 12b | 5 | which guard output each consumer reads | | 13 | 5 | the defaults a caller inherits | | 14 | 5 | the acknowledgement's collection | | 15 | 7 | the answer's collection | | 16 | 33 | what `ai-assistant.yml` claims of the same body, on each event | `The reaction steps`: 77 + 77. `reactions.sh` carries 15 assertions over four cases for the collection each of the three steps reaches; `conditions.py` gains 1 for the guard's new refusal step, which its file-wide sweep picks up on its own. `Place findings and resolve threads`: 271, untouched. PLT-1147 PLT-1153 PLT-1161 PLT-1164 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

A run cancelled by a newer
@seidroid reviewnow clears the reactions it left on its owntrigger comment, from a step that cannot post one. It left the 👀 there for good.
Carries PLT-1166. PLT-1159 comes out ruled out, on the evidence below. Nobody can
do it as written. The permission comment records the reason beside the scope the ticket
asked to drop.
PLT-1166 — the defect
Answer the requesttook!cancelled(). Two@seidroid reviewcomments in quicksuccession put both runs in one concurrency group under
cancel-in-progress, so the newerone cancels the older. By then the older comment already wears the 👀 — the
acknowledgement is the first step of the job. The newer run answers its own comment
id, so nothing ever reads the older one again.
The result: a comment that asked for a review, wears eyes, and never gets an answer. That
is the defect PLT-1144 fixed on the no-verdict path, reached by the one path that fix does
not cover.
What ships
Answer the requestkeeps!cancelled()— unchanged from the base. The withdrawal is anew step, last in the job:
Post the verdictgainsid: verdictand apostedoutput so that last term can readit, and
Answer the requestgainsid: answer. Nothing else in the workflow changes.Why not
always()onAnswer the requestThat was the first shape here and it was wrong. A step output persists once its step
completes, so a cancellation landing any time after
drivefinishes leavescheck_pathand
verdict_producedpopulated.Answer the requestwould read a real conclusion andpost a thumb, while every publisher skips on
!cancelled(). A thumb reads as an answer.That is worse than the stale eyes this PR set out to remove.
Why the new step cannot state an outcome
It contains no POST. The script lists this bot's reactions and deletes the three this
workflow posts. No code path in it adds one. Nothing the step receives can therefore make
it state an outcome. That property holds whatever its inputs are, which is what makes it
structural rather than a matter of what a cancellation happens to look like.
It reads no check file, no
verdict_producedand no conclusion.What the
steps.verdict.outputs.postedterm is, and why it does not break thatA cancellation can arrive once the verdict is already on the pull request — during thread
resolution, say — and the thumb
Answer the requestposted answers it correctly.Withdrawing it there leaves a published review with no reaction on the request that asked
for it, which reads as never answered. That is this step's own defect, one window later.
The posting step's
postedoutput separates the two. It is one boolean about anotherstep, written from the comment POST's own result. No conclusion is in it. It says whether
an answer already stands, never which answer it would be, so reading it gives the step
nothing to state. Handing it
verdict_producedinstead would have restored theconditional reasoning above: that flag is true whenever the driver reached a verdict,
including when nothing published.
Its outcome will not do, and that took a second pass to see.
Post the verdictrunsunder
continue-on-errorand tolerates a refused comment POST. Its failure path ends ona call whose failure it swallows. The step therefore exits 0, and its outcome reads
successwhether the verdict landed or not. The first version of this gate read thatoutcome. A refused POST followed by a cancellation then kept a thumb standing for a
review nobody can see. That step already tracked the POST's result in a shell variable.
It now writes it as an output.
Anything but a posted verdict withdraws. A value the step cannot read therefore clears.
It does not leave a thumb standing for a verdict that may not be on the pull request.
The cross-run half of the same problem
A re-run replays the trigger comment id. The comment can therefore already carry a thumb
from an earlier run whose verdict is on the pull request. A re-run cancelled before it
answered took that thumb along with its own eyes. The comment then ended bare while the
verdict it asked for still stood.
Answer the requestgainsid: answer, and the withdrawal reads its outcome to decidewhat this run may take:
steps.answer.outcomeskippedeyesonlysuccess+1 -1 eyesfailure,cancelled, unreadable+1 -1 eyesThe structural property is untouched: still no POST, and an outcome is still four words
about another step with no conclusion among them.
One case survives, and the comment states it rather than claiming it away. A run
answers, which withdraws an earlier thumb and posts its own. A cancellation then arrives
before publishing, and the comment ends bare. The answer step already took the earlier
thumb, so nothing at the end of the job can put it back. Knowing it happened would need a
read of the pull request this step deliberately does not make. A cancellation lands during
the driver far more often than in that gap. The step's comment records the limit instead
of asserting the invariant outright.
One tension with the stated acceptance criterion, deliberately. "Given a run cancelled
by a newer request, its trigger comment carries no reaction from this bot" now fails on
one path. A cancelled re-run leaves an earlier run's thumb. The criterion's intent holds.
The
comment does not wear 👀 with no answer coming, because the answer is on the pull request.
Satisfying the literal wording would restore the defect above. I flag it rather than read
the criterion loosely.
Why last in the job, and what holds it there
The runner evaluates a step's condition when it reaches the step. Any step after the
withdrawal is a step during which a cancellation leaves the eyes standing. The runner
already evaluated the withdrawal and skipped it by then. Placed last it also reads
steps.verdict.outputs.postedafter that step has reported.conditions.pychecks the position rather than any one ordering, which covers a stepappended later. Four mutations fail it. Move the withdrawal ahead of
Post the verdict,ahead of the resolve step, or ahead of the no-verdict report. Or append a step after it.
The id-ordering check caught only the first. In the other three
Post the verdictstillran earlier.
Why not the fix as named
Gating the conclusion read inside the script needs the job status in the shell, and GitHub
does not offer it there.
cancelled()is readable only in a step or jobif.PipelineTemplateEvaluator.EvaluateStepEnvironmentcallsCreateContext(contextData, expressionFunctions)with noexpressionState, whereEvaluateStepIfpassesstep.ExecutionContext.ToExpressionState(). AndCancelledFunction.EvaluateCorereadstemplateContext.State[nameof(IExecutionContext)]and
ArgUtil.NotNulls it.StepsRunnerturns that throw intoCompleteStep(step, TaskResult.Failed), soenv: CANCELLED: ${{ cancelled() }}fails thestep on every run, before the runner evaluates its condition. actionlint refuses it too:
calling function "cancelled" is not allowed here. "cancelled" is only available in "jobs.<job_id>.if", "jobs.<job_id>.steps.if". The same holds forrun:.The cancellation shapes, and which this covers
drivecompletesdrivecompletesAnswer the requestruns!cancelled()re-test fires, the runner kills it, then the withdrawalcancelled(), so the re-test keeps it aliveRunnerShutdownToken)StepsRunnerskips condition evaluation outrightTwo rows are gaps. In the answered-then-cancelled row the answer step has already taken
the thumb, so no later step can restore it. A hard kill of the runner leaves the eyes on
the comment, and nothing inside a workflow closes that.
The reaction table
Each case declares two job states: the one the runner reached
Answer the requestin, andthe one it reached the withdrawal step in.
success>cancelledis a cancellation thatarrived after the answer, so the answer step posts its own thumb and the fixture places
nothing by hand.
success>successbot:+1success>successbot:-1success>successsuccess>successdrive, outputs populatedcancelled>cancelleddrivefinishedcancelled>cancelledsuccess>cancelledsuccess>cancelledsuccess>cancelledsuccess>cancelledbot:+1success>cancelledbrandon:-1,bot:+1+1 -1 eyessuccess>successbot:+1success>successbot:-1success>successcancelled>cancelledbot:-1+human:+1success>successhuman:+1,bot:+1cancelled>cancelledhuman:+1bot:+1, no verdictsuccess>successbot:rocketfrom another workflowsuccess>successhuman:+1,bot:+1,bot:rocketcancelled>cancelledhuman:+1,bot:rocketsuccess>successhuman:+1,bot:+1,bot:eyes+ warningsuccess>successbot:+1,bot:eyes+ warningsuccess>successcancelled>cancelledbot:eyes+ warningcancelled>cancelledbot:eyes+ warningsuccess>successbot:+1A human's reaction survives every path. A
bot:rocketsome other workflow left survivestoo, because each step deletes only the contents this workflow posts. And a thumb that
answers a published verdict survives a later cancellation.
PLT-1159 — ruled out, with the evidence
The ticket's premise is that
ai-review.ymlreaches the same reactions through GraphQLaddReactionunderpull-requests: write. It does not, and the workflow file does notdecide the question.
1.
ai-review.ymldoes call both mutations, and itspermissions:blocks do lackissues.preflightiscontents: read+pull-requests: writeand callsaddReaction(content: EYES);complete_review_reactionispull-requests: writealoneand calls
addReaction(THUMBS_UP)thenremoveReaction(EYES).2. But neither call uses
GITHUB_TOKEN. Both steps passgithub-token: ${{ steps.app-token.outputs.token || github.token }}, and in productionthe App token wins. I checked live comments. Every reaction on an
@seidroid reviewtrigger in
sei-chainbelongs toseidroid[bot], not togithub-actions[bot]:The workflow's
permissions:block does not bound an App installation token. Its owninstallation grant governs, and that App holds Issues: write —
ai-assistant.ymlpostsPOST /repos/{o}/{r}/issues/comments/{id}/reactionswith the same token. ai-review istherefore no evidence at all about what
pull-requests: writealone can do. It is thesame class of wrong premise as the
enable-cursor: falseone.3. GitHub documents no permission for any GraphQL mutation. Not a gap in my reading —
checked at the data source. In
github/docs,src/graphql/data/fpt/schema-reactions.jsongives
addReactionandremoveReactionthe keysname, id, href, description, isDeprecated, inputFields, returnFields, categoryand no permission field. The public SDL(
docs.github.com/public/fpt/schema.docs.graphql) carries only@docsCategory(name: "reactions"). The GraphQL guide's whole statement on the subject isthat the API returns an error naming the permission it wanted. One route therefore remains
to the requirement: make the call.
4. GitHub documents what REST requires, and the alias stops at the reaction.
github/docs,src/github-apps/data/fpt-2026-03-10/server-to-server-permissions.json:GET/PATCH/DELETE /repos/{o}/{r}/issues/comments/{id}issuesandpull_requestsPOST /repos/{o}/{r}/issues/comments/{id}/reactionsissues: writeonlyDELETE /repos/{o}/{r}/issues/comments/{id}/reactions/{rid}issues: writeonlyPOST /repos/{o}/{r}/pulls/comments/{id}/reactionspull_requests: write(a review comment — a different resource)That file expresses "either permission" by listing an endpoint twice. A single listing on
the reactions endpoints is therefore a distinction, not an omission. It confirms the claim
the guard job already makes in prose.
Verdict. The premise is void and the documentation says nothing. Nothing here can mint
a fine-grained token scoped to
pull-requeststo test it. Shipping the drop blind wouldregress the defect this PR fixes.
continue-on-errorand a::warning::swallow a 403 onthe reaction, so the eyes would stay on every comment and no run would fail. Ruled out.
Two things worth keeping for whoever re-files it
removeReactionbeats the REST loop, whatever the scope turns out to be. The ticketassumed it takes a reaction node id. It does not.
RemoveReactionInputis{content: ReactionContent!, subjectId: ID!}, and the subject is the comment:IssueCommentsits in its@possibleTypes. It takes no actor input, so it can only everremove the viewer's own reaction. That makes the human-scoping property structural rather
than a
select(.user.login == $me)filter. It also retires the hardcodedme="github-actions[bot]"login and the paginated list whose miss leaves eyes behind.A third shape the ticket does not name looks likelier than either. This workflow
already mints an App token (
steps.identity.outputs.token) and already computesREVIEWER_LOGINfromapp-slug. Reacting under that identity needs no caller scope atall, and it is how production already posts these reactions. Two obstacles stand in the
way. The acknowledgement runs before the mint, on purpose. And the mint is optional, so a
GITHUB_TOKENfallback keeps the scope required — unless a caller without App credentialsmay lose the reaction.
The cost this change carries
The list-and-delete block is now duplicated between
Answer the requestand thewithdrawal step.
me="github-actions[bot]"and the set of contents each step may deleteare two copies, and a reader has to keep them in step by hand. Edit one and not the other
and a reaction stays behind on whichever path lost the edit.
That is the price of the separate step, and it buys the structural property: the
withdrawing step has no POST. Sharing the block would mean one step doing both jobs, which
is the shape that produced this PR's blocker. GitHub Actions offers no way to share a
script between two steps without a checkout, and YAML anchors are not supported.
Both harnesses cover both copies, so a drift fails rather than ships. The GraphQL
removeReactionabove is what would remove the duplication outright. It needs no loginand no listing, so the whole block collapses to one mutation per content.
Every reaction site
Three steps, six calls, all in the
reviewjob, all on the ISSUE comments endpoint:Acknowledge the triggerPOST .../issues/comments/{id}/reactions(eyes)Answer the requestGET .../reactions --paginateAnswer the requestDELETE .../reactions/{rid}Answer the requestPOST .../reactions(+1/-1)Withdraw the reactions on a cancelled runGET .../reactions --paginateWithdraw the reactions on a cancelled runDELETE .../reactions/{rid}The withdrawal step has no
POST, and that is the fix.guardreacts nowhere.ai-assistant.ymlandai-review.ymlhave their own sites; neither is in this workflow.Verification
Committed, not kept locally. An uncommitted harness is how the first blocker survived
a reading and seven mutations. Two additions to
test/seidroid-review/, wired intoworkflow-test-self.ymlas their own job so the placement check keeps its name.reactions.sh— 62 assertions over 32 cases. It runs the reaction steps underbash,extracted from the workflow on every run. No case names the step it runs.
conditions.py --selectnames it, from the job state and the posting step's outcome, sothe two layers cannot drift. The
ghstub keeps the reaction list a comment carries andserves it through the step's own
--jq. It honours idempotence per (user, content). Itcan refuse the list, a delete or the add. The acknowledgement's calls go to a separate
log, so every count belongs to the step under test. The stub reports any call it cannot
serve.
conditions.py— 76 assertions. A step condition decides which reaction step runs inwhich job state, and a shell harness cannot see it. The model applies the runner's own
rule: a condition naming none of
always/cancelled/failure/successbecomessuccess() && (...). It treats a term it cannot decide as unknown rather than false. Twochecks read the file rather than a table, so they cover a step added later:
Both walk every job's raw steps list and search the whole step:
check_pathorverdict_produced.steps.<id>a step reads must be a real id on an earlier step.Keyed off a name they dropped an unnamed step.
- uses: ...with noname:is the usualshape, so the step most likely to arrive later was the one they could not see. The guard
job already carries one. The id check also reached only
if, which left the withdrawal'snew
envread unchecked.Each fixture, mutation-tested. A fixture that cannot fail the invariant is not a test
of it:
steps.verdict.outcomeagainTHUMB GOESon a refused POST, and the matching condition rowskippedarm takes all threeEARLIER THUMB SURVIVES, plus 3 moreid: answerdeletedreads steps.answer, which is no step's idalways()step readingverdict_producedstep 17 runs on a cancelled run and reads verdict_producedpostedterm droppedTHUMB SURVIVES+ 3 more + the condition rowid: verdictdeletedreads steps.verdict, which is no step's idPost the verdictreads steps.verdict, which runs laterAnswer the requestback toalways()check_pathinterpolated inline intorun:env-only sweep passes itno step named 'Withdraw the reactions on a cancelled run'rocketcancelled casesforeign-cancelledloses arocketVERDICT_PRODUCEDgate removedOnly
conditions.pycatchesid: answerdeleted.reactions.shderives that outcomeitself, which is exactly why the id check has to exist.
conditions.pyalso modelssteps.verdict.outcomebesideposted, derived rather thanpassed. It reads
successwhenever that step reported at all, which is what the runnersees. A gate that regresses to the outcome therefore fails an assertion instead of
crashing the model.
Five checks ran narrower than their own claim. Mutating the thing each claimed to
cover is what found them.
envonly, so an inline interpolation passed.run_caseexported each per-case knob without clearing it. AnANSWERED_ASoverrideleaked forward and disarmed a later case.
The first three now read the file.
run_caseclears the fourth at the top of every case.The fifth reads an output written from the POST's own result.
actionlint, base vs branch, both rule sets.
seidroid-review.yml: 4 ×SC2102:info,unchanged. Whole
.github/workflows: 37 findings, identical after line-numbernormalisation.
workflow-test-self.yml: 0.shellcheck -S warningclean on both newharness files. The incumbent harness still passes, now 271 assertions after #102.
Base checked three times, and no move trusted. It went to
b1b51f8(#97), then2f58b80(#101), then98c2619(#102). #97 rewrote 341 lines of this file and added theharness. #102 rewrote the resolve step and placement, and renamed the harness job. Each
time all three harnesses and actionlint ran again on the new history. None of them carried
a result from before it. I checked each rebase by reading back four things: the step
order, the step ids, the gate, and the withdrawal step's POST count. Not by its exit
status.
The #102 rebase conflicted once, in the harness README, and I resolved it keeping both
sides.
workflow-test-self.ymlmerged cleanly:place-findingskeeps #102's renameddisplay name
Place findings and resolve threads, and the reaction job keeps its own.One handoff for whoever lands second. #103 replaces the hardcoded
issues/commentswith a
comment_apioutput. Its author wrote it against two reaction steps. Thisbranch leaves three, carrying six hardcoded paths rather than four: one in
Acknowledge the trigger, three inAnswer the request, two in the withdrawal step.Whichever of us rebases second has to reach all three steps.
Not verified
Nothing here ran on a GitHub runner. Five things stay unverified. That a real cancelled
run reaches the last step of the job. That
steps.verdict.outcomereadssuccesson a runcancelled after that step completed. That
steps.answer.outcomereadsskippedratherthan empty on a run cancelled before that step. That the runner's condition re-test kills
Answer the requestmid-flight, as its source says. And every permission claim above. Thecancellation semantics rest on
actions/runnersource and on actionlint.conditions.pymodels the expression engine; it is not the engine.🤖 Generated with Claude Code