PR Bundle Size Comments | Build - client packages (🔒 SDLSources 🔒 Agentless Tag) | created | d7ae935ec485ab4f80f211aebd00e6d92aab92ed #279281
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: | |
| # `contents: read` for the getCommit call that resolves the base commit. | |
| contents: read | |
| 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 and head SHA in one round-trip (we use both: number to address the | |
| // sticky, head to report in the 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 | |
| } | |
| } | |
| } | |
| }`, | |
| { 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; | |
| } | |
| core.info(`Matched PR #${pr.number} head=${pr.headRefOid}`); | |
| core.setOutput("pr_num", pr.number); | |
| core.setOutput("head_sha", pr.headRefOid); | |
| - name: Resolve base commit from the ADO build | |
| id: find_base | |
| 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 | |
| with: | |
| script: | | |
| // The check run's details_url points at the exact ADO build behind it, so its | |
| // sourceVersion — the `refs/pull/<n>/merge` commit ADO resolved at queue time — can be | |
| // read without searching for a matching build. Its first parent is the target-branch | |
| // commit that build merged the PR into, i.e. the comparison's baseline. | |
| // | |
| // Best-effort: the sticky just shows a nicer pending body, and the real comparison | |
| // derives the base itself. Any failure here leaves the base unresolved in the body. | |
| const detailsUrl = new URL(context.payload.check_run.details_url); | |
| const buildId = detailsUrl.searchParams.get("buildId"); | |
| if (!buildId) { | |
| core.info(`No buildId in details_url ${detailsUrl}; leaving the base unresolved.`); | |
| return; | |
| } | |
| // `details_url` is <origin>/<org>/<projectId>/_build/results, so the project-scoped API | |
| // root is the first two path segments. | |
| const [, org, projectId] = detailsUrl.pathname.split("/"); | |
| const buildUrl = `${detailsUrl.origin}/${org}/${projectId}/_apis/build/builds/${buildId}?api-version=7.1`; | |
| let sourceVersion; | |
| try { | |
| const response = await fetch(buildUrl); | |
| if (!response.ok) { | |
| core.info(`ADO build ${buildId} lookup failed (${response.status}); leaving the base unresolved.`); | |
| return; | |
| } | |
| ({ sourceVersion } = await response.json()); | |
| } catch (error) { | |
| core.info(`ADO build ${buildId} lookup failed (${error}); leaving the base unresolved.`); | |
| return; | |
| } | |
| // Not yet populated if ADO hasn't resolved the merge ref for a just-queued build. | |
| if (!sourceVersion) { | |
| core.info(`ADO build ${buildId} has no sourceVersion yet; leaving the base unresolved.`); | |
| return; | |
| } | |
| const { owner, repo } = context.repo; | |
| let parents; | |
| try { | |
| ({ data: { parents } } = await github.rest.repos.getCommit({ owner, repo, ref: sourceVersion })); | |
| } catch (error) { | |
| core.info(`Could not read ${sourceVersion} (${error}); leaving the base unresolved.`); | |
| return; | |
| } | |
| // A test-merge commit always has two parents: [target branch tip, PR HEAD]. | |
| if (parents.length < 2) { | |
| core.info(`${sourceVersion} is not a merge commit; leaving the base unresolved.`); | |
| return; | |
| } | |
| core.info(`ADO build ${buildId} merged PR into ${parents[0].sha}`); | |
| core.setOutput("base_sha", parents[0].sha); | |
| - 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 }} | |
| BASE_SHA: ${{ steps.find_base.outputs.base_sha }} | |
| with: | |
| script: | | |
| const fs = require("fs"); | |
| const baseSha = process.env.BASE_SHA; | |
| const body = [ | |
| "## Bundle size comparison", | |
| "", | |
| // An empty baseSha means the lookup failed — normally the base is readable off the | |
| // ADO build. Harmless for correctness: the comparison derives the base independently, | |
| // so it will be filled in when this comment is replaced with the results. | |
| `Base commit: ${baseSha ? `\`${baseSha}\`` : "_could not be determined; will be reported when the comparison runs_"}`, | |
| `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; | |
| } | |
| const affected = [{ | |
| number: pr.number, | |
| head: pr.headRefOid, | |
| }]; | |
| core.info(`Affected PRs (1): #${pr.number} head=${pr.headRefOid} base=${pr.baseRefName}`); | |
| 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 that is likely | |
| # to be waiting on this baseline — 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 completedBaselineSha = context.payload.check_run.head_sha; | |
| // SHA terminology: | |
| // - `completedBaselineSha`: target-branch commit whose baseline build just completed. | |
| // - ADO `triggerInfo["pr.sourceSha"]`: PR HEAD commit that triggered the PR build. | |
| // - ADO `sourceVersion`: ephemeral test-merge commit consumed by flub. | |
| // | |
| // ADO is authoritative for which baseline a PR is waiting on: the test-merge commit's | |
| // first parent is the target-branch commit used by the PR build. GitHub's | |
| // `potentialMergeCommit` cannot be used here because GitHub recomputes it whenever the | |
| // target branch advances, even though the existing ADO PR build remains based on the | |
| // older target-branch commit. | |
| // | |
| // One paginated GraphQL query returns every open PR's head SHA, versus two REST calls | |
| // per PR (~250 for this repo). We later resolve all retained ADO merge commits through | |
| // one GraphQL query, avoiding another REST call per PR. | |
| const query = ` | |
| query($owner: String!, $repo: String!, $cursor: String) { | |
| repository(owner: $owner, name: $repo) { | |
| pullRequests(states: OPEN, first: 100, after: $cursor) { | |
| pageInfo { hasNextPage endCursor } | |
| nodes { | |
| number | |
| headRefOid | |
| } | |
| } | |
| } | |
| }`; | |
| // Collect the complete open-PR set. `github.paginate.graphql` is unavailable in | |
| // actions/github-script, so advance the GraphQL cursor manually. | |
| const openPrs = []; | |
| let cursor = undefined; | |
| do { | |
| const { repository } = await github.graphql(query, { owner, repo, cursor }); | |
| const page = repository.pullRequests; | |
| openPrs.push(...page.nodes); | |
| cursor = page.pageInfo.hasNextPage ? page.pageInfo.endCursor : undefined; | |
| } while (cursor); | |
| if (openPrs.length === 0) { | |
| core.info("Scanned 0 open PRs; affected PRs (0)"); | |
| core.setOutput("prs", "[]"); | |
| return; | |
| } | |
| // ADO has no query-by-PR-head-SHA API. Fetch the same bounded recent PR-build window | |
| // flub searches, then match each PR HEAD against `triggerInfo["pr.sourceSha"]`. | |
| // `sourceVersion` is the separate ephemeral `refs/pull/<n>/merge` commit SHA. | |
| const prBuildsUrl = new URL("https://dev.azure.com/fluidframework/public/_apis/build/builds"); | |
| prBuildsUrl.searchParams.set("definitions", "11"); | |
| prBuildsUrl.searchParams.set("maxBuildsPerDefinition", "500"); | |
| prBuildsUrl.searchParams.set("queryOrder", "queueTimeDescending"); | |
| prBuildsUrl.searchParams.set("api-version", "7.1"); | |
| // Public ADO project reads are anonymous. Treat malformed or failed PR-build responses | |
| // as job failures rather than silently reporting that no PRs are waiting. | |
| const prBuildsResponse = await fetch(prBuildsUrl); | |
| if (!prBuildsResponse.ok) { | |
| throw new Error( | |
| `ADO PR build lookup failed (${prBuildsResponse.status} ${prBuildsResponse.statusText})`, | |
| ); | |
| } | |
| const { value: adoPrBuilds } = await prBuildsResponse.json(); | |
| if (!Array.isArray(adoPrBuilds)) { | |
| throw new Error("ADO PR build lookup returned no build list"); | |
| } | |
| // Match flub's selection by retaining the newest completed PR build with an id for | |
| // each PR HEAD. Prefer fully successful builds, then fall back to partially successful | |
| // builds whose bundle artifact flub will validate before using. | |
| const retainedPrBuildByHeadSha = new Map(); | |
| for (const prBuild of adoPrBuilds) { | |
| const prHeadSha = prBuild.triggerInfo?.["pr.sourceSha"]; | |
| const currentlyRetainedBuild = retainedPrBuildByHeadSha.get(prHeadSha); | |
| if ( | |
| prHeadSha && | |
| prBuild.id !== undefined && | |
| prBuild.status === "completed" && | |
| ["succeeded", "partiallySucceeded"].includes(prBuild.result) && | |
| (currentlyRetainedBuild === undefined || | |
| (currentlyRetainedBuild.result === "partiallySucceeded" && | |
| prBuild.result === "succeeded")) | |
| ) { | |
| retainedPrBuildByHeadSha.set(prHeadSha, prBuild); | |
| } | |
| } | |
| // Join open PRs to the retained PR build flub would use. Match both the HEAD SHA and | |
| // ADO's PR number: multiple open PRs can share a HEAD SHA, but flub will select only | |
| // this same newest successful PR build for that SHA. PRs with no exact retained match | |
| // cannot be waiting on the baseline build that just completed. | |
| const prBuildCandidates = []; | |
| for (const pr of openPrs) { | |
| const prBuild = retainedPrBuildByHeadSha.get(pr.headRefOid); | |
| const prNumberFromBuild = prBuild?.triggerInfo?.["pr.number"]; | |
| const mergeCommitSha = prBuild?.sourceVersion; | |
| if (prNumberFromBuild === String(pr.number) && mergeCommitSha) { | |
| prBuildCandidates.push({ pr, prBuild, mergeCommitSha }); | |
| } | |
| } | |
| // Resolve all test-merge parents in one query. Each `mergeCommitSha` came from ADO's | |
| // `sourceVersion`, so it is safe to pass as a GraphQL variable; only generated aliases | |
| // appear in the query text. | |
| const mergeCommitShas = [ | |
| ...new Set(prBuildCandidates.map(({ mergeCommitSha }) => mergeCommitSha)), | |
| ]; | |
| const baselineByMergeCommit = new Map(); | |
| if (mergeCommitShas.length > 0) { | |
| const declarations = []; | |
| const fields = []; | |
| const variables = { owner, repo }; | |
| for (const [index, mergeCommitSha] of mergeCommitShas.entries()) { | |
| const alias = `commit${index}`; | |
| declarations.push(`$${alias}: String!`); | |
| fields.push(` | |
| ${alias}: object(expression: $${alias}) { | |
| ... on Commit { | |
| parents(first: 2) { nodes { oid } } | |
| } | |
| }`); | |
| variables[alias] = mergeCommitSha; | |
| } | |
| // Each generated field resolves one ADO test-merge commit. A missing object is | |
| // returned as null and handled below as an unavailable merge commit. | |
| const commitQuery = ` | |
| query($owner: String!, $repo: String!, ${declarations.join(", ")}) { | |
| repository(owner: $owner, name: $repo) { | |
| ${fields.join("\n")} | |
| } | |
| }`; | |
| const { repository } = await github.graphql(commitQuery, variables); | |
| for (const [index, mergeCommitSha] of mergeCommitShas.entries()) { | |
| const parents = repository[`commit${index}`]?.parents?.nodes; | |
| if (parents?.length >= 2) { | |
| baselineByMergeCommit.set(mergeCommitSha, parents[0].oid); | |
| } | |
| } | |
| } | |
| // Emit only PRs whose retained PR build actually merged into the completed baseline. | |
| const affected = []; | |
| for (const { pr, prBuild, mergeCommitSha } of prBuildCandidates) { | |
| const prBuildBaseline = baselineByMergeCommit.get(mergeCommitSha); | |
| if (prBuildBaseline !== completedBaselineSha) { | |
| core.info( | |
| prBuildBaseline | |
| ? ` excluded #${pr.number}: ADO PR build ${prBuild.id} used baseline ${prBuildBaseline}` | |
| : ` excluded #${pr.number}: ADO test-merge commit ${mergeCommitSha} is unavailable or invalid`, | |
| ); | |
| continue; | |
| } | |
| core.info( | |
| ` matched #${pr.number}: ADO PR build ${prBuild.id} used baseline ${completedBaselineSha}`, | |
| ); | |
| affected.push({ | |
| number: pr.number, | |
| head: pr.headRefOid, | |
| }); | |
| } | |
| core.info( | |
| `Scanned ${openPrs.length} open PRs with ${prBuildCandidates.length} retained PR builds; affected PRs (${affected.length})`, | |
| ); | |
| 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: | |
| contents: read | |
| pull-requests: write | |
| steps: | |
| # Check out the default 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. | |
| # | |
| # The default branch — not the PR's base ref — because `check_run` workflows always run the default | |
| # branch's copy of this file, so that's the only branch guaranteed to have a flub whose CLI contract | |
| # matches the invocation below. Release branches lag main and don't carry this command at all. | |
| # release notes: https://github.com/actions/checkout/releases/tag/v6.0.2 | |
| - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | |
| with: | |
| ref: ${{ github.event.repository.default_branch }} | |
| 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 | |
| # The baseline commit isn't passed in: flub derives it from the PR's ADO build (the first | |
| # parent of the `refs/pull/<n>/merge` commit that build checked out), which is the only | |
| # baseline that isolates this PR's delta. 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. | |
| env: | |
| # Used only to raise the rate limit on the read-only commit lookup flub does. | |
| GITHUB_TOKEN: ${{ github.token }} | |
| run: | | |
| set -uo pipefail | |
| set +e | |
| flub report comparePipelineBundleArtifacts \ | |
| --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 \ | |
| --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 }} | |
| 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 target-branch commit this PR was built against. It may be older than the workflow's search horizon, or the baseline pipeline may not have run on it. Pushing to the PR re-runs the comparison against a newer baseline.", | |
| "in-progress": "Pending — the baseline CI build for the target-branch commit this PR was built against hasn't completed yet. Results will appear here when the build finishes.", | |
| "all-failed": "The baseline CI build for the target-branch commit this PR was built against failed — likely a flaky producer build. Pushing to the PR re-runs the comparison against a newer baseline, or the baseline pipeline can be re-queued 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 target-branch commit this PR was built against. Pushing to the PR re-runs the comparison against a newer baseline.", | |
| "no-base-commit": "Couldn't determine which target-branch commit the PR build was based on. Pushing to the PR re-runs the comparison; if it persists, please report.", | |
| }, | |
| 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", | |
| "", | |
| // The base is the target-branch commit the PR's ADO build merged into — reported by | |
| // flub, since only that build knows it. Absent when the failure happened before it | |
| // could be resolved. | |
| `Base commit: ${data.baseCommit ? `\`${data.baseCommit}\`` : "_unresolved_"}`, | |
| `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 |