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 new file mode 100644 index 000000000..0462a12e2 --- /dev/null +++ b/.github/workflows/size-check.yml @@ -0,0 +1,88 @@ +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 + # 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: + 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 + shell: pwsh + run: | + ./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: ${{ 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"