PR Bundle Size Comments | Build - client packages (Publish Internal Packages Publish package-build-feed) | completed | 33d36d62db39ac3906181cdf2745843f74d360fb #184282
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 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 |