Skip to content
Merged
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
16 changes: 16 additions & 0 deletions .github/actionlint.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# actionlint 1.7.12 (current pin) rejects the GA `concurrency.queue` key
# (upstream gap: https://github.com/rhysd/actionlint/issues/654). GitHub
# accepts and honors the key at both workflow and job level — verified by
# live probe runs recorded in docs/topics/claude-review-lanes/PLAN.md
# (Phase 0). Suppression approved 2026-07-26 (PLAN.md Approval record,
# item 3), scoped to exactly that one syntax-check message.
#
# REMOVAL TRIGGER: delete this ignore (and this file, if nothing else has
# accrued) when rhysd/actionlint#654 ships in the pinned actionlint version.
paths:
.github/workflows/**/*.yml:
ignore:
- 'unexpected key "queue" for "concurrency" section'
.github/workflows/**/*.yaml:
ignore:
- 'unexpected key "queue" for "concurrency" section'
54 changes: 54 additions & 0 deletions .github/actions/claude-lane-freshness/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
name: claude-lane-freshness
description: >-
Live freshness guard for the Claude lanes. A stale run must retire itself,
not rely on being cancelled: with a per-head concurrency key, nothing
cancels a run whose head was superseded while it sat in a queue. Asks the
live API whether this event's head is still the PR's head; when it is not,
a newer run owns the review, so every runner-consuming step downstream
should skip (posting feedback for an obsolete head is worse than doing
nothing, and the newer run also owns the marker-comment lifecycle). Fails
open — `!= 'true'` step gates treat a missing output as fresh, and only a
response shaped like a commit SHA may retire the run (a degenerate success
such as a literal `null` must not) — so an API anomaly degrades to
reviewing a possibly-stale head, never to silence. actions/github-script
rather than a `gh api` run step: the calling reusable runs on whatever
runner its caller selected, and a self-hosted image is not guaranteed to
ship the gh CLI (same pattern as do-not-merge-gate.yml).

outputs:
superseded:
description: >-
"true" when the PR head moved past this event's head, "false" when the
head is current or the check could not be made, empty when the step
skipped (no PR in the event). Gate downstream steps with `!= 'true'`.
value: ${{ steps.freshness.outputs.superseded }}

runs:
using: composite
steps:
- name: Check whether this head is still current
id: freshness
if: github.event.pull_request.number != ''
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
EVENT_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
with:
script: |
let liveHead = "";
try {
const { data: pr } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.payload.pull_request.number,
});
liveHead = pr.head.sha;
} catch (error) {
core.warning(`Could not refetch the PR head; treating this run as fresh: ${error.message}`);
}
const eventHeadSha = process.env.EVENT_HEAD_SHA ?? "";
if (/^[0-9a-f]{40}$/.test(liveHead) && liveHead !== eventHeadSha) {
core.notice(`Skipping review: the PR head moved from ${eventHeadSha} to ${liveHead}, so a newer run owns the review.`);
core.setOutput("superseded", "true");
} else {
core.setOutput("superseded", "false");
}
134 changes: 134 additions & 0 deletions .github/actions/claude-lane-marker-comment/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
name: claude-lane-marker-comment
description: >-
Marker-comment lifecycle for the Claude lanes. In post-failure mode it
upserts a warning comment on the PR identifying an infrastructure failure;
in clear-on-success mode it deletes a stale warning left by an earlier
failed run. Targets the marker comment by ID (never --edit-last, which
edits the PR's last comment from ANY author) so a stuck placeholder comment
from track_progress is never mistaken for "no findings" and an unrelated
bot comment is never clobbered. Owns the fork-PR guard: fork-triggered
pull_request runs get a read-only GITHUB_TOKEN and no secrets by design, so
commenting would just fail there — the action no-ops instead. Runs on the
github-script Node runtime — no gh CLI or other runner-image dependency.
Reporting an outage must never become one: callers should set
continue-on-error on the step invoking this action so a rejected API call
never turns the lane red.

inputs:
marker:
description: >-
HTML-comment marker identifying this lane's status comment, e.g.
"<!-- claude-review-infra-status -->".
required: true
mode:
description: '"post-failure" to upsert the warning, "clear-on-success" to delete it.'
required: true
headline:
description: >-
Bold headline for the warning body (post-failure mode), e.g.
"Automated review did not complete".
required: false
default: "Automated review did not complete"
failure-class:
description: "The failure class token to show (post-failure mode)."
required: false
default: ""
review-detail:
description: "The safe SDK-result projection to show (post-failure mode)."
required: false
default: ""
body-copy:
description: >-
Trailing guidance line of the warning body (post-failure mode).
required: false
default: "Re-running the job, or pushing a new commit, will retry the review."

runs:
using: composite
steps:
- name: Manage lane marker comment
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
MARKER: ${{ inputs.marker }}
MODE: ${{ inputs.mode }}
HEADLINE: ${{ inputs.headline }}
REVIEW_CLASS: ${{ inputs.failure-class }}
REVIEW_DETAIL: ${{ inputs.review-detail }}
BODY_COPY: ${{ inputs.body-copy }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
with:
script: |
const marker = process.env.MARKER;
const mode = process.env.MODE;
if (!/^<!--.+-->$/.test(marker ?? "")) {
core.setFailed(
`Marker must be a nonempty HTML comment like "<!-- lane-status -->" (got "${marker}") — ` +
"an empty or malformed marker would match unrelated bot comments.",
);
return;
}
if (mode !== "post-failure" && mode !== "clear-on-success") {
core.setFailed(`Unknown mode "${mode}" — expected post-failure or clear-on-success.`);
return;
}

const pr = context.payload.pull_request;
if (!pr?.number) {
core.info("No pull request in this event; nothing to do.");
return;
}
if (pr.head?.repo?.full_name !== `${context.repo.owner}/${context.repo.repo}`) {
core.info("Fork-triggered run has a read-only token; skipping comment management.");
return;
}

const comments = await github.paginate(github.rest.issues.listComments, {
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
});
const marked = comments.filter(
(comment) => comment.user?.login === "github-actions[bot]" && comment.body?.startsWith(marker),
Comment thread
kyle-sexton marked this conversation as resolved.
);
const existing = marked.length > 0 ? marked[marked.length - 1] : undefined;

if (mode === "clear-on-success") {
if (existing) {
await github.rest.issues.deleteComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
});
}
return;
}

