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
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
bump: minor
---

### Added

- The kill-switch gate (`.github/actions/check-kill-switch`) takes a `budget` input. On a gate that starts new work (`budget: "true"`), it stops the lane while the GITHUB_TOKEN core pool's remaining is under github-token's `start-gate` share (0.1, read from `.github/actions/log-api-budget/rate-limits.json` alone) and the reset is still ahead, from one GET /rate_limit with the job's token: its `x-ratelimit-*` headers, else the body's `resources.core`. A gate that finishes or judges existing work keeps the default, switches only. The stop keeps `reason=kill-switch`. New outputs: `cause` (`switch`, `budget` or `ok`) and `reset`. A failed or unreadable read proceeds with a warning. work-unblock's sweep re-fires a budget-stopped issue run once the window's reset has passed, and never counts it toward `limits.limit-stops-per-issue`.
49 changes: 42 additions & 7 deletions .github/actions/check-kill-switch/README.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,26 @@
# check-kill-switch

Gate a CI lane on the org and repository kill switches before its model step. The lane proceeds
only when both read exactly `false`; a switch that is unset, empty or holds any other value stops
it
Gate a CI lane on the org and repository kill switches before its model step, and, on a gate that
starts new work, on the GITHUB_TOKEN budget too. The lane proceeds only when both switches read
exactly `false`; a switch that is unset, empty or holds any other value stops it
([ADR 0049](../../../docs/adr/0049-run-ci-lanes-on-github-hosted-runners-under-trigger-and-token-hardening.md)
condition 4).
condition 4). Nothing writes the switches automatically: they are manual brakes.

With `budget: "true"` and both switches proceeding, the gate makes one GET /rate_limit with
`token` and reads the core pool from the `x-ratelimit-limit`, `-remaining` and `-reset` headers,
or from the body's `resources.core` when a header is missing. It stops the lane while remaining
is under github-token's `start-gate` share of the limit and the reset is still ahead. The share
is read from [`rate-limits.json`](../log-api-budget/rate-limits.json) alone (0.1); no
`pr-pipeline.yaml` field overrides it. A share of 0 turns the read off. A refused read (403, 429,
a `retry-after` header, or `x-ratelimit-remaining: 0`) stops the lane with cause `budget`, since
more calls would be refused too; its reset is `x-ratelimit-reset`, else now plus `retry-after`,
else now plus 60 seconds. A read that fails otherwise (a transport error, a timeout, a 5xx or a
401) or carries no reading, or a share that cannot be read, proceeds with a warning; the rate
guard's `stop-floor` is the backstop. GitHub counts GET /rate_limit against the secondary limit,
not the primary one, so the gate makes it once, with no retry, and not at all when a switch
already stops. Each read logs one
`start-gate` notice with the body's core block and the response's rate-limit headers, so the two
can be compared.

The repository switch has its own name because a repository variable named
`CLAUDE_LANES_DISABLED` replaces the org value in `vars`, so it could re-enable lanes under an
Expand All @@ -17,15 +33,34 @@ and a read lane's broker check, while one exists.
|---|---|---|
| `value` | empty | The org switch, normally `${{ vars.CLAUDE_LANES_DISABLED }}` |
| `repo-value` | empty | The repository switch, normally `${{ vars.CLAUDE_LANES_REPO_DISABLED }}` |
| `budget` | `false` | `true` also reads the GITHUB_TOKEN budget |
| `token` | `${{ github.token }}` | The token whose GET /rate_limit answer is the budget reading |

Set `budget: "true"` only on a gate that starts new work: the read runner's `broker-check` on the
first call of a caller that passes `start-gate: true` (the issue lane only), and a workflow's
first gate on a schedule or event that begins a run's work (intake triage and its refine select,
the audit, cleanup, recurring-issue, skill-edit, signals, reconcile, tidy, mutation and changelog
ticks, and the work-unblock sweep). A gate that finishes or judges existing work keeps the
default, so a low budget never throws that work away: reviews, merges, verdicts and verify notes,
writes after a model turn, model legs that take over a handed-off turn or run as the fallback,
and the pr-unblock legs (its dispatcher and pr-refine), which fix existing pull requests. Evals,
which a person or a pull request starts, keep it too. A budget stop ends the run neutral: a
notice, no failed step. A job that sets `budget: "true"` checks out fully or lists
`.github/actions/log-api-budget/` in its sparse checkout, where the share is read.
`check-kill-switch.test.mjs` classifies every gate in `.github/workflows` and fails on one it
does not know.

