mirror of
https://github.com/NixOS/nixpkgs.git
synced 2026-10-31 01:02:20 +00:00
Compare commits
21 Commits
python-upd
...
wip-gcc-16
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77636a6a16 | ||
|
|
bee3b6c52f | ||
|
|
cedf46f815 | ||
|
|
cd01953447 | ||
|
|
a06d109d48 | ||
|
|
c465430f82 | ||
|
|
1e10b67e35 | ||
|
|
6aa36277ff | ||
|
|
d457a15860 | ||
|
|
75c304b0e0 | ||
|
|
2012d325a3 | ||
|
|
1dd0a5d9e5 | ||
|
|
fb7e01a3b6 | ||
|
|
80c9a1708e | ||
|
|
b57ec0fcb2 | ||
|
|
f7b16131d9 | ||
|
|
6c37551652 | ||
|
|
aab37bd258 | ||
|
|
017fed1d63 | ||
|
|
f46e80f6c5 | ||
|
|
8c8bcc8565 |
@@ -23,15 +23,15 @@ insert_final_newline = false
|
||||
|
||||
# see https://nixos.org/nixpkgs/manual/#chap-conventions
|
||||
|
||||
[*.{bash,css,js,json,lock,md,nix,pl,pm,py,rb,sh,ts,xml}]
|
||||
[*.{bash,css,js,json,lock,md,nix,pl,pm,py,rb,sh,xml}]
|
||||
indent_style = space
|
||||
|
||||
# Match docbook files, set indent width of one
|
||||
[*.xml]
|
||||
indent_size = 1
|
||||
|
||||
# Match js/json/lockfiles/markdown/nix/ruby/ts files, set indent width of two
|
||||
[*.{js,json,lock,md,nix,rb,ts}]
|
||||
# Match json/lockfiles/markdown/nix/ruby files, set indent width of two
|
||||
[*.{js,json,lock,md,nix,rb}]
|
||||
indent_size = 2
|
||||
|
||||
# Match all the Bash code in Nix files, set indent width of two
|
||||
@@ -79,7 +79,7 @@ indent_size = unset
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
# binaries
|
||||
[*.{nib,torrent}]
|
||||
[*.nib]
|
||||
end_of_line = unset
|
||||
insert_final_newline = unset
|
||||
trim_trailing_whitespace = unset
|
||||
|
||||
3
.gitattributes
vendored
3
.gitattributes
vendored
@@ -62,6 +62,3 @@ ci/OWNERS linguist-language=CODEOWNERS
|
||||
# patching CRLF line endings from an upstream source package.
|
||||
*.diff !text !eol
|
||||
*.patch !text !eol
|
||||
|
||||
# Torrent files are binary files and should not be re-encoded.
|
||||
*.torrent !text !eol
|
||||
|
||||
11
.github/actions/checkout/action.yml
vendored
11
.github/actions/checkout/action.yml
vendored
@@ -13,14 +13,7 @@ inputs:
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
# We don't actually need anything in this directory, but we need a small
|
||||
# sparse checkout, and this directory is small.
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: .github/actions
|
||||
|
||||
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
env:
|
||||
MERGED_SHA: ${{ inputs.merged-as-untrusted-at }}
|
||||
TARGET_SHA: ${{ inputs.target-as-trusted-at }}
|
||||
@@ -44,7 +37,7 @@ runs:
|
||||
})
|
||||
}
|
||||
|
||||
// These are set automatically by the sparse checkout for .github/actions.
|
||||
// These are set automatically by the spare checkout for .github/actions.
|
||||
// Undo them, otherwise git fetch below will not do anything.
|
||||
await run('git', 'config', 'unset', 'remote.origin.promisor')
|
||||
await run('git', 'config', 'unset', 'remote.origin.partialclonefilter')
|
||||
|
||||
4
.github/dependabot.yml
vendored
4
.github/dependabot.yml
vendored
@@ -1,9 +1,7 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "github-actions"
|
||||
directories:
|
||||
- "/"
|
||||
- ".github/actions/*/*"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
labels: []
|
||||
|
||||
20
.github/labeler.yml
vendored
20
.github/labeler.yml
vendored
@@ -43,6 +43,14 @@
|
||||
- .github/**/*
|
||||
- ci/**/*.*
|
||||
|
||||
"6.topic: coq":
|
||||
- any:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- pkgs/applications/science/logic/coq/**/*
|
||||
- pkgs/development/coq-modules/**/*
|
||||
- pkgs/top-level/coq-packages.nix
|
||||
|
||||
"6.topic: COSMIC":
|
||||
- any:
|
||||
- changed-files:
|
||||
@@ -458,18 +466,6 @@
|
||||
- any-glob-to-any-file:
|
||||
- pkgs/development/rocm-modules/**/*
|
||||
|
||||
"6.topic: rocq":
|
||||
- any:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- pkgs/applications/science/logic/coq/**/*
|
||||
- pkgs/applications/science/logic/rocq-core/**/*
|
||||
- pkgs/build-support/coq/**/*
|
||||
- pkgs/build-support/rocq/**/*
|
||||
- pkgs/development/rocq-modules/**/*
|
||||
- pkgs/top-level/coq-packages.nix
|
||||
- pkgs/top-level/rocq-packages.nix
|
||||
|
||||
"6.topic: ruby":
|
||||
- any:
|
||||
- changed-files:
|
||||
|
||||
9
.github/workflows/backport.yml
vendored
9
.github/workflows/backport.yml
vendored
@@ -36,16 +36,11 @@ jobs:
|
||||
permission-pull-requests: write
|
||||
permission-workflows: write
|
||||
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
# Avoid materializing full nixpkgs tree
|
||||
sparse-checkout: .
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
persist-credentials: true
|
||||
# The PR is already merged, and its commits are only cherry-picked by
|
||||
# git, never executed, so checking out fork PR commits is safe here.
|
||||
allow-unsafe-pr-checkout: true
|
||||
|
||||
- name: Log current API rate limits
|
||||
env:
|
||||
@@ -54,7 +49,7 @@ jobs:
|
||||
|
||||
- name: Create backport PRs
|
||||
id: backport
|
||||
uses: korthout/backport-action@8560fb503c275d433c56f05a2f64850093baa9f7 # v4.7.0
|
||||
uses: korthout/backport-action@2e830a1d0b8269505846ddd407a70876913ad1f8 # v4.6.0
|
||||
with:
|
||||
# Config README: https://github.com/korthout/backport-action#backport-action
|
||||
add_author_as_reviewer: true
|
||||
|
||||
13
.github/workflows/bot.yml
vendored
13
.github/workflows/bot.yml
vendored
@@ -42,14 +42,14 @@ jobs:
|
||||
runs-on: ubuntu-slim
|
||||
if: github.event_name != 'schedule' || github.repository_owner == 'NixOS'
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
ci/github-script
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --package-lock-only=false --no-audit @actions/artifact bottleneck
|
||||
run: npm ci --package-lock-only=false @actions/artifact bottleneck
|
||||
working-directory: ci/github-script
|
||||
|
||||
# Use a GitHub App, because it has much higher rate limits: 12,500 instead of 5,000 req / hour.
|
||||
@@ -76,8 +76,7 @@ jobs:
|
||||
github-token: ${{ steps.app-token.outputs.token || github.token }}
|
||||
retries: 3
|
||||
script: |
|
||||
const { default: bot } = await import('${{ github.workspace }}/ci/github-script/bot.ts')
|
||||
await bot({
|
||||
require('./ci/github-script/bot.js')({
|
||||
github,
|
||||
context,
|
||||
core,
|
||||
@@ -89,7 +88,7 @@ jobs:
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token || github.token }}
|
||||
run: gh api /rate_limit | jq
|
||||
|
||||
- uses: actions/labeler@bf12e9b00b37c5c0ca2b87b79b2daf7891dbda13 # v7.0.0
|
||||
- uses: actions/labeler@b8dd2d9be0f68b860e7dae5dae7d772984eacd6d # v6.2.0
|
||||
name: Labels from touched files
|
||||
if: |
|
||||
github.event_name == 'pull_request_target' &&
|
||||
@@ -99,7 +98,7 @@ jobs:
|
||||
configuration-path: .github/labeler.yml # default
|
||||
sync-labels: true
|
||||
|
||||
- uses: actions/labeler@bf12e9b00b37c5c0ca2b87b79b2daf7891dbda13 # v7.0.0
|
||||
- uses: actions/labeler@b8dd2d9be0f68b860e7dae5dae7d772984eacd6d # v6.2.0
|
||||
name: Labels from touched files (no sync)
|
||||
if: |
|
||||
github.event_name == 'pull_request_target' &&
|
||||
@@ -109,7 +108,7 @@ jobs:
|
||||
configuration-path: .github/labeler-no-sync.yml
|
||||
sync-labels: false
|
||||
|
||||
- uses: actions/labeler@bf12e9b00b37c5c0ca2b87b79b2daf7891dbda13 # v7.0.0
|
||||
- uses: actions/labeler@b8dd2d9be0f68b860e7dae5dae7d772984eacd6d # v6.2.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
|
||||
|
||||
8
.github/workflows/build.yml
vendored
8
.github/workflows/build.yml
vendored
@@ -49,13 +49,17 @@ jobs:
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: .github/actions
|
||||
- name: Checkout the merge commit
|
||||
uses: $/.github/actions/checkout
|
||||
uses: ./.github/actions/checkout
|
||||
with:
|
||||
merged-as-untrusted-at: ${{ inputs.mergedSha }}
|
||||
target-as-trusted-at: ${{ inputs.targetSha }}
|
||||
|
||||
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
|
||||
- uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0
|
||||
with:
|
||||
# Sandbox is disabled on MacOS by default.
|
||||
extra_nix_config: sandbox = true
|
||||
|
||||
94
.github/workflows/check.yml
vendored
94
.github/workflows/check.yml
vendored
@@ -24,10 +24,6 @@ on:
|
||||
# not evaluate untrusted code.
|
||||
NIXPKGS_MANUAL_EDIT_CHECK_APP_PRIVATE_KEY:
|
||||
required: false
|
||||
# Can be provided in pull requests because the job it is used in does
|
||||
# not evaluate untrusted code.
|
||||
NIXPKGS_CI_APP_PRIVATE_KEY:
|
||||
required: false
|
||||
# Should only be provided in the merge queue, not in pull requests,
|
||||
# where we're evaluating untrusted code.
|
||||
CACHIX_AUTH_TOKEN_GHA:
|
||||
@@ -47,7 +43,7 @@ jobs:
|
||||
runs-on: ubuntu-slim
|
||||
timeout-minutes: 3
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
path: trusted
|
||||
@@ -55,7 +51,7 @@ jobs:
|
||||
ci/github-script
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --package-lock-only=false --no-audit bottleneck
|
||||
run: npm ci --package-lock-only=false bottleneck
|
||||
working-directory: trusted/ci/github-script
|
||||
|
||||
- uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
@@ -80,8 +76,7 @@ jobs:
|
||||
github-token: ${{ steps.app-token.outputs.token || github.token }}
|
||||
script: |
|
||||
const targetsStable = JSON.parse(process.env.TARGETS_STABLE)
|
||||
const { default: commits } = await import('${{ github.workspace }}/trusted/ci/github-script/commits.ts')
|
||||
await commits({
|
||||
require('./trusted/ci/github-script/commits.js')({
|
||||
github,
|
||||
context,
|
||||
core,
|
||||
@@ -101,7 +96,7 @@ jobs:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
timeout-minutes: 8
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
path: trusted
|
||||
@@ -126,8 +121,7 @@ jobs:
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token || github.token }}
|
||||
script: |
|
||||
const { default: checkManualFileEdits } = await import('${{ github.workspace }}/trusted/ci/github-script/manual-file-edits.ts')
|
||||
await checkManualFileEdits({
|
||||
require('./trusted/ci/github-script/manual-file-edits.js')({
|
||||
github,
|
||||
context,
|
||||
core,
|
||||
@@ -140,87 +134,21 @@ jobs:
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token || github.token }}
|
||||
run: gh api /rate_limit | jq
|
||||
|
||||
reminders:
|
||||
if: inputs.baseBranch && inputs.headBranch
|
||||
permissions:
|
||||
pull-requests: write
|
||||
runs-on: ubuntu-24.04-arm
|
||||
timeout-minutes: 8
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
path: trusted
|
||||
sparse-checkout: |
|
||||
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
|
||||
with:
|
||||
client-id: ${{ vars.NIXPKGS_CI_CLIENT_ID }}
|
||||
private-key: ${{ secrets.NIXPKGS_CI_APP_PRIVATE_KEY }}
|
||||
permission-pull-requests: write
|
||||
|
||||
- name: Log current API rate limits
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token || github.token }}
|
||||
run: gh api /rate_limit | jq
|
||||
|
||||
- name: Post reminders
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token || github.token }}
|
||||
script: |
|
||||
const { default: postReminders } = await import('${{ github.workspace }}/trusted/ci/github-script/reminders.ts')
|
||||
await postReminders({
|
||||
github,
|
||||
context,
|
||||
core,
|
||||
dry: context.eventName == 'pull_request',
|
||||
repoPath: 'trusted',
|
||||
})
|
||||
|
||||
- name: Log current API rate limits
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token || github.token }}
|
||||
run: gh api /rate_limit | jq
|
||||
|
||||
github-script:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Checkout merge and target commits
|
||||
uses: $/.github/actions/checkout
|
||||
with:
|
||||
merged-as-untrusted-at: ${{ inputs.mergedSha }}
|
||||
target-as-trusted-at: ${{ inputs.targetSha }}
|
||||
|
||||
- name: Install dependencies to trusted/
|
||||
run: |
|
||||
npm ci --package-lock-only=false --no-audit
|
||||
echo "$PWD/node_modules/.bin" >> "$GITHUB_PATH"
|
||||
working-directory: nixpkgs/trusted/ci/github-script
|
||||
|
||||
- name: Link trusted/ dependencies to untrusted/
|
||||
run: ln -s "$PWD/trusted/ci/github-script/node_modules" untrusted/ci/github-script/node_modules
|
||||
working-directory: nixpkgs
|
||||
|
||||
- name: Check ci/github-script
|
||||
run: npm test
|
||||
working-directory: nixpkgs/untrusted/ci/github-script
|
||||
|
||||
owners:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: .github/actions
|
||||
- name: Checkout merge and target commits
|
||||
uses: $/.github/actions/checkout
|
||||
uses: ./.github/actions/checkout
|
||||
with:
|
||||
merged-as-untrusted-at: ${{ inputs.mergedSha }}
|
||||
target-as-trusted-at: ${{ inputs.targetSha }}
|
||||
|
||||
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
|
||||
- uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0
|
||||
|
||||
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
|
||||
continue-on-error: true
|
||||
|
||||
4
.github/workflows/comment.yml
vendored
4
.github/workflows/comment.yml
vendored
@@ -23,7 +23,7 @@ jobs:
|
||||
timeout-minutes: 2
|
||||
if: contains(github.event.comment.body, '@NixOS/nixpkgs-merge-bot merge')
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
@@ -43,7 +43,7 @@ jobs:
|
||||
github-token: ${{ steps.app-token.outputs.token || github.token }}
|
||||
retries: 3
|
||||
script: |
|
||||
const { handleMergeComment } = await import('${{ github.workspace }}/ci/github-script/merge.ts')
|
||||
const { handleMergeComment } = require('./ci/github-script/merge.js')
|
||||
const { body, node_id } = context.payload.comment
|
||||
|
||||
await handleMergeComment({
|
||||
|
||||
36
.github/workflows/eval.yml
vendored
36
.github/workflows/eval.yml
vendored
@@ -50,7 +50,7 @@ jobs:
|
||||
ciPinBumpCommit: ${{ steps.find-pinned-commit.outputs.ciPinBumpCommit }}
|
||||
ciPinBumpCommitShort: ${{ steps.find-pinned-commit.outputs.ciPinBumpCommitShort }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
path: trusted
|
||||
@@ -58,16 +58,13 @@ jobs:
|
||||
ci/supportedVersions.nix
|
||||
|
||||
- name: Check out the PR at the test merge commit
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: ${{ inputs.mergedSha }}
|
||||
path: untrusted
|
||||
sparse-checkout: |
|
||||
ci/pinned.json
|
||||
# ci/pinned.json is only read as data by trusted/ci/supportedVersions.nix,
|
||||
# so checking out the fork PR's merge commit is safe here.
|
||||
allow-unsafe-pr-checkout: true
|
||||
|
||||
- name: Find commit that touched ci/pinned.json
|
||||
id: find-pinned-commit
|
||||
@@ -142,7 +139,7 @@ jobs:
|
||||
core.info(`Found pinned.json commit: ${ciPinBumpCommit}`)
|
||||
|
||||
- name: Install Nix
|
||||
uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
|
||||
uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0
|
||||
|
||||
- name: Load supported versions
|
||||
id: versions
|
||||
@@ -177,8 +174,12 @@ jobs:
|
||||
sudo mkswap /swap
|
||||
sudo swapon /swap
|
||||
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: .github/actions
|
||||
- name: Check out the PR at merged and target commits
|
||||
uses: $/.github/actions/checkout
|
||||
uses: ./.github/actions/checkout
|
||||
with:
|
||||
# For versioned evals, use the target as the untrusted base and apply the pin-bump commit
|
||||
merged-as-untrusted-at: ${{ matrix.version && inputs.targetSha || inputs.mergedSha }}
|
||||
@@ -186,7 +187,7 @@ jobs:
|
||||
target-as-trusted-at: ${{ inputs.targetSha }}
|
||||
|
||||
- name: Install Nix
|
||||
uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
|
||||
uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0
|
||||
|
||||
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
|
||||
continue-on-error: true
|
||||
@@ -258,8 +259,12 @@ jobs:
|
||||
statuses: write # creating 'Eval Summary' commit statuses
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: .github/actions
|
||||
- name: Check out the PR at the target commit
|
||||
uses: $/.github/actions/checkout
|
||||
uses: ./.github/actions/checkout
|
||||
with:
|
||||
merged-as-untrusted-at: ${{ inputs.mergedSha }}
|
||||
target-as-trusted-at: ${{ inputs.targetSha }}
|
||||
@@ -272,7 +277,7 @@ jobs:
|
||||
merge-multiple: true
|
||||
|
||||
- name: Install Nix
|
||||
uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
|
||||
uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0
|
||||
|
||||
- name: Combine all output paths and eval stats
|
||||
run: |
|
||||
@@ -370,8 +375,7 @@ jobs:
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token || github.token }}
|
||||
script: |
|
||||
const { checkTargetBranch } = await import('${{ github.workspace }}/nixpkgs/trusted/ci/github-script/check-target-branch.ts')
|
||||
await checkTargetBranch({
|
||||
require('./nixpkgs/trusted/ci/github-script/check-target-branch.js')({
|
||||
github,
|
||||
context,
|
||||
core,
|
||||
@@ -472,13 +476,17 @@ jobs:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: .github/actions
|
||||
- name: Checkout the merge commit
|
||||
uses: $/.github/actions/checkout
|
||||
uses: ./.github/actions/checkout
|
||||
with:
|
||||
merged-as-untrusted-at: ${{ inputs.mergedSha }}
|
||||
|
||||
- name: Install Nix
|
||||
uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
|
||||
uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0
|
||||
|
||||
- name: Ensure flake outputs on all systems still evaluate
|
||||
run: nix flake check --all-systems --no-build './nixpkgs/untrusted?shallow=1'
|
||||
|
||||
30
.github/workflows/lint.yml
vendored
30
.github/workflows/lint.yml
vendored
@@ -26,12 +26,16 @@ jobs:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: .github/actions
|
||||
- name: Checkout the merge commit
|
||||
uses: $/.github/actions/checkout
|
||||
uses: ./.github/actions/checkout
|
||||
with:
|
||||
merged-as-untrusted-at: ${{ inputs.mergedSha }}
|
||||
|
||||
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
|
||||
- uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0
|
||||
|
||||
# TODO: Figure out how to best enable caching for the treefmt job. Cachix won't work well,
|
||||
# because the cache would be invalidated on every commit - treefmt checks every file.
|
||||
@@ -57,12 +61,16 @@ jobs:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: .github/actions
|
||||
- name: Checkout the merge commit
|
||||
uses: $/.github/actions/checkout
|
||||
uses: ./.github/actions/checkout
|
||||
with:
|
||||
merged-as-untrusted-at: ${{ inputs.mergedSha }}
|
||||
|
||||
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
|
||||
- uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0
|
||||
|
||||
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
|
||||
continue-on-error: true
|
||||
@@ -82,13 +90,17 @@ jobs:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: .github/actions
|
||||
- name: Checkout merge and target commits
|
||||
uses: $/.github/actions/checkout
|
||||
uses: ./.github/actions/checkout
|
||||
with:
|
||||
merged-as-untrusted-at: ${{ inputs.mergedSha }}
|
||||
target-as-trusted-at: ${{ inputs.targetSha }}
|
||||
|
||||
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
|
||||
- uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0
|
||||
|
||||
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
|
||||
continue-on-error: true
|
||||
@@ -122,7 +134,7 @@ jobs:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
timeout-minutes: 8
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: true # Needed to run git fetch for large PRs.
|
||||
path: trusted
|
||||
@@ -130,9 +142,9 @@ jobs:
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const { default: checkCommitMessages } = await import('${{ github.workspace }}/trusted/ci/github-script/lint-commits.ts')
|
||||
const checkCommitMessages = require('./trusted/ci/github-script/lint-commits.js')
|
||||
|
||||
await checkCommitMessages({
|
||||
checkCommitMessages({
|
||||
github,
|
||||
context,
|
||||
core,
|
||||
|
||||
16
.github/workflows/merge-group.yml
vendored
16
.github/workflows/merge-group.yml
vendored
@@ -25,11 +25,11 @@ jobs:
|
||||
targetSha: ${{ steps.prepare.outputs.targetSha }}
|
||||
systems: ${{ steps.prepare.outputs.systems }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
ci/github-script
|
||||
ci/github-script/supportedSystems.js
|
||||
|
||||
- id: prepare
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
@@ -38,8 +38,8 @@ jobs:
|
||||
TARGET_SHA: ${{ inputs.targetSha }}
|
||||
with:
|
||||
script: |
|
||||
const { classify } = await import('${{ github.workspace }}/ci/github-script/supportedBranches.ts')
|
||||
const { default: supportedSystems } = await import('${{ github.workspace }}/ci/github-script/supportedSystems.ts')
|
||||
const { classify } = require('./ci/supportedBranches.js')
|
||||
const supportedSystems = require('./ci/github-script/supportedSystems.js')
|
||||
|
||||
const baseBranch = (
|
||||
context.payload.merge_group?.base_ref ??
|
||||
@@ -63,7 +63,7 @@ jobs:
|
||||
check:
|
||||
name: Check
|
||||
needs: [prepare]
|
||||
uses: $/.github/workflows/check.yml
|
||||
uses: ./.github/workflows/check.yml
|
||||
permissions:
|
||||
pull-requests: write # cherry-picks: unused in merge queue but required for check workflow
|
||||
secrets:
|
||||
@@ -75,7 +75,7 @@ jobs:
|
||||
lint:
|
||||
name: Lint
|
||||
needs: [prepare]
|
||||
uses: $/.github/workflows/lint.yml
|
||||
uses: ./.github/workflows/lint.yml
|
||||
secrets:
|
||||
CACHIX_AUTH_TOKEN_GHA: ${{ secrets.CACHIX_AUTH_TOKEN_GHA }}
|
||||
with:
|
||||
@@ -85,7 +85,7 @@ jobs:
|
||||
eval:
|
||||
name: Eval
|
||||
needs: [prepare]
|
||||
uses: $/.github/workflows/eval.yml
|
||||
uses: ./.github/workflows/eval.yml
|
||||
# The eval workflow requests these permissions so we must explicitly allow them,
|
||||
# even though they are unused when working with the merge queue.
|
||||
permissions:
|
||||
@@ -103,7 +103,7 @@ jobs:
|
||||
build:
|
||||
name: Build
|
||||
needs: [prepare]
|
||||
uses: $/.github/workflows/build.yml
|
||||
uses: ./.github/workflows/build.yml
|
||||
secrets:
|
||||
CACHIX_AUTH_TOKEN_GHA: ${{ secrets.CACHIX_AUTH_TOKEN_GHA }}
|
||||
with:
|
||||
|
||||
2
.github/workflows/periodic-merge-24h.yml
vendored
2
.github/workflows/periodic-merge-24h.yml
vendored
@@ -40,7 +40,7 @@ jobs:
|
||||
- name: merge-base(master,staging) → haskell-updates
|
||||
from: master staging
|
||||
into: haskell-updates
|
||||
uses: $/.github/workflows/periodic-merge.yml
|
||||
uses: ./.github/workflows/periodic-merge.yml
|
||||
with:
|
||||
from: ${{ matrix.pairs.from }}
|
||||
into: ${{ matrix.pairs.into }}
|
||||
|
||||
2
.github/workflows/periodic-merge-6h.yml
vendored
2
.github/workflows/periodic-merge-6h.yml
vendored
@@ -37,7 +37,7 @@ jobs:
|
||||
into: staging
|
||||
- from: master
|
||||
into: staging-nixos
|
||||
uses: $/.github/workflows/periodic-merge.yml
|
||||
uses: ./.github/workflows/periodic-merge.yml
|
||||
with:
|
||||
from: ${{ matrix.pairs.from }}
|
||||
into: ${{ matrix.pairs.into }}
|
||||
|
||||
6
.github/workflows/periodic-merge.yml
vendored
6
.github/workflows/periodic-merge.yml
vendored
@@ -32,9 +32,9 @@ jobs:
|
||||
client-id: ${{ vars.NIXPKGS_CI_CLIENT_ID }}
|
||||
private-key: ${{ secrets.NIXPKGS_CI_APP_PRIVATE_KEY }}
|
||||
permission-contents: write
|
||||
permission-issues: write
|
||||
permission-pull-requests: write
|
||||
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -66,4 +66,4 @@ jobs:
|
||||
Periodic merge from `${{ inputs.from }}` into [`${{ inputs.into }}`](https://github.com/NixOS/nixpkgs/tree/${{ inputs.into }}) has [failed](https://github.com/NixOS/nixpkgs/actions/runs/${{ github.run_id }}).
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
run: |
|
||||
gh issue comment 562905 --body "$BODY_TEXT"
|
||||
gh pr comment 105153 --body "$BODY_TEXT"
|
||||
|
||||
16
.github/workflows/pull-request-target.yml
vendored
16
.github/workflows/pull-request-target.yml
vendored
@@ -36,7 +36,7 @@ jobs:
|
||||
systems: ${{ steps.prepare.outputs.systems }}
|
||||
touched: ${{ steps.prepare.outputs.touched }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout-cone-mode: true # default, for clarity
|
||||
@@ -64,8 +64,7 @@ jobs:
|
||||
# https://github.com/octokit/plugin-retry.js/blob/9a2443746c350b3beedec35cf26e197ea318a261/src/index.ts#L14
|
||||
retry-exempt-status-codes: 400,401,403,404
|
||||
script: |
|
||||
const { default: prepare } = await import('${{ github.workspace }}/ci/github-script/prepare.ts')
|
||||
await prepare({
|
||||
require('./ci/github-script/prepare.js')({
|
||||
github,
|
||||
context,
|
||||
core,
|
||||
@@ -75,14 +74,13 @@ jobs:
|
||||
check:
|
||||
name: Check
|
||||
needs: [prepare]
|
||||
uses: $/.github/workflows/check.yml
|
||||
uses: ./.github/workflows/check.yml
|
||||
permissions:
|
||||
# cherry-picks
|
||||
pull-requests: write
|
||||
secrets:
|
||||
NIXPKGS_COMMIT_CHECK_APP_PRIVATE_KEY: ${{ secrets.NIXPKGS_COMMIT_CHECK_APP_PRIVATE_KEY }}
|
||||
NIXPKGS_MANUAL_EDIT_CHECK_APP_PRIVATE_KEY: ${{ secrets.NIXPKGS_MANUAL_EDIT_CHECK_APP_PRIVATE_KEY }}
|
||||
NIXPKGS_CI_APP_PRIVATE_KEY: ${{ secrets.NIXPKGS_CI_APP_PRIVATE_KEY }}
|
||||
with:
|
||||
baseBranch: ${{ needs.prepare.outputs.baseBranch }}
|
||||
headBranch: ${{ needs.prepare.outputs.headBranch }}
|
||||
@@ -92,7 +90,7 @@ jobs:
|
||||
lint:
|
||||
name: Lint
|
||||
needs: [prepare]
|
||||
uses: $/.github/workflows/lint.yml
|
||||
uses: ./.github/workflows/lint.yml
|
||||
with:
|
||||
mergedSha: ${{ needs.prepare.outputs.mergedSha }}
|
||||
targetSha: ${{ needs.prepare.outputs.targetSha }}
|
||||
@@ -100,7 +98,7 @@ jobs:
|
||||
eval:
|
||||
name: Eval
|
||||
needs: [prepare]
|
||||
uses: $/.github/workflows/eval.yml
|
||||
uses: ./.github/workflows/eval.yml
|
||||
permissions:
|
||||
# compare
|
||||
pull-requests: write
|
||||
@@ -119,7 +117,7 @@ jobs:
|
||||
bot:
|
||||
name: Bot
|
||||
needs: [prepare, eval]
|
||||
uses: $/.github/workflows/bot.yml
|
||||
uses: ./.github/workflows/bot.yml
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
@@ -131,7 +129,7 @@ jobs:
|
||||
build:
|
||||
name: Build
|
||||
needs: [prepare]
|
||||
uses: $/.github/workflows/build.yml
|
||||
uses: ./.github/workflows/build.yml
|
||||
with:
|
||||
artifact-prefix: ${{ inputs.artifact-prefix }}
|
||||
baseBranch: ${{ needs.prepare.outputs.baseBranch }}
|
||||
|
||||
4
.github/workflows/review.yml
vendored
4
.github/workflows/review.yml
vendored
@@ -20,7 +20,7 @@ jobs:
|
||||
runs-on: ubuntu-slim
|
||||
timeout-minutes: 2
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
github-token: ${{ steps.app-token.outputs.token || github.token }}
|
||||
retries: 3
|
||||
script: |
|
||||
const { handleMergeComment } = await import('${{ github.workspace }}/ci/github-script/merge.ts')
|
||||
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.
|
||||
|
||||
8
.github/workflows/teams.yml
vendored
8
.github/workflows/teams.yml
vendored
@@ -30,7 +30,7 @@ jobs:
|
||||
permission-pull-requests: write
|
||||
|
||||
- name: Fetch source
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
@@ -38,7 +38,7 @@ jobs:
|
||||
maintainers/github-teams.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --package-lock-only=false --no-audit bottleneck
|
||||
run: npm ci --package-lock-only=false bottleneck
|
||||
working-directory: ci/github-script
|
||||
|
||||
- name: Synchronise teams
|
||||
@@ -46,8 +46,7 @@ jobs:
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
script: |
|
||||
const { default: getTeams } = await import('${{ github.workspace }}/ci/github-script/get-teams.ts')
|
||||
await getTeams({
|
||||
require('./ci/github-script/get-teams.js')({
|
||||
github,
|
||||
context,
|
||||
core,
|
||||
@@ -79,3 +78,4 @@ jobs:
|
||||
This is an automated PR to sync the GitHub teams with access to this repository to the `lib.teams` list.
|
||||
|
||||
This PR can be merged without taking any further action.
|
||||
|
||||
|
||||
45
.github/workflows/test.yml
vendored
45
.github/workflows/test.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
push: ${{ steps.files.outputs.push }}
|
||||
targetSha: ${{ steps.prepare.outputs.targetSha }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout-cone-mode: true # default, for clarity
|
||||
@@ -35,8 +35,7 @@ jobs:
|
||||
# https://github.com/octokit/plugin-retry.js/blob/9a2443746c350b3beedec35cf26e197ea318a261/src/index.ts#L14
|
||||
retry-exempt-status-codes: 400,401,403,404
|
||||
script: |
|
||||
const { default: prepare } = await import('${{ github.workspace }}/ci/github-script/prepare.ts')
|
||||
await prepare({
|
||||
require('./ci/github-script/prepare.js')({
|
||||
github,
|
||||
context,
|
||||
core,
|
||||
@@ -62,11 +61,9 @@ jobs:
|
||||
'.github/workflows/lint.yml',
|
||||
'.github/workflows/merge-group.yml',
|
||||
'.github/workflows/test.yml',
|
||||
'ci/github-script/package.json',
|
||||
'ci/github-script/package-lock.json',
|
||||
'ci/github-script/supportedBranches.ts',
|
||||
'ci/github-script/supportedSystems.ts',
|
||||
'ci/github-script/supportedSystems.js',
|
||||
'ci/pinned.json',
|
||||
'ci/supportedBranches.js',
|
||||
'pkgs/top-level/release-supported-systems.json',
|
||||
].includes(file))) core.setOutput('merge-group', true)
|
||||
|
||||
@@ -79,31 +76,27 @@ jobs:
|
||||
'.github/workflows/lint.yml',
|
||||
'.github/workflows/pull-request-target.yml',
|
||||
'.github/workflows/test.yml',
|
||||
'ci/github-script/bot.ts',
|
||||
'ci/github-script/check-target-branch.ts',
|
||||
'ci/github-script/commits.ts',
|
||||
'ci/github-script/get-pr-commit-details.ts',
|
||||
'ci/github-script/lint-commits.ts',
|
||||
'ci/github-script/manual-file-edits.ts',
|
||||
'ci/github-script/merge.ts',
|
||||
'ci/github-script/package.json',
|
||||
'ci/github-script/package-lock.json',
|
||||
'ci/github-script/prepare.ts',
|
||||
'ci/github-script/reminders.ts',
|
||||
'ci/github-script/reviewers.ts',
|
||||
'ci/github-script/reviews.ts',
|
||||
'ci/github-script/supportedBranches.ts',
|
||||
'ci/github-script/supportedSystems.ts',
|
||||
'ci/github-script/withRateLimit.ts',
|
||||
'ci/github-script/bot.js',
|
||||
'ci/github-script/check-target-branch.js',
|
||||
'ci/github-script/commits.js',
|
||||
'ci/github-script/get-pr-commit-details.js',
|
||||
'ci/github-script/lint-commits.js',
|
||||
'ci/github-script/merge.js',
|
||||
'ci/github-script/prepare.js',
|
||||
'ci/github-script/reviewers.js',
|
||||
'ci/github-script/reviews.js',
|
||||
'ci/github-script/supportedSystems.js',
|
||||
'ci/github-script/withRateLimit.js',
|
||||
'ci/pinned.json',
|
||||
'ci/supportedBranches.js',
|
||||
'pkgs/top-level/release-supported-systems.json',
|
||||
].includes(file) || file.startsWith('ci/github-script/reminders/'))) core.setOutput('pr', true)
|
||||
].includes(file))) core.setOutput('pr', true)
|
||||
|
||||
merge-group:
|
||||
if: needs.prepare.outputs.merge-group
|
||||
name: Merge Group
|
||||
needs: [prepare]
|
||||
uses: $/.github/workflows/merge-group.yml
|
||||
uses: ./.github/workflows/merge-group.yml
|
||||
# Those are actually only used on the merge_group event, but will throw an error if not set.
|
||||
permissions:
|
||||
pull-requests: write # unused on pull_request, required by merge-group workflow
|
||||
@@ -117,7 +110,7 @@ jobs:
|
||||
if: needs.prepare.outputs.pr
|
||||
name: PR
|
||||
needs: [prepare]
|
||||
uses: $/.github/workflows/pull-request-target.yml
|
||||
uses: ./.github/workflows/pull-request-target.yml
|
||||
# Those are actually only used on the pull_request_target event, but will throw an error if not set.
|
||||
permissions:
|
||||
issues: write # unused on pull_request, required by bot workflow
|
||||
|
||||
3
.mailmap
3
.mailmap
@@ -6,7 +6,6 @@ Christina Sørensen <christina@cafkafk.com> <christinaafk@gmail.com>
|
||||
Christina Sørensen <christina@cafkafk.com> <89321978+cafkafk@users.noreply.github.com>
|
||||
Daniel Løvbrøtte Olsen <me@dandellion.xyz> <daniel.olsen99@gmail.com>
|
||||
Ethan Carter Edwards <ethan@ethancedwards.com> Ethan Edwards <ethancarteredwards@gmail.com>
|
||||
Ethan Carter Edwards <ethan@ethancedwards.com> <ethancedwards8@users.noreply.github.com>
|
||||
Fabian Affolter <mail@fabian-affolter.ch> <fabian@affolter-engineering.ch>
|
||||
Fiona Behrens <me@kloenk.dev>
|
||||
Fiona Behrens <me@kloenk.dev> <me@kloenk.de>
|
||||
@@ -21,8 +20,6 @@ Jörg Thalheim <joerg@thalheim.io> <Mic92@users.noreply.github.com>
|
||||
Katalin Rebhan <me@dblsaiko.net>
|
||||
Lin Jian <me@linj.tech> <linj.dev@outlook.com>
|
||||
Lin Jian <me@linj.tech> <75130626+jian-lin@users.noreply.github.com>
|
||||
Marie Ramlow <marie@marie.cologne> <me@nycode.dev>
|
||||
Marie Ramlow <marie@marie.cologne> <tabmeier12@gmail.com>
|
||||
Martin Weinelt <hexa@darmstadt.ccc.de> <mweinelt@users.noreply.github.com>
|
||||
Martin Häcker <spamfaenger@gmx.de> <spamfaenger@gmx.de>
|
||||
moni <lythe1107@gmail.com> <lythe1107@icloud.com>
|
||||
|
||||
@@ -571,10 +571,7 @@ If a contributor does not want committers to push to their branch, they must unc
|
||||
|
||||
### Release notes
|
||||
|
||||
If you add or remove a NixOS module, or make other breaking or significant NixOS changes, write about it in the next NixOS release notes in [`nixos/doc/manual/release-notes`](./nixos/doc/manual/release-notes).
|
||||
|
||||
If you make major or breaking changes to a package (other than removal), write about it in the next Nixpkgs release notes in [`doc/release-notes`](./doc/release-notes).
|
||||
Package removals should not get a Nixpkgs release note, [a throwing alias should be added instead](./pkgs/README.md#steps-to-remove-a-package-from-nixpkgs).
|
||||
If you removed packages or made some major NixOS changes, write about it in the next release notes in [`nixos/doc/manual/release-notes`](./nixos/doc/manual/release-notes).
|
||||
|
||||
### File naming and organisation
|
||||
|
||||
@@ -587,9 +584,9 @@ CI [enforces](./.github/workflows/lint.yml) all Nix files to be formatted using
|
||||
|
||||
You can ensure this locally using either of these commands:
|
||||
```
|
||||
nix fmt
|
||||
nix develop --command treefmt
|
||||
nix-shell --run treefmt
|
||||
nix develop --command treefmt
|
||||
nix fmt
|
||||
```
|
||||
|
||||
If you're starting your editor in `nix-shell` or `nix develop`, you can also set it up to automatically run `treefmt` on save.
|
||||
@@ -957,6 +954,7 @@ The following situations are fully or partially exempt:
|
||||
|
||||
If you believe that someone is using automation without appropriate disclosure and review, you can politely ask them if that’s the case and point them to this policy as appropriate.
|
||||
Please assume good faith and remain civil; it’s not always possible to determine, and it is more likely that someone overlooked this policy than deliberately violated it.
|
||||
If you think someone is continuing to break the policy after this, please escalate to the [Nixpkgs core team](https://nixos.org/community/teams/nixpkgs-core/) rather than fighting over it.
|
||||
|
||||
If a contribution is clearly in violation of the policy (e.g. the contributor admits it was not followed, or there are AI tool attributions that do not meet our required format), it can be closed or hidden, preferably after informing the contributor of the policy and giving them a chance to address the violations.
|
||||
Deliberate violations of this policy are considered to break the [Code of Conduct](https://github.com/NixOS/.github/blob/master/CODE_OF_CONDUCT.md) clause against “Wasting other people’s time with low quality contributions, including but not limited to LLM and bot spam”.
|
||||
|
||||
47
ci/OWNERS
47
ci/OWNERS
@@ -32,8 +32,8 @@
|
||||
/lib/asserts.nix @infinisil @hsjobeki @llakala
|
||||
/lib/path/* @infinisil @hsjobeki @llakala
|
||||
/lib/fileset @infinisil @hsjobeki @llakala
|
||||
/maintainers/github-teams.json @infinisil @llakala @NixOS/nixpkgs-ci
|
||||
/maintainers/computed-team-list.nix @infinisil @llakala @NixOS/nixpkgs-ci
|
||||
/maintainers/github-teams.json @infinisil @llakala
|
||||
/maintainers/computed-team-list.nix @infinisil @llakala
|
||||
## Standard environment–related libraries
|
||||
/lib/customisation.nix @alyssais @NixOS/stdenv @llakala
|
||||
/lib/derivations.nix @NixOS/stdenv @llakala
|
||||
@@ -97,7 +97,8 @@ nixos/modules/installer/tools/nix-fallback-paths.nix @Artturin @Ericson2314 @lo
|
||||
# Contributor documentation
|
||||
/CONTRIBUTING.md
|
||||
/.github/PULL_REQUEST_TEMPLATE.md
|
||||
/doc/contributing.md @infinisil
|
||||
/doc/contributing/
|
||||
/doc/contributing/contributing-to-documentation.chapter.md @jtojnar
|
||||
/lib/README.md
|
||||
/doc/README.md
|
||||
/nixos/README.md
|
||||
@@ -105,6 +106,10 @@ nixos/modules/installer/tools/nix-fallback-paths.nix @Artturin @Ericson2314 @lo
|
||||
/pkgs/by-name/README.md
|
||||
/maintainers/README.md
|
||||
|
||||
# User-facing development documentation
|
||||
/doc/development.md @infinisil
|
||||
/doc/development @infinisil
|
||||
|
||||
# NixOS Internals
|
||||
/nixos/default.nix @infinisil
|
||||
/nixos/lib/from-env.nix @infinisil
|
||||
@@ -194,7 +199,7 @@ nixos/modules/installer/tools/nix-fallback-paths.nix @Artturin @Ericson2314 @lo
|
||||
/doc/languages-frameworks/haskell.section.md @sternenseemann @maralorn @wolfgangwalther
|
||||
/maintainers/scripts/haskell @sternenseemann @maralorn @wolfgangwalther
|
||||
/pkgs/development/compilers/ghc @sternenseemann @maralorn @wolfgangwalther
|
||||
/pkgs/development/compilers/ghc/9.6.6-debian-binary.nix @sternenseemann @maralorn @wolfgangwalther @OPNA2608 @liberodark @NixOS/loongarch64
|
||||
/pkgs/development/compilers/ghc/9.6.6-debian-binary.nix @sternenseemann @maralorn @wolfgangwalther @OPNA2608
|
||||
/pkgs/development/haskell-modules @sternenseemann @maralorn @wolfgangwalther
|
||||
/pkgs/test/haskell @sternenseemann @maralorn @wolfgangwalther
|
||||
/pkgs/top-level/release-haskell.nix @sternenseemann @maralorn @wolfgangwalther
|
||||
@@ -206,7 +211,7 @@ nixos/modules/installer/tools/nix-fallback-paths.nix @Artturin @Ericson2314 @lo
|
||||
/pkgs/development/perl-modules @stigtsp @marcusramberg
|
||||
|
||||
# R
|
||||
/pkgs/by-name/r/R @jbedo
|
||||
/pkgs/applications/science/math/R @jbedo
|
||||
/pkgs/development/r-modules @jbedo
|
||||
|
||||
# Rust
|
||||
@@ -258,15 +263,15 @@ pkgs/development/python-modules/buildcatrust/ @ajs124 @lukegb @mweinelt
|
||||
/lib/licenses @alyssais @emilazy @jopejoe1
|
||||
|
||||
# Qt
|
||||
/pkgs/development/libraries/qt-5 @NixOS/qt-kde
|
||||
/pkgs/development/libraries/qt-6 @NixOS/qt-kde
|
||||
/pkgs/development/libraries/qt-5 @K900 @NickCao @SuperSandro2000
|
||||
/pkgs/development/libraries/qt-6 @K900 @NickCao @SuperSandro2000
|
||||
|
||||
# KDE Frameworks 5
|
||||
/pkgs/development/libraries/kde-frameworks @NixOS/qt-kde
|
||||
/pkgs/development/libraries/kde-frameworks @K900 @NickCao @SuperSandro2000
|
||||
|
||||
# KDE / Plasma 6
|
||||
/pkgs/kde @NixOS/qt-kde
|
||||
/maintainers/scripts/kde @NixOS/qt-kde
|
||||
/pkgs/kde @K900 @NickCao @SuperSandro2000
|
||||
/maintainers/scripts/kde @K900 @NickCao @SuperSandro2000
|
||||
|
||||
# PostgreSQL and related stuff
|
||||
/pkgs/by-name/po/postgresqlTestHook @NixOS/postgres
|
||||
@@ -356,10 +361,7 @@ pkgs/development/python-modules/buildcatrust/ @ajs124 @lukegb @mweinelt
|
||||
/pkgs/applications/editors/kakoune @philiptaron
|
||||
|
||||
# LuaPackages
|
||||
/pkgs/development/interpreters/lua-5 @NixOS/lua
|
||||
/pkgs/development/interpreters/luajit @NixOS/lua
|
||||
/pkgs/development/lua-modules @NixOS/lua
|
||||
/pkgs/top-level/lua-packages.nix @NixOS/lua
|
||||
|
||||
# Neovim
|
||||
/pkgs/applications/editors/neovim @NixOS/neovim
|
||||
@@ -388,9 +390,9 @@ pkgs/development/python-modules/buildcatrust/ @ajs124 @lukegb @mweinelt
|
||||
/doc/build-helpers/images/dockertools.section.md @roberth @jhol
|
||||
|
||||
# Go
|
||||
/doc/languages-frameworks/go.section.md @kalbasit @Mic92
|
||||
/pkgs/build-support/go @kalbasit @Mic92
|
||||
/pkgs/development/compilers/go @kalbasit @Mic92
|
||||
/doc/languages-frameworks/go.section.md @kalbasit @katexochen @Mic92
|
||||
/pkgs/build-support/go @kalbasit @katexochen @Mic92
|
||||
/pkgs/development/compilers/go @kalbasit @katexochen @Mic92
|
||||
|
||||
# GNOME
|
||||
/pkgs/desktops/gnome @NixOS/gnome
|
||||
@@ -444,9 +446,9 @@ nixos/tests/forgejo.nix @adamcstephens @bendlas @christoph-heiss @
|
||||
/doc/hooks/zig.section.md @RossComputerGuy
|
||||
|
||||
# Buildbot
|
||||
nixos/modules/services/continuous-integration/buildbot @Mic92
|
||||
nixos/tests/buildbot.nix @Mic92
|
||||
pkgs/development/tools/continuous-integration/buildbot @Mic92
|
||||
nixos/modules/services/continuous-integration/buildbot @Mic92 @zowoq
|
||||
nixos/tests/buildbot.nix @Mic92 @zowoq
|
||||
pkgs/development/tools/continuous-integration/buildbot @Mic92 @zowoq
|
||||
|
||||
# Pretix
|
||||
pkgs/by-name/pr/pretix/ @mweinelt
|
||||
@@ -464,8 +466,9 @@ nixos/tests/incus/ @adamcstephens
|
||||
pkgs/by-name/in/incus/ @adamcstephens
|
||||
pkgs/by-name/lx/lxc* @adamcstephens
|
||||
|
||||
# Flutter
|
||||
# ExpidusOS, Flutter
|
||||
/pkgs/development/compilers/flutter @RossComputerGuy
|
||||
/pkgs/desktops/expidus @RossComputerGuy
|
||||
|
||||
# GNU Tar & Zip
|
||||
/pkgs/by-name/gn/gnutar @RossComputerGuy
|
||||
@@ -523,7 +526,3 @@ pkgs/by-name/wa/warp-terminal/ @emilytrau @imadnyc @4evy @johnrtitor
|
||||
/nixos/lib/testing @NixOS/test-driver
|
||||
/nixos/tests/nixos-test-driver @NixOS/test-driver
|
||||
/nixos/modules/virtualisation/nspawn-container/run-nspawn @NixOS/test-driver
|
||||
|
||||
# Boot security
|
||||
/pkgs/by-name/au/autopen @NixOS/boot-security
|
||||
/pkgs/misc/signed-packages @NixOS/boot-security
|
||||
|
||||
12
ci/README.md
12
ci/README.md
@@ -13,10 +13,10 @@ Run [`update-pinned.sh`](./update-pinned.sh) to update it.
|
||||
## GitHub specific code
|
||||
|
||||
Some of the code is specific to GitHub.
|
||||
This code is currently spread out over multiple places and written in both Bash and TypeScript.
|
||||
The goal is to eventually have all GitHub specific code in `ci/github-script` and written in TypeScript via `actions/github-script`.
|
||||
This code is currently spread out over multiple places and written in both Bash and JavaScript.
|
||||
The goal is to eventually have all GitHub specific code in `ci/github-script` and written in JavaScript via `actions/github-script`.
|
||||
A lot of code has already been migrated, but some Bash code still remains.
|
||||
New CI features need to be introduced in TypeScript, not Bash.
|
||||
New CI features need to be introduced in JavaScript, not Bash.
|
||||
|
||||
## Nixpkgs merge bot
|
||||
|
||||
@@ -24,7 +24,7 @@ The Nixpkgs merge bot empowers package maintainers by enabling them to merge PRs
|
||||
It serves as a bridge for maintainers to quickly respond to user feedback, facilitating a more self-reliant approach.
|
||||
Especially when considering there are roughly 20 maintainers for every committer, this bot is a game-changer.
|
||||
|
||||
Following [RFC 172], the merge bot was originally implemented as a [python webapp](https://github.com/NixOS/nixpkgs-merge-bot), which has now been integrated into [`ci/github-script/bot.ts`](./github-script/bot.ts) and [`ci/github-script/merge.ts`](./github-script/merge.ts).
|
||||
Following [RFC 172], the merge bot was originally implemented as a [python webapp](https://github.com/NixOS/nixpkgs-merge-bot), which has now been integrated into [`ci/github-script/bot.js`](./github-script/bot.js) and [`ci/github-script/merge.js`](./github-script/merge.js).
|
||||
|
||||
### Using the merge bot
|
||||
|
||||
@@ -48,7 +48,7 @@ To ensure security and a focused utility, the bot adheres to specific limitation
|
||||
- approved by a [committer][@NixOS/nixpkgs-committers].
|
||||
- backported via label.
|
||||
- opened by a [committer][@NixOS/nixpkgs-committers].
|
||||
- opened by [@r-ryantm](https://nixos.github.io/nixpkgs-update/r-ryantm/).
|
||||
- opened by [@r-ryantm](https://nix-community.github.io/nixpkgs-update/r-ryantm/).
|
||||
- The user attempting to merge is a member of [@NixOS/nixpkgs-maintainers].
|
||||
- The user attempting to merge is a maintainer of all packages touched by the PR.
|
||||
- No [committer][@NixOS/nixpkgs-committers] has an outstanding "changes requested" review.
|
||||
@@ -104,7 +104,7 @@ For the purposes of CI, branches in the NixOS/nixpkgs repository are classified
|
||||
|
||||
Some branches also have a version component, which is either `unstable` or `YY.MM`.
|
||||
|
||||
`ci/github-script/supportedBranches.ts` is a script imported by CI to classify the base and head branches of a Pull Request.
|
||||
`ci/supportedBranches.js` is a script imported by CI to classify the base and head branches of a Pull Request.
|
||||
This classification will then be used to skip certain jobs.
|
||||
This script can also be run locally to print basic test cases.
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{ lib, ... }:
|
||||
rec {
|
||||
inherit (lib) uniqueStrings;
|
||||
# Borrowed from https://github.com/NixOS/nixpkgs/pull/355616
|
||||
uniqueStrings = list: builtins.attrNames (builtins.groupBy lib.id list);
|
||||
|
||||
/*
|
||||
Converts a `packagePlatformPath` into a `packagePlatformAttr`
|
||||
|
||||
3
ci/github-script/.gitignore
vendored
3
ci/github-script/.gitignore
vendored
@@ -1,5 +1,2 @@
|
||||
comparison
|
||||
comparison.zip
|
||||
node_modules
|
||||
step-summary.md
|
||||
*.tsbuildinfo
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# GitHub specific CI scripts
|
||||
|
||||
This folder contains [`actions/github-script`](https://github.com/actions/github-script)-based TypeScript code.
|
||||
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:
|
||||
@@ -15,9 +15,3 @@ Run `./run commits OWNER REPO PR`, where OWNER is your username or "NixOS", REPO
|
||||
## Labeler
|
||||
|
||||
Run `./run labels OWNER REPO`, where OWNER is your username or "NixOS" and REPO the name of your fork or "nixpkgs".
|
||||
|
||||
## Reminders
|
||||
|
||||
Run `./run reminders OWNER REPO PR` to post the reminders that apply to the paths a pull request touches.
|
||||
|
||||
To add a reminder, write the review body to `reminders/KEY.md` and list KEY with its path regexes in `reminders.ts`.
|
||||
|
||||
835
ci/github-script/bot.js
Normal file
835
ci/github-script/bot.js
Normal file
@@ -0,0 +1,835 @@
|
||||
module.exports = async ({ github, context, core, dry }) => {
|
||||
const path = require('node:path')
|
||||
const { DefaultArtifactClient } = await import('@actions/artifact')
|
||||
const { readFile, writeFile } = require('node:fs/promises')
|
||||
const withRateLimit = require('./withRateLimit.js')
|
||||
const { classify } = require('../supportedBranches.js')
|
||||
const { handleMerge } = require('./merge.js')
|
||||
const { handleReviewers } = require('./reviewers.js')
|
||||
|
||||
const artifactClient = new DefaultArtifactClient()
|
||||
|
||||
// Detect if running in a fork (not NixOS/nixpkgs)
|
||||
const isFork = context.repo.owner !== 'NixOS'
|
||||
|
||||
const orgId = (
|
||||
await github.rest.orgs.get({
|
||||
org: context.repo.owner,
|
||||
})
|
||||
).data.id
|
||||
|
||||
async function downloadMaintainerMap(branch) {
|
||||
let run
|
||||
|
||||
const commits = (
|
||||
await github.rest.repos.listCommits({
|
||||
...context.repo,
|
||||
sha: branch,
|
||||
// We look at 10 commits to find a maintainer map, but this is an arbitrary number. The
|
||||
// head commit might not have a map, if the queue was bypassed to merge it. This happens
|
||||
// frequently on staging-esque branches. The branch with the highest chance of getting
|
||||
// 10 consecutive bypassing commits is the stable staging-next branch. Luckily, this
|
||||
// also means that the number of PRs open towards that branch is very low, so falling
|
||||
// back to slightly imprecise maintainer data from master only has a marginal effect.
|
||||
per_page: 10,
|
||||
})
|
||||
).data
|
||||
|
||||
for (const commit of commits) {
|
||||
const run = (
|
||||
await github.rest.actions.listWorkflowRuns({
|
||||
...context.repo,
|
||||
workflow_id: 'merge-group.yml',
|
||||
status: 'success',
|
||||
exclude_pull_requests: true,
|
||||
per_page: 1,
|
||||
head_sha: commit.sha,
|
||||
})
|
||||
).data.workflow_runs[0]
|
||||
if (!run) continue
|
||||
|
||||
const artifact = (
|
||||
await github.rest.actions.listWorkflowRunArtifacts({
|
||||
...context.repo,
|
||||
run_id: run.id,
|
||||
name: 'maintainers',
|
||||
})
|
||||
).data.artifacts[0]
|
||||
if (!artifact || artifact.expired) continue
|
||||
|
||||
await artifactClient.downloadArtifact(artifact.id, {
|
||||
findBy: {
|
||||
repositoryName: context.repo.repo,
|
||||
repositoryOwner: context.repo.owner,
|
||||
token: core.getInput('github-token'),
|
||||
},
|
||||
path: path.resolve(path.join('branches', branch)),
|
||||
expectedHash: artifact.digest,
|
||||
})
|
||||
|
||||
return JSON.parse(
|
||||
await readFile(
|
||||
path.resolve(path.join('branches', branch, 'maintainers.json')),
|
||||
'utf-8',
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// We get here when none of the 10 commits we looked at contained a maintainer map.
|
||||
// For the master branch, we don't have any fallback options, so we error out.
|
||||
// In forks without merge-group history, return empty map to allow testing.
|
||||
if (branch === 'master') {
|
||||
if (isFork) {
|
||||
core.warning(
|
||||
'No maintainer map found. Using empty map (expected in forks without merge-group history).',
|
||||
)
|
||||
return {}
|
||||
}
|
||||
throw new Error('No maintainer map found.')
|
||||
}
|
||||
|
||||
// For other branches, we select a suitable fallback below.
|
||||
const { stable, version } = classify(branch)
|
||||
|
||||
const release = `release-${version}`
|
||||
if (stable && branch !== release) {
|
||||
// Only fallback to the release branch from *other* stable branches.
|
||||
// Explicitly avoids infinite recursion.
|
||||
return await getMaintainerMap(release)
|
||||
} else {
|
||||
// Falling back to master as last resort.
|
||||
// This can either be the case for unstable staging-esque or wip branches,
|
||||
// or for the primary stable branch (release-XX.YY).
|
||||
return await getMaintainerMap('master')
|
||||
}
|
||||
}
|
||||
|
||||
// Simple cache for maintainer maps to avoid downloading the same artifacts
|
||||
// over and over again. Ultimately returns a promise, so the result must be
|
||||
// awaited for.
|
||||
const maintainerMaps = {}
|
||||
function getMaintainerMap(branch) {
|
||||
if (!maintainerMaps[branch]) {
|
||||
maintainerMaps[branch] = downloadMaintainerMap(branch)
|
||||
}
|
||||
return maintainerMaps[branch]
|
||||
}
|
||||
|
||||
// Caching the list of team members saves API requests when running the bot on the schedule and
|
||||
// processing many PRs at once.
|
||||
const members = {}
|
||||
function getTeamMembers(team_slug) {
|
||||
if (context.eventName === 'pull_request') {
|
||||
// We have no chance of getting a token in the pull_request context with the right
|
||||
// permissions to access the members endpoint below. Thus, we're pretending to have
|
||||
// no members. This is OK; because this is only for the Test workflow, not for
|
||||
// real use.
|
||||
return []
|
||||
}
|
||||
|
||||
// Forks don't have NixOS teams, return empty list
|
||||
if (isFork) {
|
||||
return []
|
||||
}
|
||||
|
||||
if (!members[team_slug]) {
|
||||
members[team_slug] = github.paginate(github.rest.teams.listMembersInOrg, {
|
||||
org: context.repo.owner,
|
||||
team_slug,
|
||||
per_page: 100,
|
||||
})
|
||||
}
|
||||
|
||||
return members[team_slug]
|
||||
}
|
||||
|
||||
// Caching users saves API requests when running the bot on the schedule and processing
|
||||
// many PRs at once. It also helps to encapsulate the special logic we need, because
|
||||
// actions/github doesn't support that endpoint fully, yet.
|
||||
const users = {}
|
||||
function getUser(id) {
|
||||
if (!users[id]) {
|
||||
users[id] = github
|
||||
.request({
|
||||
method: 'GET',
|
||||
url: '/user/{id}',
|
||||
id,
|
||||
})
|
||||
.then((resp) => resp.data)
|
||||
.catch((e) => {
|
||||
// User may have deleted their account
|
||||
if (e.status === 404) return null
|
||||
throw e
|
||||
})
|
||||
}
|
||||
|
||||
return users[id]
|
||||
}
|
||||
|
||||
// Same for teams
|
||||
const teams = {}
|
||||
function getTeam(id) {
|
||||
if (!teams[id]) {
|
||||
teams[id] = github
|
||||
.request({
|
||||
method: 'GET',
|
||||
url: '/organizations/{orgId}/team/{id}',
|
||||
orgId,
|
||||
id,
|
||||
})
|
||||
.then((resp) => resp.data)
|
||||
.catch((e) => {
|
||||
// Team may have been deleted
|
||||
if (e.status === 404) return null
|
||||
throw e
|
||||
})
|
||||
}
|
||||
|
||||
return teams[id]
|
||||
}
|
||||
|
||||
async function handlePullRequest({ item, stats, events }) {
|
||||
const log = (k, v) => core.info(`PR #${item.number} - ${k}: ${v}`)
|
||||
|
||||
const pull_number = item.number
|
||||
|
||||
// This API request is important for the merge-conflict label, because it triggers the
|
||||
// creation of a new test merge commit. This is needed to actually determine the state of a PR.
|
||||
const pull_request = (
|
||||
await github.rest.pulls.get({
|
||||
...context.repo,
|
||||
pull_number,
|
||||
})
|
||||
).data
|
||||
|
||||
log('author', pull_request.user?.login)
|
||||
|
||||
const maintainers = await getMaintainerMap(pull_request.base.ref)
|
||||
|
||||
// Check for any human reviews other than the PR author, GitHub actions and other GitHub apps.
|
||||
// `commit { oid }` is needed by handleMerge to verify approvals are against the current head.
|
||||
const reviews = (
|
||||
await github.graphql(
|
||||
`query($owner: String!, $repo: String!, $pr: Int!) {
|
||||
repository(owner: $owner, name: $repo) {
|
||||
pullRequest(number: $pr) {
|
||||
# Unlikely that there's ever more than 100 reviews, so let's not bother,
|
||||
# but once https://github.com/actions/github-script/issues/309 is resolved,
|
||||
# it would be easy to enable pagination.
|
||||
reviews(first: 100) {
|
||||
nodes {
|
||||
state
|
||||
commit { oid }
|
||||
user: author {
|
||||
# Only get users, no bots
|
||||
... on User {
|
||||
login
|
||||
# Set the id field in the resulting JSON to GraphQL's databaseId
|
||||
# databaseId in GraphQL-land is the same as id in REST-land
|
||||
id: databaseId
|
||||
}
|
||||
}
|
||||
onBehalfOf(first: 100) {
|
||||
nodes {
|
||||
slug
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
{
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pr: pull_number,
|
||||
},
|
||||
)
|
||||
).repository.pullRequest.reviews.nodes.filter(
|
||||
(r) =>
|
||||
// The `... on User` makes it such that .login only exists for users,
|
||||
// but we still need to filter the others out.
|
||||
// Accounts could be deleted as well, so don't count them.
|
||||
r.user?.login &&
|
||||
// Also exclude author reviews, can't request their review in any case
|
||||
r.user.id !== pull_request.user?.id,
|
||||
)
|
||||
|
||||
const merge_bot_eligible = await handleMerge({
|
||||
github,
|
||||
context,
|
||||
core,
|
||||
log,
|
||||
dry,
|
||||
pull_request,
|
||||
events,
|
||||
reviews,
|
||||
maintainers,
|
||||
getTeamMembers,
|
||||
getUser,
|
||||
})
|
||||
|
||||
const approvals = new Set(
|
||||
reviews
|
||||
.filter((review) => review.state === 'APPROVED')
|
||||
.map((review) => review.user?.id),
|
||||
)
|
||||
|
||||
// After creation of a Pull Request, `merge_commit_sha` will be null initially:
|
||||
// The very first merge commit will only be calculated after a little while.
|
||||
// To avoid labeling the PR as conflicted before that, we wait a few minutes.
|
||||
// This is intentionally less than the time that Eval takes, so that the label job
|
||||
// running after Eval can indeed label the PR as conflicted if that is the case.
|
||||
const merge_commit_sha_valid =
|
||||
Date.now() - new Date(pull_request.created_at) > 3 * 60 * 1000
|
||||
|
||||
const prLabels = {
|
||||
// We intentionally don't use the mergeable or mergeable_state attributes.
|
||||
// Those have an intermediate state while the test merge commit is created.
|
||||
// This doesn't work well for us, because we might have just triggered another
|
||||
// test merge commit creation by request the pull request via API at the start
|
||||
// of this function.
|
||||
// The attribute merge_commit_sha keeps the old value of null or the hash *until*
|
||||
// the new test merge commit has either successfully been created or failed so.
|
||||
// This essentially means we are updating the merge conflict label in two steps:
|
||||
// On the first pass of the day, we just fetch the pull request, which triggers
|
||||
// the creation. At this stage, the label is likely not updated, yet.
|
||||
// The second pass will then read the result from the first pass and set the label.
|
||||
'2.status: merge conflict':
|
||||
merge_commit_sha_valid && !pull_request.merge_commit_sha,
|
||||
'2.status: merge-bot eligible': merge_bot_eligible,
|
||||
'12.approvals: 1': approvals.size === 1,
|
||||
'12.approvals: 2': approvals.size === 2,
|
||||
'12.approvals: 3+': approvals.size >= 3,
|
||||
'12.first-time contribution': [
|
||||
'NONE',
|
||||
'FIRST_TIMER',
|
||||
'FIRST_TIME_CONTRIBUTOR',
|
||||
].includes(pull_request.author_association),
|
||||
}
|
||||
|
||||
const { id: run_id, conclusion } =
|
||||
(
|
||||
await github.rest.actions.listWorkflowRuns({
|
||||
...context.repo,
|
||||
workflow_id: 'pull-request-target.yml',
|
||||
event: 'pull_request_target',
|
||||
exclude_pull_requests: true,
|
||||
head_sha: pull_request.head.sha,
|
||||
})
|
||||
).data.workflow_runs[0] ??
|
||||
// TODO: Remove this after 2026-02-01, at which point all pr.yml artifacts will have expired.
|
||||
(
|
||||
await github.rest.actions.listWorkflowRuns({
|
||||
...context.repo,
|
||||
// In older PRs, we need pr.yml instead of pull-request-target.yml.
|
||||
workflow_id: 'pr.yml',
|
||||
event: 'pull_request_target',
|
||||
exclude_pull_requests: true,
|
||||
head_sha: pull_request.head.sha,
|
||||
})
|
||||
).data.workflow_runs[0] ??
|
||||
{}
|
||||
|
||||
// Newer PRs might not have run Eval to completion, yet.
|
||||
// Older PRs might not have an eval.yml workflow, yet.
|
||||
// In either case we continue without fetching an artifact on a best-effort basis.
|
||||
log('Last eval run', run_id ?? '<n/a>')
|
||||
|
||||
if (conclusion === 'success') {
|
||||
Object.assign(prLabels, {
|
||||
// We only set this label if the latest eval run was successful, because if it was not, it
|
||||
// *could* have requested reviewers. We will let the PR author fix CI first, before "escalating"
|
||||
// this PR to "needs: reviewer".
|
||||
// Since the first Eval run on a PR always sets rebuild labels, the same PR will be "recently
|
||||
// updated" for the next scheduled run. Thus, this label will still be set within a few minutes
|
||||
// after a PR is created, if required.
|
||||
// Note that a "requested reviewer" disappears once they have given a review, so we check
|
||||
// existing reviews, too.
|
||||
'9.needs: reviewer':
|
||||
!pull_request.draft &&
|
||||
pull_request.requested_reviewers.length === 0 &&
|
||||
reviews.length === 0,
|
||||
})
|
||||
}
|
||||
|
||||
const artifact =
|
||||
run_id &&
|
||||
(
|
||||
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 skip the download.
|
||||
const expired =
|
||||
!artifact ||
|
||||
new Date(artifact?.expires_at ?? 0) < new Date(Date.now() + 60 * 1000)
|
||||
log('Artifact expires at', artifact?.expires_at ?? '<n/a>')
|
||||
if (!expired) {
|
||||
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_number.toString()),
|
||||
expectedHash: artifact.digest,
|
||||
})
|
||||
|
||||
const changedPaths = JSON.parse(
|
||||
await readFile(`${pull_number}/changed-paths.json`, 'utf-8'),
|
||||
)
|
||||
const evalLabels = changedPaths.labels
|
||||
|
||||
// Fetch all PR commits to check their messages for package patterns
|
||||
const prCommits = await github.paginate(github.rest.pulls.listCommits, {
|
||||
...context.repo,
|
||||
pull_number,
|
||||
per_page: 100,
|
||||
})
|
||||
|
||||
// label llm-assisted PRs accordingly
|
||||
const assistedByPattern = /Assisted-by: (?!nix-init)/i
|
||||
evalLabels['llm-assisted'] = prCommits.some((c) =>
|
||||
assistedByPattern.test(c.commit.message),
|
||||
)
|
||||
|
||||
const commitSubjects = prCommits.map(
|
||||
(c) => c.commit.message.split('\n')[0],
|
||||
)
|
||||
|
||||
// Label new package PRs: "packagename: init at X.Y.Z"
|
||||
// Exclude NixOS module commits like "nixos/timekpr: init at 0.5.8"
|
||||
const newPackagePattern = /^(?<!nixos\/)\S+: init at\b/
|
||||
const hasNewPackages = changedPaths.attrdiff?.added?.length > 0
|
||||
const commitsIndicateNewPackage = commitSubjects.some((msg) =>
|
||||
newPackagePattern.test(msg),
|
||||
)
|
||||
evalLabels['8.has: package (new)'] =
|
||||
hasNewPackages && commitsIndicateNewPackage
|
||||
|
||||
// Label package update PRs: "packagename: X.Y.Z -> A.B.C"
|
||||
// Matches versions like: 1.2.3, 0-unstable-2024-01-15, 1.3rc1, alpha, unstable
|
||||
// Exclude NixOS module commits like "nixos/ncps: types.str -> types.path"
|
||||
const updatePackagePattern =
|
||||
/^(?<!nixos\/)\S+: [\w.-]*\d[\w.-]* (->|→) [\w.-]*\d[\w.-]*$/
|
||||
const commitsIndicateUpdate = commitSubjects.some((msg) =>
|
||||
updatePackagePattern.test(msg),
|
||||
)
|
||||
evalLabels['8.has: package (update)'] = commitsIndicateUpdate
|
||||
|
||||
// TODO: Get "changed packages" information from list of changed by-name files
|
||||
// in addition to just the Eval results, to make this work for these packages
|
||||
// when Eval results have expired as well.
|
||||
let packages
|
||||
try {
|
||||
packages = JSON.parse(
|
||||
await readFile(`${pull_number}/packages.json`, 'utf-8'),
|
||||
)
|
||||
} catch (e) {
|
||||
if (e.code !== 'ENOENT') throw e
|
||||
// TODO: Remove this fallback code once all old artifacts without packages.json
|
||||
// have expired. This should be the case in ~ February 2026.
|
||||
packages = Array.from(
|
||||
new Set(
|
||||
Object.values(
|
||||
JSON.parse(
|
||||
await readFile(`${pull_number}/maintainers.json`, 'utf-8'),
|
||||
),
|
||||
).flat(1),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
Object.assign(prLabels, evalLabels, {
|
||||
'11.by: package-maintainer':
|
||||
Boolean(packages.length) &&
|
||||
packages.every((pkg) =>
|
||||
maintainers[pkg]?.includes(pull_request.user.id),
|
||||
),
|
||||
'12.approved-by: package-maintainer': packages.some((pkg) =>
|
||||
maintainers[pkg]?.some((m) => approvals.has(m)),
|
||||
),
|
||||
})
|
||||
|
||||
if (!pull_request.draft) {
|
||||
let owners = []
|
||||
try {
|
||||
// TODO: Create owner map similar to maintainer map.
|
||||
owners = (await readFile(`${pull_number}/owners.txt`, 'utf-8')).split(
|
||||
'\n',
|
||||
)
|
||||
} catch (e) {
|
||||
// Older artifacts don't have the owners.txt, yet.
|
||||
if (e.code !== 'ENOENT') throw e
|
||||
}
|
||||
|
||||
let team_maintainers = []
|
||||
try {
|
||||
team_maintainers = Object.keys(
|
||||
JSON.parse(await readFile(`${pull_number}/teams.json`, 'utf-8')),
|
||||
).map((id) => parseInt(id))
|
||||
} catch (e) {
|
||||
// Older artifacts don't have the teams.json, yet.
|
||||
if (e.code !== 'ENOENT') throw e
|
||||
}
|
||||
|
||||
// We set this label earlier already, but the current PR state can be very different
|
||||
// after handleReviewers has requested reviews, so update it in this case to prevent
|
||||
// this label from flip-flopping.
|
||||
prLabels['9.needs: reviewer'] = await handleReviewers({
|
||||
github,
|
||||
context,
|
||||
core,
|
||||
log,
|
||||
dry,
|
||||
pull_request,
|
||||
reviews,
|
||||
// TODO: Use maintainer map instead of the artifact.
|
||||
user_maintainers: Object.keys(
|
||||
JSON.parse(
|
||||
await readFile(`${pull_number}/maintainers.json`, 'utf-8'),
|
||||
),
|
||||
).map((id) => parseInt(id)),
|
||||
team_maintainers,
|
||||
owners,
|
||||
getUser,
|
||||
getTeam,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return prLabels
|
||||
}
|
||||
|
||||
// Returns true if the issue was closed. In this case, the labeling does not need to
|
||||
// continue for this issue. Returns false if no action was taken.
|
||||
async function handleAutoClose(item) {
|
||||
const issue_number = item.number
|
||||
|
||||
if (item.labels.some(({ name }) => name === '0.kind: packaging request')) {
|
||||
const body = [
|
||||
'Thank you for your interest in packaging new software in Nixpkgs. Unfortunately, to mitigate the unsustainable growth of unmaintained packages, **Nixpkgs is no longer accepting package requests** via Issues.',
|
||||
'',
|
||||
'As a [volunteer community][community], we are always open to new contributors. If you wish to see this package in Nixpkgs, **we encourage you to [contribute] it yourself**, via a Pull Request. Anyone can [become a package maintainer][maintainers]! You can find language-specific packaging information in the [Nixpkgs Manual][nixpkgs]. Should you need any help, please reach out to the community on [Matrix] or [Discourse].',
|
||||
'',
|
||||
'[community]: https://nixos.org/community',
|
||||
'[contribute]: https://github.com/NixOS/nixpkgs/blob/master/pkgs/README.md#quick-start-to-adding-a-package',
|
||||
'[maintainers]: https://github.com/NixOS/nixpkgs/blob/master/maintainers/README.md',
|
||||
'[nixpkgs]: https://nixos.org/manual/nixpkgs/unstable/',
|
||||
'[Matrix]: https://matrix.to/#/#dev:nixos.org',
|
||||
'[Discourse]: https://discourse.nixos.org/c/dev/14',
|
||||
].join('\n')
|
||||
|
||||
core.info(`Issue #${item.number}: auto-closed`)
|
||||
|
||||
if (!dry) {
|
||||
await github.rest.issues.createComment({
|
||||
...context.repo,
|
||||
issue_number,
|
||||
body,
|
||||
})
|
||||
|
||||
await github.rest.issues.update({
|
||||
...context.repo,
|
||||
issue_number,
|
||||
state: 'closed',
|
||||
state_reason: 'not_planned',
|
||||
})
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
async function handle({ item, stats }) {
|
||||
try {
|
||||
const log = (k, v, skip) => {
|
||||
core.info(`#${item.number} - ${k}: ${v}${skip ? ' (skipped)' : ''}`)
|
||||
return skip
|
||||
}
|
||||
|
||||
log('Last updated at', item.updated_at)
|
||||
log('URL', item.html_url)
|
||||
|
||||
const issue_number = item.number
|
||||
|
||||
const itemLabels = {}
|
||||
|
||||
const events = await github.paginate(
|
||||
github.rest.issues.listEventsForTimeline,
|
||||
{
|
||||
...context.repo,
|
||||
issue_number,
|
||||
per_page: 100,
|
||||
},
|
||||
)
|
||||
|
||||
const latest_event_at = new Date(
|
||||
events
|
||||
.filter(({ event }) =>
|
||||
[
|
||||
// These events are hand-picked from:
|
||||
// https://docs.github.com/en/rest/using-the-rest-api/issue-event-types?apiVersion=2022-11-28
|
||||
// Each of those causes a PR/issue to *not* be considered as stale anymore.
|
||||
// Most of these use created_at.
|
||||
'assigned',
|
||||
'commented', // uses updated_at, because that could be > created_at
|
||||
'committed', // uses committer.date
|
||||
...(item.labels.some(({ name }) => name === '5.scope: tracking')
|
||||
? ['cross-referenced']
|
||||
: []),
|
||||
'head_ref_force_pushed',
|
||||
'milestoned',
|
||||
'pinned',
|
||||
'ready_for_review',
|
||||
'renamed',
|
||||
'reopened',
|
||||
'review_dismissed',
|
||||
'review_requested',
|
||||
'reviewed', // uses submitted_at
|
||||
'unlocked',
|
||||
'unmarked_as_duplicate',
|
||||
].includes(event),
|
||||
)
|
||||
.map(
|
||||
({ created_at, updated_at, committer, submitted_at }) =>
|
||||
new Date(
|
||||
updated_at ?? created_at ?? submitted_at ?? committer.date,
|
||||
),
|
||||
)
|
||||
// Reverse sort by date value. The default sort() sorts by string representation, which is bad for dates.
|
||||
.sort((a, b) => b - a)
|
||||
.at(0) ?? item.created_at,
|
||||
)
|
||||
log('latest_event_at', latest_event_at.toISOString())
|
||||
|
||||
const stale_at = new Date(new Date().setDate(new Date().getDate() - 180))
|
||||
const is_stale = latest_event_at < stale_at
|
||||
|
||||
if (item.pull_request || context.payload.pull_request) {
|
||||
// No need to compute merge commits for stale PRs over and over again.
|
||||
// This increases the repo size on GitHub's side unnecessarily and wastes
|
||||
// a lot of API requests, too. Any relevant change will result in the
|
||||
// stale status to change and thus pick up the PR again for labeling.
|
||||
if (!is_stale) {
|
||||
stats.prs++
|
||||
Object.assign(
|
||||
itemLabels,
|
||||
await handlePullRequest({ item, stats, events }),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
stats.issues++
|
||||
if (item.labels.some(({ name }) => name === '4.workflow: auto-close')) {
|
||||
// If this returns true, the issue was closed. In this case we return, to not
|
||||
// label the issue anymore. Most importantly this avoids unlabeling stale issues
|
||||
// which are closed via auto-close.
|
||||
if (await handleAutoClose(item)) return
|
||||
}
|
||||
}
|
||||
|
||||
// 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,
|
||||
})
|
||||
).map(({ name }) => [name, true]),
|
||||
)
|
||||
|
||||
Object.assign(itemLabels, {
|
||||
'2.status: stale': !before['1.severity: security'] && is_stale,
|
||||
})
|
||||
|
||||
const after = Object.assign({}, before, itemLabels)
|
||||
|
||||
// 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 label 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, dry)) return
|
||||
|
||||
await github.rest.issues.setLabels({
|
||||
...context.repo,
|
||||
issue_number,
|
||||
labels,
|
||||
})
|
||||
} catch (cause) {
|
||||
throw new Error(`Labeling #${item.number} failed.`, { cause })
|
||||
}
|
||||
}
|
||||
|
||||
// Controls level of parallelism. Applies to both the number of concurrent requests
|
||||
// as well as the number of concurrent workers going through the list of PRs.
|
||||
// We'll only boost concurrency when we're running many PRs in parallel on a schedule,
|
||||
// but not for single PRs. This avoids things going wild, when we accidentally make
|
||||
// too many API requests on treewides.
|
||||
const maxConcurrent = context.payload.pull_request ? 1 : 20
|
||||
|
||||
await withRateLimit({ github, core, maxConcurrent }, async (stats) => {
|
||||
if (context.payload.pull_request) {
|
||||
await handle({ item: context.payload.pull_request, stats })
|
||||
} else {
|
||||
const lastRun = (
|
||||
await github.rest.actions.listWorkflowRuns({
|
||||
...context.repo,
|
||||
workflow_id: 'bot.yml',
|
||||
event: 'schedule',
|
||||
status: 'success',
|
||||
exclude_pull_requests: true,
|
||||
per_page: 1,
|
||||
})
|
||||
).data.workflow_runs[0]
|
||||
|
||||
const cutoff = new Date(
|
||||
Math.max(
|
||||
// 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.
|
||||
new Date(
|
||||
lastRun?.created_at ?? Date.now() - 1 * 60 * 60 * 1000,
|
||||
).getTime(),
|
||||
// Go back max. 1 day to prevent hitting all API rate limits immediately,
|
||||
// when GH API returns a wrong workflow by accident.
|
||||
Date.now() - 24 * 60 * 60 * 1000,
|
||||
),
|
||||
)
|
||||
core.info(`cutoff timestamp: ${cutoff.toISOString()}`)
|
||||
|
||||
const updatedItems = await github.paginate(
|
||||
github.rest.search.issuesAndPullRequests,
|
||||
{
|
||||
q: [
|
||||
`repo:"${context.repo.owner}/${context.repo.repo}"`,
|
||||
'is:open',
|
||||
`updated:>=${cutoff.toISOString()}`,
|
||||
].join(' AND '),
|
||||
per_page: 100,
|
||||
// TODO: Remove after 2025-11-04, when it becomes the default.
|
||||
advanced_search: true,
|
||||
},
|
||||
)
|
||||
|
||||
let cursor
|
||||
|
||||
// No workflow run available the first time.
|
||||
if (lastRun) {
|
||||
// The cursor to iterate through the full list of issues and pull requests
|
||||
// is passed between jobs as an artifact.
|
||||
const artifact = (
|
||||
await github.rest.actions.listWorkflowRunArtifacts({
|
||||
...context.repo,
|
||||
run_id: lastRun.id,
|
||||
name: 'pagination-cursor',
|
||||
})
|
||||
).data.artifacts[0]
|
||||
|
||||
// If the artifact is not available, the next iteration starts at the beginning.
|
||||
if (artifact && !artifact.expired) {
|
||||
stats.artifacts++
|
||||
|
||||
const { downloadPath } = await artifactClient.downloadArtifact(
|
||||
artifact.id,
|
||||
{
|
||||
findBy: {
|
||||
repositoryName: context.repo.repo,
|
||||
repositoryOwner: context.repo.owner,
|
||||
token: core.getInput('github-token'),
|
||||
},
|
||||
expectedHash: artifact.digest,
|
||||
},
|
||||
)
|
||||
|
||||
cursor = await readFile(path.resolve(downloadPath, 'cursor'), 'utf-8')
|
||||
}
|
||||
}
|
||||
|
||||
// From GitHub's API docs:
|
||||
// GitHub's REST API considers every pull request an issue, but not every issue is a pull request.
|
||||
// For this reason, "Issues" endpoints may return both issues and pull requests in the response.
|
||||
// You can identify pull requests by the pull_request key.
|
||||
const allItems = await github.rest.issues.listForRepo({
|
||||
...context.repo,
|
||||
state: 'open',
|
||||
sort: 'created',
|
||||
direction: 'asc',
|
||||
per_page: 100,
|
||||
after: cursor,
|
||||
})
|
||||
|
||||
// Regex taken and comment adjusted from:
|
||||
// https://github.com/octokit/plugin-paginate-rest.js/blob/8e5da25f975d2f31dda6b8b588d71f2c768a8df2/src/iterator.ts#L36-L41
|
||||
// `allItems.headers.link` format:
|
||||
// <https://api.github.com/repositories/4542716/issues?page=3&per_page=100&after=Y3Vyc29yOnYyOpLPAAABl8qNnYDOvnSJxA%3D%3D>; rel="next",
|
||||
// <https://api.github.com/repositories/4542716/issues?page=1&per_page=100&before=Y3Vyc29yOnYyOpLPAAABl8xFV9DOvoouJg%3D%3D>; rel="prev"
|
||||
// Sets `next` to undefined if "next" URL is not present or `link` header is not set.
|
||||
const next = ((allItems.headers.link ?? '').match(
|
||||
/<([^<>]+)>;\s*rel="next"/,
|
||||
) ?? [])[1]
|
||||
if (next) {
|
||||
cursor = new URL(next).searchParams.get('after')
|
||||
const uploadPath = path.resolve('cursor')
|
||||
await writeFile(uploadPath, cursor, 'utf-8')
|
||||
if (dry) {
|
||||
core.info(`pagination-cursor: ${cursor} (upload skipped)`)
|
||||
} else {
|
||||
// No stats.artifacts++, because this does not allow passing a custom token.
|
||||
// Thus, the upload will not happen with the app token, but the default github.token.
|
||||
await artifactClient.uploadArtifact(
|
||||
'pagination-cursor',
|
||||
[uploadPath],
|
||||
path.resolve('.'),
|
||||
{
|
||||
retentionDays: 1,
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Some items might be in both search results, so filtering out duplicates as well.
|
||||
const items = []
|
||||
.concat(updatedItems, allItems.data)
|
||||
.filter(
|
||||
(thisItem, idx, arr) =>
|
||||
idx ===
|
||||
arr.findIndex((firstItem) => firstItem.number === thisItem.number),
|
||||
)
|
||||
|
||||
// Instead of handling all items in parallel we set up some workers to handle the queue
|
||||
// with more controlled parallelism. This avoids problems with `pull_request` fetched at
|
||||
// the beginning getting out of date towards the end, because it took the whole job 20
|
||||
// minutes or more to go through 100's of PRs.
|
||||
await Promise.all(
|
||||
Array.from({ length: maxConcurrent }, async () => {
|
||||
while (true) {
|
||||
const item = items.pop()
|
||||
if (!item) break
|
||||
try {
|
||||
await handle({ item, stats })
|
||||
} catch (e) {
|
||||
core.setFailed(`${e.message}\n${e.cause.stack}`)
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,914 +0,0 @@
|
||||
import { readFile, writeFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { DefaultArtifactClient } from '@actions/artifact'
|
||||
import {
|
||||
handleMerge,
|
||||
type Maintainers,
|
||||
type MergeProps,
|
||||
type Review,
|
||||
type TimelineEvent,
|
||||
type User,
|
||||
} from './merge.ts'
|
||||
import { handleReviewers } from './reviewers.ts'
|
||||
import { classify } from './supportedBranches.ts'
|
||||
import withRateLimit, { type Stats } from './withRateLimit.ts'
|
||||
|
||||
type GitHub = MergeProps['github']
|
||||
type Item = Pick<
|
||||
Awaited<ReturnType<GitHub['rest']['issues']['listForRepo']>>['data'][number],
|
||||
'number' | 'updated_at' | 'created_at' | 'html_url' | 'pull_request'
|
||||
> & { labels: ({ name?: string } | string)[] }
|
||||
type ReviewNode = Omit<Review, 'user'> & { user: Partial<User> | null }
|
||||
interface ChangedPaths {
|
||||
labels: Record<string, boolean>
|
||||
attrdiff?: { added?: string[] }
|
||||
}
|
||||
|
||||
export default async ({
|
||||
github,
|
||||
context,
|
||||
core,
|
||||
dry,
|
||||
}: Pick<MergeProps, 'github' | 'context' | 'core' | 'dry'>) => {
|
||||
const artifactClient = new DefaultArtifactClient()
|
||||
|
||||
// Detect if running in a fork (not NixOS/nixpkgs)
|
||||
const isFork = context.repo.owner !== 'NixOS'
|
||||
|
||||
const orgId = (
|
||||
await github.rest.orgs.get({
|
||||
org: context.repo.owner,
|
||||
})
|
||||
).data.id
|
||||
|
||||
async function downloadMaintainerMap(branch: string): Promise<Maintainers> {
|
||||
const commits = (
|
||||
await github.rest.repos.listCommits({
|
||||
...context.repo,
|
||||
sha: branch,
|
||||
// We look at 10 commits to find a maintainer map, but this is an arbitrary number. The
|
||||
// head commit might not have a map, if the queue was bypassed to merge it. This happens
|
||||
// frequently on staging-esque branches. The branch with the highest chance of getting
|
||||
// 10 consecutive bypassing commits is the stable staging-next branch. Luckily, this
|
||||
// also means that the number of PRs open towards that branch is very low, so falling
|
||||
// back to slightly imprecise maintainer data from master only has a marginal effect.
|
||||
per_page: 10,
|
||||
})
|
||||
).data
|
||||
|
||||
for (const commit of commits) {
|
||||
const run = (
|
||||
await github.rest.actions.listWorkflowRuns({
|
||||
...context.repo,
|
||||
workflow_id: 'merge-group.yml',
|
||||
status: 'success',
|
||||
exclude_pull_requests: true,
|
||||
per_page: 1,
|
||||
head_sha: commit.sha,
|
||||
})
|
||||
).data.workflow_runs[0]
|
||||
if (!run) continue
|
||||
|
||||
const artifact = (
|
||||
await github.rest.actions.listWorkflowRunArtifacts({
|
||||
...context.repo,
|
||||
run_id: run.id,
|
||||
name: 'maintainers',
|
||||
})
|
||||
).data.artifacts[0]
|
||||
if (!artifact || artifact.expired) continue
|
||||
|
||||
await artifactClient.downloadArtifact(artifact.id, {
|
||||
findBy: {
|
||||
workflowRunId: run.id,
|
||||
repositoryName: context.repo.repo,
|
||||
repositoryOwner: context.repo.owner,
|
||||
token: core.getInput('github-token'),
|
||||
},
|
||||
path: path.resolve(path.join('branches', branch)),
|
||||
expectedHash: artifact.digest ?? undefined,
|
||||
})
|
||||
|
||||
return JSON.parse(
|
||||
await readFile(
|
||||
path.resolve(path.join('branches', branch, 'maintainers.json')),
|
||||
'utf-8',
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// We get here when none of the 10 commits we looked at contained a maintainer map.
|
||||
// For the master branch, we don't have any fallback options, so we error out.
|
||||
// In forks without merge-group history, return empty map to allow testing.
|
||||
if (branch === 'master') {
|
||||
if (isFork) {
|
||||
core.warning(
|
||||
'No maintainer map found. Using empty map (expected in forks without merge-group history).',
|
||||
)
|
||||
return {}
|
||||
}
|
||||
throw new Error('No maintainer map found.')
|
||||
}
|
||||
|
||||
// For other branches, we select a suitable fallback below.
|
||||
const { stable, version } = classify(branch)
|
||||
|
||||
const release = `release-${version}`
|
||||
if (stable && branch !== release) {
|
||||
// Only fallback to the release branch from *other* stable branches.
|
||||
// Explicitly avoids infinite recursion.
|
||||
return await getMaintainerMap(release)
|
||||
} else {
|
||||
// Falling back to master as last resort.
|
||||
// This can either be the case for unstable staging-esque or wip branches,
|
||||
// or for the primary stable branch (release-XX.YY).
|
||||
return await getMaintainerMap('master')
|
||||
}
|
||||
}
|
||||
|
||||
// Simple cache for maintainer maps to avoid downloading the same artifacts
|
||||
// over and over again. Ultimately returns a promise, so the result must be
|
||||
// awaited for.
|
||||
const maintainerMaps: Record<string, Promise<Maintainers>> = {}
|
||||
function getMaintainerMap(branch: string): Promise<Maintainers> {
|
||||
if (!maintainerMaps[branch]) {
|
||||
maintainerMaps[branch] = downloadMaintainerMap(branch)
|
||||
}
|
||||
return maintainerMaps[branch]
|
||||
}
|
||||
|
||||
// Caching the list of team members saves API requests when running the bot on the schedule and
|
||||
// processing many PRs at once.
|
||||
const members: Record<string, Promise<User[]>> = {}
|
||||
function getTeamMembers(team_slug: string) {
|
||||
if (context.eventName === 'pull_request') {
|
||||
// We have no chance of getting a token in the pull_request context with the right
|
||||
// permissions to access the members endpoint below. Thus, we're pretending to have
|
||||
// no members. This is OK; because this is only for the Test workflow, not for
|
||||
// real use.
|
||||
return []
|
||||
}
|
||||
|
||||
// Forks don't have NixOS teams, return empty list
|
||||
if (isFork) {
|
||||
return []
|
||||
}
|
||||
|
||||
if (!members[team_slug]) {
|
||||
members[team_slug] = github.paginate(github.rest.teams.listMembersInOrg, {
|
||||
org: context.repo.owner,
|
||||
team_slug,
|
||||
per_page: 100,
|
||||
})
|
||||
}
|
||||
|
||||
return members[team_slug]
|
||||
}
|
||||
|
||||
// Caching users saves API requests when running the bot on the schedule and processing
|
||||
// many PRs at once. It also helps to encapsulate the special logic we need, because
|
||||
// actions/github doesn't support that endpoint fully, yet.
|
||||
const users: Record<number, Promise<User | null>> = {}
|
||||
function getUser(id: number) {
|
||||
if (!users[id]) {
|
||||
users[id] = github
|
||||
.request<User>({
|
||||
method: 'GET',
|
||||
url: '/user/{id}',
|
||||
id,
|
||||
})
|
||||
.then((resp) => resp.data)
|
||||
.catch((e) => {
|
||||
// User may have deleted their account
|
||||
if (e.status === 404) return null
|
||||
throw e
|
||||
})
|
||||
}
|
||||
|
||||
return users[id]
|
||||
}
|
||||
|
||||
// Same for teams
|
||||
const teams: Record<number, Promise<{ slug: string } | null>> = {}
|
||||
function getTeam(id: number) {
|
||||
if (!teams[id]) {
|
||||
teams[id] = github
|
||||
.request<{ slug: string }>({
|
||||
method: 'GET',
|
||||
url: '/organizations/{orgId}/team/{id}',
|
||||
orgId,
|
||||
id,
|
||||
})
|
||||
.then((resp) => resp.data)
|
||||
.catch((e) => {
|
||||
// Team may have been deleted
|
||||
if (e.status === 404) return null
|
||||
throw e
|
||||
})
|
||||
}
|
||||
|
||||
return teams[id]
|
||||
}
|
||||
|
||||
async function handlePullRequest({
|
||||
item,
|
||||
stats,
|
||||
events,
|
||||
}: {
|
||||
item: Item
|
||||
stats: Stats
|
||||
events: TimelineEvent[]
|
||||
}) {
|
||||
const log = (k: string, v: unknown) =>
|
||||
core.info(`PR #${item.number} - ${k}: ${v}`)
|
||||
|
||||
const pull_number = item.number
|
||||
|
||||
// This API request is important for the merge-conflict label, because it triggers the
|
||||
// creation of a new test merge commit. This is needed to actually determine the state of a PR.
|
||||
const pull_request = (
|
||||
await github.rest.pulls.get({
|
||||
...context.repo,
|
||||
pull_number,
|
||||
})
|
||||
).data
|
||||
|
||||
log('author', pull_request.user?.login)
|
||||
|
||||
const maintainers = await getMaintainerMap(pull_request.base.ref)
|
||||
|
||||
// Check for any human reviews other than the PR author, GitHub actions and other GitHub apps.
|
||||
// `commit { oid }` is needed by handleMerge to verify approvals are against the current head.
|
||||
const reviews = (
|
||||
await github.graphql<{
|
||||
repository: { pullRequest: { reviews: { nodes: ReviewNode[] } } }
|
||||
}>(
|
||||
`query($owner: String!, $repo: String!, $pr: Int!) {
|
||||
repository(owner: $owner, name: $repo) {
|
||||
pullRequest(number: $pr) {
|
||||
# Unlikely that there's ever more than 100 reviews, so let's not bother,
|
||||
# but once https://github.com/actions/github-script/issues/309 is resolved,
|
||||
# it would be easy to enable pagination.
|
||||
reviews(first: 100) {
|
||||
nodes {
|
||||
state
|
||||
commit { oid }
|
||||
user: author {
|
||||
# Only get users, no bots
|
||||
... on User {
|
||||
login
|
||||
# Set the id field in the resulting JSON to GraphQL's databaseId
|
||||
# databaseId in GraphQL-land is the same as id in REST-land
|
||||
id: databaseId
|
||||
}
|
||||
}
|
||||
onBehalfOf(first: 100) {
|
||||
nodes {
|
||||
slug
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
{
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pr: pull_number,
|
||||
},
|
||||
)
|
||||
).repository.pullRequest.reviews.nodes.filter(
|
||||
(r): r is Review =>
|
||||
// The `... on User` makes it such that .login only exists for users,
|
||||
// but we still need to filter the others out.
|
||||
// Accounts could be deleted as well, so don't count them.
|
||||
!!r.user?.login &&
|
||||
typeof r.user.id === 'number' &&
|
||||
// Also exclude author reviews, can't request their review in any case
|
||||
r.user.id !== pull_request.user?.id,
|
||||
)
|
||||
|
||||
const merge_bot_eligible = await handleMerge({
|
||||
github,
|
||||
context,
|
||||
core,
|
||||
log,
|
||||
dry,
|
||||
pull_request,
|
||||
events,
|
||||
reviews,
|
||||
maintainers,
|
||||
getTeamMembers,
|
||||
getUser,
|
||||
})
|
||||
|
||||
const approvals = new Set(
|
||||
reviews
|
||||
.filter((review) => review.state === 'APPROVED')
|
||||
.map((review) => review.user?.id),
|
||||
)
|
||||
|
||||
// After creation of a Pull Request, `merge_commit_sha` will be null initially:
|
||||
// The very first merge commit will only be calculated after a little while.
|
||||
// To avoid labeling the PR as conflicted before that, we wait a few minutes.
|
||||
// This is intentionally less than the time that Eval takes, so that the label job
|
||||
// running after Eval can indeed label the PR as conflicted if that is the case.
|
||||
const merge_commit_sha_valid =
|
||||
Date.now() - new Date(pull_request.created_at).getTime() > 3 * 60 * 1000
|
||||
|
||||
const prLabels: Record<string, boolean> = {
|
||||
// We intentionally don't use the mergeable or mergeable_state attributes.
|
||||
// Those have an intermediate state while the test merge commit is created.
|
||||
// This doesn't work well for us, because we might have just triggered another
|
||||
// test merge commit creation by request the pull request via API at the start
|
||||
// of this function.
|
||||
// The attribute merge_commit_sha keeps the old value of null or the hash *until*
|
||||
// the new test merge commit has either successfully been created or failed so.
|
||||
// This essentially means we are updating the merge conflict label in two steps:
|
||||
// On the first pass of the day, we just fetch the pull request, which triggers
|
||||
// the creation. At this stage, the label is likely not updated, yet.
|
||||
// The second pass will then read the result from the first pass and set the label.
|
||||
'2.status: merge conflict':
|
||||
merge_commit_sha_valid && !pull_request.merge_commit_sha,
|
||||
'2.status: merge-bot eligible': merge_bot_eligible,
|
||||
'12.approvals: 1': approvals.size === 1,
|
||||
'12.approvals: 2': approvals.size === 2,
|
||||
'12.approvals: 3+': approvals.size >= 3,
|
||||
'12.first-time contribution': [
|
||||
'NONE',
|
||||
'FIRST_TIMER',
|
||||
'FIRST_TIME_CONTRIBUTOR',
|
||||
].includes(pull_request.author_association),
|
||||
}
|
||||
|
||||
const { id: run_id, conclusion } =
|
||||
(
|
||||
await github.rest.actions.listWorkflowRuns({
|
||||
...context.repo,
|
||||
workflow_id: 'pull-request-target.yml',
|
||||
event: 'pull_request_target',
|
||||
exclude_pull_requests: true,
|
||||
head_sha: pull_request.head.sha,
|
||||
})
|
||||
).data.workflow_runs[0] ??
|
||||
// TODO: Remove this after 2026-02-01, at which point all pr.yml artifacts will have expired.
|
||||
(
|
||||
await github.rest.actions.listWorkflowRuns({
|
||||
...context.repo,
|
||||
// In older PRs, we need pr.yml instead of pull-request-target.yml.
|
||||
workflow_id: 'pr.yml',
|
||||
event: 'pull_request_target',
|
||||
exclude_pull_requests: true,
|
||||
head_sha: pull_request.head.sha,
|
||||
})
|
||||
).data.workflow_runs[0] ??
|
||||
{}
|
||||
|
||||
// Newer PRs might not have run Eval to completion, yet.
|
||||
// Older PRs might not have an eval.yml workflow, yet.
|
||||
// In either case we continue without fetching an artifact on a best-effort basis.
|
||||
log('Last eval run', run_id ?? '<n/a>')
|
||||
|
||||
if (conclusion === 'success') {
|
||||
Object.assign(prLabels, {
|
||||
// We only set this label if the latest eval run was successful, because if it was not, it
|
||||
// *could* have requested reviewers. We will let the PR author fix CI first, before "escalating"
|
||||
// this PR to "needs: reviewer".
|
||||
// Since the first Eval run on a PR always sets rebuild labels, the same PR will be "recently
|
||||
// updated" for the next scheduled run. Thus, this label will still be set within a few minutes
|
||||
// after a PR is created, if required.
|
||||
// Note that a "requested reviewer" disappears once they have given a review, so we check
|
||||
// existing reviews, too.
|
||||
'9.needs: reviewer':
|
||||
!pull_request.draft &&
|
||||
(pull_request.requested_reviewers ?? []).length === 0 &&
|
||||
reviews.length === 0,
|
||||
})
|
||||
}
|
||||
|
||||
const artifact = run_id
|
||||
? (
|
||||
await github.rest.actions.listWorkflowRunArtifacts({
|
||||
...context.repo,
|
||||
run_id,
|
||||
name: 'comparison',
|
||||
})
|
||||
).data.artifacts[0]
|
||||
: undefined
|
||||
|
||||
// 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 skip the download.
|
||||
const expired =
|
||||
!artifact ||
|
||||
new Date(artifact?.expires_at ?? 0) < new Date(Date.now() + 60 * 1000)
|
||||
log('Artifact expires at', artifact?.expires_at ?? '<n/a>')
|
||||
if (run_id && artifact && !expired) {
|
||||
stats.artifacts++
|
||||
|
||||
await artifactClient.downloadArtifact(artifact.id, {
|
||||
findBy: {
|
||||
workflowRunId: run_id,
|
||||
repositoryName: context.repo.repo,
|
||||
repositoryOwner: context.repo.owner,
|
||||
token: core.getInput('github-token'),
|
||||
},
|
||||
path: path.resolve(pull_number.toString()),
|
||||
expectedHash: artifact.digest ?? undefined,
|
||||
})
|
||||
|
||||
const changedPaths: ChangedPaths = JSON.parse(
|
||||
await readFile(`${pull_number}/changed-paths.json`, 'utf-8'),
|
||||
)
|
||||
const evalLabels = changedPaths.labels
|
||||
|
||||
// Fetch all PR commits to check their messages for package patterns
|
||||
const prCommits = await github.paginate(github.rest.pulls.listCommits, {
|
||||
...context.repo,
|
||||
pull_number,
|
||||
per_page: 100,
|
||||
})
|
||||
|
||||
// label llm-assisted PRs accordingly, retaining it if manually set
|
||||
const assistedByPattern = /Assisted-by: (?!nix-init)/i
|
||||
if (prCommits.some((c) => assistedByPattern.test(c.commit.message))) {
|
||||
evalLabels['llm-assisted'] = true
|
||||
}
|
||||
|
||||
const commitSubjects = prCommits.map(
|
||||
(c) => c.commit.message.split('\n')[0],
|
||||
)
|
||||
|
||||
// Label new package PRs: "packagename: init at X.Y.Z"
|
||||
// Exclude NixOS module commits like "nixos/timekpr: init at 0.5.8"
|
||||
const newPackagePattern = /^(?<!nixos\/)\S+: init at\b/
|
||||
const hasNewPackages = (changedPaths.attrdiff?.added?.length ?? 0) > 0
|
||||
const commitsIndicateNewPackage = commitSubjects.some((msg) =>
|
||||
newPackagePattern.test(msg),
|
||||
)
|
||||
evalLabels['8.has: package (new)'] =
|
||||
hasNewPackages && commitsIndicateNewPackage
|
||||
|
||||
// Label package update PRs: "packagename: X.Y.Z -> A.B.C"
|
||||
// Matches versions like: 1.2.3, 0-unstable-2024-01-15, 1.3rc1, alpha, unstable
|
||||
// Exclude NixOS module commits like "nixos/ncps: types.str -> types.path"
|
||||
const updatePackagePattern =
|
||||
/^(?<!nixos\/)\S+: [\w.-]*\d[\w.-]* (->|→) [\w.-]*\d[\w.-]*$/
|
||||
const commitsIndicateUpdate = commitSubjects.some((msg) =>
|
||||
updatePackagePattern.test(msg),
|
||||
)
|
||||
evalLabels['8.has: package (update)'] = commitsIndicateUpdate
|
||||
|
||||
// TODO: Get "changed packages" information from list of changed by-name files
|
||||
// in addition to just the Eval results, to make this work for these packages
|
||||
// when Eval results have expired as well.
|
||||
let packages: string[]
|
||||
try {
|
||||
packages = JSON.parse(
|
||||
await readFile(`${pull_number}/packages.json`, 'utf-8'),
|
||||
)
|
||||
} catch (e) {
|
||||
if (!(e instanceof Error && 'code' in e && e.code === 'ENOENT')) throw e
|
||||
// TODO: Remove this fallback code once all old artifacts without packages.json
|
||||
// have expired. This should be the case in ~ February 2026.
|
||||
packages = Array.from(
|
||||
new Set(
|
||||
Object.values(
|
||||
JSON.parse(
|
||||
await readFile(`${pull_number}/maintainers.json`, 'utf-8'),
|
||||
) as Record<string, string[]>,
|
||||
).flat(1),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
Object.assign(prLabels, evalLabels, {
|
||||
'11.by: package-maintainer':
|
||||
Boolean(packages.length) &&
|
||||
packages.every((pkg) =>
|
||||
maintainers[pkg]?.includes(pull_request.user.id),
|
||||
),
|
||||
'12.approved-by: package-maintainer': packages.some((pkg) =>
|
||||
maintainers[pkg]?.some((m) => approvals.has(m)),
|
||||
),
|
||||
})
|
||||
|
||||
if (!pull_request.draft) {
|
||||
let owners: string[] = []
|
||||
try {
|
||||
// TODO: Create owner map similar to maintainer map.
|
||||
owners =
|
||||
(await readFile(`${pull_number}/owners.txt`, 'utf-8')).match(
|
||||
/[^\n]+/g,
|
||||
) || []
|
||||
} catch (e) {
|
||||
// Older artifacts don't have the owners.txt, yet.
|
||||
if (!(e instanceof Error && 'code' in e && e.code === 'ENOENT')) {
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
let team_maintainers: number[] = []
|
||||
try {
|
||||
team_maintainers = Object.keys(
|
||||
JSON.parse(await readFile(`${pull_number}/teams.json`, 'utf-8')),
|
||||
).map((id) => parseInt(id))
|
||||
} catch (e) {
|
||||
// Older artifacts don't have the teams.json, yet.
|
||||
if (!(e instanceof Error && 'code' in e && e.code === 'ENOENT')) {
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Use maintainer map instead of the artifact.
|
||||
const user_maintainers = Object.keys(
|
||||
JSON.parse(
|
||||
await readFile(`${pull_number}/maintainers.json`, 'utf-8'),
|
||||
),
|
||||
).map((id) => parseInt(id))
|
||||
|
||||
prLabels['7.no default reviewers'] =
|
||||
user_maintainers.length === 0 &&
|
||||
team_maintainers.length === 0 &&
|
||||
owners.length === 0
|
||||
|
||||
// We set this label earlier already, but the current PR state can be very different
|
||||
// after handleReviewers has requested reviews, so update it in this case to prevent
|
||||
// this label from flip-flopping.
|
||||
prLabels['9.needs: reviewer'] = await handleReviewers({
|
||||
github,
|
||||
context,
|
||||
core,
|
||||
log,
|
||||
dry,
|
||||
pull_request,
|
||||
reviews,
|
||||
user_maintainers,
|
||||
team_maintainers,
|
||||
owners,
|
||||
getUser,
|
||||
getTeam,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return prLabels
|
||||
}
|
||||
|
||||
// Returns true if the issue was closed. In this case, the labeling does not need to
|
||||
// continue for this issue. Returns false if no action was taken.
|
||||
async function handleAutoClose(item: Item) {
|
||||
const issue_number = item.number
|
||||
|
||||
if (
|
||||
item.labels.some(
|
||||
(label) =>
|
||||
(typeof label === 'string' ? label : label.name) ===
|
||||
'0.kind: packaging request',
|
||||
)
|
||||
) {
|
||||
const body = [
|
||||
'Thank you for your interest in packaging new software in Nixpkgs. Unfortunately, to mitigate the unsustainable growth of unmaintained packages, **Nixpkgs is no longer accepting package requests** via Issues.',
|
||||
'',
|
||||
'As a [volunteer community][community], we are always open to new contributors. If you wish to see this package in Nixpkgs, **we encourage you to [contribute] it yourself**, via a Pull Request. Anyone can [become a package maintainer][maintainers]! You can find language-specific packaging information in the [Nixpkgs Manual][nixpkgs]. Should you need any help, please reach out to the community on [Matrix] or [Discourse].',
|
||||
'',
|
||||
'[community]: https://nixos.org/community',
|
||||
'[contribute]: https://github.com/NixOS/nixpkgs/blob/master/pkgs/README.md#quick-start-to-adding-a-package',
|
||||
'[maintainers]: https://github.com/NixOS/nixpkgs/blob/master/maintainers/README.md',
|
||||
'[nixpkgs]: https://nixos.org/manual/nixpkgs/unstable/',
|
||||
'[Matrix]: https://matrix.to/#/#dev:nixos.org',
|
||||
'[Discourse]: https://discourse.nixos.org/c/dev/14',
|
||||
].join('\n')
|
||||
|
||||
core.info(`Issue #${item.number}: auto-closed`)
|
||||
|
||||
if (!dry) {
|
||||
await github.rest.issues.createComment({
|
||||
...context.repo,
|
||||
issue_number,
|
||||
body,
|
||||
})
|
||||
|
||||
await github.rest.issues.update({
|
||||
...context.repo,
|
||||
issue_number,
|
||||
state: 'closed',
|
||||
state_reason: 'not_planned',
|
||||
})
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
async function handle({ item, stats }: { item: Item; stats: Stats }) {
|
||||
try {
|
||||
const log = (k: string, v: unknown, skip = false) => {
|
||||
core.info(`#${item.number} - ${k}: ${v}${skip ? ' (skipped)' : ''}`)
|
||||
return skip
|
||||
}
|
||||
|
||||
log('Last updated at', item.updated_at)
|
||||
log('URL', item.html_url)
|
||||
|
||||
const issue_number = item.number
|
||||
|
||||
const itemLabels: Record<string, boolean> = {}
|
||||
|
||||
const events = await github.paginate(
|
||||
github.rest.issues.listEventsForTimeline,
|
||||
{
|
||||
...context.repo,
|
||||
issue_number,
|
||||
per_page: 100,
|
||||
},
|
||||
)
|
||||
|
||||
const timelineEvents = events as TimelineEvent[]
|
||||
|
||||
const latest_event_at = new Date(
|
||||
timelineEvents
|
||||
.filter(({ event }) =>
|
||||
[
|
||||
// These events are hand-picked from:
|
||||
// https://docs.github.com/en/rest/using-the-rest-api/issue-event-types?apiVersion=2022-11-28
|
||||
// Each of those causes a PR/issue to *not* be considered as stale anymore.
|
||||
// Most of these use created_at.
|
||||
'assigned',
|
||||
'commented', // uses updated_at, because that could be > created_at
|
||||
'committed', // uses committer!.date
|
||||
...(item.labels.some(
|
||||
(label) =>
|
||||
(typeof label === 'string' ? label : label.name) ===
|
||||
'5.scope: tracking',
|
||||
)
|
||||
? ['cross-referenced']
|
||||
: []),
|
||||
'head_ref_force_pushed',
|
||||
'milestoned',
|
||||
'pinned',
|
||||
'ready_for_review',
|
||||
'renamed',
|
||||
'reopened',
|
||||
'review_dismissed',
|
||||
'review_requested',
|
||||
'reviewed', // uses submitted_at
|
||||
'unlocked',
|
||||
'unmarked_as_duplicate',
|
||||
].includes(event),
|
||||
)
|
||||
.map(
|
||||
({ created_at, updated_at, committer, submitted_at }) =>
|
||||
new Date(
|
||||
updated_at ?? created_at ?? submitted_at ?? committer!.date,
|
||||
),
|
||||
)
|
||||
// Reverse sort by date value. The default sort() sorts by string representation, which is bad for dates.
|
||||
.sort((a, b) => b.getTime() - a.getTime())
|
||||
.at(0) ?? item.created_at,
|
||||
)
|
||||
log('latest_event_at', latest_event_at.toISOString())
|
||||
|
||||
const stale_at = new Date(new Date().setDate(new Date().getDate() - 180))
|
||||
const is_stale = latest_event_at < stale_at
|
||||
|
||||
if (item.pull_request || context.payload.pull_request) {
|
||||
// No need to compute merge commits for stale PRs over and over again.
|
||||
// This increases the repo size on GitHub's side unnecessarily and wastes
|
||||
// a lot of API requests, too. Any relevant change will result in the
|
||||
// stale status to change and thus pick up the PR again for labeling.
|
||||
if (!is_stale) {
|
||||
stats.prs++
|
||||
Object.assign(
|
||||
itemLabels,
|
||||
await handlePullRequest({ item, stats, events: timelineEvents }),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
stats.issues++
|
||||
if (
|
||||
item.labels.some(
|
||||
(label) =>
|
||||
(typeof label === 'string' ? label : label.name) ===
|
||||
'4.workflow: auto-close',
|
||||
)
|
||||
) {
|
||||
// If this returns true, the issue was closed. In this case we return, to not
|
||||
// label the issue anymore. Most importantly this avoids unlabeling stale issues
|
||||
// which are closed via auto-close.
|
||||
if (await handleAutoClose(item)) return
|
||||
}
|
||||
}
|
||||
|
||||
// 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,
|
||||
})
|
||||
).map(({ name }) => [name, true]),
|
||||
)
|
||||
|
||||
Object.assign(itemLabels, {
|
||||
'2.status: stale': !before['1.severity: security'] && is_stale,
|
||||
})
|
||||
|
||||
const after = Object.assign({}, before, itemLabels)
|
||||
|
||||
// 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 label 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, dry)) return
|
||||
|
||||
await github.rest.issues.setLabels({
|
||||
...context.repo,
|
||||
issue_number,
|
||||
labels,
|
||||
})
|
||||
} catch (cause) {
|
||||
throw new Error(`Labeling #${item.number} failed.`, { cause })
|
||||
}
|
||||
}
|
||||
|
||||
// Controls level of parallelism. Applies to both the number of concurrent requests
|
||||
// as well as the number of concurrent workers going through the list of PRs.
|
||||
// We'll only boost concurrency when we're running many PRs in parallel on a schedule,
|
||||
// but not for single PRs. This avoids things going wild, when we accidentally make
|
||||
// too many API requests on treewides.
|
||||
const maxConcurrent = context.payload.pull_request ? 1 : 20
|
||||
|
||||
await withRateLimit({ github, core, maxConcurrent }, async (stats) => {
|
||||
if (context.payload.pull_request) {
|
||||
await handle({ item: context.payload.pull_request as Item, stats })
|
||||
} else {
|
||||
// We don't use filters here because that causes GitHub to use an often-outdated index,
|
||||
// resulting in the cursor not being updated, and therefore causing the same PRs
|
||||
// to use up our rate limit over and over again.
|
||||
const lastRun = (
|
||||
await github.rest.actions.listWorkflowRuns({
|
||||
...context.repo,
|
||||
workflow_id: 'bot.yml',
|
||||
})
|
||||
).data.workflow_runs.find(
|
||||
(run) => run.event === 'schedule' && run.conclusion === 'success',
|
||||
)
|
||||
|
||||
core.info(
|
||||
`Last successful run created at: ${lastRun?.created_at ?? '<n/a>'}`,
|
||||
)
|
||||
|
||||
const cutoff = new Date(
|
||||
Math.max(
|
||||
// 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.
|
||||
new Date(
|
||||
lastRun?.created_at ?? Date.now() - 1 * 60 * 60 * 1000,
|
||||
).getTime(),
|
||||
// Go back max. 1 day to prevent hitting all API rate limits immediately,
|
||||
// when GH API returns a wrong workflow by accident.
|
||||
Date.now() - 24 * 60 * 60 * 1000,
|
||||
),
|
||||
)
|
||||
core.info(`cutoff timestamp: ${cutoff.toISOString()}`)
|
||||
|
||||
const updatedItems = await github.paginate(
|
||||
github.rest.search.issuesAndPullRequests,
|
||||
{
|
||||
q: [
|
||||
`repo:"${context.repo.owner}/${context.repo.repo}"`,
|
||||
'is:open',
|
||||
`updated:>=${cutoff.toISOString()}`,
|
||||
].join(' AND '),
|
||||
per_page: 100,
|
||||
// TODO: Remove after 2025-11-04, when it becomes the default.
|
||||
advanced_search: 'true',
|
||||
},
|
||||
)
|
||||
|
||||
let cursor: string | undefined
|
||||
|
||||
// No workflow run available the first time.
|
||||
if (lastRun) {
|
||||
// The cursor to iterate through the full list of issues and pull requests
|
||||
// is passed between jobs as an artifact.
|
||||
const artifact = (
|
||||
await github.rest.actions.listWorkflowRunArtifacts({
|
||||
...context.repo,
|
||||
run_id: lastRun.id,
|
||||
name: 'pagination-cursor',
|
||||
})
|
||||
).data.artifacts[0]
|
||||
|
||||
// If the artifact is not available, the next iteration starts at the beginning.
|
||||
if (artifact && !artifact.expired) {
|
||||
stats.artifacts++
|
||||
|
||||
const { downloadPath } = await artifactClient.downloadArtifact(
|
||||
artifact.id,
|
||||
{
|
||||
findBy: {
|
||||
workflowRunId: lastRun.id,
|
||||
repositoryName: context.repo.repo,
|
||||
repositoryOwner: context.repo.owner,
|
||||
token: core.getInput('github-token'),
|
||||
},
|
||||
expectedHash: artifact.digest ?? undefined,
|
||||
},
|
||||
)
|
||||
|
||||
if (!downloadPath)
|
||||
throw new Error('Artifact download returned no path.')
|
||||
cursor = await readFile(path.resolve(downloadPath, 'cursor'), 'utf-8')
|
||||
}
|
||||
}
|
||||
|
||||
// From GitHub's API docs:
|
||||
// GitHub's REST API considers every pull request an issue, but not every issue is a pull request.
|
||||
// For this reason, "Issues" endpoints may return both issues and pull requests in the response.
|
||||
// You can identify pull requests by the pull_request key.
|
||||
const allItems = await github.rest.issues.listForRepo({
|
||||
...context.repo,
|
||||
state: 'open',
|
||||
sort: 'created',
|
||||
direction: 'asc',
|
||||
per_page: 100,
|
||||
after: cursor,
|
||||
})
|
||||
|
||||
// Regex taken and comment adjusted from:
|
||||
// https://github.com/octokit/plugin-paginate-rest.js/blob/8e5da25f975d2f31dda6b8b588d71f2c768a8df2/src/iterator.ts#L36-L41
|
||||
// `allItems.headers.link` format:
|
||||
// <https://api.github.com/repositories/4542716/issues?page=3&per_page=100&after=Y3Vyc29yOnYyOpLPAAABl8qNnYDOvnSJxA%3D%3D>; rel="next",
|
||||
// <https://api.github.com/repositories/4542716/issues?page=1&per_page=100&before=Y3Vyc29yOnYyOpLPAAABl8xFV9DOvoouJg%3D%3D>; rel="prev"
|
||||
// Sets `next` to undefined if "next" URL is not present or `link` header is not set.
|
||||
const next = ((allItems.headers.link ?? '').match(
|
||||
/<([^<>]+)>;\s*rel="next"/,
|
||||
) ?? [])[1]
|
||||
if (next) {
|
||||
cursor = new URL(next).searchParams.get('after') ?? undefined
|
||||
if (!cursor) throw new Error('Pagination link contains no cursor.')
|
||||
const uploadPath = path.resolve('cursor')
|
||||
await writeFile(uploadPath, cursor, 'utf-8')
|
||||
if (dry) {
|
||||
core.info(`pagination-cursor: ${cursor} (upload skipped)`)
|
||||
} else {
|
||||
// No stats.artifacts++, because this does not allow passing a custom token.
|
||||
// Thus, the upload will not happen with the app token, but the default github.token.
|
||||
core.info(`pagination-cursor: ${cursor}`)
|
||||
await artifactClient.uploadArtifact(
|
||||
'pagination-cursor',
|
||||
[uploadPath],
|
||||
path.resolve('.'),
|
||||
{
|
||||
retentionDays: 1,
|
||||
},
|
||||
)
|
||||
}
|
||||
} else {
|
||||
core.info('pagination-cursor: <n/a>')
|
||||
}
|
||||
|
||||
// Some items might be in both search results, so filtering out duplicates as well.
|
||||
const items: Item[] = [...updatedItems, ...allItems.data].filter(
|
||||
(thisItem, idx, arr) =>
|
||||
idx ===
|
||||
arr.findIndex((firstItem) => firstItem.number === thisItem.number),
|
||||
)
|
||||
|
||||
// Instead of handling all items in parallel we set up some workers to handle the queue
|
||||
// with more controlled parallelism. This avoids problems with `pull_request` fetched at
|
||||
// the beginning getting out of date towards the end, because it took the whole job 20
|
||||
// minutes or more to go through 100's of PRs.
|
||||
await Promise.all(
|
||||
Array.from({ length: maxConcurrent }, async () => {
|
||||
while (true) {
|
||||
const item = items.pop()
|
||||
if (!item) break
|
||||
try {
|
||||
await handle({ item, stats })
|
||||
} catch (e) {
|
||||
core.setFailed(
|
||||
e instanceof Error
|
||||
? `${e.message}\n${e.cause instanceof Error ? e.cause.stack : e.cause}`
|
||||
: String(e),
|
||||
)
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,242 +0,0 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { evaluateTargetBranchPolicy } from './check-target-branch-policy.ts'
|
||||
|
||||
type DecisionFacts = {
|
||||
base: string
|
||||
head: string
|
||||
maxRebuildCount: number
|
||||
rebuildsAllTests: boolean
|
||||
onlyChangedFile: string | null
|
||||
}
|
||||
|
||||
type Decision =
|
||||
| 'mass-rebuild'
|
||||
| 'nixos-rebuild'
|
||||
| 'possible-mass-rebuild'
|
||||
| 'skip-development-merge'
|
||||
| 'dismiss'
|
||||
|
||||
const defaults: DecisionFacts = {
|
||||
base: 'master',
|
||||
head: 'topic-branch',
|
||||
maxRebuildCount: 0,
|
||||
rebuildsAllTests: false,
|
||||
onlyChangedFile: null,
|
||||
}
|
||||
|
||||
const cases: Array<{
|
||||
name: string
|
||||
facts: Partial<DecisionFacts>
|
||||
expected: Decision
|
||||
}> = [
|
||||
{
|
||||
name: 'allows fewer than 500 rebuilds on master',
|
||||
facts: { maxRebuildCount: 499 },
|
||||
expected: 'dismiss',
|
||||
},
|
||||
{
|
||||
name: 'flags 500 rebuilds on master as a possible mass rebuild',
|
||||
facts: { maxRebuildCount: 500 },
|
||||
expected: 'possible-mass-rebuild',
|
||||
},
|
||||
{
|
||||
name: 'flags 999 rebuilds on master as a possible mass rebuild',
|
||||
facts: { maxRebuildCount: 999 },
|
||||
expected: 'possible-mass-rebuild',
|
||||
},
|
||||
{
|
||||
name: 'flags 1000 rebuilds on master as a mass rebuild',
|
||||
facts: { maxRebuildCount: 1000 },
|
||||
expected: 'mass-rebuild',
|
||||
},
|
||||
{
|
||||
name: 'flags a mass rebuild on staging-nixos',
|
||||
facts: { base: 'staging-nixos', maxRebuildCount: 24_000 },
|
||||
expected: 'mass-rebuild',
|
||||
},
|
||||
{
|
||||
name: 'flags a mass rebuild on a release staging-nixos branch',
|
||||
facts: { base: 'staging-nixos-26.05', maxRebuildCount: 1000 },
|
||||
expected: 'mass-rebuild',
|
||||
},
|
||||
{
|
||||
name: 'allows mass rebuilds on staging',
|
||||
facts: { base: 'staging', maxRebuildCount: 1000 },
|
||||
expected: 'dismiss',
|
||||
},
|
||||
{
|
||||
name: 'flags a mass rebuild on a release branch',
|
||||
facts: { base: 'release-26.05', maxRebuildCount: 1000 },
|
||||
expected: 'mass-rebuild',
|
||||
},
|
||||
{
|
||||
name: 'flags NixOS test rebuilds on master',
|
||||
facts: { rebuildsAllTests: true },
|
||||
expected: 'nixos-rebuild',
|
||||
},
|
||||
{
|
||||
name: 'flags NixOS test rebuilds on a release branch',
|
||||
facts: { base: 'release-26.05', rebuildsAllTests: true },
|
||||
expected: 'nixos-rebuild',
|
||||
},
|
||||
{
|
||||
name: 'allows NixOS test rebuilds on staging-nixos',
|
||||
facts: { base: 'staging-nixos', rebuildsAllTests: true },
|
||||
expected: 'dismiss',
|
||||
},
|
||||
{
|
||||
name: 'does not flag a possible mass rebuild when staging-nixos rebuilds all NixOS tests',
|
||||
facts: {
|
||||
base: 'staging-nixos',
|
||||
maxRebuildCount: 500,
|
||||
rebuildsAllTests: true,
|
||||
},
|
||||
expected: 'dismiss',
|
||||
},
|
||||
{
|
||||
name: 'flags other possible mass rebuilds on staging-nixos',
|
||||
facts: { base: 'staging-nixos', maxRebuildCount: 500 },
|
||||
expected: 'possible-mass-rebuild',
|
||||
},
|
||||
{
|
||||
name: 'skips staging into master',
|
||||
facts: {
|
||||
head: 'staging',
|
||||
maxRebuildCount: 24_000,
|
||||
},
|
||||
expected: 'skip-development-merge',
|
||||
},
|
||||
{
|
||||
name: 'skips staging-nixos into master',
|
||||
facts: {
|
||||
head: 'staging-nixos',
|
||||
maxRebuildCount: 24_000,
|
||||
},
|
||||
expected: 'skip-development-merge',
|
||||
},
|
||||
{
|
||||
name: 'skips master into staging-nixos',
|
||||
facts: {
|
||||
base: 'staging-nixos',
|
||||
head: 'master',
|
||||
maxRebuildCount: 24_000,
|
||||
},
|
||||
expected: 'skip-development-merge',
|
||||
},
|
||||
{
|
||||
name: 'checks staging into staging-nixos',
|
||||
facts: {
|
||||
base: 'staging-nixos',
|
||||
head: 'staging',
|
||||
maxRebuildCount: 24_000,
|
||||
},
|
||||
expected: 'mass-rebuild',
|
||||
},
|
||||
{
|
||||
name: 'skips release staging-nixos into its release branch',
|
||||
facts: {
|
||||
base: 'release-26.05',
|
||||
head: 'staging-nixos-26.05',
|
||||
maxRebuildCount: 24_000,
|
||||
},
|
||||
expected: 'skip-development-merge',
|
||||
},
|
||||
{
|
||||
name: 'skips a release branch into its staging-nixos branch',
|
||||
facts: {
|
||||
base: 'staging-nixos-26.05',
|
||||
head: 'release-26.05',
|
||||
maxRebuildCount: 24_000,
|
||||
},
|
||||
expected: 'skip-development-merge',
|
||||
},
|
||||
{
|
||||
name: 'checks release staging into its staging-nixos branch',
|
||||
facts: {
|
||||
base: 'staging-nixos-26.05',
|
||||
head: 'staging-26.05',
|
||||
maxRebuildCount: 24_000,
|
||||
},
|
||||
expected: 'mass-rebuild',
|
||||
},
|
||||
{
|
||||
name: 'kernels-org exemption suppresses a possible mass rebuild',
|
||||
facts: {
|
||||
maxRebuildCount: 999,
|
||||
onlyChangedFile: 'pkgs/os-specific/linux/kernel/kernels-org.json',
|
||||
},
|
||||
expected: 'dismiss',
|
||||
},
|
||||
{
|
||||
name: 'kernels-org exemption suppresses a definite mass rebuild',
|
||||
facts: {
|
||||
maxRebuildCount: 1000,
|
||||
onlyChangedFile: 'pkgs/os-specific/linux/kernel/kernels-org.json',
|
||||
},
|
||||
expected: 'dismiss',
|
||||
},
|
||||
{
|
||||
name: 'kernels-org exemption suppresses a NixOS test rebuild',
|
||||
facts: {
|
||||
rebuildsAllTests: true,
|
||||
onlyChangedFile: 'pkgs/os-specific/linux/kernel/kernels-org.json',
|
||||
},
|
||||
expected: 'dismiss',
|
||||
},
|
||||
{
|
||||
name: 'xanmod kernel exemption suppresses a possible mass rebuild',
|
||||
facts: {
|
||||
maxRebuildCount: 999,
|
||||
onlyChangedFile: 'pkgs/os-specific/linux/kernel/xanmod-kernels.nix',
|
||||
},
|
||||
expected: 'dismiss',
|
||||
},
|
||||
{
|
||||
name: 'xanmod kernel exemption suppresses a definite mass rebuild',
|
||||
facts: {
|
||||
maxRebuildCount: 1000,
|
||||
onlyChangedFile: 'pkgs/os-specific/linux/kernel/xanmod-kernels.nix',
|
||||
},
|
||||
expected: 'dismiss',
|
||||
},
|
||||
{
|
||||
name: 'xanmod kernel exemption suppresses a NixOS test rebuild',
|
||||
facts: {
|
||||
rebuildsAllTests: true,
|
||||
onlyChangedFile: 'pkgs/os-specific/linux/kernel/xanmod-kernels.nix',
|
||||
},
|
||||
expected: 'dismiss',
|
||||
},
|
||||
{
|
||||
name: 'Home Assistant exemption suppresses a mass rebuild',
|
||||
facts: {
|
||||
head: 'wip-home-assistant',
|
||||
maxRebuildCount: 1500,
|
||||
},
|
||||
expected: 'dismiss',
|
||||
},
|
||||
{
|
||||
name: 'does not exempt a Home Assistant update above 1500 rebuilds',
|
||||
facts: { head: 'wip-home-assistant', maxRebuildCount: 1501 },
|
||||
expected: 'mass-rebuild',
|
||||
},
|
||||
{
|
||||
name: 'Home Assistant exemption does not suppress a NixOS test rebuild',
|
||||
facts: {
|
||||
head: 'wip-home-assistant',
|
||||
maxRebuildCount: 1500,
|
||||
rebuildsAllTests: true,
|
||||
},
|
||||
expected: 'nixos-rebuild',
|
||||
},
|
||||
]
|
||||
|
||||
for (const { name, facts, expected } of cases) {
|
||||
test(name, () => {
|
||||
assert.equal(
|
||||
evaluateTargetBranchPolicy({ ...defaults, ...facts }).decision,
|
||||
expected,
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
import { classify, split } from './supportedBranches.ts'
|
||||
|
||||
type TargetBranchPolicyFacts = {
|
||||
base: string
|
||||
head: string
|
||||
maxRebuildCount: number
|
||||
rebuildsAllTests: boolean
|
||||
onlyChangedFile: string | null
|
||||
}
|
||||
|
||||
type TargetBranchReviewDecision =
|
||||
| 'mass-rebuild'
|
||||
| 'nixos-rebuild'
|
||||
| 'possible-mass-rebuild'
|
||||
| 'skip-development-merge'
|
||||
| 'dismiss'
|
||||
|
||||
type TargetBranchPolicyResult = {
|
||||
decision: TargetBranchReviewDecision
|
||||
details: {
|
||||
isExemptKernelUpdate: boolean
|
||||
isExemptHomeAssistantUpdate: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export function getTargetBranchPolicy({
|
||||
base,
|
||||
head,
|
||||
}: {
|
||||
base: string
|
||||
head: string
|
||||
}) {
|
||||
const baseClassification = classify(base)
|
||||
const headClassification = classify(head)
|
||||
const isPrimaryBase = baseClassification.type.includes('primary')
|
||||
const isPrimaryHead = headClassification.type.includes('primary')
|
||||
const isStagingNixosBase = split(base).prefix === 'staging-nixos'
|
||||
const isDevelopmentHead = headClassification.type.includes('development')
|
||||
const shouldSkipDevelopmentMerge =
|
||||
isDevelopmentHead && (!isStagingNixosBase || isPrimaryHead)
|
||||
|
||||
return {
|
||||
isStagingNixosBase,
|
||||
shouldSkipDevelopmentMerge,
|
||||
shouldCheckMassRebuild:
|
||||
!shouldSkipDevelopmentMerge && (isPrimaryBase || isStagingNixosBase),
|
||||
shouldCheckNixosRebuild: !shouldSkipDevelopmentMerge && isPrimaryBase,
|
||||
}
|
||||
}
|
||||
|
||||
export function evaluateTargetBranchPolicy({
|
||||
base,
|
||||
head,
|
||||
maxRebuildCount,
|
||||
rebuildsAllTests,
|
||||
onlyChangedFile,
|
||||
}: TargetBranchPolicyFacts): TargetBranchPolicyResult {
|
||||
const {
|
||||
isStagingNixosBase,
|
||||
shouldSkipDevelopmentMerge,
|
||||
shouldCheckMassRebuild,
|
||||
shouldCheckNixosRebuild,
|
||||
} = getTargetBranchPolicy({ base, head })
|
||||
|
||||
// https://github.com/NixOS/nixpkgs/pull/553786#issuecomment-5510286851
|
||||
// kernels-org should go to staging-nixos (or master) and staging-nixos-xx.xx (or release-xx.xx) when backported
|
||||
// https://github.com/NixOS/nixpkgs/pull/521157
|
||||
// xanmod should go to master and release-xx.xx when backported
|
||||
const isExemptKernelUpdate =
|
||||
onlyChangedFile === 'pkgs/os-specific/linux/kernel/kernels-org.json' ||
|
||||
onlyChangedFile === 'pkgs/os-specific/linux/kernel/xanmod-kernels.nix'
|
||||
|
||||
// https://github.com/NixOS/nixpkgs/pull/483194#issuecomment-3793393218
|
||||
const isExemptHomeAssistantUpdate =
|
||||
maxRebuildCount <= 1500 && head === 'wip-home-assistant'
|
||||
|
||||
const details = {
|
||||
isExemptKernelUpdate,
|
||||
isExemptHomeAssistantUpdate,
|
||||
}
|
||||
|
||||
const result = (decision: TargetBranchReviewDecision) => ({
|
||||
decision,
|
||||
details,
|
||||
})
|
||||
|
||||
const isMassRebuild =
|
||||
maxRebuildCount >= 1000 &&
|
||||
!isExemptKernelUpdate &&
|
||||
!isExemptHomeAssistantUpdate
|
||||
|
||||
if (shouldCheckMassRebuild && isMassRebuild) {
|
||||
return result('mass-rebuild')
|
||||
}
|
||||
|
||||
if (shouldCheckNixosRebuild && rebuildsAllTests && !isExemptKernelUpdate) {
|
||||
return result('nixos-rebuild')
|
||||
}
|
||||
|
||||
const isPossibleMassRebuild =
|
||||
maxRebuildCount >= 500 &&
|
||||
!isMassRebuild &&
|
||||
!isExemptKernelUpdate &&
|
||||
!isExemptHomeAssistantUpdate
|
||||
if (
|
||||
shouldCheckMassRebuild &&
|
||||
isPossibleMassRebuild &&
|
||||
!(rebuildsAllTests && isStagingNixosBase)
|
||||
) {
|
||||
return result('possible-mass-rebuild')
|
||||
}
|
||||
|
||||
return result(
|
||||
shouldSkipDevelopmentMerge ? 'skip-development-merge' : 'dismiss',
|
||||
)
|
||||
}
|
||||
221
ci/github-script/check-target-branch.js
Normal file
221
ci/github-script/check-target-branch.js
Normal file
@@ -0,0 +1,221 @@
|
||||
/// @ts-check
|
||||
|
||||
// TODO: should this be combined with the branch checks in prepare.js?
|
||||
// They do seem quite similar, but this needs to run after eval,
|
||||
// and prepare.js obviously doesn't.
|
||||
|
||||
const { classify, split } = require('../supportedBranches.js')
|
||||
const { readFile } = require('node:fs/promises')
|
||||
const { postReview, dismissReviews } = require('./reviews.js')
|
||||
|
||||
const reviewKey = 'check-target-branch'
|
||||
/**
|
||||
* @param {{
|
||||
* github: InstanceType<import('@actions/github/lib/utils').GitHub>,
|
||||
* context: import('@actions/github/lib/context').Context
|
||||
* core: import('@actions/core')
|
||||
* dry: boolean
|
||||
* }} CheckTargetBranchProps
|
||||
*/
|
||||
async function checkTargetBranch({ github, context, core, dry }) {
|
||||
/**
|
||||
* @type {{
|
||||
* attrdiff: {
|
||||
* added: string[],
|
||||
* changed: string[],
|
||||
* removed: string[],
|
||||
* },
|
||||
* attrdiffByKernel: Record<string, {
|
||||
* added: string[],
|
||||
* changed: string[],
|
||||
* removed: string[],
|
||||
* }>,
|
||||
* attrdiffByPlatform: Record<string, {
|
||||
* added: string[],
|
||||
* changed: string[],
|
||||
* removed: string[],
|
||||
* }>,
|
||||
* labels: Record<string, boolean>,
|
||||
* rebuildCountByKernel: Record<string, number>,
|
||||
* rebuildsByKernel: Record<string, string[]>,
|
||||
* rebuildsByPlatform: Record<string, string[]>,
|
||||
* }}
|
||||
*/
|
||||
const changed = JSON.parse(
|
||||
await readFile('comparison/changed-paths.json', 'utf-8'),
|
||||
)
|
||||
const pull_number = context.payload.pull_request?.number
|
||||
if (!pull_number) {
|
||||
core.warning(
|
||||
'Skipping checkTargetBranch: no pull_request number (is this being run as part of a merge group?)',
|
||||
)
|
||||
return
|
||||
}
|
||||
const prInfo = (
|
||||
await github.rest.pulls.get({
|
||||
...context.repo,
|
||||
pull_number,
|
||||
})
|
||||
).data
|
||||
const base = prInfo.base.ref
|
||||
const head = prInfo.head.ref
|
||||
const baseClassification = classify(base)
|
||||
const headClassification = classify(head)
|
||||
|
||||
// Don't run on, e.g., staging-nixos to master merges.
|
||||
if (headClassification.type.includes('development')) {
|
||||
core.info(
|
||||
`Skipping checkTargetBranch: PR is from a development branch (${head})`,
|
||||
)
|
||||
|
||||
await dismissReviews({
|
||||
github,
|
||||
context,
|
||||
core,
|
||||
dry,
|
||||
reviewKey,
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
// Don't run on PRs against staging branches, wip branches, haskell-updates, etc.
|
||||
if (!baseClassification.type.includes('primary')) {
|
||||
core.info(
|
||||
`Skipping checkTargetBranch: PR is against a non-primary base branch (${base})`,
|
||||
)
|
||||
|
||||
await dismissReviews({
|
||||
github,
|
||||
context,
|
||||
core,
|
||||
dry,
|
||||
reviewKey,
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const maxRebuildCount = Math.max(
|
||||
...Object.values(changed.rebuildCountByKernel),
|
||||
)
|
||||
const rebuildsAllTests =
|
||||
changed.attrdiff.changed.includes('nixosTests.simple-container') ||
|
||||
changed.attrdiff.changed.includes('nixosTests.simple-vm')
|
||||
|
||||
// https://github.com/NixOS/nixpkgs/pull/521157
|
||||
// These should go to master and release-xx.xx when backported
|
||||
let isExemptKernelUpdate = false
|
||||
if (prInfo.changed_files === 1) {
|
||||
const changedFiles = (
|
||||
await github.rest.pulls.listFiles({
|
||||
...context.repo,
|
||||
pull_number,
|
||||
})
|
||||
).data
|
||||
isExemptKernelUpdate =
|
||||
changedFiles.length === 1 &&
|
||||
changedFiles[0].filename ===
|
||||
'pkgs/os-specific/linux/kernel/xanmod-kernels.nix'
|
||||
}
|
||||
|
||||
// https://github.com/NixOS/nixpkgs/pull/483194#issuecomment-3793393218
|
||||
const isExemptHomeAssistantUpdate =
|
||||
maxRebuildCount <= 1500 && head === 'wip-home-assistant'
|
||||
|
||||
core.info(
|
||||
[
|
||||
`checkTargetBranch: this PR:`,
|
||||
` * causes ${maxRebuildCount} rebuilds`,
|
||||
` * ${rebuildsAllTests ? 'rebuilds' : 'does not rebuild'} all NixOS tests`,
|
||||
` * ${isExemptKernelUpdate ? 'is' : 'is not'} an exempt kernel update`,
|
||||
` * ${isExemptHomeAssistantUpdate ? 'is' : 'is not'} an exempt home-assistant update`,
|
||||
].join('\n'),
|
||||
)
|
||||
|
||||
if (
|
||||
maxRebuildCount >= 1000 &&
|
||||
!isExemptHomeAssistantUpdate &&
|
||||
!isExemptKernelUpdate
|
||||
) {
|
||||
const desiredBranch =
|
||||
base === 'master' ? 'staging' : `staging-${split(base).version}`
|
||||
const body = [
|
||||
`The PR's base branch is set to \`${base}\`, but this PR causes ${maxRebuildCount} rebuilds.`,
|
||||
'It is therefore considered a mass rebuild.',
|
||||
`Please [change the base branch](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/changing-the-base-branch-of-a-pull-request) to [the right base branch for your changes](https://github.com/NixOS/nixpkgs/blob/master/CONTRIBUTING.md#branch-conventions) (probably \`${desiredBranch}\`).`,
|
||||
].join('\n')
|
||||
|
||||
await postReview({
|
||||
github,
|
||||
context,
|
||||
core,
|
||||
dry,
|
||||
body,
|
||||
event: 'REQUEST_CHANGES',
|
||||
reviewKey,
|
||||
})
|
||||
} else if (rebuildsAllTests && !isExemptKernelUpdate) {
|
||||
let branchText
|
||||
if (base === 'master' && maxRebuildCount >= 500) {
|
||||
branchText = '(probably either `staging-nixos` or `staging`)'
|
||||
} else if (base === 'master') {
|
||||
branchText = '(probably `staging-nixos`)'
|
||||
} else if (maxRebuildCount >= 500) {
|
||||
branchText = `(probably either \`staging-nixos-${split(base).version}\` or \`staging-${split(base).version}\`)`
|
||||
} else {
|
||||
branchText = `(probably \`staging-nixos-${split(base).version}\`)`
|
||||
}
|
||||
const body = [
|
||||
`The PR's base branch is set to \`${base}\`, but this PR rebuilds all NixOS tests.`,
|
||||
base === 'master' && maxRebuildCount >= 500
|
||||
? `Since this PR also causes ${maxRebuildCount} rebuilds, it may also be considered a mass rebuild.`
|
||||
: '',
|
||||
`Please [change the base branch](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/changing-the-base-branch-of-a-pull-request) to [the right base branch for your changes](https://github.com/NixOS/nixpkgs/blob/master/CONTRIBUTING.md#branch-conventions) ${branchText}.`,
|
||||
].join('\n')
|
||||
|
||||
await postReview({
|
||||
github,
|
||||
context,
|
||||
core,
|
||||
dry,
|
||||
body,
|
||||
event: 'REQUEST_CHANGES',
|
||||
reviewKey,
|
||||
})
|
||||
} else if (
|
||||
maxRebuildCount >= 500 &&
|
||||
!isExemptKernelUpdate &&
|
||||
!isExemptHomeAssistantUpdate
|
||||
) {
|
||||
const stagingBranch =
|
||||
base === 'master' ? 'staging' : `staging-${split(base).version}`
|
||||
const body = [
|
||||
`The PR's base branch is set to \`${base}\`, and this PR causes ${maxRebuildCount} rebuilds.`,
|
||||
`Please consider whether this PR causes a mass rebuild according to [our conventions](https://github.com/NixOS/nixpkgs/blob/master/CONTRIBUTING.md#branch-conventions).`,
|
||||
`If it does cause a mass rebuild, please [change the base branch](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/changing-the-base-branch-of-a-pull-request) to [the right base branch for your changes](https://github.com/NixOS/nixpkgs/blob/master/CONTRIBUTING.md#branch-conventions) (probably \`${stagingBranch}\`).`,
|
||||
`If it does not cause a mass rebuild, this message can be ignored.`,
|
||||
].join('\n')
|
||||
|
||||
await postReview({
|
||||
github,
|
||||
context,
|
||||
core,
|
||||
dry,
|
||||
body,
|
||||
event: 'REQUEST_CHANGES',
|
||||
reviewKey,
|
||||
})
|
||||
} else {
|
||||
core.info('checkTargetBranch: this PR is against an appropriate branch.')
|
||||
|
||||
await dismissReviews({
|
||||
github,
|
||||
context,
|
||||
core,
|
||||
dry,
|
||||
reviewKey,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = checkTargetBranch
|
||||
@@ -1,242 +0,0 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import type * as actionsCore from '@actions/core'
|
||||
import type { context as actionsContext } from '@actions/github'
|
||||
import type { GitHub } from '@actions/github/lib/utils'
|
||||
import {
|
||||
evaluateTargetBranchPolicy,
|
||||
getTargetBranchPolicy,
|
||||
} from './check-target-branch-policy.ts'
|
||||
import { dismissReviews, postReview } from './reviews.ts'
|
||||
import { split } from './supportedBranches.ts'
|
||||
|
||||
// TODO: should this be combined with the branch checks in prepare.ts?
|
||||
// They do seem quite similar, but this needs to run after eval,
|
||||
// and prepare.ts obviously doesn't.
|
||||
|
||||
const reviewKey = 'check-target-branch'
|
||||
|
||||
type ChangedPaths = {
|
||||
attrdiff: {
|
||||
added: string[]
|
||||
changed: string[]
|
||||
removed: string[]
|
||||
}
|
||||
attrdiffByKernel: Record<
|
||||
string,
|
||||
{
|
||||
added: string[]
|
||||
changed: string[]
|
||||
removed: string[]
|
||||
}
|
||||
>
|
||||
attrdiffByPlatform: Record<
|
||||
string,
|
||||
{
|
||||
added: string[]
|
||||
changed: string[]
|
||||
removed: string[]
|
||||
}
|
||||
>
|
||||
labels: Record<string, boolean>
|
||||
rebuildCountByKernel: Record<string, number>
|
||||
rebuildsByKernel: Record<string, string[]>
|
||||
rebuildsByPlatform: Record<string, string[]>
|
||||
}
|
||||
|
||||
type TargetBranchReviewFacts = {
|
||||
github: InstanceType<typeof GitHub>
|
||||
context: typeof actionsContext
|
||||
core: typeof actionsCore
|
||||
dry: boolean
|
||||
base: string
|
||||
maxRebuildCount: number
|
||||
}
|
||||
|
||||
function getStagingBranch(base: string) {
|
||||
const version = split(base).version
|
||||
return version ? `staging-${version}` : 'staging'
|
||||
}
|
||||
|
||||
async function postMassRebuildReview(facts: TargetBranchReviewFacts) {
|
||||
const { github, context, core, dry, base, maxRebuildCount } = facts
|
||||
const desiredBranch = getStagingBranch(base)
|
||||
const body = [
|
||||
`The PR's base branch is set to \`${base}\`, but this PR causes ${maxRebuildCount} rebuilds.`,
|
||||
'It is therefore considered a mass rebuild.',
|
||||
`Please [change the base branch](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/changing-the-base-branch-of-a-pull-request) to [the right base branch for your changes](https://github.com/NixOS/nixpkgs/blob/master/CONTRIBUTING.md#branch-conventions) (probably \`${desiredBranch}\`).`,
|
||||
].join('\n')
|
||||
|
||||
await postReview({
|
||||
github,
|
||||
context,
|
||||
core,
|
||||
dry,
|
||||
body,
|
||||
event: 'REQUEST_CHANGES',
|
||||
reviewKey,
|
||||
})
|
||||
}
|
||||
|
||||
async function postNixosRebuildReview(facts: TargetBranchReviewFacts) {
|
||||
const { github, context, core, dry, base, maxRebuildCount } = facts
|
||||
let branchText: string
|
||||
if (base === 'master' && maxRebuildCount >= 500) {
|
||||
branchText = '(probably either `staging-nixos` or `staging`)'
|
||||
} else if (base === 'master') {
|
||||
branchText = '(probably `staging-nixos`)'
|
||||
} else if (maxRebuildCount >= 500) {
|
||||
branchText = `(probably either \`staging-nixos-${split(base).version}\` or \`staging-${split(base).version}\`)`
|
||||
} else {
|
||||
branchText = `(probably \`staging-nixos-${split(base).version}\`)`
|
||||
}
|
||||
const body = [
|
||||
`The PR's base branch is set to \`${base}\`, but this PR rebuilds all NixOS tests.`,
|
||||
base === 'master' && maxRebuildCount >= 500
|
||||
? `Since this PR also causes ${maxRebuildCount} rebuilds, it may also be considered a mass rebuild.`
|
||||
: '',
|
||||
`Please [change the base branch](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/changing-the-base-branch-of-a-pull-request) to [the right base branch for your changes](https://github.com/NixOS/nixpkgs/blob/master/CONTRIBUTING.md#branch-conventions) ${branchText}.`,
|
||||
].join('\n')
|
||||
|
||||
await postReview({
|
||||
github,
|
||||
context,
|
||||
core,
|
||||
dry,
|
||||
body,
|
||||
event: 'REQUEST_CHANGES',
|
||||
reviewKey,
|
||||
})
|
||||
}
|
||||
|
||||
async function postPossibleMassRebuildReview(facts: TargetBranchReviewFacts) {
|
||||
const { github, context, core, dry, base, maxRebuildCount } = facts
|
||||
const stagingBranch = getStagingBranch(base)
|
||||
const body = [
|
||||
`The PR's base branch is set to \`${base}\`, and this PR causes ${maxRebuildCount} rebuilds.`,
|
||||
`Please consider whether this PR causes a mass rebuild according to [our conventions](https://github.com/NixOS/nixpkgs/blob/master/CONTRIBUTING.md#branch-conventions).`,
|
||||
`If it does cause a mass rebuild, please [change the base branch](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/changing-the-base-branch-of-a-pull-request) to [the right base branch for your changes](https://github.com/NixOS/nixpkgs/blob/master/CONTRIBUTING.md#branch-conventions) (probably \`${stagingBranch}\`).`,
|
||||
`If it does not cause a mass rebuild, this message can be ignored.`,
|
||||
].join('\n')
|
||||
|
||||
await postReview({
|
||||
github,
|
||||
context,
|
||||
core,
|
||||
dry,
|
||||
body,
|
||||
event: 'REQUEST_CHANGES',
|
||||
reviewKey,
|
||||
})
|
||||
}
|
||||
|
||||
export async function checkTargetBranch({
|
||||
github,
|
||||
context,
|
||||
core,
|
||||
dry,
|
||||
}: {
|
||||
github: InstanceType<typeof GitHub>
|
||||
context: typeof actionsContext
|
||||
core: typeof actionsCore
|
||||
dry: boolean
|
||||
}) {
|
||||
const changed: ChangedPaths = JSON.parse(
|
||||
await readFile('comparison/changed-paths.json', 'utf-8'),
|
||||
)
|
||||
const pull_number = context.payload.pull_request?.number
|
||||
if (!pull_number) {
|
||||
core.warning(
|
||||
'Skipping checkTargetBranch: no pull_request number (is this being run as part of a merge group?)',
|
||||
)
|
||||
return
|
||||
}
|
||||
const prInfo = (
|
||||
await github.rest.pulls.get({
|
||||
...context.repo,
|
||||
pull_number,
|
||||
})
|
||||
).data
|
||||
const base = prInfo.base.ref
|
||||
const head = prInfo.head.ref
|
||||
const { shouldCheckMassRebuild } = getTargetBranchPolicy({ base, head })
|
||||
|
||||
const maxRebuildCount = Math.max(
|
||||
...Object.values(changed.rebuildCountByKernel),
|
||||
)
|
||||
const rebuildsAllTests =
|
||||
changed.attrdiff.changed.includes('nixosTests.simple-container') ||
|
||||
changed.attrdiff.changed.includes('nixosTests.simple-vm')
|
||||
|
||||
let onlyChangedFile: string | null = null
|
||||
if (shouldCheckMassRebuild && prInfo.changed_files === 1) {
|
||||
const changedFiles = (
|
||||
await github.rest.pulls.listFiles({
|
||||
...context.repo,
|
||||
pull_number,
|
||||
})
|
||||
).data
|
||||
onlyChangedFile =
|
||||
changedFiles.length === 1 ? changedFiles[0].filename : null
|
||||
}
|
||||
|
||||
const {
|
||||
decision,
|
||||
details: { isExemptKernelUpdate, isExemptHomeAssistantUpdate },
|
||||
} = evaluateTargetBranchPolicy({
|
||||
base,
|
||||
head,
|
||||
maxRebuildCount,
|
||||
rebuildsAllTests,
|
||||
onlyChangedFile,
|
||||
})
|
||||
|
||||
core.info(
|
||||
[
|
||||
`checkTargetBranch: this PR:`,
|
||||
` * causes ${maxRebuildCount} rebuilds`,
|
||||
` * ${rebuildsAllTests ? 'rebuilds' : 'does not rebuild'} all NixOS tests`,
|
||||
` * ${isExemptKernelUpdate ? 'is' : 'is not'} an exempt kernel update`,
|
||||
` * ${isExemptHomeAssistantUpdate ? 'is' : 'is not'} an exempt home-assistant update`,
|
||||
].join('\n'),
|
||||
)
|
||||
|
||||
const reviewFacts: TargetBranchReviewFacts = {
|
||||
github,
|
||||
context,
|
||||
core,
|
||||
dry,
|
||||
base,
|
||||
maxRebuildCount,
|
||||
}
|
||||
|
||||
if (decision === 'mass-rebuild') {
|
||||
await postMassRebuildReview(reviewFacts)
|
||||
return
|
||||
}
|
||||
|
||||
if (decision === 'nixos-rebuild') {
|
||||
await postNixosRebuildReview(reviewFacts)
|
||||
return
|
||||
}
|
||||
|
||||
if (decision === 'possible-mass-rebuild') {
|
||||
await postPossibleMassRebuildReview(reviewFacts)
|
||||
return
|
||||
}
|
||||
|
||||
if (decision === 'skip-development-merge') {
|
||||
core.info(
|
||||
`Skipping checkTargetBranch: PR merges the development branch ${head} into ${base}`,
|
||||
)
|
||||
} else {
|
||||
core.info('checkTargetBranch: this PR is against an appropriate branch.')
|
||||
}
|
||||
|
||||
await dismissReviews({
|
||||
github,
|
||||
context,
|
||||
core,
|
||||
dry,
|
||||
reviewKey,
|
||||
})
|
||||
}
|
||||
322
ci/github-script/commits.js
Normal file
322
ci/github-script/commits.js
Normal file
@@ -0,0 +1,322 @@
|
||||
module.exports = async ({ github, context, core, dry, cherryPicks }) => {
|
||||
const { execFileSync } = require('node:child_process')
|
||||
const { classify } = require('../supportedBranches.js')
|
||||
const withRateLimit = require('./withRateLimit.js')
|
||||
const { dismissReviews, postReview } = require('./reviews.js')
|
||||
const reviewKey = 'check-commits'
|
||||
|
||||
await withRateLimit({ github, core }, async (stats) => {
|
||||
stats.prs = 1
|
||||
|
||||
const pull_number = context.payload.pull_request.number
|
||||
|
||||
const job_url =
|
||||
context.runId &&
|
||||
(
|
||||
await github.paginate(github.rest.actions.listJobsForWorkflowRun, {
|
||||
...context.repo,
|
||||
run_id: context.runId,
|
||||
per_page: 100,
|
||||
})
|
||||
).find(({ name }) => name.endsWith('Check / commits')).html_url +
|
||||
'?pr=' +
|
||||
pull_number
|
||||
|
||||
async function extract({ sha, commit }) {
|
||||
const noCherryPick = Array.from(
|
||||
commit.message.matchAll(/^Not-cherry-picked-because: (.*)$/gm),
|
||||
).at(0)
|
||||
|
||||
if (noCherryPick)
|
||||
return {
|
||||
sha,
|
||||
commit,
|
||||
severity: 'important',
|
||||
message: `${sha} is not a cherry-pick, because: ${noCherryPick[1]}. Please review this commit manually.`,
|
||||
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)
|
||||
|
||||
if (!cherry)
|
||||
return {
|
||||
sha,
|
||||
commit,
|
||||
severity: 'warning',
|
||||
message: `Couldn't locate the cherry-picked commit's hash in the commit message of ${sha}.`,
|
||||
type: 'no-commit-hash',
|
||||
}
|
||||
|
||||
const original_sha = cherry[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.`,
|
||||
}
|
||||
|
||||
return {
|
||||
sha,
|
||||
commit,
|
||||
original_sha,
|
||||
}
|
||||
}
|
||||
|
||||
function diff({ sha, commit, original_sha }) {
|
||||
const diff = execFileSync('git', [
|
||||
'-C',
|
||||
__dirname,
|
||||
'range-diff',
|
||||
'--no-color',
|
||||
'--ignore-all-space',
|
||||
'--no-notes',
|
||||
// 100 means "any change will be reported"; 0 means "no change will be reported"
|
||||
'--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.`,
|
||||
type: 'diff',
|
||||
}
|
||||
}
|
||||
|
||||
// For now we short-circuit the list of commits when cherryPicks should not be checked.
|
||||
// This will not run any checks, but still trigger the "dismiss reviews" part below.
|
||||
const commits = !cherryPicks
|
||||
? []
|
||||
: await github.paginate(github.rest.pulls.listCommits, {
|
||||
...context.repo,
|
||||
pull_number,
|
||||
})
|
||||
|
||||
const extracted = await Promise.all(commits.map(extract))
|
||||
|
||||
const fetch = extracted
|
||||
.filter(({ severity }) => !severity)
|
||||
.flatMap(({ sha, original_sha }) => [sha, original_sha])
|
||||
|
||||
if (fetch.length > 0) {
|
||||
// Fetching all commits we need for diff at once is much faster than any other method.
|
||||
execFileSync('git', [
|
||||
'-C',
|
||||
__dirname,
|
||||
'fetch',
|
||||
'--depth=2',
|
||||
'origin',
|
||||
...fetch,
|
||||
])
|
||||
}
|
||||
|
||||
const results = extracted.map((result) =>
|
||||
result.severity ? result : diff(result),
|
||||
)
|
||||
|
||||
// Log all results without truncation, with better highlighting and all whitespace changes 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)}`)
|
||||
switch (severity) {
|
||||
case 'error':
|
||||
core.error(message)
|
||||
break
|
||||
case 'warning':
|
||||
core.warning(message)
|
||||
break
|
||||
default:
|
||||
core.info(message)
|
||||
}
|
||||
core.endGroup()
|
||||
if (colored_diff) core.info(colored_diff)
|
||||
})
|
||||
|
||||
// Only create step summary below in case of warnings or errors.
|
||||
// Also clean up older reviews, when all checks are good now.
|
||||
// An empty results array will always trigger this condition, which is helpful
|
||||
// to clean up reviews created by the prepare step when on the wrong branch.
|
||||
if (results.every(({ severity }) => severity === 'info')) {
|
||||
await dismissReviews({ github, context, dry, reviewKey })
|
||||
return
|
||||
}
|
||||
|
||||
// In the case of "error" severity, we also fail the job.
|
||||
// Those should be considered blocking and not be dismissable via review.
|
||||
if (results.some(({ severity }) => severity === 'error'))
|
||||
process.exitCode = 1
|
||||
|
||||
core.summary.addRaw(
|
||||
'This report is automatically generated by the `PR / Check / cherry-pick` CI workflow.',
|
||||
true,
|
||||
)
|
||||
core.summary.addEOL()
|
||||
core.summary.addRaw(
|
||||
"Some of the commits in this PR require the author's and reviewer's attention.",
|
||||
true,
|
||||
)
|
||||
core.summary.addEOL()
|
||||
|
||||
if (results.some(({ type }) => type === 'no-commit-hash')) {
|
||||
core.summary.addRaw(
|
||||
'Please 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.',
|
||||
true,
|
||||
)
|
||||
core.summary.addRaw(
|
||||
'This requires changes to the unstable `master` and `staging` branches first, before backporting them.',
|
||||
true,
|
||||
)
|
||||
core.summary.addEOL()
|
||||
core.summary.addRaw(
|
||||
'Occasionally, commits are not cherry-picked at all, for example when updating minor versions of packages which have already advanced to the next major on unstable.',
|
||||
true,
|
||||
)
|
||||
core.summary.addRaw(
|
||||
'These commits can optionally be marked with a `Not-cherry-picked-because: <reason>` footer.',
|
||||
true,
|
||||
)
|
||||
core.summary.addEOL()
|
||||
}
|
||||
|
||||
if (results.some(({ type }) => type === 'diff')) {
|
||||
core.summary.addRaw(
|
||||
'Sometimes it is not possible to cherry-pick exactly the same patch.',
|
||||
true,
|
||||
)
|
||||
core.summary.addRaw(
|
||||
'This most frequently happens when resolving merge conflicts.',
|
||||
true,
|
||||
)
|
||||
core.summary.addRaw(
|
||||
'The range-diff will help to review the resolution of conflicts.',
|
||||
true,
|
||||
)
|
||||
core.summary.addEOL()
|
||||
}
|
||||
|
||||
core.summary.addRaw(
|
||||
'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 shortly before merging.',
|
||||
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[!${{ important: 'IMPORTANT', warning: 'WARNING', error: 'CAUTION' }[severity]}]`,
|
||||
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._`,
|
||||
)
|
||||
|
||||
const body = core.summary.stringify()
|
||||
core.summary.write()
|
||||
|
||||
// Posting a review could fail for very long comments. This can only happen with
|
||||
// multiple commits all hitting the truncation limit for the diff. If you ever hit
|
||||
// this case, consider just splitting up those commits into multiple PRs.
|
||||
await postReview({ github, context, core, dry, body, reviewKey })
|
||||
})
|
||||
}
|
||||
@@ -1,397 +0,0 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import type * as actionsCore from '@actions/core'
|
||||
import type { context as actionsContext } from '@actions/github'
|
||||
import type { GitHub } from '@actions/github/lib/utils'
|
||||
import { dismissReviews, postReview } from './reviews.ts'
|
||||
import { classify } from './supportedBranches.ts'
|
||||
import withRateLimit from './withRateLimit.ts'
|
||||
|
||||
const dirname = import.meta.dirname
|
||||
|
||||
type PullRequestCommit = Awaited<
|
||||
ReturnType<InstanceType<typeof GitHub>['rest']['pulls']['listCommits']>
|
||||
>['data'][number]
|
||||
|
||||
type CommitDetails = Pick<PullRequestCommit, 'sha' | 'commit'>
|
||||
|
||||
type CherryPick = CommitDetails & {
|
||||
original_sha: string
|
||||
severity?: undefined
|
||||
}
|
||||
|
||||
type CheckResult = CommitDetails & {
|
||||
diff?: string[]
|
||||
colored_diff?: string
|
||||
severity: 'important' | 'warning' | 'error' | 'info'
|
||||
message: string
|
||||
type?: 'no-cherry-pick' | 'no-commit-hash' | 'diff'
|
||||
}
|
||||
|
||||
type ExtractedCommit = CheckResult | CherryPick
|
||||
|
||||
type CheckCommitsProps = {
|
||||
github: InstanceType<typeof GitHub>
|
||||
context: typeof actionsContext
|
||||
core: typeof actionsCore
|
||||
dry: boolean
|
||||
cherryPicks: boolean
|
||||
}
|
||||
|
||||
type CommitBranches = {
|
||||
branches: {
|
||||
branch: string
|
||||
}[]
|
||||
}
|
||||
|
||||
type RateLimitStats = {
|
||||
prs: number
|
||||
}
|
||||
|
||||
function isCherryPick(result: ExtractedCommit): result is CherryPick {
|
||||
return !result.severity
|
||||
}
|
||||
|
||||
function isIgnoredBranchError(error: unknown) {
|
||||
return (
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'status' in error &&
|
||||
(error.status === 404 || error.status === 500)
|
||||
)
|
||||
}
|
||||
|
||||
export default async ({
|
||||
github,
|
||||
context,
|
||||
core,
|
||||
dry,
|
||||
cherryPicks,
|
||||
}: CheckCommitsProps) => {
|
||||
const reviewKey = 'check-commits'
|
||||
|
||||
await withRateLimit({ github, core }, async (stats: RateLimitStats) => {
|
||||
stats.prs = 1
|
||||
|
||||
const pull_number = context.payload.pull_request!.number
|
||||
|
||||
const job_url =
|
||||
context.runId &&
|
||||
(
|
||||
await github.paginate(github.rest.actions.listJobsForWorkflowRun, {
|
||||
...context.repo,
|
||||
run_id: context.runId,
|
||||
per_page: 100,
|
||||
})
|
||||
).find(({ name }) => name.endsWith('Check / commits'))!.html_url +
|
||||
'?pr=' +
|
||||
pull_number
|
||||
|
||||
async function extract({
|
||||
sha,
|
||||
commit,
|
||||
}: CommitDetails): Promise<ExtractedCommit> {
|
||||
const noCherryPick = Array.from(
|
||||
commit.message.matchAll(/^Not-cherry-picked-because: (.*)$/gm),
|
||||
).at(0)
|
||||
|
||||
if (noCherryPick)
|
||||
return {
|
||||
sha,
|
||||
commit,
|
||||
severity: 'important',
|
||||
message: `${sha} is not a cherry-pick, because: ${noCherryPick[1]}. Please review this commit manually.`,
|
||||
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)
|
||||
|
||||
if (!cherry)
|
||||
return {
|
||||
sha,
|
||||
commit,
|
||||
severity: 'warning',
|
||||
message: `Couldn't locate the cherry-picked commit's hash in the commit message of ${sha}.`,
|
||||
type: 'no-commit-hash',
|
||||
}
|
||||
|
||||
const original_sha = cherry[1]
|
||||
|
||||
let branches: string[] | undefined
|
||||
try {
|
||||
branches = (
|
||||
await github.request<CommitBranches>({
|
||||
// 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)
|
||||
return {
|
||||
sha,
|
||||
commit,
|
||||
severity: 'error',
|
||||
message: `${original_sha} given in ${sha} not found in any pickable branch.`,
|
||||
}
|
||||
|
||||
return {
|
||||
sha,
|
||||
commit,
|
||||
original_sha,
|
||||
}
|
||||
}
|
||||
|
||||
function diff({ sha, commit, original_sha }: CherryPick): CheckResult {
|
||||
const diff = execFileSync('git', [
|
||||
'-C',
|
||||
dirname,
|
||||
'range-diff',
|
||||
'--no-color',
|
||||
'--ignore-all-space',
|
||||
'--no-notes',
|
||||
// 100 means "any change will be reported"; 0 means "no change will be reported"
|
||||
'--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.`,
|
||||
type: 'diff',
|
||||
}
|
||||
}
|
||||
|
||||
// For now we short-circuit the list of commits when cherryPicks should not be checked.
|
||||
// This will not run any checks, but still trigger the "dismiss reviews" part below.
|
||||
const commits = !cherryPicks
|
||||
? []
|
||||
: await github.paginate(github.rest.pulls.listCommits, {
|
||||
...context.repo,
|
||||
pull_number,
|
||||
})
|
||||
|
||||
const extracted = await Promise.all(commits.map(extract))
|
||||
|
||||
const fetch = extracted
|
||||
.filter(isCherryPick)
|
||||
.flatMap(({ sha, original_sha }) => [sha, original_sha])
|
||||
|
||||
if (fetch.length > 0) {
|
||||
// Fetching all commits we need for diff at once is much faster than any other method.
|
||||
execFileSync('git', [
|
||||
'-C',
|
||||
dirname,
|
||||
'fetch',
|
||||
'--depth=2',
|
||||
'origin',
|
||||
...fetch,
|
||||
])
|
||||
}
|
||||
|
||||
const results = extracted.map((result) =>
|
||||
result.severity ? result : diff(result),
|
||||
)
|
||||
|
||||
// Log all results without truncation, with better highlighting and all whitespace changes 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!)}`)
|
||||
switch (severity) {
|
||||
case 'error':
|
||||
core.error(message)
|
||||
break
|
||||
case 'warning':
|
||||
core.warning(message)
|
||||
break
|
||||
default:
|
||||
core.info(message)
|
||||
}
|
||||
core.endGroup()
|
||||
if (colored_diff) core.info(colored_diff)
|
||||
})
|
||||
|
||||
// Only create step summary below in case of warnings or errors.
|
||||
// Also clean up older reviews, when all checks are good now.
|
||||
// An empty results array will always trigger this condition, which is helpful
|
||||
// to clean up reviews created by the prepare step when on the wrong branch.
|
||||
if (results.every(({ severity }) => severity === 'info')) {
|
||||
await dismissReviews({ github, context, core, dry, reviewKey })
|
||||
return
|
||||
}
|
||||
|
||||
// In the case of "error" severity, we also fail the job.
|
||||
// Those should be considered blocking and not be dismissable via review.
|
||||
if (results.some(({ severity }) => severity === 'error'))
|
||||
process.exitCode = 1
|
||||
|
||||
core.summary.addRaw(
|
||||
'This report is automatically generated by the `PR / Check / cherry-pick` CI workflow.',
|
||||
true,
|
||||
)
|
||||
core.summary.addEOL()
|
||||
core.summary.addRaw(
|
||||
"Some of the commits in this PR require the author's and reviewer's attention.",
|
||||
true,
|
||||
)
|
||||
core.summary.addEOL()
|
||||
|
||||
if (results.some(({ type }) => type === 'no-commit-hash')) {
|
||||
core.summary.addRaw(
|
||||
'Please 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.',
|
||||
true,
|
||||
)
|
||||
core.summary.addRaw(
|
||||
'This requires changes to the unstable `master` and `staging` branches first, before backporting them.',
|
||||
true,
|
||||
)
|
||||
core.summary.addEOL()
|
||||
core.summary.addRaw(
|
||||
'Occasionally, commits are not cherry-picked at all, for example when updating minor versions of packages which have already advanced to the next major on unstable.',
|
||||
true,
|
||||
)
|
||||
core.summary.addRaw(
|
||||
'These commits can optionally be marked with a `Not-cherry-picked-because: <reason>` footer.',
|
||||
true,
|
||||
)
|
||||
core.summary.addEOL()
|
||||
}
|
||||
|
||||
if (results.some(({ type }) => type === 'diff')) {
|
||||
core.summary.addRaw(
|
||||
'Sometimes it is not possible to cherry-pick exactly the same patch.',
|
||||
true,
|
||||
)
|
||||
core.summary.addRaw(
|
||||
'This most frequently happens when resolving merge conflicts.',
|
||||
true,
|
||||
)
|
||||
core.summary.addRaw(
|
||||
'The range-diff will help to review the resolution of conflicts.',
|
||||
true,
|
||||
)
|
||||
core.summary.addEOL()
|
||||
}
|
||||
|
||||
core.summary.addRaw(
|
||||
'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 shortly before merging.',
|
||||
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[!${{ important: 'IMPORTANT', warning: 'WARNING', error: 'CAUTION' }[severity]}]`,
|
||||
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 (const 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._`,
|
||||
)
|
||||
|
||||
const body = core.summary.stringify()
|
||||
core.summary.write()
|
||||
|
||||
// Posting a review could fail for very long comments. This can only happen with
|
||||
// multiple commits all hitting the truncation limit for the diff. If you ever hit
|
||||
// this case, consider just splitting up those commits into multiple PRs.
|
||||
await postReview({
|
||||
github,
|
||||
context,
|
||||
core,
|
||||
dry,
|
||||
body,
|
||||
event: 'REQUEST_CHANGES',
|
||||
reviewKey,
|
||||
})
|
||||
})
|
||||
}
|
||||
117
ci/github-script/get-pr-commit-details.js
Normal file
117
ci/github-script/get-pr-commit-details.js
Normal file
@@ -0,0 +1,117 @@
|
||||
// @ts-check
|
||||
const { promisify } = require('node:util')
|
||||
const execFile = promisify(require('node:child_process').execFile)
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* subject: string,
|
||||
* sha: string,
|
||||
* author: { name: string, email: string },
|
||||
* committer: { name: string, email: string}
|
||||
* changedPaths: string[],
|
||||
* changedPathSegments: Set<string>,
|
||||
* }} Commit
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* args: string[]
|
||||
* core: import('@actions/core'),
|
||||
* quiet?: boolean,
|
||||
* repoPath?: string,
|
||||
* }} RunGitProps
|
||||
*/
|
||||
async function runGit({ args, repoPath, core, quiet }) {
|
||||
if (repoPath) {
|
||||
args = ['-C', repoPath, ...args]
|
||||
}
|
||||
|
||||
if (!quiet) {
|
||||
core.info(`About to run \`git ${args.map((s) => `'${s}'`).join(' ')}\``)
|
||||
}
|
||||
|
||||
return await execFile('git', args)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the SHA, subject and changed files for each commit in the given PR.
|
||||
*
|
||||
* Don't use GitHub API at all: the "list commits on PR" endpoint has a limit
|
||||
* of 250 commits and doesn't return the changed files.
|
||||
*
|
||||
* @param {{
|
||||
* core: import('@actions/core'),
|
||||
* pr: Awaited<ReturnType<InstanceType<import('@actions/github/lib/utils').GitHub>["rest"]["pulls"]["get"]>>["data"]
|
||||
* repoPath?: string,
|
||||
* }} GetCommitMessagesForPRProps
|
||||
*
|
||||
* @returns {Promise<Commit[]>}
|
||||
*/
|
||||
async function getCommitDetailsForPR({ core, pr, repoPath }) {
|
||||
await runGit({
|
||||
args: ['fetch', `--depth=1`, 'origin', pr.base.sha],
|
||||
repoPath,
|
||||
core,
|
||||
})
|
||||
await runGit({
|
||||
args: ['fetch', `--depth=${pr.commits + 1}`, 'origin', pr.head.sha],
|
||||
repoPath,
|
||||
core,
|
||||
})
|
||||
|
||||
const shas = (
|
||||
await runGit({
|
||||
args: [
|
||||
'rev-list',
|
||||
`--max-count=${pr.commits}`,
|
||||
`${pr.base.sha}..${pr.head.sha}`,
|
||||
],
|
||||
repoPath,
|
||||
core,
|
||||
})
|
||||
).stdout
|
||||
.split('\n')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
return Promise.all(
|
||||
shas.map(async (sha) => {
|
||||
// Subject, author name, author email, committer name, committer email (all tab-seperated)
|
||||
// then a blank line, then filenames.
|
||||
const result = (
|
||||
await runGit({
|
||||
args: [
|
||||
'log',
|
||||
'--format=%s\t%aN\t%aE\t%cN\t%cE',
|
||||
'--name-only',
|
||||
'-1',
|
||||
sha,
|
||||
],
|
||||
repoPath,
|
||||
core,
|
||||
quiet: true,
|
||||
})
|
||||
).stdout.split('\n')
|
||||
|
||||
const [subject, authorName, authorEmail, committerName, committerEmail] =
|
||||
result[0].split('\t')
|
||||
|
||||
const changedPaths = result.slice(2, -1)
|
||||
|
||||
const changedPathSegments = new Set(
|
||||
changedPaths.flatMap((path) => path.split('/')),
|
||||
)
|
||||
|
||||
return {
|
||||
sha,
|
||||
subject,
|
||||
author: { name: authorName, email: authorEmail },
|
||||
committer: { name: committerName, email: committerEmail },
|
||||
changedPaths,
|
||||
changedPathSegments,
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = { getCommitDetailsForPR }
|
||||
@@ -1,121 +0,0 @@
|
||||
import { execFile as nodeExecFile } from 'node:child_process'
|
||||
import { promisify } from 'node:util'
|
||||
|
||||
const execFile = promisify(nodeExecFile)
|
||||
|
||||
export type Commit = {
|
||||
subject: string
|
||||
sha: string
|
||||
author: { name: string; email: string }
|
||||
committer: { name: string; email: string }
|
||||
changedPaths: string[]
|
||||
changedPathSegments: Set<string>
|
||||
}
|
||||
|
||||
interface RunGitProps {
|
||||
args: string[]
|
||||
core: typeof import('@actions/core')
|
||||
quiet?: boolean
|
||||
repoPath?: string
|
||||
}
|
||||
|
||||
async function runGit({ args, repoPath, core, quiet }: RunGitProps) {
|
||||
if (repoPath) {
|
||||
args = ['-C', repoPath, ...args]
|
||||
}
|
||||
|
||||
if (!quiet) {
|
||||
core.info(`About to run \`git ${args.map((s) => `'${s}'`).join(' ')}\``)
|
||||
}
|
||||
|
||||
return await execFile('git', args)
|
||||
}
|
||||
|
||||
interface GetCommitMessagesForPRProps {
|
||||
core: typeof import('@actions/core')
|
||||
pr: Awaited<
|
||||
ReturnType<
|
||||
InstanceType<
|
||||
typeof import('@actions/github/lib/utils').GitHub
|
||||
>['rest']['pulls']['get']
|
||||
>
|
||||
>['data']
|
||||
repoPath?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the SHA, subject and changed files for each commit in the given PR.
|
||||
*
|
||||
* Don't use GitHub API at all: the "list commits on PR" endpoint has a limit
|
||||
* of 250 commits and doesn't return the changed files.
|
||||
*/
|
||||
export async function getCommitDetailsForPR({
|
||||
core,
|
||||
pr,
|
||||
repoPath,
|
||||
}: GetCommitMessagesForPRProps): Promise<Commit[]> {
|
||||
await runGit({
|
||||
args: ['fetch', `--depth=1`, 'origin', pr.base.sha],
|
||||
repoPath,
|
||||
core,
|
||||
})
|
||||
await runGit({
|
||||
args: ['fetch', `--depth=${pr.commits + 1}`, 'origin', pr.head.sha],
|
||||
repoPath,
|
||||
core,
|
||||
})
|
||||
|
||||
const shas = (
|
||||
await runGit({
|
||||
args: [
|
||||
'rev-list',
|
||||
`--max-count=${pr.commits}`,
|
||||
`${pr.base.sha}..${pr.head.sha}`,
|
||||
],
|
||||
repoPath,
|
||||
core,
|
||||
})
|
||||
).stdout
|
||||
.split('\n')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
return Promise.all(
|
||||
shas.map(async (sha) => {
|
||||
// Subject, author name, author email, committer name, committer email (all tab-separated)
|
||||
// then a blank line, then filenames.
|
||||
const result = (
|
||||
await runGit({
|
||||
args: [
|
||||
'log',
|
||||
'--format=%s\t%aN\t%aE\t%cN\t%cE',
|
||||
'--name-only',
|
||||
'-1',
|
||||
sha,
|
||||
],
|
||||
repoPath,
|
||||
core,
|
||||
quiet: true,
|
||||
})
|
||||
).stdout.split('\n')
|
||||
|
||||
const [subject, authorName, authorEmail, committerName, committerEmail] =
|
||||
result[0].split('\t')
|
||||
|
||||
const changedPaths = result.slice(2, -1)
|
||||
|
||||
const changedPathSegments = new Set(
|
||||
changedPaths.flatMap((path) => path.split('/')),
|
||||
)
|
||||
|
||||
return {
|
||||
sha,
|
||||
subject,
|
||||
author: { name: authorName, email: authorEmail },
|
||||
committer: { name: committerName, email: committerEmail },
|
||||
changedPaths,
|
||||
changedPathSegments,
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
85
ci/github-script/get-teams.js
Normal file
85
ci/github-script/get-teams.js
Normal file
@@ -0,0 +1,85 @@
|
||||
const excludeTeams = [
|
||||
/^voters.*$/,
|
||||
/^nixpkgs-maintainers$/,
|
||||
/^nixpkgs-committers$/,
|
||||
]
|
||||
|
||||
module.exports = async ({ github, context, core, outFile }) => {
|
||||
const withRateLimit = require('./withRateLimit.js')
|
||||
const { writeFileSync } = require('node:fs')
|
||||
|
||||
const org = context.repo.owner
|
||||
|
||||
const result = {}
|
||||
|
||||
await withRateLimit({ github, core }, async () => {
|
||||
// Turn an Array of users into an Object, mapping user.login -> user.id
|
||||
function makeUserSet(users) {
|
||||
// Sort in-place and build result by mutation
|
||||
users.sort((a, b) => (a.login > b.login ? 1 : -1))
|
||||
|
||||
return users.reduce((acc, user) => {
|
||||
acc[user.login] = user.id
|
||||
return acc
|
||||
}, {})
|
||||
}
|
||||
|
||||
// Process a list of teams and append to the result variable
|
||||
async function processTeams(teams) {
|
||||
for (const team of teams) {
|
||||
core.notice(`Processing team ${team.slug}`)
|
||||
if (!excludeTeams.some((regex) => team.slug.match(regex))) {
|
||||
const members = makeUserSet(
|
||||
await github.paginate(github.rest.teams.listMembersInOrg, {
|
||||
org,
|
||||
team_slug: team.slug,
|
||||
role: 'member',
|
||||
}),
|
||||
)
|
||||
const maintainers = makeUserSet(
|
||||
await github.paginate(github.rest.teams.listMembersInOrg, {
|
||||
org,
|
||||
team_slug: team.slug,
|
||||
role: 'maintainer',
|
||||
}),
|
||||
)
|
||||
result[team.slug] = {
|
||||
description: team.description,
|
||||
id: team.id,
|
||||
maintainers,
|
||||
members,
|
||||
name: team.name,
|
||||
}
|
||||
}
|
||||
await processTeams(
|
||||
await github.paginate(github.rest.teams.listChildInOrg, {
|
||||
org,
|
||||
team_slug: team.slug,
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const teams = await github.paginate(github.rest.repos.listTeams, {
|
||||
...context.repo,
|
||||
})
|
||||
|
||||
await processTeams(teams)
|
||||
})
|
||||
|
||||
// Sort the teams by team name
|
||||
const sorted = Object.keys(result)
|
||||
.sort()
|
||||
.reduce((acc, key) => {
|
||||
acc[key] = result[key]
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
const json = `${JSON.stringify(sorted, null, 2)}\n`
|
||||
|
||||
if (outFile) {
|
||||
writeFileSync(outFile, json)
|
||||
} else {
|
||||
console.log(json)
|
||||
}
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
import { writeFileSync } from 'node:fs'
|
||||
import withRateLimit from './withRateLimit.ts'
|
||||
|
||||
const excludeTeams = [
|
||||
/^voters.*$/,
|
||||
/^nixpkgs-maintainers$/,
|
||||
/^nixpkgs-committers$/,
|
||||
]
|
||||
|
||||
type GitHub = InstanceType<typeof import('@actions/github/lib/utils').GitHub>
|
||||
type Context = typeof import('@actions/github').context
|
||||
type Core = typeof import('@actions/core')
|
||||
type Team = Awaited<
|
||||
ReturnType<GitHub['rest']['repos']['listTeams']>
|
||||
>['data'][number]
|
||||
type User = Awaited<
|
||||
ReturnType<GitHub['rest']['teams']['listMembersInOrg']>
|
||||
>['data'][number]
|
||||
|
||||
interface Result {
|
||||
[slug: string]: {
|
||||
description: string | null
|
||||
id: number
|
||||
maintainers: unknown
|
||||
members: unknown
|
||||
name: string
|
||||
}
|
||||
}
|
||||
|
||||
export default async ({
|
||||
github,
|
||||
context,
|
||||
core,
|
||||
outFile,
|
||||
}: {
|
||||
github: GitHub
|
||||
context: Context
|
||||
core: Core
|
||||
outFile: string
|
||||
}) => {
|
||||
const org = context.repo.owner
|
||||
|
||||
const result: Result = {}
|
||||
|
||||
await withRateLimit({ github, core }, async () => {
|
||||
// Turn an Array of users into an Object, mapping user.login -> user.id
|
||||
function makeUserSet(users: User[]) {
|
||||
// Sort in-place and build result by mutation
|
||||
users.sort((a, b) => (a.login > b.login ? 1 : -1))
|
||||
|
||||
return users.reduce(
|
||||
(acc, user) => {
|
||||
acc[user.login] = user.id
|
||||
return acc
|
||||
},
|
||||
{} as { [login: string]: number },
|
||||
)
|
||||
}
|
||||
|
||||
// Process a list of teams and append to the result variable
|
||||
async function processTeams(teams: Team[]) {
|
||||
for (const team of teams) {
|
||||
core.notice(`Processing team ${team.slug}`)
|
||||
if (!excludeTeams.some((regex) => team.slug.match(regex))) {
|
||||
const members = makeUserSet(
|
||||
await github.paginate(github.rest.teams.listMembersInOrg, {
|
||||
org,
|
||||
team_slug: team.slug,
|
||||
role: 'member',
|
||||
}),
|
||||
)
|
||||
const maintainers = makeUserSet(
|
||||
await github.paginate(github.rest.teams.listMembersInOrg, {
|
||||
org,
|
||||
team_slug: team.slug,
|
||||
role: 'maintainer',
|
||||
}),
|
||||
)
|
||||
result[team.slug] = {
|
||||
description: team.description,
|
||||
id: team.id,
|
||||
maintainers,
|
||||
members,
|
||||
name: team.name,
|
||||
}
|
||||
}
|
||||
await processTeams(
|
||||
await github.paginate(github.rest.teams.listChildInOrg, {
|
||||
org,
|
||||
team_slug: team.slug,
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const teams = await github.paginate(github.rest.repos.listTeams, {
|
||||
...context.repo,
|
||||
})
|
||||
|
||||
await processTeams(teams)
|
||||
})
|
||||
|
||||
// Sort the teams by team name
|
||||
const sorted = Object.keys(result)
|
||||
.sort()
|
||||
.reduce((acc, key) => {
|
||||
acc[key] = result[key]
|
||||
return acc
|
||||
}, {} as Result)
|
||||
|
||||
const json = `${JSON.stringify(sorted, null, 2)}\n`
|
||||
|
||||
if (outFile) {
|
||||
writeFileSync(outFile, json)
|
||||
} else {
|
||||
console.log(json)
|
||||
}
|
||||
}
|
||||
225
ci/github-script/lint-commits.js
Normal file
225
ci/github-script/lint-commits.js
Normal file
@@ -0,0 +1,225 @@
|
||||
// @ts-check
|
||||
const { classify } = require('../supportedBranches.js')
|
||||
const { getCommitDetailsForPR } = require('./get-pr-commit-details.js')
|
||||
|
||||
/** @typedef {import('./get-pr-commit-details.js').Commit} Commit */
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* github: InstanceType<import('@actions/github/lib/utils').GitHub>,
|
||||
* context: typeof import('@actions/github').context,
|
||||
* core: import('@actions/core'),
|
||||
* repoPath?: string,
|
||||
* }} LintCommitsProps
|
||||
*/
|
||||
async function lintCommits({ github, context, core, repoPath }) {
|
||||
// This check should only be run when we have the pull_request context.
|
||||
const pull_number = context.payload.pull_request?.number
|
||||
if (!pull_number) {
|
||||
core.info('This is not a pull request. Skipping checks.')
|
||||
return
|
||||
}
|
||||
|
||||
const pr = (
|
||||
await github.rest.pulls.get({
|
||||
...context.repo,
|
||||
pull_number,
|
||||
})
|
||||
).data
|
||||
|
||||
const baseBranchType = classify(
|
||||
pr.base.ref.replace(/^refs\/heads\//, ''),
|
||||
).type
|
||||
const headBranchType = classify(
|
||||
pr.head.ref.replace(/^refs\/heads\//, ''),
|
||||
).type
|
||||
|
||||
if (
|
||||
baseBranchType.includes('development') &&
|
||||
headBranchType.includes('development') &&
|
||||
pr.base.repo.id === pr.head.repo?.id
|
||||
) {
|
||||
// This matches, for example, PRs from NixOS:staging-next to NixOS:master, or vice versa.
|
||||
// Ignore them: we should only care about PRs introducing *new* commits.
|
||||
// We still want to run on PRs from, e.g., Someone:master to NixOS:master, though.
|
||||
core.info(
|
||||
'This PR is from one development branch to another. Skipping checks.',
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const commits = await getCommitDetailsForPR({ core, pr, repoPath })
|
||||
|
||||
await checkCommitMessages({ commits, core })
|
||||
await checkCommitMetadata({ commits, core })
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* commits: Commit[],
|
||||
* core: import('@actions/core'),
|
||||
* }} CheckCommitMessagesProps
|
||||
*/
|
||||
async function checkCommitMessages({ commits, core }) {
|
||||
const failures = new Set()
|
||||
|
||||
const conventionalCommitTypes = [
|
||||
'build',
|
||||
'chore',
|
||||
'ci',
|
||||
'doc',
|
||||
'docs',
|
||||
'feat',
|
||||
'feature',
|
||||
'fix',
|
||||
'perf',
|
||||
'refactor',
|
||||
'services',
|
||||
'style',
|
||||
'test',
|
||||
'update',
|
||||
]
|
||||
|
||||
/**
|
||||
* @param {string[]} types e.g. ["fix", "feat"]
|
||||
* @param {string?} sha commit hash
|
||||
*/
|
||||
function makeConventionalCommitRegex(types, sha = null) {
|
||||
core.info(
|
||||
`${
|
||||
sha
|
||||
? `Conventional commit types for ${sha?.slice(0, 16)}`
|
||||
: 'Default conventional commit types'
|
||||
}: ${JSON.stringify(types)}`,
|
||||
)
|
||||
|
||||
return new RegExp(`^(${types.join('|')})!?(\\(.*\\))?!?:`)
|
||||
}
|
||||
|
||||
// Optimize for the common case that we don't have path segments with the
|
||||
// same name as a conventional commit type.
|
||||
const fullConventionalCommitRegex = makeConventionalCommitRegex(
|
||||
conventionalCommitTypes,
|
||||
)
|
||||
|
||||
for (const commit of commits) {
|
||||
const logMsgStart = `Commit ${commit.sha}'s message's subject ("${commit.subject}")`
|
||||
|
||||
// If we have a commit `perf: ...`, and we touch a file containing the path
|
||||
// segment "perf", we don't want to flag this.
|
||||
const filteredTypes = conventionalCommitTypes.filter(
|
||||
(type) => !commit.changedPathSegments.has(type),
|
||||
)
|
||||
const conventionalCommitRegex =
|
||||
filteredTypes.length === conventionalCommitTypes.length
|
||||
? fullConventionalCommitRegex
|
||||
: makeConventionalCommitRegex(filteredTypes, commit.sha)
|
||||
|
||||
if (!commit.subject.includes(': ')) {
|
||||
core.error(
|
||||
`${logMsgStart} was detected as not meeting our guidelines because ` +
|
||||
'it does not contain a colon followed by a whitespace. ' +
|
||||
'There are likely other issues as well.',
|
||||
)
|
||||
failures.add(commit.sha)
|
||||
}
|
||||
|
||||
if (commit.subject.endsWith('.')) {
|
||||
core.error(
|
||||
`${logMsgStart} was detected as not meeting our guidelines because ` +
|
||||
'it ends in a period. There may be other issues as well.',
|
||||
)
|
||||
failures.add(commit.sha)
|
||||
}
|
||||
|
||||
const fixups = ['amend!', 'fixup!', 'squash!']
|
||||
if (fixups.some((s) => commit.subject.startsWith(s))) {
|
||||
core.error(
|
||||
`${logMsgStart} was detected as not meeting our guidelines because ` +
|
||||
`it begins with "${fixups.find((s) => commit.subject.startsWith(s))}". ` +
|
||||
'Did you forget to run `git rebase -i --autosquash`?',
|
||||
)
|
||||
failures.add(commit.sha)
|
||||
}
|
||||
|
||||
if (conventionalCommitRegex.test(commit.subject)) {
|
||||
core.error(
|
||||
`${logMsgStart} was detected as not meeting our guidelines because ` +
|
||||
'it seems to use conventional commit (conventionalcommits.org) ' +
|
||||
'formatting. Nixpkgs has its own, different, commit message ' +
|
||||
'formatting standards.',
|
||||
)
|
||||
failures.add(commit.sha)
|
||||
}
|
||||
|
||||
if (!failures.has(commit.sha)) {
|
||||
core.info(`${logMsgStart} passed our automated checks!`)
|
||||
}
|
||||
}
|
||||
|
||||
if (failures.size !== 0) {
|
||||
core.error(
|
||||
'Please review the guidelines at ' +
|
||||
'<https://github.com/NixOS/nixpkgs/blob/master/CONTRIBUTING.md#commit-conventions>, ' +
|
||||
'as well as the applicable area-specific guidelines linked there.',
|
||||
)
|
||||
core.setFailed('Committers: merging is discouraged.')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* commits: Commit[],
|
||||
* core: import('@actions/core'),
|
||||
* }} CheckGitFieldsProps
|
||||
*/
|
||||
async function checkCommitMetadata({ commits, core }) {
|
||||
const failures = new Set()
|
||||
|
||||
/** @type {(s: string) => boolean} */
|
||||
const isEmail = (s) => /^.+@.*$/.test(s)
|
||||
|
||||
for (const commit of commits) {
|
||||
if (!commit.author.name) {
|
||||
core.error(`Commit ${commit.sha} author's name field is missing`)
|
||||
failures.add(commit.sha)
|
||||
}
|
||||
|
||||
if (!commit.author.email || !isEmail(commit.author.email)) {
|
||||
core.error(
|
||||
`Commit ${commit.sha} author's email field is missing or invalid`,
|
||||
)
|
||||
failures.add(commit.sha)
|
||||
}
|
||||
|
||||
if (!commit.committer.name) {
|
||||
core.error(`Commit ${commit.sha} committer's name field is missing`)
|
||||
failures.add(commit.sha)
|
||||
}
|
||||
|
||||
if (!commit.committer.email || !isEmail(commit.committer.email)) {
|
||||
core.error(
|
||||
`Commit ${commit.sha} committer's email field is missing or invalid`,
|
||||
)
|
||||
failures.add(commit.sha)
|
||||
}
|
||||
|
||||
if (!failures.has(commit.sha)) {
|
||||
core.info(
|
||||
`Commit ${commit.sha}'s git fields passed our automated checks!`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (failures.size !== 0) {
|
||||
core.error(
|
||||
'Please add the missing commit fields. ' +
|
||||
'You can use the noreply email address generated for you by GitHub ' +
|
||||
'(https://docs.github.com/en/account-and-profile/reference/email-addresses-reference#your-noreply-email-address) ' +
|
||||
"if you'd like.",
|
||||
)
|
||||
core.setFailed('Committers: merging is discouraged.')
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = lintCommits
|
||||
@@ -1,231 +0,0 @@
|
||||
import { type Commit, getCommitDetailsForPR } from './get-pr-commit-details.ts'
|
||||
import { classify } from './supportedBranches.ts'
|
||||
|
||||
type GitHub = InstanceType<typeof import('@actions/github/lib/utils').GitHub>
|
||||
type Context = typeof import('@actions/github').context
|
||||
type Core = typeof import('@actions/core')
|
||||
|
||||
interface LintCommitsProps {
|
||||
github: GitHub
|
||||
context: Context
|
||||
core: Core
|
||||
repoPath?: string
|
||||
}
|
||||
|
||||
export default async function lintCommits({
|
||||
github,
|
||||
context,
|
||||
core,
|
||||
repoPath,
|
||||
}: LintCommitsProps) {
|
||||
// This check should only be run when we have the pull_request context.
|
||||
const pull_number = context.payload.pull_request?.number
|
||||
if (!pull_number) {
|
||||
core.info('This is not a pull request. Skipping checks.')
|
||||
return
|
||||
}
|
||||
|
||||
const pr = (
|
||||
await github.rest.pulls.get({
|
||||
...context.repo,
|
||||
pull_number,
|
||||
})
|
||||
).data
|
||||
|
||||
const baseBranchType = classify(
|
||||
pr.base.ref.replace(/^refs\/heads\//, ''),
|
||||
).type
|
||||
const headBranchType = classify(
|
||||
pr.head.ref.replace(/^refs\/heads\//, ''),
|
||||
).type
|
||||
|
||||
if (
|
||||
baseBranchType.includes('development') &&
|
||||
headBranchType.includes('development') &&
|
||||
pr.base.repo.id === pr.head.repo?.id
|
||||
) {
|
||||
// This matches, for example, PRs from NixOS:staging-next to NixOS:master, or vice versa.
|
||||
// Ignore them: we should only care about PRs introducing *new* commits.
|
||||
// We still want to run on PRs from, e.g., Someone:master to NixOS:master, though.
|
||||
core.info(
|
||||
'This PR is from one development branch to another. Skipping checks.',
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const commits = await getCommitDetailsForPR({ core, pr, repoPath })
|
||||
|
||||
await checkCommitMessages({ commits, core })
|
||||
await checkCommitMetadata({ commits, core })
|
||||
}
|
||||
|
||||
interface CheckCommitMessagesProps {
|
||||
commits: Commit[]
|
||||
core: Core
|
||||
}
|
||||
|
||||
async function checkCommitMessages({
|
||||
commits,
|
||||
core,
|
||||
}: CheckCommitMessagesProps) {
|
||||
const failures = new Set()
|
||||
|
||||
const conventionalCommitTypes = [
|
||||
'build',
|
||||
'chore',
|
||||
'ci',
|
||||
'doc',
|
||||
'docs',
|
||||
'feat',
|
||||
'feature',
|
||||
'fix',
|
||||
'perf',
|
||||
'refactor',
|
||||
'services',
|
||||
'style',
|
||||
'test',
|
||||
'update',
|
||||
]
|
||||
|
||||
/**
|
||||
* @param types e.g. ["fix", "feat"]
|
||||
* @param sha commit hash
|
||||
*/
|
||||
function makeConventionalCommitRegex(
|
||||
types: string[],
|
||||
sha: string | null = null,
|
||||
) {
|
||||
core.info(
|
||||
`${
|
||||
sha
|
||||
? `Conventional commit types for ${sha?.slice(0, 16)}`
|
||||
: 'Default conventional commit types'
|
||||
}: ${JSON.stringify(types)}`,
|
||||
)
|
||||
|
||||
return new RegExp(`^(${types.join('|')})!?(\\(.*\\))?!?:`)
|
||||
}
|
||||
|
||||
// Optimize for the common case that we don't have path segments with the
|
||||
// same name as a conventional commit type.
|
||||
const fullConventionalCommitRegex = makeConventionalCommitRegex(
|
||||
conventionalCommitTypes,
|
||||
)
|
||||
|
||||
for (const commit of commits) {
|
||||
const logMsgStart = `Commit ${commit.sha}'s message's subject ("${commit.subject}")`
|
||||
|
||||
// If we have a commit `perf: ...`, and we touch a file containing the path
|
||||
// segment "perf", we don't want to flag this.
|
||||
const filteredTypes = conventionalCommitTypes.filter(
|
||||
(type) => !commit.changedPathSegments.has(type),
|
||||
)
|
||||
const conventionalCommitRegex =
|
||||
filteredTypes.length === conventionalCommitTypes.length
|
||||
? fullConventionalCommitRegex
|
||||
: makeConventionalCommitRegex(filteredTypes, commit.sha)
|
||||
|
||||
if (!commit.subject.includes(': ')) {
|
||||
core.error(
|
||||
`${logMsgStart} was detected as not meeting our guidelines because ` +
|
||||
'it does not contain a colon followed by a whitespace. ' +
|
||||
'There are likely other issues as well.',
|
||||
)
|
||||
failures.add(commit.sha)
|
||||
}
|
||||
|
||||
if (commit.subject.endsWith('.')) {
|
||||
core.error(
|
||||
`${logMsgStart} was detected as not meeting our guidelines because ` +
|
||||
'it ends in a period. There may be other issues as well.',
|
||||
)
|
||||
failures.add(commit.sha)
|
||||
}
|
||||
|
||||
const fixups = ['amend!', 'fixup!', 'squash!']
|
||||
if (fixups.some((s) => commit.subject.startsWith(s))) {
|
||||
core.error(
|
||||
`${logMsgStart} was detected as not meeting our guidelines because ` +
|
||||
`it begins with "${fixups.find((s) => commit.subject.startsWith(s))}". ` +
|
||||
'Did you forget to run `git rebase -i --autosquash`?',
|
||||
)
|
||||
failures.add(commit.sha)
|
||||
}
|
||||
|
||||
if (conventionalCommitRegex.test(commit.subject)) {
|
||||
core.error(
|
||||
`${logMsgStart} was detected as not meeting our guidelines because ` +
|
||||
'it seems to use conventional commit (conventionalcommits.org) ' +
|
||||
'formatting. Nixpkgs has its own, different, commit message ' +
|
||||
'formatting standards.',
|
||||
)
|
||||
failures.add(commit.sha)
|
||||
}
|
||||
|
||||
if (!failures.has(commit.sha)) {
|
||||
core.info(`${logMsgStart} passed our automated checks!`)
|
||||
}
|
||||
}
|
||||
|
||||
if (failures.size !== 0) {
|
||||
core.error(
|
||||
'Please review the guidelines at ' +
|
||||
'<https://github.com/NixOS/nixpkgs/blob/master/CONTRIBUTING.md#commit-conventions>, ' +
|
||||
'as well as the applicable area-specific guidelines linked there.',
|
||||
)
|
||||
core.setFailed('Committers: merging is discouraged.')
|
||||
}
|
||||
}
|
||||
|
||||
interface CheckGitFieldsProps {
|
||||
commits: Commit[]
|
||||
core: Core
|
||||
}
|
||||
|
||||
async function checkCommitMetadata({ commits, core }: CheckGitFieldsProps) {
|
||||
const failures = new Set()
|
||||
|
||||
const isEmail = (s: string) => /^.+@.*$/.test(s)
|
||||
|
||||
for (const commit of commits) {
|
||||
if (!commit.author.name) {
|
||||
core.error(`Commit ${commit.sha} author's name field is missing`)
|
||||
failures.add(commit.sha)
|
||||
}
|
||||
|
||||
if (!commit.author.email || !isEmail(commit.author.email)) {
|
||||
core.error(
|
||||
`Commit ${commit.sha} author's email field is missing or invalid`,
|
||||
)
|
||||
failures.add(commit.sha)
|
||||
}
|
||||
|
||||
if (!commit.committer.name) {
|
||||
core.error(`Commit ${commit.sha} committer's name field is missing`)
|
||||
failures.add(commit.sha)
|
||||
}
|
||||
|
||||
if (!commit.committer.email || !isEmail(commit.committer.email)) {
|
||||
core.error(
|
||||
`Commit ${commit.sha} committer's email field is missing or invalid`,
|
||||
)
|
||||
failures.add(commit.sha)
|
||||
}
|
||||
|
||||
if (!failures.has(commit.sha)) {
|
||||
core.info(
|
||||
`Commit ${commit.sha}'s git fields passed our automated checks!`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (failures.size !== 0) {
|
||||
core.error(
|
||||
'Please add the missing commit fields. ' +
|
||||
'You can use the noreply email address generated for you by GitHub ' +
|
||||
'(https://docs.github.com/en/account-and-profile/reference/email-addresses-reference#your-noreply-email-address) ' +
|
||||
"if you'd like.",
|
||||
)
|
||||
core.setFailed('Committers: merging is discouraged.')
|
||||
}
|
||||
}
|
||||
95
ci/github-script/manual-file-edits.js
Normal file
95
ci/github-script/manual-file-edits.js
Normal file
@@ -0,0 +1,95 @@
|
||||
// @ts-check
|
||||
const { classify } = require('../supportedBranches.js')
|
||||
const { getCommitDetailsForPR } = require('./get-pr-commit-details')
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* github: InstanceType<import('@actions/github/lib/utils').GitHub>,
|
||||
* context: import('@actions/github/lib/context').Context,
|
||||
* core: import('@actions/core'),
|
||||
* repoPath?: string,
|
||||
* dry: boolean,
|
||||
* }} CheckManualFileEditsProps
|
||||
*/
|
||||
async function checkManualFileEdits({ github, context, core, repoPath, dry }) {
|
||||
const { dismissReviews, postReview } = require('./reviews.js')
|
||||
const reviewKey = 'manual-file-edits'
|
||||
|
||||
const pull_number = context.payload.pull_request?.number
|
||||
if (!pull_number) {
|
||||
core.info('This is not a pull request. Skipping checks.')
|
||||
return
|
||||
}
|
||||
|
||||
const pr = (
|
||||
await github.rest.pulls.get({
|
||||
...context.repo,
|
||||
pull_number,
|
||||
})
|
||||
).data
|
||||
|
||||
if (pr.user.login.endsWith('[bot]')) {
|
||||
core.info('This is a bot, so these checks do not apply.')
|
||||
return
|
||||
}
|
||||
|
||||
const baseBranchType = classify(
|
||||
pr.base.ref.replace(/^refs\/heads\//, ''),
|
||||
).type
|
||||
const headBranchType = classify(
|
||||
pr.head.ref.replace(/^refs\/heads\//, ''),
|
||||
).type
|
||||
|
||||
if (
|
||||
baseBranchType.includes('development') &&
|
||||
headBranchType.includes('development') &&
|
||||
pr.base.repo.id === pr.head.repo?.id
|
||||
) {
|
||||
// This matches, for example, PRs from NixOS:staging-next to NixOS:master, or vice versa.
|
||||
// Ignore them: we should only care about PRs introducing *new* commits.
|
||||
// We still want to run on PRs from, e.g., Someone:master to NixOS:master, though.
|
||||
core.info(
|
||||
'This PR is from one development branch to another. Skipping checks.',
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const details = await getCommitDetailsForPR({ core, pr, repoPath })
|
||||
|
||||
if (
|
||||
details.some(({ changedPaths }) =>
|
||||
changedPaths.includes('maintainers/github-teams.json'),
|
||||
)
|
||||
) {
|
||||
postReview({
|
||||
github,
|
||||
context,
|
||||
core,
|
||||
dry,
|
||||
event: 'REQUEST_CHANGES',
|
||||
body: [
|
||||
'maintainers/github-teams.json is supposed to accurately reflect the state of the teams in GitHub.\n',
|
||||
'Therefore, it should not be edited manually.\n',
|
||||
'All changes to teams listed in maintainers/github-teams.json should be performed in GitHub by a team maintainer.\n',
|
||||
"Team maintainers are listed in the github-teams.json file and in GitHub's UI.\n",
|
||||
'If there is no team maintainer available, an org owner can make the needed change, please contact one by',
|
||||
'following the instructions at https://github.com/NixOS/org/blob/main/doc/github-org-owners.md#how-to-contact-the-team.\n',
|
||||
'Thank you!',
|
||||
].reduce(
|
||||
(prev, curr) => prev + (!prev || prev.endsWith('\n') ? '' : ' ') + curr,
|
||||
'',
|
||||
),
|
||||
reviewKey,
|
||||
})
|
||||
} else {
|
||||
dismissReviews({
|
||||
github,
|
||||
context,
|
||||
core,
|
||||
dry,
|
||||
reviewKey,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = checkManualFileEdits
|
||||
@@ -1,95 +0,0 @@
|
||||
import { getCommitDetailsForPR } from './get-pr-commit-details.ts'
|
||||
import { dismissReviews, postReview } from './reviews.ts'
|
||||
import { classify } from './supportedBranches.ts'
|
||||
|
||||
export default async function checkManualFileEdits({
|
||||
github,
|
||||
context,
|
||||
core,
|
||||
repoPath,
|
||||
dry,
|
||||
}: {
|
||||
github: InstanceType<typeof import('@actions/github/lib/utils').GitHub>
|
||||
context: typeof import('@actions/github').context
|
||||
core: typeof import('@actions/core')
|
||||
repoPath?: string
|
||||
dry: boolean
|
||||
}) {
|
||||
const reviewKey = 'manual-file-edits'
|
||||
|
||||
const pull_number = context.payload.pull_request?.number
|
||||
if (!pull_number) {
|
||||
core.info('This is not a pull request. Skipping checks.')
|
||||
return
|
||||
}
|
||||
|
||||
const pr = (
|
||||
await github.rest.pulls.get({
|
||||
...context.repo,
|
||||
pull_number,
|
||||
})
|
||||
).data
|
||||
|
||||
if (pr.user.login.endsWith('[bot]')) {
|
||||
core.info('This is a bot, so these checks do not apply.')
|
||||
return
|
||||
}
|
||||
|
||||
const baseBranchType = classify(
|
||||
pr.base.ref.replace(/^refs\/heads\//, ''),
|
||||
).type
|
||||
const headBranchType = classify(
|
||||
pr.head.ref.replace(/^refs\/heads\//, ''),
|
||||
).type
|
||||
|
||||
if (
|
||||
baseBranchType.includes('development') &&
|
||||
headBranchType.includes('development') &&
|
||||
pr.base.repo.id === pr.head.repo?.id
|
||||
) {
|
||||
// This matches, for example, PRs from NixOS:staging-next to NixOS:master, or vice versa.
|
||||
// Ignore them: we should only care about PRs introducing *new* commits.
|
||||
// We still want to run on PRs from, e.g., Someone:master to NixOS:master, though.
|
||||
core.info(
|
||||
'This PR is from one development branch to another. Skipping checks.',
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const details = await getCommitDetailsForPR({ core, pr, repoPath })
|
||||
|
||||
if (
|
||||
details.some(({ changedPaths }) =>
|
||||
changedPaths.includes('maintainers/github-teams.json'),
|
||||
)
|
||||
) {
|
||||
postReview({
|
||||
github,
|
||||
context,
|
||||
core,
|
||||
dry,
|
||||
event: 'REQUEST_CHANGES',
|
||||
body: [
|
||||
'maintainers/github-teams.json is supposed to accurately reflect the state of the teams in GitHub.\n',
|
||||
'Therefore, it should not be edited manually.\n',
|
||||
'All changes to teams listed in maintainers/github-teams.json should be performed in GitHub by a team maintainer.\n',
|
||||
"Team maintainers are listed in the github-teams.json file and in GitHub's UI.\n",
|
||||
'If there is no team maintainer available, an org owner can make the needed change, please contact one by',
|
||||
'following the instructions at https://github.com/NixOS/org/blob/main/doc/github-org-owners.md#how-to-contact-the-team.\n',
|
||||
'Thank you!',
|
||||
].reduce(
|
||||
(prev, curr) => prev + (!prev || prev.endsWith('\n') ? '' : ' ') + curr,
|
||||
'',
|
||||
),
|
||||
reviewKey,
|
||||
})
|
||||
} else {
|
||||
dismissReviews({
|
||||
github,
|
||||
context,
|
||||
core,
|
||||
dry,
|
||||
reviewKey,
|
||||
})
|
||||
}
|
||||
}
|
||||
415
ci/github-script/merge.js
Normal file
415
ci/github-script/merge.js
Normal file
@@ -0,0 +1,415 @@
|
||||
const { classify } = require('../supportedBranches.js')
|
||||
|
||||
function runChecklist({
|
||||
committers,
|
||||
files,
|
||||
pull_request,
|
||||
log,
|
||||
maintainers,
|
||||
reviews,
|
||||
user,
|
||||
userIsMaintainer,
|
||||
}) {
|
||||
const allByName = files.every(
|
||||
({ filename }) =>
|
||||
filename.startsWith('pkgs/by-name/') && filename.split('/').length > 4,
|
||||
)
|
||||
|
||||
const packages = files
|
||||
.filter(({ filename }) => filename.startsWith('pkgs/by-name/'))
|
||||
.map(({ filename }) => filename.split('/')[3])
|
||||
.filter(Boolean)
|
||||
|
||||
const eligible = !packages.length
|
||||
? new Set()
|
||||
: packages
|
||||
.map((pkg) => new Set(maintainers[pkg]))
|
||||
.reduce((acc, cur) => acc?.intersection(cur) ?? cur)
|
||||
|
||||
const approvals = new Set(
|
||||
reviews
|
||||
.filter(
|
||||
({ state, commit }) =>
|
||||
state === 'APPROVED' &&
|
||||
// Only approvals for the current head SHA count, otherwise authors could push
|
||||
// bad code between the approval and the merge.
|
||||
commit?.oid === pull_request.head.sha,
|
||||
)
|
||||
.map(({ user }) => user.id),
|
||||
)
|
||||
|
||||
// A "changes requested" review from a committer blocks both the merge queue and
|
||||
// auto-merge, even if it was made on an older commit (unlike approvals, GitHub does
|
||||
// not auto-dismiss changes-requested reviews on push). For each committer, take their
|
||||
// latest actionable review; if it's CHANGES_REQUESTED, they're blocking the PR.
|
||||
// Dismissed reviews surface as DISMISSED and comment-only follow-ups as COMMENTED, so
|
||||
// both are skipped naturally — the prior actionable review still stands until the
|
||||
// committer explicitly approves or requests changes again.
|
||||
const committerReviewState = new Map()
|
||||
for (const { user, state } of reviews) {
|
||||
if (
|
||||
committers.has(user.id) &&
|
||||
['APPROVED', 'CHANGES_REQUESTED'].includes(state)
|
||||
) {
|
||||
committerReviewState.set(user.id, state)
|
||||
}
|
||||
}
|
||||
const noBlockingReviews = !Array.from(committerReviewState.values()).includes(
|
||||
'CHANGES_REQUESTED',
|
||||
)
|
||||
|
||||
const checklist = {
|
||||
'PR targets a [development branch](https://github.com/NixOS/nixpkgs/blob/-/ci/README.md#branch-classification).':
|
||||
classify(pull_request.base.ref).type.includes('development'),
|
||||
'PR touches only files of packages in `pkgs/by-name/`.': allByName,
|
||||
'PR is at least one of:': {
|
||||
'Approved by a [committer](https://github.com/orgs/NixOS/teams/nixpkgs-committers).':
|
||||
committers.intersection(approvals).size > 0,
|
||||
'Backported via label.':
|
||||
pull_request.user.login === 'nixpkgs-ci[bot]' &&
|
||||
pull_request.head.ref.startsWith('backport-'),
|
||||
'Opened by a [committer](https://github.com/orgs/NixOS/teams/nixpkgs-committers).':
|
||||
committers.has(pull_request.user.id),
|
||||
'Opened by [@r-ryantm](https://nix-community.github.io/nixpkgs-update/r-ryantm/).':
|
||||
pull_request.user.login === 'r-ryantm',
|
||||
},
|
||||
'PR is not a draft': !pull_request.draft,
|
||||
// CI state is intentionally *not* a checklist item: auto-merge exists precisely to
|
||||
// cover unfinished CI, and an already-failed CI is reported via the merge message
|
||||
// (see merge() below) rather than a blanket refusal.
|
||||
'PR is not blocked by a "changes requested" review from a [committer](https://github.com/orgs/NixOS/teams/nixpkgs-committers).':
|
||||
noBlockingReviews,
|
||||
}
|
||||
|
||||
if (user) {
|
||||
checklist[
|
||||
`${user.login} is a member of [@NixOS/nixpkgs-maintainers](https://github.com/orgs/NixOS/teams/nixpkgs-maintainers).`
|
||||
] = userIsMaintainer
|
||||
if (allByName) {
|
||||
// We can only determine the below, if all packages are in by-name, since
|
||||
// we can't reliably relate changed files to packages outside by-name.
|
||||
checklist[
|
||||
`${user.login} is a maintainer of all touched packages on the ${pull_request.base.ref} branch.`
|
||||
] = eligible.has(user.id)
|
||||
}
|
||||
} else {
|
||||
// This is only used when no user is passed, i.e. for labeling.
|
||||
checklist['PR has maintainers eligible to merge.'] = eligible.size > 0
|
||||
}
|
||||
|
||||
const result = Object.values(checklist).every((v) =>
|
||||
typeof v === 'boolean' ? v : Object.values(v).some(Boolean),
|
||||
)
|
||||
|
||||
log('checklist', JSON.stringify(checklist))
|
||||
log('eligible', JSON.stringify(Array.from(eligible)))
|
||||
log('result', result)
|
||||
|
||||
return {
|
||||
checklist,
|
||||
eligible,
|
||||
result,
|
||||
}
|
||||
}
|
||||
|
||||
// The merge command must be on a separate line and not within codeblocks or html comments.
|
||||
// Codeblocks can have any number of ` larger than 3 to open/close. We only look at code
|
||||
// blocks that are not indented, because the later regex wouldn't match those anyway.
|
||||
function hasMergeCommand(body) {
|
||||
return (body ?? '')
|
||||
.replace(/<!--.*?-->/gms, '')
|
||||
.replace(/(^`{3,})[^`].*?\1/gms, '')
|
||||
.match(/^@NixOS\/nixpkgs-merge-bot merge\s*$/im)
|
||||
}
|
||||
|
||||
async function handleMergeComment({ github, body, node_id, reaction }) {
|
||||
if (!hasMergeCommand(body)) return
|
||||
|
||||
await github.graphql(
|
||||
`mutation($node_id: ID!, $reaction: ReactionContent!) {
|
||||
addReaction(input: {
|
||||
content: $reaction,
|
||||
subjectId: $node_id
|
||||
})
|
||||
{ clientMutationId }
|
||||
}`,
|
||||
{ node_id, reaction },
|
||||
)
|
||||
}
|
||||
|
||||
async function handleMerge({
|
||||
github,
|
||||
context,
|
||||
core,
|
||||
log,
|
||||
dry,
|
||||
pull_request,
|
||||
events,
|
||||
reviews,
|
||||
maintainers,
|
||||
getTeamMembers,
|
||||
getUser,
|
||||
}) {
|
||||
const pull_number = pull_request.number
|
||||
|
||||
const committers = new Set(
|
||||
(await getTeamMembers('nixpkgs-committers')).map(({ id }) => id),
|
||||
)
|
||||
|
||||
const files = (
|
||||
await github.rest.pulls.listFiles({
|
||||
...context.repo,
|
||||
pull_number,
|
||||
per_page: 100,
|
||||
})
|
||||
).data
|
||||
|
||||
// Early exit to prevent treewides from using up a lot of API requests (and time!) to list
|
||||
// all the files in the pull request. For now, the merge-bot will not work when 100 or more
|
||||
// files are touched in a PR - which should be more than fine.
|
||||
// TODO: Find a more efficient way of downloading all the *names* of the touched files,
|
||||
// including an early exit when the first non-by-name file is found.
|
||||
if (files.length >= 100) return false
|
||||
|
||||
const noPrFailuresState = (
|
||||
await github.rest.repos.listCommitStatusesForRef({
|
||||
...context.repo,
|
||||
ref: pull_request.head.sha,
|
||||
per_page: 100,
|
||||
})
|
||||
).data.find(({ context }) => context === 'no PR failures')?.state
|
||||
|
||||
// Only look through comments *after* the latest (force) push.
|
||||
const lastPush = events.findLastIndex(
|
||||
({ event, sha, commit_id }) =>
|
||||
['committed', 'head_ref_force_pushed'].includes(event) &&
|
||||
(sha ?? commit_id) === pull_request.head.sha,
|
||||
)
|
||||
|
||||
const comments = events.slice(lastPush + 1).filter(
|
||||
({ event, body, user, node_id }) =>
|
||||
['commented', 'reviewed'].includes(event) &&
|
||||
hasMergeCommand(body) &&
|
||||
// Ignore comments where the user has been deleted already.
|
||||
user &&
|
||||
// Ignore comments which had already been responded to by the bot.
|
||||
(dry ||
|
||||
!events.some(
|
||||
({ event, body }) =>
|
||||
['commented'].includes(event) &&
|
||||
// We're only testing this hidden reference, but not the author of the comment.
|
||||
// We'll just assume that nobody creates comments with this marker on purpose.
|
||||
// Additionally checking the author is quite annoying for local debugging.
|
||||
body.match(new RegExp(`^<!-- comment: ${node_id} -->$`, 'm')),
|
||||
)),
|
||||
)
|
||||
|
||||
// Returns `{ reaction, messages }`: the reaction to leave on the merge comment and the
|
||||
// lines to append to the bot's reply. Throws only on an unexpected API error.
|
||||
async function merge() {
|
||||
if (dry) {
|
||||
core.info(`Merging #${pull_number}... (dry)`)
|
||||
return { reaction: 'ROCKET', messages: ['Merge completed (dry)'] }
|
||||
}
|
||||
|
||||
// Using GraphQL mutations instead of the REST /merge endpoint, because the latter
|
||||
// doesn't work with Merge Queues. We now have merge queues enabled on all development
|
||||
// branches, so we don't need a fallback for regular merges.
|
||||
try {
|
||||
const resp = await github.graphql(
|
||||
`mutation($node_id: ID!, $sha: GitObjectID) {
|
||||
enqueuePullRequest(input: {
|
||||
expectedHeadOid: $sha,
|
||||
pullRequestId: $node_id
|
||||
})
|
||||
{
|
||||
clientMutationId,
|
||||
mergeQueueEntry { mergeQueue { url } }
|
||||
}
|
||||
}`,
|
||||
{ node_id: pull_request.node_id, sha: pull_request.head.sha },
|
||||
)
|
||||
log('merge', 'Queued for merge')
|
||||
return {
|
||||
reaction: 'ROCKET',
|
||||
messages: [
|
||||
`:heavy_check_mark: [Queued](${resp.enqueuePullRequest.mergeQueueEntry.mergeQueue.url}) for merge (#306934)`,
|
||||
],
|
||||
}
|
||||
} catch (e) {
|
||||
log('Enqueuing failed', e.response.errors[0].message)
|
||||
}
|
||||
|
||||
// Enqueuing fails when the required status checks are not satisfied, yet. If CI has
|
||||
// already failed, enabling auto-merge would be pointless: it would never fire, and
|
||||
// fixing CI requires a new push, which invalidates this merge command anyway (we only
|
||||
// act on comments after the latest push). So we don't enable auto-merge and instead
|
||||
// ask for a fresh command once CI is green again.
|
||||
if (['error', 'failure'].includes(noPrFailuresState)) {
|
||||
log('merge', 'CI has failed, not enabling auto-merge')
|
||||
return {
|
||||
reaction: 'THUMBS_DOWN',
|
||||
messages: [
|
||||
':x: Pull Request could not be merged: CI has failed (#305350).',
|
||||
'',
|
||||
'> [!TIP]',
|
||||
'> PRs cannot be merged while CI is failing.',
|
||||
'> Once CI is passing, comment `@NixOS/nixpkgs-merge-bot merge` again.',
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
// CI has not finished yet, so we enable auto-merge. We could also only use auto-merge,
|
||||
// but this often gets stuck for no apparent reason.
|
||||
try {
|
||||
await github.graphql(
|
||||
`mutation($node_id: ID!, $sha: GitObjectID) {
|
||||
enablePullRequestAutoMerge(input: {
|
||||
expectedHeadOid: $sha,
|
||||
pullRequestId: $node_id
|
||||
})
|
||||
{ clientMutationId }
|
||||
}`,
|
||||
{ node_id: pull_request.node_id, sha: pull_request.head.sha },
|
||||
)
|
||||
log('merge', 'Auto-merge enabled')
|
||||
return {
|
||||
reaction: 'ROCKET',
|
||||
messages: [
|
||||
`:heavy_check_mark: Enabled Auto Merge (#306934)`,
|
||||
'',
|
||||
'> [!TIP]',
|
||||
'> [Auto Merge](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/incorporating-changes-from-a-pull-request/automatically-merging-a-pull-request) will queue this PR once required CI checks succeed.',
|
||||
'> If CI fails instead, fixing it needs a new push, which disables Auto Merge and invalidates this command — comment `@NixOS/nixpkgs-merge-bot merge` again once CI is green.',
|
||||
'> If GitHub gets stuck even though CI passed (it sometimes does), leaving another approval should kick off the merge.',
|
||||
],
|
||||
}
|
||||
} catch (e) {
|
||||
log('Auto Merge failed', e.response.errors[0].message)
|
||||
throw new Error(e.response.errors[0].message)
|
||||
}
|
||||
}
|
||||
|
||||
for (const comment of comments) {
|
||||
log('comment', comment.node_id)
|
||||
|
||||
async function react(reaction) {
|
||||
if (dry) {
|
||||
core.info(`Reaction ${reaction} on ${comment.node_id} (dry)`)
|
||||
return
|
||||
}
|
||||
|
||||
await handleMergeComment({
|
||||
github,
|
||||
body: comment.body,
|
||||
node_id: comment.node_id,
|
||||
reaction,
|
||||
})
|
||||
}
|
||||
|
||||
async function isMaintainer(username) {
|
||||
try {
|
||||
return (
|
||||
(
|
||||
await github.rest.teams.getMembershipForUserInOrg({
|
||||
org: context.repo.owner,
|
||||
team_slug: 'nixpkgs-maintainers',
|
||||
username,
|
||||
})
|
||||
).data.state === 'active'
|
||||
)
|
||||
} catch (e) {
|
||||
if (e.status === 404) return false
|
||||
else throw e
|
||||
}
|
||||
}
|
||||
|
||||
const { result, eligible, checklist } = runChecklist({
|
||||
committers,
|
||||
files,
|
||||
pull_request,
|
||||
log,
|
||||
maintainers,
|
||||
reviews,
|
||||
user: comment.user,
|
||||
userIsMaintainer: await isMaintainer(comment.user.login),
|
||||
})
|
||||
|
||||
const body = [
|
||||
`<!-- comment: ${comment.node_id} -->`,
|
||||
`@${comment.user.login} wants to merge this PR.`,
|
||||
'',
|
||||
'Requirements to merge this PR with `@NixOS/nixpkgs-merge-bot merge`:',
|
||||
...Object.entries(checklist).flatMap(([msg, res]) =>
|
||||
typeof res === 'boolean'
|
||||
? `- :${res ? 'white_check_mark' : 'x'}: ${msg}`
|
||||
: [
|
||||
`- :${Object.values(res).some(Boolean) ? 'white_check_mark' : 'x'}: ${msg}`,
|
||||
...Object.entries(res).map(
|
||||
([msg, res]) =>
|
||||
` - ${res ? ':white_check_mark:' : ':white_large_square:'} ${msg}`,
|
||||
),
|
||||
],
|
||||
),
|
||||
'',
|
||||
]
|
||||
|
||||
if (eligible.size > 0 && !eligible.has(comment.user.id)) {
|
||||
const users = await Promise.all(
|
||||
Array.from(eligible, async (id) => (await getUser(id)).login),
|
||||
)
|
||||
body.push(
|
||||
'> [!TIP]',
|
||||
'> Maintainers eligible to merge are:',
|
||||
...users.map((login) => `> - ${login}`),
|
||||
'',
|
||||
)
|
||||
}
|
||||
|
||||
if (result) {
|
||||
try {
|
||||
const { reaction, messages } = await merge()
|
||||
await react(reaction)
|
||||
body.push(...messages)
|
||||
} catch (e) {
|
||||
await react('THUMBS_DOWN')
|
||||
// Remove the HTML comment with node_id reference to allow retrying this merge on the next run.
|
||||
body.shift()
|
||||
body.push(`:x: Merge failed with: ${e} (#371492)`)
|
||||
}
|
||||
} else {
|
||||
await react('THUMBS_DOWN')
|
||||
body.push(':x: Pull Request could not be merged (#305350)')
|
||||
}
|
||||
|
||||
if (dry) {
|
||||
core.info(body.join('\n'))
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
...context.repo,
|
||||
issue_number: pull_number,
|
||||
body: body.join('\n'),
|
||||
})
|
||||
}
|
||||
|
||||
if (result) break
|
||||
}
|
||||
|
||||
const { result } = runChecklist({
|
||||
committers,
|
||||
files,
|
||||
pull_request,
|
||||
log,
|
||||
maintainers,
|
||||
reviews,
|
||||
})
|
||||
|
||||
// Returns a boolean, which indicates whether the PR is merge-bot eligible in principle.
|
||||
// This is used to set the respective label in bot.js.
|
||||
return result
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
handleMerge,
|
||||
handleMergeComment,
|
||||
}
|
||||
@@ -1,486 +0,0 @@
|
||||
import { classify } from './supportedBranches.ts'
|
||||
|
||||
type GitHub = InstanceType<typeof import('@actions/github/lib/utils').GitHub>
|
||||
type Context = typeof import('@actions/github').context
|
||||
type Core = typeof import('@actions/core')
|
||||
export type PullRequest = Awaited<
|
||||
ReturnType<GitHub['rest']['pulls']['get']>
|
||||
>['data']
|
||||
export type User = Pick<NonNullable<PullRequest['user']>, 'id' | 'login'>
|
||||
export interface Review {
|
||||
state: string
|
||||
commit: { oid: string } | null
|
||||
user: User
|
||||
onBehalfOf: { nodes: { slug: string }[] }
|
||||
}
|
||||
export interface TimelineEvent {
|
||||
event: string
|
||||
sha?: string
|
||||
commit_id?: string | null
|
||||
body?: string
|
||||
user?: User | null
|
||||
node_id?: string
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
submitted_at?: string
|
||||
committer?: { date: string }
|
||||
}
|
||||
export type Maintainers = Record<string, number[]>
|
||||
export interface MergeProps {
|
||||
github: GitHub
|
||||
context: Context
|
||||
core: Core
|
||||
log: (key: string, value: unknown) => void
|
||||
dry: boolean
|
||||
pull_request: PullRequest
|
||||
events: TimelineEvent[]
|
||||
reviews: Review[]
|
||||
maintainers: Maintainers
|
||||
getTeamMembers: (slug: string) => Promise<User[]> | User[]
|
||||
getUser: (id: number) => Promise<User | null>
|
||||
}
|
||||
export type Checklist = Record<string, boolean | Record<string, boolean>>
|
||||
|
||||
/**
|
||||
* Evaluate a mergebot checklist.
|
||||
* A checklist evaluates to true when all values are either `true`,
|
||||
* or a record with at least one `true` value.
|
||||
*/
|
||||
function evalChecklist(checklist: Checklist): boolean {
|
||||
return Object.values(checklist).every((v) =>
|
||||
typeof v === 'boolean' ? v : Object.values(v).some(Boolean),
|
||||
)
|
||||
}
|
||||
|
||||
function runChecklist({
|
||||
committers,
|
||||
files,
|
||||
pull_request,
|
||||
log,
|
||||
maintainers,
|
||||
reviews,
|
||||
user,
|
||||
userIsMaintainer,
|
||||
}: Pick<MergeProps, 'pull_request' | 'log' | 'maintainers' | 'reviews'> & {
|
||||
committers: Set<number>
|
||||
files: Awaited<ReturnType<GitHub['rest']['pulls']['listFiles']>>['data']
|
||||
user?: User
|
||||
userIsMaintainer?: boolean
|
||||
}) {
|
||||
const allByName = files.every(
|
||||
({ filename }) =>
|
||||
filename.startsWith('pkgs/by-name/') && filename.split('/').length > 4,
|
||||
)
|
||||
|
||||
const packages = files
|
||||
.filter(({ filename }) => filename.startsWith('pkgs/by-name/'))
|
||||
.map(({ filename }) => filename.split('/')[3])
|
||||
.filter(Boolean)
|
||||
|
||||
const eligible = !packages.length
|
||||
? new Set<number>()
|
||||
: packages
|
||||
.map((pkg) => new Set(maintainers[pkg]))
|
||||
.reduce((acc, cur) => acc?.intersection(cur) ?? cur)
|
||||
|
||||
const approvals = new Set(
|
||||
reviews
|
||||
.filter(
|
||||
({ state, commit }) =>
|
||||
state === 'APPROVED' &&
|
||||
// Only approvals for the current head SHA count, otherwise authors could push
|
||||
// bad code between the approval and the merge.
|
||||
commit?.oid === pull_request.head.sha,
|
||||
)
|
||||
.map(({ user }) => user.id),
|
||||
)
|
||||
|
||||
// A "changes requested" review from a committer blocks both the merge queue and
|
||||
// auto-merge, even if it was made on an older commit (unlike approvals, GitHub does
|
||||
// not auto-dismiss changes-requested reviews on push). For each committer, take their
|
||||
// latest actionable review; if it's CHANGES_REQUESTED, they're blocking the PR.
|
||||
// Dismissed reviews surface as DISMISSED and comment-only follow-ups as COMMENTED, so
|
||||
// both are skipped naturally — the prior actionable review still stands until the
|
||||
// committer explicitly approves or requests changes again.
|
||||
const committerReviewState = new Map<number, string>()
|
||||
for (const { user, state } of reviews) {
|
||||
if (
|
||||
committers.has(user.id) &&
|
||||
['APPROVED', 'CHANGES_REQUESTED'].includes(state)
|
||||
) {
|
||||
committerReviewState.set(user.id, state)
|
||||
}
|
||||
}
|
||||
const noBlockingReviews = !Array.from(committerReviewState.values()).includes(
|
||||
'CHANGES_REQUESTED',
|
||||
)
|
||||
|
||||
const checklist: Checklist = {
|
||||
'PR targets a [development branch](https://github.com/NixOS/nixpkgs/blob/-/ci/README.md#branch-classification).':
|
||||
classify(pull_request.base.ref).type.includes('development'),
|
||||
'PR touches only files of packages in `pkgs/by-name/`.': allByName,
|
||||
'PR is at least one of:': {
|
||||
'Approved by a [committer](https://github.com/orgs/NixOS/teams/nixpkgs-committers).':
|
||||
committers.intersection(approvals).size > 0,
|
||||
'Backported via label.':
|
||||
pull_request.user.login === 'nixpkgs-ci[bot]' &&
|
||||
pull_request.head.ref.startsWith('backport-'),
|
||||
'Opened by a [committer](https://github.com/orgs/NixOS/teams/nixpkgs-committers).':
|
||||
committers.has(pull_request.user.id),
|
||||
'Opened by [@r-ryantm](https://nixos.github.io/nixpkgs-update/r-ryantm/).':
|
||||
pull_request.user.login === 'r-ryantm',
|
||||
},
|
||||
'PR is not a draft': !pull_request.draft,
|
||||
// CI state is intentionally *not* a checklist item: auto-merge exists precisely to
|
||||
// cover unfinished CI, and an already-failed CI is reported via the merge message
|
||||
// (see merge() below) rather than a blanket refusal.
|
||||
'PR is not blocked by a "changes requested" review from a [committer](https://github.com/orgs/NixOS/teams/nixpkgs-committers).':
|
||||
noBlockingReviews,
|
||||
}
|
||||
|
||||
const userAgnosticResult = evalChecklist(checklist)
|
||||
|
||||
if (user) {
|
||||
checklist[
|
||||
`${user.login} is a member of [@NixOS/nixpkgs-maintainers](https://github.com/orgs/NixOS/teams/nixpkgs-maintainers) (_see [requesting a new invitation](https://github.com/NixOS/rfc39-record/blob/main/README.md#requesting-a-new-invitation)_).`
|
||||
] = !!userIsMaintainer
|
||||
if (allByName) {
|
||||
// We can only determine the below, if all packages are in by-name, since
|
||||
// we can't reliably relate changed files to packages outside by-name.
|
||||
checklist[
|
||||
`${user.login} is a maintainer of all touched packages on the ${pull_request.base.ref} branch.`
|
||||
] = eligible.has(user.id)
|
||||
}
|
||||
} else {
|
||||
// This is only used when no user is passed, i.e. for labeling.
|
||||
checklist['PR has maintainers eligible to merge.'] = eligible.size > 0
|
||||
}
|
||||
|
||||
const result = evalChecklist(checklist)
|
||||
|
||||
log('checklist', JSON.stringify(checklist))
|
||||
log('eligible', JSON.stringify(Array.from(eligible)))
|
||||
log('userAgnosticResult', userAgnosticResult)
|
||||
log('result', result)
|
||||
|
||||
return {
|
||||
checklist,
|
||||
eligible,
|
||||
userAgnosticResult,
|
||||
result,
|
||||
}
|
||||
}
|
||||
|
||||
// The merge command must be on a separate line and not within codeblocks or html comments.
|
||||
// Codeblocks can have any number of ` larger than 3 to open/close. We only look at code
|
||||
// blocks that are not indented, because the later regex wouldn't match those anyway.
|
||||
function hasMergeCommand(body: string | null | undefined) {
|
||||
return (body ?? '')
|
||||
.replace(/<!--.*?-->/gms, '')
|
||||
.replace(/(^`{3,})[^`].*?\1/gms, '')
|
||||
.match(/^@NixOS\/nixpkgs-merge-bot merge\s*$/im)
|
||||
}
|
||||
|
||||
export async function handleMergeComment({
|
||||
github,
|
||||
body,
|
||||
node_id,
|
||||
reaction,
|
||||
}: {
|
||||
github: GitHub
|
||||
body: string | null | undefined
|
||||
node_id: string
|
||||
reaction: string
|
||||
}) {
|
||||
if (!hasMergeCommand(body)) return
|
||||
|
||||
await github.graphql(
|
||||
`mutation($node_id: ID!, $reaction: ReactionContent!) {
|
||||
addReaction(input: {
|
||||
content: $reaction,
|
||||
subjectId: $node_id
|
||||
})
|
||||
{ clientMutationId }
|
||||
}`,
|
||||
{ node_id, reaction },
|
||||
)
|
||||
}
|
||||
|
||||
export async function handleMerge({
|
||||
github,
|
||||
context,
|
||||
core,
|
||||
log,
|
||||
dry,
|
||||
pull_request,
|
||||
events,
|
||||
reviews,
|
||||
maintainers,
|
||||
getTeamMembers,
|
||||
getUser,
|
||||
}: MergeProps) {
|
||||
const pull_number = pull_request.number
|
||||
|
||||
const committers = new Set(
|
||||
(await getTeamMembers('nixpkgs-committers')).map(({ id }) => id),
|
||||
)
|
||||
|
||||
const files = (
|
||||
await github.rest.pulls.listFiles({
|
||||
...context.repo,
|
||||
pull_number,
|
||||
per_page: 100,
|
||||
})
|
||||
).data
|
||||
|
||||
// Early exit to prevent treewides from using up a lot of API requests (and time!) to list
|
||||
// all the files in the pull request. For now, the merge-bot will not work when 100 or more
|
||||
// files are touched in a PR - which should be more than fine.
|
||||
// TODO: Find a more efficient way of downloading all the *names* of the touched files,
|
||||
// including an early exit when the first non-by-name file is found.
|
||||
if (files.length >= 100) return false
|
||||
|
||||
const noPrFailuresState = (
|
||||
await github.rest.repos.listCommitStatusesForRef({
|
||||
...context.repo,
|
||||
ref: pull_request.head.sha,
|
||||
per_page: 100,
|
||||
})
|
||||
).data.find(({ context }) => context === 'no PR failures')?.state
|
||||
|
||||
// Only look through comments *after* the latest (force) push.
|
||||
const lastPush = events.findLastIndex(
|
||||
({ event, sha, commit_id }) =>
|
||||
['committed', 'head_ref_force_pushed'].includes(event) &&
|
||||
(sha ?? commit_id) === pull_request.head.sha,
|
||||
)
|
||||
|
||||
const comments = events.slice(lastPush + 1).filter(
|
||||
({ event, body, user, node_id }) =>
|
||||
['commented', 'reviewed'].includes(event) &&
|
||||
hasMergeCommand(body) &&
|
||||
// Ignore comments where the user has been deleted already.
|
||||
user &&
|
||||
// Ignore comments which had already been responded to by the bot.
|
||||
(dry ||
|
||||
!events.some(
|
||||
({ event, body }) =>
|
||||
['commented'].includes(event) &&
|
||||
// We're only testing this hidden reference, but not the author of the comment.
|
||||
// We'll just assume that nobody creates comments with this marker on purpose.
|
||||
// Additionally checking the author is quite annoying for local debugging.
|
||||
body?.match(new RegExp(`^<!-- comment: ${node_id} -->$`, 'm')),
|
||||
)),
|
||||
)
|
||||
|
||||
// Returns `{ reaction, messages }`: the reaction to leave on the merge comment and the
|
||||
// lines to append to the bot's reply. Throws only on an unexpected API error.
|
||||
async function merge() {
|
||||
if (dry) {
|
||||
core.info(`Merging #${pull_number}... (dry)`)
|
||||
return { reaction: 'ROCKET', messages: ['Merge completed (dry)'] }
|
||||
}
|
||||
|
||||
// Using GraphQL mutations instead of the REST /merge endpoint, because the latter
|
||||
// doesn't work with Merge Queues. We now have merge queues enabled on all development
|
||||
// branches, so we don't need a fallback for regular merges.
|
||||
try {
|
||||
const resp = await github.graphql<{
|
||||
enqueuePullRequest: { mergeQueueEntry: { mergeQueue: { url: string } } }
|
||||
}>(
|
||||
`mutation($node_id: ID!, $sha: GitObjectID) {
|
||||
enqueuePullRequest(input: {
|
||||
expectedHeadOid: $sha,
|
||||
pullRequestId: $node_id
|
||||
})
|
||||
{
|
||||
clientMutationId,
|
||||
mergeQueueEntry { mergeQueue { url } }
|
||||
}
|
||||
}`,
|
||||
{ node_id: pull_request.node_id, sha: pull_request.head.sha },
|
||||
)
|
||||
log('merge', 'Queued for merge')
|
||||
return {
|
||||
reaction: 'ROCKET',
|
||||
messages: [
|
||||
`:heavy_check_mark: [Queued](${resp.enqueuePullRequest.mergeQueueEntry.mergeQueue.url}) for merge (#306934)`,
|
||||
],
|
||||
}
|
||||
} catch (e: any) {
|
||||
log('Enqueuing failed', e.response.errors[0].message)
|
||||
}
|
||||
|
||||
// Enqueuing fails when the required status checks are not satisfied, yet. If CI has
|
||||
// already failed, enabling auto-merge would be pointless: it would never fire, and
|
||||
// fixing CI requires a new push, which invalidates this merge command anyway (we only
|
||||
// act on comments after the latest push). So we don't enable auto-merge and instead
|
||||
// ask for a fresh command once CI is green again.
|
||||
if (['error', 'failure'].includes(noPrFailuresState ?? '')) {
|
||||
log('merge', 'CI has failed, not enabling auto-merge')
|
||||
return {
|
||||
reaction: 'THUMBS_DOWN',
|
||||
messages: [
|
||||
':x: Pull Request could not be merged: CI has failed (#305350).',
|
||||
'',
|
||||
'> [!TIP]',
|
||||
'> PRs cannot be merged while CI is failing.',
|
||||
'> Once CI is passing, comment `@NixOS/nixpkgs-merge-bot merge` again.',
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
// CI has not finished yet, so we enable auto-merge. We could also only use auto-merge,
|
||||
// but this often gets stuck for no apparent reason.
|
||||
try {
|
||||
await github.graphql(
|
||||
`mutation($node_id: ID!, $sha: GitObjectID) {
|
||||
enablePullRequestAutoMerge(input: {
|
||||
expectedHeadOid: $sha,
|
||||
pullRequestId: $node_id
|
||||
})
|
||||
{ clientMutationId }
|
||||
}`,
|
||||
{ node_id: pull_request.node_id, sha: pull_request.head.sha },
|
||||
)
|
||||
log('merge', 'Auto-merge enabled')
|
||||
return {
|
||||
reaction: 'ROCKET',
|
||||
messages: [
|
||||
`:heavy_check_mark: Enabled Auto Merge (#306934)`,
|
||||
'',
|
||||
'> [!TIP]',
|
||||
'> [Auto Merge](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/incorporating-changes-from-a-pull-request/automatically-merging-a-pull-request) will queue this PR once required CI checks succeed.',
|
||||
'> If CI fails instead, fixing it needs a new push, which disables Auto Merge and invalidates this command — comment `@NixOS/nixpkgs-merge-bot merge` again once CI is green.',
|
||||
'> If GitHub gets stuck even though CI passed (it sometimes does), leaving another approval should kick off the merge.',
|
||||
],
|
||||
}
|
||||
} catch (e: any) {
|
||||
log('Auto Merge failed', e.response.errors[0].message)
|
||||
throw new Error(e.response.errors[0].message)
|
||||
}
|
||||
}
|
||||
|
||||
for (const comment of comments) {
|
||||
if (!comment.user || !comment.node_id) continue
|
||||
const node_id = comment.node_id
|
||||
log('comment', node_id)
|
||||
|
||||
async function react(reaction: string) {
|
||||
if (dry) {
|
||||
core.info(`Reaction ${reaction} on ${comment.node_id} (dry)`)
|
||||
return
|
||||
}
|
||||
|
||||
await handleMergeComment({
|
||||
github,
|
||||
body: comment.body,
|
||||
node_id,
|
||||
reaction,
|
||||
})
|
||||
}
|
||||
|
||||
async function isMaintainer(username: string) {
|
||||
try {
|
||||
return (
|
||||
(
|
||||
await github.rest.teams.getMembershipForUserInOrg({
|
||||
org: context.repo.owner,
|
||||
team_slug: 'nixpkgs-maintainers',
|
||||
username,
|
||||
})
|
||||
).data.state === 'active'
|
||||
)
|
||||
} catch (e) {
|
||||
if (e instanceof Error && 'status' in e && e.status === 404) {
|
||||
return false
|
||||
} else {
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const { result, userAgnosticResult, eligible, checklist } = runChecklist({
|
||||
committers,
|
||||
files,
|
||||
pull_request,
|
||||
log,
|
||||
maintainers,
|
||||
reviews,
|
||||
user: comment.user,
|
||||
userIsMaintainer: await isMaintainer(comment.user.login),
|
||||
})
|
||||
|
||||
const body = [
|
||||
`<!-- comment: ${comment.node_id} -->`,
|
||||
`@${comment.user.login} wants to merge this PR.`,
|
||||
'',
|
||||
'Requirements to merge this PR with `@NixOS/nixpkgs-merge-bot merge`:',
|
||||
...Object.entries(checklist).flatMap(([msg, res]) =>
|
||||
typeof res === 'boolean'
|
||||
? `- :${res ? 'white_check_mark' : 'x'}: ${msg}`
|
||||
: [
|
||||
`- :${Object.values(res).some(Boolean) ? 'white_check_mark' : 'x'}: ${msg}`,
|
||||
...Object.entries(res).map(
|
||||
([msg, res]) =>
|
||||
` - ${res ? ':white_check_mark:' : ':white_large_square:'} ${msg}`,
|
||||
),
|
||||
],
|
||||
),
|
||||
'',
|
||||
]
|
||||
|
||||
if (eligible.size > 0 && !eligible.has(comment.user.id)) {
|
||||
const users = await Promise.all(
|
||||
Array.from(eligible, async (id) => (await getUser(id))?.login),
|
||||
)
|
||||
body.push(
|
||||
'> [!TIP]',
|
||||
`> Maintainers ${userAgnosticResult ? 'eligible to merge' : 'of all touched packages'} are:`,
|
||||
...users.map((login) => `> - ${login}`),
|
||||
'',
|
||||
)
|
||||
}
|
||||
|
||||
if (result) {
|
||||
try {
|
||||
const { reaction, messages } = await merge()
|
||||
await react(reaction)
|
||||
body.push(...messages)
|
||||
} catch (e) {
|
||||
await react('THUMBS_DOWN')
|
||||
// Remove the HTML comment with node_id reference to allow retrying this merge on the next run.
|
||||
body.shift()
|
||||
body.push(`:x: Merge failed with: ${e} (#371492)`)
|
||||
}
|
||||
} else {
|
||||
await react('THUMBS_DOWN')
|
||||
body.push(':x: Pull Request could not be merged (#305350)')
|
||||
}
|
||||
|
||||
if (dry) {
|
||||
core.info(body.join('\n'))
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
...context.repo,
|
||||
issue_number: pull_number,
|
||||
body: body.join('\n'),
|
||||
})
|
||||
}
|
||||
|
||||
if (result) break
|
||||
}
|
||||
|
||||
const { result } = runChecklist({
|
||||
committers,
|
||||
files,
|
||||
pull_request,
|
||||
log,
|
||||
maintainers,
|
||||
reviews,
|
||||
})
|
||||
|
||||
// Returns a boolean, which indicates whether the PR is merge-bot eligible in principle.
|
||||
// This is used to set the respective label in bot.ts.
|
||||
return result
|
||||
}
|
||||
387
ci/github-script/package-lock.json
generated
387
ci/github-script/package-lock.json
generated
@@ -10,10 +10,6 @@
|
||||
"@actions/github": "9.1.0",
|
||||
"bottleneck": "2.19.5",
|
||||
"commander": "14.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "24.13.3",
|
||||
"typescript": "7.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@actions/artifact": {
|
||||
@@ -592,19 +588,6 @@
|
||||
"protoc-gen-ts": "bin/protoc-gen-ts"
|
||||
}
|
||||
},
|
||||
"node_modules/@protobuf-ts/plugin/node_modules/typescript": {
|
||||
"version": "3.9.10",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-3.9.10.tgz",
|
||||
"integrity": "sha512-w6fIxVE/H1PkLKcCPsFqKE7Kv7QUwhU8qQY2MueZXWx5cPZdwFupLgKK3vntcK98BtNHZtAF4LA/yl2a7k8R6Q==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@protobuf-ts/protoc": {
|
||||
"version": "2.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@protobuf-ts/protoc/-/protoc-2.11.1.tgz",
|
||||
@@ -629,336 +612,6 @@
|
||||
"@protobuf-ts/runtime": "^2.11.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "24.13.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
|
||||
"integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-aix-ppc64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz",
|
||||
"integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-darwin-arm64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz",
|
||||
"integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-darwin-x64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz",
|
||||
"integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-freebsd-arm64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz",
|
||||
"integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-freebsd-x64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz",
|
||||
"integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-arm": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz",
|
||||
"integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-arm64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz",
|
||||
"integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-loong64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz",
|
||||
"integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-mips64el": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz",
|
||||
"integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-ppc64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz",
|
||||
"integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-riscv64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz",
|
||||
"integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-s390x": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz",
|
||||
"integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-x64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz",
|
||||
"integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-netbsd-arm64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz",
|
||||
"integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-netbsd-x64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz",
|
||||
"integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-openbsd-arm64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz",
|
||||
"integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-openbsd-x64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz",
|
||||
"integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-sunos-x64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz",
|
||||
"integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-win32-arm64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz",
|
||||
"integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-win32-x64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz",
|
||||
"integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/vfs": {
|
||||
"version": "1.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/vfs/-/vfs-1.6.1.tgz",
|
||||
@@ -2004,37 +1657,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
|
||||
"integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==",
|
||||
"version": "3.9.10",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-3.9.10.tgz",
|
||||
"integrity": "sha512-w6fIxVE/H1PkLKcCPsFqKE7Kv7QUwhU8qQY2MueZXWx5cPZdwFupLgKK3vntcK98BtNHZtAF4LA/yl2a7k8R6Q==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc"
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@typescript/typescript-aix-ppc64": "7.0.2",
|
||||
"@typescript/typescript-darwin-arm64": "7.0.2",
|
||||
"@typescript/typescript-darwin-x64": "7.0.2",
|
||||
"@typescript/typescript-freebsd-arm64": "7.0.2",
|
||||
"@typescript/typescript-freebsd-x64": "7.0.2",
|
||||
"@typescript/typescript-linux-arm": "7.0.2",
|
||||
"@typescript/typescript-linux-arm64": "7.0.2",
|
||||
"@typescript/typescript-linux-loong64": "7.0.2",
|
||||
"@typescript/typescript-linux-mips64el": "7.0.2",
|
||||
"@typescript/typescript-linux-ppc64": "7.0.2",
|
||||
"@typescript/typescript-linux-riscv64": "7.0.2",
|
||||
"@typescript/typescript-linux-s390x": "7.0.2",
|
||||
"@typescript/typescript-linux-x64": "7.0.2",
|
||||
"@typescript/typescript-netbsd-arm64": "7.0.2",
|
||||
"@typescript/typescript-netbsd-x64": "7.0.2",
|
||||
"@typescript/typescript-openbsd-arm64": "7.0.2",
|
||||
"@typescript/typescript-openbsd-x64": "7.0.2",
|
||||
"@typescript/typescript-sunos-x64": "7.0.2",
|
||||
"@typescript/typescript-win32-arm64": "7.0.2",
|
||||
"@typescript/typescript-win32-x64": "7.0.2"
|
||||
"node": ">=4.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/undici": {
|
||||
@@ -2049,13 +1681,6 @@
|
||||
"node": ">=14.0"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.18.2",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
||||
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/universal-user-agent": {
|
||||
"version": "7.0.3",
|
||||
"resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.3.tgz",
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
{
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"typecheck": "tsc --build",
|
||||
"testsuite": "node --test",
|
||||
"test": "npm run typecheck && npm run testsuite"
|
||||
},
|
||||
"type": "module",
|
||||
"//": [
|
||||
"Keep `@actions/core` and `@actions/github` in sync with",
|
||||
"https://github.com/actions/github-script/blob/main/package.json."
|
||||
@@ -16,9 +10,5 @@
|
||||
"@actions/github": "9.1.0",
|
||||
"bottleneck": "2.19.5",
|
||||
"commander": "14.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "24.13.3",
|
||||
"typescript": "7.0.2"
|
||||
}
|
||||
}
|
||||
|
||||
239
ci/github-script/prepare.js
Normal file
239
ci/github-script/prepare.js
Normal file
@@ -0,0 +1,239 @@
|
||||
const { classify } = require('../supportedBranches.js')
|
||||
const { postReview, dismissReviews } = require('./reviews.js')
|
||||
const reviewKey = 'prepare'
|
||||
const supportedSystems = require('./supportedSystems.js')
|
||||
|
||||
module.exports = async ({ github, context, core, dry }) => {
|
||||
const pull_number = context.payload.pull_request.number
|
||||
|
||||
for (const retryInterval of [5, 10, 20, 40, 80]) {
|
||||
core.info('Checking whether the pull request can be merged...')
|
||||
const prInfo = (
|
||||
await github.rest.pulls.get({
|
||||
...context.repo,
|
||||
pull_number,
|
||||
})
|
||||
).data
|
||||
|
||||
if (prInfo.state !== 'open') throw new Error('PR is not open anymore.')
|
||||
|
||||
if (prInfo.mergeable == null) {
|
||||
core.info(
|
||||
`GitHub is still computing whether this PR can be merged, waiting ${retryInterval} seconds before trying again...`,
|
||||
)
|
||||
await new Promise((resolve) => setTimeout(resolve, retryInterval * 1000))
|
||||
continue
|
||||
}
|
||||
|
||||
const { base, head } = prInfo
|
||||
|
||||
const baseClassification = classify(base.ref)
|
||||
core.setOutput('base', baseClassification)
|
||||
console.log('base classification:', baseClassification)
|
||||
|
||||
const headClassification =
|
||||
base.repo.full_name === head.repo.full_name
|
||||
? classify(head.ref)
|
||||
: // PRs from forks are always considered WIP.
|
||||
{ type: ['wip'] }
|
||||
core.setOutput('head', headClassification)
|
||||
console.log('head classification:', headClassification)
|
||||
|
||||
if (baseClassification.type.includes('channel')) {
|
||||
const { stable, version } = baseClassification
|
||||
const correctBranch = stable ? `release-${version}` : 'master'
|
||||
const body = [
|
||||
'The `nixos-*` and `nixpkgs-*` branches are pushed to by the channel release script and should not be merged into directly.',
|
||||
'',
|
||||
`Please target \`${correctBranch}\` instead.`,
|
||||
].join('\n')
|
||||
|
||||
await postReview({ github, context, core, dry, body, reviewKey })
|
||||
|
||||
throw new Error('The PR targets a channel branch.')
|
||||
}
|
||||
|
||||
if (headClassification.type.includes('wip')) {
|
||||
// In the following, we look at the git history to determine the base branch that
|
||||
// this Pull Request branched off of. This is *supposed* to be the branch that it
|
||||
// merges into, but humans make mistakes. Once that happens we want to error out as
|
||||
// early as possible.
|
||||
|
||||
// To determine the "real base", we are looking at the merge-base of primary development
|
||||
// branches and the head of the PR. The merge-base which results in the least number of
|
||||
// commits between that base and head is the real base. We can query for this via GitHub's
|
||||
// REST API. There can be multiple candidates for the real base with the same number of
|
||||
// commits. In this case we pick the "best" candidate by a fixed ordering of branches,
|
||||
// as defined in ci/supportedBranches.js.
|
||||
//
|
||||
// These requests take a while, when comparing against the wrong release - they need
|
||||
// to look at way more than 10k commits in that case. Thus, we try to minimize the
|
||||
// number of requests across releases:
|
||||
// - First, we look at the primary development branches only: master and release-xx.yy.
|
||||
// The branch with the fewest commits gives us the release this PR belongs to.
|
||||
// - We then compare this number against the relevant staging branches for this release
|
||||
// to find the exact branch that this belongs to.
|
||||
|
||||
// All potential development branches
|
||||
const branches = (
|
||||
await github.paginate(github.rest.repos.listBranches, {
|
||||
...context.repo,
|
||||
per_page: 100,
|
||||
})
|
||||
).map(({ name }) => classify(name))
|
||||
|
||||
// All stable primary development branches from latest to oldest.
|
||||
const releases = branches
|
||||
.filter(({ stable, type }) => type.includes('primary') && stable)
|
||||
.sort((a, b) => b.version.localeCompare(a.version))
|
||||
|
||||
async function mergeBase({ branch, order, version }) {
|
||||
const { data } = await github.rest.repos.compareCommitsWithBasehead({
|
||||
...context.repo,
|
||||
basehead: `${branch}...${head.sha}`,
|
||||
// Pagination for this endpoint is about the commits listed, which we don't care about.
|
||||
per_page: 1,
|
||||
// Taking the second page skips the list of files of this changeset.
|
||||
page: 2,
|
||||
})
|
||||
return {
|
||||
branch,
|
||||
order,
|
||||
version,
|
||||
commits: data.total_commits,
|
||||
sha: data.merge_base_commit.sha,
|
||||
}
|
||||
}
|
||||
|
||||
// Multiple branches can be OK at the same time, if the PR was created of a merge-base,
|
||||
// thus storing as array.
|
||||
let candidates = [await mergeBase(classify('master'))]
|
||||
for (const release of releases) {
|
||||
const nextCandidate = await mergeBase(release)
|
||||
if (candidates[0].commits === nextCandidate.commits)
|
||||
candidates.push(nextCandidate)
|
||||
if (candidates[0].commits > nextCandidate.commits)
|
||||
candidates = [nextCandidate]
|
||||
// The number 10000 is principally arbitrary, but the GitHub API returns this value
|
||||
// when the number of commits exceeds it in reality. The difference between two stable releases
|
||||
// is certainly more than 10k commits, thus this works for us as well: If we're targeting
|
||||
// a wrong release, the number *will* be 10000.
|
||||
if (candidates[0].commits < 10000) break
|
||||
}
|
||||
|
||||
core.info(`This PR is for NixOS ${candidates[0].version}.`)
|
||||
|
||||
// Secondary development branches for the selected version only.
|
||||
const secondary = branches.filter(
|
||||
({ branch, type, version }) =>
|
||||
type.includes('secondary') && version === candidates[0].version,
|
||||
)
|
||||
|
||||
// Make sure that we always check the current target as well, even if its a WIP branch.
|
||||
secondary.push(classify(base.ref))
|
||||
|
||||
for (const branch of secondary) {
|
||||
const nextCandidate = await mergeBase(branch)
|
||||
if (candidates[0].commits === nextCandidate.commits)
|
||||
candidates.push(nextCandidate)
|
||||
if (candidates[0].commits > nextCandidate.commits)
|
||||
candidates = [nextCandidate]
|
||||
}
|
||||
|
||||
// If the current branch is among the candidates, this is always better than any other,
|
||||
// thus sorting at -1.
|
||||
candidates = candidates
|
||||
.map((candidate) =>
|
||||
candidate.branch === base.ref
|
||||
? { ...candidate, order: -1 }
|
||||
: candidate,
|
||||
)
|
||||
.sort((a, b) => a.order - b.order)
|
||||
|
||||
const best = candidates.at(0)
|
||||
|
||||
core.info('The base branches for this PR are:')
|
||||
core.info(`github: ${base.ref}`)
|
||||
core.info(
|
||||
`candidates: ${candidates.map(({ branch }) => branch).join(',')}`,
|
||||
)
|
||||
core.info(`best candidate: ${best.branch}`)
|
||||
|
||||
if (best.branch !== base.ref) {
|
||||
const current = await mergeBase(classify(base.ref))
|
||||
const body = [
|
||||
`The PR's base branch is set to \`${current.branch}\`, but ${current.commits === 10000 ? 'at least 10000' : current.commits - best.commits} commits from the \`${best.branch}\` branch are included. Make sure you know the [right base branch for your changes](https://github.com/NixOS/nixpkgs/blob/master/CONTRIBUTING.md#branch-conventions), then:`,
|
||||
`- If the changes should go to the \`${best.branch}\` branch, [change the base branch](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/changing-the-base-branch-of-a-pull-request).`,
|
||||
`- If the changes should go to the \`${current.branch}\` branch, rebase your PR onto the correct merge-base:`,
|
||||
' ```bash',
|
||||
` # git rebase --onto $(git merge-base upstream/${current.branch} HEAD) $(git merge-base upstream/${best.branch} HEAD)`,
|
||||
` git rebase --onto ${current.sha} ${best.sha}`,
|
||||
` git push --force-with-lease`,
|
||||
' ```',
|
||||
].join('\n')
|
||||
|
||||
await postReview({
|
||||
github,
|
||||
context,
|
||||
core,
|
||||
dry,
|
||||
body,
|
||||
event: 'REQUEST_CHANGES',
|
||||
reviewKey,
|
||||
})
|
||||
} else {
|
||||
await dismissReviews({ github, context, core, dry, reviewKey })
|
||||
}
|
||||
}
|
||||
|
||||
let mergedSha, targetSha
|
||||
|
||||
if (prInfo.mergeable) {
|
||||
core.info('The PR can be merged.')
|
||||
|
||||
mergedSha = prInfo.merge_commit_sha
|
||||
targetSha = (
|
||||
await github.rest.repos.getCommit({
|
||||
...context.repo,
|
||||
ref: prInfo.merge_commit_sha,
|
||||
})
|
||||
).data.parents[0].sha
|
||||
} else {
|
||||
core.warning('The PR has a merge conflict.')
|
||||
|
||||
mergedSha = head.sha
|
||||
targetSha = (
|
||||
await github.rest.repos.compareCommitsWithBasehead({
|
||||
...context.repo,
|
||||
basehead: `${base.sha}...${head.sha}`,
|
||||
})
|
||||
).data.merge_base_commit.sha
|
||||
}
|
||||
|
||||
core.info(
|
||||
`Checking the commits:\nmerged: ${mergedSha}\ntarget: ${targetSha}`,
|
||||
)
|
||||
core.setOutput('mergedSha', mergedSha)
|
||||
core.setOutput('targetSha', targetSha)
|
||||
|
||||
const systems = await supportedSystems({ github, context, targetSha })
|
||||
core.setOutput('systems', systems)
|
||||
|
||||
const files = (
|
||||
await github.paginate(github.rest.pulls.listFiles, {
|
||||
...context.repo,
|
||||
pull_number: context.payload.pull_request.number,
|
||||
per_page: 100,
|
||||
})
|
||||
).map((file) => file.filename)
|
||||
|
||||
const touched = []
|
||||
if (files.includes('ci/pinned.json')) touched.push('pinned')
|
||||
core.setOutput('touched', touched)
|
||||
|
||||
return
|
||||
}
|
||||
throw new Error(
|
||||
"Not retrying anymore. It's likely that GitHub is having internal issues: check https://www.githubstatus.com.",
|
||||
)
|
||||
}
|
||||
@@ -1,259 +0,0 @@
|
||||
import { dismissReviews, postReview } from './reviews.ts'
|
||||
import { type BranchClassification, classify } from './supportedBranches.ts'
|
||||
import supportedSystems from './supportedSystems.ts'
|
||||
|
||||
const reviewKey = 'prepare'
|
||||
|
||||
type GitHub = InstanceType<typeof import('@actions/github/lib/utils').GitHub>
|
||||
type Context = typeof import('@actions/github').context
|
||||
type Core = typeof import('@actions/core')
|
||||
|
||||
export default async ({
|
||||
github,
|
||||
context,
|
||||
core,
|
||||
dry,
|
||||
}: {
|
||||
github: GitHub
|
||||
context: Context
|
||||
core: Core
|
||||
dry: boolean
|
||||
}) => {
|
||||
const pull_number = context.payload.pull_request?.number
|
||||
if (!pull_number) throw new Error('Not a pull request')
|
||||
|
||||
for (const retryInterval of [5, 10, 20, 40, 80]) {
|
||||
core.info('Checking whether the pull request can be merged...')
|
||||
const prInfo = (
|
||||
await github.rest.pulls.get({
|
||||
...context.repo,
|
||||
pull_number,
|
||||
})
|
||||
).data
|
||||
|
||||
if (prInfo.state !== 'open') throw new Error('PR is not open anymore.')
|
||||
|
||||
if (prInfo.mergeable == null) {
|
||||
core.info(
|
||||
`GitHub is still computing whether this PR can be merged, waiting ${retryInterval} seconds before trying again...`,
|
||||
)
|
||||
await new Promise((resolve) => setTimeout(resolve, retryInterval * 1000))
|
||||
continue
|
||||
}
|
||||
|
||||
const { base, head } = prInfo
|
||||
|
||||
const baseClassification = classify(base.ref)
|
||||
core.setOutput('base', baseClassification)
|
||||
console.log('base classification:', baseClassification)
|
||||
|
||||
const headClassification =
|
||||
base.repo.full_name === head.repo.full_name
|
||||
? classify(head.ref)
|
||||
: // PRs from forks are always considered WIP.
|
||||
{ type: ['wip'] }
|
||||
core.setOutput('head', headClassification)
|
||||
console.log('head classification:', headClassification)
|
||||
|
||||
if (baseClassification.type.includes('channel')) {
|
||||
const { stable, version } = baseClassification
|
||||
const correctBranch = stable ? `release-${version}` : 'master'
|
||||
const body = [
|
||||
'The `nixos-*` and `nixpkgs-*` branches are pushed to by the channel release script and should not be merged into directly.',
|
||||
'',
|
||||
`Please target \`${correctBranch}\` instead.`,
|
||||
].join('\n')
|
||||
|
||||
await postReview({ github, context, core, dry, body, reviewKey })
|
||||
|
||||
throw new Error('The PR targets a channel branch.')
|
||||
}
|
||||
|
||||
if (headClassification.type.includes('wip')) {
|
||||
// In the following, we look at the git history to determine the base branch that
|
||||
// this Pull Request branched off of. This is *supposed* to be the branch that it
|
||||
// merges into, but humans make mistakes. Once that happens we want to error out as
|
||||
// early as possible.
|
||||
|
||||
// To determine the "real base", we are looking at the merge-base of primary development
|
||||
// branches and the head of the PR. The merge-base which results in the least number of
|
||||
// commits between that base and head is the real base. We can query for this via GitHub's
|
||||
// REST API. There can be multiple candidates for the real base with the same number of
|
||||
// commits. In this case we pick the "best" candidate by a fixed ordering of branches,
|
||||
// as defined in ci/github-script/supportedBranches.ts.
|
||||
//
|
||||
// These requests take a while, when comparing against the wrong release - they need
|
||||
// to look at way more than 10k commits in that case. Thus, we try to minimize the
|
||||
// number of requests across releases:
|
||||
// - First, we look at the primary development branches only: master and release-xx.yy.
|
||||
// The branch with the fewest commits gives us the release this PR belongs to.
|
||||
// - We then compare this number against the relevant staging branches for this release
|
||||
// to find the exact branch that this belongs to.
|
||||
|
||||
// All potential development branches
|
||||
const branches = (
|
||||
await github.paginate(github.rest.repos.listBranches, {
|
||||
...context.repo,
|
||||
per_page: 100,
|
||||
})
|
||||
).map(({ name }) => classify(name))
|
||||
|
||||
// All stable primary development branches from latest to oldest.
|
||||
const releases = branches
|
||||
.filter(({ stable, type }) => type.includes('primary') && stable)
|
||||
.sort((a, b) => b.version.localeCompare(a.version))
|
||||
|
||||
async function mergeBase({
|
||||
branch,
|
||||
order,
|
||||
version,
|
||||
}: Pick<BranchClassification, 'branch' | 'order' | 'version'>) {
|
||||
const { data } = await github.rest.repos.compareCommitsWithBasehead({
|
||||
...context.repo,
|
||||
basehead: `${branch}...${head.sha}`,
|
||||
// Pagination for this endpoint is about the commits listed, which we don't care about.
|
||||
per_page: 1,
|
||||
// Taking the second page skips the list of files of this changeset.
|
||||
page: 2,
|
||||
})
|
||||
return {
|
||||
branch,
|
||||
order,
|
||||
version,
|
||||
commits: data.total_commits,
|
||||
sha: data.merge_base_commit.sha,
|
||||
}
|
||||
}
|
||||
|
||||
// Multiple branches can be OK at the same time, if the PR was created of a merge-base,
|
||||
// thus storing as array.
|
||||
let candidates = [await mergeBase(classify('master'))]
|
||||
for (const release of releases) {
|
||||
const nextCandidate = await mergeBase(release)
|
||||
if (candidates[0].commits === nextCandidate.commits)
|
||||
candidates.push(nextCandidate)
|
||||
if (candidates[0].commits > nextCandidate.commits)
|
||||
candidates = [nextCandidate]
|
||||
// The number 10000 is principally arbitrary, but the GitHub API returns this value
|
||||
// when the number of commits exceeds it in reality. The difference between two stable releases
|
||||
// is certainly more than 10k commits, thus this works for us as well: If we're targeting
|
||||
// a wrong release, the number *will* be 10000.
|
||||
if (candidates[0].commits < 10000) break
|
||||
}
|
||||
|
||||
core.info(`This PR is for NixOS ${candidates[0].version}.`)
|
||||
|
||||
// Secondary development branches for the selected version only.
|
||||
const secondary = branches.filter(
|
||||
({ branch, type, version }) =>
|
||||
type.includes('secondary') && version === candidates[0].version,
|
||||
)
|
||||
|
||||
// Make sure that we always check the current target as well, even if its a WIP branch.
|
||||
secondary.push(classify(base.ref))
|
||||
|
||||
for (const branch of secondary) {
|
||||
const nextCandidate = await mergeBase(branch)
|
||||
if (candidates[0].commits === nextCandidate.commits)
|
||||
candidates.push(nextCandidate)
|
||||
if (candidates[0].commits > nextCandidate.commits)
|
||||
candidates = [nextCandidate]
|
||||
}
|
||||
|
||||
// If the current branch is among the candidates, this is always better than any other,
|
||||
// thus sorting at -1.
|
||||
candidates = candidates
|
||||
.map((candidate) =>
|
||||
candidate.branch === base.ref
|
||||
? { ...candidate, order: -1 }
|
||||
: candidate,
|
||||
)
|
||||
.sort((a, b) => a.order - b.order)
|
||||
|
||||
const best = candidates[0]
|
||||
|
||||
core.info('The base branches for this PR are:')
|
||||
core.info(`github: ${base.ref}`)
|
||||
core.info(
|
||||
`candidates: ${candidates.map(({ branch }) => branch).join(',')}`,
|
||||
)
|
||||
core.info(`best candidate: ${best.branch}`)
|
||||
|
||||
if (best.branch !== base.ref) {
|
||||
const current = await mergeBase(classify(base.ref))
|
||||
const body = [
|
||||
`The PR's base branch is set to \`${current.branch}\`, but ${current.commits === 10000 ? 'at least 10000' : current.commits - best.commits} commits from the \`${best.branch}\` branch are included. Make sure you know the [right base branch for your changes](https://github.com/NixOS/nixpkgs/blob/master/CONTRIBUTING.md#branch-conventions), then:`,
|
||||
`- If the changes should go to the \`${best.branch}\` branch, [change the base branch](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/changing-the-base-branch-of-a-pull-request).`,
|
||||
`- If the changes should go to the \`${current.branch}\` branch, rebase your PR onto the correct merge-base:`,
|
||||
' ```bash',
|
||||
` # git rebase --onto $(git merge-base upstream/${current.branch} HEAD) $(git merge-base upstream/${best.branch} HEAD)`,
|
||||
` git rebase --onto ${current.sha} ${best.sha}`,
|
||||
` git push --force-with-lease`,
|
||||
' ```',
|
||||
].join('\n')
|
||||
|
||||
await postReview({
|
||||
github,
|
||||
context,
|
||||
core,
|
||||
dry,
|
||||
body,
|
||||
event: 'REQUEST_CHANGES',
|
||||
reviewKey,
|
||||
})
|
||||
} else {
|
||||
await dismissReviews({ github, context, core, dry, reviewKey })
|
||||
}
|
||||
}
|
||||
|
||||
let mergedSha: string, targetSha: string
|
||||
|
||||
if (prInfo.mergeable && prInfo.merge_commit_sha != null) {
|
||||
core.info('The PR can be merged.')
|
||||
|
||||
mergedSha = prInfo.merge_commit_sha
|
||||
targetSha = (
|
||||
await github.rest.repos.getCommit({
|
||||
...context.repo,
|
||||
ref: prInfo.merge_commit_sha,
|
||||
})
|
||||
).data.parents[0].sha
|
||||
} else {
|
||||
core.warning('The PR has a merge conflict.')
|
||||
|
||||
mergedSha = head.sha
|
||||
targetSha = (
|
||||
await github.rest.repos.compareCommitsWithBasehead({
|
||||
...context.repo,
|
||||
basehead: `${base.sha}...${head.sha}`,
|
||||
})
|
||||
).data.merge_base_commit.sha
|
||||
}
|
||||
|
||||
core.info(
|
||||
`Checking the commits:\nmerged: ${mergedSha}\ntarget: ${targetSha}`,
|
||||
)
|
||||
core.setOutput('mergedSha', mergedSha)
|
||||
core.setOutput('targetSha', targetSha)
|
||||
|
||||
const systems = await supportedSystems({ github, context, targetSha })
|
||||
core.setOutput('systems', systems)
|
||||
|
||||
const files = (
|
||||
await github.paginate(github.rest.pulls.listFiles, {
|
||||
...context.repo,
|
||||
pull_number,
|
||||
per_page: 100,
|
||||
})
|
||||
).map((file) => file.filename)
|
||||
|
||||
const touched = []
|
||||
if (files.includes('ci/pinned.json')) touched.push('pinned')
|
||||
core.setOutput('touched', touched)
|
||||
|
||||
return
|
||||
}
|
||||
throw new Error(
|
||||
"Not retrying anymore. It's likely that GitHub is having internal issues: check https://www.githubstatus.com.",
|
||||
)
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
import fs from 'node:fs'
|
||||
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 { getCommitDetailsForPR } from './get-pr-commit-details.ts'
|
||||
import { dismissReviews, postReview } from './reviews.ts'
|
||||
import { classify } from './supportedBranches.ts'
|
||||
|
||||
/**
|
||||
* Reminders to post as a non-blocking review when a pull request touches
|
||||
* certain matching paths.
|
||||
*/
|
||||
export const reminders: { key: string; paths: RegExp[] }[] = [
|
||||
{
|
||||
key: 'docs-styleguide',
|
||||
paths: [/^doc\//, /^nixos\/doc\//, /^nixos\/modules\/.*\.md$/],
|
||||
},
|
||||
]
|
||||
|
||||
export function matchesAny(changedPaths: string[], patterns: RegExp[]) {
|
||||
return changedPaths.some((changedPath) =>
|
||||
patterns.some((pattern) => pattern.test(changedPath)),
|
||||
)
|
||||
}
|
||||
|
||||
function reminderBody(key: string) {
|
||||
return fs
|
||||
.readFileSync(path.join(import.meta.dirname, 'reminders', `${key}.md`), {
|
||||
encoding: 'utf8',
|
||||
})
|
||||
.trim()
|
||||
}
|
||||
|
||||
export default async function postReminders({
|
||||
github,
|
||||
context,
|
||||
core,
|
||||
repoPath,
|
||||
dry,
|
||||
}: {
|
||||
github: InstanceType<typeof GitHub>
|
||||
context: typeof actionsContext
|
||||
core: typeof actionsCore
|
||||
repoPath?: string
|
||||
dry: boolean
|
||||
}) {
|
||||
const pull_number = context.payload.pull_request?.number
|
||||
if (!pull_number) {
|
||||
core.info('This is not a pull request. Skipping reminders.')
|
||||
return
|
||||
}
|
||||
|
||||
const pr = (
|
||||
await github.rest.pulls.get({
|
||||
...context.repo,
|
||||
pull_number,
|
||||
})
|
||||
).data
|
||||
|
||||
if (pr.user.login.endsWith('[bot]')) {
|
||||
core.info('This is a bot, so reminders do not apply.')
|
||||
return
|
||||
}
|
||||
|
||||
const baseBranchType = classify(
|
||||
pr.base.ref.replace(/^refs\/heads\//, ''),
|
||||
).type
|
||||
const headBranchType = classify(
|
||||
pr.head.ref.replace(/^refs\/heads\//, ''),
|
||||
).type
|
||||
|
||||
if (
|
||||
baseBranchType.includes('development') &&
|
||||
headBranchType.includes('development') &&
|
||||
pr.base.repo.id === pr.head.repo?.id
|
||||
) {
|
||||
// This matches, for example, PRs from NixOS:staging-next to NixOS:master, or vice versa.
|
||||
// We ignore them, we should only care about PRs introducing new commits.
|
||||
// We still want to run on PRs from, e.g., Someone:master to NixOS:master though.
|
||||
core.info(
|
||||
'This PR is from one development branch to another. Skipping reminders.',
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const details = await getCommitDetailsForPR({ core, pr, repoPath })
|
||||
const changedPaths = details.flatMap(({ changedPaths }) => changedPaths)
|
||||
|
||||
for (const { key, paths } of reminders) {
|
||||
if (matchesAny(changedPaths, paths)) {
|
||||
await postReview({
|
||||
github,
|
||||
context,
|
||||
core,
|
||||
dry,
|
||||
event: 'COMMENT',
|
||||
body: reminderBody(key),
|
||||
reviewKey: key,
|
||||
})
|
||||
} else {
|
||||
await dismissReviews({
|
||||
github,
|
||||
context,
|
||||
core,
|
||||
dry,
|
||||
reviewKey: key,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
Thanks for contributing to the documentation
|
||||
|
||||
Make sure you follow the [documentation style guide](https://github.com/NixOS/nixpkgs/blob/master/doc/styleguide.md), most notably:
|
||||
|
||||
- Show, don't tell: lead with a minimal working example; explanation follows the code.
|
||||
- No meta-commentary: don't write "This section explains how to…", just do it.
|
||||
- Imperative mood and active voice: "Run the command", not "The user should run the following command".
|
||||
- Present tense: "This creates a folder", not "This will create a folder".
|
||||
- Be confident: no hedging with "should", "might", "typically", "usually".
|
||||
- Cut filler words: "simply", "just", "easily", "basically"; "to", not "in order to".
|
||||
|
||||
For larger changes, like adding or removing whole sections, ask the NixOS documentation team for a review.
|
||||
188
ci/github-script/reviewers.js
Normal file
188
ci/github-script/reviewers.js
Normal file
@@ -0,0 +1,188 @@
|
||||
async function handleReviewers({
|
||||
github,
|
||||
context,
|
||||
core,
|
||||
log,
|
||||
dry,
|
||||
pull_request,
|
||||
reviews,
|
||||
user_maintainers,
|
||||
team_maintainers,
|
||||
owners,
|
||||
getUser,
|
||||
getTeam,
|
||||
}) {
|
||||
const pull_number = pull_request.number
|
||||
|
||||
// Users that the PR has already reached, e.g. they've left a review or have been requested for one
|
||||
const users_reached = new Set([
|
||||
...pull_request.requested_reviewers.map(({ login }) => login.toLowerCase()),
|
||||
...reviews.map(({ user }) => user.login.toLowerCase()),
|
||||
])
|
||||
log('reviewers - users_reached', Array.from(users_reached).join(', '))
|
||||
|
||||
// Same for teams
|
||||
const teams_reached = new Set([
|
||||
...pull_request.requested_teams.map(({ slug }) => slug.toLowerCase()),
|
||||
...reviews.flatMap(({ onBehalfOf }) =>
|
||||
onBehalfOf.nodes.map(({ slug }) => slug.toLowerCase()),
|
||||
),
|
||||
])
|
||||
log('reviewers - teams_reached', Array.from(teams_reached).join(', '))
|
||||
|
||||
// Early sanity check, before we start making any API requests. The list of maintainers
|
||||
// does not have duplicates so the only user to filter out from this list would be the
|
||||
// PR author. Therefore, we check for a limit of 15+1, where 15 is the limit we check
|
||||
// further down again.
|
||||
// This is to protect against huge treewides consuming all our API requests for no
|
||||
// reason.
|
||||
if (user_maintainers.length + team_maintainers.length > 16) {
|
||||
core.warning('Too many potential reviewers, skipping review requests.')
|
||||
// Return a boolean on whether the "needs: reviewers" label should be set.
|
||||
return users_reached.size === 0 && teams_reached.size === 0
|
||||
}
|
||||
|
||||
// Users that should be reached
|
||||
var users_to_reach = new Set([
|
||||
...(
|
||||
await Promise.all(
|
||||
user_maintainers.map(async (id) => {
|
||||
const user = await getUser(id)
|
||||
// User may have deleted their account
|
||||
return user?.login?.toLowerCase()
|
||||
}),
|
||||
)
|
||||
).filter(Boolean),
|
||||
...owners
|
||||
.filter((handle) => handle && !handle.includes('/'))
|
||||
.map((handle) => handle.toLowerCase()),
|
||||
])
|
||||
// We can't request a review from the author.
|
||||
.difference(new Set([pull_request.user?.login.toLowerCase()]))
|
||||
|
||||
// Filter users to repository collaborators. If they're not, they can't be requested
|
||||
// for review. In that case, they probably missed their invite to the maintainers team.
|
||||
users_to_reach = new Set(
|
||||
(
|
||||
await Promise.all(
|
||||
Array.from(users_to_reach, async (username) => {
|
||||
// TODO: Restructure this file to only do the collaborator check for those users
|
||||
// who were not already part of a team. Being a member of a team makes them
|
||||
// collaborators by definition.
|
||||
try {
|
||||
await github.rest.repos.checkCollaborator({
|
||||
...context.repo,
|
||||
username,
|
||||
})
|
||||
return username
|
||||
} catch (e) {
|
||||
if (e.status !== 404) throw e
|
||||
core.warning(
|
||||
`PR #${pull_number}: User ${username} cannot be requested for review because they don't exist or are not a repository collaborator, ignoring. They probably missed the automated invite to the maintainers team (see <https://github.com/NixOS/nixpkgs/issues/234293>).`,
|
||||
)
|
||||
}
|
||||
}),
|
||||
)
|
||||
).filter(Boolean),
|
||||
)
|
||||
log('reviewers - users_to_reach', Array.from(users_to_reach).join(', '))
|
||||
|
||||
// Similar for teams
|
||||
var teams_to_reach = new Set([
|
||||
...(
|
||||
await Promise.all(
|
||||
team_maintainers.map(async (id) => {
|
||||
const team = await getTeam(id)
|
||||
// Team may have been deleted
|
||||
return team?.slug?.toLowerCase()
|
||||
}),
|
||||
)
|
||||
).filter(Boolean),
|
||||
...owners
|
||||
.map((handle) => handle.split('/'))
|
||||
.filter(
|
||||
([org, slug]) =>
|
||||
org.toLowerCase() === context.repo.owner.toLowerCase() && slug,
|
||||
)
|
||||
.map(([, slug]) => slug.toLowerCase()),
|
||||
])
|
||||
teams_to_reach = new Set(
|
||||
(
|
||||
await Promise.all(
|
||||
Array.from(teams_to_reach, async (slug) => {
|
||||
try {
|
||||
await github.rest.teams.checkPermissionsForRepoInOrg({
|
||||
org: context.repo.owner,
|
||||
team_slug: slug,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
})
|
||||
return slug
|
||||
} catch (e) {
|
||||
if (e.status !== 404) throw e
|
||||
core.warning(
|
||||
`PR #${pull_number}: Team ${slug} cannot be requested for review because it doesn't exist or has no repository permissions, ignoring. Probably wasn't added to the nixpkgs-maintainers team (see https://github.com/NixOS/nixpkgs/tree/master/maintainers#maintainer-teams)`,
|
||||
)
|
||||
}
|
||||
}),
|
||||
)
|
||||
).filter(Boolean),
|
||||
)
|
||||
log('reviewers - teams_to_reach', Array.from(teams_to_reach).join(', '))
|
||||
|
||||
if (users_to_reach.size + teams_to_reach.size > 15) {
|
||||
core.warning(
|
||||
`Too many reviewers (users: ${Array.from(users_to_reach).join(', ')}, teams: ${Array.from(teams_to_reach).join(', ')}), skipping review requests.`,
|
||||
)
|
||||
// Return a boolean on whether the "needs: reviewers" label should be set.
|
||||
return users_reached.size === 0 && teams_reached.size === 0
|
||||
}
|
||||
|
||||
// We don't want to rerequest reviews from people who already reviewed or were requested
|
||||
const users_not_yet_reached = Array.from(
|
||||
users_to_reach.difference(users_reached),
|
||||
)
|
||||
log('reviewers - users_not_yet_reached', users_not_yet_reached.join(', '))
|
||||
// We don't want to rerequest reviews from teams who already reviewed or were requested
|
||||
const teams_not_yet_reached = Array.from(
|
||||
teams_to_reach.difference(teams_reached),
|
||||
)
|
||||
log('reviewers - teams_not_yet_reached', teams_not_yet_reached.join(', '))
|
||||
|
||||
if (
|
||||
users_not_yet_reached.length === 0 &&
|
||||
teams_not_yet_reached.length === 0
|
||||
) {
|
||||
log('Has reviewer changes', 'false (skipped)')
|
||||
} else if (dry) {
|
||||
core.info(
|
||||
`Requesting user reviewers for #${pull_number}: ${users_not_yet_reached.join(', ')} (dry)`,
|
||||
)
|
||||
core.info(
|
||||
`Requesting team reviewers for #${pull_number}: ${teams_not_yet_reached.join(', ')} (dry)`,
|
||||
)
|
||||
} else {
|
||||
// We had tried the "request all reviewers at once" thing in the past, but it didn't work out:
|
||||
// https://github.com/NixOS/nixpkgs/commit/034613f860fcd339bd2c20c8f6bc259a2f9dc034
|
||||
// If we're hitting API errors here again, we'll need to investigate - and possibly reverse
|
||||
// course.
|
||||
await github.rest.pulls.requestReviewers({
|
||||
...context.repo,
|
||||
pull_number,
|
||||
reviewers: users_not_yet_reached,
|
||||
team_reviewers: teams_not_yet_reached,
|
||||
})
|
||||
}
|
||||
|
||||
// Return a boolean on whether the "needs: reviewers" label should be set.
|
||||
return (
|
||||
users_not_yet_reached.length === 0 &&
|
||||
teams_not_yet_reached.length === 0 &&
|
||||
users_reached.size === 0 &&
|
||||
teams_reached.size === 0
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
handleReviewers,
|
||||
}
|
||||
@@ -1,210 +0,0 @@
|
||||
import type { MergeProps } from './merge.ts'
|
||||
|
||||
type ReviewersProps = Pick<
|
||||
MergeProps,
|
||||
| 'github'
|
||||
| 'context'
|
||||
| 'core'
|
||||
| 'log'
|
||||
| 'dry'
|
||||
| 'pull_request'
|
||||
| 'reviews'
|
||||
| 'getUser'
|
||||
> & {
|
||||
user_maintainers: number[]
|
||||
team_maintainers: number[]
|
||||
owners: string[]
|
||||
getTeam: (id: number) => Promise<{ slug: string } | null>
|
||||
}
|
||||
export async function handleReviewers({
|
||||
github,
|
||||
context,
|
||||
core,
|
||||
log,
|
||||
dry,
|
||||
pull_request,
|
||||
reviews,
|
||||
user_maintainers,
|
||||
team_maintainers,
|
||||
owners,
|
||||
getUser,
|
||||
getTeam,
|
||||
}: ReviewersProps) {
|
||||
const pull_number = pull_request.number
|
||||
|
||||
// Users that the PR has already reached, e.g. they've left a review or have been requested for one
|
||||
const users_reached = new Set([
|
||||
...(pull_request.requested_reviewers ?? []).map(({ login }) =>
|
||||
login.toLowerCase(),
|
||||
),
|
||||
...reviews.map(({ user }) => user.login.toLowerCase()),
|
||||
])
|
||||
log('reviewers - users_reached', Array.from(users_reached).join(', '))
|
||||
|
||||
// Same for teams
|
||||
const teams_reached = new Set([
|
||||
...(pull_request.requested_teams ?? []).map(({ slug }) =>
|
||||
slug.toLowerCase(),
|
||||
),
|
||||
...reviews.flatMap(({ onBehalfOf }) =>
|
||||
onBehalfOf.nodes.map(({ slug }) => slug.toLowerCase()),
|
||||
),
|
||||
])
|
||||
log('reviewers - teams_reached', Array.from(teams_reached).join(', '))
|
||||
|
||||
// Early sanity check, before we start making any API requests. The list of maintainers
|
||||
// does not have duplicates so the only user to filter out from this list would be the
|
||||
// PR author. Therefore, we check for a limit of 15+1, where 15 is the limit we check
|
||||
// further down again.
|
||||
// This is to protect against huge treewides consuming all our API requests for no
|
||||
// reason.
|
||||
if (user_maintainers.length + team_maintainers.length > 16) {
|
||||
core.warning('Too many potential reviewers, skipping review requests.')
|
||||
// Return a boolean on whether the "needs: reviewers" label should be set.
|
||||
return users_reached.size === 0 && teams_reached.size === 0
|
||||
}
|
||||
|
||||
// Users that should be reached
|
||||
var users_to_reach = new Set([
|
||||
...(
|
||||
await Promise.all(
|
||||
user_maintainers.map(async (id) => {
|
||||
const user = await getUser(id)
|
||||
// User may have deleted their account
|
||||
return user?.login?.toLowerCase()
|
||||
}),
|
||||
)
|
||||
).filter((value): value is string => !!value),
|
||||
...owners
|
||||
.filter((handle) => handle && !handle.includes('/'))
|
||||
.map((handle) => handle.toLowerCase()),
|
||||
])
|
||||
// We can't request a review from the author.
|
||||
.difference(new Set([pull_request.user?.login.toLowerCase()]))
|
||||
|
||||
// Filter users to repository collaborators. If they're not, they can't be requested
|
||||
// for review. In that case, they probably missed their invite to the maintainers team.
|
||||
users_to_reach = new Set(
|
||||
(
|
||||
await Promise.all(
|
||||
Array.from(users_to_reach, async (username) => {
|
||||
// TODO: Restructure this file to only do the collaborator check for those users
|
||||
// who were not already part of a team. Being a member of a team makes them
|
||||
// collaborators by definition.
|
||||
try {
|
||||
await github.rest.repos.checkCollaborator({
|
||||
...context.repo,
|
||||
username,
|
||||
})
|
||||
return username
|
||||
} catch (e) {
|
||||
if (!(e instanceof Error && 'status' in e && e.status === 404)) {
|
||||
throw e
|
||||
}
|
||||
core.warning(
|
||||
`PR #${pull_number}: User ${username} cannot be requested for review because they don't exist or are not a repository collaborator, ignoring. They probably missed the automated invite to the maintainers team (see <https://github.com/NixOS/nixpkgs/issues/234293>).`,
|
||||
)
|
||||
}
|
||||
}),
|
||||
)
|
||||
).filter((value): value is string => !!value),
|
||||
)
|
||||
log('reviewers - users_to_reach', Array.from(users_to_reach).join(', '))
|
||||
|
||||
// Similar for teams
|
||||
var teams_to_reach = new Set([
|
||||
...(
|
||||
await Promise.all(
|
||||
team_maintainers.map(async (id) => {
|
||||
const team = await getTeam(id)
|
||||
// Team may have been deleted
|
||||
return team?.slug?.toLowerCase()
|
||||
}),
|
||||
)
|
||||
).filter((value): value is string => !!value),
|
||||
...owners
|
||||
.map((handle) => handle.split('/'))
|
||||
.filter(
|
||||
([org, slug]) =>
|
||||
org.toLowerCase() === context.repo.owner.toLowerCase() && slug,
|
||||
)
|
||||
.map(([, slug]) => slug.toLowerCase()),
|
||||
])
|
||||
teams_to_reach = new Set(
|
||||
(
|
||||
await Promise.all(
|
||||
Array.from(teams_to_reach, async (slug) => {
|
||||
try {
|
||||
await github.rest.teams.checkPermissionsForRepoInOrg({
|
||||
org: context.repo.owner,
|
||||
team_slug: slug,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
})
|
||||
return slug
|
||||
} catch (e) {
|
||||
if (!(e instanceof Error && 'status' in e && e.status === 404)) {
|
||||
throw e
|
||||
}
|
||||
core.warning(
|
||||
`PR #${pull_number}: Team ${slug} cannot be requested for review because it doesn't exist or has no repository permissions, ignoring. Probably wasn't added to the nixpkgs-maintainers team (see https://github.com/NixOS/nixpkgs/tree/master/maintainers#maintainer-teams)`,
|
||||
)
|
||||
}
|
||||
}),
|
||||
)
|
||||
).filter((value): value is string => !!value),
|
||||
)
|
||||
log('reviewers - teams_to_reach', Array.from(teams_to_reach).join(', '))
|
||||
|
||||
if (users_to_reach.size + teams_to_reach.size > 15) {
|
||||
core.warning(
|
||||
`Too many reviewers (users: ${Array.from(users_to_reach).join(', ')}, teams: ${Array.from(teams_to_reach).join(', ')}), skipping review requests.`,
|
||||
)
|
||||
// Return a boolean on whether the "needs: reviewers" label should be set.
|
||||
return users_reached.size === 0 && teams_reached.size === 0
|
||||
}
|
||||
|
||||
// We don't want to rerequest reviews from people who already reviewed or were requested
|
||||
const users_not_yet_reached = Array.from(
|
||||
users_to_reach.difference(users_reached),
|
||||
)
|
||||
log('reviewers - users_not_yet_reached', users_not_yet_reached.join(', '))
|
||||
// We don't want to rerequest reviews from teams who already reviewed or were requested
|
||||
const teams_not_yet_reached = Array.from(
|
||||
teams_to_reach.difference(teams_reached),
|
||||
)
|
||||
log('reviewers - teams_not_yet_reached', teams_not_yet_reached.join(', '))
|
||||
|
||||
if (
|
||||
users_not_yet_reached.length === 0 &&
|
||||
teams_not_yet_reached.length === 0
|
||||
) {
|
||||
log('Has reviewer changes', 'false (skipped)')
|
||||
} else if (dry) {
|
||||
core.info(
|
||||
`Requesting user reviewers for #${pull_number}: ${users_not_yet_reached.join(', ')} (dry)`,
|
||||
)
|
||||
core.info(
|
||||
`Requesting team reviewers for #${pull_number}: ${teams_not_yet_reached.join(', ')} (dry)`,
|
||||
)
|
||||
} else {
|
||||
// We had tried the "request all reviewers at once" thing in the past, but it didn't work out:
|
||||
// https://github.com/NixOS/nixpkgs/commit/034613f860fcd339bd2c20c8f6bc259a2f9dc034
|
||||
// If we're hitting API errors here again, we'll need to investigate - and possibly reverse
|
||||
// course.
|
||||
await github.rest.pulls.requestReviewers({
|
||||
...context.repo,
|
||||
pull_number,
|
||||
reviewers: users_not_yet_reached,
|
||||
team_reviewers: teams_not_yet_reached,
|
||||
})
|
||||
}
|
||||
|
||||
// Return a boolean on whether the "needs: reviewers" label should be set.
|
||||
return (
|
||||
users_not_yet_reached.length === 0 &&
|
||||
teams_not_yet_reached.length === 0 &&
|
||||
users_reached.size === 0 &&
|
||||
teams_reached.size === 0
|
||||
)
|
||||
}
|
||||
271
ci/github-script/reviews.js
Normal file
271
ci/github-script/reviews.js
Normal file
@@ -0,0 +1,271 @@
|
||||
// @ts-check
|
||||
|
||||
const eventToState = {
|
||||
COMMENT: 'COMMENTED',
|
||||
REQUEST_CHANGES: 'CHANGES_REQUESTED',
|
||||
}
|
||||
|
||||
// Use substring checks in order to allow testing in forks
|
||||
// Usernames must also end in "[bot]"
|
||||
const reviewUsers = [
|
||||
'github-actions',
|
||||
'nixpkgs-ci',
|
||||
'branch-check',
|
||||
'commit-check',
|
||||
'manual-edit',
|
||||
]
|
||||
|
||||
/**
|
||||
* @typedef {InstanceType<import('@actions/github/lib/utils').GitHub>} GitHub
|
||||
* @typedef {typeof import('@actions/github').context} Context
|
||||
*
|
||||
* @typedef {Awaited<ReturnType<GitHub['rest']['pulls']['listReviews']>>['data'][number]} Review
|
||||
* @typedef {Review & { user: NonNullable<Review['user']> }} ReviewWithNonNullUser
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* github: GitHub,
|
||||
* context: Context,
|
||||
* core: import('@actions/core'),
|
||||
* dry: boolean,
|
||||
* reviewKey?: string,
|
||||
* }} DismissReviewsProps
|
||||
*/
|
||||
async function dismissReviews({ github, context, core, dry, reviewKey }) {
|
||||
const pull_number = context.payload.pull_request?.number
|
||||
if (!pull_number) {
|
||||
core.warning('dismissReviews called outside of pull_request context')
|
||||
return
|
||||
}
|
||||
|
||||
if (dry) {
|
||||
return
|
||||
}
|
||||
|
||||
const allReviews = await github.paginate(github.rest.pulls.listReviews, {
|
||||
...context.repo,
|
||||
pull_number,
|
||||
})
|
||||
|
||||
const reviews = /** @type {ReviewWithNonNullUser[]} */ (
|
||||
allReviews.filter(
|
||||
(review) =>
|
||||
review.user &&
|
||||
review.state !== 'DISMISSED' &&
|
||||
review.user.login.endsWith('[bot]') &&
|
||||
reviewUsers.some((substr) => review.user?.login.includes(substr)),
|
||||
)
|
||||
)
|
||||
|
||||
const reviewsByUser = reviews.reduce(
|
||||
(prev, curr) => {
|
||||
if (!(curr.user.login in prev)) {
|
||||
prev[curr.user.login] = []
|
||||
}
|
||||
|
||||
prev[curr.user.login].push(curr)
|
||||
|
||||
return prev
|
||||
},
|
||||
/** @type {Record<string, ReviewWithNonNullUser[]> } */ ({}),
|
||||
)
|
||||
|
||||
const commentRegex = new RegExp(
|
||||
/<!-- nixpkgs review key: (.*)(?:; resolved: .*)? -->/,
|
||||
)
|
||||
const reviewKeyRegex = new RegExp(
|
||||
`<!-- (nixpkgs review key: ${reviewKey})(?:; resolved: .*)? -->`,
|
||||
)
|
||||
const commentResolvedRegex = new RegExp(
|
||||
/<!-- nixpkgs review key: .*; resolved: true -->/,
|
||||
)
|
||||
|
||||
let reviewsToMinimize = reviews
|
||||
const /** @type {ReviewWithNonNullUser[]} */ reviewsToDismiss = []
|
||||
const /** @type {ReviewWithNonNullUser[]} */ reviewsToResolve = []
|
||||
|
||||
if (reviewKey && reviews.every((review) => commentRegex.test(review.body))) {
|
||||
reviewsToMinimize = reviews.filter((review) =>
|
||||
reviewKeyRegex.test(review.body),
|
||||
)
|
||||
}
|
||||
|
||||
for (const reviewsForUser of Object.values(reviewsByUser)) {
|
||||
// Make sure that we don't dismiss all reviews by a user if they
|
||||
// have any reviews we don't want to dismiss.
|
||||
if (
|
||||
reviewsForUser.every(
|
||||
(review) =>
|
||||
commentResolvedRegex.test(review.body) ||
|
||||
(reviewKey && reviewKeyRegex.test(review.body)) ||
|
||||
// If we are called by check-commits and the review body is clearly
|
||||
// from `commits.js`, then we can safely dismiss the review.
|
||||
// This helps with pre-existing reviews (before the comments were added).
|
||||
(reviewKey &&
|
||||
reviewKey === 'check-commits' &&
|
||||
review.body.includes('PR / Check / cherry-pick')),
|
||||
)
|
||||
) {
|
||||
reviewsToDismiss.push(
|
||||
...reviewsForUser.filter(
|
||||
(review) => review.state === 'CHANGES_REQUESTED',
|
||||
),
|
||||
)
|
||||
} else {
|
||||
reviewsToResolve.push(
|
||||
...reviewsForUser.filter(
|
||||
(review) =>
|
||||
review.state === 'CHANGES_REQUESTED' &&
|
||||
!commentResolvedRegex.test(review.body) &&
|
||||
reviewsToMinimize.some(
|
||||
(toMinimize) => toMinimize.node_id === review.node_id,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
...reviewsToMinimize.map(async (review) =>
|
||||
github.graphql(
|
||||
`mutation($node_id:ID!) {
|
||||
minimizeComment(input: {
|
||||
classifier: OUTDATED,
|
||||
subjectId: $node_id
|
||||
})
|
||||
{ clientMutationId }
|
||||
}`,
|
||||
{ node_id: review.node_id },
|
||||
),
|
||||
),
|
||||
...reviewsToDismiss.map(async (review) =>
|
||||
github.rest.pulls.dismissReview({
|
||||
...context.repo,
|
||||
pull_number,
|
||||
review_id: review.id,
|
||||
message: 'Review dismissed automatically',
|
||||
}),
|
||||
),
|
||||
...reviewsToResolve.map(async (review) =>
|
||||
github.rest.pulls.updateReview({
|
||||
...context.repo,
|
||||
pull_number,
|
||||
review_id: review.id,
|
||||
body: review.body.replace(
|
||||
reviewKeyRegex,
|
||||
`<!-- nixpkgs review key: ${reviewKey}; resolved: true -->`,
|
||||
),
|
||||
}),
|
||||
),
|
||||
])
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* github: GitHub,
|
||||
* context: Context,
|
||||
* core: import('@actions/core'),
|
||||
* dry: boolean,
|
||||
* body: string,
|
||||
* event: keyof eventToState,
|
||||
* reviewKey: string,
|
||||
* }} PostReviewProps
|
||||
*/
|
||||
async function postReview({
|
||||
github,
|
||||
context,
|
||||
core,
|
||||
dry,
|
||||
body,
|
||||
event = 'REQUEST_CHANGES',
|
||||
reviewKey,
|
||||
}) {
|
||||
const pull_number = context.payload.pull_request?.number
|
||||
if (!pull_number) {
|
||||
core.warning('postReview called outside of pull_request context')
|
||||
return
|
||||
}
|
||||
|
||||
const reviewKeyRegex = new RegExp(
|
||||
`<!-- (nixpkgs review key: ${reviewKey})(?:; resolved: .*)? -->`,
|
||||
)
|
||||
const reviewKeyComment = `<!-- nixpkgs review key: ${reviewKey}; resolved: false -->`
|
||||
body = body + '\n\n' + reviewKeyComment
|
||||
|
||||
const reviews = (
|
||||
await github.paginate(github.rest.pulls.listReviews, {
|
||||
...context.repo,
|
||||
pull_number,
|
||||
})
|
||||
).filter(
|
||||
(review) =>
|
||||
review.user &&
|
||||
review.state !== 'DISMISSED' &&
|
||||
review.user.login.endsWith('[bot]') &&
|
||||
reviewUsers.some((substr) => review.user?.login.includes(substr)),
|
||||
)
|
||||
|
||||
/** @type {null | Review} */
|
||||
let pendingReview
|
||||
const matchingReviews = reviews.filter((review) =>
|
||||
reviewKeyRegex.test(review.body),
|
||||
)
|
||||
|
||||
if (matchingReviews.length === 0) {
|
||||
pendingReview = null
|
||||
} else if (
|
||||
matchingReviews.length === 1 &&
|
||||
matchingReviews[0].state === eventToState[event]
|
||||
) {
|
||||
pendingReview = matchingReviews[0]
|
||||
} else {
|
||||
await dismissReviews({
|
||||
github,
|
||||
context,
|
||||
core,
|
||||
dry,
|
||||
reviewKey,
|
||||
})
|
||||
pendingReview = null
|
||||
}
|
||||
|
||||
if (dry) {
|
||||
if (pendingReview)
|
||||
core.info(`pending review found: ${pendingReview.html_url}`)
|
||||
else core.info('no pending review found')
|
||||
core.info(body)
|
||||
} else {
|
||||
if (pendingReview) {
|
||||
await Promise.all([
|
||||
github.rest.pulls.updateReview({
|
||||
...context.repo,
|
||||
pull_number,
|
||||
review_id: pendingReview.id,
|
||||
body,
|
||||
}),
|
||||
github.graphql(
|
||||
`mutation($node_id:ID!) {
|
||||
unminimizeComment(input: {
|
||||
subjectId: $node_id
|
||||
})
|
||||
{ clientMutationId }
|
||||
}`,
|
||||
{ node_id: pendingReview.node_id },
|
||||
),
|
||||
])
|
||||
} else {
|
||||
await github.rest.pulls.createReview({
|
||||
...context.repo,
|
||||
pull_number,
|
||||
event,
|
||||
body,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
dismissReviews,
|
||||
postReview,
|
||||
}
|
||||
@@ -1,272 +0,0 @@
|
||||
const eventToState = {
|
||||
COMMENT: 'COMMENTED',
|
||||
REQUEST_CHANGES: 'CHANGES_REQUESTED',
|
||||
}
|
||||
|
||||
// Use substring checks in order to allow testing in forks
|
||||
// Usernames must also end in "[bot]"
|
||||
const reviewUsers = [
|
||||
'github-actions',
|
||||
'nixpkgs-ci',
|
||||
'branch-check',
|
||||
'commit-check',
|
||||
'manual-edit',
|
||||
]
|
||||
|
||||
type GitHub = InstanceType<typeof import('@actions/github/lib/utils').GitHub>
|
||||
type Context = typeof import('@actions/github').context
|
||||
type Review = Awaited<
|
||||
ReturnType<GitHub['rest']['pulls']['listReviews']>
|
||||
>['data'][number]
|
||||
type ReviewWithNonNullUser = Review & { user: NonNullable<Review['user']> }
|
||||
|
||||
interface DismissReviewsProps {
|
||||
github: GitHub
|
||||
context: Context
|
||||
core: typeof import('@actions/core')
|
||||
dry: boolean
|
||||
reviewKey?: string
|
||||
}
|
||||
|
||||
export async function dismissReviews({
|
||||
github,
|
||||
context,
|
||||
core,
|
||||
dry,
|
||||
reviewKey,
|
||||
}: DismissReviewsProps) {
|
||||
const pull_number = context.payload.pull_request?.number
|
||||
if (!pull_number) {
|
||||
core.warning('dismissReviews called outside of pull_request context')
|
||||
return
|
||||
}
|
||||
|
||||
if (dry) {
|
||||
return
|
||||
}
|
||||
|
||||
const allReviews: Review[] = await github.paginate(
|
||||
github.rest.pulls.listReviews,
|
||||
{
|
||||
...context.repo,
|
||||
pull_number,
|
||||
},
|
||||
)
|
||||
|
||||
const reviews = allReviews
|
||||
.filter((review): review is ReviewWithNonNullUser => !!review.user)
|
||||
.filter(
|
||||
(review) =>
|
||||
review.state !== 'DISMISSED' &&
|
||||
review.user.login.endsWith('[bot]') &&
|
||||
reviewUsers.some((substr) => review.user?.login.includes(substr)),
|
||||
)
|
||||
|
||||
const reviewsByUser = reviews.reduce(
|
||||
(prev, curr) => {
|
||||
if (!curr.user) {
|
||||
return prev
|
||||
}
|
||||
|
||||
if (!(curr.user.login in prev)) {
|
||||
prev[curr.user.login] = []
|
||||
}
|
||||
|
||||
prev[curr.user.login].push(curr)
|
||||
|
||||
return prev
|
||||
},
|
||||
{} as Record<string, ReviewWithNonNullUser[]>,
|
||||
)
|
||||
|
||||
const commentRegex = new RegExp(
|
||||
/<!-- nixpkgs review key: (.*)(?:; resolved: .*)? -->/,
|
||||
)
|
||||
const reviewKeyRegex = new RegExp(
|
||||
`<!-- (nixpkgs review key: ${reviewKey})(?:; resolved: .*)? -->`,
|
||||
)
|
||||
const commentResolvedRegex = new RegExp(
|
||||
/<!-- nixpkgs review key: .*; resolved: true -->/,
|
||||
)
|
||||
|
||||
let reviewsToMinimize = reviews
|
||||
const reviewsToDismiss: ReviewWithNonNullUser[] = []
|
||||
const reviewsToResolve: ReviewWithNonNullUser[] = []
|
||||
|
||||
if (reviewKey && reviews.every((review) => commentRegex.test(review.body))) {
|
||||
reviewsToMinimize = reviews.filter((review) =>
|
||||
reviewKeyRegex.test(review.body),
|
||||
)
|
||||
}
|
||||
|
||||
for (const reviewsForUser of Object.values(reviewsByUser)) {
|
||||
// Make sure that we don't dismiss all reviews by a user if they
|
||||
// have any reviews we don't want to dismiss.
|
||||
if (
|
||||
reviewsForUser.every(
|
||||
(review) =>
|
||||
commentResolvedRegex.test(review.body) ||
|
||||
(reviewKey && reviewKeyRegex.test(review.body)) ||
|
||||
// If we are called by check-commits and the review body is clearly
|
||||
// from `commits.ts`, then we can safely dismiss the review.
|
||||
// This helps with pre-existing reviews (before the comments were added).
|
||||
(reviewKey &&
|
||||
reviewKey === 'check-commits' &&
|
||||
review.body.includes('PR / Check / cherry-pick')),
|
||||
)
|
||||
) {
|
||||
reviewsToDismiss.push(
|
||||
...reviewsForUser.filter(
|
||||
(review) => review.state === 'CHANGES_REQUESTED',
|
||||
),
|
||||
)
|
||||
} else {
|
||||
reviewsToResolve.push(
|
||||
...reviewsForUser.filter(
|
||||
(review) =>
|
||||
review.state === 'CHANGES_REQUESTED' &&
|
||||
!commentResolvedRegex.test(review.body) &&
|
||||
reviewsToMinimize.some(
|
||||
(toMinimize) => toMinimize.node_id === review.node_id,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
...reviewsToMinimize.map(async (review) =>
|
||||
github.graphql(
|
||||
`mutation($node_id:ID!) {
|
||||
minimizeComment(input: {
|
||||
classifier: OUTDATED,
|
||||
subjectId: $node_id
|
||||
})
|
||||
{ clientMutationId }
|
||||
}`,
|
||||
{ node_id: review.node_id },
|
||||
),
|
||||
),
|
||||
...reviewsToDismiss.map(async (review) =>
|
||||
github.rest.pulls.dismissReview({
|
||||
...context.repo,
|
||||
pull_number,
|
||||
review_id: review.id,
|
||||
message: 'Review dismissed automatically',
|
||||
}),
|
||||
),
|
||||
...reviewsToResolve.map(async (review) =>
|
||||
github.rest.pulls.updateReview({
|
||||
...context.repo,
|
||||
pull_number,
|
||||
review_id: review.id,
|
||||
body: review.body.replace(
|
||||
reviewKeyRegex,
|
||||
`<!-- nixpkgs review key: ${reviewKey}; resolved: true -->`,
|
||||
),
|
||||
}),
|
||||
),
|
||||
])
|
||||
}
|
||||
|
||||
interface PostReviewProps {
|
||||
github: GitHub
|
||||
context: Context
|
||||
core: typeof import('@actions/core')
|
||||
dry: boolean
|
||||
body: string
|
||||
event?: keyof typeof eventToState
|
||||
reviewKey: string
|
||||
}
|
||||
|
||||
export async function postReview({
|
||||
github,
|
||||
context,
|
||||
core,
|
||||
dry,
|
||||
body,
|
||||
event = 'REQUEST_CHANGES',
|
||||
reviewKey,
|
||||
}: PostReviewProps) {
|
||||
const pull_number = context.payload.pull_request?.number
|
||||
if (!pull_number) {
|
||||
core.warning('postReview called outside of pull_request context')
|
||||
return
|
||||
}
|
||||
|
||||
const reviewKeyRegex = new RegExp(
|
||||
`<!-- (nixpkgs review key: ${reviewKey})(?:; resolved: .*)? -->`,
|
||||
)
|
||||
const reviewKeyComment = `<!-- nixpkgs review key: ${reviewKey}; resolved: false -->`
|
||||
body = body + '\n\n' + reviewKeyComment
|
||||
|
||||
const reviews = (
|
||||
await github.paginate(github.rest.pulls.listReviews, {
|
||||
...context.repo,
|
||||
pull_number,
|
||||
})
|
||||
).filter(
|
||||
(review) =>
|
||||
review.user &&
|
||||
review.state !== 'DISMISSED' &&
|
||||
review.user.login.endsWith('[bot]') &&
|
||||
reviewUsers.some((substr) => review.user?.login.includes(substr)),
|
||||
)
|
||||
|
||||
let pendingReview: null | Review
|
||||
const matchingReviews = reviews.filter((review) =>
|
||||
reviewKeyRegex.test(review.body),
|
||||
)
|
||||
|
||||
if (matchingReviews.length === 0) {
|
||||
pendingReview = null
|
||||
} else if (
|
||||
matchingReviews.length === 1 &&
|
||||
matchingReviews[0].state === eventToState[event]
|
||||
) {
|
||||
pendingReview = matchingReviews[0]
|
||||
} else {
|
||||
await dismissReviews({
|
||||
github,
|
||||
context,
|
||||
core,
|
||||
dry,
|
||||
reviewKey,
|
||||
})
|
||||
pendingReview = null
|
||||
}
|
||||
|
||||
if (dry) {
|
||||
if (pendingReview)
|
||||
core.info(`pending review found: ${pendingReview.html_url}`)
|
||||
else core.info('no pending review found')
|
||||
core.info(body)
|
||||
} else {
|
||||
if (pendingReview) {
|
||||
await Promise.all([
|
||||
github.rest.pulls.updateReview({
|
||||
...context.repo,
|
||||
pull_number,
|
||||
review_id: pendingReview.id,
|
||||
body,
|
||||
}),
|
||||
github.graphql(
|
||||
`mutation($node_id:ID!) {
|
||||
unminimizeComment(input: {
|
||||
subjectId: $node_id
|
||||
})
|
||||
{ clientMutationId }
|
||||
}`,
|
||||
{ node_id: pendingReview.node_id },
|
||||
),
|
||||
])
|
||||
} else {
|
||||
await github.rest.pulls.createReview({
|
||||
...context.repo,
|
||||
pull_number,
|
||||
event,
|
||||
body,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/usr/bin/env node
|
||||
#!/usr/bin/env -S node --import ./run
|
||||
import { execSync } from 'node:child_process'
|
||||
import { closeSync, mkdtempSync, openSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
@@ -48,7 +48,7 @@ program
|
||||
.argument('<pr>', 'Number of the Pull Request to check')
|
||||
.option('--no-dry', 'Make actual modifications')
|
||||
.action(async (owner, repo, pr, options) => {
|
||||
const prepare = (await import('./prepare.ts')).default
|
||||
const prepare = (await import('./prepare.js')).default
|
||||
await run(prepare, owner, repo, pr, options)
|
||||
})
|
||||
|
||||
@@ -60,7 +60,7 @@ program
|
||||
.argument('<pr>', 'Number of the Pull Request to check')
|
||||
.option('--no-cherry-picks', 'Do not expect cherry-picks.')
|
||||
.action(async (owner, repo, pr, options) => {
|
||||
const commits = (await import('./commits.ts')).default
|
||||
const commits = (await import('./commits.js')).default
|
||||
await run(commits, owner, repo, pr, options)
|
||||
})
|
||||
|
||||
@@ -72,7 +72,7 @@ program
|
||||
.argument('[pr]', 'Number of the Pull Request to label')
|
||||
.option('--no-dry', 'Make actual modifications')
|
||||
.action(async (owner, repo, pr, options) => {
|
||||
const bot = (await import('./bot.ts')).default
|
||||
const bot = (await import('./bot.js')).default
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'github-script-'))
|
||||
try {
|
||||
process.env.GITHUB_WORKSPACE = tmp
|
||||
@@ -90,7 +90,7 @@ program
|
||||
.argument('<repo>', 'Name of the GitHub repository to label (Example: nixpkgs)')
|
||||
.argument('[outFile]', 'Path to the output file (Example: github-teams.json). If not set, prints to stdout')
|
||||
.action(async (owner, repo, outFile, options) => {
|
||||
const getTeams = (await import('./get-teams.ts')).default
|
||||
const getTeams = (await import('./get-teams.js')).default
|
||||
await run(getTeams, owner, repo, undefined, { ...options, outFile })
|
||||
})
|
||||
|
||||
@@ -101,7 +101,7 @@ program
|
||||
.argument('<repo>', 'Name of the GitHub repository to run on (Example: nixpkgs)')
|
||||
.argument('<pr>', 'Number of the Pull Request to run on')
|
||||
.action(async (owner, repo, pr, options) => {
|
||||
const checkCommitMessages = (await import('./lint-commits.ts')).default
|
||||
const checkCommitMessages = (await import('./lint-commits.js')).default
|
||||
await run(checkCommitMessages, owner, repo, pr, options)
|
||||
})
|
||||
|
||||
@@ -112,8 +112,8 @@ program
|
||||
.argument('<repo>', 'Name of the GitHub repository to run on (Example: nixpkgs)')
|
||||
.argument('<pr>', 'Number of the Pull Request to run on')
|
||||
.action(async (owner, repo, pr, options) => {
|
||||
const checkTargetBranch = (await import('./check-target-branch.ts')).checkTargetBranch
|
||||
await run(checkTargetBranch, owner, repo, pr, options)
|
||||
const checkCommitMessages = (await import('./check-target-branch.js')).default
|
||||
await run(checkCommitMessages, owner, repo, pr, options)
|
||||
})
|
||||
|
||||
program
|
||||
@@ -123,19 +123,8 @@ program
|
||||
.argument('<repo>', 'Name of the GitHub repository to run on (Example: nixpkgs)')
|
||||
.argument('<pr>', 'Number of the Pull Request to run on')
|
||||
.action(async (owner, repo, pr, options) => {
|
||||
const checkManualFileEdits = (await import('./manual-file-edits.ts')).default
|
||||
const checkManualFileEdits = (await import('./manual-file-edits.js')).default
|
||||
await run(checkManualFileEdits, owner, repo, pr, options)
|
||||
})
|
||||
|
||||
program
|
||||
.command('reminders')
|
||||
.description('Post reminders for the paths a PR touches')
|
||||
.argument('<owner>', 'Owner of the GitHub repository to run on (Example: NixOS)')
|
||||
.argument('<repo>', 'Name of the GitHub repository to run on (Example: nixpkgs)')
|
||||
.argument('<pr>', 'Number of the Pull Request to run on')
|
||||
.action(async (owner, repo, pr, options) => {
|
||||
const postReminders = (await import('./reminders.ts')).default
|
||||
await run(postReminders, owner, repo, pr, options)
|
||||
})
|
||||
|
||||
await program.parse()
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
#!/usr/bin/env nix-shell
|
||||
/*
|
||||
#!nix-shell -i node -p nodejs
|
||||
*/
|
||||
import { resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
type BranchType = 'channel' | 'development' | 'primary' | 'secondary' | 'wip'
|
||||
|
||||
const typeConfig: Record<string, BranchType[]> = {
|
||||
master: ['development', 'primary'],
|
||||
release: ['development', 'primary'],
|
||||
staging: ['development', 'secondary'],
|
||||
'staging-next': ['development', 'secondary'],
|
||||
'staging-nixos': ['development', 'secondary'],
|
||||
'haskell-updates': ['development', 'secondary'],
|
||||
nixos: ['channel'],
|
||||
nixpkgs: ['channel'],
|
||||
}
|
||||
|
||||
// "order" ranks the development branches by how likely they are the intended base branch
|
||||
// when they are an otherwise equally good fit according to ci/github-script/prepare.ts.
|
||||
const orderConfig: Record<string, number> = {
|
||||
master: 0,
|
||||
release: 1,
|
||||
staging: 2,
|
||||
'staging-nixos': 2,
|
||||
'haskell-updates': 3,
|
||||
'staging-next': 4,
|
||||
}
|
||||
|
||||
type Digit = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9
|
||||
type Version = `${Digit}${Digit}.${Digit}${Digit}` | 'unstable'
|
||||
interface SplitResult {
|
||||
prefix: string
|
||||
version: Version
|
||||
suffix?: string
|
||||
}
|
||||
|
||||
function split(branch: string) {
|
||||
const groups = branch.match(
|
||||
/(?<prefix>.+?)(-(?<version>\d{2}\.\d{2}|unstable)(?:-(?<suffix>.*))?)?$/,
|
||||
)!.groups!
|
||||
return groups as unknown as SplitResult
|
||||
}
|
||||
|
||||
export interface BranchClassification {
|
||||
branch: string
|
||||
order: number
|
||||
stable: boolean
|
||||
type: BranchType[]
|
||||
version: Version
|
||||
}
|
||||
|
||||
function classify(branch: string): BranchClassification {
|
||||
const { prefix, version } = split(branch)
|
||||
return {
|
||||
branch,
|
||||
order: orderConfig[prefix] ?? Infinity,
|
||||
stable: (version ?? 'unstable') !== 'unstable',
|
||||
type: typeConfig[prefix] ?? ['wip'],
|
||||
version: version ?? 'unstable',
|
||||
}
|
||||
}
|
||||
|
||||
export { classify, split }
|
||||
|
||||
// If called directly via CLI, runs the following tests:
|
||||
if (
|
||||
process.argv[1] &&
|
||||
fileURLToPath(import.meta.url) === resolve(process.argv[1])
|
||||
) {
|
||||
console.log('split(branch)')
|
||||
function testSplit(branch: string) {
|
||||
console.log(branch, split(branch))
|
||||
}
|
||||
testSplit('master')
|
||||
testSplit('release-25.05')
|
||||
testSplit('staging')
|
||||
testSplit('staging-next')
|
||||
testSplit('staging-25.05')
|
||||
testSplit('staging-next-25.05')
|
||||
testSplit('nixpkgs-25.05-darwin')
|
||||
testSplit('nixpkgs-unstable')
|
||||
testSplit('haskell-updates')
|
||||
testSplit('backport-123-to-release-25.05')
|
||||
|
||||
console.log('')
|
||||
|
||||
console.log('classify(branch)')
|
||||
function testClassify(branch: string) {
|
||||
console.log(branch, classify(branch))
|
||||
}
|
||||
testClassify('master')
|
||||
testClassify('release-25.05')
|
||||
testClassify('staging')
|
||||
testClassify('staging-next')
|
||||
testClassify('staging-25.05')
|
||||
testClassify('staging-next-25.05')
|
||||
testClassify('nixpkgs-25.05-darwin')
|
||||
testClassify('nixpkgs-unstable')
|
||||
testClassify('haskell-updates')
|
||||
testClassify('backport-123-to-release-25.05')
|
||||
}
|
||||
10
ci/github-script/supportedSystems.js
Normal file
10
ci/github-script/supportedSystems.js
Normal file
@@ -0,0 +1,10 @@
|
||||
module.exports = async ({ github, context, targetSha }) => {
|
||||
const { content, encoding } = (
|
||||
await github.rest.repos.getContent({
|
||||
...context.repo,
|
||||
path: 'pkgs/top-level/release-supported-systems.json',
|
||||
ref: targetSha,
|
||||
})
|
||||
).data
|
||||
return JSON.parse(Buffer.from(content, encoding).toString())
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
interface SupportedSystemsProps {
|
||||
github: InstanceType<typeof import('@actions/github/lib/utils').GitHub>
|
||||
context: typeof import('@actions/github').context
|
||||
targetSha: string
|
||||
}
|
||||
|
||||
export default async ({
|
||||
github,
|
||||
context,
|
||||
targetSha,
|
||||
}: SupportedSystemsProps) => {
|
||||
const contentObject = (
|
||||
await github.rest.repos.getContent({
|
||||
...context.repo,
|
||||
path: 'pkgs/top-level/release-supported-systems.json',
|
||||
ref: targetSha,
|
||||
})
|
||||
).data
|
||||
|
||||
if ('type' in contentObject && contentObject.type === 'file') {
|
||||
const { content, encoding } = contentObject
|
||||
return JSON.parse(
|
||||
Buffer.from(content, encoding as BufferEncoding).toString(),
|
||||
)
|
||||
} else {
|
||||
throw new Error(
|
||||
'Fetched pkgs/top-level/release-supported-systems.json is not a file',
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"lib": [
|
||||
"es2024",
|
||||
"ESNext.Array",
|
||||
"ESNext.Collection",
|
||||
"ESNext.Error",
|
||||
"ESNext.Iterator",
|
||||
"ESNext.Promise"
|
||||
],
|
||||
"module": "nodenext",
|
||||
"target": "es2024",
|
||||
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "nodenext",
|
||||
|
||||
"allowImportingTsExtensions": true,
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noEmit": true,
|
||||
}
|
||||
}
|
||||
63
ci/github-script/withRateLimit.js
Normal file
63
ci/github-script/withRateLimit.js
Normal file
@@ -0,0 +1,63 @@
|
||||
module.exports = async ({ github, core, maxConcurrent = 1 }, 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,
|
||||
// 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 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.
|
||||
// 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.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
import Bottleneck from 'bottleneck'
|
||||
|
||||
type GitHub = InstanceType<typeof import('@actions/github/lib/utils').GitHub>
|
||||
type Core = typeof import('@actions/core')
|
||||
|
||||
export interface Stats {
|
||||
issues: number
|
||||
prs: number
|
||||
requests: number
|
||||
artifacts: number
|
||||
}
|
||||
|
||||
export default async (
|
||||
{
|
||||
github,
|
||||
core,
|
||||
maxConcurrent = 1,
|
||||
}: {
|
||||
github: GitHub
|
||||
core: Core
|
||||
maxConcurrent?: number
|
||||
},
|
||||
callback: (stats: Stats) => Promise<void>,
|
||||
) => {
|
||||
const stats: 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,
|
||||
// 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 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.
|
||||
// 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(async () => request(options))
|
||||
else return allLimits.schedule(async () => request(options))
|
||||
})
|
||||
|
||||
async function updateReservoir() {
|
||||
let response: Awaited<ReturnType<typeof github.rest.rateLimit.get>>
|
||||
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.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,6 @@ runCommand "nixpkgs-vet"
|
||||
env.NIXPKGS_VET_NIX_PACKAGE = nix;
|
||||
}
|
||||
''
|
||||
export NIX_STORE_DIR=$(mktemp -d)
|
||||
export NIX_STATE_DIR=$(mktemp -d)
|
||||
$NIXPKGS_VET_NIX_PACKAGE/bin/nix-store --init
|
||||
|
||||
|
||||
@@ -9,22 +9,9 @@
|
||||
},
|
||||
"branch": "nixpkgs-unstable",
|
||||
"submodules": false,
|
||||
"revision": "7d5589bbf421c7b6f4185371abe3c465b1b557e9",
|
||||
"url": "https://github.com/NixOS/nixpkgs/archive/7d5589bbf421c7b6f4185371abe3c465b1b557e9.tar.gz",
|
||||
"hash": "sha256-8emM5Z42GzMSLLvjJt7UkX1j2k2TKXQIS7FnPTfeHno="
|
||||
},
|
||||
"nixpkgs-26.05-darwin": {
|
||||
"type": "Git",
|
||||
"repository": {
|
||||
"type": "GitHub",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs"
|
||||
},
|
||||
"branch": "nixpkgs-26.05-darwin",
|
||||
"submodules": false,
|
||||
"revision": "7486293a941f7b0ec123f0c431517027f705f60f",
|
||||
"url": "https://github.com/NixOS/nixpkgs/archive/7486293a941f7b0ec123f0c431517027f705f60f.tar.gz",
|
||||
"hash": "sha256-WQhNWIW3PPijuZexyl5Zf0tJuQ7sKt5C9WPXVO4pMuM="
|
||||
"revision": "421eebfd0ec7bccd4abe826ce62d7e6e83129493",
|
||||
"url": "https://github.com/NixOS/nixpkgs/archive/421eebfd0ec7bccd4abe826ce62d7e6e83129493.tar.gz",
|
||||
"hash": "sha256:1lxfhfgiv1sz2v7fg43gny57sa6wf59n98q7ldsyb2p06f4sal7w"
|
||||
}
|
||||
},
|
||||
"version": 8
|
||||
|
||||
82
ci/supportedBranches.js
Executable file
82
ci/supportedBranches.js
Executable file
@@ -0,0 +1,82 @@
|
||||
#!/usr/bin/env nix-shell
|
||||
/*
|
||||
#!nix-shell -i node -p nodejs
|
||||
*/
|
||||
|
||||
const typeConfig = {
|
||||
master: ['development', 'primary'],
|
||||
release: ['development', 'primary'],
|
||||
staging: ['development', 'secondary'],
|
||||
'staging-next': ['development', 'secondary'],
|
||||
'staging-nixos': ['development', 'secondary'],
|
||||
'haskell-updates': ['development', 'secondary'],
|
||||
nixos: ['channel'],
|
||||
nixpkgs: ['channel'],
|
||||
}
|
||||
|
||||
// "order" ranks the development branches by how likely they are the intended base branch
|
||||
// when they are an otherwise equally good fit according to ci/github-script/prepare.js.
|
||||
const orderConfig = {
|
||||
master: 0,
|
||||
release: 1,
|
||||
staging: 2,
|
||||
'staging-nixos': 2,
|
||||
'haskell-updates': 3,
|
||||
'staging-next': 4,
|
||||
}
|
||||
|
||||
function split(branch) {
|
||||
return {
|
||||
...branch.match(
|
||||
/(?<prefix>.+?)(-(?<version>\d{2}\.\d{2}|unstable)(?:-(?<suffix>.*))?)?$/,
|
||||
).groups,
|
||||
}
|
||||
}
|
||||
|
||||
function classify(branch) {
|
||||
const { prefix, version } = split(branch)
|
||||
return {
|
||||
branch,
|
||||
order: orderConfig[prefix] ?? Infinity,
|
||||
stable: (version ?? 'unstable') !== 'unstable',
|
||||
type: typeConfig[prefix] ?? ['wip'],
|
||||
version: version ?? 'unstable',
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { classify, split }
|
||||
|
||||
// If called directly via CLI, runs the following tests:
|
||||
if (!module.parent) {
|
||||
console.log('split(branch)')
|
||||
function testSplit(branch) {
|
||||
console.log(branch, split(branch))
|
||||
}
|
||||
testSplit('master')
|
||||
testSplit('release-25.05')
|
||||
testSplit('staging')
|
||||
testSplit('staging-next')
|
||||
testSplit('staging-25.05')
|
||||
testSplit('staging-next-25.05')
|
||||
testSplit('nixpkgs-25.05-darwin')
|
||||
testSplit('nixpkgs-unstable')
|
||||
testSplit('haskell-updates')
|
||||
testSplit('backport-123-to-release-25.05')
|
||||
|
||||
console.log('')
|
||||
|
||||
console.log('classify(branch)')
|
||||
function testClassify(branch) {
|
||||
console.log(branch, classify(branch))
|
||||
}
|
||||
testClassify('master')
|
||||
testClassify('release-25.05')
|
||||
testClassify('staging')
|
||||
testClassify('staging-next')
|
||||
testClassify('staging-25.05')
|
||||
testClassify('staging-next-25.05')
|
||||
testClassify('nixpkgs-25.05-darwin')
|
||||
testClassify('nixpkgs-unstable')
|
||||
testClassify('haskell-updates')
|
||||
testClassify('backport-123-to-release-25.05')
|
||||
}
|
||||
@@ -39,11 +39,6 @@
|
||||
".github/workflows/*.yml"
|
||||
".github/workflows/*.yaml"
|
||||
];
|
||||
options = [
|
||||
# Support self-repository syntax
|
||||
''-ignore=reusable workflow call "\$/.+" at "uses" is not following the format''
|
||||
''-ignore=specifying action "\$/.+" in invalid format because ref is missing.''
|
||||
];
|
||||
};
|
||||
|
||||
biome = {
|
||||
|
||||
105
doc/README.md
105
doc/README.md
@@ -1,67 +1,82 @@
|
||||
# Contributing to the Nixpkgs manual
|
||||
|
||||
This directory houses the source files for the Nixpkgs manual, including
|
||||
|
||||
- [Getting Started](./getting-started) guides
|
||||
- [Onboarding guides](./using-nixpkgs.md) for using Nixpkgs
|
||||
- [Language frameworks](./languages-frameworks) shipped with Nixpkgs.
|
||||
|
||||
There are renderings for the [rolling release](https://nixos.org/manual/nixpkgs/unstable/) and [latest stable release](https://nixos.org/manual/nixpkgs/stable/).
|
||||
This directory houses the source files for the Nixpkgs manual.
|
||||
|
||||
> [!NOTE]
|
||||
>
|
||||
> We are actively restructuring our documentation to be more beginner friendly.
|
||||
>
|
||||
|
||||
When writing new docs use **Progressive Disclosure:**
|
||||
When writing new docs use **Progressive Disclosure**
|
||||
|
||||
- Start simple, pick up beginners.
|
||||
- Use **examples** first to show how to get something done.
|
||||
- Keep **explanation** lean.
|
||||
Start simple, pick up beginners.
|
||||
Use **examples** first to show how to get something done. Keep **Explanation** lean.
|
||||
|
||||
Use our [style guide](./styleguide.md) for more in depth guidance on writing good documentation.
|
||||
Use our [styleguide](./styleguide.md) for more in depth guidance on writing good documentation.
|
||||
|
||||
Write **guides** task-first: lead with a working example, then explain in prose.
|
||||
Write **reference** as the specification of functions and attributes.
|
||||
This directory contains **guides** and **reference** documentation for Nixpkgs.
|
||||
|
||||
We are actively working to generate reference documentation from the [doc-comments](https://github.com/NixOS/rfcs/blob/master/rfcs/0145-doc-strings.md) present in code, which also lets you view it locally with the `:doc` command in `nix repl`, e.g.:
|
||||
Borrowing from [Diátaxis framework](https://diataxis.fr/) what suits our needs:
|
||||
|
||||
```
|
||||
nix-repl> :l <nixpkgs>
|
||||
nix-repl> :doc lib.mapAttrsToList
|
||||
```
|
||||
**Guides** are task-oriented. They can be tutorial-style walkthroughs or how-to sections.
|
||||
Explanations appear as prose after examples.
|
||||
|
||||
**Reference** documentation is the specification of functions and attributes.
|
||||
|
||||
We are actively working to generate **all** reference documentation from the [doc-comments](https://github.com/NixOS/rfcs/blob/master/rfcs/0145-doc-strings.md) present in code.
|
||||
This also provides the benefit of using `:doc` in the `nix repl` to view reference documentation locally on the fly.
|
||||
|
||||
See [Document structure](#document-structure) for a structural template.
|
||||
|
||||
## Building and navigating documentation locally
|
||||
Rendered documentation:
|
||||
- [Unstable (from master)](https://nixos.org/manual/nixpkgs/unstable/)
|
||||
- [Stable (from latest release)](https://nixos.org/manual/nixpkgs/stable/)
|
||||
|
||||
The Nixpkgs manual is rendered by [`nixos-render-docs`](../pkgs/by-name/ni/nixos-render-docs/).
|
||||
Its index is [`nav.json`](./nav.json).
|
||||
The rendering tool is [nixos-render-docs](../pkgs/by-name/ni/nixos-render-docs), sometimes abbreviated `nrd`.
|
||||
|
||||
## Contributing to this documentation
|
||||
|
||||
You can quickly check your edits with `nix-build`:
|
||||
|
||||
```ShellSession
|
||||
$ cd /path/to/nixpkgs
|
||||
$ nix-build doc
|
||||
```
|
||||
|
||||
If the build succeeds, the manual will be in `./result/share/doc/nixpkgs/manual.html`.
|
||||
|
||||
### Development environment
|
||||
|
||||
Consider using the tooling in the documentation development environment.
|
||||
To reduce repetition, consider using tools from the provided development environment:
|
||||
|
||||
Load it from the Nixpkgs documentation directory with
|
||||
|
||||
```ShellSession
|
||||
$ cd /path/to/nixpkgs/doc
|
||||
$ nix-shell
|
||||
```
|
||||
|
||||
### Live preview
|
||||
To load the development utilities automatically when entering that directory, [set up `nix-direnv`](https://nix.dev/guides/recipes/direnv).
|
||||
|
||||
Within the developer environment, run [`devmode`](../pkgs/by-name/de/devmode/README.md) for a live preview while editing the manual.
|
||||
If the `nixos-render-docs` source-code changes, `devmode` must be restarted.
|
||||
Make sure that your local files aren't added to Git history by adding the following lines to `.git/info/exclude` at the root of the Nixpkgs repository:
|
||||
|
||||
### Building the docs
|
||||
```
|
||||
/**/.envrc
|
||||
/**/.direnv
|
||||
```
|
||||
|
||||
To build the documentation, run `nix-build doc`.
|
||||
A successful build is stored in `./result/share/doc/nixpkgs/manual.html`.
|
||||
#### Live preview
|
||||
|
||||
Run [`devmode`](../pkgs/by-name/de/devmode/README.md) for a live preview while editing the manual: it rebuilds on every change and reloads the page in your browser automatically.
|
||||
|
||||
Changes to the renderer 'pkgs/by-name/ni/nixos-render-docs' need a manual restart. Run: `devmode` again.
|
||||
|
||||
### Testing redirects
|
||||
|
||||
Once you have a successful build, you can open the aforementioned path in a browser along with the anchor, and observe the redirection.
|
||||
Once you have a successful build, you can open the relevant HTML (path mentioned above) in a browser along with the anchor, and observe the redirection.
|
||||
|
||||
To test redirects, perform a browser refresh, as browsers do not re-run client JS code when only the anchor has changed.
|
||||
Note that if you already loaded the page and *then* input the anchor, you will need to perform a reload.
|
||||
This is because browsers do not re-run client JS code when only the anchor has changed.
|
||||
|
||||
## Syntax
|
||||
|
||||
@@ -123,12 +138,14 @@ A few markups for other kinds of literals are also available:
|
||||
- `` {env}`XDG_DATA_DIRS` ``
|
||||
- `` {file}`/etc/passwd` ``
|
||||
- `` {option}`networking.useDHCP` ``
|
||||
- `` {var}`pkgs` ``
|
||||
|
||||
The values will be formatted as inline `<code>` elements.
|
||||
- `` {var}`/etc/passwd` ``
|
||||
|
||||
These literal kinds are used mostly in NixOS option documentation.
|
||||
|
||||
This syntax is taken from [MyST](https://myst-parser.readthedocs.io/en/latest/syntax/syntax.html#roles-an-in-line-extension-point).
|
||||
Though, the feature originates from [reStructuredText](https://www.sphinx-doc.org/en/master/usage/restructuredtext/roles.html#role-manpage) with slightly different syntax.
|
||||
They are handled by `myst_role` defined per renderer. <!-- reverse references in code -->
|
||||
|
||||
#### Admonitions
|
||||
|
||||
Set off from the text to bring attention to something.
|
||||
@@ -151,7 +168,7 @@ The following are supported:
|
||||
- `example`
|
||||
|
||||
Example admonitions require a title to work.
|
||||
If you don't provide one, the manual won't build.
|
||||
If you don't provide one, the manual won't be built.
|
||||
|
||||
```markdown
|
||||
::: {.example #ex-showing-an-example}
|
||||
@@ -167,11 +184,11 @@ Text for the example.
|
||||
For defining a group of terms:
|
||||
|
||||
```markdown
|
||||
Pear
|
||||
: Green or yellow bulbous fruit
|
||||
pear
|
||||
: green or yellow bulbous fruit
|
||||
|
||||
Watermelon
|
||||
: Green fruit with red flesh
|
||||
watermelon
|
||||
: green fruit with red flesh
|
||||
```
|
||||
|
||||
## Commit conventions
|
||||
@@ -181,15 +198,15 @@ Watermelon
|
||||
- If creating a commit purely for documentation changes, format the commit message in the following way:
|
||||
|
||||
```
|
||||
doc/component: (documentation summary)
|
||||
doc: (documentation summary)
|
||||
|
||||
(Motivation for change, relevant links, additional information.)
|
||||
```
|
||||
|
||||
Examples:
|
||||
|
||||
* doc/stdenv: update the kernel config documentation to use `nix-shell`
|
||||
* doc/getting-started: add information about `nix-update-script`
|
||||
* doc: update the kernel config documentation to use `nix-shell`
|
||||
* doc: add information about `nix-update-script`
|
||||
|
||||
Closes #216321.
|
||||
|
||||
@@ -203,7 +220,7 @@ When needed, each convention explains why it exists, so you can make a decision
|
||||
Note that these conventions are about the **structure** of the manual (and its source files), not about the content that goes in it.
|
||||
You, as the writer of documentation, are still in charge of its content.
|
||||
|
||||
**For prose style, see the [documentation style guide](./styleguide.md).**
|
||||
**For prose style, see the [documentation styleguide](./styleguide.md).**
|
||||
|
||||
### Document structure
|
||||
|
||||
@@ -273,7 +290,7 @@ When changing existing content, update formatting if possible, but avoid excessi
|
||||
|
||||
### Examples first
|
||||
|
||||
Put examples before detailed explanations (see the [style guide](./styleguide.md) for the rationale).
|
||||
Put examples before detailed explanations (see the [styleguide](./styleguide.md) for the rationale).
|
||||
|
||||
Use this structure for each documented item:
|
||||
|
||||
|
||||
@@ -15,3 +15,15 @@ In addition, it offers various options to customize parts of the builds.
|
||||
There is no uniform interface for build helpers.
|
||||
[Trivial build helpers](#chap-trivial-builders) and [fetchers](#chap-pkgs-fetchers) have various input types for convenience.
|
||||
[Language- or framework-specific build helpers](#chap-language-support) usually follow the style of `stdenv.mkDerivation`, which accepts an attribute set or a fixed-point function taking an attribute set.
|
||||
|
||||
```{=include=} chapters
|
||||
build-helpers/fixed-point-arguments.chapter.md
|
||||
build-helpers/fetchers.chapter.md
|
||||
build-helpers/trivial-build-helpers.chapter.md
|
||||
build-helpers/testers.chapter.md
|
||||
build-helpers/dev-shell-tools.chapter.md
|
||||
build-helpers/special.md
|
||||
build-helpers/images.md
|
||||
hooks/index.md
|
||||
packages/index.md
|
||||
```
|
||||
|
||||
@@ -853,7 +853,7 @@ Used with CVS. Expects `cvsRoot`, `tag`, and `hash`.
|
||||
|
||||
Used with Mercurial. Expects `url`, `rev`, `hash`, overridable with [`<pkg>.overrideAttrs`](#sec-pkg-overrideAttrs).
|
||||
|
||||
A number of fetcher functions wrap lower-level fetchers such as `fetchurl`, `fetchzip`, and `fetchgit`. They are mainly convenience functions intended for commonly used destinations of source code in Nixpkgs. These wrapper fetchers are listed below.
|
||||
A number of fetcher functions wrap part of `fetchurl` and `fetchzip`. They are mainly convenience functions intended for commonly used destinations of source code in Nixpkgs. These wrapper fetchers are listed below.
|
||||
|
||||
## `fetchFromGitea`, `fetchFromForgejo` and `fetchFromCodeberg` {#fetchfromgitea}
|
||||
|
||||
@@ -876,45 +876,6 @@ However, `fetchFromGitHub` will automatically switch to using `fetchgit` in any
|
||||
|
||||
When `fetchgit` is used, refer to the `fetchgit` section for documentation of its available options.
|
||||
|
||||
## `fetchFromHuggingFace` {#fetchfromhuggingface}
|
||||
|
||||
`fetchFromHuggingFace` fetches repositories from Hugging Face Hub. It expects
|
||||
`repoId`, exactly one of `rev` or `tag`, and `hash`.
|
||||
|
||||
`repoId` must be in the form `repo` or `owner/repo`, so repositories such as
|
||||
`gpt2` work as well.
|
||||
|
||||
::: {.example #ex-fetchfromhuggingface}
|
||||
|
||||
# Fetching a model repository from Hugging Face
|
||||
|
||||
```nix
|
||||
fetchFromHuggingFace {
|
||||
repoId = "hf-internal-testing/tiny-random-gpt2";
|
||||
rev = "71034c5d8bde858ff824298bdedc65515b97d2b9";
|
||||
backend = "lfs";
|
||||
hash = "sha256-8K9B/C62GW5lXC0c8QQpQ9QAE1UMoG+kYqvGhnWIp64=";
|
||||
}
|
||||
```
|
||||
|
||||
:::
|
||||
|
||||
The optional `repoType` argument selects which Hugging Face Hub repository type
|
||||
to use:
|
||||
|
||||
- `"model"` (default) fetches from `https://huggingface.co/<repo-id>`
|
||||
- `"dataset"` fetches from `https://huggingface.co/datasets/<repo-id>`
|
||||
- `"space"` fetches from `https://huggingface.co/spaces/<repo-id>`
|
||||
|
||||
To use a different Hugging Face Hub instance, use `domain`
|
||||
(defaults to `"huggingface.co"`).
|
||||
|
||||
The optional `backend` argument defaults to `"xet"`. Because the Xet backend is
|
||||
not implemented yet, callers must currently set `backend = "lfs"`, which uses
|
||||
`fetchgit` with Git LFS enabled and defaults `fetchSubmodules` to `false`.
|
||||
`rootDir`, `sparseCheckout`, and low-level `fetchgit` options such as
|
||||
`deepClone`, `fetchTags`, `leaveDotGit`, and `branchName` are also supported.
|
||||
|
||||
## `fetchFromGitLab` {#fetchfromgitlab}
|
||||
|
||||
This is used with GitLab repositories. It behaves similarly to `fetchFromGitHub`, and expects `owner`, `repo`, `rev`, and `hash`.
|
||||
@@ -988,20 +949,6 @@ fetchRadiclePatch {
|
||||
}
|
||||
```
|
||||
|
||||
## `fetchFromTangled` {#fetchfromtangled}
|
||||
|
||||
This is to be used with tangled repositories. `fetchFromTangled` works with
|
||||
very similar arguments to `fetchFromGithub`. However, instead of a `owner` and
|
||||
`repo`, a `did` argument is expected.
|
||||
|
||||
```nix
|
||||
fetchFromTangled {
|
||||
did = "did:plc:jj6ajj6duxnlthwtnob4qyuv"; # tranquil.farm/tranquil-pds
|
||||
tag = "v6.6.0";
|
||||
hash = "sha256-cfTsjmK/IMqT5kMKOGpwwWbBlvtrCDOerUJJ8AVI3kY=";
|
||||
}
|
||||
```
|
||||
|
||||
## `requireFile` {#requirefile}
|
||||
|
||||
`requireFile` allows requesting files that cannot be fetched automatically, but whose content is known.
|
||||
|
||||
@@ -1,3 +1,12 @@
|
||||
# Images {#chap-images}
|
||||
|
||||
This chapter describes tools for creating various types of images.
|
||||
|
||||
```{=include=} sections
|
||||
images/appimagetools.section.md
|
||||
images/dockertools.section.md
|
||||
images/ocitools.section.md
|
||||
images/portableservice.section.md
|
||||
images/makediskimage.section.md
|
||||
images/binarycache.section.md
|
||||
```
|
||||
|
||||
@@ -28,7 +28,7 @@ However, [those were unified early 2020](https://github.com/NixOS/nixpkgs/pull/8
|
||||
|
||||
```nix
|
||||
{ appimageTools, fetchurl }:
|
||||
appimageTools.wrapType2 {
|
||||
let
|
||||
pname = "nuclear";
|
||||
version = "0.6.30";
|
||||
|
||||
@@ -36,12 +36,13 @@ appimageTools.wrapType2 {
|
||||
url = "https://github.com/nukeop/nuclear/releases/download/v${version}/nuclear-v${version}.AppImage";
|
||||
hash = "sha256-he1uGC1M/nFcKpMM9JKY4oeexJcnzV0ZRxhTjtJz6xw=";
|
||||
};
|
||||
}
|
||||
in
|
||||
appimageTools.wrapType2 { inherit pname version src; }
|
||||
```
|
||||
|
||||
:::
|
||||
|
||||
The argument passed to `wrapType2` can also contain an `extraPkgs` attribute, which includes additional packages inside the FHS environment your AppImage is going to run in.
|
||||
The argument passed to `wrapType2` can also contain an `extraPkgs` attribute, which allows you to include additional packages inside the FHS environment your AppImage is going to run in.
|
||||
`extraPkgs` must be a function that returns a list of packages.
|
||||
There are a few ways to learn which dependencies an application needs:
|
||||
|
||||
@@ -55,7 +56,7 @@ There are a few ways to learn which dependencies an application needs:
|
||||
|
||||
```nix
|
||||
{ appimageTools, fetchurl }:
|
||||
appimageTools.wrapType2 {
|
||||
let
|
||||
pname = "irccloud";
|
||||
version = "0.16.0";
|
||||
|
||||
@@ -63,7 +64,9 @@ appimageTools.wrapType2 {
|
||||
url = "https://github.com/irccloud/irccloud-desktop/releases/download/v${version}/IRCCloud-${version}-linux-x86_64.AppImage";
|
||||
hash = "sha256-/hMPvYdnVB1XjKgU2v47HnVvW4+uC3rhRjbucqin4iI=";
|
||||
};
|
||||
|
||||
in
|
||||
appimageTools.wrapType2 {
|
||||
inherit pname version src;
|
||||
extraPkgs = pkgs: [ pkgs.at-spi2-core ];
|
||||
}
|
||||
```
|
||||
@@ -85,12 +88,12 @@ However, [those were unified early 2020](https://github.com/NixOS/nixpkgs/pull/8
|
||||
|
||||
# Extracting an AppImage to install extra files
|
||||
|
||||
`wrapType2` automatically extracts the AppImage for you and makes it available via the `contents` attribute.
|
||||
Note how `finalAttrs.contents` is used in `extraInstallCommands` to install additional files that were extracted from the AppImage.
|
||||
This example was adapted from a real package in Nixpkgs to show how `extract` is usually used in combination with `wrapType2`.
|
||||
Note how `appimageContents` is used in `extraInstallCommands` to install additional files that were extracted from the AppImage.
|
||||
|
||||
```nix
|
||||
{ appimageTools, fetchurl }:
|
||||
appimageTools.wrapType2 (finalAttrs: {
|
||||
let
|
||||
pname = "irccloud";
|
||||
version = "0.16.0";
|
||||
|
||||
@@ -99,24 +102,27 @@ appimageTools.wrapType2 (finalAttrs: {
|
||||
hash = "sha256-/hMPvYdnVB1XjKgU2v47HnVvW4+uC3rhRjbucqin4iI=";
|
||||
};
|
||||
|
||||
appimageContents = appimageTools.extract { inherit pname version src; };
|
||||
in
|
||||
appimageTools.wrapType2 {
|
||||
inherit pname version src;
|
||||
|
||||
extraPkgs = pkgs: [ pkgs.at-spi2-core ];
|
||||
|
||||
extraInstallCommands = ''
|
||||
mv $out/bin/irccloud-${version} $out/bin/irccloud
|
||||
install -m 444 -D ${finalAttrs.contents}/irccloud.desktop $out/share/applications/irccloud.desktop
|
||||
install -m 444 -D ${finalAttrs.contents}/usr/share/icons/hicolor/512x512/apps/irccloud.png \
|
||||
install -m 444 -D ${appimageContents}/irccloud.desktop $out/share/applications/irccloud.desktop
|
||||
install -m 444 -D ${appimageContents}/usr/share/icons/hicolor/512x512/apps/irccloud.png \
|
||||
$out/share/icons/hicolor/512x512/apps/irccloud.png
|
||||
substituteInPlace $out/share/applications/irccloud.desktop \
|
||||
--replace-fail 'Exec=AppRun' 'Exec=irccloud'
|
||||
'';
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
:::
|
||||
|
||||
`appimageTools` also exposes the `extract` function should you need to do it manually, requiring `pname`, `version`, and `src` arguments (`src` being the AppImage file to extract).
|
||||
|
||||
The arguments passed to `extract` can also contain a `postExtract` attribute, which executes additional commands after the files are extracted from the AppImage.
|
||||
The argument passed to `extract` can also contain a `postExtract` attribute, which allows you to execute additional commands after the files are extracted from the AppImage.
|
||||
`postExtract` must be a string with commands to run.
|
||||
|
||||
:::{.warning}
|
||||
@@ -132,7 +138,7 @@ This is a rewrite of [](#ex-extracting-appimage) to use `postExtract` and `wrapA
|
||||
|
||||
```nix
|
||||
{ appimageTools, fetchurl }:
|
||||
appimageTools.wrapAppImage (finalAttrs: {
|
||||
let
|
||||
pname = "irccloud";
|
||||
version = "0.16.0";
|
||||
|
||||
@@ -141,22 +147,30 @@ appimageTools.wrapAppImage (finalAttrs: {
|
||||
hash = "sha256-/hMPvYdnVB1XjKgU2v47HnVvW4+uC3rhRjbucqin4iI=";
|
||||
};
|
||||
|
||||
contents = appimageTools.extract {
|
||||
inherit (finalAttrs) pname version src;
|
||||
appimageContents = appimageTools.extract {
|
||||
inherit pname version src;
|
||||
postExtract = ''
|
||||
substituteInPlace $out/irccloud.desktop --replace-fail 'Exec=AppRun' 'Exec=irccloud'
|
||||
'';
|
||||
};
|
||||
in
|
||||
appimageTools.wrapAppImage {
|
||||
inherit pname version;
|
||||
|
||||
src = appimageContents;
|
||||
|
||||
extraPkgs = pkgs: [ pkgs.at-spi2-core ];
|
||||
|
||||
extraInstallCommands = ''
|
||||
mv $out/bin/irccloud-${version} $out/bin/irccloud
|
||||
install -m 444 -D ${finalAttrs.contents}/irccloud.desktop $out/share/applications/irccloud.desktop
|
||||
install -m 444 -D ${finalAttrs.contents}/usr/share/icons/hicolor/512x512/apps/irccloud.png \
|
||||
install -m 444 -D ${appimageContents}/irccloud.desktop $out/share/applications/irccloud.desktop
|
||||
install -m 444 -D ${appimageContents}/usr/share/icons/hicolor/512x512/apps/irccloud.png \
|
||||
$out/share/icons/hicolor/512x512/apps/irccloud.png
|
||||
'';
|
||||
})
|
||||
|
||||
# specify src archive for nix-update
|
||||
passthru.src = src;
|
||||
}
|
||||
```
|
||||
|
||||
:::
|
||||
|
||||
@@ -217,7 +217,7 @@ Similarly, if you encounter errors similar to `Error_Protocol ("certificate has
|
||||
`buildArgs` (Attribute Set)
|
||||
|
||||
: The argument passed to `buildImage` itself.
|
||||
This exposes all attributes specified in the argument, as described above.
|
||||
This allows you to inspect all attributes specified in the argument, as described above.
|
||||
|
||||
`layer` (Attribute Set)
|
||||
|
||||
@@ -228,7 +228,7 @@ Similarly, if you encounter errors similar to `Error_Protocol ("certificate has
|
||||
|
||||
: The tag of the generated image.
|
||||
This is useful if no tag was specified in the attributes of the argument to `buildImage`, because an automatic tag will be used instead.
|
||||
`imageTag` contains the value of the tag used in this case.
|
||||
`imageTag` allows you to retrieve the value of the tag used in this case.
|
||||
|
||||
### Examples {#ssec-pkgs-dockerTools-buildImage-examples}
|
||||
|
||||
@@ -657,7 +657,7 @@ This allows the function to produce reproducible images.
|
||||
|
||||
: The tag of the generated image.
|
||||
This is useful if no tag was specified in the attributes of the argument to the function, because an automatic tag will be used instead.
|
||||
`imageTag` contains the value of the tag used in this case.
|
||||
`imageTag` allows you to retrieve the value of the tag used in this case.
|
||||
|
||||
### Examples {#ssec-pkgs-dockerTools-streamLayeredImage-examples}
|
||||
|
||||
|
||||
@@ -68,7 +68,7 @@ See [](#ex-portableService-hello) to understand how to use the output of `portab
|
||||
|
||||
: Allows you to override the package that provides {manpage}`mksquashfs(1)`, which is used internally by `portableService`.
|
||||
|
||||
_Default value:_ `pkgs.squashfs-tools`.
|
||||
_Default value:_ `pkgs.squashfsTools`.
|
||||
|
||||
`squash-compression` (String; _optional_)
|
||||
|
||||
|
||||
@@ -1,3 +1,13 @@
|
||||
# Special build helpers {#chap-special}
|
||||
|
||||
This chapter describes several special build helpers.
|
||||
|
||||
```{=include=} sections
|
||||
special/buildenv.section.md
|
||||
special/fakenss.section.md
|
||||
special/fhs-environments.section.md
|
||||
special/makesetuphook.section.md
|
||||
special/mkshell.section.md
|
||||
special/vm-tools.section.md
|
||||
special/checkpoint-build.section.md
|
||||
```
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# buildFHSEnv {#sec-fhs-environments}
|
||||
|
||||
`buildFHSEnv` provides a way to build and run an FHS-compatible, lightweight sandbox. It creates an isolated root filesystem with the host's `/nix/store`, so its footprint in terms of disk space is quite small. This runs software which is hard or unfeasible to patch for NixOS; 3rd-party source trees with FHS assumptions, games distributed as tarballs, software with integrity checking and/or external self-updated binaries for instance.
|
||||
`buildFHSEnv` provides a way to build and run an FHS-compatible, lightweight sandbox. It creates an isolated root filesystem with the host's `/nix/store`, so its footprint in terms of disk space is quite small. This allows you to run software which is hard or unfeasible to patch for NixOS; 3rd-party source trees with FHS assumptions, games distributed as tarballs, software with integrity checking and/or external self-updated binaries for instance.
|
||||
It uses Linux' namespaces feature to create temporary lightweight environments which are destroyed after all child processes exit, without requiring elevated privileges. It works similar to containerisation technology such as Docker or FlatPak but provides no security-relevant separation from the host system.
|
||||
|
||||
Accepted arguments are:
|
||||
|
||||
@@ -18,7 +18,7 @@ Run a derivation in a Linux virtual machine (using Qemu/KVM).
|
||||
By default, there is no disk image; the root filesystem is a `tmpfs`, and the Nix store is shared with the host (via the [9P protocol](https://wiki.qemu.org/Documentation/9p#9p_Protocol)).
|
||||
Thus, any pure Nix derivation should run unmodified.
|
||||
|
||||
If the build fails and Nix is run with the `-K/--keep-failed` option, a script `run-vm` will be left behind in the temporary build directory that boots into the VM for interactive debugging.
|
||||
If the build fails and Nix is run with the `-K/--keep-failed` option, a script `run-vm` will be left behind in the temporary build directory that allows you to boot into the VM and debug it interactively.
|
||||
|
||||
### Attributes {#vm-tools-runInLinuxVM-attributes}
|
||||
|
||||
|
||||
@@ -41,26 +41,9 @@ If the `moduleNames` argument is omitted, `hasPkgConfigModules` will use `meta.p
|
||||
## `hasCmakeConfigModules` {#tester-hasCmakeConfigModules}
|
||||
|
||||
Checks whether a package exposes a given list of `*config.cmake` modules.
|
||||
If the `moduleNames` argument is omitted, `hasCmakeConfigModules` will use `meta.cmakeConfigModules`.
|
||||
Note the moduleNames used in cmake find_package are case sensitive.
|
||||
|
||||
:::{.example #ex-hascmakeconfigmodules-defaultvalues}
|
||||
|
||||
# Check that `*config.cmake` modules are exposed using default values
|
||||
|
||||
```nix
|
||||
{
|
||||
passthru.tests.cmake-config = testers.hasCmakeConfigModules { package = finalAttrs.finalPackage; };
|
||||
|
||||
meta.cmakeConfigModules = [ "Foo" ];
|
||||
}
|
||||
```
|
||||
|
||||
:::
|
||||
|
||||
<!-- Old anchor name so links still work -->
|
||||
[]{#ex-hascmakeconfigmodules}
|
||||
:::{.example #ex-hascmakeconfigmodules-explicitmodules}
|
||||
:::{.example #ex-hascmakeconfigmodules}
|
||||
|
||||
# Check that `*config.cmake` modules are exposed using explicit module names
|
||||
|
||||
|
||||
@@ -3,35 +3,6 @@
|
||||
Nixpkgs provides a variety of wrapper functions that help build commonly useful derivations.
|
||||
Like [`stdenv.mkDerivation`](#sec-using-stdenv), each of these build helpers creates a derivation, but the arguments passed are different (usually simpler) from those required by `stdenv.mkDerivation`.
|
||||
|
||||
## Arguments with finalAttrs {#trivial-builder-finalAttrs}
|
||||
|
||||
In parameters that reference this section, you may either pass the value itself,
|
||||
or a function that produces it.
|
||||
When it's a function the argument value is [`finalAttrs`] from [`mkDerivation`].
|
||||
|
||||
Typically both the *attributes* and *script* arguments support this, simultaneously if needed.
|
||||
|
||||
::: {.example #ex-trivial-builder-finalAttrs}
|
||||
# Using `finalAttrs` in a build helper
|
||||
|
||||
```nix
|
||||
runCommand "hi" (finalAttrs: { passthru.exe = "${finalAttrs.finalPackage}/bin/hi"; }) ''
|
||||
mkdir -p $out/bin
|
||||
substitute ${./hi.foo} $out/bin/hi --replace-fail "@foo@" ${lib.getExe foo}
|
||||
''
|
||||
```
|
||||
|
||||
This creates a package with an executable script that's in the standard `bin/` directory,
|
||||
but also convenient to interpolate without reliance on `$PATH`, e.g assuming the result of the above is in binding `hi`:
|
||||
```nix
|
||||
''
|
||||
echo START_GREETING
|
||||
${hi.exe} --rude
|
||||
echo END_GREETING
|
||||
''
|
||||
```
|
||||
|
||||
:::
|
||||
|
||||
## `runCommandWith` {#trivial-builder-runCommandWith}
|
||||
|
||||
@@ -52,10 +23,8 @@ runCommandWith :: {
|
||||
name :: name;
|
||||
stdenv? :: Derivation;
|
||||
runLocal? :: Bool;
|
||||
derivationArgs? :: { ... } | finalAttrs@{ finalPackage :: Derivation, ... } -> { ... };
|
||||
}
|
||||
-> (String | finalAttrs@{ finalPackage :: Derivation, ... } -> String)
|
||||
-> Derivation
|
||||
derivationArgs? :: { ... };
|
||||
} -> String -> Derivation
|
||||
```
|
||||
|
||||
### Inputs {#trivial-builder-runCommandWith-Inputs}
|
||||
@@ -78,10 +47,10 @@ runCommandWith :: {
|
||||
`stdenv` (Derivation)
|
||||
: The [standard environment](#chap-stdenv) to use, defaulting to `pkgs.stdenv`.
|
||||
|
||||
`derivationArgs` (Attribute set *or* [function from `finalAttrs`](#trivial-builder-finalAttrs))
|
||||
`derivationArgs` (Attribute set)
|
||||
: Additional arguments for [`mkDerivation`](#sec-using-stdenv).
|
||||
|
||||
`buildCommand` (String *or* [function from `finalAttrs`](#trivial-builder-finalAttrs))
|
||||
`buildCommand` (String)
|
||||
: Shell commands to run in the derivation builder.
|
||||
|
||||
::: {.note}
|
||||
@@ -140,10 +109,10 @@ While the type signature(s) differ from [`runCommandWith`], individual arguments
|
||||
`name` (String)
|
||||
: The derivation's name
|
||||
|
||||
`derivationArgs` (Attribute set *or* [function from `finalAttrs`](#trivial-builder-finalAttrs))
|
||||
`derivationArgs` (Attribute set)
|
||||
: Additional parameters passed to [`mkDerivation`]
|
||||
|
||||
`buildCommand` (String *or* [function from `finalAttrs`](#trivial-builder-finalAttrs))
|
||||
`buildCommand` (String)
|
||||
: The command(s) run to build the derivation.
|
||||
|
||||
|
||||
@@ -933,6 +902,3 @@ produces an output path `/nix/store/<hash>-runtime-references` containing
|
||||
|
||||
but none of `hello`'s dependencies because those are not referenced directly
|
||||
by `hi`'s output.
|
||||
|
||||
[`finalAttrs`]: #mkderivation-recursive-attributes
|
||||
[`mkDerivation`]: #sec-using-stdenv
|
||||
|
||||
@@ -1,84 +1,10 @@
|
||||
# Contributing {#part-contributing}
|
||||
# Contributing to Nixpkgs {#part-contributing}
|
||||
|
||||
<!--
|
||||
Legacy anchors. The sections behind these ids used to live in this manual and are
|
||||
now maintained elsewhere. We keep these invisible anchor ids so external URIs would link to this page.
|
||||
-->
|
||||
<!-- moved to CONTRIBUTING.md -->
|
||||
[]{#chap-conventions}
|
||||
[]{#sec-syntax}
|
||||
[]{#sec-organisation}
|
||||
[]{#chap-submitting-changes}
|
||||
[]{#submitting-changes-submitting-changes}
|
||||
[]{#submitting-changes-pull-request-template}
|
||||
[]{#submitting-changes-tested-with-sandbox}
|
||||
[]{#submitting-changes-platform-diversity}
|
||||
[]{#submitting-changes-nixos-tests}
|
||||
[]{#submitting-changes-tested-compilation}
|
||||
[]{#submitting-changes-tested-execution}
|
||||
[]{#submitting-changes-contribution-standards}
|
||||
[]{#submitting-changes-hotfixing-pull-requests}
|
||||
[]{#submitting-changes-commit-policy}
|
||||
[]{#submitting-changes-branches}
|
||||
[]{#submitting-changes-master-branch}
|
||||
[]{#submitting-changes-staging-branch}
|
||||
[]{#submitting-changes-staging-next-branch}
|
||||
[]{#submitting-changes-stable-release-branches}
|
||||
[]{#submitting-changes-stable-release-branches-automatic-backports}
|
||||
[]{#submitting-changes-stable-release-branches-manual-backports}
|
||||
[]{#acceptable-backport-criteria}
|
||||
[]{#chap-reviewing-contributions}
|
||||
[]{#reviewing-contributions-other-submissions}
|
||||
[]{#reviewing-contributions--merging-pull-requests}
|
||||
[]{#part-development}
|
||||
|
||||
<!-- moved to pkgs/README.md -->
|
||||
[]{#chap-quick-start}
|
||||
[]{#sec-package-naming}
|
||||
[]{#sec-versioning}
|
||||
[]{#sec-sources}
|
||||
[]{#sec-source-hashes}
|
||||
[]{#sec-source-hashes-security}
|
||||
[]{#sec-patches}
|
||||
[]{#sec-package-tests}
|
||||
[]{#ssec-inline-package-tests-writing}
|
||||
[]{#ssec-package-tests-writing}
|
||||
[]{#ssec-package-tests-running}
|
||||
[]{#ssec-package-tests-examples}
|
||||
[]{#ssec-nixos-tests-linking}
|
||||
[]{#ssec-import-from-derivation}
|
||||
[]{#submitting-changes-submitting-security-fixes}
|
||||
[]{#submitting-changes-deprecating-packages}
|
||||
[]{#steps-to-remove-a-package-from-nixpkgs}
|
||||
[]{#chap-vulnerability-roundup}
|
||||
[]{#vulnerability-roundup-issues}
|
||||
[]{#vulnerability-roundup-triaging-and-fixing}
|
||||
[]{#reviewing-contributions-package-updates}
|
||||
[]{#reviewing-contributions-new-packages}
|
||||
|
||||
<!-- moved to nixos/README.md -->
|
||||
[]{#reviewing-contributions-module-updates}
|
||||
[]{#reviewing-contributions-new-modules}
|
||||
|
||||
<!-- moved to maintainers/README.md -->
|
||||
[]{#reviewing-contributions-individual-maintainer-list}
|
||||
[]{#reviewing-contributions-maintainer-teams}
|
||||
|
||||
<!-- moved to doc/README.md -->
|
||||
[]{#chap-contributing}
|
||||
|
||||
Contribution documentation lives along the repository scope it applies to:
|
||||
|
||||
- [CONTRIBUTING.md](https://github.com/NixOS/nixpkgs/blob/master/CONTRIBUTING.md): coding conventions, file organisation, submitting changes, commit policy, branches and backports, reviewing contributions.
|
||||
- [pkgs/README.md](https://github.com/NixOS/nixpkgs/blob/master/pkgs/README.md): adding and updating packages, package naming and versioning, source fetching and hashes, patches, package tests, deprecating packages, security fixes.
|
||||
- [nixos/README.md](https://github.com/NixOS/nixpkgs/blob/master/nixos/README.md): NixOS modules and reviewing module changes.
|
||||
- [maintainers/README.md](https://github.com/NixOS/nixpkgs/blob/master/maintainers/README.md): maintainer entries and maintainer teams.
|
||||
- [doc/README.md](https://github.com/NixOS/nixpkgs/blob/master/doc/README.md): writing and building this manual.
|
||||
|
||||
## Opening issues {#sec-opening-issues}
|
||||
|
||||
- Make sure you have a [GitHub account](https://github.com/signup/free)
|
||||
- Make sure there is no open issue on the topic
|
||||
- [Submit a new issue](https://github.com/NixOS/nixpkgs/issues/new/choose) by choosing the kind of topic and filling out the template
|
||||
|
||||
<!-- In the future this section could also include more detailed information on the issue templates -->
|
||||
```{=include=} chapters
|
||||
contributing/quick-start.chapter.md
|
||||
contributing/coding-conventions.chapter.md
|
||||
contributing/submitting-changes.chapter.md
|
||||
contributing/vulnerability-roundup.chapter.md
|
||||
contributing/reviewing-contributions.chapter.md
|
||||
contributing/contributing-to-documentation.chapter.md
|
||||
```
|
||||
|
||||
63
doc/contributing/coding-conventions.chapter.md
Normal file
63
doc/contributing/coding-conventions.chapter.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# Coding conventions {#chap-conventions}
|
||||
|
||||
This section has been moved to [CONTRIBUTING.md](https://github.com/NixOS/nixpkgs/blob/master/CONTRIBUTING.md).
|
||||
|
||||
## Syntax {#sec-syntax}
|
||||
|
||||
This section has been moved to [CONTRIBUTING.md](https://github.com/NixOS/nixpkgs/blob/master/CONTRIBUTING.md).
|
||||
|
||||
## Package naming {#sec-package-naming}
|
||||
|
||||
This section has been moved to [pkgs/README.md](https://github.com/NixOS/nixpkgs/blob/master/pkgs/README.md).
|
||||
|
||||
## File naming and organisation {#sec-organisation}
|
||||
|
||||
This section has been moved to [CONTRIBUTING.md](https://github.com/NixOS/nixpkgs/blob/master/CONTRIBUTING.md).
|
||||
|
||||
### Versioning {#sec-versioning}
|
||||
|
||||
This section has been moved to [pkgs/README.md](https://github.com/NixOS/nixpkgs/blob/master/pkgs/README.md).
|
||||
|
||||
## Fetching Sources {#sec-sources}
|
||||
|
||||
This section has been moved to [pkgs/README.md](https://github.com/NixOS/nixpkgs/blob/master/pkgs/README.md).
|
||||
|
||||
## Obtaining source hash {#sec-source-hashes}
|
||||
|
||||
This section has been moved to [pkgs/README.md](https://github.com/NixOS/nixpkgs/blob/master/pkgs/README.md).
|
||||
|
||||
### Obtaining hashes securely {#sec-source-hashes-security}
|
||||
|
||||
This section has been moved to [pkgs/README.md](https://github.com/NixOS/nixpkgs/blob/master/pkgs/README.md).
|
||||
|
||||
## Patches {#sec-patches}
|
||||
|
||||
This section has been moved to [pkgs/README.md](https://github.com/NixOS/nixpkgs/blob/master/pkgs/README.md).
|
||||
|
||||
## Package tests {#sec-package-tests}
|
||||
|
||||
This section has been moved to [pkgs/README.md](https://github.com/NixOS/nixpkgs/blob/master/pkgs/README.md).
|
||||
|
||||
### Writing inline package tests {#ssec-inline-package-tests-writing}
|
||||
|
||||
This section has been moved to [pkgs/README.md](https://github.com/NixOS/nixpkgs/blob/master/pkgs/README.md).
|
||||
|
||||
### Writing larger package tests {#ssec-package-tests-writing}
|
||||
|
||||
This section has been moved to [pkgs/README.md](https://github.com/NixOS/nixpkgs/blob/master/pkgs/README.md).
|
||||
|
||||
### Running package tests {#ssec-package-tests-running}
|
||||
|
||||
This section has been moved to [pkgs/README.md](https://github.com/NixOS/nixpkgs/blob/master/pkgs/README.md).
|
||||
|
||||
### Examples of package tests {#ssec-package-tests-examples}
|
||||
|
||||
This section has been moved to [pkgs/README.md](https://github.com/NixOS/nixpkgs/blob/master/pkgs/README.md).
|
||||
|
||||
### Linking NixOS module tests to a package {#ssec-nixos-tests-linking}
|
||||
|
||||
This section has been moved to [pkgs/README.md](https://github.com/NixOS/nixpkgs/blob/master/pkgs/README.md).
|
||||
|
||||
### Import From Derivation {#ssec-import-from-derivation}
|
||||
|
||||
This section has been moved to [pkgs/README.md](https://github.com/NixOS/nixpkgs/blob/master/pkgs/README.md).
|
||||
@@ -0,0 +1,3 @@
|
||||
# Contributing to Nixpkgs documentation {#chap-contributing}
|
||||
|
||||
This section has been moved to [doc/README.md](https://github.com/NixOS/nixpkgs/blob/master/doc/README.md).
|
||||
3
doc/contributing/quick-start.chapter.md
Normal file
3
doc/contributing/quick-start.chapter.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Quick Start to Adding a Package {#chap-quick-start}
|
||||
|
||||
This section has been moved to [pkgs/README.md](https://github.com/NixOS/nixpkgs/blob/master/pkgs/README.md).
|
||||
35
doc/contributing/reviewing-contributions.chapter.md
Normal file
35
doc/contributing/reviewing-contributions.chapter.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# Reviewing contributions {#chap-reviewing-contributions}
|
||||
|
||||
This section has been moved to [CONTRIBUTING.md](https://github.com/NixOS/nixpkgs/blob/master/CONTRIBUTING.md).
|
||||
|
||||
## Package updates {#reviewing-contributions-package-updates}
|
||||
|
||||
This section has been moved to [pkgs/README.md](https://github.com/NixOS/nixpkgs/blob/master/pkgs/README.md).
|
||||
|
||||
## New packages {#reviewing-contributions-new-packages}
|
||||
|
||||
This section has been moved to [pkgs/README.md](https://github.com/NixOS/nixpkgs/blob/master/pkgs/README.md).
|
||||
|
||||
## Module updates {#reviewing-contributions-module-updates}
|
||||
|
||||
This section has been moved to [nixos/README.md](https://github.com/NixOS/nixpkgs/blob/master/nixos/README.md).
|
||||
|
||||
## New modules {#reviewing-contributions-new-modules}
|
||||
|
||||
This section has been moved to [nixos/README.md](https://github.com/NixOS/nixpkgs/blob/master/nixos/README.md#new-modules).
|
||||
|
||||
## Individual maintainer list {#reviewing-contributions-individual-maintainer-list}
|
||||
|
||||
This section has been moved to [maintainers/README.md](https://github.com/NixOS/nixpkgs/blob/master/maintainers/README.md).
|
||||
|
||||
## Maintainer teams {#reviewing-contributions-maintainer-teams}
|
||||
|
||||
This section has been moved to [maintainers/README.md](https://github.com/NixOS/nixpkgs/blob/master/maintainers/README.md).
|
||||
|
||||
## Other submissions {#reviewing-contributions-other-submissions}
|
||||
|
||||
This section has been moved to [CONTRIBUTING.md](https://github.com/NixOS/nixpkgs/blob/master/CONTRIBUTING.md).
|
||||
|
||||
## Merging pull requests {#reviewing-contributions--merging-pull-requests}
|
||||
|
||||
This section has been moved to [CONTRIBUTING.md](https://github.com/NixOS/nixpkgs/blob/master/CONTRIBUTING.md).
|
||||
88
doc/contributing/submitting-changes.chapter.md
Normal file
88
doc/contributing/submitting-changes.chapter.md
Normal file
@@ -0,0 +1,88 @@
|
||||
# Submitting changes {#chap-submitting-changes}
|
||||
|
||||
This section has been moved to [CONTRIBUTING.md](https://github.com/NixOS/nixpkgs/blob/master/CONTRIBUTING.md).
|
||||
|
||||
## Submitting changes {#submitting-changes-submitting-changes}
|
||||
|
||||
This section has been moved to [CONTRIBUTING.md](https://github.com/NixOS/nixpkgs/blob/master/CONTRIBUTING.md).
|
||||
|
||||
## Submitting security fixes {#submitting-changes-submitting-security-fixes}
|
||||
|
||||
This section has been moved to [pkgs/README.md](https://github.com/NixOS/nixpkgs/blob/master/pkgs/README.md).
|
||||
|
||||
## Deprecating/removing packages {#submitting-changes-deprecating-packages}
|
||||
|
||||
This section has been moved to [pkgs/README.md](https://github.com/NixOS/nixpkgs/blob/master/pkgs/README.md).
|
||||
|
||||
### Steps to remove a package from Nixpkgs {#steps-to-remove-a-package-from-nixpkgs}
|
||||
|
||||
This section has been moved to [pkgs/README.md](https://github.com/NixOS/nixpkgs/blob/master/pkgs/README.md).
|
||||
|
||||
## Pull Request Template {#submitting-changes-pull-request-template}
|
||||
|
||||
This section has been moved to [CONTRIBUTING.md](https://github.com/NixOS/nixpkgs/blob/master/CONTRIBUTING.md).
|
||||
|
||||
### Tested using sandboxing {#submitting-changes-tested-with-sandbox}
|
||||
|
||||
This section has been moved to [CONTRIBUTING.md](https://github.com/NixOS/nixpkgs/blob/master/CONTRIBUTING.md).
|
||||
|
||||
### Built on platform(s) {#submitting-changes-platform-diversity}
|
||||
|
||||
This section has been moved to [CONTRIBUTING.md](https://github.com/NixOS/nixpkgs/blob/master/CONTRIBUTING.md).
|
||||
|
||||
### Tested via one or more NixOS test(s) if existing and applicable for the change (look inside nixos/tests) {#submitting-changes-nixos-tests}
|
||||
|
||||
This section has been moved to [CONTRIBUTING.md](https://github.com/NixOS/nixpkgs/blob/master/CONTRIBUTING.md).
|
||||
|
||||
### Tested compilation of all pkgs that depend on this change using `nixpkgs-review` {#submitting-changes-tested-compilation}
|
||||
|
||||
This section has been moved to [CONTRIBUTING.md](https://github.com/NixOS/nixpkgs/blob/master/CONTRIBUTING.md).
|
||||
|
||||
### Tested execution of all binary files (usually in `./result/bin/`) {#submitting-changes-tested-execution}
|
||||
|
||||
This section has been moved to [CONTRIBUTING.md](https://github.com/NixOS/nixpkgs/blob/master/CONTRIBUTING.md).
|
||||
|
||||
### Meets Nixpkgs contribution standards {#submitting-changes-contribution-standards}
|
||||
|
||||
This section has been moved to [CONTRIBUTING.md](https://github.com/NixOS/nixpkgs/blob/master/CONTRIBUTING.md).
|
||||
|
||||
## Hotfixing pull requests {#submitting-changes-hotfixing-pull-requests}
|
||||
|
||||
This section has been moved to [CONTRIBUTING.md](https://github.com/NixOS/nixpkgs/blob/master/CONTRIBUTING.md).
|
||||
|
||||
## Commit policy {#submitting-changes-commit-policy}
|
||||
|
||||
This section has been moved to [CONTRIBUTING.md](https://github.com/NixOS/nixpkgs/blob/master/CONTRIBUTING.md).
|
||||
|
||||
### Branches {#submitting-changes-branches}
|
||||
|
||||
This section has been moved to [CONTRIBUTING.md](https://github.com/NixOS/nixpkgs/blob/master/CONTRIBUTING.md).
|
||||
|
||||
#### Master branch {#submitting-changes-master-branch}
|
||||
|
||||
This section has been moved to [CONTRIBUTING.md](https://github.com/NixOS/nixpkgs/blob/master/CONTRIBUTING.md).
|
||||
|
||||
#### Staging branch {#submitting-changes-staging-branch}
|
||||
|
||||
This section has been moved to [CONTRIBUTING.md](https://github.com/NixOS/nixpkgs/blob/master/CONTRIBUTING.md).
|
||||
|
||||
#### Staging-next branch {#submitting-changes-staging-next-branch}
|
||||
|
||||
This section has been moved to [CONTRIBUTING.md](https://github.com/NixOS/nixpkgs/blob/master/CONTRIBUTING.md).
|
||||
|
||||
#### Stable release branches {#submitting-changes-stable-release-branches}
|
||||
|
||||
This section has been moved to [CONTRIBUTING.md](https://github.com/NixOS/nixpkgs/blob/master/CONTRIBUTING.md).
|
||||
|
||||
#### Automatically backporting a Pull Request {#submitting-changes-stable-release-branches-automatic-backports}
|
||||
|
||||
This section has been moved to [CONTRIBUTING.md](https://github.com/NixOS/nixpkgs/blob/master/CONTRIBUTING.md).
|
||||
|
||||
#### Manually backporting changes {#submitting-changes-stable-release-branches-manual-backports}
|
||||
|
||||
This section has been moved to [CONTRIBUTING.md](https://github.com/NixOS/nixpkgs/blob/master/CONTRIBUTING.md).
|
||||
|
||||
#### Acceptable backport criteria {#acceptable-backport-criteria}
|
||||
|
||||
This section has been moved to [CONTRIBUTING.md](https://github.com/NixOS/nixpkgs/blob/master/CONTRIBUTING.md).
|
||||
|
||||
11
doc/contributing/vulnerability-roundup.chapter.md
Normal file
11
doc/contributing/vulnerability-roundup.chapter.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# Vulnerability Roundup {#chap-vulnerability-roundup}
|
||||
|
||||
This section has been moved to [pkgs/README.md](https://github.com/NixOS/nixpkgs/blob/master/pkgs/README.md).
|
||||
|
||||
## Issues {#vulnerability-roundup-issues}
|
||||
|
||||
This section has been moved to [pkgs/README.md](https://github.com/NixOS/nixpkgs/blob/master/pkgs/README.md).
|
||||
|
||||
## Triaging and Fixing {#vulnerability-roundup-triaging-and-fixing}
|
||||
|
||||
This section has been moved to [pkgs/README.md](https://github.com/NixOS/nixpkgs/blob/master/pkgs/README.md).
|
||||
10
doc/development.md
Normal file
10
doc/development.md
Normal file
@@ -0,0 +1,10 @@
|
||||
# Development of Nixpkgs {#part-development}
|
||||
|
||||
This section shows you how Nixpkgs is developed and how you can interact with the contributors and the latest updates.
|
||||
If you are interested in contributing yourself, see [CONTRIBUTING.md](https://github.com/NixOS/nixpkgs/blob/master/CONTRIBUTING.md).
|
||||
|
||||
<!-- In the future this section should also include: How to test pull requests, how to know if pull requests are available in channels, etc. -->
|
||||
|
||||
```{=include=} chapters
|
||||
development/opening-issues.chapter.md
|
||||
```
|
||||
7
doc/development/opening-issues.chapter.md
Normal file
7
doc/development/opening-issues.chapter.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# Opening issues {#sec-opening-issues}
|
||||
|
||||
* Make sure you have a [GitHub account](https://github.com/signup/free)
|
||||
* Make sure there is no open issue on the topic
|
||||
* [Submit a new issue](https://github.com/NixOS/nixpkgs/issues/new/choose) by choosing the kind of topic and filling out the template
|
||||
|
||||
<!-- In the future this section could also include more detailed information on the issue templates -->
|
||||
@@ -1,205 +0,0 @@
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import TypedDict
|
||||
|
||||
# Coupled to where this file lives!
|
||||
# Needed to resolve the relative includes file paths
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
|
||||
ROOT_FILE = REPO_ROOT / "nixos/doc/manual/manual.md"
|
||||
DOC_ROOT = ROOT_FILE.parent
|
||||
|
||||
INCLUDE_RE = re.compile(r"^```\{=include=\}(?P<rest>.*)$")
|
||||
FENCE_RE = re.compile(r"^(```|~~~)")
|
||||
HEADING_RE = re.compile(r"^(#{1,6})\s+(.*?)\s*(?:\{#([^}]+)\})?\s*$")
|
||||
|
||||
|
||||
class TokenIncludeBlock:
|
||||
"""Represents a complete include block like:
|
||||
|
||||
```{=include=} sections
|
||||
special/buildenv.section.md
|
||||
```
|
||||
|
||||
As
|
||||
|
||||
typ = "sections"
|
||||
files = [ "special/buildenv.section.md" ]
|
||||
|
||||
"""
|
||||
kind = "include"
|
||||
|
||||
def __repr__(self):
|
||||
# For debugging
|
||||
return f"```{{=include=}} {self.typ}\n" "\n".join(self.files) + "\n```"
|
||||
|
||||
def __init__(self, typ, args, files, start, end):
|
||||
self.typ: str = typ
|
||||
self.args: list[str] = args
|
||||
self.files: list[str] = files
|
||||
self.start: int = start
|
||||
self.end: int = end
|
||||
|
||||
def is_option_block(self) -> bool:
|
||||
# option blocks have some special syntax which is part of another migration
|
||||
# 8 coccurrences, seperate migration
|
||||
return self.typ == "options"
|
||||
|
||||
def into_file(self) -> bool:
|
||||
# into-file is trivial after the nav migration
|
||||
# 2 coccurrences, can be migrated by hand
|
||||
return any("html:into-file=" in arg for arg in self.args)
|
||||
|
||||
def keep(self) -> bool:
|
||||
# Keep the include blocks that require seperate migration
|
||||
return self.is_option_block() or self.into_file()
|
||||
|
||||
class TokenHeading:
|
||||
kind = "heading"
|
||||
|
||||
def __init__(self, level, title, anchor):
|
||||
self.level: int = level
|
||||
self.title: str = title
|
||||
self.anchor: str = anchor
|
||||
|
||||
|
||||
def tokenize(src: str) -> list[TokenIncludeBlock|TokenHeading]:
|
||||
"""Finds all Includes and Headings"""
|
||||
lines = src.splitlines()
|
||||
items = []
|
||||
fenced = False
|
||||
i = 0
|
||||
while i < len(lines):
|
||||
line = lines[i]
|
||||
m = INCLUDE_RE.match(line)
|
||||
if m and not fenced:
|
||||
typ, *args = m.group("rest").split()
|
||||
end = i + 1
|
||||
while end < len(lines) and not lines[end].startswith("```"):
|
||||
end += 1
|
||||
if end == len(lines):
|
||||
sys.exit(f"unterminated include block at line {i + 1}")
|
||||
files = [f.strip() for f in lines[i + 1 : end] if f.strip()]
|
||||
items.append(TokenIncludeBlock(typ, args, files, i, end))
|
||||
i = end + 1
|
||||
continue
|
||||
if FENCE_RE.match(line):
|
||||
fenced = not fenced
|
||||
i += 1
|
||||
continue
|
||||
if not fenced:
|
||||
m = HEADING_RE.match(line)
|
||||
if m:
|
||||
items.append(TokenHeading(len(m.group(1)), m.group(2), m.group(3)))
|
||||
i += 1
|
||||
return items
|
||||
|
||||
|
||||
def read_source(source: Path) -> None | tuple[str, Path]:
|
||||
"""Read a return [content,Path]
|
||||
|
||||
library.md is called .md.in; provided at build time via nixdoc
|
||||
|
||||
So the filename might differ
|
||||
"""
|
||||
if source.exists():
|
||||
return source.read_text(), source
|
||||
|
||||
in_file = Path(str(source) + ".in")
|
||||
if in_file.exists():
|
||||
return in_file.read_text(), in_file
|
||||
return None
|
||||
|
||||
# Make sure we process every file only once
|
||||
visited = set()
|
||||
|
||||
# Map from filename -> line_nrs
|
||||
# These lines will be deleted from the file
|
||||
rewrites: dict[str, tuple[int,int]] = {}
|
||||
|
||||
group_ids: set[str] = set()
|
||||
|
||||
class Green(TypedDict):
|
||||
label: str
|
||||
id: str
|
||||
children: list["Green"]
|
||||
file: str | None
|
||||
|
||||
def convert_md_in(md_in: Path) -> Green:
|
||||
"""Build up items for the nav.json as we recurse.
|
||||
Collecting line-areas and group_ids
|
||||
"""
|
||||
if md_in in visited:
|
||||
sys.exit(f"{md_in}: reached twice")
|
||||
|
||||
visited.add(md_in)
|
||||
|
||||
res = read_source(md_in)
|
||||
|
||||
rel_file = md_in.relative_to(DOC_ROOT).as_posix()
|
||||
if not res:
|
||||
return {"file": rel_file}
|
||||
|
||||
text, actual = res
|
||||
token: list[TokenHeading|TokenIncludeBlock] = tokenize(text)
|
||||
|
||||
# Collect a list of actual files to be processed
|
||||
# Recurse for files
|
||||
# Do not recurse for "options", "into-file"
|
||||
all_file_includes: list[TokenIncludeBlock] = [it for it in token if it.kind == "include" and not (it.is_option_block() or it.into_file())]
|
||||
children = [convert_md_in((md_in.parent / f).resolve()) for b in all_file_includes for f in b.files]
|
||||
|
||||
# Lines to rewrite
|
||||
include_blocks_to_rewrite: list[TokenIncludeBlock] = [it for it in all_file_includes if not it.keep()]
|
||||
rewrites[actual] = [(b.start, b.end) for b in include_blocks_to_rewrite]
|
||||
|
||||
if not children:
|
||||
return {"file": rel_file}
|
||||
|
||||
title = next((it for it in token if it.kind == "heading" and it.level == 1), None)
|
||||
if title is None:
|
||||
sys.exit(f"{md_in}: no level-1 heading to label its group")
|
||||
if not title.anchor:
|
||||
sys.exit(f"{md_in}: level-1 heading has no id")
|
||||
if title.anchor in group_ids:
|
||||
sys.exit(f"{md_in}: duplicate group id {title.anchor}")
|
||||
group_ids.add(title.anchor)
|
||||
|
||||
return Green({
|
||||
"label": title.title.replace("`", ""),
|
||||
"id": title.anchor,
|
||||
"children": [{"file": rel_file}] + children,
|
||||
})
|
||||
|
||||
|
||||
def remove_includes(path: Path, drop_lines: tuple[int,int]):
|
||||
if not drop_lines:
|
||||
return
|
||||
|
||||
lines = path.read_text().splitlines()
|
||||
for start, end in sorted(drop_lines, reverse=True):
|
||||
del lines[start : end + 1]
|
||||
if 0 < start < len(lines) and not lines[start - 1].strip() and not lines[start].strip():
|
||||
del lines[start]
|
||||
while lines and not lines[-1].strip():
|
||||
lines.pop()
|
||||
path.write_text("\n".join(lines) + "\n")
|
||||
|
||||
|
||||
def main():
|
||||
nav = DOC_ROOT / "nav.json"
|
||||
if json.loads(nav.read_text())["items"]:
|
||||
sys.exit("nav.json already has items; This tool can only run once")
|
||||
|
||||
root = convert_md_in(ROOT_FILE)
|
||||
items = root["children"][1:]
|
||||
|
||||
nav.write_text(json.dumps({"open": [], "items": items}, indent=2) + "\n")
|
||||
for path, lines in rewrites.items():
|
||||
remove_includes(path, lines)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,27 +1,151 @@
|
||||
# Generates the documentation for library functions via nixdoc.
|
||||
# To build this derivation, run `nix-build -A nixpkgs-manual.lib-docs`
|
||||
{
|
||||
lib,
|
||||
stdenvNoCC,
|
||||
nixdoc,
|
||||
nix,
|
||||
nixpkgs ? { },
|
||||
libsets ? [
|
||||
{
|
||||
name = "asserts";
|
||||
description = "assertion functions";
|
||||
}
|
||||
{
|
||||
name = "attrsets";
|
||||
description = "attribute set functions";
|
||||
}
|
||||
{
|
||||
name = "strings";
|
||||
description = "string manipulation functions";
|
||||
}
|
||||
{
|
||||
name = "versions";
|
||||
description = "version string functions";
|
||||
}
|
||||
{
|
||||
name = "trivial";
|
||||
description = "miscellaneous functions";
|
||||
}
|
||||
{
|
||||
name = "fixedPoints";
|
||||
baseName = "fixed-points";
|
||||
description = "explicit recursion functions";
|
||||
}
|
||||
{
|
||||
name = "lists";
|
||||
description = "list manipulation functions";
|
||||
}
|
||||
{
|
||||
name = "debug";
|
||||
description = "debugging functions";
|
||||
}
|
||||
{
|
||||
name = "options";
|
||||
description = "NixOS / nixpkgs option handling";
|
||||
}
|
||||
{
|
||||
name = "path";
|
||||
description = "path functions";
|
||||
}
|
||||
{
|
||||
name = "fetchers";
|
||||
description = "functions which can be reused across fetchers";
|
||||
}
|
||||
{
|
||||
name = "filesystem";
|
||||
description = "filesystem functions";
|
||||
}
|
||||
{
|
||||
name = "fileset";
|
||||
description = "file set functions";
|
||||
}
|
||||
{
|
||||
name = "sources";
|
||||
description = "source filtering functions";
|
||||
}
|
||||
{
|
||||
name = "cli";
|
||||
description = "command-line serialization functions";
|
||||
}
|
||||
{
|
||||
name = "generators";
|
||||
description = "functions that create file formats from nix data structures";
|
||||
}
|
||||
{
|
||||
name = "gvariant";
|
||||
description = "GVariant formatted string serialization functions";
|
||||
}
|
||||
{
|
||||
name = "customisation";
|
||||
description = "Functions to customise (derivation-related) functions, derivations, or attribute sets";
|
||||
}
|
||||
{
|
||||
name = "meta";
|
||||
description = "functions for derivation metadata";
|
||||
}
|
||||
{
|
||||
name = "derivations";
|
||||
description = "miscellaneous derivation-specific functions";
|
||||
}
|
||||
],
|
||||
}:
|
||||
|
||||
stdenvNoCC.mkDerivation {
|
||||
name = "nixpkgs-lib-docs";
|
||||
|
||||
src = ../../lib;
|
||||
|
||||
nativeBuildInputs = [ nixdoc ];
|
||||
|
||||
buildPhase = ''
|
||||
mkdir -p src
|
||||
cp -r ${../../lib} src/lib
|
||||
|
||||
mkdir -p $out
|
||||
nixdoc --manifest ${../function-catalog.json} --root src --output $out/lib-functions.json
|
||||
'';
|
||||
nativeBuildInputs = [
|
||||
nixdoc
|
||||
nix
|
||||
];
|
||||
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
|
||||
cd ..
|
||||
|
||||
export NIX_STATE_DIR=$(mktemp -d)
|
||||
nix-instantiate --eval --strict --json ${./lib-function-locations.nix} \
|
||||
--arg nixpkgsPath "./." \
|
||||
--argstr revision ${nixpkgs.rev or "master"} \
|
||||
--argstr libsetsJSON ${lib.escapeShellArg (builtins.toJSON libsets)} \
|
||||
--store $(mktemp -d) \
|
||||
> locations.json
|
||||
|
||||
function docgen {
|
||||
name=$1
|
||||
baseName=$2
|
||||
description=$3
|
||||
# TODO: wrap lib.$name in <literal>, make nixdoc not escape it
|
||||
if [[ -e "lib/$baseName.nix" ]]; then
|
||||
nixdoc -c "$name" -d "lib.$name: $description" -l locations.json -f "lib/$baseName.nix" > "$out/$name.md"
|
||||
else
|
||||
nixdoc -c "$name" -d "lib.$name: $description" -l locations.json -f "lib/$baseName/default.nix" > "$out/$name.md"
|
||||
fi
|
||||
echo "$out/$name.md" >> "$out/index.md"
|
||||
}
|
||||
|
||||
mkdir -p "$out"
|
||||
|
||||
cat > "$out/index.md" << 'EOF'
|
||||
```{=include=} sections auto-id-prefix=auto-generated
|
||||
EOF
|
||||
|
||||
${lib.concatMapStrings (
|
||||
{
|
||||
name,
|
||||
baseName ? name,
|
||||
description,
|
||||
}:
|
||||
''
|
||||
docgen ${name} ${baseName} ${lib.escapeShellArg description}
|
||||
''
|
||||
) libsets}
|
||||
|
||||
echo '```' >> "$out/index.md"
|
||||
|
||||
runHook postInstall
|
||||
'';
|
||||
}
|
||||
|
||||
82
doc/doc-support/lib-function-locations.nix
Normal file
82
doc/doc-support/lib-function-locations.nix
Normal file
@@ -0,0 +1,82 @@
|
||||
{
|
||||
nixpkgsPath,
|
||||
revision,
|
||||
libsetsJSON,
|
||||
}:
|
||||
let
|
||||
lib = import (nixpkgsPath + "/lib");
|
||||
libsets = builtins.fromJSON libsetsJSON;
|
||||
|
||||
libDefPos =
|
||||
prefix: set:
|
||||
builtins.concatMap (
|
||||
name:
|
||||
[
|
||||
{
|
||||
name = builtins.concatStringsSep "." (prefix ++ [ name ]);
|
||||
location = builtins.unsafeGetAttrPos name set;
|
||||
}
|
||||
]
|
||||
++ lib.optionals (builtins.length prefix == 0 && builtins.isAttrs set.${name}) (
|
||||
libDefPos (prefix ++ [ name ]) set.${name}
|
||||
)
|
||||
) (builtins.attrNames set);
|
||||
|
||||
libset =
|
||||
toplib:
|
||||
map (subsetname: {
|
||||
subsetname = subsetname;
|
||||
functions = libDefPos [ ] toplib.${subsetname};
|
||||
}) (map (x: x.name) libsets);
|
||||
|
||||
flattenedLibSubset =
|
||||
{ subsetname, functions }:
|
||||
map (fn: {
|
||||
name = "lib.${subsetname}.${fn.name}";
|
||||
value = fn.location;
|
||||
}) functions;
|
||||
|
||||
locatedlibsets = libs: map flattenedLibSubset (libset libs);
|
||||
removeFilenamePrefix =
|
||||
prefix: filename:
|
||||
let
|
||||
prefixLen = (builtins.stringLength prefix) + 1; # +1 to remove the leading /
|
||||
filenameLen = builtins.stringLength filename;
|
||||
substr = builtins.substring prefixLen filenameLen filename;
|
||||
in
|
||||
substr;
|
||||
|
||||
removeNixpkgs = removeFilenamePrefix (toString nixpkgsPath);
|
||||
|
||||
liblocations = builtins.filter (elem: elem.value != null) (lib.lists.flatten (locatedlibsets lib));
|
||||
|
||||
fnLocationRelative =
|
||||
{ name, value }:
|
||||
{
|
||||
inherit name;
|
||||
value = value // {
|
||||
file = removeNixpkgs value.file;
|
||||
};
|
||||
};
|
||||
|
||||
relativeLocs = (map fnLocationRelative liblocations);
|
||||
sanitizeId = builtins.replaceStrings [ "'" ] [ "-prime" ];
|
||||
|
||||
urlPrefix = "https://github.com/NixOS/nixpkgs/blob/${revision}";
|
||||
jsonLocs = builtins.listToAttrs (
|
||||
map (
|
||||
{ name, value }:
|
||||
{
|
||||
name = sanitizeId name;
|
||||
value =
|
||||
let
|
||||
text = "${value.file}:${toString value.line}";
|
||||
target = "${urlPrefix}/${value.file}#L${toString value.line}";
|
||||
in
|
||||
"[${text}](${target}) in `<nixpkgs>`";
|
||||
}
|
||||
) relativeLocs
|
||||
);
|
||||
|
||||
in
|
||||
jsonLocs
|
||||
@@ -91,7 +91,7 @@ stdenvNoCC.mkDerivation (
|
||||
substituteInPlace ./languages-frameworks/python.section.md \
|
||||
--subst-var-by python-interpreter-table "$(<"${pythonInterpreterTable}")"
|
||||
|
||||
cp ${lib-docs}/lib-functions.json ./lib-functions.json
|
||||
cat ./functions/library.md.in ${lib-docs}/index.md > ./functions/library.md
|
||||
|
||||
substitute ./manual.md.in ./manual.md \
|
||||
--replace-fail '@MANUAL_VERSION@' '${lib.version}'
|
||||
@@ -118,7 +118,7 @@ stdenvNoCC.mkDerivation (
|
||||
--script ./anchor.min.js \
|
||||
--script ./anchor-use.js \
|
||||
--sidebar-depth 3 \
|
||||
--experimental-config ./nav.json \
|
||||
--nav ./nav.json \
|
||||
--header ${./header.html}\
|
||||
--no-navheader \
|
||||
manual.md \
|
||||
@@ -147,7 +147,7 @@ stdenvNoCC.mkDerivation (
|
||||
'';
|
||||
|
||||
passthru = {
|
||||
lib-docs = callPackage ./lib-function-docs.nix { };
|
||||
lib-docs = callPackage ./lib-function-docs.nix { inherit nixpkgs; };
|
||||
|
||||
epub = callPackage ./epub.nix { };
|
||||
|
||||
@@ -176,7 +176,5 @@ stdenvNoCC.mkDerivation (
|
||||
manpage-urls = callPackage ../tests/manpage-urls.nix { };
|
||||
};
|
||||
};
|
||||
|
||||
meta.license = lib.licenses.mit;
|
||||
}
|
||||
)
|
||||
|
||||
@@ -53,7 +53,7 @@ Pin Nixpkgs and call the package from `default.nix`:
|
||||
```nix
|
||||
# default.nix
|
||||
let
|
||||
nixpkgs = fetchTarball "https://channels.nixos.org/nixos-unstable/nixexprs.tar.zst";
|
||||
nixpkgs = fetchTarball "https://github.com/NixOS/nixpkgs/archive/nixos-unstable.tar.gz";
|
||||
pkgs = import nixpkgs { };
|
||||
in
|
||||
pkgs.callPackage ./package.nix { }
|
||||
@@ -1,124 +0,0 @@
|
||||
{
|
||||
"version": 1,
|
||||
"groups": [
|
||||
{ "id": "asserts", "description": "assertion functions" },
|
||||
{ "id": "attrsets", "description": "attribute set functions" },
|
||||
{ "id": "strings", "description": "string manipulation functions" },
|
||||
{ "id": "versions", "description": "version string functions" },
|
||||
{ "id": "trivial", "description": "miscellaneous functions" },
|
||||
{ "id": "fixedPoints", "description": "explicit recursion functions" },
|
||||
{ "id": "lists", "description": "list manipulation functions" },
|
||||
{ "id": "debug", "description": "debugging functions" },
|
||||
{ "id": "options", "description": "NixOS / nixpkgs option handling" },
|
||||
{ "id": "path", "description": "path functions" },
|
||||
{
|
||||
"id": "fetchers",
|
||||
"description": "functions which can be reused across fetchers"
|
||||
},
|
||||
{ "id": "filesystem", "description": "filesystem functions" },
|
||||
{ "id": "fileset" },
|
||||
{ "id": "sources", "description": "source filtering functions" },
|
||||
{ "id": "cli", "description": "command-line serialization functions" },
|
||||
{
|
||||
"id": "generators",
|
||||
"description": "functions that create file formats from nix data structures"
|
||||
},
|
||||
{
|
||||
"id": "gvariant",
|
||||
"description": "GVariant formatted string serialization functions"
|
||||
},
|
||||
{
|
||||
"id": "customisation",
|
||||
"description": "Functions to customise (derivation-related) functions, derivations, or attribute sets"
|
||||
},
|
||||
{ "id": "meta", "description": "functions for derivation metadata" },
|
||||
{
|
||||
"id": "derivations",
|
||||
"description": "miscellaneous derivation-specific functions"
|
||||
}
|
||||
],
|
||||
"sources": [
|
||||
{ "path": "lib.trivial", "file": "lib/trivial.nix", "groups": ["trivial"] },
|
||||
{
|
||||
"path": "lib.fixedPoints",
|
||||
"file": "lib/fixed-points.nix",
|
||||
"groups": ["fixedPoints"]
|
||||
},
|
||||
{
|
||||
"path": "lib.attrsets",
|
||||
"file": "lib/attrsets.nix",
|
||||
"groups": ["attrsets"]
|
||||
},
|
||||
{ "path": "lib.lists", "file": "lib/lists.nix", "groups": ["lists"] },
|
||||
{ "path": "lib.strings", "file": "lib/strings.nix", "groups": ["strings"] },
|
||||
{
|
||||
"path": "lib.customisation",
|
||||
"file": "lib/customisation.nix",
|
||||
"groups": ["customisation"]
|
||||
},
|
||||
{
|
||||
"path": "lib.derivations",
|
||||
"file": "lib/derivations.nix",
|
||||
"groups": ["derivations"]
|
||||
},
|
||||
{ "path": "lib.meta", "file": "lib/meta.nix", "groups": ["meta"] },
|
||||
{
|
||||
"path": "lib.versions",
|
||||
"file": "lib/versions.nix",
|
||||
"groups": ["versions"]
|
||||
},
|
||||
{ "path": "lib.cli", "file": "lib/cli.nix", "groups": ["cli"] },
|
||||
{
|
||||
"path": "lib.gvariant",
|
||||
"file": "lib/gvariant.nix",
|
||||
"groups": ["gvariant"]
|
||||
},
|
||||
{
|
||||
"path": "lib.generators",
|
||||
"file": "lib/generators.nix",
|
||||
"groups": ["generators"]
|
||||
},
|
||||
{ "path": "lib.asserts", "file": "lib/asserts.nix", "groups": ["asserts"] },
|
||||
{ "path": "lib.debug", "file": "lib/debug.nix", "groups": ["debug"] },
|
||||
{
|
||||
"path": "lib.fetchers",
|
||||
"file": "lib/fetchers.nix",
|
||||
"groups": ["fetchers"]
|
||||
},
|
||||
{ "path": "lib.path", "file": "lib/path/default.nix", "groups": ["path"] },
|
||||
{
|
||||
"path": "lib.filesystem",
|
||||
"file": "lib/filesystem.nix",
|
||||
"groups": ["filesystem"]
|
||||
},
|
||||
{
|
||||
"path": "lib.fileset",
|
||||
"file": "lib/fileset/default.nix",
|
||||
"groups": ["fileset"]
|
||||
},
|
||||
{
|
||||
"path": "lib.sources",
|
||||
"file": "lib/sources.nix",
|
||||
"groups": ["sources"],
|
||||
"mode": "deep",
|
||||
"include": [
|
||||
"pathIsGitRepo",
|
||||
"commitIdFromGitRepo",
|
||||
"cleanSource",
|
||||
"cleanSourceWith",
|
||||
"cleanSourceFilter",
|
||||
"pathHasContext",
|
||||
"canCleanSource",
|
||||
"urlToName",
|
||||
"shortRev",
|
||||
"revOrTag",
|
||||
"repoRevToName",
|
||||
"sourceByRegex",
|
||||
"sourceFilesBySuffices",
|
||||
"sourceByGlobs",
|
||||
"trace"
|
||||
]
|
||||
},
|
||||
{ "path": "lib.options", "file": "lib/options.nix", "groups": ["options"] }
|
||||
]
|
||||
}
|
||||
@@ -1,3 +1,11 @@
|
||||
# Functions reference {#chap-functions}
|
||||
|
||||
The Nixpkgs repository has several utility functions to manipulate Nix expressions.
|
||||
|
||||
```{=include=} sections
|
||||
functions/library.md
|
||||
functions/generators.section.md
|
||||
functions/debug.section.md
|
||||
functions/prefer-remote-fetch.section.md
|
||||
functions/nix-gitignore.section.md
|
||||
```
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
# Nixpkgs Library Functions {#sec-functions-library}
|
||||
|
||||
Nixpkgs provides a standard library at `pkgs.lib`, or through `import <nixpkgs/lib>`.
|
||||
5
doc/functions/library.md.in
Normal file
5
doc/functions/library.md.in
Normal file
@@ -0,0 +1,5 @@
|
||||
# Nixpkgs Library Functions {#sec-functions-library}
|
||||
|
||||
Nixpkgs provides a standard library at `pkgs.lib`, or through `import <nixpkgs/lib>`.
|
||||
|
||||
<!-- nixdoc-generated documentation must be appended here during build! -->
|
||||
@@ -1,49 +0,0 @@
|
||||
# Dev environments {#dev-environments}
|
||||
|
||||
Create a `shell.nix` with the following:
|
||||
|
||||
```nix
|
||||
# shell.nix
|
||||
let
|
||||
nixpkgs = fetchTarball "https://channels.nixos.org/nixos-unstable/nixexprs.tar.zst";
|
||||
pkgs = import nixpkgs { };
|
||||
in
|
||||
pkgs.mkShell {
|
||||
packages = [ pkgs.python3 ];
|
||||
shellHook = ''
|
||||
echo "Welcome in my nix shell"
|
||||
'';
|
||||
}
|
||||
```
|
||||
|
||||
run
|
||||
|
||||
```sh
|
||||
nix-shell
|
||||
```
|
||||
|
||||
This activates your `shell.nix` and you should see:
|
||||
|
||||
```sh
|
||||
unpacking 'https://channels.nixos.org/nixos-unstable/nixexprs.tar.zst' into the Git cache...
|
||||
Welcome in your nix shell
|
||||
```
|
||||
|
||||
python3 is available
|
||||
|
||||
```sh
|
||||
$ python3 --version
|
||||
```
|
||||
|
||||
To leave the shell
|
||||
|
||||
```bash
|
||||
ctrl+D
|
||||
```
|
||||
|
||||
:::{.note}
|
||||
You should use [pinned nixpkgs](https://nix.dev/guides/recipes/dependency-management.html).
|
||||
The example used `unstable` here for demonstration purposes only
|
||||
:::
|
||||
|
||||
For further information check out [nix-shell](https://nix.dev/manual/nix/stable/command-ref/nix-shell)
|
||||
@@ -1 +0,0 @@
|
||||
# Getting started {#getting-started}
|
||||
@@ -3,7 +3,7 @@
|
||||
This hook defaults a variety of environment variables known
|
||||
to control thread counts to 1. Many of these otherwise default
|
||||
to `$(nproc)`, which causes massive overloads on build machines
|
||||
if nix build jobs and build cores are already tuned to fully use
|
||||
if nix build jobs and build cores are already tuned to fully utilize
|
||||
compute capacity of a builder without additional parallelism.
|
||||
|
||||
Currently sets the following environment variables:
|
||||
|
||||
3
doc/hooks/ghc.section.md
Normal file
3
doc/hooks/ghc.section.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# GHC {#ghc}
|
||||
|
||||
Creates a temporary package database and registers every Haskell build input in it (TODO: how?).
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user