const body = [
marker,
"> [!WARNING]",
`> **${process.env.HEADLINE}** — this is an infrastructure failure, not a review verdict.`,
">",
"> Treat any Claude comment on this PR (including a placeholder like \"I'll analyze this and get back to you\") as incomplete, not \"no findings.\"",
">",
`> - Run: ${process.env.RUN_URL}`,
`> - Failure class: \`${process.env.REVIEW_CLASS}\``,
`> - Last SDK result: \`${process.env.REVIEW_DETAIL}\``,
">",
`> ${process.env.BODY_COPY}`,
].join("\n");

if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
body,
});
}
87 changes: 87 additions & 0 deletions .github/actions/claude-lane-outcome/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
name: claude-lane-outcome
description: >-
Outcome reporter for the Claude lanes. On a successful action outcome it
records review-failed=false and stops. On any other outcome it projects the
last SDK result message from the execution file into safe, structured
metadata (classify.cjs — the sole reader of the file's free-text fields;
see its header for the projection and classification contract), emits the
machine-readable `class=<token>` ::error annotation the downstream
aggregator polls for, and records the failure class and detail for the
marker-comment step. Runs entirely on the github-script Node runtime — no
jq or other runner-image tool dependency.

inputs:
outcome:
description: >-
The Claude action step's `outcome` (e.g. steps.claude-review.outcome).
Anything other than "success" is treated as an infrastructure failure.
required: true
execution-file:
description: >-
The Claude action step's execution_file output. May be unset, missing,
or unparsable — all degrade to failure class `other`.
required: false
default: ""
lane:
description: >-
Human-readable lane name used in the annotation text, e.g.
"Claude review" or "Claude security review".
required: true

outputs:
review-failed:
description: '"true" on a genuine infrastructure failure, else "false".'
value: ${{ steps.outcome.outputs.review_failed }}
failure-class:
description: "auth | rate-limit | overloaded | other (empty on success)."
value: ${{ steps.outcome.outputs.failure_class }}
review-detail:
description: >-
Compact JSON projection of the last SDK result message (safe fields
only), or a parenthesized placeholder string (empty on success).
value: ${{ steps.outcome.outputs.review_detail }}

runs:
using: composite
steps:
- name: Report review outcome
id: outcome
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
LANE_OUTCOME: ${{ inputs.outcome }}
EXECUTION_FILE: ${{ inputs.execution-file }}
LANE_NAME: ${{ inputs.lane }}
ACTION_PATH: ${{ github.action_path }}
with:
script: |
const path = require("node:path");
const { classifyExecutionFile } = require(
path.join(process.env.ACTION_PATH, "classify.cjs"),
);

const outcome = process.env.LANE_OUTCOME ?? "";
const lane = process.env.LANE_NAME ?? "Claude review";

if (outcome === "success") {
core.info(`${lane} completed. Findings (if any) are in the PR comments.`);
core.setOutput("review_failed", "false");
return;
}

const { reviewDetail, failureClass } = classifyExecutionFile(
process.env.EXECUTION_FILE ?? "",
);

// `class=<token>` is a machine-readable term, not prose: this
// annotation is the detection surface a receiver polls through the
// check-run API, so keep it bare and greppable.
core.error(
`${lane} exited with: ${outcome} class=${failureClass} ` +
"(infrastructure error, not a code-quality signal — e.g. usage limit, " +
"OIDC failure, SDK crash, is_error result, or max-turns exhaustion). " +
`Last SDK result: ${reviewDetail}`,
);

core.setOutput("failure_class", failureClass);
core.setOutput("review_detail", reviewDetail);
core.setOutput("review_failed", "true");
Loading