Unverified Commit eea09eb9 authored by Wolfgang Walther's avatar Wolfgang Walther
Browse files

workflows/bot: migrate nixpkgs-merge-bot to GHA

Running the nixpkgs-merge-bot in GitHub Actions instead of a separate
workflow has multiple advantages:
- A much better development workflow, with improved testability.
- The ability to label PRs with a "merge-bot eligible" label from the
same codebase.
- Using more data for merge strategy decisions, for example the number
of rebuilds.

This commits re-implements most of the features from the current
nxipkgs-merge-bot directly in the bot workflow. Instead of reacting to
webhook events, this now runs on the regular 10 minute schedule. Some
merges might be delayed a few minutes, but that should not be a problem
in practice.

To give the user early feedback, there are additional workflows running
when a comment or review is posted. These react with "eyes" to make the
user aware that the comment has been recognized.

The only feature not taken over was the size check for files in the PR.
This kind of check is not really relevant for maintainer merges only -
if we want to prevent bigger files from making it into the tree, then we
need a generic CI check, which is out of scope for the merge-bot.

Other than that, everything should be implemented - any omissions are by
accident.
parent f1640b71
Loading
Loading
Loading
Loading
+5 −0
Original line number Diff line number Diff line
@@ -39,6 +39,10 @@ jobs:
  run:
    runs-on: ubuntu-24.04-arm
    if: github.event_name != 'schedule' || github.repository_owner == 'NixOS'
    env:
      # TODO: Remove after 2026-03-04, when Node 24 becomes the default.
      # https://github.blog/changelog/2025-09-19-deprecation-of-node-20-on-github-actions-runners/
      FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
    steps:
      - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
        with:
@@ -56,6 +60,7 @@ jobs:
        with:
          app-id: ${{ vars.NIXPKGS_CI_APP_ID }}
          private-key: ${{ secrets.NIXPKGS_CI_APP_PRIVATE_KEY }}
          permission-contents: write
          permission-issues: write
          permission-pull-requests: write

+54 −0
Original line number Diff line number Diff line
name: Comment

on:
  issue_comment:
    types: [created]

# This is used as fallback without app only.
# This happens when testing in forks without setting up that app.
permissions:
  pull-requests: write

defaults:
  run:
    shell: bash

jobs:
  # The `bot` workflow reacts to comments with @NixOS/nixpkgs-merge-bot references, but might only
  # pick up a comment after up to 10 minutes. To give the user instant feedback, this job adds
  # a reaction to these comments.
  react:
    name: React with eyes
    runs-on: ubuntu-24.04-arm
    timeout-minutes: 2
    if: contains(github.event.comment.body, '@NixOS/nixpkgs-merge-bot merge')
    steps:
      - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
        with:
          persist-credentials: false
          sparse-checkout: |
            ci/github-script

      # Use the GitHub App to make sure the reaction happens with the same user who will later merge.
      - uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
        if: github.event_name != 'pull_request' && vars.NIXPKGS_CI_APP_ID
        id: app-token
        with:
          app-id: ${{ vars.NIXPKGS_CI_APP_ID }}
          private-key: ${{ secrets.NIXPKGS_CI_APP_PRIVATE_KEY }}
          permission-pull-requests: write

      - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
        with:
          github-token: ${{ steps.app-token.outputs.token || github.token }}
          retries: 3
          script: |
            const { handleMergeComment } = require('./ci/github-script/merge.js')
            const { body, node_id } = context.payload.comment

            await handleMergeComment({
              github,
              body,
              node_id,
              reaction: 'EYES',
            })
+49 −19
Original line number Diff line number Diff line
@@ -6,6 +6,8 @@ on:
      - Reviewed
    types: [completed]

# This is used as fallback without app only.
# This happens when testing in forks without setting up that app.
permissions:
  pull-requests: write

@@ -14,18 +16,32 @@ defaults:
    shell: bash

jobs:
  # The `check-cherry-picks` workflow creates review comments which reviewers
  # are encouraged to manually dismiss if they're not relevant.
  # When a CI-generated review is dismissed, this job automatically minimizes
  # it, preventing it from cluttering the PR.
  minimize:
    name: Minimize as resolved
  process:
    runs-on: ubuntu-24.04-arm
    timeout-minutes: 2
    steps:
      - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
        with:
          persist-credentials: false
          sparse-checkout: |
            ci/github-script

      # Use the GitHub App to make sure the reaction happens with the same user who will later merge.
      - uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
        if: github.event_name != 'pull_request' && vars.NIXPKGS_CI_APP_ID
        id: app-token
        with:
          app-id: ${{ vars.NIXPKGS_CI_APP_ID }}
          private-key: ${{ secrets.NIXPKGS_CI_APP_PRIVATE_KEY }}
          permission-pull-requests: write

      - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
        with:
          github-token: ${{ steps.app-token.outputs.token || github.token }}
          retries: 3
          script: |
            const { handleMergeComment } = require('./ci/github-script/merge.js')

            // PRs from forks don't have any PRs associated by default.
            // Thus, we request the PR number with an API call *to* the fork's repo.
            // Multiple pull requests can be open from the same head commit, either via
