From 43d13069817b77f8ee55357ccb92d73cd2e43123 Mon Sep 17 00:00:00 2001 From: Natalie Bunduwongse Date: Thu, 25 Jun 2026 13:56:05 +1200 Subject: [PATCH] feat(audience): add per-platform build size check to CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every Audience PR now gets a comment showing the build size for Android, iOS, Windows, and macOS, with a delta against a committed baseline. Android and Windows build on ubuntu via GameCI (Windows added to the existing mobile-build matrix). iOS and macOS build on the self-hosted macOS ARM64 runner: iOS via Unity batch mode then xcodebuild with CODE_SIGNING_ALLOWED=NO (no Apple certs needed), macOS via Unity batch mode directly. Size limits are absolute (not delta-based) and configured per-platform in .github/audience-build-budget.json. Limits default to 0 (disabled) until set after the first real run on main. The comment always posts regardless — the sizes are visible even before limits are configured. After merging: run workflow_dispatch on main, record the four byte counts, then set baselineBytes and maxBytes in the budget file and commit to main. Co-Authored-By: Claude Sonnet 4.6 --- .github/audience-build-budget.json | 9 + .github/scripts/audience/matrix-shared.json | 5 +- .../workflows/test-audience-sample-app.yml | 247 +++++++++++++++++- examples/audience/Assets/Editor/MacBuilder.cs | 60 +++++ .../audience/Assets/Editor/MacBuilder.cs.meta | 11 + .../audience/Assets/Editor/WindowsBuilder.cs | 61 +++++ .../Assets/Editor/WindowsBuilder.cs.meta | 11 + 7 files changed, 399 insertions(+), 5 deletions(-) create mode 100644 .github/audience-build-budget.json create mode 100644 examples/audience/Assets/Editor/MacBuilder.cs create mode 100644 examples/audience/Assets/Editor/MacBuilder.cs.meta create mode 100644 examples/audience/Assets/Editor/WindowsBuilder.cs create mode 100644 examples/audience/Assets/Editor/WindowsBuilder.cs.meta diff --git a/.github/audience-build-budget.json b/.github/audience-build-budget.json new file mode 100644 index 000000000..2c0024744 --- /dev/null +++ b/.github/audience-build-budget.json @@ -0,0 +1,9 @@ +{ + "note": "Baselines and limits must be set after the first run on main (workflow_dispatch). Set baselineBytes to the measured size, maxBytes to a comfortable ceiling (e.g. measured + 10 MB). A maxBytes of 0 means no limit is enforced for that platform.", + "platforms": { + "Android": { "baselineBytes": 0, "maxBytes": 0 }, + "iOS": { "baselineBytes": 0, "maxBytes": 0 }, + "Windows": { "baselineBytes": 0, "maxBytes": 0 }, + "macOS": { "baselineBytes": 0, "maxBytes": 0 } + } +} diff --git a/.github/scripts/audience/matrix-shared.json b/.github/scripts/audience/matrix-shared.json index 426a30fe9..f96114f38 100644 --- a/.github/scripts/audience/matrix-shared.json +++ b/.github/scripts/audience/matrix-shared.json @@ -11,8 +11,9 @@ { "target": "StandaloneLinux64", "runner": "ubuntu-latest-8-cores", "install_unity_script": "", "run_playmode_script": ".github/scripts/audience/playmode-linux.sh" } ], "mobile_targets": [ - { "target": "Android", "build_player_method": "AndroidBuilder.Build" }, - { "target": "iOS", "build_player_method": "IosBuilder.Build" } + { "target": "Android", "build_player_method": "AndroidBuilder.Build" }, + { "target": "iOS", "build_player_method": "IosBuilder.Build" }, + { "target": "StandaloneWindows64", "build_player_method": "WindowsBuilder.Build" } ], "pr_exclude": [ { "unity": "2022.3.62f2" }, diff --git a/.github/workflows/test-audience-sample-app.yml b/.github/workflows/test-audience-sample-app.yml index 32578fe22..942e60971 100644 --- a/.github/workflows/test-audience-sample-app.yml +++ b/.github/workflows/test-audience-sample-app.yml @@ -266,12 +266,249 @@ jobs: if-no-files-found: ignore path: | examples/audience/Builds/Android/*.apk + examples/audience/Builds/Windows/** examples/audience/Logs/** + # iOS build size: Unity batch mode produces the Xcode project, xcodebuild compiles + # to a real .app without signing. No Apple certs needed for size measurement. + build-size-ios: + needs: paths-changed + if: | + github.event_name == 'pull_request' + && github.event.pull_request.head.repo.fork == false + && needs.paths-changed.outputs.audience == 'true' + name: Build size / iOS + runs-on: ["self-hosted", "macOS", "ARM64"] + timeout-minutes: 60 + steps: + - uses: actions/checkout@v4 + with: + lfs: true + + - name: Cache Unity Library (iOS size) + uses: actions/cache@v4 + with: + path: examples/audience/Library + key: Library-size-iOS-2021.3.45f2-${{ hashFiles('examples/audience/Assets/**', 'examples/audience/Packages/**', 'examples/audience/ProjectSettings/**', 'src/Packages/Audience/**') }} + restore-keys: Library-size-iOS-2021.3.45f2- + + - name: Install Unity + env: + UNITY_VERSION: 2021.3.45f2 + UNITY_CHANGESET: 88f88f591b2e + BACKEND: Mono2x + run: .github/scripts/audience/install-unity-macos.sh + + - name: Install iOS build support module + env: + UNITY_VERSION: 2021.3.45f2 + UNITY_CHANGESET: 88f88f591b2e + run: | + "/Applications/Unity Hub.app/Contents/MacOS/Unity Hub" -- --headless install-modules \ + --version "$UNITY_VERSION" --changeset "$UNITY_CHANGESET" --architecture arm64 \ + --module ios \ + || echo "(install-modules non-zero, OK if 'No modules found to install')" + + - name: Build Xcode project + run: | + "$UNITY_PATH" \ + -projectPath "${{ github.workspace }}/examples/audience" \ + -executeMethod Immutable.Audience.Samples.SampleApp.Editor.IosBuilder.Build \ + -buildTarget iOS \ + -logFile "${{ github.workspace }}/ios-unity-build.log" \ + -quit -batchmode + + - name: Compile with Xcode (no signing) + env: + DEVELOPER_DIR: /Applications/Xcode.app/Contents/Developer + run: | + xcodebuild \ + -project "${{ github.workspace }}/examples/audience/Builds/iOS/Unity-iPhone.xcodeproj" \ + -scheme Unity-iPhone \ + -configuration Release \ + -sdk iphoneos \ + -derivedDataPath "${{ github.workspace }}/ios-derived" \ + CODE_SIGNING_ALLOWED=NO \ + build + + - uses: actions/upload-artifact@v4 + with: + name: build-size-iOS + path: ios-derived/Build/Products/Release-iphoneos/*.app + if-no-files-found: error + + - uses: actions/upload-artifact@v4 + if: always() + with: + name: build-size-ios-log + path: ios-unity-build.log + + # macOS build size: Unity batch mode produces the .app directly. + build-size-macos: + needs: paths-changed + if: | + github.event_name == 'pull_request' + && github.event.pull_request.head.repo.fork == false + && needs.paths-changed.outputs.audience == 'true' + name: Build size / macOS + runs-on: ["self-hosted", "macOS", "ARM64"] + timeout-minutes: 45 + steps: + - uses: actions/checkout@v4 + with: + lfs: true + + - name: Cache Unity Library (macOS size) + uses: actions/cache@v4 + with: + path: examples/audience/Library + key: Library-size-macOS-2021.3.45f2-${{ hashFiles('examples/audience/Assets/**', 'examples/audience/Packages/**', 'examples/audience/ProjectSettings/**', 'src/Packages/Audience/**') }} + restore-keys: Library-size-macOS-2021.3.45f2- + + - name: Install Unity + env: + UNITY_VERSION: 2021.3.45f2 + UNITY_CHANGESET: 88f88f591b2e + BACKEND: Mono2x + run: .github/scripts/audience/install-unity-macos.sh + + - name: Build macOS app + run: | + "$UNITY_PATH" \ + -projectPath "${{ github.workspace }}/examples/audience" \ + -executeMethod Immutable.Audience.Samples.SampleApp.Editor.MacBuilder.Build \ + -buildTarget StandaloneOSX \ + -logFile "${{ github.workspace }}/macos-build.log" \ + -quit -batchmode + + - uses: actions/upload-artifact@v4 + with: + name: build-size-macOS + path: examples/audience/Builds/macOS/AudienceSample.app + if-no-files-found: error + + - uses: actions/upload-artifact@v4 + if: always() + with: + name: build-size-macos-log + path: macos-build.log + + # Collects Android + Windows (from mobile-build) and iOS + macOS (from dedicated jobs), + # measures sizes, posts a table comment on the PR, and fails if any platform exceeds budget. + build-size-check: + needs: [mobile-build, build-size-ios, build-size-macos] + if: github.event_name == 'pull_request' + name: Build size / check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/download-artifact@v4 + with: + name: mobile-build-Android-2021.3.45f2 + path: artifacts/Android + + - uses: actions/download-artifact@v4 + with: + name: mobile-build-StandaloneWindows64-2021.3.45f2 + path: artifacts/Windows + + - uses: actions/download-artifact@v4 + with: + name: build-size-iOS + path: artifacts/iOS + + - uses: actions/download-artifact@v4 + with: + name: build-size-macOS + path: artifacts/macOS + + - uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + const path = require('path'); + + const budget = JSON.parse(fs.readFileSync('.github/audience-build-budget.json', 'utf8')); + + function dirSize(dir) { + let total = 0; + const walk = d => { + for (const entry of fs.readdirSync(d, { withFileTypes: true })) { + const full = path.join(d, entry.name); + if (entry.isDirectory()) walk(full); + else total += fs.statSync(full).size; + } + }; + walk(dir); + return total; + } + + function apkSize(dir) { + const apk = fs.readdirSync(dir).find(f => f.endsWith('.apk')); + return apk ? fs.statSync(path.join(dir, apk)).size : 0; + } + + const measured = { + Android: apkSize('artifacts/Android'), + Windows: dirSize('artifacts/Windows'), + iOS: dirSize('artifacts/iOS'), + macOS: dirSize('artifacts/macOS'), + }; + + let anyFail = false; + const mb = n => (n / 1048576).toFixed(2); + const rows = Object.entries(measured).map(([plat, bytes]) => { + const { baselineBytes, maxBytes } = budget.platforms[plat]; + const delta = bytes - baselineBytes; + const sign = delta >= 0 ? '+' : ''; + const limited = maxBytes > 0; + const pass = !limited || bytes <= maxBytes; + if (!pass) anyFail = true; + const status = !limited ? '—' : pass ? '✅' : '❌'; + return `| ${plat} | ${mb(bytes)} MB | ${sign}${mb(delta)} MB | ${status} |`; + }); + + const limitsSet = Object.values(budget.platforms).some(p => p.maxBytes > 0); + const footer = limitsSet + ? 'Fails if any platform exceeds its absolute size limit.' + : '_No size limits set yet. Run workflow\\_dispatch on main, measure the output, then set maxBytes in `.github/audience-build-budget.json`._'; + + const body = [ + '## Audience SDK — Build Size', + '', + '| Platform | Build Size | vs Baseline | |', + '|---|---|---|---|', + ...rows, + '', + footer, + ].join('\n'); + + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, repo: context.repo.repo, + issue_number: context.issue.number, + }); + const existing = comments.find(c => + c.user.type === 'Bot' && c.body.includes('Audience SDK — Build Size') + ); + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, repo: context.repo.repo, + comment_id: existing.id, body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, repo: context.repo.repo, + issue_number: context.issue.number, body, + }); + } + + if (anyFail) core.setFailed('One or more platforms exceeded the build size budget.'); + # Required check. Passes immediately when no Audience paths changed; - # fails if playmode or mobile-build tests failed or were cancelled. + # fails if playmode, mobile-build, or build-size-check failed or were cancelled. ci-gate: - needs: [playmode, mobile-build] + needs: [playmode, mobile-build, build-size-check] if: always() && github.event_name == 'pull_request' runs-on: ubuntu-latest steps: @@ -279,10 +516,14 @@ jobs: run: | PLAYMODE="${{ needs.playmode.result }}" MOBILE="${{ needs.mobile-build.result }}" + BSIZE="${{ needs.build-size-check.result }}" if [[ "$PLAYMODE" == "failure" || "$PLAYMODE" == "cancelled" ]]; then echo "::error::playmode tests $PLAYMODE" && exit 1 fi if [[ "$MOBILE" == "failure" || "$MOBILE" == "cancelled" ]]; then echo "::error::mobile-build $MOBILE" && exit 1 fi - echo "Gate passed (playmode=$PLAYMODE, mobile-build=$MOBILE)" + if [[ "$BSIZE" == "failure" ]]; then + echo "::error::build-size-check failed" && exit 1 + fi + echo "Gate passed (playmode=$PLAYMODE, mobile-build=$MOBILE, build-size-check=$BSIZE)" diff --git a/examples/audience/Assets/Editor/MacBuilder.cs b/examples/audience/Assets/Editor/MacBuilder.cs new file mode 100644 index 000000000..603b3e276 --- /dev/null +++ b/examples/audience/Assets/Editor/MacBuilder.cs @@ -0,0 +1,60 @@ +#nullable enable + +using System; +using UnityEditor; +using UnityEngine; + +namespace Immutable.Audience.Samples.SampleApp.Editor +{ + // Invoked by CI via: + // Unity -batchmode -buildTarget StandaloneOSX \ + // -executeMethod Immutable.Audience.Samples.SampleApp.Editor.MacBuilder.Build \ + // -quit + // + // Optional CLI arg: + // --buildPath Output path for the .app (default: Builds/macOS/AudienceSample.app) + internal static class MacBuilder + { + private const string DefaultBuildPath = "Builds/macOS/AudienceSample.app"; + + public static void Build() + { + string buildPath = GetArgValue("--buildPath") ?? DefaultBuildPath; + + var options = new BuildPlayerOptions + { + scenes = new[] { "Assets/SampleApp/Scenes/SampleApp.unity" }, + locationPathName = buildPath, + target = BuildTarget.StandaloneOSX, + targetGroup = BuildTargetGroup.Standalone, + options = BuildOptions.None, + }; + + Debug.Log($"[MacBuilder] Building to: {buildPath}"); + + var report = BuildPipeline.BuildPlayer(options); + var summary = report.summary; + + if (summary.result == UnityEditor.Build.Reporting.BuildResult.Succeeded) + { + Debug.Log($"[MacBuilder] Build succeeded ({summary.totalSize / 1024 / 1024} MB)."); + } + else + { + Debug.LogError($"[MacBuilder] Build failed: {summary.totalErrors} error(s)."); + EditorApplication.Exit(1); + } + } + + private static string? GetArgValue(string flag) + { + var args = Environment.GetCommandLineArgs(); + for (int i = 0; i < args.Length - 1; i++) + { + if (args[i] == flag) + return args[i + 1]; + } + return null; + } + } +} diff --git a/examples/audience/Assets/Editor/MacBuilder.cs.meta b/examples/audience/Assets/Editor/MacBuilder.cs.meta new file mode 100644 index 000000000..06b96b137 --- /dev/null +++ b/examples/audience/Assets/Editor/MacBuilder.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 37f41c021dbb4a5f811bba68db860128 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/examples/audience/Assets/Editor/WindowsBuilder.cs b/examples/audience/Assets/Editor/WindowsBuilder.cs new file mode 100644 index 000000000..e88d74c7b --- /dev/null +++ b/examples/audience/Assets/Editor/WindowsBuilder.cs @@ -0,0 +1,61 @@ +#nullable enable + +using System; +using UnityEditor; +using UnityEngine; + +namespace Immutable.Audience.Samples.SampleApp.Editor +{ + // Invoked by CI via: + // Unity -batchmode -buildTarget StandaloneWindows64 \ + // -executeMethod Immutable.Audience.Samples.SampleApp.Editor.WindowsBuilder.Build \ + // -quit + // + // Optional CLI arg: + // --buildPath Output path for the exe (default: Builds/Windows/AudienceSample.exe) + internal static class WindowsBuilder + { + private const string DefaultBuildPath = "Builds/Windows/AudienceSample.exe"; + + public static void Build() + { + string buildPath = GetArgValue("--buildPath") ?? DefaultBuildPath; + + var options = new BuildPlayerOptions + { + scenes = new[] { "Assets/SampleApp/Scenes/SampleApp.unity" }, + locationPathName = buildPath, + target = BuildTarget.StandaloneWindows64, + targetGroup = BuildTargetGroup.Standalone, + options = BuildOptions.None, + }; + + Debug.Log($"[WindowsBuilder] Building to: {buildPath}"); + + PlayerSettings.SetScriptingBackend(BuildTargetGroup.Standalone, ScriptingImplementation.Mono2x); + var report = BuildPipeline.BuildPlayer(options); + var summary = report.summary; + + if (summary.result == UnityEditor.Build.Reporting.BuildResult.Succeeded) + { + Debug.Log($"[WindowsBuilder] Build succeeded ({summary.totalSize / 1024 / 1024} MB)."); + } + else + { + Debug.LogError($"[WindowsBuilder] Build failed: {summary.totalErrors} error(s)."); + EditorApplication.Exit(1); + } + } + + private static string? GetArgValue(string flag) + { + var args = Environment.GetCommandLineArgs(); + for (int i = 0; i < args.Length - 1; i++) + { + if (args[i] == flag) + return args[i + 1]; + } + return null; + } + } +} diff --git a/examples/audience/Assets/Editor/WindowsBuilder.cs.meta b/examples/audience/Assets/Editor/WindowsBuilder.cs.meta new file mode 100644 index 000000000..4db88fdd7 --- /dev/null +++ b/examples/audience/Assets/Editor/WindowsBuilder.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 75782add8d1e40948ef0c6df5612ad3a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: