Skip to content

PR Bundle Size Comments | Build - client packages | created | 33d36d62db39ac3906181cdf2745843f74d360fb #184264

PR Bundle Size Comments | Build - client packages | created | 33d36d62db39ac3906181cdf2745843f74d360fb

PR Bundle Size Comments | Build - client packages | created | 33d36d62db39ac3906181cdf2745843f74d360fb #184264

name: "PR Bundle Size Comments"
# Per-run title shown in the Actions list — the static workflow name on its own makes the list
# unscannable when many runs fire on the same workflow. Including the trigger event subtype and
# check name lets us tell at a glance which kind of event each run handled.
run-name: "${{ github.workflow }} | ${{ github.event.check_run.name }} | ${{ github.event.action }} | ${{ github.sha }}"
# This workflow runs the bundle-size comparison for a PR and posts the result back to the PR via a sticky comment.
#
# Triggers are all `check_run`. We use the lifecycle of ADO's own published checks to decide what to do:
#
# check_run.created: when ADO creates the `Build - client packages` check on a commit (at build queue time),
# acknowledge-build posts an initial "pending" sticky on the matching open PR. Today the
# `Build - client packages` pipeline is the only producer of PR-side bundle artifacts, so
# PRs that don't trigger it (e.g. server-only PRs) don't get a sticky — that scope would
# widen if we ever publish bundle artifacts from another pipeline.
# check_run.completed: when a bundle-publishing pipeline's check completes successfully, identify-targets maps
# the check's SHA back to affected PRs and the compare job fans out via matrix. Today there
# are two such pipelines:
# - `Build - Client bundle size artifacts`: main/release pushes (baseline bundle).
# - `Build - client packages`: PR commits (head-side bundle).
#
# Unlike the pr-check-changeset / changeset-reporter pair, this is a single workflow rather than the worker/reporter
# split. The split's primary defense — preventing PR-controlled code from running with write perms — doesn't apply
# here because this workflow never executes PR-authored code: it doesn't check out PR HEAD and never references PR
# sources or scripts, only its SHA (which is forwarded to ADO to fetch a server-side artifact).
on:
check_run:
types: [created, completed]
# Use concurrency to ensure the completed event wins over the created event for a given (SHA, check
# name) — so the results sticky is never clobbered by a still-running "pending" handler. The check name
# is part of the group key because each ADO sub-check fires its own check_run event on the same SHA
# (e.g. `Build - client packages (🔒 SDLSources ...)`, `Build - client packages (Build Stage Build)`)
# and we don't want those to cancel an in-flight acknowledge-build / identify-* run that's processing a
# matching event. Cross-SHA staleness on the PR path (push A then B, then A's build completes anyway)
# is handled in identify-from-pr-build: it searches for an open PR whose current head SHA matches the
# just-completed build, so an event for a stale SHA finds no PR and can't write anything.
concurrency:
group: pr-bundle-size-${{ github.event.check_run.head_sha }}-${{ github.event.check_run.name }}
cancel-in-progress: true
permissions:
contents: read
pull-requests: read
jobs:
# NOTE on the `contains(... '10f9b53e-c7fd-4538-9fff-13cd088a436c')` check that appears in each of the
# triggering jobs below: that GUID is the `public` ADO project under `dev.azure.com/fluidframework`. The
# pipelines whose check_runs we react to all live there. We need the scope because both the `public` and
# `internal` projects publish check_runs with the same name (e.g. `Build - client packages`); without it
# the workflow also fires on `internal`'s check_runs, which we can't process (no anonymous read access)
# and don't produce the artifacts we consume. The GUID appears in `check_run.details_url`; there's no
# structured `project` field in the event payload, so `contains(details_url, '<guid>')` is the only
# stable distinguisher. The project lives at https://dev.azure.com/fluidframework/public.
# Posts the initial "build pending" sticky comment when ADO queues the `Build - client packages` check
# for a PR commit. We only measure bundle size on client packages today, so PRs that don't trigger that
# check (server-only, docs-only) receive no sticky.
acknowledge-build:
if: >-
github.event.action == 'created' &&
github.event.check_run.name == 'Build - client packages' &&
contains(github.event.check_run.details_url, '10f9b53e-c7fd-4538-9fff-13cd088a436c')
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
- name: Resolve PR for check SHA
id: find_pr
# release notes: https://github.com/actions/github-script/releases/tag/v9.0.0
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const { owner, repo } = context.repo;
const sha = context.payload.check_run.head_sha;
// Find the open PR whose head SHA matches this check. A single GraphQL query returns the
// PR number, head SHA, and base ref in one round-trip (we use all three: number to address
// the sticky, head + base to compute merge-base for the pending body). At most one PR is
// expected in practice. Two open PRs *can* share a head SHA (same branch PR'd against two
// base refs), in which case only the first one matched here gets a sticky — accepted
// limitation.
const { search } = await github.graphql(
`query($q: String!) {
search(query: $q, type: ISSUE, first: 1) {
nodes {
... on PullRequest {
number
headRefOid
baseRefName
}
}
}
}`,
{ q: `type:pr is:open repo:${owner}/${repo} head-sha:${sha}` },
);
const pr = search.nodes[0];
if (!pr) {
core.info(`No matching open PR for SHA ${sha}; nothing to do.`);
return;
}
// Server-side merge-base lookup. compareCommits resolves to the SHA on shared history (or
// null on disjoint history) for any successful HTTP response, and throws for HTTP/network/
// rate-limit failure. API failure here is fatal.
let mb;
try {
const { data } = await github.rest.repos.compareCommits({
owner, repo, base: pr.baseRefName, head: pr.headRefOid,
});
mb = data.merge_base_commit?.sha ?? undefined;
} catch (err) {
core.error(`Merge-base lookup failed for PR #${pr.number} (base=${pr.baseRefName}, head=${pr.headRefOid}): ${err.message}`);
throw err;
}
if (!mb) {
core.info(`PR #${pr.number} has no shared history with ${pr.baseRefName}; nothing to do.`);
return;
}
core.info(`Matched PR #${pr.number} head=${pr.headRefOid} base=${pr.baseRefName} mergeBase=${mb}`);
core.setOutput("pr_num", pr.number);
core.setOutput("head_sha", pr.headRefOid);
core.setOutput("merge_base", mb);
- name: Render initial comment body
id: render
if: steps.find_pr.outputs.pr_num != ''
# release notes: https://github.com/actions/github-script/releases/tag/v9.0.0
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
PR_NUM: ${{ steps.find_pr.outputs.pr_num }}
HEAD_SHA: ${{ steps.find_pr.outputs.head_sha }}
MERGE_BASE: ${{ steps.find_pr.outputs.merge_base }}
with:
script: |
const fs = require("fs");
const body = [
"## Bundle size comparison",
"",
`Base commit: \`${process.env.MERGE_BASE}\``,
`Head commit: \`${process.env.HEAD_SHA}\``,
"",
"Pending — `Build - client packages` is running. Results will appear here when the build completes.",
"",
// Hidden footer — invisible in the rendered comment but visible when viewing source. Lets
// us trace a sticky back to the run that last wrote it.
"<!-- pr-bundle-size-comments: run_id=${{ github.run_id }} attempt=${{ github.run_attempt }} -->",
"",
].join("\n");
fs.writeFileSync("acknowledge.md", body);
core.info(`PR #${process.env.PR_NUM}: rendered the following body for the sticky comment.`);
core.startGroup("acknowledge.md");
core.info(body);
core.endGroup();
- if: steps.render.outcome == 'success'
# release notes: https://github.com/marocchino/sticky-pull-request-comment/releases/tag/v3.0.4
uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
with:
header: bundle-size-report
number: ${{ steps.find_pr.outputs.pr_num }}
path: ${{ github.workspace }}/acknowledge.md
# Keep the sticky at the bottom of the timeline on each update.
recreate: true
# Reacts when ADO reports a PR's `Build - client packages` as `neutral` — typically because the
# pipeline's path filter skipped the build (no bundle-relevant changes). If a prior commit on this PR
# produced a real sticky, those numbers are now stale; delete the sticky so the PR doesn't carry
# outdated info. `marocchino/sticky-pull-request-comment` with `delete: true` is a no-op when no
# sticky exists, so PRs that never had one stay clean.
delete-sticky-on-neutral:
if: >-
github.event.action == 'completed' &&
github.event.check_run.name == 'Build - client packages' &&
contains(github.event.check_run.details_url, '10f9b53e-c7fd-4538-9fff-13cd088a436c') &&
github.event.check_run.conclusion == 'neutral'
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
- name: Resolve PR for check SHA
id: find_pr
# release notes: https://github.com/actions/github-script/releases/tag/v9.0.0
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const { owner, repo } = context.repo;
const sha = context.payload.check_run.head_sha;
const { search } = await github.graphql(
`query($q: String!) {
search(query: $q, type: ISSUE, first: 1) {
nodes {
... on PullRequest { number }
}
}
}`,
{ q: `type:pr is:open repo:${owner}/${repo} head-sha:${sha}` },
);
const pr = search.nodes[0];
if (!pr) {
core.info(`No matching open PR for SHA ${sha}; nothing to delete.`);
return;
}
core.info(`Matched PR #${pr.number} — will delete the bundle-size sticky if present.`);
core.setOutput("pr_num", pr.number);
- if: steps.find_pr.outputs.pr_num != ''
# release notes: https://github.com/marocchino/sticky-pull-request-comment/releases/tag/v3.0.4
uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
with:
header: bundle-size-report
number: ${{ steps.find_pr.outputs.pr_num }}
delete: true
# Reacts to a PR's own `Build - client packages` build completing. The check SHA is a PR head SHA, so we
# produce at most one matrix entry — the PR whose head matches. Fires on `success` or `failure` so a
# failed PR build also updates the sticky (the compare job's flub call returns the appropriate failure
# kind and the sticky moves off the acknowledge-build "Pending — …" placeholder), but skips `neutral` /
# `cancelled` / `skipped` so path-filtered PRs (no bundle-relevant changes → ADO reports `neutral`)
# don't get a spurious "Comparison unavailable" sticky.
identify-from-pr-build:
if: >-
github.event.action == 'completed' &&
github.event.check_run.name == 'Build - client packages' &&
contains(github.event.check_run.details_url, '10f9b53e-c7fd-4538-9fff-13cd088a436c') &&
(github.event.check_run.conclusion == 'success' || github.event.check_run.conclusion == 'failure')
runs-on: ubuntu-latest
outputs:
prs: ${{ steps.collect.outputs.prs }}
steps:
- name: Collect affected PRs
id: collect
# release notes: https://github.com/actions/github-script/releases/tag/v9.0.0
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const { owner, repo } = context.repo;
const sha = context.payload.check_run.head_sha;
// Find the open PR whose head SHA matches this check. A single GraphQL query returns the
// PR number, head SHA, and base ref in one round-trip; the REST search API would return
// only the number, requiring a follow-up pulls.get() call. At most one PR is expected in
// practice. Two open PRs *can* share a head SHA (same branch PR'd against two base refs),
// in which case only the first one matched here gets a comparison — accepted limitation.
//
// Also acts as the cross-SHA staleness guard: an event for a stale SHA (e.g. push A then
// B, A's build completes anyway) finds no open PR with head A and emits no matrix entry.
const { search } = await github.graphql(
`query($q: String!) {
search(query: $q, type: ISSUE, first: 1) {
nodes {
... on PullRequest {
number
headRefOid
baseRefName
}
}
}
}`,
{ q: `type:pr is:open repo:${owner}/${repo} head-sha:${sha}` },
);
const pr = search.nodes[0];
if (!pr) {
core.info(`Affected PRs (0): no open PR matches head SHA ${sha}`);
core.setOutput("prs", "[]");
return;
}
// Server-side merge-base lookup. compareCommits resolves to the SHA on shared history (or null
// on disjoint history) for any successful HTTP response, and throws for HTTP/network/rate-limit
// failure — distinguishing "no shared history" (legitimate, emit no entry and stay green) from
// "API call failed" (often actionable, e.g. a rate limit). API failure here is fatal.
let mb;
try {
const { data } = await github.rest.repos.compareCommits({
owner, repo, base: pr.baseRefName, head: pr.headRefOid,
});
mb = data.merge_base_commit?.sha ?? undefined;
} catch (err) {
core.error(`Merge-base lookup failed for PR #${pr.number} (base=${pr.baseRefName}, head=${pr.headRefOid}): ${err.message}`);
throw err;
}
if (!mb) {
core.info(`Affected PRs (0): PR #${pr.number} has no shared history with ${pr.baseRefName}`);
core.setOutput("prs", "[]");
return;
}
const affected = [{
number: pr.number,
head: pr.headRefOid,
baseRef: pr.baseRefName,
mergeBase: mb,
}];
core.info(`Affected PRs (1): #${pr.number} head=${pr.headRefOid} base=${pr.baseRefName} mergeBase=${mb}`);
core.setOutput("prs", JSON.stringify(affected));
# Reacts to the baseline pipeline (`Build - Client bundle size artifacts`) completing on a main/release
# commit. The check SHA is on the base branch, so we produce a matrix entry per open PR whose merge-base
# equals this SHA — typically 0–few PRs. Fires on `success` or `failure` so a baseline failure also
# re-triggers affected PRs (the compare job's flub call returns the appropriate failure kind and the
# sticky updates from "Pending — …" to a per-kind body), but skips `neutral` / `cancelled` / `skipped`
# so path-filtered or otherwise no-op baseline completions don't generate noise.
identify-from-baseline-build:
if: >-
github.event.action == 'completed' &&
github.event.check_run.name == 'Build - Client bundle size artifacts' &&
contains(github.event.check_run.details_url, '10f9b53e-c7fd-4538-9fff-13cd088a436c') &&
(github.event.check_run.conclusion == 'success' || github.event.check_run.conclusion == 'failure')
runs-on: ubuntu-latest
outputs:
prs: ${{ steps.collect.outputs.prs }}
steps:
- name: Collect affected PRs
id: collect
# release notes: https://github.com/actions/github-script/releases/tag/v9.0.0
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const { owner, repo } = context.repo;
const sha = context.payload.check_run.head_sha;
const prs = await github.paginate(github.rest.pulls.list, {
owner, repo, state: "open", per_page: 100,
});
const affected = [];
for (const pr of prs) {
// Server-side merge-base lookup. Resolves to the SHA on shared history (or null on disjoint
// history) for any successful HTTP response, and throws for HTTP/network/rate-limit failure.
// Per-PR API failure is logged and skipped so a transient hit doesn't abort the whole scan;
// disjoint history naturally falls through the match check below.
let mb;
try {
const { data } = await github.rest.repos.compareCommits({
owner, repo, base: pr.base.ref, head: pr.head.sha,
});
mb = data.merge_base_commit?.sha ?? undefined;
} catch (err) {
core.warning(`Skipping PR #${pr.number}: merge-base lookup failed (base=${pr.base.ref}, head=${pr.head.sha}): ${err.message}`);
continue;
}
if (mb === sha) {
affected.push({
number: pr.number,
head: pr.head.sha,
baseRef: pr.base.ref,
mergeBase: mb,
});
}
}
if (affected.length === 0) {
core.info("Affected PRs (0): none");
} else {
core.info(`Affected PRs (${affected.length}):`);
for (const a of affected) {
core.info(` - #${a.number} head=${a.head} base=${a.baseRef} mergeBase=${a.mergeBase}`);
}
}
core.setOutput("prs", JSON.stringify(affected));
compare:
needs: [identify-from-pr-build, identify-from-baseline-build]
# One of the identify-* jobs produces the matrix; the other will be skipped per event type.
# Without `!cancelled()`, GitHub Actions auto-skips a job whose `needs:` had any skipped entry.
if: |
!cancelled() && (
(needs.identify-from-pr-build.result == 'success' && needs.identify-from-pr-build.outputs.prs != '[]') ||
(needs.identify-from-baseline-build.result == 'success' && needs.identify-from-baseline-build.outputs.prs != '[]')
)
# Skipped runs always show the raw expression text — no fallback helps. Keep it simple.
name: "compare (PR #${{ matrix.pr.number }})"
strategy:
matrix:
pr: ${{ fromJSON(needs.identify-from-pr-build.outputs.prs || needs.identify-from-baseline-build.outputs.prs || '[]') }}
fail-fast: false
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
# Check out the PR's base branch (never PR HEAD — PR-authored code isn't trusted) just to get build-tools
# source so we can build flub. The comparison itself reads only ADO artifacts identified by SHA — no local
# git state is required.
# release notes: https://github.com/actions/checkout/releases/tag/v6.0.2
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ matrix.pr.baseRef }}
fetch-depth: "1"
persist-credentials: false
# release notes: https://github.com/pnpm/action-setup/releases/tag/v5.0.0
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
# release notes: https://github.com/actions/setup-node/releases/tag/v6.3.0
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version-file: .nvmrc
cache: "pnpm"
cache-dependency-path: pnpm-lock.yaml
- name: Install Fluid build tools
run: |
cd build-tools
pnpm install --frozen-lockfile
pnpm run build:compile
# Use npm link (not pnpm link) so flub lands on PATH with a proper shim.
cd packages/build-cli
npm link
- name: Compare bundle sizes
# Expected failure modes (missing/in-progress/failed baseline, no
# analyzer.json) come back as a structured `{ kind, side }` payload
# under `--json` and a zero exit — the render step dispatches on
# `kind` to produce a friendly sticky body. Non-zero exit is reserved
# for *unexpected* errors (network, malformed zip, …); upstream oclif
# bug oclif/core#1608 swallows their message in `--json` mode, so we
# re-run without `--json` to surface it in the action log.
run: |
set -uo pipefail
set +e
flub report comparePipelineBundleArtifacts \
--base ${{ matrix.pr.mergeBase }} \
--head ${{ matrix.pr.head }} \
--json > bundle-comparison.json
EC=$?
echo "::group::bundle-comparison.json"
cat bundle-comparison.json
echo "::endgroup::"
if [ "$EC" -ne 0 ]; then
echo
echo "flub --json exited $EC; re-running without --json so the error message is visible:"
flub report comparePipelineBundleArtifacts \
--base ${{ matrix.pr.mergeBase }} \
--head ${{ matrix.pr.head }} 2>&1
exit "$EC"
fi
# Format the JSON into the markdown body the sticky comment posts. Done here (not in flub) so
# the formatting is easy to iterate on without rebuilding build-tools. The body is written to
# bundle-comparison.md and also echoed to the action log so we can see what got posted.
# Dispatches on `data.kind` — happy path renders the comparison; failure kinds render a
# friendly per-(kind, side) message.
- name: Render bundle-size comment body
# release notes: https://github.com/actions/github-script/releases/tag/v9.0.0
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
PR_NUM: ${{ matrix.pr.number }}
BASE_SHA: ${{ matrix.pr.mergeBase }}
HEAD_SHA: ${{ matrix.pr.head }}
with:
script: |
const fs = require("fs");
const data = JSON.parse(fs.readFileSync("bundle-comparison.json", "utf8"));
// Render the happy-path body: a "Notable changes" summary (added,
// removed, or changed-with-parsed-delta ≥ NOTABLE_THRESHOLD) above
// the collapsed full per-bundle inventory.
function renderComparison(comparison) {
const NOTABLE_THRESHOLD = 500;
const fmtDelta = d => (d > 0 ? `+${d}` : `${d}`);
// Indicator emoji and notability for one bundle. ➕/➖ for added/removed,
// 🔴/🟢 for changes ≥ NOTABLE_THRESHOLD; smaller/unchanged → none.
function getRenderProps(cmp) {
if (cmp.base === undefined) return { indicator: "➕", isNotable: true };
if (cmp.compare === undefined) return { indicator: "➖", isNotable: true };
const delta = cmp.compare.parsedSize - cmp.base.parsedSize;
if (Math.abs(delta) >= NOTABLE_THRESHOLD) {
return { indicator: delta > 0 ? "🔴" : "🟢", isNotable: true };
}
return { indicator: undefined, isNotable: false };
}
// Render one bundle's diff line. Always emitted, including unchanged.
// `indicator` (➕/➖/🔴/🟢) is prepended when present.
function renderBundleLine(bundle, cmp, indicator) {
const prefix = indicator ? `${indicator} ` : "";
if (cmp.base === undefined && cmp.compare !== undefined) {
return `- ${prefix}\`${bundle}\`: **added** (parsed ${cmp.compare.parsedSize}, gzip ${cmp.compare.gzipSize})`;
}
if (cmp.compare === undefined && cmp.base !== undefined) {
return `- ${prefix}\`${bundle}\`: **removed** (was parsed ${cmp.base.parsedSize}, gzip ${cmp.base.gzipSize})`;
}
const dp = cmp.compare.parsedSize - cmp.base.parsedSize;
const dg = cmp.compare.gzipSize - cmp.base.gzipSize;
return `- ${prefix}\`${bundle}\`: parsed ${cmp.base.parsedSize} → ${cmp.compare.parsedSize} (${fmtDelta(dp)}), gzip ${cmp.base.gzipSize} → ${cmp.compare.gzipSize} (${fmtDelta(dg)})`;
}
const notableLines = [];
const sections = [];
for (const [pkg, bundles] of Object.entries(comparison)) {
const bundleLines = [];
for (const [bundle, cmp] of Object.entries(bundles)) {
const { indicator, isNotable } = getRenderProps(cmp);
const line = renderBundleLine(bundle, cmp, indicator);
bundleLines.push(line);
if (isNotable) {
notableLines.push(line);
}
}
if (bundleLines.length > 0) {
sections.push(`### \`${pkg}\`\n\n${bundleLines.join("\n")}`);
}
}
if (sections.length === 0) {
return "No bundles found in comparison.";
}
const notableSection = [
`### Notable changes`,
"",
notableLines.length > 0
? notableLines.join("\n")
: `No bundles changed by ≥ ${NOTABLE_THRESHOLD} bytes parsed.`,
].join("\n");
// Wrap the full inventory in <details> so the top of the comment stays compact.
return [
notableSection,
"",
"<details>",
"<summary>Per-bundle deltas</summary>",
"",
sections.join("\n\n"),
"",
"</details>",
].join("\n");
}
// Render a failure body for an expected (side, kind) failure.
// `in-progress` is the normal "wait for the build" path and reads
// like acknowledge-build's pending sticky — no warning framing.
// Everything else is an actual error and gets the "Comparison
// unavailable" header.
function renderFailure(side, kind) {
// Friendly per-(side, kind) sticky body for expected failure modes.
const failureMessages = {
base: {
"no-build": "No baseline CI build was found for the merge-base commit. It may be older than the workflow's search horizon, or the baseline pipeline may not have run on it. Try merging a recent main commit into this PR.",
"in-progress": "Pending — the baseline CI build for the merge-base commit hasn't completed yet. Results will appear here when the build finishes.",
"all-failed": "The baseline CI build for the merge-base commit failed — likely a flaky producer build. Try merging a recent main commit into this PR, or re-queue the baseline pipeline manually.",
"no-id": "An ADO state anomaly prevented looking up the baseline build (no usable build id). This shouldn't happen in practice — please report.",
"no-analyzer-jsons": "The baseline build completed but didn't publish a bundle-size artifact for the merge-base commit. Try merging a recent main commit into this PR.",
},
head: {
"no-build": "No CI build was found for the PR HEAD commit. This shouldn't happen — the workflow only runs after the PR's `Build - client packages` check completes. Please report.",
"in-progress": "Pending — the PR's CI build hasn't completed yet. Results will appear here when the build finishes.",
"all-failed": "The PR's CI build failed — fix the build and the comment will update once the next run succeeds.",
"no-id": "An ADO state anomaly prevented looking up the PR's build (no usable build id). This shouldn't happen in practice — please report.",
"no-analyzer-jsons": "The PR's CI build completed but didn't publish a bundle-size artifact. Check whether your changes affect the bundle-publishing client packages.",
},
};
const message = failureMessages[side]?.[kind]
?? `Comparison failed with kind \`${kind}\` on the \`${side}\` side. Please report.`;
return kind === "in-progress" ? message : `⚠️ Comparison unavailable.\n\n${message}`;
}
const body = [
"## Bundle size comparison",
"",
`Base commit: \`${process.env.BASE_SHA}\``,
`Head commit: \`${process.env.HEAD_SHA}\``,
"",
data.kind === "completed"
? renderComparison(data.comparison)
: renderFailure(data.side, data.kind),
"",
// Hidden footer — invisible in the rendered comment but visible when viewing source. Lets
// us trace a sticky back to the run that last wrote it.
"<!-- pr-bundle-size-comments: run_id=${{ github.run_id }} attempt=${{ github.run_attempt }} -->",
].join("\n") + "\n";
fs.writeFileSync("bundle-comparison.md", body);
core.info(`PR #${process.env.PR_NUM}: rendered the following body for the sticky comment.`);
core.startGroup("bundle-comparison.md");
core.info(body);
core.endGroup();
# release notes: https://github.com/marocchino/sticky-pull-request-comment/releases/tag/v3.0.4
- uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
with:
header: bundle-size-report
number: ${{ matrix.pr.number }}
path: ${{ github.workspace }}/bundle-comparison.md
# Keep the sticky at the bottom of the timeline on each update.
recreate: true