## Outputs

| Output | Values |
|---|---|
| `proceed` | `true` only when `value` and `repo-value` are both exactly `false`; otherwise `false` |
| `reason` | `ok` or `kill-switch` |
| `proceed` | `true` when both switches proceed and the budget does not stop the lane; otherwise `false` |
| `reason` | `ok` or `kill-switch`, for every stop |
| `cause` | `ok`, `switch` (a switch stops) or `budget` |
| `reset` | on a budget stop, the epoch second the reading's window resets; otherwise empty |

The step always exits 0. A stopped lane is an expected outcome, not a failure.
The step always exits 0. A stopped lane is an expected outcome, not a failure. A step that
reports the stop prints `cause` beside `reason`, so a budget stop is not read as a switch.

## Job contract

Expand Down
36 changes: 31 additions & 5 deletions .github/actions/check-kill-switch/action.yml
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
name: Check Kill Switch
description: >
Gate a CI lane on the org and repository kill switches. Emits proceed=true
only when both values read exactly `false`; unset, empty and every other
value emit proceed=false reason=kill-switch. Always exits 0: the model
step's `if:` reads the output, so a missing output also keeps the lane
stopped.
Gate a CI lane on the org and repository kill switches, and, on a gate
that starts new work (budget "true"), the GITHUB_TOKEN budget. Each switch
proceeds only on exactly `false`; unset, empty and every other value stop.
With budget "true" and both switches off, one GET /rate_limit stops the
lane while the core pool's remaining is under rate-limits.json's
github-token start-gate share of its limit, or when the read is refused
(403, 429, retry-after, remaining 0); any other failed read proceeds. Every
stop is proceed=false reason=kill-switch, with cause switch or budget.
Always exits 0: the model step's `if:` reads the output, so a missing
output also keeps the lane stopped.

inputs:
value:
Expand All @@ -20,6 +25,19 @@ inputs:
the lane proceed.
required: false
default: ""
budget:
description: >
`true` also reads the GITHUB_TOKEN budget. Set it only on a gate that
starts new work; a gate that finishes or judges existing work keeps the
default, switches only, so a low budget never throws that work away.
required: false
default: "false"
token:
description: >
The token whose GET /rate_limit answer is the budget reading. Defaults
to the job's GITHUB_TOKEN.
required: false
default: ${{ github.token }}

outputs:
proceed:
Expand All @@ -28,6 +46,12 @@ outputs:
reason:
description: "`ok` or `kill-switch`."
value: ${{ steps.check.outputs.reason }}
cause:
description: "`ok`, or why the lane stopped: `switch` or `budget`."
value: ${{ steps.check.outputs.cause }}
reset:
description: "On a budget stop, the epoch second the reading's window resets; otherwise empty."
value: ${{ steps.check.outputs.reset }}

