From c66cf7f346066a3cee8a0015c9eefe56455add33 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Fri, 23 Oct 2026 00:37:54 -0400 Subject: [PATCH 1/7] feat(pr-pipeline): restore pr-refine as the refine stage for fix-docs pr-refine.yml takes fix-docs, its subscription fallback and its push job out of pr-unblock.yml, and pr-pipeline.yaml gains lanes.pr-refine (stage refine: fix-docs, then apply-patch). pr-unblock-dispatch.yml dispatches pr-refine.yml once per PR; a pr-unblock / fix-docs run counts as done. The person-only filter goes: every PR is tidied except Dependabot's, the release App's and the standards-sync App's, excluded by account id. Both runners pick the dispatched workflow by lane, and refire-refused covers dispatched pr-refine runs again. Co-Authored-By: Claude Opus 5.5 --- .../feat-pr-refine-restore-9a0d0a8e.md | 9 + .../actions/resolve-config/callers.test.mjs | 42 ++-- .../resolve-config/dispatch-limits.test.mjs | 4 +- .../resolve-config/live-config.test.mjs | 39 ++-- .../actions/resolve-config/models.test.mjs | 4 +- .github/lane-funding.json | 4 +- .../scripts/apply-patch/apply-patch.test.mjs | 10 +- .github/scripts/pr-merge/refire-refused.mjs | 21 +- .../scripts/pr-merge/refire-refused.test.mjs | 61 +++++- .github/scripts/pr-unblock/dispatch.mjs | 9 +- .github/scripts/pr-unblock/dispatch.test.mjs | 2 +- .../scripts/pr-unblock/fix-docs-dispatch.mjs | 72 ++++--- .../pr-unblock/fix-docs-dispatch.test.mjs | 90 ++++++--- .github/workflows/pr-merge.yml | 11 +- .github/workflows/pr-refine.yml | 180 ++++++++++++++++++ .github/workflows/pr-run-activity-read.yml | 3 +- .github/workflows/pr-run-activity-write.yml | 3 +- .github/workflows/pr-unblock-dispatch.yml | 12 +- .github/workflows/pr-unblock.yml | 143 +------------- docs/conventions/pr-pipeline.yaml | 19 +- docs/conventions/pr-pipeline/README.md | 27 +-- .../pr-pipeline/pr-run-activity.md | 8 +- scripts/read-runner-dispatcher-gate.test.sh | 7 +- 23 files changed, 496 insertions(+), 284 deletions(-) create mode 100644 .changes/pr-pipeline/feat-pr-refine-restore-9a0d0a8e.md create mode 100644 .github/workflows/pr-refine.yml diff --git a/.changes/pr-pipeline/feat-pr-refine-restore-9a0d0a8e.md b/.changes/pr-pipeline/feat-pr-refine-restore-9a0d0a8e.md new file mode 100644 index 0000000000..824187c741 --- /dev/null +++ b/.changes/pr-pipeline/feat-pr-refine-restore-9a0d0a8e.md @@ -0,0 +1,9 @@ +--- +bump: minor +--- + +### Changed + +- `fix-docs` moves from `pr-unblock` back into its own `pr-refine` lane (stage `refine`): `pr-refine.yml` holds the `fix-docs`, `fix-docs-subscription` and `apply-docs-patch` jobs, and `pr-unblock.yml` no longer takes `fix-docs`. `pr-unblock-dispatch.yml` dispatches `pr-refine.yml`, still once per PR and at most `limits.fix-docs-per-run` per run; a PR that already has a `pr-unblock / fix-docs` run counts as done. +- `fix-docs` now tidies every ready PR that changes Markdown, lanes App PRs included, except Dependabot's, the release App's and the standards-sync App's, matched by account id. +- Both activity runners name `pr-refine.yml` as the dispatched workflow for the `pr-refine` lane, and pr-merge's refire of a broker `workflow-modified` refusal covers dispatched `pr-refine` runs again. diff --git a/.github/actions/resolve-config/callers.test.mjs b/.github/actions/resolve-config/callers.test.mjs index c20936baf1..480bcfc221 100644 --- a/.github/actions/resolve-config/callers.test.mjs +++ b/.github/actions/resolve-config/callers.test.mjs @@ -953,14 +953,14 @@ test("every read-runner caller job passes exactly one Claude secret, named for i } } assert.deepEqual(callers.sort(), [ + "pr-refine.yml fix-docs", + "pr-refine.yml fix-docs-subscription", "pr-unblock.yml address-feedback", "pr-unblock.yml address-feedback-subscription", "pr-unblock.yml fix-ci", "pr-unblock.yml fix-ci-rung-up", "pr-unblock.yml fix-ci-rung-up-subscription", "pr-unblock.yml fix-ci-subscription", - "pr-unblock.yml fix-docs", - "pr-unblock.yml fix-docs-subscription", "work-resolve-issue.yml resolve-issue", ]); }); @@ -1279,7 +1279,7 @@ test("pr-run-activity-read.yml: the fallback call's lane record names why it ran // call ran the activity. const PATCH_LANES = [ ["pr-unblock.yml", "fix-ci", "fix-ci-subscription", ["fix-ci-context"], { "needs.fix-ci-context.outputs.proceed": "true" }, "apply-patch"], - ["pr-unblock.yml", "fix-docs", "fix-docs-subscription", ["fix-docs-head"], {}, "apply-docs-patch"], + ["pr-refine.yml", "fix-docs", "fix-docs-subscription", ["fix-docs-head"], {}, "apply-docs-patch"], // apply-feedback-patch stays out of report-escalation: every refusal leaves // no fix commit, and publish-feedback escalates feedback-not-landed // (publish-feedback.test.mjs). @@ -1424,6 +1424,11 @@ for (const [file, firstId, subId, extraNeeds, extraProceed, applyId] of PATCH_LA assert.equal(runs("empty", ""), EMPTY_RUNS_APPLY[firstId], "an empty patch"); assert.equal(runs("", ""), false, "a handoff whose fallback call left no patch"); const escalation = jobs["report-escalation"]; + if (escalation === undefined) { + // A lane with no escalation job hands no PR to a person. + assert.equal(EMPTY_RUNS_APPLY[firstId], false, `${file} has no report-escalation for ${applyId}`); + return; + } for (const need of escalation.needs) assert.ok(Object.hasOwn(jobs, need), `report-escalation needs ${need}, a job of the file`); if (EMPTY_RUNS_APPLY[firstId]) { assert.ok(escalation.needs.includes(applyId)); @@ -1446,17 +1451,19 @@ test("pr-run-activity-write.yml passes the producer's prepare mode to both apply } }); -// fix-docs starts only from a pr-unblock.yml dispatch naming it: the +// fix-docs starts only from a pr-refine.yml dispatch naming it: the // dispatcher, or a listed person. Every fix-docs job trusts the default // branch tip, and the lanes App's own dispatch starts none. -test("pr-unblock.yml: fix-docs runs only on a fix-docs dispatch with a PR number, never the lanes App's, trusting the default branch tip", () => { - const { on, jobs } = workflow("pr-unblock.yml"); - assert.ok(on.workflow_dispatch.inputs.activity.options.includes("fix-docs"), "a fix-docs dispatch is a valid choice"); - assert.equal(on.pull_request, undefined); +test("pr-refine.yml: fix-docs runs only on a fix-docs dispatch with a PR number, never the lanes App's, trusting the default branch tip", () => { + const { on, jobs, "run-name": runName } = workflow("pr-refine.yml"); + assert.deepEqual(Object.keys(on), ["workflow_dispatch"], "dispatch only, no pull_request trigger"); + assert.deepEqual(on.workflow_dispatch.inputs.activity.options, ["fix-docs"]); + // fix-docs-dispatch.mjs matches this title to skip a PR whose run is not done. + assert.equal(runName, "pr-refine ${{ inputs.activity }} #${{ inputs.pr-number }}"); for (const id of ["fix-docs", "fix-docs-subscription", "apply-docs-patch"]) { assert.equal(jobs[id].with["trusted-base"], "default-tip", id); assert.equal(jobs[id].with["dispatcher-run-id"], "${{ inputs.dispatcher-run-id }}", id); - assert.equal(jobs[id].with.lane, "pr-unblock", id); + assert.equal(jobs[id].with.lane, "pr-refine", id); assert.deepEqual(jobs[id].permissions, { contents: "read", "pull-requests": "read", checks: "write", issues: "write", "id-token": "write" }, id); } const starts = ({ activity = "fix-docs", pr = "7", sender = "1" } = {}) => @@ -1471,18 +1478,20 @@ test("pr-unblock.yml: fix-docs runs only on a fix-docs dispatch with a PR number assert.equal(starts({ activity: "resolve-conflicts" }), false); assert.equal(starts({ pr: "" }), false); assert.equal(starts({ sender: "2" }), false, "the lanes App's own dispatch"); - assert.equal(existsSync(new URL("../../workflows/pr-refine.yml", import.meta.url)), false, "pr-refine.yml and its pull_request trigger are gone"); + const unblock = workflow("pr-unblock.yml"); + assert.ok(!unblock.on.workflow_dispatch.inputs.activity.options.includes("fix-docs"), "pr-unblock.yml no longer takes fix-docs"); + assert.deepEqual(Object.keys(unblock.jobs).filter((id) => /docs/.test(id)), []); }); // The read report job posts on the run's head only when it equals a head the // caller or event gated; with neither it trusts the PR head read at report // time, so a push mid-run made a finished run look forged and posted failure // on the new head (#9207). Runs the report job's head step with the head-sha -// each pr-unblock call passes. -test("pr-unblock.yml: a read activity whose PR head moved mid-run posts on its own head, never on the new one", () => { +// each pr-unblock and pr-refine call passes. +test("pr-unblock.yml and pr-refine.yml: a read activity whose PR head moved mid-run posts on its own head, never on the new one", () => { const old = "1".repeat(40); const moved = "2".repeat(40); - const { jobs } = workflow("pr-unblock.yml"); + const jobs = { ...workflow("pr-unblock.yml").jobs, ...workflow("pr-refine.yml").jobs }; const read = workflow("pr-run-activity-read.yml"); const step = read.jobs.report.steps.find((s) => s.name === "Choose the head the check is posted on"); const choose = (callerHead, runHead, postDecline = true) => { @@ -2941,11 +2950,12 @@ test("report job: the PR number comes from the caller or the event, a run job pr assert.equal(steps.filter((s) => String(s.uses ?? "").startsWith("actions/checkout@")).length, 1); assert.doesNotMatch(JSON.stringify(steps), /base-sha/, "no step reads the run job's base-sha"); assert.equal(steps.findIndex((s) => s.name === "Assert the default branch is set"), 0); - // pr-unblock passes the head a job that ran no head code read to both calls - // of each activity. + // pr-unblock and pr-refine pass the head a job that ran no head code read + // to both calls of each activity. const { jobs } = workflow("pr-unblock.yml"); for (const id of ["fix-ci", "fix-ci-subscription"]) assert.equal(jobs[id].with["head-sha"], "${{ needs.fix-ci-context.outputs.head-sha }}", id); - for (const id of ["fix-docs", "fix-docs-subscription"]) assert.equal(jobs[id].with["head-sha"], "${{ needs.fix-docs-head.outputs.head-sha }}", id); + const refine = workflow("pr-refine.yml").jobs; + for (const id of ["fix-docs", "fix-docs-subscription"]) assert.equal(refine[id].with["head-sha"], "${{ needs.fix-docs-head.outputs.head-sha }}", id); }); test("scan job: an empty default branch fails closed before the opener is checked out", (t) => { diff --git a/.github/actions/resolve-config/dispatch-limits.test.mjs b/.github/actions/resolve-config/dispatch-limits.test.mjs index 63a6481578..fbef1d03d3 100644 --- a/.github/actions/resolve-config/dispatch-limits.test.mjs +++ b/.github/actions/resolve-config/dispatch-limits.test.mjs @@ -59,7 +59,7 @@ const ciFixPull = (number) => { }; }; -// Ready PRs a person opened that change Markdown and never had fix-docs, as +// Ready PRs that change Markdown and never had fix-docs, as // fix-docs-dispatch.mjs lists them. const docsPull = (number) => { const head = String(number).padStart(40, "d"); @@ -69,7 +69,7 @@ const docsPull = (number) => { isCrossRepository: false, baseRefName: "main", headRefOid: head, - author: { __typename: "User", login: "someone" }, + author: { __typename: "User", login: "someone", databaseId: 4242 }, labels: { nodes: [] }, files: { nodes: [{ path: "README.md" }], pageInfo: { hasNextPage: false } }, commits: { nodes: [{ commit: { oid: head, statusCheckRollup: { contexts: { nodes: [] } } } }] }, diff --git a/.github/actions/resolve-config/live-config.test.mjs b/.github/actions/resolve-config/live-config.test.mjs index c6aa1dc49a..b192f5bb7a 100644 --- a/.github/actions/resolve-config/live-config.test.mjs +++ b/.github/actions/resolve-config/live-config.test.mjs @@ -110,6 +110,13 @@ test("live config: report-escalation applies and is granted publish-artifact onl }); // Expected values from #7032's acceptance criteria and effect-grants.json. +test("live config: pr-refine is the refine stage: fix-docs, then its push job", () => { + const { lane } = resolve(FILES, { lane: "pr-refine", activity: "fix-docs", configPath: CONFIG_PATH, basePath: ".base", facts: FACTS }); + assert.equal(lane.stage, "refine"); + assert.deepEqual(lane.slots.map((slot) => slot.activity ?? slot.name), ["fix-docs", "apply-patch"]); + assert.deepEqual(selected("pr-unblock", "apply-patch").needs, ["fix-ci"]); +}); + test("live config: pr-unblock runs its slots in order", () => { const { lane } = resolve(FILES, { lane: "pr-unblock", @@ -128,7 +135,6 @@ test("live config: pr-unblock runs its slots in order", () => { "review-dependency", "apply-dependency-verdict", "fix-ci", - "fix-docs", "apply-patch", "report-escalation", ], @@ -189,7 +195,7 @@ test("live config: every other PR activity still waits on needs-human", () => { for (const [lane, activity, facts] of [ ["pr-unblock", "resolve-conflicts", {}], ["pr-unblock", "review-dependency", { mergeable: "clean" }], - ["pr-unblock", "fix-docs", {}], + ["pr-refine", "fix-docs", {}], ]) { assert.equal(skipReason(lane, activity, { ...facts, labels: ["needs-human"] }), "awaiting-human", activity); } @@ -209,8 +215,7 @@ test("live config: publish-feedback runs the reply script after address-feedback ); }); -// fix-docs hands no PR to a person, so report-escalation does not follow it. -test("live config: report-escalation follows every other pr-unblock activity but fix-docs", () => { +test("live config: report-escalation follows every pr-unblock activity", () => { assert.deepEqual(selected("pr-unblock", "report-escalation").needs, [ "resolve-conflicts", "address-feedback", @@ -348,7 +353,7 @@ test("live config: resolve-issue reads official docs hosts only; review-dependen enabled: true, domains: ["github.com", "raw.githubusercontent.com", "www.npmjs.com", "registry.npmjs.org", "pypi.org"], }); - assert.notEqual(selected("pr-unblock", "fix-docs")["web-access"]?.enabled, true); + assert.notEqual(selected("pr-refine", "fix-docs")["web-access"]?.enabled, true); const others = Object.entries(doc.activities).filter( ([name, a]) => !["resolve-issue", "review-dependency"].includes(name) && a["web-access"]?.enabled, ); @@ -434,31 +439,31 @@ test("live config: label-unblocked may only write issues", () => { }); test("live config: fix-docs applies to a Markdown change and waits on needs-human", () => { - assert.equal(selected("pr-unblock", "fix-docs").applies, true); + assert.equal(selected("pr-refine", "fix-docs").applies, true); assert.equal( - skipReason("pr-unblock", "fix-docs", { labels: ["needs-human"] }), + skipReason("pr-refine", "fix-docs", { labels: ["needs-human"] }), "awaiting-human", ); }); test("live config: fix-docs does not apply to a draft PR", () => { - assert.equal(skipReason("pr-unblock", "fix-docs", { draft: true }), "not-applicable"); + assert.equal(skipReason("pr-refine", "fix-docs", { draft: true }), "not-applicable"); }); // The model run holds no write token; only the script that pushes its patch -// may write the branch, from its own job in pr-unblock. -test("live config: fix-docs holds no write token and its push job in pr-unblock may only write its branch", () => { - const grant = (activity) => resolveGrant(FILES, { lane: "pr-unblock", activity, configPath: CONFIG_PATH }); +// may write the branch, from its own job in pr-refine. +test("live config: fix-docs holds no write token and its push job in pr-refine may only write its branch", () => { + const grant = (activity) => resolveGrant(FILES, { lane: "pr-refine", activity, configPath: CONFIG_PATH }); assert.deepEqual(grant("fix-docs"), { effect: "read", grant: { contents: "read", "pull-requests": "read", issues: "read" } }); assert.deepEqual(grant("apply-patch"), { effect: "mutate-branch", grant: { contents: "write", "pull-requests": "read", issues: "read" }, }); - const fix = selected("pr-unblock", "fix-docs"); + const fix = selected("pr-refine", "fix-docs"); assert.equal(fix.prepare, undefined, "no prepare mode: apply-patch takes the default row"); assert.deepEqual(fix["applied-by"], { patch: "apply-patch" }); - const apply = selected("pr-unblock", "apply-patch"); - assert.deepEqual(apply.needs, ["fix-ci", "fix-docs"]); + const apply = selected("pr-refine", "apply-patch"); + assert.deepEqual(apply.needs, ["fix-docs"]); assert.equal(apply.script, ".github/scripts/apply-patch/apply-patch.mjs"); assert.equal(apply.prepare, "apply-patch"); }); @@ -467,7 +472,7 @@ test("live config: fix-docs holds no write token and its push job in pr-unblock // auto, so no activity asks for it; fix-docs takes the dontAsk default and // runs only what the stage's allow rules name. test("live config: no activity sets permission-mode", () => { - assert.equal(selected("pr-unblock", "fix-docs")["permission-mode"], undefined); + assert.equal(selected("pr-refine", "fix-docs")["permission-mode"], undefined); const { activities } = parse(FILES.config); assert.deepEqual(Object.keys(activities).filter((name) => Object.hasOwn(activities[name], "permission-mode")), []); }); @@ -475,7 +480,7 @@ test("live config: no activity sets permission-mode", () => { // fix-docs hands on a patch, so its targets leave out instruction surfaces // and CODEOWNERS paths, as a write activity's do. test("live config: fix-docs is not asked to fix an instruction surface", () => { - const slot = selected("pr-unblock", "fix-docs", { changedTargets: ["docs/guide.md", "CLAUDE.md", "plugins/p/skills/s/SKILL.md"] }); + const slot = selected("pr-refine", "fix-docs", { changedTargets: ["docs/guide.md", "CLAUDE.md", "plugins/p/skills/s/SKILL.md"] }); assert.deepEqual(slot["changed-paths"], ["docs/guide.md"]); }); @@ -485,7 +490,7 @@ test("live config: fix-docs with a write effect is refused", () => { const writes = FILES.config.replace("effect: read\n applied-by:\n patch: apply-patch\n", "effect: mutate-branch\n"); assert.notEqual(writes, FILES.config); assert.throws( - () => resolve({ ...FILES, config: writes }, { lane: "pr-unblock", activity: "fix-docs", configPath: CONFIG_PATH, basePath: ".base", facts: FACTS }), + () => resolve({ ...FILES, config: writes }, { lane: "pr-refine", activity: "fix-docs", configPath: CONFIG_PATH, basePath: ".base", facts: FACTS }), (error) => error.code === "forbidden-by-activity", ); }); diff --git a/.github/actions/resolve-config/models.test.mjs b/.github/actions/resolve-config/models.test.mjs index a15486b72b..68be171523 100644 --- a/.github/actions/resolve-config/models.test.mjs +++ b/.github/actions/resolve-config/models.test.mjs @@ -340,7 +340,7 @@ test("live config: the live issue-dimensions maps every org option and runs a kn }); test("live config: an activity with no issue input takes no dimensions", () => { - const slot = liveSlot("pr-unblock", "fix-docs"); + const slot = liveSlot("pr-refine", "fix-docs"); assert.equal(slot["issue-dimensions"], undefined); assert.equal(slot["timeout-minutes"], undefined); }); @@ -490,7 +490,7 @@ for (const activity of ["fix-ci", "address-feedback"]) { // The role map's worker runs mechanical work on sonnet at its own effort, // medium; the profile keeps standard's 75 turns. test("live config: fix-docs runs as a mechanical worker, sonnet at the worker's effort, 75 turns", () => { - const slot = liveSlot("pr-unblock", "fix-docs"); + const slot = liveSlot("pr-refine", "fix-docs"); assert.deepEqual(slot.models, { profile: "docs-fix", role: "worker", diff --git a/.github/lane-funding.json b/.github/lane-funding.json index 0674d01eda..fd0e339f30 100644 --- a/.github/lane-funding.json +++ b/.github/lane-funding.json @@ -58,8 +58,8 @@ { "id": "fix-ci-subscription", "workflow": "pr-unblock.yml", "launcher": "run-claude-cli", "currentSecrets": ["AUTOMATIONS_CLAUDE_CODE_OAUTH_TOKEN"] }, { "id": "fix-ci-rung-up", "workflow": "pr-unblock.yml", "launcher": "run-claude-cli", "currentSecrets": ["AUTOMATIONS_CLAUDE_API_KEY"] }, { "id": "fix-ci-rung-up-subscription", "workflow": "pr-unblock.yml", "launcher": "run-claude-cli", "currentSecrets": ["AUTOMATIONS_CLAUDE_CODE_OAUTH_TOKEN"] }, - { "id": "fix-docs", "workflow": "pr-unblock.yml", "launcher": "run-claude-cli", "currentSecrets": ["AUTOMATIONS_CLAUDE_API_KEY"] }, - { "id": "fix-docs-subscription", "workflow": "pr-unblock.yml", "launcher": "run-claude-cli", "currentSecrets": ["AUTOMATIONS_CLAUDE_CODE_OAUTH_TOKEN"] }, + { "id": "fix-docs", "workflow": "pr-refine.yml", "launcher": "run-claude-cli", "currentSecrets": ["AUTOMATIONS_CLAUDE_API_KEY"] }, + { "id": "fix-docs-subscription", "workflow": "pr-refine.yml", "launcher": "run-claude-cli", "currentSecrets": ["AUTOMATIONS_CLAUDE_CODE_OAUTH_TOKEN"] }, { "id": "address-feedback", "workflow": "pr-unblock.yml", "launcher": "run-claude-cli", "currentSecrets": ["AUTOMATIONS_CLAUDE_API_KEY"] }, { "id": "address-feedback-subscription", "workflow": "pr-unblock.yml", "launcher": "run-claude-cli", "currentSecrets": ["AUTOMATIONS_CLAUDE_CODE_OAUTH_TOKEN"] }, { "id": "review-dependency", "workflow": "pr-unblock.yml", "launcher": "claude-code-action", "currentSecrets": ["AUTOMATIONS_CLAUDE_CODE_OAUTH_TOKEN"] }, diff --git a/.github/scripts/apply-patch/apply-patch.test.mjs b/.github/scripts/apply-patch/apply-patch.test.mjs index 64b11bbee1..7215c096a5 100644 --- a/.github/scripts/apply-patch/apply-patch.test.mjs +++ b/.github/scripts/apply-patch/apply-patch.test.mjs @@ -328,7 +328,7 @@ for (const [path, edit] of [ writeFileSync(join(repo, "README.md"), "# fixed\n"); edit(repo); }); - assert.equal((await pushWith(fix, fakeApi(fix.repo, fix.head), { lane: "pr-unblock", activity: "fix-docs" })).outcome, "pushed"); + assert.equal((await pushWith(fix, fakeApi(fix.repo, fix.head), { lane: "pr-refine", activity: "fix-docs" })).outcome, "pushed"); }); } @@ -340,7 +340,7 @@ test("a fix-docs patch that edits docs/CODEOWNERS is refused patch-protected-pat }); const api = fakeApi(fix.repo, fix.head); const lines = []; - assert.deepEqual(await pushWith(fix, api, { lane: "pr-unblock", activity: "fix-docs", log: (line) => lines.push(line) }), { + assert.deepEqual(await pushWith(fix, api, { lane: "pr-refine", activity: "fix-docs", log: (line) => lines.push(line) }), { outcome: "escalate", escalationReason: "patch-protected-path", }); @@ -363,12 +363,12 @@ test("a fix-docs patch of 300 files pushes, and one of 301 escalates patch-inval assert.deepEqual(refused.calls, []); }); -test("a fix-docs patch commits Apply fix-docs on the head with the Lane-Activity: pr-unblock/fix-docs trailer", async (t) => { +test("a fix-docs patch commits Apply fix-docs on the head with the Lane-Activity: pr-refine/fix-docs trailer", async (t) => { const fix = repoWithPatch(t, (repo) => writeFileSync(join(repo, "README.md"), "# fixed\n")); const api = fakeApi(fix.repo, fix.head); - assert.deepEqual(await pushWith(fix, api, { lane: "pr-unblock", activity: "fix-docs" }), { outcome: "pushed", commit: "c".repeat(40), branch: "feat/x" }); + assert.deepEqual(await pushWith(fix, api, { lane: "pr-refine", activity: "fix-docs" }), { outcome: "pushed", commit: "c".repeat(40), branch: "feat/x" }); const { createCommit } = api.calls.find((call) => call.createCommit); - assert.equal(createCommit.message, "Apply fix-docs on feat/x\n\nLane-Activity: pr-unblock/fix-docs\n"); + assert.equal(createCommit.message, "Apply fix-docs on feat/x\n\nLane-Activity: pr-refine/fix-docs\n"); assert.deepEqual(createCommit.parents, [fix.head]); }); diff --git a/.github/scripts/pr-merge/refire-refused.mjs b/.github/scripts/pr-merge/refire-refused.mjs index 038b91d290..94d67fa4f4 100755 --- a/.github/scripts/pr-merge/refire-refused.mjs +++ b/.github/scripts/pr-merge/refire-refused.mjs @@ -1,9 +1,9 @@ #!/usr/bin/env node -// refire-refused.mjs: gives a pull request a new head when a pr-unblock run -// on its current head failed at "Request the lane token" because the token -// broker refused it as workflow-modified. A rerun keeps its first event's -// workflow files, so no rerun can pass; after the new head, the next -// pr-unblock run uses main's files. No model. +// refire-refused.mjs: gives a pull request a new head when a pr-unblock or +// pr-refine run on its current head failed at "Request the lane token" +// because the token broker refused it as workflow-modified. A rerun keeps its +// first event's workflow files, so no rerun can pass; after the new head, the +// lane's next run uses main's files. No model. // // The new head is an empty commit: the head's own tree, the head as its only // parent, made with the lanes App token through the Git Data API and moved @@ -75,11 +75,20 @@ const LANES = Object.freeze({ ".github/workflows/pr-feedback-dispatch.yml", ], }, + // fix-docs-dispatch.mjs dispatches fix-docs once per pull request, and the + // refused run's check on the old head counts, so no dispatch follows. + "pr-refine.yml": { + events: ["workflow_dispatch"], + after: "pr-merge pushes an empty commit so the next pr-refine run uses main's files.", + starts: + " The push starts no pr-refine run itself: fix-docs runs once per pull request, so a listed person dispatches `pr-refine` with its number to run it again.", + files: [".github/workflows/pr-refine.yml", WRITE_RUNNER, ".github/workflows/pr-unblock-dispatch.yml"], + }, }); const REFUSED = /^lane-token-denied: the broker answered 403 with reason workflow-modified: (\.github\/workflows\/[A-Za-z0-9._-]+\.yml) differs from refs\/heads\/main$/; const MARKER = /^/; -const DISPATCH_TITLE = /^pr-unblock [a-z]+(?:-[a-z]+)* #([1-9][0-9]{0,9})$/; +const DISPATCH_TITLE = /^pr-(?:unblock|refine) [a-z]+(?:-[a-z]+)* #([1-9][0-9]{0,9})$/; const REPO = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/; // check-trusted-trigger's head checks. const HEAD_REF = /^[A-Za-z0-9._/-]+$/; diff --git a/.github/scripts/pr-merge/refire-refused.test.mjs b/.github/scripts/pr-merge/refire-refused.test.mjs index b16b1ce16d..762196234e 100644 --- a/.github/scripts/pr-merge/refire-refused.test.mjs +++ b/.github/scripts/pr-merge/refire-refused.test.mjs @@ -27,10 +27,12 @@ const sha = (n) => String(n).padStart(40, "0"); const MAIN_TIP = "a".repeat(40); const LANE = "pr-unblock.yml"; const UNBLOCK = ".github/workflows/pr-unblock.yml"; +const REFINE_LANE = "pr-refine.yml"; +const REFINE = ".github/workflows/pr-refine.yml"; const WRITER = ".github/workflows/pr-run-activity-write.yml"; const ACTIONS_BOT = { id: 41898282, login: "github-actions[bot]" }; const FILES = [UNBLOCK, WRITER, ".github/workflows/pr-unblock-dispatch.yml", ".github/workflows/pr-feedback-dispatch.yml"]; -const MAIN_BLOBS = Object.fromEntries(FILES.map((file) => [file, BLOB])); +const MAIN_BLOBS = Object.fromEntries([...FILES, REFINE].map((file) => [file, BLOB])); const pr = (number, { user = OWNER, draft = false, repo = REPO, ref = `fix/${number}` } = {}) => ({ number, @@ -194,12 +196,59 @@ test("a dispatched pr-unblock run re-fires only when its failed check is on the assert.equal(pushes(rerun.calls).length, 0); }); -test("a pr-refine.yml run is no longer read or re-fired", async () => { - const w = world({ pulls: [pr(7)], runs: { "pr-refine.yml": [run(70, { number: 7 })] }, checks: { [sha(7)]: [linkedCheck(70)] } }); +// A refused pr-refine run, dispatched on main for PR `number`, with its +// failed check on that PR's head. +const refineWorld = (number, runOptions = {}, options = {}) => + world({ + pulls: [pr(number)], + runs: { [REFINE_LANE]: [run(number * 10, { number, title: `pr-refine fix-docs #${number}`, messages: [denial(REFINE)], ...runOptions })] }, + checks: { [sha(number)]: [linkedCheck(number * 10)] }, + ...options, + }); + +test("a pr-refine run refused as workflow-modified on the current head gets one empty commit on that branch", async () => { + const w = refineWorld(7); assert.equal(await main({ env, github: w.github, actions: w.actions, now: NOW }), 0); - assert.equal(w.calls.some((call) => call.includes("/actions/workflows/pr-refine.yml/")), false); - assert.deepEqual(pushes(w.calls), []); - assert.deepEqual(posts(w.calls), []); + assert.deepEqual(posts(w.calls), [`POST /repos/${REPO}/issues/7/comments ${JSON.stringify({ body: refireComment(REFINE, BLOB, sha(7), REFINE_LANE) })}`]); + assert.deepEqual(pushes(w.calls), [`PATCH /repos/${REPO}/git/refs/heads/fix/7 {"sha":"${NEW_SHA}","force":false}`]); +}); + +test("a pr-refine re-fire says the push starts no pr-refine run, which waits for a listed person's dispatch", async () => { + const w = refineWorld(7); + assert.equal(await main({ env, github: w.github, actions: w.actions, now: NOW }), 0); + const body = postedBody(w.calls); + assert.ok(body.includes("so the next pr-refine run uses main's files"), body); + assert.ok( + body.endsWith(" The push starts no pr-refine run itself: fix-docs runs once per pull request, so a listed person dispatches `pr-refine` with its number to run it again."), + body, + ); +}); + +test("a pr-refine run refused for the write runner or the dispatcher re-fires; one refused for pr-unblock.yml does not", async () => { + for (const file of [WRITER, ".github/workflows/pr-unblock-dispatch.yml"]) { + const w = refineWorld(7, { messages: [denial(file)] }); + assert.equal(await main({ env, github: w.github, actions: w.actions, now: NOW }), 0); + assert.equal(pushes(w.calls).length, 1, file); + } + const other = refineWorld(7, { messages: [denial(UNBLOCK)] }); + assert.equal(await main({ env, github: other.github, actions: other.actions, now: NOW }), 0); + assert.deepEqual(pushes(other.calls), []); +}); + +test("a pr-refine run on a head that changes a file the broker compares for it is not re-fired", async () => { + for (const file of [REFINE, WRITER]) { + const w = refineWorld(7, {}, { changed: { [sha(7)]: ["README.md", file] } }); + assert.equal(await main({ env, github: w.github, actions: w.actions, now: NOW }), 0); + assert.deepEqual(pushes(w.calls), [], file); + } +}); + +test("a pr-refine run whose title names no PR, or another workflow's title, re-fires nothing", async () => { + for (const title of ["pr-refine fix-docs", "pr-review fix-docs #7", "pr-refine fix-docs #7 extra"]) { + const w = refineWorld(7, { title }); + assert.equal(await main({ env, github: w.github, actions: w.actions, now: NOW }), 0); + assert.deepEqual(pushes(w.calls), [], title); + } }); // The comment text pr-merge posted for every lane until now, verbatim: markers diff --git a/.github/scripts/pr-unblock/dispatch.mjs b/.github/scripts/pr-unblock/dispatch.mjs index 574096505f..c581022061 100644 --- a/.github/scripts/pr-unblock/dispatch.mjs +++ b/.github/scripts/pr-unblock/dispatch.mjs @@ -17,8 +17,9 @@ // (dependency-dispatch.mjs), fix-ci for lanes App PRs and, with an // `adoption` section, PRs of the base's trusted actors, whose ci-status or // pr-test-windows run failed past the lost-run reconciler's re-run -// (fix-ci-dispatch.mjs) and fix-docs once per ready PR that changes -// Markdown (fix-docs-dispatch.mjs), and their run ids join the same record. +// (fix-ci-dispatch.mjs) and pr-refine.yml's fix-docs once per ready PR that +// changes Markdown (fix-docs-dispatch.mjs), and their run ids join the same +// record. // resolve-conflicts and review-dependency read the one open-PR listing. A head whose check runs handle it for // good is kept in DISPATCH_RECORD (dispatch-record.mjs), which the workflow // carries to the next run through the Actions cache; the next run skips that @@ -280,8 +281,8 @@ export function createGitHubApi({ const data = await graphql(DOCS_FIX_QUERY, { owner, name, base, after: after ?? null }); return { rateLimit: data.rateLimit, pullRequests: data.repository.pullRequests }; }, - async readDocsFixHistory({ number, appId, checkName }) { - const data = await graphql(DOCS_FIX_HISTORY_QUERY, { owner, name, number, appId, checkName }); + async readDocsFixHistory({ number, appId, checkName, earlierCheckName }) { + const data = await graphql(DOCS_FIX_HISTORY_QUERY, { owner, name, number, appId, checkName, earlierCheckName }); return { rateLimit: data.rateLimit, pullRequest: data.repository.pullRequest }; }, async getPulls(numbers) { diff --git a/.github/scripts/pr-unblock/dispatch.test.mjs b/.github/scripts/pr-unblock/dispatch.test.mjs index 24f3209830..5d10c7cff8 100644 --- a/.github/scripts/pr-unblock/dispatch.test.mjs +++ b/.github/scripts/pr-unblock/dispatch.test.mjs @@ -596,7 +596,7 @@ const docsPull = (number) => { isCrossRepository: false, baseRefName: "main", headRefOid: head, - author: { __typename: "User", login: "someone" }, + author: { __typename: "User", login: "someone", databaseId: 4242 }, labels: { nodes: [] }, files: { nodes: [{ path: "README.md" }], pageInfo: { hasNextPage: false } }, commits: { nodes: [{ commit: { oid: head, statusCheckRollup: { contexts: { nodes: [] } } } }] }, diff --git a/.github/scripts/pr-unblock/fix-docs-dispatch.mjs b/.github/scripts/pr-unblock/fix-docs-dispatch.mjs index c690a171cd..64167d5eb5 100644 --- a/.github/scripts/pr-unblock/fix-docs-dispatch.mjs +++ b/.github/scripts/pr-unblock/fix-docs-dispatch.mjs @@ -1,12 +1,12 @@ -// Starts the pr-unblock lane's fix-docs activity once per pull request: for -// each open, ready, same-repository PR into the base that a person opened, -// that changes a Markdown file, carries no hold label, and has never had a -// pr-unblock / fix-docs check run. It runs from the same dispatcher run as -// dispatch.mjs (pr-unblock-dispatch.yml), so the trigger gate and the broker -// check it as they check a resolve-conflicts dispatch. +// Starts the pr-refine lane's fix-docs activity once per pull request: for +// each open, ready, same-repository PR into the base that changes a Markdown +// file, carries no hold label, and has never had a fix-docs check run under +// either lane fix-docs has run in, whoever opened it except the accounts in +// excludedAuthorIds. It runs from the same dispatcher run as dispatch.mjs +// (pr-unblock-dispatch.yml), so the trigger gate and the broker check it as +// they check a resolve-conflicts dispatch. // -// Once per PR, as pr-refine.yml's pull_request trigger ran it when a PR opened -// or turned ready, not after each push. The listing is one GraphQL query per +// Once per PR, when it is first seen ready, not after each push. The listing is one GraphQL query per // page of 50 pull requests, whose rateLimit.cost is logged; it drops a PR // whose head already has the check run. For each PR left, lowest number // first, one more query reads the check runs on its last HISTORY_COMMITS @@ -17,13 +17,19 @@ // numbers, SHAs, run ids and counts only. import { isHandled, markHandled, emptyRecord } from "./dispatch-record.mjs"; -import { runTitle } from "./feedback-dispatch.mjs"; export const DEFAULTS = Object.freeze({ base: "main", - workflow: "pr-unblock.yml", + workflow: "pr-refine.yml", activity: "fix-docs", - checkName: "pr-unblock / fix-docs", + checkName: "pr-refine / fix-docs", + // fix-docs ran in the pr-unblock lane before; a run there counts as done. + earlierCheckName: "pr-unblock / fix-docs", + // Account ids (GET /users/, the same as GraphQL's databaseId): + // dependabot[bot], whose own rebases a lane commit stops; + // melodic-plugin-release[bot] and melodic-standards-sync[bot], whose PRs + // carry generated or synced files. + excludedAuthorIds: Object.freeze([49699333, 337859524, 300666570]), // The GitHub Actions app, as in dispatch.mjs: only its runs count. actionsAppId: 15368, // fix-docs's applies-when labels-absent (docs/conventions/pr-pipeline.yaml). @@ -42,7 +48,7 @@ export const LIST_QUERY = `query($owner: String!, $name: String!, $base: String! pullRequests(states: OPEN, baseRefName: $base, first: 50, after: $after) { nodes { number isDraft isCrossRepository baseRefName headRefOid - author { __typename login } + author { __typename login ... on Bot { databaseId } ... on User { databaseId } } labels(first: 100) { nodes { name } } files(first: 100) { nodes { path } pageInfo { hasNextPage } } commits(last: 1) { @@ -67,7 +73,7 @@ export const LIST_QUERY = `query($owner: String!, $name: String!, $base: String! } }`; -export const HISTORY_QUERY = `query($owner: String!, $name: String!, $number: Int!, $appId: Int!, $checkName: String!) { +export const HISTORY_QUERY = `query($owner: String!, $name: String!, $number: Int!, $appId: Int!, $checkName: String!, $earlierCheckName: String!) { rateLimit { cost remaining } repository(owner: $owner, name: $name) { pullRequest(number: $number) { @@ -75,7 +81,10 @@ export const HISTORY_QUERY = `query($owner: String!, $name: String!, $number: In nodes { commit { checkSuites(first: 20, filterBy: { appId: $appId }) { - nodes { checkRuns(first: 1, filterBy: { checkName: $checkName }) { totalCount } } + nodes { + checkRuns(first: 1, filterBy: { checkName: $checkName }) { totalCount } + earlierRuns: checkRuns(first: 1, filterBy: { checkName: $earlierCheckName }) { totalCount } + } pageInfo { hasNextPage } } } @@ -86,13 +95,16 @@ export const HISTORY_QUERY = `query($owner: String!, $name: String!, $number: In }`; // Why a listed PR is not a candidate, or "" when it is one. -export function skipReason(node, { base, actionsAppId, checkName, holdLabels }) { +export function skipReason(node, { base, actionsAppId, checkName, earlierCheckName, excludedAuthorIds, holdLabels }) { if (node.isDraft !== false || node.isCrossRepository !== false || node.baseRefName !== base) { return "not a ready same-repository PR into the base"; } - // pr-refine.yml skipped the lanes App's events and every other bot's. - if (node.author?.__typename !== "User") { - return "not opened by a person"; + const author = node.author?.databaseId; + if (!Number.isSafeInteger(author)) { + return "author has no account id"; + } + if (excludedAuthorIds.includes(author)) { + return "opened by an excluded account"; } if ((node.labels?.nodes ?? []).some((label) => holdLabels.includes(label?.name))) { return "held"; @@ -107,21 +119,29 @@ export function skipReason(node, { base, actionsAppId, checkName, holdLabels }) if (!commit || commit.oid !== node.headRefOid || commit.statusCheckRollup?.contexts?.pageInfo?.hasNextPage) { return "head commit or its checks not listed whole"; } + const names = [checkName, earlierCheckName]; const runs = (commit.statusCheckRollup?.contexts?.nodes ?? []).filter( - (context) => context?.__typename === "CheckRun" && context.name === checkName && context.checkSuite?.app?.databaseId === actionsAppId, + (context) => context?.__typename === "CheckRun" && names.includes(context.name) && context.checkSuite?.app?.databaseId === actionsAppId, ); return runs.length > 0 ? `head already has a ${checkName} check run` : ""; } -// Whether any of the PR's last commits carries a check run of checkName; a +// Whether any of the PR's last commits carries a check run of either name; a // commit whose suites were not listed whole counts as carrying one. function historyHasRun(pullRequest) { return (pullRequest?.commits?.nodes ?? []).some((node) => { const suites = node?.commit?.checkSuites; - return suites?.pageInfo?.hasNextPage === true || (suites?.nodes ?? []).some((suite) => suite?.checkRuns?.totalCount > 0); + return ( + suites?.pageInfo?.hasNextPage === true || + (suites?.nodes ?? []).some((suite) => suite?.checkRuns?.totalCount > 0 || suite?.earlierRuns?.totalCount > 0) + ); }); } +// The run-name the workflow gives a dispatch: its file stem, the activity +// and the PR (pr-refine.yml's run-name). +const runTitle = (workflow, activity, number) => `${workflow.replace(/\.ya?ml$/, "")} ${activity} #${number}`; + export async function dispatchDocsFixes({ api, runId, @@ -132,6 +152,8 @@ export async function dispatchDocsFixes({ workflow = DEFAULTS.workflow, activity = DEFAULTS.activity, checkName = DEFAULTS.checkName, + earlierCheckName = DEFAULTS.earlierCheckName, + excludedAuthorIds = DEFAULTS.excludedAuthorIds, actionsAppId = DEFAULTS.actionsAppId, holdLabels = DEFAULTS.holdLabels, }) { @@ -141,8 +163,8 @@ export async function dispatchDocsFixes({ const { rateLimit, pullRequests } = await api.listDocsFixCandidates({ base, after }); log(`fix-docs listing page ${page}: rateLimit.cost=${rateLimit?.cost} remaining=${rateLimit?.remaining}`); for (const node of pullRequests.nodes) { - const reason = skipReason(node, { base, actionsAppId, checkName, holdLabels }); - if (reason.startsWith("head already has")) { + const reason = skipReason(node, { base, actionsAppId, checkName, earlierCheckName, excludedAuthorIds, holdLabels }); + if (reason.startsWith("head already has") || (reason === "" && isHandled(record, earlierCheckName, node.headRefOid))) { markHandled(record, checkName, node.headRefOid); } else if (reason === "" && !isHandled(record, checkName, node.headRefOid)) { candidates.push(node); @@ -176,13 +198,13 @@ export async function dispatchDocsFixes({ log(`defer fix-docs #${node.number}: ${maxDispatch} fix-docs dispatches this run; left for a later run`); continue; } - const inFlight = active.find((run) => run.display_title === runTitle(activity, node.number)); + const inFlight = active.find((run) => run.display_title === runTitle(workflow, activity, node.number)); if (inFlight) { log(`skip fix-docs #${node.number}: ${workflow} run ${inFlight.id} is not done yet`); continue; } try { - const { rateLimit, pullRequest } = await api.readDocsFixHistory({ number: node.number, appId: actionsAppId, checkName }); + const { rateLimit, pullRequest } = await api.readDocsFixHistory({ number: node.number, appId: actionsAppId, checkName, earlierCheckName }); log(`fix-docs history #${node.number}: rateLimit.cost=${rateLimit?.cost} remaining=${rateLimit?.remaining}`); if (historyHasRun(pullRequest)) { markHandled(record, checkName, node.headRefOid); diff --git a/.github/scripts/pr-unblock/fix-docs-dispatch.test.mjs b/.github/scripts/pr-unblock/fix-docs-dispatch.test.mjs index d01aeb9911..37dea6ec1c 100644 --- a/.github/scripts/pr-unblock/fix-docs-dispatch.test.mjs +++ b/.github/scripts/pr-unblock/fix-docs-dispatch.test.mjs @@ -1,8 +1,10 @@ // Expected values come from fix-docs's applies-when in // docs/conventions/pr-pipeline.yaml (paths **/*.md, labels-absent do-not-merge -// and needs-human, draft false), the check name `pr-unblock / fix-docs` the -// read runner posts as ` / `, and pr-refine.yml's old trigger: -// once when a PR opened ready or turned ready, never on a bot's event. +// and needs-human, draft false), the check name `pr-refine / fix-docs` the +// read runner posts as ` / ` (and `pr-unblock / fix-docs`, the +// lane fix-docs ran in before), the owner's call to tidy every PR but +// Dependabot's, the release App's and the standards-sync App's, and once per +// PR, as pr-refine.yml's old trigger ran it when a PR opened or turned ready. import assert from "node:assert/strict"; import { test } from "node:test"; @@ -11,7 +13,13 @@ import { createGitHubApi } from "./dispatch.mjs"; import { dispatchDocsFixes, HISTORY_COMMITS, HISTORY_QUERY, LIST_QUERY } from "./fix-docs-dispatch.mjs"; const ACTIONS = 15368; -const CHECK = "pr-unblock / fix-docs"; +const CHECK = "pr-refine / fix-docs"; +const EARLIER_CHECK = "pr-unblock / fix-docs"; +// GET /users/ ids, which GraphQL's Bot.databaseId returns too. +const DEPENDABOT = 49699333; +const RELEASE_APP = 337859524; +const STANDARDS_SYNC_APP = 300666570; +const LANES_APP = 337595936; const checkRun = (name, app = ACTIONS) => ({ __typename: "CheckRun", name, checkSuite: { app: { databaseId: app } } }); @@ -24,7 +32,7 @@ function pr(number, { paths = ["docs/guide.md"], more = false, checks = [], ...o isCrossRepository: false, baseRefName: "main", headRefOid: head, - author: { __typename: "User", login: "someone" }, + author: { __typename: "User", login: "someone", databaseId: 4242 }, labels: { nodes: [] }, files: { nodes: paths.map((path) => ({ path })), pageInfo: { hasNextPage: more } }, commits: { nodes: [{ commit: { oid: head, statusCheckRollup: { contexts: { nodes: checks, pageInfo: { hasNextPage: false } } } } }] }, @@ -32,9 +40,16 @@ function pr(number, { paths = ["docs/guide.md"], more = false, checks = [], ...o }; } -// Per PR number, the check-run counts on each of its last commits; absent is none. -const history = (counts = []) => ({ - commits: { nodes: counts.map((count) => ({ commit: { checkSuites: { nodes: [{ checkRuns: { totalCount: count } }], pageInfo: { hasNextPage: false } } } })) }, +// Per PR number, the check-run counts on each of its last commits; absent is +// none. `earlier` gives the counts under the earlier check name. +const history = (counts = [], earlier = counts.map(() => 0)) => ({ + commits: { + nodes: counts.map((count, index) => ({ + commit: { + checkSuites: { nodes: [{ checkRuns: { totalCount: count }, earlierRuns: { totalCount: earlier[index] } }], pageInfo: { hasNextPage: false } }, + }, + })), + }, }); function fakeApi({ pages = [[]], histories = {}, workflowRuns = [], failHistory = new Set() } = {}) { @@ -48,8 +63,8 @@ function fakeApi({ pages = [[]], histories = {}, workflowRuns = [], failHistory pullRequests: { nodes: pages[index], pageInfo: { hasNextPage: index + 1 < pages.length, endCursor: String(index + 1) } }, }; }, - async readDocsFixHistory({ number, appId, checkName }) { - calls.history.push({ number, appId, checkName }); + async readDocsFixHistory({ number, appId, checkName, earlierCheckName }) { + calls.history.push({ number, appId, checkName, earlierCheckName }); if (failHistory.has(number)) { throw new Error("history read failed"); } @@ -75,13 +90,13 @@ async function run(apiOptions, options = {}) { const numbers = (api) => api.calls.dispatch.map((call) => call.inputs["pr-number"]); -test("a ready PR a person opened that changes Markdown is dispatched fix-docs on main with the dispatcher's run id", async () => { +test("a ready PR a person opened that changes Markdown is dispatched pr-refine.yml fix-docs on main with the dispatcher's run id", async () => { const { api, result, lines } = await run({ pages: [[pr(5)]] }); assert.deepEqual(api.calls.dispatch, [ - { workflow: "pr-unblock.yml", ref: "refs/heads/main", inputs: { "pr-number": "5", activity: "fix-docs", "dispatcher-run-id": "4242" } }, + { workflow: "pr-refine.yml", ref: "refs/heads/main", inputs: { "pr-number": "5", activity: "fix-docs", "dispatcher-run-id": "4242" } }, ]); assert.deepEqual(result, { dispatched: [{ number: 5, workflowRunId: "8005" }], failed: 0 }); - assert.deepEqual(api.calls.history, [{ number: 5, appId: ACTIONS, checkName: CHECK }]); + assert.deepEqual(api.calls.history, [{ number: 5, appId: ACTIONS, checkName: CHECK, earlierCheckName: EARLIER_CHECK }]); assert.ok(lines.includes("fix-docs listing page 1: rateLimit.cost=3 remaining=4990"), "the listing's cost is logged"); }); @@ -104,14 +119,39 @@ test("a draft, a fork, a PR into another base, or a held PR is not dispatched", assert.deepEqual(api.calls.history, [], "no history read for a PR the listing drops"); }); -test("PRs the lanes App, Dependabot or any other bot opened are not dispatched, as pr-refine.yml skipped bot events", async () => { +test("PRs the lanes App or another bot opened are dispatched; Dependabot's, the release App's and the standards-sync App's are not", async () => { const pulls = [ - pr(20, { author: { __typename: "Bot", login: "melodic-automation-lanes" } }), - pr(21, { author: { __typename: "Bot", login: "dependabot" } }), - pr(22, { author: null }), - pr(23), + pr(20, { author: { __typename: "Bot", login: "melodic-automation-lanes", databaseId: LANES_APP } }), + pr(21, { author: { __typename: "Bot", login: "dependabot", databaseId: DEPENDABOT } }), + pr(22, { author: { __typename: "Bot", login: "melodic-plugin-release", databaseId: RELEASE_APP } }), + pr(23, { author: { __typename: "Bot", login: "melodic-standards-sync", databaseId: STANDARDS_SYNC_APP } }), + pr(24, { author: { __typename: "Bot", login: "claude", databaseId: 209825114 } }), + pr(25), ]; - assert.deepEqual(numbers((await run({ pages: [pulls] })).api), ["23"]); + assert.deepEqual(numbers((await run({ pages: [pulls] })).api), ["20", "24"]); +}); + +test("an excluded account is matched by id, not login, and a PR whose author has no id is not dispatched", async () => { + const pulls = [ + pr(26, { author: { __typename: "Bot", login: "renamed-dependabot", databaseId: DEPENDABOT } }), + pr(27, { author: { __typename: "User", login: "dependabot" } }), + pr(28, { author: null }), + pr(29, { author: { __typename: "Bot", login: "x", databaseId: "49699333" } }), + ]; + assert.deepEqual(numbers((await run({ pages: [pulls] })).api), []); +}); + +test("a head or an earlier commit with a pr-unblock / fix-docs run, from before the lane moved, is not dispatched again", async () => { + const record = emptyRecord(); + markHandled(record, EARLIER_CHECK, pr(38).headRefOid); + const { api } = await run({ + pages: [[pr(36, { checks: [checkRun(EARLIER_CHECK)] }), pr(37), pr(38), pr(39)]], + histories: { 37: history([0, 0], [0, 1]) }, + }, { record }); + assert.deepEqual(numbers(api), ["39"]); + assert.deepEqual(api.calls.history.map((call) => call.number), [37, 39], "a head recorded under the earlier name skips its read"); + assert.ok(isHandled(record, CHECK, pr(36).headRefOid), "a head handled under the earlier name is recorded under the current one"); + assert.ok(isHandled(record, CHECK, pr(37).headRefOid)); }); test("once per PR: a head with a fix-docs run, or any earlier commit with one, is not dispatched again", async () => { @@ -152,9 +192,9 @@ test("a fix-docs run still queued or going for the PR holds the dispatch", async const { api } = await run({ pages: [[pr(50), pr(51)]], workflowRuns: [ - { id: 1, status: "in_progress", display_title: "pr-unblock fix-docs #50" }, - { id: 2, status: "completed", display_title: "pr-unblock fix-docs #51" }, - { id: 3, status: "queued", display_title: "pr-unblock fix-ci #51" }, + { id: 1, status: "in_progress", display_title: "pr-refine fix-docs #50" }, + { id: 2, status: "completed", display_title: "pr-refine fix-docs #51" }, + { id: 3, status: "queued", display_title: "pr-unblock fix-docs #51" }, ], }); assert.deepEqual(numbers(api), ["51"]); @@ -182,9 +222,11 @@ test("the GitHub API sends the listing and history queries with their variables" }; const api = createGitHubApi({ token: "t", repository: "octo/repo", apiUrl: "https://api.example", graphqlUrl: "https://api.example/graphql", fetch }); await api.listDocsFixCandidates({ base: "main" }); - const read = await api.readDocsFixHistory({ number: 9, appId: ACTIONS, checkName: CHECK }); + const read = await api.readDocsFixHistory({ number: 9, appId: ACTIONS, checkName: CHECK, earlierCheckName: EARLIER_CHECK }); assert.deepEqual(bodies[0].variables, { owner: "octo", name: "repo", base: "main", after: null }); - assert.deepEqual(bodies[1].variables, { owner: "octo", name: "repo", number: 9, appId: ACTIONS, checkName: CHECK }); + assert.deepEqual(bodies[1].variables, { owner: "octo", name: "repo", number: 9, appId: ACTIONS, checkName: CHECK, earlierCheckName: EARLIER_CHECK }); + assert.match(LIST_QUERY, /\.\.\. on Bot \{ databaseId \}/); + assert.match(LIST_QUERY, /\.\.\. on User \{ databaseId \}/); assert.equal(bodies[1].query, HISTORY_QUERY); assert.match(HISTORY_QUERY, new RegExp(`commits\\(last: ${HISTORY_COMMITS}\\)`)); assert.deepEqual(read.pullRequest, history([0])); diff --git a/.github/workflows/pr-merge.yml b/.github/workflows/pr-merge.yml index 20688f5cfc..45beff2c21 100644 --- a/.github/workflows/pr-merge.yml +++ b/.github/workflows/pr-merge.yml @@ -997,11 +997,12 @@ jobs: retention-days: 1 if-no-files-found: error - # A pr-unblock run the broker refused as workflow-modified cannot - # pass on a rerun, which keeps its first event's workflow files. On - # the schedule, this pushes an empty commit with the lane token to - # each such PR whose head check-trusted-trigger would trust (no fork, - # no Dependabot PR), so the next pr-unblock run uses main's files: at + # A pr-unblock or pr-refine run the broker refused as + # workflow-modified cannot pass on a rerun, which keeps its first + # event's workflow files. On the schedule, this pushes an empty commit + # with the lane token to each such PR whose head check-trusted-trigger + # would trust (no fork, no Dependabot PR), so the lane's next run uses + # main's files: at # most five a run, and once per PR per version of the file the # broker named, recorded in a marker comment first. It runs after the # gate, which refuses a PR with a failing check, and not while the lane diff --git a/.github/workflows/pr-refine.yml b/.github/workflows/pr-refine.yml new file mode 100644 index 0000000000..8a5fab8955 --- /dev/null +++ b/.github/workflows/pr-refine.yml @@ -0,0 +1,180 @@ +name: pr-refine + +# Lane caller for pr-refine: its file stem is the lane, so the lanes token +# broker accepts its write activities. No App key; each run job's OIDC token is +# exchanged at LANES_BROKER_URL for audience LANES_BROKER_AUDIENCE. +# Job contract: docs/conventions/pr-pipeline/pr-run-activity.md. +# +# workflow_dispatch only. fix-docs: pr-unblock-dispatch.yml sends one per +# ready PR that changes a Markdown file and never had a fix-docs run, whoever +# opened it except Dependabot, the release App and the standards-sync App +# (fix-docs-dispatch.mjs). It sets dispatcher-run-id; a trusted human may +# dispatch with pr-number and activity alone. Every job trusts the default +# branch tip, as the dispatcher runs from the tip. +# +# Each activity is a model job and its push job (slots[].needs in +# docs/conventions/pr-pipeline.yaml); pairs on one PR serialize on the per-PR +# write queue. + +# fix-docs-dispatch.mjs reads this title to skip a second dispatch while one +# for the same activity and PR is still queued or going. Quoted: unquoted, +# " #" would start a YAML comment and drop the PR number. +run-name: "pr-refine ${{ inputs.activity }} #${{ inputs.pr-number }}" + +on: + workflow_dispatch: + inputs: + pr-number: + description: The PR to refine. + required: true + type: string + activity: + description: What to refine. + required: false + type: choice + options: [fix-docs] + default: fix-docs + dispatcher-run-id: + description: The dispatcher run that sent this dispatch; empty for a human dispatch. + required: false + type: string + default: "" + +permissions: {} + +# A newer dispatch of the same activity for the same PR replaces one still +# waiting; a running one finishes. +concurrency: + group: pr-refine-${{ inputs.activity }}-${{ inputs.pr-number }} + cancel-in-progress: false + +jobs: + # The PR head fix-docs's report posts its check on, read by a job that runs + # no head code, as fix-ci-context's is for fix-ci. Without it the report + # trusts only the head read at report time, so a push mid-run posts failure + # on the new head. + fix-docs-head: + if: >- + inputs.pr-number != '' && inputs.activity == 'fix-docs' + && format('{0}', github.event.sender.id) != vars.AUTOMATION_LANES_APP_SENDER_ID + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + pull-requests: read + outputs: + head-sha: ${{ steps.head.outputs.sha }} + steps: + - name: Read the PR's head SHA + id: head + env: + PR_NUMBER: ${{ inputs.pr-number }} + GH_TOKEN: ${{ github.token }} + run: | + [[ $PR_NUMBER =~ ^[1-9][0-9]*$ ]] || { echo "::error::pr-number is not a PR number"; exit 1; } + sha=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER" --jq .head.sha) + [[ $sha =~ ^[0-9a-f]{40}$ ]] || { echo "::error::no PR head SHA read"; exit 1; } + echo "sha=$sha" >>"$GITHUB_OUTPUT" + + # fix-docs is four jobs, so no model ever holds a write token. The model + # job: a read activity with no write token and no OIDC token in the job that + # runs head code. The skill fixes the changed Markdown files; the runner + # uploads its uncommitted edits as a patch. It runs on the automations + # role's API key alone. Drafts and held PRs skip in the config's + # applies-when, before any model turn. + fix-docs: + needs: fix-docs-head + if: >- + inputs.pr-number != '' && inputs.activity == 'fix-docs' + && format('{0}', github.event.sender.id) != vars.AUTOMATION_LANES_APP_SENDER_ID + permissions: + contents: read + pull-requests: read + checks: write + issues: write + id-token: write + concurrency: + group: pr-pipeline-write-${{ inputs.pr-number }} + queue: max + uses: ./.github/workflows/pr-run-activity-read.yml # zizmor: ignore[self-repository] + with: + role: automations + lane: pr-refine + activity: fix-docs + pr-number: ${{ inputs.pr-number }} + dispatcher-run-id: ${{ inputs.dispatcher-run-id }} + trusted-base: default-tip + head-sha: ${{ needs.fix-docs-head.outputs.head-sha }} + post-decline: false + timeout-minutes: 60 + secrets: + claude-api-key: ${{ secrets.AUTOMATIONS_CLAUDE_API_KEY }} + scan-carrier-private-key: ${{ secrets.SCAN_CARRIER_PRIVATE_KEY }} + + # The subscription fallback, as fix-ci-subscription is for fix-ci: it runs + # only when fix-docs's key leg hit a credit stop or its key is empty, reads + # only those two outputs, each compared to a fixed string, and holds the + # OAuth token alone. + fix-docs-subscription: + needs: [fix-docs-head, fix-docs] + if: >- + ${{ !cancelled() && (needs.fix-docs.outputs.lane-class == 'credit-stop' + || needs.fix-docs.outputs.credential-present == 'false') }} + permissions: + contents: read + pull-requests: read + checks: write + issues: write + id-token: write + concurrency: + group: pr-pipeline-write-${{ inputs.pr-number }} + queue: max + uses: ./.github/workflows/pr-run-activity-read.yml # zizmor: ignore[self-repository] + with: + role: automations + lane: pr-refine + activity: fix-docs + fallback: ${{ needs.fix-docs.outputs.lane-class == 'credit-stop' && 'credit-stop' || 'no-key' }} + pr-number: ${{ inputs.pr-number }} + dispatcher-run-id: ${{ inputs.dispatcher-run-id }} + trusted-base: default-tip + head-sha: ${{ needs.fix-docs-head.outputs.head-sha }} + post-decline: false + timeout-minutes: 60 + secrets: + claude-code-oauth-token: ${{ secrets.AUTOMATIONS_CLAUDE_CODE_OAUTH_TOKEN }} + scan-carrier-private-key: ${{ secrets.SCAN_CARRIER_PRIVATE_KEY }} + + # fix-docs's push job: the apply-patch activity under patch-rules.json's + # default row (fix-docs has no prepare mode). It runs only on a written + # patch: no change is the common case and needs no one. The lane has no + # escalation job, so a refused patch posts the pr-refine / apply-patch + # check neutral and labels nothing. The patch and its head come from + # whichever call ran the activity; apply-patch still applies onto the head + # its own gate reads. + apply-docs-patch: + needs: [fix-docs, fix-docs-subscription] + if: >- + ${{ !cancelled() && (needs.fix-docs-subscription.outputs.patch-state || needs.fix-docs.outputs.patch-state) == 'written' }} + permissions: + contents: read + pull-requests: read + checks: write + issues: write + id-token: write + concurrency: + group: pr-pipeline-write-${{ inputs.pr-number }} + queue: max + uses: ./.github/workflows/pr-run-activity-write.yml # zizmor: ignore[self-repository] + with: + role: automations + lane: pr-refine + activity: apply-patch + pr-number: ${{ inputs.pr-number }} + dispatcher-run-id: ${{ inputs.dispatcher-run-id }} + trusted-base: default-tip + patch-state: ${{ needs.fix-docs-subscription.outputs.patch-state || needs.fix-docs.outputs.patch-state }} + patch-sha256: ${{ needs.fix-docs-subscription.outputs.patch-sha256 || needs.fix-docs.outputs.patch-sha256 }} + patch-attempt: ${{ needs.fix-docs-subscription.outputs.patch-attempt || needs.fix-docs.outputs.patch-attempt }} + patch-activity: fix-docs + patch-head-sha: ${{ needs.fix-docs-subscription.outputs.head-sha || needs.fix-docs.outputs.head-sha }} + reply-attempt: ${{ needs.fix-docs-subscription.outputs.reply-attempt || needs.fix-docs.outputs.reply-attempt }} diff --git a/.github/workflows/pr-run-activity-read.yml b/.github/workflows/pr-run-activity-read.yml index dbab43edda..db9efde11c 100644 --- a/.github/workflows/pr-run-activity-read.yml +++ b/.github/workflows/pr-run-activity-read.yml @@ -535,7 +535,8 @@ jobs: dispatcher-workflow: .github/workflows/pr-unblock-dispatch.yml run-dispatcher-workflow: ${{ inputs.feedback-context-sha256 != '' && '.github/workflows/pr-feedback-dispatch.yml' || '' }} run-dispatcher-upstreams: ${{ inputs.feedback-context-sha256 != '' && '.github/workflows/pr-review-hosted.yml .github/workflows/pr-review-security-hosted.yml' || '' }} - dispatched-workflow: .github/workflows/pr-unblock.yml + # A dispatcher sends pr-refine.yml for the pr-refine lane, pr-unblock.yml otherwise. + dispatched-workflow: ${{ inputs.lane == 'pr-refine' && '.github/workflows/pr-refine.yml' || '.github/workflows/pr-unblock.yml' }} # Issue mode reads no PR: the account that applied the label, and # any re-runner, must be listed as a human; the lanes App passes # only by applying the caller's app-label. diff --git a/.github/workflows/pr-run-activity-write.yml b/.github/workflows/pr-run-activity-write.yml index be49ad39de..c6cdf4652e 100644 --- a/.github/workflows/pr-run-activity-write.yml +++ b/.github/workflows/pr-run-activity-write.yml @@ -584,7 +584,8 @@ jobs: # gated, and only the lanes App's signed commits may follow it. expected-upstream-head: ${{ inputs.expected-upstream-head }} lanes-app-id: ${{ vars.AUTOMATION_LANES_APP_SENDER_ID }} - dispatched-workflow: .github/workflows/pr-unblock.yml + # A dispatcher sends pr-refine.yml for the pr-refine lane, pr-unblock.yml otherwise. + dispatched-workflow: ${{ inputs.lane == 'pr-refine' && '.github/workflows/pr-refine.yml' || '.github/workflows/pr-unblock.yml' }} # Issue mode reads no PR: the account that applied the label, and # any re-runner, must be listed as a human. require-pr: ${{ inputs.issue-number == '' }} diff --git a/.github/workflows/pr-unblock-dispatch.yml b/.github/workflows/pr-unblock-dispatch.yml index 2567386759..31c021bb39 100644 --- a/.github/workflows/pr-unblock-dispatch.yml +++ b/.github/workflows/pr-unblock-dispatch.yml @@ -13,10 +13,11 @@ name: pr-unblock-dispatch # run failed) after the lost-run reconciler's re-run, and has no fix-ci run yet, # at most limits.fix-ci-per-run per run, from one GraphQL query whose # rateLimit.cost it logs (.github/scripts/pr-unblock/fix-ci-dispatch.mjs). It -# also dispatches fix-docs once per open, ready PR a person opened into main +# also dispatches pr-refine.yml's fix-docs once per open, ready PR into main # that changes a Markdown file, carries no hold label and never had a fix-docs -# check run, at most limits.fix-docs-per-run per run, from its own logged -# GraphQL listing (.github/scripts/pr-unblock/fix-docs-dispatch.mjs). No +# check run, whoever opened it except Dependabot, the release App and the +# standards-sync App, at most limits.fix-docs-per-run per run, from its own +# logged GraphQL listing (.github/scripts/pr-unblock/fix-docs-dispatch.mjs). No # schedule: the broker accepts a dispatcher run started by a push or a listed # human only. # @@ -24,7 +25,8 @@ name: pr-unblock-dispatch # workflow runs. A run started by hand, or a re-run of a push run, must come # from a listed trusted human. The dispatched lane runs its own trigger gate, # kill switch and config. Only the fix-ci and fix-docs listings filter by -# author and hold label, to spend no model run on a PR the lane would skip. +# author and hold label: fix-ci to spend no model run on a PR the lane would +# skip, fix-docs to leave alone PRs whose files are generated or synced. # # The token's budget is logged at the start and end of the job # (.github/actions/log-api-budget), and the script logs its own call counts. @@ -56,7 +58,7 @@ jobs: contents: read # check out main pull-requests: read # list open PRs and their mergeable state checks: read # skip a PR whose head already has a pr-unblock check run; read ci-status - actions: write # list and dispatch pr-unblock.yml runs + actions: write # list and dispatch pr-unblock.yml and pr-refine.yml runs steps: - name: Check out the default branch tip uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/.github/workflows/pr-unblock.yml b/.github/workflows/pr-unblock.yml index 915f0f6a85..42fcaec3a9 100644 --- a/.github/workflows/pr-unblock.yml +++ b/.github/workflows/pr-unblock.yml @@ -16,13 +16,10 @@ name: pr-unblock # bump, an action publisher outside the allowlist, a group holding a major, or # a red ci-status) whose head has no review yet. fix-ci: pr-unblock-dispatch.yml # also sends one per lanes App PR whose ci-status or pr-test-windows run failed -# past the lost-run reconciler's re-run. fix-docs: -# pr-unblock-dispatch.yml also sends one per ready PR a person opened that -# changes a Markdown file and never had a fix-docs run. All set -# dispatcher-run-id; a trusted human may dispatch with pr-number and activity -# alone. Every job -# trusts the default branch tip, since a conflicting PR's base SHA is stale and -# the dispatchers run from the tip. +# past the lost-run reconciler's re-run. All set dispatcher-run-id; a trusted +# human may dispatch with pr-number and activity alone. Every job trusts the +# default branch tip, since a conflicting PR's base SHA is stale and the +# dispatchers run from the tip. # feedback-dispatch.mjs and dispatch.mjs read this title to skip a second # dispatch while one for the same activity and PR is still queued or going. @@ -40,7 +37,7 @@ on: description: What to unblock. required: false type: choice - options: [resolve-conflicts, address-feedback, review-dependency, fix-ci, fix-docs] + options: [resolve-conflicts, address-feedback, review-dependency, fix-ci] default: resolve-conflicts dispatcher-run-id: description: The dispatcher run that sent this dispatch; empty for a human dispatch. @@ -821,136 +818,6 @@ jobs: patch-model-rung: "1" reply-attempt: ${{ needs.fix-ci-rung-up-subscription.outputs.reply-attempt || needs.fix-ci-rung-up.outputs.reply-attempt }} - # The PR head fix-docs's report posts its check on, read by a job that runs - # no head code, as fix-ci-context's is for fix-ci. Without it the report - # trusts only the head read at report time, so a push mid-run posts failure - # on the new head. - fix-docs-head: - if: >- - inputs.pr-number != '' && inputs.activity == 'fix-docs' - && format('{0}', github.event.sender.id) != vars.AUTOMATION_LANES_APP_SENDER_ID - runs-on: ubuntu-24.04 - timeout-minutes: 5 - permissions: - pull-requests: read - outputs: - head-sha: ${{ steps.head.outputs.sha }} - steps: - - name: Read the PR's head SHA - id: head - env: - PR_NUMBER: ${{ inputs.pr-number }} - GH_TOKEN: ${{ github.token }} - run: | - [[ $PR_NUMBER =~ ^[1-9][0-9]*$ ]] || { echo "::error::pr-number is not a PR number"; exit 1; } - sha=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER" --jq .head.sha) - [[ $sha =~ ^[0-9a-f]{40}$ ]] || { echo "::error::no PR head SHA read"; exit 1; } - echo "sha=$sha" >>"$GITHUB_OUTPUT" - - # fix-docs is four jobs, so no model ever holds a write token. The model - # job: a read activity with no write token and no OIDC token in the job that - # runs head code. The skill fixes the changed Markdown files; the runner - # uploads its uncommitted edits as a patch. It runs on the automations - # role's API key alone. Drafts and held PRs skip in the config's - # applies-when, before any model turn. - fix-docs: - needs: fix-docs-head - if: >- - inputs.pr-number != '' && inputs.activity == 'fix-docs' - && format('{0}', github.event.sender.id) != vars.AUTOMATION_LANES_APP_SENDER_ID - permissions: - contents: read - pull-requests: read - checks: write - issues: write - id-token: write - concurrency: - group: pr-pipeline-write-${{ inputs.pr-number }} - queue: max - uses: ./.github/workflows/pr-run-activity-read.yml # zizmor: ignore[self-repository] - with: - role: automations - lane: pr-unblock - activity: fix-docs - pr-number: ${{ inputs.pr-number }} - dispatcher-run-id: ${{ inputs.dispatcher-run-id }} - trusted-base: default-tip - head-sha: ${{ needs.fix-docs-head.outputs.head-sha }} - post-decline: false - timeout-minutes: 60 - secrets: - claude-api-key: ${{ secrets.AUTOMATIONS_CLAUDE_API_KEY }} - scan-carrier-private-key: ${{ secrets.SCAN_CARRIER_PRIVATE_KEY }} - - # The subscription fallback, as fix-ci-subscription is for fix-ci: it runs - # only when fix-docs's key leg hit a credit stop or its key is empty, reads - # only those two outputs, each compared to a fixed string, and holds the - # OAuth token alone. - fix-docs-subscription: - needs: [fix-docs-head, fix-docs] - if: >- - ${{ !cancelled() && (needs.fix-docs.outputs.lane-class == 'credit-stop' - || needs.fix-docs.outputs.credential-present == 'false') }} - permissions: - contents: read - pull-requests: read - checks: write - issues: write - id-token: write - concurrency: - group: pr-pipeline-write-${{ inputs.pr-number }} - queue: max - uses: ./.github/workflows/pr-run-activity-read.yml # zizmor: ignore[self-repository] - with: - role: automations - lane: pr-unblock - activity: fix-docs - fallback: ${{ needs.fix-docs.outputs.lane-class == 'credit-stop' && 'credit-stop' || 'no-key' }} - pr-number: ${{ inputs.pr-number }} - dispatcher-run-id: ${{ inputs.dispatcher-run-id }} - trusted-base: default-tip - head-sha: ${{ needs.fix-docs-head.outputs.head-sha }} - post-decline: false - timeout-minutes: 60 - secrets: - claude-code-oauth-token: ${{ secrets.AUTOMATIONS_CLAUDE_CODE_OAUTH_TOKEN }} - scan-carrier-private-key: ${{ secrets.SCAN_CARRIER_PRIVATE_KEY }} - - # fix-docs's push job: the apply-patch activity under patch-rules.json's - # default row (fix-docs has no prepare mode). It runs only on a written - # patch: no change is the common case and needs no one. report-escalation - # does not need it, so a refused patch posts the pr-unblock / apply-patch - # check neutral and labels nothing. The patch and its head come from - # whichever call ran the activity; apply-patch still applies onto the head - # its own gate reads. - apply-docs-patch: - needs: [fix-docs, fix-docs-subscription] - if: >- - ${{ !cancelled() && (needs.fix-docs-subscription.outputs.patch-state || needs.fix-docs.outputs.patch-state) == 'written' }} - permissions: - contents: read - pull-requests: read - checks: write - issues: write - id-token: write - concurrency: - group: pr-pipeline-write-${{ inputs.pr-number }} - queue: max - uses: ./.github/workflows/pr-run-activity-write.yml # zizmor: ignore[self-repository] - with: - role: automations - lane: pr-unblock - activity: apply-patch - pr-number: ${{ inputs.pr-number }} - dispatcher-run-id: ${{ inputs.dispatcher-run-id }} - trusted-base: default-tip - patch-state: ${{ needs.fix-docs-subscription.outputs.patch-state || needs.fix-docs.outputs.patch-state }} - patch-sha256: ${{ needs.fix-docs-subscription.outputs.patch-sha256 || needs.fix-docs.outputs.patch-sha256 }} - patch-attempt: ${{ needs.fix-docs-subscription.outputs.patch-attempt || needs.fix-docs.outputs.patch-attempt }} - patch-activity: fix-docs - patch-head-sha: ${{ needs.fix-docs-subscription.outputs.head-sha || needs.fix-docs.outputs.head-sha }} - reply-attempt: ${{ needs.fix-docs-subscription.outputs.reply-attempt || needs.fix-docs.outputs.reply-attempt }} - # GitHub starts no pull_request workflow on a conflicted PR, so a head the # lane could not resolve gets no CI and no sign why. This script job, with # no model, posts a failing pr-unblock/conflict commit status on that head. diff --git a/docs/conventions/pr-pipeline.yaml b/docs/conventions/pr-pipeline.yaml index 98c8dc1a7b..ab9d13c266 100644 --- a/docs/conventions/pr-pipeline.yaml +++ b/docs/conventions/pr-pipeline.yaml @@ -385,6 +385,18 @@ lanes: needs: - resolve-issue - apply-issue-patch + # Tidies a ready PR. Each activity is a model job and its push job, in + # .github/workflows/pr-refine.yml. + pr-refine: + stage: refine + slots: + # Started by pr-unblock-dispatch.yml once per ready PR that changes a + # Markdown file, whoever opened it except Dependabot, the release App + # and the standards-sync App. + - activity: fix-docs + # Runs after fix-docs, in its own job with its own token. + - activity: apply-patch + needs: [fix-docs] pr-unblock: stage: unblock slots: @@ -406,12 +418,9 @@ lanes: # Started by pr-unblock-dispatch.yml for lanes App PRs whose ci-status # failed. - activity: fix-ci - # Started by pr-unblock-dispatch.yml once per ready PR that changes a - # Markdown file. - - activity: fix-docs - # Runs after fix-ci or fix-docs, in its own job with its own token. + # Runs after fix-ci, in its own job with its own token. - activity: apply-patch - needs: [fix-ci, fix-docs] + needs: [fix-ci] # Hands the pull request to a person when any of the others escalates. - activity: report-escalation needs: [resolve-conflicts, address-feedback, publish-feedback, review-dependency, apply-dependency-verdict, fix-ci, apply-patch] diff --git a/docs/conventions/pr-pipeline/README.md b/docs/conventions/pr-pipeline/README.md index 6cf06cfb5d..f1aa02b738 100644 --- a/docs/conventions/pr-pipeline/README.md +++ b/docs/conventions/pr-pipeline/README.md @@ -110,13 +110,13 @@ reader applies. | Lane | Starts on | |---|---| -| `pr-refine` | no caller today; `fix-docs` runs in `pr-unblock` | +| `pr-refine` | a `fix-docs` dispatch from `pr-unblock-dispatch.yml` for each ready PR that changes Markdown and never had `fix-docs`, after a push to the default branch, or a trusted human's dispatch | | `pr-run-checks` | dispatch from `pr-refine` | | `pr-review`, `pr-review-security` | dispatch from `pr-refine` | | `pr-verify` | dispatch from `pr-refine` | | `pr-explain` | dispatch from `pr-refine`, alongside verify | | `pr-require-checks` | each verify result | -| `pr-unblock` | a dispatch for each conflicting PR, each unreviewed review-tier Dependabot PR, each failing lanes App PR and each ready PR a person opened that changes Markdown and never had `fix-docs` after a push to the default branch, a dispatch after a review lane finishes on a lanes App PR with an open reviewer-bot thread, or a trusted human's dispatch | +| `pr-unblock` | a dispatch for each conflicting PR, each unreviewed review-tier Dependabot PR, and each failing lanes App PR after a push to the default branch, a dispatch after a review lane finishes on a lanes App PR with an open reviewer-bot thread, or a trusted human's dispatch | | `pr-merge` | final check green, or a hold removed | | `work-unblock` | an issue closing, by a listed human, and an hourly sweep of the issues a listed human or a merge (a merged PR or a commit) closed in the last three hours (catching PR merges a bot ran); labels `agent-ready` on each issue it blocked that is now unblocked, unheld and owner-ratified; the label starts `work-resolve-issue`. The sweep also restarts or re-fires stopped issue runs and relabels a red-main fix issue a repeat failure reopened | | `post-merge-verify` | push to the default branch | @@ -260,24 +260,27 @@ and no `fix-ci` run for the PR still queued or going, at most `limits.fix-ci-per lowest number first. Their run ids join the dispatched-runs record. The dispatcher job's concurrency group cancels a run in progress, so a burst of pushes ends in one listing. -`pr-unblock`'s `fix-docs` runs the same way in three jobs of `pr-unblock.yml`: `fix-docs` (a +`pr-refine`'s `fix-docs` runs the same way in three jobs of `pr-refine.yml`: `fix-docs` (a `read` skill in patch output mode with no prepare mode, on run-claude-cli with the automations role's API key) runs `ai-slop:audit fix` on the PR's changed Markdown files; `fix-docs-subscription` runs the same on the role's OAuth token, only after a credit stop or with the key empty; and `apply-docs-patch` runs the `apply-patch` activity on a `written` patch, pushing it as one commit under `patch-rules.json`'s `default` row, which refuses the universal set, every agent instruction path and more than 300 paths. No change is the common case, so an -`empty` patch runs no push job. `report-escalation` does not follow `fix-docs`: a refused or -unappliable patch posts the `pr-unblock / apply-patch` check neutral `awaiting-human` and labels +`empty` patch runs no push job. `pr-refine.yml` has no escalation job: a refused or +unappliable patch posts the `pr-refine / apply-patch` check neutral `awaiting-human` and labels nothing. Every job trusts the default branch tip (`trusted-base: default-tip`). `fix-docs` routes to the `docs-fix` profile ([Models](#models)). -The same `pr-unblock-dispatch.yml` run starts `fix-docs` once per pull request, as a PR opening -ready or turning ready used to: one GraphQL query per page of 50 open pull requests, its -`rateLimit.cost` logged, lists the non-draft, same-repository ones into `main` that a person -opened (a `User` author, so no lanes App or other bot PR), with no `do-not-merge` or -`needs-human` label, that change at least one `.md` file, and whose head has no -`pr-unblock / fix-docs` check run from the GitHub Actions app. For each, lowest number first, one +The same `pr-unblock-dispatch.yml` run dispatches `pr-refine.yml`'s `fix-docs` once per pull +request: one GraphQL query per page of 50 open pull requests, its `rateLimit.cost` logged, lists +the non-draft, same-repository ones into `main`, whoever opened them, lanes App PRs included, +except Dependabot (account 49699333, whose own rebases a lane commit stops), the release App +(337859524) and the standards-sync App (300666570), whose files are generated or synced; an +author with no account id is skipped. It keeps those with no `do-not-merge` or `needs-human` +label, that change at least one `.md` file, and whose head has no `pr-refine / fix-docs` or +`pr-unblock / fix-docs` (the lane `fix-docs` ran in before) check run from the GitHub Actions +app. For each, lowest number first, one more query reads the check runs on its last 50 commits, and a PR with a `fix-docs` run on any of them is skipped for good; the dispatch record keeps its head so the next run skips that read. A run older than 50 commits, or a force-push that dropped its commit, costs one more run. It sends @@ -432,7 +435,7 @@ Each leaf declares: paths `.github/CODEOWNERS` lists ([`pr-run-activity.md`](pr-run-activity.md#skill-activities); `changedPathTargets` in [`resolve.mjs`](../../../.github/actions/resolve-config/resolve.mjs)). When nothing is left, the - activity skips with `not-applicable-paths`: `pr-unblock / fix-docs` does not run on a PR whose + activity skips with `not-applicable-paths`: `pr-refine / fix-docs` does not run on a PR whose only changed Markdown is instruction surfaces or CODEOWNERS paths, such as this directory. - `scope` (`diff`, `tree`, `target`) and `applies-when` (paths, labels, `labels-absent`, work classes, events, `mergeable`, `draft`, `admission`). `mergeable` is `conflicting` or `clean`, diff --git a/docs/conventions/pr-pipeline/pr-run-activity.md b/docs/conventions/pr-pipeline/pr-run-activity.md index c2676586e6..f6c2b855c6 100644 --- a/docs/conventions/pr-pipeline/pr-run-activity.md +++ b/docs/conventions/pr-pipeline/pr-run-activity.md @@ -1152,14 +1152,14 @@ of `patch-rules.json`. The caller passes the outputs of whichever `fix-ci` call The run's `escalation-reason` output is the script's reason, else the select step's. The runner accepts the script's reasons only in this mode. -In `pr-unblock.yml` the same leaf, as the `apply-docs-patch` job, follows `fix-docs`, which has +In `pr-refine.yml` the same leaf, as the `apply-docs-patch` job, follows `fix-docs`, which has no prepare mode: the caller passes no `patch-prepare`, so the script takes the `default` row, and runs the job only on a `written` patch. That row refuses the universal set, escalating `patch-protected-path`, caps a patch at 300 paths (`patch-invalid`), takes a moved head with `--3way` when the moved commits touch no patched path (`skip-moved-head: false`), and commits -`Apply fix-docs on ` with the trailer `Lane-Activity: pr-unblock/fix-docs`. -`report-escalation` does not need `apply-docs-patch` and takes no `patch-*` reason, so an -escalation leaves only the neutral `awaiting-human` check. +`Apply fix-docs on ` with the trailer `Lane-Activity: pr-refine/fix-docs`. +`pr-refine.yml` has no escalation job, so a refusal leaves only the neutral `awaiting-human` +check. In issue mode the same leaf is `apply-issue-patch`, which runs [`apply-patch.mjs`](../../../.github/scripts/apply-patch/apply-patch.mjs) with `PATCH_TARGET=issue` diff --git a/scripts/read-runner-dispatcher-gate.test.sh b/scripts/read-runner-dispatcher-gate.test.sh index 6c9d880c47..e05a8b2b13 100755 --- a/scripts/read-runner-dispatcher-gate.test.sh +++ b/scripts/read-runner-dispatcher-gate.test.sh @@ -2,7 +2,8 @@ # Structure test: pr-run-activity-read.yml admits a github-actions[bot] # workflow_dispatch only through a pr-unblock-dispatch.yml run, or a # pr-feedback-dispatch.yml run whose recorded review run reviewed the PR's -# current head, that sent it to pr-unblock.yml, as +# current head, that sent it to pr-unblock.yml, or to pr-refine.yml for the +# pr-refine lane, as # docs/conventions/pr-pipeline/pr-run-activity.md (run job step 3) states. The gate's own checks are check-trusted-trigger's suite; this # asserts only the read file's wiring of them. # test-scope: .github/workflows/pr-run-activity-read.yml .github/standards/runner-policy/package.json @@ -31,8 +32,8 @@ const gate = steps.find((s) => s.id === "gate")?.with ?? {}; say(gate["dispatcher-run-id"] === "${{ inputs.dispatcher-run-id }}", "the trigger gate receives the dispatcher-run-id input"); say(gate["dispatcher-workflow"] === ".github/workflows/pr-unblock-dispatch.yml", "the gate trusts only a pr-unblock-dispatch.yml dispatcher run"); -say(gate["dispatched-workflow"] === ".github/workflows/pr-unblock.yml", - "the gate admits a dispatcher-sent run only when it is pr-unblock.yml"); +say(gate["dispatched-workflow"] === "${{ inputs.lane == 'pr-refine' && '.github/workflows/pr-refine.yml' || '.github/workflows/pr-unblock.yml' }}", + "the gate admits a dispatcher-sent run only when it is pr-refine.yml for the pr-refine lane, else pr-unblock.yml"); // pr-feedback-dispatch.yml, started by workflow_run after a review lane, // vouches for address-feedback's model calls only: the gate gets it only with // the select job's feedback context, which only a review-threads call passes. From 1e92c845c5531f17f0249e06c0550d815d426abc Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Fri, 23 Oct 2026 01:06:04 -0400 Subject: [PATCH 2/7] feat(harness-ops): list pr-refine.yml as a morning-brief lane again Co-Authored-By: Claude Opus 5.5 --- .changes/harness-ops/feat-pr-refine-restore-db899809.md | 7 +++++++ .../skills/morning-brief/scripts/morning-brief.sh | 1 + 2 files changed, 8 insertions(+) create mode 100644 .changes/harness-ops/feat-pr-refine-restore-db899809.md diff --git a/.changes/harness-ops/feat-pr-refine-restore-db899809.md b/.changes/harness-ops/feat-pr-refine-restore-db899809.md new file mode 100644 index 0000000000..6989a2877e --- /dev/null +++ b/.changes/harness-ops/feat-pr-refine-restore-db899809.md @@ -0,0 +1,7 @@ +--- +bump: patch +--- + +### Changed + +- `/harness-ops:morning-brief` lists `pr-refine.yml` among its default lane workflows again, now that the pr-pipeline `pr-refine` lane runs `fix-docs`. diff --git a/plugins/harness-ops/skills/morning-brief/scripts/morning-brief.sh b/plugins/harness-ops/skills/morning-brief/scripts/morning-brief.sh index 208ebac6ac..bbff4d8355 100755 --- a/plugins/harness-ops/skills/morning-brief/scripts/morning-brief.sh +++ b/plugins/harness-ops/skills/morning-brief/scripts/morning-brief.sh @@ -200,6 +200,7 @@ DEFAULT_LANE_WORKFLOWS=( "pr-review-hosted.yml" "pr-review-security-hosted.yml" "pr-verify.yml" + "pr-refine.yml" "pr-unblock.yml" "work-resolve-issue.yml" ) From 808cdc536edc2e91a35f86b677c62d6432fa910b Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Fri, 23 Oct 2026 02:18:39 -0400 Subject: [PATCH 3/7] docs(loop-lane): list pr-refine in the posting-lanes record Co-Authored-By: Claude Opus 5.5 --- docs/conventions/loop-lane/posting-lanes.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/conventions/loop-lane/posting-lanes.md b/docs/conventions/loop-lane/posting-lanes.md index 960e140d0b..0342e3fb58 100644 --- a/docs/conventions/loop-lane/posting-lanes.md +++ b/docs/conventions/loop-lane/posting-lanes.md @@ -20,12 +20,14 @@ Marks: **concurrency** (a workflow `concurrency:` group), **claim** (another ato | `intake-triage` | issue labels, comments | concurrency: `intake-triage-` | | `pr-unblock` (write activities, through `pr-run-activity-write`) | PR comments, pushes, checks | concurrency: `pr-pipeline-write-`, `queue: max` | | `pr-unblock` (the dispatching workflow itself) | none beyond the above | concurrency: `pr-unblock--` | -| `pr-unblock-dispatch`, `pr-feedback-dispatch` | start `pr-unblock` runs | concurrency: `pr-unblock-dispatch`; `pr-feedback-dispatch-` | +| `pr-refine` (write activity `apply-patch`, through `pr-run-activity-write`) | pushes, checks | concurrency: `pr-pipeline-write-`, `queue: max` | +| `pr-refine` (the dispatching workflow itself) | none beyond the above | concurrency: `pr-refine--` | +| `pr-unblock-dispatch`, `pr-feedback-dispatch` | start `pr-unblock` runs; `pr-unblock-dispatch` also starts `pr-refine` runs | concurrency: `pr-unblock-dispatch`; `pr-feedback-dispatch-` | | `pr-review-hosted`, `pr-review-security-hosted` | review comments, check | concurrency: `-`, `cancel-in-progress: true` | | `pr-check-managed-files-hosted`, `pr-verify` | read-only token at job level; no comment or label write found | concurrency (per PR or head sha); not verified to post | | `pr-require-checks` | statuses; the advisory linkage comment and the `needs-issue-linkage` label (`check-contract`); re-runs of red contract-only sibling runs (`actions: write`) | uncovered. Label and body-edit events take a per-run group (`ci-contract--`), so those runs do not serialize. Each status write is a fresh set, not a check-then-act. The comment upsert is check-then-act: it looks up the newest bot comment carrying the `` marker, so two overlapping runs that both find none each post one, and every later run edits only the newest. The duplicate is an advisory comment a person can ignore; the gate is the status, not the comment. A label add or remove is idempotent, and the next contract event re-derives it from the current body. A duplicate sibling re-run repeats the contract check, whose writes are covered above | -| `pr-run-activity-write` (reusable; called by `pr-unblock` and `work-resolve-issue`) | the caller's writes | covered by the caller's group: `pr-pipeline-write-` (`queue: max`) on the `pr-unblock` jobs; the `work-resolve-issue--` run group for `work-resolve-issue`, whose `resolve-issue` job also queues on `work-resolve-issue-slot-` | -| `pr-run-activity-read` (reusable; PR-mode callers the `pr-unblock` `fix-ci`, `fix-ci-subscription`, `fix-docs` and `fix-docs-subscription` jobs) | a check run; a patch artifact that `apply-patch` pushes through `pr-run-activity-write`; a call that hands off to its `-subscription` job posts neither | concurrency: the caller's run group `pr-unblock--`, `cancel-in-progress: false`, and each call's job group `pr-pipeline-write-` with `queue: max`, which differs from the run group, so neither waits on its own run | +| `pr-run-activity-write` (reusable; called by `pr-unblock`, `pr-refine` and `work-resolve-issue`) | the caller's writes | covered by the caller's group: `pr-pipeline-write-` (`queue: max`) on the `pr-unblock` and `pr-refine` jobs; the `work-resolve-issue--` run group for `work-resolve-issue`, whose `resolve-issue` job also queues on `work-resolve-issue-slot-` | +| `pr-run-activity-read` (reusable; PR-mode callers the `pr-unblock` `fix-ci` and `fix-ci-subscription` jobs and the `pr-refine` `fix-docs` and `fix-docs-subscription` jobs) | a check run; a patch artifact that `apply-patch` pushes through `pr-run-activity-write`; a call that hands off to its `-subscription` job posts neither | concurrency: the caller's run group `pr-unblock--` or `pr-refine--`, `cancel-in-progress: false`, and each call's job group `pr-pipeline-write-` with `queue: max`, which differs from the run group, so neither waits on its own run | | `pr-merge` | merge | concurrency: `pr-merge`, `queue: max` | | `dependabot-fragments` | fragment commit | concurrency: `dependabot-fragments` | | `maintenance-audit-lane-runs`, `maintenance-audit-test-selection` | audit report | concurrency: own named group (the second on one job only; its other job is read-only) | From 38a39ad5969a182dc25a02d172e2452a579d8451 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Fri, 23 Oct 2026 02:24:59 -0400 Subject: [PATCH 4/7] fix(pr-pipeline): leave dispatched pr-refine runs out of the pr-merge refire A pr-refine run is a workflow_dispatch on main, so an empty commit on the PR cannot make it pass; it only restarts CI and the review lanes. Co-Authored-By: Claude Opus 5.5 --- .../feat-pr-refine-restore-9a0d0a8e.md | 2 +- .github/scripts/pr-merge/refire-refused.mjs | 21 ++----- .../scripts/pr-merge/refire-refused.test.mjs | 61 ++----------------- .github/workflows/pr-merge.yml | 11 ++-- 4 files changed, 18 insertions(+), 77 deletions(-) diff --git a/.changes/pr-pipeline/feat-pr-refine-restore-9a0d0a8e.md b/.changes/pr-pipeline/feat-pr-refine-restore-9a0d0a8e.md index 824187c741..5a1434980e 100644 --- a/.changes/pr-pipeline/feat-pr-refine-restore-9a0d0a8e.md +++ b/.changes/pr-pipeline/feat-pr-refine-restore-9a0d0a8e.md @@ -6,4 +6,4 @@ bump: minor - `fix-docs` moves from `pr-unblock` back into its own `pr-refine` lane (stage `refine`): `pr-refine.yml` holds the `fix-docs`, `fix-docs-subscription` and `apply-docs-patch` jobs, and `pr-unblock.yml` no longer takes `fix-docs`. `pr-unblock-dispatch.yml` dispatches `pr-refine.yml`, still once per PR and at most `limits.fix-docs-per-run` per run; a PR that already has a `pr-unblock / fix-docs` run counts as done. - `fix-docs` now tidies every ready PR that changes Markdown, lanes App PRs included, except Dependabot's, the release App's and the standards-sync App's, matched by account id. -- Both activity runners name `pr-refine.yml` as the dispatched workflow for the `pr-refine` lane, and pr-merge's refire of a broker `workflow-modified` refusal covers dispatched `pr-refine` runs again. +- Both activity runners name `pr-refine.yml` as the dispatched workflow for the `pr-refine` lane. pr-merge's refire of a broker `workflow-modified` refusal leaves `pr-refine` runs out: a `pr-refine` run is dispatched on main, so an empty commit on the PR cannot make it pass, and a listed person re-dispatches it. diff --git a/.github/scripts/pr-merge/refire-refused.mjs b/.github/scripts/pr-merge/refire-refused.mjs index 94d67fa4f4..038b91d290 100755 --- a/.github/scripts/pr-merge/refire-refused.mjs +++ b/.github/scripts/pr-merge/refire-refused.mjs @@ -1,9 +1,9 @@ #!/usr/bin/env node -// refire-refused.mjs: gives a pull request a new head when a pr-unblock or -// pr-refine run on its current head failed at "Request the lane token" -// because the token broker refused it as workflow-modified. A rerun keeps its -// first event's workflow files, so no rerun can pass; after the new head, the -// lane's next run uses main's files. No model. +// refire-refused.mjs: gives a pull request a new head when a pr-unblock run +// on its current head failed at "Request the lane token" because the token +// broker refused it as workflow-modified. A rerun keeps its first event's +// workflow files, so no rerun can pass; after the new head, the next +// pr-unblock run uses main's files. No model. // // The new head is an empty commit: the head's own tree, the head as its only // parent, made with the lanes App token through the Git Data API and moved @@ -75,20 +75,11 @@ const LANES = Object.freeze({ ".github/workflows/pr-feedback-dispatch.yml", ], }, - // fix-docs-dispatch.mjs dispatches fix-docs once per pull request, and the - // refused run's check on the old head counts, so no dispatch follows. - "pr-refine.yml": { - events: ["workflow_dispatch"], - after: "pr-merge pushes an empty commit so the next pr-refine run uses main's files.", - starts: - " The push starts no pr-refine run itself: fix-docs runs once per pull request, so a listed person dispatches `pr-refine` with its number to run it again.", - files: [".github/workflows/pr-refine.yml", WRITE_RUNNER, ".github/workflows/pr-unblock-dispatch.yml"], - }, }); const REFUSED = /^lane-token-denied: the broker answered 403 with reason workflow-modified: (\.github\/workflows\/[A-Za-z0-9._-]+\.yml) differs from refs\/heads\/main$/; const MARKER = /^/; -const DISPATCH_TITLE = /^pr-(?:unblock|refine) [a-z]+(?:-[a-z]+)* #([1-9][0-9]{0,9})$/; +const DISPATCH_TITLE = /^pr-unblock [a-z]+(?:-[a-z]+)* #([1-9][0-9]{0,9})$/; const REPO = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/; // check-trusted-trigger's head checks. const HEAD_REF = /^[A-Za-z0-9._/-]+$/; diff --git a/.github/scripts/pr-merge/refire-refused.test.mjs b/.github/scripts/pr-merge/refire-refused.test.mjs index 762196234e..b16b1ce16d 100644 --- a/.github/scripts/pr-merge/refire-refused.test.mjs +++ b/.github/scripts/pr-merge/refire-refused.test.mjs @@ -27,12 +27,10 @@ const sha = (n) => String(n).padStart(40, "0"); const MAIN_TIP = "a".repeat(40); const LANE = "pr-unblock.yml"; const UNBLOCK = ".github/workflows/pr-unblock.yml"; -const REFINE_LANE = "pr-refine.yml"; -const REFINE = ".github/workflows/pr-refine.yml"; const WRITER = ".github/workflows/pr-run-activity-write.yml"; const ACTIONS_BOT = { id: 41898282, login: "github-actions[bot]" }; const FILES = [UNBLOCK, WRITER, ".github/workflows/pr-unblock-dispatch.yml", ".github/workflows/pr-feedback-dispatch.yml"]; -const MAIN_BLOBS = Object.fromEntries([...FILES, REFINE].map((file) => [file, BLOB])); +const MAIN_BLOBS = Object.fromEntries(FILES.map((file) => [file, BLOB])); const pr = (number, { user = OWNER, draft = false, repo = REPO, ref = `fix/${number}` } = {}) => ({ number, @@ -196,59 +194,12 @@ test("a dispatched pr-unblock run re-fires only when its failed check is on the assert.equal(pushes(rerun.calls).length, 0); }); -// A refused pr-refine run, dispatched on main for PR `number`, with its -// failed check on that PR's head. -const refineWorld = (number, runOptions = {}, options = {}) => - world({ - pulls: [pr(number)], - runs: { [REFINE_LANE]: [run(number * 10, { number, title: `pr-refine fix-docs #${number}`, messages: [denial(REFINE)], ...runOptions })] }, - checks: { [sha(number)]: [linkedCheck(number * 10)] }, - ...options, - }); - -test("a pr-refine run refused as workflow-modified on the current head gets one empty commit on that branch", async () => { - const w = refineWorld(7); - assert.equal(await main({ env, github: w.github, actions: w.actions, now: NOW }), 0); - assert.deepEqual(posts(w.calls), [`POST /repos/${REPO}/issues/7/comments ${JSON.stringify({ body: refireComment(REFINE, BLOB, sha(7), REFINE_LANE) })}`]); - assert.deepEqual(pushes(w.calls), [`PATCH /repos/${REPO}/git/refs/heads/fix/7 {"sha":"${NEW_SHA}","force":false}`]); -}); - -test("a pr-refine re-fire says the push starts no pr-refine run, which waits for a listed person's dispatch", async () => { - const w = refineWorld(7); +test("a pr-refine.yml run is no longer read or re-fired", async () => { + const w = world({ pulls: [pr(7)], runs: { "pr-refine.yml": [run(70, { number: 7 })] }, checks: { [sha(7)]: [linkedCheck(70)] } }); assert.equal(await main({ env, github: w.github, actions: w.actions, now: NOW }), 0); - const body = postedBody(w.calls); - assert.ok(body.includes("so the next pr-refine run uses main's files"), body); - assert.ok( - body.endsWith(" The push starts no pr-refine run itself: fix-docs runs once per pull request, so a listed person dispatches `pr-refine` with its number to run it again."), - body, - ); -}); - -test("a pr-refine run refused for the write runner or the dispatcher re-fires; one refused for pr-unblock.yml does not", async () => { - for (const file of [WRITER, ".github/workflows/pr-unblock-dispatch.yml"]) { - const w = refineWorld(7, { messages: [denial(file)] }); - assert.equal(await main({ env, github: w.github, actions: w.actions, now: NOW }), 0); - assert.equal(pushes(w.calls).length, 1, file); - } - const other = refineWorld(7, { messages: [denial(UNBLOCK)] }); - assert.equal(await main({ env, github: other.github, actions: other.actions, now: NOW }), 0); - assert.deepEqual(pushes(other.calls), []); -}); - -test("a pr-refine run on a head that changes a file the broker compares for it is not re-fired", async () => { - for (const file of [REFINE, WRITER]) { - const w = refineWorld(7, {}, { changed: { [sha(7)]: ["README.md", file] } }); - assert.equal(await main({ env, github: w.github, actions: w.actions, now: NOW }), 0); - assert.deepEqual(pushes(w.calls), [], file); - } -}); - -test("a pr-refine run whose title names no PR, or another workflow's title, re-fires nothing", async () => { - for (const title of ["pr-refine fix-docs", "pr-review fix-docs #7", "pr-refine fix-docs #7 extra"]) { - const w = refineWorld(7, { title }); - assert.equal(await main({ env, github: w.github, actions: w.actions, now: NOW }), 0); - assert.deepEqual(pushes(w.calls), [], title); - } + assert.equal(w.calls.some((call) => call.includes("/actions/workflows/pr-refine.yml/")), false); + assert.deepEqual(pushes(w.calls), []); + assert.deepEqual(posts(w.calls), []); }); // The comment text pr-merge posted for every lane until now, verbatim: markers diff --git a/.github/workflows/pr-merge.yml b/.github/workflows/pr-merge.yml index 4c682ccac1..e08fe0184f 100644 --- a/.github/workflows/pr-merge.yml +++ b/.github/workflows/pr-merge.yml @@ -1031,12 +1031,11 @@ jobs: retention-days: 1 if-no-files-found: error - # A pr-unblock or pr-refine run the broker refused as - # workflow-modified cannot pass on a rerun, which keeps its first - # event's workflow files. On the schedule, this pushes an empty commit - # with the lane token to each such PR whose head check-trusted-trigger - # would trust (no fork, no Dependabot PR), so the lane's next run uses - # main's files: at + # A pr-unblock run the broker refused as workflow-modified cannot + # pass on a rerun, which keeps its first event's workflow files. On + # the schedule, this pushes an empty commit with the lane token to + # each such PR whose head check-trusted-trigger would trust (no fork, + # no Dependabot PR), so the next pr-unblock run uses main's files: at # most five a run, and once per PR per version of the file the # broker named, recorded in a marker comment first. It runs after the # gate, which refuses a PR with a failing check, and not while the lane From 39af152361ea3ff0b467515b4256ca5426136eb6 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Fri, 23 Oct 2026 03:37:37 -0400 Subject: [PATCH 5/7] fix(pr-pipeline): start fix-docs-subscription on a jobs-API handoff read fix-docs-subscription started on fix-docs's lane-class and credential-present outputs, which its head-code run job can forge. A fix-docs-handoff job now runs fix-ci-handoff's jobs-API read, and the fallback starts only when the broker allowed fix-docs and its report posted no check. Refs: #8890 Co-Authored-By: Claude Opus 5.5 --- .../feat-pr-refine-restore-9a0d0a8e.md | 4 ++ .../actions/resolve-config/callers.test.mjs | 65 ++++++++++++++++++- .github/workflows/pr-refine.yml | 52 +++++++++++++-- docs/conventions/pr-pipeline/README.md | 5 +- .../pr-pipeline/pr-run-activity.md | 15 +++-- 5 files changed, 127 insertions(+), 14 deletions(-) diff --git a/.changes/pr-pipeline/feat-pr-refine-restore-9a0d0a8e.md b/.changes/pr-pipeline/feat-pr-refine-restore-9a0d0a8e.md index 5a1434980e..c8428604f3 100644 --- a/.changes/pr-pipeline/feat-pr-refine-restore-9a0d0a8e.md +++ b/.changes/pr-pipeline/feat-pr-refine-restore-9a0d0a8e.md @@ -7,3 +7,7 @@ bump: minor - `fix-docs` moves from `pr-unblock` back into its own `pr-refine` lane (stage `refine`): `pr-refine.yml` holds the `fix-docs`, `fix-docs-subscription` and `apply-docs-patch` jobs, and `pr-unblock.yml` no longer takes `fix-docs`. `pr-unblock-dispatch.yml` dispatches `pr-refine.yml`, still once per PR and at most `limits.fix-docs-per-run` per run; a PR that already has a `pr-unblock / fix-docs` run counts as done. - `fix-docs` now tidies every ready PR that changes Markdown, lanes App PRs included, except Dependabot's, the release App's and the standards-sync App's, matched by account id. - Both activity runners name `pr-refine.yml` as the dispatched workflow for the `pr-refine` lane. pr-merge's refire of a broker `workflow-modified` refusal leaves `pr-refine` runs out: a `pr-refine` run is dispatched on main, so an empty commit on the PR cannot make it pass, and a listed person re-dispatches it. + +### Security + +- `fix-docs-subscription` no longer starts on `fix-docs` outputs, which its head-code run job could forge. A `fix-docs-handoff` job reads this run's jobs through the Actions API, as `fix-ci-handoff` does for fix-ci. The fallback runs only when the broker allowed `fix-docs` and its report job posted no check (#8890). diff --git a/.github/actions/resolve-config/callers.test.mjs b/.github/actions/resolve-config/callers.test.mjs index bbc4351c42..e2d764b6f4 100644 --- a/.github/actions/resolve-config/callers.test.mjs +++ b/.github/actions/resolve-config/callers.test.mjs @@ -1340,7 +1340,7 @@ const PATCH_OUTPUTS = { "patch-state": "patch-state", "patch-sha256": "patch-sha const SCAN_ONLY = new Set(["fix-ci", "address-feedback", "review-dependency"]); // The base-code job whose jobs-API read starts a lane's fallback call, in // place of the first call's own handoff outputs. -const HANDOFF = { "fix-ci": "fix-ci-handoff" }; +const HANDOFF = { "fix-ci": "fix-ci-handoff", "fix-docs": "fix-docs-handoff" }; const PATCH_OUTPUTS_OF = (firstId) => SCAN_ONLY.has(firstId) ? Object.fromEntries(Object.entries(PATCH_OUTPUTS).map(([input, output]) => [input, `scan-${output}`])) : PATCH_OUTPUTS; const WRITE_RUNNER = "./.github/workflows/pr-run-activity-write.yml"; @@ -4026,6 +4026,69 @@ test("pr-unblock.yml: each fix-ci fallback call starts on a base job's jobs-API } }); +// #8890 for fix-docs: fix-docs's run job is as hostile as fix-ci's, so its +// fallback call starts on fix-docs-handoff's jobs-API read, the same step +// fix-ci-handoff runs, never on a fix-docs output. fix-docs posts nothing on a +// decline (post-decline: false), so the fallback also needs the broker's allow: +// a kill switch or a refusal starts no second leg, and a failed read leaves +// no check, so fix-docs-dispatch.mjs dispatches the PR again. +test("pr-refine.yml: fix-docs-subscription starts on a base job's jobs-API read of fix-docs's report, never on a fix-docs output", (t) => { + const { jobs } = workflow("pr-refine.yml"); + const sub = jobs["fix-docs-subscription"]; + const handoff = jobs["fix-docs-handoff"]; + assert.ok(handoff, "pr-refine.yml has a fix-docs-handoff job"); + assert.doesNotMatch(sub.if, /needs\.fix-docs\.outputs\./, "the start reads no fix-docs output"); + // The evaluator refuses any reference left unset: the start reads only these. + const starts = (posted, allowed, result = "success") => + evaluate(sub.if, { + "needs.fix-docs.result": result, + "needs.fix-docs-handoff.outputs.posted": posted, + "needs.fix-docs-handoff.outputs.allowed": allowed, + }); + assert.equal(starts("false", "true"), true, "a handoff after the broker's allow"); + assert.equal(starts("true", "true"), false, "fix-docs posted its check"); + assert.equal(starts("false", "false"), false, "a kill switch or broker refusal"); + assert.equal(starts("", ""), false, "a failed read: no check, so the dispatcher sends the PR again"); + assert.equal(starts("", "", "skipped"), false, "no fix-docs call, no fallback"); + assert.deepEqual(handoff.needs, ["fix-docs-head", "fix-docs"]); + for (const [result, runs] of [["success", true], ["failure", true], ["skipped", false]]) { + assert.equal(evaluate(handoff.if, { "needs.fix-docs.result": result }), runs, result); + } + assert.deepEqual(handoff.permissions, { actions: "read" }); + assert.deepEqual(handoff.steps.map((step) => step.uses ?? null), [null]); + assert.deepEqual(stepSecrets(handoff), []); + assert.doesNotMatch(JSON.stringify(handoff), /needs\.fix-docs\.outputs\./); + assert.deepEqual(handoff.outputs, { posted: "${{ steps.posted.outputs.posted }}", allowed: "${{ steps.posted.outputs.allowed }}" }); + const step = handoff.steps[0]; + assert.equal(step.env.CALLS, "fix-docs"); + // The one read #9363 runs for fix-ci, byte for byte. + assert.equal(step.run, workflow("pr-unblock.yml").jobs["fix-ci-handoff"].steps[0].run); + const listing = ({ posted = false, reportRan = true, allowed = true } = {}) => ({ + jobs: [ + { name: "fix-docs-head", conclusion: "success", steps: [] }, + { name: "fix-docs / broker-check", conclusion: "success", steps: [{ name: "Ask the broker whether the lane may run", conclusion: allowed ? "success" : "skipped" }] }, + { name: "fix-docs / run", conclusion: "success", steps: [{ name: "Post the check run", conclusion: "success" }] }, + { + name: "fix-docs / report", + conclusion: reportRan ? "success" : "skipped", + steps: reportRan ? [{ name: "Post the check run", conclusion: posted ? "success" : "skipped" }] : [], + }, + ], + }); + for (const [name, list, outputs] of [ + ["fix-docs posted its check", listing({ posted: true }), { posted: "true", allowed: "true" }], + ["a handoff skipped the report job", listing({ reportRan: false }), { posted: "false", allowed: "true" }], + ["the broker allowed nothing", listing({ allowed: false }), { posted: "false", allowed: "false" }], + ]) { + const read = readPosted(t, step, list); + assert.equal(read.status, 0, name); + assert.deepEqual(read.outputs, outputs, name); + } + const failed = readPosted(t, step, listing({ posted: true }), { fail: true }); + assert.notEqual(failed.status, 0); + assert.deepEqual(failed.outputs, {}); +}); + // #8890: a round in which no call posted left fix-ci-dispatch.mjs nothing to // count, so it dispatched the head again with no end. test("pr-unblock.yml: a fix-ci round with no posted check posts fix-ci failure on the gated head, which fix-ci-dispatch counts toward fix-ci-restarts", (t) => { diff --git a/.github/workflows/pr-refine.yml b/.github/workflows/pr-refine.yml index 8a5fab8955..48849df67a 100644 --- a/.github/workflows/pr-refine.yml +++ b/.github/workflows/pr-refine.yml @@ -110,15 +110,55 @@ jobs: claude-api-key: ${{ secrets.AUTOMATIONS_CLAUDE_API_KEY }} scan-carrier-private-key: ${{ secrets.SCAN_CARRIER_PRIVATE_KEY }} + # Whether fix-docs's report job posted its check, and whether the broker + # allowed the call, read from this run's jobs through the Actions API, as + # pr-unblock.yml's fix-ci-handoff reads them for fix-ci: never from a + # fix-docs output, since fix-docs's run job runs head code and can forge + # handed-off, lane-class or credential-present. A failed read leaves both + # empty, which starts no fallback; with no check posted, + # fix-docs-dispatch.mjs dispatches the PR again. + fix-docs-handoff: + needs: [fix-docs-head, fix-docs] + if: ${{ !cancelled() && needs.fix-docs.result != 'skipped' }} + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + actions: read # this run's jobs and their steps + outputs: + posted: ${{ steps.posted.outputs.posted }} + allowed: ${{ steps.posted.outputs.allowed }} + steps: + - name: Read whether fix-docs posted its check + id: posted + env: + GH_TOKEN: ${{ github.token }} + CALLS: fix-docs + run: | + names=$(gh api --paginate "repos/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID/jobs?filter=latest&per_page=100" \ + --jq '.jobs[] | (select(any(.steps[]?; .name == "Post the check run" and .conclusion == "success")) | "posted \(.name)"), + (select(any(.steps[]?; .name == "Ask the broker whether the lane may run" and .conclusion == "success")) | "allowed \(.name)")') + posted=false + allowed=false + for call in $CALLS; do + if grep -qxF "posted $call / report" <<<"$names"; then posted=true; fi + if grep -qxF "allowed $call / broker-check" <<<"$names"; then allowed=true; fi + done + printf 'posted=%s\nallowed=%s\n' "$posted" "$allowed" >>"$GITHUB_OUTPUT" + # The subscription fallback, as fix-ci-subscription is for fix-ci: it runs - # only when fix-docs's key leg hit a credit stop or its key is empty, reads - # only those two outputs, each compared to a fixed string, and holds the - # OAuth token alone. + # only when the broker allowed fix-docs and fix-docs posted no check, which + # a credit stop or an empty key leaves as a handoff with no upload, lane + # record or check. fix-docs posts nothing on a decline (post-decline: false), + # so the broker's allow keeps a kill switch or a refusal from starting a + # second leg. Its start reads only fix-docs-handoff, and it holds the OAuth + # token alone. fallback, the lane record's label, picks between two literals + # on fix-docs's lane-class: a forged one only mislabels the record. fix-docs-subscription: - needs: [fix-docs-head, fix-docs] + needs: [fix-docs-head, fix-docs, fix-docs-handoff] if: >- - ${{ !cancelled() && (needs.fix-docs.outputs.lane-class == 'credit-stop' - || needs.fix-docs.outputs.credential-present == 'false') }} + ${{ !cancelled() && needs.fix-docs.result != 'skipped' + && needs.fix-docs-handoff.outputs.posted != 'true' + && needs.fix-docs-handoff.outputs.allowed == 'true' }} permissions: contents: read pull-requests: read diff --git a/docs/conventions/pr-pipeline/README.md b/docs/conventions/pr-pipeline/README.md index a38fd68ca7..f636ac5f9e 100644 --- a/docs/conventions/pr-pipeline/README.md +++ b/docs/conventions/pr-pipeline/README.md @@ -272,8 +272,9 @@ concurrency group cancels a run in progress, so a burst of pushes ends in one li `pr-refine`'s `fix-docs` runs the same way in three jobs of `pr-refine.yml`: `fix-docs` (a `read` skill in patch output mode with no prepare mode, on run-claude-cli with the automations role's API key) runs `ai-slop:audit fix` on the PR's changed Markdown files; -`fix-docs-subscription` runs the same on the role's OAuth token, only after a credit stop or with -the key empty; and `apply-docs-patch` runs the `apply-patch` activity on a `written` patch, +`fix-docs-subscription` runs the same on the role's OAuth token, only when `fix-docs-handoff`'s +read of this run's jobs shows the broker allowed `fix-docs` and `fix-docs` posted no check, as a +credit stop or an empty key leaves it; and `apply-docs-patch` runs the `apply-patch` activity on a `written` patch, pushing it as one commit under `patch-rules.json`'s `default` row, which refuses the universal set, every agent instruction path and more than 300 paths. No change is the common case, so an `empty` patch runs no push job. `pr-refine.yml` has no escalation job: a refused or diff --git a/docs/conventions/pr-pipeline/pr-run-activity.md b/docs/conventions/pr-pipeline/pr-run-activity.md index c238a92ad5..9b0e86f746 100644 --- a/docs/conventions/pr-pipeline/pr-run-activity.md +++ b/docs/conventions/pr-pipeline/pr-run-activity.md @@ -643,14 +643,19 @@ job. The caller calls the read file twice, each call with one secret, named for `pr-unblock.yml`'s `fix-ci` passes only `claude-api-key` (`AUTOMATIONS_CLAUDE_API_KEY`), and `fix-ci-subscription` passes only `claude-code-oauth-token` (`AUTOMATIONS_CLAUDE_CODE_OAUTH_TOKEN`); its `fix-docs` and `fix-docs-subscription`, and its -`address-feedback` and `address-feedback-subscription`, do the same. The second runs only when the first's `lane-class` output -is `credit-stop` or its `credential-present` is `false`, each compared to that fixed string, and -passes `fallback: credit-stop` or `no-key`. Both queue in `pr-pipeline-write-`. fix-ci's +`address-feedback` and `address-feedback-subscription`, do the same. Where no handoff job stands +in (below), the second runs only when the first's `lane-class` output is `credit-stop` or its +`credential-present` is `false`, each compared to that fixed string. It passes +`fallback: credit-stop` or `no-key`. Both queue in `pr-pipeline-write-`. fix-ci's fallback calls start on no first-call output: `fix-ci-handoff` (and `fix-ci-rung-up-handoff` for the rung-up call), a job with only `actions: read` that checks out nothing, reads this run's jobs through the Actions API, and the fallback runs unless the first call's ` / report` job has -a `Post the check run` step that succeeded. A failed read runs the fallback too. Only `fallback`, -the lane record's label, still reads `lane-class`; a forged one mislabels the record. +a `Post the check run` step that succeeded. A failed read runs the fallback too. `pr-refine.yml`'s +`fix-docs-handoff` runs the same read for `fix-docs`, and `fix-docs-subscription` also needs the +first call's ` / broker-check` job to show the broker's allow: `fix-docs` posts nothing on a +decline, so a kill switch or a refusal starts no second leg, and a failed read starts none either, +since with no check posted `fix-docs-dispatch.mjs` dispatches the PR again. Only `fallback`, the +lane record's label, still reads `lane-class`; a forged one mislabels the record. 1. Before any head code, the run job fails red unless the `role` is lower-case kebab, the model route resolves an alias (`default` resolves to none) and the skill is advisory. While sudo From a6e77b3c687e48e0d251b98683c9cae0db21c17c Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Fri, 23 Oct 2026 04:33:56 -0400 Subject: [PATCH 6/7] fix(pr-pipeline): label fix-docs-subscription fallback: handoff fix-docs-subscription no longer reads a fix-docs output anywhere, so the run-job-output test drops its pending entry and lists the leg under pr-refine.yml. Refs: #8890 Co-Authored-By: Claude Opus 5.5 --- .github/actions/resolve-config/callers.test.mjs | 9 ++------- .github/workflows/pr-refine.yml | 7 ++++--- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/.github/actions/resolve-config/callers.test.mjs b/.github/actions/resolve-config/callers.test.mjs index 9c73d65dff..fb5be92c40 100644 --- a/.github/actions/resolve-config/callers.test.mjs +++ b/.github/actions/resolve-config/callers.test.mjs @@ -4488,10 +4488,6 @@ const RUN_JOB_OUTPUTS = () => Object.entries(workflow("pr-run-activity-read.yml").on.workflow_call.outputs) .filter(([, output]) => /\bjobs\.run\.outputs\./.test(output.value)) .map(([name]) => name); -// Automation Lanes' PR-B (#9332) gives fix-docs its handoff job; until then -// its subscription leg still starts on the run job's outputs. -const SUBSCRIPTION_START_PENDING = new Set(["pr-unblock.yml fix-docs-subscription"]); - // #9365: a forged run job could start or suppress a subscription leg. test("no subscription leg's if: reads a read runner run-job output", () => { const runOutputs = RUN_JOB_OUTPUTS(); @@ -4507,15 +4503,14 @@ test("no subscription leg's if: reads a read runner run-job output", () => { const read = [...String(jobs[id].if).matchAll(/needs\.([\w-]+)\.outputs\.([\w-]+)/g)] .filter(([, job, output]) => calls.has(job) && runOutputs.includes(output)) .map(([ref]) => ref); - if (SUBSCRIPTION_START_PENDING.has(leg)) assert.notDeepEqual(read, [], `${leg} no longer reads a run-job output: drop it from SUBSCRIPTION_START_PENDING`); - else assert.deepEqual(read, [], leg); + assert.deepEqual(read, [], leg); } } assert.deepEqual(legs.sort(), [ + "pr-refine.yml fix-docs-subscription", "pr-unblock.yml address-feedback-subscription", "pr-unblock.yml fix-ci-rung-up-subscription", "pr-unblock.yml fix-ci-subscription", - "pr-unblock.yml fix-docs-subscription", "pr-unblock.yml review-dependency-subscription", ]); }); diff --git a/.github/workflows/pr-refine.yml b/.github/workflows/pr-refine.yml index 48849df67a..0d1513a8db 100644 --- a/.github/workflows/pr-refine.yml +++ b/.github/workflows/pr-refine.yml @@ -151,8 +151,9 @@ jobs: # record or check. fix-docs posts nothing on a decline (post-decline: false), # so the broker's allow keeps a kill switch or a refusal from starting a # second leg. Its start reads only fix-docs-handoff, and it holds the OAuth - # token alone. fallback, the lane record's label, picks between two literals - # on fix-docs's lane-class: a forged one only mislabels the record. + # token alone. fallback: handoff labels the lane record without reading a + # fix-docs output: only the run job knows whether it was a credit stop or an + # empty key, and head code can forge that. fix-docs-subscription: needs: [fix-docs-head, fix-docs, fix-docs-handoff] if: >- @@ -173,7 +174,7 @@ jobs: role: automations lane: pr-refine activity: fix-docs - fallback: ${{ needs.fix-docs.outputs.lane-class == 'credit-stop' && 'credit-stop' || 'no-key' }} + fallback: handoff pr-number: ${{ inputs.pr-number }} dispatcher-run-id: ${{ inputs.dispatcher-run-id }} trusted-base: default-tip From 80fea3582386827a45ff48783639b5317000d0d6 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Fri, 23 Oct 2026 04:43:26 -0400 Subject: [PATCH 7/7] fix(pr-pipeline): take apply-docs-patch inputs from the scan job apply-docs-patch took patch-state, patch-sha256, patch-attempt, reply-attempt and the head from fix-docs run-job outputs, which head code can forge. It now takes one leg and that leg's scan-* outputs, and the head from fix-docs-head, as fix-ci's apply-patch does. Refs: #8890 Co-Authored-By: Claude Opus 5.5 --- .../feat-pr-refine-restore-9a0d0a8e.md | 1 + .../actions/resolve-config/callers.test.mjs | 44 +++++++++++++++---- .github/workflows/pr-refine.yml | 23 +++++----- .../pr-pipeline/pr-run-activity.md | 8 +++- 4 files changed, 55 insertions(+), 21 deletions(-) diff --git a/.changes/pr-pipeline/feat-pr-refine-restore-9a0d0a8e.md b/.changes/pr-pipeline/feat-pr-refine-restore-9a0d0a8e.md index c8428604f3..636cbff0af 100644 --- a/.changes/pr-pipeline/feat-pr-refine-restore-9a0d0a8e.md +++ b/.changes/pr-pipeline/feat-pr-refine-restore-9a0d0a8e.md @@ -11,3 +11,4 @@ bump: minor ### Security - `fix-docs-subscription` no longer starts on `fix-docs` outputs, which its head-code run job could forge. A `fix-docs-handoff` job reads this run's jobs through the Actions API, as `fix-ci-handoff` does for fix-ci. The fallback runs only when the broker allowed `fix-docs` and its report job posted no check (#8890). +- `apply-docs-patch` no longer takes its patch inputs or head from `fix-docs` run-job outputs. It takes them from the answering leg's scan job and the head from `fix-docs-head`, as fix-ci's `apply-patch` does (#8890). diff --git a/.github/actions/resolve-config/callers.test.mjs b/.github/actions/resolve-config/callers.test.mjs index fb5be92c40..4f4e7e9de6 100644 --- a/.github/actions/resolve-config/callers.test.mjs +++ b/.github/actions/resolve-config/callers.test.mjs @@ -1457,13 +1457,12 @@ const APPLY_ACTIVITY = { }; const CARRIER_SECRETS = { "scan-carrier-private-key": "${{ secrets.SCAN_CARRIER_PRIVATE_KEY }}" }; // The head apply-patch holds the patch to, per producer. fix-ci's push job -// takes the head fix-ci-context gated, from a job that ran no head code. -// fix-docs's push job takes the run job's head-sha -// output from whichever call ran; apply-patch applies onto the head its own -// gate reads either way. +// takes the head fix-ci-context gated, and fix-docs's the head fix-docs-head +// read, each from a job that ran no head code; apply-patch applies onto the +// head its own gate reads either way. const PATCH_HEAD = { "fix-ci": { needs: ["fix-ci-context"], value: "${{ needs.fix-ci-context.outputs.head-sha }}" }, - "fix-docs": { needs: [], value: null }, + "fix-docs": { needs: ["fix-docs-head"], value: "${{ needs.fix-docs-head.outputs.head-sha }}" }, "address-feedback": { needs: ["address-feedback-select"], value: "${{ needs.address-feedback-select.outputs.head-sha }}" }, "review-dependency": { needs: ["review-dependency-select"], value: "${{ needs.review-dependency-select.outputs.head-sha }}" }, }; @@ -1475,10 +1474,9 @@ const PATCH_HEAD = { // and review-dependency's one whose verdict needs none. const EMPTY_RUNS_APPLY = { "fix-ci": true, "fix-docs": false, "address-feedback": false, "review-dependency": false }; const PATCH_OUTPUTS = { "patch-state": "patch-state", "patch-sha256": "patch-sha256", "patch-attempt": "patch-attempt", "reply-attempt": "reply-attempt" }; -// The read runner output each push job reads per input: address-feedback's -// and review-dependency's, lanes the briefs hold to no run job output, the -// scan job's alone. -const SCAN_ONLY = new Set(["fix-ci", "address-feedback", "review-dependency"]); +// The read runner output each push job reads per input: the scan job's alone, +// never the run job's, which head code can forge. +const SCAN_ONLY = new Set(["fix-ci", "fix-docs", "address-feedback", "review-dependency"]); // The base-code job whose jobs-API read starts a lane's fallback call, in // place of the first call's own handoff outputs. const HANDOFF = { "fix-ci": "fix-ci-handoff", "fix-docs": "fix-docs-handoff", "address-feedback": "address-feedback-handoff", "review-dependency": "review-dependency-handoff" }; @@ -4293,6 +4291,34 @@ test("pr-refine.yml: fix-docs-subscription starts on a base job's jobs-API read assert.deepEqual(failed.outputs, {}); }); +// #8890 for fix-docs's push job: patch-state, patch-sha256, patch-attempt, +// reply-attempt and the head came from the run job, which head code can +// forge. Now each comes from the answering leg's scan job, or the head from +// fix-docs-head, as #9363 did for fix-ci's apply-patch. +test("pr-refine.yml: apply-docs-patch reads no read-runner run-job output and takes only the leg that answered", () => { + const apply = workflow("pr-refine.yml").jobs["apply-docs-patch"]; + const runOutputs = Object.entries(workflow("pr-run-activity-read.yml").on.workflow_call.outputs) + .filter(([, output]) => /\bjobs\.run\.outputs\./.test(output.value)) + .map(([name]) => name); + assert.ok(["patch-state", "patch-sha256", "head-sha"].every((name) => runOutputs.includes(name)), runOutputs.join(" ")); + const read = [...JSON.stringify(apply).matchAll(/needs\.(fix-docs(?:-subscription)?)\.outputs\.([\w-]+)/g)] + .filter(([, , output]) => runOutputs.includes(output)) + .map(([ref]) => ref); + assert.deepEqual(read, [], "apply-docs-patch reads no run-job output"); + assert.equal(apply.with["patch-head-sha"], "${{ needs.fix-docs-head.outputs.head-sha }}"); + const runs = (subResult, sub, first) => + evaluate(apply.if, { + "needs.fix-docs-subscription.result": subResult, + "needs.fix-docs-subscription.outputs.scan-patch-state": sub, + "needs.fix-docs.outputs.scan-patch-state": first, + }); + assert.equal(runs("skipped", "", "written"), true, "the key call's patch, its subscription call skipped"); + assert.equal(runs("success", "written", ""), true, "the subscription call's patch after a handoff"); + assert.equal(runs("success", "", "written"), false, "the key call's patch is not taken once the subscription call ran"); + assert.equal(runs("skipped", "", "empty"), false, "no change runs no push job"); + assert.equal(runs("skipped", "", ""), false, "a call that left no patch"); +}); + // #8890: a round in which no call posted left fix-ci-dispatch.mjs nothing to // count, so it dispatched the head again with no end. test("pr-unblock.yml: a fix-ci round with no posted check posts fix-ci failure on the gated head, which fix-ci-dispatch counts toward fix-ci-restarts", (t) => { diff --git a/.github/workflows/pr-refine.yml b/.github/workflows/pr-refine.yml index 0d1513a8db..dcf6738220 100644 --- a/.github/workflows/pr-refine.yml +++ b/.github/workflows/pr-refine.yml @@ -189,13 +189,16 @@ jobs: # default row (fix-docs has no prepare mode). It runs only on a written # patch: no change is the common case and needs no one. The lane has no # escalation job, so a refused patch posts the pr-refine / apply-patch - # check neutral and labels nothing. The patch and its head come from - # whichever call ran the activity; apply-patch still applies onto the head - # its own gate reads. + # check neutral and labels nothing. One leg answers, as for pr-unblock.yml's + # apply-patch: the fallback call when it ran, else the first, and every + # input is that leg's scan job's, never a run job's, since the run job runs + # head code. The head is fix-docs-head's, read by a job that ran no head + # code; apply-patch still applies onto the head its own gate reads. apply-docs-patch: - needs: [fix-docs, fix-docs-subscription] + needs: [fix-docs-head, fix-docs, fix-docs-subscription] if: >- - ${{ !cancelled() && (needs.fix-docs-subscription.outputs.patch-state || needs.fix-docs.outputs.patch-state) == 'written' }} + ${{ !cancelled() + && (needs.fix-docs-subscription.result != 'skipped' && needs.fix-docs-subscription.outputs.scan-patch-state || needs.fix-docs-subscription.result == 'skipped' && needs.fix-docs.outputs.scan-patch-state || '') == 'written' }} permissions: contents: read pull-requests: read @@ -213,9 +216,9 @@ jobs: pr-number: ${{ inputs.pr-number }} dispatcher-run-id: ${{ inputs.dispatcher-run-id }} trusted-base: default-tip - patch-state: ${{ needs.fix-docs-subscription.outputs.patch-state || needs.fix-docs.outputs.patch-state }} - patch-sha256: ${{ needs.fix-docs-subscription.outputs.patch-sha256 || needs.fix-docs.outputs.patch-sha256 }} - patch-attempt: ${{ needs.fix-docs-subscription.outputs.patch-attempt || needs.fix-docs.outputs.patch-attempt }} + patch-state: ${{ needs.fix-docs-subscription.result != 'skipped' && needs.fix-docs-subscription.outputs.scan-patch-state || needs.fix-docs-subscription.result == 'skipped' && needs.fix-docs.outputs.scan-patch-state || '' }} + patch-sha256: ${{ needs.fix-docs-subscription.result != 'skipped' && needs.fix-docs-subscription.outputs.scan-patch-sha256 || needs.fix-docs-subscription.result == 'skipped' && needs.fix-docs.outputs.scan-patch-sha256 || '' }} + patch-attempt: ${{ needs.fix-docs-subscription.result != 'skipped' && needs.fix-docs-subscription.outputs.scan-patch-attempt || needs.fix-docs-subscription.result == 'skipped' && needs.fix-docs.outputs.scan-patch-attempt || '' }} patch-activity: fix-docs - patch-head-sha: ${{ needs.fix-docs-subscription.outputs.head-sha || needs.fix-docs.outputs.head-sha }} - reply-attempt: ${{ needs.fix-docs-subscription.outputs.reply-attempt || needs.fix-docs.outputs.reply-attempt }} + patch-head-sha: ${{ needs.fix-docs-head.outputs.head-sha }} + reply-attempt: ${{ needs.fix-docs-subscription.result != 'skipped' && needs.fix-docs-subscription.outputs.scan-reply-attempt || needs.fix-docs-subscription.result == 'skipped' && needs.fix-docs.outputs.scan-reply-attempt || '' }} diff --git a/docs/conventions/pr-pipeline/pr-run-activity.md b/docs/conventions/pr-pipeline/pr-run-activity.md index 41bc0491b6..51cb0e654c 100644 --- a/docs/conventions/pr-pipeline/pr-run-activity.md +++ b/docs/conventions/pr-pipeline/pr-run-activity.md @@ -1223,7 +1223,10 @@ accepts the script's reasons only in this mode. In `pr-refine.yml` the same leaf, as the `apply-docs-patch` job, follows `fix-docs`, which has no prepare mode: the caller passes no `patch-prepare`, so the script takes the `default` row, and -runs the job only on a `written` patch. That row refuses the universal set, escalating +runs the job only on a `written` patch. As fix-ci's `apply-patch` does, it takes one leg (the +fallback call when it ran, else the first) and every patch input from that leg's scan job +(`scan-patch-state`, `scan-patch-sha256`, `scan-patch-attempt`, `scan-reply-attempt`), never from +a run job. That row refuses the universal set, escalating `patch-protected-path`, caps a patch at 300 paths (`patch-invalid`), takes a moved head with `--3way` when the moved commits touch no patched path (`skip-moved-head: false`), and commits `Apply fix-docs on ` with the trailer `Lane-Activity: pr-refine/fix-docs`. @@ -1416,7 +1419,8 @@ outputs are sent), so the report job differs: goes posts its check on the head it ran on, not failure on the new head. They also pass `post-decline: false`: fix-docs is dispatched once per PR, so a run that stops before its trigger gate posts no `not-assessed` neutral and the PR is dispatched again; and - `apply-docs-patch` passes the read runner's `head-sha` output as `patch-head-sha`; apply-patch still applies onto the head its own gate reads and measures the + `apply-docs-patch` passes `fix-docs-head`'s head as `patch-head-sha`, never the read runner's + run-job `head-sha` output; apply-patch still applies onto the head its own gate reads and measures the `default` row's refusals from there. What it trusts: