|
| 1 | +name: 'Dependabot Scan' |
| 2 | +description: > |
| 3 | + Scans one repository for open Dependabot pull requests and the current state of its |
| 4 | + Dependabot update jobs, classifies each PR, and writes the result as JSON. Read-only, |
| 5 | + so it is shared by the reporting and triage workflows. |
| 6 | +
|
| 7 | +inputs: |
| 8 | + repo: |
| 9 | + description: 'Full repository path (e.g. spring-cloud/spring-cloud-build)' |
| 10 | + required: true |
| 11 | + project: |
| 12 | + description: 'Spring Cloud project key (e.g. spring-cloud-build)' |
| 13 | + required: true |
| 14 | + type: |
| 15 | + description: 'Repository flavor: oss or commercial' |
| 16 | + required: true |
| 17 | + maintained-branches: |
| 18 | + description: > |
| 19 | + Comma-separated branches this repo actively maintains, from projects.json |
| 20 | + (branches.scheduled). A PR against anything else is reported as invalid. |
| 21 | + Comma-separated rather than JSON because a matrix cannot carry structured data |
| 22 | + through an expression without toJson pretty-printing it and breaking the YAML. |
| 23 | + required: true |
| 24 | + extra-branches: |
| 25 | + description: > |
| 26 | + Comma-separated branches that are maintained but are not release branches, so they |
| 27 | + never appear in projects.json. Dependabot legitimately targets these. |
| 28 | + required: false |
| 29 | + default: 'docs-build' |
| 30 | + releaser-map-file: |
| 31 | + description: > |
| 32 | + Path to a JSON file of {type: {project: {version: train}}}, used to resolve which |
| 33 | + GitHub Project a PR belongs to. Empty disables project resolution. |
| 34 | + required: false |
| 35 | + default: '' |
| 36 | + token: |
| 37 | + description: 'GitHub token with read access to the repository' |
| 38 | + required: true |
| 39 | + |
| 40 | +outputs: |
| 41 | + result-file: |
| 42 | + description: 'Path of the JSON file written' |
| 43 | + value: ${{ steps.scan.outputs.result-file }} |
| 44 | + safe-name: |
| 45 | + description: 'Filename-safe form of the repo, for artifact naming' |
| 46 | + value: ${{ steps.scan.outputs.safe-name }} |
| 47 | + |
| 48 | +runs: |
| 49 | + using: composite |
| 50 | + steps: |
| 51 | + - name: Scan repository |
| 52 | + id: scan |
| 53 | + shell: bash |
| 54 | + env: |
| 55 | + GH_TOKEN: ${{ inputs.token }} |
| 56 | + REPO: ${{ inputs.repo }} |
| 57 | + PROJECT: ${{ inputs.project }} |
| 58 | + TYPE: ${{ inputs.type }} |
| 59 | + MAINTAINED_BRANCHES: ${{ inputs.maintained-branches }} |
| 60 | + EXTRA_BRANCHES: ${{ inputs.extra-branches }} |
| 61 | + RELEASER_MAP_FILE: ${{ inputs.releaser-map-file }} |
| 62 | + run: | |
| 63 | + node - << 'JSEOF' |
| 64 | + const fs = require('fs'); |
| 65 | + const { execFileSync } = require('child_process'); |
| 66 | +
|
| 67 | + const REPO = process.env.REPO; |
| 68 | + const PROJECT = process.env.PROJECT; |
| 69 | + const TYPE = process.env.TYPE; |
| 70 | +
|
| 71 | + const parseJson = (raw, fallback) => { |
| 72 | + try { return JSON.parse(raw); } catch (_) { return fallback; } |
| 73 | + }; |
| 74 | +
|
| 75 | + const maintained = new Set(); |
| 76 | + for (const source of [process.env.MAINTAINED_BRANCHES, process.env.EXTRA_BRANCHES]) { |
| 77 | + for (const b of (source || '').split(',')) { |
| 78 | + const t = b.trim(); |
| 79 | + if (t) maintained.add(t); |
| 80 | + } |
| 81 | + } |
| 82 | +
|
| 83 | + // {type: {project: {version: train}}}, written once by the calling workflow so |
| 84 | + // the releaser config is fetched a single time rather than once per repo. |
| 85 | + let releaserMap = {}; |
| 86 | + const mapFile = (process.env.RELEASER_MAP_FILE || '').trim(); |
| 87 | + if (mapFile && fs.existsSync(mapFile)) { |
| 88 | + const all = parseJson(fs.readFileSync(mapFile, 'utf8'), {}) || {}; |
| 89 | + releaserMap = (all[TYPE] || {})[PROJECT] || {}; |
| 90 | + } |
| 91 | +
|
| 92 | + const gh = args => { |
| 93 | + try { |
| 94 | + return { ok: true, out: execFileSync('gh', args, |
| 95 | + { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 1 << 26 }) }; |
| 96 | + } catch (err) { |
| 97 | + return { ok: false, out: '', err: (err.stderr || err.message || '').split('\n')[0].trim() }; |
| 98 | + } |
| 99 | + }; |
| 100 | +
|
| 101 | + // GitHub's API - the GraphQL endpoint behind `gh pr list` especially - returns |
| 102 | + // the occasional 502/504. Without a retry those surface as "0 open PRs", which |
| 103 | + // reads as good news and is the most dangerous way for this report to be wrong. |
| 104 | + const ghRetry = (args, attempts = 3) => { |
| 105 | + let last; |
| 106 | + for (let attempt = 1; attempt <= attempts; attempt++) { |
| 107 | + last = gh(args); |
| 108 | + if (last.ok) return last; |
| 109 | + if (attempt < attempts) { |
| 110 | + console.log(` attempt ${attempt} failed (${last.err}) - retrying`); |
| 111 | + execFileSync('sleep', [String(attempt * 3)]); |
| 112 | + } |
| 113 | + } |
| 114 | + return last; |
| 115 | + }; |
| 116 | +
|
| 117 | + const warnings = []; |
| 118 | +
|
| 119 | + // ── Is the repository readable at all? ────────────────────────────────────── |
| 120 | + // `gh pr list` answers with an empty array and exit 0 for a repository it cannot |
| 121 | + // see, so a renamed, deleted or permission-denied repo would otherwise be |
| 122 | + // reported as simply having no open Dependabot PRs. One explicit existence check |
| 123 | + // turns that silent zero into a visible error. |
| 124 | + const repoResp = ghRetry(['api', `repos/${REPO}`, '--jq', '.full_name']); |
| 125 | + const repoUnreadable = !repoResp.ok; |
| 126 | + if (repoUnreadable) { |
| 127 | + warnings.push(`repository is not readable: ${repoResp.err}`); |
| 128 | + } |
| 129 | +
|
| 130 | + // ── Dependabot update jobs ────────────────────────────────────────────────── |
| 131 | + // Dependabot's own update runs surface as normal workflow runs with |
| 132 | + // event=dynamic and path=dynamic/dependabot/dependabot-updates. The run name |
| 133 | + // encodes the ecosystem and directory, e.g. "maven in /. - Update #1510829977". |
| 134 | + const updateJobs = []; |
| 135 | + let updateJobsFailed = false; |
| 136 | + const runsResp = ghRetry(['api', |
| 137 | + `repos/${REPO}/actions/runs?actor=dependabot%5Bbot%5D&event=dynamic&per_page=100`]); |
| 138 | + if (!runsResp.ok) { |
| 139 | + updateJobsFailed = true; |
| 140 | + warnings.push(`could not list Dependabot update runs: ${runsResp.err}`); |
| 141 | + } else { |
| 142 | + const runs = parseJson(runsResp.out, {}).workflow_runs || []; |
| 143 | + // Runs come back newest-first, so the first entry seen for a given |
| 144 | + // ecosystem/directory/branch is that update's current state. Older runs for |
| 145 | + // the same key are history and are ignored - the question is "is this update |
| 146 | + // broken now?", not "has it ever failed?". |
| 147 | + const seen = new Set(); |
| 148 | + for (const run of runs) { |
| 149 | + const m = (run.name || '').match(/^(\S+) in (\S+) - Update #(\d+)$/); |
| 150 | + const ecosystem = m ? m[1] : (run.name || 'unknown'); |
| 151 | + const directory = m ? m[2] : '?'; |
| 152 | + const key = `${ecosystem}|${directory}|${run.head_branch}`; |
| 153 | + if (seen.has(key)) continue; |
| 154 | + seen.add(key); |
| 155 | + updateJobs.push({ |
| 156 | + ecosystem, directory, |
| 157 | + branch: run.head_branch, |
| 158 | + status: run.status, |
| 159 | + conclusion: run.conclusion || 'none', |
| 160 | + url: run.html_url, |
| 161 | + createdAt: run.created_at, |
| 162 | + }); |
| 163 | + } |
| 164 | + } |
| 165 | + const failingUpdateJobs = updateJobs.filter(j => j.conclusion === 'failure'); |
| 166 | +
|
| 167 | + // ── Open Dependabot pull requests ─────────────────────────────────────────── |
| 168 | + const PR_FIELDS = 'number,title,url,baseRefName,createdAt,updatedAt,headRefOid,' + |
| 169 | + 'mergeable,mergeStateStatus,statusCheckRollup,milestone'; |
| 170 | +
|
| 171 | + const listPrs = () => { |
| 172 | + const r = ghRetry(['pr', 'list', '--repo', REPO, '--author', 'app/dependabot', |
| 173 | + '--state', 'open', '--limit', '100', '--json', PR_FIELDS]); |
| 174 | + if (!r.ok) { |
| 175 | + warnings.push(`could not list Dependabot PRs: ${r.err}`); |
| 176 | + return null; |
| 177 | + } |
| 178 | + return parseJson(r.out, []); |
| 179 | + }; |
| 180 | +
|
| 181 | + // A repo whose PR list could not be read is reported as unscannable rather than |
| 182 | + // as having no open PRs - see prListFailed in the result below. |
| 183 | + let prs = listPrs(); |
| 184 | + const prListFailed = prs === null; |
| 185 | + if (prListFailed) prs = []; |
| 186 | +
|
| 187 | + // GitHub computes mergeability lazily and returns UNKNOWN until it has. Asking |
| 188 | + // for the PR is itself what triggers the computation, so a single re-read a few |
| 189 | + // seconds later resolves it in practice. |
| 190 | + if (prs.some(pr => pr.mergeable === 'UNKNOWN')) { |
| 191 | + execFileSync('sleep', ['5']); |
| 192 | + const retried = listPrs(); |
| 193 | + if (retried) prs = retried; |
| 194 | + } |
| 195 | +
|
| 196 | + // A check is either a CheckRun (status + conclusion) or a legacy StatusContext |
| 197 | + // (state). Both appear in statusCheckRollup, so they are normalised to one shape. |
| 198 | + const FAILING = new Set(['FAILURE', 'TIMED_OUT', 'ERROR', 'STARTUP_FAILURE', 'CANCELLED']); |
| 199 | + const PASSING = new Set(['SUCCESS', 'NEUTRAL', 'SKIPPED']); |
| 200 | +
|
| 201 | + const normalizeCheck = c => { |
| 202 | + if (c.__typename === 'StatusContext' || c.state) { |
| 203 | + return { name: c.context || 'status', state: (c.state || '').toUpperCase(), url: c.targetUrl || '' }; |
| 204 | + } |
| 205 | + const status = (c.status || '').toUpperCase(); |
| 206 | + if (status && status !== 'COMPLETED') { |
| 207 | + return { name: c.name || 'check', state: 'PENDING', url: c.detailsUrl || '' }; |
| 208 | + } |
| 209 | + return { name: c.name || 'check', state: (c.conclusion || 'PENDING').toUpperCase(), url: c.detailsUrl || '' }; |
| 210 | + }; |
| 211 | +
|
| 212 | + // Project version of a branch: strip <parent> so its <version> is not mistaken |
| 213 | + // for the project's own, then take the first <version> that remains. |
| 214 | + const versionCache = new Map(); |
| 215 | + const branchVersion = branch => { |
| 216 | + if (versionCache.has(branch)) return versionCache.get(branch); |
| 217 | + let version = null; |
| 218 | + const r = gh(['api', `repos/${REPO}/contents/pom.xml?ref=${branch}`, '--jq', '.content']); |
| 219 | + if (r.ok) { |
| 220 | + const pom = Buffer.from(r.out.trim(), 'base64').toString('utf8'); |
| 221 | + const m = pom.replace(/<parent>[\s\S]*?<\/parent>/, '').match(/<version>([^<]+)<\/version>/); |
| 222 | + if (m) version = m[1].trim(); |
| 223 | + } |
| 224 | + versionCache.set(branch, version); |
| 225 | + return version; |
| 226 | + }; |
| 227 | +
|
| 228 | + // Existing milestone titles, fetched once and reused for every PR in this repo. |
| 229 | + let milestoneTitles = null; |
| 230 | + const milestoneExists = title => { |
| 231 | + if (milestoneTitles === null) { |
| 232 | + const r = gh(['api', `repos/${REPO}/milestones?state=open&per_page=100`, |
| 233 | + '--jq', '[.[].title]']); |
| 234 | + milestoneTitles = r.ok ? new Set(parseJson(r.out, [])) : new Set(); |
| 235 | + if (!r.ok) warnings.push(`could not list milestones: ${r.err}`); |
| 236 | + } |
| 237 | + return milestoneTitles.has(title); |
| 238 | + }; |
| 239 | +
|
| 240 | + const scanned = prs.map(pr => { |
| 241 | + const checks = (pr.statusCheckRollup || []).map(normalizeCheck); |
| 242 | + const failing = checks.filter(c => FAILING.has(c.state)); |
| 243 | + const pending = checks.filter(c => !FAILING.has(c.state) && !PASSING.has(c.state)); |
| 244 | +
|
| 245 | + const isMaintained = maintained.has(pr.baseRefName); |
| 246 | +
|
| 247 | + // First match wins. |
| 248 | + // |
| 249 | + // The order matters most for retired branches: those are locked, so their PRs |
| 250 | + // report mergeStateStatus BLOCKED even with every check green. Testing |
| 251 | + // maintenance first keeps a locked branch from ever being described in terms |
| 252 | + // of its checks - the actionable fact is that the branch is gone. |
| 253 | + // |
| 254 | + // Checks are read from statusCheckRollup rather than inferred from |
| 255 | + // mergeStateStatus, which conflates too many causes to be useful here. |
| 256 | + // A green PR that GitHub still refuses to merge (branch protection, an |
| 257 | + // unreported required check) is called "blocked" rather than "ready", so that |
| 258 | + // everything reported as ready can genuinely be merged. |
| 259 | + let state; |
| 260 | + if (!isMaintained) state = 'unmaintained'; |
| 261 | + else if (pr.mergeable === 'CONFLICTING') state = 'conflicting'; |
| 262 | + else if (failing.length) state = 'failing'; |
| 263 | + else if (pending.length) state = 'pending'; |
| 264 | + else if (pr.mergeable !== 'MERGEABLE') state = 'unknown'; |
| 265 | + else if (pr.mergeStateStatus === 'BLOCKED') state = 'blocked'; |
| 266 | + else state = 'ready'; |
| 267 | +
|
| 268 | + // Milestone and project are only meaningful for a branch we still maintain. |
| 269 | + let expectedMilestone = null, milestoneState = 'n/a'; |
| 270 | + let expectedProject = null, projectState = 'n/a'; |
| 271 | +
|
| 272 | + if (isMaintained) { |
| 273 | + const version = branchVersion(pr.baseRefName); |
| 274 | + if (!version) { |
| 275 | + milestoneState = 'unresolved'; |
| 276 | + projectState = 'unresolved'; |
| 277 | + } else { |
| 278 | + expectedMilestone = version.replace(/-SNAPSHOT$/, ''); |
| 279 | + const current = pr.milestone?.title || null; |
| 280 | + if (current === expectedMilestone) milestoneState = 'set'; |
| 281 | + else if (current) milestoneState = 'mismatch'; |
| 282 | + else if (!milestoneExists(expectedMilestone)) milestoneState = 'missing'; |
| 283 | + else milestoneState = 'unset'; |
| 284 | +
|
| 285 | + // Boards are OSS-only by design; commercial PRs get a milestone alone. |
| 286 | + if (TYPE === 'oss') { |
| 287 | + expectedProject = releaserMap[version] || null; |
| 288 | + projectState = expectedProject ? 'resolved' : 'unresolved'; |
| 289 | + } |
| 290 | + } |
| 291 | + } |
| 292 | +
|
| 293 | + return { |
| 294 | + number: pr.number, |
| 295 | + title: pr.title, |
| 296 | + url: pr.url, |
| 297 | + baseRefName: pr.baseRefName, |
| 298 | + createdAt: pr.createdAt, |
| 299 | + headRefOid: pr.headRefOid, |
| 300 | + mergeable: pr.mergeable, |
| 301 | + mergeStateStatus: pr.mergeStateStatus, |
| 302 | + currentMilestone: pr.milestone?.title || null, |
| 303 | + state, |
| 304 | + failingChecks: failing.map(c => c.name), |
| 305 | + pendingChecks: pending.map(c => c.name), |
| 306 | + expectedMilestone, milestoneState, |
| 307 | + expectedProject, projectState, |
| 308 | + }; |
| 309 | + }); |
| 310 | +
|
| 311 | + const countOf = s => scanned.filter(p => p.state === s).length; |
| 312 | + const counts = { |
| 313 | + open: scanned.length, |
| 314 | + ready: countOf('ready'), |
| 315 | + blocked: countOf('blocked'), |
| 316 | + failing: countOf('failing'), |
| 317 | + conflicting: countOf('conflicting'), |
| 318 | + pending: countOf('pending'), |
| 319 | + unknown: countOf('unknown'), |
| 320 | + unmaintained: countOf('unmaintained'), |
| 321 | + }; |
| 322 | +
|
| 323 | + const result = { |
| 324 | + project: PROJECT, repo: REPO, type: TYPE, |
| 325 | + counts, |
| 326 | + repoUnreadable, |
| 327 | + prListFailed, |
| 328 | + updateJobsFailed, |
| 329 | + updateJobsChecked: updateJobs.length, |
| 330 | + failingUpdateJobs, |
| 331 | + prs: scanned, |
| 332 | + warnings, |
| 333 | + }; |
| 334 | +
|
| 335 | + const safe = `${REPO}`.replace(/\//g, '-'); |
| 336 | + const file = `result-${safe}.json`; |
| 337 | + fs.writeFileSync(file, JSON.stringify(result, null, 2)); |
| 338 | +
|
| 339 | + console.log(`${REPO}: ${counts.open} open PR(s) - ` + |
| 340 | + `${counts.ready} ready, ${counts.blocked} blocked, ${counts.failing} failing, ` + |
| 341 | + `${counts.conflicting} conflicting, ${counts.pending} pending, ` + |
| 342 | + `${counts.unknown} unknown, ${counts.unmaintained} unmaintained`); |
| 343 | + console.log(`${REPO}: ${failingUpdateJobs.length} failing update job(s) of ${updateJobs.length} tracked`); |
| 344 | + for (const w of warnings) console.log(` warning: ${w}`); |
| 345 | +
|
| 346 | + fs.appendFileSync(process.env.GITHUB_OUTPUT, `result-file=${file}\n`); |
| 347 | + fs.appendFileSync(process.env.GITHUB_OUTPUT, `safe-name=${safe}\n`); |
| 348 | + JSEOF |
0 commit comments