Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changes/pr-pipeline/feat-review-lane-holds-3c9e1a52.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
bump: minor
---

### Added

- Both review lanes hold a pull request for a person: `pr-review-hosted.yml` and `pr-review-security-hosted.yml` call ci-workflows' [`pr-review-cli.yml`](https://github.com/melodic-software/ci-workflows/blob/9aca743c4ed9aaf7870288a36586c3994e9321e4/.github/workflows/pr-review-cli.yml) v0.64.1 with `hold-label: needs-human`, and a changed file under `.github/standards/trusted-actors/**`, `.github/actions/check-trusted-trigger/**`, `**/CODEOWNERS` or `**/.gitmodules` holds as `trust-gate` whatever the review says ([Review-lane holds](https://github.com/melodic-software/claude-code-plugins/blob/main/docs/conventions/pr-pipeline/README.md#review-lane-holds)).
- ci-status requires both review lanes on the head of a ready pull request: the newest `pull_request` run of each caller must have a successful status job whose `Apply the hold` step ran. A pending, failed, skipped or summary-mode review fails with the cause named; a lane the kill switch stopped passes; Dependabot, the release App and the standards-sync App are exempt by author id unless their PR changes a hold path.
- ci-status fails when a review-lane hold was lifted by anyone but a person on the base's trusted-actor list: the first removal of the human-gated label after the newest hold comment or lane-added label event decides.
- `pr-feedback-dispatch.yml`'s `rerun-ci-status` job re-runs the head's ci-status job when either review lane completes on an open same-repository pull request, after polling at most 10 minutes for a ci-status already in progress.

### Changed

