diff --git a/.github/actions/publish-review/publish.cjs b/.github/actions/publish-review/publish.cjs index f4ca82d..bd03972 100644 --- a/.github/actions/publish-review/publish.cjs +++ b/.github/actions/publish-review/publish.cjs @@ -89,6 +89,24 @@ function commentBody(body, lane) { return `${stripHtml(body).slice(0, MAX_BODY)}\n\nclaude-lane: ${lane}`; } +// The comments the extract step could not place (path, line or side +// malformed), each with a readable location where it had one. +function malformedComments(review) { + return (review.malformed ?? []) + .filter((c) => typeof c?.body === "string" && c.body.trim() !== "") + .map((c) => ({ + location: [ + typeof c.path === "string" && /^[^\0\r\n`]{1,500}$/u.test(c.path) + ? c.path + : "", + Number.isSafeInteger(c.line) && c.line >= 1 ? String(c.line) : "", + ] + .filter(Boolean) + .join(":"), + body: stripHtml(c.body).slice(0, MAX_BODY), + })); +} + // The review the model job extracted, or null when it is not one. function parseReview(text) { let review; @@ -101,7 +119,8 @@ function parseReview(text) { !Number.isSafeInteger(review?.findings) || review.findings < 0 || typeof review.summary !== "string" || - !Array.isArray(review.comments) + !Array.isArray(review.comments) || + !(review.malformed === undefined || Array.isArray(review.malformed)) ) { return null; } @@ -171,13 +190,15 @@ function isCritical(body) { } // The model's summary, its one critical-findings line raised by the CRITICAL -// findings moved out of the inline batch, which that line leaves out. With no -// line, one is added that counts every summary finding as CRITICAL; with more -// than one, a gate already counts them all, so the text stays as written. -function countedSummary(review, unanchored) { +// findings moved out of the inline batch or malformed, which that line leaves +// out. With no line, one is added that counts every summary finding as +// CRITICAL; with more than one, a gate already counts them all, so the text +// stays as written. +function countedSummary(review, unanchored, malformed) { const text = stripHtml(review.summary); - if (unanchored.length === 0) return text; - const demoted = unanchored.filter((c) => isCritical(c.body)).length; + const moved = [...unanchored, ...malformed]; + if (moved.length === 0) return text; + const demoted = moved.filter((c) => isCritical(c.body)).length; const lines = [...text.matchAll(CRITICAL_LINE)].length; if (lines === 1) return text.replace( @@ -185,19 +206,27 @@ function countedSummary(review, unanchored) { (_, before, count, after) => `${before}${Number(count) + demoted}${after}`, ); if (lines > 1) return text; - const rest = Math.max(review.findings - review.comments.length, 0); + const rest = Math.max( + review.findings - review.comments.length - malformed.length, + 0, + ); return `${text}\n\ncritical-findings: ${rest + demoted}`; } // The summary as posted: the model's summary, then any finding that could not // sit on a line, with its location. function summaryText(review, unanchored, lane) { + const malformed = malformedComments(review); const extra = unanchored.map( (comment) => `- \`${comment.path}:${comment.line}\` (${comment.side}): ${comment.body.replace(`\n\nclaude-lane: ${lane}`, "")}`, ); + const unplaced = malformed.map( + (comment) => + `- ${comment.location ? `\`${comment.location}\`: ` : ""}${comment.body}`, + ); return [ - countedSummary(review, unanchored), + countedSummary(review, unanchored, malformed), ...(extra.length > 0 ? [ "", @@ -206,6 +235,14 @@ function summaryText(review, unanchored, lane) { ...extra, ] : []), + ...(unplaced.length > 0 + ? [ + "", + "Findings not posted inline (their path, line or side is malformed):", + "", + ...unplaced, + ] + : []), ] .join("\n") .slice(0, MAX_SUMMARY); @@ -390,7 +427,10 @@ async function publish({ github, context, core, env }) { if (live !== head) placed.problems.unshift(`the pull request's head moved to ${live}`); const summary = summaryText(review, placed.unanchored, lane); - const findings = Math.max(review.findings, review.comments.length); + const findings = Math.max( + review.findings, + review.comments.length + malformedComments(review).length, + ); const findSecret = await loadGate(env); if (!findSecret) @@ -417,6 +457,7 @@ async function publish({ github, context, core, env }) { findings: review.findings, summary, comments: [...placed.inline, ...placed.unanchored], + malformed: malformedComments(review), }, }); return { findings, inline: placed.inline.length }; diff --git a/.github/scripts/actions-expression.cjs b/.github/scripts/actions-expression.cjs index e7d4ac5..45ff0bd 100644 --- a/.github/scripts/actions-expression.cjs +++ b/.github/scripts/actions-expression.cjs @@ -1,7 +1,7 @@ "use strict"; // Evaluates the subset of the Actions expression language the lanes' job -// and step gates use: github, inputs, needs, steps, vars and secrets context paths (a +// and step gates use: github, inputs, needs, steps, vars, secrets and env context paths (a // missing property is null), string literals, ! && || == != and parentheses, // the case-insensitive contains/startsWith/endsWith (contains also over an // array, by element), format, fromJSON, always(), and cancelled(), which is @@ -26,14 +26,22 @@ const functions = { always: () => true, cancelled: () => false, }; -const CONTEXTS = ["github", "inputs", "needs", "steps", "vars", "secrets"]; +const CONTEXTS = [ + "github", + "inputs", + "needs", + "steps", + "vars", + "secrets", + "env", +]; const evaluate = (expression, contexts) => { const body = expression .trim() .replace(/^\$\{\{([\s\S]*)\}\}$/u, "$1") .replace( - /\b(github|inputs|needs|steps|vars|secrets)((?:\.[A-Za-z_][\w-]*)+)/gu, + /\b(github|inputs|needs|steps|vars|secrets|env)((?:\.[A-Za-z_][\w-]*)+)/gu, (_, root, rest) => `${root}${rest .slice(1) diff --git a/.github/scripts/pr-review-cli.test.cjs b/.github/scripts/pr-review-cli.test.cjs index d6cc148..923aac4 100644 --- a/.github/scripts/pr-review-cli.test.cjs +++ b/.github/scripts/pr-review-cli.test.cjs @@ -274,18 +274,36 @@ test("the key job runs no model when the key is empty or REVIEWS_KEY_EXHAUSTED i const minutes = (value, lane) => Number(evaluate(String(value), { inputs: { lane } })); +// Whether a step continues on error, evaluated as the runner does. After a +// composite action (`uses: $/...`) runs, the runner replaces the step's +// `inputs` and `steps` contexts with the action's own before it reads +// continue-on-error (actions/runner CompositeActionHandler.cs), so there +// only the step's env still carries the workflow's publish input. +function continues(s, publish) { + const value = s["continue-on-error"]; + if (value === undefined || typeof value === "boolean") return value === true; + // Only env values the publish input sets matter here. + const env = Object.fromEntries( + Object.entries(s.env ?? {}) + .filter(([, text]) => /^\$\{\{\s*inputs\.publish\s*\}\}$/u.test(text)) + .map(([name]) => [name, publish]), + ); + const composite = String(s.uses ?? "").startsWith("$/"); + return ( + evaluate(String(value), { + inputs: composite ? {} : { publish }, + steps: composite ? {} : undefined, + env, + }) === true + ); +} + // GitHub compares strings ignoring case, so `Comments` is comments mode. for (const publish of ["summary", "", "anything"]) { test(`publish=${JSON.stringify(publish)} continues every step on error and keeps every job inside its limit`, () => { for (const job of Object.values(workflow.jobs)) { for (const s of job.steps) { - const value = s["continue-on-error"]; - assert.ok( - value === true || - evaluate(String(value ?? "false"), { inputs: { publish } }) === - true, - `${job.name} / ${s.name}`, - ); + assert.equal(continues(s, publish), true, `${job.name} / ${s.name}`); } for (const lane of ["code", "security"]) { const total = job.steps.reduce((sum, s) => { @@ -306,20 +324,23 @@ for (const publish of ["summary", "", "anything"]) { }); } -test("comments mode lets the hardening and publish steps fail the job", () => { - for (const name of [ - "Apply the Claude managed settings", - "Isolate the runner", - "Check out", - ]) { - const value = key.steps.find((s) => s.name === name)["continue-on-error"]; - assert.equal(evaluate(value, { inputs: { publish: "comments" } }), false); +// The header's contract: in comments mode the status job goes red, naming the +// class, when no review happened; publish.cjs fails the step for it. +test("comments mode lets the hardening, publish and hold steps fail the job", () => { + for (const job of [key, subscription]) { + for (const name of [ + "Apply the Claude managed settings", + "Isolate the runner", + "Check out", + ]) { + const s = job.steps.find((x) => x.name === name); + assert.equal(continues(s, "comments"), false, `${job.name} / ${name}`); + } + } + for (const name of ["Publish the review", "Apply the hold"]) { + const s = status.steps.find((x) => x.name === name); + assert.equal(continues(s, "comments"), false, name); } - const publish = status.steps.find((s) => s.name === "Publish the review"); - assert.equal( - evaluate(publish["continue-on-error"], { inputs: { publish: "comments" } }), - false, - ); }); test("the shadow report uploads per lane and attempt, overwriting on a rerun", () => { @@ -465,6 +486,7 @@ async function extract(files, env = {}) { fs.writeFileSync(paths[name], content); } const outputs = {}; + const warnings = []; const values = { STRUCTURED_FILE: paths.structured ?? "", LOG_FILE: paths.log ?? "", @@ -488,6 +510,7 @@ async function extract(files, env = {}) { outputs[k] = v; }, info: () => {}, + warning: (message) => warnings.push(message), summary, }); } finally { @@ -496,7 +519,7 @@ async function extract(files, env = {}) { else process.env[k] = v; } } - return outputs; + return Object.assign(outputs, { warnings }); } const LOG = JSON.stringify({ @@ -552,36 +575,6 @@ test("the extract step calls a review incomplete on every gap it checks", async {}, /no summary/u, ], - [ - { - structured: JSON.stringify({ - ...good, - comments: [{ ...COMMENT, side: "BOTH" }], - }), - }, - {}, - /malformed/u, - ], - [ - { - structured: JSON.stringify({ - ...good, - comments: [{ ...COMMENT, path: "/etc/passwd" }], - }), - }, - {}, - /malformed/u, - ], - [ - { - structured: JSON.stringify({ - ...good, - comments: [{ ...COMMENT, start_line: 3 }], - }), - }, - {}, - /malformed/u, - ], ]) { const outputs = await extract(files, env); assert.match(outputs.verdict, reason); @@ -595,6 +588,61 @@ test("the extract step calls a review incomplete on every gap it checks", async assert.equal(outputs.verdict, "complete"); }); +test("the extract step keeps the valid comments and passes a malformed one on for the summary", async () => { + const critical = "Injection.\n\nseverity: CRITICAL"; + for (const bad of [ + { ...COMMENT, side: "BOTH", body: critical }, + { ...COMMENT, path: "/etc/passwd", body: critical }, + { ...COMMENT, start_line: 3, body: critical }, + { ...COMMENT, line: 0, body: critical }, + ]) { + const outputs = await extract({ + structured: JSON.stringify({ + findings: 0, + summary: "S", + comments: [bad, COMMENT], + }), + }); + assert.equal(outputs.verdict, "complete", JSON.stringify(bad)); + const review = JSON.parse(outputs.review); + assert.deepEqual(review.comments, [COMMENT]); + assert.equal(review.malformed.length, 1); + assert.equal(review.malformed[0].body, critical); + // Two findings: the valid comment and the malformed one. + assert.equal(review.findings, 2); + } + // The location is kept only where it is readable. + const located = await extract({ + structured: JSON.stringify({ + findings: 1, + summary: "S", + comments: [ + { path: "a.js", line: 7, side: "BOTH", body: "B" }, + { path: "x\ncritical-findings: 0", line: -1, body: "C" }, + ], + }), + }); + assert.deepEqual(JSON.parse(located.review).malformed, [ + { path: "a.js", line: 7, body: "B" }, + { body: "C" }, + ]); +}); + +test("the extract step drops a comment with no body, with a warning", async () => { + const outputs = await extract({ + structured: JSON.stringify({ + findings: 1, + summary: "S", + comments: [COMMENT, { ...COMMENT, body: " " }, { path: "a.js" }], + }), + }); + assert.equal(outputs.verdict, "complete"); + const review = JSON.parse(outputs.review); + assert.deepEqual(review.comments, [COMMENT]); + assert.equal("malformed" in review, false); + assert.equal(outputs.warnings.length, 2); +}); + // Linux refuses an environment string over 128 KiB (MAX_ARG_STRLEN), and the // status job receives the review as one. test("the extract step passes on a review no larger than one environment string can hold", async () => { @@ -706,10 +754,7 @@ test("the hold step runs only in comments mode with hold-label set, even after a false, ); assert.match(hold.if, /^always\(\) && /u); - assert.equal( - evaluate(hold["continue-on-error"], { inputs: { publish: "comments" } }), - false, - ); + assert.equal(continues(hold, "comments"), false); // Every input reaches the script through env, never an expression in it. for (const name of [ "HOLD_LABEL", diff --git a/.github/scripts/publish-review.test.cjs b/.github/scripts/publish-review.test.cjs index ad2d7b5..3367d07 100644 --- a/.github/scripts/publish-review.test.cjs +++ b/.github/scripts/publish-review.test.cjs @@ -570,3 +570,52 @@ test("an inline batch and a summary with two count lines keep the model's text", ); assert.deepEqual(criticalLines(twice), [0, 1]); }); + +// A comment the extract step could not place (its path, line or side is +// malformed) arrives in `malformed` and goes to the summary as a bullet. +test("a malformed comment beside a valid one posts the valid one inline and the other in the summary", async () => { + const review = { + findings: 2, + summary: "Summary.", + comments: [REVIEW.comments[0]], + malformed: [{ path: "b.js", line: 4, body: "Wrong side." }], + }; + const { writes, log } = await publish({ KEY_REVIEW: JSON.stringify(review) }); + assert.deepEqual(log.failed, []); + const [posted, marker] = writes; + assert.equal(posted[0], "createReview"); + assert.equal(posted[1].comments.length, 1); + assert.equal(posted[1].comments[0].path, "a.js"); + assert.match(marker[1].body, /^- `b\.js:4`: Wrong side\.$/mu); + assert.match(marker[1].body, /2 finding\(s\), 1 as inline comments\./u); + assert.match(log.notices[0], / findings=2 inline=1 /u); +}); + +test("a malformed CRITICAL comment raises the summary's critical-findings line", async () => { + const review = (summary) => ({ + findings: 2, + summary, + comments: [REVIEW.comments[0]], + malformed: [{ body: "Injection.\n\nseverity: CRITICAL" }], + }); + const raised = await publish({ + KEY_REVIEW: JSON.stringify(review("Summary.\n\ncritical-findings: 0")), + }); + const marker = raised.writes.find(([name]) => name === "createComment"); + assert.deepEqual(criticalLines(marker[1].body), [1]); + // With no line, one is added; the malformed finding counts once. + const added = await publish({ + KEY_REVIEW: JSON.stringify(review("Summary.")), + }); + const addedMarker = added.writes.find(([name]) => name === "createComment"); + assert.deepEqual(criticalLines(addedMarker[1].body), [1]); + // A malformed SUGGESTION leaves the line as written. + const suggestion = await publish({ + KEY_REVIEW: JSON.stringify({ + ...review("Summary.\n\ncritical-findings: 0"), + malformed: [{ body: "Naming.\n\nseverity: SUGGESTION" }], + }), + }); + const kept = suggestion.writes.find(([name]) => name === "createComment"); + assert.deepEqual(criticalLines(kept[1].body), [0]); +}); diff --git a/.github/workflows/pr-review-cli.yml b/.github/workflows/pr-review-cli.yml index 26b1564..6852f70 100644 --- a/.github/workflows/pr-review-cli.yml +++ b/.github/workflows/pr-review-cli.yml @@ -103,7 +103,10 @@ name: pr-review-cli # scope=<...>` notice (title `claude-lane-review-complete`), ending # ` account=fallback` when the subscription leg reviewed. When one bad anchor # moves every comment to the summary, the summary's `critical-findings:` line -# counts the moved CRITICAL findings too. Its status job is named `claude-review-status` (code) or +# counts the moved CRITICAL findings too. A comment whose path, line or side +# is malformed goes to the summary the same way, and the other comments still +# post inline; one with no body is dropped with a warning. Its status job is +# named `claude-review-status` (code) or # `security-review` (security) and goes red, naming the class, when no # review happened. Any other value is summary mode: no comment, no review, # no marker and no notice; the status job writes a job summary and uploads @@ -267,9 +270,11 @@ jobs: # sudo and docker before the checkout. run-claude-cli fails closed # without either. - name: Apply the Claude managed settings - continue-on-error: ${{ inputs.publish != 'comments' }} + continue-on-error: ${{ env.PUBLISH != 'comments' }} timeout-minutes: 2 uses: $/.github/actions/apply-claude-managed-settings + env: + PUBLISH: ${{ inputs.publish }} # With CLAUDE_CODE_SUBPROCESS_ENV_SCRUB=1, Claude Code on Linux will not # start without bubblewrap. The AppArmor sysctl is lifted only on @@ -1068,18 +1073,31 @@ jobs: if (!Number.isSafeInteger(raw?.findings) || raw.findings < 0) return incomplete("the review returned no findings count"); if (!summary) return incomplete("the review returned no summary"); if (!Array.isArray(raw.comments) || raw.comments.length > 25) return incomplete("the review returned no comment list"); + // A comment with no body is dropped. One whose path, line, side or + // start_line is malformed goes on in `malformed`, with its path and + // line where readable, and the status job lists it in the summary. const comments = []; + const malformed = []; for (const c of raw.comments) { + if (typeof c?.body !== "string" || c.body.trim() === "") { + core.warning("a review comment with no body was dropped"); + continue; + } const ok = - typeof c?.path === "string" && + typeof c.path === "string" && /^[^/\0][^\0]{0,499}$/u.test(c.path) && Number.isSafeInteger(c.line) && c.line >= 1 && (c.side === "RIGHT" || c.side === "LEFT") && - (c.start_line === undefined || (Number.isSafeInteger(c.start_line) && c.start_line >= 1 && c.start_line < c.line)) && - typeof c.body === "string" && - c.body.trim() !== ""; - if (!ok) return incomplete("a comment's path, line, side or body is malformed"); + (c.start_line === undefined || (Number.isSafeInteger(c.start_line) && c.start_line >= 1 && c.start_line < c.line)); + if (!ok) { + malformed.push({ + ...(typeof c.path === "string" && /^[^\0\r\n`]{1,500}$/u.test(c.path) ? { path: c.path } : {}), + ...(Number.isSafeInteger(c.line) && c.line >= 1 ? { line: c.line } : {}), + body: c.body.slice(0, 3000), + }); + continue; + } comments.push({ path: c.path, line: c.line, @@ -1109,16 +1127,22 @@ jobs: hold = { criteria, reason }; } const flags = summary.match(/^[ \t]*(?:[-*+][ \t]+)?`?manifest \S+: flag/gmu)?.length ?? 0; - const findings = Math.max(raw.findings, comments.length, flags); + const findings = Math.max(raw.findings, comments.length + malformed.length, flags); // The status job receives the review as one environment string, which // Linux caps at 128 KiB (MAX_ARG_STRLEN); the schema's caps keep an // ASCII review near 100 KB, and anything over that is not passed on. - const review = JSON.stringify({ findings, summary, comments, ...(hold ? { hold } : {}) }); + const review = JSON.stringify({ + findings, + summary, + comments, + ...(malformed.length > 0 ? { malformed } : {}), + ...(hold ? { hold } : {}), + }); if (Buffer.byteLength(review, "utf8") > 100000) return incomplete("the review is over 100,000 bytes, too large to pass on"); core.setOutput("review", review); core.setOutput("verdict", "complete"); await core.summary - .addRaw(`### review leg\n\n- Findings: ${findings}; comments: ${comments.length}\n- Turns: ${record.num_turns ?? "?"}\n`) + .addRaw(`### review leg\n\n- Findings: ${findings}; comments: ${comments.length}; malformed: ${malformed.length}\n- Turns: ${record.num_turns ?? "?"}\n`) .write(); - name: Report review outcome @@ -1198,9 +1222,11 @@ jobs: # sudo and docker before the checkout. run-claude-cli fails closed # without either. - name: Apply the Claude managed settings - continue-on-error: ${{ inputs.publish != 'comments' }} + continue-on-error: ${{ env.PUBLISH != 'comments' }} timeout-minutes: 2 uses: $/.github/actions/apply-claude-managed-settings + env: + PUBLISH: ${{ inputs.publish }} # With CLAUDE_CODE_SUBPROCESS_ENV_SCRUB=1, Claude Code on Linux will not # start without bubblewrap. The AppArmor sysctl is lifted only on @@ -1999,18 +2025,31 @@ jobs: if (!Number.isSafeInteger(raw?.findings) || raw.findings < 0) return incomplete("the review returned no findings count"); if (!summary) return incomplete("the review returned no summary"); if (!Array.isArray(raw.comments) || raw.comments.length > 25) return incomplete("the review returned no comment list"); + // A comment with no body is dropped. One whose path, line, side or + // start_line is malformed goes on in `malformed`, with its path and + // line where readable, and the status job lists it in the summary. const comments = []; + const malformed = []; for (const c of raw.comments) { + if (typeof c?.body !== "string" || c.body.trim() === "") { + core.warning("a review comment with no body was dropped"); + continue; + } const ok = - typeof c?.path === "string" && + typeof c.path === "string" && /^[^/\0][^\0]{0,499}$/u.test(c.path) && Number.isSafeInteger(c.line) && c.line >= 1 && (c.side === "RIGHT" || c.side === "LEFT") && - (c.start_line === undefined || (Number.isSafeInteger(c.start_line) && c.start_line >= 1 && c.start_line < c.line)) && - typeof c.body === "string" && - c.body.trim() !== ""; - if (!ok) return incomplete("a comment's path, line, side or body is malformed"); + (c.start_line === undefined || (Number.isSafeInteger(c.start_line) && c.start_line >= 1 && c.start_line < c.line)); + if (!ok) { + malformed.push({ + ...(typeof c.path === "string" && /^[^\0\r\n`]{1,500}$/u.test(c.path) ? { path: c.path } : {}), + ...(Number.isSafeInteger(c.line) && c.line >= 1 ? { line: c.line } : {}), + body: c.body.slice(0, 3000), + }); + continue; + } comments.push({ path: c.path, line: c.line, @@ -2040,16 +2079,22 @@ jobs: hold = { criteria, reason }; } const flags = summary.match(/^[ \t]*(?:[-*+][ \t]+)?`?manifest \S+: flag/gmu)?.length ?? 0; - const findings = Math.max(raw.findings, comments.length, flags); + const findings = Math.max(raw.findings, comments.length + malformed.length, flags); // The status job receives the review as one environment string, which // Linux caps at 128 KiB (MAX_ARG_STRLEN); the schema's caps keep an // ASCII review near 100 KB, and anything over that is not passed on. - const review = JSON.stringify({ findings, summary, comments, ...(hold ? { hold } : {}) }); + const review = JSON.stringify({ + findings, + summary, + comments, + ...(malformed.length > 0 ? { malformed } : {}), + ...(hold ? { hold } : {}), + }); if (Buffer.byteLength(review, "utf8") > 100000) return incomplete("the review is over 100,000 bytes, too large to pass on"); core.setOutput("review", review); core.setOutput("verdict", "complete"); await core.summary - .addRaw(`### review leg\n\n- Findings: ${findings}; comments: ${comments.length}\n- Turns: ${record.num_turns ?? "?"}\n`) + .addRaw(`### review leg\n\n- Findings: ${findings}; comments: ${comments.length}; malformed: ${malformed.length}\n- Turns: ${record.num_turns ?? "?"}\n`) .write(); - name: Report review outcome @@ -2076,7 +2121,9 @@ jobs: # The one job that writes, and no lane record (each leg writes its own). # No model runs here and nothing is checked out. Every value arrives - # through env. + # through env. A `uses: $/...` step reads publish from its own env in + # continue-on-error: once the composite has run, the runner evaluates that + # with the action's inputs, not the workflow's. status: name: ${{ inputs.lane == 'security' && 'security-review' || 'claude-review-status' }} needs: [review-key, review-subscription] @@ -2119,7 +2166,7 @@ jobs: core.info(`publish gate from claude-code-plugins@${tip}`); - name: Publish the review - continue-on-error: ${{ inputs.publish != 'comments' }} + continue-on-error: ${{ env.PUBLISH != 'comments' }} timeout-minutes: 5 uses: $/.github/actions/publish-review env: @@ -2168,10 +2215,11 @@ jobs: - name: Apply the hold id: hold if: always() && inputs.hold-label != '' && inputs.publish == 'comments' - continue-on-error: ${{ inputs.publish != 'comments' }} + continue-on-error: ${{ env.PUBLISH != 'comments' }} timeout-minutes: 2 uses: $/.github/actions/apply-review-hold env: + PUBLISH: ${{ inputs.publish }} HOLD_LABEL: ${{ inputs.hold-label }} HOLD_PATHS: ${{ inputs.hold-paths }} LANE_ID: ${{ inputs.lane == 'security' && 'claude-security-review' || 'claude-review' }}