nixpkgs mirror (for testing)
github.com/NixOS/nixpkgs
nix
1const { classify } = require('../supportedBranches.js')
2const { postReview, dismissReviews } = require('./reviews.js')
3const reviewKey = 'prepare'
4const supportedSystems = require('./supportedSystems.js')
5
6module.exports = async ({ github, context, core, dry }) => {
7 const pull_number = context.payload.pull_request.number
8
9 for (const retryInterval of [5, 10, 20, 40, 80]) {
10 core.info('Checking whether the pull request can be merged...')
11 const prInfo = (
12 await github.rest.pulls.get({
13 ...context.repo,
14 pull_number,
15 })
16 ).data
17
18 if (prInfo.state !== 'open') throw new Error('PR is not open anymore.')
19
20 if (prInfo.mergeable == null) {
21 core.info(
22 `GitHub is still computing whether this PR can be merged, waiting ${retryInterval} seconds before trying again...`,
23 )
24 await new Promise((resolve) => setTimeout(resolve, retryInterval * 1000))
25 continue
26 }
27
28 const { base, head } = prInfo
29
30 const baseClassification = classify(base.ref)
31 core.setOutput('base', baseClassification)
32 console.log('base classification:', baseClassification)
33
34 const headClassification =
35 base.repo.full_name === head.repo.full_name
36 ? classify(head.ref)
37 : // PRs from forks are always considered WIP.
38 { type: ['wip'] }
39 core.setOutput('head', headClassification)
40 console.log('head classification:', headClassification)
41
42 if (baseClassification.type.includes('channel')) {
43 const { stable, version } = baseClassification
44 const correctBranch = stable ? `release-${version}` : 'master'
45 const body = [
46 'The `nixos-*` and `nixpkgs-*` branches are pushed to by the channel release script and should not be merged into directly.',
47 '',
48 `Please target \`${correctBranch}\` instead.`,
49 ].join('\n')
50
51 await postReview({ github, context, core, dry, body, reviewKey })
52
53 throw new Error('The PR targets a channel branch.')
54 }
55
56 if (headClassification.type.includes('wip')) {
57 // In the following, we look at the git history to determine the base branch that
58 // this Pull Request branched off of. This is *supposed* to be the branch that it
59 // merges into, but humans make mistakes. Once that happens we want to error out as
60 // early as possible.
61
62 // To determine the "real base", we are looking at the merge-base of primary development
63 // branches and the head of the PR. The merge-base which results in the least number of
64 // commits between that base and head is the real base. We can query for this via GitHub's
65 // REST API. There can be multiple candidates for the real base with the same number of
66 // commits. In this case we pick the "best" candidate by a fixed ordering of branches,
67 // as defined in ci/supportedBranches.js.
68 //
69 // These requests take a while, when comparing against the wrong release - they need
70 // to look at way more than 10k commits in that case. Thus, we try to minimize the
71 // number of requests across releases:
72 // - First, we look at the primary development branches only: master and release-xx.yy.
73 // The branch with the fewest commits gives us the release this PR belongs to.
74 // - We then compare this number against the relevant staging branches for this release
75 // to find the exact branch that this belongs to.
76
77 // All potential development branches
78 const branches = (
79 await github.paginate(github.rest.repos.listBranches, {
80 ...context.repo,
81 per_page: 100,
82 })
83 ).map(({ name }) => classify(name))
84
85 // All stable primary development branches from latest to oldest.
86 const releases = branches
87 .filter(({ stable, type }) => type.includes('primary') && stable)
88 .sort((a, b) => b.version.localeCompare(a.version))
89
90 async function mergeBase({ branch, order, version }) {
91 const { data } = await github.rest.repos.compareCommitsWithBasehead({
92 ...context.repo,
93 basehead: `${branch}...${head.sha}`,
94 // Pagination for this endpoint is about the commits listed, which we don't care about.
95 per_page: 1,
96 // Taking the second page skips the list of files of this changeset.
97 page: 2,
98 })
99 return {
100 branch,
101 order,
102 version,
103 commits: data.total_commits,
104 sha: data.merge_base_commit.sha,
105 }
106 }
107
108 // Multiple branches can be OK at the same time, if the PR was created of a merge-base,
109 // thus storing as array.
110 let candidates = [await mergeBase(classify('master'))]
111 for (const release of releases) {
112 const nextCandidate = await mergeBase(release)
113 if (candidates[0].commits === nextCandidate.commits)
114 candidates.push(nextCandidate)
115 if (candidates[0].commits > nextCandidate.commits)
116 candidates = [nextCandidate]
117 // The number 10000 is principally arbitrary, but the GitHub API returns this value
118 // when the number of commits exceeds it in reality. The difference between two stable releases
119 // is certainly more than 10k commits, thus this works for us as well: If we're targeting
120 // a wrong release, the number *will* be 10000.
121 if (candidates[0].commits < 10000) break
122 }
123
124 core.info(`This PR is for NixOS ${candidates[0].version}.`)
125
126 // Secondary development branches for the selected version only.
127 const secondary = branches.filter(
128 ({ branch, type, version }) =>
129 type.includes('secondary') && version === candidates[0].version,
130 )
131
132 // Make sure that we always check the current target as well, even if its a WIP branch.
133 secondary.push(classify(base.ref))
134
135 for (const branch of secondary) {
136 const nextCandidate = await mergeBase(branch)
137 if (candidates[0].commits === nextCandidate.commits)
138 candidates.push(nextCandidate)
139 if (candidates[0].commits > nextCandidate.commits)
140 candidates = [nextCandidate]
141 }
142
143 // If the current branch is among the candidates, this is always better than any other,
144 // thus sorting at -1.
145 candidates = candidates
146 .map((candidate) =>
147 candidate.branch === base.ref
148 ? { ...candidate, order: -1 }
149 : candidate,
150 )
151 .sort((a, b) => a.order - b.order)
152
153 const best = candidates.at(0)
154
155 core.info('The base branches for this PR are:')
156 core.info(`github: ${base.ref}`)
157 core.info(
158 `candidates: ${candidates.map(({ branch }) => branch).join(',')}`,
159 )
160 core.info(`best candidate: ${best.branch}`)
161
162 if (best.branch !== base.ref) {
163 const current = await mergeBase(classify(base.ref))
164 const body = [
165 `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:`,
166 `- 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).`,
167 `- If the changes should go to the \`${current.branch}\` branch, rebase your PR onto the correct merge-base:`,
168 ' ```bash',
169 ` # git rebase --onto $(git merge-base upstream/${current.branch} HEAD) $(git merge-base upstream/${best.branch} HEAD)`,
170 ` git rebase --onto ${current.sha} ${best.sha}`,
171 ` git push --force-with-lease`,
172 ' ```',
173 ].join('\n')
174
175 await postReview({ github, context, core, dry, body, reviewKey })
176
177 throw new Error(`The PR contains commits from a different base.`)
178 }
179 }
180
181 await dismissReviews({ github, context, core, dry, reviewKey })
182
183 let mergedSha, targetSha
184
185 if (prInfo.mergeable) {
186 core.info('The PR can be merged.')
187
188 mergedSha = prInfo.merge_commit_sha
189 targetSha = (
190 await github.rest.repos.getCommit({
191 ...context.repo,
192 ref: prInfo.merge_commit_sha,
193 })
194 ).data.parents[0].sha
195 } else {
196 core.warning('The PR has a merge conflict.')
197
198 mergedSha = head.sha
199 targetSha = (
200 await github.rest.repos.compareCommitsWithBasehead({
201 ...context.repo,
202 basehead: `${base.sha}...${head.sha}`,
203 })
204 ).data.merge_base_commit.sha
205 }
206
207 core.info(
208 `Checking the commits:\nmerged: ${mergedSha}\ntarget: ${targetSha}`,
209 )
210 core.setOutput('mergedSha', mergedSha)
211 core.setOutput('targetSha', targetSha)
212
213 const systems = await supportedSystems({ github, context, targetSha })
214 core.setOutput('systems', systems)
215
216 const files = (
217 await github.paginate(github.rest.pulls.listFiles, {
218 ...context.repo,
219 pull_number: context.payload.pull_request.number,
220 per_page: 100,
221 })
222 ).map((file) => file.filename)
223
224 const touched = []
225 if (files.includes('ci/pinned.json')) touched.push('pinned')
226 core.setOutput('touched', touched)
227
228 return
229 }
230 throw new Error(
231 "Not retrying anymore. It's likely that GitHub is having internal issues: check https://www.githubstatus.com.",
232 )
233}