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
51 changes: 51 additions & 0 deletions .changes/pr-pipeline/fix-kill-switch-deny-ends-neutral-3b7e91c4.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
---
bump: patch
---

### Fixed

- A pr-merge or intake-triage run that the token broker refuses with reason `kill-switch` no longer
ends red. The lane breaker turns the repository kill switch on after a queued run has read its
variables, so the run's own kill-switch check passes and only the broker's live read stops it.
`request-lane-token` takes a new `kill-switch-ok` input (`token` mode only): on that 403 it ends
green with a `lane-token-stopped` notice, no token and `skip-reason` `kill-switch`. Every step in
pr-merge's `merge` and `enable-auto-merge` jobs and intake-triage's `apply-verdict` and
`apply-refinement` jobs that uses the token already runs only when the token is set, and
intake-triage's `refine-select` now skips on apply-verdict's new `stopped` output. Any other
denial, and every caller without the input, stays red; work-resolve-issue stays red because
work-unblock re-fires its issue from the failed mint step. `version` stays 2.

- pr-merge's `enable-auto-merge` no longer queues a lanes App pull request whose head a pr-unblock
lane stopped. ci-status's merge-queue run fails a head whose newest `pr-unblock/*` status by
github-actions[bot] is `failure` or `error`, but only after every lane leg ran, and the queue then
ejected it. The rule now lives in `merge-holds.mjs` (`unblockStops`, `readStatuses`): a stopped
head, or one whose statuses do not read, is left unarmed (an armed one is disarmed), and each
such skip gets a line in the run summary. The statuses are read on the job token, whose job now
holds `statuses: read`, because the lanes App holds no commit-statuses permission.

