diff --git a/.github/actions/apply-review-hold/hold.cjs b/.github/actions/apply-review-hold/hold.cjs index 2195a14..8210f53 100644 --- a/.github/actions/apply-review-hold/hold.cjs +++ b/.github/actions/apply-review-hold/hold.cjs @@ -1,6 +1,6 @@ // pr-review-cli's hold step. No model runs here. It reads, from env: // HOLD_LABEL, HOLD_PATHS (newline-separated globs), LANE_ID, PR_NUMBER, -// HEAD_SHA, PUBLISH_GATE, PR_FILES (the scope step's file list), +// HEAD_SHA, BASE_REF (the pull request's base branch), PUBLISH_GATE, PR_FILES (the scope step's file list), // TRUSTED_AUTHOR_IDS (the people whose cost decision counts), and the // legs' KEY_* and SUB_* outputs that publish-review's judge reads. It only adds: the label and one marker // comment. Every write goes through issues.addLabels or issues.createComment. @@ -66,9 +66,10 @@ function modelHold(env) { // A cost decision: an unedited comment by a trusted person whose first line // opens `**Decision recorded.** Cost:` (claude-code-plugins@eda837d4c8 // .github/scripts/pr-merge/merge-holds.mjs:422 DECISION_LINE, read after -// trimStart as at :470; author, App and edit checks as at :466-476). It binds to one pull request and covers every head -// of it: one on the pull request counts, and so does one on its linked issue -// that names `#` and is newer than the pull request. +// trimStart as at :470; author, App and edit checks as at :466-476). It binds +// to one pull request: one on the pull request counts, and so does one on its +// linked issue that names `#` and is newer than the pull request. It +// covers only the net diff it was made at (carryDecision). const DECISION_LINE = /^\*\*Decision recorded\.\*\*[ \t]+Cost:/u; const PULL_QUERY = `query($owner: String!, $name: String!, $number: Int!) { repository(owner: $owner, name: $name) { @@ -142,9 +143,10 @@ const namesPull = (text, number) => String(text ?? ""), ); -// Whether a trusted person recorded a cost decision for pull request -// `number`. `prComments` is the pull request's comment list. Reads only -// when a cost hold would be added; a read that fails decides nothing. +// The newest unedited cost decision a trusted person recorded for pull +// request `number`, or null: the pull request's own first, else its linked +// issue's. `prComments` is the pull request's comment list. Reads only when a +// cost hold would be added; a read that fails decides nothing. async function costDecided(github, owner, repo, number, env, prComments) { const trusted = new Set( String(env.TRUSTED_AUTHOR_IDS ?? "") @@ -153,7 +155,7 @@ async function costDecided(github, owner, repo, number, env, prComments) { .filter((id) => /^[1-9][0-9]*$/u.test(id)) .map(Number), ); - if (trusted.size === 0) return false; + if (trusted.size === 0) return null; const candidate = (c) => c?.user?.type === "User" && c.performed_via_github_app == null && @@ -168,21 +170,28 @@ async function costDecided(github, owner, repo, number, env, prComments) { return false; } }; - for (const c of prComments.filter(candidate)) - if (await unedited(c)) return true; + const newest = async (list) => { + for (const c of list + .filter(candidate) + .sort((a, b) => Date.parse(b.created_at) - Date.parse(a.created_at))) + if (await unedited(c)) return c; + return null; + }; + const own = await newest(prComments); + if (own) return own; try { const pull = ( await github.graphql(PULL_QUERY, { owner, name: repo, number }) )?.repository?.pullRequest; const issue = linkedIssue(pull, owner, repo); - if (!issue) return false; + if (!issue) return null; if (issue.refs) { const read = ( await github.rest.issues.get({ owner, repo, issue_number: issue.number }) ).data; // An issue whose number is the Refs number (merge-holds.mjs:484 at // #9569's head 6c3b6ae9), never a pull request. - if (read?.number !== issue.number || read.pull_request != null) return false; + if (read?.number !== issue.number || read.pull_request != null) return null; } const opened = Date.parse(pull.createdAt); const comments = await github.paginate(github.rest.issues.listComments, { @@ -191,17 +200,96 @@ async function costDecided(github, owner, repo, number, env, prComments) { issue_number: issue.number, per_page: 100, }); - for (const c of comments.filter( - (c) => - candidate(c) && - Date.parse(c.created_at) > opened && - namesPull(c.body, number), - )) - if (await unedited(c)) return true; + return await newest( + comments.filter( + (c) => Date.parse(c.created_at) > opened && namesPull(c.body, number), + ), + ); } catch { // Nothing read, nothing decided: the cost hold stays. } - return false; + return null; +} + +// GitHub's compare lists at most 300 files +// (https://docs.github.com/en/rest/commits/commits#compare-two-commits). +const MAX_COMPARE_FILES = 300; +const BRANCH = /^(?!-)(?!.*\.\.)[A-Za-z0-9._/-]{1,255}$/u; + +// Whether `decision` covers the head: { carry, why }. The decided head is +// the sha of this lane's newest cost hold marker posted before the decision. +// The decision carries when the head is that sha, or when the pull request's +// net diff against its base branch is the same at both: compare +// base...decided and base...head (three-dot, so each is the diff from its +// merge base) list the same files, each with the same status, +// previous_filename and patch text. So a base-only merge or a rebase that +// leaves every patch as it was carries, and any other change does not. At +// most two compare reads; any failure, a missing patch, or a list of 300 or +// more files carries nothing. +async function carryDecision(github, owner, repo, env, prComments, decision) { + const cutoff = Date.parse(decision.created_at); + if (!Number.isFinite(cutoff)) return { carry: false, why: "the decision has no time" }; + const markerLine = new RegExp( + `^(?:\n|$)`, + "u", + ); + let decided = null; + let at = -Infinity; + for (const c of prComments) { + if (c?.user?.login !== "github-actions[bot]") continue; + const m = markerLine.exec(String(c.body ?? "")); + const t = Date.parse(c.created_at); + if (!m || !m[2].split(",").includes("cost")) continue; + if (!(t < cutoff) || t <= at) continue; + decided = m[1]; + at = t; + } + if (!decided) + return { carry: false, why: "no cost hold marker of this lane precedes the decision" }; + if (decided === env.HEAD_SHA) + return { carry: true, why: `the head is the decided head ${decided}` }; + const base = String(env.BASE_REF ?? ""); + if (!BRANCH.test(base)) return { carry: false, why: "the base branch name is not usable" }; + const files = async (sha) => { + const data = ( + await github.rest.repos.compareCommitsWithBasehead({ + owner, + repo, + basehead: `${base}...${sha}`, + }) + ).data; + const list = data?.files; + if (!Array.isArray(list) || list.length >= MAX_COMPARE_FILES) return null; + const byName = new Map(); + for (const f of list) { + if (typeof f?.filename !== "string" || typeof f.patch !== "string") return null; + if (byName.has(f.filename)) return null; + byName.set(f.filename, [f.status, f.previous_filename ?? null, f.patch]); + } + return byName; + }; + let then; + let now; + try { + then = await files(decided); + now = await files(env.HEAD_SHA); + } catch { + return { carry: false, why: "a compare read failed" }; + } + if (!then || !now) + return { + carry: false, + why: `a compare listed ${MAX_COMPARE_FILES} or more files, or a file without a patch`, + }; + const same = + then.size === now.size && + [...then].every(([name, v]) => { + const w = now.get(name); + return w !== undefined && v.every((x, i) => x === w[i]); + }); + return same + ? { carry: true, why: `the net diff against ${base} is the same as at the decided head ${decided}` } + : { carry: false, why: `the net diff against ${base} differs from the decided head ${decided}` }; } // trust-gate when a changed file, or a renamed file's old path, matches a @@ -275,15 +363,20 @@ async function run({ github, context, core, env = process.env }) { return prComments; }; if (model?.criteria.includes("cost")) { - let decided = false; + let result = { carry: false, why: "no recorded cost decision" }; try { - decided = await costDecided(github, owner, repo, number, env, await listPrComments()); + const comments = await listPrComments(); + const decision = await costDecided(github, owner, repo, number, env, comments); + if (decision) + result = await carryDecision(github, owner, repo, env, comments, decision); } catch { - // The comments could not be listed: the cost hold stays. + result = { carry: false, why: "a read failed" }; } - if (decided) { - core.info(`${lane}: a recorded cost decision covers this pull request; the cost hold is not added again.`); + if (result.carry) { + core.info(`${lane}: the recorded cost decision carries (${result.why}); the cost hold is not added again.`); model.criteria = model.criteria.filter((c) => c !== "cost"); + } else { + core.info(`${lane}: no recorded cost decision carries (${result.why}); the cost hold stays.`); } } if (model && model.criteria.length > 0) { diff --git a/.github/scripts/apply-review-hold.test.cjs b/.github/scripts/apply-review-hold.test.cjs index 09c024e..8f733f4 100644 --- a/.github/scripts/apply-review-hold.test.cjs +++ b/.github/scripts/apply-review-hold.test.cjs @@ -45,7 +45,8 @@ const TRUST_PATHS = [ // issue number to its comments; `pull` is the GraphQL pull request; // `edited` holds the node ids of edited comments; `pullNumbers` the numbers // that are pull requests, not issues; `misnumbered` the numbers whose read -// answers another number. +// answers another number; `compares` maps a basehead to its compare +// response data. function fakeGithub({ files = [], changedFiles, @@ -60,6 +61,7 @@ function fakeGithub({ edited = new Set(), pullNumbers = new Set([7]), misnumbered = new Set(), + compares = {}, }) { const calls = []; const refuse = (name) => () => { @@ -81,6 +83,15 @@ function fakeGithub({ }, paginate: async (method, params) => method(params), rest: { + repos: { + compareCommitsWithBasehead: async (params) => { + calls.push(["compare", params]); + if (fail.compare) throw new Error("HTTP 404"); + if (!(params.basehead in compares)) + throw new Error(`unexpected compare ${params.basehead}`); + return { data: compares[params.basehead] }; + }, + }, pulls: { listFiles: (params) => { calls.push(["listFiles", params]); @@ -134,13 +145,13 @@ function fakeGithub({ } function fakeCore() { - const core = { outputs: {}, warnings: [], notices: [] }; + const core = { outputs: {}, warnings: [], notices: [], infos: [] }; core.setOutput = (k, v) => { core.outputs[k] = v; }; core.warning = (m) => core.warnings.push(m); core.notice = (m) => core.notices.push(m); - core.info = () => {}; + core.info = (m) => core.infos.push(m); return core; } @@ -153,6 +164,7 @@ const baseEnv = (extra = {}) => ({ LANE_ID: "claude-review", PR_NUMBER: "7", HEAD_SHA: HEAD, + BASE_REF: "main", PUBLISH_GATE: gate, SUB_RESULT: "skipped", KEY_PRESENT: "true", @@ -408,7 +420,8 @@ test("a warn-only hit in the reason posts it with a warning", async () => { ]); }); -// A cost decision binds to the pull request and covers every head of it. +// A cost decision binds to the pull request and covers the net diff it was +// made at: the head of this lane's newest cost hold marker before it. const decision = ({ id = 1, userId = OWNER_ID, @@ -430,6 +443,21 @@ const costEnv = (criteria = ["cost"], extra = {}) => KEY_REVIEW: review({ hold: { criteria, reason: "It adds a schedule." } }), ...extra, }); +// This lane's hold marker, posted by github-actions[bot] before the decision. +const marker = ({ + id = 100, + sha = HEAD, + criteria = "cost", + lane = "claude-review", + createdAt = "2026-10-23T11:00:00Z", + login = "github-actions[bot]", +} = {}) => ({ + id, + node_id: `IC_${id}`, + user: { login, type: "Bot" }, + created_at: createdAt, + body: `\n**${lane}** holds this pull request.`, +}); const refsPull = (body) => ({ createdAt: OPENED, body, @@ -438,13 +466,14 @@ const refsPull = (body) => ({ test("a cost decision on the pull request keeps the cost hold from coming back", async () => { const { core, error, writes, github } = await apply(costEnv(), { - comments: [decision()], + comments: [marker(), decision()], }); assert.equal(error, undefined); assert.equal(core.outputs.hold, "false"); assert.deepEqual(writes, []); // The lookup's reads, the pull request's comments and one edit check, - // then hold-paths' own file list. + // then hold-paths' own file list: the head is the decided head, so no + // compare. assert.deepEqual( github.calls.map(([name]) => name), ["listComments", "graphql", "listFiles", "get"], @@ -453,6 +482,7 @@ test("a cost decision on the pull request keeps the cost hold from coming back", test("a cost decision on the Refs issue that names the pull request keeps the cost hold off", async () => { const { core, writes, github } = await apply(costEnv(), { + comments: [marker()], pull: refsPull("Refs: #9436\n\n## Summary"), issueComments: { 9436: [ @@ -478,6 +508,7 @@ test("a cost decision on the Refs issue that names the pull request keeps the co test("a cost decision on the closing issue that names the pull request keeps the cost hold off", async () => { const { core } = await apply(costEnv(), { + comments: [marker()], pull: { createdAt: OPENED, body: "Closes #9436", @@ -585,18 +616,18 @@ test("an edited, quoted, older or non-cost decision still holds", async () => { test("a trust-gate hold still holds when a cost decision exists", async () => { const both = await apply(costEnv(["cost", "trust-gate"]), { - comments: [decision()], + comments: [marker(), decision()], }); assert.equal(both.core.outputs.hold, "true"); assert.equal(both.core.outputs.criteria, "trust-gate"); const byPath = await apply(costEnv(), { - comments: [decision()], + comments: [marker(), decision()], files: ["CODEOWNERS"], }); assert.equal(byPath.core.outputs.criteria, "trust-gate"); assert.equal(byPath.writes.length, 2); const security = await apply(costEnv(["cost", "critical-security"]), { - comments: [decision()], + comments: [marker(), decision()], }); assert.equal(security.core.outputs.criteria, "critical-security"); }); @@ -631,23 +662,241 @@ test("a decision names the pull request as # the way merge-holds reads i assert.equal(namesPull(text, 7), named, text); }); -// #9436: the owner lifted the cost hold at head A; a base-only merge makes -// head B, and the review holds for cost again. The decision covers B. -test("a cost decision recorded at an earlier head keeps the cost hold off at a later one", async () => { - const later = "b".repeat(40); - const atA = decision({ - body: `**Decision recorded.** Cost: approve the schedule at ${HEAD}.`, +// #9436, option d: the owner lifted the cost hold at head A (the decided +// head, from this lane's cost marker before the decision); at head B the +// review holds for cost again. The decision carries only when compare +// main...A and main...B list the same files with the same status, +// previous_filename and patch. The fixtures are hand-written compare +// responses in GitHub's shape (docs.github.com/en/rest/commits/commits +// #compare-two-commits: files[].filename, status, previous_filename, patch). +const LATER = "b".repeat(40); +const APP_PATCH = + "@@ -10,3 +10,4 @@ function main() {\n start();\n+ log();\n stop();"; +const WORKFLOW_PATCH = + "@@ -3,3 +3,3 @@ on:\n concurrency:\n- group: old\n+ group: new"; +const prFile = (extra = {}) => ({ + filename: "src/app.js", + status: "modified", + sha: "1".repeat(40), + patch: APP_PATCH, + ...extra, +}); +const workflowFile = (extra = {}) => ({ + filename: ".github/workflows/ci.yml", + status: "modified", + sha: "2".repeat(40), + patch: WORKFLOW_PATCH, + ...extra, +}); +const atDecided = [prFile(), workflowFile()]; +const carryCase = (laterFiles, options = {}) => + apply(costEnv(["cost"], { HEAD_SHA: LATER }), { + comments: [marker(), decision()], + compares: { + [`main...${HEAD}`]: { status: "ahead", files: atDecided }, + [`main...${LATER}`]: { status: "ahead", files: laterFiles }, + }, + ...options, }); - const { core, writes } = await apply(costEnv(["cost"], { HEAD_SHA: later }), { - comments: [ - atA, - { - id: 2, - user: { login: "github-actions[bot]", type: "Bot" }, - body: ``, +const compares = (github) => + github.calls + .filter(([name]) => name === "compare") + .map(([, p]) => p.basehead); + +test("a base-only merge that leaves the pull request's patches alone carries the decision", async () => { + // The base changed src/app.js below the hunk, so the blob sha moved and + // the patch did not. + const { core, writes, github } = await carryCase([ + prFile({ sha: "3".repeat(40) }), + workflowFile(), + ]); + assert.equal(core.outputs.hold, "false"); + assert.deepEqual(writes, []); + assert.deepEqual(compares(github), [`main...${HEAD}`, `main...${LATER}`]); + assert.match( + core.infos.join("\n"), + /the recorded cost decision carries \(the net diff against main is the same/u, + ); +}); + +test("a rebase onto a newer base with the same patches carries the decision", async () => { + const renamedThen = { + filename: "lib/b.js", + previous_filename: "lib/a.js", + status: "renamed", + patch: "@@ -1 +1 @@\n-a\n+b", + }; + const { core } = await apply(costEnv(["cost"], { HEAD_SHA: LATER }), { + comments: [marker(), decision()], + compares: { + [`main...${HEAD}`]: { status: "ahead", files: [renamedThen, prFile()] }, + // Listed in another order, with new blob shas: still the same diff. + [`main...${LATER}`]: { + status: "ahead", + files: [ + prFile({ sha: "4".repeat(40) }), + { ...renamedThen, sha: "5".repeat(40) }, + ], }, - ], + }, }); assert.equal(core.outputs.hold, "false"); - assert.deepEqual(writes, []); +}); + +test("the #9509 shape, a later cancel-in-progress edit, keeps the cost hold", async () => { + const { core, writes } = await carryCase([ + prFile(), + workflowFile({ + patch: `${WORKFLOW_PATCH}\n@@ -5,1 +5,1 @@\n- cancel-in-progress: false\n+ cancel-in-progress: true`, + }), + ]); + assert.equal(core.outputs.hold, "true"); + assert.equal(core.outputs.criteria, "cost"); + assert.equal(writes.length, 2); + assert.match( + core.infos.join("\n"), + /no recorded cost decision carries \(the net diff against main differs/u, + ); +}); + +test("a merge that hand-edits the pull request's own lines keeps the cost hold", async () => { + const { core } = await carryCase([ + prFile({ patch: APP_PATCH.replace("+ log();", "+ log(resolved);") }), + workflowFile(), + ]); + assert.equal(core.outputs.hold, "true"); + assert.match(core.infos.join("\n"), /differs from the decided head/u); +}); + +test("a base change that shifts a patch's context keeps the cost hold", async () => { + const { core } = await carryCase([ + prFile({ + patch: APP_PATCH.replace("@@ -10,3 +10,4 @@", "@@ -12,3 +12,4 @@"), + }), + workflowFile(), + ]); + assert.equal(core.outputs.hold, "true"); + assert.match(core.infos.join("\n"), /differs from the decided head/u); +}); + +test("an added or dropped file keeps the cost hold", async () => { + for (const files of [ + [prFile()], + [...atDecided, prFile({ filename: "src/extra.js" })], + [prFile({ status: "added" }), workflowFile()], + ]) { + const { core } = await carryCase(files); + assert.equal( + core.outputs.hold, + "true", + JSON.stringify(files.map((f) => f.filename)), + ); + } +}); + +test("a file with no patch, or a list of 300 or more files, keeps the cost hold", async () => { + const binary = { + filename: "logo.png", + status: "modified", + sha: "6".repeat(40), + }; + const many = Array.from({ length: 300 }, (_, i) => + prFile({ filename: `f${i}.js` }), + ); + for (const [name, then, later] of [ + ["no patch at either head", [...atDecided, binary], [...atDecided, binary]], + ["300 files", many, many], + ]) { + const { core } = await apply(costEnv(["cost"], { HEAD_SHA: LATER }), { + comments: [marker(), decision()], + compares: { + [`main...${HEAD}`]: { status: "ahead", files: then }, + [`main...${LATER}`]: { status: "ahead", files: later }, + }, + }); + assert.equal(core.outputs.hold, "true", name); + assert.match( + core.infos.join("\n"), + /300 or more files, or a file without a patch/u, + name, + ); + } +}); + +test("a failed compare keeps the cost hold", async () => { + const { core, error, github } = await carryCase(atDecided, { + fail: { compare: true }, + }); + assert.equal(error, undefined); + assert.equal(core.outputs.hold, "true"); + assert.equal(compares(github).length, 1); + assert.match(core.infos.join("\n"), /a compare read failed/u); +}); + +test("a decision with no cost marker of this lane before it keeps the cost hold, even at the same head", async () => { + for (const [name, comments] of [ + ["no marker", [decision()]], + [ + "a marker after the decision", + [marker({ createdAt: "2026-10-23T13:00:00Z" }), decision()], + ], + [ + "another lane's marker", + [marker({ lane: "claude-security-review" }), decision()], + ], + ["a marker without cost", [marker({ criteria: "trust-gate" }), decision()]], + ["a marker by someone else", [marker({ login: "someone" }), decision()]], + ]) { + const { core, github } = await apply(costEnv(), { comments }); + assert.equal(core.outputs.hold, "true", name); + assert.equal(compares(github).length, 0, name); + assert.match( + core.infos.join("\n"), + /no cost hold marker of this lane precedes the decision/u, + name, + ); + } +}); + +test("the decided head is the newest cost marker before the newest decision", async () => { + const older = "c".repeat(40); + const { github } = await carryCase(atDecided, { + comments: [ + marker({ id: 101, sha: older, createdAt: "2026-10-23T10:30:00Z" }), + marker({ id: 102, sha: HEAD, criteria: "cost,trust-gate" }), + marker({ + id: 103, + sha: older, + criteria: "trust-gate", + createdAt: "2026-10-23T11:30:00Z", + }), + decision({ id: 1, createdAt: "2026-10-23T10:45:00Z" }), + decision({ id: 2 }), + ], + }); + assert.deepEqual(compares(github), [`main...${HEAD}`, `main...${LATER}`]); +}); + +test("an unusable base branch name reads no compare and keeps the cost hold", async () => { + const { core, github } = await apply( + costEnv(["cost"], { HEAD_SHA: LATER, BASE_REF: "main..x" }), + { comments: [marker(), decision()] }, + ); + assert.equal(core.outputs.hold, "true"); + assert.equal(compares(github).length, 0); +}); + +test("the carry check reads at most two compares and nothing else", async () => { + const { github } = await carryCase(atDecided); + const names = github.calls.map(([name]) => name); + // The pull request's comments, one edit check, two compares, then + // hold-paths' own file list. + assert.deepEqual(names, [ + "listComments", + "graphql", + "compare", + "compare", + "listFiles", + "get", + ]); }); diff --git a/.github/workflows/pr-review-cli.yml b/.github/workflows/pr-review-cli.yml index 5196b21..9d043f4 100644 --- a/.github/workflows/pr-review-cli.yml +++ b/.github/workflows/pr-review-cli.yml @@ -103,12 +103,23 @@ name: pr-review-cli # file list is the scope step's at the head; without it (scope failed, # the head moved, or the list was too large) the step reads it. It # only adds, through the same pull-requests: write grant, and fails the -# job when a write fails. A cost criterion is not added again once a +# job when a write fails. A cost criterion is not added again when a # person in allowed-author-ids recorded an unedited comment opening # `**Decision recorded.** Cost:` on the pull request, or on its one # closing issue (else its one `Refs:` issue) naming `#` after -# the pull request opened; the other criteria still hold. The reason is model text: HTML comments are -# stripped and the publish gate withholds a credential-shaped one. +# the pull request opened, and that decision covers the head. The +# decided head is the sha of this lane's newest cost hold marker posted +# before the decision; with none, nothing carries. It covers the head +# when the head is that sha, or when the pull request's net diff is the +# same at both: the three-dot compares base...decided and base...head +# list the same files with the same status, previous_filename and patch +# text. A base-only merge or a rebase that leaves every patch alone +# carries; any edit of the pull request's own diff, or a base change +# that shifts a patch's context, does not. Two compare reads at most; +# a failed read, a file with no patch or a list of 300 or more files +# keeps the cost hold. The other criteria still hold. The reason is +# model text: HTML comments are stripped and the publish gate withholds +# a credential-shaped one. # # REVIEW CADENCE: as pr-review.yml's header states, with this lane's marker: # whole pull request on open, reopen and ready; after a push, the files @@ -203,7 +214,8 @@ on: Comma-separated numeric GitHub user ids, no spaces, of people whose closing issues the review reads in full, as pr-review.yml's input, and whose recorded cost decision keeps the hold step from adding - the cost hold again on a later head. + the cost hold again on a later head whose net diff against the + base is the one decided. type: string default: "" hold-label: @@ -2502,6 +2514,7 @@ jobs: LANE_ID: ${{ inputs.lane == 'security' && 'claude-security-review' || 'claude-review' }} PR_NUMBER: ${{ github.event.pull_request.number }} HEAD_SHA: ${{ github.event.pull_request.head.sha }} + BASE_REF: ${{ github.event.pull_request.base.ref }} PUBLISH_GATE: ${{ steps.gate.outputs.path }} PR_FILES: ${{ needs.review-key.outputs.pr-files }} KEY_LANE_CLASS: ${{ needs.review-key.outputs.lane-class }}