mirror of
https://github.com/CHN-beta/nixpkgs.git
synced 2026-01-12 02:40:31 +08:00
This is currently managed with yet another bot, but we don't need to -
this fits into our workflow nicely.
(cherry picked from commit 28f3b92403)
316 lines
14 KiB
YAML
316 lines
14 KiB
YAML
# WARNING:
|
|
# When extending this action, be aware that $GITHUB_TOKEN allows some write
|
|
# access to the GitHub API. This means that it should not evaluate user input in
|
|
# a way that allows code injection.
|
|
|
|
name: Labels
|
|
|
|
on:
|
|
schedule:
|
|
- cron: '07,17,27,37,47,57 * * * *'
|
|
workflow_call:
|
|
workflow_dispatch:
|
|
inputs:
|
|
updatedWithin:
|
|
description: 'Updated within [hours]'
|
|
type: number
|
|
required: false
|
|
default: 0 # everything since last run
|
|
|
|
concurrency:
|
|
# This explicitly avoids using `run_id` for the concurrency key to make sure that only
|
|
# *one* non-PR run can run at a time.
|
|
group: labels-${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number }}
|
|
# PR- and manually-triggered runs will be cancelled, but scheduled runs will be queued.
|
|
cancel-in-progress: ${{ github.event_name != 'schedule' }}
|
|
|
|
permissions:
|
|
issues: write # needed to create *new* labels
|
|
pull-requests: write
|
|
|
|
defaults:
|
|
run:
|
|
shell: bash
|
|
|
|
jobs:
|
|
update:
|
|
runs-on: ubuntu-24.04-arm
|
|
if: github.event_name != 'schedule' || github.repository_owner == 'NixOS'
|
|
steps:
|
|
- name: Install dependencies
|
|
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
|
|
env:
|
|
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: 500,
|
|
reservoirRefreshAmount: 500,
|
|
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.')
|
|
|
|
const cutoff = new Date(await (async () => {
|
|
// Always run for Pull Request triggers, no cutoff since there will be a single
|
|
// response only anyway. 0 is the Unix epoch, so always smaller.
|
|
if (context.payload.pull_request?.number) return 0
|
|
|
|
// Manually triggered via UI when updatedWithin is set. Will fallthrough to the last
|
|
// option if the updatedWithin parameter is set to 0, which is the default.
|
|
const updatedWithin = Number.parseInt(process.env.UPDATED_WITHIN, 10)
|
|
if (updatedWithin) return new Date().getTime() - updatedWithin * 60 * 60 * 1000
|
|
|
|
// Normally a scheduled run, but could be workflow_dispatch, see above. Go back as far
|
|
// as the last successful run of this workflow to make sure we are not leaving anyone
|
|
// behind on GHA failures.
|
|
// Defaults to go back 1 hour on the first run.
|
|
return (await github.rest.actions.listWorkflowRuns({
|
|
...context.repo,
|
|
workflow_id: 'labels.yml',
|
|
event: 'schedule',
|
|
status: 'success',
|
|
exclude_pull_requests: true
|
|
})).data.workflow_runs[0]?.created_at ?? new Date().getTime() - 1 * 60 * 60 * 1000
|
|
})())
|
|
core.info('cutoff timestamp: ' + cutoff.toISOString())
|
|
|
|
// To simplify this action's logic we fetch the pull_request data again below, even if
|
|
// we are already in a pull_request event's context and would have the data readily
|
|
// available. We do this by filtering the list of pull requests with head and base
|
|
// branch - there can only be a single open Pull Request for any such combination.
|
|
const prEventCondition = !context.payload.pull_request ? undefined : {
|
|
// "label" is in the format of `user:branch` or `org:branch`
|
|
head: context.payload.pull_request.head.label,
|
|
base: context.payload.pull_request.base.ref
|
|
}
|
|
|
|
const prs = await github.paginate(
|
|
github.rest.pulls.list,
|
|
{
|
|
...context.repo,
|
|
state: 'open',
|
|
sort: 'updated',
|
|
direction: 'desc',
|
|
...prEventCondition
|
|
},
|
|
(response, done) => response.data.map(async (pull_request) => {
|
|
try {
|
|
const log = (k,v,skip) => {
|
|
core.info(`PR #${pull_request.number} - ${k}: ${v}` + (skip ? ' (skipped)' : ''))
|
|
return skip
|
|
}
|
|
|
|
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({
|
|
...context.repo,
|
|
workflow_id: 'pr.yml',
|
|
event: 'pull_request_target',
|
|
// For PR events, the workflow run is still in progress with this job itself.
|
|
status: prEventCondition ? 'in_progress' : 'success',
|
|
exclude_pull_requests: true,
|
|
head_sha: pull_request.head.sha
|
|
})).data.workflow_runs[0]?.id ??
|
|
// TODO: Remove this after 2025-09-17, at which point all eval.yml artifacts will have expired.
|
|
(await github.rest.actions.listWorkflowRuns({
|
|
...context.repo,
|
|
// In older PRs, we need eval.yml instead of pr.yml.
|
|
workflow_id: 'eval.yml',
|
|
event: 'pull_request_target',
|
|
status: 'success',
|
|
exclude_pull_requests: true,
|
|
head_sha: pull_request.head.sha
|
|
})).data.workflow_runs[0]?.id
|
|
|
|
// 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.
|
|
if (log('Last eval run', run_id ?? '<pending>', !run_id))
|
|
return;
|
|
|
|
const artifact = (await github.rest.actions.listWorkflowRunArtifacts({
|
|
...context.repo,
|
|
run_id,
|
|
name: 'comparison'
|
|
})).data.artifacts[0]
|
|
|
|
// Instead of checking the boolean artifact.expired, we will give us a minute to
|
|
// 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.
|
|
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: {
|
|
repositoryName: context.repo.repo,
|
|
repositoryOwner: context.repo.owner,
|
|
token: core.getInput('github-token')
|
|
},
|
|
path: path.resolve(pull_request.number.toString()),
|
|
expectedHash: artifact.digest
|
|
})
|
|
|
|
// Create a map (Label -> Boolean) of all currently set labels.
|
|
// Each label is set to True and can be disabled later.
|
|
const before = Object.fromEntries(
|
|
(await github.paginate(github.rest.issues.listLabelsOnIssue, {
|
|
...context.repo,
|
|
issue_number: pull_request.number
|
|
}))
|
|
.map(({ name }) => [name, true])
|
|
)
|
|
|
|
const approvals = new Set(
|
|
(await github.paginate(github.rest.pulls.listReviews, {
|
|
...context.repo,
|
|
pull_number: pull_request.number
|
|
}))
|
|
.filter(review => review.state == 'APPROVED')
|
|
.map(review => review.user?.id)
|
|
)
|
|
|
|
const maintainers = new Set(Object.keys(
|
|
JSON.parse(await readFile(`${pull_request.number}/maintainers.json`, 'utf-8'))
|
|
).map(m => Number.parseInt(m, 10)))
|
|
|
|
const evalLabels = JSON.parse(await readFile(`${pull_request.number}/changed-paths.json`, 'utf-8')).labels
|
|
|
|
// Manage the labels
|
|
const after = Object.assign(
|
|
{},
|
|
before,
|
|
// Ignore `evalLabels` if it's an array.
|
|
// This can happen for older eval runs, before we switched to objects.
|
|
// The old eval labels would have been set by the eval run,
|
|
// so now they'll be present in `before`.
|
|
// TODO: Simplify once old eval results have expired (~2025-10)
|
|
(Array.isArray(evalLabels) ? undefined : evalLabels),
|
|
{
|
|
'12.approvals: 1': approvals.size == 1,
|
|
'12.approvals: 2': approvals.size == 2,
|
|
'12.approvals: 3+': approvals.size >= 3,
|
|
'12.approved-by: package-maintainer': Array.from(maintainers).some(m => approvals.has(m)),
|
|
'12.first-time contribution':
|
|
[ 'NONE', 'FIRST_TIMER', 'FIRST_TIME_CONTRIBUTOR' ].includes(pull_request.author_association),
|
|
}
|
|
)
|
|
|
|
// No need for an API request, if all labels are the same.
|
|
const hasChanges = Object.keys(after).some(name => (before[name] ?? false) != after[name])
|
|
if (log('Has changes', hasChanges, !hasChanges))
|
|
return;
|
|
|
|
// Skipping labeling on a pull_request event, because we have no privileges.
|
|
const labels = Object.entries(after).filter(([,value]) => value).map(([name]) => name)
|
|
if (log('Set labels', labels, context.eventName == 'pull_request'))
|
|
return;
|
|
|
|
await github.rest.issues.setLabels({
|
|
...context.repo,
|
|
issue_number: pull_request.number,
|
|
labels
|
|
})
|
|
} 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
|
|
if: |
|
|
github.event_name == 'pull_request_target' &&
|
|
(github.event.pull_request.head.repo.owner.login != 'NixOS' || !(
|
|
github.head_ref == 'haskell-updates' ||
|
|
github.head_ref == 'python-updates' ||
|
|
github.head_ref == 'staging-next' ||
|
|
startsWith(github.head_ref, 'staging-next-')
|
|
))
|
|
with:
|
|
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
|
configuration-path: .github/labeler.yml # default
|
|
sync-labels: true
|
|
|
|
- uses: actions/labeler@8558fd74291d67161a8a78ce36a881fa63b766a9 # v5.0.0
|
|
name: Labels from touched files (no sync)
|
|
if: |
|
|
github.event_name == 'pull_request_target' &&
|
|
(github.event.pull_request.head.repo.owner.login != 'NixOS' || !(
|
|
github.head_ref == 'haskell-updates' ||
|
|
github.head_ref == 'python-updates' ||
|
|
github.head_ref == 'staging-next' ||
|
|
startsWith(github.head_ref, 'staging-next-')
|
|
))
|
|
with:
|
|
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
|
configuration-path: .github/labeler-no-sync.yml
|
|
sync-labels: false
|
|
|
|
- uses: actions/labeler@8558fd74291d67161a8a78ce36a881fa63b766a9 # v5.0.0
|
|
name: Labels from touched files (development branches)
|
|
# Development branches like staging-next, haskell-updates and python-updates get special labels.
|
|
# This is to avoid the mass of labels there, which is mostly useless - and really annoying for
|
|
# the backport labels.
|
|
if: |
|
|
github.event_name == 'pull_request_target' &&
|
|
(github.event.pull_request.head.repo.owner.login == 'NixOS' && (
|
|
github.head_ref == 'haskell-updates' ||
|
|
github.head_ref == 'python-updates' ||
|
|
github.head_ref == 'staging-next' ||
|
|
startsWith(github.head_ref, 'staging-next-')
|
|
))
|
|
with:
|
|
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
|
configuration-path: .github/labeler-development-branches.yml
|
|
sync-labels: true
|