From e051ed5d7202ef6cd5c5db3bf167507c6dcda957 Mon Sep 17 00:00:00 2001 From: Michael Daniels Date: Sun, 11 Oct 2026 13:42:12 -0400 Subject: [PATCH 1/2] ci/github-script/commits: extract cherry-pick logic to functions --- ci/github-script/commits.ts | 82 +++++++++++++++++++++++-------------- 1 file changed, 52 insertions(+), 30 deletions(-) diff --git a/ci/github-script/commits.ts b/ci/github-script/commits.ts index 479b0c19d054..75a2d36f3bcd 100644 --- a/ci/github-script/commits.ts +++ b/ci/github-script/commits.ts @@ -60,6 +60,49 @@ function isIgnoredBranchError(error: unknown) { ) } +export function cherryPickedFromSha(message: string): string | undefined { + // Using the last line with "cherry" + hash, because a chained backport + // can result in multiple of those lines. Only the last one counts. + return Array.from(message.matchAll(/cherry.*([0-9a-f]{40})/g)) + .at(-1) + ?.at(1) +} + +interface ShaIsInDevelopmentBranchProps { + github: InstanceType + context: typeof actionsContext + sha: string +} +export async function shaIsInDevelopmentBranch({ + github, + context, + sha, +}: ShaIsInDevelopmentBranchProps): Promise { + let branches: string[] | undefined + 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/${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 (!isIgnoredBranchError(e)) throw e + } + + return branches !== undefined && branches.length > 0 +} + export default async ({ github, context, @@ -103,13 +146,9 @@ export default async ({ type: 'no-cherry-pick', } - // Using the last line with "cherry" + hash, because a chained backport - // can result in multiple of those lines. Only the last one counts. - const cherry = Array.from( - commit.message.matchAll(/cherry.*([0-9a-f]{40})/g), - ).at(-1) + const original_sha = cherryPickedFromSha(commit.message) - if (!cherry) + if (!original_sha) return { sha, commit, @@ -118,30 +157,13 @@ export default async ({ type: 'no-commit-hash', } - const original_sha = cherry[1] - - let branches: string[] | undefined - 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 (!isIgnoredBranchError(e)) throw e - } - if (!branches?.length) + if ( + !(await shaIsInDevelopmentBranch({ + github, + context, + sha: original_sha, + })) + ) return { sha, commit, From 9e37f67efc896c6101fe196f7be4e4e1996b236d Mon Sep 17 00:00:00 2001 From: Michael Daniels Date: Sun, 11 Oct 2026 13:42:51 -0400 Subject: [PATCH 2/2] ci/github-script/reminders: don't remind on cherry-picks Addresses https://github.com/NixOS/nixpkgs/pull/562912#issuecomment-6101844681 --- .github/workflows/check.yml | 4 ++++ ci/github-script/reminders.ts | 40 ++++++++++++++++++++++++++++++----- 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 06f9fa8d8e90..e426cfeda106 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -154,6 +154,10 @@ jobs: sparse-checkout: | ci/github-script + - name: Install dependencies + run: npm ci --package-lock-only=false --no-audit bottleneck + working-directory: trusted/ci/github-script + - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 if: github.event_name != 'pull_request' && vars.NIXPKGS_CI_CLIENT_ID id: app-token diff --git a/ci/github-script/reminders.ts b/ci/github-script/reminders.ts index 98d58af95da4..01fbfd69f3c7 100644 --- a/ci/github-script/reminders.ts +++ b/ci/github-script/reminders.ts @@ -3,6 +3,7 @@ import path from 'node:path' import type * as actionsCore from '@actions/core' import type { context as actionsContext } from '@actions/github' import type { GitHub } from '@actions/github/lib/utils' +import { cherryPickedFromSha, shaIsInDevelopmentBranch } from './commits.ts' import { getCommitDetailsForPR } from './get-pr-commit-details.ts' import { dismissReviews, postReview } from './reviews.ts' import { classify } from './supportedBranches.ts' @@ -63,12 +64,12 @@ export default async function postReminders({ return } - const baseBranchType = classify( + const { type: baseBranchType, stable } = classify( pr.base.ref.replace(/^refs\/heads\//, ''), - ).type - const headBranchType = classify( + ) + const { type: headBranchType } = classify( pr.head.ref.replace(/^refs\/heads\//, ''), - ).type + ) if ( baseBranchType.includes('development') && @@ -85,7 +86,36 @@ export default async function postReminders({ } const details = await getCommitDetailsForPR({ core, pr, repoPath }) - const changedPaths = details.flatMap(({ changedPaths }) => changedPaths) + let changedPaths: string[] + if (stable && details.length < 250) { + const prCommits = await github.paginate(github.rest.pulls.listCommits, { + ...context.repo, + pull_number, + }) + changedPaths = ( + await Promise.all( + prCommits.map(async ({ files, commit }) => { + // Ignore cherry-picks from development branches to stable branches + const original_sha = cherryPickedFromSha(commit.message) + if ( + !files || + (original_sha && + (await shaIsInDevelopmentBranch({ + github, + context, + sha: original_sha, + }))) + ) { + return [] + } else { + return files?.map(({ filename }) => filename) + } + }), + ) + ).flat(1) + } else { + changedPaths = details.flatMap(({ changedPaths }) => changedPaths) + } for (const { key, paths } of reminders) { if (matchesAny(changedPaths, paths)) {