- `fix-ci` dispatch keys on the head's `ci-lanes` commit status instead of ci-status, so a hold, a pending review or a contract failure no longer summons it.
12 changes: 9 additions & 3 deletions .github/actions/resolve-config/callers.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -506,7 +506,9 @@ test("pr-unblock.yml: fix-ci is reported neutral when fix-ci-context skips, and
conclusion: calls[0].conclusion.toUpperCase(),
title: calls[0].output.title,
},
].map((run) => ({ __typename: "CheckRun", checkSuite: { app: { databaseId: 15368 } }, ...run })),
]
.map((run) => ({ __typename: "CheckRun", checkSuite: { app: { databaseId: 15368 } }, ...run }))
.concat({ __typename: "StatusContext", context: "ci-lanes", state: "FAILURE", creator: { login: "github-actions[bot]" } }),
pageInfo: { hasNextPage: false },
},
},
Expand Down Expand Up @@ -3351,7 +3353,9 @@ test("report job: fix-ci-dispatch reads the neutral not-assessed post as the hea
oid: head,
statusCheckRollup: {
contexts: {
nodes: [{ name: "ci-status", status: "COMPLETED", conclusion: "FAILURE" }, ...runs].map((run) => ({ __typename: "CheckRun", checkSuite: { app: { databaseId: 15368 } }, ...run })),
nodes: [{ name: "ci-status", status: "COMPLETED", conclusion: "FAILURE" }, ...runs]
.map((run) => ({ __typename: "CheckRun", checkSuite: { app: { databaseId: 15368 } }, ...run }))
.concat({ __typename: "StatusContext", context: "ci-lanes", state: "FAILURE", creator: { login: "github-actions[bot]" } }),
pageInfo: { hasNextPage: false },
},
},
Expand Down Expand Up @@ -4276,7 +4280,9 @@ test("pr-unblock.yml: a fix-ci round with no posted check posts fix-ci failure o
conclusion: decision.report.conclusion.toUpperCase(),
title: decision.report.output.title,
})),
].map((run) => ({ __typename: "CheckRun", checkSuite: { app: { databaseId: 15368 } }, ...run })),
]
.map((run) => ({ __typename: "CheckRun", checkSuite: { app: { databaseId: 15368 } }, ...run }))
.concat({ __typename: "StatusContext", context: "ci-lanes", state: "FAILURE", creator: { login: "github-actions[bot]" } }),
pageInfo: { hasNextPage: false },
},
},
Expand Down
5 changes: 3 additions & 2 deletions .github/actions/resolve-config/dispatch-limits.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,11 @@ function baseWith(text) {

// The lanes App bot's user id (.github/standards/trusted-actors/trusted-actors.json).
const LANES_APP = 337595936;
// Lanes App PRs whose head's ci-status failed, as fix-ci-dispatch.mjs lists them.
// Lanes App PRs whose head's lanes failed, as fix-ci-dispatch.mjs lists them.
const ciFixPull = (number) => {
const head = String(number).padStart(40, "c");
const ciStatus = { __typename: "CheckRun", name: "ci-status", status: "COMPLETED", conclusion: "FAILURE", checkSuite: { app: { databaseId: 15368 } } };
const ciLanes = { __typename: "StatusContext", context: "ci-lanes", state: "FAILURE", creator: { login: "github-actions[bot]" } };
return {
number,
isDraft: false,
Expand All @@ -55,7 +56,7 @@ const ciFixPull = (number) => {
headRefOid: head,
author: { databaseId: LANES_APP },
labels: { nodes: [] },
commits: { nodes: [{ commit: { oid: head, statusCheckRollup: { contexts: { nodes: [ciStatus] } } } }] },
commits: { nodes: [{ commit: { oid: head, statusCheckRollup: { contexts: { nodes: [ciStatus, ciLanes] } } } }] },
};
};

Expand Down
7 changes: 5 additions & 2 deletions .github/scripts/pr-unblock/dispatch.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -551,7 +551,7 @@ test("main records a review-dependency dispatch beside the conflict dispatches",
assert.equal(output, "run-ids=9130 9131\n");
});

// A lanes App PR whose head's ci-status failed, as fix-ci-dispatch.mjs lists it.
// A lanes App PR whose head's lanes failed, as fix-ci-dispatch.mjs lists it.
const ciFixPull = (number) => {
const head = String(number).padStart(40, "c");
return {
Expand All @@ -569,7 +569,10 @@ const ciFixPull = (number) => {
oid: head,
statusCheckRollup: {
contexts: {
nodes: [{ __typename: "CheckRun", name: "ci-status", status: "COMPLETED", conclusion: "FAILURE", checkSuite: { app: { databaseId: 15368 } } }],
nodes: [
{ __typename: "CheckRun", name: "ci-status", status: "COMPLETED", conclusion: "FAILURE", checkSuite: { app: { databaseId: 15368 } } },
{ __typename: "StatusContext", context: "ci-lanes", state: "FAILURE", creator: { login: "github-actions[bot]" } },
],
},
},
},
Expand Down
44 changes: 28 additions & 16 deletions .github/scripts/pr-unblock/fix-ci-dispatch.mjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
// Starts the pr-unblock lane's fix-ci activity for each open pull request the
// lanes App opened, or a trusted actor opened (an App's at once, a person's
// once idle; the base config's `adoption` section), whose head's ci-status
// failed (or whose pr-test-windows run failed), from the same
// once idle; the base config's `adoption` section), whose head's lanes failed
// (the `ci-lanes` commit status ci-status records, not ci-status itself, which
// a review-lane hold or a pending review also fails) or whose pr-test-windows
// run failed, 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.
Expand All @@ -13,7 +15,7 @@
// when it has no pr-unblock / fix-ci check run from the GitHub Actions app,
// or when every such run failed and there are at most `fix-ci-restarts` of
// them; at most `fix-ci-per-run` per dispatcher run, lowest PR number first.
// A head whose ci-status passed but whose pr-test-windows run failed is a
// A head whose lanes passed but whose pr-test-windows run failed is a
// candidate too. A head is skipped while the lost-run reconciler still re-runs
// its failed run (infra-failure.mjs reconcilerOwns: an infra-only failure for
// its backoff re-runs, any other for one re-run at attempt 1, inside the
Expand All @@ -29,7 +31,7 @@ export const DEFAULTS = Object.freeze({
workflow: "pr-unblock.yml",
activity: "fix-ci",
checkName: "pr-unblock / fix-ci",
// The run whose failure counts: pr-require-checks when ci-status failed,
// The run whose failure counts: pr-require-checks when its lanes failed,
// else the advisory pr-test-windows, which ci-status does not aggregate but
// the merge gate holds on.
requiredWorkflow: ".github/workflows/pr-require-checks.yml",
Expand Down Expand Up @@ -76,6 +78,7 @@ export const LIST_QUERY = `query($owner: String!, $name: String!, $base: String!
nodes {
__typename
... on CheckRun { name status conclusion title checkSuite { app { databaseId } } }
... on StatusContext { context state creator { login } }
}
pageInfo { hasNextPage }
}
Expand All @@ -92,7 +95,7 @@ export const LIST_QUERY = `query($owner: String!, $name: String!, $base: String!
// The check runs the GitHub Actions app posted on the PR's head.
function headChecks(node, actionsAppId) {
const commit = node.commits?.nodes?.[0]?.commit;
// A cut-off list could hide ci-status or an earlier fix-ci run.
// A cut-off list could hide ci-lanes or an earlier fix-ci run.
if (!commit || commit.oid !== node.headRefOid || commit.statusCheckRollup?.contexts?.pageInfo?.hasNextPage) {
return null;
}
Expand All @@ -113,13 +116,22 @@ function ownerIdle(node, idleHours, now) {
return commit?.oid === node.headRefOid && Date.parse(node.updatedAt) <= since && Date.parse(commit.committedDate) <= since;
}

const ciStatusFailed = (checks) => checks.some((run) => run.name === "ci-status" && run.status === "COMPLETED" && run.conclusion === "FAILURE");
// The head's `ci-lanes` commit status when github-actions[bot] wrote it, as
// ci-status's aggregate step does: the lanes' verdict alone. ci-status itself
// also fails on a hold label, a pending or missing review and the PR
// contract, none of which a CI fix mends.
function lanesState(node) {
const contexts = node.commits?.nodes?.[0]?.commit?.statusCheckRollup?.contexts?.nodes ?? [];
const status = contexts.find((context) => context?.__typename === "StatusContext" && context.context === "ci-lanes");
return status?.creator?.login === "github-actions[bot]" ? status.state : null;
}

const lanesFailed = (node) => ["FAILURE", "ERROR"].includes(lanesState(node));

// ci-status passed and another check failed: an advisory lane's, if any.
const advisoryFailed = (checks, checkName) =>
!ciStatusFailed(checks) &&
checks.some((run) => run.name === "ci-status" && run.status === "COMPLETED" && run.conclusion === "SUCCESS") &&
checks.some((run) => run.name !== checkName && run.status === "COMPLETED" && run.conclusion === "FAILURE");
// The lanes passed and another check failed: an advisory lane's, if any.
const advisoryFailed = (node, checks, checkName) =>
lanesState(node) === "SUCCESS" &&
checks.some((run) => run.name !== checkName && run.name !== "ci-status" && run.status === "COMPLETED" && run.conclusion === "FAILURE");

// Why a listed PR is not dispatched, or "" when it is a candidate.
export function skipReason(node, { base, appSenderId, actionsAppId, checkName, holdLabels, restarts, adoption = DEFAULTS.adoption, now = Date.now() }) {
Expand Down Expand Up @@ -149,17 +161,17 @@ export function skipReason(node, { base, appSenderId, actionsAppId, checkName, h
if (!checks) {
return "head commit or its checks not listed whole";
}
const advisory = advisoryFailed(checks, checkName);
if (!ciStatusFailed(checks) && !advisory) {
return "ci-status has not failed";
const advisory = advisoryFailed(node, checks, checkName);
if (!lanesFailed(node) && !advisory) {
return "ci-lanes has not failed";
}
// node.requiredRun, the head's tested pr-require-checks run (pr-test-windows
// when only an advisory check failed) and its latest jobs, is attached by
// dispatchCiFixes just before it would dispatch.
const required = node.requiredRun;
if (required !== undefined) {
if (advisory && required.run?.conclusion !== "failure") {
return "ci-status passed and no pr-test-windows run failed";
return "ci-lanes passed and no pr-test-windows run failed";
}
const owned = reconcilerOwns(required, now);
if (owned !== "") {
Expand Down Expand Up @@ -246,7 +258,7 @@ export async function dispatchCiFixes({
}
// A failure an outside outage explains, or a first failure, is the
// lost-run reconciler's to re-run before a model run fixes it.
const path = ciStatusFailed(headChecks(node, actionsAppId)) ? DEFAULTS.requiredWorkflow : DEFAULTS.advisoryWorkflow;
const path = lanesFailed(node) ? DEFAULTS.requiredWorkflow : DEFAULTS.advisoryWorkflow;
let requiredRun;
try {
requiredRun = await api.readRequiredRun(node.headRefOid, path);
Expand Down
53 changes: 40 additions & 13 deletions .github/scripts/pr-unblock/fix-ci-dispatch.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,11 @@ const check = (name, conclusion, extra = {}) => ({
...extra,
});

// A lanes App PR whose head's ci-status failed and has no fix-ci run yet.
function pr(number, { checks = [check("ci-status", "FAILURE")], ...overrides } = {}) {
// The head's ci-lanes commit status, as ci-status's aggregate step writes it.
const lanes = (state, creator = "github-actions[bot]") => ({ __typename: "StatusContext", context: "ci-lanes", state, creator: { login: creator } });

// A lanes App PR whose head's lanes failed and has no fix-ci run yet.
function pr(number, { checks = [lanes("FAILURE")], ...overrides } = {}) {
const head = String(number).padStart(40, "a");
return {
number,
Expand Down Expand Up @@ -136,19 +139,19 @@ test("other authors, drafts, forks, other bases and held PRs are left alone", as
assert.deepEqual(result.dispatched, []);
});

test("a head whose ci-status passed, is pending, is missing or came from another app is left alone", async () => {
test("a head whose ci-lanes passed, is pending, is missing or came from another writer is left alone", async () => {
const { result } = await run({
pages: [
[
pr(1, { checks: [check("ci-status", "SUCCESS")] }),
pr(2, { checks: [check("ci-status", null)] }),
pr(1, { checks: [lanes("SUCCESS")] }),
pr(2, { checks: [lanes("PENDING")] }),
pr(3, { checks: [] }),
pr(4, { checks: [check("ci-status", "FAILURE", { checkSuite: { app: { databaseId: 999 } } })] }),
pr(4, { checks: [lanes("FAILURE", "someone")] }),
pr(5, { commits: { nodes: [] } }),
// More checks than the page holds could hide an earlier fix-ci run.
pr(6, {
commits: {
nodes: [{ commit: { oid: "6".padStart(40, "a"), statusCheckRollup: { contexts: { nodes: [check("ci-status", "FAILURE")], pageInfo: { hasNextPage: true } } } } }],
nodes: [{ commit: { oid: "6".padStart(40, "a"), statusCheckRollup: { contexts: { nodes: [lanes("FAILURE")], pageInfo: { hasNextPage: true } } } } }],
},
}),
],
Expand All @@ -159,7 +162,7 @@ test("a head whose ci-status passed, is pending, is missing or came from another

test("a head that already has a fix-ci run is skipped, unless every run failed within the restart count", async () => {
const fix = (conclusion, extra) => check("pr-unblock / fix-ci", conclusion, extra);
const failed = check("ci-status", "FAILURE");
const failed = lanes("FAILURE");
const { result } = await run({
pages: [
[
Expand All @@ -183,7 +186,7 @@ test("a head that already has a fix-ci run is skipped, unless every run failed w

test("a restart count of 0 never dispatches a head again", async () => {
const { result } = await run(
{ pages: [[pr(4, { checks: [check("ci-status", "FAILURE"), check("pr-unblock / fix-ci", "FAILURE")] })]] },
{ pages: [[pr(4, { checks: [lanes("FAILURE"), check("pr-unblock / fix-ci", "FAILURE")] })]] },
{ restarts: 0 },
);
assert.deepEqual(result.dispatched, []);
Expand Down Expand Up @@ -367,7 +370,7 @@ test("the adapter sends one query naming the PR, its last commit's check rollup
assert.equal(requests[0].url, "https://api.example/graphql");
assert.equal(requests[0].body.query, LIST_QUERY);
assert.deepEqual(requests[0].body.variables, { owner: "octo", name: "repo", base: "main", after: null });
for (const field of ["rateLimit", "cost", "statusCheckRollup", "commits(last: 1)", "databaseId", "... on User { databaseId }", "updatedAt", "committedDate", "body"]) {
for (const field of ["rateLimit", "cost", "statusCheckRollup", "commits(last: 1)", "databaseId", "... on User { databaseId }", "updatedAt", "committedDate", "body", "... on StatusContext { context state creator { login } }"]) {
assert.ok(LIST_QUERY.includes(field), field);
}
});
Expand Down Expand Up @@ -455,7 +458,7 @@ test("a real failure at attempt 1 is the reconciler's to re-run once; at attempt
// failing check, such as a macOS test_tunnel flake.
test("a failed advisory pr-test-windows run with ci-status green is fix-ci's once its one re-run failed", async () => {
const head = (number) => String(number).padStart(40, "a");
const advisory = [check("ci-status", "SUCCESS"), check("test-macos-planning-shared", "FAILURE")];
const advisory = [lanes("SUCCESS"), check("test-macos-planning-shared", "FAILURE")];
const requiredRuns = new Map([
[`${head(5)} ${ADVISORY}`, { run: tested(1), jobs: MIXED_JOBS }],
[`${head(6)} ${ADVISORY}`, { run: tested(2), jobs: MIXED_JOBS }],
Expand All @@ -468,13 +471,37 @@ test("a failed advisory pr-test-windows run with ci-status green is fix-ci's onc
);
assert.deepEqual(api.calls.paths, [ADVISORY, ADVISORY, ADVISORY]);
assert.ok(lines.includes("skip fix-ci #5: first failure, re-run job re-runs it once"));
assert.ok(lines.includes("skip fix-ci #7: ci-status passed and no pr-test-windows run failed"));
assert.ok(lines.includes("skip fix-ci #7: ci-lanes passed and no pr-test-windows run failed"));
});

test("a green ci-status with only a failed fix-ci run, or no failure, is left alone", async () => {
const { api, result } = await run({
pages: [[pr(5, { checks: [check("ci-status", "SUCCESS"), check("pr-unblock / fix-ci", "FAILURE")] }), pr(6, { checks: [check("ci-status", "SUCCESS"), check("lint", "SUCCESS")] })]],
pages: [[pr(5, { checks: [lanes("SUCCESS"), check("pr-unblock / fix-ci", "FAILURE")] }), pr(6, { checks: [lanes("SUCCESS"), check("lint", "SUCCESS")] })]],
});
assert.deepEqual(result.dispatched, []);
assert.deepEqual(api.calls.paths, []);
});

// ci-status also fails while a review lane is pending, missing or holding the
// PR, with no label on it; none of that is a CI fix. Only ci-lanes counts.
test("a red ci-status over green or absent ci-lanes summons no fix-ci", async () => {
const red = check("ci-status", "FAILURE");
const { api, lines, result } = await run({
pages: [[pr(1, { checks: [red, lanes("SUCCESS")] }), pr(2, { checks: [red] }), pr(3, { checks: [red, lanes("PENDING")] })]],
});
assert.deepEqual(result.dispatched, []);
assert.deepEqual(api.calls.paths, []);
assert.equal(skipReason(pr(1, { checks: [red, lanes("SUCCESS")] }), { base: "main", appSenderId: APP, actionsAppId: ACTIONS, checkName: "pr-unblock / fix-ci", holdLabels: [], restarts: 1, now: NOW }), "ci-lanes has not failed");
assert.ok(!lines.some((line) => line.includes("fix-ci #")), lines.join("\n"));
});

test("a ci-lanes failure or error from github-actions[bot] is dispatched, whatever ci-status says", async () => {
const { api, result } = await run({
pages: [[pr(1, { checks: [check("ci-status", "SUCCESS"), lanes("FAILURE")] }), pr(2, { checks: [lanes("ERROR")] }), pr(3, { checks: [lanes("FAILURE", "octocat")] })]],
}, { maxDispatch: 5 });
assert.deepEqual(
result.dispatched.map((d) => d.number),
[1, 2],
);
assert.deepEqual(api.calls.paths, [REQUIRED, REQUIRED]);
});
Loading
Loading