diff --git a/.github/actions/log-api-budget/action.yml b/.github/actions/log-api-budget/action.yml index 303fb80e546..d0863463f1a 100644 --- a/.github/actions/log-api-budget/action.yml +++ b/.github/actions/log-api-budget/action.yml @@ -43,13 +43,10 @@ runs: printf '%s\n' "$lines" sed 's/^::notice title=api-budget::/api-budget: /' <<<"$lines" >>"$GITHUB_STEP_SUMMARY" # This job's own calls, one reading each: unlike cost, no other job's. - # A notice, as the hub reads annotations, not the log; its message - # escaped as workflow commands require. - calls=$(awk '$1 != 304 { n++ } $1 == 304 { m++ } END { printf "%s=%d not-modified=%d", token, n, m }' token="$name" "$RUNNER_TEMP/api-budget/$name") - notice=${calls//%/%25} - notice=${notice//$'\r'/%0D} - echo "::notice title=api-calls::${notice//$'\n'/%0A}" - echo "api-calls $calls" >>"$GITHUB_STEP_SUMMARY" + # A notice, as the hub reads annotations, not the log. + calls=$(api_budget_emit_calls "$name") + printf '%s\n' "$calls" + echo "api-calls ${calls#*title=api-calls::}" >>"$GITHUB_STEP_SUMMARY" cost=${lines##*cost=} costs+=("$name-cost=${cost/na/unknown}") done diff --git a/.github/actions/log-api-budget/api-budget.bash b/.github/actions/log-api-budget/api-budget.bash index f777f289a70..776124bdad7 100644 --- a/.github/actions/log-api-budget/api-budget.bash +++ b/.github/actions/log-api-budget/api-budget.bash @@ -70,6 +70,11 @@ # with the same reset and resource, else na # api_budget_emit_content the notice for the job's content-creating # writes: count and most in any 60 s +# api_budget_emit_calls ... +# the hub's api-calls notice for each token with +# a reading, `=N not-modified=M`: N is +# the job's own calls, M its 304 answers, which +# GitHub does not count # # 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); @@ -367,6 +372,16 @@ api_budget_emit_content() { return 0 } +api_budget_emit_calls() { + local token file + for token; do + file=${RUNNER_TEMP:-}/api-budget/$token + [[ -n ${RUNNER_TEMP:-} && $token =~ ^[a-z][a-z0-9-]{0,39}$ && -s $file ]] || continue + 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" + done + return 0 +} + api_budget_emit() { local token file where workflow=${GITHUB_WORKFLOW_REF:-} workflow=${workflow%%@*} 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 489ec2f6ac5..052d03be5a2 100644 --- a/.github/actions/log-api-budget/log-api-budget.test.mjs +++ b/.github/actions/log-api-budget/log-api-budget.test.mjs @@ -615,6 +615,8 @@ test("each heavy lane job names its API_BUDGET_TOKEN and ends with the log-api-b // label-unblocked's ACTIONS_TOKEN client records under github-token, so // that job logs both: [API_BUDGET_TOKEN, the tokens it logs]. "work-unblock.yml": { "wip-state": "github-token", "label-unblocked": ["lanes-app", "lanes-app github-token"] }, + "pr-merge.yml": { "post-merge-dispatch": "github-token" }, + "intake-reenter.yml": { reenter: "github-token" }, }; for (const [file, tokens] of Object.entries(jobs)) { const text = readFileSync(new URL(`../../workflows/${file}`, import.meta.url), "utf8"); @@ -672,6 +674,10 @@ test("ci-status carries api-budget.bash verbatim in its API_BUDGET_SH env", () = assert.equal(carried, readFileSync(LIB, "utf8")); }); +test("ci-status emits the api-calls notice beside its api-budget notice", () => { + assert.match(CI, /api_budget_emit github-token; api_budget_emit_calls github-token; /); +}); + // pr-feedback-dispatch.yml's post-verify-notes and rerun-ci-status jobs post // no other notice, so without these lines their calls go unattributed. // post-verify-notes' notice covers only its artifact listing, the one call diff --git a/.github/scripts/pr-merge/merge-job.test.mjs b/.github/scripts/pr-merge/merge-job.test.mjs index cf337dcfa3e..2b84cc098f1 100644 --- a/.github/scripts/pr-merge/merge-job.test.mjs +++ b/.github/scripts/pr-merge/merge-job.test.mjs @@ -61,6 +61,7 @@ test("the re-dispatch for earlier lane merges waits on neither the listing nor t "Record the re-run", "Send a red full run to the demotion alert", "Record the alert sent", + "Log the API budget at the end", ], ); assert.doesNotMatch(early, /steps\.candidates|steps\.gate|needs\.merge/); @@ -1896,7 +1897,11 @@ test("the alert send runs after the re-run while the lane is live, and stops aft /\n {8}if: >-\n {10}!cancelled\(\)\n {10}&& steps\.kill\.outputs\.proceed == 'true'\n {10}&& steps\.mode\.outputs\.live == 'true'\n {10}&& steps\.post-merge\.outputs\.code != '75'\n {10}&& steps\.cover\.outputs\.rate-limited != 'true'\n {10}&& steps\.dispatch\.outputs\.rate-limited != 'true'\n {10}&& steps\.rerun\.outputs\.rate-limited != 'true'\n/, ); const names = list.map((s) => s.name); - assert.deepEqual(names.slice(-2), ["Send a red full run to the demotion alert", "Record the alert sent"]); + assert.deepEqual(names.slice(-3), [ + "Send a red full run to the demotion alert", + "Record the alert sent", + "Log the API budget at the end", + ]); }); // The scheduled job's cover read, run with .base at this checkout (the rate diff --git a/.github/workflows/intake-reenter.yml b/.github/workflows/intake-reenter.yml index 559d732fb24..f5327894cf8 100644 --- a/.github/workflows/intake-reenter.yml +++ b/.github/workflows/intake-reenter.yml @@ -76,6 +76,10 @@ jobs: concurrency: group: intake-reenter-${{ github.event.issue.number }} queue: max + # Each guarded API call's rate-limit reading, for the job-end api-budget + # notice (.github/actions/log-api-budget). + env: + API_BUDGET_TOKEN: github-token steps: - name: Check out the default branch tip uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -114,3 +118,11 @@ jobs: TRUSTED_ACTORS_PATH: .base/.github/standards/trusted-actors/trusted-actors.json KILL_SWITCH: proceed run: node .base/.github/scripts/intake-reenter/reenter.mjs + + - name: Log the API budget at the end + if: always() + continue-on-error: true + timeout-minutes: 1 + uses: ./.base/.github/actions/log-api-budget # zizmor: ignore[self-repository] + with: + tokens: github-token diff --git a/.github/workflows/pr-merge.yml b/.github/workflows/pr-merge.yml index 0f37fcaf66b..7bbbaa05a8f 100644 --- a/.github/workflows/pr-merge.yml +++ b/.github/workflows/pr-merge.yml @@ -133,6 +133,11 @@ jobs: # The pool and reset a rate limit stopped this job's GITHUB_TOKEN calls on, # if one did: the merge and enable-auto-merge jobs then send nothing. rate-limited: ${{ steps.post-merge.outputs.rate-limited || steps.cover.outputs.stop || steps.dispatch.outputs.stop || steps.rerun.outputs.stop || steps.alert.outputs.stop }} + # Each guarded API call's rate-limit reading, for the job-end api-budget + # notice (.github/actions/log-api-budget). The full-run dispatch's own + # `gh api -i` POST leaves none. + env: + API_BUDGET_TOKEN: github-token steps: - name: Check out the default branch tip uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -695,6 +700,14 @@ jobs: retention-days: 7 if-no-files-found: error + - name: Log the API budget at the end + if: always() + continue-on-error: true + timeout-minutes: 1 + uses: ./.base/.github/actions/log-api-budget # zizmor: ignore[self-repository] + with: + tokens: github-token + merge: # On the schedule and a hand-started run it waits for post-merge-dispatch # and reuses its report (the post-merge check below). Where that job is diff --git a/.github/workflows/pr-require-checks.yml b/.github/workflows/pr-require-checks.yml index 7f7c2382988..570c8009121 100644 --- a/.github/workflows/pr-require-checks.yml +++ b/.github/workflows/pr-require-checks.yml @@ -3359,6 +3359,11 @@ jobs: # with the same reset and resource, else na # api_budget_emit_content the notice for the job's content-creating # writes: count and most in any 60 s + # api_budget_emit_calls ... + # the hub's api-calls notice for each token with + # a reading, `=N not-modified=M`: N is + # the job's own calls, M its 304 answers, which + # GitHub does not count # # 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); @@ -3656,6 +3661,16 @@ jobs: return 0 } + api_budget_emit_calls() { + local token file + for token; do + file=${RUNNER_TEMP:-}/api-budget/$token + [[ -n ${RUNNER_TEMP:-} && $token =~ ^[a-z][a-z0-9-]{0,39}$ && -s $file ]] || continue + 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" + done + return 0 + } + api_budget_emit() { local token file where workflow=${GITHUB_WORKFLOW_REF:-} workflow=${workflow%%@*} @@ -5249,7 +5264,7 @@ jobs: env: LANES: ${{ toJSON(needs) }} run: | - [ -z "${API_BUDGET_SH:-}" ] || { eval "$API_BUDGET_SH"; api_budget_emit github-token; api_budget_emit_content; } + [ -z "${API_BUDGET_SH:-}" ] || { eval "$API_BUDGET_SH"; api_budget_emit github-token; api_budget_emit_calls github-token; api_budget_emit_content; } results=$(jq -r ' .["select-tests"] as $s | to_entries