diff --git a/.github/workflows/test-audience-sample-app.yml b/.github/workflows/test-audience-sample-app.yml index 9263173ef..07393d788 100644 --- a/.github/workflows/test-audience-sample-app.yml +++ b/.github/workflows/test-audience-sample-app.yml @@ -521,6 +521,114 @@ jobs: name: playmode-${{ matrix.backend }}-${{ matrix.target }}-${{ matrix.unity }} path: ${{ steps.playmode.outputs.artifactsPath }} + linux-build-smoke: + 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: smoke ${{ matrix.target }} / ${{ matrix.backend }} / Unity ${{ matrix.unity }} + runs-on: ubuntu-latest-8-cores + timeout-minutes: 30 + env: + AUDIENCE_TEST_CELL_ID: ${{ matrix.target }}-${{ matrix.backend }}-${{ matrix.unity }} + strategy: + fail-fast: false + matrix: + include: ${{ fromJSON(needs.set-matrix.outputs.playmode_linux) }} + + steps: + - uses: actions/checkout@v4 + with: + lfs: true + + - name: Inject testables passthrough (no-op) + # The smoke build does not need test asmdefs; this step exists so + # the Library cache key shape stays identical to the playmode-linux + # job and we share warm caches. + run: | + jq '.' examples/audience/Packages/manifest.json > /dev/null + + - uses: actions/cache@v4 + with: + path: examples/audience/Library + key: Library-smoke-${{ matrix.backend }}-${{ matrix.target }}-${{ matrix.unity }}-${{ hashFiles('examples/audience/Assets/**', 'examples/audience/Packages/**', 'examples/audience/ProjectSettings/**', 'src/Packages/Audience/**') }} + restore-keys: | + Library-smoke-${{ matrix.backend }}-${{ matrix.target }}-${{ matrix.unity }}- + Library-smoke-${{ matrix.backend }}-${{ matrix.target }}- + + - uses: game-ci/unity-builder@v4 + env: + UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} + UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} + UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} + AUDIENCE_SCRIPTING_BACKEND: ${{ matrix.backend }} + with: + unityVersion: ${{ matrix.unity }} + targetPlatform: ${{ matrix.target }} + projectPath: examples/audience + buildMethod: Immutable.Audience.Samples.SampleApp.Editor.LinuxSmokeBuilder.Build + buildName: LinuxSmokePlayer + customParameters: --buildPath Builds/LinuxSmoke/LinuxSmokePlayer.x86_64 + + - name: Run smoke + env: + AUDIENCE_TEST_PUBLISHABLE_KEY: ${{ secrets.AUDIENCE_TEST_PUBLISHABLE_KEY }} + AUDIENCE_TEST_RUN_ID: ${{ github.run_id }}-${{ github.run_attempt }} + shell: bash + run: | + set -uo pipefail + player="examples/audience/Builds/LinuxSmoke/LinuxSmokePlayer.x86_64" + if [ ! -x "$player" ]; then + echo "::error::Built player not found at $player" + ls -la "examples/audience/Builds/LinuxSmoke/" 2>/dev/null || true + exit 1 + fi + + mkdir -p artifacts + + # xvfb-run guards against Linux player init failing without a + # display, even with -batchmode. Cheap insurance. + sudo apt-get update >/dev/null 2>&1 || true + sudo apt-get install -y xvfb >/dev/null 2>&1 || true + + xvfb-run -a --server-args="-screen 0 320x240x24 -ac" -- \ + "$player" -batchmode -nographics -logFile - \ + > artifacts/smoke.log 2>&1 & + smoke_pid=$! + + # Hard cap 60s. The runner itself enforces a 20s flush + 30s deadline; + # this is just to release the cell if Application.Quit hangs. + deadline=$((SECONDS + 60)) + while kill -0 $smoke_pid 2>/dev/null; do + if [ "$SECONDS" -ge "$deadline" ]; then + echo "::error::Smoke runner did not exit within 60s; killing." + kill -KILL $smoke_pid 2>/dev/null || true + wait $smoke_pid 2>/dev/null || true + echo "----- smoke.log -----" + cat artifacts/smoke.log || true + exit 1 + fi + sleep 1 + done + + wait $smoke_pid + rc=$? + echo "Smoke exit code: $rc" + echo "----- smoke.log (tail 80) -----" + tail -n 80 artifacts/smoke.log || true + exit $rc + + - name: Upload smoke artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: linux-build-smoke-${{ matrix.backend }}-${{ matrix.target }}-${{ matrix.unity }} + if-no-files-found: ignore + path: | + artifacts/smoke.log + examples/audience/Builds/LinuxSmoke/** + # 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 diff --git a/examples/audience/Assets/Editor/Immutable.Audience.Samples.SampleApp.Editor.asmdef b/examples/audience/Assets/Editor/Immutable.Audience.Samples.SampleApp.Editor.asmdef index 3591a15de..8c4bca8e6 100644 --- a/examples/audience/Assets/Editor/Immutable.Audience.Samples.SampleApp.Editor.asmdef +++ b/examples/audience/Assets/Editor/Immutable.Audience.Samples.SampleApp.Editor.asmdef @@ -1,7 +1,7 @@ { "name": "Immutable.Audience.Samples.SampleApp.Editor", "rootNamespace": "Immutable.Audience.Samples.SampleApp.Editor", - "references": [], + "references": ["Immutable.Audience.Samples.SampleApp"], "includePlatforms": [ "Editor" ], diff --git a/examples/audience/Assets/Editor/LinuxSmokeBuilder.cs b/examples/audience/Assets/Editor/LinuxSmokeBuilder.cs new file mode 100644 index 000000000..747e40c9f --- /dev/null +++ b/examples/audience/Assets/Editor/LinuxSmokeBuilder.cs @@ -0,0 +1,94 @@ +#nullable enable + +using System; +using System.IO; +using Immutable.Audience.Samples.SampleApp; +using UnityEditor; +using UnityEditor.SceneManagement; +using UnityEditor.Build.Reporting; +using UnityEngine; +using UnityEngine.SceneManagement; + +namespace Immutable.Audience.Samples.SampleApp.Editor +{ + // Invoked by CI via: + // Unity -batchmode -buildTarget StandaloneLinux64 \ + // -executeMethod Immutable.Audience.Samples.SampleApp.Editor.LinuxSmokeBuilder.Build \ + // -quit + // + // Optional CLI arg: + // --buildPath Output path for the player (default: Builds/LinuxSmoke/LinuxSmokePlayer.x86_64) + // + // Produces a single-scene Linux player whose only behaviour is the + // LinuxSmokeRunner MonoBehaviour. The scene is generated in memory at + // build time to avoid shipping a hand-written .unity asset. + internal static class LinuxSmokeBuilder + { + private const string DefaultBuildPath = "Builds/LinuxSmoke/LinuxSmokePlayer.x86_64"; + private const string TempScenePath = "Assets/_LinuxSmokeBuild.unity"; + + public static void Build() + { + string buildPath = GetArgValue("--buildPath") ?? DefaultBuildPath; + string scenePath = TempScenePath; + + try + { + CreateSmokeScene(scenePath); + + Directory.CreateDirectory(Path.GetDirectoryName(buildPath)!); + + var options = new BuildPlayerOptions + { + scenes = new[] { scenePath }, + locationPathName = buildPath, + target = BuildTarget.StandaloneLinux64, + targetGroup = BuildTargetGroup.Standalone, + options = BuildOptions.None, + }; + + Debug.Log($"[LinuxSmokeBuilder] Building -> {buildPath}"); + var report = BuildPipeline.BuildPlayer(options); + var summary = report.summary; + + if (summary.result == BuildResult.Succeeded) + { + Debug.Log($"[LinuxSmokeBuilder] Build succeeded ({summary.totalSize / 1024 / 1024} MB)."); + } + else + { + Debug.LogError($"[LinuxSmokeBuilder] Build failed: {summary.totalErrors} error(s)."); + EditorApplication.Exit(1); + } + } + finally + { + if (AssetDatabase.LoadAssetAtPath(scenePath) != null) + { + AssetDatabase.DeleteAsset(scenePath); + } + } + } + + private static void CreateSmokeScene(string scenePath) + { + var scene = EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single); + + var go = new GameObject("SmokeRunner"); + go.AddComponent(); + + EditorSceneManager.SaveScene(scene, scenePath); + } + + 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/LinuxSmokeBuilder.cs.meta b/examples/audience/Assets/Editor/LinuxSmokeBuilder.cs.meta new file mode 100644 index 000000000..b3b6cdf1f --- /dev/null +++ b/examples/audience/Assets/Editor/LinuxSmokeBuilder.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4d2e7a1c9b8f3e4ab1c5d6e7f8a9b0c1 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/examples/audience/Assets/SampleApp/Scripts/LinuxSmokeRunner.cs b/examples/audience/Assets/SampleApp/Scripts/LinuxSmokeRunner.cs new file mode 100644 index 000000000..d250c748e --- /dev/null +++ b/examples/audience/Assets/SampleApp/Scripts/LinuxSmokeRunner.cs @@ -0,0 +1,114 @@ +#nullable enable + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using UnityEngine; + +namespace Immutable.Audience.Samples.SampleApp +{ + // Headless smoke runner used by the linux-build-smoke CI job. Boots the + // Audience SDK, sends one marker event, awaits a flush, then quits with + // exit code 0 on success or 1 on any failure. Attached to a runtime + // GameObject by LinuxSmokeBuilder; does not run during normal SampleApp + // execution. + public sealed class LinuxSmokeRunner : MonoBehaviour + { + private const string KeyEnv = "AUDIENCE_TEST_PUBLISHABLE_KEY"; + private const string RunEnv = "AUDIENCE_TEST_RUN_ID"; + private const string CellEnv = "AUDIENCE_TEST_CELL_ID"; + + private const string EventName = "linux_smoke_ci_marker"; + private const int FlushTimeoutSeconds = 20; + private const int OverallTimeoutSeconds = 30; + + private void Start() + { + StartCoroutine(RunSmoke()); + } + + private IEnumerator RunSmoke() + { + var deadline = Time.realtimeSinceStartup + OverallTimeoutSeconds; + AudienceError? capturedError = null; + + string? key = Environment.GetEnvironmentVariable(KeyEnv); + if (string.IsNullOrEmpty(key)) + { + Debug.LogError($"[LinuxSmoke] {KeyEnv} is unset. Cannot init."); + Application.Quit(1); + yield break; + } + + var config = new AudienceConfig + { + PublishableKey = key, + Consent = ConsentLevel.Full, + Debug = true, + OnError = err => capturedError = err, + }; + + try + { + ImmutableAudience.Init(config); + } + catch (Exception ex) + { + Debug.LogError($"[LinuxSmoke] Init threw: {ex}"); + Application.Quit(1); + yield break; + } + + var props = new Dictionary + { + ["runId"] = Environment.GetEnvironmentVariable(RunEnv) ?? "(unset)", + ["cellId"] = Environment.GetEnvironmentVariable(CellEnv) ?? "(unset)", + ["host"] = "linux-build-smoke", + }; + + try + { + ImmutableAudience.Track(EventName, props); + } + catch (Exception ex) + { + Debug.LogError($"[LinuxSmoke] Track threw: {ex}"); + Application.Quit(1); + yield break; + } + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(FlushTimeoutSeconds)); + var flushTask = ImmutableAudience.FlushAsync(cts.Token); + + while (!flushTask.IsCompleted) + { + if (Time.realtimeSinceStartup > deadline) + { + Debug.LogError("[LinuxSmoke] Overall deadline reached before flush completed."); + Application.Quit(1); + yield break; + } + yield return null; + } + + if (flushTask.IsFaulted) + { + Debug.LogError($"[LinuxSmoke] FlushAsync faulted: {flushTask.Exception}"); + Application.Quit(1); + yield break; + } + + if (capturedError != null) + { + Debug.LogError($"[LinuxSmoke] AudienceError fired during run: {capturedError}"); + Application.Quit(1); + yield break; + } + + Debug.Log("[LinuxSmoke] OK. Init + Track + FlushAsync completed without errors."); + Application.Quit(0); + } + } +} diff --git a/examples/audience/Assets/SampleApp/Scripts/LinuxSmokeRunner.cs.meta b/examples/audience/Assets/SampleApp/Scripts/LinuxSmokeRunner.cs.meta new file mode 100644 index 000000000..48b434fe1 --- /dev/null +++ b/examples/audience/Assets/SampleApp/Scripts/LinuxSmokeRunner.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9c5a3b8e1d6f4a4e9f3b7c8d2a1e0f5a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: