diff --git a/.github/actions/resolve-config/dispatch-limits.test.mjs b/.github/actions/resolve-config/dispatch-limits.test.mjs index 22b3ef90d3..04f6e3fa28 100644 --- a/.github/actions/resolve-config/dispatch-limits.test.mjs +++ b/.github/actions/resolve-config/dispatch-limits.test.mjs @@ -155,11 +155,19 @@ test("the base config's adoption section lets fix-ci adopt an idle owner PR", as owner.commits.nodes[0].commit.committedDate = idle; assert.equal(parse(LIVE).adoption["idle-hours"], 2, "the live config adopts a trusted person after 2 hours"); assert.deepEqual(await run(LIVE, [owner]), { code: 0, dispatched: ["40", "61"] }); - // Every trusted-actors.json entry is adoptable: the release App, a Bot, at once. + // Another trusted Bot is adoptable at once (a Bot never waits out idle-hours). + const bot = ciFixPull(64); + bot.author = { __typename: "Bot", databaseId: 209825114 }; + bot.updatedAt = "2026-10-17T11:59:00Z"; + assert.deepEqual(await run(LIVE, [bot]), { code: 0, dispatched: ["40", "64"] }); + // The release and standards-sync Apps' PRs carry generated or synced files a lane commit breaks: never adopted. const release = ciFixPull(65); release.author = { __typename: "Bot", databaseId: 337859524 }; release.updatedAt = "2026-10-17T11:59:00Z"; - assert.deepEqual(await run(LIVE, [release]), { code: 0, dispatched: ["40", "65"] }); + assert.deepEqual(await run(LIVE, [release]), { code: 0, dispatched: ["40"] }); + const sync = ciFixPull(68); + sync.author = { __typename: "Bot", databaseId: 300666570 }; + assert.deepEqual(await run(LIVE, [sync]), { code: 0, dispatched: ["40"] }); // Dependabot's red PR is review-dependency's, never fix-ci's. const dependabot = ciFixPull(67); dependabot.author = { __typename: "Bot", databaseId: 49699333 }; diff --git a/.github/scripts/pr-unblock/dispatch.mjs b/.github/scripts/pr-unblock/dispatch.mjs index bffcfe29fb..d42ee4245b 100644 --- a/.github/scripts/pr-unblock/dispatch.mjs +++ b/.github/scripts/pr-unblock/dispatch.mjs @@ -55,7 +55,7 @@ import { emptyRecord, isHandled, markHandled, pruneRecord, readRecord, RUN_ID, s import { laneReader, withLaneBinding } from "./address-feedback.mjs"; import { prHold, reviewReader } from "./gate-source.mjs"; import { runTitle, shareWorkflowRuns } from "./feedback-dispatch.mjs"; -import { DEFAULTS as CI_FIX_DEFAULTS, dispatchCiFixes, humanGatedLabel } from "./fix-ci-dispatch.mjs"; +import { adoptableOwnerIds, DEFAULTS as CI_FIX_DEFAULTS, dispatchCiFixes, humanGatedLabel } from "./fix-ci-dispatch.mjs"; import { TRACKER_BINDING_PATH, trackerBindingText } from "../pr-merge/merge-holds.mjs"; import { HISTORY_QUERY as DOCS_FIX_HISTORY_QUERY } from "./fix-docs-dispatch.mjs"; import { anyHandles, cappedAt, headLagStop, laneStop, limitStop, pendingStop, permanentlyHandles, releasedBy, stopRelease } from "./limit-stop.mjs"; @@ -884,15 +884,15 @@ async function dispatchAll(env, { client, sleep, log, loadLimits, loadAdoption, // human-gated label is read only when owners are adopted; an unreadable // tracker config holds the adopted owner PRs alone, as a failure. // With `adoption.idle-hours` set (as pr-merge requires), every account on - // the base's trusted-actors list is adoptable, by id, but Dependabot: - // review-dependency owns its red PRs. + // the base's trusted-actors list is adoptable, by id, but the bot authors + // fix-ci-dispatch.mjs adoptableOwnerIds leaves out. let fixes = { dispatched: [], failed: 1 }; let ownerIds = []; let holdLabels = CI_FIX_DEFAULTS.holdLabels; let ownersHeld = ""; if (adoption["idle-hours"] !== undefined) { try { - ownerIds = [...loadTrustedList(`${basePath(env)}/${TRUSTED_ACTORS_PATH}`).ids].filter((id) => id !== DEPENDABOT_ID); + ownerIds = adoptableOwnerIds(loadTrustedList(`${basePath(env)}/${TRUSTED_ACTORS_PATH}`).ids); holdLabels = [...new Set([...holdLabels, baseHumanGatedLabel(env)])]; } catch (error) { log(`::error::cannot read the base trusted-actors list or the human-gated label from the base tracker binding (${error.message}); no adopted PR gets fix-ci`); diff --git a/.github/scripts/pr-unblock/fix-ci-dispatch.mjs b/.github/scripts/pr-unblock/fix-ci-dispatch.mjs index 8121d49adf..9aa17f9bb1 100644 --- a/.github/scripts/pr-unblock/fix-ci-dispatch.mjs +++ b/.github/scripts/pr-unblock/fix-ci-dispatch.mjs @@ -57,6 +57,7 @@ import { postingAttempt } from "../../actions/report-check-run/posting-run.mjs"; import { emptyRecord, isHandled, markHandled, sendDispatch } from "./dispatch-record.mjs"; import { STOP_RESOLVED } from "./escalation-routes.mjs"; import { HOLD_LINE, runTitle } from "./feedback-dispatch.mjs"; +import { DEFAULTS as DOCS_FIX_DEFAULTS } from "./fix-docs-dispatch.mjs"; import { failingChecks } from "./fix-ci-context.mjs"; import { prHold, reviewReader } from "./gate-source.mjs"; import { HEAD_LAG_STOP, KILL_SWITCH_STOP, killSwitchStop, laneStop, releasesHead } from "./limit-stop.mjs"; @@ -95,6 +96,15 @@ const HELD_SKIP = "Skipped: awaiting-human"; const MAX_PAGES = 20; +// The trusted accounts whose idle PRs fix-ci adopts: every one but the authors +// fix-docs also leaves alone (Dependabot, whose red PRs review-dependency owns; +// the release and standards-sync Apps, whose PRs carry generated or synced +// files that a lane commit breaks: check-managed-files refuses a sync PR +// commit by any other author). +export function adoptableOwnerIds(trustedIds, excludedAuthorIds = DOCS_FIX_DEFAULTS.excludedAuthorIds) { + return [...trustedIds].filter((id) => !excludedAuthorIds.includes(id)); +} + // The tracker's human-gated label from the binding's JSON text, as // pr-merge.yml resolves it: config.role_labels["human-gated"] trimmed, or // needs-human when the file is absent (null) or maps none. Invalid JSON throws. diff --git a/.github/scripts/pr-unblock/fix-ci-dispatch.test.mjs b/.github/scripts/pr-unblock/fix-ci-dispatch.test.mjs index 3b66000f48..4a1d4fd013 100644 --- a/.github/scripts/pr-unblock/fix-ci-dispatch.test.mjs +++ b/.github/scripts/pr-unblock/fix-ci-dispatch.test.mjs @@ -6,7 +6,7 @@ import { createGitHubApi, LIST_QUERY } from "./dispatch.mjs"; import { failingChecks } from "./gate-source.mjs"; import { emptyRecord, isHandled } from "./dispatch-record.mjs"; import { RERUN_DUE_MINUTES } from "../maintenance-reconcile-lost-runs/infra-failure.mjs"; -import { DEFAULTS, dispatchCiFixes, humanGatedLabel, skipReason, staleStop } from "./fix-ci-dispatch.mjs"; +import { adoptableOwnerIds, DEFAULTS, dispatchCiFixes, humanGatedLabel, skipReason, staleStop } from "./fix-ci-dispatch.mjs"; // The lanes App bot's user id and the GitHub Actions app id // (.github/standards/trusted-actors/trusted-actors.json; dispatch.mjs). @@ -1552,3 +1552,13 @@ test("a fix-ci stop is left on a head with a check failing or going, a run since const older = await run({ pages: [[settled(green)]], workflowRuns: [{ id: 2, status: "completed", display_title: "pr-unblock fix-ci #9", created_at: "2026-10-22T10:00:00Z" }] }); assert.equal(older.api.calls.dispatch.length, 1); }); + +test("adoption leaves out Dependabot and the release and standards-sync Apps, whose PRs a lane commit breaks", () => { + const trusted = JSON.parse(readFileSync(new URL("../../standards/trusted-actors/trusted-actors.json", import.meta.url), "utf8")); + const ids = trusted.actors.map((actor) => actor.id); + const adopted = adoptableOwnerIds(new Set(ids)); + const excluded = [49699333, 337859524, 300666570]; + assert.ok(excluded.every((id) => ids.includes(id))); + assert.deepEqual(adopted, ids.filter((id) => !excluded.includes(id))); + assert.ok(adopted.includes(APP)); +});