diff --git a/.github/scripts/audience/ensure-msvc-windows.ps1 b/.github/scripts/audience/ensure-msvc-windows.ps1 new file mode 100644 index 000000000..8dbba22f6 --- /dev/null +++ b/.github/scripts/audience/ensure-msvc-windows.ps1 @@ -0,0 +1,59 @@ +# Ensures Visual Studio Build Tools (VC.Tools + Win10 SDK) are present on the runner. +# Workflow caller: .github/workflows/test-audience-sample-app.yml (Windows IL2CPP cells). + +$vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" + +# Match Unity's detection logic: vswhere requires VC.Tools (any version), registry +# probe for any Win10 SDK at v10.0/InstallationFolder. Pinning a specific SDK +# version in -requires is too strict; VCTools ships with whatever Win10 SDK is +# current, and Unity accepts any. +function Test-Toolchain { + $vc = if (Test-Path $vswhere) { + & $vswhere -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath 2>$null + } else { '' } + $sdk = (Get-ItemProperty 'HKLM:\SOFTWARE\Wow6432Node\Microsoft\Microsoft SDKs\Windows\v10.0' -ErrorAction SilentlyContinue).InstallationFolder + return @{ VcTools = $vc; Win10Sdk = $sdk } +} + +$state = Test-Toolchain +if ($state.VcTools -and $state.Win10Sdk) { + Write-Output "VC.Tools at: $($state.VcTools)" + Write-Output "Win10 SDK at: $($state.Win10Sdk)" + exit 0 +} +Write-Output "Toolchain incomplete. VC.Tools='$($state.VcTools)' Win10Sdk='$($state.Win10Sdk)'" + +Write-Output "::group::Install VS 2022 Build Tools (VCTools + Win10 SDK)" +$installer = "$env:RUNNER_TEMP\vs_BuildTools.exe" +Invoke-WebRequest -Uri 'https://aka.ms/vs/17/release/vs_BuildTools.exe' -OutFile $installer + +$installArgs = @( + '--quiet','--wait','--norestart','--nocache', + '--add','Microsoft.VisualStudio.Workload.VCTools', + '--add','Microsoft.VisualStudio.Component.VC.Tools.x86.x64', + '--add','Microsoft.VisualStudio.Component.Windows10SDK.20348', + '--includeRecommended' +) +$p = Start-Process -FilePath $installer -ArgumentList $installArgs -Wait -PassThru -NoNewWindow +# 3010 = success, reboot pending (tools are usable without reboot). +if ($p.ExitCode -ne 0 -and $p.ExitCode -ne 3010) { + Write-Output "::error::VS Build Tools installer exited $($p.ExitCode)" + exit $p.ExitCode +} +Write-Output "::endgroup::" + +$state = Test-Toolchain +if (-not ($state.VcTools -and $state.Win10Sdk)) { + Write-Output "::group::diagnostic" + Write-Output "VC.Tools path (vswhere): '$($state.VcTools)'" + Write-Output "Win10 SDK (registry v10.0/InstallationFolder): '$($state.Win10Sdk)'" + Write-Output "--- all VS installations ---" + if (Test-Path $vswhere) { & $vswhere -all -products * -format json } + Write-Output "--- HKLM Win10 SDK roots ---" + Get-ChildItem 'HKLM:\SOFTWARE\Wow6432Node\Microsoft\Microsoft SDKs\Windows' -ErrorAction SilentlyContinue | Format-List + Write-Output "::endgroup::" + Write-Output "::error::Install reported success but VC.Tools or Win10 SDK still not detected. Runner service account likely lacks admin to install system-wide. Install VS Build Tools manually on IMX_SDKBUILD: vs_BuildTools.exe --quiet --wait --add Microsoft.VisualStudio.Workload.VCTools --includeRecommended" + exit 1 +} +Write-Output "Verified VC.Tools at: $($state.VcTools)" +Write-Output "Verified Win10 SDK at: $($state.Win10Sdk)" diff --git a/.github/scripts/audience/install-unity-macos.sh b/.github/scripts/audience/install-unity-macos.sh new file mode 100755 index 000000000..a54a4db6f --- /dev/null +++ b/.github/scripts/audience/install-unity-macos.sh @@ -0,0 +1,56 @@ +#!/bin/bash +# Installs the Unity editor and (for IL2CPP cells) the mac-il2cpp module. +# Idempotent. Sets UNITY_PATH in GITHUB_ENV so the playmode step picks it up. +# Workflow caller: .github/workflows/test-audience-sample-app.yml (playmode job). +# +# Inputs (env): UNITY_VERSION, UNITY_CHANGESET, BACKEND. + +set -uo pipefail + +HUB="/Applications/Unity Hub.app/Contents/MacOS/Unity Hub" + +echo "::group::install editor" +"$HUB" -- --headless install \ + --version "$UNITY_VERSION" --changeset "$UNITY_CHANGESET" --architecture arm64 \ + || echo "(install non-zero, OK if 'Editor already installed in this location')" +echo "::endgroup::" + +if [ "$BACKEND" = "IL2CPP" ]; then + echo "::group::install mac-il2cpp module" + "$HUB" -- --headless install-modules \ + --version "$UNITY_VERSION" --changeset "$UNITY_CHANGESET" --architecture arm64 \ + --module mac-il2cpp \ + || echo "(install-modules non-zero, OK if 'No modules found to install')" + echo "::endgroup::" +fi + +EDITOR_APP="" +for cand in \ + "/Applications/Unity/Hub/Editor/$UNITY_VERSION-arm64/Unity.app" \ + "/Applications/Unity/Hub/Editor/$UNITY_VERSION/Unity.app"; do + if [ -x "$cand/Contents/MacOS/Unity" ]; then EDITOR_APP="$cand"; break; fi +done + +IL2CPP_DIR="" +if [ "$BACKEND" = "IL2CPP" ] && [ -n "$EDITOR_APP" ]; then + for d in \ + "$EDITOR_APP/Contents/PlaybackEngines/MacStandaloneSupport/Variations/macos_arm64_player_nondevelopment_il2cpp" \ + "$EDITOR_APP/Contents/PlaybackEngines/MacStandaloneSupport/Variations/macos_x64_player_nondevelopment_il2cpp"; do + if [ -d "$d" ]; then IL2CPP_DIR="$d"; break; fi + done +fi + +MISSING="" +[ -z "$EDITOR_APP" ] && MISSING="editor" +[ "$BACKEND" = "IL2CPP" ] && [ -z "$IL2CPP_DIR" ] && MISSING="${MISSING:+$MISSING+}mac-il2cpp" +if [ -n "$MISSING" ]; then + echo "::error::Unity $UNITY_VERSION missing: $MISSING" + ls -la /Applications/Unity/Hub/Editor/ 2>&1 || true + "$HUB" -- --headless editors --installed 2>&1 || true + exit 1 +fi + +UNITY_PATH="$EDITOR_APP/Contents/MacOS/Unity" +echo "Found Unity: $UNITY_PATH" +[ -n "$IL2CPP_DIR" ] && echo "Found IL2CPP: $IL2CPP_DIR" +echo "UNITY_PATH=$UNITY_PATH" >> "$GITHUB_ENV" diff --git a/.github/scripts/audience/install-unity-windows.ps1 b/.github/scripts/audience/install-unity-windows.ps1 new file mode 100644 index 000000000..ebceef1d4 --- /dev/null +++ b/.github/scripts/audience/install-unity-windows.ps1 @@ -0,0 +1,39 @@ +# Installs the Unity editor and (for IL2CPP cells) the windows-il2cpp module. +# Idempotent. Sets UNITY_PATH in GITHUB_ENV so the playmode step picks it up. +# Workflow caller: .github/workflows/test-audience-sample-app.yml (playmode job). +# +# Inputs (env): UNITY_VERSION, UNITY_CHANGESET, BACKEND. + +$ErrorActionPreference = 'Continue' +$hub = "C:\Program Files\Unity Hub\Unity Hub.exe" + +Write-Output "::group::install editor" +& $hub -- --headless install --version $env:UNITY_VERSION --changeset $env:UNITY_CHANGESET --architecture x86_64 2>&1 | Write-Output +if ($LASTEXITCODE -ne 0) { Write-Output "(install non-zero, OK if 'Editor already installed in this location')" } +$global:LASTEXITCODE = 0 +Write-Output "::endgroup::" + +if ($env:BACKEND -eq 'IL2CPP') { + Write-Output "::group::install windows-il2cpp module" + & $hub -- --headless install-modules --version $env:UNITY_VERSION --changeset $env:UNITY_CHANGESET --architecture x86_64 --module windows-il2cpp 2>&1 | Write-Output + if ($LASTEXITCODE -ne 0) { Write-Output "(install-modules non-zero, OK if 'No modules found to install')" } + $global:LASTEXITCODE = 0 + Write-Output "::endgroup::" +} + +$editor = "C:\Program Files\Unity\Hub\Editor\$env:UNITY_VERSION\Editor\Unity.exe" +$il2cpp = "C:\Program Files\Unity\Hub\Editor\$env:UNITY_VERSION\Editor\Data\PlaybackEngines\windowsstandalonesupport\Variations\win64_player_nondevelopment_il2cpp" +$missing = @() +if (-not (Test-Path $editor)) { $missing += 'editor' } +if ($env:BACKEND -eq 'IL2CPP' -and -not (Test-Path $il2cpp)) { $missing += 'windows-il2cpp' } +if ($missing.Count -gt 0) { + Write-Output "::error::Unity $env:UNITY_VERSION missing: $($missing -join '+')" + Get-ChildItem "C:\Program Files\Unity\Hub\Editor\" -ErrorAction SilentlyContinue | Format-Table + & $hub -- --headless editors --installed + exit 1 +} + +Write-Output "Found Unity: $editor" +if ($env:BACKEND -eq 'IL2CPP') { Write-Output "Found IL2CPP: $il2cpp" } + +"UNITY_PATH=$editor" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 diff --git a/.github/scripts/audience/matrix-shared.json b/.github/scripts/audience/matrix-shared.json new file mode 100644 index 000000000..91018b4f9 --- /dev/null +++ b/.github/scripts/audience/matrix-shared.json @@ -0,0 +1,20 @@ +{ + "unity_versions": [ + { "version": "2021.3.45f2", "changeset": "88f88f591b2e" }, + { "version": "6000.4.0f1", "changeset": "8cf496087c8f" }, + { "version": "2022.3.62f2", "changeset": "7670c08855a9" } + ], + "scripting_backends": ["IL2CPP", "Mono2x"], + "desktop_targets": [ + { "target": "StandaloneWindows64", "runner": ["self-hosted", "Windows", "X64"], "install_unity_script": ".github/scripts/audience/install-unity-windows.ps1", "run_playmode_script": ".github/scripts/audience/playmode-windows.ps1" }, + { "target": "StandaloneOSX", "runner": ["self-hosted", "macOS", "ARM64"], "install_unity_script": ".github/scripts/audience/install-unity-macos.sh", "run_playmode_script": ".github/scripts/audience/playmode-macos.sh" }, + { "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" } + ], + "pr_exclude": [ + { "unity": { "version": "2022.3.62f2", "changeset": "7670c08855a9" } } + ] +} diff --git a/.github/scripts/audience/playmode-linux-container.sh b/.github/scripts/audience/playmode-linux-container.sh new file mode 100755 index 000000000..991f73eeb --- /dev/null +++ b/.github/scripts/audience/playmode-linux-container.sh @@ -0,0 +1,118 @@ +#!/bin/bash +# Audience SDK PlayMode test runner for Linux: in-container body. +# Runs inside the unityci/editor:ubuntu-X-linux-il2cpp-3 container. +# Caller: .github/scripts/audience/playmode-linux.sh (host-side docker wrapper). + +set -uo pipefail + +LOG=/github/workspace/artifacts/unity.log +ACTIVATION_LOG=/github/workspace/artifacts/activation.log +RESULTS=/github/workspace/artifacts/test-results.xml +PROJECT=/github/workspace/examples/audience + +test_rc=1 + +activate_license() { + unity-editor -batchmode -nographics -quit \ + -username "$UNITY_EMAIL" \ + -password "$UNITY_PASSWORD" \ + -serial "$UNITY_SERIAL" \ + -logFile - 2>&1 | tee "$ACTIVATION_LOG" || true + + if grep -qE "License activation has failed|\[Licensing::Client\] Error: Code [0-9]+" "$ACTIVATION_LOG"; then + echo "::error::Unity license activation failed." + exit 1 + fi + if ! grep -qE "Successfully activated the entitlement license" "$ACTIVATION_LOG"; then + echo "::error::Unity license activation: no success marker in log." + exit 1 + fi +} + +run_tests_with_watchdog() { + # xvfb-run gives Unity a virtual X display. UI Toolkit needs GLX + render; + # llvmpipe in the image provides software OpenGL so no GPU is needed. + # -force-glcore skips the Unity 6 Vulkan init and matches the Unity 2021.3 default path. + xvfb-run -a --server-args="-ac +extension GLX +render -noreset" -- \ + unity-editor \ + -batchmode \ + -force-glcore \ + -screen-fullscreen 0 \ + -screen-width 320 \ + -screen-height 240 \ + -projectPath "$PROJECT" \ + -runTests \ + -testPlatform StandaloneLinux64 \ + -testResults "$RESULTS" \ + -logFile "$LOG" & + local unity_pid=$! + + # Mirror Unity log to job stdout while the editor is alive. + tail --pid=$unity_pid -F "$LOG" 2>/dev/null & + + # Watchdog (vs fixed timeout) because per-version run length varies wildly: + # Unity 2021.3 cells finish in ~2 min, Unity 6 in ~22 min, and Unity 6 has a + # known post-test shutdown hang. SIGTERM 30 s after "Test run completed" so + # each cell exits as soon as its suite finishes. 40 min hard cap as fallback. + local deadline=$((SECONDS + 2400)) + local flush_deadline=0 + local kill_reason="" + while kill -0 "$unity_pid" 2>/dev/null; do + if [ "$SECONDS" -ge "$deadline" ]; then + kill_reason="hard-cap-40m" + break + fi + if [ "$flush_deadline" -eq 0 ] && grep -q "Test run completed" "$LOG" 2>/dev/null; then + flush_deadline=$((SECONDS + 30)) + echo "[watchdog] saw \"Test run completed\" at ${SECONDS}s; SIGTERM after 30s flush window" + fi + if [ "$flush_deadline" -gt 0 ] && [ "$SECONDS" -ge "$flush_deadline" ]; then + kill_reason="flush-window-elapsed" + break + fi + sleep 5 + done + + if [ -n "$kill_reason" ]; then + echo "[watchdog] sending SIGTERM to Unity (reason: $kill_reason)" + kill -TERM "$unity_pid" 2>/dev/null || true + # 15 s grace, then SIGKILL. + for _ in 1 2 3; do + kill -0 "$unity_pid" 2>/dev/null || break + sleep 5 + done + if kill -0 "$unity_pid" 2>/dev/null; then + echo "[watchdog] SIGTERM not honored, sending SIGKILL" + kill -KILL "$unity_pid" 2>/dev/null || true + fi + fi + + wait "$unity_pid" 2>/dev/null + test_rc=$? + if [ "$kill_reason" = "hard-cap-40m" ]; then + echo "::warning::Unity hit the 40 min hard cap without logging \"Test run completed\". Inspect Player.log." + fi +} + +capture_player_log() { + # Player runs in a separate process from the editor; copy its Player.log so + # HTTP traces and OnError fires are captured. Glob across companies / products. + find /root/.config/unity3d -name "Player.log" 2>/dev/null | while IFS= read -r f; do + co=$(basename "$(dirname "$(dirname "$f")")") + pr=$(basename "$(dirname "$f")") + cp "$f" "/github/workspace/artifacts/Player-${co}-${pr}.log" 2>/dev/null || true + done +} + +return_license() { + # Always return the seat to keep the activation pool from exhausting on reruns. + unity-editor -batchmode -nographics -quit -returnlicense -logFile - 2>&1 || true +} + +activate_license +run_tests_with_watchdog +capture_player_log +return_license + +# Unity exits 2 on test failure or inconclusive; propagate so the step fails. +exit "$test_rc" diff --git a/.github/scripts/audience/playmode-linux.sh b/.github/scripts/audience/playmode-linux.sh new file mode 100755 index 000000000..f83e6eac8 --- /dev/null +++ b/.github/scripts/audience/playmode-linux.sh @@ -0,0 +1,27 @@ +#!/bin/bash +# Audience SDK PlayMode test runner for Linux: host-side docker wrapper. +# Runs unityci/editor with the env and volume mounts the inner +# playmode-linux-container.sh expects. Lives outside the container so the +# workflow can launch all 3 desktop platforms from one matrix-shared.json entry. +# +# Manual docker run because game-ci/unity-test-runner@v4 hardcodes +# -nographics. Without a virtual display every PlayMode test comes back +# inconclusive, and the action's USE_EXIT_CODE=false suppresses Unity +# exit 2, so cells went silently green. +# +# Workflow caller: .github/workflows/test-audience-sample-app.yml (playmode job). +# Inputs (env): UNITY_VERSION, UNITY_EMAIL, UNITY_PASSWORD, UNITY_SERIAL, +# AUDIENCE_TEST_PUBLISHABLE_KEY, AUDIENCE_SCRIPTING_BACKEND. + +set -uo pipefail +mkdir -p artifacts + +docker run --rm \ + --workdir /github/workspace \ + --env UNITY_EMAIL --env UNITY_PASSWORD --env UNITY_SERIAL \ + --env AUDIENCE_TEST_PUBLISHABLE_KEY --env AUDIENCE_SCRIPTING_BACKEND \ + --env AUDIENCE_TEST_RUN_ID --env AUDIENCE_TEST_CELL_ID --env AUDIENCE_TEST_JOB_ID \ + --volume "$PWD":/github/workspace:z \ + --cpus=8 --memory=30487m \ + "unityci/editor:ubuntu-${UNITY_VERSION}-linux-il2cpp-3" \ + bash /github/workspace/.github/scripts/audience/playmode-linux-container.sh diff --git a/.github/scripts/audience/playmode-macos.sh b/.github/scripts/audience/playmode-macos.sh new file mode 100755 index 000000000..881e817d1 --- /dev/null +++ b/.github/scripts/audience/playmode-macos.sh @@ -0,0 +1,45 @@ +#!/bin/bash +# Runs the audience PlayMode tests on macOS. Captures Player.log into artifacts/. +# Surfaces Unity compile errors as ::error:: annotations. +# Workflow caller: .github/workflows/test-audience-sample-app.yml (playmode job). +# +# Inputs (env): UNITY_PATH (set by install-unity-macos.sh), TARGET. + +set -uo pipefail + +LOG=artifacts/unity.log +RESULTS="$(pwd)/artifacts/test-results.xml" + +mkdir -p artifacts + +# Tee Unity stdout to artifacts/unity.log so the annotation step has a file +# to scan; pipefail propagates Unity's exit code through tee. +"$UNITY_PATH" \ + -batchmode -nographics \ + -projectPath examples/audience \ + -runTests \ + -testPlatform "$TARGET" \ + -testResults "$RESULTS" \ + -logFile - 2>&1 | tee "$LOG" +test_rc=${PIPESTATUS[0]} + +# Player runs as a separate process; copy its Player.log so HTTP traces and +# OnError fires are captured. Glob across companies and products. +src="$HOME/Library/Logs" +if [ -d "$src" ]; then + find "$src" -name "Player.log" 2>/dev/null | while IFS= read -r f; do + cp "$f" "artifacts/Player-$(basename "$(dirname "$f")").log" 2>/dev/null || true + done +fi + +# Promote Unity compile errors to ::error:: annotations. Sanitize '::' so log +# lines containing workflow commands cannot terminate the annotation early. +if [ -f "$LOG" ]; then + grep -E '(error CS[0-9]+:|Compilation failed:)' "$LOG" | sort -u | while IFS= read -r line; do + trimmed="${line#"${line%%[![:space:]]*}"}" + sanitized="${trimmed//::/%3A%3A}" + echo "::error::$sanitized" + done || true +fi + +exit "$test_rc" diff --git a/.github/scripts/audience/playmode-windows.ps1 b/.github/scripts/audience/playmode-windows.ps1 new file mode 100644 index 000000000..5b718d05c --- /dev/null +++ b/.github/scripts/audience/playmode-windows.ps1 @@ -0,0 +1,51 @@ +# Runs the audience PlayMode tests on Windows. Captures Player.log into artifacts/. +# Surfaces Unity compile errors as ::error:: annotations. +# Workflow caller: .github/workflows/test-audience-sample-app.yml (playmode job). +# +# Inputs (env): UNITY_PATH (set by install-unity-windows.ps1), TARGET. + +$ErrorActionPreference = 'Continue' +$logFile = "$pwd\artifacts\unity.log" +$resultsFile = "$pwd\artifacts\test-results.xml" + +New-Item -ItemType Directory -Force -Path artifacts | Out-Null + +$unityArgs = @( + '-batchmode','-nographics', + '-projectPath','examples/audience', + '-runTests', + '-testPlatform',$env:TARGET, + '-testResults',$resultsFile, + '-logFile',$logFile +) +Write-Output "Launching Unity: $env:UNITY_PATH $($unityArgs -join ' ')" +$p = Start-Process -FilePath $env:UNITY_PATH -ArgumentList $unityArgs -Wait -PassThru -NoNewWindow +Write-Output "::group::Unity log" +Get-Content $logFile -ErrorAction SilentlyContinue | Write-Output +Write-Output "::endgroup::" +Write-Output "Unity exited with code $($p.ExitCode)" + +# Copy Player.log files into artifacts so HTTP traces and OnError fires survive. +$src = "$env:USERPROFILE\AppData\LocalLow" +if (Test-Path $src) { + Get-ChildItem -Path $src -Recurse -Filter "Player.log" -ErrorAction SilentlyContinue | + ForEach-Object { + $name = $_.Directory.Name + Copy-Item -Path $_.FullName -Destination "artifacts/Player-$name.log" -ErrorAction SilentlyContinue + } +} + +# Promote Unity compile errors to ::error:: annotations. Sanitize '::' so log +# lines containing workflow commands cannot terminate the annotation early. +if (Test-Path $logFile) { + Get-Content $logFile | + Select-String -Pattern '(error CS\d+:|Compilation failed:)' | + ForEach-Object { $_.Line.Trim() } | + Sort-Object -Unique | + ForEach-Object { + $sanitized = $_ -replace '::', '%3A%3A' + Write-Output "::error::$sanitized" + } +} + +exit $p.ExitCode diff --git a/.github/workflows/test-audience-sample-app.yml b/.github/workflows/test-audience-sample-app.yml index 9263173ef..c13fcd724 100644 --- a/.github/workflows/test-audience-sample-app.yml +++ b/.github/workflows/test-audience-sample-app.yml @@ -1,4 +1,4 @@ -name: Audience SDK — PlayMode (IL2CPP + Mono) +name: Audience SDK PlayMode (IL2CPP + Mono) on: pull_request: @@ -13,11 +13,7 @@ on: - 'examples/audience/ProjectSettings/**' - '.github/workflows/test-audience-sample-app.yml' schedule: - # Weekly full-matrix run on the default branch. - # Cron is UTC; cron has no DST awareness, so the local time shifts by one - # hour twice a year. Saturday 14:00 UTC maps to: - # Sun 00:00 Sydney AEST / Sun 02:00 NZ NZST (winter, Apr to Oct) - # Sun 01:00 Sydney AEDT / Sun 03:00 NZ NZDT (summer, Oct to Apr) + # Weekly full-matrix run on the default branch. Saturday 14:00 UTC. - cron: '0 14 * * 6' workflow_dispatch: @@ -25,122 +21,85 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true +# CI run id stamped into the player for CDP filtering. Per-cell id set on jobs below. +env: + AUDIENCE_TEST_RUN_ID: ${{ github.run_id }}-${{ github.run_attempt }} + jobs: - # The playmode, playmode-linux and mobile-build matrices are built here - # and consumed by the dependent jobs via fromJSON. PR runs trim Unity - # 2022.3.62f2 cells; schedule and workflow_dispatch run the full set. - set-matrix: + # SSOT for the unity matrix and the PR-only Unity 2022 exclude. Both + # playmode and mobile-build consume these outputs via fromJSON. Source + # data lives in .github/scripts/audience/matrix-shared.json. + setup: + if: | + (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false) + || github.event_name == 'schedule' + || github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest outputs: - playmode: ${{ steps.set.outputs.playmode }} - playmode_linux: ${{ steps.set.outputs.playmode_linux }} - mobile: ${{ steps.set.outputs.mobile }} + unity_versions: ${{ steps.set.outputs.unity_versions }} + scripting_backends: ${{ steps.set.outputs.scripting_backends }} + desktop_targets: ${{ steps.set.outputs.desktop_targets }} + mobile_targets: ${{ steps.set.outputs.mobile_targets }} + pr_exclude: ${{ steps.set.outputs.pr_exclude }} steps: + - uses: actions/checkout@v4 - id: set shell: bash run: | - playmode_full='[ - {"target":"StandaloneWindows64","backend":"IL2CPP","unity":"2021.3.45f2","changeset":"88f88f591b2e","runner":["self-hosted","Windows","X64"]}, - {"target":"StandaloneWindows64","backend":"Mono2x","unity":"2021.3.45f2","changeset":"88f88f591b2e","runner":["self-hosted","Windows","X64"]}, - {"target":"StandaloneOSX","backend":"IL2CPP","unity":"2021.3.45f2","changeset":"88f88f591b2e","runner":["self-hosted","macOS","ARM64"]}, - {"target":"StandaloneOSX","backend":"Mono2x","unity":"2021.3.45f2","changeset":"88f88f591b2e","runner":["self-hosted","macOS","ARM64"]}, - {"target":"StandaloneWindows64","backend":"IL2CPP","unity":"6000.4.0f1","changeset":"8cf496087c8f","runner":["self-hosted","Windows","X64"]}, - {"target":"StandaloneWindows64","backend":"Mono2x","unity":"6000.4.0f1","changeset":"8cf496087c8f","runner":["self-hosted","Windows","X64"]}, - {"target":"StandaloneOSX","backend":"IL2CPP","unity":"6000.4.0f1","changeset":"8cf496087c8f","runner":["self-hosted","macOS","ARM64"]}, - {"target":"StandaloneOSX","backend":"Mono2x","unity":"6000.4.0f1","changeset":"8cf496087c8f","runner":["self-hosted","macOS","ARM64"]}, - {"target":"StandaloneWindows64","backend":"IL2CPP","unity":"2022.3.62f2","changeset":"7670c08855a9","runner":["self-hosted","Windows","X64"]}, - {"target":"StandaloneWindows64","backend":"Mono2x","unity":"2022.3.62f2","changeset":"7670c08855a9","runner":["self-hosted","Windows","X64"]}, - {"target":"StandaloneOSX","backend":"IL2CPP","unity":"2022.3.62f2","changeset":"7670c08855a9","runner":["self-hosted","macOS","ARM64"]}, - {"target":"StandaloneOSX","backend":"Mono2x","unity":"2022.3.62f2","changeset":"7670c08855a9","runner":["self-hosted","macOS","ARM64"]} - ]' - playmode_linux_full='[ - {"target":"StandaloneLinux64","backend":"IL2CPP","unity":"2021.3.45f2"}, - {"target":"StandaloneLinux64","backend":"Mono2x","unity":"2021.3.45f2"}, - {"target":"StandaloneLinux64","backend":"IL2CPP","unity":"6000.4.0f1"}, - {"target":"StandaloneLinux64","backend":"Mono2x","unity":"6000.4.0f1"}, - {"target":"StandaloneLinux64","backend":"IL2CPP","unity":"2022.3.62f2"}, - {"target":"StandaloneLinux64","backend":"Mono2x","unity":"2022.3.62f2"} - ]' - mobile_full='[ - {"target":"Android","unity":"2021.3.45f2","method":"AndroidBuilder.Build"}, - {"target":"Android","unity":"2022.3.62f2","method":"AndroidBuilder.Build"}, - {"target":"Android","unity":"6000.4.0f1","method":"AndroidBuilder.Build"}, - {"target":"iOS","unity":"2021.3.45f2","method":"IosBuilder.Build"}, - {"target":"iOS","unity":"2022.3.62f2","method":"IosBuilder.Build"}, - {"target":"iOS","unity":"6000.4.0f1","method":"IosBuilder.Build"} - ]' + f=.github/scripts/audience/matrix-shared.json + for key in unity_versions scripting_backends desktop_targets mobile_targets; do + echo "$key=$(jq -c ".$key" "$f")" >> "$GITHUB_OUTPUT" + done if [[ "${{ github.event_name }}" == "pull_request" ]]; then - filter='[.[] | select(.unity != "2022.3.62f2")]' - playmode=$(jq -c "$filter" <<<"$playmode_full") - playmode_linux=$(jq -c "$filter" <<<"$playmode_linux_full") - mobile=$(jq -c "$filter" <<<"$mobile_full") + # Drop Unity 2022.3.62f2 for PRs. The full set runs on schedule and workflow_dispatch. + echo "pr_exclude=$(jq -c .pr_exclude "$f")" >> "$GITHUB_OUTPUT" else - playmode=$(jq -c '.' <<<"$playmode_full") - playmode_linux=$(jq -c '.' <<<"$playmode_linux_full") - mobile=$(jq -c '.' <<<"$mobile_full") + echo 'pr_exclude=[]' >> "$GITHUB_OUTPUT" fi - { - echo "playmode=$playmode" - echo "playmode_linux=$playmode_linux" - echo "mobile=$mobile" - } >> "$GITHUB_OUTPUT" playmode: - needs: set-matrix - if: | - (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false) - || github.event_name == 'schedule' - || github.event_name == 'workflow_dispatch' - name: ${{ matrix.target }} / ${{ matrix.backend }} / Unity ${{ matrix.unity }} + needs: setup + name: ${{ matrix.platform.target }} / ${{ matrix.backend }} / Unity ${{ matrix.unity.version }} strategy: fail-fast: false matrix: - include: ${{ fromJSON(needs.set-matrix.outputs.playmode) }} - runs-on: ${{ matrix.runner }} - # Healthy cells finish in ~10 min. 30 min covers cold caches + - # IL2CPP + Unity 6 startup; anything past that is a hang. Capping - # short releases the self-hosted runner sooner so queued cells can - # progress instead of waiting 60 min on a stuck job. - timeout-minutes: 30 + unity: ${{ fromJSON(needs.setup.outputs.unity_versions) }} + platform: ${{ fromJSON(needs.setup.outputs.desktop_targets) }} + backend: ${{ fromJSON(needs.setup.outputs.scripting_backends) }} + exclude: ${{ fromJSON(needs.setup.outputs.pr_exclude) }} + runs-on: ${{ matrix.platform.runner }} + timeout-minutes: 45 + env: + AUDIENCE_TEST_CELL_ID: ${{ matrix.platform.target }}-${{ matrix.backend }}-${{ matrix.unity.version }} steps: - - name: Clean Windows workspace (pre-checkout) - if: runner.os == 'Windows' - shell: pwsh + - name: Resolve job ID + # Resolves the GitHub-assigned numeric job ID for the current cell so + # the player can stamp it into Player.log and CDP rows. Lets a CDP + # event link straight to the cell's GHA log via the canonical URL + # https://github.com/{repo}/actions/runs/{run_id}/job/{job_id}. + # Non-blocking: if the API call fails, AUDIENCE_TEST_JOB_ID stays unset. continue-on-error: true - run: | - # actions/checkout@v4 removes the prior workspace before cloning. If - # a previous run's Unity build / IL2CPP linker / bee_backend / shader - # compiler is still holding handles, checkout dies with EBUSY on - # examples/audience. Kill known offenders, then force-remove the - # workspace contents ourselves so checkout's cleanup succeeds. - Get-Process | Where-Object { - $_.Name -like 'Unity*' -or - $_.Name -like 'il2cpp*' -or - $_.Name -like 'UnityShaderCompiler*' -or - $_.Name -like 'UnityCrashHandler*' -or - $_.Name -like 'bee_backend*' -or - $_.Name -like 'mono*' - } | ForEach-Object { - Write-Host "Killing stale process: $($_.Name) (pid $($_.Id))" - Stop-Process -Id $_.Id -Force -ErrorAction SilentlyContinue - } - Start-Sleep -Seconds 3 - - $ws = "$env:GITHUB_WORKSPACE" - if (-not (Test-Path $ws)) { return } - for ($i = 1; $i -le 6; $i++) { - try { - Get-ChildItem -Path $ws -Force -ErrorAction Stop | - Remove-Item -Recurse -Force -ErrorAction Stop - Write-Host "Cleaned $ws on attempt ${i}" - return - } catch { - Write-Host "Attempt ${i}: $($_.Exception.Message)" - Start-Sleep -Seconds 3 + uses: actions/github-script@v7 + env: + JOB_NAME: ${{ matrix.platform.target }} / ${{ matrix.backend }} / Unity ${{ matrix.unity.version }} + with: + script: | + const jobs = await github.paginate( + github.rest.actions.listJobsForWorkflowRunAttempt, + { + owner: context.repo.owner, + repo: context.repo.repo, + run_id: context.runId, + attempt_number: context.runAttempt, + } + ); + const job = jobs.find(j => j.name === process.env.JOB_NAME); + if (!job) { + core.warning(`No job matching name="${process.env.JOB_NAME}"`); + return; } - } - Write-Host "::warning::Workspace not fully cleaned; checkout may fail" + core.exportVariable('AUDIENCE_TEST_JOB_ID', String(job.id)); - uses: actions/checkout@v4 with: @@ -150,393 +109,63 @@ jobs: uses: actions/cache@v4 with: path: examples/audience/Library - key: Library-${{ matrix.backend }}-${{ matrix.target }}-${{ matrix.unity }}-${{ hashFiles('examples/audience/Assets/**', 'examples/audience/Packages/**', 'examples/audience/ProjectSettings/**', 'src/Packages/Audience/**') }} + key: Library-${{ matrix.backend }}-${{ matrix.platform.target }}-${{ matrix.unity.version }}-${{ hashFiles('examples/audience/Assets/**', 'examples/audience/Packages/**', 'examples/audience/ProjectSettings/**', 'src/Packages/Audience/**') }} restore-keys: | - Library-${{ matrix.backend }}-${{ matrix.target }}-${{ matrix.unity }}- - Library-${{ matrix.backend }}-${{ matrix.target }}- - - - name: Ensure MSVC + Windows 10 SDK (Windows IL2CPP) - if: runner.os == 'Windows' && matrix.backend == 'IL2CPP' - shell: pwsh - run: | - $vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" - - # Match Unity's detection logic exactly: vswhere requires VC.Tools - # (any version), registry probe for any Win10 SDK at v10.0/InstallationFolder. - # Pinning a specific SDK version in -requires is too strict — VCTools - # ships with whatever Win10 SDK is current, and Unity accepts any. - function Test-Toolchain { - $vc = if (Test-Path $vswhere) { - & $vswhere -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath 2>$null - } else { '' } - $sdk = (Get-ItemProperty 'HKLM:\SOFTWARE\Wow6432Node\Microsoft\Microsoft SDKs\Windows\v10.0' -ErrorAction SilentlyContinue).InstallationFolder - return @{ VcTools = $vc; Win10Sdk = $sdk } - } - - $state = Test-Toolchain - if ($state.VcTools -and $state.Win10Sdk) { - Write-Host "VC.Tools at: $($state.VcTools)" - Write-Host "Win10 SDK at: $($state.Win10Sdk)" - exit 0 - } - Write-Host "Toolchain incomplete. VC.Tools='$($state.VcTools)' Win10Sdk='$($state.Win10Sdk)'" - - Write-Host "::group::Install VS 2022 Build Tools (VCTools + Win10 SDK)" - $installer = "$env:RUNNER_TEMP\vs_BuildTools.exe" - Invoke-WebRequest -Uri 'https://aka.ms/vs/17/release/vs_BuildTools.exe' -OutFile $installer - - $installArgs = @( - '--quiet','--wait','--norestart','--nocache', - '--add','Microsoft.VisualStudio.Workload.VCTools', - '--add','Microsoft.VisualStudio.Component.VC.Tools.x86.x64', - '--add','Microsoft.VisualStudio.Component.Windows10SDK.20348', - '--includeRecommended' - ) - $p = Start-Process -FilePath $installer -ArgumentList $installArgs -Wait -PassThru -NoNewWindow - # 3010 = success, reboot pending (tools are usable without reboot). - if ($p.ExitCode -ne 0 -and $p.ExitCode -ne 3010) { - Write-Host "::error::VS Build Tools installer exited $($p.ExitCode)" - exit $p.ExitCode - } - Write-Host "::endgroup::" - - $state = Test-Toolchain - if (-not ($state.VcTools -and $state.Win10Sdk)) { - Write-Host "::group::diagnostic" - Write-Host "VC.Tools path (vswhere): '$($state.VcTools)'" - Write-Host "Win10 SDK (registry v10.0/InstallationFolder): '$($state.Win10Sdk)'" - Write-Host "--- all VS installations ---" - if (Test-Path $vswhere) { & $vswhere -all -products * -format json } - Write-Host "--- HKLM Win10 SDK roots ---" - Get-ChildItem 'HKLM:\SOFTWARE\Wow6432Node\Microsoft\Microsoft SDKs\Windows' -ErrorAction SilentlyContinue | Format-List - Write-Host "::endgroup::" - Write-Host "::error::Install reported success but VC.Tools or Win10 SDK still not detected — runner service account likely lacks admin to install system-wide. Install VS Build Tools manually on IMX_SDKBUILD: vs_BuildTools.exe --quiet --wait --add Microsoft.VisualStudio.Workload.VCTools --includeRecommended" - exit 1 - } - Write-Host "Verified VC.Tools at: $($state.VcTools)" - Write-Host "Verified Win10 SDK at: $($state.Win10Sdk)" - - - name: Resolve Unity ${{ matrix.unity }} (macOS) - if: runner.os == 'macOS' - shell: bash - env: - UNITY_VER: ${{ matrix.unity }} - UNITY_CS: ${{ matrix.changeset }} - run: | - set -uo pipefail - HUB="/Applications/Unity Hub.app/Contents/MacOS/Unity Hub" + Library-${{ matrix.backend }}-${{ matrix.platform.target }}-${{ matrix.unity.version }}- + Library-${{ matrix.backend }}-${{ matrix.platform.target }}- - echo "::group::install editor" - "$HUB" -- --headless install \ - --version "$UNITY_VER" --changeset "$UNITY_CS" --architecture arm64 \ - || echo "(install non-zero — OK if 'Editor already installed in this location')" - echo "::endgroup::" - - if [ "${{ matrix.backend }}" = "IL2CPP" ]; then - echo "::group::install mac-il2cpp module" - "$HUB" -- --headless install-modules \ - --version "$UNITY_VER" --changeset "$UNITY_CS" --architecture arm64 \ - --module mac-il2cpp \ - || echo "(install-modules non-zero — OK if 'No modules found to install')" - echo "::endgroup::" - fi - - EDITOR_APP="" - for cand in \ - "/Applications/Unity/Hub/Editor/$UNITY_VER-arm64/Unity.app" \ - "/Applications/Unity/Hub/Editor/$UNITY_VER/Unity.app"; do - if [ -x "$cand/Contents/MacOS/Unity" ]; then EDITOR_APP="$cand"; break; fi - done - - IL2CPP_DIR="" - if [ "${{ matrix.backend }}" = "IL2CPP" ] && [ -n "$EDITOR_APP" ]; then - for d in \ - "$EDITOR_APP/Contents/PlaybackEngines/MacStandaloneSupport/Variations/macos_arm64_player_nondevelopment_il2cpp" \ - "$EDITOR_APP/Contents/PlaybackEngines/MacStandaloneSupport/Variations/macos_x64_player_nondevelopment_il2cpp"; do - if [ -d "$d" ]; then IL2CPP_DIR="$d"; break; fi - done - fi - - MISSING="" - [ -z "$EDITOR_APP" ] && MISSING="editor" - [ "${{ matrix.backend }}" = "IL2CPP" ] && [ -z "$IL2CPP_DIR" ] && MISSING="${MISSING:+$MISSING+}mac-il2cpp" - if [ -n "$MISSING" ]; then - echo "::error::Unity $UNITY_VER missing: $MISSING" - ls -la /Applications/Unity/Hub/Editor/ 2>&1 || true - "$HUB" -- --headless editors --installed 2>&1 || true - exit 1 - fi - - echo "Found Unity: $EDITOR_APP/Contents/MacOS/Unity" - [ -n "$IL2CPP_DIR" ] && echo "Found IL2CPP: $IL2CPP_DIR" - echo "UNITY_PATH=$EDITOR_APP/Contents/MacOS/Unity" >> "$GITHUB_ENV" - - - name: Resolve Unity ${{ matrix.unity }} (Windows) - if: runner.os == 'Windows' - shell: pwsh + - name: Detect or Install Unity (Windows + macOS only) + if: matrix.platform.install_unity_script != '' env: - UNITY_VER: ${{ matrix.unity }} - UNITY_CS: ${{ matrix.changeset }} - run: | - $hub = "C:\Program Files\Unity Hub\Unity Hub.exe" - - Write-Host "::group::install editor" - $installArgs = @('--','--headless','install','--version',$env:UNITY_VER,'--changeset',$env:UNITY_CS,'--architecture','x86_64') - & $hub @installArgs 2>&1 | Write-Host - if ($LASTEXITCODE -ne 0) { Write-Host "(install non-zero — OK if 'Editor already installed in this location')" } - $global:LASTEXITCODE = 0 - Write-Host "::endgroup::" - - if ('${{ matrix.backend }}' -eq 'IL2CPP') { - Write-Host "::group::install windows-il2cpp module" - $modArgs = @('--','--headless','install-modules','--version',$env:UNITY_VER,'--changeset',$env:UNITY_CS,'--architecture','x86_64','--module','windows-il2cpp') - & $hub @modArgs 2>&1 | Write-Host - if ($LASTEXITCODE -ne 0) { Write-Host "(install-modules non-zero — OK if 'No modules found to install')" } - $global:LASTEXITCODE = 0 - Write-Host "::endgroup::" - } - - $editor = "C:\Program Files\Unity\Hub\Editor\$env:UNITY_VER\Editor\Unity.exe" - $il2cpp = "C:\Program Files\Unity\Hub\Editor\$env:UNITY_VER\Editor\Data\PlaybackEngines\windowsstandalonesupport\Variations\win64_player_nondevelopment_il2cpp" - $missing = @() - if (-not (Test-Path $editor)) { $missing += 'editor' } - if ('${{ matrix.backend }}' -eq 'IL2CPP' -and -not (Test-Path $il2cpp)) { $missing += 'windows-il2cpp' } - if ($missing.Count -gt 0) { - Write-Host "::error::Unity $env:UNITY_VER missing: $($missing -join '+')" - Get-ChildItem "C:\Program Files\Unity\Hub\Editor\" -ErrorAction SilentlyContinue | Format-Table - & $hub -- --headless editors --installed - exit 1 - } - - Write-Host "Found Unity: $editor" - if ('${{ matrix.backend }}' -eq 'IL2CPP') { Write-Host "Found IL2CPP: $il2cpp" } - "UNITY_PATH=$editor" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 - - - name: Run PlayMode tests (macOS) - if: runner.os == 'macOS' - shell: bash - env: - AUDIENCE_TEST_PUBLISHABLE_KEY: ${{ secrets.AUDIENCE_TEST_PUBLISHABLE_KEY }} - AUDIENCE_SCRIPTING_BACKEND: ${{ matrix.backend }} - run: | - set -euo pipefail - mkdir -p artifacts - # Tee Unity's stdout to artifacts/unity.log so the annotation step has a - # file to scan, while still streaming progress to the job log. pipefail - # propagates Unity's exit code through tee. The annotation step reads this - # file in-job; the actions/upload-artifact step below also uploads it so - # compile failures retain a full post-mortem (annotations are matched-line - # only and drop IL2CPP linker output, build config dumps, etc). - "$UNITY_PATH" \ - -batchmode -nographics \ - -projectPath examples/audience \ - -runTests \ - -testPlatform ${{ matrix.target }} \ - -testResults "$(pwd)/artifacts/test-results.xml" \ - -logFile - 2>&1 | tee "$(pwd)/artifacts/unity.log" - - - name: Run PlayMode tests (Windows) - if: runner.os == 'Windows' - shell: pwsh - env: - AUDIENCE_TEST_PUBLISHABLE_KEY: ${{ secrets.AUDIENCE_TEST_PUBLISHABLE_KEY }} - AUDIENCE_SCRIPTING_BACKEND: ${{ matrix.backend }} - run: | - New-Item -ItemType Directory -Force -Path artifacts | Out-Null - $logFile = "$pwd\artifacts\unity.log" - $unityArgs = @( - '-batchmode','-nographics', - '-projectPath','examples/audience', - '-runTests', - '-testPlatform','${{ matrix.target }}', - '-testResults',"$pwd\artifacts\test-results.xml", - '-logFile',$logFile - ) - Write-Host "Launching Unity: $env:UNITY_PATH $($unityArgs -join ' ')" - $p = Start-Process -FilePath $env:UNITY_PATH -ArgumentList $unityArgs -Wait -PassThru -NoNewWindow - $exit = $p.ExitCode - Write-Host "::group::Unity log" - Get-Content $logFile -ErrorAction SilentlyContinue | Write-Host - Write-Host "::endgroup::" - Write-Host "Unity exited with code $exit" - if ($exit -ne 0) { exit $exit } - - - name: Mark workspace safe for git (Windows) - if: always() && runner.os == 'Windows' - shell: pwsh - run: | - git config --global --add safe.directory $env:GITHUB_WORKSPACE.Replace('\','/') - - - name: Capture player log (macOS) - if: always() && runner.os == 'macOS' - shell: bash - run: | - # The test-runner builds + launches a player binary that writes its own - # log separately from Unity's editor log. When the editor reports - # "Test execution timed out. No activity received from the player ..." - # the editor unity.log alone cannot tell us whether the player crashed, - # hung, or never started. Copy whatever Player.log files Unity wrote - # into artifacts/ so the upload-artifact step preserves them. - mkdir -p artifacts - src="$HOME/Library/Logs" - if [ -d "$src" ]; then - find "$src" -name "Player.log" 2>/dev/null | while IFS= read -r f; do - cp "$f" "artifacts/Player-$(basename "$(dirname "$f")").log" 2>/dev/null || true - done - fi + UNITY_VERSION: ${{ matrix.unity.version }} + UNITY_CHANGESET: ${{ matrix.unity.changeset }} + BACKEND: ${{ matrix.backend }} + run: ${{ matrix.platform.install_unity_script }} - - name: Capture player log (Windows) - if: always() && runner.os == 'Windows' + - name: Detect or Install VS Build Tools (StandaloneWindows64 IL2CPP only) + if: matrix.platform.target == 'StandaloneWindows64' && matrix.backend == 'IL2CPP' shell: pwsh - run: | - # See macOS counterpart for rationale. Windows player log location: - # %USERPROFILE%\AppData\LocalLow\\\Player.log - New-Item -ItemType Directory -Force -Path artifacts | Out-Null - $src = "$env:USERPROFILE\AppData\LocalLow" - if (Test-Path $src) { - Get-ChildItem -Path $src -Recurse -Filter "Player.log" -ErrorAction SilentlyContinue | - ForEach-Object { - $name = $_.Directory.Name - Copy-Item -Path $_.FullName -Destination "artifacts/Player-$name.log" -ErrorAction SilentlyContinue - } - } - - - name: Surface Unity compile errors as annotations (macOS) - if: always() && runner.os == 'macOS' - shell: bash - run: | - set -uo pipefail - # Unity writes compile errors as 'error CS####:' or 'Compilation failed: '. - # When a cell fails compile (vs fails a test), the test-results.xml is empty - # and the only signal otherwise is the artifact zip. Promote those lines to - # ::error:: annotations so the PR UI shows the cause inline. - LOG_FILE="artifacts/unity.log" - if [ ! -f "$LOG_FILE" ]; then - echo "::notice::No Unity log file at $LOG_FILE." - exit 0 - fi - # `|| true` guards the success path: with `pipefail`, grep exits 1 when no - # matches (the clean-build case), which would otherwise propagate as the - # step's exit code and falsely mark every green cell red. - grep -E '(error CS[0-9]+:|Compilation failed:)' "$LOG_FILE" | sort -u | while IFS= read -r line; do - trimmed="${line#"${line%%[![:space:]]*}"}" - # Sanitize '::' so log lines containing workflow commands (e.g. ::endgroup::) - # cannot terminate the annotation early or inject other commands. - sanitized="${trimmed//::/%3A%3A}" - echo "::error::$sanitized" - done || true - - - name: Surface Unity compile errors as annotations (Windows) - if: always() && runner.os == 'Windows' - shell: pwsh - run: | - $logFile = "artifacts\unity.log" - if (-not (Test-Path $logFile)) { - Write-Host "::notice::No Unity log file at $logFile." - exit 0 - } - Get-Content $logFile | - Select-String -Pattern '(error CS\d+:|Compilation failed:)' | - ForEach-Object { $_.Line.Trim() } | - Sort-Object -Unique | - ForEach-Object { - # Sanitize '::' so log lines containing workflow commands cannot - # terminate the annotation early or inject other commands. - $sanitized = $_ -replace '::', '%3A%3A' - Write-Host "::error::$sanitized" - } - - - name: Publish test report - uses: dorny/test-reporter@v3 - if: always() - with: - name: PlayMode (${{ matrix.backend }} / ${{ matrix.target }}) - path: artifacts/test-results.xml - reporter: dotnet-nunit - fail-on-error: true - - - uses: actions/upload-artifact@v4 - if: always() - with: - name: playmode-${{ matrix.backend }}-${{ matrix.target }}-${{ matrix.unity }} - path: | - artifacts/test-results.xml - artifacts/unity.log - artifacts/Player-*.log - examples/audience/Logs/** - - playmode-linux: - needs: set-matrix - if: | - (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false) - || github.event_name == 'schedule' - || github.event_name == 'workflow_dispatch' - name: ${{ matrix.target }} / ${{ matrix.backend }} / Unity ${{ matrix.unity }} - runs-on: ubuntu-latest-8-cores - strategy: - fail-fast: false - matrix: - include: ${{ fromJSON(needs.set-matrix.outputs.playmode_linux) }} - - steps: - - uses: actions/checkout@v4 - with: - lfs: true + run: .github/scripts/audience/ensure-msvc-windows.ps1 - - uses: actions/cache@v4 - with: - path: examples/audience/Library - key: Library-${{ matrix.backend }}-${{ matrix.target }}-${{ matrix.unity }}-${{ hashFiles('examples/audience/Assets/**', 'examples/audience/Packages/**', 'examples/audience/ProjectSettings/**', 'src/Packages/Audience/**') }} - restore-keys: | - Library-${{ matrix.backend }}-${{ matrix.target }}-${{ matrix.unity }}- - Library-${{ matrix.backend }}-${{ matrix.target }}- - - - uses: game-ci/unity-test-runner@v4 - id: playmode + - name: Run PlayMode tests env: + UNITY_VERSION: ${{ matrix.unity.version }} + TARGET: ${{ matrix.platform.target }} UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} AUDIENCE_TEST_PUBLISHABLE_KEY: ${{ secrets.AUDIENCE_TEST_PUBLISHABLE_KEY }} AUDIENCE_SCRIPTING_BACKEND: ${{ matrix.backend }} - with: - unityVersion: ${{ matrix.unity }} - targetPlatform: ${{ matrix.target }} - projectPath: examples/audience - testMode: playmode - githubToken: ${{ secrets.GITHUB_TOKEN }} + run: ${{ matrix.platform.run_playmode_script }} - name: Publish test report uses: dorny/test-reporter@v3 if: always() with: - name: PlayMode (${{ matrix.backend }} / ${{ matrix.target }}) - path: ${{ steps.playmode.outputs.artifactsPath }}/playmode-results.xml + name: PlayMode (${{ matrix.backend }} / ${{ matrix.platform.target }}) + path: artifacts/test-results.xml reporter: dotnet-nunit fail-on-error: true - uses: actions/upload-artifact@v4 if: always() with: - name: playmode-${{ matrix.backend }}-${{ matrix.target }}-${{ matrix.unity }} - path: ${{ steps.playmode.outputs.artifactsPath }} + name: playmode-${{ matrix.backend }}-${{ matrix.platform.target }}-${{ matrix.unity.version }} + path: | + artifacts/** + examples/audience/Logs/** - # Mobile IL2CPP build validation — runs on GitHub-hosted Ubuntu via GameCI Docker - # containers so self-hosted macOS/Windows machines are not occupied. - # Scope: IL2CPP compile pipeline only. Runtime tests require a real device and - # are out of scope until a device farm is available. + # Mobile IL2CPP build validation. Compile-only; runtime tests need real devices. mobile-build: - needs: set-matrix - if: | - (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false) - || github.event_name == 'schedule' - || github.event_name == 'workflow_dispatch' - name: ${{ matrix.target }} / IL2CPP / Unity ${{ matrix.unity }} + needs: setup + name: ${{ matrix.platform.target }} / IL2CPP / Unity ${{ matrix.unity.version }} runs-on: ubuntu-latest-8-cores strategy: fail-fast: false matrix: - include: ${{ fromJSON(needs.set-matrix.outputs.mobile) }} + unity: ${{ fromJSON(needs.setup.outputs.unity_versions) }} + platform: ${{ fromJSON(needs.setup.outputs.mobile_targets) }} + exclude: ${{ fromJSON(needs.setup.outputs.pr_exclude) }} steps: - uses: actions/checkout@v4 @@ -546,10 +175,10 @@ jobs: - uses: actions/cache@v4 with: path: examples/audience/Library - key: Library-mobile-${{ matrix.target }}-${{ matrix.unity }}-${{ hashFiles('examples/audience/Assets/**', 'examples/audience/Packages/**', 'examples/audience/ProjectSettings/**', 'src/Packages/Audience/**') }} + key: Library-mobile-${{ matrix.platform.target }}-${{ matrix.unity.version }}-${{ hashFiles('examples/audience/Assets/**', 'examples/audience/Packages/**', 'examples/audience/ProjectSettings/**', 'src/Packages/Audience/**') }} restore-keys: | - Library-mobile-${{ matrix.target }}-${{ matrix.unity }}- - Library-mobile-${{ matrix.target }}- + Library-mobile-${{ matrix.platform.target }}-${{ matrix.unity.version }}- + Library-mobile-${{ matrix.platform.target }}- - uses: game-ci/unity-builder@v4 env: @@ -557,15 +186,15 @@ jobs: UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} with: - unityVersion: ${{ matrix.unity }} - targetPlatform: ${{ matrix.target }} + unityVersion: ${{ matrix.unity.version }} + targetPlatform: ${{ matrix.platform.target }} projectPath: examples/audience - buildMethod: Immutable.Audience.Samples.SampleApp.Editor.${{ matrix.method }} + buildMethod: Immutable.Audience.Samples.SampleApp.Editor.${{ matrix.platform.build_player_method }} - uses: actions/upload-artifact@v4 if: always() with: - name: mobile-build-${{ matrix.target }}-${{ matrix.unity }} + name: mobile-build-${{ matrix.platform.target }}-${{ matrix.unity.version }} if-no-files-found: ignore path: | examples/audience/Builds/Android/*.apk diff --git a/examples/audience/Assets/SampleApp/Scripts/AudienceSample.cs b/examples/audience/Assets/SampleApp/Scripts/AudienceSample.cs index 7764d8dc5..153192fa1 100644 --- a/examples/audience/Assets/SampleApp/Scripts/AudienceSample.cs +++ b/examples/audience/Assets/SampleApp/Scripts/AudienceSample.cs @@ -8,7 +8,7 @@ namespace Immutable.Audience.Samples.SampleApp { - // Audience SDK sample — UI Toolkit port of the web sample-app. Exercises + // Audience SDK sample, UI Toolkit port of the web sample-app. Exercises // every public ImmutableAudience API plus an event log that mirrors SDK // debug output. // @@ -16,13 +16,13 @@ namespace Immutable.Audience.Samples.SampleApp // // AudienceSample.cs SDK calls, On* handlers, mirror state, SDK // callbacks, config builders. Reads UXML - // state ONLY via UI's Capture*Form accessors - // — never touches a UXML field directly. + // state ONLY via UI's Capture*Form accessors. + // Never touches a UXML field directly. // AudienceSample.UI.cs UXML fields, binding, rendering, log pane, // Refresh* methods, Capture*Form accessors. // No SDK calls, no mirror-state knowledge. // AudienceSample.Events.cs Catalogue, typed-event factory, props - // builder. Pure factory — no UXML, no SDK. + // builder. Pure factory: no UXML, no SDK. public sealed partial class AudienceSample : MonoBehaviour { // ---- State ---- @@ -38,9 +38,21 @@ public sealed partial class AudienceSample : MonoBehaviour // ---- Lifecycle ---- + // Logs CI build info (buildGuid, runId, cellId, jobId) to Player.log on player startup. CI-only. + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)] + private static void LogCiBuildInfo() + { + var runId = Environment.GetEnvironmentVariable("AUDIENCE_TEST_RUN_ID") ?? string.Empty; + var cellId = Environment.GetEnvironmentVariable("AUDIENCE_TEST_CELL_ID") ?? string.Empty; + var jobId = Environment.GetEnvironmentVariable("AUDIENCE_TEST_JOB_ID") ?? string.Empty; + if (string.IsNullOrEmpty(runId) && string.IsNullOrEmpty(cellId)) return; + UnityEngine.Debug.Log( + $"[CI] buildGuid={Application.buildGUID} runId={runId} cellId={cellId} jobId={jobId}"); + } + private void Awake() { - // InitializeUi must precede the Log.Writer swap — _logView has + // InitializeUi must precede the Log.Writer swap. _logView has // to be bound before any Log.Warn can land in RouteSdkLogToPane. InitializeUi(); _priorSdkLogWriter = Immutable.Audience.Log.Writer; @@ -121,7 +133,7 @@ private async Task OnRequestAttAsync() // Prefers the typed overload for the four events with public C# // classes (Progression, Resource, Purchase, MilestoneReached); the // rest stay on the string overload. Typed validation errors are - // expected for user input — let them propagate through RunAndLog. + // expected for user input. Let them propagate through RunAndLog. private void OnSendCatalogueEvent(EventSpec spec, Dictionary inputs) => RunAndLog("track()", () => { @@ -194,7 +206,7 @@ private void OnIdentify() => RunAndLog("identify()", () => var traits = ParseTraits(f.RawTraits); ImmutableAudience.Identify(f.Id, ParseIdentityType(f.Type), traits); // SDK drops via Log.Warn when id is empty or consent < Full. Mirror - // only when accepted — otherwise the panel would show stale state. + // only when accepted; otherwise the panel would show stale state. var accepted = !string.IsNullOrEmpty(f.Id) && string.Equals(ImmutableAudience.UserId, f.Id, StringComparison.Ordinal); if (accepted) { _mirrorIdentityType = f.Type; _mirrorTraits = traits; } @@ -212,7 +224,7 @@ private void OnIdentify() => RunAndLog("identify()", () => private void OnIdentifyTraits() => RunAndLog("identify(traits)", () => { var userId = ImmutableAudience.UserId; - if (string.IsNullOrEmpty(userId)) throw new InvalidOperationException("no active identity — call Identify first"); + if (string.IsNullOrEmpty(userId)) throw new InvalidOperationException("no active identity; call Identify first"); var traits = ParseTraits(CaptureTraitsUpdate()); if (traits == null || traits.Count == 0) throw new InvalidOperationException("traits required"); ImmutableAudience.Identify(userId, ParseIdentityType(_mirrorIdentityType), traits); @@ -246,15 +258,19 @@ private void OnAlias() => RunAndLog("alias()", () => // Fires from background flush threads; AppendLog marshals to main. // Body is JSON for parity with handler "Copy" output. - private void OnSdkError(AudienceError err) => - AppendLog("onError", Json.Serialize(new Dictionary + // Mirrors to Debug.LogError so failures land in Player.log, not just the in-app pane. + private void OnSdkError(AudienceError err) + { + var body = Json.Serialize(new Dictionary { ["code"] = err.Code.ToString(), ["message"] = err.Message, - }, 2), LogLevel.Err, LogSource.Sdk); + }, 2); + UnityEngine.Debug.LogError($"[Audience.OnError] {body}"); + AppendLog("onError", body, LogLevel.Err, LogSource.Sdk); + } - // SDK Log.Writer adapter. May fire from any thread; AppendLog handles - // the main-thread marshal. + // SDK Log.Writer adapter. Mirrors to Debug.Log so SDK output reaches Player.log. private void RouteSdkLogToPane(string msg) { const string warnTag = "[ImmutableAudience] WARN:"; @@ -265,10 +281,16 @@ private void RouteSdkLogToPane(string msg) { level = LogLevel.Warn; body = msg.Substring(warnTag.Length).TrimStart(); + UnityEngine.Debug.LogWarning($"[Audience] {body}"); } else if (msg.StartsWith(prefix, StringComparison.Ordinal)) { body = msg.Substring(prefix.Length).TrimStart(); + UnityEngine.Debug.Log($"[Audience] {body}"); + } + else + { + UnityEngine.Debug.Log($"[Audience] {body}"); } AppendLog("sdk", body, level, LogSource.Sdk); } @@ -293,7 +315,7 @@ private static void GuardConsentForTrack() var consent = ImmutableAudience.CurrentConsent; if (!consent.CanTrack()) throw new InvalidOperationException( - $"track dropped — consent is {consent.ToLowercaseString()}; raise to anonymous or full to queue events"); + $"track dropped: consent is {consent.ToLowercaseString()}; raise to anonymous or full to queue events"); } // Refresh* are idempotent reads, so calling all four every time is @@ -326,7 +348,7 @@ private AudienceConfig BuildAudienceConfig(InitForm form, Action if (form.FlushIntervalMs is int flushMs && flushMs > 0) { if (flushMs < 1000) - AppendLog("INIT", $"flushInterval {flushMs}ms below 1s — clamped", LogLevel.Warn, LogSource.App); + AppendLog("INIT", $"flushInterval {flushMs}ms below 1s, clamped", LogLevel.Warn, LogSource.App); config.FlushIntervalSeconds = Math.Max(1, flushMs / 1000); } if (form.FlushSize is int flushSize && flushSize > 0) diff --git a/examples/audience/Assets/SampleApp/Tests/Runtime/LinuxLogPaneSuppression.cs b/examples/audience/Assets/SampleApp/Tests/Runtime/LinuxLogPaneSuppression.cs new file mode 100644 index 000000000..fc037df46 --- /dev/null +++ b/examples/audience/Assets/SampleApp/Tests/Runtime/LinuxLogPaneSuppression.cs @@ -0,0 +1,48 @@ +#nullable enable + +#if UNITY_STANDALONE_LINUX && UNITY_6000_0_OR_NEWER +using NUnit.Framework; +using UnityEngine; +using UnityEngine.SceneManagement; +using UnityEngine.UIElements; + +namespace Immutable.Audience.Samples.SampleApp.Tests +{ + // Hides log pane on Unity 6 Linux. Skips llvmpipe rasterising + // thousands of UI Toolkit triangles per frame. + [SetUpFixture] + public sealed class LinuxLogPaneSuppression + { + [OneTimeSetUp] + public void RegisterSceneHook() + { + SceneManager.sceneLoaded += HideLogPane; + } + + [OneTimeTearDown] + public void DeregisterSceneHook() + { + SceneManager.sceneLoaded -= HideLogPane; + } + + // Fires on every scene load. Idempotent. + private static void HideLogPane(Scene scene, LoadSceneMode mode) + { + var sample = Object.FindFirstObjectByType(FindObjectsInactive.Include); + if (sample == null) return; + + var doc = sample.GetComponent(); + if (doc == null) return; + + var root = doc.rootVisualElement; + if (root == null) return; + + var log = root.Q(SampleAppUi.LogScrollView); + if (log == null) return; + + log.style.display = new StyleEnum(DisplayStyle.None); + Debug.Log("[LinuxLogPaneSuppression] log pane hidden for Linux PlayMode test run."); + } + } +} +#endif diff --git a/examples/audience/Assets/SampleApp/Tests/Runtime/LinuxLogPaneSuppression.cs.meta b/examples/audience/Assets/SampleApp/Tests/Runtime/LinuxLogPaneSuppression.cs.meta new file mode 100644 index 000000000..375feaffe --- /dev/null +++ b/examples/audience/Assets/SampleApp/Tests/Runtime/LinuxLogPaneSuppression.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 3a7e9d4b8c1f5a6e2b8d9c0e1f2a3b4c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/examples/audience/Assets/SampleApp/Tests/Runtime/SampleAppLiveFireTests.cs b/examples/audience/Assets/SampleApp/Tests/Runtime/SampleAppLiveFireTests.cs index aef4b65be..9f6c59b99 100644 --- a/examples/audience/Assets/SampleApp/Tests/Runtime/SampleAppLiveFireTests.cs +++ b/examples/audience/Assets/SampleApp/Tests/Runtime/SampleAppLiveFireTests.cs @@ -24,8 +24,12 @@ internal class SampleAppLiveFireTests [SetUp] public void SetUp() { + // Don't fail tests on cleanup-time OnError fires when in-flight HTTP + // gets cancelled. Errors still land in Player.log via Debug.LogError. + LogAssert.ignoreFailingMessages = true; + // ImmutableAudience is a static; tests must reset between runs. - // ResetState is internal — reached via reflection (BindingFlags.NonPublic + // ResetState is internal, reached via reflection (BindingFlags.NonPublic // bypasses C# access checks; no InternalsVisibleTo required). var t = typeof(ImmutableAudience); var m = t.GetMethod("ResetState", @@ -128,6 +132,32 @@ private IEnumerator SetConsentVia(string consentButtonName) // ---- Tests ---- + // Emits a CDP marker row so this run's events can be filtered out of analytics. + [UnityTest] + public IEnumerator AudienceCiTestMarker_EmitsRunMetadata() + { + var runId = Environment.GetEnvironmentVariable("AUDIENCE_TEST_RUN_ID"); + var cellId = Environment.GetEnvironmentVariable("AUDIENCE_TEST_CELL_ID"); + var jobId = Environment.GetEnvironmentVariable("AUDIENCE_TEST_JOB_ID"); + if (string.IsNullOrEmpty(runId) && string.IsNullOrEmpty(cellId)) + { + Assert.Ignore("Not running in CI."); + yield break; + } + + yield return LoadAndInit(); + + ImmutableAudience.Track("audience_ci_test_marker", new System.Collections.Generic.Dictionary + { + ["source"] = "ci", + ["ciRunId"] = runId ?? string.Empty, + ["ciCellId"] = cellId ?? string.Empty, + ["ciJobId"] = jobId ?? string.Empty, + }); + + yield return null; + } + [UnityTest] public IEnumerator InitTrackFlush_AgainstSandbox_FlushReportsOk() { @@ -197,7 +227,7 @@ private IEnumerator DriveTypedEventAndFlush( [UnityTest] public IEnumerator Identify_AndFlush_FlushReportsOk() { - // Identify requires consent ≥ Full — set it on the initial-consent + // Identify requires consent >= Full. Set it on the initial-consent // dropdown before Init rather than upgrading mid-test. yield return LoadAndInit(initialConsent: SampleAppUi.Consent.Full); @@ -224,7 +254,7 @@ public IEnumerator Alias_AndFlush_FlushReportsOk() [UnityTest] public IEnumerator SetConsent_None_PurgesQueueAndPersists() { - // Init at default Anonymous; enqueue an event; revoke; flush — no errors. + // Init at default Anonymous; enqueue an event; revoke; flush. No errors. yield return LoadAndInit(); _root!.Q