Skip to content

NAS-142136 / 27.0.0-BETA.1 / Publish the Claude review as a shared workflow, with pr-title and CI for this repo - #3

Merged
AlexKarpov98 merged 11 commits into
masterfrom
feat/shared-claude-review-gated
Aug 11, 2026
Merged

NAS-142136 / 27.0.0-BETA.1 / Publish the Claude review as a shared workflow, with pr-title and CI for this repo#3
AlexKarpov98 merged 11 commits into
masterfrom
feat/shared-claude-review-gated

Conversation

@AlexKarpov98

@AlexKarpov98 AlexKarpov98 commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

What this is

Aaron asked for the review running in truenas/api-client-ts to be published here so the other repos can call it. William's call was to not keep the old comment-only review alongside it — if we don't like this one we change it rather than carry two. So this publishes one review workflow; the comment-only variant was written, reviewed and dropped before it ever reached master.

What lands

Path
.github/workflows/claude-review.yml the review
review/schema.json shape of the structured output
review/rubric.md the severity rubric the gate scores
review/check-review-threshold.mjs fails the job at MEDIUM and above
review/collect-review-threads.mjs prior threads and their resolved state
review/mark-review-stale.mjs "superseded" banner on the previous summary
.github/workflows/pr-title.yml Conventional Commits title gate (separate concern; only for repos where a squash merge feeds semantic-release)
.github/workflows/pr-ticket.yml this repo runs check-ticket on its own PRs
.github/workflows/ci.yml actionlint, input-reference check, review-asset check, self-test

How a repo calls it

jobs:
  claude-review:
    uses: iXsystems/ux-github-workflows/.github/workflows/claude-review.yml@master
    permissions:
      contents: read
      issues: write
      pull-requests: write
      id-token: write
    secrets:
      anthropic-api-key: ${{ secrets.CLAUDE_API_KEY }}

The caller keeps its own on: trigger — branch filters and paths-ignore differ per repo, and a workflow_call has no say in what triggers its caller. The secret is named rather than inherited because the repos call it different things (CLAUDE_API_KEY vs CLAUDE_TOKEN).

Decisions worth a second opinion

  • The rubric lives here, not in each repo. The gate and the schema are here; three copies of the rubric would drift from the thing that scores them, and a check would start passing or failing for reasons nobody wrote down. It is appended to the caller's own prompt-file, which keeps saying what to look for in that codebase.
  • tooling-ref. review/ is checked out into the caller's workspace at run time, and a reusable workflow cannot see the ref it was called at. github.job_workflow_sha is exactly that, but actionlint 1.7.7 rejects the property and our CI runs actionlint — so it is an input, defaulting to master.
  • The action version is hardcoded, not an input. uses: does not evaluate expressions, and a configurable version is how the consumers ended up on v1.0.182, v1.0.154 and v1.0.134. Bump it here and every caller moves.
  • No override label. Bypassing a red check is branch protection's job — it already restricts who may do it and records that they did.

The github_token line is load-bearing

Without it every migration PR fails, and not for a reason that looks like the review:

Skipping action due to workflow validation: The workflow file must exist and have
identical content to the version on the repository's default branch.

That is the OIDC→app-token exchange refusing, not the review failing — setupGitHubToken only reaches the exchange when no token was provided. A sensible guard on Anthropic's own credentials, but it meant the PR that installs the reviewer was the one PR it could never run on: the step went green in ~4 seconds having done nothing, and the threshold gate correctly failed closed on empty output.

Passing github_token: ${{ github.token }} skips the exchange. GitHub has already scoped that token to the job's permissions: block, which is where the equivalent restriction belongs. Costs: comments come from github-actions[bot] rather than the Claude app (which is the identity mark-review-stale.mjs already edits with), and on a fork PR GITHUB_TOKEN is read-only — require-write-access skips those anyway, leaving a write-access author working from a fork as the one gap.

Verified live

Running against iXsystems/truenas-ui-components#175, pinned to this branch: inline comments on the right lines, one summary comment carrying the reviewed-sha marker the stale banner keys on, structured output scored by the gate, and a MEDIUM finding that correctly identified the temporary branch pin in that PR's own workflow file.

Follow-ups, not in this PR

Migrations, one small PR per repo: truenas-ui-components#175, truenas-connect/ui#370, api-client-ts#33. Each replaces its inline claude.yml — a repo must not run both, since both post as github-actions[bot] and gh pr comment --edit-last would edit whichever summary that bot wrote last. truenas/webui has not been started.

Note for whoever merges

