From 2703f29ebc8cb4ecb12e7bb18366b8c2400a6bad Mon Sep 17 00:00:00 2001 From: wallstop Date: Sun, 14 Jun 2026 01:47:42 +0000 Subject: [PATCH 01/31] chore(mcp): verify Unity MCP setup, gitignore client configs, add validator - Gitignore machine-local .mcp.json + .cursor/mcp.json; drop stale DxMessaging cruft. - Fix a command-injection in probe-unity-mcp-endpoint.sh, align arg/env precedence, and correct a misplaced shellcheck directive in both copied MCP scripts. - Remove the dangling "Claude Desktop helper" README section and add a session-binding note. - Add scripts/validate-mcp-config.ps1 (+ self-test + validate-mcp-config.yml CI) enforcing configs are gitignored, valid, target /mcp, and doc refs resolve. - Add the mcp-configuration skill, MCP Local Setup nav entry, and the "mcp" cspell term. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/validate-mcp-config.yml | 47 +++++ .gitignore | 43 +--- .llm/skills/mcp-configuration.md | 68 +++++++ cspell.json | 3 +- docs/guides/mcp-local-setup.md | 72 +++++++ docs/guides/mcp-local-setup.md.meta | 7 + mkdocs.yml | 1 + package.json | 6 +- scripts/mcp.meta | 8 + scripts/mcp/README.md | 87 ++++++++ scripts/mcp/README.md.meta | 7 + scripts/mcp/configure-unity-mcp-endpoint.sh | 152 ++++++++++++++ .../mcp/configure-unity-mcp-endpoint.sh.meta | 7 + scripts/mcp/probe-unity-mcp-endpoint.sh | 75 +++++++ scripts/mcp/probe-unity-mcp-endpoint.sh.meta | 7 + scripts/mcp/start-unity-mcp-bridge.ps1 | 89 ++++++++ scripts/mcp/start-unity-mcp-bridge.ps1.meta | 7 + scripts/tests/test-validate-mcp-config.ps1 | 192 ++++++++++++++++++ .../tests/test-validate-mcp-config.ps1.meta | 7 + scripts/validate-mcp-config.ps1 | 162 +++++++++++++++ scripts/validate-mcp-config.ps1.meta | 7 + 21 files changed, 1016 insertions(+), 38 deletions(-) create mode 100644 .github/workflows/validate-mcp-config.yml create mode 100644 .llm/skills/mcp-configuration.md create mode 100644 docs/guides/mcp-local-setup.md create mode 100644 docs/guides/mcp-local-setup.md.meta create mode 100644 scripts/mcp.meta create mode 100644 scripts/mcp/README.md create mode 100644 scripts/mcp/README.md.meta create mode 100644 scripts/mcp/configure-unity-mcp-endpoint.sh create mode 100644 scripts/mcp/configure-unity-mcp-endpoint.sh.meta create mode 100644 scripts/mcp/probe-unity-mcp-endpoint.sh create mode 100644 scripts/mcp/probe-unity-mcp-endpoint.sh.meta create mode 100644 scripts/mcp/start-unity-mcp-bridge.ps1 create mode 100644 scripts/mcp/start-unity-mcp-bridge.ps1.meta create mode 100644 scripts/tests/test-validate-mcp-config.ps1 create mode 100644 scripts/tests/test-validate-mcp-config.ps1.meta create mode 100644 scripts/validate-mcp-config.ps1 create mode 100644 scripts/validate-mcp-config.ps1.meta diff --git a/.github/workflows/validate-mcp-config.yml b/.github/workflows/validate-mcp-config.yml new file mode 100644 index 000000000..092c2979b --- /dev/null +++ b/.github/workflows/validate-mcp-config.yml @@ -0,0 +1,47 @@ +name: Validate MCP Config + +on: + push: + branches: + - main + paths: + - ".mcp.json" + - ".cursor/**" + - ".vscode/**" + - ".codex/**" + - "scripts/mcp/**" + - "scripts/validate-mcp-config.ps1" + - "scripts/tests/test-validate-mcp-config.ps1" + - "docs/guides/mcp-local-setup.md" + - ".gitignore" + - ".github/workflows/validate-mcp-config.yml" + pull_request: + paths: + - ".mcp.json" + - ".cursor/**" + - ".vscode/**" + - ".codex/**" + - "scripts/mcp/**" + - "scripts/validate-mcp-config.ps1" + - "scripts/tests/test-validate-mcp-config.ps1" + - "docs/guides/mcp-local-setup.md" + - ".gitignore" + - ".github/workflows/validate-mcp-config.yml" + +permissions: + contents: read + +jobs: + validate-mcp-config: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Test MCP config validator + shell: pwsh + run: ./scripts/tests/test-validate-mcp-config.ps1 -VerboseOutput + + - name: Validate MCP config + shell: pwsh + run: ./scripts/validate-mcp-config.ps1 -VerboseOutput diff --git a/.gitignore b/.gitignore index 0724d1ef7..ca6afcc12 100644 --- a/.gitignore +++ b/.gitignore @@ -46,6 +46,14 @@ logs_extracted* !.claude/settings.json !.claude/.gitkeep .codex/* + +# Machine-local Unity MCP client configuration. The bridge endpoint (host:port) +# is per-developer, written by scripts/mcp/configure-unity-mcp-endpoint.sh — never +# commit these. See docs/guides/mcp-local-setup.md. (.vscode/** and .codex/* above +# already cover the VS Code and Codex client configs.) +.mcp.json +.cursor/mcp.json + _llm_* _llm_*.meta /.*/**/*.meta @@ -333,41 +341,6 @@ __pycache__/ .pytest_cache/ *.pyc -/Unity/DxMessagingUnity/[Ll]ibrary/ -/Unity/DxMessagingUnity/[Tt]emp/ -/Unity/DxMessagingUnity/[Oo]bj/ -/Unity/DxMessagingUnity/[Bb]uild/ -/Unity/DxMessagingUnity/[Bb]uilds/ -/Unity/DxMessagingUnity/Assets/AssetStoreTools* - -# Visual Studio 2015 cache directory -/Unity/DxMessagingUnity/.vs/ - -# Autogenerated VS/MD/Consulo solution and project files -/Unity/DxMessagingUnityExportedObj/ -/Unity/DxMessagingUnity.consulo/ -/Unity/DxMessagingUnity*.csproj -/Unity/DxMessagingUnity*.unityproj -/Unity/DxMessagingUnity*.sln -/Unity/DxMessagingUnity*.suo -/Unity/DxMessagingUnity*.tmp -/Unity/DxMessagingUnity*.user -/Unity/DxMessagingUnity*.userprefs -/Unity/DxMessagingUnity*.pidb -/Unity/DxMessagingUnity*.booproj -/Unity/DxMessagingUnity*.svd -/Unity/DxMessagingUnity*.pdb - -# Unity3D generated meta files -/Unity/DxMessagingUnity*.pidb.meta - -# Unity3D Generated File On Crash Reports -/Unity/DxMessagingUnitysysinfo.txt - -# Builds -/Unity/DxMessagingUnity*.apk -/Unity/DxMessagingUnity*.unitypackage - package-lock.json package-lock.json.meta diff --git a/.llm/skills/mcp-configuration.md b/.llm/skills/mcp-configuration.md new file mode 100644 index 000000000..0acff1654 --- /dev/null +++ b/.llm/skills/mcp-configuration.md @@ -0,0 +1,68 @@ +# Skill: Unity MCP Configuration + + + +**Trigger**: When configuring the Unity MCP server for an agent (Claude Code, Cursor, Codex, VS Code/Copilot), when the `Unity_*` tools are missing from an agent, or when editing anything under `scripts/mcp/` or the MCP client config files. + +--- + +## What this is + +Unity runs on a Windows host; agents run in a Linux devcontainer. The Windows +relay speaks stdio, which cannot cross into the container, so `supergateway` +bridges it to streamable HTTP and the container's agents point at that HTTP +endpoint. Full setup: [`docs/guides/mcp-local-setup.md`](../../docs/guides/mcp-local-setup.md); +script details: `scripts/mcp/README.md`. + +``` +Unity (Windows, stdio) → supergateway bridge → http://:/mcp → agent clients (Linux container) +``` + +## The config files are machine-local (never commit) + +The bridge `host:port` is per-developer, so all four generated client configs are +**gitignored** and regenerated locally: + +| Client | File | Ignored by | +| ----------------- | -------------------- | -------------- | +| Claude Code | `.mcp.json` | explicit entry | +| Cursor | `.cursor/mcp.json` | explicit entry | +| VS Code / Copilot | `.vscode/mcp.json` | `.vscode/**` | +| Codex | `.codex/config.toml` | `.codex/*` | + +Generate/refresh them all from one endpoint: + +```bash +UNITY_MCP_BRIDGE_HOST=YOUR_WINDOWS_HOST_IP UNITY_MCP_BRIDGE_PORT=9003 \ + bash scripts/mcp/configure-unity-mcp-endpoint.sh +``` + +## "My agent has no `Unity_*` tools" + +Most often the server is reachable but the agent session is stale. Order of checks: + +1. **Reachability** — `bash scripts/mcp/probe-unity-mcp-endpoint.sh 9003`. + A healthy bridge answers `POST /mcp → 200` with an MCP `initialize` result. +2. **Config present + valid** — `pwsh scripts/validate-mcp-config.ps1`. +3. **Bind to the agent** — generating the config does NOT attach the server to an + already-running agent. Reload it: restart the editor/CLI, or in Claude Code + re-approve the project MCP server. Only then do the `Unity_*` tools appear. + +## Enforced forever + +`scripts/validate-mcp-config.ps1` (CI: `.github/workflows/validate-mcp-config.yml`) +fails the build on: + +- `UNH-MCP-TRACKED` — a machine-local config path is not gitignored. +- `UNH-MCP-INVALID` — a present config is malformed or its `unity-mcp-remote` URL + does not end with `/mcp`. +- `UNH-MCP-MISSINGREF` — an MCP doc references a `scripts/mcp/*.sh|*.ps1` that does + not exist (the dangling-helper-script class of bug). + +When you add a new MCP client config path or helper script, update +`scripts/mcp/configure-unity-mcp-endpoint.sh`, `.gitignore`, and the +`$localConfigs` list in `scripts/validate-mcp-config.ps1` together. + +## Related Skills + +- `.llm/skills/unity-devcontainer-testing.md` — running Unity from the devcontainer. diff --git a/cspell.json b/cspell.json index a063c072e..da9d6bd9b 100644 --- a/cspell.json +++ b/cspell.json @@ -818,7 +818,8 @@ "env", "instanceof", "TMPDIR", - "cwd" + "cwd", + "mcp" ] } ], diff --git a/docs/guides/mcp-local-setup.md b/docs/guides/mcp-local-setup.md new file mode 100644 index 000000000..9559443cd --- /dev/null +++ b/docs/guides/mcp-local-setup.md @@ -0,0 +1,72 @@ +# MCP Local Setup + +This page covers machine-local MCP configuration for a Linux devcontainer with a +Windows host relay. + +## Why this is local-only + +These files contain machine-specific host and port values and are gitignored: + +- `.vscode/mcp.json` +- `.mcp.json` +- `.cursor/mcp.json` +- `.codex/config.toml` + +Do not commit these files. + +## 1. Define local endpoint values + +Create `.env.local` in repo root: + +```bash +export UNITY_MCP_BRIDGE_HOST=YOUR_WINDOWS_HOST_IP +export UNITY_MCP_BRIDGE_PORT=9003 +export UNITY_MCP_BRIDGE_PATH=/mcp +``` + +Optional defaults used by scripts: + +- `UNITY_MCP_DEFAULT_HOST` +- `UNITY_MCP_DEFAULT_PORT` +- `UNITY_MCP_DEFAULT_PATH` + +## 2. Generate all local client configs + +Run from the devcontainer: + +```bash +bash scripts/mcp/configure-unity-mcp-endpoint.sh +``` + +This updates local MCP config files for VS Code, Claude Code, Cursor, and Codex. + +## 3. Start bridge on Windows host + +See the [official Unity Docs](https://docs.unity3d.com/Packages/com.unity.ai.assistant@2.9/manual/integration/unity-mcp-get-started.html) for more details. + +```powershell +$env:UNITY_MCP_RELAY_COMMAND = '' +pwsh -File scripts/mcp/start-unity-mcp-bridge.ps1 -Port 9003 +``` + +## 4. Probe from the devcontainer + +```bash +bash scripts/mcp/probe-unity-mcp-endpoint.sh YOUR_WINDOWS_HOST_IP 9003 +``` + +## Notes + +- `9003` is the default fallback port in MCP helper scripts. +- If your host uses a different port, set it in `.env.local` or pass it as a + script argument. +- See `scripts/mcp/README.md` for script-level details. + +## Binding the server to your agent + +Generating the config files does not retroactively connect an agent that was +already running. After step 2, **reload the agent** so it picks up the new +server — restart the editor/CLI, or in Claude Code re-approve the project MCP +server — then the `Unity_*` tools attach. The MCP server can be running and +reachable (step 4 returns HTTP 200) while a stale agent session still shows no +Unity tools; reloading is what binds them. diff --git a/docs/guides/mcp-local-setup.md.meta b/docs/guides/mcp-local-setup.md.meta new file mode 100644 index 000000000..c834b3903 --- /dev/null +++ b/docs/guides/mcp-local-setup.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 2fa90207f9e700c46a9e5fd0b2e31890 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/mkdocs.yml b/mkdocs.yml index 22c23609e..b2ba2c98b 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -228,6 +228,7 @@ nav: - Guides: - Odin Migration: guides/odin-migration-guide.md - Unity Devcontainer Licensing: guides/unity-devcontainer-licensing.md + - MCP Local Setup: guides/mcp-local-setup.md - Contributing: - Overview: project/contributing.md - Testing Patterns: contributing/testing-patterns.md diff --git a/package.json b/package.json index 12689cff4..e88a478a0 100644 --- a/package.json +++ b/package.json @@ -134,6 +134,7 @@ "lint:dependabot": "pwsh -NoProfile -File scripts/lint-dependabot.ps1 -VerboseOutput", "lint:pwsh-invocations": "pwsh -NoProfile -File scripts/lint-pwsh-invocations.ps1 -VerboseOutput", "validate:lint-error-codes": "pwsh -NoProfile -File scripts/validate-lint-error-codes.ps1 -VerboseOutput", + "validate:mcp-config": "pwsh -NoProfile -File scripts/validate-mcp-config.ps1 -VerboseOutput", "validate:git-push-config": "pwsh -NoProfile -File scripts/validate-git-push-config.ps1", "test:add-cspell-word": "node scripts/tests/test-add-cspell-word.js", "test:configure-git-defaults": "bash scripts/tests/test-configure-git-defaults.sh", @@ -147,8 +148,8 @@ "test:validate-lint-error-codes": "pwsh -NoProfile -File scripts/tests/test-validate-lint-error-codes.ps1 -VerboseOutput", "test:precommit-integration": "bash scripts/tests/test-precommit-integration.sh", "lint:markdown": "node ./scripts/run-node-bin.js markdownlint --config .markdownlint.json --ignore-path .markdownlintignore -- \"**/*.md\" \"**/*.markdown\"", - "validate:content": "npm run lint:docs && npm run test:deprecated-external-links && npm run lint:markdown && npm run lint:changelog && npm run lint:yaml && npm run format:check && npm run lint:llm && npm run lint:doc-counts && npm run lint:dependabot && npm run lint:pwsh-invocations && npm run validate:lint-error-codes", - "validate:tests": "npm run lint:tests && npm run test:gitignore-docs && npm run test:sync-script-contracts && npm run test:agent-preflight && npm run test:git-path-helpers && npm run test:postinstall-hooks && npm run test:github-pages-sortable && npm run test:add-cspell-word && npm run test:configure-git-defaults && npm run test:validate-git-push-config && npm run test:git-staging-helpers && npm run test:lint-dependabot && npm run test:lint-duplicate-usings && npm run test:lint-pwsh-invocations && npm run test:validate-lint-error-codes && npm run test:precommit-integration && npm run test:npm-package-signature && npm run test:npm-package-changelog", + "validate:content": "npm run lint:docs && npm run test:deprecated-external-links && npm run lint:markdown && npm run lint:changelog && npm run lint:yaml && npm run format:check && npm run lint:llm && npm run lint:doc-counts && npm run lint:dependabot && npm run lint:pwsh-invocations && npm run validate:lint-error-codes && npm run validate:mcp-config", + "validate:tests": "npm run lint:tests && npm run test:gitignore-docs && npm run test:validate-mcp-config && npm run test:sync-script-contracts && npm run test:agent-preflight && npm run test:git-path-helpers && npm run test:postinstall-hooks && npm run test:github-pages-sortable && npm run test:add-cspell-word && npm run test:configure-git-defaults && npm run test:validate-git-push-config && npm run test:git-staging-helpers && npm run test:lint-dependabot && npm run test:lint-duplicate-usings && npm run test:lint-pwsh-invocations && npm run test:validate-lint-error-codes && npm run test:precommit-integration && npm run test:npm-package-signature && npm run test:npm-package-changelog", "validate:prepush": "npm run validate:content && npm run lint:spelling && npm run eol:check && npm run validate:tests && npm run lint:csharp-naming && npm run lint:duplicate-usings && npm run lint:spelling:config && npm run validate:devcontainer && npm run validate:hook-sync && npm run validate:hook-perms && npm run validate:hook-spell-parity && npm run validate:cspell-files-parity && npm run validate:git-push-config && npm run test:shell-portability", "validate:devcontainer": "pwsh -NoProfile -File scripts/validate-devcontainer-config.ps1 -VerboseOutput && npm run test:validate-devcontainer-urls && npm run test:post-create", "validate:hook-sync": "pwsh -NoProfile -File scripts/validate-hook-sync-calls.ps1 -VerboseOutput", @@ -163,6 +164,7 @@ "validate:npm-package": "pwsh -NoProfile -File scripts/validate-npm-package.ps1 -VerboseOutput", "test:lint-unity-file-naming": "pwsh -NoProfile -File scripts/tests/test-lint-unity-file-naming.ps1 -VerboseOutput", "test:gitignore-docs": "pwsh -NoProfile -File scripts/tests/test-gitignore-docs.ps1", + "test:validate-mcp-config": "pwsh -NoProfile -File scripts/tests/test-validate-mcp-config.ps1", "test:sync-script-contracts": "pwsh -NoProfile -File scripts/tests/test-sync-script-contracts.ps1", "test:agent-preflight": "pwsh -NoProfile -File scripts/tests/test-agent-preflight.ps1", "test:git-staging-helpers": "bash scripts/tests/test-git-staging-helpers.sh", diff --git a/scripts/mcp.meta b/scripts/mcp.meta new file mode 100644 index 000000000..b3d2c5a85 --- /dev/null +++ b/scripts/mcp.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: dc0792d380562fa40b3bd74a64663399 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/scripts/mcp/README.md b/scripts/mcp/README.md new file mode 100644 index 000000000..c67b789e4 --- /dev/null +++ b/scripts/mcp/README.md @@ -0,0 +1,87 @@ +# Unity MCP in a Linux devcontainer with a Windows host + +When Unity runs on Windows and your agents run inside a Linux devcontainer, the +Windows relay binary cannot run inside the container. The working pattern is: + +1. Start the Unity relay on Windows (stdio). +2. Bridge stdio to HTTP on Windows. +3. Point container MCP clients at that HTTP endpoint. + +This repository uses `supergateway` for step 2. + +The generated MCP client config files are machine-local and gitignored: + +- `.vscode/mcp.json` +- `.mcp.json` +- `.cursor/mcp.json` +- `.codex/config.toml` + +Set your local endpoint in `.env.local` at repo root: + +```bash +export UNITY_MCP_BRIDGE_HOST=YOUR_WINDOWS_HOST_IP +export UNITY_MCP_BRIDGE_PORT=9003 +export UNITY_MCP_BRIDGE_PATH=/mcp +``` + +## Start the bridge on Windows + +From the repository root on the Windows host: + +```powershell +$env:UNITY_MCP_RELAY_COMMAND = '' +pwsh -File scripts/mcp/start-unity-mcp-bridge.ps1 -Port 9003 +``` + +The relay command is installation-specific. Use the exact relay invocation shown +in your Unity MCP integration docs for your machine. + +Optional flags: + +- `-McpPath /mcp` (default `/mcp`) +- `-Stateful` if your MCP client requires stateful streamable HTTP sessions +- `-LogLevel info|debug|none` + +If needed, add a Windows firewall rule for the selected port. + +## Validate from the Linux devcontainer + +```bash +bash scripts/mcp/probe-unity-mcp-endpoint.sh YOUR_WINDOWS_HOST_IP 9003 +``` + +Use your actual Windows host LAN IP and bridge port. + +## Sync all workspace client configs + +Run this inside the Linux devcontainer whenever host/port/path changes: + +```bash +UNITY_MCP_BRIDGE_HOST=YOUR_WINDOWS_HOST_IP UNITY_MCP_BRIDGE_PORT=9003 \ + bash scripts/mcp/configure-unity-mcp-endpoint.sh +``` + +This updates all local client config files to the same endpoint: + +- `.vscode/mcp.json` +- `.mcp.json` +- `.cursor/mcp.json` +- `.codex/config.toml` + +## Client configs in this repository + +- VS Code/Copilot: `.vscode/mcp.json` +- Claude Code: `.mcp.json` +- Cursor: `.cursor/mcp.json` +- Codex: `.codex/config.toml` + +All are configured to target a host bridge endpoint similar to +`http://:/mcp`. + +## Binding the server to your agent + +`configure-unity-mcp-endpoint.sh` only writes the config files. An agent that was +already running when the file appeared will not see the server until it reloads — +restart the agent (or, in Claude Code, re-approve the project MCP server) so the +`Unity_*` tools attach. Confirm reachability first with +`probe-unity-mcp-endpoint.sh`. diff --git a/scripts/mcp/README.md.meta b/scripts/mcp/README.md.meta new file mode 100644 index 000000000..60205997f --- /dev/null +++ b/scripts/mcp/README.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 4de2cd7b27ec4514b842d562d8b51e85 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/scripts/mcp/configure-unity-mcp-endpoint.sh b/scripts/mcp/configure-unity-mcp-endpoint.sh new file mode 100644 index 000000000..3a6467c84 --- /dev/null +++ b/scripts/mcp/configure-unity-mcp-endpoint.sh @@ -0,0 +1,152 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +local_env_file="${repo_root}/.env.local" + +if [[ -f "$local_env_file" ]]; then + set -a + # shellcheck disable=SC1090 + source "$local_env_file" + set +a +fi + +default_host="${UNITY_MCP_DEFAULT_HOST:-192.168.1.33}" +default_port="${UNITY_MCP_DEFAULT_PORT:-9003}" +default_path="${UNITY_MCP_DEFAULT_PATH:-/mcp}" + +# Positional args take precedence over env vars (consistent with +# probe-unity-mcp-endpoint.sh), falling back to env, then defaults. +host="${1:-${UNITY_MCP_BRIDGE_HOST:-${UNITY_MCP_HOST:-$default_host}}}" +port="${2:-${UNITY_MCP_BRIDGE_PORT:-${UNITY_MCP_PORT:-$default_port}}}" +endpoint_path="${3:-${UNITY_MCP_BRIDGE_PATH:-${UNITY_MCP_PATH:-$default_path}}}" + +if ! command -v node >/dev/null 2>&1; then + echo "node is required but was not found on PATH." >&2 + exit 1 +fi + +if [[ ! "$port" =~ ^[0-9]+$ ]] || (( port < 1 || port > 65535 )); then + echo "UNITY MCP port must be an integer between 1 and 65535. Got: $port" >&2 + exit 1 +fi + +if [[ "$endpoint_path" != /* ]]; then + endpoint_path="/$endpoint_path" +fi + +UNITY_MCP_HOST="$host" \ +UNITY_MCP_PORT="$port" \ +UNITY_MCP_PATH="$endpoint_path" \ +REPO_ROOT="$repo_root" \ +node <<'EOF' +const fs = require('node:fs'); +const path = require('node:path'); + +const repoRoot = process.env.REPO_ROOT; +const host = process.env.UNITY_MCP_HOST; +const port = process.env.UNITY_MCP_PORT; +const endpointPath = process.env.UNITY_MCP_PATH; +const url = `http://${host}:${port}${endpointPath}`; + +function toTomlString(value) { + return String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"'); +} + +function ensureParentDir(filePath) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); +} + +function writeJson(filePath, value) { + ensureParentDir(filePath); + fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8'); +} + +function readJsonOrDefault(filePath, fallbackValue) { + if (!fs.existsSync(filePath)) { + return fallbackValue; + } + + const raw = fs.readFileSync(filePath, 'utf8').trim(); + if (raw.length === 0) { + return fallbackValue; + } + + try { + return JSON.parse(raw); + } catch (error) { + throw new Error(`Invalid JSON in ${filePath}: ${error.message}`); + } +} + +function asObject(value) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return {}; + } + + return value; +} + +function updateJsonUrl(filePath, serverKey) { + const json = asObject(readJsonOrDefault(filePath, {})); + + const servers = asObject(json[serverKey]); + json[serverKey] = servers; + + const unityServer = asObject(servers['unity-mcp-remote']); + servers['unity-mcp-remote'] = unityServer; + + unityServer.type = 'http'; + unityServer.url = url; + + writeJson(filePath, json); +} + +function updateCodexUrl(filePath) { + const tomlUrl = toTomlString(url); + const codexBlock = [ + '[mcp_servers.unity_mcp_remote]', + `url = "${tomlUrl}"`, + 'startup_timeout_sec = 20', + 'tool_timeout_sec = 120', + 'enabled = true', + '', + ].join('\n'); + + if (!fs.existsSync(filePath)) { + const initial = [ + '# Machine-local Codex MCP config generated by scripts/mcp/configure-unity-mcp-endpoint.sh.', + '# This file is gitignored. Re-run the script when host/port/path changes.', + '', + codexBlock, + ].join('\n'); + + ensureParentDir(filePath); + fs.writeFileSync(filePath, initial, 'utf8'); + return; + } + + const codexRaw = fs.readFileSync(filePath, 'utf8'); + const codexPattern = /(\[mcp_servers\.unity_mcp_remote\][\s\S]*?^url\s*=\s*").*?(")/m; + + if (codexPattern.test(codexRaw)) { + const codexNext = codexRaw.replace(codexPattern, (match, prefix, suffix) => `${prefix}${tomlUrl}${suffix}`); + if (codexNext !== codexRaw) { + fs.writeFileSync(filePath, codexNext, 'utf8'); + } + return; + } + + const appended = `${codexRaw.replace(/\s*$/, '')}\n\n${codexBlock}`; + fs.writeFileSync(filePath, appended, 'utf8'); +} + +updateJsonUrl(path.join(repoRoot, '.vscode', 'mcp.json'), 'servers'); +updateJsonUrl(path.join(repoRoot, '.mcp.json'), 'mcpServers'); +updateJsonUrl(path.join(repoRoot, '.cursor', 'mcp.json'), 'mcpServers'); +updateCodexUrl(path.join(repoRoot, '.codex', 'config.toml')); + +console.log(`Updated Unity MCP endpoint to ${url}`); +EOF + +echo "Configured bridge endpoint for workspace clients: http://${host}:${port}${endpoint_path}" diff --git a/scripts/mcp/configure-unity-mcp-endpoint.sh.meta b/scripts/mcp/configure-unity-mcp-endpoint.sh.meta new file mode 100644 index 000000000..ee419f858 --- /dev/null +++ b/scripts/mcp/configure-unity-mcp-endpoint.sh.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: f39fae0dd55cb7f41b6b2fa6947db6f8 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/scripts/mcp/probe-unity-mcp-endpoint.sh b/scripts/mcp/probe-unity-mcp-endpoint.sh new file mode 100644 index 000000000..e4f751839 --- /dev/null +++ b/scripts/mcp/probe-unity-mcp-endpoint.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +local_env_file="${repo_root}/.env.local" + +if [[ -f "$local_env_file" ]]; then + set -a + # shellcheck disable=SC1090 + source "$local_env_file" + set +a +fi + +default_host="${UNITY_MCP_DEFAULT_HOST:-192.168.1.33}" +default_port="${UNITY_MCP_DEFAULT_PORT:-9003}" + +host="${1:-${UNITY_MCP_BRIDGE_HOST:-${UNITY_MCP_HOST:-$default_host}}}" +shift || true + +# Validate host before it is ever used in a connection string. host can come +# from a positional arg, an env var, or a sourced .env.local; restricting it to +# hostname/IP characters prevents shell metacharacters from being re-parsed. +if [[ ! "$host" =~ ^[A-Za-z0-9._-]+$ ]]; then + echo "Invalid host '${host}'. Expected a hostname or IP (letters, digits, '.', '-', '_')." >&2 + exit 1 +fi + +protocol_version="${UNITY_MCP_PROTOCOL_VERSION:-2025-11-25}" +probe_body="$(mktemp /tmp/unity_mcp_probe_body.XXXXXX)" +trap 'rm -f "$probe_body"' EXIT +found_open_port=0 + +if [[ "$#" -gt 0 ]]; then + ports=("$@") +elif [[ -n "${UNITY_MCP_PROBE_PORTS:-}" ]]; then + read -r -a ports <<< "${UNITY_MCP_PROBE_PORTS//,/ }" +else + ports=("${UNITY_MCP_BRIDGE_PORT:-${UNITY_MCP_PORT:-$default_port}}") +fi + +initialize_payload="{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{\"protocolVersion\":\"${protocol_version}\",\"capabilities\":{},\"clientInfo\":{\"name\":\"unity-mcp-probe\",\"version\":\"1.0\"}}}" + +for port in "${ports[@]}"; do + if [[ ! "$port" =~ ^[0-9]+$ ]] || (( port < 1 || port > 65535 )); then + echo "Skipping invalid port: ${port}" >&2 + continue + fi + # Pass host/port as positional args to bash -c (NOT interpolated into the + # script text) so they are never re-parsed as shell. The single quotes are + # intentional: $1/$2 must expand in the INNER shell, from the args we pass. + # shellcheck disable=SC2016 + if timeout 0.3 bash -c 'exec 3<>"/dev/tcp/$1/$2"' _ "$host" "$port" 2>/dev/null; then + found_open_port=1 + echo "open:${port}" + + for endpoint in /mcp /sse /; do + get_status="$(curl -sS -m 2 -o "$probe_body" -w "%{http_code}" "http://${host}:${port}${endpoint}" || true)" + if [[ "$get_status" != "000" ]]; then + get_preview="$(head -c 160 "$probe_body" | tr '\n' ' ')" + echo " GET ${endpoint} -> ${get_status} :: ${get_preview}" + fi + + post_status="$(curl -sS -m 2 -o "$probe_body" -w "%{http_code}" -X POST -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' -H "MCP-Protocol-Version: ${protocol_version}" "http://${host}:${port}${endpoint}" --data "$initialize_payload" || true)" + if [[ "$post_status" != "000" ]]; then + post_preview="$(head -c 160 "$probe_body" | tr '\n' ' ')" + echo " POST ${endpoint} -> ${post_status} :: ${post_preview}" + fi + done + fi +done + +if [[ "$found_open_port" -eq 0 ]]; then + echo "No reachable TCP ports found on host ${host} for requested probe set." >&2 + exit 1 +fi \ No newline at end of file diff --git a/scripts/mcp/probe-unity-mcp-endpoint.sh.meta b/scripts/mcp/probe-unity-mcp-endpoint.sh.meta new file mode 100644 index 000000000..e70672185 --- /dev/null +++ b/scripts/mcp/probe-unity-mcp-endpoint.sh.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 1c91d2fb724e8e64aa15d43f7036ef9f +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/scripts/mcp/start-unity-mcp-bridge.ps1 b/scripts/mcp/start-unity-mcp-bridge.ps1 new file mode 100644 index 000000000..60acedbe0 --- /dev/null +++ b/scripts/mcp/start-unity-mcp-bridge.ps1 @@ -0,0 +1,89 @@ +#!/usr/bin/env pwsh +[CmdletBinding()] +param( + [Parameter(Mandatory = $false)] + [string]$RelayCommand = $env:UNITY_MCP_RELAY_COMMAND, + + [Parameter(Mandatory = $false)] + [int]$Port = 0, + + [Parameter(Mandatory = $false)] + [string]$McpPath = "/mcp", + + [Parameter(Mandatory = $false)] + [ValidateSet("info", "debug", "none")] + [string]$LogLevel = "info", + + [Parameter(Mandatory = $false)] + [switch]$Stateful +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" +$supergatewayPackage = if ($env:UNITY_MCP_SUPERGATEWAY_PACKAGE) { $env:UNITY_MCP_SUPERGATEWAY_PACKAGE } else { "supergateway@3.4.3" } +$defaultPort = if ($env:UNITY_MCP_DEFAULT_PORT) { [int]$env:UNITY_MCP_DEFAULT_PORT } else { 9003 } + +if (-not $PSBoundParameters.ContainsKey("Port")) { + if ($env:UNITY_MCP_BRIDGE_PORT) { + $Port = [int]$env:UNITY_MCP_BRIDGE_PORT + } + elseif ($env:UNITY_MCP_PORT) { + $Port = [int]$env:UNITY_MCP_PORT + } + else { + $Port = $defaultPort + } +} + +if ($Port -lt 1 -or $Port -gt 65535) { + throw "Port must be between 1 and 65535. Got: $Port" +} + +if (-not $PSBoundParameters.ContainsKey("McpPath")) { + if ($env:UNITY_MCP_BRIDGE_PATH) { + $McpPath = $env:UNITY_MCP_BRIDGE_PATH + } + elseif ($env:UNITY_MCP_PATH) { + $McpPath = $env:UNITY_MCP_PATH + } +} + +if ([string]::IsNullOrWhiteSpace($RelayCommand)) { + throw @" +UNITY_MCP_RELAY_COMMAND is required. + +Example: + `$env:UNITY_MCP_RELAY_COMMAND = 'C:\Path\To\relay_win.exe --mcp' + pwsh -File scripts/mcp/start-unity-mcp-bridge.ps1 -Port 9003 +"@ +} + +if ([string]::IsNullOrWhiteSpace($McpPath)) { + $McpPath = "/mcp" +} + +if (-not $McpPath.StartsWith('/')) { + $McpPath = "/$McpPath" +} + +$null = Get-Command npx -ErrorAction Stop + +$supergatewayArgs = @( + "-y", + $supergatewayPackage, + "--stdio", $RelayCommand, + "--outputTransport", "streamableHttp", + "--streamableHttpPath", $McpPath, + "--port", $Port.ToString(), + "--logLevel", $LogLevel +) + +if ($Stateful.IsPresent) { + $supergatewayArgs += "--stateful" +} + +Write-Host "Starting Unity MCP bridge with supergateway..." +Write-Host "Relay command: $RelayCommand" +Write-Host "Bridge URL: http://0.0.0.0:$Port$McpPath" + +& npx @supergatewayArgs diff --git a/scripts/mcp/start-unity-mcp-bridge.ps1.meta b/scripts/mcp/start-unity-mcp-bridge.ps1.meta new file mode 100644 index 000000000..0f140506e --- /dev/null +++ b/scripts/mcp/start-unity-mcp-bridge.ps1.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 8abd0368b7a15bf48926668a9dac6122 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/scripts/tests/test-validate-mcp-config.ps1 b/scripts/tests/test-validate-mcp-config.ps1 new file mode 100644 index 000000000..db599324e --- /dev/null +++ b/scripts/tests/test-validate-mcp-config.ps1 @@ -0,0 +1,192 @@ +Param( + [switch]$VerboseOutput +) + +<# +.SYNOPSIS + Test runner for validate-mcp-config.ps1 + +.DESCRIPTION + Verifies that validate-mcp-config.ps1 correctly: + - Passes a clean fixture (all configs gitignored, valid, doc refs resolve) + - Detects UNH-MCP-TRACKED (a machine-local config not matched by .gitignore) + - Detects UNH-MCP-INVALID (a config URL not ending in /mcp) + - Detects UNH-MCP-MISSINGREF (a doc referencing a nonexistent helper script) + - Passes against the real repository (regression smoke test) + +.PARAMETER VerboseOutput + Show detailed output during test execution. + +.EXAMPLE + ./scripts/tests/test-validate-mcp-config.ps1 + ./scripts/tests/test-validate-mcp-config.ps1 -VerboseOutput +#> + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$script:TestsPassed = 0 +$script:TestsFailed = 0 +$script:FailedTests = @() + +$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..' '..')).Path +$validator = Join-Path $repoRoot 'scripts/validate-mcp-config.ps1' + +function Write-Info($msg) { + if ($VerboseOutput) { Write-Host "[test-validate-mcp-config] $msg" -ForegroundColor Cyan } +} + +function Write-TestResult { + param([string]$TestName, [bool]$Passed, [string]$Message = '') + if ($Passed) { + Write-Host " [PASS] $TestName" -ForegroundColor Green + $script:TestsPassed++ + } + else { + Write-Host " [FAIL] $TestName" -ForegroundColor Red + if ($Message) { Write-Host " $Message" -ForegroundColor Yellow } + $script:TestsFailed++ + $script:FailedTests += $TestName + } +} + +# Creates a temporary git repo fixture and returns its path. $Files is a hashtable +# of repo-relative path -> file content. A .gitignore is always written from +# $GitIgnore. +function New-McpFixture { + param( + [hashtable]$Files = @{}, + [string]$GitIgnore = '' + ) + $dir = Join-Path ([System.IO.Path]::GetTempPath()) ("mcp-fixture-" + [guid]::NewGuid().ToString('N')) + New-Item -ItemType Directory -Force -Path $dir | Out-Null + Push-Location $dir + try { + git init -q 2>$null | Out-Null + git config user.email 'test@example.com' 2>$null | Out-Null + git config user.name 'test' 2>$null | Out-Null + } + finally { + Pop-Location + } + Set-Content -LiteralPath (Join-Path $dir '.gitignore') -Value $GitIgnore -NoNewline + foreach ($rel in $Files.Keys) { + $full = Join-Path $dir $rel + New-Item -ItemType Directory -Force -Path (Split-Path -Parent $full) | Out-Null + Set-Content -LiteralPath $full -Value $Files[$rel] -NoNewline + } + return $dir +} + +function Invoke-Validator { + param([string]$FixtureRoot) + $out = & pwsh -NoProfile -File $validator -RepoRoot $FixtureRoot 2>&1 + return [pscustomobject]@{ ExitCode = $LASTEXITCODE; Output = ($out -join "`n") } +} + +# Gitignore that covers all four machine-local config paths (mirrors the real repo). +$cleanGitIgnore = @" +.mcp.json +.cursor/mcp.json +.vscode/** +.codex/* +"@ + +$validMcpJson = '{ "mcpServers": { "unity-mcp-remote": { "type": "http", "url": "http://192.168.1.33:9003/mcp" } } }' +# Reference-free README so fixtures exercise Check 1/Check 2 without tripping the +# Check 3 doc-reference rule (the real-repo smoke test covers Check 3's happy path). +$readmeOk = "See the local MCP setup guide for configuration steps." +$readmeMissing = "Run ``scripts/mcp/install-claude-desktop-config.sh`` to set up." +$configureScript = "#!/usr/bin/env bash`necho configure" + +Write-Host 'Testing validate-mcp-config.ps1...' -ForegroundColor White + +if (-not (Test-Path -LiteralPath $validator)) { + Write-Host "Validator not found at $validator" -ForegroundColor Red + exit 1 +} + +# --- Test 1: clean fixture passes --- +$f1 = New-McpFixture -GitIgnore $cleanGitIgnore -Files @{ + '.mcp.json' = $validMcpJson + 'scripts/mcp/README.md' = $readmeOk + 'scripts/mcp/configure-unity-mcp-endpoint.sh' = $configureScript +} +try { + $r1 = Invoke-Validator -FixtureRoot $f1 + Write-Info $r1.Output + Write-TestResult 'Clean fixture passes (exit 0)' ($r1.ExitCode -eq 0) $r1.Output +} +finally { Remove-Item -Recurse -Force -LiteralPath $f1 -ErrorAction SilentlyContinue } + +# --- Test 2: untracked config -> UNH-MCP-TRACKED --- +$f2 = New-McpFixture -GitIgnore ".cursor/mcp.json`n.vscode/**`n.codex/*" -Files @{ + '.mcp.json' = $validMcpJson + 'scripts/mcp/README.md' = $readmeOk +} +try { + $r2 = Invoke-Validator -FixtureRoot $f2 + Write-TestResult 'Untracked .mcp.json -> UNH-MCP-TRACKED' (($r2.ExitCode -ne 0) -and ($r2.Output -match 'UNH-MCP-TRACKED')) $r2.Output +} +finally { Remove-Item -Recurse -Force -LiteralPath $f2 -ErrorAction SilentlyContinue } + +# --- Test 3: invalid URL -> UNH-MCP-INVALID --- +$f3 = New-McpFixture -GitIgnore $cleanGitIgnore -Files @{ + '.mcp.json' = '{ "mcpServers": { "unity-mcp-remote": { "type": "http", "url": "http://192.168.1.33:9003/wrong" } } }' + 'scripts/mcp/README.md' = $readmeOk +} +try { + $r3 = Invoke-Validator -FixtureRoot $f3 + Write-TestResult 'Bad URL -> UNH-MCP-INVALID' (($r3.ExitCode -ne 0) -and ($r3.Output -match 'UNH-MCP-INVALID')) $r3.Output +} +finally { Remove-Item -Recurse -Force -LiteralPath $f3 -ErrorAction SilentlyContinue } + +# --- Test 4: dangling doc reference -> UNH-MCP-MISSINGREF --- +$f4 = New-McpFixture -GitIgnore $cleanGitIgnore -Files @{ + '.mcp.json' = $validMcpJson + 'scripts/mcp/README.md' = $readmeMissing +} +try { + $r4 = Invoke-Validator -FixtureRoot $f4 + Write-TestResult 'Missing helper script -> UNH-MCP-MISSINGREF' (($r4.ExitCode -ne 0) -and ($r4.Output -match 'UNH-MCP-MISSINGREF')) $r4.Output +} +finally { Remove-Item -Recurse -Force -LiteralPath $f4 -ErrorAction SilentlyContinue } + +# --- Test 5: valid Codex TOML passes --- +$validToml = "[mcp_servers.unity_mcp_remote]`nurl = `"http://192.168.1.33:9003/mcp`"`nstartup_timeout_sec = 20`n" +$f5 = New-McpFixture -GitIgnore $cleanGitIgnore -Files @{ + '.mcp.json' = $validMcpJson + '.codex/config.toml' = $validToml + 'scripts/mcp/README.md' = $readmeOk +} +try { + $r5 = Invoke-Validator -FixtureRoot $f5 + Write-TestResult 'Valid Codex TOML passes (exit 0)' ($r5.ExitCode -eq 0) $r5.Output +} +finally { Remove-Item -Recurse -Force -LiteralPath $f5 -ErrorAction SilentlyContinue } + +# --- Test 6: bad Codex TOML url -> UNH-MCP-INVALID --- +$badToml = "[mcp_servers.unity_mcp_remote]`nurl = `"http://192.168.1.33:9003/wrong`"`n`n[other]`nurl = `"http://x/mcp`"`n" +$f6 = New-McpFixture -GitIgnore $cleanGitIgnore -Files @{ + '.mcp.json' = $validMcpJson + '.codex/config.toml' = $badToml + 'scripts/mcp/README.md' = $readmeOk +} +try { + $r6 = Invoke-Validator -FixtureRoot $f6 + Write-TestResult 'Bad Codex TOML url (with /mcp in another section) -> UNH-MCP-INVALID' (($r6.ExitCode -ne 0) -and ($r6.Output -match 'UNH-MCP-INVALID')) $r6.Output +} +finally { Remove-Item -Recurse -Force -LiteralPath $f6 -ErrorAction SilentlyContinue } + +# --- Test 7: regression smoke test against the real repo --- +$r7 = Invoke-Validator -FixtureRoot $repoRoot +Write-TestResult 'Real repository passes (exit 0)' ($r7.ExitCode -eq 0) $r7.Output + +Write-Host '' +Write-Host "Passed: $script:TestsPassed Failed: $script:TestsFailed" -ForegroundColor White +if ($script:TestsFailed -gt 0) { + Write-Host 'Failed tests:' -ForegroundColor Red + foreach ($t in $script:FailedTests) { Write-Host " - $t" -ForegroundColor Red } + exit 1 +} +Write-Host 'All validate-mcp-config tests passed.' -ForegroundColor Green diff --git a/scripts/tests/test-validate-mcp-config.ps1.meta b/scripts/tests/test-validate-mcp-config.ps1.meta new file mode 100644 index 000000000..bfa3dd811 --- /dev/null +++ b/scripts/tests/test-validate-mcp-config.ps1.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 3b00ed0771c4f882e7cf7fd99fe8f73f +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/scripts/validate-mcp-config.ps1 b/scripts/validate-mcp-config.ps1 new file mode 100644 index 000000000..3ff2ef76e --- /dev/null +++ b/scripts/validate-mcp-config.ps1 @@ -0,0 +1,162 @@ +Param( + [string]$RepoRoot, + [switch]$VerboseOutput +) + +<# +.SYNOPSIS + Validates the repository's machine-local Unity MCP client configuration. + +.DESCRIPTION + The Unity MCP bridge endpoint (host:port) is per-developer, so the generated + MCP client config files must never be committed, must be structurally valid, + and must target the `/mcp` streamable-HTTP path. This linter enforces three + invariants so the MCP setup copied from DxMessaging cannot silently rot: + + 1. UNH-MCP-TRACKED - every machine-local MCP client config path is matched by + .gitignore (it holds a per-developer host:port and must never be committed). + 2. UNH-MCP-INVALID - any config that IS present is structurally valid (JSON + configs are parsed; the Codex TOML block is regex-checked) and its + `unity-mcp-remote` server URL ends with `/mcp` (case-sensitive). + 3. UNH-MCP-MISSINGREF - every `scripts/mcp/*.sh|*.ps1` path referenced by the + MCP docs actually exists on disk (catches the dangling-reference class of + bug, e.g. a documented helper script that was never copied over). + + Keep the config list in sync with scripts/mcp/configure-unity-mcp-endpoint.sh + and docs/guides/mcp-local-setup.md. + +.PARAMETER VerboseOutput + Show detailed per-check output. + +.EXAMPLE + ./scripts/validate-mcp-config.ps1 + ./scripts/validate-mcp-config.ps1 -VerboseOutput +#> + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Write-Info($msg) { + if ($VerboseOutput) { Write-Host "[validate-mcp-config] $msg" -ForegroundColor Cyan } +} + +if (-not (Get-Command git -ErrorAction SilentlyContinue)) { + throw 'git is not available on PATH. validate-mcp-config requires git check-ignore.' +} + +if ([string]::IsNullOrWhiteSpace($RepoRoot)) { + $RepoRoot = Join-Path $PSScriptRoot '..' +} +$repoRoot = (Resolve-Path -LiteralPath $RepoRoot).Path +Push-Location $repoRoot +try { + $errors = New-Object System.Collections.Generic.List[string] + + # Machine-local MCP client config files written by + # scripts/mcp/configure-unity-mcp-endpoint.sh. host:port is per-developer, so + # all four MUST be gitignored. .vscode/** and .codex/* already cover two of + # them; .mcp.json and .cursor/mcp.json need explicit entries. + $localConfigs = @('.mcp.json', '.cursor/mcp.json', '.vscode/mcp.json', '.codex/config.toml') + + # ---- Check 1: every machine-local config path is gitignored ---- + Write-Info 'Check 1: machine-local MCP configs are gitignored...' + foreach ($cfg in $localConfigs) { + & git check-ignore --quiet -- $cfg 2>$null + $checkIgnoreExit = $LASTEXITCODE + if ($checkIgnoreExit -eq 0) { + Write-Info " gitignored OK: $cfg" + } + elseif ($checkIgnoreExit -eq 1) { + # Exit 1 = path is NOT ignored. (Exit 128 = git error, handled below.) + $errors.Add("::error file=.gitignore::UNH-MCP-TRACKED: '$cfg' is a machine-local MCP config (per-developer host:port) and MUST be gitignored. Add it to .gitignore.") + } + else { + throw "git check-ignore failed for '$cfg' (exit $checkIgnoreExit). Is '$repoRoot' a git repository?" + } + } + + # ---- Check 2: present configs are structurally valid and target /mcp ---- + Write-Info 'Check 2: present MCP configs are valid and target /mcp...' + $jsonConfigs = [ordered]@{ + '.mcp.json' = 'mcpServers' + '.cursor/mcp.json' = 'mcpServers' + '.vscode/mcp.json' = 'servers' + } + foreach ($path in $jsonConfigs.Keys) { + if (-not (Test-Path -LiteralPath $path)) { continue } + $json = $null + try { + $json = Get-Content -Raw -LiteralPath $path | ConvertFrom-Json -ErrorAction Stop + } + catch { + $errors.Add("::error file=$path::UNH-MCP-INVALID: not valid JSON ($($_.Exception.Message)).") + continue + } + $serverKey = $jsonConfigs[$path] + $url = $null + try { $url = $json.$serverKey.'unity-mcp-remote'.url } catch { $url = $null } + if ([string]::IsNullOrWhiteSpace($url)) { + $errors.Add("::error file=$path::UNH-MCP-INVALID: missing '$serverKey.unity-mcp-remote.url'.") + } + elseif ($url -cnotmatch '/mcp/?$') { + # Case-SENSITIVE: the server serves /mcp, not /MCP. + $errors.Add("::error file=$path::UNH-MCP-INVALID: unity-mcp-remote url '$url' should end with '/mcp'.") + } + else { + Write-Info " config OK: $path -> $url" + } + } + + if (Test-Path -LiteralPath '.codex/config.toml') { + $toml = Get-Content -Raw -LiteralPath '.codex/config.toml' + # Isolate the [mcp_servers.unity_mcp_remote] table (until the next table + # header or EOF) so a `/mcp` url in a DIFFERENT section can't satisfy the + # check, then drop comment lines so a commented-out url cannot pass either. + $tomlBlock = [regex]::Match($toml, '(?ms)^\s*\[mcp_servers\.unity_mcp_remote\]\s*(.*?)(?=^\s*\[|\z)') + if (-not $tomlBlock.Success) { + $errors.Add("::error file=.codex/config.toml::UNH-MCP-INVALID: missing [mcp_servers.unity_mcp_remote] block.") + } + else { + $tomlBody = (($tomlBlock.Groups[1].Value -split "`n") | Where-Object { $_ -notmatch '^\s*#' }) -join "`n" + if ($tomlBody -cnotmatch 'url\s*=\s*"[^"]*/mcp"') { + $errors.Add("::error file=.codex/config.toml::UNH-MCP-INVALID: unity_mcp_remote url must be set and end with '/mcp'.") + } + else { + Write-Info ' config OK: .codex/config.toml' + } + } + } + + # ---- Check 3: every scripts/mcp/* path referenced by the docs exists ---- + Write-Info 'Check 3: MCP doc script references resolve...' + $docFiles = @('scripts/mcp/README.md', 'docs/guides/mcp-local-setup.md') + $refRegex = [regex]'(? Date: Sun, 14 Jun 2026 01:48:09 +0000 Subject: [PATCH 02/31] ci(unity): add DxMessaging-style self-hosted Unity test + benchmark matrix - .github/unity-versions.json (2021.3.45f1 / 2022.3.45f1 / 6000.3.16f1) as the single version source; .github/actionlint.yaml declares the RAM-64GB runner label. - unity-tests.yml: matrix-config (secrets gate) -> runner-preflight (soft-pass) -> [self-hosted, Windows, RAM-64GB] matrix (version x editmode/playmode/standalone), max-parallel:1, org build-lock acquire/release, perf excluded (!Performance;!Stress). - unity-benchmarks.yml: dedicated perf job (Performance;Stress) + a full-N Random leg (UH_RANDOM_SAMPLE_COUNT) + auto-commit of raw results to top-level perf-results/. - 6 composite actions + scripts/unity/{run-ci-tests.ps1, ensure-editor.ps1, lib/asmdef-discovery.js}; stuck-job-watchdog / unstick-run / runner-bootstrap + the unity-runners-after-transfer runbook. - The matrix cleanly SKIPS until ORG_BUILD_LOCK_TOKEN + UNITY_SERIAL/EMAIL/PASSWORD exist. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/actionlint.yaml | 20 + .../compute-unity-assemblies/action.yml | 70 + .../actions/dump-unity-log-tail/action.yml | 181 + .../action.yml | 188 + .../actions/return-unity-license/action.yml | 123 + .../actions/validate-unity-license/action.yml | 62 + .../actions/verify-unity-results/action.yml | 495 ++ .github/unity-versions.json | 5 + .github/workflows/runner-bootstrap.yml | 403 ++ .github/workflows/stuck-job-watchdog.yml | 519 ++ .github/workflows/unity-benchmarks.yml | 479 ++ .github/workflows/unity-tests.yml | 434 ++ .github/workflows/unstick-run.yml | 330 ++ docs/runbooks.meta | 8 + docs/runbooks/unity-runners-after-transfer.md | 174 + .../unity-runners-after-transfer.md.meta | 7 + scripts/unity/ensure-editor.ps1 | 4584 +++++++++++++++++ scripts/unity/ensure-editor.ps1.meta | 7 + scripts/unity/lib.meta | 8 + scripts/unity/lib/asmdef-discovery.js | 420 ++ scripts/unity/lib/asmdef-discovery.js.meta | 7 + scripts/unity/run-ci-tests.ps1 | 2783 ++++++++++ scripts/unity/run-ci-tests.ps1.meta | 7 + 23 files changed, 11314 insertions(+) create mode 100644 .github/actionlint.yaml create mode 100644 .github/actions/compute-unity-assemblies/action.yml create mode 100644 .github/actions/dump-unity-log-tail/action.yml create mode 100644 .github/actions/print-self-hosted-runner-diagnostics/action.yml create mode 100644 .github/actions/return-unity-license/action.yml create mode 100644 .github/actions/validate-unity-license/action.yml create mode 100644 .github/actions/verify-unity-results/action.yml create mode 100644 .github/unity-versions.json create mode 100644 .github/workflows/runner-bootstrap.yml create mode 100644 .github/workflows/stuck-job-watchdog.yml create mode 100644 .github/workflows/unity-benchmarks.yml create mode 100644 .github/workflows/unity-tests.yml create mode 100644 .github/workflows/unstick-run.yml create mode 100644 docs/runbooks.meta create mode 100644 docs/runbooks/unity-runners-after-transfer.md create mode 100644 docs/runbooks/unity-runners-after-transfer.md.meta create mode 100644 scripts/unity/ensure-editor.ps1 create mode 100644 scripts/unity/ensure-editor.ps1.meta create mode 100644 scripts/unity/lib.meta create mode 100644 scripts/unity/lib/asmdef-discovery.js create mode 100644 scripts/unity/lib/asmdef-discovery.js.meta create mode 100644 scripts/unity/run-ci-tests.ps1 create mode 100644 scripts/unity/run-ci-tests.ps1.meta diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml new file mode 100644 index 000000000..36a127ca9 --- /dev/null +++ b/.github/actionlint.yaml @@ -0,0 +1,20 @@ +# actionlint configuration. +# Declares custom self-hosted runner labels that actionlint cannot infer +# from the workflow files. Without this, actionlint reports +# "label \"\" is unknown" for every job referencing them. +self-hosted-runner: + labels: + # Marker label applied to self-hosted Windows runners with 64GB+ RAM, + # used by the Unity workflows (unity-tests.yml, unity-benchmarks.yml, + # runner-bootstrap.yml). Add additional custom labels here as new runner + # pools are introduced. + - RAM-64GB + # Speed marker reserved on the org's runners for future opt-in (e.g., + # hotfix dispatch); no currently-active workflow requests it. actionlint + # still allows the label so a future workflow can opt in without churn. + - fast + # Legacy marker reserved here so any future inline + # `runs-on: [self-hosted, Linux, ..., old]` does not trip an actionlint + # false-positive. + - old +config-variables: null diff --git a/.github/actions/compute-unity-assemblies/action.yml b/.github/actions/compute-unity-assemblies/action.yml new file mode 100644 index 000000000..e88407370 --- /dev/null +++ b/.github/actions/compute-unity-assemblies/action.yml @@ -0,0 +1,70 @@ +name: Compute Unity test assembly list +description: >- + Resolve the default Unity test assembly include list via the single-source + asmdef-discovery.js module. Exports it as the UH_TEST_ASSEMBLIES env var AND + as step outputs (assemblies, is-empty) so a caller can SKIP a target that has + no unity-helpers-owned test assemblies instead of failing the whole run. +inputs: + include-perf: + description: "Include the Performance/Stress perf assemblies." + required: false + default: "false" + include-integrations: + description: "Include the DI-container integration suites (VContainer / Zenject / Reflex)." + required: false + default: "false" + runtime-only: + description: "Drop editor-only asmdefs (standalone player flow cannot run EditMode tests)." + required: false + default: "false" + target: + description: "Unity test target: editmode, playmode, or standalone." + required: false + default: "" +outputs: + assemblies: + description: "Semicolon-joined unity-helpers test assembly names (empty string when none match)." + value: ${{ steps.compute.outputs.assemblies }} + is-empty: + description: >- + 'true' when no unity-helpers-owned test assembly matched this target -- the + caller should skip the Unity run/verify steps. 'false' otherwise. + value: ${{ steps.compute.outputs.is-empty }} +runs: + using: composite + steps: + - name: Compute assembly list + id: compute + shell: pwsh + run: | + $parts = @() + if ("${{ inputs.include-perf }}" -eq "true") { $parts += "includePerf: true" } + if ("${{ inputs.include-integrations }}" -eq "true") { $parts += "includeIntegrations: true" } + if ("${{ inputs.runtime-only }}" -eq "true") { $parts += "runtimeOnly: true" } + if ("${{ inputs.target }}" -ne "") { $parts += "target: '${{ inputs.target }}'" } + $opts = if ($parts.Count) { "{ " + ($parts -join ", ") + " }" } else { "{}" } + $assemblies = node -e "const m=require('./scripts/unity/lib/asmdef-discovery.js'); console.log(m.defaultIncludeAssemblies(process.cwd(), $opts).join(';'))" + $discoveryExit = $LASTEXITCODE + # A non-zero exit means the discovery SCRIPT itself failed (bad repo + # checkout, syntax error, throw) -- a real error, not an empty list. Fail + # hard so it is never silently mistaken for "no assemblies". + if ($discoveryExit -ne 0) { + Write-Host "::error::asmdef discovery script failed (exit code $discoveryExit). The Unity test assembly list could not be resolved." + exit 1 + } + $assemblies = ([string]$assemblies).Trim() + if ([string]::IsNullOrWhiteSpace($assemblies)) { + # SKIP, do not fail: the discovery succeeded but no unity-helpers-owned + # test assembly matched this target (for example a runtime-only + # standalone run when every unity-helpers test asmdef is editor-only). + # The caller gates its provision/run/verify steps on is-empty so the + # target is skipped cleanly instead of failing with "0 tests ran". + Write-Host "::notice::No unity-helpers test assemblies matched this target; the Unity run will be skipped." + Add-Content -Path $env:GITHUB_OUTPUT -Value "assemblies=" -Encoding utf8 + Add-Content -Path $env:GITHUB_OUTPUT -Value "is-empty=true" -Encoding utf8 + exit 0 + } + Add-Content -Path $env:GITHUB_ENV -Value "UH_TEST_ASSEMBLIES=$assemblies" -Encoding utf8 + Add-Content -Path $env:GITHUB_OUTPUT -Value "assemblies=$assemblies" -Encoding utf8 + Add-Content -Path $env:GITHUB_OUTPUT -Value "is-empty=false" -Encoding utf8 + Write-Host "Resolved assemblies: $assemblies" diff --git a/.github/actions/dump-unity-log-tail/action.yml b/.github/actions/dump-unity-log-tail/action.yml new file mode 100644 index 000000000..cb4ca9936 --- /dev/null +++ b/.github/actions/dump-unity-log-tail/action.yml @@ -0,0 +1,181 @@ +name: Dump Unity log tail on failure or cancellation +description: >- + Surface the tail of /unity.log and rescan it for catastrophic + compile-time failure patterns when an upstream Unity step fails OR is + cancelled. Gives operators something concrete to look at when the + test-runner step timed out or was cancelled and verify-unity-results + (which gates on !cancelled()) would otherwise skip. +inputs: + results-dir: + description: "Directory the Unity runner wrote artifacts (and unity.log) into." + required: true + label: + description: "Human label used in the group annotation." + required: false + default: "Unity tests" + tail-lines: + description: "Number of trailing log lines to print." + required: false + default: "200" +runs: + using: composite + steps: + # IMPORTANT: this step is gated by the workflow caller's `if:` clause + # (which must include both `failure()` AND `cancelled()`); the inner + # guards here only protect against the log being absent (cancellation + # before Unity launched, or a different upstream failure that never + # wrote unity.log). This step itself NEVER fails -- it is purely a + # best-effort diagnostic. A throw here would mask the upstream failure + # the operator is actually trying to investigate. + - name: Dump Unity log tail and rescan for catastrophic patterns + shell: pwsh + run: | + $dir = "${{ inputs.results-dir }}" + $label = "${{ inputs.label }}" + $tailLines = [int]"${{ inputs.tail-lines }}" + if ($tailLines -lt 1) { $tailLines = 200 } + + function Add-UnityDiagnosticLogFile { + param( + [Parameter(Mandatory = $true)]$LogFiles, + [Parameter(Mandatory = $true)]$Seen, + [string]$Path + ) + + if (-not $Path -or -not (Test-Path -LiteralPath $Path -PathType Leaf)) { + return + } + + try { + $fullPath = (Resolve-Path -LiteralPath $Path -ErrorAction Stop).Path + } catch { + return + } + + if ($Seen.Add($fullPath)) { + $LogFiles.Add($fullPath) + } + } + + function Get-UnityDiagnosticLogFiles { + param([string]$ResultsDir) + + $logFiles = New-Object System.Collections.Generic.List[string] + $seen = New-Object 'System.Collections.Generic.HashSet[string]' ([StringComparer]::OrdinalIgnoreCase) + if (-not $ResultsDir) { + return @($logFiles) + } + + Add-UnityDiagnosticLogFile ` + -LogFiles $logFiles ` + -Seen $seen ` + -Path (Join-Path $ResultsDir 'unity.log') + + if (Test-Path -LiteralPath $ResultsDir -PathType Container) { + Get-ChildItem -LiteralPath $ResultsDir -File -Recurse -Filter '*.log' -ErrorAction SilentlyContinue | + Sort-Object FullName | + ForEach-Object { + Add-UnityDiagnosticLogFile ` + -LogFiles $logFiles ` + -Seen $seen ` + -Path $_.FullName + } + } + + return @($logFiles) + } + + $logFiles = @(Get-UnityDiagnosticLogFiles -ResultsDir $dir) + $log = if ($dir) { Join-Path $dir 'unity.log' } else { $null } + if (-not $log -or -not (Test-Path -LiteralPath $log -PathType Leaf)) { + # Cancellation before Unity launched leaves no unity.log; this is + # NOT an error condition for this diagnostic helper. + Write-Host "::notice::${label}: no unity.log under '$dir' to dump (Unity may not have started, or the run was cancelled before launch)." + } else { + Write-Host "::group::Unity log tail (last $tailLines lines) -- $label" + try { + Get-Content -LiteralPath $log -Tail $tailLines | ForEach-Object { + Write-Host $_ + } + } catch { + # Best-effort: a partially-written log (Unity killed mid-flush) + # may surface an IO error. Surface a notice + continue so the + # downstream catastrophic-pattern scan still runs. + Write-Host "::notice::Unable to read tail of '$log': $($_.Exception.Message)" + } + Write-Host "::endgroup::" + } + + # STANDALONE: the test player runs as a SEPARATE process and writes its own + # log (player.log), not unity.log. On a standalone failure the player log + # carries the actual test output and quit diagnostics, so tail it too when + # present (no-op for editmode/playmode, which write only unity.log). + $playerLog = if ($dir) { Join-Path $dir 'player.log' } else { $null } + if ($playerLog -and (Test-Path -LiteralPath $playerLog -PathType Leaf)) { + Write-Host "::group::Standalone player log tail (last $tailLines lines) -- $label" + try { + Get-Content -LiteralPath $playerLog -Tail $tailLines | ForEach-Object { + Write-Host $_ + } + } catch { + Write-Host "::notice::Unable to read tail of '$playerLog': $($_.Exception.Message)" + } + Write-Host "::endgroup::" + } + + if ($logFiles.Count -lt 1) { + return + } + + # Rerun the catastrophic-pattern scan over every available Unity + # diagnostic log, including retry logs saved as *.first-attempt.log. + # The pattern list MUST stay in sync with verify-unity-results/ + # action.yml's $patterns array and run-ci-tests.ps1's + # $script:CatastrophicPatterns -- all three call sites must use the + # same labels (kept in sync by convention; update all three together). + $patterns = @( + @{ Label = 'PrecompiledAssemblyException'; Pattern = 'PrecompiledAssemblyException'; UseSimple = $true } + @{ Label = 'CompilationFailedException'; Pattern = 'CompilationFailedException'; UseSimple = $true } + @{ Label = 'Multiple precompiled assemblies with the same name'; Pattern = 'Multiple precompiled assemblies with the same name'; UseSimple = $true } + @{ Label = 'error CS\d+'; Pattern = 'error CS\d+'; UseSimple = $false } + @{ Label = 'warning CS8032'; Pattern = 'warning CS8032'; UseSimple = $false } + ) + + $maxPerPattern = 5 + foreach ($entry in $patterns) { + $hits = New-Object System.Collections.Generic.List[object] + foreach ($logFile in $logFiles) { + try { + if ($entry.UseSimple) { + Select-String -LiteralPath $logFile -SimpleMatch -Pattern $entry.Pattern -ErrorAction SilentlyContinue | + ForEach-Object { + if ($hits.Count -lt $maxPerPattern) { + $hits.Add($_) + } + } + } else { + Select-String -LiteralPath $logFile -Pattern $entry.Pattern -ErrorAction SilentlyContinue | + ForEach-Object { + if ($hits.Count -lt $maxPerPattern) { + $hits.Add($_) + } + } + } + } catch { + # Never throw from a diagnostic helper. + } + if ($hits.Count -ge $maxPerPattern) { + break + } + } + + if ($hits.Count -gt 0) { + Write-Host "::group::Catastrophic pattern: $($entry.Label) -- $label" + foreach ($hit in $hits) { + $line = $hit.Line.Trim() + Write-Host "::error::Pattern detected -- $($entry.Label):: $line" + Write-Host " $($hit.Path):$($hit.LineNumber): $line" + } + Write-Host "::endgroup::" + } + } diff --git a/.github/actions/print-self-hosted-runner-diagnostics/action.yml b/.github/actions/print-self-hosted-runner-diagnostics/action.yml new file mode 100644 index 000000000..71bc35b23 --- /dev/null +++ b/.github/actions/print-self-hosted-runner-diagnostics/action.yml @@ -0,0 +1,188 @@ +name: Print self-hosted runner diagnostics +# cspell:ignore WSL prereqs +description: >- + Emit a self-contained diagnostic block for a self-hosted Windows Unity + runner. First runs a Windows PowerShell 5.1 preflight that fails fast with + a clear, actionable error when PowerShell 7 (pwsh) is missing, so the + cryptic "pwsh: command not found" never surfaces. Then emits diagnostics + via PowerShell so we never depend on the runner-host PATH ordering between + WSL bash and Git Bash. Surfaces the resolved bash path and warns when the + WSL stub at System32\bash.exe leaks ahead of + C:\Program Files\Git\bin\bash.exe so the operator can fix the agent + PATH before queueing further Unity work. + +inputs: + concurrency-note: + description: One-line note describing the job's concurrency contract. + required: false + default: "wallstop-organization-builds (central organization Unity lock)" + matrix-note: + description: One-line note describing matrix scheduling. + required: false + default: "" + requested-labels: + description: Human-readable runs-on label set for this job. + required: false + default: "self-hosted, Windows, RAM-64GB" + +runs: + using: "composite" + steps: + # Runs in Windows PowerShell 5.1 (always built into Windows runners), so + # it executes even when pwsh is absent. The remaining steps in this action + # and the Unity jobs that consume it use `shell: pwsh`; without this + # preflight a missing pwsh surfaces only as the cryptic + # "##[error]pwsh: command not found". This turns that into a clear, + # actionable failure pointing at the runbook. PowerShell 5.1-safe: no + # `??`, no ternary. + - name: Verify PowerShell 7 (pwsh) is available + shell: powershell + run: | + $pwshCommand = Get-Command pwsh -CommandType Application -ErrorAction SilentlyContinue + if ($null -eq $pwshCommand -or + [string]::IsNullOrWhiteSpace($pwshCommand.Source) -or + -not (Test-Path $pwshCommand.Source -PathType Leaf)) { + Write-Output ("::error title=pwsh missing on self-hosted runner::PowerShell 7 (pwsh) is not installed on runner '$env:RUNNER_NAME'. Install it before queueing Unity jobs -- see " + + "docs/runbooks/unity-runners-after-transfer.md (PowerShell 7 prerequisite).") + exit 1 + } + Write-Output "pwsh found at: $($pwshCommand.Source)" + - name: Emit runner diagnostics + shell: pwsh + env: + # Surfaced as inputs so the diagnostic line per workflow stays + # accurate ( # other three do). Empty strings render as "n/a". + UH_DIAG_CONCURRENCY_NOTE: ${{ inputs.concurrency-note }} + UH_DIAG_MATRIX_NOTE: ${{ inputs.matrix-note }} + UH_DIAG_REQUESTED_LABELS: ${{ inputs.requested-labels }} + run: | + $ErrorActionPreference = 'Continue' + function Default-Value { + param([string]$value, [string]$fallback = '') + if ([string]::IsNullOrEmpty($value)) { return $fallback } + return $value + } + Write-Output "::group::Runner diagnostics" + Write-Output ("Runner name: " + (Default-Value $env:RUNNER_NAME)) + Write-Output ("Runner OS: " + (Default-Value $env:RUNNER_OS)) + Write-Output ("Runner arch: " + (Default-Value $env:RUNNER_ARCH)) + Write-Output ("Runner workspace: " + (Default-Value $env:RUNNER_WORKSPACE)) + Write-Output ("Concurrency group: " + (Default-Value $env:UH_DIAG_CONCURRENCY_NOTE 'n/a')) + $matrixNote = $env:UH_DIAG_MATRIX_NOTE + if (-not [string]::IsNullOrEmpty($matrixNote)) { + Write-Output ("Matrix scheduling: " + $matrixNote) + } + Write-Output ("Requested labels: " + $env:UH_DIAG_REQUESTED_LABELS) + Write-Output ("GitHub workflow: " + (Default-Value $env:GITHUB_WORKFLOW)) + Write-Output ("GitHub job: " + (Default-Value $env:GITHUB_JOB)) + $runId = Default-Value $env:GITHUB_RUN_ID + $runAttempt = Default-Value $env:GITHUB_RUN_ATTEMPT + Write-Output ("GitHub run id/attempt: " + $runId + " / " + $runAttempt) + $githubRef = Default-Value $env:GITHUB_REF + $refProtected = Default-Value $env:GITHUB_REF_PROTECTED + Write-Output ("GitHub ref: " + $githubRef + " (protected=" + $refProtected + ")") + Write-Output ("GitHub event: " + (Default-Value $env:GITHUB_EVENT_NAME)) + Write-Output "" + Write-Output ("PowerShell version: " + $PSVersionTable.PSVersion.ToString()) + # PATH-walk that mirrors actions/runner WhichUtil. The runner agent + # resolves shell:bash by walking $env:Path entries in order and + # returning the first directory that contains bash.exe (no PATHEXT + # search, no PowerShell-specific aliases). PowerShell's Get-Command + # consults aliases, functions, and built-ins BEFORE PATH, so it can + # report a bash binding that the runner agent will never actually + # pick. Reproduce the agent's algorithm explicitly. We probe for + # bash.exe first (the standard Windows artifact), then bash without + # an extension as a fallback for unconventional installs. + $resolvedBash = $null + $pathEntries = ($env:Path -split ';') | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } + foreach ($candidateName in @('bash.exe', 'bash')) { + foreach ($pathEntry in $pathEntries) { + $candidatePath = Join-Path $pathEntry $candidateName + if (Test-Path $candidatePath -PathType Leaf) { + $resolvedBash = $candidatePath + break + } + } + if ($null -ne $resolvedBash) { break } + } + if ($null -ne $resolvedBash) { + Write-Output ("Resolved bash: " + $resolvedBash) + # The WSL stub sits at C:\Windows\System32\bash.exe and tries to + # launch a WSL distro; on Unity runners that distro is usually + # not installed and `shell: bash` steps fail with + # "Windows Subsystem for Linux has no installed distributions." + # When that stub wins the PATH resolution race, surface a + # ::warning:: so the operator fixes the runner-host PATH + # ordering (Git Bash must precede System32 in PATH). + if ($resolvedBash -match '\\System32\\bash(\.exe)?$' -or $resolvedBash -match '/System32/bash(\.exe)?$') { + Write-Output ("::warning::self-hosted runner PATH resolves bash to the WSL stub; ensure Git Bash precedes System32 in PATH") + } + } else { + Write-Output "Resolved bash: (not on PATH)" + } + $pathParts = ($env:Path -split ';') | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } + $head = $pathParts | Select-Object -First 10 + Write-Output "PATH (first 10 entries):" + foreach ($entry in $head) { + Write-Output (" - " + $entry) + } + Write-Output "::endgroup::" + - name: Configure Git compression tools for Actions cache + shell: pwsh + run: | + $ErrorActionPreference = 'Continue' + $candidateDirs = New-Object System.Collections.Generic.List[string] + foreach ($root in @($env:ProgramFiles, ${env:ProgramFiles(x86)}, $env:ProgramW6432)) { + if ([string]::IsNullOrWhiteSpace($root)) { + continue + } + $dir = Join-Path $root 'Git\usr\bin' + if (-not $candidateDirs.Contains($dir)) { + [void]$candidateDirs.Add($dir) + } + } + + $gitToolDir = $null + foreach ($dir in $candidateDirs) { + $gzipPath = Join-Path $dir 'gzip.exe' + $tarPath = Join-Path $dir 'tar.exe' + if ((Test-Path $gzipPath -PathType Leaf) -and (Test-Path $tarPath -PathType Leaf)) { + $gitToolDir = $dir + break + } + } + + if ($null -ne $gitToolDir) { + $env:Path = "$gitToolDir;$env:Path" + if (-not [string]::IsNullOrWhiteSpace($env:GITHUB_PATH)) { + Add-Content -Path $env:GITHUB_PATH -Value $gitToolDir -Encoding utf8 + } + Write-Output "::notice::Prepended Git usr/bin for Actions cache compression tools" + Write-Output ("Git tool dir: " + $gitToolDir) + Write-Output ("gzip: " + (Join-Path $gitToolDir 'gzip.exe')) + Write-Output ("tar: " + (Join-Path $gitToolDir 'tar.exe')) + } else { + Write-Output ("::warning::Git usr/bin with gzip.exe and tar.exe was not found; " + + "actions/cache may fail to restore/save compressed archives.") + Write-Output "Checked candidate Git usr/bin directories:" + foreach ($dir in $candidateDirs) { + Write-Output (" - " + $dir) + } + } + # TODO(unity-helpers): DxMessaging ran an `assert-unity-host-prereqs` + # composite here to detect missing Windows host prerequisites + # (the Visual C++ runtime, long-paths, Defender exclusions, pwsh, the + # Universal C Runtime) -- the class of failure that surfaces as Unity.exe + # STATUS_DLL_NOT_FOUND (0xC0000135). That composite plus its + # scripts/unity/bootstrap-windows-runner.ps1 / maintain-windows-runner.ps1 + # backends were NOT ported in this batch (out of scope). The diagnostic + # emission above (PATH walk, bash resolution, Git compression tools) still + # runs. To restore the host prerequisite assertion, port + # .github/actions/assert-unity-host-prereqs/ and its PowerShell backends + # from DxMessaging, then re-add the step: + # - name: Assert Unity host prerequisites + # uses: ./.github/actions/assert-unity-host-prereqs + # with: + # auto-install: "false" + # Until then this composite is detect/diagnose-only, which is a safe + # superset of "do nothing extra" -- it never fails the job on its own. diff --git a/.github/actions/return-unity-license/action.yml b/.github/actions/return-unity-license/action.yml new file mode 100644 index 000000000..d67167e45 --- /dev/null +++ b/.github/actions/return-unity-license/action.yml @@ -0,0 +1,123 @@ +name: Return Unity license +description: >- + Defense-in-depth: explicitly return the per-run Unity license seat via + `Unity.exe -returnlicense` (classic serial activation). Runs as an if:always() + backstop for a killed/crashed/timed-out editor that could not return its own + seat. NEVER fails the build (a missing editor path or credentials, or any error, is + non-fatal -- the next run's return-at-start is a further backstop). +inputs: + unity-editor-path: + description: "Path to the resolved Unity editor exe (exported to GITHUB_ENV by run-ci-tests.ps1). When empty there is nothing to return." + required: false +runs: + using: composite + steps: + - name: Return Unity license + shell: pwsh + run: | + Set-StrictMode -Version Latest + # Pin $PSNativeCommandUseErrorActionPreference = $false (mirrors the + # rationale in scripts/unity/run-ci-tests.ps1). PowerShell 7.4+ + # can ENABLE the native-error behavior so that `& ` THROWS on a + # non-zero exit. If that fired here, `& $editorPath @returnArgs` would + # throw BEFORE `$exitCode = $LASTEXITCODE` runs, jumping straight to the + # catch and making the best-effort return's exit-code-classified warning + # unreliable across hosts. Pinning it $false keeps the LASTEXITCODE-based + # classification authoritative and identical on every runner. (Pre-7.4 + # builds lack the variable; assigning it is harmless and StrictMode-safe.) + $PSNativeCommandUseErrorActionPreference = $false + + # Best-effort, never-fails seat return. Top-level try/catch + a final + # `exit 0` guarantee this step can never fail the build: a leaked seat is + # also reclaimed by the NEXT run's return-at-start even if every branch + # below is skipped. + try { + $editorPath = '${{ inputs.unity-editor-path }}' + if ([string]::IsNullOrWhiteSpace($editorPath)) { + $editorPath = $env:UNITY_EDITOR_PATH + } + + # Nothing to return when the editor path was never resolved (the Unity + # run never reached the point of exporting UNITY_EDITOR_PATH). + if ([string]::IsNullOrWhiteSpace($editorPath)) { + Write-Host "::notice::No Unity editor path resolved; nothing to return." + exit 0 + } + if (-not (Test-Path -LiteralPath $editorPath -PathType Leaf)) { + Write-Host "::notice::Resolved Unity editor path does not exist; nothing to return." + exit 0 + } + + # Serial activation returns by account, so both credentials are required. If + # either is empty we cannot return -- warn (never echo the values) and + # exit 0 (the next run's return-at-start is the backstop). + if ([string]::IsNullOrWhiteSpace($env:UNITY_EMAIL) -or [string]::IsNullOrWhiteSpace($env:UNITY_PASSWORD)) { + Write-Host "::warning::UNITY_EMAIL/UNITY_PASSWORD are not both set; cannot return the Unity license here. The next run's return-at-start is the backstop." + exit 0 + } + + # SECURITY: email/password ride in the argument array, and Unity may echo + # account fragments into its log, so write the return log to a NON-uploaded + # temp dir (RUNNER_TEMP, fallback the system temp path), NOT under the + # uploaded artifacts. Never echo the args/secrets. + $licenseLogDir = if ($env:RUNNER_TEMP) { $env:RUNNER_TEMP } else { [System.IO.Path]::GetTempPath() } + $returnLog = Join-Path $licenseLogDir 'unity-return-license.log' + + # SECURITY: do NOT echo this array (it carries the credentials). + $returnArgs = @( + '-quit', + '-batchmode', + '-nographics', + '-returnlicense', + '-username', $env:UNITY_EMAIL, + '-password', $env:UNITY_PASSWORD, + '-logFile', '-' + ) + + Write-Host "::group::Return Unity license" + # Unity.exe is a Windows GUI-subsystem binary: PowerShell's `&` does NOT + # wait for it or set $LASTEXITCODE unless its stdout is consumed via the + # pipeline. `-logFile -` puts the log on stdout and `| Tee-Object` forces + # the wait and sets $LASTEXITCODE. Tee-Object DOES persist the log to + # $returnLog, but that target lives under the NON-uploaded temp dir + # ($licenseLogDir above), so it stays out of any UPLOADED ARTIFACT and the + # account fragments Unity may print cannot leak into uploads. Same proven + # idiom as run-ci-tests.ps1. + & $editorPath @returnArgs 2>&1 | Tee-Object -FilePath $returnLog + $exitCode = $LASTEXITCODE + Write-Host "::endgroup::" + + if ($exitCode -ne 0) { + $returnedEntitlement = $false + $legacyFileUnavailable = $false + try { + if (Test-Path -LiteralPath $returnLog -PathType Leaf) { + $returnedEntitlement = Select-String ` + -LiteralPath $returnLog ` + -Pattern 'Successfully returned the entitlement license' ` + -SimpleMatch ` + -Quiet + $legacyFileUnavailable = Select-String ` + -LiteralPath $returnLog ` + -Pattern 'Serial number unavailable for ULF return' ` + -SimpleMatch ` + -Quiet + } + } catch { + $returnedEntitlement = $false + $legacyFileUnavailable = $false + } + + if ($returnedEntitlement -and $legacyFileUnavailable) { + Write-Host "::notice::Unity returned the entitlement license, then exited with code $exitCode while skipping legacy ULF return; treating the seat return as successful." + } else { + Write-Host "::warning::Unity license return exited with code $exitCode; the next run's return-at-start is the backstop for the leaked seat." + } + } else { + Write-Host "::notice::Returned the Unity license seat." + } + } catch { + Write-Host "::warning::Unity license return step hit an unexpected error: $($_.Exception.Message). The next run's return-at-start is the backstop." + } + # Defense-in-depth: this step must never fail the build. + exit 0 diff --git a/.github/actions/validate-unity-license/action.yml b/.github/actions/validate-unity-license/action.yml new file mode 100644 index 000000000..6f2bed281 --- /dev/null +++ b/.github/actions/validate-unity-license/action.yml @@ -0,0 +1,62 @@ +name: Validate Unity license +description: >- + Fail fast before acquiring the org build lock when classic serial Unity + activation is misconfigured: UNITY_SERIAL, UNITY_EMAIL, and UNITY_PASSWORD must + all be set, and the retired UNITY_LICENSING_SERVER must NOT be. Prints only + presence booleans, never secret values. The directory name + (validate-unity-license) is retained to avoid churn in callers/validators. +runs: + using: composite + steps: + - name: Validate Unity serial-activation preflight + shell: pwsh + run: | + Set-StrictMode -Version Latest + # Hygiene/parity pin (same rationale as scripts/unity/run-ci-tests.ps1 + # and the return action). This block no longer invokes a native binary + # through a pipe, so $PSNativeCommandUseErrorActionPreference is purely + # belt-and-suspenders here; pinning it $false keeps every pwsh block in + # these actions behaving identically regardless of host/version. StrictMode + # is the load-bearing part: it makes any accidental $null/undefined-property + # access throw loudly instead of silently passing the preflight. + $PSNativeCommandUseErrorActionPreference = $false + + # This action validates classic SERIAL activation (the repo uses classic + # serial activation, NOT the Unity Licensing Server / floating license). + # The directory name validate-unity-license is retained so callers do not + # churn. + $hasSerial = -not [string]::IsNullOrWhiteSpace($env:UNITY_SERIAL) + $hasEmail = -not [string]::IsNullOrWhiteSpace($env:UNITY_EMAIL) + $hasPassword = -not [string]::IsNullOrWhiteSpace($env:UNITY_PASSWORD) + + Write-Host "::group::Unity serial-activation preflight" + + # This repo uses classic serial activation, not the floating-license + # server. If UNITY_LICENSING_SERVER is still wired up, fail loudly so it + # gets removed (no silent fallback). + if (-not [string]::IsNullOrWhiteSpace($env:UNITY_LICENSING_SERVER)) { + Write-Host ("::error::UNITY_LICENSING_SERVER is set but unused. This repo uses classic serial activation (UNITY_SERIAL + UNITY_EMAIL + UNITY_PASSWORD); remove the " + + "UNITY_LICENSING_SERVER secret.") + Write-Host "::endgroup::" + exit 1 + } + + # All three serial-activation credentials are required. Name WHICH are + # missing (never their values) so the fix is obvious. + $missing = @() + if (-not $hasSerial) { $missing += 'UNITY_SERIAL' } + if (-not $hasEmail) { $missing += 'UNITY_EMAIL' } + if (-not $hasPassword) { $missing += 'UNITY_PASSWORD' } + if ($missing.Count -gt 0) { + Write-Host ("::error::Serial Unity activation requires UNITY_SERIAL, UNITY_EMAIL, and UNITY_PASSWORD. Missing or empty: " + ($missing -join ', ') + ".") + Write-Host "::endgroup::" + exit 1 + } + + # Presence booleans only -- never echo the values. + Write-Host "UNITY_SERIAL present: $hasSerial" + Write-Host "UNITY_EMAIL present: $hasEmail" + Write-Host "UNITY_PASSWORD present: $hasPassword" + + Write-Host "::notice::Unity serial-activation preflight passed." + Write-Host "::endgroup::" diff --git a/.github/actions/verify-unity-results/action.yml b/.github/actions/verify-unity-results/action.yml new file mode 100644 index 000000000..c08d7c1de --- /dev/null +++ b/.github/actions/verify-unity-results/action.yml @@ -0,0 +1,495 @@ +name: Verify Unity tests actually ran +# cspell:ignore ieq IPC +description: >- + Fail unless an NUnit results.xml under results-dir reports total > 0 and + failed = 0. When XML is absent, list provisioning diagnostics and scan the + Unity log for catastrophic compile-time failures. +inputs: + results-dir: + description: "Directory the Unity runner wrote the NUnit results XML into." + required: true + label: + description: "Human label used in log lines and annotations." + required: false + default: "Unity tests" + expected-empty: + description: >- + Set to 'true' by the caller when the unity-helpers test-assembly list + resolved EMPTY for this target and the Unity run was intentionally + skipped. The verifier then treats the absence of results as an expected + skip (notice + success) instead of failing with "tests did not run". + required: false + default: "false" +runs: + using: composite + steps: + # Skip on cancellation so this step does not race against the user's + # cancel and emit a misleading "tests did not run" error annotation + # when in fact the run was cancelled. Note: this is a STEP-level + # condition; the workflow-level "Verify tests actually ran" entry that + # invokes this composite should use the SAME idiom so the composite is + # not invoked at all on cancel, but the inner guard belt-and-suspenders + # protects against a future workflow forgetting the outer condition. + - name: Verify tests actually ran + if: ${{ !cancelled() }} + shell: pwsh + env: + UH_EXPECTED_EMPTY: ${{ inputs.expected-empty }} + run: | + $dir = "${{ inputs.results-dir }}" + $label = "${{ inputs.label }}" + # When the caller resolved an empty unity-helpers test-assembly list for + # this target it skips the Unity run entirely (see compute-unity-assemblies + # and unity-tests.yml). There are then no results to verify, so treat it + # as an explicit success instead of a "tests did not run" failure. Read + # the signal from the environment via an env: mapping above (NOT an inline + # workflow-expression expansion in this run script) so the spawn-based + # verifier tests, which only substitute results-dir/label, observe an + # unset var and exercise the normal verification path. + if ($env:UH_EXPECTED_EMPTY -eq 'true') { + Write-Host "::notice::No unity-helpers test assemblies were selected for $label; the Unity run was skipped, so there is nothing to verify." + exit 0 + } + function Write-ProvisioningDiagnosticsHint { + param([Parameter(Mandatory = $true)][string]$ResultsDir) + + $roots = New-Object System.Collections.Generic.List[string] + foreach ($candidate in @($ResultsDir, '.artifacts', 'artifact')) { + if ($candidate -and (Test-Path -LiteralPath $candidate)) { + $full = (Resolve-Path -LiteralPath $candidate).Path + if (-not $roots.Contains($full)) { + $roots.Add($full) + } + } + } + + $dirs = New-Object System.Collections.Generic.List[string] + foreach ($root in $roots) { + if ((Split-Path -Leaf $root) -ieq 'provisioning') { + if (-not $dirs.Contains($root)) { + $dirs.Add($root) + } + } + + $direct = Join-Path $root 'provisioning' + if ((Test-Path -LiteralPath $direct) -and -not $dirs.Contains($direct)) { + $dirs.Add((Resolve-Path -LiteralPath $direct).Path) + } + + Get-ChildItem -LiteralPath $root -Directory -Recurse -ErrorAction SilentlyContinue | + Where-Object { $_.Name -ieq 'provisioning' } | + ForEach-Object { + if (-not $dirs.Contains($_.FullName)) { + $dirs.Add($_.FullName) + } + } + } + + if ($dirs.Count -lt 1) { + Write-Host "::warning::No provisioning diagnostics directory found under $ResultsDir, .artifacts, or artifact." + return + } + + Write-Host "::group::Provisioning diagnostics files" + foreach ($diagnosticsDir in $dirs) { + Write-Host "Provisioning diagnostics directory: $diagnosticsDir" + $files = @( + Get-ChildItem -LiteralPath $diagnosticsDir -File -Recurse -ErrorAction SilentlyContinue | + Sort-Object FullName + ) + if ($files.Count -lt 1) { + Write-Host "- (empty)" + continue + } + + foreach ($file in $files) { + Write-Host "- $($file.FullName)" + } + + $summary = $files | + Where-Object { $_.Name -match '(?i)summary' } | + Select-Object -First 1 + if ($summary) { + Write-Host "::notice::Provisioning summary: $($summary.FullName)" + try { + $summaryJson = Get-Content -LiteralPath $summary.FullName -Raw | ConvertFrom-Json + $classification = if ($summaryJson.finalClassification) { + [string]$summaryJson.finalClassification + } else { + '(missing)' + } + $provisioningProfile = if ($summaryJson.provisioningProfile) { + [string]$summaryJson.provisioningProfile + } else { + '(missing)' + } + Write-Host "::notice::Provisioning summary classification=$classification profile=$provisioningProfile" + if ($env:GITHUB_STEP_SUMMARY) { + Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value ("- Provisioning summary: ``" + $summary.FullName + "``") + Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value ("- Provisioning classification: ``" + $classification + "`` (profile ``" + $provisioningProfile + "``)") + } + if ($classification -ne 'success' -and $classification -ne '(missing)') { + $missingModules = @() + if ($summaryJson.requiredModulePresence) { + foreach ($property in @($summaryJson.requiredModulePresence.PSObject.Properties)) { + if ($property.Value -eq $false) { + $missingModules += [string]$property.Name + } + } + } + $missingText = if ($missingModules.Count -gt 0) { + " missingModules=$($missingModules -join ',')" + } else { + "" + } + Write-Host "::error::Provisioning failed before Unity results verification: classification=$classification profile=$provisioningProfile summary=$($summary.FullName)$missingText" + } + } catch { + Write-Host "::notice::Provisioning summary metadata could not be parsed: $($_.Exception.Message)" + } + } + } + Write-Host "::endgroup::" + } + + function Add-UnityDiagnosticLogFile { + param( + [Parameter(Mandatory = $true)]$LogFiles, + [Parameter(Mandatory = $true)]$Seen, + [string]$Path + ) + + if (-not $Path -or -not (Test-Path -LiteralPath $Path -PathType Leaf)) { + return + } + + try { + $fullPath = (Resolve-Path -LiteralPath $Path -ErrorAction Stop).Path + } catch { + return + } + + if ($Seen.Add($fullPath)) { + $LogFiles.Add($fullPath) + } + } + + function Get-UnityDiagnosticLogFiles { + param([string]$ResultsDir) + + $logFiles = New-Object System.Collections.Generic.List[string] + $seen = New-Object 'System.Collections.Generic.HashSet[string]' ([StringComparer]::OrdinalIgnoreCase) + if (-not $ResultsDir) { + return @($logFiles) + } + + Add-UnityDiagnosticLogFile ` + -LogFiles $logFiles ` + -Seen $seen ` + -Path (Join-Path $ResultsDir 'unity.log') + + if (Test-Path -LiteralPath $ResultsDir -PathType Container) { + Get-ChildItem -LiteralPath $ResultsDir -File -Recurse -Filter '*.log' -ErrorAction SilentlyContinue | + Sort-Object FullName | + ForEach-Object { + Add-UnityDiagnosticLogFile ` + -LogFiles $logFiles ` + -Seen $seen ` + -Path $_.FullName + } + } + + return @($logFiles) + } + + # CLASS-OF-ISSUE DIAGNOSTIC: when results.xml is missing, the operator's + # next question is "WHY did Unity not produce results?". The most common + # silent-killer answers are catastrophic compile-time errors -- the editor + # exits before running tests at all, leaving no NUnit XML. Surface these + # patterns BEFORE the generic "no results.xml" message so the operator + # sees the actual root cause in the GitHub error summary. + # + # Patterns covered: + # - PrecompiledAssemblyException -- "Multiple precompiled assemblies + # with the same name" (the analyzer-DLL duplicate that motivated this + # diagnostic; the runtime auto-copy that caused it has been removed). + # - CompilationFailedException -- generic compile-failure path. + # - error CS\d+ -- compiler errors (CS0246, CS0103, CS0117, etc). + # - warning CS8032 -- "An instance of analyzer cannot be created" + # (analyzer failed to instantiate; same class of issue). + function Write-UnityCatastrophicPatternHits { + param([Parameter(Mandatory = $true)][string]$ResultsDir) + + $patterns = @( + @{ Label = 'PrecompiledAssemblyException'; Pattern = 'PrecompiledAssemblyException'; UseSimple = $true } + @{ Label = 'CompilationFailedException'; Pattern = 'CompilationFailedException'; UseSimple = $true } + @{ Label = 'Multiple precompiled assemblies with the same name'; Pattern = 'Multiple precompiled assemblies with the same name'; UseSimple = $true } + @{ Label = 'error CS\d+'; Pattern = 'error CS\d+'; UseSimple = $false } + @{ Label = 'warning CS8032'; Pattern = 'warning CS8032'; UseSimple = $false } + ) + + $logFiles = @(Get-UnityDiagnosticLogFiles -ResultsDir $ResultsDir) + + if ($logFiles.Count -lt 1) { + return + } + + $maxPerPattern = 5 + foreach ($entry in $patterns) { + $hits = New-Object System.Collections.Generic.List[object] + foreach ($logFile in $logFiles) { + try { + if ($entry.UseSimple) { + Select-String -LiteralPath $logFile -SimpleMatch -Pattern $entry.Pattern -ErrorAction SilentlyContinue | + ForEach-Object { + if ($hits.Count -lt $maxPerPattern) { + $hits.Add($_) + } + } + } else { + Select-String -LiteralPath $logFile -Pattern $entry.Pattern -ErrorAction SilentlyContinue | + ForEach-Object { + if ($hits.Count -lt $maxPerPattern) { + $hits.Add($_) + } + } + } + } catch { + # Best-effort; never throw from the diagnostic helper. + } + if ($hits.Count -ge $maxPerPattern) { + break + } + } + + if ($hits.Count -gt 0) { + Write-Host "::group::Catastrophic pattern: $($entry.Label)" + foreach ($hit in $hits) { + $line = $hit.Line.Trim() + Write-Host "::error::Pattern detected -- $($entry.Label):: $line" + Write-Host " $($hit.Path):$($hit.LineNumber): $line" + } + Write-Host "::endgroup::" + } + } + } + + # DIAGNOSTIC: when results.xml reports failures, name WHICH tests failed + # and print their assertion messages. The aggregate "failed=N" count is + # not actionable on its own (a real 2021.3 PlayMode run failed 1 of 697 + # tests and the logs never named it). Emits a single-line ::error:: + # annotation per failed test plus a ::group:: console block with the full + # multi-line message + stack trace. Two classes are enumerated: + # (1) failed leaf cases: //test-case[@result='Failed'], and + # (2) failed suites with their OWN direct child (the + # OneTimeSetUp/OneTimeTearDown shape, e.g. a [OneTimeTearDown] + # Assert.Fail), which manifest as a failed SUITE. Reported even when + # the suite also has a failed child case, so the teardown's own + # message is not lost; fullname de-dup keeps it distinct from the + # child cases. An aggregate-only suite (no direct ) is + # skipped. + # De-duplicated by fullname, capped at the first 50 (with a truncation + # notice), and best-effort (never throws -- a diagnostic must not mask + # the real failure below). Attribute reads use XmlElement.GetAttribute + # (returns '' when absent, never throws) so behavior stays synchronized + # with run-ci-tests.ps1 even though this script does not set StrictMode. + function ConvertTo-SingleLineDiagnostic { + param([string]$Text) + if (-not $Text) { + return '' + } + return (($Text -replace '\s+', ' ').Trim()) + } + function Write-UnityPackageManagerFailureHints { + param([Parameter(Mandatory = $true)][string]$ResultsDir) + + $patterns = @( + 'Cancelled resolving packages', + 'Failed to resolve packages:\s+operation cancelled', + 'IPCStream \(Upm-[^)]+\): IPC stream failed to read' + ) + $logFiles = @(Get-UnityDiagnosticLogFiles -ResultsDir $ResultsDir) + + foreach ($logFile in $logFiles) { + try { + $hit = Select-String -LiteralPath $logFile -Pattern $patterns -ErrorAction SilentlyContinue | + Select-Object -First 1 + if ($hit) { + $line = ConvertTo-SingleLineDiagnostic -Text $hit.Line + Write-Host "::error::Unity Package Manager canceled package resolution before tests started: $line" + Write-Host " $($hit.Path):$($hit.LineNumber): $line" + return + } + } catch { + # Best-effort diagnostic only. + } + } + } + # Token holder for the ::stop-commands:: ... :::: fence that + # wraps caller-controlled raw multi-line dumps (NUnit + # /). GitHub parses every stdout line for + # `::command::` directives; fencing the raw body disables that processing + # so an assertion message containing a line like `::error file=...::` or + # `::set-output name=x::` cannot inject a spurious workflow command. The + # token is NOT a fixed literal: a crafted message containing the exact + # `::::` close line could otherwise end the fence early and + # re-enable injection. A FRESH random GUID token is generated per dump + # via New-WorkflowCommandStopToken (mirroring GitHub's own @actions/core + # random per-invocation delimiter) and reused for the open and close. + # Kept in sync with run-ci-tests.ps1's matching fence scheme. + function New-WorkflowCommandStopToken { + return ('uh-stop-commands-{0}' -f [guid]::NewGuid().ToString('N')) + } + # Resolve a node's display name via XmlElement.GetAttribute (returns '' + # for an ABSENT attribute, never throws) -- kept synchronized with + # run-ci-tests.ps1's StrictMode-safe Get-NUnitNodeFullName. Prefers + # fullname, then name, then a '(unnamed test)' fallback. + function Get-NUnitNodeFullName { + param([Parameter(Mandatory = $true)]$Node) + $fullName = $Node.GetAttribute('fullname') + if (-not $fullName) { + $fullName = $Node.GetAttribute('name') + } + if (-not $fullName) { + $fullName = '(unnamed test)' + } + return $fullName + } + function Write-FailedTestAnnotations { + param( + [Parameter(Mandatory = $true)]$Doc, + [Parameter(Mandatory = $true)][string]$Label, + [int]$MaxFailures = 50 + ) + + try { + $failedCases = @($Doc.SelectNodes("//test-case[@result='Failed']")) + $failedSuites = @($Doc.SelectNodes("//test-suite[@result='Failed']")) + # Report a failed suite on its OWN merits whenever it carries a + # direct child (even if it also has a failed descendant + # case) so a [OneTimeTearDown] message is not lost. fullname de-dup + # keeps it distinct from its child cases; an aggregate-only suite + # (no direct ) is skipped. + $ownFailureSuites = @( + foreach ($suite in $failedSuites) { + $directFailure = $suite.SelectSingleNode('failure') + if ($directFailure) { + $suite + } + } + ) + $failedNodes = @($failedCases) + @($ownFailureSuites) + if ($failedNodes.Count -lt 1) { + return + } + + $seen = New-Object 'System.Collections.Generic.HashSet[string]' + $uniqueNodes = New-Object 'System.Collections.Generic.List[object]' + foreach ($node in $failedNodes) { + $fullName = Get-NUnitNodeFullName -Node $node + if ($seen.Add($fullName)) { + $uniqueNodes.Add($node) + } + } + + $totalFailed = $uniqueNodes.Count + $shown = @($uniqueNodes | Select-Object -First $MaxFailures) + foreach ($node in $shown) { + $fullName = Get-NUnitNodeFullName -Node $node + $failureNode = $node.SelectSingleNode('failure') + $message = '' + $stackTrace = '' + if ($failureNode) { + $messageNode = $failureNode.SelectSingleNode('message') + if ($messageNode) { + $message = $messageNode.InnerText + } + $stackNode = $failureNode.SelectSingleNode('stack-trace') + if ($stackNode) { + $stackTrace = $stackNode.InnerText + } + } + $firstLine = ConvertTo-SingleLineDiagnostic -Text $message + # The single-line ::error:: annotation stays OUTSIDE the fence so + # GitHub still processes it; it is already flattened to one line. + Write-Host "::error::${Label} failed test: $fullName -- $firstLine" + Write-Host "::group::Failed test: $fullName" + # SECURITY: the raw NUnit / are + # caller-controlled; GitHub parses every stdout line for + # `::command::` directives. Fence the raw multi-line dump with + # ::stop-commands:: ... :::: so an injected line like + # `::set-output name=x::` is neutralized. The token is a FRESH + # random GUID per dump (never a fixed literal) so a crafted message + # containing the exact `::::` close line cannot end the + # fence early. The ::group::/::endgroup:: markers stay OUTSIDE the + # fence so they are still processed. + $stopToken = New-WorkflowCommandStopToken + Write-Host "::stop-commands::$stopToken" + if ($message) { + Write-Host "Message:" + Write-Host $message + } else { + Write-Host "Message: (none recorded)" + } + if ($stackTrace) { + Write-Host "Stack trace:" + Write-Host $stackTrace + } + Write-Host "::$stopToken::" + Write-Host "::endgroup::" + } + + if ($totalFailed -gt $shown.Count) { + $omitted = $totalFailed - $shown.Count + Write-Host "::notice::${Label}: $omitted additional failed test(s) not shown (showing first $($shown.Count) of $totalFailed)." + } + } catch { + Write-Host "::warning::Could not enumerate failed tests for ${Label}: $($_.Exception.Message)" + } + } + + # ALWAYS scan the Unity log for catastrophic patterns first, regardless + # of which exit branch fires below. The patterns (PrecompiledAssemblyException, + # CompilationFailedException, error CS####, warning CS8032) are the + # most common silent-killer causes of every exit branch -- missing dir + # AND missing XML AND total==0 AND failed>0 can all coincide with a + # partial Unity crash. The helper gracefully skips when no log file + # exists, so this is cheap on the happy path. Run it at the TOP so + # the operator sees the actual root cause first in the GitHub error + # summary, not buried under a generic "tests did not run" line. + Write-UnityCatastrophicPatternHits -ResultsDir $dir + if (-not (Test-Path $dir)) { + Write-ProvisioningDiagnosticsHint -ResultsDir $dir + Write-Host "::error::No artifacts directory ($dir) -- Unity Test Runner did not produce results for $label" + exit 1 + } + $xml = Get-ChildItem -Path $dir -Recurse -Filter *.xml -ErrorAction SilentlyContinue | + Where-Object { Select-String -Path $_.FullName -Pattern ' element) under $dir -- tests did not run for $label" + exit 1 + } + [xml]$doc = Get-Content -LiteralPath $xml.FullName + $run = $doc.SelectSingleNode('//test-run') + $total = [int]($run.total) + $passed = [int]($run.passed) + $failed = [int]($run.failed) + $skipped = [int]($run.skipped) + Write-Host "Results: total=$total passed=$passed failed=$failed skipped=$skipped ($($xml.Name))" + if ($total -lt 1) { + Write-Host "::error::0 tests ran for $label -- check the assembly list / filter" + exit 1 + } + if ($failed -gt 0) { + Write-FailedTestAnnotations -Doc $doc -Label $label + Write-Host "::error::$label reported $failed failed test(s); see $($xml.FullName)" + exit 1 + } + Write-Host "::notice::${label}: total=$total passed=$passed failed=$failed skipped=$skipped" diff --git a/.github/unity-versions.json b/.github/unity-versions.json new file mode 100644 index 000000000..d5a93f256 --- /dev/null +++ b/.github/unity-versions.json @@ -0,0 +1,5 @@ +{ + "_comment": "Canonical Unity version source of truth for all CI. The newest entry of `all` (last element) is the 'latest' tracked by unity-benchmarks. `release` is the version release/publish flows pin. Bump versions HERE so every consumer stays in sync.", + "all": ["2021.3.45f1", "2022.3.45f1", "6000.3.16f1"], + "release": "2022.3.45f1" +} diff --git a/.github/workflows/runner-bootstrap.yml b/.github/workflows/runner-bootstrap.yml new file mode 100644 index 000000000..cf8d64100 --- /dev/null +++ b/.github/workflows/runner-bootstrap.yml @@ -0,0 +1,403 @@ +name: Runner Bootstrap (Windows) + +# cspell:ignore Redistributables redist redists MSVCP MSVCR VCRUNTIME UCRT prereqs + +# Operator-driven, workflow_dispatch-only bootstrap of a self-hosted Windows +# runner's host-OS prerequisites for Unity CI. Installs the Microsoft Visual +# C++ 2010 SP1 + 2015-2022 x64 Redistributables (BOTH generations -- Unity +# 2021/2022/6000 depend on each: MSVCP100.dll/MSVCR100.dll come from the +# 2010 redist, VCRUNTIME140.dll/MSVCP140.dll/VCRUNTIME140_1.dll from the +# 2015-2022 redist), Windows long-paths, Windows Defender exclusions, +# PowerShell 7, and (where applicable) the UCRT KB. +# +# Root cause this workflow addresses: Unity.exe failed at startup with +# -1073741515 / 0xC0000135 (STATUS_DLL_NOT_FOUND) on DAD-MACHINE because the +# VC++ Redistributables were missing -- Unity 2021/2022/6000 depend on BOTH +# the 2010 SP1 (MSVCP100.dll / MSVCR100.dll) AND the 2015-2022 x64 (MSVCP140 + +# VCRUNTIME140) generations. GitHub-hosted windows-2022 ships both preinstalled; +# self-hosted runners do not. The fix is OS-level (install the redists), so +# ensure-editor.ps1's Unity-reinstall retry loop is futile. This workflow +# gives operators a one-click recovery path that does not require shelling +# onto the runner host. +# +# Targeting a SPECIFIC runner (HARD-FAIL on wrong target): +# * The repo's runner topology currently labels both Windows runners +# uniformly (`self-hosted, Windows, RAM-64GB`); only ELI-MACHINE carries +# the additional `fast` label (the org runner topology). +# * To bootstrap one specific machine without affecting the other, take +# the OTHER runner offline (gh CLI or Actions UI -> Runners) BEFORE +# dispatching this workflow, then bring it back online afterwards. +# * If the dispatch lands on the wrong runner the workflow HARD-FAILS with +# `::error::` and exit 1 (NOT a silent ::warning::). This is by design: +# a silent bootstrap of the WRONG (healthy) machine is operator-fatal. +# * Long-term fix: add machine-name labels to runner agents so the +# `runs-on:` set can include the runner name itself. +# +# Idempotent: the bootstrap script is safe to re-run on a healthy host +# (every prereq is detected first and remediated only when missing). A +# repeat dispatch on the same machine is a no-op. +# +# SHELL CHOICE (chicken-and-egg): self-hosted-runner steps use +# `shell: powershell` (Windows PowerShell 5.1, ALWAYS preinstalled on +# Windows) rather than `shell: pwsh` (PowerShell 7). This workflow's +# purpose INCLUDES installing pwsh on runners that lack it -- so it cannot +# require pwsh in order to run. The bootstrap script declares +# `#Requires -Version 5.1` and is verified PS 5.1-compatible. The +# ubuntu-latest preflight job uses `shell: bash` as normal. +# +# MODE RESOLUTION PRECEDENCE (highest wins): +# 1. UH_RUNNER_DISABLE_AUTO_BOOTSTRAP=1 -> forces detect-only (composite) +# 2. inputs.detect-only == true -> detect-only via the composite +# 3. (composite default) -> auto-install +# Operators flipping the dispatch toggle should know that the composite +# input is the workflow-author surface; the env var is the operator override. + +on: + workflow_dispatch: + inputs: + runner-label: + description: >- + (HARD-FAIL on wrong target) Name of the runner you want to bootstrap + (DAD-MACHINE or ELI-MACHINE). Both runners share the same label set, + so the scheduler may land on either machine. If it lands on the + WRONG one, this workflow FAILS FAST with exit 1 -- it does NOT + silently bootstrap the unintended machine. Take the unwanted runner + offline (Settings -> Runners) BEFORE dispatching to guarantee + targeting. + required: true + type: choice + options: + - DAD-MACHINE + - ELI-MACHINE + detect-only: + description: >- + When true, run the bootstrap script with -DetectOnly (no installs); + the workflow fails with exit 2 if any prereq is missing. Useful for + a pure preflight audit without mutating the host. Accepts the + natural truthy strings (true/True/1/yes/y); normalized server-side. + required: false + type: boolean + default: false + +# Forbid concurrent bootstraps OF THIS REPO across BOTH runners. The group +# is intentionally NOT parameterized by `inputs.runner-label` so that two +# dispatches (one per machine) cannot race on installer locks or quarantine +# the same Windows feature/UCRT machine-wide state simultaneously. This is +# stricter than per-runner concurrency: any in-flight bootstrap anywhere +# blocks any other bootstrap dispatch from starting. +# Distinct from `wallstop-organization-builds` (the Unity build lock) -- this +# workflow does not enter the Unity license seat path, so it must not block +# Unity jobs and Unity jobs must not block it. +concurrency: + group: runner-bootstrap-windows + cancel-in-progress: false + +permissions: + contents: read + actions: read + +jobs: + # Preflight convention: every job whose runs-on declares `self-hosted` + # must `needs:` a runner-access preflight (see + # docs/runbooks/unity-runners-after-transfer.md). This mirrors the + # unity-tests.yml runner-preflight pattern exactly. + runner-preflight: + name: Self-hosted runner access preflight + runs-on: ubuntu-latest + timeout-minutes: 3 + permissions: + actions: read + # NOTE: `administration: read` is NOT a valid permissions key for + # workflow GITHUB_TOKEN (per actionlint and the GitHub docs schema). + # Listing self-hosted runners requires admin:org (org-scope) OR a + # fine-grained PAT with repo "Administration: read" -- neither of + # which the default GITHUB_TOKEN can carry. The soft-pass path in + # the run script below is the supported coverage when the token + # is unscoped. See docs/runbooks/unity-runners-after-transfer.md. + concurrency: + group: ${{ github.workflow }}-runner-preflight-${{ github.ref }} + cancel-in-progress: true + steps: + - name: Probe self-hosted runner availability + shell: bash + env: + GH_TOKEN: ${{ secrets.RUNNER_AUDIT_PAT || secrets.GITHUB_TOKEN }} + REQUIRED_LABELS: "self-hosted,Windows,RAM-64GB" + REQUIRED_RUNNER_NAME: ${{ inputs.runner-label }} + # gh api --paginate | jq -s pattern mirrors stuck-job-watchdog.yml and + # unity-tests.yml. CRITICAL: this preflight must NEVER make CI more broken + # than the no-preflight baseline -- if both runner-inventory endpoints + # return 403/404, soft-pass with a ::warning::. When the inventory IS + # visible AND the named runner is offline, hard-fail (operator action + # required before bootstrap can dispatch). + run: | + set -euo pipefail + echo "::group::Runner-access preflight" + echo "Required labels: ${REQUIRED_LABELS}" + echo "Required runner name: ${REQUIRED_RUNNER_NAME}" + IFS=',' read -r -a required <<< "${REQUIRED_LABELS}" + runners_json="$(mktemp)" + runners_scope="" + if gh api --paginate \ + "orgs/${GITHUB_REPOSITORY_OWNER}/actions/runners?per_page=100" \ + 2>/dev/null \ + | jq -s '[.[] | (.runners // [])[]]' > "${runners_json}" 2>/dev/null; then + runners_scope="org" + else + if gh api --paginate \ + "repos/${GITHUB_REPOSITORY}/actions/runners?per_page=100" \ + 2>/dev/null \ + | jq -s '[.[] | (.runners // [])[]]' > "${runners_json}" 2>/dev/null; then + runners_scope="repo" + fi + fi + if [ -z "${runners_scope}" ]; then + org_url="orgs/${GITHUB_REPOSITORY_OWNER}/actions/runners" + repo_url="repos/${GITHUB_REPOSITORY}/actions/runners" + { + echo "::warning::Runner inventory unavailable from both ${org_url}" + echo "::warning::and ${repo_url} (likely 403: GITHUB_TOKEN lacks admin:org" + echo "::warning::or administration:read)." + echo "::warning::See docs/runbooks/unity-runners-after-transfer.md" + echo "::warning::for how to plumb a RUNNER_AUDIT_PAT secret to upgrade" + echo "::warning::this soft-pass to a hard-pass." + } + echo "Soft pass: skipping runner inventory check." + echo "::endgroup::" + exit 0 + fi + echo "Runner inventory scope: ${runners_scope}" + total="$(jq 'length' < "${runners_json}")" + echo "Visible runners (${runners_scope} scope): ${total}" + matched="$(jq --argjson labels "$(printf '%s\n' "${required[@]}" | jq -R . | jq -s .)" ' + [ .[] + | select(.status == "online") + | select(($labels | all(. as $l | (.labels // []) | map(.name) | index($l) | type == "number"))) + ] | length + ' < "${runners_json}")" + echo "Runners satisfying ${REQUIRED_LABELS}: ${matched}" + if [ "${matched}" -lt 1 ]; then + { + echo "::error::No online self-hosted runner satisfies the required labels (${REQUIRED_LABELS})." + echo "Bootstrap cannot dispatch. Bring the target runner online first." + echo "See docs/runbooks/unity-runners-after-transfer.md for the post-transfer runner-group ACL fix." + } + jq '[ .[] | {name, status, busy, labels: [(.labels // [])[].name]} ]' \ + < "${runners_json}" + exit 1 + fi + # F12: the label match above is necessary but not sufficient when the + # operator named a SPECIFIC runner. Require that the named runner is + # also online (otherwise the dispatch would land on the other + # machine, which the bootstrap job's "Confirm runner identity" step + # then HARD-FAILS on -- catching it here gives a clearer error + # before consuming a self-hosted slot). + named_online="$(jq --arg name "${REQUIRED_RUNNER_NAME}" ' + [ .[] | select(.name == $name) | select(.status == "online") ] | length + ' < "${runners_json}")" + echo "Named runner online (name='${REQUIRED_RUNNER_NAME}'): ${named_online}" + if [ "${named_online}" -lt 1 ]; then + { + echo "::error::Requested runner '${REQUIRED_RUNNER_NAME}' is not currently online." + echo "Bring it online via Settings -> Runners (or the runner-host service) before dispatching." + echo "See docs/runbooks/unity-runners-after-transfer.md." + } + jq --arg name "${REQUIRED_RUNNER_NAME}" ' + [ .[] | select(.name == $name) | {name, status, busy, labels: [(.labels // [])[].name]} ] + ' < "${runners_json}" + exit 1 + fi + echo "::endgroup::" + + bootstrap: + # F9: surface the mode (Audit vs Maintenance) in the job display name so + # operators reading the Actions UI immediately see which mode this run is + # without expanding the inputs. + name: ${{ inputs.detect-only && 'Audit' || 'Maintain' }} ${{ inputs.runner-label }} + needs: + - runner-preflight + # Cannot dynamically pin to a specific runner without machine-name labels + # configured on the runners themselves (operator action; not yet done in + # this org). Use the documented allowlisted set; operator takes the + # unwanted runner offline before dispatching when targeting is needed. + # The "Confirm runner identity" step below HARD-FAILS if the dispatch + # lands on the wrong machine (see header comment for the chicken-and-egg + # rationale around `shell: powershell` for the rest of this job). + runs-on: [self-hosted, Windows, RAM-64GB] + # F13: inherit workflow-level permissions (contents: read, actions: read). + # No job-level override -- the previous explicit block silently dropped + # `actions: read` and shadowed the workflow-level intent. + timeout-minutes: 360 + steps: + - name: Enable Git long paths + shell: powershell + env: + GIT_CONFIG_GLOBAL: ${{ runner.temp }}/uh-gitconfig + run: git config --global core.longpaths true + + - name: Checkout + # Shallow checkout: bootstrap only needs scripts/unity/* and a + # composite action; no history depth required. + uses: actions/checkout@v6 + env: + GIT_CONFIG_GLOBAL: ${{ runner.temp }}/uh-gitconfig + with: + persist-credentials: false + + - name: Confirm runner identity (hard-fail on wrong target) + # F15: Windows PowerShell 5.1, NOT pwsh -- this workflow's purpose + # includes installing pwsh, so requiring pwsh here would be the + # chicken-and-egg failure mode. + shell: powershell + env: + UH_BOOTSTRAP_REQUESTED_RUNNER: ${{ inputs.runner-label }} + run: | + Set-StrictMode -Version Latest + $ErrorActionPreference = 'Stop' + $actual = $env:RUNNER_NAME + $requested = $env:UH_BOOTSTRAP_REQUESTED_RUNNER + Write-Host "Requested runner: $requested" + Write-Host "Actual runner: $actual" + # F2: HARD-FAIL on mismatch. The previous ::warning:: would silently + # bootstrap the wrong (healthy) machine when the scheduler picked + # the runner the operator did NOT intend. + if (-not [string]::IsNullOrWhiteSpace($actual) -and -not [string]::IsNullOrWhiteSpace($requested)) { + if (-not [string]::Equals($actual, $requested, [System.StringComparison]::OrdinalIgnoreCase)) { + Write-Host ("::error::Bootstrap dispatched to '$actual' but operator requested '$requested'. Cancel this run, take '$actual' offline (Settings -> Runners or 'gh api -X POST " + + "orgs//actions/runners//...'), then re-dispatch so the run lands on '$requested'. Alternatively, add machine-name labels to runner agents so 'runs-on:' can pin to a " + + "specific runner.") + if ($env:GITHUB_STEP_SUMMARY) { + Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value ("## Runner Bootstrap (FAILED: wrong target)`n`n- Requested runner: ``$requested```n- Actual runner: ``$actual```n- Action: " + + "hard-fail; take ``$actual`` offline and re-dispatch.") + } + exit 1 + } + } + # Surface to the step summary for the Actions UI. + $summary = @( + "## Runner Bootstrap" + "" + "- Requested runner: ``$requested``" + "- Actual runner: ``$actual``" + "- Detect-only: ``${{ inputs.detect-only }}``" + ) -join "`n" + if ($env:GITHUB_STEP_SUMMARY) { + Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value $summary + } + + - name: Run runner maintenance (single-step transcript) + # F5/F7: a single PowerShell 5.1 step opens the transcript, invokes + # the bootstrap script, and Stop-Transcripts in a finally{} -- the + # previous design split Start-Transcript and the bootstrap into + # separate pwsh processes, leaving the artifact empty. + # F15: Windows PowerShell 5.1 -- see header comment. + # F22: we deliberately do NOT `uses: ./.github/actions/assert-unity-host-prereqs` + # here, even though that composite would normally own this orchestration. + # The composite contains a PS 5.1 preflight (F14) that hard-fails when + # pwsh is missing -- but pwsh missing is EXACTLY the case this + # workflow exists to repair (chicken-and-egg). To avoid duplication + # without breaking the bootstrap-out-of-pwsh-missing case, this + # workflow's logic is kept thin: it owns ONLY the transcript wrapper + # and the detect-only normalization. All real orchestration + # (auto-install vs DetectOnly, redist install, defender exclusions, + # UCRT, pwsh install) lives inside scripts/unity/bootstrap-windows-runner.ps1 + # -- the SAME script the composite invokes. The composite remains the + # canonical Unity-job-side entry point. + # F4: parse inputs.detect-only via natural truthy normalization + # rather than string-equality against the unquoted 'true' literal. + # Also honor UH_RUNNER_DISABLE_AUTO_BOOTSTRAP=1 here (forces + # DetectOnly) so the operator override behaves identically across + # the workflow path and the composite path. + shell: powershell + env: + UH_BOOTSTRAP_DETECT_ONLY: ${{ inputs.detect-only }} + run: | + Set-StrictMode -Version Latest + $ErrorActionPreference = 'Stop' + $artifactDir = Join-Path $env:GITHUB_WORKSPACE '.artifacts\runner-bootstrap' + New-Item -ItemType Directory -Force -Path $artifactDir | Out-Null + $timestamp = (Get-Date -Format 'yyyyMMdd-HHmmss') + $logPath = Join-Path $artifactDir ("maintenance-{0}-{1}-{2}.log" -f $timestamp, $env:GITHUB_RUN_ID, $env:GITHUB_RUN_ATTEMPT) + Write-Host "Transcript: $logPath" + + # F4: normalize detect-only to a single bool. Accepts true/True/1/yes/y. + $rawDetect = '' + $env:UH_BOOTSTRAP_DETECT_ONLY + $normalizedDetect = $rawDetect.Trim().ToLowerInvariant() + $detectOnly = ($normalizedDetect -eq 'true' -or $normalizedDetect -eq '1' -or $normalizedDetect -eq 'yes' -or $normalizedDetect -eq 'y') + # Operator escape hatch -- UH_RUNNER_DISABLE_AUTO_BOOTSTRAP=1 + # forces DetectOnly even when the dispatch input says auto-install. + # Mirrors the composite (./.github/actions/assert-unity-host-prereqs) + # so this workflow path and the composite path observe the same + # precedence rule (per the header's MODE RESOLUTION PRECEDENCE list). + if ($env:UH_RUNNER_DISABLE_AUTO_BOOTSTRAP -eq '1') { + Write-Host "::notice::UH_RUNNER_DISABLE_AUTO_BOOTSTRAP=1 -> forcing DetectOnly (overrides inputs.detect-only)" + $detectOnly = $true + } + Write-Host "Resolved detect-only: $detectOnly (raw='$rawDetect')" + + # We are inside a workflow (not a composite), so $GITHUB_WORKSPACE is + # reliably the repo root post-checkout (actions/checkout@v6 above). + # + # TODO(unity-helpers): scripts/unity/maintain-windows-runner.ps1 (and + # the scripts/unity/bootstrap-windows-runner.ps1 it wraps, plus the + # .github/actions/assert-unity-host-prereqs composite) were NOT ported + # in this batch -- they are the host-OS remediation backend (VC++ + # redist, long-paths, Defender exclusions, pwsh, UCRT installs). Until + # they are ported from DxMessaging, this workflow HARD-FAILS below with + # a clear "script not found" error instead of silently doing nothing. + # The runner-preflight job above (runner online + label check) still + # works standalone; only this maintenance step needs the backend. + $script = Join-Path $env:GITHUB_WORKSPACE 'scripts\unity\maintain-windows-runner.ps1' + if (-not (Test-Path -LiteralPath $script)) { + Write-Host "::error::Runner maintenance script not found at $script (see TODO(unity-helpers) in this workflow: scripts/unity/maintain-windows-runner.ps1 has not been ported yet)." + exit 1 + } + + $code = 0 + try { + Start-Transcript -Path $logPath -Force | Out-Null + . $script + $unityVersions = @('2021.3.45f1', '2022.3.45f1', '6000.3.16f1') + $provisioningProfile = 'StandaloneWindowsIl2Cpp' + $installRoot = if ($env:UNITY_EDITOR_INSTALL_ROOT) { $env:UNITY_EDITOR_INSTALL_ROOT } else { 'C:\Unity\Editors' } + $maintenanceArgs = @{ + UnityVersions = $unityVersions + ProvisioningProfile = $provisioningProfile + InstallRoot = $installRoot + Force = $true + DiagnosticsRoot = $artifactDir + } + if ($detectOnly) { + $maintenanceArgs.DetectOnly = $true + } + $code = Invoke-WindowsRunnerMaintenance @maintenanceArgs + } finally { + try { Stop-Transcript | Out-Null } catch { Write-Host "::notice::Stop-Transcript: $($_.Exception.Message) (non-fatal)" } + } + + Write-Host "Runner maintenance exit code: $code" + if ($env:GITHUB_STEP_SUMMARY) { + $label = switch ($code) { + 0 { 'ok' } + 2 { 'detect-only-missing' } + 3 { 'mutex-busy' } + 4 { 'runner-busy' } + default { "fail($code)" } + } + Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value ("- Runner maintenance result: ``" + $label + "`` (exit $code)") + Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value ("- Transcript: ``" + $logPath + "``") + } + if ($code -ne 0) { exit $code } + + # F1: actions/upload-artifact@v7 -- matches the repo-wide pinned + # version used by unity-tests.yml/unity-benchmarks.yml. + # F19: if-no-files-found is `error` (not `warn`) so a missing transcript + # is a HARD failure rather than a silent empty artifact upload. + - name: Upload bootstrap transcript + if: always() + uses: actions/upload-artifact@v7 + with: + name: runner-bootstrap-${{ inputs.runner-label }}-${{ github.run_id }}-${{ github.run_attempt }} + path: .artifacts/runner-bootstrap/** + if-no-files-found: error + retention-days: 30 diff --git a/.github/workflows/stuck-job-watchdog.yml b/.github/workflows/stuck-job-watchdog.yml new file mode 100644 index 000000000..a232f818b --- /dev/null +++ b/.github/workflows/stuck-job-watchdog.yml @@ -0,0 +1,519 @@ +name: Stuck Job Watchdog + +# cspell:ignore pushd popd + +# Recovery automation for the known GitHub Actions self-hosted dispatcher bug +# documented at https://github.com/orgs/community/discussions/186811: +# self-hosted runners report Online/Idle but `runner_id` stays at 0 for +# 7+ minutes, so a queued job whose label set is satisfied by an idle runner +# nonetheless waits indefinitely until manually re-run. +# +# Detection requires ALL of the following to be true: +# * The workflow run is `status: queued` AND older than MIN_QUEUE_AGE_SECONDS +# (default 300s / 5 min; round-2 false-positive guards below -- zero +# in-progress jobs, self-run exclusion, excluded-workflow list -- make +# the previous conservative 600s buffer unnecessary). +# * No job in the run is `status: in_progress` - a run with even one +# in-progress job is by definition holding/using a runner, not +# dispatcher-stuck. This is what prevents false positives for matrix cells +# or jobs waiting while another job from the same run is actively running. +# * At least one job in the run is `status: queued`. +# * At least one idle runner's labels satisfy a queued job's label +# requirements (superset match). +# * The run's workflow file is NOT in the exclusion list (`release.yml` +# is hard-excluded by default; additional entries may be added via the +# `WATCHDOG_EXCLUDED_WORKFLOWS` repo variable, whitespace-separated). +# * The run id is NOT the watchdog's own run id. +# +# Recovery action (per cli/cli#9221 and the gh-run-rerun manual, +# `gh run rerun --failed` cannot be used on a `status: queued` run because +# the run never reached `failed`; the documented workaround is cancel + +# redispatch / cancel + operator-managed re-run): +# * `gh run cancel ` kills the stuck dispatch. +# * If the run was triggered by push/schedule/workflow_dispatch on a +# branch (not a tag) AND the workflow file declares `workflow_dispatch:`, +# re-dispatch via REST API +# (`POST repos/{owner}/{repo}/actions/workflows/{workflow_id}/dispatches`). +# * Otherwise (pull_request, tag, no workflow_dispatch trigger), emit a +# clear `GITHUB_STEP_SUMMARY` line instructing the operator to click +# "Re-run all jobs" in the GitHub UI. Do NOT push a commit, comment on +# the PR, or escalate any other automatic action. +# +# Cancel attempts are capped at 2 per run-id per 24h via a small state file +# on the `watchdog-state` orphan branch. State is pushed immediately after each +# successful cancel, with a single rebase+retry on push failure. +# +# Workflow-level concurrency guarantees only one watchdog instance ever runs; +# newer schedules must not cancel an in-flight audit after it has cancelled a +# stuck run but before it persists the state-branch counter. + +on: + schedule: + - cron: "*/5 * * * *" + workflow_dispatch: + +concurrency: + group: stuck-job-watchdog + cancel-in-progress: false + +permissions: + actions: write + contents: write + +jobs: + audit-queue: + name: Audit queued runs and recover dispatcher-stuck ones + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Audit + cancel-and-redispatch + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + OWNER: ${{ github.repository_owner }} + SELF_RUN_ID: ${{ github.run_id }} + STATE_BRANCH: watchdog-state + STATE_DIR: .watchdog-state + MAX_CANCELS_PER_DAY: "2" + MIN_QUEUE_AGE_SECONDS: "300" + DEFAULT_EXCLUDED_WORKFLOWS: "release.yml" + EXTRA_EXCLUDED_WORKFLOWS: ${{ vars.WATCHDOG_EXCLUDED_WORKFLOWS }} + run: | + set -euo pipefail + + summary_file="$(mktemp)" + : > "${summary_file}" + log_summary() { + printf '%s\n' "$1" | tee -a "${summary_file}" + } + + flush_summary_and_exit() { + local code="${1:-0}" + { + echo "## Watchdog summary" + cat "${summary_file}" + } >> "${GITHUB_STEP_SUMMARY}" + exit "${code}" + } + + # Category buckets for the final step-summary table. + healthy_runs_file="$(mktemp)" + : > "${healthy_runs_file}" + stuck_runs_file="$(mktemp)" + : > "${stuck_runs_file}" + excluded_runs_file="$(mktemp)" + : > "${excluded_runs_file}" + + log_summary "## Stuck-job watchdog audit ($(date -u +'%Y-%m-%dT%H:%M:%SZ'))" + log_summary "Repo: ${REPO}" + log_summary "Owner: ${OWNER}" + log_summary "Self run id (will skip): ${SELF_RUN_ID}" + + # Build the workflow-file exclusion list (default + repo variable). + declare -A EXCLUDED_BY_FILE=() + for wf in ${DEFAULT_EXCLUDED_WORKFLOWS} ${EXTRA_EXCLUDED_WORKFLOWS:-}; do + [[ -z "${wf}" ]] && continue + # Normalize: strip any leading .github/workflows/ if present. + base="${wf##*/}" + EXCLUDED_BY_FILE["${base}"]=1 + done + excluded_list="" + for k in "${!EXCLUDED_BY_FILE[@]}"; do + excluded_list+="${k} " + done + log_summary "Excluded workflows: ${excluded_list:-}" + + # ------------------------------------------------------------------ + # 1. Bootstrap or check out the watchdog-state orphan branch. + # ------------------------------------------------------------------ + work_dir="$(mktemp -d)" + pushd "${work_dir}" > /dev/null + # Scope credentials to the exact remote commands that need them; the + # cloned repo keeps a plain HTTPS origin with no tokenized remote. + auth_header="$(printf 'x-access-token:%s' "${GH_TOKEN}" | base64 | tr -d '\n')" + git_auth() { + git -c "http.https://github.com/.extraheader=AUTHORIZATION: basic ${auth_header}" "$@" + } + git_auth clone --depth 1 "https://github.com/${REPO}.git" repo + cd repo + GIT_AUTHOR_ID=(-c "user.email=actions@github.com" -c "user.name=stuck-job-watchdog") + + # Decoupled probe + bootstrap: ls-remote distinguishes + # "branch missing" (zero rows + exit 0) from "transient fetch + # failure" (non-zero exit). We MUST NOT bootstrap on transient + # failure - that would push-corrupt an existing branch by + # rewriting it as a fresh orphan. + state_branch_probe="$(mktemp)" + if ! git_auth ls-remote --heads origin "${STATE_BRANCH}" > "${state_branch_probe}" 2>/dev/null; then + log_summary "WARN: 'git ls-remote --heads origin ${STATE_BRANCH}' failed (transient?); refusing to bootstrap. Skipping this cycle." + popd > /dev/null + flush_summary_and_exit 0 + fi + + if [[ -s "${state_branch_probe}" ]]; then + if ! git_auth fetch origin "${STATE_BRANCH}:refs/remotes/origin/${STATE_BRANCH}"; then + log_summary "WARN: state branch '${STATE_BRANCH}' exists per ls-remote but fetch failed; skipping this cycle." + popd > /dev/null + flush_summary_and_exit 0 + fi + git checkout -B "${STATE_BRANCH}" "refs/remotes/origin/${STATE_BRANCH}" + log_summary "State branch '${STATE_BRANCH}' checked out." + else + log_summary "State branch '${STATE_BRANCH}' missing -- bootstrapping orphan branch." + git checkout --orphan "${STATE_BRANCH}" + git rm -rf . > /dev/null 2>&1 || true + mkdir -p "${STATE_DIR}" + touch "${STATE_DIR}/.gitkeep" + git add "${STATE_DIR}/.gitkeep" + git "${GIT_AUTHOR_ID[@]}" commit -m "Initialize watchdog state" || true + if ! git_auth push origin "${STATE_BRANCH}"; then + log_summary "WARN: bootstrap push failed; will retry on next run." + popd > /dev/null + flush_summary_and_exit 0 + fi + fi + mkdir -p "${STATE_DIR}" + state_dirty=0 + + persist_state_changes() { + local reason="${1:-state update}" + if (( state_dirty != 1 )); then + return 0 + fi + + git add "${STATE_DIR}" + if git diff-index --cached --quiet HEAD --; then + if git rev-parse --verify "refs/remotes/origin/${STATE_BRANCH}" > /dev/null 2>&1 \ + && [[ "$(git rev-parse HEAD)" == "$(git rev-parse "refs/remotes/origin/${STATE_BRANCH}")" ]]; then + state_dirty=0 + return 0 + fi + else + if ! git "${GIT_AUTHOR_ID[@]}" commit -m "Watchdog: ${reason} at $(date -u +'%Y-%m-%dT%H:%M:%SZ')"; then + log_summary "WARN: state commit failed; counters may double-count next run." + return 1 + fi + fi + + if git_auth push origin "${STATE_BRANCH}"; then + log_summary "State branch updated (${reason})." + state_dirty=0 + return 0 + fi + + log_summary "WARN: state push failed; attempting fetch + rebase + retry." + if git_auth fetch origin "${STATE_BRANCH}:refs/remotes/origin/${STATE_BRANCH}" \ + && git rebase "refs/remotes/origin/${STATE_BRANCH}" \ + && git_auth push origin "${STATE_BRANCH}"; then + log_summary "State branch updated on second attempt (${reason}, after rebase)." + state_dirty=0 + return 0 + fi + + log_summary "WARN: state push failed twice; counters may double-count next run." + return 1 + } + + is_nonnegative_integer() { + [[ "${1:-}" =~ ^[0-9]+$ ]] + } + + # ------------------------------------------------------------------ + # 2. Enumerate queued runs older than MIN_QUEUE_AGE_SECONDS. + # ------------------------------------------------------------------ + now_epoch="$(date -u +%s)" + queued_runs_json="$(mktemp)" + # `gh api --paginate` over the runs endpoint returns one JSON object + # per page; `jq -s '[.[] | .workflow_runs[]?]'` flattens all pages + # into a single array. Equivalent to using `--slurp` on newer gh + # versions but works on all gh versions shipped with ubuntu-latest. + if ! gh api --paginate "repos/${REPO}/actions/runs?status=queued&per_page=100" \ + | jq -s '[.[] | (.workflow_runs // [])[]]' > "${queued_runs_json}"; then + log_summary "ERROR: failed to list queued runs." + popd > /dev/null + flush_summary_and_exit 0 + fi + + mapfile -t queued_ids < <(jq -r --argjson now "${now_epoch}" --argjson min "${MIN_QUEUE_AGE_SECONDS}" ' + .[] + | select(.created_at != null) + | (.created_at | fromdateiso8601) as $created + | select(($now - $created) >= $min) + | .id + ' < "${queued_runs_json}" | sort -u) + + log_summary "Queued runs older than ${MIN_QUEUE_AGE_SECONDS}s: ${#queued_ids[@]}" + if [[ ${#queued_ids[@]} -eq 0 ]]; then + log_summary "Queue is clean. No action." + popd > /dev/null + flush_summary_and_exit 0 + fi + + # ------------------------------------------------------------------ + # 3. Fetch idle runners (org first, fall back to repo on 403). + # `gh api --paginate` over an object-shaped endpoint returns one + # object per page; without `jq -s`, downstream evaluation runs + # against only the last page (silent false negatives once the + # org grows past 100 runners - see cli/cli#1268). + # ------------------------------------------------------------------ + runners_json="$(mktemp)" + runners_scope="org" + if ! gh api --paginate "orgs/${OWNER}/actions/runners?per_page=100" \ + | jq -s '[.[] | (.runners // [])[]]' > "${runners_json}" 2>/dev/null; then + runners_scope="repo" + log_summary "WARN: org-level runner list unavailable (likely 403). Falling back to repo runners." + if ! gh api --paginate "repos/${REPO}/actions/runners?per_page=100" \ + | jq -s '[.[] | (.runners // [])[]]' > "${runners_json}"; then + log_summary "ERROR: repo-level runner list also failed; cannot evaluate idle runners. No action issued." + popd > /dev/null + flush_summary_and_exit 0 + fi + fi + log_summary "Runner inventory scope: ${runners_scope}" + + idle_runners_json="$(mktemp)" + jq ' + [ .[] + | select(.status == "online") + | select(.busy == false) + | { id: .id, name: .name, labels: ([(.labels // [])[].name]) } ] + ' < "${runners_json}" > "${idle_runners_json}" + idle_count="$(jq 'length' < "${idle_runners_json}")" + log_summary "Idle runners (online + not busy): ${idle_count}" + + # ------------------------------------------------------------------ + # 4. For each queued run, decide stuck vs healthy vs excluded. + # ------------------------------------------------------------------ + state_dirty=0 + for run_id in "${queued_ids[@]}"; do + # Skip the watchdog's own run id (defense in depth - the watchdog + # workflow file is excluded by name above, but if someone renames + # the file or runs ad-hoc the id check still protects us). + if [[ "${run_id}" == "${SELF_RUN_ID}" ]]; then + log_summary "run ${run_id}: this is the watchdog's own run; skipping." + continue + fi + + # Pull the run metadata we need from the cached list (workflow + # file path, event, head_branch). + run_meta="$( + jq -c --argjson id "${run_id}" ' + .[] + | select(.id == $id) + | { + path: (.path // ""), + event: (.event // ""), + workflow_id: (.workflow_id // 0), + head_branch: (.head_branch // ""), + html_url: (.html_url // "") + } + ' < "${queued_runs_json}" | head -n 1 + )" + if [[ -z "${run_meta}" ]]; then + log_summary "run ${run_id}: metadata unavailable; skipping." + continue + fi + run_path="$(jq -r '.path' <<< "${run_meta}")" + run_event="$(jq -r '.event' <<< "${run_meta}")" + run_workflow_id="$(jq -r '.workflow_id' <<< "${run_meta}")" + run_head_branch="$(jq -r '.head_branch' <<< "${run_meta}")" + run_html_url="$(jq -r '.html_url' <<< "${run_meta}")" + run_path_base="${run_path##*/}" + + # Workflow-file exclusion (must NOT cancel release.yml). + if [[ -n "${EXCLUDED_BY_FILE[${run_path_base}]+x}" ]]; then + log_summary "run ${run_id} (${run_path_base}, event=${run_event}): workflow is excluded; operator action needed if stuck." + printf '* run %s (%s, event=%s) -- %s\n' "${run_id}" "${run_path_base}" "${run_event}" "${run_html_url}" >> "${excluded_runs_file}" + continue + fi + + jobs_json="$(mktemp)" + if ! gh api --paginate "repos/${REPO}/actions/runs/${run_id}/jobs?per_page=100" \ + | jq -s '[.[] | (.jobs // [])[]]' > "${jobs_json}" 2>/dev/null; then + log_summary "run ${run_id}: failed to list jobs; skipping." + continue + fi + + in_progress_count="$(jq '[ .[] | select(.status == "in_progress") ] | length' < "${jobs_json}")" + queued_count="$(jq '[ .[] | select(.status == "queued") ] | length' < "${jobs_json}")" + + if (( in_progress_count > 0 )); then + # A run with even one in-progress job is by definition not + # dispatcher-stuck. This covers matrix cells or later jobs + # waiting while another job from the same run is active, and the + # general case of a run that has at least one runner. + log_summary "run ${run_id} (${run_path_base}, event=${run_event}): healthy queued (${in_progress_count} in_progress, ${queued_count} queued -- waiting on concurrency/matrix slot)." + printf '* run %s (%s, event=%s) -- %d in_progress, %d queued\n' "${run_id}" "${run_path_base}" "${run_event}" "${in_progress_count}" "${queued_count}" >> "${healthy_runs_file}" + continue + fi + + if (( queued_count == 0 )); then + # No queued jobs at all - the run is in some other transitional + # state, not the dispatcher-stuck pattern. + log_summary "run ${run_id} (${run_path_base}, event=${run_event}): no queued jobs yet (early state); skipping." + continue + fi + + mapfile -t queued_job_labels < <(jq -c ' + .[] + | select(.status == "queued") + | (.labels // []) + ' < "${jobs_json}") + + matched=0 + for labels_csv in "${queued_job_labels[@]}"; do + # labels_csv is a JSON array like ["self-hosted","Windows","RAM-64GB"] + if jq -e --argjson labels "${labels_csv}" ' + map( + . as $r + | ($labels | all(. as $l | $r.labels | index($l) | type == "number")) + ) | any + ' < "${idle_runners_json}" > /dev/null; then + matched=1 + break + fi + done + + if [[ ${matched} -eq 0 ]]; then + log_summary "run ${run_id} (${run_path_base}, event=${run_event}): no matching idle runner -- investigate label config. No action." + continue + fi + + # ------------------------------------------------------------------ + # 5. Genuinely stuck. Cap at MAX_CANCELS_PER_DAY per run-id. + # ------------------------------------------------------------------ + state_file="${STATE_DIR}/${run_id}.json" + cancels=0 + last_cancel=0 + if [[ -f "${state_file}" ]]; then + state_ok=1 + state_content="$(cat "${state_file}" 2>/dev/null)" || state_ok=0 + if [[ ${state_ok} -eq 1 ]]; then + parsed_cancels="$(jq -r '.cancels // .reruns // 0' <<< "${state_content}" 2>/dev/null)" || state_ok=0 + parsed_last="$(jq -r '.last_cancel // .last_rerun // 0' <<< "${state_content}" 2>/dev/null)" || state_ok=0 + fi + if [[ ${state_ok} -eq 1 ]]; then + if is_nonnegative_integer "${parsed_cancels}" && is_nonnegative_integer "${parsed_last}"; then + cancels="${parsed_cancels}" + last_cancel="${parsed_last}" + else + state_ok=0 + fi + fi + if [[ ${state_ok} -ne 1 ]]; then + log_summary "run ${run_id}: state file corrupt; resetting." + else + log_summary "run ${run_id}: loaded cancel state (cancels=${cancels}, last_cancel=${last_cancel})." + fi + fi + # Reset counter if last action was >24h ago. + if (( now_epoch - last_cancel > 86400 )); then + cancels=0 + fi + + if (( cancels >= MAX_CANCELS_PER_DAY )); then + log_summary "run ${run_id} (${run_path_base}, event=${run_event}): cancel cap (${MAX_CANCELS_PER_DAY}/24h) reached; skipping. Manual intervention required." + printf '* run %s (%s, event=%s) -- cap reached, operator action: %s\n' "${run_id}" "${run_path_base}" "${run_event}" "${run_html_url}" >> "${stuck_runs_file}" + continue + fi + + log_summary "run ${run_id} (${run_path_base}, event=${run_event}): dispatcher-stuck; cancelling (attempt $((cancels + 1))/${MAX_CANCELS_PER_DAY})." + if ! gh run cancel "${run_id}" --repo "${REPO}" 2>&1 | tee -a "${summary_file}"; then + log_summary "run ${run_id}: 'gh run cancel' failed; will try again next cycle." + continue + fi + + cancels=$((cancels + 1)) + printf '{"cancels": %d, "last_cancel": %d}\n' "${cancels}" "${now_epoch}" > "${state_file}" + git add "${state_file}" + state_dirty=1 + persist_state_changes "record cancel for run ${run_id}" || true + + # Decide redispatch path based on event + workflow_dispatch trigger. + workflow_def="$(mktemp)" + workflow_supports_dispatch=0 + if gh api "repos/${REPO}/actions/workflows/${run_workflow_id}" > "${workflow_def}" 2>/dev/null; then + workflow_path="$(jq -r '.path // ""' < "${workflow_def}")" + if [[ -n "${workflow_path}" ]]; then + # Look at the contents of the workflow file to see if it + # declares `workflow_dispatch:` (the trigger we need to use + # the dispatches REST API). + workflow_file_raw="$(mktemp)" + if gh api "repos/${REPO}/contents/${workflow_path}" --jq '.content' 2>/dev/null \ + | base64 -d > "${workflow_file_raw}" 2>/dev/null; then + if grep -Eq '^[[:space:]]*workflow_dispatch:' "${workflow_file_raw}"; then + workflow_supports_dispatch=1 + fi + fi + fi + fi + + case "${run_event}" in + push|schedule|workflow_dispatch) + if [[ -n "${run_head_branch}" && ${workflow_supports_dispatch} -eq 1 ]]; then + log_summary "run ${run_id}: re-dispatching workflow ${run_workflow_id} on ref '${run_head_branch}'." + if gh api -X POST "repos/${REPO}/actions/workflows/${run_workflow_id}/dispatches" \ + -f "ref=${run_head_branch}" 2>&1 | tee -a "${summary_file}"; then + printf '* run %s (%s, event=%s) -- cancelled and re-dispatched on %s\n' "${run_id}" "${run_path_base}" "${run_event}" "${run_head_branch}" >> "${stuck_runs_file}" + else + log_summary "run ${run_id}: re-dispatch failed; operator action: ${run_html_url}" + printf '* run %s (%s, event=%s) -- cancelled; re-dispatch FAILED, operator action: %s\n' "${run_id}" "${run_path_base}" "${run_event}" "${run_html_url}" >> "${stuck_runs_file}" + fi + else + log_summary "run ${run_id}: workflow does not support workflow_dispatch on ref '${run_head_branch}'; operator action: click 'Re-run all jobs' at ${run_html_url}" + printf \ + '* run %s (%s, event=%s) -- cancelled; operator action: click "Re-run all jobs" at %s\n' \ + "${run_id}" "${run_path_base}" "${run_event}" "${run_html_url}" >> "${stuck_runs_file}" + fi + ;; + pull_request|pull_request_target) + # No safe API path: the dispatches endpoint cannot re-trigger + # a pull_request run, and pushing a no-op commit to the head + # ref would tamper with the PR. Operator-visible cancel + + # explicit step-summary instruction is the supported path. + log_summary "run ${run_id}: pull_request-triggered; operator action: click 'Re-run all jobs' at ${run_html_url}" + printf '* run %s (%s, event=%s) -- cancelled; operator action: click "Re-run all jobs" at %s\n' "${run_id}" "${run_path_base}" "${run_event}" "${run_html_url}" >> "${stuck_runs_file}" + ;; + *) + log_summary "run ${run_id}: event '${run_event}' has no automatic recovery path; operator action: click 'Re-run all jobs' at ${run_html_url}" + printf '* run %s (%s, event=%s) -- cancelled; operator action: click "Re-run all jobs" at %s\n' "${run_id}" "${run_path_base}" "${run_event}" "${run_html_url}" >> "${stuck_runs_file}" + ;; + esac + done + + # ------------------------------------------------------------------ + # 6. Commit + push state changes with a single rebase+retry. + # ------------------------------------------------------------------ + persist_state_changes "final state sync" || true + + popd > /dev/null + + # ------------------------------------------------------------------ + # Final categorized step-summary table. + # ------------------------------------------------------------------ + { + echo "## Watchdog summary" + cat "${summary_file}" + echo "" + echo "### Healthy queued (waiting on concurrency / matrix slot)" + if [[ -s "${healthy_runs_file}" ]]; then + cat "${healthy_runs_file}" + else + echo "_(none)_" + fi + echo "" + echo "### Stuck (auto-cancelled)" + if [[ -s "${stuck_runs_file}" ]]; then + cat "${stuck_runs_file}" + else + echo "_(none)_" + fi + echo "" + echo "### Stuck but excluded (operator action needed)" + if [[ -s "${excluded_runs_file}" ]]; then + cat "${excluded_runs_file}" + else + echo "_(none)_" + fi + } >> "${GITHUB_STEP_SUMMARY}" diff --git a/.github/workflows/unity-benchmarks.yml b/.github/workflows/unity-benchmarks.yml new file mode 100644 index 000000000..93e4328c8 --- /dev/null +++ b/.github/workflows/unity-benchmarks.yml @@ -0,0 +1,479 @@ +name: Unity Benchmarks + +# cspell:ignore lfs WSL + +on: + schedule: + - cron: "29 10 * * 3" + workflow_dispatch: + inputs: + unity-version: + description: "Pin a single Unity version. Empty = full matrix." + required: false + default: "" + type: string + +# Top-level permissions are least-privilege and mirror unity-tests.yml. The +# licensed matrix jobs (matrix-config, runner-preflight, benchmarks) only need +# read access plus checks:write for NUnit annotations and issues:write for +# benchmark-failure notifications; they must NOT inherit write scope. The +# dedicated commit-perf-results job below opts into contents:write at the JOB +# level so it (and only it) can push the refreshed perf artifacts. +permissions: + contents: read + checks: write + issues: write + +jobs: + matrix-config: + name: Resolve benchmark matrix + runs-on: ubuntu-latest + timeout-minutes: 2 + outputs: + unity-versions: ${{ steps.resolve.outputs.unity-versions }} + latest-version: ${{ steps.resolve.outputs.latest-version }} + has-required-secrets: ${{ steps.check-secrets.outputs.has-secrets }} + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + persist-credentials: false + - name: Resolve dispatch overrides + id: resolve + env: + INPUT_UNITY_VERSION: ${{ inputs.unity-version }} + # The benchmark matrix list and the latest entry both come from the + # canonical source (.github/unity-versions.json), so the scheduled perf + # coverage tracks every supported runtime and the latest-vs-list pair + # cannot drift. The LAST entry is the latest Unity version. A dispatch + # override pins a single version. + run: | + set -euo pipefail + if [ -n "${INPUT_UNITY_VERSION:-}" ]; then + versions="[\"${INPUT_UNITY_VERSION}\"]" + latest="${INPUT_UNITY_VERSION}" + else + versions="$(jq -c '.all' .github/unity-versions.json)" + latest="$(jq -r '.all[-1]' .github/unity-versions.json)" + fi + { + echo "unity-versions=${versions}" + echo "latest-version=${latest}" + } >> "${GITHUB_OUTPUT}" + + - name: Check for required Unity license secrets + id: check-secrets + env: + UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} + UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} + UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} + ORG_BUILD_LOCK_TOKEN: ${{ secrets.ORG_BUILD_LOCK_TOKEN }} + run: | + set -euo pipefail + if [ -n "${UNITY_SERIAL:-}" ] && \ + [ -n "${UNITY_EMAIL:-}" ] && \ + [ -n "${UNITY_PASSWORD:-}" ] && \ + [ -n "${ORG_BUILD_LOCK_TOKEN:-}" ]; then + echo "has-secrets=true" >> "${GITHUB_OUTPUT}" + else + echo "has-secrets=false" >> "${GITHUB_OUTPUT}" + fi + + runner-preflight: + name: Self-hosted runner access preflight + # See docs/runbooks/unity-runners-after-transfer.md for the design + # contract. CRITICAL: this preflight must NEVER make Unity CI more + # broken than the no-preflight baseline -- if both runner-inventory + # endpoints return 403/404, soft-pass with a ::warning::. + runs-on: ubuntu-latest + timeout-minutes: 3 + permissions: + actions: read + # NOTE: `administration: read` is NOT a valid permissions key for + # workflow GITHUB_TOKEN (per actionlint and the GitHub docs schema). + # Listing self-hosted runners requires admin:org (org-scope) OR a + # fine-grained PAT with repo "Administration: read" -- neither of + # which the default GITHUB_TOKEN can carry. The soft-pass path in + # the run script below is the supported coverage when the token + # is unscoped. See docs/runbooks/unity-runners-after-transfer.md. + concurrency: + group: ${{ github.workflow }}-runner-preflight-${{ github.ref }} + cancel-in-progress: true + steps: + - name: Probe self-hosted runner availability + env: + GH_TOKEN: ${{ secrets.RUNNER_AUDIT_PAT || secrets.GITHUB_TOKEN }} + REQUIRED_LABELS: "self-hosted,Windows,RAM-64GB" + # gh api --paginate | jq -s pattern mirrors stuck-job-watchdog.yml. + run: | + set -euo pipefail + echo "::group::Runner-access preflight" + echo "Required labels: ${REQUIRED_LABELS}" + IFS=',' read -r -a required <<< "${REQUIRED_LABELS}" + runners_json="$(mktemp)" + runners_scope="" + if gh api --paginate \ + "orgs/${GITHUB_REPOSITORY_OWNER}/actions/runners?per_page=100" \ + 2>/dev/null \ + | jq -s '[.[] | (.runners // [])[]]' > "${runners_json}" 2>/dev/null; then + runners_scope="org" + else + if gh api --paginate \ + "repos/${GITHUB_REPOSITORY}/actions/runners?per_page=100" \ + 2>/dev/null \ + | jq -s '[.[] | (.runners // [])[]]' > "${runners_json}" 2>/dev/null; then + runners_scope="repo" + fi + fi + if [ -z "${runners_scope}" ]; then + org_url="orgs/${GITHUB_REPOSITORY_OWNER}/actions/runners" + repo_url="repos/${GITHUB_REPOSITORY}/actions/runners" + { + echo "::warning::Runner inventory unavailable from both ${org_url}" + echo "::warning::and ${repo_url} (likely 403: GITHUB_TOKEN lacks admin:org" + echo "::warning::or administration:read)." + echo "::warning::See docs/runbooks/unity-runners-after-transfer.md" + echo "::warning::for how to plumb a RUNNER_AUDIT_PAT secret to upgrade" + echo "::warning::this soft-pass to a hard-pass." + } + echo "Soft pass: skipping runner inventory check." + echo "::endgroup::" + exit 0 + fi + echo "Runner inventory scope: ${runners_scope}" + total="$(jq 'length' < "${runners_json}")" + echo "Visible runners (${runners_scope} scope): ${total}" + matched="$(jq --argjson labels "$(printf '%s\n' "${required[@]}" | jq -R . | jq -s .)" ' + [ .[] + | select(.status == "online") + | select(($labels | all(. as $l | (.labels // []) | map(.name) | index($l) | type == "number"))) + ] | length + ' < "${runners_json}")" + echo "Runners satisfying ${REQUIRED_LABELS}: ${matched}" + if [ "${matched}" -lt 1 ]; then + { + echo "::error::No online self-hosted runner satisfies the required labels (${REQUIRED_LABELS})." + echo "The Unity matrix would queue forever." + echo "See docs/runbooks/unity-runners-after-transfer.md for the post-transfer runner-group ACL fix." + } + jq '[ .[] | {name, status, busy, labels: [(.labels // [])[].name]} ]' \ + < "${runners_json}" + exit 1 + fi + echo "::endgroup::" + + benchmarks: + name: Benchmarks ${{ matrix.unity-version }} ${{ matrix.test-mode }} + needs: + - matrix-config + - runner-preflight + # Skip the self-hosted matrix entirely when license/lock secrets are absent, + # so a secret-less repo does not queue forever on a nonexistent runner. + if: ${{ needs.matrix-config.outputs.has-required-secrets == 'true' }} + runs-on: [self-hosted, Windows, RAM-64GB] + timeout-minutes: 660 + strategy: + fail-fast: false + max-parallel: 1 + matrix: + unity-version: ${{ fromJSON(needs.matrix-config.outputs.unity-versions) }} + test-mode: + - editmode + - playmode + steps: + - name: Enable Git long paths + shell: pwsh + env: + GIT_CONFIG_GLOBAL: ${{ runner.temp }}/uh-gitconfig + run: git config --global core.longpaths true + + - name: Checkout + uses: actions/checkout@v6 + env: + GIT_CONFIG_GLOBAL: ${{ runner.temp }}/uh-gitconfig + with: + lfs: true + persist-credentials: false + + - name: Print runner diagnostics + # Never use plain `shell: bash` on self-hosted Windows runners (the WSL + # stub at C:\Windows\System32\bash.exe can win PATH resolution and fail). + uses: ./.github/actions/print-self-hosted-runner-diagnostics + with: + matrix-note: "default (organization lock wraps the Unity section)" + + - name: Cache Unity Library and package caches + uses: actions/cache@v5 + env: + PACKAGE_HASH: >- + ${{ hashFiles( + 'package.json', + 'Runtime/**', + 'Editor/**', + 'Tests/**', + 'scripts/unity/run-ci-tests.ps1', + 'scripts/unity/lib/asmdef-discovery.js', + '.github/actions/compute-unity-assemblies/action.yml' + ) }} + with: + path: | + .artifacts/unity/projects/${{ matrix.unity-version }}-${{ matrix.test-mode }}/Library + .artifacts/unity/cache/${{ matrix.unity-version }} + key: Library-bench-${{ runner.os }}-${{ runner.arch }}-${{ matrix.unity-version }}-${{ matrix.test-mode }}-${{ env.PACKAGE_HASH }} + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: "22.18.0" + + # Single source of truth for the assembly include list: the same + # asmdef-discovery.js module. For benchmarks we OPT IN to the perf + # assemblies (*.Tests.Runtime.Performance) via include-perf. + - name: Compute benchmark assembly list + id: compute + uses: ./.github/actions/compute-unity-assemblies + with: + include-perf: "true" + target: "${{ matrix.test-mode }}" + + - name: Validate Unity license secrets + uses: ./.github/actions/validate-unity-license + env: + UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} + UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} + UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} + + # Skip provisioning, the org lock, and the run when the compute step + # resolved an empty assembly list. Mirrors unity-tests.yml. The benchmark + # list is structurally non-empty, so this is defense-in-depth. + - name: Provision Unity Editor + if: ${{ steps.compute.outputs.is-empty != 'true' }} + timeout-minutes: 180 + shell: pwsh + run: | + $artifactsPath = '.artifacts/unity/benchmarks/${{ matrix.unity-version }}-${{ matrix.test-mode }}' + $diagnosticsPath = Join-Path $artifactsPath 'provisioning' + $diagnosticsFile = Join-Path $diagnosticsPath 'ensure-editor-summary.json' + New-Item -ItemType Directory -Force -Path $diagnosticsPath | Out-Null + $editor = ./scripts/unity/ensure-editor.ps1 ` + -UnityVersion '${{ matrix.unity-version }}' ` + -CiManagedOnly ` + -RequireHealthyExisting ` + -ProvisioningProfile EditorOnly ` + -DiagnosticsPath $diagnosticsFile + "UNITY_EDITOR_PATH=$editor" | Out-File -FilePath $env:GITHUB_ENV -Append + + - name: Upload Unity provisioning diagnostics + if: always() + uses: actions/upload-artifact@v7 + with: + name: unity-benchmark-provisioning-${{ matrix.unity-version }}-${{ matrix.test-mode }} + path: .artifacts/unity/benchmarks/${{ matrix.unity-version }}-${{ matrix.test-mode }}/provisioning + if-no-files-found: warn + retention-days: 90 + + - name: Acquire organization Unity lock + if: ${{ steps.compute.outputs.is-empty != 'true' }} + uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/acquire-build-lock@v1 + with: + lock-name: wallstop-organization-builds + holder-id-suffix: ${{ matrix.unity-version }}-${{ matrix.test-mode }} + timeout-minutes: "300" + env: + BUILD_LOCK_TOKEN: ${{ secrets.ORG_BUILD_LOCK_TOKEN }} + + - name: Run Unity Test Runner + id: run_tests + if: ${{ steps.compute.outputs.is-empty != 'true' }} + timeout-minutes: 120 + shell: pwsh + env: + UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} + UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} + UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} + UNITY_ACCELERATOR_ENDPOINT: ${{ secrets.UNITY_ACCELERATOR_ENDPOINT }} + # Run ONLY the perf/stress categories in this dedicated perf job (the + # inverse of unity-tests.yml, which excludes them). + UH_UNITY_TEST_CATEGORY: "Performance;Stress" + UH_PERF_COMMIT: ${{ github.sha }} + run: | + # Release (not Debug) code optimization for the editmode/playmode perf + # legs, mirroring the repo-wide Unity Release-mode contract so the + # weekly coverage run measures the same optimized hot path the docs do. + ./scripts/unity/run-ci-tests.ps1 ` + -UnityVersion '${{ matrix.unity-version }}' ` + -TestMode '${{ matrix.test-mode }}' ` + -AssemblyNames $env:UH_TEST_ASSEMBLIES ` + -ArtifactsPath '.artifacts/unity/benchmarks/${{ matrix.unity-version }}-${{ matrix.test-mode }}' ` + -ReleaseCodeOptimization ` + -ReleasePlayerBuild + + # The Random distribution tests are [Category("Fast")] and run in the main + # PR suite at the REDUCED default sample count (Tests/Runtime/Random/ + # RandomTestBase.cs). This dedicated leg re-runs the Random assembly at the + # FULL statistical sample count so the thorough bias-detection coverage the + # fast default trades away is recovered weekly in CI. editmode-only (the + # Random tests are pure C# EditMode); runs inside the org-lock section. + # This is the ONLY place UH_RANDOM_SAMPLE_COUNT is consumed. + - name: Run Random suite at full sample count + id: run_random_thorough + if: ${{ matrix.test-mode == 'editmode' && steps.compute.outputs.is-empty != 'true' }} + timeout-minutes: 120 + shell: pwsh + env: + UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} + UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} + UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} + UNITY_ACCELERATOR_ENDPOINT: ${{ secrets.UNITY_ACCELERATOR_ENDPOINT }} + # No perf-category filter: run the Fast-tagged Random tests by assembly. + UH_UNITY_TEST_CATEGORY: "" + UH_RANDOM_SAMPLE_COUNT: "12750000" + run: | + ./scripts/unity/run-ci-tests.ps1 ` + -UnityVersion '${{ matrix.unity-version }}' ` + -TestMode 'editmode' ` + -AssemblyNames 'WallstopStudios.UnityHelpers.Tests.Runtime.Random' ` + -ArtifactsPath '.artifacts/unity/benchmarks-random/${{ matrix.unity-version }}' + + - name: Return Unity license + if: always() + uses: ./.github/actions/return-unity-license + env: + UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} + UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} + + - name: Release organization Unity lock + if: always() + uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/release-build-lock@v1 + with: + lock-name: wallstop-organization-builds + holder-id-suffix: ${{ matrix.unity-version }}-${{ matrix.test-mode }} + env: + BUILD_LOCK_TOKEN: ${{ secrets.ORG_BUILD_LOCK_TOKEN }} + + - name: Dump Unity log tail on failure or cancellation + if: ${{ failure() || cancelled() }} + uses: ./.github/actions/dump-unity-log-tail + with: + results-dir: .artifacts/unity/benchmarks/${{ matrix.unity-version }}-${{ matrix.test-mode }} + label: Benchmarks ${{ matrix.unity-version }} ${{ matrix.test-mode }} + + - name: Verify tests actually ran + if: >- + ${{ + !cancelled() && + steps.compute.outcome == 'success' && + (steps.compute.outputs.is-empty == 'true' || steps.run_tests.outcome != 'skipped') + }} + uses: ./.github/actions/verify-unity-results + with: + results-dir: .artifacts/unity/benchmarks/${{ matrix.unity-version }}-${{ matrix.test-mode }} + label: Benchmarks ${{ matrix.unity-version }} ${{ matrix.test-mode }} + expected-empty: ${{ steps.compute.outputs.is-empty }} + + # Stage the raw NUnit results for this leg into a deterministic path so + # the downstream commit job can collect every leg's results via + # download-artifact and commit them under perf-results/. We commit + # the raw results.xml (the source of truth) rather than a hand-formatted + # doc; unity-helpers has no NUnit->markdown perf-doc generator yet (the + # human-authored docs under docs/performance/*.md stay hand-maintained). + # TODO(unity-helpers): if a perf-doc generator is added, render the + # polished table here instead of copying raw XML. + - name: Stage perf results for commit + if: ${{ !cancelled() && steps.run_tests.outcome == 'success' }} + shell: pwsh + run: | + $src = '.artifacts/unity/benchmarks/${{ matrix.unity-version }}-${{ matrix.test-mode }}/results.xml' + $stageDir = 'perf-staging' + New-Item -ItemType Directory -Force -Path $stageDir | Out-Null + if (Test-Path -LiteralPath $src -PathType Leaf) { + Copy-Item -LiteralPath $src -Destination (Join-Path $stageDir 'results-${{ matrix.unity-version }}-${{ matrix.test-mode }}.xml') -Force + Write-Host "::notice::Staged perf results for ${{ matrix.unity-version }} ${{ matrix.test-mode }}." + } else { + Write-Host "::warning::No results.xml to stage for ${{ matrix.unity-version }} ${{ matrix.test-mode }}." + } + + - name: Upload staged perf results + if: ${{ !cancelled() && steps.run_tests.outcome == 'success' }} + uses: actions/upload-artifact@v7 + with: + name: perf-results-${{ matrix.unity-version }}-${{ matrix.test-mode }} + path: perf-staging + if-no-files-found: warn + retention-days: 90 + + - name: Upload benchmark artifacts + if: always() + uses: actions/upload-artifact@v7 + with: + name: unity-benchmarks-${{ matrix.unity-version }}-${{ matrix.test-mode }} + path: .artifacts/unity/benchmarks/${{ matrix.unity-version }}-${{ matrix.test-mode }} + if-no-files-found: warn + retention-days: 90 + + commit-perf-results: + name: Commit refreshed perf results + needs: benchmarks + # schedule + workflow_dispatch only ever run on the canonical repo (scheduled + # runs fire only from the default branch; a dispatch requires write access), + # so no repo-slug guard is needed (avoids hardcoding a slug that breaks after + # an org transfer). Run even if some benchmark legs failed (!cancelled, not + # success()) so a partial result set is still captured; if there are no + # artifacts at all, git-auto-commit-action is a no-op. + if: ${{ !cancelled() }} + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + # Persist credentials so git-auto-commit-action can push. + persist-credentials: true + + - name: Download staged perf results + uses: actions/download-artifact@v8 + with: + pattern: perf-results-* + path: perf-download + merge-multiple: true + + - name: Assemble perf-results + shell: bash + run: | + set -euo pipefail + # Commit raw NUnit results to a TOP-LEVEL perf-results/ dir (NOT under + # docs/ or any other Unity-asset source root), so they need no Unity + # .meta companions and fall outside the prettier/markdown/cspell scan + # globs (none scan .xml). Committing under docs/** would break + # meta-file-lint (every tracked docs file/dir requires a .meta) on the + # next push; perf-results/ sidesteps that entirely. + dest="perf-results" + mkdir -p "${dest}" + if [ -d perf-download ] && [ -n "$(ls -A perf-download 2>/dev/null || true)" ]; then + find perf-download -type f -name '*.xml' -exec cp -f {} "${dest}/" \; + echo "Assembled $(find "${dest}" -name '*.xml' | wc -l) result file(s) under ${dest}." + else + echo "::notice::No staged perf results were downloaded; nothing to commit." + fi + + # git-auto-commit-action is a no-op when there is no diff, so a run that + # produced no new results does not create an empty commit. + # + # TODO(unity-helpers): if the default branch is protected such that the + # built-in GITHUB_TOKEN cannot push (branch protection / required reviews), + # this direct commit will be REJECTED. DxMessaging's perf-numbers.yml hit + # exactly this and switched to a GitHub App installation token + # (actions/create-github-app-token@v3) that opens an auto-merge PR instead. + # If that happens here, provision an App token and mirror that rationale; + # the default below is the simplest path that works on an UNprotected + # default branch. + - name: Commit perf results + uses: stefanzweifel/git-auto-commit-action@v7.1.0 + with: + commit_message: "chore(perf): refresh CI perf results [skip ci]" + file_pattern: perf-results/*.xml + commit_user_name: "github-actions[bot]" + commit_user_email: "41898282+github-actions[bot]@users.noreply.github.com" + commit_author: "github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>" diff --git a/.github/workflows/unity-tests.yml b/.github/workflows/unity-tests.yml new file mode 100644 index 000000000..6a1d710a1 --- /dev/null +++ b/.github/workflows/unity-tests.yml @@ -0,0 +1,434 @@ +name: Unity Tests + +# cspell:ignore lfs WSL + +on: + pull_request: + branches: + - master + - main + push: + branches: + - master + - main + schedule: + - cron: "17 8 * * 1" + workflow_dispatch: + inputs: + unity-version: + description: "Pin a single Unity version. Empty = full matrix." + required: false + default: "" + type: string + test-mode: + description: "Pin a single test mode." + required: false + default: "all" + type: choice + options: + - all + - editmode + - playmode + - standalone + +permissions: + contents: read + checks: write + +jobs: + matrix-config: + name: Resolve Unity test matrix + runs-on: ubuntu-latest + timeout-minutes: 2 + outputs: + unity-versions: ${{ steps.resolve.outputs.unity-versions }} + test-modes: ${{ steps.resolve.outputs.test-modes }} + has-required-secrets: ${{ steps.check-secrets.outputs.has-secrets }} + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + persist-credentials: false + + # NOTE: DxMessaging had a "ci-owned docs-only PR" detection here that + # skipped the licensed matrix when a PR touched ONLY its perf-numbers doc + # + baseline. unity-helpers has no such CI-owned perf-doc auto-commit on + # the PR path (the unity-benchmarks workflow commits perf results from a + # SCHEDULED run, not a PR), so that detection is intentionally DROPPED for + # simplicity. If a perf-doc auto-update PR flow is added later, repoint a + # docs-only gate at docs/performance/ rather than DxMessaging's + # docs/architecture/performance.md path. + - name: Resolve dispatch overrides + id: resolve + env: + INPUT_UNITY_VERSION: ${{ inputs.unity-version }} + INPUT_TEST_MODE: ${{ inputs.test-mode }} + run: | + set -euo pipefail + if [ -n "${INPUT_UNITY_VERSION:-}" ]; then + versions="[\"${INPUT_UNITY_VERSION}\"]" + else + versions="$(jq -c '.all' .github/unity-versions.json)" + fi + if [ -n "${INPUT_TEST_MODE:-}" ] && [ "${INPUT_TEST_MODE}" != "all" ]; then + modes="[\"${INPUT_TEST_MODE}\"]" + else + modes='["editmode","playmode","standalone"]' + fi + { + echo "unity-versions=${versions}" + echo "test-modes=${modes}" + } >> "${GITHUB_OUTPUT}" + + - name: Check for required Unity license secrets + id: check-secrets + env: + UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} + UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} + UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} + ORG_BUILD_LOCK_TOKEN: ${{ secrets.ORG_BUILD_LOCK_TOKEN }} + run: | + set -euo pipefail + if [ -n "${UNITY_SERIAL:-}" ] && \ + [ -n "${UNITY_EMAIL:-}" ] && \ + [ -n "${UNITY_PASSWORD:-}" ] && \ + [ -n "${ORG_BUILD_LOCK_TOKEN:-}" ]; then + echo "has-secrets=true" >> "${GITHUB_OUTPUT}" + else + echo "has-secrets=false" >> "${GITHUB_OUTPUT}" + fi + + runner-preflight: + name: Self-hosted runner access preflight + # Runs on ubuntu-latest BEFORE any self-hosted matrix entry tries to + # queue. This converts the "stuck forever" failure mode that motivated + # docs/runbooks/unity-runners-after-transfer.md into a fast, + # clearly-explained failure. The runbook explains the post-transfer + # runner-group ACL pitfall and the resolution paths. + # + # CRITICAL CONTRACT: this preflight must NEVER make Unity CI more + # broken than the no-preflight baseline. If the token cannot list + # either the org-level OR repo-level runner inventory (403/404 from + # both), the preflight emits a ::warning:: and exits 0 (soft pass). + # Only when we can prove the inventory is wrong do we fail hard. + if: >- + ${{ + (github.event_name != 'pull_request' || + github.event.pull_request.head.repo.full_name == github.repository) && + (github.event_name != 'push' || github.ref_protected) + }} + runs-on: ubuntu-latest + timeout-minutes: 3 + permissions: + actions: read + # NOTE: `administration: read` is NOT a valid permissions key for + # workflow GITHUB_TOKEN (per actionlint and the GitHub docs schema). + # Listing self-hosted runners requires admin:org (org-scope) OR a + # fine-grained PAT with repo "Administration: read" -- neither of + # which the default GITHUB_TOKEN can carry. The soft-pass path in + # the run script below is the supported coverage when the token + # is unscoped. See docs/runbooks/unity-runners-after-transfer.md. + concurrency: + group: ${{ github.workflow }}-runner-preflight-${{ github.ref }} + cancel-in-progress: true + steps: + - name: Probe self-hosted runner availability + env: + GH_TOKEN: ${{ secrets.RUNNER_AUDIT_PAT || secrets.GITHUB_TOKEN }} + REQUIRED_LABELS: "self-hosted,Windows,RAM-64GB" + # Repeated literal of the labels list. Keep in sync with unity-tests + # `runs-on:` declaration below; this preflight must run before any + # self-hosted matrix in this workflow. + # gh api --paginate | jq -s pattern mirrors stuck-job-watchdog.yml; + # see that file's comments for why `jq -s` is required. + run: | + set -euo pipefail + echo "::group::Runner-access preflight" + echo "Required labels: ${REQUIRED_LABELS}" + IFS=',' read -r -a required <<< "${REQUIRED_LABELS}" + runners_json="$(mktemp)" + runners_scope="" + # 1) Try the ORG-scoped endpoint first. Self-hosted runners live + # at org scope in this repo (group "Default" in Ambiguous-Interactive). + # The default GITHUB_TOKEN often lacks admin:org and will 403 here. + if gh api --paginate \ + "orgs/${GITHUB_REPOSITORY_OWNER}/actions/runners?per_page=100" \ + 2>/dev/null \ + | jq -s '[.[] | (.runners // [])[]]' > "${runners_json}" 2>/dev/null; then + runners_scope="org" + else + # 2) Fall back to the REPO-scoped endpoint. Requires + # administration:read; less common to be granted but possible. + if gh api --paginate \ + "repos/${GITHUB_REPOSITORY}/actions/runners?per_page=100" \ + 2>/dev/null \ + | jq -s '[.[] | (.runners // [])[]]' > "${runners_json}" 2>/dev/null; then + runners_scope="repo" + fi + fi + if [ -z "${runners_scope}" ]; then + # 3) Soft pass: both endpoints failed (almost certainly the + # default GITHUB_TOKEN lacks scope). DO NOT fail the build -- + # that would make Unity CI strictly more broken than baseline. + # The watchdog + manual unstick workflows still cover the + # actually-stuck case. + org_url="orgs/${GITHUB_REPOSITORY_OWNER}/actions/runners" + repo_url="repos/${GITHUB_REPOSITORY}/actions/runners" + { + echo "::warning::Runner inventory unavailable from both ${org_url}" + echo "::warning::and ${repo_url} (likely 403: GITHUB_TOKEN lacks admin:org" + echo "::warning::or administration:read)." + echo "::warning::See docs/runbooks/unity-runners-after-transfer.md" + echo "::warning::for how to plumb a RUNNER_AUDIT_PAT secret to upgrade" + echo "::warning::this soft-pass to a hard-pass." + } + echo "Soft pass: skipping runner inventory check." + echo "::endgroup::" + exit 0 + fi + echo "Runner inventory scope: ${runners_scope}" + total="$(jq 'length' < "${runners_json}")" + echo "Visible runners (${runners_scope} scope): ${total}" + # Find at least one ONLINE runner with all required labels. + matched="$(jq --argjson labels "$(printf '%s\n' "${required[@]}" | jq -R . | jq -s .)" ' + [ .[] + | select(.status == "online") + | select(($labels | all(. as $l | (.labels // []) | map(.name) | index($l) | type == "number"))) + ] | length + ' < "${runners_json}")" + echo "Runners satisfying ${REQUIRED_LABELS}: ${matched}" + if [ "${matched}" -lt 1 ]; then + { + echo "::error::No online self-hosted runner satisfies the required labels (${REQUIRED_LABELS})." + echo "The Unity matrix would queue forever." + echo "See docs/runbooks/unity-runners-after-transfer.md for the post-transfer runner-group ACL fix." + } + jq '[ .[] | {name, status, busy, labels: [(.labels // [])[].name]} ]' \ + < "${runners_json}" + exit 1 + fi + echo "::endgroup::" + + unity-tests: + name: Unity ${{ matrix.unity-version }} ${{ matrix.test-mode }} + needs: + - matrix-config + - runner-preflight + if: >- + ${{ + (github.event_name != 'pull_request' || + github.event.pull_request.head.repo.full_name == github.repository) && + (github.event_name != 'push' || github.ref_protected) && + needs.matrix-config.outputs.has-required-secrets == 'true' + }} + runs-on: [self-hosted, Windows, RAM-64GB] + timeout-minutes: 660 + strategy: + fail-fast: false + max-parallel: 1 + matrix: + unity-version: ${{ fromJSON(needs.matrix-config.outputs.unity-versions) }} + test-mode: ${{ fromJSON(needs.matrix-config.outputs.test-modes) }} + steps: + - name: Enable Git long paths + shell: pwsh + env: + GIT_CONFIG_GLOBAL: ${{ runner.temp }}/uh-gitconfig + run: git config --global core.longpaths true + + - name: Checkout + uses: actions/checkout@v6 + env: + GIT_CONFIG_GLOBAL: ${{ runner.temp }}/uh-gitconfig + with: + lfs: true + persist-credentials: false + + - name: Print runner diagnostics + # Composite action runs PowerShell internally; `shell: bash` on a + # self-hosted Windows runner can resolve to the WSL stub at + # C:\Windows\System32\bash.exe and fail with "Windows Subsystem + # for Linux has no installed distributions." Never use plain + # `shell: bash` on self-hosted Windows runners. + uses: ./.github/actions/print-self-hosted-runner-diagnostics + with: + matrix-note: "default (organization lock wraps the Unity section)" + + - name: Cache Unity Library and package caches + uses: actions/cache@v5 + env: + PACKAGE_HASH: >- + ${{ hashFiles( + 'package.json', + 'Runtime/**', + 'Editor/**', + 'Tests/**', + 'scripts/unity/run-ci-tests.ps1', + 'scripts/unity/lib/asmdef-discovery.js', + '.github/actions/compute-unity-assemblies/action.yml' + ) }} + with: + path: | + .artifacts/unity/projects/${{ matrix.unity-version }}-${{ matrix.test-mode }}/Library + .artifacts/unity/cache/${{ matrix.unity-version }} + key: Library-${{ runner.os }}-${{ runner.arch }}-${{ matrix.unity-version }}-${{ matrix.test-mode }}-${{ env.PACKAGE_HASH }} + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: "22.18.0" + + # Single source of truth for the assembly include list: the same + # asmdef-discovery.js module the local discovery uses. Extracted into a + # composite action so all Unity workflows share one implementation. + # shell: pwsh is enforced inside the composite. + - name: Compute test assembly list + id: compute + uses: ./.github/actions/compute-unity-assemblies + with: + target: "${{ matrix.test-mode }}" + runtime-only: "${{ matrix.test-mode == 'standalone' && 'true' || 'false' }}" + + - name: Validate Unity license secrets + uses: ./.github/actions/validate-unity-license + env: + UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} + UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} + UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} + + # Skip provisioning, the org lock, and the run when no unity-helpers test + # assembly matched this target (compute step resolved an empty list). The + # verify step runs only after compute succeeds and is told the run was an + # expected skip via the expected-empty input. This never triggers for the + # current asmdef set; it is the robustness path for a target with no + # matching assemblies. If checkout/cache/setup fails before compute, let + # that setup failure stand on its own instead of adding a misleading + # "tests did not run" annotation for a Unity run that never started. + - name: Provision Unity Editor + if: ${{ steps.compute.outputs.is-empty != 'true' }} + timeout-minutes: 180 + shell: pwsh + run: | + $artifactsPath = '.artifacts/unity/${{ matrix.unity-version }}-${{ matrix.test-mode }}' + $diagnosticsPath = Join-Path $artifactsPath 'provisioning' + $diagnosticsFile = Join-Path $diagnosticsPath 'ensure-editor-summary.json' + New-Item -ItemType Directory -Force -Path $diagnosticsPath | Out-Null + $provisioningProfile = if ('${{ matrix.test-mode }}' -eq 'standalone') { + 'StandaloneWindowsIl2Cpp' + } else { + 'EditorOnly' + } + $editor = ./scripts/unity/ensure-editor.ps1 ` + -UnityVersion '${{ matrix.unity-version }}' ` + -CiManagedOnly ` + -RequireHealthyExisting ` + -ProvisioningProfile $provisioningProfile ` + -DiagnosticsPath $diagnosticsFile + "UNITY_EDITOR_PATH=$editor" | Out-File -FilePath $env:GITHUB_ENV -Append + + - name: Upload Unity provisioning diagnostics + if: always() + uses: actions/upload-artifact@v7 + with: + name: unity-provisioning-${{ matrix.unity-version }}-${{ matrix.test-mode }} + path: .artifacts/unity/${{ matrix.unity-version }}-${{ matrix.test-mode }}/provisioning + if-no-files-found: warn + retention-days: 14 + + - name: Acquire organization Unity lock + if: ${{ steps.compute.outputs.is-empty != 'true' }} + uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/acquire-build-lock@v1 + with: + lock-name: wallstop-organization-builds + holder-id-suffix: ${{ matrix.unity-version }}-${{ matrix.test-mode }} + timeout-minutes: "300" + env: + BUILD_LOCK_TOKEN: ${{ secrets.ORG_BUILD_LOCK_TOKEN }} + + - name: Run Unity Test Runner + id: run_tests + if: ${{ steps.compute.outputs.is-empty != 'true' }} + # 150: the standalone leg builds a NON-development IL2CPP player with + # the Release C++ configuration, and a run-ci-tests.ps1 change rotates + # the Library cache key, so first runs build cold. The editmode/playmode + # legs finish far below this; stays well under the job timeout (660) so + # the step clock fires first and releases the shared Unity seat. + timeout-minutes: 150 + shell: pwsh + env: + UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} + UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} + UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} + UNITY_ACCELERATOR_ENDPOINT: ${{ secrets.UNITY_ACCELERATOR_ENDPOINT }} + UH_UNITY_TEST_CATEGORY: "!Performance;!Stress" + run: | + ./scripts/unity/run-ci-tests.ps1 ` + -UnityVersion '${{ matrix.unity-version }}' ` + -TestMode '${{ matrix.test-mode }}' ` + -AssemblyNames $env:UH_TEST_ASSEMBLIES ` + -ArtifactsPath '.artifacts/unity/${{ matrix.unity-version }}-${{ matrix.test-mode }}' ` + -ReleaseCodeOptimization ` + -ReleasePlayerBuild + + - name: Return Unity license + if: always() + uses: ./.github/actions/return-unity-license + env: + UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} + UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} + + - name: Release organization Unity lock + if: always() + uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/release-build-lock@v1 + with: + lock-name: wallstop-organization-builds + holder-id-suffix: ${{ matrix.unity-version }}-${{ matrix.test-mode }} + env: + BUILD_LOCK_TOKEN: ${{ secrets.ORG_BUILD_LOCK_TOKEN }} + + # CANCELLATION + TIMEOUT DIAGNOSTIC: when Unity hangs in csc the runner + # step times out (or the operator cancels), and the verify step below + # skips -- so its catastrophic-pattern scan never fires either. This step + # covers both failure() AND cancelled() so the operator has SOMETHING to + # look at in the GitHub summary (the tail of unity.log + any catastrophic + # patterns the partial log happened to surface). Best-effort: never fails + # the job, no-ops when unity.log doesn't exist (cancellation before Unity + # launched). + - name: Dump Unity log tail on failure or cancellation + if: ${{ failure() || cancelled() }} + uses: ./.github/actions/dump-unity-log-tail + with: + results-dir: .artifacts/unity/${{ matrix.unity-version }}-${{ matrix.test-mode }} + label: Unity ${{ matrix.unity-version }} ${{ matrix.test-mode }} + + # CLASS GUARD: Unity can fail before NUnit writes results. The shared + # composite fails unless a results XML exists AND reports total > 0. + # Skip on cancellation so a user-cancelled run does not emit the + # generic "tests did not run" annotation -- the cancellation itself + # is the explanation. Also skip if setup/provisioning/lock work failed + # before the Unity run step attempted; that earlier failure is then the + # actionable signal. The guard still runs for intentional empty targets, + # after a Unity-run failure (to surface "tests did not run" / + # catastrophic-compile diagnostics), and on success (to confirm a real + # result XML exists). + - name: Verify tests actually ran + if: >- + ${{ + !cancelled() && + steps.compute.outcome == 'success' && + (steps.compute.outputs.is-empty == 'true' || steps.run_tests.outcome != 'skipped') + }} + uses: ./.github/actions/verify-unity-results + with: + results-dir: .artifacts/unity/${{ matrix.unity-version }}-${{ matrix.test-mode }} + label: Unity ${{ matrix.unity-version }} ${{ matrix.test-mode }} + expected-empty: ${{ steps.compute.outputs.is-empty }} + + - name: Upload Unity test artifacts + if: always() + uses: actions/upload-artifact@v7 + with: + name: unity-${{ matrix.unity-version }}-${{ matrix.test-mode }} + path: .artifacts/unity/${{ matrix.unity-version }}-${{ matrix.test-mode }} + if-no-files-found: warn + retention-days: 14 diff --git a/.github/workflows/unstick-run.yml b/.github/workflows/unstick-run.yml new file mode 100644 index 000000000..8c55cae85 --- /dev/null +++ b/.github/workflows/unstick-run.yml @@ -0,0 +1,330 @@ +name: Unstick Run + +# Manual one-click recovery for a single GitHub Actions run stuck on the +# known self-hosted dispatcher bug +# (https://github.com/orgs/community/discussions/186811). +# +# This workflow is the operator-driven sibling of stuck-job-watchdog.yml: +# the watchdog auto-scans every 5 minutes from the default branch, while +# this workflow targets ONE explicit run id on demand. Use this when: +# * The watchdog is not yet on the default branch (GitHub `schedule:` +# cron triggers fire only from the repo default branch, so a watchdog +# committed to a feature branch is inactive until merged to master). +# * You want immediate recovery and do not want to wait for the next +# cron tick or the queue-age threshold. +# +# Behavior mirrors the watchdog's per-run logic: +# * Confirm the run exists, is `status: queued`, and is older than +# MIN_AGE_SECONDS=30 (guards against accidental cancellation of fresh +# runs). +# * Honor the same workflow-file exclusion list (release.yml + +# `vars.WATCHDOG_EXCLUDED_WORKFLOWS`) unless `bypass_exclusion=true`. +# * `gh run cancel` the run. +# * If `force_redispatch=true` AND the run was push/schedule/ +# workflow_dispatch on a branch AND the workflow file declares +# `workflow_dispatch:`, REST-redispatch via +# `POST repos/{owner}/{repo}/actions/workflows/{workflow_id}/dispatches`. +# * Otherwise, surface a step-summary line instructing the operator to +# click "Re-run all jobs" in the UI (the only safe recovery path for +# pull_request runs). +# +# This workflow does NOT touch the watchdog state branch and does NOT +# count against the watchdog's per-run cancel cap. + +on: + workflow_dispatch: + inputs: + run_id: + description: "GitHub Actions run id to recover (digits only)." + required: true + type: string + force_redispatch: + description: "Attempt REST re-dispatch after cancel (push/schedule/workflow_dispatch on a branch only)." + required: false + type: boolean + default: false + bypass_exclusion: + description: "Operate on a run whose workflow file is in the exclusion list (e.g., release.yml). Use deliberately." + required: false + type: boolean + default: false + +concurrency: + group: unstick-run-${{ inputs.run_id }} + cancel-in-progress: false + +permissions: + actions: write + contents: read + +jobs: + unstick: + name: Unstick a single dispatcher-stuck run + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Validate, cancel, and optionally re-dispatch + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + INPUT_RUN_ID: ${{ inputs.run_id }} + INPUT_FORCE_REDISPATCH: ${{ inputs.force_redispatch }} + INPUT_BYPASS_EXCLUSION: ${{ inputs.bypass_exclusion }} + MIN_AGE_SECONDS: "30" + DEFAULT_EXCLUDED_WORKFLOWS: "release.yml" + EXTRA_EXCLUDED_WORKFLOWS: ${{ vars.WATCHDOG_EXCLUDED_WORKFLOWS }} + run: | + set -euo pipefail + + summary_file="$(mktemp)" + : > "${summary_file}" + log_summary() { + printf '%s\n' "$1" | tee -a "${summary_file}" + } + + flush_summary_and_exit() { + local code="${1:-0}" + { + echo "## Unstick-run summary" + cat "${summary_file}" + } >> "${GITHUB_STEP_SUMMARY}" + exit "${code}" + } + + run_id="${INPUT_RUN_ID}" + force_redispatch="${INPUT_FORCE_REDISPATCH:-false}" + bypass_exclusion="${INPUT_BYPASS_EXCLUSION:-false}" + + log_summary "## Unstick-run audit ($(date -u +'%Y-%m-%dT%H:%M:%SZ'))" + log_summary "Repo: ${REPO}" + log_summary "Requested run id: ${run_id}" + log_summary "force_redispatch: ${force_redispatch}" + log_summary "bypass_exclusion: ${bypass_exclusion}" + + # -------------------------------------------------------------- + # 1. Validate run_id is a positive integer (string input from + # workflow_dispatch is free-form; reject anything else). + # -------------------------------------------------------------- + if ! [[ "${run_id}" =~ ^[0-9]+$ ]]; then + log_summary "ERROR: run_id '${run_id}' is not a positive integer." + flush_summary_and_exit 1 + fi + + # -------------------------------------------------------------- + # 1b. Defense-in-depth: refuse to act on this workflow's own + # run id. The watchdog has the same SELF_RUN_ID guard for + # the same reason -- a self-cancel mid-run produces a + # confusing partial step-summary and leaves the operator + # uncertain whether the target run was actually touched. + # -------------------------------------------------------------- + if [[ "${run_id}" == "${GITHUB_RUN_ID}" ]]; then + log_summary "ERROR: cannot unstick this workflow's own run (run_id == GITHUB_RUN_ID == ${GITHUB_RUN_ID})." + flush_summary_and_exit 1 + fi + + # -------------------------------------------------------------- + # 2. Fetch the run. 404 => either the id is bogus or it belongs + # to a different repository (gh api scopes the call to REPO). + # -------------------------------------------------------------- + run_json="$(mktemp)" + if ! gh api "repos/${REPO}/actions/runs/${run_id}" > "${run_json}" 2>/dev/null; then + log_summary "ERROR: run ${run_id} not found in ${REPO} (404). Confirm the run id and that it belongs to this repository." + flush_summary_and_exit 1 + fi + + run_status="$(jq -r '.status // ""' < "${run_json}")" + run_event="$(jq -r '.event // ""' < "${run_json}")" + run_head_branch="$(jq -r '.head_branch // ""' < "${run_json}")" + run_workflow_id="$(jq -r '.workflow_id // 0' < "${run_json}")" + run_path="$(jq -r '.path // ""' < "${run_json}")" + run_name="$(jq -r '.name // ""' < "${run_json}")" + run_html_url="$(jq -r '.html_url // ""' < "${run_json}")" + run_created_at="$(jq -r '.created_at // ""' < "${run_json}")" + run_path_base="${run_path##*/}" + + log_summary "Run name: ${run_name}" + log_summary "Workflow file: ${run_path_base}" + log_summary "Status (pre-cancel): ${run_status}" + log_summary "Event: ${run_event}" + log_summary "Head branch: ${run_head_branch:-}" + log_summary "URL: ${run_html_url}" + + # -------------------------------------------------------------- + # 3. Cheap safety guard: only operate on queued runs. + # -------------------------------------------------------------- + if [[ "${run_status}" != "queued" ]]; then + log_summary "Run is not currently queued (status: ${run_status}). Skipping cancel; nothing to recover." + flush_summary_and_exit 0 + fi + + # -------------------------------------------------------------- + # 4. Cheap safety guard: ensure the run is at least + # MIN_AGE_SECONDS old so a manual misclick does not nuke a + # fresh, legitimately-queued run. + # -------------------------------------------------------------- + if [[ -z "${run_created_at}" ]]; then + log_summary "ERROR: run ${run_id} has no created_at timestamp; refusing to act." + flush_summary_and_exit 1 + fi + created_epoch="$(date -u -d "${run_created_at}" +%s 2>/dev/null || echo 0)" + if [[ "${created_epoch}" -eq 0 ]]; then + log_summary "ERROR: could not parse created_at '${run_created_at}'." + flush_summary_and_exit 1 + fi + now_epoch="$(date -u +%s)" + age=$(( now_epoch - created_epoch )) + log_summary "Run age: ${age}s (min: ${MIN_AGE_SECONDS}s)" + if (( age < MIN_AGE_SECONDS )); then + log_summary "Run is younger than MIN_AGE_SECONDS=${MIN_AGE_SECONDS}; skipping. Re-invoke after the run has had time to dispatch normally." + flush_summary_and_exit 0 + fi + + # -------------------------------------------------------------- + # 5. Workflow-file exclusion check (release.yml + repo variable). + # NOTE: This exclusion-list construction is duplicated from + # stuck-job-watchdog.yml. Kept intentionally duplicated so each + # workflow file is self-contained; if you change the rules here, + # update the watchdog too. + # -------------------------------------------------------------- + declare -A EXCLUDED_BY_FILE=() + for wf in ${DEFAULT_EXCLUDED_WORKFLOWS} ${EXTRA_EXCLUDED_WORKFLOWS:-}; do + [[ -z "${wf}" ]] && continue + base="${wf##*/}" + EXCLUDED_BY_FILE["${base}"]=1 + done + excluded_list="" + for k in "${!EXCLUDED_BY_FILE[@]}"; do + excluded_list+="${k} " + done + log_summary "Excluded workflows: ${excluded_list:-}" + + if [[ -n "${EXCLUDED_BY_FILE[${run_path_base}]+x}" ]]; then + if [[ "${bypass_exclusion}" != "true" ]]; then + log_summary "Workflow '${run_path_base}' is in the exclusion list; pass bypass_exclusion=true to operate on it. Skipping." + flush_summary_and_exit 0 + fi + log_summary "Workflow '${run_path_base}' is excluded but bypass_exclusion=true; proceeding." + fi + + # -------------------------------------------------------------- + # 6. Cancel the run. From here on, this is the recovery action. + # -------------------------------------------------------------- + log_summary "Cancelling run ${run_id}..." + # TOCTOU note: if the run transitions out of `queued` between status check and + # cancel, gh run cancel returns non-zero. We let the step fail loudly so the + # operator notices; watchdog has the same intentional pattern. + if ! gh run cancel "${run_id}" --repo "${REPO}" 2>&1 | tee -a "${summary_file}"; then + log_summary "ERROR: 'gh run cancel ${run_id}' failed." + flush_summary_and_exit 1 + fi + log_summary "Cancel issued." + + # Re-fetch status post-cancel for the summary. If the re-fetch + # fails (transient API hiccup, run vanished), surface that + # explicitly rather than logging the stale pre-cancel status as + # though it were the post-cancel value. + post_status="unknown (status re-fetch failed)" + if gh api "repos/${REPO}/actions/runs/${run_id}" > "${run_json}" 2>/dev/null; then + post_status="$(jq -r '.status // ""' < "${run_json}")" + fi + log_summary "Status (post-cancel): ${post_status}" + + # -------------------------------------------------------------- + # 7. Optional REST re-dispatch. + # -------------------------------------------------------------- + redispatched="no" + redispatch_reason="" + if [[ "${force_redispatch}" == "true" ]]; then + case "${run_event}" in + push|schedule|workflow_dispatch) + if [[ -z "${run_head_branch}" ]]; then + redispatch_reason="event=${run_event} but head_branch is empty (likely a tag); cannot REST-dispatch." + else + # Parse the workflow file to confirm it declares + # `workflow_dispatch:` (required by the dispatches API). + workflow_file_raw="$(mktemp)" + has_dispatch=0 + if [[ -n "${run_path}" ]]; then + set +e + gh api "repos/${REPO}/contents/${run_path}" --jq '.content' 2>/dev/null \ + | base64 -d > "${workflow_file_raw}" 2>/dev/null + contents_rc=$? + set -e + if [[ "${contents_rc}" -eq 0 && -s "${workflow_file_raw}" ]]; then + set +e + grep -Eq '^[[:space:]]*workflow_dispatch:' "${workflow_file_raw}" + grep_rc=$? + set -e + if [[ "${grep_rc}" -eq 0 ]]; then + has_dispatch=1 + fi + fi + fi + + if [[ "${has_dispatch}" -eq 1 ]]; then + log_summary "Re-dispatching workflow ${run_workflow_id} on ref '${run_head_branch}'..." + if gh api -X POST \ + "repos/${REPO}/actions/workflows/${run_workflow_id}/dispatches" \ + -f "ref=${run_head_branch}" 2>&1 | tee -a "${summary_file}"; then + redispatched="yes" + else + redispatch_reason="REST dispatches call failed (see log above)." + fi + else + redispatch_reason="workflow file '${run_path_base}' does not declare 'workflow_dispatch:' (or could not be read)." + fi + fi + ;; + *) + redispatch_reason="event='${run_event}' has no safe REST re-dispatch path (only push/schedule/workflow_dispatch on a branch are supported). Operator must use the UI." + ;; + esac + else + redispatch_reason="force_redispatch=false (default)." + fi + + if [[ "${redispatched}" == "yes" ]]; then + log_summary "Re-dispatch: succeeded." + else + log_summary "Re-dispatch: skipped (${redispatch_reason})" + fi + + # -------------------------------------------------------------- + # 8. Operator next-step guidance. + # -------------------------------------------------------------- + if [[ "${redispatched}" == "yes" ]]; then + log_summary "Next step: the workflow has been re-dispatched. Watch ${run_html_url%/runs/*} for the new run." + else + case "${run_event}" in + pull_request|pull_request_target) + log_summary "Next step: open ${run_html_url} and click 'Re-run all jobs' (PR-triggered runs cannot be REST-dispatched)." + ;; + *) + # When force_redispatch=true was requested but the re-dispatch + # was skipped (event/branch/workflow_dispatch trigger guard), + # re-suggesting force_redispatch=true would just repeat the + # path the operator already tried. Branch the guidance. + if [[ "${force_redispatch}" == "true" ]]; then + log_summary "Next step: force_redispatch was attempted but skipped (see reason above). Click 'Re-run all jobs' on the run's UI page (${run_html_url}) to recover." + else + log_summary "Next step: open ${run_html_url} and click 'Re-run all jobs', OR re-invoke this workflow with force_redispatch=true if the event supports it." + fi + ;; + esac + fi + + # -------------------------------------------------------------- + # 9. Rate-limit guidance (this workflow has NO automatic cap). + # -------------------------------------------------------------- + # Unlike the watchdog (MAX_CANCELS_PER_DAY=2 per run-id via + # state branch), unstick-run.yml is operator-driven and relies on + # the implicit gate of a human clicking "Run workflow". That + # means rapid repeat invocations on the same run-id are not + # blocked here -- the operator must self-pace. GitHub's + # dispatcher state can take time to settle after a cancel, so a + # second attempt within seconds may race the previous one. + log_summary "Rate-limit note: unstick-run.yml has no automatic per-run cap (operator-driven)." + log_summary "If a single run id needs another recovery attempt, wait at least 5 minutes between invocations to let GitHub's dispatcher state settle." + + flush_summary_and_exit 0 diff --git a/docs/runbooks.meta b/docs/runbooks.meta new file mode 100644 index 000000000..194ce9ebc --- /dev/null +++ b/docs/runbooks.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: c23454ea89fd4653e768ee9ec20591b4 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/docs/runbooks/unity-runners-after-transfer.md b/docs/runbooks/unity-runners-after-transfer.md new file mode 100644 index 000000000..fa9c9431d --- /dev/null +++ b/docs/runbooks/unity-runners-after-transfer.md @@ -0,0 +1,174 @@ + + +# Unity Runners After Repository Transfer Runbook + +This runbook explains how to restore self-hosted Unity runner access after a repository is transferred between GitHub organizations (or when a freshly provisioned runner does not pick up queued Unity jobs). Keep execution notes local. Do not paste secrets, screenshots of organization settings, or other private account metadata into this file or any tracked follow-up. + +It is referenced by the `runner-preflight` job in `.github/workflows/unity-tests.yml`, `.github/workflows/unity-benchmarks.yml`, and `.github/workflows/runner-bootstrap.yml`, and by `scripts/unity/ensure-editor.ps1` and `.github/actions/print-self-hosted-runner-diagnostics`. + +## Symptom + +- A queued Unity workflow run (for example **Unity Tests** or **Unity Benchmarks**) stays queued indefinitely. +- The GitHub Actions UI shows the job waiting for a runner. There is no error, no warning, and the run never starts. +- The organization's self-hosted runners report Online and Idle in the GitHub UI, with labels that exactly match the workflow's `runs-on` request (`self-hosted`, `Windows`, `RAM-64GB`). +- The watchdog defined in `.github/workflows/stuck-job-watchdog.yml` does not recover the run because no idle runner is visible to the repository, so the watchdog's label-matching rule never fires. + +## Root cause + +After a repository transfer between GitHub organizations, the destination organization's runner groups do not automatically include the transferred repository in their repository-access list. When a runner group is configured as "Selected repositories", any repository that is not explicitly listed cannot dispatch jobs to that group's runners. The dispatcher does not log an error in this state; the job simply stays queued. + +This is a configuration-state issue, not the intermittent dispatcher bug tracked upstream as [GitHub Community Discussion #186811](https://github.com/orgs/community/discussions/186811). The dispatcher bug applies when an idle matching runner _is_ visible to the repository through the GitHub API but never receives the job. If the API does not list the runner at all for the repository, this runbook applies instead. + +## Diagnose with the GitHub CLI + +Run the following from any workstation with `gh auth login` already completed. Replace `` with the destination organization that owns the runners. + +List the organization's runner groups, including each group's visibility setting: + +```bash +gh api orgs//actions/runner-groups \ + -q '.runner_groups[] | {id, name, visibility, allows_public_repositories}' +``` + +For a runner group whose visibility is `selected`, list the repositories that currently have access: + +```bash +gh api orgs//actions/runner-groups//repositories \ + -q '.repositories[] | {id, name, full_name}' +``` + +If `wallstop/unity-helpers` does not appear in that list, the dispatcher has no path to the group's runners from this repository, which matches the symptom above. + +Cross-check by listing runners that the repository itself can see: + +```bash +gh api repos/wallstop/unity-helpers/actions/runners \ + -q '.runners[] | {id, name, status, busy, labels: [.labels[].name]}' +``` + +When this list is empty or omits the expected runner names while the organization-level inventory shows them online, the access list is the cause. + +## Resolution + +Choose one of the following inside the destination organization. Either restores dispatch; pick the one that matches the organization's security model. + +Add the transferred repository to the selected list: + +1. Organization Settings. +2. Actions. +3. Runner groups. +4. Default (or the relevant group). +5. Repository access. +6. Add `wallstop/unity-helpers` to the list. +7. Save. + +Change the group's visibility to all repositories: + +1. Organization Settings. +2. Actions. +3. Runner groups. +4. Default (or the relevant group). +5. Repository access. +6. Set visibility to all repositories. +7. Save. + +The second resolution avoids future per-transfer maintenance but exposes the runners to every repository in the organization. Use it only when that exposure is acceptable for the runner group's security posture. + +After applying the chosen resolution, re-run the queued workflow from the Actions tab. The `runner-preflight` job added to each Unity workflow validates runner access from `ubuntu-latest` before any matrix entry attempts to dispatch onto self-hosted; a green preflight confirms the fix. + +## Preflight diagnostic in this repository + +Unity workflows run a `runner-preflight` job on `ubuntu-latest` before the self-hosted matrix. That preflight queries `gh api orgs/${OWNER}/actions/runners` first and, on 403/404 (the default `secrets.GITHUB_TOKEN` cannot list org-scoped runners under most org policies), falls back to `gh api repos/${GITHUB_REPOSITORY}/actions/runners`. If both endpoints fail (typically a 403 from each because the token is unscoped for runner administration), the preflight emits a `::warning::` and exits 0 (soft pass). + +**Critical contract:** the preflight must NEVER be more strict than the no-preflight baseline. Its only job is to surface a fast, clear failure when it can _prove_ the runner inventory is wrong. When it cannot prove that, it soft-passes so Unity CI is never made strictly more broken than it was without the preflight. + +### Upgrading the soft pass to a hard pass + +The default `secrets.GITHUB_TOKEN` cannot list runners under a repo-level scope strict enough to reflect the runner-group ACL, so the preflight falls back to a soft pass on most installations. To upgrade the soft-pass path to a hard-pass: + +1. Mint a fine-grained personal access token (or a GitHub App installation token) holding the repository-level "Administration: read" permission, scoped to `wallstop/unity-helpers` only. Do NOT use a classic PAT with `admin:org`, and do NOT use the fine-grained "Organization administration: read" permission: both grant org-wide visibility, which causes `gh api orgs//actions/runners` to return the entire org runner inventory regardless of any individual repository's runner-group ACL. That would let the preflight see runners as online and silently pass even when the post-transfer ACL is broken, which is exactly the pitfall this runbook addresses. +2. Add the token as a repository secret named `RUNNER_AUDIT_PAT`. +3. The Unity workflows already prefer `RUNNER_AUDIT_PAT` over `GITHUB_TOKEN` when set (`GH_TOKEN: ${{ secrets.RUNNER_AUDIT_PAT || secrets.GITHUB_TOKEN }}`) and query the repo-scoped endpoint. That endpoint enforces the runner-group ACL: if the repository does not have access to a runner via its group, the runner is invisible there, which is the live ACL state we want the preflight to detect. The preflight retains the same soft-pass behavior if the secret is absent, so this is opt-in. + +The rationale is deliberate: we want the upgrade token to FAIL when the ACL is misconfigured, not paper over it; that is why we use the repo-scoped "Administration: read" permission rather than any org admin scope. Without that property the hard-pass mode would be worse than the soft-pass mode it replaces. + +Because `administration` is not a valid `permissions:` key for the workflow-scoped `GITHUB_TOKEN`, the only way to grant the preflight read access to the runner inventory under a repo-level scope is to provision an external token (PAT or app installation token) via `RUNNER_AUDIT_PAT`. Without that, the preflight falls back to the soft-pass path, which is the design intent. + +If the preflight passes but the matrix job still stays queued, the cause is more likely the dispatcher bug (see [GitHub Community Discussion #186811](https://github.com/orgs/community/discussions/186811)) than the access list. Use the recovery workflows in this repository: `.github/workflows/unstick-run.yml` for manual recovery of a single run, and `.github/workflows/stuck-job-watchdog.yml` for the automated 5-minute scan. + +## PowerShell 7 prerequisite on self-hosted runners + +Self-hosted Windows Unity runners require **PowerShell 7 (`pwsh`)** in addition to Git Bash. Every Unity workflow consumes the `print-self-hosted-runner-diagnostics` composite action (`.github/actions/print-self-hosted-runner-diagnostics/action.yml`) before its own steps, and that action plus the Unity run/provision steps run with `shell: pwsh`. PowerShell 7 is _not_ the Windows-built-in PowerShell 5.1 (`powershell`); it is a separate install that provides the `pwsh` executable. + +### Symptom + +- A self-hosted Unity job fails almost immediately with `##[error]pwsh: command not found`. +- The failure originates from the first `shell: pwsh` step the agent reaches. +- Git Bash and the runner agent are otherwise healthy. + +The diagnostics composite action fails fast with a clear, actionable error annotation (`pwsh missing on self-hosted runner`) when `pwsh` is absent, so this state no longer surfaces only as the cryptic `pwsh: command not found`. The preflight step that emits that error runs under Windows PowerShell 5.1, which is always present, so it executes even when PowerShell 7 is missing. + +### Install PowerShell 7 + +On a machine with winget: + +```powershell +winget install --id Microsoft.PowerShell --source winget +``` + +For machines without winget, download and run the latest MSI installer from the official releases page: . + +### Verify + +Open a **new** shell (so the updated PATH is picked up) and confirm: + +```powershell +pwsh -v +Get-Command pwsh +``` + +`pwsh -v` should print the installed PowerShell 7 version, and `Get-Command pwsh` should resolve to the installed executable's path. + +### Restart the runner agent + +After installing PowerShell 7, restart the self-hosted runner service/agent (or refresh the machine's PATH and restart the runner) so the agent process sees `pwsh` on its PATH. The runner agent inherits its environment at start time; until it is restarted it keeps reporting `pwsh: command not found` even though a fresh interactive shell can find `pwsh`. Re-run the queued Unity workflow once the agent is back online. + +## Git compression tools for Actions cache + +Self-hosted Windows Unity runners also need Git for Windows' Unix tools available to GitHub Actions cache steps. `actions/cache` restores and saves archives through `tar` and `gzip`; when the runner PATH exposes Git Bash but omits `C:\Program Files\Git\usr\bin`, cache post steps can warn with `gzip: command not found` and fail to save the Unity Library cache. + +The `print-self-hosted-runner-diagnostics` composite action prepends Git's `usr\bin` directory to `$GITHUB_PATH` when it finds both `gzip.exe` and `tar.exe`, and emits a warning when that directory is absent. To verify locally on the runner: + +```powershell +Get-Command gzip.exe +Get-Command tar.exe +``` + +If either command is missing, install Git for Windows or add `C:\Program Files\Git\usr\bin` to the runner service PATH, then restart the runner agent. + +## Never use plain `shell: bash` on self-hosted Windows runners + +On a self-hosted Windows runner, `shell: bash` can resolve to the WSL stub at `C:\Windows\System32\bash.exe`, which tries to launch a WSL distro that is usually not installed and fails with "Windows Subsystem for Linux has no installed distributions." The diagnostics composite warns when the runner PATH resolves bash to that stub (Git Bash must precede `System32` in PATH). Unity workflow steps therefore use `shell: pwsh` (or `shell: powershell` for steps that must run before PowerShell 7 is installed) rather than `shell: bash`. + +## Windows host prerequisites (0xC0000135 / STATUS_DLL_NOT_FOUND) + +If `Unity.exe` fails at startup with `-1073741515` / `0xC0000135` (STATUS_DLL_NOT_FOUND), the host is missing an OS-level dependency Unity imports — most commonly the Microsoft Visual C++ Redistributables (both the 2010 SP1 and the 2015-2022 x64 generations). This is an OS-level fix; `ensure-editor.ps1`'s Unity-reinstall retry loop cannot repair it (the missing DLL is on the OS, not in the Unity install). `ensure-editor.ps1` detects this case and short-circuits with a clear error rather than retrying futilely. + +> **TODO(unity-helpers):** The automated host-prerequisite remediation backend that DxMessaging ships — `scripts/unity/bootstrap-windows-runner.ps1`, `scripts/unity/maintain-windows-runner.ps1`, and the `.github/actions/assert-unity-host-prereqs` composite — was **not** ported in this batch. Until it is, `.github/workflows/runner-bootstrap.yml` hard-fails its maintenance step with a clear "script not found" error, and the runner host must be prepared manually: +> +> 1. Install the Microsoft Visual C++ 2010 SP1 x64 Redistributable (provides `MSVCP100.dll` / `MSVCR100.dll`). +> 2. Install the Microsoft Visual C++ 2015-2022 x64 Redistributable (provides `VCRUNTIME140.dll`, `VCRUNTIME140_1.dll`, `MSVCP140.dll`). +> 3. Enable Windows long paths (`git config --system core.longpaths true` and the `LongPathsEnabled` registry value). +> 4. Add Windows Defender exclusions for the Unity install root and the runner work directory to avoid scan-induced timeouts. +> 5. Install PowerShell 7 (see above). +> +> Re-run the queued Unity workflow once the host is prepared. When the backend scripts are ported, this section should point at them and `runner-bootstrap.yml` will perform these steps automatically. + +## Required secrets + +The Unity workflows expect the following repository (or organization) secrets. They are NOT provisioned by this batch; a maintainer must add them before the first self-hosted run: + +- `UNITY_SERIAL`, `UNITY_EMAIL`, `UNITY_PASSWORD` — classic serial Unity activation (all three required together). +- `ORG_BUILD_LOCK_TOKEN` — token for the `wallstop-organization-builds` org build lock (`Ambiguous-Interactive/ambiguous-organization-build-lock`). +- `UNITY_ACCELERATOR_ENDPOINT` — optional; enables the Unity Accelerator cache namespace when set. +- `RUNNER_AUDIT_PAT` — optional; upgrades the runner-preflight soft pass to a hard pass (see above). diff --git a/docs/runbooks/unity-runners-after-transfer.md.meta b/docs/runbooks/unity-runners-after-transfer.md.meta new file mode 100644 index 000000000..cbfcd4cdd --- /dev/null +++ b/docs/runbooks/unity-runners-after-transfer.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: d1fb8d26408302bdcf6018c5de108c59 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/scripts/unity/ensure-editor.ps1 b/scripts/unity/ensure-editor.ps1 new file mode 100644 index 000000000..c7ad42069 --- /dev/null +++ b/scripts/unity/ensure-editor.ps1 @@ -0,0 +1,4584 @@ +#Requires -Version 5.1 +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [ValidatePattern('^\d+\.\d+\.\d+f\d+$')] + [string]$UnityVersion, + + [string]$InstallRoot = $(if ($env:UNITY_EDITOR_INSTALL_ROOT) { $env:UNITY_EDITOR_INSTALL_ROOT } else { 'C:\Unity\Editors' }), + + [string]$DiagnosticsPath = $(if ($env:UH_UNITY_DIAGNOSTICS_PATH) { $env:UH_UNITY_DIAGNOSTICS_PATH } else { '' }), + + [switch]$CiManagedOnly = $($env:GITHUB_ACTIONS -eq 'true'), + + [ValidateSet('EditorOnly', 'StandaloneWindowsIl2Cpp', 'Android', 'Full')] + [string]$ProvisioningProfile = 'Full', + + [switch]$WithWindowsIl2Cpp, + + [switch]$RequireHealthyExisting +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +if ($WithWindowsIl2Cpp) { + if ($PSBoundParameters.ContainsKey('ProvisioningProfile') -and $ProvisioningProfile -ne 'StandaloneWindowsIl2Cpp') { + throw "-WithWindowsIl2Cpp is an alias for -ProvisioningProfile StandaloneWindowsIl2Cpp and cannot be combined with -ProvisioningProfile $ProvisioningProfile." + } + $ProvisioningProfile = 'StandaloneWindowsIl2Cpp' +} + +$script:UnityCliPath = 'unity' +$script:UnityProvisioningProfile = $ProvisioningProfile +$script:UnityInstallLockDepth = 0 +$script:ProvisioningDeadlineUtc = [DateTime]::MaxValue +$script:ProvisioningBudgetSeconds = 0 +$script:ProvisioningEditorPath = '' +$script:ProvisioningFinalClassification = 'not-finished' +$script:ProvisioningTimeoutEvents = New-Object System.Collections.Generic.List[object] +$script:ProvisioningProcessCleanupEvents = New-Object System.Collections.Generic.List[object] +$script:ProvisioningCommandClasses = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase) +$script:UnityCliVersionText = '' + +# PowerShell 7.4 introduced $PSNativeCommandUseErrorActionPreference (stabilizing +# the native-error experimental feature). Its default is $false on current builds, +# so `& ` does NOT throw on a non-zero exit and our explicit exit checks +# run as written. However, a host profile or a future/different build could enable +# it, which would make `& ` THROW on a non-zero exit BEFORE our +# `if ($LASTEXITCODE -ne 0)` check runs -- making the best-effort invokers rely on +# their catch block instead of the explicit exit check. Pinning it $false makes +# LASTEXITCODE-based handling authoritative and identical across hosts/versions. +# (PS 5.1 lacks this variable; assigning it there is harmless, and the assignment +# is StrictMode-safe.) +$PSNativeCommandUseErrorActionPreference = $false + +function Write-CiNotice { + param([Parameter(Mandatory = $true)][string]$Message) + Write-Host "::notice::$Message" +} + +function Register-UnityCliCommandAttempt { + param([string[]]$Arguments) + + $args = @($Arguments) + if ($args.Count -eq 0) { + [void]$script:ProvisioningCommandClasses.Add('unknown') + return + } + + $verb = [string]$args[0] + $class = $verb + if ($verb -in @('install', 'install-modules')) { + if ($args -contains '-m') { + $class = "$verb/modules" + } + } + [void]$script:ProvisioningCommandClasses.Add($class) +} + +function Add-ProvisioningTimeoutEvent { + param( + [string[]]$Arguments, + [int]$TimeoutSeconds + ) + + $script:ProvisioningTimeoutEvents.Add([pscustomobject]@{ + utc = [DateTime]::UtcNow.ToString('o') + command = (@($Arguments) -join ' ') + timeoutSeconds = $TimeoutSeconds + }) | Out-Null +} + +function Add-ProvisioningProcessCleanupEvent { + param( + [Parameter(Mandatory = $true)][string]$Reason, + [int]$Matched = 0, + [int]$Stopped = 0, + [string[]]$Details + ) + + $script:ProvisioningProcessCleanupEvents.Add([pscustomobject]@{ + utc = [DateTime]::UtcNow.ToString('o') + reason = $Reason + matched = $Matched + stopped = $Stopped + details = @($Details) + }) | Out-Null +} + +function ConvertTo-ProcessArgumentLine { + param([string[]]$Arguments) + + $quoted = foreach ($arg in @($Arguments)) { + if ($null -eq $arg) { + '""' + continue + } + + $value = [string]$arg + if ($value.Length -gt 0 -and $value -notmatch '[\s"]') { + $value + continue + } + + $builder = New-Object System.Text.StringBuilder + [void]$builder.Append('"') + $backslashes = 0 + foreach ($ch in $value.ToCharArray()) { + if ($ch -eq '\') { + $backslashes++ + continue + } + + if ($ch -eq '"') { + if ($backslashes -gt 0) { + [void]$builder.Append('\' * ($backslashes * 2)) + } + [void]$builder.Append('\"') + $backslashes = 0 + continue + } + + if ($backslashes -gt 0) { + [void]$builder.Append('\' * $backslashes) + $backslashes = 0 + } + [void]$builder.Append($ch) + } + + if ($backslashes -gt 0) { + [void]$builder.Append('\' * ($backslashes * 2)) + } + [void]$builder.Append('"') + $builder.ToString() + } + + return ($quoted -join ' ') +} + +function Get-EnsureEditorProvisioningBudgetSeconds { + param([int]$Default = 9000) + + if ($env:UH_ENSURE_EDITOR_PROVISIONING_BUDGET_SECONDS) { + $parsed = 0 + if ( + [int]::TryParse($env:UH_ENSURE_EDITOR_PROVISIONING_BUDGET_SECONDS, [ref]$parsed) -and + $parsed -ge 0 + ) { + return $parsed + } + Write-Host "::warning::Ignoring invalid UH_ENSURE_EDITOR_PROVISIONING_BUDGET_SECONDS='$env:UH_ENSURE_EDITOR_PROVISIONING_BUDGET_SECONDS'; using $Default second(s)." + } + return $Default +} + +function Get-EnsureEditorProbeTimeoutSeconds { + param([int]$Default = 120) + + $raw = $env:UH_ENSURE_EDITOR_PROBE_TIMEOUT_SECONDS + if ($raw) { + $parsed = 0 + if ([int]::TryParse($raw, [ref]$parsed) -and $parsed -ge 0) { + return $parsed + } + Write-Host "::warning::Ignoring invalid UH_ENSURE_EDITOR_PROBE_TIMEOUT_SECONDS='$raw'; using $Default." + } + return $Default +} + +function Initialize-UnityProvisioningBudget { + $script:ProvisioningBudgetSeconds = Get-EnsureEditorProvisioningBudgetSeconds + if ($script:ProvisioningBudgetSeconds -le 0) { + $script:ProvisioningDeadlineUtc = [DateTime]::MaxValue + Write-CiNotice "Unity provisioning whole-run budget is disabled." + return + } + + $script:ProvisioningDeadlineUtc = [DateTime]::UtcNow.AddSeconds($script:ProvisioningBudgetSeconds) + Write-CiNotice "Unity provisioning whole-run budget: $script:ProvisioningBudgetSeconds second(s)." +} + +function Get-RemainingUnityProvisioningBudgetSeconds { + if ($script:ProvisioningDeadlineUtc -eq [DateTime]::MaxValue) { + return 0 + } + + $remaining = [int][Math]::Floor(($script:ProvisioningDeadlineUtc - [DateTime]::UtcNow).TotalSeconds) + if ($remaining -lt 0) { + return 0 + } + return $remaining +} + +function Get-EffectiveUnityCliTimeoutSeconds { + param([int]$RequestedSeconds) + + $remaining = Get-RemainingUnityProvisioningBudgetSeconds + if ($script:ProvisioningDeadlineUtc -ne [DateTime]::MaxValue) { + if ($remaining -le 0) { + Write-Host "::error::Unity provisioning budget of $script:ProvisioningBudgetSeconds second(s) is exhausted before the next Unity CLI command can start." + throw "Unity provisioning budget of $script:ProvisioningBudgetSeconds second(s) is exhausted before the next Unity CLI command can start." + } + if ($RequestedSeconds -le 0) { + return $remaining + } + return [Math]::Min($RequestedSeconds, $remaining) + } + + return $RequestedSeconds +} + +function Assert-UnityProvisioningBudgetCanFit { + param( + [Parameter(Mandatory = $true)][string]$Operation, + [int]$MinimumSeconds = 60 + ) + + if ($script:ProvisioningDeadlineUtc -eq [DateTime]::MaxValue) { + return + } + + $remaining = Get-RemainingUnityProvisioningBudgetSeconds + if ($remaining -lt $MinimumSeconds) { + Write-Host "::error::Unity provisioning budget cannot fit '$Operation': $remaining second(s) remain, but at least $MinimumSeconds second(s) are required." + throw "Unity provisioning budget cannot fit '$Operation': $remaining second(s) remain, but at least $MinimumSeconds second(s) are required. Increase UH_ENSURE_EDITOR_PROVISIONING_BUDGET_SECONDS or avoid this recovery path." + } +} + +function Invoke-WithRetry { + # Generic retry wrapper for a TERMINATING-error-prone operation (the base + # editor install, which has been observed to fail flakily after a long run + # with exit code 6 and almost no diagnostic output). Runs $Action; on a + # thrown terminating error it logs a ::warning:: with the attempt number and + # message, sleeps with linear backoff (DelaySeconds * attempt), then retries. + # After exhausting $MaxAttempts it RETHROWS the LAST error so a persistent + # failure still aborts the bootstrap loudly (never silently swallowed). + # StrictMode-safe: no collection captures, no property reads on $null. + param( + [Parameter(Mandatory = $true)][scriptblock]$Action, + [int]$MaxAttempts = 2, + [int]$DelaySeconds = 15 + ) + + if ($MaxAttempts -lt 1) { $MaxAttempts = 1 } + + $lastError = $null + for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) { + try { + return & $Action + } catch { + $lastError = $_ + $message = $_.Exception.Message + if ($attempt -lt $MaxAttempts) { + $sleep = $DelaySeconds * $attempt + Write-Host "::warning::Attempt $attempt of $MaxAttempts failed: $message. Retrying in $sleep second(s)." + Start-Sleep -Seconds $sleep + } else { + Write-Host "::warning::Attempt $attempt of $MaxAttempts failed: $message. No attempts remaining." + } + } + } + + # Exhausted every attempt: rethrow the last terminating error verbatim so the + # original message (which now includes captured CLI output + exit code) reaches + # CI logs unchanged. + if ($lastError) { + throw $lastError + } + + # Defensive: only reachable if $MaxAttempts somehow yielded no iteration. + throw "Invoke-WithRetry exhausted all attempts without capturing an error." +} + +function Get-EnsureEditorRetryDelaySeconds { + # Single source of truth for the Invoke-WithRetry backoff delay. Honors the + # UH_ENSURE_EDITOR_RETRY_DELAY_SECONDS override (tests set it to 0 to avoid + # real sleeps; CI leaves it unset for the production 15s backoff). A + # non-integer or negative override is ignored with a ::warning:: and the + # default is used. StrictMode-safe: no collection reads. + param([int]$Default = 15) + + if ($env:UH_ENSURE_EDITOR_RETRY_DELAY_SECONDS) { + $parsed = 0 + if ( + [int]::TryParse($env:UH_ENSURE_EDITOR_RETRY_DELAY_SECONDS, [ref]$parsed) -and + $parsed -ge 0 + ) { + return $parsed + } + Write-Host "::warning::Ignoring invalid UH_ENSURE_EDITOR_RETRY_DELAY_SECONDS='$env:UH_ENSURE_EDITOR_RETRY_DELAY_SECONDS'; using $Default second(s)." + } + return $Default +} + +function Get-EnsureEditorInstallTimeoutSeconds { + # Single source of truth for the TOTAL wall-clock timeout applied to a + # module-install CLI invocation (see Invoke-UnityCliCaptureWithTimeout). The + # Android NDK module install has been observed to HANG so long the GitHub job + # is cancelled ("The operation was canceled") -- which means the retry never + # triggers and NO diagnostics are produced. A bounded timeout kills the hung + # install and lets the existing retry + classification flow run on a hang. + # + # Honors the UH_ENSURE_EDITOR_INSTALL_TIMEOUT_SECONDS override following the + # EXACT convention of Get-EnsureEditorRetryDelaySeconds: tests set it small + # (e.g. 2) to force the timeout path; CI leaves it unset for the production + # default. A non-integer or NEGATIVE override is ignored with a ::warning:: + # and the default is used. A value of 0 is the explicit OPT-OUT (no timeout): + # it returns 0 and the runner waits indefinitely, matching the prior + # behavior, for the rare case an operator must allow an unbounded install. + # + # Default rationale (2700s = 45 minutes): a healthy full CI module install + # (Windows IL2CPP + WebGL + Android SDK/NDK/OpenJDK + Linux Mono/IL2CPP) on a + # warm self-hosted runner completes in well under this; 45 minutes comfortably + # exceeds a slow-but-progressing install yet stays well under the Unity job's + # wall-clock budget, so a genuine HANG is killed (and retried) long before the + # GitHub job would be cancelled. StrictMode-safe: no collection reads. + param([int]$Default = 2700) + + if ($env:UH_ENSURE_EDITOR_INSTALL_TIMEOUT_SECONDS) { + $parsed = 0 + if ( + [int]::TryParse($env:UH_ENSURE_EDITOR_INSTALL_TIMEOUT_SECONDS, [ref]$parsed) -and + $parsed -ge 0 + ) { + return $parsed + } + Write-Host "::warning::Ignoring invalid UH_ENSURE_EDITOR_INSTALL_TIMEOUT_SECONDS='$env:UH_ENSURE_EDITOR_INSTALL_TIMEOUT_SECONDS'; using $Default second(s)." + } + return $Default +} + +function Get-EnsureEditorProgressStallSeconds { + # Single source of truth for the HEARTBEAT-STALL threshold applied to a captured + # CLI invocation (see Invoke-UnityCliCaptureWithTimeout's poll loop). This is + # COMPLEMENTARY to Get-EnsureEditorInstallTimeoutSeconds (the total wall-clock + # fallback): the heartbeat detector fires when the LAST observed progress + # triple (pct, phase, msg) has been unchanged for >= this many seconds, which + # is the actual failure mode of the Unity 6.3 install hang -- thousands of + # byte-identical `{"type":"progress","pct":50,"msg":"Installing Unity (6000.3.16f1)...","phase":"install"}` + # lines stream for 20 minutes with NO triple advance, then the job times out. + # Killing on stall classifies as retryable (sentinel exit 125, distinct from + # the wall-clock 124 so callers and tests can tell the two apart) and lets + # the existing retry + classification flow run on a hang. + # + # Honors UH_ENSURE_EDITOR_PROGRESS_STALL_SECONDS following the EXACT + # convention of Get-EnsureEditorInstallTimeoutSeconds: tests set it small + # (e.g. 2) to force the stall path; CI leaves it unset for the production + # default. A non-integer or NEGATIVE override is ignored with a ::warning:: + # and the default is used. A value of 0 is the explicit OPT-OUT (no heartbeat + # detection): the wall-clock fallback alone gates the run. + # + # Default rationale (PROFILE-AWARE; raised from a flat 600s after CI evidence): + # the Unity 6000.3.16f1 install emits a SINGLE monolithic + # `{"...,"pct":50,"phase":"install","msg":"Installing Unity (6000.3.16f1)..."}` + # triple that does NOT advance for the WHOLE on-disk unpack. On run 26701943540 + # the EditorOnly playmode job sat at that exact triple for 600s on a REAL, + # HEALTHY install and was killed at precisely 600s (exit 125) -- a FALSE + # POSITIVE; it only recovered because Unity.exe happened to be resolvable and + # EditorOnly skips module verification. The il2cpp/standalone (and Android/Full) + # profiles unpack MORE payload during that same frozen phase, so they freeze + # even LONGER, and they CANNOT lean on the EditorOnly skip. So: + # * EditorOnly -> 900s (15 min; base-editor unpack only) + # * StandaloneWindowsIl2Cpp/Android/Full -> 1800s (30 min; heavier payload, and + # a false kill here cascades into the + # module step, the real-world failure) + # Both stay well under the 2700s (45 min) wall-clock fallback, so a GENUINE hang + # is still surfaced before the job is cancelled, while a slow-but-real unpack is + # no longer killed mid-flight. The env override remains authoritative and is + # honored verbatim (tests set it to 2 to force the stall path; 0 opts out). + # StrictMode-safe: no collection reads. + param([int]$Default = -1) + + if ($env:UH_ENSURE_EDITOR_PROGRESS_STALL_SECONDS) { + $parsed = 0 + if ( + [int]::TryParse($env:UH_ENSURE_EDITOR_PROGRESS_STALL_SECONDS, [ref]$parsed) -and + $parsed -ge 0 + ) { + return $parsed + } + Write-Host "::warning::Ignoring invalid UH_ENSURE_EDITOR_PROGRESS_STALL_SECONDS='$env:UH_ENSURE_EDITOR_PROGRESS_STALL_SECONDS'; using the profile-aware default." + } + + if ($Default -ge 0) { + return $Default + } + + # Profile-aware default (only when the caller did not pin an explicit $Default). + # Guard the Get-UnityProvisioningProfile lookup with Get-Command so this helper + # still works when AST-extracted in ISOLATION (some unit tests extract only the + # timeout/heartbeat helpers, not the profile getter); fall back to the heavier + # default in that case, which is the safe (less-likely-to-false-kill) choice. + $profile = 'Full' + if (Get-Command Get-UnityProvisioningProfile -ErrorAction SilentlyContinue) { + $profile = Get-UnityProvisioningProfile + } + if ($profile -eq 'EditorOnly') { + return 900 + } + return 1800 +} + +function Get-EnsureEditorProgressNoticeIntervalSeconds { + # Single source of truth for the PERIODIC ::notice:: cadence in + # Invoke-UnityCliCaptureWithTimeout's poll loop. The notice is wall-clock + # gated (NOT per-line) so a long advancing install yields a human-readable + # mid-flight summary in the live CI log instead of the raw dupe-progress + # wall the Unity beta CLI emits. Extracted to a helper so tests can drop + # the cadence to a few seconds without forcing the suite to wait the + # production minute. + # + # Honors UH_ENSURE_EDITOR_PROGRESS_NOTICE_INTERVAL_SECONDS following the + # EXACT convention of Get-EnsureEditorProgressStallSeconds: a non-integer + # or NEGATIVE override is ignored with a ::warning:: and the default is + # used. A value of 0 is the explicit OPT-OUT (no periodic notice ever + # fires); the live progress stream is unaffected. StrictMode-safe: no + # collection reads. + # + # Default 60s balances "human can see progress mid-flight" against "do not + # bury the live progress stream"; lower would spam, higher would lose + # actionability on the long-install path. + param([int]$Default = 60) + + if ($env:UH_ENSURE_EDITOR_PROGRESS_NOTICE_INTERVAL_SECONDS) { + $parsed = 0 + if ( + [int]::TryParse($env:UH_ENSURE_EDITOR_PROGRESS_NOTICE_INTERVAL_SECONDS, [ref]$parsed) -and + $parsed -ge 0 + ) { + return $parsed + } + Write-Host "::warning::Ignoring invalid UH_ENSURE_EDITOR_PROGRESS_NOTICE_INTERVAL_SECONDS='$env:UH_ENSURE_EDITOR_PROGRESS_NOTICE_INTERVAL_SECONDS'; using $Default second(s)." + } + return $Default +} + +function Get-EnsureEditorInstallRetryAttempts { + # Single source of truth for the base-install Invoke-WithRetry attempt count. + # Honors UH_ENSURE_EDITOR_INSTALL_RETRY_ATTEMPTS following the EXACT + # convention of Get-EnsureEditorRetryDelaySeconds. The DEFAULT is UNCHANGED at + # 2 (the documented "two attempts fit inside the 180-minute step budget even + # for a slow install"): now that a HANG is bounded by the install timeout and + # classified as a retryable failure, the existing 2-attempt retry already + # covers a transient hang, so the default is deliberately not bumped. This knob + # only gives an operator a low-risk lever (e.g. set to 3 for a flaky window) + # without destabilizing the default retry contract. A value below 1 is clamped + # to 1 by Invoke-WithRetry; a non-integer/negative override is ignored with a + # ::warning::. StrictMode-safe: no collection reads. + param([int]$Default = 2) + + if ($env:UH_ENSURE_EDITOR_INSTALL_RETRY_ATTEMPTS) { + $parsed = 0 + if ( + [int]::TryParse($env:UH_ENSURE_EDITOR_INSTALL_RETRY_ATTEMPTS, [ref]$parsed) -and + $parsed -ge 1 + ) { + return $parsed + } + Write-Host "::warning::Ignoring invalid UH_ENSURE_EDITOR_INSTALL_RETRY_ATTEMPTS='$env:UH_ENSURE_EDITOR_INSTALL_RETRY_ATTEMPTS'; using $Default attempt(s)." + } + return $Default +} + +function Get-EnsureEditorAndroidInstallRetryAttempts { + # Single source of truth for the DEDICATED Android module-install retry count + # used by Install-UnityAndroidModules. The Android SDK/NDK is a multi-GB Google + # download whose NDK UNPACK phase (~93%) fails flakily on Windows (suspected + # MAX_PATH during extraction, or Defender file-locking), so existing editors + # get a bounded Android-only repair before the script escalates to a + # profile-scoped managed quarantine/reinstall with the selected + # Android-capable provisioning profile. Honors UH_ENSURE_EDITOR_ANDROID_INSTALL_RETRY_ATTEMPTS + # following the EXACT convention of Get-EnsureEditorInstallRetryAttempts. The + # DEFAULT is 3 (one more than the base-install default of 2) because the Android + # unpack flake is the specific failure this loop targets and an extra bounded, + # editor-preserving attempt is cheaper than managed quarantine/reinstall. A value below 1 is + # invalid; a non-integer/negative override is ignored with a ::warning::. + # StrictMode-safe: no collection reads. + param([int]$Default = 3) + + if ($env:UH_ENSURE_EDITOR_ANDROID_INSTALL_RETRY_ATTEMPTS) { + $parsed = 0 + if ( + [int]::TryParse($env:UH_ENSURE_EDITOR_ANDROID_INSTALL_RETRY_ATTEMPTS, [ref]$parsed) -and + $parsed -ge 1 + ) { + return $parsed + } + Write-Host "::warning::Ignoring invalid UH_ENSURE_EDITOR_ANDROID_INSTALL_RETRY_ATTEMPTS='$env:UH_ENSURE_EDITOR_ANDROID_INSTALL_RETRY_ATTEMPTS'; using $Default attempt(s)." + } + return $Default +} + +function Get-CollapsedCliOutputTail { + # PURE, StrictMode-safe diagnostic formatter. Takes the captured CLI output + # lines and COLLAPSES consecutive identical lines into a single line annotated + # with a repeat count, then returns the LAST $MaxLines of the collapsed result + # joined with newlines. This is what makes a failed install READABLE: the + # Android NDK install can spam thousands of IDENTICAL progress lines + # (`{"type":"progress","pct":96,"msg":"Installing Android NDK..."}`), and the + # previous "last 20-40 raw lines" tail was therefore thousands of copies of + # the same line -- useless. Collapsing first means the tail shows DISTINCT + # recent activity, e.g. `{"...Installing Android NDK..."} (x3847)`. + # + # Contract: + # * A run of N (N >= 2) consecutive identical lines becomes ONE line with a + # " (xN)" suffix; a non-repeated line passes through UNCHANGED (no suffix). + # * Only the LAST $MaxLines COLLAPSED entries are returned (cap respected + # AFTER collapsing, so the cap counts distinct runs, not raw duplicates). + # * Empty/whitespace-only input returns the literal '(no output captured)'. + # StrictMode-safe: @()-wraps the input so a 0/1/many capture never unwraps to + # AutomationNull, and never indexes a possibly-$null value. + param( + [string[]]$Output, + [int]$MaxLines = 40 + ) + + # @()-wrap defends against the 0/1/many AutomationNull hazard, BUT note @($null) + # is a ONE-element array whose single element is $null -- so .Count is 1, not 0. + # Treat input that is empty OR carries no non-whitespace content (all $null / + # all-blank lines) as "nothing to report" and return the placeholder, exactly as + # the contract above promises. Casting each element via [string] makes $null -> + # '' so the Trim() probe is StrictMode-safe and never indexes a $null value. + $capturedLines = @($Output) + $hasContent = $false + foreach ($probe in $capturedLines) { + if (([string]$probe).Trim().Length -gt 0) { + $hasContent = $true + break + } + } + if (-not $hasContent) { + return '(no output captured)' + } + + if ($MaxLines -lt 1) { + $MaxLines = 1 + } + + # Collapse consecutive identical lines into " (xN)" (N >= 2) or the + # bare line (N == 1). Build the collapsed list in order. + $collapsed = New-Object System.Collections.Generic.List[string] + $previous = $null + $havePrevious = $false + $runLength = 0 + foreach ($rawLine in $capturedLines) { + $line = [string]$rawLine + if ($havePrevious -and $line -eq $previous) { + $runLength++ + continue + } + if ($havePrevious) { + if ($runLength -gt 1) { + $collapsed.Add("$previous (x$runLength)") + } else { + $collapsed.Add($previous) + } + } + $previous = $line + $havePrevious = $true + $runLength = 1 + } + if ($havePrevious) { + if ($runLength -gt 1) { + $collapsed.Add("$previous (x$runLength)") + } else { + $collapsed.Add($previous) + } + } + + $collapsedArray = @($collapsed.ToArray()) + if ($collapsedArray.Count -eq 0) { + return '(no output captured)' + } + + $tailCount = [Math]::Min($MaxLines, $collapsedArray.Count) + $tailLines = @($collapsedArray[($collapsedArray.Count - $tailCount)..($collapsedArray.Count - 1)]) + return ($tailLines -join "`n") +} + +function Get-CliProgressTriple { + # PURE, StrictMode-safe extractor for the (pct, phase, msg) progress TRIPLE + # from a single captured CLI line. Returns a hashtable with three string + # fields (any missing field is the empty string), or $null if the line is + # NOT a JSON progress line. Used by the heartbeat-stall detector in + # Invoke-UnityCliCaptureWithTimeout to recognize an UNCHANGED triple over + # the configured stall window (the actual failure mode of the Unity 6.3 + # install hang -- thousands of byte-identical progress lines streaming for + # 20 minutes with NO triple advance). + # + # Deliberately regex-based (no ConvertFrom-Json): the lines are interleaved + # progress spam, not a single JSON document, and a malformed/non-JSON beta + # line must never throw here. Mirrors Get-LastCliProgressMessage's parsing + # idiom so the two scanners stay in lockstep on field shape. + param([string]$Line) + + $text = [string]$Line + if ($text.Length -eq 0) { + return $null + } + # Must carry the progress shape; otherwise it is plainly not a progress line. + if ($text -notmatch '"type"\s*:\s*"progress"') { + return $null + } + $pctMatch = [regex]::Match($text, '"pct"\s*:\s*(\d+)') + $phaseMatch = [regex]::Match($text, '"phase"\s*:\s*"((?:\\.|[^"\\])*)"') + $msgMatch = [regex]::Match($text, '"msg"\s*:\s*"((?:\\.|[^"\\])*)"') + $pct = if ($pctMatch.Success) { $pctMatch.Groups[1].Value } else { '' } + $phase = if ($phaseMatch.Success) { $phaseMatch.Groups[1].Value } else { '' } + $msg = if ($msgMatch.Success) { $msgMatch.Groups[1].Value } else { '' } + if ($pct -eq '' -and $phase -eq '' -and $msg -eq '') { + return $null + } + return @{ + Pct = $pct + Phase = $phase + Msg = $msg + } +} + +function Get-LastCliProgressMessage { + # PURE, StrictMode-safe extractor for the MOST DIAGNOSTIC progress message in + # the captured CLI output, for a wrap-immune one-line failure summary. The + # standalone Unity CLI emits JSON progress lines shaped like + # `{"type":"progress","phase":"install","pct":93,"msg":"Installing Android NDK"}`. + # + # WHY THE INSTALL-PHASE/MAX-PCT PREFERENCE (the fix): the CLI interleaves + # DOWNLOAD-phase and INSTALL-phase progress, and on a failure the LAST line + # carrying a `"msg"` is frequently an OUT-OF-ORDER download line (e.g. a late + # `"Starting install..."` for some other module) -- so naively reporting the + # last `"msg"` MASKED the true failing module (the Android NDK unpack at 93%). + # Instead we scan ALL lines and, among INSTALL-phase lines (`"phase":"install"`), + # track the `"msg"` seen at the MAXIMUM `"pct"`; that is the deepest the + # installer got before dying (e.g. `Installing Android NDK (93%)`), which is + # the actionable datum. We return it as `" (%)"`. + # + # Fallbacks (unchanged order): if no install-phase msg is found, the LAST line + # carrying ANY JSON `"msg"`; else the LAST non-empty captured line; else the + # literal '(no output captured)'. + # + # Deliberately regex-based (no ConvertFrom-Json): the lines are interleaved + # progress spam, not a single JSON document, and a malformed/non-JSON beta + # line must never throw here. StrictMode-safe: @()-wraps the input. + param([string[]]$Output) + + $capturedLines = @($Output) + if ($capturedLines.Count -eq 0) { + return '(no output captured)' + } + + # PREFERRED: the install-phase message at the highest pct seen. Scan ALL lines; + # for any line that is in the install phase AND carries both a pct and a msg, + # remember the msg at the maximum pct. This is immune to out-of-order trailing + # download lines that would otherwise mask the real failing module. + $bestInstallMsg = $null + $bestInstallPct = -1 + foreach ($raw in $capturedLines) { + $line = [string]$raw + # This phase test could in THEORY match the literal `"phase":"install"` + # appearing INSIDE a quoted "msg" value, but a real Unity progress message + # never embeds that token (the msg is human text like "Installing Android + # NDK"), so there is no realistic trigger; deliberately left as a simple + # substring match rather than a brittle full-JSON parse of interleaved spam. + if ($line -notmatch '"phase"\s*:\s*"install"') { + continue + } + $pctMatch = [regex]::Match($line, '"pct"\s*:\s*(\d+)') + $msgMatch = [regex]::Match($line, '"msg"\s*:\s*"((?:\\.|[^"\\])*)"') + if (-not ($pctMatch.Success -and $msgMatch.Success)) { + continue + } + $pct = [int]$pctMatch.Groups[1].Value + if ($pct -ge $bestInstallPct) { + $bestInstallPct = $pct + $bestInstallMsg = $msgMatch.Groups[1].Value + } + } + if ($null -ne $bestInstallMsg) { + return "$bestInstallMsg ($bestInstallPct%)" + } + + # FALLBACK 1: scan from the END for the last line carrying a JSON "msg":"..." field. + for ($i = $capturedLines.Count - 1; $i -ge 0; $i--) { + $line = [string]$capturedLines[$i] + $match = [regex]::Match($line, '"msg"\s*:\s*"((?:\\.|[^"\\])*)"') + if ($match.Success) { + return $match.Groups[1].Value + } + } + + # FALLBACK 2: no JSON progress message: fall back to the last non-empty captured line. + for ($i = $capturedLines.Count - 1; $i -ge 0; $i--) { + $line = ([string]$capturedLines[$i]).Trim() + if ($line.Length -gt 0) { + return $line + } + } + + return '(no output captured)' +} + +function Invoke-WithUnityInstallLock { + param( + [Parameter(Mandatory = $true)][string]$Version, + [Parameter(Mandatory = $true)][string]$InstallRoot, + [Parameter(Mandatory = $true)][scriptblock]$Action, + [int]$TimeoutMinutes = 180 + ) + + if ($script:UnityInstallLockDepth -gt 0) { + return & $Action + } + + $lockRoot = Join-Path $InstallRoot '_locks' + New-Item -ItemType Directory -Force -Path $lockRoot | Out-Null + $lockPath = Join-Path $lockRoot "$Version-ci-modules.lock" + $deadline = [DateTime]::UtcNow.AddMinutes($TimeoutMinutes) + $stream = $null + + while ($null -eq $stream) { + try { + $stream = [System.IO.File]::Open( + $lockPath, + [System.IO.FileMode]::OpenOrCreate, + [System.IO.FileAccess]::ReadWrite, + [System.IO.FileShare]::None + ) + } catch { + if ([DateTime]::UtcNow -ge $deadline) { + throw "Timed out waiting for Unity install lock '$lockPath' after $TimeoutMinutes minute(s)." + } + Write-Host "::notice::Waiting for Unity install lock: $lockPath" + Start-Sleep -Seconds 10 + } + } + + try { + $script:UnityInstallLockDepth++ + return & $Action + } finally { + $script:UnityInstallLockDepth-- + if ($stream) { + $stream.Dispose() + } + } +} + +function Get-UnityEditorCandidates { + param( + [Parameter(Mandatory = $true)][string]$Version, + [Parameter(Mandatory = $true)][string]$Root, + [switch]$IncludeHostInstalls + ) + + $candidates = New-Object System.Collections.Generic.List[string] + $candidates.Add((Join-Path $Root "$Version\Editor\Unity.exe")) + $candidates.Add((Join-Path $Root "$Version\Unity.exe")) + + if ($IncludeHostInstalls -and ${env:ProgramFiles} -and ${env:ProgramFiles}.Trim().Length -gt 0) { + $candidates.Add((Join-Path ${env:ProgramFiles} "Unity\Hub\Editor\$Version\Editor\Unity.exe")) + $candidates.Add((Join-Path ${env:ProgramFiles} "Unity\$Version\Editor\Unity.exe")) + } + if ($IncludeHostInstalls -and ${env:ProgramFiles(x86)} -and ${env:ProgramFiles(x86)}.Trim().Length -gt 0) { + $candidates.Add( + (Join-Path ${env:ProgramFiles(x86)} "Unity\Hub\Editor\$Version\Editor\Unity.exe") + ) + } + + return @($candidates.ToArray() | Where-Object { $_ -and $_.Trim().Length -gt 0 }) +} + +function Find-UnityEditor { + param( + [Parameter(Mandatory = $true)][string]$Version, + [Parameter(Mandatory = $true)][string]$Root, + [switch]$IncludeHostInstalls + ) + + foreach ($candidate in Get-UnityEditorCandidates -Version $Version -Root $Root -IncludeHostInstalls:$IncludeHostInstalls) { + if (Test-Path -LiteralPath $candidate -PathType Leaf) { + return (Resolve-Path -LiteralPath $candidate).Path + } + } + + return $null +} + +function Update-SessionPathFromRegistry { + # The standalone Unity CLI installer writes %LOCALAPPDATA%\Unity\bin\unity.exe + # and updates only the User-scope registry PATH; it never refreshes the + # current session's $env:PATH. Rebuild the session PATH from the persisted + # Machine + User registry values so the freshly installed CLI resolves in + # this process, and prepend the installer's known target in case the + # registry write lags. CRUCIAL: this .ps1 shares the caller's process + # environment, so the existing $env:PATH carries process-only entries (e.g. + # node added by setup-node via $GITHUB_PATH). Append it as the FINAL segment + # so those entries survive instead of being clobbered. + $segments = New-Object System.Collections.Generic.List[string] + + if ($env:LOCALAPPDATA) { + $segments.Add((Join-Path $env:LOCALAPPDATA 'Unity\bin')) + } + + $machinePath = [System.Environment]::GetEnvironmentVariable('Path', 'Machine') + if ($machinePath) { + $segments.Add($machinePath) + } + + $userPath = [System.Environment]::GetEnvironmentVariable('Path', 'User') + if ($userPath) { + $segments.Add($userPath) + } + + if ($env:PATH) { + $segments.Add($env:PATH) + } + + $env:PATH = (($segments | Where-Object { $_ -and $_.Trim().Length -gt 0 }) -join ';') +} + +function Ensure-UnityCli { + $command = Get-Command unity -ErrorAction SilentlyContinue + if ($command) { + $script:UnityCliPath = $command.Source + return $command.Source + } + + Write-CiNotice "Unity CLI was not found on PATH; installing the standalone Unity CLI for this runner." + $env:UNITY_CLI_CHANNEL = if ($env:UNITY_CLI_CHANNEL) { $env:UNITY_CLI_CHANNEL } else { 'beta' } + Invoke-Expression (Invoke-RestMethod 'https://public-cdn.cloud.unity3d.com/hub/prod/cli/install.ps1') + + $maxTries = 3 + for ($try = 1; $try -le $maxTries; $try++) { + Update-SessionPathFromRegistry + $command = Get-Command unity -ErrorAction SilentlyContinue + if ($command) { + $script:UnityCliPath = $command.Source + return $command.Source + } + if ($try -lt $maxTries) { + Start-Sleep -Seconds 2 + } + } + + if ($env:LOCALAPPDATA) { + $fallback = Join-Path $env:LOCALAPPDATA 'Unity\bin\unity.exe' + if (Test-Path -LiteralPath $fallback -PathType Leaf) { + $script:UnityCliPath = (Resolve-Path -LiteralPath $fallback).Path + return $script:UnityCliPath + } + } + + throw "Unity CLI installation completed but 'unity' is still not on PATH. Reopen the runner shell or add the Unity CLI install directory to PATH." +} + +function Invoke-UnityCliSafe { + # NON-THROWING best-effort invoker. The standalone Unity CLI is a moving + # beta surface (v0.1.0-beta.x); some flags are undocumented and may differ + # between releases. For optional operations (setting the install path, + # probing module ids) a non-zero exit must NOT abort the bootstrap, so this + # variant returns $true/$false and never throws on a non-zero exit code. + # It echoes the command and surfaces any output via Write-Host so failures + # remain diagnosable in CI logs. Captured-output callers should use + # Get-UnityCliOutput instead; this one is for fire-and-forget effects. + param([Parameter(Mandatory = $true)][string[]]$Arguments) + + $requestedTimeout = Get-EnsureEditorProbeTimeoutSeconds + $effectiveTimeout = Get-EffectiveUnityCliTimeoutSeconds -RequestedSeconds $requestedTimeout + $result = Invoke-UnityCliCaptureWithTimeout -Arguments $Arguments -TimeoutSeconds $effectiveTimeout -TimeoutKnob 'UH_ENSURE_EDITOR_PROBE_TIMEOUT_SECONDS' + $exit = [int]$result.ExitCode + return ($exit -eq 0) +} + +function Get-UnityCliOutput { + # CAPTURING, NON-THROWING invoker for getter-style commands (install-path, + # editors -i --format json). Returns an array of output lines (strings) on + # success, or $null on any failure. Does NOT echo to the success pipeline + # of this script: the caller (run-ci-tests.ps1) reads our LAST stdout line + # as the resolved editor path, so getter output must never leak there. + param([Parameter(Mandatory = $true)][string[]]$Arguments) + + $requestedTimeout = Get-EnsureEditorProbeTimeoutSeconds + $effectiveTimeout = Get-EffectiveUnityCliTimeoutSeconds -RequestedSeconds $requestedTimeout + $result = Invoke-UnityCliCaptureWithTimeout -Arguments $Arguments -TimeoutSeconds $effectiveTimeout -TimeoutKnob 'UH_ENSURE_EDITOR_PROBE_TIMEOUT_SECONDS' + if ($result.ExitCode -ne 0) { + return $null + } + + # Normalize to an array of strings regardless of whether 0/1/many lines came + # back (a single line returns a scalar under the call operator). + return @($result.Output | ForEach-Object { [string]$_ }) +} + +function Get-UnityCliVersionText { + if ($script:UnityCliVersionText) { + return $script:UnityCliVersionText + } + + try { + $versionLines = @(Get-UnityCliOutput -Arguments @('--version')) + if ($versionLines.Count -gt 0) { + $script:UnityCliVersionText = ($versionLines -join ' ') + } else { + $script:UnityCliVersionText = '(unavailable)' + } + } catch { + $script:UnityCliVersionText = "(query failed: $($_.Exception.Message))" + } + + return $script:UnityCliVersionText +} + +function Invoke-UnityCliCapture { + # CAPTURING, NON-THROWING invoker that returns BOTH the exit status AND the + # full output text, while STILL streaming output live to the console. The + # other live invokers each give only part of this: Invoke-UnityCliSafe returns + # a bool (no output, no exit code), and Get-UnityCliOutput captures lines but + # returns $null on a non-zero exit (discarding the very output you need to + # diagnose a failure). For classification logic that must inspect WHY a + # command failed (e.g. the IL2CPP "No modules found to install" no-op) we need + # the exit code + output together, so this helper provides both. + # + # Returns a StrictMode-safe hashtable with: + # Success [bool] - $true when exit code is 0 + # ExitCode [int] - the native exit code (-1 if the call threw/spawn failed; + # 124 for wall-clock timeout, 125 for heartbeat-stall kill) + # Output [string[]] - @()-wrapped stdout+stderr lines (never $null) + # StallKilled [bool] - $true when killed by the heartbeat-stall detector + # (no (pct,phase,msg) triple change for the stall window); + # see Invoke-UnityCliCaptureWithTimeout + # TimedOutWallClock [bool] - $true when killed by the absolute wall-clock timeout; + # mutually exclusive with StallKilled + # Every field is always populated, so callers can read .Output.Count and + # index .Output without the 0/1/many AutomationNull hazard. + param([Parameter(Mandatory = $true)][string[]]$Arguments) + + # DELEGATE to the timeout-capable runner so EVERY captured CLI invocation + # (install/repair/module-add/uninstall) is bounded by a total wall-clock + # timeout and cannot hang until the GitHub job is cancelled. The contract of + # this function is UNCHANGED: same per-line LIVE streaming (the timeout runner + # echoes each line the instant it arrives, exactly like this function's + # original `& $cli | ForEach-Object { Write-Host }` did), same 2>&1 merge + # semantics, return shape `@{ Success; ExitCode; Output; StallKilled; TimedOutWallClock }` + # (the last two added with the heartbeat-stall detector; see the header for field + # semantics), same exit code on normal completion, same catch-on-spawn-failure behavior + # (the timeout runner maps a + # spawn failure to ExitCode -1 with the message in Output, exactly as before). + # The timeout is sourced from the single override-aware helper so tests can + # force the timeout path (small value) or opt out (0) without changing callers. + $requestedTimeout = if (Get-Command Get-EnsureEditorInstallTimeoutSeconds -ErrorAction SilentlyContinue) { + Get-EnsureEditorInstallTimeoutSeconds + } else { + 2700 + } + $effectiveTimeout = if (Get-Command Get-EffectiveUnityCliTimeoutSeconds -ErrorAction SilentlyContinue) { + Get-EffectiveUnityCliTimeoutSeconds -RequestedSeconds $requestedTimeout + } else { + $requestedTimeout + } + return Invoke-UnityCliCaptureWithTimeout -Arguments $Arguments -TimeoutSeconds $effectiveTimeout +} + +function Invoke-UnityCliCaptureWithTimeout { + # TIMEOUT-CAPABLE, CAPTURING, NON-THROWING invoker -- the resilience core. It + # is the implementation Invoke-UnityCliCapture delegates to, and it preserves + # that function's EXACT contract on the normal-completion path while adding a + # total wall-clock timeout that a hung install (the Android NDK hang that gets + # the GitHub job cancelled) cannot exceed. + # + # WHY System.Diagnostics.Process and NOT `& `: the call operator cannot + # be interrupted -- a hung child runs until the whole job is killed, so the + # retry never fires and no diagnostics are produced. A Process lets us enforce + # a wall-clock deadline in the poll loop below and Kill($true) (tree-kill) the + # whole tree, so a hang is bounded, killed, classified as a (retryable) + # failure, and annotated. + # + # WHY A MAIN-THREAD POLL LOOP OVER TWO ASYNC LINE READS: two invariants must + # hold AT ONCE -- (1) every line is echoed LIVE the instant it arrives, so a + # long (45-minute) install is never a silent, blank console where an observer + # cannot tell a slow install from a hang; and (2) the run is bounded by a total + # wall-clock deadline. A single ReadToEndAsync per stream satisfies (2) but + # VIOLATES (1): it yields nothing until the process EXITS, so the whole install + # streams as one burst at the end (empirically line 1 appeared at process exit, + # not within ~60ms of being printed). We instead keep ONE outstanding + # ReadLineAsync per stream and poll BOTH from the main thread: when a line is + # ready we Write-Host it immediately (live), buffer it, and issue the next + # ReadLineAsync; every iteration also checks the deadline. Both pipes are always + # being drained, so neither can fill and back-pressure the child (the classic + # full-pipe-buffer deadlock is impossible). The reads run on the MAIN thread on + # purpose: a PowerShell scriptblock has no runspace on an arbitrary threadpool + # thread, so Write-Host from a Task/Register-ObjectEvent -Action either has no + # console or (for eventing) delivers lines OUT OF ORDER -- the poll loop avoids + # both by doing all the I/O and echoing inline. On a deadline hit we tree-kill, + # which closes the pipes so the outstanding ReadLineAsync tasks complete; we then + # drain any already-finished line tasks so no pre-kill output is lost. The two + # streams are merged into a single arrival-order buffer: this reproduces a + # captured `2>&1` closely enough (the old code also did not interleave the two + # streams once captured) and ALL downstream consumers of .Output are order- + # independent (tail de-dup, last-progress parse, substring matches), so + # arrival-order is acceptable and, for live echo, strictly more faithful. + # + # HEARTBEAT-STALL DETECTOR (Unity 6.3 install hang): a captured progress + # TRIPLE (pct, phase, msg) that has not advanced for >= $StallSeconds is + # classified as hung and tree-killed with sentinel exit 125 (distinct from + # the wall-clock 124 so callers and tests can tell hang-detected from + # wall-timeout-elapsed). This is the surgical fix for the Unity 6.3 install + # that streams ~4,672 byte-identical + # `{"type":"progress","pct":50,"msg":"Installing Unity (6000.3.16f1)...","phase":"install"}` + # lines for 20 minutes before the GitHub job is cancelled by the outer wall. + # Detecting the stall and surfacing it as a RETRYABLE failure (handled by + # the same Invoke-WithRetry flow as 124) lets the next attempt run. + # + # The periodic ::notice:: emitted every PROGRESS_NOTICE_INTERVAL_SECONDS + # makes the live CI log human-readable mid-flight (the alternative is a + # 4,672-line dupe wall the reader has to scroll past); it is GATED ON + # WALL-CLOCK TIME, not on every output line, so it cannot dilute the live + # progress stream. + # + # Returns a SUPERSET of Invoke-UnityCliCapture's StrictMode-safe shape, with + # two additional fields so downstream classifiers can attribute a 125 exit to + # WHO actually killed the process (NOT to the raw exit code alone -- a + # native exit 125 from the Unity CLI must NOT be misread as "heartbeat + # stalled"). All callers that ONLY consume Success / ExitCode / Output + # continue to work unchanged; the diagnostic sites read the new fields when + # they need to phrase the failure correctly. + # Success [bool] - $true when exit code is 0 + # ExitCode [int] - native exit (-1 on spawn failure; the + # wall-clock timeout sentinel 124 on a + # wall-clock kill; the heartbeat-stall + # sentinel 125 on a stall kill OR on a + # native non-killed 125 from Unity CLI) + # Output [string[]] - @()-wrapped merged stdout+stderr lines + # (never $null) + # StallKilled [bool] - $true ONLY when the wrapper's heartbeat + # detector killed the process (sentinel + # exit 125 from THIS wrapper). $false on + # a NATIVE 125 from the CLI. + # TimedOutWallClock [bool] - $true ONLY when the wrapper's wall-clock + # deadline killed the process (sentinel + # exit 124 from THIS wrapper). $false on + # a NATIVE 124 from the CLI. + param( + [Parameter(Mandatory = $true)][string[]]$Arguments, + [int]$TimeoutSeconds = 2700, + [string]$TimeoutKnob = 'UH_ENSURE_EDITOR_INSTALL_TIMEOUT_SECONDS', + [int]$StallSeconds = -1, + [string]$StallKnob = 'UH_ENSURE_EDITOR_PROGRESS_STALL_SECONDS' + ) + + # Default the stall threshold from the env-aware helper when the caller did + # not pass one. -1 means "unset" so 0 (explicit opt-out) remains distinguishable + # from "not specified" -- 0 disables the heartbeat detector entirely. + if ($StallSeconds -lt 0) { + $StallSeconds = Get-EnsureEditorProgressStallSeconds + } + + if (Get-Command Register-UnityCliCommandAttempt -ErrorAction SilentlyContinue) { + Register-UnityCliCommandAttempt -Arguments $Arguments + } + Write-Host "$script:UnityCliPath $($Arguments -join ' ')" + + # Sentinel exit code for a WALL-CLOCK TIMEOUT kill. 124 mirrors GNU coreutils + # `timeout` (it exits 124 when the command times out), so the code is + # recognizable in logs; it is non-zero, so the standard non-zero-exit + # classification (a retryable failure) applies without any special-casing. + $timeoutExitCode = 124 + # Sentinel exit code for a HEARTBEAT-STALL kill. 125 is one above the + # wall-clock sentinel so callers (and tests) can tell the two failure modes + # apart at a glance; both are RETRYABLE and treated identically by + # Write-ModuleInstallFailureDiagnostics + the install retry classifier. + $stallExitCode = 125 + # Live-log periodic ::notice:: cadence. 60s default balances "human can see + # progress mid-flight" against "do not bury the live progress stream"; lower + # would spam, higher would lose actionability on the stall path. Sourced via + # the env-aware helper so tests can drop the cadence to a few seconds + # without forcing the suite to wait the production minute; 0 opts out. + $progressNoticeIntervalSeconds = Get-EnsureEditorProgressNoticeIntervalSeconds + $progressNoticeEnabled = ($progressNoticeIntervalSeconds -gt 0) + + # Ordered capture buffer. Appended ONLY from the main-thread poll loop (and the + # spawn-failure catch), so no synchronization is needed. + $buffer = New-Object System.Collections.Generic.List[string] + + # A timeout of 0 (or negative) is the explicit OPT-OUT: wait indefinitely, + # matching the prior unbounded behavior. Otherwise convert seconds to the ms the + # deadline math uses, guarding against Int64 overflow on a very large value. + if ($TimeoutSeconds -le 0) { + $hasDeadline = $false + $timeoutMs = -1 + } else { + $hasDeadline = $true + $timeoutMsLong = [int64]$TimeoutSeconds * 1000 + if ($timeoutMsLong -gt [int64]::MaxValue - 1) { + $timeoutMs = [int64]::MaxValue - 1 + } else { + $timeoutMs = $timeoutMsLong + } + } + + $proc = $null + $exit = -1 + $timedOut = $false + $reaped = $false + # Declared at the OUTER scope (not just inside the try) so a spawn failure + # that lands in the catch still leaves both kill-state booleans defined + # for the StrictMode-safe return-shape construction below. + $stalled = $false + try { + $psi = New-Object System.Diagnostics.ProcessStartInfo + $psi.FileName = $script:UnityCliPath + $psi.Arguments = ConvertTo-ProcessArgumentLine -Arguments $Arguments + $psi.RedirectStandardOutput = $true + $psi.RedirectStandardError = $true + $psi.UseShellExecute = $false + $psi.CreateNoWindow = $true + + $proc = New-Object System.Diagnostics.Process + $proc.StartInfo = $psi + + [void]$proc.Start() + + # Keep ONE outstanding async line read per stream and poll both from the + # main thread. A completed read whose Result is $null means that stream + # reached EOF (the pipe closed); any other Result is a line we echo LIVE, + # buffer, and immediately re-arm with the next ReadLineAsync. Because both + # streams are continuously drained, neither pipe can fill and block the + # child (no full-pipe-buffer deadlock). + $outReader = $proc.StandardOutput + $errReader = $proc.StandardError + $oTask = $outReader.ReadLineAsync() + $eTask = $errReader.ReadLineAsync() + + # Absolute deadline (UtcNow is monotonic-enough for a wall-clock budget and + # immune to the local-clock skew a relative subtraction would risk). When + # the timeout is opted out the deadline is DateTime.MaxValue (never fires). + if ($hasDeadline) { + $deadline = [DateTime]::UtcNow.AddMilliseconds([double]$timeoutMs) + } else { + $deadline = [DateTime]::MaxValue + } + + # Heartbeat-stall + periodic-notice state. The "last triple" is the most + # recently observed (pct, phase, msg); we restart the stall clock every + # time it CHANGES. The notice clock is independent (time-gated, not + # output-gated) so a long advancing install still gets a human-readable + # cadence in the live log instead of the raw dupe wall. Opt-out semantics: + # $StallSeconds == 0 disables heartbeat-kill entirely; $startedAt is the + # wall-clock anchor for the elapsed/stallElapsed fields in the notice. + $startedAt = [DateTime]::UtcNow + $lastTripleAdvanceAt = $startedAt + $lastNoticeAt = $startedAt + $lastTripleKey = $null + $lastTriple = $null + $stallEnabled = ($StallSeconds -gt 0) + $stalled = $false + + $oDone = $false + $eDone = $false + while (-not ($oDone -and $eDone)) { + $progressed = $false + + if (-not $oDone -and $oTask.Wait(0)) { + $line = $oTask.Result + if ($null -eq $line) { + $oDone = $true + } else { + Write-Host $line + $buffer.Add([string]$line) + # Triple advance: if this line is a JSON progress line whose + # (pct, phase, msg) differs from the last observed triple, the + # install is making forward progress; reset the stall clock. + $triple = Get-CliProgressTriple -Line $line + if ($null -ne $triple) { + $key = "$($triple.Pct)|$($triple.Phase)|$($triple.Msg)" + if ($key -ne $lastTripleKey) { + $lastTripleKey = $key + $lastTriple = $triple + $lastTripleAdvanceAt = [DateTime]::UtcNow + } + } + $oTask = $outReader.ReadLineAsync() + } + $progressed = $true + } + + if (-not $eDone -and $eTask.Wait(0)) { + $line = $eTask.Result + if ($null -eq $line) { + $eDone = $true + } else { + Write-Host $line + $buffer.Add([string]$line) + $triple = Get-CliProgressTriple -Line $line + if ($null -ne $triple) { + $key = "$($triple.Pct)|$($triple.Phase)|$($triple.Msg)" + if ($key -ne $lastTripleKey) { + $lastTripleKey = $key + $lastTriple = $triple + $lastTripleAdvanceAt = [DateTime]::UtcNow + } + } + $eTask = $errReader.ReadLineAsync() + } + $progressed = $true + } + + $nowUtc = [DateTime]::UtcNow + + # Periodic human-readable progress notice (time-gated, NOT per-line). + # Reports the last triple + elapsed totals so an observer can see at a + # glance how far the install has come AND how long the stall clock has + # been ticking on the current triple. + $sinceNotice = ($nowUtc - $lastNoticeAt).TotalSeconds + if ($progressNoticeEnabled -and $sinceNotice -ge $progressNoticeIntervalSeconds) { + $lastNoticeAt = $nowUtc + $elapsedSec = [int][Math]::Floor(($nowUtc - $startedAt).TotalSeconds) + $stallElapsedSec = [int][Math]::Floor(($nowUtc - $lastTripleAdvanceAt).TotalSeconds) + if ($null -ne $lastTriple) { + $pctText = if ($lastTriple.Pct) { $lastTriple.Pct } else { '?' } + $phaseText = if ($lastTriple.Phase) { $lastTriple.Phase } else { '?' } + $msgText = if ($lastTriple.Msg) { $lastTriple.Msg } else { '?' } + Write-Host "::notice::Unity CLI install heartbeat: pct=$pctText phase=$phaseText msg=`"$msgText`" elapsed=${elapsedSec}s stallElapsed=${stallElapsedSec}s" + } else { + Write-Host "::notice::Unity CLI install heartbeat: no progress line observed yet elapsed=${elapsedSec}s stallElapsed=${stallElapsedSec}s" + } + } + + # HEARTBEAT-STALL DETECTOR. Fires only when the operator has not opted + # out AND the last observed triple has been unchanged for >= the + # configured window. Tree-kills with the distinct stall sentinel so + # the failure-diagnostic path can name "heartbeat stall" specifically. + if ($stallEnabled -and ($nowUtc - $lastTripleAdvanceAt).TotalSeconds -ge $StallSeconds) { + $stalled = $true + $timedOut = $true + try { + $proc.Kill($true) + } catch { + try { $proc.Kill() } catch { } + } + break + } + + if ($nowUtc -ge $deadline) { + # HUNG (or a quick-exit child whose grandchild still holds the pipe + # open, so EOF never arrives): kill the WHOLE process tree. The bool + # overload Kill($true) terminates descendants on .NET Core / PS7 (the + # Android NDK installer spawns child processes, so a bare Kill() would + # orphan them); a descendant already reparented away from a + # quick-exiting child is OS-unreachable by any tree walk -- the + # CRITICAL fix here is that we no longer mistake that case for success. + $timedOut = $true + try { + $proc.Kill($true) + } catch { + # Best-effort: the process may have exited between the check and + # the kill, or the platform may reject the descendant kill; fall + # back to a plain kill so at least the direct child dies. + try { $proc.Kill() } catch { } + } + break + } + + # Only sleep when NEITHER stream produced a line this iteration, so a + # busy stream is drained at full speed while an idle wait does not + # burn a core spinning. + if (-not $progressed) { + Start-Sleep -Milliseconds 50 + } + } + + # The loop ended either at EOF on both streams (normal/early exit) or at a + # kill. Reap the process so ExitCode is valid, bounded so a stuck reap + # cannot hang the harness. + $reaped = $proc.WaitForExit(5000) + + # Drain any line reads that completed during/after the kill so no pre-kill + # output is dropped. A non-$null Result is a buffered line; $null is EOF. + foreach ($pending in @($oTask, $eTask)) { + try { + if ($pending.Wait(2000) -and $null -ne $pending.Result) { + $line = $pending.Result + Write-Host $line + $buffer.Add([string]$line) + } + } catch { + # A faulted/cancelled read on a killed pipe carries nothing to add. + } + } + + if ($timedOut) { + # Distinguish heartbeat-stall (125) from wall-clock timeout (124) so + # callers + tests can attribute the failure mode precisely. Both are + # treated as retryable by the install retry classifier. + if ($stalled) { + $exit = $stallExitCode + } else { + $exit = $timeoutExitCode + } + } else { + # ExitCode is only valid after a CONFIRMED exit; HasExited guards the + # rare case the bounded reap above did not catch a (non-killed) exit. + if ($reaped -and $proc.HasExited) { + $exit = $proc.ExitCode + } else { + $exit = $timeoutExitCode + $timedOut = $true + } + } + } catch { + # Spawn/resolution failure (e.g. the CLI vanished or the path is bad). + # Mirror Invoke-UnityCliCapture's original catch: surface the message in the + # captured output AND emit it as a GitHub ::notice:: annotation (the prior + # implementation did both), and report exit -1. + $message = "Unity CLI capture invoker threw: $($_.Exception.Message)" + Write-Host "::notice::$message" + $buffer.Add($message) + $exit = -1 + } finally { + if ($proc) { $proc.Dispose() } + } + + # Snapshot the captured lines (already streamed LIVE, in arrival order, by the + # poll loop above) to a plain string[] for classification and the return value. + $captured = @($buffer.ToArray()) + + if ($timedOut) { + if (Get-Command Add-ProvisioningTimeoutEvent -ErrorAction SilentlyContinue) { + Add-ProvisioningTimeoutEvent -Arguments $Arguments -TimeoutSeconds $TimeoutSeconds + } + # Wrap-immune timeout annotation (Write-Host "::error::" is NOT subject to + # ConciseView word-wrap): name the timeout, the configured limit, the env + # knob to raise it, and the LAST progress message seen so CI has a stable, + # greppable summary of WHAT hung. The normal throw/classification flow + # still runs on the returned (retryable) failure. Reuse the de-duplicating + # tail formatter so the surfaced lines are READABLE even when the CLI spammed + # thousands of identical progress lines before the kill. + $lastProgress = Get-LastCliProgressMessage -Output $captured + $collapsedTail = Get-CollapsedCliOutputTail -Output $captured -MaxLines 10 + if ($stalled) { + # HEARTBEAT-STALL kill: distinct sentinel (125) AND distinct annotation + # wording so an observer can tell at a glance whether the install was + # killed for "no triple advance in N seconds" (this branch) versus + # "exceeded the total wall-clock budget" (the else branch below). + $stallKnobName = if ($StallKnob) { $StallKnob } else { 'UH_ENSURE_EDITOR_PROGRESS_STALL_SECONDS' } + Write-Host "::error::Unity CLI command '$($Arguments -join ' ')' HEARTBEAT STALLED after $StallSeconds second(s) with no progress (pct, phase, msg) advance; the process tree was killed (sentinel exit $stallExitCode). Raise the threshold via $stallKnobName (0 disables the heartbeat detector). Last progress message: $lastProgress. Collapsed tail:`n$collapsedTail" + } else { + $knob = if ($TimeoutKnob) { $TimeoutKnob } else { 'UH_ENSURE_EDITOR_INSTALL_TIMEOUT_SECONDS' } + Write-Host "::error::Unity CLI command '$($Arguments -join ' ')' TIMED OUT after $TimeoutSeconds second(s) and the process tree was killed (sentinel exit $timeoutExitCode). Raise the limit via $knob (0 disables the timeout). Last progress message: $lastProgress. Collapsed tail:`n$collapsedTail" + } + } + + # StallKilled / TimedOutWallClock distinguish a WRAPPER-DRIVEN kill (the + # heartbeat detector or the wall-clock deadline) from a NATIVE non-zero exit + # that happens to share the same sentinel code. A native exit 125 from the + # Unity CLI must NOT be misread as "heartbeat stalled" by downstream + # classifiers, and a native exit 124 likewise must not be misread as + # "wall-clock timeout." Both are derived ONLY from the in-process kill + # state, never from $exit, so a coincidental native exit code cannot + # impersonate a kill. + return @{ + Success = ($exit -eq 0) + ExitCode = $exit + Output = @($captured) + StallKilled = [bool]$stalled + TimedOutWallClock = [bool]($timedOut -and -not $stalled) + } +} + +function Write-UnityCliInstallFailureAnnotation { + # HIGH-SIGNAL, additive CI annotation for a FAILED module install. Scans the + # captured CLI output for the two failure SIGNATURES this script has been bitten + # by and emits a targeted ::error::/::warning:: that NAMES the remediation, so a + # future regression of this exact class is obvious at a glance in the CI log + # instead of buried in a generic exit-code dump. Additive only: callers still + # throw/log their full message + arg vector + exit code separately. Best-effort + # and StrictMode-safe: never throws, @()-wraps the output capture. + param( + [Parameter(Mandatory = $true)][string]$Version, + [string[]]$Output, + [int]$ExitCode = -1, + [string[]]$Arguments + ) + + $text = (@($Output) -join "`n") + $argLine = if ($Arguments) { ($Arguments -join ' ') } else { '(unavailable)' } + + # Match only the ACTUAL EULA-rejection phrasing, never a bare `--accept-eula`. + # The real failing log line is "Error: One or more modules require license + # acceptance. Pass --accept-eula to accept all module license terms and + # proceed." -- matching the remediation phrase `Pass --accept-eula` (or the + # "require[s] license acceptance" cause) avoids a self-false-positive if the CLI + # ever echoes our own invoked args (which contain `--accept-eula`) back to stdout. + if ($text -match '(?i)require[s]? license acceptance|Pass\s+--accept-eula') { + # The fix is structural (Get-UnityCliModuleInstallArguments injects + # --accept-eula for every module install); if this still fires, the flag is + # no longer being honored by this CLI build for this verb. + Write-Host "::error::Unity $Version module install was rejected for missing EULA acceptance (exit $ExitCode). Every module install in this script must pass --accept-eula via Get-UnityCliModuleInstallArguments. Args: $argLine" + } + if ($text -match "(?i)couldn't find module|could not find module|missing module|did you mean") { + # A requested -m id is unknown to this CLI build (e.g. a version-pinned id + # drifted). OpenJDK is intentionally NOT requested (it arrives as an + # android-sdk-ndk-tools dependency); any other id here needs correcting in + # Get-UnityCiModuleIds. + Write-Host "::warning::Unity $Version module install reported an unknown module id (exit $ExitCode). Check the 'Did you mean:' hint in the CLI output above and reconcile Get-UnityCiModuleIds. Args: $argLine" + } +} + +function Test-VcRedistGeneration { + # CLASSIFIES a list of missing-DLL names into the Microsoft Visual C++ + # redistributable generation that ships them. Returns one of four string + # tags so the annotation's cause-line can be precise: + # 'vc2010' -- missing includes MSVCP100/MSVCR100 (2010 SP1 generation) + # 'vcmodern' -- missing includes MSVCP140/VCRUNTIME140/VCRUNTIME140_1 + # (2015-2022 generation) + # 'both' -- missing contains BOTH 2010 and modern markers + # 'neither' -- missing contains neither generation's marker DLLs (the + # missing DLL is something else: KERNEL32/ucrtbase/Unity- + # shipped/etc.) + # Match is CASE-INSENSITIVE; `.dll` suffix is OPTIONAL. NEVER throws. + # Empty / null input returns 'neither' (no evidence either way). + # + # WHY this matters: production run 70874414898 identified MSVCP100.dll as + # the load-bearing missing DLL on both self-hosted Windows runners. The + # previous annotation hard-coded "missing VC++ 2015-2022 Redistributable" + # which was wrong for MSVCP100 (a 2010 file). The 2010 and 2015-2022 + # redistributables are SEPARATE Microsoft packages -- installing one does + # NOT install the other. The annotation needs to tell the operator which + # generation to install, NOT both / neither / the wrong one. + param( + [Parameter(Mandatory = $true)] + [AllowEmptyCollection()] + [AllowNull()] + [string[]]$MissingDlls + ) + + if ($null -eq $MissingDlls -or $MissingDlls.Count -eq 0) { + return 'neither' + } + + # Case-insensitive regex anchors. The .dll suffix is optional so a name + # like 'MSVCP100' (without extension) still classifies correctly. We + # match the prefix only -- 'MSVCP100' matches 'MSVCP100.dll' and bare + # 'MSVCP100', but NOT 'MSVCP100_v2.dll' (that would be a future-version + # variant the heuristic should not silently absorb). + $vc2010Pattern = '(?i)^(MSVCP100|MSVCR100)(\.dll)?$' + $vcmodernPattern = '(?i)^(MSVCP140|MSVCR140|VCRUNTIME140|VCRUNTIME140_1)(\.dll)?$' + + $has2010 = $false + $hasModern = $false + foreach ($name in $MissingDlls) { + if ([string]::IsNullOrWhiteSpace($name)) { continue } + $trimmed = $name.Trim() + # Strip any leading directory component (defensive: callers should + # pass bare filenames, but the resolver may surface a full path on + # some edge cases). + try { $trimmed = [System.IO.Path]::GetFileName($trimmed) } catch { } + if ($trimmed -match $vc2010Pattern) { + $has2010 = $true + } + if ($trimmed -match $vcmodernPattern) { + $hasModern = $true + } + } + + if ($has2010 -and $hasModern) { return 'both' } + if ($has2010) { return 'vc2010' } + if ($hasModern) { return 'vcmodern' } + return 'neither' +} + +function Write-UnityHostPrereqAnnotation { + # WRAP-IMMUNE, single-line CI annotation for the 0xC0000135 short-circuit in + # Ensure-UnityNativeStartupHealthy. Emits a `::error::` line that NAMES the + # operator-actionable remediation (run scripts/unity/bootstrap-windows-runner.ps1, + # or trigger the runner-bootstrap workflow) AND the runbook, so the failure + # surfaces as a host-OS prerequisite problem instead of a generic Unity install + # error. The annotation also lists the DLLs Unity.exe IMPORTS (best-effort, via + # Get-UnityNativeImports) -- listing imports does not tell us WHICH one is + # missing, but seeing `VCRUNTIME140.dll` / `MSVCP140.dll` / `VCRUNTIME140_1.dll` + # in the list pins the missing Microsoft Visual C++ Redistributable as the + # overwhelming-most-likely culprit at a glance. + # + # WHY a separate single-line annotation when the throw text right after it also + # carries this info? PowerShell's ConciseView error formatter word-wraps thrown + # text at the runner console width and prepends frame markers; that makes the + # throw message an unreliable grep target. A `Write-Host "::error::..."` line is + # never wrapped or reformatted by the runner, so it survives as a stable single + # line in the CI log AND as a stable assertion target for the regression tests + # that pin this branch. See reference_pwsh_error_wrap_test_fragility for the + # full pattern. + # + # NEVER THROWS: a failure inside the diagnostic must not mask the underlying + # 0xC0000135 throw the caller is about to raise. Get-UnityNativeImports is itself + # best-effort; we still emit the rest of the line even with no imports. + # + # CONTEXT-AWARE CAUSE PHRASING: when the composite preflight action + # (`assert-unity-host-prereqs`) has already installed VC++ at job start, it + # exports UH_RUNNER_PREREQ_INSTALLED=1 to the rest of the job. In that case the + # 0xC0000135 we are now diagnosing CANNOT be a missing VC++ Redistributable + # (preflight just installed it successfully); it is a DIFFERENT missing DLL -- + # Unity-version-specific, a corrupt install, or a runtime DLL deleted mid-job. + # The annotation branches on that env var so we never tell the operator "install + # VC++" after preflight already did. When the env var is unset (or any value + # other than '1') we keep the original VC++-most-likely phrasing. + # + # REPAIR-PATH AWARENESS: callers that fire this annotation AFTER a managed + # reinstall has already run pass -RepairAttempted; the annotation then says + # "managed reinstall already ran and did not help" so the operator does not waste + # cycles asking us to retry the auto-repair we already attempted. + param( + [Parameter(Mandatory = $true)][string]$Version, + [Parameter(Mandatory = $true)][int]$ExitCode, + [Parameter(Mandatory = $true)][string]$Description, + [string]$EditorPath, + [string]$ProbeLog, + [switch]$RepairAttempted + ) + + try { + # OUTER @()-wrap is REQUIRED under StrictMode: when the `if` branches both + # evaluate to an empty array, the right-hand-side captures $null without the + # wrap (PowerShell "implicit unrolling"), and the subsequent .Count access + # throws. See reference_powershell_strictmode_collection_safety. + $imports = @(if ($EditorPath) { + Get-UnityNativeImports -EditorPath $EditorPath + } else { + @() + }) + + # RESOLUTION PROBE: take the full import list and resolve each entry + # against the Windows loader search path. The result hashtable lets us + # NAME the specific missing DLL(s) instead of listing the first 12 + # imports and saying "(+24 more)". Test-UnityImportResolution NEVER + # THROWS: on any failure (non-Windows, unreadable registry, no editor + # path) it falls through to "everything missing" or partial data, both + # of which still produce a useful annotation. + $resolution = $null + if ($EditorPath -and $imports.Count -gt 0) { + $resolution = Test-UnityImportResolution -EditorPath $EditorPath -Imports $imports + } + + # Resolved-count tallies (always computed, even when $resolution is + # $null, so the annotation's "Resolved: ..." segment is uniform). + $missingList = @() + $systemCount = 0 + $windowsCount = 0 + $unityCount = 0 + $pathCount = 0 + $knownDllsCount = 0 + if ($resolution) { + $missingList = @($resolution.missing) + $systemCount = $resolution.systemResolved.Count + $windowsCount = $resolution.windowsResolved.Count + $unityCount = $resolution.unityResolved.Count + $pathCount = $resolution.pathResolved.Count + $knownDllsCount = $resolution.knownDllsResolved.Count + } + + # MISSING-DLL phrasing: when the resolver identifies at least one DLL + # the OS loader could not find, NAME it. Otherwise (everything + # resolves), point at transitive dependencies / loader-init policy. + if ($missingList.Count -gt 0) { + # Sub-classify: if ANY missing DLL looks Unity-shipped (libfbxsdk, + # optix*, OpenImageDenoise, *compress*, FreeImage, etc.), point at + # "install corruption" because reinstalling Unity WILL fix that + # case -- and surface UH_UNITY_FORCE_REINSTALL=1 as the + # operator-actionable override for the 0xC0000135 short-circuit. + $unityShippedMissing = @($missingList | Where-Object { Test-UnityImportLooksUnityShipped -Name $_ }) + $missingNames = ($missingList -join ', ') + # R1 (round-3 review nit): segments do NOT end with `.` -- the + # outer Write-Host template inserts the period between segments so + # double-period regressions cannot creep in. Each $...Segment + # value is a CLAUSE not a SENTENCE. + $missingSegment = "MISSING DLL(s): $missingNames -- these are imported by Unity.exe but were not found on the Windows loader search path (KnownDLLs / Unity install dir / System32 / Windows / PATH); install these DLLs on the host" + if ($unityShippedMissing.Count -gt 0) { + $editorDirForHint = if ($EditorPath) { + try { Split-Path -Parent $EditorPath } catch { '(unknown)' } + } else { '(unknown)' } + $missingSegment += "; some missing DLLs ($($unityShippedMissing -join ', ')) appear to be Unity-shipped third-party libraries, suggesting the Unity install at $editorDirForHint is partial or corrupt -- quarantine and reinstall via ``unity install $Version`` (or set UH_UNITY_FORCE_REINSTALL=1 to override the 0xC0000135 short-circuit and let ensure-editor.ps1 perform a managed reinstall)" + } + $diagnosticSegment = $missingSegment + } else { + # Either the resolver found everything OR we had no imports to + # resolve (best-effort fallthrough). Both phrasings carry the + # transitive-dependency hint. + if ($imports.Count -gt 0) { + $diagnosticSegment = "All Unity.exe imports resolve on the loader search path, yet the OS loader still failed -- this is unusual; possible causes: (a) a transitive dependency (one of the imported DLLs has its own unresolved dependency); (b) loader-init-time security policy block (EDR/AppLocker/CIG); (c) a malformed Unity.exe -- try running ``gflags.exe -i Unity.exe +sls`` on the host to enable loader snaps and re-run for more detail" + } else { + $diagnosticSegment = "Unity.exe imports: (could not enumerate) -- the OS loader failed but the diagnostic could not list the imports; inspect the probe log" + } + } + + # Resolved-count diagnostic appears IN ADDITION to the named-missing + # block, so the operator sees both "what is missing" and "how much + # successfully resolved" (useful when only one DLL is missing out of + # ~36). R4 (round-3 review nit): omit the segment entirely when there + # are no imports to resolve -- the diagnostic segment already says + # "could not enumerate" and "Resolved: 0+0+0+0+0 out of 0 total" is + # operator-confusing noise that adds nothing actionable. + $resolvedSegment = if ($imports.Count -gt 0) { + "Resolved: $systemCount system + $unityCount editor + $windowsCount Windows + $pathCount PATH + $knownDllsCount KnownDLLs out of $($imports.Count) total imports" + } else { + $null + } + + $probeLine = if ($ProbeLog) { "Probe log: $ProbeLog. " } else { '' } + # CONTEXT-AWARE cause phrasing: if preflight already installed both VC++ + # generations this job, the missing DLL is something else; otherwise + # SUB-CLASSIFY by the generation Microsoft ships the missing DLL in + # (2010 vs 2015-2022 vs both). The 2010 generation is a SEPARATE + # Microsoft package -- the bootstrap's `vcredist-2015-2022` step alone + # does NOT install MSVCP100. R2 (round-3 review minor): + # SUPPRESS the VC++ cause line when every import resolves (the + # "all resolve" diagnostic segment directly contradicts a "missing + # VC++" claim). Same when no imports could be enumerated (no evidence + # for the VC++ hypothesis either way). + $preflightRan = ($env:UH_RUNNER_PREREQ_INSTALLED -eq '1') + $generation = Test-VcRedistGeneration -MissingDlls $missingList + $causeLine = if ($preflightRan) { + "Preflight ran successfully at job start (VC++ 2010/VC++ 2015-2022/long-paths/Defender/pwsh OK), so this is a DIFFERENT missing DLL (Unity-version-specific or corrupt install). Re-running the bootstrap script will NOT help. If the missing DLL is MSVCP100.dll, the host needs Microsoft Visual C++ 2010 Redistributable; the bootstrap script's 'vcredist-2010' step installs this." + } elseif ($missingList.Count -eq 0 -and $imports.Count -gt 0) { + # The resolver found every import on the loader search path -- the + # VC++ cause line would directly contradict the "All Unity.exe + # imports resolve" diagnostic segment. Suppress it. (When we + # could not enumerate imports AT ALL -- $imports.Count == 0 -- + # we keep the default VC++ cause hypothesis: lack of evidence + # is not evidence of lack.) + $null + } elseif ($generation -eq 'both') { + # Both 2010 AND 2015-2022 markers in the missing-DLL list: the host + # is missing BOTH redistributable packages. Direct the operator at + # the bootstrap which installs both generations. + "Most likely cause: missing Microsoft Visual C++ Redistributables (BOTH 2010 AND 2015-2022 x64 are missing). Run the bootstrap script as Administrator to install both." + } elseif ($generation -eq 'vc2010') { + # MSVCP100 / MSVCR100 in the missing-DLL list -- the host needs the + # 2010 SP1 redistributable specifically. The modern installer does + # NOT install these (they are a separate Microsoft package). + "Most likely cause: missing Microsoft Visual C++ 2010 Redistributable (x64). Run the bootstrap script as Administrator to install it." + } elseif ($generation -eq 'vcmodern') { + # MSVCP140 / VCRUNTIME140 / VCRUNTIME140_1 in the missing-DLL list + # -- the modern 2015-2022 generation. Preserve the original + # wording so existing test assertions stay green. + "Most likely cause: missing Microsoft Visual C++ 2015-2022 Redistributable (x64). Run the bootstrap script as Administrator to install it." + } else { + # 'neither' (no VC++ marker in the missing-DLL list). The default + # hypothesis still surfaces VC++ 2015-2022 -- it is the single + # most common cause empirically -- but mentions VC++ 2010 as a + # near-second so operators with MSVCP100 missing still get a hint. + "Most likely cause: missing Microsoft Visual C++ 2015-2022 Redistributable (x64); if the missing DLL is MSVCP100.dll, install Microsoft Visual C++ 2010 Redistributable (x64) instead. Run the bootstrap script as Administrator to install both." + } + # REPAIR-PATH awareness: phrase the remediation line based on whether the + # caller had already tried the managed reinstall before firing this + # annotation (post-repair short-circuit) vs. firing it on the first probe + # failure (no reinstall yet). + $repairLine = if ($RepairAttempted) { + "The managed reinstall already ran and did not help (as expected for 0xC0000135 -- the missing DLL is on the OS, not in the Unity install)." + } else { + $null + } + # Build remediation line. When preflight already ran, bootstrap will not + # help; otherwise direct the operator to run it. + $fixLine = if ($preflightRan) { + "Fix: identify the missing DLL from above and install it on the host (or reimage the runner). Runbook: docs/runbooks/unity-runners-after-transfer.md (Windows host prerequisites)." + } else { + "Fix: run scripts/unity/bootstrap-windows-runner.ps1 on this runner, or trigger .github/workflows/runner-bootstrap.yml from the Actions UI. Runbook: docs/runbooks/unity-runners-after-transfer.md (Windows host prerequisites)." + } + # Single-line, wrap-immune ::error:: annotation. Do NOT split across lines: + # the runner emits each Write-Host as one CI log line, and a multi-line + # annotation degrades to a generic ::error::. The standalone "(0xC0000135 + # / STATUS_DLL_NOT_FOUND)" parenthetical is intentionally absent here: it + # is already carried by $Description (e.g. "0xC0000135 / STATUS_DLL_NOT_FOUND") + # rendered in "exit $ExitCode ($Description)" so repeating it is redundant. + $repairSegment = if ($repairLine) { "$repairLine " } else { '' } + # R1 (round-3 review nit) + R2/R4 conditionality: only emit + # segments that have content, separated by `. ` exactly once. + # Building via a builder avoids "$x. $y. $z." producing double + # periods when an intermediate segment is null/empty. + $causeFragment = if ($causeLine) { "$causeLine " } else { '' } + $diagnosticFragment = if ($diagnosticSegment) { "$diagnosticSegment. " } else { '' } + $resolvedFragment = if ($resolvedSegment) { "$resolvedSegment. " } else { '' } + Write-Host "::error title=Unity $Version host prerequisite missing::Unity $Version native startup failed with exit $ExitCode ($Description). The Windows loader could not resolve a DLL Unity.exe imports. ${causeFragment}${diagnosticFragment}${resolvedFragment}${probeLine}${repairSegment}$fixLine" + } catch { + # A failure here must not mask the caller's throw. Emit a minimal fallback + # so the operator still sees the host-prereq verdict, then swallow. + try { + $fallbackPreflight = ($env:UH_RUNNER_PREREQ_INSTALLED -eq '1') + if ($fallbackPreflight) { + Write-Host "::error title=Unity $Version host prerequisite missing::Unity $Version native startup failed with exit $ExitCode. Preflight already installed VC++ (both 2010 and 2015-2022 generations) this job, so a DIFFERENT host DLL is missing -- inspect the Unity.exe imports above. Runbook: docs/runbooks/unity-runners-after-transfer.md." + } else { + Write-Host "::error title=Unity $Version host prerequisite missing::Unity $Version native startup failed with exit $ExitCode. Likely missing Microsoft Visual C++ Redistributables (2010 SP1 x64 ships MSVCP100.dll/MSVCR100.dll; 2015-2022 x64 ships VCRUNTIME140.dll/MSVCP140.dll -- both are required for Unity). Run scripts/unity/bootstrap-windows-runner.ps1. Runbook: docs/runbooks/unity-runners-after-transfer.md." + } + } catch { + # Truly nothing more we can do; let the caller's throw fail loudly. + } + } +} + +function Get-InstallDriveFreeSpaceText { + # PURE-ish, best-effort, StrictMode-safe disk-headroom probe shared by the + # pre-install diagnostic dump (Write-InstallDiagnostics) and the on-failure + # wrap-immune summary (Write-ModuleInstallFailureDiagnostics). A multi-GB + # module download that runs out of disk is a prime suspect for a slow/failed + # install, so the free/total space belongs in BOTH places. Returns a single + # human line (never throws, never $null) so callers can drop it straight into + # an annotation. + param([Parameter(Mandatory = $true)][string]$Root) + + try { + $rootFull = [System.IO.Path]::GetFullPath($Root) + $drive = [System.IO.Path]::GetPathRoot($rootFull) + if (-not $drive) { + return "install drive for '$Root': (undeterminable)" + } + $driveInfo = New-Object System.IO.DriveInfo($drive) + $freeGb = [Math]::Round($driveInfo.AvailableFreeSpace / 1GB, 2) + $totalGb = [Math]::Round($driveInfo.TotalSize / 1GB, 2) + return "install drive $drive free space: $freeGb GB free of $totalGb GB total" + } catch { + return "install drive for '$Root': (query failed: $($_.Exception.Message))" + } +} + +function Write-ModuleInstallFailureDiagnostics { + # WRAP-IMMUNE, single-line CI failure summary for ANY module-install failure + # (a TIMEOUT kill OR a non-zero exit). PowerShell's ConciseView formatter + # word-wraps a `throw` message at the console width, so the throw text alone is + # an unreliable single-line annotation; a `Write-Host "::error::..."` line is + # NOT wrapped, giving CI a stable, greppable failure summary AND a robust + # assertion target. Additive only -- the caller still throws its full message. + # + # The summary names: the version, the failing verb/args, the outcome (exit code + # OR "wall-clock timed out after Ns" OR "heartbeat stalled after Ns" -- chosen + # by the kill-state booleans, NOT the raw exit code, so a NATIVE 125 from the + # Unity CLI is never misattributed as a heartbeat-stall kill), the LAST + # meaningful progress message parsed from the captured output (the JSON + # "msg" of the last progress line, else the last non-empty line -- via + # Get-LastCliProgressMessage), and the install-drive free space (via the + # shared Get-InstallDriveFreeSpaceText). Best-effort and StrictMode-safe: + # never throws, @()-wraps the output capture. + param( + [Parameter(Mandatory = $true)][string]$Version, + [string[]]$Output, + [int]$ExitCode = -1, + [string[]]$Arguments, + [string]$Root, + [switch]$TimedOut, + [switch]$StallKilled, + [switch]$TimedOutWallClock, + [int]$TimeoutSeconds = 0, + [int]$StallSeconds = 0 + ) + + $argLine = if ($Arguments) { ($Arguments -join ' ') } else { '(unavailable)' } + $lastProgress = Get-LastCliProgressMessage -Output $Output + # Phrase the outcome from the KILL-STATE BOOLEANS supplied by the caller, NOT + # from the raw exit code: a Unity CLI that ORGANICALLY exits with 124 or 125 + # would otherwise be misattributed to a wrapper-driven timeout/stall kill. + # The -TimedOut switch remains accepted as the legacy "either kind of + # wrapper-driven timeout" signal for backward compatibility with any caller + # that has not migrated; the new switches WIN when supplied. + $outcome = if ($StallKilled) { + "heartbeat stalled after $StallSeconds second(s)" + } elseif ($TimedOutWallClock) { + "wall-clock timed out after $TimeoutSeconds second(s)" + } elseif ($TimedOut) { + "timed out after $TimeoutSeconds second(s)" + } else { + "exit code $ExitCode" + } + $diskText = if ($Root) { Get-InstallDriveFreeSpaceText -Root $Root } else { 'install drive: (unknown root)' } + + Write-Host "::error::Unity $Version module install FAILED ($outcome). Verb/args: $argLine. Last progress message: $lastProgress. Disk: $diskText" +} + +function Test-LooksLikeAbsolutePath { + # True only for a Windows drive-letter path (C:\...) or a UNC path (\\...). + # Guards the getter resolver against decorated/empty/relative output from a + # beta CLI that might print a banner or a prompt instead of a bare path. + param([string]$Value) + + if (-not $Value) { + return $false + } + $trimmed = $Value.Trim() + if ($trimmed.Length -lt 3) { + return $false + } + if ($trimmed -match '^[A-Za-z]:[\\/]') { + return $true + } + if ($trimmed.StartsWith('\\')) { + return $true + } + if ($trimmed.StartsWith('/')) { + return $true + } + + return $false +} + +function Get-UnityCliInstallRoot { + # GETTER-based authoritative resolver. `unity install-path` with NO args is + # a 0-arg getter that PRINTS the CLI's current editor install directory. + # This reports the CLI's REAL install location regardless of whether our + # best-effort SET succeeded, so discovery does not depend on the (uncertain) + # set flag. Take the last non-empty path-like stdout line; ignore banners + # and decorated output. + $lines = Get-UnityCliOutput -Arguments @('install-path') + if (-not $lines) { + return $null + } + + $candidate = $null + foreach ($line in $lines) { + if ($null -eq $line) { + continue + } + $trimmed = ([string]$line).Trim() + if ($trimmed.Length -eq 0) { + continue + } + if (Test-LooksLikeAbsolutePath $trimmed) { + $candidate = $trimmed + } + } + + if ($candidate) { + Write-CiNotice "Unity CLI reports install root: $candidate" + } + + return $candidate +} + +function Set-UnityCliInstallPath { + # BEST-EFFORT. Setting the install path is an OPTIMIZATION (it co-locates + # editors under our chosen root), never a requirement: discovery falls back + # to the getter-reported root and the candidate search. The SET flag for the + # standalone CLI is NOT documented exactly (the Hub CLI uses `-s `; the + # standalone CLI very likely mirrors `-s`/`--set`). Try `-s` first, then + # `--set`; if both fail, emit a ::notice:: (NOT an error) and continue. + param([Parameter(Mandatory = $true)][string]$Root) + + if (Invoke-UnityCliSafe -Arguments @('install-path', '-s', $Root)) { + return + } + + if (Invoke-UnityCliSafe -Arguments @('install-path', '--set', $Root)) { + return + } + + Write-CiNotice "Could not set the Unity CLI install path to '$Root' (best-effort; the standalone CLI set flag may differ). Continuing; discovery will use the CLI-reported install root." +} + +function Confirm-UnityCliManagedInstallRoot { + param([Parameter(Mandatory = $true)][string]$Root) + + $cliRoot = Get-UnityCliInstallRoot + if (-not $cliRoot) { + # Emit a wrap-IMMUNE CI annotation BEFORE the throw. PowerShell's + # ConciseView formatter word-wraps a thrown message at the console width + # (splitting phrases across a ` | ` gutter), so the throw text alone is + # an unreliable single-line annotation; Write-Host output is never wrapped, + # giving CI a clean ::error:: line AND a stable assertion target. Additive + # only -- the throw below still aborts with identical semantics. + Write-Host "::error::CI-managed Unity provisioning cannot mutate editors because the Unity CLI did not report an install root after setting '$Root'." + throw "CI-managed Unity provisioning cannot mutate editors because the Unity CLI did not report an install root after setting '$Root'." + } + if (-not (Test-IsPathInsideDirectory -Path $cliRoot -Directory $Root)) { + # Wrap-immune CI annotation before the throw (see the note above): the + # "outside the managed root" phrase is exactly the one PowerShell's + # word-wrap was observed to split on the narrower Windows runner. + Write-Host "::error::CI-managed Unity provisioning cannot mutate editors because the Unity CLI install root is outside the managed root. CLI root: '$cliRoot'. Managed root: '$Root'." + throw "CI-managed Unity provisioning cannot mutate editors because the Unity CLI install root is outside the managed root. CLI root: '$cliRoot'. Managed root: '$Root'." + } + return $cliRoot +} + +function Resolve-EditorFromCliJson { + # DEFENSIVE parse of `unity editors -i --format json`. The JSON schema is + # NOT documented for the standalone CLI, so this scans every object for an + # entry whose version matches $Version and pulls ANY plausible path-like + # field. ConvertFrom-Json is wrapped in try/catch: malformed/non-JSON output + # (e.g. a banner-prefixed beta response) returns $null instead of throwing. + param( + [Parameter(Mandatory = $true)][string]$Version + ) + + $lines = Get-UnityCliOutput -Arguments @('editors', '-i', '--format', 'json') + if (-not $lines) { + return $null + } + + $jsonText = ($lines -join "`n").Trim() + if ($jsonText.Length -eq 0) { + return $null + } + + try { + $parsed = $jsonText | ConvertFrom-Json + } catch { + Write-Host "::notice::Could not parse 'unity editors -i --format json' output as JSON; continuing with candidate search." + return $null + } + + if ($null -eq $parsed) { + return $null + } + + # Normalize to a flat list of objects whether the CLI returned a top-level + # array, a single object, or the most-likely real schema: a single object + # that WRAPS the editor array under a property (e.g. {"editors":[...]}). + # For the wrapped case we keep the wrapper object itself as a candidate AND + # descend into any property whose value is a non-string IEnumerable, flattening + # those items into $entries so the version-field scan below sees the real + # editor records instead of mis-treating the wrapper as the lone entry. + $entries = New-Object System.Collections.Generic.List[object] + if ($parsed -is [System.Collections.IEnumerable] -and $parsed -isnot [string]) { + foreach ($item in $parsed) { $entries.Add($item) } + } else { + $entries.Add($parsed) + foreach ($prop in $parsed.PSObject.Properties) { + if ($prop.Value -is [System.Collections.IEnumerable] -and $prop.Value -isnot [string]) { + foreach ($item in $prop.Value) { $entries.Add($item) } + } + } + } + + $versionFields = @('version', 'editorVersion', 'unityVersion', 'name') + $pathFields = @('path', 'location', 'installPath', 'executable', 'installation', 'editorPath') + + foreach ($entry in $entries) { + if ($null -eq $entry) { continue } + + $matchesVersion = $false + foreach ($vf in $versionFields) { + $vv = $null + try { $vv = $entry.$vf } catch { $vv = $null } + if ($vv -and ([string]$vv).Trim() -eq $Version) { + $matchesVersion = $true + break + } + } + if (-not $matchesVersion) { continue } + + foreach ($pf in $pathFields) { + $pv = $null + try { $pv = $entry.$pf } catch { $pv = $null } + if (-not $pv) { continue } + $pathValue = ([string]$pv).Trim() + if ($pathValue.Length -eq 0) { continue } + + # The field may already be Unity.exe, or a directory we must probe. + if ((Test-Path -LiteralPath $pathValue -PathType Leaf) -and + $pathValue.ToLowerInvariant().EndsWith('unity.exe')) { + return (Resolve-Path -LiteralPath $pathValue).Path + } + + $exeProbe = @( + (Join-Path $pathValue 'Editor\Unity.exe'), + (Join-Path $pathValue 'Unity.exe') + ) + foreach ($probe in $exeProbe) { + if (Test-Path -LiteralPath $probe -PathType Leaf) { + return (Resolve-Path -LiteralPath $probe).Path + } + } + } + } + + return $null +} + +function Resolve-InstalledEditor { + # Layered discovery, in order: + # (a) under the getter-reported CLI install root (authoritative), + # (b) under the configured $InstallRoot (candidate search), + # (c) defensive parse of `unity editors -i --format json`. + # Returns the absolute Unity.exe path, or $null if every strategy fails. + param( + [Parameter(Mandatory = $true)][string]$Version, + [Parameter(Mandatory = $true)][string]$Root, + [switch]$ManagedOnly + ) + + $cliRoot = Get-UnityCliInstallRoot + if ($cliRoot) { + foreach ($candidate in @( + (Join-Path $cliRoot "$Version\Editor\Unity.exe"), + (Join-Path $cliRoot "$Version\Unity.exe"))) { + if (Test-Path -LiteralPath $candidate -PathType Leaf) { + if ($ManagedOnly -and -not (Test-IsPathInsideDirectory -Path $candidate -Directory $Root)) { + Write-CiNotice "Ignoring Unity $Version from CLI install root because it is outside the managed install root: $candidate" + continue + } + return (Resolve-Path -LiteralPath $candidate).Path + } + } + } + + $byCandidate = Find-UnityEditor -Version $Version -Root $Root -IncludeHostInstalls:(-not $ManagedOnly) + if ($byCandidate) { + return $byCandidate + } + + $byJson = Resolve-EditorFromCliJson -Version $Version + if ($byJson) { + if ($ManagedOnly -and -not (Test-IsPathInsideDirectory -Path $byJson -Directory $Root)) { + Write-CiNotice "Ignoring Unity $Version from CLI editor inventory because it is outside the managed install root: $byJson" + return $null + } + return $byJson + } + + return $null +} + +function Test-Il2CppModulePresent { + # Disk-authoritative, best-effort probe for whether Windows IL2CPP support is + # already installed for a resolved editor. The standalone CLI's + # `install-modules` output is not a reliable success source by itself. Disk + # evidence is the success proof we accept after a non-zero module install. + # + # IMPORTANT: the exact on-disk layout VARIES BY UNITY VERSION (and has shifted + # across the 2020->6000 lineage), so this probes concrete STANDALONE-SPECIFIC + # player leaves under known Windows IL2CPP variations. Empty directories are + # not enough proof: failed or partial module installs have left folder shells + # behind without a usable player/toolchain payload. + param([Parameter(Mandatory = $true)][string]$EditorPath) + + if (-not $EditorPath) { + return $false + } + + try { + # $EditorPath looks like ...\\Editor\Unity.exe; the editor data + # root is its parent directory + 'Data'. + $editorDir = Split-Path -Parent $EditorPath + if (-not $editorDir) { + return $false + } + $dataRoot = Join-Path $editorDir 'Data' + $standaloneVariations = Join-Path $dataRoot 'PlaybackEngines\windowsstandalonesupport\Variations' + + $variationNames = @( + 'win64_player_development_il2cpp', + 'win64_player_nondevelopment_il2cpp' + ) + foreach ($variationName in $variationNames) { + $variation = Join-Path $standaloneVariations $variationName + $leafCandidates = @( + (Join-Path $variation 'WindowsPlayer.exe'), + (Join-Path $variation 'UnityPlayer.dll'), + (Join-Path $variation 'GameAssembly.dll') + ) + foreach ($candidate in $leafCandidates) { + if (Test-Path -LiteralPath $candidate -PathType Leaf) { + return $true + } + } + $payloadLeaves = @( + Get-ChildItem -LiteralPath $variation -File -ErrorAction SilentlyContinue | + Where-Object { $_.Name -match '(?i)\.(dll|exe)$' } + ) + if ($payloadLeaves.Count -gt 0) { + return $true + } + } + } catch { + # Any unexpected probe error is non-fatal: treat as "inconclusive" (false). + return $false + } + + return $false +} + +function Test-AnyUnityLeafPresent { + param([Parameter(Mandatory = $true)][string[]]$Paths) + + foreach ($path in $Paths) { + if (Test-Path -LiteralPath $path -PathType Leaf) { + return $true + } + } + + return $false +} + +function Test-IsPathInsideDirectory { + param( + [Parameter(Mandatory = $true)][string]$Path, + [Parameter(Mandatory = $true)][string]$Directory + ) + + $fullPath = [System.IO.Path]::GetFullPath($Path).TrimEnd([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar) + $fullDirectory = [System.IO.Path]::GetFullPath($Directory).TrimEnd([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar) + $isWindowsHost = [System.IO.Path]::DirectorySeparatorChar -eq '\' + $comparison = if ($isWindowsHost -or $PSVersionTable.PSEdition -eq 'Desktop') { + [System.StringComparison]::OrdinalIgnoreCase + } else { + [System.StringComparison]::Ordinal + } + + return $fullPath.Equals($fullDirectory, $comparison) -or + $fullPath.StartsWith($fullDirectory + [System.IO.Path]::DirectorySeparatorChar, $comparison) -or + $fullPath.StartsWith($fullDirectory + [System.IO.Path]::AltDirectorySeparatorChar, $comparison) +} + +function Get-UnityEditorInstallDirectory { + param([Parameter(Mandatory = $true)][string]$EditorPath) + + $editorDir = Split-Path -Parent $EditorPath + if (-not $editorDir) { + return $null + } + + $leaf = Split-Path -Leaf $editorDir + if ($leaf -eq 'Editor') { + return (Split-Path -Parent $editorDir) + } + + return $editorDir +} + +function Get-UnityProvisioningProfile { + $profileVar = Get-Variable -Name UnityProvisioningProfile -Scope Script -ErrorAction SilentlyContinue + if ($profileVar -and $profileVar.Value) { + return [string]$profileVar.Value + } + return 'Full' +} + +function Assert-UnityProvisioningProfile { + param([Parameter(Mandatory = $true)][string]$Profile) + + if ($Profile -notin @('EditorOnly', 'StandaloneWindowsIl2Cpp', 'Android', 'Full')) { + throw "Unknown Unity provisioning profile '$Profile'." + } +} + +function Get-UnityCiModuleSpec { + # SINGLE SOURCE OF TRUTH for the CI Unity module set. Returns an ORDERED array + # of [pscustomobject] rows (core tier first), each describing one module group: + # Id - the module group identifier. + # Requested - $true if the bare id is passed to the standalone CLI's `-m` + # install list; $false if it is verified-on-disk ONLY (never + # requested). + # Verified - $true if the group must be PROVEN present on disk after install. + # Tier - 'core' (provisions reliably with the base editor) or 'android' + # (the heavy/flaky multi-GB Google download whose NDK unpack + # deterministically fails at ~93% on Windows). + # Profiles - provisioning profiles that require this module group. + # + # WHY THIS EXISTS (and why everything DERIVES from it): the REQUESTED `-m` list, + # the VERIFIED-on-disk groups, and TIER membership all derive from these rows so + # they CANNOT DRIFT from one another -- the historical bug class where the + # requested list and the verified list silently diverged. Add/remove/retier a + # module HERE and every consumer follows. + # + # OpenJDK is Requested=$false (verified-only): the standalone beta CLI rejects + # the bare id 'android-open-jdk' (it emits "Couldn't find module ... Did you + # mean: android-open-jdk-11.0.14.1+1" because the real id is VERSION-PINNED and + # that suffix drifts across Unity versions). OpenJDK instead arrives as a + # DEPENDENCY of 'android-sdk-ndk-tools', so we PROVE it on disk but NEVER + # request it. + # + # The 'android' tier (android + android-sdk-ndk-tools, with android-open-jdk as + # its verified-only dependency) is requested only for Android/Full profiles. + # Existing editors can still try a bounded Android-only repair first; exhaustion + # escalates to profile-scoped managed quarantine/reinstall unless repair is + # disabled. + return @( + [pscustomobject]@{ Id = 'windows-il2cpp'; Requested = $true; Verified = $true; Tier = 'core'; Profiles = @('StandaloneWindowsIl2Cpp', 'Full') }, + [pscustomobject]@{ Id = 'webgl'; Requested = $true; Verified = $true; Tier = 'core'; Profiles = @('Full') }, + [pscustomobject]@{ Id = 'linux-mono'; Requested = $true; Verified = $true; Tier = 'core'; Profiles = @('Full') }, + [pscustomobject]@{ Id = 'linux-il2cpp'; Requested = $true; Verified = $true; Tier = 'core'; Profiles = @('Full') }, + [pscustomobject]@{ Id = 'android'; Requested = $true; Verified = $true; Tier = 'android'; Profiles = @('Android', 'Full') }, + [pscustomobject]@{ Id = 'android-sdk-ndk-tools'; Requested = $true; Verified = $true; Tier = 'android'; Profiles = @('Android', 'Full') }, + [pscustomobject]@{ Id = 'android-open-jdk'; Requested = $false; Verified = $true; Tier = 'android'; Profiles = @('Android', 'Full') } + ) +} + +function Get-UnityCiModuleSpecForProfile { + param([string]$Profile = $(Get-UnityProvisioningProfile)) + + Assert-UnityProvisioningProfile -Profile $Profile + return @(Get-UnityCiModuleSpec | Where-Object { $_.Profiles -contains $Profile }) +} + +function Get-UnityCiModuleIds { + # REQUESTED module ids passed to the standalone Unity CLI's `-m` install list. + # DERIVED from Get-UnityCiModuleSpec (the single source of truth) so it cannot + # drift from the verified-on-disk groups or the tier membership. + # + # NOTE: this is intentionally DECOUPLED from Get-UnityCiVerifiedModuleGroups + # (the on-disk verification list). The two answer different questions: "what do + # we ASK the CLI to install" vs. "what must we PROVE is on disk". OpenJDK is + # deliberately ABSENT here (Requested=$false in the spec) even though we verify + # it on disk: the beta CLI rejects the version-pinned bare id, and OpenJDK + # arrives as an 'android-sdk-ndk-tools' dependency instead. StrictMode-safe: + # @()-wraps the derived list. + param([string]$Profile = $(Get-UnityProvisioningProfile)) + + return @(Get-UnityCiModuleSpecForProfile -Profile $Profile | Where-Object { $_.Requested } | ForEach-Object { $_.Id }) +} + +function Get-UnityCiVerifiedModuleGroups { + # VERIFIED-on-disk module groups (the on-disk truth we require after any + # install/repair). DERIVED from Get-UnityCiModuleSpec so it cannot drift from + # the requested ids or the tiers. Iterated by Get-MissingUnityCiModuleGroups / + # Test-UnityCiModuleGroupPresent. Includes 'android-open-jdk' (Verified=$true, + # Requested=$false in the spec): OpenJDK arrives as an 'android-sdk-ndk-tools' + # dependency and must be PROVEN present, not assumed. StrictMode-safe: @()-wraps. + param([string]$Profile = $(Get-UnityProvisioningProfile)) + + return @(Get-UnityCiModuleSpecForProfile -Profile $Profile | Where-Object { $_.Verified } | ForEach-Object { $_.Id }) +} + +function Get-UnityCiSkippedModuleGroups { + param([string]$Profile = $(Get-UnityProvisioningProfile)) + + Assert-UnityProvisioningProfile -Profile $Profile + $selected = @(Get-UnityCiVerifiedModuleGroups -Profile $Profile) + return @(Get-UnityCiModuleSpec | Where-Object { $_.Verified -and $selected -notcontains $_.Id } | ForEach-Object { $_.Id }) +} + +function Test-UnityProvisioningProfileIncludesAndroid { + param([string]$Profile = $(Get-UnityProvisioningProfile)) + + return (@(Get-UnityCiModuleSpecForProfile -Profile $Profile | Where-Object { $_.Tier -eq 'android' }).Count -gt 0) +} + +function Get-UnityCiModuleIdsForTier { + # REQUESTED ids for a single tier ('core' or 'android'), derived from the spec. + # Used to drive the dedicated, bounded Android-only repair for existing editors + # while fresh/full repair installs request Get-UnityCiModuleIds atomically. + # Validates $Tier against the spec's known tiers and THROWS on an unknown one + # (mirroring the throw in Get-UnityCiModuleTier) so a bogus tier can never + # silently yield an empty -- and therefore malformed, id-less -- `-m` vector. + # StrictMode-safe: @()-wraps the derived list. + param( + [Parameter(Mandatory = $true)][string]$Tier, + [string]$Profile = $(Get-UnityProvisioningProfile) + ) + + $knownTiers = @(Get-UnityCiModuleSpec | ForEach-Object { $_.Tier } | Select-Object -Unique) + if ($knownTiers -notcontains $Tier) { + throw "Unknown Unity CI module tier '$Tier'." + } + + return @(Get-UnityCiModuleSpecForProfile -Profile $Profile | Where-Object { $_.Requested -and $_.Tier -eq $Tier } | ForEach-Object { $_.Id }) +} + +function Get-UnityCiModuleTier { + # Look up the Tier ('core'/'android') for a module group id from the spec, so a + # missing-group list (which carries verified ids, including the verified-only + # 'android-open-jdk') can be partitioned by tier. Throws on an unknown id, + # mirroring the default-case error in Test-UnityCiModuleGroupPresent so the spec + # and the on-disk switch cannot silently diverge. StrictMode-safe: uses + # [pscustomobject] property access (not bare hashtable indexing). + param([Parameter(Mandatory = $true)][string]$Id) + + foreach ($row in @(Get-UnityCiModuleSpec)) { + if ($row.Id -eq $Id) { + return $row.Tier + } + } + throw "Unknown Unity CI module group '$Id'." +} + +function Get-UnityCliModuleInstallArguments { + # SINGLE SOURCE OF TRUTH for the Unity-CLI module-install argument vector. + # ALL THREE module-install call sites (top-level `install`, the repair-path + # `install`, and the `install-modules` module-add) route through this so it is + # structurally impossible for one to carry `--accept-eula` while another omits + # it -- the exact drift that broke every CI cell. + # + # `--accept-eula` is MANDATORY (never optional): the Android SDK/NDK/OpenJDK + # modules carry license terms, and without the flag the standalone CLI aborts + # the ENTIRE install with "One or more modules require license acceptance. Pass + # --accept-eula ...". The failing CI log proved the `install` verb emits that + # message too, so the flag is valid for BOTH verbs handled here. + # + # Verb handling (preserving the resilient beta-CLI arg shapes already in use): + # install -> @('install', , '--accept-eula', [--childModules], '-m', ) + # install-modules -> @('install-modules', '-e', , '--accept-eula', [--childModules], '-m', ) + # --childModules is included whenever the requested ids include Android so the + # CLI resolves SDK/NDK/OpenJDK dependencies under AndroidPlayer atomically. + # NOTE: this builder is for the EULA-bearing module INSTALL only. The `-l` + # listing diagnostic and `editors`/`install-path` getters are NOT module + # installs and must keep their own (EULA-free) shapes; do not route them here. + # + # Optional -ModuleIds scopes the vector to a SUBSET of ids (e.g. the bounded + # Android-only repair) WITHOUT bypassing this sole producer (the + # `--accept-eula` + child-module + `-m` shape is still owned here for both + # verbs). When -ModuleIds is omitted the behavior is UNCHANGED: the full + # requested-id list (Get-UnityCiModuleIds). + # + # StrictMode-safe: @()-wraps the module-id capture so an empty list never + # collapses to AutomationNull, and uses array `+` concatenation only. + param( + [Parameter(Mandatory = $true)] + [ValidateSet('install', 'install-modules')] + [string]$Verb, + + [Parameter(Mandatory = $true)] + [string]$Version, + + [string[]]$ModuleIds + ) + + [string[]]$moduleIds = if ($PSBoundParameters.ContainsKey('ModuleIds')) { + @($ModuleIds | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) + } else { + @(Get-UnityCiModuleIds) + } + if ($null -eq $moduleIds) { + $moduleIds = [string[]]@() + } + + if ($moduleIds.Count -eq 0) { + if ($Verb -eq 'install') { + return @('install', $Version) + } + throw "Cannot build a Unity install-modules command for profile '$(Get-UnityProvisioningProfile)' because no module ids are selected." + } + + $includeChildModules = ($moduleIds -contains 'android' -or $moduleIds -contains 'android-sdk-ndk-tools') + + if ($Verb -eq 'install-modules') { + # `install-modules` targets an EXISTING editor, so it needs `-e `. + if ($includeChildModules) { + return @('install-modules', '-e', $Version, '--accept-eula', '--childModules', '-m') + $moduleIds + } + return @('install-modules', '-e', $Version, '--accept-eula', '-m') + $moduleIds + } + + # `install` provisions a fresh editor; the version is positional (no `-e`). + if ($includeChildModules) { + return @('install', $Version, '--accept-eula', '--childModules', '-m') + $moduleIds + } + return @('install', $Version, '--accept-eula', '-m') + $moduleIds +} + +function Test-UnityCiModuleGroupPresent { + param( + [Parameter(Mandatory = $true)][string]$EditorPath, + [Parameter(Mandatory = $true)][string]$Group + ) + + if (-not $EditorPath) { + return $false + } + + try { + $editorDir = Split-Path -Parent $EditorPath + if (-not $editorDir) { + return $false + } + + $dataRoot = Join-Path $editorDir 'Data' + switch ($Group) { + 'windows-il2cpp' { + return Test-Il2CppModulePresent -EditorPath $EditorPath + } + 'webgl' { + $webGlRoot = Join-Path $dataRoot 'PlaybackEngines\WebGLSupport' + $hasEditorExtension = Test-Path -LiteralPath (Join-Path $webGlRoot 'UnityEditor.WebGL.Extensions.dll') -PathType Leaf + $hasEmscriptenToolchain = Test-AnyUnityLeafPresent -Paths @( + (Join-Path $webGlRoot 'BuildTools\Emscripten\emscripten\emscripten-version.txt'), + (Join-Path $webGlRoot 'BuildTools\Emscripten\emscripten\emcc.py'), + (Join-Path $webGlRoot 'BuildTools\Emscripten\emscripten-version.txt'), + (Join-Path $webGlRoot 'BuildTools\Emscripten\emcc.py') + ) + return $hasEditorExtension -and $hasEmscriptenToolchain + } + 'android' { + $androidRoot = Join-Path $dataRoot 'PlaybackEngines\AndroidPlayer' + return Test-AnyUnityLeafPresent -Paths @( + (Join-Path $androidRoot 'UnityEditor.Android.Extensions.dll'), + (Join-Path $androidRoot 'Tools\Source.properties') + ) + } + 'android-sdk-ndk-tools' { + $androidRoot = Join-Path $dataRoot 'PlaybackEngines\AndroidPlayer' + $sdk = Join-Path $androidRoot 'SDK' + $ndk = Join-Path $androidRoot 'NDK' + $hasAdb = Test-AnyUnityLeafPresent -Paths @( + (Join-Path $sdk 'platform-tools\adb.exe'), + (Join-Path $sdk 'platform-tools\adb') + ) + $hasNdkProperties = Test-Path -LiteralPath (Join-Path $ndk 'source.properties') -PathType Leaf + $llvmRoot = Join-Path $ndk 'toolchains\llvm\prebuilt' + $hasLlvmClang = $false + if (Test-Path -LiteralPath $llvmRoot -PathType Container) { + $clangLeaves = @( + Get-ChildItem -LiteralPath $llvmRoot -Recurse -File -ErrorAction SilentlyContinue | + Where-Object { $_.Name -in @('clang++', 'clang++.exe') } | + Select-Object -First 1 + ) + $hasLlvmClang = $clangLeaves.Count -gt 0 + } + return $hasAdb -and $hasNdkProperties -and $hasLlvmClang + } + 'android-open-jdk' { + $androidRoot = Join-Path $dataRoot 'PlaybackEngines\AndroidPlayer' + return Test-AnyUnityLeafPresent -Paths @( + (Join-Path $androidRoot 'OpenJDK\bin\java.exe'), + (Join-Path $androidRoot 'OpenJDK\bin\java') + ) + } + 'linux-mono' { + $linuxRoot = Join-Path $dataRoot 'PlaybackEngines\LinuxStandaloneSupport' + $variationRoot = Join-Path $linuxRoot 'Variations' + return Test-AnyUnityLeafPresent -Paths @( + (Join-Path $variationRoot 'linux64_player_development_mono\LinuxPlayer'), + (Join-Path $variationRoot 'linux64_player_development_mono\UnityPlayer.so'), + (Join-Path $variationRoot 'linux64_player_nondevelopment_mono\LinuxPlayer'), + (Join-Path $variationRoot 'linux64_player_nondevelopment_mono\UnityPlayer.so') + ) + } + 'linux-il2cpp' { + $linuxRoot = Join-Path $dataRoot 'PlaybackEngines\LinuxStandaloneSupport' + $variationRoot = Join-Path $linuxRoot 'Variations' + return Test-AnyUnityLeafPresent -Paths @( + (Join-Path $variationRoot 'linux64_player_development_il2cpp\LinuxPlayer'), + (Join-Path $variationRoot 'linux64_player_development_il2cpp\UnityPlayer.so'), + (Join-Path $variationRoot 'linux64_player_nondevelopment_il2cpp\LinuxPlayer'), + (Join-Path $variationRoot 'linux64_player_nondevelopment_il2cpp\UnityPlayer.so') + ) + } + default { + throw "Unknown Unity CI module group '$Group'." + } + } + } catch { + return $false + } +} + +function Get-MissingUnityCiModuleGroups { + param( + [Parameter(Mandatory = $true)][string]$EditorPath, + [string]$Profile = $(Get-UnityProvisioningProfile) + ) + + $missing = New-Object System.Collections.Generic.List[string] + foreach ($group in @(Get-UnityCiVerifiedModuleGroups -Profile $Profile)) { + if (-not (Test-UnityCiModuleGroupPresent -EditorPath $EditorPath -Group $group)) { + $missing.Add($group) + } + } + + return @($missing.ToArray()) +} + +function Test-UnityCiModulesPresent { + param( + [Parameter(Mandatory = $true)][string]$EditorPath, + [string]$Profile = $(Get-UnityProvisioningProfile) + ) + + $missing = @(Get-MissingUnityCiModuleGroups -EditorPath $EditorPath -Profile $Profile) + return ($missing.Count -eq 0) +} + +function Test-ProcessHasModuleUnderDirectory { + # Best-effort, NEVER-THROWS cross-identity locker signal. Returns $true iff the + # process with $ProcessId has at least one LOADED MODULE whose on-disk file path + # is inside $Directory. + # + # WHY THIS EXISTS (the honest reason): the primary scoping signals in + # Stop-StaleUnityProvisioningProcesses come from Win32_Process.CommandLine / + # .ExecutablePath, both of which are read from the target process's PEB. PEB + # memory is privilege-gated: for a process owned by ANOTHER identity (the Unity + # CLI runs install/uninstall work as NetworkService on this runner) queried + # WITHOUT elevation, BOTH come back empty -- so a NetworkService-owned locker is + # invisible to command-line/image scoping. Get-Process surfaces the loaded-module + # list (System.Diagnostics.Process.Modules) through a DIFFERENT API + # (CreateToolhelp32Snapshot / EnumProcessModules) that is frequently readable for + # a cross-identity process where the PEB is not. It is NOT guaranteed (a fully + # protected/elevated process can still deny it, and the call can throw Access + # Denied), hence DEFENSE-IN-DEPTH, not the primary fix: any failure is swallowed + # and returns $false. + # + # Scoped to the caller-supplied $Directory (the VERSION dir, never the bare + # managed root) so this can never become a cross-version collateral signal. + # + # TEST override: UH_UNITY_FAKE_PROCESS_MODULES is a ';'-separated list of + # '=[,...]' entries; when set, the modules for $ProcessId are + # read from it verbatim (no real Get-Process), so a hermetic test can prove the + # loaded-module scoping branch on a non-Windows host. StrictMode-safe throughout. + param( + [Parameter(Mandatory = $true)][int]$ProcessId, + [Parameter(Mandatory = $true)][string]$Directory + ) + + if ($ProcessId -le 0) { + return $false + } + + # Hermetic test path: parse the fake module table without touching real processes. + $fakeTable = $env:UH_UNITY_FAKE_PROCESS_MODULES + if (-not [string]::IsNullOrEmpty($fakeTable)) { + try { + foreach ($entry in ($fakeTable -split ';')) { + $trimmedEntry = ([string]$entry).Trim() + if ($trimmedEntry.Length -eq 0) { + continue + } + $eq = $trimmedEntry.IndexOf('=') + if ($eq -lt 1) { + continue + } + $pidText = $trimmedEntry.Substring(0, $eq).Trim() + $parsedPid = 0 + if (-not [int]::TryParse($pidText, [ref]$parsedPid)) { + continue + } + if ($parsedPid -ne $ProcessId) { + continue + } + $pathsText = $trimmedEntry.Substring($eq + 1) + foreach ($modulePath in ($pathsText -split ',')) { + $candidate = ([string]$modulePath).Trim() + if ($candidate.Length -gt 0 -and (Test-IsPathInsideDirectory -Path $candidate -Directory $Directory)) { + return $true + } + } + } + } catch { + return $false + } + return $false + } + + try { + $proc = Get-Process -Id $ProcessId -ErrorAction SilentlyContinue + if ($null -eq $proc) { + return $false + } + $modules = $null + try { $modules = $proc.Modules } catch { $modules = $null } + if ($null -eq $modules) { + return $false + } + foreach ($module in $modules) { + if ($null -eq $module) { + continue + } + $fileName = '' + try { $fileName = [string]$module.FileName } catch { $fileName = '' } + if ($fileName.Trim().Length -eq 0) { + continue + } + if (Test-IsPathInsideDirectory -Path $fileName -Directory $Directory) { + return $true + } + } + } catch { + # Access Denied / process exited / any other failure: best-effort means + # "no positive signal", never an abort of the sweep. + return $false + } + + return $false +} + +function Stop-StaleUnityProvisioningProcesses { + param( + [Parameter(Mandatory = $true)][string]$InstallRoot, + [Parameter(Mandatory = $true)][string]$Version, + [Parameter(Mandatory = $true)][string]$Reason + ) + + $matched = 0 + $stopped = 0 + $details = New-Object System.Collections.Generic.List[string] + try { + $rootFull = [System.IO.Path]::GetFullPath($InstallRoot) + $versionDirFull = [System.IO.Path]::GetFullPath((Join-Path $rootFull $Version)) + $processes = @() + try { + # Win32_Process also carries ExecutablePath; we capture it to scope a + # locker by its ON-DISK image path. NOTE the honest limitation: both + # CommandLine and ExecutablePath are read from the target process's PEB, + # which is privilege-gated -- for a process owned by ANOTHER identity (the + # Unity CLI runs install/uninstall work as NetworkService here) queried + # WITHOUT elevation, BOTH read back empty, so image-path scoping is ALSO + # blind to a cross-identity locker. On the observed Unity 6000.3.16f1 + # standalone failure (run 26701943540) the first sweep matched 0 and every + # move attempt failed; the logs do NOT identify the locker (no process + # listing), so its identity is SUSPECTED, not established. The loaded-module + # signal (Test-ProcessHasModuleUnderDirectory, consulted per process below) + # is the best-effort cross-identity catch that does not depend on the PEB. + $processes = @(Get-CimInstance Win32_Process -ErrorAction Stop) + } catch { + $processes = @(Get-Process -ErrorAction SilentlyContinue | ForEach-Object { + $exePath = '' + try { $exePath = [string]$_.Path } catch { $exePath = '' } + [pscustomobject]@{ + ProcessId = $_.Id + Name = $_.ProcessName + CommandLine = '' + ExecutablePath = $exePath + } + }) + } + + foreach ($proc in $processes) { + if ($null -eq $proc) { + continue + } + $name = '' + $processId = 0 + $commandLine = '' + $executablePath = '' + try { $name = [string]$proc.Name } catch { $name = '' } + try { $processId = [int]$proc.ProcessId } catch { $processId = 0 } + try { $commandLine = [string]$proc.CommandLine } catch { $commandLine = '' } + try { $executablePath = [string]$proc.ExecutablePath } catch { $executablePath = '' } + if ($processId -le 0 -or $processId -eq $PID) { + continue + } + # VERSION-DIR image scope is the ONLY unconditional kill signal: a process + # whose on-disk image lives inside THIS version's directory + # (...\\\) is unambiguously the editor we are quarantining, + # regardless of its name or an empty CommandLine (e.g. the editor's own + # Unity.exe locking its own tree, or a helper unpacker under that dir). + # Loaded-module scope (below) catches a locker whose image path the PEB + # would not give us but whose loaded modules resolve under the version dir. + $imageInsideVersionDir = ($executablePath.Trim().Length -gt 0) -and (Test-IsPathInsideDirectory -Path $executablePath -Directory $versionDirFull) + + # DELIBERATELY NOT a kill signal on its own: "image somewhere under the + # shared managed root". The runner hosts 2021/2022/6000 editors side by + # side under one root, and this sweep runs in the provision step BEFORE the + # cross-version organization lock is acquired, so a sibling-version editor + # can legitimately be running concurrently. Killing ANY Unity-named binary + # under the root (the prior fix's `imageInsideRoot -and looksUnity` branch) + # force-killed that sibling -- a cross-version collateral kill. A broad + # under-root match is allowed ONLY when the command line ALSO ties the + # process to THIS version/root (commandLineScoped), which a sibling version's + # editor does not satisfy. + $looksUnity = $name -match '(?i)^(unity|unity\.exe|unity hub|unity hub\.exe|unitycli|unitycli\.exe|unitysetup|unitysetup\.exe|unitydownloadassistant|unitydownloadassistant\.exe|unityhelper|unityhelper\.exe)$' + + $commandLineScoped = ($commandLine.IndexOf($Version, [System.StringComparison]::OrdinalIgnoreCase) -ge 0) -or + ($commandLine.IndexOf($versionDirFull, [System.StringComparison]::OrdinalIgnoreCase) -ge 0) + + # Defense-in-depth cross-identity signal (see Test-ProcessHasModuleUnderDirectory): + # a process's LOADED MODULE paths are frequently readable via Get-Process + # where the PEB CommandLine/ExecutablePath are NOT (those live in + # privilege-gated PEB memory and read back empty for a NetworkService/SYSTEM + # process queried without elevation). A module loaded from THIS version's + # directory means the process is executing the editor we are quarantining. + # Scoped to the VERSION dir (never the bare root) for the same anti-collateral + # reason as above. + $modulesUnderVersionDir = Test-ProcessHasModuleUnderDirectory -ProcessId $processId -Directory $versionDirFull + + # In-scope when EITHER the image / a loaded module runs from within THIS + # version's directory (unconditional -- unambiguously ours), OR it looks + # like a Unity process AND its command line genuinely ties it to THIS + # version/dir. The bare under-root match is gone: a sibling-version editor + # under the shared root with an empty CommandLine is now SPARED (it matches + # none of these), while the editor's own Unity.exe locking its own version + # dir is still caught. This narrows the prior blast radius without losing the + # real, safe catch. + $isScoped = $imageInsideVersionDir -or $modulesUnderVersionDir -or ($looksUnity -and $commandLineScoped) + if (-not $isScoped) { + continue + } + + $matched++ + $details.Add("pid=$processId name=$name image=$executablePath modulesUnderVersionDir=$modulesUnderVersionDir command=$commandLine") + try { + Stop-Process -Id $processId -Force -ErrorAction Stop + $stopped++ + } catch { + $details.Add("pid=$processId stop failed: $($_.Exception.Message)") + } + } + + Write-CiNotice "Stale Unity provisioning process cleanup for Unity $Version ($Reason): matched $matched, stopped $stopped." + } catch { + $details.Add("cleanup failed: $($_.Exception.Message)") + Write-Host "::notice::Stale Unity provisioning process cleanup failed for Unity ${Version}: $($_.Exception.Message)" + } finally { + Add-ProvisioningProcessCleanupEvent -Reason $Reason -Matched $matched -Stopped $stopped -Details @($details.ToArray()) + } +} + +function Move-UnityInstallDirectoryToQuarantine { + param( + [Parameter(Mandatory = $true)][string]$InstallDirectory, + [Parameter(Mandatory = $true)][string]$InstallRoot, + [Parameter(Mandatory = $true)][string]$Version + ) + + if (-not $InstallDirectory -or -not (Test-Path -LiteralPath $InstallDirectory -PathType Container)) { + return + } + + if (-not (Test-IsPathInsideDirectory -Path $InstallDirectory -Directory $InstallRoot)) { + throw "Refusing to auto-repair Unity $Version because the resolved editor install '$InstallDirectory' is outside the configured managed install root '$InstallRoot'. Remove or reinstall that editor manually, or set UNITY_EDITOR_INSTALL_ROOT to a CI-owned directory." + } + + $quarantineRoot = Join-Path $InstallRoot '_quarantine' + New-Item -ItemType Directory -Force -Path $quarantineRoot | Out-Null + $stamp = Get-Date -Format 'yyyyMMdd-HHmmss' + $suffix = [Guid]::NewGuid().ToString('N').Substring(0, 8) + $destination = Join-Path $quarantineRoot "$Version-$stamp-$suffix" + + Write-Host "::warning::Quarantining unmanaged or partial Unity $Version install before repair: $InstallDirectory -> $destination" + Stop-StaleUnityProvisioningProcesses -InstallRoot $InstallRoot -Version $Version -Reason "before quarantining $InstallDirectory" + # Move-Item against a Unity editor directory can fail transiently on Windows + # with "The process cannot access the file '...' because it is being used by + # another process." when Unity, an antivirus scanner, or the Windows indexer + # still holds a handle on a file under the tree. Retry the move with backoff + # so a momentary lock does not abort the whole repair; Invoke-WithRetry emits + # a per-attempt ::warning:: and RETHROWS the last error if every attempt + # fails, so a genuinely stuck directory still aborts loudly. Class rule: any + # destructive dir op (Move/Remove/Rename) on a transiently-lockable Unity + # editor directory on Windows goes through this retry helper. + # + # BEFORE EACH RETRY (attempt > 1) we re-run the stale-process sweep. The + # observed Unity 6000.3.16f1 standalone failure (run 26701943540) was a held + # handle on C:\Unity\Editors\6000.3.16f1\Editor where the FIRST sweep matched 0 + # and all three move attempts then failed identically. HONEST LIMITATION: the + # logs contain NO process listing identifying the locker; the only Unity binary + # visible in that run is the CLI at C:\Windows\ServiceProfiles\NetworkService\... + # (NetworkService, OUTSIDE the managed root), whose PEB CommandLine/ExecutablePath + # read back empty without elevation. The per-retry re-sweep + the loaded-module + # signal (Test-ProcessHasModuleUnderDirectory) give the kill multiple shots as a + # TRANSIENT handle (AV/indexer/an editor child under the version dir) releases, + # which recovers the COMMON case. It does NOT recover a HARD lock by a non-Unity, + # cross-identity, out-of-root process; that residual is runner-side and is + # surfaced by the wrap-immune ::error:: below. The primary mitigation for the + # exit-6 origin is the PROACTIVE atomic-reinstall route (Ensure-UnityCiModules + # Step 1b), which avoids reaching this quarantine for a not-module-manageable + # editor in the first place. + $moveAttempt = [ref] 0 + try { + Invoke-WithRetry -MaxAttempts 3 -DelaySeconds (Get-EnsureEditorRetryDelaySeconds) -Action { + $moveAttempt.Value++ + if ($moveAttempt.Value -gt 1) { + Stop-StaleUnityProvisioningProcesses -InstallRoot $InstallRoot -Version $Version -Reason "retry $($moveAttempt.Value) before quarantining $InstallDirectory (a process is still holding the editor tree)" + } + Move-Item -LiteralPath $InstallDirectory -Destination $destination -Force + } | Out-Null + } catch { + # Persistent lock after every sweep + retry. Emit a WRAP-IMMUNE ::error:: + # (a thrown message is word-wrapped by PowerShell's ConciseView at the + # console width; a Write-Host "::error::..." line is not) so the operator + # sees the exact locked path and the remediation instead of a wrapped + # Move-Item stack trace. Then rethrow so the run still fails loudly. + Write-Host ("::error::Could not quarantine Unity $Version install '$InstallDirectory' for repair: a process is holding a handle on the editor tree and the stale-process sweep could not release it ($($_.Exception.Message)). " + + "This blocks the automatic quarantine+reinstall that recovers a base editor whose modules cannot be added (Unity CLI 'install-modules' exit 6 / 'Try reinstalling this editor with Unity Hub'). " + + "Remediation on the runner: ensure no Unity.exe/Unity Hub/indexer/antivirus process is holding C:\\...\\$Version\\Editor (Sysinternals handle64.exe `"$InstallDirectory`" finds the locker), then re-run; or manually delete '$InstallDirectory' and let ensure-editor.ps1 reinstall the editor with its modules.") + throw + } +} + +function Move-UnityEditorInstallToQuarantine { + param( + [Parameter(Mandatory = $true)][string]$EditorPath, + [Parameter(Mandatory = $true)][string]$InstallRoot, + [Parameter(Mandatory = $true)][string]$Version + ) + + $installDirectory = Get-UnityEditorInstallDirectory -EditorPath $EditorPath + if (-not $installDirectory) { + return + } + + Move-UnityInstallDirectoryToQuarantine -InstallDirectory $installDirectory -InstallRoot $InstallRoot -Version $Version +} + +function Move-UnityVersionInstallToQuarantine { + param( + [Parameter(Mandatory = $true)][string]$Version, + [Parameter(Mandatory = $true)][string]$InstallRoot + ) + + $candidateRoots = New-Object System.Collections.Generic.List[string] + $candidateRoots.Add($InstallRoot) + try { + $cliRoot = Get-UnityCliInstallRoot + if ($cliRoot -and (Test-IsPathInsideDirectory -Path $cliRoot -Directory $InstallRoot)) { + $candidateRoots.Add($cliRoot) + } + } catch { + Write-Host "::notice::Could not query Unity CLI install root while quarantining Unity ${Version}: $($_.Exception.Message)" + } + + $seen = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase) + foreach ($root in @($candidateRoots.ToArray())) { + if (-not $root) { + continue + } + $installDirectory = Join-Path $root $Version + $full = [System.IO.Path]::GetFullPath($installDirectory) + if ($seen.Add($full)) { + Move-UnityInstallDirectoryToQuarantine -InstallDirectory $full -InstallRoot $InstallRoot -Version $Version + } + } +} + +function Invoke-UnityVersionUninstallForRepair { + param( + [Parameter(Mandatory = $true)][string]$Version, + [Parameter(Mandatory = $true)][string]$Reason, + [string]$InstallRoot + ) + + $uninstallResult = Invoke-UnityCliCapture -Arguments @('uninstall', $Version) + if (-not $uninstallResult.Success) { + $uninstallLines = @($uninstallResult.Output) + $tailCount = [Math]::Min(12, $uninstallLines.Count) + $tail = if ($tailCount -gt 0) { + ($uninstallLines[($uninstallLines.Count - $tailCount)..($uninstallLines.Count - 1)] -join "`n") + } else { + '(no output captured)' + } + Write-CiNotice "Unity CLI uninstall for $Version did not complete cleanly before repair (exit code $($uninstallResult.ExitCode)); quarantining the install directory instead. Reason: $Reason Output tail:`n$tail" + if ($InstallRoot) { + Stop-StaleUnityProvisioningProcesses -InstallRoot $InstallRoot -Version $Version -Reason "failed uninstall before repair: $Reason" + } + } + + return $uninstallResult +} + +function Install-UnityEditorWithCiModules { + param( + [Parameter(Mandatory = $true)][string]$Version, + [Parameter(Mandatory = $true)][string]$InstallRoot, + [Parameter(Mandatory = $true)][string]$Reason, + [string]$Profile = $(Get-UnityProvisioningProfile), + [switch]$ManagedOnly + ) + + Assert-UnityProvisioningBudgetCanFit -Operation "fresh Unity $Version managed install" -MinimumSeconds 60 + $moduleIds = @(Get-UnityCiModuleIds -Profile $Profile) + if ($ManagedOnly) { + Confirm-UnityCliManagedInstallRoot -Root $InstallRoot | Out-Null + } + $moduleText = if ($moduleIds.Count -gt 0) { $moduleIds -join ', ' } else { '(editor only)' } + Write-CiNotice "Repairing Unity $Version by installing a fresh CLI-managed editor with provisioning profile '$Profile' modules ($moduleText). Reason: $Reason" + + # Single source of truth for the (EULA-bearing) module-install arg vector, + # scoped to the selected provisioning profile. + $installArgs = @(Get-UnityCliModuleInstallArguments -Verb 'install' -Version $Version -ModuleIds $moduleIds) + + $resolved = $null + for ($attempt = 1; $attempt -le 2; $attempt++) { + $installResult = Invoke-UnityCliCapture -Arguments $installArgs + if ($installResult.Success) { + $resolved = Resolve-InstalledEditor -Version $Version -Root $InstallRoot -ManagedOnly:$ManagedOnly + if ($resolved) { + $script:ProvisioningEditorPath = $resolved + break + } + if ($attempt -lt 2) { + Write-InstalledEditorDiagnostics -Version $Version -Root $InstallRoot -Reason "Unity repair install exited 0, but Unity.exe could not be resolved afterward." + Invoke-UnityVersionUninstallForRepair -Version $Version -Reason "Unity repair install exited 0, but Unity.exe could not be resolved afterward." -InstallRoot $InstallRoot | Out-Null + Move-UnityVersionInstallToQuarantine -Version $Version -InstallRoot $InstallRoot + Write-Host "::warning::Retrying Unity $Version repair install after successful CLI install left no resolvable Unity.exe." + continue + } + break + } + + $installLines = @($installResult.Output) + $installText = ($installLines -join "`n") + # Collapse consecutive identical lines (the Android NDK install can spam + # thousands of identical progress lines) so the tail is READABLE. + $tail = Get-CollapsedCliOutputTail -Output $installResult.Output -MaxLines 40 + $resolvedAfterFailure = Resolve-InstalledEditor -Version $Version -Root $InstallRoot -ManagedOnly:$ManagedOnly + if ($installText -match '(?i)already installed|editor already installed|is already installed') { + if ($resolvedAfterFailure) { + Write-CiNotice "Unity repair install for $Version reported already-installed with exit code $($installResult.ExitCode), but Unity.exe is resolvable afterward; verifying modules against disk." + $resolved = $resolvedAfterFailure + $script:ProvisioningEditorPath = $resolved + break + } + + if ($attempt -lt 2) { + Write-InstalledEditorDiagnostics -Version $Version -Root $InstallRoot -Reason "Unity repair install reported already-installed, but Unity.exe could not be resolved afterward." + Invoke-UnityVersionUninstallForRepair -Version $Version -Reason "Unity repair install reported already-installed, but Unity.exe could not be resolved." -InstallRoot $InstallRoot | Out-Null + Move-UnityVersionInstallToQuarantine -Version $Version -InstallRoot $InstallRoot + Write-Host "::warning::Retrying Unity $Version repair install after clearing stale CLI metadata and quarantining the managed version directory." + continue + } + } + + Write-UnityCliInstallFailureAnnotation -Version $Version -Output $installResult.Output -ExitCode $installResult.ExitCode -Arguments $installArgs + # Wrapper-driven kill state drives the diagnostic wording: the new + # StallKilled / TimedOutWallClock fields distinguish a heartbeat-stall + # kill from a wall-clock kill from a NATIVE exit code that happens to + # equal 124 or 125 (which must NOT be misread as a wrapper kill). The + # legacy exit-code classification is retained ONLY for the retryable + # decision (both sentinels remain retryable, as before); the failure + # summary now names the actual kill reason or the raw exit code. + $installStallKilled = [bool]$installResult.StallKilled + $installWallTimedOut = [bool]$installResult.TimedOutWallClock + $installTimedOut = ($installStallKilled -or $installWallTimedOut) + Write-ModuleInstallFailureDiagnostics -Version $Version -Output $installResult.Output -ExitCode $installResult.ExitCode -Arguments $installArgs -Root $InstallRoot -TimedOut:$installTimedOut -StallKilled:$installStallKilled -TimedOutWallClock:$installWallTimedOut -TimeoutSeconds (Get-EnsureEditorInstallTimeoutSeconds) -StallSeconds (Get-EnsureEditorProgressStallSeconds) + Write-InstalledEditorDiagnostics -Version $Version -Root $InstallRoot -Reason "Unity repair install failed." + throw "Unity $Version repair install with CI modules failed with exit code $($installResult.ExitCode). CLI output tail:`n$tail" + } + + if (-not $resolved) { + Write-InstalledEditorDiagnostics -Version $Version -Root $InstallRoot -Reason "Unity repair install completed, but Unity.exe could not be resolved afterward." + throw "Unity $Version repair install completed, but Unity.exe could not be found afterward." + } + + $missing = @(Get-MissingUnityCiModuleGroups -EditorPath $resolved -Profile $Profile) + if ($missing.Count -gt 0) { + throw "Unity $Version repair install completed at '$resolved', but required CI module groups for provisioning profile '$Profile' are still missing on disk after the atomic install: $($missing -join ', ')." + } + + return $resolved +} + +function Repair-UnityEditorWithCiModules { + param( + [Parameter(Mandatory = $true)][string]$Version, + [Parameter(Mandatory = $true)][string]$EditorPath, + [Parameter(Mandatory = $true)][string]$InstallRoot, + [Parameter(Mandatory = $true)][string]$Reason, + [string]$Profile = $(Get-UnityProvisioningProfile), + [switch]$ManagedOnly + ) + + return Invoke-WithUnityInstallLock -Version $Version -InstallRoot $InstallRoot -Action { + Assert-UnityProvisioningBudgetCanFit -Operation "managed quarantine/reinstall for Unity $Version" -MinimumSeconds 60 + if ($ManagedOnly) { + Confirm-UnityCliManagedInstallRoot -Root $InstallRoot | Out-Null + } + Invoke-UnityVersionUninstallForRepair -Version $Version -Reason $Reason -InstallRoot $InstallRoot | Out-Null + + if (Test-Path -LiteralPath $EditorPath -PathType Leaf) { + Move-UnityEditorInstallToQuarantine -EditorPath $EditorPath -InstallRoot $InstallRoot -Version $Version + } + Move-UnityVersionInstallToQuarantine -Version $Version -InstallRoot $InstallRoot + + return Install-UnityEditorWithCiModules -Version $Version -InstallRoot $InstallRoot -Reason $Reason -Profile $Profile -ManagedOnly:$ManagedOnly + } +} + +function Get-NativeExitCodeDescription { + param([Parameter(Mandatory = $true)][int]$ExitCode) + + $normalized = if ($ExitCode -lt 0) { + [uint32]($ExitCode + 4294967296) + } else { + [uint32]$ExitCode + } + $hex = $normalized.ToString('X8') + # Compare against the hex STRING form (not the literal 0xC0000135 token) because + # PowerShell parses `0xC0000135` as Int32 -1073741515 and `[uint32]$normalized -eq + # 0xC0000135` therefore coerces to Int32 -- $normalized (the unsigned value + # 3221225781) and -1073741515 are NOT -eq. String compare on the canonical 8-char + # hex avoids the int/uint conflation entirely and is what Test-IsNativeDllNotFound + # also relies on. + if ($hex -eq 'C0000135') { + return "0x$hex / STATUS_DLL_NOT_FOUND" + } + if ($hex -eq '8007007E') { + return "0x$hex / ERROR_MOD_NOT_FOUND" + } + + return "0x$hex" +} + +function Test-IsNativeDllNotFound { + # TRUE iff $ExitCode normalizes to the Windows NTSTATUS 0xC0000135 + # (STATUS_DLL_NOT_FOUND): the OS loader could not resolve an imported DLL when + # spawning Unity.exe. This is a HOST OS prerequisite failure (e.g. a missing + # Microsoft Visual C++ Redistributable), NOT a Unity install issue, so the + # caller must SKIP the managed reinstall path -- reinstalling Unity does NOT + # add a DLL to the OS loader's search path. Implemented as a single-purpose + # helper (instead of a bare `-eq` in the caller) so the int/uint comparison + # bug fixed in Get-NativeExitCodeDescription cannot regress here either: we + # compare on the canonical 8-char hex string of the uint32 value. + param([Parameter(Mandatory = $true)][int]$ExitCode) + + $normalized = if ($ExitCode -lt 0) { + [uint32]($ExitCode + 4294967296) + } else { + [uint32]$ExitCode + } + return ($normalized.ToString('X8') -eq 'C0000135') +} + +function Get-UnityNativeImports { + # Best-effort PE-import-table dump of Unity.exe. Returns a string[] of imported + # DLL filenames (e.g. 'KERNEL32.dll', 'VCRUNTIME140.dll', 'VCRUNTIME140_1.dll', + # 'MSVCP140.dll') or @() on ANY failure / non-PE file / missing PEReader type. + # NEVER THROWS -- the caller (Write-UnityHostPrereqAnnotation) calls this from a + # diagnostic path that must not itself fail the build. + # + # WHY: when Unity.exe is launched and the Windows loader exits the process with + # 0xC0000135 (STATUS_DLL_NOT_FOUND), the loader does NOT tell us WHICH DLL it + # could not resolve -- only that some import was missing. Listing every DLL + # Unity.exe IMPORTS narrows the search: if `VCRUNTIME140.dll` / `MSVCP140.dll` / + # `VCRUNTIME140_1.dll` appear in the list, the missing DLL is overwhelmingly + # likely the Microsoft Visual C++ 2015-2022 Redistributable (x64). The CI + # annotation can then NAME that prereq instead of telling the operator "some DLL + # is missing -- good luck." + # + # PARSING STRATEGY: we use System.Reflection.PortableExecutable.PEReader (.NET + # 5+ / pwsh 7) to read the import directory. The descriptor walk: + # 1. PEHeaders.PEHeader.ImportTableDirectory -> RVA + Size of the import dir. + # 2. GetSectionData(rva).GetReader() -> a BlobReader anchored AT the requested + # RVA (the reader is the section data sliced from that RVA to end of + # section, NOT from the section start). + # 3. Each IMAGE_IMPORT_DESCRIPTOR is 20 bytes (5 x uint32): ILT, TimeStamp, + # ForwarderChain, NameRVA, FirstThunk. The descriptor sequence is + # terminated by an all-zero entry. + # 4. For each non-zero NameRVA, GetSectionData(NameRVA).GetReader() yields the + # ASCII null-terminated DLL name. + # + # CRITICAL INT/UINT GOTCHA: PEReader.GetSectionData takes an Int32 rva, but the + # ReadUInt32 calls return [uint32]. Passing the [uint32] to a [int]-typed + # overload SILENTLY routes the call to the (string sectionName) overload (since + # neither type matches exactly) and returns a 0-length block, which then throws + # "Read out of bounds" on the first ReadByte. We explicitly `[int]`-cast every + # RVA before calling GetSectionData to pin the int32 overload. + # + # TEST-ONLY override (same spirit as UH_UNITY_FAKE_LONGPATHS_ENABLED): when + # UH_UNITY_FAKE_IMPORTS is non-empty, the comma-separated value is returned + # verbatim WITHOUT touching the file. Lets hermetic tests prove the annotation + # branch on Linux/macOS without smuggling a real PE binary into the repo. + param([Parameter(Mandatory = $true)][string]$EditorPath) + + if (-not [string]::IsNullOrEmpty($env:UH_UNITY_FAKE_IMPORTS)) { + $fake = New-Object 'System.Collections.Generic.List[string]' + foreach ($entry in ($env:UH_UNITY_FAKE_IMPORTS -split ',')) { + $trimmed = if ($null -eq $entry) { '' } else { ([string]$entry).Trim() } + if (-not [string]::IsNullOrWhiteSpace($trimmed)) { + [void]$fake.Add($trimmed) + } + } + return @($fake.ToArray()) + } + + if ([string]::IsNullOrWhiteSpace($EditorPath)) { + return @() + } + if (-not (Test-Path -LiteralPath $EditorPath -PathType Leaf)) { + return @() + } + + try { + # Best-effort: pwsh 7 / .NET 5+ ship System.Reflection.PortableExecutable in + # the default LoadContext, so the type literal resolves directly. PS 5.1 + # MAY need an explicit Add-Type for `System.Reflection.Metadata`; if that + # fails too, the outer try/catch returns @() and the caller falls back to + # an "(could not enumerate)" annotation. + try { + $null = [System.Reflection.PortableExecutable.PEReader] + } catch { + try { + Add-Type -AssemblyName 'System.Reflection.Metadata' -ErrorAction SilentlyContinue + } catch { + # Ignored: outer catch returns @() if the type still cannot resolve. + } + } + + $stream = [System.IO.File]::OpenRead($EditorPath) + try { + # -ArgumentList is the explicit, StrictMode-friendly form; positional + # `(, $stream)` works too but is harder to grep / understand at a glance. + $pe = New-Object -TypeName 'System.Reflection.PortableExecutable.PEReader' -ArgumentList $stream + try { + $headers = $pe.PEHeaders + # A non-PE file (e.g. a test stub with a shell shebang body) is + # accepted by the PEReader constructor lazily; PEHeader is $null in + # that case. Bail out cleanly. + if ($null -eq $headers -or $null -eq $headers.PEHeader) { + return @() + } + $names = New-Object 'System.Collections.Generic.List[string]' + + # Inline helper: walk a sequence of fixed-size PE descriptors that + # each carry a single DLL NameRVA at a known offset, terminated by + # an all-zero descriptor. Used for BOTH the regular import + # directory (20-byte IMAGE_IMPORT_DESCRIPTOR, NameRVA at offset 12) + # and the delay-import directory (32-byte IMAGE_DELAYLOAD_DESCRIPTOR, + # DllNameRVA at offset 4). Splitting the walk into a closure keeps + # both passes byte-for-byte consistent and prevents a regression in + # the existing import walk from sneaking past the delay-import + # pass. + $readDllNameAtRva = { + param([uint32]$NameRva) + if ($NameRva -eq 0) { return $null } + try { + $nameBlock = $pe.GetSectionData([int]$NameRva) + $nameReader = $nameBlock.GetReader() + $sb = New-Object 'System.Text.StringBuilder' + $byteCount = 0 + # A reasonable DLL name is well under 256 chars; cap to + # prevent a corrupt RVA from consuming the entire + # section. + while ($nameReader.RemainingBytes -gt 0 -and $byteCount -lt 1024) { + $b = $nameReader.ReadByte() + if ($b -eq 0) { + break + } + # Restrict to printable ASCII to avoid emitting + # garbage if the RVA pointed somewhere other than a + # name table. + if ($b -ge 32 -and $b -lt 127) { + [void]$sb.Append([char]$b) + } else { + # Non-printable byte before terminator -- abandon + # this name; treat as parse failure. + $sb.Length = 0 + break + } + $byteCount++ + } + $name = $sb.ToString() + if (-not [string]::IsNullOrWhiteSpace($name)) { + return $name + } + return $null + } catch { + # A single bad descriptor must not abort the whole walk. + return $null + } + } + + # PASS 1: regular import directory (IMAGE_DIRECTORY_ENTRY_IMPORT, slot 1). + $importDir = $headers.PEHeader.ImportTableDirectory + if ($null -ne $importDir -and $importDir.Size -gt 0 -and $importDir.RelativeVirtualAddress -gt 0) { + try { + $block = $pe.GetSectionData([int]$importDir.RelativeVirtualAddress) + $reader = $block.GetReader() + # Defensive caps: a corrupt or attacker-crafted PE could otherwise + # loop indefinitely (no terminator) or chase a name RVA that walks + # off the end of every section. + $maxEntries = 256 + $loopCount = 0 + while ($reader.RemainingBytes -ge 20 -and $loopCount -lt $maxEntries) { + $loopCount++ + $importLookupTable = $reader.ReadUInt32() + $null = $reader.ReadUInt32() # TimeDateStamp (unused) + $null = $reader.ReadUInt32() # ForwarderChain (unused) + $nameRva = $reader.ReadUInt32() + $iatRva = $reader.ReadUInt32() + if ($importLookupTable -eq 0 -and $nameRva -eq 0 -and $iatRva -eq 0) { + # Standard PE terminator descriptor -- end of imports. + break + } + $name = & $readDllNameAtRva $nameRva + if ($name) { + [void]$names.Add($name) + } + } + } catch { + # Best-effort: a corrupt import directory must not abort + # the delay-import pass below. + } + } + + # PASS 2: delay-import directory (IMAGE_DIRECTORY_ENTRY_DELAY_IMPORT, + # slot 13). Unity may declare some imports via delay-load (e.g. plugins + # bound at runtime via LoadLibrary). The OS loader still resolves them + # at module-init time when they are referenced; a missing delay-loaded + # DLL surfaces as 0xC0000135 just like a regular import does, so we + # MUST include them in the resolution probe. The descriptor is the + # IMAGE_DELAYLOAD_DESCRIPTOR record (32 bytes total): + # Attributes (4 bytes, uint32) + # DllNameRVA (4 bytes, uint32) <-- the name we want + # ModuleHandleRVA (4 bytes, uint32) + # DelayIATRVA (4 bytes, uint32) + # DelayINT (4 bytes, uint32) + # BoundDelayIT (4 bytes, uint32) + # UnloadDelayIT (4 bytes, uint32) + # TimeStamp (4 bytes, uint32) + # Terminated by an all-zero descriptor. Same int/uint cast gotcha + # applies: GetSectionData takes Int32, ReadUInt32 returns UInt32, + # so explicit [int]-cast pins the correct overload. + try { + $delayDir = $headers.PEHeader.DelayImportTableDirectory + } catch { + $delayDir = $null + } + if ($null -ne $delayDir -and $delayDir.Size -gt 0 -and $delayDir.RelativeVirtualAddress -gt 0) { + try { + $delayBlock = $pe.GetSectionData([int]$delayDir.RelativeVirtualAddress) + $delayReader = $delayBlock.GetReader() + $maxDelayEntries = 256 + $delayLoop = 0 + while ($delayReader.RemainingBytes -ge 32 -and $delayLoop -lt $maxDelayEntries) { + $delayLoop++ + $attributes = $delayReader.ReadUInt32() + $dllNameRva = $delayReader.ReadUInt32() + $moduleHandleRva = $delayReader.ReadUInt32() + $delayIatRva = $delayReader.ReadUInt32() + $null = $delayReader.ReadUInt32() # DelayINT (unused) + $null = $delayReader.ReadUInt32() # BoundDelayIT (unused) + $null = $delayReader.ReadUInt32() # UnloadDelayIT (unused) + $null = $delayReader.ReadUInt32() # TimeStamp (unused) + if ($attributes -eq 0 -and $dllNameRva -eq 0 -and $moduleHandleRva -eq 0 -and $delayIatRva -eq 0) { + # All-zero terminator descriptor. + break + } + $name = & $readDllNameAtRva $dllNameRva + if ($name) { + # De-dup: if a DLL appears in BOTH the regular and + # delay-import directories (uncommon but possible), + # we only want it listed once in the resolution + # probe. + $alreadyPresent = $false + foreach ($existing in $names) { + if ([string]::Equals($existing, $name, [System.StringComparison]::OrdinalIgnoreCase)) { + $alreadyPresent = $true + break + } + } + if (-not $alreadyPresent) { + [void]$names.Add($name) + } + } + } + } catch { + # Best-effort: a missing or malformed delay-import + # directory falls through to whatever we collected from + # the regular import pass. + } + } + + return @($names.ToArray()) + } finally { + $pe.Dispose() + } + } finally { + $stream.Dispose() + } + } catch { + # ANY failure -- type missing, file unreadable, malformed PE, BlobReader + # exhausted -- returns an empty list. The annotation branch then prints + # "(could not enumerate)" instead of named imports, and the build still + # surfaces the underlying 0xC0000135 / STATUS_DLL_NOT_FOUND throw. + return @() + } +} + +function Test-UnityImportResolution { + # Resolve each Unity.exe import against the Windows loader search path so the + # 0xC0000135 / STATUS_DLL_NOT_FOUND short-circuit can NAME the specific + # missing DLL(s) rather than printing a truncated list of "things Unity.exe + # imports". Returns a hashtable describing WHERE each import was found (or + # that it was found NOWHERE). + # + # WHY: previously the short-circuit annotation listed the first 12 of ~36 + # Unity.exe imports with "(+24 more)". The actual missing DLL was usually in + # the truncated tail; the operator had no way to identify it without RDP / + # offline analysis. This helper enumerates every import against the same + # search order the Windows loader uses (default = "safe DLL search mode" via + # CreateProcess), so the annotation can flip from "here are some DLLs Unity + # uses" to "DLL is missing". + # + # SEARCH ORDER (matches Microsoft's documented order for default + # CreateProcess loads): + # 1. KnownDLLs (registry-pinned system DLLs loaded from System32 by name, + # bypassing the path search). Read once from + # HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager\KnownDLLs. + # 2. The directory of Unity.exe. + # 3. %WINDIR%\System32. + # 4. %WINDIR%. + # 5. (Current directory -- not probed; CI invocations don't depend on it + # and probing CWD would add false-positive resolves.) + # 6. Directories listed in %PATH%. + # SysWOW64 is intentionally NOT probed: Unity.exe is 64-bit and SysWOW64 is + # the 32-bit redirector target, so a 64-bit process would never load from + # there anyway. + # + # KEY DESIGN CHOICES: + # - PURE FILE-EXISTENCE PROBE. We do NOT actually call LoadLibrary -- doing + # so would re-trigger the same loader failure inside the diagnostic and + # potentially crash the diagnostic itself. Test-Path against the candidate + # path is the resolver we can run safely from PowerShell. + # - RECORDS WHERE EACH IMPORT WAS FOUND. The hashtable carries both the + # resolved-paths-per-bucket and the missing list. Surfaces "resolved from + # PATH" anomalies -- a Unity-shipped DLL that resolves from PATH instead + # of the Unity install dir is a hint that another tool (e.g. a stale CUDA + # install) is shadowing the Unity copy. + # - EditorDir RESOLVED VIA Split-Path -Parent $EditorPath so a non-default + # Unity install location still gets a correct probe directory. + # - NEVER THROWS. Any failure inside the probe (unreadable registry, + # malformed PATH, missing %WINDIR%) falls through to "best-effort partial + # data". + # + # TEST-ONLY OVERRIDE (mirrors UH_UNITY_FAKE_IMPORTS): + # UH_UNITY_FAKE_MISSING_IMPORTS = comma-separated DLL names FORCED into + # the .missing bucket BEFORE any real probing. Lets hermetic tests on + # Linux/macOS prove the "MISSING DLL(s):" annotation branch without a real + # Unity install and without dropping a real PE binary in the repo. + param( + [Parameter(Mandatory = $true)][AllowEmptyString()][string]$EditorPath, + [Parameter(Mandatory = $true)][AllowEmptyCollection()][string[]]$Imports + ) + + # Hashtable initialized with EVERY key the caller may inspect so a + # StrictMode (Set-StrictMode -Version Latest) reader can't accidentally + # probe an undefined property name and throw mid-annotation. + # Named $buckets (not $result / $resolution) to keep this in-body + # indexing visually distinct from the bare captures of + # Test-UnityImportResolution / Invoke-UnityCliCapture in callers. The + # in-body indexing is provably safe (we always initialize every key + # above). + $buckets = @{ + missing = @() + systemResolved = @{} + windowsResolved = @{} + unityResolved = @{} + pathResolved = @{} + knownDllsResolved = @{} + } + + try { + # KnownDLLs registry probe: Windows-only. The values under + # HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager\KnownDLLs map + # name -> DLL filename. Both keys ("CRYPT32" -> "crypt32.dll", or + # "crypt32" -> "crypt32.dll") are possible across Windows versions; we + # probe BOTH and accept matches in either direction. + $knownDlls = @() + $isWindowsHost = ([System.IO.Path]::DirectorySeparatorChar -eq '\') + if ($isWindowsHost) { + try { + $knownDllsKey = 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager\KnownDLLs' + $knownDllsItem = Get-Item -LiteralPath $knownDllsKey -ErrorAction Stop + foreach ($valueName in $knownDllsItem.GetValueNames()) { + $v = $knownDllsItem.GetValue($valueName) + if ($v -is [string] -and $v.Length -gt 0) { + $knownDlls += $v + } + } + } catch { + # Registry unreadable / non-Windows / no KnownDLLs key. Fall through. + } + } + + # Test-only fake-missing list parsed ONCE (outside the per-import loop). + $fakeMissing = @() + if (-not [string]::IsNullOrEmpty($env:UH_UNITY_FAKE_MISSING_IMPORTS)) { + $fakeMissing = @( + $env:UH_UNITY_FAKE_MISSING_IMPORTS -split ',' | + ForEach-Object { + if ($null -eq $_) { '' } else { ([string]$_).Trim() } + } | + Where-Object { -not [string]::IsNullOrWhiteSpace($_) } + ) + } + + # Resolve search directories ONCE per call (NOT once per import). + $editorDir = $null + if (-not [string]::IsNullOrWhiteSpace($EditorPath)) { + try { + $editorDir = Split-Path -Parent $EditorPath + } catch { + $editorDir = $null + } + } + $windowsDir = if (-not [string]::IsNullOrEmpty($env:WINDIR)) { $env:WINDIR } else { $null } + $system32 = $null + if ($windowsDir) { + try { + $system32 = Join-Path $windowsDir 'System32' + } catch { + $system32 = $null + } + } + + $pathEntries = @() + if (-not [string]::IsNullOrEmpty($env:Path)) { + # ';' splits Windows-style PATH; ':' is the POSIX form and is not + # relevant here (we only resolve against a Windows loader's search + # order). Split-but-trim, drop blanks. + $pathEntries = @( + $env:Path -split ';' | + ForEach-Object { + if ($null -eq $_) { '' } else { ([string]$_).Trim() } + } | + Where-Object { -not [string]::IsNullOrWhiteSpace($_) } + ) + } + + foreach ($import in $Imports) { + if ($null -eq $import) { continue } + $name = ([string]$import).Trim() + if ([string]::IsNullOrWhiteSpace($name)) { continue } + + # TEST-ONLY override BEFORE any real probing so hermetic tests can + # stage a deterministic missing bucket on any OS. + if ($fakeMissing.Count -gt 0) { + $forced = $false + foreach ($f in $fakeMissing) { + if ([string]::Equals($f, $name, [System.StringComparison]::OrdinalIgnoreCase)) { + $forced = $true + break + } + } + if ($forced) { + $buckets.missing += $name + continue + } + } + + # KnownDLLs check FIRST: those load by name from System32 without + # path search. KnownDLLs values are sometimes stored with the .dll + # extension and sometimes without, so accept both shapes. + $isKnown = $false + foreach ($k in $knownDlls) { + if ([string]::IsNullOrWhiteSpace($k)) { continue } + $kTrim = ([string]$k).Trim() + if ([string]::Equals($kTrim, $name, [System.StringComparison]::OrdinalIgnoreCase) -or + [string]::Equals(($kTrim + '.dll'), $name, [System.StringComparison]::OrdinalIgnoreCase) -or + [string]::Equals($kTrim, ($name -replace '\.dll$', ''), [System.StringComparison]::OrdinalIgnoreCase)) { + $isKnown = $true + break + } + } + if ($isKnown) { + $buckets.knownDllsResolved[$name] = 'KnownDLLs' + continue + } + + # Path-search buckets in loader order. Each entry is (bucketKey, dir). + $searchDirs = New-Object 'System.Collections.Generic.List[object]' + if ($editorDir) { + [void]$searchDirs.Add(@{ bucket = 'unityResolved'; dir = $editorDir }) + } + if ($system32) { + [void]$searchDirs.Add(@{ bucket = 'systemResolved'; dir = $system32 }) + } + if ($windowsDir) { + [void]$searchDirs.Add(@{ bucket = 'windowsResolved'; dir = $windowsDir }) + } + foreach ($p in $pathEntries) { + [void]$searchDirs.Add(@{ bucket = 'pathResolved'; dir = $p }) + } + + $resolved = $false + foreach ($candidate in $searchDirs) { + $dir = $candidate.dir + if ([string]::IsNullOrWhiteSpace($dir)) { continue } + try { + $probe = Join-Path $dir $name + } catch { + continue + } + try { + if (Test-Path -LiteralPath $probe -PathType Leaf) { + $bucket = $candidate.bucket + # First-hit wins (matches loader-search semantics); do not + # overwrite a later resolution onto an earlier bucket. + if (-not $buckets[$bucket].ContainsKey($name)) { + $buckets[$bucket][$name] = $probe + } + $resolved = $true + break + } + } catch { + # Continue to the next candidate dir if Test-Path itself + # blows up on a malformed PATH entry. + continue + } + } + if (-not $resolved) { + $buckets.missing += $name + } + } + } catch { + # Outer-level safety net: even a catastrophic failure inside the resolver + # must NEVER mask the underlying 0xC0000135 throw. Return whatever + # partial data we already collected. + } + + return $buckets +} + +function Test-UnityImportLooksUnityShipped { + # True iff $Name matches the heuristic for "Unity-shipped third-party + # library" -- a DLL whose presence in the missing list points the operator + # at "Unity install is partial/corrupt" rather than "host OS prereq is + # missing". The patterns are deliberately broad (Unity ships dozens of + # third-party libs under names that vary by version): if the operator sees a + # false positive, the remediation hint ("reinstall Unity") is still safe + # (auto-repair quarantines + reinstalls; on a healthy install it's a no-op). + # + # MAINTENANCE NOTE (R6, round-3 review): + # Last reviewed against Unity 2021.3 / 2022.3 / 6000.3 import lists from + # live CI run 70874414898 (date 2026-05-26). Revisit when bumping to a + # new Unity major (e.g. Unity 7) -- Unity's bundled third-party set + # evolves between major versions and a brand-new shipped DLL not + # matching any pattern below will fall through to the OS-prereq hint + # (which still produces a SAFE remediation: "run bootstrap"; the only + # loss is the more-specific "your Unity install is corrupt, reinstall" + # hint that would have been more accurate). + # + # We intentionally do NOT match VCRUNTIME140* / MSVCP140* / ucrtbase* / + # KERNEL32* / api-ms-win-* / CRYPT32* / bcrypt* / ntdll* -- those are OS + # prereqs whose remediation is the bootstrap script, not a Unity reinstall. + # + # R5 (round-3 review nit): the broader patterns (^optix.*\.dll$ and + # ^.*compress.*\.dll$) subsume several narrower ones (^optix\.[\d\.]+\.dll$, + # ^etccompress\.dll$, ^s3tcompress\.dll$, ^compress_bc7e\.dll$). The + # narrower names are deliberately RETAINED below as in-line documentation + # of the specific Unity-shipped DLLs we've actually observed -- they + # serve as commit-archaeology breadcrumbs ("Unity actually ships these") + # without changing behavior (first-hit-wins; redundant matches are no-ops). + param([Parameter(Mandatory = $true)][string]$Name) + + if ([string]::IsNullOrWhiteSpace($Name)) { return $false } + $lower = $Name.ToLowerInvariant() + # Anchor on known Unity-shipped third-party DLLs and Unity-specific naming + # conventions. Order is irrelevant; first hit wins. The narrower entries + # are documentation as much as detection (see R5 maintenance note above). + $patterns = @( + '^libfbxsdk\.dll$', + '^optix\.[\d\.]+\.dll$', # documented variant of the broader optix*.dll + '^optix.*\.dll$', + '^openimagedenoise\.dll$', + '^umbraoptimizer64\.dll$', + '^.*compress.*\.dll$', + '^freeimage\.dll$', + '^winpixeventruntime\.dll$', + '^ispc_texcomp\.dll$', + '^etccompress\.dll$', # documented variant; subsumed by *compress* + '^s3tcompress\.dll$', # documented variant; subsumed by *compress* + '^compress_bc7e\.dll$' # documented variant; subsumed by *compress* + ) + foreach ($p in $patterns) { + if ($lower -match $p) { + return $true + } + } + return $false +} + +function Test-UnityNativeStartup { + param( + [Parameter(Mandatory = $true)][string]$EditorPath, + [Parameter(Mandatory = $true)][string]$LogPath + ) + + $logDir = Split-Path -Parent $LogPath + if ($logDir -and -not (Test-Path -LiteralPath $logDir -PathType Container)) { + New-Item -ItemType Directory -Force -Path $logDir | Out-Null + } + + $probeArgs = @( + '-version', + '-batchmode', + '-nographics', + '-quit', + '-logFile', '-' + ) + + Write-Host "::group::Unity editor startup provisioning probe" + Write-Host "`"$EditorPath`" $($probeArgs -join ' ')" + & $EditorPath @probeArgs 2>&1 | + Tee-Object -FilePath $LogPath | + ForEach-Object { Write-Host ([string]$_) } + $exitCode = $LASTEXITCODE + $description = Get-NativeExitCodeDescription -ExitCode $exitCode + Write-Host "Unity startup provisioning probe exit code: $exitCode ($description)" + Write-Host "::endgroup::" + + return [pscustomobject]@{ + Success = ($exitCode -eq 0) + ExitCode = $exitCode + Description = $description + } +} + +function Ensure-UnityNativeStartupHealthy { + param( + [Parameter(Mandatory = $true)][string]$Version, + [Parameter(Mandatory = $true)][string]$EditorPath, + [Parameter(Mandatory = $true)][string]$InstallRoot, + [string]$Profile = $(Get-UnityProvisioningProfile), + [switch]$ManagedOnly + ) + + # Test harnesses spawn ensure-editor.ps1 against a stub Unity.exe; Windows CreateProcess refuses non-PE .exe, so opt-in skip the probe. + if ($env:UH_UNITY_SKIP_NATIVE_STARTUP_PROBE -eq '1') { + Write-CiNotice "Skipping Unity $Version native startup probe (UH_UNITY_SKIP_NATIVE_STARTUP_PROBE=1)." + return $EditorPath + } + + $probeRoot = Join-Path $InstallRoot '_probes' + $probeLog = Join-Path $probeRoot "$Version-startup-probe.log" + $result = Test-UnityNativeStartup -EditorPath $EditorPath -LogPath $probeLog + if ($result.Success) { + return $EditorPath + } + + # SHORT-CIRCUIT: 0xC0000135 / STATUS_DLL_NOT_FOUND is a HOST OS prerequisite + # failure -- the Windows loader could not resolve a DLL Unity.exe imports + # (overwhelmingly the Microsoft Visual C++ Redistributables -- production + # run 70874414898 identified MSVCP100 from the 2010 generation; the 2015-2022 + # generation is the other common culprit). A managed reinstall of Unity does + # NOT help: the missing DLL is on the OS, not in the Unity install tree. + # Wasting ~6 minutes per matrix cell on a reinstall that + # cannot succeed delays the actionable failure mode and obscures the real + # remediation. We therefore short-circuit BEFORE the UH_UNITY_DISABLE_EDITOR_REPAIR + # check too: that flag is an operator opt-out of auto-repair, and on 0xC0000135 + # there is nothing TO repair via reinstall regardless of the flag. The + # short-circuit emits a wrap-immune ::error:: annotation BEFORE throwing so the + # actionable host-prereq guidance survives ConciseView word-wrap (the throw text + # is reformatted by the runner's error formatter; the Write-Host line is not). + # + # OVERRIDE: UH_UNITY_FORCE_REINSTALL=1 lets the operator bypass the + # short-circuit when the OPERATOR has determined (via the named-missing-DLL + # annotation from a prior failed job) that the missing DLL is a Unity-shipped + # third-party library and the install is corrupt rather than the OS being + # broken. In that case a managed reinstall WILL fix it -- the asymmetry that + # justified the short-circuit (missing DLL is on the OS, reinstall doesn't + # help) is inverted. The bypass emits a CI notice so the override is visible + # in the log, then falls through to the existing repair path. + if (Test-IsNativeDllNotFound -ExitCode $result.ExitCode) { + if ($env:UH_UNITY_FORCE_REINSTALL -eq '1') { + Write-CiNotice "UH_UNITY_FORCE_REINSTALL=1: bypassing 0xC0000135 short-circuit; will attempt managed reinstall (caller asserts the failure is install corruption, not host prereq). If the reinstall fails to recover Unity startup, the post-repair short-circuit will fire." + } else { + Write-UnityHostPrereqAnnotation -Version $Version -ExitCode $result.ExitCode -Description $result.Description -EditorPath $EditorPath -ProbeLog $probeLog + throw "Unity $Version native startup probe failed with exit code $($result.ExitCode) (0xC0000135 / STATUS_DLL_NOT_FOUND). This is a host OS prerequisite failure (the Windows loader could not find a DLL Unity.exe imports). The most likely cause is a missing Microsoft Visual C++ Redistributable: the 2010 SP1 generation ships MSVCP100.dll/MSVCR100.dll, and the 2015-2022 generation ships VCRUNTIME140.dll/MSVCP140.dll -- BOTH are required for Unity. Skipped managed reinstall (would not help: the missing DLL is on the OS, not in the Unity install). Probe log: $probeLog. Runbook: docs/runbooks/unity-runners-after-transfer.md (Windows host prerequisites). Remediation: run scripts/unity/bootstrap-windows-runner.ps1 on this runner (or trigger the runner-bootstrap workflow_dispatch from the Actions UI). If the missing DLL is Unity-shipped (libfbxsdk, optix, etc., per the MISSING DLL annotation above), set UH_UNITY_FORCE_REINSTALL=1 to bypass this short-circuit and retry with a managed reinstall." + } + } + + if ($env:UH_UNITY_DISABLE_EDITOR_REPAIR -eq '1') { + throw "Unity $Version native startup probe failed with exit code $($result.ExitCode) ($($result.Description)), and UH_UNITY_DISABLE_EDITOR_REPAIR=1 disabled auto-repair. Probe log: $probeLog" + } + + Write-Host "::warning::Unity $Version native startup probe failed before the license lock; attempting one managed reinstall." + $repaired = Repair-UnityEditorWithCiModules -Version $Version -EditorPath $EditorPath -InstallRoot $InstallRoot -Reason "native startup probe failed with exit code $($result.ExitCode) ($($result.Description)). Probe log: $probeLog" -Profile $Profile -ManagedOnly:$ManagedOnly + # Repair-UnityEditorWithCiModules requests the selected provisioning profile, + # then we re-run the disk-authoritative module check so a CLI success with + # missing selected-profile children is still caught before the final native + # startup probe. + $repaired = Ensure-UnityCiModules -Version $Version -EditorPath $repaired -InstallRoot $InstallRoot -Profile $Profile -ManagedOnly:$ManagedOnly + $repairProbe = Test-UnityNativeStartup -EditorPath $repaired -LogPath $probeLog + if (-not $repairProbe.Success) { + # POST-REPAIR HOST-PREREQ SHORT-CIRCUIT: a managed reinstall succeeded but + # the editor STILL fails 0xC0000135 / STATUS_DLL_NOT_FOUND. This means the + # host went south mid-job (a runtime DLL was deleted or the repair installer + # wiped a prerequisite). Same operator-actionable annotation as the + # first-probe short-circuit, but with -RepairAttempted so the message + # reflects that the managed reinstall already ran (and did not help, as + # expected for 0xC0000135 -- the missing DLL is on the OS). + if (Test-IsNativeDllNotFound -ExitCode $repairProbe.ExitCode) { + Write-UnityHostPrereqAnnotation -Version $Version -ExitCode $repairProbe.ExitCode -Description $repairProbe.Description -EditorPath $repaired -ProbeLog $probeLog -RepairAttempted + throw "Unity $Version native startup probe still failed after managed reinstall with exit code $($repairProbe.ExitCode) ($($repairProbe.Description)). Host OS prerequisite damage. The managed reinstall did not help (as expected for 0xC0000135). Probe log: $probeLog. Runbook: docs/runbooks/unity-runners-after-transfer.md. Remediation: run scripts/unity/bootstrap-windows-runner.ps1 (or trigger .github/workflows/runner-bootstrap.yml)." + } + throw "Unity $Version native startup probe still failed after managed reinstall with exit code $($repairProbe.ExitCode) ($($repairProbe.Description)). This indicates host OS/runtime prerequisite damage rather than a package/test issue. Probe log: $probeLog" + } + + return $repaired +} + +function Test-WindowsLongPathSupport { + # Best-effort probe of whether Windows long-path (>260 char) support is enabled. + # Reads HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem!LongPathsEnabled. + # Returns $true (enabled), $false (explicitly disabled), or $null (unknown / + # non-Windows / unreadable). NEVER throws. This is a prime suspect for the + # Android NDK unpack failure: NDK extraction produces very deep paths and a + # disabled MAX_PATH can break the unzip mid-way. + # + # TEST-ONLY hermeticity override (same spirit as the other UH_UNITY_* test + # knobs): the real registry value is uncontrolled by a test and differs per + # runner, so honor UH_UNITY_FAKE_LONGPATHS_ENABLED FIRST -- '1' => $true, + # '0' => $false -- before falling through to the real registry probe. This lets + # the post-mortem MAX_PATH-warning test deterministically exercise both sides of + # the guard on every OS without depending on the host registry. + if ($env:UH_UNITY_FAKE_LONGPATHS_ENABLED -eq '1') { + return $true + } + if ($env:UH_UNITY_FAKE_LONGPATHS_ENABLED -eq '0') { + return $false + } + if ([System.IO.Path]::DirectorySeparatorChar -ne '\') { + return $null + } + try { + $value = Get-ItemPropertyValue -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem' -Name 'LongPathsEnabled' -ErrorAction Stop + if ($null -eq $value) { + return $null + } + return ([int]$value -ne 0) + } catch { + return $null + } +} + +function Get-DeepestPathLengthUnder { + # Best-effort: the maximum full-path character length of any file/dir under + # $Directory (0 if none / unreadable / missing). NEVER throws. Used by the + # post-mortem to surface whether the Android NDK extraction produced paths at or + # beyond the Windows MAX_PATH (260) limit. + param([string]$Directory) + + if (-not $Directory -or -not (Test-Path -LiteralPath $Directory)) { + return 0 + } + try { + $max = 0 + foreach ($item in @(Get-ChildItem -LiteralPath $Directory -Recurse -Force -ErrorAction SilentlyContinue)) { + if ($null -eq $item) { + continue + } + $len = ([string]$item.FullName).Length + if ($len -gt $max) { + $max = $len + } + } + return $max + } catch { + return 0 + } +} + +function Write-UnityModuleInstallPostMortem { + # WRAP-IMMUNE, best-effort post-mortem for a failed CI module install. Emits + # single-line `::notice::`/`::error::`/`::warning::` annotations (immune to + # ConciseView word-wrap) describing the on-disk state of every verified module + # group and, for the Android groups specifically, deep diagnostics about the + # NDK/SDK payload and the Windows long-path/MAX_PATH state. NEVER throws. + param( + [Parameter(Mandatory = $true)][string]$Version, + [Parameter(Mandatory = $true)][string]$EditorPath, + [string]$Root, + [string]$Profile = $(Get-UnityProvisioningProfile) + ) + + try { + Write-Host "::notice::Unity $Version module install post-mortem for provisioning profile '$Profile' (disk is the source of truth):" + + foreach ($group in @(Get-UnityCiVerifiedModuleGroups -Profile $Profile)) { + $present = Test-UnityCiModuleGroupPresent -EditorPath $EditorPath -Group $group + $state = if ($present) { 'present' } else { 'MISSING' } + Write-Host "::notice:: module group '$group': $state" + } + + $editorDir = Split-Path -Parent $EditorPath + if ($editorDir -and (Test-UnityProvisioningProfileIncludesAndroid -Profile $Profile)) { + $androidRoot = Join-Path $editorDir 'Data\PlaybackEngines\AndroidPlayer' + foreach ($payload in @('NDK', 'SDK')) { + $payloadRoot = Join-Path $androidRoot $payload + if (Test-Path -LiteralPath $payloadRoot -PathType Container) { + $fileCount = @(Get-ChildItem -LiteralPath $payloadRoot -Recurse -Force -File -ErrorAction SilentlyContinue).Count + $deepest = Get-DeepestPathLengthUnder -Directory $payloadRoot + Write-Host "::notice:: AndroidPlayer\$payload : exists, $fileCount file(s), deepest absolute path length $deepest" + } else { + Write-Host "::notice:: AndroidPlayer\$payload : (absent)" + } + } + + $ndkProps = Join-Path $androidRoot 'NDK\source.properties' + Write-Host "::notice:: NDK\source.properties present: $([bool](Test-Path -LiteralPath $ndkProps -PathType Leaf))" + $clang = Test-AnyUnityLeafPresent -Paths @( + (Join-Path $androidRoot 'NDK\toolchains\llvm\prebuilt\windows-x86_64\bin\clang++.exe'), + (Join-Path $androidRoot 'NDK\toolchains\llvm\prebuilt\linux-x86_64\bin\clang++') + ) + # A loose recursive probe too (toolchain host-arch dir name varies). + if (-not $clang) { + $llvmRoot = Join-Path $androidRoot 'NDK\toolchains\llvm\prebuilt' + if (Test-Path -LiteralPath $llvmRoot -PathType Container) { + $clangLeaves = @( + Get-ChildItem -LiteralPath $llvmRoot -Recurse -File -ErrorAction SilentlyContinue | + Where-Object { $_.Name -in @('clang++', 'clang++.exe') } | + Select-Object -First 1 + ) + $clang = $clangLeaves.Count -gt 0 + } + } + Write-Host "::notice:: NDK clang++ present: $clang" + $java = Test-AnyUnityLeafPresent -Paths @( + (Join-Path $androidRoot 'OpenJDK\bin\java.exe'), + (Join-Path $androidRoot 'OpenJDK\bin\java') + ) + Write-Host "::notice:: OpenJDK java present: $java" + + $deepestNdk = Get-DeepestPathLengthUnder -Directory (Join-Path $androidRoot 'NDK') + $longPaths = Test-WindowsLongPathSupport + $longPathsText = if ($null -eq $longPaths) { 'unknown' } else { [string]$longPaths } + Write-Host "::notice:: Windows long-path support (LongPathsEnabled): $longPathsText" + if ($deepestNdk -ge 240 -and $longPaths -ne $true) { + Write-Host "::warning::Unity $Version Android NDK extraction reached a deep path (deepest NDK path length $deepestNdk >= 240) while Windows long-path support is not enabled. NDK extraction likely hit the Windows MAX_PATH (260) limit. See docs/runbooks/unity-runners-after-transfer.md." + } + } + + if ($Root) { + Write-Host "::notice:: $(Get-InstallDriveFreeSpaceText -Root $Root)" + } + } catch { + Write-Host "::notice::Unity $Version module install post-mortem could not complete: $($_.Exception.Message)" + } +} + +function Clear-PartialAndroidModulePayload { + # Best-effort removal of the partial heavy Android payload (the NDK and SDK + # directories under AndroidPlayer) before a RETRY of the Android module install. + # A failed NDK unpack can leave a half-written tree that confuses the next + # attempt; clearing only the NDK/SDK dirs (NOT the whole editor) lets the retry + # start clean WITHOUT a multi-GB editor re-download. SAFETY: operates ONLY + # inside the resolved editor directory; never touches anything outside it. The + # destructive Remove-Item is wrapped in Invoke-WithRetry for Windows lock + # resilience (the indexer/Defender can transiently hold a handle). NEVER throws + # (best-effort): a failed clear just means the retry runs against the partial + # tree, which is no worse than not clearing. + param([Parameter(Mandatory = $true)][string]$EditorPath) + + try { + $editorDir = Split-Path -Parent $EditorPath + if (-not $editorDir) { + return + } + $androidRoot = Join-Path $editorDir 'Data\PlaybackEngines\AndroidPlayer' + $cleared = New-Object System.Collections.Generic.List[string] + foreach ($payload in @('NDK', 'SDK')) { + $payloadRoot = Join-Path $androidRoot $payload + if (Test-Path -LiteralPath $payloadRoot -PathType Container) { + try { + Invoke-WithRetry -MaxAttempts 3 -DelaySeconds (Get-EnsureEditorRetryDelaySeconds) -Action { + Remove-Item -LiteralPath $payloadRoot -Recurse -Force + } | Out-Null + $cleared.Add($payload) + } catch { + Write-Host "::notice::Could not clear partial Android payload '$payloadRoot' before retry: $($_.Exception.Message)" + } + } + } + if ($cleared.Count -gt 0) { + Write-Host "::notice::Cleared partial Android module payload before retry under '$androidRoot': $($cleared.ToArray() -join ', ')." + } + } catch { + Write-Host "::notice::Clear-PartialAndroidModulePayload best-effort cleanup failed: $($_.Exception.Message)" + } +} + +function Install-UnityAndroidModules { + # DEDICATED, BOUNDED Android module install for existing editors -- the + # heavy/flaky tier (android + android-sdk-ndk-tools, multi-GB Google download + # whose NDK unpack fails deterministically at ~93% on Windows). This cheap + # repair runs before the script escalates to a profile-scoped managed + # reinstall. + # + # Loop up to Get-EnsureEditorAndroidInstallRetryAttempts times: before a retry + # (attempt > 1), clear the partial NDK/SDK payload and back off (linear). Each + # attempt requests the android tier via the sole-producer helper and runs the + # capturing invoker. After each attempt, re-verify the android tier ON DISK + # (disk is the truth: exit 6 with everything present is success). On a failed + # attempt emit the targeted failure annotation + the wrap-immune summary. On + # exhaustion, emit the post-mortem and escalate to quarantine/reinstall unless + # UH_UNITY_DISABLE_EDITOR_REPAIR=1. + param( + [Parameter(Mandatory = $true)][string]$Version, + [Parameter(Mandatory = $true)][string]$EditorPath, + [string]$InstallRoot, + [string]$Profile = $(Get-UnityProvisioningProfile), + [switch]$ManagedOnly + ) + + if (-not (Test-UnityProvisioningProfileIncludesAndroid -Profile $Profile)) { + throw "Provisioning profile '$Profile' does not include the Android module tier." + } + + # Honor -ManagedOnly for consistency with every other install path (the base + # install, the repair, and Ensure-UnityCiModules): refuse to mutate editors + # outside the managed root before the install loop runs. + if ($ManagedOnly) { + Confirm-UnityCliManagedInstallRoot -Root $InstallRoot | Out-Null + } + + $androidIds = @(Get-UnityCiModuleIdsForTier -Tier 'android' -Profile $Profile) + $maxAttempts = Get-EnsureEditorAndroidInstallRetryAttempts + $retryDelaySeconds = Get-EnsureEditorRetryDelaySeconds + $installTimeout = Get-EnsureEditorInstallTimeoutSeconds + + # The (EULA-bearing) android-tier install vector, routed through the sole + # producer (scoped to the android tier via -ModuleIds). Captured once and reused + # for both the install call and the failure-annotation arg echo. + $installArgs = @(Get-UnityCliModuleInstallArguments -Verb 'install-modules' -Version $Version -ModuleIds $androidIds) + + Write-CiNotice "Installing the Android CI module tier for Unity $Version in a dedicated, separately-retried step ($($androidIds -join ', ')); exhaustion escalates to managed quarantine/reinstall unless repair is disabled." + + for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) { + if ($attempt -gt 1) { + Clear-PartialAndroidModulePayload -EditorPath $EditorPath + $sleep = $retryDelaySeconds * $attempt + Write-Host "::warning::Android module install attempt $($attempt - 1) of $maxAttempts did not deliver the Android tier for Unity $Version. Retrying (attempt $attempt) in $sleep second(s) after clearing the partial payload." + Start-Sleep -Seconds $sleep + } + + $result = Invoke-UnityCliCapture -Arguments $installArgs + + # Disk is the source of truth: re-verify the android tier groups. If none + # are missing, the install succeeded regardless of the CLI exit code (an + # exit 6 with everything present is the idempotent no-op). + $missingAndroid = @(Get-MissingUnityCiModuleGroups -EditorPath $EditorPath -Profile $Profile | Where-Object { (Get-UnityCiModuleTier $_) -eq 'android' }) + if ($missingAndroid.Count -eq 0) { + Write-CiNotice "Android CI module tier for Unity $Version present on disk after attempt $attempt (CLI exit code $($result.ExitCode))." + return $EditorPath + } + + # This attempt did not deliver the android tier: emit the targeted + # annotation + the wrap-immune summary so each failed attempt is diagnosable. + Write-UnityCliInstallFailureAnnotation -Version $Version -Output $result.Output -ExitCode $result.ExitCode -Arguments $installArgs + # Phrase the annotation from the WRAPPER-DRIVEN kill state, not the raw + # exit code: a NATIVE 124/125 from the Unity CLI must not be misread as + # a heartbeat-stall or wall-clock kill. The retry classifier is unchanged + # -- both sentinels remain retryable via the existing throw flow. + $androidStallKilled = [bool]$result.StallKilled + $androidWallTimedOut = [bool]$result.TimedOutWallClock + $androidTimedOut = ($androidStallKilled -or $androidWallTimedOut) + Write-ModuleInstallFailureDiagnostics -Version $Version -Output $result.Output -ExitCode $result.ExitCode -Arguments $installArgs -Root $InstallRoot -TimedOut:$androidTimedOut -StallKilled:$androidStallKilled -TimedOutWallClock:$androidWallTimedOut -TimeoutSeconds $installTimeout -StallSeconds (Get-EnsureEditorProgressStallSeconds) + } + + # Exhausted every bounded Android-only attempt. Existing editors get this + # cheap repair first, but Android exhaustion is now treated as evidence that + # the editor tree may be internally inconsistent. Unless the operator disabled + # editor repair, escalate to the profile-scoped managed quarantine/reinstall path. + $stillMissing = @(Get-MissingUnityCiModuleGroups -EditorPath $EditorPath -Profile $Profile | Where-Object { (Get-UnityCiModuleTier $_) -eq 'android' }) + Write-UnityModuleInstallPostMortem -Version $Version -EditorPath $EditorPath -Root $InstallRoot -Profile $Profile + if ($env:UH_UNITY_DISABLE_EDITOR_REPAIR -eq '1') { + throw "Unity $Version Android CI module install FAILED after $maxAttempts attempt(s): the Android tier groups are still missing on disk ($($stillMissing -join ', ')), and UH_UNITY_DISABLE_EDITOR_REPAIR=1 disabled escalation to managed quarantine/reinstall." + } + if (-not $InstallRoot) { + throw "Unity $Version Android CI module install FAILED after $maxAttempts attempt(s): the Android tier groups are still missing on disk ($($stillMissing -join ', ')), and no managed install root was supplied for quarantine/reinstall." + } + + Stop-StaleUnityProvisioningProcesses -InstallRoot $InstallRoot -Version $Version -Reason "Android-only repair exhausted before managed reinstall" + Write-Host "::warning::Unity $Version Android-only repair exhausted after $maxAttempts attempt(s); escalating to managed quarantine/reinstall with provisioning profile '$Profile'." + return Repair-UnityEditorWithCiModules -Version $Version -EditorPath $EditorPath -InstallRoot $InstallRoot -Reason "Android-only repair exhausted after $maxAttempts attempt(s); missing Android groups: $($stillMissing -join ', ')." -Profile $Profile -ManagedOnly:$ManagedOnly +} + +function Test-TextIndicatesEditorNotModuleManageable { + # PURE, StrictMode-safe classifier. Returns $true iff the supplied text carries + # the Unity-CLI signal that the target editor is NOT module-manageable, i.e. that + # `install-modules` will refuse to add anything to it. Observed verbatim on the + # failing self-hosted run (Unity 6000.3.16f1 standalone, run 26701943540): + # + # Error: No modules found for this editor. + # Module installation is only supported for editors installed with Unity Hub. + # Try reinstalling this editor with Unity Hub to use this feature. + # + # Matching ANY of these phrases is sufficient; the wording has drifted across + # beta builds, so we accept the stable substrings rather than the whole block. + # This is the PROACTIVE root-cause signal that lets the caller skip the doomed + # `install-modules` -> exit 6 -> uninstall -> quarantine path and go straight to + # the atomic `install -m ` reinstallation verb (the documented way + # to add modules to such an editor; see Install-UnityEditorWithCiModules). + param([string]$Text) + + $value = [string]$Text + if ($value.Trim().Length -eq 0) { + return $false + } + return ($value -match '(?i)only supported for editors installed with Unity Hub') -or + ($value -match '(?i)reinstall(?:ing)? this editor with Unity Hub') -or + ($value -match '(?i)No modules found for this editor') +} + +function Test-UnityEditorModuleManageable { + # Best-effort, NON-THROWING probe: is the editor at $Version in a state where the + # Unity CLI's `install-modules` can ADD modules to it? Returns a StrictMode-safe + # hashtable @{ Manageable=[bool]; Reason=[string]; Output=[string[]] }. + # + # Manageable is $false ONLY when the `-l` listing text carries an explicit + # not-module-manageable signal (Test-TextIndicatesEditorNotModuleManageable). + # When the probe cannot run, times out, or returns ambiguous output, Manageable + # defaults to $true: we must NOT route a healthy editor down the heavyweight + # atomic-reinstall path on a flaky probe -- the existing disk-authoritative + # install-modules flow already handles the normal "modules missing" case and an + # exit 6 there still escalates correctly. Only a POSITIVE unmanageable signal + # short-circuits to the atomic reinstall. + # + # Uses the timeout-capable capturing invoker bounded by the PROBE timeout (the + # `-l` listing is a quick metadata read, not a multi-GB install -- it must NOT + # inherit the 45-minute install wall-clock), and we read the captured output even + # on a non-zero exit (Get-UnityCliOutput would discard it) so the signal text is + # available regardless of the listing's exit code. + param([Parameter(Mandatory = $true)][string]$Version) + + $result = $null + try { + $requestedTimeout = Get-EnsureEditorProbeTimeoutSeconds + $effectiveTimeout = Get-EffectiveUnityCliTimeoutSeconds -RequestedSeconds $requestedTimeout + $result = Invoke-UnityCliCaptureWithTimeout -Arguments @('install-modules', '-e', $Version, '-l') -TimeoutSeconds $effectiveTimeout -TimeoutKnob 'UH_ENSURE_EDITOR_PROBE_TIMEOUT_SECONDS' + } catch { + return @{ Manageable = $true; Reason = "module-manageability probe threw: $($_.Exception.Message)"; Output = @() } + } + + $lines = @($result.Output | ForEach-Object { [string]$_ }) + $text = ($lines -join "`n") + if (Test-TextIndicatesEditorNotModuleManageable -Text $text) { + return @{ + Manageable = $false + Reason = 'Unity CLI reports this editor is not module-manageable (not installed via Hub/CLI registration); install-modules would fail with exit 6.' + Output = $lines + } + } + + return @{ Manageable = $true; Reason = ''; Output = $lines } +} + +function Install-UnityEditorModulesViaAtomicReinstall { + # ROOT-CAUSE SIDESTEP for the not-module-manageable editor (the exit-6 failure): + # add the required modules by re-running the ATOMIC `install -m ` + # verb -- the Unity-documented "headless reinstallation ... to add modules during + # installation" -- INSTEAD of `install-modules` (which exits 6 on such an editor) + # followed by uninstall + quarantine. + # + # WHY TRY IN PLACE FIRST (the real win): the observed failure is that the + # uninstall+quarantine path then fails because a process holds a handle on + # ...\\Editor, so the Move-Item cannot win and the whole run aborts. + # Install-UnityEditorWithCiModules runs the atomic `install` verb DIRECTLY, + # WITHOUT a preceding quarantine, so when the install can repair/overlay the + # existing tree in place we never touch the locker at all. If that atomic + # in-place install genuinely fails (e.g. the editor tree is corrupt and the CLI + # cannot overlay it), we FALL BACK to the existing quarantine+reinstall path + # (Repair-UnityEditorWithCiModules) -- which may still hit the locker, but that is + # no worse than today and is reported honestly by the wrap-immune ::error:: there. + # + # Wrapped in the install lock so the in-place attempt and any fallback share one + # critical section (Invoke-WithUnityInstallLock is re-entrant, so the nested + # Repair-... call does not deadlock). StrictMode-safe. + param( + [Parameter(Mandatory = $true)][string]$Version, + [Parameter(Mandatory = $true)][string]$EditorPath, + [Parameter(Mandatory = $true)][string]$InstallRoot, + [Parameter(Mandatory = $true)][string]$Reason, + [string]$Profile = $(Get-UnityProvisioningProfile), + [switch]$ManagedOnly + ) + + return Invoke-WithUnityInstallLock -Version $Version -InstallRoot $InstallRoot -Action { + Write-CiNotice "Unity $Version is not module-manageable via install-modules; routing to the atomic 'install -m' reinstallation (in place, no quarantine) to add the required modules. Reason: $Reason" + try { + # In-place atomic install: NO quarantine first, so a held handle on the + # editor tree is never even contended unless the CLI itself needs to + # rewrite a locked file. + return Install-UnityEditorWithCiModules -Version $Version -InstallRoot $InstallRoot -Reason $Reason -Profile $Profile -ManagedOnly:$ManagedOnly + } catch { + $inPlaceMessage = $_.Exception.Message + if ($env:UH_UNITY_DISABLE_EDITOR_REPAIR -eq '1') { + throw + } + # The atomic in-place install could not deliver the modules (e.g. the CLI + # could not overlay the existing tree). Fall back to the heavier + # quarantine+reinstall, which moves the version dir aside first. HONEST + # CAVEAT: if a cross-identity process holds a hard lock on the tree, that + # quarantine can still fail -- Move-UnityInstallDirectoryToQuarantine emits + # a wrap-immune ::error:: naming the residual as runner-side. + Write-Host "::warning::Atomic in-place reinstall for Unity $Version did not deliver the required modules ($inPlaceMessage); falling back to quarantine + reinstall." + return Repair-UnityEditorWithCiModules -Version $Version -EditorPath $EditorPath -InstallRoot $InstallRoot -Reason "atomic in-place reinstall did not deliver required modules ($inPlaceMessage)" -Profile $Profile -ManagedOnly:$ManagedOnly + } + } +} + +function Ensure-UnityCiModules { + # IDEMPOTENT, disk-authoritative, TIER-AWARE CI module install. The standalone + # beta CLI can return "No modules found to install." with exit code 6 when + # modules are already present, and it cannot add modules to manually installed + # editors. Classify the result against disk proof first; if required module + # groups are missing, handle the CORE tier and the ANDROID tier separately. + # 1. all groups present on disk -> done. + # 2. core missing -> scoped install-modules; if + # still missing and repair enabled -> quarantine/reinstall (core). + # 3. android missing -> dedicated bounded retry; + # exhaustion escalates to managed quarantine/reinstall unless disabled. + # 4. still missing after repair is disabled -> throw with post-mortem. + param( + [Parameter(Mandatory = $true)][string]$Version, + [Parameter(Mandatory = $true)][string]$EditorPath, + [string]$InstallRoot, + [string]$Profile = $(Get-UnityProvisioningProfile), + [switch]$ManagedOnly + ) + + $moduleIds = @(Get-UnityCiModuleIds -Profile $Profile) + $verifiedGroups = @(Get-UnityCiVerifiedModuleGroups -Profile $Profile) + + if ($ManagedOnly) { + Confirm-UnityCliManagedInstallRoot -Root $InstallRoot | Out-Null + } + + if ($moduleIds.Count -eq 0 -and $verifiedGroups.Count -eq 0) { + Write-CiNotice "Provisioning profile '$Profile' requires the Unity editor only; skipping Unity module install and verification." + return $EditorPath + } + + # Best-effort listing diagnostic (unchanged): the beta listing format may not + # contain every literal module id, so a mismatch only warns and never aborts. + $listLines = @(Get-UnityCliOutput -Arguments @('install-modules', '-e', $Version, '-l')) + if ($listLines.Count -gt 0) { + $listText = ($listLines -join "`n") + $missingFromList = @($moduleIds | Where-Object { $listText -notmatch [regex]::Escape($_) }) + if ($missingFromList.Count -gt 0) { + Write-Host "::warning::Unity $Version module listing did not contain every required CI module id ($($missingFromList -join ', ')). Proceeding; the install result below is classified against the on-disk module layout." + } + } else { + Write-CiNotice "Could not list installable modules for Unity $Version (best-effort); proceeding with required CI module ids: $($moduleIds -join ', ')." + } + + # Step 1: verify everything in the selected profile. If all present, nothing + # to do. + $missing = @(Get-MissingUnityCiModuleGroups -EditorPath $EditorPath -Profile $Profile) + if ($missing.Count -eq 0) { + Write-CiNotice "All required Unity CI module groups for provisioning profile '$Profile' already present on disk for Unity $Version; nothing to install." + return $EditorPath + } + + # Step 1b (ROOT-CAUSE SIDESTEP): modules ARE missing. Before driving the + # `install-modules` path (which exits 6 -> uninstall -> quarantine -> can be + # blocked by a locker, the observed 6000.3.16f1 standalone failure), PROACTIVELY + # ask whether this editor is even module-manageable. If the CLI reports it is not + # (it was not installed via Hub/CLI registration), `install-modules` cannot help; + # route straight to the atomic `install -m` reinstallation, which adds the modules + # IN PLACE (no quarantine, so the locker is never contended) and only falls back + # to quarantine+reinstall if the in-place install genuinely fails. + # + # Gated to the cases where the atomic reinstall can actually run: a managed + # install root is available and editor repair is not disabled. An operator can + # force the legacy install-modules-first path via + # UH_UNITY_DISABLE_PROACTIVE_MODULE_REINSTALL=1 (defense for an unforeseen + # regression on a future CLI build). The disk-authoritative install-modules flow + # below remains the path for a module-MANAGEABLE editor that is merely missing a + # module (the common, healthy case). + $proactiveDisabled = $env:UH_UNITY_DISABLE_PROACTIVE_MODULE_REINSTALL -eq '1' + $repairDisabledForProactive = $env:UH_UNITY_DISABLE_EDITOR_REPAIR -eq '1' + if (-not $proactiveDisabled -and -not $repairDisabledForProactive -and $InstallRoot) { + $manageability = Test-UnityEditorModuleManageable -Version $Version + if (-not $manageability.Manageable) { + Write-Host "::warning::Unity $Version editor is not module-manageable ($($manageability.Reason)). Missing module groups: $($missing -join ', '). Skipping the install-modules -> exit 6 -> quarantine path and reinstalling atomically with the required modules instead." + return Install-UnityEditorModulesViaAtomicReinstall -Version $Version -EditorPath $EditorPath -InstallRoot $InstallRoot -Reason "editor not module-manageable; missing groups: $($missing -join ', ')" -Profile $Profile -ManagedOnly:$ManagedOnly + } + } + + # Step 2: determine whether any CORE-tier group is missing so it can be handled + # with the heavy reinstall/repair strategy. The android-tier partition is + # deliberately NOT computed here: Step 4 re-derives it from disk AFTER the core + # repair (which can reinstall the whole editor), so a Step-2 snapshot would be + # stale -- disk is the source of truth. + $missingCore = @($missing | Where-Object { (Get-UnityCiModuleTier $_) -eq 'core' }) + + # Step 3: install the CORE tier if any core group is missing. This runs the + # existing heavy/classify-then-decide path, but SCOPED to the core tier ids. + if ($missingCore.Count -gt 0) { + # Single source of truth for the (EULA-bearing) `install-modules` arg vector, + # scoped to the CORE tier. Captured ONCE and reused for the install call and + # the failure-annotation arg echo. + $installArgs = @(Get-UnityCliModuleInstallArguments -Verb 'install-modules' -Version $Version -ModuleIds (Get-UnityCiModuleIdsForTier -Tier 'core' -Profile $Profile)) + + # Attempt the install via the capturing (non-throwing) path so we can inspect + # BOTH the exit code AND the output text before deciding whether it was fatal. + $result = Invoke-UnityCliCapture -Arguments $installArgs + + # Re-verify the core tier on disk (disk is the source of truth; an exit 6 + # with everything present is the idempotent no-op). + $missingCoreAfter = @(Get-MissingUnityCiModuleGroups -EditorPath $EditorPath -Profile $Profile | Where-Object { (Get-UnityCiModuleTier $_) -eq 'core' }) + if ($missingCoreAfter.Count -gt 0) { + # Tail of the captured output for diagnostics (collapsed first so the + # Android NDK progress spam does not bury the tail). + $tail = Get-CollapsedCliOutputTail -Output $result.Output -MaxLines 20 + + # The install genuinely did not deliver the required core modules. Emit a + # targeted, high-signal annotation + the wrap-immune summary BEFORE we + # repair or throw, so the root cause is obvious in the CI log. + Write-UnityCliInstallFailureAnnotation -Version $Version -Output $result.Output -ExitCode $result.ExitCode -Arguments $installArgs + # Phrase from wrapper-driven kill state so a NATIVE 124/125 from the + # Unity CLI is not misread as a stall or wall-clock kill. Retryable + # classification (both sentinels) is unchanged via the throw flow. + $moduleAddStallKilled = [bool]$result.StallKilled + $moduleAddWallTimedOut = [bool]$result.TimedOutWallClock + $moduleAddTimedOut = ($moduleAddStallKilled -or $moduleAddWallTimedOut) + Write-ModuleInstallFailureDiagnostics -Version $Version -Output $result.Output -ExitCode $result.ExitCode -Arguments $installArgs -Root $InstallRoot -TimedOut:$moduleAddTimedOut -StallKilled:$moduleAddStallKilled -TimedOutWallClock:$moduleAddWallTimedOut -TimeoutSeconds (Get-EnsureEditorInstallTimeoutSeconds) -StallSeconds (Get-EnsureEditorProgressStallSeconds) + + $repairDisabled = $env:UH_UNITY_DISABLE_EDITOR_REPAIR -eq '1' + if ($repairDisabled) { + throw "Unity $Version is missing required CORE CI module groups ($($missingCoreAfter -join ', ')), and UH_UNITY_DISABLE_EDITOR_REPAIR=1 disabled auto-repair. CLI output tail:`n$tail" + } + + if ($InstallRoot) { + # Quarantine + reinstall with the selected provisioning profile. + $EditorPath = Repair-UnityEditorWithCiModules -Version $Version -EditorPath $EditorPath -InstallRoot $InstallRoot -Reason "required CORE CI module groups missing ($($missingCoreAfter -join ', ')). CLI output tail:`n$tail" -Profile $Profile -ManagedOnly:$ManagedOnly + } else { + throw "Unity $Version 'install-modules' failed with exit code $($result.ExitCode), and required CORE CI module groups are missing on disk ($($missingCoreAfter -join ', ')). CLI output tail:`n$tail" + } + } else { + Write-CiNotice "Core Unity CI module tier present on disk for Unity $Version (CLI exit code $($result.ExitCode))." + } + } + + # Step 4: re-verify and, if any ANDROID-tier group is still missing, install it + # via the dedicated, bounded Android step. That step can escalate to managed + # quarantine/reinstall only after its editor-preserving attempts are exhausted. + $missingAndroid = @(Get-MissingUnityCiModuleGroups -EditorPath $EditorPath -Profile $Profile | Where-Object { (Get-UnityCiModuleTier $_) -eq 'android' }) + if ($missingAndroid.Count -gt 0) { + return Install-UnityAndroidModules -Version $Version -EditorPath $EditorPath -InstallRoot $InstallRoot -Profile $Profile -ManagedOnly:$ManagedOnly + } + + # Step 5: final verification across all tiers. + $finalMissing = @(Get-MissingUnityCiModuleGroups -EditorPath $EditorPath -Profile $Profile) + if ($finalMissing.Count -gt 0) { + Write-UnityModuleInstallPostMortem -Version $Version -EditorPath $EditorPath -Root $InstallRoot -Profile $Profile + throw "Unity $Version CI module install completed, but required module groups for provisioning profile '$Profile' are still missing on disk: $($finalMissing -join ', ') (see the post-mortem above)." + } + + return $EditorPath +} + +function Add-WindowsIl2CppModule { + param( + [Parameter(Mandatory = $true)][string]$Version, + [Parameter(Mandatory = $true)][string]$EditorPath, + [string]$InstallRoot, + [string]$Profile = $(Get-UnityProvisioningProfile), + [switch]$ManagedOnly + ) + + return Ensure-UnityCiModules -Version $Version -EditorPath $EditorPath -InstallRoot $InstallRoot -Profile $Profile -ManagedOnly:$ManagedOnly +} + +function Write-InstalledEditorDiagnostics { + param( + [Parameter(Mandatory = $true)][string]$Version, + [Parameter(Mandatory = $true)][string]$Root, + [Parameter(Mandatory = $true)][string]$Reason + ) + + Write-Host "::group::Unity editor resolution diagnostics" + Write-Host "Reason: $Reason" + Write-Host "Requested Unity version: $Version" + Write-Host "Configured install root: $Root" + try { + $cliRoot = Get-UnityCliInstallRoot + if ($cliRoot) { + Write-Host "Unity CLI reported install root: $cliRoot" + } else { + Write-Host "Unity CLI reported install root: (unavailable)" + } + } catch { + Write-Host "::notice::Could not query Unity CLI install root: $($_.Exception.Message)" + } + + try { + Write-Host "Known Unity.exe candidate paths:" + foreach ($candidate in Get-UnityEditorCandidates -Version $Version -Root $Root) { + $exists = Test-Path -LiteralPath $candidate -PathType Leaf + Write-Host " [$exists] $candidate" + } + } catch { + Write-Host "::notice::Could not enumerate Unity.exe candidates: $($_.Exception.Message)" + } + + try { + Write-Host "Installed Unity editors reported by CLI:" + Invoke-UnityCliSafe -Arguments @('editors', '-i') | Out-Null + } catch { + Write-Host "::notice::Could not query installed Unity editors: $($_.Exception.Message)" + } + Write-Host "::endgroup::" +} + +function Write-InstallDiagnostics { + # Pre-install diagnostic dump so the NEXT base-install failure is debuggable. + # The observed failure (6000.0.32f1, ~34 minutes, exit 6, almost no output) + # gave us nothing to act on. This emits, inside a collapsible ::group::: + # * the resolved CLI path actually being invoked, + # * the CLI version (best-effort), and + # * free disk space on the install drive (a likely culprit for a slow/failed + # multi-GB editor download). + # Every probe is wrapped in try/catch and StrictMode-safe: a diagnostic must + # NEVER abort the bootstrap it is meant to help debug. + param([Parameter(Mandatory = $true)][string]$Root) + + Write-Host "::group::Unity CLI install diagnostics" + try { + Write-Host "Resolved Unity CLI path: $script:UnityCliPath" + } catch { + Write-Host "::notice::Could not report the resolved Unity CLI path: $($_.Exception.Message)" + } + + try { + Write-Host "Unity CLI version: $(Get-UnityCliVersionText)" + } catch { + Write-Host "::notice::Could not query the Unity CLI version: $($_.Exception.Message)" + } + + # Free space on the drive of the install root. A multi-GB editor download that + # runs out of disk would explain a long, output-starved failure. Reuses the + # shared probe so the pre-install dump and the on-failure summary agree. + Write-Host (Get-InstallDriveFreeSpaceText -Root $Root) + Write-Host "::endgroup::" +} + +function Get-ProvisioningDiagnosticsPath { + param( + [Parameter(Mandatory = $true)][string]$Root, + [Parameter(Mandatory = $true)][string]$Version, + [string]$Path + ) + + if ($Path -and $Path.Trim().Length -gt 0) { + if ((Test-Path -LiteralPath $Path -PathType Container) -or [string]::IsNullOrEmpty([System.IO.Path]::GetExtension($Path))) { + return (Join-Path $Path 'ensure-editor-summary.json') + } + return $Path + } + return (Join-Path (Join-Path $Root '_diagnostics') "$Version-provisioning-summary.json") +} + +function Write-UnityProvisioningSummary { + param( + [Parameter(Mandatory = $true)][string]$Root, + [Parameter(Mandatory = $true)][string]$Version, + [string]$Path, + [string]$EditorPath + ) + + $jsonPath = Get-ProvisioningDiagnosticsPath -Root $Root -Version $Version -Path $Path + $textPath = [System.IO.Path]::ChangeExtension($jsonPath, '.txt') + try { + $dir = Split-Path -Parent $jsonPath + if ($dir -and -not (Test-Path -LiteralPath $dir -PathType Container)) { + New-Item -ItemType Directory -Force -Path $dir | Out-Null + } + + # Local copy of the current provisioning profile. Named + # $provisioningProfile (NOT the auto-variable $PROFILE, which holds + # the PowerShell startup-script path) so this helper does not + # shadow that built-in even though PowerShell variable names are + # case-insensitive. + $provisioningProfile = Get-UnityProvisioningProfile + $modulePresence = [ordered]@{} + foreach ($group in @(Get-UnityCiModuleSpec | Where-Object { $_.Verified } | ForEach-Object { $_.Id })) { + $present = $false + if ($EditorPath -and (Test-Path -LiteralPath $EditorPath -PathType Leaf)) { + $present = Test-UnityCiModuleGroupPresent -EditorPath $EditorPath -Group $group + } + $modulePresence[$group] = $present + } + $requiredModulePresence = [ordered]@{} + foreach ($group in @(Get-UnityCiVerifiedModuleGroups -Profile $provisioningProfile)) { + $requiredModulePresence[$group] = $modulePresence[$group] + } + + $commandClasses = @($script:ProvisioningCommandClasses | Sort-Object) + $summary = [ordered]@{ + generatedUtc = [DateTime]::UtcNow.ToString('o') + unityVersion = $Version + provisioningProfile = $provisioningProfile + cliPath = $script:UnityCliPath + cliVersion = $(if ($script:UnityCliVersionText) { $script:UnityCliVersionText } else { '(not queried)' }) + installRoot = $Root + editorPath = $EditorPath + ciManagedOnly = [bool]$CiManagedOnly + attemptedCommandClasses = $commandClasses + desiredModules = @(Get-UnityCiModuleIds -Profile $provisioningProfile) + verifiedModules = @(Get-UnityCiVerifiedModuleGroups -Profile $provisioningProfile) + skippedModuleGroups = @(Get-UnityCiSkippedModuleGroups -Profile $provisioningProfile) + modulePresence = $modulePresence + requiredModulePresence = $requiredModulePresence + provisioningBudgetSeconds = $script:ProvisioningBudgetSeconds + remainingBudgetSeconds = Get-RemainingUnityProvisioningBudgetSeconds + timeoutEvents = @($script:ProvisioningTimeoutEvents.ToArray()) + processCleanupEvents = @($script:ProvisioningProcessCleanupEvents.ToArray()) + finalClassification = $script:ProvisioningFinalClassification + } + + $summary | ConvertTo-Json -Depth 8 -Compress | Set-Content -LiteralPath $jsonPath -Encoding UTF8 + $moduleText = ($modulePresence.GetEnumerator() | ForEach-Object { "$($_.Key)=$($_.Value)" }) -join ', ' + $textLines = @( + "Unity provisioning summary", + "classification=$script:ProvisioningFinalClassification", + "provisioningProfile=$provisioningProfile", + "unityVersion=$Version", + "cliPath=$script:UnityCliPath", + "cliVersion=$($summary.cliVersion)", + "installRoot=$Root", + "editorPath=$EditorPath", + "attemptedCommandClasses=$($commandClasses -join ',')", + "desiredModules=$($summary.desiredModules -join ',')", + "verifiedModules=$($summary.verifiedModules -join ',')", + "skippedModuleGroups=$($summary.skippedModuleGroups -join ',')", + "modulePresence=$moduleText", + "timeoutEvents=$($script:ProvisioningTimeoutEvents.Count)", + "processCleanupEvents=$($script:ProvisioningProcessCleanupEvents.Count)" + ) + $textLines | Set-Content -LiteralPath $textPath -Encoding UTF8 + } catch { + Write-Host "::warning::Failed to write Unity provisioning diagnostics summary: $($_.Exception.Message)" + } +} + +Initialize-UnityProvisioningBudget +Write-CiNotice "Unity editor provisioning profile: $ProvisioningProfile." + +try { +New-Item -ItemType Directory -Force -Path $InstallRoot | Out-Null + +$editor = Find-UnityEditor -Version $UnityVersion -Root $InstallRoot -IncludeHostInstalls:(-not $CiManagedOnly) +if ($RequireHealthyExisting) { + if (-not $editor) { + Write-InstalledEditorDiagnostics -Version $UnityVersion -Root $InstallRoot -Reason "RequireHealthyExisting was set and no existing Unity editor was found." + throw "Unity $UnityVersion is not installed under '$InstallRoot' and RequireHealthyExisting is set. CI test jobs do not install or repair Unity in-job; run scripts/unity/maintain-windows-runner.ps1 or dispatch .github/workflows/runner-bootstrap.yml to provision the editor and required modules first." + } + + $script:ProvisioningEditorPath = $editor + $missingModules = @(Get-MissingUnityCiModuleGroups -EditorPath $editor -Profile $ProvisioningProfile) + if ($missingModules.Count -gt 0) { + Write-InstalledEditorDiagnostics -Version $UnityVersion -Root $InstallRoot -Reason "RequireHealthyExisting was set and required module groups are missing: $($missingModules -join ', ')." + throw "Unity $UnityVersion is missing required CI module groups for provisioning profile '$ProvisioningProfile': $($missingModules -join ', '). CI test jobs fail fast instead of installing modules in-job; run scripts/unity/maintain-windows-runner.ps1 or dispatch .github/workflows/runner-bootstrap.yml to repair this editor." + } + + if ($env:UH_UNITY_SKIP_NATIVE_STARTUP_PROBE -eq '1') { + Write-CiNotice "Skipping Unity $UnityVersion native startup probe (UH_UNITY_SKIP_NATIVE_STARTUP_PROBE=1)." + } else { + $probeRoot = Join-Path $InstallRoot '_probes' + $probeLog = Join-Path $probeRoot "$UnityVersion-startup-probe.log" + $startupResult = Test-UnityNativeStartup -EditorPath $editor -LogPath $probeLog + if (-not $startupResult.Success) { + if (Test-IsNativeDllNotFound -ExitCode $startupResult.ExitCode) { + Write-UnityHostPrereqAnnotation -Version $UnityVersion -ExitCode $startupResult.ExitCode -Description $startupResult.Description -EditorPath $editor -ProbeLog $probeLog + } + throw "Unity $UnityVersion native startup probe failed with exit code $($startupResult.ExitCode) ($($startupResult.Description)) and RequireHealthyExisting is set. CI test jobs do not repair Unity in-job. Run scripts/unity/maintain-windows-runner.ps1 or dispatch .github/workflows/runner-bootstrap.yml. Probe log: $probeLog" + } + } + $script:ProvisioningEditorPath = $editor + $script:ProvisioningFinalClassification = 'success' + Write-CiNotice "Unity editor resolved by healthy-existing preflight: $editor" + Write-Output $editor + return +} +if (-not $editor) { + Ensure-UnityCli | Out-Null + Set-UnityCliInstallPath -Root $InstallRoot + if ($CiManagedOnly) { + Confirm-UnityCliManagedInstallRoot -Root $InstallRoot | Out-Null + } + + Write-CiNotice "Installing Unity Editor $UnityVersion on the self-hosted Windows runner." + # Single source of truth for the (EULA-bearing) module-install arg vector. + # Fresh installs request the selected profile's desired modules atomically so + # Android dependencies are resolved with the editor install when Android is in + # scope, instead of through an amplified outer retry. + $installArgs = @(Get-UnityCliModuleInstallArguments -Verb 'install' -Version $UnityVersion -ModuleIds (Get-UnityCiModuleIds -Profile $ProvisioningProfile)) + + # Emit diagnostics BEFORE the (potentially 30+ minute) install so the logs + # carry the CLI path/version + disk headroom even if the install then stalls. + Write-InstallDiagnostics -Root $InstallRoot + + # The base install has been observed to fail flakily (exit 6 after a long run + # with almost no output) AND to HANG until the job is cancelled. Each attempt + # is now bounded by the install timeout (Invoke-UnityCliCapture delegates to + # the timeout runner), so a hang is killed and classified as a retryable + # failure that Invoke-WithRetry can re-attempt. The attempt count is sourced + # from the override-aware helper (default 2 -- two attempts fit inside the + # 180-minute Provision-Unity-Editor step budget even for a slow install), and + # the CAPTURING invoker makes a final failure THROW with the CLI output tail + + # exit code. Output is streamed live, per line, by Invoke-UnityCliCapture (each + # line is echoed the instant it arrives), never silently buffered. + $retryDelaySeconds = Get-EnsureEditorRetryDelaySeconds + $installRetryAttempts = Get-EnsureEditorInstallRetryAttempts + + $recoveredEditor = Invoke-WithUnityInstallLock -Version $UnityVersion -InstallRoot $InstallRoot -Action { + Invoke-WithRetry -MaxAttempts $installRetryAttempts -DelaySeconds $retryDelaySeconds -Action { + $installResult = Invoke-UnityCliCapture -Arguments $installArgs + if (-not $installResult.Success) { + $installLines = @($installResult.Output) + $installText = ($installLines -join "`n") + # Collapse consecutive identical lines (the Android NDK install can + # spam thousands of identical progress lines) so the tail is READABLE. + $installTail = Get-CollapsedCliOutputTail -Output $installResult.Output -MaxLines 40 + + $resolvedAfterFailure = Resolve-InstalledEditor -Version $UnityVersion -Root $InstallRoot -ManagedOnly:$CiManagedOnly + if ($resolvedAfterFailure) { + Write-CiNotice "Unity CLI '$($installArgs -join ' ')' failed with exit code $($installResult.ExitCode), but Unity.exe is resolvable afterward; treating the install as already present." + Write-CiNotice "Verifying required CI modules after recovered editor install." + return $resolvedAfterFailure + } + + if ($installText -match '(?i)already installed|editor already installed|is already installed') { + Write-InstalledEditorDiagnostics -Version $UnityVersion -Root $InstallRoot -Reason "Unity CLI reported the editor is already installed, but Unity.exe could not be resolved afterward." + Invoke-UnityVersionUninstallForRepair -Version $UnityVersion -Reason "Unity CLI reported an already-installed editor, but Unity.exe could not be resolved." -InstallRoot $InstallRoot | Out-Null + # Stop stale provisioning processes ONLY when the wrapper + # actually killed the install (heartbeat-stall OR wall-clock + # deadline). A NATIVE 124/125 from the Unity CLI is a clean + # exit and leaves no stale tree to clean up; running the + # stale-process sweep on a native exit would be wasted work + # at best and a footgun against an unrelated Unity instance + # at worst. + if ($installResult.StallKilled -or $installResult.TimedOutWallClock) { + Stop-StaleUnityProvisioningProcesses -InstallRoot $InstallRoot -Version $UnityVersion -Reason "timed out already-installed install before quarantine" + } + Move-UnityVersionInstallToQuarantine -Version $UnityVersion -InstallRoot $InstallRoot + throw "Unity CLI '$($installArgs -join ' ')' reported an already-installed editor with exit code $($installResult.ExitCode), but Unity.exe could not be found. Uninstalled any CLI metadata and quarantined the managed version directory as partial or corrupt before retry. CLI output tail:`n$installTail" + } + + Write-UnityCliInstallFailureAnnotation -Version $UnityVersion -Output $installResult.Output -ExitCode $installResult.ExitCode -Arguments $installArgs + # Phrase from wrapper-driven kill state so a NATIVE 124/125 from + # the Unity CLI is not misread as a stall or wall-clock kill. + # The retry classifier is unchanged -- both sentinels remain + # retryable via the existing throw flow. + $baseInstallStallKilled = [bool]$installResult.StallKilled + $baseInstallWallTimedOut = [bool]$installResult.TimedOutWallClock + $baseInstallTimedOut = ($baseInstallStallKilled -or $baseInstallWallTimedOut) + Write-ModuleInstallFailureDiagnostics -Version $UnityVersion -Output $installResult.Output -ExitCode $installResult.ExitCode -Arguments $installArgs -Root $InstallRoot -TimedOut:$baseInstallTimedOut -StallKilled:$baseInstallStallKilled -TimedOutWallClock:$baseInstallWallTimedOut -TimeoutSeconds (Get-EnsureEditorInstallTimeoutSeconds) -StallSeconds (Get-EnsureEditorProgressStallSeconds) + Write-InstalledEditorDiagnostics -Version $UnityVersion -Root $InstallRoot -Reason "Unity CLI install failed and Unity.exe could not be resolved afterward." + throw "Unity CLI '$($installArgs -join ' ')' failed with exit code $($installResult.ExitCode). CLI output tail:`n$installTail" + } + + return $null + } + } + + if ($recoveredEditor) { + $editor = $recoveredEditor + } else { + $editor = Resolve-InstalledEditor -Version $UnityVersion -Root $InstallRoot -ManagedOnly:$CiManagedOnly + } + if ($editor) { + $script:ProvisioningEditorPath = $editor + } + if (-not $editor) { + Write-InstalledEditorDiagnostics -Version $UnityVersion -Root $InstallRoot -Reason "Unity CLI install completed, but Unity.exe could not be resolved afterward." + Move-UnityVersionInstallToQuarantine -Version $UnityVersion -InstallRoot $InstallRoot + $editor = Install-UnityEditorWithCiModules -Version $UnityVersion -InstallRoot $InstallRoot -Reason "Unity CLI install completed, but Unity.exe could not be resolved afterward; quarantined the managed version directory and retrying with a fresh install." -Profile $ProvisioningProfile -ManagedOnly:$CiManagedOnly + $script:ProvisioningEditorPath = $editor + } + $editor = Ensure-UnityCiModules -Version $UnityVersion -EditorPath $editor -InstallRoot $InstallRoot -Profile $ProvisioningProfile -ManagedOnly:$CiManagedOnly + $script:ProvisioningEditorPath = $editor +} else { + Ensure-UnityCli | Out-Null + Set-UnityCliInstallPath -Root $InstallRoot + if ($CiManagedOnly) { + Confirm-UnityCliManagedInstallRoot -Root $InstallRoot | Out-Null + } + Write-CiNotice "Ensuring required CI modules are installed for Unity $UnityVersion." + $script:ProvisioningEditorPath = $editor + $editor = Ensure-UnityCiModules -Version $UnityVersion -EditorPath $editor -InstallRoot $InstallRoot -Profile $ProvisioningProfile -ManagedOnly:$CiManagedOnly + $script:ProvisioningEditorPath = $editor +} + +$editor = Ensure-UnityNativeStartupHealthy -Version $UnityVersion -EditorPath $editor -InstallRoot $InstallRoot -Profile $ProvisioningProfile -ManagedOnly:$CiManagedOnly +$script:ProvisioningEditorPath = $editor +$script:ProvisioningFinalClassification = 'success' +Write-CiNotice "Unity editor resolved: $editor" +Write-Output $editor +} catch { + try { + $editorVar = Get-Variable -Name editor -Scope Local -ErrorAction SilentlyContinue + if ($editorVar -and $editorVar.Value) { + $script:ProvisioningEditorPath = [string]$editorVar.Value + } + } catch { } + $script:ProvisioningFinalClassification = "failed: $($_.Exception.Message)" + throw +} finally { + Write-UnityProvisioningSummary -Root $InstallRoot -Version $UnityVersion -Path $DiagnosticsPath -EditorPath $script:ProvisioningEditorPath +} diff --git a/scripts/unity/ensure-editor.ps1.meta b/scripts/unity/ensure-editor.ps1.meta new file mode 100644 index 000000000..e1a620f79 --- /dev/null +++ b/scripts/unity/ensure-editor.ps1.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: c688ef8246ef688dd7bc14914fa52505 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/scripts/unity/lib.meta b/scripts/unity/lib.meta new file mode 100644 index 000000000..7c7232d80 --- /dev/null +++ b/scripts/unity/lib.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 1699abdf061130d488f69b7121cc8c33 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/scripts/unity/lib/asmdef-discovery.js b/scripts/unity/lib/asmdef-discovery.js new file mode 100644 index 000000000..9ecc78afc --- /dev/null +++ b/scripts/unity/lib/asmdef-discovery.js @@ -0,0 +1,420 @@ +"use strict"; + +// cspell:ignore OSX iff Integ cls + +/** + * @file asmdef-discovery.js + * + * Shared, deterministic discovery + classification of Unity test asmdef files. + * + * Used by: + * - .github/actions/compute-unity-assemblies (primary CI consumer) + * + * No filesystem mutation. Pure functions only. + * + * Exports: + * - defaultIncludeAssemblies(repoRoot, options?) + * + * The enumeration/classification helpers are module-internal; run this file + * directly (`node scripts/unity/lib/asmdef-discovery.js`) for a self-test + * that prints every discovered asmdef with its classification. + * + * Default include/exclude rules: + * - "core" => INCLUDED by default. + * - "perf" => EXCLUDED by default. Opt in with { includePerf: true }. + * - "integration" => EXCLUDED by default (their DI-container packages are not + * in the test project's manifest.json and would fail to + * compile). Opt in with { includeIntegrations: true }. + */ + +const fs = require("fs"); +const path = require("path"); + +/** + * Recursively enumerate files under `dir`, returning the absolute paths whose + * dirent satisfies `match(fullPath, dirent)`. Pure read-only walk; missing or + * unreadable directories yield no entries (never throws on ENOENT). Inlined + * here so this module has no dependency outside scripts/unity/. + * + * @param {string} dir - Absolute directory to walk + * @param {{ match: (full: string, dirent: import('fs').Dirent) => boolean }} options + * @returns {string[]} Absolute paths of matching files + */ +function walkFiles(dir, options) { + const match = options && typeof options.match === "function" ? options.match : () => true; + const results = []; + + /** @param {string} current */ + function recurse(current) { + let entries; + try { + entries = fs.readdirSync(current, { withFileTypes: true }); + } catch { + // Missing/unreadable directory: contribute nothing rather than throw. + return; + } + for (const dirent of entries) { + const full = path.join(current, dirent.name); + if (dirent.isDirectory()) { + recurse(full); + } else if (dirent.isFile() && match(full, dirent)) { + results.push(full); + } + } + } + + recurse(dir); + return results; +} + +/** + * Names matching this pattern are perf/benchmark assemblies and must be + * excluded from default Unity Test Runner runs. unity-helpers' perf suite is + * the `*.Tests.Runtime.Performance` assembly; the run-step category filter + * additionally drops the [Performance]/[Stress] NUnit categories. + * + * @type {RegExp} + */ +const PERF_NAME_REGEX = /(?:Performance)/; + +/** + * Names matching this pattern are DI-container integration suites + * (VContainer / Zenject / Reflex). EXCLUDED from the default suite because + * their backing packages (com.gustavopsantos.reflex, com.svermeulen.extenject, + * jp.hadashikick.vcontainer) are not declared in the test project's + * manifest.json -- including them would cause compile errors. Opt in via the + * `includeIntegrations` option on `defaultIncludeAssemblies`. + * + * @type {RegExp} + */ +const INTEGRATION_NAME_REGEX = /(?:VContainer|Zenject|Reflex)/; + +/** + * Assembly-name prefix that marks an asmdef as owned by unity-helpers. The + * Unity Test Runner is invoked with an explicit `-assemblyNames` list, so a + * foreign test asmdef that happens to live under `Tests/` (for example one + * pulled in by an external package, or a stray sample) must never be added to + * the list -- it would not compile against the harness manifest and would fail + * the run for a reason unrelated to unity-helpers. Every real unity-helpers + * test assembly is named `WallstopStudios.UnityHelpers.Tests*`, so this owner + * prefix is a safe, future-proof gate that is a no-op for the current asmdef + * set. + * + * @type {string} + */ +const UNITY_HELPERS_ASSEMBLY_PREFIX = "WallstopStudios.UnityHelpers."; +const STANDALONE_PLATFORM_NAMES = new Set([ + "Standalone", + "WindowsStandalone32", + "WindowsStandalone64", + "LinuxStandalone64", + "OSXStandalone" +]); + +/** + * True when `name` is a unity-helpers-owned assembly (see + * {@link UNITY_HELPERS_ASSEMBLY_PREFIX}). Non-string / empty input is treated + * as NOT owned so a malformed asmdef can never slip through the include gate. + * + * @param {string} name - Asmdef assembly name (no extension) + * @returns {boolean} True iff the name carries the unity-helpers owner prefix + */ +function isUnityHelpersOwnedAssembly(name) { + return typeof name === "string" && name.startsWith(UNITY_HELPERS_ASSEMBLY_PREFIX); +} + +/** + * Strip the `.asmdef` extension and return the asmdef's declared name. The + * file's `name` field is the canonical assembly name and must match the + * filename per Unity convention; we read the JSON to be safe. + * + * @param {string} asmdefPath - Absolute path to an .asmdef file + * @returns {string} Asmdef name (without extension) + */ +function readAsmdefName(asmdefPath) { + const raw = fs.readFileSync(asmdefPath, "utf8"); + const parsed = JSON.parse(raw); + if (typeof parsed.name !== "string" || parsed.name.length === 0) { + // Fall back to the filename to keep this function pure-ish. + return path.basename(asmdefPath, ".asmdef"); + } + return parsed.name; +} + +/** + * Classify an asmdef name into a single category. + * + * Categories: + * - "perf" -- *.Tests.Runtime.Performance (excluded from PR gates). + * - "integration" -- VContainer/Zenject/Reflex DI integration suites. + * - "core" -- Everything else (Editor, Runtime, etc.). + * + * @param {string} name - Asmdef assembly name (no extension) + * @returns {"perf" | "integration" | "core"} Classification + */ +function classifyAsmdef(name) { + if (typeof name !== "string" || name.length === 0) { + return "core"; + } + + if (PERF_NAME_REGEX.test(name)) { + return "perf"; + } + + if (INTEGRATION_NAME_REGEX.test(name)) { + return "integration"; + } + + return "core"; +} + +/** + * @typedef {object} AsmdefEntry + * @property {string} name - Asmdef assembly name + * @property {string} path - Absolute path to the asmdef file + * @property {boolean} isPerf - True when classification is "perf" + * @property {boolean} isInteg - True when classification is "integration" + * @property {boolean} isEditorOnly - True iff includePlatforms is exactly ["Editor"] + * @property {boolean} isForeign - True when the assembly is NOT unity-helpers-owned + * (name lacks the `WallstopStudios.UnityHelpers.` prefix). Such + * assemblies are never added to the Unity `-assemblyNames` list. + */ + +/** + * Read an asmdef's `includePlatforms` array and decide whether the assembly is + * editor-only. An assembly is editor-only iff `includePlatforms` is exactly + * `["Editor"]`. Editor-only test assemblies (EditMode suites + Editor + * integrations) cannot run inside a built player, so the standalone + * runtime-only flow must exclude them. + * + * @param {string} asmdefPath - Absolute path to an .asmdef file + * @returns {{ includePlatforms: string[], excludePlatforms: string[] }} + */ +function readAsmdefPlatforms(asmdefPath) { + const raw = fs.readFileSync(asmdefPath, "utf8"); + const parsed = JSON.parse(raw); + return { + includePlatforms: Array.isArray(parsed.includePlatforms) ? parsed.includePlatforms : [], + excludePlatforms: Array.isArray(parsed.excludePlatforms) ? parsed.excludePlatforms : [] + }; +} + +/** + * @param {string[]} includePlatforms + * @param {string[]} excludePlatforms + * @param {"editmode" | "playmode" | "standalone"} target + * @returns {boolean} + */ +function isAsmdefCompatibleWithTarget(includePlatforms, excludePlatforms, target) { + const includes = new Set(includePlatforms); + const excludes = new Set(excludePlatforms); + + if (target === "standalone") { + if (excludes.has("Standalone") || excludes.has("WindowsStandalone64")) { + return false; + } + if (includes.size === 0) { + return true; + } + for (const platform of includes) { + if (STANDALONE_PLATFORM_NAMES.has(platform)) { + return true; + } + } + return false; + } + + if (target === "editmode") { + if (excludes.has("Editor")) { + return false; + } + return includes.size === 0 || includes.has("Editor"); + } + + if (target === "playmode") { + if (excludes.has("Editor")) { + return false; + } + return includes.size === 0; + } + + if (excludes.has("Editor")) { + return false; + } + return includes.size === 0 || includes.has("Editor"); +} + +/** + * Enumerate every asmdef under `/Tests/`. Sorted by `name` for + * stable downstream output (CI summaries, contract tests). + * + * @param {string} repoRoot - Absolute path to the repository root + * @returns {AsmdefEntry[]} Discovered test asmdefs + */ +function enumerateTestAsmdefs(repoRoot) { + if (typeof repoRoot !== "string" || repoRoot.length === 0) { + throw new TypeError("enumerateTestAsmdefs: repoRoot must be a non-empty string"); + } + + const testsDir = path.join(repoRoot, "Tests"); + const asmdefPaths = walkFiles(testsDir, { + match: (full, dirent) => dirent.name.endsWith(".asmdef") + }); + + /** @type {AsmdefEntry[]} */ + const entries = asmdefPaths.map((asmdefPath) => { + const name = readAsmdefName(asmdefPath); + const classification = classifyAsmdef(name); + const platforms = readAsmdefPlatforms(asmdefPath); + return { + name, + path: asmdefPath, + isPerf: classification === "perf", + isInteg: classification === "integration", + includePlatforms: platforms.includePlatforms, + excludePlatforms: platforms.excludePlatforms, + isEditorOnly: + platforms.includePlatforms.length === 1 && platforms.includePlatforms[0] === "Editor", + isForeign: !isUnityHelpersOwnedAssembly(name) + }; + }); + + entries.sort((a, b) => a.name.localeCompare(b.name)); + return entries; +} + +/** + * @typedef {object} IncludeOptions + * @property {boolean} [includePerf=false] Include "perf" asmdefs. + * @property {boolean} [includeIntegrations=false] Include "integration" asmdefs. + * @property {"editmode" | "playmode" | "standalone"} [target=editmode] + * Select assemblies compatible with the Unity test target. + * PlayMode and standalone omit editor-only asmdefs. + * @property {boolean} [runtimeOnly=false] Back-compat alias for + * target: "standalone". Applied before the perf/integration + * gating so it composes. + */ + +/** + * Names of test asmdefs included in the default Unity Test Runner suite. + * + * By default ONLY "core" asmdefs are returned. Perf and integration suites + * are opt-in: + * - includePerf: add *.Tests.Runtime.Performance. + * - includeIntegrations: add VContainer/Zenject/Reflex (caller must ensure + * the corresponding DI packages are in manifest.json). + * + * @param {string} repoRoot - Absolute path to the repository root + * @param {IncludeOptions} [options] - Opt-in flags (default: all false) + * @returns {string[]} Sorted asmdef names (no extension) + */ +function defaultIncludeAssemblies(repoRoot, options) { + const opts = options || {}; + const includePerf = opts.includePerf === true; + const includeIntegrations = opts.includeIntegrations === true; + const target = opts.target || (opts.runtimeOnly === true ? "standalone" : "editmode"); + + return enumerateTestAsmdefs(repoRoot) + .filter((entry) => { + // Foreign (non-unity-helpers-owned) asmdefs are never added to the Unity + // -assemblyNames list: they would not compile against the harness + // manifest and would fail the run for a reason unrelated to unity-helpers. + // Gated first, ahead of every other decision. A no-op for the current + // asmdef set (all entries are unity-helpers-owned). + if (entry.isForeign) { + return false; + } + if (!isAsmdefCompatibleWithTarget(entry.includePlatforms, entry.excludePlatforms, target)) { + return false; + } + if (entry.isPerf) { + return includePerf; + } + if (entry.isInteg) { + return includeIntegrations; + } + return true; + }) + .map((entry) => entry.name); +} + +/** + * Names of test asmdefs excluded from the default Unity Test Runner suite. + * Mirror of `defaultIncludeAssemblies` -- anything not selected by the include + * options is returned here. With no options, returns all perf + integration + * asmdefs. + * + * @param {string} repoRoot - Absolute path to the repository root + * @param {IncludeOptions} [options] - Opt-in flags (default: all false) + * @returns {string[]} Sorted asmdef names (no extension) + */ +function defaultExcludeAssemblies(repoRoot, options) { + const opts = options || {}; + const includePerf = opts.includePerf === true; + const includeIntegrations = opts.includeIntegrations === true; + const target = opts.target || (opts.runtimeOnly === true ? "standalone" : "editmode"); + + return enumerateTestAsmdefs(repoRoot) + .filter((entry) => { + // Mirror of defaultIncludeAssemblies. Foreign (non-unity-helpers-owned) + // asmdefs are never included, so they are always "excluded" here too. + if (entry.isForeign) { + return true; + } + if (!isAsmdefCompatibleWithTarget(entry.includePlatforms, entry.excludePlatforms, target)) { + return true; + } + if (entry.isPerf) { + return !includePerf; + } + if (entry.isInteg) { + return !includeIntegrations; + } + return false; + }) + .map((entry) => entry.name); +} + +// Only defaultIncludeAssemblies has external consumers +// (compute-unity-assemblies/action.yml). The other helpers are internal; the +// self-test block below uses them directly. +module.exports = { + defaultIncludeAssemblies +}; + +if (require.main === module) { + // Self-test mode: print classified asmdefs for the current repo. This file + // lives at scripts/unity/lib/, so the repo root is three levels up. + const repoRoot = path.resolve(__dirname, "..", "..", ".."); + const all = enumerateTestAsmdefs(repoRoot); + const include = defaultIncludeAssemblies(repoRoot); + const exclude = defaultExcludeAssemblies(repoRoot); + + process.stdout.write(`repoRoot: ${repoRoot}\n`); + process.stdout.write(`discovered ${all.length} asmdef(s):\n`); + for (const entry of all) { + const cls = entry.isPerf ? "perf" : entry.isInteg ? "integration" : "core"; + process.stdout.write(` [${cls}] ${entry.name}\n`); + } + process.stdout.write( + `\ndefault include (${include.length}, core only -- pass ` + + `{ includePerf, includeIntegrations } to opt in):\n` + ); + for (const name of include) { + process.stdout.write(` + ${name}\n`); + } + process.stdout.write(`\ndefault exclude (${exclude.length}, perf + integration suites):\n`); + for (const name of exclude) { + process.stdout.write(` - ${name}\n`); + } + + // Diagnostic: runtime-only include list (used by the standalone player flow, + // where EditMode/editor-only asmdefs cannot run). + const runtimeInclude = defaultIncludeAssemblies(repoRoot, { target: "standalone" }); + process.stdout.write( + `\nruntime-only include (${runtimeInclude.length}, drops editor-only asmdefs):\n` + ); + for (const name of runtimeInclude) { + process.stdout.write(` * ${name}\n`); + } +} diff --git a/scripts/unity/lib/asmdef-discovery.js.meta b/scripts/unity/lib/asmdef-discovery.js.meta new file mode 100644 index 000000000..0177aca04 --- /dev/null +++ b/scripts/unity/lib/asmdef-discovery.js.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: acdd70fb658f9b69df35960b1864288d +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/scripts/unity/run-ci-tests.ps1 b/scripts/unity/run-ci-tests.ps1 new file mode 100644 index 000000000..754dfa35d --- /dev/null +++ b/scripts/unity/run-ci-tests.ps1 @@ -0,0 +1,2783 @@ +#Requires -Version 5.1 +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [ValidatePattern('^\d+\.\d+\.\d+f\d+$')] + [string]$UnityVersion, + + [Parameter(Mandatory = $true)] + [ValidateSet('editmode', 'playmode', 'standalone')] + [string]$TestMode, + + [Parameter(Mandatory = $true)] + [string]$AssemblyNames, + + [Parameter(Mandatory = $true)] + [string]$ArtifactsPath, + + [string]$RepoRoot = $(if ($env:GITHUB_WORKSPACE) { $env:GITHUB_WORKSPACE } else { (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path }), + + [string]$ProjectPath, + + [string]$UnityEditorPath = $env:UNITY_EDITOR_PATH, + + [string]$UnityInstallRoot = $(if ($env:UNITY_EDITOR_INSTALL_ROOT) { $env:UNITY_EDITOR_INSTALL_ROOT } else { 'C:\Unity\Editors' }), + + [string]$TestCategory = $(if ($env:UH_UNITY_TEST_CATEGORY) { $env:UH_UNITY_TEST_CATEGORY } else { '' }), + + [switch]$IncludeComparisons, + + [switch]$ReleaseCodeOptimization, + + [ValidateSet('IL2CPP', 'Mono2x')] + [string]$StandaloneScriptingBackend = 'IL2CPP', + + [switch]$ReleasePlayerBuild, + + [switch]$GenerateOnly +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +# PowerShell 7.4 introduced $PSNativeCommandUseErrorActionPreference (stabilizing +# the native-error experimental feature). Its default is $false on current builds, +# so `& ` does NOT throw on a non-zero exit and our explicit checks run as +# written. However, a host profile or a future/different build could enable it, +# which would make `& ` THROW on a non-zero exit BEFORE our explicit +# `$LASTEXITCODE` check runs -- short-circuiting Invoke-UnityEditor's exit-code +# diagnostic and making the best-effort license return rely on its catch block +# instead of finishing. Pinning it $false makes LASTEXITCODE-based handling +# authoritative and identical across hosts/versions. (PS 5.1 lacks this variable; +# assigning it there is harmless, and the assignment is StrictMode-safe.) +$PSNativeCommandUseErrorActionPreference = $false + +$PackageName = 'com.wallstop-studios.unity-helpers' +# TODO(unity-helpers): test-framework version reconciliation. DxMessaging pinned +# com.unity.test-framework 1.4.5; unity-helpers' existing +# scripts/unity/create-test-project.sh pins 1.1.33. The harness here builds its +# OWN manifest (New-ManifestJson) independent of create-test-project.sh, so this +# value is the version Unity resolves for the ephemeral CI project. Kept at 1.4.5 +# (the harness-proven value) -- a maintainer should confirm whether unity-helpers +# requires 1.1.33 (to match create-test-project.sh) or 1.4.5 before the first +# self-hosted run. The performance package (3.4.2) is required by the +# *.Tests.Runtime.Performance assembly the unity-benchmarks workflow opts into. +$TestFrameworkVersion = '1.4.5' +$PerformanceFrameworkVersion = '3.4.2' +# TODO(unity-helpers): unity-helpers ships NO analyzers today (no Editor/Analyzers/ +# directory, no RoslynAnalyzer-labeled assets), so this required-DLL roster is +# EMPTY and the analyzer copy/assert/diagnostic functions are no-ops (see +# Copy-UnityHelpersAnalyzersToAssets). The .gitignore reserves Editor/Analyzers/ +# *.dll|*.pdb for a future analyzer; when one ships, add its RoslynAnalyzer-labeled +# DLL names here (and to $RoslynAnalyzerLabeledDllNames) and port DxMessaging's +# analyzer-copy bodies so the generator is registered at the first compile. +$RequiredUnityHelpersAnalyzerDllNames = @() + +function Write-CiError { + param([Parameter(Mandatory = $true)][string]$Message) + Write-Host "::error::$Message" +} + +function Write-CiNotice { + param([Parameter(Mandatory = $true)][string]$Message) + Write-Host "::notice::$Message" +} + +# SINGLE SOURCE OF TRUTH for the catastrophic-pattern list that both +# Write-UnityCatastrophicErrorAnnotations (new ::error:: annotation surface) +# AND Write-UnityResultFailureDiagnostics (older line-numbered selected-line +# printer) scan for. Each entry has: +# Label : human-readable label written into the GitHub group/error line +# Pattern : the Select-String pattern (regex when UseSimple=false, literal +# substring when UseSimple=true) +# UseSimple: whether to invoke Select-String -SimpleMatch (literal substring, +# cheaper) or as a regex +# Keeping this at $script: scope keeps the array deterministic and shared +# even when callers run from inside a try/finally or a child function. +# +# Patterns covered: +# - PrecompiledAssemblyException -- "Multiple precompiled assemblies with +# the same name" (the analyzer-DLL duplicate that motivated this +# diagnostic; the runtime auto-copy that caused it has been removed). +# - CompilationFailedException -- generic compile-failure path. +# - error CS\d+ -- compiler errors (CS0246, CS0103, CS0117, etc). +# - warning CS8032 -- "An instance of analyzer cannot be created" (analyzer +# failed to instantiate; same class of issue). +$script:CatastrophicPatterns = @( + @{ Label = 'PrecompiledAssemblyException'; Pattern = 'PrecompiledAssemblyException'; UseSimple = $true } + @{ Label = 'CompilationFailedException'; Pattern = 'CompilationFailedException'; UseSimple = $true } + @{ Label = 'Multiple precompiled assemblies with the same name'; Pattern = 'Multiple precompiled assemblies with the same name'; UseSimple = $true } + @{ Label = 'error CS\d+'; Pattern = 'error CS\d+'; UseSimple = $false } + @{ Label = 'warning CS8032'; Pattern = 'warning CS8032'; UseSimple = $false } +) + +# CLASS-OF-ISSUE DIAGNOSTIC: when Unity exits non-zero, the operator's next +# question is "WHY did Unity fail?". The most common silent-killer answers are +# catastrophic compile-time errors -- the editor exits before running tests at +# all, leaving no NUnit XML. Surface these patterns as `::error::` annotations +# directly from the runner script so they ALWAYS show up in both the runner log +# and GitHub's error summary, independent of whether the workflow-level verify +# step also runs. Reusable at top-level so additional call sites can adopt it. +# Patterns come from the single-source-of-truth $script:CatastrophicPatterns +# array above; see Write-UnityResultFailureDiagnostics for the second consumer. +function Write-UnityCatastrophicErrorAnnotations { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)][string]$LogPath, + [int]$MaxPerPattern = 5 + ) + + if (-not $LogPath -or -not (Test-Path -LiteralPath $LogPath -PathType Leaf)) { + return + } + + foreach ($entry in $script:CatastrophicPatterns) { + try { + if ($entry.UseSimple) { + $hits = @( + Select-String -LiteralPath $LogPath -SimpleMatch -Pattern $entry.Pattern -ErrorAction SilentlyContinue | + Select-Object -First $MaxPerPattern + ) + } else { + $hits = @( + Select-String -LiteralPath $LogPath -Pattern $entry.Pattern -ErrorAction SilentlyContinue | + Select-Object -First $MaxPerPattern + ) + } + } catch { + # Best-effort; never throw from a diagnostic helper -- the caller is + # already in the middle of a throw path. + continue + } + + if ($hits.Count -lt 1) { + continue + } + + Write-Host "::group::Catastrophic pattern: $($entry.Label)" + foreach ($hit in $hits) { + $line = $hit.Line.Trim() + Write-Host "::error::Pattern detected -- $($entry.Label):: $line" + Write-Host " $($hit.Path):$($hit.LineNumber): $line" + } + Write-Host "::endgroup::" + } +} + +function Test-UnityPackageManagerTransientFailure { + param([string]$LogPath) + + if (-not $LogPath -or -not (Test-Path -LiteralPath $LogPath -PathType Leaf)) { + return $false + } + + try { + $logText = Get-Content -LiteralPath $LogPath -Raw + } catch { + return $false + } + + if (-not $logText) { + return $false + } + + return ( + $logText -match 'Cancelled resolving packages' -or + $logText -match 'Failed to resolve packages:\s+operation cancelled' -or + $logText -match 'IPCStream \(Upm-[^)]+\): IPC stream failed to read' + ) +} + +function Write-UnityPackageManagerTransientFailureWarnings { + param( + [string]$LogPath, + [int]$MaxLines = 12 + ) + + if (-not $LogPath -or -not (Test-Path -LiteralPath $LogPath -PathType Leaf)) { + return + } + + $patterns = @( + 'Cancelled resolving packages', + 'Failed to resolve packages:\s+operation cancelled', + 'IPCStream \(Upm-[^)]+\): IPC stream failed to read' + ) + + try { + $matches = @( + Select-String -LiteralPath $LogPath -Pattern $patterns -ErrorAction SilentlyContinue | + Select-Object -First $MaxLines + ) + } catch { + return + } + + foreach ($match in $matches) { + $line = ConvertTo-SingleLineDiagnostic -Text $match.Line + Write-Host "::warning::Unity Package Manager transient package-resolution signal: $line" + } +} + +function Clear-UnityPackageManagerRetryState { + param([Parameter(Mandatory = $true)][string]$Project) + + $packageCachePath = Join-Path $Project 'Library\PackageCache' + $packageManagerPath = Join-Path $Project 'Library\PackageManager' + $tempPath = Join-Path $Project 'Temp' + $paths = @( + $packageCachePath, + $packageManagerPath, + $tempPath + ) + + foreach ($envName in @('UPM_CACHE_ROOT', 'UPM_NPM_CACHE_PATH')) { + $value = [Environment]::GetEnvironmentVariable($envName) + if (-not [string]::IsNullOrWhiteSpace($value)) { + $paths += $value + } + } + + Write-Host "::group::Unity Package Manager retry cleanup" + foreach ($path in ($paths | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Unique)) { + try { + if (Test-Path -LiteralPath $path) { + Write-Host "Removing $path" + Remove-Item -LiteralPath $path -Recurse -Force -ErrorAction Stop + } else { + Write-Host "Already absent: $path" + } + New-Item -ItemType Directory -Force -Path $path -ErrorAction Stop | Out-Null + } catch { + Write-Host "::warning::Could not clear Unity Package Manager retry path '${path}': $($_.Exception.Message)" + } + } + Write-Host "::endgroup::" +} + +function Write-UnityPackageManagerDiagnostics { + param( + [string]$Project, + [string]$LogPath + ) + + Write-Host "::group::Unity Package Manager diagnostics" + try { + foreach ($envName in @('UPM_CACHE_ROOT', 'UPM_NPM_CACHE_PATH', 'UPM_GIT_LFS_CACHE_PATH')) { + Write-Host "${envName}: $([Environment]::GetEnvironmentVariable($envName))" + } + + if ($Project) { + foreach ($relativePath in @('Packages\manifest.json', 'Packages\packages-lock.json')) { + $file = Join-Path $Project $relativePath + if (Test-Path -LiteralPath $file -PathType Leaf) { + Write-Host "${relativePath}:" + Get-Content -LiteralPath $file -ErrorAction SilentlyContinue | + ForEach-Object { Write-Host " $_" } + } else { + Write-Host "${relativePath}: (missing)" + } + } + + $packageCache = Join-Path $Project 'Library\PackageCache' + Write-Host "Library PackageCache: $packageCache" + if (Test-Path -LiteralPath $packageCache -PathType Container) { + Get-ChildItem -LiteralPath $packageCache -Force -ErrorAction SilentlyContinue | + Sort-Object Name | + Select-Object -First 80 | + ForEach-Object { + $kind = if ($_.PSIsContainer) { 'dir ' } else { 'file' } + Write-Host (" [{0}] {1}" -f $kind, $_.Name) + } + } else { + Write-Host " (missing)" + } + } + + if ($LogPath -and (Test-Path -LiteralPath $LogPath -PathType Leaf)) { + Write-Host "Package Manager failure log hits:" + Select-String -LiteralPath $LogPath -Pattern @( + 'IPCStream \(Upm-[^)]+\): IPC stream failed to read', + 'Failed to resolve packages', + 'Cancelled resolving packages' + ) -ErrorAction SilentlyContinue | + Select-Object -First 40 | + ForEach-Object { + Write-Host (" line {0}: {1}" -f $_.LineNumber, $_.Line.Trim()) + } + } + } catch { + Write-Host "::warning::Could not collect Unity Package Manager diagnostics: $($_.Exception.Message)" + } + Write-Host "::endgroup::" +} + +function Invoke-UnityEditorTestsWithPackageManagerRetry { + param( + [Parameter(Mandatory = $true)][string]$EditorPath, + [Parameter(Mandatory = $true)][string[]]$Arguments, + [Parameter(Mandatory = $true)][string]$Label, + [Parameter(Mandatory = $true)][string]$LogPath, + [Parameter(Mandatory = $true)][string]$ResultsPath, + [Parameter(Mandatory = $true)][string]$Project + ) + + $runExit = Invoke-UnityEditor ` + -EditorPath $EditorPath ` + -Arguments $Arguments ` + -Label $Label ` + -LogPath $LogPath + + if ((Test-Path -LiteralPath $ResultsPath -PathType Leaf) -or + -not (Test-UnityPackageManagerTransientFailure -LogPath $LogPath)) { + return $runExit + } + + Write-CiWarning "Unity Package Manager canceled package resolution before NUnit results existed; clearing UPM state and retrying once." + Write-UnityPackageManagerTransientFailureWarnings -LogPath $LogPath + $firstAttemptLogPath = Join-Path (Split-Path -Parent $LogPath) ("{0}.first-attempt.log" -f [System.IO.Path]::GetFileNameWithoutExtension($LogPath)) + try { + Copy-Item -LiteralPath $LogPath -Destination $firstAttemptLogPath -Force -ErrorAction Stop + Write-CiNotice "Saved first failed Unity log before retry: $firstAttemptLogPath" + } catch { + Write-CiWarning "Could not preserve first failed Unity log before retry: $($_.Exception.Message)" + } + Clear-UnityPackageManagerRetryState -Project $Project + + if (Test-Path -LiteralPath $ResultsPath -PathType Leaf) { + Remove-Item -LiteralPath $ResultsPath -Force + } + + return Invoke-UnityEditor ` + -EditorPath $EditorPath ` + -Arguments $Arguments ` + -Label "$Label (retry 1 after UPM cancellation)" ` + -LogPath $LogPath +} + +# Collapse any run of whitespace (including CR/LF) to a single space and trim, so +# a multi-line NUnit / renders as ONE line. GitHub `::error::` +# annotations are single-line: an embedded newline silently truncates the +# annotation at the first line break, so the whole message must be flattened +# before it is emitted. Mirrors the `.Trim()` collapse the catastrophic-pattern +# scanner applies to each matched log line. +function ConvertTo-SingleLineDiagnostic { + param([string]$Text) + if (-not $Text) { + return '' + } + return (($Text -replace '\s+', ' ').Trim()) +} + +# Holder for the ::stop-commands:: ... :::: fence token that wraps +# caller-controlled raw multi-line dumps (NUnit /). GitHub +# parses every stdout line for `::command::` directives; fencing the raw body +# disables that processing so an assertion message containing a line like +# `::error file=...::` or `::set-output name=x::` cannot inject a spurious +# workflow command. The token is NOT a fixed literal: a crafted message +# containing the exact `::::` close line could otherwise end the fence +# early and re-enable injection. Instead a FRESH random token is generated per +# enumeration via New-WorkflowCommandStopToken (mirroring GitHub's own +# @actions/core, which uses a random per-invocation delimiter) and the SAME +# value is used for the opening and closing fence lines. The matching fence in +# .github/actions/verify-unity-results/action.yml uses the same scheme. +$script:WorkflowCommandStopToken = $null + +# Generate a fresh, unpredictable stop-commands fence token. A GUID 'N' form is +# 32 hex chars with no separators, so it can never collide with caller text and +# is regenerated each call so it is neither predictable nor committed. +function New-WorkflowCommandStopToken { + return ('uh-stop-commands-{0}' -f [guid]::NewGuid().ToString('N')) +} + +# Resolve an NUnit test-case / test-suite node's display name using +# XmlElement.GetAttribute, which returns '' for an ABSENT attribute instead of +# THROWING under Set-StrictMode -Version Latest (the dynamic `$node.fullname` +# property accessor throws "The property 'fullname' cannot be found" when the +# attribute is missing, which would degrade the whole failed-test enumeration to +# a generic warning for any NUnit XML lacking a fullname). Prefers fullname, then +# name, then a final '(unnamed test)' fallback. +function Get-NUnitNodeFullName { + param([Parameter(Mandatory = $true)]$Node) + + $fullName = $Node.GetAttribute('fullname') + if (-not $fullName) { + $fullName = $Node.GetAttribute('name') + } + if (-not $fullName) { + $fullName = '(unnamed test)' + } + return $fullName +} + +# DIAGNOSTIC: when a Unity test run reports failures, the operator's next question +# is "WHICH tests failed and WHY?". The aggregate `failed=N` count alone is not +# actionable -- a real 2021.3 PlayMode run failed 1 of 697 tests and the logs +# never named it. This best-effort helper enumerates each failed test from the +# NUnit3 results XML and emits BOTH: +# - a single-line `::error::` GitHub annotation per failed test (label + +# fullname + first line of the failure message), and +# - a `::group::Failed test: ` ... `::endgroup::` console block with +# the full multi-line message and stack trace. +# It NEVER throws (the caller is already on a throw path; a diagnostic error must +# not mask the real test failure) and follows the structure of the other +# best-effort scanners (Write-UnityCatastrophicErrorAnnotations / +# Write-UnityResultFailureDiagnostics). +# +# Two classes of failed node are enumerated: +# (1) Failed leaf cases: //test-case[@result='Failed'] -- the ordinary +# assertion failure. +# (2) Failed suites that carry their OWN direct child: +# //test-suite[@result='Failed'] with a direct element. This is +# the OneTimeSetUp / OneTimeTearDown failure shape (e.g. +# SuiteWallClockBudgetTest's [OneTimeTearDown] Assert.Fail) -- a suite can +# carry its OWN teardown failure message EVEN WHEN it also has a failed +# child case, so we report on the direct regardless of failed +# descendants. The fullname de-dup keeps a suite distinct from its child +# cases (suite fullname differs from case fullname), so this never +# double-prints; an aggregate-only suite (no direct ) is still +# skipped because its failure is just the roll-up of the child cases. +# De-duplicated by fullname so the same logical node is never printed twice, and +# capped at the first $MaxFailures (a truncation notice is printed -- no silent +# cap). Attribute reads use XmlElement.GetAttribute (returns '' when absent, +# never throws) so a results.xml lacking a fullname/name attribute does NOT +# degrade the whole enumeration to a generic warning under Set-StrictMode. +function Write-UnityFailedTestAnnotations { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)]$Xml, + [Parameter(Mandatory = $true)][string]$Label, + [int]$MaxFailures = 50 + ) + + try { + $failedCases = @($Xml.SelectNodes("//test-case[@result='Failed']")) + $failedSuites = @($Xml.SelectNodes("//test-suite[@result='Failed']")) + + # A failed suite is reported on its OWN merits whenever it carries a + # direct child element. This captures the OneTimeSetUp / + # OneTimeTearDown failure message even when the suite ALSO has a failed + # descendant case (the teardown's own message would otherwise be lost). + # An aggregate-only suite (no direct , just a roll-up of failed + # children) is skipped. The fullname de-dup below keeps the suite + # distinct from its child cases, so this never double-prints. + $ownFailureSuites = @( + foreach ($suite in $failedSuites) { + $directFailure = $suite.SelectSingleNode('failure') + if ($directFailure) { + $suite + } + } + ) + + $failedNodes = @($failedCases) + @($ownFailureSuites) + if ($failedNodes.Count -lt 1) { + return + } + + # De-duplicate by fullname (fallback name) so the same logical test is + # never printed twice. + $seen = New-Object 'System.Collections.Generic.HashSet[string]' + $uniqueNodes = New-Object 'System.Collections.Generic.List[object]' + foreach ($node in $failedNodes) { + $fullName = Get-NUnitNodeFullName -Node $node + if ($seen.Add($fullName)) { + $uniqueNodes.Add($node) + } + } + + $totalFailed = $uniqueNodes.Count + $shown = @($uniqueNodes | Select-Object -First $MaxFailures) + foreach ($node in $shown) { + $fullName = Get-NUnitNodeFullName -Node $node + + $failureNode = $node.SelectSingleNode('failure') + $message = '' + $stackTrace = '' + if ($failureNode) { + $messageNode = $failureNode.SelectSingleNode('message') + if ($messageNode) { + $message = $messageNode.InnerText + } + $stackNode = $failureNode.SelectSingleNode('stack-trace') + if ($stackNode) { + $stackTrace = $stackNode.InnerText + } + } + + $firstMessageLine = ConvertTo-SingleLineDiagnostic -Text $message + # The single-line ::error:: annotation stays OUTSIDE the fence so it + # is still processed as a GitHub annotation. ConvertTo-SingleLineDiagnostic + # already flattens it to one line, so an embedded `::error::`/`::set-output::` + # token cannot start a NEW directive on its own line here. + Write-Host "::error::${Label} failed test: $fullName -- $firstMessageLine" + + Write-Host "::group::Failed test: $fullName" + # SECURITY: the raw NUnit / are caller-controlled + # (an assertion message can contain ANY text). GitHub parses every + # stdout line for `::command::` directives, so a message line like + # `::error file=...::` or `::set-output name=x::` would inject a + # spurious workflow command. Fence the raw multi-line dump with + # ::stop-commands:: ... :::: so command processing is + # disabled for the enclosed lines. The token is a FRESH random GUID + # per dump (never a fixed literal) so a crafted message containing + # the exact `::::` close line cannot end the fence early and + # re-enable injection. The ::group::/::endgroup:: markers stay OUTSIDE + # the fence so they are still processed. + $script:WorkflowCommandStopToken = New-WorkflowCommandStopToken + Write-Host "::stop-commands::$script:WorkflowCommandStopToken" + if ($message) { + Write-Host "Message:" + Write-Host $message + } else { + Write-Host "Message: (none recorded)" + } + if ($stackTrace) { + Write-Host "Stack trace:" + Write-Host $stackTrace + } + Write-Host "::$script:WorkflowCommandStopToken::" + Write-Host "::endgroup::" + } + + if ($totalFailed -gt $shown.Count) { + $omitted = $totalFailed - $shown.Count + Write-CiNotice "${Label}: $omitted additional failed test(s) not shown (showing first $($shown.Count) of $totalFailed)." + } + } catch { + # Best-effort; a diagnostic must never mask the real test failure. + Write-Host "::warning::Could not enumerate failed tests for ${Label}: $($_.Exception.Message)" + } +} + +function Resolve-FullPath { + param([Parameter(Mandatory = $true)][string]$Path) + $executionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path) +} + +function Assert-RepoRoot { + param([Parameter(Mandatory = $true)][string]$Path) + if (-not (Test-Path -LiteralPath (Join-Path $Path 'package.json') -PathType Leaf)) { + throw "Repo root '$Path' does not contain package.json." + } + if (-not (Test-Path -LiteralPath (Join-Path $Path 'Runtime') -PathType Container)) { + throw "Repo root '$Path' does not contain Runtime/." + } +} + +function ConvertTo-UnityFileUriPath { + param([Parameter(Mandatory = $true)][string]$Path) + return ($Path -replace '\\', '/') +} + +function Initialize-UnityCacheEnvironment { + param( + [Parameter(Mandatory = $true)][string]$Root, + [Parameter(Mandatory = $true)][string]$Version + ) + + $cacheRoot = Join-Path $Root ".artifacts\unity\cache\$Version" + $upmRoot = Join-Path $cacheRoot 'upm' + $npmRoot = Join-Path $cacheRoot 'npm' + $gitLfsRoot = Join-Path $cacheRoot 'git-lfs' + $localUnityCaches = if ($env:LOCALAPPDATA) { + Join-Path $env:LOCALAPPDATA 'Unity\Caches' + } else { + Join-Path $cacheRoot 'localappdata\Unity\Caches' + } + + foreach ($path in @($cacheRoot, $upmRoot, $npmRoot, $gitLfsRoot, $localUnityCaches)) { + New-Item -ItemType Directory -Force -Path $path | Out-Null + } + + $env:UPM_CACHE_ROOT = $upmRoot + $env:UPM_NPM_CACHE_PATH = $npmRoot + $env:UPM_GIT_LFS_CACHE_PATH = $gitLfsRoot + $env:UPM_ENABLE_GIT_LFS_CACHE = 'true' + + Write-Host "::group::Unity cache environment" + Write-Host "LOCALAPPDATA Unity caches: $localUnityCaches" + Write-Host "UPM_CACHE_ROOT: $env:UPM_CACHE_ROOT" + Write-Host "UPM_NPM_CACHE_PATH: $env:UPM_NPM_CACHE_PATH" + Write-Host "UPM_GIT_LFS_CACHE_PATH: $env:UPM_GIT_LFS_CACHE_PATH" + Write-Host "::endgroup::" +} + +function Get-ComparisonPackages { + param([Parameter(Mandatory = $true)][string]$Root) + $path = Join-Path $Root '.github/comparison-packages.json' + if (-not (Test-Path -LiteralPath $path)) { + throw "Comparison packages single source not found: $path" + } + return Get-Content -LiteralPath $path -Raw | ConvertFrom-Json +} + +function New-ManifestJson { + param( + [Parameter(Mandatory = $true)][string]$Root, + [switch]$IncludeComparisons, + [string]$RepoRoot + ) + + $packagePath = ConvertTo-UnityFileUriPath -Path $Root + $dependencies = [ordered]@{ + 'com.unity.test-framework' = $TestFrameworkVersion + 'com.unity.test-framework.performance' = $PerformanceFrameworkVersion + $PackageName = "file:$packagePath" + } + + $manifest = [ordered]@{ + dependencies = $dependencies + testables = @($PackageName) + } + + # ONLY the comparison legs (-IncludeComparisons) get the OpenUPM scoped + # registry, pinned comparison packages, and comparison-package-required Unity + # built-in modules, read from the single source .github/comparison-packages.json. + # Non-comparison legs MUST stay byte-for-byte identical to before (no + # scopedRegistries key and no extra dependencies) so their Library cache and + # reliability are unchanged. + if ($IncludeComparisons) { + if ([string]::IsNullOrWhiteSpace($RepoRoot)) { + throw "New-ManifestJson -IncludeComparisons requires -RepoRoot (the comparison-packages.json single source)." + } + $comparisons = Get-ComparisonPackages -Root $RepoRoot + foreach ($pkg in $comparisons.packages.PSObject.Properties) { + $dependencies[$pkg.Name] = $pkg.Value + } + $builtInPackages = $comparisons.PSObject.Properties['unityBuiltInPackages'] + if (-not $builtInPackages) { + throw "comparison-packages.json is missing unityBuiltInPackages; cannot generate the comparison manifest." + } + foreach ($pkg in $builtInPackages.Value.PSObject.Properties) { + $dependencies[$pkg.Name] = $pkg.Value + } + $reg = $comparisons.registry + # Ordered so ConvertTo-Json emits name/url/scopes deterministically (matches + # the committed local-parity manifest field order and keeps the CI-log diff + # of the generated manifest stable run-to-run). + $manifest['scopedRegistries'] = @( + [ordered]@{ + name = $reg.name + url = $reg.url + scopes = @($reg.scopes) + } + ) + } + + return ($manifest | ConvertTo-Json -Depth 8) +} + +function New-ConfiguratorSource { + param([string]$Backend = 'IL2CPP') + + # NOTE: this is a DOUBLE-quoted here-string so $Backend interpolates into the + # generated C#. Every LITERAL C# dollar sign (the Debug.Log interpolated + # string) is therefore backtick-escaped (`$). The LIVE code uses the + # parameterized scripting backend (ScriptingImplementation.), the + # non-deprecated ApiCompatibilityLevel.NET_Standard (which targets .NET Standard + # 2.1), CompilationPipeline.codeOptimization = Release, and disables managed + # stripping so the test assemblies + [Preserve] callback survive a Release Mono + # player build. This is an invariant of the + # generated configurator; no automated contract test pins it anymore. + @" +using System; +using System.IO; +using UnityEditor; +using UnityEngine; + +public static class UhCiTestConfigurator +{ + public static void Apply() + { + // Prove Release editor code optimization for every Unity CI leg. Set FIRST + // so the effective value is logged below. + UnityEditor.Compilation.CompilationPipeline.codeOptimization = UnityEditor.Compilation.CodeOptimization.Release; + + EditorUserBuildSettings.SwitchActiveBuildTarget(BuildTargetGroup.Standalone, BuildTarget.StandaloneWindows64); + // The scripting backend is parameterized: the runner passes the IL2CPP or + // the Mono backend for the Mono perf leg via -Backend. + PlayerSettings.SetScriptingBackend(BuildTargetGroup.Standalone, ScriptingImplementation.$Backend); + // Use the non-deprecated ApiCompatibilityLevel.NET_Standard (targets .NET + // Standard 2.1). The deprecated 2.0 form and the non-existent 2.1 enum + // member are intentionally NOT used. + PlayerSettings.SetApiCompatibilityLevel(BuildTargetGroup.Standalone, ApiCompatibilityLevel.NET_Standard); + // Disable managed code stripping so IncludeTestAssemblies + the [Preserve] + // standalone TestRunCallback survive a NON-development (Release) Mono player + // build; otherwise the stripper can drop the test code from the player. + PlayerSettings.SetManagedStrippingLevel(BuildTargetGroup.Standalone, ManagedStrippingLevel.Disabled); + // Pin the IL2CPP C++ compiler configuration to Release explicitly. An + // ephemeral CI project has no committed default for this setting, and the + // published benchmark numbers must come from Release-optimized native code + // (matching what a shipped Release player runs), so the pin removes the + // variable instead of trusting any implicit default. Harmless under Mono. + PlayerSettings.SetIl2CppCompilerConfiguration(BuildTargetGroup.Standalone, Il2CppCompilerConfiguration.Release); + + // Print the EFFECTIVE Unity config so the artifact log PROVES Mono/IL2CPP + // + .NET Standard 2.1 + Release for this run. + Debug.Log(`$"UH perf config: backend={PlayerSettings.GetScriptingBackend(BuildTargetGroup.Standalone)}, api={PlayerSettings.GetApiCompatibilityLevel(BuildTargetGroup.Standalone)}, codeOpt={UnityEditor.Compilation.CompilationPipeline.codeOptimization}, il2cppConfig={PlayerSettings.GetIl2CppCompilerConfiguration(BuildTargetGroup.Standalone)}"); + + // Write a success marker as the FINAL action so the runner can treat the + // CONFIGURED PROJECT -- not Unity's process exit code -- as the source of + // truth. Unity can crash in a BACKGROUND thread (for example the + // DirectoryMonitor file-watcher's teardown) DURING shutdown, AFTER Apply() + // has fully completed and the editor logged "Batchmode quit successfully + // invoked"; that returns a crash exit code (0xC0000005 STATUS_ACCESS_VIOLATION) + // for a run whose configuration work actually succeeded. A fresh marker + // proves Apply() ran to completion regardless of the shutdown exit code. The + // marker path is handed in via UH_CONFIGURE_MARKER_PATH (mirrors how the + // standalone build modifier receives UH_PLAYER_BUILD_PATH). + string markerPath = Environment.GetEnvironmentVariable("UH_CONFIGURE_MARKER_PATH"); + if (!string.IsNullOrEmpty(markerPath)) + { + string dir = Path.GetDirectoryName(markerPath); + if (!string.IsNullOrEmpty(dir)) + { + Directory.CreateDirectory(dir); + } + File.WriteAllText(markerPath, "UhCiTestConfigurator.Apply completed"); + } + } +} +"@ +} + +# STANDALONE ONLY. The Editor-side type that severs the test player's outbound +# PlayerConnection/Profiler TCP dependency at build time AND makes the editor's +# `-runTests` build step terminate. Emitted into Assets/Editor/ of the standalone +# CI project by Initialize-EphemeralProject. It mirrors Unity's documented +# "Split build and run" example (vendored com.unity.test-framework +# TestPlayerBuildModifierAttribute.cs): ITestPlayerBuildModifier rewrites the +# BuildPlayerOptions, IPostBuildCleanup exits the editor after the build. +# +# CRITICAL: clearing BuildOptions.AutoRunPlayer ALONE is NOT enough. The CLI +# `-runTests` path registers Executer.ExitIfRunIsCompleted on +# EditorApplication.update, which returns early while TestRunnerApi.IsRunActive() +# is true; for a player run that flag clears only on the PlayerConnection +# runFinished message. With the player never launched the message never arrives, +# so the editor idles forever. The PostBuildCleanup exit (run AFTER the build via +# ExecutePostBuildCleanupMethods) is mandatory. +function New-StandaloneBuildModifierSource { + param([bool]$DevelopmentBuild = $false) + + # The Development BuildOptions flag is opt-in only. Unity CI defaults to a true + # Release/non-development player; the compatibility -ReleasePlayerBuild switch is + # retained at the script boundary but Release is the unconditional contract. + # CRITICAL: the Unity Test Framework's PlayerLauncher hands ModifyOptions a + # BuildPlayerOptions that ALREADY carries BuildOptions.Development, so the + # Release path must actively CLEAR the flag -- merely not adding it leaves the + # player a development build (Debug.isDebugBuild=true; published runs reported + # "x64 Debug" platform strings until this strip landed). Every OTHER option (clearing + # AutoRunPlayer/ConnectToHost/ConnectWithProfiler, |= IncludeTestAssemblies, the + # UH_PLAYER_BUILD_PATH redirect, and the PostBuildCleanup exit) is REQUIRED for + # the split-build test execution and is emitted unconditionally. This is a + # DOUBLE-quoted here-string so $developmentOption interpolates; the generated C# + # contains no other dollar signs or backticks, so nothing else needs escaping. + $developmentOption = if ($DevelopmentBuild) { ' playerOptions.options |= BuildOptions.Development;' } else { ' playerOptions.options &= ~BuildOptions.Development;' } + @" +using System; +using System.IO; +using System.Linq; +using UnityEditor; +using UnityEditor.TestTools; +using UnityEngine; +using UnityEngine.TestTools; + +[assembly: TestPlayerBuildModifier(typeof(UhCiStandaloneBuildModifier))] +[assembly: PostBuildCleanup(typeof(UhCiStandaloneBuildModifier))] + +// Mirrors the documented Unity "Split build and run" example. Clearing +// AutoRunPlayer alone is NOT enough: the CLI -runTests path registers +// Executer.ExitIfRunIsCompleted on EditorApplication.update, which returns early +// while TestRunnerApi.IsRunActive() is true; for a player run that flag only +// clears on the PlayerConnection runFinished message, which never arrives when +// the player is not launched. PostBuildCleanup is the framework's hook (run after +// the build) to exit the editor cleanly. +public sealed class UhCiStandaloneBuildModifier : ITestPlayerBuildModifier, IPostBuildCleanup +{ + private static bool s_Armed; + private static readonly EditorApplication.CallbackFunction s_Exit = () => EditorApplication.Exit(0); + + public BuildPlayerOptions ModifyOptions(BuildPlayerOptions playerOptions) + { + playerOptions.options &= ~BuildOptions.AutoRunPlayer; + playerOptions.options &= ~BuildOptions.ConnectToHost; + playerOptions.options &= ~BuildOptions.ConnectWithProfiler; + playerOptions.options |= BuildOptions.IncludeTestAssemblies; +$developmentOption + string outPath = Environment.GetEnvironmentVariable("UH_PLAYER_BUILD_PATH"); + if (!string.IsNullOrEmpty(outPath)) + { + string dir = Path.GetDirectoryName(outPath); + if (!string.IsNullOrEmpty(dir)) + { + Directory.CreateDirectory(dir); + } + playerOptions.locationPathName = outPath; + } + return playerOptions; + } + + public void Cleanup() + { + if (s_Armed) + { + return; + } + s_Armed = true; + if (Environment.GetCommandLineArgs().Any(a => a == "-runTests")) + { + EditorApplication.update += s_Exit; + } + } +} +"@ +} + +# STANDALONE ONLY. The player-side [assembly:TestRunCallback] that REPLACES the +# editor's need to receive results over PlayerConnection/TCP. On RunFinished it +# serializes the NUnit result to NUnit-compatible XML (mirroring Unity's +# ResultsWriter.WriteResultsToXml) at the path from the -uhTestResults +# command-line arg, then Application.Quit(0 pass / 1 fail / 2 no-path / 3 write +# error). Emitted into Assets/UhCiStandaloneTestCallback/ with its own .asmdef. +# [Preserve] keeps the type for IL2CPP. +# +# On the PLAYER, ITestResult.ResultState is a NUnit.Framework.Interfaces.ResultState +# OBJECT, so we call .ToString() (the editor adaptor does the same). The single +# results channel is -uhTestResults; there is NO environment-variable fallback and +# NO per-user-data-folder silent-loss fallback. +function New-StandaloneTestCallbackSource { + @' +using System; +using System.IO; +using System.Xml; +using NUnit.Framework.Interfaces; +using UnityEngine; +using UnityEngine.Scripting; +using UnityEngine.TestRunner; + +[assembly: TestRunCallback(typeof(UhCiStandaloneTestCallback))] + +[Preserve] +internal sealed class UhCiStandaloneTestCallback : ITestRunCallback +{ + public void RunStarted(ITest testsToRun) + { + } + + public void TestStarted(ITest test) + { + } + + public void TestFinished(ITestResult result) + { + } + + public void RunFinished(ITestResult result) + { + string path = ResolveResultsPath(); + if (string.IsNullOrEmpty(path)) + { + Debug.LogError("UH: standalone test player received no -uhTestResults ; not writing results."); + Application.Quit(2); + return; + } + int exitCode; + try + { + WriteNUnitXml(result, path); + exitCode = result.FailCount > 0 ? 1 : 0; + int total = result.PassCount + result.FailCount + result.SkipCount + result.InconclusiveCount; + Debug.LogFormat( + LogType.Log, + LogOption.NoStacktrace, + null, + "UH: wrote standalone results to {0} (total={1} passed={2} failed={3} skipped={4})", + path, + total, + result.PassCount, + result.FailCount, + result.SkipCount); + } + catch (Exception ex) + { + Debug.LogException(ex); + exitCode = 3; + } + Application.Quit(exitCode); + } + + private static string ResolveResultsPath() + { + string[] args = Environment.GetCommandLineArgs(); + for (int i = 0; i < args.Length - 1; i++) + { + if (string.Equals(args[i], "-uhTestResults", StringComparison.Ordinal)) + { + return args[i + 1]; + } + } + return null; + } + + private static void WriteNUnitXml(ITestResult result, string filePath) + { + string dir = Path.GetDirectoryName(filePath); + if (!string.IsNullOrEmpty(dir)) + { + Directory.CreateDirectory(dir); + } + XmlWriterSettings settings = new XmlWriterSettings + { + Indent = true, + NewLineOnAttributes = false + }; + using (StreamWriter sw = File.CreateText(filePath)) + using (XmlWriter xw = XmlWriter.Create(sw, settings)) + { + int total = result.PassCount + result.FailCount + result.SkipCount + result.InconclusiveCount; + TNode run = new TNode("test-run"); + run.AddAttribute("id", "2"); + run.AddAttribute("testcasecount", total.ToString()); + run.AddAttribute("result", result.ResultState.ToString()); + run.AddAttribute("total", total.ToString()); + run.AddAttribute("passed", result.PassCount.ToString()); + run.AddAttribute("failed", result.FailCount.ToString()); + run.AddAttribute("inconclusive", result.InconclusiveCount.ToString()); + run.AddAttribute("skipped", result.SkipCount.ToString()); + run.AddAttribute("asserts", result.AssertCount.ToString()); + run.AddAttribute("engine-version", "3.5.0.0"); + run.AddAttribute("clr-version", Environment.Version.ToString()); + run.AddAttribute("start-time", result.StartTime.ToString("u")); + run.AddAttribute("end-time", result.EndTime.ToString("u")); + run.AddAttribute("duration", result.Duration.ToString()); + run.ChildNodes.Add(result.ToXml(true)); + run.WriteTo(xw); + } + } +} +'@ +} + +# STANDALONE ONLY. The asmdef for the player-side test callback above. Referencing +# UnityEngine.TestRunner is MANDATORY: TestRunCallbackListener.GetAllCallbacks only +# scans assemblies that reference UnityEngine.TestRunner. overrideReferences + +# precompiledReferences=nunit.framework.dll gives the callback the NUnit types; +# defineConstraints UNITY_INCLUDE_TESTS keeps it out of non-test builds. This must +# be a PLAYER assembly (NOT under Assets/Editor/), so includePlatforms is empty. +function New-StandaloneTestCallbackAsmdef { + @' +{ + "name": "UhCiStandaloneTestCallback", + "references": [ + "UnityEngine.TestRunner" + ], + "includePlatforms": [], + "excludePlatforms": [], + "overrideReferences": true, + "precompiledReferences": [ + "nunit.framework.dll" + ], + "autoReferenced": true, + "defineConstraints": [ + "UNITY_INCLUDE_TESTS" + ] +} +'@ +} + +# TODO(unity-helpers): NO-OP. DxMessaging ships RoslynAnalyzer/source-generator +# DLLs under Editor/Analyzers/ and the harness pre-copies them into the ephemeral +# project's Assets/Plugins so the generator is registered at the first compile. +# unity-helpers ships NO analyzers today: the repo has no Editor/Analyzers/ dir +# and no RoslynAnalyzer-labeled assets (the .gitignore RESERVES Editor/Analyzers/ +# *.dll|*.pdb for a future analyzer, but none exists yet). The original +# Assert/Copy/diagnostic functions THREW when those DLLs were absent, which would +# hard-fail every unity-helpers CI run. They are neutralized to safe no-ops below +# so the proven harness flow (manifest, configurator, standalone split-build, +# license, catastrophic-pattern scanning, exit-code handling) is otherwise +# preserved. If/when unity-helpers ships analyzers under Editor/Analyzers/, port +# DxMessaging's bodies for these three functions (Assert-/Copy-/Write-AnalyzerSetupDiagnostics) +# and add the labeled DLL names to $RoslynAnalyzerLabeledDllNames. +$RoslynAnalyzerLabeledDllNames = @() + +function Assert-UnityHelpersAnalyzerDllsPresent { + param([Parameter(Mandatory = $true)][string]$Root) + + # NO-OP (see TODO above): unity-helpers ships no analyzer DLLs, so there is + # nothing to assert. $Root is accepted to keep the call-site signature stable. + $null = $Root +} + +# NO-OP (see TODO above). DxMessaging pre-created the SAME +# Assets/Plugins/Editor/WallstopStudios.DxMessaging/ analyzer copy that its +# Editor/SetupCscRsp.cs makes at editor load, BEFORE Unity launched, so the source +# generator was registered exactly once at the first compile. unity-helpers ships +# no analyzers, so this copy is skipped entirely. +function Copy-UnityHelpersAnalyzersToAssets { + param( + [Parameter(Mandatory = $true)][string]$Root, + [Parameter(Mandatory = $true)][string]$Project + ) + + Assert-UnityHelpersAnalyzerDllsPresent -Root $Root + + # No analyzers to copy. Return immediately; the rest of the original body + # (DLL enumeration, .meta authoring, RoslynAnalyzer labeling) is intentionally + # not ported because there is no Editor/Analyzers/ source to copy from. + $null = $Project +} + +function Write-AnalyzerSetupDiagnostics { + param( + [Parameter(Mandatory = $true)][string]$Project, + [string]$LogPath, + [Parameter(Mandatory = $true)][string]$Label + ) + + # TODO(unity-helpers): NO-OP (see Copy-UnityHelpersAnalyzersToAssets above). + # DxMessaging asserted here that the pre-created Assets/Plugins analyzer copy + # was RoslynAnalyzer-labeled AND Editor-excluded, THROWING otherwise. With no + # analyzers shipped there is nothing to verify, so emit a single notice and + # return. When unity-helpers ships analyzers, port DxMessaging's body here. + $null = $Project + $null = $LogPath + Write-Host "::notice::unity-helpers ships no analyzers; skipping analyzer setup diagnostics ($Label)." +} + +function Initialize-EphemeralProject { + param( + [Parameter(Mandatory = $true)][string]$Root, + [Parameter(Mandatory = $true)][string]$Version, + [Parameter(Mandatory = $true)][string]$Mode, + [string]$Path, + [switch]$IncludeComparisons, + [string]$Backend = 'IL2CPP', + [bool]$DevelopmentBuild = $false, + [string]$RepoRoot + ) + + # The comparison-packages single source lives at the repo root. Default to + # -Root when no explicit -RepoRoot is threaded (the package source root is the + # repo root in this harness), so New-ManifestJson -IncludeComparisons can read it. + if ([string]::IsNullOrWhiteSpace($RepoRoot)) { + $RepoRoot = $Root + } + + $project = if ($Path) { + Resolve-FullPath -Path $Path + } else { + Join-Path $Root ".artifacts\unity\projects\$Version-$Mode" + } + + New-Item -ItemType Directory -Force -Path (Join-Path $project 'Packages') | Out-Null + New-Item -ItemType Directory -Force -Path (Join-Path $project 'ProjectSettings') | Out-Null + New-Item -ItemType Directory -Force -Path (Join-Path $project 'Assets\Editor') | Out-Null + + New-ManifestJson -Root $Root -IncludeComparisons:$IncludeComparisons -RepoRoot $RepoRoot | + Set-Content -LiteralPath (Join-Path $project 'Packages\manifest.json') -Encoding UTF8 + "m_EditorVersion: $Version`n" | + Set-Content -LiteralPath (Join-Path $project 'ProjectSettings\ProjectVersion.txt') -Encoding UTF8 + New-ConfiguratorSource -Backend $Backend | + Set-Content -LiteralPath (Join-Path $project 'Assets\Editor\UhCiTestConfigurator.cs') -Encoding UTF8 + + # Pre-create the Assets/Plugins analyzer copy (NO-OP for unity-helpers, which + # ships no analyzers -- see Copy-UnityHelpersAnalyzersToAssets). Kept as a call + # so the flow matches DxMessaging's and so the body becomes load-bearing again + # the moment unity-helpers ships a RoslynAnalyzer under Editor/Analyzers/. + Copy-UnityHelpersAnalyzersToAssets -Root $Root -Project $project + + # STANDALONE ONLY: generate the split-build helpers that sever the test + # player's PlayerConnection/TCP result streaming (the 10060 hang on multi-NIC + # self-hosted runners). The Editor-side build modifier clears the player's + # outbound-connection BuildOptions and exits the editor after the build; the + # player-side TestRunCallback writes NUnit XML to -uhTestResults and quits. + # Written idempotently (only when missing or changed), exactly like + # Copy-UnityHelpersAnalyzersToAssets, so reruns against the cached project do + # not needlessly invalidate Unity's import cache. editmode/playmode never emit + # these files (the local single -runTests path is untouched). + if ($Mode -eq 'standalone') { + $standaloneFiles = @( + @{ Path = (Join-Path $project 'Assets\Editor\UhCiStandaloneBuildModifier.cs'); Content = (New-StandaloneBuildModifierSource -DevelopmentBuild $DevelopmentBuild) }, + @{ Path = (Join-Path $project 'Assets\UhCiStandaloneTestCallback\UhCiStandaloneTestCallback.cs'); Content = (New-StandaloneTestCallbackSource) }, + @{ Path = (Join-Path $project 'Assets\UhCiStandaloneTestCallback\UhCiStandaloneTestCallback.asmdef'); Content = (New-StandaloneTestCallbackAsmdef) } + ) + foreach ($file in $standaloneFiles) { + $dir = Split-Path -Parent $file.Path + if ($dir -and -not (Test-Path -LiteralPath $dir -PathType Container)) { + New-Item -ItemType Directory -Force -Path $dir | Out-Null + } + $needsWrite = -not (Test-Path -LiteralPath $file.Path -PathType Leaf) + if (-not $needsWrite) { + # Compare EOL-trailing-tolerantly: Set-Content appends a trailing + # newline that the here-string content lacks, so a naive `-ne` would + # rewrite on every run and needlessly bust Unity's import cache. + $existing = Get-Content -LiteralPath $file.Path -Raw + $needsWrite = ($existing.TrimEnd("`r", "`n") -ne $file.Content.TrimEnd("`r", "`n")) + } + if ($needsWrite) { + Set-Content -LiteralPath $file.Path -Value $file.Content -Encoding UTF8 + } + } + Write-Host "::group::unity-helpers standalone split-build helpers" + Write-Host "Generated the standalone build modifier + player TestRunCallback under $project (file-based results; no PlayerConnection)." + foreach ($file in $standaloneFiles) { + Write-Host " $($file.Path)" + } + Write-Host "::endgroup::" + } + + return $project +} + +function ConvertTo-NormalizedAcceleratorEndpoint { + param([string]$Endpoint) + + # Pure: returns $null for empty input or a non-empty 'host:port' string; + # THROWS with form-only diagnostics (never echoes the input value -- the + # raw form is sensitive even if it just looks like a URL, and a future + # secret-masking lapse must not exfiltrate it through our error text). + if (-not $Endpoint -or $Endpoint.Trim().Length -eq 0) { + return $null + } + + $trimmed = $Endpoint.Trim() + $hostPart = $null + $portPart = 0 + + # URL form: a scheme is present. [System.Uri]::TryCreate handles userinfo + # stripping, path/query/fragment stripping, bracketed IPv6 hosts, and + # explicit port extraction in one call. PS 5.1 compatible. + if ($trimmed -match '^[a-zA-Z][a-zA-Z0-9+.\-]*://') { + [System.Uri]$uri = $null + # NOTE (leak-guard): the throw text below is form-only and intentionally + # interpolates NO part of `$Endpoint`/`$trimmed`. The fourth normalizer + # throw path (URL TryCreate failure) is therefore statically safe even + # though it cannot be deterministically triggered from a unit test -- + # [System.Uri]::TryCreate is too permissive about most malformed URLs. + if (-not [System.Uri]::TryCreate($trimmed, [System.UriKind]::Absolute, [ref]$uri)) { + throw 'UNITY_ACCELERATOR_ENDPOINT could not be parsed as a URL form (scheme present, but not RFC 3986 well-formed). Expected host:port or scheme://host:port.' + } + # IsDefaultPort=TRUE means the URL OMITTED :port and the scheme's + # default (e.g. 80/443 for http/https) was substituted -- both cases + # are wrong for a Unity cache server, which needs an EXPLICIT port. + # The `$uri.Port -lt 0` clause is belt-and-suspenders: on pwsh 7+ a + # missing port yields Port == -1 AND IsDefaultPort == True, so the + # -lt 0 check is subsumed -- it stays here as defense against a future + # .NET runtime change that decouples the two flags. + if ($uri.Port -lt 0 -or $uri.IsDefaultPort) { + throw 'UNITY_ACCELERATOR_ENDPOINT URL is missing an explicit :port. Provide host:port or scheme://host:port.' + } + # `Uri.Host` returns `[::1]` (with brackets) on pwsh 7+ / .NET Core (the + # CI runtime), and historically returned `::1` (no brackets) on PS 5.1 / + # .NET Framework. The `StartsWith('[')` guard makes the assembled + # 'host:port' string unambiguous on both runtimes; the production target + # is pwsh 7+, so this is defense-in-depth against a future PS 5.1 + # backport. + $hostPart = $uri.Host + if ($uri.HostNameType -eq [System.UriHostNameType]::IPv6 -and -not $hostPart.StartsWith('[')) { + $hostPart = "[$hostPart]" + } + $portPart = $uri.Port + } + else { + # Bare host:port (canonical). Bracketed IPv6 first because the v4 / + # hostname regex would mis-anchor on the closing bracket. + # + # LEAK GUARD: pre-validate the port digit length BEFORE the `[int]` cast. + # The .NET Int32 overflow exception text echoes the offending value + # verbatim ("Cannot convert value "99999999999" to type ...") which would + # contradict the function's "never echoes the input" invariant. 5 digits + # is the max legal port (65535); anything longer is automatically out of + # range, so reject with the existing form-only message before the cast. + if ($trimmed -match '^\[([0-9A-Fa-f:]+)\]:(\d+)$') { + if ($matches[2].Length -gt 5) { + throw 'UNITY_ACCELERATOR_ENDPOINT port is out of range (must be 1-65535).' + } + $hostPart = "[$($matches[1])]" + $portPart = [int]$matches[2] + } + elseif ($trimmed -match '^([^:\s/?#]+):(\d+)$') { + if ($matches[2].Length -gt 5) { + throw 'UNITY_ACCELERATOR_ENDPOINT port is out of range (must be 1-65535).' + } + $hostPart = $matches[1] + $portPart = [int]$matches[2] + } + else { + throw 'UNITY_ACCELERATOR_ENDPOINT could not be parsed: expected host:port (e.g. 127.0.0.1:10080), [ipv6]:port, or scheme://host:port[/path].' + } + } + + if ($portPart -le 0 -or $portPart -gt 65535) { + throw 'UNITY_ACCELERATOR_ENDPOINT port is out of range (must be 1-65535).' + } + + return ('{0}:{1}' -f $hostPart, $portPart) +} + +function Get-AcceleratorArguments { + param( + [string]$Endpoint, + [Parameter(Mandatory = $true)][string]$Version, + [Parameter(Mandatory = $true)][string]$Mode + ) + + $normalized = ConvertTo-NormalizedAcceleratorEndpoint -Endpoint $Endpoint + if (-not $normalized) { + return @() + } + + # SECURITY: defense-in-depth masking. GitHub Actions masks the original + # secret value, but here we extract a NEW substring (the normalized + # host:port form) -- masking a parent string does NOT propagate to derived + # substrings. Register BOTH the raw trimmed input (defense-in-depth, in + # case the secret was passed via non-secret env in some other call path) + # AND the normalized form BEFORE any downstream log line could echo them: + # Invoke-UnityEditor prints "$EditorPath $($Arguments -join ' ')" later in + # this same script (search for `Write-Host "`"$EditorPath`"`) which WOULD + # leak the host:port unmasked without these directives. + # + # `::add-mask::` is a no-op outside GitHub Actions, so local runs are + # unaffected. Done at the top of the success path so all callers benefit. + Write-Host "::add-mask::$($Endpoint.Trim())" + Write-Host "::add-mask::$normalized" + + return @( + '-EnableCacheServer', + '-cacheServerEndpoint', $normalized, + '-cacheServerNamespacePrefix', "unity-helpers-$Version-$Mode", + '-cacheServerEnableDownload', 'true', + '-cacheServerEnableUpload', 'true' + ) +} + +function Invoke-UnityLicenseActivate { + param( + [Parameter(Mandatory = $true)][string]$EditorPath, + [Parameter(Mandatory = $true)][string]$Serial, + [Parameter(Mandatory = $true)][string]$Email, + [Parameter(Mandatory = $true)][string]$Password, + [Parameter(Mandatory = $true)][string]$LogPath + ) + + # Classic SERIAL activation: a single editor invocation that activates the + # paid Unity seat and immediately quits. This MUST succeed before the test + # run, so unlike the return path it THROWS on a non-zero exit -- a failed + # activation means the test editor would launch unlicensed and fail opaquely. + $logDir = Split-Path -Parent $LogPath + if ($logDir -and -not (Test-Path -LiteralPath $logDir -PathType Container)) { + New-Item -ItemType Directory -Force -Path $logDir | Out-Null + } + + # SECURITY: the serial/email/password ride in the argument array, so this site + # must NEVER echo the args (no "...$activateArgs..." Write-Host). The caller + # passes a $LogPath that lives under a NON-uploaded temp dir (RUNNER_TEMP / + # system temp), never under $ArtifactsPath, so the credentials cannot leak into + # an uploaded artifact. + $activateArgs = @( + '-quit', + '-batchmode', + '-nographics', + '-serial', $Serial, + '-username', $Email, + '-password', $Password, + '-logFile', '-' + ) + + Write-Host "::group::Activate Unity license (serial)" + # Unity.exe is a Windows GUI-subsystem binary: PowerShell's `&` does NOT wait + # for it or set $LASTEXITCODE unless its stdout is consumed via the pipeline. + # `-logFile -` puts the Unity log on stdout and `| Tee-Object` forces the wait, + # sets $LASTEXITCODE, and persists the (non-uploaded) temp log. (Proven idiom; + # see Invoke-UnityEditor.) + & $EditorPath @activateArgs 2>&1 | Tee-Object -FilePath $LogPath + $exitCode = $LASTEXITCODE + Write-Host "::endgroup::" + if ($exitCode -ne 0) { + # The message names the failure and the (non-uploaded) log path ONLY -- it + # must never embed the serial/email/password values. + throw "Unity license activation failed with exit code $exitCode. See the activation log at $LogPath (not uploaded as an artifact)." + } + + Write-CiNotice 'Activated the Unity license (serial).' +} + +function Test-UnityLicenseReturnLogShowsEntitlementReturned { + param( + [Parameter(Mandatory = $true)][string]$LogPath + ) + + try { + if (-not (Test-Path -LiteralPath $LogPath -PathType Leaf)) { + return $false + } + + $returnedEntitlement = Select-String ` + -LiteralPath $LogPath ` + -Pattern 'Successfully returned the entitlement license' ` + -SimpleMatch ` + -Quiet + $legacyFileUnavailable = Select-String ` + -LiteralPath $LogPath ` + -Pattern 'Serial number unavailable for ULF return' ` + -SimpleMatch ` + -Quiet + return $returnedEntitlement -and $legacyFileUnavailable + } catch { + return $false + } +} + +function Invoke-UnityLicenseReturn { + param( + [Parameter(Mandatory = $true)][string]$EditorPath, + [Parameter(Mandatory = $true)][string]$Email, + [Parameter(Mandatory = $true)][string]$Password, + [Parameter(Mandatory = $true)][string]$LogPath + ) + + # Best-effort, defense-in-depth: this MUST NEVER throw. The license is also + # returned by the workflow if:always() step (a backstop for a hard-killed + # editor that never reaches this finally) and by the NEXT run's + # return-at-start (which reclaims a seat leaked by a prior force-killed run on + # this persistent self-hosted runner). + try { + $logDir = Split-Path -Parent $LogPath + if ($logDir -and -not (Test-Path -LiteralPath $logDir -PathType Container)) { + New-Item -ItemType Directory -Force -Path $logDir | Out-Null + } + + # SECURITY: email/password ride in the argument array; never echo the args + # and keep the return log in the NON-uploaded temp dir, never under + # $ArtifactsPath. + $returnArgs = @( + '-quit', + '-batchmode', + '-nographics', + '-returnlicense', + '-username', $Email, + '-password', $Password, + '-logFile', '-' + ) + + Write-Host "::group::Return Unity license (serial)" + # Same Tee-Object wait + $LASTEXITCODE idiom as Invoke-UnityLicenseActivate + # / Invoke-UnityEditor (a bare `&` would not wait for the GUI-subsystem + # binary). `-logFile -` puts the log on stdout; Tee-Object DOES persist it + # to $LogPath, but the caller keeps $LogPath under the NON-uploaded temp dir + # (RUNNER_TEMP / system temp), so it stays out of any UPLOADED ARTIFACT and + # the account fragments Unity may print cannot leak into uploads. + & $EditorPath @returnArgs 2>&1 | Tee-Object -FilePath $LogPath + $exitCode = $LASTEXITCODE + Write-Host "::endgroup::" + + if ($exitCode -ne 0) { + if (Test-UnityLicenseReturnLogShowsEntitlementReturned -LogPath $LogPath) { + Write-CiNotice "Unity returned the entitlement license, then exited with code $exitCode while skipping legacy ULF return; treating the seat return as successful." + } else { + Write-Host "::warning::Unity license return exited with code $exitCode; the workflow if:always() return step and the next run's return-at-start are the backstops for the leaked seat." + } + } else { + Write-CiNotice 'Returned the Unity license (serial).' + } + } catch { + Write-Host "::warning::Unity license return failed: $($_.Exception.Message). The workflow if:always() return step and the next run's return-at-start are the backstops." + } +} + +function Get-StandaloneTestPlayerTimeoutSeconds { + # Single source of truth for the TOTAL wall-clock timeout applied to the + # DIRECTLY-LAUNCHED standalone test player (Invoke-StandaloneTestPlayer). The + # player runs ~700 runtime tests headless in single-digit minutes; the 30 min + # default is a generous backstop so a player that hangs (e.g. a residual + # connection dial-out or a deadlocked test) is tree-killed instead of running + # until the 120-minute GitHub step is cancelled. Mirrors ensure-editor.ps1 + # Get-EnsureEditorInstallTimeoutSeconds EXACTLY: honors + # UH_STANDALONE_PLAYER_TIMEOUT_SECONDS; a non-integer or NEGATIVE override is + # ignored with a ::warning:: and the default is used; 0 is the explicit OPT-OUT + # (unbounded wait). StrictMode-safe: no collection reads. + param([int]$Default = 1800) + + if ($env:UH_STANDALONE_PLAYER_TIMEOUT_SECONDS) { + $parsed = 0 + if ( + [int]::TryParse($env:UH_STANDALONE_PLAYER_TIMEOUT_SECONDS, [ref]$parsed) -and + $parsed -ge 0 + ) { + return $parsed + } + Write-Host "::warning::Ignoring invalid UH_STANDALONE_PLAYER_TIMEOUT_SECONDS='$env:UH_STANDALONE_PLAYER_TIMEOUT_SECONDS'; using $Default second(s)." + } + return $Default +} + +function Get-StandaloneBuildTimeoutSeconds { + # Single source of truth for the TOTAL wall-clock timeout applied to the editor + # BUILD step that produces the standalone IL2CPP test player. The IL2CPP build + # is the long pole; the 45 min default matches the install default and comfortably + # exceeds a slow-but-progressing build, so a build that idles forever (e.g. the + # PostBuildCleanup exit never fired because the modifier failed to compile and + # AutoRunPlayer stayed set) is tree-killed instead of consuming the 120-minute + # GitHub step. Mirrors ensure-editor.ps1 Get-EnsureEditorInstallTimeoutSeconds + # EXACTLY: honors UH_STANDALONE_BUILD_TIMEOUT_SECONDS; a non-integer or NEGATIVE + # override is ignored with a ::warning:: and the default is used; 0 is the + # explicit OPT-OUT (unbounded wait). StrictMode-safe: no collection reads. + param([int]$Default = 2700) + + if ($env:UH_STANDALONE_BUILD_TIMEOUT_SECONDS) { + $parsed = 0 + if ( + [int]::TryParse($env:UH_STANDALONE_BUILD_TIMEOUT_SECONDS, [ref]$parsed) -and + $parsed -ge 0 + ) { + return $parsed + } + Write-Host "::warning::Ignoring invalid UH_STANDALONE_BUILD_TIMEOUT_SECONDS='$env:UH_STANDALONE_BUILD_TIMEOUT_SECONDS'; using $Default second(s)." + } + return $Default +} + +function ConvertTo-ProcessArgumentLine { + # MIRROR of scripts/unity/ensure-editor.ps1 ConvertTo-ProcessArgumentLine + # (run-ci-tests.ps1 does not import that script, so the helper is copied here + # verbatim). Builds a single Windows command-line argument string from an array, + # quoting any argument containing whitespace or a quote and escaping embedded + # backslashes/quotes per the CommandLineToArgvW rules. Used by + # Invoke-ProcessWithTreeKillTimeout (it assigns ProcessStartInfo.Arguments, the + # single command-line string form, NOT the per-element argument-list property + # the contract forbids). + param([string[]]$Arguments) + + $quoted = foreach ($arg in @($Arguments)) { + if ($null -eq $arg) { + '""' + continue + } + + $value = [string]$arg + if ($value.Length -gt 0 -and $value -notmatch '[\s"]') { + $value + continue + } + + $builder = New-Object System.Text.StringBuilder + [void]$builder.Append('"') + $backslashes = 0 + foreach ($ch in $value.ToCharArray()) { + if ($ch -eq '\') { + $backslashes++ + continue + } + + if ($ch -eq '"') { + if ($backslashes -gt 0) { + [void]$builder.Append('\' * ($backslashes * 2)) + } + [void]$builder.Append('\"') + $backslashes = 0 + continue + } + + if ($backslashes -gt 0) { + [void]$builder.Append('\' * $backslashes) + $backslashes = 0 + } + [void]$builder.Append($ch) + } + + if ($backslashes -gt 0) { + [void]$builder.Append('\' * ($backslashes * 2)) + } + [void]$builder.Append('"') + $builder.ToString() + } + + return ($quoted -join ' ') +} + +function Invoke-ProcessWithTreeKillTimeout { + # GENERALIZED hard tree-kill watchdog, STRUCTURALLY IDENTICAL to + # scripts/unity/ensure-editor.ps1 Invoke-UnityCliCaptureWithTimeout (the proven + # resilience core). It launches $FilePath with $Arguments via + # System.Diagnostics.Process + ProcessStartInfo, drains BOTH stdout and stderr + # from a MAIN-THREAD ReadLineAsync poll loop (live echo via Write-Host + Tee to + # $LogPath), enforces an absolute UTC deadline, and on a breach $proc.Kill($true) + # tree-kills the whole process tree (the Unity editor build spawns child + # processes -- IL2CPP/bee -- and the player may too, so a bare Kill() would orphan + # them). The process is held in a try/finally that kills it on ANY throw between + # launch and reap, so a pwsh cancellation cannot leave an orphaned editor/player. + # + # WHY a Process and NOT `& `: the call operator cannot be interrupted -- a + # hung child runs until the whole job is killed. WHY the main-thread poll loop: + # every line is echoed LIVE the instant it arrives (no silent multi-minute build + # console) AND both pipes are continuously drained so neither can fill and + # back-pressure the child (the classic full-pipe-buffer deadlock is impossible). + # A Process.Start() launch is NOT an `&`/`.` call, so it does not trip the + # powershell-unity-process-wait-safety parser rule; the contract test additionally + # forbids a bare empty-parens WaitForExit and the per-element argument-list + # property here, both of which this implementation avoids. + # + # Returns a StrictMode-safe hashtable @{ ExitCode; TimedOut }. The caller throws + # on $TimedOut or a non-zero $ExitCode; the FILE written by the player is the + # source of truth for pass/fail. + param( + [Parameter(Mandatory = $true)][string]$FilePath, + [string[]]$Arguments, + [int]$TimeoutSeconds = 1800, + [Parameter(Mandatory = $true)][string]$LogPath, + [Parameter(Mandatory = $true)][string]$Label + ) + + $logDir = Split-Path -Parent $LogPath + if ($logDir -and -not (Test-Path -LiteralPath $logDir -PathType Container)) { + New-Item -ItemType Directory -Force -Path $logDir | Out-Null + } + + # Sentinel exit code for a wall-clock timeout kill. 124 mirrors GNU coreutils + # `timeout`; it is non-zero so the caller's "exit != 0 -> fail" path applies. + $timeoutExitCode = 124 + + Write-Host "::group::$Label" + Write-Host "`"$FilePath`" $($Arguments -join ' ')" + + $buffer = New-Object System.Collections.Generic.List[string] + + if ($TimeoutSeconds -le 0) { + $hasDeadline = $false + $timeoutMs = -1 + } else { + $hasDeadline = $true + $timeoutMsLong = [int64]$TimeoutSeconds * 1000 + if ($timeoutMsLong -gt [int64]::MaxValue - 1) { + $timeoutMs = [int64]::MaxValue - 1 + } else { + $timeoutMs = $timeoutMsLong + } + } + + $proc = $null + $exit = -1 + $timedOut = $false + $reaped = $false + try { + $psi = New-Object System.Diagnostics.ProcessStartInfo + $psi.FileName = $FilePath + $psi.Arguments = ConvertTo-ProcessArgumentLine -Arguments $Arguments + $psi.RedirectStandardOutput = $true + $psi.RedirectStandardError = $true + $psi.UseShellExecute = $false + $psi.CreateNoWindow = $true + + $proc = New-Object System.Diagnostics.Process + $proc.StartInfo = $psi + + [void]$proc.Start() + + $outReader = $proc.StandardOutput + $errReader = $proc.StandardError + $oTask = $outReader.ReadLineAsync() + $eTask = $errReader.ReadLineAsync() + + if ($hasDeadline) { + $deadline = [DateTime]::UtcNow.AddMilliseconds([double]$timeoutMs) + } else { + $deadline = [DateTime]::MaxValue + } + + $oDone = $false + $eDone = $false + while (-not ($oDone -and $eDone)) { + $progressed = $false + + if (-not $oDone -and $oTask.Wait(0)) { + $line = $oTask.Result + if ($null -eq $line) { + $oDone = $true + } else { + Write-Host $line + $buffer.Add([string]$line) + $oTask = $outReader.ReadLineAsync() + } + $progressed = $true + } + + if (-not $eDone -and $eTask.Wait(0)) { + $line = $eTask.Result + if ($null -eq $line) { + $eDone = $true + } else { + Write-Host $line + $buffer.Add([string]$line) + $eTask = $errReader.ReadLineAsync() + } + $progressed = $true + } + + if ([DateTime]::UtcNow -ge $deadline) { + # HUNG (or a quick-exit child whose grandchild still holds the pipe + # open, so EOF never arrives): tree-kill the WHOLE process tree. + $timedOut = $true + try { + $proc.Kill($true) + } catch { + try { $proc.Kill() } catch { } + } + break + } + + if (-not $progressed) { + Start-Sleep -Milliseconds 50 + } + } + + # Reap so ExitCode is valid; bounded so a stuck reap cannot hang the harness. + $reaped = $proc.WaitForExit(5000) + + # Drain any reads that completed during/after the kill so no pre-kill output + # is dropped. + foreach ($pending in @($oTask, $eTask)) { + try { + if ($pending.Wait(2000) -and $null -ne $pending.Result) { + $line = $pending.Result + Write-Host $line + $buffer.Add([string]$line) + } + } catch { + # A faulted/cancelled read on a killed pipe carries nothing to add. + } + } + + if ($timedOut) { + $exit = $timeoutExitCode + } elseif ($reaped -and $proc.HasExited) { + $exit = $proc.ExitCode + } else { + $exit = $timeoutExitCode + $timedOut = $true + } + } catch { + $message = "Process watchdog '$Label' threw: $($_.Exception.Message)" + Write-Host "::warning::$message" + $buffer.Add($message) + $exit = -1 + } finally { + # If we are unwinding on a throw/cancellation and the process is still alive, + # tree-kill it so a cancelled step never orphans the editor/player. + if ($proc -and -not $proc.HasExited) { + try { $proc.Kill($true) } catch { } + } + if ($proc) { $proc.Dispose() } + } + + Write-Host "::endgroup::" + + # Persist the captured (already-streamed) output to $LogPath for diagnostics. + try { + Set-Content -LiteralPath $LogPath -Value (@($buffer.ToArray()) -join "`n") -Encoding UTF8 + } catch { + Write-Host "::warning::Could not persist '$Label' log to ${LogPath}: $($_.Exception.Message)" + } + + return @{ + ExitCode = $exit + TimedOut = [bool]$timedOut + } +} + +function Invoke-StandaloneTestPlayer { + # RUN the editor-built standalone IL2CPP test player DIRECTLY (no + # PlayerConnection): the player-side TestRunCallback writes NUnit XML to the + # -uhTestResults path and quits 0/1/2/3. The exe is launched under the hard + # tree-kill watchdog so a hung player is killed long before the GitHub step is + # cancelled. Returns @{ ExitCode; TimedOut }. The FILE is the source of truth: the + # caller validates results.xml and treats a watchdog timeout as fatal ONLY when no + # usable results file was written (a player can finish writing results in + # RunFinished and then have Application.Quit deferred in -batchmode IL2CPP, which + # the watchdog would otherwise turn into a spurious failure). Exit 2 (the player got + # no -uhTestResults arg -- a harness-contract violation) is still thrown here. + # + # ONE results channel: -uhTestResults. There is NO environment-variable handoff + # and NO per-user-data-folder fallback. + param( + [Parameter(Mandatory = $true)][string]$EditorBuiltExePath, + [Parameter(Mandatory = $true)][string]$ResultsPath, + [Parameter(Mandatory = $true)][string]$LogPath, + [int]$TimeoutSeconds = 1800 + ) + + $playerArgs = @( + '-batchmode', + '-nographics', + '-logFile', '-', + '-uhTestResults', $ResultsPath + ) + + $result = Invoke-ProcessWithTreeKillTimeout ` + -FilePath $EditorBuiltExePath ` + -Arguments $playerArgs ` + -TimeoutSeconds $TimeoutSeconds ` + -LogPath $LogPath ` + -Label 'Run standalone test player' + + # Exit 2 means the player received no -uhTestResults arg (a harness-contract + # violation -- the harness always passes it), so no file can exist: fail fast. + if ($result.ExitCode -eq 2) { + throw "Standalone test player reported no -uhTestResults path (exit 2); no results were written. See the player log at $LogPath." + } + + # Do NOT throw on a watchdog timeout here. A player can write a complete results + # file in its RunFinished callback and then have Application.Quit deferred/ignored + # in -batchmode -nographics IL2CPP; the watchdog then tree-kills it (TimedOut) even + # though the results are valid. The caller validates the FILE (the source of truth) + # and decides, so a deferred-quit run is not turned into a spurious failure. + return @{ ExitCode = $result.ExitCode; TimedOut = $result.TimedOut } +} + +function Invoke-UnityEditor { + param( + [Parameter(Mandatory = $true)][string]$EditorPath, + [Parameter(Mandatory = $true)][string[]]$Arguments, + [Parameter(Mandatory = $true)][string]$Label, + [Parameter(Mandatory = $true)][string]$LogPath + ) + + # Unity.exe is a Windows GUI-subsystem binary. PowerShell's `&` launches such + # executables ASYNCHRONOUSLY: it does NOT wait for them and does NOT set + # $LASTEXITCODE. Callers therefore pass `-logFile -` (Unity logs to stdout) so + # that consuming the process's stdout via the pipeline forces PowerShell to + # BLOCK until the process exits AND reliably sets $LASTEXITCODE. Tee-Object both + # streams the log live to the CI console and persists it to $LogPath. This is + # the proven idiom from scripts/unity/run-tests.ps1. + $logDir = Split-Path -Parent $LogPath + if ($logDir -and -not (Test-Path -LiteralPath $logDir -PathType Container)) { + New-Item -ItemType Directory -Force -Path $logDir | Out-Null + } + + Write-Host "::group::$Label" + Write-Host "`"$EditorPath`" $($Arguments -join ' ')" + # Stream Unity's output LIVE to the console AND persist it to $LogPath, but route + # it to the HOST (Out-Host) so it never enters this function's success stream: + # the function RETURNS the exit code, and a bare `| Tee-Object` would otherwise + # collect every streamed log line into the caller's `$x = Invoke-UnityEditor` + # capture (turning the return value into an Object[] of log lines + the code). + # Consuming the process's stdout via the pipeline still forces PowerShell to + # BLOCK until the GUI-subsystem Unity.exe exits and to set $LASTEXITCODE. + & $EditorPath @Arguments 2>&1 | Tee-Object -FilePath $LogPath | Out-Host + $exitCode = $LASTEXITCODE + Write-Host "::endgroup::" + if ($exitCode -ne 0) { + # Proactively surface catastrophic compile-time failure patterns + # (PrecompiledAssemblyException, CompilationFailedException, CS####, + # CS8032) as ::error:: annotations so the operator sees the root cause + # in BOTH the runner log AND GitHub's error summary, independent of + # whether the workflow-level verify step also fires. On a benign + # shutdown-race crash the log matches no catastrophic pattern, so this + # is a no-op; on a real compile failure it names the root cause. + Write-UnityCatastrophicErrorAnnotations -LogPath $LogPath + } + # RETURN the exit code; do NOT throw on a non-zero value. The DURABLE ARTIFACT + # the invocation produces (the configure marker / the built player exe / the + # NUnit results.xml) is the source of truth, validated by the caller. Unity + # can crash in a BACKGROUND thread (for example the DirectoryMonitor file + # watcher) DURING shutdown AFTER the artifact is fully written, returning a + # crash exit code for an otherwise-successful run; gating on the artifact (not + # the exit code) makes those benign shutdown-race crashes non-fatal while a + # missing/invalid artifact still fails loudly. + return $exitCode +} + +# The Windows NTSTATUS codes a Unity batch process most commonly exits WITH when +# it crashes or aborts. Keyed by the canonical 8-char uppercase hex of the +# UNSIGNED exit code. This is the single source of truth for both the human +# description (Get-NativeExitCodeDescription) and the "is this a native crash +# code" classifier (Test-NativeCrashExitCode). Crash codes (the 0xC000xxxx +# family) are EXACTLY the benign post-work shutdown-race exits the +# artifact-is-source-of-truth gate tolerates when the durable artifact is valid. +$script:NativeExitCodeDescriptions = [ordered]@{ + 'C0000005' = 'STATUS_ACCESS_VIOLATION' + 'C000001D' = 'STATUS_ILLEGAL_INSTRUCTION' + 'C0000017' = 'STATUS_NO_MEMORY' + 'C00000FD' = 'STATUS_STACK_OVERFLOW' + 'C0000135' = 'STATUS_DLL_NOT_FOUND' + 'C0000139' = 'STATUS_ENTRYPOINT_NOT_FOUND' + 'C0000374' = 'STATUS_HEAP_CORRUPTION' + 'C0000409' = 'STATUS_STACK_BUFFER_OVERRUN' + 'C0000420' = 'STATUS_ASSERTION_FAILURE' +} + +function ConvertTo-UnsignedExitHex { + # Canonical 8-char uppercase hex of an exit code, normalizing the negative + # Int32 form PowerShell yields for a high-bit NTSTATUS (for example -1073741819 + # -> 'C0000005'). Compare against this STRING form, never the 0xC0000005 token: + # PowerShell parses `0xC0000005` as a NEGATIVE Int32, so a numeric -eq against + # the unsigned value silently fails (the int/uint conflation this whole helper + # exists to avoid). + param([Parameter(Mandatory = $true)][int]$ExitCode) + $normalized = if ($ExitCode -lt 0) { + [uint32]($ExitCode + 4294967296) + } else { + [uint32]$ExitCode + } + return $normalized.ToString('X8') +} + +function Test-NativeCrashExitCode { + # True when the exit code is a native Windows CRASH/abort NTSTATUS (the + # 0xC000xxxx severity-error family), i.e. a process the OS terminated rather + # than a value the app returned (0..255). Used ONLY to phrase the benign-exit + # ::warning:: accurately; the pass/fail decision is gated on the durable + # artifact, never on this classifier. + param([Parameter(Mandatory = $true)][int]$ExitCode) + $hexBare = ConvertTo-UnsignedExitHex -ExitCode $ExitCode + if ($script:NativeExitCodeDescriptions.Contains($hexBare)) { + return $true + } + # The 0xC000xxxx NTSTATUS family (STATUS_SEVERITY_ERROR + facility 0) covers + # the native crash/abort statuses a Unity batch process exits with. This is a + # best-effort classifier for the warning text ONLY; pass/fail is gated on the + # durable artifact, so a status outside this prefix is at worst a missing + # "(a native crash code)" note, never a wrong verdict. + return ($hexBare -like 'C0*') +} + +function Get-NativeExitCodeDescription { + param([Parameter(Mandatory = $true)][int]$ExitCode) + + $hexBare = ConvertTo-UnsignedExitHex -ExitCode $ExitCode + $hex = "0x$hexBare" + if ($script:NativeExitCodeDescriptions.Contains($hexBare)) { + return "$hex / $($script:NativeExitCodeDescriptions[$hexBare])" + } + + return $hex +} + +function Get-UnityCrashSignature { + # Best-effort: scan a captured Unity log for the signature of a BACKGROUND-thread + # crash that fired DURING shutdown, AFTER the batch work completed. Returns a + # short human description (for the benign-exit ::warning::) or '' when no crash + # signature is present. NEVER throws -- a diagnostic must not mask the real + # decision (which is gated on the durable artifact, not on this scan). + param([string]$LogPath) + + if (-not $LogPath -or -not (Test-Path -LiteralPath $LogPath -PathType Leaf)) { + return '' + } + try { + $logText = Get-Content -LiteralPath $LogPath -Raw + } catch { + return '' + } + if (-not $logText) { + return '' + } + + # The editor reached the end of batch execution before the crash -> the crash + # is in teardown, not in the work. (-quit prints this; -runTests prints the + # "Exiting batchmode successfully" variant.) + $cleanShutdown = ($logText -match 'Batchmode quit successfully invoked' -or + $logText -match 'Exiting batchmode successfully') + + # A known benign Windows shutdown-race: the DirectoryMonitor file-watcher + # thread faulting while the editor tears down. This is the crash observed on + # the 6000.3 standalone configure pass. + if ($logText -match 'DirectoryMonitor') { + $suffix = if ($cleanShutdown) { ' after a clean batch shutdown' } else { '' } + return "Unity DirectoryMonitor file-watcher thread crash during shutdown$suffix" + } + if ($logText -match 'Crash!!!') { + $suffix = if ($cleanShutdown) { ' after a clean batch shutdown' } else { '' } + return "Unity native crash during shutdown$suffix" + } + if ($cleanShutdown) { + return 'Unity completed its batch work (clean shutdown logged) before exiting non-zero' + } + return '' +} + +function Write-UnityBenignExitWarning { + # Emit a single ::warning:: when a Unity batch invocation produced a VALID + # durable artifact but still exited non-zero or was tree-killed by the + # watchdog. Decodes the exit code (for example 0xC0000005 / + # STATUS_ACCESS_VIOLATION) and names any crash signature found in the log, so + # the benign post-work shutdown crash stays VISIBLE and trackable in CI without + # failing the job. The artifact -- already validated by the caller -- is the + # source of truth; this only narrates why a non-zero exit was tolerated. + param( + [Parameter(Mandatory = $true)][string]$Label, + [int]$ExitCode = 0, + [switch]$TimedOut, + [string]$LogPath + ) + + $cause = if ($TimedOut) { + 'was tree-killed by the watchdog (likely a deferred Application.Quit)' + } else { + $description = Get-NativeExitCodeDescription -ExitCode $ExitCode + $crashNote = if (Test-NativeCrashExitCode -ExitCode $ExitCode) { ' (a native crash code)' } else { '' } + "exited with code $ExitCode / $description$crashNote" + } + $signature = Get-UnityCrashSignature -LogPath $LogPath + $signatureNote = if ($signature) { " Crash signature: $signature." } else { '' } + Write-Host "::warning::${Label}: Unity $cause AFTER producing a valid result artifact; honoring the artifact as the source of truth and treating this as a benign post-work shutdown crash.$signatureNote" +} + +function Test-UnityConfigureMarker { + # Validate the standalone-configure SUCCESS MARKER as the source of truth for + # the configure pass (UhCiTestConfigurator.Apply writes it as its final + # action). Returns '' when the marker exists and is FRESH for this run, else a + # short reason string (mirrors Test-StandalonePlayerBuildOutput's contract). + # A fresh marker proves Apply() ran to completion even if Unity then crashed in + # a background thread during shutdown and returned a crash exit code. + param( + [Parameter(Mandatory = $true)][string]$MarkerPath, + [Parameter(Mandatory = $true)][datetime]$StartedUtc + ) + + if (-not (Test-Path -LiteralPath $MarkerPath -PathType Leaf)) { + return 'configure marker was not written (UhCiTestConfigurator.Apply did not run to completion)' + } + $marker = Get-Item -LiteralPath $MarkerPath + if ($marker.LastWriteTimeUtc -lt $StartedUtc.AddSeconds(-5)) { + return "stale configure marker; LastWriteTimeUtc=$($marker.LastWriteTimeUtc.ToString('o'))" + } + return '' +} + +function Invoke-UnityNativeStartupProbe { + param( + [Parameter(Mandatory = $true)][string]$EditorPath, + [Parameter(Mandatory = $true)][string]$LogPath + ) + + $logDir = Split-Path -Parent $LogPath + if ($logDir -and -not (Test-Path -LiteralPath $logDir -PathType Container)) { + New-Item -ItemType Directory -Force -Path $logDir | Out-Null + } + + Write-Host "::group::Unity native startup diagnostics" + Write-Host "Runner name: $env:RUNNER_NAME" + Write-Host "Runner OS: $env:RUNNER_OS" + Write-Host "Runner architecture: $env:RUNNER_ARCH" + Write-Host "Unity editor path: $EditorPath" + try { + $editorItem = Get-Item -LiteralPath $EditorPath + Write-Host "Unity editor file version: $($editorItem.VersionInfo.FileVersion)" + Write-Host "Unity editor product version: $($editorItem.VersionInfo.ProductVersion)" + } catch { + Write-Host "::notice::Could not read Unity editor version info: $($_.Exception.Message)" + } + + Write-Host "Unity licensing client inventory:" + $licensingClientCandidates = New-Object System.Collections.Generic.List[string] + foreach ($root in @(${env:ProgramFiles}, ${env:ProgramFiles(x86)})) { + if ($root -and $root.Trim().Length -gt 0) { + $licensingClientCandidates.Add( + (Join-Path $root 'Common Files\Unity\UnityLicensingClient\Unity.Licensing.Client.exe') + ) + } + } + if ($env:LOCALAPPDATA -and $env:LOCALAPPDATA.Trim().Length -gt 0) { + $licensingClientCandidates.Add( + (Join-Path $env:LOCALAPPDATA 'Unity\Unity.Licensing.Client\Unity.Licensing.Client.exe') + ) + } + foreach ($candidate in $licensingClientCandidates) { + $exists = Test-Path -LiteralPath $candidate -PathType Leaf + Write-Host " [$exists] $candidate" + } + + $probeArgs = @( + '-version', + '-batchmode', + '-nographics', + '-quit', + '-logFile', '-' + ) + + Write-Host "`"$EditorPath`" $($probeArgs -join ' ')" + & $EditorPath @probeArgs 2>&1 | Tee-Object -FilePath $LogPath + $exitCode = $LASTEXITCODE + $description = Get-NativeExitCodeDescription -ExitCode $exitCode + Write-Host "Unity native startup probe exit code: $exitCode ($description)" + Write-Host "::endgroup::" + + if ($exitCode -ne 0) { + throw "Unity native startup probe failed with exit code $exitCode ($description) after the pre-lock healthy-existing editor check. CI Unity jobs do not repair editors in-job; run scripts/unity/maintain-windows-runner.ps1 or dispatch .github/workflows/runner-bootstrap.yml, then retry. See the streamed probe log above (also saved to $LogPath)." + } +} + +# CLASS-OF-ISSUE GUARD: the defect this whole change fixes is a single analyzer +# DLL handed to the compiler from MORE THAN ONE path (the Assets/Plugins copy plus +# a duplicate registration). That is invisible in a raw csc command line, so this +# best-effort scanner reads the Unity compile log, collects every analyzer the +# compiler was given (-a:/-analyzer:, quoted or not), and -- when the SAME DLL file +# name came from more than one distinct path -- names the offending DLL and every +# path. It catches a regression of the project-generation fix loudly. NEVER throws +# (the caller is already on a throw path). +function Write-DuplicateAnalyzerDiagnostics { + [CmdletBinding()] + param([Parameter(Mandatory = $true)][string]$LogPath) + + if (-not $LogPath -or -not (Test-Path -LiteralPath $LogPath -PathType Leaf)) { + return + } + + try { + # -a:"path" / -a:path / -analyzer:"path" / -analyzer:path. Captured lazily + # up to the first '.dll' so an unquoted, space-separated token does not + # swallow the next argument. + $pattern = '-(?:a|analyzer):"?([^"\r\n]+?\.dll)"?(?:"|\s|$)' + $pathsByName = @{} + $hits = @( + Select-String -LiteralPath $LogPath -Pattern $pattern -AllMatches -ErrorAction SilentlyContinue + ) + foreach ($hit in $hits) { + foreach ($match in $hit.Matches) { + $fullPath = $match.Groups[1].Value.Trim() -replace '\\', '/' + if (-not $fullPath) { + continue + } + $name = Split-Path -Leaf $fullPath + if (-not $pathsByName.ContainsKey($name)) { + $pathsByName[$name] = New-Object 'System.Collections.Generic.HashSet[string]' + } + [void]$pathsByName[$name].Add($fullPath) + } + } + + $duplicates = @($pathsByName.GetEnumerator() | Where-Object { $_.Value.Count -gt 1 }) + if ($duplicates.Count -lt 1) { + return + } + + Write-Host "::group::Duplicate analyzer registration" + foreach ($entry in $duplicates) { + $joinedPaths = (@($entry.Value) | Sort-Object) -join '; ' + Write-CiError ("Analyzer/source-generator '$($entry.Key)' was handed to the compiler from " + + "$($entry.Value.Count) distinct paths: $joinedPaths. A source generator that runs more than " + + "once emits each member twice (CS0102) and duplicate precompiled assemblies are rejected " + + "outright. The harness must register each analyzer DLL EXACTLY ONCE (the pre-created " + + "Assets/Plugins copy); it must NOT also wire one via csc.rsp.") + } + Write-Host "::endgroup::" + } catch { + Write-Host "::warning::Could not scan for duplicate analyzer registration: $($_.Exception.Message)" + } +} + +function Write-UnityResultFailureDiagnostics { + param( + [string]$LogPath, + [string]$Project, + [Parameter(Mandatory = $true)][string]$Label + ) + + Write-Host "::group::Unity result failure diagnostics ($Label)" + try { + if ($LogPath -and (Test-Path -LiteralPath $LogPath -PathType Leaf)) { + Write-Host "Unity log path: $LogPath" + # Compose this function's scan list as: + # (catastrophic patterns from the shared $script:CatastrophicPatterns + # array; ONLY the regex-form entries, since Select-String's + # -Pattern overload is regex when -SimpleMatch is absent) + # plus this function's local additions (Aborting/Exiting/No tests/ + # TestRunner/results.xml/assemblyNames) -- the latter are NOT + # catastrophic-class patterns and are intentionally NOT in the + # shared array. This keeps the "single source of truth" rule for + # the overlapping patterns (error CS\d+, warning CS8032) without + # changing the function's overall scan behavior. + $catastrophicRegexes = @( + foreach ($entry in $script:CatastrophicPatterns) { + if (-not $entry.UseSimple) { + $entry.Pattern + } + } + ) + $localDiagnosticPatterns = @( + 'Aborting batchmode', + 'Exiting batchmode successfully', + 'No tests', + 'TestRunner', + 'IPCStream \(Upm-[^)]+\): IPC stream failed to read', + 'Failed to resolve packages', + 'Cancelled resolving packages', + 'results\.xml', + 'assemblyNames' + ) + $diagnosticPatterns = @($catastrophicRegexes) + @($localDiagnosticPatterns) + $matches = @( + Select-String -LiteralPath $LogPath -Pattern $diagnosticPatterns -ErrorAction SilentlyContinue | + Select-Object -First 80 + ) + if ($matches.Count -gt 0) { + Write-Host "Selected Unity log lines:" + foreach ($match in $matches) { + Write-Host (" line {0}: {1}" -f $match.LineNumber, $match.Line.Trim()) + } + } else { + Write-Host "No targeted diagnostic lines matched in the Unity log." + } + + $logText = Get-Content -LiteralPath $LogPath -Raw + if ($logText -match 'warning CS8032') { + Write-CiError "Unity could not instantiate one or more unity-helpers analyzers/source generators (CS8032). Check that Editor/Analyzers DLLs target the Roslyn version supported by this Unity editor." + } + if ($logText -match 'error CS0315' -and $logText -match 'Simple(?:Untargeted|Targeted|Broadcast)Message') { + Write-CiError "Message fixture compile errors followed missing generated interfaces. This usually means the unity-helpers source generator did not load." + } + if ($logText -match 'Exiting batchmode successfully') { + Write-CiError "Unity exited with code 0 but did not write NUnit results. Check the selected assembly list, test platform, and TestRunner log lines above." + } + if (Test-UnityPackageManagerTransientFailure -LogPath $LogPath) { + Write-CiError "Unity Package Manager canceled package resolution before tests started. This is a CI/Unity package-resolution failure, not a unity-helpers test assertion." + Write-UnityPackageManagerDiagnostics -Project $Project -LogPath $LogPath + } + + # Name a duplicate analyzer registration (the same generator/analyzer + # DLL fed to csc from two paths) -- the precise root cause of the + # "Multiple precompiled assemblies" / CS0102 duplicate-'MessageType' + # failures this harness change fixes. + Write-DuplicateAnalyzerDiagnostics -LogPath $LogPath + } else { + Write-Host "Unity log path unavailable or missing: $LogPath" + } + + if ($Project) { + $analyzerCopyDir = Join-Path $Project 'Assets\Plugins\Editor\WallstopStudios.UnityHelpers' + Write-Host "Pre-created analyzer copy dir exists: $(Test-Path -LiteralPath $analyzerCopyDir -PathType Container)" + $scriptAssemblies = Join-Path $Project 'Library\ScriptAssemblies' + if (Test-Path -LiteralPath $scriptAssemblies -PathType Container) { + Write-Host "Script assemblies present:" + Get-ChildItem -LiteralPath $scriptAssemblies -Filter '*.dll' -ErrorAction SilentlyContinue | + Select-Object -ExpandProperty Name | + Sort-Object | + ForEach-Object { Write-Host " $_" } + } else { + Write-Host "Script assemblies directory missing: $scriptAssemblies" + } + } + } catch { + Write-Host "::warning::Could not collect Unity result failure diagnostics: $($_.Exception.Message)" + } + Write-Host "::endgroup::" +} + +function Write-UnityRunFailureDiagnostics { + # Emit the combined analyzer-setup + result-failure diagnostics for a Unity + # batch invocation whose DURABLE ARTIFACT validation failed (missing configure + # marker / invalid player exe / missing-or-invalid results.xml). This is the + # failure-path diagnostics bundle the retired Invoke-UnityEditorWithFailureDiagnostics + # wrapper used to emit on a thrown non-zero exit; it now fires from the + # artifact-validation failure branch (the exit code is no longer the trigger). + # Two callers: the configure marker-validation failure and the standalone build + # exe-validation failure (the latter then also emits Write-StandaloneBuildOutputDiagnostics). + # The editmode/playmode + standalone-player paths get the result-failure half + # directly from Test-NUnitResults, which calls Write-UnityResultFailureDiagnostics. + param( + [Parameter(Mandatory = $true)][string]$Project, + [Parameter(Mandatory = $true)][string]$LogPath, + [Parameter(Mandatory = $true)][string]$CscLabel, + [Parameter(Mandatory = $true)][string]$DiagnosticsLabel + ) + + Write-AnalyzerSetupDiagnostics -Project $Project -LogPath $LogPath -Label $CscLabel + Write-UnityResultFailureDiagnostics -LogPath $LogPath -Project $Project -Label $DiagnosticsLabel +} + +function Write-StandaloneDirectorySnapshot { + param( + [Parameter(Mandatory = $true)][string]$Label, + [Parameter(Mandatory = $true)][string]$Path, + [int]$MaxEntries = 60 + ) + + try { + Write-Host "${Label}: $Path" + if (-not (Test-Path -LiteralPath $Path -PathType Container)) { + Write-Host " (missing)" + return + } + + $entries = @( + Get-ChildItem -LiteralPath $Path -Recurse -Force -ErrorAction SilentlyContinue | + Sort-Object FullName | + Select-Object -First $MaxEntries + ) + if ($entries.Count -lt 1) { + Write-Host " (empty)" + return + } + + foreach ($entry in $entries) { + $kind = if ($entry.PSIsContainer) { 'dir ' } else { 'file' } + $length = if ($entry.PSIsContainer) { '' } else { " $($entry.Length) bytes" } + Write-Host " [$kind] $($entry.FullName)$length" + } + } catch { + Write-Host "::warning::Could not snapshot ${Label}: $($_.Exception.Message)" + } +} + +function Write-StandaloneBuildOutputDiagnostics { + param( + [Parameter(Mandatory = $true)][string]$Project, + [Parameter(Mandatory = $true)][string]$ExpectedExe, + [string]$LogPath, + [datetime]$BuildStartedUtc + ) + + Write-Host "::group::Standalone player build output diagnostics" + try { + Write-Host "Expected exe: $ExpectedExe" + Write-Host "UH_PLAYER_BUILD_PATH: $env:UH_PLAYER_BUILD_PATH" + Write-Host "Build started UTC: $($BuildStartedUtc.ToString('o'))" + + $expectedDir = Split-Path -Parent $ExpectedExe + Write-StandaloneDirectorySnapshot -Label 'Expected output directory' -Path $expectedDir + Write-StandaloneDirectorySnapshot -Label 'Project Build directory' -Path (Join-Path $Project 'Build') + Write-StandaloneDirectorySnapshot -Label 'Project Temp\UhTestPlayer directory' -Path (Join-Path $Project 'Temp\UhTestPlayer') + Write-StandaloneDirectorySnapshot -Label 'Project Temp\PlayerWithTests directory' -Path (Join-Path $Project 'Temp\PlayerWithTests') + + Write-Host "Discovered executable candidates under Build/Temp:" + $candidateRoots = @( + Join-Path $Project 'Build', + Join-Path $Project 'Temp' + ) + $candidates = @( + foreach ($root in $candidateRoots) { + if (Test-Path -LiteralPath $root -PathType Container) { + Get-ChildItem -LiteralPath $root -Recurse -Filter '*.exe' -File -ErrorAction SilentlyContinue + } + } + ) + if ($candidates.Count -lt 1) { + Write-Host " (none)" + } else { + foreach ($candidate in ($candidates | Sort-Object FullName | Select-Object -First 40)) { + Write-Host (" {0} ({1} bytes, LastWriteTimeUtc={2:o})" -f $candidate.FullName, $candidate.Length, $candidate.LastWriteTimeUtc) + } + } + + if ($LogPath -and (Test-Path -LiteralPath $LogPath -PathType Leaf)) { + $logText = Get-Content -LiteralPath $LogPath -Raw + Write-Host "Build log markers:" + foreach ($marker in @( + 'UhCiStandaloneBuildModifier', + 'UH_PLAYER_BUILD_PATH', + 'UhTestPlayer', + 'PlayerWithTests', + 'AutoRunPlayer', + 'CopyFiles' + )) { + Write-Host " ${marker}: $($logText.Contains($marker))" + } + Write-Host "Build log tail:" + Get-Content -LiteralPath $LogPath -Tail 80 -ErrorAction SilentlyContinue | + ForEach-Object { Write-Host " $_" } + } else { + Write-Host "Build log missing: $LogPath" + } + } catch { + Write-Host "::warning::Could not collect standalone player build diagnostics: $($_.Exception.Message)" + } + Write-Host "::endgroup::" +} + +function Test-StandalonePlayerBuildOutput { + param( + [Parameter(Mandatory = $true)][string]$ExpectedExe, + [Parameter(Mandatory = $true)][datetime]$BuildStartedUtc + ) + + if (-not (Test-Path -LiteralPath $ExpectedExe -PathType Leaf)) { + return "missing exe" + } + + $exe = Get-Item -LiteralPath $ExpectedExe + if ($exe.LastWriteTimeUtc -lt $BuildStartedUtc.AddSeconds(-5)) { + return "stale exe; LastWriteTimeUtc=$($exe.LastWriteTimeUtc.ToString('o'))" + } + + $dataDir = Join-Path (Split-Path -Parent $ExpectedExe) ("{0}_Data" -f [System.IO.Path]::GetFileNameWithoutExtension($ExpectedExe)) + if (-not (Test-Path -LiteralPath $dataDir -PathType Container)) { + return "missing player data directory: $dataDir" + } + + return '' +} + +function Test-NUnitResults { + # The NUnit results.xml is the SOLE source of truth for editmode/playmode and + # the standalone player run. $UnityExitCode is the process exit code of the + # editor/player that produced the file; it is ADVISORY only -- a valid passing + # results.xml means the run succeeded EVEN IF the process then exited non-zero + # (a benign background-thread shutdown-race crash after RunFinished already + # wrote the file). A missing/invalid/failing file still fails loudly, and the + # exit code is folded into the diagnostics so a crash-before-results is named. + param( + [Parameter(Mandatory = $true)][string]$Path, + [Parameter(Mandatory = $true)][string]$Label, + [string]$LogPath, + [string]$Project, + [int]$UnityExitCode = 0 + ) + + $exitNote = if ($UnityExitCode -ne 0) { + " Unity exited $UnityExitCode / $(Get-NativeExitCodeDescription -ExitCode $UnityExitCode) (the results FILE, not the exit code, is the source of truth)." + } else { + '' + } + + if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { + Write-CiError "No NUnit results XML exists at $Path for $Label.$exitNote" + Write-UnityResultFailureDiagnostics -LogPath $LogPath -Project $Project -Label $Label + throw "Unity did not produce NUnit results for $Label.$exitNote" + } + + [xml]$xml = Get-Content -LiteralPath $Path -Raw + $run = $xml.SelectSingleNode('//test-run') + if (-not $run) { + Write-CiError "NUnit results at $Path do not contain a element.$exitNote" + Write-UnityResultFailureDiagnostics -LogPath $LogPath -Project $Project -Label $Label + throw "Invalid NUnit results for $Label." + } + + $total = [int]$run.total + $passed = [int]$run.passed + $failed = [int]$run.failed + $skipped = [int]$run.skipped + + Write-Host "Results: total=$total passed=$passed failed=$failed skipped=$skipped" + if ($total -lt 1) { + Write-CiError "0 tests ran for $Label -- check assembly selection and package testables.$exitNote" + throw "0 tests ran for $Label." + } + if ($failed -gt 0) { + # Enumerate WHICH tests failed (fullname + message + stack) BEFORE the + # throw so the operator sees the actionable detail, not just the count. + # Best-effort inside the helper's own try/catch -- it never masks the + # real failure below. ($exitNote is intentionally omitted here: when tests + # genuinely failed, the named failing tests ARE the actionable signal and + # the producing process's exit code is noise. The exit note is folded into + # the missing-file / invalid / zero-test branches above, where the exit + # code IS the most informative remaining clue.) + Write-UnityFailedTestAnnotations -Xml $xml -Label $Label + Write-CiError "$failed tests failed for $Label." + throw "$failed tests failed for $Label." + } + + # PASS. If the producing process exited non-zero despite the valid passing + # file, narrate the benign post-work shutdown crash (and KEEP it green). + if ($UnityExitCode -ne 0) { + Write-UnityBenignExitWarning -Label $Label -ExitCode $UnityExitCode -LogPath $LogPath + } + Write-CiNotice "${Label}: total=$total passed=$passed failed=$failed skipped=$skipped" +} + +$RepoRoot = Resolve-FullPath -Path $RepoRoot +Assert-RepoRoot -Path $RepoRoot +$ArtifactsPath = Resolve-FullPath -Path $ArtifactsPath +New-Item -ItemType Directory -Force -Path $ArtifactsPath | Out-Null + +Initialize-UnityCacheEnvironment -Root $RepoRoot -Version $UnityVersion + +# Release is now the repo-wide Unity CI contract. The historical switches remain +# accepted for workflow/back-compat, but the effective mode is always Release: +# editor/test compilations get -releaseCodeOptimization, and standalone generated +# players omit BuildOptions.Development. +$UseReleaseCodeOptimization = $true +$UseReleasePlayerBuild = $true + +$ProjectPath = Initialize-EphemeralProject -Root $RepoRoot -Version $UnityVersion -Mode $TestMode -Path $ProjectPath -IncludeComparisons:$IncludeComparisons -Backend $StandaloneScriptingBackend -DevelopmentBuild:(-not $UseReleasePlayerBuild) -RepoRoot $RepoRoot +$LibraryPath = Join-Path $ProjectPath 'Library' +New-Item -ItemType Directory -Force -Path $LibraryPath | Out-Null + +Write-Host "::group::Ephemeral Unity project" +Write-Host "RepoRoot: $RepoRoot" +Write-Host "ProjectPath: $ProjectPath" +Write-Host "LibraryPath: $LibraryPath" +Write-Host "ArtifactsPath: $ArtifactsPath" +Write-Host "IncludeComparisons: $IncludeComparisons" +Write-Host "StandaloneScriptingBackend: $StandaloneScriptingBackend" +Write-Host "ReleasePlayerBuild: $UseReleasePlayerBuild" +Write-Host "ReleaseCodeOptimization: $UseReleaseCodeOptimization" +Write-Host "Manifest:" +Get-Content -LiteralPath (Join-Path $ProjectPath 'Packages\manifest.json') +Write-Host "Pre-created analyzer copy (Assets/Plugins/Editor/WallstopStudios.UnityHelpers):" +$analyzerCopyDir = Join-Path $ProjectPath 'Assets\Plugins\Editor\WallstopStudios.UnityHelpers' +if (Test-Path -LiteralPath $analyzerCopyDir -PathType Container) { + Get-ChildItem -LiteralPath $analyzerCopyDir -File | + Select-Object -ExpandProperty Name | + Sort-Object | + ForEach-Object { Write-Host " $_" } +} else { + Write-Host " (missing)" +} +Write-Host "::endgroup::" + +if ($GenerateOnly) { + Write-CiNotice "Generated ephemeral Unity project only: $ProjectPath" + exit 0 +} + +if (-not $UnityEditorPath -or $UnityEditorPath.Trim().Length -eq 0) { + $ensureEditor = Join-Path $PSScriptRoot 'ensure-editor.ps1' + $provisioningProfile = if ($TestMode -eq 'standalone') { 'StandaloneWindowsIl2Cpp' } else { 'EditorOnly' } + $ensureArgs = @{ + UnityVersion = $UnityVersion + InstallRoot = $UnityInstallRoot + ProvisioningProfile = $provisioningProfile + } + if ($env:GITHUB_ACTIONS -eq 'true') { + $ensureArgs.RequireHealthyExisting = $true + } + $UnityEditorPath = (& $ensureEditor @ensureArgs | Select-Object -Last 1) +} + +if (-not (Test-Path -LiteralPath $UnityEditorPath -PathType Leaf)) { + throw "Unity editor not found: $UnityEditorPath" +} + +# Export the resolved editor path so a workflow if:always() step (which runs in a +# SEPARATE process after this one exits) can run `Unity.exe -returnlicense` to +# return the seat as defense-in-depth. +if ($env:GITHUB_ENV) { + Add-Content -LiteralPath $env:GITHUB_ENV -Value "UNITY_EDITOR_PATH=$UnityEditorPath" +} + +# Classic SERIAL activation: the paid seat is activated from UNITY_SERIAL + +# UNITY_EMAIL + UNITY_PASSWORD and explicitly returned on EVERY exit path so the +# seat is never leaked. All three credentials are required together; we test each +# with IsNullOrWhiteSpace so a blank-but-set secret counts as missing. +$hasLicenseCreds = ( + -not [string]::IsNullOrWhiteSpace($env:UNITY_SERIAL) -and + -not [string]::IsNullOrWhiteSpace($env:UNITY_EMAIL) -and + -not [string]::IsNullOrWhiteSpace($env:UNITY_PASSWORD) +) +# In CI all three credentials are MANDATORY: a missing one means the editor would +# launch unlicensed and fail opaquely. The error names the missing VARS (never +# their values). Locally, missing creds is fine -- we assume the machine is +# already licensed (Hub sign-in / a local .ulf) and simply skip activate/return. +if ($env:GITHUB_ACTIONS -eq 'true' -and -not $hasLicenseCreds) { + $missing = @() + if ([string]::IsNullOrWhiteSpace($env:UNITY_SERIAL)) { $missing += 'UNITY_SERIAL' } + if ([string]::IsNullOrWhiteSpace($env:UNITY_EMAIL)) { $missing += 'UNITY_EMAIL' } + if ([string]::IsNullOrWhiteSpace($env:UNITY_PASSWORD)) { $missing += 'UNITY_PASSWORD' } + throw "Serial Unity activation requires UNITY_SERIAL, UNITY_EMAIL, and UNITY_PASSWORD in CI. Missing or empty: $($missing -join ', ')." +} + +# Array-wrap the capture so it is ALWAYS an array under Set-StrictMode -Version +# Latest. Get-AcceleratorArguments `return @()` on its empty path emits ZERO +# objects, so a bare `$x = Get-Foo` assigns AutomationNull (the empty array +# unwraps to nothing). Then reading `$x.Count` THROWS "property 'Count' cannot be +# found on this object" under StrictMode 2.0+ (verified on pwsh 7.6.1). @(...) +# forces Count 0 when empty so the read is safe. (The later `... + $x` concat was +# fine either way: `+` DROPS the empty/AutomationNull capture rather than adding +# it -- only a LITERAL $null operand would add a spurious element.) +$acceleratorArgs = @(Get-AcceleratorArguments -Endpoint $env:UNITY_ACCELERATOR_ENDPOINT -Version $UnityVersion -Mode $TestMode) +if ($acceleratorArgs.Count -gt 0) { + Write-CiNotice "Unity Accelerator enabled for namespace unity-helpers-$UnityVersion-$TestMode (endpoint normalized at the script boundary; value masked)." +} else { + Write-CiNotice "Unity Accelerator disabled; UNITY_ACCELERATOR_ENDPOINT is unset." +} + +$testPlatform = switch ($TestMode) { + 'editmode' { 'EditMode' } + 'playmode' { 'PlayMode' } + 'standalone' { 'StandaloneWindows64' } +} + +$categoryArgs = @() +if (-not [string]::IsNullOrWhiteSpace($TestCategory)) { + $categoryArgs = @('-testCategory', $TestCategory) + Write-CiNotice "Unity test category filter enabled: $TestCategory" +} else { + Write-CiNotice "Unity test category filter disabled." +} + +$resultsPath = Join-Path $ArtifactsPath 'results.xml' +$logPath = Join-Path $ArtifactsPath 'unity.log' +$configureLogPath = Join-Path $ArtifactsPath 'configure.log' +$startupProbeLogPath = Join-Path $ArtifactsPath 'unity-startup-probe.log' +# The standalone-configure SUCCESS MARKER: UhCiTestConfigurator.Apply writes it +# as its final action (path handed in via UH_CONFIGURE_MARKER_PATH). A fresh +# marker is the source of truth that the configure pass completed -- even if Unity +# then crashed in a background thread during shutdown and returned a crash exit +# code -- so we never fail a successful configure on a benign teardown crash. +$configureMarkerPath = Join-Path $ArtifactsPath 'configure-complete.marker' + +# STANDALONE split-build artifacts. The built IL2CPP player goes under a stable +# per-run project Build directory, not project Temp: Unity's test player build +# pipeline can populate Temp/PlayerWithTests or copy through Temp and then clean +# it before this script's post-build assertion runs. The player still stays out +# of $ArtifactsPath because a full IL2CPP player is hundreds of MB; only the +# small player log and NUnit XML are uploaded. +$standaloneExe = Join-Path $ProjectPath 'Build\UhTestPlayer\UhTestPlayer.exe' +$playerLogPath = Join-Path $ArtifactsPath 'player.log' + +# Activation/return carry the serial/email/password in their argument arrays and +# Unity may echo account/serial fragments into the activation log, so these logs +# MUST NOT live under $ArtifactsPath (the workflow uploads that as an artifact and +# the credentials would leak). Write them to a NON-uploaded temp dir instead. +$licenseLogDir = if ($env:RUNNER_TEMP) { $env:RUNNER_TEMP } else { [System.IO.Path]::GetTempPath() } +$activateLogPath = Join-Path $licenseLogDir "unity-activate-$UnityVersion-$TestMode.log" +$returnLogPath = Join-Path $licenseLogDir "unity-return-$UnityVersion-$TestMode.log" + +# Return-at-start (defense-in-depth): reclaim a seat that a PRIOR force-killed run +# on this persistent self-hosted runner may have leaked before its own finally / +# the workflow if:always() step could run. Best-effort and never throws; if no +# seat is held this is a harmless no-op. Done BEFORE the activate so we start each +# run from a clean licensing state. +if ($hasLicenseCreds) { + Invoke-UnityLicenseReturn -EditorPath $UnityEditorPath -Email $env:UNITY_EMAIL -Password $env:UNITY_PASSWORD -LogPath $returnLogPath +} + +try { + Invoke-UnityNativeStartupProbe -EditorPath $UnityEditorPath -LogPath $startupProbeLogPath + + # Activate the paid seat BEFORE configure/run so the test editor launches + # licensed. Activation THROWS on failure (caught by this try's finally, which + # still returns the seat). Skipped locally when creds are absent (the machine + # is assumed already licensed). + if ($hasLicenseCreds) { + Invoke-UnityLicenseActivate -EditorPath $UnityEditorPath -Serial $env:UNITY_SERIAL -Email $env:UNITY_EMAIL -Password $env:UNITY_PASSWORD -LogPath $activateLogPath + } + + if ($TestMode -eq 'standalone') { + # CONFIGURE the standalone IL2CPP project. The CONFIGURED PROJECT (proven by + # the success marker UhCiTestConfigurator.Apply writes as its final action) + # is the source of truth -- NOT Unity's process exit code. Delete any stale + # marker, hand the path in via UH_CONFIGURE_MARKER_PATH, and validate a + # FRESH marker after the run. A non-zero exit with a fresh marker is a benign + # post-work shutdown crash (for example the DirectoryMonitor file-watcher + # thread faulting during teardown, which returns 0xC0000005 even though the + # configuration fully succeeded); a MISSING marker is a real configure + # failure that fails loudly with the usual diagnostics. + if (Test-Path -LiteralPath $configureMarkerPath -PathType Leaf) { + Remove-Item -LiteralPath $configureMarkerPath -Force + } + $env:UH_CONFIGURE_MARKER_PATH = $configureMarkerPath + $configureStartedUtc = [DateTime]::UtcNow + $configureArgs = @( + '-quit', + '-batchmode', + '-nographics', + '-projectPath', $ProjectPath, + '-buildTarget', 'StandaloneWindows64', + '-executeMethod', 'UhCiTestConfigurator.Apply', + '-logFile', '-' + ) + $acceleratorArgs + $configureExit = Invoke-UnityEditor ` + -EditorPath $UnityEditorPath ` + -Arguments $configureArgs ` + -Label 'Configure standalone IL2CPP project' ` + -LogPath $configureLogPath + # The configurator has run; drop the marker-path env var so it cannot be + # inherited by the later build/player child processes (only Apply reads it, + # so this is hygiene against a future invocation accidentally writing it). + Remove-Item -LiteralPath Env:\UH_CONFIGURE_MARKER_PATH -ErrorAction SilentlyContinue + $configureProblem = Test-UnityConfigureMarker -MarkerPath $configureMarkerPath -StartedUtc $configureStartedUtc + if (-not [string]::IsNullOrWhiteSpace($configureProblem)) { + Write-UnityRunFailureDiagnostics ` + -Project $ProjectPath ` + -LogPath $configureLogPath ` + -CscLabel 'standalone configure' ` + -DiagnosticsLabel 'Unity standalone configure' + throw "Configure standalone IL2CPP project failed ($configureProblem; Unity exit code $configureExit / $(Get-NativeExitCodeDescription -ExitCode $configureExit)). See the streamed Unity log above (also saved to $configureLogPath)." + } + if ($configureExit -ne 0) { + Write-UnityBenignExitWarning -Label 'Configure standalone IL2CPP project' -ExitCode $configureExit -LogPath $configureLogPath + } + Write-AnalyzerSetupDiagnostics -Project $ProjectPath -LogPath $configureLogPath -Label 'standalone configure' + } + + if ($TestMode -eq 'standalone') { + # STANDALONE SPLIT BUILD + FILE-BASED RESULTS (zero PlayerConnection + # dependency). The legacy `-runTests -testPlatform StandaloneWindows64` flow + # had the built player stream NUnit results back to the editor over + # PlayerConnection/TCP; on the self-hosted runners' multi-NIC networks the + # player cannot reach the editor's listener (TcpProtobufClient errorcode + # 10060) and the editor's run never completes, hanging the 120-minute step. + # Instead we (2a) BUILD the player via the editor -- the generated + # UhCiStandaloneBuildModifier clears AutoRunPlayer|ConnectToHost| + # ConnectWithProfiler and IPostBuildCleanup exits the editor after the build + # -- then (2b) RUN the built exe directly, where the generated + # UhCiStandaloneTestCallback writes NUnit XML to -uhTestResults and quits, + # then (2c) validate the FILE (the source of truth). Both 2a and 2b run under + # the hard tree-kill watchdog so neither can hang to the step timeout. + + # (2a) BUILD. Set UH_PLAYER_BUILD_PATH so the modifier redirects the player + # output to a known path under the project's Build dir, then build with + # -runTests (so PlayerLauncher's ModifyBuildOptions reflection path fires) but + # NO -quit (the editor must reach PostBuildCleanup, which arms the exit). + $env:UH_PLAYER_BUILD_PATH = $standaloneExe + $standaloneBuildStartedUtc = [DateTime]::UtcNow + $standaloneExeDir = Split-Path -Parent $standaloneExe + if ($standaloneExeDir -and (Test-Path -LiteralPath $standaloneExeDir -PathType Container)) { + Remove-Item -LiteralPath $standaloneExeDir -Recurse -Force + } + if ($standaloneExeDir) { + New-Item -ItemType Directory -Force -Path $standaloneExeDir | Out-Null + } + if (Test-Path -LiteralPath $playerLogPath -PathType Leaf) { + Remove-Item -LiteralPath $playerLogPath -Force + } + $buildArgs = @( + '-batchmode', + '-nographics', + '-projectPath', $ProjectPath, + '-runTests', + '-testPlatform', 'StandaloneWindows64', + '-testResults', $resultsPath, + '-assemblyNames', $AssemblyNames, + '-releaseCodeOptimization', + '-buildTarget', 'StandaloneWindows64', + '-logFile', '-' + ) + $categoryArgs + $acceleratorArgs + + $buildResult = Invoke-ProcessWithTreeKillTimeout ` + -FilePath $UnityEditorPath ` + -Arguments $buildArgs ` + -TimeoutSeconds (Get-StandaloneBuildTimeoutSeconds) ` + -LogPath $logPath ` + -Label "Build standalone IL2CPP test player (Unity $UnityVersion)" + + # POST-BUILD ASSERT (the BUILT PLAYER EXE is the source of truth): the exe + # MUST exist at UH_PLAYER_BUILD_PATH, be fresh for this build, and include + # its companion _Data directory. A non-zero build exit code OR a watchdog + # tree-kill is fatal ONLY when the exe is missing/stale/incomplete: Unity can + # crash in a background thread during shutdown AFTER the player is fully + # built, or defer Application.Quit in -batchmode IL2CPP (the watchdog then + # tree-kills an already-finished build). Validating the exe FIRST -- before + # consulting the exit code -- keeps those benign post-build crashes from + # turning a good build red, while a genuinely failed build (which leaves no + # fresh, complete exe) still fails loudly with full diagnostics. + $standaloneBuildProblem = Test-StandalonePlayerBuildOutput ` + -ExpectedExe $standaloneExe ` + -BuildStartedUtc $standaloneBuildStartedUtc + if (-not [string]::IsNullOrWhiteSpace($standaloneBuildProblem)) { + Write-UnityRunFailureDiagnostics ` + -Project $ProjectPath ` + -LogPath $logPath ` + -CscLabel "$UnityVersion standalone build" ` + -DiagnosticsLabel "Unity $UnityVersion standalone build" + Write-StandaloneBuildOutputDiagnostics ` + -Project $ProjectPath ` + -ExpectedExe $standaloneExe ` + -LogPath $logPath ` + -BuildStartedUtc $standaloneBuildStartedUtc + if ($buildResult.TimedOut) { + throw "Standalone test-player build timed out and the process tree was killed before producing a valid player at $standaloneExe ($standaloneBuildProblem). Raise the limit via UH_STANDALONE_BUILD_TIMEOUT_SECONDS (0 disables the timeout). See the build log at $logPath." + } + throw "Editor build produced invalid unity-helpers test player output at $standaloneExe ($standaloneBuildProblem; build exit code $($buildResult.ExitCode) / $(Get-NativeExitCodeDescription -ExitCode $buildResult.ExitCode)). The build modifier may not have run, Unity may have cleaned a Temp output, or a stale player was detected. See the build log at $logPath." + } + # The exe is valid. If the build process nonetheless exited non-zero or was + # tree-killed, narrate the benign post-build shutdown crash and keep going. + if ($buildResult.TimedOut -or $buildResult.ExitCode -ne 0) { + Write-UnityBenignExitWarning -Label "Build standalone IL2CPP test player (Unity $UnityVersion)" -ExitCode $buildResult.ExitCode -TimedOut:$buildResult.TimedOut -LogPath $logPath + } + + # MISSED-CASE GUARD: even when the exe exists, scan the build log for the + # signatures of a NON-redirected AutoRun build (PlayerWithTests / + # AutoRunPlayer = True). If present, the modifier did not fully take and a + # live run may still attempt the 10060 dial-out -- surface a ::warning::. + if (Test-Path -LiteralPath $logPath -PathType Leaf) { + $buildLogText = Get-Content -LiteralPath $logPath -Raw + if ($buildLogText -match 'PlayerWithTests' -or $buildLogText -match 'options\.AutoRunPlayer = True') { + Write-Host "::warning::Standalone build log mentions PlayerWithTests / AutoRunPlayer = True; the UhCiStandaloneBuildModifier may not have fully suppressed the player auto-run. If the player run hangs on a TcpProtobufClient 10060, verify the modifier compiled." + } + } + + # Delete any STALE results file before the player runs, so the + # timeout-honors-file branch below can only honor results THIS player run + # wrote -- never a prior run's leftover. (Defensive for local re-runs against + # the same -ArtifactsPath; CI checkout already cleans the gitignored + # .artifacts tree per job.) + if (Test-Path -LiteralPath $resultsPath -PathType Leaf) { + Remove-Item -LiteralPath $resultsPath -Force + } + + # (2b) RUN the built exe directly (no PlayerConnection), under the watchdog. + $playerTimeoutSeconds = Get-StandaloneTestPlayerTimeoutSeconds + $playerResult = Invoke-StandaloneTestPlayer ` + -EditorBuiltExePath $standaloneExe ` + -ResultsPath $resultsPath ` + -LogPath $playerLogPath ` + -TimeoutSeconds $playerTimeoutSeconds + + # A watchdog timeout is fatal ONLY when the player wrote no results. If the + # results file exists, honor it as the source of truth (Application.Quit can be + # deferred in -batchmode -nographics IL2CPP after RunFinished already wrote the + # file) and fall through to Test-NUnitResults; otherwise fail with the timeout. + $playerExitForValidation = $playerResult.ExitCode + if ($playerResult.TimedOut) { + if (Test-Path -LiteralPath $resultsPath -PathType Leaf) { + Write-Host "::warning::Standalone test player exceeded the ${playerTimeoutSeconds}s watchdog and was tree-killed, but it had already written $resultsPath; honoring that results file as the source of truth (Application.Quit was likely deferred in -batchmode IL2CPP). Raise UH_STANDALONE_PLAYER_TIMEOUT_SECONDS if this recurs." + # The inline timeout warning above is the single, correctly-phrased + # notice for this case; pass exit 0 to the validator so it does NOT + # re-warn and MISLABEL the watchdog timeout (sentinel 124) as a + # native shutdown crash. + $playerExitForValidation = 0 + } else { + throw "Standalone test player timed out after $playerTimeoutSeconds second(s) and was tree-killed before writing any results to $resultsPath. Raise the limit via UH_STANDALONE_PLAYER_TIMEOUT_SECONDS (0 disables the timeout). See the player log at $playerLogPath." + } + } + + # (2c) VALIDATE the FILE (the source of truth). The player log carries the + # diagnostics for a missing/empty file (its stdout no longer flows through + # unity.log). The player exit code is advisory only: a valid passing file + # with a non-zero player exit gets a benign-crash ::warning::, not a failure. + Test-NUnitResults -Path $resultsPath -Label "Unity $UnityVersion standalone" -LogPath $playerLogPath -Project $ProjectPath -UnityExitCode $playerExitForValidation + } else { + # MUST NOT include '-quit' alongside '-runTests': per the Unity Editor manual + # (https://docs.unity3d.com/Manual/EditorCommandLineArguments.html), if the + # Editor is running tests with -runTests, -quit causes it to QUIT IMMEDIATELY + # before in-progress tests can complete -- the editor exits 0 having written + # no results.xml. + $testArgs = @( + '-batchmode', + '-nographics', + '-projectPath', $ProjectPath, + '-runTests', + '-testPlatform', $testPlatform, + '-testResults', $resultsPath, + '-assemblyNames', $AssemblyNames, + '-releaseCodeOptimization', + '-logFile', '-' + ) + $testArgs = $testArgs + $categoryArgs + $acceleratorArgs + + # Delete any STALE results file first so the file validation below can only + # honor results THIS run wrote (defensive for local re-runs; CI checkout + # already cleans the gitignored .artifacts tree per job). + if (Test-Path -LiteralPath $resultsPath -PathType Leaf) { + Remove-Item -LiteralPath $resultsPath -Force + } + + # Run the editor; capture (do NOT throw on) its exit code. The NUnit + # results.xml is the source of truth: Test-NUnitResults fails loudly on a + # missing/invalid/failing file AND folds the exit code into its diagnostics, + # but PASSES a valid run that exited non-zero only because Unity crashed in a + # background thread during shutdown AFTER RunFinished wrote the file. + $runExit = Invoke-UnityEditorTestsWithPackageManagerRetry ` + -EditorPath $UnityEditorPath ` + -Arguments $testArgs ` + -Label "Run Unity $UnityVersion $TestMode tests" ` + -LogPath $logPath ` + -ResultsPath $resultsPath ` + -Project $ProjectPath + Write-AnalyzerSetupDiagnostics -Project $ProjectPath -LogPath $logPath -Label "$UnityVersion $TestMode test compile" + Test-NUnitResults -Path $resultsPath -Label "Unity $UnityVersion $TestMode" -LogPath $logPath -Project $ProjectPath -UnityExitCode $runExit + } +} finally { + # Deterministic RETURN of the seat on EVERY exit path (clean exit, throw, or a + # kill that still unwinds this finally). The workflow if:always() step is the + # additional backstop for a hard-killed process that never reaches this finally, + # and the NEXT run's return-at-start reclaims anything still leaked. Best-effort + # and never throws, so it cannot mask a real test failure. + if ($hasLicenseCreds) { + Invoke-UnityLicenseReturn -EditorPath $UnityEditorPath -Email $env:UNITY_EMAIL -Password $env:UNITY_PASSWORD -LogPath $returnLogPath + } +} diff --git a/scripts/unity/run-ci-tests.ps1.meta b/scripts/unity/run-ci-tests.ps1.meta new file mode 100644 index 000000000..fec63775e --- /dev/null +++ b/scripts/unity/run-ci-tests.ps1.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 0097dd2eb4addf454e6a8a03622a1a32 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: From 87f9f444b716004b291a1560ec1c2ab175aca28c Mon Sep 17 00:00:00 2001 From: wallstop Date: Sun, 14 Jun 2026 01:49:00 +0000 Subject: [PATCH 03/31] test(perf): faster suite via statistical sampling + Performance/Stress taxonomy + lint - RandomTestBase: SampleCount 12.75M -> env-overridable 250k fast default (UH_RANDOM_SAMPLE_COUNT restores the full count for the benchmark job); add a sqrt(average) deviation floor so reduced-N runs stay reliably green while the large-N run keeps its original tighter sensitivity. Monte-Carlo validated. - Add lint rules UNH007 (giant literal loops outside perf fixtures), UNH008 (perf fixtures must carry [Category("Performance"|"Stress")] so the CI filter excludes them) and UNH009 (advisory: per-test AssetDatabase churn) + red-green tests. - Tag the 14 Tests/Runtime/Performance fixtures + 2 wall-clock pool tests Performance; trim the Proto RoundTripLargeCollections correctness test 100k -> 10k. - Add the test-performance skill. Co-Authored-By: Claude Opus 4.8 (1M context) --- .llm/skills/test-performance.md | 69 ++++++++ .../ConcaveHullPerformanceTests.cs | 1 + .../Performance/ConvexHullPerformanceTests.cs | 1 + .../IListSortingPerformanceTests.cs | 1 + .../JsonSerializationPerformanceTests.cs | 1 + .../ListExtensionPerformanceTests.cs | 1 + .../Performance/PerformanceBaselineTests.cs | 1 + .../PoolPurgeTriggerPerformanceTests.cs | 1 + .../ProtoEqualsPerformanceTests.cs | 1 + .../ProtoSerializationPerformanceTests.cs | 1 + .../Performance/RandomPerformanceTests.cs | 1 + .../Performance/ReflectionPerformanceTests.cs | 1 + .../RelationalComponentBenchmarkTests.cs | 1 + .../SpatialTree2DPerformanceTests.cs | 1 + .../SpatialTree3DPerformanceTests.cs | 1 + .../Runtime/Pool/RollingHighWaterMarkTests.cs | 1 + .../Runtime/Pool/WallstopGenericPoolTests.cs | 1 + Tests/Runtime/Random/RandomTestBase.cs | 41 ++++- .../ProtoSerializationCorrectnessTests.cs | 9 +- scripts/lint-tests.ps1 | 83 ++++++++++ scripts/tests/test-lint-tests.ps1 | 152 ++++++++++++++++++ 21 files changed, 365 insertions(+), 5 deletions(-) create mode 100644 .llm/skills/test-performance.md diff --git a/.llm/skills/test-performance.md b/.llm/skills/test-performance.md new file mode 100644 index 000000000..b7e10e1fb --- /dev/null +++ b/.llm/skills/test-performance.md @@ -0,0 +1,69 @@ +# Skill: Test Performance Budgets + + + +**Trigger**: When writing or reviewing tests that loop many times, measure throughput, sample randomness, or touch the AssetDatabase — and whenever lint reports UNH007/UNH008/UNH009. + +--- + +## The fast suite vs. the benchmark job + +CI runs the test matrix with the NUnit filter `UH_UNITY_TEST_CATEGORY="!Performance;!Stress"`, so anything +tagged `[Category("Performance")]` or `[Category("Stress")]` is **excluded from the main matrix** and runs +**only** in the dedicated `unity-benchmarks.yml` job (which opts those categories back in and sets +`UH_RANDOM_SAMPLE_COUNT` to the thorough value). Two consequences: + +- A genuine benchmark / soak / huge-iteration test MUST carry `Performance` or `Stress`, or it slows every PR. +- A **correctness** test must stay fast and stay in the main suite — do NOT tag it `Performance`/`Stress` just + to dodge a budget; reduce its cost instead. + +| Category | Meaning | Runs in main matrix? | +| ------------- | -------------------------------------------------- | -------------------- | +| `Performance` | Throughput/allocation benchmark (Stopwatch/budget) | No (benchmark job) | +| `Stress` | Very high-iteration robustness/soak | No (benchmark job) | +| (none) | Normal correctness test — must be fast | Yes | + +## Enforced rules (scripts/lint-tests.ps1) + +- **UNH007** (blocking): a literal loop bound `>= 50,000` in a test that is NOT in a `Performance`/`Stress` + fixture. Fix by reducing the count, moving the test into a perf fixture, or `// UNH-SUPPRESS` with a reason. + (Const/field bounds like `< SampleCount` are intentionally not matched.) +- **UNH008** (blocking): a fixture that lives under a `Performance/` folder or is named `*PerformanceTests` / + `*BenchmarkTests` MUST declare `[Category("Performance")]` (or `[Category("Stress")]`). +- **UNH009** (advisory, non-blocking): per-test `AssetDatabase.Refresh()` / `SaveAndReimport()` churns the + importer. Prefer `BatchedEditorTestBase`. Reported every run so churn stays visible; it does not fail the + build because converting a fixture to a batched base can change timing-dependent behaviour. + +## Smarter coverage, not brute force + +Big speedups come from sizing work to the goal, not from looping more: + +1. **Statistically-sized sampling.** `Tests/Runtime/Random/RandomTestBase.cs` is the model: a fast default + sample count for the main suite, an env-overridable thorough count (`UH_RANDOM_SAMPLE_COUNT`) for the + benchmark job, and a `sqrt(average)` deviation FLOOR so reduced-N runs stay reliably green while large-N + runs keep their original tighter sensitivity. A uniform-RNG sanity check needs far fewer samples than a + brute-force loop — pick N from the statistic (chi-square sufficiency is ~1k–10k), don't default to millions. +2. **Parametrize + parallelize.** Convert independent brute-force iterations into `[TestCase]`/`[Values]` cases + and mark pure, Unity-free tests `[Parallelizable]` — see [test-parallelization-rules](./test-parallelization-rules.md) + for the hard constraint (Editor/Unity-object tests must NOT be parallelized). +3. **Avoid AssetDatabase churn.** Prefer in-memory assets (`Tests/Core/TextureTestHelper.cs`) over importing + from disk; when you must touch assets, inherit `BatchedEditorTestBase` so a single refresh is deferred to + `OneTimeTearDown` instead of one per test. +4. **EditMode-first.** Pure C# logic belongs in EditMode (no Play-Mode domain reload). Reserve PlayMode for + runtime-only behaviour (physics, coroutines, Update). + +## Checklist before adding a heavy test + +- [ ] Is this measuring performance/throughput? → `[Category("Performance")]`. +- [ ] Is it a high-iteration soak/robustness run? → `[Category("Stress")]` (and consider an env-overridable count). +- [ ] Is it a correctness test? → keep it fast: size the loop to what the assertion actually needs (`< 50,000`), + or reduce data size; do not tag it perf to dodge UNH007. +- [ ] Touching assets? → inherit `BatchedEditorTestBase` and/or use in-memory `TextureTestHelper`. +- [ ] Run `pwsh -NoProfile -File scripts/lint-tests.ps1` — zero UNH007/UNH008, and review UNH009 advisories. + +## Related Skills + +- [test-parallelization-rules](./test-parallelization-rules.md) — when `[Parallelizable]` is allowed. +- [unity-performance-patterns](./unity-performance-patterns.md) — allocation/perf patterns in runtime code. +- [test-data-driven](./test-data-driven.md) — `[TestCase]`/`[ValueSource]` parametrization. +- [unity-devcontainer-testing](./unity-devcontainer-testing.md) — running the suite locally. diff --git a/Tests/Runtime/Performance/ConcaveHullPerformanceTests.cs b/Tests/Runtime/Performance/ConcaveHullPerformanceTests.cs index 0b8df505b..6aea7bb7b 100644 --- a/Tests/Runtime/Performance/ConcaveHullPerformanceTests.cs +++ b/Tests/Runtime/Performance/ConcaveHullPerformanceTests.cs @@ -12,6 +12,7 @@ namespace WallstopStudios.UnityHelpers.Tests.Runtime.Performance using WallstopStudios.UnityHelpers.Tests.TestUtils; [TestFixture] + [Category("Performance")] [NUnit.Framework.Category("Slow")] [NUnit.Framework.Category("Integration")] public sealed class ConcaveHullPerformanceTests : CommonTestBase diff --git a/Tests/Runtime/Performance/ConvexHullPerformanceTests.cs b/Tests/Runtime/Performance/ConvexHullPerformanceTests.cs index 8f6063a9d..de96f8038 100644 --- a/Tests/Runtime/Performance/ConvexHullPerformanceTests.cs +++ b/Tests/Runtime/Performance/ConvexHullPerformanceTests.cs @@ -13,6 +13,7 @@ namespace WallstopStudios.UnityHelpers.Tests.Runtime.Performance using WallstopStudios.UnityHelpers.Tests.TestUtils; [TestFixture] + [Category("Performance")] [NUnit.Framework.Category("Slow")] [NUnit.Framework.Category("Integration")] public sealed class ConvexHullPerformanceTests : CommonTestBase diff --git a/Tests/Runtime/Performance/IListSortingPerformanceTests.cs b/Tests/Runtime/Performance/IListSortingPerformanceTests.cs index bea965635..a458ad8f1 100644 --- a/Tests/Runtime/Performance/IListSortingPerformanceTests.cs +++ b/Tests/Runtime/Performance/IListSortingPerformanceTests.cs @@ -14,6 +14,7 @@ namespace WallstopStudios.UnityHelpers.Tests.Runtime.Performance using WallstopStudios.UnityHelpers.Core.Random; [TestFixture] + [Category("Performance")] [NUnit.Framework.Category("Slow")] [NUnit.Framework.Category("Integration")] public sealed class IListSortingPerformanceTests diff --git a/Tests/Runtime/Performance/JsonSerializationPerformanceTests.cs b/Tests/Runtime/Performance/JsonSerializationPerformanceTests.cs index 297e8f309..7c44cdcdd 100644 --- a/Tests/Runtime/Performance/JsonSerializationPerformanceTests.cs +++ b/Tests/Runtime/Performance/JsonSerializationPerformanceTests.cs @@ -10,6 +10,7 @@ namespace WallstopStudios.UnityHelpers.Tests.Runtime.Performance using SerializerAlias = WallstopStudios.UnityHelpers.Core.Serialization.Serializer; [TestFixture] + [Category("Performance")] [NUnit.Framework.Category("Slow")] [NUnit.Framework.Category("Integration")] public sealed class JsonSerializationPerformanceTests diff --git a/Tests/Runtime/Performance/ListExtensionPerformanceTests.cs b/Tests/Runtime/Performance/ListExtensionPerformanceTests.cs index 1db9cad27..d431cc5ce 100644 --- a/Tests/Runtime/Performance/ListExtensionPerformanceTests.cs +++ b/Tests/Runtime/Performance/ListExtensionPerformanceTests.cs @@ -12,6 +12,7 @@ namespace WallstopStudios.UnityHelpers.Tests.Runtime.Performance using WallstopStudios.UnityHelpers.Core.Random; [TestFixture] + [Category("Performance")] [NUnit.Framework.Category("Slow")] [NUnit.Framework.Category("Integration")] public sealed class ListExtensionPerformanceTests diff --git a/Tests/Runtime/Performance/PerformanceBaselineTests.cs b/Tests/Runtime/Performance/PerformanceBaselineTests.cs index 15278e443..3a9dfa33c 100644 --- a/Tests/Runtime/Performance/PerformanceBaselineTests.cs +++ b/Tests/Runtime/Performance/PerformanceBaselineTests.cs @@ -40,6 +40,7 @@ namespace WallstopStudios.UnityHelpers.Tests.Runtime.Performance /// [Ignore("These have to be created and measured properly.")] [TestFixture] + [Category("Performance")] [NUnit.Framework.Category("Slow")] [NUnit.Framework.Category("Integration")] public sealed class PerformanceBaselineTests diff --git a/Tests/Runtime/Performance/PoolPurgeTriggerPerformanceTests.cs b/Tests/Runtime/Performance/PoolPurgeTriggerPerformanceTests.cs index 3567beb65..9a465b1e1 100644 --- a/Tests/Runtime/Performance/PoolPurgeTriggerPerformanceTests.cs +++ b/Tests/Runtime/Performance/PoolPurgeTriggerPerformanceTests.cs @@ -17,6 +17,7 @@ namespace WallstopStudios.UnityHelpers.Tests.Runtime.Performance /// Guards against regression of GitHub issue #226 where the default OnRent trigger caused 119,752% over budget. /// [TestFixture] + [Category("Performance")] [NUnit.Framework.Category("Slow")] public sealed class PoolPurgeTriggerPerformanceTests { diff --git a/Tests/Runtime/Performance/ProtoEqualsPerformanceTests.cs b/Tests/Runtime/Performance/ProtoEqualsPerformanceTests.cs index 395bd39c9..723db0c4f 100644 --- a/Tests/Runtime/Performance/ProtoEqualsPerformanceTests.cs +++ b/Tests/Runtime/Performance/ProtoEqualsPerformanceTests.cs @@ -11,6 +11,7 @@ namespace WallstopStudios.UnityHelpers.Tests.Runtime.Performance using WallstopStudios.UnityHelpers.Core.Extension; [TestFixture] + [Category("Performance")] [NUnit.Framework.Category("Slow")] [NUnit.Framework.Category("Integration")] public sealed class ProtoEqualsPerformanceTests diff --git a/Tests/Runtime/Performance/ProtoSerializationPerformanceTests.cs b/Tests/Runtime/Performance/ProtoSerializationPerformanceTests.cs index 994afa063..723ae9aeb 100644 --- a/Tests/Runtime/Performance/ProtoSerializationPerformanceTests.cs +++ b/Tests/Runtime/Performance/ProtoSerializationPerformanceTests.cs @@ -11,6 +11,7 @@ namespace WallstopStudios.UnityHelpers.Tests.Runtime.Performance using SerializerAlias = WallstopStudios.UnityHelpers.Core.Serialization.Serializer; [TestFixture] + [Category("Performance")] [NUnit.Framework.Category("Slow")] [NUnit.Framework.Category("Integration")] public sealed class ProtoSerializationPerformanceTests diff --git a/Tests/Runtime/Performance/RandomPerformanceTests.cs b/Tests/Runtime/Performance/RandomPerformanceTests.cs index 28cc9959e..590371101 100644 --- a/Tests/Runtime/Performance/RandomPerformanceTests.cs +++ b/Tests/Runtime/Performance/RandomPerformanceTests.cs @@ -10,6 +10,7 @@ namespace WallstopStudios.UnityHelpers.Tests.Runtime.Performance using WallstopStudios.UnityHelpers.Core.Random; [TestFixture] + [Category("Performance")] [NUnit.Framework.Category("Slow")] [NUnit.Framework.Category("Integration")] public sealed class RandomPerformanceTests diff --git a/Tests/Runtime/Performance/ReflectionPerformanceTests.cs b/Tests/Runtime/Performance/ReflectionPerformanceTests.cs index 56ab6be61..4b8ba8a00 100644 --- a/Tests/Runtime/Performance/ReflectionPerformanceTests.cs +++ b/Tests/Runtime/Performance/ReflectionPerformanceTests.cs @@ -12,6 +12,7 @@ namespace WallstopStudios.UnityHelpers.Tests.Runtime.Performance using WallstopStudios.UnityHelpers.Core.Helper; [TestFixture] + [Category("Performance")] [NUnit.Framework.Category("Slow")] [NUnit.Framework.Category("Integration")] public sealed class ReflectionPerformanceTests diff --git a/Tests/Runtime/Performance/RelationalComponentBenchmarkTests.cs b/Tests/Runtime/Performance/RelationalComponentBenchmarkTests.cs index 0702744bb..731543797 100644 --- a/Tests/Runtime/Performance/RelationalComponentBenchmarkTests.cs +++ b/Tests/Runtime/Performance/RelationalComponentBenchmarkTests.cs @@ -14,6 +14,7 @@ namespace WallstopStudios.UnityHelpers.Tests.Runtime.Performance using WallstopStudios.UnityHelpers.Tests.Core; [TestFixture] + [Category("Performance")] [NUnit.Framework.Category("Slow")] [NUnit.Framework.Category("Integration")] public sealed class RelationalComponentBenchmarkTests : CommonTestBase diff --git a/Tests/Runtime/Performance/SpatialTree2DPerformanceTests.cs b/Tests/Runtime/Performance/SpatialTree2DPerformanceTests.cs index 43f0527c3..813aa22c6 100644 --- a/Tests/Runtime/Performance/SpatialTree2DPerformanceTests.cs +++ b/Tests/Runtime/Performance/SpatialTree2DPerformanceTests.cs @@ -16,6 +16,7 @@ namespace WallstopStudios.UnityHelpers.Tests.Runtime.Performance using WallstopStudios.UnityHelpers.Core.Extension; [TestFixture] + [Category("Performance")] [NUnit.Framework.Category("Slow")] [NUnit.Framework.Category("Integration")] public sealed class SpatialTree2DPerformanceTests diff --git a/Tests/Runtime/Performance/SpatialTree3DPerformanceTests.cs b/Tests/Runtime/Performance/SpatialTree3DPerformanceTests.cs index 609dbefa1..15cb1e2e0 100644 --- a/Tests/Runtime/Performance/SpatialTree3DPerformanceTests.cs +++ b/Tests/Runtime/Performance/SpatialTree3DPerformanceTests.cs @@ -16,6 +16,7 @@ namespace WallstopStudios.UnityHelpers.Tests.Runtime.Performance using WallstopStudios.UnityHelpers.Core.Extension; [TestFixture] + [Category("Performance")] [NUnit.Framework.Category("Slow")] [NUnit.Framework.Category("Integration")] public sealed class SpatialTree3DPerformanceTests diff --git a/Tests/Runtime/Pool/RollingHighWaterMarkTests.cs b/Tests/Runtime/Pool/RollingHighWaterMarkTests.cs index 0b647c219..90a5b1b3a 100644 --- a/Tests/Runtime/Pool/RollingHighWaterMarkTests.cs +++ b/Tests/Runtime/Pool/RollingHighWaterMarkTests.cs @@ -127,6 +127,7 @@ public void ClearResetsAllState() } [Test] + [Category("Performance")] public void HighVolumeInsertionCompletesWithinBudget() { RollingHighWaterMark hwm = new RollingHighWaterMark(300f); diff --git a/Tests/Runtime/Pool/WallstopGenericPoolTests.cs b/Tests/Runtime/Pool/WallstopGenericPoolTests.cs index 0823ee78a..c069a9f61 100644 --- a/Tests/Runtime/Pool/WallstopGenericPoolTests.cs +++ b/Tests/Runtime/Pool/WallstopGenericPoolTests.cs @@ -3153,6 +3153,7 @@ public void ExplicitOnRentTriggerStillWorksWhenConfigured() } [Test] + [Category("Performance")] public void DefaultPoolGetPerformanceIsAcceptable() { // Measure that the default pool (Periodic trigger) can do many Get/Return diff --git a/Tests/Runtime/Random/RandomTestBase.cs b/Tests/Runtime/Random/RandomTestBase.cs index df13f677b..615af0c41 100644 --- a/Tests/Runtime/Random/RandomTestBase.cs +++ b/Tests/Runtime/Random/RandomTestBase.cs @@ -31,7 +31,25 @@ public abstract class RandomTestBase { private const int NumGeneratorChecks = 1_000; private const int NormalIterations = 1_000; - private const int SampleCount = 12_750_000; + + // The distribution tests dominate suite runtime: each loops SampleCount + // times across ~21 PRNG subclasses (~half a billion iterations at the + // historical count). The fast default keeps the MAIN suite quick while + // staying statistically valid (see the sqrt deviation floor in + // TestAndVerify); the perf/stress CI job exports UH_RANDOM_SAMPLE_COUNT = + // ThoroughSampleCount to restore the original, tighter bias-detection + // sensitivity. Override the env var for a thorough local run. + private const int DefaultFastSampleCount = 250_000; + private const int ThoroughSampleCount = 12_750_000; + private static readonly int SampleCount = ResolveSampleCount(); + + // Floor for the allowed per-bin deviation, in standard deviations. Counts + // over `sampleLength` bins are ~Poisson(average) with stddev sqrt(average), + // so the natural max deviation grows like sqrt(average). A fixed RELATIVE + // tolerance (GetDeviationFor) is correct at large N but turns flaky as N + // shrinks; flooring at this many sigma keeps reduced-sample runs reliably + // green while large-N runs keep their original tolerance unchanged. + private const double DeviationSigmaFloor = 5.5; protected const uint DeterministicSeed32 = 0xC0FFEE11U; protected const ulong DeterministicSeed64 = 0x0123456789ABCDEFUL; protected const ulong DeterministicSeed64B = 0xF0E1D2C3B4A59687UL; @@ -820,6 +838,17 @@ protected int GetSampleLength(int? sampleLength = null) return Math.Min(_samples.Length, sampleLength ?? _samples.Length); } + private static int ResolveSampleCount() + { + string raw = Environment.GetEnvironmentVariable("UH_RANDOM_SAMPLE_COUNT"); + if (!string.IsNullOrWhiteSpace(raw) && int.TryParse(raw, out int parsed) && parsed > 0) + { + return parsed; + } + + return DefaultFastSampleCount; + } + private void TestAndVerify( Func sample, int? maxLength = null, @@ -843,7 +872,15 @@ private void TestAndVerify( sampleLength = GetSampleLength(maxLength); double average = SampleCount * 1.0 / sampleLength; - double deviationAllowed = average * GetDeviationFor(caller); + // Allow the LOOSER of the configured relative tolerance and a + // sqrt(average)-based statistical floor. At the historical SampleCount + // the relative term dominates (identical behaviour); at the reduced + // fast-suite count the sqrt floor prevents spurious failures from + // natural sampling variance across `sampleLength` bins. + double deviationAllowed = Math.Max( + average * GetDeviationFor(caller), + DeviationSigmaFloor * Math.Sqrt(average) + ); List zeroCountIndexes = new(); List outsideRange = new(); for (int i = 0; i < sampleLength; i++) diff --git a/Tests/Runtime/Serialization/ProtoSerializationCorrectnessTests.cs b/Tests/Runtime/Serialization/ProtoSerializationCorrectnessTests.cs index 1364ca70b..af04f72d9 100644 --- a/Tests/Runtime/Serialization/ProtoSerializationCorrectnessTests.cs +++ b/Tests/Runtime/Serialization/ProtoSerializationCorrectnessTests.cs @@ -470,11 +470,14 @@ public void RoundTripLargeCollections() { Id = 999, Name = "LargeCollection", - Values = new List(100_000), + Values = new List(10_000), Data = MakeBytes(1024 * 1024), // 1 MB }; - for (int i = 0; i < 100_000; ++i) + // 10k still exercises large-collection round-trip paths while keeping + // this correctness test in the fast suite (full-scale throughput is + // covered by the Performance-categorized benchmark suite). + for (int i = 0; i < 10_000; ++i) { msg.Values.Add(i); } @@ -484,7 +487,7 @@ public void RoundTripLargeCollections() Assert.AreEqual(msg.Id, clone.Id); Assert.AreEqual(msg.Name, clone.Name); - Assert.AreEqual(100_000, clone.Values.Count); + Assert.AreEqual(10_000, clone.Values.Count); CollectionAssert.AreEqual(msg.Values, clone.Values); Assert.AreEqual(1024 * 1024, clone.Data.Length); CollectionAssert.AreEqual(msg.Data, clone.Data); diff --git a/scripts/lint-tests.ps1 b/scripts/lint-tests.ps1 index 7dd6e1447..fb283f6a9 100644 --- a/scripts/lint-tests.ps1 +++ b/scripts/lint-tests.ps1 @@ -502,6 +502,10 @@ function Get-TestCaseSourceNamesFromAttributeBlock { } $violations = @() +# Non-blocking guidance (currently UNH009 asset-churn): reported every run but +# does NOT fail the build, so pre-existing churn is surfaced for cleanup without +# forcing a risky mass-conversion of fixtures to a batched base. +$advisories = @() $filesToScan = @() if ($Paths -and $Paths.Count -gt 0) { @@ -890,6 +894,85 @@ foreach ($file in $filesToScan) { } } } + + # ---- Test performance budgets (UNH007 / UNH008 / UNH009) ---- + # A fixture is "perf-tagged" when it declares the Performance or Stress + # category; those fixtures are EXCLUDED from the fast CI matrix + # (UH_UNITY_TEST_CATEGORY="!Performance;!Stress") and run only in the + # dedicated benchmark job, so heavy work is allowed there. + # Match both the short `[Category("Performance")]` and fully-qualified + # `[NUnit.Framework.Category("Performance")]` / `...CategoryAttribute(...)` forms. + # Use a COMMENT-MASKED (but NOT string-blanked) view: masking comments stops a + # commented-out `// [Category("Performance")]` from satisfying the rule, while + # preserving string contents keeps the "Performance"/"Stress" category name + # visible (the full $scrubbedText would blank it). + $categoryRegex = '\[\s*(?:NUnit\.Framework\.)?Category(?:Attribute)?\(\s*"(Performance|Stress)"\s*\)\s*\]' + $commentMaskedText = [string]::Join("`n", (Get-CommentMaskedLines -Lines ($text -split "`n", -1) -Language 'csharp')) + $perfCategory = [regex]::IsMatch($commentMaskedText, $categoryRegex) + + # UNH008: a fixture that LOOKS like a benchmark (lives under a Performance/ + # folder or is named *PerformanceTests / *BenchmarkTests) MUST carry the + # Performance or Stress category, otherwise the fast matrix would run it. + $looksPerf = ($rel -match '(^|/)Performance/') -or [regex]::IsMatch($scrubbedText, '\bclass\s+\w*(Performance|Benchmark)\w*Tests\b') + $isTestFile = [regex]::IsMatch($scrubbedText, '\[\s*Test\b|\[\s*TestFixture\b|\[\s*UnityTest\b') + if ($looksPerf -and $isTestFile -and -not $perfCategory -and ($text -notmatch 'UNH-SUPPRESS.*UNH008')) { + $violations += (@{ + Path=$rel; Line=1; Message='UNH008: Performance/benchmark fixture must declare [Category("Performance")] or [Category("Stress")] so the main CI matrix (which runs !Performance;!Stress) excludes it' + }) + } + + # UNH009 (ADVISORY, non-blocking): per-test AssetDatabase.Refresh()/ + # SaveAndReimport() churns the asset importer on every test. Prefer + # BatchedEditorTestBase (batches and defers a single refresh to + # OneTimeTearDown). Reported as guidance only — converting an existing + # fixture to a batched base can change timing-dependent behaviour and must be + # validated in the editor, so this never fails the build. Infra/base files + # (Tests/Core/**, *TestBase.cs) legitimately manage refreshes and are skipped. + $isInfra = ($rel -match '(^|/)Tests/Core/') -or ($rel -match 'TestBase\.cs$') + $batchedBase = ($text -match ':\s*(BatchedEditorTestBase|SpriteSheetExtractorTestBase|DetectAssetChangeTestBase)\b') + if (-not $batchedBase -and -not $isInfra) { + $lineIndex = 0 + foreach ($line in $content) { + $lineIndex++ + if ($line -match 'UNH-SUPPRESS') { continue } + $scrubbedLine = $scrubbedContent[$lineIndex - 1] + if ([regex]::IsMatch($scrubbedLine, 'AssetDatabase\.Refresh\s*\(|\.SaveAndReimport\s*\(')) { + $advisories += (@{ + Path=$rel; Line=$lineIndex; Message='UNH009: per-test AssetDatabase.Refresh()/SaveAndReimport() churns imports; prefer BatchedEditorTestBase (advisory)' + }) + } + } + } + + # UNH007: an enormous literal loop bound in a non-perf test belongs in a + # Performance/Stress fixture (excluded from the fast suite) or should be + # reduced. Const/field bounds (e.g. `< SampleCount`) are intentionally NOT + # matched — only raw literals. + if (-not $perfCategory) { + $lineIndex = 0 + foreach ($line in $content) { + $lineIndex++ + if ($line -match 'UNH-SUPPRESS') { continue } + $scrubbedLine = $scrubbedContent[$lineIndex - 1] + $loopMatch = [regex]::Match($scrubbedLine, '\bfor\s*\([^;]*;[^;]*<\s*=?\s*([0-9][0-9_]{2,})') + if ($loopMatch.Success) { + $boundText = $loopMatch.Groups[1].Value -replace '_', '' + [long]$bound = 0 + if ([long]::TryParse($boundText, [ref]$bound) -and $bound -ge 50000) { + $violations += (@{ + Path=$rel; Line=$lineIndex; Message="UNH007: loop of $bound iterations in a non-perf test; tag the fixture [Category(`"Stress`")]/[Category(`"Performance`")], reduce the count, or add // UNH-SUPPRESS" + }) + } + } + } + } +} + +if ($advisories.Count -gt 0) { + Write-Host "Test performance advisories (non-blocking): $($advisories.Count)" -ForegroundColor DarkYellow + foreach ($a in $advisories) { + Write-Host (" {0}:{1}: {2}" -f $a.Path, $a.Line, $a.Message) -ForegroundColor DarkYellow + } } if ($violations.Count -gt 0) { diff --git a/scripts/tests/test-lint-tests.ps1 b/scripts/tests/test-lint-tests.ps1 index ebd19667c..28b2c41ca 100644 --- a/scripts/tests/test-lint-tests.ps1 +++ b/scripts/tests/test-lint-tests.ps1 @@ -1166,6 +1166,158 @@ $r = Invoke-LintOnFixture -FixtureRelativePath 'CaseNearbyUnityTestDoesNotBleedI $ok = ($r.ExitCode -eq 0) -and ($r.Output -notmatch 'UNH006') Write-TestResult "UNH006.DoesNotUseNearbyUnityTestAttribute" $ok "Exit: $($r.ExitCode), Output: $($r.Output)" +# ── Test: UNH007 (giant literal loop bound in a non-perf test) ──────────────── +Write-Host "`n Section: UNH007 detection" -ForegroundColor White + +$unh007Pos = @' +namespace WallstopStudios.UnityHelpers.Tests +{ + using NUnit.Framework; + + public sealed class BigLoopTest : CommonTestBase + { + [Test] + public void Loop() + { + int sum = 0; + for (int i = 0; i < 60000; i++) + { + sum += i; + } + Assert.IsTrue(sum >= 0); + } + } +} +'@ +$r = Invoke-LintOnFixture -FixtureRelativePath 'BigLoopTest.cs' -FixtureContent $unh007Pos +Write-TestResult "UNH007.DetectsGiantLoop" (($r.ExitCode -ne 0) -and ($r.Output -match 'UNH007')) "Exit: $($r.ExitCode), Output: $($r.Output)" + +$unh007Neg = @' +namespace WallstopStudios.UnityHelpers.Tests +{ + using NUnit.Framework; + + [Category("Stress")] + public sealed class BigLoopStressTest : CommonTestBase + { + [Test] + public void Loop() + { + int sum = 0; + for (int i = 0; i < 60000; i++) + { + sum += i; + } + Assert.IsTrue(sum >= 0); + } + } +} +'@ +$r = Invoke-LintOnFixture -FixtureRelativePath 'BigLoopStressTest.cs' -FixtureContent $unh007Neg +Write-TestResult "UNH007.AllowsGiantLoopInStressFixture" (($r.ExitCode -eq 0) -and ($r.Output -notmatch 'UNH007')) "Exit: $($r.ExitCode), Output: $($r.Output)" + +# ── Test: UNH008 (perf-named fixture must carry Performance/Stress category) ── +Write-Host "`n Section: UNH008 detection" -ForegroundColor White + +$unh008Pos = @' +namespace WallstopStudios.UnityHelpers.Tests +{ + using NUnit.Framework; + + public sealed class WidgetPerformanceTests : CommonTestBase + { + [Test] + public void Bench() + { + Assert.IsTrue(true); + } + } +} +'@ +$r = Invoke-LintOnFixture -FixtureRelativePath 'WidgetPerformanceTests.cs' -FixtureContent $unh008Pos +Write-TestResult "UNH008.DetectsUntaggedPerfFixture" (($r.ExitCode -ne 0) -and ($r.Output -match 'UNH008')) "Exit: $($r.ExitCode), Output: $($r.Output)" + +$unh008Neg = @' +namespace WallstopStudios.UnityHelpers.Tests +{ + using NUnit.Framework; + + [Category("Performance")] + public sealed class WidgetPerformanceTaggedTests : CommonTestBase + { + [Test] + public void Bench() + { + Assert.IsTrue(true); + } + } +} +'@ +$r = Invoke-LintOnFixture -FixtureRelativePath 'WidgetPerformanceTaggedTests.cs' -FixtureContent $unh008Neg +Write-TestResult "UNH008.AllowsTaggedPerfFixture" (($r.ExitCode -eq 0) -and ($r.Output -notmatch 'UNH008')) "Exit: $($r.ExitCode), Output: $($r.Output)" + +# Fully-qualified [NUnit.Framework.Category("Performance")] must also satisfy the rule. +$unh008NegFq = @' +namespace WallstopStudios.UnityHelpers.Tests +{ + using NUnit.Framework; + + [NUnit.Framework.Category("Performance")] + public sealed class WidgetPerformanceFqTests : CommonTestBase + { + [Test] + public void Bench() + { + Assert.IsTrue(true); + } + } +} +'@ +$r = Invoke-LintOnFixture -FixtureRelativePath 'WidgetPerformanceFqTests.cs' -FixtureContent $unh008NegFq +Write-TestResult "UNH008.AllowsFullyQualifiedCategory" (($r.ExitCode -eq 0) -and ($r.Output -notmatch 'UNH008')) "Exit: $($r.ExitCode), Output: $($r.Output)" + +# ── Test: UNH009 (advisory, non-blocking AssetDatabase churn) ──────────────── +Write-Host "`n Section: UNH009 advisory" -ForegroundColor White + +$unh009Pos = @' +namespace WallstopStudios.UnityHelpers.Tests +{ + using NUnit.Framework; + using UnityEditor; + + public sealed class RefreshTest : CommonTestBase + { + [Test] + public void DoRefresh() + { + AssetDatabase.Refresh(); + } + } +} +'@ +$r = Invoke-LintOnFixture -FixtureRelativePath 'RefreshTest.cs' -FixtureContent $unh009Pos +# UNH009 is ADVISORY: it must appear in output but MUST NOT fail the build. +Write-TestResult "UNH009.AdvisoryNonBlocking" (($r.ExitCode -eq 0) -and ($r.Output -match 'UNH009')) "Exit: $($r.ExitCode), Output: $($r.Output)" + +$unh009Neg = @' +namespace WallstopStudios.UnityHelpers.Tests +{ + using NUnit.Framework; + using UnityEditor; + + public sealed class RefreshBatchedTest : BatchedEditorTestBase + { + [Test] + public void DoRefresh() + { + AssetDatabase.Refresh(); + } + } +} +'@ +$r = Invoke-LintOnFixture -FixtureRelativePath 'RefreshBatchedTest.cs' -FixtureContent $unh009Neg +Write-TestResult "UNH009.SkipsBatchedBase" (($r.ExitCode -eq 0) -and ($r.Output -notmatch 'UNH009')) "Exit: $($r.ExitCode), Output: $($r.Output)" + } finally { # ── Cleanup ────────────────────────────────────────────────────────────────── Remove-Item -Recurse -Force $tempDir -ErrorAction SilentlyContinue From efe6f6bdaa2a19931b7d4ade60fb1dc7f66d471e Mon Sep 17 00:00:00 2001 From: wallstop Date: Sun, 14 Jun 2026 02:09:54 +0000 Subject: [PATCH 04/31] fix(serialization): typed-exception contract + lint-clean the in-progress tests - SerializationFailureException (+ sealed subclasses); Serializer deserialize paths wrapped so every public entry point throws SerializationFailureException or has a Try* sibling, enforced by SerializerApiContractTests. - New contract/fuzz/Try-API/exception tests; rename test methods to PascalCase (UNH004) and replace Assert.IsNull/IsNotNull with Assert.IsTrue(== null / != null) (UNH005). - Update defensive-programming / use-serialization / forbidden-patterns concepts and add the serialization-safety skill (the documented "never throw" carve-out). - Trim the JSON RoundTripLargeCollections correctness test 100k -> 10k. - Drop stray .llm/skills/*.md.meta (skills carry no Unity .meta); regenerate skills index. Co-Authored-By: Claude Opus 4.8 (1M context) --- .llm/context.md | 183 ++++--- .llm/references/forbidden-patterns.md | 17 + .llm/skills/bash-pwsh-invocation.md.meta | 7 - .llm/skills/defensive-programming.md | 26 +- .llm/skills/serialization-safety.md | 185 +++++++ .llm/skills/use-serialization.md | 26 + .../SerializationFailureException.cs | 518 ++++++++++++++++++ .../SerializationFailureException.cs.meta | 10 + Runtime/Core/Serialization/Serializer.cs | 468 ++++++++++++++-- .../System.Collections.Immutable.dll.meta | 4 +- .../SerializableSetPropertyDrawerTests.cs | 1 + .../Serialization/JsonConverterTests.cs | 84 +-- .../JsonSerializationCorrectnessTests.cs | 15 +- .../ProtoInterfaceResolutionEdgeTests.cs | 5 +- .../Serialization/ProtoSerializationTests.cs | 42 +- .../SerializationFailureExceptionTests.cs | 242 ++++++++ ...SerializationFailureExceptionTests.cs.meta | 10 + .../SerializerAdditionalTests.cs | 26 +- .../SerializerApiContractTests.cs | 213 +++++++ .../SerializerApiContractTests.cs.meta | 10 + .../SerializerExceptionContractTests.cs | 355 ++++++++++++ .../SerializerExceptionContractTests.cs.meta | 10 + .../Serialization/SerializerFuzzTests.cs | 243 ++++++++ .../Serialization/SerializerFuzzTests.cs.meta | 10 + .../Serialization/SerializerTryApiTests.cs | 201 +++++++ .../SerializerTryApiTests.cs.meta | 10 + .../serialization/serialization-flow.svg.meta | 48 +- 27 files changed, 2714 insertions(+), 255 deletions(-) delete mode 100644 .llm/skills/bash-pwsh-invocation.md.meta create mode 100644 .llm/skills/serialization-safety.md create mode 100644 Runtime/Core/Serialization/SerializationFailureException.cs create mode 100644 Runtime/Core/Serialization/SerializationFailureException.cs.meta create mode 100644 Tests/Runtime/Serialization/SerializationFailureExceptionTests.cs create mode 100644 Tests/Runtime/Serialization/SerializationFailureExceptionTests.cs.meta create mode 100644 Tests/Runtime/Serialization/SerializerApiContractTests.cs create mode 100644 Tests/Runtime/Serialization/SerializerApiContractTests.cs.meta create mode 100644 Tests/Runtime/Serialization/SerializerExceptionContractTests.cs create mode 100644 Tests/Runtime/Serialization/SerializerExceptionContractTests.cs.meta create mode 100644 Tests/Runtime/Serialization/SerializerFuzzTests.cs create mode 100644 Tests/Runtime/Serialization/SerializerFuzzTests.cs.meta create mode 100644 Tests/Runtime/Serialization/SerializerTryApiTests.cs create mode 100644 Tests/Runtime/Serialization/SerializerTryApiTests.cs.meta diff --git a/.llm/context.md b/.llm/context.md index 376f7b663..880b9ed58 100644 --- a/.llm/context.md +++ b/.llm/context.md @@ -55,71 +55,73 @@ Invoke these skills for specific tasks. **Regenerate with**: `pwsh -NoProfile -File scripts/generate-skills-index.ps1` - + ### Core Skills (Always Consider) -| Skill | When to Use | -| -------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | -| [apply-completeness](./skills/apply-completeness.md) | Always do the complete thing when cost is near-zero | -| [ask-structured-questions](./skills/ask-structured-questions.md) | Present questions with context, options, and recommendations | -| [asset-postprocessor-safety](./skills/asset-postprocessor-safety.md) | AssetPostprocessor callbacks - avoid SendMessage warnings | -| [avoid-magic-strings](./skills/avoid-magic-strings.md) | ALL code - use nameof() not strings | -| [avoid-reflection](./skills/avoid-reflection.md) | ALL code - never reflect on our own types | -| [bash-pwsh-invocation](./skills/bash-pwsh-invocation.md) | Calling .ps1 scripts from bash/hooks/workflows | -| [create-csharp-file](./skills/create-csharp-file.md) | Creating any new .cs file | -| [create-editor-tool](./skills/create-editor-tool.md) | Creating Editor windows and inspectors | -| [create-enum](./skills/create-enum.md) | Creating a new enum type | -| [create-property-drawer](./skills/create-property-drawer.md) | Creating PropertyDrawers for custom attributes | -| [create-scriptable-object](./skills/create-scriptable-object.md) | Creating ScriptableObject data assets | -| [create-test](./skills/create-test.md) | Writing or modifying test files | -| [create-unity-meta](./skills/create-unity-meta.md) | After creating ANY new file or folder | -| [defensive-editor-programming](./skills/defensive-editor-programming.md) | Editor code - handle Unity Editor edge cases | -| [defensive-programming](./skills/defensive-programming.md) | ALL code - never throw, handle gracefully | -| [documentation-consistency](./skills/documentation-consistency.md) | When writing or reviewing documentation | -| [editor-api-rules](./skills/editor-api-rules.md) | Forbidden Editor APIs and value handling rules | -| [editor-caching-patterns](./skills/editor-caching-patterns.md) | Caching strategies for Editor code | -| [editor-multi-object-editing](./skills/editor-multi-object-editing.md) | Multi-object editing patterns and undo support for editor code | -| [editor-singleton-patterns](./skills/editor-singleton-patterns.md) | Singleton asset management patterns for Editor code | -| [editor-undo-complete](./skills/editor-undo-complete.md) | Complete undo policy for editor tooling with enforceable scope boundaries | -| [formatting](./skills/formatting.md) | After ANY file change (CSharpier/Prettier) | -| [formatting-and-linting](./skills/formatting-and-linting.md) | Before committing, after editing files | -| [git-hook-lifecycle-debugging](./skills/git-hook-lifecycle-debugging.md) | Hook validation philosophy, framework config, PowerShell exit codes, debugging | -| [git-hook-patterns](./skills/git-hook-patterns.md) | Git hook safety, syntax, and debugging patterns (hub) | -| [git-hook-safety](./skills/git-hook-safety.md) | Hook index safety, permissions, and execution templates | -| [git-hook-syntax-portability](./skills/git-hook-syntax-portability.md) | Hook regex, CLI safety, CRLF handling, portable grep patterns | -| [git-safe-operations](./skills/git-safe-operations.md) | Scripts or hooks that interact with git index | -| [git-staging-helpers](./skills/git-staging-helpers.md) | PowerShell/Bash helpers for safe git staging | -| [github-actions-shell-foundations](./skills/github-actions-shell-foundations.md) | Core shell scripting safety for GitHub Actions | -| [github-actions-shell-scripting](./skills/github-actions-shell-scripting.md) | Shell scripting best practices for GitHub Actions | -| [github-actions-shell-workflow-patterns](./skills/github-actions-shell-workflow-patterns.md) | Workflow integration patterns for GitHub Actions shell steps | -| [high-performance-csharp](./skills/high-performance-csharp.md) | ALL code - zero allocation patterns | -| [investigate-test-failures](./skills/investigate-test-failures.md) | ANY test failure - investigate before fixing | -| [license-headers](./skills/license-headers.md) | Maintaining MIT license headers in C# files | -| [linter-reference](./skills/linter-reference.md) | Detailed linter commands, configurations | -| [manage-skills](./skills/manage-skills.md) | Creating, updating, splitting, consolidating, or removing skills | -| [markdown-reference](./skills/markdown-reference.md) | Link formatting, escaping, linting rules | -| [no-regions](./skills/no-regions.md) | ALL C# code - never use #region/#endregion | -| [odin-undo-safety](./skills/odin-undo-safety.md) | Safe undo recording patterns for Odin Inspector drawers | -| [optimize-git-hooks](./skills/optimize-git-hooks.md) | How to keep git hooks fast | -| [prefer-logging-extensions](./skills/prefer-logging-extensions.md) | Unity logging in UnityEngine.Object classes | -| [property-drawer-examples](./skills/property-drawer-examples.md) | Property drawer code examples | -| [property-drawer-rules](./skills/property-drawer-rules.md) | PropertyDrawer critical rules and requirements | -| [review-code-changes](./skills/review-code-changes.md) | Pre-landing code review with two-pass analysis | -| [review-plan](./skills/review-plan.md) | Engineering review of implementation plans | -| [run-retrospective](./skills/run-retrospective.md) | Structured retrospective analyzing what happened, what worked, and what to improve | -| [search-codebase](./skills/search-codebase.md) | Finding code, files, or patterns | -| [self-regulate-changes](./skills/self-regulate-changes.md) | Know when to stop: risk scoring and hard caps for cascading changes | -| [ship-changes](./skills/ship-changes.md) | End-to-end workflow for shipping changes: validate, review, version, changelog, commit | -| [test-data-driven](./skills/test-data-driven.md) | Data-driven testing with TestCase and TestCaseSource | -| [test-naming-conventions](./skills/test-naming-conventions.md) | Test method and TestName naming rules | -| [test-odin-drawers](./skills/test-odin-drawers.md) | Odin Inspector drawer testing patterns | -| [test-parallelization-rules](./skills/test-parallelization-rules.md) | Unity Editor test threading constraints | -| [test-unity-lifecycle](./skills/test-unity-lifecycle.md) | Track(), DestroyImmediate, object cleanup | -| [update-documentation](./skills/update-documentation.md) | After ANY feature/bug fix/API change | -| [validate-before-commit](./skills/validate-before-commit.md) | Before completing any task (run linters!) | -| [validation-troubleshooting](./skills/validation-troubleshooting.md) | Common validation errors, CI failures, fixes | +| Skill | When to Use | +| -------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | +| [apply-completeness](./skills/apply-completeness.md) | Always do the complete thing when cost is near-zero | +| [ask-structured-questions](./skills/ask-structured-questions.md) | Present questions with context, options, and recommendations | +| [asset-postprocessor-safety](./skills/asset-postprocessor-safety.md) | AssetPostprocessor callbacks - avoid SendMessage warnings | +| [avoid-magic-strings](./skills/avoid-magic-strings.md) | ALL code - use nameof() not strings | +| [avoid-reflection](./skills/avoid-reflection.md) | ALL code - never reflect on our own types | +| [bash-pwsh-invocation](./skills/bash-pwsh-invocation.md) | Calling .ps1 scripts from bash/hooks/workflows | +| [create-csharp-file](./skills/create-csharp-file.md) | Creating any new .cs file | +| [create-editor-tool](./skills/create-editor-tool.md) | Creating Editor windows and inspectors | +| [create-enum](./skills/create-enum.md) | Creating a new enum type | +| [create-property-drawer](./skills/create-property-drawer.md) | Creating PropertyDrawers for custom attributes | +| [create-scriptable-object](./skills/create-scriptable-object.md) | Creating ScriptableObject data assets | +| [create-test](./skills/create-test.md) | Writing or modifying test files | +| [create-unity-meta](./skills/create-unity-meta.md) | After creating ANY new file or folder | +| [defensive-editor-programming](./skills/defensive-editor-programming.md) | Editor code - handle Unity Editor edge cases | +| [defensive-programming](./skills/defensive-programming.md) | ALL code - never throw, handle gracefully | +| [documentation-consistency](./skills/documentation-consistency.md) | When writing or reviewing documentation | +| [editor-api-rules](./skills/editor-api-rules.md) | Forbidden Editor APIs and value handling rules | +| [editor-caching-patterns](./skills/editor-caching-patterns.md) | Caching strategies for Editor code | +| [editor-multi-object-editing](./skills/editor-multi-object-editing.md) | Multi-object editing patterns and undo support for editor code | +| [editor-singleton-patterns](./skills/editor-singleton-patterns.md) | Singleton asset management patterns for Editor code | +| [editor-undo-complete](./skills/editor-undo-complete.md) | Complete undo policy for editor tooling with enforceable scope boundaries | +| [formatting](./skills/formatting.md) | After ANY file change (CSharpier/Prettier) | +| [formatting-and-linting](./skills/formatting-and-linting.md) | Before committing, after editing files | +| [git-hook-lifecycle-debugging](./skills/git-hook-lifecycle-debugging.md) | Hook validation philosophy, framework config, PowerShell exit codes, debugging | +| [git-hook-patterns](./skills/git-hook-patterns.md) | Git hook safety, syntax, and debugging patterns (hub) | +| [git-hook-safety](./skills/git-hook-safety.md) | Hook index safety, permissions, and execution templates | +| [git-hook-syntax-portability](./skills/git-hook-syntax-portability.md) | Hook regex, CLI safety, CRLF handling, portable grep patterns | +| [git-safe-operations](./skills/git-safe-operations.md) | Scripts or hooks that interact with git index | +| [git-staging-helpers](./skills/git-staging-helpers.md) | PowerShell/Bash helpers for safe git staging | +| [github-actions-shell-foundations](./skills/github-actions-shell-foundations.md) | Core shell scripting safety for GitHub Actions | +| [github-actions-shell-scripting](./skills/github-actions-shell-scripting.md) | Shell scripting best practices for GitHub Actions | +| [github-actions-shell-workflow-patterns](./skills/github-actions-shell-workflow-patterns.md) | Workflow integration patterns for GitHub Actions shell steps | +| [high-performance-csharp](./skills/high-performance-csharp.md) | ALL code - zero allocation patterns | +| [investigate-test-failures](./skills/investigate-test-failures.md) | ANY test failure - investigate before fixing | +| [license-headers](./skills/license-headers.md) | Maintaining MIT license headers in C# files | +| [linter-reference](./skills/linter-reference.md) | Detailed linter commands, configurations | +| [manage-skills](./skills/manage-skills.md) | Creating, updating, splitting, consolidating, or removing skills | +| [markdown-reference](./skills/markdown-reference.md) | Link formatting, escaping, linting rules | +| [no-regions](./skills/no-regions.md) | ALL C# code - never use #region/#endregion | +| [odin-undo-safety](./skills/odin-undo-safety.md) | Safe undo recording patterns for Odin Inspector drawers | +| [optimize-git-hooks](./skills/optimize-git-hooks.md) | How to keep git hooks fast | +| [prefer-logging-extensions](./skills/prefer-logging-extensions.md) | Unity logging in UnityEngine.Object classes | +| [property-drawer-examples](./skills/property-drawer-examples.md) | Property drawer code examples | +| [property-drawer-rules](./skills/property-drawer-rules.md) | PropertyDrawer critical rules and requirements | +| [review-code-changes](./skills/review-code-changes.md) | Pre-landing code review with two-pass analysis | +| [review-plan](./skills/review-plan.md) | Engineering review of implementation plans | +| [run-retrospective](./skills/run-retrospective.md) | Structured retrospective analyzing what happened, what worked, and what to improve | +| [search-codebase](./skills/search-codebase.md) | Finding code, files, or patterns | +| [self-regulate-changes](./skills/self-regulate-changes.md) | Know when to stop: risk scoring and hard caps for cascading changes | +| [serialization-safety](./skills/serialization-safety.md) | Serializer exception contract — every entry point throws SerializationFailureException or has a Try sibling | +| [ship-changes](./skills/ship-changes.md) | End-to-end workflow for shipping changes: validate, review, version, changelog, commit | +| [test-data-driven](./skills/test-data-driven.md) | Data-driven testing with TestCase and TestCaseSource | +| [test-naming-conventions](./skills/test-naming-conventions.md) | Test method and TestName naming rules | +| [test-odin-drawers](./skills/test-odin-drawers.md) | Odin Inspector drawer testing patterns | +| [test-parallelization-rules](./skills/test-parallelization-rules.md) | Unity Editor test threading constraints | +| [test-performance](./skills/test-performance.md) | Keep the main test suite fast: categorize/exclude perf tests, size samples statistically, batch asset I/O | +| [test-unity-lifecycle](./skills/test-unity-lifecycle.md) | Track(), DestroyImmediate, object cleanup | +| [update-documentation](./skills/update-documentation.md) | After ANY feature/bug fix/API change | +| [validate-before-commit](./skills/validate-before-commit.md) | Before completing any task (run linters!) | +| [validation-troubleshooting](./skills/validation-troubleshooting.md) | Common validation errors, CI failures, fixes | ### Performance Skills @@ -141,35 +143,36 @@ Invoke these skills for specific tasks. ### Feature Skills -| Skill | When to Use | -| ------------------------------------------------------------------------------ | ----------------------------------------------------------------- | -| [add-inspector-attribute](./skills/add-inspector-attribute.md) | Improving editor UX with attributes | -| [debug-il2cpp](./skills/debug-il2cpp.md) | IL2CPP build issues or AOT errors | -| [devcontainer-volume-permissions](./skills/devcontainer-volume-permissions.md) | Docker volume permission fixes for non-root devcontainer users | -| [github-actions-script-pattern](./skills/github-actions-script-pattern.md) | Extract GHA logic to testable scripts | -| [github-pages](./skills/github-pages.md) | GitHub Pages, Jekyll, markdown link format | -| [github-pages-theming](./skills/github-pages-theming.md) | GitHub Pages CSS theming, Jekyll theme customization | -| [github-workflow-permissions](./skills/github-workflow-permissions.md) | Workflow permissions, automated PRs, debugging | -| [integrate-odin-inspector](./skills/integrate-odin-inspector.md) | Odin Inspector integration patterns | -| [integrate-optional-dependency](./skills/integrate-optional-dependency.md) | Odin, VContainer, Zenject integration patterns | -| [manage-assembly-definitions](./skills/manage-assembly-definitions.md) | Assembly definition creation, splitting, and reference management | -| [unity-devcontainer-testing](./skills/unity-devcontainer-testing.md) | Compile and test Unity C# code in devcontainer | -| [use-algorithmic-structures](./skills/use-algorithmic-structures.md) | Connectivity, prefix search, bit manipulation, caching | -| [use-data-structures](./skills/use-data-structures.md) | Selecting appropriate data structures | -| [use-discriminated-union](./skills/use-discriminated-union.md) | OneOf/Result types, type-safe unions | -| [use-effects-system](./skills/use-effects-system.md) | Buffs, debuffs, stat modifications | -| [use-extension-methods](./skills/use-extension-methods.md) | Collection, string, color utilities | -| [use-priority-structures](./skills/use-priority-structures.md) | Priority ordering or task scheduling | -| [use-prng](./skills/use-prng.md) | Implementing randomization | -| [use-queue-structures](./skills/use-queue-structures.md) | Rolling history, double-ended queues | -| [use-relational-attributes](./skills/use-relational-attributes.md) | Auto-wiring components via hierarchy | -| [use-serializable-types](./skills/use-serializable-types.md) | Dictionaries, HashSets, Nullable, Type, Guid | -| [use-serializable-types-patterns](./skills/use-serializable-types-patterns.md) | Common patterns for serializable collections | -| [use-serialization](./skills/use-serialization.md) | Save files, network, persistence | -| [use-singleton](./skills/use-singleton.md) | Global managers, service locators, configuration | -| [use-spatial-structure](./skills/use-spatial-structure.md) | Spatial queries or proximity logic | -| [use-threading](./skills/use-threading.md) | Main thread dispatch, thread safety | -| [wiki-generation](./skills/wiki-generation.md) | GitHub Wiki deployment, sidebar links | +| Skill | When to Use | +| ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------ | +| [add-inspector-attribute](./skills/add-inspector-attribute.md) | Improving editor UX with attributes | +| [debug-il2cpp](./skills/debug-il2cpp.md) | IL2CPP build issues or AOT errors | +| [devcontainer-volume-permissions](./skills/devcontainer-volume-permissions.md) | Docker volume permission fixes for non-root devcontainer users | +| [github-actions-script-pattern](./skills/github-actions-script-pattern.md) | Extract GHA logic to testable scripts | +| [github-pages](./skills/github-pages.md) | GitHub Pages, Jekyll, markdown link format | +| [github-pages-theming](./skills/github-pages-theming.md) | GitHub Pages CSS theming, Jekyll theme customization | +| [github-workflow-permissions](./skills/github-workflow-permissions.md) | Workflow permissions, automated PRs, debugging | +| [integrate-odin-inspector](./skills/integrate-odin-inspector.md) | Odin Inspector integration patterns | +| [integrate-optional-dependency](./skills/integrate-optional-dependency.md) | Odin, VContainer, Zenject integration patterns | +| [manage-assembly-definitions](./skills/manage-assembly-definitions.md) | Assembly definition creation, splitting, and reference management | +| [mcp-configuration](./skills/mcp-configuration.md) | Configure/verify the per-developer Unity MCP client config; configs are gitignored and validated | +| [unity-devcontainer-testing](./skills/unity-devcontainer-testing.md) | Compile and test Unity C# code in devcontainer | +| [use-algorithmic-structures](./skills/use-algorithmic-structures.md) | Connectivity, prefix search, bit manipulation, caching | +| [use-data-structures](./skills/use-data-structures.md) | Selecting appropriate data structures | +| [use-discriminated-union](./skills/use-discriminated-union.md) | OneOf/Result types, type-safe unions | +| [use-effects-system](./skills/use-effects-system.md) | Buffs, debuffs, stat modifications | +| [use-extension-methods](./skills/use-extension-methods.md) | Collection, string, color utilities | +| [use-priority-structures](./skills/use-priority-structures.md) | Priority ordering or task scheduling | +| [use-prng](./skills/use-prng.md) | Implementing randomization | +| [use-queue-structures](./skills/use-queue-structures.md) | Rolling history, double-ended queues | +| [use-relational-attributes](./skills/use-relational-attributes.md) | Auto-wiring components via hierarchy | +| [use-serializable-types](./skills/use-serializable-types.md) | Dictionaries, HashSets, Nullable, Type, Guid | +| [use-serializable-types-patterns](./skills/use-serializable-types-patterns.md) | Common patterns for serializable collections | +| [use-serialization](./skills/use-serialization.md) | Save files, network, persistence | +| [use-singleton](./skills/use-singleton.md) | Global managers, service locators, configuration | +| [use-spatial-structure](./skills/use-spatial-structure.md) | Spatial queries or proximity logic | +| [use-threading](./skills/use-threading.md) | Main thread dispatch, thread safety | +| [wiki-generation](./skills/wiki-generation.md) | GitHub Wiki deployment, sidebar links | diff --git a/.llm/references/forbidden-patterns.md b/.llm/references/forbidden-patterns.md index ea497d63d..3284514af 100644 --- a/.llm/references/forbidden-patterns.md +++ b/.llm/references/forbidden-patterns.md @@ -333,6 +333,22 @@ When passing file arguments to CLI tools, a `--` (end-of-options) separator MUST --- +## Serialization Patterns + +`Serializer` is the single documented carve-out from the "never throw" rule (see `.llm/skills/serialization-safety.md`). Inside `Runtime/Core/Serialization/Serializer.cs` and any future format added there: + +| Forbidden | Use Instead | Reason | +| ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | +| `new MemoryStream(byte[])` without a prior null/empty guard | `SerializationFailureException.ThrowNullInput` / `ThrowEmptyInput` then `new MemoryStream(data)` | Legacy crash: `ArgumentNullException: Buffer cannot be null` leaked to a ZLinq pipeline. | +| `throw new ProtoBuf.ProtoException(...)` | `SerializationFailureException.ThrowCorrupt(..., inner)` (wrap the framework exception) | Callers can only catch one type — the documented hierarchy. | +| `throw new System.Text.Json.JsonException(...)` | `SerializationFailureException.ThrowCorrupt(..., inner)` | Same. | +| `throw new ArgumentNullException(nameof(data))` from `*Deserialize*` | `SerializationFailureException.ThrowNullInput(format, op)` | Same hierarchy. | +| A new `*Deserialize*` method without a matching `Try*` sibling | Add `TryXxx` overload that catches `SerializationInputException` + `SerializationCorruptDataException` only | `SerializerApiContractTests` will fail the build otherwise. | +| A `Try*` sibling that catches `Exception` | Catch only `SerializationInputException` + `SerializationCorruptDataException` | Programmer errors (`Type` / `Configuration`) must propagate. | +| Silent `catch { return default; }` around `Serializer.*Deserialize*` in caller code | `Try*` sibling, OR catch `SerializationFailureException` and log+fallback | Silent corruption looks identical to a missing field hours later. | + +--- + ## Related Documentation - [high-performance-csharp](../skills/high-performance-csharp.md) - Core performance patterns @@ -340,3 +356,4 @@ When passing file arguments to CLI tools, a `--` (end-of-options) separator MUST - [memory-allocation-traps](../skills/memory-allocation-traps.md) - Hidden allocation sources - [avoid-reflection](../skills/avoid-reflection.md) - Reflection avoidance - [avoid-magic-strings](../skills/avoid-magic-strings.md) - Magic string avoidance +- [serialization-safety](../skills/serialization-safety.md) - Serializer exception contract diff --git a/.llm/skills/bash-pwsh-invocation.md.meta b/.llm/skills/bash-pwsh-invocation.md.meta deleted file mode 100644 index b714a5933..000000000 --- a/.llm/skills/bash-pwsh-invocation.md.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: dfecbb0d10ac415a91ee60396d8722c4 -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/.llm/skills/defensive-programming.md b/.llm/skills/defensive-programming.md index 8d74cf7e7..a2ff0529b 100644 --- a/.llm/skills/defensive-programming.md +++ b/.llm/skills/defensive-programming.md @@ -25,22 +25,22 @@ Production code—including editor tooling—must be **resilient to any state**. Exceptions should ONLY be thrown for: -| Scenario | Example | Why It's OK | -| ----------------------------------- | ------------------------------------------ | ------------------------------- | -| **Programmer error (debug only)** | `Debug.Assert(index >= 0)` | Catches bugs during development | -| **Fundamentally impossible states** | Constructor receives negative capacity | API contract violation | -| **Security violations** | Unauthorized access to protected resources | Must fail loudly | +| Scenario | Example | Why It's OK | +| ------------------------------------ | -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | +| **Programmer error (debug only)** | `Debug.Assert(index >= 0)` | Catches bugs during development | +| **Fundamentally impossible states** | Constructor receives negative capacity | API contract violation | +| **Security violations** | Unauthorized access to protected resources | Must fail loudly | +| **Serializer input/decode failures** | `Serializer.ProtoDeserialize(corrupt)` throws `SerializationFailureException` | Save/network data is load-bearing — silent `default(T)` corrupts state. See `.llm/skills/serialization-safety.md`. | ### When Exceptions Are FORBIDDEN -| Scenario | Bad | Good | -| --------------------------- | ----------------------------------------- | ---------------------------------------- | -| Null input to public method | `throw new ArgumentNullException()` | Return `default`, empty, or `false` | -| Index out of range | `throw new IndexOutOfRangeException()` | Clamp, return `false`, or no-op | -| Type mismatch | `throw new InvalidCastException()` | Use `TryXxx` pattern or return `default` | -| Missing resource | `throw new FileNotFoundException()` | Return `null`, log warning | -| Deserialization failure | `throw new JsonException()` | Return `default(T)` with error info | -| Invalid enum value | `throw new ArgumentOutOfRangeException()` | Use `default` case, log warning | +| Scenario | Bad | Good | +| --------------------------- | -------------------------------------- | ---------------------------------------------------------------------------------------------- | --- | ------------------ | ----------------------------------------- | ------------------------------- | +| Null input to public method | `throw new ArgumentNullException()` | Return `default`, empty, or `false` | +| Index out of range | `throw new IndexOutOfRangeException()` | Clamp, return `false`, or no-op | +| Type mismatch | `throw new InvalidCastException()` | Use `TryXxx` pattern or return `default` | +| Missing resource | `throw new FileNotFoundException()` | Return `null`, log warning | +| Deserialization failure | `throw new JsonException()` | `Serializer.TryXxx` or wrap `SerializationFailureException` (see `serialization-safety` skill) | | Invalid enum value | `throw new ArgumentOutOfRangeException()` | Use `default` case, log warning | --- diff --git a/.llm/skills/serialization-safety.md b/.llm/skills/serialization-safety.md new file mode 100644 index 000000000..402ad5b41 --- /dev/null +++ b/.llm/skills/serialization-safety.md @@ -0,0 +1,185 @@ +# Skill: Serialization Safety + + + +**Trigger**: When writing ANY code that calls or extends the `Serializer` class, or when reviewing PRs that touch `Runtime/Core/Serialization/**`. + +--- + +## Why This Skill Exists + +`Serializer` is the **single, documented carve-out** from this repository's "never throw, handle gracefully" rule (see `.llm/skills/defensive-programming.md`). Save files, network packets, and persisted state are too load-bearing to silently return `default(T)` — a swallowed corruption looks identical to a missing field and produces ghost data hours later in production. + +A real production crash (`ArgumentNullException: Buffer cannot be null`) leaked out of `new MemoryStream(byte[])` deep inside a ZLinq pipeline. The fix is structural: **every deserialize path is wrapped, every failure has a typed exception, and every throwing method has a `Try*` sibling for callers who want flow control.** + +--- + +## The Exception Contract + +Every public `Serializer.*Deserialize*` and `Serializer.Deserialize`/`Serializer.Serialize` method MUST throw exactly one exception type: `SerializationFailureException` (or one of its sealed subclasses). Framework exceptions (`ProtoBuf.ProtoException`, `System.Text.Json.JsonException`, `ArgumentNullException`, `InvalidOperationException`, ...) are **wrapped at the format boundary** and surfaced as `InnerException`. + +| Subclass | When | `InnerException` | Swallowed by `TryXxx`? | +| ------------------------------------- | ---------------------------------------------- | ------------------------- | ------------------------ | +| `SerializationInputException` | Null/empty/malformed argument | `null` | ✅ Yes | +| `SerializationCorruptDataException` | Codec rejected a non-null payload | The framework exception | ✅ Yes | +| `SerializationTypeException` | Polymorphic root unresolved / no registration | `null` or codec exception | ❌ No (programmer error) | +| `SerializationConfigurationException` | Invalid `SerializationType`, null `Type`, etc. | `null` | ❌ No (programmer error) | + +All subclasses expose the same immutable properties: `Format`, `Operation`, `DeclaredType`, `ResolvedType`, `InputDescriptor`, `Stage`, `Reason`. `Message` is composed lazily on first access — callers that never log pay no string-formatting cost. + +--- + +## How to Catch Failures + +### When you want to know _something_ went wrong but don't care which format + +```csharp +using WallstopStudios.UnityHelpers.Core.Serialization; + +try +{ + PlayerData data = Serializer.ProtoDeserialize(bytes); + Apply(data); +} +catch (SerializationFailureException ex) +{ + // ex.Format, ex.Stage, ex.DeclaredType, ex.InnerException — all populated + Debug.LogWarning($"Save load failed: {ex.Message}"); + LoadDefaults(); +} +``` + +### When you want flow control without throwing — use `TryXxx` + +```csharp +// Try* swallows Input + CorruptData failures (caller can recover). +// Type + Configuration failures still throw — those are programmer errors. +if (Serializer.TryProtoDeserialize(bytes, out PlayerData data)) +{ + Apply(data); +} +else +{ + LoadDefaults(); +} +``` + +| Throwing method | `Try` sibling | +| ----------------------------------------------------------- | ---------------------------------------------------------- | +| `Deserialize(byte[], SerializationType)` | `TryDeserialize(byte[], SerializationType, out T)` | +| `ProtoDeserialize(byte[])` | `TryProtoDeserialize(byte[], out T)` | +| `ProtoDeserialize(byte[], Type)` | `TryProtoDeserialize(byte[], Type, out T)` | +| `JsonDeserialize(string)` / `JsonDeserialize(byte[])` | `TryJsonDeserialize(string, out T)` / `(byte[], out T)` | +| `JsonDeserializeFast(byte[])` | `TryJsonDeserializeFast(byte[], out T)` | +| `BinaryDeserialize(byte[])` | `TryBinaryDeserialize(byte[], out T)` | + +A `SerializerApiContractTests` reflection test fails the build if any of these pairs go missing. + +--- + +## When Adding a New Deserialize Method + +You MUST follow this checklist (otherwise the build will fail): + +1. **Guard null/empty** — use `SerializationFailureException.ThrowNullInput` / `ThrowEmptyInput`. Never write `if (data == null) throw new ArgumentNullException(...)`. +2. **Wrap codec failures** — `try { ... } catch (Exception inner) { SerializationFailureException.ThrowCorrupt(format, op, data.Length, stage, inner); }`. Never let a `ProtoException`, `JsonException`, or any other framework exception escape. +3. **Add the `Try*` sibling** — must return `bool`, set `out T = default` on failure, and **catch only `SerializationInputException` and `SerializationCorruptDataException`**. `SerializationTypeException` and `SerializationConfigurationException` must propagate (they signal programmer errors). +4. **Add contract tests** in `Tests/Runtime/Serialization/` covering: null, empty, corrupt bytes, valid roundtrip, and the matrix in `SerializerExceptionContractTests`. +5. **`SerializerApiContractTests` will refuse to build** if a `*Deserialize*` method exists without a matching `Try*` overload — do not suppress this test. + +--- + +## Forbidden Patterns + +```csharp +// ❌ NEVER — silent default on failure for save/load data +try { return Serializer.ProtoDeserialize(bytes); } +catch { return default; } + +// ❌ NEVER — leaking framework exception +public static T DeserializeFancy(byte[] data) => ProtoBuf.Serializer.Deserialize(new MemoryStream(data)); +// ^^^^^^^^^^^^^^^^ unguarded null! + +// ❌ NEVER — catching too broad in a Try* sibling (would hide programmer errors) +public static bool TryDeserializeFancy(byte[] data, out T value) { + try { value = DeserializeFancy(data); return true; } + catch { value = default; return false; } // SWALLOWS SerializationTypeException! +} + +// ❌ NEVER — `throw new ProtoException(...)` / `throw new JsonException(...)` from Serializer.cs +// Use SerializationFailureException + an InnerException instead. +``` + +```csharp +// ✅ CORRECT — guard + wrap + Try sibling +public static T DeserializeFancy(byte[] data) { + if (data == null) { + SerializationFailureException.ThrowNullInput(SerializationFormat.Protobuf, SerializationOperation.Deserialize); + } + if (data.Length == 0) { + SerializationFailureException.ThrowEmptyInput(SerializationFormat.Protobuf, SerializationOperation.Deserialize); + } + try { + using MemoryStream ms = new(data); + return ProtoBuf.Serializer.Deserialize(ms); + } + catch (Exception inner) when (inner is not SerializationFailureException) { + SerializationFailureException.ThrowCorrupt( + SerializationFormat.Protobuf, + SerializationOperation.Deserialize, + data.Length, + SerializationStage.Decode, + inner); + return default; // unreachable; ThrowCorrupt is [DoesNotReturn] + } +} + +public static bool TryDeserializeFancy(byte[] data, out T value) { + try { value = DeserializeFancy(data); return true; } + catch (SerializationInputException) { value = default; return false; } + catch (SerializationCorruptDataException) { value = default; return false; } + // Type/Configuration exceptions still propagate — programmer errors. +} +``` + +--- + +## Caller-Side Patterns + +### Save/load with graceful fallback + +```csharp +public SaveData Load() { + try { + byte[] bytes = File.ReadAllBytes(_path); + return Serializer.ProtoDeserialize(bytes); + } + catch (SerializationFailureException ex) { + Debug.LogWarning($"Save corrupt ({ex.Format}/{ex.Stage}): {ex.Message}"); + return SaveData.CreateDefault(); + } + catch (IOException ex) { + Debug.LogWarning($"Save unreadable: {ex.Message}"); + return SaveData.CreateDefault(); + } +} +``` + +### Streaming pipeline that may see null/missing entries (the screenshot bug) + +```csharp +// ✅ Use the Try sibling so the pipeline never throws on a poisoned record. +foreach (byte[] blob in records) { + if (Serializer.TryProtoDeserialize(blob, out PlayerState state)) { + yield return state; + } +} +``` + +--- + +## Related Skills + +- `.llm/skills/use-serialization.md` — overall serializer reference (formats, schema evolution, Unity types). +- `.llm/skills/defensive-programming.md` — the "never throw" rule and its serialization carve-out. +- `.llm/references/forbidden-patterns.md` — concrete anti-patterns to flag in PR review. diff --git a/.llm/skills/use-serialization.md b/.llm/skills/use-serialization.md index e93de0d0c..16bd01532 100644 --- a/.llm/skills/use-serialization.md +++ b/.llm/skills/use-serialization.md @@ -15,6 +15,32 @@ --- +## Error Handling + +`Serializer` is the **single documented exception** to this repo's "never throw" rule (see `.llm/skills/defensive-programming.md`). Save/network data is too load-bearing for silent `default(T)`. **Every** deserialize entry point either throws `SerializationFailureException` or returns `false` via a `TryXxx` sibling. Full details: `.llm/skills/serialization-safety.md`. + +```csharp +// Throwing — catch SerializationFailureException for any format/stage. +try +{ + PlayerData data = Serializer.ProtoDeserialize(bytes); +} +catch (SerializationFailureException ex) +{ + Debug.LogWarning($"Load failed: {ex.Format}/{ex.Stage} — {ex.Message}"); +} + +// Non-throwing — Try* returns false on null/empty/corrupt input. +if (Serializer.TryProtoDeserialize(bytes, out PlayerData data)) +{ + Apply(data); +} +``` + +`Try*` swallows `SerializationInputException` and `SerializationCorruptDataException`. `SerializationTypeException` (unresolved polymorphic root) and `SerializationConfigurationException` (invalid `SerializationType`) still propagate — they are programmer errors. + +--- + ## JSON Serialization ### Basic Usage diff --git a/Runtime/Core/Serialization/SerializationFailureException.cs b/Runtime/Core/Serialization/SerializationFailureException.cs new file mode 100644 index 000000000..a1f33ff1b --- /dev/null +++ b/Runtime/Core/Serialization/SerializationFailureException.cs @@ -0,0 +1,518 @@ +// MIT License - Copyright (c) 2023 wallstop +// Full license text: https://github.com/wallstop/unity-helpers/blob/main/LICENSE + +// Polyfill DoesNotReturnAttribute on targets older than .NET 5 (e.g. Unity 2021.3 with +// .NET Standard 2.1). The attribute is purely informational for static analyzers; declaring +// it locally lets us decorate the Throw* helpers on every supported target without conditional +// source changes elsewhere. +#if !NET5_0_OR_GREATER && !UNITY_6000_0_OR_NEWER +namespace System.Diagnostics.CodeAnalysis +{ + using System; + + [AttributeUsage(AttributeTargets.Method, Inherited = false)] + internal sealed class DoesNotReturnAttribute : Attribute { } +} +#endif + +namespace WallstopStudios.UnityHelpers.Core.Serialization +{ + using System; + using System.Diagnostics.CodeAnalysis; + using System.Runtime.CompilerServices; + using System.Runtime.Serialization; + + /// + /// Identifies the wire format involved in a serialization failure. + /// + public enum SerializationFormat + { + /// Unknown / not-yet-classified format. + Unknown = 0, + /// protobuf-net binary format. + Protobuf = 1, + /// System.Text.Json (standard options). + Json = 2, + /// System.Text.Json (fast/strict options). + JsonFast = 3, + /// Legacy BinaryFormatter. + Binary = 4, + /// Generic dispatcher ( / ). + Dispatcher = 5, + } + + /// + /// Identifies the direction of the failed operation. + /// + public enum SerializationOperation + { + /// Decoding bytes/string into an instance. + Deserialize = 0, + /// Encoding an instance into bytes/string. + Serialize = 1, + } + + /// + /// Identifies which stage of the pipeline rejected the operation. Used to make stack-trace + /// triage trivial without parsing exception messages. + /// + public enum SerializationStage + { + /// Argument guards rejected the input (e.g. null or empty payload). + InputValidation = 0, + /// The unified dispatcher rejected an unknown . + Dispatch = 1, + /// Polymorphic root resolution failed (e.g. missing [ProtoInclude] or registration). + TypeResolution = 2, + /// The wire-format decoder rejected the payload. + Decode = 3, + /// The wire-format encoder failed. + Encode = 4, + /// Post-decode processing failed (e.g. collection wrapper unpack). + PostProcess = 5, + } + + /// + /// Single root exception for every failure surfaced by . + /// + /// + /// + /// All public entry points are contractually allowed to throw exactly one + /// exception type: (or one of its sealed subclasses). + /// Framework exceptions (ProtoBuf.ProtoException, System.Text.Json.JsonException, + /// ArgumentNullException, etc.) are wrapped at the format boundary and exposed as + /// . Callers that want flow-control rather than throwing should + /// use the matching TryXxx overloads. + /// + /// + /// Designed for zero allocation on the happy path: the property is composed + /// lazily on first access, so callers that never log the message pay no string-formatting cost. + /// The exception itself only ever materializes when the throw path is taken (a slow path by + /// definition). + /// + /// + /// Thread-safety: the cached message field is read and written via plain reference assignment. + /// Concurrent first reads from multiple threads may compose the message twice (benign, since the + /// inputs are immutable and produce identical strings); the cached field then stabilizes. + /// + /// + [Serializable] + public class SerializationFailureException : Exception + { + private const string PlaceholderReason = "operation failed"; + + // Lazy cache for the composed message. Intentionally excluded from binary serialization — + // after BinaryFormatter round-trip, Message will recompose from the immutable properties. + // Reference assignment is atomic on all .NET runtimes; concurrent compose-twice is benign. + [NonSerialized] + private string _composedMessage; + + /// The wire format involved in the failure. + public SerializationFormat Format { get; } + + /// The direction of the operation (serialize or deserialize). + public SerializationOperation Operation { get; } + + /// + /// The declared (generic) type that the caller requested. May be after + /// [Serializable] round-trip on platforms with type-trimming (IL2CPP/WebGL) if the + /// stored cannot be resolved at deserialization time. + /// Always non-null on the throw path. + /// + public Type DeclaredType { get; } + + /// + /// The runtime/concrete type involved (e.g. a polymorphic protobuf root, or the runtime type + /// of the input on the serialize path). May be if not yet resolved, or + /// after [Serializable] round-trip on trimmed runtimes. + /// + public Type ResolvedType { get; } + + /// + /// A short, allocation-free description of the offending input (e.g. "byte[256]", + /// "null byte[]", "string(len=0)"). Never contains payload bytes themselves — + /// safe to log without leaking sensitive data. + /// + public string InputDescriptor { get; } + + /// The pipeline stage that rejected the operation. + public SerializationStage Stage { get; } + + /// A short, human-readable reason supplied by the throw site. + public string Reason { get; } + + /// + /// Constructs a new instance. Prefer the static Throw* helpers on this type so that + /// throw sites stay terse and the JIT can keep the hot path tight. + /// + public SerializationFailureException( + SerializationFormat format, + SerializationOperation operation, + Type declaredType, + string inputDescriptor, + SerializationStage stage, + string reason, + Type resolvedType = null, + Exception innerException = null + ) + : base(null, innerException) + { + Format = format; + Operation = operation; + DeclaredType = declaredType; + ResolvedType = resolvedType; + InputDescriptor = inputDescriptor ?? ""; + Stage = stage; + Reason = string.IsNullOrEmpty(reason) ? PlaceholderReason : reason; + } + + /// + /// Serialization constructor for cross-AppDomain / legacy binary serialization scenarios. + /// + protected SerializationFailureException( + SerializationInfo info, + StreamingContext context + ) + : base(info, context) + { + // info is non-null in every documented BinaryFormatter/ISerializable code path — the base + // ctor already validates this. We never short-circuit because that would leave the object + // in a half-initialized state. + Format = (SerializationFormat)info.GetInt32(nameof(Format)); + Operation = (SerializationOperation)info.GetInt32(nameof(Operation)); + // Types are persisted as AssemblyQualifiedName strings so the exception survives platforms + // (IL2CPP / WebGL) where BinaryFormatter cannot serialize a raw Type instance. + DeclaredType = ResolveTypeOrNull(info.GetString(nameof(DeclaredType))); + ResolvedType = ResolveTypeOrNull(info.GetString(nameof(ResolvedType))); + InputDescriptor = info.GetString(nameof(InputDescriptor)) ?? ""; + Stage = (SerializationStage)info.GetInt32(nameof(Stage)); + Reason = info.GetString(nameof(Reason)) ?? PlaceholderReason; + } + + private static Type ResolveTypeOrNull(string assemblyQualifiedName) + { + if (string.IsNullOrEmpty(assemblyQualifiedName)) + { + return null; + } + // Type.GetType(name, throwOnError: false) returns null instead of throwing when the + // type cannot be located, so no try/catch is necessary. + return Type.GetType(assemblyQualifiedName, throwOnError: false); + } + + /// + public override void GetObjectData(SerializationInfo info, StreamingContext context) + { + base.GetObjectData(info, context); + // info is non-null per the BinaryFormatter contract; base.GetObjectData validates it. + info.AddValue(nameof(Format), (int)Format); + info.AddValue(nameof(Operation), (int)Operation); + // Persist Type as AssemblyQualifiedName so the round-trip works on IL2CPP / WebGL where + // BinaryFormatter cannot serialize raw Type references. + info.AddValue(nameof(DeclaredType), DeclaredType?.AssemblyQualifiedName); + info.AddValue(nameof(ResolvedType), ResolvedType?.AssemblyQualifiedName); + info.AddValue(nameof(InputDescriptor), InputDescriptor); + info.AddValue(nameof(Stage), (int)Stage); + info.AddValue(nameof(Reason), Reason); + } + + /// + public override string Message => _composedMessage ??= ComposeMessage(); + + private string ComposeMessage() + { + // Plain string concatenation is used here (rather than DefaultInterpolatedStringHandler) + // for compatibility with .NET Standard 2.1 / Unity 2021.3 IL2CPP, where the handler type + // is not available. The throw path is already slow — the resulting String.Concat call is + // comfortably under the cost of the exception throw + stack-walk. + string declaredName = DeclaredType?.FullName ?? ""; + string resolvedSuffix = ResolvedType == null || ResolvedType == DeclaredType + ? string.Empty + : " (resolved as " + ResolvedType.FullName + ")"; + return "[" + Format + "." + Operation + "] " + Stage + " failed for " + + declaredName + resolvedSuffix + " (input: " + InputDescriptor + "): " + Reason; + } + + // ----------------------------------------------------------------------------------- + // Throw helpers — keep call sites tiny and JIT-friendly. + // ----------------------------------------------------------------------------------- + + /// + /// Throws for a null payload. + /// + [DoesNotReturn] + [MethodImpl(MethodImplOptions.NoInlining)] + public static void ThrowNullInput( + SerializationFormat format, + SerializationOperation operation, + string parameterName = "data" + ) + { + throw new SerializationInputException( + format, + operation, + typeof(T), + DescribeNull(parameterName), + $"{parameterName} is null." + ); + } + + /// + /// Throws for an empty payload. + /// + [DoesNotReturn] + [MethodImpl(MethodImplOptions.NoInlining)] + public static void ThrowEmptyInput( + SerializationFormat format, + SerializationOperation operation, + string parameterName = "data" + ) + { + throw new SerializationInputException( + format, + operation, + typeof(T), + DescribeEmpty(parameterName), + $"{parameterName} is empty." + ); + } + + /// + /// Throws wrapping a decoder/encoder failure. + /// + [DoesNotReturn] + [MethodImpl(MethodImplOptions.NoInlining)] + public static void ThrowCorrupt( + SerializationFormat format, + SerializationOperation operation, + int inputLength, + SerializationStage stage, + Exception inner, + string reason = null + ) + { + throw new SerializationCorruptDataException( + format, + operation, + typeof(T), + DescribeBytes(inputLength), + stage, + reason ?? "Underlying codec rejected the payload.", + inner + ); + } + + /// + /// Throws for an unresolved polymorphic root. + /// + [DoesNotReturn] + [MethodImpl(MethodImplOptions.NoInlining)] + public static void ThrowTypeResolution( + SerializationFormat format, + SerializationOperation operation, + string reason + ) + { + throw new SerializationTypeException( + format, + operation, + typeof(T), + "", + reason + ); + } + + /// + /// Throws for an invalid configuration value + /// (e.g. an unknown ). + /// + [DoesNotReturn] + [MethodImpl(MethodImplOptions.NoInlining)] + public static void ThrowConfiguration( + SerializationFormat format, + SerializationOperation operation, + string reason + ) + { + throw new SerializationConfigurationException( + format, + operation, + typeof(T), + "", + reason + ); + } + + internal static string DescribeBytes(int length) => + length switch + { + < 0 => "byte[?]", + 0 => "byte[0]", + _ => "byte[" + length.ToString(System.Globalization.CultureInfo.InvariantCulture) + "]", + }; + + internal static string DescribeString(int length) => + length switch + { + < 0 => "string(len=?)", + 0 => "string(len=0)", + _ => "string(len=" + length.ToString(System.Globalization.CultureInfo.InvariantCulture) + ")", + }; + + internal static string DescribeNull(string parameterName) => + parameterName == "data" || string.IsNullOrEmpty(parameterName) + ? "null" + : "null " + parameterName; + + internal static string DescribeEmpty(string parameterName) => + parameterName == "data" || string.IsNullOrEmpty(parameterName) + ? "empty" + : "empty " + parameterName; + } + + /// + /// Raised when the input to a method violates the parameter contract + /// (null, empty, wrong shape). The caller passed bad arguments — there is no + /// . + /// + /// + /// Swallowed by Serializer.TryXxx overloads. + /// + [Serializable] + public sealed class SerializationInputException : SerializationFailureException + { + /// + public SerializationInputException( + SerializationFormat format, + SerializationOperation operation, + Type declaredType, + string inputDescriptor, + string reason + ) + : base( + format, + operation, + declaredType, + inputDescriptor, + SerializationStage.InputValidation, + reason + ) + { } + + private SerializationInputException(SerializationInfo info, StreamingContext context) + : base(info, context) + { } + } + + /// + /// Raised when the underlying codec (protobuf-net, System.Text.Json, BinaryFormatter, ...) + /// rejects a non-null payload. The original framework exception is preserved as + /// . + /// + /// + /// Swallowed by Serializer.TryXxx overloads. + /// + [Serializable] + public sealed class SerializationCorruptDataException : SerializationFailureException + { + /// + public SerializationCorruptDataException( + SerializationFormat format, + SerializationOperation operation, + Type declaredType, + string inputDescriptor, + SerializationStage stage, + string reason, + Exception innerException + ) + : base( + format, + operation, + declaredType, + inputDescriptor, + stage, + reason, + innerException: innerException + ) + { } + + private SerializationCorruptDataException( + SerializationInfo info, + StreamingContext context + ) + : base(info, context) + { } + } + + /// + /// Raised when a polymorphic protobuf root cannot be resolved (e.g. the declared type is an + /// interface and no [ProtoInclude] chain or + /// registration was found). This indicates a programmer/config error, not corrupt data. + /// + /// + /// Not swallowed by Serializer.TryXxx overloads — it surfaces a developer + /// mistake that should fail loudly. + /// + [Serializable] + public sealed class SerializationTypeException : SerializationFailureException + { + /// + public SerializationTypeException( + SerializationFormat format, + SerializationOperation operation, + Type declaredType, + string inputDescriptor, + string reason + ) + : base( + format, + operation, + declaredType, + inputDescriptor, + SerializationStage.TypeResolution, + reason + ) + { } + + private SerializationTypeException(SerializationInfo info, StreamingContext context) + : base(info, context) + { } + } + + /// + /// Raised when a entry point is invoked with an invalid configuration + /// value (e.g. an undefined ). + /// + /// + /// Not swallowed by Serializer.TryXxx overloads — it surfaces a developer + /// mistake. + /// + [Serializable] + public sealed class SerializationConfigurationException : SerializationFailureException + { + /// + public SerializationConfigurationException( + SerializationFormat format, + SerializationOperation operation, + Type declaredType, + string inputDescriptor, + string reason + ) + : base( + format, + operation, + declaredType, + inputDescriptor, + SerializationStage.Dispatch, + reason + ) + { } + + private SerializationConfigurationException( + SerializationInfo info, + StreamingContext context + ) + : base(info, context) + { } + } +} diff --git a/Runtime/Core/Serialization/SerializationFailureException.cs.meta b/Runtime/Core/Serialization/SerializationFailureException.cs.meta new file mode 100644 index 000000000..22bcc710e --- /dev/null +++ b/Runtime/Core/Serialization/SerializationFailureException.cs.meta @@ -0,0 +1,10 @@ +fileFormatVersion: 2 +guid: 8a892c4e8c614dd7bbf5dee924309958 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: diff --git a/Runtime/Core/Serialization/Serializer.cs b/Runtime/Core/Serialization/Serializer.cs index 00768f4c1..06bbaccd9 100644 --- a/Runtime/Core/Serialization/Serializer.cs +++ b/Runtime/Core/Serialization/Serializer.cs @@ -1102,15 +1102,45 @@ public static T Deserialize(byte[] serialized, SerializationType serializatio } default: { - throw new InvalidEnumArgumentException( - nameof(serializationType), - (int)serializationType, - typeof(SerializationType) + SerializationFailureException.ThrowConfiguration( + SerializationFormat.Dispatcher, + SerializationOperation.Deserialize, + $"Unknown SerializationType '{(int)serializationType}'." ); + return default; } } } + /// + /// Attempts to deserialize bytes using . Returns + /// and sets to if the payload is null/empty or the + /// codec rejects it. Programmer errors (unknown , unresolved polymorphic + /// root) still throw . + /// + public static bool TryDeserialize( + byte[] serialized, + SerializationType serializationType, + out T value + ) + { + try + { + value = Deserialize(serialized, serializationType); + return true; + } + catch (SerializationInputException) + { + value = default; + return false; + } + catch (SerializationCorruptDataException) + { + value = default; + return false; + } + } + /// /// Serializes an instance into bytes using the specified . /// @@ -1146,11 +1176,12 @@ public static byte[] Serialize(T instance, SerializationType serializationTyp } default: { - throw new InvalidEnumArgumentException( - nameof(serializationType), - (int)serializationType, - typeof(SerializationType) + SerializationFailureException.ThrowConfiguration( + SerializationFormat.Dispatcher, + SerializationOperation.Serialize, + $"Unknown SerializationType '{(int)serializationType}'." ); + return default; } } } @@ -1187,11 +1218,12 @@ ref byte[] buffer } default: { - throw new InvalidEnumArgumentException( - nameof(serializationType), - (int)serializationType, - typeof(SerializationType) + SerializationFailureException.ThrowConfiguration( + SerializationFormat.Dispatcher, + SerializationOperation.Serialize, + $"Unknown SerializationType '{(int)serializationType}'." ); + return 0; } } } @@ -1208,13 +1240,70 @@ ref byte[] buffer /// public static T BinaryDeserialize(byte[] data) { - using Utils.PooledResource lease = - PooledReadOnlyMemoryStream.Rent(out PooledReadOnlyMemoryStream stream); - stream.SetBuffer(data); - using Utils.PooledResource fmtLease = BinaryFormatterPool.Get( - out BinaryFormatter binaryFormatter - ); - return (T)binaryFormatter.Deserialize(stream); + if (data == null) + { + SerializationFailureException.ThrowNullInput( + SerializationFormat.Binary, + SerializationOperation.Deserialize + ); + } + if (data.Length == 0) + { + SerializationFailureException.ThrowEmptyInput( + SerializationFormat.Binary, + SerializationOperation.Deserialize + ); + } + + try + { + using Utils.PooledResource lease = + PooledReadOnlyMemoryStream.Rent(out PooledReadOnlyMemoryStream stream); + stream.SetBuffer(data); + using Utils.PooledResource fmtLease = BinaryFormatterPool.Get( + out BinaryFormatter binaryFormatter + ); + return (T)binaryFormatter.Deserialize(stream); + } + catch (SerializationFailureException) + { + throw; + } + catch (Exception e) + { + SerializationFailureException.ThrowCorrupt( + SerializationFormat.Binary, + SerializationOperation.Deserialize, + data.Length, + SerializationStage.Decode, + e, + "BinaryFormatter rejected the payload." + ); + return default; + } + } + + /// + /// Attempts to deserialize bytes with BinaryFormatter. Returns + /// for null/empty/corrupt payloads. + /// + public static bool TryBinaryDeserialize(byte[] data, out T value) + { + try + { + value = BinaryDeserialize(data); + return true; + } + catch (SerializationInputException) + { + value = default; + return false; + } + catch (SerializationCorruptDataException) + { + value = default; + return false; + } } /// @@ -1297,7 +1386,17 @@ public static T ProtoDeserialize(byte[] data) { if (data == null) { - throw new ProtoException("No data provided for Protobuf deserialization."); + SerializationFailureException.ThrowNullInput( + SerializationFormat.Protobuf, + SerializationOperation.Deserialize + ); + } + if (data.Length == 0) + { + SerializationFailureException.ThrowEmptyInput( + SerializationFormat.Protobuf, + SerializationOperation.Deserialize + ); } // Intercept serializable collection types to use wrapper-based deserialization @@ -1305,7 +1404,26 @@ public static T ProtoDeserialize(byte[] data) Type declared = typeof(T); if (IsSerializableCollectionType(declared)) { - return DeserializeCollectionFromWrapper(data); + try + { + return DeserializeCollectionFromWrapper(data); + } + catch (SerializationFailureException) + { + throw; + } + catch (Exception e) + { + SerializationFailureException.ThrowCorrupt( + SerializationFormat.Protobuf, + SerializationOperation.Deserialize, + data.Length, + SerializationStage.PostProcess, + e, + "Failed to unpack protobuf collection wrapper." + ); + return default; + } } try @@ -1328,7 +1446,9 @@ public static T ProtoDeserialize(byte[] data) return (T)ProtoDeserializeTypeFromROMFast(root, rom); } - throw new ProtoException( + SerializationFailureException.ThrowTypeResolution( + SerializationFormat.Protobuf, + SerializationOperation.Deserialize, $"Unable to resolve a unique protobuf root for declared type {declared.FullName}. Register a root via RegisterProtobufRoot or annotate a shared abstract base with [ProtoInclude]s." ); } @@ -1353,7 +1473,9 @@ public static T ProtoDeserialize(byte[] data) return (T)ProtoDeserializeTypeFromROSFast(root, ros); } - throw new ProtoException( + SerializationFailureException.ThrowTypeResolution( + SerializationFormat.Protobuf, + SerializationOperation.Deserialize, $"Unable to resolve a unique protobuf root for declared type {declared.FullName}. Register a root via RegisterProtobufRoot or annotate a shared abstract base with [ProtoInclude]s." ); } @@ -1379,7 +1501,9 @@ public static T ProtoDeserialize(byte[] data) return (T)ProtoBuf.Serializer.Deserialize(root, ms); } - throw new ProtoException( + SerializationFailureException.ThrowTypeResolution( + SerializationFormat.Protobuf, + SerializationOperation.Deserialize, $"Unable to resolve a unique protobuf root for declared type {declared.FullName}. Register a root via RegisterProtobufRoot or annotate a shared abstract base with [ProtoInclude]s." ); } @@ -1407,23 +1531,54 @@ public static T ProtoDeserialize(byte[] data) return (T)ProtoBuf.Serializer.Deserialize(root, stream); } - throw new ProtoException( + SerializationFailureException.ThrowTypeResolution( + SerializationFormat.Protobuf, + SerializationOperation.Deserialize, $"Unable to resolve a unique protobuf root for declared type {declaredLarge.FullName}. Register a root via RegisterProtobufRoot or annotate a shared abstract base with [ProtoInclude]s." ); } return ProtoBuf.Serializer.Deserialize(stream); } - catch (ProtoException) + catch (SerializationFailureException) { throw; } catch (Exception e) { - throw new ProtoException( - "Protobuf deserialization failed: invalid or corrupted data.", - e + SerializationFailureException.ThrowCorrupt( + SerializationFormat.Protobuf, + SerializationOperation.Deserialize, + data.Length, + SerializationStage.Decode, + e, + "protobuf-net rejected the payload." ); + return default; + } + } + + /// + /// Attempts to deserialize a protobuf payload. Returns and sets + /// to for null/empty/corrupt input. + /// Polymorphic-root resolution failures still throw (programmer error). + /// + public static bool TryProtoDeserialize(byte[] data, out T value) + { + try + { + value = ProtoDeserialize(data); + return true; + } + catch (SerializationInputException) + { + value = default; + return false; + } + catch (SerializationCorruptDataException) + { + value = default; + return false; } } @@ -1541,12 +1696,25 @@ public static T ProtoDeserialize(byte[] data, Type type) { if (data == null) { - throw new ArgumentException(nameof(data)); + SerializationFailureException.ThrowNullInput( + SerializationFormat.Protobuf, + SerializationOperation.Deserialize + ); + } + if (data.Length == 0) + { + SerializationFailureException.ThrowEmptyInput( + SerializationFormat.Protobuf, + SerializationOperation.Deserialize + ); } - if (type == null) { - throw new ArgumentNullException(nameof(type)); + SerializationFailureException.ThrowConfiguration( + SerializationFormat.Protobuf, + SerializationOperation.Deserialize, + "Target Type argument is null." + ); } try @@ -1574,16 +1742,45 @@ public static T ProtoDeserialize(byte[] data, Type type) stream.SetBuffer(data); return (T)ProtoBuf.Serializer.Deserialize(type, stream); } - catch (ProtoException) + catch (SerializationFailureException) { throw; } catch (Exception e) { - throw new ProtoException( - "Protobuf deserialization failed: invalid or corrupted data.", - e + SerializationFailureException.ThrowCorrupt( + SerializationFormat.Protobuf, + SerializationOperation.Deserialize, + data.Length, + SerializationStage.Decode, + e, + "protobuf-net rejected the payload." ); + return default; + } + } + + /// + /// Attempts to deserialize a protobuf payload into the supplied . + /// Returns on null/empty/corrupt input. A null + /// still throws (programmer error). + /// + public static bool TryProtoDeserialize(byte[] data, Type type, out T value) + { + try + { + value = ProtoDeserialize(data, type); + return true; + } + catch (SerializationInputException) + { + value = default; + return false; + } + catch (SerializationCorruptDataException) + { + value = default; + return false; } } @@ -1690,12 +1887,73 @@ public static T JsonDeserialize( JsonSerializerOptions options = null ) { - return (T) - JsonSerializer.Deserialize( - data, - type ?? typeof(T), - options ?? SerializerEncoding.NormalJsonOptions + if (data == null) + { + SerializationFailureException.ThrowNullInput( + SerializationFormat.Json, + SerializationOperation.Deserialize + ); + } + if (data.Length == 0) + { + SerializationFailureException.ThrowEmptyInput( + SerializationFormat.Json, + SerializationOperation.Deserialize + ); + } + + try + { + return (T) + JsonSerializer.Deserialize( + data, + type ?? typeof(T), + options ?? SerializerEncoding.NormalJsonOptions + ); + } + catch (SerializationFailureException) + { + throw; + } + catch (Exception e) + { + SerializationFailureException.ThrowCorrupt( + SerializationFormat.Json, + SerializationOperation.Deserialize, + data.Length, + SerializationStage.Decode, + e, + "System.Text.Json rejected the payload." ); + return default; + } + } + + /// + /// Attempts to deserialize a JSON string. Returns for null/empty/corrupt input. + /// + public static bool TryJsonDeserialize( + string data, + out T value, + Type type = null, + JsonSerializerOptions options = null + ) + { + try + { + value = JsonDeserialize(data, type, options); + return true; + } + catch (SerializationInputException) + { + value = default; + return false; + } + catch (SerializationCorruptDataException) + { + value = default; + return false; + } } /// @@ -1715,16 +1973,72 @@ public static T JsonDeserialize( { if (data == null) { - throw new ArgumentNullException(nameof(data)); + SerializationFailureException.ThrowNullInput( + SerializationFormat.Json, + SerializationOperation.Deserialize + ); + } + if (data.Length == 0) + { + SerializationFailureException.ThrowEmptyInput( + SerializationFormat.Json, + SerializationOperation.Deserialize + ); } - ReadOnlySpan span = new(data); - return (T) - JsonSerializer.Deserialize( - span, - type ?? typeof(T), - options ?? SerializerEncoding.NormalJsonOptions + try + { + ReadOnlySpan span = new(data); + return (T) + JsonSerializer.Deserialize( + span, + type ?? typeof(T), + options ?? SerializerEncoding.NormalJsonOptions + ); + } + catch (SerializationFailureException) + { + throw; + } + catch (Exception e) + { + SerializationFailureException.ThrowCorrupt( + SerializationFormat.Json, + SerializationOperation.Deserialize, + data.Length, + SerializationStage.Decode, + e, + "System.Text.Json rejected the payload." ); + return default; + } + } + + /// + /// Attempts to deserialize JSON bytes. Returns for null/empty/corrupt input. + /// + public static bool TryJsonDeserialize( + byte[] data, + out T value, + Type type = null, + JsonSerializerOptions options = null + ) + { + try + { + value = JsonDeserialize(data, type, options); + return true; + } + catch (SerializationInputException) + { + value = default; + return false; + } + catch (SerializationCorruptDataException) + { + value = default; + return false; + } } /// @@ -1734,10 +2048,62 @@ public static T JsonDeserializeFast(byte[] data) { if (data == null) { - throw new ArgumentNullException(nameof(data)); + SerializationFailureException.ThrowNullInput( + SerializationFormat.JsonFast, + SerializationOperation.Deserialize + ); + } + if (data.Length == 0) + { + SerializationFailureException.ThrowEmptyInput( + SerializationFormat.JsonFast, + SerializationOperation.Deserialize + ); + } + + try + { + ReadOnlySpan span = new(data); + return JsonSerializer.Deserialize(span, SerializerEncoding.FastJsonOptions); + } + catch (SerializationFailureException) + { + throw; + } + catch (Exception e) + { + SerializationFailureException.ThrowCorrupt( + SerializationFormat.JsonFast, + SerializationOperation.Deserialize, + data.Length, + SerializationStage.Decode, + e, + "System.Text.Json (fast options) rejected the payload." + ); + return default; + } + } + + /// + /// Attempts a fast-path JSON deserialize. Returns for null/empty/corrupt input. + /// + public static bool TryJsonDeserializeFast(byte[] data, out T value) + { + try + { + value = JsonDeserializeFast(data); + return true; + } + catch (SerializationInputException) + { + value = default; + return false; + } + catch (SerializationCorruptDataException) + { + value = default; + return false; } - ReadOnlySpan span = new(data); - return JsonSerializer.Deserialize(span, SerializerEncoding.FastJsonOptions); } /// diff --git a/Runtime/Protobuf-Net/System.Collections.Immutable.dll.meta b/Runtime/Protobuf-Net/System.Collections.Immutable.dll.meta index a81ae4ba9..221c7debd 100644 --- a/Runtime/Protobuf-Net/System.Collections.Immutable.dll.meta +++ b/Runtime/Protobuf-Net/System.Collections.Immutable.dll.meta @@ -9,7 +9,7 @@ PluginImporter: isPreloaded: 0 isOverridable: 1 isExplicitlyReferenced: 0 - validateReferences: 1 + validateReferences: 0 platformData: - first: Any: @@ -19,7 +19,7 @@ PluginImporter: - first: Editor: Editor second: - enabled: 0 + enabled: 1 settings: DefaultValueInitialized: true - first: diff --git a/Tests/Editor/CustomDrawers/SerializableSetPropertyDrawerTests.cs b/Tests/Editor/CustomDrawers/SerializableSetPropertyDrawerTests.cs index ff3210910..d3e0c14c8 100644 --- a/Tests/Editor/CustomDrawers/SerializableSetPropertyDrawerTests.cs +++ b/Tests/Editor/CustomDrawers/SerializableSetPropertyDrawerTests.cs @@ -21,6 +21,7 @@ namespace WallstopStudios.UnityHelpers.Tests.CustomDrawers using WallstopStudios.UnityHelpers.Tests.Editor.TestTypes; using WallstopStudios.UnityHelpers.Tests.EditorFramework; using Object = UnityEngine.Object; + using TestData = WallstopStudios.UnityHelpers.Tests.Editor.TestTypes.TestData; // TODO: Consolidate [TestFixture] diff --git a/Tests/Runtime/Serialization/JsonConverterTests.cs b/Tests/Runtime/Serialization/JsonConverterTests.cs index 1f9663a63..9519cb949 100644 --- a/Tests/Runtime/Serialization/JsonConverterTests.cs +++ b/Tests/Runtime/Serialization/JsonConverterTests.cs @@ -45,21 +45,21 @@ public void QuaternionConverterIdentityDefaultWSuccess() public void QuaternionConverterInvalidTokenTypeThrowsException() { string invalidJson = "\"not an object\""; - Assert.Throws(() => Serializer.JsonDeserialize(invalidJson)); + Assert.Throws(() => Serializer.JsonDeserialize(invalidJson)); } [Test] public void QuaternionConverterUnknownPropertyThrowsException() { string invalidJson = "{\"x\":0,\"y\":0,\"z\":0,\"w\":1,\"extra\":0}"; - Assert.Throws(() => Serializer.JsonDeserialize(invalidJson)); + Assert.Throws(() => Serializer.JsonDeserialize(invalidJson)); } [Test] public void QuaternionConverterIncompleteJsonThrowsException() { string incompleteJson = "{\"x\":0.0,\"y\":0.0"; - Assert.Throws(() => + Assert.Throws(() => Serializer.JsonDeserialize(incompleteJson) ); } @@ -106,28 +106,28 @@ public void Color32ConverterDefaultAlphaValueSuccess() public void Color32ConverterInvalidTokenTypeThrowsException() { string invalidJson = "\"not an object\""; - Assert.Throws(() => Serializer.JsonDeserialize(invalidJson)); + Assert.Throws(() => Serializer.JsonDeserialize(invalidJson)); } [Test] public void Color32ConverterUnknownPropertyThrowsException() { string invalidJson = "{\"r\":1,\"g\":2,\"b\":3,\"a\":4,\"x\":5}"; - Assert.Throws(() => Serializer.JsonDeserialize(invalidJson)); + Assert.Throws(() => Serializer.JsonDeserialize(invalidJson)); } [Test] public void Color32ConverterChannelOutOfRangeThrowsException() { string invalidJson = "{\"r\":-1,\"g\":0,\"b\":0,\"a\":0}"; - Assert.Throws(() => Serializer.JsonDeserialize(invalidJson)); + Assert.Throws(() => Serializer.JsonDeserialize(invalidJson)); } [Test] public void Color32ConverterChannelAboveRangeThrowsException() { string invalidJson = "{\"r\":256,\"g\":0,\"b\":0,\"a\":0}"; - Assert.Throws(() => Serializer.JsonDeserialize(invalidJson)); + Assert.Throws(() => Serializer.JsonDeserialize(invalidJson)); } [Test] @@ -145,21 +145,21 @@ public void Vector2IntConverterSerializeAndDeserializeSuccess() public void Vector2IntConverterInvalidTokenTypeThrowsException() { string invalidJson = "\"not an object\""; - Assert.Throws(() => Serializer.JsonDeserialize(invalidJson)); + Assert.Throws(() => Serializer.JsonDeserialize(invalidJson)); } [Test] public void Vector2IntConverterUnknownPropertyThrowsException() { string invalidJson = "{\"x\":1,\"y\":2,\"z\":3}"; - Assert.Throws(() => Serializer.JsonDeserialize(invalidJson)); + Assert.Throws(() => Serializer.JsonDeserialize(invalidJson)); } [Test] public void Vector2IntConverterIncompleteJsonThrowsException() { string incompleteJson = "{\"x\":1"; - Assert.Throws(() => + Assert.Throws(() => Serializer.JsonDeserialize(incompleteJson) ); } @@ -180,21 +180,21 @@ public void Vector3IntConverterSerializeAndDeserializeSuccess() public void Vector3IntConverterInvalidTokenTypeThrowsException() { string invalidJson = "\"not an object\""; - Assert.Throws(() => Serializer.JsonDeserialize(invalidJson)); + Assert.Throws(() => Serializer.JsonDeserialize(invalidJson)); } [Test] public void Vector3IntConverterUnknownPropertyThrowsException() { string invalidJson = "{\"x\":1,\"y\":2,\"z\":3,\"w\":4}"; - Assert.Throws(() => Serializer.JsonDeserialize(invalidJson)); + Assert.Throws(() => Serializer.JsonDeserialize(invalidJson)); } [Test] public void Vector3IntConverterIncompleteJsonThrowsException() { string incompleteJson = "{\"x\":1,\"y\":2"; - Assert.Throws(() => + Assert.Throws(() => Serializer.JsonDeserialize(incompleteJson) ); } @@ -216,21 +216,21 @@ public void RectConverterSerializeAndDeserializeSuccess() public void RectConverterInvalidTokenTypeThrowsException() { string invalidJson = "\"not an object\""; - Assert.Throws(() => Serializer.JsonDeserialize(invalidJson)); + Assert.Throws(() => Serializer.JsonDeserialize(invalidJson)); } [Test] public void RectConverterUnknownPropertyThrowsException() { string invalidJson = "{\"x\":0,\"y\":0,\"width\":1,\"height\":1,\"extra\":0}"; - Assert.Throws(() => Serializer.JsonDeserialize(invalidJson)); + Assert.Throws(() => Serializer.JsonDeserialize(invalidJson)); } [Test] public void RectConverterIncompleteJsonThrowsException() { string incompleteJson = "{\"x\":0,\"y\":0"; - Assert.Throws(() => Serializer.JsonDeserialize(incompleteJson)); + Assert.Throws(() => Serializer.JsonDeserialize(incompleteJson)); } [Test] @@ -261,21 +261,21 @@ public void RectIntConverterSerializeAndDeserializeSuccess() public void RectIntConverterInvalidTokenTypeThrowsException() { string invalidJson = "\"not an object\""; - Assert.Throws(() => Serializer.JsonDeserialize(invalidJson)); + Assert.Throws(() => Serializer.JsonDeserialize(invalidJson)); } [Test] public void RectIntConverterUnknownPropertyThrowsException() { string invalidJson = "{\"x\":0,\"y\":0,\"width\":1,\"height\":1,\"extra\":0}"; - Assert.Throws(() => Serializer.JsonDeserialize(invalidJson)); + Assert.Throws(() => Serializer.JsonDeserialize(invalidJson)); } [Test] public void RectIntConverterIncompleteJsonThrowsException() { string incompleteJson = "{\"x\":0,\"y\":0"; - Assert.Throws(() => Serializer.JsonDeserialize(incompleteJson)); + Assert.Throws(() => Serializer.JsonDeserialize(incompleteJson)); } [Test] @@ -293,7 +293,7 @@ public void BoundsConverterSerializeAndDeserializeSuccess() public void BoundsConverterInvalidTokenTypeThrowsException() { string invalidJson = "\"not an object\""; - Assert.Throws(() => Serializer.JsonDeserialize(invalidJson)); + Assert.Throws(() => Serializer.JsonDeserialize(invalidJson)); } [Test] @@ -301,14 +301,14 @@ public void BoundsConverterUnknownPropertyThrowsException() { string invalidJson = "{\"center\":{\"x\":0,\"y\":0,\"z\":0},\"size\":{\"x\":1,\"y\":1,\"z\":1},\"extra\":1}"; - Assert.Throws(() => Serializer.JsonDeserialize(invalidJson)); + Assert.Throws(() => Serializer.JsonDeserialize(invalidJson)); } [Test] public void BoundsConverterIncompleteJsonThrowsException() { string incompleteJson = "{\"center\":{\"x\":0}}"; - Assert.Throws(() => Serializer.JsonDeserialize(incompleteJson)); + Assert.Throws(() => Serializer.JsonDeserialize(incompleteJson)); } [Test] @@ -335,7 +335,7 @@ public void BoundsIntConverterSerializeAndDeserializeSuccess() public void BoundsIntConverterInvalidTokenTypeThrowsException() { string invalidJson = "\"not an object\""; - Assert.Throws(() => Serializer.JsonDeserialize(invalidJson)); + Assert.Throws(() => Serializer.JsonDeserialize(invalidJson)); } [Test] @@ -343,14 +343,14 @@ public void BoundsIntConverterUnknownPropertyThrowsException() { string invalidJson = "{\"position\":{\"x\":0,\"y\":0,\"z\":0},\"size\":{\"x\":1,\"y\":1,\"z\":1},\"extra\":1}"; - Assert.Throws(() => Serializer.JsonDeserialize(invalidJson)); + Assert.Throws(() => Serializer.JsonDeserialize(invalidJson)); } [Test] public void BoundsIntConverterIncompleteJsonThrowsException() { string incompleteJson = "{\"position\":{\"x\":0}}"; - Assert.Throws(() => + Assert.Throws(() => Serializer.JsonDeserialize(incompleteJson) ); } @@ -400,21 +400,21 @@ public void Vector2ConverterNegativeValuesSuccess() public void Vector2ConverterInvalidTokenTypeThrowsException() { string invalidJson = "\"not an object\""; - Assert.Throws(() => Serializer.JsonDeserialize(invalidJson)); + Assert.Throws(() => Serializer.JsonDeserialize(invalidJson)); } [Test] public void Vector2ConverterUnknownPropertyThrowsException() { string invalidJson = "{\"x\":1.0,\"y\":2.0,\"z\":3.0}"; - Assert.Throws(() => Serializer.JsonDeserialize(invalidJson)); + Assert.Throws(() => Serializer.JsonDeserialize(invalidJson)); } [Test] public void Vector2ConverterIncompleteJsonThrowsException() { string incompleteJson = "{\"x\":1.0"; - Assert.Throws(() => Serializer.JsonDeserialize(incompleteJson)); + Assert.Throws(() => Serializer.JsonDeserialize(incompleteJson)); } [Test] @@ -455,21 +455,21 @@ public void Vector3ConverterNegativeValuesSuccess() public void Vector3ConverterInvalidTokenTypeThrowsException() { string invalidJson = "\"not an object\""; - Assert.Throws(() => Serializer.JsonDeserialize(invalidJson)); + Assert.Throws(() => Serializer.JsonDeserialize(invalidJson)); } [Test] public void Vector3ConverterUnknownPropertyThrowsException() { string invalidJson = "{\"x\":1.0,\"y\":2.0,\"z\":3.0,\"w\":4.0}"; - Assert.Throws(() => Serializer.JsonDeserialize(invalidJson)); + Assert.Throws(() => Serializer.JsonDeserialize(invalidJson)); } [Test] public void Vector3ConverterIncompleteJsonThrowsException() { string incompleteJson = "{\"x\":1.0,\"y\":2.0"; - Assert.Throws(() => Serializer.JsonDeserialize(incompleteJson)); + Assert.Throws(() => Serializer.JsonDeserialize(incompleteJson)); } [Test] @@ -512,21 +512,21 @@ public void Vector4ConverterNegativeValuesSuccess() public void Vector4ConverterInvalidTokenTypeThrowsException() { string invalidJson = "\"not an object\""; - Assert.Throws(() => Serializer.JsonDeserialize(invalidJson)); + Assert.Throws(() => Serializer.JsonDeserialize(invalidJson)); } [Test] public void Vector4ConverterUnknownPropertyThrowsException() { string invalidJson = "{\"x\":1.0,\"y\":2.0,\"z\":3.0,\"w\":4.0,\"extra\":5.0}"; - Assert.Throws(() => Serializer.JsonDeserialize(invalidJson)); + Assert.Throws(() => Serializer.JsonDeserialize(invalidJson)); } [Test] public void Vector4ConverterIncompleteJsonThrowsException() { string incompleteJson = "{\"x\":1.0,\"y\":2.0,\"z\":3.0"; - Assert.Throws(() => Serializer.JsonDeserialize(incompleteJson)); + Assert.Throws(() => Serializer.JsonDeserialize(incompleteJson)); } [Test] @@ -585,21 +585,21 @@ public void ColorConverterDefaultAlphaValueSuccess() public void ColorConverterInvalidTokenTypeThrowsException() { string invalidJson = "\"not an object\""; - Assert.Throws(() => Serializer.JsonDeserialize(invalidJson)); + Assert.Throws(() => Serializer.JsonDeserialize(invalidJson)); } [Test] public void ColorConverterUnknownPropertyThrowsException() { string invalidJson = "{\"r\":0.5,\"g\":0.5,\"b\":0.5,\"a\":1.0,\"extra\":0.0}"; - Assert.Throws(() => Serializer.JsonDeserialize(invalidJson)); + Assert.Throws(() => Serializer.JsonDeserialize(invalidJson)); } [Test] public void ColorConverterIncompleteJsonThrowsException() { string incompleteJson = "{\"r\":0.5,\"g\":0.5"; - Assert.Throws(() => Serializer.JsonDeserialize(incompleteJson)); + Assert.Throws(() => Serializer.JsonDeserialize(incompleteJson)); } [Test] @@ -666,14 +666,14 @@ public void Matrix4X4ConverterTranslationMatrixSuccess() public void Matrix4X4ConverterInvalidTokenTypeThrowsException() { string invalidJson = "\"not an object\""; - Assert.Throws(() => Serializer.JsonDeserialize(invalidJson)); + Assert.Throws(() => Serializer.JsonDeserialize(invalidJson)); } [Test] public void Matrix4X4ConverterMissingPropertyThrowsException() { string incompleteJson = "{\"m00\":1.0,\"m01\":0.0,\"m02\":0.0,\"m03\":0.0}"; - Assert.Throws(() => + Assert.Throws(() => Serializer.JsonDeserialize(incompleteJson) ); } @@ -683,7 +683,7 @@ public void Matrix4X4ConverterInvalidPropertyValueThrowsException() { string invalidJson = "{\"m00\":\"invalid\",\"m01\":0.0,\"m02\":0.0,\"m03\":0.0,\"m10\":0.0,\"m11\":1.0,\"m12\":0.0,\"m13\":0.0,\"m20\":0.0,\"m21\":0.0,\"m22\":1.0,\"m23\":0.0,\"m30\":0.0,\"m31\":0.0,\"m32\":0.0,\"m33\":1.0}"; - Assert.Throws(() => Serializer.JsonDeserialize(invalidJson)); + Assert.Throws(() => Serializer.JsonDeserialize(invalidJson)); } // Note: TypeConverter is tested within object contexts in JsonSerializationTest.SerializationWorks @@ -843,7 +843,7 @@ public void LayerMaskConverterAcceptsLayersByName() public void LayerMaskConverterInvalidTokenTypeThrowsException() { string invalidJson = "{\"layers\":\"not array\"}"; - Assert.Throws(() => Serializer.JsonDeserialize(invalidJson)); + Assert.Throws(() => Serializer.JsonDeserialize(invalidJson)); } [Test] @@ -860,7 +860,7 @@ public void PoseConverterSerializeAndDeserializeSuccess() public void PoseConverterUnknownPropertyThrowsException() { string invalidJson = "{\"position\":{\"x\":0,\"y\":0,\"z\":0},\"extra\":0}"; - Assert.Throws(() => Serializer.JsonDeserialize(invalidJson)); + Assert.Throws(() => Serializer.JsonDeserialize(invalidJson)); } [Test] diff --git a/Tests/Runtime/Serialization/JsonSerializationCorrectnessTests.cs b/Tests/Runtime/Serialization/JsonSerializationCorrectnessTests.cs index afa58e513..791aab17e 100644 --- a/Tests/Runtime/Serialization/JsonSerializationCorrectnessTests.cs +++ b/Tests/Runtime/Serialization/JsonSerializationCorrectnessTests.cs @@ -317,10 +317,13 @@ public void RoundTripLargeCollections() { Id = 888, Name = "LargeCollection", - Values = new List(100_000), + Values = new List(10_000), }; - for (int i = 0; i < 100_000; ++i) + // 10k still exercises large-collection round-trip paths while keeping + // this correctness test in the fast suite (full-scale throughput is + // covered by the Performance-categorized benchmark suite). + for (int i = 0; i < 10_000; ++i) { msg.Values.Add(i); } @@ -330,7 +333,7 @@ public void RoundTripLargeCollections() Assert.AreEqual(msg.Id, clone.Id); Assert.AreEqual(msg.Name, clone.Name); - Assert.AreEqual(100_000, clone.Values.Count); + Assert.AreEqual(10_000, clone.Values.Count); CollectionAssert.AreEqual(msg.Values, clone.Values); } @@ -338,7 +341,7 @@ public void RoundTripLargeCollections() public void DeserializeInvalidJsonThrowsException() { string invalidJson = "{ invalid json }"; - Assert.Throws(() => + Assert.Throws(() => Serializer.JsonDeserialize(invalidJson) ); } @@ -347,7 +350,7 @@ public void DeserializeInvalidJsonThrowsException() public void DeserializeMalformedJsonThrowsException() { string malformedJson = "{\"Id\": 123, \"Name\": \"Test\""; // Missing closing brace - Assert.Throws(() => + Assert.Throws(() => Serializer.JsonDeserialize(malformedJson) ); } @@ -355,7 +358,7 @@ public void DeserializeMalformedJsonThrowsException() [Test] public void DeserializeEmptyStringThrowsException() { - Assert.Throws(() => + Assert.Throws(() => Serializer.JsonDeserialize(string.Empty) ); } diff --git a/Tests/Runtime/Serialization/ProtoInterfaceResolutionEdgeTests.cs b/Tests/Runtime/Serialization/ProtoInterfaceResolutionEdgeTests.cs index e67e72072..f8bcaa50e 100644 --- a/Tests/Runtime/Serialization/ProtoInterfaceResolutionEdgeTests.cs +++ b/Tests/Runtime/Serialization/ProtoInterfaceResolutionEdgeTests.cs @@ -5,6 +5,7 @@ namespace WallstopStudios.UnityHelpers.Tests.Serialization { using NUnit.Framework; using ProtoBuf; + using WallstopStudios.UnityHelpers.Core.Serialization; using Serializer = WallstopStudios.UnityHelpers.Core.Serialization.Serializer; [TestFixture] @@ -29,7 +30,7 @@ public void SingleImplementationRequiresRegistration() IWidget original = new Widget { Id = 3, Label = "ok" }; byte[] data = Serializer.ProtoSerialize(original); - Assert.Throws( + Assert.Throws( () => Serializer.ProtoDeserialize(data), "Deserializing interface even with a single implementation should require registration" ); @@ -71,7 +72,7 @@ public void AbstractBaseWithoutRegistrationThrows() AbstractBase original = new DerivedA { Common = 9, ExtraA = "x" }; byte[] data = Serializer.ProtoSerialize(original, forceRuntimeType: true); - Assert.Throws( + Assert.Throws( () => Serializer.ProtoDeserialize(data), "Deserializing abstract base with multiple derived types should require registration" ); diff --git a/Tests/Runtime/Serialization/ProtoSerializationTests.cs b/Tests/Runtime/Serialization/ProtoSerializationTests.cs index cf1e55a6f..eaf264140 100644 --- a/Tests/Runtime/Serialization/ProtoSerializationTests.cs +++ b/Tests/Runtime/Serialization/ProtoSerializationTests.cs @@ -88,39 +88,36 @@ public void SerializeThrowsForUnsupportedSerializationType() { SampleMessage sample = new(); - Assert.Throws(() => + Assert.Throws(() => Serializer.Serialize(sample, (SerializationType)999) ); - Assert.Throws(() => + Assert.Throws(() => Serializer.Deserialize(Array.Empty(), (SerializationType)999) ); } [Test] - public void ProtoDeserializeHandlesEmpty() + public void ProtoDeserializeEmptyArrayThrowsSerializationInputException() { - SampleMessage message = Serializer.ProtoDeserialize(Array.Empty()); - Assert.IsTrue( - message != null, - "ProtoDeserialize should return non-null for empty byte array" + // Empty byte[] is unambiguously caller error: no codec can decode 0 bytes into a meaningful instance. + Assert.Throws(() => + Serializer.ProtoDeserialize(Array.Empty()) ); - SampleMessage expected = new(); - Assert.AreEqual(expected.Id, message.Id); - Assert.AreEqual(expected.Name, message.Name); - Assert.AreEqual(expected.Values, message.Values); } [Test] public void ProtoDeserializeThrowsWhenDataNull() { - Assert.Throws(() => Serializer.ProtoDeserialize(null)); + Assert.Throws(() => + Serializer.ProtoDeserialize(null) + ); } [Test] public void ProtoDeserializeThrowsWhenDataIsGarbage() { byte[] garbage = { 0xFF, 0x00, 0x01, 0x02, 0xAB, 0xCD }; - Assert.Throws(() => + Assert.Throws(() => Serializer.ProtoDeserialize(garbage) ); } @@ -130,28 +127,17 @@ public void ProtoDeserializeWithExplicitTypeThrowsWhenTypeNull() { SampleMessage original = new() { Id = 1, Name = "NullType" }; byte[] data = Serializer.ProtoSerialize(original); - Assert.Throws(() => + Assert.Throws(() => Serializer.ProtoDeserialize(data, null) ); } [Test] - public void ProtoDeserializeWithExplicitTypeWhenDataEmpty() + public void ProtoDeserializeWithExplicitTypeWhenDataEmptyThrows() { - object message = Serializer.ProtoDeserialize( - Array.Empty(), - typeof(SampleMessage) - ); - Assert.IsTrue( - message != null, - "ProtoDeserialize should return non-null for empty byte array with explicit type" + Assert.Throws(() => + Serializer.ProtoDeserialize(Array.Empty(), typeof(SampleMessage)) ); - Assert.IsInstanceOf(message); - SampleMessage sample = (SampleMessage)message; - SampleMessage expected = new(); - Assert.AreEqual(expected.Id, sample.Id); - Assert.AreEqual(expected.Name, sample.Name); - Assert.AreEqual(expected.Values, sample.Values); } [Test] diff --git a/Tests/Runtime/Serialization/SerializationFailureExceptionTests.cs b/Tests/Runtime/Serialization/SerializationFailureExceptionTests.cs new file mode 100644 index 000000000..067a57be1 --- /dev/null +++ b/Tests/Runtime/Serialization/SerializationFailureExceptionTests.cs @@ -0,0 +1,242 @@ +// MIT License - Copyright (c) 2025 wallstop +// Full license text: https://github.com/wallstop/unity-helpers/blob/main/LICENSE + +namespace WallstopStudios.UnityHelpers.Tests.Serialization +{ + using System; + using System.IO; + using System.Runtime.Serialization.Formatters.Binary; + using NUnit.Framework; + using WallstopStudios.UnityHelpers.Core.Serialization; + + /// + /// Unit tests for the hierarchy itself: + /// property immutability, lazy Message composition, ToString clarity, InnerException + /// preservation, and round-trip. + /// + [TestFixture] + [NUnit.Framework.Category("Fast")] + public sealed class SerializationFailureExceptionTests + { + [Test] + public void PropertiesAreImmutableAfterConstruction() + { + SerializationInputException ex = new( + SerializationFormat.Protobuf, + SerializationOperation.Deserialize, + typeof(int), + "null", + "data is null." + ); + + Assert.AreEqual(SerializationFormat.Protobuf, ex.Format); + Assert.AreEqual(SerializationOperation.Deserialize, ex.Operation); + Assert.AreEqual(typeof(int), ex.DeclaredType); + Assert.AreEqual(SerializationStage.InputValidation, ex.Stage); + Assert.AreEqual("null", ex.InputDescriptor); + Assert.AreEqual("data is null.", ex.Reason); + Assert.IsTrue(ex.InnerException == null); + } + + [Test] + public void MessageIsLazilyComposedAndStableAcrossCalls() + { + SerializationInputException ex = new( + SerializationFormat.Json, + SerializationOperation.Deserialize, + typeof(string), + "null", + "data is null." + ); + + string first = ex.Message; + string second = ex.Message; + Assert.AreSame(first, second, "Message must be cached on first access."); + StringAssert.Contains("Json", first); + StringAssert.Contains("Deserialize", first); + StringAssert.Contains("System.String", first); + StringAssert.Contains("null", first); + StringAssert.Contains("data is null.", first); + } + + [Test] + public void CorruptDataExceptionPreservesInnerException() + { + InvalidOperationException inner = new("codec rejected"); + SerializationCorruptDataException ex = new( + SerializationFormat.Protobuf, + SerializationOperation.Deserialize, + typeof(object), + "byte[16]", + SerializationStage.Decode, + "protobuf-net rejected the payload.", + inner + ); + Assert.AreSame(inner, ex.InnerException); + Assert.AreEqual(SerializationStage.Decode, ex.Stage); + StringAssert.Contains("protobuf-net rejected the payload.", ex.Message); + } + + [Test] + public void ThrowNullInputPopulatesProperties() + { + SerializationInputException ex = Assert.Throws(() => + SerializationFailureException.ThrowNullInput( + SerializationFormat.Binary, + SerializationOperation.Deserialize + ) + ); + Assert.AreEqual(SerializationFormat.Binary, ex.Format); + Assert.AreEqual(typeof(int), ex.DeclaredType); + Assert.IsTrue(ex.InnerException == null); + } + + [Test] + public void ThrowCorruptWrapsInnerExceptionAndDescribesLength() + { + Exception inner = new InvalidDataException("bad"); + SerializationCorruptDataException ex = Assert.Throws( + () => + SerializationFailureException.ThrowCorrupt( + SerializationFormat.Json, + SerializationOperation.Deserialize, + inputLength: 256, + SerializationStage.Decode, + inner + ) + ); + Assert.AreSame(inner, ex.InnerException); + StringAssert.Contains("byte[256]", ex.Message); + } + + [Test] + public void SubclassesAreDistinguishableByType() + { + SerializationFailureException inputEx = new SerializationInputException( + SerializationFormat.Protobuf, + SerializationOperation.Deserialize, + typeof(int), + "null", + "x" + ); + SerializationFailureException corruptEx = new SerializationCorruptDataException( + SerializationFormat.Protobuf, + SerializationOperation.Deserialize, + typeof(int), + "byte[3]", + SerializationStage.Decode, + "x", + new InvalidOperationException() + ); + SerializationFailureException typeEx = new SerializationTypeException( + SerializationFormat.Protobuf, + SerializationOperation.Deserialize, + typeof(int), + "", + "x" + ); + SerializationFailureException configEx = new SerializationConfigurationException( + SerializationFormat.Dispatcher, + SerializationOperation.Deserialize, + typeof(int), + "", + "x" + ); + + Assert.IsInstanceOf(inputEx); + Assert.IsInstanceOf(corruptEx); + Assert.IsInstanceOf(typeEx); + Assert.IsInstanceOf(configEx); + Assert.IsNotInstanceOf(inputEx); + Assert.IsNotInstanceOf(corruptEx); + } + + [Test] + public void InputDescriptorNeverContainsPayloadBytes() + { + // Defense-in-depth: regardless of how we describe the input, the descriptor must not + // contain the raw payload bytes (sensitive-data guarantee). + byte[] secret = { 0x53, 0x65, 0x63, 0x72, 0x65, 0x74 }; // "Secret" + SerializationCorruptDataException ex = null; + try + { + SerializationFailureException.ThrowCorrupt( + SerializationFormat.Protobuf, + SerializationOperation.Deserialize, + secret.Length, + SerializationStage.Decode, + new InvalidOperationException("simulated codec failure") + ); + } + catch (SerializationCorruptDataException caught) + { + ex = caught; + } + Assert.IsTrue(ex != null); + StringAssert.DoesNotContain("Secret", ex.Message); + StringAssert.DoesNotContain("53", ex.InputDescriptor); // hex of 'S' + // Length is fine — that's intentional. + StringAssert.Contains("byte[6]", ex.InputDescriptor); + } + +#pragma warning disable SYSLIB0011 // Type or member is obsolete (BinaryFormatter) + [Test] + public void ExceptionRoundTripsThroughBinarySerialization() + { + // Sanity-check that the [Serializable] attribute + GetObjectData/ctor pair work. + // BinaryFormatter is obsolete and unsafe — used here purely as a serialization probe. + SerializationInputException original = new( + SerializationFormat.Protobuf, + SerializationOperation.Deserialize, + typeof(int), + "null", + "data is null." + ); + string originalMessage = original.Message; // force composition + + using MemoryStream ms = new(); + BinaryFormatter formatter = new(); + formatter.Serialize(ms, original); + ms.Position = 0; + SerializationInputException copy = (SerializationInputException) + formatter.Deserialize(ms); + + Assert.AreEqual(original.Format, copy.Format); + Assert.AreEqual(original.Operation, copy.Operation); + Assert.AreEqual(original.DeclaredType, copy.DeclaredType); + Assert.AreEqual(original.Stage, copy.Stage); + Assert.AreEqual(original.InputDescriptor, copy.InputDescriptor); + Assert.AreEqual(original.Reason, copy.Reason); + Assert.AreEqual(originalMessage, copy.Message); + } + + [Serializable] + public sealed class PublicNestedSample { } + + [Test] + public void ExceptionRoundTripsWithNestedTypeReference() + { + // Verifies that a non-trivial Type (a nested class in this test assembly) survives the + // AssemblyQualifiedName round-trip. If the assembly is trimmed at runtime, DeclaredType + // would resolve to null — that's the documented contract. + SerializationInputException original = new( + SerializationFormat.Protobuf, + SerializationOperation.Deserialize, + typeof(PublicNestedSample), + "null", + "data is null." + ); + + using MemoryStream ms = new(); + BinaryFormatter formatter = new(); + formatter.Serialize(ms, original); + ms.Position = 0; + SerializationInputException copy = (SerializationInputException) + formatter.Deserialize(ms); + + // In the test process the assembly is always loaded — round-trip should preserve the Type. + Assert.AreEqual(typeof(PublicNestedSample), copy.DeclaredType); + } +#pragma warning restore SYSLIB0011 + } +} diff --git a/Tests/Runtime/Serialization/SerializationFailureExceptionTests.cs.meta b/Tests/Runtime/Serialization/SerializationFailureExceptionTests.cs.meta new file mode 100644 index 000000000..a7f141c70 --- /dev/null +++ b/Tests/Runtime/Serialization/SerializationFailureExceptionTests.cs.meta @@ -0,0 +1,10 @@ +fileFormatVersion: 2 +guid: 90afcbc176d94bbd8a0347c834f7799c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: diff --git a/Tests/Runtime/Serialization/SerializerAdditionalTests.cs b/Tests/Runtime/Serialization/SerializerAdditionalTests.cs index cc8db6d75..de0ac129b 100644 --- a/Tests/Runtime/Serialization/SerializerAdditionalTests.cs +++ b/Tests/Runtime/Serialization/SerializerAdditionalTests.cs @@ -339,7 +339,7 @@ public void GenericSerializeInvalidSerializationTypeThrowsException() { TestMessage msg = new() { Id = 1, Name = "Test" }; - Assert.Throws(() => + Assert.Throws(() => Serializer.Serialize(msg, (SerializationType)999) ); } @@ -349,7 +349,7 @@ public void GenericDeserializeInvalidSerializationTypeThrowsException() { byte[] data = { 1, 2, 3 }; - Assert.Throws(() => + Assert.Throws(() => Serializer.Deserialize(data, (SerializationType)999) ); } @@ -359,7 +359,7 @@ public void GenericSerializeNoneTypeThrowsException() { TestMessage msg = new() { Id = 1, Name = "Test" }; - Assert.Throws(() => + Assert.Throws(() => #pragma warning disable CS0618 // Type or member is obsolete Serializer.Serialize(msg, SerializationType.None) #pragma warning restore CS0618 // Type or member is obsolete @@ -371,7 +371,7 @@ public void GenericDeserializeNoneTypeThrowsException() { byte[] data = { 1, 2, 3 }; - Assert.Throws(() => + Assert.Throws(() => #pragma warning disable CS0618 // Type or member is obsolete Serializer.Deserialize(data, SerializationType.None) #pragma warning restore CS0618 // Type or member is obsolete @@ -446,7 +446,7 @@ public void GenericSerializeWithBufferInvalidTypeThrowsException() TestMessage msg = new() { Id = 1 }; byte[] buffer = null; - Assert.Throws(() => + Assert.Throws(() => Serializer.Serialize(msg, (SerializationType)999, ref buffer) ); } @@ -812,19 +812,19 @@ public void BinaryDeserializeCorruptedDataThrowsException() [Test] public void ProtoDeserializeNullDataThrowsException() { - Assert.Throws(() => Serializer.ProtoDeserialize(null)); + Assert.Throws( + () => Serializer.ProtoDeserialize(null) + ); } [Test] - public void ProtoDeserializeEmptyArrayReturnsDefaultInstance() + public void ProtoDeserializeEmptyArrayThrowsInputException() { byte[] emptyData = Array.Empty(); - // Protobuf allows deserializing empty data as default instance - TestMessage result = Serializer.ProtoDeserialize(emptyData); - - Assert.NotNull(result); - Assert.AreEqual(0, result.Id); + Assert.Throws( + () => Serializer.ProtoDeserialize(emptyData) + ); } [Test] @@ -913,7 +913,7 @@ public void FileIOReadFromInvalidJsonThrowsException() string filePath = Path.Combine(_tempDirectory, "invalid.json"); File.WriteAllText(filePath, "{ invalid json content }"); - Assert.Throws(() => Serializer.ReadFromJsonFile(filePath)); + Assert.Throws(() => Serializer.ReadFromJsonFile(filePath)); } } } diff --git a/Tests/Runtime/Serialization/SerializerApiContractTests.cs b/Tests/Runtime/Serialization/SerializerApiContractTests.cs new file mode 100644 index 000000000..2cf1a52a9 --- /dev/null +++ b/Tests/Runtime/Serialization/SerializerApiContractTests.cs @@ -0,0 +1,213 @@ +// MIT License - Copyright (c) 2025 wallstop +// Full license text: https://github.com/wallstop/unity-helpers/blob/main/LICENSE + +namespace WallstopStudios.UnityHelpers.Tests.Serialization +{ + using System; + using System.Collections.Generic; + using System.Linq; + using System.Reflection; + using NUnit.Framework; + using WallstopStudios.UnityHelpers.Core.Serialization; + using Serializer = WallstopStudios.UnityHelpers.Core.Serialization.Serializer; + + /// + /// The "forever" gate: a reflection-based architecture test that fails the build if any future + /// PR adds a public deserialize method on without a matching + /// TryXxx sibling — preventing reintroduction of the screenshot bug class. + /// + [TestFixture] + [NUnit.Framework.Category("Fast")] + public sealed class SerializerApiContractTests + { + private static readonly Type SerializerType = typeof(Serializer); + + private static IReadOnlyList PublicMethods => + SerializerType + .GetMethods(BindingFlags.Public | BindingFlags.Static) + .Where(m => !m.IsSpecialName) + .ToList(); + + private static bool IsDataParameter(ParameterInfo p) => + p.ParameterType == typeof(byte[]) || p.ParameterType == typeof(string); + + private static bool IsDeserializeMethod(MethodInfo m) + { + // The "forever gate": flag every public static method that takes a byte[]/string payload + // and returns a value (i.e. acts as a deserializer) but isn't a Try*/Serialize* variant. + // Catches deserialize-shaped methods regardless of whether their name contains + // "Deserialize" (e.g. a future "Decode" helper would also trip this). + // ReadFromJsonFile* is excluded because its first argument is a *path* — file-IO + // methods have their own established Try* siblings (TryReadFromJsonFile) and a different + // contract (file-existence semantics). + if (m.Name.StartsWith("Try", StringComparison.Ordinal)) + { + return false; + } + if ( + m.Name.StartsWith("Serialize", StringComparison.Ordinal) + || m.Name.EndsWith("Serialize", StringComparison.Ordinal) + || m.Name.Contains("Stringify", StringComparison.Ordinal) + || m.Name.Contains("WriteTo", StringComparison.Ordinal) + || m.Name.Contains("ReadFrom", StringComparison.Ordinal) + || m.Name.Contains("Register", StringComparison.Ordinal) + ) + { + return false; + } + ParameterInfo[] parameters = m.GetParameters(); + if (parameters.Length == 0 || !IsDataParameter(parameters[0])) + { + return false; + } + // Must return a value (deserialize-shaped). + if (m.ReturnType == typeof(void) || m.ReturnType == typeof(bool)) + { + return false; + } + // Heuristic: name must contain Deserialize, Decode, Parse, or From. + return m.Name.Contains("Deserialize", StringComparison.Ordinal) + || m.Name.Contains("Decode", StringComparison.Ordinal) + || m.Name.Contains("Parse", StringComparison.Ordinal); + } + + [Test] + public void EveryPublicDeserializerHasMatchingTrySibling() + { + List missing = new(); + foreach (MethodInfo method in PublicMethods.Where(IsDeserializeMethod)) + { + string expectedTryName = "Try" + method.Name; + bool hasSibling = PublicMethods.Any(candidate => + candidate.Name == expectedTryName && HasMatchingTrySignature(method, candidate) + ); + if (!hasSibling) + { + missing.Add(FormatSignature(method)); + } + } + + if (missing.Count > 0) + { + Assert.Fail( + "The following public Serializer deserialize methods lack a matching Try* sibling — " + + "every new deserializer MUST ship with one (see .llm/skills/serialization-safety.md):\n " + + string.Join("\n ", missing) + ); + } + } + + private static bool HasMatchingTrySignature(MethodInfo source, MethodInfo candidate) + { + // Generic arity must match. + if (source.IsGenericMethodDefinition != candidate.IsGenericMethodDefinition) + { + return false; + } + if ( + source.IsGenericMethodDefinition + && source.GetGenericArguments().Length != candidate.GetGenericArguments().Length + ) + { + return false; + } + + // Candidate must accept the source's data parameter type as its first arg, and + // expose an `out` parameter convertible from the source return type somewhere. + ParameterInfo[] sourceParameters = source.GetParameters(); + ParameterInfo[] candidateParameters = candidate.GetParameters(); + if (candidateParameters.Length == 0) + { + return false; + } + if (candidateParameters[0].ParameterType != sourceParameters[0].ParameterType) + { + return false; + } + return candidateParameters.Any(p => p.IsOut); + } + + private static string FormatSignature(MethodInfo method) + { + string parameters = string.Join( + ", ", + method.GetParameters().Select(p => p.ParameterType.Name + " " + p.Name) + ); + return method.DeclaringType?.Name + "." + method.Name + "(" + parameters + ")"; + } + + // --------------------------------------------------------------------------- + // The Try* family must, by reflection, share the documented contract: + // accept null/empty/corrupt input without throwing. + // --------------------------------------------------------------------------- + + [Test] + public void EveryPublicTryDeserializerHandlesNullPayloadWithoutThrowing() + { + // Build a list of (methodInfo, null-payload) test cases. + foreach (MethodInfo method in PublicMethods) + { + if (!method.Name.StartsWith("Try", StringComparison.Ordinal)) + { + continue; + } + if (!method.Name.Contains("Deserialize", StringComparison.Ordinal)) + { + continue; + } + ParameterInfo[] parameters = method.GetParameters(); + if (parameters.Length == 0 || !IsDataParameter(parameters[0])) + { + continue; + } + // Skip Type-based overloads (require a non-null Type, which is a configuration concern). + if (parameters.Any(p => p.ParameterType == typeof(Type))) + { + continue; + } + // Skip file-IO TryRead/TryWrite (different contract — they don't take byte[]/string at all). + + MethodInfo concreteMethod = method; + if (method.IsGenericMethodDefinition) + { + concreteMethod = method.MakeGenericMethod(typeof(string)); + } + + object[] args = new object[parameters.Length]; + args[0] = null; // null payload + for (int i = 1; i < parameters.Length; i++) + { + ParameterInfo p = parameters[i]; + args[i] = p.HasDefaultValue + ? p.DefaultValue + : ( + p.ParameterType.IsValueType + ? Activator.CreateInstance(p.ParameterType) + : null + ); + } + + try + { + object result = concreteMethod.Invoke(null, args); + Assert.AreEqual( + false, + result, + "Try* method " + method.Name + " must return false on null input." + ); + } + catch (TargetInvocationException tie) + { + Assert.Fail( + "Try* method " + + method.Name + + " leaked an exception on null input: " + + tie.InnerException?.GetType().FullName + + ": " + + tie.InnerException?.Message + ); + } + } + } + } +} diff --git a/Tests/Runtime/Serialization/SerializerApiContractTests.cs.meta b/Tests/Runtime/Serialization/SerializerApiContractTests.cs.meta new file mode 100644 index 000000000..f27b5c1bf --- /dev/null +++ b/Tests/Runtime/Serialization/SerializerApiContractTests.cs.meta @@ -0,0 +1,10 @@ +fileFormatVersion: 2 +guid: 5c61af2568fe41109ba4ba203259c926 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: diff --git a/Tests/Runtime/Serialization/SerializerExceptionContractTests.cs b/Tests/Runtime/Serialization/SerializerExceptionContractTests.cs new file mode 100644 index 000000000..754e8ab38 --- /dev/null +++ b/Tests/Runtime/Serialization/SerializerExceptionContractTests.cs @@ -0,0 +1,355 @@ +// MIT License - Copyright (c) 2025 wallstop +// Full license text: https://github.com/wallstop/unity-helpers/blob/main/LICENSE + +namespace WallstopStudios.UnityHelpers.Tests.Serialization +{ + using System; + using System.Collections.Generic; + using NUnit.Framework; + using ProtoBuf; + using WallstopStudios.UnityHelpers.Core.Serialization; + using Serializer = WallstopStudios.UnityHelpers.Core.Serialization.Serializer; + + /// + /// Regression tests covering the exception contract on every public deserialize entry point. + /// Locks in the screenshot bug fix: new MemoryStream(null) can never surface again, and + /// every failure surfaces as a (or a documented subclass) + /// — never a leaked framework exception (ProtoException, JsonException, etc.). + /// + [TestFixture] + [NUnit.Framework.Category("Fast")] + public sealed class SerializerExceptionContractTests + { + [ProtoContract] + private sealed class Sample + { + [ProtoMember(1)] + public int Id { get; set; } + + [ProtoMember(2)] + public string Name { get; set; } + } + + // --------------------------------------------------------------------------- + // Null input — InputException, never a framework exception. + // --------------------------------------------------------------------------- + + [Test] + public void ProtoDeserializeNullBytesThrowsSerializationInputException() + { + SerializationInputException ex = Assert.Throws(() => + Serializer.ProtoDeserialize(null) + ); + Assert.AreEqual(SerializationFormat.Protobuf, ex.Format); + Assert.AreEqual(SerializationOperation.Deserialize, ex.Operation); + Assert.AreEqual(SerializationStage.InputValidation, ex.Stage); + Assert.AreEqual(typeof(Sample), ex.DeclaredType); + Assert.IsTrue(ex.InnerException == null); + StringAssert.Contains("Protobuf", ex.Message); + StringAssert.Contains("null", ex.Message); + } + + [Test] + public void ProtoDeserializeWithTypeNullBytesThrowsSerializationInputException() + { + Assert.Throws(() => + Serializer.ProtoDeserialize(null, typeof(Sample)) + ); + } + + [Test] + public void JsonDeserializeBytesNullBytesThrowsSerializationInputException() + { + SerializationInputException ex = Assert.Throws(() => + Serializer.JsonDeserialize((byte[])null) + ); + Assert.AreEqual(SerializationFormat.Json, ex.Format); + Assert.IsTrue(ex.InnerException == null); + } + + [Test] + public void JsonDeserializeStringNullStringThrowsSerializationInputException() + { + Assert.Throws(() => + Serializer.JsonDeserialize((string)null) + ); + } + + [Test] + public void JsonDeserializeFastNullBytesThrowsSerializationInputException() + { + SerializationInputException ex = Assert.Throws(() => + Serializer.JsonDeserializeFast(null) + ); + Assert.AreEqual(SerializationFormat.JsonFast, ex.Format); + } + + [Test] + public void BinaryDeserializeNullBytesThrowsSerializationInputException() + { + SerializationInputException ex = Assert.Throws(() => + Serializer.BinaryDeserialize(null) + ); + Assert.AreEqual(SerializationFormat.Binary, ex.Format); + } + + [Test] + public void DispatcherDeserializeNullBytesRoutesToInputException( + [Values(SerializationType.Protobuf, SerializationType.Json)] + SerializationType serializationType + ) + { + Assert.Throws(() => + Serializer.Deserialize(null, serializationType) + ); + } + + // --------------------------------------------------------------------------- + // Empty input — also InputException. + // --------------------------------------------------------------------------- + + [Test] + public void ProtoDeserializeEmptyBytesThrowsSerializationInputException() + { + Assert.Throws(() => + Serializer.ProtoDeserialize(Array.Empty()) + ); + } + + [Test] + public void JsonDeserializeBytesEmptyBytesThrowsSerializationInputException() + { + Assert.Throws(() => + Serializer.JsonDeserialize(Array.Empty()) + ); + } + + [Test] + public void JsonDeserializeStringEmptyStringThrowsSerializationInputException() + { + Assert.Throws(() => + Serializer.JsonDeserialize(string.Empty) + ); + } + + [Test] + public void BinaryDeserializeEmptyBytesThrowsSerializationInputException() + { + Assert.Throws(() => + Serializer.BinaryDeserialize(Array.Empty()) + ); + } + + // --------------------------------------------------------------------------- + // Corrupt input — CorruptDataException with InnerException preserved. + // --------------------------------------------------------------------------- + + [Test] + public void ProtoDeserializeGarbageBytesThrowsSerializationCorruptDataException() + { + byte[] garbage = { 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF }; + SerializationCorruptDataException ex = Assert.Throws( + () => + Serializer.ProtoDeserialize(garbage) + ); + Assert.AreEqual(SerializationFormat.Protobuf, ex.Format); + Assert.AreEqual(SerializationStage.Decode, ex.Stage); + Assert.AreEqual(typeof(Sample), ex.DeclaredType); + Assert.IsTrue( + ex.InnerException != null, + "InnerException must preserve the underlying codec failure." + ); + StringAssert.Contains("byte[" + garbage.Length, ex.Message); + } + + [Test] + public void JsonDeserializeBytesGarbageBytesThrowsSerializationCorruptDataException() + { + byte[] garbage = { 0xFF, 0xFE, 0xFD }; + SerializationCorruptDataException ex = Assert.Throws( + () => + Serializer.JsonDeserialize(garbage) + ); + Assert.AreEqual(SerializationFormat.Json, ex.Format); + Assert.IsTrue(ex.InnerException != null); + } + + [Test] + public void JsonDeserializeStringGarbageThrowsSerializationCorruptDataException() + { + Assert.Throws(() => + Serializer.JsonDeserialize("not json {{{") + ); + } + + [Test] + public void JsonDeserializeFastGarbageThrowsSerializationCorruptDataException() + { + byte[] garbage = { 0xFF }; + Assert.Throws(() => + Serializer.JsonDeserializeFast(garbage) + ); + } + + [Test] + public void BinaryDeserializeGarbageThrowsSerializationCorruptDataException() + { + byte[] garbage = { 0x00, 0x01, 0x02, 0x03 }; + Assert.Throws(() => + Serializer.BinaryDeserialize(garbage) + ); + } + + // --------------------------------------------------------------------------- + // Type-resolution failures — TypeException (not swallowed by Try*). + // --------------------------------------------------------------------------- + + private interface IUnregistered { } + + [ProtoContract] + private sealed class Unregistered : IUnregistered + { + [ProtoMember(1)] + public int X { get; set; } + } + + [Test] + public void ProtoDeserializeUnresolvedInterfaceThrowsSerializationTypeException() + { + byte[] data = Serializer.ProtoSerialize(new Unregistered { X = 1 }); + SerializationTypeException ex = Assert.Throws(() => + Serializer.ProtoDeserialize(data) + ); + Assert.AreEqual(SerializationFormat.Protobuf, ex.Format); + Assert.AreEqual(SerializationStage.TypeResolution, ex.Stage); + Assert.AreEqual(typeof(IUnregistered), ex.DeclaredType); + } + + // --------------------------------------------------------------------------- + // Configuration failures — ConfigurationException (not swallowed by Try*). + // --------------------------------------------------------------------------- + + [Test] + public void DeserializeUnknownSerializationTypeThrowsConfiguration() + { + byte[] data = { 0x00 }; + Assert.Throws(() => + Serializer.Deserialize(data, (SerializationType)9999) + ); + } + + [Test] + public void SerializeUnknownSerializationTypeThrowsConfiguration() + { + Assert.Throws(() => + Serializer.Serialize(new Sample(), (SerializationType)9999) + ); + } + + [Test] + public void SerializeWithBufferUnknownSerializationTypeThrowsConfiguration() + { + byte[] buffer = null; + Assert.Throws(() => + Serializer.Serialize(new Sample(), (SerializationType)9999, ref buffer) + ); + } + + [Test] + public void ProtoDeserializeWithTypeNullTypeThrowsConfiguration() + { + byte[] data = Serializer.ProtoSerialize(new Sample { Id = 1 }); + Assert.Throws(() => + Serializer.ProtoDeserialize(data, null) + ); + } + + // --------------------------------------------------------------------------- + // Catch-all: every failure is a SerializationFailureException — no leaks. + // --------------------------------------------------------------------------- + + private static IEnumerable AllBadInputCases() + { + byte[][] badBytes = + { + null, + Array.Empty(), + new byte[] { 0xFF }, + new byte[] { 0xFF, 0xFE, 0xFD, 0xFC, 0xFB, 0xFA }, + }; + foreach (byte[] b in badBytes) + { + yield return new TestCaseData(b); + } + } + + [TestCaseSource(nameof(AllBadInputCases))] + public void EveryDeserializerBadInputThrowsOnlySerializationFailure(byte[] bad) + { + AssertOnlySerializationFailure(() => Serializer.ProtoDeserialize(bad)); + AssertOnlySerializationFailure(() => Serializer.JsonDeserialize(bad)); + AssertOnlySerializationFailure(() => Serializer.JsonDeserializeFast(bad)); + AssertOnlySerializationFailure(() => Serializer.BinaryDeserialize(bad)); + AssertOnlySerializationFailure(() => + Serializer.Deserialize(bad, SerializationType.Protobuf) + ); + AssertOnlySerializationFailure(() => + Serializer.Deserialize(bad, SerializationType.Json) + ); + } + + private static void AssertOnlySerializationFailure(TestDelegate action) + { + try + { + action(); + // If no throw, that's also acceptable — many serializers happily accept e.g. {0xFF}. + // The contract under test is "no LEAKED framework exception", not "always throws". + } + catch (SerializationFailureException) + { + // Pass — the documented exception type. + } + catch (Exception other) + { + Assert.Fail( + "Serializer leaked a non-SerializationFailureException type: " + + other.GetType().FullName + + ": " + + other.Message + ); + } + } + + // --------------------------------------------------------------------------- + // Screenshot-bug regression: a ZLinq-style pipeline that maps null payloads + // must surface a clean SerializationFailureException (never the legacy + // "ArgumentNullException: buffer cannot be null" from MemoryStream). + // --------------------------------------------------------------------------- + + [Test] + public void ScreenshotRegressionNullPayloadInPipelineNeverLeaksMemoryStreamException() + { + byte[][] payloads = { null }; + foreach (byte[] payload in payloads) + { + try + { + _ = Serializer.ProtoDeserialize(payload); + Assert.Fail("Expected SerializationInputException to be thrown."); + } + catch (SerializationInputException) + { + // Expected. + } + catch (ArgumentNullException ane) + { + Assert.Fail( + "Regression: legacy MemoryStream(buffer:null) ArgumentNullException leaked: " + + ane.Message + ); + } + } + } + } +} diff --git a/Tests/Runtime/Serialization/SerializerExceptionContractTests.cs.meta b/Tests/Runtime/Serialization/SerializerExceptionContractTests.cs.meta new file mode 100644 index 000000000..a74ae7a23 --- /dev/null +++ b/Tests/Runtime/Serialization/SerializerExceptionContractTests.cs.meta @@ -0,0 +1,10 @@ +fileFormatVersion: 2 +guid: d050cb6fcfcc425d9018b5d78e2b6db7 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: diff --git a/Tests/Runtime/Serialization/SerializerFuzzTests.cs b/Tests/Runtime/Serialization/SerializerFuzzTests.cs new file mode 100644 index 000000000..cb4eb28bd --- /dev/null +++ b/Tests/Runtime/Serialization/SerializerFuzzTests.cs @@ -0,0 +1,243 @@ +// MIT License - Copyright (c) 2025 wallstop +// Full license text: https://github.com/wallstop/unity-helpers/blob/main/LICENSE + +namespace WallstopStudios.UnityHelpers.Tests.Serialization +{ + using System; + using NUnit.Framework; + using ProtoBuf; + using WallstopStudios.UnityHelpers.Core.Serialization; + using Serializer = WallstopStudios.UnityHelpers.Core.Serialization.Serializer; + + /// + /// Fuzz tests random byte buffers through every public deserializer. Throwing variants must + /// only ever throw (or a subclass) — never a raw + /// framework exception. Try* variants must never throw at all on input/corrupt-data + /// failures. + /// + [TestFixture] + [NUnit.Framework.Category("Fast")] + public sealed class SerializerFuzzTests + { + private const int Iterations = 1024; + + [ProtoContract] + private sealed class Sample + { + [ProtoMember(1)] + public int Id { get; set; } + + [ProtoMember(2)] + public string Name { get; set; } + } + + [Test] + public void ProtoDeserializeRandomBytesOnlyLeaksSerializationFailureException() + { + FuzzThrowing(bytes => Serializer.ProtoDeserialize(bytes)); + } + + [Test] + public void JsonDeserializeBytesRandomBytesOnlyLeaksSerializationFailureException() + { + FuzzThrowing(bytes => Serializer.JsonDeserialize(bytes)); + } + + [Test] + public void JsonDeserializeFastRandomBytesOnlyLeaksSerializationFailureException() + { + FuzzThrowing(bytes => Serializer.JsonDeserializeFast(bytes)); + } + + [Test] + public void BinaryDeserializeRandomBytesOnlyLeaksSerializationFailureException() + { + FuzzThrowing(bytes => Serializer.BinaryDeserialize(bytes)); + } + + [Test] + public void TryProtoDeserializeRandomBytesNeverThrows() + { + FuzzTry(bytes => Serializer.TryProtoDeserialize(bytes, out Sample _)); + } + + [Test] + public void TryJsonDeserializeRandomBytesNeverThrows() + { + FuzzTry(bytes => Serializer.TryJsonDeserialize(bytes, out Sample _)); + } + + [Test] + public void TryJsonDeserializeFastRandomBytesNeverThrows() + { + FuzzTry(bytes => Serializer.TryJsonDeserializeFast(bytes, out Sample _)); + } + + [Test] + public void TryBinaryDeserializeRandomBytesNeverThrows() + { + FuzzTry(bytes => Serializer.TryBinaryDeserialize(bytes, out Sample _)); + } + + // --------------------------------------------------------------------------- + // Allocation guard: the null-input fast path must not allocate. The exception + // itself necessarily allocates, but the lazy Message means a caller who never + // touches ex.Message pays no string-formatting cost. + // --------------------------------------------------------------------------- + + [Test] + public void NullInputFastPathDoesNotAllocateMessageString() + { + // Warm up the JIT, type initializers, and any test-runner internal caches. + for (int i = 0; i < 16; i++) + { + try + { + Serializer.ProtoDeserialize(null); + } + catch (SerializationInputException) { } + } + + // Take the MINIMUM over multiple runs to filter out background-GC / test-runner noise. + long minThrowAlloc = long.MaxValue; + long minMessageDelta = long.MaxValue; + for (int i = 0; i < 8; i++) + { + long before = GC.GetAllocatedBytesForCurrentThread(); + SerializationInputException captured = null; + try + { + Serializer.ProtoDeserialize(null); + } + catch (SerializationInputException ex) + { + captured = ex; + } + long afterThrow = GC.GetAllocatedBytesForCurrentThread(); + Assert.IsTrue(captured != null); + _ = captured.Message; + long afterMessage = GC.GetAllocatedBytesForCurrentThread(); + + minThrowAlloc = Math.Min(minThrowAlloc, afterThrow - before); + minMessageDelta = Math.Min(minMessageDelta, afterMessage - afterThrow); + } + + // Composing Message must allocate at least one string — verifies lazy composition. + Assert.Greater( + minMessageDelta, + 0, + "Composing ex.Message after the throw must allocate. If zero, the lazy path is " + + "broken (message was composed eagerly in the constructor)." + ); + // Throwing without touching Message stays in a tight allocation envelope. + Assert.LessOrEqual( + minThrowAlloc, + 2048, + "Throwing SerializationInputException allocated more than 2KB without Message access; " + + "check that the constructor does not eagerly format the message string." + ); + } + + // --------------------------------------------------------------------------- + // Helpers. + // --------------------------------------------------------------------------- + + private static void FuzzThrowing(Action action) + { + Random rng = new(unchecked((int)0xCafeBabe)); + for (int i = 0; i < Iterations; i++) + { + byte[] payload = RandomPayload(rng, i); + try + { + action(payload); + } + catch (SerializationFailureException) + { + // OK — documented exception type. + } + catch (Exception other) + { + Assert.Fail( + "Iteration " + + i + + " (len=" + + (payload?.Length.ToString() ?? "null") + + "): leaked non-SerializationFailureException: " + + other.GetType().FullName + + ": " + + other.Message + ); + } + } + } + + private static void FuzzTry(Func action) + { + Random rng = new(unchecked((int)0xDeadBeef)); + for (int i = 0; i < Iterations; i++) + { + byte[] payload = RandomPayload(rng, i); + try + { + _ = action(payload); + } + catch (SerializationTypeException) + { + // Programmer-error path is allowed to propagate even from Try* — not relevant + // here because Sample is concrete. + } + catch (SerializationConfigurationException) + { + // Same as above. + } + catch (Exception other) + { + Assert.Fail( + "Iteration " + + i + + " (len=" + + (payload?.Length.ToString() ?? "null") + + "): Try* must not throw, but it did: " + + other.GetType().FullName + + ": " + + other.Message + ); + } + } + } + + private static byte[] RandomPayload(Random rng, int i) + { + int kind = i % 8; + return kind switch + { + 0 => null, + 1 => Array.Empty(), + 2 => new byte[] { 0x00 }, + 3 => Repeat((byte)0xFF, rng.Next(1, 32)), + 4 => Repeat((byte)0x00, rng.Next(1, 32)), + 5 => RandomBytes(rng, rng.Next(1, 256)), + 6 => RandomBytes(rng, rng.Next(256, 4096)), + _ => RandomBytes(rng, rng.Next(4096, 16384)), + }; + } + + private static byte[] Repeat(byte value, int count) + { + byte[] buf = new byte[count]; + for (int i = 0; i < count; i++) + { + buf[i] = value; + } + return buf; + } + + private static byte[] RandomBytes(Random rng, int count) + { + byte[] buf = new byte[count]; + rng.NextBytes(buf); + return buf; + } + } +} diff --git a/Tests/Runtime/Serialization/SerializerFuzzTests.cs.meta b/Tests/Runtime/Serialization/SerializerFuzzTests.cs.meta new file mode 100644 index 000000000..7932d3204 --- /dev/null +++ b/Tests/Runtime/Serialization/SerializerFuzzTests.cs.meta @@ -0,0 +1,10 @@ +fileFormatVersion: 2 +guid: 44eae12d192e43c8b202034b47dce23b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: diff --git a/Tests/Runtime/Serialization/SerializerTryApiTests.cs b/Tests/Runtime/Serialization/SerializerTryApiTests.cs new file mode 100644 index 000000000..f9cb1fe5d --- /dev/null +++ b/Tests/Runtime/Serialization/SerializerTryApiTests.cs @@ -0,0 +1,201 @@ +// MIT License - Copyright (c) 2025 wallstop +// Full license text: https://github.com/wallstop/unity-helpers/blob/main/LICENSE + +namespace WallstopStudios.UnityHelpers.Tests.Serialization +{ + using System; + using NUnit.Framework; + using ProtoBuf; + using WallstopStudios.UnityHelpers.Core.Serialization; + using Serializer = WallstopStudios.UnityHelpers.Core.Serialization.Serializer; + + /// + /// Tests the TryXxx deserialize family: must never throw for + /// or , + /// must set out to on failure, and must propagate + /// / + /// (programmer errors). + /// + [TestFixture] + [NUnit.Framework.Category("Fast")] + public sealed class SerializerTryApiTests + { + [ProtoContract] + private sealed class Sample + { + [ProtoMember(1)] + public int Id { get; set; } + + [ProtoMember(2)] + public string Name { get; set; } + } + + // --------------------------------------------------------------------------- + // Happy path: Try* returns true for a valid roundtrip. + // --------------------------------------------------------------------------- + + [Test] + public void TryProtoDeserializeValidPayloadReturnsTrue() + { + byte[] data = Serializer.ProtoSerialize(new Sample { Id = 7, Name = "ok" }); + bool ok = Serializer.TryProtoDeserialize(data, out Sample value); + Assert.IsTrue(ok); + Assert.IsTrue(value != null); + Assert.AreEqual(7, value.Id); + Assert.AreEqual("ok", value.Name); + } + + [Test] + public void TryJsonDeserializeStringValidPayloadReturnsTrue() + { + string json = "{\"Id\":42,\"Name\":\"x\"}"; + bool ok = Serializer.TryJsonDeserialize(json, out Sample value); + Assert.IsTrue(ok); + Assert.AreEqual(42, value.Id); + } + + [Test] + public void TryJsonDeserializeBytesValidPayloadReturnsTrue() + { + byte[] data = Serializer.JsonSerialize(new Sample { Id = 9, Name = "y" }); + bool ok = Serializer.TryJsonDeserialize(data, out Sample value); + Assert.IsTrue(ok); + Assert.AreEqual(9, value.Id); + } + + [Test] + public void TryDeserializeDispatcherValidPayloadReturnsTrue() + { + byte[] data = Serializer.ProtoSerialize(new Sample { Id = 3 }); + bool ok = Serializer.TryDeserialize(data, SerializationType.Protobuf, out Sample value); + Assert.IsTrue(ok); + Assert.AreEqual(3, value.Id); + } + + // --------------------------------------------------------------------------- + // Sad path: null/empty/corrupt input returns false, out=default, never throws. + // --------------------------------------------------------------------------- + + [Test] + public void TryProtoDeserializeNullBytesReturnsFalse() + { + bool ok = Serializer.TryProtoDeserialize(null, out Sample value); + Assert.IsFalse(ok); + Assert.IsTrue(value == null); + } + + [Test] + public void TryProtoDeserializeEmptyBytesReturnsFalse() + { + bool ok = Serializer.TryProtoDeserialize(Array.Empty(), out Sample value); + Assert.IsFalse(ok); + Assert.IsTrue(value == null); + } + + [Test] + public void TryProtoDeserializeGarbageBytesReturnsFalse() + { + byte[] garbage = { 0xFF, 0xFF, 0xFF, 0xFF }; + bool ok = Serializer.TryProtoDeserialize(garbage, out Sample value); + Assert.IsFalse(ok); + Assert.IsTrue(value == null); + } + + [Test] + public void TryJsonDeserializeNullStringReturnsFalse() + { + bool ok = Serializer.TryJsonDeserialize((string)null, out Sample value); + Assert.IsFalse(ok); + Assert.IsTrue(value == null); + } + + [Test] + public void TryJsonDeserializeNullBytesReturnsFalse() + { + bool ok = Serializer.TryJsonDeserialize((byte[])null, out Sample value); + Assert.IsFalse(ok); + } + + [Test] + public void TryJsonDeserializeGarbageReturnsFalse() + { + bool ok = Serializer.TryJsonDeserialize("not json", out Sample value); + Assert.IsFalse(ok); + Assert.IsTrue(value == null); + } + + [Test] + public void TryJsonDeserializeFastNullBytesReturnsFalse() + { + bool ok = Serializer.TryJsonDeserializeFast(null, out Sample value); + Assert.IsFalse(ok); + } + + [Test] + public void TryJsonDeserializeFastGarbageReturnsFalse() + { + bool ok = Serializer.TryJsonDeserializeFast(new byte[] { 0xFF }, out Sample value); + Assert.IsFalse(ok); + } + + [Test] + public void TryBinaryDeserializeNullBytesReturnsFalse() + { + bool ok = Serializer.TryBinaryDeserialize(null, out Sample value); + Assert.IsFalse(ok); + } + + [Test] + public void TryBinaryDeserializeGarbageReturnsFalse() + { + bool ok = Serializer.TryBinaryDeserialize(new byte[] { 0xFF, 0xFE }, out Sample value); + Assert.IsFalse(ok); + } + + [Test] + public void TryDeserializeNullBytesReturnsFalse() + { + bool ok = Serializer.TryDeserialize(null, SerializationType.Protobuf, out Sample value); + Assert.IsFalse(ok); + } + + // --------------------------------------------------------------------------- + // Programmer errors still throw — Try* does NOT swallow Type/Configuration failures. + // --------------------------------------------------------------------------- + + private interface IUnregistered { } + + [ProtoContract] + private sealed class Unregistered : IUnregistered + { + [ProtoMember(1)] + public int X { get; set; } + } + + [Test] + public void TryProtoDeserializeUnresolvedInterfaceStillThrowsTypeException() + { + byte[] data = Serializer.ProtoSerialize(new Unregistered { X = 1 }); + Assert.Throws(() => + Serializer.TryProtoDeserialize(data, out IUnregistered _) + ); + } + + [Test] + public void TryDeserializeUnknownSerializationTypeStillThrowsConfiguration() + { + Assert.Throws(() => + Serializer.TryDeserialize(new byte[] { 0 }, (SerializationType)9999, out Sample _) + ); + } + + [Test] + public void TryProtoDeserializeWithTypeNullTypeStillThrowsConfiguration() + { + byte[] data = Serializer.ProtoSerialize(new Sample { Id = 1 }); + Assert.Throws(() => + Serializer.TryProtoDeserialize(data, null, out object _) + ); + } + } +} diff --git a/Tests/Runtime/Serialization/SerializerTryApiTests.cs.meta b/Tests/Runtime/Serialization/SerializerTryApiTests.cs.meta new file mode 100644 index 000000000..5f3a736cd --- /dev/null +++ b/Tests/Runtime/Serialization/SerializerTryApiTests.cs.meta @@ -0,0 +1,10 @@ +fileFormatVersion: 2 +guid: 56dcb5c442b64dc9a164509194e5fa16 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: diff --git a/docs/images/serialization/serialization-flow.svg.meta b/docs/images/serialization/serialization-flow.svg.meta index 60d63b27f..5a8909326 100644 --- a/docs/images/serialization/serialization-flow.svg.meta +++ b/docs/images/serialization/serialization-flow.svg.meta @@ -1,7 +1,53 @@ fileFormatVersion: 2 guid: 6763d971408345f459cf1aabad287761 -DefaultImporter: +ScriptedImporter: + internalIDToNameTable: [] externalObjects: {} + serializedVersion: 2 userData: assetBundleName: assetBundleVariant: + script: {fileID: 12408, guid: 0000000000000000e000000000000000, type: 0} + svgType: 3 + texturedSpriteMeshType: 0 + svgPixelsPerUnit: 100 + gradientResolution: 64 + alignment: 0 + customPivot: {x: 0, y: 0} + generatePhysicsShape: 0 + viewportOptions: 0 + preserveViewport: 0 + advancedMode: 0 + tessellationMode: 1 + predefinedResolutionIndex: 1 + targetResolution: 1080 + resolutionMultiplier: 1 + stepDistance: 10 + samplingStepDistance: 100 + maxCordDeviationEnabled: 0 + maxCordDeviation: 1 + maxTangentAngleEnabled: 0 + maxTangentAngle: 5 + keepTextureAspectRatio: 1 + textureSize: 256 + textureWidth: 256 + textureHeight: 256 + wrapMode: 0 + filterMode: 1 + sampleCount: 4 + preserveSVGImageAspect: 0 + useSVGPixelsPerUnit: 0 + spriteData: + TessellationDetail: 0 + SpriteName: + SpritePivot: {x: 0, y: 0} + SpriteAlignment: 0 + SpriteBorder: {x: 0, y: 0, z: 0, w: 0} + SpriteRect: + serializedVersion: 2 + x: 0 + y: 0 + width: 0 + height: 0 + SpriteID: + PhysicsOutlines: [] From e66695aa6572e1e8abb1a9f1c22d94c589089285 Mon Sep 17 00:00:00 2001 From: wallstop Date: Sun, 14 Jun 2026 02:11:51 +0000 Subject: [PATCH 05/31] chore: in-flight RuntimeSingleton/registry, Physics, and extension tweaks Pre-existing branch work, committed to keep the tree clean. Lint + csharpier clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- Runtime/Core/Extension/Partials/Physics.cs | 2 +- Runtime/Utils/RuntimeSingleton.cs | 20 ++++- Runtime/Utils/RuntimeSingletonRegistry.cs | 11 ++- Runtime/WallstopStudios.UnityHelpers.asmdef | 2 +- .../Extensions/UnityExtensionsBasicTests.cs | 4 +- Tests/Runtime/Utils/RuntimeSingletonTests.cs | 81 +++++++++++++++++++ 6 files changed, 113 insertions(+), 7 deletions(-) diff --git a/Runtime/Core/Extension/Partials/Physics.cs b/Runtime/Core/Extension/Partials/Physics.cs index 99fe07c21..a962ab999 100644 --- a/Runtime/Core/Extension/Partials/Physics.cs +++ b/Runtime/Core/Extension/Partials/Physics.cs @@ -21,7 +21,7 @@ public static void Stop(this Rigidbody2D rigidBody) return; } - rigidBody.velocity = Vector2.zero; + rigidBody.linearVelocity = Vector2.zero; rigidBody.angularVelocity = 0; rigidBody.Sleep(); } diff --git a/Runtime/Utils/RuntimeSingleton.cs b/Runtime/Utils/RuntimeSingleton.cs index 5a5207bb2..d586213d0 100644 --- a/Runtime/Utils/RuntimeSingleton.cs +++ b/Runtime/Utils/RuntimeSingleton.cs @@ -27,7 +27,8 @@ namespace WallstopStudios.UnityHelpers.Utils /// - In , sets the static instance and, when is true and in play mode, /// detaches and calls to persist across scene loads. /// - In , detects duplicate instances and destroys the newer one. - /// - Instance cache is cleared on domain reload before scene load. + /// - Instance cache is cleared on domain reload before scene load via . + /// - Call to manually drop a stale reference in editor tooling or at runtime. /// /// ODIN compatibility: When the ODIN_INSPECTOR symbol is defined, this class derives from /// Sirenix.OdinInspector.SerializedMonoBehaviour for richer serialization; otherwise it derives from @@ -111,8 +112,23 @@ public static T Instance } } - internal static void ClearInstance() + /// + /// Clears the cached singleton instance, destroying its when present. + /// Safe to call unconditionally; no-op when is false. + /// + /// + /// Use when editor tooling or runtime code needs to drop a stale reference and force a fresh + /// instance on the next access. Automatic clearing on domain reload is + /// handled by because Unity disallows + /// [RuntimeInitializeOnLoadMethod] on methods in generic classes. + /// + public static void ClearInstance() { + if (_instance == null) + { + return; + } + _instance.Destroy(); Interlocked.Exchange(ref _initializeCount, 0); _instance = null; diff --git a/Runtime/Utils/RuntimeSingletonRegistry.cs b/Runtime/Utils/RuntimeSingletonRegistry.cs index 5b2829b07..342076eff 100644 --- a/Runtime/Utils/RuntimeSingletonRegistry.cs +++ b/Runtime/Utils/RuntimeSingletonRegistry.cs @@ -33,7 +33,16 @@ internal static void Register(Action clearAction) } [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)] - private static void ClearAllInstances() + private static void OnBeforeSceneLoad() + { + ClearAllRegisteredInstances(); + } + + /// + /// Clears every registered instance. + /// Invoked automatically before scene load and available for manual editor/runtime resets. + /// + internal static void ClearAllRegisteredInstances() { lock (_clearActions) { diff --git a/Runtime/WallstopStudios.UnityHelpers.asmdef b/Runtime/WallstopStudios.UnityHelpers.asmdef index 96bb238b7..5d766633d 100644 --- a/Runtime/WallstopStudios.UnityHelpers.asmdef +++ b/Runtime/WallstopStudios.UnityHelpers.asmdef @@ -5,7 +5,7 @@ "includePlatforms": [], "excludePlatforms": [], "allowUnsafeCode": true, - "overrideReferences": false, + "overrideReferences": true, "precompiledReferences": [ "System.Text.Json.dll", "System.Text.Encodings.Web.dll", diff --git a/Tests/Runtime/Extensions/UnityExtensionsBasicTests.cs b/Tests/Runtime/Extensions/UnityExtensionsBasicTests.cs index 67d40b94c..13244db9e 100644 --- a/Tests/Runtime/Extensions/UnityExtensionsBasicTests.cs +++ b/Tests/Runtime/Extensions/UnityExtensionsBasicTests.cs @@ -147,10 +147,10 @@ public void StopResetsRigidBody() GameObject go = Track(new GameObject("RigidBodyTest", typeof(Rigidbody2D))); Rigidbody2D body = go.GetComponent(); - body.velocity = new Vector2(10f, 5f); + body.linearVelocity = new Vector2(10f, 5f); body.angularVelocity = 15f; body.Stop(); - Assert.AreEqual(Vector2.zero, body.velocity); + Assert.AreEqual(Vector2.zero, body.linearVelocity); Assert.AreEqual(0f, body.angularVelocity); } diff --git a/Tests/Runtime/Utils/RuntimeSingletonTests.cs b/Tests/Runtime/Utils/RuntimeSingletonTests.cs index 90a9a52b5..00f5b8456 100644 --- a/Tests/Runtime/Utils/RuntimeSingletonTests.cs +++ b/Tests/Runtime/Utils/RuntimeSingletonTests.cs @@ -835,5 +835,86 @@ public void BackgroundThreadCanAccessInstanceAfterMainThreadCreation() Assert.AreSame(instance, backgroundInstance); } + + [Test] + public void ClearInstanceWhenNeverAccessedDoesNotThrow() + { + Assert.DoesNotThrow(() => TestRuntimeSingleton.ClearInstance()); + Assert.IsFalse(TestRuntimeSingleton.HasInstance); + } + + [Test] + public void ClearInstanceWhenCalledTwiceDoesNotThrow() + { + TestRuntimeSingleton instance = TestRuntimeSingleton.Instance; + Track(instance.gameObject); + + Assert.DoesNotThrow(() => + { + TestRuntimeSingleton.ClearInstance(); + TestRuntimeSingleton.ClearInstance(); + }); + Assert.IsFalse(TestRuntimeSingleton.HasInstance); + } + + [UnityTest] + public IEnumerator ClearInstanceDestroysGameObjectAndClearsReference() + { + TestRuntimeSingleton instance = TestRuntimeSingleton.Instance; + GameObject gameObject = instance.gameObject; + + TestRuntimeSingleton.ClearInstance(); + yield return null; + + Assert.IsFalse(TestRuntimeSingleton.HasInstance); + Assert.IsTrue(gameObject == null); + } + + [UnityTest] + public IEnumerator ClearInstanceResetsInitializeCount() + { + TestRuntimeSingleton instance = TestRuntimeSingleton.Instance; + Track(instance.gameObject); + + yield return null; + + Assert.Greater(TestRuntimeSingleton.InitializeCount, 0); + + TestRuntimeSingleton.ClearInstance(); + yield return null; + + Assert.AreEqual(0, TestRuntimeSingleton.InitializeCount); + } + + [UnityTest] + public IEnumerator ClearInstanceAllowsFreshInstanceAfterClear() + { + TestRuntimeSingleton first = TestRuntimeSingleton.Instance; + Track(first.gameObject); + first.testValue = 99; + + TestRuntimeSingleton.ClearInstance(); + yield return null; + + TestRuntimeSingleton second = TestRuntimeSingleton.Instance; + Track(second.gameObject); + + Assert.IsTrue(first == null); + Assert.AreNotSame(first, second); + Assert.AreEqual(42, second.testValue); + } + + [UnityTest] + public IEnumerator RegistryClearAllDestroysRegisteredSingletonInstances() + { + TestRuntimeSingleton instance = TestRuntimeSingleton.Instance; + GameObject gameObject = instance.gameObject; + + RuntimeSingletonRegistry.ClearAllRegisteredInstances(); + yield return null; + + Assert.IsFalse(TestRuntimeSingleton.HasInstance); + Assert.IsTrue(gameObject == null); + } } } From 464f5dc55bdac44912f42448d08abbe1c2bc2645 Mon Sep 17 00:00:00 2001 From: wallstop Date: Sun, 14 Jun 2026 02:11:53 +0000 Subject: [PATCH 06/31] chore: switch docs SVG assets to the Unity vector-graphics ScriptedImporter Importer metadata change (DefaultImporter -> ScriptedImporter) for the docs SVGs plus a material tweak; pre-existing branch work. Isolated for easy revert. Co-Authored-By: Claude Opus 4.8 (1M context) --- Shaders/Materials/BackgroundMask-Material.mat | 3 +- .../effects/attribute-resolution.svg.meta | 48 ++++++++++++++++++- docs/images/effects/effects-pipeline.svg.meta | 48 ++++++++++++++++++- .../relational-wiring.svg.meta | 48 ++++++++++++++++++- docs/images/spatial/concave-hull.svg.meta | 48 ++++++++++++++++++- docs/images/spatial/convex-hull.svg.meta | 48 ++++++++++++++++++- docs/images/spatial/kd-tree-2d.svg.meta | 48 ++++++++++++++++++- docs/images/spatial/kd-tree-3d.svg.meta | 48 ++++++++++++++++++- docs/images/spatial/octree-3d.svg.meta | 48 ++++++++++++++++++- docs/images/spatial/quadtree-2d.svg.meta | 48 ++++++++++++++++++- docs/images/spatial/query-boundaries.svg.meta | 48 ++++++++++++++++++- docs/images/spatial/r-tree-2d.svg.meta | 48 ++++++++++++++++++- docs/images/spatial/r-tree-3d.svg.meta | 48 ++++++++++++++++++- docs/images/unity-helpers-banner.svg.meta | 48 ++++++++++++++++++- .../utilities/data-structures/bitset.svg.meta | 48 ++++++++++++++++++- .../data-structures/cyclic-buffer.svg.meta | 48 ++++++++++++++++++- .../data-structures/deque-queue.svg.meta | 48 ++++++++++++++++++- .../utilities/data-structures/deque.svg.meta | 48 ++++++++++++++++++- .../data-structures/disjoint-set.svg.meta | 48 ++++++++++++++++++- .../utilities/data-structures/heap.svg.meta | 48 ++++++++++++++++++- .../data-structures/sparse-set.svg.meta | 48 ++++++++++++++++++- .../utilities/data-structures/trie.svg.meta | 48 ++++++++++++++++++- .../math/geometry-edge-cases.svg.meta | 48 ++++++++++++++++++- .../utilities/math/polyline-simplify.svg.meta | 48 ++++++++++++++++++- .../random/random-generators.svg.meta | 48 ++++++++++++++++++- .../reflection/reflection-scan.svg.meta | 48 ++++++++++++++++++- .../data-distribution-decision.svg.meta | 48 ++++++++++++++++++- .../singletons/singletons-lifecycle.svg.meta | 48 ++++++++++++++++++- 28 files changed, 1271 insertions(+), 28 deletions(-) diff --git a/Shaders/Materials/BackgroundMask-Material.mat b/Shaders/Materials/BackgroundMask-Material.mat index d69a4d987..7e0b78710 100644 --- a/Shaders/Materials/BackgroundMask-Material.mat +++ b/Shaders/Materials/BackgroundMask-Material.mat @@ -12,7 +12,7 @@ MonoBehaviour: m_Script: {fileID: 11500000, guid: d0353a89b1f911e48b9e16bdc9f2e058, type: 3} m_Name: m_EditorClassIdentifier: - version: 7 + version: 10 --- !u!21 &2100000 Material: serializedVersion: 8 @@ -57,3 +57,4 @@ Material: m_Colors: - _Color: {r: 1, g: 1, b: 1, a: 1} m_BuildTextureStacks: [] + m_AllowLocking: 1 diff --git a/docs/images/effects/attribute-resolution.svg.meta b/docs/images/effects/attribute-resolution.svg.meta index 051835573..f2dbc5186 100644 --- a/docs/images/effects/attribute-resolution.svg.meta +++ b/docs/images/effects/attribute-resolution.svg.meta @@ -1,7 +1,53 @@ fileFormatVersion: 2 guid: 8f6b6a4d4c85d3546bc475fd04875875 -DefaultImporter: +ScriptedImporter: + internalIDToNameTable: [] externalObjects: {} + serializedVersion: 2 userData: assetBundleName: assetBundleVariant: + script: {fileID: 12408, guid: 0000000000000000e000000000000000, type: 0} + svgType: 3 + texturedSpriteMeshType: 0 + svgPixelsPerUnit: 100 + gradientResolution: 64 + alignment: 0 + customPivot: {x: 0, y: 0} + generatePhysicsShape: 0 + viewportOptions: 0 + preserveViewport: 0 + advancedMode: 0 + tessellationMode: 1 + predefinedResolutionIndex: 1 + targetResolution: 1080 + resolutionMultiplier: 1 + stepDistance: 10 + samplingStepDistance: 100 + maxCordDeviationEnabled: 0 + maxCordDeviation: 1 + maxTangentAngleEnabled: 0 + maxTangentAngle: 5 + keepTextureAspectRatio: 1 + textureSize: 256 + textureWidth: 256 + textureHeight: 256 + wrapMode: 0 + filterMode: 1 + sampleCount: 4 + preserveSVGImageAspect: 0 + useSVGPixelsPerUnit: 0 + spriteData: + TessellationDetail: 0 + SpriteName: + SpritePivot: {x: 0, y: 0} + SpriteAlignment: 0 + SpriteBorder: {x: 0, y: 0, z: 0, w: 0} + SpriteRect: + serializedVersion: 2 + x: 0 + y: 0 + width: 0 + height: 0 + SpriteID: + PhysicsOutlines: [] diff --git a/docs/images/effects/effects-pipeline.svg.meta b/docs/images/effects/effects-pipeline.svg.meta index cc66b229d..67cb7d9e2 100644 --- a/docs/images/effects/effects-pipeline.svg.meta +++ b/docs/images/effects/effects-pipeline.svg.meta @@ -1,7 +1,53 @@ fileFormatVersion: 2 guid: f322f806e72089642864bfa3f5e462c7 -DefaultImporter: +ScriptedImporter: + internalIDToNameTable: [] externalObjects: {} + serializedVersion: 2 userData: assetBundleName: assetBundleVariant: + script: {fileID: 12408, guid: 0000000000000000e000000000000000, type: 0} + svgType: 3 + texturedSpriteMeshType: 0 + svgPixelsPerUnit: 100 + gradientResolution: 64 + alignment: 0 + customPivot: {x: 0, y: 0} + generatePhysicsShape: 0 + viewportOptions: 0 + preserveViewport: 0 + advancedMode: 0 + tessellationMode: 1 + predefinedResolutionIndex: 1 + targetResolution: 1080 + resolutionMultiplier: 1 + stepDistance: 10 + samplingStepDistance: 100 + maxCordDeviationEnabled: 0 + maxCordDeviation: 1 + maxTangentAngleEnabled: 0 + maxTangentAngle: 5 + keepTextureAspectRatio: 1 + textureSize: 256 + textureWidth: 256 + textureHeight: 256 + wrapMode: 0 + filterMode: 1 + sampleCount: 4 + preserveSVGImageAspect: 0 + useSVGPixelsPerUnit: 0 + spriteData: + TessellationDetail: 0 + SpriteName: + SpritePivot: {x: 0, y: 0} + SpriteAlignment: 0 + SpriteBorder: {x: 0, y: 0, z: 0, w: 0} + SpriteRect: + serializedVersion: 2 + x: 0 + y: 0 + width: 0 + height: 0 + SpriteID: + PhysicsOutlines: [] diff --git a/docs/images/relational-components/relational-wiring.svg.meta b/docs/images/relational-components/relational-wiring.svg.meta index c09a3b9a4..c3020171d 100644 --- a/docs/images/relational-components/relational-wiring.svg.meta +++ b/docs/images/relational-components/relational-wiring.svg.meta @@ -1,7 +1,53 @@ fileFormatVersion: 2 guid: c42aa6b33dc0b9a448c05ad6d0e0b93e -DefaultImporter: +ScriptedImporter: + internalIDToNameTable: [] externalObjects: {} + serializedVersion: 2 userData: assetBundleName: assetBundleVariant: + script: {fileID: 12408, guid: 0000000000000000e000000000000000, type: 0} + svgType: 3 + texturedSpriteMeshType: 0 + svgPixelsPerUnit: 100 + gradientResolution: 64 + alignment: 0 + customPivot: {x: 0, y: 0} + generatePhysicsShape: 0 + viewportOptions: 0 + preserveViewport: 0 + advancedMode: 0 + tessellationMode: 1 + predefinedResolutionIndex: 1 + targetResolution: 1080 + resolutionMultiplier: 1 + stepDistance: 10 + samplingStepDistance: 100 + maxCordDeviationEnabled: 0 + maxCordDeviation: 1 + maxTangentAngleEnabled: 0 + maxTangentAngle: 5 + keepTextureAspectRatio: 1 + textureSize: 256 + textureWidth: 256 + textureHeight: 256 + wrapMode: 0 + filterMode: 1 + sampleCount: 4 + preserveSVGImageAspect: 0 + useSVGPixelsPerUnit: 0 + spriteData: + TessellationDetail: 0 + SpriteName: + SpritePivot: {x: 0, y: 0} + SpriteAlignment: 0 + SpriteBorder: {x: 0, y: 0, z: 0, w: 0} + SpriteRect: + serializedVersion: 2 + x: 0 + y: 0 + width: 0 + height: 0 + SpriteID: + PhysicsOutlines: [] diff --git a/docs/images/spatial/concave-hull.svg.meta b/docs/images/spatial/concave-hull.svg.meta index 4c301bee3..2386e70e6 100644 --- a/docs/images/spatial/concave-hull.svg.meta +++ b/docs/images/spatial/concave-hull.svg.meta @@ -1,7 +1,53 @@ fileFormatVersion: 2 guid: 05a9c97b50954ef4fa0f96e3003970a1 -DefaultImporter: +ScriptedImporter: + internalIDToNameTable: [] externalObjects: {} + serializedVersion: 2 userData: assetBundleName: assetBundleVariant: + script: {fileID: 12408, guid: 0000000000000000e000000000000000, type: 0} + svgType: 3 + texturedSpriteMeshType: 0 + svgPixelsPerUnit: 100 + gradientResolution: 64 + alignment: 0 + customPivot: {x: 0, y: 0} + generatePhysicsShape: 0 + viewportOptions: 0 + preserveViewport: 0 + advancedMode: 0 + tessellationMode: 1 + predefinedResolutionIndex: 1 + targetResolution: 1080 + resolutionMultiplier: 1 + stepDistance: 10 + samplingStepDistance: 100 + maxCordDeviationEnabled: 0 + maxCordDeviation: 1 + maxTangentAngleEnabled: 0 + maxTangentAngle: 5 + keepTextureAspectRatio: 1 + textureSize: 256 + textureWidth: 256 + textureHeight: 256 + wrapMode: 0 + filterMode: 1 + sampleCount: 4 + preserveSVGImageAspect: 0 + useSVGPixelsPerUnit: 0 + spriteData: + TessellationDetail: 0 + SpriteName: + SpritePivot: {x: 0, y: 0} + SpriteAlignment: 0 + SpriteBorder: {x: 0, y: 0, z: 0, w: 0} + SpriteRect: + serializedVersion: 2 + x: 0 + y: 0 + width: 0 + height: 0 + SpriteID: + PhysicsOutlines: [] diff --git a/docs/images/spatial/convex-hull.svg.meta b/docs/images/spatial/convex-hull.svg.meta index 4d0c50bf4..c8f43bad5 100644 --- a/docs/images/spatial/convex-hull.svg.meta +++ b/docs/images/spatial/convex-hull.svg.meta @@ -1,7 +1,53 @@ fileFormatVersion: 2 guid: a45327d05356aed48886a3e2920cd4d1 -DefaultImporter: +ScriptedImporter: + internalIDToNameTable: [] externalObjects: {} + serializedVersion: 2 userData: assetBundleName: assetBundleVariant: + script: {fileID: 12408, guid: 0000000000000000e000000000000000, type: 0} + svgType: 3 + texturedSpriteMeshType: 0 + svgPixelsPerUnit: 100 + gradientResolution: 64 + alignment: 0 + customPivot: {x: 0, y: 0} + generatePhysicsShape: 0 + viewportOptions: 0 + preserveViewport: 0 + advancedMode: 0 + tessellationMode: 1 + predefinedResolutionIndex: 1 + targetResolution: 1080 + resolutionMultiplier: 1 + stepDistance: 10 + samplingStepDistance: 100 + maxCordDeviationEnabled: 0 + maxCordDeviation: 1 + maxTangentAngleEnabled: 0 + maxTangentAngle: 5 + keepTextureAspectRatio: 1 + textureSize: 256 + textureWidth: 256 + textureHeight: 256 + wrapMode: 0 + filterMode: 1 + sampleCount: 4 + preserveSVGImageAspect: 0 + useSVGPixelsPerUnit: 0 + spriteData: + TessellationDetail: 0 + SpriteName: + SpritePivot: {x: 0, y: 0} + SpriteAlignment: 0 + SpriteBorder: {x: 0, y: 0, z: 0, w: 0} + SpriteRect: + serializedVersion: 2 + x: 0 + y: 0 + width: 0 + height: 0 + SpriteID: + PhysicsOutlines: [] diff --git a/docs/images/spatial/kd-tree-2d.svg.meta b/docs/images/spatial/kd-tree-2d.svg.meta index 735bf6c81..bc0ac7f28 100644 --- a/docs/images/spatial/kd-tree-2d.svg.meta +++ b/docs/images/spatial/kd-tree-2d.svg.meta @@ -1,7 +1,53 @@ fileFormatVersion: 2 guid: c8a3e407419e9654093e73c784c5d22e -DefaultImporter: +ScriptedImporter: + internalIDToNameTable: [] externalObjects: {} + serializedVersion: 2 userData: assetBundleName: assetBundleVariant: + script: {fileID: 12408, guid: 0000000000000000e000000000000000, type: 0} + svgType: 3 + texturedSpriteMeshType: 0 + svgPixelsPerUnit: 100 + gradientResolution: 64 + alignment: 0 + customPivot: {x: 0, y: 0} + generatePhysicsShape: 0 + viewportOptions: 0 + preserveViewport: 0 + advancedMode: 0 + tessellationMode: 1 + predefinedResolutionIndex: 1 + targetResolution: 1080 + resolutionMultiplier: 1 + stepDistance: 10 + samplingStepDistance: 100 + maxCordDeviationEnabled: 0 + maxCordDeviation: 1 + maxTangentAngleEnabled: 0 + maxTangentAngle: 5 + keepTextureAspectRatio: 1 + textureSize: 256 + textureWidth: 256 + textureHeight: 256 + wrapMode: 0 + filterMode: 1 + sampleCount: 4 + preserveSVGImageAspect: 0 + useSVGPixelsPerUnit: 0 + spriteData: + TessellationDetail: 0 + SpriteName: + SpritePivot: {x: 0, y: 0} + SpriteAlignment: 0 + SpriteBorder: {x: 0, y: 0, z: 0, w: 0} + SpriteRect: + serializedVersion: 2 + x: 0 + y: 0 + width: 0 + height: 0 + SpriteID: + PhysicsOutlines: [] diff --git a/docs/images/spatial/kd-tree-3d.svg.meta b/docs/images/spatial/kd-tree-3d.svg.meta index 03f5c19bc..0eff15517 100644 --- a/docs/images/spatial/kd-tree-3d.svg.meta +++ b/docs/images/spatial/kd-tree-3d.svg.meta @@ -1,7 +1,53 @@ fileFormatVersion: 2 guid: 77caaeae4ee6fe34cb491dd188245a67 -DefaultImporter: +ScriptedImporter: + internalIDToNameTable: [] externalObjects: {} + serializedVersion: 2 userData: assetBundleName: assetBundleVariant: + script: {fileID: 12408, guid: 0000000000000000e000000000000000, type: 0} + svgType: 3 + texturedSpriteMeshType: 0 + svgPixelsPerUnit: 100 + gradientResolution: 64 + alignment: 0 + customPivot: {x: 0, y: 0} + generatePhysicsShape: 0 + viewportOptions: 0 + preserveViewport: 0 + advancedMode: 0 + tessellationMode: 1 + predefinedResolutionIndex: 1 + targetResolution: 1080 + resolutionMultiplier: 1 + stepDistance: 10 + samplingStepDistance: 100 + maxCordDeviationEnabled: 0 + maxCordDeviation: 1 + maxTangentAngleEnabled: 0 + maxTangentAngle: 5 + keepTextureAspectRatio: 1 + textureSize: 256 + textureWidth: 256 + textureHeight: 256 + wrapMode: 0 + filterMode: 1 + sampleCount: 4 + preserveSVGImageAspect: 0 + useSVGPixelsPerUnit: 0 + spriteData: + TessellationDetail: 0 + SpriteName: + SpritePivot: {x: 0, y: 0} + SpriteAlignment: 0 + SpriteBorder: {x: 0, y: 0, z: 0, w: 0} + SpriteRect: + serializedVersion: 2 + x: 0 + y: 0 + width: 0 + height: 0 + SpriteID: + PhysicsOutlines: [] diff --git a/docs/images/spatial/octree-3d.svg.meta b/docs/images/spatial/octree-3d.svg.meta index 8bc466ffc..51d337264 100644 --- a/docs/images/spatial/octree-3d.svg.meta +++ b/docs/images/spatial/octree-3d.svg.meta @@ -1,7 +1,53 @@ fileFormatVersion: 2 guid: 69f15367d88369940b4f530384fa028c -DefaultImporter: +ScriptedImporter: + internalIDToNameTable: [] externalObjects: {} + serializedVersion: 2 userData: assetBundleName: assetBundleVariant: + script: {fileID: 12408, guid: 0000000000000000e000000000000000, type: 0} + svgType: 3 + texturedSpriteMeshType: 0 + svgPixelsPerUnit: 100 + gradientResolution: 64 + alignment: 0 + customPivot: {x: 0, y: 0} + generatePhysicsShape: 0 + viewportOptions: 0 + preserveViewport: 0 + advancedMode: 0 + tessellationMode: 1 + predefinedResolutionIndex: 1 + targetResolution: 1080 + resolutionMultiplier: 1 + stepDistance: 10 + samplingStepDistance: 100 + maxCordDeviationEnabled: 0 + maxCordDeviation: 1 + maxTangentAngleEnabled: 0 + maxTangentAngle: 5 + keepTextureAspectRatio: 1 + textureSize: 256 + textureWidth: 256 + textureHeight: 256 + wrapMode: 0 + filterMode: 1 + sampleCount: 4 + preserveSVGImageAspect: 0 + useSVGPixelsPerUnit: 0 + spriteData: + TessellationDetail: 0 + SpriteName: + SpritePivot: {x: 0, y: 0} + SpriteAlignment: 0 + SpriteBorder: {x: 0, y: 0, z: 0, w: 0} + SpriteRect: + serializedVersion: 2 + x: 0 + y: 0 + width: 0 + height: 0 + SpriteID: + PhysicsOutlines: [] diff --git a/docs/images/spatial/quadtree-2d.svg.meta b/docs/images/spatial/quadtree-2d.svg.meta index b82464ddf..9bb42675a 100644 --- a/docs/images/spatial/quadtree-2d.svg.meta +++ b/docs/images/spatial/quadtree-2d.svg.meta @@ -1,7 +1,53 @@ fileFormatVersion: 2 guid: 7eb0d1c73ecb7384ab98089133e9a2d5 -DefaultImporter: +ScriptedImporter: + internalIDToNameTable: [] externalObjects: {} + serializedVersion: 2 userData: assetBundleName: assetBundleVariant: + script: {fileID: 12408, guid: 0000000000000000e000000000000000, type: 0} + svgType: 3 + texturedSpriteMeshType: 0 + svgPixelsPerUnit: 100 + gradientResolution: 64 + alignment: 0 + customPivot: {x: 0, y: 0} + generatePhysicsShape: 0 + viewportOptions: 0 + preserveViewport: 0 + advancedMode: 0 + tessellationMode: 1 + predefinedResolutionIndex: 1 + targetResolution: 1080 + resolutionMultiplier: 1 + stepDistance: 10 + samplingStepDistance: 100 + maxCordDeviationEnabled: 0 + maxCordDeviation: 1 + maxTangentAngleEnabled: 0 + maxTangentAngle: 5 + keepTextureAspectRatio: 1 + textureSize: 256 + textureWidth: 256 + textureHeight: 256 + wrapMode: 0 + filterMode: 1 + sampleCount: 4 + preserveSVGImageAspect: 0 + useSVGPixelsPerUnit: 0 + spriteData: + TessellationDetail: 0 + SpriteName: + SpritePivot: {x: 0, y: 0} + SpriteAlignment: 0 + SpriteBorder: {x: 0, y: 0, z: 0, w: 0} + SpriteRect: + serializedVersion: 2 + x: 0 + y: 0 + width: 0 + height: 0 + SpriteID: + PhysicsOutlines: [] diff --git a/docs/images/spatial/query-boundaries.svg.meta b/docs/images/spatial/query-boundaries.svg.meta index 74fc9e18c..567eecdfe 100644 --- a/docs/images/spatial/query-boundaries.svg.meta +++ b/docs/images/spatial/query-boundaries.svg.meta @@ -1,7 +1,53 @@ fileFormatVersion: 2 guid: c5967f82e34e6f44d951bd8db69707d2 -DefaultImporter: +ScriptedImporter: + internalIDToNameTable: [] externalObjects: {} + serializedVersion: 2 userData: assetBundleName: assetBundleVariant: + script: {fileID: 12408, guid: 0000000000000000e000000000000000, type: 0} + svgType: 3 + texturedSpriteMeshType: 0 + svgPixelsPerUnit: 100 + gradientResolution: 64 + alignment: 0 + customPivot: {x: 0, y: 0} + generatePhysicsShape: 0 + viewportOptions: 0 + preserveViewport: 0 + advancedMode: 0 + tessellationMode: 1 + predefinedResolutionIndex: 1 + targetResolution: 1080 + resolutionMultiplier: 1 + stepDistance: 10 + samplingStepDistance: 100 + maxCordDeviationEnabled: 0 + maxCordDeviation: 1 + maxTangentAngleEnabled: 0 + maxTangentAngle: 5 + keepTextureAspectRatio: 1 + textureSize: 256 + textureWidth: 256 + textureHeight: 256 + wrapMode: 0 + filterMode: 1 + sampleCount: 4 + preserveSVGImageAspect: 0 + useSVGPixelsPerUnit: 0 + spriteData: + TessellationDetail: 0 + SpriteName: + SpritePivot: {x: 0, y: 0} + SpriteAlignment: 0 + SpriteBorder: {x: 0, y: 0, z: 0, w: 0} + SpriteRect: + serializedVersion: 2 + x: 0 + y: 0 + width: 0 + height: 0 + SpriteID: + PhysicsOutlines: [] diff --git a/docs/images/spatial/r-tree-2d.svg.meta b/docs/images/spatial/r-tree-2d.svg.meta index bed1274d3..fb46d8e34 100644 --- a/docs/images/spatial/r-tree-2d.svg.meta +++ b/docs/images/spatial/r-tree-2d.svg.meta @@ -1,7 +1,53 @@ fileFormatVersion: 2 guid: 96831c50c45784749aed67abb7b2b992 -DefaultImporter: +ScriptedImporter: + internalIDToNameTable: [] externalObjects: {} + serializedVersion: 2 userData: assetBundleName: assetBundleVariant: + script: {fileID: 12408, guid: 0000000000000000e000000000000000, type: 0} + svgType: 3 + texturedSpriteMeshType: 0 + svgPixelsPerUnit: 100 + gradientResolution: 64 + alignment: 0 + customPivot: {x: 0, y: 0} + generatePhysicsShape: 0 + viewportOptions: 0 + preserveViewport: 0 + advancedMode: 0 + tessellationMode: 1 + predefinedResolutionIndex: 1 + targetResolution: 1080 + resolutionMultiplier: 1 + stepDistance: 10 + samplingStepDistance: 100 + maxCordDeviationEnabled: 0 + maxCordDeviation: 1 + maxTangentAngleEnabled: 0 + maxTangentAngle: 5 + keepTextureAspectRatio: 1 + textureSize: 256 + textureWidth: 256 + textureHeight: 256 + wrapMode: 0 + filterMode: 1 + sampleCount: 4 + preserveSVGImageAspect: 0 + useSVGPixelsPerUnit: 0 + spriteData: + TessellationDetail: 0 + SpriteName: + SpritePivot: {x: 0, y: 0} + SpriteAlignment: 0 + SpriteBorder: {x: 0, y: 0, z: 0, w: 0} + SpriteRect: + serializedVersion: 2 + x: 0 + y: 0 + width: 0 + height: 0 + SpriteID: + PhysicsOutlines: [] diff --git a/docs/images/spatial/r-tree-3d.svg.meta b/docs/images/spatial/r-tree-3d.svg.meta index cd8149eeb..2e737966f 100644 --- a/docs/images/spatial/r-tree-3d.svg.meta +++ b/docs/images/spatial/r-tree-3d.svg.meta @@ -1,7 +1,53 @@ fileFormatVersion: 2 guid: e5a10316d59ffcb489e97bc2cda4a6cc -DefaultImporter: +ScriptedImporter: + internalIDToNameTable: [] externalObjects: {} + serializedVersion: 2 userData: assetBundleName: assetBundleVariant: + script: {fileID: 12408, guid: 0000000000000000e000000000000000, type: 0} + svgType: 3 + texturedSpriteMeshType: 0 + svgPixelsPerUnit: 100 + gradientResolution: 64 + alignment: 0 + customPivot: {x: 0, y: 0} + generatePhysicsShape: 0 + viewportOptions: 0 + preserveViewport: 0 + advancedMode: 0 + tessellationMode: 1 + predefinedResolutionIndex: 1 + targetResolution: 1080 + resolutionMultiplier: 1 + stepDistance: 10 + samplingStepDistance: 100 + maxCordDeviationEnabled: 0 + maxCordDeviation: 1 + maxTangentAngleEnabled: 0 + maxTangentAngle: 5 + keepTextureAspectRatio: 1 + textureSize: 256 + textureWidth: 256 + textureHeight: 256 + wrapMode: 0 + filterMode: 1 + sampleCount: 4 + preserveSVGImageAspect: 0 + useSVGPixelsPerUnit: 0 + spriteData: + TessellationDetail: 0 + SpriteName: + SpritePivot: {x: 0, y: 0} + SpriteAlignment: 0 + SpriteBorder: {x: 0, y: 0, z: 0, w: 0} + SpriteRect: + serializedVersion: 2 + x: 0 + y: 0 + width: 0 + height: 0 + SpriteID: + PhysicsOutlines: [] diff --git a/docs/images/unity-helpers-banner.svg.meta b/docs/images/unity-helpers-banner.svg.meta index 3b20f738f..9f2e9a48a 100644 --- a/docs/images/unity-helpers-banner.svg.meta +++ b/docs/images/unity-helpers-banner.svg.meta @@ -1,7 +1,53 @@ fileFormatVersion: 2 guid: 2e73cb8302eef11f9fb97105fdcab1db -DefaultImporter: +ScriptedImporter: + internalIDToNameTable: [] externalObjects: {} + serializedVersion: 2 userData: assetBundleName: assetBundleVariant: + script: {fileID: 12408, guid: 0000000000000000e000000000000000, type: 0} + svgType: 3 + texturedSpriteMeshType: 0 + svgPixelsPerUnit: 100 + gradientResolution: 64 + alignment: 0 + customPivot: {x: 0, y: 0} + generatePhysicsShape: 0 + viewportOptions: 0 + preserveViewport: 0 + advancedMode: 0 + tessellationMode: 1 + predefinedResolutionIndex: 1 + targetResolution: 1080 + resolutionMultiplier: 1 + stepDistance: 10 + samplingStepDistance: 100 + maxCordDeviationEnabled: 0 + maxCordDeviation: 1 + maxTangentAngleEnabled: 0 + maxTangentAngle: 5 + keepTextureAspectRatio: 1 + textureSize: 256 + textureWidth: 256 + textureHeight: 256 + wrapMode: 0 + filterMode: 1 + sampleCount: 4 + preserveSVGImageAspect: 0 + useSVGPixelsPerUnit: 0 + spriteData: + TessellationDetail: 0 + SpriteName: + SpritePivot: {x: 0, y: 0} + SpriteAlignment: 0 + SpriteBorder: {x: 0, y: 0, z: 0, w: 0} + SpriteRect: + serializedVersion: 2 + x: 0 + y: 0 + width: 0 + height: 0 + SpriteID: + PhysicsOutlines: [] diff --git a/docs/images/utilities/data-structures/bitset.svg.meta b/docs/images/utilities/data-structures/bitset.svg.meta index fa43799bf..1f0ab8082 100644 --- a/docs/images/utilities/data-structures/bitset.svg.meta +++ b/docs/images/utilities/data-structures/bitset.svg.meta @@ -1,7 +1,53 @@ fileFormatVersion: 2 guid: 6383a3c562486c24db742b96ca2a3f21 -DefaultImporter: +ScriptedImporter: + internalIDToNameTable: [] externalObjects: {} + serializedVersion: 2 userData: assetBundleName: assetBundleVariant: + script: {fileID: 12408, guid: 0000000000000000e000000000000000, type: 0} + svgType: 3 + texturedSpriteMeshType: 0 + svgPixelsPerUnit: 100 + gradientResolution: 64 + alignment: 0 + customPivot: {x: 0, y: 0} + generatePhysicsShape: 0 + viewportOptions: 0 + preserveViewport: 0 + advancedMode: 0 + tessellationMode: 1 + predefinedResolutionIndex: 1 + targetResolution: 1080 + resolutionMultiplier: 1 + stepDistance: 10 + samplingStepDistance: 100 + maxCordDeviationEnabled: 0 + maxCordDeviation: 1 + maxTangentAngleEnabled: 0 + maxTangentAngle: 5 + keepTextureAspectRatio: 1 + textureSize: 256 + textureWidth: 256 + textureHeight: 256 + wrapMode: 0 + filterMode: 1 + sampleCount: 4 + preserveSVGImageAspect: 0 + useSVGPixelsPerUnit: 0 + spriteData: + TessellationDetail: 0 + SpriteName: + SpritePivot: {x: 0, y: 0} + SpriteAlignment: 0 + SpriteBorder: {x: 0, y: 0, z: 0, w: 0} + SpriteRect: + serializedVersion: 2 + x: 0 + y: 0 + width: 0 + height: 0 + SpriteID: + PhysicsOutlines: [] diff --git a/docs/images/utilities/data-structures/cyclic-buffer.svg.meta b/docs/images/utilities/data-structures/cyclic-buffer.svg.meta index e8d3e49cc..768057dcb 100644 --- a/docs/images/utilities/data-structures/cyclic-buffer.svg.meta +++ b/docs/images/utilities/data-structures/cyclic-buffer.svg.meta @@ -1,7 +1,53 @@ fileFormatVersion: 2 guid: ae32bca4276616742871a305096a44ef -DefaultImporter: +ScriptedImporter: + internalIDToNameTable: [] externalObjects: {} + serializedVersion: 2 userData: assetBundleName: assetBundleVariant: + script: {fileID: 12408, guid: 0000000000000000e000000000000000, type: 0} + svgType: 3 + texturedSpriteMeshType: 0 + svgPixelsPerUnit: 100 + gradientResolution: 64 + alignment: 0 + customPivot: {x: 0, y: 0} + generatePhysicsShape: 0 + viewportOptions: 0 + preserveViewport: 0 + advancedMode: 0 + tessellationMode: 1 + predefinedResolutionIndex: 1 + targetResolution: 1080 + resolutionMultiplier: 1 + stepDistance: 10 + samplingStepDistance: 100 + maxCordDeviationEnabled: 0 + maxCordDeviation: 1 + maxTangentAngleEnabled: 0 + maxTangentAngle: 5 + keepTextureAspectRatio: 1 + textureSize: 256 + textureWidth: 256 + textureHeight: 256 + wrapMode: 0 + filterMode: 1 + sampleCount: 4 + preserveSVGImageAspect: 0 + useSVGPixelsPerUnit: 0 + spriteData: + TessellationDetail: 0 + SpriteName: + SpritePivot: {x: 0, y: 0} + SpriteAlignment: 0 + SpriteBorder: {x: 0, y: 0, z: 0, w: 0} + SpriteRect: + serializedVersion: 2 + x: 0 + y: 0 + width: 0 + height: 0 + SpriteID: + PhysicsOutlines: [] diff --git a/docs/images/utilities/data-structures/deque-queue.svg.meta b/docs/images/utilities/data-structures/deque-queue.svg.meta index 89b580f38..2ff2d11a4 100644 --- a/docs/images/utilities/data-structures/deque-queue.svg.meta +++ b/docs/images/utilities/data-structures/deque-queue.svg.meta @@ -1,7 +1,53 @@ fileFormatVersion: 2 guid: 8e8bd6f7274d47c4983dff6925ef0b04 -DefaultImporter: +ScriptedImporter: + internalIDToNameTable: [] externalObjects: {} + serializedVersion: 2 userData: assetBundleName: assetBundleVariant: + script: {fileID: 12408, guid: 0000000000000000e000000000000000, type: 0} + svgType: 3 + texturedSpriteMeshType: 0 + svgPixelsPerUnit: 100 + gradientResolution: 64 + alignment: 0 + customPivot: {x: 0, y: 0} + generatePhysicsShape: 0 + viewportOptions: 0 + preserveViewport: 0 + advancedMode: 0 + tessellationMode: 1 + predefinedResolutionIndex: 1 + targetResolution: 1080 + resolutionMultiplier: 1 + stepDistance: 10 + samplingStepDistance: 100 + maxCordDeviationEnabled: 0 + maxCordDeviation: 1 + maxTangentAngleEnabled: 0 + maxTangentAngle: 5 + keepTextureAspectRatio: 1 + textureSize: 256 + textureWidth: 256 + textureHeight: 256 + wrapMode: 0 + filterMode: 1 + sampleCount: 4 + preserveSVGImageAspect: 0 + useSVGPixelsPerUnit: 0 + spriteData: + TessellationDetail: 0 + SpriteName: + SpritePivot: {x: 0, y: 0} + SpriteAlignment: 0 + SpriteBorder: {x: 0, y: 0, z: 0, w: 0} + SpriteRect: + serializedVersion: 2 + x: 0 + y: 0 + width: 0 + height: 0 + SpriteID: + PhysicsOutlines: [] diff --git a/docs/images/utilities/data-structures/deque.svg.meta b/docs/images/utilities/data-structures/deque.svg.meta index 11c34e051..141e9236f 100644 --- a/docs/images/utilities/data-structures/deque.svg.meta +++ b/docs/images/utilities/data-structures/deque.svg.meta @@ -1,7 +1,53 @@ fileFormatVersion: 2 guid: 2750f6cffedfd30448046c93ef853b80 -DefaultImporter: +ScriptedImporter: + internalIDToNameTable: [] externalObjects: {} + serializedVersion: 2 userData: assetBundleName: assetBundleVariant: + script: {fileID: 12408, guid: 0000000000000000e000000000000000, type: 0} + svgType: 3 + texturedSpriteMeshType: 0 + svgPixelsPerUnit: 100 + gradientResolution: 64 + alignment: 0 + customPivot: {x: 0, y: 0} + generatePhysicsShape: 0 + viewportOptions: 0 + preserveViewport: 0 + advancedMode: 0 + tessellationMode: 1 + predefinedResolutionIndex: 1 + targetResolution: 1080 + resolutionMultiplier: 1 + stepDistance: 10 + samplingStepDistance: 100 + maxCordDeviationEnabled: 0 + maxCordDeviation: 1 + maxTangentAngleEnabled: 0 + maxTangentAngle: 5 + keepTextureAspectRatio: 1 + textureSize: 256 + textureWidth: 256 + textureHeight: 256 + wrapMode: 0 + filterMode: 1 + sampleCount: 4 + preserveSVGImageAspect: 0 + useSVGPixelsPerUnit: 0 + spriteData: + TessellationDetail: 0 + SpriteName: + SpritePivot: {x: 0, y: 0} + SpriteAlignment: 0 + SpriteBorder: {x: 0, y: 0, z: 0, w: 0} + SpriteRect: + serializedVersion: 2 + x: 0 + y: 0 + width: 0 + height: 0 + SpriteID: + PhysicsOutlines: [] diff --git a/docs/images/utilities/data-structures/disjoint-set.svg.meta b/docs/images/utilities/data-structures/disjoint-set.svg.meta index f9acdf471..b318fee78 100644 --- a/docs/images/utilities/data-structures/disjoint-set.svg.meta +++ b/docs/images/utilities/data-structures/disjoint-set.svg.meta @@ -1,7 +1,53 @@ fileFormatVersion: 2 guid: 200d0c30fed8d4945ba4fafcc8a0fe93 -DefaultImporter: +ScriptedImporter: + internalIDToNameTable: [] externalObjects: {} + serializedVersion: 2 userData: assetBundleName: assetBundleVariant: + script: {fileID: 12408, guid: 0000000000000000e000000000000000, type: 0} + svgType: 3 + texturedSpriteMeshType: 0 + svgPixelsPerUnit: 100 + gradientResolution: 64 + alignment: 0 + customPivot: {x: 0, y: 0} + generatePhysicsShape: 0 + viewportOptions: 0 + preserveViewport: 0 + advancedMode: 0 + tessellationMode: 1 + predefinedResolutionIndex: 1 + targetResolution: 1080 + resolutionMultiplier: 1 + stepDistance: 10 + samplingStepDistance: 100 + maxCordDeviationEnabled: 0 + maxCordDeviation: 1 + maxTangentAngleEnabled: 0 + maxTangentAngle: 5 + keepTextureAspectRatio: 1 + textureSize: 256 + textureWidth: 256 + textureHeight: 256 + wrapMode: 0 + filterMode: 1 + sampleCount: 4 + preserveSVGImageAspect: 0 + useSVGPixelsPerUnit: 0 + spriteData: + TessellationDetail: 0 + SpriteName: + SpritePivot: {x: 0, y: 0} + SpriteAlignment: 0 + SpriteBorder: {x: 0, y: 0, z: 0, w: 0} + SpriteRect: + serializedVersion: 2 + x: 0 + y: 0 + width: 0 + height: 0 + SpriteID: + PhysicsOutlines: [] diff --git a/docs/images/utilities/data-structures/heap.svg.meta b/docs/images/utilities/data-structures/heap.svg.meta index 849f8316c..5f182ab5f 100644 --- a/docs/images/utilities/data-structures/heap.svg.meta +++ b/docs/images/utilities/data-structures/heap.svg.meta @@ -1,7 +1,53 @@ fileFormatVersion: 2 guid: 48d8b1cdc58fa9a4baae35a1708cfe10 -DefaultImporter: +ScriptedImporter: + internalIDToNameTable: [] externalObjects: {} + serializedVersion: 2 userData: assetBundleName: assetBundleVariant: + script: {fileID: 12408, guid: 0000000000000000e000000000000000, type: 0} + svgType: 3 + texturedSpriteMeshType: 0 + svgPixelsPerUnit: 100 + gradientResolution: 64 + alignment: 0 + customPivot: {x: 0, y: 0} + generatePhysicsShape: 0 + viewportOptions: 0 + preserveViewport: 0 + advancedMode: 0 + tessellationMode: 1 + predefinedResolutionIndex: 1 + targetResolution: 1080 + resolutionMultiplier: 1 + stepDistance: 10 + samplingStepDistance: 100 + maxCordDeviationEnabled: 0 + maxCordDeviation: 1 + maxTangentAngleEnabled: 0 + maxTangentAngle: 5 + keepTextureAspectRatio: 1 + textureSize: 256 + textureWidth: 256 + textureHeight: 256 + wrapMode: 0 + filterMode: 1 + sampleCount: 4 + preserveSVGImageAspect: 0 + useSVGPixelsPerUnit: 0 + spriteData: + TessellationDetail: 0 + SpriteName: + SpritePivot: {x: 0, y: 0} + SpriteAlignment: 0 + SpriteBorder: {x: 0, y: 0, z: 0, w: 0} + SpriteRect: + serializedVersion: 2 + x: 0 + y: 0 + width: 0 + height: 0 + SpriteID: + PhysicsOutlines: [] diff --git a/docs/images/utilities/data-structures/sparse-set.svg.meta b/docs/images/utilities/data-structures/sparse-set.svg.meta index 4f681f888..9370be139 100644 --- a/docs/images/utilities/data-structures/sparse-set.svg.meta +++ b/docs/images/utilities/data-structures/sparse-set.svg.meta @@ -1,7 +1,53 @@ fileFormatVersion: 2 guid: 53c823b4e9f706041a2374c858945225 -DefaultImporter: +ScriptedImporter: + internalIDToNameTable: [] externalObjects: {} + serializedVersion: 2 userData: assetBundleName: assetBundleVariant: + script: {fileID: 12408, guid: 0000000000000000e000000000000000, type: 0} + svgType: 3 + texturedSpriteMeshType: 0 + svgPixelsPerUnit: 100 + gradientResolution: 64 + alignment: 0 + customPivot: {x: 0, y: 0} + generatePhysicsShape: 0 + viewportOptions: 0 + preserveViewport: 0 + advancedMode: 0 + tessellationMode: 1 + predefinedResolutionIndex: 1 + targetResolution: 1080 + resolutionMultiplier: 1 + stepDistance: 10 + samplingStepDistance: 100 + maxCordDeviationEnabled: 0 + maxCordDeviation: 1 + maxTangentAngleEnabled: 0 + maxTangentAngle: 5 + keepTextureAspectRatio: 1 + textureSize: 256 + textureWidth: 256 + textureHeight: 256 + wrapMode: 0 + filterMode: 1 + sampleCount: 4 + preserveSVGImageAspect: 0 + useSVGPixelsPerUnit: 0 + spriteData: + TessellationDetail: 0 + SpriteName: + SpritePivot: {x: 0, y: 0} + SpriteAlignment: 0 + SpriteBorder: {x: 0, y: 0, z: 0, w: 0} + SpriteRect: + serializedVersion: 2 + x: 0 + y: 0 + width: 0 + height: 0 + SpriteID: + PhysicsOutlines: [] diff --git a/docs/images/utilities/data-structures/trie.svg.meta b/docs/images/utilities/data-structures/trie.svg.meta index 6648137b5..469fa6dab 100644 --- a/docs/images/utilities/data-structures/trie.svg.meta +++ b/docs/images/utilities/data-structures/trie.svg.meta @@ -1,7 +1,53 @@ fileFormatVersion: 2 guid: aed770266a425a645b1d91195fd26b0a -DefaultImporter: +ScriptedImporter: + internalIDToNameTable: [] externalObjects: {} + serializedVersion: 2 userData: assetBundleName: assetBundleVariant: + script: {fileID: 12408, guid: 0000000000000000e000000000000000, type: 0} + svgType: 3 + texturedSpriteMeshType: 0 + svgPixelsPerUnit: 100 + gradientResolution: 64 + alignment: 0 + customPivot: {x: 0, y: 0} + generatePhysicsShape: 0 + viewportOptions: 0 + preserveViewport: 0 + advancedMode: 0 + tessellationMode: 1 + predefinedResolutionIndex: 1 + targetResolution: 1080 + resolutionMultiplier: 1 + stepDistance: 10 + samplingStepDistance: 100 + maxCordDeviationEnabled: 0 + maxCordDeviation: 1 + maxTangentAngleEnabled: 0 + maxTangentAngle: 5 + keepTextureAspectRatio: 1 + textureSize: 256 + textureWidth: 256 + textureHeight: 256 + wrapMode: 0 + filterMode: 1 + sampleCount: 4 + preserveSVGImageAspect: 0 + useSVGPixelsPerUnit: 0 + spriteData: + TessellationDetail: 0 + SpriteName: + SpritePivot: {x: 0, y: 0} + SpriteAlignment: 0 + SpriteBorder: {x: 0, y: 0, z: 0, w: 0} + SpriteRect: + serializedVersion: 2 + x: 0 + y: 0 + width: 0 + height: 0 + SpriteID: + PhysicsOutlines: [] diff --git a/docs/images/utilities/math/geometry-edge-cases.svg.meta b/docs/images/utilities/math/geometry-edge-cases.svg.meta index b363bae8f..f55a7f4f2 100644 --- a/docs/images/utilities/math/geometry-edge-cases.svg.meta +++ b/docs/images/utilities/math/geometry-edge-cases.svg.meta @@ -1,7 +1,53 @@ fileFormatVersion: 2 guid: 06d179bd2fc8b794aab83bd431e20e13 -DefaultImporter: +ScriptedImporter: + internalIDToNameTable: [] externalObjects: {} + serializedVersion: 2 userData: assetBundleName: assetBundleVariant: + script: {fileID: 12408, guid: 0000000000000000e000000000000000, type: 0} + svgType: 3 + texturedSpriteMeshType: 0 + svgPixelsPerUnit: 100 + gradientResolution: 64 + alignment: 0 + customPivot: {x: 0, y: 0} + generatePhysicsShape: 0 + viewportOptions: 0 + preserveViewport: 0 + advancedMode: 0 + tessellationMode: 1 + predefinedResolutionIndex: 1 + targetResolution: 1080 + resolutionMultiplier: 1 + stepDistance: 10 + samplingStepDistance: 100 + maxCordDeviationEnabled: 0 + maxCordDeviation: 1 + maxTangentAngleEnabled: 0 + maxTangentAngle: 5 + keepTextureAspectRatio: 1 + textureSize: 256 + textureWidth: 256 + textureHeight: 256 + wrapMode: 0 + filterMode: 1 + sampleCount: 4 + preserveSVGImageAspect: 0 + useSVGPixelsPerUnit: 0 + spriteData: + TessellationDetail: 0 + SpriteName: + SpritePivot: {x: 0, y: 0} + SpriteAlignment: 0 + SpriteBorder: {x: 0, y: 0, z: 0, w: 0} + SpriteRect: + serializedVersion: 2 + x: 0 + y: 0 + width: 0 + height: 0 + SpriteID: + PhysicsOutlines: [] diff --git a/docs/images/utilities/math/polyline-simplify.svg.meta b/docs/images/utilities/math/polyline-simplify.svg.meta index 1800ef919..74b0e3599 100644 --- a/docs/images/utilities/math/polyline-simplify.svg.meta +++ b/docs/images/utilities/math/polyline-simplify.svg.meta @@ -1,7 +1,53 @@ fileFormatVersion: 2 guid: 39bc13cd4b26a074eaa9b62f0a915baa -DefaultImporter: +ScriptedImporter: + internalIDToNameTable: [] externalObjects: {} + serializedVersion: 2 userData: assetBundleName: assetBundleVariant: + script: {fileID: 12408, guid: 0000000000000000e000000000000000, type: 0} + svgType: 3 + texturedSpriteMeshType: 0 + svgPixelsPerUnit: 100 + gradientResolution: 64 + alignment: 0 + customPivot: {x: 0, y: 0} + generatePhysicsShape: 0 + viewportOptions: 0 + preserveViewport: 0 + advancedMode: 0 + tessellationMode: 1 + predefinedResolutionIndex: 1 + targetResolution: 1080 + resolutionMultiplier: 1 + stepDistance: 10 + samplingStepDistance: 100 + maxCordDeviationEnabled: 0 + maxCordDeviation: 1 + maxTangentAngleEnabled: 0 + maxTangentAngle: 5 + keepTextureAspectRatio: 1 + textureSize: 256 + textureWidth: 256 + textureHeight: 256 + wrapMode: 0 + filterMode: 1 + sampleCount: 4 + preserveSVGImageAspect: 0 + useSVGPixelsPerUnit: 0 + spriteData: + TessellationDetail: 0 + SpriteName: + SpritePivot: {x: 0, y: 0} + SpriteAlignment: 0 + SpriteBorder: {x: 0, y: 0, z: 0, w: 0} + SpriteRect: + serializedVersion: 2 + x: 0 + y: 0 + width: 0 + height: 0 + SpriteID: + PhysicsOutlines: [] diff --git a/docs/images/utilities/random/random-generators.svg.meta b/docs/images/utilities/random/random-generators.svg.meta index 9118d7a70..9709b5d2f 100644 --- a/docs/images/utilities/random/random-generators.svg.meta +++ b/docs/images/utilities/random/random-generators.svg.meta @@ -1,7 +1,53 @@ fileFormatVersion: 2 guid: 535e22c9e013be6449afc7018dd09408 -DefaultImporter: +ScriptedImporter: + internalIDToNameTable: [] externalObjects: {} + serializedVersion: 2 userData: assetBundleName: assetBundleVariant: + script: {fileID: 12408, guid: 0000000000000000e000000000000000, type: 0} + svgType: 3 + texturedSpriteMeshType: 0 + svgPixelsPerUnit: 100 + gradientResolution: 64 + alignment: 0 + customPivot: {x: 0, y: 0} + generatePhysicsShape: 0 + viewportOptions: 0 + preserveViewport: 0 + advancedMode: 0 + tessellationMode: 1 + predefinedResolutionIndex: 1 + targetResolution: 1080 + resolutionMultiplier: 1 + stepDistance: 10 + samplingStepDistance: 100 + maxCordDeviationEnabled: 0 + maxCordDeviation: 1 + maxTangentAngleEnabled: 0 + maxTangentAngle: 5 + keepTextureAspectRatio: 1 + textureSize: 256 + textureWidth: 256 + textureHeight: 256 + wrapMode: 0 + filterMode: 1 + sampleCount: 4 + preserveSVGImageAspect: 0 + useSVGPixelsPerUnit: 0 + spriteData: + TessellationDetail: 0 + SpriteName: + SpritePivot: {x: 0, y: 0} + SpriteAlignment: 0 + SpriteBorder: {x: 0, y: 0, z: 0, w: 0} + SpriteRect: + serializedVersion: 2 + x: 0 + y: 0 + width: 0 + height: 0 + SpriteID: + PhysicsOutlines: [] diff --git a/docs/images/utilities/reflection/reflection-scan.svg.meta b/docs/images/utilities/reflection/reflection-scan.svg.meta index b333d5726..edd3a989f 100644 --- a/docs/images/utilities/reflection/reflection-scan.svg.meta +++ b/docs/images/utilities/reflection/reflection-scan.svg.meta @@ -1,7 +1,53 @@ fileFormatVersion: 2 guid: 6ad5a1e81e95d694a93480e0dd8034e3 -DefaultImporter: +ScriptedImporter: + internalIDToNameTable: [] externalObjects: {} + serializedVersion: 2 userData: assetBundleName: assetBundleVariant: + script: {fileID: 12408, guid: 0000000000000000e000000000000000, type: 0} + svgType: 3 + texturedSpriteMeshType: 0 + svgPixelsPerUnit: 100 + gradientResolution: 64 + alignment: 0 + customPivot: {x: 0, y: 0} + generatePhysicsShape: 0 + viewportOptions: 0 + preserveViewport: 0 + advancedMode: 0 + tessellationMode: 1 + predefinedResolutionIndex: 1 + targetResolution: 1080 + resolutionMultiplier: 1 + stepDistance: 10 + samplingStepDistance: 100 + maxCordDeviationEnabled: 0 + maxCordDeviation: 1 + maxTangentAngleEnabled: 0 + maxTangentAngle: 5 + keepTextureAspectRatio: 1 + textureSize: 256 + textureWidth: 256 + textureHeight: 256 + wrapMode: 0 + filterMode: 1 + sampleCount: 4 + preserveSVGImageAspect: 0 + useSVGPixelsPerUnit: 0 + spriteData: + TessellationDetail: 0 + SpriteName: + SpritePivot: {x: 0, y: 0} + SpriteAlignment: 0 + SpriteBorder: {x: 0, y: 0, z: 0, w: 0} + SpriteRect: + serializedVersion: 2 + x: 0 + y: 0 + width: 0 + height: 0 + SpriteID: + PhysicsOutlines: [] diff --git a/docs/images/utilities/singletons/data-distribution-decision.svg.meta b/docs/images/utilities/singletons/data-distribution-decision.svg.meta index 2f3ad5490..8787a10d4 100644 --- a/docs/images/utilities/singletons/data-distribution-decision.svg.meta +++ b/docs/images/utilities/singletons/data-distribution-decision.svg.meta @@ -1,7 +1,53 @@ fileFormatVersion: 2 guid: 3282c524f1e6827419d2c239e4b41cd0 -DefaultImporter: +ScriptedImporter: + internalIDToNameTable: [] externalObjects: {} + serializedVersion: 2 userData: assetBundleName: assetBundleVariant: + script: {fileID: 12408, guid: 0000000000000000e000000000000000, type: 0} + svgType: 3 + texturedSpriteMeshType: 0 + svgPixelsPerUnit: 100 + gradientResolution: 64 + alignment: 0 + customPivot: {x: 0, y: 0} + generatePhysicsShape: 0 + viewportOptions: 0 + preserveViewport: 0 + advancedMode: 0 + tessellationMode: 1 + predefinedResolutionIndex: 1 + targetResolution: 1080 + resolutionMultiplier: 1 + stepDistance: 10 + samplingStepDistance: 100 + maxCordDeviationEnabled: 0 + maxCordDeviation: 1 + maxTangentAngleEnabled: 0 + maxTangentAngle: 5 + keepTextureAspectRatio: 1 + textureSize: 256 + textureWidth: 256 + textureHeight: 256 + wrapMode: 0 + filterMode: 1 + sampleCount: 4 + preserveSVGImageAspect: 0 + useSVGPixelsPerUnit: 0 + spriteData: + TessellationDetail: 0 + SpriteName: + SpritePivot: {x: 0, y: 0} + SpriteAlignment: 0 + SpriteBorder: {x: 0, y: 0, z: 0, w: 0} + SpriteRect: + serializedVersion: 2 + x: 0 + y: 0 + width: 0 + height: 0 + SpriteID: + PhysicsOutlines: [] diff --git a/docs/images/utilities/singletons/singletons-lifecycle.svg.meta b/docs/images/utilities/singletons/singletons-lifecycle.svg.meta index 58b9bc385..acbf4f49d 100644 --- a/docs/images/utilities/singletons/singletons-lifecycle.svg.meta +++ b/docs/images/utilities/singletons/singletons-lifecycle.svg.meta @@ -1,7 +1,53 @@ fileFormatVersion: 2 guid: 85a8a72410201244d99fba6e98da2de6 -DefaultImporter: +ScriptedImporter: + internalIDToNameTable: [] externalObjects: {} + serializedVersion: 2 userData: assetBundleName: assetBundleVariant: + script: {fileID: 12408, guid: 0000000000000000e000000000000000, type: 0} + svgType: 3 + texturedSpriteMeshType: 0 + svgPixelsPerUnit: 100 + gradientResolution: 64 + alignment: 0 + customPivot: {x: 0, y: 0} + generatePhysicsShape: 0 + viewportOptions: 0 + preserveViewport: 0 + advancedMode: 0 + tessellationMode: 1 + predefinedResolutionIndex: 1 + targetResolution: 1080 + resolutionMultiplier: 1 + stepDistance: 10 + samplingStepDistance: 100 + maxCordDeviationEnabled: 0 + maxCordDeviation: 1 + maxTangentAngleEnabled: 0 + maxTangentAngle: 5 + keepTextureAspectRatio: 1 + textureSize: 256 + textureWidth: 256 + textureHeight: 256 + wrapMode: 0 + filterMode: 1 + sampleCount: 4 + preserveSVGImageAspect: 0 + useSVGPixelsPerUnit: 0 + spriteData: + TessellationDetail: 0 + SpriteName: + SpritePivot: {x: 0, y: 0} + SpriteAlignment: 0 + SpriteBorder: {x: 0, y: 0, z: 0, w: 0} + SpriteRect: + serializedVersion: 2 + x: 0 + y: 0 + width: 0 + height: 0 + SpriteID: + PhysicsOutlines: [] From a058f6afb0678bfb4c031ef57a6cd4116b4262b8 Mon Sep 17 00:00:00 2001 From: wallstop Date: Sun, 14 Jun 2026 02:36:31 +0000 Subject: [PATCH 07/31] ci(unity): Unity-6 single-threaded leg + third-party DI integration matrix Goal 1 (special defines): add -AdditionalScriptingDefines to run-ci-tests.ps1, injected via a PlayerSettings.SetScriptingDefineSymbols + SaveAssets configure pass BEFORE -runTests so the asmdef test assemblies compile with them; add a Unity-6-only `unity-tests-single-threaded` job (editmode+playmode, core-only, SINGLE_THREADED) with its own org-lock/preflight/license/verify scaffolding and -single-threaded cache/artifact paths. Exercises the 10-file SINGLE_THREADED path never compiled in the normal matrix. Goal 2 (integrations): add .github/integration-packages.json (Reflex/VContainer/ Extenject from OpenUPM) + -IncludeIntegrations; the main matrix now installs the 3 DI packages and runs the Reflex/VContainer/Zenject integration asmdefs across all Unity versions. Also prettier-fix asmdef-discovery.js. Unverified without a self-hosted run: define persistence+recompile and OpenUPM pin resolution. Matrix still skips until secrets. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/integration-packages.json | 19 ++ .github/workflows/unity-tests.yml | 219 ++++++++++++++++++- cspell.json | 6 +- scripts/unity/run-ci-tests.ps1 | 350 +++++++++++++++++++++++++----- 4 files changed, 532 insertions(+), 62 deletions(-) create mode 100644 .github/integration-packages.json diff --git a/.github/integration-packages.json b/.github/integration-packages.json new file mode 100644 index 000000000..bea3a0080 --- /dev/null +++ b/.github/integration-packages.json @@ -0,0 +1,19 @@ +{ + "_comment": "Canonical single source for the OpenUPM scoped registry and PINNED third-party dependency-injection container packages (Reflex / VContainer / Zenject-Extenject) used by the integration test legs. Consumed by scripts/unity/run-ci-tests.ps1 (ephemeral manifest, integration legs only via -IncludeIntegrations). The Runtime/Integrations and Tests/{Editor,Runtime}/Integrations asmdefs gate on REFLEX_PRESENT / VCONTAINER_PRESENT / ZENJECT_PRESENT, which Unity defines automatically (via each asmdef's versionDefines) ONLY when the matching package below is installed; installing them here is what makes those integration assemblies compile and their tests run. Bump versions/scopes HERE only.", + "_note_pins": "Pins are the OpenUPM 'latest' dist-tag as of 2026-06: Reflex 14.3.0, VContainer 1.18.0, Extenject 9.2.0-stcf3 (the same Extenject pin DxMessaging uses; the only OpenUPM-published Extenject build with proper meta files). A wrong pin only surfaces at the first real self-hosted CI run (the package fails to resolve); confirm against https://openupm.com if an integration leg reports a package-resolution failure. # TODO(unity-helpers): confirm pins on first self-hosted integration run.", + "registry": { + "name": "package.openupm.com", + "url": "https://package.openupm.com", + "scopes": ["com.gustavopsantos", "jp.hadashikick", "com.svermeulen"] + }, + "packages": { + "com.gustavopsantos.reflex": "14.3.0", + "jp.hadashikick.vcontainer": "1.18.0", + "com.svermeulen.extenject": "9.2.0-stcf3" + }, + "defines": { + "com.gustavopsantos.reflex": "REFLEX_PRESENT", + "jp.hadashikick.vcontainer": "VCONTAINER_PRESENT", + "com.svermeulen.extenject": "ZENJECT_PRESENT" + } +} diff --git a/.github/workflows/unity-tests.yml b/.github/workflows/unity-tests.yml index 6a1d710a1..869757cd2 100644 --- a/.github/workflows/unity-tests.yml +++ b/.github/workflows/unity-tests.yml @@ -265,7 +265,8 @@ jobs: 'Tests/**', 'scripts/unity/run-ci-tests.ps1', 'scripts/unity/lib/asmdef-discovery.js', - '.github/actions/compute-unity-assemblies/action.yml' + '.github/actions/compute-unity-assemblies/action.yml', + '.github/integration-packages.json' ) }} with: path: | @@ -288,6 +289,11 @@ jobs: with: target: "${{ matrix.test-mode }}" runtime-only: "${{ matrix.test-mode == 'standalone' && 'true' || 'false' }}" + # Run the DI-container integration suites (Reflex / VContainer / Zenject) + # alongside core on EVERY version x mode leg. The matching run step passes + # -IncludeIntegrations so the 3 packages are installed into the ephemeral + # manifest; without that the integration assemblies would not compile. + include-integrations: "true" - name: Validate Unity license secrets uses: ./.github/actions/validate-unity-license @@ -367,6 +373,7 @@ jobs: -TestMode '${{ matrix.test-mode }}' ` -AssemblyNames $env:UH_TEST_ASSEMBLIES ` -ArtifactsPath '.artifacts/unity/${{ matrix.unity-version }}-${{ matrix.test-mode }}' ` + -IncludeIntegrations ` -ReleaseCodeOptimization ` -ReleasePlayerBuild @@ -432,3 +439,213 @@ jobs: path: .artifacts/unity/${{ matrix.unity-version }}-${{ matrix.test-mode }} if-no-files-found: warn retention-days: 14 + + # SINGLE_THREADED leg. The default matrix never exercises the SINGLE_THREADED + # code paths (Runtime/Core/DataStructure/Cache.cs et al. gate thread-safe vs + # single-threaded behavior on `#if SINGLE_THREADED`, OFF by default). This job + # builds+tests with SINGLE_THREADED (and WALLSTOP_CONCAVE_HULL_STATS) defined as + # GLOBAL scripting symbols so EVERY asmdef test assembly compiles the + # single-threaded path. Restricted to Unity 6 (6000.3.16f1) editmode+playmode to + # keep time small (no IL2CPP standalone build). Core-only (no integrations): the + # single-threaded toggle is a core-runtime concern, so installing the DI packages + # would only add cost. It is a SEPARATE job from unity-tests but shares the SAME + # self-hosted Unity seat; the org lock `wallstop-organization-builds` (acquired + # below, released if:always()) is what actually serializes it against the main + # matrix on that single seat -- max-parallel:1 only serializes within this job's + # own (version x mode) legs. Reuses the same secrets gate, runner-preflight, + # provisioning, perf-category exclusion, verify-results, and artifact-upload + # scaffolding as unity-tests, with DISTINCT project/Library/artifact paths + # (suffix `-single-threaded`) so its differently-compiled Library never collides + # with or poisons the default matrix's `6000.3.16f1-{mode}` cache. + unity-tests-single-threaded: + name: Unity ${{ matrix.unity-version }} ${{ matrix.test-mode }} (SINGLE_THREADED) + needs: + - matrix-config + - runner-preflight + if: >- + ${{ + (github.event_name != 'pull_request' || + github.event.pull_request.head.repo.full_name == github.repository) && + (github.event_name != 'push' || github.ref_protected) && + needs.matrix-config.outputs.has-required-secrets == 'true' + }} + runs-on: [self-hosted, Windows, RAM-64GB] + timeout-minutes: 660 + strategy: + fail-fast: false + max-parallel: 1 + matrix: + # Unity 6 ONLY (6000.3.16f1) to keep the extra leg cheap. editmode + playmode + # only -- both are fast in-editor runs; the SINGLE_THREADED path does not need + # a standalone IL2CPP player build to be exercised. + unity-version: ["6000.3.16f1"] + test-mode: ["editmode", "playmode"] + steps: + - name: Enable Git long paths + shell: pwsh + env: + GIT_CONFIG_GLOBAL: ${{ runner.temp }}/uh-gitconfig + run: git config --global core.longpaths true + + - name: Checkout + uses: actions/checkout@v6 + env: + GIT_CONFIG_GLOBAL: ${{ runner.temp }}/uh-gitconfig + with: + lfs: true + persist-credentials: false + + - name: Print runner diagnostics + # Composite action runs PowerShell internally; `shell: bash` on a + # self-hosted Windows runner can resolve to the WSL stub and fail. Never + # use plain `shell: bash` on self-hosted Windows runners. + uses: ./.github/actions/print-self-hosted-runner-diagnostics + with: + matrix-note: "single-threaded (organization lock wraps the Unity section)" + + - name: Cache Unity Library and package caches + uses: actions/cache@v5 + env: + PACKAGE_HASH: >- + ${{ hashFiles( + 'package.json', + 'Runtime/**', + 'Editor/**', + 'Tests/**', + 'scripts/unity/run-ci-tests.ps1', + 'scripts/unity/lib/asmdef-discovery.js', + '.github/actions/compute-unity-assemblies/action.yml', + '.github/integration-packages.json' + ) }} + with: + path: | + .artifacts/unity/projects/${{ matrix.unity-version }}-${{ matrix.test-mode }}-single-threaded/Library + .artifacts/unity/cache/${{ matrix.unity-version }} + # `single-threaded` in BOTH the project path above and this key keeps this + # leg's Library (compiled WITH SINGLE_THREADED) separate from the default + # matrix's `6000.3.16f1-{mode}` Library (compiled WITHOUT it). + key: Library-${{ runner.os }}-${{ runner.arch }}-${{ matrix.unity-version }}-${{ matrix.test-mode }}-single-threaded-${{ env.PACKAGE_HASH }} + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: "22.18.0" + + - name: Compute test assembly list + id: compute + uses: ./.github/actions/compute-unity-assemblies + with: + target: "${{ matrix.test-mode }}" + runtime-only: "false" + + - name: Validate Unity license secrets + uses: ./.github/actions/validate-unity-license + env: + UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} + UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} + UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} + + - name: Provision Unity Editor + if: ${{ steps.compute.outputs.is-empty != 'true' }} + timeout-minutes: 180 + shell: pwsh + run: | + $artifactsPath = '.artifacts/unity/${{ matrix.unity-version }}-${{ matrix.test-mode }}-single-threaded' + $diagnosticsPath = Join-Path $artifactsPath 'provisioning' + $diagnosticsFile = Join-Path $diagnosticsPath 'ensure-editor-summary.json' + New-Item -ItemType Directory -Force -Path $diagnosticsPath | Out-Null + $editor = ./scripts/unity/ensure-editor.ps1 ` + -UnityVersion '${{ matrix.unity-version }}' ` + -CiManagedOnly ` + -RequireHealthyExisting ` + -ProvisioningProfile 'EditorOnly' ` + -DiagnosticsPath $diagnosticsFile + "UNITY_EDITOR_PATH=$editor" | Out-File -FilePath $env:GITHUB_ENV -Append + + - name: Upload Unity provisioning diagnostics + if: always() + uses: actions/upload-artifact@v7 + with: + name: unity-provisioning-${{ matrix.unity-version }}-${{ matrix.test-mode }}-single-threaded + path: .artifacts/unity/${{ matrix.unity-version }}-${{ matrix.test-mode }}-single-threaded/provisioning + if-no-files-found: warn + retention-days: 14 + + - name: Acquire organization Unity lock + if: ${{ steps.compute.outputs.is-empty != 'true' }} + uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/acquire-build-lock@v1 + with: + lock-name: wallstop-organization-builds + holder-id-suffix: ${{ matrix.unity-version }}-${{ matrix.test-mode }}-single-threaded + timeout-minutes: "300" + env: + BUILD_LOCK_TOKEN: ${{ secrets.ORG_BUILD_LOCK_TOKEN }} + + - name: Run Unity Test Runner + id: run_tests + if: ${{ steps.compute.outputs.is-empty != 'true' }} + # editmode/playmode in-editor runs finish well below this; stays under the + # job timeout (660) so the step clock fires first and releases the seat. + timeout-minutes: 150 + shell: pwsh + env: + UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} + UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} + UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} + UNITY_ACCELERATOR_ENDPOINT: ${{ secrets.UNITY_ACCELERATOR_ENDPOINT }} + UH_UNITY_TEST_CATEGORY: "!Performance;!Stress" + run: | + ./scripts/unity/run-ci-tests.ps1 ` + -UnityVersion '${{ matrix.unity-version }}' ` + -TestMode '${{ matrix.test-mode }}' ` + -AssemblyNames $env:UH_TEST_ASSEMBLIES ` + -ArtifactsPath '.artifacts/unity/${{ matrix.unity-version }}-${{ matrix.test-mode }}-single-threaded' ` + -ProjectPath '.artifacts/unity/projects/${{ matrix.unity-version }}-${{ matrix.test-mode }}-single-threaded' ` + -AdditionalScriptingDefines SINGLE_THREADED,WALLSTOP_CONCAVE_HULL_STATS ` + -ReleaseCodeOptimization ` + -ReleasePlayerBuild + + - name: Return Unity license + if: always() + uses: ./.github/actions/return-unity-license + env: + UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} + UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} + + - name: Release organization Unity lock + if: always() + uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/release-build-lock@v1 + with: + lock-name: wallstop-organization-builds + holder-id-suffix: ${{ matrix.unity-version }}-${{ matrix.test-mode }}-single-threaded + env: + BUILD_LOCK_TOKEN: ${{ secrets.ORG_BUILD_LOCK_TOKEN }} + + - name: Dump Unity log tail on failure or cancellation + if: ${{ failure() || cancelled() }} + uses: ./.github/actions/dump-unity-log-tail + with: + results-dir: .artifacts/unity/${{ matrix.unity-version }}-${{ matrix.test-mode }}-single-threaded + label: Unity ${{ matrix.unity-version }} ${{ matrix.test-mode }} (SINGLE_THREADED) + + - name: Verify tests actually ran + if: >- + ${{ + !cancelled() && + steps.compute.outcome == 'success' && + (steps.compute.outputs.is-empty == 'true' || steps.run_tests.outcome != 'skipped') + }} + uses: ./.github/actions/verify-unity-results + with: + results-dir: .artifacts/unity/${{ matrix.unity-version }}-${{ matrix.test-mode }}-single-threaded + label: Unity ${{ matrix.unity-version }} ${{ matrix.test-mode }} (SINGLE_THREADED) + expected-empty: ${{ steps.compute.outputs.is-empty }} + + - name: Upload Unity test artifacts + if: always() + uses: actions/upload-artifact@v7 + with: + name: unity-${{ matrix.unity-version }}-${{ matrix.test-mode }}-single-threaded + path: .artifacts/unity/${{ matrix.unity-version }}-${{ matrix.test-mode }}-single-threaded + if-no-files-found: warn + retention-days: 14 diff --git a/cspell.json b/cspell.json index da9d6bd9b..3ce8f666c 100644 --- a/cspell.json +++ b/cspell.json @@ -30,7 +30,8 @@ "**/*.min.css", "Samples~/**/Library/**", "Samples~/**/Temp/**", - "Samples~/**/obj/**" + "Samples~/**/obj/**", + "perf-results/**" ], "files": [ "**/*.{md,markdown}", @@ -489,7 +490,8 @@ "UnityLogTagFormatter", "Sirenix", "asmdef", - "asmref" + "asmref", + "stcf" ] }, { diff --git a/scripts/unity/run-ci-tests.ps1 b/scripts/unity/run-ci-tests.ps1 index 754dfa35d..d2f00d518 100644 --- a/scripts/unity/run-ci-tests.ps1 +++ b/scripts/unity/run-ci-tests.ps1 @@ -27,6 +27,27 @@ param( [switch]$IncludeComparisons, + # Install the third-party DI-container packages (Reflex / VContainer / Zenject- + # Extenject) from .github/integration-packages.json + the OpenUPM scoped + # registry into the ephemeral manifest, so the Runtime/Integrations and + # Tests/{Editor,Runtime}/Integrations asmdefs (gated on REFLEX_PRESENT / + # VCONTAINER_PRESENT / ZENJECT_PRESENT versionDefines) compile and their tests + # run. The integration test ASSEMBLIES must additionally be added to + # -AssemblyNames by the caller (compute-unity-assemblies include-integrations). + [switch]$IncludeIntegrations, + + # Extra GLOBAL scripting define symbols compiled into EVERY assembly (asmdef + # assemblies included), e.g. SINGLE_THREADED to exercise the single-threaded + # code paths. Empty by default so the DEFAULT (multi-threaded) behavior is + # unchanged. Applied via a configure pass that sets PlayerSettings scripting + # defines and lets Unity persist them BEFORE the -runTests pass loads the + # project, because Unity in -batchmode does NOT recompile when defines change + # mid-run -- the symbols must be in place from editor startup (Unity issue + # tracker: define edits before project open are honored from 2021.1+, which the + # Unity-6-only single-threaded leg satisfies). See New-ConfiguratorSource and + # the configure-pass dispatch below. + [string[]]$AdditionalScriptingDefines = @(), + [switch]$ReleaseCodeOptimization, [ValidateSet('IL2CPP', 'Mono2x')] @@ -603,19 +624,42 @@ function Initialize-UnityCacheEnvironment { Write-Host "::endgroup::" } -function Get-ComparisonPackages { - param([Parameter(Mandatory = $true)][string]$Root) - $path = Join-Path $Root '.github/comparison-packages.json' +# Read+parse a package-manifest single-source JSON (the OpenUPM registry + pinned +# packages used to extend the ephemeral manifest). Shared by the comparison and +# integration legs, which read DIFFERENT files of the SAME shape: +# .github/comparison-packages.json (benchmark comparison deps; not present in +# unity-helpers today) +# .github/integration-packages.json (DI-container integration deps) +# Kept DRY so both legs parse identically and a missing/typo'd source fails loudly +# with the file path rather than silently producing an empty manifest extension. +function Get-PackageManifestSource { + param( + [Parameter(Mandatory = $true)][string]$Root, + [Parameter(Mandatory = $true)][string]$RelativePath, + [Parameter(Mandatory = $true)][string]$Kind + ) + $path = Join-Path $Root $RelativePath if (-not (Test-Path -LiteralPath $path)) { - throw "Comparison packages single source not found: $path" + throw "$Kind packages single source not found: $path" } return Get-Content -LiteralPath $path -Raw | ConvertFrom-Json } +function Get-ComparisonPackages { + param([Parameter(Mandatory = $true)][string]$Root) + return Get-PackageManifestSource -Root $Root -RelativePath '.github/comparison-packages.json' -Kind 'Comparison' +} + +function Get-IntegrationPackages { + param([Parameter(Mandatory = $true)][string]$Root) + return Get-PackageManifestSource -Root $Root -RelativePath '.github/integration-packages.json' -Kind 'Integration' +} + function New-ManifestJson { param( [Parameter(Mandatory = $true)][string]$Root, [switch]$IncludeComparisons, + [switch]$IncludeIntegrations, [string]$RepoRoot ) @@ -631,12 +675,20 @@ function New-ManifestJson { testables = @($PackageName) } + # Accumulate the OpenUPM scoped-registry scopes contributed by whichever opt-in + # legs are active. Both comparison and integration legs use the SAME OpenUPM + # registry (package.openupm.com); if both were ever active together their + # scopes are merged into a SINGLE scopedRegistries entry (Unity would otherwise + # see two registries with the same URL). The non-opt-in legs add NOTHING here, + # so their manifest stays byte-for-byte identical to before (no scopedRegistries + # key, no extra dependencies) and their Library cache/reliability are unchanged. + $registryName = $null + $registryUrl = $null + $registryScopes = New-Object System.Collections.Generic.List[string] + # ONLY the comparison legs (-IncludeComparisons) get the OpenUPM scoped # registry, pinned comparison packages, and comparison-package-required Unity # built-in modules, read from the single source .github/comparison-packages.json. - # Non-comparison legs MUST stay byte-for-byte identical to before (no - # scopedRegistries key and no extra dependencies) so their Library cache and - # reliability are unchanged. if ($IncludeComparisons) { if ([string]::IsNullOrWhiteSpace($RepoRoot)) { throw "New-ManifestJson -IncludeComparisons requires -RepoRoot (the comparison-packages.json single source)." @@ -653,14 +705,57 @@ function New-ManifestJson { $dependencies[$pkg.Name] = $pkg.Value } $reg = $comparisons.registry + $registryName = $reg.name + $registryUrl = $reg.url + foreach ($scope in @($reg.scopes)) { + if (-not $registryScopes.Contains($scope)) { + $registryScopes.Add($scope) + } + } + } + + # ONLY the integration legs (-IncludeIntegrations) get the OpenUPM scoped + # registry + the pinned DI-container packages (Reflex / VContainer / Zenject- + # Extenject) from .github/integration-packages.json. Installing them is what + # makes the Runtime/Integrations + Tests/{Editor,Runtime}/Integrations asmdefs + # (REFLEX_PRESENT / VCONTAINER_PRESENT / ZENJECT_PRESENT versionDefines) compile + # and their tests run. unityBuiltInPackages is OPTIONAL here (the DI packages + # are pure-managed and pull no extra Unity modules). + if ($IncludeIntegrations) { + if ([string]::IsNullOrWhiteSpace($RepoRoot)) { + throw "New-ManifestJson -IncludeIntegrations requires -RepoRoot (the integration-packages.json single source)." + } + $integrations = Get-IntegrationPackages -Root $RepoRoot + foreach ($pkg in $integrations.packages.PSObject.Properties) { + $dependencies[$pkg.Name] = $pkg.Value + } + $builtInPackages = $integrations.PSObject.Properties['unityBuiltInPackages'] + if ($builtInPackages) { + foreach ($pkg in $builtInPackages.Value.PSObject.Properties) { + $dependencies[$pkg.Name] = $pkg.Value + } + } + $reg = $integrations.registry + if (-not $registryName) { + $registryName = $reg.name + $registryUrl = $reg.url + } + foreach ($scope in @($reg.scopes)) { + if (-not $registryScopes.Contains($scope)) { + $registryScopes.Add($scope) + } + } + } + + if ($registryScopes.Count -gt 0) { # Ordered so ConvertTo-Json emits name/url/scopes deterministically (matches # the committed local-parity manifest field order and keeps the CI-log diff # of the generated manifest stable run-to-run). $manifest['scopedRegistries'] = @( [ordered]@{ - name = $reg.name - url = $reg.url - scopes = @($reg.scopes) + name = $registryName + url = $registryUrl + scopes = @($registryScopes.ToArray()) } ) } @@ -682,8 +777,11 @@ function New-ConfiguratorSource { # generated configurator; no automated contract test pins it anymore. @" using System; +using System.Collections.Generic; using System.IO; +using System.Linq; using UnityEditor; +using UnityEditor.Build; using UnityEngine; public static class UhCiTestConfigurator @@ -695,6 +793,22 @@ public static class UhCiTestConfigurator UnityEditor.Compilation.CompilationPipeline.codeOptimization = UnityEditor.Compilation.CodeOptimization.Release; EditorUserBuildSettings.SwitchActiveBuildTarget(BuildTargetGroup.Standalone, BuildTarget.StandaloneWindows64); + + // GLOBAL scripting define injection (e.g. SINGLE_THREADED). The runner hands + // the requested defines in via UH_ADDITIONAL_SCRIPTING_DEFINES (semicolon- + // delimited). They are set on the Standalone NamedBuildTarget -- the only + // build-target group every CI leg (editmode/playmode/standalone) uses -- so + // they apply to ALL assemblies, asmdef assemblies INCLUDED (global scripting + // defines, not an Assets/csc.rsp that only reaches the predefined assembly). + // Unity in -batchmode does NOT recompile when defines change mid-run, so the + // runner runs this configure pass in a SEPARATE editor invocation that + // persists the defines to ProjectSettings.asset (AssetDatabase.SaveAssets + // below); the subsequent -runTests invocation then loads the project with + // the defines in place from startup, and its FIRST compile sees them. When + // the env var is empty this is a no-op, so the DEFAULT (no-extra-defines) + // behavior and the existing comparison/standalone legs are unchanged. + ApplyAdditionalScriptingDefines(); + // The scripting backend is parameterized: the runner passes the IL2CPP or // the Mono backend for the Mono perf leg via -Backend. PlayerSettings.SetScriptingBackend(BuildTargetGroup.Standalone, ScriptingImplementation.$Backend); @@ -715,7 +829,17 @@ public static class UhCiTestConfigurator // Print the EFFECTIVE Unity config so the artifact log PROVES Mono/IL2CPP // + .NET Standard 2.1 + Release for this run. - Debug.Log(`$"UH perf config: backend={PlayerSettings.GetScriptingBackend(BuildTargetGroup.Standalone)}, api={PlayerSettings.GetApiCompatibilityLevel(BuildTargetGroup.Standalone)}, codeOpt={UnityEditor.Compilation.CompilationPipeline.codeOptimization}, il2cppConfig={PlayerSettings.GetIl2CppCompilerConfiguration(BuildTargetGroup.Standalone)}"); + PlayerSettings.GetScriptingDefineSymbols(NamedBuildTarget.Standalone, out string[] effectiveDefines); + Debug.Log(`$"UH perf config: backend={PlayerSettings.GetScriptingBackend(BuildTargetGroup.Standalone)}, api={PlayerSettings.GetApiCompatibilityLevel(BuildTargetGroup.Standalone)}, codeOpt={UnityEditor.Compilation.CompilationPipeline.codeOptimization}, il2cppConfig={PlayerSettings.GetIl2CppCompilerConfiguration(BuildTargetGroup.Standalone)}, defines=[{string.Join(`";`", effectiveDefines ?? new string[0])}]"); + + // Persist the PlayerSettings mutations (scripting backend/api/stripping AND + // any injected scripting defines) to ProjectSettings.asset so the SEPARATE + // -runTests editor invocation that follows this configure pass loads them + // from startup. A clean -batchmode quit normally flushes settings, but the + // explicit save removes that dependency and is the load-bearing step for the + // editmode/playmode single-threaded leg (where this configure pass is the + // ONLY place the defines get persisted before the test invocation compiles). + AssetDatabase.SaveAssets(); // Write a success marker as the FINAL action so the runner can treat the // CONFIGURED PROJECT -- not Unity's process exit code -- as the source of @@ -738,6 +862,48 @@ public static class UhCiTestConfigurator File.WriteAllText(markerPath, "UhCiTestConfigurator.Apply completed"); } } + + // Union the requested global scripting defines (UH_ADDITIONAL_SCRIPTING_DEFINES, + // semicolon-delimited) with whatever is already set for the Standalone group and + // write them back via the non-deprecated SetScriptingDefineSymbols(NamedBuildTarget, + // string[]) API (the BuildTargetGroup overload is obsolete in Unity 6). Order is + // preserved and duplicates are dropped. A null/empty env var leaves the existing + // defines untouched (no-op), so a normal leg's compilation is byte-for-byte + // unchanged. NamedBuildTarget.Standalone exists in 2021.2+, so this compiles on + // every CI Unity version even though only the Unity-6 leg passes extra defines. + private static void ApplyAdditionalScriptingDefines() + { + string raw = Environment.GetEnvironmentVariable("UH_ADDITIONAL_SCRIPTING_DEFINES"); + if (string.IsNullOrWhiteSpace(raw)) + { + return; + } + + string[] requested = raw + .Split(new[] { ';' }, StringSplitOptions.RemoveEmptyEntries) + .Select(d => d.Trim()) + .Where(d => d.Length > 0) + .ToArray(); + if (requested.Length == 0) + { + return; + } + + NamedBuildTarget target = NamedBuildTarget.Standalone; + PlayerSettings.GetScriptingDefineSymbols(target, out string[] existing); + + List merged = new List(existing ?? new string[0]); + foreach (string define in requested) + { + if (!merged.Contains(define)) + { + merged.Add(define); + } + } + + PlayerSettings.SetScriptingDefineSymbols(target, merged.ToArray()); + Debug.Log(`$"UH additional scripting defines applied to {target.TargetName}: requested=[{string.Join(`";`", requested)}] effective=[{string.Join(`";`", merged)}]"); + } } "@ } @@ -1052,14 +1218,15 @@ function Initialize-EphemeralProject { [Parameter(Mandatory = $true)][string]$Mode, [string]$Path, [switch]$IncludeComparisons, + [switch]$IncludeIntegrations, [string]$Backend = 'IL2CPP', [bool]$DevelopmentBuild = $false, [string]$RepoRoot ) - # The comparison-packages single source lives at the repo root. Default to - # -Root when no explicit -RepoRoot is threaded (the package source root is the - # repo root in this harness), so New-ManifestJson -IncludeComparisons can read it. + # The comparison/integration package single sources live at the repo root. + # Default to -Root when no explicit -RepoRoot is threaded (the package source + # root is the repo root in this harness), so New-ManifestJson can read them. if ([string]::IsNullOrWhiteSpace($RepoRoot)) { $RepoRoot = $Root } @@ -1074,7 +1241,7 @@ function Initialize-EphemeralProject { New-Item -ItemType Directory -Force -Path (Join-Path $project 'ProjectSettings') | Out-Null New-Item -ItemType Directory -Force -Path (Join-Path $project 'Assets\Editor') | Out-Null - New-ManifestJson -Root $Root -IncludeComparisons:$IncludeComparisons -RepoRoot $RepoRoot | + New-ManifestJson -Root $Root -IncludeComparisons:$IncludeComparisons -IncludeIntegrations:$IncludeIntegrations -RepoRoot $RepoRoot | Set-Content -LiteralPath (Join-Path $project 'Packages\manifest.json') -Encoding UTF8 "m_EditorVersion: $Version`n" | Set-Content -LiteralPath (Join-Path $project 'ProjectSettings\ProjectVersion.txt') -Encoding UTF8 @@ -2376,6 +2543,64 @@ function Test-NUnitResults { Write-CiNotice "${Label}: total=$total passed=$passed failed=$failed skipped=$skipped" } +# Run the UhCiTestConfigurator.Apply configure pass (a SEPARATE -executeMethod +# editor invocation) and validate the success marker it writes as its final action. +# The CONFIGURED PROJECT -- proven by a FRESH marker -- is the source of truth, NOT +# Unity's process exit code: Unity can crash in a background thread during shutdown +# AFTER Apply() fully completes (e.g. the DirectoryMonitor file-watcher faulting, +# returning 0xC0000005) for a configure that actually succeeded; a MISSING marker is +# a real failure that throws with the usual diagnostics. Apply() also persists any +# UH_ADDITIONAL_SCRIPTING_DEFINES onto the Standalone group's scripting defines and +# saves the project, so a SUBSEQUENT -runTests invocation loads them from startup +# (Unity does not recompile mid-run in -batchmode). Shared by the standalone path +# (always) and the editmode/playmode path (only when extra defines are requested). +function Invoke-UnityConfigurePass { + param( + [Parameter(Mandatory = $true)][string]$EditorPath, + [Parameter(Mandatory = $true)][string]$ProjectPath, + [Parameter(Mandatory = $true)][string]$MarkerPath, + [Parameter(Mandatory = $true)][string]$LogPath, + [Parameter(Mandatory = $true)][string]$Label, + [string[]]$ExtraArguments = @() + ) + + if (Test-Path -LiteralPath $MarkerPath -PathType Leaf) { + Remove-Item -LiteralPath $MarkerPath -Force + } + $env:UH_CONFIGURE_MARKER_PATH = $MarkerPath + $configureStartedUtc = [DateTime]::UtcNow + $configureArgs = @( + '-quit', + '-batchmode', + '-nographics', + '-projectPath', $ProjectPath, + '-buildTarget', 'StandaloneWindows64', + '-executeMethod', 'UhCiTestConfigurator.Apply', + '-logFile', '-' + ) + @($ExtraArguments) + $configureExit = Invoke-UnityEditor ` + -EditorPath $EditorPath ` + -Arguments $configureArgs ` + -Label $Label ` + -LogPath $LogPath + # The configurator has run; drop the marker-path env var so it cannot be + # inherited by later child processes (only Apply reads it). + Remove-Item -LiteralPath Env:\UH_CONFIGURE_MARKER_PATH -ErrorAction SilentlyContinue + $configureProblem = Test-UnityConfigureMarker -MarkerPath $MarkerPath -StartedUtc $configureStartedUtc + if (-not [string]::IsNullOrWhiteSpace($configureProblem)) { + Write-UnityRunFailureDiagnostics ` + -Project $ProjectPath ` + -LogPath $LogPath ` + -CscLabel $Label ` + -DiagnosticsLabel $Label + throw "$Label failed ($configureProblem; Unity exit code $configureExit / $(Get-NativeExitCodeDescription -ExitCode $configureExit)). See the streamed Unity log above (also saved to $LogPath)." + } + if ($configureExit -ne 0) { + Write-UnityBenignExitWarning -Label $Label -ExitCode $configureExit -LogPath $LogPath + } + Write-AnalyzerSetupDiagnostics -Project $ProjectPath -LogPath $LogPath -Label $Label +} + $RepoRoot = Resolve-FullPath -Path $RepoRoot Assert-RepoRoot -Path $RepoRoot $ArtifactsPath = Resolve-FullPath -Path $ArtifactsPath @@ -2390,7 +2615,19 @@ Initialize-UnityCacheEnvironment -Root $RepoRoot -Version $UnityVersion $UseReleaseCodeOptimization = $true $UseReleasePlayerBuild = $true -$ProjectPath = Initialize-EphemeralProject -Root $RepoRoot -Version $UnityVersion -Mode $TestMode -Path $ProjectPath -IncludeComparisons:$IncludeComparisons -Backend $StandaloneScriptingBackend -DevelopmentBuild:(-not $UseReleasePlayerBuild) -RepoRoot $RepoRoot +# Normalize the requested extra scripting defines to a clean, de-duplicated, +# semicolon-joined string ONCE here. The configurator C# reads this exact value +# from UH_ADDITIONAL_SCRIPTING_DEFINES; computing it up front keeps the env var, +# the diagnostic logging, and the configure-pass dispatch decision all consistent. +$AdditionalScriptingDefinesList = @( + @($AdditionalScriptingDefines) | + ForEach-Object { if ($null -ne $_) { ([string]$_).Trim() } } | + Where-Object { $_ -and $_.Length -gt 0 } | + Select-Object -Unique +) +$AdditionalScriptingDefinesJoined = ($AdditionalScriptingDefinesList -join ';') + +$ProjectPath = Initialize-EphemeralProject -Root $RepoRoot -Version $UnityVersion -Mode $TestMode -Path $ProjectPath -IncludeComparisons:$IncludeComparisons -IncludeIntegrations:$IncludeIntegrations -Backend $StandaloneScriptingBackend -DevelopmentBuild:(-not $UseReleasePlayerBuild) -RepoRoot $RepoRoot $LibraryPath = Join-Path $ProjectPath 'Library' New-Item -ItemType Directory -Force -Path $LibraryPath | Out-Null @@ -2400,6 +2637,8 @@ Write-Host "ProjectPath: $ProjectPath" Write-Host "LibraryPath: $LibraryPath" Write-Host "ArtifactsPath: $ArtifactsPath" Write-Host "IncludeComparisons: $IncludeComparisons" +Write-Host "IncludeIntegrations: $IncludeIntegrations" +Write-Host "AdditionalScriptingDefines: $AdditionalScriptingDefinesJoined" Write-Host "StandaloneScriptingBackend: $StandaloneScriptingBackend" Write-Host "ReleasePlayerBuild: $UseReleasePlayerBuild" Write-Host "ReleaseCodeOptimization: $UseReleaseCodeOptimization" @@ -2517,6 +2756,18 @@ $configureMarkerPath = Join-Path $ArtifactsPath 'configure-complete.marker' $standaloneExe = Join-Path $ProjectPath 'Build\UhTestPlayer\UhTestPlayer.exe' $playerLogPath = Join-Path $ArtifactsPath 'player.log' +# Hand the requested global scripting defines to the configurator C# +# (UhCiTestConfigurator.ApplyAdditionalScriptingDefines reads this env var). Set it +# unconditionally -- empty when none requested, in which case the configurator's +# define injection is a no-op -- so the SAME env var feeds both the standalone +# configure pass (already run for every standalone leg) and the editmode/playmode +# configure pass dispatched below. The configurator unions these onto the Standalone +# group's defines, which apply to ALL assemblies (asmdef assemblies included). +$env:UH_ADDITIONAL_SCRIPTING_DEFINES = $AdditionalScriptingDefinesJoined +if ($AdditionalScriptingDefinesList.Count -gt 0) { + Write-CiNotice "Global scripting defines requested for asmdef compilation: $AdditionalScriptingDefinesJoined" +} + # Activation/return carry the serial/email/password in their argument arrays and # Unity may echo account/serial fragments into the activation log, so these logs # MUST NOT live under $ArtifactsPath (the workflow uploads that as an artifact and @@ -2546,51 +2797,32 @@ try { } if ($TestMode -eq 'standalone') { - # CONFIGURE the standalone IL2CPP project. The CONFIGURED PROJECT (proven by - # the success marker UhCiTestConfigurator.Apply writes as its final action) - # is the source of truth -- NOT Unity's process exit code. Delete any stale - # marker, hand the path in via UH_CONFIGURE_MARKER_PATH, and validate a - # FRESH marker after the run. A non-zero exit with a fresh marker is a benign - # post-work shutdown crash (for example the DirectoryMonitor file-watcher - # thread faulting during teardown, which returns 0xC0000005 even though the - # configuration fully succeeded); a MISSING marker is a real configure - # failure that fails loudly with the usual diagnostics. - if (Test-Path -LiteralPath $configureMarkerPath -PathType Leaf) { - Remove-Item -LiteralPath $configureMarkerPath -Force - } - $env:UH_CONFIGURE_MARKER_PATH = $configureMarkerPath - $configureStartedUtc = [DateTime]::UtcNow - $configureArgs = @( - '-quit', - '-batchmode', - '-nographics', - '-projectPath', $ProjectPath, - '-buildTarget', 'StandaloneWindows64', - '-executeMethod', 'UhCiTestConfigurator.Apply', - '-logFile', '-' - ) + $acceleratorArgs - $configureExit = Invoke-UnityEditor ` + # CONFIGURE the standalone IL2CPP project (scripting backend/api/stripping, + # Release code optimization, and any injected scripting defines). Marker-gated + # via the shared Invoke-UnityConfigurePass. + Invoke-UnityConfigurePass ` -EditorPath $UnityEditorPath ` - -Arguments $configureArgs ` + -ProjectPath $ProjectPath ` + -MarkerPath $configureMarkerPath ` + -LogPath $configureLogPath ` -Label 'Configure standalone IL2CPP project' ` - -LogPath $configureLogPath - # The configurator has run; drop the marker-path env var so it cannot be - # inherited by the later build/player child processes (only Apply reads it, - # so this is hygiene against a future invocation accidentally writing it). - Remove-Item -LiteralPath Env:\UH_CONFIGURE_MARKER_PATH -ErrorAction SilentlyContinue - $configureProblem = Test-UnityConfigureMarker -MarkerPath $configureMarkerPath -StartedUtc $configureStartedUtc - if (-not [string]::IsNullOrWhiteSpace($configureProblem)) { - Write-UnityRunFailureDiagnostics ` - -Project $ProjectPath ` - -LogPath $configureLogPath ` - -CscLabel 'standalone configure' ` - -DiagnosticsLabel 'Unity standalone configure' - throw "Configure standalone IL2CPP project failed ($configureProblem; Unity exit code $configureExit / $(Get-NativeExitCodeDescription -ExitCode $configureExit)). See the streamed Unity log above (also saved to $configureLogPath)." - } - if ($configureExit -ne 0) { - Write-UnityBenignExitWarning -Label 'Configure standalone IL2CPP project' -ExitCode $configureExit -LogPath $configureLogPath - } - Write-AnalyzerSetupDiagnostics -Project $ProjectPath -LogPath $configureLogPath -Label 'standalone configure' + -ExtraArguments $acceleratorArgs + } elseif ($AdditionalScriptingDefinesList.Count -gt 0) { + # EDITMODE/PLAYMODE with extra global scripting defines (e.g. SINGLE_THREADED): + # run the SAME configure pass FIRST so UhCiTestConfigurator.Apply persists the + # defines onto the Standalone group and saves the project. The -runTests + # invocation below then loads the project with the defines already in place, + # so its first compile of the asmdef assemblies sees them (Unity does not + # recompile mid-run in -batchmode). Without requested defines this pass is + # skipped entirely, so the default editmode/playmode flow is unchanged (no + # extra editor launch, byte-for-byte identical behavior). + Invoke-UnityConfigurePass ` + -EditorPath $UnityEditorPath ` + -ProjectPath $ProjectPath ` + -MarkerPath $configureMarkerPath ` + -LogPath $configureLogPath ` + -Label "Configure $UnityVersion $TestMode scripting defines" ` + -ExtraArguments $acceleratorArgs } if ($TestMode -eq 'standalone') { From 3704120c9dab1a342d598c8bc8d9bec8b23c50a0 Mon Sep 17 00:00:00 2001 From: wallstop Date: Sun, 14 Jun 2026 02:36:32 +0000 Subject: [PATCH 08/31] ci(perf): perf-delta reporting in the Unity benchmark job - extract-perf-metrics.js parses the benchmark NUnit results (unity-helpers perf tests emit Stopwatch timings as GFM markdown tables in each test's CDATA) into normalized metrics; render-perf-deltas.js diffs vs a committed rolling baseline and renders Markdown + JSON with regression classification (report-only). - Seed perf-results/baseline.json; the benchmark commit job now extracts, diffs, updates the baseline, writes perf-deltas.md, and commits perf-results/**. - Ignore perf-results/** in prettier + cspell so generated reports never trip CI. - Both JS pass --self-test. Unverified without a real results.xml. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/unity-benchmarks.yml | 58 +- .prettierignore | 8 + perf-results/baseline.json | 4 + scripts/unity/lib/extract-perf-metrics.js | 704 ++++++++++++++ .../unity/lib/extract-perf-metrics.js.meta | 7 + scripts/unity/lib/render-perf-deltas.js | 872 ++++++++++++++++++ scripts/unity/lib/render-perf-deltas.js.meta | 7 + 7 files changed, 1659 insertions(+), 1 deletion(-) create mode 100644 perf-results/baseline.json create mode 100644 scripts/unity/lib/extract-perf-metrics.js create mode 100644 scripts/unity/lib/extract-perf-metrics.js.meta create mode 100644 scripts/unity/lib/render-perf-deltas.js create mode 100644 scripts/unity/lib/render-perf-deltas.js.meta diff --git a/.github/workflows/unity-benchmarks.yml b/.github/workflows/unity-benchmarks.yml index 93e4328c8..a7ca1534d 100644 --- a/.github/workflows/unity-benchmarks.yml +++ b/.github/workflows/unity-benchmarks.yml @@ -458,6 +458,57 @@ jobs: echo "::notice::No staged perf results were downloaded; nothing to commit." fi + # Node 22 (matches the benchmarks job's setup-node) to run the pure-Node, + # zero-dependency perf-delta tooling under scripts/unity/lib/. + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: "22.18.0" + + # Extract normalized perf metrics from the assembled NUnit results, diff + # them against the committed rolling baseline (perf-results/baseline.json), + # write the human-readable report (perf-results/perf-deltas.md) plus a + # machine-readable payload (perf-results/perf-deltas.json), and roll the + # baseline forward to the latest numbers. These are committed alongside the + # raw XML. REPORT-ONLY by default: the weekly scheduled run must never fail + # just because perf moved, so we do NOT pass --fail-on-regression here; the + # `regressed=` signal is surfaced as a step output + workflow annotation for + # visibility. To make a regression hard-fail later, set + # PERF_FAIL_ON_REGRESSION=1 on the render step (and gate on its outcome). + - name: Compute perf deltas vs baseline + id: perf_deltas + shell: bash + run: | + set -euo pipefail + shopt -s nullglob + results=(perf-results/results-*.xml) + if [ ${#results[@]} -eq 0 ]; then + echo "::notice::No perf results XML to analyze; leaving baseline untouched." + echo "regressed=false" >> "${GITHUB_OUTPUT}" + exit 0 + fi + node scripts/unity/lib/extract-perf-metrics.js "${results[@]}" \ + --output perf-results/perf-current.json + metric_count="$(node -e 'const m=require("./perf-results/perf-current.json");process.stdout.write(String(m.length))')" + echo "Extracted ${metric_count} perf metric(s) from ${#results[@]} result file(s)." + # render-perf-deltas.js always exits 0 in report-only mode and prints + # changed=/regressed= on stdout; capture regressed for the annotation. + render_output="$(node scripts/unity/lib/render-perf-deltas.js \ + --current perf-results/perf-current.json \ + --baseline perf-results/baseline.json \ + --out-md perf-results/perf-deltas.md \ + --out-json perf-results/perf-deltas.json \ + --update-baseline perf-results/baseline.json)" + echo "${render_output}" + regressed="$(printf '%s\n' "${render_output}" | sed -n 's/^regressed=//p' | tail -n1)" + regressed="${regressed:-false}" + echo "regressed=${regressed}" >> "${GITHUB_OUTPUT}" + if [ "${regressed}" = "true" ]; then + echo "::warning::Perf delta computed a significant regression; see perf-results/perf-deltas.md." + fi + # perf-current.json is a transient intermediate; do not commit it. + rm -f perf-results/perf-current.json + # git-auto-commit-action is a no-op when there is no diff, so a run that # produced no new results does not create an empty commit. # @@ -473,7 +524,12 @@ jobs: uses: stefanzweifel/git-auto-commit-action@v7.1.0 with: commit_message: "chore(perf): refresh CI perf results [skip ci]" - file_pattern: perf-results/*.xml + # Broadened from perf-results/*.xml to the whole tree so the rolling + # baseline (baseline.json) and the generated delta report + # (perf-deltas.md / perf-deltas.json) are committed next to the raw XML. + # perf-results/** is in .prettierignore and cspell's ignorePaths, so + # these generated files never trip the prettier/cspell CI checks. + file_pattern: perf-results/** commit_user_name: "github-actions[bot]" commit_user_email: "41898282+github-actions[bot]@users.noreply.github.com" commit_author: "github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>" diff --git a/.prettierignore b/.prettierignore index 3046e1b6c..9291437c0 100644 --- a/.prettierignore +++ b/.prettierignore @@ -13,6 +13,14 @@ Logs/ # Samples and external content Samples~/ +# Generated perf artifacts: the raw NUnit XML, the auto-generated rolling +# baseline (perf-results/baseline.json), and the delta report +# (perf-results/perf-deltas.md / .json) are machine-written by the Unity +# Benchmarks workflow (scripts/unity/lib/render-perf-deltas.js). They are +# emitted prettier-shaped already, but keep the whole tree out of prettier's +# scope so a generated file can never trip `run-prettier.js --check` in CI. +perf-results/** + # Only format supported text/assets; Prettier globbing will still be constrained by scripts **/*.meta **/*.unity diff --git a/perf-results/baseline.json b/perf-results/baseline.json new file mode 100644 index 000000000..e2dbfc321 --- /dev/null +++ b/perf-results/baseline.json @@ -0,0 +1,4 @@ +{ + "_comment": "Rolling perf baseline for unity-helpers benchmarks. Seeded empty; the first Unity Benchmarks run (.github/workflows/unity-benchmarks.yml) extracts metrics from the NUnit results and rewrites this file via scripts/unity/lib/render-perf-deltas.js. Subsequent runs diff current metrics against this file and write perf-results/perf-deltas.md. Do not hand-edit.", + "metrics": [] +} diff --git a/scripts/unity/lib/extract-perf-metrics.js b/scripts/unity/lib/extract-perf-metrics.js new file mode 100644 index 000000000..446d78160 --- /dev/null +++ b/scripts/unity/lib/extract-perf-metrics.js @@ -0,0 +1,704 @@ +"use strict"; + +// cspell:ignore apos quot CDATA + +/* + * extract-perf-metrics.js + * ----------------------- + * Pure-Node (Node 22, zero deps) extractor that reads one or more NUnit3 + * results.xml files emitted by the Unity Benchmarks CI job and normalizes the + * perf numbers into a flat JSON array: + * + * [{ test, sampleGroup, unit, median, min, max, stddev, sampleCount, + * unityVersion, testMode }] + * + * --------------------------------------------------------------------------- + * WHAT unity-helpers ACTUALLY EMITS (verified against the source, NOT a guess) + * --------------------------------------------------------------------------- + * unity-helpers' perf tests under Tests/Runtime/Performance/ do NOT use Unity's + * com.unity.test-framework.performance package. There is no `Measure.Method`, + * no `SampleGroup`, and the perf asmdef + * (WallstopStudios.UnityHelpers.Tests.Runtime.Performance.asmdef) does not + * reference Unity.PerformanceTesting. Instead every perf test uses a raw + * `System.Diagnostics.Stopwatch` and prints results with + * `UnityEngine.Debug.Log(...)`, almost always as a GitHub-flavored Markdown + * TABLE, emitted one row per Debug.Log call. Concrete examples from the repo: + * + * ProtoEqualsPerformanceTests.CompareProtoEqualsSmallMediumLarge: + * | Payload | Optimized ProtoEquals (ms) | Classic ProtoEquals (ms) | Speedup | + * | ------- | -------------------------:| ------------------------:| -------:| + * | Small | 12 | 45 | 3.75x | + * + * ProtoSerializationPerformanceTests.CompareSerializeSmallMediumLarge: + * | Payload | Pooled Serialize (ms) | Classic Serialize (ms) | Speedup | Size (bytes) | + * | ... + * + * SpatialTree2DPerformanceTests.Benchmark (per-dataset tables): + * | Construction | RTree | Quadtree | KDTree | ... | + * | --- | --- | --- | --- | ... | + * | Build | 12,345 (0.003s) | ... | + * + * The Unity test runner captures per-test `Debug.Log` output, and the + * standalone player writer in scripts/unity/run-ci-tests.ps1 serializes results + * with NUnit3's `TestResult.ToXml(recursive: true)`. NUnit3 puts captured + * console output in an `` CDATA child of each ``. So the + * perf numbers live in ``. + * + * Because the column schema differs per test family (Optimized/Classic/Speedup + * vs Pooled/Classic/Size vs per-tree columns), we do NOT hardcode any family. + * The extractor is FORMAT-AGNOSTIC over Markdown tables: for every detected + * table it emits one metric per numeric data cell, keyed as + * + * sampleGroup = " / " + * unit = parsed from the column header, e.g. "(ms)" -> "ms" + * median = the numeric value of the cell (min/max/stddev = null, + * sampleCount = null, because Stopwatch tables report a single + * aggregate number, not a Unity SampleGroup distribution) + * + * This is the documented, intentional shape: these are single-sample aggregate + * timings, so only `median` is populated and the delta tool compares medians. + * + * --------------------------------------------------------------------------- + * FALLBACKS (also documented + tested) + * --------------------------------------------------------------------------- + * 1. Unity SampleGroup JSON: IF a future test ever does adopt + * com.unity.test-framework.performance, that framework writes a + * `` (older builds + * used name "performanceData"/"PerformanceTestResults"). We parse that JSON + * shape too (SampleGroups with Median/Min/Max/StandardDeviation/Unit/ + * SampleCount). This path is dormant today but future-proofs the extractor. + * + * 2. Plain "Stopwatch / WriteLine" timing lines NOT inside a Markdown table. + * Some logs print free-form lines such as: + * "Foo took 12.34 ms" + * "Bar: 1234 ns" + * "Baz elapsed 0.50s" + * We key on the documented regex TIMING_LINE_RE below: a label, then a + * number, then a recognized time unit (ns|us|µs|ms|s) at a word boundary. + * These become sampleGroup = "", openRe.lastIndex); + const body = closeIndex === -1 ? "" : xml.slice(openRe.lastIndex, closeIndex); + callback(attrText, body); + if (closeIndex !== -1) { + openRe.lastIndex = closeIndex + "".length; + } + } +} + +// Pull every ... text from a test-case body, preferring CDATA +// but also handling entity-encoded output. +function readOutputs(body) { + const outputs = []; + const re = /]*>([\s\S]*?)<\/output>/g; + let match; + while ((match = re.exec(body)) !== null) { + outputs.push(extractCdataOrText(match[1])); + } + return outputs; +} + +function extractCdataOrText(inner) { + const cdata = inner.match(//); + if (cdata) { + return cdata[1]; + } + return decodeXmlEntities(inner); +} + +// --- Markdown table parsing ------------------------------------------------ + +// Recognize a GitHub-flavored markdown divider row: every cell is dashes with +// optional leading/trailing colon for alignment, e.g. "---", ":--", "--:". +function isDividerRow(cells) { + return cells.length > 0 && cells.every((cell) => /^:?-{1,}:?$/.test(cell.trim())); +} + +// Split a pipe-delimited markdown row into trimmed cells, dropping the leading +// and trailing empties produced by the bounding pipes. +function splitRow(line) { + const trimmed = line.trim(); + if (!trimmed.startsWith("|")) { + return null; + } + const parts = trimmed.split("|"); + // Drop first/last (empty from bounding pipes). + parts.shift(); + parts.pop(); + return parts.map((cell) => cell.trim()); +} + +// Parse a unit out of a column header. "Optimized ProtoEquals (ms)" -> "ms"; +// "Size (bytes)" -> "bytes"; "Speedup" -> null. +function unitFromHeader(header) { + const match = header.match(/\(([^)]+)\)\s*$/); + return match ? match[1].trim() : null; +} + +// Strip the trailing "(...)" unit annotation from a header to get a clean label. +function headerLabel(header) { + return header.replace(/\s*\([^)]*\)\s*$/, "").trim(); +} + +// Parse the leading numeric value out of a markdown cell. Handles thousands +// separators, decimals, sign, and trailing annotations like "x" (speedup), +// "(0.003s)" (SpatialTree construction) or "B"/"ms" suffixes. +// Returns { value, suffixUnit } or null when there is no number. +function parseCell(cell) { + const text = cell.trim(); + if (text === "" || text === "-" || /^n\/?a$/i.test(text)) { + return null; + } + const numMatch = text.match(/^([-+]?\d[\d,]*(?:\.\d+)?)(.*)$/); + if (!numMatch) { + return null; + } + const value = Number.parseFloat(numMatch[1].replace(/,/g, "")); + if (!Number.isFinite(value)) { + return null; + } + // A trailing inline unit on the value itself, e.g. "12.3ms" or "1,024 B". + const rest = numMatch[2].trim(); + let suffixUnit = null; + const suffixMatch = rest.match(/^([a-zµ%]+)\b/i); + if (suffixMatch && !/^x$/i.test(suffixMatch[1])) { + // "x" denotes a speedup ratio (e.g. "3.75x"), which is dimensionless. + suffixUnit = suffixMatch[1]; + } + return { value, suffixUnit }; +} + +// Given the lines of one test's output, find markdown tables and emit metrics. +function metricsFromMarkdownTables(lines, context, consumedLineSet) { + const metrics = []; + for (let i = 0; i + 1 < lines.length; i++) { + const headerCells = splitRow(lines[i]); + if (!headerCells) { + continue; + } + const dividerCells = splitRow(lines[i + 1]); + if (!dividerCells || !isDividerRow(dividerCells)) { + continue; + } + if (headerCells.length < 2) { + continue; + } + consumedLineSet.add(i); + consumedLineSet.add(i + 1); + + // First column is the row label; the rest are metric columns. + for (let r = i + 2; r < lines.length; r++) { + const rowCells = splitRow(lines[r]); + if (!rowCells || isDividerRow(rowCells)) { + break; // table ended + } + consumedLineSet.add(r); + const rowLabel = rowCells[0] || `row${r}`; + for (let c = 1; c < headerCells.length && c < rowCells.length; c++) { + const parsed = parseCell(rowCells[c]); + if (!parsed) { + continue; + } + const header = headerCells[c]; + const unit = unitFromHeader(header) || parsed.suffixUnit; + const columnLabel = headerLabel(header); + metrics.push(makeMetric(context, `${rowLabel} / ${columnLabel}`, unit, parsed.value)); + } + } + // Continue scanning AFTER this table for additional tables in the same log. + i = i; // (loop increments i; tables can be adjacent) + } + return metrics; +} + +// Free-form "label ... " lines not already consumed by a table. +function metricsFromTimingLines(lines, context, consumedLineSet) { + const metrics = []; + for (let i = 0; i < lines.length; i++) { + if (consumedLineSet.has(i)) { + continue; + } + const line = lines[i]; + // Skip lines that are clearly markdown table rows (defensive; tables are + // handled above) so we never double-count. + if (line.trim().startsWith("|")) { + continue; + } + const match = line.match(TIMING_LINE_RE); + if (!match || !match.groups) { + continue; + } + const label = match.groups.label.trim(); + const value = Number.parseFloat(match.groups.value.replace(/,/g, "")); + if (!Number.isFinite(value) || label === "") { + continue; + } + metrics.push(makeMetric(context, label, normalizeUnit(match.groups.unit), value)); + } + return metrics; +} + +function normalizeUnit(unit) { + if (!unit) { + return null; + } + const lower = unit.toLowerCase(); + return lower === "µs" ? "us" : lower; +} + +function makeMetric(context, sampleGroup, unit, median, extras = {}) { + return { + test: context.test, + sampleGroup, + unit: unit || null, + median, + min: extras.min ?? null, + max: extras.max ?? null, + stddev: extras.stddev ?? null, + sampleCount: extras.sampleCount ?? null, + unityVersion: context.unityVersion ?? null, + testMode: context.testMode ?? null + }; +} + +// --- Unity SampleGroup property fallback (dormant today, future-proof) ------- +// If com.unity.test-framework.performance is ever adopted, parse its JSON blob. +function metricsFromPerfProperties(body, context) { + const metrics = []; + const propRe = /]*)\/?>/g; + let match; + while ((match = propRe.exec(body)) !== null) { + const attrText = match[1]; + const name = readAttr(attrText, "name"); + if ( + name !== "performanceTestResults" && + name !== "performanceData" && + name !== "PerformanceTestResults" + ) { + continue; + } + const value = readAttr(attrText, "value"); + if (!value) { + continue; + } + let parsed; + try { + parsed = JSON.parse(value); + } catch { + continue; + } + const groups = collectSampleGroups(parsed); + for (const group of groups) { + metrics.push( + makeMetric(context, group.name, group.unit, group.median, { + min: group.min, + max: group.max, + stddev: group.stddev, + sampleCount: group.sampleCount + }) + ); + } + } + return metrics; +} + +// Normalize Unity's SampleGroup JSON (property casing varies across versions). +function collectSampleGroups(parsed) { + const out = []; + const sampleGroups = parsed && (parsed.SampleGroups || parsed.sampleGroups); + if (!Array.isArray(sampleGroups)) { + return out; + } + for (const group of sampleGroups) { + const definition = group.Definition || group.definition || {}; + const name = definition.Name || definition.name || group.Name || group.name || "Unknown"; + const unit = definition.SampleUnit || definition.sampleUnit || group.Unit || group.unit || null; + out.push({ + name, + unit: typeof unit === "string" ? unit : unitEnumToString(unit), + median: numOrNull(group.Median ?? group.median), + min: numOrNull(group.Min ?? group.min), + max: numOrNull(group.Max ?? group.max), + stddev: numOrNull(group.StandardDeviation ?? group.standardDeviation), + sampleCount: numOrNull( + group.SampleCount ?? group.sampleCount ?? (group.Samples ? group.Samples.length : null) + ) + }); + } + return out; +} + +function unitEnumToString(value) { + // Unity's SampleUnit enum: 0=Nanosecond,1=Microsecond,2=Millisecond,3=Second, + // 4=Byte,5=Kilobyte,6=Megabyte,7=Gigabyte,8=Undefined. + const map = ["ns", "us", "ms", "s", "b", "kb", "mb", "gb", null]; + return typeof value === "number" ? (map[value] ?? null) : null; +} + +function numOrNull(value) { + const num = Number(value); + return Number.isFinite(num) ? num : null; +} + +// --- Top-level extraction -------------------------------------------------- + +function inferFromFileName(filePath) { + const base = path.basename(filePath); + const match = base.match(RESULT_FILE_RE); + if (!match || !match.groups) { + return { unityVersion: null, testMode: null }; + } + return { + unityVersion: match.groups.version, + testMode: match.groups.mode.toLowerCase() + }; +} + +function extractFromXml(xml, defaults) { + const metrics = []; + forEachTestCase(xml, (attrText, body) => { + const test = readAttr(attrText, "fullname") || readAttr(attrText, "name") || "Unknown"; + const context = { + test, + unityVersion: defaults.unityVersion, + testMode: defaults.testMode + }; + + // 1) Unity SampleGroup properties (future-proof; usually empty today). + const propMetrics = metricsFromPerfProperties(body, context); + metrics.push(...propMetrics); + + // 2) Captured Debug.Log output -> markdown tables + timing lines. + const outputs = readOutputs(body); + for (const output of outputs) { + const lines = output.split(/\r?\n/); + const consumed = new Set(); + metrics.push(...metricsFromMarkdownTables(lines, context, consumed)); + metrics.push(...metricsFromTimingLines(lines, context, consumed)); + } + }); + return metrics; +} + +function extractFromFiles(inputs, flagDefaults) { + const all = []; + for (const input of inputs) { + if (!fs.existsSync(input)) { + throw new Error(`Input file not found: ${input}`); + } + const inferred = inferFromFileName(input); + const defaults = { + unityVersion: flagDefaults.unityVersion ?? inferred.unityVersion, + testMode: flagDefaults.testMode ?? inferred.testMode + }; + const xml = fs.readFileSync(input, "utf8"); + all.push(...extractFromXml(xml, defaults)); + } + return all; +} + +function main(argv = process.argv) { + const options = parseArgs(argv); + if (options.help) { + process.stdout.write(`${usage()}\n`); + return 0; + } + if (options.selfTest) { + return runSelfTest(); + } + if (options.inputs.length === 0) { + process.stderr.write(`No input files provided.\n\n${usage()}\n`); + return 2; + } + const metrics = extractFromFiles(options.inputs, { + unityVersion: options.unityVersion, + testMode: options.testMode + }); + const json = JSON.stringify(metrics, null, 2); + if (options.output) { + fs.writeFileSync(options.output, `${json}\n`, "utf8"); + } else { + process.stdout.write(`${json}\n`); + } + return 0; +} + +// --- Self-test ------------------------------------------------------------- + +function assert(condition, message) { + if (!condition) { + throw new Error(`Self-test failed: ${message}`); + } +} + +function findMetric(metrics, sampleGroup) { + return metrics.find((m) => m.sampleGroup === sampleGroup); +} + +function runSelfTest() { + // Tiny inline fixture modeled on the REAL ProtoEquals/ProtoSerialization + // tables plus a SpatialTree-style "value (0.003s)" cell and a free-form + // timing line, all inside CDATA exactly as Unity/NUnit3 emit them. + const fixture = [ + '', + '', + ' ', + ' ', + " ", + " ", + ' ', + " ", + " ", + " ", + "" + ].join("\n"); + + const metrics = extractFromXml(fixture, { unityVersion: "6000.3.16f1", testMode: "playmode" }); + + // ProtoEquals table: 2 rows x 3 numeric columns = 6 metrics + // (Speedup "3.75x" is numeric too -> 6, not 4). + const optSmall = findMetric(metrics, "Small / Optimized ProtoEquals"); + assert(optSmall, "expected 'Small / Optimized ProtoEquals' metric"); + assert(optSmall.median === 12, `Small optimized median should be 12, got ${optSmall.median}`); + assert(optSmall.unit === "ms", `Small optimized unit should be ms, got ${optSmall.unit}`); + assert( + optSmall.unityVersion === "6000.3.16f1" && optSmall.testMode === "playmode", + "context (unityVersion/testMode) should propagate onto metrics" + ); + assert(optSmall.test === "Perf.ProtoEquals.Compare", "test fullname should be captured"); + + const classicLarge = findMetric(metrics, "Large / Classic ProtoEquals"); + assert(classicLarge && classicLarge.median === 4500, "thousands separators must be stripped"); + + const speedup = findMetric(metrics, "Small / Speedup"); + assert(speedup && speedup.median === 3.75, "speedup 3.75x should parse to 3.75"); + assert(speedup.unit === null, "speedup ratio is dimensionless (unit null)"); + + // SpatialTree cell "12,345 (0.003s)" -> value 12345, no unit from a bare + // header "RTree" (the inline (0.003s) is an annotation, not the cell number). + const build = findMetric(metrics, "Build / RTree"); + assert( + build && build.median === 12345, + `Build/RTree should be 12345, got ${build && build.median}` + ); + + // Free-form timing line fallback. + const sort = findMetric(metrics, "Sort took"); + assert( + sort && sort.median === 12.34 && sort.unit === "ms", + "free-form 'Sort took 12.34 ms' must parse" + ); + + // No metric should be double-counted from the table via the timing-line path. + const buildDupes = metrics.filter((m) => m.sampleGroup === "Build / RTree"); + assert(buildDupes.length === 1, "table cells must not be double-counted as timing lines"); + + // File-name inference. + const inferred = inferFromFileName("results-2022.3.45f1-editmode.xml"); + assert( + inferred.unityVersion === "2022.3.45f1" && inferred.testMode === "editmode", + "file-name inference should parse version + mode" + ); + + // Unity SampleGroup JSON fallback path. + const perfJson = JSON.stringify({ + SampleGroups: [ + { + Definition: { Name: "Time", SampleUnit: 2 }, + Median: 1.5, + Min: 1.0, + Max: 2.0, + StandardDeviation: 0.25, + SampleCount: 9 + } + ] + }).replace(/"/g, """); + const perfXml = [ + '', + ' ', + " ", + ` `, + " ", + " ", + "" + ].join("\n"); + const perfMetrics = extractFromXml(perfXml, { unityVersion: null, testMode: null }); + const measured = findMetric(perfMetrics, "Time"); + assert(measured, "Unity SampleGroup JSON should yield a 'Time' metric"); + assert(measured.median === 1.5 && measured.unit === "ms", "SampleGroup median/unit"); + assert( + measured.min === 1.0 && + measured.max === 2.0 && + measured.stddev === 0.25 && + measured.sampleCount === 9, + "SampleGroup min/max/stddev/sampleCount must populate" + ); + + process.stdout.write( + `extract-perf-metrics self-test passed (${metrics.length + perfMetrics.length} metrics across fixtures).\n` + ); + return 0; +} + +if (require.main === module) { + try { + process.exitCode = main(); + } catch (error) { + process.stderr.write(`${error.message}\n`); + process.exitCode = 1; + } +} + +module.exports = { + extractFromXml, + extractFromFiles, + inferFromFileName, + parseCell, + unitFromHeader, + headerLabel, + isDividerRow, + splitRow, + metricsFromMarkdownTables, + metricsFromTimingLines, + collectSampleGroups, + TIMING_LINE_RE +}; diff --git a/scripts/unity/lib/extract-perf-metrics.js.meta b/scripts/unity/lib/extract-perf-metrics.js.meta new file mode 100644 index 000000000..c9c4222dd --- /dev/null +++ b/scripts/unity/lib/extract-perf-metrics.js.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: c9188e16d2aaef137c86e08084ed279b +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/scripts/unity/lib/render-perf-deltas.js b/scripts/unity/lib/render-perf-deltas.js new file mode 100644 index 000000000..a29947eb5 --- /dev/null +++ b/scripts/unity/lib/render-perf-deltas.js @@ -0,0 +1,872 @@ +"use strict"; + +// cspell:ignore stddev + +/* + * render-perf-deltas.js + * --------------------- + * Pure-Node (Node 22, zero deps) delta renderer. Given the CURRENT normalized + * metrics JSON (produced by extract-perf-metrics.js) and a committed BASELINE + * JSON, it: + * 1. Matches metrics by composite key + * (test | sampleGroup | unityVersion | testMode). + * 2. Computes per-metric delta (absolute + percent vs baseline). + * 3. Classifies each metric as regression / improvement / stable using a + * tolerance, and flags a *significant* regression using a stricter + * threshold (the hard-gate signal). + * 4. Renders a GitHub-flavored Markdown report AND a machine-readable JSON + * payload (current metrics, regressions, improvements, summary). + * + * --------------------------------------------------------------------------- + * DIRECTION-OF-IMPROVEMENT + * --------------------------------------------------------------------------- + * unity-helpers' perf metrics are mostly raw Stopwatch timings (unit "ms"/"s"/ + * "ns"/"us") or sizes ("bytes"/"b"/"kb"/...). For those, LOWER is better, so a + * positive delta (current > baseline) is SLOWER/larger = a regression. + * + * Dimensionless ratio metrics (e.g. "Speedup" cells like "3.75x", which the + * extractor stores with unit === null) have NO well-defined regression + * direction from the number alone, so they are always classified "stable" for + * gating purposes and merely reported. This avoids falsely failing CI when a + * speedup ratio merely shifts. + * + * --------------------------------------------------------------------------- + * REGRESSION CLASSIFICATION + * --------------------------------------------------------------------------- + * tolerance (default 0.05 = 5%): |pct| <= tolerance => "stable". + * pct > tolerance for a lower-is-better metric => "regression". + * pct < -tolerance for a lower-is-better metric => "improvement". + * + * A regression is "significant" (the hard-gate signal) when it is BOTH: + * - at least `regressionThreshold` slower (default 0.10 = 10%), AND + * - beyond noise. unity-helpers Stopwatch metrics carry NO stddev/sampleCount + * (they are single aggregate numbers), so when stddev is null the + * "beyond stddev" test degrades to the percent threshold alone. When a + * metric DOES carry a stddev (the future Unity-perf SampleGroup path), the + * absolute delta must also exceed `stddevMultiplier * stddev` (default 1x) + * for the regression to count as significant. This matches the brief's + * ">5% slower AND beyond stddev" intent while remaining meaningful for the + * stddev-less data we actually have today. + * + * The process exits NON-ZERO only when a significant regression is found AND + * gating is enabled (PERF_FAIL_ON_REGRESSION=1 / --fail-on-regression). The + * DEFAULT is report-only (exit 0) so the weekly scheduled run never blocks + * unexpectedly; the regression list is always written into the JSON + Markdown. + * + * --------------------------------------------------------------------------- + * USAGE + * node render-perf-deltas.js --current --baseline \ + * [--out-md ] [--out-json ] \ + * [--update-baseline ] [--tolerance 0.05] \ + * [--regression-threshold 0.10] [--stddev-multiplier 1] \ + * [--fail-on-regression] + * node render-perf-deltas.js --self-test + * + * --update-baseline writes the CURRENT metrics back out as the new rolling + * baseline (prettier-clean, 2-space, trailing newline) so the workflow can keep + * a rolling baseline. The baseline file shape is { _comment?, metrics: [...] }. + * + * Env equivalents (flags win): PERF_TOLERANCE, PERF_REGRESSION_THRESHOLD, + * PERF_STDDEV_MULTIPLIER, PERF_FAIL_ON_REGRESSION. + */ + +const fs = require("fs"); + +const DEFAULT_TOLERANCE = 0.05; +const DEFAULT_REGRESSION_THRESHOLD = 0.1; +const DEFAULT_STDDEV_MULTIPLIER = 1; + +// Units for which LOWER is better (a positive delta is a regression). Anything +// else (notably unit === null, e.g. dimensionless speedup ratios) is reported +// but never gated. +const LOWER_IS_BETTER_UNITS = new Set([ + "ns", + "us", + "µs", + "ms", + "s", + "sec", + "b", + "byte", + "bytes", + "kb", + "mb", + "gb" +]); + +function parseArgs(argv) { + const env = process.env; + const options = { + current: null, + baseline: null, + outMd: null, + outJson: null, + updateBaseline: null, + tolerance: numFromEnv(env.PERF_TOLERANCE, DEFAULT_TOLERANCE), + regressionThreshold: numFromEnv(env.PERF_REGRESSION_THRESHOLD, DEFAULT_REGRESSION_THRESHOLD), + stddevMultiplier: numFromEnv(env.PERF_STDDEV_MULTIPLIER, DEFAULT_STDDEV_MULTIPLIER), + failOnRegression: boolFromEnv(env.PERF_FAIL_ON_REGRESSION), + selfTest: false, + help: false + }; + for (let index = 2; index < argv.length; index++) { + const arg = argv[index]; + switch (arg) { + case "--current": + options.current = requireValue(argv, ++index, arg); + break; + case "--baseline": + options.baseline = requireValue(argv, ++index, arg); + break; + case "--out-md": + options.outMd = requireValue(argv, ++index, arg); + break; + case "--out-json": + options.outJson = requireValue(argv, ++index, arg); + break; + case "--update-baseline": + options.updateBaseline = requireValue(argv, ++index, arg); + break; + case "--tolerance": + options.tolerance = parseNonNegative(requireValue(argv, ++index, arg), arg); + break; + case "--regression-threshold": + options.regressionThreshold = parseNonNegative(requireValue(argv, ++index, arg), arg); + break; + case "--stddev-multiplier": + options.stddevMultiplier = parseNonNegative(requireValue(argv, ++index, arg), arg); + break; + case "--fail-on-regression": + options.failOnRegression = true; + break; + case "--self-test": + options.selfTest = true; + break; + case "--help": + case "-h": + options.help = true; + break; + default: + throw new Error(`Unknown argument: ${arg}`); + } + } + return options; +} + +function requireValue(argv, index, flag) { + const value = argv[index]; + if (value === undefined || value.startsWith("--")) { + throw new Error(`${flag} requires a value.`); + } + return value; +} + +function parseNonNegative(value, flag) { + const parsed = Number.parseFloat(value); + if (!Number.isFinite(parsed) || parsed < 0) { + throw new Error(`${flag} must be a non-negative number, got: ${value}`); + } + return parsed; +} + +function numFromEnv(value, fallback) { + if (value === undefined || value === "") { + return fallback; + } + const parsed = Number.parseFloat(value); + return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback; +} + +function boolFromEnv(value) { + if (value === undefined) { + return false; + } + return /^(1|true|yes|on)$/i.test(value.trim()); +} + +function usage() { + return [ + "Usage: node scripts/unity/lib/render-perf-deltas.js --current \\", + " --baseline [--out-md ] [--out-json ] \\", + " [--update-baseline ] [--tolerance 0.05] [--regression-threshold 0.10] \\", + " [--stddev-multiplier 1] [--fail-on-regression]", + " node scripts/unity/lib/render-perf-deltas.js --self-test", + "", + "Compares current perf metrics with a committed baseline, renders a Markdown", + "delta report + a machine-readable JSON payload, and (optionally) rewrites the", + "rolling baseline. Report-only by default; exits non-zero on a significant", + "regression only when --fail-on-regression / PERF_FAIL_ON_REGRESSION=1." + ].join("\n"); +} + +// --- Loading --------------------------------------------------------------- + +function loadMetrics(filePath) { + if (!filePath || !fs.existsSync(filePath)) { + return null; + } + let raw; + try { + raw = fs.readFileSync(filePath, "utf8"); + } catch { + return null; + } + if (raw.trim() === "") { + return []; + } + let parsed; + try { + parsed = JSON.parse(raw); + } catch (error) { + throw new Error(`Failed to parse JSON from ${filePath}: ${error.message}`); + } + // Accept either a bare array (extractor output) or { metrics: [...] } + // (baseline file shape). + if (Array.isArray(parsed)) { + return parsed; + } + if (parsed && Array.isArray(parsed.metrics)) { + return parsed.metrics; + } + return []; +} + +// --- Comparison ------------------------------------------------------------ + +function metricKey(metric) { + return [ + metric.test ?? "", + metric.sampleGroup ?? "", + metric.unityVersion ?? "", + metric.testMode ?? "" + ].join(""); +} + +function indexByKey(metrics) { + const map = new Map(); + for (const metric of metrics) { + const key = metricKey(metric); + // First wins so output is deterministic if a run emits duplicates. + if (!map.has(key)) { + map.set(key, metric); + } + } + return map; +} + +function lowerIsBetter(unit) { + return unit != null && LOWER_IS_BETTER_UNITS.has(String(unit).toLowerCase()); +} + +function relativeChange(current, baseline) { + if (baseline === 0) { + return current === 0 ? 0 : Infinity; + } + return (current - baseline) / baseline; +} + +// Classify one matched metric. Returns null when the current value is not a +// finite number (nothing to compare). +function classify(current, baseline, options) { + const currentVal = Number(current.median); + const baselineVal = Number(baseline.median); + if (!Number.isFinite(currentVal) || !Number.isFinite(baselineVal)) { + return null; + } + + const absDelta = currentVal - baselineVal; + const pct = relativeChange(currentVal, baselineVal); + const gated = lowerIsBetter(current.unit); + + let status = "stable"; + let significant = false; + + if (gated && Number.isFinite(pct)) { + if (pct > options.tolerance) { + status = "regression"; + } else if (pct < -options.tolerance) { + status = "improvement"; + } + + if (status === "regression") { + const meetsThreshold = pct >= options.regressionThreshold; + // stddev gate: only applies when the baseline carries a stddev. For the + // stddev-less Stopwatch metrics we have today, beyondNoise is true and the + // percent threshold alone decides significance. + const stddev = Number(baseline.stddev); + const beyondNoise = Number.isFinite(stddev) + ? Math.abs(absDelta) > options.stddevMultiplier * stddev + : true; + significant = meetsThreshold && beyondNoise; + } + } + + return { + test: current.test ?? null, + sampleGroup: current.sampleGroup ?? null, + unit: current.unit ?? null, + unityVersion: current.unityVersion ?? null, + testMode: current.testMode ?? null, + baseline: baselineVal, + current: currentVal, + absDelta, + pct, + gated, + status, + significant + }; +} + +function compareMetrics(currentMetrics, baselineMetrics, options) { + const baselineIndex = indexByKey(baselineMetrics); + const comparisons = []; + const newMetrics = []; + + for (const current of currentMetrics) { + const key = metricKey(current); + const baseline = baselineIndex.get(key); + if (!baseline) { + newMetrics.push(current); + continue; + } + const comparison = classify(current, baseline, options); + if (comparison) { + comparisons.push(comparison); + } + } + + const currentKeys = new Set(currentMetrics.map(metricKey)); + const removedMetrics = baselineMetrics.filter((m) => !currentKeys.has(metricKey(m))); + + const regressions = comparisons.filter((c) => c.status === "regression"); + const significantRegressions = comparisons.filter((c) => c.significant); + const improvements = comparisons.filter((c) => c.status === "improvement"); + + return { + comparisons, + regressions, + significantRegressions, + improvements, + newMetrics, + removedMetrics + }; +} + +// --- Formatting ------------------------------------------------------------ + +function formatNumber(value) { + if (!Number.isFinite(value)) { + return "n/a"; + } + if (Number.isInteger(value)) { + return value.toLocaleString("en-US"); + } + return value.toLocaleString("en-US", { maximumFractionDigits: 3 }); +} + +function formatValueWithUnit(value, unit) { + const formatted = formatNumber(value); + return unit ? `${formatted} ${unit}` : formatted; +} + +function formatPct(pct) { + if (!Number.isFinite(pct)) { + return "n/a"; + } + const scaled = pct * 100; + const sign = scaled > 0 ? "+" : ""; + return `${sign}${scaled.toFixed(2)}%`; +} + +function statusEmoji(comparison) { + if (comparison.status === "regression") { + return comparison.significant ? "regression!" : "regression"; + } + if (comparison.status === "improvement") { + return "improvement"; + } + return "stable"; +} + +// Render a left/right-padded markdown table (header + rows), all string cells. +function alignTable(rows) { + if (rows.length === 0) { + return ""; + } + const columnCount = rows[0].length; + const widths = new Array(columnCount).fill(0); + for (const row of rows) { + for (let c = 0; c < columnCount; c++) { + widths[c] = Math.max(widths[c], String(row[c] ?? "").length); + } + } + const renderRow = (row) => + `| ${row.map((cell, c) => String(cell ?? "").padEnd(widths[c])).join(" | ")} |`; + const divider = `| ${widths.map((w) => "-".repeat(Math.max(3, w))).join(" | ")} |`; + return [renderRow(rows[0]), divider, ...rows.slice(1).map(renderRow)].join("\n"); +} + +function comparisonRow(comparison) { + const scope = [comparison.unityVersion, comparison.testMode].filter(Boolean).join(" / ") || "-"; + return [ + `${comparison.test ?? ""} :: ${comparison.sampleGroup ?? ""}`, + scope, + formatValueWithUnit(comparison.baseline, comparison.unit), + formatValueWithUnit(comparison.current, comparison.unit), + formatPct(comparison.pct), + statusEmoji(comparison) + ]; +} + +function buildMarkdown(result, options, meta) { + const lines = []; + lines.push("# Perf Deltas"); + lines.push(""); + lines.push(`_Generated ${meta.generatedAt}._`); + lines.push(""); + + if (meta.noBaseline) { + lines.push( + "_No baseline committed yet; skipping the delta comparison. The next benchmark run will seed `perf-results/baseline.json` and subsequent runs will diff against it._" + ); + if (meta.currentCount > 0) { + lines.push(""); + lines.push(`Captured **${meta.currentCount}** metric(s) this run (now the baseline).`); + } + lines.push(""); + return lines.join("\n"); + } + + const summary = [ + `- Metrics compared: **${result.comparisons.length}**`, + `- Regressions: **${result.regressions.length}** (significant: **${result.significantRegressions.length}**)`, + `- Improvements: **${result.improvements.length}**`, + `- New metrics: **${result.newMetrics.length}**, removed: **${result.removedMetrics.length}**`, + `- Tolerance: ${(options.tolerance * 100).toFixed(2)}%, regression threshold: ${( + options.regressionThreshold * 100 + ).toFixed(2)}%` + ]; + lines.push(...summary); + lines.push(""); + + if (result.significantRegressions.length > 0) { + lines.push("## Significant regressions"); + lines.push(""); + lines.push( + alignTable([ + ["Metric", "Scope", "Baseline", "Current", "Delta", "Status"], + ...result.significantRegressions.map(comparisonRow) + ]) + ); + lines.push(""); + } + + // Show the moved metrics (regressions + improvements) to keep the report + // focused; stable metrics are summarized by count only. + const moved = result.comparisons.filter((c) => c.status !== "stable"); + lines.push("## Changed metrics"); + lines.push(""); + if (moved.length === 0) { + lines.push("_All compared metrics are within tolerance._"); + } else { + lines.push( + alignTable([ + ["Metric", "Scope", "Baseline", "Current", "Delta", "Status"], + ...moved.map(comparisonRow) + ]) + ); + } + lines.push(""); + + if (result.newMetrics.length > 0) { + lines.push("## New metrics (no baseline yet)"); + lines.push(""); + lines.push( + alignTable([ + ["Metric", "Scope", "Current"], + ...result.newMetrics.map((m) => [ + `${m.test ?? ""} :: ${m.sampleGroup ?? ""}`, + [m.unityVersion, m.testMode].filter(Boolean).join(" / ") || "-", + formatValueWithUnit(Number(m.median), m.unit) + ]) + ]) + ); + lines.push(""); + } + + return lines.join("\n"); +} + +function buildJsonPayload(result, options, meta) { + return { + generatedAt: meta.generatedAt, + noBaseline: meta.noBaseline, + tolerance: options.tolerance, + regressionThreshold: options.regressionThreshold, + stddevMultiplier: options.stddevMultiplier, + summary: { + compared: result.comparisons.length, + regressions: result.regressions.length, + significantRegressions: result.significantRegressions.length, + improvements: result.improvements.length, + newMetrics: result.newMetrics.length, + removedMetrics: result.removedMetrics.length + }, + significantRegressions: result.significantRegressions, + regressions: result.regressions, + improvements: result.improvements, + newMetrics: result.newMetrics, + comparisons: result.comparisons + }; +} + +// --- Output ---------------------------------------------------------------- + +function writeFileClean(filePath, content) { + // Always end with exactly one trailing newline (prettier-clean). + const normalized = content.endsWith("\n") ? content : `${content}\n`; + fs.writeFileSync(filePath, normalized, "utf8"); +} + +function writeBaseline(filePath, metrics, comment) { + const payload = { + _comment: + comment || + "Rolling perf baseline for unity-helpers benchmarks. Auto-updated by the Unity Benchmarks workflow (scripts/unity/lib/render-perf-deltas.js). Do not hand-edit.", + metrics + }; + // JSON.stringify with 2-space indent matches prettier's default for JSON. + writeFileClean(filePath, JSON.stringify(payload, null, 2)); +} + +// --- Orchestration --------------------------------------------------------- + +function run(options, nowIso) { + const generatedAt = nowIso || new Date().toISOString(); + const currentMetrics = loadMetrics(options.current) || []; + const baselineMetrics = loadMetrics(options.baseline); + + const noBaseline = baselineMetrics === null || baselineMetrics.length === 0; + const meta = { + generatedAt, + noBaseline, + currentCount: currentMetrics.length + }; + + let result; + if (noBaseline) { + result = { + comparisons: [], + regressions: [], + significantRegressions: [], + improvements: [], + newMetrics: currentMetrics, + removedMetrics: [] + }; + } else { + result = compareMetrics(currentMetrics, baselineMetrics, options); + } + + const markdown = buildMarkdown(result, options, meta); + const json = buildJsonPayload(result, options, meta); + + return { + markdown, + json, + result, + meta, + currentMetrics, + significant: result.significantRegressions.length > 0 + }; +} + +function main(argv = process.argv) { + const options = parseArgs(argv); + if (options.help) { + process.stdout.write(`${usage()}\n`); + return 0; + } + if (options.selfTest) { + return runSelfTest(); + } + if (!options.current) { + process.stderr.write(`--current is required.\n\n${usage()}\n`); + return 2; + } + + const outcome = run(options); + + if (options.outMd) { + writeFileClean(options.outMd, outcome.markdown); + } else { + process.stdout.write(`${outcome.markdown}\n`); + } + if (options.outJson) { + writeFileClean(options.outJson, JSON.stringify(outcome.json, null, 2)); + } + if (options.updateBaseline) { + writeBaseline(options.updateBaseline, outcome.currentMetrics); + } + + // Emit GitHub-friendly signals on stdout regardless of output routing. + process.stdout.write( + `changed=${outcome.result.comparisons.some((c) => c.status !== "stable") ? "true" : "false"}\n` + ); + process.stdout.write(`regressed=${outcome.significant ? "true" : "false"}\n`); + + if (outcome.significant && options.failOnRegression) { + process.stderr.write( + `Significant perf regression(s) detected: ${outcome.result.significantRegressions.length}.\n` + ); + return 1; + } + return 0; +} + +// --- Self-test ------------------------------------------------------------- + +function assert(condition, message) { + if (!condition) { + throw new Error(`Self-test failed: ${message}`); + } +} + +function runSelfTest() { + const options = { + tolerance: DEFAULT_TOLERANCE, + regressionThreshold: DEFAULT_REGRESSION_THRESHOLD, + stddevMultiplier: DEFAULT_STDDEV_MULTIPLIER + }; + + const baseline = [ + { + test: "T", + sampleGroup: "Small / Serialize", + unit: "ms", + median: 100, + unityVersion: "6000.3.16f1", + testMode: "playmode" + }, + { + test: "T", + sampleGroup: "Small / Deserialize", + unit: "ms", + median: 100, + unityVersion: "6000.3.16f1", + testMode: "playmode" + }, + { + test: "T", + sampleGroup: "Small / Stable", + unit: "ms", + median: 100, + unityVersion: "6000.3.16f1", + testMode: "playmode" + }, + { + test: "T", + sampleGroup: "Small / Speedup", + unit: null, + median: 3.0, + unityVersion: "6000.3.16f1", + testMode: "playmode" + }, + { + test: "T", + sampleGroup: "Gone", + unit: "ms", + median: 50, + unityVersion: "6000.3.16f1", + testMode: "playmode" + } + ]; + const current = [ + // +20% slower, beyond 10% threshold => significant regression. + { + test: "T", + sampleGroup: "Small / Serialize", + unit: "ms", + median: 120, + unityVersion: "6000.3.16f1", + testMode: "playmode" + }, + // -30% faster => improvement. + { + test: "T", + sampleGroup: "Small / Deserialize", + unit: "ms", + median: 70, + unityVersion: "6000.3.16f1", + testMode: "playmode" + }, + // +2% => within tolerance => stable. + { + test: "T", + sampleGroup: "Small / Stable", + unit: "ms", + median: 102, + unityVersion: "6000.3.16f1", + testMode: "playmode" + }, + // Dimensionless ratio moved a lot but must NOT gate (unit null). + { + test: "T", + sampleGroup: "Small / Speedup", + unit: null, + median: 1.0, + unityVersion: "6000.3.16f1", + testMode: "playmode" + }, + // A brand-new metric with no baseline. + { + test: "T", + sampleGroup: "Small / New", + unit: "ms", + median: 5, + unityVersion: "6000.3.16f1", + testMode: "playmode" + } + ]; + + const result = compareMetrics(current, baseline, options); + + assert( + result.comparisons.length === 4, + `expected 4 compared metrics, got ${result.comparisons.length}` + ); + assert( + result.regressions.length === 1, + `expected 1 regression, got ${result.regressions.length}` + ); + assert( + result.significantRegressions.length === 1, + "the +20% ms metric should be a significant regression" + ); + assert( + result.significantRegressions[0].sampleGroup === "Small / Serialize", + "significant regression should be the Serialize metric" + ); + assert( + result.improvements.length === 1, + `expected 1 improvement, got ${result.improvements.length}` + ); + assert( + result.improvements[0].sampleGroup === "Small / Deserialize", + "improvement should be Deserialize" + ); + assert( + result.newMetrics.length === 1 && result.newMetrics[0].sampleGroup === "Small / New", + "New metric should be detected" + ); + assert( + result.removedMetrics.length === 1 && result.removedMetrics[0].sampleGroup === "Gone", + "Removed metric should be detected" + ); + + const speedup = result.comparisons.find((c) => c.sampleGroup === "Small / Speedup"); + assert( + speedup && speedup.status === "stable" && speedup.gated === false, + "dimensionless ratio must be stable and not gated" + ); + + const stableOne = result.comparisons.find((c) => c.sampleGroup === "Small / Stable"); + assert(stableOne && stableOne.status === "stable", "+2% metric must be stable"); + + // stddev gate: a +20% move that is WITHIN the stddev band must NOT be significant. + const noisyBaseline = [ + { + test: "T", + sampleGroup: "Jittery", + unit: "ms", + median: 100, + stddev: 40, + unityVersion: "6000.3.16f1", + testMode: "playmode" + } + ]; + const noisyCurrent = [ + { + test: "T", + sampleGroup: "Jittery", + unit: "ms", + median: 120, + unityVersion: "6000.3.16f1", + testMode: "playmode" + } + ]; + const noisy = compareMetrics(noisyCurrent, noisyBaseline, options); + assert( + noisy.regressions.length === 1, + "the noisy metric is still a (non-significant) regression" + ); + assert( + noisy.significantRegressions.length === 0, + "a +20% move inside a 40ms stddev band must NOT be a significant regression" + ); + + // Missing-baseline path. + const noBaselineOutcome = run( + { current: null, baseline: null, ...options }, + "2026-06-14T00:00:00.000Z" + ); + assert(noBaselineOutcome.meta.noBaseline === true, "null baseline path should set noBaseline"); + assert( + /No baseline committed yet/.test(noBaselineOutcome.markdown), + "no-baseline markdown should explain itself" + ); + assert( + noBaselineOutcome.significant === false, + "no-baseline path must never report a regression" + ); + + // Markdown renders without throwing and includes the significant section. + const md = buildMarkdown(result, options, { + generatedAt: "2026-06-14T00:00:00.000Z", + noBaseline: false, + currentCount: current.length + }); + assert( + /Significant regressions/.test(md), + "markdown should include a Significant regressions section" + ); + assert(/Small \/ Serialize/.test(md), "markdown should list the regressed metric"); + + // JSON payload shape. + const payload = buildJsonPayload(result, options, { + generatedAt: "2026-06-14T00:00:00.000Z", + noBaseline: false, + currentCount: current.length + }); + assert( + payload.summary.significantRegressions === 1, + "JSON summary should report 1 significant regression" + ); + assert(Array.isArray(payload.comparisons), "JSON payload should carry the comparisons array"); + + process.stdout.write("render-perf-deltas self-test passed.\n"); + return 0; +} + +if (require.main === module) { + try { + process.exitCode = main(); + } catch (error) { + process.stderr.write(`${error.message}\n`); + process.exitCode = 1; + } +} + +module.exports = { + loadMetrics, + metricKey, + indexByKey, + lowerIsBetter, + classify, + compareMetrics, + buildMarkdown, + buildJsonPayload, + alignTable, + writeBaseline, + run, + LOWER_IS_BETTER_UNITS +}; diff --git a/scripts/unity/lib/render-perf-deltas.js.meta b/scripts/unity/lib/render-perf-deltas.js.meta new file mode 100644 index 000000000..713bf7bec --- /dev/null +++ b/scripts/unity/lib/render-perf-deltas.js.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: a3836ab5759eeef5b69efb4b34619651 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: From 897628bffaedecc0eef2bbd7778f4db7e7db4d81 Mon Sep 17 00:00:00 2001 From: wallstop Date: Sun, 14 Jun 2026 08:58:17 -0700 Subject: [PATCH 09/31] Fix Git Hooks --- .githooks/post-rewrite | 6 +- .githooks/pre-commit | 161 ++++-- .githooks/pre-push | 91 +++- .llm/context.md | 6 +- .llm/references/forbidden-patterns.md | 2 +- .llm/skills/defensive-programming.md | 12 +- .llm/skills/formatting-and-linting.md | 22 +- .llm/skills/linter-reference.md | 2 +- .llm/skills/markdown-reference.md | 4 + .llm/skills/mcp-configuration.md | 8 +- .llm/skills/optimize-git-hooks.md | 23 +- .llm/skills/serialization-safety.md | 8 +- .llm/skills/use-serialization.md | 2 +- .prettierrc.json | 1 + cspell.json | 3 + docs/guides/mcp-local-setup.md | 2 +- package.json | 6 +- perf-results.meta | 8 + perf-results/baseline.json.meta | 7 + scripts/agent-preflight.ps1 | 509 +++++++++++++++++- scripts/audit-license-years.sh | 73 ++- scripts/check-eol.ps1 | 13 +- scripts/fix-markdown-fence-languages.ps1 | 248 +++++++++ scripts/fix-markdown-fence-languages.ps1.meta | 7 + scripts/install-hooks.ps1 | 51 +- scripts/lint-doc-links.ps1 | 128 ++++- scripts/lint-staged-markdown.ps1 | 9 + scripts/normalize-eol.ps1 | 19 +- scripts/tests/test-agent-preflight.ps1 | 257 +++++++++ scripts/tests/test-hook-patterns.sh | 11 + scripts/tests/test-license-cache.sh | 76 ++- scripts/tests/test-lint-doc-links.ps1 | 73 ++- scripts/tests/test-pre-push-changed-files.sh | 46 ++ scripts/tests/test-sync-script-contracts.ps1 | 136 +++++ scripts/update-license-headers.sh | 94 +++- 35 files changed, 1957 insertions(+), 167 deletions(-) create mode 100644 perf-results.meta create mode 100644 perf-results/baseline.json.meta create mode 100644 scripts/fix-markdown-fence-languages.ps1 create mode 100644 scripts/fix-markdown-fence-languages.ps1.meta diff --git a/.githooks/post-rewrite b/.githooks/post-rewrite index c2e997e12..6a5b8f022 100755 --- a/.githooks/post-rewrite +++ b/.githooks/post-rewrite @@ -11,7 +11,11 @@ if [ -z "$REPO_ROOT" ]; then exit 0 fi -CACHE_FILE="$REPO_ROOT/.git/license-year-cache" +CACHE_FILE=$(git rev-parse --git-path license-year-cache 2>/dev/null) +case "$CACHE_FILE" in + /*) ;; + *) CACHE_FILE="$REPO_ROOT/$CACHE_FILE" ;; +esac if [ -f "$CACHE_FILE" ]; then rm -f "$CACHE_FILE" echo "License year cache invalidated (history rewritten)." diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 8e947a385..d05650ba0 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -88,26 +88,97 @@ if command -v dotnet >/dev/null 2>&1; then dotnet tool restore >/dev/null 2>&1 || true fi -# 3) Lint Markdown link text style (via Node wrapper -> PowerShell script) -if command -v node >/dev/null 2>&1; then - node ./scripts/run-doc-link-lint.js -else - run_pwsh() { - pwsh -NoProfile -File scripts/lint-doc-links.ps1 - } +# ============================================================================ +# SECURITY: Safe file list handling +# ============================================================================ +# File lists are read using null-delimited (-z) output from git and stored in +# bash arrays. This prevents shell injection attacks via crafted filenames +# containing spaces, newlines, semicolons, or other special characters. +# +# Pattern: git diff -z | while IFS= read -r -d '' file; do array+=("$file"); done +# This ensures each filename is treated as a single atomic argument, regardless +# of what characters it contains. +# ============================================================================ - run_windows_ps() { - powershell -NoProfile -ExecutionPolicy Bypass -File scripts/lint-doc-links.ps1 - } +# Read staged files into an array safely using null-delimited output +STAGED_FILES_ARRAY=() +while IFS= read -r -d '' file; do + STAGED_FILES_ARRAY+=("$file") +done < <(git diff --cached --name-only --diff-filter=ACMR -z) + +DOC_LINK_FULL_SCAN_REQUIRED=0 +mark_doc_link_full_scan_path() { + case "$1" in + *.md|*.markdown) + DOC_LINK_FULL_SCAN_REQUIRED=1 + ;; + esac +} - if command -v pwsh >/dev/null 2>&1; then - run_pwsh +while IFS= read -r -d '' status; do + case "$status" in + D*) + IFS= read -r -d '' deleted_path || true + mark_doc_link_full_scan_path "$deleted_path" + ;; + R*) + IFS= read -r -d '' old_path || true + IFS= read -r -d '' _new_path || true + mark_doc_link_full_scan_path "$old_path" + ;; + esac +done < <(git diff --cached --name-status -z -M --diff-filter=DR) + +# Early exit if no staged files (e.g., amend with no changes, or merge commit) +if [ ${#STAGED_FILES_ARRAY[@]} -eq 0 ] && [ "$DOC_LINK_FULL_SCAN_REQUIRED" -eq 0 ]; then + echo "No staged files to check. Skipping pre-commit hooks." + exit 0 +fi + +# 3) Lint Markdown link text style (via Node wrapper -> PowerShell script) +DOC_LINK_FILES_ARRAY=() +for file in "${STAGED_FILES_ARRAY[@]}"; do + case "$file" in + *.md|*.markdown|*.cs|*.csproj|*.props|*.targets|*.ps1|*.psm1|*.psd1|*.py|*.ts|*.tsx|*.js|*.jsx|*.json|*.yml|*.yaml|*.sh|*.cmd) + DOC_LINK_FILES_ARRAY+=("$file") + ;; + esac +done + +if [ "$DOC_LINK_FULL_SCAN_REQUIRED" -ne 0 ]; then + if command -v node >/dev/null 2>&1; then + node ./scripts/run-doc-link-lint.js + elif command -v pwsh >/dev/null 2>&1; then + pwsh -NoProfile -File scripts/lint-doc-links.ps1 elif command -v powershell >/dev/null 2>&1; then - run_windows_ps + powershell -NoProfile -ExecutionPolicy Bypass -File scripts/lint-doc-links.ps1 else echo "PowerShell not found. Please install Node.js (preferred) or pwsh/powershell to run docs linter." >&2 exit 1 fi +elif [ ${#DOC_LINK_FILES_ARRAY[@]} -gt 0 ]; then + if command -v node >/dev/null 2>&1; then + node ./scripts/run-doc-link-lint.js -Paths "${DOC_LINK_FILES_ARRAY[@]}" + else + run_pwsh() { + pwsh -NoProfile -File scripts/lint-doc-links.ps1 -Paths "${DOC_LINK_FILES_ARRAY[@]}" + } + + run_windows_ps() { + powershell -NoProfile -ExecutionPolicy Bypass -File scripts/lint-doc-links.ps1 -Paths "${DOC_LINK_FILES_ARRAY[@]}" + } + + if command -v pwsh >/dev/null 2>&1; then + run_pwsh + elif command -v powershell >/dev/null 2>&1; then + run_windows_ps + else + echo "PowerShell not found. Please install Node.js (preferred) or pwsh/powershell to run docs linter." >&2 + exit 1 + fi + fi +else + echo "No staged files require documentation link lint." fi run_node_tool() { @@ -131,30 +202,6 @@ require_node_tool() { fi } -# ============================================================================ -# SECURITY: Safe file list handling -# ============================================================================ -# File lists are read using null-delimited (-z) output from git and stored in -# bash arrays. This prevents shell injection attacks via crafted filenames -# containing spaces, newlines, semicolons, or other special characters. -# -# Pattern: git diff -z | while IFS= read -r -d '' file; do array+=("$file"); done -# This ensures each filename is treated as a single atomic argument, regardless -# of what characters it contains. -# ============================================================================ - -# Read staged files into an array safely using null-delimited output -STAGED_FILES_ARRAY=() -while IFS= read -r -d '' file; do - STAGED_FILES_ARRAY+=("$file") -done < <(git diff --cached --name-only --diff-filter=ACM -z) - -# Early exit if no staged files (e.g., amend with no changes, or merge commit) -if [ ${#STAGED_FILES_ARRAY[@]} -eq 0 ]; then - echo "No staged files to check. Skipping pre-commit hooks." - exit 0 -fi - # 5) Ensure staged text files end with a final newline # This MUST run before Prettier formatting to prevent missing-newline diffs @@ -318,11 +365,47 @@ if [ ${#CS_FILES_ARRAY[@]} -gt 0 ]; then fi fi -# 7) Markdown lint for staged Markdown files +# 6b) Audit and auto-fix staged C# license years before commit. +if [ ${#CS_FILES_ARRAY[@]} -gt 0 ]; then + echo "Checking license year headers on staged C# files..." + if ! bash scripts/audit-license-years.sh --summary --paths "${CS_FILES_ARRAY[@]}"; then + echo "License year drift detected. Attempting auto-fix..." >&2 + bash scripts/update-license-headers.sh --paths "${CS_FILES_ARRAY[@]}" + stage_with_retry_or_fail "license header auto-fix re-stage" "${CS_FILES_ARRAY[@]}" + + if ! bash scripts/audit-license-years.sh --summary --paths "${CS_FILES_ARRAY[@]}"; then + echo "" >&2 + echo "=== License year audit failed after auto-fix ===" >&2 + echo "Run: npm run agent:preflight:fix" >&2 + echo "" >&2 + exit 1 + fi + fi +fi + +# 7) Auto-fix staged Markdown files, recover missing fence languages when needed, then lint if [ ${#MD_FILES_ARRAY[@]} -gt 0 ]; then require_node_tool markdownlint "markdownlint validation" # First, auto-fix what can be fixed - run_node_tool markdownlint --fix --config .markdownlint.json --ignore-path .markdownlintignore -- "${MD_FILES_ARRAY[@]}" || true + MARKDOWNLINT_FIX_CAPTURE="$(mktemp 2>/dev/null || true)" + if [ -z "$MARKDOWNLINT_FIX_CAPTURE" ]; then + echo "Error: Failed to create temporary file for markdownlint auto-fix output." >&2 + exit 1 + fi + MARKDOWNLINT_FIX_EXIT=0 + run_node_tool markdownlint --fix --config .markdownlint.json --ignore-path .markdownlintignore -- "${MD_FILES_ARRAY[@]}" >"$MARKDOWNLINT_FIX_CAPTURE" 2>&1 || MARKDOWNLINT_FIX_EXIT=$? + if [ "$MARKDOWNLINT_FIX_EXIT" -ne 0 ] && grep -q 'MD040/fenced-code-language' -- "$MARKDOWNLINT_FIX_CAPTURE"; then + if command -v pwsh >/dev/null 2>&1; then + pwsh -NoProfile -File scripts/fix-markdown-fence-languages.ps1 -Paths "${MD_FILES_ARRAY[@]}" + stage_with_retry_or_fail "Markdown fence language auto-fix re-stage" "${MD_FILES_ARRAY[@]}" + elif command -v powershell >/dev/null 2>&1; then + powershell -NoProfile -ExecutionPolicy Bypass -File scripts/fix-markdown-fence-languages.ps1 -Paths "${MD_FILES_ARRAY[@]}" + stage_with_retry_or_fail "Markdown fence language auto-fix re-stage" "${MD_FILES_ARRAY[@]}" + else + echo "PowerShell not found. Skipping Markdown fence language auto-fix." >&2 + fi + fi + rm -f "$MARKDOWNLINT_FIX_CAPTURE" # Re-stage the fixed files stage_with_retry_or_fail "markdownlint auto-fix re-stage" "${MD_FILES_ARRAY[@]}" # Then run markdownlint again to catch any remaining unfixable issues diff --git a/.githooks/pre-push b/.githooks/pre-push index bcc52f2ee..c2fbb1613 100755 --- a/.githooks/pre-push +++ b/.githooks/pre-push @@ -7,7 +7,7 @@ set -euo pipefail # This hook reads stdin to detect which files are being pushed, then runs # only the relevant checks on changed files. Full-repo validation happens in CI. # -# Performance: Targets <10s for typical pushes by: +# Performance: Targets <1s for typical warm pushes by: # - Parsing stdin to detect exactly which files changed # - Running checks only on changed files (not the whole repo) # - Executing independent checks in parallel @@ -43,6 +43,7 @@ set -euo pipefail ZERO_SHA="0000000000000000000000000000000000000000" HAS_REFS=false ALL_CHANGED_FILES=() +DOC_LINK_FULL_SCAN_REQUIRED=0 array_contains_exact() { local needle="$1" @@ -67,6 +68,29 @@ add_changed_file() { fi } +write_agent_preflight_path_list() { + local path_list + path_list=$(git rev-parse --git-path pre-push-agent-preflight-paths.bin 2>/dev/null || printf '%s' ".git/pre-push-agent-preflight-paths.bin") + + if [ "$#" -gt 0 ]; then + # NUL-delimit so even unusual filenames survive the handoff to PowerShell. + printf '%s\0' "$@" > "$path_list" + else + : > "$path_list" + fi + + printf '%s\n' "$path_list" +} + +print_agent_preflight_fix_hint() { + local path_list + path_list=$(write_agent_preflight_path_list "$@") + + echo "Automated recovery (path-scoped):" + echo " npm run agent:preflight:fix -- -PathList \"$path_list\"" + echo "Then commit generated fixes and push again." +} + collect_changed_files() { local file while IFS= read -r -d '' file; do @@ -74,6 +98,35 @@ collect_changed_files() { done } +mark_doc_link_full_scan_path() { + case "$1" in + *.md|*.markdown) + DOC_LINK_FULL_SCAN_REQUIRED=1 + ;; + esac +} + +collect_doc_link_full_scan_triggers() { + local status + local deleted_path + local old_path + local new_path + + while IFS= read -r -d '' status; do + case "$status" in + D*) + IFS= read -r -d '' deleted_path || true + mark_doc_link_full_scan_path "$deleted_path" + ;; + R*) + IFS= read -r -d '' old_path || true + IFS= read -r -d '' new_path || true + mark_doc_link_full_scan_path "$old_path" + ;; + esac + done +} + # Cleanup on exit/interrupt (defined before while read so temp file is always cleaned up) PID_NODE="" PID_PWSH="" PID_BASH="" cleanup() { @@ -95,19 +148,21 @@ while read -r _local_ref local_sha _remote_ref remote_sha; do # New branch: compare against default branch merge-base merge_base=$(git merge-base main "$local_sha" 2>/dev/null || echo "") if [ -n "$merge_base" ]; then - collect_changed_files < <(git diff --name-only -z "$merge_base".."$local_sha" 2>/dev/null || true) + collect_changed_files < <(git diff --name-only -z --diff-filter=ACMRTUXB "$merge_base".."$local_sha" 2>/dev/null || true) + collect_doc_link_full_scan_triggers < <(git diff --name-status -z -M --diff-filter=DR "$merge_base".."$local_sha" 2>/dev/null || true) else # No merge-base (orphan branch or no main): compare against the full tree. collect_changed_files < <(git ls-tree -r -z --name-only "$local_sha" 2>/dev/null || true) fi else # Normal push: diff between remote and local - collect_changed_files < <(git diff --name-only -z "$remote_sha".."$local_sha" 2>/dev/null || true) + collect_changed_files < <(git diff --name-only -z --diff-filter=ACMRTUXB "$remote_sha".."$local_sha" 2>/dev/null || true) + collect_doc_link_full_scan_triggers < <(git diff --name-status -z -M --diff-filter=DR "$remote_sha".."$local_sha" 2>/dev/null || true) fi done # If no refs to push (delete-only) or stdin was empty (no-op), skip all checks -if [ "$HAS_REFS" = "false" ] || [ ${#ALL_CHANGED_FILES[@]} -eq 0 ]; then +if [ "$HAS_REFS" = "false" ] || { [ ${#ALL_CHANGED_FILES[@]} -eq 0 ] && [ "$DOC_LINK_FULL_SCAN_REQUIRED" -eq 0 ]; }; then echo "No files changed in push, skipping checks." exit 0 fi @@ -134,6 +189,7 @@ CHANGED_LINT_DUPLICATE_USINGS=() CHANGED_GITIGNORE_DOCS_LINT=() CHANGED_SYNC_SCRIPT_CONTRACTS=() CHANGED_LINT_ERROR_CODE_SCRIPTS=() +CHANGED_DOC_LINK=() for file in "${ALL_CHANGED_FILES[@]}"; do [[ "$file" =~ \.(md|markdown)$ ]] && CHANGED_MD+=("$file") @@ -166,6 +222,11 @@ for file in "${ALL_CHANGED_FILES[@]}"; do # its regression test. The `.githooks/` anchor uses `^\.githooks/` to avoid # accidentally matching the similarly-prefixed `.github/` directory. [[ "$file" =~ ^(scripts/lint-[^/]+\.(ps1|js)|scripts/tests/test-lint-[^/]+\.(ps1|js|sh)|\.githooks/[^/]+|scripts/validate-lint-error-codes\.ps1|scripts/tests/test-validate-lint-error-codes\.ps1|cspell\.json)$ ]] && CHANGED_LINT_ERROR_CODE_SCRIPTS+=("$file") + case "$file" in + *.md|*.markdown|*.cs|*.csproj|*.props|*.targets|*.ps1|*.psm1|*.psd1|*.py|*.ts|*.tsx|*.js|*.jsx|*.json|*.yml|*.yaml|*.sh|*.cmd) + CHANGED_DOC_LINK+=("$file") + ;; + esac done CHANGED_PRETTIER=("${CHANGED_MD[@]}" "${CHANGED_JSON[@]}" "${CHANGED_YAML[@]}" "${CHANGED_JS[@]}") @@ -212,8 +273,7 @@ run_node_checks() { if ! node scripts/run-prettier.js --check -- "${CHANGED_PRETTIER[@]}"; then echo "" echo "=== Prettier formatting issues detected ===" - echo "Run: node scripts/run-prettier.js --write -- " - echo "Then commit and push again." + echo "Run the path-scoped recovery command printed at the end of this hook." echo "" return 1 fi @@ -293,7 +353,7 @@ run_node_checks() { if ! node scripts/lint-cspell-config.js; then echo "" echo "=== cspell.json configuration issues ===" - echo "Run: npm run lint:spelling:config:fix" + echo "Run the path-scoped recovery command printed at the end of this hook." echo "" return 1 fi @@ -302,10 +362,14 @@ run_node_checks() { fi # 4) Doc link lint (only if docs or markdown changed) - if [ ${#CHANGED_MD[@]} -gt 0 ] || [ ${#CHANGED_DOCS[@]} -gt 0 ] || [ ${#CHANGED_LLM[@]} -gt 0 ]; then + if [ "$DOC_LINK_FULL_SCAN_REQUIRED" -ne 0 ] || [ ${#CHANGED_DOC_LINK[@]} -gt 0 ]; then require_node "documentation link lint" || return 1 echo "Checking documentation links..." - node ./scripts/run-doc-link-lint.js || return 1 + if [ "$DOC_LINK_FULL_SCAN_REQUIRED" -ne 0 ]; then + node ./scripts/run-doc-link-lint.js || return 1 + else + node ./scripts/run-doc-link-lint.js -Paths "${CHANGED_DOC_LINK[@]}" || return 1 + fi echo "✓ Doc links OK" fi @@ -355,7 +419,7 @@ run_pwsh_checks() { "${PWSH_CMD[@]}" scripts/check-eol.ps1 -VerboseOutput -Paths "${CHANGED_EOL[@]}" || { echo "" echo "=== EOL check failed ===" - echo "Run: npm run fix:eol" + echo "Run the path-scoped recovery command printed at the end of this hook." echo "" return 1 } @@ -404,7 +468,7 @@ run_pwsh_checks() { "${PWSH_CMD[@]}" scripts/lint-llm-instructions.ps1 || { echo "" echo "=== LLM instructions validation failed ===" - echo "Run: pwsh -NoProfile -File scripts/lint-llm-instructions.ps1 -Fix" + echo "Run the path-scoped recovery command printed at the end of this hook." echo "" return 1 } @@ -422,7 +486,7 @@ run_bash_checks() { if ! bash scripts/audit-license-years.sh --summary --paths "${CHANGED_CS[@]}"; then echo "" echo "=== License year audit failed ===" - echo "To auto-fix: bash scripts/update-license-headers.sh" + echo "Run the path-scoped recovery command printed at the end of this hook." echo "" return 1 fi @@ -603,7 +667,8 @@ fi # ============================================================================ if [ $HOOK_FAILED -ne 0 ]; then echo "" - echo "Pre-push checks FAILED. Fix the issues above and push again." + echo "Pre-push checks FAILED." + print_agent_preflight_fix_hint "${ALL_CHANGED_FILES[@]}" echo "To skip in emergencies: git push --no-verify (CI will still validate)" exit 1 fi diff --git a/.llm/context.md b/.llm/context.md index 880b9ed58..dc29cd86f 100644 --- a/.llm/context.md +++ b/.llm/context.md @@ -55,7 +55,7 @@ Invoke these skills for specific tasks. **Regenerate with**: `pwsh -NoProfile -File scripts/generate-skills-index.ps1` - + ### Core Skills (Always Consider) @@ -111,7 +111,7 @@ Invoke these skills for specific tasks. | [run-retrospective](./skills/run-retrospective.md) | Structured retrospective analyzing what happened, what worked, and what to improve | | [search-codebase](./skills/search-codebase.md) | Finding code, files, or patterns | | [self-regulate-changes](./skills/self-regulate-changes.md) | Know when to stop: risk scoring and hard caps for cascading changes | -| [serialization-safety](./skills/serialization-safety.md) | Serializer exception contract — every entry point throws SerializationFailureException or has a Try sibling | +| [serialization-safety](./skills/serialization-safety.md) | Serializer exception contract - every entry point throws SerializationFailureException or has a Try sibling | | [ship-changes](./skills/ship-changes.md) | End-to-end workflow for shipping changes: validate, review, version, changelog, commit | | [test-data-driven](./skills/test-data-driven.md) | Data-driven testing with TestCase and TestCaseSource | | [test-naming-conventions](./skills/test-naming-conventions.md) | Test method and TestName naming rules | @@ -224,7 +224,7 @@ Run formatters/linters **immediately after each file change**, not batched at ta - **Spelling**: `npm run lint:spelling` (add valid terms to `cspell.json`). A Claude Code PostToolUse hook (`scripts/hooks/cspell-post-edit.js`, registered in the tracked [`.claude/settings.json`](../.claude/settings.json) which ships with the repo) auto-runs cspell after every Edit/Write/MultiEdit/NotebookEdit, so typos surface immediately; manual invocation before completion remains the expectation (the hook is a safety net, not a substitute -- it does not fire in CI or when editing outside Claude Code) - **Tests**: `pwsh -NoProfile -File scripts/lint-tests.ps1 -FixNullChecks -Paths ` - **Skill files and [context](./context.md)**: `pwsh -NoProfile -File scripts/lint-skill-sizes.ps1` (500-line limit) -- **Commit prep**: stage files, then run `npm run agent:preflight:fix` (includes changed markdown spelling checks) before any commit attempt +- **Commit prep**: stage files, then run `npm run agent:preflight:fix` (includes changed spell-checkable file checks) before any commit attempt - **Pre-push parity**: run `npm run validate:prepush` (includes full `lint:spelling`) before push; treat git hooks as last-resort only. For the push step itself (setup, redirection, rejection handling) follow [ship-changes Step 9](./skills/ship-changes.md#step-9-push-to-remote) See [formatting](./skills/formatting.md) and [validate-before-commit](./skills/validate-before-commit.md) for details. diff --git a/.llm/references/forbidden-patterns.md b/.llm/references/forbidden-patterns.md index 3284514af..23aa0f129 100644 --- a/.llm/references/forbidden-patterns.md +++ b/.llm/references/forbidden-patterns.md @@ -335,7 +335,7 @@ When passing file arguments to CLI tools, a `--` (end-of-options) separator MUST ## Serialization Patterns -`Serializer` is the single documented carve-out from the "never throw" rule (see `.llm/skills/serialization-safety.md`). Inside `Runtime/Core/Serialization/Serializer.cs` and any future format added there: +`Serializer` is the single documented carve-out from the "never throw" rule (see [Serialization Safety](../skills/serialization-safety.md)). Inside `Runtime/Core/Serialization/Serializer.cs` and any future format added there: | Forbidden | Use Instead | Reason | | ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | diff --git a/.llm/skills/defensive-programming.md b/.llm/skills/defensive-programming.md index a2ff0529b..019af082e 100644 --- a/.llm/skills/defensive-programming.md +++ b/.llm/skills/defensive-programming.md @@ -25,12 +25,12 @@ Production code—including editor tooling—must be **resilient to any state**. Exceptions should ONLY be thrown for: -| Scenario | Example | Why It's OK | -| ------------------------------------ | -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | -| **Programmer error (debug only)** | `Debug.Assert(index >= 0)` | Catches bugs during development | -| **Fundamentally impossible states** | Constructor receives negative capacity | API contract violation | -| **Security violations** | Unauthorized access to protected resources | Must fail loudly | -| **Serializer input/decode failures** | `Serializer.ProtoDeserialize(corrupt)` throws `SerializationFailureException` | Save/network data is load-bearing — silent `default(T)` corrupts state. See `.llm/skills/serialization-safety.md`. | +| Scenario | Example | Why It's OK | +| ------------------------------------ | -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | +| **Programmer error (debug only)** | `Debug.Assert(index >= 0)` | Catches bugs during development | +| **Fundamentally impossible states** | Constructor receives negative capacity | API contract violation | +| **Security violations** | Unauthorized access to protected resources | Must fail loudly | +| **Serializer input/decode failures** | `Serializer.ProtoDeserialize(corrupt)` throws `SerializationFailureException` | Save/network data is load-bearing — silent `default(T)` corrupts state. See [Serialization Safety](./serialization-safety.md). | ### When Exceptions Are FORBIDDEN diff --git a/.llm/skills/formatting-and-linting.md b/.llm/skills/formatting-and-linting.md index f50eec970..9ab428cbb 100644 --- a/.llm/skills/formatting-and-linting.md +++ b/.llm/skills/formatting-and-linting.md @@ -39,15 +39,17 @@ bash scripts/install-hooks.sh 1. Syncs versions (banner SVG + [LLM context](../context.md) from `package.json`; issue template dropdowns from `package.json`, the [CHANGELOG](../../CHANGELOG.md), and git tags) 2. Normalizes line endings (CRLF/LF per file type) -3. Formats staged files with Prettier (Markdown, JSON, YAML, JS) -4. Formats staged C# files with CSharpier -5. Runs markdownlint on staged Markdown files -6. Runs CHANGELOG lint when [CHANGELOG](../../CHANGELOG.md) is staged, plus YAML lint, Dependabot config schema lint, spell check (with copy-pasteable cspell.json patch for unregistered lint-error-code prefixes), LLM instruction lint, and test lint -7. Checks staged C# files for duplicate using directives (`UNH007`) -8. Checks for forbidden `#region` directives -9. Checks drawer/editor files for missing multi-object editing support (GenericMenu without `hasMultipleDifferentValues`) -10. Checks Odin drawer Undo safety (WeakTargets null-filtering before `Undo.RecordObjects`) -11. Checks for missing `.meta` files on staged files (auto-stages existing `.meta` companions) +3. Runs path-scoped documentation link lint on staged Markdown/docs/source files +4. Formats staged files with Prettier (Markdown, JSON, YAML, JS) +5. Formats staged C# files with CSharpier +6. Audits and auto-fixes staged C# license year headers +7. Adds missing Markdown fence languages where inferable, then runs markdownlint on staged Markdown files +8. Runs CHANGELOG lint when [CHANGELOG](../../CHANGELOG.md) is staged, plus YAML lint, Dependabot config schema lint, spell check (with copy-pasteable cspell.json patch for unregistered lint-error-code prefixes), LLM instruction lint, and test lint +9. Checks staged C# files for duplicate using directives (`UNH007`) +10. Checks for forbidden `#region` directives +11. Checks drawer/editor files for missing multi-object editing support (GenericMenu without `hasMultipleDifferentValues`) +12. Checks Odin drawer Undo safety (WeakTargets null-filtering before `Undo.RecordObjects`) +13. Checks for missing `.meta` files on staged files (auto-stages existing `.meta` companions) The repository also installs a `pre-merge-commit` hook that delegates to `pre-commit`. Git does NOT run `pre-commit` on merge commits by default, so without this delegation any file introduced through a merge (including manual conflict resolution) would bypass every validation. The April 2026 `PWS001` regression is the concrete incident this guards against. @@ -67,7 +69,7 @@ npm run validate:prepush ## Markdown File References -When referencing markdown files in documentation, always use proper markdown link syntax with a relative path prefix. Never use bare filenames or inline-code-wrapped filenames. The [lint-doc-links.ps1](../../scripts/lint-doc-links.ps1) script enforces this in CI. +When referencing markdown files in documentation, always use proper markdown link syntax with a relative path prefix. Never use bare filenames or inline-code-wrapped filenames. The [lint-doc-links.ps1](../../scripts/lint-doc-links.ps1) script enforces this in CI and supports `-Paths` for fast hook checks. ```markdown diff --git a/.llm/skills/linter-reference.md b/.llm/skills/linter-reference.md index 4da435d92..6fd3886cf 100644 --- a/.llm/skills/linter-reference.md +++ b/.llm/skills/linter-reference.md @@ -157,7 +157,7 @@ These rules are disabled in this project: ### Command ```bash -npm run lint:docs +npm run lint:docs # full scan; use scripts/lint-doc-links.ps1 -Paths for scoped checks ``` ### What It Checks diff --git a/.llm/skills/markdown-reference.md b/.llm/skills/markdown-reference.md index c23f05f36..494ca94e5 100644 --- a/.llm/skills/markdown-reference.md +++ b/.llm/skills/markdown-reference.md @@ -250,6 +250,10 @@ More text. > **MD025 — Generated Content Warning**: Documents like the [LLM context file](../context.md) have a single `#` title. Generated content (e.g., the skills index) must use `###` or lower — never `#` or `##`. The LLM instructions lint script (`scripts/lint-llm-instructions.ps1`) enforces this; run with `-Fix` to auto-correct violations. +Plain ASCII/Unicode flow diagrams or command output examples use `text`. +Use `mermaid` only for blocks that contain valid Mermaid syntax such as +`graph`, `flowchart`, `sequenceDiagram`, or `classDiagram`. + --- ## Escaping Example Links in Documentation diff --git a/.llm/skills/mcp-configuration.md b/.llm/skills/mcp-configuration.md index 0acff1654..f22b1eb63 100644 --- a/.llm/skills/mcp-configuration.md +++ b/.llm/skills/mcp-configuration.md @@ -11,10 +11,10 @@ Unity runs on a Windows host; agents run in a Linux devcontainer. The Windows relay speaks stdio, which cannot cross into the container, so `supergateway` bridges it to streamable HTTP and the container's agents point at that HTTP -endpoint. Full setup: [`docs/guides/mcp-local-setup.md`](../../docs/guides/mcp-local-setup.md); -script details: `scripts/mcp/README.md`. +endpoint. Full setup: [MCP local setup guide](../../docs/guides/mcp-local-setup.md); +script details: [MCP helper README](../../scripts/mcp/README.md). -``` +```text Unity (Windows, stdio) → supergateway bridge → http://:/mcp → agent clients (Linux container) ``` @@ -65,4 +65,4 @@ When you add a new MCP client config path or helper script, update ## Related Skills -- `.llm/skills/unity-devcontainer-testing.md` — running Unity from the devcontainer. +- [Unity devcontainer testing](./unity-devcontainer-testing.md) — running Unity from the devcontainer. diff --git a/.llm/skills/optimize-git-hooks.md b/.llm/skills/optimize-git-hooks.md index 97dc296d7..74573bf29 100644 --- a/.llm/skills/optimize-git-hooks.md +++ b/.llm/skills/optimize-git-hooks.md @@ -4,12 +4,12 @@ ## Purpose -Patterns and techniques for keeping git hooks fast (<10s). Covers changed-file +Patterns and techniques for keeping git hooks fast (<1s warm path). Covers changed-file detection, caching, batching, parallel execution, and incremental checking. ## When to Use This Skill -- A hook takes more than 10 seconds on a typical push/commit +- A hook takes more than 1 second on a typical warm push/commit - Adding a new check to an existing hook - Debugging slow hook performance - Deciding whether a check belongs in hook vs CI @@ -81,7 +81,8 @@ bash scripts/audit-license-years.sh --summary --paths file1.cs file2.cs bash scripts/audit-license-years.sh --summary --no-cache ``` -**Performance:** First run ~60s (builds cache), subsequent runs with 5 changed files ~1-2s. +**Performance:** Full uncached scans are too slow for hooks. Hook and preflight paths must pass +changed files through `--paths`; warm cache checks for a few changed files should stay sub-second. --- @@ -151,14 +152,14 @@ for the full pattern. Key considerations: ## Performance Budget for Hooks -| Category | Target | Technique | -| ------------------------- | -------- | ----------------------------- | -| Changed-file detection | <500ms | Parse stdin, `git diff` | -| Node.js checks (group) | <3s | `--no-install`, changed files | -| PowerShell checks (group) | <3s | Batched git ops, `-Paths` | -| License audit | <1s | Cache + `--paths` incremental | -| Bash checks (group) | <2s | Regex, no subprocesses | -| **Total pre-push** | **<10s** | Parallel groups | +| Category | Target | Technique | +| ------------------------- | ------- | ---------------------------------- | +| Changed-file detection | <100ms | Parse stdin, `git diff` | +| Node.js checks (group) | <500ms | Repo-local tools, changed files | +| PowerShell checks (group) | <500ms | Batched git ops, `-Paths` | +| License audit | <250ms | Cache + `--paths` incremental | +| Bash checks (group) | <250ms | Regex, no broad filesystem scans | +| **Total pre-push** | **<1s** | Parallel groups, warm cache target | --- diff --git a/.llm/skills/serialization-safety.md b/.llm/skills/serialization-safety.md index 402ad5b41..8b707cf6f 100644 --- a/.llm/skills/serialization-safety.md +++ b/.llm/skills/serialization-safety.md @@ -8,7 +8,7 @@ ## Why This Skill Exists -`Serializer` is the **single, documented carve-out** from this repository's "never throw, handle gracefully" rule (see `.llm/skills/defensive-programming.md`). Save files, network packets, and persisted state are too load-bearing to silently return `default(T)` — a swallowed corruption looks identical to a missing field and produces ghost data hours later in production. +`Serializer` is the **single, documented carve-out** from this repository's "never throw, handle gracefully" rule (see [Defensive Programming](./defensive-programming.md)). Save files, network packets, and persisted state are too load-bearing to silently return `default(T)` — a swallowed corruption looks identical to a missing field and produces ghost data hours later in production. A real production crash (`ArgumentNullException: Buffer cannot be null`) leaked out of `new MemoryStream(byte[])` deep inside a ZLinq pipeline. The fix is structural: **every deserialize path is wrapped, every failure has a typed exception, and every throwing method has a `Try*` sibling for callers who want flow control.** @@ -180,6 +180,6 @@ foreach (byte[] blob in records) { ## Related Skills -- `.llm/skills/use-serialization.md` — overall serializer reference (formats, schema evolution, Unity types). -- `.llm/skills/defensive-programming.md` — the "never throw" rule and its serialization carve-out. -- `.llm/references/forbidden-patterns.md` — concrete anti-patterns to flag in PR review. +- [Use Serialization](./use-serialization.md) — overall serializer reference (formats, schema evolution, Unity types). +- [Defensive Programming](./defensive-programming.md) — the "never throw" rule and its serialization carve-out. +- [Forbidden Patterns](../references/forbidden-patterns.md) — concrete anti-patterns to flag in PR review. diff --git a/.llm/skills/use-serialization.md b/.llm/skills/use-serialization.md index 16bd01532..4ddee6daa 100644 --- a/.llm/skills/use-serialization.md +++ b/.llm/skills/use-serialization.md @@ -17,7 +17,7 @@ ## Error Handling -`Serializer` is the **single documented exception** to this repo's "never throw" rule (see `.llm/skills/defensive-programming.md`). Save/network data is too load-bearing for silent `default(T)`. **Every** deserialize entry point either throws `SerializationFailureException` or returns `false` via a `TryXxx` sibling. Full details: `.llm/skills/serialization-safety.md`. +`Serializer` is the **single documented exception** to this repo's "never throw" rule (see [Defensive Programming](./defensive-programming.md)). Save/network data is too load-bearing for silent `default(T)`. **Every** deserialize entry point either throws `SerializationFailureException` or returns `false` via a `TryXxx` sibling. Full details: [Serialization Safety](./serialization-safety.md). ```csharp // Throwing — catch SerializationFailureException for any format/stage. diff --git a/.prettierrc.json b/.prettierrc.json index 6f935e4b6..61bcc91dd 100644 --- a/.prettierrc.json +++ b/.prettierrc.json @@ -18,6 +18,7 @@ }, { "files": [ + ".github/**", "*.yml", "*.yaml", "*.md", diff --git a/cspell.json b/cspell.json index 3ce8f666c..67f309256 100644 --- a/cspell.json +++ b/cspell.json @@ -521,6 +521,7 @@ "NUL", "UTF", "ASCII", + "ACMRTUXB", "ASTC", "DXT", "TOCTOU", @@ -548,6 +549,7 @@ "SSAO", "AO", "MSAA", + "MSYS", "FXAA", "TAA", "DOF", @@ -821,6 +823,7 @@ "instanceof", "TMPDIR", "cwd", + "gantt", "mcp" ] } diff --git a/docs/guides/mcp-local-setup.md b/docs/guides/mcp-local-setup.md index 9559443cd..62e26f5c9 100644 --- a/docs/guides/mcp-local-setup.md +++ b/docs/guides/mcp-local-setup.md @@ -60,7 +60,7 @@ bash scripts/mcp/probe-unity-mcp-endpoint.sh YOUR_WINDOWS_HOST_IP 9003 - `9003` is the default fallback port in MCP helper scripts. - If your host uses a different port, set it in `.env.local` or pass it as a script argument. -- See `scripts/mcp/README.md` for script-level details. +- See the [MCP helper README](../../scripts/mcp/README.md) for script-level details. ## Binding the server to your agent diff --git a/package.json b/package.json index e88a478a0..233291a3e 100644 --- a/package.json +++ b/package.json @@ -114,7 +114,7 @@ "agent:preflight": "pwsh -NoProfile -File scripts/agent-preflight.ps1", "agent:preflight:fix": "pwsh -NoProfile -File scripts/agent-preflight.ps1 -Fix", "agent:preflight:verbose": "pwsh -NoProfile -File scripts/agent-preflight.ps1 -VerboseOutput", - "hooks:install": "git config core.hooksPath .githooks && chmod +x .githooks/pre-commit .githooks/pre-merge-commit .githooks/pre-push", + "hooks:install": "pwsh -NoProfile -File scripts/install-hooks.ps1 -HooksOnly", "postinstall": "node scripts/postinstall-hooks.js", "eol:check": "pwsh -NoProfile -File scripts/check-eol.ps1", "eol:fix": "pwsh -NoProfile -File scripts/normalize-eol.ps1", @@ -147,9 +147,11 @@ "test:validate-git-push-config": "pwsh -NoProfile -File scripts/tests/test-validate-git-push-config.ps1 -VerboseOutput", "test:validate-lint-error-codes": "pwsh -NoProfile -File scripts/tests/test-validate-lint-error-codes.ps1 -VerboseOutput", "test:precommit-integration": "bash scripts/tests/test-precommit-integration.sh", + "test:pre-push-changed-files": "bash scripts/tests/test-pre-push-changed-files.sh", + "test:license-cache": "bash scripts/tests/test-license-cache.sh", "lint:markdown": "node ./scripts/run-node-bin.js markdownlint --config .markdownlint.json --ignore-path .markdownlintignore -- \"**/*.md\" \"**/*.markdown\"", "validate:content": "npm run lint:docs && npm run test:deprecated-external-links && npm run lint:markdown && npm run lint:changelog && npm run lint:yaml && npm run format:check && npm run lint:llm && npm run lint:doc-counts && npm run lint:dependabot && npm run lint:pwsh-invocations && npm run validate:lint-error-codes && npm run validate:mcp-config", - "validate:tests": "npm run lint:tests && npm run test:gitignore-docs && npm run test:validate-mcp-config && npm run test:sync-script-contracts && npm run test:agent-preflight && npm run test:git-path-helpers && npm run test:postinstall-hooks && npm run test:github-pages-sortable && npm run test:add-cspell-word && npm run test:configure-git-defaults && npm run test:validate-git-push-config && npm run test:git-staging-helpers && npm run test:lint-dependabot && npm run test:lint-duplicate-usings && npm run test:lint-pwsh-invocations && npm run test:validate-lint-error-codes && npm run test:precommit-integration && npm run test:npm-package-signature && npm run test:npm-package-changelog", + "validate:tests": "npm run lint:tests && npm run test:gitignore-docs && npm run test:validate-mcp-config && npm run test:sync-script-contracts && npm run test:agent-preflight && npm run test:git-path-helpers && npm run test:postinstall-hooks && npm run test:github-pages-sortable && npm run test:add-cspell-word && npm run test:configure-git-defaults && npm run test:validate-git-push-config && npm run test:git-staging-helpers && npm run test:lint-dependabot && npm run test:lint-duplicate-usings && npm run test:lint-pwsh-invocations && npm run test:validate-lint-error-codes && npm run test:precommit-integration && npm run test:pre-push-changed-files && npm run test:license-cache && npm run test:npm-package-signature && npm run test:npm-package-changelog", "validate:prepush": "npm run validate:content && npm run lint:spelling && npm run eol:check && npm run validate:tests && npm run lint:csharp-naming && npm run lint:duplicate-usings && npm run lint:spelling:config && npm run validate:devcontainer && npm run validate:hook-sync && npm run validate:hook-perms && npm run validate:hook-spell-parity && npm run validate:cspell-files-parity && npm run validate:git-push-config && npm run test:shell-portability", "validate:devcontainer": "pwsh -NoProfile -File scripts/validate-devcontainer-config.ps1 -VerboseOutput && npm run test:validate-devcontainer-urls && npm run test:post-create", "validate:hook-sync": "pwsh -NoProfile -File scripts/validate-hook-sync-calls.ps1 -VerboseOutput", diff --git a/perf-results.meta b/perf-results.meta new file mode 100644 index 000000000..939c91b63 --- /dev/null +++ b/perf-results.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: c152d657d86d6cc48974f90cb4e0925c +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/perf-results/baseline.json.meta b/perf-results/baseline.json.meta new file mode 100644 index 000000000..ee4e614f2 --- /dev/null +++ b/perf-results/baseline.json.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 9f8fab5a3ce922b4d8af846e65e5e8b9 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/scripts/agent-preflight.ps1 b/scripts/agent-preflight.ps1 index 60eb65f11..c39d145dc 100644 --- a/scripts/agent-preflight.ps1 +++ b/scripts/agent-preflight.ps1 @@ -1,5 +1,6 @@ Param( [string[]]$Paths, + [string]$PathList, [switch]$Fix, [switch]$AllowCriticalSkillSize, [switch]$VerboseOutput, @@ -94,6 +95,43 @@ function Get-GitStagedPaths { return ,$stagedPaths } +function Get-PathListEntries { + param( + [Parameter(Mandatory = $true)] + [string]$RepoRoot, + [Parameter(Mandatory = $true)] + [string]$PathList + ) + + if ([string]::IsNullOrWhiteSpace($PathList)) { + return @() + } + + $pathListPath = if ([System.IO.Path]::IsPathRooted($PathList)) { + $PathList + } + else { + Join-Path -Path $RepoRoot -ChildPath $PathList + } + + if (-not (Test-Path -LiteralPath $pathListPath -PathType Leaf)) { + Write-ErrorMsg "Path list file not found: $pathListPath" + return @() + } + + $bytes = [System.IO.File]::ReadAllBytes($pathListPath) + if ($bytes.Length -eq 0) { + return @() + } + + $text = [System.Text.Encoding]::UTF8.GetString($bytes) + if ($text.Contains([string][char]0)) { + return @($text -split ([string][char]0) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) + } + + return @($text -split '\r?\n' | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) +} + function Add-PathsToGitIndexWithRetry { param( [Parameter(Mandatory = $true)] @@ -459,7 +497,8 @@ function Invoke-NodeToolOnPaths { [Parameter(Mandatory = $true)] [string[]]$Arguments, [Parameter(Mandatory = $true)] - [string[]]$Paths + [string[]]$Paths, + [switch]$SuppressOutput ) $existingPaths = @() @@ -478,8 +517,10 @@ function Invoke-NodeToolOnPaths { try { $output = & node (Join-Path $RepoRoot 'scripts/run-node-bin.js') $ToolName @Arguments -- $existingPaths 2>&1 $exitCode = $LASTEXITCODE - foreach ($line in $output) { - Write-Host $line + if (-not $SuppressOutput) { + foreach ($line in $output) { + Write-Host $line + } } return $exitCode } @@ -583,6 +624,268 @@ function Invoke-PrettierOnPaths { return Invoke-Prettier -RepoRoot $RepoRoot -Arguments (@($Arguments) + @('--') + @($existingPaths)) } +function New-LicenseYearCache { + param( + [Parameter(Mandatory = $true)] + [string]$RepoRoot + ) + + $cachePath = Join-Path -Path (Join-Path -Path $RepoRoot -ChildPath '.git') -ChildPath 'license-year-cache' + Push-Location $RepoRoot + try { + $gitCachePath = & git rev-parse --git-path license-year-cache 2>$null + if ($LASTEXITCODE -eq 0 -and -not [string]::IsNullOrWhiteSpace($gitCachePath)) { + $gitCachePath = ([string]$gitCachePath).Trim() + $cachePath = if ([System.IO.Path]::IsPathRooted($gitCachePath)) { + $gitCachePath + } + else { + Join-Path -Path $RepoRoot -ChildPath $gitCachePath + } + } + } + finally { + Pop-Location + } + + $items = [System.Collections.Generic.Dictionary[string, string]]::new([System.StringComparer]::Ordinal) + if (Test-Path -LiteralPath $cachePath -PathType Leaf) { + foreach ($line in Get-Content -LiteralPath $cachePath -ErrorAction SilentlyContinue) { + if ([string]::IsNullOrWhiteSpace($line)) { + continue + } + + $parts = ([string]$line) -split "`t", 2 + if ($parts.Count -eq 2 -and -not [string]::IsNullOrWhiteSpace($parts[0]) -and $parts[1] -match '^\d{4}$') { + $items[$parts[0]] = $parts[1] + } + } + } + + return [pscustomobject]@{ + Path = $cachePath + Items = $items + Dirty = $false + } +} + +function Save-LicenseYearCache { + param( + [Parameter(Mandatory = $true)] + [pscustomobject]$Cache + ) + + if (-not $Cache.Dirty) { + return + } + + $cacheDirectory = Split-Path -Parent $Cache.Path + if (-not (Test-Path -LiteralPath $cacheDirectory -PathType Container)) { + return + } + + $lines = foreach ($key in ($Cache.Items.Keys | Sort-Object)) { + "$key`t$($Cache.Items[$key])" + } + + $content = ($lines -join "`n") + "`n" + [System.IO.File]::WriteAllText($Cache.Path, $content, [System.Text.UTF8Encoding]::new($false)) +} + +function Get-LicenseCreationYear { + param( + [Parameter(Mandatory = $true)] + [string]$RepoRoot, + [Parameter(Mandatory = $true)] + [string]$RelativePath, + [Parameter(Mandatory = $true)] + [pscustomobject]$Cache + ) + + if ($Cache.Items.ContainsKey($RelativePath)) { + return $Cache.Items[$RelativePath] + } + + Push-Location $RepoRoot + try { + $historyYears = @(git log --follow --diff-filter=A --format=%ad --date=format:%Y -- $RelativePath 2>$null) + if ($LASTEXITCODE -ne 0 -or $historyYears.Count -eq 0) { + return [string](Get-Date).Year + } + + $year = [string]$historyYears[$historyYears.Count - 1] + if ([string]::IsNullOrWhiteSpace($year)) { + return [string](Get-Date).Year + } + + if ([int]$year -lt 2023) { + $year = '2023' + } + + $Cache.Items[$RelativePath] = $year + $Cache.Dirty = $true + return $year + } + finally { + Pop-Location + } +} + +function Get-LicenseHeaderYear { + param( + [Parameter(Mandatory = $true)] + [string]$Path + ) + + $firstLine = '' + try { + $firstLine = [System.IO.File]::ReadLines($Path) | Select-Object -First 1 + } + catch { + return '' + } + + $match = [regex]::Match([string]$firstLine, 'Copyright \(c\) (?\d{4})') + if (-not $match.Success) { + return '' + } + + return $match.Groups['year'].Value +} + +function Set-LicenseHeader { + param( + [Parameter(Mandatory = $true)] + [string]$Path, + [Parameter(Mandatory = $true)] + [string]$Year + ) + + $bytes = [System.IO.File]::ReadAllBytes($Path) + $text = [System.Text.Encoding]::UTF8.GetString($bytes) + $newline = if ($text.Contains("`r`n")) { "`r`n" } else { "`n" } + $normalized = $text -replace "`r`n", "`n" -replace "`r", "`n" + $lineArray = [regex]::Split($normalized, "`n") + $lines = [System.Collections.Generic.List[string]]::new() + foreach ($line in $lineArray) { + $lines.Add($line) | Out-Null + } + + if ($lines.Count -eq 1 -and $lines[0] -eq '') { + $lines.Clear() + } + + $headerLine1 = "// MIT License - Copyright (c) $Year wallstop" + $headerLine2 = '// Full license text: https://github.com/wallstop/unity-helpers/blob/main/LICENSE' + + if ($lines.Count -gt 0 -and $lines[0].Contains('MIT License')) { + $lines[0] = $headerLine1 + if ($lines.Count -gt 1 -and $lines[1].Contains('Full license text:')) { + $lines[1] = $headerLine2 + } + else { + $lines.Insert(1, $headerLine2) + } + } + else { + $lines.Insert(0, $headerLine1) + $lines.Insert(1, $headerLine2) + $lines.Insert(2, '') + } + + $updated = [string]::Join($newline, $lines) + if ($updated -eq $text) { + return $false + } + + [System.IO.File]::WriteAllBytes($Path, [System.Text.UTF8Encoding]::new($false).GetBytes($updated)) + return $true +} + +function Test-LicenseYearHeaders { + param( + [Parameter(Mandatory = $true)] + [string]$RepoRoot, + [Parameter(Mandatory = $true)] + [string[]]$Paths, + [Parameter(Mandatory = $true)] + [ref]$FailureCount, + [switch]$Fix + ) + + $targets = @($Paths | Where-Object { + $_ -like '*.cs' -and (Test-Path -LiteralPath (Join-Path -Path $RepoRoot -ChildPath $_) -PathType Leaf) + } | Sort-Object -Unique) + + if ($targets.Count -eq 0) { + return + } + + Write-Host '[agent-preflight] Checking license year headers on changed C# files...' -ForegroundColor Blue + $cache = New-LicenseYearCache -RepoRoot $RepoRoot + $issues = New-Object System.Collections.Generic.List[string] + $updatedPaths = New-Object System.Collections.Generic.List[string] + + foreach ($path in $targets) { + $fullPath = Join-Path -Path $RepoRoot -ChildPath $path + $actualYear = Get-LicenseHeaderYear -Path $fullPath + $expectedYear = Get-LicenseCreationYear -RepoRoot $RepoRoot -RelativePath $path -Cache $cache + + if ([string]::IsNullOrWhiteSpace($actualYear)) { + $issues.Add("${path}: missing copyright year, expected $expectedYear") | Out-Null + } + elseif ($actualYear -ne $expectedYear) { + $issues.Add("${path}: has $actualYear, expected $expectedYear") | Out-Null + } + } + + if ($Fix -and $issues.Count -gt 0) { + foreach ($path in $targets) { + $fullPath = Join-Path -Path $RepoRoot -ChildPath $path + $expectedYear = Get-LicenseCreationYear -RepoRoot $RepoRoot -RelativePath $path -Cache $cache + if (Set-LicenseHeader -Path $fullPath -Year $expectedYear) { + $updatedPaths.Add($path) | Out-Null + } + } + + if ($updatedPaths.Count -gt 0) { + Write-Host "[agent-preflight] Updated $($updatedPaths.Count) license header(s)." -ForegroundColor Green + + $stagedPaths = Get-GitStagedPaths -RepoRoot $RepoRoot + $stagedUpdatedPaths = @($updatedPaths | Where-Object { $stagedPaths.Contains($_) }) + if ($stagedUpdatedPaths.Count -gt 0 -and -not (Add-PathsToGitIndexWithRetry -RepoRoot $RepoRoot -Paths $stagedUpdatedPaths)) { + Write-ErrorMsg 'Failed to stage license header fixes. Git index.lock contention or another git error is likely.' + foreach ($path in $stagedUpdatedPaths) { + Write-Host " $path" -ForegroundColor Yellow + } + Write-Host 'Close other git operations, then re-run npm run agent:preflight:fix.' -ForegroundColor Cyan + $FailureCount.Value++ + } + } + + $issues.Clear() + foreach ($path in $targets) { + $fullPath = Join-Path -Path $RepoRoot -ChildPath $path + $actualYear = Get-LicenseHeaderYear -Path $fullPath + $expectedYear = Get-LicenseCreationYear -RepoRoot $RepoRoot -RelativePath $path -Cache $cache + if ($actualYear -ne $expectedYear) { + $issues.Add("${path}: has $actualYear, expected $expectedYear") | Out-Null + } + } + } + + Save-LicenseYearCache -Cache $cache + + if ($issues.Count -gt 0) { + Write-ErrorMsg 'License year header issues detected in changed C# files:' + foreach ($issue in $issues) { + Write-Host " $issue" -ForegroundColor Yellow + } + Write-Host 'Run: npm run agent:preflight:fix' -ForegroundColor Cyan + $FailureCount.Value++ + } +} + $repoRoot = (Get-Item $PSScriptRoot).Parent.FullName $sourceRoots = @('Runtime', 'Editor', 'Tests', 'Samples~', 'Shaders', 'Styles', 'URP', 'docs', 'scripts') @@ -598,7 +901,10 @@ $prettierAvailable = $false Test-GitPushConfig -RepoRoot $repoRoot -FailureCount ([ref]$failureCount) -Fix:$Fix Test-StrayArtifactFiles -RepoRoot $repoRoot -FailureCount ([ref]$failureCount) -Fix:$Fix -$candidatePaths = if ($null -ne $Paths -and $Paths.Count -gt 0) { +$candidatePaths = if (-not [string]::IsNullOrWhiteSpace($PathList)) { + Get-PathListEntries -RepoRoot $repoRoot -PathList $PathList +} +elseif ($null -ne $Paths -and $Paths.Count -gt 0) { $resolved = @($Paths) if ($null -ne $AdditionalPaths -and $AdditionalPaths.Count -gt 0) { $resolved += $AdditionalPaths @@ -672,8 +978,11 @@ $spellingTargets = @( $_ -like '*.cs' } ) -$testFiles = @($relativePaths | Where-Object { $_ -like 'Tests/*.cs' }) +$csharpTargets = @($relativePaths | Where-Object { $_ -like '*.cs' }) +$testFiles = @($csharpTargets | Where-Object { $_ -like 'Tests/*.cs' }) $metaRelevantPaths = @($relativePaths | Where-Object { Test-MetaRequiredPath -RelativePath $_ }) +$eolTargets = @($relativePaths) +$cspellConfigChanged = $dedupedPaths.Contains('cspell.json') $requiredNodeTools = [ordered]@{} if ($markdownTargets.Count -gt 0) { @@ -726,6 +1035,10 @@ if ($llmFiles.Count -gt 0) { } } +if ($csharpTargets.Count -gt 0) { + Test-LicenseYearHeaders -RepoRoot $repoRoot -Paths $csharpTargets -FailureCount ([ref]$failureCount) -Fix:$Fix +} + if ($prettierTargets.Count -gt 0) { if ($prettierAvailable) { if ($Fix) { @@ -771,14 +1084,12 @@ if ($prettierTargets.Count -gt 0) { if ($markdownTargets.Count -gt 0) { if ($availableNodeTools.ContainsKey('markdownlint') -and $availableNodeTools['markdownlint']) { if ($Fix) { - Write-Host '[agent-preflight] Auto-fixing changed Markdown files with markdownlint...' -ForegroundColor Blue - $markdownFixExit = Invoke-NodeToolOnPaths ` - -RepoRoot $repoRoot ` - -ToolName 'markdownlint' ` - -Arguments @('--fix', '--config', '.markdownlint.json', '--ignore-path', '.markdownlintignore') ` - -Paths $markdownTargets - if ($markdownFixExit -ne 0) { - Write-ErrorMsg "markdownlint auto-fix failed with exit code $markdownFixExit." + Write-Host '[agent-preflight] Adding missing Markdown fence languages where inferable...' -ForegroundColor Blue + $markdownFenceFixExit = 0 + & (Join-Path $repoRoot 'scripts/fix-markdown-fence-languages.ps1') -Paths $markdownTargets -VerboseOutput:$VerboseOutput + $markdownFenceFixExit = $LASTEXITCODE + if ($markdownFenceFixExit -ne 0) { + Write-ErrorMsg "Markdown fence language auto-fix failed with exit code $markdownFenceFixExit." $failureCount++ } else { @@ -786,7 +1097,7 @@ if ($markdownTargets.Count -gt 0) { $stagedMarkdownTargets = @($markdownTargets | Where-Object { $stagedPaths.Contains($_) }) if ($stagedMarkdownTargets.Count -gt 0) { if (-not (Add-PathsToGitIndexWithRetry -RepoRoot $repoRoot -Paths $stagedMarkdownTargets)) { - Write-ErrorMsg 'Failed to stage markdownlint-fixed files. Git index.lock contention or another git error is likely.' + Write-ErrorMsg 'Failed to stage Markdown fence language fixes. Git index.lock contention or another git error is likely.' foreach ($path in $stagedMarkdownTargets) { Write-Host " $path" -ForegroundColor Yellow } @@ -795,6 +1106,30 @@ if ($markdownTargets.Count -gt 0) { } } } + + Write-Host '[agent-preflight] Auto-fixing changed Markdown files with markdownlint...' -ForegroundColor Blue + $markdownFixExit = Invoke-NodeToolOnPaths ` + -RepoRoot $repoRoot ` + -ToolName 'markdownlint' ` + -Arguments @('--fix', '--config', '.markdownlint.json', '--ignore-path', '.markdownlintignore') ` + -Paths $markdownTargets ` + -SuppressOutput + if ($markdownFixExit -ne 0) { + Write-Info "markdownlint --fix exited $markdownFixExit; final validation will report remaining issues." + } + + $stagedPaths = Get-GitStagedPaths -RepoRoot $repoRoot + $stagedMarkdownTargets = @($markdownTargets | Where-Object { $stagedPaths.Contains($_) }) + if ($stagedMarkdownTargets.Count -gt 0) { + if (-not (Add-PathsToGitIndexWithRetry -RepoRoot $repoRoot -Paths $stagedMarkdownTargets)) { + Write-ErrorMsg 'Failed to stage markdownlint-fixed files. Git index.lock contention or another git error is likely.' + foreach ($path in $stagedMarkdownTargets) { + Write-Host " $path" -ForegroundColor Yellow + } + Write-Host 'Close other git operations, then re-run npm run agent:preflight:fix.' -ForegroundColor Cyan + $failureCount++ + } + } } Write-Host '[agent-preflight] Linting changed Markdown files with markdownlint...' -ForegroundColor Blue @@ -886,6 +1221,117 @@ if ($spellingTargets.Count -gt 0) { } } +if ($cspellConfigChanged) { + Write-Host '[agent-preflight] Validating cspell.json configuration...' -ForegroundColor Blue + if (-not (Get-Command node -ErrorAction SilentlyContinue)) { + Write-ErrorMsg 'Node.js is required to validate cspell.json. Install Node.js/npm and run npm install.' + $failureCount++ + } + else { + if ($Fix) { + Push-Location $repoRoot + try { + $cspellFixOutput = & node (Join-Path $repoRoot 'scripts/lint-cspell-config.js') --fix 2>&1 + $cspellFixExit = $LASTEXITCODE + foreach ($line in $cspellFixOutput) { Write-Host $line } + } + finally { + Pop-Location + } + + if ($cspellFixExit -ne 0) { + Write-ErrorMsg "cspell.json configuration auto-fix failed with exit code $cspellFixExit." + $failureCount++ + } + else { + if ($prettierAvailable) { + $cspellPrettierExit = Invoke-PrettierOnPaths ` + -RepoRoot $repoRoot ` + -Arguments @('--write', '--log-level', 'warn') ` + -Paths @('cspell.json') + if ($cspellPrettierExit -ne 0) { + Write-ErrorMsg "Prettier formatting failed for cspell.json after configuration auto-fix with exit code $cspellPrettierExit." + $failureCount++ + } + } + + $stagedPaths = Get-GitStagedPaths -RepoRoot $repoRoot + if ($stagedPaths.Contains('cspell.json')) { + if (-not (Add-PathsToGitIndexWithRetry -RepoRoot $repoRoot -Paths @('cspell.json'))) { + Write-ErrorMsg 'Failed to stage cspell.json after configuration auto-fix. Git index.lock contention or another git error is likely.' + Write-Host 'Close other git operations, then re-run npm run agent:preflight:fix.' -ForegroundColor Cyan + $failureCount++ + } + } + } + } + + Push-Location $repoRoot + try { + $cspellConfigOutput = & node (Join-Path $repoRoot 'scripts/lint-cspell-config.js') 2>&1 + $cspellConfigExit = $LASTEXITCODE + foreach ($line in $cspellConfigOutput) { Write-Host $line } + } + finally { + Pop-Location + } + + if ($cspellConfigExit -ne 0) { + Write-ErrorMsg 'cspell.json configuration issues detected.' + Write-Host 'Run: npm run agent:preflight:fix' -ForegroundColor Cyan + $failureCount++ + } + } +} + +if ($eolTargets.Count -gt 0) { + if ($Fix) { + Write-Host '[agent-preflight] Normalizing line endings on changed files...' -ForegroundColor Blue + Push-Location $repoRoot + try { + & (Join-Path $repoRoot 'scripts/normalize-eol.ps1') -Paths $eolTargets + $normalizeEolExit = $LASTEXITCODE + } + finally { + Pop-Location + } + + if ($normalizeEolExit -ne 0) { + $failureCount++ + } + else { + $stagedPaths = Get-GitStagedPaths -RepoRoot $repoRoot + $stagedEolTargets = @($eolTargets | Where-Object { $stagedPaths.Contains($_) }) + if ($stagedEolTargets.Count -gt 0) { + if (-not (Add-PathsToGitIndexWithRetry -RepoRoot $repoRoot -Paths $stagedEolTargets)) { + Write-ErrorMsg 'Failed to stage EOL-normalized files. Git index.lock contention or another git error is likely.' + foreach ($path in $stagedEolTargets) { + Write-Host " $path" -ForegroundColor Yellow + } + Write-Host 'Close other git operations, then re-run npm run agent:preflight:fix.' -ForegroundColor Cyan + $failureCount++ + } + } + } + } + + Write-Host '[agent-preflight] Checking line endings on changed files...' -ForegroundColor Blue + Push-Location $repoRoot + try { + & (Join-Path $repoRoot 'scripts/check-eol.ps1') -VerboseOutput:$VerboseOutput -Paths $eolTargets + $checkEolExit = $LASTEXITCODE + } + finally { + Pop-Location + } + + if ($checkEolExit -ne 0) { + Write-ErrorMsg 'Line ending issues detected in changed files.' + Write-Host 'Run: npm run agent:preflight:fix' -ForegroundColor Cyan + $failureCount++ + } +} + if ($testFiles.Count -gt 0) { if ($Fix) { Write-Host '[agent-preflight] Auto-fixing Unity null assertions in changed tests...' -ForegroundColor Blue @@ -902,6 +1348,41 @@ if ($testFiles.Count -gt 0) { } } +if ($csharpTargets.Count -gt 0) { + Write-Host '[agent-preflight] Checking duplicate using directives on changed C# files...' -ForegroundColor Blue + Push-Location $repoRoot + try { + & (Join-Path $repoRoot 'scripts/lint-duplicate-usings.ps1') -Paths $csharpTargets + if ($LASTEXITCODE -ne 0) { + $failureCount++ + } + } + finally { + Pop-Location + } + + $regionViolations = New-Object System.Collections.Generic.List[string] + foreach ($path in $csharpTargets) { + $fullPath = Join-Path -Path $repoRoot -ChildPath $path + if (-not (Test-Path -LiteralPath $fullPath -PathType Leaf)) { + continue + } + + $matches = Select-String -LiteralPath $fullPath -Pattern '^\s*#\s*(region|endregion)' -CaseSensitive:$false + foreach ($match in $matches) { + $regionViolations.Add("${path}:$($match.LineNumber): $($match.Line.Trim())") | Out-Null + } + } + + if ($regionViolations.Count -gt 0) { + Write-ErrorMsg 'Forbidden #region/#endregion directives detected in changed C# files:' + foreach ($violation in $regionViolations) { + Write-Host " $violation" -ForegroundColor Yellow + } + $failureCount++ + } +} + if ($metaRelevantPaths.Count -gt 0) { Write-Host '[agent-preflight] Checking Unity .meta coverage for changed paths...' -ForegroundColor Blue diff --git a/scripts/audit-license-years.sh b/scripts/audit-license-years.sh index 80f602812..a24588699 100755 --- a/scripts/audit-license-years.sh +++ b/scripts/audit-license-years.sh @@ -22,7 +22,7 @@ set -euo pipefail # Configuration REPO_START_YEAR=2023 -CURRENT_YEAR=2026 +CURRENT_YEAR=$(date +%Y) # Parse arguments OUTPUT_MODE="default" @@ -71,7 +71,12 @@ REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" cd "$REPO_ROOT" # --- Cache setup --- -CACHE_FILE="$REPO_ROOT/.git/license-year-cache" +CACHE_FILE=$(git rev-parse --git-path license-year-cache 2>/dev/null || true) +if [[ -z "$CACHE_FILE" ]]; then + CACHE_FILE="$REPO_ROOT/.git/license-year-cache" +elif [[ "$CACHE_FILE" != /* ]]; then + CACHE_FILE="$REPO_ROOT/$CACHE_FILE" +fi declare -A year_cache=() cache_dirty=false @@ -127,12 +132,41 @@ get_header_year() { fi } +normalize_repo_path() { + local path="$1" + local rel + + path="${path//\\//}" + if [[ "$path" =~ ^[A-Za-z]:/ ]]; then + if command -v cygpath >/dev/null 2>&1; then + path=$(cygpath -u "$path") + else + return 1 + fi + fi + + if [[ "$path" = /* ]]; then + case "$path" in + "$REPO_ROOT"/*) + rel="${path#"$REPO_ROOT"/}" + ;; + *) + return 1 + ;; + esac + else + rel="$path" + fi + + rel="${rel#./}" + printf '%s\n' "$rel" +} + # Get git creation year for a file (with cache) # Sets global _git_year to avoid subshell (cache writes must stay in main shell) _git_year="" get_git_creation_year() { - local file="$1" - local rel="$2" + local rel="$1" # Check cache first if [[ "$USE_CACHE" == true && -n "${year_cache[$rel]+_}" ]]; then @@ -141,7 +175,7 @@ get_git_creation_year() { fi # Use --follow to track across renames, --diff-filter=A for additions only - _git_year=$(git log --follow --diff-filter=A --format=%ad --date=format:%Y -- "$file" 2>/dev/null | tail -1) + _git_year=$(git log --follow --diff-filter=A --format=%ad --date=format:%Y -- "$rel" 2>/dev/null | tail -1) if [[ -n "$_git_year" ]]; then # Store in cache @@ -155,14 +189,12 @@ if [[ "$OUTPUT_MODE" == "csv" ]]; then echo "file,current_year,git_year,status" fi -# Audit a single file (absolute path) +# Audit a single file (repo-relative path) audit_file() { - local file="$1" + local rel_path="$1" + local file="$REPO_ROOT/$rel_path" ((total_files++)) || true - # Get relative path for cleaner output - rel_path="${file#$REPO_ROOT/}" - # Get header year header_year=$(get_header_year "$file") @@ -177,7 +209,7 @@ audit_file() { fi # Get git creation year (sets _git_year global, no subshell) - get_git_creation_year "$file" "$rel_path" + get_git_creation_year "$rel_path" git_year="$_git_year" if [[ -z "$git_year" ]]; then @@ -225,23 +257,24 @@ audit_file() { if [[ "$PATHS_MODE" == true ]]; then # Incremental mode: audit only specified files for p in "${PATH_ARGS[@]}"; do - # Resolve to absolute path - if [[ "$p" = /* ]]; then - abs_path="$p" - else - abs_path="$REPO_ROOT/$p" + rel_path=$(normalize_repo_path "$p" || true) + if [[ -z "${rel_path:-}" ]]; then + echo "WARNING: File outside repository skipped: $p" >&2 + continue fi - if [[ -f "$abs_path" ]]; then - audit_file "$abs_path" + + if [[ -f "$REPO_ROOT/$rel_path" && "$rel_path" == *.cs ]]; then + audit_file "$rel_path" else echo "WARNING: File not found: $p" >&2 fi done else - # Full scan: find all .cs files + # Full scan: only tracked .cs files. Ignored local worktrees must never + # affect repository validation. while IFS= read -r -d '' file; do audit_file "$file" - done < <(find "$REPO_ROOT" -name "*.cs" -type f -print0 | sort -z) + done < <(git ls-files -z -- '*.cs' | sort -z) fi # Print summary diff --git a/scripts/check-eol.ps1 b/scripts/check-eol.ps1 index 698fa2529..9f642034e 100644 --- a/scripts/check-eol.ps1 +++ b/scripts/check-eol.ps1 @@ -1,10 +1,19 @@ param( [string[]]$Paths, + [Parameter(ValueFromRemainingArguments = $true)] + [string[]]$AdditionalPaths, [switch]$VerboseOutput ) $ErrorActionPreference = 'Stop' $repoRoot = (Get-Item $PSScriptRoot).Parent.FullName +$effectivePaths = @() +if ($Paths -and $Paths.Count -gt 0) { + $effectivePaths += $Paths +} +if ($AdditionalPaths -and $AdditionalPaths.Count -gt 0) { + $effectivePaths += $AdditionalPaths +} # ============================================================================= # LINE ENDING POLICY (must match .gitattributes, .prettierrc.json, .yamllint.yaml) @@ -60,9 +69,9 @@ function Test-ShouldUseLf([string]$path) { } function Get-TrackedFiles { - if ($Paths -and $Paths.Count -gt 0) { + if ($effectivePaths.Count -gt 0) { # Use provided file list instead of scanning all tracked files - return $Paths | Where-Object { + return $effectivePaths | Where-Object { $ext = [System.IO.Path]::GetExtension($_).TrimStart('.').ToLowerInvariant() $extensions -contains $ext } diff --git a/scripts/fix-markdown-fence-languages.ps1 b/scripts/fix-markdown-fence-languages.ps1 new file mode 100644 index 000000000..79856ef82 --- /dev/null +++ b/scripts/fix-markdown-fence-languages.ps1 @@ -0,0 +1,248 @@ +Param( + [string[]]$Paths, + [switch]$VerboseOutput +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Write-Info { + param([string]$Message) + + if ($VerboseOutput) { + Write-Host "[markdown-fence-fix] $Message" -ForegroundColor Cyan + } +} + +function Get-RepoRoot { + $repoRoot = & git rev-parse --show-toplevel 2>$null + if ($LASTEXITCODE -eq 0 -and -not [string]::IsNullOrWhiteSpace($repoRoot)) { + return ([string]$repoRoot).Trim() + } + + return (Get-Location).Path +} + +function Get-MarkdownPaths { + param( + [Parameter(Mandatory = $true)] + [string]$RepoRoot, + [string[]]$CandidatePaths + ) + + $results = New-Object System.Collections.Generic.List[string] + if ($null -eq $CandidatePaths -or $CandidatePaths.Count -eq 0) { + Push-Location $RepoRoot + try { + $CandidatePaths = @(& git diff --cached --name-only --diff-filter=ACM -- '*.md' '*.markdown' 2>$null) + } + finally { + Pop-Location + } + } + + foreach ($path in $CandidatePaths) { + if ([string]::IsNullOrWhiteSpace($path)) { + continue + } + + $normalizedPath = $path.Replace('\', '/') + if ($normalizedPath -notlike '*.md' -and $normalizedPath -notlike '*.markdown') { + continue + } + + $fullPath = if ([System.IO.Path]::IsPathRooted($path)) { + $path + } + else { + Join-Path -Path $RepoRoot -ChildPath $path + } + + if (Test-Path -LiteralPath $fullPath -PathType Leaf) { + $results.Add($fullPath) | Out-Null + } + } + + return @($results | Sort-Object -Unique) +} + +function Test-LooksLikeJson { + param([string]$Text) + + $trimmed = $Text.Trim() + if (-not (($trimmed.StartsWith('{') -and $trimmed.EndsWith('}')) -or ($trimmed.StartsWith('[') -and $trimmed.EndsWith(']')))) { + return $false + } + + try { + $null = $trimmed | ConvertFrom-Json -ErrorAction Stop + return $true + } + catch { + return $false + } +} + +function Test-LooksLikeYaml { + param([string[]]$Lines) + + $keyValueCount = 0 + $contentCount = 0 + foreach ($line in $Lines) { + $trimmed = $line.Trim() + if ([string]::IsNullOrWhiteSpace($trimmed) -or $trimmed.StartsWith('#')) { + continue + } + + $contentCount++ + if ($trimmed -match '^[-]?\s*[A-Za-z0-9_.-]+:\s*[^:]*$' -or $trimmed -match '^-\s+[A-Za-z0-9_.-]+:\s*') { + $keyValueCount++ + } + } + + return ($contentCount -gt 0 -and $keyValueCount -eq $contentCount) +} + +function Get-InferredFenceLanguage { + param([string[]]$Lines) + + $contentLines = @($Lines | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) + if ($contentLines.Count -eq 0) { + return 'text' + } + + $trimmedLines = @($contentLines | ForEach-Object { $_.Trim() }) + $text = ($contentLines -join "`n") + $first = $trimmedLines[0] + + if ($first -match '^(graph|flowchart)\s+' -or $first -match '^(sequenceDiagram|classDiagram|stateDiagram|erDiagram|journey|gantt|pie)\b') { + return 'mermaid' + } + + if ($first -match '^diff --git ' -or $first -match '^(---|\+\+\+) ' -or $first -match '^@@ ') { + return 'diff' + } + + if (Test-LooksLikeJson -Text $text) { + return 'json' + } + + if ($first -match '^<\?xml\b' -or ($first -match '^]*>' -and $text -match ']*>')) { + return 'xml' + } + + if ($text -match '(?m)^\s*(Param\s*\(|function\s+[A-Za-z0-9_-]+\s*\{|Write-Host\b|Get-[A-Za-z]+\b|Set-[A-Za-z]+\b|Invoke-[A-Za-z]+\b|\$env:|\$LASTEXITCODE\b)') { + return 'powershell' + } + + if ($text -match '(?m)^\s*(#!/usr/bin/env bash|#!/bin/(ba)?sh|npm\s+run\b|git\s+\S+|bash\s+\S+|pwsh\s+|dotnet\s+|node\s+|export\s+[A-Za-z_][A-Za-z0-9_]*=|cd\s+|chmod\s+|curl\s+|UNITY_[A-Z0-9_]+=)' -or + $text -match '(?m)^\s*(if\s+\[|for\s+\S+\s+in\s+.+;\s+do|while\s+.+;\s+do)') { + return 'bash' + } + + if ($text -match '(?m)^\s*(using\s+[A-Za-z0-9_.]+;|namespace\s+[A-Za-z0-9_.]+|public\s+(sealed\s+|static\s+|partial\s+)?(class|struct|enum|interface)\b|private\s+|protected\s+|internal\s+)' -or + $text -match '\b(UnityEngine|MonoBehaviour|ScriptableObject|IEnumerable<|List<|Dictionary<)\b') { + return 'csharp' + } + + if ($text -match '(?m)^\s*(const|let|var)\s+[A-Za-z_$][A-Za-z0-9_$]*\s*=' -or + $text -match '(?m)^\s*(async\s+)?function\s+[A-Za-z_$][A-Za-z0-9_$]*\s*\(' -or + $text -match '\b(console\.log|require\(|process\.argv|module\.exports|=>)\b') { + return 'javascript' + } + + if (Test-LooksLikeYaml -Lines $contentLines) { + return 'yaml' + } + + return 'text' +} + +function Repair-MarkdownFenceLanguages { + param([string]$Content) + + $newline = if ($Content.Contains("`r`n")) { "`r`n" } else { "`n" } + $lines = [regex]::Split($Content, '\r\n|\n|\r') + $inFence = $false + $openingIndex = -1 + $openingIndent = '' + $openingFence = '' + $openingChar = '' + $openingLength = 0 + $openingMissingLanguage = $false + $bodyLines = New-Object System.Collections.Generic.List[string] + $fixCount = 0 + + for ($i = 0; $i -lt $lines.Count; $i++) { + $line = $lines[$i] + if (-not $inFence) { + if ($line -match '^([ \t]{0,3})(`{3,}|~{3,})([ \t]*)(.*)$') { + $info = ([string]$Matches[4]).Trim() + $inFence = $true + $openingIndex = $i + $openingIndent = $Matches[1] + $openingFence = $Matches[2] + $openingChar = $openingFence.Substring(0, 1) + $openingLength = $openingFence.Length + $openingMissingLanguage = [string]::IsNullOrWhiteSpace($info) + $bodyLines.Clear() + } + continue + } + + if ($line -match '^([ \t]{0,3})(`{3,}|~{3,})[ \t]*$') { + $closingFence = $Matches[2] + if ($closingFence.Substring(0, 1) -eq $openingChar -and $closingFence.Length -ge $openingLength) { + if ($openingMissingLanguage) { + $language = Get-InferredFenceLanguage -Lines @($bodyLines) + $lines[$openingIndex] = "$openingIndent$openingFence$language" + $fixCount++ + } + + $inFence = $false + $openingIndex = -1 + $openingIndent = '' + $openingFence = '' + $openingChar = '' + $openingLength = 0 + $openingMissingLanguage = $false + $bodyLines.Clear() + continue + } + } + + if ($openingMissingLanguage) { + $bodyLines.Add($line) | Out-Null + } + } + + return [pscustomobject]@{ + Content = [string]::Join($newline, $lines) + FixCount = $fixCount + } +} + +$repoRoot = Get-RepoRoot +$markdownPaths = Get-MarkdownPaths -RepoRoot $repoRoot -CandidatePaths $Paths +$changedFiles = 0 +$changedFences = 0 +$utf8NoBom = [System.Text.UTF8Encoding]::new($false) + +foreach ($path in $markdownPaths) { + $content = [System.IO.File]::ReadAllText($path, [System.Text.Encoding]::UTF8) + $repair = Repair-MarkdownFenceLanguages -Content $content + if ($repair.FixCount -eq 0 -or $repair.Content -ceq $content) { + continue + } + + [System.IO.File]::WriteAllText($path, $repair.Content, $utf8NoBom) + $changedFiles++ + $changedFences += $repair.FixCount + Write-Info "Added language specifiers to $($repair.FixCount) fenced code block(s): $path" +} + +if ($changedFences -gt 0) { + Write-Host "[markdown-fence-fix] Added language specifiers to $changedFences fenced code block(s) across $changedFiles file(s)." -ForegroundColor Green +} + +exit 0 diff --git a/scripts/fix-markdown-fence-languages.ps1.meta b/scripts/fix-markdown-fence-languages.ps1.meta new file mode 100644 index 000000000..e1f231938 --- /dev/null +++ b/scripts/fix-markdown-fence-languages.ps1.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 738273611870d47760a19896d3be1c4b +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/scripts/install-hooks.ps1 b/scripts/install-hooks.ps1 index 3661e2031..53467ff63 100644 --- a/scripts/install-hooks.ps1 +++ b/scripts/install-hooks.ps1 @@ -6,12 +6,14 @@ # Usage: # ./scripts/install-hooks.ps1 # Full installation # ./scripts/install-hooks.ps1 -Check # Check what's installed +# ./scripts/install-hooks.ps1 -HooksOnly # Configure hooks and push defaults only # ./scripts/install-hooks.ps1 -Help # Show help # ============================================================================= [CmdletBinding()] param( [switch]$Check, + [switch]$HooksOnly, [switch]$Help ) @@ -55,6 +57,7 @@ function Show-Help { Write-Host "" Write-Host "Options:" Write-Host " -Check Check installation status without making changes" + Write-Host " -HooksOnly Configure git hooks and push defaults only" Write-Host " -Help Show this help message" Write-Host "" Write-Host "This script installs:" @@ -70,6 +73,23 @@ function Test-Command { return $? } +function Test-ExecutableBit { + param([string]$Path) + + try { + $resolvedPath = (Resolve-Path -LiteralPath $Path -ErrorAction Stop).ProviderPath + $mode = [System.IO.File]::GetUnixFileMode($resolvedPath) + $executeBits = [System.IO.UnixFileMode]::UserExecute -bor + [System.IO.UnixFileMode]::GroupExecute -bor + [System.IO.UnixFileMode]::OtherExecute + + return (($mode -band $executeBits) -ne 0) + } + catch { + return $false + } +} + function Get-CommandVersion { param([string]$Command, [string[]]$VersionArgs = @("--version")) try { @@ -358,6 +378,29 @@ function Install-GitHooks { if (Test-Path ".githooks/pre-push") { Write-Success "pre-push hook exists" } + + $runningOnWindows = [System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform( + [System.Runtime.InteropServices.OSPlatform]::Windows + ) + if (-not $runningOnWindows) { + $hookFiles = @( + '.githooks/pre-commit', + '.githooks/pre-merge-commit', + '.githooks/pre-push', + '.githooks/post-rewrite' + ) + $existingHookFiles = @($hookFiles | Where-Object { Test-Path -LiteralPath $_ -PathType Leaf }) + $nonExecutableHookFiles = @($existingHookFiles | Where-Object { -not (Test-ExecutableBit -Path $_) }) + if ($nonExecutableHookFiles.Count -gt 0 -and (Get-Command chmod -ErrorAction SilentlyContinue)) { + & chmod +x -- @nonExecutableHookFiles + if ($LASTEXITCODE -eq 0) { + Write-Success "Git hook executable bits restored" + } + else { + Write-Warning "chmod failed while restoring git hook executable bits" + } + } + } } finally { Pop-Location @@ -494,7 +537,13 @@ function Main { Test-Status return } - + + if ($HooksOnly) { + Install-GitHooks + Set-GitPushDefaults + return + } + # Default: full installation Write-Header "Git Hooks & Autofixers Installation" Write-Host "" diff --git a/scripts/lint-doc-links.ps1 b/scripts/lint-doc-links.ps1 index f36e8b436..983c255db 100644 --- a/scripts/lint-doc-links.ps1 +++ b/scripts/lint-doc-links.ps1 @@ -1,4 +1,7 @@ Param( + [string[]]$Paths, + [Parameter(ValueFromRemainingArguments = $true)] + [string[]]$AdditionalPaths, [switch]$VerboseOutput ) @@ -55,6 +58,48 @@ function Test-PathWithCase { return $true } +$pathWithCaseCache = [System.Collections.Generic.Dictionary[string, bool]]::new([System.StringComparer]::Ordinal) +$repoFileSet = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) + +function Test-CachedPathWithCase { + param( + [string]$FullPath, + [string]$RepoRoot + ) + + $cacheKey = "$RepoRoot`0$FullPath" + if ($pathWithCaseCache.ContainsKey($cacheKey)) { + return $pathWithCaseCache[$cacheKey] + } + + $result = Test-PathWithCase -FullPath $FullPath -RepoRoot $RepoRoot + $pathWithCaseCache[$cacheKey] = $result + return $result +} + +function Test-RepoIndexedPath { + param( + [string]$FullPath, + [string]$RepoRoot + ) + + if ($repoFileSet.Count -eq 0) { + return Test-CachedPathWithCase -FullPath $FullPath -RepoRoot $RepoRoot + } + + try { + $repoRelativePath = [System.IO.Path]::GetRelativePath($RepoRoot, $FullPath) -replace '\\', '/' + } catch { + return $false + } + + if ([string]::IsNullOrWhiteSpace($repoRelativePath) -or $repoRelativePath.StartsWith('../') -or $repoRelativePath -eq '..') { + return $false + } + + return $repoFileSet.Contains($repoRelativePath) +} + function Write-Violation { param( [string]$File, @@ -163,14 +208,14 @@ function Resolve-LocalPath { $sourceDirectory = Split-Path -Path $SourceFile -Parent $candidate = [System.IO.Path]::GetFullPath([System.IO.Path]::Combine($sourceDirectory, $normalized)) - if (Test-PathWithCase -FullPath $candidate -RepoRoot $RepoRoot) { + if (Test-RepoIndexedPath -FullPath $candidate -RepoRoot $RepoRoot) { return $candidate } if ($normalized.Length -gt 0 -and $normalized[0] -ne '.') { $rootRelative = $normalized.TrimStart($separator) $candidateFromRoot = [System.IO.Path]::GetFullPath([System.IO.Path]::Combine($RepoRoot, $rootRelative)) - if (Test-PathWithCase -FullPath $candidateFromRoot -RepoRoot $RepoRoot) { + if (Test-RepoIndexedPath -FullPath $candidateFromRoot -RepoRoot $RepoRoot) { return $candidateFromRoot } } @@ -178,7 +223,7 @@ function Resolve-LocalPath { return $null } -$mdPattern = [regex]'[A-Za-z0-9._/\-]+\.md(?:#[A-Za-z0-9_\-]+)?' +$mdPattern = [regex]'[A-Za-z0-9._/\-]+\.md(?:#[A-Za-z0-9_\-]+)?(?![A-Za-z0-9_-])' # LIMITATION: [^)] patterns cannot handle URLs with parentheses like file(1).md # This is valid markdown but rare. Files with parens in names should be renamed. $linkPattern = [regex]'\[[^\]]+\]\([^)]+\)' @@ -187,6 +232,7 @@ $anglePattern = [regex]'<[^>]+>' $filenameTextLinkPattern = [regex]'\[(?[^\]]+?\.md(?:#[^\]]+)?)\]\((?[^)]+?\.md(?:#[^)]+)?)\)' # Match actual markdown inline code spans (double-backtick then single-backtick) $codeSpanPattern = [regex]'(?:``(.+?)``|`([^`\n]+)`)' +$codeSpanMarkdownPathPattern = [regex]'(?i)(^|[^A-Za-z0-9_.-])(?[A-Za-z0-9._/\\-]+\.md(?:#[A-Za-z0-9_\-]+)?)(?![A-Za-z0-9_-])' $imagePattern = [regex]'!\[[^\]]*\]\((?[^)]+)\)' $imageReferencePattern = [regex]'!\[[^\]]*\]\[(?