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 4a1a3388c09..9a9829e28d8 100644 --- a/.github/actions/log-api-budget/log-api-budget.test.mjs +++ b/.github/actions/log-api-budget/log-api-budget.test.mjs @@ -797,6 +797,7 @@ test("each heavy lane job names its API_BUDGET_TOKEN and ends with the log-api-b "pr-feedback-dispatch.yml": { "post-verify-notes": "github-token", dispatch: "github-token", "rerun-ci-status": "github-token" }, "post-merge-test-mutation.yml": { report: "github-token" }, "pr-refine.yml": { "fix-docs-head": "github-token" }, + "post-merge-demotion-alert.yml": { alert: "github-token", "report-red-main": "github-token" }, }; for (const [file, tokens] of Object.entries(jobs)) { const text = readFileSync(new URL(`../../workflows/${file}`, import.meta.url), "utf8"); @@ -815,6 +816,23 @@ test("each heavy lane job names its API_BUDGET_TOKEN and ends with the log-api-b } }); +// A job's shell steps call gh through api-budget.bash's wrapper, so the +// job-end step has their readings to count; a step that sends gh before it +// wraps would leave those calls out. +test("each shell step in the demotion alert job that calls gh wraps it first", () => { + const text = readFileSync(new URL("../../workflows/post-merge-demotion-alert.yml", import.meta.url), "utf8"); + const block = /\n alert:\n([\s\S]*?)(?=\n [a-z0-9-]+:\n|$)/.exec(text)?.[1] ?? ""; + const wrap = "{ . .base/.github/actions/log-api-budget/api-budget.bash 2>/dev/null && api_budget_wrap_gh github-token; } || true"; + const calling = block.split(/\n {6}- name: /).filter((step) => /^ {10}[^#\n]*\bgh api\b/m.test(step)); + assert.ok(calling.length >= 2, "the alert job's gh-calling steps are found"); + for (const step of calling) { + const run = step.indexOf(" run: |\n"); + assert.ok(run > 0, `${step.split("\n")[0]} is a run step`); + const at = step.indexOf(wrap); + assert.ok(at > run && at < step.search(/^ {10}[^#\n]*\bgh api\b/m), `${step.split("\n")[0]} wraps gh before its first call`); + } +}); + // pr-review-main's record job has no checkout of the action, and its only // calls are one check-run POST per verdict file, so it counts them itself and // ends with the notice in api_budget_emit_calls' shape. @@ -847,6 +865,7 @@ test("each job that holds the job token and calls no guarded client ends with th "pr-test-windows.yml": Object.fromEntries(windows.map((job) => [job, "./"])), "post-merge-detect-silent-revert.yml": { "detect-silent-revert": "./" }, "release-publish-plugins.yml": { merge: "./", build: "./" }, + "dependabot-fragments.yml": { write: "./" }, }; for (const [file, byJob] of Object.entries(jobs)) { const text = readFileSync(new URL(`../../workflows/${file}`, import.meta.url), "utf8"); diff --git a/.github/workflows/dependabot-fragments.yml b/.github/workflows/dependabot-fragments.yml index d4c0656ce1b..dd6ecf6523e 100644 --- a/.github/workflows/dependabot-fragments.yml +++ b/.github/workflows/dependabot-fragments.yml @@ -145,3 +145,12 @@ jobs: gh pr edit "$number" --repo "$GITHUB_REPOSITORY" --body-file "$body" fi gh pr merge "$number" --repo "$GITHUB_REPOSITORY" --auto --squash + # The steps above call GitHub on the release App's token, not this one, + # so the line reads 0 unless a step starts spending the job token. + - name: Log the API budget at the end + if: always() + continue-on-error: true + timeout-minutes: 1 + uses: ./.github/actions/log-api-budget # zizmor: ignore[self-repository] + with: + tokens: github-token diff --git a/.github/workflows/post-merge-demotion-alert.yml b/.github/workflows/post-merge-demotion-alert.yml index 0d1119fa2ce..d3e1731ea7b 100644 --- a/.github/workflows/post-merge-demotion-alert.yml +++ b/.github/workflows/post-merge-demotion-alert.yml @@ -142,6 +142,11 @@ jobs: # 'true' when every newly failing suite was outside the merges' # affected set or quarantined: no record, and no red-main report. excused: ${{ steps.file.outputs.excused }} + # Each guarded API call's rate-limit reading (the gh wrapper below and + # babysit_gh.py under the judge), for the job-end api-budget notice + # (.github/actions/log-api-budget). + env: + API_BUDGET_TOKEN: github-token steps: # main's tip, for babysit_postmerge.py --judge-run and the plan script it # runs in a worktree of the last green SHA; full history for that worktree. @@ -185,6 +190,10 @@ jobs: RUN_TRIGGERING_ACTOR: ${{ github.event.workflow_run.triggering_actor.login }} RUN_COMMIT_MESSAGE: ${{ github.event.workflow_run.head_commit.message }} run: | + # Each `gh api` call keeps its rate-limit reading for the job-end + # api-budget notice (.github/actions/log-api-budget). + # shellcheck source=/dev/null + { . .base/.github/actions/log-api-budget/api-budget.bash 2>/dev/null && api_budget_wrap_gh github-token; } || true if [[ "$EVENT_NAME" == workflow_dispatch ]]; then if [[ ! "$INPUT_RUN_ID" =~ ^[1-9][0-9]{0,14}$ ]]; then echo "::error::run-id must be a run id (digits only)" @@ -278,6 +287,10 @@ jobs: RUN_TRIGGERING_ACTOR: ${{ steps.run.outputs.triggering-actor }} RUN_COMMIT_MESSAGE: ${{ steps.run.outputs.commit-message }} run: | + # Each `gh api` call keeps its rate-limit reading for the job-end + # api-budget notice (.github/actions/log-api-budget). + # shellcheck source=/dev/null + { . .base/.github/actions/log-api-budget/api-budget.bash 2>/dev/null && api_budget_wrap_gh github-token; } || true case "$RUN_WORKFLOW:$RUN_EVENT" in pr-require-checks:schedule) signal=schedule ;; pr-require-checks:workflow_dispatch) signal=dispatch ;; @@ -868,6 +881,14 @@ jobs: exit 1 fi + - 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 + # The red-main report. It runs after the alert job, so a re-run the alert # started is already under way, and only for a full run on main that # concluded failure: a cancelled, timed-out or not-started run, and the diff --git a/scripts/post-merge-demotion-alert.test.sh b/scripts/post-merge-demotion-alert.test.sh index a84d5972f7f..642d957de63 100755 --- a/scripts/post-merge-demotion-alert.test.sh +++ b/scripts/post-merge-demotion-alert.test.sh @@ -62,12 +62,14 @@ if [[ -n "$alert_job" ]] && grep -q '^permissions: {}$' "$WORKFLOW" && else fail "the alert job's permissions are not exactly issues: write, actions: write, checks: read, contents: read, pull-requests: read" fi -uses="$(grep -E '^[[:space:]]*-?[[:space:]]*uses:' <<<"$alert_job" | sed -E 's/^[[:space:]]*-?[[:space:]]*uses:[[:space:]]*//; s/@.*//')" +# Beside the checkout, only the job-end log-api-budget action, run from that +# checkout of main's tip. +uses="$(grep -E '^[[:space:]]*-?[[:space:]]*uses:' <<<"$alert_job" | sed -E 's/^[[:space:]]*-?[[:space:]]*uses:[[:space:]]*//; s/@.*//; s/[[:space:]]+#.*//')" checkout="$(awk '/- name: Check out the default branch tip/ { on = 1 } on && /^ - name: Read/ { exit } on' <<<"$alert_job")" -if [[ "$uses" == actions/checkout && "$checkout" == *"ref: refs/heads/main"* && "$checkout" == *"persist-credentials: false"* ]]; then - ok "the alert job's one action checks out main's tip without credentials" +if [[ "$uses" == $'actions/checkout\n./.base/.github/actions/log-api-budget' && "$checkout" == *"ref: refs/heads/main"* && "$checkout" == *"persist-credentials: false"* ]]; then + ok "the alert job checks out main's tip without credentials and runs no other action but that checkout's log-api-budget" else - fail "the alert job runs an action other than one credential-less checkout of main: '$uses'" + fail "the alert job runs an action other than one credential-less checkout of main and its log-api-budget: '$uses'" fi if grep -qF "\${{" "$TMP_ROOT/body.sh"; then fail "the step body interpolates an expression; pass it through env"