Consumers reference @master, so anything landing here is live in every consumer immediately, review/ included — tooling-ref defaults to master, so an edit to the rubric changes how every review grades on its next run.

AlexKarpov98 and others added 3 commits August 11, 2026 11:17
Aaron and William are happy with the review variation running in
truenas/api-client-ts and asked for it here, keeping the existing one
available in case the team prefers it. So this publishes both:

- `claude-review.yml` — the original comment-only review, restored from
  the work on NAS-142094 that was held back while it settled.
- `claude-review-gated.yml` — the api-client-ts variant: structured
  output scored by a threshold script, inline comments, prior review
  threads handed to the reviewer, and a superseded banner on the stale
  summary.

Three things the port had to decide:

- The severity rubric lives here as `review/rubric.md` and is appended
  to the caller's own prompt file, rather than being copied into each
  repo. The gate and the schema are here; three copies of the rubric
  would drift from the thing that scores them, and a check would start
  passing or failing for reasons nobody wrote down.
- The scripts and schema are checked out into the caller's workspace at
  run time, under `.claude-review/` and excluded from git. A reusable
  workflow cannot see the ref it was called at — `job_workflow_sha` is
  exactly that, but actionlint 1.7.7 rejects the property and this
  repo's CI runs actionlint — so the ref is the `tooling-ref` input,
  defaulting to `master` like every current caller.
- The concurrency group is distinct from `claude-review.yml`'s, so a
  repo trialling the new one does not have each workflow cancel the
  other's runs. They still must not both run on one PR: both post as
  github-actions[bot], and `gh pr comment --edit-last` would edit
  whichever summary that bot wrote last.

CI grows a `review-assets` job. Nothing in this repo executes those
files — the consumer does — so without it a syntax error or a renamed
path would first show up as someone else's review job dying halfway,
which is the one failure a gate must not have.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMo51bM6QV14CK3Kgyp3DQ
truenas-ui-components and api-client-ts both run semantic-release off
the squashed PR title, and both carried their own Conventional Commits
gate to protect it. The README said the former was "the only repo
running semantic-release"; that stopped being true when api-client-ts
started publishing.

The two copies had drifted in the one place that matters. The optional
`<ticket> / <version> / ` prefix was `.+ / ` in ui-components and
`[^:]+ / ` in api-client-ts, and the greedy version swallows the real
type: in "fix: adjust a / b: c" it matches "fix: adjust a / " and leaves
"b" as the type. The gate passes, semantic-release sees a type with no
release rule, and the merge publishes nothing — a failure that shows up
as an absence, which nobody is watching for. The shared one takes the
strict pattern.

That makes this a release gate wearing a style gate's clothes, so the
caller's .releaserc.json parserOpts has to match it. There is no way to
enforce that from here; the README and the workflow header both spell
out the pattern to keep in sync, and adopting repos change their
.releaserc.json in the same PR.

Checked against the last 40 merged titles in both repos: no title
changes verdict or parsed type, other than the colon case above.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMo51bM6QV14CK3Kgyp3DQ
This repository published `check-ticket.yml` without ever running it. PRs
here were the only ones in the set not required to name a ticket, and the
workflow was the one published file nothing executed before a merge.

`pr-ticket.yml` calls it by relative path, the way ci.yml's self-test calls
check-member.yml, so a PR that breaks the gate fails its own check instead
of surfacing in a consumer's next one.

It is a separate file from ci.yml because of the trigger. The check needs
`edited` — retitling is how a red check gets fixed, and without it the
corrected title never re-runs — and ci.yml must not take `edited` in
exchange, or actionlint, input-refs, review-assets and the self-test would
rebuild every time someone edited a description.

`pr-title.yml` stays unadopted. It matters only where a squash merge feeds
the PR title to semantic-release, and there is no package.json or
.releaserc here to release from.

Prefixes stay at the default of NAS, which is all this repo's history uses.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011tXnFePKrH8VRg4uYzJkJq
@bugclerk bugclerk changed the title Add the gated Claude review workflow, alongside the original NAS-142136 / 27.0.0-BETA.1 / Add the gated Claude review workflow, alongside the original Aug 11, 2026
@bugclerk

Copy link
Copy Markdown
Contributor

…lure

The gate's message for an empty structured output said the run "failed or
was cut short", and pointed at the review step. On the PR that adopts this
workflow that reads as a broken reviewer, when it is neither: the review
step reports success in a few seconds and reviews nothing.

