PR build size comment #66
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: PR build size comment | |
| on: | |
| workflow_run: | |
| workflows: [PR build size] | |
| types: [completed] | |
| permissions: | |
| actions: read | |
| pull-requests: write | |
| concurrency: | |
| group: >- | |
| pr-build-size-comment-${{ github.event.workflow_run.head_repository.id || github.event.workflow_run.id }}-${{ github.event.workflow_run.head_branch || github.event.workflow_run.id }} | |
| cancel-in-progress: false | |
| jobs: | |
| comment: | |
| if: >- | |
| github.event.workflow_run.event == 'pull_request' && | |
| github.event.workflow_run.conclusion == 'success' && | |
| github.event.workflow_run.head_repository.id != null && | |
| github.event.workflow_run.head_branch != null | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 5 | |
| steps: | |
| - name: Inspect report artifact | |
| id: artifact | |
| env: | |
| GITHUB_TOKEN: ${{ github.token }} | |
| REPOSITORY: ${{ github.repository }} | |
| RUN_ID: ${{ github.event.workflow_run.id }} | |
| run: | | |
| node --input-type=module <<'NODE' | |
| import { appendFileSync } from 'node:fs'; | |
| const expectedName = 'bento-pr-build-size'; | |
| const maximumArtifactBytes = 16 * 1024; | |
| const runId = Number(process.env.RUN_ID); | |
| function finish(download, reason) { | |
| appendFileSync(process.env.GITHUB_OUTPUT, `download=${download}\n`); | |
| console.log(reason); | |
| } | |
| if (!Number.isSafeInteger(runId) || runId <= 0) { | |
| finish('false', 'No report: invalid workflow-run metadata.'); | |
| process.exit(0); | |
| } | |
| const artifacts = []; | |
| for (let page = 1; page <= 10; page += 1) { | |
| const url = new URL( | |
| `https://api.github.com/repos/${process.env.REPOSITORY}/actions/runs/${runId}/artifacts`, | |
| ); | |
| url.searchParams.set('per_page', '100'); | |
| url.searchParams.set('page', String(page)); | |
| const response = await fetch(url, { | |
| headers: { | |
| Accept: 'application/vnd.github+json', | |
| Authorization: `Bearer ${process.env.GITHUB_TOKEN}`, | |
| 'X-GitHub-Api-Version': '2022-11-28', | |
| }, | |
| }); | |
| if (!response.ok) { | |
| throw new Error(`Artifact listing failed with status ${response.status}.`); | |
| } | |
| const payload = await response.json(); | |
| if (!Array.isArray(payload.artifacts)) { | |
| finish('false', 'No report: malformed artifact listing.'); | |
| process.exit(0); | |
| } | |
| artifacts.push(...payload.artifacts); | |
| if (payload.artifacts.length < 100) break; | |
| if (page === 10) { | |
| finish('false', 'No report: artifact listing exceeded the scan limit.'); | |
| process.exit(0); | |
| } | |
| } | |
| const matches = artifacts.filter( | |
| (artifact) => artifact?.name === expectedName && artifact?.expired === false, | |
| ); | |
| if (matches.length !== 1) { | |
| finish('false', 'No report: expected exactly one current report artifact.'); | |
| process.exit(0); | |
| } | |
| const size = matches[0].size_in_bytes; | |
| if (!Number.isSafeInteger(size) || size <= 0 || size > maximumArtifactBytes) { | |
| finish('false', 'No report: artifact size is outside the allowed range.'); | |
| process.exit(0); | |
| } | |
| finish('true', 'Report artifact metadata is valid.'); | |
| NODE | |
| - name: Download report artifact | |
| id: download | |
| if: steps.artifact.outputs.download == 'true' | |
| continue-on-error: true | |
| uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 | |
| with: | |
| name: bento-pr-build-size | |
| path: ${{ runner.temp }}/bento-pr-build-size | |
| repository: ${{ github.repository }} | |
| run-id: ${{ github.event.workflow_run.id }} | |
| github-token: ${{ github.token }} | |
| - name: Validate report and update comment | |
| if: >- | |
| steps.artifact.outputs.download == 'true' && | |
| steps.download.outcome == 'success' | |
| env: | |
| BASE_REPOSITORY_ID: ${{ github.repository_id }} | |
| GITHUB_TOKEN: ${{ github.token }} | |
| HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }} | |
| HEAD_REPOSITORY_ID: ${{ github.event.workflow_run.head_repository.id }} | |
| HEAD_SHA: ${{ github.event.workflow_run.head_sha }} | |
| REPORT_DIRECTORY: ${{ runner.temp }}/bento-pr-build-size | |
| REPOSITORY: ${{ github.repository }} | |
| run: | | |
| node --input-type=module <<'NODE' | |
| import { existsSync, lstatSync, readFileSync, readdirSync } from 'node:fs'; | |
| import { join } from 'node:path'; | |
| const marker = '<!-- bento-build-size-report -->'; | |
| const botId = 41898282; | |
| const maximumJsonBytes = 16 * 1024; | |
| const expectedApps = [ | |
| { id: 'bento/slides', label: '`bento/slides`' }, | |
| { id: 'bento/spaces', label: '`bento/spaces`' }, | |
| { id: 'bento/dash', label: '`bento/dash`' }, | |
| ]; | |
| function noOp(reason) { | |
| console.log(`No comment: ${reason}`); | |
| process.exit(0); | |
| } | |
| function hasExactKeys(value, keys) { | |
| return value !== null && | |
| typeof value === 'object' && | |
| !Array.isArray(value) && | |
| Object.keys(value).sort().join('\0') === [...keys].sort().join('\0'); | |
| } | |
| function isSha(value) { | |
| return typeof value === 'string' && /^[0-9a-f]{40}$/.test(value); | |
| } | |
| function isSize(value) { | |
| return Number.isSafeInteger(value) && value >= 0; | |
| } | |
| async function api(path, options = {}) { | |
| const response = await fetch(`https://api.github.com${path}`, { | |
| method: options.method ?? 'GET', | |
| headers: { | |
| Accept: 'application/vnd.github+json', | |
| Authorization: `Bearer ${process.env.GITHUB_TOKEN}`, | |
| 'Content-Type': 'application/json', | |
| 'X-GitHub-Api-Version': '2022-11-28', | |
| }, | |
| body: options.body === undefined ? undefined : JSON.stringify(options.body), | |
| }); | |
| if (response.status === 404 && options.allowNotFound === true) { | |
| return null; | |
| } | |
| if (!response.ok) { | |
| throw new Error(`GitHub API request failed with status ${response.status}.`); | |
| } | |
| return response.status === 204 ? null : response.json(); | |
| } | |
| if (!existsSync(process.env.REPORT_DIRECTORY)) { | |
| noOp('artifact download produced no report directory.'); | |
| } | |
| const entries = readdirSync(process.env.REPORT_DIRECTORY, { withFileTypes: true }); | |
| if (entries.length !== 1 || entries[0].name !== 'report.json' || !entries[0].isFile()) { | |
| noOp('artifact contents were not exactly the expected JSON file.'); | |
| } | |
| const reportPath = join(process.env.REPORT_DIRECTORY, 'report.json'); | |
| const reportStat = lstatSync(reportPath); | |
| if (!reportStat.isFile() || reportStat.size <= 0 || reportStat.size > maximumJsonBytes) { | |
| noOp('report JSON size is outside the allowed range.'); | |
| } | |
| let report; | |
| try { | |
| report = JSON.parse(readFileSync(reportPath, 'utf8')); | |
| } catch { | |
| noOp('report JSON is malformed.'); | |
| } | |
| if (!hasExactKeys(report, ['schemaVersion', 'pullRequest', 'baseSha', 'headSha', 'apps'])) { | |
| noOp('report schema is invalid.'); | |
| } | |
| if (report.schemaVersion !== 1 || | |
| !Number.isSafeInteger(report.pullRequest) || | |
| report.pullRequest <= 0 || | |
| !isSha(report.baseSha) || | |
| !isSha(report.headSha) || | |
| !Array.isArray(report.apps) || | |
| report.apps.length !== expectedApps.length) { | |
| noOp('report values are invalid.'); | |
| } | |
| const sizes = new Map(); | |
| for (const value of report.apps) { | |
| if (!hasExactKeys(value, ['app', 'baseBytes', 'headBytes']) || | |
| !expectedApps.some((app) => app.id === value.app) || | |
| sizes.has(value.app) || | |
| !isSize(value.baseBytes) || | |
| !isSize(value.headBytes)) { | |
| noOp('report app data is invalid.'); | |
| } | |
| sizes.set(value.app, value); | |
| } | |
| if (expectedApps.some((app) => !sizes.has(app.id))) { | |
| noOp('report app data is incomplete.'); | |
| } | |
| const baseRepositoryId = Number(process.env.BASE_REPOSITORY_ID); | |
| const headRepositoryId = Number(process.env.HEAD_REPOSITORY_ID); | |
| if (!Number.isSafeInteger(baseRepositoryId) || baseRepositoryId <= 0 || | |
| !Number.isSafeInteger(headRepositoryId) || headRepositoryId <= 0 || | |
| !isSha(process.env.HEAD_SHA) || | |
| report.headSha !== process.env.HEAD_SHA || | |
| typeof process.env.HEAD_BRANCH !== 'string' || | |
| process.env.HEAD_BRANCH.length === 0) { | |
| noOp('workflow-run metadata is invalid or does not match the report.'); | |
| } | |
| const pullPath = `/repos/${process.env.REPOSITORY}/pulls/${report.pullRequest}`; | |
| function validPull(pull) { | |
| return pull?.number === report.pullRequest && | |
| pull?.state === 'open' && | |
| pull?.merged_at === null && | |
| pull?.base?.repo?.id === baseRepositoryId && | |
| pull?.base?.repo?.full_name?.toLowerCase() === process.env.REPOSITORY.toLowerCase() && | |
| typeof pull?.base?.ref === 'string' && | |
| pull.base.ref.length > 0 && | |
| pull?.base?.sha === report.baseSha && | |
| pull?.head?.repo?.id === headRepositoryId && | |
| pull?.head?.ref === process.env.HEAD_BRANCH && | |
| pull?.head?.sha === report.headSha; | |
| } | |
| let pull = await api(pullPath, { allowNotFound: true }); | |
| if (!validPull(pull)) { | |
| noOp('the pull request is closed, merged, stale, or does not match the workflow run.'); | |
| } | |
| const matchingComments = []; | |
| for (let page = 1; page <= 100; page += 1) { | |
| const comments = await api( | |
| `/repos/${process.env.REPOSITORY}/issues/${report.pullRequest}/comments?per_page=100&page=${page}`, | |
| ); | |
| if (!Array.isArray(comments)) { | |
| noOp('pull-request comments response was malformed.'); | |
| } | |
| matchingComments.push(...comments.filter( | |
| (comment) => comment?.user?.login === 'github-actions[bot]' && | |
| comment?.user?.id === botId && | |
| typeof comment?.body === 'string' && | |
| (comment.body === marker || comment.body.startsWith(`${marker}\n`)), | |
| )); | |
| if (comments.length < 100) break; | |
| if (page === 100) { | |
| noOp('pull request has too many comments to scan safely.'); | |
| } | |
| } | |
| if (matchingComments.length > 1) { | |
| noOp('multiple matching bot comments already exist.'); | |
| } | |
| function kib(bytes) { | |
| return `${(bytes / 1024).toFixed(1)} KiB`; | |
| } | |
| function change(baseBytes, headBytes) { | |
| const delta = headBytes - baseBytes; | |
| const absolute = delta > 0 ? `+${kib(delta)}` : kib(delta); | |
| if (baseBytes === 0) { | |
| return `${absolute} (${headBytes === 0 ? '0.00%' : 'n/a'})`; | |
| } | |
| const percent = (delta / baseBytes) * 100; | |
| const formattedPercent = `${percent > 0 ? '+' : ''}${percent.toFixed(2)}%`; | |
| return `${absolute} (${formattedPercent})`; | |
| } | |
| function escapeHtml(value) { | |
| return value | |
| .replaceAll('&', '&') | |
| .replaceAll('<', '<') | |
| .replaceAll('>', '>') | |
| .replaceAll('"', '"') | |
| .replaceAll("'", '''); | |
| } | |
| const rows = expectedApps.map(({ id, label }) => { | |
| const value = sizes.get(id); | |
| return `| ${label} | ${kib(value.baseBytes)} | ${kib(value.headBytes)} | ${change(value.baseBytes, value.headBytes)} |`; | |
| }); | |
| pull = await api(pullPath, { allowNotFound: true }); | |
| if (!validPull(pull)) { | |
| noOp('the pull request changed before the comment could be written.'); | |
| } | |
| const updatedAt = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'); | |
| const body = [ | |
| marker, | |
| '### Build size', | |
| '', | |
| `<code>${escapeHtml(pull.base.ref)}</code> (<code>${report.baseSha.slice(0, 7)}</code>) → <code>${escapeHtml(pull.head.ref)}</code> (<code>${report.headSha.slice(0, 7)}</code>)`, | |
| '', | |
| '| app | base | PR | change |', | |
| '| --- | ---: | ---: | ---: |', | |
| ...rows, | |
| '', | |
| `Updated: \`${updatedAt}\``, | |
| ].join('\n'); | |
| if (matchingComments.length === 1) { | |
| const commentId = matchingComments[0].id; | |
| if (!Number.isSafeInteger(commentId) || commentId <= 0) { | |
| noOp('the existing comment id is invalid.'); | |
| } | |
| await api(`/repos/${process.env.REPOSITORY}/issues/comments/${commentId}`, { | |
| method: 'PATCH', | |
| body: { body }, | |
| }); | |
| console.log('Updated the advisory build-size comment.'); | |
| } else { | |
| await api(`/repos/${process.env.REPOSITORY}/issues/${report.pullRequest}/comments`, { | |
| method: 'POST', | |
| body: { body }, | |
| }); | |
| console.log('Created the advisory build-size comment.'); | |
| } | |
| NODE |