- pr-merge's `enable-auto-merge` takes `needs-human` off a lanes App pull request itself when the
lane's newest refusal at the head names only hold paths and a person on the trusted-actor list
approved that head (merge-holds `pathLifters`, or `liftersOf` over base merges), the release the
refusal and ci-status already name. Removing the label re-runs ci-status at the head. A cost,
risk or verifier reason, a refusal at another head, an approval of another head, a dismissed
approval, an untrusted reviewer or a report-only run lifts nothing. The same label also carries
holds that live only in it, so the label stays unless it stands for that refusal alone: no
review lane's hold names the head (or fails to parse); the newest `labeled` event is the lanes
App's and comes within a minute of the refusal comment, so a label put back with no new comment
stays; no review-lane or check-signed-commits hold stands since a person last removed the label
(a bot's removal lifts nothing); the lanes App wrote no pr-unblock escalation or other marked
comment since; no person commented, replied on a review thread, reviewed (an approval aside) or
labeled since; and the head read again just before the delete is the one judged. A failed read
keeps it. It never removes a label a person applied or one another escalation stands behind.

- A trusted person's approval of a hold-path head now also carries to a later head when the diff
between the two touches no hold path and no file a hold path loads (the owner's decision Q10 of
2026-10-28, #10759), in `merge-holds.mjs` `approvalCarry` and its twin
`babysit_merge_policy.approval_carry`. The diff is read with the hold-path rules plus the
enforcement floor and `CARRY_REFUSES` (workflows, actions, `.github/scripts`, `.github/standards`,
the pipeline config's directory, `scripts/`, `lib/`, babysit-prs' scripts and every plugin
script directory a hold-path workflow runs a script from). Any edit to such a file, a test-only
or comment-only edit included, needs a new approval, and so do a compare not read whole and a
head that is not ahead of the approved one. `test_merge_holds_sync.py` derives every file the
hold paths load and fails on one outside the floor and `CARRY_REFUSES`. The control is still a
person's approval.
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
bump: patch
---

### Fixed

- carry a hold-path approval over pushes that touch no gated code

The owner's decision Q10 of 2026-10-28 (#10759): a person's approval of
a hold-path head stays valid at a later head when the diff between them
touches no hold-path file. approvalCarry and its Python twin
approval_carry take that as a second carry path beside the base-merge
one, read from the compare of the approved head to the head.

That diff must also touch no file a hold path loads: the enforcement
floor, and CARRY_REFUSES for the workflows, actions and scripts
hold-path workflows run, the trusted-actor list and the pipeline
config, and babysit-prs' own scripts directory. test_merge_holds_sync.py
derives that closure, including every tracked script a hold-path
workflow names in any directory and scripts run by a bare or
variable-prefixed path, and fails on any member outside the floor and
CARRY_REFUSES. A compare not read whole, a
head not ahead, a dismissal the push did not make, an untrusted or
dismissed approval still carry nothing.
6 changes: 4 additions & 2 deletions .github/actions/request-lane-token/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ run, and mints nothing ([Check mode](#check-mode)).
| `dispatcher-run-id` | `token` mode: the `pr-unblock-dispatch.yml` run that dispatched this one; sent as `dispatcher_run_id` when set. The broker needs it to trust a run sent by `github-actions[bot]` |
| `effect`, `contents`, `pull-requests`, `issues`, `actions` | `token` mode: this job's own `resolve-config` outputs, compared with the broker's answer. `actions` is `read` or empty; empty expects no `actions` permission |
| `superseded-ok` | `true` ends green, with a `lane-token-superseded` warning and no token, on a 403 whose reason is `workflow-modified` (a newer copy of the caller workflow on the default branch, whose run does the work). Default `false` |
| `kill-switch-ok` | `token` mode: `true` ends green, with a `lane-token-stopped` notice, no token and `skip-reason` `kill-switch`, on a 403 whose reason is `kill-switch` (a kill switch turned on after the run read its variables, a designed stop). Steps that use the token must gate on `token`, and jobs that need this one on `skip-reason`. Default `false` |
| `pr-workflow-change-ok` | `token` mode with `pr-number`: `true` ends green, with no token and `skip-reason` `workflow-modified`, on a 403 `workflow-modified` naming a workflow file the PR itself changes ([below](#a-workflow-file-the-pr-changes)). Default `false` |
| `head-sha`, `default-branch`, `github-token` | Required with `pr-workflow-change-ok`: the gated head, the default branch, and a token that reads the repository's contents, for the compare |

Expand All @@ -35,7 +36,7 @@ run, and mints nothing ([Check mode](#check-mode)).
| `expires-at` | The expiry the broker returned |
| `mint-id` | `<check_run_id>-<run_attempt>`, the broker's audit key for this mint |
| `failure-reason` | The one-line reason the step failed with (`lane-token-denied: ...`); empty on a success |
| `skip-reason` | `workflow-modified` when `pr-workflow-change-ok` skipped; empty otherwise |
| `skip-reason` | `workflow-modified` when `pr-workflow-change-ok` skipped, `kill-switch` when `kill-switch-ok` did; empty otherwise |

## What it does

Expand Down Expand Up @@ -66,7 +67,8 @@ run, and mints nothing ([Check mode](#check-mode)).
With `superseded-ok: true`, a 403 with reason `workflow-modified` warns and ends green instead,
setting no output, so steps gated on the `token` output skip. With `pr-workflow-change-ok: true`,
such a 403 for a workflow file the PR changes ends green with `skip-reason` set
([below](#a-workflow-file-the-pr-changes)).
([below](#a-workflow-file-the-pr-changes)). With `kill-switch-ok: true`, a 403 with reason
`kill-switch` ends green with a notice and `skip-reason` `kill-switch`, setting no token.
5. On 200, masks the token, then compares the broker's `effect`, `permissions`, `lane`, `activity`
and `repository_ids` with this job's values (`pull-requests` is `pull_requests` in the API). Any
difference revokes the token and fails red with `effect-mismatch`. That happens when the PR's
Expand Down
31 changes: 29 additions & 2 deletions .github/actions/request-lane-token/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ description: >
revoking the token, unless the broker's effect, permissions, lane, activity
and repository equal what this job resolved; with `pr-workflow-change-ok` a
`workflow-modified` refusal of a workflow file the PR changes skips instead
of failing. In `check` mode it asks the
of failing, and with `kill-switch-ok` a `kill-switch` refusal skips with a
notice. In `check` mode it asks the
broker's check endpoint, which mints nothing, whether a read lane may run,
and fails red on anything but an allow. Contract:
docs/conventions/pr-pipeline/pr-run-activity.md, run job step 6 and the
Expand Down Expand Up @@ -88,6 +89,17 @@ inputs:
work. Any other denial still fails red. Default `false`.
required: false
default: "false"
kill-switch-ok:
description: >-
`true` ends the step green, with a notice, no token and the
`skip-reason` output `kill-switch`, when the broker answers 403 with
reason `kill-switch`: a kill switch turned on after this run read its
variables, a designed stop. Every step that uses the token must run
only when the `token` output is set, and every job that depends on this
one must skip on `skip-reason`. Any other denial still fails red.
`token` mode only. Default `false`.
required: false
default: "false"
pr-workflow-change-ok:
description: >-
`true` ends the step green, with no token and the `skip-reason` output
Expand Down Expand Up @@ -129,7 +141,8 @@ outputs:
value: ${{ steps.request.outputs.failure-reason }}
skip-reason:
description: >-
`workflow-modified` when `pr-workflow-change-ok` skipped the activity; empty otherwise.
`workflow-modified` when `pr-workflow-change-ok` skipped the activity,
`kill-switch` when `kill-switch-ok` did; empty otherwise.
value: ${{ steps.request.outputs.skip-reason }}

runs:
Expand All @@ -154,6 +167,7 @@ runs:
ISSUES: ${{ inputs.issues }}
ACTIONS: ${{ inputs.actions }}
SUPERSEDED_OK: ${{ inputs.superseded-ok }}
KILL_SWITCH_OK: ${{ inputs.kill-switch-ok }}
PR_WORKFLOW_CHANGE_OK: ${{ inputs.pr-workflow-change-ok }}
HEAD_SHA: ${{ inputs.head-sha }}
DEFAULT_BRANCH: ${{ inputs.default-branch }}
Expand All @@ -180,6 +194,10 @@ runs:
[[ $LANE =~ $name && $ACTIVITY =~ $name ]] || fail "the lane and activity must be kebab-case names"
SUPERSEDED_OK=${SUPERSEDED_OK:-false}
[[ $SUPERSEDED_OK == true || $SUPERSEDED_OK == false ]] || fail "superseded-ok must be true or false"
KILL_SWITCH_OK=${KILL_SWITCH_OK:-false}
[[ $KILL_SWITCH_OK == true || $KILL_SWITCH_OK == false ]] || fail "kill-switch-ok must be true or false"
# A check allow is the exit code itself, so a check may never end green on a refusal.
[[ $KILL_SWITCH_OK == false || $MODE == token ]] || fail "kill-switch-ok needs token mode"
PR_WORKFLOW_CHANGE_OK=${PR_WORKFLOW_CHANGE_OK:-false}
[[ $PR_WORKFLOW_CHANGE_OK == true || $PR_WORKFLOW_CHANGE_OK == false ]] ||
fail "pr-workflow-change-ok must be true or false"
Expand Down Expand Up @@ -334,6 +352,15 @@ runs:
printf -- '- %s\n' "$msg" >>"$GITHUB_STEP_SUMMARY"
exit 0
fi
# The broker reads the kill switches live; this run's vars were read
# when it was queued, so a switch turned on since reaches it here.
if [[ $KILL_SWITCH_OK == true && $status == 403 && $reason == kill-switch ]]; then
msg="lane-token-stopped: the broker refused the token because a lanes kill switch is on, a designed stop; this run takes no token and skips the rest"
echo "::notice::$msg"
printf -- '- %s\n' "$msg" >>"$GITHUB_STEP_SUMMARY"
echo "skip-reason=kill-switch" >>"$GITHUB_OUTPUT"
exit 0
fi
# The refusal does not say whether the PR or the default branch
# changed the file, so the skip needs GitHub's own three-dot compare
# at the gated head to list it; any doubt stays red.
Expand Down
62 changes: 62 additions & 0 deletions .github/actions/request-lane-token/request-lane-token.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,68 @@ test("without superseded-ok, a 403 workflow-modified still fails red", () => {
);
});

// The 403 pr-merge run 40747727299 logged when the breaker turned the
// repository kill switch on after the run had read its variables.
const KILLED = {
status: "403",
body: { reason: "kill-switch", detail: 'not-false: the repository CLAUDE_LANES_REPO_DISABLED is set and not exactly "false"' },
};
const KILLED_ERROR =
'lane-token-denied: the broker answered 403 with reason kill-switch: not-false: the repository CLAUDE_LANES_REPO_DISABLED is set and not exactly "false"';

test("with kill-switch-ok, a 403 kill-switch ends green with a notice, skip-reason kill-switch and no token", () => {
const run = request({ ...MERGE, KILL_SWITCH_OK: "true" }, KILLED);
assert.equal(run.status, 0, run.stdout);
assert.match(run.stdout, /^::notice::lane-token-stopped: the broker refused the token because a lanes kill switch is on/m);
assert.doesNotMatch(run.stdout, /::error::/);
assert.equal(run.output, "skip-reason=kill-switch\n");
assert.equal(run.auth.length, 1);
});

test("with kill-switch-ok, any other denial reason still fails red", () => {
const run = request({ ...MERGE, KILL_SWITCH_OK: "true" }, { status: "403", body: { reason: "already-minted" } });
assert.equal(run.status, 1);
assert.equal(run.output, "failure-reason=lane-token-denied: the broker answered 403 with reason already-minted\n");
});

test("with kill-switch-ok, a kill-switch reason on a status other than 403 still fails red", () => {
const run = request({ ...MERGE, KILL_SWITCH_OK: "true" }, { ...KILLED, status: "500" });
assert.equal(run.status, 1);
assert.doesNotMatch(run.output, /skip-reason/);
});

test("without kill-switch-ok, a 403 kill-switch still fails red as it did in run 40747727299", () => {
const run = request(MERGE, KILLED);
assert.equal(run.status, 1);
assert.match(run.stdout, new RegExp(`^::error::${KILLED_ERROR.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`, "m"));
assert.equal(run.output, `failure-reason=${KILLED_ERROR}\n`);
});

test("with kill-switch-ok, an allow still mints the token unchanged", () => {
const run = request({ ...MERGE, KILL_SWITCH_OK: "true" });
assert.equal(run.status, 0, run.stdout);
assert.match(run.output, /^token=ghs_lane$/m);
assert.match(run.output, /^mint-id=5550001-1$/m);
assert.doesNotMatch(run.output, /skip-reason/);
});

test("kill-switch-ok in check mode fails before the broker request", () => {
const run = request(
{ MODE: "check", EFFECT: "", CONTENTS: "", PULL_REQUESTS: "", ISSUES: "", PR_NUMBER: "", KILL_SWITCH_OK: "true" },
KILLED,
);
assert.equal(run.status, 1);
assert.match(run.stdout, /::error::kill-switch-ok needs token mode/);
assert.equal(run.body, null);
});

test("a kill-switch-ok other than true or false fails before the broker request", () => {
const run = request({ ...MERGE, KILL_SWITCH_OK: "yes" }, KILLED);
assert.equal(run.status, 1);
assert.match(run.stdout, /::error::kill-switch-ok must be true or false/);
assert.equal(run.body, null);
});

// #7732: the refusal fix-docs got on #7717, a PR that changes a workflow file.
// The detail's shape is the broker's, as SUPERSEDED above records it.
const HEAD = "a".repeat(40);
Expand Down
50 changes: 48 additions & 2 deletions .github/actions/resolve-config/callers.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -497,8 +497,11 @@ test("intake-triage.yml: a thin issue's refinement runs its model with no write
assert.equal(job.concurrency["cancel-in-progress"], false);
}

assert.equal(evaluate(select.if, { "needs.apply-verdict.result": "success" }), true);
assert.equal(evaluate(select.if, { "needs.apply-verdict.result": "failure" }), false);
const selects = (result, stopped) =>
evaluate(select.if, { "needs.apply-verdict.result": result, "needs.apply-verdict.outputs.stopped": stopped });
assert.equal(selects("success", ""), true);
assert.equal(selects("failure", ""), false);
assert.equal(selects("success", "kill-switch"), false, "a kill-switch stop of apply-verdict refines nothing");
assert.equal(evaluate(model.if, { "needs.refine-select.outputs.refine": "true" }), true);
assert.equal(evaluate(model.if, { "needs.refine-select.outputs.refine": "false" }), false);
// The subscription leg runs only after the key leg handed off or found no key.
Expand Down Expand Up @@ -6850,3 +6853,46 @@ test("every workflow npm ci reads npm-fetch.npmrc from the job's checkout", () =
// and one each in ten other workflows.
assert.ok(covered >= 15, `only ${covered} installing jobs found`);
});

// request-lane-token's kill-switch-ok ends a kill-switch refusal green with no
// token, so nothing after it may run on success() alone: every later step is
// gated, a step that reads the token runs only when it is set, and a job that
// needs the stopped job's success and reads none of its outputs also needs its
// skip output empty (an output only a token step sets already stops it).
test("every kill-switch-ok caller runs no token step and no dependent job on a kill-switch stop", () => {
const callers = [];
for (const name of readdirSync(new URL("../../workflows/", import.meta.url)).filter((f) => /\.ya?ml$/.test(f))) {
const { jobs } = workflow(name);
for (const [id, job] of Object.entries(jobs ?? {})) {
const steps = job.steps ?? [];
const at = steps.findIndex((step) => step.with?.["kill-switch-ok"] === true);
if (at === -1) continue;
callers.push(`${name}:${id}`);
const tokenId = steps[at].id;
assert.ok(tokenId, `${name}:${id}: the token step has an id`);
const tokenRef = `steps.${tokenId}.outputs.token`;
for (const step of steps.slice(at + 1)) {
const where = `${name}:${id}: "${step.name}"`;
assert.equal(typeof step.if, "string", `${where} has an if:, so it never runs on success() alone`);
if (JSON.stringify({ env: step.env, with: step.with, run: step.run }).includes(tokenRef)) {
assert.ok(step.if.includes(`${tokenRef} != ''`), `${where} reads the token only when it is set`);
}
}
const [skip] = Object.entries(job.outputs ?? {}).find(([, value]) => value.includes(`steps.${tokenId}.outputs.skip-reason`)) ?? [];
for (const [otherId, other] of Object.entries(jobs)) {
const needs = [other.needs ?? []].flat();
const cond = String(other.if ?? "");
if (!needs.includes(id) || !cond.includes(`needs.${id}.result == 'success'`)) continue;
if (cond.replaceAll(`needs.${id}.outputs.${skip} `, "").includes(`needs.${id}.outputs.`)) continue;
assert.ok(skip, `${name}:${id} exposes the token step's skip-reason for ${otherId}`);
assert.ok(cond.includes(`needs.${id}.outputs.${skip} == ''`), `${name}:${otherId} skips on ${id}'s ${skip}`);
}
}
}
assert.deepEqual(callers.sort(), [
"intake-triage.yml:apply-refinement",
"intake-triage.yml:apply-verdict",
"pr-merge.yml:enable-auto-merge",
"pr-merge.yml:merge",
]);
});
Loading
Loading