From 46cbe8f133f4f2256c12a8f5e30875575382917b Mon Sep 17 00:00:00 2001 From: Gary Hsu Date: Fri, 15 May 2026 09:50:22 -0700 Subject: [PATCH 1/3] Add binary size check workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new `size-check.yml` workflow that measures and surfaces binary size impact on every PR to master. ## What it does On every `pull_request` to master, the workflow: 1. Checks out the PR's base SHA and head SHA into separate trees. 2. Configures and builds the `Playground` target in each tree using the same Win32 x64 D3D11 RelWithDebInfo flags as the existing CI's `Win32_x64_D3D11` job (`BX_CONFIG_DEBUG=ON`, `BABYLON_DEBUG_TRACE=ON`, `BGFX_CONFIG_MAX_FRAME_BUFFERS=256`). 3. Enumerates every `.exe`/`.dll`/`.lib` under each build's `RelWithDebInfo` output, computes the per-artifact delta, and formats a markdown report with: - The final `Playground.exe` size delta (the canonical headline). - The aggregate size delta across all built artifacts. - The top 10 other artifacts that moved by more than 256 B. 4. Posts a sticky PR comment (updated in place on subsequent pushes) keyed by an HTML marker, and also writes the same report to the run summary so reviewers can find it from the run page when the comment can't be posted (e.g., fork PRs without write tokens). ## Why PR #1695 (SPIRV-Cross bump) added ~17.5 KB to `Playground.exe` and ~119 KB to `spirv-cross-hlsl.lib`. The change was small but there was no automated guardrail to surface either number on the PR. Future dependency bumps (SPIRV-Cross, bgfx, glslang) and large feature additions all have the same gap. This workflow is **informational only** — no threshold enforcement. The goal for the first iteration is to make size impact visible so reviewers can react. A threshold (e.g., fail-on-N%) can be layered on later once we have data on natural per-PR variance. ## Cost Two sequential Win32 RelWithDebInfo builds on a single `windows-latest` runner, ~30 min wall clock. Runs in parallel with the rest of CI so it doesn't extend the bottleneck. `concurrency: cancel-in-progress: true` cancels stale runs when a PR gets a new push, so quick iteration doesn't pile up runner minutes. ## Trade-offs / follow-ups - **Win32 only.** Easiest platform to add first; covers the highest-traffic consumer scenario. macOS/iOS/Android/Linux are natural follow-ups when the workflow proves stable. Each platform has its own SPIRV-Cross emitter linked in (HLSL on Win32/UWP, MSL on Apple, GLSL on Linux), so cross-platform coverage is meaningful. - **No baseline caching.** Each run rebuilds `base` from scratch. Could cache `_deps` keyed on hashed CMakeLists later if the wall-clock cost becomes a problem. - **No threshold enforcement.** Strictly informational. After a few weeks of data we can decide on an appropriate failure threshold. - **Static lib deltas include COFF timestamp noise** (small, sub-1 KB movements on libs whose source didn't change). The 256 B filter excludes the smallest noise but a few bytes of timestamp can still show up. Acceptable for an MVP. [Created by Copilot on behalf of @bghgary] Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/size-check.yml | 221 +++++++++++++++++++++++++++++++ 1 file changed, 221 insertions(+) create mode 100644 .github/workflows/size-check.yml diff --git a/.github/workflows/size-check.yml b/.github/workflows/size-check.yml new file mode 100644 index 000000000..a3e1a8682 --- /dev/null +++ b/.github/workflows/size-check.yml @@ -0,0 +1,221 @@ +name: Binary Size Check + +on: + pull_request: + branches: [master] + +concurrency: + group: size-check-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + size-check: + runs-on: windows-latest + timeout-minutes: 90 + permissions: + pull-requests: write + contents: read + steps: + - name: Checkout base ref + uses: actions/checkout@v5 + with: + ref: ${{ github.event.pull_request.base.sha }} + path: base + + - name: Checkout head ref + uses: actions/checkout@v5 + with: + ref: ${{ github.event.pull_request.head.sha }} + path: head + + - name: Configure base (Win32 x64 D3D11, matches CI flags) + shell: cmd + working-directory: base + run: | + cmake -G "Visual Studio 17 2022" ^ + -B build\Win32_x64 ^ + -A x64 ^ + -D BX_CONFIG_DEBUG=ON ^ + -D GRAPHICS_API=D3D11 ^ + -D BGFX_CONFIG_MAX_FRAME_BUFFERS=256 ^ + -D BABYLON_DEBUG_TRACE=ON + + - name: Build base Playground + shell: cmd + working-directory: base + run: cmake --build build\Win32_x64 --config RelWithDebInfo --target Playground -- /m + + - name: Configure head (same flags) + shell: cmd + working-directory: head + run: | + cmake -G "Visual Studio 17 2022" ^ + -B build\Win32_x64 ^ + -A x64 ^ + -D BX_CONFIG_DEBUG=ON ^ + -D GRAPHICS_API=D3D11 ^ + -D BGFX_CONFIG_MAX_FRAME_BUFFERS=256 ^ + -D BABYLON_DEBUG_TRACE=ON + + - name: Build head Playground + shell: cmd + working-directory: head + run: cmake --build build\Win32_x64 --config RelWithDebInfo --target Playground -- /m + + - name: Measure sizes and format comment + id: measure + shell: pwsh + run: | + $marker = '' + $config = 'RelWithDebInfo' + $platform = 'Win32 x64 D3D11 RelWithDebInfo' + $baseBuild = Join-Path $env:GITHUB_WORKSPACE 'base\build\Win32_x64' + $headBuild = Join-Path $env:GITHUB_WORKSPACE 'head\build\Win32_x64' + $baseRef = '${{ github.event.pull_request.base.sha }}' + $headRef = '${{ github.event.pull_request.head.sha }}' + $playgroundRel = "Apps\Playground\$config\Playground.exe" + + function Get-Artifacts($root, $cfg) { + if (-not (Test-Path $root)) { return @() } + Get-ChildItem $root -Recurse -File -Include *.exe,*.dll,*.lib -ErrorAction SilentlyContinue | + Where-Object { $_.FullName -match "\\$cfg\\" } | + ForEach-Object { + [PSCustomObject]@{ + Rel = $_.FullName.Substring($root.Length + 1) + Size = $_.Length + } + } + } + + $baseArts = Get-Artifacts $baseBuild $config + $headArts = Get-Artifacts $headBuild $config + + if ($baseArts.Count -eq 0 -or $headArts.Count -eq 0) { + throw "No artifacts found. base=$($baseArts.Count) head=$($headArts.Count). Build likely failed." + } + + function FormatBytes([long]$n) { + $sign = if ($n -lt 0) { '-' } else { '+' } + $abs = [math]::Abs($n) + if ($abs -ge 1MB) { return "$sign$([math]::Round($abs/1MB, 2)) MB" } + if ($abs -ge 1KB) { return "$sign$([math]::Round($abs/1KB, 1)) KB" } + return "$sign$abs B" + } + + function FormatCount([long]$n) { '{0:N0}' -f $n } + + # Headline: Playground.exe + $basePg = $baseArts | Where-Object { $_.Rel -match 'Playground\.exe$' } | Select-Object -First 1 + $headPg = $headArts | Where-Object { $_.Rel -match 'Playground\.exe$' } | Select-Object -First 1 + + if (-not $basePg -or -not $headPg) { + throw "Playground.exe missing. base=$($basePg -ne $null) head=$($headPg -ne $null)" + } + + $pgDelta = $headPg.Size - $basePg.Size + $pgPct = if ($basePg.Size -gt 0) { [math]::Round(100 * $pgDelta / $basePg.Size, 3) } else { 0 } + + # Build a name->sizes map for delta comparison + $pairs = @{} + foreach ($a in $baseArts) { $pairs[$a.Rel] = @{ Base = $a.Size; Head = 0 } } + foreach ($a in $headArts) { + if (-not $pairs.ContainsKey($a.Rel)) { $pairs[$a.Rel] = @{ Base = 0; Head = 0 } } + $pairs[$a.Rel].Head = $a.Size + } + $rows = $pairs.Keys | ForEach-Object { + $p = $pairs[$_] + [PSCustomObject]@{ + Rel = $_ + Base = $p.Base + Head = $p.Head + Delta = $p.Head - $p.Base + } + } + + # Top movers (absolute delta, excluding Playground.exe which we already feature) + $top = $rows | + Where-Object { $_.Rel -notmatch 'Playground\.exe$' -and [math]::Abs($_.Delta) -gt 256 } | + Sort-Object { [math]::Abs($_.Delta) } -Descending | + Select-Object -First 10 + + $baseTotal = ($baseArts | Measure-Object Size -Sum).Sum + $headTotal = ($headArts | Measure-Object Size -Sum).Sum + $totalDelta = $headTotal - $baseTotal + $totalPct = if ($baseTotal -gt 0) { [math]::Round(100 * $totalDelta / $baseTotal, 3) } else { 0 } + + # Build markdown + $sb = [System.Text.StringBuilder]::new() + [void]$sb.AppendLine($marker) + [void]$sb.AppendLine('## Binary size impact') + [void]$sb.AppendLine('') + [void]$sb.AppendLine("Platform: ``$platform``  |  base ``$($baseRef.Substring(0,7))``  |  head ``$($headRef.Substring(0,7))``") + [void]$sb.AppendLine('') + [void]$sb.AppendLine('### Final linked binary') + [void]$sb.AppendLine('') + [void]$sb.AppendLine('| Artifact | Base | Head | Δ bytes | Δ% |') + [void]$sb.AppendLine('|---|---:|---:|---:|---:|') + [void]$sb.AppendLine("| **Playground.exe** | $(FormatCount $basePg.Size) | $(FormatCount $headPg.Size) | **$(FormatBytes $pgDelta)** | **$pgPct %** |") + [void]$sb.AppendLine("| Aggregate (.exe/.dll/.lib) | $(FormatCount $baseTotal) | $(FormatCount $headTotal) | $(FormatBytes $totalDelta) | $totalPct % |") + [void]$sb.AppendLine('') + + if ($top -and $top.Count -gt 0) { + [void]$sb.AppendLine('### Top movers (other static libs / DLLs, |Δ| > 256 B)') + [void]$sb.AppendLine('') + [void]$sb.AppendLine('| Artifact | Base | Head | Δ bytes |') + [void]$sb.AppendLine('|---|---:|---:|---:|') + foreach ($r in $top) { + $shortRel = $r.Rel -replace '\\', '/' + [void]$sb.AppendLine("| ``$shortRel`` | $(FormatCount $r.Base) | $(FormatCount $r.Head) | $(FormatBytes $r.Delta) |") + } + [void]$sb.AppendLine('') + } else { + [void]$sb.AppendLine('_No other artifact moved by more than 256 B._') + [void]$sb.AppendLine('') + } + + [void]$sb.AppendLine('---') + [void]$sb.AppendLine('Comparison is informational only — no threshold enforcement. Configuration matches the Win32_x64_D3D11 CI job. Two sequential Release-with-debug-info builds per run. Small deltas on unchanged libs are typically COFF timestamps.') + + $body = $sb.ToString() + $bodyFile = Join-Path $env:RUNNER_TEMP 'size-comment.md' + $body | Set-Content -Path $bodyFile -Encoding utf8 + + Write-Host "=== comment body ===" + Write-Host $body + Write-Host "====================" + + # Surface in run summary too + $body | Add-Content -Path $env:GITHUB_STEP_SUMMARY -Encoding utf8 + + "body_file=$bodyFile" | Add-Content -Path $env:GITHUB_OUTPUT -Encoding utf8 + "marker=$marker" | Add-Content -Path $env:GITHUB_OUTPUT -Encoding utf8 + + - name: Post or update sticky PR comment + shell: pwsh + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + $marker = '${{ steps.measure.outputs.marker }}' + $bodyFile = '${{ steps.measure.outputs.body_file }}' + $pr = ${{ github.event.pull_request.number }} + $repo = '${{ github.repository }}' + + try { + $comments = gh api "repos/$repo/issues/$pr/comments" --paginate | ConvertFrom-Json + if ($LASTEXITCODE -ne 0) { throw "gh api list comments exited $LASTEXITCODE" } + + $existing = $comments | Where-Object { $_.body -like "*$marker*" } | Select-Object -First 1 + + if ($existing) { + Write-Host "Updating existing comment $($existing.id)" + gh api "repos/$repo/issues/comments/$($existing.id)" --method PATCH -F "body=@$bodyFile" | Out-Null + } else { + Write-Host "Creating new comment" + gh api "repos/$repo/issues/$pr/comments" --method POST -F "body=@$bodyFile" | Out-Null + } + if ($LASTEXITCODE -ne 0) { throw "gh api comment write exited $LASTEXITCODE" } + Write-Host "Posted comment OK." + } catch { + Write-Warning "Could not post PR comment (likely no write token on fork PR): $_" + Write-Host "Size details are still available in the run summary above." + } From 66c8b6e1e0957ec2672532976b9b6c52422411aa Mon Sep 17 00:00:00 2001 From: Gary Hsu Date: Fri, 15 May 2026 11:04:05 -0700 Subject: [PATCH 2/3] Split size-check into unprivileged build + privileged comment workflows The single-workflow approach failed at the comment-post step on fork PRs (the default for BN contributors) because `pull_request` events from forks get a read-only `GITHUB_TOKEN`, which doesn't have permission to post comments. Refactor to the two-workflow pattern recommended by the GitHub Security Lab: https://securitylab.github.com/resources/github-actions-preventing-pwn-requests/ - `size-check.yml` (on: pull_request) builds + measures + uploads a report artifact. Runs in an unprivileged context (no write tokens, no secrets exposed to PR-author code). Same Win32 RelWithDebInfo build as before, ~30 min. - `size-check-comment.yml` (on: workflow_run) triggers when the build workflow completes successfully, downloads the artifact, and posts the sticky PR comment. Runs from master with full `pull-requests: write` permission, so the comment works on every PR regardless of whether it's from a fork. This PR can't fully exercise the comment-post path because `size-check-comment.yml` doesn't yet exist on master (workflow_run loads the workflow file from the default branch). After merge, the first new PR will trigger end-to-end and post the first sticky comment. [Created by Copilot on behalf of @bghgary] Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/size-check-comment.yml | 67 ++++++++++++++++++++++++ .github/workflows/size-check.yml | 55 ++++++++----------- 2 files changed, 88 insertions(+), 34 deletions(-) create mode 100644 .github/workflows/size-check-comment.yml diff --git a/.github/workflows/size-check-comment.yml b/.github/workflows/size-check-comment.yml new file mode 100644 index 000000000..0eaa67156 --- /dev/null +++ b/.github/workflows/size-check-comment.yml @@ -0,0 +1,67 @@ +name: Binary Size Check Comment + +# Privileged companion to `Binary Size Check`. Triggered on the completion of +# that build workflow (which runs in an unprivileged context for safety on +# fork PRs). This workflow runs from the default branch with write access to +# the target repo, downloads the size report artifact, and posts the sticky +# PR comment. +# +# Pattern: https://securitylab.github.com/resources/github-actions-preventing-pwn-requests/ + +on: + workflow_run: + workflows: ["Binary Size Check"] + types: [completed] + +permissions: + pull-requests: write + actions: read + contents: read + +jobs: + comment: + runs-on: ubuntu-latest + if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success' + steps: + - name: Download size-check report artifact + uses: actions/download-artifact@v6 + with: + name: size-check-report + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ secrets.GITHUB_TOKEN }} + path: report + + - name: Post or update sticky PR comment + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + shell: bash + run: | + set -euo pipefail + + PR=$(tr -d '[:space:]' < report/pr-number.txt) + if ! [[ "$PR" =~ ^[0-9]+$ ]]; then + echo "PR number from artifact is not numeric: '$PR'" + exit 1 + fi + + BODY_FILE=report/body.md + MARKER='' + + if ! grep -qF "$MARKER" "$BODY_FILE"; then + echo "Body file is missing the expected marker; refusing to post." + exit 1 + fi + + EXISTING=$(gh api "repos/${REPO}/issues/${PR}/comments" --paginate \ + | jq -r --arg m "$MARKER" '.[] | select(.body | contains($m)) | .id' \ + | head -n 1) + + if [ -n "$EXISTING" ]; then + echo "Updating existing comment $EXISTING on PR #$PR" + gh api "repos/${REPO}/issues/comments/${EXISTING}" --method PATCH -F "body=@${BODY_FILE}" > /dev/null + else + echo "Creating new comment on PR #$PR" + gh api "repos/${REPO}/issues/${PR}/comments" --method POST -F "body=@${BODY_FILE}" > /dev/null + fi + echo "Done." diff --git a/.github/workflows/size-check.yml b/.github/workflows/size-check.yml index a3e1a8682..eb8628a26 100644 --- a/.github/workflows/size-check.yml +++ b/.github/workflows/size-check.yml @@ -12,8 +12,12 @@ jobs: size-check: runs-on: windows-latest timeout-minutes: 90 + # Intentionally no write permissions: this workflow builds untrusted PR code + # and uploads results as an artifact. The companion `Binary Size Check Comment` + # workflow (triggered on workflow_run) downloads the artifact and posts the + # sticky comment with full write perms from master. + # See https://securitylab.github.com/resources/github-actions-preventing-pwn-requests/ permissions: - pull-requests: write contents: read steps: - name: Checkout base ref @@ -177,45 +181,28 @@ jobs: [void]$sb.AppendLine('Comparison is informational only — no threshold enforcement. Configuration matches the Win32_x64_D3D11 CI job. Two sequential Release-with-debug-info builds per run. Small deltas on unchanged libs are typically COFF timestamps.') $body = $sb.ToString() - $bodyFile = Join-Path $env:RUNNER_TEMP 'size-comment.md' + $reportDir = Join-Path $env:RUNNER_TEMP 'size-check-report' + New-Item -ItemType Directory -Force $reportDir | Out-Null + $bodyFile = Join-Path $reportDir 'body.md' $body | Set-Content -Path $bodyFile -Encoding utf8 + # Stash the PR number alongside the body so the comment workflow can find it + # (workflow_run events don't reliably populate pull_request context for fork PRs). + '${{ github.event.pull_request.number }}' | Set-Content -Path (Join-Path $reportDir 'pr-number.txt') -Encoding utf8 + Write-Host "=== comment body ===" Write-Host $body Write-Host "====================" - # Surface in run summary too + # Surface in run summary too (visible on the PR's checks page even when no sticky comment posts). $body | Add-Content -Path $env:GITHUB_STEP_SUMMARY -Encoding utf8 - "body_file=$bodyFile" | Add-Content -Path $env:GITHUB_OUTPUT -Encoding utf8 - "marker=$marker" | Add-Content -Path $env:GITHUB_OUTPUT -Encoding utf8 + "report_dir=$reportDir" | Add-Content -Path $env:GITHUB_OUTPUT -Encoding utf8 - - name: Post or update sticky PR comment - shell: pwsh - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - $marker = '${{ steps.measure.outputs.marker }}' - $bodyFile = '${{ steps.measure.outputs.body_file }}' - $pr = ${{ github.event.pull_request.number }} - $repo = '${{ github.repository }}' - - try { - $comments = gh api "repos/$repo/issues/$pr/comments" --paginate | ConvertFrom-Json - if ($LASTEXITCODE -ne 0) { throw "gh api list comments exited $LASTEXITCODE" } - - $existing = $comments | Where-Object { $_.body -like "*$marker*" } | Select-Object -First 1 - - if ($existing) { - Write-Host "Updating existing comment $($existing.id)" - gh api "repos/$repo/issues/comments/$($existing.id)" --method PATCH -F "body=@$bodyFile" | Out-Null - } else { - Write-Host "Creating new comment" - gh api "repos/$repo/issues/$pr/comments" --method POST -F "body=@$bodyFile" | Out-Null - } - if ($LASTEXITCODE -ne 0) { throw "gh api comment write exited $LASTEXITCODE" } - Write-Host "Posted comment OK." - } catch { - Write-Warning "Could not post PR comment (likely no write token on fork PR): $_" - Write-Host "Size details are still available in the run summary above." - } + - name: Upload size-check report artifact + uses: actions/upload-artifact@v6 + with: + name: size-check-report + path: ${{ steps.measure.outputs.report_dir }} + if-no-files-found: error + retention-days: 7 From d826bb0a1ea1bb031e99402ff5d8583a92209fc5 Mon Sep 17 00:00:00 2001 From: Gary Hsu Date: Fri, 15 May 2026 15:53:38 -0700 Subject: [PATCH 3/3] Extract size-check logic into Scripts/MeasureBinarySize.ps1 The inline ~120-line pwsh block in `size-check.yml` is moved to a standalone script so it can be run locally for ad-hoc size measurements (build a master worktree and a feature worktree with the same cmake flags, then point the script at both). The workflow step becomes a single one-line invocation. Lives under a new top-level `Scripts/` folder (PascalCase, matching the rest of the repo). Existing `Apps/scripts/getNightly.js` is Apps-scoped; this new script measures binary sizes across the whole build (Apps + Plugins + Core + Polyfills + _deps) so it doesn't belong under Apps/. [Created by Copilot on behalf of @bghgary] Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/size-check.yml | 140 ++----------------- Scripts/MeasureBinarySize.ps1 | 225 +++++++++++++++++++++++++++++++ 2 files changed, 235 insertions(+), 130 deletions(-) create mode 100644 Scripts/MeasureBinarySize.ps1 diff --git a/.github/workflows/size-check.yml b/.github/workflows/size-check.yml index eb8628a26..0462a12e2 100644 --- a/.github/workflows/size-check.yml +++ b/.github/workflows/size-check.yml @@ -67,142 +67,22 @@ jobs: run: cmake --build build\Win32_x64 --config RelWithDebInfo --target Playground -- /m - name: Measure sizes and format comment - id: measure shell: pwsh run: | - $marker = '' - $config = 'RelWithDebInfo' - $platform = 'Win32 x64 D3D11 RelWithDebInfo' - $baseBuild = Join-Path $env:GITHUB_WORKSPACE 'base\build\Win32_x64' - $headBuild = Join-Path $env:GITHUB_WORKSPACE 'head\build\Win32_x64' - $baseRef = '${{ github.event.pull_request.base.sha }}' - $headRef = '${{ github.event.pull_request.head.sha }}' - $playgroundRel = "Apps\Playground\$config\Playground.exe" - - function Get-Artifacts($root, $cfg) { - if (-not (Test-Path $root)) { return @() } - Get-ChildItem $root -Recurse -File -Include *.exe,*.dll,*.lib -ErrorAction SilentlyContinue | - Where-Object { $_.FullName -match "\\$cfg\\" } | - ForEach-Object { - [PSCustomObject]@{ - Rel = $_.FullName.Substring($root.Length + 1) - Size = $_.Length - } - } - } - - $baseArts = Get-Artifacts $baseBuild $config - $headArts = Get-Artifacts $headBuild $config - - if ($baseArts.Count -eq 0 -or $headArts.Count -eq 0) { - throw "No artifacts found. base=$($baseArts.Count) head=$($headArts.Count). Build likely failed." - } - - function FormatBytes([long]$n) { - $sign = if ($n -lt 0) { '-' } else { '+' } - $abs = [math]::Abs($n) - if ($abs -ge 1MB) { return "$sign$([math]::Round($abs/1MB, 2)) MB" } - if ($abs -ge 1KB) { return "$sign$([math]::Round($abs/1KB, 1)) KB" } - return "$sign$abs B" - } - - function FormatCount([long]$n) { '{0:N0}' -f $n } - - # Headline: Playground.exe - $basePg = $baseArts | Where-Object { $_.Rel -match 'Playground\.exe$' } | Select-Object -First 1 - $headPg = $headArts | Where-Object { $_.Rel -match 'Playground\.exe$' } | Select-Object -First 1 - - if (-not $basePg -or -not $headPg) { - throw "Playground.exe missing. base=$($basePg -ne $null) head=$($headPg -ne $null)" - } - - $pgDelta = $headPg.Size - $basePg.Size - $pgPct = if ($basePg.Size -gt 0) { [math]::Round(100 * $pgDelta / $basePg.Size, 3) } else { 0 } - - # Build a name->sizes map for delta comparison - $pairs = @{} - foreach ($a in $baseArts) { $pairs[$a.Rel] = @{ Base = $a.Size; Head = 0 } } - foreach ($a in $headArts) { - if (-not $pairs.ContainsKey($a.Rel)) { $pairs[$a.Rel] = @{ Base = 0; Head = 0 } } - $pairs[$a.Rel].Head = $a.Size - } - $rows = $pairs.Keys | ForEach-Object { - $p = $pairs[$_] - [PSCustomObject]@{ - Rel = $_ - Base = $p.Base - Head = $p.Head - Delta = $p.Head - $p.Base - } - } - - # Top movers (absolute delta, excluding Playground.exe which we already feature) - $top = $rows | - Where-Object { $_.Rel -notmatch 'Playground\.exe$' -and [math]::Abs($_.Delta) -gt 256 } | - Sort-Object { [math]::Abs($_.Delta) } -Descending | - Select-Object -First 10 - - $baseTotal = ($baseArts | Measure-Object Size -Sum).Sum - $headTotal = ($headArts | Measure-Object Size -Sum).Sum - $totalDelta = $headTotal - $baseTotal - $totalPct = if ($baseTotal -gt 0) { [math]::Round(100 * $totalDelta / $baseTotal, 3) } else { 0 } - - # Build markdown - $sb = [System.Text.StringBuilder]::new() - [void]$sb.AppendLine($marker) - [void]$sb.AppendLine('## Binary size impact') - [void]$sb.AppendLine('') - [void]$sb.AppendLine("Platform: ``$platform``  |  base ``$($baseRef.Substring(0,7))``  |  head ``$($headRef.Substring(0,7))``") - [void]$sb.AppendLine('') - [void]$sb.AppendLine('### Final linked binary') - [void]$sb.AppendLine('') - [void]$sb.AppendLine('| Artifact | Base | Head | Δ bytes | Δ% |') - [void]$sb.AppendLine('|---|---:|---:|---:|---:|') - [void]$sb.AppendLine("| **Playground.exe** | $(FormatCount $basePg.Size) | $(FormatCount $headPg.Size) | **$(FormatBytes $pgDelta)** | **$pgPct %** |") - [void]$sb.AppendLine("| Aggregate (.exe/.dll/.lib) | $(FormatCount $baseTotal) | $(FormatCount $headTotal) | $(FormatBytes $totalDelta) | $totalPct % |") - [void]$sb.AppendLine('') - - if ($top -and $top.Count -gt 0) { - [void]$sb.AppendLine('### Top movers (other static libs / DLLs, |Δ| > 256 B)') - [void]$sb.AppendLine('') - [void]$sb.AppendLine('| Artifact | Base | Head | Δ bytes |') - [void]$sb.AppendLine('|---|---:|---:|---:|') - foreach ($r in $top) { - $shortRel = $r.Rel -replace '\\', '/' - [void]$sb.AppendLine("| ``$shortRel`` | $(FormatCount $r.Base) | $(FormatCount $r.Head) | $(FormatBytes $r.Delta) |") - } - [void]$sb.AppendLine('') - } else { - [void]$sb.AppendLine('_No other artifact moved by more than 256 B._') - [void]$sb.AppendLine('') - } - - [void]$sb.AppendLine('---') - [void]$sb.AppendLine('Comparison is informational only — no threshold enforcement. Configuration matches the Win32_x64_D3D11 CI job. Two sequential Release-with-debug-info builds per run. Small deltas on unchanged libs are typically COFF timestamps.') - - $body = $sb.ToString() - $reportDir = Join-Path $env:RUNNER_TEMP 'size-check-report' - New-Item -ItemType Directory -Force $reportDir | Out-Null - $bodyFile = Join-Path $reportDir 'body.md' - $body | Set-Content -Path $bodyFile -Encoding utf8 - - # Stash the PR number alongside the body so the comment workflow can find it - # (workflow_run events don't reliably populate pull_request context for fork PRs). - '${{ github.event.pull_request.number }}' | Set-Content -Path (Join-Path $reportDir 'pr-number.txt') -Encoding utf8 - - Write-Host "=== comment body ===" - Write-Host $body - Write-Host "====================" - - # Surface in run summary too (visible on the PR's checks page even when no sticky comment posts). - $body | Add-Content -Path $env:GITHUB_STEP_SUMMARY -Encoding utf8 - - "report_dir=$reportDir" | Add-Content -Path $env:GITHUB_OUTPUT -Encoding utf8 + ./head/Scripts/MeasureBinarySize.ps1 ` + -BaseBuildDir base/build/Win32_x64 ` + -HeadBuildDir head/build/Win32_x64 ` + -Config RelWithDebInfo ` + -Platform "Win32 x64 D3D11 RelWithDebInfo" ` + -BaseRef "${{ github.event.pull_request.base.sha }}" ` + -HeadRef "${{ github.event.pull_request.head.sha }}" ` + -PrNumber ${{ github.event.pull_request.number }} ` + -OutputDir "${{ runner.temp }}/size-check-report" - name: Upload size-check report artifact uses: actions/upload-artifact@v6 with: name: size-check-report - path: ${{ steps.measure.outputs.report_dir }} + path: ${{ runner.temp }}/size-check-report if-no-files-found: error retention-days: 7 diff --git a/Scripts/MeasureBinarySize.ps1 b/Scripts/MeasureBinarySize.ps1 new file mode 100644 index 000000000..2e802dec6 --- /dev/null +++ b/Scripts/MeasureBinarySize.ps1 @@ -0,0 +1,225 @@ +<# +.SYNOPSIS + Compares binary sizes between two build trees and emits a markdown report. + +.DESCRIPTION + Enumerates every .exe / .dll / .lib under each build directory's matching + configuration subfolder, computes per-artifact size deltas, and writes a + markdown report (Playground.exe delta headline, aggregate, top 10 other + movers) to "/body.md". + + Used by the size-check CI workflow. Also useful locally: build two trees + (a master worktree and your feature branch worktree) with the same cmake + flags and point this at them. + +.PARAMETER BaseBuildDir + Path to the "base" build tree (the reference / before). + +.PARAMETER HeadBuildDir + Path to the "head" build tree (the change / after). + +.PARAMETER Config + Build configuration name (e.g. 'Release', 'RelWithDebInfo'). Artifacts are + selected by their path containing "\\". + +.PARAMETER OutputDir + Directory where body.md (and pr-number.txt, if -PrNumber is given) are + written. Created if missing. + +.PARAMETER Platform + Free-form description of the build configuration shown in the report + header. Defaults to "Win32 x64 D3D11 ". + +.PARAMETER BaseRef + Optional base git ref / SHA. Short-form shown in the report header. + +.PARAMETER HeadRef + Optional head git ref / SHA. Short-form shown in the report header. + +.PARAMETER PrNumber + Optional PR number. When provided, written to OutputDir/pr-number.txt so + the privileged comment workflow can locate the originating PR. + +.EXAMPLE + ./Scripts/MeasureBinarySize.ps1 ` + -BaseBuildDir ../master/build/win32 ` + -HeadBuildDir ../my-feature/build/win32 ` + -Config Release ` + -OutputDir ./size-report +#> + +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [string]$BaseBuildDir, + + [Parameter(Mandatory)] + [string]$HeadBuildDir, + + [Parameter(Mandatory)] + [string]$Config, + + [Parameter(Mandatory)] + [string]$OutputDir, + + [string]$Platform, + + [string]$BaseRef, + + [string]$HeadRef, + + [int]$PrNumber +) + +$ErrorActionPreference = 'Stop' + +$marker = '' + +if (-not $Platform) { + $Platform = "Win32 x64 D3D11 $Config" +} + +function Get-Artifacts { + param([string]$Root, [string]$Cfg) + + if (-not (Test-Path $Root)) { + return @() + } + Get-ChildItem $Root -Recurse -File -Include *.exe,*.dll,*.lib -ErrorAction SilentlyContinue | + Where-Object { $_.FullName -match "\\$Cfg\\" } | + ForEach-Object { + [PSCustomObject]@{ + Rel = $_.FullName.Substring($Root.Length + 1) + Size = $_.Length + } + } +} + +function Format-Bytes { + param([long]$N) + + $sign = if ($N -lt 0) { '-' } else { '+' } + $abs = [math]::Abs($N) + if ($abs -ge 1MB) { + return "$sign$([math]::Round($abs / 1MB, 2)) MB" + } + if ($abs -ge 1KB) { + return "$sign$([math]::Round($abs / 1KB, 1)) KB" + } + return "$sign$abs B" +} + +function Format-Count { + param([long]$N) + '{0:N0}' -f $N +} + +$baseArts = Get-Artifacts -Root $BaseBuildDir -Cfg $Config +$headArts = Get-Artifacts -Root $HeadBuildDir -Cfg $Config + +if ($baseArts.Count -eq 0 -or $headArts.Count -eq 0) { + throw "No artifacts found. base=$($baseArts.Count) head=$($headArts.Count). Build likely failed." +} + +$basePg = $baseArts | Where-Object { $_.Rel -match 'Playground\.exe$' } | Select-Object -First 1 +$headPg = $headArts | Where-Object { $_.Rel -match 'Playground\.exe$' } | Select-Object -First 1 + +if (-not $basePg -or -not $headPg) { + throw "Playground.exe missing. base=$($null -ne $basePg) head=$($null -ne $headPg)" +} + +$pgDelta = $headPg.Size - $basePg.Size +$pgPct = if ($basePg.Size -gt 0) { [math]::Round(100 * $pgDelta / $basePg.Size, 3) } else { 0 } + +# Build name -> {Base, Head} map so we can diff every artifact in either set. +$pairs = @{} +foreach ($a in $baseArts) { + $pairs[$a.Rel] = @{ Base = $a.Size; Head = 0 } +} +foreach ($a in $headArts) { + if (-not $pairs.ContainsKey($a.Rel)) { + $pairs[$a.Rel] = @{ Base = 0; Head = 0 } + } + $pairs[$a.Rel].Head = $a.Size +} +$rows = $pairs.Keys | ForEach-Object { + $p = $pairs[$_] + [PSCustomObject]@{ + Rel = $_ + Base = $p.Base + Head = $p.Head + Delta = $p.Head - $p.Base + } +} + +# Top movers, excluding Playground.exe (already featured). 256 B threshold trims +# COFF timestamp noise on libs whose source didn't change. +$top = $rows | + Where-Object { $_.Rel -notmatch 'Playground\.exe$' -and [math]::Abs($_.Delta) -gt 256 } | + Sort-Object { [math]::Abs($_.Delta) } -Descending | + Select-Object -First 10 + +$baseTotal = ($baseArts | Measure-Object Size -Sum).Sum +$headTotal = ($headArts | Measure-Object Size -Sum).Sum +$totalDelta = $headTotal - $baseTotal +$totalPct = if ($baseTotal -gt 0) { [math]::Round(100 * $totalDelta / $baseTotal, 3) } else { 0 } + +$sb = [System.Text.StringBuilder]::new() +[void]$sb.AppendLine($marker) +[void]$sb.AppendLine('## Binary size impact') +[void]$sb.AppendLine('') + +$refLine = "Platform: ``$Platform``" +if ($BaseRef) { $refLine += "  |  base ``$($BaseRef.Substring(0, [math]::Min(7, $BaseRef.Length)))``" } +if ($HeadRef) { $refLine += "  |  head ``$($HeadRef.Substring(0, [math]::Min(7, $HeadRef.Length)))``" } +[void]$sb.AppendLine($refLine) +[void]$sb.AppendLine('') + +[void]$sb.AppendLine('### Final linked binary') +[void]$sb.AppendLine('') +[void]$sb.AppendLine('| Artifact | Base | Head | Δ bytes | Δ% |') +[void]$sb.AppendLine('|---|---:|---:|---:|---:|') +[void]$sb.AppendLine("| **Playground.exe** | $(Format-Count -N $basePg.Size) | $(Format-Count -N $headPg.Size) | **$(Format-Bytes -N $pgDelta)** | **$pgPct %** |") +[void]$sb.AppendLine("| Aggregate (.exe/.dll/.lib) | $(Format-Count -N $baseTotal) | $(Format-Count -N $headTotal) | $(Format-Bytes -N $totalDelta) | $totalPct % |") +[void]$sb.AppendLine('') + +if ($top -and $top.Count -gt 0) { + [void]$sb.AppendLine('### Top movers (other static libs / DLLs, |Δ| > 256 B)') + [void]$sb.AppendLine('') + [void]$sb.AppendLine('| Artifact | Base | Head | Δ bytes |') + [void]$sb.AppendLine('|---|---:|---:|---:|') + foreach ($r in $top) { + $shortRel = $r.Rel -replace '\\', '/' + [void]$sb.AppendLine("| ``$shortRel`` | $(Format-Count -N $r.Base) | $(Format-Count -N $r.Head) | $(Format-Bytes -N $r.Delta) |") + } + [void]$sb.AppendLine('') +} else { + [void]$sb.AppendLine('_No other artifact moved by more than 256 B._') + [void]$sb.AppendLine('') +} + +[void]$sb.AppendLine('---') +[void]$sb.AppendLine('Comparison is informational only — no threshold enforcement. Small deltas on unchanged libs are typically COFF timestamps.') + +$body = $sb.ToString() + +New-Item -ItemType Directory -Force $OutputDir | Out-Null +$bodyFile = Join-Path $OutputDir 'body.md' +$body | Set-Content -Path $bodyFile -Encoding utf8 + +if ($PrNumber -gt 0) { + $prFile = Join-Path $OutputDir 'pr-number.txt' + $PrNumber | Set-Content -Path $prFile -Encoding utf8 +} + +# Surface in the GitHub Actions run summary when running in CI. +if ($env:GITHUB_STEP_SUMMARY) { + $body | Add-Content -Path $env:GITHUB_STEP_SUMMARY -Encoding utf8 +} + +Write-Host '=== binary size report ===' +Write-Host $body +Write-Host '==========================' +Write-Host '' +Write-Host "Headline: Playground.exe $(Format-Bytes -N $pgDelta) ($pgPct %)" +Write-Host "Output: $OutputDir"