@@ -44,10 +60,13 @@ jobs:
                    owner: context.repo.owner,
                    repo: context.repo.repo,
                    pull_number: pull_request.number
                  })).filter(review =>
                    review.user?.login == 'github-actions[bot]' &&
                    review.state == 'DISMISSED'
                  ).map(review => github.graphql(`
                  })).map(review => {
                    // The `check` workflow creates review comments which reviewers
                    // are encouraged to manually dismiss if they're not relevant.
                    // When a CI-generated review is dismissed, this job automatically minimizes
                    // it, preventing it from cluttering the PR.
                    if (review.user?.login == 'github-actions[bot]' && review.state == 'DISMISSED')
                      return github.graphql(`
                        mutation($node_id:ID!) {
                          minimizeComment(input: {
                            classifier: RESOLVED,
@@ -56,7 +75,18 @@ jobs:
                          { clientMutationId }
                        }`,
                        { node_id: review.node_id }
                  ))
                      )

                    // The `bot` workflow reacts to comments with @NixOS/nixpkgs-merge-bot references, but might only
                    // pick up a comment after up to 10 minutes. To give the user instant feedback, this job adds
                    // a reaction to these comments.
                    return handleMergeComment({
                      github,
                      body: review.body,
                      node_id: review.node_id,
                      reaction: 'EYES',
                    })
                  })
                )
              )
            )
+1 −1
Original line number Diff line number Diff line
@@ -2,7 +2,7 @@ name: Reviewed

on:
  pull_request_review:
    types: [dismissed]
    types: [submitted, dismissed]

permissions: {}

+29 −11
Original line number Diff line number Diff line
@@ -4,6 +4,7 @@ module.exports = async ({ github, context, core, dry }) => {
  const { readFile, writeFile } = require('node:fs/promises')
  const withRateLimit = require('./withRateLimit.js')
  const { classify } = require('../supportedBranches.js')
  const { handleMerge } = require('./merge.js')

  const artifactClient = new DefaultArtifactClient()

@@ -95,7 +96,7 @@ module.exports = async ({ github, context, core, dry }) => {
    return maintainerMaps[branch]
  }

  async function handlePullRequest({ item, stats }) {
  async function handlePullRequest({ item, stats, events }) {
    const log = (k, v) => core.info(`PR #${item.number} - ${k}: ${v}`)

    const pull_number = item.number
@@ -109,6 +110,19 @@ module.exports = async ({ github, context, core, dry }) => {
      })
    ).data

    const maintainers = await getMaintainerMap(pull_request.base.ref)

    await handleMerge({
      github,
      context,
      core,
      log,
      dry,
      pull_request,
      events,
      maintainers,
    })

    // When the same change has already been merged to the target branch, a PR will still be
    // open and display the same changes - but will not actually have any effect. This causes
    // strange CI behavior, because the diff of the merge-commit is empty, no rebuilds will
@@ -305,8 +319,6 @@ module.exports = async ({ github, context, core, dry }) => {
        )
      }

      const maintainers = await getMaintainerMap(pull_request.base.ref)

      Object.assign(prLabels, evalLabels, {
        '11.by: package-maintainer':
          packages.length &&
@@ -377,9 +389,21 @@ module.exports = async ({ github, context, core, dry }) => {

      const itemLabels = {}

      const events = await github.paginate(
        github.rest.issues.listEventsForTimeline,
        {
          ...context.repo,
          issue_number,
          per_page: 100,
        },
      )

      if (item.pull_request || context.payload.pull_request) {
        stats.prs++
        Object.assign(itemLabels, await handlePullRequest({ item, stats }))
        Object.assign(
          itemLabels,
          await handlePullRequest({ item, stats, events }),
        )
      } else {
        stats.issues++
        if (item.labels.some(({ name }) => name === '4.workflow: auto-close')) {
@@ -391,13 +415,7 @@ module.exports = async ({ github, context, core, dry }) => {
      }

      const latest_event_at = new Date(
        (
          await github.paginate(github.rest.issues.listEventsForTimeline, {
            ...context.repo,
            issue_number,
            per_page: 100,
          })
        )
        events
          .filter(({ event }) =>
            [
              // These events are hand-picked from:
Loading