claude-code-action refuses to run when the pull request changes the
workflow file that invokes it — the guard that stops a PR editing its own
reviewer — so it skips, emits no structured output, and this gate fails
closed on it. Confirmed on iXsystems/truenas-ui-components#175 and
truenas-connect/ui#370, where every other step passed and the action
logged "Skipping action due to workflow validation".

Two things about it are easy to get wrong, so both are now stated: the ref
a caller is pinned to is irrelevant, and re-running cannot help. The
calling workflow has to reach the caller's default branch first, which
makes this a once-per-repo red check on the migration PR.

The fail-closed behaviour is unchanged and correct — this only stops it
misdirecting the reader.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011tXnFePKrH8VRg4uYzJkJq

@william-gr william-gr left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Why are we keeping the old claude review?

AlexKarpov98 and others added 4 commits August 11, 2026 13:16
Publishing both was meant to let a repo keep the review it knew while
trying the new one. William's call on review, and it is the right one: two
workflows doing one job is a choice every consumer has to make and then
keep making, and if the gated review turns out to be wrong the fix is to
change it here — git history is the revert path, not a second file kept
alive in case.

So claude-review.yml goes, and with it everything written around the
premise that a caller picks between two:

- the comparison table now reads against the inline claude.yml each
  consumer actually has, which is what a migration is measured against;
- "do not run both on one PR" becomes "do not keep your inline review
  running alongside this one" — the comment collision is real either way,
  but it is a migration hazard now, not a menu;
- the tooling-ref note no longer says "gated only", there being nothing to
  distinguish it from.

The file name keeps the -gated suffix. It describes what the workflow does
rather than which of two it is, and renaming it would break the three open
migration PRs that already name this path.

Adoption table now tracks the three migration PRs, and says plainly that
each will show one red Automatic PR review — the PR that installs the
reviewer is the one PR it cannot run on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011tXnFePKrH8VRg4uYzJkJq
…l copy

The comment-only variant never reached master — it was added and removed
within this branch — so saying it "was published alongside" this one
describes something no reader of master ever saw. Reworded, and it now
says where the `-gated` in the file name came from, since that is the only
trace the name still carries.

The adoption note also called truenas-ui-components' local check-member.yml
a byte-identical copy of the one here. It is not, and has not been since
the payload guard landed: that copy is the earlier version, which throws on
a non-PR event instead of reporting 'false'. Its migration deletes it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011tXnFePKrH8VRg4uYzJkJq
The `-gated` suffix only ever meant "not the other one", and there is no
other one. With a single review workflow the suffix reads as a promise that
a non-gated variant exists somewhere, which is the question it now makes a
reader ask.

Also drops the paragraphs explaining that a comment-only variant was
written and dropped. It never reached master, so on master it describes
something no reader has seen — archaeology about a file that only ever
existed on this branch. Git history has it, which was the argument for
dropping the file in the first place.

Renamed rather than left alone because nothing on master references either
name yet: the three migration PRs that name this path are all still open,
and are updated alongside this. The same rename after they merge would be
a breaking change to every consumer.

`ci.yml`'s tooling-path check greps this filename, so it moves too —
otherwise it would silently check nothing and pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011tXnFePKrH8VRg4uYzJkJq
…s it

Every migration PR was failing this gate, and the cause was not the review.
Left unset, the action exchanges its OIDC token for an Anthropic GitHub App
token, and setupGitHubToken only reaches that exchange when no token was
provided:

    const providedToken = process.env.OVERRIDE_GITHUB_TOKEN;
    if (providedToken) return providedToken;   // no exchange, no validation

The exchange is the only caller of the workflow-file validation — the check
that the calling workflow matches the version on the default branch. So a
PR that adds or migrates claude.yml could not be reviewed by it: the action
skipped, the step went green in four seconds having done nothing, and the
threshold gate fails closed on empty output. Confirmed on
truenas-ui-components#175 and truenas-connect/ui#370.

Guarding the exchange that way is reasonable — a PR should not mint an app
token for a workflow nobody has merged. It just has nothing to do with
reviewing, and GITHUB_TOKEN carries the job's own `permissions:` block,
which is where that restriction already lives.

What this costs: comments come from github-actions[bot] rather than the
Claude app, which is the identity mark-review-stale.mjs already edits with;
and on a fork PR GITHUB_TOKEN is read-only, so posting would fail there.
require-write-access skips fork PRs from non-members, leaving a write-access
author working from a fork as the one case this does not serve.

