diff --git a/.github/actions/resolve-config/README.md b/.github/actions/resolve-config/README.md index 5b9509f90d2..632dc99b61d 100644 --- a/.github/actions/resolve-config/README.md +++ b/.github/actions/resolve-config/README.md @@ -11,7 +11,7 @@ effort, turn budget) and the App token grant. The config's contract is the |---|---|---| | `lane` | empty | The lane, its workflow's file stem; required unless `model-route` is set | | `activity` | empty | The activity to select from the lane's slots | -| `model-route` | empty | A run that is no lane activity (`verify-pr`, `review-code`, `review-security`, `triage-issue`): resolve only its models profile. Set it or `lane`, not both | +| `model-route` | empty | A run that is no lane activity (`verify-pr`, `review-code`, `review-security`, `triage-issue`, `summarize-reuse`): resolve only its models profile. Set it or `lane`, not both | | `base-path` | `.base` | The base-SHA checkout; every config, schema and vocabulary read is under it | | `config-path` | `docs/conventions/pr-pipeline.yaml` | The config, relative to `base-path`; for test callers only | | `output-path` | required | Where the `ResolvedConfig` JSON file is written | @@ -59,7 +59,7 @@ The step fails and writes no file when: does not. 13. `models.default-profile` or a route names a profile `models.profiles` does not define (`undefined-profile`), or a route key is neither a skill activity nor a run route - (`verify-pr`, `review-code`, `review-security`, `triage-issue`; `undefined-route`), so a mistyped route cannot quietly take the default. + (`verify-pr`, `review-code`, `review-security`, `triage-issue`, `summarize-reuse`; `undefined-route`), so a mistyped route cannot quietly take the default. It also fails when a predicate it decides cannot be decided: `pulls/{n}/files` lists 3000 files or fewer than the PR's `changed_files`, a predicate needs the PR and there is no `pr-number`, a diff --git a/.github/actions/resolve-config/models.test.mjs b/.github/actions/resolve-config/models.test.mjs index a85bdcf0311..dbd8cd3d7fa 100644 --- a/.github/actions/resolve-config/models.test.mjs +++ b/.github/actions/resolve-config/models.test.mjs @@ -109,6 +109,8 @@ for (const [route, profile, args] of [ ["review-code", "pr-review", "--model sonnet --max-turns 75 --effort high"], ["review-security", "pr-review", "--model sonnet --max-turns 75 --effort high"], ["triage-issue", "triage", "--model sonnet --max-turns 30 --effort medium"], + // Owner decision: the reuse summary is a short read-only pass at a low turn budget. + ["summarize-reuse", "summary", "--model sonnet --max-turns 10 --effort medium"], ]) { test(`live config: ${route} resolves ${args}`, () => { const models = resolveRoute(LIVE, { route, configPath: CONFIG_PATH }); @@ -219,7 +221,7 @@ test("a route naming a script activity or nothing known is undefined-route", () assertRejected(() => resolveRoute(LIVE, { route: "fix-docs", configPath: CONFIG_PATH }), "undefined-route"); }); -test("a run route that is not one of the four is undefined-route, as a model-route and as a config route", () => { +test("a run route that is not one of the five is undefined-route, as a model-route and as a config route", () => { assertRejected(() => resolveRoute(LIVE, { route: "review-docs", configPath: CONFIG_PATH }), "undefined-route"); const profiles = { p: { model: "opus", "max-turns": 5 } }; assertRejected( diff --git a/.github/actions/resolve-config/resolve.mjs b/.github/actions/resolve-config/resolve.mjs index e64e2b5ddb2..83f2ed649f9 100644 --- a/.github/actions/resolve-config/resolve.mjs +++ b/.github/actions/resolve-config/resolve.mjs @@ -17,7 +17,7 @@ const TRUSTED_ACTORS_PATH = ".github/standards/trusted-actors/trusted-actors.json"; // Model routes that are no activity: pr-verify.yml's judgment, the code and // security review lanes, and intake triage. -const RUN_ROUTES = new Set(["verify-pr", "review-code", "review-security", "triage-issue"]); +const RUN_ROUTES = new Set(["verify-pr", "review-code", "review-security", "triage-issue", "summarize-reuse"]); // The profile every run takes in a config with no `models` block. const BUILTIN_MODELS = Object.freeze({ profiles: { standard: { model: "default", "max-turns": 75 } }, diff --git a/.github/scripts/work-resolve-issue/report-outcome.mjs b/.github/scripts/work-resolve-issue/report-outcome.mjs index 12800ada341..58c4d2a2ad9 100755 --- a/.github/scripts/work-resolve-issue/report-outcome.mjs +++ b/.github/scripts/work-resolve-issue/report-outcome.mjs @@ -24,11 +24,13 @@ // limit before it could report): comments once with the // limited marker naming when the limit resets, keeps agent-ready // and adds no label, so work-unblock restarts it after the reset. -// reused the resolve-issue run skipped the model for a lane branch an +// reused the resolve-issue run skipped the work for a lane branch an // earlier run left with commits and no report (REUSE_BRANCH): // the earlier outcome counts only when this run's artifact names // that branch (a re-run attempt); with none readable, a held PR -// with fixed text only opens from it, whatever the limit record. +// opens from it, whatever the limit record. It carries the +// read-only summary pass's summary when the summary names this +// issue, the branch and its current tip; else fixed text only. // // Run from the base copy. It reads only the outcome directory and the GitHub // API, and never writes to a branch. @@ -46,6 +48,8 @@ // untrusted) and lane.json (written by the runner: the // lane branch, and the run's limit `class` and `resets`) // REUSE_BRANCH the lane branch --find-prior chose, or empty +// REUSE_SUMMARY_DIR on a reuse, holds the summary pass's reuse-summary.json +// (runner-written issue, branch and tip; model output) // TRACKER_CONFIG base .work-item-tracker.json, for the human-gated label // RESOLVED_CONFIG resolve-config's output for this run (default // $RUNNER_TEMP/resolved-config.json), for the denied paths @@ -218,10 +222,13 @@ export function readOutcome(path) { // body as plain text, so `#` becomes a full-width #, `GH-` and `/issues/` or // `/pull/` gain a space, and no closing keyword in it can close an issue. // `://` and `www.` gain a space too, so no bare URL autolinks. -// Control characters other than newline go. The cap applies after escaping. -const neutral = (value, cap) => - value - .replaceAll(/|$)/g, "") +// Control characters other than newline go, and so do format characters +// (bidi overrides, zero-width), which can make text read other than it is. +// The cap applies after escaping. A file path keeps its comments, escaped, +// so the whole name shows. +const neutral = (value, cap, { path = false } = {}) => + (path ? value : value.replaceAll(/|$)/g, "")) + .replaceAll(/\p{Cf}/gu, "") .replaceAll("\r\n", "\n") .replaceAll(/[\u0000-\u0009\u000b-\u001f\u007f]/g, " ") .replaceAll("&", "&") @@ -309,10 +316,86 @@ function limitedPlan({ issueNumber, branch, resets, runId, runUrl }) { return { action: "limited", exit: "limited", issueNumber, resets, runId, comment: lines.join("\n"), addLabels: [], removeLabels: [] }; } +// The summary pass's file for a reused branch, or null unless it is bound to +// this issue and branch, names the tip it read, and holds a non-empty summary. +// issue, branch and tip are runner values; output is the model's, read as data. +export function readSummary(dir, { issueNumber, branch }) { + let parsed; + try { + parsed = JSON.parse(readFileSync(join(dir, "reuse-summary.json"), "utf8")); + } catch { + return null; + } + const output = parsed?.output; + if ( + parsed?.version !== 1 || + parsed.issue !== issueNumber || + parsed.branch !== branch || + !/^[0-9a-f]{40}$/.test(parsed.tip ?? "") || + output === null || + typeof output !== "object" || + Array.isArray(output) + ) { + return null; + } + const summary = text(output.summary); + return summary.trim() ? { tip: parsed.tip, title: text(output.title), summary, changes: list(output.changes) } : null; +} + +// The files a reused branch changes, from the runner's GET /compare (null +// when it failed), with each one the merge lane's denied paths cover marked +// and listed first. File names come from the branch, so they are data. +const MAX_FILES = 50; +function fileList(files, holdPaths) { + if (!files) { + return "The changed-file list is unavailable: the compare request failed."; + } + if (!files.length) { + return "The compare listing names no changed files."; + } + const denied = (path) => holdPaths?.some((glob) => posix.matchesGlob(path, glob)) ?? false; + const sorted = [...files.filter(denied), ...files.filter((path) => !denied(path))]; + return [ + holdPaths + ? "Files the branch changes, from the compare listing; a denied path, which the merge lane refuses, is marked and listed first:" + : "Files the branch changes, from the compare listing; the denied paths could not be read, so none is marked:", + "", + ...sorted + .slice(0, MAX_FILES) + .map((path) => `- ${neutral(path.replaceAll(/\s+/g, " "), MAX_ITEM, { path: true }).trim()}${denied(path) ? " (denied path)" : ""}`), + ...(sorted.length > MAX_FILES ? [`- and ${sorted.length - MAX_FILES} more`] : []), + ...(files.length >= COMPARE_FILES_CAP ? ["", `The compare listing stops at ${COMPARE_FILES_CAP} files, so this list may be incomplete.`] : []), + ].join("\n"); +} + // A lane branch an earlier run left with commits and no report, whose outcome -// this run cannot read: a PR a human merges, titled from the issue, with no -// model text. -export function reusePlan({ issueNumber, issueTitle = "", branch, runUrl = "", earlierRunUrl = "" }) { +// this run cannot read: a PR a human merges, listing the files it changes. +// With the summary pass's summary it carries that text; without it, titled +// from the issue, no model text. +export function reusePlan({ + issueNumber, + issueTitle = "", + branch, + runUrl = "", + earlierRunUrl = "", + summary = null, + changedFiles = null, + holdPaths = null, +}) { + const opened = `Opened by the work-resolve-issue lane from \`${branch}\`, which an earlier run left with commits and no report.`; + const [about, fix, checks, leftOut] = summary + ? [ + [`${opened} A summary-only model pass read the branch's diff and the issue, changed nothing, and wrote:`, "", quoted(summary.summary)], + summary.changes.length ? bullets(summary.changes) : "The summary pass listed no changes; the diff shows them.", + "The earlier run reported no checks here, and the summary pass ran none.", + "every check the earlier run would have reported", + ] + : [ + [`${opened} The model did not run again, and the earlier run's outcome could not be read, so this body carries none of its text.`], + "The diff shows the changes.", + "The earlier run reported no checks here.", + "every check", + ]; const body = [ `Closes #${issueNumber}`, "", @@ -320,26 +403,28 @@ export function reusePlan({ issueNumber, issueTitle = "", branch, runUrl = "", e "", "## Summary", "", - `Opened by the work-resolve-issue lane from \`${branch}\`, which an earlier run left with commits and no report. The model did not run again, and the earlier run's outcome could not be read, so this body carries none of its text.`, + ...about, "", "## Fix", "", - "The diff shows the changes.", + fix, + "", + fileList(changedFiles, holdPaths), "", "## Verification", "", - "The earlier run reported no checks here.", + checks, "", earlierRunUrl ? `- The earlier lane run: ${earlierRunUrl}` : "- The earlier lane run.", runUrl ? `- This lane run: ${runUrl}` : "- This lane run.", "", - "Left out: every check; a person reviews the diff before merging.", + `Left out: ${leftOut}; a person reviews the diff before merging.`, "", "## Related", "", `#${issueNumber}`, ].join("\n"); - return { action: "open-pr", issueNumber, title: prTitle("", issueTitle, issueNumber), body, branch }; + return { action: "open-pr", issueNumber, title: prTitle(summary?.title ?? "", issueTitle, issueNumber), body, branch }; } // What to do on the issue, from the outcome and what the run left. `limit` is @@ -695,7 +780,7 @@ export async function findPriorMain({ env = process.env, fetch = globalThis.fetc prior.action === "run" ? `report-outcome: no earlier run of issue #${issueNumber} left work to report; the model runs` : prior.action === "reuse" - ? `report-outcome: ${prior.branch} holds unreported work for issue #${issueNumber}; the model is skipped and report-outcome opens its PR` + ? `report-outcome: ${prior.branch} holds unreported work for issue #${issueNumber}; the work is skipped, a summary pass describes it and report-outcome opens its PR` : `report-outcome: ${prior.reason}; the model is skipped`, ); return 0; @@ -731,40 +816,60 @@ export async function main({ env = process.env, fetch = globalThis.fetch, now = const runUrlOf = (id) => (env.GITHUB_SERVER_URL && id ? `${env.GITHUB_SERVER_URL}/${env.GITHUB_REPOSITORY}/actions/runs/${id}` : ""); const runUrl = runUrlOf(env.GITHUB_RUN_ID); const issue = await github("GET", `${repo}/issues/${issueNumber}`); - let changedFiles = []; + const unreported = reuse && (outcome === null || outcome.unread); + // The summary counts only while the branch is still at the tip it read; a + // tip that cannot be read keeps the fixed text. + let summary = unreported && env.REUSE_SUMMARY_DIR ? readSummary(env.REUSE_SUMMARY_DIR, { issueNumber, branch: reuse }) : null; + if (summary) { + const ref = reuse.split("/").map(encodeURIComponent).join("/"); + const tip = await github("GET", `${repo}/git/ref/heads/${ref}`).then( + (answer) => answer?.object?.sha, + () => "", + ); + if (tip !== summary.tip) { + console.log(`report-outcome: ${reuse} is not at the tip the summary read, so the PR keeps the fixed text`); + summary = null; + } + } let base = ""; - if (outcome?.exit === "done" && branch) { + const compare = async () => { base = (await github("GET", repo)).default_branch; const comparison = await github("GET", `${repo}/compare/${encodeURIComponent(base)}...${encodeURIComponent(branch)}`); - changedFiles = (comparison.files ?? []).flatMap((file) => + return (comparison.files ?? []).flatMap((file) => file.previous_filename ? [file.filename, file.previous_filename] : [file.filename], ); - } + }; + const changedFiles = outcome?.exit === "done" && branch ? await compare() : []; + const holdPaths = readHoldPaths(env.RESOLVED_CONFIG || join(env.RUNNER_TEMP ?? "", "resolved-config.json")); // A reuse whose outcome cannot be read never takes the limit record: the - // skipped run hit no limit, and a limited marker would loop restarts. - const plan = - reuse && (outcome === null || outcome.unread) - ? reusePlan({ - issueNumber, - issueTitle: typeof issue.title === "string" ? issue.title : "", - branch, - runUrl, - earlierRunUrl: runUrlOf(reused[1]), - }) - : decide({ - outcome, - issueNumber, - issueLabels: (issue.labels ?? []).map((label) => label.name), - issueTitle: typeof issue.title === "string" ? issue.title : "", - branch, - changedFiles, - holdPaths: readHoldPaths(env.RESOLVED_CONFIG || join(env.RUNNER_TEMP ?? "", "resolved-config.json")), - humanLabel: humanLabelFrom(env.TRACKER_CONFIG ?? ".base/.work-item-tracker.json"), - runUrl, - limit, - now, - runId: /^[1-9][0-9]{0,14}$/.test(env.GITHUB_RUN_ID ?? "") ? env.GITHUB_RUN_ID : "", - }); + // skipped run hit no limit, and a limited marker would loop restarts. Its + // file list is only text, so a failed compare never blocks the PR. + const issueTitle = typeof issue.title === "string" ? issue.title : ""; + const plan = unreported + ? reusePlan({ + issueNumber, + issueTitle, + branch, + runUrl, + earlierRunUrl: runUrlOf(reused[1]), + summary, + changedFiles: await compare().catch(() => null), + holdPaths, + }) + : decide({ + outcome, + issueNumber, + issueLabels: (issue.labels ?? []).map((label) => label.name), + issueTitle, + branch, + changedFiles, + holdPaths, + humanLabel: humanLabelFrom(env.TRACKER_CONFIG ?? ".base/.work-item-tracker.json"), + runUrl, + limit, + now, + runId: /^[1-9][0-9]{0,14}$/.test(env.GITHUB_RUN_ID ?? "") ? env.GITHUB_RUN_ID : "", + }); if (plan.action === "limited") { // Once per run: a rerun of this report, however late, finds its own // marker in the issue's whole comment history and posts nothing. diff --git a/.github/scripts/work-resolve-issue/report-outcome.test.mjs b/.github/scripts/work-resolve-issue/report-outcome.test.mjs index b60fdbd9791..d4a13406c52 100644 --- a/.github/scripts/work-resolve-issue/report-outcome.test.mjs +++ b/.github/scripts/work-resolve-issue/report-outcome.test.mjs @@ -1205,6 +1205,145 @@ test("a reuse branch that is not this issue's lane branch is refused", async () } }); +// The summary pass: on a reuse, a read-only model pass over the branch diff +// and the issue writes reuse-summary.json beside runner facts (issue, branch, +// the tip it read). Owner decision: a reuse PR keeps the hold line whether or +// not it carries a usable summary; with none, it keeps today's fixed text. +const TIP = "a".repeat(40); +const summaryFile = (overrides = {}, output = {}) => + JSON.stringify({ + version: 1, + issue: 77, + branch: lane(400), + tip: TIP, + output: { title: "docs(work-items): summary title", summary: "SUMMARY-PASS-TEXT", changes: ["CHANGE-ONE"], ...output }, + ...overrides, + }); +// Readable denied paths, so a hold decided from runner facts (decide()'s +// predicate) would not hold the README-only, read-only-class reuse PRs below: +// only the rule that every reuse PR is held puts the hold line on them. +const RESOLVED = JSON.stringify({ merge: { "diff-check": { "denied-paths": HOLD_GLOBS } } }); +const summaryEnv = (dir) => ({ ...reuseEnv(dir), REUSE_SUMMARY_DIR: dir, RESOLVED_CONFIG: join(dir, "resolved.json") }); +const tipRoute = (sha = TIP) => ["GET", /\/git\/ref\/heads\/work-resolve-issue\/issue-77-400-1$/, 200, { object: { sha } }]; +const usable = { title: "docs(work-items): summary title", summary: "SUMMARY-PASS-TEXT", changes: ["CHANGE-ONE"] }; + +test("a reuse PR with a usable summary carries it under the hold line", () => { + const plan = reusePlan({ issueNumber: 77, branch: lane(400), summary: usable, changedFiles: ["README.md"], holdPaths: HOLD_GLOBS }); + assert.equal(plan.title, "docs(work-items): summary title"); + assert.ok(plan.body.startsWith("Closes #77\n\nHold: a human merges this\n\n## Summary\n"), plan.body); + assert.ok(plan.body.includes("> SUMMARY-PASS-TEXT"), plan.body); + assert.ok(plan.body.includes("- CHANGE-ONE"), plan.body); + assert.deepEqual(headings(plan.body), ["## Summary", "## Fix", "## Verification", "## Related"]); +}); + +test("a reuse PR lists the files the branch changes, denied paths marked and first", () => { + for (const summary of [usable, null]) { + const plan = reusePlan({ + issueNumber: 77, + branch: lane(400), + summary, + changedFiles: ["README.md", ".github/workflows/x.yml"], + holdPaths: HOLD_GLOBS, + }); + const fix = plan.body.slice(plan.body.indexOf("## Fix"), plan.body.indexOf("## Verification")); + assert.ok(fix.includes("- .github/workflows/x.yml (denied path)\n- README.md\n"), fix); + } +}); + +test("a reuse PR lists a file name holding a comment opener in full, escaped", () => { + const plan = reusePlan({ issueNumber: 77, branch: lane(400), changedFiles: ["a`, which `issue-prior` matches to the branches of that run (a marker naming no run covers every branch whose tip is older than it). +- On a reuse the skill run mints nothing, and the [summary job](#the-summary-job) has a read-only + model pass describe the branch from its diff and the issue, as the `reuse-summary-` artifact. - A non-empty `reuse-branch` also starts `report-outcome` and `report-statuses`, and reaches the - script as `REUSE_BRANCH`. The downloaded outcome counts only when its `lane.json` names that - branch (a re-run attempt of the run that made it). Otherwise, and when that outcome cannot be - read, `report-outcome` opens a held PR from the branch with fixed text naming the earlier run and - none of the model's text, ignoring any limit record; the earlier run's artifact would need - `actions: read`, which no lane token holds. `report-statuses` then posts only - `pipeline/report-outcome` on that branch, so any statuses the earlier run posted on the same - head stand. + script as `REUSE_BRANCH`; `report-outcome` also downloads `reuse-summary-`. The downloaded + outcome counts only when its `lane.json` names that branch (a re-run attempt of the run that + made it). Otherwise, and when that outcome cannot be read, `report-outcome` opens a held PR from + the branch, ignoring any limit record: the earlier run's artifact would need `actions: read`, + which no lane token holds. When the summary names this issue and branch, the branch is still at + its tip, and its `summary` is not empty, the PR carries the summary and its changes, quoted as + model text. Otherwise the PR has fixed text naming the earlier run and none of the model's text. + Either way its Fix section lists the files `GET /compare` names, those on a + `merge.diff-check.denied-paths` glob marked and listed first; a failed compare says the list is + unavailable and still opens the PR. + The summary never reaches a status or the hold line, which every reuse PR carries: + `report-statuses` then posts only `pipeline/report-outcome` on that branch, so any statuses the + earlier run posted on the same head stand. - No check run is posted: there is no PR head. The lane's `report-statuses` job (in `work-resolve-issue.yml`, the lane's only `statuses: write`, on its own `GITHUB_TOKEN`) posts a `pipeline/` commit status on the lane branch's head for `resolve-issue` and diff --git a/scripts/write-runner-never-rules.test.sh b/scripts/write-runner-never-rules.test.sh index c5eb2bddd67..d4e82f26d2b 100755 --- a/scripts/write-runner-never-rules.test.sh +++ b/scripts/write-runner-never-rules.test.sh @@ -261,6 +261,54 @@ say(first.env?.REUSE_BRANCH === "${{ inputs.reuse-branch }}" && (first.run ?? "").includes("[[ $REUSE_BRANCH =~ ^work-resolve-issue/issue-${ISSUE_NUMBER}-[1-9][0-9]*-[1-9][0-9]*$ ]]"), "the first step refuses a reuse-branch input that is not this issue's lane branch"); say(byId("act-script").env?.REUSE_BRANCH === "${{ inputs.reuse-branch }}", "the script step gets the checked reuse-branch input"); + +// Issue mode, reuse: the summary pass is its own job after the run job, +// started only by the earlier-work check's reuse-branch (which skips the mint). +// It holds no id-token, reads the branch as data, and no step can turn it red. +const sjob = wf.jobs.summary ?? {}; +const ssteps = sjob.steps ?? []; +const sById = (id) => ssteps.find((s) => s.id === id) ?? {}; +const summaryIds = ["summary-data", "summary-models", "summary-prompt", "summary-run", "summary-collect"]; +say(!steps.some((s) => summaryIds.includes(s.id) || /claude-code-action/.test(s.uses ?? "") && s.id !== "act-skill"), + "the run job, which holds id-token: write, runs no summary step and no model step but the skill"); +say(sjob.needs === "run" && /needs\.run\.outputs\.reuse-branch != ''/.test(String(sjob.if ?? "")) && /!cancelled\(\)/.test(String(sjob.if ?? "")), + "the summary job runs after the run job, only on its reuse-branch output"); +say(JSON.stringify(sjob.permissions) === JSON.stringify({ contents: "read" }), + "the summary job holds contents: read and nothing else, no id-token"); +say(wf.jobs.run.outputs?.["reuse-branch"] === "${{ steps.issue-prior.outputs.reuse-branch }}", + "the run job's reuse-branch output is the earlier-work check's"); +say(ssteps.length > 0 && ssteps.every((s) => s["continue-on-error"] === true), + "every summary job step is continue-on-error, so a failed pass turns no job red"); +say(summaryIds.every((id) => sById(id).id === id) && !/lane-token/.test(JSON.stringify(sjob)), + "the summary job has every summary step and reads no lane token"); +const sBase = ssteps.find((s) => /actions\/checkout/.test(s.uses ?? "")) ?? {}; +say(sBase.with?.ref === "${{ needs.run.outputs.base-sha }}" && sBase.with?.["persist-credentials"] === false + && ssteps.filter((s) => /actions\/checkout/.test(s.uses ?? "")).length === 1, + "the summary job checks out only the run job's trusted base, without persisted credentials"); +const summaryData = sById("summary-data"); +say(summaryData.env?.GITHUB_TOKEN === "${{ github.token }}" && /GIT_CONFIG_GLOBAL=\/dev\/null/.test(summaryData.run ?? "") + && /--filter=blob:none/.test(summaryData.run ?? "") + && /diff --no-ext-diff --no-textconv/.test(summaryData.run ?? "") && !/checkout|worktree|npm |bash "\$SRC|node "\$SRC/.test(summaryData.run ?? ""), + "the summary data step fetches the branch as objects with the read-only token and diffs it without running anything from it"); +const summaryRun = sById("summary-run"); +const summaryArgs = String(sById("summary-prompt").run ?? ""); +say(String(summaryRun.uses ?? "") === String(skill.uses ?? "") && summaryRun.with?.github_token === "${{ github.token }}" + && !("use_commit_signing" in (summaryRun.with ?? {})) && !("plugins" in (summaryRun.with ?? {})) + && !("plugin_marketplaces" in (summaryRun.with ?? {})) && sById("isolate").uses === "./.base/.github/actions/setup-runner-isolation" + && String(summaryRun.if ?? "") === "steps.isolate.outcome == 'success'", + "the summary model step runs only after runner isolation, with the job's read-only token, no commit signing and no plugins"); +say(/--setting-sources user/.test(String(summaryRun.with?.claude_args ?? "")) && /--permission-mode dontAsk/.test(String(summaryRun.with?.claude_args ?? "")) + && /--allowedTools \\"Read\(\/\$DATA\/\*\*\)\\"/.test(summaryArgs) && /denied="Bash,Edit,Write,/.test(summaryArgs) + && /mcp__github_file_ops/.test(summaryArgs) && /--json-schema/.test(summaryArgs) && /openssl rand -hex/.test(summaryArgs), + "the summary model reads the data directory and nothing else, under dontAsk, and returns structured output"); +const summaryCollect = sById("summary-collect"); +say(summaryCollect.env?.REUSE_BRANCH === "${{ needs.run.outputs.reuse-branch }}" + && summaryCollect.env?.TIP === "${{ steps.summary-data.outputs.tip }}" && /try fromjson catch null/.test(summaryCollect.run ?? ""), + "the summary file binds the model's output to the runner's branch and tip"); +const summaryDownload = steps.find((s) => s.name === "Download the reused branch's summary"); +say(/inputs\.reuse-branch != ''/.test(String(summaryDownload?.if ?? "")) && /steps\.config\.outputs\.kind == 'script'/.test(String(summaryDownload?.if ?? "")) + && summaryDownload?.["continue-on-error"] === true, + "only a reuse's report-outcome run downloads the summary, and a missing one fails nothing"); EOF ) if [[ -z "$results" ]]; then