diff --git a/.changes/pr-pipeline/ci-count-every-node-github-request-4b9e21c7.md b/.changes/pr-pipeline/ci-count-every-node-github-request-4b9e21c7.md new file mode 100644 index 0000000000..15b5e63e21 --- /dev/null +++ b/.changes/pr-pipeline/ci-count-every-node-github-request-4b9e21c7.md @@ -0,0 +1,8 @@ +--- +bump: patch +--- + +### Fixed + +- The job-end `api-calls` notice counts every GitHub request the lane scripts' Node clients send. Before, the clients built by address-feedback (its select and context mains, fix-ci-context and review-dependency), pr-verify's job-log read, and three steps that named no bucket sent requests the notice never saw. A client on a second token now counts under `API_BUDGET_OVERRIDE_TOKEN` when the request's token is not the step's own `GH_TOKEN` (else `GITHUB_TOKEN`), the rule babysit's gh calls already follow. Before, the job token's calls in a lanes App step were counted as lanes-app. No request changes. +- The notice adds `graphql=K`, the GraphQL requests among the count, which spend GraphQL's own pool: `=N not-modified=M graphql=K`. diff --git a/.github/actions/log-api-budget/action.yml b/.github/actions/log-api-budget/action.yml index e49a9c1bab..888210e127 100644 --- a/.github/actions/log-api-budget/action.yml +++ b/.github/actions/log-api-budget/action.yml @@ -5,9 +5,10 @@ description: > in $RUNNER_TEMP/api-budget (api-budget.bash): one per refused (403/429) response and one for the last. GET /rate_limit is never read: it reports a different bucket from the one the job spends. Then an `api-calls` notice, - `=N not-modified=M`: N is this job's own requests, which cost (the rise in a - counter every concurrent job shares) is not, and M its 304 answers, which - GitHub does not count. Then one line for the job's content-creating writes + `=N not-modified=M graphql=K`: N is this job's own requests, which cost (the rise in a + counter every concurrent job shares) is not, M its 304 answers, which + GitHub does not count, and K the GraphQL requests among N, which spend + GraphQL's own pool. Then one line for the job's content-creating writes (count and most in any 60 s). Also writes each line and a `-cost=N` line to the step summary. A token with no reading gets only its api-calls line, at 0. Never fails the job. diff --git a/.github/actions/log-api-budget/api-budget.bash b/.github/actions/log-api-budget/api-budget.bash index beb55d0e9e..e7d341e500 100644 --- a/.github/actions/log-api-budget/api-budget.bash +++ b/.github/actions/log-api-budget/api-budget.bash @@ -6,7 +6,8 @@ # # (`na` for a missing value). api-budget.mjs and babysit_gh.py append the same # lines. A request the gh wrapper counts with no header block to read (each -# page of --paginate, a call that is not `gh api`) is a line `request`. Never +# page of --paginate, a call that is not `gh api`) is a line `request`, or +# `request graphql` for a GraphQL request. Never # fails the caller, except that a stop the rate guard below cannot record ends # the step. # @@ -75,9 +76,11 @@ # writes: count and most in any 60 s # api_budget_emit_calls ... # the hub's api-calls notice for each token, -# `=N not-modified=M`: N is the job's own -# requests, M its 304 answers, which GitHub does -# not count; 0 for a token with no reading +# `=N not-modified=M graphql=K`: N is the +# job's own requests, M its 304 answers, which +# GitHub does not count, and K the GraphQL ones +# among N, which spend their own pool; 0 for a +# token with no reading # # pr-require-checks.yml's ci-status job carries this file verbatim in its # API_BUDGET_SH env (it has no checkout of this directory); @@ -209,7 +212,7 @@ api_budget_traced() { /^\* Request at / { open++; next } /^\* Request took / { if (open) open--; next } /^\* Request to / { - if ($4 ~ /^https:\/\/((api|uploads)\.github\.com|[^\/]+\/api)\// && ++n > skip) print "request" >>state + if ($4 ~ /^https:\/\/((api|uploads)\.github\.com|[^\/]+\/api)\// && ++n > skip) print "request" ($4 ~ /\/graphql([?]|$)/ ? " graphql" : "") >>state next } /^\* (Using proxy|Skipping TLS verification): / || (open && /^\* /) || /^\[git[] ]/ { next } @@ -426,7 +429,7 @@ api_budget_emit_calls() { file=${RUNNER_TEMP:-}/api-budget/$token [[ -n ${RUNNER_TEMP:-} && $token =~ ^[a-z][a-z0-9-]{0,39}$ ]] || continue [[ -s $file ]] || file=/dev/null - awk '$1 != 304 { n++ } $1 == 304 { m++ } END { printf "::notice title=api-calls::%s=%d not-modified=%d\n", token, n, m }' token="$token" "$file" + awk '$1 != 304 { n++ } $1 == 304 { m++ } $6 == "graphql" || $2 == "graphql" { g++ } END { printf "::notice title=api-calls::%s=%d not-modified=%d graphql=%d\n", token, n, m, g }' token="$token" "$file" done return 0 } diff --git a/.github/actions/log-api-budget/count-requests.test.mjs b/.github/actions/log-api-budget/count-requests.test.mjs new file mode 100644 index 0000000000..2a13a7c408 --- /dev/null +++ b/.github/actions/log-api-budget/count-requests.test.mjs @@ -0,0 +1,185 @@ +// Every GitHub client factory a lane script builds counts each request it +// sends in $RUNNER_TEMP/api-budget/, one reading line per answer, for +// the job-end api-calls notice (api-budget.bash api_budget_emit_calls). +import assert from "node:assert/strict"; +import { existsSync, readFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { test } from "node:test"; +import { createGitHub } from "../check-trusted-trigger/check-trusted-trigger.mjs"; +import { countRequests, createRateLimitLatch } from "./rate-guard.mjs"; +import { countedFetch, createGitHubApi as addressFeedbackApi } from "../../scripts/pr-unblock/address-feedback.mjs"; +import { createGitHubApi as dispatchApi } from "../../scripts/pr-unblock/dispatch.mjs"; +import { createGitHubApi as feedbackDispatchApi } from "../../scripts/pr-unblock/feedback-dispatch.mjs"; +import { budgetedFetch } from "../../scripts/pr-unblock/request-budget.mjs"; +import { tempDir } from "../../../lib/test-tmp.mjs"; + +const API = "https://api.github.com"; +const READING = { "x-ratelimit-limit": "5000", "x-ratelimit-remaining": "4990", "x-ratelimit-used": "10", "x-ratelimit-reset": "1792751600", "x-ratelimit-resource": "core" }; +const LINE = "200 5000 4990 10 1792751600 core\n"; + +// A fetch that answers each request 200 with body and the core reading above. +const fake = (body = {}) => { + const sent = []; + const fetch = async (url, init = {}) => { + sent.push(`${init.method ?? "GET"} ${url}`); + return new Response(JSON.stringify(body), { status: 200, headers: READING }); + }; + return { sent, fetch }; +}; + +// Runs fn with RUNNER_TEMP at a fresh directory and the given variables set +// (undefined unsets one), then restores them; lines(name) reads a token's file. +async function withBudget(vars, fn) { + const dir = tempDir("count-requests-"); + const keys = ["RUNNER_TEMP", "API_BUDGET_TOKEN", "API_BUDGET_OVERRIDE_TOKEN", "GH_TOKEN", "GITHUB_TOKEN", ...Object.keys(vars)]; + const saved = Object.fromEntries(keys.map((key) => [key, process.env[key]])); + for (const key of keys) delete process.env[key]; + Object.assign(process.env, { RUNNER_TEMP: dir }, Object.fromEntries(Object.entries(vars).filter(([, value]) => value !== undefined))); + const lines = (name) => { + const file = join(dir, "api-budget", name); + return existsSync(file) ? readFileSync(file, "utf8") : ""; + }; + try { + return await fn(lines); + } finally { + for (const [key, value] of Object.entries(saved)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + rmSync(dir, { recursive: true, force: true }); + } +} + +const quietLatch = () => createRateLimitLatch({ log: () => {}, fail: () => {}, limits: () => ({}), stopFile: null }); + +test("createGitHub counts each request under API_BUDGET_TOKEN", () => + withBudget({ API_BUDGET_TOKEN: "github-token" }, async (lines) => { + const github = createGitHub({ token: "t", fetchImpl: fake().fetch, latch: quietLatch(), log: () => {} }); + await github("GET", "/repos/o/r/pulls/1"); + await github("GET", "/repos/o/r/pulls/2"); + assert.equal(lines("github-token"), LINE + LINE); + })); + +test("a client on another token than the step's GH_TOKEN counts under API_BUDGET_OVERRIDE_TOKEN", () => + withBudget({ API_BUDGET_TOKEN: "lanes-app", API_BUDGET_OVERRIDE_TOKEN: "github-token", GH_TOKEN: "lane" }, async (lines) => { + const latch = quietLatch(); + const own = createGitHub({ token: "lane", fetchImpl: fake().fetch, latch, log: () => {} }); + const job = createGitHub({ token: "job", fetchImpl: fake().fetch, latch, log: () => {} }); + await own("GET", "/repos/o/r/pulls/1"); + await job("GET", "/repos/o/r/actions/runs"); + await job("GET", "/repos/o/r/actions/runs"); + assert.equal(lines("lanes-app"), LINE); + assert.equal(lines("github-token"), LINE + LINE); + })); + +test("without API_BUDGET_OVERRIDE_TOKEN, or with no GH_TOKEN or GITHUB_TOKEN to compare, every client counts under API_BUDGET_TOKEN", async () => { + for (const vars of [{ GH_TOKEN: "lane" }, { API_BUDGET_OVERRIDE_TOKEN: "github-token" }]) { + await withBudget({ API_BUDGET_TOKEN: "lanes-app", ...vars }, async (lines) => { + await createGitHub({ token: "job", fetchImpl: fake().fetch, latch: quietLatch(), log: () => {} })("GET", "/repos/o/r/actions/runs"); + assert.equal(lines("lanes-app"), LINE, JSON.stringify(vars)); + assert.equal(lines("github-token"), "", JSON.stringify(vars)); + }); + } +}); + +test("GITHUB_TOKEN is the step's own token when GH_TOKEN is unset", () => + withBudget({ API_BUDGET_TOKEN: "github-token", API_BUDGET_OVERRIDE_TOKEN: "lanes-app", GITHUB_TOKEN: "job" }, async (lines) => { + const latch = quietLatch(); + await createGitHub({ token: "job", fetchImpl: fake().fetch, latch, log: () => {} })("GET", "/repos/o/r/pulls/1"); + await createGitHub({ token: "app", fetchImpl: fake().fetch, latch, log: () => {} })("GET", "/repos/o/r/pulls/1"); + assert.equal(lines("github-token"), LINE); + assert.equal(lines("lanes-app"), LINE); + })); + +test("a fetch that already counts is counted once, however often it is wrapped", () => + withBudget({ API_BUDGET_TOKEN: "github-token" }, async (lines) => { + const latch = quietLatch(); + const twice = countRequests(latch.guard(countRequests(fake().fetch))); + await twice(`${API}/repos/o/r/pulls/1`); + assert.equal(lines("github-token"), LINE); + })); + +test("address-feedback's client counts each attempt, a resent read included, through countedFetch", () => + withBudget({ API_BUDGET_TOKEN: "github-token" }, async (lines) => { + const answers = [500, 200]; + const fetch = async () => new Response(JSON.stringify({ number: 1 }), { status: answers.shift(), headers: READING }); + const api = addressFeedbackApi({ token: "t", repository: "o/r", apiUrl: API, graphqlUrl: `${API}/graphql`, fetch: await countedFetch(fetch), sleep: async () => {} }); + assert.deepEqual(await api.getPull(1), { number: 1 }); + assert.equal(lines("github-token"), `500 5000 4990 10 1792751600 core\n${LINE}`); + })); + +test("dispatch's client counts each request, and once when handed budgetedFetch", () => + withBudget({ API_BUDGET_TOKEN: "github-token" }, async (lines) => { + const pulls = { data: { repository: { pullRequests: { nodes: [] } } } }; + const raw = dispatchApi({ token: "t", repository: "o/r", apiUrl: API, graphqlUrl: `${API}/graphql`, fetch: fake(pulls).fetch }); + assert.deepEqual(await raw.listOpenPulls({ base: "main" }), { nodes: [] }); + assert.equal(lines("github-token"), LINE); + const budgeted = dispatchApi({ token: "t", repository: "o/r", apiUrl: API, graphqlUrl: `${API}/graphql`, fetch: budgetedFetch({ fetch: fake(pulls).fetch, latch: quietLatch(), log: () => {} }).fetch }); + await budgeted.listOpenPulls({ base: "main" }); + assert.equal(lines("github-token"), LINE + LINE); + })); + +test("feedback-dispatch's client counts each request, and once when handed budgetedFetch", () => + withBudget({ API_BUDGET_TOKEN: "github-token" }, async (lines) => { + const raw = feedbackDispatchApi({ token: "t", repository: "o/r", apiUrl: API, graphqlUrl: `${API}/graphql`, fetch: fake({ permission: "write" }).fetch }); + assert.equal(await raw.getPermission("someone"), "write"); + assert.equal(lines("github-token"), LINE); + const budgeted = feedbackDispatchApi({ + token: "t", + repository: "o/r", + apiUrl: API, + graphqlUrl: `${API}/graphql`, + fetch: budgetedFetch({ fetch: fake({ permission: "read" }).fetch, latch: quietLatch(), log: () => {} }).fetch, + }); + assert.equal(await budgeted.getPermission("someone"), "read"); + assert.equal(lines("github-token"), LINE + LINE); + })); + +// The mains run from the base checkout build their address-feedback client +// with countedFetch, over the global fetch. Each answers 404 here, so a main +// stops at its first request; every request it sent is one reading. +const ROOT = new URL("../../../", import.meta.url).pathname; +const SHA = "a".repeat(40); +for (const [name, run] of [ + ["address-feedback select", async (env) => (await import("../../scripts/pr-unblock/address-feedback.mjs")).main(["select"], env, { log: () => {} })], + ["fix-ci-context", async (env) => (await import("../../scripts/pr-unblock/fix-ci-context.mjs")).main({ ...env, FIX_CI_ROUNDS: "3" }, { log: () => {} })], + ["review-dependency select", async (env) => (await import("../../scripts/pr-unblock/review-dependency.mjs")).main(["select"], env, { log: () => {} })], +]) { + test(`${name}'s main counts each request its client sends`, () => + withBudget({ API_BUDGET_TOKEN: "github-token" }, async (lines) => { + const dir = tempDir("count-requests-main-"); + const saved = globalThis.fetch; + let sent = 0; + globalThis.fetch = async () => { + sent += 1; + return new Response("{}", { status: 404, headers: READING }); + }; + try { + const env = { + GITHUB_TOKEN: "t", + GITHUB_REPOSITORY: "o/r", + GITHUB_API_URL: API, + GITHUB_OUTPUT: join(dir, "out"), + PR_NUMBER: "7", + HEAD_SHA: SHA, + APP_SENDER_ID: "1", + CONTEXT_FILE: join(dir, "context.json"), + THREAD_FILE: join(dir, "threads.json"), + RUN_FILE: join(dir, "run.json"), + TRUSTED_ACTORS: join(ROOT, ".github/standards/trusted-actors/trusted-actors.json"), + TRACKER_CONFIG: join(ROOT, ".work-item-tracker.json"), + DEPENDENCY_FILE: join(dir, "dependency.json"), + CONTEXT_RUN_FILE: join(dir, "run.json"), + DEPENDABOT_CONFIG: join(ROOT, ".github/dependabot.yml"), + DEPENDABOT_POLICY: join(ROOT, ".github/standards/dependabot-policy/policy.json"), + BASE_PATH: ROOT, + }; + await assert.rejects(run(env), /returned 404/); + assert.ok(sent > 0, "the main sent a request"); + assert.equal(lines("github-token"), "404 5000 4990 10 1792751600 core\n".repeat(sent)); + } finally { + globalThis.fetch = saved; + rmSync(dir, { recursive: true, force: true }); + } + })); +} diff --git a/.github/actions/log-api-budget/log-api-budget.test.mjs b/.github/actions/log-api-budget/log-api-budget.test.mjs index b0c7081561..0eab2dd69f 100644 --- a/.github/actions/log-api-budget/log-api-budget.test.mjs +++ b/.github/actions/log-api-budget/log-api-budget.test.mjs @@ -177,15 +177,15 @@ test("the action emits each named token, the call count as an api-calls notice t }); assert.equal( out, - "::notice title=api-calls::lanes-app=0 not-modified=0\n" + + "::notice title=api-calls::lanes-app=0 not-modified=0 graphql=0\n" + `${NOTICE}token=github-token ${WHERE} limit=5000 remaining=4959 used=41 reset=1792748042 resource=core cost=28\n` + - "::notice title=api-calls::github-token=2 not-modified=1\n", + "::notice title=api-calls::github-token=2 not-modified=1 graphql=0\n", ); assert.equal( readFileSync(join(dir, "summary"), "utf8"), - "api-calls lanes-app=0 not-modified=0\n" + + "api-calls lanes-app=0 not-modified=0 graphql=0\n" + `api-budget: token=github-token ${WHERE} limit=5000 remaining=4959 used=41 reset=1792748042 resource=core cost=28\n` + - "api-calls github-token=2 not-modified=1\ngithub-token-cost=28\n", + "api-calls github-token=2 not-modified=1 graphql=0\ngithub-token-cost=28\n", ); assert.equal(readFileSync(join(dir, "output"), "utf8"), "costs=github-token-cost=28\n"); assert.doesNotMatch(ACTION + readFileSync(LIB, "utf8"), /gh api[^\n]*rate_limit/); @@ -203,13 +203,13 @@ test("ci-status ends with the same api-budget and api-calls notices the action w assert.equal( out, `${NOTICE}token=github-token ${WHERE} limit=5000 remaining=4959 used=41 reset=1792748042 resource=core cost=28\n` + - "::notice title=api-calls::github-token=2 not-modified=1\n", + "::notice title=api-calls::github-token=2 not-modified=1 graphql=0\n", ); // With no reading, no api-budget line and an api-calls count of 0, as the action writes. rmSync(join(dir, "api-budget"), { recursive: true }); assert.equal( execFileSync("bash", ["-c", block[1]], { encoding: "utf8", env: { PATH: process.env.PATH, RUNNER_TEMP: dir, API_BUDGET_SH: readFileSync(LIB, "utf8") } }), - "::notice title=api-calls::github-token=0 not-modified=0\n", + "::notice title=api-calls::github-token=0 not-modified=0 graphql=0\n", ); })); @@ -372,7 +372,7 @@ test("gh guard: a -i call is counted in api-calls, and a 304 as not-modified", ( 'gh api -i repos/o/r >/dev/null\ngh api -i -H "If-None-Match: x" repos/o/r >/dev/null || true\napi_budget_emit_calls github-token', ); assert.equal(out.calls.length, 2); - assert.equal(out.stdout, "::notice title=api-calls::github-token=1 not-modified=1\n"); + assert.equal(out.stdout, "::notice title=api-calls::github-token=1 not-modified=1 graphql=0\n"); })); // A gh that plays $RUNNER_TEMP/script, one ` ` line each: `o` @@ -429,7 +429,7 @@ test("gh guard: a paginated call of three pages counts three requests, with stdo [...request("https://api.github.com/repos/o/r/labels?per_page=20"), "o 20", ...request(`${LABELS}&page=2`), "o 20", ...request(`${LABELS}&page=3`), "o 9"], "api --paginate repos/o/r/labels --jq length", ); - assert.equal(wrapped.stdout, "20\n20\n9\nrc=0\n::notice title=api-calls::github-token=3 not-modified=0\n"); + assert.equal(wrapped.stdout, "20\n20\n9\nrc=0\n::notice title=api-calls::github-token=3 not-modified=0 graphql=0\n"); assert.equal(bare.stdout, "20\n20\n9\nrc=0\n"); assert.equal(wrapped.stderr, ""); // A page line is a request, never a reading: no api-budget notice. @@ -449,7 +449,7 @@ test("gh guard: a call that is not gh api counts its requests and drops the git ], "run list --limit 1 --json databaseId", ); - assert.equal(wrapped.stdout, "[]\nrc=0\n::notice title=api-calls::github-token=2 not-modified=0\n"); + assert.equal(wrapped.stdout, "[]\nrc=0\n::notice title=api-calls::github-token=2 not-modified=0 graphql=0\n"); assert.equal(wrapped.stderr, bare.stderr); })); @@ -475,7 +475,7 @@ test("gh guard: a failing call keeps gh's stderr byte for byte and its status", ); assert.equal(bare.stderr.endsWith("gh: Not Found (HTTP 404)"), true); assert.equal(wrapped.stderr, bare.stderr); - assert.equal(wrapped.stdout, "rc=1\n::notice title=api-calls::github-token=1 not-modified=0\n"); + assert.equal(wrapped.stdout, "rc=1\n::notice title=api-calls::github-token=1 not-modified=0 graphql=0\n"); })); test("gh guard: a header-read call counts each request once, a 304 still as not-modified, and a download host not at all", () => @@ -483,17 +483,31 @@ test("gh guard: a header-read call counts each request once, a 304 still as not- const head304 = "o HTTP/2.0 304 Not Modified"; // One request, read from its header block: one call, not two. let out = wrappedAndBare(dir, [...request("https://api.github.com/repos/o/r"), head304, "o ", "x 1"], 'api -i -H "If-None-Match: x" repos/o/r').wrapped; - assert.equal(out.stdout.split("\n").at(-2), "::notice title=api-calls::github-token=0 not-modified=1"); + assert.equal(out.stdout.split("\n").at(-2), "::notice title=api-calls::github-token=0 not-modified=1 graphql=0"); // A redirect the header block does not show is a second request. out = wrappedAndBare(dir, [...request("https://api.github.com/repos/o/old"), ...request("https://api.github.com/repositories/1"), "o HTTP/2.0 200 OK", "o ", "o {}"], "api repos/o/old").wrapped; - assert.equal(out.stdout, "{}\nrc=0\n::notice title=api-calls::github-token=2 not-modified=0\n"); + assert.equal(out.stdout, "{}\nrc=0\n::notice title=api-calls::github-token=2 not-modified=0 graphql=0\n"); // A download redirect leaves GitHub's API, so it spends nothing there. out = wrappedAndBare( dir, [...request("https://api.github.com/repos/o/r/actions/runs/1/logs"), ...request("https://results-receiver.actions.githubusercontent.com/x")], "run view 1 --log", ).wrapped; - assert.equal(out.stdout, "rc=0\n::notice title=api-calls::github-token=1 not-modified=0\n"); + assert.equal(out.stdout, "rc=0\n::notice title=api-calls::github-token=1 not-modified=0 graphql=0\n"); + })); + +test("gh guard: a GraphQL request is counted among the requests and again as graphql, from a request line or a reading", () => + withDir((dir) => { + // gh pr view: one GraphQL request, one REST request. + let out = wrappedAndBare(dir, [...request("https://api.github.com/graphql"), ...request("https://api.github.com/repos/o/r/pulls/7"), "o {}"], "pr view 7 --json number").wrapped; + assert.equal(out.stdout, "{}\nrc=0\n::notice title=api-calls::github-token=2 not-modified=0 graphql=1\n"); + // gh api graphql, read from its header block: one reading whose resource is graphql. + out = wrappedAndBare( + dir, + [...request("https://api.github.com/graphql"), "o HTTP/2.0 200 OK", "o X-Ratelimit-Resource: graphql", "o ", "o {}"], + "api graphql -f query=x", + ).wrapped; + assert.equal(out.stdout, "{}\nrc=0\n::notice title=api-calls::github-token=1 not-modified=0 graphql=1\n"); })); test("gh guard: a stop-file line whose reset is not a number stops nothing", () => @@ -759,10 +773,10 @@ test("the action writes the content-creation notice after the token lines", () = TOKENS: "github-token", }, }); - assert.equal(out, `::notice title=api-calls::github-token=0 not-modified=0\n${NOTICE}content-creation ${WHERE} count=1 max-per-minute=1\n`); + assert.equal(out, `::notice title=api-calls::github-token=0 not-modified=0 graphql=0\n${NOTICE}content-creation ${WHERE} count=1 max-per-minute=1\n`); assert.equal( readFileSync(join(dir, "summary"), "utf8"), - `api-calls github-token=0 not-modified=0\napi-budget: content-creation ${WHERE} count=1 max-per-minute=1\n`, + `api-calls github-token=0 not-modified=0 graphql=0\napi-budget: content-creation ${WHERE} count=1 max-per-minute=1\n`, ); })); diff --git a/.github/actions/log-api-budget/rate-guard.mjs b/.github/actions/log-api-budget/rate-guard.mjs index 008e25e2e8..72e43ad547 100644 --- a/.github/actions/log-api-budget/rate-guard.mjs +++ b/.github/actions/log-api-budget/rate-guard.mjs @@ -161,6 +161,41 @@ export function recordReading({ token = process.env.API_BUDGET_TOKEN, dir = proc } } +// The bucket a request's reading goes under: token when the client pins one; +// else API_BUDGET_OVERRIDE_TOKEN for a request whose bearer is not the +// step's own token (GH_TOKEN, else GITHUB_TOKEN), as babysit_gh.py buckets a +// call carrying its own GH_TOKEN; else API_BUDGET_TOKEN. +const bearerOf = (headers) => { + const value = typeof headers?.get === "function" ? headers.get("authorization") : Object.entries(headers ?? {}).find(([name]) => name.toLowerCase() === "authorization")?.[1]; + return String(value ?? "").replace(/^(bearer|token)\s+/i, ""); +}; +export function budgetToken(init, { token, env = process.env } = {}) { + if (token) { + return token; + } + const own = env.GH_TOKEN || env.GITHUB_TOKEN; + const bearer = bearerOf(init?.headers); + return env.API_BUDGET_OVERRIDE_TOKEN && own && bearer && bearer !== own ? env.API_BUDGET_OVERRIDE_TOKEN : env.API_BUDGET_TOKEN; +} + +// A fetch that keeps each answer's rate-limit reading (recordReading) under +// budgetToken's bucket, for the job-end api-calls notice, and changes nothing +// else. Every client factory sends its attempts through one, inside its retry +// layer. A fetch that already counts (marked with countsRequests) comes back +// as it is, so no request is counted twice. +export const COUNTED = Symbol.for("api-budget.counted"); +export const countsRequests = (fetchImpl) => Object.assign(fetchImpl, { [COUNTED]: true }); +export function countRequests(fetchImpl, { token } = {}) { + if (fetchImpl?.[COUNTED]) { + return fetchImpl; + } + return countsRequests(async function counted(url, init = {}) { + const response = await fetchImpl(url, init); + recordReading({ token: budgetToken(init, { token }), status: response.status, headers: response.headers }); + return response; + }); +} + // Appends one content-creating write, as its epoch second, to // $RUNNER_TEMP/api-budget/content-creation, which api-budget.bash's gh wrapper // also appends to, for the job-end content-creation notice. Without @@ -402,19 +437,20 @@ export function createRateLimitLatch({ belowFloor: (url) => low.get(poolOf(url)) === true, // A fetch that checks the latch before each request, counts a // content-creating write, and reads each answer, keeping its rate-limit - // reading under token (API_BUDGET_TOKEN when unset, recordReading), so a + // reading under token (budgetToken when unset, countRequests), so a // second client's readings never mix with the first's. - guard: (fetchImpl, { token } = {}) => - async function guarded(url, init = {}) { + guard: (fetchImpl, { token } = {}) => { + const send = countRequests(fetchImpl, { token }); + return countsRequests(async function guarded(url, init = {}) { check(url, init.method); if (isContentWrite(url, init.method, init.body)) { recordContentWrite({ at: Math.floor(now() / 1000) }); } - const response = await fetchImpl(url, init); - recordReading({ token, status: response.status, headers: response.headers }); + const response = await send(url, init); await observe(url, response, init.method, token); return response; - }, + }); + }, }; } diff --git a/.github/actions/log-api-budget/rate-guard.test.mjs b/.github/actions/log-api-budget/rate-guard.test.mjs index 8325b98bfc..de76a699d2 100644 --- a/.github/actions/log-api-budget/rate-guard.test.mjs +++ b/.github/actions/log-api-budget/rate-guard.test.mjs @@ -452,12 +452,10 @@ const UNGUARDED = { // Package registries and status pages, not the API. ".github/scripts/pr-unblock/dependency-classify.mjs": "package registries", ".github/scripts/maintenance-reconcile-lost-runs/component-status.mjs": "the status pages, not the API", - // Their fetch is budgetedFetch's, which the guard wraps. - ".github/scripts/pr-unblock/dispatch.mjs": "fetch injected from budgetedFetch", - ".github/scripts/pr-unblock/feedback-dispatch.mjs": "fetch injected from budgetedFetch", // Script activities that run from a copy of their own directory, where the // guard module is not: open until the copy carries it. ".github/scripts/apply-patch/apply-patch.mjs": "script activity run from a directory copy", + // Its mains run from the base checkout count their requests (countedFetch). ".github/scripts/pr-unblock/address-feedback.mjs": "imported by script activities run from a directory copy", ".github/scripts/work-resolve-issue/report-outcome.mjs": "script activity run from a directory copy", // pr-merge's own guard is its owner's change. diff --git a/.github/scripts/pr-merge/workflow.test.mjs b/.github/scripts/pr-merge/workflow.test.mjs index 993a2f49e4..dceff8e284 100644 --- a/.github/scripts/pr-merge/workflow.test.mjs +++ b/.github/scripts/pr-merge/workflow.test.mjs @@ -303,7 +303,7 @@ while [ $# -gt 0 ]; do if [ "$1" = --jq ]; then echo '[]' | jq -c "$2"; exit; fi assert.match(readFileSync(join(dir, "output"), "utf8"), /^run-id=4242$/m); assert.equal(readFileSync(join(dir, "api-budget", "github-token"), "utf8"), "200 5000 4000 1000 9999999999 core\n".repeat(2)); const calls = spawnSync("bash", ["-c", ". .base/.github/actions/log-api-budget/api-budget.bash; api_budget_emit_calls github-token"], { cwd: dir, env, encoding: "utf8" }); - assert.equal(calls.stdout, "::notice title=api-calls::github-token=2 not-modified=0\n"); + assert.equal(calls.stdout, "::notice title=api-calls::github-token=2 not-modified=0 graphql=0\n"); } finally { rmSync(dir, { recursive: true, force: true }); } @@ -522,9 +522,10 @@ test("every job that calls GitHub with the job token logs its api-calls", () => // 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", () => { +// the job-end notice counts nothing; one that also holds the lane token names +// lanes-app and the github-token override. 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 names the bucket its calls go to", () => { const ids = [...jobs.matchAll(/\n {2}([a-z-]+):\n/g)].map((m) => m[1]); const unnamed = []; const needless = []; @@ -534,11 +535,21 @@ test("every createGitHub step that calls GitHub with the job token alone names A 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 }}")) { + if (!script || !step.includes("${{ github.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]; + // A step that also holds the lane token as its GH_TOKEN counts that + // token's calls under lanes-app and the job token's under the override + // (rate-guard.mjs budgetToken). + if (step.includes("${{ steps.lane-token.outputs.token }}")) { + const override = /\n {10}API_BUDGET_OVERRIDE_TOKEN: ([a-z-]+)\n/.exec(step)?.[1]; + if (calls && (bucket !== "lanes-app" || override !== "github-token")) { + unnamed.push(`${id}: ${name}`); + } + continue; + } if (calls && (bucket ?? jobBucket) !== "github-token") { unnamed.push(`${id}: ${name}`); } @@ -648,8 +659,8 @@ for (const jobId of ["merge", "enable-auto-merge"]) { 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.match(out.stdout, /^::notice title=api-calls::lanes-app=0 not-modified=0$/m); + assert.match(out.stdout, /^::notice title=api-calls::github-token=3 not-modified=0 graphql=0$/m); + assert.match(out.stdout, /^::notice title=api-calls::lanes-app=0 not-modified=0 graphql=0$/m); } finally { server.close(); rmSync(dir, { recursive: true, force: true }); diff --git a/.github/scripts/pr-unblock/address-feedback.mjs b/.github/scripts/pr-unblock/address-feedback.mjs index d66621094f..9c453ce909 100644 --- a/.github/scripts/pr-unblock/address-feedback.mjs +++ b/.github/scripts/pr-unblock/address-feedback.mjs @@ -746,6 +746,14 @@ const THREADS_QUERY = `query($owner: String!, $name: String!, $pr: Int!, $after: } }`; +// The fetch a main run from the full base checkout hands createGitHubApi: each +// attempt's rate-limit reading goes to the job-end api-calls notice +// (rate-guard.mjs countRequests), and nothing else changes. It loads here, at +// run time: publish-feedback.mjs and apply-dependency-verdict.mjs import this +// module from a copy of this directory alone, which cannot reach .github/actions. +export const countedFetch = async (fetchImpl = globalThis.fetch) => + (await import("../../actions/log-api-budget/rate-guard.mjs")).countRequests(fetchImpl); + // A read, GraphQL queries included, and a write marked safeToResend, is sent // again after a network error or a 5xx (retry-fetch.mjs). Closing a closed // pull request changes nothing; a blob, a tree and a commit are used only by @@ -1047,6 +1055,7 @@ export async function main(argv, env, { api, log = (line) => console.log(line) } repository: env.GITHUB_REPOSITORY, apiUrl: env.GITHUB_API_URL ?? "https://api.github.com", graphqlUrl: env.GITHUB_GRAPHQL_URL ?? "https://api.github.com/graphql", + fetch: await countedFetch(), }); const output = (key, value) => appendFileSync(env.GITHUB_OUTPUT, `${key}=${value}\n`); const selectThreads = () => { diff --git a/.github/scripts/pr-unblock/dispatch.mjs b/.github/scripts/pr-unblock/dispatch.mjs index 69e248d55b..7336a31fa6 100644 --- a/.github/scripts/pr-unblock/dispatch.mjs +++ b/.github/scripts/pr-unblock/dispatch.mjs @@ -37,6 +37,7 @@ import { appendFileSync, readFileSync } from "node:fs"; import { pathToFileURL } from "node:url"; import { loadTrustedList } from "../../actions/check-trusted-trigger/check-trusted-trigger.mjs"; +import { countRequests } from "../../actions/log-api-budget/rate-guard.mjs"; import { testedRun } from "../maintenance-reconcile-lost-runs/infra-failure.mjs"; import { LIMIT_DEFAULTS } from "../work-resolve-issue/limits.mjs"; import { DEPENDABOT_ID, POLICY_PATH } from "./dependency-classify.mjs"; @@ -307,8 +308,10 @@ export function createGitHubApi({ repository, apiUrl, graphqlUrl, - fetch = globalThis.fetch, + fetch: rawFetch = globalThis.fetch, }) { + // Each request's rate-limit reading goes to the job-end api-calls notice. + const fetch = countRequests(rawFetch); const [owner, name] = repository.split("/"); // Connection: close ends each socket after its answer, so no request goes // out on a pooled one that sat idle through dispatchConflicting's waits and diff --git a/.github/scripts/pr-unblock/feedback-dispatch.mjs b/.github/scripts/pr-unblock/feedback-dispatch.mjs index 53e14d7b3b..1bea6534c0 100644 --- a/.github/scripts/pr-unblock/feedback-dispatch.mjs +++ b/.github/scripts/pr-unblock/feedback-dispatch.mjs @@ -112,6 +112,7 @@ import { appendFileSync, readFileSync } from "node:fs"; import { pathToFileURL } from "node:url"; import { commitPulls } from "../../actions/check-trusted-trigger/check-trusted-trigger.mjs"; +import { countRequests } from "../../actions/log-api-budget/rate-guard.mjs"; import { LOOKBACK_HOURS, LOST } from "../maintenance-reconcile-lost-runs/infra-failure.mjs"; import { fixRequesters, humanGatedLabelFrom, onReviewedHead, reviewedHeads, reviewerIdsFrom, threadAuthors, trustedHumansFrom } from "./address-feedback.mjs"; import { commentItems, gateSource, listLabelEvents } from "./gate-source.mjs"; @@ -676,7 +677,9 @@ const THREADS_QUERY = `query($owner: String!, $name: String!, $number: Int!, $af } }`; -export function createGitHubApi({ token, repository, apiUrl, graphqlUrl, fetch = globalThis.fetch, attemptTimeoutMs }) { +export function createGitHubApi({ token, repository, apiUrl, graphqlUrl, fetch: rawFetch = globalThis.fetch, attemptTimeoutMs }) { + // Each request's rate-limit reading goes to the job-end api-calls notice. + const fetch = countRequests(rawFetch); const [owner, name] = repository.split("/"); const repo = `${apiUrl}/repos/${owner}/${name}`; const headers = { diff --git a/.github/scripts/pr-unblock/fix-ci-context.mjs b/.github/scripts/pr-unblock/fix-ci-context.mjs index fb1fa83ab3..cfebee30e4 100644 --- a/.github/scripts/pr-unblock/fix-ci-context.mjs +++ b/.github/scripts/pr-unblock/fix-ci-context.mjs @@ -22,7 +22,7 @@ import { appendFileSync, writeFileSync } from "node:fs"; import { pathToFileURL } from "node:url"; import { timeLimitNoted } from "../maintenance-reconcile-lost-runs/infra-failure.mjs"; -import { createGitHubApi, humanGatedLabelFrom, keepThreads, need, positive, reviewedHeads, reviewerIdFrom, reviewerIdsFrom, SHA } from "./address-feedback.mjs"; +import { countedFetch, createGitHubApi, humanGatedLabelFrom, keepThreads, need, positive, reviewedHeads, reviewerIdFrom, reviewerIdsFrom, SHA } from "./address-feedback.mjs"; import { failingChecks, prHold, reviewReader } from "./gate-source.mjs"; // clear-escalations reads the head the same way, so the reader lives on the @@ -212,6 +212,7 @@ export async function main(env, { api, log = (line) => console.log(line) } = {}) repository: env.GITHUB_REPOSITORY, apiUrl: env.GITHUB_API_URL ?? "https://api.github.com", graphqlUrl: env.GITHUB_GRAPHQL_URL ?? "https://api.github.com/graphql", + fetch: await countedFetch(), }); const humanGatedLabel = humanGatedLabelFrom(env.TRACKER_CONFIG || `${env.BASE_PATH || ".base"}/.work-item-tracker.json`); const result = await select({ api: client, pr, appId, reviewerId, reviewerIds, maxRounds, humanGatedLabel, log }); diff --git a/.github/scripts/pr-unblock/request-budget.mjs b/.github/scripts/pr-unblock/request-budget.mjs index a070ed7ff6..65e1046b43 100644 --- a/.github/scripts/pr-unblock/request-budget.mjs +++ b/.github/scripts/pr-unblock/request-budget.mjs @@ -28,7 +28,7 @@ // answer reaches the above; serverFailure tells a caller it still failed. import { retryServerErrors } from "../../actions/check-trusted-trigger/check-trusted-trigger.mjs"; -import { rateLimitLatch, resetLabel, SIGNALS } from "../../actions/log-api-budget/rate-guard.mjs"; +import { countsRequests, rateLimitLatch, resetLabel, SIGNALS } from "../../actions/log-api-budget/rate-guard.mjs"; export function budgetedFetch({ fetch: rawFetch = globalThis.fetch, @@ -125,7 +125,9 @@ export function budgetedFetch({ return [`api-calls rest=${counts.rest} not-modified=${counts.notModified} graphql=${counts.graphql}`, ...readings].join(" "); } - return { fetch: wrapped, counts, summary }; + // retryServerErrors counts each attempt, so a factory handed this fetch + // counts nothing more (countRequests). + return { fetch: countsRequests(wrapped), counts, summary }; } // Whether a thrown read error is a GitHub 5xx that outlasted retryServerErrors: diff --git a/.github/scripts/pr-unblock/review-dependency.mjs b/.github/scripts/pr-unblock/review-dependency.mjs index e11e713884..545dacdbfd 100644 --- a/.github/scripts/pr-unblock/review-dependency.mjs +++ b/.github/scripts/pr-unblock/review-dependency.mjs @@ -19,7 +19,7 @@ import { appendFileSync, readFileSync, writeFileSync } from "node:fs"; import { pathToFileURL } from "node:url"; -import { createGitHubApi, need, positive, SHA } from "./address-feedback.mjs"; +import { countedFetch, createGitHubApi, need, positive, SHA } from "./address-feedback.mjs"; import { classifierGaps, classify, @@ -322,6 +322,7 @@ export async function main(argv, env, { api, log = (line) => console.log(line), repository: env.GITHUB_REPOSITORY, apiUrl: env.GITHUB_API_URL ?? "https://api.github.com", graphqlUrl: env.GITHUB_GRAPHQL_URL ?? "https://api.github.com/graphql", + fetch: await countedFetch(), }); const config = readConfig(env.DEPENDABOT_CONFIG, log); const result = await select({ diff --git a/.github/scripts/pr-verify/resolve-pr.mjs b/.github/scripts/pr-verify/resolve-pr.mjs index 5df2e578dd..aae03db19f 100644 --- a/.github/scripts/pr-verify/resolve-pr.mjs +++ b/.github/scripts/pr-verify/resolve-pr.mjs @@ -49,6 +49,7 @@ import { graphQLReader, loadTrustedList, } from "../../actions/check-trusted-trigger/check-trusted-trigger.mjs"; +import { countRequests } from "../../actions/log-api-budget/rate-guard.mjs"; import { titleRenamers } from "../../actions/select-trusted-text/select-trusted-text.mjs"; import { issuesSuffix, isVerdictName, NAME } from "./write-verdict.mjs"; import { refsIssue } from "../pr-merge/merge-holds.mjs"; @@ -652,8 +653,11 @@ export async function main(env = process.env, { github, fetchImpl } = {}) { // A job's log: GitHub answers with a redirect to storage, which is read // without the token. const send = fetchImpl ?? fetch; + // The API request is counted for the job-end api-calls notice; the storage + // read it redirects to is not a GitHub API request. + const counted = countRequests(send); const readLog = async (jobId) => { - let response = await send(`${env.GITHUB_API_URL ?? "https://api.github.com"}/repos/${env.GITHUB_REPOSITORY}/actions/jobs/${jobId}/logs`, { + let response = await counted(`${env.GITHUB_API_URL ?? "https://api.github.com"}/repos/${env.GITHUB_REPOSITORY}/actions/jobs/${jobId}/logs`, { headers: { Accept: "application/vnd.github+json", Authorization: `Bearer ${env.GITHUB_TOKEN}`, "X-GitHub-Api-Version": "2022-11-28" }, redirect: "manual", }); diff --git a/.github/scripts/pr-verify/resolve-pr.test.mjs b/.github/scripts/pr-verify/resolve-pr.test.mjs index 5b342fd4bc..d6d2b53132 100644 --- a/.github/scripts/pr-verify/resolve-pr.test.mjs +++ b/.github/scripts/pr-verify/resolve-pr.test.mjs @@ -379,7 +379,7 @@ test("a run that failed on anything but a hold an approval lifts starts nothing" assert.equal(await reason({ run: upstream({ conclusion: "failure" }), jobs: heldJobs() }), "not-a-passing-run"); }); -test("main reads the ci-status log through the redirect, without the token", async () => { +test("main reads the ci-status log through the redirect, without the token, and counts each API request but not the storage read", async () => { const dir = tempDir("resolve-pr-held-"); const env = { GITHUB_TOKEN: "t0ken", @@ -395,7 +395,9 @@ test("main reads the ci-status log through the redirect, without the token", asy writeFileSync(env.GITHUB_OUTPUT, ""); const github = fake({ run: upstream({ conclusion: "failure" }), jobs: heldJobs() }); const storage = []; + let apiRequests = 0; const fetchImpl = async (url, init = {}) => { + apiRequests += url.startsWith("https://api.github.com/") ? 1 : 0; if (url === `https://api.github.com/repos/${REPO}/actions/jobs/9001/logs`) { assert.equal(init.redirect, "manual"); assert.equal(init.headers.Authorization, "Bearer t0ken"); @@ -409,9 +411,22 @@ test("main reads the ci-status log through the redirect, without the token", asy const json = await github(init.method, new URL(url).pathname + new URL(url).search, body); return new Response(JSON.stringify(json), { status: 200, headers: { "Content-Type": "application/json" } }); }; - await main(env, { fetchImpl }); + const saved = { RUNNER_TEMP: process.env.RUNNER_TEMP, API_BUDGET_TOKEN: process.env.API_BUDGET_TOKEN }; + Object.assign(process.env, { RUNNER_TEMP: dir, API_BUDGET_TOKEN: "github-token" }); + try { + await main(env, { fetchImpl }); + } finally { + for (const [key, value] of Object.entries(saved)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } assert.match(readFileSync(env.GITHUB_OUTPUT, "utf8"), /^proceed=true\nreason=ok\npr-number=41\n/); assert.deepEqual(storage, [{}]); + // One reading per GitHub API request, the log's 302 among them; none for storage. + const readings = readFileSync(path.join(dir, "api-budget", "github-token"), "utf8").trim().split("\n"); + assert.equal(readings.length, apiRequests); + assert.equal(readings.filter((line) => line.startsWith("302 ")).length, 1); }); test("anything but exactly one open same-repository PR into main at that head starts nothing", async () => { diff --git a/.github/workflows/pr-merge.yml b/.github/workflows/pr-merge.yml index 1812c4d6fa..4a7009a556 100644 --- a/.github/workflows/pr-merge.yml +++ b/.github/workflows/pr-merge.yml @@ -1726,9 +1726,11 @@ jobs: env: LANES_APP_SENDER_ID: ${{ vars.AUTOMATION_LANES_APP_SENDER_ID }} GH_TOKEN: ${{ steps.lane-token.outputs.token }} + API_BUDGET_TOKEN: lanes-app # The job's own read-only token lists runs, jobs and annotations; # the lane token holds no Actions or Checks permission. ACTIONS_TOKEN: ${{ github.token }} + API_BUDGET_OVERRIDE_TOKEN: github-token KILL_SWITCH: ${{ steps.kill.outputs.proceed == 'true' && 'proceed' || '' }} TRUSTED_ACTORS_PATH: .base/.github/standards/trusted-actors/trusted-actors.json run: node .base/.github/scripts/pr-merge/refire-refused.mjs diff --git a/.github/workflows/pr-require-checks.yml b/.github/workflows/pr-require-checks.yml index 1e0215e870..02782f4aeb 100644 --- a/.github/workflows/pr-require-checks.yml +++ b/.github/workflows/pr-require-checks.yml @@ -3302,7 +3302,8 @@ jobs: # # (`na` for a missing value). api-budget.mjs and babysit_gh.py append the same # lines. A request the gh wrapper counts with no header block to read (each - # page of --paginate, a call that is not `gh api`) is a line `request`. Never + # page of --paginate, a call that is not `gh api`) is a line `request`, or + # `request graphql` for a GraphQL request. Never # fails the caller, except that a stop the rate guard below cannot record ends # the step. # @@ -3371,9 +3372,11 @@ jobs: # writes: count and most in any 60 s # api_budget_emit_calls ... # the hub's api-calls notice for each token, - # `=N not-modified=M`: N is the job's own - # requests, M its 304 answers, which GitHub does - # not count; 0 for a token with no reading + # `=N not-modified=M graphql=K`: N is the + # job's own requests, M its 304 answers, which + # GitHub does not count, and K the GraphQL ones + # among N, which spend their own pool; 0 for a + # token with no reading # # pr-require-checks.yml's ci-status job carries this file verbatim in its # API_BUDGET_SH env (it has no checkout of this directory); @@ -3505,7 +3508,7 @@ jobs: /^\* Request at / { open++; next } /^\* Request took / { if (open) open--; next } /^\* Request to / { - if ($4 ~ /^https:\/\/((api|uploads)\.github\.com|[^\/]+\/api)\// && ++n > skip) print "request" >>state + if ($4 ~ /^https:\/\/((api|uploads)\.github\.com|[^\/]+\/api)\// && ++n > skip) print "request" ($4 ~ /\/graphql([?]|$)/ ? " graphql" : "") >>state next } /^\* (Using proxy|Skipping TLS verification): / || (open && /^\* /) || /^\[git[] ]/ { next } @@ -3722,7 +3725,7 @@ jobs: file=${RUNNER_TEMP:-}/api-budget/$token [[ -n ${RUNNER_TEMP:-} && $token =~ ^[a-z][a-z0-9-]{0,39}$ ]] || continue [[ -s $file ]] || file=/dev/null - awk '$1 != 304 { n++ } $1 == 304 { m++ } END { printf "::notice title=api-calls::%s=%d not-modified=%d\n", token, n, m }' token="$token" "$file" + awk '$1 != 304 { n++ } $1 == 304 { m++ } $6 == "graphql" || $2 == "graphql" { g++ } END { printf "::notice title=api-calls::%s=%d not-modified=%d graphql=%d\n", token, n, m, g }' token="$token" "$file" done return 0 } @@ -4957,6 +4960,7 @@ jobs: timeout-minutes: 2 env: ACTIONS_TOKEN: ${{ github.token }} + API_BUDGET_TOKEN: github-token CARRY_TO: ${{ runner.temp }}/pr-merge-carried.json PR_MERGE_READS_PATH: ${{ runner.temp }}/pr-merge-reads.json run: node .base/.github/scripts/pr-merge/carry-run-cache.mjs read diff --git a/.github/workflows/work-unblock.yml b/.github/workflows/work-unblock.yml index 6cb0b1f232..dae0eb9e9d 100644 --- a/.github/workflows/work-unblock.yml +++ b/.github/workflows/work-unblock.yml @@ -181,6 +181,7 @@ jobs: ACTOR_ID: ${{ github.actor_id }} # The job's own read-only token, to read this run's record. ACTIONS_TOKEN: ${{ github.token }} + API_BUDGET_TOKEN: github-token TRUSTED_ACTORS_PATH: .base/.github/standards/trusted-actors/trusted-actors.json run: node .base/.github/scripts/work-unblock/label-unblocked.mjs --check-closer