ci/github-script: move from ci/labels; allow single PR testing and non-dry mode (#424872)

This commit is contained in:
Wolfgang Walther 2025-07-15 12:56:51 +00:00 committed by GitHub
commit 13855a517b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 171 additions and 123 deletions

View File

@ -43,7 +43,7 @@ jobs:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
sparse-checkout: |
ci/labels
ci/github-script
- name: Install dependencies
run: npm install @actions/artifact bottleneck
@ -69,7 +69,7 @@ jobs:
github-token: ${{ steps.app-token.outputs.token || github.token }}
retries: 3
script: |
require('./ci/labels/labels.cjs')({
require('./ci/github-script/labels.js')({
github,
context,
core,

View File

@ -0,0 +1,3 @@
[run]
indent_style = space
indent_size = 2

View File

@ -0,0 +1,13 @@
# GitHub specific CI scripts
This folder contains [`actions/github-script`](https://github.com/actions/github-script)-based JavaScript code.
It provides a `nix-shell` environment to run and test these actions locally.
To run any of the scripts locally:
- Enter `nix-shell` in `./ci/github-script`.
- Ensure `gh` is authenticated.
## Labeler
Run `./run labels OWNER REPO`, where OWNER is your username or "NixOS" and REPO the name of your fork or "nixpkgs".

View File

@ -1,61 +1,12 @@
module.exports = async function ({ github, context, core, dry }) {
const Bottleneck = require('bottleneck')
const path = require('node:path')
const { DefaultArtifactClient } = require('@actions/artifact')
const { readFile, writeFile } = require('node:fs/promises')
const withRateLimit = require('./withRateLimit.js')
const artifactClient = new DefaultArtifactClient()
const stats = {
issues: 0,
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,
// Will be updated with first `updateReservoir()` call below.
reservoir: 0,
})
// Pause between mutative requests
const writeLimits = new Bottleneck({ minTime: 1000 }).chain(allLimits)
github.hook.wrap('request', async (request, options) => {
// Requests to the /rate_limit endpoint do not count against the rate limit.
if (options.url == '/rate_limit') return request(options)
// Search requests are in a different resource group, which allows 30 requests / minute.
// We do less than a handful each run, so not implementing throttling for now.
if (options.url.startsWith('/search/')) return 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))
})
async function updateReservoir() {
let response
try {
response = await github.rest.rateLimit.get()
} catch (err) {
core.error(`Failed updating reservoir:\n${err}`)
// Keep retrying on failed rate limit requests instead of exiting the script early.
return
}
// Always keep 1000 spare requests for other jobs to do their regular duty.
// They normally use below 100, so 1000 is *plenty* of room to work with.
const reservoir = Math.max(0, response.data.resources.core.remaining - 1000)
core.info(`Updating reservoir to: ${reservoir}`)
allLimits.updateSettings({ reservoir })
}
await updateReservoir()
// Update remaining requests every minute to account for other jobs running in parallel.
const reservoirUpdater = setInterval(updateReservoir, 60 * 1000)
async function handlePullRequest(item) {
async function handlePullRequest({ item, stats }) {
const log = (k, v) => core.info(`PR #${item.number} - ${k}: ${v}`)
const pull_number = item.number
@ -221,7 +172,7 @@ module.exports = async function ({ github, context, core, dry }) {
return prLabels
}
async function handle(item) {
async function handle({ item, stats }) {
try {
const log = (k, v, skip) => {
core.info(`#${item.number} - ${k}: ${v}` + (skip ? ' (skipped)' : ''))
@ -237,7 +188,7 @@ module.exports = async function ({ github, context, core, dry }) {
if (item.pull_request || context.payload.pull_request) {
stats.prs++
Object.assign(itemLabels, await handlePullRequest(item))
Object.assign(itemLabels, await handlePullRequest({ item, stats }))
} else {
stats.issues++
}
@ -326,9 +277,9 @@ module.exports = async function ({ github, context, core, dry }) {
}
}
try {
await withRateLimit({ github, core }, async (stats) => {
if (context.payload.pull_request) {
await handle(context.payload.pull_request)
await handle({ item: context.payload.pull_request, stats })
} else {
const lastRun = (
await github.rest.actions.listWorkflowRuns({
@ -447,17 +398,11 @@ module.exports = async function ({ github, context, core, dry }) {
arr.findIndex((firstItem) => firstItem.number == thisItem.number),
)
;(await Promise.allSettled(items.map(handle)))
;(await Promise.allSettled(items.map((item) => handle({ item, stats }))))
.filter(({ status }) => status == 'rejected')
.map(({ reason }) =>
core.setFailed(`${reason.message}\n${reason.cause.stack}`),
)
core.notice(
`Processed ${stats.prs} PRs, ${stats.issues} Issues, made ${stats.requests + stats.artifacts} API requests and downloaded ${stats.artifacts} artifacts.`,
)
}
} finally {
clearInterval(reservoirUpdater)
}
})
}

View File

@ -1,5 +1,5 @@
{
"name": "labels",
"name": "github-script",
"lockfileVersion": 3,
"requires": true,
"packages": {
@ -7,7 +7,8 @@
"dependencies": {
"@actions/artifact": "2.3.2",
"@actions/github": "6.0.1",
"bottleneck": "2.19.5"
"bottleneck": "2.19.5",
"commander": "14.0.0"
}
},
"node_modules/@actions/artifact": {
@ -950,6 +951,15 @@
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"license": "MIT"
},
"node_modules/commander": {
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-14.0.0.tgz",
"integrity": "sha512-2uM9rYjPvyq39NwLRqaiLtWHyDC1FvryJDa2ATTVims5YAS4PupsEQsDvP14FqhFr0P49CYDugi59xaxJlTXRA==",
"license": "MIT",
"engines": {
"node": ">=20"
}
},
"node_modules/compress-commons": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-6.0.2.tgz",

View File

@ -1,9 +1,9 @@
{
"private": true,
"type": "module",
"dependencies": {
"@actions/artifact": "2.3.2",
"@actions/github": "6.0.1",
"bottleneck": "2.19.5"
"bottleneck": "2.19.5",
"commander": "14.0.0"
}
}

67
ci/github-script/run Executable file
View File

@ -0,0 +1,67 @@
#!/usr/bin/env -S node --import ./run
import { execSync } from 'node:child_process'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { program } from 'commander'
import { getOctokit } from '@actions/github'
async function run(action, owner, repo, pull_number, dry) {
const token = execSync('gh auth token', { encoding: 'utf-8' }).trim()
const github = getOctokit(token)
const payload = !pull_number ? {} : {
pull_request: (await github.rest.pulls.get({
owner,
repo,
pull_number,
})).data
}
const tmp = mkdtempSync(join(tmpdir(), 'github-script-'))
try {
process.env.GITHUB_WORKSPACE = tmp
process.chdir(tmp)
await action({
github,
context: {
payload,
repo: {
owner,
repo,
},
},
core: {
getInput() {
return token
},
error: console.error,
info: console.log,
notice: console.log,
setFailed(msg) {
console.error(msg)
process.exitCode = 1
},
},
dry,
})
} finally {
rmSync(tmp, { recursive: true })
}
}
program
.command('labels')
.description('Manage labels on pull requests.')
.argument('<owner>', 'Owner of the GitHub repository to label (Example: NixOS)')
.argument('<repo>', 'Name of the GitHub repository to label (Example: nixpkgs)')
.argument('[pr]', 'Number of the Pull Request to label')
.option('--no-dry', 'Make actual modifications')
.action(async (owner, repo, pr, options) => {
const labels = (await import('./labels.js')).default
run(labels, owner, repo, pr, options.dry)
})
await program.parse()

View File

@ -5,12 +5,14 @@
pkgs.callPackage (
{
mkShell,
gh,
importNpmLock,
mkShell,
nodejs,
}:
mkShell {
packages = [
gh
importNpmLock.hooks.linkNodeModulesHook
nodejs
];

View File

@ -0,0 +1,61 @@
module.exports = async function ({ github, core }, callback) {
const Bottleneck = require('bottleneck')
const stats = {
issues: 0,
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,
// Will be updated with first `updateReservoir()` call below.
reservoir: 0,
})
// Pause between mutative requests
const writeLimits = new Bottleneck({ minTime: 1000 }).chain(allLimits)
github.hook.wrap('request', async (request, options) => {
// Requests to the /rate_limit endpoint do not count against the rate limit.
if (options.url == '/rate_limit') return request(options)
// Search requests are in a different resource group, which allows 30 requests / minute.
// We do less than a handful each run, so not implementing throttling for now.
if (options.url.startsWith('/search/')) return 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))
})
async function updateReservoir() {
let response
try {
response = await github.rest.rateLimit.get()
} catch (err) {
core.error(`Failed updating reservoir:\n${err}`)
// Keep retrying on failed rate limit requests instead of exiting the script early.
return
}
// Always keep 1000 spare requests for other jobs to do their regular duty.
// They normally use below 100, so 1000 is *plenty* of room to work with.
const reservoir = Math.max(0, response.data.resources.core.remaining - 1000)
core.info(`Updating reservoir to: ${reservoir}`)
allLimits.updateSettings({ reservoir })
}
await updateReservoir()
// Update remaining requests every minute to account for other jobs running in parallel.
const reservoirUpdater = setInterval(updateReservoir, 60 * 1000)
try {
await callback(stats)
} finally {
clearInterval(reservoirUpdater)
core.notice(
`Processed ${stats.prs} PRs, ${stats.issues} Issues, made ${stats.requests + stats.artifacts} API requests and downloaded ${stats.artifacts} artifacts.`,
)
}
}

View File

@ -1,4 +0,0 @@
# TODO: Move to <top-level>/.editorconfig, once ci/.editorconfig has made its way through staging.
[*.cjs]
indent_style = space
indent_size = 2

View File

@ -1,4 +0,0 @@
To test the labeler locally:
- Provide `gh` on `PATH` and make sure it's authenticated.
- Enter `nix-shell` in `./ci/labels`.
- Run `./run.js OWNER REPO`, where OWNER is your username or "NixOS" and REPO the name of your fork or "nixpkgs".

View File

@ -1,45 +0,0 @@
#!/usr/bin/env node
import { execSync } from 'node:child_process'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { getOctokit } from '@actions/github'
import labels from './labels.cjs'
if (process.argv.length !== 4)
throw new Error('Call this with exactly two arguments: ./run.js OWNER REPO')
const [, , owner, repo] = process.argv
const token = execSync('gh auth token', { encoding: 'utf-8' }).trim()
const tmp = mkdtempSync(join(tmpdir(), 'labels-'))
try {
process.env.GITHUB_WORKSPACE = tmp
process.chdir(tmp)
await labels({
github: getOctokit(token),
context: {
payload: {},
repo: {
owner,
repo,
},
},
core: {
getInput() {
return token
},
error: console.error,
info: console.log,
notice: console.log,
setFailed(msg) {
console.error(msg)
process.exitCode = 1
},
},
dry: true,
})
} finally {
rmSync(tmp, { recursive: true })
}