|
| 1 | +name: Resolve PR Release Versions |
| 2 | + |
| 3 | +on: |
| 4 | + workflow_dispatch: |
| 5 | + inputs: |
| 6 | + max_release_count: |
| 7 | + description: Max number (<= 100) of unprocessed releases to process (all if left blank) |
| 8 | + required: false |
| 9 | + type: number |
| 10 | + schedule: |
| 11 | + - cron: '0 */6 * * *' |
| 12 | + |
| 13 | +permissions: |
| 14 | + actions: read |
| 15 | + |
| 16 | +jobs: |
| 17 | + resolve_pr_release_versions: |
| 18 | + name: Resolve PR Release Versions |
| 19 | + runs-on: ubuntu-latest |
| 20 | + steps: |
| 21 | + - name: Restore previous run data |
| 22 | + uses: dawidd6/action-download-artifact@71072fbb1229e1317f1a8de6b04206afb461bd67 # v3.1.2 |
| 23 | + with: |
| 24 | + name: resolved-pr-versions |
| 25 | + if_no_artifact_found: ignore |
| 26 | + workflow_conclusion: 'completed' |
| 27 | + search_artifacts: true |
| 28 | + - run: npm install @electron/fiddle-core |
| 29 | + - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 |
| 30 | + with: |
| 31 | + script: | |
| 32 | + const fs = require('node:fs/promises'); |
| 33 | + const { ElectronVersions } = require('@electron/fiddle-core'); |
| 34 | + const semver = require('semver'); |
| 35 | +
|
| 36 | + // https://github.com/electron/trop/blob/a481299dfd522c7b5c5d10e2355ad9e7f0ce193e/src/utils/branch-util.ts#L90-L94 |
| 37 | + const getBackportPattern = () => |
| 38 | + /(?:^|\n)(?:manual |manually )?backport (?:of )?(?:#(\d+)|https:\/\/github.com\/.*\/pull\/(\d+))/gim; |
| 39 | +
|
| 40 | + const BOOTSTRAP_DATA_URL = 'https://gist.githubusercontent.com/dsanders11/eb51a04d04a6a3e0710d88db5250e698/raw/fd960b6dea1152b55427407646044f1ba187e52b/data.json'; |
| 41 | + const MIN_MAJOR = 10; |
| 42 | + const RELEASE_MAX_PAGINATION_COUNT = 100; |
| 43 | + const NEW_RELEASES_QUERY = `query($endCursor: String, $count: Int!) { |
| 44 | + rateLimit { |
| 45 | + limit |
| 46 | + remaining |
| 47 | + used |
| 48 | + resetAt |
| 49 | + } |
| 50 | + repository(owner: "electron", name: "electron") { |
| 51 | + releases: refs( |
| 52 | + refPrefix: "refs/tags/", |
| 53 | + after: $endCursor, |
| 54 | + first: $count, |
| 55 | + orderBy: {field: TAG_COMMIT_DATE, direction: ASC} |
| 56 | + ) { |
| 57 | + pageInfo { |
| 58 | + endCursor |
| 59 | + hasNextPage |
| 60 | + } |
| 61 | + nodes { |
| 62 | + name |
| 63 | + } |
| 64 | + } |
| 65 | + } |
| 66 | + }`; |
| 67 | + const RELEASE_PRS_QUERY = `query($releaseHeadRef: String!, $previousRelease: String!, $endCursor: String) { |
| 68 | + rateLimit { |
| 69 | + limit |
| 70 | + remaining |
| 71 | + used |
| 72 | + resetAt |
| 73 | + } |
| 74 | + repository(owner: "electron", name: "electron") { |
| 75 | + release: ref(qualifiedName: $previousRelease) { |
| 76 | + compare(headRef: $releaseHeadRef) { |
| 77 | + commits(after: $endCursor, last: 100) { |
| 78 | + pageInfo { |
| 79 | + endCursor |
| 80 | + hasNextPage |
| 81 | + } |
| 82 | + nodes { |
| 83 | + url |
| 84 | + author { |
| 85 | + user { |
| 86 | + login |
| 87 | + } |
| 88 | + } |
| 89 | + associatedPullRequests(first: 20) { |
| 90 | + pageInfo { |
| 91 | + hasNextPage |
| 92 | + } |
| 93 | + nodes { |
| 94 | + labels(first: 20) { |
| 95 | + pageInfo { |
| 96 | + hasNextPage |
| 97 | + } |
| 98 | + nodes { |
| 99 | + name |
| 100 | + } |
| 101 | + } |
| 102 | + number |
| 103 | + bodyText |
| 104 | + state |
| 105 | + } |
| 106 | + } |
| 107 | + } |
| 108 | + } |
| 109 | + } |
| 110 | + } |
| 111 | + } |
| 112 | + }`; |
| 113 | +
|
| 114 | + const maxReleaseCount = ${{ inputs.max_release_count || 0 }}; |
| 115 | +
|
| 116 | + if (maxReleaseCount > 100) { |
| 117 | + core.error('max_release_count must be <= 100'); |
| 118 | + return; |
| 119 | + } |
| 120 | +
|
| 121 | + const filename = 'data.json'; |
| 122 | + let data = { endCursor: undefined, data: {} }; |
| 123 | +
|
| 124 | + try { |
| 125 | + data = JSON.parse(await fs.readFile(filename)); |
| 126 | + } catch (err) { |
| 127 | + if (err.code !== 'ENOENT') { |
| 128 | + throw err; |
| 129 | + } else { |
| 130 | + core.debug('Previous data not found, bootstrapping'); |
| 131 | + const resp = await fetch(BOOTSTRAP_DATA_URL); |
| 132 | + data = await resp.json(); |
| 133 | + } |
| 134 | + } |
| 135 | +
|
| 136 | + const { versions } = await ElectronVersions.create(undefined, { ignoreCache: true }); |
| 137 | +
|
| 138 | + try { |
| 139 | + while (true) { |
| 140 | + const { rateLimit: rateLimitA, repository: { releases } } = await github.graphql(NEW_RELEASES_QUERY, { endCursor: data.endCursor, count: maxReleaseCount === 0 ? RELEASE_MAX_PAGINATION_COUNT : maxReleaseCount }); |
| 141 | + core.debug(rateLimitA); |
| 142 | +
|
| 143 | + if (releases.nodes.length === 0) { |
| 144 | + core.notice('No new releases to process'); |
| 145 | + break; |
| 146 | + } |
| 147 | +
|
| 148 | + for (const { name: tagName } of releases.nodes) { |
| 149 | + const parsedVersion = semver.parse(tagName); |
| 150 | +
|
| 151 | + if (parsedVersion === null) { |
| 152 | + core.error(`Could not parse version from ${tagName} - skipping`); |
| 153 | + continue; |
| 154 | + } else if (parsedVersion.major < MIN_MAJOR) { |
| 155 | + core.debug(`Skipping release ${tagName} as it's before major ${MIN_MAJOR}`); |
| 156 | + continue; |
| 157 | + } |
| 158 | +
|
| 159 | + let idx = versions.findIndex(({ version }) => `v${version}` === tagName); |
| 160 | + if (idx === -1) { |
| 161 | + core.warning(`Could not find release ${tagName} - skipping`); |
| 162 | + continue; |
| 163 | + } else if (idx === 0) { |
| 164 | + core.error(`No previous release for ${tagName} - skipping`); |
| 165 | + continue; |
| 166 | + } |
| 167 | + let previousRelease = versions[--idx]; |
| 168 | +
|
| 169 | + let endCursor = undefined; |
| 170 | + while (true) { |
| 171 | + const { rateLimit: rateLimitB, repository: { release } } = await github.graphql(RELEASE_PRS_QUERY, { endCursor, releaseHeadRef: `tags/${tagName}`, previousRelease: `refs/tags/v${previousRelease}` }); |
| 172 | + core.debug(rateLimitB); |
| 173 | +
|
| 174 | + if (release === null) { |
| 175 | + // There are occasionally missing releases which made it into index.json, so |
| 176 | + // move on to the next previous release until we're back to a valid release |
| 177 | + core.warning(`${previousRelease} is a missing release - skipping`); |
| 178 | + previousRelease = versions[--idx]; |
| 179 | + continue; |
| 180 | + } |
| 181 | +
|
| 182 | + const { compare: { commits } } = release; |
| 183 | +
|
| 184 | + for (const commit of commits.nodes) { |
| 185 | + if (commit.associatedPullRequests.pageInfo.hasNextPage) { |
| 186 | + core.error(`Commit (${commit.url}) had more than expected max associated PRs - skipping`); |
| 187 | + continue; |
| 188 | + } |
| 189 | + const prs = commit.associatedPullRequests.nodes.filter(node => node.state === 'MERGED'); |
| 190 | + if (prs.length !== 1) { |
| 191 | + if (!['electron-bot', 'sudowoodo-release-bot[bot]'].includes(commit.author?.user?.login)) { |
| 192 | + core.warning(`Could not determine PR associated with ${commit.url} - skipping`); |
| 193 | + } else { |
| 194 | + core.debug(`${commit.author?.user?.login} commit, ${commit.url} - skipping`); |
| 195 | + } |
| 196 | + continue; |
| 197 | + } |
| 198 | + const pr = prs[0]; |
| 199 | + if (pr.labels.pageInfo.hasNextPage) { |
| 200 | + core.error(`PR #${pr.number} had more than expected max labels - skipping`); |
| 201 | + continue; |
| 202 | + } |
| 203 | +
|
| 204 | + // |
| 205 | + // We finally have a valid PR to process |
| 206 | + // |
| 207 | +
|
| 208 | + // If it's a backport, include the version number in the root PR's backport list |
| 209 | + const backportPattern = getBackportPattern(); |
| 210 | + const match = backportPattern.exec(pr.bodyText); |
| 211 | + if (match) { |
| 212 | + const rootPr = match[1] ? parseInt(match[1], 10) : parseInt(match[2], 10); |
| 213 | +
|
| 214 | + data.data[rootPr] = data.data[rootPr] ?? { release: null, backports: [] }; |
| 215 | + if (!data.data[rootPr].backports.includes(tagName)) { |
| 216 | + data.data[rootPr].backports.push(tagName); |
| 217 | + } |
| 218 | + } else { |
| 219 | + data.data[pr.number] = data.data[pr.number] ?? { release: null, backports: [] }; |
| 220 | + if (data.data[pr.number].release !== null && data.data[pr.number].release !== tagName) { |
| 221 | + core.error(`PR #${pr.number} already has a different release version than expected (found ${data.data[pr.number].release} but expected ${tagName})`); |
| 222 | + continue; |
| 223 | + } |
| 224 | + data.data[pr.number].release = tagName; |
| 225 | + } |
| 226 | + } |
| 227 | +
|
| 228 | + if (!commits.pageInfo.hasNextPage) { |
| 229 | + break; |
| 230 | + } else { |
| 231 | + endCursor = commits.pageInfo.endCursor; |
| 232 | + } |
| 233 | + } |
| 234 | + } |
| 235 | +
|
| 236 | + // Only update this after all releases have been processed, |
| 237 | + // and make sure it's not null which would happen if there |
| 238 | + // were no new releases to process during the run |
| 239 | + if (releases.pageInfo.endCursor !== null) { |
| 240 | + data.endCursor = releases.pageInfo.endCursor; |
| 241 | + } |
| 242 | +
|
| 243 | + if (releases.pageInfo.hasNextPage && maxReleaseCount === 0) { |
| 244 | + continue; |
| 245 | + } else { |
| 246 | + break; |
| 247 | + } |
| 248 | + } |
| 249 | + } catch (error) { |
| 250 | + if (error instanceof Error && error.stack) core.debug(error.stack); |
| 251 | + core.setFailed(`Error while processing new releases: ${error}`); |
| 252 | + } |
| 253 | +
|
| 254 | + // Write to file to upload as artifact |
| 255 | + await fs.writeFile(filename, JSON.stringify(data)); |
| 256 | + - name: Persist data |
| 257 | + uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1 |
| 258 | + if: ${{ !cancelled() }} |
| 259 | + with: |
| 260 | + name: resolved-pr-versions |
| 261 | + path: data.json |
0 commit comments