diff --git a/.github/scripts/pr-merge/merge-job.test.mjs b/.github/scripts/pr-merge/merge-job.test.mjs index d58497a234..d11399a3f0 100644 --- a/.github/scripts/pr-merge/merge-job.test.mjs +++ b/.github/scripts/pr-merge/merge-job.test.mjs @@ -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:/); @@ -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"); @@ -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}`); diff --git a/.github/scripts/pr-merge/workflow.test.mjs b/.github/scripts/pr-merge/workflow.test.mjs index dd1fe9a7b3..0c7a91f4ce 100644 --- a/.github/scripts/pr-merge/workflow.test.mjs +++ b/.github/scripts/pr-merge/workflow.test.mjs @@ -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. @@ -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 }}")); @@ -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) @@ -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 diff --git a/.github/workflows/pr-merge.yml b/.github/workflows/pr-merge.yml index a3b0a29bd6..404f1d54ce 100644 --- a/.github/workflows/pr-merge.yml +++ b/.github/workflows/pr-merge.yml @@ -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 }} @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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] @@ -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 }} @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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]