runs:
using: composite
Expand All @@ -38,4 +62,6 @@ runs:
env:
KILL_SWITCH_VALUE: ${{ inputs.value }}
REPO_KILL_SWITCH_VALUE: ${{ inputs.repo-value }}
BUDGET_CHECK: ${{ inputs.budget }}
BUDGET_TOKEN: ${{ inputs.token }}
run: node "$GITHUB_ACTION_PATH/check-kill-switch.mjs"
154 changes: 143 additions & 11 deletions .github/actions/check-kill-switch/check-kill-switch.mjs
Original file line number Diff line number Diff line change
@@ -1,15 +1,137 @@
// Kill-switch gate for CI lanes: proceed only when the org switch and the
// repository switch both read exactly `false`. Unset, empty and every other
// value stop the lane (fail closed).
// Gate for CI lanes: two manual switches, then, on a gate that starts new
// work, the GITHUB_TOKEN budget.
//
// - The org switch and the repository switch each proceed only on exactly
// `false`. Unset, empty and every other value stop the lane (fail closed).
// - With BUDGET_CHECK=true (the action's `budget: "true"`, set only on gates
// that start new work) and both switches proceeding, one GET /rate_limit
// with the job's token reads the core pool: x-ratelimit-limit, -remaining
// and -reset when the response carries all three, else the body's
// resources.core. The lane stops while remaining < start-gate x limit and
// the reset is still ahead; start-gate is github-token's entry in
// ../log-api-budget/rate-limits.json. A refused read (403, 429, retry-after
// or x-ratelimit-remaining 0) stops, since more calls would be refused too.
// A read that fails otherwise (transport, timeout, 5xx, 401) or cannot be
// parsed, or a share that cannot be read, proceeds with a warning; the rate guard's
// stop-floor (log-api-budget/rate-guard.mjs) is the backstop. GitHub counts
// this call against the secondary limit, not the primary one, so it is made
// once, with no retry. A gate that finishes or judges existing work leaves
// the budget off, so a low budget never throws that work away.
//
// Every stop is reason=kill-switch; cause says which: switch, budget, or ok
// when the lane proceeds. A budget stop also writes reset, the epoch second
// the reading's window resets.
import { randomUUID } from "node:crypto";
import { appendFileSync } from "node:fs";
import { appendFileSync, readFileSync } from "node:fs";
import process from "node:process";
import { pathToFileURL } from "node:url";

const READ_TIMEOUT_MS = 10_000;
const RATE_LIMITS = new URL("../log-api-budget/rate-limits.json", import.meta.url);

export function checkKillSwitch(value, repoValue) {
return value === "false" && repoValue === "false"
? { proceed: "true", reason: "ok" }
: { proceed: "false", reason: "kill-switch" };
? { proceed: "true", reason: "ok", cause: "ok" }
: { proceed: "false", reason: "kill-switch", cause: "switch" };
}

// Whether `reading` ({ limit, remaining, reset }, reset in epoch seconds) is
// under `share` of its limit with its reset after `now` (epoch seconds).
// Anything unreadable never stops.
export function budgetStops({ limit, remaining, reset } = {}, share, now) {
return [limit, remaining, reset, share].every(Number.isFinite) && limit > 0 && reset > now && remaining < share * limit;
}

const HEADERS = ["limit", "remaining", "reset", "used", "resource"];

// The reading from one GET /rate_limit response: its headers when limit,
// remaining and reset all parse, else the body's resources.core.
export function readingOf(headers, body) {
const header = Object.fromEntries(HEADERS.map((name) => [name, headers?.get?.(`x-ratelimit-${name}`) ?? null]));
const fromHeaders = Object.fromEntries(["limit", "remaining", "reset"].map((name) => [name, /^\d+$/.test(header[name] ?? "") ? Number(header[name]) : Number.NaN]));
const core = body?.resources?.core ?? null;
const reading = Object.values(fromHeaders).every(Number.isFinite)
? { ...fromHeaders, source: "headers" }
: { limit: Number(core?.limit), remaining: Number(core?.remaining), reset: Number(core?.reset), source: "body" };
return { reading, header, core };
}

// A value from a response, kept to one line with no workflow command in it.
const clean = (text) => String(text).replace(/[\r\n%:]/g, " ");
const show = (fields) => Object.entries(fields ?? {}).map(([name, value]) => `${name}=${clean(value ?? "na")}`).join(" ");

const DEFAULT_WAIT_S = 60;
const whole = (value) => (/^\d+$/.test(value ?? "") ? Number(value) : Number.NaN);

// A response that says the token is already limited (403, 429, retry-after, or
// no requests remaining) stops the lane rather than proceeding into more calls
// that would be refused too. Its reset is always ahead of now: the header's
// reset, else now + retry-after, else now + 60.
function refusal(response, now) {
const { status, headers } = response;
const header = (name) => headers?.get?.(name) ?? null;
const [retryAfter, remaining, headerReset] = [header("retry-after"), header("x-ratelimit-remaining"), header("x-ratelimit-reset")];
if (status !== 403 && status !== 429 && retryAfter === null && remaining !== "0") {
return null;
}
const wait = Number.isFinite(whole(retryAfter)) ? whole(retryAfter) : DEFAULT_WAIT_S;
const reset = whole(headerReset) > now ? whole(headerReset) : Math.ceil(now + wait);
return {
stop: true,
reset,
line: `::warning title=start-gate::the GITHUB_TOKEN budget read was refused (HTTP ${status} ${show({ "retry-after": retryAfter, remaining, reset: headerReset })}); the lane stops until ${reset}`,
};
}