The gate's own message pointed at "the PR changes the workflow file" as an
expected, unavoidable failure. It is neither now, so it says what a
recurrence would actually mean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011tXnFePKrH8VRg4uYzJkJq
@AlexKarpov98 AlexKarpov98 changed the title NAS-142136 / 27.0.0-BETA.1 / Add the gated Claude review workflow, alongside the original NAS-142136 / 27.0.0-BETA.1 / Publish the Claude review as a shared workflow, with pr-title and CI for this repo Aug 11, 2026
AlexKarpov98 and others added 3 commits August 11, 2026 16:45
Findings are interpolated straight into workflow-command position, and every
field in one is written by the model. Workflow commands are line-oriented, so
a newline in a summary ends the annotation and the runner reads the next line
fresh:

    ::error file=a.ts,line=3::MEDIUM: broke it
    ::stop-commands::xyz

The second line is a command, not text. `maxLength: 200` in the schema bounds
how much text arrives, not which bytes, and nothing validates the payload
against that schema before this script reads it — `structured_output` is
whatever the action emitted.

The likelier version of the same bug needs no adversary: a summary that wraps
onto a second line loses everything after the break, and the annotation on the
diff shows half a finding.

So the message is escaped per GitHub's own rules (%, CR, LF), and property
values additionally for `:` and `,`, which is what separates `file=` from
`line=` — a path containing either used to truncate the properties. `%` is
substituted first, or it would re-escape the escapes.

`where` can be empty, since `file` is required by the schema and not enforced
here, so its space is appended with it rather than interpolated around it:
`::error ::…` was a command with a trailing space in its name.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EsxwaA1vMSLJA2m1iQd3JW
The previous summary was found by taking the newest comment whose body
contains the reviewed-sha marker, with no filter on who wrote it. The marker
is in the body, so it travels: quote the summary to reply to it, or to
disagree with a finding, and your comment now carries it too — and being
newer, wins.

The step then PATCHes that comment, prepending a superseded banner to words
someone else wrote. It succeeds, because GITHUB_TOKEN carries `issues: write`
across the repo rather than over the bot's own comments. Meanwhile the real
summary is left unmarked and goes on reading as current, which is the single
thing this step exists to prevent.

Filtering on `user.type === 'Bot'` rather than a login, because the identity
the summary is posted under has already moved once — the Claude app before
this workflow passed `github_token`, github-actions[bot] after — and this
should not have to move with it. It also matches what the reviewer itself
does: `gh pr comment --edit-last` is scoped to the token's own comments.

Second, smaller: the marker is re-matched against the banner-stripped body,
so a match on the raw body does not guarantee one here. It threw a TypeError
into the catch, which reported "could not mark the previous review stale:
Cannot read properties of null" — a message about a null, not about a comment.

Exercised against a stubbed API: with a human quoting the summary after it,
the old code patched the quote and the new code patches the summary; a marker
that only ever appears in a human's comment now reports nothing to mark.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EsxwaA1vMSLJA2m1iQd3JW
…p it

A skipped job satisfies a required status check. That is the whole of it: if
a caller marks `Automatic PR review` required, every path that skips the job
reports green, and the check says a PR was reviewed when nothing reviewed it.

Two of those paths are meant to skip — the label, and an author without write
access. A failed check-member was not. `is_member` comes back empty, the
condition is unmet, the job skips, and the merge gate quietly opens. The
comment here claimed that case "stays fail-closed either way", which was true
of the thing check-member guards (no review runs, no tokens are spent on a
stranger's PR) and false of the gate the workflow is named for.

So a failed check-member is now admitted and rejected in the job's first step,
before the checkout. The failure lands on the required check instead of being
absent from it, and a runner start costs seconds and no API tokens.

Narrow in practice, which is why it was worth being explicit about rather than
leaving to inference: check-member catches a permission lookup it cannot make
and answers 'false', so reaching this step means the job itself died — runner
or action infrastructure, and a re-run. The step's message says so, since the
reader arriving at it has a red check and no findings to explain it.

skip-label keeps the same skips-as-green property deliberately; its input
description already says so, and the README now says it for all three paths in
one place, next to the decision it bears on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EsxwaA1vMSLJA2m1iQd3JW
@AlexKarpov98
AlexKarpov98 merged commit 21a9460 into master Aug 11, 2026
5 checks passed
@AlexKarpov98
AlexKarpov98 deleted the feat/shared-claude-review-gated branch August 11, 2026 15:06
@bugclerk

Copy link
Copy Markdown
Contributor

This PR has been merged and conversations have been locked.
If you would like to discuss more about this issue please use our forums or raise a Jira ticket.

@iXsystems iXsystems locked as resolved and limited conversation to collaborators Aug 11, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants