Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions ai-qa/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,8 @@ prompt.)
7. **Stage QA rubric** — copies the action's own `rubric.md` into the
workspace so the review can read it with a stable path.
8. **Post-merge QA review (agentic)** — only runs when an Anthropic
credential is configured. Claude (`qa-model`, Sonnet by default) reads the
credential is configured. Claude (locked Sonnet primary with
Cursor → free fallbacks) reads the
rubric, inspects the merged diff via `git`, **smoke-tests the deployed app
over HTTP** (the health URL plus any routes the diff touches), **evaluates
the PR's Test Plan** if one is present (running each item it can against
Expand Down Expand Up @@ -114,7 +115,6 @@ prompt.)
| `health-url` | URL polled with `curl --fail` until healthy or `deploy-timeout` elapses; also smoke-tested directly by the review. No sensible generic default exists. | **Yes** | — |
| `deploy-timeout` | Seconds to keep polling `health-url` before giving up. | No | `180` |
| `test-hint` | Optional free-text describing how to build/test this repo. Handed to the review as context — Claude MAY run it at its discretion to confirm a suspected regression, never mechanically. Consumer must provision the toolchain first. | No | `""` |
| `qa-model` | Model used for the agentic QA review. | No | `claude-sonnet-5` |
| `allowed-tools` | Tool allowlist passed to the review's `--allowedTools` (read/grep the code, `curl` the deploy, `git` the diff, optionally run a JS/TS build/test). Override to widen or narrow. | No | *(read/grep/glob + curl/git + node/npm/npx/yarn/pnpm/corepack)* |
| `pass-label` | Label applied when the overall QA signal (health + review) passes. Also applied to linked issues when `update-linked-issues` is on. | No | `✓ /ai-qa` |
| `fail-label` | Label applied when the overall QA signal fails. Also applied to linked issues (and the merge-auto-closed issue is reopened) when `update-linked-issues` is on. | No | `✗ /ai-qa` |
Expand Down
71 changes: 53 additions & 18 deletions ai-qa/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,12 +52,6 @@ inputs:
before this action runs.
required: false
default: ""
qa-model:
description: >-
Model used for the agentic post-merge QA review. Defaults to Sonnet;
override with a repo var if a custom gateway aliases model names.
required: false
default: claude-sonnet-5
allowed-tools:
description: >-
Tool allowlist passed to the QA review's `--allowedTools`. The default
Expand Down Expand Up @@ -292,6 +286,22 @@ runs:
run: | # zizmor: ignore[github-env] BASE_URL is this action's own anthropic-base-url input (caller-supplied static config), never attacker-influenceable event data — see ADR-0010's env-binding rule
echo "ANTHROPIC_BASE_URL=${BASE_URL}" >> "${GITHUB_ENV}"

# Locked model IDs (not inputs). Keep in sync with ai-review's *schema*
# cascade: Claude → structured_output free only (no Cursor — none advertise
# structured_output, and --json-schema reviews go inconclusive on them).
- name: Resolve locked models
id: models
if: >-
steps.merge.outputs.skip != 'true' &&
(inputs.anthropic-api-key != '' || inputs.anthropic-auth-token != '')
shell: bash
run: |
set -euo pipefail
{
echo "primary=claude/claude-sonnet-5"
echo "fallback=oc/nemotron-3.5-lightning-free,oc/deepseek-v4-flash-free,auto/best-free"
} >> "${GITHUB_OUTPUT}"

# Workaround for anthropics/claude-code-action#1294: the action requires
# *some* value in `anthropic_api_key` even when authenticating to a custom
# gateway via a bearer token, so fall back to the auth token itself and
Expand All @@ -308,19 +318,14 @@ runs:
with:
anthropic_api_key: ${{ inputs.anthropic-api-key != '' && inputs.anthropic-api-key || inputs.anthropic-auth-token }}
github_token: ${{ inputs.github-token }}
# --fallback-model: escape hatch for HTTP 529 (Overloaded). Without it
# the CLI retries the primary internally with no exit. Measured on the
# sibling ai-review action, that backoff ran ~28 min per stage and
# produced 85-min jobs that did zero work (turns:3, $0, 0 tool calls).
# This action has a single model stage, so the same overload costs it
# one long stall rather than three — but it had no exit either.
# Neither entry may equal the primary or the fallback is a no-op:
# `qa-model` defaults to Sonnet 5, so the list is same-tier first
# (Sonnet 4.6), then Opus. The list is ordered and the primary is
# re-tried at the start of each turn.
# --fallback-model: Claude primary → Cursor if Claude blocked → free
# if Cursor blocked. Keep the free tail in sync with ai-review.
# Neither entry may equal the primary or the fallback is a no-op.
# The list is ordered and the primary is re-tried at the start of
# each turn. Does not bound silent gateway stalls (ADR 0005).
claude_args: |
--model ${{ inputs.qa-model }}
--fallback-model claude-sonnet-4-6,claude-opus-4-8
--model ${{ steps.models.outputs.primary }}
--fallback-model ${{ steps.models.outputs.fallback }}
--allowedTools "${{ inputs.allowed-tools }}"
--json-schema '{"type":"object","additionalProperties":false,"required":["verdict","confidence","merge_risk","deploy_status","counts","summary","report_markdown"],"properties":{"verdict":{"type":"string","enum":["pass","fail"]},"confidence":{"type":"integer","minimum":0,"maximum":100},"merge_risk":{"type":"string","enum":["low","medium","high"]},"deploy_status":{"type":"string","enum":["healthy","unhealthy","unknown"]},"counts":{"type":"object","additionalProperties":false,"required":["p0","p1","p2","p3"],"properties":{"p0":{"type":"integer","minimum":0},"p1":{"type":"integer","minimum":0},"p2":{"type":"integer","minimum":0},"p3":{"type":"integer","minimum":0}}},"summary":{"type":"string"},"report_markdown":{"type":"string"},"test_plan":{"type":"object","additionalProperties":false,"required":["present"],"properties":{"present":{"type":"boolean"},"items":{"type":"array","items":{"type":"object","additionalProperties":false,"required":["text","status"],"properties":{"text":{"type":"string"},"status":{"type":"string","enum":["passed","failed","unverifiable"]},"evidence":{"type":"string"}}}}}}}}'
prompt: |
Expand Down Expand Up @@ -395,9 +400,33 @@ runs:
UPDATE_PR_BODY: ${{ inputs.update-pr-body }}
UPDATE_LINKED_ISSUES: ${{ inputs.update-linked-issues }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
REVIEW_EXEC_FILE: ${{ steps.review.outputs.execution_file }}
REPORT_FOOTER_PATH: ${{ github.action_path }}/lib/report-footer.js
# Same locked primary the review stage was started with (steps.models).
# Prefer the execution-log model when present; otherwise the requested
# primary — never a second hardcoded literal that can drift from --model.
QA_PRIMARY_MODEL: ${{ steps.models.outputs.primary }}
with:
github-token: ${{ steps.identity.outputs.author-token }}
script: |
const fs = require('fs');
const { formatModelFooter, modelFromExecutionLog } = require(process.env.REPORT_FOOTER_PATH);

let modelUsed = '';
try {
const log = JSON.parse(fs.readFileSync(process.env.REVIEW_EXEC_FILE || '', 'utf8'));
modelUsed = modelFromExecutionLog(log);
} catch {
// No execution log (review skipped / missing).
}
// Only attribute a model when we have structured QA output. Prefer the
// log's resolved model (captures Cursor/free fallback). If the log is
// silent, fall back to the locked primary that this run requested —
// same shape as ai-review's resolveModelUsed({ fallback: ROUTED_MODEL }).
if (!modelUsed && process.env.QA_OUTPUT) {
modelUsed = process.env.QA_PRIMARY_MODEL || '';
}

const prNumber = Number(process.env.PR_NUMBER);
const healthOk = process.env.HEALTH_STATUS === 'healthy';

Expand Down Expand Up @@ -515,6 +544,12 @@ runs:
body.push('> ⚠️ The QA review step reported a non-zero outcome — the report above may be partial.');
}

const footer = formatModelFooter(modelUsed);
if (footer) {
body.push('');
body.push(footer);
}

body.push('');
body.push(`_Posted by \`${process.env.AUTHOR_LOGIN}\` · [workflow run](${process.env.RUN_URL})_`);

Expand Down
28 changes: 28 additions & 0 deletions ai-qa/lib/report-footer.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
"use strict";

/**
* @param {string|null|undefined} modelUsed
* @returns {string} empty when modelUsed is blank
*/
function formatModelFooter(modelUsed) {
if (!modelUsed || typeof modelUsed !== "string" || !modelUsed.trim()) return "";
return [
`Model: \`${modelUsed.trim()}\``,
"_Re-run this job if you need another review pass._",
].join("\n");
}

/**
* First named model in a claude-code-action execution log.
* @param {unknown} entries
* @returns {string}
*/
function modelFromExecutionLog(entries) {
if (!Array.isArray(entries)) return "";
for (const e of entries) {
if (e && typeof e.model === "string" && e.model.trim()) return e.model.trim();
}
return "";
}

module.exports = { formatModelFooter, modelFromExecutionLog };
27 changes: 27 additions & 0 deletions ai-qa/lib/report-footer.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
"use strict";

const test = require("node:test");
const assert = require("node:assert/strict");
const { formatModelFooter, modelFromExecutionLog } = require("./report-footer.js");

test("formatModelFooter renders model and re-run hint", () => {
const s = formatModelFooter("claude/claude-sonnet-5");
assert.match(s, /Model: `claude\/claude-sonnet-5`/);
assert.match(s, /Re-run this job if you need another review pass/);
});

test("formatModelFooter returns empty for blank", () => {
assert.equal(formatModelFooter(""), "");
assert.equal(formatModelFooter(null), "");
});

test("modelFromExecutionLog reads the first named model", () => {
assert.equal(
modelFromExecutionLog([
{ type: "system", subtype: "init", model: "oc/mimo-v2.5-free" },
{ type: "result", subtype: "success" },
]),
"oc/mimo-v2.5-free",
);
assert.equal(modelFromExecutionLog(null), "");
});
19 changes: 11 additions & 8 deletions ai-review/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,12 @@ workflow can gate its own heavier build/test/deploy jobs on
The verdict comes from a real two-stage AI review: a Haiku context stage
summarizes the diff, then a diff-size-routed Sonnet/Opus review stage
performs the full rubric scan (see `ai-review/rubric.md`) and returns a
schema-validated structured result. The `Publish review` step never trusts
schema-validated structured result. Model IDs are **locked in the action**
(Claude primary → for context, Cursor `composer-2.5` then free; for schema
reviews, structured_output free models only — Cursor has no SO on this
gateway). The posted PR review footer names the model that
actually ran and hints to re-run the job for another pass. The `Publish review`
step never trusts
the model's self-reported verdict directly — it deterministically
recomputes confidence, verdict, and merge risk from the model's reported
P0-P3 finding counts and test-quality signals, then posts that as a native
Expand Down Expand Up @@ -59,8 +64,9 @@ injection-safety rule.
derives the diff base from a false premise reviews the wrong range and
reports confidently on it.

The same step routes ordinary diffs to `sonnet-model`, escalating to
`opus-model` once a diff exceeds **either** `sonnet-files-threshold`
The same step routes ordinary diffs to the locked Sonnet primary
(`claude/claude-sonnet-5`), escalating to Opus (`claude/claude-opus-5`)
once a diff exceeds **either** `sonnet-files-threshold`
(25) or `sonnet-churn-threshold` (800). These were briefly 3/60, which
sent nearly every real PR to Opus and moved the review stage from
~10-13 min to a 35-min median. Widened 15/400 → 25/800 after measuring
Expand Down Expand Up @@ -204,11 +210,8 @@ injection-safety rule.
| `qa-pass-label` | Post-merge `ai-qa` pass label; cleared (not applied) by this action on every new commit. | No | `✓ /ai-qa` |
| `qa-fail-label` | Post-merge `ai-qa` fail label; cleared (not applied) by this action on every new commit. | No | `✗ /ai-qa` |
| `confidence-threshold` | Minimum **blocking-finding** confidence (0-100) required for a pass. The Publish step recomputes confidence from the review stage's P0/P1 counts and test-quality signals and compares it against this threshold. P2/P3 findings lower the *reported* confidence but are advisory and never block. | No | `90` |
| `sonnet-files-threshold` | Max changed-file count for a diff to still route to `sonnet-model` (must hold together with `sonnet-churn-threshold`); larger diffs route to `opus-model`. | No | `25` |
| `sonnet-churn-threshold` | Max changed-line count (adds + deletes) for a diff to still route to `sonnet-model`. | No | `800` |
| `sonnet-model` | Model the routing step selects for diffs within **both** thresholds. Override when a gateway aliases model names. | No | `claude-sonnet-5` |
| `opus-model` | Model the routing step selects for every larger diff. Override when a gateway aliases model names. | No | `claude-opus-5` |
| `haiku-model` | Model used by the context stage, and stamped on the roster's `history`/`scorer` roles by the prep step. Note: Haiku 4.5 does not accept the `effort` parameter, so no stage or role running it passes `--effort` — the roster resolves that against the model id, not the tier, so overriding another tier to a literal Haiku id (e.g. `sonnet-model: claude-haiku-4-5`) is also covered. A gateway alias that *routes* to Haiku under an unrelated string is not detected; the check is a substring match, not alias resolution. | No | `claude-haiku-4-5` |
| `sonnet-files-threshold` | Max changed-file count for a diff to still route to the locked Sonnet primary (must hold together with `sonnet-churn-threshold`); larger diffs route to Opus. | No | `25` |
| `sonnet-churn-threshold` | Max changed-line count (adds + deletes) for a diff to still route to Sonnet. | No | `800` |
| `enable-context-stage` | When `false`, skips the Haiku context stage (and its `context.md` verification) entirely. The stage is best-effort and its output optional, so disabling it removes a wall-clock risk without changing the gate contract. | No | `true` |
| `api-timeout-ms` | Per-request timeout (ms) for every Claude stage, passed as `API_TIMEOUT_MS` (CLI default `600000`). **Does not bound the ~27.5-min stall** — a run with this set to `180000` still stalled 27m36s. It is a genuine per-request bound and fails a wedged request faster than the default, nothing more. | No | `180000` |
| `test-command` | **DEPRECATED — accepted but ignored.** The Review stage no longer runs tests; see [Why the review no longer runs tests](#why-the-review-no-longer-runs-tests). | No | — |
Expand Down
Loading
Loading