ci/github-script/commits: init from ci/check-cherry-picks

This turns the check-cherry-pick script into a github-script based
JavaScript program. This makes it much easier to extend to check reverts
or merge commits later on.
This commit is contained in:
Wolfgang Walther
2025-07-13 19:42:12 +02:00
parent 2433050fb7
commit b46cb23251
8 changed files with 252 additions and 201 deletions

View File

@@ -1 +1,2 @@
node_modules
step-summary.md

View File

@@ -8,6 +8,10 @@ To run any of the scripts locally:
- Enter `nix-shell` in `./ci/github-script`.
- Ensure `gh` is authenticated.
## Check commits
Run `./run commits OWNER REPO PR`, where OWNER is your username or "NixOS", REPO is the name of your fork or "nixpkgs" and PR is the number of the pull request to check.
## Labeler
Run `./run labels OWNER REPO`, where OWNER is your username or "NixOS" and REPO the name of your fork or "nixpkgs".

View File

@@ -0,0 +1,7 @@
This report is automatically generated by the `check-cherry-picks` CI workflow.
Some of the commits in this PR have not been cherry-picked exactly and require the author's and reviewer's attention.
Please make sure to follow the [backporting guidelines](https://github.com/NixOS/nixpkgs/blob/master/CONTRIBUTING.md#how-to-backport-pull-requests) and cherry-pick with the `-x` flag. This requires changes to go to the unstable branches (`master` / `staging`) first, before backporting them.
Occasionally, it is not possible to cherry-pick exactly the same patch. This most frequently happens when resolving merge conflicts while cherry-picking or when updating minor versions of packages which have already advanced to the next major on unstable. If you need to merge this PR despite the warnings, please [dismiss](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/reviewing-changes-in-pull-requests/dismissing-a-pull-request-review) this review.

199
ci/github-script/commits.js Normal file
View File

@@ -0,0 +1,199 @@
module.exports = async function ({ github, context, core }) {
const { execFileSync } = require('node:child_process')
const { readFile, writeFile } = require('node:fs/promises')
const { join } = require('node:path')
const { classify } = require('../supportedBranches.js')
const withRateLimit = require('./withRateLimit.js')
await withRateLimit({ github, core }, async (stats) => {
stats.prs = 1
const job_url =
context.runId &&
(
await github.rest.actions.listJobsForWorkflowRun({
...context.repo,
run_id: context.runId,
})
).data.jobs[0].html_url +
'?pr=' +
context.payload.pull_request.number
async function handle({ sha, commit }) {
// Using the last line with "cherry" + hash, because a chained backport
// can result in multiple of those lines. Only the last one counts.
const match = Array.from(
commit.message.matchAll(/cherry.*([0-9a-f]{40})/g),
).at(-1)
if (!match)
return {
sha,
commit,
severity: 'warning',
message: `Couldn't locate original commit hash in message of ${sha}.`,
}
const original_sha = match[1]
let branches
try {
branches = (
await github.request({
// This is an undocumented endpoint to fetch the branches a commit is part of.
// There is no equivalent in neither the REST nor the GraphQL API.
// The endpoint itself is unlikely to go away, because GitHub uses it to display
// the list of branches on the detail page of a commit.
url: `https://github.com/${context.repo.owner}/${context.repo.repo}/branch_commits/${original_sha}`,
headers: {
accept: 'application/json',
},
})
).data.branches
.map(({ branch }) => branch)
.filter((branch) => classify(branch).type.includes('development'))
} catch (e) {
// For some unknown reason a 404 error comes back as 500 without any more details in a GitHub Actions runner.
// Ignore these to return a regular error message below.
if (![404, 500].includes(e.status)) throw e
}
if (!branches?.length)
return {
sha,
commit,
severity: 'error',
message: `${original_sha} given in ${sha} not found in any pickable branch.`,
}
const diff = execFileSync('git', [
'-C',
__dirname,
'range-diff',
'--no-color',
'--no-notes',
'--creation-factor=100',
`${original_sha}~..${original_sha}`,
`${sha}~..${sha}`,
])
.toString()
.split('\n')
// First line contains commit SHAs, which we'll print separately.
.slice(1)
// # The output of `git range-diff` is indented with 4 spaces, but we'll control indentation manually.
.map((line) => line.replace(/^ {4}/, ''))
if (!diff.some((line) => line.match(/^[+-]{2}/)))
return {
sha,
commit,
severity: 'info',
message: `${original_sha} is highly similar to ${sha}.`,
}
const colored_diff = execFileSync('git', [
'-C',
__dirname,
'range-diff',
'--color',
'--no-notes',
'--creation-factor=100',
`${original_sha}~..${original_sha}`,
`${sha}~..${sha}`,
]).toString()
return {
sha,
commit,
diff,
colored_diff,
severity: 'warning',
message: `Difference between ${sha} and original ${original_sha} may warrant inspection.`,
}
}
const commits = await github.paginate(github.rest.pulls.listCommits, {
...context.repo,
pull_number: context.payload.pull_request.number,
})
const results = await Promise.all(commits.map(handle))
// Log all results without truncation and with better highlighting to the job log.
results.forEach(({ sha, commit, severity, message, colored_diff }) => {
core.startGroup(`Commit ${sha}`)
core.info(`Author: ${commit.author.name} ${commit.author.email}`)
core.info(`Date: ${new Date(commit.author.date)}`)
core[severity](message)
core.endGroup()
if (colored_diff) core.info(colored_diff)
})
// Only create step summary below in case of warnings or errors.
if (results.every(({ severity }) => severity == 'info')) return
else process.exitCode = 1
core.summary.addRaw(
await readFile(join(__dirname, 'check-cherry-picks.md'), 'utf-8'),
true,
)
results.forEach(({ severity, message, diff }) => {
if (severity == 'info') return
// The docs for markdown alerts only show examples with markdown blockquote syntax, like this:
// > [!WARNING]
// > message
// However, our testing shows that this also works with a `<blockquote>` html tag, as long as there
// is an empty line:
// <blockquote>
//
// [!WARNING]
// message
// </blockquote>
// Whether this is intended or just an implementation detail is unclear.
core.summary.addRaw('<blockquote>')
core.summary.addRaw(
`\n\n[!${severity == 'warning' ? 'WARNING' : 'CAUTION'}]`,
true,
)
core.summary.addRaw(`${message}`, true)
if (diff) {
// Limit the output to 10k bytes and remove the last, potentially incomplete line, because GitHub
// comments are limited in length. The value of 10k is arbitrary with the assumption, that after
// the range-diff becomes a certain size, a reviewer is better off reviewing the regular diff in
// GitHub's UI anyway, thus treating the commit as "new" and not cherry-picked.
// Note: if multiple commits are close to the limit, this approach could still lead to a comment
// that's too long. We think this is unlikely to happen, and so don't deal with it explicitly.
const truncated = []
let total_length = 0
for (line of diff) {
total_length += line.length
if (total_length > 10000) {
truncated.push('', '[...truncated...]')
break
} else {
truncated.push(line)
}
}
core.summary.addRaw('<details><summary>Show diff</summary>')
core.summary.addRaw('\n\n```diff', true)
core.summary.addRaw(truncated.join('\n'), true)
core.summary.addRaw('```', true)
core.summary.addRaw('</details>')
}
core.summary.addRaw('</blockquote>')
})
if (job_url)
core.summary.addRaw(
`\n\n_Hint: The full diffs are also available in the [runner logs](${job_url}) with slightly better highlighting._`,
)
// Write to disk temporarily for next step in GHA.
await writeFile('review.md', core.summary.stringify())
core.summary.write()
})
}

View File

@@ -1,13 +1,13 @@
#!/usr/bin/env -S node --import ./run
import { execSync } from 'node:child_process'
import { mkdtempSync, rmSync } from 'node:fs'
import { closeSync, mkdtempSync, openSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { program } from 'commander'
import * as core from '@actions/core'
import { getOctokit } from '@actions/github'
async function run(action, owner, repo, pull_number, dry) {
async function run(action, owner, repo, pull_number, dry = true) {
const token = execSync('gh auth token', { encoding: 'utf-8' }).trim()
const github = getOctokit(token)
@@ -20,29 +20,36 @@ async function run(action, owner, repo, pull_number, dry) {
})).data
}
const tmp = mkdtempSync(join(tmpdir(), 'github-script-'))
try {
process.env.GITHUB_WORKSPACE = tmp
process.env['INPUT_GITHUB-TOKEN'] = token
process.chdir(tmp)
process.env['INPUT_GITHUB-TOKEN'] = token
await action({
github,
context: {
payload,
repo: {
owner,
repo,
},
closeSync(openSync('step-summary.md', 'w'))
process.env.GITHUB_STEP_SUMMARY = 'step-summary.md'
await action({
github,
context: {
payload,
repo: {
owner,
repo,
},
core,
dry,
})
} finally {
rmSync(tmp, { recursive: true })
}
},
core,
dry,
})
}
program
.command('commits')
.description('Check commit structure of a pull request.')
.argument('<owner>', 'Owner of the GitHub repository to check (Example: NixOS)')
.argument('<repo>', 'Name of the GitHub repository to check (Example: nixpkgs)')
.argument('<pr>', 'Number of the Pull Request to check')
.action(async (owner, repo, pr) => {
const commits = (await import('./commits.js')).default
run(commits, owner, repo, pr)
})
program
.command('labels')
.description('Manage labels on pull requests.')
@@ -52,7 +59,14 @@ program
.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)
const tmp = mkdtempSync(join(tmpdir(), 'github-script-'))
try {
process.env.GITHUB_WORKSPACE = tmp
process.chdir(tmp)
run(labels, owner, repo, pr, options.dry)
} finally {
rmSync(tmp, { recursive: true })
}
})
await program.parse()

View File

@@ -20,6 +20,8 @@ module.exports = async function ({ github, core }, callback) {
// Pause between mutative requests
const writeLimits = new Bottleneck({ minTime: 1000 }).chain(allLimits)
github.hook.wrap('request', async (request, options) => {
// Requests to a different host do not count against the rate limit.
if (options.url.startsWith('https://github.com')) return 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.