// The budget half of the gate: { stop, reset, line } where line is the run's
// one notice or warning. Never throws.
export async function checkBudget({ token, api = "https://api.github.com", share, now = Date.now() / 1000, fetchImpl = fetch } = {}) {
if (!token) {
return { stop: false, line: "::warning title=start-gate::no token to read the GITHUB_TOKEN budget; the lane proceeds" };
}
try {
const response = await fetchImpl(`${api}/rate_limit`, {
headers: { Accept: "application/vnd.github+json", Authorization: `Bearer ${token}`, "X-GitHub-Api-Version": "2022-11-28" },
signal: AbortSignal.timeout(READ_TIMEOUT_MS),
});
const refused = refusal(response, now);
if (refused) {
return refused;
}
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const { reading, header, core } = readingOf(response.headers, await response.json().catch(() => null));
if (![reading.limit, reading.remaining, reading.reset].every(Number.isFinite)) {
throw new Error("neither the headers nor the body's resources.core carry limit, remaining and reset");
}
const stop = budgetStops(reading, share, now);
const coreFields = core && { limit: core.limit, remaining: core.remaining, reset: core.reset, used: core.used };
return {
stop,
reset: reading.reset,
line:
`::notice title=start-gate::github-token start-gate=${share} decided from ${reading.source}: limit=${reading.limit} remaining=${reading.remaining} reset=${reading.reset} stop=${stop}; ` +
`body core ${core ? show(coreFields) : "absent"}; headers ${show(header)}`,
};
} catch (error) {
return { stop: false, line: `::warning title=start-gate::the GITHUB_TOKEN budget was not read (${clean(error?.message ?? error)}); the lane proceeds` };
}
}

// github-token's start-gate share from rate-limits.json, the only copy, or
// null with a warning when it cannot be read.
export function startGateShare(path = RATE_LIMITS, log = (line) => console.log(line)) {
try {
const share = JSON.parse(readFileSync(path, "utf8"))?.["github-token"]?.["start-gate"];
if (typeof share !== "number" || share < 0 || share > 1) {
throw new Error("github-token start-gate is not a share from 0 to 1");
}
return share;
} catch (error) {
log(`::warning title=start-gate::rate-limits.json gave no start-gate share (${clean(error?.code ?? error?.message)}); the lane proceeds without a budget read`);
return null;
}
}

// Every value is written in the delimited form, so no value can start a
Expand All @@ -30,12 +152,22 @@ export function writeOutputs(outputs, outputPath = process.env.GITHUB_OUTPUT) {
appendFileSync(outputPath, text);
}

function main() {
const result = checkKillSwitch(process.env.KILL_SWITCH_VALUE, process.env.REPO_KILL_SWITCH_VALUE);
writeOutputs(result);
console.log(`check-kill-switch: proceed=${result.proceed} reason=${result.reason}`);
export async function main(env = process.env, { fetchImpl = fetch, now = Date.now, log = (line) => console.log(line), rateLimits = RATE_LIMITS } = {}) {
let result = { ...checkKillSwitch(env.KILL_SWITCH_VALUE, env.REPO_KILL_SWITCH_VALUE), reset: "" };
if (result.proceed === "true" && env.BUDGET_CHECK === "true") {
const share = startGateShare(rateLimits, log);
if (share) {
const budget = await checkBudget({ token: env.BUDGET_TOKEN, api: env.GITHUB_API_URL || undefined, share, now: now() / 1000, fetchImpl });
log(budget.line);
if (budget.stop) {
result = { proceed: "false", reason: "kill-switch", cause: "budget", reset: String(budget.reset) };
}
}
}
writeOutputs(result, env.GITHUB_OUTPUT);
log(`check-kill-switch: proceed=${result.proceed} reason=${result.reason} cause=${result.cause}${result.reset ? ` reset=${result.reset}` : ""}`);
}

if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
main();
await main();
}
Loading
Loading