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
17 changes: 17 additions & 0 deletions .changes/pr-pipeline/fix-12446-hold-step-names-80d0f286.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
bump: patch
---

### Changed

- ci-status's merge-holds step is now `Fail a lanes App pull request on kill switch, hold path, verifier or veto`. pr-verify's held-run check still takes the earlier name, which runs from before the rename report.
- The merge-holds verdict line is now `no member veto and no hold path.`. ci-status still takes the earlier line, for a run whose module comes from a main tip behind its workflow.

### Removed

- `pr-feedback-dispatch.yml` no longer re-runs ci-status when a pr-unblock run completes: ci-status reads no `pr-unblock/*` status.

### Fixed

- The loop-lane contract no longer says a pull request comment is never a hold: a member's veto comment holds a lanes App pull request.
- The "Not settled yet" note on holds gives the seven places as the 2026-11-01 count, not today's.
13 changes: 3 additions & 10 deletions .github/actions/check-trusted-trigger/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,10 +193,8 @@ condition 1 and the actor and author parts of condition 2).
`ci-status-rerun-workflow` adds a second recorder for the upstream run only:
`pr-feedback-dispatch.yml`'s `rerun-ci-status` job, which uploads
`rerun-ci-status-<run id>-<attempt>-<head_sha>` immediately before it re-runs a ci-status job,
naming the attempt that re-run starts. Its upstream is a completed review run, a completed
`pr-unblock.yml` run whose newest `pr-unblock/*` status on the PR's live head may be newer than
ci-status's read, or a completed `pr-review-approved.yml` relay of an approval by a listed
human. A repeated stop costs one extra re-run, bounded by the dispatchers' caps. A github-actions[bot] upstream at attempt 2 or later is
naming the attempt that re-run starts. Its upstream is a completed review run or a completed
`pr-review-approved.yml` relay of an approval by a listed human. A github-actions[bot] upstream at attempt 2 or later is
checked as above against `GET /actions/runs/{id}/attempts/{attempt}` and that name (at attempt 2
the reconciler's name is read first), except that the recording run's `event` must be
`workflow_run` and its `actor` is not checked, since such a run runs the default branch's file,
Expand All @@ -215,12 +213,7 @@ condition 1 and the actor and author parts of condition 2).
reconciler's attempt 2 whose `reconcile-rerun-<run id>-2-<head_sha>` record passes the
reconciler re-run check above, unchanged (`reconcilerRecordVouches`, run through
`reconciler-record.mjs`); another re-runner's re-run of ci-status goes ahead unrecorded and is
refused here. A `pr-unblock.yml` upstream is recorded only when its `report-stop-status` job
succeeded and it is at attempt 1 started by github-actions[bot], or was started by a listed
human. Any workflow's `GITHUB_TOKEN` with `actions: write`, a same-repository PR branch's
included, can dispatch one, so such a holder can cause one recorded re-run of ci-status at the
PR's live head when its newest `pr-unblock/*` status may be newer than ci-status's read: the
class this residual already accepts. A relay
refused here. A relay
upstream is recorded only at attempt 1, whose triggering actor is the reviewer, a listed human;
a relay re-run re-runs nothing. This check does not
repeat that one: the record's listing names neither the review run nor its re-runner.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -909,7 +909,6 @@ async function plan({ repo, call, say, since, now, readStatus, judgeSpec, laneAp
// A newest checks run that failed only in ci-status: a review a kill
// switch stopped is re-run once nothing would stop it again, a lanes App
// PR's that the kill switch failed is re-run now that the switch is off,
// any PR's that a pr-unblock stop failed once no stop fails the head,
// and any PR's that a running review failed once the reviews are done.
// The tested run's jobs are reused when it is that run.
if (set.length === 0 && isRedChecks(checks)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1770,7 +1770,7 @@ test("a kill-switch re-run shares the cap of 3, and one re-run since the listing

test("ci-status's lanes-hold step still fails a lanes App PR with the kill-switch note the reconciler matches", () => {
const workflow = readFileSync(new URL("../../workflows/pr-require-checks.yml", import.meta.url), "utf8");
const step = /- name: Fail a lanes App pull request its body or a member holds\n(?:(?:[ ]{8,}.*)?\n)+/.exec(workflow)?.[0] ?? "";
const step = /- name: Fail a lanes App pull request on kill switch, hold path, verifier or veto\n(?:(?:[ ]{8,}.*)?\n)+/.exec(workflow)?.[0] ?? "";
assert.match(step, /LANES_DISABLED: \$\{\{ vars\.CLAUDE_LANES_DISABLED \}\}/);
// The step prints merge-holds.mjs switchHold's reason as its error.
assert.match(step, /const \{ switchHold \} = await import\(/);
Expand Down
2 changes: 1 addition & 1 deletion .github/scripts/pr-merge/enable-auto-merge.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3874,7 +3874,7 @@ test("merge-holds.mjs holds no hold path from the command line with PATH_HOLDS f
const gate = [".github/scripts/pr-merge/clear-escalations.mjs"];
const off = cli([gate], "false");
assert.equal(off.status, 0, off.stdout);
assert.ok(off.stdout.split("\n").includes("#7: no body hold, no member veto and no hold path."), off.stdout);
assert.ok(off.stdout.split("\n").includes("#7: no member veto and no hold path."), off.stdout);
assert.ok(off.stdout.includes("#7: path holds are off (merge.diff-check.path-holds), so nothing holds it for this: it changes .github/scripts/pr-merge/clear-escalations.mjs, a hold path."), off.stdout);
for (const on of [undefined, "true"]) {
const held = cli([gate], on);
Expand Down
2 changes: 1 addition & 1 deletion .github/scripts/pr-merge/merge-holds.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1693,7 +1693,7 @@ export async function main(argv = process.argv.slice(2), { env = process.env, gi
console.log(`::error title=${VERIFIER_TITLE}::#${number}: ${escape(reason)}.`);
}
if (!reasons.length) {
console.log(`#${number}: no body hold, no member veto and no hold path.`);
console.log(`#${number}: no member veto and no hold path.`);
}
return reasons.length || verifier.length ? 1 : 0;
} catch (error) {
Expand Down
10 changes: 5 additions & 5 deletions .github/scripts/pr-merge/merge-holds.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -484,7 +484,7 @@ test("ci-status holds no other trusted path or manifest change, only a configure
]) {
const passed = await ci([{ filename: path, status: "modified" }]);
assert.equal(passed.code, 0, `${path}: ${passed.out}`);
assert.match(passed.out, /no body hold, no member veto and no hold path\./);
assert.match(passed.out, /no member veto and no hold path\./);
}
const held = await ci([{ filename: "docs/held.md", status: "modified" }], { denied: ["docs/held.md"] });
assert.equal(held.code, 1);
Expand Down Expand Up @@ -515,7 +515,7 @@ test("ci-status holds a trusted-actor list widening until a trusted person appro
assert.equal(changed.code, 1, changed.out);
const removed = await ci(modified, { list: { [BASE]: listOf(KYLE, LANES), [HEAD]: listOf(KYLE) } });
assert.equal(removed.code, 0, removed.out);
assert.match(removed.out, /no body hold, no member veto and no hold path\./);
assert.match(removed.out, /no member veto and no hold path\./);
// A side that does not read, a repeated key, or a rename holds.
assert.equal((await ci(modified, { list: { [BASE]: listOf(KYLE, LANES) } })).code, 1);
assert.equal((await ci(modified, { list: { [BASE]: listOf(KYLE, LANES), [HEAD]: listOf(KYLE).replace('"version": 1', '"version": 1, "version": 1') } })).code, 1);
Expand All @@ -529,7 +529,7 @@ test("ci-status holds no body hold text, even a person's; a hold path or a membe
const person = { __typename: "User" };
const written = await ci(["docs/a.md"], { body, writers: { author: person, userContentEdits: { totalCount: 0, nodes: [] } } });
assert.equal(written.code, 0, written.out);
assert.match(written.out, /^#99: no body hold, no member veto and no hold path\.$/m);
assert.match(written.out, /^#99: no member veto and no hold path\.$/m);
const pathed = await ci(["docs/held.md"], { body, denied: ["docs/held.md"] });
assert.equal(pathed.code, 1, pathed.out);
assert.match(pathed.out, /it changes docs\/held\.md, a hold path/);
Expand All @@ -550,7 +550,7 @@ test("ci-status holds a cost path that an approval does not lift, and the owner'
assert.match(approved.out, /a cost path/);
const decided = await ci(["infra/main.tf"], { body: `Closes #40\n\n${LINK}` });
assert.equal(decided.code, 0, decided.out);
assert.match(decided.out, /no body hold, no member veto and no hold path\./);
assert.match(decided.out, /no member veto and no hold path\./);
assert.equal((await ci(["infra/main.tf"], { body: `Closes #40\n\n${LINK}`, options: { node: { lastEditedAt: "2026-10-22T13:00:00Z" } } })).code, 1);
});

Expand Down Expand Up @@ -980,7 +980,7 @@ test("in the merge queue, a FAIL the reader counts holds a passed head", async (
assert.doesNotMatch(out, /no pr-verify FAIL hold/);
// A verifier hold is not a body hold: no untitled error line.
assert.doesNotMatch(out, /^::error::/m);
assert.match(out, /^#99: no body hold, no member veto and no hold path\.$/m);
assert.match(out, /^#99: no member veto and no hold path\.$/m);
});

test("in the merge queue, the reader's verify:unreadable is a pr-verify hold beside a body hold", async () => {
Expand Down
22 changes: 9 additions & 13 deletions .github/scripts/pr-unblock/workflow-gates.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -371,9 +371,9 @@ test("pr-feedback-dispatch.yml re-runs ci-status for a completed review run of a
assert.equal(runs({ "github.ref": "refs/heads/feat/x" }), false);
});

// A pr-unblock run (its status write starts no workflow) and a relayed
// approval change what ci-status reads, so each re-runs it too.
test("pr-feedback-dispatch.yml re-runs ci-status after a pr-unblock dispatch on main or a relayed review", () => {
// A relayed approval changes what ci-status reads, so it re-runs it too. A
// pr-unblock run does not: ci-status reads no pr-unblock/* status.
test("pr-feedback-dispatch.yml re-runs ci-status after a relayed review, not a pr-unblock dispatch on main", () => {
const gate = jobIf("pr-feedback-dispatch.yml", "rerun-ci-status");
const repo = "melodic-software/claude-code-plugins";
const base = {
Expand All @@ -383,20 +383,15 @@ test("pr-feedback-dispatch.yml re-runs ci-status after a pr-unblock dispatch on
"github.event.workflow_run.repository.full_name": repo,
"github.event.workflow_run.head_repository.full_name": repo,
};
const unblock = (o = {}) =>
assert.equal(
evaluate(gate, {
...base,
"github.event.workflow_run.path": ".github/workflows/pr-unblock.yml",
"github.event.workflow_run.event": "workflow_dispatch",
"github.event.workflow_run.head_branch": "main",
...o,
});
assert.equal(unblock(), true);
assert.equal(unblock({ "github.event.workflow_run.conclusion": "failure" }), true);
assert.equal(unblock({ "github.event.workflow_run.conclusion": "skipped" }), false);
assert.equal(unblock({ "github.event.workflow_run.head_branch": "feat/x" }), false);
assert.equal(unblock({ "github.event.workflow_run.event": "pull_request" }), false);
assert.equal(unblock({ "github.event.workflow_run.head_repository.full_name": "fork/repo" }), false);
}),
false,
);
const review = (o = {}) =>
evaluate(gate, {
...base,
Expand All @@ -413,7 +408,8 @@ test("pr-feedback-dispatch.yml re-runs ci-status after a pr-unblock dispatch on
assert.equal(review({ "github.ref": "refs/heads/feat/x" }), false);
const text = readFileSync(new URL("../../workflows/pr-feedback-dispatch.yml", import.meta.url), "utf8");
const upstreams = text.match(/\n workflows: \[([^\]]*)\]\n/)[1].split(",").map((name) => name.trim());
for (const name of ["pr-unblock", "pr-review-approved"]) assert.ok(upstreams.includes(name), `${name} is a workflow_run upstream`);
assert.ok(upstreams.includes("pr-review-approved"), "pr-review-approved is a workflow_run upstream");
assert.ok(!upstreams.includes("pr-unblock"), "pr-unblock is no workflow_run upstream");
});

// The relay starts a runner only for an approval of a same-repository PR.
Expand Down
12 changes: 8 additions & 4 deletions .github/scripts/pr-verify/resolve-pr.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -254,8 +254,12 @@ const laneAuthored = (user, laneIdentity) =>
const ownerAuthored = (user, ids) => (user?.type === "User" || user?.type === "Bot") && ids.includes(user.id);

// pr-require-checks.yml's ci-status step that runs merge-holds.mjs on a lanes
// App pull request, and the step after it that aggregates the lanes.
export const HOLD_STEP = "Fail a lanes App pull request its body or a member holds";
// App pull request, and the step after it that aggregates the lanes. A run
// reports its own commit's step names, on a re-run too, so a held head whose
// newest run predates the current name still reports OLD_HOLD_STEP.
export const HOLD_STEP = "Fail a lanes App pull request on kill switch, hold path, verifier or veto";
const OLD_HOLD_STEP = "Fail a lanes App pull request its body or a member holds";
const HOLD_STEPS = [HOLD_STEP, OLD_HOLD_STEP];
export const AGGREGATE_STEP = "Aggregate lane results";
// The ci-status step that runs ci-workflows' check-contract (title,
// `do-not-merge` label, issue linkage), and the one error line its run.sh
Expand Down Expand Up @@ -317,7 +321,7 @@ async function heldShape(read) {
const names = failed.map((step) => step?.name);
if (
failed.length === 0 ||
failed.some((step) => step.conclusion !== "failure" || ![HOLD_STEP, CONTRACT_STEP].includes(step.name)) ||
failed.some((step) => step.conclusion !== "failure" || ![...HOLD_STEPS, CONTRACT_STEP].includes(step.name)) ||
new Set(names).size !== names.length ||
!steps.some((step) => step?.name === AGGREGATE_STEP && step.conclusion === "success")
) {
Expand All @@ -338,7 +342,7 @@ async function heldOnly(readLog, { ciId, names }, number) {
return (
errors.length - reasons.length === names.length &&
person.length + label.length === reasons.length &&
(person.length > 0) === names.includes(HOLD_STEP) &&
(person.length > 0) === names.some((name) => HOLD_STEPS.includes(name)) &&
label.length === (names.includes(CONTRACT_STEP) ? 1 : 0)
);
} catch {
Expand Down
13 changes: 12 additions & 1 deletion .github/scripts/pr-verify/resolve-pr.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -393,7 +393,7 @@ const CI_STEPS = [
["Note a draft", "skipped"],
["Fail a pull request carrying the human-gated label", "success"],
["Fail a head a pr-unblock lane stopped", "success"],
["Fail a lanes App pull request its body or a member holds", "failure"],
["Fail a lanes App pull request on kill switch, hold path, verifier or veto", "failure"],
["Check each skipped lane against scope", "success"],
["Aggregate lane results", "success"],
["Complete job", "success"],
Expand Down Expand Up @@ -470,6 +470,17 @@ test("a run that failed only on do-not-merge, alone or beside a hold an approval
assert.equal(await held(`${contractLog(DO_NOT_MERGE_LINE)}\r\n${logOf(PATH_HOLD)}`, failing(CONTRACT_STEP, HOLD_STEP)), "ok");
});

// A run reports its own commit's step names, so a held run from before the
// hold step's rename still reports the earlier name.
test("a held run that still reports the hold step's earlier name is judged the same way", async () => {
const oldName = "Fail a lanes App pull request its body or a member holds";
const jobs = heldJobs(ciJob(CI_STEPS.map(([n, c]) => [n === HOLD_STEP ? oldName : n, c])));
assert.equal(await held(logOf(PATH_HOLD), { jobs }), "ok");
assert.equal(await held(logOf(COST), { jobs }), "not-a-passing-run");
assert.equal(await held(`${contractLog(DO_NOT_MERGE_LINE)}\r\n${logOf(PATH_HOLD)}`, { jobs: heldJobs(ciJob(CI_STEPS.map(([n, c]) => [n === HOLD_STEP ? oldName : n, n === CONTRACT_STEP ? "failure" : c]))) }), "ok");
assert.equal(await held(logOf(), { jobs }), "not-a-passing-run");
});

test("a contract failure other than do-not-merge, or a label line its step does not account for, starts nothing", async () => {
const TITLE = "pr-contract: the PR title does not follow Conventional Commits";
const cases = {
Expand Down
Loading
Loading