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
8 changes: 5 additions & 3 deletions .github/scripts/pr-merge/merge-job.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,7 @@ test("the listing fails with one clear error after three failed tries", () => {
test("the job end logs both tokens' readings from the job's own responses, and reads no /rate_limit", () => {
const block = step("Log the API budget at job end");
assert.match(block, /\n {8}uses: \.\/\.base\/\.github\/actions\/log-api-budget /);
assert.match(block, /\n {8}if: always\(\) && steps\.lane-token\.outputs\.token != ''\n/);
assert.match(block, /\n {8}if: always\(\) && needs\.post-merge-dispatch\.outputs\.rate-limited == ''\n/);
assert.match(block, /\n {8}continue-on-error: true\n/);
assert.match(block, /\n {10}tokens: lanes-app github-token\n/);
assert.doesNotMatch(job, /gh api rate_limit|phase: start|\$\{\{ [^}]*token[^}]* \}\}\n {10}tokens:/);
Expand Down Expand Up @@ -1119,7 +1119,9 @@ test("after the gate's rate-limit stop, no later merge-job step calls GitHub", (
try {
const bin = join(dir, "bin");
mkdirSync(bin);
writeFileSync(join(bin, "gh"), `#!/usr/bin/env bash\necho "$*" >>"${dir}/gh"\necho 2026-10-26T00:00:00Z\n`);
// The step reads through the wrapped gh, which asks for the headers (-i).
symlinkSync(fileURLToPath(new URL("../../..", import.meta.url)), join(dir, ".base"));
writeFileSync(join(bin, "gh"), `#!/usr/bin/env bash\necho "$*" >>"${dir}/gh"\nprintf 'HTTP/2.0 200 OK\\r\\n\\r\\n'\necho 2026-10-26T00:00:00Z\n`);
writeFileSync(join(bin, "node"), `#!/usr/bin/env bash\necho "RUN_STARTED_AT=$RUN_STARTED_AT" >"${dir}/node"\n`);
for (const file of ["gh", "node"]) chmodSync(join(bin, file), 0o755);
const sweep = join(dir, "sweep.json");
Expand All @@ -1129,7 +1131,7 @@ test("after the gate's rate-limit stop, no later merge-job step calls GitHub", (
const result = spawnSync("bash", ["-c", runScript("Count the sweep's refusals")], {
cwd: dir,
encoding: "utf8",
env: { ...process.env, PATH: `${bin}:${process.env.PATH}`, LIMITED: limited, REPO: "o/r", GITHUB_RUN_ID: "1", SWEEP_SUMMARY_TO: sweep },
env: { ...process.env, PATH: `${bin}:${process.env.PATH}`, LIMITED: limited, REPO: "o/r", GITHUB_RUN_ID: "1", SWEEP_SUMMARY_TO: sweep, RUNNER_TEMP: dir },
});
assert.equal(result.status, 0, result.stderr);
assert.equal(existsSync(join(dir, "gh")), limited !== "true", `gh with LIMITED=${limited}`);
Expand Down
146 changes: 143 additions & 3 deletions .github/scripts/pr-merge/workflow.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -484,7 +484,7 @@ test("enable-auto-merge logs both tokens' readings at job end, before the token
const block = /- name: Log the API budget at job end\n([\s\S]*?)(?=\n {6}- name: |$)/.exec(arm)?.[1];
assert.ok(block, "a job-end budget step");
assert.match(block, /\n {8}uses: \.\/\.base\/\.github\/actions\/log-api-budget /);
assert.match(block, /^ {8}id: budget-end\n {8}if: always\(\) && steps\.lane-token\.outputs\.token != ''\n/);
assert.match(block, /^ {8}id: budget-end\n {8}if: always\(\) && needs\.post-merge-dispatch\.outputs\.rate-limited == ''\n/);
assert.match(block, /\n {8}continue-on-error: true\n {8}timeout-minutes: 1\n/);
assert.match(block, /\n {10}tokens: lanes-app github-token\n/);
// The reader's gh calls read into both names.
Expand All @@ -498,7 +498,9 @@ test("enable-auto-merge logs both tokens' readings at job end, before the token
// The hub ranks GITHUB_TOKEN spend by each job's `api-calls` notice, so a
// job that calls GitHub with the job token and logs nothing is spend no one
// sees. Every such job carries the log-api-budget step, run whatever failed
// before it, naming github-token.
// or stopped before it, naming github-token: under `always()` alone, so a job
// that ends before it mints a lane token still logs the job token's calls. A
// token with no reading gets no line (log-api-budget.test.mjs).
test("every job that calls GitHub with the job token logs its api-calls", () => {
const ids = [...jobs.matchAll(/\n {2}([a-z-]+):\n/g)].map((m) => m[1]);
const callers = ids.filter((id) => job(id).includes("${{ github.token }}"));
Expand All @@ -508,7 +510,7 @@ test("every job that calls GitHub with the job token logs its api-calls", () =>
const unlogged = callers.filter((id) => {
const step = `\n${/- name: Log the API budget[^\n]*\n([\s\S]*?)(?=\n {6}- name: |$)/.exec(job(id))?.[1] ?? ""}`;
return !(
/\n {8}if: always\(\)/.test(step) &&
/\n {8}if: always\(\)(?: && needs\.post-merge-dispatch\.outputs\.rate-limited == '')?\n/.test(step) &&
/\n {8}continue-on-error: true\n/.test(step) &&
/\n {8}uses: \.\/\.base\/\.github\/actions\/log-api-budget /.test(step) &&
/\n {10}tokens: (?:[a-z-]+ )*github-token\b/.test(step)
Expand All @@ -517,6 +519,144 @@ test("every job that calls GitHub with the job token logs its api-calls", () =>
assert.deepEqual(unlogged, []);
});

// A node step whose only token is the job token and whose script builds a
// createGitHub client keeps each answer's reading under github-token (the
// client's rate guard writes it when API_BUDGET_TOKEN names the bucket), or
// the job-end notice counts nothing. A step whose script calls nothing needs
// no bucket, so none is asked of it.
test("every createGitHub step that calls GitHub with the job token alone names API_BUDGET_TOKEN github-token", () => {
const ids = [...jobs.matchAll(/\n {2}([a-z-]+):\n/g)].map((m) => m[1]);
const unnamed = [];
const needless = [];
for (const id of ids) {
const body = job(id);
const jobBucket = /(?:^|\n) {4}env:\n(?: {6}.*\n)*? {6}API_BUDGET_TOKEN: ([a-z-]+)$/m.exec(body.split("\n steps:\n")[0])?.[1];
for (const step of body.split(/\n {6}- name: /).slice(1)) {
const name = step.split("\n")[0];
const script = /\n {8}run: (?:\|\n(?: {10}.*\n|\s*\n)*? {10})?(?:[A-Z_]+=\S+ )*node \.base\/(\S+\.mjs)/.exec(step)?.[1];
if (!script || !step.includes("${{ github.token }}") || step.includes("${{ steps.lane-token.outputs.token }}")) {
continue;
}
const calls = readFileSync(new URL(`../../../${script}`, import.meta.url), "utf8").includes("createGitHub(");
const bucket = /\n {10}API_BUDGET_TOKEN: ([a-z-]+)\n/.exec(step)?.[1];
if (calls && (bucket ?? jobBucket) !== "github-token") {
unnamed.push(`${id}: ${name}`);
}
if (!calls && bucket !== undefined) {
needless.push(`${id}: ${name}`);
}
}
}
assert.deepEqual(unnamed, []);
assert.deepEqual(needless, []);
});

// The sweep-count step's one read, the run's start, goes through the wrapped
// gh, so the job-end notice counts it; sweep-summary.mjs calls nothing.
test("the merge job's sweep-count read leaves one github-token reading", () => {
const step = /- name: Count the sweep's refusals\n([\s\S]*?)(?=\n {6}- name: )/.exec(job("merge"))[1];
const run = /\n {8}run: \|\n((?: {10}.*\n|\s*\n)+)/.exec(step)[1].replace(/^ {10}/gm, "");
const dir = tempDir("pr-merge-sweep-count-");
try {
mkdirSync(join(dir, "bin"));
symlinkSync(fileURLToPath(new URL("../../..", import.meta.url)), join(dir, ".base"));
const headers = "X-Ratelimit-Limit: 5000\\r\\nX-Ratelimit-Remaining: 4000\\r\\nX-Ratelimit-Used: 1000\\r\\nX-Ratelimit-Reset: 9999999999\\r\\nX-Ratelimit-Resource: core\\r\\n\\r\\n";
writeFileSync(
join(dir, "bin", "gh"),
`#!/usr/bin/env bash
printf 'HTTP/2.0 200 OK\\r\\n${headers}'
while [ $# -gt 0 ]; do if [ "$1" = --jq ]; then echo '{"run_started_at":"2026-10-28T00:00:00Z"}' | jq -r "$2"; exit; fi; shift; done
`,
{ mode: 0o755 },
);
writeFileSync(join(dir, "bin", "node"), "#!/usr/bin/env bash\necho \"$RUN_STARTED_AT\" >\"$RUNNER_TEMP/started\"\n", { mode: 0o755 });
writeFileSync(join(dir, "sweep.json"), '{"refusal_counts":{}}');
const r = spawnSync("bash", ["-c", run], {
cwd: dir,
encoding: "utf8",
env: { PATH: `${join(dir, "bin")}:${process.env.PATH}`, GH_TOKEN: "x", REPO: "o/r", GITHUB_RUN_ID: "77", LIMITED: "", RUNNER_TEMP: dir, SWEEP_SUMMARY_TO: join(dir, "sweep.json") },
});
assert.equal(r.status, 0, r.stdout + r.stderr);
assert.equal(readFileSync(join(dir, "started"), "utf8"), "2026-10-28T00:00:00Z\n");
assert.equal(readFileSync(join(dir, "api-budget", "github-token"), "utf8"), "200 5000 4000 1000 9999999999 core\n");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});

// A run that stops at the superseded step, before the lane token, still
// ends with the job token's calls: who's read, the pending-run listing and
// the waiting run's jobs read, each through createGitHub.
for (const jobId of ["merge", "enable-auto-merge"]) {
test(`${jobId}: a run that stops as superseded still logs its github-token calls`, async () => {
const body = job(jobId);
const stepOf = (name) => new RegExp(`- name: ${name}\\n([\\s\\S]*?)(?=\\n {6}- name: |\\n*$)`).exec(body)?.[1];
const context = { "github.event_name": "workflow_dispatch", "github.actor_id": String(OWNER.id), "github.token": "t" };
const envOf = (step) =>
Object.fromEntries([...step.matchAll(/^ {10}([A-Z_]+): (.*)$/gm)].map(([, k, v]) => [k, v.replace(/\$\{\{ (\S+) \}\}/g, (_, e) => context[e] ?? "")]));
const answers = {
"/repos/o/r/actions/runs/4242": { id: 4242, run_attempt: 2, actor: OWNER, triggering_actor: OWNER },
"/repos/o/r/actions/workflows/pr-merge.yml/runs": { workflow_runs: [{ id: 5000, status: "pending", event: "schedule", head_branch: "main", run_attempt: 1 }] },
"/repos/o/r/actions/runs/5000/jobs": { jobs: [{ name: jobId, status: "waiting" }] },
};
const server = createServer((req, res) => {
const answer = answers[req.url.split("?")[0]];
res.writeHead(answer ? 200 : 404, {
"Content-Type": "application/json",
"X-Ratelimit-Limit": "5000",
"X-Ratelimit-Remaining": "4000",
"X-Ratelimit-Used": "1000",
"X-Ratelimit-Reset": "9999999999",
"X-Ratelimit-Resource": "core",
});
res.end(JSON.stringify(answer ?? { message: "Not Found" }));
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
const dir = tempDir("pr-merge-superseded-budget-");
try {
symlinkSync(fileURLToPath(new URL("../../../", import.meta.url)), join(dir, ".base"));
const output = join(dir, "output");
writeFileSync(output, "");
writeFileSync(join(dir, "summary"), "");
const base = {
PATH: process.env.PATH,
RUNNER_TEMP: dir,
GITHUB_OUTPUT: output,
GITHUB_STEP_SUMMARY: join(dir, "summary"),
GITHUB_REPOSITORY: "o/r",
GITHUB_RUN_ID: "4242",
GITHUB_RUN_ATTEMPT: "2",
GITHUB_EVENT_NAME: "workflow_dispatch",
GITHUB_API_URL: `http://127.0.0.1:${server.address().port}`,
NODE_OPTIONS: "--preserve-symlinks-main",
};
for (const name of ["Check who started the run", "Check for a newer run that does this work"]) {
const step = stepOf(name);
const run = /\n {8}run: (.*)/.exec(step)[1];
const status = await new Promise((resolve) =>
spawn("bash", ["-ec", run], { cwd: dir, env: { ...base, ...envOf(step) }, stdio: "ignore" }).on("close", resolve),
);
assert.equal(status, 0, name);
}
assert.match(readFileSync(output, "utf8"), /^proceed=true$/m);
assert.match(readFileSync(output, "utf8"), /^superseded=true$/m);
const log = stepOf("Log the API budget at job end");
assert.match(log, /^ {8}id: budget-end\n {8}if: always\(\) && needs\.post-merge-dispatch\.outputs\.rate-limited == ''\n/);
const action = readFileSync(new URL("../../actions/log-api-budget/action.yml", import.meta.url), "utf8");
const out = spawnSync("bash", ["-c", /\n {6}run: \|\n([\s\S]*)$/.exec(action)[1].replace(/^ {8}/gm, "")], {
cwd: dir,
encoding: "utf8",
env: { ...base, GITHUB_ACTION_PATH: fileURLToPath(new URL("../../actions/log-api-budget/", import.meta.url)), TOKENS: /\n {10}tokens: (.*)\n/.exec(log)[1] },
});
assert.match(out.stdout, /^::notice title=api-calls::github-token=3 not-modified=0$/m);
assert.doesNotMatch(out.stdout, /lanes-app/);
} finally {
server.close();
rmSync(dir, { recursive: true, force: true });
}
});
}

// The lane's room (lane-room) reads this run's post-merge report, which
// post-merge-dispatch keeps, rather than running the check a third time per
// scheduled run, and the rung cap as the merge job passes it: an unset cap
Expand Down
29 changes: 27 additions & 2 deletions .github/workflows/pr-merge.yml
Original file line number Diff line number Diff line change
Expand Up @@ -854,6 +854,7 @@ jobs:
id: who
if: steps.kill.outputs.proceed == 'true'
env:
API_BUDGET_TOKEN: github-token
# The lane token holds no Actions permission.
ACTIONS_TOKEN: ${{ github.token }}
ACTOR_ID: ${{ github.actor_id }}
Expand All @@ -872,6 +873,7 @@ jobs:
if: steps.who.outputs.proceed == 'true'
continue-on-error: true
env:
API_BUDGET_TOKEN: github-token
# The lane token holds no Actions permission.
ACTIONS_TOKEN: ${{ github.token }}
TRUSTED_ACTORS_PATH: .base/.github/standards/trusted-actors/trusted-actors.json
Expand Down Expand Up @@ -1063,6 +1065,7 @@ jobs:
if: steps.candidates.outcome == 'success'
continue-on-error: true
env:
API_BUDGET_TOKEN: github-token
ACTIONS_TOKEN: ${{ github.token }}
CANDIDATES_PATH: ${{ runner.temp }}/candidates.json
PR_MERGE_READS_PATH: ${{ runner.temp }}/pr-merge-reads.json
Expand All @@ -1078,6 +1081,7 @@ jobs:
if: steps.candidates.outcome == 'success'
continue-on-error: true
env:
API_BUDGET_TOKEN: github-token
ACTIONS_TOKEN: ${{ github.token }}
HEAD_REFS_PATH: ${{ runner.temp }}/head-refs-in.json
PR_MERGE_READS_PATH: ${{ runner.temp }}/pr-merge-reads.json
Expand All @@ -1100,6 +1104,7 @@ jobs:
continue-on-error: true
timeout-minutes: 2
env:
API_BUDGET_TOKEN: github-token
ACTIONS_TOKEN: ${{ github.token }}
CARRY_TO: ${{ runner.temp }}/pr-merge-carried.json
PR_MERGE_READS_PATH: ${{ runner.temp }}/pr-merge-reads.json
Expand All @@ -1125,6 +1130,7 @@ jobs:
continue-on-error: true
timeout-minutes: 2
env:
API_BUDGET_TOKEN: github-token
ACTIONS_TOKEN: ${{ github.token }}
BABYSIT_VERIFY_LISTING_CARRY: ${{ runner.temp }}/verify-listing-carry.json
PR_MERGE_READS_PATH: ${{ runner.temp }}/pr-merge-reads.json
Expand All @@ -1139,6 +1145,7 @@ jobs:
if: steps.lane-token.outputs.token != ''
continue-on-error: true
env:
API_BUDGET_TOKEN: github-token
ACTIONS_TOKEN: ${{ github.token }}
LANE_RECORDS_PATH: ${{ runner.temp }}/lane-records.json
PR_MERGE_READS_PATH: ${{ runner.temp }}/pr-merge-reads.json
Expand Down Expand Up @@ -1658,6 +1665,9 @@ jobs:
SWEEP_SUMMARY_TO: ${{ runner.temp }}/pr-merge-sweep.json
run: |
set -euo pipefail
# The read goes through the wrapped gh, so the job-end notice counts it.
. .base/.github/actions/log-api-budget/api-budget.bash
api_budget_wrap_gh github-token
# After the gate's rate-limit stop, no read: the start stays unknown.
RUN_STARTED_AT=
if [ "$LIMITED" != true ]; then
Expand Down Expand Up @@ -1774,9 +1784,13 @@ jobs:
retention-days: 1
if-no-files-found: ignore

# Under always(), not the lane token, so a run that stops before it mints
# one (superseded, refused) still logs the job token's calls; a token
# with no reading gets no line. After post-merge-dispatch's rate-limit
# stop the job made no call and checked nothing out.
- name: Log the API budget at job end
id: budget-end
if: always() && steps.lane-token.outputs.token != ''
if: always() && needs.post-merge-dispatch.outputs.rate-limited == ''
continue-on-error: true
timeout-minutes: 1
uses: ./.base/.github/actions/log-api-budget # zizmor: ignore[self-repository]
Expand Down Expand Up @@ -1942,6 +1956,7 @@ jobs:
id: who
if: steps.kill.outputs.proceed == 'true'
env:
API_BUDGET_TOKEN: github-token
# The lane token holds no Actions permission.
ACTIONS_TOKEN: ${{ github.token }}
ACTOR_ID: ${{ github.actor_id }}
Expand All @@ -1962,6 +1977,7 @@ jobs:
continue-on-error: true
timeout-minutes: 2
env:
API_BUDGET_TOKEN: github-token
ACTIONS_TOKEN: ${{ github.token }}
SUPERSEDED_JOB: enable-auto-merge
TRUSTED_ACTORS_PATH: .base/.github/standards/trusted-actors/trusted-actors.json
Expand Down Expand Up @@ -2000,6 +2016,7 @@ jobs:
if: steps.lane-token.outputs.token != '' && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')
continue-on-error: true
env:
API_BUDGET_TOKEN: github-token
ACTIONS_TOKEN: ${{ github.token }}
HEAD_REFS_PATH: ${{ runner.temp }}/head-refs-in.json
PR_MERGE_READS_PATH: ${{ runner.temp }}/pr-merge-reads.json
Expand All @@ -2009,6 +2026,7 @@ jobs:
if: steps.lane-token.outputs.token != '' && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')
continue-on-error: true
env:
API_BUDGET_TOKEN: github-token
ACTIONS_TOKEN: ${{ github.token }}
BABYSIT_VERIFY_LISTING_CARRY: ${{ runner.temp }}/verify-listing-carry.json
PR_MERGE_READS_PATH: ${{ runner.temp }}/pr-merge-reads.json
Expand All @@ -2019,6 +2037,7 @@ jobs:
continue-on-error: true
timeout-minutes: 2
env:
API_BUDGET_TOKEN: github-token
ACTIONS_TOKEN: ${{ github.token }}
CARRY_TO: ${{ runner.temp }}/pr-merge-carried.json
PR_MERGE_READS_PATH: ${{ runner.temp }}/pr-merge-reads.json
Expand All @@ -2044,6 +2063,7 @@ jobs:
continue-on-error: true
timeout-minutes: 2
env:
API_BUDGET_TOKEN: github-token
ACTIONS_TOKEN: ${{ github.token }}
VERIFY_ANSWERS_PATH: ${{ runner.temp }}/verify-answers-in.json
PR_MERGE_READS_PATH: ${{ runner.temp }}/pr-merge-reads.json
Expand Down Expand Up @@ -2080,6 +2100,7 @@ jobs:
continue-on-error: true
timeout-minutes: 2
env:
API_BUDGET_TOKEN: github-token
ACTIONS_TOKEN: ${{ github.token }}
LANE_RECORDS_PATH: ${{ runner.temp }}/lane-records.json
PR_MERGE_READS_PATH: ${{ runner.temp }}/pr-merge-reads.json
Expand Down Expand Up @@ -2174,9 +2195,13 @@ jobs:
retention-days: 1
if-no-files-found: ignore

# Under always(), not the lane token, so a run that stops before it mints
# one (superseded, refused) still logs the job token's calls; a token
# with no reading gets no line. After post-merge-dispatch's rate-limit
# stop the job made no call and checked nothing out.
- name: Log the API budget at job end
id: budget-end
if: always() && steps.lane-token.outputs.token != ''
if: always() && needs.post-merge-dispatch.outputs.rate-limited == ''
continue-on-error: true
timeout-minutes: 1
uses: ./.base/.github/actions/log-api-budget # zizmor: ignore[self-repository]
Expand Down
Loading