Skip to content

PR build size comment #66

PR build size comment

PR build size comment #66

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('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
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