workflows/labels: add bottleneck for throttling (#417512)

This commit is contained in:
Wolfgang Walther
2025-06-17 13:56:17 +00:00
committed by GitHub
+56 -12
View File
@@ -39,7 +39,12 @@ jobs:
if: github.event_name != 'schedule' || github.repository_owner == 'NixOS'
steps:
- name: Install dependencies
run: npm install @actions/artifact
run: npm install @actions/artifact bottleneck
- name: Log current API rate limits
env:
GH_TOKEN: ${{ github.token }}
run: gh api /rate_limit | jq
- name: Labels from API data and Eval results
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
@@ -47,12 +52,41 @@ jobs:
UPDATED_WITHIN: ${{ inputs.updatedWithin }}
with:
script: |
const Bottleneck = require('bottleneck')
const path = require('node:path')
const { DefaultArtifactClient } = require('@actions/artifact')
const { readFile } = require('node:fs/promises')
const artifactClient = new DefaultArtifactClient()
const stats = {
prs: 0,
requests: 0,
artifacts: 0
}
// Rate-Limiting and Throttling, see for details:
// https://github.com/octokit/octokit.js/issues/1069#throttling
// https://docs.github.com/en/rest/using-the-rest-api/best-practices-for-using-the-rest-api
const allLimits = new Bottleneck({
// Avoid concurrent requests
maxConcurrent: 1,
// Hourly limit is at 5000, but other jobs need some, too!
// https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api
reservoir: 1000,
reservoirRefreshAmount: 1000,
reservoirRefreshInterval: 60 * 60 * 1000
})
// Pause between mutative requests
const writeLimits = new Bottleneck({ minTime: 1000 }).chain(allLimits)
github.hook.wrap('request', async (request, options) => {
stats.requests++
if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(options.method))
return writeLimits.schedule(request.bind(null, options))
else
return allLimits.schedule(request.bind(null, options))
})
if (process.env.UPDATED_WITHIN && !/^\d+$/.test(process.env.UPDATED_WITHIN))
throw new Error('Please enter "updated within" as integer in hours.')
@@ -90,7 +124,7 @@ jobs:
base: context.payload.pull_request.base.ref
}
await github.paginate(
const prs = await github.paginate(
github.rest.pulls.list,
{
...context.repo,
@@ -99,12 +133,16 @@ jobs:
direction: 'desc',
...prEventCondition
},
async (response, done) => (await Promise.allSettled(response.data.map(async (pull_request) => {
(response, done) => response.data.map(async (pull_request) => {
try {
const log = (k,v) => core.info(`PR #${pull_request.number} - ${k}: ${v}`)
const log = (k,v,skip) => {
core.info(`PR #${pull_request.number} - ${k}: ${v}` + (skip ? ' (skipped)' : ''))
return skip
}
log('Last updated at', pull_request.updated_at)
if (new Date(pull_request.updated_at) < cutoff) return done()
if (log('Last updated at', pull_request.updated_at, new Date(pull_request.updated_at) < cutoff))
return done()
stats.prs++
log('URL', pull_request.html_url)
const run_id = (await github.rest.actions.listWorkflowRuns({
@@ -119,8 +157,8 @@ jobs:
// Newer PRs might not have run Eval to completion, yet. We can skip them, because this
// job will be run as part of that Eval run anyway.
log('Last eval run', run_id ?? '<pending>')
if (!run_id) return;
if (log('Last eval run', run_id ?? '<pending>', !run_id))
return;
const artifact = (await github.rest.actions.listWorkflowRunArtifacts({
...context.repo,
@@ -132,8 +170,10 @@ jobs:
// actually download the artifact in the next step and avoid that race condition.
// Older PRs, where the workflow run was already eval.yml, but the artifact was not
// called "comparison", yet, will be skipped as well.
log('Artifact expires at', artifact?.expires_at ?? '<not found>')
if (new Date(artifact?.expires_at ?? 0) < new Date(new Date().getTime() + 60 * 1000)) return;
const expired = new Date(artifact?.expires_at ?? 0) < new Date(new Date().getTime() + 60 * 1000)
if (log('Artifact expires at', artifact?.expires_at ?? '<not found>', expired))
return;
stats.artifacts++
await artifactClient.downloadArtifact(artifact.id, {
findBy: {
@@ -195,10 +235,14 @@ jobs:
} catch (cause) {
throw new Error(`Labeling PR #${pull_request.number} failed.`, { cause })
}
})))
})
);
(await Promise.allSettled(prs.flat()))
.filter(({ status }) => status == 'rejected')
.map(({ reason }) => core.setFailed(`${reason.message}\n${reason.cause.stack}`))
)
core.notice(`Processed ${stats.prs} PRs, made ${stats.requests + stats.artifacts} API requests and downloaded ${stats.artifacts} artifacts.`)
- uses: actions/labeler@8558fd74291d67161a8a78ce36a881fa63b766a9 # v5.0.0
name: Labels from touched files